@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,803 +1,16 @@
1
1
  /**
2
- * Workflow CLI command
2
+ * Workflow CLI command — thin delegation to the SDK WorkflowCli.
3
3
  *
4
- * Usage:
5
- * atomic workflow -a <agent> interactive picker
6
- * atomic workflow -n <name> -a <agent> <prompt> free-form workflow
7
- * atomic workflow -n <name> -a <agent> --<field>=<value> ...
8
- * structured-input workflow
9
- * atomic workflow -n <name> -a <agent> -d <args> run detached (background)
10
- * atomic workflow list [-a <agent>] list discoverable workflows
4
+ * The Command returned here is mounted directly into the parent program
5
+ * (src/cli.ts), which attaches the `list`, `inputs`, `status`, and
6
+ * `session` subcommands on top of it.
11
7
  */
12
8
 
13
- import { AGENT_CONFIG, type AgentKey } from "../../services/config/index.ts";
14
- import { COLORS, createPainter, type PaletteKey } from "../../theme/colors.ts";
15
- import { isCommandInstalled } from "../../services/system/detect.ts";
16
- import { checkAgentAuth, printAuthError } from "../../services/system/auth.ts";
17
- import { ensureTmuxInstalled, ensureBunInstalled } from "../../lib/spawn.ts";
18
- import { ensureProjectSetup } from "./init/index.ts";
19
- import { ensureAtomicGlobalAgentConfigs } from "../../services/config/atomic-global-config.ts";
20
- import { getConfigRoot } from "../../services/config/config-path.ts";
21
- import {
22
- isTmuxInstalled,
23
- discoverWorkflows,
24
- findWorkflow,
25
- loadWorkflowsMetadata,
26
- executeWorkflow,
27
- WorkflowLoader,
28
- resetMuxBinaryCache,
29
- } from "../../sdk/workflows/index.ts";
30
- import type {
31
- AgentType,
32
- DiscoveredWorkflow,
33
- WorkflowInput,
34
- WorkflowMetadataStatus,
35
- WorkflowWithMetadata,
36
- } from "../../sdk/workflows/index.ts";
37
- import { WorkflowPickerPanel } from "../../sdk/components/workflow-picker-panel.tsx";
9
+ import { createWorkflowCli } from "../../sdk/workflow-cli.ts";
10
+ import { toCommand } from "../../sdk/commander.ts";
11
+ import { createBuiltinRegistry } from "../../sdk/workflows/builtin-registry.ts";
38
12
 
39
- // ─── Flag parser ────────────────────────────────────────────────────────────
40
-
41
- /**
42
- * Split commander's passthrough arg list into structured input flags and
43
- * positional tokens (the latter get joined to form the free-form prompt).
44
- *
45
- * Accepts both `--name=value` and `--name value` forms, mirroring the
46
- * conventions users already know from native agent CLIs. Flags whose
47
- * values parse-fail (e.g. a trailing `--foo` with nothing after it) are
48
- * returned as errors so the caller can print a clear usage hint rather
49
- * than swallowing the mistake.
50
- *
51
- * Short flags (`-x value`) are treated as unknown and left in the
52
- * positional bucket — we only recognise long-form `--<name>` flags as
53
- * structured inputs.
54
- */
55
- export function parsePassthroughArgs(args: string[]): {
56
- flags: Record<string, string>;
57
- positional: string[];
58
- errors: string[];
59
- } {
60
- const flags: Record<string, string> = {};
61
- const positional: string[] = [];
62
- const errors: string[] = [];
63
-
64
- for (let i = 0; i < args.length; i++) {
65
- const tok = args[i]!;
66
- if (tok.startsWith("--")) {
67
- const body = tok.slice(2);
68
- const eq = body.indexOf("=");
69
- if (eq >= 0) {
70
- const name = body.slice(0, eq);
71
- const value = body.slice(eq + 1);
72
- if (name === "") {
73
- errors.push(`Malformed flag "${tok}" — expected --<name>=<value>.`);
74
- continue;
75
- }
76
- flags[name] = value;
77
- } else {
78
- const next = args[i + 1];
79
- if (next === undefined || next.startsWith("-")) {
80
- errors.push(
81
- `Missing value for --${body}. Use --${body}=<value> or --${body} <value>.`,
82
- );
83
- continue;
84
- }
85
- flags[body] = next;
86
- i++;
87
- }
88
- } else {
89
- positional.push(tok);
90
- }
91
- }
92
-
93
- return { flags, positional, errors };
94
- }
95
-
96
- // ─── Validation ─────────────────────────────────────────────────────────────
97
-
98
- /**
99
- * Validate a set of CLI-provided input values against a workflow's
100
- * declared schema. Returns a list of human-readable error strings — the
101
- * caller should print each on its own line and exit non-zero if any are
102
- * returned.
103
- */
104
- export function validateInputsAgainstSchema(
105
- inputs: Record<string, string>,
106
- schema: readonly WorkflowInput[],
107
- ): string[] {
108
- const errors: string[] = [];
109
- const known = new Set(schema.map((i) => i.name));
110
-
111
- for (const field of schema) {
112
- const raw = inputs[field.name];
113
- const defaultStr =
114
- field.default !== undefined ? String(field.default) : undefined;
115
- const value =
116
- raw === undefined || raw === ""
117
- ? defaultStr ?? (field.type === "enum" ? field.values?.[0] ?? "" : "")
118
- : raw;
119
-
120
- if (field.required) {
121
- if (field.type === "enum") {
122
- if (value === "") {
123
- errors.push(
124
- `Missing required input --${field.name} (expected one of: ${(field.values ?? []).join(", ")}).`,
125
- );
126
- }
127
- } else if (value.trim() === "") {
128
- errors.push(`Missing required input --${field.name}.`);
129
- }
130
- }
131
-
132
- if (field.type === "enum" && value !== "") {
133
- const allowed = field.values ?? [];
134
- if (!allowed.includes(value)) {
135
- errors.push(
136
- `Invalid value for --${field.name}: "${value}". ` +
137
- `Expected one of: ${allowed.join(", ")}.`,
138
- );
139
- }
140
- }
141
-
142
- if (field.type === "integer" && value !== "") {
143
- const parsed = Number.parseInt(value, 10);
144
- if (
145
- !Number.isFinite(parsed) ||
146
- !Number.isInteger(parsed) ||
147
- String(parsed) !== value.trim()
148
- ) {
149
- errors.push(
150
- `Invalid value for --${field.name}: "${value}". Expected an integer.`,
151
- );
152
- }
153
- }
154
- }
155
-
156
- for (const name of Object.keys(inputs)) {
157
- if (!known.has(name)) {
158
- errors.push(
159
- `Unknown input --${name}. ` +
160
- `Valid inputs: ${schema.length > 0 ? schema.map((i) => `--${i.name}`).join(", ") : "(none — this workflow takes a free-form prompt)"}.`,
161
- );
162
- }
163
- }
164
-
165
- return errors;
166
- }
167
-
168
- /**
169
- * Merge CLI-provided values with schema defaults so the executor sees a
170
- * fully-resolved inputs record. Defaults for enum fields fall back to the
171
- * first declared value when no explicit default is set. Unknown keys are
172
- * dropped — validation has already flagged them.
173
- */
174
- export function resolveInputs(
175
- provided: Record<string, string>,
176
- schema: readonly WorkflowInput[],
177
- ): Record<string, string> {
178
- const out: Record<string, string> = {};
179
- for (const field of schema) {
180
- const raw = provided[field.name];
181
- if (raw !== undefined && raw !== "") {
182
- out[field.name] = raw;
183
- } else if (field.default !== undefined) {
184
- out[field.name] = String(field.default);
185
- } else if (field.type === "enum" && field.values && field.values.length > 0) {
186
- out[field.name] = field.values[0]!;
187
- }
188
- }
189
- return out;
190
- }
191
-
192
- // ─── Entry point ────────────────────────────────────────────────────────────
193
-
194
- export async function workflowCommand(options: {
195
- name?: string;
196
- agent?: string;
197
- list?: boolean;
198
- /**
199
- * When true, create the tmux session and return immediately without
200
- * attaching. Callers can use `atomic workflow session connect <id>`
201
- * to attach later. Useful for scripting / background automation.
202
- */
203
- detach?: boolean;
204
- /**
205
- * Everything commander parked in `cmd.args` — a mix of positional
206
- * prompt tokens and unknown `--<name>` flags that the
207
- * {@link parsePassthroughArgs} helper splits apart.
208
- */
209
- passthroughArgs?: string[];
210
- /**
211
- * Project root used for workflow discovery. Defaults to
212
- * `process.cwd()` in production; tests inject a temp dir so they
213
- * can control which workflows are visible without touching the
214
- * real filesystem.
215
- */
216
- cwd?: string;
217
- }): Promise<number> {
218
- const passthroughArgs = options.passthroughArgs ?? [];
219
- const cwd = options.cwd;
220
-
221
- // `ATOMIC_AGENT` is set by `atomic chat` and `atomic workflow` on the tmux
222
- // session they create, so every pane in that session inherits it. Its
223
- // presence is a reliable signal that this command is being invoked from
224
- // inside an atomic-managed session (i.e., the caller is an agent running
225
- // in a chat or a workflow pane rather than a plain shell). We use that in
226
- // two places:
227
- // 1. If `-a` was omitted, fall back to ATOMIC_AGENT so agents don't have
228
- // to pass their own provider back to themselves.
229
- // 2. Force `detach = true`, because attaching from inside the atomic
230
- // socket would `switch-client` the caller's own terminal onto the new
231
- // session — i.e., the agent would hijack the user's view. Detach is
232
- // always the safe choice here; the CLI prints attach hints so the
233
- // user can switch to the workflow whenever they want.
234
- const atomicAgentEnv = process.env.ATOMIC_AGENT;
235
- const insideAtomicSession = atomicAgentEnv !== undefined && atomicAgentEnv !== "";
236
- const detach = insideAtomicSession ? true : (options.detach ?? false);
237
-
238
- // ── List mode ──
239
- // `merge: false` keeps local and global entries independent so the
240
- // list can show both copies of a non-reserved name when they coexist
241
- // on disk. Reserved builtin names are already filtered out of both
242
- // merge modes inside `discoverWorkflows`, so shadowed local/global
243
- // workflows never reach the renderer.
244
- if (options.list) {
245
- const discovered = await discoverWorkflows(
246
- cwd,
247
- options.agent as AgentType | undefined,
248
- { merge: false },
249
- );
250
- // Keep workflows that failed to load in the list — their status is
251
- // surfaced inline as a "needs update" or "broken" tag so the user
252
- // can see that a workflow still exists on disk even after an SDK
253
- // bump invalidated it. Silent filtering was the original cause of
254
- // the "workflow vanished after upgrade" report.
255
- const workflows = await loadWorkflowsMetadata(discovered);
256
- process.stdout.write(renderWorkflowList(workflows));
257
- return 0;
258
- }
259
-
260
- // ── Agent validation (required for every non-list branch) ──
261
- // Explicit `-a` wins; otherwise fall back to the ATOMIC_AGENT env var so
262
- // workflows launched from inside an atomic chat/workflow session don't
263
- // need to re-specify the provider they're already running under.
264
- const agentInput = options.agent ?? atomicAgentEnv;
265
- if (!agentInput) {
266
- console.error(
267
- `${COLORS.red}Error: Missing agent. Use -a <agent>.${COLORS.reset}`,
268
- );
269
- return 1;
270
- }
271
-
272
- const validAgents = Object.keys(AGENT_CONFIG);
273
- if (!validAgents.includes(agentInput)) {
274
- console.error(
275
- `${COLORS.red}Error: Unknown agent '${agentInput}'.${COLORS.reset}`,
276
- );
277
- console.error(`Valid agents: ${validAgents.join(", ")}`);
278
- return 1;
279
- }
280
- const agent = agentInput as AgentKey;
281
-
282
- // ── Preflight checks (shared between picker and named modes) ──
283
- const preflightCode = await runPrereqChecks(agent);
284
- if (preflightCode !== 0) return preflightCode;
285
-
286
- // ── Preflight: global config sync + project onboarding files ──
287
- // Mirrors `atomic chat` so workflow runs see the same MCP configs,
288
- // agent settings, and global agent folders the chat command auto-heals.
289
- const projectRoot = cwd ?? process.cwd();
290
- await ensureAtomicGlobalAgentConfigs(getConfigRoot());
291
- await ensureProjectSetup(agent, projectRoot);
292
-
293
- // ── Picker mode: -a <agent>, no -n ──
294
- if (!options.name) {
295
- return runPickerMode(agent, passthroughArgs, cwd, detach);
296
- }
297
-
298
- // ── Named mode: -n <name> -a <agent> [args...] ──
299
- return runNamedMode(options.name, agent, passthroughArgs, cwd, detach);
300
- }
301
-
302
- // ─── Shared helpers ─────────────────────────────────────────────────────────
303
-
304
- /**
305
- * Verify that the agent CLI, tmux (or psmux on Windows), and bun are all
306
- * installed. Attempts best-effort installs for the latter two and
307
- * returns a non-zero exit code if any check still fails afterwards.
308
- */
309
- async function runPrereqChecks(agent: AgentKey): Promise<number> {
310
- if (!isCommandInstalled(AGENT_CONFIG[agent].cmd)) {
311
- console.error(
312
- `${COLORS.red}Error: '${AGENT_CONFIG[agent].cmd}' is not installed.${COLORS.reset}`,
313
- );
314
- console.error(`Install it from: ${AGENT_CONFIG[agent].install_url}`);
315
- return 1;
316
- }
317
-
318
- // Fail fast when the SDK reports the user isn't authenticated —
319
- // otherwise the workflow spawns the agent CLI in a detached tmux pane
320
- // and silently stalls on a login screen the user never sees.
321
- const auth = await checkAgentAuth(agent);
322
- if (!auth.loggedIn) {
323
- printAuthError(agent, auth);
324
- return 1;
325
- }
326
-
327
- if (!isTmuxInstalled()) {
328
- console.log("Terminal multiplexer not found. Installing...");
329
- try {
330
- await ensureTmuxInstalled();
331
- resetMuxBinaryCache();
332
- } catch {
333
- // Fall through to the check below — best effort.
334
- }
335
- if (!isTmuxInstalled()) {
336
- const isWin = process.platform === "win32";
337
- console.error(
338
- `${COLORS.red}Error: ${isWin ? "psmux" : "tmux"} is not installed.${COLORS.reset}`,
339
- );
340
- console.error(
341
- isWin
342
- ? "Install psmux: https://github.com/psmux/psmux#installation"
343
- : "Install tmux: https://github.com/tmux/tmux/wiki/Installing",
344
- );
345
- return 1;
346
- }
347
- }
348
-
349
- if (!Bun.which("bun")) {
350
- console.log("Bun runtime not found. Installing...");
351
- try {
352
- await ensureBunInstalled();
353
- } catch {
354
- // Best effort — fall through to the check below.
355
- }
356
- if (!Bun.which("bun")) {
357
- console.error(
358
- `${COLORS.red}Error: bun is not installed.${COLORS.reset}`,
359
- );
360
- console.error("Install bun: https://bun.sh");
361
- return 1;
362
- }
363
- }
364
-
365
- return 0;
366
- }
367
-
368
- /**
369
- * Run the given workflow definition through the executor, catching any
370
- * execution errors so the CLI can exit with a non-zero code instead of
371
- * letting an unhandled promise rejection bubble to `main()`.
372
- *
373
- * Free-form workflows ride the same `inputs` pipe — their positional
374
- * prompt is stored under `inputs.prompt`, so workflow authors read it
375
- * via `ctx.inputs.prompt ?? ""` whether or not the workflow declares
376
- * a schema.
377
- */
378
- async function runLoadedWorkflow(args: {
379
- definition: Parameters<typeof executeWorkflow>[0]["definition"];
380
- agent: AgentKey;
381
- inputs: Record<string, string>;
382
- workflowFile: string;
383
- detach: boolean;
384
- }): Promise<number> {
385
- try {
386
- await executeWorkflow({
387
- definition: args.definition,
388
- agent: args.agent,
389
- inputs: args.inputs,
390
- workflowFile: args.workflowFile,
391
- detach: args.detach,
392
- });
393
- return 0;
394
- } catch (error) {
395
- const message = error instanceof Error ? error.message : String(error);
396
- console.error(`${COLORS.red}Workflow failed: ${message}${COLORS.reset}`);
397
- return 1;
398
- }
399
- }
400
-
401
- // ─── Picker mode ────────────────────────────────────────────────────────────
402
-
403
- /**
404
- * Show the interactive picker, then hand off to the executor if the
405
- * user confirms a selection. Passthrough args are rejected here — the
406
- * picker already surfaces the same UI for typing values, so letting CLI
407
- * flags leak through would create two conflicting sources of truth.
408
- */
409
- async function runPickerMode(
410
- agent: AgentKey,
411
- passthroughArgs: string[],
412
- cwd: string | undefined,
413
- detach: boolean,
414
- ): Promise<number> {
415
- if (passthroughArgs.length > 0) {
416
- console.error(
417
- `${COLORS.red}Error: unexpected arguments for the interactive picker: ${passthroughArgs.join(" ")}${COLORS.reset}`,
418
- );
419
- console.error(
420
- `Pass workflow-specific flags only alongside -n <name>, or remove them to launch the picker.`,
421
- );
422
- return 1;
423
- }
424
-
425
- const discovered = await discoverWorkflows(cwd, agent);
426
- if (discovered.length === 0) {
427
- console.error(
428
- `${COLORS.red}No workflows found for agent '${agent}'.${COLORS.reset}`,
429
- );
430
- console.error(
431
- `Create one at: .atomic/workflows/<name>/${agent}/index.ts`,
432
- );
433
- return 1;
434
- }
435
-
436
- const metadata = await loadWorkflowsMetadata(discovered);
437
- if (metadata.length === 0) {
438
- console.error(
439
- `${COLORS.red}All discovered workflows failed to load. Check the files under .atomic/workflows/ and ~/.atomic/workflows/.${COLORS.reset}`,
440
- );
441
- return 1;
442
- }
443
-
444
- // Stable sort so the picker list order is deterministic.
445
- metadata.sort((a, b) => a.name.localeCompare(b.name));
446
-
447
- const panel = await WorkflowPickerPanel.create({ agent, workflows: metadata });
448
- let result;
449
- try {
450
- result = await panel.waitForSelection();
451
- } finally {
452
- panel.destroy();
453
- }
454
-
455
- if (!result) {
456
- return 0;
457
- }
458
-
459
- return runResolvedSelection(result.workflow, agent, result.inputs, detach);
460
- }
461
-
462
- /**
463
- * Execute a workflow selected via the picker. The picker already stores
464
- * free-form prompts under the canonical `prompt` key,
465
- * so we can hand the inputs record straight through — no split between
466
- * "prompt" and "structured inputs" is needed.
467
- */
468
- async function runResolvedSelection(
469
- workflow: WorkflowWithMetadata,
470
- agent: AgentKey,
471
- inputs: Record<string, string>,
472
- detach: boolean,
473
- ): Promise<number> {
474
- const loaded = await WorkflowLoader.loadWorkflow(workflow, {
475
- warn(warnings) {
476
- for (const w of warnings) {
477
- console.warn(`⚠ [${w.rule}] ${w.message}`);
478
- }
479
- },
480
- error(stage, _error, message) {
481
- console.error(`${COLORS.red}Error (${stage}): ${message}${COLORS.reset}`);
482
- },
483
- });
484
- if (!loaded.ok) return 1;
485
-
486
- return runLoadedWorkflow({
487
- definition: loaded.value.definition,
488
- agent,
489
- inputs,
490
- workflowFile: workflow.path,
491
- detach,
492
- });
493
- }
494
-
495
- // ─── Named mode ─────────────────────────────────────────────────────────────
496
-
497
- async function runNamedMode(
498
- name: string,
499
- agent: AgentKey,
500
- passthroughArgs: string[],
501
- cwd: string | undefined,
502
- detach: boolean,
503
- ): Promise<number> {
504
- // Find the workflow
505
- const discovered = await findWorkflow(name, agent, cwd);
506
-
507
- if (!discovered) {
508
- console.error(
509
- `${COLORS.red}Error: Workflow '${name}' not found for agent '${agent}'.${COLORS.reset}`,
510
- );
511
- console.error(`\nExpected location:`);
512
- console.error(
513
- ` .atomic/workflows/${name}/${agent}/index.ts ${COLORS.dim}(local)${COLORS.reset}`,
514
- );
515
- console.error(
516
- ` ~/.atomic/workflows/${name}/${agent}/index.ts ${COLORS.dim}(global)${COLORS.reset}`,
517
- );
518
-
519
- // Only suggest runnable alternatives — broken/incompatible workflows
520
- // are visible via `atomic workflow -l` where their status is surfaced;
521
- // listing them here would mask the real problem (the name the user
522
- // typed does not exist) behind a dead-end suggestion.
523
- const available = (
524
- await loadWorkflowsMetadata(await discoverWorkflows(cwd, agent))
525
- ).filter((w) => w.status.kind === "ok");
526
- if (available.length > 0) {
527
- console.error(`\nAvailable ${agent} workflows:`);
528
- for (const wf of available) {
529
- console.error(
530
- ` ${COLORS.dim}•${COLORS.reset} ${wf.name} ${COLORS.dim}(${wf.source})${COLORS.reset}`,
531
- );
532
- }
533
- }
534
-
535
- return 1;
536
- }
537
-
538
- // Load workflow so we can read the declared input schema before
539
- // trusting any passthrough values.
540
- const result = await WorkflowLoader.loadWorkflow(discovered, {
541
- warn(warnings) {
542
- for (const w of warnings) {
543
- console.warn(`⚠ [${w.rule}] ${w.message}`);
544
- }
545
- },
546
- error(stage, _error, message) {
547
- console.error(`${COLORS.red}Error (${stage}): ${message}${COLORS.reset}`);
548
- },
549
- });
550
-
551
- if (!result.ok) return 1;
552
- const definition = result.value.definition;
553
-
554
- // Parse passthrough args into typed flags + positional tokens. The
555
- // parser intentionally rejects only obviously-broken flags (e.g.
556
- // `--foo` with nothing after it) — unknown flag names are surfaced
557
- // later, in validateInputsAgainstSchema, so we can show the valid
558
- // flag list alongside the error.
559
- const { flags, positional, errors: parseErrors } =
560
- parsePassthroughArgs(passthroughArgs);
561
- if (parseErrors.length > 0) {
562
- for (const e of parseErrors) {
563
- console.error(`${COLORS.red}Error: ${e}${COLORS.reset}`);
564
- }
565
- return 1;
566
- }
567
-
568
- const isStructured = definition.inputs.length > 0;
569
-
570
- if (isStructured) {
571
- // Positional args are ambiguous for structured workflows — users
572
- // must go through `--<name>` flags so the executor has a typed
573
- // record to validate against.
574
- if (positional.length > 0) {
575
- console.error(
576
- `${COLORS.red}Error: workflow '${definition.name}' takes structured inputs — ` +
577
- `pass them as --<name>=<value> flags instead of a positional prompt.${COLORS.reset}`,
578
- );
579
- console.error(
580
- `Expected flags: ${definition.inputs.map((i) => `--${i.name}`).join(", ")}`,
581
- );
582
- return 1;
583
- }
584
- const validationErrors = validateInputsAgainstSchema(flags, definition.inputs);
585
- if (validationErrors.length > 0) {
586
- for (const e of validationErrors) {
587
- console.error(`${COLORS.red}Error: ${e}${COLORS.reset}`);
588
- }
589
- return 1;
590
- }
591
- const resolvedInputs = resolveInputs(flags, definition.inputs);
592
- return runLoadedWorkflow({
593
- definition,
594
- agent,
595
- inputs: resolvedInputs,
596
- workflowFile: discovered.path,
597
- detach,
598
- });
599
- }
600
-
601
- // Free-form workflows: reject stray --<flag> flags outright, since
602
- // they have no schema to validate against.
603
- if (Object.keys(flags).length > 0) {
604
- console.error(
605
- `${COLORS.red}Error: workflow '${definition.name}' has no declared inputs — unknown flags: ${Object.keys(flags).map((n) => `--${n}`).join(", ")}.${COLORS.reset}`,
606
- );
607
- console.error(
608
- `Pass your request as a positional prompt: atomic workflow -n ${definition.name} -a ${agent} "your prompt"`,
609
- );
610
- return 1;
611
- }
612
-
613
- // Free-form workflows store their single prompt under the `prompt`
614
- // key so workflow authors can read `ctx.inputs.prompt` uniformly.
615
- // An empty positional list stays as an empty inputs record and
616
- // `ctx.inputs.prompt` stays undefined.
617
- const prompt = positional.join(" ");
618
- const inputs: Record<string, string> = prompt === "" ? {} : { prompt };
619
- return runLoadedWorkflow({
620
- definition,
621
- agent,
622
- inputs,
623
- workflowFile: discovered.path,
624
- detach,
625
- });
626
- }
627
-
628
- /** Stable agent sort order; keeps output deterministic across runs. */
629
- const AGENT_ORDER: readonly AgentType[] = ["claude", "opencode", "copilot"];
630
- /** Display names shown as provider sub-headings; honours proper branding. */
631
- const AGENT_DISPLAY_NAMES: Record<AgentType, string> = {
632
- claude: "Claude",
633
- opencode: "OpenCode",
634
- copilot: "Copilot CLI",
635
- };
636
- /** Local first — project-scoped workflows are the most immediately relevant. */
637
- const SOURCE_ORDER: readonly DiscoveredWorkflow["source"][] = ["local", "global", "builtin"];
638
- /** Friendly directory labels shown inline with each section heading. */
639
- const SOURCE_DIRS: Record<DiscoveredWorkflow["source"], string> = {
640
- local: ".atomic/workflows",
641
- global: "~/.atomic/workflows",
642
- builtin: "built-in",
643
- };
644
- /** Section heading colour per source — three distinct hues so each
645
- * source reads at a glance. `accent` (blue) is deliberately reserved
646
- * for the agent-provider sub-headings nested inside each section, so
647
- * builtin uses the new `info` (sky) key to avoid a clash. */
648
- const SOURCE_COLORS: Record<DiscoveredWorkflow["source"], PaletteKey> = {
649
- local: "success", // green — project-scoped, "yours"
650
- global: "mauve", // purple — user-scoped, personal
651
- builtin: "info", // sky — ships with atomic, foundational
652
- };
653
-
654
- /**
655
- * Per-row status badge shown in `atomic workflow -l` output. `ok` rows
656
- * render with no badge (the list is already dense; flagging only
657
- * non-ok rows keeps the happy path untouched). Incompatible rows
658
- * include the required version so the user can compare at a glance;
659
- * error rows stay terse and defer detail to `atomic workflow -n
660
- * <name>` which surfaces the structured loader message.
661
- */
662
- function renderStatusBadge(
663
- paint: ReturnType<typeof createPainter>,
664
- status: WorkflowMetadataStatus,
665
- ): string {
666
- if (status.kind === "ok") return "";
667
- if (status.kind === "incompatible") {
668
- return (
669
- " " +
670
- paint("warning", "⚠ needs v" + status.requiredVersion) +
671
- paint("dim", ` (installed v${status.currentVersion})`)
672
- );
673
- }
674
- return " " + paint("error", "✗ broken");
675
- }
676
-
677
- /**
678
- * Render `atomic workflow --list` output as a printable string.
679
- *
680
- * Three-level hierarchy: source → provider → workflow name.
681
- *
682
- * Layout:
683
- * N workflows
684
- *
685
- * local (.atomic/workflows)
686
- *
687
- * Claude
688
- * <name>
689
- * <name>
690
- *
691
- * OpenCode
692
- * <name>
693
- *
694
- * global (~/.atomic/workflows)
695
- *
696
- * Claude
697
- * <name>
698
- *
699
- * run: atomic workflow -n <name> -a <agent>
700
- *
701
- * Exported for testing — the pure-function shape makes coverage for the
702
- * renderer trivial without spinning up a full CLI invocation.
703
- */
704
- export function renderWorkflowList(workflows: WorkflowWithMetadata[]): string {
705
- const paint = createPainter();
706
- const lines: string[] = [];
707
-
708
- // Empty state — teach the user where workflows live.
709
- if (workflows.length === 0) {
710
- lines.push("");
711
- lines.push(" " + paint("text", "no workflows found", { bold: true }));
712
- lines.push("");
713
- lines.push(" " + paint("dim", "create one at"));
714
- lines.push(
715
- " " +
716
- paint("accent", ".atomic/workflows/<name>/<agent>/index.ts"),
717
- );
718
- lines.push("");
719
- return lines.join("\n") + "\n";
720
- }
721
-
722
- // Group by source → agent → sorted entries. Entries carry the full
723
- // metadata (name + status) so the row renderer can append a status
724
- // badge to non-ok rows without another lookup.
725
- type EntrySummary = { name: string; status: WorkflowMetadataStatus };
726
- type ByAgent = Map<AgentType, EntrySummary[]>;
727
- const bySource = new Map<DiscoveredWorkflow["source"], ByAgent>();
728
- for (const wf of workflows) {
729
- let byAgent = bySource.get(wf.source);
730
- if (!byAgent) {
731
- byAgent = new Map();
732
- bySource.set(wf.source, byAgent);
733
- }
734
- const entries = byAgent.get(wf.agent) ?? [];
735
- entries.push({ name: wf.name, status: wf.status });
736
- byAgent.set(wf.agent, entries);
737
- }
738
- for (const byAgent of bySource.values()) {
739
- for (const entries of byAgent.values()) {
740
- entries.sort((a, b) => a.name.localeCompare(b.name));
741
- }
742
- }
743
-
744
- // Top header — data-first: the count is bold (it's the actual info), the
745
- // noun trails in dim. Handles singular "1 workflow" gracefully.
746
- const count = workflows.length;
747
- const noun = count === 1 ? "workflow" : "workflows";
748
- lines.push("");
749
- lines.push(
750
- " " + paint("text", String(count), { bold: true }) + " " + paint("dim", noun),
751
- );
752
-
753
- // One stanza per source section, with nested provider sub-groups inside.
754
- // Rhythm:
755
- // 1 blank before each source heading (section break)
756
- // 1 blank before each provider heading (grouped with its entries)
757
- for (const source of SOURCE_ORDER) {
758
- const byAgent = bySource.get(source);
759
- if (!byAgent || byAgent.size === 0) continue;
760
-
761
- // Section break before the source section.
762
- lines.push("");
763
-
764
- // Source heading: bold semantic colour + dim inline directory hint.
765
- // `local (.atomic/workflows)` — label carries the weight, parens recede.
766
- lines.push(
767
- " " +
768
- paint(SOURCE_COLORS[source], source, { bold: true }) +
769
- paint("dim", ` (${SOURCE_DIRS[source]})`),
770
- );
771
-
772
- for (const agent of AGENT_ORDER) {
773
- const entries = byAgent.get(agent);
774
- if (!entries || entries.length === 0) continue;
775
-
776
- // Provider heading: bold accent blue — a clearly different layer from
777
- // both the semantic source heading above and the neutral entries below.
778
- lines.push("");
779
- lines.push(
780
- " " + paint("accent", AGENT_DISPLAY_NAMES[agent], { bold: true }),
781
- );
782
-
783
- for (const entry of entries) {
784
- // Dim the name on non-ok rows so the eye lands on the status
785
- // badge rather than the workflow name — the badge is where the
786
- // actionable info lives, and the name is already unrunnable.
787
- const nameCol: PaletteKey = entry.status.kind === "ok" ? "text" : "dim";
788
- lines.push(
789
- " " + paint(nameCol, entry.name) + renderStatusBadge(paint, entry.status),
790
- );
791
- }
792
- }
793
- }
794
-
795
- // Footer — dim run hint, separated by a section break.
796
- lines.push("");
797
- lines.push(
798
- " " + paint("dim", "run: atomic workflow -n <name> -a <agent>"),
799
- );
800
- lines.push("");
801
-
802
- return lines.join("\n") + "\n";
803
- }
13
+ export const workflowCommand = toCommand(
14
+ createWorkflowCli(createBuiltinRegistry()),
15
+ "workflow",
16
+ );