Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<target>/.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).
Expand Down
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
52 changes: 52 additions & 0 deletions packages/cli/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
Expand Down
55 changes: 53 additions & 2 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -113,9 +114,10 @@ Commands:
continue <branch> [msg] Continue work on an existing worktree with prior context
complete <branch> [...] 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:
Expand All @@ -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
`);
}

Expand Down Expand Up @@ -175,6 +179,21 @@ async function printUpdateNotice(quiet: boolean | undefined): Promise<void> {
* 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<number> {
const args = process.argv.slice(2);

Expand All @@ -184,6 +203,18 @@ async function main(): Promise<number> {
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<string, unknown>; positionals: string[] };

Expand Down Expand Up @@ -244,7 +275,7 @@ async function main(): Promise<number> {
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 {
Expand Down Expand Up @@ -556,6 +587,26 @@ async function main(): Promise<number> {
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');
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/setup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
30 changes: 2 additions & 28 deletions packages/cli/src/commands/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -1329,33 +1330,6 @@ function writeEnvFiles(
return { globalPath, repoEnvPath };
}

/**
* Copy the bundled Archon skill files to <targetPath>/.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<void> {
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
// =============================================================================
Expand Down
85 changes: 85 additions & 0 deletions packages/cli/src/commands/skill.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof spyOn>;
let errSpy: ReturnType<typeof spyOn>;

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);
});
});
Loading
Loading