@sweny-ai/core 0.1.123 → 0.1.125

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 (56) hide show
  1. package/dist/agent-env.d.ts +171 -0
  2. package/dist/agent-env.js +393 -0
  3. package/dist/browser.d.ts +2 -2
  4. package/dist/browser.js +1 -1
  5. package/dist/claude.d.ts +61 -3
  6. package/dist/claude.js +187 -27
  7. package/dist/cli/check.d.ts +42 -1
  8. package/dist/cli/check.js +130 -8
  9. package/dist/cli/cloud-lifecycle.d.ts +24 -29
  10. package/dist/cli/cloud-lifecycle.js +187 -12
  11. package/dist/cli/cloud-report.js +45 -2
  12. package/dist/cli/config-file.d.ts +8 -1
  13. package/dist/cli/config-file.js +42 -1
  14. package/dist/cli/config.js +11 -14
  15. package/dist/cli/e2e.d.ts +22 -0
  16. package/dist/cli/e2e.js +31 -4
  17. package/dist/cli/main.d.ts +1 -0
  18. package/dist/cli/main.js +138 -46
  19. package/dist/cli/marketplace.d.ts +3 -1
  20. package/dist/cli/marketplace.js +2 -2
  21. package/dist/cli/new.d.ts +15 -0
  22. package/dist/cli/new.js +139 -65
  23. package/dist/cli/publish.js +9 -0
  24. package/dist/cli/run-output.d.ts +59 -0
  25. package/dist/cli/run-output.js +189 -0
  26. package/dist/cli/skill-env.d.ts +34 -0
  27. package/dist/cli/skill-env.js +64 -0
  28. package/dist/cli/templates.js +587 -0
  29. package/dist/cli/workflow-input.d.ts +41 -0
  30. package/dist/cli/workflow-input.js +56 -0
  31. package/dist/executor.d.ts +17 -1
  32. package/dist/executor.js +119 -9
  33. package/dist/index.d.ts +3 -3
  34. package/dist/index.js +2 -2
  35. package/dist/mermaid.d.ts +6 -0
  36. package/dist/mermaid.js +10 -5
  37. package/dist/schema.d.ts +218 -188
  38. package/dist/schema.js +24 -3
  39. package/dist/skills/betterstack.js +4 -0
  40. package/dist/skills/datadog.js +3 -0
  41. package/dist/skills/github.js +8 -0
  42. package/dist/skills/linear.js +8 -0
  43. package/dist/skills/notification.js +3 -0
  44. package/dist/skills/sentry.js +4 -0
  45. package/dist/skills/slack.js +2 -0
  46. package/dist/skills/supabase.js +9 -0
  47. package/dist/testing.d.ts +3 -2
  48. package/dist/testing.js +9 -3
  49. package/dist/types.d.ts +99 -2
  50. package/dist/types.js +17 -0
  51. package/dist/untrusted.d.ts +32 -0
  52. package/dist/untrusted.js +46 -0
  53. package/dist/workflows/browser.js +2 -2
  54. package/dist/workflows/implement.yml +2 -0
  55. package/dist/workflows/triage.yml +3 -1
  56. package/package.json +1 -1
@@ -32,6 +32,40 @@ export function loadDotenv(cwd = process.cwd()) {
32
32
  }
33
33
  }
34
34
  }
35
+ /**
36
+ * `.sweny.yml` keys for the agent execution floor (#360) and the env var each
37
+ * maps to. The runtime (claude.ts / agent-env.ts) reads the env var, so the
38
+ * same knob works for the CLI, the Action and library callers.
39
+ */
40
+ const AGENT_FILE_KEYS = [
41
+ [["env-passthrough", "env_passthrough"], "SWENY_ENV_PASSTHROUGH"],
42
+ [["env-scope", "env_scope"], "SWENY_ENV_SCOPE"],
43
+ [["sandbox"], "SWENY_SANDBOX"],
44
+ [["sandbox-allowed-domains", "sandbox_allowed_domains"], "SWENY_SANDBOX_ALLOWED_DOMAINS"],
45
+ ];
46
+ /**
47
+ * Copy agent sandbox / env-passthrough settings from `.sweny.yml` into the
48
+ * environment. Real env vars win (same precedence as {@link loadDotenv}).
49
+ * YAML booleans arrive as "true"/"false", which the sandbox mode parser
50
+ * treats as on/off.
51
+ */
52
+ export function applyAgentFileConfig(fileConfig, env = process.env) {
53
+ for (const [keys, envVar] of AGENT_FILE_KEYS) {
54
+ if (env[envVar] !== undefined)
55
+ continue;
56
+ for (const key of keys) {
57
+ const v = fileConfig[key];
58
+ if (Array.isArray(v) && v.length > 0) {
59
+ env[envVar] = v.join(",");
60
+ break;
61
+ }
62
+ if (typeof v === "string" && v) {
63
+ env[envVar] = v;
64
+ break;
65
+ }
66
+ }
67
+ }
68
+ }
35
69
  /**
36
70
  * Search upward from `cwd` for `.sweny.yml` and parse it.
37
71
  * Scalar values are strings, list values (rules, context) are string arrays.
@@ -114,7 +148,7 @@ export const STARTER_CONFIG = `# .sweny.yml — SWEny project configuration
114
148
  # observability-provider: datadog # datadog | sentry | cloudwatch | splunk | elastic | newrelic | loki | prometheus | pagerduty | heroku | opsgenie | vercel | supabase | netlify | fly | render | file
115
149
  # issue-tracker-provider: github-issues # github-issues | linear | jira
116
150
  # source-control-provider: github # github | gitlab
117
- # coding-agent-provider: claude # claude | codex | gemini
151
+ # coding-agent-provider: claude # claude (the only supported agent)
118
152
  # notification-provider: console # console | slack | teams | discord | email | webhook
119
153
 
120
154
  # ── Investigation ────────────────────────────────────────────────────
@@ -141,6 +175,13 @@ export const STARTER_CONFIG = `# .sweny.yml — SWEny project configuration
141
175
  # cloud-token: sweny_pk_...
142
176
  # Or set SWENY_CLOUD_TOKEN in your environment.
143
177
 
178
+ # ── Agent sandbox ─────────────────────────────────────────────────────
179
+ # See https://docs.sweny.ai/advanced/agent-sandbox/
180
+ # sandbox: off # off (local default) | auto (CI default: sandbox if supported, else warn) | strict
181
+ # sandbox-allowed-domains: [internal.example.com] # extra hosts agent commands may reach
182
+ # env-scope: off # off (local default: full env) | on (CI default: allowlist)
183
+ # env-passthrough: [NPM_TOKEN] # extra env vars the agent may see when scoped ("*" = all)
184
+
144
185
  # ── MCP servers ───────────────────────────────────────────────────────
145
186
  # Extend the coding agent with additional tools via MCP.
146
187
  # Value is a JSON object — each key is a server name you choose.
@@ -6,7 +6,7 @@ export function registerTriageCommand(program) {
6
6
  return program
7
7
  .command("triage")
8
8
  .description("Run the SWEny triage workflow")
9
- .option("--agent <provider>", "Coding agent: claude (default), codex, gemini")
9
+ .option("--agent <provider>", "Coding agent: claude (the only supported agent)")
10
10
  .option("--coding-agent-provider <provider>", "Coding agent provider (alias for --agent)")
11
11
  .option("--observability-provider <provider>", "Observability provider (default: none)")
12
12
  .option("--issue-tracker-provider <provider>", "Issue tracker provider (default: github-issues)")
@@ -228,18 +228,12 @@ export function validateInputs(config) {
228
228
  errors.push("Missing: ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN — get a key at https://console.anthropic.com");
229
229
  }
230
230
  break;
231
- case "codex":
232
- if (!config.openaiApiKey) {
233
- errors.push("Missing: OPENAI_API_KEY — get a key at https://platform.openai.com/api-keys");
234
- }
235
- break;
236
- case "gemini":
237
- if (!config.geminiApiKey) {
238
- errors.push("Missing: GEMINI_API_KEY or GOOGLE_API_KEY — get a key at https://aistudio.google.com/app/apikey");
239
- }
240
- break;
241
231
  default:
242
- errors.push(`Unsupported coding agent provider: ${config.codingAgentProvider} (use claude, codex, or gemini)`);
232
+ // Honest --agent (#330): SWEny runs headless Claude Code only. Nothing
233
+ // dispatches on other values, so accepting them would silently run
234
+ // Claude under a different name.
235
+ errors.push(`Unsupported coding agent "${config.codingAgentProvider}": the only supported agent is "claude" ` +
236
+ `(headless Claude Code). Remove --agent / coding-agent-provider or set it to claude.`);
243
237
  }
244
238
  // Repository required unless all providers are file-based
245
239
  const allLocal = config.issueTrackerProvider === "file" && config.sourceControlProvider === "file";
@@ -656,7 +650,7 @@ export function registerImplementCommand(program) {
656
650
  return program
657
651
  .command("implement <issueId>")
658
652
  .description("Implement a fix for a specific issue and open a PR")
659
- .option("--agent <provider>", "Coding agent: claude (default), codex, gemini")
653
+ .option("--agent <provider>", "Coding agent: claude (the only supported agent)")
660
654
  .option("--coding-agent-provider <provider>", "Coding agent provider (alias for --agent)")
661
655
  .option("--issue-tracker-provider <provider>", "Issue tracker (linear|jira|github-issues|file)")
662
656
  .option("--source-control-provider <provider>", "Source control (github|gitlab|file)")
@@ -701,7 +695,10 @@ function parseMcpServers(json) {
701
695
  }
702
696
  function detectRepository() {
703
697
  try {
704
- const remote = execSync("git remote get-url origin", { encoding: "utf-8" }).trim();
698
+ const remote = execSync("git remote get-url origin", {
699
+ encoding: "utf-8",
700
+ stdio: ["ignore", "pipe", "ignore"],
701
+ }).trim();
705
702
  const match = remote.match(/[:/]([^/]+\/[^/.]+?)(?:\.git)?$/);
706
703
  return match?.[1] ?? "";
707
704
  }
package/dist/cli/e2e.d.ts CHANGED
@@ -75,6 +75,28 @@ export interface E2eInitOptions {
75
75
  * then generates .sweny/e2e/*.yml and .env additions.
76
76
  */
77
77
  export declare function runE2eInit(options?: E2eInitOptions): Promise<void>;
78
+ /**
79
+ * Default wall-clock budget (60 min) for a single workflow run when the
80
+ * caller doesn't pass `--timeout`. `--timeout 0` disables it. Shared by the e2e batch runner and the primary
81
+ * `sweny workflow run <file>` path (see main.ts workflowRunAction) so both
82
+ * fail loudly on a wedged node instead of hanging CI indefinitely. See #325.
83
+ */
84
+ export declare const DEFAULT_WORKFLOW_TIMEOUT_MS: number;
85
+ export declare function withTimeout<T>(promise: Promise<T>, ms: number, label: string, onTimeout?: () => void): Promise<T>;
86
+ /**
87
+ * Run a workflow execution under a whole-run wall-clock budget, aborting the
88
+ * in-flight run via `AbortSignal` when the budget is exceeded.
89
+ *
90
+ * Shared by the `.sweny/e2e/` batch runner (below) and the primary
91
+ * `sweny workflow run <file>` path (main.ts `workflowRunAction`) so both
92
+ * paths get the same abort/timeout behavior from one implementation instead
93
+ * of main.ts re-deriving it. Before #325, `workflowRunAction` never wired
94
+ * `--timeout` or an abort signal into `execute()` at all: a wedged node
95
+ * (hung tool call, runaway model turn) could hang the primary run path
96
+ * indefinitely even though the abort/timeout plumbing already existed in
97
+ * the executor and `claude.run`.
98
+ */
99
+ export declare function runWithWallClockBudget<T>(run: (signal: AbortSignal) => Promise<T>, timeoutMs: number, label: string): Promise<T>;
78
100
  export interface E2eRunOptions {
79
101
  file?: string;
80
102
  timeout?: number;
package/dist/cli/e2e.js CHANGED
@@ -481,6 +481,7 @@ export function buildFlowWorkflow(flow, baseUrl, cleanup) {
481
481
  id: `e2e-${flow.type}`,
482
482
  name: `E2E: ${typeName}`,
483
483
  description: `End-to-end test for ${flow.type} flow`,
484
+ workflow_type: "e2e_test",
484
485
  entry: "setup",
485
486
  nodes,
486
487
  edges,
@@ -760,7 +761,14 @@ export async function runE2eInit(options = {}) {
760
761
  }
761
762
  }
762
763
  // ── Timeout helper ─────────────────────────────────────────────────────
763
- function withTimeout(promise, ms, label, onTimeout) {
764
+ /**
765
+ * Default wall-clock budget (60 min) for a single workflow run when the
766
+ * caller doesn't pass `--timeout`. `--timeout 0` disables it. Shared by the e2e batch runner and the primary
767
+ * `sweny workflow run <file>` path (see main.ts workflowRunAction) so both
768
+ * fail loudly on a wedged node instead of hanging CI indefinitely. See #325.
769
+ */
770
+ export const DEFAULT_WORKFLOW_TIMEOUT_MS = 60 * 60 * 1000;
771
+ export function withTimeout(promise, ms, label, onTimeout) {
764
772
  return new Promise((resolve, reject) => {
765
773
  const timer = setTimeout(() => {
766
774
  // Abort the underlying work first so it stops advancing and the in-flight
@@ -775,6 +783,26 @@ function withTimeout(promise, ms, label, onTimeout) {
775
783
  .finally(() => clearTimeout(timer));
776
784
  });
777
785
  }
786
+ /**
787
+ * Run a workflow execution under a whole-run wall-clock budget, aborting the
788
+ * in-flight run via `AbortSignal` when the budget is exceeded.
789
+ *
790
+ * Shared by the `.sweny/e2e/` batch runner (below) and the primary
791
+ * `sweny workflow run <file>` path (main.ts `workflowRunAction`) so both
792
+ * paths get the same abort/timeout behavior from one implementation instead
793
+ * of main.ts re-deriving it. Before #325, `workflowRunAction` never wired
794
+ * `--timeout` or an abort signal into `execute()` at all: a wedged node
795
+ * (hung tool call, runaway model turn) could hang the primary run path
796
+ * indefinitely even though the abort/timeout plumbing already existed in
797
+ * the executor and `claude.run`.
798
+ */
799
+ export async function runWithWallClockBudget(run, timeoutMs, label) {
800
+ const controller = new AbortController();
801
+ // timeoutMs <= 0 means no wall-clock budget (`--timeout 0`).
802
+ if (!(timeoutMs > 0))
803
+ return run(controller.signal);
804
+ return withTimeout(run(controller.signal), timeoutMs, label, () => controller.abort());
805
+ }
778
806
  /**
779
807
  * Policy for a no-file batch run. Pure so it can be unit-tested without
780
808
  * touching the filesystem, stdin, or the engine.
@@ -794,7 +822,7 @@ export function batchRunDecision(opts) {
794
822
  */
795
823
  export async function runE2eRun(options) {
796
824
  const cwd = process.cwd();
797
- const timeoutMs = options.timeout || 15 * 60 * 1000;
825
+ const timeoutMs = options.timeout ?? DEFAULT_WORKFLOW_TIMEOUT_MS;
798
826
  // 1. Discover workflow files
799
827
  let files;
800
828
  if (options.file) {
@@ -911,9 +939,8 @@ export async function runE2eRun(options) {
911
939
  }
912
940
  }
913
941
  };
914
- const controller = new AbortController();
915
942
  try {
916
- const { results } = await withTimeout(execute(workflow, { run_id: vars.run_id, base_url: vars.base_url }, { skills, claude, observer, logger: consoleLogger, signal: controller.signal }), timeoutMs, `Workflow ${workflow.name}`, () => controller.abort());
943
+ const { results } = await runWithWallClockBudget((signal) => execute(workflow, { run_id: vars.run_id, base_url: vars.base_url }, { skills, claude, observer, logger: consoleLogger, signal }), timeoutMs, `Workflow ${workflow.name}`);
917
944
  // Extract report
918
945
  const report = results.get("report");
919
946
  if (report?.data) {
@@ -7,6 +7,7 @@ export declare function workflowRunAction(file: string | undefined, options: Rec
7
7
  mermaid?: boolean;
8
8
  verbose?: boolean;
9
9
  timeout?: string;
10
+ maxSteps?: string;
10
11
  yes?: boolean;
11
12
  }): Promise<void>;
12
13
  export declare function workflowExportAction(name: string): void;
package/dist/cli/main.js CHANGED
@@ -10,26 +10,29 @@ import { execute } from "../executor.js";
10
10
  import { triageWorkflow, implementWorkflow, seedContentWorkflow } from "../workflows/index.js";
11
11
  import { consoleLogger } from "../types.js";
12
12
  import { ClaudeClient } from "../claude.js";
13
- import { createSkillMap, validateWorkflowSkills } from "../skills/index.js";
13
+ import { builtinSkills, createSkillMap, validateWorkflowSkills } from "../skills/index.js";
14
+ import { formatMissingSkillLines, skillEnvWarnings } from "./skill-env.js";
14
15
  import { configuredSkills, configuredSkillsWithDiagnostics } from "../skills/custom-loader.js";
15
16
  import { buildAutoMcpServers, buildSkillMcpServers, buildProviderContext } from "../mcp.js";
16
17
  import { loadAdditionalContext } from "../templates.js";
17
18
  import { loadAndValidateWorkflow } from "../loader.js";
18
19
  import { validateRuntimeInput } from "../inputs.js";
20
+ import { mergeDryRunIntoInput, parseRunBudgetFlags } from "./workflow-input.js";
19
21
  import { stringify as stringifyYaml } from "yaml";
20
22
  import { buildWorkflow, refineWorkflow } from "../workflow-builder.js";
21
23
  import { toMermaidBlock } from "../mermaid.js";
22
24
  import { runWorkflowDiagram } from "./diagram.js";
23
25
  import { DagRenderer } from "./renderer.js";
24
26
  import * as readline from "node:readline";
25
- import { loadDotenv, loadConfigFile } from "./config-file.js";
27
+ import { loadDotenv, loadConfigFile, applyAgentFileConfig } from "./config-file.js";
26
28
  import { buildCredentialMap } from "./credentials.js";
27
- import { runNew } from "./new.js";
28
- import { runE2eRun } from "./e2e.js";
29
+ import { nonInteractiveUsage, runNew } from "./new.js";
30
+ import { runE2eRun, runWithWallClockBudget, DEFAULT_WORKFLOW_TIMEOUT_MS } from "./e2e.js";
29
31
  import { createVerboseToolObserver } from "./verbose-observer.js";
32
+ import { createRunLogger, renderReceiptLine, summarizeRun, writeStepSummary, WORKFLOW_RUN_DESCRIPTION, WORKFLOW_RUN_OPTIONS, } from "./run-output.js";
30
33
  import { registerTriageCommand, registerImplementCommand, parseCliInputs, validateInputs, validateWarnings, parsePositiveInt, } from "./config.js";
31
34
  import { c, formatBanner, getStepDetails, formatStepLine, formatDagResultHuman, formatDagResultMarkdown, formatResultJson, formatValidationErrors, formatCrashError, formatCheckResults, } from "./output.js";
32
- import { checkProviderConnectivity } from "./check.js";
35
+ import { checkProviderConnectivity, detectClaudeCodeLogin, discoverWorkflowSkillIds, validateCheckInputs, } from "./check.js";
33
36
  import { registerSetupCommand } from "./setup.js";
34
37
  import { registerPublishCommand } from "./publish.js";
35
38
  import { registerSkillCommand } from "./skill.js";
@@ -64,6 +67,8 @@ function composeObservers(...observers) {
64
67
  }
65
68
  // Auto-load .env before Commander parses (so env vars are available for defaults)
66
69
  loadDotenv();
70
+ // Agent sandbox / env-passthrough keys from .sweny.yml -> SWENY_* env (#360).
71
+ applyAgentFileConfig(loadConfigFile());
67
72
  const program = new Command()
68
73
  .name("sweny")
69
74
  .description("SWEny CLI \u2014 autonomous engineering workflows")
@@ -71,9 +76,17 @@ const program = new Command()
71
76
  // ── sweny new ─────────────────────────────────────────────────────────
72
77
  program
73
78
  .command("new [id]")
74
- .description("Create a new workflow. With no id, opens the interactive picker. With an id, installs that workflow from the marketplace (swenyai/workflows).")
75
- .action(async (id) => {
76
- await runNew(id ? { marketplaceId: id } : undefined);
79
+ .description("Create a new workflow. With no id, opens the interactive picker. With an id, uses that built-in template, or installs it from the marketplace (swenyai/workflows).")
80
+ .option("--template <id>", "Use a built-in template without the picker")
81
+ .option("-y, --yes", "Skip every prompt (never overwrites existing files)")
82
+ .action(async (id, options) => {
83
+ // Prompts need a terminal. Without one (CI, pipes), a prompt never
84
+ // settles, so print usage and exit 2 instead of hanging.
85
+ if (!options.yes && !process.stdin.isTTY) {
86
+ console.error(nonInteractiveUsage());
87
+ process.exit(2);
88
+ }
89
+ await runNew({ marketplaceId: id, template: options.template, yes: options.yes });
77
90
  });
78
91
  // ── sweny check ───────────────────────────────────────────────────────
79
92
  program
@@ -82,13 +95,16 @@ program
82
95
  .action(async () => {
83
96
  const fileConfig = loadConfigFile();
84
97
  const config = parseCliInputs({}, fileConfig);
85
- const errors = validateInputs(config);
98
+ const workflows = discoverWorkflowSkillIds(process.cwd());
99
+ const scope = workflows.found ? workflows.skillIds : undefined;
100
+ const claudeCodeLogin = detectClaudeCodeLogin();
101
+ const errors = validateCheckInputs(config, { scope, claudeCodeLogin });
86
102
  if (errors.length > 0) {
87
103
  console.error(formatValidationErrors(errors));
88
104
  process.exit(1);
89
105
  }
90
106
  console.log(chalk.dim("\n Checking provider connectivity…\n"));
91
- const results = await checkProviderConnectivity(config);
107
+ const results = await checkProviderConnectivity(config, { scope, claudeCodeLogin });
92
108
  console.log(formatCheckResults(results));
93
109
  const hasFailure = results.some((r) => r.status === "fail");
94
110
  process.exit(hasFailure ? 1 : 0);
@@ -418,17 +434,33 @@ triageCmd.action(async (options) => {
418
434
  process.exit(hasFailed ? 1 : 0);
419
435
  }
420
436
  catch (error) {
437
+ const crashMsg = error instanceof Error ? error.message : "Unknown error";
421
438
  if (config.json) {
422
- console.log(JSON.stringify({ error: error instanceof Error ? error.message : "Unknown error" }));
439
+ console.log(JSON.stringify({ error: crashMsg }));
423
440
  }
424
441
  else {
425
442
  console.error(formatCrashError(error));
426
443
  }
444
+ // Finalize the cloud run as failed. execute() threw (including the
445
+ // RouteEvaluationError the executor raises on route-eval failure), so
446
+ // the in-try finish never ran and the run would otherwise stay stuck at
447
+ // "running" forever. No results map exists on this path, so pass an empty
448
+ // one; the error summary is a short message, never raw agent prose.
449
+ try {
450
+ // PRIVACY: pass the raw error, not crashMsg. finishCloudLifecycle reduces it
451
+ // to error.name + a 200-char message: thrown messages can embed agent/LLM
452
+ // prose or log text, and the cloud gets metadata only. It also sends at
453
+ // most one finish per run, so a throw after the in-try finish cannot
454
+ // overwrite the real result with a second "failed".
455
+ await finishCloudLifecycle(config, cloudHandle, new Map(), Date.now() - runStart, "failed", error);
456
+ }
457
+ catch {
458
+ // silent — cloud reporting must never block or mask the crash
459
+ }
427
460
  // Best-effort GitHub Actions step summary on crash
428
461
  if (config.notificationProvider === "github-summary" && process.env.GITHUB_STEP_SUMMARY) {
429
462
  try {
430
- const msg = error instanceof Error ? error.message : "Unknown error";
431
- fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `## ❌ SWEny Triage Crashed\n\n\`\`\`\n${msg}\n\`\`\`\n`);
463
+ fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `## ❌ SWEny Triage Crashed\n\n\`\`\`\n${crashMsg}\n\`\`\`\n`);
432
464
  }
433
465
  catch {
434
466
  // ignore
@@ -589,7 +621,22 @@ implementCmd.action(async (issueId, options) => {
589
621
  process.exit(0);
590
622
  }
591
623
  catch (err) {
592
- console.error(chalk.red(`\n Error: ${err instanceof Error ? err.message : String(err)}\n`));
624
+ const crashMsg = err instanceof Error ? err.message : String(err);
625
+ console.error(chalk.red(`\n Error: ${crashMsg}\n`));
626
+ // Finalize the cloud run as failed (covers thrown errors, incl.
627
+ // RouteEvaluationError). Without this a crashed implement run stays
628
+ // "running" in cloud forever.
629
+ try {
630
+ // PRIVACY: pass the raw error, not crashMsg. finishCloudLifecycle reduces it
631
+ // to error.name + a 200-char message: thrown messages can embed agent/LLM
632
+ // prose or log text, and the cloud gets metadata only. It also sends at
633
+ // most one finish per run, so a throw after the in-try finish cannot
634
+ // overwrite the real result with a second "failed".
635
+ await finishCloudLifecycle(config, implCloudHandle, new Map(), Date.now() - implRunStart, "failed", err);
636
+ }
637
+ catch {
638
+ // silent
639
+ }
593
640
  process.exit(1);
594
641
  }
595
642
  });
@@ -612,12 +659,22 @@ export function loadWorkflowFile(filePath, knownSkills) {
612
659
  return result.workflow;
613
660
  }
614
661
  export async function workflowRunAction(file, options) {
662
+ // Reject junk --timeout/--max-steps up front (both paths) instead of
663
+ // silently falling back to a default.
664
+ let budget;
665
+ try {
666
+ budget = parseRunBudgetFlags(options.timeout, options.maxSteps, DEFAULT_WORKFLOW_TIMEOUT_MS);
667
+ }
668
+ catch (err) {
669
+ console.error(chalk.red(`\n ${err instanceof Error ? err.message : String(err)}\n`));
670
+ process.exit(1);
671
+ }
615
672
  // No file given → batch-run the e2e workflows in .sweny/e2e/. This is the
616
673
  // home for what used to be `sweny e2e run`; it lists what will run and
617
674
  // confirms first (bypass with --yes).
618
675
  if (!file) {
619
676
  await runE2eRun({
620
- timeout: options.timeout ? parseInt(options.timeout, 10) : undefined,
677
+ timeout: options.timeout === undefined ? undefined : budget.timeoutMs,
621
678
  yes: Boolean(options.yes),
622
679
  });
623
680
  return;
@@ -631,7 +688,9 @@ export async function workflowRunAction(file, options) {
631
688
  for (const w of earlySkillDiscovery.warnings) {
632
689
  console.error(chalk.yellow(` ⚠ ${w.message}`));
633
690
  }
634
- const knownSkillIds = new Set(earlySkillDiscovery.skills.map((s) => s.id));
691
+ // A built-in skill with unset env is not "unknown": include every built-in id
692
+ // so the loader only flags genuine typos; missing env is reported below.
693
+ const knownSkillIds = new Set([...earlySkillDiscovery.skills.map((s) => s.id), ...builtinSkills.map((s) => s.id)]);
635
694
  let workflow;
636
695
  try {
637
696
  workflow = loadWorkflowFile(file, knownSkillIds);
@@ -663,23 +722,16 @@ export async function workflowRunAction(file, options) {
663
722
  // we require at least one configured skill per category.
664
723
  const validation = validateWorkflowSkills(workflow, skills, workflow.skills);
665
724
  if (validation.errors.length > 0) {
666
- console.error(chalk.red(`\n Workflow cannot run — missing required skills:\n`));
667
- for (const err of validation.errors)
668
- console.error(chalk.red(` \u2717 ${err}`));
725
+ console.error(chalk.red(`\n Workflow cannot run:\n`));
726
+ for (const line of formatMissingSkillLines(validation))
727
+ console.error(chalk.red(` \u2717 ${line}`));
669
728
  const unknown = validation.missing.filter((m) => m.category === "unknown");
670
- const envGaps = validation.missing.filter((m) => m.category !== "unknown" && m.missingEnv.length > 0);
671
729
  if (unknown.length > 0) {
672
730
  console.error(chalk.dim(`\n These skill IDs aren't built-in or discovered in .{claude,sweny,agents,gemini}/skills/.\n Scaffold one with:\n`));
673
731
  for (const m of unknown) {
674
732
  console.error(chalk.dim(` sweny skill new ${m.id}`));
675
733
  }
676
734
  }
677
- if (envGaps.length > 0) {
678
- console.error(chalk.dim(`\n These skills are built-in but their env vars aren't set:`));
679
- for (const m of envGaps) {
680
- console.error(chalk.dim(` - ${m.id}: ${m.missingEnv.join(", ")}`));
681
- }
682
- }
683
735
  console.error(chalk.dim(`\n Run \`sweny skill list\` to see what's available.\n`));
684
736
  process.exit(1);
685
737
  return;
@@ -698,10 +750,12 @@ export async function workflowRunAction(file, options) {
698
750
  credentials: buildCredentialMap(),
699
751
  userMcpServers: Object.keys(config.mcpServers).length > 0 ? config.mcpServers : undefined,
700
752
  });
753
+ // Raw [info]/[debug] lines are verbose-only; warnings are rendered (#383).
754
+ const runLogger = createRunLogger({ verbose: Boolean(options.verbose), tty: isTTY });
701
755
  const claude = new ClaudeClient({
702
756
  maxTurns: config.maxInvestigateTurns || 50,
703
757
  cwd: process.cwd(),
704
- logger: consoleLogger,
758
+ logger: runLogger,
705
759
  mcpServers,
706
760
  model: workflow.model,
707
761
  });
@@ -733,6 +787,7 @@ export async function workflowRunAction(file, options) {
733
787
  else {
734
788
  process.stderr.write(` ${icon} ${event.node} ${elapsed}\n`);
735
789
  }
790
+ runLogger.flush();
736
791
  break;
737
792
  }
738
793
  case "workflow:end":
@@ -775,7 +830,7 @@ export async function workflowRunAction(file, options) {
775
830
  process.exit(1);
776
831
  return;
777
832
  }
778
- workflowInput = validated.value;
833
+ workflowInput = mergeDryRunIntoInput(validated.value, config.dryRun);
779
834
  }
780
835
  else {
781
836
  workflowInput = {
@@ -827,18 +882,31 @@ export async function workflowRunAction(file, options) {
827
882
  console.log(c.subtle(` cloud: ${wfCloudHandle.dashboardUrl}`));
828
883
  }
829
884
  const observer = composeObservers(wfProgressObserver, options.verbose ? createVerboseToolObserver() : undefined, options.stream ? createStreamObserver() : undefined, createCloudStreamObserver(config, wfCloudHandle));
885
+ // #325: this is the primary run path (GitHub Action / CLI / MCP all land
886
+ // here), and previously never passed signal/timeoutMs/max_steps to
887
+ // execute() at all: a wedged node (hung tool call, runaway model turn)
888
+ // could hang the run indefinitely despite the abort/timeout plumbing
889
+ // existing in the executor. --timeout is a whole-run wall-clock budget,
890
+ // enforced by runWithWallClockBudget (shared with the .sweny/e2e/ batch
891
+ // runner in e2e.ts). Absent = DEFAULT_WORKFLOW_TIMEOUT_MS (60 min);
892
+ // `--timeout 0` = no wall-clock budget; junk is rejected above.
893
+ // --max-steps overrides the executor's own DEFAULT_MAX_STEPS when set.
894
+ const wfTimeoutMs = budget.timeoutMs;
895
+ const wfMaxSteps = budget.maxSteps;
830
896
  try {
831
- const { results, trace } = await execute(workflow, workflowInput, {
897
+ const { results, trace } = await runWithWallClockBudget((signal) => execute(workflow, workflowInput, {
832
898
  skills,
833
899
  claude,
834
900
  observer,
835
- logger: consoleLogger,
901
+ logger: runLogger,
836
902
  cwd: process.cwd(),
837
903
  env: process.env,
838
904
  fetchAuth: config.fetchAuth,
839
905
  offline: config.offline,
840
906
  fileRoot: config.fileRoot || undefined,
841
- });
907
+ signal,
908
+ max_steps: wfMaxSteps,
909
+ }), wfTimeoutMs, `Workflow ${workflow.name}`);
842
910
  const wfDurationMs = Date.now() - runStart;
843
911
  const wfHasFailed = [...results.values()].some((r) => r.status === "failed");
844
912
  // Close the cloud lifecycle session BEFORE the JSON early-exit so
@@ -870,16 +938,37 @@ export async function workflowRunAction(file, options) {
870
938
  catch {
871
939
  // silent
872
940
  }
941
+ // Run receipt (metadata only) + optional $GITHUB_STEP_SUMMARY.
942
+ runLogger.flush();
943
+ const receipt = summarizeRun(results, wfDurationMs);
944
+ writeStepSummary(workflow, results, receipt, trace);
873
945
  if (wfHasFailed) {
874
- console.error(chalk.red(` Workflow failed\n`));
946
+ console.error(` ${renderReceiptLine(receipt, isTTY)}\n`);
875
947
  process.exit(1);
876
948
  return;
877
949
  }
878
- console.log(chalk.green(` Workflow completed\n`));
950
+ console.log(` ${renderReceiptLine(receipt, isTTY)}\n`);
879
951
  process.exit(0);
880
952
  }
881
953
  catch (err) {
882
- console.error(chalk.red(`\n Error: ${err instanceof Error ? err.message : String(err)}\n`));
954
+ const crashMsg = err instanceof Error ? err.message : String(err);
955
+ console.error(chalk.red(`\n Error: ${crashMsg}\n`));
956
+ runLogger.flush();
957
+ console.error(` ${renderReceiptLine(summarizeRun(new Map(), Date.now() - runStart, true), isTTY)}\n`);
958
+ // Finalize the cloud run as failed (covers thrown errors, incl.
959
+ // RouteEvaluationError). Without this a crashed workflow run stays
960
+ // "running" in cloud forever.
961
+ try {
962
+ // PRIVACY: pass the raw error, not crashMsg. finishCloudLifecycle reduces it
963
+ // to error.name + a 200-char message: thrown messages can embed agent/LLM
964
+ // prose or log text, and the cloud gets metadata only. It also sends at
965
+ // most one finish per run, so a throw after the in-try finish cannot
966
+ // overwrite the real result with a second "failed".
967
+ await finishCloudLifecycle(config, wfCloudHandle, new Map(), Date.now() - runStart, "failed", err);
968
+ }
969
+ catch {
970
+ // silent
971
+ }
883
972
  process.exit(1);
884
973
  }
885
974
  }
@@ -904,12 +993,21 @@ export function workflowExportAction(name) {
904
993
  }
905
994
  export function workflowValidateAction(file, options) {
906
995
  const result = loadAndValidateWorkflow(file);
996
+ // Missing skill env is a warning here, not a failure: `run` is where it blocks.
997
+ const warnings = result.ok
998
+ ? skillEnvWarnings(result.workflow, process.env, builtinSkills.concat(configuredSkills(process.env, process.cwd())))
999
+ : [];
907
1000
  if (options.json) {
908
1001
  const errs = result.ok ? [] : result.errors;
909
- process.stdout.write(JSON.stringify({ valid: result.ok, errors: errs }, null, 2) + "\n");
1002
+ const payload = { valid: result.ok, errors: errs };
1003
+ if (warnings.length > 0)
1004
+ payload.warnings = warnings;
1005
+ process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
910
1006
  }
911
1007
  else if (result.ok) {
912
1008
  console.log(chalk.green(` \u2713 ${file} is valid`));
1009
+ for (const w of warnings)
1010
+ console.error(chalk.yellow(` \u26A0 ${w}`));
913
1011
  }
914
1012
  else {
915
1013
  console.error(chalk.red(` \u2717 ${file} has ${result.errors.length} validation error${result.errors.length > 1 ? "s" : ""}:`));
@@ -924,19 +1022,13 @@ workflowCmd
924
1022
  .description("Validate a workflow YAML or JSON file")
925
1023
  .option("--json", "Output result as JSON")
926
1024
  .action(workflowValidateAction);
927
- workflowCmd
1025
+ const workflowRunCmd = workflowCmd
928
1026
  .command("run [file]")
929
- .description("Run a workflow from a YAML or JSON file. With no file, batch-runs every workflow in .sweny/e2e/ (lists and confirms first; use --yes to skip the prompt).")
930
- .option("--timeout <ms>", "Per-workflow timeout in ms for batch runs (default: 900000 = 15 min)")
931
- .option("-y, --yes", "Skip the batch confirmation prompt (for CI)")
932
- .option("--dry-run", "Execute until the first conditional routing decision, then stop (no side effects past that point). NOTE: behavior changed in this release — previously --dry-run only printed the node list. For that behavior use --list-nodes.")
933
- .option("--list-nodes", "Validate, print nodes/skills, and exit without running. (Replaces the pre-Fix-#6 --dry-run behavior.)")
934
- .option("--json", "Output result as JSON on stdout; suppress progress output")
935
- .option("--stream", "Stream NDJSON events to stdout (for Studio / automation)")
936
- .option("--verbose", "Print each tool call's input and output inline (human-readable, truncated). Use --stream for full untruncated NDJSON.")
937
- .option("--mermaid", "Output a Mermaid diagram with execution state after run")
938
- .option("--input <json>", "JSON string of input data to pass to the workflow")
1027
+ .description(WORKFLOW_RUN_DESCRIPTION)
939
1028
  .action(workflowRunAction);
1029
+ for (const [flags, description] of WORKFLOW_RUN_OPTIONS) {
1030
+ workflowRunCmd.option(flags, description);
1031
+ }
940
1032
  workflowCmd
941
1033
  .command("diagram <file>")
942
1034
  .description("Render a workflow as a Mermaid diagram (raw .mmd by default; .md output auto-fences)")
@@ -24,7 +24,9 @@ export interface FetchedWorkflow {
24
24
  yaml: string;
25
25
  }
26
26
  export declare function fetchMarketplaceWorkflow(id: string): Promise<FetchedWorkflow>;
27
- export declare function fetchMarketplaceIndex(): Promise<MarketplaceEntry[]>;
27
+ export declare function fetchMarketplaceIndex(opts?: {
28
+ timeoutMs?: number;
29
+ }): Promise<MarketplaceEntry[]>;
28
30
  export interface ProviderMismatch {
29
31
  category: SkillCategory;
30
32
  configKey: string;
@@ -46,11 +46,11 @@ export async function fetchMarketplaceWorkflow(id) {
46
46
  }
47
47
  return { id, yaml: await res.text() };
48
48
  }
49
- export async function fetchMarketplaceIndex() {
49
+ export async function fetchMarketplaceIndex(opts = {}) {
50
50
  const url = `${MARKETPLACE_RAW_BASE}/index.json`;
51
51
  let res;
52
52
  try {
53
- res = await fetch(url);
53
+ res = await fetch(url, opts.timeoutMs ? { signal: AbortSignal.timeout(opts.timeoutMs) } : undefined);
54
54
  }
55
55
  catch {
56
56
  const err = new Error(`Could not reach github.com`);
package/dist/cli/new.d.ts CHANGED
@@ -13,6 +13,8 @@ export interface Credential {
13
13
  hint?: string;
14
14
  url?: string;
15
15
  default?: string;
16
+ /** Written commented-out in .env (not needed in every setup). */
17
+ optional?: boolean;
16
18
  }
17
19
  export interface InitSelections {
18
20
  sourceControl: string;
@@ -118,6 +120,13 @@ export declare function writeWorkflowFile(cwd: string, id: string, yaml: string,
118
120
  exists?: boolean;
119
121
  path: string;
120
122
  };
123
+ /**
124
+ * Make sure `.env` is gitignored: create `.gitignore` if missing, append
125
+ * `.env` if absent. Returns what happened.
126
+ */
127
+ export declare function ensureGitignoreEnv(cwd: string): "created" | "appended" | "present";
128
+ /** Usage printed (exit 2) when `sweny new` has no terminal and no --yes. */
129
+ export declare function nonInteractiveUsage(): string;
121
130
  /**
122
131
  * Interactive workflow creation wizard — workflow-first approach.
123
132
  *
@@ -141,4 +150,10 @@ export declare function writeWorkflowFile(cwd: string, id: string, yaml: string,
141
150
  export declare function runNew(options?: {
142
151
  marketplaceId?: string;
143
152
  skipIntro?: boolean;
153
+ /** Built-in template id: skip the picker. */
154
+ template?: string;
155
+ /** Skip every confirmation prompt (never overwrites existing files). */
156
+ yes?: boolean;
157
+ /** Internal: the id came from the marketplace picker, skip the built-in shortcut. */
158
+ forceMarketplace?: boolean;
144
159
  }): Promise<void>;