@bastani/atomic 0.5.34-0 → 0.6.0-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.
- package/README.md +329 -50
- package/dist/commands/cli/session.d.ts +67 -0
- package/dist/commands/cli/session.d.ts.map +1 -0
- package/dist/commands/cli/workflow-status.d.ts +63 -0
- package/dist/commands/cli/workflow-status.d.ts.map +1 -0
- package/dist/sdk/commander.d.ts +74 -0
- package/dist/sdk/commander.d.ts.map +1 -0
- package/dist/sdk/components/workflow-picker-panel.d.ts +14 -17
- package/dist/sdk/components/workflow-picker-panel.d.ts.map +1 -1
- package/dist/sdk/define-workflow.d.ts +18 -9
- package/dist/sdk/define-workflow.d.ts.map +1 -1
- package/dist/sdk/index.d.ts +4 -3
- package/dist/sdk/index.d.ts.map +1 -1
- package/dist/sdk/management-commands.d.ts +42 -0
- package/dist/sdk/management-commands.d.ts.map +1 -0
- package/dist/sdk/registry.d.ts +27 -0
- package/dist/sdk/registry.d.ts.map +1 -0
- package/dist/sdk/runtime/attached-footer.d.ts +1 -1
- package/dist/sdk/runtime/executor-env.d.ts +20 -0
- package/dist/sdk/runtime/executor-env.d.ts.map +1 -0
- package/dist/sdk/runtime/executor.d.ts +61 -10
- package/dist/sdk/runtime/executor.d.ts.map +1 -1
- package/dist/sdk/types.d.ts +147 -4
- package/dist/sdk/types.d.ts.map +1 -1
- package/dist/sdk/worker-shared.d.ts +42 -0
- package/dist/sdk/worker-shared.d.ts.map +1 -0
- package/dist/sdk/workflow-cli.d.ts +103 -0
- package/dist/sdk/workflow-cli.d.ts.map +1 -0
- package/dist/sdk/workflows/builtin-registry.d.ts +113 -0
- package/dist/sdk/workflows/builtin-registry.d.ts.map +1 -0
- package/dist/sdk/workflows/index.d.ts +5 -5
- package/dist/sdk/workflows/index.d.ts.map +1 -1
- package/package.json +12 -8
- package/src/cli.ts +85 -144
- package/src/commands/cli/chat/index.ts +10 -0
- package/src/commands/cli/workflow-command.test.ts +279 -938
- package/src/commands/cli/workflow-inputs.test.ts +41 -11
- package/src/commands/cli/workflow-inputs.ts +47 -12
- package/src/commands/cli/workflow-list.test.ts +234 -0
- package/src/commands/cli/workflow-list.ts +0 -0
- package/src/commands/cli/workflow.ts +11 -798
- package/src/scripts/constants.ts +2 -1
- package/src/sdk/commander.ts +161 -0
- package/src/sdk/components/workflow-picker-panel.tsx +78 -258
- package/src/sdk/define-workflow.test.ts +104 -11
- package/src/sdk/define-workflow.ts +47 -11
- package/src/sdk/errors.test.ts +16 -0
- package/src/sdk/index.ts +8 -8
- package/src/sdk/management-commands.ts +151 -0
- package/src/sdk/registry.ts +132 -0
- package/src/sdk/runtime/attached-footer.ts +1 -1
- package/src/sdk/runtime/executor-env.ts +45 -0
- package/src/sdk/runtime/executor.test.ts +37 -0
- package/src/sdk/runtime/executor.ts +147 -68
- package/src/sdk/types.ts +169 -4
- package/src/sdk/worker-shared.test.ts +163 -0
- package/src/sdk/worker-shared.ts +155 -0
- package/src/sdk/workflow-cli.ts +409 -0
- package/src/sdk/workflows/builtin/deep-research-codebase/claude/index.ts +1 -1
- package/src/sdk/workflows/builtin/deep-research-codebase/copilot/index.ts +1 -1
- package/src/sdk/workflows/builtin/deep-research-codebase/opencode/index.ts +1 -1
- package/src/sdk/workflows/builtin/open-claude-design/claude/index.ts +1 -1
- package/src/sdk/workflows/builtin/open-claude-design/copilot/index.ts +1 -1
- package/src/sdk/workflows/builtin/open-claude-design/opencode/index.ts +1 -1
- package/src/sdk/workflows/builtin/ralph/claude/index.ts +1 -1
- package/src/sdk/workflows/builtin/ralph/copilot/index.ts +1 -1
- package/src/sdk/workflows/builtin/ralph/opencode/index.ts +1 -1
- package/src/sdk/workflows/builtin-registry.ts +23 -0
- package/src/sdk/workflows/index.ts +10 -20
- package/src/services/system/auth.test.ts +63 -1
- package/.agents/skills/workflow-creator/SKILL.md +0 -334
- package/.agents/skills/workflow-creator/references/agent-sessions.md +0 -888
- package/.agents/skills/workflow-creator/references/computation-and-validation.md +0 -201
- package/.agents/skills/workflow-creator/references/control-flow.md +0 -470
- package/.agents/skills/workflow-creator/references/discovery-and-verification.md +0 -232
- package/.agents/skills/workflow-creator/references/failure-modes.md +0 -903
- package/.agents/skills/workflow-creator/references/getting-started.md +0 -275
- package/.agents/skills/workflow-creator/references/running-workflows.md +0 -235
- package/.agents/skills/workflow-creator/references/session-config.md +0 -384
- package/.agents/skills/workflow-creator/references/state-and-data-flow.md +0 -357
- package/.agents/skills/workflow-creator/references/user-input.md +0 -234
- package/.agents/skills/workflow-creator/references/workflow-inputs.md +0 -272
- package/dist/sdk/runtime/discovery.d.ts +0 -132
- package/dist/sdk/runtime/discovery.d.ts.map +0 -1
- package/dist/sdk/runtime/executor-entry.d.ts +0 -11
- package/dist/sdk/runtime/executor-entry.d.ts.map +0 -1
- package/dist/sdk/runtime/loader.d.ts +0 -70
- package/dist/sdk/runtime/loader.d.ts.map +0 -1
- package/dist/version.d.ts +0 -2
- package/dist/version.d.ts.map +0 -1
- package/src/commands/cli/workflow.test.ts +0 -317
- package/src/sdk/runtime/discovery.ts +0 -368
- package/src/sdk/runtime/executor-entry.ts +0 -18
- package/src/sdk/runtime/loader.ts +0 -267
|
@@ -1,317 +0,0 @@
|
|
|
1
|
-
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
|
|
2
|
-
import {
|
|
3
|
-
parsePassthroughArgs,
|
|
4
|
-
validateInputsAgainstSchema,
|
|
5
|
-
resolveInputs,
|
|
6
|
-
renderWorkflowList,
|
|
7
|
-
} from "./workflow.ts";
|
|
8
|
-
import type { WorkflowInput } from "../../sdk/workflows/index.ts";
|
|
9
|
-
import type {
|
|
10
|
-
DiscoveredWorkflow,
|
|
11
|
-
WorkflowWithMetadata,
|
|
12
|
-
} from "../../sdk/workflows/index.ts";
|
|
13
|
-
|
|
14
|
-
// ─── Colour handling ────────────────────────────────────────────────────────
|
|
15
|
-
// The renderer emits ANSI sequences when the host terminal claims truecolor
|
|
16
|
-
// support. Force `NO_COLOR=1` for this file so renderWorkflowList assertions
|
|
17
|
-
// can match on plain-text output rather than brittle SGR escapes. Restore the
|
|
18
|
-
// prior value on teardown so other suites in the same run are unaffected.
|
|
19
|
-
let originalNoColor: string | undefined;
|
|
20
|
-
beforeAll(() => {
|
|
21
|
-
originalNoColor = process.env.NO_COLOR;
|
|
22
|
-
process.env.NO_COLOR = "1";
|
|
23
|
-
});
|
|
24
|
-
afterAll(() => {
|
|
25
|
-
if (originalNoColor === undefined) delete process.env.NO_COLOR;
|
|
26
|
-
else process.env.NO_COLOR = originalNoColor;
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
// ─── parsePassthroughArgs ──────────────────────────────────────────────────
|
|
30
|
-
|
|
31
|
-
describe("parsePassthroughArgs", () => {
|
|
32
|
-
test("parses --name=value flag pairs", () => {
|
|
33
|
-
const out = parsePassthroughArgs(["--foo=bar", "--baz=qux"]);
|
|
34
|
-
expect(out.flags).toEqual({ foo: "bar", baz: "qux" });
|
|
35
|
-
expect(out.positional).toEqual([]);
|
|
36
|
-
expect(out.errors).toEqual([]);
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
test("parses --name value flag pairs", () => {
|
|
40
|
-
const out = parsePassthroughArgs(["--foo", "bar", "--baz", "qux"]);
|
|
41
|
-
expect(out.flags).toEqual({ foo: "bar", baz: "qux" });
|
|
42
|
-
expect(out.errors).toEqual([]);
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
test("preserves values containing equals signs", () => {
|
|
46
|
-
const out = parsePassthroughArgs(["--query=key=value"]);
|
|
47
|
-
expect(out.flags).toEqual({ query: "key=value" });
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
test("collects positional tokens separately", () => {
|
|
51
|
-
const out = parsePassthroughArgs(["fix", "the", "bug"]);
|
|
52
|
-
expect(out.positional).toEqual(["fix", "the", "bug"]);
|
|
53
|
-
expect(out.flags).toEqual({});
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
test("returns an error when a flag has no value", () => {
|
|
57
|
-
const out = parsePassthroughArgs(["--foo"]);
|
|
58
|
-
expect(out.errors.length).toBe(1);
|
|
59
|
-
expect(out.errors[0]).toContain("--foo");
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
test("flags an empty flag name when `--=value` slips through", () => {
|
|
63
|
-
// `--=foo` tokenises to body="=foo", splits at the first `=`,
|
|
64
|
-
// and yields an empty name. The parser rejects this so users see
|
|
65
|
-
// "did you mean --name=foo?" rather than silently accepting a
|
|
66
|
-
// nameless entry in the flags record.
|
|
67
|
-
const out = parsePassthroughArgs(["--=foo"]);
|
|
68
|
-
expect(out.errors.length).toBe(1);
|
|
69
|
-
expect(out.errors[0]).toContain("Malformed flag");
|
|
70
|
-
expect(out.flags).toEqual({});
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
test("treats a trailing --flag --other as a missing value", () => {
|
|
74
|
-
// Second `--other` looks like a flag so the parser should not
|
|
75
|
-
// silently consume it as the first flag's value.
|
|
76
|
-
const out = parsePassthroughArgs(["--foo", "--other=x"]);
|
|
77
|
-
expect(out.errors.length).toBe(1);
|
|
78
|
-
expect(out.flags).toEqual({ other: "x" });
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
test("handles mixed positional and flag tokens", () => {
|
|
82
|
-
const out = parsePassthroughArgs([
|
|
83
|
-
"run",
|
|
84
|
-
"--mode=fast",
|
|
85
|
-
"now",
|
|
86
|
-
"--retries",
|
|
87
|
-
"3",
|
|
88
|
-
]);
|
|
89
|
-
expect(out.positional).toEqual(["run", "now"]);
|
|
90
|
-
expect(out.flags).toEqual({ mode: "fast", retries: "3" });
|
|
91
|
-
expect(out.errors).toEqual([]);
|
|
92
|
-
});
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
// ─── validateInputsAgainstSchema ───────────────────────────────────────────
|
|
96
|
-
|
|
97
|
-
const schema: WorkflowInput[] = [
|
|
98
|
-
{
|
|
99
|
-
name: "research_doc",
|
|
100
|
-
type: "string",
|
|
101
|
-
required: true,
|
|
102
|
-
description: "path",
|
|
103
|
-
},
|
|
104
|
-
{
|
|
105
|
-
name: "focus",
|
|
106
|
-
type: "enum",
|
|
107
|
-
required: true,
|
|
108
|
-
values: ["minimal", "standard", "exhaustive"],
|
|
109
|
-
default: "standard",
|
|
110
|
-
},
|
|
111
|
-
{
|
|
112
|
-
name: "notes",
|
|
113
|
-
type: "text",
|
|
114
|
-
},
|
|
115
|
-
];
|
|
116
|
-
|
|
117
|
-
describe("validateInputsAgainstSchema", () => {
|
|
118
|
-
test("passes when all required fields are present", () => {
|
|
119
|
-
const errors = validateInputsAgainstSchema(
|
|
120
|
-
{ research_doc: "notes.md", focus: "standard" },
|
|
121
|
-
schema,
|
|
122
|
-
);
|
|
123
|
-
expect(errors).toEqual([]);
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
test("accepts an omitted enum with a declared default", () => {
|
|
127
|
-
// `focus` has a default of "standard", so omission should be OK.
|
|
128
|
-
const errors = validateInputsAgainstSchema(
|
|
129
|
-
{ research_doc: "notes.md" },
|
|
130
|
-
schema,
|
|
131
|
-
);
|
|
132
|
-
expect(errors).toEqual([]);
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
test("flags a missing required string input", () => {
|
|
136
|
-
const errors = validateInputsAgainstSchema(
|
|
137
|
-
{ focus: "standard" },
|
|
138
|
-
schema,
|
|
139
|
-
);
|
|
140
|
-
expect(errors.length).toBe(1);
|
|
141
|
-
expect(errors[0]).toContain("--research_doc");
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
test("rejects required strings that are whitespace only", () => {
|
|
145
|
-
const errors = validateInputsAgainstSchema(
|
|
146
|
-
{ research_doc: " ", focus: "standard" },
|
|
147
|
-
schema,
|
|
148
|
-
);
|
|
149
|
-
expect(errors[0]).toContain("--research_doc");
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
test("rejects enum values that are not in the allowed list", () => {
|
|
153
|
-
const errors = validateInputsAgainstSchema(
|
|
154
|
-
{ research_doc: "notes.md", focus: "bogus" },
|
|
155
|
-
schema,
|
|
156
|
-
);
|
|
157
|
-
expect(errors.length).toBe(1);
|
|
158
|
-
expect(errors[0]).toContain("bogus");
|
|
159
|
-
expect(errors[0]).toContain("minimal");
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
test("flags unknown inputs", () => {
|
|
163
|
-
const errors = validateInputsAgainstSchema(
|
|
164
|
-
{
|
|
165
|
-
research_doc: "notes.md",
|
|
166
|
-
focus: "standard",
|
|
167
|
-
bogus: "hello",
|
|
168
|
-
},
|
|
169
|
-
schema,
|
|
170
|
-
);
|
|
171
|
-
expect(errors.length).toBe(1);
|
|
172
|
-
expect(errors[0]).toContain("--bogus");
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
test("flags a required enum that resolves to empty when no values are declared", () => {
|
|
176
|
-
// Pathological but reachable: a declared enum with an empty
|
|
177
|
-
// `values` array and no default falls through to value==="" in the
|
|
178
|
-
// resolver, which should trip the enum-specific required-input
|
|
179
|
-
// error branch rather than the generic string-required branch.
|
|
180
|
-
const brokenSchema: WorkflowInput[] = [
|
|
181
|
-
{ name: "mode", type: "enum", required: true, values: [] },
|
|
182
|
-
];
|
|
183
|
-
const errors = validateInputsAgainstSchema({}, brokenSchema);
|
|
184
|
-
expect(errors.length).toBe(1);
|
|
185
|
-
expect(errors[0]).toContain("--mode");
|
|
186
|
-
expect(errors[0]).toContain("expected one of:");
|
|
187
|
-
});
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
// ─── renderWorkflowList ────────────────────────────────────────────────────
|
|
191
|
-
|
|
192
|
-
function wf(
|
|
193
|
-
name: string,
|
|
194
|
-
agent: DiscoveredWorkflow["agent"],
|
|
195
|
-
source: DiscoveredWorkflow["source"],
|
|
196
|
-
status: WorkflowWithMetadata["status"] = { kind: "ok" },
|
|
197
|
-
): WorkflowWithMetadata {
|
|
198
|
-
return {
|
|
199
|
-
name,
|
|
200
|
-
agent,
|
|
201
|
-
source,
|
|
202
|
-
path: `/tmp/fake/${source}/${name}/${agent}/index.ts`,
|
|
203
|
-
description: "",
|
|
204
|
-
inputs: [],
|
|
205
|
-
status,
|
|
206
|
-
};
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
describe("renderWorkflowList", () => {
|
|
210
|
-
test("renders an empty-state stanza when no workflows are available", () => {
|
|
211
|
-
const out = renderWorkflowList([]);
|
|
212
|
-
expect(out).toContain("no workflows found");
|
|
213
|
-
// Teaches the user where to drop a new workflow.
|
|
214
|
-
expect(out).toContain(".atomic/workflows/<name>/<agent>/index.ts");
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
test("uses singular 'workflow' for a count of exactly one", () => {
|
|
218
|
-
const out = renderWorkflowList([wf("only", "claude", "local")]);
|
|
219
|
-
// Must say "1 workflow", never "1 workflows".
|
|
220
|
-
expect(out).toMatch(/\b1 workflow\b/);
|
|
221
|
-
expect(out).not.toMatch(/\b1 workflows\b/);
|
|
222
|
-
expect(out).toContain("only");
|
|
223
|
-
});
|
|
224
|
-
|
|
225
|
-
test("groups entries by source → provider and sorts names", () => {
|
|
226
|
-
const workflows: WorkflowWithMetadata[] = [
|
|
227
|
-
wf("zebra", "claude", "local"),
|
|
228
|
-
wf("apple", "claude", "local"),
|
|
229
|
-
wf("middle", "opencode", "local"),
|
|
230
|
-
wf("personal", "claude", "global"),
|
|
231
|
-
wf("shipped", "copilot", "builtin"),
|
|
232
|
-
];
|
|
233
|
-
const out = renderWorkflowList(workflows);
|
|
234
|
-
|
|
235
|
-
// Count uses plural noun.
|
|
236
|
-
expect(out).toMatch(/\b5 workflows\b/);
|
|
237
|
-
|
|
238
|
-
// Section headings appear with friendly directory hints.
|
|
239
|
-
expect(out).toContain("local");
|
|
240
|
-
expect(out).toContain(".atomic/workflows");
|
|
241
|
-
expect(out).toContain("global");
|
|
242
|
-
expect(out).toContain("~/.atomic/workflows");
|
|
243
|
-
expect(out).toContain("builtin");
|
|
244
|
-
expect(out).toContain("built-in");
|
|
245
|
-
|
|
246
|
-
// Provider sub-headings are present with branded names.
|
|
247
|
-
expect(out).toContain("Claude");
|
|
248
|
-
expect(out).toContain("OpenCode");
|
|
249
|
-
expect(out).toContain("Copilot CLI");
|
|
250
|
-
|
|
251
|
-
// Run-hint footer is appended.
|
|
252
|
-
expect(out).toContain("run: atomic workflow -n <name> -a <agent>");
|
|
253
|
-
|
|
254
|
-
// Local/Claude names are sorted alphabetically: apple before zebra.
|
|
255
|
-
const appleIdx = out.indexOf("apple");
|
|
256
|
-
const zebraIdx = out.indexOf("zebra");
|
|
257
|
-
expect(appleIdx).toBeGreaterThanOrEqual(0);
|
|
258
|
-
expect(zebraIdx).toBeGreaterThan(appleIdx);
|
|
259
|
-
|
|
260
|
-
// Source ordering: local before global before builtin.
|
|
261
|
-
const localIdx = out.indexOf("local");
|
|
262
|
-
const globalIdx = out.indexOf("global");
|
|
263
|
-
const builtinIdx = out.indexOf("builtin");
|
|
264
|
-
expect(localIdx).toBeLessThan(globalIdx);
|
|
265
|
-
expect(globalIdx).toBeLessThan(builtinIdx);
|
|
266
|
-
});
|
|
267
|
-
|
|
268
|
-
test("omits provider sub-groups that have no entries for a given source", () => {
|
|
269
|
-
// Only a copilot workflow exists, so the local stanza should render a
|
|
270
|
-
// single "Copilot CLI" sub-heading — never "Claude" or "OpenCode".
|
|
271
|
-
const out = renderWorkflowList([wf("alone", "copilot", "local")]);
|
|
272
|
-
expect(out).toContain("Copilot CLI");
|
|
273
|
-
expect(out).not.toContain("Claude");
|
|
274
|
-
expect(out).not.toContain("OpenCode");
|
|
275
|
-
expect(out).toContain("alone");
|
|
276
|
-
});
|
|
277
|
-
});
|
|
278
|
-
|
|
279
|
-
// ─── resolveInputs ─────────────────────────────────────────────────────────
|
|
280
|
-
|
|
281
|
-
describe("resolveInputs", () => {
|
|
282
|
-
test("fills in declared defaults", () => {
|
|
283
|
-
const out = resolveInputs({ research_doc: "notes.md" }, schema);
|
|
284
|
-
expect(out.research_doc).toBe("notes.md");
|
|
285
|
-
expect(out.focus).toBe("standard");
|
|
286
|
-
// `notes` has no default — should be absent, not empty-string.
|
|
287
|
-
expect(out.notes).toBeUndefined();
|
|
288
|
-
});
|
|
289
|
-
|
|
290
|
-
test("prefers provided values over defaults", () => {
|
|
291
|
-
const out = resolveInputs(
|
|
292
|
-
{ research_doc: "notes.md", focus: "exhaustive" },
|
|
293
|
-
schema,
|
|
294
|
-
);
|
|
295
|
-
expect(out.focus).toBe("exhaustive");
|
|
296
|
-
});
|
|
297
|
-
|
|
298
|
-
test("falls back to the first enum value when no default and no input", () => {
|
|
299
|
-
const enumOnly: WorkflowInput[] = [
|
|
300
|
-
{
|
|
301
|
-
name: "mode",
|
|
302
|
-
type: "enum",
|
|
303
|
-
required: true,
|
|
304
|
-
values: ["a", "b", "c"],
|
|
305
|
-
},
|
|
306
|
-
];
|
|
307
|
-
expect(resolveInputs({}, enumOnly)).toEqual({ mode: "a" });
|
|
308
|
-
});
|
|
309
|
-
|
|
310
|
-
test("ignores unknown provided keys", () => {
|
|
311
|
-
const out = resolveInputs(
|
|
312
|
-
{ research_doc: "notes.md", bogus: "hello" },
|
|
313
|
-
schema,
|
|
314
|
-
);
|
|
315
|
-
expect(out.bogus).toBeUndefined();
|
|
316
|
-
});
|
|
317
|
-
});
|
|
@@ -1,368 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Workflow discovery — finds workflow definitions from disk.
|
|
3
|
-
*
|
|
4
|
-
* Workflows are discovered from:
|
|
5
|
-
* 1. src/sdk/workflows/builtin/<name>/<agent>/index.ts (SDK-shipped builtins)
|
|
6
|
-
* 2. .atomic/workflows/<name>/<agent>/index.ts (project-local)
|
|
7
|
-
* 3. ~/.atomic/workflows/<name>/<agent>/index.ts (global)
|
|
8
|
-
*
|
|
9
|
-
* All three sources use the same scanning function (`discoverFromBaseDir`)
|
|
10
|
-
* so registration is uniform. Builtin names are reserved — project-local
|
|
11
|
-
* and global workflows with the same name are dropped during merge.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import { join } from "node:path";
|
|
15
|
-
import { readdir } from "node:fs/promises";
|
|
16
|
-
import { homedir } from "node:os";
|
|
17
|
-
import ignore from "ignore";
|
|
18
|
-
import type { AgentType, WorkflowInput } from "../types.ts";
|
|
19
|
-
import { WorkflowLoader } from "./loader.ts";
|
|
20
|
-
import { IncompatibleSDKError } from "../errors.ts";
|
|
21
|
-
|
|
22
|
-
export interface DiscoveredWorkflow {
|
|
23
|
-
name: string;
|
|
24
|
-
agent: AgentType;
|
|
25
|
-
path: string;
|
|
26
|
-
source: "local" | "global" | "builtin";
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function getLocalWorkflowsDir(projectRoot: string): string {
|
|
30
|
-
return join(projectRoot, ".atomic", "workflows");
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function getGlobalWorkflowsDir(): string {
|
|
34
|
-
return join(homedir(), ".atomic", "workflows");
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export const AGENTS: AgentType[] = ["copilot", "opencode", "claude"];
|
|
38
|
-
const AGENT_SET = new Set<string>(AGENTS);
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Default `.gitignore` content for a workflows directory.
|
|
42
|
-
* Auto-generated during install; regenerated by discovery if missing.
|
|
43
|
-
*/
|
|
44
|
-
export const WORKFLOWS_GITIGNORE = [
|
|
45
|
-
"node_modules/",
|
|
46
|
-
"dist/",
|
|
47
|
-
"build/",
|
|
48
|
-
"coverage/",
|
|
49
|
-
".cache/",
|
|
50
|
-
"*.log",
|
|
51
|
-
"*.tsbuildinfo",
|
|
52
|
-
"",
|
|
53
|
-
].join("\n");
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Load the `.gitignore` from a workflows directory, regenerating it if absent.
|
|
57
|
-
* The workflows `.gitignore` is always auto-generated so a missing file
|
|
58
|
-
* indicates an incomplete setup rather than an intentional absence.
|
|
59
|
-
*/
|
|
60
|
-
async function loadWorkflowsGitignore(workflowsDir: string): Promise<ignore.Ignore> {
|
|
61
|
-
const gitignorePath = join(workflowsDir, ".gitignore");
|
|
62
|
-
let content: string;
|
|
63
|
-
try {
|
|
64
|
-
content = await Bun.file(gitignorePath).text();
|
|
65
|
-
} catch {
|
|
66
|
-
// Missing — regenerate from the canonical template
|
|
67
|
-
await Bun.write(gitignorePath, WORKFLOWS_GITIGNORE);
|
|
68
|
-
content = WORKFLOWS_GITIGNORE;
|
|
69
|
-
}
|
|
70
|
-
return ignore().add(content);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Discover workflows from a base directory by scanning workflow-name
|
|
75
|
-
* directories first, then agent subdirectories within each.
|
|
76
|
-
*
|
|
77
|
-
* Layout: baseDir/<workflow_name>/<agent>/index.ts
|
|
78
|
-
*
|
|
79
|
-
* Entries are filtered against the `.gitignore` that lives inside the
|
|
80
|
-
* workflows directory itself (auto-generated, regenerated if missing).
|
|
81
|
-
*/
|
|
82
|
-
async function discoverFromBaseDir(
|
|
83
|
-
baseDir: string,
|
|
84
|
-
source: "local" | "global" | "builtin",
|
|
85
|
-
agentFilter?: AgentType,
|
|
86
|
-
): Promise<DiscoveredWorkflow[]> {
|
|
87
|
-
const workflows: DiscoveredWorkflow[] = [];
|
|
88
|
-
const agents = agentFilter ? [agentFilter] : AGENTS;
|
|
89
|
-
const agentNames = new Set<string>(agents);
|
|
90
|
-
|
|
91
|
-
let workflowEntries;
|
|
92
|
-
try {
|
|
93
|
-
workflowEntries = await readdir(baseDir, { withFileTypes: true });
|
|
94
|
-
} catch {
|
|
95
|
-
return workflows;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// Builtin workflows live inside the SDK source tree — skip .gitignore
|
|
99
|
-
// handling to avoid writing files into node_modules or the SDK dir.
|
|
100
|
-
const ig = source === "builtin"
|
|
101
|
-
? ignore()
|
|
102
|
-
: await loadWorkflowsGitignore(baseDir);
|
|
103
|
-
|
|
104
|
-
for (const wfEntry of workflowEntries) {
|
|
105
|
-
if (!wfEntry.isDirectory()) continue;
|
|
106
|
-
if (wfEntry.name.startsWith(".")) continue;
|
|
107
|
-
// Skip agent-named directories at root (they are not workflow names)
|
|
108
|
-
if (AGENT_SET.has(wfEntry.name)) continue;
|
|
109
|
-
// Skip directories matched by the workflows .gitignore.
|
|
110
|
-
// Append "/" so directory-only patterns (e.g. "build/") match correctly.
|
|
111
|
-
if (ig.ignores(wfEntry.name + "/")) continue;
|
|
112
|
-
|
|
113
|
-
const workflowDir = join(baseDir, wfEntry.name);
|
|
114
|
-
|
|
115
|
-
let agentEntries;
|
|
116
|
-
try {
|
|
117
|
-
agentEntries = await readdir(workflowDir, { withFileTypes: true });
|
|
118
|
-
} catch {
|
|
119
|
-
continue;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
for (const agentEntry of agentEntries) {
|
|
123
|
-
if (!agentEntry.isDirectory()) continue;
|
|
124
|
-
if (!agentNames.has(agentEntry.name)) continue;
|
|
125
|
-
|
|
126
|
-
const indexPath = join(workflowDir, agentEntry.name, "index.ts");
|
|
127
|
-
const file = Bun.file(indexPath);
|
|
128
|
-
if (await file.exists()) {
|
|
129
|
-
workflows.push({
|
|
130
|
-
name: wfEntry.name,
|
|
131
|
-
agent: agentEntry.name as AgentType,
|
|
132
|
-
path: indexPath,
|
|
133
|
-
source,
|
|
134
|
-
});
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
return workflows;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* Absolute path to the `builtin/` directory inside the SDK source tree.
|
|
144
|
-
* Computed from this file's URL so it always resolves correctly regardless
|
|
145
|
-
* of how the package was installed (dev checkout, global, bunx, etc.).
|
|
146
|
-
*/
|
|
147
|
-
const BUILTIN_WORKFLOWS_DIR = join(
|
|
148
|
-
Bun.fileURLToPath(new URL("../workflows/builtin", import.meta.url)),
|
|
149
|
-
);
|
|
150
|
-
|
|
151
|
-
/**
|
|
152
|
-
* Discover all available workflows from built-in, global, and local sources.
|
|
153
|
-
* Optionally filter by agent.
|
|
154
|
-
*
|
|
155
|
-
* **Merge precedence:** `builtin > local > global`.
|
|
156
|
-
*
|
|
157
|
-
* Builtin names are **strictly reserved** — a user-defined local or
|
|
158
|
-
* global workflow whose name matches any built-in workflow is dropped
|
|
159
|
-
* entirely from discovery. It will not be registered, returned from
|
|
160
|
-
* `findWorkflow`, appear in the interactive picker, or show up in
|
|
161
|
-
* `atomic workflow -l`. This protects SDK-shipped workflows (e.g.
|
|
162
|
-
* `ralph`) from being silently overridden or even visibly "competing
|
|
163
|
-
* with" a user's own definition, which would otherwise be confusing
|
|
164
|
-
* when someone tries to run the canonical version.
|
|
165
|
-
*
|
|
166
|
-
* Reservation is by **name only**, across all agents: if a builtin
|
|
167
|
-
* defines `ralph` for any agent, a local `ralph` for any other agent is
|
|
168
|
-
* also dropped. Local still overrides global for every non-builtin
|
|
169
|
-
* name, so project-scoped customisation of user-scoped workflows
|
|
170
|
-
* continues to work.
|
|
171
|
-
*
|
|
172
|
-
* By default, the result is **merged by precedence** — if a workflow is
|
|
173
|
-
* defined in both local and global sources, only the higher-precedence
|
|
174
|
-
* entry is returned. This is the right shape for `findWorkflow`, which
|
|
175
|
-
* needs the single resolved entry per (name, agent) pair.
|
|
176
|
-
*
|
|
177
|
-
* Pass `{ merge: false }` to get the **unmerged** result — local and
|
|
178
|
-
* global contribute their entries independently, so `--list` can show
|
|
179
|
-
* both a local and a global copy of the same workflow when they coexist
|
|
180
|
-
* on disk. (Builtin reservation still applies in both modes.)
|
|
181
|
-
*/
|
|
182
|
-
export async function discoverWorkflows(
|
|
183
|
-
projectRoot: string = process.cwd(),
|
|
184
|
-
agentFilter?: AgentType,
|
|
185
|
-
options: { merge?: boolean } = {},
|
|
186
|
-
): Promise<DiscoveredWorkflow[]> {
|
|
187
|
-
const { merge = true } = options;
|
|
188
|
-
|
|
189
|
-
const localDir = getLocalWorkflowsDir(projectRoot);
|
|
190
|
-
const globalDir = getGlobalWorkflowsDir();
|
|
191
|
-
|
|
192
|
-
// Collect ALL builtin names (ignoring agentFilter) so reservation is
|
|
193
|
-
// name-based across every agent: a local `ralph` for copilot is still
|
|
194
|
-
// reserved by a builtin `ralph` for claude, even when the discovery
|
|
195
|
-
// call was filtered to copilot.
|
|
196
|
-
const [allBuiltins, globalResults, localResults] = await Promise.all([
|
|
197
|
-
discoverFromBaseDir(BUILTIN_WORKFLOWS_DIR, "builtin"),
|
|
198
|
-
discoverFromBaseDir(globalDir, "global", agentFilter),
|
|
199
|
-
discoverFromBaseDir(localDir, "local", agentFilter),
|
|
200
|
-
]);
|
|
201
|
-
const reservedNames = new Set<string>(allBuiltins.map((w) => w.name));
|
|
202
|
-
const builtinResults = agentFilter
|
|
203
|
-
? allBuiltins.filter((w) => w.agent === agentFilter)
|
|
204
|
-
: allBuiltins;
|
|
205
|
-
|
|
206
|
-
// Drop any local/global workflow whose name matches a reserved
|
|
207
|
-
// builtin. This happens BEFORE both merge and unmerged code paths so
|
|
208
|
-
// reserved names never leak into `findWorkflow`, the picker, or
|
|
209
|
-
// `--list` — there is exactly one canonical entry per reserved name,
|
|
210
|
-
// the SDK-shipped one.
|
|
211
|
-
const filteredGlobal = globalResults.filter((w) => !reservedNames.has(w.name));
|
|
212
|
-
const filteredLocal = localResults.filter((w) => !reservedNames.has(w.name));
|
|
213
|
-
|
|
214
|
-
if (!merge) {
|
|
215
|
-
// Unmerged: keep local and global independent so `--list` can show
|
|
216
|
-
// both copies of a non-reserved name when they coexist. Order lowest
|
|
217
|
-
// → highest precedence so callers that want the winning entry can
|
|
218
|
-
// take the last one by (agent, name).
|
|
219
|
-
return [...filteredGlobal, ...filteredLocal, ...builtinResults];
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
// Merge with precedence: global (lowest) → local → builtin (highest).
|
|
223
|
-
// Builtin is layered last as a belt-and-braces guarantee — though
|
|
224
|
-
// reserved-name filtering above already makes this overwrite
|
|
225
|
-
// impossible in practice.
|
|
226
|
-
const byKey = new Map<string, DiscoveredWorkflow>();
|
|
227
|
-
for (const wf of filteredGlobal) {
|
|
228
|
-
byKey.set(`${wf.agent}/${wf.name}`, wf);
|
|
229
|
-
}
|
|
230
|
-
for (const wf of filteredLocal) {
|
|
231
|
-
byKey.set(`${wf.agent}/${wf.name}`, wf);
|
|
232
|
-
}
|
|
233
|
-
for (const wf of builtinResults) {
|
|
234
|
-
byKey.set(`${wf.agent}/${wf.name}`, wf);
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
return Array.from(byKey.values());
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
/**
|
|
241
|
-
* Find a specific workflow by name and agent.
|
|
242
|
-
*/
|
|
243
|
-
export async function findWorkflow(
|
|
244
|
-
name: string,
|
|
245
|
-
agent: AgentType,
|
|
246
|
-
projectRoot: string = process.cwd()
|
|
247
|
-
): Promise<DiscoveredWorkflow | null> {
|
|
248
|
-
const all = await discoverWorkflows(projectRoot, agent);
|
|
249
|
-
return all.find((w) => w.name === name) ?? null;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
/**
|
|
253
|
-
* Load status for a {@link WorkflowWithMetadata} entry.
|
|
254
|
-
*
|
|
255
|
-
* - `ok` — the workflow compiled cleanly and is ready to run.
|
|
256
|
-
* - `incompatible` — the workflow declared a `minSDKVersion` newer
|
|
257
|
-
* than the bundled CLI. The CLI renders it with an
|
|
258
|
-
* "update required" badge so users see the mismatch
|
|
259
|
-
* rather than a silent disappearance.
|
|
260
|
-
* - `error` — any other load failure (syntax error, missing
|
|
261
|
-
* `.compile()`, invalid default export, etc.).
|
|
262
|
-
* Rendered with a "failed to load" badge plus the
|
|
263
|
-
* underlying message.
|
|
264
|
-
*/
|
|
265
|
-
export type WorkflowMetadataStatus =
|
|
266
|
-
| { kind: "ok" }
|
|
267
|
-
| {
|
|
268
|
-
kind: "incompatible";
|
|
269
|
-
requiredVersion: string;
|
|
270
|
-
currentVersion: string;
|
|
271
|
-
message: string;
|
|
272
|
-
}
|
|
273
|
-
| {
|
|
274
|
-
kind: "error";
|
|
275
|
-
stage: "resolve" | "validate" | "load";
|
|
276
|
-
message: string;
|
|
277
|
-
};
|
|
278
|
-
|
|
279
|
-
/**
|
|
280
|
-
* A discovered workflow enriched with the metadata the picker needs to
|
|
281
|
-
* render it: the human description, the declared input schema, and the
|
|
282
|
-
* load status.
|
|
283
|
-
*
|
|
284
|
-
* Populated by {@link loadWorkflowsMetadata}, which runs each discovered
|
|
285
|
-
* workflow through {@link WorkflowLoader.loadWorkflow} and extracts the
|
|
286
|
-
* display-relevant fields — the full compiled definition is discarded
|
|
287
|
-
* after extraction so re-imports during execution are cheap.
|
|
288
|
-
*
|
|
289
|
-
* Broken entries still materialise with empty `description` / `inputs`
|
|
290
|
-
* and a non-`ok` {@link status}, so the picker can render them as
|
|
291
|
-
* visible "update required" / "failed to load" rows instead of
|
|
292
|
-
* silently omitting them (the previous behaviour, which made user and
|
|
293
|
-
* global workflows vanish whenever the base SDK shape drifted between
|
|
294
|
-
* releases).
|
|
295
|
-
*/
|
|
296
|
-
export interface WorkflowWithMetadata extends DiscoveredWorkflow {
|
|
297
|
-
/** Workflow description, empty string when none was declared or the workflow failed to load. */
|
|
298
|
-
description: string;
|
|
299
|
-
/** Picker-ready input schema; empty for free-form or failed-to-load workflows. */
|
|
300
|
-
inputs: readonly WorkflowInput[];
|
|
301
|
-
/** Load outcome — non-`ok` entries are rendered as visible diagnostics in the picker/list. */
|
|
302
|
-
status: WorkflowMetadataStatus;
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
/**
|
|
306
|
-
* Load metadata (description + picker-ready inputs + status) for a batch
|
|
307
|
-
* of discovered workflows.
|
|
308
|
-
*
|
|
309
|
-
* **Failed workflows are kept in the returned list**, not dropped. Each
|
|
310
|
-
* broken entry carries a {@link WorkflowMetadataStatus} explaining the
|
|
311
|
-
* failure so the picker and `atomic workflow -l` can surface it as an
|
|
312
|
-
* actionable diagnostic. This is the only way end users discover that a
|
|
313
|
-
* workflow from an older SDK release has gone incompatible after an
|
|
314
|
-
* `atomic` upgrade — silent filtering would leave them with a missing
|
|
315
|
-
* entry and no breadcrumb.
|
|
316
|
-
*
|
|
317
|
-
* Callers that want to execute a workflow should still route through
|
|
318
|
-
* {@link WorkflowLoader.loadWorkflow} — this function throws away the
|
|
319
|
-
* compiled definition so re-running the loader on a confirmed pick is
|
|
320
|
-
* unavoidable.
|
|
321
|
-
*/
|
|
322
|
-
export async function loadWorkflowsMetadata(
|
|
323
|
-
discovered: DiscoveredWorkflow[],
|
|
324
|
-
): Promise<WorkflowWithMetadata[]> {
|
|
325
|
-
return Promise.all(
|
|
326
|
-
discovered.map(async (wf): Promise<WorkflowWithMetadata> => {
|
|
327
|
-
const loaded = await WorkflowLoader.loadWorkflow(wf);
|
|
328
|
-
if (loaded.ok) {
|
|
329
|
-
return {
|
|
330
|
-
...wf,
|
|
331
|
-
description: loaded.value.definition.description,
|
|
332
|
-
inputs: loaded.value.definition.inputs,
|
|
333
|
-
status: { kind: "ok" },
|
|
334
|
-
};
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
// Incompatible SDK version is a first-class status so the UI can
|
|
338
|
-
// show a dedicated "update required" hint. Every other failure
|
|
339
|
-
// maps to a generic `error` variant — the picker renders the
|
|
340
|
-
// message but doesn't try to interpret it further.
|
|
341
|
-
if (loaded.error instanceof IncompatibleSDKError) {
|
|
342
|
-
return {
|
|
343
|
-
...wf,
|
|
344
|
-
description: "",
|
|
345
|
-
inputs: [],
|
|
346
|
-
status: {
|
|
347
|
-
kind: "incompatible",
|
|
348
|
-
requiredVersion: loaded.error.requiredVersion,
|
|
349
|
-
currentVersion: loaded.error.currentVersion,
|
|
350
|
-
message: loaded.message,
|
|
351
|
-
},
|
|
352
|
-
};
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
return {
|
|
356
|
-
...wf,
|
|
357
|
-
description: "",
|
|
358
|
-
inputs: [],
|
|
359
|
-
status: {
|
|
360
|
-
kind: "error",
|
|
361
|
-
stage: loaded.stage,
|
|
362
|
-
message: loaded.message,
|
|
363
|
-
},
|
|
364
|
-
};
|
|
365
|
-
}),
|
|
366
|
-
);
|
|
367
|
-
}
|
|
368
|
-
|