diff --git a/.claude/skills/aeon/SKILL.md b/.claude/skills/aeon/SKILL.md index f79ec1ccdf0..5df87fdc6c2 100644 --- a/.claude/skills/aeon/SKILL.md +++ b/.claude/skills/aeon/SKILL.md @@ -298,7 +298,7 @@ Three at a time, not twelve. Every enabled skill is a recurring notification, an ./aeon packs ls # the six first-party packs ``` -`ls` footers with `85 skills · 1 enabled` — read it to them before proposing anything. First run installs the CLI runtime (tsx + yaml, ~12MB); the npm noise is one-time and expected. Grep-only equivalents: `references/layout.md`. +`ls` footers with `86 skills · 1 enabled` — read it to them before proposing anything. First run installs the CLI runtime (tsx + yaml, ~12MB); the npm noise is one-time and expected. Grep-only equivalents: `references/layout.md`. Packs are a visibility filter, not a runtime switch — revealing one runs nothing. Core (12), Evolution (9) and Basics (18) show by default; Dev (16), Crypto (19) and Productivity (11) are on demand. diff --git a/.claude/skills/aeon/references/layout.md b/.claude/skills/aeon/references/layout.md index a07f343278e..c207f2e82d8 100644 --- a/.claude/skills/aeon/references/layout.md +++ b/.claude/skills/aeon/references/layout.md @@ -12,7 +12,7 @@ Where everything lives in an Aeon repo, and the fastest way to see what's on. ./aeon skills ls --enabled --json # for building the Mode 2 timeline ``` -`ls` prints `SKILL / ON / SCHEDULE / PACK / DESCRIPTION` and a footer — `85 skills · 1 enabled`. First run installs the CLI runtime (tsx + yaml, ~12MB, one-time); the noise is expected. +`ls` prints `SKILL / ON / SCHEDULE / PACK / DESCRIPTION` and a footer — `86 skills · 1 enabled`. First run installs the CLI runtime (tsx + yaml, ~12MB, one-time); the noise is expected. **The `SCHEDULE` column is populated for disabled skills too** — it's their `aeon.yml` entry, not proof anything fires. Only the `●` in `ON` means it runs. @@ -59,7 +59,7 @@ AGENTS.md GENERATED from CLAUDE.md (STRATEGY.md inlined) for every non- harness. Never hand-edit; run `node scripts/gen-agents-md.js` (gated by ci-agents-md). -skills//SKILL.md the skills themselves - 85 upstream. One prompt per file. +skills//SKILL.md the skills themselves - 86 upstream. One prompt per file. soul/ SOUL.md + STYLE.md + examples/ — voice, read on every run (Mode 7). memory/ durable state between runs: logs/.md per-run append under `### `. The dedup substrate. diff --git a/.claude/skills/aeon/references/mcp.md b/.claude/skills/aeon/references/mcp.md index 5b5cece7eeb..cd577efce02 100644 --- a/.claude/skills/aeon/references/mcp.md +++ b/.claude/skills/aeon/references/mcp.md @@ -7,7 +7,7 @@ Two unrelated things share the name. Get this wrong and nothing works. | | | |---|---| | **`.mcp.json`** — *external MCP servers, called BY Aeon skills* | Wired via the dashboard MCP panel or `./aeon mcp add`. This is what you want when a skill needs a tool. | -| **`bin/add-mcp`** — *Aeon itself AS an MCP server* | Builds `apps/mcp-server` and registers it with Claude Code / Desktop, so all 85 skills appear as `aeon-*` tools **in your local Claude**. Nothing to do with a skill calling out. | +| **`bin/add-mcp`** — *Aeon itself AS an MCP server* | Builds `apps/mcp-server` and registers it with Claude Code / Desktop, so all 86 skills appear as `aeon-*` tools **in your local Claude**. Nothing to do with a skill calling out. | The rest of this doc is the first one. For the second: `bin/add-mcp`, `--desktop` for a Claude Desktop snippet, `--uninstall` to remove, `claude mcp list` to verify. diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 98815ca8be2..3e9ebe0b866 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -105,7 +105,7 @@ per run by a [claude-code-router](https://github.com/musistudio/claude-code-rout sidecar, like Venice/Surplus). 1. **`apps/dashboard/lib/gateway-registry.ts`** — add `slug: { label, secretName, prefixes, domain }` (empty `prefixes: []` = dropdown-only, no auto-detect). This is the **single source of truth**: it auto-flows to the `GatewayProvider` union (`lib/types.ts`), `CLAUDE_AUTH_SECRETS` (`lib/constants.ts`), the secrets route's gateway-key detection, the auth key-prefix detection (`lib/auth-provider.ts`), and the service-icon domain. -2. **`apps/dashboard/components/AuthModal.tsx`** — add the slug to `PROVIDER_OPTIONS` (this dropdown list is **not** registry-derived). +2. **Connect modal** - nothing to edit. Its provider dropdown and key-prefix detection (`apps/dashboard/lib/connect-detect.ts`) read `harness-adapter/gateways.json`, so the gateway shows up once step 6 regenerates it. 3. **`apps/dashboard/lib/secrets-catalog.ts`** — add a `BUILTIN_SECRETS` row (description only) so the secret shows in Settings (and in `aeon secrets ls`). 4. **`scripts/llm-gateway.sh`** — add an `aeon_present()` case, add the slug to the auto-resolver's default `GATEWAY_ORDER`, and add a `case` branch (a **native** provider exports `ANTHROPIC_BASE_URL` + the auth token; a **sidecar** provider calls `start_ccr_sidecar `). 5. **`.github/workflows/aeon.yml`** — pass the new secret (and any `*_MODEL` override **variables**) into the run's `env:` (also `messages.yml`), so the resolver can see it. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 1a1be66df9e..ba12c0df1bd 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -28,7 +28,7 @@ ### New LLM gateway -- [ ] Wired through the five files (`gateway-registry.ts`, `AuthModal.tsx`, `secrets-catalog.ts`, `scripts/llm-gateway.sh`, `.github/workflows/*.yml`) — see [Contributing an LLM gateway](CONTRIBUTING.md#contributing-an-llm-gateway) +- [ ] Wired through the five files (`gateway-registry.ts`, `adapters/claude.sh` gw-meta, `secrets-catalog.ts`, `scripts/llm-gateway.sh`, `.github/workflows/*.yml`) - see [Contributing an LLM gateway](CONTRIBUTING.md#contributing-an-llm-gateway) - [ ] Added a row to the gateway table in [`docs/CONFIGURATION.md`](../docs/CONFIGURATION.md#llm-gateways) - [ ] Verified end to end — a run logs `gateway=auto resolved to ` diff --git a/.github/README.md b/.github/README.md index 3ae6f0b0ac6..6e13af22cf4 100644 --- a/.github/README.md +++ b/.github/README.md @@ -1,5 +1,5 @@

- AEON - the most autonomous agent framework. 85 skills across 9 harnesses (Claude Code, Grok, Codex, Pi, Vibe, Kimi, fx, Cursor, Hermes), running unattended on GitHub Actions: it ships features to your repos, privately discloses real vulnerabilities, deploys live apps, runs deep research, and writes new skills for itself. Keywords: autonomous AI agent, agent framework, GitHub Actions automation, self-improving agent, multi-agent orchestration, LLM skills, cron agent. + AEON - the most autonomous agent framework. 86 skills across 9 harnesses (Claude Code, Grok, Codex, Pi, Vibe, Kimi, fx, Cursor, Hermes), running unattended on GitHub Actions: it ships features to your repos, privately discloses real vulnerabilities, deploys live apps, runs deep research, and writes new skills for itself. Keywords: autonomous AI agent, agent framework, GitHub Actions automation, self-improving agent, multi-agent orchestration, LLM skills, cron agent.

@@ -102,10 +102,10 @@ metadata: The prompt *is* the skill. You schedule it, hand it a `var`, chain it into others, and a cheap model rates every run (Haiku on Claude). How packs work: [`docs/skill-packs.md`](../docs/skill-packs.md).

- Six skill packs, 85 skills total: Core (fleet coordination, self-config, liveness), Evolution (authors and heals its own skills), Basics (simple runnable skills), Dev & Code, Crypto & Markets, and Productivity. + Six skill packs, 86 skills total: Core (fleet coordination, self-config, liveness), Evolution (authors and heals its own skills), Basics (simple runnable skills), Dev & Code, Crypto & Markets, and Productivity.

-

Full catalog - all 85 skills by pack →

+

Full catalog - all 86 skills by pack →

Community skill packs →

diff --git a/aeon.yml b/aeon.yml index a918407a899..c6a88ff7400 100644 --- a/aeon.yml +++ b/aeon.yml @@ -71,6 +71,7 @@ skills: # --- Evening / meta (6 PM UTC) --- strategy-builder: { enabled: false, schedule: "workflow_dispatch", var: "" } # on-demand — draft STRATEGY.md (north-star / priorities / audience / constraints) from a brief + repo README + memory. var: bare goal text, or structured brief "repo=owner/repo | links=url1,url2 | goal=free text". Powers the dashboard Strategy → Build my strategy button. No API key needed. + connect-check: { enabled: false, schedule: "workflow_dispatch", var: "" } # on-demand - dashboard "Test connection": one tiny model call that must answer AEON_CONNECT_OK; proves the saved model key works on a runner. No notify, no memory. skill-health: { enabled: false, schedule: "0 18 * * *", var: "" } # self-healing health audit + analytics. var: empty=health (files/resolves issues) | =single | analytics[:hours]=fleet run/pass-rate metrics (folds in skill-analytics) aeon-doctor: { enabled: false, schedule: "0 15 * * 1", var: "" } # weekly Mon 15:00 UTC — static config-correctness linter (silent-failure class: unquoted schedules, dup keys, unconfigured-on-disk, requires-as-list, mode typo, mcp ${VAR}, multi-line entries). read-only; notifies only on problems. var: empty=whole config | =one skill diff --git a/apps/cli/README.md b/apps/cli/README.md index 9d143c422dd..657ec1a050d 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -26,7 +26,8 @@ the web dashboard, and `./aeon …` runs this CLI. Setting up a new instance? `./aeon init` does it end to end from a clone of `aeonfun/aeon`: creates your repo from the template, points this folder and `gh` at it, enables Actions, stores `GH_GLOBAL`, connects a model (from the credential -manifest in `harness-adapter/harnesses.json`) and links Telegram. Every step checks +manifest in `harness-adapter/harnesses.json`), tests it with one tiny +`connect-check` run on GitHub (skip with `--no-test`) and links Telegram. Every step checks first, so it is safe to re-run; `./aeon init --dry-run` shows what it would do. ```sh @@ -75,7 +76,7 @@ runs pick the change up), call `gh` (secrets/auth), or dispatch a workflow. | `aeon skills rm --yes` | delete the skill dir + config entry | | `aeon skills run [--var\|--model]` | dispatch a run (`gh workflow run aeon.yml`) | | `aeon secrets set --stdin` · `aeon secrets rm ` | manage secrets via `gh` | -| `aeon init [--name\|--private\|--dir\|--harness\|--yes]` | set up an instance end to end (interactive, idempotent) | +| `aeon init [--name\|--private\|--dir\|--harness\|--no-test\|--yes]` | set up an instance end to end (interactive, idempotent) | | `aeon auth --harness claude-code \| --key [--provider\|--base-url]` | set Claude auth (`--oauth` still works) | | `aeon auth --harness [--key ]` | another harness's login or key (`grok` included); `--github` copies the gh token to `GH_GLOBAL` (needs `repo` + `workflow`) | | `aeon sync [--status]` | commit + push local changes | diff --git a/apps/cli/src/commands/init.ts b/apps/cli/src/commands/init.ts index 9d816c4e593..0338c8bb91a 100644 --- a/apps/cli/src/commands/init.ts +++ b/apps/cli/src/commands/init.ts @@ -9,8 +9,10 @@ // 4. Actions enabled + Actions may open PRs (the default token is left as is) // 5. GH_GLOBAL from your gh token (only when it has repo + workflow) // 6. Model: pick a harness and connect one of its credentials (manifest-driven) -// 7. Telegram (optional): bot token + chat id via a /start deep link -// 8. Summary checklist, then optionally start the dashboard +// 7. Test connection: run the tiny connect-check skill on GitHub and read the +// verdict (same lib as the dashboard's "Test connection") +// 8. Telegram (optional): bot token + chat id via a /start deep link +// 9. Summary checklist, then optionally start the dashboard import { parseArgs } from 'node:util' import { spawnSync } from 'node:child_process' import { existsSync, readFileSync, readdirSync, rmSync, statSync } from 'node:fs' @@ -28,6 +30,9 @@ import { parseConfig } from '../../../dashboard/lib/config.ts' import { HARNESSES, type Harness } from '../../../dashboard/lib/types.ts' import { loadGateways, loadHarnesses, runnableSecrets, type Credential, type Gateway, type HarnessManifest } from '../manifest.ts' import { grokLogin, storeGrokKey } from '../grok.ts' +import { createMemoryStore } from '../../../dashboard/lib/connect-store.ts' +import { ConnectCheckMissing, dispatchConnectCheck, readConnectCheck } from '../../../dashboard/lib/connect-check-server.ts' +import { pollConnectCheck, type CheckResult } from '../../../dashboard/lib/connect-check.ts' import { c, fail, isDryRun, isUpstreamRepo } from '../output.ts' const USAGE = `aeon init - set up your own Aeon instance, end to end (safe to re-run) @@ -41,6 +46,7 @@ Options: --dir Clone your instance into and continue there, instead of turning this folder into it --harness Preselect the agent: ${HARNESSES.join(' | ')} + --no-test Skip the connection test (a tiny skill run on GitHub) --no-telegram Skip the Telegram step --no-dashboard Do not offer to start the dashboard at the end -y, --yes Accept every default (still asks for keys it cannot guess) @@ -59,6 +65,7 @@ interface Opts { private: boolean dir?: string harness?: string + test: boolean telegram: boolean dashboard: boolean yes: boolean @@ -343,7 +350,7 @@ async function cloneElsewhere(opts: Opts, target: string): Promise { report('clone', 'fixed', `cloned ${target} into ${dir}; continuing there`) printSummary() const args = ['init', ...(opts.yes ? ['--yes'] : []), ...(opts.harness ? ['--harness', opts.harness] : []), - ...(opts.telegram ? [] : ['--no-telegram']), ...(opts.dashboard ? [] : ['--no-dashboard'])] + ...(opts.test ? [] : ['--no-test']), ...(opts.telegram ? [] : ['--no-telegram']), ...(opts.dashboard ? [] : ['--no-dashboard'])] const env = { ...process.env, AEON_REPO_ROOT: dir } const r = spawnSync(launcher, args, { cwd: dir, stdio: 'inherit', env }) if (r.error) { @@ -777,6 +784,78 @@ async function stepModel(opts: Opts, secrets: Set | null) { if (h.id !== configured) await switchHarness(h.id) } +// --- Test connection -------------------------------------------------------- +// The harness aeon.yml runs, if one of its credentials is set on the instance. +function connectedHarness(secrets: Set | null): HarnessManifest | null { + const h = loadHarnesses().find((x) => x.id === currentHarness()) + return h && runnableSecrets(h, loadGateways()).some((s) => secrets?.has(s)) ? h : null +} + +const STATE_TEXT: Record = { + queued: 'waiting for a runner', running: 'running on GitHub', none: 'looking for the run', +} + +// Dispatch connect-check on the configured harness and wait for the verdict: +// the run must succeed AND the model must answer (token usage, or the exact +// reply on harnesses that report no usage). A saved key that GitHub rejects +// (a Claude subscription token, typically) fails here instead of on the first +// scheduled run. +async function stepTest(opts: Opts, secrets: Set | null) { + heading(7, 'Test connection') + const h = connectedHarness(secrets) + if (!h) { report('test', 'skip', 'no model connected, nothing to test'); return } + if (!opts.test) { report('test', 'skip', 'skipped (--no-test)', './aeon init, or Test connection on the dashboard HQ'); return } + note('Runs the tiny connect-check skill once on GitHub (about 1 to 3 minutes) to prove the model answers.') + if (!(await confirm(opts, `Test ${h.label} now?`, true))) { + report('test', 'skip', 'connection not tested', './aeon init, or Test connection on the dashboard HQ') + return + } + if (isDryRun()) { report('test', 'skip', `would run the connect-check skill on ${h.id} and wait for the result`); return } + + const store = createMemoryStore() + let dispatchId: string + try { + dispatchId = (await dispatchConnectCheck(store, h.id)).dispatchId + } catch (e) { + if (e instanceof ConnectCheckMissing) { + report('test', 'warn', 'this instance has no connect-check skill yet, so the model was not tested', + 'update your instance (git pull upstream main, then ./aeon sync), then re-run ./aeon init') + } else { + report('test', 'fail', `could not start the test run: ${e instanceof Error ? e.message.split('\n')[0] : String(e)}`, 'check that Actions is enabled, then re-run ./aeon init') + } + return + } + + let last = '' + const progress = (r: CheckResult) => { + const text = STATE_TEXT[r.state] + if (!text || text === last) return + last = text + if (interactive) process.stdout.write(`\r ${c.dim(`${text}...`)}\x1b[K`) + else console.log(c.dim(` ${text}...`)) + } + progress({ state: 'queued' }) + const pollMs = Number(process.env.AEON_CONNECT_CHECK_POLL_MS) || 5000 + const result = await pollConnectCheck({ + read: () => readConnectCheck(store, h.id, dispatchId), + onUpdate: progress, + sleep: (ms) => sleep(ms).then(() => undefined), + now: Date.now, + cancelled: () => false, + intervalMs: pollMs, + timeoutMs: Math.max(10 * 60_000, pollMs * 20), + }) + if (interactive) process.stdout.write('\r\x1b[K') + if (result.state === 'pass') { + const tokens = result.usage && result.usage.total > 0 ? ` (${result.usage.total} tokens)` : '' + report('test', 'ok', `${h.label} answered from GitHub${tokens}`) + return + } + const fix = [result.hint, result.fix ? `then: ${result.fix.cli}` : '', result.runUrl ? `run: ${result.runUrl}` : ''] + .filter(Boolean).join(' ') + report('test', 'fail', `${h.label} test failed: ${result.reason ?? 'no verdict'}`, fix || './aeon init') +} + // --- Telegram --------------------------------------------------------------- interface TgUpdate { message?: { text?: string; chat?: { id?: number } } } @@ -793,7 +872,7 @@ async function manualChatId(why: string): Promise { } async function stepTelegram(opts: Opts, secrets: Set | null) { - heading(7, 'Telegram (optional)') + heading(8, 'Telegram (optional)') if (!opts.telegram) { report('telegram', 'skip', 'skipped (--no-telegram)'); return } if (secrets?.has('TELEGRAM_BOT_TOKEN') && secrets.has('TELEGRAM_CHAT_ID')) { report('telegram', 'ok', 'TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID are set') @@ -890,18 +969,18 @@ function finish(completed: boolean): never { export async function initCommand(argv: string[]) { if (argv.includes('-h') || argv.includes('--help')) { console.log(USAGE); return } - let values: { name?: string; private?: boolean; dir?: string; harness?: string; 'no-telegram'?: boolean; 'no-dashboard'?: boolean; yes?: boolean } + let values: { name?: string; private?: boolean; dir?: string; harness?: string; 'no-test'?: boolean; 'no-telegram'?: boolean; 'no-dashboard'?: boolean; yes?: boolean } try { ;({ values } = parseArgs({ args: argv, options: { name: { type: 'string' }, private: { type: 'boolean' }, dir: { type: 'string' }, harness: { type: 'string' }, - 'no-telegram': { type: 'boolean' }, 'no-dashboard': { type: 'boolean' }, yes: { type: 'boolean', short: 'y' }, + 'no-test': { type: 'boolean' }, 'no-telegram': { type: 'boolean' }, 'no-dashboard': { type: 'boolean' }, yes: { type: 'boolean', short: 'y' }, } })) } catch (e) { fail(e instanceof Error ? e.message : 'bad arguments') } const name = values.name ?? 'aeon' if (!/^[A-Za-z0-9._-]+$/.test(name)) fail(`--name must be a plain repo name (got '${name}')`) const opts: Opts = { name, private: Boolean(values.private), dir: values.dir, harness: values.harness, - telegram: !values['no-telegram'], dashboard: !values['no-dashboard'], yes: Boolean(values.yes), + test: !values['no-test'], telegram: !values['no-telegram'], dashboard: !values['no-dashboard'], yes: Boolean(values.yes), } console.log(c.bold('Aeon setup') + c.dim(` - each step checks first and only fixes what is missing; safe to re-run${isDryRun() ? ' (dry run: nothing changes)' : ''}`)) @@ -920,6 +999,7 @@ export async function initCommand(argv: string[]) { if (!secrets) note('Could not list the repo secrets; the steps below will offer to set everything.') await stepGhGlobal(opts, secrets) await stepModel(opts, secrets) + await stepTest(opts, secretNames(slug) ?? secrets) await stepTelegram(opts, secretNames(slug) ?? secrets) const failed = rows.some((r) => r.status === 'fail') diff --git a/apps/cli/test/fake-gh b/apps/cli/test/fake-gh index adde3f8e6d8..f2d264bd6e2 100755 --- a/apps/cli/test/fake-gh +++ b/apps/cli/test/fake-gh @@ -66,7 +66,19 @@ case "${1:-} ${2:-}" in slug="$(repo_arg "$@")" cat > /dev/null echo "$3" >> "$ST/secrets-$(key "$slug")" ;; - "workflow run") exit 0 ;; + "workflow run") + # Remember a connect-check dispatch so `run list` can return its run. + for a in "$@"; do case "$a" in dispatch_id=*) printf '%s' "${a#dispatch_id=}" > "$ST/dispatch" ;; esac; done + exit 0 ;; + "run list") + # One completed run for the last dispatch; conclusion from FAKE_RUN_CONCLUSION. + if [ -s "$ST/dispatch" ]; then + printf '[{"databaseId":101,"displayTitle":"skill: connect-check [dispatch: %s]","status":"completed","conclusion":"%s","url":"https://github.com/fake/actions/runs/101"}]\n' \ + "$(cat "$ST/dispatch")" "${FAKE_RUN_CONCLUSION:-success}" + else + echo '[]' + fi ;; + "run view") cat "${FAKE_RUN_LOG:?FAKE_RUN_LOG not set}" ;; "api user") echo "tester" ;; "api -i") printf 'HTTP/2.0 200 OK\r\nX-Oauth-Scopes: gist, read:org, repo, workflow\r\n\r\n{"login":"tester"}\n' ;; @@ -85,7 +97,7 @@ case "${1:-} ${2:-}" in slug="$(printf '%s' "$path" | cut -d/ -f2-3)" exists "$slug" || { echo "HTTP 404" >&2; exit 1; } case "$path" in - */contents/aeon.yml) git --git-dir="$(bare "$slug")" cat-file -e HEAD:aeon.yml 2>/dev/null || { echo "HTTP 404" >&2; exit 1; } ;; + */contents/*) git --git-dir="$(bare "$slug")" cat-file -e "HEAD:${path#*/contents/}" 2>/dev/null || { echo "gh: Not Found (HTTP 404)" >&2; exit 1; } ;; */actions/permissions) cat "$ST/perms-$(key "$slug")" 2>/dev/null || echo '{"enabled":false}' ;; */actions/permissions/workflow) cat "$ST/wf-$(key "$slug")" 2>/dev/null || echo '{"default_workflow_permissions":"read","can_approve_pull_request_reviews":false}' ;; diff --git a/apps/cli/test/init-sandbox.sh b/apps/cli/test/init-sandbox.sh index e8aa7d7a9c1..67c2d82a366 100755 --- a/apps/cli/test/init-sandbox.sh +++ b/apps/cli/test/init-sandbox.sh @@ -10,7 +10,9 @@ # tracking the template, resuming an interrupted switch-over, refusing to create # anything without a terminal or --yes, refusing a dirty folder BEFORE creating # the repo, --dir waiting for content and handing over, and --harness on an -# already-connected harness only switching aeon.yml (no login). +# already-connected harness only switching aeon.yml (no login), and the +# connection test (pass with tokens, zero-usage fail with the remove-token +# command, --no-test, the instance-predates-connect-check case). # # Run: bash apps/cli/test/init-sandbox.sh (needs apps/cli deps: npm ci in apps/cli) set -uo pipefail @@ -164,5 +166,50 @@ init "$W" --yes; rc=$? [ "$(git -C "$W" rev-parse HEAD)" = "$tpl_head" ] && grep -q "local edit" "$W/aeon.yml" \ && pass "dirty folder left untouched" || bad "dirty folder changed" +# --- 10. test connection -------------------------------------------------------- +# Step 4 left tester/aeon connected (codex via OpenRouter) on an instance made +# before connect-check existed: init says to update instead of dispatching. +grep -q "no connect-check skill yet" "$T/out" 2>/dev/null || init "$T/w1" --yes +grep -q "this instance has no connect-check skill yet" "$T/out" && pass "old instance: says to update" || bad "missing-skill case not reported" + +# From here on the template ships the skill, so new instances have it. +SK="$T/tplw"; git clone -q https://github.com/aeonfun/aeon "$SK" +mkdir -p "$SK/skills/connect-check" && printf -- '---\nname: connect-check\n---\nAEON_CONNECT_OK\n' > "$SK/skills/connect-check/SKILL.md" +git -C "$SK" add -A && git -C "$SK" commit -q -m "add connect-check" && git -C "$SK" push -q origin main + +FIX="$HERE/../../dashboard/lib/fixtures/connect-check-run.log" +ZERO="$T/zero.log" +sed -E 's/Token usage - input: [0-9]+, output: [0-9]+, cache_read: [0-9]+, cache_creation: [0-9]+, total: [0-9]+/Token usage - input: 0, output: 0, cache_read: 0, cache_creation: 0, total: 0/' "$FIX" > "$ZERO" +export AEON_CONNECT_CHECK_POLL_MS=20 + +W="$T/w13"; clone_template "$W" +"$HERE/fake-gh" repo create tester/aeon13 --template aeonfun/aeon --public >/dev/null +echo CLAUDE_CODE_OAUTH_TOKEN > "$FAKE_GH_STATE/secrets-tester_aeon13" +rm -f "$FAKE_GH_STATE/dispatch" +FAKE_RUN_LOG="$FIX" init "$W" --yes --name aeon13; rc=$? +[ "$rc" = 0 ] && pass "connection test passes" || { bad "connection test run exited $rc"; cat "$T/out"; } +grep -q "workflow run aeon.yml -R tester/aeon13 -f skill=connect-check -f harness=claude -f dispatch_id=cc-claude-" "$FAKE_GH_STATE/calls" \ + && pass "connect-check dispatched on the instance for claude" || bad "dispatch call wrong: $(grep 'workflow run' "$FAKE_GH_STATE/calls")" +grep -q "Claude Code answered from GitHub (22584 tokens)" "$T/out" && pass "pass shows the token count" || bad "no pass line" +grep -Eq "test +Claude Code answered" "$T/out" && pass "test is in the summary" || bad "test missing from the summary" + +FAKE_RUN_LOG="$ZERO" init "$W" --yes --name aeon13; rc=$? +[ "$rc" != 0 ] && pass "zero usage fails the run" || bad "zero usage exited 0" +grep -q "test failed: The run finished with zero model usage" "$T/out" && pass "fail shows the reason" || { bad "no fail reason"; cat "$T/out"; } +grep -q "Remove CLAUDE_CODE_OAUTH_TOKEN" "$T/out" && grep -q "then: ./aeon secrets rm CLAUDE_CODE_OAUTH_TOKEN" "$T/out" \ + && pass "fail shows the hint and the remove command" || bad "hint/remove command missing" + +calls_before="$(grep -c 'workflow run' "$FAKE_GH_STATE/calls")" +FAKE_RUN_LOG="$FIX" init "$W" --yes --name aeon13 --no-test; rc=$? +[ "$rc" = 0 ] && grep -q "skipped (--no-test)" "$T/out" && [ "$(grep -c 'workflow run' "$FAKE_GH_STATE/calls")" = "$calls_before" ] \ + && pass "--no-test skips without dispatching" || bad "--no-test: rc=$rc" + +W="$T/w14"; clone_template "$W" +"$HERE/fake-gh" repo create tester/aeon14 --template aeonfun/aeon --public >/dev/null +echo CLAUDE_CODE_OAUTH_TOKEN > "$FAKE_GH_STATE/secrets-tester_aeon14" +calls_before="$(grep -c 'workflow run' "$FAKE_GH_STATE/calls")" +FAKE_RUN_LOG="$FIX" init "$W" --name aeon14; rc=$? +[ "$(grep -c 'workflow run' "$FAKE_GH_STATE/calls")" = "$calls_before" ] && pass "no terminal and no --yes: no test dispatched" || bad "test dispatched without confirmation" + [ "$fail" = 0 ] && echo "PASS" || echo "SOME TESTS FAILED" exit $fail diff --git a/apps/dashboard/README.md b/apps/dashboard/README.md index 4a95645cfa8..8792c86ec8e 100644 --- a/apps/dashboard/README.md +++ b/apps/dashboard/README.md @@ -51,7 +51,7 @@ Selecting a skill from the roster opens its detail panel: description, schedule, ### Harness selector -The **top bar** carries a harness dropdown that sets which agent CLI runs your skills — one of six: **Claude Code** (default), **Grok**, **Codex**, **Pi**, **Vibe**, or **Kimi**. It writes `harness:` in `aeon.yml` (global, with an optional per-skill override), and the skill detail panel's model picker swaps to the selected harness's model ids. The **Authenticate** modal wires the credentials each one needs: **Connect X account** for Grok, **Connect ChatGPT** for Codex, **Connect Kimi** for Moonshot, or a single shared `OPENROUTER_API_KEY` that unlocks Codex/Pi/Vibe/Kimi at once. See [Harnesses](../../docs/harnesses.md) for the full matrix. +The **top bar** carries a harness dropdown that sets which agent CLI runs your skills (Claude Code by default; see [Harnesses](../../docs/harnesses.md) for all of them). It writes `harness:` in `aeon.yml` (global, with an optional per-skill override), and the skill detail panel's model picker swaps to the selected harness's model ids. The **Auth** button opens the **Connect** modal for the selected harness: step 1 shows the exact command to run on your computer (for example `claude setup-token`, or `codex login` plus a one-liner that copies the saved login), step 2 is one paste box that says what it detected ("Claude subscription token -> CLAUDE_CODE_OAUTH_TOKEN") before saving. Other ways: one-click OpenRouter for any harness that takes `OPENROUTER_API_KEY`, and, when the dashboard runs on your machine, **Do it for me** (runs the login here) and **Found on this machine** (existing CLI logins and model keys in the environment, by name only). After saving, a tiny `connect-check` skill runs on GitHub; it only goes green when the run succeeds and the model reported token usage. HQ shows a **Setup** checklist (repo, Actions, verified model, notifications, first run) until everything is done, and Settings can link your Telegram chat with a `t.me` link instead of copying a chat id. ## Configuration diff --git a/apps/dashboard/app/api/connect-check/route.ts b/apps/dashboard/app/api/connect-check/route.ts new file mode 100644 index 00000000000..8431ad74cd1 --- /dev/null +++ b/apps/dashboard/app/api/connect-check/route.ts @@ -0,0 +1,37 @@ +import { NextResponse } from 'next/server' +import { errorResponse, requireGh } from '@/lib/http' +import { getConnectStore } from '@/lib/connect-store' +import { ConnectCheckMissing, dispatchConnectCheck, readConnectCheck } from '@/lib/connect-check-server' + +// Post-connect "Test connection". +// POST { harness } -> dispatch the connect-check skill, { dispatchId } +// GET ?harness=&id= -> that dispatch's state; without id, the newest +// connect-check run for the harness (onboarding card). +// Pass = run succeeded AND model usage > 0 (lib/connect-check.ts). +export async function POST(request: Request) { + try { + const notReady = requireGh() + if (notReady) return notReady + const body = (await request.json().catch(() => ({}))) as { harness?: unknown } + const harness = typeof body.harness === 'string' ? body.harness : '' + if (!harness) return NextResponse.json({ error: 'harness is required' }, { status: 400 }) + return NextResponse.json(await dispatchConnectCheck(getConnectStore(), harness)) + } catch (error: unknown) { + if (error instanceof ConnectCheckMissing) return NextResponse.json({ error: error.message, missingSkill: true }, { status: 409 }) + return errorResponse(error, 'Failed to start the connection test') + } +} + +export async function GET(request: Request) { + try { + const notReady = requireGh() + if (notReady) return notReady + const params = new URL(request.url).searchParams + const harness = params.get('harness') || '' + const id = params.get('id') || undefined + if (!harness) return NextResponse.json({ error: 'harness is required' }, { status: 400 }) + return NextResponse.json(await readConnectCheck(getConnectStore(), harness, id)) + } catch (error: unknown) { + return errorResponse(error, 'Failed to read the connection test') + } +} diff --git a/apps/dashboard/app/api/connect/detect/route.ts b/apps/dashboard/app/api/connect/detect/route.ts new file mode 100644 index 00000000000..0852a69b456 --- /dev/null +++ b/apps/dashboard/app/api/connect/detect/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from 'next/server' +import { detect } from '@/lib/connect-server' + +// POST /api/connect/detect { harness, value, provider? } - preview only, saves +// nothing. The browser detects keys itself; it calls this for login captures, +// which have to be gunzipped and listed to know which login they hold. +export async function POST(request: Request) { + const body = (await request.json().catch(() => ({}))) as { harness?: unknown; value?: unknown; provider?: unknown } + const harness = typeof body.harness === 'string' ? body.harness : '' + const value = typeof body.value === 'string' ? body.value : '' + const provider = typeof body.provider === 'string' ? body.provider : '' + return NextResponse.json({ detection: detect(value, harness, provider).detection }) +} diff --git a/apps/dashboard/app/api/connect/found/route.ts b/apps/dashboard/app/api/connect/found/route.ts new file mode 100644 index 00000000000..73f5dc5b004 --- /dev/null +++ b/apps/dashboard/app/api/connect/found/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from 'next/server' +import { errorResponse, requireGh } from '@/lib/http' +import { isLocal } from '@/lib/github' +import { ConnectInputError, connectFound, listFound } from '@/lib/connect-server' + +// "Found on this machine" (local mode only). GET lists existing CLI logins and +// model keys in the dashboard's environment that could connect ?harness=, by +// name only. POST { id, harness } captures one server-side and saves it, so the +// value never reaches the browser. `local` also tells the modal whether to show +// the other machine-bound extras ("Do it for me"). Loopback-only via proxy.ts, +// like every /api route. +export async function GET(request: Request) { + const harness = new URL(request.url).searchParams.get('harness') || 'claude' + const local = isLocal() + return NextResponse.json({ local, items: local ? listFound(harness) : [] }) +} + +export async function POST(request: Request) { + try { + const notReady = requireGh() + if (notReady) return notReady + const body = (await request.json().catch(() => ({}))) as { id?: unknown; harness?: unknown } + const id = typeof body.id === 'string' ? body.id : '' + const harness = typeof body.harness === 'string' ? body.harness : '' + if (!id || !harness) return NextResponse.json({ error: 'id and harness are required' }, { status: 400 }) + return NextResponse.json(await connectFound(id, harness)) + } catch (error: unknown) { + if (error instanceof ConnectInputError) return NextResponse.json({ error: error.message }, { status: 400 }) + return errorResponse(error, 'Failed to use the found credential') + } +} diff --git a/apps/dashboard/app/api/connect/route.ts b/apps/dashboard/app/api/connect/route.ts new file mode 100644 index 00000000000..c1b4b0a90e8 --- /dev/null +++ b/apps/dashboard/app/api/connect/route.ts @@ -0,0 +1,23 @@ +import { NextResponse } from 'next/server' +import { errorResponse, requireGh } from '@/lib/http' +import { ConnectInputError, saveConnection } from '@/lib/connect-server' + +// POST /api/connect { harness, value, provider? } - the Connect modal's single +// save path. Detects what was pasted (key, setup-token, or base64 login +// capture), stores it under the right secret, and for a login capture switches +// aeon.yml to that harness. See lib/connect-detect.ts for the rules. +export async function POST(request: Request) { + try { + const notReady = requireGh() + if (notReady) return notReady + const body = (await request.json().catch(() => ({}))) as { harness?: unknown; value?: unknown; provider?: unknown } + const harness = typeof body.harness === 'string' ? body.harness : '' + const value = typeof body.value === 'string' ? body.value : '' + const provider = typeof body.provider === 'string' ? body.provider : '' + if (!harness || !value.trim()) return NextResponse.json({ error: 'harness and value are required' }, { status: 400 }) + return NextResponse.json(await saveConnection({ harness, value, provider })) + } catch (error: unknown) { + if (error instanceof ConnectInputError) return NextResponse.json({ error: error.message }, { status: 400 }) + return errorResponse(error, 'Failed to save the credential') + } +} diff --git a/apps/dashboard/app/api/onboarding/route.ts b/apps/dashboard/app/api/onboarding/route.ts new file mode 100644 index 00000000000..b17ede758c0 --- /dev/null +++ b/apps/dashboard/app/api/onboarding/route.ts @@ -0,0 +1,23 @@ +import { NextResponse } from 'next/server' +import { execFileSync } from 'child_process' +import { REPO_ROOT, ghArgsRepo } from '@/lib/gh' +import { isLocal } from '@/lib/github' + +// GET /api/onboarding -> { actionsEnabled } for the HQ setup checklist. The +// rest of the checklist (repo, model key, notifications, first run) is derived +// on the client from data it already has. Local mode asks GitHub through gh; +// null means "could not tell" (no gh, no admin read, or hosted mode), which the +// card shows as unknown instead of failed. +export async function GET() { + let actionsEnabled: boolean | null = null + if (isLocal()) { + const repo = ghArgsRepo()[1] + if (repo) { + try { + const out = execFileSync('gh', ['api', `repos/${repo}/actions/permissions`, '-q', '.enabled'], { stdio: 'pipe', cwd: REPO_ROOT, timeout: 15_000 }).toString().trim() + actionsEnabled = out === 'true' ? true : out === 'false' ? false : null + } catch { /* leave unknown */ } + } + } + return NextResponse.json({ actionsEnabled }) +} diff --git a/apps/dashboard/app/api/openrouter-auth/callback/route.ts b/apps/dashboard/app/api/openrouter-auth/callback/route.ts new file mode 100644 index 00000000000..4e8853f1780 --- /dev/null +++ b/apps/dashboard/app/api/openrouter-auth/callback/route.ts @@ -0,0 +1,45 @@ +import { getConnectStore } from '@/lib/connect-store' +import { finishFlow } from '@/lib/openrouter-oauth' +import { setSecret } from '@/lib/secrets-catalog' + +// GET /api/openrouter-auth/callback?state=...&code=... - OpenRouter's redirect +// target. Exchanges the single-use code with the stored PKCE verifier, saves +// the key as OPENROUTER_API_KEY (setSecret also re-syncs the claude gateway), +// then tells the opener and closes. The message carries only the flow state and +// outcome, never the key, so it can go to any origin: the dashboard may be open +// on 127.0.0.1 while this page loads on localhost. + +const escapeHtml = (s: string) => + s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!) + +function page(state: string, ok: boolean, title: string, detail: string): Response { + const msg = JSON.stringify({ type: 'aeon-openrouter', state, status: ok ? 'done' : 'error' }).replace(/ +${escapeHtml(title)} + +
+

${escapeHtml(title)}

+

${escapeHtml(detail)}

+
+ +` + return new Response(html, { status: ok ? 200 : 400, headers: { 'Content-Type': 'text/html; charset=utf-8' } }) +} + +export async function GET(request: Request) { + const url = new URL(request.url) + const state = url.searchParams.get('state') || '' + const result = await finishFlow(getConnectStore(), { + state, + code: url.searchParams.get('code'), + error: url.searchParams.get('error'), + }, { + save: async (key) => { + await setSecret('OPENROUTER_API_KEY', key) + return 'OPENROUTER_API_KEY' + }, + }) + return result.status === 'done' + ? page(state, true, 'OpenRouter connected', 'Saved as OPENROUTER_API_KEY. You can close this tab.') + : page(state, false, 'OpenRouter connect failed', result.status === 'error' ? result.error : '') +} diff --git a/apps/dashboard/app/api/openrouter-auth/route.ts b/apps/dashboard/app/api/openrouter-auth/route.ts new file mode 100644 index 00000000000..1560fec908d --- /dev/null +++ b/apps/dashboard/app/api/openrouter-auth/route.ts @@ -0,0 +1,32 @@ +import { NextResponse } from 'next/server' +import { errorResponse, requireGh } from '@/lib/http' +import { getConnectStore } from '@/lib/connect-store' +import { ghArgsRepo } from '@/lib/gh' +import { flowStatus, startFlow } from '@/lib/openrouter-oauth' + +// One-click OpenRouter (an option next to paste-a-key, never the default). +// POST { harness } -> { url, state }: the browser opens `url` in a popup. +// GET ?state= -> pending | done | error, polled by the modal in case +// the popup cannot postMessage back. +// The callback (./callback) exchanges the code and saves OPENROUTER_API_KEY. +// See lib/openrouter-oauth.ts. +export async function POST(request: Request) { + try { + const notReady = requireGh() + if (notReady) return notReady + const body = (await request.json().catch(() => ({}))) as { harness?: unknown } + const harness = typeof body.harness === 'string' && /^[a-z]+$/.test(body.harness) ? body.harness : 'claude' + const origin = request.headers.get('origin') || new URL(request.url).origin + const repo = ghArgsRepo()[1] + const label = `Aeon${repo ? ` (${repo})` : ''}` + return NextResponse.json(await startFlow(getConnectStore(), { origin, harness, label })) + } catch (error: unknown) { + return errorResponse(error, 'Failed to start OpenRouter connect') + } +} + +export async function GET(request: Request) { + const state = new URL(request.url).searchParams.get('state') || '' + const status = state ? await flowStatus(getConnectStore(), state) : null + return NextResponse.json(status ?? { status: 'error', error: 'Unknown or expired request' }) +} diff --git a/apps/dashboard/app/api/telegram/link/check/route.ts b/apps/dashboard/app/api/telegram/link/check/route.ts new file mode 100644 index 00000000000..94837faab67 --- /dev/null +++ b/apps/dashboard/app/api/telegram/link/check/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from 'next/server' +import { errorResponse, requireGh } from '@/lib/http' +import { getConnectStore } from '@/lib/connect-store' +import { setSecret } from '@/lib/secrets-catalog' +import { checkLink } from '@/lib/telegram-link' + +// POST /api/telegram/link/check { token, nonce } -> waiting | found | webhook | +// backlog | expired. Reads getUpdates WITHOUT an offset (so nothing is consumed for the +// messages.yml poller) looking for "/start "; on a hit it saves the chat +// as TELEGRAM_CHAT_ID and says hello in that chat. `webhook` (HTTP 409) means +// the bot is in webhook mode and `backlog` means 100+ unread updates hide the +// /start: the UI falls back to the manual helper for both. +export async function POST(request: Request) { + try { + const notReady = requireGh() + if (notReady) return notReady + const body = (await request.json().catch(() => ({}))) as { token?: unknown; nonce?: unknown } + const token = typeof body.token === 'string' ? body.token.trim() : '' + const nonce = typeof body.nonce === 'string' ? body.nonce : '' + if (!token || !nonce) return NextResponse.json({ error: 'token and nonce are required' }, { status: 400 }) + + const result = await checkLink(getConnectStore(), token, nonce) + if (result.status === 'found') { + await setSecret('TELEGRAM_CHAT_ID', result.chatId) + // Best-effort confirmation in the chat itself. + await fetch(`https://api.telegram.org/bot${token}/sendMessage`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ chat_id: result.chatId, text: 'Aeon is linked to this chat. Notifications will arrive here.' }), + }).catch(() => {}) + } + return NextResponse.json(result) + } catch (error: unknown) { + return errorResponse(error, 'Failed to check Telegram') + } +} diff --git a/apps/dashboard/app/api/telegram/link/route.ts b/apps/dashboard/app/api/telegram/link/route.ts new file mode 100644 index 00000000000..eb7d4818969 --- /dev/null +++ b/apps/dashboard/app/api/telegram/link/route.ts @@ -0,0 +1,20 @@ +import { NextResponse } from 'next/server' +import { errorResponse } from '@/lib/http' +import { getConnectStore } from '@/lib/connect-store' +import { startLink } from '@/lib/telegram-link' + +// POST /api/telegram/link { token } -> { username, nonce, link }. Step 1 of +// the no-copy-paste chat id: the operator taps link (t.me/?start=), +// then ./check polls for that /start and saves TELEGRAM_CHAT_ID. The token is +// passed in because GitHub secrets are write-only; it is used for these calls +// and never stored. See lib/telegram-link.ts. +export async function POST(request: Request) { + try { + const body = (await request.json().catch(() => ({}))) as { token?: unknown } + const token = typeof body.token === 'string' ? body.token.trim() : '' + if (!token) return NextResponse.json({ error: 'token is required' }, { status: 400 }) + return NextResponse.json(await startLink(getConnectStore(), token)) + } catch (error: unknown) { + return errorResponse(error, 'Failed to reach Telegram', 400) + } +} diff --git a/apps/dashboard/app/page.tsx b/apps/dashboard/app/page.tsx index a8917e0e9aa..89e3ca6b49f 100644 --- a/apps/dashboard/app/page.tsx +++ b/apps/dashboard/app/page.tsx @@ -27,10 +27,9 @@ import { McpPanel } from '../components/McpPanel' import { PacksPanel } from '../components/PacksPanel' import { RightPanel } from '../components/RightPanel' import { ImportModal } from '../components/ImportModal' -import { AuthModal } from '../components/AuthModal' -import { GrokAuthModal } from '../components/GrokAuthModal' -import { HarnessAuthModal } from '../components/HarnessAuthModal' -import { HARNESS_AUTH } from '../lib/harness-auth' +import { ConnectModal, type SavedCredential } from '../components/ConnectModal' +import { OnboardingChecklist } from '../components/OnboardingChecklist' +import { useConnectChecks } from '../lib/use-connect-checks' import { PanelError } from '../components/PanelError' export default function Dashboard() { @@ -83,11 +82,14 @@ export default function Dashboard() { const narrow = useNarrow() const [navOpen, setNavOpen] = useState(false) const [activityOpen, setActivityOpen] = useState(false) - const [authLoading, setAuthLoading] = useState(false) - const [grokLoading, setGrokLoading] = useState(false) - const [harnessAuthLoading, setHarnessAuthLoading] = useState(false) const [githubLoading, setGithubLoading] = useState(false) - const [showAuthModal, setShowAuthModal] = useState(false) + // The Connect modal: which harness it connects, and whether it opens on the + // live test (the checklist's "Test"). + const [connectFor, setConnectFor] = useState<{ harness: Harness; test?: boolean } | null>(null) + // Latest connect-check result per harness (HQ checklist "verified"). The + // page dispatches and polls, so closing the modal doesn't strand a run. + const { checks, startCheck, loadLatest } = useConnectChecks() + const [actionsEnabled, setActionsEnabled] = useState(null) const [strategy, setStrategy] = useState('') const [strategyLoaded, setStrategyLoaded] = useState(false) @@ -190,14 +192,15 @@ export default function Dashboard() { // stale afterward. Refetch core data + the feed, and drop cached panel state so // strategy/soul/mcp/analytics reload from the freshly-pulled files. const pullFromGithub = async () => { setPulling(true); try { const { ok, data } = await postJson('/api/outputs'); if (ok) { flash('Pulled - refreshing'); setAnalyticsData(null); setStrategyLoaded(false); setMcpLoaded(false); setSoulLoaded(false); setFeedKey(k => k + 1); await fetchData() } else { flash(data.error || 'Pull failed') } } finally { setPulling(false) } } - const setupAuth = async (auth?: string | { key: string, baseUrl?: string, provider?: string }) => { setAuthLoading(true); try { const body = typeof auth === 'string' ? { key: auth } : (auth || {}); const { ok, data } = await postJson('/api/auth', body); if (ok) { flash('Authenticated'); setShowAuthModal(false); fetchData() } else { const msg = typeof data?.error === 'string' ? data.error : (auth ? 'Auth failed' : 'Auto-setup failed'); if (!auth) setShowAuthModal(true); flash(msg) } } finally { setAuthLoading(false) } } - // Connect the grok harness: no arg captures the local X-account OAuth session - // (GROK_CREDENTIALS); a key stores XAI_API_KEY instead. - const setupGrokAuth = async (payload?: { key: string }) => { setGrokLoading(true); try { const { ok, data } = await postJson('/api/grok-auth', payload || {}); if (ok) { if (data?.harness === 'grok') { setHarness('grok'); flashSynced('X account connected - harness set to grok', data) } else { if (payload?.key) markSecretSet('XAI_API_KEY'); flash(payload?.key ? 'XAI_API_KEY saved' : 'X account connected') } setShowAuthModal(false); fetchData() } else { flash(typeof data?.error === 'string' ? data.error : 'Grok connect failed') } } finally { setGrokLoading(false) } } - // Native auth for codex/pi/vibe/kimi (the /api/harness-auth parallel to grok): - // no arg = OAuth capture (codex→ChatGPT, kimi→device), which also switches the - // repo to that harness; {key} = a provider key stored under its own secret. - const setupHarnessAuth = async (targetHarness: string, payload?: { key: string }) => { setHarnessAuthLoading(true); try { const { ok, data } = await postJson('/api/harness-auth', { harness: targetHarness, ...(payload || {}) }); if (ok) { if (data?.method === 'oauth' && data?.harness) { setHarness(data.harness); flashSynced(`${data.harness} connected - harness set`, data) } else { if (data?.secret) markSecretSet(data.secret); flash(`${data?.secret || 'Key'} saved`) } setShowAuthModal(false); fetchData() } else { flash(typeof data?.error === 'string' ? data.error : 'Connect failed') } } finally { setHarnessAuthLoading(false) } } + // A credential saved from the Connect modal (paste, OpenRouter, Do it for me, + // Found on this machine). A login capture also switched aeon.yml's harness. + // The modal moves on to the live test, so mark that harness as testing. + const onCredentialSaved = (c: SavedCredential) => { + if (c.secret) markSecretSet(c.secret, 'Core') + if (c.harness) { setHarness(c.harness); flashSynced(`${c.secret} saved - harness set to ${HARNESSES.find(x => x.id === c.harness)?.label || c.harness}`, c) } else flash(`${c.secret || 'Credential'} saved`) + startCheck(c.harness ?? connectFor?.harness ?? harness) + fetchData() + } const setupGithubAuth = async () => { setGithubLoading(true); try { const { ok, data } = await postJson('/api/github-auth', {}); if (ok) { markSecretSet('GH_GLOBAL'); flash('GH_GLOBAL saved from gh') } else { flash(typeof data?.error === 'string' ? data.error : 'GitHub connect failed') } } finally { setGithubLoading(false) } } const saveSecret = async (n: string, value: string) => { setBusy(b => ({ ...b, [`sec-${n}`]: true })); try { const { ok } = await postJson('/api/secrets', { name: n, value }); if (ok) { markSecretSet(n); flash(`${n} saved`) } } finally { setBusy(b => ({ ...b, [`sec-${n}`]: false })) } } const deleteSecret = async (n: string) => { setBusy(b => ({ ...b, [`sec-${n}`]: true })); try { const { ok } = await del('/api/secrets', { name: n }); if (ok) { setSecrets(s => s.map(x => x.name === n ? { ...x, isSet: false } : x)); flash(`${n} removed`) } } finally { setBusy(b => ({ ...b, [`sec-${n}`]: false })) } } @@ -222,6 +225,13 @@ export default function Dashboard() { const buildSoul = async (sources: SoulSources) => { setSoulBuilding(true); try { const { ok, data } = await postJson('/api/soul/build', { ...sources, model }); if (ok) { const label = sources.handle ? `@${sources.handle}` : sources.name || 'your links'; flash(`Soul-builder started for ${label}`); scheduleRunRefresh(refreshRuns) } else { flash(data.error || 'Build failed to dispatch') } } finally { setSoulBuilding(false) } } const installSoulExample = async (key: string) => { setSoulInstalling(key); try { const { ok, data } = await postJson('/api/soul/examples', { example: key }); if (ok) { setSoul(data.soul || ''); setSoulStyle(data.style || ''); setSoulLoaded(true); flashSynced(`Installed ${key} soul`, data) } else { flash(data.error || 'Install failed') } } finally { setSoulInstalling(null) } } + // Setup checklist inputs that need their own reads: whether Actions is on, + // and the newest connect-check verdict for the selected harness. + useEffect(() => { if (!loading) getJson<{ actionsEnabled: boolean | null }>('/api/onboarding').then(d => setActionsEnabled(d.actionsEnabled)).catch(() => {}) }, [loading]) + useEffect(() => { if (!loading) loadLatest(harness) }, [loading, harness, loadLatest]) + // The test panel's one-click fix (e.g. drop a rejected subscription token). + const removeSecretForFix = async (n: string) => { const { ok, data } = await del('/api/secrets', { name: n }); if (ok) { setSecrets(s => s.map(x => x.name === n ? { ...x, isSet: false } : x)); flash(`${n} removed`) } else flash(data?.error || `Could not remove ${n}`); return ok } + // Jump from a skill's API-keys panel straight to Settings → Access Keys, // scrolled to the chosen key with its input open and ready to paste. const goToSecret = (name: string) => { setSelectedSkill(null); setView('secrets'); setSecretFocus(name) } @@ -234,6 +244,13 @@ export default function Dashboard() { // xAI key), so a Claude token doesn't count when grok is selected — that's what // surfaces the Auth CTA → "Connect X account". Derived from live `secrets`. const hasModelKey = secrets.some(s => s.isSet && authSecretsForHarness(harness).includes(s.name)) + const isSet = (n: string) => secrets.some(s => s.isSet && s.name === n) + // Any one notify channel fully configured (see ./notify's opt-in secrets). + const notificationsSet = (isSet('TELEGRAM_BOT_TOKEN') && isSet('TELEGRAM_CHAT_ID')) + || isSet('DISCORD_WEBHOOK_URL') || (isSet('DISCORD_BOT_TOKEN') && isSet('DISCORD_CHANNEL_ID')) + || isSet('SLACK_WEBHOOK_URL') || (isSet('SLACK_BOT_TOKEN') && isSet('SLACK_CHANNEL_ID')) + || (isSet('RESEND_API_KEY') && isSet('NOTIFY_EMAIL_TO')) + const firstRunDone = runs.some(r => r.conclusion === 'success' && r.workflow.startsWith('skill: ') && !r.workflow.startsWith('skill: connect-check')) // Skills visible across the dashboard = first-party skills whose pack is // enabled (Core always on), PLUS every community skill — anything in a pack // that isn't first-party was installed from another repo on purpose, so it's @@ -247,6 +264,17 @@ export default function Dashboard() { const enabledCount = visibleSkills.filter(s => s.enabled).length const workingCount = runs.filter(r => r.status === 'in_progress').length + // A model key exists but no connect-check has ever run for this harness + // (e.g. right after `./aeon init`): start one automatically, once per repo + // and harness in this browser, so HQ can show "verified" without a click. + const autoCheckState = checks[harness]?.state + useEffect(() => { + if (loading || !repo || !hasModelKey || autoCheckState !== 'none') return + const key = `aeon.connectCheck.autoStarted:${repo}:${harness}` + try { if (localStorage.getItem(key)) return; localStorage.setItem(key, new Date().toISOString()) } catch { return } + startCheck(harness) + }, [loading, repo, harness, hasModelKey, autoCheckState, startCheck]) + if (loading) return if (error) return @@ -284,15 +312,15 @@ export default function Dashboard() { /> setShowAuthModal(true)} onUpdateModel={updateModel} onUpdateHarness={updateHarness} + onSetupAuth={() => setConnectFor({ harness })} onUpdateModel={updateModel} onUpdateHarness={updateHarness} onPull={pullFromGithub} onSync={syncToGithub} />
{view === 'secrets' && !selectedSkill && ( - setSecretFocus(null)} onSave={saveSecret} onDelete={deleteSecret} onSelectSkill={(name) => { setSelectedSkill(name); setView('hq') }} onConnectClaude={() => setupAuth()} connecting={authLoading} onConnectGrok={() => setupGrokAuth()} grokConnecting={grokLoading} onConnectHarness={(h) => setupHarnessAuth(h)} harnessConnecting={harnessAuthLoading} onConnectGithub={() => setupGithubAuth()} githubConnecting={githubLoading} /> + setSecretFocus(null)} onSave={saveSecret} onDelete={deleteSecret} onSelectSkill={(name) => { setSelectedSkill(name); setView('hq') }} onConnect={(h) => setConnectFor({ harness: HARNESSES.find(x => x.id === h)?.id ?? 'claude' })} onMarkSet={(n) => markSecretSet(n, 'Telegram')} onConnectGithub={() => setupGithubAuth()} githubConnecting={githubLoading} /> )} {view === 'strategy' && !selectedSkill && ( strategyError @@ -310,7 +338,18 @@ export default function Dashboard() { : )} {view === 'hq' && !selectedSkill && ( - setCategoryFilter(categoryFilter === key ? null : key)} onOpenPacks={() => setView('packs')} /> + setCategoryFilter(categoryFilter === key ? null : key)} onOpenPacks={() => setView('packs')} + checklist={ + setConnectFor({ harness })} + onTest={() => { startCheck(harness); setConnectFor({ harness, test: true }) }} + onFix={() => setConnectFor({ harness, test: true })} + onNotifications={() => goToSecret(isSet('TELEGRAM_BOT_TOKEN') ? 'TELEGRAM_CHAT_ID' : 'TELEGRAM_BOT_TOKEN')} + onFirstRun={() => { const first = visibleSkills.find(s => s.enabled && s.name !== 'connect-check'); if (first) { setSelectedSkill(first.name); setView('hq') } else setView('packs') }} + /> + } /> )} {view === 'packs' && !selectedSkill && ( packsError @@ -336,13 +375,19 @@ export default function Dashboard() { /> {showImport && setShowImport(false)} onImport={importSkill} />} - {showAuthModal && (harness === 'grok' - ? setShowAuthModal(false)} onGrokAuth={(p) => setupGrokAuth(p)} - patSet={secrets.some(s => s.isSet && (s.name === 'GH_SECRETS_PAT' || s.name === 'GH_GLOBAL'))} - onGoToSecret={(n) => { setShowAuthModal(false); goToSecret(n) }} /> - : HARNESS_AUTH[harness] - ? setShowAuthModal(false)} onHarnessAuth={(p) => setupHarnessAuth(harness, p)} /> - : setShowAuthModal(false)} onAuth={(auth) => setupAuth(auth)} />)} + {connectFor && ( + setConnectFor(null)} + onSaved={onCredentialSaved} + checks={checks} + onTestAgain={(h) => startCheck(h)} + onRemoveSecret={removeSecretForFix} + onGoToSecret={(n) => { setConnectFor(null); goToSecret(n) }} + /> + )}
) } diff --git a/apps/dashboard/components/AuthModal.tsx b/apps/dashboard/components/AuthModal.tsx deleted file mode 100644 index a9fb3ee0512..00000000000 --- a/apps/dashboard/components/AuthModal.tsx +++ /dev/null @@ -1,58 +0,0 @@ -'use client' - -import { useState } from 'react' -import { inputCls } from '../lib/utils' - -// Anthropic (Claude Code) auth: a Claude subscription token (one-click), or a -// gateway/Anthropic-compatible key. Anthropic submits no provider, so the backend -// still prefix-detects (Anthropic-compatible keys, OAuth tokens); every gateway is -// selected explicitly. The grok gateway (Claude Code → xAI) is intentionally NOT a -// menu option — the grok CLI has its own harness + modal (GrokAuthModal → "Connect -// X account"); an `xai-`-prefixed key pasted under "Anthropic (or compatible)" is -// still prefix-detected and routed to the xAI gateway. -const PROVIDER_OPTIONS = [ - { value: '', label: 'Anthropic (or compatible)' }, - { value: 'bankr', label: 'Bankr' }, - { value: 'openrouter', label: 'OpenRouter' }, - { value: 'usepod', label: 'UsePod' }, - { value: 'venice', label: 'Venice' }, - { value: 'surplus', label: 'Surplus Intelligence' }, - { value: 'glm', label: 'GLM (Z.AI)' }, -] - -interface AuthModalProps { - loading: boolean - onClose: () => void - onAuth: (payload?: { key: string, baseUrl?: string, provider?: string }) => void -} - -export function AuthModal({ loading, onClose, onAuth }: AuthModalProps) { - const [authKey, setAuthKey] = useState('') - const [provider, setProvider] = useState('') - const submit = () => authKey.trim() && onAuth({ key: authKey.trim(), ...(provider ? { provider } : {}) }) - - return ( -
-
-
-

Claude

- -
-

Connect a Claude subscription token, or pick your key's provider and paste it below. Routing is automatic - at run time Aeon uses whichever provider keys are set, in priority order.

- -
- - setAuthKey(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && submit()} placeholder="API key" className={`${inputCls} mb-[var(--space-md)]`} /> - -
-
- ) -} diff --git a/apps/dashboard/components/ConnectModal.tsx b/apps/dashboard/components/ConnectModal.tsx new file mode 100644 index 00000000000..5f5edc25572 --- /dev/null +++ b/apps/dashboard/components/ConnectModal.tsx @@ -0,0 +1,385 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' +import { inputCls } from '../lib/utils' +import { postJson } from '../lib/api-client' +import type { Harness } from '../lib/types' +import { detectPaste, providersForHarness, acceptsOpenRouter, harnessName, type Detection } from '../lib/connect-detect' +import { guideFor, captureCommand, canDriveLogin, type Os } from '../lib/connect-commands' +import type { CheckResult } from '../lib/connect-check' + +// One modal to connect any harness to a model, command first: +// Step 1 run this on your computer (copy button), or get a key +// Step 2 paste the result into ONE box; we show what it is before saving +// then a live "Test connection" run proves the credential works on GitHub. +// Options under that: one-click OpenRouter (any harness that takes +// OPENROUTER_API_KEY), and, only when the dashboard runs locally, "Do it for +// me" (drives the CLI login here) and "Found on this machine". + +export interface SavedCredential { secret: string; label?: string; harness?: Harness; synced?: boolean } + +interface ConnectModalProps { + harness: Harness + // GH_SECRETS_PAT / GH_GLOBAL set: grok's X-account session can persist rotations. + patSet: boolean + // Open straight on the test panel (the checklist's "Test" action). + startWithTest?: boolean + onClose: () => void + // Saved: the page records it and starts the live test for c.harness ?? harness. + onSaved: (c: SavedCredential) => void + // Latest connect-check result per harness, owned by the page. + checks: Record + onTestAgain: (harness: Harness) => void + onRemoveSecret: (name: string) => Promise + onGoToSecret: (name: string) => void +} + +const panelCls = 'border border-[rgba(250,250,250,0.10)] bg-aeon-bg/40 px-[var(--space-md)] py-[var(--space-sm)]' +const stepCls = 'text-[10px] font-mono uppercase tracking-[0.18em] text-primary-40 mb-2' +const primaryBtn = 'w-full bg-aeon-fg text-aeon-bg text-sm py-3 font-mono uppercase tracking-[2px] hover:opacity-90 transition-opacity disabled:opacity-50' +const secondaryBtn = 'w-full bg-aeon-panel text-aeon-fg border border-[rgba(250,250,250,0.14)] text-xs py-2.5 font-mono uppercase tracking-[2px] hover:border-aeon-red transition-colors disabled:opacity-50' + +function CopyIcon({ done }: { done: boolean }) { + return done + ? + : +} + +function CommandBox({ command }: { command: string }) { + const [copied, setCopied] = useState(false) + const copy = async () => { + try { await navigator.clipboard.writeText(command); setCopied(true); setTimeout(() => setCopied(false), 1500) } catch { /* select-all fallback below */ } + } + return ( +
+ {command} + +
+ ) +} + +function DetectionLine({ d }: { d: Detection }) { + if (d.state === 'empty') return null + const tone = d.state === 'error' ? 'text-aeon-red-alert' : d.warn || d.needsProvider ? 'text-aeon-red' : d.state === 'pending' ? 'text-primary-50' : 'text-aeon-green' + return ( +
+
+ {d.state === 'error' ? 'Not savable: ' : d.state === 'pending' ? 'Detected: ' : 'Detected: '} + {d.label} + {d.secret && <> {'->'} {d.secret}} +
+ {d.note &&
{d.note}
} +
+ ) +} + +// The live check's result. The page dispatches and polls (lib/use-connect-checks.ts) +// so the run keeps being followed after this modal closes. +function TestPanel({ result, onTestAgain, onRemoveSecret, onRetryConnect, onClose }: { + result: CheckResult | undefined + onTestAgain: () => void + onRemoveSecret: (name: string) => Promise + onRetryConnect: () => void + onClose: () => void +}) { + const [fixing, setFixing] = useState(false) + const [fixed, setFixed] = useState('') + const state = result?.state ?? 'queued' + const done = state === 'pass' || state === 'fail' || state === 'none' + const applyFix = async () => { + if (!result?.fix) return + setFixing(true) + try { if (await onRemoveSecret(result.fix.secret)) setFixed(result.fix.secret) } finally { setFixing(false) } + } + return ( +
+

Test connection

+
+ +
+
+ {state === 'pass' ? 'Connected. The model answered from a GitHub runner.' + : state === 'fail' ? (result?.reason || 'The test failed.') + : state === 'none' ? 'Not tested yet.' + : state === 'running' ? 'Running a tiny test skill on GitHub...' + : 'Starting a test run on GitHub...'} +
+ {state === 'pass' && result?.usage && result.usage.total > 0 &&
{result.usage.total} tokens used.
} + {state === 'fail' && result?.hint && !fixed &&
Next step: {result.hint}
} + {state === 'fail' && result?.fix && !fixed && ( + + )} + {fixed &&
Removed {fixed}. Test again to confirm the next key works.
} + {!done &&
Usually 1 to 3 minutes. You can close this; HQ keeps following the run.
} + {result?.runUrl && Open the run on GitHub} +
+
+
+ {state === 'pass' + ? + : <> + + + } +
+
+ ) +} + +export function ConnectModal({ harness, patSet, startWithTest, onClose, onSaved, checks, onTestAgain, onRemoveSecret, onGoToSecret }: ConnectModalProps) { + const guide = guideFor(harness) + const [view, setView] = useState<'connect' | 'test'>(startWithTest ? 'test' : 'connect') + // A pasted login capture can belong to another harness (and switches to it), + // so the test runs on whatever was actually connected. + const [testHarness, setTestHarness] = useState(harness) + const [os, setOs] = useState(() => typeof navigator !== 'undefined' && !/Mac|iPhone|iPad/.test(navigator.userAgent) ? 'linux' : 'mac') + const [value, setValue] = useState('') + const [provider, setProvider] = useState('') + const [showProvider, setShowProvider] = useState(false) + // Server answer for a pasted capture, keyed by the exact paste it describes. + const [serverDetection, setServerDetection] = useState<{ key: string; d: Detection } | null>(null) + const [busy, setBusy] = useState('') + const [error, setError] = useState('') + const [local, setLocal] = useState(false) + const [found, setFound] = useState<{ id: string; label: string; secret: string }[]>([]) + const [orLink, setOrLink] = useState('') + // The OpenRouter wait (message listener + status poll) outlives the click, + // so it is torn down on unmount and never sets state after close. + const mounted = useRef(true) + const stopOpenRouter = useRef<(() => void) | null>(null) + useEffect(() => { + mounted.current = true + return () => { mounted.current = false; stopOpenRouter.current?.() } + }, []) + + useEffect(() => { + let live = true + fetch(`/api/connect/found?harness=${harness}`).then((r) => r.json()).then((d: { local?: boolean; items?: { id: string; label: string; secret: string }[] }) => { + if (!live) return + setLocal(Boolean(d.local)); setFound(d.items ?? []) + }).catch(() => {}) + return () => { live = false } + }, [harness]) + + const localDetection = detectPaste(value, harness, provider) + // Captures are opened server-side; debounce the round-trip while typing. + const detectKey = `${harness}|${provider}|${value}` + useEffect(() => { + if (localDetection.state !== 'pending') return + const t = setTimeout(async () => { + const { data } = await postJson<{ detection?: Detection }>('/api/connect/detect', { harness, value, provider }) + if (data.detection) setServerDetection({ key: detectKey, d: data.detection }) + }, 300) + return () => clearTimeout(t) + }, [value, harness, provider, detectKey, localDetection.state]) + const detection = localDetection.state === 'pending' && serverDetection?.key === detectKey ? serverDetection.d : localDetection + const providerOptions = providersForHarness(harness) + + const saved = (c: SavedCredential) => { + onSaved(c) + setTestHarness(c.harness ?? harness) + setValue(''); setProvider(''); setError('') + setView('test') + } + + const save = async () => { + if (detection.state !== 'ok') return + setBusy('save'); setError('') + try { + const { ok, data } = await postJson('/api/connect', { harness, value, provider }) + if (ok) saved(data) + else setError(data.error || 'Save failed') + } finally { setBusy('') } + } + + // "Do it for me": the existing machine-bound CLI flows. + const driveLogin = async () => { + setBusy('drive'); setError('') + try { + const [url, body] = harness === 'claude' ? ['/api/auth', {}] : harness === 'grok' ? ['/api/grok-auth', {}] : ['/api/harness-auth', { harness }] + const { ok, data } = await postJson(url, body) + if (ok) saved({ secret: data.secret || '', harness: data.harness, synced: data.synced }) + else setError(data.error || 'Login failed') + } finally { setBusy('') } + } + + const pickFound = async (id: string) => { + setBusy(id); setError('') + try { + const { ok, data } = await postJson('/api/connect/found', { id, harness }) + if (ok) saved(data) + else setError(data.error || 'Could not use it') + } finally { setBusy('') } + } + + // OpenRouter OAuth in a popup. Open the window synchronously (popup blockers + // only allow it inside the click), then point it at the authorize URL. + const connectOpenRouter = async () => { + setBusy('openrouter'); setError(''); setOrLink('') + stopOpenRouter.current?.() + const popup = window.open('', 'aeon-openrouter', 'width=520,height=760') + const { ok, data } = await postJson<{ url?: string; state?: string; error?: string }>('/api/openrouter-auth', { harness }) + if (!mounted.current) { popup?.close(); return } + if (!ok || !data.url || !data.state) { popup?.close(); setBusy(''); setError(data.error || 'Could not start OpenRouter connect'); return } + if (popup) popup.location.href = data.url + else setOrLink(data.url) + const state = data.state + let finished = false + const stop = () => { + finished = true + window.removeEventListener('message', onMessage) + clearInterval(timer) + if (stopOpenRouter.current === stop) stopOpenRouter.current = null + } + const finish = (status: string, err?: string) => { + if (finished) return + stop() + if (!mounted.current) return + setBusy('') + if (status === 'done') saved({ secret: 'OPENROUTER_API_KEY', label: 'OpenRouter key' }) + else setError(err || 'OpenRouter connect failed') + } + const check = async () => { + if (finished) return + try { + const d = await (await fetch(`/api/openrouter-auth?state=${encodeURIComponent(state)}`)).json() as { status: string; error?: string } + if (d.status !== 'pending') finish(d.status, d.error) + } catch { /* keep waiting */ } + } + const onMessage = (e: MessageEvent) => { + const m = e.data as { type?: string; state?: string } | null + if (m?.type === 'aeon-openrouter' && m.state === state) check() + } + window.addEventListener('message', onMessage) + const started = Date.now() + const timer = setInterval(() => { + if (Date.now() - started > 10 * 60_000) finish('error', 'OpenRouter connect timed out. Start again.') + else check() + }, 2000) + stopOpenRouter.current = stop + } + + const step1 = captureCommand(harness, os) ?? guide.login + const isCapture = Boolean(captureCommand(harness, os)) && harness !== 'claude' + + return ( +
+
+
+

Connect {harnessName(harness)}

+ +
+ + {view === 'test' ? ( + onTestAgain(testHarness)} onRemoveSecret={onRemoveSecret} onRetryConnect={() => setView('connect')} onClose={onClose} /> + ) : ( + <> +

+ Give Aeon a model to run on. It is saved as an encrypted GitHub secret, then tested with a tiny run. +

+ + {/* Step 1 */} +

Step 1 {step1 ? '- run this on your computer' : '- get a key'}

+ {step1 ? ( + <> + {isCapture && ( +
+ {(['mac', 'linux'] as const).map((o) => ( + + ))} +
+ )} + +

+ {harness === 'claude' + ? 'Signs in with your Claude plan and prints a token that starts with sk-ant-oat.' + : isCapture && os === 'mac' ? 'Logs in, then copies the saved login to your clipboard.' + : isCapture ? 'Logs in, then prints the saved login. Copy the whole line it prints.' : ''} + {guide.cli && <> Inside your aeon folder? {guide.cli} does it all.} +

+ + ) : null} + {guide.keys.length > 0 && ( +

+ {step1 ? 'Or use an API key: ' : 'Get one: '} + {guide.keys.map((k, i) => ( + {i > 0 && ' / '}{k.label} + ))} +

+ )} + + {/* Step 2 */} +

Step 2 - paste the result

+ { setValue(e.target.value); setError('') }} + onKeyDown={(e) => e.key === 'Enter' && save()} + placeholder={guide.pasteHint} + aria-label="Paste your token, key, or login" + className={inputCls} + /> + + {value.trim() && providerOptions.length > 1 && (detection.needsProvider || showProvider || provider) ? ( + + ) : value.trim() && providerOptions.length > 1 && detection.state !== 'pending' && !detection.captureHarness ? ( + + ) : null} + {harness === 'claude' && ( +

GitHub servers sometimes reject Claude subscription tokens. The test after saving will tell you; an API key or OpenRouter always works.

+ )} + {harness === 'grok' && !patSet && ( +

+ An X-account login rotates on every run and needs a secrets PAT to keep working past ~6h:{' '} + . An xAI key needs nothing extra. +

+ )} + + {error &&

{error}

} + + {/* Options */} + {(acceptsOpenRouter(harness) || (local && (canDriveLogin(harness) || found.length > 0))) && ( +
+

Other ways

+ {acceptsOpenRouter(harness) && ( + <> + + {orLink && Popup blocked - open OpenRouter} + + )} + {local && canDriveLogin(harness) && ( + + )} + {local && found.length > 0 && ( +
+

Found on this machine

+ {found.map((f) => ( +
+
+
{f.label}
+
would fill {f.secret}
+
+ +
+ ))} +
+ )} +
+ )} + + )} +
+
+ ) +} diff --git a/apps/dashboard/components/GrokAuthModal.tsx b/apps/dashboard/components/GrokAuthModal.tsx deleted file mode 100644 index babf63e602b..00000000000 --- a/apps/dashboard/components/GrokAuthModal.tsx +++ /dev/null @@ -1,60 +0,0 @@ -'use client' - -import { useState } from 'react' -import { inputCls } from '../lib/utils' - -// Grok Build harness auth. Two ways in: -// - "Connect X account": one-click OAuth (runs `grok login --device-auth`, opens -// the browser, captures ~/.grok/auth.json → GROK_CREDENTIALS). Parallels the -// Claude subscription one-click. -// - Paste an xAI API key (xai-…) → stored as XAI_API_KEY. -// Both post to /api/grok-auth. -interface GrokAuthModalProps { - loading: boolean - onClose: () => void - onGrokAuth: (payload?: { key: string }) => void - // Whether a secrets-write PAT (GH_SECRETS_PAT / GH_GLOBAL) is - // set - the OAuth session rotates its refresh token and only survives past 6h if - // the runner can persist each rotation back to GROK_CREDENTIALS. Hides the note once set. - patSet: boolean - onGoToSecret: (name: string) => void -} - -export function GrokAuthModal({ loading, onClose, onGrokAuth, patSet, onGoToSecret }: GrokAuthModalProps) { - const [key, setKey] = useState('') - const submitKey = () => key.trim() && onGrokAuth({ key: key.trim() }) - - return ( -
-
-
-

Grok

- -
-

Run skills with the grok CLI on your X account. Click below: a browser tab opens to approve on accounts.x.ai, and the session is stored for CI. Needs SuperGrok / X Premium+ and the grok CLI installed.

- - {/* Secrets-PAT setup note (parallels McpPanel's). The X-account session - rotates its refresh token on every run; the runner can only save each - rotation back with a secrets-write PAT. Without one, a Connected account - works ~6h and then its auth breaks. Hidden once GH_SECRETS_PAT / GH_GLOBAL - is set. */} - {!patSet && ( -
-

⚠ The X-account session won't keep working without a secrets PAT

-

- Grok rotates its refresh token on every run, and the runner needs a secrets-write credential to save each rotation - without it a Connected account works once (~6h), then its auth breaks. To set it up: create a fine-grained PAT at github.com/settings/personal-access-tokens, add this repo under Repository access, grant Secrets: Read and write, and save it as{' '} - - {' '}in Settings. Already Connected? Re-connect once after adding the PAT. -

-
- )} -
-

Or use an xAI API key (no browser flow) - also powers the Grok gateway.

- setKey(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && submitKey()} placeholder="xai-..." className={`${inputCls} mb-[var(--space-md)]`} /> - -
-
- ) -} diff --git a/apps/dashboard/components/HQOverview.tsx b/apps/dashboard/components/HQOverview.tsx index 2fa1469172e..5525f9a239a 100644 --- a/apps/dashboard/components/HQOverview.tsx +++ b/apps/dashboard/components/HQOverview.tsx @@ -14,9 +14,11 @@ interface HQOverviewProps { categoryFilter: string | null onCategoryClick: (key: string) => void onOpenPacks: () => void + // Setup checklist card, rendered above everything until setup is complete. + checklist?: React.ReactNode } -export function HQOverview({ skills, runs, enabledCount, workingCount, categoryFilter, onCategoryClick, onOpenPacks }: HQOverviewProps) { +export function HQOverview({ skills, runs, enabledCount, workingCount, categoryFilter, onCategoryClick, onOpenPacks, checklist }: HQOverviewProps) { const onMove = (e: React.MouseEvent) => { const card = (e.target as HTMLElement).closest('li') if (!card) return @@ -38,6 +40,7 @@ export function HQOverview({ skills, runs, enabledCount, workingCount, categoryF return (
+ {checklist}
))} + {group === 'Telegram' && (sessionBotToken || secrets.some(s => s.name === 'TELEGRAM_BOT_TOKEN' && s.isSet)) && ( + s.name === 'TELEGRAM_CHAT_ID' && s.isSet)} onLinked={() => onMarkSet('TELEGRAM_CHAT_ID')} /> + )} {group === 'Telegram' && s.name === 'TELEGRAM_BOT_TOKEN' && s.isSet)} />} {group === 'Telegram' && } {group === 'Observability' && s.name === 'LANGFUSE_PUBLIC_KEY' && s.isSet) && secrets.some(s => s.name === 'LANGFUSE_SECRET_KEY' && s.isSet)} />} diff --git a/apps/dashboard/components/TelegramLinkCard.tsx b/apps/dashboard/components/TelegramLinkCard.tsx new file mode 100644 index 00000000000..5b275d1a033 --- /dev/null +++ b/apps/dashboard/components/TelegramLinkCard.tsx @@ -0,0 +1,104 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' +import { inputCls } from '../lib/utils' +import { postJson } from '../lib/api-client' + +// Sets TELEGRAM_CHAT_ID without copy-paste: show a t.me deep link with a +// one-time code, the operator taps it (Telegram sends "/start "), and the +// server spots that message in getUpdates and saves the chat id. Falls back to +// the manual TelegramChatIdHelper (on the TELEGRAM_CHAT_ID row) when the bot is in webhook mode (409). +// Server side: app/api/telegram/link (+ /check), lib/telegram-link.ts. + +interface TelegramLinkCardProps { + // Bot token saved earlier in this session (secrets are write-only on GitHub). + sessionBotToken: string + chatIdSet: boolean + onLinked: (chatId: string) => void +} + +const POLL_MS = 3000 +const MAX_POLLS = 300 // 15 minutes, the nonce's lifetime + +export function TelegramLinkCard({ sessionBotToken, chatIdSet, onLinked }: TelegramLinkCardProps) { + const [token, setToken] = useState('') + const [link, setLink] = useState<{ username: string; nonce: string; link: string } | null>(null) + const [status, setStatus] = useState<'idle' | 'starting' | 'waiting' | 'found' | 'webhook' | 'backlog' | 'error'>('idle') + const [msg, setMsg] = useState('') + const [copied, setCopied] = useState(false) + const onLinkedRef = useRef(onLinked) + useEffect(() => { onLinkedRef.current = onLinked }) + + const botToken = (token || sessionBotToken).trim() + + const start = async () => { + setStatus('starting'); setMsg('') + const { ok, data } = await postJson<{ username?: string; nonce?: string; link?: string; error?: string }>('/api/telegram/link', { token: botToken }) + if (!ok || !data.link || !data.nonce || !data.username) { setStatus('error'); setMsg(data.error || 'Telegram rejected the token.'); return } + setLink({ username: data.username, nonce: data.nonce, link: data.link }) + setStatus('waiting') + } + + // Poll for the /start message while waiting. + useEffect(() => { + if (status !== 'waiting' || !link) return + let stopped = false + let polls = 0 + let timer: ReturnType + const poll = async () => { + if (stopped) return + const { ok, data } = await postJson<{ status?: string; chatId?: string; error?: string }>('/api/telegram/link/check', { token: botToken, nonce: link.nonce }) + if (stopped) return + if (ok && data.status === 'found' && data.chatId) { setStatus('found'); setMsg(`Linked chat ${data.chatId}. Saved as TELEGRAM_CHAT_ID.`); onLinkedRef.current(data.chatId); return } + if (ok && data.status === 'webhook') { setStatus('webhook'); return } + if (ok && data.status === 'backlog') { setStatus('backlog'); return } + if (ok && data.status === 'expired') { setStatus('error'); setMsg('That link expired. Start again.'); return } + if (!ok) { setStatus('error'); setMsg(data.error || 'Could not check Telegram.'); return } + if (++polls >= MAX_POLLS) { setStatus('error'); setMsg('No /start seen yet. Start again.'); return } + timer = setTimeout(poll, POLL_MS) + } + timer = setTimeout(poll, POLL_MS) + return () => { stopped = true; clearTimeout(timer) } + }, [status, link, botToken]) + + const copy = async () => { if (!link) return; try { await navigator.clipboard.writeText(link.link); setCopied(true); setTimeout(() => setCopied(false), 1500) } catch {} } + + return ( +
+
Link your chat {chatIdSet && status !== 'found' ? '(already set - relink to change)' : ''}
+ {status === 'idle' || status === 'starting' || status === 'error' ? ( + <> +

+ Skip finding the chat ID by hand: get a link, tap it, press Start, done. + {!sessionBotToken && ' Paste the bot token once more (GitHub secrets cannot be read back); it is only used for this and not stored.'} +

+
+ {!sessionBotToken && ( + setToken(e.target.value)} placeholder="bot token (123456789:AA...)" className={inputCls} /> + )} + +
+ {status === 'error' &&

{msg}

} + + ) : status === 'waiting' && link ? ( +
+

Open this on the phone or computer where you use Telegram and press Start:

+
+ {link.link} + +
+

Waiting for /start from @{link.username}...

+
+ ) : status === 'found' ? ( +

{msg}

+ ) : status === 'backlog' ? ( +
+

This bot has 100+ old updates waiting, so Aeon cannot see your new /start. Paste the chat id yourself with Find my chat ID under TELEGRAM_CHAT_ID above, or clear the bot's old updates and try again.

+ +
+ ) : ( +

This bot uses a webhook (instant mode), so Aeon cannot read its messages here. Use Find my chat ID under TELEGRAM_CHAT_ID above instead.

+ )} +
+ ) +} diff --git a/apps/dashboard/components/TopBar.tsx b/apps/dashboard/components/TopBar.tsx index ac64891835d..1143d8d1960 100644 --- a/apps/dashboard/components/TopBar.tsx +++ b/apps/dashboard/components/TopBar.tsx @@ -10,7 +10,6 @@ interface TopBarProps { harness: Harness gateway: GatewayProvider hasModelKey: boolean - authLoading: boolean pulling: boolean syncing: boolean hasChanges: boolean @@ -34,7 +33,7 @@ export function viewTitle(skill: Skill | null, view: DashboardView, repo: string return `${repo ? repo.split('/').pop() : 'Aeon'} HQ` } -export function TopBar({ skill, view, repo, model, harness, gateway, hasModelKey, authLoading, pulling, syncing, hasChanges, behind, onSetupAuth, onUpdateModel, onUpdateHarness, onPull, onSync }: TopBarProps) { +export function TopBar({ skill, view, repo, model, harness, gateway, hasModelKey, pulling, syncing, hasChanges, behind, onSetupAuth, onUpdateModel, onUpdateHarness, onPull, onSync }: TopBarProps) { const dept = skill ? (PACK_BY_KEY[skill.pack || 'lab'] || null) : null const modelOptions = pickerOptions(modelsForHarness(harness), model) @@ -60,8 +59,8 @@ export function TopBar({ skill, view, repo, model, harness, gateway, hasModelKey {gateway} )} {!hasModelKey && ( - )}