@bastani/atomic 0.5.34 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/README.md +329 -50
  2. package/dist/commands/cli/session.d.ts +67 -0
  3. package/dist/commands/cli/session.d.ts.map +1 -0
  4. package/dist/commands/cli/workflow-status.d.ts +63 -0
  5. package/dist/commands/cli/workflow-status.d.ts.map +1 -0
  6. package/dist/sdk/commander.d.ts +74 -0
  7. package/dist/sdk/commander.d.ts.map +1 -0
  8. package/dist/sdk/components/workflow-picker-panel.d.ts +14 -17
  9. package/dist/sdk/components/workflow-picker-panel.d.ts.map +1 -1
  10. package/dist/sdk/define-workflow.d.ts +18 -9
  11. package/dist/sdk/define-workflow.d.ts.map +1 -1
  12. package/dist/sdk/index.d.ts +4 -3
  13. package/dist/sdk/index.d.ts.map +1 -1
  14. package/dist/sdk/management-commands.d.ts +42 -0
  15. package/dist/sdk/management-commands.d.ts.map +1 -0
  16. package/dist/sdk/registry.d.ts +27 -0
  17. package/dist/sdk/registry.d.ts.map +1 -0
  18. package/dist/sdk/runtime/attached-footer.d.ts +1 -1
  19. package/dist/sdk/runtime/executor-env.d.ts +20 -0
  20. package/dist/sdk/runtime/executor-env.d.ts.map +1 -0
  21. package/dist/sdk/runtime/executor.d.ts +61 -10
  22. package/dist/sdk/runtime/executor.d.ts.map +1 -1
  23. package/dist/sdk/types.d.ts +147 -4
  24. package/dist/sdk/types.d.ts.map +1 -1
  25. package/dist/sdk/worker-shared.d.ts +42 -0
  26. package/dist/sdk/worker-shared.d.ts.map +1 -0
  27. package/dist/sdk/workflow-cli.d.ts +103 -0
  28. package/dist/sdk/workflow-cli.d.ts.map +1 -0
  29. package/dist/sdk/workflows/builtin-registry.d.ts +113 -0
  30. package/dist/sdk/workflows/builtin-registry.d.ts.map +1 -0
  31. package/dist/sdk/workflows/index.d.ts +5 -5
  32. package/dist/sdk/workflows/index.d.ts.map +1 -1
  33. package/package.json +12 -8
  34. package/src/cli.ts +85 -144
  35. package/src/commands/cli/chat/index.ts +10 -0
  36. package/src/commands/cli/workflow-command.test.ts +279 -938
  37. package/src/commands/cli/workflow-inputs.test.ts +41 -11
  38. package/src/commands/cli/workflow-inputs.ts +47 -12
  39. package/src/commands/cli/workflow-list.test.ts +234 -0
  40. package/src/commands/cli/workflow-list.ts +0 -0
  41. package/src/commands/cli/workflow.ts +11 -798
  42. package/src/scripts/constants.ts +2 -1
  43. package/src/sdk/commander.ts +161 -0
  44. package/src/sdk/components/workflow-picker-panel.tsx +78 -258
  45. package/src/sdk/define-workflow.test.ts +104 -11
  46. package/src/sdk/define-workflow.ts +47 -11
  47. package/src/sdk/errors.test.ts +16 -0
  48. package/src/sdk/index.ts +8 -8
  49. package/src/sdk/management-commands.ts +151 -0
  50. package/src/sdk/registry.ts +132 -0
  51. package/src/sdk/runtime/attached-footer.ts +1 -1
  52. package/src/sdk/runtime/executor-env.ts +45 -0
  53. package/src/sdk/runtime/executor.test.ts +37 -0
  54. package/src/sdk/runtime/executor.ts +147 -68
  55. package/src/sdk/types.ts +169 -4
  56. package/src/sdk/worker-shared.test.ts +163 -0
  57. package/src/sdk/worker-shared.ts +155 -0
  58. package/src/sdk/workflow-cli.ts +409 -0
  59. package/src/sdk/workflows/builtin/deep-research-codebase/claude/index.ts +1 -1
  60. package/src/sdk/workflows/builtin/deep-research-codebase/copilot/index.ts +1 -1
  61. package/src/sdk/workflows/builtin/deep-research-codebase/opencode/index.ts +1 -1
  62. package/src/sdk/workflows/builtin/open-claude-design/claude/index.ts +1 -1
  63. package/src/sdk/workflows/builtin/open-claude-design/copilot/index.ts +1 -1
  64. package/src/sdk/workflows/builtin/open-claude-design/opencode/index.ts +1 -1
  65. package/src/sdk/workflows/builtin/ralph/claude/index.ts +1 -1
  66. package/src/sdk/workflows/builtin/ralph/copilot/index.ts +1 -1
  67. package/src/sdk/workflows/builtin/ralph/opencode/index.ts +1 -1
  68. package/src/sdk/workflows/builtin-registry.ts +23 -0
  69. package/src/sdk/workflows/index.ts +10 -20
  70. package/src/services/system/auth.test.ts +63 -1
  71. package/.agents/skills/workflow-creator/SKILL.md +0 -334
  72. package/.agents/skills/workflow-creator/references/agent-sessions.md +0 -888
  73. package/.agents/skills/workflow-creator/references/computation-and-validation.md +0 -201
  74. package/.agents/skills/workflow-creator/references/control-flow.md +0 -470
  75. package/.agents/skills/workflow-creator/references/discovery-and-verification.md +0 -232
  76. package/.agents/skills/workflow-creator/references/failure-modes.md +0 -903
  77. package/.agents/skills/workflow-creator/references/getting-started.md +0 -275
  78. package/.agents/skills/workflow-creator/references/running-workflows.md +0 -235
  79. package/.agents/skills/workflow-creator/references/session-config.md +0 -384
  80. package/.agents/skills/workflow-creator/references/state-and-data-flow.md +0 -357
  81. package/.agents/skills/workflow-creator/references/user-input.md +0 -234
  82. package/.agents/skills/workflow-creator/references/workflow-inputs.md +0 -272
  83. package/dist/sdk/runtime/discovery.d.ts +0 -132
  84. package/dist/sdk/runtime/discovery.d.ts.map +0 -1
  85. package/dist/sdk/runtime/executor-entry.d.ts +0 -11
  86. package/dist/sdk/runtime/executor-entry.d.ts.map +0 -1
  87. package/dist/sdk/runtime/loader.d.ts +0 -70
  88. package/dist/sdk/runtime/loader.d.ts.map +0 -1
  89. package/dist/version.d.ts +0 -2
  90. package/dist/version.d.ts.map +0 -1
  91. package/src/commands/cli/workflow.test.ts +0 -317
  92. package/src/sdk/runtime/discovery.ts +0 -368
  93. package/src/sdk/runtime/executor-entry.ts +0 -18
  94. package/src/sdk/runtime/loader.ts +0 -267
@@ -1,154 +1,65 @@
1
1
  /**
2
- * Integration-style tests for `workflowCommand` — the CLI entry point that
3
- * wires list/picker/named-mode branching together. Three modules are stubbed:
4
- * the workflows SDK (executor + tmux probe + discovery), the system detector
5
- * (command-presence checks), and the spawn helpers (best-effort installers).
6
- * Every one of these is a side-effectful dependency — tmux spawn, disk I/O,
7
- * agent CLI spawn — and replacing them with controlled fakes lets us hit the
8
- * CLI's error/success branches without actually touching the real system.
2
+ * Tests for `workflowCommand` — the Commander Command returned by
3
+ * `createWorkflowCli(createBuiltinRegistry()).command("workflow")`.
9
4
  *
10
- * Two patterns make this file work:
5
+ * Mocking strategy: mock.module("../../sdk/runtime/executor.ts") replaces
6
+ * executeWorkflow with a spy BEFORE the dynamic import of workflow.ts.
11
7
  *
12
- * 1. `mock.module(…)` replaces each dependency module BEFORE the first
13
- * dynamic `import("./workflow.ts")` so the module-under-test binds to
14
- * the mocked references. Top-level await is required — a static import
15
- * would hoist above the mocks and defeat them.
8
+ * Module load order:
9
+ * 1. Static imports execute first (hoisted by ES module semantics)
10
+ * this loads registry.ts providers/claude.ts executor.ts (REAL),
11
+ * so `escBash` and all other executor exports are cached before the mock.
12
+ * 2. `mock.module` replaces executor.ts for SUBSEQUENT imports — only
13
+ * `worker.ts` picks up the mocked executeWorkflow/runOrchestrator.
14
+ * 3. Dynamic import of workflow.ts uses the mocked executor via worker.ts.
16
15
  *
17
- * 2. Every test runs against a fresh `mkdtemp`ed cwd plumbed through the
18
- * `cwd` option. That lets us control which workflows the command sees
19
- * without touching the repo's own `.atomic/workflows` tree.
16
+ * Commander error handling: `exitOverride()` is called on the command before
17
+ * tests that expect rejection, converting process.exit(1) into a thrown Error.
20
18
  */
21
19
 
22
20
  import {
23
21
  describe,
24
22
  test,
25
23
  expect,
26
- beforeAll,
27
- afterAll,
28
24
  beforeEach,
29
25
  afterEach,
30
26
  mock,
31
27
  } from "bun:test";
32
- import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
33
- import { join } from "node:path";
34
- import { tmpdir } from "node:os";
35
- import * as realWorkflows from "../../sdk/workflows/index.ts";
36
- import * as realDetect from "../../services/system/detect.ts";
37
- import * as realAuth from "../../services/system/auth.ts";
38
- import * as realSpawn from "../../lib/spawn.ts";
39
- import { AGENT_CONFIG } from "../../services/config/index.ts";
40
- import type {
41
- WorkflowDefinition,
42
- WorkflowRunOptions,
43
- DiscoveredWorkflow,
44
- } from "../../sdk/workflows/index.ts";
45
-
46
- // Capture original function references BEFORE `mock.module` replaces the
47
- // module exports. `import * as realWorkflows` gives a LIVE namespace — after
48
- // mock.module rebinds the exports, `realWorkflows.discoverWorkflows` would
49
- // resolve to our own mock and a pass-through would recurse infinitely. These
50
- // constants lock in the real implementations so pass-through defaults work.
51
- const realDiscoverWorkflows = realWorkflows.discoverWorkflows;
52
- const realLoadWorkflowsMetadata = realWorkflows.loadWorkflowsMetadata;
53
- const realIsCommandInstalled = realDetect.isCommandInstalled;
54
- // Snapshot the real `auth.ts` exports before `mock.module` rebinds them.
55
- // Bun 1.3.13 canonicalizes `mock.module` specifiers to an absolute path, so
56
- // our registration for `"../../services/system/auth.ts"` now shares a key with
57
- // `auth.test.ts`'s `"./auth.ts"` — the mock leaks across files unless we
58
- // explicitly restore on teardown. See `afterAll` at the bottom of this file.
59
- const realAuthSnapshot = { ...realAuth };
60
-
61
- // ─── Dependency mocks ───────────────────────────────────────────────────────
62
- // Every mock is a wrapper around the real implementation by default so
63
- // unrelated tests that don't care about a given mock still see the real
64
- // behaviour. Tests override specific mocks via `mockImplementationOnce` (or a
65
- // longer-lived `mockImplementation` inside a describe block) to exercise
66
- // failure branches. `beforeEach` resets everything to the default pass-through.
67
-
68
- const executeWorkflowMock =
69
- mock<(opts: WorkflowRunOptions) => Promise<void>>(async () => {});
70
-
71
- // Default: real discovery so the filesystem-level branches still work.
72
- const discoverWorkflowsMock = mock<typeof realWorkflows.discoverWorkflows>(
73
- (...args) => realDiscoverWorkflows(...args),
74
- );
75
-
76
- // Default: real metadata load — supports the picker branches that need
77
- // compiled metadata from a real workflow on disk.
78
- const loadWorkflowsMetadataMock = mock<
79
- typeof realWorkflows.loadWorkflowsMetadata
80
- >((...args) => realLoadWorkflowsMetadata(...args));
81
-
82
- // Default: pretend tmux is installed. The test env has it, but we want the
83
- // coverage test to be deterministic regardless of host config — if the host
84
- // removed tmux we'd still want these tests to cover the happy path.
85
- const isTmuxInstalledMock =
86
- mock<typeof realWorkflows.isTmuxInstalled>(() => true);
87
-
88
- // Default: delegate to the real check, but pretend agent CLIs are installed.
89
- // CI runners won't have copilot/opencode/claude on PATH; without this
90
- // override every test that passes through runPrereqChecks would bail early.
91
- // Non-agent commands still hit the real check so mock.module doesn't break
92
- // detect.test.ts (Bun shares one process across test files).
93
- const AGENT_CMDS = new Set(Object.values(AGENT_CONFIG).map((c) => c.cmd));
94
- const defaultIsCommandInstalled = (cmd: string) =>
95
- AGENT_CMDS.has(cmd) || realIsCommandInstalled(cmd);
96
- const isCommandInstalledMock = mock<typeof realDetect.isCommandInstalled>(
97
- defaultIsCommandInstalled,
98
- );
99
-
100
- // Default: no-op so the best-effort installer branch in runPrereqChecks
101
- // doesn't try to actually install tmux/bun on the test machine.
102
- const ensureTmuxInstalledMock = mock<typeof realSpawn.ensureTmuxInstalled>(
103
- async () => {},
104
- );
105
- const ensureBunInstalledMock = mock<typeof realSpawn.ensureBunInstalled>(
106
- async () => {},
107
- );
108
-
109
- // Default: pretend auth succeeded. Real probes spawn the agent CLI via
110
- // its SDK to talk to an auth RPC — that's network/process-heavy and
111
- // non-deterministic on CI runners, so the auth branch is faked here and
112
- // exercised directly by `auth.test.ts`.
113
- const checkAgentAuthMock = mock<typeof realAuth.checkAgentAuth>(async () => ({
114
- loggedIn: true,
115
- }));
28
+ import type { WorkflowRunOptions } from "../../sdk/runtime/executor.ts";
29
+ // Static import loads providers/claude.ts real executor.ts into module cache
30
+ // BEFORE mock.module replaces it for subsequent imports.
31
+ import "../../sdk/registry.ts";
32
+
33
+ // ─── Module-level mock ────────────────────────────────────────────────────────
34
+ // Must be declared AFTER the static imports above (which load the real executor)
35
+ // but BEFORE the dynamic import of workflow.ts below (which uses worker.ts → mock).
36
+
37
+ const executeWorkflowCalls: WorkflowRunOptions[] = [];
38
+ const executeWorkflowMock = mock(async (opts: WorkflowRunOptions): Promise<void> => {
39
+ executeWorkflowCalls.push(opts);
40
+ });
116
41
 
117
- mock.module("../../sdk/workflows/index.ts", () => ({
118
- ...realWorkflows,
42
+ // Spread real module to preserve all exports (escBash, discoverCopilotBinary, etc.)
43
+ // so this mock doesn't break other test files that import those exports.
44
+ const realExecutor = await import("../../sdk/runtime/executor.ts");
45
+ await mock.module("../../sdk/runtime/executor.ts", () => ({
46
+ ...realExecutor,
119
47
  executeWorkflow: executeWorkflowMock,
120
- discoverWorkflows: discoverWorkflowsMock,
121
- loadWorkflowsMetadata: loadWorkflowsMetadataMock,
122
- isTmuxInstalled: isTmuxInstalledMock,
123
- }));
124
- mock.module("../../services/system/detect.ts", () => ({
125
- ...realDetect,
126
- isCommandInstalled: isCommandInstalledMock,
127
- }));
128
- mock.module("../../services/system/auth.ts", () => ({
129
- ...realAuth,
130
- checkAgentAuth: checkAgentAuthMock,
131
- }));
132
- mock.module("../../lib/spawn.ts", () => ({
133
- ...realSpawn,
134
- ensureTmuxInstalled: ensureTmuxInstalledMock,
135
- ensureBunInstalled: ensureBunInstalledMock,
48
+ runOrchestrator: async () => {},
136
49
  }));
137
50
 
138
- // Dynamic import must happen AFTER `mock.module` so the module-under-test
139
- // binds to the mocked dependencies. Top-level await is fine under Bun.
140
- const { workflowCommand } = await import("./workflow.ts");
141
-
142
- // Restore `auth.ts` to its real exports once this file's tests finish so the
143
- // leaked `checkAgentAuthMock` doesn't hijack `auth.test.ts` when it loads next.
144
- afterAll(() => {
145
- mock.module("../../services/system/auth.ts", () => realAuthSnapshot);
146
- });
51
+ // Build a fresh workflowCommand using the real builtin registry directly.
52
+ // This avoids stale-cache issues when workflow.ts was previously loaded by
53
+ // cli.ts with a mocked (fake) builtin-registry in earlier test files.
54
+ const { createWorkflowCli } = await import("../../sdk/workflow-cli.ts");
55
+ const { toCommand } = await import("../../sdk/commander.ts");
56
+ const { createBuiltinRegistry } = await import("../../sdk/workflows/builtin-registry.ts");
57
+ const workflowCommand = toCommand(
58
+ createWorkflowCli(createBuiltinRegistry()),
59
+ "workflow",
60
+ );
147
61
 
148
- // ─── Output capture ─────────────────────────────────────────────────────────
149
- // The CLI writes error banners to stderr via `console.error`, success content
150
- // to stdout via `process.stdout.write`. Wrap both so tests can snapshot the
151
- // emitted text without leaking it into the test runner's own output.
62
+ // ─── Output capture ──────────────────────────────────────────────────────────
152
63
 
153
64
  interface CapturedOutput {
154
65
  stdout: string;
@@ -157,887 +68,317 @@ interface CapturedOutput {
157
68
  }
158
69
 
159
70
  function captureOutput(): CapturedOutput {
160
- const captured: CapturedOutput = {
161
- stdout: "",
162
- stderr: "",
163
- restore: () => {},
164
- };
165
- const originalStdoutWrite = process.stdout.write.bind(process.stdout);
166
- const originalConsoleError = console.error;
167
- const originalConsoleLog = console.log;
168
- const originalConsoleWarn = console.warn;
71
+ const captured: CapturedOutput = { stdout: "", stderr: "", restore: () => {} };
72
+ const origStdout = process.stdout.write.bind(process.stdout);
73
+ const origConsoleLog = console.log;
74
+ const origConsoleError = console.error;
75
+ const origConsoleWarn = console.warn;
169
76
 
170
- // Typed as never so the loose commander signature doesn't widen.
171
77
  process.stdout.write = ((chunk: string | Uint8Array): boolean => {
172
- captured.stdout +=
173
- typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk);
78
+ captured.stdout += typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk);
174
79
  return true;
175
80
  }) as typeof process.stdout.write;
176
- console.error = (...args: unknown[]) => {
177
- captured.stderr += args.map((a) => String(a)).join(" ") + "\n";
178
- };
179
81
  console.log = (...args: unknown[]) => {
180
- captured.stdout += args.map((a) => String(a)).join(" ") + "\n";
82
+ captured.stdout += args.map(String).join(" ") + "\n";
83
+ };
84
+ console.error = (...args: unknown[]) => {
85
+ captured.stderr += args.map(String).join(" ") + "\n";
181
86
  };
182
87
  console.warn = (...args: unknown[]) => {
183
- captured.stderr += args.map((a) => String(a)).join(" ") + "\n";
88
+ captured.stderr += args.map(String).join(" ") + "\n";
184
89
  };
185
90
 
186
91
  captured.restore = () => {
187
- process.stdout.write = originalStdoutWrite;
188
- console.error = originalConsoleError;
189
- console.log = originalConsoleLog;
190
- console.warn = originalConsoleWarn;
92
+ process.stdout.write = origStdout;
93
+ console.log = origConsoleLog;
94
+ console.error = origConsoleError;
95
+ console.warn = origConsoleWarn;
191
96
  };
192
97
  return captured;
193
98
  }
194
99
 
195
- // ─── Colour handling ────────────────────────────────────────────────────────
196
- // `NO_COLOR` flips both COLORS (module load time) and createPainter (call
197
- // time) into plain-text mode so assertions can match against readable
198
- // substrings rather than SGR escape noise. COLORS is baked at module load
199
- // so the env var must already be set by the time workflow.ts gets imported.
100
+ // ─── Colour suppression ──────────────────────────────────────────────────────
200
101
 
201
- let originalNoColor: string | undefined;
202
- let originalAtomicAgent: string | undefined;
203
- beforeAll(() => {
204
- originalNoColor = process.env.NO_COLOR;
102
+ let savedNoColor: string | undefined;
103
+ beforeEach(() => {
104
+ savedNoColor = process.env.NO_COLOR;
205
105
  process.env.NO_COLOR = "1";
206
- // Snapshot once so tests can freely set/unset ATOMIC_AGENT without
207
- // leaking into unrelated suites in the same bun-test process.
208
- originalAtomicAgent = process.env.ATOMIC_AGENT;
209
- });
210
- afterAll(() => {
211
- if (originalNoColor === undefined) delete process.env.NO_COLOR;
212
- else process.env.NO_COLOR = originalNoColor;
213
- if (originalAtomicAgent === undefined) delete process.env.ATOMIC_AGENT;
214
- else process.env.ATOMIC_AGENT = originalAtomicAgent;
215
- });
216
-
217
- // ─── Temp workspace plumbing ────────────────────────────────────────────────
218
- // Each test gets a fresh cwd so one test's workflows can't leak into another.
219
- // The actual workflow files live under `.atomic/workflows/<name>/<agent>/index.ts`
220
- // — matching the layout that `discoverWorkflows` scans.
221
-
222
- let tempDir: string;
223
-
224
- beforeEach(async () => {
225
- tempDir = await mkdtemp(join(tmpdir(), "atomic-workflow-cmd-test-"));
226
- // Clear ATOMIC_AGENT by default — `workflowCommand` falls back to this
227
- // env var when `-a` is omitted, and we don't want the ambient env (e.g.
228
- // a developer running tests from inside an atomic chat pane) to silently
229
- // change the agent any given test sees. Tests that need it explicitly
230
- // set it themselves.
231
- delete process.env.ATOMIC_AGENT;
232
- // Reset every mock to its default pass-through / no-op so tests are
233
- // independent — no leftover state from prior overrides. `mockClear` wipes
234
- // call history; `mockImplementation` replaces the queued implementation
235
- // (including anything set via `mockImplementationOnce`) with the default.
106
+ executeWorkflowCalls.length = 0;
236
107
  executeWorkflowMock.mockClear();
237
- executeWorkflowMock.mockImplementation(async () => {});
238
- discoverWorkflowsMock.mockClear();
239
- discoverWorkflowsMock.mockImplementation((...args) =>
240
- realDiscoverWorkflows(...args),
241
- );
242
- loadWorkflowsMetadataMock.mockClear();
243
- loadWorkflowsMetadataMock.mockImplementation((...args) =>
244
- realLoadWorkflowsMetadata(...args),
245
- );
246
- isTmuxInstalledMock.mockClear();
247
- isTmuxInstalledMock.mockImplementation(() => true);
248
- isCommandInstalledMock.mockClear();
249
- isCommandInstalledMock.mockImplementation(defaultIsCommandInstalled);
250
- ensureTmuxInstalledMock.mockClear();
251
- ensureTmuxInstalledMock.mockImplementation(async () => {});
252
- ensureBunInstalledMock.mockClear();
253
- ensureBunInstalledMock.mockImplementation(async () => {});
254
- checkAgentAuthMock.mockClear();
255
- checkAgentAuthMock.mockImplementation(async () => ({ loggedIn: true }));
256
- });
257
-
258
- afterEach(async () => {
259
- await rm(tempDir, { recursive: true, force: true });
260
- });
261
-
262
- /**
263
- * Write a real workflow file that compiles through `defineWorkflow()`.
264
- * Tests import a real SDK so the module under test sees a live
265
- * `WorkflowDefinition`, not a mock shape — this keeps the coverage
266
- * line-level on `runNamedMode`'s resolution of the compiled definition.
267
- */
268
- async function writeCompiledWorkflow(
269
- opts: {
270
- name: string;
271
- agent: "claude" | "copilot" | "opencode";
272
- source?: string;
273
- },
274
- ): Promise<string> {
275
- const dir = join(tempDir, ".atomic", "workflows", opts.name, opts.agent);
276
- await mkdir(dir, { recursive: true });
277
- const filePath = join(dir, "index.ts");
278
- const defaultBody =
279
- opts.source ??
280
- `
281
- import { defineWorkflow } from "${join(process.cwd(), "src/sdk/workflows/index.ts")}";
282
-
283
- export default defineWorkflow({ name: "${opts.name}" })
284
- .run(async () => {})
285
- .compile();
286
- `;
287
- await writeFile(filePath, defaultBody);
288
- return filePath;
289
- }
290
-
291
- // ─── List mode ──────────────────────────────────────────────────────────────
292
-
293
- describe("workflowCommand --list", () => {
294
- test("prints the rendered list and returns 0", async () => {
295
- await writeCompiledWorkflow({ name: "alpha", agent: "copilot" });
296
-
297
- const cap = captureOutput();
298
- const code = await workflowCommand({
299
- list: true,
300
- agent: "copilot",
301
- cwd: tempDir,
302
- });
303
- cap.restore();
304
-
305
- expect(code).toBe(0);
306
- // Singular noun because only our one workflow is filtered in, and builtins
307
- // discovered via `{ merge: false }` may still show up — so assert on the
308
- // name we wrote instead of a count.
309
- expect(cap.stdout).toContain("alpha");
310
- expect(cap.stdout).toContain("run: atomic workflow -n <name> -a <agent>");
311
- });
312
-
313
- test("filters by the provided agent", async () => {
314
- await writeCompiledWorkflow({ name: "claude-only", agent: "claude" });
315
- await writeCompiledWorkflow({ name: "copilot-only", agent: "copilot" });
316
-
317
- const cap = captureOutput();
318
- const code = await workflowCommand({
319
- list: true,
320
- agent: "claude",
321
- cwd: tempDir,
322
- });
323
- cap.restore();
324
-
325
- expect(code).toBe(0);
326
- expect(cap.stdout).toContain("claude-only");
327
- expect(cap.stdout).not.toContain("copilot-only");
328
- });
329
-
330
- test("shows workflows missing .compile() with a broken marker", async () => {
331
- // Broken workflows used to vanish from the list silently. Surfacing
332
- // them with a visible "✗ broken" badge is the remediation for the
333
- // "my workflow disappeared after upgrading" class of bug reports —
334
- // the user can now see the file still exists and needs a fix.
335
- await writeCompiledWorkflow({ name: "good", agent: "copilot" });
336
- await writeCompiledWorkflow({
337
- name: "not-compiled",
338
- agent: "copilot",
339
- source: `
340
- import { defineWorkflow } from "${join(process.cwd(), "src/sdk/workflows/index.ts")}";
341
-
342
- export default defineWorkflow({ name: "not-compiled" })
343
- .run(async () => {});
344
- // intentionally missing .compile()
345
- `,
346
- });
347
-
348
- const cap = captureOutput();
349
- const code = await workflowCommand({
350
- list: true,
351
- agent: "copilot",
352
- cwd: tempDir,
353
- });
354
- cap.restore();
355
-
356
- expect(code).toBe(0);
357
- expect(cap.stdout).toContain("good");
358
- expect(cap.stdout).toContain("not-compiled");
359
- expect(cap.stdout).toContain("✗ broken");
360
- });
361
-
362
- test("shows workflows with type errors with a broken marker", async () => {
363
- await writeCompiledWorkflow({ name: "valid", agent: "copilot" });
364
- await writeCompiledWorkflow({
365
- name: "broken-syntax",
366
- agent: "copilot",
367
- source: `this is not valid typescript }{}{`,
368
- });
369
-
370
- const cap = captureOutput();
371
- const code = await workflowCommand({
372
- list: true,
373
- agent: "copilot",
374
- cwd: tempDir,
375
- });
376
- cap.restore();
377
-
378
- expect(code).toBe(0);
379
- expect(cap.stdout).toContain("valid");
380
- expect(cap.stdout).toContain("broken-syntax");
381
- expect(cap.stdout).toContain("✗ broken");
382
- });
383
-
384
- test("renders the empty state when no workflows exist and no agent filter is set", async () => {
385
- // No agent filter + a fresh tempdir means `discoverWorkflows` only
386
- // returns builtins for whichever agents exist on disk; to exercise
387
- // the real empty-state branch we filter to an agent with no builtin
388
- // coverage for the tempdir — `opencode` has builtins too, so instead
389
- // point at an empty workflows directory.
390
- const cap = captureOutput();
391
- const code = await workflowCommand({
392
- list: true,
393
- agent: "copilot",
394
- cwd: tempDir,
395
- });
396
- cap.restore();
397
-
398
- expect(code).toBe(0);
399
- // Either the builtin ralph shows up or we get the "no workflows" banner.
400
- // We only need to verify the code path completes and writes *something*.
401
- expect(cap.stdout.length).toBeGreaterThan(0);
108
+ executeWorkflowMock.mockImplementation(async (opts) => {
109
+ executeWorkflowCalls.push(opts);
402
110
  });
403
111
  });
404
-
405
- // ─── Agent validation ──────────────────────────────────────────────────────
406
-
407
- describe("workflowCommand agent validation", () => {
408
- test("missing agent returns 1 and logs a targeted error", async () => {
409
- const cap = captureOutput();
410
- const code = await workflowCommand({ cwd: tempDir });
411
- cap.restore();
412
-
413
- expect(code).toBe(1);
414
- expect(cap.stderr).toContain("Missing agent");
415
- });
416
-
417
- test("unknown agent returns 1 and lists valid agents", async () => {
418
- const cap = captureOutput();
419
- const code = await workflowCommand({
420
- agent: "bogus-agent",
421
- cwd: tempDir,
422
- });
423
- cap.restore();
424
-
425
- expect(code).toBe(1);
426
- expect(cap.stderr).toContain("Unknown agent");
427
- // Error helper lists valid agents — spot-check one.
428
- expect(cap.stderr).toContain("claude");
429
- });
430
- });
431
-
432
- // ─── Picker mode error paths ───────────────────────────────────────────────
433
-
434
- describe("workflowCommand picker mode", () => {
435
- test("rejects passthrough args in picker mode", async () => {
436
- // No `-n` means picker mode; any extra args are ambiguous (would the
437
- // user want them fed into the picker's form, or straight through?), so
438
- // the command bails early rather than guessing.
439
- const cap = captureOutput();
440
- const code = await workflowCommand({
441
- agent: "copilot",
442
- passthroughArgs: ["oops", "--mode=fast"],
443
- cwd: tempDir,
444
- });
445
- cap.restore();
446
-
447
- expect(code).toBe(1);
448
- expect(cap.stderr).toContain("unexpected arguments");
449
- // The hint points the user at the right place.
450
- expect(cap.stderr).toContain("-n <name>");
451
- });
112
+ afterEach(() => {
113
+ if (savedNoColor === undefined) delete process.env.NO_COLOR;
114
+ else process.env.NO_COLOR = savedNoColor;
452
115
  });
453
116
 
454
- // ─── Named mode error paths ────────────────────────────────────────────────
455
-
456
- describe("workflowCommand named-mode error paths", () => {
457
- test("unknown workflow name returns 1 and lists available options", async () => {
458
- // Seed one workflow so the "Available" section renders.
459
- await writeCompiledWorkflow({ name: "real-one", agent: "copilot" });
460
-
461
- const cap = captureOutput();
462
- const code = await workflowCommand({
463
- name: "does-not-exist",
464
- agent: "copilot",
465
- cwd: tempDir,
466
- });
467
- cap.restore();
468
-
469
- expect(code).toBe(1);
470
- expect(cap.stderr).toContain("does-not-exist");
471
- expect(cap.stderr).toContain("not found");
472
- // Lists the real workflow we wrote so users can copy-paste a valid name.
473
- expect(cap.stderr).toContain("real-one");
474
- // executeWorkflow should never be called on the error path.
475
- expect(executeWorkflowMock).not.toHaveBeenCalled();
476
- });
477
-
478
- test("available workflows hint excludes uncompiled workflows", async () => {
479
- await writeCompiledWorkflow({ name: "real-one", agent: "copilot" });
480
- await writeCompiledWorkflow({
481
- name: "uncompiled",
482
- agent: "copilot",
483
- source: `
484
- import { defineWorkflow } from "${join(process.cwd(), "src/sdk/workflows/index.ts")}";
485
-
486
- export default defineWorkflow({ name: "uncompiled" })
487
- .run(async () => {});
488
- `,
489
- });
490
-
491
- const cap = captureOutput();
492
- const code = await workflowCommand({
493
- name: "does-not-exist",
494
- agent: "copilot",
495
- cwd: tempDir,
496
- });
497
- cap.restore();
498
-
499
- expect(code).toBe(1);
500
- expect(cap.stderr).toContain("real-one");
501
- expect(cap.stderr).not.toContain("uncompiled");
502
- });
503
-
504
- test("parse errors in passthrough args abort before loading", async () => {
505
- await writeCompiledWorkflow({ name: "parse-err", agent: "copilot" });
506
-
507
- const cap = captureOutput();
508
- const code = await workflowCommand({
509
- name: "parse-err",
510
- agent: "copilot",
511
- // Trailing --flag with no value is the canonical parse error.
512
- passthroughArgs: ["--orphan"],
513
- cwd: tempDir,
514
- });
515
- cap.restore();
516
-
517
- expect(code).toBe(1);
518
- expect(cap.stderr).toContain("--orphan");
519
- expect(executeWorkflowMock).not.toHaveBeenCalled();
520
- });
521
-
522
- test("load errors from WorkflowLoader surface cleanly", async () => {
523
- // Write a workflow file that lacks `.compile()` — the loader treats
524
- // this as a hard error and the CLI must return 1 rather than crash.
525
- await writeCompiledWorkflow({
526
- name: "broken",
527
- agent: "copilot",
528
- source: `
529
- import { defineWorkflow } from "${join(process.cwd(), "src/sdk/workflows/index.ts")}";
530
-
531
- export default defineWorkflow({ name: "broken" })
532
- .run(async () => {});
533
- // intentionally missing .compile()
534
- `,
535
- });
536
-
537
- const cap = captureOutput();
538
- const code = await workflowCommand({
539
- name: "broken",
540
- agent: "copilot",
541
- cwd: tempDir,
542
- });
543
- cap.restore();
544
-
545
- expect(code).toBe(1);
546
- expect(cap.stderr).toContain("not compiled");
547
- expect(executeWorkflowMock).not.toHaveBeenCalled();
548
- });
549
-
550
- test("free-form workflow rejects stray --flags", async () => {
551
- // A workflow with no declared `inputs` takes a positional prompt; any
552
- // `--<name>` flag is definitionally wrong because there's nothing for
553
- // it to bind to.
554
- await writeCompiledWorkflow({ name: "free-form", agent: "copilot" });
555
-
556
- const cap = captureOutput();
557
- const code = await workflowCommand({
558
- name: "free-form",
559
- agent: "copilot",
560
- passthroughArgs: ["--mode=fast"],
561
- cwd: tempDir,
562
- });
563
- cap.restore();
564
-
565
- expect(code).toBe(1);
566
- expect(cap.stderr).toContain("no declared inputs");
567
- expect(cap.stderr).toContain("--mode");
568
- expect(executeWorkflowMock).not.toHaveBeenCalled();
569
- });
570
-
571
- test("structured workflow rejects positional prompt tokens", async () => {
572
- await writeCompiledWorkflow({
573
- name: "structured",
574
- agent: "copilot",
575
- source: `
576
- import { defineWorkflow } from "${join(process.cwd(), "src/sdk/workflows/index.ts")}";
577
-
578
- export default defineWorkflow({
579
- name: "structured",
580
- inputs: [
581
- { name: "topic", type: "string", required: true },
582
- ],
583
- })
584
- .run(async () => {})
585
- .compile();
586
- `,
587
- });
588
-
589
- const cap = captureOutput();
590
- const code = await workflowCommand({
591
- name: "structured",
592
- agent: "copilot",
593
- // Positional-only invocation is ambiguous against a structured
594
- // schema, so the command refuses to guess.
595
- passthroughArgs: ["just", "a", "prompt"],
596
- cwd: tempDir,
597
- });
598
- cap.restore();
599
-
600
- expect(code).toBe(1);
601
- expect(cap.stderr).toContain("structured inputs");
602
- expect(cap.stderr).toContain("--topic");
603
- expect(executeWorkflowMock).not.toHaveBeenCalled();
604
- });
605
-
606
- test("structured workflow surfaces schema validation errors", async () => {
607
- await writeCompiledWorkflow({
608
- name: "validated",
609
- agent: "copilot",
610
- source: `
611
- import { defineWorkflow } from "${join(process.cwd(), "src/sdk/workflows/index.ts")}";
117
+ // ─── exitOverride helper ──────────────────────────────────────────────────────
118
+ // Calling exitOverride() converts Commander's process.exit(1) into a thrown
119
+ // Error so tests can assert on rejection without killing the process.
612
120
 
613
- export default defineWorkflow({
614
- name: "validated",
615
- inputs: [
616
- { name: "topic", type: "string", required: true },
617
- ],
618
- })
619
- .run(async () => {})
620
- .compile();
621
- `,
622
- });
623
-
624
- const cap = captureOutput();
625
- const code = await workflowCommand({
626
- name: "validated",
627
- agent: "copilot",
628
- // Empty flag set — required `topic` is missing.
629
- passthroughArgs: [],
630
- cwd: tempDir,
631
- });
632
- cap.restore();
121
+ function enableExitOverride(): void {
122
+ workflowCommand.exitOverride();
123
+ }
633
124
 
634
- expect(code).toBe(1);
635
- expect(cap.stderr).toContain("--topic");
125
+ // ─── Listing removed from dispatcher flags ──────────────────────────────────
126
+ //
127
+ // `--list` / `-l` used to live on the dispatcher command as a flag. It's
128
+ // since moved to a dedicated `atomic workflow list` subcommand (registered
129
+ // in src/cli.ts, implemented in ./workflow-list.ts) because the flag form
130
+ // had confusing interactions with argv parsing. The dispatcher itself no
131
+ // longer accepts the flag.
132
+
133
+ describe("workflowCommand: --list flag removed", () => {
134
+ test("--list is not a recognised dispatcher option", async () => {
135
+ enableExitOverride();
136
+ let threw = false;
137
+ const cap = captureOutput();
138
+ try {
139
+ await workflowCommand.parseAsync(["node", "cli", "--list"]);
140
+ } catch {
141
+ threw = true;
142
+ } finally {
143
+ cap.restore();
144
+ }
145
+ expect(threw).toBe(true);
636
146
  expect(executeWorkflowMock).not.toHaveBeenCalled();
637
147
  });
638
148
  });
639
149
 
640
- // ─── Named mode success paths (via mocked executor) ────────────────────────
150
+ // ─── Named mode success ───────────────────────────────────────────────────────
641
151
 
642
- describe("workflowCommand named-mode success paths", () => {
643
- test("free-form workflow runs through the executor with the prompt as input", async () => {
644
- await writeCompiledWorkflow({ name: "runs", agent: "copilot" });
152
+ describe("workflowCommand named mode success", () => {
153
+ test("dispatches ralph/claude with prompt to executor", async () => {
154
+ await workflowCommand.parseAsync([
155
+ "node", "cli",
156
+ "-n", "ralph",
157
+ "-a", "claude",
158
+ "--prompt", "fix the auth bug",
159
+ ]);
645
160
 
646
- const cap = captureOutput();
647
- const code = await workflowCommand({
648
- name: "runs",
649
- agent: "copilot",
650
- passthroughArgs: ["fix", "the", "bug"],
651
- cwd: tempDir,
652
- });
653
- cap.restore();
654
-
655
- expect(code).toBe(0);
656
161
  expect(executeWorkflowMock).toHaveBeenCalledTimes(1);
657
- const call = executeWorkflowMock.mock.calls[0]![0];
658
- expect(call.agent).toBe("copilot");
659
- // Free-form prompt is threaded under the `prompt` key so workflow
660
- // authors can read `ctx.inputs.prompt` uniformly.
661
- expect(call.inputs).toEqual({ prompt: "fix the bug" });
662
- expect((call.definition as WorkflowDefinition).name).toBe("runs");
162
+ const call = executeWorkflowCalls[0]!;
163
+ expect(call.agent).toBe("claude");
164
+ expect(call.inputs?.["prompt"]).toBe("fix the auth bug");
165
+ expect(call.workflowKey).toBe("claude/ralph");
663
166
  });
664
167
 
665
- test("free-form workflow with no prompt forwards an empty inputs record", async () => {
666
- await writeCompiledWorkflow({ name: "silent", agent: "copilot" });
168
+ test("dispatches ralph/copilot successfully", async () => {
169
+ await workflowCommand.parseAsync([
170
+ "node", "cli",
171
+ "-n", "ralph",
172
+ "-a", "copilot",
173
+ "--prompt", "review this PR",
174
+ ]);
667
175
 
668
- const cap = captureOutput();
669
- const code = await workflowCommand({
670
- name: "silent",
671
- agent: "copilot",
672
- passthroughArgs: [],
673
- cwd: tempDir,
674
- });
675
- cap.restore();
676
-
677
- expect(code).toBe(0);
678
176
  expect(executeWorkflowMock).toHaveBeenCalledTimes(1);
679
- expect(executeWorkflowMock.mock.calls[0]![0].inputs).toEqual({});
177
+ const call = executeWorkflowCalls[0]!;
178
+ expect(call.agent).toBe("copilot");
179
+ expect(call.inputs?.["prompt"]).toBe("review this PR");
680
180
  });
681
181
 
682
- test("detach flag is threaded through to the executor", async () => {
683
- await writeCompiledWorkflow({ name: "detached", agent: "copilot" });
182
+ test("dispatches ralph/opencode successfully", async () => {
183
+ await workflowCommand.parseAsync([
184
+ "node", "cli",
185
+ "-n", "ralph",
186
+ "-a", "opencode",
187
+ "--prompt", "refactor the service layer",
188
+ ]);
684
189
 
685
- const cap = captureOutput();
686
- const code = await workflowCommand({
687
- name: "detached",
688
- agent: "copilot",
689
- detach: true,
690
- passthroughArgs: ["run", "in", "bg"],
691
- cwd: tempDir,
692
- });
693
- cap.restore();
694
-
695
- expect(code).toBe(0);
696
190
  expect(executeWorkflowMock).toHaveBeenCalledTimes(1);
697
- expect(executeWorkflowMock.mock.calls[0]![0].detach).toBe(true);
191
+ expect(executeWorkflowCalls[0]!.agent).toBe("opencode");
698
192
  });
699
193
 
700
- test("detach defaults to false when not provided", async () => {
701
- await writeCompiledWorkflow({ name: "default-attach", agent: "copilot" });
194
+ test("dispatches deep-research-codebase/claude with prompt", async () => {
195
+ await workflowCommand.parseAsync([
196
+ "node", "cli",
197
+ "-n", "deep-research-codebase",
198
+ "-a", "claude",
199
+ "--prompt", "how does auth work",
200
+ ]);
702
201
 
703
- const cap = captureOutput();
704
- const code = await workflowCommand({
705
- name: "default-attach",
706
- agent: "copilot",
707
- passthroughArgs: [],
708
- cwd: tempDir,
709
- });
710
- cap.restore();
711
-
712
- expect(code).toBe(0);
713
202
  expect(executeWorkflowMock).toHaveBeenCalledTimes(1);
714
- expect(executeWorkflowMock.mock.calls[0]![0].detach).toBe(false);
203
+ expect(executeWorkflowCalls[0]!.workflowKey).toBe("claude/deep-research-codebase");
715
204
  });
716
205
 
717
- test("structured workflow resolves flags and calls executor with merged inputs", async () => {
718
- await writeCompiledWorkflow({
719
- name: "struct-run",
720
- agent: "copilot",
721
- source: `
722
- import { defineWorkflow } from "${join(process.cwd(), "src/sdk/workflows/index.ts")}";
723
-
724
- export default defineWorkflow({
725
- name: "struct-run",
726
- inputs: [
727
- { name: "topic", type: "string", required: true },
728
- { name: "depth", type: "enum", values: ["shallow", "deep"], default: "shallow" },
729
- ],
730
- })
731
- .run(async () => {})
732
- .compile();
733
- `,
734
- });
735
-
736
- const cap = captureOutput();
737
- const code = await workflowCommand({
738
- name: "struct-run",
739
- agent: "copilot",
740
- passthroughArgs: ["--topic=authz", "--depth=deep"],
741
- cwd: tempDir,
742
- });
743
- cap.restore();
206
+ test("--detach flag threads detach=true to executor", async () => {
207
+ await workflowCommand.parseAsync([
208
+ "node", "cli",
209
+ "-n", "ralph",
210
+ "-a", "claude",
211
+ "--prompt", "test",
212
+ "--detach",
213
+ ]);
744
214
 
745
- expect(code).toBe(0);
746
215
  expect(executeWorkflowMock).toHaveBeenCalledTimes(1);
747
- expect(executeWorkflowMock.mock.calls[0]![0].inputs).toEqual({
748
- topic: "authz",
749
- depth: "deep",
750
- });
751
- });
752
-
753
- test("runLoadedWorkflow surfaces executor failures as exit code 1", async () => {
754
- await writeCompiledWorkflow({ name: "boom", agent: "copilot" });
755
-
756
- executeWorkflowMock.mockImplementationOnce(async () => {
757
- throw new Error("tmux is on fire");
758
- });
759
-
760
- const cap = captureOutput();
761
- const code = await workflowCommand({
762
- name: "boom",
763
- agent: "copilot",
764
- passthroughArgs: ["try", "it"],
765
- cwd: tempDir,
766
- });
767
- cap.restore();
768
-
769
- expect(code).toBe(1);
770
- expect(cap.stderr).toContain("Workflow failed");
771
- expect(cap.stderr).toContain("tmux is on fire");
772
- });
773
-
774
- test("runLoadedWorkflow stringifies non-Error throwns", async () => {
775
- await writeCompiledWorkflow({ name: "non-err", agent: "copilot" });
776
-
777
- executeWorkflowMock.mockImplementationOnce(async () => {
778
- // Thrown value is a plain string — the catch branch falls back to
779
- // `String(error)` rather than reading `.message`.
780
- throw "raw string failure";
781
- });
782
-
783
- const cap = captureOutput();
784
- const code = await workflowCommand({
785
- name: "non-err",
786
- agent: "copilot",
787
- passthroughArgs: [],
788
- cwd: tempDir,
789
- });
790
- cap.restore();
791
-
792
- expect(code).toBe(1);
793
- expect(cap.stderr).toContain("raw string failure");
216
+ expect(executeWorkflowCalls[0]!.detach).toBe(true);
794
217
  });
795
- });
796
218
 
797
- // ─── ATOMIC_AGENT env var inference ────────────────────────────────────────
219
+ test("-d shorthand also sets detach=true", async () => {
220
+ await workflowCommand.parseAsync([
221
+ "node", "cli",
222
+ "-n", "ralph",
223
+ "-a", "claude",
224
+ "--prompt", "test",
225
+ "-d",
226
+ ]);
798
227
 
799
- describe("workflowCommand ATOMIC_AGENT inference", () => {
800
- // Top-level beforeEach already clears ATOMIC_AGENT; tests that need it
801
- // set it explicitly and rely on the next test's clear to reset.
802
-
803
- test("infers -a from ATOMIC_AGENT when omitted", async () => {
804
- // Agents spawned inside an atomic chat/workflow pane inherit
805
- // ATOMIC_AGENT. Re-passing their own provider back through `-a` is
806
- // boilerplate we can eliminate.
807
- await writeCompiledWorkflow({ name: "inferred", agent: "claude" });
808
- process.env.ATOMIC_AGENT = "claude";
809
-
810
- const cap = captureOutput();
811
- const code = await workflowCommand({
812
- name: "inferred",
813
- // no agent passed
814
- passthroughArgs: ["go"],
815
- cwd: tempDir,
816
- });
817
- cap.restore();
818
-
819
- expect(code).toBe(0);
820
228
  expect(executeWorkflowMock).toHaveBeenCalledTimes(1);
821
- expect(executeWorkflowMock.mock.calls[0]![0].agent).toBe("claude");
822
- });
823
-
824
- test("forces detach=true when ATOMIC_AGENT is set", async () => {
825
- // Attaching from inside the atomic socket would switch-client the
826
- // caller's own terminal onto the new workflow session — hijacking the
827
- // very pane the agent is running in. Force detach so the command
828
- // returns immediately and the caller can attach on their own terms.
829
- await writeCompiledWorkflow({ name: "auto-detach", agent: "copilot" });
830
- process.env.ATOMIC_AGENT = "copilot";
831
-
832
- const cap = captureOutput();
833
- const code = await workflowCommand({
834
- name: "auto-detach",
835
- agent: "copilot",
836
- // detach intentionally omitted
837
- passthroughArgs: [],
838
- cwd: tempDir,
839
- });
840
- cap.restore();
841
-
842
- expect(code).toBe(0);
843
- expect(executeWorkflowMock.mock.calls[0]![0].detach).toBe(true);
229
+ expect(executeWorkflowCalls[0]!.detach).toBe(true);
844
230
  });
845
231
 
846
- test("explicit -a wins over ATOMIC_AGENT", async () => {
847
- // Users running on Claude who want to invoke a Copilot workflow must
848
- // be able to override — the env var is a fallback, not a pin.
849
- await writeCompiledWorkflow({ name: "override", agent: "copilot" });
850
- process.env.ATOMIC_AGENT = "claude";
232
+ test("detach defaults to false when flag omitted", async () => {
233
+ await workflowCommand.parseAsync([
234
+ "node", "cli",
235
+ "-n", "ralph",
236
+ "-a", "claude",
237
+ "--prompt", "test",
238
+ ]);
851
239
 
852
- const cap = captureOutput();
853
- const code = await workflowCommand({
854
- name: "override",
855
- agent: "copilot",
856
- passthroughArgs: [],
857
- cwd: tempDir,
858
- });
859
- cap.restore();
860
-
861
- expect(code).toBe(0);
862
- expect(executeWorkflowMock.mock.calls[0]![0].agent).toBe("copilot");
240
+ expect(executeWorkflowMock).toHaveBeenCalledTimes(1);
241
+ expect(executeWorkflowCalls[0]!.detach).toBe(false);
863
242
  });
864
243
 
865
- test("no ATOMIC_AGENT + no -a still errors", async () => {
866
- // Baseline: outside an atomic session, `-a` is still required.
867
- const cap = captureOutput();
868
- const code = await workflowCommand({
869
- name: "anything",
870
- cwd: tempDir,
871
- });
872
- cap.restore();
244
+ test("integer input --max_loops is forwarded to executor", async () => {
245
+ await workflowCommand.parseAsync([
246
+ "node", "cli",
247
+ "-n", "ralph",
248
+ "-a", "claude",
249
+ "--prompt", "test",
250
+ "--max_loops", "3",
251
+ ]);
873
252
 
874
- expect(code).toBe(1);
875
- expect(cap.stderr).toContain("Missing agent");
253
+ expect(executeWorkflowMock).toHaveBeenCalledTimes(1);
254
+ expect(executeWorkflowCalls[0]!.inputs?.["max_loops"]).toBe("3");
876
255
  });
877
256
 
878
- test("empty ATOMIC_AGENT is treated as unset", async () => {
879
- // Shells sometimes export empty strings; don't let that poison the
880
- // agent fallback with an empty value that fails validation with a
881
- // misleading "unknown agent ''" message.
882
- process.env.ATOMIC_AGENT = "";
883
-
884
- const cap = captureOutput();
885
- const code = await workflowCommand({
886
- name: "anything",
887
- cwd: tempDir,
888
- });
889
- cap.restore();
257
+ test("workflowKey is always <agent>/<name>", async () => {
258
+ await workflowCommand.parseAsync([
259
+ "node", "cli",
260
+ "-n", "deep-research-codebase",
261
+ "-a", "copilot",
262
+ "--prompt", "research something",
263
+ ]);
890
264
 
891
- expect(code).toBe(1);
892
- expect(cap.stderr).toContain("Missing agent");
265
+ expect(executeWorkflowMock).toHaveBeenCalledTimes(1);
266
+ expect(executeWorkflowCalls[0]!.workflowKey).toBe("copilot/deep-research-codebase");
893
267
  });
894
268
  });
895
269
 
896
- // ─── Prereq checks (runPrereqChecks) ───────────────────────────────────────
897
-
898
- describe("workflowCommand prereq checks", () => {
899
- test("missing agent CLI returns 1 with an install hint", async () => {
900
- // `isCommandInstalled` is the first gate in runPrereqChecks — when it
901
- // returns false for the agent binary, the command errors out before
902
- // ever touching tmux or bun.
903
- isCommandInstalledMock.mockImplementation((cmd) => cmd !== "claude");
904
-
905
- const cap = captureOutput();
906
- const code = await workflowCommand({
907
- name: "anything",
908
- agent: "claude",
909
- cwd: tempDir,
910
- });
911
- cap.restore();
912
-
913
- expect(code).toBe(1);
914
- expect(cap.stderr).toContain("'claude' is not installed");
915
- expect(cap.stderr).toContain("Install it from");
270
+ // ─── Named mode error paths ─────────────────────────────────────────────────
271
+
272
+ describe("workflowCommand named mode — error paths", () => {
273
+ test("unknown workflow name throws (Commander exits via exitOverride)", async () => {
274
+ enableExitOverride();
275
+ let threw = false;
276
+ const cap = captureOutput();
277
+ try {
278
+ await workflowCommand.parseAsync([
279
+ "node", "cli",
280
+ "-n", "bogus-workflow",
281
+ "-a", "claude",
282
+ ]);
283
+ } catch (_e) {
284
+ threw = true;
285
+ } finally {
286
+ cap.restore();
287
+ }
288
+ expect(threw).toBe(true);
289
+ expect(executeWorkflowMock).not.toHaveBeenCalled();
916
290
  });
917
291
 
918
- test("missing tmux attempts installer then errors when still absent", async () => {
919
- // Force tmux to never appear even after the installer runs. The
920
- // installer itself resolves cleanly, so we exercise the post-installer
921
- // recheck + error-branch combination.
922
- isTmuxInstalledMock.mockImplementation(() => false);
923
-
924
- const cap = captureOutput();
925
- const code = await workflowCommand({
926
- name: "anything",
927
- agent: "copilot",
928
- cwd: tempDir,
929
- });
930
- cap.restore();
931
-
932
- expect(code).toBe(1);
933
- expect(ensureTmuxInstalledMock).toHaveBeenCalledTimes(1);
934
- // Platform-specific message — both tmux and psmux acceptable.
935
- expect(cap.stderr).toMatch(/(tmux|psmux) is not installed/);
292
+ test("unknown agent throws (Commander exits via exitOverride)", async () => {
293
+ enableExitOverride();
294
+ let threw = false;
295
+ const cap = captureOutput();
296
+ try {
297
+ await workflowCommand.parseAsync([
298
+ "node", "cli",
299
+ "-n", "ralph",
300
+ "-a", "bogus-agent",
301
+ ]);
302
+ } catch (_e) {
303
+ threw = true;
304
+ } finally {
305
+ cap.restore();
306
+ }
307
+ expect(threw).toBe(true);
308
+ expect(executeWorkflowMock).not.toHaveBeenCalled();
936
309
  });
937
310
 
938
- test("returns 1 with a login hint when the user isn't authenticated", async () => {
939
- // Auth probe runs after `isCommandInstalled` and before tmux/bun
940
- // installer checks — the workflow must bail before spawning a tmux
941
- // session users would then have to kill manually.
942
- checkAgentAuthMock.mockImplementationOnce(async () => ({
943
- loggedIn: false,
944
- detail: "oauth token missing",
945
- }));
946
-
947
- const cap = captureOutput();
948
- const code = await workflowCommand({
949
- name: "anything",
950
- agent: "copilot",
951
- cwd: tempDir,
952
- });
953
- cap.restore();
954
-
955
- expect(code).toBe(1);
956
- expect(cap.stderr).toContain("Not logged in to GitHub Copilot CLI");
957
- expect(cap.stderr).toContain("oauth token missing");
958
- expect(cap.stderr).toContain("copilot");
959
- // Downstream installers never run — the auth gate short-circuits.
960
- expect(ensureTmuxInstalledMock).not.toHaveBeenCalled();
311
+ test("missing required prompt for ralph throws from validateAndResolve", async () => {
312
+ enableExitOverride();
313
+ let threw = false;
314
+ const cap = captureOutput();
315
+ try {
316
+ await workflowCommand.parseAsync([
317
+ "node", "cli",
318
+ "-n", "ralph",
319
+ "-a", "claude",
320
+ // --prompt intentionally omitted
321
+ ]);
322
+ } catch (_e) {
323
+ threw = true;
324
+ } finally {
325
+ cap.restore();
326
+ }
327
+ expect(threw).toBe(true);
328
+ expect(executeWorkflowMock).not.toHaveBeenCalled();
961
329
  });
962
330
 
963
- test("best-effort tmux installer errors are swallowed", async () => {
964
- // Even if the installer throws, runPrereqChecks falls through to a
965
- // second `isTmuxInstalled()` check — if that still says false, we
966
- // return the same error. The installer failure itself must not
967
- // propagate.
968
- isTmuxInstalledMock.mockImplementation(() => false);
969
- ensureTmuxInstalledMock.mockImplementationOnce(async () => {
970
- throw new Error("installer crashed");
971
- });
972
-
973
- const cap = captureOutput();
974
- const code = await workflowCommand({
975
- name: "anything",
976
- agent: "copilot",
977
- cwd: tempDir,
978
- });
979
- cap.restore();
980
-
981
- expect(code).toBe(1);
982
- // The crash message never surfaces — the catch block just swallows it.
983
- expect(cap.stderr).not.toContain("installer crashed");
984
- expect(cap.stderr).toMatch(/(tmux|psmux) is not installed/);
331
+ test("non-integer value for --max_loops throws from validateAndResolve", async () => {
332
+ enableExitOverride();
333
+ let threw = false;
334
+ const cap = captureOutput();
335
+ try {
336
+ await workflowCommand.parseAsync([
337
+ "node", "cli",
338
+ "-n", "ralph",
339
+ "-a", "claude",
340
+ "--prompt", "test",
341
+ "--max_loops", "not-an-int",
342
+ ]);
343
+ } catch (_e) {
344
+ threw = true;
345
+ } finally {
346
+ cap.restore();
347
+ }
348
+ expect(threw).toBe(true);
349
+ expect(executeWorkflowMock).not.toHaveBeenCalled();
985
350
  });
986
351
  });
987
352
 
988
- // ─── Picker mode discovery branches ────────────────────────────────────────
989
-
990
- describe("workflowCommand picker discovery branches", () => {
991
- test("returns 1 when discovery finds zero workflows", async () => {
992
- // Picker mode without any workflows on disk — the CLI should explain
993
- // where to put a new workflow rather than render an empty picker.
994
- discoverWorkflowsMock.mockImplementationOnce(async () => []);
353
+ // ─── Enum input coercion ──────────────────────────────────────────────────────
995
354
 
996
- const cap = captureOutput();
997
- const code = await workflowCommand({
998
- agent: "copilot",
999
- cwd: tempDir,
1000
- });
1001
- cap.restore();
355
+ describe("workflowCommand enum input coercion", () => {
356
+ test("valid enum value accepted for open-claude-design --output-type", async () => {
357
+ await workflowCommand.parseAsync([
358
+ "node", "cli",
359
+ "-n", "open-claude-design",
360
+ "-a", "claude",
361
+ "--prompt", "design a button",
362
+ "--output-type", "prototype",
363
+ ]);
1002
364
 
1003
- expect(code).toBe(1);
1004
- expect(cap.stderr).toContain("No workflows found");
1005
- expect(cap.stderr).toContain(".atomic/workflows/<name>/copilot/index.ts");
365
+ expect(executeWorkflowMock).toHaveBeenCalledTimes(1);
366
+ expect(executeWorkflowCalls[0]!.inputs?.["output-type"]).toBe("prototype");
1006
367
  });
1007
368
 
1008
- test("returns 1 when every discovered workflow fails to load metadata", async () => {
1009
- // Discovery found entries but metadata load returned nothing — that's
1010
- // the "all workflows on disk are broken" branch. We fake a single
1011
- // discovered entry and then make the metadata loader drop it.
1012
- const fakeEntry: DiscoveredWorkflow = {
1013
- name: "broken",
1014
- agent: "copilot",
1015
- source: "local",
1016
- path: join(tempDir, ".atomic/workflows/broken/copilot/index.ts"),
1017
- };
1018
- discoverWorkflowsMock.mockImplementationOnce(async () => [fakeEntry]);
1019
- loadWorkflowsMetadataMock.mockImplementationOnce(async () => []);
1020
-
1021
- const cap = captureOutput();
1022
- const code = await workflowCommand({
1023
- agent: "copilot",
1024
- cwd: tempDir,
1025
- });
1026
- cap.restore();
369
+ test("default enum value applied when --output-type omitted", async () => {
370
+ // output-type has default "prototype" validateAndResolve fills it in.
371
+ // Note: Commander camelCases hyphenated flags (output-type outputType),
372
+ // so the CLI flag lookup for "output-type" falls through to the default.
373
+ await workflowCommand.parseAsync([
374
+ "node", "cli",
375
+ "-n", "open-claude-design",
376
+ "-a", "claude",
377
+ "--prompt", "design a button",
378
+ // --output-type intentionally omitted
379
+ ]);
1027
380
 
1028
- expect(code).toBe(1);
1029
- expect(cap.stderr).toContain("All discovered workflows failed to load");
381
+ expect(executeWorkflowMock).toHaveBeenCalledTimes(1);
382
+ expect(executeWorkflowCalls[0]!.inputs?.["output-type"]).toBe("prototype");
1030
383
  });
1031
384
  });
1032
-
1033
- // Note on the picker success path: the branches that actually open the
1034
- // interactive picker (runPickerMode lines after the "no workflows found" and
1035
- // "all failed to load" guards, plus all of runResolvedSelection) are not
1036
- // covered from this file. Exercising them requires mocking
1037
- // `WorkflowPickerPanel`, which is a side-effectful class that spins up a
1038
- // real CliRenderer on stdin/stdout. Mocking it process-wide via mock.module
1039
- // leaks into the WorkflowPickerPanel's own unit tests (they share the same
1040
- // bun test process) and breaks them — the same live-binding issue that
1041
- // mock.module has with other consumers in the suite. Rather than fight the
1042
- // tooling, we accept a small amount of uncovered code in the picker success
1043
- // path; the remaining coverage comfortably clears the per-file threshold.