From 965b1166b9b3aa3f00be532853c02e799e4d0cd2 Mon Sep 17 00:00:00 2001 From: Miya Date: Mon, 22 Jun 2026 12:41:06 +0200 Subject: [PATCH] fix(cli): expose factory actions at top level --- README.md | 13 +++-- bin/fleet.mjs | 2 +- scripts/factory-canary.sh | 2 +- src/cli/fleet.test.ts | 102 ++++++++++++++++++++++++++++++-------- src/cli/fleet.ts | 90 +++++++++++++++++++++++++++------ 5 files changed, 163 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index e825263..6eaf2e3 100644 --- a/README.md +++ b/README.md @@ -47,12 +47,11 @@ and sign in to that separately (it's a peer of this package). Once installed, th CLI is available as `factory`: ```bash -factory factory run-once --config ./factory.config.json --dry-run +factory run-once --config ./factory.config.json --dry-run ``` -> Yes, `factory factory` — the `factory` CLI groups its commands under a `factory` -> subcommand. From a source checkout instead of an npm install, run -> `npm ci && npm run build` first, then `node bin/factory.mjs factory …`. +From a source checkout instead of an npm install, run +`npm ci && npm run build` first, then `node bin/factory.mjs …`. ## Quick start @@ -78,17 +77,17 @@ factory factory run-once --config ./factory.config.json --dry-run but writes nothing and spawns no agents: ```bash - factory factory run-once --config ./factory.config.json --dry-run + factory run-once --config ./factory.config.json --dry-run ``` 3. **Let it work for real:** ```bash # One discovery→dispatch cycle, then exit. - factory factory run-once --config ./factory.config.json + factory run-once --config ./factory.config.json # Or run continuously as a daemon (the production form). - factory factory start --mode live --config ./factory.config.json + factory start --mode live --config ./factory.config.json ``` > **Pulled some issues but dispatched none?** That's the safety gate doing its diff --git a/bin/fleet.mjs b/bin/fleet.mjs index 1f6bc9c..7986eee 100755 --- a/bin/fleet.mjs +++ b/bin/fleet.mjs @@ -54,4 +54,4 @@ if (forceRebuild || !existsSync(outfile)) { } const mod = await import(pathToFileURL(outfile).href) -await mod.main(cliArgs) +await mod.main(['fleet', ...cliArgs]) diff --git a/scripts/factory-canary.sh b/scripts/factory-canary.sh index 55412e8..2358022 100755 --- a/scripts/factory-canary.sh +++ b/scripts/factory-canary.sh @@ -48,7 +48,7 @@ cd "$WORKDIR" || { echo "[$TS] factory-canary: cannot cd to $WORKDIR" >&2; exit # The canary runs the real dry-run triage path (no agents spawned) and prints a # JSON verdict {ok,issue,status,reason}; exit code mirrors ok. A hung run # (broker/mount wedge) is bounded by FACTORY_CANARY_TIMEOUT. -RUN=(node "$BIN" factory canary "$ISSUE" --config "$CONFIG" --backend "$BACKEND") +RUN=(node "$BIN" canary "$ISSUE" --config "$CONFIG" --backend "$BACKEND") # A hung run (broker/mount wedge) MUST be bounded — an unbounded canary on a # scheduler (launchd/cron) can wedge the slot forever and suppress later alerts. # macOS has no `timeout` by default; coreutils ships it as `gtimeout`. If neither diff --git a/src/cli/fleet.test.ts b/src/cli/fleet.test.ts index 726ed71..52981f1 100644 --- a/src/cli/fleet.test.ts +++ b/src/cli/fleet.test.ts @@ -49,6 +49,7 @@ const issueFile = { describe('fleet CLI parsing', () => { it('parses spawn flags into a FleetClient spawn input shape', () => { expect(parseFleetCommand([ + 'fleet', 'spawn', 'spawn:codex', '--node', @@ -77,7 +78,6 @@ describe('fleet CLI parsing', () => { it('parses global backend, config, and dry-run independently of subcommand position', () => { expect(parseGlobalOptions([ - 'factory', 'run-once', '--dry-run', '--backend', @@ -86,13 +86,12 @@ describe('fleet CLI parsing', () => { 'factory.json', ])).toEqual({ globals: { backend: 'relay', dryRun: true, config: 'factory.json' }, - args: ['factory', 'run-once'], + args: ['run-once'], }) }) it('parses manual probe close command', () => { expect(parseFleetCommand([ - 'factory', 'close-probe', '42', '--repo', @@ -108,20 +107,24 @@ describe('fleet CLI parsing', () => { }) it('parses the factory orphan reaper command', () => { - expect(parseFleetCommand(['factory', 'reap-orphans'])).toEqual({ + expect(parseFleetCommand(['reap-orphans'])).toEqual({ kind: 'factory', action: 'reap-orphans', }) }) it('parses the factory live start command', () => { - expect(parseFleetCommand(['factory', 'start', '--mode', 'live'])).toEqual({ + expect(parseFleetCommand(['start', '--mode', 'live'])).toEqual({ kind: 'factory', action: 'start', mode: 'live', }) }) + it('rejects the removed nested factory namespace', () => { + expect(() => parseFleetCommand(['factory', 'run-once'])).toThrow(/Unknown factory command: factory/) + }) + it('resolves a broker connection path by walking up from the command cwd', async () => { const root = await mkdtemp(join(tmpdir(), 'fleet-cli-broker-')) try { @@ -139,6 +142,40 @@ describe('fleet CLI parsing', () => { }) describe('fleet CLI runtime', () => { + it('prints factory help for -h without requiring config or showing internal fleet as the binary', async () => { + const output = buffer() + const errors = buffer() + + const code = await runFleetCli(['-h'], { + createFleet: () => { + throw new Error('help should not construct a fleet') + }, + stdout: output, + stderr: errors, + }) + + expect(code).toBe(0) + expect(errors.text()).toBe('') + expect(output.text()).toContain('usage: factory [options]') + expect(output.text()).toContain('run-once') + expect(output.text()).toContain('start --mode live') + expect(output.text()).toContain('fleet ') + expect(output.text()).not.toContain('usage: fleet') + }) + + it('prints factory help for --help even when passed after the fleet namespace', async () => { + const output = buffer() + + const code = await runFleetCli(['fleet', '--help'], { + stdout: output, + stderr: buffer(), + }) + + expect(code).toBe(0) + expect(output.text()).toContain('usage: factory [options]') + expect(output.text()).not.toContain('usage: fleet') + }) + it('uses real fleet and cloud mount for fixture-less factory configs on the operator path', async () => { const root = await mkdtemp(join(tmpdir(), 'fleet-cli-real-default-')) try { @@ -151,7 +188,6 @@ describe('fleet CLI runtime', () => { const output = buffer() const code = await runFleetCli([ - 'factory', 'run-once', '--dry-run', '--config', @@ -193,7 +229,6 @@ describe('fleet CLI runtime', () => { const output = buffer() const code = await runFleetCli([ - 'factory', 'run-once', '--dry-run', '--config', @@ -233,7 +268,6 @@ describe('fleet CLI runtime', () => { const stderr = buffer() const code = await runFleetCli([ - 'factory', 'run-once', '--config', configPath, @@ -258,7 +292,6 @@ describe('fleet CLI runtime', () => { const output = buffer() const code = await runFleetCli([ - 'factory', 'run-once', '--dry-run', '--config', @@ -283,6 +316,43 @@ describe('fleet CLI runtime', () => { expect(mount.writes).toEqual([]) }) + it('prints factory status from the top-level status command', async () => { + const root = await mkdtemp(join(tmpdir(), 'fleet-cli-status-')) + try { + const configPath = await writeConfig(root) + const output = buffer() + const factoryStatus = { inFlight: [], queued: [], counters: { pulled: 0 } } + const factory = { + start: vi.fn(), + stop: vi.fn(), + runLoop: vi.fn(async () => []), + runOnce: vi.fn(), + status: vi.fn(() => factoryStatus), + triageIssue: vi.fn(), + dispatch: vi.fn(), + on: vi.fn(), + dispose: vi.fn(), + } as unknown as Factory + + const code = await runFleetCli([ + 'status', + '--config', + configPath, + ], { + fleet: new FakeFleetClient(), + mount: new FakeMountClient(), + createFactory: () => factory, + stdout: output, + stderr: buffer(), + }) + + expect(code).toBe(0) + expect(JSON.parse(output.text())).toEqual(factoryStatus) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + it('drives the real RelayFleetClient when --backend relay is requested', async () => { // Strip every relay credential so the lazily-built HTTP transport surfaces a // deterministic auth error instead of attempting a real network request. @@ -294,7 +364,7 @@ describe('fleet CLI runtime', () => { const output = buffer() const errors = buffer() - const code = await runFleetCli(['roster', '--backend', 'relay'], { + const code = await runFleetCli(['fleet', 'roster', '--backend', 'relay'], { stdout: output, stderr: errors, }) @@ -323,7 +393,6 @@ describe('fleet CLI runtime', () => { const errors = buffer() const code = await runFleetCli([ - 'factory', 'dispatch', 'AR-77', '--config', @@ -345,7 +414,6 @@ describe('fleet CLI runtime', () => { const output = buffer() const calls: unknown[] = [] const code = await runFleetCli([ - 'factory', 'close-probe', '42', '--repo', @@ -376,7 +444,6 @@ describe('fleet CLI runtime', () => { const output = buffer() const code = await runFleetCli([ - 'factory', 'loop', '--dry-run', '--config', @@ -397,7 +464,6 @@ describe('fleet CLI runtime', () => { const statusOut = buffer() const statusCode = await runFleetCli([ - 'factory', 'loop-status', '--config', configPath, @@ -439,7 +505,6 @@ describe('fleet CLI runtime', () => { })) const code = await runFleetCli([ - 'factory', 'start', '--mode', 'live', @@ -484,7 +549,6 @@ describe('fleet CLI runtime', () => { const resolveWorkspace = vi.fn(async () => ({ workspaceId: 'rw_unused' })) const code = await runFleetCli([ - 'factory', 'start', '--mode', 'live', @@ -530,7 +594,6 @@ describe('fleet CLI runtime', () => { const ensureLocalMount = vi.fn(async () => {}) const code = await runFleetCli([ - 'factory', 'start', '--mode', 'live', @@ -594,7 +657,6 @@ describe('fleet CLI runtime', () => { const daemonExits: number[] = [] const run = runFleetCli([ - 'factory', 'start', '--mode', 'live', @@ -651,7 +713,6 @@ describe('fleet CLI runtime', () => { const runOnceCode = await runFleetCli([ '--dry-run', - 'factory', 'run-once', '--config', configPath, @@ -670,7 +731,6 @@ describe('fleet CLI runtime', () => { }) const reapCode = await runFleetCli([ - 'factory', 'reap-orphans', '--config', configPath, @@ -718,7 +778,6 @@ describe('fleet CLI runtime', () => { const output = buffer() const code = await runFleetCli([ - 'factory', 'kill-loop', '--config', configPath, @@ -761,7 +820,6 @@ describe('fleet CLI runtime', () => { const output = buffer() const code = await runFleetCli([ - 'factory', 'reap-orphans', '--config', configPath, diff --git a/src/cli/fleet.ts b/src/cli/fleet.ts index e310942..2d18d64 100644 --- a/src/cli/fleet.ts +++ b/src/cli/fleet.ts @@ -88,6 +88,10 @@ export async function runFleetCli(argv: string[], deps: FleetCliDeps = {}): Prom let fleet: FleetClient | undefined try { + if (argv.some(isHelpFlag)) { + out.write(helpText()) + return 0 + } const { globals, args } = parseGlobalOptions(argv) const command = parseFleetCommand(args) @@ -190,10 +194,27 @@ export function parseFleetCommand(args: string[]): ParsedCommand { throw new Error(usage()) } + if (isFactoryAction(verb)) { + return parseFactoryCommand(args) + } + + if (verb === 'fleet') { + return parseFleetSubcommand(rest) + } + + throw new Error(`Unknown factory command: ${verb}`) +} + +function parseFleetSubcommand(args: string[]): ParsedCommand { + const [verb, ...rest] = args + if (!verb) { + throw new Error('factory fleet requires a command') + } + if (verb === 'spawn') { const [capability, ...flags] = rest if (!isCapability(capability)) { - throw new Error('fleet spawn requires capability spawn:codex, spawn:claude, or workflow:run') + throw new Error('factory fleet spawn requires capability spawn:codex, spawn:claude, or workflow:run') } const parsed = parseFlags(flags) return { @@ -216,15 +237,11 @@ export function parseFleetCommand(args: string[]): ParsedCommand { if (verb === 'release') { const [name, ...flags] = rest - if (!name) throw new Error('fleet release requires agent name') + if (!name) throw new Error('factory fleet release requires agent name') return { kind: 'release', name, reason: parseFlags(flags).reason } } - if (verb === 'factory') { - return parseFactoryCommand(rest) - } - - throw new Error(`Unknown fleet command: ${verb}`) + throw new Error(`Unknown factory fleet command: ${verb}`) } export function parseGlobalOptions(argv: string[]): { globals: GlobalOptions; args: string[] } { @@ -365,25 +382,25 @@ function parseFactoryCommand(args: string[]): ParsedCommand { return { kind: 'factory', action } } if (action === 'canary') { - if (!issueOrPr) throw new Error('fleet factory canary requires an issue key or path') + if (!issueOrPr) throw new Error('factory canary requires an issue key or path') return { kind: 'factory-canary', issue: issueOrPr } } if (action === 'triage') { - if (!issueOrPr) throw new Error('fleet factory triage requires an issue key or path') + if (!issueOrPr) throw new Error('factory triage requires an issue key or path') return { kind: 'factory-triage', issue: issueOrPr } } if (action === 'dispatch') { - if (!issueOrPr) throw new Error('fleet factory dispatch requires an issue key or path') + if (!issueOrPr) throw new Error('factory dispatch requires an issue key or path') return { kind: 'factory-dispatch', issue: issueOrPr } } if (action === 'close-probe') { const prNumber = Number(issueOrPr) - if (!Number.isInteger(prNumber) || prNumber <= 0) throw new Error('fleet factory close-probe requires a PR number') + if (!Number.isInteger(prNumber) || prNumber <= 0) throw new Error('factory close-probe requires a PR number') const parsed = parseFlags(flags) - if (!parsed.repo || !parsed.issue) throw new Error('fleet factory close-probe requires --repo --issue ') + if (!parsed.repo || !parsed.issue) throw new Error('factory close-probe requires --repo --issue ') return { kind: 'factory-close-probe', prNumber, repo: parsed.repo, issue: parsed.issue } } - throw new Error(`Unknown fleet factory action: ${action ?? ''}`) + throw new Error(`Unknown factory action: ${action ?? ''}`) } // Canary: assert a known "Ready for Agent" issue is classified dispatch-ready @@ -436,7 +453,7 @@ function parseFactoryStartFlags(args: Array): { mode?: 'live mode = value continue } - throw new Error(`Unknown fleet factory start option: ${flag}`) + throw new Error(`Unknown factory start option: ${flag}`) } return { mode } } @@ -630,6 +647,20 @@ function isCapability(value: string | undefined): value is Capability { return value === 'spawn:codex' || value === 'spawn:claude' || value === 'workflow:run' } +function isFactoryAction(value: string): boolean { + return value === 'start' || + value === 'run-once' || + value === 'loop' || + value === 'status' || + value === 'loop-status' || + value === 'kill-loop' || + value === 'reap-orphans' || + value === 'canary' || + value === 'triage' || + value === 'dispatch' || + value === 'close-probe' +} + function defaultAgentName(capability: Capability, now: number): string { return `fleet-${capability.replace('spawn:', '').replace(':', '-')}-${now}` } @@ -685,7 +716,36 @@ function asRecord(value: unknown): Record { } function usage(): string { - return 'usage: fleet [options]' + return 'usage: factory [options]' +} + +function helpText(): string { + return `${usage()} + +Commands: + run-once Run one discovery -> triage -> dispatch cycle + start --mode live Run the live factory daemon + status Print current factory status as JSON + loop Run the bounded loop configured in factory.config.json + loop-status Print heartbeat/liveness status for the loop + kill-loop Send SIGTERM to the heartbeat pid + reap-orphans Reap stale factory-owned agents + canary Check that a known issue is dispatch-ready + triage Triage one issue and print the decision + dispatch Triage and dispatch one issue + close-probe Probe/close a PR for an issue + fleet Low-level fleet commands: spawn, roster, release + +Options: + --config Factory config JSON path + --dry-run Discover and triage without writes or agent spawns + --backend Fleet backend: internal or relay + -h, --help Show this help +` +} + +function isHelpFlag(arg: string): boolean { + return arg === '-h' || arg === '--help' } export async function main(argv = process.argv.slice(2)): Promise {