diff --git a/CHANGELOG.md b/CHANGELOG.md index 272f8d602f..8cd63315cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Cherry-pick batch 4 from upstream — Tier 3 CLI (2 commits).** Two CLI commits picked from `coleam00/archon` upstream/dev. Three other CLI commits in the same chronological window were already in the fork from earlier batches (`056707d0` stale-workspace error, `7d067738` lazy-import bundled skill — both landed via PR #6/#7), and one large CLI commit (`5e61faf0` — setup overhaul + `archon doctor` + complete bundled skill) was deferred for separate review because it removes the database/Discord prompts the fork still surfaces. + - `4631b8e0` — New standalone `archon skill install [path]` subcommand copies the bundled Archon skill files into `/.claude/skills/archon/` so users can install or refresh the skill outside the interactive setup wizard. `copyArchonSkill()` was refactored out of `commands/setup.ts` into `commands/skill.ts` so the helper can be shared without pulling in `@clack/prompts`. Defaults to the current directory (#1445). + - `88d01099` — `--version`, `-V`, `-version`, and lone `-v` are now treated as version requests, matching common CLI conventions; previously only `version` (positional) and `--help`/`-h` short-circuited (#1444). + - **Cherry-pick batch 3 from upstream — Tier 2 workflow engine (11 commits).** Workflow-engine improvements pulled selectively; one commit (`e33e0de6` — `archon-assist` opt-out of worktree) was deferred because it depends on the workflow `worktree:` policy schema that lives in a later upstream commit (`5ed38dc7`) not yet picked. - `60eeb00e` — Inline sub-agent definitions on DAG nodes via the `agents:` field (Claude only). Pi-related additions in this commit were dropped (fork doesn't ship Pi). - `e71c496a` — Bash nodes now receive `ARTIFACTS_DIR`, `LOG_DIR`, and `BASE_BRANCH` in their subprocess env, matching what AI nodes already see (#1387). diff --git a/CLAUDE.md b/CLAUDE.md index c91ad8c2da..c9aa938c50 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -251,6 +251,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 04cd620be4..454e5c4d3c 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.test.ts b/packages/cli/src/cli.test.ts index 40b98e4887..24e025eb31 100644 --- a/packages/cli/src/cli.test.ts +++ b/packages/cli/src/cli.test.ts @@ -151,6 +151,58 @@ describe('CLI argument parsing', () => { }); }); + describe('version flag detection', () => { + /** + * Duplicates the isVersionRequest() helper from cli.ts (which is not + * exported — importing cli.ts would execute its top-level main()). Must + * be updated manually if the source logic changes. + */ + const isVersionRequest = (args: string[]): boolean => { + if (args.length === 1 && args[0] === '-v') return true; + for (const arg of args) { + if (arg === '--version' || arg === '-V' || arg === '-version') return true; + } + return false; + }; + + it('detects --version', () => { + expect(isVersionRequest(['--version'])).toBe(true); + }); + + it('detects -V (uppercase short flag)', () => { + expect(isVersionRequest(['-V'])).toBe(true); + }); + + it('detects -version (single-dash typo)', () => { + expect(isVersionRequest(['-version'])).toBe(true); + }); + + it('treats lone -v as a version request', () => { + expect(isVersionRequest(['-v'])).toBe(true); + }); + + it('treats -v with other args as --verbose (NOT a version request)', () => { + expect(isVersionRequest(['-v', 'workflow', 'list'])).toBe(false); + expect(isVersionRequest(['workflow', '-v', 'list'])).toBe(false); + }); + + it('does not treat the literal "version" command as a flag-style request', () => { + // The `version` positional command is handled by the existing switch, + // not the early flag bypass. isVersionRequest should not match it. + expect(isVersionRequest(['version'])).toBe(false); + }); + + it('detects --version anywhere in argv', () => { + expect(isVersionRequest(['--cwd', '/foo', '--version'])).toBe(true); + }); + + it('returns false for unrelated args', () => { + expect(isVersionRequest(['workflow', 'list'])).toBe(false); + expect(isVersionRequest(['help'])).toBe(false); + expect(isVersionRequest([])).toBe(false); + }); + }); + describe('unknown flags with strict: false', () => { it('should pass through unknown flags', () => { const result = parseCliArgs(['--unknown', 'workflow', 'list']); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 968d51d833..ab3b81aa6b 100755 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -71,6 +71,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'; @@ -113,9 +114,10 @@ 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 + version, --version, -V Show version info (also -v when used alone) help Show this help message Options: @@ -141,6 +143,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 `); } @@ -175,6 +179,21 @@ async function printUpdateNotice(quiet: boolean | undefined): Promise { * Main CLI entry point * Returns exit code (0 = success, non-zero = failure) */ +/** + * Detect a request for version output. Treats `--version`, `-V`, and the + * single-dash typo `-version` as version flags anywhere in argv. `-v` keeps + * its role as the short alias for `--verbose`, except when used alone — then + * it falls back to version output to match the convention used by node, npm, + * bun, and most other CLIs. + */ +function isVersionRequest(args: string[]): boolean { + if (args.length === 1 && args[0] === '-v') return true; + for (const arg of args) { + if (arg === '--version' || arg === '-V' || arg === '-version') return true; + } + return false; +} + async function main(): Promise { const args = process.argv.slice(2); @@ -184,6 +203,18 @@ async function main(): Promise { return 0; } + // Version flag aliases bypass option parsing and the git-repo check so + // `archon --version` works the same as `archon version` from any directory. + if (isVersionRequest(args)) { + try { + await versionCommand(); + return 0; + } finally { + await shutdownTelemetry(); + await closeDb(); + } + } + // Parse global options let parsedArgs: { values: Record; positionals: string[] }; @@ -244,7 +275,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 { @@ -556,6 +587,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 0ef6d3f742..62bb10a6ed 100644 --- a/packages/cli/src/commands/setup.test.ts +++ b/packages/cli/src/commands/setup.test.ts @@ -10,10 +10,10 @@ import { generateEnvContent, generateWebhookSecret, spawnTerminalWithSetup, - copyArchonSkill, detectClaudeExecutablePath, } from './setup'; import * as setupModule from './setup'; +import { copyArchonSkill } from './skill'; // Test directory for file operations const TEST_DIR = join(tmpdir(), 'archon-setup-test-' + Date.now()); diff --git a/packages/cli/src/commands/setup.ts b/packages/cli/src/commands/setup.ts index d913891486..ab1fa2e921 100644 --- a/packages/cli/src/commands/setup.ts +++ b/packages/cli/src/commands/setup.ts @@ -23,7 +23,8 @@ import { log, } from '@clack/prompts'; import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; -import { join, dirname } from 'path'; +import { join } from 'path'; +import { copyArchonSkill } from './skill'; import { homedir } from 'os'; import { randomBytes } from 'crypto'; import { spawn, execSync, type ChildProcess } from 'child_process'; @@ -1329,33 +1330,6 @@ function writeEnvFiles( return { globalPath, repoEnvPath }; } -/** - * 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 d27262ffac..3bc5fc8b43 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 a1facfc21c..9023ea6005 100644 --- a/packages/docs-web/src/content/docs/reference/cli.md +++ b/packages/docs-web/src/content/docs/reference/cli.md @@ -328,6 +328,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.