@sweny-ai/core 0.1.102 → 0.1.104

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/check.js CHANGED
@@ -1,3 +1,8 @@
1
+ /**
2
+ * Per-check network timeout. A hung connect must not make `sweny check` hang
3
+ * indefinitely — the whole point of the command is fast diagnostics.
4
+ */
5
+ const CHECK_TIMEOUT_MS = 5000;
1
6
  /**
2
7
  * Run lightweight connectivity checks for all configured providers.
3
8
  * Uses raw fetch — does NOT import provider packages.
@@ -7,17 +12,24 @@ export async function checkProviderConnectivity(config) {
7
12
  const githubChecked = { done: false }; // avoid double-checking same token
8
13
  // ── Coding agent ────────────────────────────────────────────────────────────
9
14
  if (config.codingAgentProvider === "claude") {
10
- if (!config.anthropicApiKey && !config.claudeOauthToken) {
11
- results.push({ name: "Anthropic (claude agent)", status: "skip", detail: "No key configured" });
15
+ const mode = resolveCheckAuthMode(config);
16
+ const base = config.anthropicBaseUrl;
17
+ if (mode === "none") {
18
+ results.push({ name: "Anthropic (claude agent)", status: "skip", detail: "No credential configured" });
19
+ }
20
+ else if (base) {
21
+ // Gateway: probe the gateway, never real Anthropic with a gateway-bound key.
22
+ results.push(await checkAnthropicGateway(base, config, mode));
12
23
  }
13
- else if (config.anthropicApiKey) {
24
+ else if (mode === "api-key") {
14
25
  results.push(await checkAnthropic(config.anthropicApiKey));
15
26
  }
16
27
  else {
28
+ // oauth or auth-token, no base URL: nothing cheap to probe, report the mode.
17
29
  results.push({
18
30
  name: "Anthropic (claude agent)",
19
31
  status: "skip",
20
- detail: "CLAUDE_CODE_OAUTH_TOKEN set — skipping API check",
32
+ detail: `auth mode: ${mode}, skipping upstream API check`,
21
33
  });
22
34
  }
23
35
  }
@@ -105,6 +117,7 @@ async function checkAnthropic(apiKey) {
105
117
  try {
106
118
  const res = await fetch("https://api.anthropic.com/v1/models", {
107
119
  headers: { "x-api-key": apiKey, "anthropic-version": "2023-06-01" },
120
+ signal: AbortSignal.timeout(CHECK_TIMEOUT_MS),
108
121
  });
109
122
  if (res.status === 200) {
110
123
  return { name, status: "ok", detail: "API key is valid" };
@@ -122,15 +135,79 @@ async function checkAnthropic(apiKey) {
122
135
  return { name, status: "fail", detail: networkErrorMessage(err) };
123
136
  }
124
137
  }
125
- async function checkDatadog(creds) {
138
+ export function resolveCheckAuthMode(config) {
139
+ const hasKey = !!config.anthropicApiKey;
140
+ const hasBearer = !!config.anthropicAuthToken;
141
+ const hasOauth = !!config.claudeOauthToken;
142
+ if (config.swenyAuth === "oauth")
143
+ return hasOauth ? "oauth" : "none";
144
+ if (config.swenyAuth === "api-key")
145
+ return hasKey ? "api-key" : hasBearer ? "auth-token" : "none";
146
+ // auto: OAuth wins when present (today's protective behavior)
147
+ if (hasOauth)
148
+ return "oauth";
149
+ if (hasKey)
150
+ return "api-key";
151
+ if (hasBearer)
152
+ return "auth-token";
153
+ return "none";
154
+ }
155
+ /** Redact a URL to scheme + host so userinfo / query (which can carry a key) never logs. */
156
+ export function redactUrl(raw) {
157
+ try {
158
+ const u = new URL(raw);
159
+ return `${u.protocol}//${u.host}`;
160
+ }
161
+ catch {
162
+ return "(invalid URL)";
163
+ }
164
+ }
165
+ export async function checkAnthropicGateway(base, config, mode) {
166
+ const name = "Anthropic (gateway)";
167
+ const safeBase = redactUrl(base);
168
+ const headers = { "anthropic-version": "2023-06-01" };
169
+ if (mode === "api-key") {
170
+ headers["x-api-key"] = config.anthropicApiKey;
171
+ }
172
+ else {
173
+ // auth-token or oauth against the gateway
174
+ headers.Authorization = `Bearer ${config.anthropicAuthToken || config.claudeOauthToken}`;
175
+ }
176
+ try {
177
+ const url = base.replace(/\/+$/, "") + "/v1/models";
178
+ const res = await fetch(url, { headers, signal: AbortSignal.timeout(CHECK_TIMEOUT_MS) });
179
+ // Gateways vary on whether /v1/models exists; reachable + non-auth-error is good enough.
180
+ if (res.ok || res.status === 404) {
181
+ return { name, status: "ok", detail: `gateway reachable (${safeBase}), auth mode: ${mode}` };
182
+ }
183
+ if (res.status === 401 || res.status === 403) {
184
+ return { name, status: "fail", detail: `${res.status} from gateway ${safeBase}, check gateway credentials` };
185
+ }
186
+ return { name, status: "fail", detail: `Unexpected HTTP ${res.status} from gateway ${safeBase}` };
187
+ }
188
+ catch (err) {
189
+ return { name, status: "fail", detail: networkErrorMessage(err) };
190
+ }
191
+ }
192
+ export async function checkDatadog(creds) {
126
193
  const name = "Observability (datadog)";
127
194
  const { apiKey, appKey, site = "datadoghq.com" } = creds;
128
195
  if (!apiKey || !appKey) {
129
196
  return { name, status: "skip", detail: "DD_API_KEY or DD_APP_KEY not configured" };
130
197
  }
198
+ // `site` (DD_SITE) is interpolated into the request URL — reject anything
199
+ // outside a hostname allowlist before it can smuggle a path/query/host.
200
+ if (!/^[a-z0-9.-]+$/.test(site)) {
201
+ return {
202
+ name,
203
+ status: "fail",
204
+ detail: `Invalid DD_SITE "${site}" — expected a hostname like datadoghq.com or datadoghq.eu`,
205
+ };
206
+ }
131
207
  try {
132
208
  const res = await fetch(`https://api.${site}/api/v2/validate`, {
133
209
  headers: { "DD-API-KEY": apiKey, "DD-APPLICATION-KEY": appKey },
210
+ signal: AbortSignal.timeout(CHECK_TIMEOUT_MS),
134
211
  });
135
212
  if (res.status === 200) {
136
213
  return { name, status: "ok", detail: "API key is valid" };
@@ -157,6 +234,7 @@ async function checkSentry(creds) {
157
234
  try {
158
235
  const res = await fetch("https://sentry.io/api/0/", {
159
236
  headers: { Authorization: `Bearer ${authToken}` },
237
+ signal: AbortSignal.timeout(CHECK_TIMEOUT_MS),
160
238
  });
161
239
  if (res.status === 200) {
162
240
  return { name, status: "ok", detail: "Auth token is valid" };
@@ -184,6 +262,7 @@ async function checkLinear(apiKey) {
184
262
  method: "POST",
185
263
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
186
264
  body: JSON.stringify({ query: "{ viewer { id name } }" }),
265
+ signal: AbortSignal.timeout(CHECK_TIMEOUT_MS),
187
266
  });
188
267
  if (res.status === 200) {
189
268
  const body = (await res.json());
@@ -213,6 +292,7 @@ async function checkGitHub(token, name) {
213
292
  try {
214
293
  const res = await fetch("https://api.github.com/user", {
215
294
  headers: { Authorization: `token ${token}`, "User-Agent": "sweny-cli" },
295
+ signal: AbortSignal.timeout(CHECK_TIMEOUT_MS),
216
296
  });
217
297
  if (res.status === 200) {
218
298
  const body = (await res.json());
@@ -232,6 +312,12 @@ async function checkGitHub(token, name) {
232
312
  }
233
313
  }
234
314
  function networkErrorMessage(err) {
315
+ // AbortSignal.timeout fires a DOMException with name "TimeoutError"; an
316
+ // explicit abort surfaces as "AbortError". Either means we gave up waiting.
317
+ const errName = err instanceof Error ? err.name : "";
318
+ if (errName === "TimeoutError" || errName === "AbortError") {
319
+ return `Connection timed out after ${CHECK_TIMEOUT_MS}ms — host unreachable or too slow`;
320
+ }
235
321
  const msg = err instanceof Error ? err.message : String(err);
236
322
  if (/ENOTFOUND|ETIMEDOUT|ECONNREFUSED/i.test(msg)) {
237
323
  return `Network error — check your internet connection (${msg})`;
@@ -52,7 +52,11 @@ export function loadConfigFile(cwd = process.cwd()) {
52
52
  try {
53
53
  raw = parseYaml(content);
54
54
  }
55
- catch {
55
+ catch (err) {
56
+ // A malformed config file must not silently fall back to defaults — the
57
+ // user would get unexpected behavior with no clue their file was dropped.
58
+ const reason = err instanceof Error ? err.message : String(err);
59
+ console.warn(`⚠ Ignoring malformed config file ${filePath}: ${reason}`);
56
60
  return {};
57
61
  }
58
62
  if (!raw || typeof raw !== "object")
@@ -4,7 +4,12 @@ import type { FileConfig } from "./config-file.js";
4
4
  export interface CliConfig {
5
5
  codingAgentProvider: string;
6
6
  anthropicApiKey: string;
7
+ anthropicAuthToken: string;
7
8
  claudeOauthToken: string;
9
+ /** Anthropic-compatible base URL (e.g. a LiteLLM gateway). */
10
+ anthropicBaseUrl: string;
11
+ /** Auth precedence mode: auto (default), api-key, or oauth. */
12
+ swenyAuth: "auto" | "api-key" | "oauth";
8
13
  openaiApiKey: string;
9
14
  geminiApiKey: string;
10
15
  observabilityProviders: string[];
@@ -82,6 +87,12 @@ export declare function parseCliInputs(options: Record<string, unknown>, fileCon
82
87
  */
83
88
  export declare const SUPPORTED_WORKSPACE_TOOLS: Set<string>;
84
89
  export declare function validateInputs(config: CliConfig): string[];
90
+ /**
91
+ * Normalize a SWENY_AUTH value. Unknown / empty values fall back to "auto"
92
+ * (the safe, billing-protective default). Never throws: an env typo must not
93
+ * crash a run, and `auto` only ever strips a key to protect a subscription.
94
+ */
95
+ export declare function normalizeSwenyAuth(raw: unknown): "auto" | "api-key" | "oauth";
85
96
  /**
86
97
  * Parse a value as a positive integer (>= 1).
87
98
  *
@@ -59,7 +59,7 @@ export function registerTriageCommand(program) {
59
59
  .option("--verbose", "Print each tool call's input and output inline (human-readable, truncated). Use --stream for full untruncated NDJSON.", false)
60
60
  .option("--bell", "Ring terminal bell on completion", false)
61
61
  .option("--cache-dir <path>", "Step cache directory (default: .sweny/cache)")
62
- .option("--cache-ttl <seconds>", "Cache TTL in seconds, 0 = infinite (default: 86400)")
62
+ .option("--cache-ttl <seconds>", "Cache TTL in seconds, 1 to 31536000 (default: 86400)")
63
63
  .option("--no-cache", "Disable step cache")
64
64
  .option("--output-dir <path>", "Output directory for file providers (default: .sweny/output)")
65
65
  .option("--workspace-tools <tools>", "Comma-separated workspace tool integrations to enable (slack, notion, pagerduty, monday)")
@@ -100,7 +100,11 @@ export function parseCliInputs(options, fileConfig = {}) {
100
100
  codingAgentProvider: options.agent || options.codingAgentProvider || f("coding-agent-provider") || "claude",
101
101
  // Secrets: env only — never from config file
102
102
  anthropicApiKey: env.ANTHROPIC_API_KEY || "",
103
+ anthropicAuthToken: env.ANTHROPIC_AUTH_TOKEN || "",
103
104
  claudeOauthToken: env.CLAUDE_CODE_OAUTH_TOKEN || "",
105
+ // Non-secret config: env or .sweny config file (mirrors gitlabBaseUrl)
106
+ anthropicBaseUrl: env.ANTHROPIC_BASE_URL || f("anthropic-base-url") || "",
107
+ swenyAuth: normalizeSwenyAuth(env.SWENY_AUTH || f("sweny-auth")),
104
108
  openaiApiKey: env.OPENAI_API_KEY || "",
105
109
  geminiApiKey: env.GEMINI_API_KEY || env.GOOGLE_API_KEY || "",
106
110
  observabilityProviders: obsProviders,
@@ -431,6 +435,9 @@ export function validateInputs(config) {
431
435
  // bounds check let junk through).
432
436
  validateIntegerBound(errors, "--max-investigate-turns", config.maxInvestigateTurns, 1, 500);
433
437
  validateIntegerBound(errors, "--max-implement-turns", config.maxImplementTurns, 1, 500);
438
+ // cacheTtl is parsed with parsePositiveInt (junk → NaN); bound it so a
439
+ // malformed --cache-ttl / cache-ttl: surfaces a field error instead of NaN.
440
+ validateIntegerBound(errors, "--cache-ttl", config.cacheTtl, 1, 31_536_000);
434
441
  return errors;
435
442
  }
436
443
  function validateIntegerBound(errors, flag, value, min, max) {
@@ -442,6 +449,17 @@ function validateIntegerBound(errors, flag, value, min, max) {
442
449
  errors.push(`${flag} must be between ${min} and ${max}`);
443
450
  }
444
451
  }
452
+ /**
453
+ * Normalize a SWENY_AUTH value. Unknown / empty values fall back to "auto"
454
+ * (the safe, billing-protective default). Never throws: an env typo must not
455
+ * crash a run, and `auto` only ever strips a key to protect a subscription.
456
+ */
457
+ export function normalizeSwenyAuth(raw) {
458
+ const v = String(raw ?? "")
459
+ .trim()
460
+ .toLowerCase();
461
+ return v === "api-key" || v === "oauth" ? v : "auto";
462
+ }
445
463
  /**
446
464
  * Parse a value as a positive integer (>= 1).
447
465
  *
package/dist/cli/e2e.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * sweny e2e — Generate and run agent-driven end-to-end browser tests.
2
+ * sweny e2e (now `sweny new e2e` + `sweny workflow run`) — generate and run agent-driven end-to-end browser tests.
3
3
  *
4
4
  * Pure functions for workflow generation + thin @clack/prompts interactive layer.
5
5
  * Tests cover the pure functions; the interactive wizard is thin glue.
@@ -78,7 +78,20 @@ export declare function runE2eInit(options?: E2eInitOptions): Promise<void>;
78
78
  export interface E2eRunOptions {
79
79
  file?: string;
80
80
  timeout?: number;
81
+ /** Skip the batch confirmation prompt (CI). Only applies to no-file batch runs. */
82
+ yes?: boolean;
81
83
  }
84
+ /**
85
+ * Policy for a no-file batch run. Pure so it can be unit-tested without
86
+ * touching the filesystem, stdin, or the engine.
87
+ * - `yes` → run without asking (CI).
88
+ * - non-interactive without `yes` → refuse (don't fire every workflow by accident).
89
+ * - interactive → ask for confirmation.
90
+ */
91
+ export declare function batchRunDecision(opts: {
92
+ yes?: boolean;
93
+ isTTY: boolean;
94
+ }): "run" | "confirm" | "refuse-noninteractive";
82
95
  /**
83
96
  * Load and execute e2e workflow files from .sweny/e2e/.
84
97
  */
package/dist/cli/e2e.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * sweny e2e — Generate and run agent-driven end-to-end browser tests.
2
+ * sweny e2e (now `sweny new e2e` + `sweny workflow run`) — generate and run agent-driven end-to-end browser tests.
3
3
  *
4
4
  * Pure functions for workflow generation + thin @clack/prompts interactive layer.
5
5
  * Tests cover the pure functions; the interactive wizard is thin glue.
@@ -501,7 +501,7 @@ const CLEANUP_ENV_VARS = {
501
501
  export function buildE2eEnvTemplate(selections) {
502
502
  const lines = [];
503
503
  lines.push("# E2E Testing — SWEny");
504
- lines.push("# Fill in values, then run: sweny e2e run");
504
+ lines.push("# Fill in values, then run: sweny workflow run");
505
505
  lines.push("");
506
506
  lines.push(`E2E_BASE_URL=${selections.baseUrl}`);
507
507
  lines.push("");
@@ -753,10 +753,10 @@ export async function runE2eInit(options = {}) {
753
753
  console.log(chalk.dim(" 1. Fill in your .env values"));
754
754
  if (selections.flows.some((f) => AUTH_REQUIRED_FLOWS.includes(f.type) || f.type === "login")) {
755
755
  console.log(chalk.dim(" 2. Set E2E_EMAIL and E2E_PASSWORD to a test account"));
756
- console.log(chalk.dim(` 3. Run: ${chalk.cyan("sweny e2e run")}\n`));
756
+ console.log(chalk.dim(` 3. Run: ${chalk.cyan("sweny workflow run")}\n`));
757
757
  }
758
758
  else {
759
- console.log(chalk.dim(` 2. Run: ${chalk.cyan("sweny e2e run")}\n`));
759
+ console.log(chalk.dim(` 2. Run: ${chalk.cyan("sweny workflow run")}\n`));
760
760
  }
761
761
  }
762
762
  // ── Timeout helper ─────────────────────────────────────────────────────
@@ -769,6 +769,20 @@ function withTimeout(promise, ms, label) {
769
769
  .finally(() => clearTimeout(timer));
770
770
  });
771
771
  }
772
+ /**
773
+ * Policy for a no-file batch run. Pure so it can be unit-tested without
774
+ * touching the filesystem, stdin, or the engine.
775
+ * - `yes` → run without asking (CI).
776
+ * - non-interactive without `yes` → refuse (don't fire every workflow by accident).
777
+ * - interactive → ask for confirmation.
778
+ */
779
+ export function batchRunDecision(opts) {
780
+ if (opts.yes)
781
+ return "run";
782
+ if (!opts.isTTY)
783
+ return "refuse-noninteractive";
784
+ return "confirm";
785
+ }
772
786
  /**
773
787
  * Load and execute e2e workflow files from .sweny/e2e/.
774
788
  */
@@ -789,7 +803,7 @@ export async function runE2eRun(options) {
789
803
  const e2eDir = path.join(cwd, ".sweny", "e2e");
790
804
  if (!fs.existsSync(e2eDir)) {
791
805
  console.error(chalk.red(" No .sweny/e2e/ directory found."));
792
- console.error(chalk.dim(" Run 'sweny e2e init' to set up e2e testing."));
806
+ console.error(chalk.dim(" Run 'sweny new e2e' to set up e2e testing."));
793
807
  process.exit(1);
794
808
  }
795
809
  files = fs
@@ -799,9 +813,29 @@ export async function runE2eRun(options) {
799
813
  .map((f) => path.join(e2eDir, f));
800
814
  if (files.length === 0) {
801
815
  console.error(chalk.red(" No workflow files found in .sweny/e2e/"));
802
- console.error(chalk.dim(" Run 'sweny e2e init' to set up e2e testing."));
816
+ console.error(chalk.dim(" Run 'sweny new e2e' to set up e2e testing."));
803
817
  process.exit(1);
804
818
  }
819
+ // Batch run (no explicit file): list what will run and confirm, so an
820
+ // accidental `sweny workflow run` doesn't fire every e2e workflow.
821
+ // --yes bypasses for CI.
822
+ const decision = batchRunDecision({ yes: options.yes, isTTY: process.stdin.isTTY ?? false });
823
+ if (decision !== "run") {
824
+ console.log(chalk.bold(`\n About to run ${files.length} e2e workflow(s) from .sweny/e2e/:`));
825
+ for (const f of files)
826
+ console.log(chalk.dim(` • ${path.basename(f)}`));
827
+ console.log("");
828
+ if (decision === "refuse-noninteractive") {
829
+ console.error(chalk.red(" Refusing to batch-run without confirmation in a non-interactive shell."));
830
+ console.error(chalk.dim(" Re-run with --yes to proceed (or pass a specific file)."));
831
+ process.exit(1);
832
+ }
833
+ const ok = await p.confirm({ message: `Run all ${files.length} e2e workflow(s)?` });
834
+ if (p.isCancel(ok) || !ok) {
835
+ p.cancel("Cancelled.");
836
+ process.exit(0);
837
+ }
838
+ }
805
839
  }
806
840
  // 2. Build template vars
807
841
  const vars = buildE2eVars(process.env);
@@ -1,11 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  import type { Workflow } from "../types.js";
3
3
  export declare function loadWorkflowFile(filePath: string, knownSkills?: Set<string>): Workflow;
4
- export declare function workflowRunAction(file: string, options: Record<string, unknown> & {
4
+ export declare function workflowRunAction(file: string | undefined, options: Record<string, unknown> & {
5
5
  json?: boolean;
6
6
  stream?: boolean;
7
7
  mermaid?: boolean;
8
8
  verbose?: boolean;
9
+ timeout?: string;
10
+ yes?: boolean;
9
11
  }): Promise<void>;
10
12
  export declare function workflowExportAction(name: string): void;
11
13
  export declare function workflowValidateAction(file: string, options: {
package/dist/cli/main.js CHANGED
@@ -27,7 +27,7 @@ import { buildCredentialMap } from "./credentials.js";
27
27
  import { runNew } from "./new.js";
28
28
  import { runE2eRun } from "./e2e.js";
29
29
  import { createVerboseToolObserver } from "./verbose-observer.js";
30
- import { registerTriageCommand, registerImplementCommand, parseCliInputs, validateInputs, validateWarnings, } from "./config.js";
30
+ import { registerTriageCommand, registerImplementCommand, parseCliInputs, validateInputs, validateWarnings, parsePositiveInt, } from "./config.js";
31
31
  import { c, formatBanner, getStepDetails, formatStepLine, formatDagResultHuman, formatDagResultMarkdown, formatResultJson, formatValidationErrors, formatCrashError, formatCheckResults, } from "./output.js";
32
32
  import { checkProviderConnectivity } from "./check.js";
33
33
  import { registerSetupCommand } from "./setup.js";
@@ -75,18 +75,6 @@ program
75
75
  .action(async (id) => {
76
76
  await runNew(id ? { marketplaceId: id } : undefined);
77
77
  });
78
- // ── sweny e2e ────────────────────────────────────────────────────────
79
- const e2eCmd = program.command("e2e").description("End-to-end browser testing");
80
- e2eCmd
81
- .command("run [file]")
82
- .description("Run e2e workflow files from .sweny/e2e/")
83
- .option("--timeout <ms>", "Timeout per workflow in milliseconds (default: 900000 = 15 min)")
84
- .action(async (file, options) => {
85
- await runE2eRun({
86
- file,
87
- timeout: options.timeout ? parseInt(options.timeout, 10) : undefined,
88
- });
89
- });
90
78
  // ── sweny check ───────────────────────────────────────────────────────
91
79
  program
92
80
  .command("check")
@@ -462,7 +450,9 @@ implementCmd.action(async (issueId, options) => {
462
450
  sourceControlProvider: options.sourceControlProvider || fileConfig["source-control-provider"] || "github",
463
451
  codingAgentProvider: options.codingAgentProvider || fileConfig["coding-agent-provider"] || "claude",
464
452
  dryRun: Boolean(options.dryRun),
465
- maxImplementTurns: parseInt(String(options.maxImplementTurns || fileConfig["max-implement-turns"] || "40"), 10),
453
+ // parsePositiveInt mirrors the triage path: junk → NaN, which
454
+ // validateInputs rejects via validateIntegerBound below.
455
+ maxImplementTurns: parsePositiveInt(options.maxImplementTurns ?? fileConfig["max-implement-turns"], 40),
466
456
  baseBranch: options.baseBranch || fileConfig["base-branch"] || "main",
467
457
  repository: options.repository || process.env.GITHUB_REPOSITORY || "",
468
458
  outputDir: options.outputDir ||
@@ -470,6 +460,14 @@ implementCmd.action(async (issueId, options) => {
470
460
  fileConfig["output-dir"] ||
471
461
  ".sweny/output",
472
462
  };
463
+ // Validate (parity with triage): surface the curated "Missing: ..." errors
464
+ // and reject malformed integer flags before any execution.
465
+ const implErrors = validateInputs(config);
466
+ if (implErrors.length > 0) {
467
+ console.error(formatValidationErrors(implErrors));
468
+ console.error(c.subtle("\n Run sweny implement --help for usage information.\n"));
469
+ process.exit(1);
470
+ }
473
471
  const implementSkillDiscovery = configuredSkillsWithDiagnostics(process.env, process.cwd());
474
472
  for (const w of implementSkillDiscovery.warnings) {
475
473
  console.error(chalk.yellow(` ⚠ ${w.message}`));
@@ -611,6 +609,16 @@ export function loadWorkflowFile(filePath, knownSkills) {
611
609
  return result.workflow;
612
610
  }
613
611
  export async function workflowRunAction(file, options) {
612
+ // No file given → batch-run the e2e workflows in .sweny/e2e/. This is the
613
+ // home for what used to be `sweny e2e run`; it lists what will run and
614
+ // confirms first (bypass with --yes).
615
+ if (!file) {
616
+ await runE2eRun({
617
+ timeout: options.timeout ? parseInt(options.timeout, 10) : undefined,
618
+ yes: Boolean(options.yes),
619
+ });
620
+ return;
621
+ }
614
622
  // Discover skills first so the loader can flag UNKNOWN_SKILL at parse
615
623
  // time. validateWorkflowSkills below still runs for richer category /
616
624
  // env-var diagnostics, but the loader's structural check fires earlier
@@ -912,8 +920,10 @@ workflowCmd
912
920
  .option("--json", "Output result as JSON")
913
921
  .action(workflowValidateAction);
914
922
  workflowCmd
915
- .command("run <file>")
916
- .description("Run a workflow from a YAML or JSON file")
923
+ .command("run [file]")
924
+ .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).")
925
+ .option("--timeout <ms>", "Per-workflow timeout in ms for batch runs (default: 900000 = 15 min)")
926
+ .option("-y, --yes", "Skip the batch confirmation prompt (for CI)")
917
927
  .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.")
918
928
  .option("--list-nodes", "Validate, print nodes/skills, and exit without running. (Replaces the pre-Fix-#6 --dry-run behavior.)")
919
929
  .option("--json", "Output result as JSON on stdout; suppress progress output")
package/dist/cli/new.js CHANGED
@@ -468,6 +468,13 @@ function cancel() {
468
468
  */
469
469
  export async function runNew(options) {
470
470
  const cwd = process.cwd();
471
+ // ── E2E shortcut: `sweny new e2e` jumps straight into the e2e wizard ──
472
+ // `new` is the single entry for all workflow creation; this is a named
473
+ // shortcut into the same flow the interactive picker reaches via "__e2e".
474
+ if (options?.marketplaceId === "e2e") {
475
+ await runE2eInit({ skipIntro: options.skipIntro });
476
+ return;
477
+ }
471
478
  // ── Marketplace install fast path ───────────────────────────────────
472
479
  if (options?.marketplaceId) {
473
480
  const { installMarketplaceWorkflow } = await import("./marketplace.js");
@@ -115,7 +115,18 @@ export function checkOutputRequired(paths, data) {
115
115
  continue;
116
116
  }
117
117
  if (r.mode === "all") {
118
- if (r.values.length > 0 && !r.values.every((v) => v !== null && v !== undefined)) {
118
+ // An empty wildcard expansion (e.g. `findings[*].severity` when
119
+ // `findings` is `[]`) resolves to zero values. That is a
120
+ // structurally-absent required output, not a satisfied gate: treat it
121
+ // as a failure rather than vacuously passing. A non-wildcard path always
122
+ // yields exactly one value, so this branch only ever sees an empty array
123
+ // for an empty wildcard expansion. Same class as the route-eval
124
+ // `missing` warning: a required field that is absent must not green-light
125
+ // the gate.
126
+ if (r.values.length === 0) {
127
+ failures.push(`'${path}' resolved to no elements (empty wildcard expansion)`);
128
+ }
129
+ else if (!r.values.every((v) => v !== null && v !== undefined)) {
119
130
  failures.push(`'${path}' resolved to null/undefined value(s)`);
120
131
  }
121
132
  }
@@ -32,7 +32,23 @@ export interface ExecuteOptions {
32
32
  fetchAuth?: Record<string, string>;
33
33
  /** When true, URL Sources throw instead of fetching */
34
34
  offline?: boolean;
35
+ /**
36
+ * Workflow-level hard cap on total node executions (the number of times the
37
+ * main loop visits a node). Guards against unbounded cycles, e.g. a back-edge
38
+ * with no `max_iterations` or an LLM route evaluator that keeps choosing the
39
+ * loop-back edge. Defaults to {@link DEFAULT_MAX_STEPS}. When exceeded the
40
+ * executor throws a clear "step budget exceeded" error rather than hanging.
41
+ * Per-edge `max_iterations` behavior is unchanged.
42
+ */
43
+ max_steps?: number;
35
44
  }
45
+ /**
46
+ * Default workflow-level step budget. Counts total node executions across the
47
+ * whole run (loop iterations included). Generous enough for legitimate deep
48
+ * workflows with controlled cycles, tight enough to terminate a runaway loop
49
+ * before it burns meaningful model spend.
50
+ */
51
+ export declare const DEFAULT_MAX_STEPS = 200;
36
52
  /**
37
53
  * Execute a workflow from entry to completion.
38
54
  *