@bastani/atomic 0.5.20-0 → 0.5.21-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.
@@ -0,0 +1,321 @@
1
+ /**
2
+ * Unit tests for the workflow-inputs CLI command.
3
+ *
4
+ * Focused on the pure helpers (`buildInputsPayload` + `renderInputsText`)
5
+ * since they carry the schema-shaping logic. The thin command wrapper
6
+ * is exercised end-to-end by the existing workflow-command harness.
7
+ */
8
+
9
+ import { describe, test, expect, beforeAll, afterAll, mock } from "bun:test";
10
+ import {
11
+ buildInputsPayload,
12
+ renderInputsText,
13
+ workflowInputsCommand,
14
+ type WorkflowInputsDeps,
15
+ } from "./workflow-inputs.ts";
16
+ import type {
17
+ WorkflowInput,
18
+ DiscoveredWorkflow,
19
+ WorkflowDefinition,
20
+ } from "../../sdk/workflows/index.ts";
21
+
22
+ let originalNoColor: string | undefined;
23
+ beforeAll(() => {
24
+ originalNoColor = process.env.NO_COLOR;
25
+ process.env.NO_COLOR = "1";
26
+ });
27
+ afterAll(() => {
28
+ if (originalNoColor === undefined) delete process.env.NO_COLOR;
29
+ else process.env.NO_COLOR = originalNoColor;
30
+ });
31
+
32
+ describe("buildInputsPayload", () => {
33
+ test("synthesises a 'prompt' field for free-form workflows", () => {
34
+ const out = buildInputsPayload("ralph", "claude", "loop", []);
35
+ expect(out.freeform).toBe(true);
36
+ expect(out.inputs).toHaveLength(1);
37
+ expect(out.inputs[0]!.name).toBe("prompt");
38
+ expect(out.inputs[0]!.type).toBe("text");
39
+ });
40
+
41
+ test("clones structured inputs without mutating callers' arrays", () => {
42
+ const schema: WorkflowInput[] = [
43
+ { name: "research_doc", type: "string", required: true },
44
+ {
45
+ name: "focus",
46
+ type: "enum",
47
+ values: ["minimal", "standard"],
48
+ default: "standard",
49
+ },
50
+ ];
51
+ const out = buildInputsPayload("gen-spec", "claude", "spec", schema);
52
+ expect(out.freeform).toBe(false);
53
+ expect(out.inputs).toHaveLength(2);
54
+ expect(out.inputs[0]!.name).toBe("research_doc");
55
+ expect(out.inputs[1]!.values).toEqual(["minimal", "standard"]);
56
+ // mutating the output must not leak into the input
57
+ out.inputs[0]!.required = false;
58
+ expect(schema[0]!.required).toBe(true);
59
+ });
60
+
61
+ test("propagates description and agent into the payload", () => {
62
+ const out = buildInputsPayload("foo", "copilot", "describe me", []);
63
+ expect(out.workflow).toBe("foo");
64
+ expect(out.agent).toBe("copilot");
65
+ expect(out.description).toBe("describe me");
66
+ });
67
+ });
68
+
69
+ describe("renderInputsText", () => {
70
+ test("free-form workflows show the positional-prompt run hint", () => {
71
+ const payload = buildInputsPayload("ralph", "claude", "loop", []);
72
+ const out = renderInputsText(payload);
73
+ expect(out).toContain("ralph");
74
+ expect(out).toContain("claude");
75
+ expect(out).toContain("free-form");
76
+ expect(out).toContain('atomic workflow -n ralph -a claude "<prompt>"');
77
+ });
78
+
79
+ test("renders placeholder hint when a field declares one", () => {
80
+ const schema: WorkflowInput[] = [
81
+ {
82
+ name: "note",
83
+ type: "text",
84
+ placeholder: "short summary goes here",
85
+ },
86
+ ];
87
+ const payload = buildInputsPayload("foo", "claude", "", schema);
88
+ const out = renderInputsText(payload);
89
+ expect(out).toContain("placeholder:");
90
+ expect(out).toContain("short summary goes here");
91
+ });
92
+
93
+ test("structured workflows render flag names, types, required, defaults, and enum values", () => {
94
+ const schema: WorkflowInput[] = [
95
+ {
96
+ name: "research_doc",
97
+ type: "string",
98
+ required: true,
99
+ description: "path to research notes",
100
+ },
101
+ {
102
+ name: "focus",
103
+ type: "enum",
104
+ values: ["minimal", "standard", "exhaustive"],
105
+ default: "standard",
106
+ },
107
+ ];
108
+ const payload = buildInputsPayload("gen-spec", "claude", "spec", schema);
109
+ const out = renderInputsText(payload);
110
+
111
+ expect(out).toContain("--research_doc");
112
+ expect(out).toContain("(required)");
113
+ expect(out).toContain("[string]");
114
+ expect(out).toContain("path to research notes");
115
+
116
+ expect(out).toContain("--focus");
117
+ expect(out).toContain("[enum]");
118
+ expect(out).toContain("minimal, standard, exhaustive");
119
+ expect(out).toContain("default: standard");
120
+
121
+ // run hint references both flags
122
+ expect(out).toContain("--research_doc=<string>");
123
+ expect(out).toContain("--focus=<enum>");
124
+ });
125
+ });
126
+
127
+ // ─── workflowInputsCommand ─────────────────────────────────────────
128
+
129
+ function captureOutput(): {
130
+ stdout: () => string;
131
+ stderr: () => string;
132
+ restore: () => void;
133
+ } {
134
+ const outChunks: string[] = [];
135
+ const errChunks: string[] = [];
136
+ const origOut = process.stdout.write;
137
+ const origErr = process.stderr.write;
138
+ process.stdout.write = ((c: string | Uint8Array) => {
139
+ outChunks.push(typeof c === "string" ? c : new TextDecoder().decode(c));
140
+ return true;
141
+ }) as typeof process.stdout.write;
142
+ process.stderr.write = ((c: string | Uint8Array) => {
143
+ errChunks.push(typeof c === "string" ? c : new TextDecoder().decode(c));
144
+ return true;
145
+ }) as typeof process.stderr.write;
146
+ return {
147
+ stdout: () => outChunks.join(""),
148
+ stderr: () => errChunks.join(""),
149
+ restore: () => {
150
+ process.stdout.write = origOut;
151
+ process.stderr.write = origErr;
152
+ },
153
+ };
154
+ }
155
+
156
+ function fakeDiscovered(name: string): DiscoveredWorkflow {
157
+ return {
158
+ name,
159
+ agent: "claude",
160
+ path: `/fake/path/${name}.ts`,
161
+ source: "builtin",
162
+ };
163
+ }
164
+
165
+ function fakeDefinition(
166
+ name: string,
167
+ description: string,
168
+ inputs: WorkflowInput[],
169
+ ): WorkflowDefinition {
170
+ return {
171
+ __brand: "WorkflowDefinition",
172
+ name,
173
+ description,
174
+ inputs,
175
+ run: async () => {},
176
+ } as WorkflowDefinition;
177
+ }
178
+
179
+ function makeDeps(overrides: Partial<WorkflowInputsDeps> = {}): WorkflowInputsDeps {
180
+ return {
181
+ findWorkflow: mock(async () => fakeDiscovered("gen-spec")) as unknown as
182
+ WorkflowInputsDeps["findWorkflow"],
183
+ loadWorkflow: mock(async (plan) => ({
184
+ ok: true,
185
+ value: {
186
+ ...plan,
187
+ warnings: [],
188
+ definition: fakeDefinition("gen-spec", "spec generator", [
189
+ { name: "research_doc", type: "string", required: true },
190
+ ]),
191
+ },
192
+ })) as unknown as WorkflowInputsDeps["loadWorkflow"],
193
+ ...overrides,
194
+ };
195
+ }
196
+
197
+ describe("workflowInputsCommand", () => {
198
+ test("returns 1 with a JSON error envelope on unknown agent", async () => {
199
+ const cap = captureOutput();
200
+ try {
201
+ const code = await workflowInputsCommand(
202
+ { name: "gen-spec", agent: "bogus", format: "json" },
203
+ makeDeps(),
204
+ );
205
+ expect(code).toBe(1);
206
+ const parsed = JSON.parse(cap.stdout());
207
+ expect(parsed.error).toContain("Unknown agent");
208
+ } finally {
209
+ cap.restore();
210
+ }
211
+ });
212
+
213
+ test("returns 1 with a JSON error envelope when the workflow is missing", async () => {
214
+ const deps = makeDeps({
215
+ findWorkflow: mock(async () => null) as unknown as
216
+ WorkflowInputsDeps["findWorkflow"],
217
+ });
218
+ const cap = captureOutput();
219
+ try {
220
+ const code = await workflowInputsCommand(
221
+ { name: "missing", agent: "claude", format: "json" },
222
+ deps,
223
+ );
224
+ expect(code).toBe(1);
225
+ const parsed = JSON.parse(cap.stdout());
226
+ expect(parsed.error).toContain("not found");
227
+ } finally {
228
+ cap.restore();
229
+ }
230
+ });
231
+
232
+ test("returns 1 when the loader fails to load the workflow", async () => {
233
+ const deps = makeDeps({
234
+ loadWorkflow: mock(async () => ({
235
+ ok: false,
236
+ stage: "load" as const,
237
+ error: new Error("boom"),
238
+ message: "boom",
239
+ })) as unknown as WorkflowInputsDeps["loadWorkflow"],
240
+ });
241
+ const cap = captureOutput();
242
+ try {
243
+ const code = await workflowInputsCommand(
244
+ { name: "gen-spec", agent: "claude", format: "json" },
245
+ deps,
246
+ );
247
+ expect(code).toBe(1);
248
+ const parsed = JSON.parse(cap.stdout());
249
+ expect(parsed.error).toBe("boom");
250
+ } finally {
251
+ cap.restore();
252
+ }
253
+ });
254
+
255
+ test("prints the JSON payload on success", async () => {
256
+ const cap = captureOutput();
257
+ try {
258
+ const code = await workflowInputsCommand(
259
+ { name: "gen-spec", agent: "claude", format: "json" },
260
+ makeDeps(),
261
+ );
262
+ expect(code).toBe(0);
263
+ const parsed = JSON.parse(cap.stdout());
264
+ expect(parsed.workflow).toBe("gen-spec");
265
+ expect(parsed.agent).toBe("claude");
266
+ expect(parsed.inputs).toHaveLength(1);
267
+ expect(parsed.inputs[0].name).toBe("research_doc");
268
+ } finally {
269
+ cap.restore();
270
+ }
271
+ });
272
+
273
+ test("prints the text render on success when format is 'text'", async () => {
274
+ const cap = captureOutput();
275
+ try {
276
+ const code = await workflowInputsCommand(
277
+ { name: "gen-spec", agent: "claude", format: "text" },
278
+ makeDeps(),
279
+ );
280
+ expect(code).toBe(0);
281
+ const out = cap.stdout();
282
+ expect(out).toContain("gen-spec");
283
+ expect(out).toContain("--research_doc");
284
+ } finally {
285
+ cap.restore();
286
+ }
287
+ });
288
+
289
+ test("writes errors to stderr when format is 'text'", async () => {
290
+ const deps = makeDeps({
291
+ findWorkflow: mock(async () => null) as unknown as
292
+ WorkflowInputsDeps["findWorkflow"],
293
+ });
294
+ const cap = captureOutput();
295
+ try {
296
+ const code = await workflowInputsCommand(
297
+ { name: "missing", agent: "claude", format: "text" },
298
+ deps,
299
+ );
300
+ expect(code).toBe(1);
301
+ expect(cap.stderr()).toContain("not found");
302
+ } finally {
303
+ cap.restore();
304
+ }
305
+ });
306
+
307
+ test("defaults format to 'json' when omitted", async () => {
308
+ const cap = captureOutput();
309
+ try {
310
+ const code = await workflowInputsCommand(
311
+ { name: "gen-spec", agent: "claude" },
312
+ makeDeps(),
313
+ );
314
+ expect(code).toBe(0);
315
+ // JSON parses cleanly
316
+ JSON.parse(cap.stdout());
317
+ } finally {
318
+ cap.restore();
319
+ }
320
+ });
321
+ });
@@ -0,0 +1,219 @@
1
+ /**
2
+ * `atomic workflow inputs <name> -a <agent>` — print a workflow's
3
+ * declared input schema so an orchestrating agent can build a valid
4
+ * `atomic workflow -n <name> -a <agent> --<field>=<value>` invocation
5
+ * without having to read the workflow source.
6
+ *
7
+ * Output formats:
8
+ * --format json (default) — machine-parseable JSON
9
+ * --format text — human-friendly text table
10
+ *
11
+ * Free-form workflows (no declared inputs) report a single synthetic
12
+ * `prompt` field so callers can treat both shapes uniformly.
13
+ */
14
+
15
+ import { COLORS, createPainter } from "../../theme/colors.ts";
16
+ import { AGENT_CONFIG, type AgentKey } from "../../services/config/index.ts";
17
+ import {
18
+ findWorkflow as _findWorkflow,
19
+ WorkflowLoader,
20
+ } from "../../sdk/workflows/index.ts";
21
+ import type { WorkflowInput } from "../../sdk/workflows/index.ts";
22
+
23
+ export type WorkflowInputsFormat = "json" | "text";
24
+
25
+ export interface WorkflowInputsResult {
26
+ workflow: string;
27
+ agent: string;
28
+ description: string;
29
+ freeform: boolean;
30
+ inputs: WorkflowInput[];
31
+ }
32
+
33
+ /**
34
+ * Build the JSON payload returned to the agent. Free-form workflows
35
+ * synthesise a single optional `prompt` field so consumers don't have
36
+ * to special-case them — the same call shape works for both kinds.
37
+ */
38
+ export function buildInputsPayload(
39
+ workflowName: string,
40
+ agent: string,
41
+ description: string,
42
+ inputs: readonly WorkflowInput[],
43
+ ): WorkflowInputsResult {
44
+ const freeform = inputs.length === 0;
45
+ const declared: WorkflowInput[] = freeform
46
+ ? [
47
+ {
48
+ name: "prompt",
49
+ type: "text",
50
+ required: false,
51
+ description:
52
+ "Free-form prompt — pass as a positional arg to `atomic workflow -n <name> -a <agent> \"<prompt>\"`.",
53
+ },
54
+ ]
55
+ : inputs.map((i) => ({ ...i }));
56
+ return {
57
+ workflow: workflowName,
58
+ agent,
59
+ description,
60
+ freeform,
61
+ inputs: declared,
62
+ };
63
+ }
64
+
65
+ /** Render the payload as a human-friendly text block. */
66
+ export function renderInputsText(payload: WorkflowInputsResult): string {
67
+ const paint = createPainter();
68
+ const lines: string[] = [];
69
+ lines.push("");
70
+ lines.push(
71
+ " " +
72
+ paint("text", payload.workflow, { bold: true }) +
73
+ paint("dim", " (") +
74
+ paint("accent", payload.agent) +
75
+ paint("dim", ")"),
76
+ );
77
+ if (payload.description) {
78
+ lines.push(" " + paint("dim", payload.description));
79
+ }
80
+ lines.push("");
81
+ if (payload.freeform) {
82
+ lines.push(" " + paint("dim", "free-form workflow — single positional prompt"));
83
+ lines.push("");
84
+ lines.push(
85
+ " " +
86
+ paint("dim", "run: ") +
87
+ paint(
88
+ "accent",
89
+ `atomic workflow -n ${payload.workflow} -a ${payload.agent} "<prompt>"`,
90
+ ),
91
+ );
92
+ lines.push("");
93
+ return lines.join("\n") + "\n";
94
+ }
95
+
96
+ for (const field of payload.inputs) {
97
+ const requiredLabel = field.required ? paint("warning", " (required)") : "";
98
+ const typeLabel = paint("dim", ` [${field.type}]`);
99
+ lines.push(
100
+ " " +
101
+ paint("accent", `--${field.name}`) +
102
+ typeLabel +
103
+ requiredLabel,
104
+ );
105
+ if (field.description) {
106
+ lines.push(" " + paint("text", field.description));
107
+ }
108
+ if (field.type === "enum" && field.values && field.values.length > 0) {
109
+ lines.push(
110
+ " " +
111
+ paint("dim", "values: ") +
112
+ paint("text", field.values.join(", ")),
113
+ );
114
+ }
115
+ if (field.default !== undefined) {
116
+ lines.push(
117
+ " " + paint("dim", "default: ") + paint("text", field.default),
118
+ );
119
+ }
120
+ if (field.placeholder) {
121
+ lines.push(
122
+ " " +
123
+ paint("dim", "placeholder: ") +
124
+ paint("text", field.placeholder),
125
+ );
126
+ }
127
+ }
128
+
129
+ lines.push("");
130
+ const flagExample = payload.inputs
131
+ .map((i) => `--${i.name}=<${i.type}>`)
132
+ .join(" ");
133
+ lines.push(
134
+ " " +
135
+ paint("dim", "run: ") +
136
+ paint(
137
+ "accent",
138
+ `atomic workflow -n ${payload.workflow} -a ${payload.agent} ${flagExample}`,
139
+ ),
140
+ );
141
+ lines.push("");
142
+ return lines.join("\n") + "\n";
143
+ }
144
+
145
+ export interface WorkflowInputsOptions {
146
+ name: string;
147
+ agent: string;
148
+ format?: WorkflowInputsFormat;
149
+ cwd?: string;
150
+ }
151
+
152
+ /**
153
+ * Deps for `workflowInputsCommand`. Injected so tests can drive every
154
+ * branch (unknown agent / missing workflow / load failure / success)
155
+ * without the SDK's real filesystem-dependent discovery.
156
+ */
157
+ export interface WorkflowInputsDeps {
158
+ findWorkflow: typeof _findWorkflow;
159
+ loadWorkflow: typeof WorkflowLoader.loadWorkflow;
160
+ }
161
+
162
+ const defaultDeps: WorkflowInputsDeps = {
163
+ findWorkflow: _findWorkflow,
164
+ loadWorkflow: WorkflowLoader.loadWorkflow,
165
+ };
166
+
167
+ /**
168
+ * Resolve the workflow, then either print its input schema (success)
169
+ * or print an error and return a non-zero exit code. The json branch
170
+ * also writes errors as JSON so an agent can parse a single envelope
171
+ * regardless of outcome.
172
+ */
173
+ export async function workflowInputsCommand(
174
+ options: WorkflowInputsOptions,
175
+ deps: WorkflowInputsDeps = defaultDeps,
176
+ ): Promise<number> {
177
+ const format: WorkflowInputsFormat = options.format ?? "json";
178
+
179
+ const validAgents = Object.keys(AGENT_CONFIG);
180
+ if (!validAgents.includes(options.agent)) {
181
+ return reportError(
182
+ format,
183
+ `Unknown agent '${options.agent}'. Valid agents: ${validAgents.join(", ")}`,
184
+ );
185
+ }
186
+ const agent = options.agent as AgentKey;
187
+
188
+ const discovered = await deps.findWorkflow(options.name, agent, options.cwd);
189
+ if (!discovered) {
190
+ return reportError(
191
+ format,
192
+ `Workflow '${options.name}' not found for agent '${agent}'.`,
193
+ );
194
+ }
195
+
196
+ const loaded = await deps.loadWorkflow(discovered);
197
+ if (!loaded.ok) {
198
+ return reportError(format, loaded.message);
199
+ }
200
+ const def = loaded.value.definition;
201
+
202
+ const payload = buildInputsPayload(def.name, agent, def.description, def.inputs);
203
+
204
+ if (format === "json") {
205
+ process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
206
+ } else {
207
+ process.stdout.write(renderInputsText(payload));
208
+ }
209
+ return 0;
210
+ }
211
+
212
+ function reportError(format: WorkflowInputsFormat, message: string): number {
213
+ if (format === "json") {
214
+ process.stdout.write(JSON.stringify({ error: message }, null, 2) + "\n");
215
+ } else {
216
+ process.stderr.write(`${COLORS.red}Error: ${message}${COLORS.reset}\n`);
217
+ }
218
+ return 1;
219
+ }