@bastani/atomic 0.5.3-1 → 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
@@ -2,76 +2,271 @@
2
2
  * Workflow CLI command
3
3
  *
4
4
  * Usage:
5
- * atomic workflow -n <name> -a <agent> <prompt>
6
- * atomic workflow --list
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 --list list discoverable workflows
7
10
  */
8
11
 
9
12
  import { AGENT_CONFIG, type AgentKey } from "@/services/config/index.ts";
10
- import { COLORS } from "@/theme/colors.ts";
11
- import { isCommandInstalled, supportsColor, supportsTrueColor } from "@/services/system/detect.ts";
13
+ import { COLORS, createPainter, type PaletteKey } from "@/theme/colors.ts";
14
+ import { isCommandInstalled } from "@/services/system/detect.ts";
12
15
  import { ensureTmuxInstalled, ensureBunInstalled } from "../../lib/spawn.ts";
13
16
  import {
14
17
  isTmuxInstalled,
15
18
  discoverWorkflows,
16
19
  findWorkflow,
20
+ loadWorkflowsMetadata,
17
21
  executeWorkflow,
18
22
  WorkflowLoader,
19
23
  resetMuxBinaryCache,
20
24
  } from "@/sdk/workflows/index.ts";
21
- import type { AgentType, DiscoveredWorkflow } from "@/sdk/workflows/index.ts";
25
+ import type {
26
+ AgentType,
27
+ DiscoveredWorkflow,
28
+ WorkflowInput,
29
+ WorkflowWithMetadata,
30
+ } from "@/sdk/workflows/index.ts";
31
+ import { WorkflowPickerPanel } from "@/sdk/components/workflow-picker-panel.tsx";
32
+
33
+ // ─── Flag parser ────────────────────────────────────────────────────────────
34
+
35
+ /**
36
+ * Split commander's passthrough arg list into structured input flags and
37
+ * positional tokens (the latter get joined to form the free-form prompt).
38
+ *
39
+ * Accepts both `--name=value` and `--name value` forms, mirroring the
40
+ * conventions users already know from native agent CLIs. Flags whose
41
+ * values parse-fail (e.g. a trailing `--foo` with nothing after it) are
42
+ * returned as errors so the caller can print a clear usage hint rather
43
+ * than swallowing the mistake.
44
+ *
45
+ * Short flags (`-x value`) are treated as unknown and left in the
46
+ * positional bucket — we only recognise long-form `--<name>` flags as
47
+ * structured inputs.
48
+ */
49
+ export function parsePassthroughArgs(args: string[]): {
50
+ flags: Record<string, string>;
51
+ positional: string[];
52
+ errors: string[];
53
+ } {
54
+ const flags: Record<string, string> = {};
55
+ const positional: string[] = [];
56
+ const errors: string[] = [];
57
+
58
+ for (let i = 0; i < args.length; i++) {
59
+ const tok = args[i]!;
60
+ if (tok.startsWith("--")) {
61
+ const body = tok.slice(2);
62
+ const eq = body.indexOf("=");
63
+ if (eq >= 0) {
64
+ const name = body.slice(0, eq);
65
+ const value = body.slice(eq + 1);
66
+ if (name === "") {
67
+ errors.push(`Malformed flag "${tok}" — expected --<name>=<value>.`);
68
+ continue;
69
+ }
70
+ flags[name] = value;
71
+ } else {
72
+ const next = args[i + 1];
73
+ if (next === undefined || next.startsWith("-")) {
74
+ errors.push(
75
+ `Missing value for --${body}. Use --${body}=<value> or --${body} <value>.`,
76
+ );
77
+ continue;
78
+ }
79
+ flags[body] = next;
80
+ i++;
81
+ }
82
+ } else {
83
+ positional.push(tok);
84
+ }
85
+ }
86
+
87
+ return { flags, positional, errors };
88
+ }
89
+
90
+ // ─── Validation ─────────────────────────────────────────────────────────────
91
+
92
+ /**
93
+ * Validate a set of CLI-provided input values against a workflow's
94
+ * declared schema. Returns a list of human-readable error strings — the
95
+ * caller should print each on its own line and exit non-zero if any are
96
+ * returned.
97
+ */
98
+ export function validateInputsAgainstSchema(
99
+ inputs: Record<string, string>,
100
+ schema: readonly WorkflowInput[],
101
+ ): string[] {
102
+ const errors: string[] = [];
103
+ const known = new Set(schema.map((i) => i.name));
104
+
105
+ for (const field of schema) {
106
+ const raw = inputs[field.name];
107
+ const value =
108
+ raw === undefined || raw === ""
109
+ ? field.default ?? (field.type === "enum" ? field.values?.[0] ?? "" : "")
110
+ : raw;
111
+
112
+ if (field.required) {
113
+ if (field.type === "enum") {
114
+ if (value === "") {
115
+ errors.push(
116
+ `Missing required input --${field.name} (expected one of: ${(field.values ?? []).join(", ")}).`,
117
+ );
118
+ }
119
+ } else if (value.trim() === "") {
120
+ errors.push(`Missing required input --${field.name}.`);
121
+ }
122
+ }
123
+
124
+ if (field.type === "enum" && value !== "") {
125
+ const allowed = field.values ?? [];
126
+ if (!allowed.includes(value)) {
127
+ errors.push(
128
+ `Invalid value for --${field.name}: "${value}". ` +
129
+ `Expected one of: ${allowed.join(", ")}.`,
130
+ );
131
+ }
132
+ }
133
+ }
134
+
135
+ for (const name of Object.keys(inputs)) {
136
+ if (!known.has(name)) {
137
+ errors.push(
138
+ `Unknown input --${name}. ` +
139
+ `Valid inputs: ${schema.length > 0 ? schema.map((i) => `--${i.name}`).join(", ") : "(none — this workflow takes a free-form prompt)"}.`,
140
+ );
141
+ }
142
+ }
143
+
144
+ return errors;
145
+ }
146
+
147
+ /**
148
+ * Merge CLI-provided values with schema defaults so the executor sees a
149
+ * fully-resolved inputs record. Defaults for enum fields fall back to the
150
+ * first declared value when no explicit default is set. Unknown keys are
151
+ * dropped — validation has already flagged them.
152
+ */
153
+ export function resolveInputs(
154
+ provided: Record<string, string>,
155
+ schema: readonly WorkflowInput[],
156
+ ): Record<string, string> {
157
+ const out: Record<string, string> = {};
158
+ for (const field of schema) {
159
+ const raw = provided[field.name];
160
+ if (raw !== undefined && raw !== "") {
161
+ out[field.name] = raw;
162
+ } else if (field.default !== undefined) {
163
+ out[field.name] = field.default;
164
+ } else if (field.type === "enum" && field.values && field.values.length > 0) {
165
+ out[field.name] = field.values[0]!;
166
+ }
167
+ }
168
+ return out;
169
+ }
170
+
171
+ // ─── Entry point ────────────────────────────────────────────────────────────
22
172
 
23
173
  export async function workflowCommand(options: {
24
174
  name?: string;
25
175
  agent?: string;
26
- prompt?: string;
27
176
  list?: boolean;
177
+ /**
178
+ * Everything commander parked in `cmd.args` — a mix of positional
179
+ * prompt tokens and unknown `--<name>` flags that the
180
+ * {@link parsePassthroughArgs} helper splits apart.
181
+ */
182
+ passthroughArgs?: string[];
183
+ /**
184
+ * Project root used for workflow discovery. Defaults to
185
+ * `process.cwd()` in production; tests inject a temp dir so they
186
+ * can control which workflows are visible without touching the
187
+ * real filesystem.
188
+ */
189
+ cwd?: string;
28
190
  }): Promise<number> {
29
- // List mode
191
+ const passthroughArgs = options.passthroughArgs ?? [];
192
+ const cwd = options.cwd;
193
+
194
+ // ── List mode ──
195
+ // `merge: false` keeps local and global entries independent so the
196
+ // list can show both copies of a non-reserved name when they coexist
197
+ // on disk. Reserved builtin names are already filtered out of both
198
+ // merge modes inside `discoverWorkflows`, so shadowed local/global
199
+ // workflows never reach the renderer.
30
200
  if (options.list) {
31
- const workflows = await discoverWorkflows(undefined, options.agent as AgentType | undefined);
201
+ const workflows = await discoverWorkflows(
202
+ cwd,
203
+ options.agent as AgentType | undefined,
204
+ { merge: false },
205
+ );
32
206
  process.stdout.write(renderWorkflowList(workflows));
33
207
  return 0;
34
208
  }
35
209
 
36
- // Run mode validate inputs
37
- if (!options.name) {
38
- console.error(`${COLORS.red}Error: Missing workflow name. Use -n <name>.${COLORS.reset}`);
39
- return 1;
40
- }
41
-
210
+ // ── Agent validation (required for every non-list branch) ──
42
211
  if (!options.agent) {
43
- console.error(`${COLORS.red}Error: Missing agent. Use -a <agent>.${COLORS.reset}`);
212
+ console.error(
213
+ `${COLORS.red}Error: Missing agent. Use -a <agent>.${COLORS.reset}`,
214
+ );
44
215
  return 1;
45
216
  }
46
217
 
47
218
  const validAgents = Object.keys(AGENT_CONFIG);
48
219
  if (!validAgents.includes(options.agent)) {
49
- console.error(`${COLORS.red}Error: Unknown agent '${options.agent}'.${COLORS.reset}`);
220
+ console.error(
221
+ `${COLORS.red}Error: Unknown agent '${options.agent}'.${COLORS.reset}`,
222
+ );
50
223
  console.error(`Valid agents: ${validAgents.join(", ")}`);
51
224
  return 1;
52
225
  }
53
-
54
226
  const agent = options.agent as AgentKey;
55
227
 
56
- // Check agent CLI is installed
228
+ // ── Preflight checks (shared between picker and named modes) ──
229
+ const preflightCode = await runPrereqChecks(agent);
230
+ if (preflightCode !== 0) return preflightCode;
231
+
232
+ // ── Picker mode: -a <agent>, no -n ──
233
+ if (!options.name) {
234
+ return runPickerMode(agent, passthroughArgs, cwd);
235
+ }
236
+
237
+ // ── Named mode: -n <name> -a <agent> [args...] ──
238
+ return runNamedMode(options.name, agent, passthroughArgs, cwd);
239
+ }
240
+
241
+ // ─── Shared helpers ─────────────────────────────────────────────────────────
242
+
243
+ /**
244
+ * Verify that the agent CLI, tmux (or psmux on Windows), and bun are all
245
+ * installed. Attempts best-effort installs for the latter two and
246
+ * returns a non-zero exit code if any check still fails afterwards.
247
+ */
248
+ async function runPrereqChecks(agent: AgentKey): Promise<number> {
57
249
  if (!isCommandInstalled(AGENT_CONFIG[agent].cmd)) {
58
- console.error(`${COLORS.red}Error: '${AGENT_CONFIG[agent].cmd}' is not installed.${COLORS.reset}`);
250
+ console.error(
251
+ `${COLORS.red}Error: '${AGENT_CONFIG[agent].cmd}' is not installed.${COLORS.reset}`,
252
+ );
59
253
  console.error(`Install it from: ${AGENT_CONFIG[agent].install_url}`);
60
254
  return 1;
61
255
  }
62
256
 
63
- // Ensure tmux/psmux is installed
64
257
  if (!isTmuxInstalled()) {
65
258
  console.log("Terminal multiplexer not found. Installing...");
66
259
  try {
67
260
  await ensureTmuxInstalled();
68
261
  resetMuxBinaryCache();
69
262
  } catch {
70
- // Installation attempt failed fall through to check below
263
+ // Fall through to the check below best effort.
71
264
  }
72
265
  if (!isTmuxInstalled()) {
73
266
  const isWin = process.platform === "win32";
74
- console.error(`${COLORS.red}Error: ${isWin ? "psmux" : "tmux"} is not installed.${COLORS.reset}`);
267
+ console.error(
268
+ `${COLORS.red}Error: ${isWin ? "psmux" : "tmux"} is not installed.${COLORS.reset}`,
269
+ );
75
270
  console.error(
76
271
  isWin
77
272
  ? "Install psmux: https://github.com/psmux/psmux#installation"
@@ -81,43 +276,185 @@ export async function workflowCommand(options: {
81
276
  }
82
277
  }
83
278
 
84
- // Ensure bun is installed (required for workflow execution)
85
279
  if (!Bun.which("bun")) {
86
280
  console.log("Bun runtime not found. Installing...");
87
281
  try {
88
282
  await ensureBunInstalled();
89
283
  } catch {
90
- // Installation attempt failed — fall through to check below
284
+ // Best effort — fall through to the check below.
91
285
  }
92
286
  if (!Bun.which("bun")) {
93
- console.error(`${COLORS.red}Error: bun is not installed.${COLORS.reset}`);
287
+ console.error(
288
+ `${COLORS.red}Error: bun is not installed.${COLORS.reset}`,
289
+ );
94
290
  console.error("Install bun: https://bun.sh");
95
291
  return 1;
96
292
  }
97
293
  }
98
294
 
295
+ return 0;
296
+ }
297
+
298
+ /**
299
+ * Run the given workflow definition through the executor, catching any
300
+ * execution errors so the CLI can exit with a non-zero code instead of
301
+ * letting an unhandled promise rejection bubble to `main()`.
302
+ *
303
+ * Free-form workflows ride the same `inputs` pipe — their positional
304
+ * prompt is stored under `inputs.prompt`, so workflow authors read it
305
+ * via `ctx.inputs.prompt ?? ""` whether or not the workflow declares
306
+ * a schema.
307
+ */
308
+ async function runLoadedWorkflow(args: {
309
+ definition: Parameters<typeof executeWorkflow>[0]["definition"];
310
+ agent: AgentKey;
311
+ inputs: Record<string, string>;
312
+ workflowFile: string;
313
+ }): Promise<number> {
314
+ try {
315
+ await executeWorkflow({
316
+ definition: args.definition,
317
+ agent: args.agent,
318
+ inputs: args.inputs,
319
+ workflowFile: args.workflowFile,
320
+ });
321
+ return 0;
322
+ } catch (error) {
323
+ const message = error instanceof Error ? error.message : String(error);
324
+ console.error(`${COLORS.red}Workflow failed: ${message}${COLORS.reset}`);
325
+ return 1;
326
+ }
327
+ }
328
+
329
+ // ─── Picker mode ────────────────────────────────────────────────────────────
330
+
331
+ /**
332
+ * Show the interactive picker, then hand off to the executor if the
333
+ * user confirms a selection. Passthrough args are rejected here — the
334
+ * picker already surfaces the same UI for typing values, so letting CLI
335
+ * flags leak through would create two conflicting sources of truth.
336
+ */
337
+ async function runPickerMode(
338
+ agent: AgentKey,
339
+ passthroughArgs: string[],
340
+ cwd: string | undefined,
341
+ ): Promise<number> {
342
+ if (passthroughArgs.length > 0) {
343
+ console.error(
344
+ `${COLORS.red}Error: unexpected arguments for the interactive picker: ${passthroughArgs.join(" ")}${COLORS.reset}`,
345
+ );
346
+ console.error(
347
+ `Pass workflow-specific flags only alongside -n <name>, or remove them to launch the picker.`,
348
+ );
349
+ return 1;
350
+ }
351
+
352
+ const discovered = await discoverWorkflows(cwd, agent);
353
+ if (discovered.length === 0) {
354
+ console.error(
355
+ `${COLORS.red}No workflows found for agent '${agent}'.${COLORS.reset}`,
356
+ );
357
+ console.error(
358
+ `Create one at: .atomic/workflows/<name>/${agent}/index.ts`,
359
+ );
360
+ return 1;
361
+ }
362
+
363
+ const metadata = await loadWorkflowsMetadata(discovered);
364
+ if (metadata.length === 0) {
365
+ console.error(
366
+ `${COLORS.red}All discovered workflows failed to load. Check the files under .atomic/workflows/ and ~/.atomic/workflows/.${COLORS.reset}`,
367
+ );
368
+ return 1;
369
+ }
370
+
371
+ // Stable sort so the picker list order is deterministic.
372
+ metadata.sort((a, b) => a.name.localeCompare(b.name));
373
+
374
+ const panel = await WorkflowPickerPanel.create({ agent, workflows: metadata });
375
+ let result;
376
+ try {
377
+ result = await panel.waitForSelection();
378
+ } finally {
379
+ panel.destroy();
380
+ }
381
+
382
+ if (!result) {
383
+ return 0;
384
+ }
385
+
386
+ return runResolvedSelection(result.workflow, agent, result.inputs);
387
+ }
388
+
389
+ /**
390
+ * Execute a workflow selected via the picker. The picker already stores
391
+ * free-form prompts under the `prompt` key (via `DEFAULT_PROMPT_INPUT`),
392
+ * so we can hand the inputs record straight through — no split between
393
+ * "prompt" and "structured inputs" is needed.
394
+ */
395
+ async function runResolvedSelection(
396
+ workflow: WorkflowWithMetadata,
397
+ agent: AgentKey,
398
+ inputs: Record<string, string>,
399
+ ): Promise<number> {
400
+ const loaded = await WorkflowLoader.loadWorkflow(workflow, {
401
+ warn(warnings) {
402
+ for (const w of warnings) {
403
+ console.warn(`⚠ [${w.rule}] ${w.message}`);
404
+ }
405
+ },
406
+ error(stage, _error, message) {
407
+ console.error(`${COLORS.red}Error (${stage}): ${message}${COLORS.reset}`);
408
+ },
409
+ });
410
+ if (!loaded.ok) return 1;
411
+
412
+ return runLoadedWorkflow({
413
+ definition: loaded.value.definition,
414
+ agent,
415
+ inputs,
416
+ workflowFile: workflow.path,
417
+ });
418
+ }
419
+
420
+ // ─── Named mode ─────────────────────────────────────────────────────────────
421
+
422
+ async function runNamedMode(
423
+ name: string,
424
+ agent: AgentKey,
425
+ passthroughArgs: string[],
426
+ cwd: string | undefined,
427
+ ): Promise<number> {
99
428
  // Find the workflow
100
- const discovered = await findWorkflow(options.name, agent);
429
+ const discovered = await findWorkflow(name, agent, cwd);
101
430
 
102
431
  if (!discovered) {
103
- console.error(`${COLORS.red}Error: Workflow '${options.name}' not found for agent '${agent}'.${COLORS.reset}`);
432
+ console.error(
433
+ `${COLORS.red}Error: Workflow '${name}' not found for agent '${agent}'.${COLORS.reset}`,
434
+ );
104
435
  console.error(`\nExpected location:`);
105
- console.error(` .atomic/workflows/${options.name}/${agent}/index.ts ${COLORS.dim}(local)${COLORS.reset}`);
106
- console.error(` ~/.atomic/workflows/${options.name}/${agent}/index.ts ${COLORS.dim}(global)${COLORS.reset}`);
436
+ console.error(
437
+ ` .atomic/workflows/${name}/${agent}/index.ts ${COLORS.dim}(local)${COLORS.reset}`,
438
+ );
439
+ console.error(
440
+ ` ~/.atomic/workflows/${name}/${agent}/index.ts ${COLORS.dim}(global)${COLORS.reset}`,
441
+ );
107
442
 
108
- const available = await discoverWorkflows(undefined, agent);
443
+ const available = await discoverWorkflows(cwd, agent);
109
444
  if (available.length > 0) {
110
445
  console.error(`\nAvailable ${agent} workflows:`);
111
446
  for (const wf of available) {
112
- console.error(` ${COLORS.dim}•${COLORS.reset} ${wf.name} ${COLORS.dim}(${wf.source})${COLORS.reset}`);
447
+ console.error(
448
+ ` ${COLORS.dim}•${COLORS.reset} ${wf.name} ${COLORS.dim}(${wf.source})${COLORS.reset}`,
449
+ );
113
450
  }
114
451
  }
115
452
 
116
453
  return 1;
117
454
  }
118
455
 
119
- // Load workflow through the pipeline: resolve validate load.
120
- // External workflows must have `@bastani/atomic` installed as a dependency.
456
+ // Load workflow so we can read the declared input schema before
457
+ // trusting any passthrough values.
121
458
  const result = await WorkflowLoader.loadWorkflow(discovered, {
122
459
  warn(warnings) {
123
460
  for (const w of warnings) {
@@ -129,51 +466,81 @@ export async function workflowCommand(options: {
129
466
  },
130
467
  });
131
468
 
132
- if (!result.ok) {
469
+ if (!result.ok) return 1;
470
+ const definition = result.value.definition;
471
+
472
+ // Parse passthrough args into typed flags + positional tokens. The
473
+ // parser intentionally rejects only obviously-broken flags (e.g.
474
+ // `--foo` with nothing after it) — unknown flag names are surfaced
475
+ // later, in validateInputsAgainstSchema, so we can show the valid
476
+ // flag list alongside the error.
477
+ const { flags, positional, errors: parseErrors } =
478
+ parsePassthroughArgs(passthroughArgs);
479
+ if (parseErrors.length > 0) {
480
+ for (const e of parseErrors) {
481
+ console.error(`${COLORS.red}Error: ${e}${COLORS.reset}`);
482
+ }
133
483
  return 1;
134
484
  }
135
485
 
136
- // Execute
137
- try {
138
- await executeWorkflow({
139
- definition: result.value.definition,
486
+ const isStructured = definition.inputs.length > 0;
487
+
488
+ if (isStructured) {
489
+ // Positional args are ambiguous for structured workflows — users
490
+ // must go through `--<name>` flags so the executor has a typed
491
+ // record to validate against.
492
+ if (positional.length > 0) {
493
+ console.error(
494
+ `${COLORS.red}Error: workflow '${definition.name}' takes structured inputs — ` +
495
+ `pass them as --<name>=<value> flags instead of a positional prompt.${COLORS.reset}`,
496
+ );
497
+ console.error(
498
+ `Expected flags: ${definition.inputs.map((i) => `--${i.name}`).join(", ")}`,
499
+ );
500
+ return 1;
501
+ }
502
+ const validationErrors = validateInputsAgainstSchema(flags, definition.inputs);
503
+ if (validationErrors.length > 0) {
504
+ for (const e of validationErrors) {
505
+ console.error(`${COLORS.red}Error: ${e}${COLORS.reset}`);
506
+ }
507
+ return 1;
508
+ }
509
+ const resolvedInputs = resolveInputs(flags, definition.inputs);
510
+ return runLoadedWorkflow({
511
+ definition,
140
512
  agent,
141
- prompt: options.prompt ?? "",
513
+ inputs: resolvedInputs,
142
514
  workflowFile: discovered.path,
143
515
  });
144
- return 0;
145
- } catch (error) {
146
- const message = error instanceof Error ? error.message : String(error);
147
- console.error(`${COLORS.red}Workflow failed: ${message}${COLORS.reset}`);
148
- return 1;
149
516
  }
150
- }
151
517
 
152
- // ---------------------------------------------------------------------------
153
- // Workflow list rendering
154
- //
155
- // Catppuccin Mocha palette rendered via 24-bit ANSI, with graceful fallback
156
- // to basic ANSI on legacy terminals and plain text when colour is disabled.
157
- // Hex values mirror src/sdk/runtime/theme.ts so the CLI output and the TUI
158
- // speak one visual language.
159
- // ---------------------------------------------------------------------------
160
-
161
- type PaletteKey = "text" | "dim" | "accent" | "success" | "mauve";
162
-
163
- const PALETTE: Record<PaletteKey, readonly [number, number, number]> = {
164
- text: [205, 214, 244], // #cdd6f4 — primary text
165
- dim: [127, 132, 156], // #7f849c — secondary text
166
- accent: [137, 180, 250], // #89b4fa — blue accent
167
- success: [166, 227, 161], // #a6e3a1 — green (local source)
168
- mauve: [203, 166, 247], // #cba6f7 — mauve (global source)
169
- };
518
+ // Free-form workflows: reject stray --<flag> flags outright, since
519
+ // they have no schema to validate against.
520
+ if (Object.keys(flags).length > 0) {
521
+ console.error(
522
+ `${COLORS.red}Error: workflow '${definition.name}' has no declared inputs unknown flags: ${Object.keys(flags).map((n) => `--${n}`).join(", ")}.${COLORS.reset}`,
523
+ );
524
+ console.error(
525
+ `Pass your request as a positional prompt: atomic workflow -n ${definition.name} -a ${agent} "your prompt"`,
526
+ );
527
+ return 1;
528
+ }
170
529
 
171
- interface PaintOptions {
172
- bold?: boolean;
530
+ // Free-form workflows store their single prompt under the `prompt`
531
+ // key so workflow authors can read `ctx.inputs.prompt` uniformly.
532
+ // An empty positional list stays as an empty inputs record and
533
+ // `ctx.inputs.prompt` stays undefined.
534
+ const prompt = positional.join(" ");
535
+ const inputs: Record<string, string> = prompt === "" ? {} : { prompt };
536
+ return runLoadedWorkflow({
537
+ definition,
538
+ agent,
539
+ inputs,
540
+ workflowFile: discovered.path,
541
+ });
173
542
  }
174
543
 
175
- type Paint = (key: PaletteKey, text: string, opts?: PaintOptions) => string;
176
-
177
544
  /** Stable agent sort order; keeps output deterministic across runs. */
178
545
  const AGENT_ORDER: readonly AgentType[] = ["claude", "opencode", "copilot"];
179
546
  /** Display names shown as provider sub-headings; honours proper branding. */
@@ -190,46 +557,16 @@ const SOURCE_DIRS: Record<DiscoveredWorkflow["source"], string> = {
190
557
  global: "~/.atomic/workflows",
191
558
  builtin: "built-in",
192
559
  };
193
- /** Section heading colour per source — preserves the source-type semantic. */
560
+ /** Section heading colour per source — three distinct hues so each
561
+ * source reads at a glance. `accent` (blue) is deliberately reserved
562
+ * for the agent-provider sub-headings nested inside each section, so
563
+ * builtin uses the new `info` (sky) key to avoid a clash. */
194
564
  const SOURCE_COLORS: Record<DiscoveredWorkflow["source"], PaletteKey> = {
195
- local: "success",
196
- global: "mauve",
197
- builtin: "accent",
565
+ local: "success", // green — project-scoped, "yours"
566
+ global: "mauve", // purple — user-scoped, personal
567
+ builtin: "info", // sky — ships with atomic, foundational
198
568
  };
199
569
 
200
- /**
201
- * Build a colour-aware painter for the current terminal.
202
- * Truecolor terminals get the full Catppuccin palette; legacy terminals
203
- * degrade to basic ANSI; NO_COLOR emits plain text. The optional `bold`
204
- * flag adds weight contrast — essential for typographic hierarchy in a
205
- * monospace medium where size and family are fixed.
206
- */
207
- function createPainter(): Paint {
208
- if (supportsTrueColor()) {
209
- return (key, text, opts) => {
210
- const [r, g, b] = PALETTE[key];
211
- const sgr = opts?.bold
212
- ? `\x1b[1;38;2;${r};${g};${b}m`
213
- : `\x1b[38;2;${r};${g};${b}m`;
214
- return `${sgr}${text}\x1b[0m`;
215
- };
216
- }
217
- if (supportsColor()) {
218
- const ANSI: Record<PaletteKey, string> = {
219
- text: "",
220
- dim: "\x1b[2m",
221
- accent: "\x1b[34m",
222
- success: "\x1b[32m",
223
- mauve: "\x1b[35m",
224
- };
225
- return (key, text, opts) => {
226
- const weight = opts?.bold ? "\x1b[1m" : "";
227
- return `${weight}${ANSI[key]}${text}\x1b[0m`;
228
- };
229
- }
230
- return (_key, text) => text;
231
- }
232
-
233
570
  /**
234
571
  * Render `atomic workflow --list` output as a printable string.
235
572
  *
@@ -253,8 +590,11 @@ function createPainter(): Paint {
253
590
  * <name>
254
591
  *
255
592
  * run: atomic workflow -n <name> -a <agent>
593
+ *
594
+ * Exported for testing — the pure-function shape makes coverage for the
595
+ * renderer trivial without spinning up a full CLI invocation.
256
596
  */
257
- function renderWorkflowList(workflows: DiscoveredWorkflow[]): string {
597
+ export function renderWorkflowList(workflows: DiscoveredWorkflow[]): string {
258
598
  const paint = createPainter();
259
599
  const lines: string[] = [];
260
600