diff --git a/CLAUDE.md b/CLAUDE.md index 81ac7f9de3..fee68cff06 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -152,7 +152,7 @@ bun run format:check bun run validate ``` -This runs `check:bundled`, type-check, lint, format check, and tests. All five must pass for CI to succeed. +This runs `check:bundled`, `check:bundled-skill`, type-check, lint, format check, and tests. All six must pass for CI to succeed. ### ESLint Guidelines @@ -257,6 +257,9 @@ bun run cli serve --download-only # Download without starting bun run cli skill install bun run cli skill install /path/to/project +# Verify your Archon setup (Claude binary, gh auth, DB, adapters) +bun run cli doctor + # Show version bun run cli version ``` @@ -723,7 +726,7 @@ async function createSession(conversationId: string, codebaseId: string) { - Source builds: Loaded from filesystem at runtime - Merged with repo-specific commands/workflows (repo overrides defaults by name) - Opt-out: Set `defaults.loadDefaultCommands: false` or `defaults.loadDefaultWorkflows: false` in `.archon/config.yaml` -- **After adding, removing, or editing a default file, run `bun run generate:bundled`** to refresh the embedded bundle. `bun run validate` (and CI) run `check:bundled` and will fail loudly if the generated file is stale. +- **After adding, removing, or editing a default file, run `bun run generate:bundled`** to refresh the embedded bundle. `bun run validate` (and CI) run `check:bundled` and `check:bundled-skill` and will fail loudly if either generated file is stale. **Home-scoped ("global") workflows, commands, and scripts** (user-level, applies to every project): - Workflows: `~/.archon/workflows/` (or `$ARCHON_HOME/workflows/`) diff --git a/package.json b/package.json index 4e7954d1f0..409f183495 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "build:checksums": "bash scripts/checksums.sh", "generate:bundled": "bun run scripts/generate-bundled-defaults.ts", "check:bundled": "bun run scripts/generate-bundled-defaults.ts --check", + "check:bundled-skill": "bun run scripts/check-bundled-skill.ts --check", "test": "bun --filter '*' --parallel test", "test:watch": "bun --filter @archon/server test:watch", "type-check": "bun --filter '*' type-check && bun x tsc --noEmit -p scripts/tsconfig.json", @@ -27,7 +28,7 @@ "build:web": "bun --filter @archon/web build", "dev:docs": "bun --filter @archon/docs-web dev", "build:docs": "bun --filter @archon/docs-web build", - "validate": "bun run check:bundled && bun run type-check && bun run lint --max-warnings 0 && bun run format:check && bun run test", + "validate": "bun run check:bundled && bun run check:bundled-skill && bun run type-check && bun run lint --max-warnings 0 && bun run format:check && bun run test", "prepare": "husky", "setup-auth": "bun --filter @archon/server setup-auth" }, diff --git a/packages/cli/package.json b/packages/cli/package.json index b11439caa1..29a8d6cebc 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -8,7 +8,7 @@ }, "scripts": { "cli": "bun src/cli.ts", - "test": "bun test src/commands/version.test.ts src/commands/setup.test.ts src/commands/skill.test.ts && bun test src/commands/workflow.test.ts && bun test src/commands/isolation.test.ts && bun test src/commands/chat.test.ts && bun test src/commands/serve.test.ts", + "test": "bun test src/commands/version.test.ts src/commands/setup.test.ts src/commands/skill.test.ts src/commands/doctor.test.ts && bun test src/commands/workflow.test.ts && bun test src/commands/isolation.test.ts && bun test src/commands/chat.test.ts && bun test src/commands/serve.test.ts", "type-check": "bun x tsc --noEmit" }, "dependencies": { diff --git a/packages/cli/src/bundled-skill.ts b/packages/cli/src/bundled-skill.ts index ca1cd3bee1..a822d9a660 100644 --- a/packages/cli/src/bundled-skill.ts +++ b/packages/cli/src/bundled-skill.ts @@ -9,7 +9,7 @@ */ // ============================================================================= -// Skill Files (18 total) +// Skill Files (21 total) // ============================================================================= import skillMd from '../../../.claude/skills/archon/SKILL.md' with { type: 'text' }; @@ -26,8 +26,11 @@ import telegramGuide from '../../../.claude/skills/archon/guides/telegram.md' wi import authoringCommands from '../../../.claude/skills/archon/references/authoring-commands.md' with { type: 'text' }; import cliCommands from '../../../.claude/skills/archon/references/cli-commands.md' with { type: 'text' }; import dagAdvanced from '../../../.claude/skills/archon/references/dag-advanced.md' with { type: 'text' }; +import goodPractices from '../../../.claude/skills/archon/references/good-practices.md' with { type: 'text' }; import interactiveWorkflows from '../../../.claude/skills/archon/references/interactive-workflows.md' with { type: 'text' }; +import parameterMatrix from '../../../.claude/skills/archon/references/parameter-matrix.md' with { type: 'text' }; import repoInit from '../../../.claude/skills/archon/references/repo-init.md' with { type: 'text' }; +import troubleshooting from '../../../.claude/skills/archon/references/troubleshooting.md' with { type: 'text' }; import variables from '../../../.claude/skills/archon/references/variables.md' with { type: 'text' }; import workflowDag from '../../../.claude/skills/archon/references/workflow-dag.md' with { type: 'text' }; @@ -53,8 +56,11 @@ export const BUNDLED_SKILL_FILES: Record = { 'references/authoring-commands.md': authoringCommands, 'references/cli-commands.md': cliCommands, 'references/dag-advanced.md': dagAdvanced, + 'references/good-practices.md': goodPractices, 'references/interactive-workflows.md': interactiveWorkflows, + 'references/parameter-matrix.md': parameterMatrix, 'references/repo-init.md': repoInit, + 'references/troubleshooting.md': troubleshooting, 'references/variables.md': variables, 'references/workflow-dag.md': workflowDag, }; diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 5493bccbd8..24ce862d57 100755 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -65,6 +65,7 @@ import { setupCommand } from './commands/setup'; import { skillInstallCommand } from './commands/skill'; import { validateWorkflowsCommand, validateCommandsCommand } from './commands/validate'; import { serveCommand } from './commands/serve'; +import { doctorCommand } from './commands/doctor'; import { closeDatabase } from '@archon/core'; import { setLogLevel, @@ -106,6 +107,7 @@ Commands: complete [...] Complete branch lifecycle (remove worktree + branches) serve Start the web UI server (downloads web UI on first run) skill install [path] Install the bundled Archon skill into .claude/skills/archon + doctor Verify your Archon setup (Claude binary, gh auth, DB, adapters) validate workflows [name] Validate workflow definitions and their references validate commands [name] Validate command files version, --version, -V Show version info (also -v when used alone) @@ -267,7 +269,16 @@ async function main(): Promise { const subcommand = positionals[1]; // Commands that don't require git repo validation - const noGitCommands = ['version', 'help', 'setup', 'chat', 'continue', 'serve', 'skill']; + const noGitCommands = [ + 'version', + 'help', + 'setup', + 'chat', + 'continue', + 'serve', + 'skill', + 'doctor', + ]; const requiresGitRepo = !noGitCommands.includes(command ?? ''); try { @@ -600,6 +611,10 @@ async function main(): Promise { return await serveCommand({ port: servePort, downloadOnly }); } + case 'doctor': { + return await doctorCommand(); + } + case 'skill': { switch (subcommand) { case 'install': { diff --git a/packages/cli/src/commands/doctor.test.ts b/packages/cli/src/commands/doctor.test.ts new file mode 100644 index 0000000000..f6c40549d1 --- /dev/null +++ b/packages/cli/src/commands/doctor.test.ts @@ -0,0 +1,342 @@ +/** + * Tests for `archon doctor` check functions. + * + * Uses spyOn for `@archon/git.execFileAsync` and `globalThis.fetch`. + * `BUNDLED_IS_BINARY` is a static const re-export and cannot be spied at + * runtime — `checkClaudeBinary` accepts it as an injectable parameter for + * testability. Avoids `mock.module()` because it is process-global and + * irreversible in Bun, which would pollute other test files in this package. + */ +import { describe, it, expect, spyOn, afterEach, beforeEach } from 'bun:test'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { mkdirSync, rmSync } from 'fs'; +import * as git from '@archon/git'; +import { + checkClaudeBinary, + checkDatabase, + checkGhAuth, + checkWorkspaceWritable, + checkBundledDefaults, + checkSlack, + checkTelegram, + doctorCommand, + type DatabaseDeps, +} from './doctor'; + +describe('checkClaudeBinary', () => { + let execSpy: ReturnType>; + + beforeEach(() => { + execSpy = spyOn(git, 'execFileAsync'); + }); + + afterEach(() => { + execSpy.mockRestore(); + }); + + it('returns skip when not in binary mode', async () => { + const result = await checkClaudeBinary({}, false); + expect(result.status).toBe('skip'); + expect(result.label).toBe('Claude binary'); + expect(execSpy).not.toHaveBeenCalled(); + }); + + it('returns fail in binary mode when CLAUDE_BIN_PATH is unset', async () => { + const result = await checkClaudeBinary({}, true); + expect(result.status).toBe('fail'); + expect(result.message).toContain('CLAUDE_BIN_PATH'); + expect(execSpy).not.toHaveBeenCalled(); + }); + + it('returns pass in binary mode when binary spawns successfully', async () => { + execSpy.mockResolvedValue({ stdout: '1.0.0', stderr: '' }); + const result = await checkClaudeBinary({ CLAUDE_BIN_PATH: '/opt/claude' }, true); + expect(result.status).toBe('pass'); + expect(result.message).toContain('/opt/claude'); + expect(execSpy).toHaveBeenCalledWith('/opt/claude', ['--version'], expect.any(Object)); + }); + + it('returns fail in binary mode when spawn throws', async () => { + execSpy.mockRejectedValue(new Error('ENOENT')); + const result = await checkClaudeBinary({ CLAUDE_BIN_PATH: '/opt/claude' }, true); + expect(result.status).toBe('fail'); + expect(result.message).toContain('did not spawn'); + expect(result.message).toContain('ENOENT'); + }); +}); + +describe('checkGhAuth', () => { + let execSpy: ReturnType>; + + beforeEach(() => { + execSpy = spyOn(git, 'execFileAsync'); + }); + + afterEach(() => { + execSpy.mockRestore(); + }); + + it('returns skip when no GitHub token is set', async () => { + const result = await checkGhAuth({}); + expect(result.status).toBe('skip'); + expect(result.message).toContain('GitHub not configured'); + expect(execSpy).not.toHaveBeenCalled(); + }); + + it('runs gh auth check when only GH_TOKEN is set', async () => { + execSpy.mockResolvedValue({ stdout: 'Logged in as @user', stderr: '' }); + const result = await checkGhAuth({ GH_TOKEN: 'ghp_y' }); + expect(result.status).toBe('pass'); + expect(execSpy).toHaveBeenCalledWith('gh', ['auth', 'status'], expect.any(Object)); + }); + + it('returns pass when gh auth status succeeds', async () => { + execSpy.mockResolvedValue({ stdout: 'Logged in as @user', stderr: '' }); + const result = await checkGhAuth({ GITHUB_TOKEN: 'ghp_x' }); + expect(result.status).toBe('pass'); + expect(execSpy).toHaveBeenCalledWith('gh', ['auth', 'status'], expect.any(Object)); + }); + + it('returns fail when gh auth status throws', async () => { + execSpy.mockRejectedValue(new Error('not logged in')); + const result = await checkGhAuth({ GH_TOKEN: 'ghp_y' }); + expect(result.status).toBe('fail'); + expect(result.message).toContain('not logged in'); + }); +}); + +describe('checkDatabase', () => { + it('returns pass when query succeeds', async () => { + const deps: DatabaseDeps = { + pool: { query: async () => undefined }, + getDatabaseType: () => 'sqlite', + }; + const result = await checkDatabase(async () => deps); + expect(result.status).toBe('pass'); + expect(result.message).toContain('sqlite'); + }); + + it('reports postgres dbType when configured', async () => { + const deps: DatabaseDeps = { + pool: { query: async () => undefined }, + getDatabaseType: () => 'postgres', + }; + const result = await checkDatabase(async () => deps); + expect(result.status).toBe('pass'); + expect(result.message).toContain('postgres'); + }); + + it('returns fail with "not reachable" when query throws', async () => { + const deps: DatabaseDeps = { + pool: { + query: async () => { + throw new Error('connection refused'); + }, + }, + getDatabaseType: () => 'postgres', + }; + const result = await checkDatabase(async () => deps); + expect(result.status).toBe('fail'); + expect(result.message).toContain('not reachable'); + expect(result.message).toContain('connection refused'); + }); + + it('returns fail with "failed to load" when module load throws', async () => { + const result = await checkDatabase(async () => { + throw new Error('Cannot find module @archon/core'); + }); + expect(result.status).toBe('fail'); + expect(result.message).toContain('failed to load database module'); + expect(result.message).toContain('Cannot find module'); + }); +}); + +describe('checkWorkspaceWritable', () => { + const TMP = join(tmpdir(), 'archon-doctor-test-' + Date.now()); + let originalHome: string | undefined; + + beforeEach(() => { + mkdirSync(TMP, { recursive: true }); + originalHome = process.env.ARCHON_HOME; + process.env.ARCHON_HOME = TMP; + }); + + afterEach(() => { + if (originalHome === undefined) { + delete process.env.ARCHON_HOME; + } else { + process.env.ARCHON_HOME = originalHome; + } + try { + rmSync(TMP, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + it('returns pass when directory is writable', async () => { + const result = await checkWorkspaceWritable(); + expect(result.status).toBe('pass'); + expect(result.message).toContain('writable'); + }); + + it('returns pass when directory does not exist (creates it)', async () => { + rmSync(TMP, { recursive: true, force: true }); + const result = await checkWorkspaceWritable(); + expect(result.status).toBe('pass'); + }); +}); + +describe('checkBundledDefaults', () => { + it('returns pass with workflow and command counts in dev mode', async () => { + const result = await checkBundledDefaults(); + expect(result.status).toBe('pass'); + expect(result.label).toBe('Bundled defaults'); + expect(result.message).toMatch(/\d+ workflow/); + expect(result.message).toMatch(/\d+ command/); + }); +}); + +describe('checkSlack', () => { + let fetchSpy: ReturnType>; + + beforeEach(() => { + fetchSpy = spyOn(globalThis, 'fetch'); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + }); + + it('returns skip when SLACK_BOT_TOKEN not set', async () => { + const result = await checkSlack({}); + expect(result.status).toBe('skip'); + expect(result.message).toContain('SLACK_BOT_TOKEN'); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('returns pass when auth.test responds ok', async () => { + fetchSpy.mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { status: 200 }) as unknown as Response + ); + const result = await checkSlack({ SLACK_BOT_TOKEN: 'xoxb-x' }); + expect(result.status).toBe('pass'); + }); + + it('returns fail when auth.test rejects with body.ok=false', async () => { + fetchSpy.mockResolvedValue( + new Response(JSON.stringify({ ok: false, error: 'invalid_auth' }), { + status: 200, + }) as unknown as Response + ); + const result = await checkSlack({ SLACK_BOT_TOKEN: 'xoxb-x' }); + expect(result.status).toBe('fail'); + expect(result.message).toContain('invalid_auth'); + }); + + it('returns skip on network error (best-effort by design)', async () => { + fetchSpy.mockRejectedValue(new Error('ECONNREFUSED')); + const result = await checkSlack({ SLACK_BOT_TOKEN: 'xoxb-x' }); + expect(result.status).toBe('skip'); + expect(result.message).toContain('ECONNREFUSED'); + }); +}); + +describe('checkTelegram', () => { + let fetchSpy: ReturnType>; + + beforeEach(() => { + fetchSpy = spyOn(globalThis, 'fetch'); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + }); + + it('returns skip when TELEGRAM_BOT_TOKEN not set', async () => { + const result = await checkTelegram({}); + expect(result.status).toBe('skip'); + expect(result.message).toContain('TELEGRAM_BOT_TOKEN'); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('returns pass when getMe responds ok', async () => { + fetchSpy.mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { status: 200 }) as unknown as Response + ); + const result = await checkTelegram({ TELEGRAM_BOT_TOKEN: '123:abc' }); + expect(result.status).toBe('pass'); + }); + + it('returns fail when getMe responds ok=false', async () => { + fetchSpy.mockResolvedValue( + new Response(JSON.stringify({ ok: false, description: 'Unauthorized' }), { + status: 401, + }) as unknown as Response + ); + const result = await checkTelegram({ TELEGRAM_BOT_TOKEN: '123:abc' }); + expect(result.status).toBe('fail'); + expect(result.message).toContain('Unauthorized'); + }); + + it('returns skip on network error (best-effort by design)', async () => { + fetchSpy.mockRejectedValue(new Error('ETIMEDOUT')); + const result = await checkTelegram({ TELEGRAM_BOT_TOKEN: '123:abc' }); + expect(result.status).toBe('skip'); + expect(result.message).toContain('ETIMEDOUT'); + }); +}); + +describe('doctorCommand', () => { + let logSpy: ReturnType>; + + beforeEach(() => { + logSpy = spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + logSpy.mockRestore(); + }); + + const passing = (label: string) => async () => + ({ label, status: 'pass', message: 'ok' }) as const; + const failing = (label: string) => async () => + ({ label, status: 'fail', message: 'broken' }) as const; + const skipping = (label: string) => async () => + ({ label, status: 'skip', message: 'no token' }) as const; + const throwing = (label: string) => async (): Promise => { + throw new Error(`${label} blew up`); + }; + + it('returns 0 when every check passes', async () => { + const exit = await doctorCommand([passing('A'), passing('B')]); + expect(exit).toBe(0); + }); + + it('returns 0 when checks are pass + skip (skip is not a failure)', async () => { + const exit = await doctorCommand([passing('A'), skipping('B')]); + expect(exit).toBe(0); + }); + + it('returns 1 when any check fails', async () => { + const exit = await doctorCommand([passing('A'), failing('B')]); + expect(exit).toBe(1); + }); + + it('counts a thrown check as a failure (allSettled rejection branch)', async () => { + const exit = await doctorCommand([passing('A'), throwing('B')]); + expect(exit).toBe(1); + }); + + it('continues after a thrown check (Promise.allSettled does not short-circuit)', async () => { + const exit = await doctorCommand([throwing('A'), passing('B'), failing('C')]); + // 1 throw + 1 fail = 2 failures, but exit code is still 1. + expect(exit).toBe(1); + // Verify all three were rendered (one per ✓/✗/unknown line). + const renderedLines = logSpy.mock.calls + .map(args => String(args[0] ?? '')) + .filter(s => s.startsWith('✓') || s.startsWith('✗') || s.startsWith('○')); + expect(renderedLines.length).toBeGreaterThanOrEqual(2); + }); +}); diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts new file mode 100644 index 0000000000..d50723deed --- /dev/null +++ b/packages/cli/src/commands/doctor.ts @@ -0,0 +1,259 @@ +/** + * Doctor command - Verifies the local Archon setup. + * + * Also invoked from the end of `archon setup`; the setup wizard discards the + * return value so a doctor failure does not abort setup (the env file was + * already written successfully). + */ +import { mkdirSync, writeFileSync, rmSync } from 'fs'; +import { join } from 'path'; +import { execFileAsync } from '@archon/git'; +import { BUNDLED_IS_BINARY, getArchonHome, createLogger } from '@archon/paths'; + +let cachedLog: ReturnType | undefined; +function getLog(): ReturnType { + if (!cachedLog) cachedLog = createLogger('cli.doctor'); + return cachedLog; +} + +export interface CheckResult { + label: string; + status: 'pass' | 'fail' | 'skip'; + message: string; +} + +export async function checkClaudeBinary( + env: NodeJS.ProcessEnv, + // Injected so tests can drive the binary-mode branch — `BUNDLED_IS_BINARY` + // is a static const re-export and cannot be spied at runtime. + isBinary: boolean = BUNDLED_IS_BINARY +): Promise { + const label = 'Claude binary'; + if (!isBinary) { + return { label, status: 'skip', message: 'dev mode (SDK resolves via node_modules)' }; + } + const path = env.CLAUDE_BIN_PATH; + if (!path) { + return { + label, + status: 'fail', + message: 'CLAUDE_BIN_PATH is not set. Run `archon setup` to configure.', + }; + } + try { + await execFileAsync(path, ['--version'], { timeout: 5000 }); + return { label, status: 'pass', message: `${path} (spawns OK)` }; + } catch (err) { + return { + label, + status: 'fail', + message: `${path} did not spawn: ${(err as Error).message}`, + }; + } +} + +export async function checkGhAuth(env: NodeJS.ProcessEnv): Promise { + const label = 'gh CLI'; + // Skip for users without GitHub configured — gh auth is irrelevant + // to a CLI-only or Slack/Telegram setup, so reporting fail would be noise. + if (!env.GITHUB_TOKEN && !env.GH_TOKEN) { + return { label, status: 'skip', message: 'GitHub not configured (no GITHUB_TOKEN)' }; + } + try { + await execFileAsync('gh', ['auth', 'status'], { timeout: 10_000 }); + return { label, status: 'pass', message: 'authenticated' }; + } catch (err) { + return { + label, + status: 'fail', + message: `gh auth status failed: ${(err as Error).message}. Run \`gh auth login\`.`, + }; + } +} + +export interface DatabaseDeps { + pool: { query: (sql: string) => Promise }; + getDatabaseType: () => string; +} + +export async function checkDatabase( + // Injected so tests can drive both code paths without mocking the dynamic + // import. Falls back to the lazy `@archon/core` import in production. + loadDeps: () => Promise = defaultLoadDatabaseDeps +): Promise { + const label = 'Database'; + let deps: DatabaseDeps; + try { + deps = await loadDeps(); + } catch (err) { + // Distinguish module-load failure from query failure — surfacing + // "not reachable" for an import error misleads the user into running + // `archon setup` when the real fix is a binary rebuild. + getLog().error({ err }, 'doctor.db_module_load_failed'); + return { + label, + status: 'fail', + message: `failed to load database module: ${(err as Error).message}`, + }; + } + try { + const dbType = deps.getDatabaseType(); + await deps.pool.query('SELECT 1'); + return { label, status: 'pass', message: `reachable (${dbType})` }; + } catch (err) { + getLog().error({ err }, 'doctor.db_query_failed'); + return { label, status: 'fail', message: `not reachable: ${(err as Error).message}` }; + } +} + +async function defaultLoadDatabaseDeps(): Promise { + // Lazy import so doctor doesn't pull in the full @archon/core graph just to + // print --help or run a different check. + const { pool, getDatabaseType } = await import('@archon/core'); + return { pool, getDatabaseType }; +} + +export async function checkWorkspaceWritable(): Promise { + const label = 'Workspace'; + const home = getArchonHome(); + const probe = join(home, `.doctor-probe-${process.pid}-${Date.now()}`); + try { + mkdirSync(home, { recursive: true }); + writeFileSync(probe, 'ok'); + } catch (err) { + return { label, status: 'fail', message: `${home} not writable: ${(err as Error).message}` }; + } + try { + rmSync(probe, { force: true }); + } catch (err) { + // Deletion failure is cosmetic — the write succeeded, so the dir is + // writable. Log so repeated failures leave a diagnostic trace instead of + // silently accumulating .doctor-probe-* files in ARCHON_HOME. + getLog().warn({ probe, err }, 'doctor.workspace_probe_delete_failed'); + } + return { label, status: 'pass', message: `${home} is writable` }; +} + +export async function checkBundledDefaults(): Promise { + const label = 'Bundled defaults'; + try { + const { BUNDLED_COMMANDS, BUNDLED_WORKFLOWS } = await import('@archon/workflows/defaults'); + const commands = Object.keys(BUNDLED_COMMANDS).length; + const workflows = Object.keys(BUNDLED_WORKFLOWS).length; + return { + label, + status: 'pass', + message: `${workflows} workflow(s), ${commands} command(s) loaded`, + }; + } catch (err) { + return { label, status: 'fail', message: `failed to load: ${(err as Error).message}` }; + } +} + +export async function checkSlack(env: NodeJS.ProcessEnv): Promise { + const label = 'Slack'; + const token = env.SLACK_BOT_TOKEN; + if (!token) { + return { label, status: 'skip', message: 'no SLACK_BOT_TOKEN set' }; + } + try { + const res = await fetch('https://slack.com/api/auth.test', { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(5000), + }); + const body = (await res.json()) as { ok?: boolean; error?: string }; + if (body.ok) { + return { label, status: 'pass', message: 'auth.test OK' }; + } + return { label, status: 'fail', message: `auth.test rejected: ${body.error ?? 'unknown'}` }; + } catch (err) { + // Network errors → skip, not fail — best-effort by design. + return { + label, + status: 'skip', + message: `ping skipped (${(err as Error).message})`, + }; + } +} + +export async function checkTelegram(env: NodeJS.ProcessEnv): Promise { + const label = 'Telegram'; + const token = env.TELEGRAM_BOT_TOKEN; + if (!token) { + return { label, status: 'skip', message: 'no TELEGRAM_BOT_TOKEN set' }; + } + try { + const res = await fetch(`https://api.telegram.org/bot${token}/getMe`, { + signal: AbortSignal.timeout(5000), + }); + const body = (await res.json()) as { ok?: boolean; description?: string }; + if (body.ok) { + return { label, status: 'pass', message: 'getMe OK' }; + } + return { + label, + status: 'fail', + message: `getMe rejected: ${body.description ?? 'unknown'}`, + }; + } catch (err) { + return { + label, + status: 'skip', + message: `ping skipped (${(err as Error).message})`, + }; + } +} + +function renderResult(r: CheckResult): string { + const icon = r.status === 'pass' ? '✓' : r.status === 'fail' ? '✗' : '○'; + return `${icon} ${r.label}: ${r.message}`; +} + +export async function doctorCommand( + // Injected so tests can drive the exit-code contract and the + // Promise.allSettled rejection branch with synthetic checks. + checks?: (() => Promise)[] +): Promise { + console.log('archon doctor — verifying your setup\n'); + getLog().info('doctor.run_started'); + const env = process.env; + + const promises = checks + ? checks.map(fn => fn()) + : [ + checkClaudeBinary(env), + checkGhAuth(env), + checkDatabase(), + checkWorkspaceWritable(), + checkBundledDefaults(), + checkSlack(env), + checkTelegram(env), + ]; + + // Promise.allSettled so one unexpected rejection doesn't skip remaining checks. + const settled = await Promise.allSettled(promises); + + let failures = 0; + for (const s of settled) { + if (s.status === 'rejected') { + failures++; + const msg = s.reason instanceof Error ? s.reason.message : String(s.reason); + console.log(`✗ unknown: check threw: ${msg}`); + getLog().error({ reason: s.reason }, 'doctor.check_threw_unexpectedly'); + continue; + } + if (s.value.status === 'fail') failures++; + console.log(renderResult(s.value)); + } + + console.log(''); + if (failures === 0) { + console.log('All checks passed.'); + getLog().info('doctor.run_completed'); + return 0; + } + console.log(`${failures} check(s) failed. Run \`archon setup\` to reconfigure.`); + getLog().warn({ failures }, 'doctor.run_failed'); + return 1; +} diff --git a/packages/cli/src/commands/setup.test.ts b/packages/cli/src/commands/setup.test.ts index bb73eec09a..c64cb064dc 100644 --- a/packages/cli/src/commands/setup.test.ts +++ b/packages/cli/src/commands/setup.test.ts @@ -6,6 +6,7 @@ import { existsSync, readFileSync, mkdirSync, writeFileSync, rmSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; import { + bootstrapProjectConfig, checkExistingConfig, generateEnvContent, generateWebhookSecret, @@ -99,30 +100,6 @@ CODEX_ACCOUNT_ID=account1 expect(result?.platforms.telegram).toBe(true); expect(result?.platforms.github).toBe(false); expect(result?.platforms.slack).toBe(false); - expect(result?.platforms.discord).toBe(false); - expect(result?.hasDatabase).toBe(false); - - if (originalHome === undefined) { - delete process.env.ARCHON_HOME; - } else { - process.env.ARCHON_HOME = originalHome; - } - }); - - it('should detect PostgreSQL database configuration', () => { - const envDir = join(TEST_DIR, '.archon2'); - mkdirSync(envDir, { recursive: true }); - const envPath = join(envDir, '.env'); - - writeFileSync(envPath, 'DATABASE_URL=postgresql://localhost:5432/test'); - - const originalHome = process.env.ARCHON_HOME; - process.env.ARCHON_HOME = envDir; - - const result = checkExistingConfig(); - - expect(result).not.toBeNull(); - expect(result?.hasDatabase).toBe(true); if (originalHome === undefined) { delete process.env.ARCHON_HOME; @@ -135,7 +112,6 @@ CODEX_ACCOUNT_ID=account1 describe('generateEnvContent', () => { it('should generate valid .env content for SQLite configuration', () => { const content = generateEnvContent({ - database: { type: 'sqlite' }, ai: { claude: true, claudeAuthType: 'global', @@ -146,7 +122,6 @@ CODEX_ACCOUNT_ID=account1 github: false, telegram: false, slack: false, - discord: false, }, botDisplayName: 'Archon', }); @@ -157,36 +132,13 @@ CODEX_ACCOUNT_ID=account1 // PORT is intentionally commented out — server and Vite both default to 3090 when unset (#1152). expect(content).toContain('# PORT=3090'); expect(content).not.toMatch(/^PORT=/m); - expect(content).not.toContain('DATABASE_URL='); - }); - - it('should generate valid .env content for PostgreSQL configuration', () => { - const content = generateEnvContent({ - database: { type: 'postgresql', url: 'postgresql://localhost:5432/archon' }, - ai: { - claude: true, - claudeAuthType: 'apiKey', - claudeApiKey: 'sk-test-key', - codex: false, - defaultAssistant: 'claude', - }, - platforms: { - github: false, - telegram: false, - slack: false, - discord: false, - }, - botDisplayName: 'Archon', - }); - - expect(content).toContain('DATABASE_URL=postgresql://localhost:5432/archon'); - expect(content).toContain('CLAUDE_USE_GLOBAL_AUTH=false'); - expect(content).toContain('CLAUDE_API_KEY=sk-test-key'); + // Sanity: never emit an active DATABASE_URL line. The "# Set DATABASE_URL=..." + // hint is a comment and is fine — only an unprefixed assignment would be wrong. + expect(content).not.toMatch(/^DATABASE_URL=/m); }); it('emits CLAUDE_BIN_PATH when claudeBinaryPath is configured', () => { const content = generateEnvContent({ - database: { type: 'sqlite' }, ai: { claude: true, claudeAuthType: 'global', @@ -194,7 +146,7 @@ CODEX_ACCOUNT_ID=account1 codex: false, defaultAssistant: 'claude', }, - platforms: { github: false, telegram: false, slack: false, discord: false }, + platforms: { github: false, telegram: false, slack: false }, botDisplayName: 'Archon', }); @@ -205,14 +157,13 @@ CODEX_ACCOUNT_ID=account1 it('omits CLAUDE_BIN_PATH when not configured', () => { const content = generateEnvContent({ - database: { type: 'sqlite' }, ai: { claude: true, claudeAuthType: 'global', codex: false, defaultAssistant: 'claude', }, - platforms: { github: false, telegram: false, slack: false, discord: false }, + platforms: { github: false, telegram: false, slack: false }, botDisplayName: 'Archon', }); @@ -221,7 +172,6 @@ CODEX_ACCOUNT_ID=account1 it('should include platform configurations', () => { const content = generateEnvContent({ - database: { type: 'sqlite' }, ai: { claude: true, claudeAuthType: 'global', @@ -232,7 +182,6 @@ CODEX_ACCOUNT_ID=account1 github: true, telegram: true, slack: false, - discord: false, }, github: { token: 'ghp_testtoken', @@ -259,7 +208,6 @@ CODEX_ACCOUNT_ID=account1 it('should include Codex tokens when configured', () => { const content = generateEnvContent({ - database: { type: 'sqlite' }, ai: { claude: false, codex: true, @@ -275,7 +223,6 @@ CODEX_ACCOUNT_ID=account1 github: false, telegram: false, slack: false, - discord: false, }, botDisplayName: 'Archon', }); @@ -289,7 +236,6 @@ CODEX_ACCOUNT_ID=account1 it('should include custom bot display name', () => { const content = generateEnvContent({ - database: { type: 'sqlite' }, ai: { claude: true, claudeAuthType: 'global', @@ -300,7 +246,6 @@ CODEX_ACCOUNT_ID=account1 github: false, telegram: false, slack: false, - discord: false, }, botDisplayName: 'MyCustomBot', }); @@ -310,7 +255,6 @@ CODEX_ACCOUNT_ID=account1 it('should not include bot display name when default', () => { const content = generateEnvContent({ - database: { type: 'sqlite' }, ai: { claude: true, claudeAuthType: 'global', @@ -321,7 +265,6 @@ CODEX_ACCOUNT_ID=account1 github: false, telegram: false, slack: false, - discord: false, }, botDisplayName: 'Archon', }); @@ -331,7 +274,6 @@ CODEX_ACCOUNT_ID=account1 it('should include Slack configuration', () => { const content = generateEnvContent({ - database: { type: 'sqlite' }, ai: { claude: true, claudeAuthType: 'global', @@ -342,7 +284,6 @@ CODEX_ACCOUNT_ID=account1 github: false, telegram: false, slack: true, - discord: false, }, slack: { botToken: 'xoxb-test', @@ -357,33 +298,6 @@ CODEX_ACCOUNT_ID=account1 expect(content).toContain('SLACK_ALLOWED_USER_IDS=U123'); expect(content).toContain('SLACK_STREAMING_MODE=batch'); }); - - it('should include Discord configuration', () => { - const content = generateEnvContent({ - database: { type: 'sqlite' }, - ai: { - claude: true, - claudeAuthType: 'global', - codex: false, - defaultAssistant: 'claude', - }, - platforms: { - github: false, - telegram: false, - slack: false, - discord: true, - }, - discord: { - botToken: 'discord-bot-token-test', - allowedUserIds: '123456789', - }, - botDisplayName: 'Archon', - }); - - expect(content).toContain('DISCORD_BOT_TOKEN=discord-bot-token-test'); - expect(content).toContain('DISCORD_ALLOWED_USER_IDS=123456789'); - expect(content).toContain('DISCORD_STREAMING_MODE=batch'); - }); }); describe('spawnTerminalWithSetup', () => { @@ -460,6 +374,65 @@ CODEX_ACCOUNT_ID=account1 expect(existsSync(join(target, '.claude', 'skills', 'archon', 'SKILL.md'))).toBe(true); }); }); + + describe('bootstrapProjectConfig', () => { + it('creates .archon/config.yaml when it does not exist', () => { + const target = join(TEST_DIR, 'bootstrap-target'); + mkdirSync(target, { recursive: true }); + + const result = bootstrapProjectConfig(target); + + expect(result.state).toBe('created'); + expect(result.path).toBe(join(target, '.archon', 'config.yaml')); + expect(existsSync(result.path)).toBe(true); + const content = readFileSync(result.path, 'utf-8'); + // Must be valid YAML — comment lines only — so loaders treat it as empty. + expect(content.split('\n').every(line => line === '' || line.startsWith('#'))).toBe(true); + expect(content).toContain('Project-scoped Archon config'); + expect(content).toContain('archon.diy/reference/configuration'); + }); + + it('creates the .archon directory if missing (idempotent on parent)', () => { + const target = join(TEST_DIR, 'bootstrap-no-archon-dir'); + mkdirSync(target, { recursive: true }); + // Do NOT pre-create .archon — bootstrap must create it + + const result = bootstrapProjectConfig(target); + + expect(result.state).toBe('created'); + expect(existsSync(join(target, '.archon'))).toBe(true); + }); + + it('is idempotent — leaves an existing config untouched', () => { + const target = join(TEST_DIR, 'bootstrap-existing'); + const archonDir = join(target, '.archon'); + mkdirSync(archonDir, { recursive: true }); + const userContent = '# my custom config\nassistants:\n claude:\n model: opus\n'; + writeFileSync(join(archonDir, 'config.yaml'), userContent); + + const result = bootstrapProjectConfig(target); + + expect(result.state).toBe('existed'); + const after = readFileSync(join(archonDir, 'config.yaml'), 'utf-8'); + expect(after).toBe(userContent); + }); + + it('returns failed state without throwing when the target path is unwritable', () => { + // Pointing at a path inside a non-existent parent that mkdirSync can + // create succeeds. Use a deeply-nested path inside a regular file + // (which fs cannot mkdir into) to force a real failure. + const blocker = join(TEST_DIR, 'blocker-file'); + writeFileSync(blocker, 'not a directory'); + // mkdir under a file path fails with ENOTDIR — that's the failure mode + // we want to model (read-only FS, permission denied, etc.). + const result = bootstrapProjectConfig(blocker); + + expect(result.state).toBe('failed'); + if (result.state === 'failed') { + expect(result.error.length).toBeGreaterThan(0); + } + }); + }); }); describe('detectClaudeExecutablePath probe order', () => { diff --git a/packages/cli/src/commands/setup.ts b/packages/cli/src/commands/setup.ts index 42ca63e3a4..eca05654fa 100644 --- a/packages/cli/src/commands/setup.ts +++ b/packages/cli/src/commands/setup.ts @@ -2,9 +2,11 @@ * Setup command - Interactive CLI wizard for Archon credential configuration * * Guides users through configuring: - * - Database (SQLite default vs PostgreSQL) * - AI assistants (Claude and/or Codex) - * - Platform connections (GitHub, Telegram, Slack, Discord) + * - Platform connections (GitHub, Telegram, Slack — all skippable) + * + * SQLite is the implicit default; no database prompt. PostgreSQL users set + * DATABASE_URL by hand (documented separately). * * Writes configuration to one archon-owned env file, chosen by --scope: * - 'home' (default) → ~/.archon/.env @@ -38,7 +40,8 @@ import { join, dirname } from 'path'; import { copyArchonSkill } from './skill'; import { homedir } from 'os'; import { randomBytes } from 'crypto'; -import { spawn, execSync, type ChildProcess } from 'child_process'; +import { spawn, execSync, spawnSync, type ChildProcess } from 'child_process'; +import { execFileAsync } from '@archon/git'; import { getRegisteredProviders } from '@archon/providers'; import { getArchonEnvPath as pathsGetArchonEnvPath, @@ -50,10 +53,6 @@ import { // ============================================================================= interface SetupConfig { - database: { - type: 'sqlite' | 'postgresql'; - url?: string; - }; ai: { claude: boolean; claudeAuthType?: 'global' | 'apiKey' | 'oauthToken'; @@ -70,12 +69,10 @@ interface SetupConfig { github: boolean; telegram: boolean; slack: boolean; - discord: boolean; }; github?: GitHubConfig; telegram?: TelegramConfig; slack?: SlackConfig; - discord?: DiscordConfig; botDisplayName: string; } @@ -97,11 +94,6 @@ interface SlackConfig { allowedUserIds: string; } -interface DiscordConfig { - botToken: string; - allowedUserIds: string; -} - interface CodexTokens { idToken: string; accessToken: string; @@ -110,14 +102,12 @@ interface CodexTokens { } interface ExistingConfig { - hasDatabase: boolean; hasClaude: boolean; hasCodex: boolean; platforms: { github: boolean; telegram: boolean; slack: boolean; - discord: boolean; }; } @@ -343,7 +333,6 @@ export function checkExistingConfig(envPath?: string): ExistingConfig | null { const content = readFileSync(path, 'utf-8'); return { - hasDatabase: hasEnvValue(content, 'DATABASE_URL'), hasClaude: hasEnvValue(content, 'CLAUDE_API_KEY') || hasEnvValue(content, 'CLAUDE_CODE_OAUTH_TOKEN') || @@ -357,7 +346,6 @@ export function checkExistingConfig(envPath?: string): ExistingConfig | null { github: hasEnvValue(content, 'GITHUB_TOKEN') || hasEnvValue(content, 'GH_TOKEN'), telegram: hasEnvValue(content, 'TELEGRAM_BOT_TOKEN'), slack: hasEnvValue(content, 'SLACK_BOT_TOKEN') && hasEnvValue(content, 'SLACK_APP_TOKEN'), - discord: hasEnvValue(content, 'DISCORD_BOT_TOKEN'), }, }; } @@ -366,53 +354,6 @@ export function checkExistingConfig(envPath?: string): ExistingConfig | null { // Data Collection Functions // ============================================================================= -/** - * Collect database configuration - */ -async function collectDatabaseConfig(): Promise { - const dbType = await select({ - message: 'Which database do you want to use?', - options: [ - { - value: 'sqlite', - label: 'SQLite (default - no setup needed)', - hint: 'Recommended for single user', - }, - { value: 'postgresql', label: 'PostgreSQL', hint: 'For server deployments' }, - ], - }); - - if (isCancel(dbType)) { - cancel('Setup cancelled.'); - process.exit(0); - } - - if (dbType === 'postgresql') { - const url = await text({ - message: 'Enter your PostgreSQL connection string:', - placeholder: 'postgresql://user:pass@localhost:5432/archon', - validate: value => { - if (!value) { - return 'Connection string is required'; - } - if (!value.startsWith('postgresql://') && !value.startsWith('postgres://')) { - return 'Must be a valid PostgreSQL URL (postgresql:// or postgres://)'; - } - return undefined; - }, - }); - - if (isCancel(url)) { - cancel('Setup cancelled.'); - process.exit(0); - } - - return { type: 'postgresql', url }; - } - - return { type: 'sqlite' }; -} - /** * Try to read Codex tokens from ~/.codex/auth.json */ @@ -455,8 +396,22 @@ function tryReadCodexAuth(): CodexTokens | null { } /** - * Collect Claude authentication method + * Try to spawn the Claude binary with `--version` to confirm it actually runs. + * Returns `{ ok: true }` on success or `{ ok: false, reason }` with the spawn + * error message so the caller can show it to the user. Bounded to 5s so a hung + * process can't stall setup. */ +async function probeClaudeBinarySpawns( + path: string +): Promise<{ ok: true } | { ok: false; reason: string }> { + try { + await execFileAsync(path, ['--version'], { timeout: 5000 }); + return { ok: true }; + } catch (err) { + return { ok: false, reason: (err as Error).message }; + } +} + /** * Resolve the Claude Code executable path for CLAUDE_BIN_PATH. * Auto-detects common install locations and falls back to prompting the user. @@ -467,8 +422,10 @@ async function collectClaudeBinaryPath(): Promise { const detected = detectClaudeExecutablePath(); if (detected) { + const probe = await probeClaudeBinarySpawns(detected); + const suffix = probe.ok ? '(spawns OK)' : `(could not spawn: ${probe.reason})`; const useDetected = await confirm({ - message: `Found Claude Code at ${detected}. Write this to CLAUDE_BIN_PATH?`, + message: `Found Claude Code at ${detected} ${suffix}. Write this to CLAUDE_BIN_PATH?`, initialValue: true, }); if (isCancel(useDetected)) { @@ -509,10 +466,21 @@ async function collectClaudeBinaryPath(): Promise { log.warning( `Path does not exist: ${trimmed}. Saving anyway — the compiled binary will error on first use until this is correct.` ); + return trimmed; + } + + const probe = await probeClaudeBinarySpawns(trimmed); + if (!probe.ok) { + log.warning( + `Could not spawn ${trimmed} --version: ${probe.reason}. Saving anyway — verify the binary works (try running it directly).` + ); } return trimmed; } +/** + * Collect Claude authentication method (API key, OAuth token, or global auth). + */ async function collectClaudeAuth(): Promise<{ authType: 'global' | 'apiKey' | 'oauthToken'; apiKey?: string; @@ -884,12 +852,12 @@ After upgrading, run 'archon setup' again.`, */ async function collectPlatforms(): Promise { const platforms = await multiselect({ - message: 'Which platforms do you want to connect? (↑↓ navigate, space select, enter confirm)', + message: + 'Which chat adapters do you want to connect? (all optional — Archon works as CLI + skill without any)\n(↑↓ navigate, space select, enter confirm)', options: [ { value: 'github', label: 'GitHub', hint: 'Respond to issues/PRs via webhooks' }, { value: 'telegram', label: 'Telegram', hint: 'Chat bot via BotFather' }, { value: 'slack', label: 'Slack', hint: 'Workspace app with Socket Mode' }, - { value: 'discord', label: 'Discord', hint: 'Server bot' }, ], required: false, }); @@ -903,7 +871,6 @@ async function collectPlatforms(): Promise { github: platforms.includes('github'), telegram: platforms.includes('telegram'), slack: platforms.includes('slack'), - discord: platforms.includes('discord'), }; } @@ -939,6 +906,58 @@ async function collectGitHubConfig(): Promise { process.exit(0); } + // Probe `gh` CLI auth — workflows that shell out to `gh` (e.g. `gh issue + // create`, `gh pr edit`) need this even if the PAT is set, because they call + // the local `gh` binary, not the API directly. + const ghSpin = spinner(); + ghSpin.start('Checking gh CLI authentication...'); + let ghAuthOk = false; + let ghAuthError: string | undefined; + try { + await execFileAsync('gh', ['auth', 'status'], { timeout: 10_000 }); + ghAuthOk = true; + ghSpin.stop('gh CLI is authenticated'); + } catch (err) { + const e = err as NodeJS.ErrnoException; + ghAuthError = + e.code === 'ENOENT' + ? 'gh not found in PATH — install it first (https://cli.github.com)' + : (e.message ?? 'unknown error'); + ghSpin.stop('gh CLI check failed'); + } + + if (!ghAuthOk) { + log.warning( + `gh auth check failed: ${ghAuthError}\n` + + (ghAuthError?.includes('not found') ? '' : 'Run: gh auth login') + ); + // gh auth login is an interactive OAuth flow — only offer it from a TTY. + if (process.stdout.isTTY) { + const runGhLogin = await confirm({ + message: 'Run `gh auth login` now?', + initialValue: true, + }); + if (!isCancel(runGhLogin) && runGhLogin) { + // spawnSync with inherited stdio so the OAuth prompt reaches the terminal. + const ghLoginResult = spawnSync('gh', ['auth', 'login'], { stdio: 'inherit' }); + if (ghLoginResult.error) { + log.warning( + `Could not run gh auth login: ${ghLoginResult.error.message}. ` + + 'Install the gh CLI from https://cli.github.com/ and run it manually.' + ); + } else if (ghLoginResult.status !== 0) { + // gh exited non-zero (user cancelled, OAuth callback failed, etc.). + // .error is only set on spawn failure, so without this the wizard + // would proceed as if auth succeeded. + log.warning( + `gh auth login exited with code ${ghLoginResult.status ?? 'null'}. ` + + 'Authentication may not have completed — re-run `gh auth login` manually if needed.' + ); + } + } + } + } + const allowedUsers = await text({ message: 'Enter allowed GitHub usernames (comma-separated, or leave empty for all):', placeholder: 'username1,username2', @@ -994,6 +1013,15 @@ async function collectGitHubConfig(): Promise { * Collect Telegram credentials */ async function collectTelegramConfig(): Promise { + note( + 'SECURITY: Telegram bots are public by default — anyone can DM your bot.\n' + + 'Set TELEGRAM_ALLOWED_USER_IDS to restrict access to your user ID only.\n\n' + + 'To find your user ID:\n' + + '1. Open Telegram and search for @userinfobot\n' + + '2. Send any message — it replies with your user ID (a number)', + 'Telegram Security' + ); + note( 'Telegram Bot Setup\n\n' + 'Step 1: Create your bot\n' + @@ -1001,11 +1029,7 @@ async function collectTelegramConfig(): Promise { '2. Send /newbot\n' + '3. Choose a display name (e.g., "My Archon Bot")\n' + '4. Choose a username (must end in "bot")\n' + - '5. Copy the token BotFather gives you\n\n' + - 'Step 2: Get your user ID\n' + - '1. Search for @userinfobot on Telegram\n' + - '2. Send any message\n' + - '3. It will reply with your user ID (a number)', + '5. Copy the token BotFather gives you', 'Telegram Setup' ); @@ -1024,8 +1048,11 @@ async function collectTelegramConfig(): Promise { process.exit(0); } + // Do NOT set required: true — clack's text() blocks the enter key when + // required is true and the value is empty, which traps the user. Validate + // post-hoc with a warning instead. const allowedUserIds = await text({ - message: 'Enter allowed Telegram user IDs (comma-separated, or leave empty for all):', + message: 'Enter allowed Telegram user IDs (comma-separated):', placeholder: '123456789,987654321', }); @@ -1034,6 +1061,13 @@ async function collectTelegramConfig(): Promise { process.exit(0); } + if (!allowedUserIds?.trim()) { + log.warning( + 'No allowlist set — your Telegram bot will accept messages from ANYONE.\n' + + 'Add TELEGRAM_ALLOWED_USER_IDS to ~/.archon/.env after setup to restrict access.' + ); + } + return { botToken, allowedUserIds: allowedUserIds || '', @@ -1110,58 +1144,6 @@ async function collectSlackConfig(): Promise { }; } -/** - * Collect Discord credentials - */ -async function collectDiscordConfig(): Promise { - note( - 'Discord Bot Setup\n\n' + - '1. Go to discord.com/developers/applications\n' + - '2. Click "New Application" and name it\n' + - '3. Go to "Bot" in sidebar:\n' + - ' - Click "Reset Token" and copy it\n' + - ' - Enable "MESSAGE CONTENT INTENT"\n' + - '4. Go to "OAuth2" -> "URL Generator":\n' + - ' - Select scope: bot\n' + - ' - Select permissions: Send Messages, Read Message History\n' + - ' - Open generated URL to add bot to your server\n\n' + - 'Get your user ID:\n' + - '- Discord Settings -> Advanced -> Enable Developer Mode\n' + - '- Right-click yourself -> Copy User ID', - 'Discord Setup' - ); - - const botToken = await password({ - message: 'Enter your Discord Bot Token:', - validate: value => { - if (!value || value.length < 50) { - return 'Please enter a valid Discord bot token'; - } - return undefined; - }, - }); - - if (isCancel(botToken)) { - cancel('Setup cancelled.'); - process.exit(0); - } - - const allowedUserIds = await text({ - message: 'Enter allowed Discord user IDs (comma-separated, or leave empty for all):', - placeholder: '123456789012345678,987654321098765432', - }); - - if (isCancel(allowedUserIds)) { - cancel('Setup cancelled.'); - process.exit(0); - } - - return { - botToken, - allowedUserIds: allowedUserIds || '', - }; -} - /** * Collect bot display name */ @@ -1213,11 +1195,8 @@ export function generateEnvContent(config: SetupConfig): string { // Database lines.push('# Database'); - if (config.database.type === 'postgresql' && config.database.url) { - lines.push(`DATABASE_URL=${config.database.url}`); - } else { - lines.push('# Using SQLite (default) - no DATABASE_URL needed'); - } + lines.push('# Using SQLite (default) - no DATABASE_URL needed'); + lines.push('# Set DATABASE_URL=postgresql://... to use PostgreSQL instead.'); lines.push(''); // AI Assistants @@ -1293,17 +1272,6 @@ export function generateEnvContent(config: SetupConfig): string { lines.push(''); } - // Discord - if (config.platforms.discord && config.discord) { - lines.push('# Discord'); - lines.push(`DISCORD_BOT_TOKEN=${config.discord.botToken}`); - if (config.discord.allowedUserIds) { - lines.push(`DISCORD_ALLOWED_USER_IDS=${config.discord.allowedUserIds}`); - } - lines.push('DISCORD_STREAMING_MODE=batch'); - lines.push(''); - } - // Bot Display Name if (config.botDisplayName !== 'Archon') { lines.push('# Bot Display Name'); @@ -1338,6 +1306,63 @@ export function resolveScopedEnvPath(scope: 'home' | 'project', repoPath: string return pathsGetArchonEnvPath(); } +/** + * Result of attempting to bootstrap project-scoped Archon config. + * - `created`: `.archon/config.yaml` did not exist; we wrote a starter. + * - `existed`: file already present; left untouched (idempotent re-run). + * - `failed`: mkdir or write failed (permissions, read-only FS, etc.). + * Setup continues — the user can hand-create the file later. + */ +export type BootstrapProjectConfigResult = + | { state: 'created'; path: string } + | { state: 'existed'; path: string } + | { state: 'failed'; path: string; error: string }; + +/** + * Create `/.archon/config.yaml` with a commented-out template if + * absent. Pairs with the skill install — gives the user a place to put + * per-project overrides without manual mkdir. Workflows/commands/scripts + * subdirs are intentionally not created; empty directories would clutter + * users' trees and Archon's loaders handle their absence cleanly. + */ +export function bootstrapProjectConfig(projectPath: string): BootstrapProjectConfigResult { + const archonDir = join(projectPath, '.archon'); + const configPath = join(archonDir, 'config.yaml'); + try { + mkdirSync(archonDir, { recursive: true }); + // `wx` flag = exclusive create. Atomic against a concurrent create between + // a check and a write, so an in-flight user edit is never overwritten. + writeFileSync( + configPath, + [ + '# Project-scoped Archon config', + '# Inherits defaults from ~/.archon/config.yaml.', + '# Reference: https://archon.diy/reference/configuration/', + '#', + '# Examples:', + '# assistants:', + '# claude:', + '# model: sonnet', + '# docs:', + '# path: docs', + '', + ].join('\n'), + { mode: 0o644, flag: 'wx' } + ); + return { state: 'created', path: configPath }; + } catch (err) { + const e = err as NodeJS.ErrnoException; + if (e.code === 'EEXIST') { + return { state: 'existed', path: configPath }; + } + return { + state: 'failed', + path: configPath, + error: e.message, + }; + } +} + /** * Serialize a key/value map back to `KEY=value` lines. Values with whitespace, * `#`, `"`, `'`, `\n`, or `\r` are double-quoted with `\\`, `"`, `\n`, `\r` @@ -1648,10 +1673,8 @@ export async function setupCommand(options: SetupOptions): Promise { if (existing.platforms.github) configuredPlatforms.push('GitHub'); if (existing.platforms.telegram) configuredPlatforms.push('Telegram'); if (existing.platforms.slack) configuredPlatforms.push('Slack'); - if (existing.platforms.discord) configuredPlatforms.push('Discord'); const summary = [ - `Database: ${existing.hasDatabase ? 'PostgreSQL' : 'SQLite'}`, `Claude: ${existing.hasClaude ? 'Configured' : 'Not configured'}`, `Codex: ${existing.hasCodex ? 'Configured' : 'Not configured'}`, `Platforms: ${configuredPlatforms.length > 0 ? configuredPlatforms.join(', ') : 'None'}`, @@ -1687,7 +1710,6 @@ export async function setupCommand(options: SetupOptions): Promise { // Read existing config values - for simplicity, start with defaults and merge config = { - database: { type: 'sqlite' }, ai: { claude: existing?.hasClaude ?? false, codex: existing?.hasCodex ?? false, @@ -1697,7 +1719,6 @@ export async function setupCommand(options: SetupOptions): Promise { github: existing?.platforms.github ?? false, telegram: existing?.platforms.telegram ?? false, slack: existing?.platforms.slack ?? false, - discord: existing?.platforms.discord ?? false, }, botDisplayName: 'Archon', }; @@ -1713,7 +1734,6 @@ export async function setupCommand(options: SetupOptions): Promise { github: config.platforms.github || newPlatforms.github, telegram: config.platforms.telegram || newPlatforms.telegram, slack: config.platforms.slack || newPlatforms.slack, - discord: config.platforms.discord || newPlatforms.discord, }; // Collect credentials for new platforms only @@ -1726,17 +1746,11 @@ export async function setupCommand(options: SetupOptions): Promise { if (newPlatforms.slack && !existing?.platforms.slack) { config.slack = await collectSlackConfig(); } - if (newPlatforms.discord && !existing?.platforms.discord) { - config.discord = await collectDiscordConfig(); - } } else { - // Fresh or update mode - collect everything - const database = await collectDatabaseConfig(); const ai = await collectAIConfig(); const platforms = await collectPlatforms(); config = { - database, ai, platforms, botDisplayName: 'Archon', @@ -1752,9 +1766,6 @@ export async function setupCommand(options: SetupOptions): Promise { if (platforms.slack) { config.slack = await collectSlackConfig(); } - if (platforms.discord) { - config.discord = await collectDiscordConfig(); - } // Collect bot display name config.botDisplayName = await collectBotDisplayName(); @@ -1808,6 +1819,7 @@ export async function setupCommand(options: SetupOptions): Promise { } let skillInstalledPath: string | null = null; + let projectConfigCreatedPath: string | null = null; if (shouldCopySkill) { const skillTargetRaw = await text({ @@ -1832,6 +1844,16 @@ export async function setupCommand(options: SetupOptions): Promise { } s.stop('Archon skill installed'); skillInstalledPath = join(skillTarget, '.claude', 'skills', 'archon'); + + const bootstrapResult = bootstrapProjectConfig(skillTarget); + if (bootstrapResult.state === 'created') { + log.info(`Created project config: ${bootstrapResult.path}`); + projectConfigCreatedPath = bootstrapResult.path; + } else if (bootstrapResult.state === 'failed') { + // Non-fatal — log so silent permission errors don't masquerade as a + // successful setup. The user can hand-create the file later. + log.warn(`Could not create ${bootstrapResult.path}: ${bootstrapResult.error}`); + } } // Optional: configure docs directory @@ -1873,7 +1895,6 @@ export async function setupCommand(options: SetupOptions): Promise { if (config.platforms.github) configuredPlatforms.push('GitHub'); if (config.platforms.telegram) configuredPlatforms.push('Telegram'); if (config.platforms.slack) configuredPlatforms.push('Slack'); - if (config.platforms.discord) configuredPlatforms.push('Discord'); const aiConfigured: string[] = []; if (config.ai.claude) { @@ -1890,10 +1911,9 @@ export async function setupCommand(options: SetupOptions): Promise { } const summaryLines = [ - `Database: ${config.database.type === 'postgresql' ? 'PostgreSQL' : 'SQLite (default)'}`, `AI: ${aiConfigured.length > 0 ? aiConfigured.join(', ') : 'None configured'}`, `Default: ${config.ai.defaultAssistant}`, - `Platforms: ${configuredPlatforms.length > 0 ? configuredPlatforms.join(', ') : 'None'}`, + `Platforms: ${configuredPlatforms.length > 0 ? configuredPlatforms.join(', ') : 'None (CLI + skill only)'}`, '', `File written (${scope} scope):`, ` ${writeResult.targetPath}`, @@ -1910,6 +1930,11 @@ export async function setupCommand(options: SetupOptions): Promise { summaryLines.push(''); summaryLines.push('Archon skill installed:'); summaryLines.push(` ${skillInstalledPath}`); + if (projectConfigCreatedPath) { + summaryLines.push(''); + summaryLines.push('Project config created:'); + summaryLines.push(` ${projectConfigCreatedPath}`); + } } note(summaryLines.join('\n'), 'Configuration Complete'); @@ -1924,5 +1949,22 @@ export async function setupCommand(options: SetupOptions): Promise { 'Additional Options' ); - outro('Setup complete! Run `archon version` to verify.'); + note( + 'To update Archon:\n' + + ' Homebrew: brew upgrade coleam00/archon/archon\n' + + ' curl: curl -fsSL https://raw.githubusercontent.com/coleam00/Archon/main/scripts/install.sh | bash\n' + + ' Docker: docker pull ghcr.io/coleam00/archon:latest', + 'Update Instructions' + ); + + const runDoctor = await confirm({ + message: 'Run `archon doctor` now to verify your setup?', + initialValue: true, + }); + if (!isCancel(runDoctor) && runDoctor) { + const { doctorCommand } = await import('./doctor'); + await doctorCommand(); + } + + outro('Setup complete!'); } diff --git a/packages/docs-web/src/content/docs/getting-started/overview.md b/packages/docs-web/src/content/docs/getting-started/overview.md index 5125b93503..057c0d2784 100644 --- a/packages/docs-web/src/content/docs/getting-started/overview.md +++ b/packages/docs-web/src/content/docs/getting-started/overview.md @@ -304,6 +304,7 @@ archon workflow run --cwd /path/to/repo "" |---------|-------------| | `archon chat ` | Send a message to the orchestrator | | `archon setup` | Interactive setup wizard for credentials and config | +| `archon doctor` | Verify your setup (Claude binary, gh auth, DB, adapters) | | `archon workflow list` | List available workflows | | `archon workflow run [msg]` | Run a workflow | | `archon workflow status` | Show running workflows | diff --git a/packages/docs-web/src/content/docs/reference/cli.md b/packages/docs-web/src/content/docs/reference/cli.md index 37790374cf..5717e51b5c 100644 --- a/packages/docs-web/src/content/docs/reference/cli.md +++ b/packages/docs-web/src/content/docs/reference/cli.md @@ -50,7 +50,7 @@ archon workflow run plan --cwd /path/to/repo --branch feature-auth "Add OAuth su archon workflow run assist --cwd /path/to/repo --no-worktree "Quick question" ``` -**Note:** Workflow and isolation commands require running from within a git repository. Running from subdirectories automatically resolves to the repo root. The `version`, `help`, `chat`, `setup`, and `serve` commands work anywhere. +**Note:** Workflow and isolation commands require running from within a git repository. Running from subdirectories automatically resolves to the repo root. The `version`, `help`, `chat`, `setup`, `serve`, and `doctor` commands work anywhere. ## Commands @@ -84,6 +84,18 @@ archon setup --spawn # open in a new terminal window **Write safety**: `archon setup` never writes to `/.env` — that file belongs to you. The wizard always targets one archon-owned file chosen by `--scope`, merges into existing content (so user-added keys survive), and writes a timestamped backup before every rewrite (e.g. `~/.archon/.env.archon-backup-2026-04-20T09-28-11-000Z`). +### `doctor` + +Verify your Archon setup. Runs a checklist of common failure points: Claude binary spawn, gh CLI auth, database reachability, workspace writability, bundled defaults, and adapter token pings (Slack/Telegram, best-effort). + +```bash +archon doctor +``` + +Exit code 0 if all checks pass or are skipped; 1 if any critical check fails. Adapter pings degrade to `skip` on network errors — a flaky connection does not flip the result red. + +Also runs automatically at the end of `archon setup` (optional). + ### `workflow list` List workflows available in target directory. diff --git a/packages/docs-web/src/content/docs/reference/troubleshooting.md b/packages/docs-web/src/content/docs/reference/troubleshooting.md index 5e9b032293..b1e503156c 100644 --- a/packages/docs-web/src/content/docs/reference/troubleshooting.md +++ b/packages/docs-web/src/content/docs/reference/troubleshooting.md @@ -311,7 +311,7 @@ assistants: claudeBinaryPath: /absolute/path/to/claude ``` -`archon setup` auto-detects and writes `CLAUDE_BIN_PATH` for you. Docker users do not need to do anything — the image pre-sets the variable. +`archon setup` auto-detects and writes `CLAUDE_BIN_PATH` for you. After setup, run `archon doctor` to confirm the binary actually spawns. Docker users do not need to do anything — the image pre-sets the variable. See the [AI Assistants → Binary path configuration](/getting-started/ai-assistants/#binary-path-configuration-compiled-binaries-only) guide for the full install matrix. diff --git a/scripts/check-bundled-skill.ts b/scripts/check-bundled-skill.ts new file mode 100644 index 0000000000..90cade23eb --- /dev/null +++ b/scripts/check-bundled-skill.ts @@ -0,0 +1,49 @@ +#!/usr/bin/env bun +/** + * Verifies that packages/cli/src/bundled-skill.ts embeds every file from + * .claude/skills/archon/. The bundled-skill.ts file is hand-maintained + * (uses Bun's `with { type: 'text' }` import attributes, which the + * generator approach in scripts/generate-bundled-defaults.ts cannot + * reproduce for the binary build). This script is the safety net. + * + * Usage: + * bun run scripts/check-bundled-skill.ts # exit 1 if missing + * bun run scripts/check-bundled-skill.ts --check # exit 2 if missing (CI) + * + * Exit codes: + * 0 bundled-skill.ts covers every file under .claude/skills/archon/ + * 1 missing files (default mode) + * 2 missing files (--check mode, used by `bun run validate`) + */ +import { readdirSync, readFileSync, statSync } from 'fs'; +import { join, relative, resolve } from 'path'; + +const REPO_ROOT = resolve(import.meta.dir, '..'); +const SKILL_ROOT = join(REPO_ROOT, '.claude', 'skills', 'archon'); +const BUNDLED_SKILL_PATH = join(REPO_ROOT, 'packages', 'cli', 'src', 'bundled-skill.ts'); + +const CHECK_ONLY = process.argv.includes('--check'); + +function listSkillFiles(dir: string, base: string = dir): string[] { + return readdirSync(dir).flatMap(entry => { + const full = join(dir, entry); + return statSync(full).isDirectory() ? listSkillFiles(full, base) : [relative(base, full)]; + }); +} + +const skillFiles = listSkillFiles(SKILL_ROOT).sort(); +const bundledSrc = readFileSync(BUNDLED_SKILL_PATH, 'utf-8'); +// NOTE: This is a substring check — a filename that appears in a comment or +// stale string literal will also pass. It's a safety net against missing imports, +// not a structural verification of the export map. +const missing = skillFiles.filter(f => !bundledSrc.includes(f)); + +if (missing.length > 0) { + console.error( + `bundled-skill.ts is missing these files:\n${missing.map(f => ` - ${f}`).join('\n')}\n\n` + + `Add a corresponding import + BUNDLED_SKILL_FILES entry to\n ${relative(REPO_ROOT, BUNDLED_SKILL_PATH)}` + ); + process.exit(CHECK_ONLY ? 2 : 1); +} + +console.log(`bundled-skill.ts is up to date (${skillFiles.length} files).`);