diff --git a/.claude/skills/aeon/references/ci.md b/.claude/skills/aeon/references/ci.md index e712a665af7..76cb19e9324 100644 --- a/.claude/skills/aeon/references/ci.md +++ b/.claude/skills/aeon/references/ci.md @@ -72,6 +72,7 @@ cd apps/dashboard && npm ci && npm run typecheck && npm run lint && npm test && cd apps/cli && npm ci && npm run typecheck && npm run lint # needs apps/dashboard deps installed first cd apps/mcp-server && npm install && npm run build cd apps/webhook && node --check src/worker.js && npm install && npm run lint && npx wrangler deploy --dry-run --outdir /tmp/w +npm run format:check # from the repo root: biome formatter over all four apps (fix: npm run format) ``` The dashboard runs **both** `typecheck` and `build` on purpose: a past Dependabot bump crashed `next build` while `tsc --noEmit` passed. Don't treat the typecheck as sufficient. The CLI cannot typecheck without the dashboard's `node_modules` - its tsconfig compiles `../dashboard/lib/**/*.ts` and borrows that app's typescript and `@types`. diff --git a/.github/workflows/ci-apps.yml b/.github/workflows/ci-apps.yml index 45c1ba82edc..f1476e64d15 100644 --- a/.github/workflows/ci-apps.yml +++ b/.github/workflows/ci-apps.yml @@ -30,11 +30,13 @@ on: pull_request: paths: - 'apps/**' + - 'biome.json' - '.github/workflows/ci-apps.yml' push: branches: [main] paths: - 'apps/**' + - 'biome.json' workflow_dispatch: permissions: @@ -47,6 +49,21 @@ concurrency: cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} jobs: + format: + name: format - biome + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: '22' + # Formatter only (linting stays with each app's eslint). One root biome.json + # covers all four apps, with per-app quote/semicolon overrides that keep each + # app's existing style. Pinned exact so a Biome release can't reformat the + # tree under an unrelated PR. Fix locally with `npm run format`. + - name: Format check (biome ci) + run: npx --yes @biomejs/biome@2.5.15 ci --reporter=github . + dashboard: name: dashboard — typecheck, test, build runs-on: ubuntu-24.04 diff --git a/apps/cli/eslint.config.mjs b/apps/cli/eslint.config.mjs index 4dc49ecc36f..be3dc60edbe 100644 --- a/apps/cli/eslint.config.mjs +++ b/apps/cli/eslint.config.mjs @@ -4,7 +4,4 @@ // TypeScript sub-config is used - the CLI is not a React/Next app. import ts from '../dashboard/node_modules/eslint-config-next/dist/typescript.js' -export default [ - { ignores: ['node_modules/**'] }, - ...ts, -] +export default [{ ignores: ['node_modules/**'] }, ...ts] diff --git a/apps/cli/src/commands/auth.ts b/apps/cli/src/commands/auth.ts index 631e1cca67b..4a35d50e878 100644 --- a/apps/cli/src/commands/auth.ts +++ b/apps/cli/src/commands/auth.ts @@ -41,38 +41,86 @@ Options: const CLAUDE_HARNESS = new Set(['claude-code', 'claude']) export async function authCommand(argv: string[]) { - if (argv.includes('-h') || argv.includes('--help')) { console.log(USAGE); return } + if (argv.includes('-h') || argv.includes('--help')) { + console.log(USAGE) + return + } requireGh() if (!isDryRun()) requireInstanceRepo() - let values: { key?: string; provider?: string; 'base-url'?: string; oauth?: boolean; harness?: string; github?: boolean } + let values: { + key?: string + provider?: string + 'base-url'?: string + oauth?: boolean + harness?: string + github?: boolean + } let positionals: string[] try { - ;({ values, positionals } = parseArgs({ args: argv, options: { - key: { type: 'string' }, provider: { type: 'string' }, - 'base-url': { type: 'string' }, oauth: { type: 'boolean' }, - harness: { type: 'string' }, github: { type: 'boolean' }, - }, allowPositionals: true })) - } catch (e) { fail(e instanceof Error ? e.message : 'bad arguments') } + ;({ values, positionals } = parseArgs({ + args: argv, + options: { + key: { type: 'string' }, + provider: { type: 'string' }, + 'base-url': { type: 'string' }, + oauth: { type: 'boolean' }, + harness: { type: 'string' }, + github: { type: 'boolean' }, + }, + allowPositionals: true, + })) + } catch (e) { + fail(e instanceof Error ? e.message : 'bad arguments') + } if (values.github) { - if (isDryRun()) return emit({ dryRun: true, method: 'oauth', secret: 'GH_GLOBAL' }, () => - console.log(c.yellow('dry-run: ') + 'would copy `gh auth token` -> secret GH_GLOBAL')) + if (isDryRun()) + return emit({ dryRun: true, method: 'oauth', secret: 'GH_GLOBAL' }, () => + console.log(c.yellow('dry-run: ') + 'would copy `gh auth token` -> secret GH_GLOBAL'), + ) let result - try { result = captureGithubToken() } - catch (e) { fail(e instanceof Error ? e.message : 'failed to copy GitHub token') } + try { + result = captureGithubToken() + } catch (e) { + fail(e instanceof Error ? e.message : 'failed to copy GitHub token') + } return emit(result, () => console.log(c.green('✓ ') + `GitHub: copied gh token as ${result.secret}`)) } // --- grok: X-account OAuth capture or an xAI key (not in HARNESS_AUTH) --- if (values.harness === 'grok') { const key = (values.key ?? positionals[0] ?? '').trim() - if (isDryRun()) return emit({ dryRun: true, harness: 'grok', method: key ? 'api-key' : 'oauth', secret: key ? 'XAI_API_KEY' : 'GROK_CREDENTIALS' }, () => - console.log(c.yellow('dry-run: ') + (key ? 'grok key -> secret XAI_API_KEY' : 'would run `grok login --device-auth` -> secret GROK_CREDENTIALS'))) + if (isDryRun()) + return emit( + { + dryRun: true, + harness: 'grok', + method: key ? 'api-key' : 'oauth', + secret: key ? 'XAI_API_KEY' : 'GROK_CREDENTIALS', + }, + () => + console.log( + c.yellow('dry-run: ') + + (key + ? 'grok key -> secret XAI_API_KEY' + : 'would run `grok login --device-auth` -> secret GROK_CREDENTIALS'), + ), + ) let res: { secret: string } - try { res = key ? await storeGrokKey(key) : grokLogin() } catch (e) { fail(e instanceof Error ? e.message : 'grok auth failed') } + try { + res = key ? await storeGrokKey(key) : grokLogin() + } catch (e) { + fail(e instanceof Error ? e.message : 'grok auth failed') + } return emit({ ok: true, harness: 'grok', method: key ? 'api-key' : 'oauth', secret: res.secret }, () => - console.log(c.green('✓ ') + `grok: stored as ${res.secret}. Select the harness with \`aeon config set harness grok\`.` + - (key ? '' : '\n The X login rotates its refresh token; set GH_GLOBAL (aeon auth --github) so each run can save the new one.'))) + console.log( + c.green('✓ ') + + `grok: stored as ${res.secret}. Select the harness with \`aeon config set harness grok\`.` + + (key + ? '' + : '\n The X login rotates its refresh token; set GH_GLOBAL (aeon auth --github) so each run can save the new one.'), + ), + ) } // --- Non-Claude harnesses: native OAuth capture or a provider key --- @@ -80,33 +128,58 @@ export async function authCommand(argv: string[]) { if (values.harness && !CLAUDE_HARNESS.has(values.harness)) { const harness = values.harness const spec = HARNESS_AUTH[harness] - if (!spec) fail(`unknown harness '${harness}'. Native auth is available for: claude-code, ${Object.keys(HARNESS_AUTH).join(', ')}`) + if (!spec) + fail( + `unknown harness '${harness}'. Native auth is available for: claude-code, ${Object.keys(HARNESS_AUTH).join(', ')}`, + ) const key = (values.key ?? positionals[0] ?? '').trim() // A key was given (or the harness only supports keys) → store it. if (key || !spec.oauth) { - if (!spec.apiKey) fail(`${harness} has no API-key path — run \`aeon auth --harness ${harness}\` for its login flow`) + if (!spec.apiKey) + fail(`${harness} has no API-key path — run \`aeon auth --harness ${harness}\` for its login flow`) if (!key) fail(`${harness} takes a provider API key: aeon auth --harness ${harness} --key <…>`) const target = spec.apiKey.detect ? spec.apiKey.detect(key) : spec.apiKey.secret - if (isDryRun()) return emit({ dryRun: true, harness, method: 'api-key', secret: target }, () => - console.log(c.yellow('dry-run: ') + `${harness} key → secret ${target}`)) + if (isDryRun()) + return emit({ dryRun: true, harness, method: 'api-key', secret: target }, () => + console.log(c.yellow('dry-run: ') + `${harness} key → secret ${target}`), + ) let res: { secret: string } - try { res = setHarnessApiKey(harness, key) } catch (e) { fail(e instanceof Error ? e.message : 'failed to set key') } + try { + res = setHarnessApiKey(harness, key) + } catch (e) { + fail(e instanceof Error ? e.message : 'failed to set key') + } return emit({ ok: true, harness, method: 'api-key', secret: res.secret }, () => - console.log(c.green('✓ ') + `${harness}: key stored as ${res.secret}. Select the harness with \`aeon config set harness ${harness}\`.`)) + console.log( + c.green('✓ ') + + `${harness}: key stored as ${res.secret}. Select the harness with \`aeon config set harness ${harness}\`.`, + ), + ) } // No key → drive the native OAuth login, then capture the credential. - if (isDryRun()) return emit({ dryRun: true, harness, method: 'oauth', secret: spec.oauth.secret }, () => - console.log(c.yellow('dry-run: ') + `would run \`${spec.oauth!.cli} ${spec.oauth!.ttyArgs.join(' ')}\` → secret ${spec.oauth!.secret}`)) + if (isDryRun()) + return emit({ dryRun: true, harness, method: 'oauth', secret: spec.oauth.secret }, () => + console.log( + c.yellow('dry-run: ') + + `would run \`${spec.oauth!.cli} ${spec.oauth!.ttyArgs.join(' ')}\` → secret ${spec.oauth!.secret}`, + ), + ) console.log(c.dim(`Opening ${spec.oauth.cli} login — approve in your browser…`)) let res: { secret: string } try { driveTtyLogin(harness) res = captureHarnessCreds(harness) - } catch (e) { fail(e instanceof Error ? e.message : `${harness} login failed`) } + } catch (e) { + fail(e instanceof Error ? e.message : `${harness} login failed`) + } return emit({ ok: true, harness, method: 'oauth', secret: res.secret }, () => - console.log(c.green('✓ ') + `${harness}: login captured as ${res.secret}. Select the harness with \`aeon config set harness ${harness}\`.`)) + console.log( + c.green('✓ ') + + `${harness}: login captured as ${res.secret}. Select the harness with \`aeon config set harness ${harness}\`.`, + ), + ) } // --- Claude harness (default), unchanged --- @@ -117,11 +190,19 @@ export async function authCommand(argv: string[]) { // normalizeAuthConfig is pure — it tells us the resolved method/secret without // touching gh or claude. let plan - try { plan = normalizeAuthConfig(body) } catch (e) { fail(e instanceof Error ? e.message : 'invalid auth config') } + try { + plan = normalizeAuthConfig(body) + } catch (e) { + fail(e instanceof Error ? e.message : 'invalid auth config') + } return emit({ dryRun: true, ...plan, key: undefined }, () => - console.log(c.yellow('dry-run: ') + `method=${plan.method} → secret ${plan.secretName}` + - (plan.baseUrl ? ` + ANTHROPIC_BASE_URL=${plan.baseUrl}` : '') + - (plan.method === 'oauth' && !key ? ' (would run `claude setup-token`)' : ''))) + console.log( + c.yellow('dry-run: ') + + `method=${plan.method} → secret ${plan.secretName}` + + (plan.baseUrl ? ` + ANTHROPIC_BASE_URL=${plan.baseUrl}` : '') + + (plan.method === 'oauth' && !key ? ' (would run `claude setup-token`)' : ''), + ), + ) } let result @@ -130,6 +211,12 @@ export async function authCommand(argv: string[]) { } catch (e) { fail(e instanceof Error ? e.message : 'failed to configure auth') } - emit(result, () => console.log(c.green('✓ ') + `authenticated (method: ${result.method}` + - (result.secret ? `, secret: ${result.secret}` : '') + ')')) + emit(result, () => + console.log( + c.green('✓ ') + + `authenticated (method: ${result.method}` + + (result.secret ? `, secret: ${result.secret}` : '') + + ')', + ), + ) } diff --git a/apps/cli/src/commands/config.ts b/apps/cli/src/commands/config.ts index 437be2206c0..230afeea695 100644 --- a/apps/cli/src/commands/config.ts +++ b/apps/cli/src/commands/config.ts @@ -1,6 +1,9 @@ import { getFileContent } from '../../../dashboard/lib/github.ts' import { - parseConfig, updateModelInConfig, updateHarnessInConfig, updateGatewayInConfig, + parseConfig, + updateModelInConfig, + updateHarnessInConfig, + updateGatewayInConfig, } from '../../../dashboard/lib/config.ts' import { getRepoSlug } from '../../../dashboard/lib/skills.ts' import { HARNESSES, GATEWAY_PROVIDERS } from '../../../dashboard/lib/types.ts' @@ -21,7 +24,10 @@ Options: export async function configCommand(argv: string[]) { const sub = argv[0] && !argv[0].startsWith('-') ? argv[0] : 'show' - if (sub === 'help' || argv.includes('-h') || argv.includes('--help')) { console.log(USAGE); return } + if (sub === 'help' || argv.includes('-h') || argv.includes('--help')) { + console.log(USAGE) + return + } if (sub === 'show') return show() if (sub === 'set') return set(argv.slice(1)) fail(`unknown subcommand: ${sub}\n\n${USAGE}`) @@ -70,12 +76,19 @@ async function set(args: string[]) { } case 'harness': { if (!HARNESSES.includes(value as Harness)) fail(`harness must be one of: ${HARNESSES.join(', ')}`) - const res = await applyConfig(raw => updateHarnessInConfig(raw, value as Harness), `chore: set harness to ${value}`) + const res = await applyConfig( + raw => updateHarnessInConfig(raw, value as Harness), + `chore: set harness to ${value}`, + ) return reportConfig(res, `set harness → ${value}`) } case 'gateway': { - if (!GATEWAY_PROVIDERS.includes(value as GatewayProvider)) fail(`gateway must be one of: ${GATEWAY_PROVIDERS.join(', ')}`) - const res = await applyConfig(raw => updateGatewayInConfig(raw, value as GatewayProvider), `chore: set gateway to ${value}`) + if (!GATEWAY_PROVIDERS.includes(value as GatewayProvider)) + fail(`gateway must be one of: ${GATEWAY_PROVIDERS.join(', ')}`) + const res = await applyConfig( + raw => updateGatewayInConfig(raw, value as GatewayProvider), + `chore: set gateway to ${value}`, + ) return reportConfig(res, `set gateway → ${value}`) } default: diff --git a/apps/cli/src/commands/init.ts b/apps/cli/src/commands/init.ts index e34aed0a2bb..f9355c1c551 100644 --- a/apps/cli/src/commands/init.ts +++ b/apps/cli/src/commands/init.ts @@ -20,7 +20,12 @@ import { join, resolve } from 'node:path' import { randomBytes } from 'node:crypto' import { createInterface } from 'node:readline/promises' import { REPO_ROOT } from '../../../dashboard/lib/gh.ts' -import { GH_GLOBAL_SCOPES, captureGithubToken, ghTokenScopes, missingScopes } from '../../../dashboard/lib/github-auth.ts' +import { + GH_GLOBAL_SCOPES, + captureGithubToken, + ghTokenScopes, + missingScopes, +} from '../../../dashboard/lib/github-auth.ts' import { setSecret } from '../../../dashboard/lib/secrets-catalog.ts' import { configureAuth } from '../../../dashboard/lib/auth.ts' import { HARNESS_AUTH } from '../../../dashboard/lib/harness-auth.ts' @@ -28,7 +33,14 @@ import { captureHarnessCreds, driveTtyLogin, setHarnessApiKey } from '../../../d import { syncHarness } from '../../../dashboard/lib/gateway.ts' 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 { + loadGateways, + loadHarnesses, + runnableSecrets, + type Credential, + type Gateway, + type HarnessManifest, +} from '../manifest.ts' import { grokLogin, storeGrokKey } from '../grok.ts' import { c, fail, isDryRun, isUpstreamRepo } from '../output.ts' @@ -57,7 +69,11 @@ https://www.aeon.fun/connect` const TEMPLATE = 'aeonfun/aeon' type Status = 'ok' | 'fixed' | 'warn' | 'fail' | 'skip' -interface Row { step: string; status: Status; detail: string } +interface Row { + step: string + status: Status + detail: string +} interface Opts { name: string @@ -72,7 +88,11 @@ interface Opts { // --- output ----------------------------------------------------------------- const rows: Row[] = [] const ICON: Record = { - ok: c.green('✓'), fixed: c.green('✓'), warn: c.yellow('!'), fail: c.red('✗'), skip: c.dim('-'), + ok: c.green('✓'), + fixed: c.green('✓'), + warn: c.yellow('!'), + fail: c.red('✗'), + skip: c.dim('-'), } function report(step: string, status: Status, detail: string, fix?: string) { rows.push({ step, status, detail }) @@ -81,7 +101,7 @@ function report(step: string, status: Status, detail: string, fix?: string) { } const heading = (n: number, title: string) => console.log(`\n${c.bold(`${n}. ${title}`)}`) const note = (s: string) => console.log(c.dim(` ${s}`)) -const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) +const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)) // --- prompts ---------------------------------------------------------------- const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY) @@ -112,7 +132,7 @@ async function confirm(opts: Opts, question: string, def = true): Promise { if (!interactive) return Promise.resolve('') - return new Promise((done) => { + return new Promise(done => { process.stdout.write(` ${question} `) const stdin = process.stdin stdin.setRawMode(true) @@ -129,8 +149,15 @@ function askSecret(question: string): Promise { const onData = (chunk: string) => { for (const ch of chunk) { if (ch === '\r' || ch === '\n') return submit() - if (ch === '\u0003') { stdin.setRawMode(false); process.stdout.write('\n'); process.exit(130) } - if (ch === '\u007f' || ch === '\b') { buf = buf.slice(0, -1); continue } + if (ch === '\u0003') { + stdin.setRawMode(false) + process.stdout.write('\n') + process.exit(130) + } + if (ch === '\u007f' || ch === '\b') { + buf = buf.slice(0, -1) + continue + } buf += ch } } @@ -139,10 +166,18 @@ function askSecret(question: string): Promise { } // --- gh / git helpers ----------------------------------------------------------- -interface Run { ok: boolean; out: string; err: string } +interface Run { + ok: boolean + out: string + err: string +} function run(cmd: string, args: string[], cwd = REPO_ROOT): Run { const r = spawnSync(cmd, args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) - return { ok: r.status === 0 && !r.error, out: (r.stdout ?? '').trim(), err: (r.stderr ?? '').trim() || (r.error?.message ?? '') } + return { + ok: r.status === 0 && !r.error, + out: (r.stdout ?? '').trim(), + err: (r.stderr ?? '').trim() || (r.error?.message ?? ''), + } } const gh = (...args: string[]) => run('gh', args) const git = (...args: string[]) => run('git', args) @@ -167,11 +202,24 @@ async function stepGh(): Promise { heading(1, 'GitHub CLI') const v = run('gh', ['--version']) if (!v.ok) { - report('gh', 'fail', 'GitHub CLI (gh) is not installed', - 'macOS: brew install gh | Linux/Windows: https://github.com/cli/cli#installation - then re-run ./aeon init (no terminal? https://www.aeon.fun/connect)') + report( + 'gh', + 'fail', + 'GitHub CLI (gh) is not installed', + 'macOS: brew install gh | Linux/Windows: https://github.com/cli/cli#installation - then re-run ./aeon init (no terminal? https://www.aeon.fun/connect)', + ) finish(false) } - report('gh', 'ok', `gh installed (${v.out.split('\n')[0].replace(/^gh version /, '').split(' ')[0]})`) + report( + 'gh', + 'ok', + `gh installed (${ + v.out + .split('\n')[0] + .replace(/^gh version /, '') + .split(' ')[0] + })`, + ) if (!gh('auth', 'status').ok) { if (isDryRun()) { @@ -179,10 +227,19 @@ async function stepGh(): Promise { return '' } if (!interactive) { - report('gh auth', 'fail', 'not signed in to GitHub (the browser sign-in needs a terminal)', 'gh auth login --web -s workflow, then re-run ./aeon init') + report( + 'gh auth', + 'fail', + 'not signed in to GitHub (the browser sign-in needs a terminal)', + 'gh auth login --web -s workflow, then re-run ./aeon init', + ) finish(false) } - console.log(c.dim(' Signing in to GitHub (a browser window opens; the workflow scope lets Aeon update its own workflows).')) + console.log( + c.dim( + ' Signing in to GitHub (a browser window opens; the workflow scope lets Aeon update its own workflows).', + ), + ) if (!interactiveRun('gh', ['auth', 'login', '--web', '-s', 'workflow'])) { report('gh auth', 'fail', 'GitHub sign-in did not finish', 'gh auth login --web -s workflow') finish(false) @@ -199,13 +256,21 @@ async function stepGh(): Promise { if (scopes && missing.length) { const envToken = process.env.GH_TOKEN || process.env.GITHUB_TOKEN if (envToken) { - report('gh scopes', 'warn', `signed in as ${login}, but the GH_TOKEN in your shell lacks ${missing.join(' + ')}`, - 'unset GH_TOKEN GITHUB_TOKEN, then re-run ./aeon init') + report( + 'gh scopes', + 'warn', + `signed in as ${login}, but the GH_TOKEN in your shell lacks ${missing.join(' + ')}`, + 'unset GH_TOKEN GITHUB_TOKEN, then re-run ./aeon init', + ) return login } if (isDryRun() || !interactive) { - report('gh scopes', 'warn', `signed in as ${login}; token lacks ${missing.join(' + ')}`, - `${isDryRun() ? 'would run: ' : ''}gh auth refresh -h github.com -s ${GH_GLOBAL_SCOPES.join(',')}`) + report( + 'gh scopes', + 'warn', + `signed in as ${login}; token lacks ${missing.join(' + ')}`, + `${isDryRun() ? 'would run: ' : ''}gh auth refresh -h github.com -s ${GH_GLOBAL_SCOPES.join(',')}`, + ) return login } console.log(c.dim(` Adding the ${missing.join(' + ')} scope to your gh login (approve in the browser).`)) @@ -214,11 +279,19 @@ async function stepGh(): Promise { missing = scopes ? missingScopes(scopes) : [] } if (!scopes) { - report('gh scopes', 'warn', `signed in as ${login}; token scopes cannot be read (fine-grained token?)`, - 'gh auth login --web -s workflow (a gh login token works everywhere Aeon needs one)') + report( + 'gh scopes', + 'warn', + `signed in as ${login}; token scopes cannot be read (fine-grained token?)`, + 'gh auth login --web -s workflow (a gh login token works everywhere Aeon needs one)', + ) } else if (missing.length) { - report('gh scopes', 'warn', `signed in as ${login}; token still lacks ${missing.join(' + ')}`, - `gh auth refresh -h github.com -s ${GH_GLOBAL_SCOPES.join(',')}`) + report( + 'gh scopes', + 'warn', + `signed in as ${login}; token still lacks ${missing.join(' + ')}`, + `gh auth refresh -h github.com -s ${GH_GLOBAL_SCOPES.join(',')}`, + ) } else { report('gh scopes', 'ok', `signed in as ${login} (scopes include repo + workflow)`) } @@ -235,7 +308,8 @@ async function stepRepo(opts: Opts, login: string): Promise { const here = originSlug() if (here && !isUpstreamRepo(here) && existsSync(join(REPO_ROOT, 'aeon.yml'))) { report('repo', 'ok', `this folder is your instance: ${here}`) - if (opts.name !== 'aeon' && !here.endsWith(`/${opts.name}`)) note(`--name ${opts.name} ignored: already inside ${here}`) + if (opts.name !== 'aeon' && !here.endsWith(`/${opts.name}`)) + note(`--name ${opts.name} ignored: already inside ${here}`) await ensureTracksOrigin(here) return here } @@ -245,14 +319,25 @@ async function stepRepo(opts: Opts, login: string): Promise { } const target = `${login}/${opts.name}` - note(here ? `This folder is a copy of ${here}, the shared template. Your agent needs its own repo.` : 'This folder has no GitHub remote yet.') + note( + here + ? `This folder is a copy of ${here}, the shared template. Your agent needs its own repo.` + : 'This folder has no GitHub remote yet.', + ) // A renamed/transferred repo answers under its old name too (GitHub // redirects), so compare the name GitHub resolves, not the one we asked for: // never adopt the template, or anything that redirects to another repo. const view = gh('repo', 'view', target, '--json', 'nameWithOwner', '-q', '.nameWithOwner') - if (isUpstreamRepo(target) || (view.ok && (isUpstreamRepo(view.out) || view.out.toLowerCase() !== target.toLowerCase()))) { - report('repo', 'fail', `${target} is ${view.ok && view.out.toLowerCase() !== target.toLowerCase() ? `a redirect to ${view.out}` : 'the Aeon template itself'}, not a new instance`, - './aeon init --name (e.g. --name my-aeon)') + if ( + isUpstreamRepo(target) || + (view.ok && (isUpstreamRepo(view.out) || view.out.toLowerCase() !== target.toLowerCase())) + ) { + report( + 'repo', + 'fail', + `${target} is ${view.ok && view.out.toLowerCase() !== target.toLowerCase() ? `a redirect to ${view.out}` : 'the Aeon template itself'}, not a new instance`, + './aeon init --name (e.g. --name my-aeon)', + ) finish(false) } // Switching this folder over must not cost any work, so check that BEFORE @@ -262,31 +347,52 @@ async function stepRepo(opts: Opts, login: string): Promise { if (view.ok) { if (!isAeonRepo(target)) { - report('repo', 'fail', `${target} already exists and is not an Aeon repo (no aeon.yml)`, './aeon init --name ') + report( + 'repo', + 'fail', + `${target} already exists and is not an Aeon repo (no aeon.yml)`, + './aeon init --name ', + ) finish(false) } report('repo', 'ok', `found your instance ${target}`) } else { const vis = opts.private ? 'private' : 'public' - note(opts.private - ? 'Private: GitHub Actions minutes come out of your plan (Free includes 2,000/month).' - : 'Public: GitHub Actions minutes are free. Secrets stay secret either way; pass --private to keep the code private.') + note( + opts.private + ? 'Private: GitHub Actions minutes come out of your plan (Free includes 2,000/month).' + : 'Public: GitHub Actions minutes are free. Secrets stay secret either way; pass --private to keep the code private.', + ) if (!(await confirm(opts, `Create ${target} (${vis}) from the ${TEMPLATE} template?`))) { - report('repo', 'fail', interactive ? 'no instance repo' : 'no instance repo (no terminal to confirm; pass --yes to create it)', - `./aeon init --yes (or gh repo create ${target} --template ${TEMPLATE} --${vis})`) + report( + 'repo', + 'fail', + interactive ? 'no instance repo' : 'no instance repo (no terminal to confirm; pass --yes to create it)', + `./aeon init --yes (or gh repo create ${target} --template ${TEMPLATE} --${vis})`, + ) finish(false) } if (isDryRun()) { report('repo', 'skip', `would run: gh repo create ${target} --template ${TEMPLATE} --${vis}`) if (opts.dir) report('clone', 'skip', `would clone ${target} into ${resolve(opts.dir)} and continue there`) - else report('folder', 'skip', `would make ${target} this folder's origin (${here ?? 'no remote'} kept as upstream) and check out its main`) + else + report( + 'folder', + 'skip', + `would make ${target} this folder's origin (${here ?? 'no remote'} kept as upstream) and check out its main`, + ) return target } // A template copy, not a fork: forks start with Actions disabled and // inherit nothing useful, which is the #1 "my agent never ran" cause. const created = gh('repo', 'create', target, '--template', TEMPLATE, `--${vis}`, '--description', 'My Aeon agent') if (!created.ok) { - report('repo', 'fail', `could not create ${target}: ${created.err.split('\n')[0]}`, `gh repo create ${target} --template ${TEMPLATE} --${vis}`) + report( + 'repo', + 'fail', + `could not create ${target}: ${created.err.split('\n')[0]}`, + `gh repo create ${target} --template ${TEMPLATE} --${vis}`, + ) finish(false) } report('repo', 'fixed', `created ${target} from the ${TEMPLATE} template`) @@ -318,7 +424,12 @@ async function cloneElsewhere(opts: Opts, target: string): Promise { preflightDir(dir) const preExisted = existsSync(dir) if (!(await waitForContent(target))) { - report('clone', 'fail', `${target} is still empty (GitHub has not finished copying the template)`, 'wait a minute, then re-run the same ./aeon init command') + report( + 'clone', + 'fail', + `${target} is still empty (GitHub has not finished copying the template)`, + 'wait a minute, then re-run the same ./aeon init command', + ) finish(false) } let cloned = false @@ -347,12 +458,19 @@ 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'])] + const args = [ + 'init', + ...(opts.yes ? ['--yes'] : []), + ...(opts.harness ? ['--harness', opts.harness] : []), + ...(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) { - console.error(c.red('error: ') + `could not start ${launcher}: ${r.error.message}. Run it yourself: cd ${dir} && ./aeon init`) + console.error( + c.red('error: ') + `could not start ${launcher}: ${r.error.message}. Run it yourself: cd ${dir} && ./aeon init`, + ) process.exit(1) } process.exit(r.status ?? 1) @@ -368,7 +486,12 @@ function preflightDir(path: string) { finish(false) } if (!existsSync(join(dir, 'aeon.yml')) && readdirSync(dir).length) { - report('clone', 'fail', `${dir} exists, is not empty and is not an Aeon checkout`, 'pick a new or empty folder: ./aeon init --dir ') + report( + 'clone', + 'fail', + `${dir} exists, is not empty and is not an Aeon checkout`, + 'pick a new or empty folder: ./aeon init --dir ', + ) finish(false) } } @@ -378,14 +501,22 @@ function preflightDir(path: string) { function preflightFolder(opts: Opts) { const dirty = git('status', '--porcelain', '--untracked-files=no') if (dirty.out) { - report('folder', 'fail', 'this folder has uncommitted changes, so it was not switched over', - `commit or stash them and re-run, or: ./aeon init --dir ../${opts.name}`) + report( + 'folder', + 'fail', + 'this folder has uncommitted changes, so it was not switched over', + `commit or stash them and re-run, or: ./aeon init --dir ../${opts.name}`, + ) finish(false) } const local = git('rev-list', '--count', '--branches', '--not', '--remotes') if (local.ok && local.out !== '0') { - report('folder', 'fail', `this folder has ${local.out} local commit(s) not on any remote, so it was not switched over`, - `push them somewhere first, or: ./aeon init --dir ../${opts.name}`) + report( + 'folder', + 'fail', + `this folder has ${local.out} local commit(s) not on any remote, so it was not switched over`, + `push them somewhere first, or: ./aeon init --dir ../${opts.name}`, + ) finish(false) } } @@ -399,7 +530,11 @@ const TEMP_REMOTE = 'aeon-instance' // always ends with the branch tracking origin/. async function adoptThisFolder(here: string | null, target: string): Promise { if (isDryRun()) { - report('folder', 'skip', `would make ${target} this folder's origin (${here ?? 'no remote'} kept as upstream) and check out its main`) + report( + 'folder', + 'skip', + `would make ${target} this folder's origin (${here ?? 'no remote'} kept as upstream) and check out its main`, + ) return target } const remotes = () => git('remote').out.split('\n').filter(Boolean) @@ -407,19 +542,30 @@ async function adoptThisFolder(here: string | null, target: string): Promise !gh('api', '-X', 'PUT', `repos/${slug}/actions/workflows/${l.split(' ')[0]}/enable`).ok) - if (failed.length) report('workflows', 'fail', `${failed.length} workflow(s) still disabled`, `open https://github.com/${slug}/actions and enable them`) + if (isDryRun()) { + report('workflows', 'skip', `would enable ${off.length} workflow(s) GitHub left off`) + return + } + const failed = off.filter( + l => !gh('api', '-X', 'PUT', `repos/${slug}/actions/workflows/${l.split(' ')[0]}/enable`).ok, + ) + if (failed.length) + report( + 'workflows', + 'fail', + `${failed.length} workflow(s) still disabled`, + `open https://github.com/${slug}/actions and enable them`, + ) else report('workflows', 'fixed', `enabled ${off.length} workflow(s) GitHub had left off`) } @@ -605,17 +839,28 @@ async function stepGhGlobal(opts: Opts, secrets: Set | null) { const scopes = ghTokenScopes() const missing = scopes ? missingScopes(scopes) : [...GH_GLOBAL_SCOPES] if (missing.length) { - report('GH_GLOBAL', 'warn', `not set; your gh token lacks ${missing.join(' + ')}, so it was not copied`, - `gh auth refresh -h github.com -s ${GH_GLOBAL_SCOPES.join(',')} && ./aeon auth --github (or a classic PAT with repo + workflow: ./aeon secrets set GH_GLOBAL --stdin)`) + report( + 'GH_GLOBAL', + 'warn', + `not set; your gh token lacks ${missing.join(' + ')}, so it was not copied`, + `gh auth refresh -h github.com -s ${GH_GLOBAL_SCOPES.join(',')} && ./aeon auth --github (or a classic PAT with repo + workflow: ./aeon secrets set GH_GLOBAL --stdin)`, + ) return } - note('Good for getting started. GitHub revokes a gh login token after a year unused, or when you have more than 10 for the') - note('same app and scopes, so for an instance that runs for a long time use a dedicated classic PAT (repo + workflow).') + note( + 'Good for getting started. GitHub revokes a gh login token after a year unused, or when you have more than 10 for the', + ) + note( + 'same app and scopes, so for an instance that runs for a long time use a dedicated classic PAT (repo + workflow).', + ) if (!(await confirm(opts, 'Store your gh token as GH_GLOBAL now? (scopes: repo, workflow)'))) { report('GH_GLOBAL', 'skip', 'GH_GLOBAL not set (optional)', './aeon auth --github') return } - if (isDryRun()) { report('GH_GLOBAL', 'skip', 'would copy `gh auth token` into GH_GLOBAL'); return } + if (isDryRun()) { + report('GH_GLOBAL', 'skip', 'would copy `gh auth token` into GH_GLOBAL') + return + } try { captureGithubToken() report('GH_GLOBAL', 'fixed', 'stored your gh token as GH_GLOBAL') @@ -639,7 +884,12 @@ function cliInstalled(bin: string): boolean { // Store a pasted key for `h` under the credential's secret, going through the // same lib paths the dashboard uses so gateway/harness side effects match. -async function storeKey(h: HarnessManifest, cred: Credential | null, gateway: Gateway | null, key: string): Promise { +async function storeKey( + h: HarnessManifest, + cred: Credential | null, + gateway: Gateway | null, + key: string, +): Promise { if (gateway) return (await configureAuth({ key, provider: gateway.id })).secret ?? gateway.secrets[0] if (!cred) throw new Error('nothing to store') if (h.id === 'claude') return (await configureAuth({ key })).secret ?? cred.secret @@ -666,13 +916,25 @@ async function runLogin(h: HarnessManifest, cred: Credential): Promise { // Point aeon.yml's harness: at `id` (commit + push to origin via the lib). async function switchHarness(id: string) { - if (isDryRun()) { report('harness', 'skip', `would set harness: ${id} in aeon.yml`); return } + if (isDryRun()) { + report('harness', 'skip', `would set harness: ${id} in aeon.yml`) + return + } try { const sync = await syncHarness(id as Harness) - report('harness', sync.synced ? 'fixed' : 'warn', `aeon.yml now runs ${id}${sync.synced ? ' (pushed)' : ` (saved locally, not pushed: ${sync.reason ?? 'unknown'})`}`, - sync.synced ? undefined : './aeon sync') + report( + 'harness', + sync.synced ? 'fixed' : 'warn', + `aeon.yml now runs ${id}${sync.synced ? ' (pushed)' : ` (saved locally, not pushed: ${sync.reason ?? 'unknown'})`}`, + sync.synced ? undefined : './aeon sync', + ) } catch (e) { - report('harness', 'fail', `could not set harness: ${e instanceof Error ? e.message : String(e)}`, `./aeon config set harness ${id}`) + report( + 'harness', + 'fail', + `could not set harness: ${e instanceof Error ? e.message : String(e)}`, + `./aeon config set harness ${id}`, + ) } } @@ -680,12 +942,12 @@ async function stepModel(opts: Opts, secrets: Set | null) { heading(6, 'Model') const harnesses = loadHarnesses() const gateways = loadGateways() - const byId = new Map(harnesses.map((h) => [h.id, h])) + const byId = new Map(harnesses.map(h => [h.id, h])) const configured = currentHarness() - const have = (h: HarnessManifest) => runnableSecrets(h, gateways).find((s) => secrets?.has(s)) + const have = (h: HarnessManifest) => runnableSecrets(h, gateways).find(s => secrets?.has(s)) const asked = opts.harness ? byId.get(opts.harness === 'claude-code' ? 'claude' : opts.harness) : undefined - if (opts.harness && !asked) fail(`unknown harness '${opts.harness}'. One of: ${harnesses.map((h) => h.id).join(', ')}`) + if (opts.harness && !asked) fail(`unknown harness '${opts.harness}'. One of: ${harnesses.map(h => h.id).join(', ')}`) // Already connected? Then do not run a login again: some captures rotate on // every login (grok), so a needless re-login can break a working setup. @@ -700,19 +962,31 @@ async function stepModel(opts: Opts, secrets: Set | null) { console.log('') harnesses.forEach((h, i) => { - const how = h.credentials.map((cr) => cr.label.replace(/ \(.*\)$/, '')).join(', ') - console.log(` ${String(i + 1).padStart(2)}. ${h.label}${h.id === configured ? c.dim(' (current)') : ''} ${c.dim(`- ${how}${h.gateways ? ', or a gateway key' : ''}`)}`) + const how = h.credentials.map(cr => cr.label.replace(/ \(.*\)$/, '')).join(', ') + console.log( + ` ${String(i + 1).padStart(2)}. ${h.label}${h.id === configured ? c.dim(' (current)') : ''} ${c.dim(`- ${how}${h.gateways ? ', or a gateway key' : ''}`)}`, + ) }) note('One OpenRouter key (https://openrouter.ai/settings/keys) works for most of these:') - note(`${harnesses.filter((h) => h.gateways || h.credentials.some((cr) => cr.secret === 'OPENROUTER_API_KEY')).map((h) => h.id).join(', ')}.`) + note( + `${harnesses + .filter(h => h.gateways || h.credentials.some(cr => cr.secret === 'OPENROUTER_API_KEY')) + .map(h => h.id) + .join(', ')}.`, + ) let pick = asked if (!pick && !interactive && !isDryRun()) { - report('model', 'warn', 'no model connected (choosing one needs a terminal)', './aeon init (in a terminal), or ./aeon init --harness ') + report( + 'model', + 'warn', + 'no model connected (choosing one needs a terminal)', + './aeon init (in a terminal), or ./aeon init --harness ', + ) return } if (!pick) { - const def = String(harnesses.findIndex((h) => h.id === configured) + 1) + const def = String(harnesses.findIndex(h => h.id === configured) + 1) const ans = await ask('Which agent should run your skills?', def) pick = harnesses[Number(ans) - 1] ?? byId.get(ans) } @@ -725,13 +999,31 @@ async function stepModel(opts: Opts, secrets: Set | null) { // The harness's own credentials (most preferred first), then for claude the // gateway keys it can route through. const options: { cred: Credential | null; gw: Gateway | null; label: string; how: string; url: string }[] = [ - ...h.credentials.map((cr) => ({ cred: cr, gw: null, label: cr.label, how: cr.aeon_cmd ?? `./aeon secrets set ${cr.secret} --stdin`, url: cr.get_url })), - ...(h.gateways ? gateways.filter((g) => g.transport !== 'native').map((g) => ({ - cred: null, gw: g, label: `${g.label} gateway key`, how: `./aeon auth --key --provider ${g.id}`, url: g.get_url, - })) : []), + ...h.credentials.map(cr => ({ + cred: cr, + gw: null, + label: cr.label, + how: cr.aeon_cmd ?? `./aeon secrets set ${cr.secret} --stdin`, + url: cr.get_url, + })), + ...(h.gateways + ? gateways + .filter(g => g.transport !== 'native') + .map(g => ({ + cred: null, + gw: g, + label: `${g.label} gateway key`, + how: `./aeon auth --key --provider ${g.id}`, + url: g.get_url, + })) + : []), ] console.log(`\n ${c.bold(h.label)} can sign in with:`) - options.forEach((o, i) => console.log(` ${String(i + 1).padStart(2)}. ${o.label} ${c.dim(`- get one: ${o.url}`)}\n ${c.dim(o.how)}`)) + options.forEach((o, i) => + console.log( + ` ${String(i + 1).padStart(2)}. ${o.label} ${c.dim(`- get one: ${o.url}`)}\n ${c.dim(o.how)}`, + ), + ) const ans = await ask('Which one?', '1') const choice = options[Number(ans) - 1] @@ -740,14 +1032,26 @@ async function stepModel(opts: Opts, secrets: Set | null) { return } - const isLogin = choice.cred !== null && (choice.cred.kind === 'oauth_capture' || (choice.cred.kind === 'oauth_token' && Boolean(choice.cred.login_cmd) && h.id === 'claude')) + const isLogin = + choice.cred !== null && + (choice.cred.kind === 'oauth_capture' || + (choice.cred.kind === 'oauth_token' && Boolean(choice.cred.login_cmd) && h.id === 'claude')) if (isDryRun()) { - report('model', 'skip', `would ${isLogin ? `run ${choice.cred?.login_cmd}` : 'ask for the key'} and store ${choice.gw?.secrets[0] ?? choice.cred?.secret}${h.id !== configured ? `, then set harness: ${h.id}` : ''}`) + report( + 'model', + 'skip', + `would ${isLogin ? `run ${choice.cred?.login_cmd}` : 'ask for the key'} and store ${choice.gw?.secrets[0] ?? choice.cred?.secret}${h.id !== configured ? `, then set harness: ${h.id}` : ''}`, + ) return } // Logins open a browser and keys are pasted: both need someone at a terminal. if (!interactive) { - report('model', 'warn', `${h.label}: not connected (${isLogin ? 'the login' : 'pasting a key'} needs a terminal)`, choice.how) + report( + 'model', + 'warn', + `${h.label}: not connected (${isLogin ? 'the login' : 'pasting a key'} needs a terminal)`, + choice.how, + ) return } @@ -763,8 +1067,11 @@ async function stepModel(opts: Opts, secrets: Set | null) { return } const prefixes = choice.gw ? choice.gw.prefixes : choice.cred?.prefix ? [choice.cred.prefix] : [] - if (prefixes.length && !prefixes.some((p) => key.startsWith(p)) && - !(await confirm(opts, `That key does not start with ${prefixes.join(' / ')}. Store it anyway?`, false))) { + if ( + prefixes.length && + !prefixes.some(p => key.startsWith(p)) && + !(await confirm(opts, `That key does not start with ${prefixes.join(' / ')}. Store it anyway?`, false)) + ) { report('model', 'warn', `${h.label}: key not stored`, choice.how) return } @@ -776,16 +1083,23 @@ async function stepModel(opts: Opts, secrets: Set | null) { } report('model', 'fixed', `${h.label} connected (stored as ${stored})`) const aux = choice.cred?.aux_secrets - if (aux?.length && !aux.some((s) => secrets?.has(s))) { - note(`${choice.cred!.refresh ?? 'This login needs a secrets-write token to stay alive'}: set ${aux.join(' or ')} (step 5).`) + if (aux?.length && !aux.some(s => secrets?.has(s))) { + note( + `${choice.cred!.refresh ?? 'This login needs a secrets-write token to stay alive'}: set ${aux.join(' or ')} (step 5).`, + ) } if (h.id !== configured) await switchHarness(h.id) } // --- Telegram --------------------------------------------------------------- -interface TgUpdate { message?: { text?: string; chat?: { id?: number } } } +interface TgUpdate { + message?: { text?: string; chat?: { id?: number } } +} -async function tg(token: string, method: string): Promise<{ status: number; ok: boolean; result?: T; description?: string }> { +async function tg( + token: string, + method: string, +): Promise<{ status: number; ok: boolean; result?: T; description?: string }> { const res = await fetch(`https://api.telegram.org/bot${token}/${method}`) const body = (await res.json().catch(() => ({}))) as { ok?: boolean; result?: T; description?: string } return { status: res.status, ok: Boolean(body.ok), result: body.result, description: body.description } @@ -799,36 +1113,64 @@ async function manualChatId(why: string): Promise { async function stepTelegram(opts: Opts, secrets: Set | null) { heading(7, 'Telegram (optional)') - if (!opts.telegram) { report('telegram', 'skip', 'skipped (--no-telegram)'); return } + 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') return } if (!interactive) { - report('telegram', 'skip', 'not set (needs a terminal)', './aeon init (interactive), or set TELEGRAM_BOT_TOKEN + TELEGRAM_CHAT_ID with ./aeon secrets set') + report( + 'telegram', + 'skip', + 'not set (needs a terminal)', + './aeon init (interactive), or set TELEGRAM_BOT_TOKEN + TELEGRAM_CHAT_ID with ./aeon secrets set', + ) return } - if (!(await confirm(opts, 'Get your agent\'s reports on Telegram?', true))) { + if (!(await confirm(opts, "Get your agent's reports on Telegram?", true))) { report('telegram', 'skip', 'Telegram not set up (optional)', './aeon init') return } - if (isDryRun()) { report('telegram', 'skip', 'would ask for a bot token, then link your chat with a /start deep link'); return } + if (isDryRun()) { + report('telegram', 'skip', 'would ask for a bot token, then link your chat with a /start deep link') + return + } note('In Telegram, message @BotFather, send /newbot, and copy the token it gives you.') const token = await askSecret('Bot token:') - if (!token) { report('telegram', 'skip', 'no bot token entered', './aeon init'); return } + if (!token) { + report('telegram', 'skip', 'no bot token entered', './aeon init') + return + } let me - try { me = await tg<{ username?: string }>(token, 'getMe') } catch (e) { + try { + me = await tg<{ username?: string }>(token, 'getMe') + } catch (e) { report('telegram', 'fail', `could not reach Telegram: ${e instanceof Error ? e.message : String(e)}`, './aeon init') return } if (!me.ok || !me.result?.username) { - report('telegram', 'fail', `Telegram rejected that token${me.description ? ` (${me.description})` : ''}`, 'copy the token from @BotFather again and re-run ./aeon init') + report( + 'telegram', + 'fail', + `Telegram rejected that token${me.description ? ` (${me.description})` : ''}`, + 'copy the token from @BotFather again and re-run ./aeon init', + ) return } const bot = me.result.username - try { await setSecret('TELEGRAM_BOT_TOKEN', token) } catch (e) { - report('telegram', 'fail', `could not save TELEGRAM_BOT_TOKEN: ${e instanceof Error ? e.message : String(e)}`, './aeon secrets set TELEGRAM_BOT_TOKEN --stdin') + try { + await setSecret('TELEGRAM_BOT_TOKEN', token) + } catch (e) { + report( + 'telegram', + 'fail', + `could not save TELEGRAM_BOT_TOKEN: ${e instanceof Error ? e.message : String(e)}`, + './aeon secrets set TELEGRAM_BOT_TOKEN --stdin', + ) return } report('telegram', 'fixed', `saved the token for @${bot}`) @@ -842,7 +1184,11 @@ async function stepTelegram(opts: Opts, secrets: Set | null) { const deadline = Date.now() + 180_000 while (!chatId && Date.now() < deadline) { let up - try { up = await tg(token, 'getUpdates') } catch { up = null } + try { + up = await tg(token, 'getUpdates') + } catch { + up = null + } if (up && up.status === 409) { chatId = await manualChatId('This bot has a webhook set, so its updates cannot be read here.') break @@ -851,10 +1197,12 @@ async function stepTelegram(opts: Opts, secrets: Set | null) { // updates, so a busy bot would hide the /start forever. An offset would // confirm (drop) updates the messages.yml poller still needs, so ask instead. if ((up?.result?.length ?? 0) >= 100) { - chatId = await manualChatId('This bot has a backlog of unread updates, so the /start message cannot be picked out here.') + chatId = await manualChatId( + 'This bot has a backlog of unread updates, so the /start message cannot be picked out here.', + ) break } - const hit = up?.result?.find((u) => u.message?.text?.trim() === `/start ${nonce}`) + const hit = up?.result?.find(u => u.message?.text?.trim() === `/start ${nonce}`) if (hit?.message?.chat?.id !== undefined) chatId = String(hit.message.chat.id) else await sleep(2000) } @@ -867,7 +1215,12 @@ async function stepTelegram(opts: Opts, secrets: Set | null) { await setSecret('TELEGRAM_CHAT_ID', chatId) report('telegram', 'fixed', `linked chat ${chatId} - reports will arrive from @${bot}`) } catch (e) { - report('telegram', 'fail', `could not save TELEGRAM_CHAT_ID: ${e instanceof Error ? e.message : String(e)}`, './aeon secrets set TELEGRAM_CHAT_ID --stdin') + report( + 'telegram', + 'fail', + `could not save TELEGRAM_CHAT_ID: ${e instanceof Error ? e.message : String(e)}`, + './aeon secrets set TELEGRAM_CHAT_ID --stdin', + ) } } @@ -880,36 +1233,73 @@ function printSummary() { // Every exit path: print the checklist, exit 0 only when nothing failed. function finish(completed: boolean): never { printSummary() - const failed = rows.filter((r) => r.status === 'fail').length + const failed = rows.filter(r => r.status === 'fail').length if (!completed || failed) { - console.log(`\n${c.yellow('Not finished.')} Fix the ${c.red('✗')} items above, then re-run ${c.bold('./aeon init')} - it skips what is already done.`) + console.log( + `\n${c.yellow('Not finished.')} Fix the ${c.red('✗')} items above, then re-run ${c.bold('./aeon init')} - it skips what is already done.`, + ) } else if (isDryRun()) { console.log(`\n${c.yellow('Dry run finished:')} nothing was changed. Run ${c.bold('./aeon init')} to do it.`) - } else if (rows.some((r) => r.status === 'warn')) { - console.log(`\n${c.yellow('Almost there.')} Fix the ${c.yellow('!')} items above (or re-run ${c.bold('./aeon init')}); ${c.bold('bin/onboard')} re-checks everything.`) + } else if (rows.some(r => r.status === 'warn')) { + console.log( + `\n${c.yellow('Almost there.')} Fix the ${c.yellow('!')} items above (or re-run ${c.bold('./aeon init')}); ${c.bold('bin/onboard')} re-checks everything.`, + ) } else { - console.log(`\n${c.green('Your agent is set up.')} Skills run on their schedule in GitHub Actions; ${c.bold('bin/onboard')} re-checks everything.`) + console.log( + `\n${c.green('Your agent is set up.')} Skills run on their schedule in GitHub Actions; ${c.bold('bin/onboard')} re-checks everything.`, + ) } process.exit(failed || !completed ? 1 : 0) } 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 } + 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 + } 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' }, - } })) - } catch (e) { fail(e instanceof Error ? e.message : 'bad arguments') } + ;({ 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' }, + }, + })) + } 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), + name, + private: Boolean(values.private), + dir: values.dir, + harness: values.harness, + 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)' : ''}`)) + 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)' : ''}`, + ), + ) const login = await stepGh() const slug = await stepRepo(opts, login) stepDefaultRepo(slug) @@ -917,7 +1307,12 @@ export async function initCommand(argv: string[]) { // (origin). Both must point at the instance, never the template. const origin = originSlug() if (!isDryRun() && (!origin || isUpstreamRepo(origin) || origin.toLowerCase() !== slug.toLowerCase())) { - report('origin', 'fail', `this folder's origin is ${origin ?? 'unset'}, not ${slug}`, `git remote set-url origin https://github.com/${slug}.git`) + report( + 'origin', + 'fail', + `this folder's origin is ${origin ?? 'unset'}, not ${slug}`, + `git remote set-url origin https://github.com/${slug}.git`, + ) finish(false) } stepActions(slug) @@ -927,15 +1322,21 @@ export async function initCommand(argv: string[]) { await stepModel(opts, secrets) await stepTelegram(opts, secretNames(slug) ?? secrets) - const failed = rows.some((r) => r.status === 'fail') + const failed = rows.some(r => r.status === 'fail') // --yes means unattended, and the dashboard is a foreground server: never // start it implicitly. - if (!failed && opts.dashboard && interactive && !opts.yes && !isDryRun() && - (await confirm(opts, 'Start the dashboard now?', true))) { + if ( + !failed && + opts.dashboard && + interactive && + !opts.yes && + !isDryRun() && + (await confirm(opts, 'Start the dashboard now?', true)) + ) { printSummary() const r = spawnSync(join(REPO_ROOT, 'aeon'), [], { cwd: REPO_ROOT, stdio: 'inherit' }) process.exit(r.status ?? 0) } if (!failed && opts.dashboard) note('Start the dashboard any time with ./aeon') finish(true) -} \ No newline at end of file +} diff --git a/apps/cli/src/commands/mcp.ts b/apps/cli/src/commands/mcp.ts index 1d9d5f16a23..3b9962e2a6d 100644 --- a/apps/cli/src/commands/mcp.ts +++ b/apps/cli/src/commands/mcp.ts @@ -45,13 +45,21 @@ async function writeServers(servers: McpServers, message: string) { export async function mcpCommand(argv: string[]) { const sub = argv[0] && !argv[0].startsWith('-') ? argv[0] : 'ls' - if (sub === 'help' || argv.includes('-h') || argv.includes('--help')) { console.log(USAGE); return } + if (sub === 'help' || argv.includes('-h') || argv.includes('--help')) { + console.log(USAGE) + return + } switch (sub) { - case 'ls': return list() - case 'catalog': return catalog() - case 'add': return add(argv.slice(1)) - case 'rm': return remove(argv.slice(1)) - default: fail(`unknown subcommand: ${sub}\n\n${USAGE}`) + case 'ls': + return list() + case 'catalog': + return catalog() + case 'add': + return add(argv.slice(1)) + case 'rm': + return remove(argv.slice(1)) + default: + fail(`unknown subcommand: ${sub}\n\n${USAGE}`) } } @@ -59,24 +67,38 @@ async function list() { const servers = await readServers() const names = Object.keys(servers) emit(servers, () => { - if (!names.length) { console.log(c.dim('(no servers configured — see `aeon mcp catalog`)')); return } - table(['NAME', 'TRANSPORT', 'ENDPOINT'], names.map(n => { - const s = servers[n] - return [n, s.type || (s.command ? 'stdio' : 'http'), s.url || s.command || ''] - })) + if (!names.length) { + console.log(c.dim('(no servers configured — see `aeon mcp catalog`)')) + return + } + table( + ['NAME', 'TRANSPORT', 'ENDPOINT'], + names.map(n => { + const s = servers[n] + return [n, s.type || (s.command ? 'stdio' : 'http'), s.url || s.command || ''] + }), + ) }) } function catalog() { emit(MCP_CATALOG, () => { - table(['SLUG', 'NAME', 'URL'], MCP_CATALOG.map(e => [e.slug, e.name, e.url])) + table( + ['SLUG', 'NAME', 'URL'], + MCP_CATALOG.map(e => [e.slug, e.name, e.url]), + ) }) } async function add(args: string[]) { - const { values, positionals } = parseArgs({ args, options: { - sse: { type: 'boolean' }, header: { type: 'string', multiple: true }, - }, allowPositionals: true }) + const { values, positionals } = parseArgs({ + args, + options: { + sse: { type: 'boolean' }, + header: { type: 'string', multiple: true }, + }, + allowPositionals: true, + }) const servers = await readServers() let name: string @@ -92,7 +114,11 @@ async function add(args: string[]) { } else if (positionals.length >= 2) { name = positionals[0] const url = positionals[1] - try { new URL(url) } catch { fail('url must be a valid URL') } + try { + new URL(url) + } catch { + fail('url must be a valid URL') + } server = { type: values.sse ? 'sse' : 'http', url } const headers: Record = {} for (const h of values.header ?? []) { @@ -106,10 +132,15 @@ async function add(args: string[]) { } const next = { ...servers, [name]: server } - if (isDryRun()) return emit({ dryRun: true, add: { [name]: server } }, () => - console.log(c.yellow('dry-run: ') + `would add server "${name}" → ${server.url}`)) + if (isDryRun()) + return emit({ dryRun: true, add: { [name]: server } }, () => + console.log(c.yellow('dry-run: ') + `would add server "${name}" → ${server.url}`), + ) const sync = await writeServers(next, `chore: add MCP server ${name}`) - emit({ ok: true, added: name, synced: sync.synced }, () => { console.log(c.green('✓ ') + `added ${name}`); printSync(sync) }) + emit({ ok: true, added: name, synced: sync.synced }, () => { + console.log(c.green('✓ ') + `added ${name}`) + printSync(sync) + }) } async function remove(args: string[]) { @@ -119,8 +150,13 @@ async function remove(args: string[]) { if (!(name in servers)) fail(`no such server: ${name}`) const next = { ...servers } delete next[name] - if (isDryRun()) return emit({ dryRun: true, remove: name }, () => - console.log(c.yellow('dry-run: ') + `would remove server "${name}"`)) + if (isDryRun()) + return emit({ dryRun: true, remove: name }, () => + console.log(c.yellow('dry-run: ') + `would remove server "${name}"`), + ) const sync = await writeServers(next, `chore: remove MCP server ${name}`) - emit({ ok: true, removed: name, synced: sync.synced }, () => { console.log(c.green('✓ ') + `removed ${name}`); printSync(sync) }) + emit({ ok: true, removed: name, synced: sync.synced }, () => { + console.log(c.green('✓ ') + `removed ${name}`) + printSync(sync) + }) } diff --git a/apps/cli/src/commands/memory.ts b/apps/cli/src/commands/memory.ts index 26629d0f431..bf04211f279 100644 --- a/apps/cli/src/commands/memory.ts +++ b/apps/cli/src/commands/memory.ts @@ -1,6 +1,12 @@ import { - listLogs, readLog, listTopics, readTopic, listIssues, readIssue, - searchMemory, readMemoryIndex, + listLogs, + readLog, + listTopics, + readTopic, + listIssues, + readIssue, + searchMemory, + readMemoryIndex, } from '../../../dashboard/lib/memory.ts' import { emit, table, c, fail } from '../output.ts' @@ -17,15 +23,29 @@ Options: export async function memoryCommand(argv: string[]) { const sub = argv[0] - if (sub === 'help' || argv.includes('-h') || argv.includes('--help')) { console.log(USAGE); return } + if (sub === 'help' || argv.includes('-h') || argv.includes('--help')) { + console.log(USAGE) + return + } switch (sub) { - case undefined: return index() - case 'logs': return logs(argv[1]) - case 'topics': return topics(argv[1]) - case 'issues': return issues(argv[1]) - case 'search': return search(argv.slice(1).filter(a => !a.startsWith('-')).join(' ')) - default: fail(`unknown subcommand: ${sub}\n\n${USAGE}`) + case undefined: + return index() + case 'logs': + return logs(argv[1]) + case 'topics': + return topics(argv[1]) + case 'issues': + return issues(argv[1]) + case 'search': + return search( + argv + .slice(1) + .filter(a => !a.startsWith('-')) + .join(' '), + ) + default: + fail(`unknown subcommand: ${sub}\n\n${USAGE}`) } } @@ -37,10 +57,19 @@ async function index() { latestLog: l[0]?.date ?? null, } emit(data, () => { - console.log(c.dim('topics ') + t.length + c.dim(' logs ') + l.length + c.dim(' issues ') + i.length + - (l[0] ? c.dim(' latest ') + l[0].date : '')) - if (mem) { console.log('\n' + c.bold('MEMORY.md')); console.log(mem.trim()) } - else console.log(c.dim('\n(no MEMORY.md yet)')) + console.log( + c.dim('topics ') + + t.length + + c.dim(' logs ') + + l.length + + c.dim(' issues ') + + i.length + + (l[0] ? c.dim(' latest ') + l[0].date : ''), + ) + if (mem) { + console.log('\n' + c.bold('MEMORY.md')) + console.log(mem.trim()) + } else console.log(c.dim('\n(no MEMORY.md yet)')) }) } @@ -48,12 +77,21 @@ async function logs(date?: string) { if (date) { const log = await readLog(date) if (!log) fail(`no log for ${date} (expected YYYY-MM-DD)`) - return emit(log, () => { console.log(c.bold(log.date)); console.log(log.content.trim()) }) + return emit(log, () => { + console.log(c.bold(log.date)) + console.log(log.content.trim()) + }) } const all = await listLogs() emit(all, () => { - if (!all.length) { console.log(c.dim('(no logs)')); return } - table(['DATE', 'SIZE'], all.map(x => [x.date, `${x.size}b`])) + if (!all.length) { + console.log(c.dim('(no logs)')) + return + } + table( + ['DATE', 'SIZE'], + all.map(x => [x.date, `${x.size}b`]), + ) }) } @@ -61,12 +99,21 @@ async function topics(slug?: string) { if (slug) { const t = await readTopic(slug) if (!t) fail(`no topic: ${slug}`) - return emit(t, () => { console.log(c.bold(t.slug)); console.log(t.content.trim()) }) + return emit(t, () => { + console.log(c.bold(t.slug)) + console.log(t.content.trim()) + }) } const all = await listTopics() emit(all, () => { - if (!all.length) { console.log(c.dim('(no topics)')); return } - table(['SLUG', 'SIZE'], all.map(x => [x.slug, `${x.size}b`])) + if (!all.length) { + console.log(c.dim('(no topics)')) + return + } + table( + ['SLUG', 'SIZE'], + all.map(x => [x.slug, `${x.size}b`]), + ) }) } @@ -74,12 +121,21 @@ async function issues(id?: string) { if (id) { const iss = await readIssue(id) if (!iss) fail(`no issue: ${id} (expected ISS-NNN)`) - return emit(iss, () => { console.log(c.bold(iss.id)); console.log(iss.content.trim()) }) + return emit(iss, () => { + console.log(c.bold(iss.id)) + console.log(iss.content.trim()) + }) } const all = await listIssues() emit(all, () => { - if (!all.length) { console.log(c.dim('(no issues)')); return } - table(['ID', 'UPDATED'], all.map(x => [x.id, x.updatedAt.slice(0, 10)])) + if (!all.length) { + console.log(c.dim('(no issues)')) + return + } + table( + ['ID', 'UPDATED'], + all.map(x => [x.id, x.updatedAt.slice(0, 10)]), + ) }) } @@ -87,7 +143,10 @@ async function search(query: string) { if (!query) fail('usage: aeon memory search ') const hits = await searchMemory(query, { limit: 20 }) emit(hits, () => { - if (!hits.length) { console.log(c.dim(`no matches for "${query}"`)); return } + if (!hits.length) { + console.log(c.dim(`no matches for "${query}"`)) + return + } for (const h of hits) { console.log(`${c.cyan(h.source)} ${c.bold(h.ref)}${c.dim(':' + h.lineNumber)}`) // The lib marks matched substrings with **…**; render them bold in a TTY. diff --git a/apps/cli/src/commands/packs.ts b/apps/cli/src/commands/packs.ts index 9fa2baa6331..d92493148ad 100644 --- a/apps/cli/src/commands/packs.ts +++ b/apps/cli/src/commands/packs.ts @@ -17,7 +17,10 @@ Installed skills land DISABLED and security-scanned — enable them with export async function packsCommand(argv: string[]) { const sub = argv[0] && !argv[0].startsWith('-') ? argv[0] : 'ls' - if (sub === 'help' || argv.includes('-h') || argv.includes('--help')) { console.log(USAGE); return } + if (sub === 'help' || argv.includes('-h') || argv.includes('--help')) { + console.log(USAGE) + return + } if (sub === 'ls') return list() if (sub === 'install') return install(argv.slice(1)) fail(`unknown subcommand: ${sub}\n\n${USAGE}`) @@ -28,13 +31,17 @@ async function list() { emit({ firstParty, community }, () => { if (firstParty.length) { console.log(c.bold('First-party')) - table(['PACK', 'ENABLED', 'DESCRIPTION'], - firstParty.map(p => [p.key, `${p.enabled}/${p.total}`, truncate(p.description, 56)])) + table( + ['PACK', 'ENABLED', 'DESCRIPTION'], + firstParty.map(p => [p.key, `${p.enabled}/${p.total}`, truncate(p.description, 56)]), + ) } if (community.length) { console.log('\n' + c.bold('Community')) - table(['PACK', 'INSTALLED', 'REPO'], - community.map(p => [p.name, `${p.installedCount}/${p.skills.length}`, p.repo])) + table( + ['PACK', 'INSTALLED', 'REPO'], + community.map(p => [p.name, `${p.installedCount}/${p.skills.length}`, p.repo]), + ) } if (!firstParty.length && !community.length) console.log(c.dim('(no pack manifests found)')) }) @@ -48,10 +55,14 @@ function install(args: string[]) { if (isDryRun()) { const ghArgs = buildSkillRunArgs('install-skill', { var: varArg }) return emit({ dryRun: true, var: varArg, command: ['gh', ...ghArgs] }, () => - console.log(c.yellow('dry-run: ') + 'gh ' + ghArgs.join(' '))) + console.log(c.yellow('dry-run: ') + 'gh ' + ghArgs.join(' ')), + ) } requireGh() runSkill('install-skill', { var: varArg }) emit({ ok: true, installing: varArg }, () => - console.log(c.green('✓ ') + `dispatched install-skill for "${varArg}" — it opens an auto-merging PR; watch \`aeon runs ls\``)) + console.log( + c.green('✓ ') + `dispatched install-skill for "${varArg}" — it opens an auto-merging PR; watch \`aeon runs ls\``, + ), + ) } diff --git a/apps/cli/src/commands/runs.ts b/apps/cli/src/commands/runs.ts index 9b0728f446e..3d6007d9050 100644 --- a/apps/cli/src/commands/runs.ts +++ b/apps/cli/src/commands/runs.ts @@ -12,7 +12,10 @@ Options: export async function runsCommand(argv: string[]) { const sub = argv[0] && !argv[0].startsWith('-') ? argv[0] : 'ls' - if (sub === 'help' || argv.includes('-h') || argv.includes('--help')) { console.log(USAGE); return } + if (sub === 'help' || argv.includes('-h') || argv.includes('--help')) { + console.log(USAGE) + return + } requireGh() @@ -36,7 +39,10 @@ function listRunsCmd(args: string[]) { const runs = listRuns(limit) emit(runs, () => { - if (runs.length === 0) { console.log(c.dim('(no recent Aeon runs)')); return } + if (runs.length === 0) { + console.log(c.dim('(no recent Aeon runs)')) + return + } table( ['ID', 'STATUS', 'WORKFLOW', 'WHEN'], runs.map(r => [String(r.id), statusCell(r.status, r.conclusion), truncate(r.workflow, 48), rel(r.created_at)]), diff --git a/apps/cli/src/commands/secrets.ts b/apps/cli/src/commands/secrets.ts index b94b3a0237b..dec208cb5ad 100644 --- a/apps/cli/src/commands/secrets.ts +++ b/apps/cli/src/commands/secrets.ts @@ -1,7 +1,5 @@ import { readFileSync } from 'node:fs' -import { - getSecrets, setSecret, deleteSecret, VALID_SECRET_NAME, -} from '../../../dashboard/lib/secrets-catalog.ts' +import { getSecrets, setSecret, deleteSecret, VALID_SECRET_NAME } from '../../../dashboard/lib/secrets-catalog.ts' import type { Secret } from '../../../dashboard/lib/types.ts' import { emit, table, c, fail, isDryRun, requireGh, truncate } from '../output.ts' @@ -22,7 +20,10 @@ Options: export async function secretsCommand(argv: string[]) { const sub = argv[0] && !argv[0].startsWith('-') ? argv[0] : 'ls' - if (sub === 'help' || argv.includes('-h') || argv.includes('--help')) { console.log(USAGE); return } + if (sub === 'help' || argv.includes('-h') || argv.includes('--help')) { + console.log(USAGE) + return + } if (sub === 'set') return setCmd(argv.slice(1)) if (sub === 'rm') return rmCmd(argv.slice(1)) if (sub !== 'ls') fail(`unknown subcommand: ${sub}\n\n${USAGE}`) @@ -37,7 +38,10 @@ export async function secretsCommand(argv: string[]) { if (onlyUnset) rows = rows.filter(s => !s.isSet) emit(rows, () => { - if (rows.length === 0) { console.log(c.dim('(no matching secrets)')); return } + if (rows.length === 0) { + console.log(c.dim('(no matching secrets)')) + return + } const groups = new Map() for (const s of rows) { const g = groups.get(s.group) ?? [] @@ -83,7 +87,8 @@ async function setCmd(args: string[]) { if (isDryRun()) { return emit({ label: `set ${name}`, dryRun: true }, () => - console.log(c.yellow('dry-run: ') + `would set secret ${name} (${value.length} chars) via gh`)) + console.log(c.yellow('dry-run: ') + `would set secret ${name} (${value.length} chars) via gh`), + ) } await setSecret(name, value) emit({ ok: true, set: name }, () => console.log(c.green('✓ ') + `set ${name}`)) @@ -94,7 +99,8 @@ async function rmCmd(args: string[]) { const name = requireSecretName(args) if (isDryRun()) { return emit({ label: `rm ${name}`, dryRun: true }, () => - console.log(c.yellow('dry-run: ') + `would delete secret ${name} via gh`)) + console.log(c.yellow('dry-run: ') + `would delete secret ${name} via gh`), + ) } await deleteSecret(name) emit({ ok: true, deleted: name }, () => console.log(c.green('✓ ') + `deleted ${name}`)) diff --git a/apps/cli/src/commands/skills.ts b/apps/cli/src/commands/skills.ts index 8d70f99ce0b..67ea4c9e676 100644 --- a/apps/cli/src/commands/skills.ts +++ b/apps/cli/src/commands/skills.ts @@ -33,18 +33,27 @@ export async function skillsCommand(argv: string[]) { const rest = argv[0] === sub ? argv.slice(1) : argv if (sub === 'help' || argv.includes('-h') || argv.includes('--help')) { - console.log(USAGE); return + console.log(USAGE) + return } switch (sub) { - case 'ls': return listSkills((await getSkills()).skills, rest) - case 'enable': return toggle(rest, true) - case 'disable': return toggle(rest, false) - case 'schedule': return schedule(rest) - case 'set': return setFields(rest) - case 'rm': return remove(rest) - case 'run': return run(rest) - default: return showSkill((await getSkills()).skills, sub) + case 'ls': + return listSkills((await getSkills()).skills, rest) + case 'enable': + return toggle(rest, true) + case 'disable': + return toggle(rest, false) + case 'schedule': + return schedule(rest) + case 'set': + return setFields(rest) + case 'rm': + return remove(rest) + case 'run': + return run(rest) + default: + return showSkill((await getSkills()).skills, sub) } } @@ -92,10 +101,18 @@ async function setFields(args: string[]) { const name = requireName(args) let values: { var?: string; model?: string; harness?: string } try { - ;({ values } = parseArgs({ args: args.filter(a => a !== name), options: { - var: { type: 'string' }, model: { type: 'string' }, harness: { type: 'string' }, - }, allowPositionals: true })) - } catch (e) { fail(e instanceof Error ? e.message : 'bad arguments') } + ;({ values } = parseArgs({ + args: args.filter(a => a !== name), + options: { + var: { type: 'string' }, + model: { type: 'string' }, + harness: { type: 'string' }, + }, + allowPositionals: true, + })) + } catch (e) { + fail(e instanceof Error ? e.message : 'bad arguments') + } const updates: Parameters[2] = {} if (typeof values.var === 'string') updates.var = values.var if (typeof values.model === 'string') updates.model = values.model @@ -103,18 +120,26 @@ async function setFields(args: string[]) { if (Object.keys(updates).length === 0) fail('nothing to set — pass --var, --model, or --harness') await requireInstalled(name) const res = await applyConfig(raw => upsertSkillInConfig(raw, name, updates), `chore: update ${name} config`) - reportConfig(res, `set ${name} ${Object.entries(updates).map(([k, v]) => `${k}=${v || '(clear)'}`).join(' ')}`) + reportConfig( + res, + `set ${name} ${Object.entries(updates) + .map(([k, v]) => `${k}=${v || '(clear)'}`) + .join(' ')}`, + ) } async function remove(args: string[]) { const name = requireName(args) const yes = args.includes('--yes') || args.includes('-y') if (!yes && !isDryRun()) { - fail(`refusing to delete "${name}" without --yes. This removes skills/${name}/ and its aeon.yml entry, then pushes to main.`) + fail( + `refusing to delete "${name}" without --yes. This removes skills/${name}/ and its aeon.yml entry, then pushes to main.`, + ) } if (isDryRun()) { emit({ label: `remove ${name}`, dryRun: true }, () => - console.log(c.yellow('dry-run: ') + `would delete skills/${name}/ and its aeon.yml entry, then push`)) + console.log(c.yellow('dry-run: ') + `would delete skills/${name}/ and its aeon.yml entry, then push`), + ) return } await deleteDirectory(`skills/${name}`, `chore: delete ${name} skill`) @@ -122,8 +147,11 @@ async function remove(args: string[]) { try { const { content } = await getFileContent('aeon.yml') const updated = removeSkillFromConfig(content, name) - if (updated !== content) await saveFile('aeon.yml', updated, { updateMsg: `chore: remove ${name} from config`, createMsg: '' }) - } catch (e) { configError = e instanceof Error ? e.message : 'failed to update aeon.yml' } + if (updated !== content) + await saveFile('aeon.yml', updated, { updateMsg: `chore: remove ${name} from config`, createMsg: '' }) + } catch (e) { + configError = e instanceof Error ? e.message : 'failed to update aeon.yml' + } const sync = commitAndPush(['aeon.yml', `skills/${name}`], `chore: remove ${name} skill`) emit({ label: `remove ${name}`, synced: sync.synced, syncError: sync.reason, configError }, () => { console.log(c.green('✓ ') + `removed skill ${name}`) @@ -136,27 +164,41 @@ function run(args: string[]) { const name = requireName(args) let values: { var?: string; model?: string } try { - ;({ values } = parseArgs({ args: args.filter(a => a !== name), options: { - var: { type: 'string' }, model: { type: 'string' }, - }, allowPositionals: true })) - } catch (e) { fail(e instanceof Error ? e.message : 'bad arguments') } + ;({ values } = parseArgs({ + args: args.filter(a => a !== name), + options: { + var: { type: 'string' }, + model: { type: 'string' }, + }, + allowPositionals: true, + })) + } catch (e) { + fail(e instanceof Error ? e.message : 'bad arguments') + } if (isDryRun()) { const ghArgs = buildSkillRunArgs(name, values) emit({ label: `run ${name}`, dryRun: true, command: ['gh', ...ghArgs] }, () => - console.log(c.yellow('dry-run: ') + 'gh ' + ghArgs.join(' '))) + console.log(c.yellow('dry-run: ') + 'gh ' + ghArgs.join(' ')), + ) return } runSkill(name, values) - emit({ ok: true, dispatched: name }, () => console.log(c.green('✓ ') + `dispatched ${name} — watch it with \`aeon runs ls\``)) + emit({ ok: true, dispatched: name }, () => + console.log(c.green('✓ ') + `dispatched ${name} — watch it with \`aeon runs ls\``), + ) } function listSkills(skills: Skill[], args: string[]) { let opts: { values: { enabled?: boolean; pack?: string } } try { - opts = parseArgs({ args, options: { - enabled: { type: 'boolean' }, - pack: { type: 'string' }, - }, allowPositionals: false }) + opts = parseArgs({ + args, + options: { + enabled: { type: 'boolean' }, + pack: { type: 'string' }, + }, + allowPositionals: false, + }) } catch (e) { fail(e instanceof Error ? e.message : 'bad arguments') } @@ -167,7 +209,10 @@ function listSkills(skills: Skill[], args: string[]) { rows = [...rows].sort((a, b) => Number(b.enabled) - Number(a.enabled) || a.name.localeCompare(b.name)) emit(rows, () => { - if (rows.length === 0) { console.log(c.dim('(no matching skills)')); return } + if (rows.length === 0) { + console.log(c.dim('(no matching skills)')) + return + } table( ['SKILL', 'ON', 'SCHEDULE', 'PACK', 'DESCRIPTION'], rows.map(s => [ diff --git a/apps/cli/src/commands/soul.ts b/apps/cli/src/commands/soul.ts index 23027bcc924..0cc97e2eaaa 100644 --- a/apps/cli/src/commands/soul.ts +++ b/apps/cli/src/commands/soul.ts @@ -15,38 +15,69 @@ Options: export async function soulCommand(argv: string[]) { const sub = argv[0] && !argv[0].startsWith('-') ? argv[0] : 'show' - if (sub === 'help' || argv.includes('-h') || argv.includes('--help')) { console.log(USAGE); return } + if (sub === 'help' || argv.includes('-h') || argv.includes('--help')) { + console.log(USAGE) + return + } if (sub === 'show') return show() if (sub === 'build') return build(argv.slice(1)) fail(`unknown subcommand: ${sub}\n\n${USAGE}`) } async function read(path: string): Promise { - try { return (await getFileContent(path)).content } catch { return '' } + try { + return (await getFileContent(path)).content + } catch { + return '' + } } async function show() { const [soul, style] = await Promise.all([read('soul/SOUL.md'), read('soul/STYLE.md')]) emit({ soul: { content: soul, exists: !!soul }, style: { content: style, exists: !!style } }, () => { - if (!soul && !style) { console.log(c.dim('(no soul files yet — run `aeon soul build`)')); return } - if (soul) { console.log(c.bold('── SOUL.md ──')); console.log(soul.trimEnd()) } - if (style) { console.log('\n' + c.bold('── STYLE.md ──')); console.log(style.trimEnd()) } + if (!soul && !style) { + console.log(c.dim('(no soul files yet — run `aeon soul build`)')) + return + } + if (soul) { + console.log(c.bold('── SOUL.md ──')) + console.log(soul.trimEnd()) + } + if (style) { + console.log('\n' + c.bold('── STYLE.md ──')) + console.log(style.trimEnd()) + } }) } function build(args: string[]) { - const { values } = parseArgs({ args, options: { - handle: { type: 'string' }, name: { type: 'string' }, links: { type: 'string' }, model: { type: 'string' }, - }, allowPositionals: true }) + const { values } = parseArgs({ + args, + options: { + handle: { type: 'string' }, + name: { type: 'string' }, + links: { type: 'string' }, + model: { type: 'string' }, + }, + allowPositionals: true, + }) const input = { handle: values.handle, name: values.name, links: values.links, model: values.model } let result - try { result = buildSoul(input, { dispatch: false }) } catch (e) { return fail(e instanceof Error ? e.message : 'invalid input') } + try { + result = buildSoul(input, { dispatch: false }) + } catch (e) { + return fail(e instanceof Error ? e.message : 'invalid input') + } - if (isDryRun()) return emit({ dryRun: true, sources: result.sources, command: ['gh', ...result.args] }, () => - console.log(c.yellow('dry-run: ') + 'gh ' + result.args.join(' '))) + if (isDryRun()) + return emit({ dryRun: true, sources: result.sources, command: ['gh', ...result.args] }, () => + console.log(c.yellow('dry-run: ') + 'gh ' + result.args.join(' ')), + ) requireGh() buildSoul(input, { dispatch: true }) - emit({ ok: true, sources: result.sources }, () => console.log(c.green('✓ ') + 'dispatched soul-builder — watch `aeon runs ls`')) + emit({ ok: true, sources: result.sources }, () => + console.log(c.green('✓ ') + 'dispatched soul-builder — watch `aeon runs ls`'), + ) } diff --git a/apps/cli/src/commands/strategy.ts b/apps/cli/src/commands/strategy.ts index 615c953934f..434d2bd269f 100644 --- a/apps/cli/src/commands/strategy.ts +++ b/apps/cli/src/commands/strategy.ts @@ -18,7 +18,10 @@ Options: export async function strategyCommand(argv: string[]) { const sub = argv[0] && !argv[0].startsWith('-') ? argv[0] : 'show' - if (sub === 'help' || argv.includes('-h') || argv.includes('--help')) { console.log(USAGE); return } + if (sub === 'help' || argv.includes('-h') || argv.includes('--help')) { + console.log(USAGE) + return + } if (sub === 'show') return show() if (sub === 'set') return setDoc(argv.slice(1)) if (sub === 'build') return build(argv.slice(1)) @@ -27,36 +30,66 @@ export async function strategyCommand(argv: string[]) { async function show() { let content = '' - try { content = (await getFileContent('STRATEGY.md')).content } catch { fail('STRATEGY.md not found') } + try { + content = (await getFileContent('STRATEGY.md')).content + } catch { + fail('STRATEGY.md not found') + } emit({ exists: true, content }, () => console.log(content.trimEnd())) } async function setDoc(args: string[]) { - const { values } = parseArgs({ args, options: { file: { type: 'string' }, stdin: { type: 'boolean' } }, allowPositionals: true }) + const { values } = parseArgs({ + args, + options: { file: { type: 'string' }, stdin: { type: 'boolean' } }, + allowPositionals: true, + }) let content: string if (values.stdin) content = readFileSync(0, 'utf-8') else if (values.file) content = readFileSync(values.file, 'utf-8') else return fail('usage: aeon strategy set --stdin | --file ') - if (isDryRun()) return emit({ dryRun: true, bytes: content.length }, () => - console.log(c.yellow('dry-run: ') + `would write STRATEGY.md (${content.length} bytes) + push`)) - const sync = await saveFile('STRATEGY.md', content, { updateMsg: 'chore: update STRATEGY.md from CLI', createMsg: 'chore: add STRATEGY.md from CLI' }) - emit({ ok: true, synced: sync.synced }, () => { console.log(c.green('✓ ') + 'wrote STRATEGY.md'); printSync(sync) }) + if (isDryRun()) + return emit({ dryRun: true, bytes: content.length }, () => + console.log(c.yellow('dry-run: ') + `would write STRATEGY.md (${content.length} bytes) + push`), + ) + const sync = await saveFile('STRATEGY.md', content, { + updateMsg: 'chore: update STRATEGY.md from CLI', + createMsg: 'chore: add STRATEGY.md from CLI', + }) + emit({ ok: true, synced: sync.synced }, () => { + console.log(c.green('✓ ') + 'wrote STRATEGY.md') + printSync(sync) + }) } function build(args: string[]) { - const { values, positionals } = parseArgs({ args, options: { - repo: { type: 'string' }, links: { type: 'string' }, model: { type: 'string' }, - }, allowPositionals: true }) + const { values, positionals } = parseArgs({ + args, + options: { + repo: { type: 'string' }, + links: { type: 'string' }, + model: { type: 'string' }, + }, + allowPositionals: true, + }) const goal = positionals.join(' ') const input = { goal, repo: values.repo, links: values.links, model: values.model } let result - try { result = buildStrategy(input, { dispatch: false }) } catch (e) { return fail(e instanceof Error ? e.message : 'invalid input') } + try { + result = buildStrategy(input, { dispatch: false }) + } catch (e) { + return fail(e instanceof Error ? e.message : 'invalid input') + } - if (isDryRun()) return emit({ dryRun: true, brief: result.brief, command: ['gh', ...result.args] }, () => - console.log(c.yellow('dry-run: ') + 'gh ' + result.args.join(' '))) + if (isDryRun()) + return emit({ dryRun: true, brief: result.brief, command: ['gh', ...result.args] }, () => + console.log(c.yellow('dry-run: ') + 'gh ' + result.args.join(' ')), + ) requireGh() buildStrategy(input, { dispatch: true }) - emit({ ok: true, brief: result.brief }, () => console.log(c.green('✓ ') + 'dispatched strategy-builder — watch `aeon runs ls`')) + emit({ ok: true, brief: result.brief }, () => + console.log(c.green('✓ ') + 'dispatched strategy-builder — watch `aeon runs ls`'), + ) } diff --git a/apps/cli/src/commands/sync.ts b/apps/cli/src/commands/sync.ts index f1f24ea19b1..2c7a4204649 100644 --- a/apps/cli/src/commands/sync.ts +++ b/apps/cli/src/commands/sync.ts @@ -11,13 +11,18 @@ Options: --json Machine-readable output` export function syncCommand(argv: string[]) { - if (argv.includes('-h') || argv.includes('--help')) { console.log(USAGE); return } + if (argv.includes('-h') || argv.includes('--help')) { + console.log(USAGE) + return + } if (argv.includes('--status')) { const s = syncStatus() return emit(s, () => { - console.log(`${s.hasChanges ? c.yellow(`${s.changedFiles} changed file(s)`) : c.green('clean tree')}` + - ` ${s.behind > 0 ? c.yellow(`${s.behind} behind origin/main`) : c.dim('up to date')}`) + console.log( + `${s.hasChanges ? c.yellow(`${s.changedFiles} changed file(s)`) : c.green('clean tree')}` + + ` ${s.behind > 0 ? c.yellow(`${s.behind} behind origin/main`) : c.dim('up to date')}`, + ) }) } diff --git a/apps/cli/src/commands/telegram.ts b/apps/cli/src/commands/telegram.ts index 1774ad7830e..05e9cc140a4 100644 --- a/apps/cli/src/commands/telegram.ts +++ b/apps/cli/src/commands/telegram.ts @@ -14,13 +14,24 @@ auto-registered the moment you set that token via \`aeon secrets set\`.` export function telegramCommand(argv: string[]) { const sub = argv[0] && !argv[0].startsWith('-') ? argv[0] : '' - if (sub === 'help' || argv.includes('-h') || argv.includes('--help')) { console.log(USAGE); return } + if (sub === 'help' || argv.includes('-h') || argv.includes('--help')) { + console.log(USAGE) + return + } if (sub !== 'register') fail(`unknown subcommand: ${sub || '(none)'}\n\n${USAGE}`) - if (isDryRun()) return emit({ dryRun: true, workflow: 'setup-commands.yml' }, () => - console.log(c.yellow('dry-run: ') + 'gh workflow run setup-commands.yml')) + if (isDryRun()) + return emit({ dryRun: true, workflow: 'setup-commands.yml' }, () => + console.log(c.yellow('dry-run: ') + 'gh workflow run setup-commands.yml'), + ) requireGh() - try { dispatchCommandsWorkflow() } catch (e) { fail(e instanceof Error ? e.message : 'failed to dispatch') } - emit({ ok: true }, () => console.log(c.green('✓ ') + 'dispatched setup-commands.yml — the / menu will refresh shortly')) + try { + dispatchCommandsWorkflow() + } catch (e) { + fail(e instanceof Error ? e.message : 'failed to dispatch') + } + emit({ ok: true }, () => + console.log(c.green('✓ ') + 'dispatched setup-commands.yml — the / menu will refresh shortly'), + ) } diff --git a/apps/cli/src/grok.ts b/apps/cli/src/grok.ts index 51e73ea5f1f..08c33988462 100644 --- a/apps/cli/src/grok.ts +++ b/apps/cli/src/grok.ts @@ -17,14 +17,18 @@ const AUTH_FILE = '.grok/auth.json' export function grokLogin(): { secret: string } { const res = spawnSync('grok', ['login', '--device-auth'], { stdio: 'inherit' }) if (res.error) { - throw new Error((res.error as NodeJS.ErrnoException).code === 'ENOENT' - ? 'grok CLI not found. Install it: npm i -g @xai-official/grok' - : res.error.message) + throw new Error( + (res.error as NodeJS.ErrnoException).code === 'ENOENT' + ? 'grok CLI not found. Install it: npm i -g @xai-official/grok' + : res.error.message, + ) } if (res.status !== 0) throw new Error(`grok login exited ${res.status}`) const home = homedir() if (!existsSync(join(home, AUTH_FILE))) { - throw new Error('Login finished but no ~/.grok/auth.json was found. Run `grok login` in a terminal, then try again.') + throw new Error( + 'Login finished but no ~/.grok/auth.json was found. Run `grok login` in a terminal, then try again.', + ) } const archive = execFileSync('tar', ['czf', '-', '-C', home, AUTH_FILE], { maxBuffer: 8 * 1024 * 1024 }) ghSecretSet('GROK_CREDENTIALS', archive.toString('base64')) diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index ae2c6051441..fb0f2711b0a 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -68,8 +68,14 @@ async function main() { // Pull the global flags out of argv wherever they appear, so commands only see // their own args. let argv = process.argv.slice(2) - if (argv.includes('--json')) { setJsonMode(true); argv = argv.filter(a => a !== '--json') } - if (argv.includes('--dry-run')) { setDryRun(true); argv = argv.filter(a => a !== '--dry-run') } + if (argv.includes('--json')) { + setJsonMode(true) + argv = argv.filter(a => a !== '--json') + } + if (argv.includes('--dry-run')) { + setDryRun(true) + argv = argv.filter(a => a !== '--dry-run') + } const cmd = argv[0] if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') { diff --git a/apps/cli/src/manifest.ts b/apps/cli/src/manifest.ts index 94bfa55ebc1..28b59a67260 100644 --- a/apps/cli/src/manifest.ts +++ b/apps/cli/src/manifest.ts @@ -65,7 +65,7 @@ export function loadGateways(root = CLI_REPO): Gateway[] { // Every secret that, once set, lets `harness` run: its own credentials, plus // (claude only) every gateway key in the cascade. export function runnableSecrets(harness: HarnessManifest, gateways: Gateway[]): string[] { - const own = harness.credentials.map((c) => c.secret) - const gw = harness.gateways ? gateways.flatMap((g) => g.secrets) : [] + const own = harness.credentials.map(c => c.secret) + const gw = harness.gateways ? gateways.flatMap(g => g.secrets) : [] return [...new Set([...own, ...gw])] } diff --git a/apps/cli/src/mutate.ts b/apps/cli/src/mutate.ts index a59d84f46f3..608e12669df 100644 --- a/apps/cli/src/mutate.ts +++ b/apps/cli/src/mutate.ts @@ -26,23 +26,36 @@ export async function applyConfig(mutate: (raw: string) => string, message: stri // Human/JSON report for a config change. `label` names what happened, e.g. // "enabled heartbeat". export function reportConfig(res: ConfigChange, label: string) { - emit({ label, changed: res.changed, dryRun: res.dryRun, ...(res.sync ? { synced: res.sync.synced, syncError: res.sync.reason } : {}) }, () => { - if (!res.changed) { console.log(c.dim(`no change — ${label} already in that state`)); return } - if (res.dryRun) { - console.log(c.yellow('dry-run: ') + `would ${label}`) - printDiff(res.before, res.after) - return - } - console.log(c.green('✓ ') + label) - printSync(res.sync) - }) + emit( + { + label, + changed: res.changed, + dryRun: res.dryRun, + ...(res.sync ? { synced: res.sync.synced, syncError: res.sync.reason } : {}), + }, + () => { + if (!res.changed) { + console.log(c.dim(`no change — ${label} already in that state`)) + return + } + if (res.dryRun) { + console.log(c.yellow('dry-run: ') + `would ${label}`) + printDiff(res.before, res.after) + return + } + console.log(c.green('✓ ') + label) + printSync(res.sync) + }, + ) } // Compact preview of which aeon.yml lines changed. Localized yaml edits touch a // line or two, so a naive add/remove set is enough — no full LCS needed. export function printDiff(before: string, after: string) { - const b = before.split('\n'), a = after.split('\n') - const bs = new Set(b), as = new Set(a) + const b = before.split('\n'), + a = after.split('\n') + const bs = new Set(b), + as = new Set(a) const removed = b.filter(l => !as.has(l) && l.trim()) const added = a.filter(l => !bs.has(l) && l.trim()) for (const l of removed) console.log(c.red(' - ' + l.trim())) diff --git a/apps/cli/src/output.ts b/apps/cli/src/output.ts index 282f35dcd17..026027e7339 100644 --- a/apps/cli/src/output.ts +++ b/apps/cli/src/output.ts @@ -4,11 +4,17 @@ import { ghAvailable, ghArgsRepo } from '../../dashboard/lib/gh.ts' let jsonMode = false -export function setJsonMode(on: boolean) { jsonMode = on } +export function setJsonMode(on: boolean) { + jsonMode = on +} let dryRun = false -export function setDryRun(on: boolean) { dryRun = on } -export function isDryRun() { return dryRun } +export function setDryRun(on: boolean) { + dryRun = on +} +export function isDryRun() { + return dryRun +} const useColor = process.stdout.isTTY && !process.env.NO_COLOR const wrap = (code: number, s: string) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : s) @@ -37,8 +43,7 @@ export function truncate(s: string, n: number) { } export function table(headers: string[], rows: string[][]) { - const widths = headers.map((h, i) => - Math.max(h.length, ...rows.map(r => stripAnsi(r[i] ?? '').length))) + const widths = headers.map((h, i) => Math.max(h.length, ...rows.map(r => stripAnsi(r[i] ?? '').length))) const pad = (cell: string, i: number) => { const visible = stripAnsi(cell).length return cell + ' '.repeat(Math.max(0, widths[i] - visible)) @@ -80,6 +85,8 @@ export function requireInstanceRepo(): void { const args = ghArgsRepo() const repo = args[1] ?? '' if (repo && isUpstreamRepo(repo)) { - fail(`gh points at ${repo}, the Aeon template - not your instance. Run \`./aeon init\` (or \`gh repo set-default /\`) first.`) + fail( + `gh points at ${repo}, the Aeon template - not your instance. Run \`./aeon init\` (or \`gh repo set-default /\`) first.`, + ) } } diff --git a/apps/dashboard/app/api/auth/route.ts b/apps/dashboard/app/api/auth/route.ts index 13a7cfa572b..0785c2e0e0a 100644 --- a/apps/dashboard/app/api/auth/route.ts +++ b/apps/dashboard/app/api/auth/route.ts @@ -9,11 +9,14 @@ export async function POST(request: Request) { try { const notReady = requireGh() if (notReady) return notReady - const body = await request.json().catch(() => ({})) as { key?: string, baseUrl?: string, provider?: string } + const body = (await request.json().catch(() => ({}))) as { key?: string; baseUrl?: string; provider?: string } return NextResponse.json(await configureAuth(body)) } catch (error: unknown) { const msg = error instanceof Error ? error.message : 'Failed to setup auth' - const status = msg.includes('Base URL') || msg.includes('OAuth tokens') || msg.includes('gateway') || msg.includes('extract') ? 400 : 500 + const status = + msg.includes('Base URL') || msg.includes('OAuth tokens') || msg.includes('gateway') || msg.includes('extract') + ? 400 + : 500 return NextResponse.json({ error: msg }, { status }) } } diff --git a/apps/dashboard/app/api/connect/route.ts b/apps/dashboard/app/api/connect/route.ts index c1b4b0a90e8..4839cd5507b 100644 --- a/apps/dashboard/app/api/connect/route.ts +++ b/apps/dashboard/app/api/connect/route.ts @@ -14,7 +14,8 @@ export async function POST(request: Request) { 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 }) + 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 }) diff --git a/apps/dashboard/app/api/grok-auth/route.ts b/apps/dashboard/app/api/grok-auth/route.ts index 1d7fb3e58ea..3633eba0974 100644 --- a/apps/dashboard/app/api/grok-auth/route.ts +++ b/apps/dashboard/app/api/grok-auth/route.ts @@ -43,7 +43,9 @@ function grokLogin(): Promise { return reject(e) } let buf = '' - const onData = (chunk: Buffer) => { buf += chunk.toString() } + const onData = (chunk: Buffer) => { + buf += chunk.toString() + } child.stdout?.on('data', onData) child.stderr?.on('data', onData) // Read off the whole buffer at failure time rather than latching the first @@ -53,16 +55,18 @@ function grokLogin(): Promise { const timer = setTimeout(() => { child.kill() const url = verifyUrl() - reject(new Error(`Timed out waiting for approval. Approve in the browser and click Connect again.${url ? ` If no tab opened, visit ${url}` : ''}`)) + reject( + new Error( + `Timed out waiting for approval. Approve in the browser and click Connect again.${url ? ` If no tab opened, visit ${url}` : ''}`, + ), + ) }, LOGIN_TIMEOUT_MS) child.on('error', (e: NodeJS.ErrnoException) => { clearTimeout(timer) - reject(e.code === 'ENOENT' - ? new Error('grok CLI not found. Install it: npm i -g @xai-official/grok') - : e) + reject(e.code === 'ENOENT' ? new Error('grok CLI not found. Install it: npm i -g @xai-official/grok') : e) }) - child.on('close', (code) => { + child.on('close', code => { clearTimeout(timer) if (code === 0) resolve() else reject(new Error(`grok login exited ${code}. ${buf.slice(-200)}`)) @@ -90,9 +94,13 @@ export async function POST(request: Request) { await grokLogin() if (!existsSync(join(home, AUTH_FILE))) { - return NextResponse.json({ - error: 'Login completed but no ~/.grok/auth.json was found. Try `grok login` in a terminal, then click Connect again.', - }, { status: 400 }) + return NextResponse.json( + { + error: + 'Login completed but no ~/.grok/auth.json was found. Try `grok login` in a terminal, then click Connect again.', + }, + { status: 400 }, + ) } // tar.gz just the credential (rooted at $HOME so it restores as ~/.grok/auth.json). @@ -106,7 +114,13 @@ export async function POST(request: Request) { // gateway/tweet-skill secret.) const sync = await syncHarness('grok') - return NextResponse.json({ ok: true, method: 'oauth', secret: 'GROK_CREDENTIALS', harness: 'grok', synced: sync.synced }) + return NextResponse.json({ + ok: true, + method: 'oauth', + secret: 'GROK_CREDENTIALS', + harness: 'grok', + synced: sync.synced, + }) } catch (error: unknown) { return errorResponse(error, 'Failed to connect Grok') } diff --git a/apps/dashboard/app/api/harness-auth/route.ts b/apps/dashboard/app/api/harness-auth/route.ts index 998b87350c7..0f7407b4f65 100644 --- a/apps/dashboard/app/api/harness-auth/route.ts +++ b/apps/dashboard/app/api/harness-auth/route.ts @@ -29,7 +29,8 @@ export async function POST(request: Request) { // Path 1 — API key (given, or the only option for pi/vibe). if (key || !spec.oauth) { - if (!spec.apiKey) return NextResponse.json({ error: `${harness} needs its login flow, not a key` }, { status: 400 }) + if (!spec.apiKey) + return NextResponse.json({ error: `${harness} needs its login flow, not a key` }, { status: 400 }) if (!key) return NextResponse.json({ error: `${harness} takes a provider API key` }, { status: 400 }) const { secret } = setHarnessApiKey(harness, key) return NextResponse.json({ ok: true, harness, method: 'api-key', secret }) diff --git a/apps/dashboard/app/api/mcp-auth/callback/route.ts b/apps/dashboard/app/api/mcp-auth/callback/route.ts index 021328d3e5e..b1afc40726c 100644 --- a/apps/dashboard/app/api/mcp-auth/callback/route.ts +++ b/apps/dashboard/app/api/mcp-auth/callback/route.ts @@ -10,7 +10,7 @@ import { pendingFlows } from '@/lib/mcp-oauth-server' // `title`/`detail` can carry attacker-controlled OAuth ?error/?error_description values // (GHSA-gh95-xx4q-qch8) or a token-endpoint's error body, so escape before interpolating. function escapeHtml(s: string): string { - return s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!) + return s.replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!) } function page(title: string, detail = '', status = 200): Response { @@ -35,7 +35,11 @@ export async function GET(request: Request) { const flow = state ? pendingFlows.get(state) : undefined if (!flow) { - return page('Unknown or expired request', 'This authorization request is no longer pending. Start again from the dashboard.', 400) + return page( + 'Unknown or expired request', + 'This authorization request is no longer pending. Start again from the dashboard.', + 400, + ) } // One-shot: remove it and stop the POST's timeout regardless of outcome. pendingFlows.delete(state) diff --git a/apps/dashboard/app/api/mcp-auth/route.ts b/apps/dashboard/app/api/mcp-auth/route.ts index f56fc86bd12..cb6e2328875 100644 --- a/apps/dashboard/app/api/mcp-auth/route.ts +++ b/apps/dashboard/app/api/mcp-auth/route.ts @@ -18,7 +18,11 @@ export async function POST(request: Request) { if (notReady) return notReady const body = (await request.json().catch(() => ({}))) as { - slug?: string; url?: string; name?: string; scopes?: string[]; clientId?: string + slug?: string + url?: string + name?: string + scopes?: string[] + clientId?: string } const slug = (body.slug || '').trim() const url = (body.url || '').trim() @@ -38,10 +42,14 @@ export async function POST(request: Request) { let clientSecret: string | undefined if (!clientId) { if (!disc.metadata.registration_endpoint) { - return NextResponse.json({ - error: 'This server does not support dynamic client registration. ' + - 'Register a client with the provider and retry with its client_id.', - }, { status: 400 }) + return NextResponse.json( + { + error: + 'This server does not support dynamic client registration. ' + + 'Register a client with the provider and retry with its client_id.', + }, + { status: 400 }, + ) } const reg = await registerClient(disc.metadata.registration_endpoint, redirectUri) clientId = reg.client_id @@ -60,13 +68,31 @@ export async function POST(request: Request) { reject(new Error('Timed out waiting for authorization. Approve in the browser, then click Connect again.')) }, OAUTH_TIMEOUT_MS) flow = { - slug, name, url, + slug, + name, + url, tokenEndpoint: disc.metadata.token_endpoint, - clientId, clientSecret, verifier, redirectUri, - resource: disc.resource, resolve, reject, timer, + clientId, + clientSecret, + verifier, + redirectUri, + resource: disc.resource, + resolve, + reject, + timer, } pendingFlows.set(state, flow) - openBrowser(authorizeUrl({ metadata: disc.metadata, clientId, redirectUri, challenge, state, resource: disc.resource, scopes })) + openBrowser( + authorizeUrl({ + metadata: disc.metadata, + clientId, + redirectUri, + challenge, + state, + resource: disc.resource, + scopes, + }), + ) }) // 5. Persist tokens as secrets; hand the server descriptor back for the panel @@ -77,7 +103,12 @@ export async function POST(request: Request) { slug, server, durable, - ...(durable ? {} : { warning: 'No refresh token was granted, so the access token will expire and need reconnecting. The provider may require an offline-access scope.' }), + ...(durable + ? {} + : { + warning: + 'No refresh token was granted, so the access token will expire and need reconnecting. The provider may require an offline-access scope.', + }), }) } catch (error: unknown) { return errorResponse(error, 'Failed to connect the MCP server') diff --git a/apps/dashboard/app/api/memory/issues/[id]/route.ts b/apps/dashboard/app/api/memory/issues/[id]/route.ts index 7d7815f7d1d..282d264bf8f 100644 --- a/apps/dashboard/app/api/memory/issues/[id]/route.ts +++ b/apps/dashboard/app/api/memory/issues/[id]/route.ts @@ -2,18 +2,12 @@ import { NextResponse } from 'next/server' import { readIssue } from '@/lib/memory' import { errorResponse } from '@/lib/http' -export async function GET( - _request: Request, - { params }: { params: Promise<{ id: string }> }, -) { +export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) { try { const { id } = await params const issue = await readIssue(id) if (!issue) { - return NextResponse.json( - { error: `No issue found for id '${id}' (expected format: ISS-NNN)` }, - { status: 404 }, - ) + return NextResponse.json({ error: `No issue found for id '${id}' (expected format: ISS-NNN)` }, { status: 404 }) } return NextResponse.json(issue) } catch (error: unknown) { diff --git a/apps/dashboard/app/api/memory/logs/route.ts b/apps/dashboard/app/api/memory/logs/route.ts index 3815f136386..35e00d69b6f 100644 --- a/apps/dashboard/app/api/memory/logs/route.ts +++ b/apps/dashboard/app/api/memory/logs/route.ts @@ -10,10 +10,7 @@ export async function GET(request: Request) { if (date) { const log = await readLog(date) if (!log) { - return NextResponse.json( - { error: `No log found for ${date} (expected YYYY-MM-DD)` }, - { status: 404 }, - ) + return NextResponse.json({ error: `No log found for ${date} (expected YYYY-MM-DD)` }, { status: 404 }) } return NextResponse.json(log) } diff --git a/apps/dashboard/app/api/memory/route.ts b/apps/dashboard/app/api/memory/route.ts index 3dfb0d4e2d3..9345f720265 100644 --- a/apps/dashboard/app/api/memory/route.ts +++ b/apps/dashboard/app/api/memory/route.ts @@ -12,9 +12,7 @@ export async function GET() { ]) return NextResponse.json({ - memory: memory - ? { exists: true, size: memory.length, excerpt: memory.slice(0, 400) } - : { exists: false }, + memory: memory ? { exists: true, size: memory.length, excerpt: memory.slice(0, 400) } : { exists: false }, counts: { topics: topics.length, logs: logs.length, diff --git a/apps/dashboard/app/api/memory/search/route.ts b/apps/dashboard/app/api/memory/search/route.ts index 1b6eaa81ffe..e0b53ff7b0f 100644 --- a/apps/dashboard/app/api/memory/search/route.ts +++ b/apps/dashboard/app/api/memory/search/route.ts @@ -9,10 +9,7 @@ export async function GET(request: Request) { const url = new URL(request.url) const q = (url.searchParams.get('q') ?? '').trim() if (!q) { - return NextResponse.json( - { error: 'Missing required query parameter: q' }, - { status: 400 }, - ) + return NextResponse.json({ error: 'Missing required query parameter: q' }, { status: 400 }) } const limitRaw = url.searchParams.get('limit') diff --git a/apps/dashboard/app/api/memory/topics/[slug]/route.ts b/apps/dashboard/app/api/memory/topics/[slug]/route.ts index 23739da8170..82baaff6691 100644 --- a/apps/dashboard/app/api/memory/topics/[slug]/route.ts +++ b/apps/dashboard/app/api/memory/topics/[slug]/route.ts @@ -2,18 +2,12 @@ import { NextResponse } from 'next/server' import { readTopic } from '@/lib/memory' import { errorResponse } from '@/lib/http' -export async function GET( - _request: Request, - { params }: { params: Promise<{ slug: string }> }, -) { +export async function GET(_request: Request, { params }: { params: Promise<{ slug: string }> }) { try { const { slug } = await params const topic = await readTopic(slug) if (!topic) { - return NextResponse.json( - { error: `No topic found for slug '${slug}'` }, - { status: 404 }, - ) + return NextResponse.json({ error: `No topic found for slug '${slug}'` }, { status: 404 }) } return NextResponse.json(topic) } catch (error: unknown) { diff --git a/apps/dashboard/app/api/observability/route.ts b/apps/dashboard/app/api/observability/route.ts index 27492abd28e..146eb2f3060 100644 --- a/apps/dashboard/app/api/observability/route.ts +++ b/apps/dashboard/app/api/observability/route.ts @@ -27,7 +27,9 @@ function readVar(name: string): string | null { 'gh', ['variable', 'list', ...ghArgsRepo(), '--json', 'name,value', '-q', `.[] | select(.name=="${name}") | .value`], { stdio: 'pipe', cwd: process.cwd() }, - ).toString().trim() + ) + .toString() + .trim() return out || null } catch { return null @@ -44,7 +46,7 @@ export async function GET() { export async function POST(request: Request) { const notReady = requireGh() if (notReady) return notReady - const body = await request.json().catch(() => ({})) as { region?: string } + const body = (await request.json().catch(() => ({}))) as { region?: string } const region: Region | null = body.region === 'us' ? 'us' : body.region === 'eu' ? 'eu' : null if (!region) { return NextResponse.json({ error: "region must be 'eu' or 'us'" }, { status: 400 }) diff --git a/apps/dashboard/app/api/onboarding/route.ts b/apps/dashboard/app/api/onboarding/route.ts index b17ede758c0..c10352555eb 100644 --- a/apps/dashboard/app/api/onboarding/route.ts +++ b/apps/dashboard/app/api/onboarding/route.ts @@ -14,9 +14,17 @@ export async function GET() { 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() + 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 */ } + } 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 index 4e8853f1780..e7c0ba71145 100644 --- a/apps/dashboard/app/api/openrouter-auth/callback/route.ts +++ b/apps/dashboard/app/api/openrouter-auth/callback/route.ts @@ -10,7 +10,7 @@ import { setSecret } from '@/lib/secrets-catalog' // on 127.0.0.1 while this page loads on localhost. const escapeHtml = (s: string) => - s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!) + 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(/ { - await setSecret('OPENROUTER_API_KEY', key) - return 'OPENROUTER_API_KEY' + 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/outputs/route.ts b/apps/dashboard/app/api/outputs/route.ts index 537367b5e94..c5fa2228c71 100644 --- a/apps/dashboard/app/api/outputs/route.ts +++ b/apps/dashboard/app/api/outputs/route.ts @@ -27,15 +27,17 @@ function fileTsToIso(ts: string): string { export async function GET() { try { const files = await readdir(OUTPUTS_DIR).catch(() => [] as string[]) - const jsonFiles = files.filter(f => f.endsWith('.json')).sort((a, b) => { - // Extract timestamp from filename: -.json - const tsA = a.match(/(\d{4}-\d{2}-\d{2}T[\d-]+Z)\.json$/)?.[1] || '' - const tsB = b.match(/(\d{4}-\d{2}-\d{2}T[\d-]+Z)\.json$/)?.[1] || '' - return tsB.localeCompare(tsA) // newest first - }) + const jsonFiles = files + .filter(f => f.endsWith('.json')) + .sort((a, b) => { + // Extract timestamp from filename: -.json + const tsA = a.match(/(\d{4}-\d{2}-\d{2}T[\d-]+Z)\.json$/)?.[1] || '' + const tsB = b.match(/(\d{4}-\d{2}-\d{2}T[\d-]+Z)\.json$/)?.[1] || '' + return tsB.localeCompare(tsA) // newest first + }) const outputs = await Promise.all( - jsonFiles.slice(0, 100).map(async (filename) => { + jsonFiles.slice(0, 100).map(async filename => { try { const raw = await readFile(join(OUTPUTS_DIR, filename), 'utf-8') const spec = JSON.parse(raw) as unknown @@ -54,7 +56,7 @@ export async function GET() { } catch { return null } - }) + }), ) return NextResponse.json({ outputs: outputs.filter(Boolean) }) diff --git a/apps/dashboard/app/api/runs/[id]/diagnosis/route.ts b/apps/dashboard/app/api/runs/[id]/diagnosis/route.ts index 06d21c502a2..f4a794882f0 100644 --- a/apps/dashboard/app/api/runs/[id]/diagnosis/route.ts +++ b/apps/dashboard/app/api/runs/[id]/diagnosis/route.ts @@ -6,10 +6,7 @@ import { readRunDiagnosis } from '@/lib/run-diagnosis-server' // Why a failed or timed-out run ended that way, in plain words, with the next // step (lib/run-diagnosis.ts). HQ asks only when a run is opened. // GET -> { diagnosis } (null while running, or when the run did not fail) -export async function GET( - _request: Request, - { params }: { params: Promise<{ id: string }> }, -) { +export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) { try { const notReady = requireGh() if (notReady) return notReady diff --git a/apps/dashboard/app/api/runs/[id]/logs/route.ts b/apps/dashboard/app/api/runs/[id]/logs/route.ts index e83352d6dab..6cb77f7d2a8 100644 --- a/apps/dashboard/app/api/runs/[id]/logs/route.ts +++ b/apps/dashboard/app/api/runs/[id]/logs/route.ts @@ -4,10 +4,7 @@ import { getRunLogs } from '@/lib/runs' // The gh log-fetch, Run-step extraction, and Summary parsing live in lib/runs.ts // so `aeon runs logs ` and this route return identical data. -export async function GET( - _request: Request, - { params }: { params: Promise<{ id: string }> }, -) { +export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) { try { const { id } = await params if (!/^\d+$/.test(id)) { diff --git a/apps/dashboard/app/api/secrets/route.ts b/apps/dashboard/app/api/secrets/route.ts index bd8c9ef4949..2bd7cf082e7 100644 --- a/apps/dashboard/app/api/secrets/route.ts +++ b/apps/dashboard/app/api/secrets/route.ts @@ -11,10 +11,13 @@ export async function GET() { try { const { secrets, ghReady } = getSecrets() if (!ghReady) { - return NextResponse.json({ - error: 'GitHub CLI not authenticated. Run: gh auth login', - ghReady: false, - }, { status: 503 }) + return NextResponse.json( + { + error: 'GitHub CLI not authenticated. Run: gh auth login', + ghReady: false, + }, + { status: 503 }, + ) } return NextResponse.json({ secrets, ghReady: true }) } catch (error: unknown) { @@ -26,7 +29,7 @@ export async function POST(request: Request) { const notReady = requireGh() if (notReady) return notReady - const { name, value } = await request.json() as { name?: string; value?: string } + const { name, value } = (await request.json()) as { name?: string; value?: string } if (!name || !value) { return NextResponse.json({ error: 'name and value required' }, { status: 400 }) @@ -47,7 +50,7 @@ export async function DELETE(request: Request) { const notReady = requireGh() if (notReady) return notReady - const { name } = await request.json() as { name?: string } + const { name } = (await request.json()) as { name?: string } if (!name || !VALID_SECRET_NAME.test(name)) { return NextResponse.json({ error: 'Invalid secret name' }, { status: 400 }) diff --git a/apps/dashboard/app/api/skills/[name]/run/route.ts b/apps/dashboard/app/api/skills/[name]/run/route.ts index 5f31856891e..9396a3f5fa9 100644 --- a/apps/dashboard/app/api/skills/[name]/run/route.ts +++ b/apps/dashboard/app/api/skills/[name]/run/route.ts @@ -5,20 +5,19 @@ import { runSkill } from '@/lib/run-skill' // The name validation, var/model sanitization, install-skill PR-permission // guarantee, and gh dispatch live in lib/run-skill.ts so `aeon skills run` and // this route dispatch identically. -export async function POST( - request: Request, - { params }: { params: Promise<{ name: string }> }, -) { +export async function POST(request: Request, { params }: { params: Promise<{ name: string }> }) { try { const { name } = await params let skillVar = '' let model = '' try { - const body = await request.json() as { var?: string; model?: string } + const body = (await request.json()) as { var?: string; model?: string } if (typeof body.var === 'string') skillVar = body.var if (typeof body.model === 'string') model = body.model - } catch { /* no body is fine */ } + } catch { + /* no body is fine */ + } runSkill(name, { var: skillVar, model }) return NextResponse.json({ ok: true }) diff --git a/apps/dashboard/app/api/skills/route.ts b/apps/dashboard/app/api/skills/route.ts index 2c59b5b515a..9e9fea7f2d2 100644 --- a/apps/dashboard/app/api/skills/route.ts +++ b/apps/dashboard/app/api/skills/route.ts @@ -24,7 +24,27 @@ export async function GET() { export async function PATCH(request: Request) { try { - const { name, enabled, schedule, var: skillVar, model, skillModel, harness, skillHarness, jsonrenderEnabled } = await request.json() as { name?: string; enabled?: boolean; schedule?: string; var?: string; model?: string; skillModel?: string; harness?: string; skillHarness?: string; jsonrenderEnabled?: boolean } + const { + name, + enabled, + schedule, + var: skillVar, + model, + skillModel, + harness, + skillHarness, + jsonrenderEnabled, + } = (await request.json()) as { + name?: string + enabled?: boolean + schedule?: string + var?: string + model?: string + skillModel?: string + harness?: string + skillHarness?: string + jsonrenderEnabled?: boolean + } const sync = await withFileLock('aeon.yml', async () => { const { content, sha } = await getFileContent('aeon.yml') @@ -43,7 +63,14 @@ export async function PATCH(request: Request) { updated = updateHarnessInConfig(updated, harness as Harness) } - if (name && (typeof enabled === 'boolean' || typeof schedule === 'string' || typeof skillVar === 'string' || typeof skillModel === 'string' || typeof skillHarness === 'string')) { + if ( + name && + (typeof enabled === 'boolean' || + typeof schedule === 'string' || + typeof skillVar === 'string' || + typeof skillModel === 'string' || + typeof skillHarness === 'string') + ) { // upsert, not update: the skill list is built from disk, so the UI can // toggle a skill that has no aeon.yml entry yet (a freshly added // SKILL.md). A plain update would silently no-op on it. @@ -76,7 +103,7 @@ export async function PATCH(request: Request) { export async function DELETE(request: Request) { try { - const { name } = await request.json() as { name?: string } + const { name } = (await request.json()) as { name?: string } if (!name || !/^[a-z][a-z0-9-]*$/.test(name)) { return NextResponse.json({ error: 'Invalid skill name' }, { status: 400 }) } diff --git a/apps/dashboard/app/api/soul/examples/route.ts b/apps/dashboard/app/api/soul/examples/route.ts index b4f3991af95..15621085277 100644 --- a/apps/dashboard/app/api/soul/examples/route.ts +++ b/apps/dashboard/app/api/soul/examples/route.ts @@ -48,8 +48,14 @@ export async function POST(request: Request) { const paths = ['soul/SOUL.md'] // createFile overwrites-or-creates in both local and hosted modes. await createFile('soul/SOUL.md', soul, msg) - if (style) { await createFile('soul/STYLE.md', style, msg); paths.push('soul/STYLE.md') } - if (good) { await createFile('soul/examples/good-outputs.md', good, msg); paths.push('soul/examples/good-outputs.md') } + if (style) { + await createFile('soul/STYLE.md', style, msg) + paths.push('soul/STYLE.md') + } + if (good) { + await createFile('soul/examples/good-outputs.md', good, msg) + paths.push('soul/examples/good-outputs.md') + } const sync = commitAndPush(paths, msg) return NextResponse.json({ ok: true, soul, style: style || '', ...syncFields(sync) }) diff --git a/apps/dashboard/app/api/telegram/link/check/route.ts b/apps/dashboard/app/api/telegram/link/check/route.ts index 94837faab67..bfba16ee98d 100644 --- a/apps/dashboard/app/api/telegram/link/check/route.ts +++ b/apps/dashboard/app/api/telegram/link/check/route.ts @@ -26,7 +26,10 @@ export async function POST(request: Request) { 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.' }), + body: JSON.stringify({ + chat_id: result.chatId, + text: 'Aeon is linked to this chat. Notifications will arrive here.', + }), }).catch(() => {}) } return NextResponse.json(result) diff --git a/apps/dashboard/app/api/upload/route.ts b/apps/dashboard/app/api/upload/route.ts index e6702ab3dde..ab74bd056a9 100644 --- a/apps/dashboard/app/api/upload/route.ts +++ b/apps/dashboard/app/api/upload/route.ts @@ -46,8 +46,8 @@ function sanitizeRelativePath(relativePath: string): string | null { if (!relativePath || relativePath.startsWith('/') || relativePath.includes('..')) { return null } - const parts = relativePath.split('/').filter((p) => p && p !== '.') - if (parts.some((p) => p === '..')) return null + const parts = relativePath.split('/').filter(p => p && p !== '.') + if (parts.some(p => p === '..')) return null return parts.join('/') } @@ -61,11 +61,12 @@ function assertSkillDestPath(skillName: string, relativePath: string): string | function deriveSkillName(files: UploadFile[]): { name: string; prefix: string } { // First try SKILL.md - const skillFile = files.find(f => - f.path === 'SKILL.md' || - f.path.endsWith('/SKILL.md') || - f.path.toLowerCase() === 'skill.md' || - f.path.toLowerCase().endsWith('/skill.md') + const skillFile = files.find( + f => + f.path === 'SKILL.md' || + f.path.endsWith('/SKILL.md') || + f.path.toLowerCase() === 'skill.md' || + f.path.toLowerCase().endsWith('/skill.md'), ) // Then try *.skill files @@ -111,7 +112,7 @@ function deriveSkillName(files: UploadFile[]): { name: string; prefix: string } export async function POST(request: Request) { try { - const body = await request.json() as unknown + const body = (await request.json()) as unknown const rawFiles = isRecord(body) && Array.isArray(body.files) ? body.files : [] const overrideName = isRecord(body) && typeof body.name === 'string' ? body.name : undefined // Optional pack category - injected into the uploaded SKILL.md frontmatter so @@ -130,18 +131,24 @@ export async function POST(request: Request) { const hasSkillFile = files.some(f => isSkillFile(f.path)) if (!hasSkillFile) { - return NextResponse.json({ - error: 'No SKILL.md or .skill file found.', - }, { status: 400 }) + return NextResponse.json( + { + error: 'No SKILL.md or .skill file found.', + }, + { status: 400 }, + ) } const { name: derivedName, prefix } = deriveSkillName(files) const skillName = slugify(overrideName?.trim() ?? '') || derivedName if (!skillName) { - return NextResponse.json({ - error: 'Could not determine skill name. Please provide a name.', - }, { status: 400 }) + return NextResponse.json( + { + error: 'Could not determine skill name. Please provide a name.', + }, + { status: 400 }, + ) } let filesWritten = 0 @@ -166,15 +173,10 @@ export async function POST(request: Request) { } // Stamp the chosen category onto the skill's SKILL.md frontmatter. - const content = (category && relativePath === 'SKILL.md') - ? setFrontmatterCategory(file.content, category) - : file.content - - await createFile( - destPath, - content, - `feat: upload ${skillName} skill`, - ) + const content = + category && relativePath === 'SKILL.md' ? setFrontmatterCategory(file.content, category) : file.content + + await createFile(destPath, content, `feat: upload ${skillName} skill`) filesWritten++ } diff --git a/apps/dashboard/app/page.tsx b/apps/dashboard/app/page.tsx index c5e8396e2d2..84664949f87 100644 --- a/apps/dashboard/app/page.tsx +++ b/apps/dashboard/app/page.tsx @@ -2,14 +2,42 @@ import { useState, useEffect, useCallback, useRef } from 'react' import type { - Skill, Run, Secret, SkillOutput, GatewayProvider, Harness, UploadFile, AnalyticsData, - SkillsResponse, RunsResponse, SecretsResponse, SyncStatusResponse, McpResponse, - OutputsResponse, StrategyResponse, SoulResponse, SyncResult, SoulExampleResponse, - UploadResponse, ErrorResponse, PacksResponse, McpServers, SoulSources, StrategySources, + Skill, + Run, + Secret, + SkillOutput, + GatewayProvider, + Harness, + UploadFile, + AnalyticsData, + SkillsResponse, + RunsResponse, + SecretsResponse, + SyncStatusResponse, + McpResponse, + OutputsResponse, + StrategyResponse, + SoulResponse, + SyncResult, + SoulExampleResponse, + UploadResponse, + ErrorResponse, + PacksResponse, + McpServers, + SoulSources, + StrategySources, DashboardView, } from '../lib/types' import { getJson, postJson, putJson, patchJson, del, scheduleRunRefresh } from '../lib/api-client' -import { MODELS, authSecretsForHarness, PACK_BY_KEY, FIRST_PARTY_KEYS, DEFAULT_VISIBLE_PACKS, HARNESSES, modelsForHarness } from '../lib/constants' +import { + MODELS, + authSecretsForHarness, + PACK_BY_KEY, + FIRST_PARTY_KEYS, + DEFAULT_VISIBLE_PACKS, + HARNESSES, + modelsForHarness, +} from '../lib/constants' import { displayName, lastSuccessfulRunId } from '../lib/utils' import TargetCursor from '../components/ui/TargetCursor' import { LoadingScreen } from '../components/LoadingScreen' @@ -31,14 +59,13 @@ import { ConnectModal, type SavedCredential } from '../components/ConnectModal' import { OnboardingChecklist, type NotifyChannel } from '../components/OnboardingChecklist' import { PanelError } from '../components/PanelError' - // The secret each notification channel's setup starts at: the first one of // its pair that is still missing (see ./notify's opt-in secrets). const NOTIFY_FIRST_SECRET: Record boolean) => string> = { - telegram: (isSet) => (isSet('TELEGRAM_BOT_TOKEN') ? 'TELEGRAM_CHAT_ID' : 'TELEGRAM_BOT_TOKEN'), + telegram: isSet => (isSet('TELEGRAM_BOT_TOKEN') ? 'TELEGRAM_CHAT_ID' : 'TELEGRAM_BOT_TOKEN'), discord: () => 'DISCORD_WEBHOOK_URL', slack: () => 'SLACK_WEBHOOK_URL', - email: (isSet) => (isSet('RESEND_API_KEY') ? 'NOTIFY_EMAIL_TO' : 'RESEND_API_KEY'), + email: isSet => (isSet('RESEND_API_KEY') ? 'NOTIFY_EMAIL_TO' : 'RESEND_API_KEY'), } export default function Dashboard() { @@ -113,7 +140,10 @@ export default function Dashboard() { const [soulBuilding, setSoulBuilding] = useState(false) const [soulInstalling, setSoulInstalling] = useState(null) - const flash = (msg: string) => { setToast(msg); setTimeout(() => setToast(''), 3000) } + const flash = (msg: string) => { + setToast(msg) + setTimeout(() => setToast(''), 3000) + } // Config writes auto-commit+push in local mode (no-op in hosted mode). Reflect // the result: clear the "needs Sync" nudge on success, raise it only if the // push failed (e.g. behind origin/main → resolve via the manual Sync button). @@ -125,18 +155,70 @@ export default function Dashboard() { // --- API --- const fetchData = useCallback(async () => { - try { const [sr, rr, secr] = await Promise.all([fetch('/api/skills'), fetch('/api/runs'), fetch('/api/secrets')]); if (sr.ok) { const d = await sr.json() as SkillsResponse; setSkills(d.skills); if (d.model) setModel(d.model); if (d.harness) setHarness(d.harness); if (d.gateway?.provider) setGateway(d.gateway.provider); if (d.repo) setRepo(d.repo) }; if (rr.ok) setRuns((await rr.json() as RunsResponse).runs); if (secr.ok) { const d = await secr.json() as SecretsResponse; if (d.secrets) { setSecrets(d.secrets); setSecretsReadOk(true) } } else { setSecretsReadOk(false) } } catch (e: unknown) { setError(e instanceof Error ? e.message : 'Failed to connect') } finally { setLoading(false) } - try { const r = await fetch('/api/sync'); if (r.ok) { const d = await r.json() as SyncStatusResponse; setHasChanges(d.hasChanges); if (typeof d.behind === 'number') setBehind(d.behind) } } catch {} + try { + const [sr, rr, secr] = await Promise.all([fetch('/api/skills'), fetch('/api/runs'), fetch('/api/secrets')]) + if (sr.ok) { + const d = (await sr.json()) as SkillsResponse + setSkills(d.skills) + if (d.model) setModel(d.model) + if (d.harness) setHarness(d.harness) + if (d.gateway?.provider) setGateway(d.gateway.provider) + if (d.repo) setRepo(d.repo) + } + if (rr.ok) setRuns(((await rr.json()) as RunsResponse).runs) + if (secr.ok) { + const d = (await secr.json()) as SecretsResponse + if (d.secrets) { + setSecrets(d.secrets) + setSecretsReadOk(true) + } + } else { + setSecretsReadOk(false) + } + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'Failed to connect') + } finally { + setLoading(false) + } + try { + const r = await fetch('/api/sync') + if (r.ok) { + const d = (await r.json()) as SyncStatusResponse + setHasChanges(d.hasChanges) + if (typeof d.behind === 'number') setBehind(d.behind) + } + } catch {} // Preload MCP servers so each skill's "MCP servers" panel can show install state. - try { const r = await fetch('/api/mcp'); if (r.ok) { const d = await r.json() as McpResponse; setMcpServers(d.servers || {}); setMcpLoaded(true) } } catch {} + try { + const r = await fetch('/api/mcp') + if (r.ok) { + const d = (await r.json()) as McpResponse + setMcpServers(d.servers || {}) + setMcpLoaded(true) + } + } catch {} + }, []) + const refreshRuns = useCallback(async () => { + try { + const r = await fetch('/api/runs') + if (r.ok) setRuns(((await r.json()) as RunsResponse).runs) + } catch {} }, []) - const refreshRuns = useCallback(async () => { try { const r = await fetch('/api/runs'); if (r.ok) setRuns((await r.json() as RunsResponse).runs) } catch {} }, []) // Optimistically mark a just-saved secret as set. We know it's set (the write // succeeded), so reflect it even before the follow-up /api/secrets read lands - // and flag the vault readable so the run-gate stops blaming a missing key when // that read is flaky (the GitHub-503 case behind #878). - const markSecretSet = (name: string, group = 'Skill Keys') => { setSecrets(s => s.some(x => x.name === name) ? s.map(x => x.name === name ? { ...x, isSet: true } : x) : [...s, { name, group, description: 'Custom', isSet: true }]); setSecretsReadOk(true) } - useEffect(() => { fetchData() }, [fetchData]) + const markSecretSet = (name: string, group = 'Skill Keys') => { + setSecrets(s => + s.some(x => x.name === name) + ? s.map(x => (x.name === name ? { ...x, isSet: true } : x)) + : [...s, { name, group, description: 'Custom', isSet: true }], + ) + setSecretsReadOk(true) + } + useEffect(() => { + fetchData() + }, [fetchData]) // Restore the operator's enabled-pack selection from a prior visit, scoped to // THIS repo (Core is always on). Per-repo keying so testing multiple forks on // the same localhost doesn't bleed one fork's pack selection into another — a @@ -148,11 +230,17 @@ export default function Dashboard() { let saved: string[] = [] try { const raw = localStorage.getItem(`aeon.enabledPacks:${repo}`) - if (raw) { const arr: unknown = JSON.parse(raw); if (Array.isArray(arr)) saved = arr.filter((k: unknown): k is string => typeof k === 'string') } + if (raw) { + const arr: unknown = JSON.parse(raw) + if (Array.isArray(arr)) saved = arr.filter((k: unknown): k is string => typeof k === 'string') + } } catch {} setEnabledPacks(Array.from(new Set([...DEFAULT_VISIBLE_PACKS, ...saved]))) }, [repo]) - useEffect(() => { const id = setInterval(refreshRuns, 10_000); return () => clearInterval(id) }, [refreshRuns]) + useEffect(() => { + const id = setInterval(refreshRuns, 10_000) + return () => clearInterval(id) + }, [refreshRuns]) // A builder run commits STRATEGY.md / soul/ on GitHub. Once a new one // succeeds, pull it into the local checkout (local mode reads these files from // disk, same as the Pull button) and reload that editor so it stops showing the @@ -167,50 +255,295 @@ export default function Dashboard() { const soulChanged = seen.soul !== undefined && soulBuildId !== null && soulBuildId !== seen.soul seenBuild.current = { strategy: strategyBuildId, soul: soulBuildId } if (!strategyChanged && !soulChanged) return - postJson('/api/outputs').catch(() => {}).finally(() => { - if (strategyChanged) setStrategyLoaded(false) - if (soulChanged) setSoulLoaded(false) - }) + postJson('/api/outputs') + .catch(() => {}) + .finally(() => { + if (strategyChanged) setStrategyLoaded(false) + if (soulChanged) setSoulLoaded(false) + }) }, [loading, strategyBuildId, soulBuildId]) - useEffect(() => { setFeedLoading(true); setFeedError(false); getJson('/api/outputs').then(d => setOutputs(d.outputs || [])).catch(() => setFeedError(true)).finally(() => setFeedLoading(false)) }, [feedKey]) - useEffect(() => { if (view === 'strategy' && !strategyLoaded) { getJson('/api/strategy').then(d => { setStrategy(d.content || ''); setStrategyLoaded(true) }).catch(() => { setStrategyError(true); setStrategyLoaded(true) }) } }, [view, strategyLoaded]) - useEffect(() => { if (view === 'mcp' && !mcpLoaded) { getJson('/api/mcp').then(d => { setMcpServers(d.servers || {}); setMcpLoaded(true) }).catch(() => { setMcpError(true); setMcpLoaded(true) }) } }, [view, mcpLoaded]) - useEffect(() => { if (view === 'soul' && !soulLoaded) { getJson('/api/soul').then(d => { setSoul(d.soul?.content || ''); setSoulStyle(d.style?.content || ''); setSoulLoaded(true) }).catch(() => { setSoulError(true); setSoulLoaded(true) }) } }, [view, soulLoaded]) - useEffect(() => { if (view === 'packs' && !packsLoaded) { getJson('/api/packs').then(d => { setPacks(d); setPacksLoaded(true) }).catch(() => { setPacksError(true); setPacksLoaded(true) }) } }, [view, packsLoaded]) + useEffect(() => { + setFeedLoading(true) + setFeedError(false) + getJson('/api/outputs') + .then(d => setOutputs(d.outputs || [])) + .catch(() => setFeedError(true)) + .finally(() => setFeedLoading(false)) + }, [feedKey]) + useEffect(() => { + if (view === 'strategy' && !strategyLoaded) { + getJson('/api/strategy') + .then(d => { + setStrategy(d.content || '') + setStrategyLoaded(true) + }) + .catch(() => { + setStrategyError(true) + setStrategyLoaded(true) + }) + } + }, [view, strategyLoaded]) + useEffect(() => { + if (view === 'mcp' && !mcpLoaded) { + getJson('/api/mcp') + .then(d => { + setMcpServers(d.servers || {}) + setMcpLoaded(true) + }) + .catch(() => { + setMcpError(true) + setMcpLoaded(true) + }) + } + }, [view, mcpLoaded]) + useEffect(() => { + if (view === 'soul' && !soulLoaded) { + getJson('/api/soul') + .then(d => { + setSoul(d.soul?.content || '') + setSoulStyle(d.style?.content || '') + setSoulLoaded(true) + }) + .catch(() => { + setSoulError(true) + setSoulLoaded(true) + }) + } + }, [view, soulLoaded]) + useEffect(() => { + if (view === 'packs' && !packsLoaded) { + getJson('/api/packs') + .then(d => { + setPacks(d) + setPacksLoaded(true) + }) + .catch(() => { + setPacksError(true) + setPacksLoaded(true) + }) + } + }, [view, packsLoaded]) // Reset the main content scroll to the top whenever the active view or the // selected skill changes, so each screen (Soul, Strategy, a skill, …) opens at the top. - useEffect(() => { mainScrollRef.current?.scrollTo({ top: 0 }) }, [view, selectedSkill]) + useEffect(() => { + mainScrollRef.current?.scrollTo({ top: 0 }) + }, [view, selectedSkill]) - const toggleSkill = async (n: string, en: boolean) => { setBusy(b => ({ ...b, [n]: true })); try { const { ok, data } = await patchJson('/api/skills', { name: n, enabled: en }); if (ok) { setSkills(s => s.map(sk => sk.name === n ? { ...sk, enabled: en } : sk)); flashSynced(`${displayName(n)} ${en ? 'enabled' : 'disabled'}`, data) } else { flash(`${displayName(n)} update failed`) } } catch { flash('Network error') } finally { setBusy(b => ({ ...b, [n]: false })) } } - const runSkill = async (n: string, v?: string, sm?: string) => { if (!secrets.some(s => s.isSet && authSecretsForHarness(harness).includes(s.name))) { flash(secretsReadOk ? 'No model connected yet - connect a model before running skills' : "Couldn't read repo secrets - GitHub API error or gh not authenticated. Retry, or run `gh auth login`."); return } setBusy(b => ({ ...b, [`r-${n}`]: true })); try { const { ok, data } = await postJson(`/api/skills/${n}/run`, { var: v || '', model: sm || model }); if (ok) { flash(`${displayName(n)} started`); scheduleRunRefresh(refreshRuns) } else { flash(data.error || 'Failed') } } finally { setBusy(b => ({ ...b, [`r-${n}`]: false })) } } - const updateSchedule = async (n: string, s: string) => { try { const { ok, data } = await patchJson('/api/skills', { name: n, schedule: s }); if (ok) { setSkills(sk => sk.map(x => x.name === n ? { ...x, schedule: s } : x)); flashSynced('Schedule updated', data) } } catch { flash('Network error') } } - const updateVar = async (n: string, v: string) => { try { const { ok, data } = await patchJson('/api/skills', { name: n, var: v }); if (ok) { setSkills(s => s.map(x => x.name === n ? { ...x, var: v } : x)); flashSynced('Brief updated', data) } } catch { flash('Network error') } } - const updateSkillModel = async (n: string, m: string) => { try { const { ok, data } = await patchJson('/api/skills', { name: n, skillModel: m }); if (ok) { setSkills(s => s.map(x => x.name === n ? { ...x, model: m } : x)); flashSynced('Capability updated', data) } } catch { flash('Network error') } } - const updateModel = async (m: string) => { setModel(m); try { const { data } = await patchJson('/api/skills', { model: m }); flashSynced(`Default: ${modelsForHarness(harness).find(x => x.id === m)?.label || m}`, data) } catch { flash('Network error') } } + const toggleSkill = async (n: string, en: boolean) => { + setBusy(b => ({ ...b, [n]: true })) + try { + const { ok, data } = await patchJson('/api/skills', { name: n, enabled: en }) + if (ok) { + setSkills(s => s.map(sk => (sk.name === n ? { ...sk, enabled: en } : sk))) + flashSynced(`${displayName(n)} ${en ? 'enabled' : 'disabled'}`, data) + } else { + flash(`${displayName(n)} update failed`) + } + } catch { + flash('Network error') + } finally { + setBusy(b => ({ ...b, [n]: false })) + } + } + const runSkill = async (n: string, v?: string, sm?: string) => { + if (!secrets.some(s => s.isSet && authSecretsForHarness(harness).includes(s.name))) { + flash( + secretsReadOk + ? 'No model connected yet - connect a model before running skills' + : "Couldn't read repo secrets - GitHub API error or gh not authenticated. Retry, or run `gh auth login`.", + ) + return + } + setBusy(b => ({ ...b, [`r-${n}`]: true })) + try { + const { ok, data } = await postJson(`/api/skills/${n}/run`, { var: v || '', model: sm || model }) + if (ok) { + flash(`${displayName(n)} started`) + scheduleRunRefresh(refreshRuns) + } else { + flash(data.error || 'Failed') + } + } finally { + setBusy(b => ({ ...b, [`r-${n}`]: false })) + } + } + const updateSchedule = async (n: string, s: string) => { + try { + const { ok, data } = await patchJson('/api/skills', { name: n, schedule: s }) + if (ok) { + setSkills(sk => sk.map(x => (x.name === n ? { ...x, schedule: s } : x))) + flashSynced('Schedule updated', data) + } + } catch { + flash('Network error') + } + } + const updateVar = async (n: string, v: string) => { + try { + const { ok, data } = await patchJson('/api/skills', { name: n, var: v }) + if (ok) { + setSkills(s => s.map(x => (x.name === n ? { ...x, var: v } : x))) + flashSynced('Brief updated', data) + } + } catch { + flash('Network error') + } + } + const updateSkillModel = async (n: string, m: string) => { + try { + const { ok, data } = await patchJson('/api/skills', { name: n, skillModel: m }) + if (ok) { + setSkills(s => s.map(x => (x.name === n ? { ...x, model: m } : x))) + flashSynced('Capability updated', data) + } + } catch { + flash('Network error') + } + } + const updateModel = async (m: string) => { + setModel(m) + try { + const { data } = await patchJson('/api/skills', { model: m }) + flashSynced(`Default: ${modelsForHarness(harness).find(x => x.id === m)?.label || m}`, data) + } catch { + flash('Network error') + } + } // Switch the agent harness. If the current model doesn't belong to the new // harness's model set, snap it to that harness's default so the picker + runs // stay coherent (persisted in the same PATCH round-trip). - const updateHarness = async (h: string) => { const hh: Harness = HARNESSES.find(x => x.id === h)?.id ?? 'claude'; setHarness(hh); const list = modelsForHarness(hh); const nextModel = list.some(x => x.id === model) ? undefined : list[0]?.id; if (nextModel) setModel(nextModel); try { const { data } = await patchJson('/api/skills', { harness: hh, ...(nextModel ? { model: nextModel } : {}) }); flashSynced(`Harness: ${HARNESSES.find(x => x.id === hh)?.label || hh}`, data) } catch { flash('Network error') } } - const deleteSkill = async (n: string) => { setBusy(b => ({ ...b, [`d-${n}`]: true })); try { const { ok, data } = await del('/api/skills', { name: n }); if (ok) { setSkills(s => s.filter(x => x.name !== n)); setSelectedSkill(null); flashSynced(`${displayName(n)} removed`, data) } else { flash(`${displayName(n)} removal failed`) } } catch { flash('Network error') } finally { setBusy(b => ({ ...b, [`d-${n}`]: false })) } } - const syncToGithub = async () => { setSyncing(true); try { const { ok } = await postJson('/api/sync'); if (ok) { flash('Synced'); setHasChanges(false) } else { flash('Sync failed') } } catch { flash('Network error') } finally { setSyncing(false) } } + const updateHarness = async (h: string) => { + const hh: Harness = HARNESSES.find(x => x.id === h)?.id ?? 'claude' + setHarness(hh) + const list = modelsForHarness(hh) + const nextModel = list.some(x => x.id === model) ? undefined : list[0]?.id + if (nextModel) setModel(nextModel) + try { + const { data } = await patchJson('/api/skills', { + harness: hh, + ...(nextModel ? { model: nextModel } : {}), + }) + flashSynced(`Harness: ${HARNESSES.find(x => x.id === hh)?.label || hh}`, data) + } catch { + flash('Network error') + } + } + const deleteSkill = async (n: string) => { + setBusy(b => ({ ...b, [`d-${n}`]: true })) + try { + const { ok, data } = await del('/api/skills', { name: n }) + if (ok) { + setSkills(s => s.filter(x => x.name !== n)) + setSelectedSkill(null) + flashSynced(`${displayName(n)} removed`, data) + } else { + flash(`${displayName(n)} removal failed`) + } + } catch { + flash('Network error') + } finally { + setBusy(b => ({ ...b, [`d-${n}`]: false })) + } + } + const syncToGithub = async () => { + setSyncing(true) + try { + const { ok } = await postJson('/api/sync') + if (ok) { + flash('Synced') + setHasChanges(false) + } else { + flash('Sync failed') + } + } catch { + flash('Network error') + } finally { + setSyncing(false) + } + } // Pull rebases origin/main onto the working tree, so the whole dashboard can be // 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 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) + } + } // 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. // No test run follows: the next skill run uses it. 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`) + 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`) fetchData() } // A failed run's "Connect": the harness it ran on, else the selected one. const connectHarness = (h?: string) => setConnectFor({ harness: HARNESSES.find(x => x.id === h)?.id ?? harness }) - 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 })) } } - const importSkill = async (files: UploadFile[], name?: string, category?: string) => { const { ok, data } = await postJson('/api/upload', { files, name, category }); if (ok) { flash(`${displayName(data.name)} added`); 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 })) + } + } + const importSkill = async (files: UploadFile[], name?: string, category?: string) => { + const { ok, data } = await postJson('/api/upload', { files, name, category }) + if (ok) { + flash(`${displayName(data.name)} added`) + fetchData() + } + } // Enable/disable a pack's *visibility* (not its skills). Core is always on. // Toggling reveals or hides the pack's skills across the sidebar + HQ; it's a // view preference, persisted to localStorage, with zero effect on what runs. @@ -219,25 +552,130 @@ export default function Dashboard() { setEnabledPacks(prev => { const has = prev.includes(key) const next = has ? prev.filter(k => k !== key) : [...prev, key] - try { if (repo) localStorage.setItem(`aeon.enabledPacks:${repo}`, JSON.stringify(next.filter(k => !DEFAULT_VISIBLE_PACKS.has(k)))) } catch {} + try { + if (repo) + localStorage.setItem( + `aeon.enabledPacks:${repo}`, + JSON.stringify(next.filter(k => !DEFAULT_VISIBLE_PACKS.has(k))), + ) + } catch {} flash(`${PACK_BY_KEY[key]?.label || key} ${has ? 'hidden' : 'revealed'}`) return next }) } - const saveStrategy = async (content: string) => { setStrategySaving(true); try { const { ok, data } = await putJson('/api/strategy', { content }); if (ok) { setStrategy(content); flashSynced('Strategy saved', data) } else { flash('Save failed') } } finally { setStrategySaving(false) } } - const buildStrategy = async (sources: StrategySources) => { setStrategyBuilding(true); try { const { ok, data } = await postJson('/api/strategy/build', { ...sources, model }); if (ok) { flash('Strategy-builder started'); scheduleRunRefresh(refreshRuns) } else { flash(data.error || 'Build failed to dispatch') } } finally { setStrategyBuilding(false) } } - const saveMcp = async (servers: McpServers) => { setMcpSaving(true); try { const { ok, data } = await putJson('/api/mcp', { servers }); if (ok) { setMcpServers(servers); const added = data?.allowlist?.added ?? []; const base = added.length ? `MCP servers saved · allowlisted ${added.join(', ')} for runs` : 'MCP servers saved'; flashSynced(base, { synced: data?.synced !== false && data?.allowlist?.synced !== false }) } else { flash('Save failed') } } finally { setMcpSaving(false) } } - const saveSoul = async (file: SoulFile, content: string) => { setSoulSaving(true); try { const { ok, data } = await putJson('/api/soul', { file, content }); if (ok) { if (file === 'soul') setSoul(content); else setSoulStyle(content); flashSynced(`${file === 'soul' ? 'SOUL.md' : 'STYLE.md'} saved`, data) } else { flash('Save failed') } } finally { setSoulSaving(false) } } - 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) } } + const saveStrategy = async (content: string) => { + setStrategySaving(true) + try { + const { ok, data } = await putJson('/api/strategy', { content }) + if (ok) { + setStrategy(content) + flashSynced('Strategy saved', data) + } else { + flash('Save failed') + } + } finally { + setStrategySaving(false) + } + } + const buildStrategy = async (sources: StrategySources) => { + setStrategyBuilding(true) + try { + const { ok, data } = await postJson('/api/strategy/build', { ...sources, model }) + if (ok) { + flash('Strategy-builder started') + scheduleRunRefresh(refreshRuns) + } else { + flash(data.error || 'Build failed to dispatch') + } + } finally { + setStrategyBuilding(false) + } + } + const saveMcp = async (servers: McpServers) => { + setMcpSaving(true) + try { + const { ok, data } = await putJson( + '/api/mcp', + { servers }, + ) + if (ok) { + setMcpServers(servers) + const added = data?.allowlist?.added ?? [] + const base = added.length ? `MCP servers saved · allowlisted ${added.join(', ')} for runs` : 'MCP servers saved' + flashSynced(base, { synced: data?.synced !== false && data?.allowlist?.synced !== false }) + } else { + flash('Save failed') + } + } finally { + setMcpSaving(false) + } + } + const saveSoul = async (file: SoulFile, content: string) => { + setSoulSaving(true) + try { + const { ok, data } = await putJson('/api/soul', { file, content }) + if (ok) { + if (file === 'soul') setSoul(content) + else setSoulStyle(content) + flashSynced(`${file === 'soul' ? 'SOUL.md' : 'STYLE.md'} saved`, data) + } else { + flash('Save failed') + } + } finally { + setSoulSaving(false) + } + } + 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 input that needs its own read: whether Actions is on. - useEffect(() => { if (!loading) getJson<{ actionsEnabled: boolean | null }>('/api/onboarding').then(d => setActionsEnabled(d.actionsEnabled)).catch(() => {}) }, [loading]) + useEffect(() => { + if (!loading) + getJson<{ actionsEnabled: boolean | null }>('/api/onboarding') + .then(d => setActionsEnabled(d.actionsEnabled)) + .catch(() => {}) + }, [loading]) // Jump from a skill's API-keys panel straight to Keys → 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) } - const goToMcp = () => { setSelectedSkill(null); setView('mcp') } + const goToSecret = (name: string) => { + setSelectedSkill(null) + setView('secrets') + setSecretFocus(name) + } + const goToMcp = () => { + setSelectedSkill(null) + setView('mcp') + } // --- Derived --- const skill = selectedSkill ? skills.find(s => s.name === selectedSkill) || null : null @@ -248,10 +686,13 @@ export default function Dashboard() { 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 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: ')) // Skills visible across the dashboard = first-party skills whose pack is // enabled (Core always on), PLUS every community skill — anything in a pack @@ -275,25 +716,58 @@ export default function Dashboard() { return (
- {toast &&
{toast}
} + {toast && ( +
+ {toast} +
+ )} {/* Below the modals (z-40) and the toast (z-50). */} {narrow && (navOpen || activityOpen) && ( -
{ setNavOpen(false); setActivityOpen(false) }} /> +
{ + setNavOpen(false) + setActivityOpen(false) + }} + /> )}
{ setView(v); setSelectedSkill(null); setNavOpen(false) }} + view={view} + setView={v => { + setView(v) + setSelectedSkill(null) + setNavOpen(false) + }} selectedSkill={selectedSkill} - skills={visibleSkills} runs={runs} secrets={secrets} repo={repo} harness={harness} - enabledCount={enabledCount} workingCount={workingCount} - categoryFilter={categoryFilter} setCategoryFilter={setCategoryFilter} - onSkillSelect={(name) => { setSelectedSkill(name); setView('hq'); setNavOpen(false) }} - onShowImport={() => { setShowImport(true); setNavOpen(false) }} + skills={visibleSkills} + runs={runs} + secrets={secrets} + repo={repo} + harness={harness} + enabledCount={enabledCount} + workingCount={workingCount} + categoryFilter={categoryFilter} + setCategoryFilter={setCategoryFilter} + onSkillSelect={name => { + setSelectedSkill(name) + setView('hq') + setNavOpen(false) + }} + onShowImport={() => { + setShowImport(true) + setNavOpen(false) + }} sync={{ pulling, syncing, hasChanges, behind, onPull: pullFromGithub, onPush: syncToGithub }} />
@@ -305,66 +779,220 @@ export default function Dashboard() { onOpenActivity={() => setActivityOpen(true)} /> setConnectFor({ harness })} onUpdateModel={updateModel} onUpdateHarness={updateHarness} - onPull={pullFromGithub} onSync={syncToGithub} + pulling={pulling} + syncing={syncing} + hasChanges={hasChanges} + behind={behind} + 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') }} 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 - ? { setStrategyError(false); setStrategyLoaded(false) }} /> - : - )} - {view === 'mcp' && !selectedSkill && ( - mcpError - ? { setMcpError(false); setMcpLoaded(false) }} /> - : - )} - {view === 'soul' && !selectedSkill && ( - soulError - ? { setSoulError(false); setSoulLoaded(false) }} /> - : + 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 ? ( + { + setStrategyError(false) + setStrategyLoaded(false) + }} + /> + ) : ( + + ))} + {view === 'mcp' && + !selectedSkill && + (mcpError ? ( + { + setMcpError(false) + setMcpLoaded(false) + }} + /> + ) : ( + + ))} + {view === 'soul' && + !selectedSkill && + (soulError ? ( + { + setSoulError(false) + setSoulLoaded(false) + }} + /> + ) : ( + + ))} {view === 'hq' && !selectedSkill && ( - setCategoryFilter(categoryFilter === key ? null : key)} onOpenPacks={() => setView('packs')} onConnect={connectHarness} + setCategoryFilter(categoryFilter === key ? null : key)} + onOpenPacks={() => setView('packs')} + onConnect={connectHarness} checklist={ setConnectFor({ harness })} - onPickSkills={() => { setSelectedSkill(null); setView('packs') }} - onNotifications={(c) => goToSecret(NOTIFY_FIRST_SECRET[c](isSet))} - onFirstRun={() => { const first = visibleSkills.find(s => s.enabled); if (first) { setSelectedSkill(first.name); setView('hq') } else setView('packs') }} + onPickSkills={() => { + setSelectedSkill(null) + setView('packs') + }} + onNotifications={c => goToSecret(NOTIFY_FIRST_SECRET[c](isSet))} + onFirstRun={() => { + const first = visibleSkills.find(s => s.enabled) + if (first) { + setSelectedSkill(first.name) + setView('hq') + } else setView('packs') + }} /> - } /> - )} - {view === 'packs' && !selectedSkill && ( - packsError - ? { setPacksError(false); setPacksLoaded(false) }} /> - : { setSelectedSkill(name); setView('hq') }} onInstallPack={(arg) => runSkill('install-skill', arg)} /> + } + /> )} + {view === 'packs' && + !selectedSkill && + (packsError ? ( + { + setPacksError(false) + setPacksLoaded(false) + }} + /> + ) : ( + { + setSelectedSkill(name) + setView('hq') + }} + onInstallPack={arg => runSkill('install-skill', arg)} + /> + ))} {skill && ( )}
setActivityOpen(false)} - onRefresh={() => { fetchData(); setFeedKey(k => k + 1); setAnalyticsData(null); setAnalyticsError(false) }} - onFetchAnalytics={() => { if (!analyticsData) { setAnalyticsError(false); getJson('/api/analytics').then(d => setAnalyticsData(d)).catch(() => setAnalyticsError(true)) } }} + runs={runs} + outputs={outputs} + feedLoading={feedLoading} + feedError={feedError} + analyticsData={analyticsData} + analyticsError={analyticsError} + drawerOpen={activityOpen} + onCloseDrawer={() => setActivityOpen(false)} + onRefresh={() => { + fetchData() + setFeedKey(k => k + 1) + setAnalyticsData(null) + setAnalyticsError(false) + }} + onFetchAnalytics={() => { + if (!analyticsData) { + setAnalyticsError(false) + getJson('/api/analytics') + .then(d => setAnalyticsData(d)) + .catch(() => setAnalyticsError(true)) + } + }} onConnect={connectHarness} /> @@ -376,7 +1004,10 @@ export default function Dashboard() { patSet={isSet('GH_SECRETS_PAT') || isSet('GH_GLOBAL')} onClose={() => setConnectFor(null)} onSaved={onCredentialSaved} - onGoToSecret={(n) => { setConnectFor(null); goToSecret(n) }} + onGoToSecret={n => { + setConnectFor(null) + goToSecret(n) + }} /> )}
diff --git a/apps/dashboard/components/ConnectModal.tsx b/apps/dashboard/components/ConnectModal.tsx index 277a4a137b3..64344a06446 100644 --- a/apps/dashboard/components/ConnectModal.tsx +++ b/apps/dashboard/components/ConnectModal.tsx @@ -15,7 +15,12 @@ import { guideFor, captureCommand, canDriveLogin, type Os } from '../lib/connect // 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 } +export interface SavedCredential { + secret: string + label?: string + harness?: Harness + synced?: boolean +} interface ConnectModalProps { harness: Harness @@ -29,25 +34,61 @@ interface ConnectModalProps { 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' +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 - ? - : + 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 */ } + try { + await navigator.clipboard.writeText(command) + setCopied(true) + setTimeout(() => setCopied(false), 1500) + } catch { + /* select-all fallback below */ + } } return (
- {command} -
) @@ -55,15 +96,29 @@ function CommandBox({ command }: { command: string }) { 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' + 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.secret && ( + <> + {' '} + {'->'} {d.secret} + + )}
- {d.note &&
{d.note}
} + {d.note && ( +
{d.note}
+ )}
) } @@ -74,14 +129,29 @@ function SavedPanel({ saved, harness, onClose }: { saved: SavedCredential; harne

Saved

- +
-
Saved {saved.secret || saved.label || 'the credential'}. The next run uses it.
- {saved.harness && saved.harness !== harness &&
Saving also selected the {harnessName(saved.harness)} harness.
} +
+ Saved {saved.secret || saved.label || 'the credential'}. The next run uses it. +
+ {saved.harness && saved.harness !== harness && ( +
Saving also selected the {harnessName(saved.harness)} harness.
+ )}
If a run fails, HQ says why and what to do next.
- +
) } @@ -90,7 +160,9 @@ export function ConnectModal({ harness, patSet, onClose, onSaved, onGoToSecret } const guide = guideFor(harness) // Set once a credential is saved; the modal then shows what was saved. const [saved, setSaved] = useState(null) - const [os, setOs] = useState(() => typeof navigator !== 'undefined' && !/Mac|iPhone|iPad/.test(navigator.userAgent) ? 'linux' : 'mac') + 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) @@ -107,16 +179,25 @@ export function ConnectModal({ harness, patSet, onClose, onSaved, onGoToSecret } const stopOpenRouter = useRef<(() => void) | null>(null) useEffect(() => { mounted.current = true - return () => { mounted.current = false; stopOpenRouter.current?.() } + 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 } + 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) @@ -130,54 +211,87 @@ export function ConnectModal({ harness, patSet, onClose, onSaved, onGoToSecret } }, 300) return () => clearTimeout(t) }, [value, harness, provider, detectKey, localDetection.state]) - const detection = localDetection.state === 'pending' && serverDetection?.key === detectKey ? serverDetection.d : localDetection + const detection = + localDetection.state === 'pending' && serverDetection?.key === detectKey ? serverDetection.d : localDetection const providerOptions = providersForHarness(harness) const done = (c: SavedCredential) => { onSaved(c) - setValue(''); setProvider(''); setError('') + setValue('') + setProvider('') + setError('') setSaved(c) } const save = async () => { if (detection.state !== 'ok') return - setBusy('save'); setError('') + setBusy('save') + setError('') try { - const { ok, data } = await postJson('/api/connect', { harness, value, provider }) + const { ok, data } = await postJson('/api/connect', { + harness, + value, + provider, + }) if (ok) done(data) else setError(data.error || 'Save failed') - } finally { setBusy('') } + } finally { + setBusy('') + } } // "Do it for me": the existing machine-bound CLI flows. const driveLogin = async () => { - setBusy('drive'); setError('') + setBusy('drive') + setError('') try { - const [url, body] = harness === 'claude' ? ['/api/auth', {}] : harness === 'grok' ? ['/api/grok-auth', {}] : ['/api/harness-auth', { harness }] + 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) done({ secret: data.secret || '', harness: data.harness, synced: data.synced }) else setError(data.error || 'Login failed') - } finally { setBusy('') } + } finally { + setBusy('') + } } const pickFound = async (id: string) => { - setBusy(id); setError('') + setBusy(id) + setError('') try { const { ok, data } = await postJson('/api/connect/found', { id, harness }) if (ok) done(data) else setError(data.error || 'Could not use it') - } finally { setBusy('') } + } 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('') + 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 } + 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 @@ -199,9 +313,14 @@ export function ConnectModal({ harness, patSet, onClose, onSaved, onGoToSecret } const check = async () => { if (finished) return try { - const d = await (await fetch(`/api/openrouter-auth?state=${encodeURIComponent(state)}`)).json() as { status: string; error?: string } + 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 */ } + } catch { + /* keep waiting */ + } } const onMessage = (e: MessageEvent) => { const m = e.data as { type?: string; state?: string } | null @@ -221,10 +340,16 @@ export function ConnectModal({ harness, patSet, onClose, onSaved, onGoToSecret } return (
-
+

Connect {harnessName(harness)}

- +
{saved ? ( @@ -241,8 +366,15 @@ export function ConnectModal({ harness, patSet, onClose, onSaved, onGoToSecret } <> {isCapture && (
- {(['mac', 'linux'] as const).map((o) => ( - + {(['mac', 'linux'] as const).map(o => ( + ))}
)} @@ -250,9 +382,17 @@ export function ConnectModal({ harness, patSet, onClose, onSaved, onGoToSecret }

{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.} + : 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} @@ -260,7 +400,17 @@ export function ConnectModal({ harness, patSet, onClose, onSaved, onGoToSecret }

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

)} @@ -268,36 +418,74 @@ export function ConnectModal({ harness, patSet, onClose, onSaved, onGoToSecret } {/* Step 2 */}

Step 2 - paste the result

{ setValue(e.target.value); setError('') }} - onKeyDown={(e) => e.key === 'Enter' && save()} + type="password" + value={value} + autoComplete="off" + spellCheck={false} + onChange={e => { + 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) ? ( - setProvider(e.target.value)} + aria-label="Provider" + className="mt-2 w-full bg-aeon-bg text-aeon-fg text-xs px-3 py-2 border border-[rgba(250,250,250,0.10)] outline-none font-mono cursor-pointer hover:border-[rgba(250,250,250,0.22)] focus:border-aeon-red transition-colors" + > - {providerOptions.map((p) => )} + {providerOptions.map(p => ( + + ))} - ) : value.trim() && providerOptions.length > 1 && detection.state !== 'pending' && !detection.captureHarness ? ( - + ) : value.trim() && + providerOptions.length > 1 && + detection.state !== 'pending' && + !detection.captureHarness ? ( + ) : null} {harness === 'claude' && ( -

If a run fails, the Why? toggle on HQ says why. An API key or OpenRouter also works.

+

+ If a run fails, the Why? toggle on HQ says why. An API key or OpenRouter also 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. + + . An xAI key needs nothing extra.

)} - - {error &&

{error}

} + {error && ( +

+ {error} +

+ )} {/* Options */} {(acceptsOpenRouter(harness) || (local && (canDriveLogin(harness) || found.length > 0))) && ( @@ -308,24 +496,44 @@ export function ConnectModal({ harness, patSet, onClose, onSaved, onGoToSecret } - {orLink && Popup blocked - open OpenRouter} + {orLink && ( + + Popup blocked - open OpenRouter + + )} )} {local && canDriveLogin(harness) && ( - )} {local && found.length > 0 && (
-

Found on this machine

- {found.map((f) => ( +

+ Found on this machine +

+ {found.map(f => (
{f.label}
would fill {f.secret}
- +
))}
diff --git a/apps/dashboard/components/HQOverview.tsx b/apps/dashboard/components/HQOverview.tsx index b0cc19cb6cf..f215535125b 100644 --- a/apps/dashboard/components/HQOverview.tsx +++ b/apps/dashboard/components/HQOverview.tsx @@ -21,7 +21,17 @@ interface HQOverviewProps { onConnect?: (harness?: string) => void } -export function HQOverview({ skills, runs, enabledCount, workingCount, categoryFilter, onCategoryClick, onOpenPacks, checklist, onConnect }: HQOverviewProps) { +export function HQOverview({ + skills, + runs, + enabledCount, + workingCount, + categoryFilter, + onCategoryClick, + onOpenPacks, + checklist, + onConnect, +}: HQOverviewProps) { const onMove = (e: React.MouseEvent) => { const card = (e.target as HTMLElement).closest('li') if (!card) return @@ -47,13 +57,18 @@ export function HQOverview({ skills, runs, enabledCount, workingCount, categoryF
@@ -84,20 +102,34 @@ export function HQOverview({ skills, runs, enabledCount, workingCount, categoryF // li is flex so the button stretches to the grid row's full height — // otherwise the active ring stops short when the row neighbor is taller return ( -
  • +
  • @@ -132,7 +171,9 @@ export function HQOverview({ skills, runs, enabledCount, workingCount, categoryF
    {runStatusGlyph(run)} {run.workflow} - {timeAgo(run.created_at)} + + {timeAgo(run.created_at)} +
    {/* Failed runs only: "Why?" reads the log on demand. */} @@ -141,7 +182,9 @@ export function HQOverview({ skills, runs, enabledCount, workingCount, categoryF {!runs.length && (

    Nothing yet

    -

    Open any skill and press Run, or use Run one in the setup list

    +

    + Open any skill and press Run, or use Run one in the setup list +

    )}
    @@ -153,7 +196,10 @@ export function HQOverview({ skills, runs, enabledCount, workingCount, categoryF > {Array.from({ length: 2 }).map((_, k) => ( - AEON HQ ★ {enabledCount} ENABLED ★ {cats.length} PACKS ★ {runs.length} RUNS LOGGED ★ NO BABYSITTING ★ + AEON HQ ★ {enabledCount} ENABLED{' '} + ★ {cats.length} PACKS{' '} + ★ {runs.length} RUNS LOGGED{' '} + ★ NO BABYSITTING ★ ))} diff --git a/apps/dashboard/components/ImportModal.tsx b/apps/dashboard/components/ImportModal.tsx index 35488a6cc9e..ad0a045dc10 100644 --- a/apps/dashboard/components/ImportModal.tsx +++ b/apps/dashboard/components/ImportModal.tsx @@ -31,10 +31,19 @@ export function ImportModal({ onClose, onImport }: ImportModalProps) { files.push({ path: f.webkitRelativePath || f.name, content: await f.text() }) } setUploadFiles(files) - const sf = files.find(f => { const l = f.path.toLowerCase(); return l === 'skill.md' || l.endsWith('/skill.md') || l.endsWith('.skill') }) + const sf = files.find(f => { + const l = f.path.toLowerCase() + return l === 'skill.md' || l.endsWith('/skill.md') || l.endsWith('.skill') + }) if (sf) { const fm = sf.content.match(/^---\s*\n([\s\S]*?)\n---/) - if (fm) { const n = fm[1].match(/name:\s*(.+)/); if (n) { const slug = slugify(n[1].trim().replace(/^['"]|['"]$/g, '')); if (slug) setUploadName(slug) } } + if (fm) { + const n = fm[1].match(/name:\s*(.+)/) + if (n) { + const slug = slugify(n[1].trim().replace(/^['"]|['"]$/g, '')) + if (slug) setUploadName(slug) + } + } } } @@ -51,27 +60,110 @@ export function ImportModal({ onClose, onImport }: ImportModalProps) { return (
    -
    e.stopPropagation()} className="bg-aeon-panel border border-[rgba(250,250,250,0.10)] w-full max-w-md mx-4 p-[var(--space-lg)] shadow-2xl"> +
    e.stopPropagation()} + className="bg-aeon-panel border border-[rgba(250,250,250,0.10)] w-full max-w-md mx-4 p-[var(--space-lg)] shadow-2xl" + >

    Add new skill

    - +
    - e.target.files && readFilesFromInput(e.target.files)} /> - { if (el) el.setAttribute('webkitdirectory', '') }} type="file" className="hidden" id="folder-input" onChange={(e) => e.target.files && readFilesFromInput(e.target.files)} /> -
    { e.preventDefault(); setUploadDragOver(true) }} onDragLeave={() => setUploadDragOver(false)} onDrop={(e) => { e.preventDefault(); setUploadDragOver(false); if (e.dataTransfer.files.length > 0) readFilesFromInput(e.dataTransfer.files) }} - className={`border-2 border-dashed p-8 text-center transition-colors ${uploadDragOver ? 'border-aeon-red bg-aeon-red/10' : 'border-[rgba(250,250,250,0.12)] hover:border-[rgba(250,250,250,0.2)]'}`}> - {!uploadFiles.length ? (<>
    Drop a skill folder here
    Must include SKILL.md
    ) : (<>
    {uploadFiles.length} file{uploadFiles.length !== 1 ? 's' : ''}
    )} + e.target.files && readFilesFromInput(e.target.files)} + /> + { + if (el) el.setAttribute('webkitdirectory', '') + }} + type="file" + className="hidden" + id="folder-input" + onChange={e => e.target.files && readFilesFromInput(e.target.files)} + /> +
    { + e.preventDefault() + setUploadDragOver(true) + }} + onDragLeave={() => setUploadDragOver(false)} + onDrop={e => { + e.preventDefault() + setUploadDragOver(false) + if (e.dataTransfer.files.length > 0) readFilesFromInput(e.dataTransfer.files) + }} + className={`border-2 border-dashed p-8 text-center transition-colors ${uploadDragOver ? 'border-aeon-red bg-aeon-red/10' : 'border-[rgba(250,250,250,0.12)] hover:border-[rgba(250,250,250,0.2)]'}`} + > + {!uploadFiles.length ? ( + <> +
    Drop a skill folder here
    +
    + + +
    +
    Must include SKILL.md
    + + ) : ( + <> +
    + {uploadFiles.length} file{uploadFiles.length !== 1 ? 's' : ''} +
    + + + )}
    {uploadFiles.length > 0 && (
    - setUploadName(e.target.value)} placeholder="skill-name" className={inputCls} /> - setUploadName(e.target.value)} + placeholder="skill-name" + className={inputCls} + /> + - +
    )}
    diff --git a/apps/dashboard/components/InstantModeCard.tsx b/apps/dashboard/components/InstantModeCard.tsx index 9ab38a42d1e..b194a816f7b 100644 --- a/apps/dashboard/components/InstantModeCard.tsx +++ b/apps/dashboard/components/InstantModeCard.tsx @@ -16,7 +16,7 @@ interface InstantModeCardProps { function newWebhookSecret(): string { const bytes = new Uint8Array(32) crypto.getRandomValues(bytes) - return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('') + return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('') } // Rendered as a row inside the Telegram credentials list: a one-liner with a @@ -33,7 +33,9 @@ export function InstantModeCard({ repo, sessionBotToken }: InstantModeCardProps) // Without it the Worker 403s every update and inbound Telegram goes dead. const [webhookSecret, setWebhookSecret] = useState('') - useEffect(() => { if (sessionBotToken) setBotToken(sessionBotToken) }, [sessionBotToken]) + useEffect(() => { + if (sessionBotToken) setBotToken(sessionBotToken) + }, [sessionBotToken]) const deployRepo = repo || 'aeonfun/aeon' const deployUrl = `https://deploy.workers.cloudflare.com/?url=https://github.com/${deployRepo}/tree/main/apps/webhook` @@ -43,19 +45,21 @@ export function InstantModeCard({ repo, sessionBotToken }: InstantModeCardProps) // Accept a bare subdomain, a host, or a full URL - normalize to https://host. const trimmedWorker = workerUrl.trim().replace(/\/+$/, '') const fullWorkerUrl = trimmedWorker - ? trimmedWorker.startsWith('http') ? trimmedWorker + ? trimmedWorker.startsWith('http') + ? trimmedWorker : `https://${trimmedWorker.includes('.') ? trimmedWorker : `aeon-telegram-webhook.${trimmedWorker}.workers.dev`}` : '' const secret = webhookSecret.trim() - const setWebhookCmd = - `curl "https://api.telegram.org/bot${botToken.trim() || ''}/setWebhook?url=${fullWorkerUrl || 'https://.workers.dev'}&secret_token=${secret || ''}"` + const setWebhookCmd = `curl "https://api.telegram.org/bot${botToken.trim() || ''}/setWebhook?url=${fullWorkerUrl || 'https://.workers.dev'}&secret_token=${secret || ''}"` const copy = async (key: string, text: string) => { try { await navigator.clipboard.writeText(text) setCopied(key) setTimeout(() => setCopied(null), 1500) - } catch { /* clipboard blocked - the value is visible to copy manually */ } + } catch { + /* clipboard blocked - the value is visible to copy manually */ + } } // Telegram's API allows CORS, so the webhook can be registered straight from @@ -66,13 +70,20 @@ export function InstantModeCard({ repo, sessionBotToken }: InstantModeCardProps) setWhBusy(true) setWhStatus(null) try { - const res = await fetch(`https://api.telegram.org/bot${botToken.trim()}/setWebhook?url=${encodeURIComponent(fullWorkerUrl)}&secret_token=${encodeURIComponent(secret)}`) - const data = await res.json() as { ok: boolean; description?: string } - setWhStatus(data.ok - ? { ok: true, msg: 'Webhook set - replies now arrive in ~1s. The poller backs off automatically.' } - : { ok: false, msg: data.description ?? 'Telegram rejected the request - double-check the token.' }) + const res = await fetch( + `https://api.telegram.org/bot${botToken.trim()}/setWebhook?url=${encodeURIComponent(fullWorkerUrl)}&secret_token=${encodeURIComponent(secret)}`, + ) + const data = (await res.json()) as { ok: boolean; description?: string } + setWhStatus( + data.ok + ? { ok: true, msg: 'Webhook set - replies now arrive in ~1s. The poller backs off automatically.' } + : { ok: false, msg: data.description ?? 'Telegram rejected the request - double-check the token.' }, + ) } catch { - setWhStatus({ ok: false, msg: 'Could not reach api.telegram.org from the browser - copy the curl command below instead.' }) + setWhStatus({ + ok: false, + msg: 'Could not reach api.telegram.org from the browser - copy the curl command below instead.', + }) } finally { setWhBusy(false) } @@ -102,7 +113,10 @@ export function InstantModeCard({ repo, sessionBotToken }: InstantModeCardProps) {' '} - {' '} - - random, generated here; step 3 sends the same value to Telegram (paste yours if the Worker already has one) + + - random, generated here; step 3 sends the same value to Telegram (paste yours if the Worker already + has one) +
  • GITHUB_REPO = {deployRepo}{' '} -
  • GITHUB_TOKEN{' '} - create token ↗ + className="text-[10px] text-aeon-red/80 hover:text-aeon-red transition-colors" + > + create token ↗ +
  • - The wizard prompts for these during deploy and stores them as encrypted Worker secrets in your - Cloudflare account. Edit later: Workers & Pages → your worker → Settings → Variables. + The wizard prompts for these during deploy and stores them as encrypted Worker secrets in your Cloudflare + account. Edit later: Workers & Pages → your worker → Settings → Variables.

    {/* 3 - register */}
    -
    3 · Point Telegram at the Worker
    +
    + 3 · Point Telegram at the Worker +
    - setBotToken(e.target.value)} - placeholder="bot token..." className={inputCls} /> - setWorkerUrl(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && registerWebhook()} - placeholder="aeon-telegram-webhook..workers.dev" className={inputCls} /> -

    - The Worker URL is on its Overview tab in Cloudflare. The token only goes to api.telegram.org; nothing is stored. + The Worker URL is on its Overview tab in Cloudflare. The token only goes to api.telegram.org; nothing is + stored.

    {whStatus && ( -

    {whStatus.msg}

    +

    + {whStatus.msg} +

    )}
    {setWebhookCmd} -
    diff --git a/apps/dashboard/components/LangfuseRegionCard.tsx b/apps/dashboard/components/LangfuseRegionCard.tsx index 7ef8716125b..4227f4443d2 100644 --- a/apps/dashboard/components/LangfuseRegionCard.tsx +++ b/apps/dashboard/components/LangfuseRegionCard.tsx @@ -36,14 +36,22 @@ export function LangfuseRegionCard({ keysSet }: LangfuseRegionCardProps) { if (d.region) setRegion(d.region) setHost(d.host ?? null) }) - .catch(() => { /* leave EU default */ }) - .finally(() => { if (alive) setLoading(false) }) - return () => { alive = false } + .catch(() => { + /* leave EU default */ + }) + .finally(() => { + if (alive) setLoading(false) + }) + return () => { + alive = false + } }, []) const choose = async (next: Region) => { if (next === 'custom' || next === region) return - setSaving(true); setSaved(false); setError(null) + setSaving(true) + setSaved(false) + setError(null) const prev = region setRegion(next) try { @@ -52,7 +60,7 @@ export function LangfuseRegionCard({ keysSet }: LangfuseRegionCardProps) { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ region: next }), }) - const data = await res.json() as { ok?: boolean; host?: string; error?: string } + const data = (await res.json()) as { ok?: boolean; host?: string; error?: string } if (!res.ok || !data.ok) throw new Error(data.error || 'Failed to save region') setHost(data.host ?? null) setSaved(true) @@ -92,7 +100,7 @@ export function LangfuseRegionCard({ keysSet }: LangfuseRegionCardProps) { {saved && saved ✓} setSkillSearch(e.target.value)} placeholder="Search..." className="w-full bg-aeon-bg text-aeon-fg text-[11px] px-3 py-2 border border-[rgba(250,250,250,0.10)] outline-none font-mono focus:border-aeon-red transition-colors placeholder:text-primary-35 cursor-target" /> + setSkillSearch(e.target.value)} + placeholder="Search..." + className="w-full bg-aeon-bg text-aeon-fg text-[11px] px-3 py-2 border border-[rgba(250,250,250,0.10)] outline-none font-mono focus:border-aeon-red transition-colors placeholder:text-primary-35 cursor-target" + />
    {/* Category filter */} @@ -144,7 +232,9 @@ export function LeftSidebar({ view, setView, selectedSkill, skills, runs, secret onClick={() => setCategoryFilter(active ? null : cat.key)} title={`${cat.label} - show this pack's skills`} className={`text-[10px] font-mono uppercase tracking-[0.1em] px-2 py-1 border flex items-center gap-1.5 transition-colors ${active ? '' : 'text-primary-40 border-[rgba(250,250,250,0.12)] hover:text-primary-70 hover:border-[rgba(250,250,250,0.22)]'}`} - style={active ? { color: cat.color, borderColor: cat.color, backgroundColor: cat.color + '1A' } : undefined} + style={ + active ? { color: cat.color, borderColor: cat.color, backgroundColor: cat.color + '1A' } : undefined + } > {cat.short} @@ -157,7 +247,13 @@ export function LeftSidebar({ view, setView, selectedSkill, skills, runs, secret if (categoryFilter && cat.key !== categoryFilter) return null const catSkills = skills.filter(s => (s.pack || 'lab') === cat.key) if (!catSkills.length) return null - const searched = skillSearch ? catSkills.filter(s => displayName(s.name).toLowerCase().includes(skillSearch.toLowerCase()) || s.name.includes(skillSearch.toLowerCase())) : catSkills + const searched = skillSearch + ? catSkills.filter( + s => + displayName(s.name).toLowerCase().includes(skillSearch.toLowerCase()) || + s.name.includes(skillSearch.toLowerCase()), + ) + : catSkills const filtered = effectiveEnabledOnly ? searched.filter(s => s.enabled) : searched if (!filtered.length) return null const en = filtered.filter(s => s.enabled).length @@ -165,36 +261,64 @@ export function LeftSidebar({ view, setView, selectedSkill, skills, runs, secret
    - {cat.label} + + {cat.label} + {en}
    - {filtered.sort((a, b) => Number(b.enabled) - Number(a.enabled) || a.name.localeCompare(b.name)).map(s => { - const st = getSkillStatus(s.name, s.enabled, runs) - const sel = selectedSkill === s.name - const keyGap = s.enabled && missingRequiredKeys(s).length > 0 - return ( - - ) - })} + {keyGap && ( + + + + + + )} + + ) + })}
    ) })} diff --git a/apps/dashboard/components/LoadingScreen.tsx b/apps/dashboard/components/LoadingScreen.tsx index 30bee15646f..acd5a4b7483 100644 --- a/apps/dashboard/components/LoadingScreen.tsx +++ b/apps/dashboard/components/LoadingScreen.tsx @@ -4,7 +4,10 @@ export function LoadingScreen() {