@bastani/atomic 0.5.3 → 0.5.4-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 (48) hide show
  1. package/README.md +110 -11
  2. package/dist/{chunk-mn870nrv.js → chunk-xkxndz5g.js} +213 -154
  3. package/dist/sdk/components/workflow-picker-panel.d.ts +120 -0
  4. package/dist/sdk/define-workflow.d.ts +1 -1
  5. package/dist/sdk/index.js +1 -1
  6. package/dist/sdk/runtime/discovery.d.ts +57 -3
  7. package/dist/sdk/runtime/executor.d.ts +15 -2
  8. package/dist/sdk/runtime/tmux.d.ts +9 -0
  9. package/dist/sdk/types.d.ts +63 -4
  10. package/dist/sdk/workflows/builtin/deep-research-codebase/claude/index.d.ts +61 -0
  11. package/dist/sdk/workflows/builtin/deep-research-codebase/copilot/index.d.ts +48 -0
  12. package/dist/sdk/workflows/builtin/deep-research-codebase/helpers/heuristic.d.ts +25 -0
  13. package/dist/sdk/workflows/builtin/deep-research-codebase/helpers/prompts.d.ts +91 -0
  14. package/dist/sdk/workflows/builtin/deep-research-codebase/helpers/scout.d.ts +56 -0
  15. package/dist/sdk/workflows/builtin/deep-research-codebase/opencode/index.d.ts +48 -0
  16. package/dist/sdk/workflows/builtin/ralph/claude/index.js +6 -5
  17. package/dist/sdk/workflows/builtin/ralph/copilot/index.js +6 -5
  18. package/dist/sdk/workflows/builtin/ralph/opencode/index.js +6 -5
  19. package/dist/sdk/workflows/index.d.ts +4 -4
  20. package/dist/sdk/workflows/index.js +7 -1
  21. package/package.json +1 -1
  22. package/src/cli.ts +25 -3
  23. package/src/commands/cli/chat/index.ts +5 -5
  24. package/src/commands/cli/init/index.ts +79 -77
  25. package/src/commands/cli/workflow-command.test.ts +757 -0
  26. package/src/commands/cli/workflow.test.ts +310 -0
  27. package/src/commands/cli/workflow.ts +445 -105
  28. package/src/sdk/components/workflow-picker-panel.tsx +1462 -0
  29. package/src/sdk/define-workflow.test.ts +101 -0
  30. package/src/sdk/define-workflow.ts +62 -2
  31. package/src/sdk/runtime/discovery.ts +111 -8
  32. package/src/sdk/runtime/executor.ts +89 -32
  33. package/src/sdk/runtime/tmux.conf +55 -0
  34. package/src/sdk/runtime/tmux.ts +34 -10
  35. package/src/sdk/types.ts +67 -4
  36. package/src/sdk/workflows/builtin/deep-research-codebase/claude/index.ts +294 -0
  37. package/src/sdk/workflows/builtin/deep-research-codebase/copilot/index.ts +276 -0
  38. package/src/sdk/workflows/builtin/deep-research-codebase/helpers/heuristic.ts +38 -0
  39. package/src/sdk/workflows/builtin/deep-research-codebase/helpers/prompts.ts +816 -0
  40. package/src/sdk/workflows/builtin/deep-research-codebase/helpers/scout.ts +334 -0
  41. package/src/sdk/workflows/builtin/deep-research-codebase/opencode/index.ts +284 -0
  42. package/src/sdk/workflows/builtin/ralph/claude/index.ts +8 -4
  43. package/src/sdk/workflows/builtin/ralph/copilot/index.ts +10 -4
  44. package/src/sdk/workflows/builtin/ralph/opencode/index.ts +8 -4
  45. package/src/sdk/workflows/index.ts +9 -1
  46. package/src/services/system/auto-sync.ts +1 -1
  47. package/src/services/system/install-ui.ts +109 -39
  48. package/src/theme/colors.ts +65 -1
@@ -0,0 +1,757 @@
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.
9
+ *
10
+ * Two patterns make this file work:
11
+ *
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.
16
+ *
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.
20
+ */
21
+
22
+ import {
23
+ describe,
24
+ test,
25
+ expect,
26
+ beforeAll,
27
+ afterAll,
28
+ beforeEach,
29
+ afterEach,
30
+ mock,
31
+ } from "bun:test";
32
+ import { mkdtemp, mkdir, rm, writeFile } from "fs/promises";
33
+ import { join } from "path";
34
+ import { tmpdir } from "os";
35
+ import * as realWorkflows from "@/sdk/workflows/index.ts";
36
+ import * as realDetect from "@/services/system/detect.ts";
37
+ import * as realSpawn from "../../lib/spawn.ts";
38
+ import type {
39
+ WorkflowDefinition,
40
+ WorkflowRunOptions,
41
+ DiscoveredWorkflow,
42
+ } from "@/sdk/workflows/index.ts";
43
+
44
+ // Capture original function references BEFORE `mock.module` replaces the
45
+ // module exports. `import * as realWorkflows` gives a LIVE namespace — after
46
+ // mock.module rebinds the exports, `realWorkflows.discoverWorkflows` would
47
+ // resolve to our own mock and a pass-through would recurse infinitely. These
48
+ // constants lock in the real implementations so pass-through defaults work.
49
+ const realDiscoverWorkflows = realWorkflows.discoverWorkflows;
50
+ const realLoadWorkflowsMetadata = realWorkflows.loadWorkflowsMetadata;
51
+ const realIsCommandInstalled = realDetect.isCommandInstalled;
52
+
53
+ // ─── Dependency mocks ───────────────────────────────────────────────────────
54
+ // Every mock is a wrapper around the real implementation by default so
55
+ // unrelated tests that don't care about a given mock still see the real
56
+ // behaviour. Tests override specific mocks via `mockImplementationOnce` (or a
57
+ // longer-lived `mockImplementation` inside a describe block) to exercise
58
+ // failure branches. `beforeEach` resets everything to the default pass-through.
59
+
60
+ const executeWorkflowMock =
61
+ mock<(opts: WorkflowRunOptions) => Promise<void>>(async () => {});
62
+
63
+ // Default: real discovery so the filesystem-level branches still work.
64
+ const discoverWorkflowsMock = mock<typeof realWorkflows.discoverWorkflows>(
65
+ (...args) => realDiscoverWorkflows(...args),
66
+ );
67
+
68
+ // Default: real metadata load — supports the picker branches that need
69
+ // compiled metadata from a real workflow on disk.
70
+ const loadWorkflowsMetadataMock = mock<
71
+ typeof realWorkflows.loadWorkflowsMetadata
72
+ >((...args) => realLoadWorkflowsMetadata(...args));
73
+
74
+ // Default: pretend tmux is installed. The test env has it, but we want the
75
+ // coverage test to be deterministic regardless of host config — if the host
76
+ // removed tmux we'd still want these tests to cover the happy path.
77
+ const isTmuxInstalledMock =
78
+ mock<typeof realWorkflows.isTmuxInstalled>(() => true);
79
+
80
+ // Default: real presence check. Tests override for the agent-missing branch.
81
+ const isCommandInstalledMock = mock<typeof realDetect.isCommandInstalled>(
82
+ (cmd) => realIsCommandInstalled(cmd),
83
+ );
84
+
85
+ // Default: no-op so the best-effort installer branch in runPrereqChecks
86
+ // doesn't try to actually install tmux/bun on the test machine.
87
+ const ensureTmuxInstalledMock = mock<typeof realSpawn.ensureTmuxInstalled>(
88
+ async () => {},
89
+ );
90
+ const ensureBunInstalledMock = mock<typeof realSpawn.ensureBunInstalled>(
91
+ async () => {},
92
+ );
93
+
94
+ mock.module("@/sdk/workflows/index.ts", () => ({
95
+ ...realWorkflows,
96
+ executeWorkflow: executeWorkflowMock,
97
+ discoverWorkflows: discoverWorkflowsMock,
98
+ loadWorkflowsMetadata: loadWorkflowsMetadataMock,
99
+ isTmuxInstalled: isTmuxInstalledMock,
100
+ }));
101
+ mock.module("@/services/system/detect.ts", () => ({
102
+ ...realDetect,
103
+ isCommandInstalled: isCommandInstalledMock,
104
+ }));
105
+ mock.module("../../lib/spawn.ts", () => ({
106
+ ...realSpawn,
107
+ ensureTmuxInstalled: ensureTmuxInstalledMock,
108
+ ensureBunInstalled: ensureBunInstalledMock,
109
+ }));
110
+
111
+ // Dynamic import — must happen AFTER `mock.module` so the module-under-test
112
+ // binds to the mocked dependencies. Top-level await is fine under Bun.
113
+ const { workflowCommand } = await import("./workflow.ts");
114
+
115
+ // ─── Output capture ─────────────────────────────────────────────────────────
116
+ // The CLI writes error banners to stderr via `console.error`, success content
117
+ // to stdout via `process.stdout.write`. Wrap both so tests can snapshot the
118
+ // emitted text without leaking it into the test runner's own output.
119
+
120
+ interface CapturedOutput {
121
+ stdout: string;
122
+ stderr: string;
123
+ restore: () => void;
124
+ }
125
+
126
+ function captureOutput(): CapturedOutput {
127
+ const captured: CapturedOutput = {
128
+ stdout: "",
129
+ stderr: "",
130
+ restore: () => {},
131
+ };
132
+ const originalStdoutWrite = process.stdout.write.bind(process.stdout);
133
+ const originalConsoleError = console.error;
134
+ const originalConsoleLog = console.log;
135
+ const originalConsoleWarn = console.warn;
136
+
137
+ // Typed as never so the loose commander signature doesn't widen.
138
+ process.stdout.write = ((chunk: string | Uint8Array): boolean => {
139
+ captured.stdout +=
140
+ typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk);
141
+ return true;
142
+ }) as typeof process.stdout.write;
143
+ console.error = (...args: unknown[]) => {
144
+ captured.stderr += args.map((a) => String(a)).join(" ") + "\n";
145
+ };
146
+ console.log = (...args: unknown[]) => {
147
+ captured.stdout += args.map((a) => String(a)).join(" ") + "\n";
148
+ };
149
+ console.warn = (...args: unknown[]) => {
150
+ captured.stderr += args.map((a) => String(a)).join(" ") + "\n";
151
+ };
152
+
153
+ captured.restore = () => {
154
+ process.stdout.write = originalStdoutWrite;
155
+ console.error = originalConsoleError;
156
+ console.log = originalConsoleLog;
157
+ console.warn = originalConsoleWarn;
158
+ };
159
+ return captured;
160
+ }
161
+
162
+ // ─── Colour handling ────────────────────────────────────────────────────────
163
+ // `NO_COLOR` flips both COLORS (module load time) and createPainter (call
164
+ // time) into plain-text mode so assertions can match against readable
165
+ // substrings rather than SGR escape noise. COLORS is baked at module load
166
+ // so the env var must already be set by the time workflow.ts gets imported.
167
+
168
+ let originalNoColor: string | undefined;
169
+ beforeAll(() => {
170
+ originalNoColor = process.env.NO_COLOR;
171
+ process.env.NO_COLOR = "1";
172
+ });
173
+ afterAll(() => {
174
+ if (originalNoColor === undefined) delete process.env.NO_COLOR;
175
+ else process.env.NO_COLOR = originalNoColor;
176
+ });
177
+
178
+ // ─── Temp workspace plumbing ────────────────────────────────────────────────
179
+ // Each test gets a fresh cwd so one test's workflows can't leak into another.
180
+ // The actual workflow files live under `.atomic/workflows/<name>/<agent>/index.ts`
181
+ // — matching the layout that `discoverWorkflows` scans.
182
+
183
+ let tempDir: string;
184
+
185
+ beforeEach(async () => {
186
+ tempDir = await mkdtemp(join(tmpdir(), "atomic-workflow-cmd-test-"));
187
+ // Reset every mock to its default pass-through / no-op so tests are
188
+ // independent — no leftover state from prior overrides. `mockClear` wipes
189
+ // call history; `mockImplementation` replaces the queued implementation
190
+ // (including anything set via `mockImplementationOnce`) with the default.
191
+ executeWorkflowMock.mockClear();
192
+ executeWorkflowMock.mockImplementation(async () => {});
193
+ discoverWorkflowsMock.mockClear();
194
+ discoverWorkflowsMock.mockImplementation((...args) =>
195
+ realDiscoverWorkflows(...args),
196
+ );
197
+ loadWorkflowsMetadataMock.mockClear();
198
+ loadWorkflowsMetadataMock.mockImplementation((...args) =>
199
+ realLoadWorkflowsMetadata(...args),
200
+ );
201
+ isTmuxInstalledMock.mockClear();
202
+ isTmuxInstalledMock.mockImplementation(() => true);
203
+ isCommandInstalledMock.mockClear();
204
+ isCommandInstalledMock.mockImplementation((cmd) => realIsCommandInstalled(cmd));
205
+ ensureTmuxInstalledMock.mockClear();
206
+ ensureTmuxInstalledMock.mockImplementation(async () => {});
207
+ ensureBunInstalledMock.mockClear();
208
+ ensureBunInstalledMock.mockImplementation(async () => {});
209
+ });
210
+
211
+ afterEach(async () => {
212
+ await rm(tempDir, { recursive: true, force: true });
213
+ });
214
+
215
+ /**
216
+ * Write a real workflow file that compiles through `defineWorkflow()`.
217
+ * Tests import a real SDK so the module under test sees a live
218
+ * `WorkflowDefinition`, not a mock shape — this keeps the coverage
219
+ * line-level on `runNamedMode`'s resolution of the compiled definition.
220
+ */
221
+ async function writeCompiledWorkflow(
222
+ opts: {
223
+ name: string;
224
+ agent: "claude" | "copilot" | "opencode";
225
+ source?: string;
226
+ },
227
+ ): Promise<string> {
228
+ const dir = join(tempDir, ".atomic", "workflows", opts.name, opts.agent);
229
+ await mkdir(dir, { recursive: true });
230
+ const filePath = join(dir, "index.ts");
231
+ const defaultBody =
232
+ opts.source ??
233
+ `
234
+ import { defineWorkflow } from "${join(process.cwd(), "src/sdk/workflows/index.ts")}";
235
+
236
+ export default defineWorkflow({ name: "${opts.name}" })
237
+ .run(async () => {})
238
+ .compile();
239
+ `;
240
+ await writeFile(filePath, defaultBody);
241
+ return filePath;
242
+ }
243
+
244
+ // ─── List mode ──────────────────────────────────────────────────────────────
245
+
246
+ describe("workflowCommand --list", () => {
247
+ test("prints the rendered list and returns 0", async () => {
248
+ await writeCompiledWorkflow({ name: "alpha", agent: "copilot" });
249
+
250
+ const cap = captureOutput();
251
+ const code = await workflowCommand({
252
+ list: true,
253
+ agent: "copilot",
254
+ cwd: tempDir,
255
+ });
256
+ cap.restore();
257
+
258
+ expect(code).toBe(0);
259
+ // Singular noun because only our one workflow is filtered in, and builtins
260
+ // discovered via `{ merge: false }` may still show up — so assert on the
261
+ // name we wrote instead of a count.
262
+ expect(cap.stdout).toContain("alpha");
263
+ expect(cap.stdout).toContain("run: atomic workflow -n <name> -a <agent>");
264
+ });
265
+
266
+ test("filters by the provided agent", async () => {
267
+ await writeCompiledWorkflow({ name: "claude-only", agent: "claude" });
268
+ await writeCompiledWorkflow({ name: "copilot-only", agent: "copilot" });
269
+
270
+ const cap = captureOutput();
271
+ const code = await workflowCommand({
272
+ list: true,
273
+ agent: "claude",
274
+ cwd: tempDir,
275
+ });
276
+ cap.restore();
277
+
278
+ expect(code).toBe(0);
279
+ expect(cap.stdout).toContain("claude-only");
280
+ expect(cap.stdout).not.toContain("copilot-only");
281
+ });
282
+
283
+ test("renders the empty state when no workflows exist and no agent filter is set", async () => {
284
+ // No agent filter + a fresh tempdir means `discoverWorkflows` only
285
+ // returns builtins for whichever agents exist on disk; to exercise
286
+ // the real empty-state branch we filter to an agent with no builtin
287
+ // coverage for the tempdir — `opencode` has builtins too, so instead
288
+ // point at an empty workflows directory.
289
+ const cap = captureOutput();
290
+ const code = await workflowCommand({
291
+ list: true,
292
+ agent: "copilot",
293
+ cwd: tempDir,
294
+ });
295
+ cap.restore();
296
+
297
+ expect(code).toBe(0);
298
+ // Either the builtin ralph shows up or we get the "no workflows" banner.
299
+ // We only need to verify the code path completes and writes *something*.
300
+ expect(cap.stdout.length).toBeGreaterThan(0);
301
+ });
302
+ });
303
+
304
+ // ─── Agent validation ──────────────────────────────────────────────────────
305
+
306
+ describe("workflowCommand agent validation", () => {
307
+ test("missing agent returns 1 and logs a targeted error", async () => {
308
+ const cap = captureOutput();
309
+ const code = await workflowCommand({ cwd: tempDir });
310
+ cap.restore();
311
+
312
+ expect(code).toBe(1);
313
+ expect(cap.stderr).toContain("Missing agent");
314
+ });
315
+
316
+ test("unknown agent returns 1 and lists valid agents", async () => {
317
+ const cap = captureOutput();
318
+ const code = await workflowCommand({
319
+ agent: "bogus-agent",
320
+ cwd: tempDir,
321
+ });
322
+ cap.restore();
323
+
324
+ expect(code).toBe(1);
325
+ expect(cap.stderr).toContain("Unknown agent");
326
+ // Error helper lists valid agents — spot-check one.
327
+ expect(cap.stderr).toContain("claude");
328
+ });
329
+ });
330
+
331
+ // ─── Picker mode error paths ───────────────────────────────────────────────
332
+
333
+ describe("workflowCommand picker mode", () => {
334
+ test("rejects passthrough args in picker mode", async () => {
335
+ // No `-n` means picker mode; any extra args are ambiguous (would the
336
+ // user want them fed into the picker's form, or straight through?), so
337
+ // the command bails early rather than guessing.
338
+ const cap = captureOutput();
339
+ const code = await workflowCommand({
340
+ agent: "copilot",
341
+ passthroughArgs: ["oops", "--mode=fast"],
342
+ cwd: tempDir,
343
+ });
344
+ cap.restore();
345
+
346
+ expect(code).toBe(1);
347
+ expect(cap.stderr).toContain("unexpected arguments");
348
+ // The hint points the user at the right place.
349
+ expect(cap.stderr).toContain("-n <name>");
350
+ });
351
+ });
352
+
353
+ // ─── Named mode error paths ────────────────────────────────────────────────
354
+
355
+ describe("workflowCommand named-mode error paths", () => {
356
+ test("unknown workflow name returns 1 and lists available options", async () => {
357
+ // Seed one workflow so the "Available" section renders.
358
+ await writeCompiledWorkflow({ name: "real-one", agent: "copilot" });
359
+
360
+ const cap = captureOutput();
361
+ const code = await workflowCommand({
362
+ name: "does-not-exist",
363
+ agent: "copilot",
364
+ cwd: tempDir,
365
+ });
366
+ cap.restore();
367
+
368
+ expect(code).toBe(1);
369
+ expect(cap.stderr).toContain("does-not-exist");
370
+ expect(cap.stderr).toContain("not found");
371
+ // Lists the real workflow we wrote so users can copy-paste a valid name.
372
+ expect(cap.stderr).toContain("real-one");
373
+ // executeWorkflow should never be called on the error path.
374
+ expect(executeWorkflowMock).not.toHaveBeenCalled();
375
+ });
376
+
377
+ test("parse errors in passthrough args abort before loading", async () => {
378
+ await writeCompiledWorkflow({ name: "parse-err", agent: "copilot" });
379
+
380
+ const cap = captureOutput();
381
+ const code = await workflowCommand({
382
+ name: "parse-err",
383
+ agent: "copilot",
384
+ // Trailing --flag with no value is the canonical parse error.
385
+ passthroughArgs: ["--orphan"],
386
+ cwd: tempDir,
387
+ });
388
+ cap.restore();
389
+
390
+ expect(code).toBe(1);
391
+ expect(cap.stderr).toContain("--orphan");
392
+ expect(executeWorkflowMock).not.toHaveBeenCalled();
393
+ });
394
+
395
+ test("load errors from WorkflowLoader surface cleanly", async () => {
396
+ // Write a workflow file that lacks `.compile()` — the loader treats
397
+ // this as a hard error and the CLI must return 1 rather than crash.
398
+ await writeCompiledWorkflow({
399
+ name: "broken",
400
+ agent: "copilot",
401
+ source: `
402
+ import { defineWorkflow } from "${join(process.cwd(), "src/sdk/workflows/index.ts")}";
403
+
404
+ export default defineWorkflow({ name: "broken" })
405
+ .run(async () => {});
406
+ // intentionally missing .compile()
407
+ `,
408
+ });
409
+
410
+ const cap = captureOutput();
411
+ const code = await workflowCommand({
412
+ name: "broken",
413
+ agent: "copilot",
414
+ cwd: tempDir,
415
+ });
416
+ cap.restore();
417
+
418
+ expect(code).toBe(1);
419
+ expect(cap.stderr).toContain("not compiled");
420
+ expect(executeWorkflowMock).not.toHaveBeenCalled();
421
+ });
422
+
423
+ test("free-form workflow rejects stray --flags", async () => {
424
+ // A workflow with no declared `inputs` takes a positional prompt; any
425
+ // `--<name>` flag is definitionally wrong because there's nothing for
426
+ // it to bind to.
427
+ await writeCompiledWorkflow({ name: "free-form", agent: "copilot" });
428
+
429
+ const cap = captureOutput();
430
+ const code = await workflowCommand({
431
+ name: "free-form",
432
+ agent: "copilot",
433
+ passthroughArgs: ["--mode=fast"],
434
+ cwd: tempDir,
435
+ });
436
+ cap.restore();
437
+
438
+ expect(code).toBe(1);
439
+ expect(cap.stderr).toContain("no declared inputs");
440
+ expect(cap.stderr).toContain("--mode");
441
+ expect(executeWorkflowMock).not.toHaveBeenCalled();
442
+ });
443
+
444
+ test("structured workflow rejects positional prompt tokens", async () => {
445
+ await writeCompiledWorkflow({
446
+ name: "structured",
447
+ agent: "copilot",
448
+ source: `
449
+ import { defineWorkflow } from "${join(process.cwd(), "src/sdk/workflows/index.ts")}";
450
+
451
+ export default defineWorkflow({
452
+ name: "structured",
453
+ inputs: [
454
+ { name: "topic", type: "string", required: true },
455
+ ],
456
+ })
457
+ .run(async () => {})
458
+ .compile();
459
+ `,
460
+ });
461
+
462
+ const cap = captureOutput();
463
+ const code = await workflowCommand({
464
+ name: "structured",
465
+ agent: "copilot",
466
+ // Positional-only invocation is ambiguous against a structured
467
+ // schema, so the command refuses to guess.
468
+ passthroughArgs: ["just", "a", "prompt"],
469
+ cwd: tempDir,
470
+ });
471
+ cap.restore();
472
+
473
+ expect(code).toBe(1);
474
+ expect(cap.stderr).toContain("structured inputs");
475
+ expect(cap.stderr).toContain("--topic");
476
+ expect(executeWorkflowMock).not.toHaveBeenCalled();
477
+ });
478
+
479
+ test("structured workflow surfaces schema validation errors", async () => {
480
+ await writeCompiledWorkflow({
481
+ name: "validated",
482
+ agent: "copilot",
483
+ source: `
484
+ import { defineWorkflow } from "${join(process.cwd(), "src/sdk/workflows/index.ts")}";
485
+
486
+ export default defineWorkflow({
487
+ name: "validated",
488
+ inputs: [
489
+ { name: "topic", type: "string", required: true },
490
+ ],
491
+ })
492
+ .run(async () => {})
493
+ .compile();
494
+ `,
495
+ });
496
+
497
+ const cap = captureOutput();
498
+ const code = await workflowCommand({
499
+ name: "validated",
500
+ agent: "copilot",
501
+ // Empty flag set — required `topic` is missing.
502
+ passthroughArgs: [],
503
+ cwd: tempDir,
504
+ });
505
+ cap.restore();
506
+
507
+ expect(code).toBe(1);
508
+ expect(cap.stderr).toContain("--topic");
509
+ expect(executeWorkflowMock).not.toHaveBeenCalled();
510
+ });
511
+ });
512
+
513
+ // ─── Named mode success paths (via mocked executor) ────────────────────────
514
+
515
+ describe("workflowCommand named-mode success paths", () => {
516
+ test("free-form workflow runs through the executor with the prompt as input", async () => {
517
+ await writeCompiledWorkflow({ name: "runs", agent: "copilot" });
518
+
519
+ const cap = captureOutput();
520
+ const code = await workflowCommand({
521
+ name: "runs",
522
+ agent: "copilot",
523
+ passthroughArgs: ["fix", "the", "bug"],
524
+ cwd: tempDir,
525
+ });
526
+ cap.restore();
527
+
528
+ expect(code).toBe(0);
529
+ expect(executeWorkflowMock).toHaveBeenCalledTimes(1);
530
+ const call = executeWorkflowMock.mock.calls[0]![0];
531
+ expect(call.agent).toBe("copilot");
532
+ // Free-form prompt is threaded under the `prompt` key so workflow
533
+ // authors can read `ctx.inputs.prompt` uniformly.
534
+ expect(call.inputs).toEqual({ prompt: "fix the bug" });
535
+ expect((call.definition as WorkflowDefinition).name).toBe("runs");
536
+ });
537
+
538
+ test("free-form workflow with no prompt forwards an empty inputs record", async () => {
539
+ await writeCompiledWorkflow({ name: "silent", agent: "copilot" });
540
+
541
+ const cap = captureOutput();
542
+ const code = await workflowCommand({
543
+ name: "silent",
544
+ agent: "copilot",
545
+ passthroughArgs: [],
546
+ cwd: tempDir,
547
+ });
548
+ cap.restore();
549
+
550
+ expect(code).toBe(0);
551
+ expect(executeWorkflowMock).toHaveBeenCalledTimes(1);
552
+ expect(executeWorkflowMock.mock.calls[0]![0].inputs).toEqual({});
553
+ });
554
+
555
+ test("structured workflow resolves flags and calls executor with merged inputs", async () => {
556
+ await writeCompiledWorkflow({
557
+ name: "struct-run",
558
+ agent: "copilot",
559
+ source: `
560
+ import { defineWorkflow } from "${join(process.cwd(), "src/sdk/workflows/index.ts")}";
561
+
562
+ export default defineWorkflow({
563
+ name: "struct-run",
564
+ inputs: [
565
+ { name: "topic", type: "string", required: true },
566
+ { name: "depth", type: "enum", values: ["shallow", "deep"], default: "shallow" },
567
+ ],
568
+ })
569
+ .run(async () => {})
570
+ .compile();
571
+ `,
572
+ });
573
+
574
+ const cap = captureOutput();
575
+ const code = await workflowCommand({
576
+ name: "struct-run",
577
+ agent: "copilot",
578
+ passthroughArgs: ["--topic=authz", "--depth=deep"],
579
+ cwd: tempDir,
580
+ });
581
+ cap.restore();
582
+
583
+ expect(code).toBe(0);
584
+ expect(executeWorkflowMock).toHaveBeenCalledTimes(1);
585
+ expect(executeWorkflowMock.mock.calls[0]![0].inputs).toEqual({
586
+ topic: "authz",
587
+ depth: "deep",
588
+ });
589
+ });
590
+
591
+ test("runLoadedWorkflow surfaces executor failures as exit code 1", async () => {
592
+ await writeCompiledWorkflow({ name: "boom", agent: "copilot" });
593
+
594
+ executeWorkflowMock.mockImplementationOnce(async () => {
595
+ throw new Error("tmux is on fire");
596
+ });
597
+
598
+ const cap = captureOutput();
599
+ const code = await workflowCommand({
600
+ name: "boom",
601
+ agent: "copilot",
602
+ passthroughArgs: ["try", "it"],
603
+ cwd: tempDir,
604
+ });
605
+ cap.restore();
606
+
607
+ expect(code).toBe(1);
608
+ expect(cap.stderr).toContain("Workflow failed");
609
+ expect(cap.stderr).toContain("tmux is on fire");
610
+ });
611
+
612
+ test("runLoadedWorkflow stringifies non-Error throwns", async () => {
613
+ await writeCompiledWorkflow({ name: "non-err", agent: "copilot" });
614
+
615
+ executeWorkflowMock.mockImplementationOnce(async () => {
616
+ // Thrown value is a plain string — the catch branch falls back to
617
+ // `String(error)` rather than reading `.message`.
618
+ throw "raw string failure";
619
+ });
620
+
621
+ const cap = captureOutput();
622
+ const code = await workflowCommand({
623
+ name: "non-err",
624
+ agent: "copilot",
625
+ passthroughArgs: [],
626
+ cwd: tempDir,
627
+ });
628
+ cap.restore();
629
+
630
+ expect(code).toBe(1);
631
+ expect(cap.stderr).toContain("raw string failure");
632
+ });
633
+ });
634
+
635
+ // ─── Prereq checks (runPrereqChecks) ───────────────────────────────────────
636
+
637
+ describe("workflowCommand prereq checks", () => {
638
+ test("missing agent CLI returns 1 with an install hint", async () => {
639
+ // `isCommandInstalled` is the first gate in runPrereqChecks — when it
640
+ // returns false for the agent binary, the command errors out before
641
+ // ever touching tmux or bun.
642
+ isCommandInstalledMock.mockImplementation((cmd) => cmd !== "claude");
643
+
644
+ const cap = captureOutput();
645
+ const code = await workflowCommand({
646
+ name: "anything",
647
+ agent: "claude",
648
+ cwd: tempDir,
649
+ });
650
+ cap.restore();
651
+
652
+ expect(code).toBe(1);
653
+ expect(cap.stderr).toContain("'claude' is not installed");
654
+ expect(cap.stderr).toContain("Install it from");
655
+ });
656
+
657
+ test("missing tmux attempts installer then errors when still absent", async () => {
658
+ // Force tmux to never appear even after the installer runs. The
659
+ // installer itself resolves cleanly, so we exercise the post-installer
660
+ // recheck + error-branch combination.
661
+ isTmuxInstalledMock.mockImplementation(() => false);
662
+
663
+ const cap = captureOutput();
664
+ const code = await workflowCommand({
665
+ name: "anything",
666
+ agent: "copilot",
667
+ cwd: tempDir,
668
+ });
669
+ cap.restore();
670
+
671
+ expect(code).toBe(1);
672
+ expect(ensureTmuxInstalledMock).toHaveBeenCalledTimes(1);
673
+ // Platform-specific message — both tmux and psmux acceptable.
674
+ expect(cap.stderr).toMatch(/(tmux|psmux) is not installed/);
675
+ });
676
+
677
+ test("best-effort tmux installer errors are swallowed", async () => {
678
+ // Even if the installer throws, runPrereqChecks falls through to a
679
+ // second `isTmuxInstalled()` check — if that still says false, we
680
+ // return the same error. The installer failure itself must not
681
+ // propagate.
682
+ isTmuxInstalledMock.mockImplementation(() => false);
683
+ ensureTmuxInstalledMock.mockImplementationOnce(async () => {
684
+ throw new Error("installer crashed");
685
+ });
686
+
687
+ const cap = captureOutput();
688
+ const code = await workflowCommand({
689
+ name: "anything",
690
+ agent: "copilot",
691
+ cwd: tempDir,
692
+ });
693
+ cap.restore();
694
+
695
+ expect(code).toBe(1);
696
+ // The crash message never surfaces — the catch block just swallows it.
697
+ expect(cap.stderr).not.toContain("installer crashed");
698
+ expect(cap.stderr).toMatch(/(tmux|psmux) is not installed/);
699
+ });
700
+ });
701
+
702
+ // ─── Picker mode discovery branches ────────────────────────────────────────
703
+
704
+ describe("workflowCommand picker discovery branches", () => {
705
+ test("returns 1 when discovery finds zero workflows", async () => {
706
+ // Picker mode without any workflows on disk — the CLI should explain
707
+ // where to put a new workflow rather than render an empty picker.
708
+ discoverWorkflowsMock.mockImplementationOnce(async () => []);
709
+
710
+ const cap = captureOutput();
711
+ const code = await workflowCommand({
712
+ agent: "copilot",
713
+ cwd: tempDir,
714
+ });
715
+ cap.restore();
716
+
717
+ expect(code).toBe(1);
718
+ expect(cap.stderr).toContain("No workflows found");
719
+ expect(cap.stderr).toContain(".atomic/workflows/<name>/copilot/index.ts");
720
+ });
721
+
722
+ test("returns 1 when every discovered workflow fails to load metadata", async () => {
723
+ // Discovery found entries but metadata load returned nothing — that's
724
+ // the "all workflows on disk are broken" branch. We fake a single
725
+ // discovered entry and then make the metadata loader drop it.
726
+ const fakeEntry: DiscoveredWorkflow = {
727
+ name: "broken",
728
+ agent: "copilot",
729
+ source: "local",
730
+ path: join(tempDir, ".atomic/workflows/broken/copilot/index.ts"),
731
+ };
732
+ discoverWorkflowsMock.mockImplementationOnce(async () => [fakeEntry]);
733
+ loadWorkflowsMetadataMock.mockImplementationOnce(async () => []);
734
+
735
+ const cap = captureOutput();
736
+ const code = await workflowCommand({
737
+ agent: "copilot",
738
+ cwd: tempDir,
739
+ });
740
+ cap.restore();
741
+
742
+ expect(code).toBe(1);
743
+ expect(cap.stderr).toContain("All discovered workflows failed to load");
744
+ });
745
+ });
746
+
747
+ // Note on the picker success path: the branches that actually open the
748
+ // interactive picker (runPickerMode lines after the "no workflows found" and
749
+ // "all failed to load" guards, plus all of runResolvedSelection) are not
750
+ // covered from this file. Exercising them requires mocking
751
+ // `WorkflowPickerPanel`, which is a side-effectful class that spins up a
752
+ // real CliRenderer on stdin/stdout. Mocking it process-wide via mock.module
753
+ // leaks into the WorkflowPickerPanel's own unit tests (they share the same
754
+ // bun test process) and breaks them — the same live-binding issue that
755
+ // mock.module has with other consumers in the suite. Rather than fight the
756
+ // tooling, we accept a small amount of uncovered code in the picker success
757
+ // path; the remaining coverage comfortably clears the per-file threshold.