@bastani/atomic 0.5.0-1

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 (68) hide show
  1. package/LICENSE +24 -0
  2. package/README.md +956 -0
  3. package/assets/settings.schema.json +52 -0
  4. package/package.json +68 -0
  5. package/src/cli.ts +197 -0
  6. package/src/commands/cli/chat/client.ts +18 -0
  7. package/src/commands/cli/chat/index.ts +247 -0
  8. package/src/commands/cli/chat.ts +8 -0
  9. package/src/commands/cli/config.ts +55 -0
  10. package/src/commands/cli/init/index.ts +452 -0
  11. package/src/commands/cli/init/onboarding.ts +45 -0
  12. package/src/commands/cli/init/scm.ts +190 -0
  13. package/src/commands/cli/init.ts +8 -0
  14. package/src/commands/cli/update.ts +46 -0
  15. package/src/commands/cli/workflow.ts +164 -0
  16. package/src/lib/merge.ts +65 -0
  17. package/src/lib/path-root-guard.ts +38 -0
  18. package/src/lib/spawn.ts +467 -0
  19. package/src/scripts/bump-version.ts +94 -0
  20. package/src/scripts/constants-base.ts +14 -0
  21. package/src/scripts/constants.ts +34 -0
  22. package/src/sdk/components/color-utils.ts +20 -0
  23. package/src/sdk/components/connectors.test.ts +661 -0
  24. package/src/sdk/components/connectors.ts +156 -0
  25. package/src/sdk/components/edge.tsx +11 -0
  26. package/src/sdk/components/error-boundary.tsx +38 -0
  27. package/src/sdk/components/graph-theme.ts +36 -0
  28. package/src/sdk/components/header.tsx +60 -0
  29. package/src/sdk/components/layout.test.ts +924 -0
  30. package/src/sdk/components/layout.ts +186 -0
  31. package/src/sdk/components/node-card.tsx +68 -0
  32. package/src/sdk/components/orchestrator-panel-contexts.ts +26 -0
  33. package/src/sdk/components/orchestrator-panel-store.test.ts +561 -0
  34. package/src/sdk/components/orchestrator-panel-store.ts +118 -0
  35. package/src/sdk/components/orchestrator-panel-types.ts +21 -0
  36. package/src/sdk/components/orchestrator-panel.tsx +143 -0
  37. package/src/sdk/components/session-graph-panel.tsx +364 -0
  38. package/src/sdk/components/status-helpers.ts +32 -0
  39. package/src/sdk/components/statusline.tsx +63 -0
  40. package/src/sdk/define-workflow.ts +98 -0
  41. package/src/sdk/errors.ts +39 -0
  42. package/src/sdk/index.ts +38 -0
  43. package/src/sdk/providers/claude.ts +316 -0
  44. package/src/sdk/providers/copilot.ts +43 -0
  45. package/src/sdk/providers/opencode.ts +43 -0
  46. package/src/sdk/runtime/discovery.ts +172 -0
  47. package/src/sdk/runtime/executor.test.ts +415 -0
  48. package/src/sdk/runtime/executor.ts +695 -0
  49. package/src/sdk/runtime/loader.ts +372 -0
  50. package/src/sdk/runtime/panel.tsx +9 -0
  51. package/src/sdk/runtime/theme.ts +76 -0
  52. package/src/sdk/runtime/tmux.ts +542 -0
  53. package/src/sdk/types.ts +114 -0
  54. package/src/sdk/workflows.ts +85 -0
  55. package/src/services/config/atomic-config.ts +124 -0
  56. package/src/services/config/atomic-global-config.ts +361 -0
  57. package/src/services/config/config-path.ts +19 -0
  58. package/src/services/config/definitions.ts +176 -0
  59. package/src/services/config/index.ts +7 -0
  60. package/src/services/config/settings-schema.ts +2 -0
  61. package/src/services/config/settings.ts +149 -0
  62. package/src/services/system/copy.ts +381 -0
  63. package/src/services/system/detect.ts +161 -0
  64. package/src/services/system/download.ts +325 -0
  65. package/src/services/system/file-lock.ts +289 -0
  66. package/src/services/system/skills.ts +67 -0
  67. package/src/theme/colors.ts +25 -0
  68. package/src/version.ts +7 -0
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Typed error classes for the SDK.
3
+ *
4
+ * The SDK throws these instead of calling process.exit() or console.error().
5
+ * The CLI layer catches them and maps to user-visible output.
6
+ */
7
+
8
+ /** Thrown when a required system dependency is not found on PATH. */
9
+ export class MissingDependencyError extends Error {
10
+ constructor(public readonly dependency: "tmux" | "psmux" | "bun") {
11
+ super(`Required dependency not found: ${dependency}`);
12
+ this.name = "MissingDependencyError";
13
+ }
14
+ }
15
+
16
+ /** Thrown when a workflow file is defined but missing .compile(). */
17
+ export class WorkflowNotCompiledError extends Error {
18
+ constructor(public readonly path: string) {
19
+ super(
20
+ `Workflow at ${path} was defined but not compiled.\n` +
21
+ ` Add .compile() at the end of your defineWorkflow() chain:\n\n` +
22
+ ` export default defineWorkflow({ ... })\n` +
23
+ ` .session({ ... })\n` +
24
+ ` .compile();`,
25
+ );
26
+ this.name = "WorkflowNotCompiledError";
27
+ }
28
+ }
29
+
30
+ /** Thrown when a workflow file does not export a valid WorkflowDefinition. */
31
+ export class InvalidWorkflowError extends Error {
32
+ constructor(public readonly path: string) {
33
+ super(
34
+ `${path} does not export a valid WorkflowDefinition.\n` +
35
+ ` Make sure it exports defineWorkflow(...).compile() as the default export.`,
36
+ );
37
+ this.name = "InvalidWorkflowError";
38
+ }
39
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * atomic SDK
3
+ *
4
+ * Public API barrel — re-exports the SDK surface.
5
+ * CLI-only concerns (colors, prompts, process management) are not exported here.
6
+ */
7
+
8
+ // Typed errors
9
+ export {
10
+ MissingDependencyError,
11
+ WorkflowNotCompiledError,
12
+ InvalidWorkflowError,
13
+ } from "./errors.ts";
14
+
15
+ // Shared types
16
+ export type {
17
+ AgentType,
18
+ Transcript,
19
+ SavedMessage,
20
+ SaveTranscript,
21
+ SessionContext,
22
+ SessionOptions,
23
+ WorkflowOptions,
24
+ WorkflowDefinition,
25
+ } from "./types.ts";
26
+
27
+ // Workflow SDK (also available as atomic/workflows subpath)
28
+ export { defineWorkflow } from "./define-workflow.ts";
29
+
30
+ // Workflow discovery and execution
31
+ export {
32
+ discoverWorkflows,
33
+ findWorkflow,
34
+ } from "./runtime/discovery.ts";
35
+
36
+ export { WorkflowLoader } from "./runtime/loader.ts";
37
+
38
+ export { executeWorkflow } from "./runtime/executor.ts";
@@ -0,0 +1,316 @@
1
+ /**
2
+ * Claude Code query abstraction.
3
+ *
4
+ * Sends a prompt to an interactive Claude Code session running in a tmux pane
5
+ * using `tmux send-keys -l --` (literal text) + `C-m` (raw carriage return).
6
+ * Verifies delivery by polling `capture-pane` and retries if needed.
7
+ *
8
+ * This is NOT headless — Claude runs as a full interactive TUI in the pane.
9
+ * We're automating keyboard input and reading pane output.
10
+ *
11
+ * Reliability hardened from oh-my-codex's sendToWorker implementation:
12
+ * - Pre-send readiness wait with exponential backoff
13
+ * - CLI-specific submit plan (Claude: 1 C-m per round)
14
+ * - Per-round capture verification (6 rounds)
15
+ * - Adaptive retry with C-u clear + retype
16
+ * - Post-submit active-task detection
17
+ * - Whitespace-collapsing normalization
18
+ */
19
+
20
+ import {
21
+ sendLiteralText,
22
+ sendSpecialKey,
23
+ sendKeysAndSubmit,
24
+ capturePaneVisible,
25
+ capturePaneScrollback,
26
+ normalizeTmuxCapture,
27
+ normalizeTmuxLines,
28
+ paneLooksReady,
29
+ paneHasActiveTask,
30
+ waitForPaneReady,
31
+ attemptSubmitRounds,
32
+ } from "../runtime/tmux.ts";
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Session tracking — ensures createClaudeSession is called before claudeQuery
36
+ // ---------------------------------------------------------------------------
37
+
38
+ const initializedPanes = new Set<string>();
39
+
40
+ /**
41
+ * Remove a pane from the initialized set, freeing memory.
42
+ * Call when a Claude session is killed or no longer needed.
43
+ */
44
+ export function clearClaudeSession(paneId: string): void {
45
+ initializedPanes.delete(paneId);
46
+ }
47
+
48
+ /** Default CLI flags passed to the `claude` command. */
49
+ const DEFAULT_CHAT_FLAGS = [
50
+ "--allow-dangerously-skip-permissions",
51
+ "--dangerously-skip-permissions",
52
+ ];
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // createClaudeSession
56
+ // ---------------------------------------------------------------------------
57
+
58
+ export interface ClaudeSessionOptions {
59
+ /** tmux pane ID where Claude should be started */
60
+ paneId: string;
61
+ /** CLI flags to pass to the `claude` command (default: ["--allow-dangerously-skip-permissions", "--dangerously-skip-permissions"]) */
62
+ chatFlags?: string[];
63
+ /** Timeout in ms waiting for Claude TUI to be ready (default: 30s) */
64
+ readyTimeoutMs?: number;
65
+ }
66
+
67
+ /**
68
+ * Start Claude Code in a tmux pane with configurable CLI flags.
69
+ *
70
+ * Must be called before any `claudeQuery()` calls targeting the same pane.
71
+ * The pane should be a bare shell — `createClaudeSession` sends the `claude`
72
+ * command with the given flags and waits for the TUI to become ready.
73
+ *
74
+ * @example
75
+ * ```typescript
76
+ * import { createClaudeSession, claudeQuery } from "@bastani/atomic/workflows";
77
+ *
78
+ * await createClaudeSession({ paneId: ctx.paneId });
79
+ * await claudeQuery({ paneId: ctx.paneId, prompt: "Describe this project" });
80
+ * ```
81
+ *
82
+ * @example
83
+ * ```typescript
84
+ * // With custom flags
85
+ * await createClaudeSession({
86
+ * paneId: ctx.paneId,
87
+ * chatFlags: ["--model", "opus", "--dangerously-skip-permissions"],
88
+ * });
89
+ * ```
90
+ */
91
+ export async function createClaudeSession(options: ClaudeSessionOptions): Promise<void> {
92
+ const {
93
+ paneId,
94
+ chatFlags = DEFAULT_CHAT_FLAGS,
95
+ readyTimeoutMs = 30_000,
96
+ } = options;
97
+
98
+ const cmd = ["claude", ...chatFlags].join(" ");
99
+ sendKeysAndSubmit(paneId, cmd);
100
+
101
+ // Give the shell time to exec before polling for TUI readiness
102
+ await Bun.sleep(1_000);
103
+ await waitForPaneReady(paneId, readyTimeoutMs);
104
+
105
+ // Verify Claude TUI actually rendered — a bare shell or crash won't show
106
+ // the expected prompt/task indicators
107
+ const visible = capturePaneVisible(paneId);
108
+ if (!paneLooksReady(visible) && !paneHasActiveTask(visible)) {
109
+ throw new Error(
110
+ "createClaudeSession() timed out waiting for the Claude TUI to start. " +
111
+ "Verify the `claude` command is installed and the flags are valid.",
112
+ );
113
+ }
114
+
115
+ initializedPanes.add(paneId);
116
+ }
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // claudeQuery
120
+ // ---------------------------------------------------------------------------
121
+
122
+ export interface ClaudeQueryOptions {
123
+ /** tmux pane ID where Claude is running */
124
+ paneId: string;
125
+ /** The prompt to send */
126
+ prompt: string;
127
+ /** Timeout in ms waiting for Claude to finish responding (default: 300s) */
128
+ timeoutMs?: number;
129
+ /** Polling interval in ms (default: 2000) */
130
+ pollIntervalMs?: number;
131
+ /** Number of C-m presses per submit round (default: 1 for Claude) */
132
+ submitPresses?: number;
133
+ /** Max submit rounds if text isn't consumed (default: 6) */
134
+ maxSubmitRounds?: number;
135
+ /** Timeout in ms waiting for pane to be ready before sending (default: 30s) */
136
+ readyTimeoutMs?: number;
137
+ }
138
+
139
+ export interface ClaudeQueryResult {
140
+ /** The full pane content after the response completed */
141
+ output: string;
142
+ /** Whether delivery was confirmed (text disappeared from input) */
143
+ delivered: boolean;
144
+ }
145
+
146
+ /**
147
+ * Send a prompt to a Claude Code interactive session running in a tmux pane.
148
+ *
149
+ * Flow (hardened from OMX's sendToWorker):
150
+ * 1. Wait for pane readiness with exponential backoff
151
+ * 2. Capture pane content before sending
152
+ * 3. Send literal text via `send-keys -l --`
153
+ * 4. Submit with C-m rounds and per-round capture verification
154
+ * 5. Adaptive retry: clear line (C-u), re-type, re-submit
155
+ * 6. Post-submit verification via active-task detection
156
+ * 7. Wait for response by polling for output stabilization + prompt return
157
+ *
158
+ * @example
159
+ * ```typescript
160
+ * import { claudeQuery } from "@bastani/atomic/workflows";
161
+ *
162
+ * const result = await claudeQuery({
163
+ * paneId: ctx.paneId,
164
+ * prompt: "Describe this project",
165
+ * });
166
+ * ctx.log(result.output);
167
+ * ```
168
+ */
169
+ export async function claudeQuery(options: ClaudeQueryOptions): Promise<ClaudeQueryResult> {
170
+ const {
171
+ paneId,
172
+ prompt,
173
+ timeoutMs = 300_000,
174
+ pollIntervalMs = 2_000,
175
+ submitPresses = 1,
176
+ maxSubmitRounds = 6,
177
+ readyTimeoutMs = 30_000,
178
+ } = options;
179
+
180
+ if (!initializedPanes.has(paneId)) {
181
+ throw new Error(
182
+ "claudeQuery() called without a prior createClaudeSession() for this pane. " +
183
+ "Call createClaudeSession({ paneId }) first to start the Claude CLI.",
184
+ );
185
+ }
186
+
187
+ const normalizedPrompt = normalizeTmuxCapture(prompt).slice(0, 100);
188
+
189
+ // Step 1: Wait for pane readiness before sending (deducted from response timeout)
190
+ const waitElapsed = await waitForPaneReady(paneId, readyTimeoutMs);
191
+ const responseTimeoutMs = Math.max(0, timeoutMs - waitElapsed);
192
+
193
+ if (waitElapsed > timeoutMs * 0.5) {
194
+ console.warn(
195
+ `claudeQuery: readiness wait consumed ${Math.round(waitElapsed / 1000)}s ` +
196
+ `of ${Math.round(timeoutMs / 1000)}s total timeout budget`,
197
+ );
198
+ }
199
+
200
+ const beforeContent = normalizeTmuxLines(capturePaneScrollback(paneId));
201
+
202
+ // Step 2: Send literal text
203
+ sendLiteralText(paneId, prompt);
204
+ await Bun.sleep(150);
205
+
206
+ // Step 3: Submit with per-round capture verification
207
+ let delivered = await attemptSubmitRounds(paneId, normalizedPrompt, maxSubmitRounds, submitPresses);
208
+
209
+ // Step 4: Adaptive retry — clear line, re-type, re-submit
210
+ if (!delivered) {
211
+ const visibleCapture = capturePaneVisible(paneId);
212
+ const visibleNorm = normalizeTmuxCapture(visibleCapture);
213
+
214
+ // Only retry if text is still visible and pane is idle (not mid-task)
215
+ if (visibleNorm.includes(normalizedPrompt) && !paneHasActiveTask(visibleCapture) && paneLooksReady(visibleCapture)) {
216
+ sendSpecialKey(paneId, "C-u");
217
+ await Bun.sleep(80);
218
+ sendLiteralText(paneId, prompt);
219
+ await Bun.sleep(120);
220
+ delivered = await attemptSubmitRounds(paneId, normalizedPrompt, 4, submitPresses);
221
+ }
222
+ }
223
+
224
+ // Step 5: Final fallback — double C-m nudge + post-submit verification
225
+ if (!delivered) {
226
+ sendSpecialKey(paneId, "C-m");
227
+ await Bun.sleep(120);
228
+ sendSpecialKey(paneId, "C-m");
229
+ await Bun.sleep(300);
230
+
231
+ const verifyCapture = capturePaneVisible(paneId);
232
+ if (paneHasActiveTask(verifyCapture)) {
233
+ delivered = true;
234
+ } else {
235
+ delivered = !normalizeTmuxCapture(verifyCapture).includes(normalizedPrompt);
236
+ }
237
+
238
+ // One more attempt if text is still stuck
239
+ if (!delivered) {
240
+ sendSpecialKey(paneId, "C-m");
241
+ await Bun.sleep(150);
242
+ sendSpecialKey(paneId, "C-m");
243
+ }
244
+ }
245
+
246
+ // Step 6: Wait for response by detecting output stabilization or prompt return
247
+ const deadline = Date.now() + responseTimeoutMs;
248
+ let lastContent = "";
249
+ let stableCount = 0;
250
+
251
+ // Give Claude time to start processing
252
+ await Bun.sleep(3_000);
253
+
254
+ while (Date.now() < deadline) {
255
+ const currentContent = normalizeTmuxLines(capturePaneScrollback(paneId));
256
+
257
+ // Must have new content compared to before we sent
258
+ if (currentContent === beforeContent) {
259
+ await Bun.sleep(pollIntervalMs);
260
+ continue;
261
+ }
262
+
263
+ // Use visible capture for state detection to avoid stale scrollback matches
264
+ const visible = capturePaneVisible(paneId);
265
+ if (paneLooksReady(visible) && !paneHasActiveTask(visible)) {
266
+ return { output: currentContent, delivered };
267
+ }
268
+
269
+ if (currentContent === lastContent) {
270
+ stableCount++;
271
+ if (stableCount >= 3) {
272
+ return { output: currentContent, delivered };
273
+ }
274
+ } else {
275
+ stableCount = 0;
276
+ }
277
+
278
+ lastContent = currentContent;
279
+ await Bun.sleep(pollIntervalMs);
280
+ }
281
+
282
+ // Timeout — return whatever we have
283
+ return { output: lastContent || capturePaneScrollback(paneId), delivered };
284
+ }
285
+
286
+ // ---------------------------------------------------------------------------
287
+ // Static source validation
288
+ // ---------------------------------------------------------------------------
289
+
290
+ export interface ClaudeValidationWarning {
291
+ rule: string;
292
+ message: string;
293
+ }
294
+
295
+ /**
296
+ * Validate a Claude workflow source file for common mistakes.
297
+ *
298
+ * Checks that `createClaudeSession` is called when `claudeQuery` is used,
299
+ * paralleling the validation patterns for Copilot and OpenCode workflows.
300
+ */
301
+ export function validateClaudeWorkflow(source: string): ClaudeValidationWarning[] {
302
+ const warnings: ClaudeValidationWarning[] = [];
303
+
304
+ if (/\bclaudeQuery\b/.test(source)) {
305
+ if (!/\bcreateClaudeSession\b/.test(source)) {
306
+ warnings.push({
307
+ rule: "claude/create-session",
308
+ message:
309
+ "Could not verify that createClaudeSession is called before claudeQuery(). " +
310
+ "Call createClaudeSession({ paneId: ctx.paneId }) to start the Claude CLI before sending queries.",
311
+ });
312
+ }
313
+ }
314
+
315
+ return warnings;
316
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Copilot workflow source validation.
3
+ *
4
+ * Checks that Copilot workflow source files follow required patterns:
5
+ * - `cliUrl` is wired to `ctx.serverUrl` (or destructured `serverUrl`)
6
+ * - `setForegroundSessionId` is called after creating a session
7
+ */
8
+
9
+ export interface CopilotValidationWarning {
10
+ rule: string;
11
+ message: string;
12
+ }
13
+
14
+ /**
15
+ * Validate a Copilot workflow source file for common mistakes.
16
+ */
17
+ export function validateCopilotWorkflow(source: string): CopilotValidationWarning[] {
18
+ const warnings: CopilotValidationWarning[] = [];
19
+
20
+ if (/\bCopilotClient\b/.test(source)) {
21
+ if (!/cliUrl\s*:\s*(?:ctx\.serverUrl|serverUrl)/.test(source)) {
22
+ warnings.push({
23
+ rule: "copilot/cli-url",
24
+ message:
25
+ "Could not verify that CopilotClient is created with { cliUrl: ctx.serverUrl }. " +
26
+ "This is required to connect to the workflow's agent pane.",
27
+ });
28
+ }
29
+ }
30
+
31
+ if (/\bcreateSession\b/.test(source)) {
32
+ if (!/\bsetForegroundSessionId\b/.test(source)) {
33
+ warnings.push({
34
+ rule: "copilot/foreground-session",
35
+ message:
36
+ "Could not verify that setForegroundSessionId is called after createSession(). " +
37
+ "Call client.setForegroundSessionId(session.sessionId) so the TUI displays the workflow session.",
38
+ });
39
+ }
40
+ }
41
+
42
+ return warnings;
43
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * OpenCode workflow source validation.
3
+ *
4
+ * Checks that OpenCode workflow source files follow required patterns:
5
+ * - `baseUrl` is wired to `ctx.serverUrl` (or destructured `serverUrl`)
6
+ * - `tui.selectSession` is called after creating a session
7
+ */
8
+
9
+ export interface OpenCodeValidationWarning {
10
+ rule: string;
11
+ message: string;
12
+ }
13
+
14
+ /**
15
+ * Validate an OpenCode workflow source file for common mistakes.
16
+ */
17
+ export function validateOpenCodeWorkflow(source: string): OpenCodeValidationWarning[] {
18
+ const warnings: OpenCodeValidationWarning[] = [];
19
+
20
+ if (/\bcreateOpencodeClient\b/.test(source)) {
21
+ if (!/baseUrl\s*:\s*(?:ctx\.serverUrl|serverUrl)/.test(source)) {
22
+ warnings.push({
23
+ rule: "opencode/base-url",
24
+ message:
25
+ "Could not verify that createOpencodeClient is called with { baseUrl: ctx.serverUrl }. " +
26
+ "This is required to connect to the workflow's agent pane.",
27
+ });
28
+ }
29
+ }
30
+
31
+ if (/\bsession\.create\b/.test(source)) {
32
+ if (!/\btui\.selectSession\b/.test(source)) {
33
+ warnings.push({
34
+ rule: "opencode/select-session",
35
+ message:
36
+ "Could not verify that tui.selectSession is called after session.create(). " +
37
+ "Call client.tui.selectSession({ sessionID }) so the TUI displays the workflow session.",
38
+ });
39
+ }
40
+ }
41
+
42
+ return warnings;
43
+ }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Workflow discovery — finds workflow definitions from disk.
3
+ *
4
+ * Workflows are discovered from:
5
+ * 1. .atomic/workflows/<name>/<agent>/index.ts (project-local)
6
+ * 2. ~/.atomic/workflows/<name>/<agent>/index.ts (global)
7
+ *
8
+ * Project-local workflows take precedence over global ones with the same name.
9
+ */
10
+
11
+ import { join } from "path";
12
+ import { readdir, writeFile } from "fs/promises";
13
+ import { homedir } from "os";
14
+ import ignore from "ignore";
15
+ import type { AgentType } from "../types.ts";
16
+
17
+ export interface DiscoveredWorkflow {
18
+ name: string;
19
+ agent: AgentType;
20
+ path: string;
21
+ source: "local" | "global";
22
+ }
23
+
24
+ function getLocalWorkflowsDir(projectRoot: string): string {
25
+ return join(projectRoot, ".atomic", "workflows");
26
+ }
27
+
28
+ function getGlobalWorkflowsDir(): string {
29
+ return join(homedir(), ".atomic", "workflows");
30
+ }
31
+
32
+ export const AGENTS: AgentType[] = ["copilot", "opencode", "claude"];
33
+ const AGENT_SET = new Set<string>(AGENTS);
34
+
35
+ /**
36
+ * Default `.gitignore` content for a workflows directory.
37
+ * Auto-generated during install; regenerated by discovery if missing.
38
+ */
39
+ export const WORKFLOWS_GITIGNORE = [
40
+ "node_modules/",
41
+ "dist/",
42
+ "build/",
43
+ "coverage/",
44
+ ".cache/",
45
+ "*.log",
46
+ "*.tsbuildinfo",
47
+ "",
48
+ ].join("\n");
49
+
50
+ /**
51
+ * Load the `.gitignore` from a workflows directory, regenerating it if absent.
52
+ * The workflows `.gitignore` is always auto-generated so a missing file
53
+ * indicates an incomplete setup rather than an intentional absence.
54
+ */
55
+ async function loadWorkflowsGitignore(workflowsDir: string): Promise<ignore.Ignore> {
56
+ const gitignorePath = join(workflowsDir, ".gitignore");
57
+ let content: string;
58
+ try {
59
+ content = await Bun.file(gitignorePath).text();
60
+ } catch {
61
+ // Missing — regenerate from the canonical template
62
+ await writeFile(gitignorePath, WORKFLOWS_GITIGNORE);
63
+ content = WORKFLOWS_GITIGNORE;
64
+ }
65
+ return ignore().add(content);
66
+ }
67
+
68
+ /**
69
+ * Discover workflows from a base directory by scanning workflow-name
70
+ * directories first, then agent subdirectories within each.
71
+ *
72
+ * Layout: baseDir/<workflow_name>/<agent>/index.ts
73
+ *
74
+ * Entries are filtered against the `.gitignore` that lives inside the
75
+ * workflows directory itself (auto-generated, regenerated if missing).
76
+ */
77
+ async function discoverFromBaseDir(
78
+ baseDir: string,
79
+ source: "local" | "global",
80
+ agentFilter?: AgentType,
81
+ ): Promise<DiscoveredWorkflow[]> {
82
+ const workflows: DiscoveredWorkflow[] = [];
83
+ const agents = agentFilter ? [agentFilter] : AGENTS;
84
+ const agentNames = new Set<string>(agents);
85
+
86
+ let workflowEntries;
87
+ try {
88
+ workflowEntries = await readdir(baseDir, { withFileTypes: true });
89
+ } catch {
90
+ return workflows;
91
+ }
92
+
93
+ const ig = await loadWorkflowsGitignore(baseDir);
94
+
95
+ for (const wfEntry of workflowEntries) {
96
+ if (!wfEntry.isDirectory()) continue;
97
+ if (wfEntry.name.startsWith(".")) continue;
98
+ // Skip agent-named directories at root (they are not workflow names)
99
+ if (AGENT_SET.has(wfEntry.name)) continue;
100
+ // Skip directories matched by the workflows .gitignore.
101
+ // Append "/" so directory-only patterns (e.g. "build/") match correctly.
102
+ if (ig.ignores(wfEntry.name + "/")) continue;
103
+
104
+ const workflowDir = join(baseDir, wfEntry.name);
105
+
106
+ let agentEntries;
107
+ try {
108
+ agentEntries = await readdir(workflowDir, { withFileTypes: true });
109
+ } catch {
110
+ continue;
111
+ }
112
+
113
+ for (const agentEntry of agentEntries) {
114
+ if (!agentEntry.isDirectory()) continue;
115
+ if (!agentNames.has(agentEntry.name)) continue;
116
+
117
+ const indexPath = join(workflowDir, agentEntry.name, "index.ts");
118
+ const file = Bun.file(indexPath);
119
+ if (await file.exists()) {
120
+ workflows.push({
121
+ name: wfEntry.name,
122
+ agent: agentEntry.name as AgentType,
123
+ path: indexPath,
124
+ source,
125
+ });
126
+ }
127
+ }
128
+ }
129
+
130
+ return workflows;
131
+ }
132
+
133
+ /**
134
+ * Discover all available workflows from local and global directories.
135
+ * Optionally filter by agent. Local workflows take precedence over global.
136
+ */
137
+ export async function discoverWorkflows(
138
+ projectRoot: string = process.cwd(),
139
+ agentFilter?: AgentType
140
+ ): Promise<DiscoveredWorkflow[]> {
141
+ const localDir = getLocalWorkflowsDir(projectRoot);
142
+ const globalDir = getGlobalWorkflowsDir();
143
+
144
+ const [globalResults, localResults] = await Promise.all([
145
+ discoverFromBaseDir(globalDir, "global", agentFilter),
146
+ discoverFromBaseDir(localDir, "local", agentFilter),
147
+ ]);
148
+
149
+ const byKey = new Map<string, DiscoveredWorkflow>();
150
+ for (const wf of globalResults) {
151
+ byKey.set(`${wf.agent}/${wf.name}`, wf);
152
+ }
153
+ for (const wf of localResults) {
154
+ byKey.set(`${wf.agent}/${wf.name}`, wf);
155
+ }
156
+
157
+ return Array.from(byKey.values());
158
+ }
159
+
160
+ /**
161
+ * Find a specific workflow by name and agent.
162
+ */
163
+ export async function findWorkflow(
164
+ name: string,
165
+ agent: AgentType,
166
+ projectRoot: string = process.cwd()
167
+ ): Promise<DiscoveredWorkflow | null> {
168
+ const all = await discoverWorkflows(projectRoot, agent);
169
+ return all.find((w) => w.name === name) ?? null;
170
+ }
171
+
172
+