@sweny-ai/core 0.1.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 (93) hide show
  1. package/dist/__tests__/claude.test.d.ts +1 -0
  2. package/dist/__tests__/claude.test.js +328 -0
  3. package/dist/__tests__/executor.test.d.ts +1 -0
  4. package/dist/__tests__/executor.test.js +296 -0
  5. package/dist/__tests__/integration/datadog.integration.test.d.ts +1 -0
  6. package/dist/__tests__/integration/datadog.integration.test.js +23 -0
  7. package/dist/__tests__/integration/e2e-workflow.integration.test.d.ts +1 -0
  8. package/dist/__tests__/integration/e2e-workflow.integration.test.js +75 -0
  9. package/dist/__tests__/integration/github.integration.test.d.ts +1 -0
  10. package/dist/__tests__/integration/github.integration.test.js +37 -0
  11. package/dist/__tests__/integration/harness.d.ts +24 -0
  12. package/dist/__tests__/integration/harness.js +34 -0
  13. package/dist/__tests__/integration/linear.integration.test.d.ts +1 -0
  14. package/dist/__tests__/integration/linear.integration.test.js +15 -0
  15. package/dist/__tests__/integration/sentry.integration.test.d.ts +1 -0
  16. package/dist/__tests__/integration/sentry.integration.test.js +20 -0
  17. package/dist/__tests__/integration/slack.integration.test.d.ts +1 -0
  18. package/dist/__tests__/integration/slack.integration.test.js +22 -0
  19. package/dist/__tests__/schema.test.d.ts +1 -0
  20. package/dist/__tests__/schema.test.js +239 -0
  21. package/dist/__tests__/skills-index.test.d.ts +1 -0
  22. package/dist/__tests__/skills-index.test.js +122 -0
  23. package/dist/__tests__/skills.test.d.ts +1 -0
  24. package/dist/__tests__/skills.test.js +296 -0
  25. package/dist/__tests__/studio.test.d.ts +1 -0
  26. package/dist/__tests__/studio.test.js +172 -0
  27. package/dist/__tests__/testing.test.d.ts +1 -0
  28. package/dist/__tests__/testing.test.js +224 -0
  29. package/dist/browser.d.ts +17 -0
  30. package/dist/browser.js +22 -0
  31. package/dist/claude.d.ts +48 -0
  32. package/dist/claude.js +293 -0
  33. package/dist/cli/check.d.ts +11 -0
  34. package/dist/cli/check.js +237 -0
  35. package/dist/cli/config-file.d.ts +12 -0
  36. package/dist/cli/config-file.js +208 -0
  37. package/dist/cli/config.d.ts +77 -0
  38. package/dist/cli/config.js +565 -0
  39. package/dist/cli/main.d.ts +10 -0
  40. package/dist/cli/main.js +744 -0
  41. package/dist/cli/output.d.ts +26 -0
  42. package/dist/cli/output.js +357 -0
  43. package/dist/cli/renderer.d.ts +33 -0
  44. package/dist/cli/renderer.js +423 -0
  45. package/dist/cli/renderer.test.d.ts +1 -0
  46. package/dist/cli/renderer.test.js +302 -0
  47. package/dist/cli/setup.d.ts +11 -0
  48. package/dist/cli/setup.js +310 -0
  49. package/dist/executor.d.ts +29 -0
  50. package/dist/executor.js +173 -0
  51. package/dist/executor.test.d.ts +1 -0
  52. package/dist/executor.test.js +314 -0
  53. package/dist/index.d.ts +37 -0
  54. package/dist/index.js +36 -0
  55. package/dist/mcp.d.ts +11 -0
  56. package/dist/mcp.js +183 -0
  57. package/dist/mcp.test.d.ts +1 -0
  58. package/dist/mcp.test.js +334 -0
  59. package/dist/schema.d.ts +318 -0
  60. package/dist/schema.js +207 -0
  61. package/dist/skills/betterstack.d.ts +7 -0
  62. package/dist/skills/betterstack.js +114 -0
  63. package/dist/skills/datadog.d.ts +7 -0
  64. package/dist/skills/datadog.js +107 -0
  65. package/dist/skills/github.d.ts +8 -0
  66. package/dist/skills/github.js +155 -0
  67. package/dist/skills/index.d.ts +68 -0
  68. package/dist/skills/index.js +134 -0
  69. package/dist/skills/linear.d.ts +7 -0
  70. package/dist/skills/linear.js +89 -0
  71. package/dist/skills/notification.d.ts +11 -0
  72. package/dist/skills/notification.js +142 -0
  73. package/dist/skills/sentry.d.ts +7 -0
  74. package/dist/skills/sentry.js +105 -0
  75. package/dist/skills/slack.d.ts +8 -0
  76. package/dist/skills/slack.js +115 -0
  77. package/dist/studio.d.ts +124 -0
  78. package/dist/studio.js +174 -0
  79. package/dist/testing.d.ts +88 -0
  80. package/dist/testing.js +253 -0
  81. package/dist/types.d.ts +144 -0
  82. package/dist/types.js +11 -0
  83. package/dist/workflow-builder.d.ts +45 -0
  84. package/dist/workflow-builder.js +120 -0
  85. package/dist/workflow-builder.test.d.ts +1 -0
  86. package/dist/workflow-builder.test.js +117 -0
  87. package/dist/workflows/implement.d.ts +11 -0
  88. package/dist/workflows/implement.js +83 -0
  89. package/dist/workflows/index.d.ts +2 -0
  90. package/dist/workflows/index.js +2 -0
  91. package/dist/workflows/triage.d.ts +18 -0
  92. package/dist/workflows/triage.js +108 -0
  93. package/package.json +83 -0
@@ -0,0 +1,744 @@
1
+ #!/usr/bin/env node
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import { createRequire } from "node:module";
5
+ import { Command } from "commander";
6
+ const _require = createRequire(import.meta.url);
7
+ const { version } = _require("../../package.json");
8
+ import chalk from "chalk";
9
+ import { execute } from "../executor.js";
10
+ import { triageWorkflow } from "../workflows/triage.js";
11
+ import { implementWorkflow } from "../workflows/implement.js";
12
+ import { consoleLogger } from "../types.js";
13
+ import { ClaudeClient } from "../claude.js";
14
+ import { createSkillMap, configuredSkills } from "../skills/index.js";
15
+ import { validateWorkflow as validateWorkflowSchema } from "../schema.js";
16
+ import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
17
+ import { buildWorkflow, refineWorkflow } from "../workflow-builder.js";
18
+ import { DagRenderer } from "./renderer.js";
19
+ import * as readline from "node:readline";
20
+ import { loadDotenv, loadConfigFile, STARTER_CONFIG } from "./config-file.js";
21
+ import { registerTriageCommand, registerImplementCommand, parseCliInputs, validateInputs, validateWarnings, } from "./config.js";
22
+ import { c, formatBanner, getStepDetails, formatStepLine, formatDagResultHuman, formatResultJson, formatValidationErrors, formatCrashError, formatCheckResults, } from "./output.js";
23
+ import { checkProviderConnectivity } from "./check.js";
24
+ import { registerSetupCommand } from "./setup.js";
25
+ // Auto-load .env before Commander parses (so env vars are available for defaults)
26
+ loadDotenv();
27
+ const program = new Command()
28
+ .name("sweny")
29
+ .description("SWEny CLI \u2014 autonomous engineering workflows")
30
+ .version(version);
31
+ // ── sweny init ────────────────────────────────────────────────────────
32
+ program
33
+ .command("init")
34
+ .description("Create a starter .sweny.yml config file")
35
+ .action(() => {
36
+ const target = path.join(process.cwd(), ".sweny.yml");
37
+ if (fs.existsSync(target)) {
38
+ console.error(chalk.yellow(" .sweny.yml already exists — skipping."));
39
+ process.exit(1);
40
+ }
41
+ fs.writeFileSync(target, STARTER_CONFIG, "utf-8");
42
+ console.log(chalk.green(" Created .sweny.yml"));
43
+ console.log(chalk.dim(" Add your secrets to .env and run: sweny triage --dry-run"));
44
+ });
45
+ // ── sweny check ───────────────────────────────────────────────────────
46
+ program
47
+ .command("check")
48
+ .description("Verify provider credentials and connectivity")
49
+ .action(async () => {
50
+ const fileConfig = loadConfigFile();
51
+ const config = parseCliInputs({}, fileConfig);
52
+ const errors = validateInputs(config);
53
+ if (errors.length > 0) {
54
+ console.error(formatValidationErrors(errors));
55
+ process.exit(1);
56
+ }
57
+ console.log(chalk.dim("\n Checking provider connectivity…\n"));
58
+ const results = await checkProviderConnectivity(config);
59
+ console.log(formatCheckResults(results));
60
+ const hasFailure = results.some((r) => r.status === "fail");
61
+ process.exit(hasFailure ? 1 : 0);
62
+ });
63
+ registerSetupCommand(program);
64
+ // ── Credential map builder ──────────────────────────────────────────
65
+ /**
66
+ * Read env vars into the flat credential map expected by buildAutoMcpServers.
67
+ */
68
+ function buildCredentialMap() {
69
+ const creds = {};
70
+ const env = process.env;
71
+ const keys = [
72
+ "GITHUB_TOKEN",
73
+ "GITLAB_TOKEN",
74
+ "GITLAB_URL",
75
+ "LINEAR_API_KEY",
76
+ "JIRA_URL",
77
+ "JIRA_EMAIL",
78
+ "JIRA_API_TOKEN",
79
+ "DD_API_KEY",
80
+ "DD_APP_KEY",
81
+ "SENTRY_AUTH_TOKEN",
82
+ "SENTRY_ORG",
83
+ "SENTRY_URL",
84
+ "NR_API_KEY",
85
+ "NR_REGION",
86
+ "BETTERSTACK_API_TOKEN",
87
+ "SLACK_BOT_TOKEN",
88
+ "SLACK_TEAM_ID",
89
+ "NOTION_TOKEN",
90
+ "PAGERDUTY_API_TOKEN",
91
+ "MONDAY_TOKEN",
92
+ "ASANA_ACCESS_TOKEN",
93
+ ];
94
+ for (const k of keys) {
95
+ if (env[k])
96
+ creds[k] = env[k];
97
+ }
98
+ return creds;
99
+ }
100
+ /**
101
+ * Build the McpAutoConfig from CLI config for buildAutoMcpServers.
102
+ */
103
+ function buildMcpAutoConfig(config) {
104
+ return {
105
+ sourceControlProvider: config.sourceControlProvider,
106
+ issueTrackerProvider: config.issueTrackerProvider,
107
+ observabilityProvider: config.observabilityProvider,
108
+ credentials: buildCredentialMap(),
109
+ workspaceTools: config.workspaceTools,
110
+ userMcpServers: Object.keys(config.mcpServers).length > 0 ? config.mcpServers : undefined,
111
+ };
112
+ }
113
+ // ── sweny triage ──────────────────────────────────────────────────────
114
+ const triageCmd = registerTriageCommand(program);
115
+ triageCmd.action(async (options) => {
116
+ const fileConfig = loadConfigFile();
117
+ const config = parseCliInputs(options, fileConfig);
118
+ // Validate
119
+ const errors = validateInputs(config);
120
+ if (errors.length > 0) {
121
+ console.error(formatValidationErrors(errors));
122
+ console.error(c.subtle("\n Run sweny triage --help for usage information.\n"));
123
+ process.exit(1);
124
+ }
125
+ // Non-fatal warnings (e.g. missing service map file)
126
+ for (const warning of validateWarnings(config)) {
127
+ console.warn(chalk.yellow(` \u26A0 ${warning}`));
128
+ }
129
+ // Banner
130
+ if (!config.json) {
131
+ console.log(formatBanner(config, version));
132
+ }
133
+ // ── Build skill map + Claude client ──────────────────────
134
+ const skills = createSkillMap(configuredSkills());
135
+ const claude = new ClaudeClient({
136
+ maxTurns: config.maxInvestigateTurns || 50,
137
+ cwd: process.cwd(),
138
+ logger: consoleLogger,
139
+ });
140
+ // ── Spinner state ──────────────────────────────────────────
141
+ const FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
142
+ const isTTY = !config.json && (process.stderr.isTTY ?? false);
143
+ let spinnerInterval;
144
+ let spinnerActive = false;
145
+ let frameIdx = 0;
146
+ let stepStart = 0;
147
+ let stepLabel = "";
148
+ let spinnerStatus = "";
149
+ let currentPhaseColor = chalk.cyan;
150
+ let stepIndex = 0;
151
+ const totalNodes = Object.keys(triageWorkflow.nodes).length;
152
+ function formatElapsed(ms) {
153
+ const s = Math.round(ms / 1000);
154
+ if (s < 60)
155
+ return `${s}s`;
156
+ const m = Math.floor(s / 60);
157
+ return `${m}m ${s % 60}s`;
158
+ }
159
+ function clearSpinnerLine() {
160
+ if (spinnerActive && isTTY) {
161
+ process.stderr.write("\r\x1b[K");
162
+ }
163
+ }
164
+ function startSpinner(label) {
165
+ stepStart = Date.now();
166
+ stepLabel = label;
167
+ spinnerStatus = "";
168
+ frameIdx = 0;
169
+ spinnerActive = true;
170
+ if (isTTY) {
171
+ const cols = process.stderr.columns || 80;
172
+ spinnerInterval = setInterval(() => {
173
+ const frame = currentPhaseColor(FRAMES[frameIdx++ % FRAMES.length]);
174
+ const counter = c.subtle(`[${stepIndex}/${totalNodes}]`);
175
+ const elapsed = c.subtle(formatElapsed(Date.now() - stepStart));
176
+ const status = spinnerStatus ? ` ${c.subtle("\u2014")} ${c.subtle(spinnerStatus)}` : "";
177
+ // Truncate to terminal width to prevent line wrapping
178
+ let line = ` ${frame} ${counter} ${stepLabel}${status} ${elapsed}`;
179
+ const visibleLen = line.replace(/\x1B\[[0-9;]*m/g, "").length;
180
+ if (visibleLen > cols) {
181
+ // Re-render without status if too wide
182
+ line = ` ${frame} ${counter} ${stepLabel} ${elapsed}`;
183
+ }
184
+ process.stderr.write(`\r\x1b[K${line}`);
185
+ }, 80);
186
+ }
187
+ else if (!config.json) {
188
+ spinnerInterval = setInterval(() => {
189
+ const elapsed = formatElapsed(Date.now() - stepStart);
190
+ process.stderr.write(` > [${stepIndex}/${totalNodes}] ${stepLabel} ${elapsed}\n`);
191
+ }, 15_000);
192
+ }
193
+ }
194
+ function stopSpinner() {
195
+ if (spinnerInterval) {
196
+ clearInterval(spinnerInterval);
197
+ spinnerInterval = undefined;
198
+ }
199
+ if (isTTY) {
200
+ process.stderr.write("\r\x1b[K");
201
+ }
202
+ spinnerActive = false;
203
+ }
204
+ // ── Build observer for DAG events ──────────────────────────
205
+ const runStart = Date.now();
206
+ const observer = config.json
207
+ ? undefined
208
+ : (event) => {
209
+ switch (event.type) {
210
+ case "workflow:start":
211
+ // Already printed the banner
212
+ break;
213
+ case "node:enter":
214
+ stepIndex++;
215
+ if (!config.json) {
216
+ startSpinner(event.node);
217
+ }
218
+ break;
219
+ case "tool:call":
220
+ if (spinnerActive && isTTY) {
221
+ spinnerStatus = `${event.tool}(...)`;
222
+ }
223
+ break;
224
+ case "node:exit": {
225
+ stopSpinner();
226
+ if (!config.json) {
227
+ const elapsed = formatElapsed(Date.now() - stepStart);
228
+ const icon = event.result.status === "success"
229
+ ? c.ok("\u2713")
230
+ : event.result.status === "skipped"
231
+ ? c.subtle("\u2212")
232
+ : c.fail("\u2717");
233
+ const reason = event.result.status !== "success" ? event.result.data?.error : undefined;
234
+ const counter = `[${stepIndex}/${totalNodes}]`;
235
+ console.log(formatStepLine(icon, counter, event.node, elapsed, reason));
236
+ // Inline data details
237
+ const details = getStepDetails(event.node, event.result.data);
238
+ for (const detail of details) {
239
+ console.log(` ${c.subtle("\u21B3")} ${c.subtle(detail)}`);
240
+ }
241
+ }
242
+ break;
243
+ }
244
+ case "route":
245
+ // Optionally log routing decisions
246
+ break;
247
+ case "workflow:end":
248
+ // Output is handled after execute() returns
249
+ break;
250
+ }
251
+ };
252
+ // ── Build workflow input from config ──────────────────────
253
+ // TODO: The triage workflow input structure may need further refinement
254
+ // once the workflow nodes have stabilized. For now, pass config fields
255
+ // that the workflow instructions can reference via the `input` context.
256
+ const workflowInput = {
257
+ timeRange: config.timeRange,
258
+ severityFocus: config.severityFocus,
259
+ serviceFilter: config.serviceFilter,
260
+ investigationDepth: config.investigationDepth,
261
+ repository: config.repository,
262
+ dryRun: config.dryRun,
263
+ baseBranch: config.baseBranch,
264
+ prLabels: config.prLabels,
265
+ issueLabels: config.issueLabels,
266
+ additionalInstructions: config.additionalInstructions,
267
+ issueOverride: config.issueOverride,
268
+ noveltyMode: config.noveltyMode,
269
+ reviewMode: config.reviewMode,
270
+ };
271
+ try {
272
+ const results = await execute(triageWorkflow, workflowInput, {
273
+ skills,
274
+ claude,
275
+ observer,
276
+ logger: consoleLogger,
277
+ });
278
+ const durationMs = Date.now() - runStart;
279
+ // Output
280
+ if (config.json) {
281
+ console.log(formatResultJson(results));
282
+ }
283
+ else {
284
+ console.log(formatDagResultHuman(results, durationMs, config));
285
+ }
286
+ // Terminal bell
287
+ if (config.bell)
288
+ process.stderr.write("\x07");
289
+ // Check if any node failed
290
+ const hasFailed = [...results.values()].some((r) => r.status === "failed");
291
+ process.exit(hasFailed ? 1 : 0);
292
+ }
293
+ catch (error) {
294
+ if (config.json) {
295
+ console.log(JSON.stringify({ error: error instanceof Error ? error.message : "Unknown error" }));
296
+ }
297
+ else {
298
+ console.error(formatCrashError(error));
299
+ }
300
+ // Terminal bell even on crash
301
+ if (config.bell)
302
+ process.stderr.write("\x07");
303
+ process.exit(1);
304
+ }
305
+ });
306
+ // ── sweny implement ───────────────────────────────────────────────────
307
+ const implementCmd = registerImplementCommand(program);
308
+ implementCmd.action(async (issueId, options) => {
309
+ const fileConfig = loadConfigFile();
310
+ // Build a minimal CliConfig for the implement command by merging CLI opts with env/file
311
+ const config = {
312
+ ...parseCliInputs(options, fileConfig),
313
+ // Override specific fields that differ for implement
314
+ issueTrackerProvider: options.issueTrackerProvider || fileConfig["issue-tracker-provider"] || "linear",
315
+ sourceControlProvider: options.sourceControlProvider || fileConfig["source-control-provider"] || "github",
316
+ codingAgentProvider: options.codingAgentProvider || fileConfig["coding-agent-provider"] || "claude",
317
+ dryRun: Boolean(options.dryRun),
318
+ maxImplementTurns: parseInt(String(options.maxImplementTurns || fileConfig["max-implement-turns"] || "40"), 10),
319
+ baseBranch: options.baseBranch || fileConfig["base-branch"] || "main",
320
+ repository: options.repository || process.env.GITHUB_REPOSITORY || "",
321
+ outputDir: options.outputDir || process.env.SWENY_OUTPUT_DIR || fileConfig["output-dir"] || ".sweny/output",
322
+ };
323
+ const skills = createSkillMap(configuredSkills());
324
+ const claude = new ClaudeClient({
325
+ maxTurns: config.maxImplementTurns || 40,
326
+ cwd: process.cwd(),
327
+ logger: consoleLogger,
328
+ });
329
+ console.log(chalk.cyan(`\n sweny implement ${issueId}\n`));
330
+ const isTTY = process.stderr.isTTY ?? false;
331
+ const observer = (event) => {
332
+ switch (event.type) {
333
+ case "workflow:start":
334
+ process.stderr.write(`\n \u25B2 ${chalk.bold(event.workflow)}\n\n`);
335
+ break;
336
+ case "node:enter":
337
+ process.stderr.write(` ${c.subtle("\u25CB")} ${chalk.dim(event.node)}\u2026\n`);
338
+ break;
339
+ case "node:exit": {
340
+ const icon = event.result.status === "success"
341
+ ? c.ok("\u2713")
342
+ : event.result.status === "skipped"
343
+ ? c.subtle("\u2212")
344
+ : c.fail("\u2717");
345
+ if (isTTY) {
346
+ process.stderr.write(`\x1B[1A\x1B[2K ${icon} ${event.node}\n`);
347
+ }
348
+ else {
349
+ process.stderr.write(` ${icon} ${event.node}\n`);
350
+ }
351
+ break;
352
+ }
353
+ case "workflow:end":
354
+ process.stderr.write(`\n`);
355
+ break;
356
+ }
357
+ };
358
+ // Build workflow input for implement
359
+ const workflowInput = {
360
+ issueIdentifier: issueId,
361
+ repository: config.repository,
362
+ dryRun: config.dryRun,
363
+ baseBranch: config.baseBranch,
364
+ prLabels: config.prLabels,
365
+ reviewMode: config.reviewMode,
366
+ additionalInstructions: config.additionalInstructions,
367
+ };
368
+ try {
369
+ const results = await execute(implementWorkflow, workflowInput, {
370
+ skills,
371
+ claude,
372
+ observer,
373
+ logger: consoleLogger,
374
+ });
375
+ const hasFailed = [...results.values()].some((r) => r.status === "failed");
376
+ if (hasFailed) {
377
+ console.error(chalk.red(`\n Implement workflow failed\n`));
378
+ process.exit(1);
379
+ }
380
+ const prResult = results.get("create_pr");
381
+ const prUrl = prResult?.data?.prUrl;
382
+ if (prUrl) {
383
+ console.log(chalk.green(`\n PR created: ${prUrl}\n`));
384
+ }
385
+ else {
386
+ console.log(chalk.green(`\n Implement workflow completed\n`));
387
+ }
388
+ process.exit(0);
389
+ }
390
+ catch (err) {
391
+ console.error(chalk.red(`\n Error: ${err instanceof Error ? err.message : String(err)}\n`));
392
+ process.exit(1);
393
+ }
394
+ });
395
+ // ── sweny workflow ─────────────────────────────────────────────────────
396
+ function promptUser(question) {
397
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
398
+ return new Promise((resolve) => {
399
+ rl.question(question, (answer) => {
400
+ rl.close();
401
+ resolve(answer.trim());
402
+ });
403
+ });
404
+ }
405
+ const workflowCmd = program.command("workflow").description("Manage and run workflow files");
406
+ /** Reads and parses a workflow file (YAML or JSON). Throws on I/O or parse error. */
407
+ function parseWorkflowFileContent(filePath) {
408
+ const content = fs.readFileSync(filePath, "utf-8");
409
+ const ext = path.extname(filePath).toLowerCase();
410
+ return ext === ".yaml" || ext === ".yml" ? parseYaml(content) : JSON.parse(content);
411
+ }
412
+ export function loadWorkflowFile(filePath) {
413
+ const raw = parseWorkflowFileContent(filePath);
414
+ const errors = validateWorkflowSchema(raw);
415
+ if (errors.length > 0) {
416
+ throw new Error(`Invalid workflow file:\n${errors.map((e) => ` ${e.message}`).join("\n")}`);
417
+ }
418
+ return raw;
419
+ }
420
+ export async function workflowRunAction(file, options) {
421
+ let workflow;
422
+ try {
423
+ workflow = loadWorkflowFile(file);
424
+ }
425
+ catch (err) {
426
+ console.error(chalk.red(` Error loading workflow file: ${err instanceof Error ? err.message : String(err)}`));
427
+ process.exit(1);
428
+ return;
429
+ }
430
+ if (options.dryRun) {
431
+ console.log(chalk.green(` Workflow "${workflow.name}" is valid (${Object.keys(workflow.nodes).length} nodes)`));
432
+ for (const [id, node] of Object.entries(workflow.nodes)) {
433
+ console.log(chalk.dim(` ${id}: ${node.name}${node.skills.length ? ` skills=[${node.skills.join(",")}]` : ""}`));
434
+ }
435
+ process.exit(0);
436
+ }
437
+ const fileConfig = loadConfigFile();
438
+ const config = parseCliInputs(options, fileConfig);
439
+ const isJson = Boolean(options.json);
440
+ const isTTY = !isJson && (process.stderr.isTTY ?? false);
441
+ const skills = createSkillMap(configuredSkills());
442
+ const claude = new ClaudeClient({
443
+ maxTurns: config.maxInvestigateTurns || 50,
444
+ cwd: process.cwd(),
445
+ logger: consoleLogger,
446
+ });
447
+ // Track per-node entry time to compute elapsed on exit
448
+ const nodeEnterTimes = new Map();
449
+ const observer = isJson
450
+ ? undefined
451
+ : (event) => {
452
+ switch (event.type) {
453
+ case "workflow:start":
454
+ process.stderr.write(`\n \u25B2 ${chalk.bold(event.workflow)}\n\n`);
455
+ break;
456
+ case "node:enter":
457
+ nodeEnterTimes.set(event.node, Date.now());
458
+ process.stderr.write(` ${c.subtle("\u25CB")} ${chalk.dim(event.node)}\u2026\n`);
459
+ break;
460
+ case "node:exit": {
461
+ const icon = event.result.status === "success"
462
+ ? c.ok("\u2713")
463
+ : event.result.status === "skipped"
464
+ ? c.subtle("\u2212")
465
+ : c.fail("\u2717");
466
+ const enterTime = nodeEnterTimes.get(event.node) ?? Date.now();
467
+ const elapsedMs = Date.now() - enterTime;
468
+ const elapsed = chalk.dim(elapsedMs < 1000 ? `${elapsedMs}ms` : `${Math.round(elapsedMs / 100) / 10}s`);
469
+ if (isTTY) {
470
+ // Overwrite the pending "○ nodeId…" line with the final status
471
+ process.stderr.write(`\x1B[1A\x1B[2K ${icon} ${event.node} ${elapsed}\n`);
472
+ }
473
+ else {
474
+ process.stderr.write(` ${icon} ${event.node} ${elapsed}\n`);
475
+ }
476
+ break;
477
+ }
478
+ case "workflow:end":
479
+ process.stderr.write(`\n`);
480
+ break;
481
+ }
482
+ };
483
+ // Build workflow input from config
484
+ const workflowInput = {
485
+ timeRange: config.timeRange,
486
+ severityFocus: config.severityFocus,
487
+ serviceFilter: config.serviceFilter,
488
+ repository: config.repository,
489
+ dryRun: config.dryRun,
490
+ baseBranch: config.baseBranch,
491
+ prLabels: config.prLabels,
492
+ additionalInstructions: config.additionalInstructions,
493
+ };
494
+ try {
495
+ const results = await execute(workflow, workflowInput, {
496
+ skills,
497
+ claude,
498
+ observer,
499
+ logger: consoleLogger,
500
+ });
501
+ if (isJson) {
502
+ process.stdout.write(JSON.stringify(Object.fromEntries(results), null, 2) + "\n");
503
+ const hasFailed = [...results.values()].some((r) => r.status === "failed");
504
+ process.exit(hasFailed ? 1 : 0);
505
+ return;
506
+ }
507
+ const hasFailed = [...results.values()].some((r) => r.status === "failed");
508
+ if (hasFailed) {
509
+ console.error(chalk.red(` Workflow failed\n`));
510
+ process.exit(1);
511
+ return;
512
+ }
513
+ console.log(chalk.green(` Workflow completed\n`));
514
+ process.exit(0);
515
+ }
516
+ catch (err) {
517
+ console.error(chalk.red(`\n Error: ${err instanceof Error ? err.message : String(err)}\n`));
518
+ process.exit(1);
519
+ }
520
+ }
521
+ export function workflowExportAction(name) {
522
+ let workflow;
523
+ if (name === "triage") {
524
+ workflow = triageWorkflow;
525
+ }
526
+ else if (name === "implement") {
527
+ workflow = implementWorkflow;
528
+ }
529
+ else {
530
+ console.error(chalk.red(` Unknown workflow "${name}". Available: triage, implement`));
531
+ process.exit(1);
532
+ return;
533
+ }
534
+ // Export as YAML
535
+ process.stdout.write(stringifyYaml(workflow, { indent: 2, lineWidth: 120 }));
536
+ }
537
+ export function workflowValidateAction(file, options) {
538
+ let raw;
539
+ try {
540
+ raw = parseWorkflowFileContent(file);
541
+ }
542
+ catch (err) {
543
+ const message = err instanceof Error ? err.message : String(err);
544
+ if (options.json) {
545
+ process.stderr.write(JSON.stringify({ valid: false, errors: [{ message }] }, null, 2) + "\n");
546
+ }
547
+ else {
548
+ console.error(chalk.red(` Cannot read "${file}": ${message}`));
549
+ }
550
+ process.exit(1);
551
+ return;
552
+ }
553
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
554
+ const kind = raw === null ? "null" : Array.isArray(raw) ? "array" : typeof raw;
555
+ const message = `Expected a YAML/JSON object, got ${kind}`;
556
+ if (options.json) {
557
+ process.stderr.write(JSON.stringify({ valid: false, errors: [{ message }] }, null, 2) + "\n");
558
+ }
559
+ else {
560
+ console.error(chalk.red(` \u2717 ${file}: ${message}`));
561
+ }
562
+ process.exit(1);
563
+ return;
564
+ }
565
+ const errors = validateWorkflowSchema(raw);
566
+ if (options.json) {
567
+ process.stdout.write(JSON.stringify({ valid: errors.length === 0, errors }, null, 2) + "\n");
568
+ }
569
+ else if (errors.length === 0) {
570
+ console.log(chalk.green(` \u2713 ${file} is valid`));
571
+ }
572
+ else {
573
+ console.error(chalk.red(` \u2717 ${file} has ${errors.length} validation error${errors.length > 1 ? "s" : ""}:`));
574
+ for (const err of errors) {
575
+ console.error(chalk.dim(` ${err.message}`));
576
+ }
577
+ }
578
+ process.exit(errors.length === 0 ? 0 : 1);
579
+ }
580
+ workflowCmd
581
+ .command("validate <file>")
582
+ .description("Validate a workflow YAML or JSON file")
583
+ .option("--json", "Output result as JSON")
584
+ .action(workflowValidateAction);
585
+ workflowCmd
586
+ .command("run <file>")
587
+ .description("Run a workflow from a YAML or JSON file")
588
+ .option("--dry-run", "Validate workflow without running")
589
+ .option("--json", "Output result as JSON on stdout; suppress progress output")
590
+ .action(workflowRunAction);
591
+ workflowCmd
592
+ .command("export <name>")
593
+ .description("Print a built-in workflow as YAML (triage or implement)")
594
+ .action(workflowExportAction);
595
+ workflowCmd
596
+ .command("create <description>")
597
+ .description("Generate a new workflow from a natural language description")
598
+ .option("--json", "Output workflow JSON to stdout (no interactive prompt)")
599
+ .action(async (description, options) => {
600
+ const skills = configuredSkills();
601
+ const claude = new ClaudeClient({
602
+ maxTurns: 3,
603
+ cwd: process.cwd(),
604
+ logger: consoleLogger,
605
+ });
606
+ try {
607
+ let workflow = await buildWorkflow(description, { claude, skills, logger: consoleLogger });
608
+ if (options.json) {
609
+ process.stdout.write(JSON.stringify(workflow, null, 2) + "\n");
610
+ process.exit(0);
611
+ return;
612
+ }
613
+ while (true) {
614
+ console.log("");
615
+ const renderer = new DagRenderer(workflow, { animate: false });
616
+ console.log(renderer.renderToString());
617
+ console.log("");
618
+ const defaultPath = `.sweny/workflows/${workflow.id}.yml`;
619
+ const answer = await promptUser(` Save to ${defaultPath}? [Y/n/refine] `);
620
+ const choice = answer.toLowerCase() || "y";
621
+ if (choice === "y" || choice === "yes") {
622
+ const dir = path.dirname(defaultPath);
623
+ if (!fs.existsSync(dir))
624
+ fs.mkdirSync(dir, { recursive: true });
625
+ fs.writeFileSync(defaultPath, stringifyYaml(workflow, { indent: 2, lineWidth: 120 }), "utf-8");
626
+ console.log(chalk.green(`\n Saved to ${defaultPath}\n`));
627
+ process.exit(0);
628
+ return;
629
+ }
630
+ else if (choice === "n" || choice === "no") {
631
+ console.log(chalk.dim("\n Discarded.\n"));
632
+ process.exit(0);
633
+ return;
634
+ }
635
+ else if (choice === "refine" || choice === "r") {
636
+ const refinement = await promptUser(" What would you like to change? ");
637
+ if (!refinement)
638
+ continue;
639
+ console.log(chalk.dim("\n Refining...\n"));
640
+ workflow = await refineWorkflow(workflow, refinement, { claude, skills, logger: consoleLogger });
641
+ }
642
+ else {
643
+ console.log(chalk.dim("\n Refining...\n"));
644
+ workflow = await refineWorkflow(workflow, choice, { claude, skills, logger: consoleLogger });
645
+ }
646
+ }
647
+ }
648
+ catch (err) {
649
+ console.error(chalk.red(`\n Error: ${err instanceof Error ? err.message : String(err)}\n`));
650
+ process.exit(1);
651
+ }
652
+ });
653
+ workflowCmd
654
+ .command("edit <file> [instruction]")
655
+ .description("Edit an existing workflow file with natural language instructions")
656
+ .option("--json", "Output updated workflow JSON to stdout (no interactive prompt)")
657
+ .action(async (file, instruction, options) => {
658
+ let workflow;
659
+ try {
660
+ workflow = loadWorkflowFile(file);
661
+ }
662
+ catch (err) {
663
+ console.error(chalk.red(` Error loading ${file}: ${err instanceof Error ? err.message : String(err)}`));
664
+ process.exit(1);
665
+ return;
666
+ }
667
+ const skills = configuredSkills();
668
+ const claude = new ClaudeClient({
669
+ maxTurns: 3,
670
+ cwd: process.cwd(),
671
+ logger: consoleLogger,
672
+ });
673
+ if (!instruction) {
674
+ instruction = await promptUser(" What would you like to change? ");
675
+ if (!instruction) {
676
+ console.log(chalk.dim(" No changes.\n"));
677
+ process.exit(0);
678
+ return;
679
+ }
680
+ }
681
+ try {
682
+ let updated = await refineWorkflow(workflow, instruction, { claude, skills, logger: consoleLogger });
683
+ if (options.json) {
684
+ process.stdout.write(JSON.stringify(updated, null, 2) + "\n");
685
+ process.exit(0);
686
+ return;
687
+ }
688
+ while (true) {
689
+ console.log("");
690
+ const renderer = new DagRenderer(updated, { animate: false });
691
+ console.log(renderer.renderToString());
692
+ console.log("");
693
+ const answer = await promptUser(` Save changes to ${file}? [Y/n/refine] `);
694
+ const choice = answer.toLowerCase() || "y";
695
+ if (choice === "y" || choice === "yes") {
696
+ fs.writeFileSync(file, stringifyYaml(updated, { indent: 2, lineWidth: 120 }), "utf-8");
697
+ console.log(chalk.green(`\n Saved to ${file}\n`));
698
+ process.exit(0);
699
+ return;
700
+ }
701
+ else if (choice === "n" || choice === "no") {
702
+ console.log(chalk.dim("\n Discarded.\n"));
703
+ process.exit(0);
704
+ return;
705
+ }
706
+ else if (choice === "refine" || choice === "r") {
707
+ const refinement = await promptUser(" What would you like to change? ");
708
+ if (!refinement)
709
+ continue;
710
+ console.log(chalk.dim("\n Refining...\n"));
711
+ updated = await refineWorkflow(updated, refinement, { claude, skills, logger: consoleLogger });
712
+ }
713
+ else {
714
+ console.log(chalk.dim("\n Refining...\n"));
715
+ updated = await refineWorkflow(updated, choice, { claude, skills, logger: consoleLogger });
716
+ }
717
+ }
718
+ }
719
+ catch (err) {
720
+ console.error(chalk.red(`\n Error: ${err instanceof Error ? err.message : String(err)}\n`));
721
+ process.exit(1);
722
+ }
723
+ });
724
+ // TODO: The old CLI had `workflow list` that showed registered step types
725
+ // from the engine. In the new DAG model, we list available skills instead.
726
+ workflowCmd
727
+ .command("list")
728
+ .description("List available skills")
729
+ .option("--json", "Output as JSON array")
730
+ .action((options) => {
731
+ const skills = configuredSkills();
732
+ if (options.json) {
733
+ const data = skills.map((s) => ({ id: s.id, name: s.name, description: s.description, category: s.category }));
734
+ process.stdout.write(JSON.stringify(data, null, 2) + "\n");
735
+ return;
736
+ }
737
+ console.log(chalk.bold("\nConfigured skills:\n"));
738
+ for (const skill of skills) {
739
+ console.log(` ${chalk.cyan(skill.id)} (${skill.category})`);
740
+ console.log(chalk.dim(` ${skill.description}`));
741
+ }
742
+ console.log();
743
+ });
744
+ program.parse();