diff --git a/CLAUDE.md b/CLAUDE.md index de588e5987..75ec512975 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -253,6 +253,10 @@ bun run cli serve bun run cli serve --port 4000 bun run cli serve --download-only # Download without starting +# Install the bundled Archon skill into a project +bun run cli skill install +bun run cli skill install /path/to/project + # Show version bun run cli version ``` diff --git a/packages/cli/package.json b/packages/cli/package.json index a0946f6884..b11439caa1 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 && 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 && 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/cli.ts b/packages/cli/src/cli.ts index 3ecd580178..34070f1d3c 100755 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -62,6 +62,7 @@ import { import { continueCommand } from './commands/continue'; import { chatCommand } from './commands/chat'; import { setupCommand } from './commands/setup'; +import { skillInstallCommand } from './commands/skill'; import { validateWorkflowsCommand, validateCommandsCommand } from './commands/validate'; import { serveCommand } from './commands/serve'; import { closeDatabase } from '@archon/core'; @@ -104,6 +105,7 @@ Commands: continue [msg] Continue work on an existing worktree with prior context 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 validate workflows [name] Validate workflow definitions and their references validate commands [name] Validate command files version Show version info @@ -132,6 +134,8 @@ Examples: archon workflow run implement --branch feature-auth "Implement auth" archon workflow run quick-fix --no-worktree "Fix typo" archon continue fix/issue-42 --workflow archon-smart-pr-review "Review the changes" + archon skill install + archon skill install /path/to/project `); } @@ -236,7 +240,7 @@ async function main(): Promise { const subcommand = positionals[1]; // Commands that don't require git repo validation - const noGitCommands = ['version', 'help', 'setup', 'chat', 'continue', 'serve']; + const noGitCommands = ['version', 'help', 'setup', 'chat', 'continue', 'serve', 'skill']; const requiresGitRepo = !noGitCommands.includes(command ?? ''); try { @@ -569,6 +573,26 @@ async function main(): Promise { return await serveCommand({ port: servePort, downloadOnly }); } + case 'skill': { + switch (subcommand) { + case 'install': { + // Optional positional path; otherwise install into the resolved cwd. + const targetArg = positionals[2]; + const targetPath = targetArg ? resolve(targetArg) : cwd; + return await skillInstallCommand(targetPath); + } + + default: + if (subcommand === undefined) { + console.error('Missing skill subcommand'); + } else { + console.error(`Unknown skill subcommand: ${subcommand}`); + } + console.error('Available: install'); + return 1; + } + } + default: if (command === undefined) { console.error('Missing command'); diff --git a/packages/cli/src/commands/setup.test.ts b/packages/cli/src/commands/setup.test.ts index 03a6b32d60..bb73eec09a 100644 --- a/packages/cli/src/commands/setup.test.ts +++ b/packages/cli/src/commands/setup.test.ts @@ -10,13 +10,13 @@ import { generateEnvContent, generateWebhookSecret, spawnTerminalWithSetup, - copyArchonSkill, detectClaudeExecutablePath, writeScopedEnv, serializeEnv, resolveScopedEnvPath, } from './setup'; import * as setupModule from './setup'; +import { copyArchonSkill } from './skill'; import { parse as parseDotenv } from 'dotenv'; // Test directory for file operations diff --git a/packages/cli/src/commands/setup.ts b/packages/cli/src/commands/setup.ts index b1405d6298..42ca63e3a4 100644 --- a/packages/cli/src/commands/setup.ts +++ b/packages/cli/src/commands/setup.ts @@ -35,6 +35,7 @@ import { import { existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync, chmodSync } from 'fs'; import { parse as parseDotenv } from 'dotenv'; 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'; @@ -1443,33 +1444,6 @@ export function writeScopedEnv( return { targetPath, backupPath, preservedKeys, forced: options.force && exists }; } -/** - * Copy the bundled Archon skill files to /.claude/skills/archon/ - * - * Always overwrites existing files to ensure the latest skill version is installed. - * - * The `bundled-skill` module is dynamically imported here so that its 18 top-level - * `import … with { type: 'text' }` statements only execute when this function is - * actually called. Compiled binaries (`bun build --compile`) still statically - * analyze the literal-string `import()` and embed the chunk; linked-source - * installs (`bun link`) don't touch the source skill files unless the user runs - * `archon setup`. Without this indirection, every `archon` invocation — - * including `archon --help` — fails at module load when the source skill files - * are missing from disk. - */ -export async function copyArchonSkill(targetPath: string): Promise { - const { BUNDLED_SKILL_FILES } = await import('../bundled-skill'); - const skillRoot = join(targetPath, '.claude', 'skills', 'archon'); - for (const [relativePath, content] of Object.entries(BUNDLED_SKILL_FILES)) { - const dest = join(skillRoot, relativePath); - const destDir = dirname(dest); - if (!existsSync(destDir)) { - mkdirSync(destDir, { recursive: true }); - } - writeFileSync(dest, content); - } -} - // ============================================================================= // Terminal Spawning // ============================================================================= diff --git a/packages/cli/src/commands/skill.test.ts b/packages/cli/src/commands/skill.test.ts new file mode 100644 index 0000000000..8c3bc07dcf --- /dev/null +++ b/packages/cli/src/commands/skill.test.ts @@ -0,0 +1,85 @@ +/** + * Tests for skill install command + */ +import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test'; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { BUNDLED_SKILL_FILES } from '../bundled-skill'; +import { copyArchonSkill, skillInstallCommand } from './skill'; + +describe('copyArchonSkill', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'archon-skill-test-')); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('writes every bundled skill file under .claude/skills/archon/', async () => { + await copyArchonSkill(tempDir); + + const skillRoot = join(tempDir, '.claude', 'skills', 'archon'); + for (const [relativePath, content] of Object.entries(BUNDLED_SKILL_FILES)) { + const dest = join(skillRoot, relativePath); + expect(existsSync(dest)).toBe(true); + expect(readFileSync(dest, 'utf-8')).toBe(content); + } + }); + + it('overwrites pre-existing skill files with bundled content', async () => { + const skillRoot = join(tempDir, '.claude', 'skills', 'archon'); + const skillMdPath = join(skillRoot, 'SKILL.md'); + + // Pre-seed with stale content; copyArchonSkill must overwrite it. + await copyArchonSkill(tempDir); + writeFileSync(skillMdPath, 'STALE'); + expect(readFileSync(skillMdPath, 'utf-8')).toBe('STALE'); + + await copyArchonSkill(tempDir); + expect(readFileSync(skillMdPath, 'utf-8')).toBe(BUNDLED_SKILL_FILES['SKILL.md']); + }); +}); + +describe('skillInstallCommand', () => { + let tempDir: string; + let logSpy: ReturnType; + let errSpy: ReturnType; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'archon-skill-cmd-test-')); + logSpy = spyOn(console, 'log').mockImplementation(() => {}); + errSpy = spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + logSpy.mockRestore(); + errSpy.mockRestore(); + }); + + it('returns 0 and installs the skill into the target directory', async () => { + const exitCode = await skillInstallCommand(tempDir); + + expect(exitCode).toBe(0); + expect(existsSync(join(tempDir, '.claude', 'skills', 'archon', 'SKILL.md'))).toBe(true); + // Final log line should mention restarting Claude Code + const lastLog = logSpy.mock.calls.at(-1)?.[0] as string | undefined; + expect(lastLog).toContain('Restart Claude Code'); + }); + + it('returns 1 and prints an error when the target directory does not exist', async () => { + const missing = join(tempDir, 'does-not-exist'); + const exitCode = await skillInstallCommand(missing); + + expect(exitCode).toBe(1); + expect(errSpy).toHaveBeenCalled(); + const firstError = errSpy.mock.calls[0][0] as string; + expect(firstError).toContain('Directory does not exist'); + // Nothing should have been written + expect(existsSync(join(missing, '.claude'))).toBe(false); + }); +}); diff --git a/packages/cli/src/commands/skill.ts b/packages/cli/src/commands/skill.ts new file mode 100644 index 0000000000..e759ab5a57 --- /dev/null +++ b/packages/cli/src/commands/skill.ts @@ -0,0 +1,69 @@ +/** + * Skill command - Install bundled Archon skill files into a project + * + * Writes the bundled SKILL.md, guides, references and examples into + * /.claude/skills/archon/ so Claude Code picks up the skill + * the next time the project is opened. + * + * Always overwrites existing files to ensure the latest skill version + * shipped with the current Archon binary is installed. + */ +import { existsSync, mkdirSync, writeFileSync } from 'fs'; +import { dirname, join, resolve } from 'path'; + +/** + * Copy the bundled Archon skill files to /.claude/skills/archon/ + * + * Pure file-system helper used by both the standalone `skill install` CLI + * command and the interactive setup wizard. + * + * The `bundled-skill` module is dynamically imported here so that its 18 top-level + * `import … with { type: 'text' }` statements only execute when this function is + * actually called. Compiled binaries (`bun build --compile`) still statically + * analyze the literal-string `import()` and embed the chunk; linked-source + * installs (`bun link`) don't touch the source skill files unless the user runs + * `archon setup` or `archon skill install`. Without this indirection, every + * `archon` invocation — including `archon --help` — fails at module load when + * the source skill files are missing from disk. + */ +export async function copyArchonSkill(targetPath: string): Promise { + const { BUNDLED_SKILL_FILES } = await import('../bundled-skill'); + const skillRoot = join(targetPath, '.claude', 'skills', 'archon'); + for (const [relativePath, content] of Object.entries(BUNDLED_SKILL_FILES)) { + const dest = join(skillRoot, relativePath); + const destDir = dirname(dest); + if (!existsSync(destDir)) { + mkdirSync(destDir, { recursive: true }); + } + writeFileSync(dest, content); + } +} + +/** + * Install the bundled Archon skill into a project directory. + * + * Returns an exit code: 0 on success, 1 on failure. + */ +export async function skillInstallCommand(targetPath: string): Promise { + const absoluteTarget = resolve(targetPath); + + if (!existsSync(absoluteTarget)) { + console.error(`Error: Directory does not exist: ${absoluteTarget}`); + return 1; + } + + const skillRoot = join(absoluteTarget, '.claude', 'skills', 'archon'); + try { + const { BUNDLED_SKILL_FILES } = await import('../bundled-skill'); + const fileCount = Object.keys(BUNDLED_SKILL_FILES).length; + console.log(`Installing Archon skill (${fileCount} files) into ${skillRoot}`); + + await copyArchonSkill(absoluteTarget); + console.log('Done. Restart Claude Code to load the skill.'); + return 0; + } catch (error) { + const err = error as NodeJS.ErrnoException; + console.error(`Error: Failed to install skill: ${err.message}`); + return 1; + } +} diff --git a/packages/docs-web/src/content/docs/guides/skills.md b/packages/docs-web/src/content/docs/guides/skills.md index f64b6def3d..667f88562f 100644 --- a/packages/docs-web/src/content/docs/guides/skills.md +++ b/packages/docs-web/src/content/docs/guides/skills.md @@ -166,6 +166,7 @@ smaller box with a tastefully curated set of tools." | Skill | Install | What It Teaches | |-------|---------|----------------| +| `archon` (bundled) | `archon skill install` | Archon workflows, commands, and project conventions | | `remotion-best-practices` | `npx skills add remotion-dev/skills` | Remotion animation patterns, API usage, gotchas (35 rules) | | `skill-creator` | `npx skills add anthropics/skills` | How to create new SKILL.md files | | Community skills | Browse [skills.sh](https://skills.sh) | Search 500K+ skills for any domain | diff --git a/packages/docs-web/src/content/docs/reference/cli.md b/packages/docs-web/src/content/docs/reference/cli.md index adf0471c01..37790374cf 100644 --- a/packages/docs-web/src/content/docs/reference/cli.md +++ b/packages/docs-web/src/content/docs/reference/cli.md @@ -335,6 +335,20 @@ archon serve --download-only The cached web UI is stored at `~/.archon/web-dist//`. Each version is cached independently, so upgrading the binary automatically downloads the matching web UI. +### `skill install [path]` + +Install the bundled Archon skill files into a project's `.claude/skills/archon/` directory. Always overwrites existing files to ensure the latest version shipped with the current Archon binary is installed. + +```bash +# Install into the current directory +archon skill install + +# Install into a specific project +archon skill install /path/to/project +``` + +The Archon skill teaches Claude Code how to work with Archon workflows, commands, and project conventions. It is also installed automatically during `archon setup`. + ### `version` Show version, build type, and database info.