@sweny-ai/core 0.1.123 → 0.1.124

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
package/dist/cli/new.js CHANGED
@@ -355,11 +355,12 @@ export function buildEnvTemplate(credentials) {
355
355
  if (cred.hint) {
356
356
  lines.push(`# ${cred.hint}`);
357
357
  }
358
+ const prefix = cred.optional ? "# " : "";
358
359
  if (cred.default !== undefined) {
359
- lines.push(`${cred.key}=${cred.default}`);
360
+ lines.push(`${prefix}${cred.key}=${cred.default}`);
360
361
  }
361
362
  else {
362
- lines.push(`${cred.key}=`);
363
+ lines.push(`${prefix}${cred.key}=`);
363
364
  }
364
365
  lines.push("");
365
366
  }
@@ -441,6 +442,47 @@ export function writeWorkflowFile(cwd, id, yaml, options) {
441
442
  fs.writeFileSync(target, yaml, "utf-8");
442
443
  return { written: true, path: target };
443
444
  }
445
+ /**
446
+ * Make sure `.env` is gitignored: create `.gitignore` if missing, append
447
+ * `.env` if absent. Returns what happened.
448
+ */
449
+ export function ensureGitignoreEnv(cwd) {
450
+ const gitignorePath = path.join(cwd, ".gitignore");
451
+ if (!fs.existsSync(gitignorePath)) {
452
+ fs.writeFileSync(gitignorePath, ".env\n", "utf-8");
453
+ return "created";
454
+ }
455
+ const content = fs.readFileSync(gitignorePath, "utf-8");
456
+ if (content.split(/\r?\n/).some((line) => line.trim() === ".env"))
457
+ return "present";
458
+ const sep = content.length === 0 || content.endsWith("\n") ? "" : "\n";
459
+ fs.appendFileSync(gitignorePath, `${sep}.env\n`, "utf-8");
460
+ return "appended";
461
+ }
462
+ /** The command to show in next steps: `npx` users have no `sweny` on PATH. */
463
+ function cliCommand() {
464
+ return process.env.npm_command === "exec" ? "npx @sweny-ai/core" : "sweny";
465
+ }
466
+ /** Usage printed (exit 2) when `sweny new` has no terminal and no --yes. */
467
+ export function nonInteractiveUsage() {
468
+ return [
469
+ "sweny new needs a terminal to ask questions. Non-interactive form:",
470
+ "",
471
+ " sweny new --template <id> --yes",
472
+ "",
473
+ `Templates: ${WORKFLOW_TEMPLATES.map((t) => t.id).join(", ")}`,
474
+ `Zero-credential starter: sweny new --template ${WORKFLOW_TEMPLATES[0].id} --yes`,
475
+ ].join("\n");
476
+ }
477
+ /** Credentials to list for a workflow: a no-skill starter only needs Claude auth, which a Claude Code login covers. */
478
+ function credentialsForWorkflow(skillIds, allSkills) {
479
+ const creds = collectCredentialsForSkills(skillIds, allSkills);
480
+ if (skillIds.length > 0)
481
+ return creds;
482
+ return creds.map((c) => c.key === "ANTHROPIC_API_KEY"
483
+ ? { ...c, optional: true, hint: "Not needed if you are logged in to Claude Code (run `claude` once)" }
484
+ : c);
485
+ }
444
486
  // ── Interactive wizard ────────────────────────────────────────────────
445
487
  function cancel() {
446
488
  p.cancel("Setup cancelled.");
@@ -468,6 +510,17 @@ function cancel() {
468
510
  */
469
511
  export async function runNew(options) {
470
512
  const cwd = process.cwd();
513
+ // `sweny new <id>`: a built-in template id wins over a marketplace lookup,
514
+ // so it works with no network. Only other ids go to the marketplace.
515
+ if (options?.marketplaceId && options.marketplaceId !== "e2e" && !options.forceMarketplace) {
516
+ if (WORKFLOW_TEMPLATES.some((t) => t.id === options.marketplaceId)) {
517
+ return runNew({ template: options.marketplaceId, skipIntro: options.skipIntro, yes: options.yes });
518
+ }
519
+ }
520
+ if (options?.template && !WORKFLOW_TEMPLATES.some((t) => t.id === options.template)) {
521
+ p.log.error(`Unknown template "${options.template}". Available: ${WORKFLOW_TEMPLATES.map((t) => t.id).join(", ")}`);
522
+ process.exit(1);
523
+ }
471
524
  // ── E2E shortcut: `sweny new e2e` jumps straight into the e2e wizard ──
472
525
  // `new` is the single entry for all workflow creation; this is a named
473
526
  // shortcut into the same flow the interactive picker reaches via "__e2e".
@@ -491,6 +544,10 @@ export async function runNew(options) {
491
544
  // Check for existing workflow file and prompt before fetching.
492
545
  const existingWorkflowPath = path.join(cwd, ".sweny", "workflows", `${options.marketplaceId}.yml`);
493
546
  let overwrite = false;
547
+ if (fs.existsSync(existingWorkflowPath) && options.yes) {
548
+ p.log.info(`Workflow already exists at ${path.relative(cwd, existingWorkflowPath)}. Not overwritten.`);
549
+ return;
550
+ }
494
551
  if (fs.existsSync(existingWorkflowPath)) {
495
552
  const confirmed = await p.confirm({
496
553
  message: `.sweny/workflows/${options.marketplaceId}.yml already exists. Overwrite?`,
@@ -515,7 +572,8 @@ export async function runNew(options) {
515
572
  });
516
573
  }
517
574
  catch (err) {
518
- p.log.error(err instanceof Error ? err.message : String(err));
575
+ const reason = err instanceof Error ? err.message : String(err);
576
+ p.log.error(`${reason}\nNot a built-in template either. Built-in templates (work offline): ${WORKFLOW_TEMPLATES.map((t) => t.id).join(", ")}`);
519
577
  process.exit(1);
520
578
  }
521
579
  if (!result.installed) {
@@ -546,44 +604,57 @@ export async function runNew(options) {
546
604
  p.log.info(`Found ${customSkills.length} custom skill(s): ${chalk.cyan(customSkills.map((s) => s.id).join(", "))}`);
547
605
  }
548
606
  // ── Step 1: Pick a workflow ─────────────────────────────────────────
549
- const templateChoice = await p.select({
550
- message: "What do you want to do?",
551
- options: [
552
- {
553
- value: "__marketplace",
554
- label: "Browse marketplace",
555
- hint: "install a published workflow from swenyai/workflows",
556
- },
557
- ...WORKFLOW_TEMPLATES.map((t) => ({
558
- value: t.id,
559
- label: t.name,
560
- hint: t.description,
561
- })),
562
- { value: "__e2e", label: "End-to-end browser testing", hint: "Automated browser tests for your app" },
563
- { value: "__custom", label: "Describe your own", hint: "AI-generated from your description" },
564
- { value: "__blank", label: "Start blank", hint: "just set up config, I'll create workflows later" },
565
- ],
566
- });
567
- if (p.isCancel(templateChoice))
568
- cancel();
569
- // ── Marketplace short-circuit: fetch index, pick, delegate ──────────
570
- if (templateChoice === "__marketplace") {
571
- const { fetchMarketplaceIndex } = await import("./marketplace.js");
572
- const spinner = p.spinner();
573
- spinner.start("Fetching marketplace index…");
574
- let entries;
607
+ // Marketplace is offered only when its index is reachable. Offline or a
608
+ // missing index must never leave the default option broken.
609
+ let marketplaceEntries = null;
610
+ let templateChoice;
611
+ if (options?.template) {
612
+ templateChoice = options.template;
613
+ }
614
+ else {
575
615
  try {
576
- entries = await fetchMarketplaceIndex();
577
- spinner.stop(`Found ${entries.length} workflow(s)`);
616
+ const { fetchMarketplaceIndex } = await import("./marketplace.js");
617
+ const entries = await fetchMarketplaceIndex({ timeoutMs: 3000 });
618
+ if (Array.isArray(entries) && entries.length > 0)
619
+ marketplaceEntries = entries;
620
+ else
621
+ p.log.info("Marketplace has no workflows yet. Showing built-in templates.");
578
622
  }
579
623
  catch (err) {
580
- spinner.stop("Failed");
581
- p.log.error(err instanceof Error ? err.message : String(err));
582
- process.exit(1);
624
+ const reason = err instanceof Error ? err.message : String(err);
625
+ p.log.info(`Marketplace unavailable (${reason}). Showing built-in templates.`);
583
626
  }
627
+ templateChoice = await p.select({
628
+ message: "What do you want to do?",
629
+ initialValue: WORKFLOW_TEMPLATES[0].id,
630
+ options: [
631
+ ...WORKFLOW_TEMPLATES.map((t) => ({
632
+ value: t.id,
633
+ label: t.name,
634
+ hint: t.description,
635
+ })),
636
+ ...(marketplaceEntries
637
+ ? [
638
+ {
639
+ value: "__marketplace",
640
+ label: "Browse marketplace",
641
+ hint: "install a published workflow from swenyai/workflows",
642
+ },
643
+ ]
644
+ : []),
645
+ { value: "__e2e", label: "End-to-end browser testing", hint: "Automated browser tests for your app" },
646
+ { value: "__custom", label: "Describe your own", hint: "AI-generated from your description" },
647
+ { value: "__blank", label: "Start blank", hint: "just set up config, I'll create workflows later" },
648
+ ],
649
+ });
650
+ }
651
+ if (p.isCancel(templateChoice))
652
+ cancel();
653
+ // ── Marketplace short-circuit: pick from the fetched index, delegate ──
654
+ if (templateChoice === "__marketplace" && marketplaceEntries) {
584
655
  const pick = await p.select({
585
656
  message: "Which workflow?",
586
- options: entries.map((e) => ({
657
+ options: marketplaceEntries.map((e) => ({
587
658
  value: e.id,
588
659
  label: e.name,
589
660
  hint: e.description,
@@ -593,7 +664,7 @@ export async function runNew(options) {
593
664
  cancel();
594
665
  // Delegate to the marketplace install path
595
666
  // Wizard already showed its intro — skip the fast-path intro to avoid doubling.
596
- return runNew({ marketplaceId: pick, skipIntro: true });
667
+ return runNew({ marketplaceId: pick, skipIntro: true, forceMarketplace: true });
597
668
  }
598
669
  // ── E2E short-circuit: delegate to the e2e wizard ────────────────────
599
670
  if (templateChoice === "__e2e") {
@@ -617,7 +688,7 @@ export async function runNew(options) {
617
688
  const issueTracker = inferIssueTracker(workflowSkills, sourceControl);
618
689
  const observability = inferObservability(workflowSkills);
619
690
  // Collect credentials for the workflow's skills (+ always ANTHROPIC_API_KEY)
620
- const credentials = collectCredentialsForSkills(workflowSkills, allSkills);
691
+ const credentials = credentialsForWorkflow(workflowSkills, allSkills);
621
692
  // ── Step 3: Summary + confirm ───────────────────────────────────────
622
693
  const files = [];
623
694
  if (!hasExistingConfig)
@@ -647,18 +718,20 @@ export async function runNew(options) {
647
718
  summaryLines.push(chalk.bold(hasExistingConfig ? "Workflow uses:" : "Inferred from workflow:"));
648
719
  summaryLines.push(...inferred);
649
720
  summaryLines.push("");
650
- summaryLines.push(chalk.bold(`Credentials needed: ${credentials.length}`));
651
- summaryLines.push(...credentials.map((c) => ` ${chalk.dim(c.key)}`));
721
+ summaryLines.push(chalk.bold(`Credentials: ${credentials.filter((c) => !c.optional).length} required`));
722
+ summaryLines.push(...credentials.map((c) => ` ${chalk.dim(c.key)}${c.optional ? chalk.dim(" (optional with a Claude Code login)") : ""}`));
652
723
  p.log.message(summaryLines.join("\n"));
653
- const confirmed = await p.confirm({
654
- message: hasExistingConfig ? "Add this workflow?" : "Create these files?",
655
- initialValue: true,
656
- });
657
- if (p.isCancel(confirmed))
658
- cancel();
659
- if (!confirmed) {
660
- p.cancel("Setup cancelled.");
661
- process.exit(0);
724
+ if (!options?.yes) {
725
+ const confirmed = await p.confirm({
726
+ message: hasExistingConfig ? "Add this workflow?" : "Create these files?",
727
+ initialValue: true,
728
+ });
729
+ if (p.isCancel(confirmed))
730
+ cancel();
731
+ if (!confirmed) {
732
+ p.cancel("Setup cancelled.");
733
+ process.exit(0);
734
+ }
662
735
  }
663
736
  // ── Step 4: Write files ─────────────────────────────────────────────
664
737
  // 1. Write .sweny.yml (only if fresh)
@@ -684,6 +757,11 @@ export async function runNew(options) {
684
757
  // 3. Workflow template
685
758
  if (template) {
686
759
  const firstAttempt = writeWorkflowFile(cwd, template.id, template.yaml, { overwrite: false });
760
+ if (firstAttempt.exists && options?.yes) {
761
+ p.log.info(`.sweny/workflows/${template.id}.yml already exists, not overwritten`);
762
+ p.outro("Done.");
763
+ return;
764
+ }
687
765
  if (firstAttempt.exists) {
688
766
  const overwrite = await p.confirm({
689
767
  message: `.sweny/workflows/${template.id}.yml already exists. Overwrite?`,
@@ -700,30 +778,26 @@ export async function runNew(options) {
700
778
  }
701
779
  p.log.success(`Created .sweny/workflows/${template.id}.yml`);
702
780
  }
703
- // 4. .gitignore check
704
- const gitignorePath = path.join(cwd, ".gitignore");
705
- if (fs.existsSync(gitignorePath)) {
706
- const gitignore = fs.readFileSync(gitignorePath, "utf-8");
707
- const envIgnored = gitignore.split("\n").some((line) => line.trim() === ".env");
708
- if (!envIgnored) {
709
- p.log.warn(chalk.yellow(".env is not in .gitignore — add it to avoid committing secrets"));
710
- }
711
- }
712
- else {
713
- p.log.warn(chalk.yellow("No .gitignore found — make sure .env is not committed"));
714
- }
781
+ // 4. Keep .env out of version control
782
+ const ignoreResult = ensureGitignoreEnv(cwd);
783
+ if (ignoreResult === "created")
784
+ p.log.success("Created .gitignore with .env");
785
+ else if (ignoreResult === "appended")
786
+ p.log.success("Added .env to .gitignore");
715
787
  // 5. Next steps
716
- const credUrls = credentials.filter((c) => c.url).map((c) => ` ${c.key}: ${c.url}`);
788
+ const requiredCreds = credentials.filter((c) => !c.optional);
789
+ const credUrls = requiredCreds.filter((c) => c.url).map((c) => ` ${c.key}: ${c.url}`);
717
790
  const steps = [];
718
791
  let stepNum = 1;
719
- if (addedNewKeys) {
720
- steps.push(`${stepNum++}. Fill in your API keys in .env:`, ...credUrls, "", `${stepNum++}. Verify connectivity:`, " sweny check", "");
792
+ const cli = cliCommand();
793
+ if (addedNewKeys && requiredCreds.length > 0) {
794
+ steps.push(`${stepNum++}. Fill in your API keys in .env:`, ...credUrls, "", `${stepNum++}. Verify connectivity:`, ` ${cli} check`, "");
721
795
  }
722
796
  if (template) {
723
- steps.push(`${stepNum++}. Run your workflow:`, ` sweny workflow run .sweny/workflows/${template.id}.yml`);
797
+ steps.push(`${stepNum++}. Run your workflow:`, ` ${cli} workflow run .sweny/workflows/${template.id}.yml`);
724
798
  }
725
799
  else {
726
- steps.push(`${stepNum++}. Create your first workflow:`, " sweny new");
800
+ steps.push(`${stepNum++}. Create your first workflow:`, ` ${cli} new`);
727
801
  }
728
802
  p.note(steps.join("\n"), "Next steps");
729
803
  p.outro(hasExistingConfig ? "Workflow added!" : "You're all set!");
@@ -58,6 +58,15 @@ export function validateWorkflowFile(filePath) {
58
58
  warnings,
59
59
  };
60
60
  }
61
+ // Marketplace templates are required to declare workflow_type (spec:
62
+ // https://spec.sweny.ai/nodes/#workflow_type). Absence defaults to
63
+ // "generic" at runtime, but a marketplace submission that never chose a
64
+ // type is a template that never got the cross-type renderer it should
65
+ // have. Warn, don't block: this stays a warning so existing community
66
+ // submissions aren't hard-rejected by `sweny publish`.
67
+ if (!workflow.workflow_type) {
68
+ warnings.push('Workflow does not declare "workflow_type" (pr_review, e2e_test, content_generation, monitor, data_sync, generic); defaults to "generic". Marketplace templates should declare one explicitly.');
69
+ }
61
70
  // Config completeness warnings
62
71
  const allSkills = [...builtinSkills, ...customSkills];
63
72
  const skillMap = new Map(allSkills.map((s) => [s.id, s]));
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Run-end output for `sweny workflow run`: the receipt line, the
3
+ * GITHUB_STEP_SUMMARY writer, the quiet-by-default logger, and the help text.
4
+ *
5
+ * METADATA ONLY. Nothing here reads prompts, node outputs, tool inputs, or
6
+ * model prose: only counts, durations, token totals, and cost.
7
+ */
8
+ import { type ExecutionTrace, type Logger, type NodeResult, type Workflow } from "../types.js";
9
+ export interface RunSummary {
10
+ ok: boolean;
11
+ nodesOk: number;
12
+ nodesTotal: number;
13
+ nodesSkipped: number;
14
+ toolCalls: number;
15
+ durationMs: number;
16
+ /** input + output tokens. Undefined when no node reported usage. */
17
+ tokens?: number;
18
+ /** Sum of SDK-reported cost. Undefined when the SDK reported none (never estimated). */
19
+ costUsd?: number;
20
+ }
21
+ /** Sum the per-node results into one run summary. */
22
+ export declare function summarizeRun(results: Map<string, NodeResult>, durationMs: number, crashed?: boolean): RunSummary;
23
+ export declare function formatReceiptDuration(ms: number): string;
24
+ export declare function formatTokenCount(n: number): string;
25
+ export declare function formatCost(usd: number): string;
26
+ /**
27
+ * One plain line: `✓ 3/3 nodes · 41 tool calls · 2m10s · 12k tokens · $0.18`.
28
+ * Token and cost segments are omitted when the SDK reported none. No ANSI;
29
+ * callers colorize only when writing to a TTY.
30
+ */
31
+ export declare function formatReceipt(s: RunSummary): string;
32
+ /** Receipt for a terminal: colored only when `color` is true. */
33
+ export declare function renderReceiptLine(s: RunSummary, color: boolean): string;
34
+ /** Markdown for `$GITHUB_STEP_SUMMARY`: receipt + status-colored Mermaid DAG. */
35
+ export declare function formatStepSummary(workflow: Workflow, results: Map<string, NodeResult>, summary: RunSummary, trace?: ExecutionTrace): string;
36
+ /**
37
+ * Append the step summary to the file named by GITHUB_STEP_SUMMARY.
38
+ * Runner-agnostic: does nothing when the variable is unset or empty.
39
+ * Never throws; returns true only when something was written.
40
+ */
41
+ export declare function writeStepSummary(workflow: Workflow, results: Map<string, NodeResult>, summary: RunSummary, trace?: ExecutionTrace, env?: NodeJS.ProcessEnv): boolean;
42
+ export interface RunLogger extends Logger {
43
+ /** Write any warnings held back while a node line was live. */
44
+ flush(): void;
45
+ }
46
+ /**
47
+ * Logger for `workflow run`. With `verbose`, the raw `[info]`/`[debug]` lines
48
+ * pass through unchanged. Otherwise info/debug are dropped and warnings and
49
+ * errors are rendered as ` ⚠ msg` / ` ✗ msg` on stderr. On a TTY they are held
50
+ * while a node line is live (the progress line is redrawn in place) and written
51
+ * by `flush()` after the node exits.
52
+ */
53
+ export declare function createRunLogger(opts: {
54
+ verbose: boolean;
55
+ tty: boolean;
56
+ write?: (s: string) => void;
57
+ }): RunLogger;
58
+ export declare const WORKFLOW_RUN_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).";
59
+ export declare const WORKFLOW_RUN_OPTIONS: ReadonlyArray<readonly [flags: string, description: string]>;
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Run-end output for `sweny workflow run`: the receipt line, the
3
+ * GITHUB_STEP_SUMMARY writer, the quiet-by-default logger, and the help text.
4
+ *
5
+ * METADATA ONLY. Nothing here reads prompts, node outputs, tool inputs, or
6
+ * model prose: only counts, durations, token totals, and cost.
7
+ */
8
+ import fs from "node:fs";
9
+ import chalk from "chalk";
10
+ import { consoleLogger } from "../types.js";
11
+ import { toMermaidBlock } from "../mermaid.js";
12
+ /** Sum the per-node results into one run summary. */
13
+ export function summarizeRun(results, durationMs, crashed = false) {
14
+ let nodesOk = 0;
15
+ let nodesSkipped = 0;
16
+ let failed = crashed;
17
+ let toolCalls = 0;
18
+ let tokens;
19
+ let costUsd;
20
+ const isNum = (v) => typeof v === "number" && Number.isFinite(v);
21
+ for (const r of results.values()) {
22
+ if (r.status === "success")
23
+ nodesOk++;
24
+ else if (r.status === "skipped")
25
+ nodesSkipped++;
26
+ else
27
+ failed = true;
28
+ toolCalls += r.toolCalls?.length ?? 0;
29
+ const u = r.usage;
30
+ if (u) {
31
+ if (isNum(u.inputTokens) || isNum(u.outputTokens)) {
32
+ tokens =
33
+ (tokens ?? 0) + (isNum(u.inputTokens) ? u.inputTokens : 0) + (isNum(u.outputTokens) ? u.outputTokens : 0);
34
+ }
35
+ if (isNum(u.costUsd))
36
+ costUsd = (costUsd ?? 0) + u.costUsd;
37
+ }
38
+ }
39
+ return {
40
+ ok: !failed,
41
+ nodesOk,
42
+ nodesTotal: results.size,
43
+ nodesSkipped,
44
+ toolCalls,
45
+ durationMs,
46
+ ...(tokens !== undefined ? { tokens } : {}),
47
+ ...(costUsd !== undefined ? { costUsd } : {}),
48
+ };
49
+ }
50
+ export function formatReceiptDuration(ms) {
51
+ if (ms < 1000)
52
+ return `${Math.max(0, Math.round(ms))}ms`;
53
+ const s = Math.round(ms / 1000);
54
+ if (s < 60)
55
+ return `${s}s`;
56
+ return `${Math.floor(s / 60)}m${String(s % 60).padStart(2, "0")}s`;
57
+ }
58
+ export function formatTokenCount(n) {
59
+ if (n < 1000)
60
+ return String(Math.round(n));
61
+ if (n < 10_000)
62
+ return `${Math.round(n / 100) / 10}k`;
63
+ if (n < 1_000_000)
64
+ return `${Math.round(n / 1000)}k`;
65
+ return `${Math.round(n / 100_000) / 10}M`;
66
+ }
67
+ export function formatCost(usd) {
68
+ if (usd > 0 && usd < 0.01)
69
+ return "<$0.01";
70
+ return `$${usd.toFixed(2)}`;
71
+ }
72
+ /**
73
+ * One plain line: `✓ 3/3 nodes · 41 tool calls · 2m10s · 12k tokens · $0.18`.
74
+ * Token and cost segments are omitted when the SDK reported none. No ANSI;
75
+ * callers colorize only when writing to a TTY.
76
+ */
77
+ export function formatReceipt(s) {
78
+ const parts = [
79
+ `${s.ok ? "✓" : "✗"} ${s.nodesOk}/${s.nodesTotal} nodes`,
80
+ ...(s.nodesSkipped > 0 ? [`${s.nodesSkipped} skipped`] : []),
81
+ `${s.toolCalls} tool ${s.toolCalls === 1 ? "call" : "calls"}`,
82
+ formatReceiptDuration(s.durationMs),
83
+ ...(s.tokens !== undefined ? [`${formatTokenCount(s.tokens)} tokens`] : []),
84
+ ...(s.costUsd !== undefined ? [formatCost(s.costUsd)] : []),
85
+ ];
86
+ return parts.join(" · ");
87
+ }
88
+ /** Receipt for a terminal: colored only when `color` is true. */
89
+ export function renderReceiptLine(s, color) {
90
+ const line = formatReceipt(s);
91
+ if (!color)
92
+ return line;
93
+ return s.ok ? chalk.green(line) : chalk.red(line);
94
+ }
95
+ // ── Step summary ────────────────────────────────────────────────
96
+ /** Brand blue (blue-600 / blue-700), not indigo. Applied to successful nodes. */
97
+ const STEP_SUMMARY_CLASS_DEFS = {
98
+ success: "fill:#2563eb,stroke:#1d4ed8,color:#fff,stroke-width:2px",
99
+ };
100
+ /** Markdown for `$GITHUB_STEP_SUMMARY`: receipt + status-colored Mermaid DAG. */
101
+ export function formatStepSummary(workflow, results, summary, trace) {
102
+ const state = {};
103
+ for (const [id, r] of results) {
104
+ state[id] = r.status === "success" ? "success" : r.status === "failed" ? "failed" : "skipped";
105
+ }
106
+ return [
107
+ `## ${summary.ok ? "✅" : "❌"} ${workflow.name}`,
108
+ "",
109
+ `\`${formatReceipt(summary)}\``,
110
+ "",
111
+ toMermaidBlock(workflow, { state, trace, classDefs: STEP_SUMMARY_CLASS_DEFS }),
112
+ "",
113
+ "",
114
+ ].join("\n");
115
+ }
116
+ /**
117
+ * Append the step summary to the file named by GITHUB_STEP_SUMMARY.
118
+ * Runner-agnostic: does nothing when the variable is unset or empty.
119
+ * Never throws; returns true only when something was written.
120
+ */
121
+ export function writeStepSummary(workflow, results, summary, trace, env = process.env) {
122
+ const file = env.GITHUB_STEP_SUMMARY;
123
+ if (!file)
124
+ return false;
125
+ try {
126
+ fs.appendFileSync(file, formatStepSummary(workflow, results, summary, trace));
127
+ return true;
128
+ }
129
+ catch (err) {
130
+ process.stderr.write(` ⚠ could not write GITHUB_STEP_SUMMARY: ${err instanceof Error ? err.message : err}\n`);
131
+ return false;
132
+ }
133
+ }
134
+ /**
135
+ * Logger for `workflow run`. With `verbose`, the raw `[info]`/`[debug]` lines
136
+ * pass through unchanged. Otherwise info/debug are dropped and warnings and
137
+ * errors are rendered as ` ⚠ msg` / ` ✗ msg` on stderr. On a TTY they are held
138
+ * while a node line is live (the progress line is redrawn in place) and written
139
+ * by `flush()` after the node exits.
140
+ */
141
+ export function createRunLogger(opts) {
142
+ const write = opts.write ?? ((s) => void process.stderr.write(s));
143
+ if (opts.verbose)
144
+ return { ...consoleLogger, flush() { } };
145
+ const held = [];
146
+ const emit = (line) => {
147
+ if (opts.tty)
148
+ held.push(line);
149
+ else
150
+ write(line);
151
+ };
152
+ const noop = () => { };
153
+ return {
154
+ info: noop,
155
+ debug: noop,
156
+ warn: (msg) => emit(` ${chalk.yellow("⚠")} ${msg.trim()}\n`),
157
+ error: (msg) => emit(` ${chalk.red("✗")} ${msg.trim()}\n`),
158
+ flush() {
159
+ while (held.length)
160
+ write(held.shift());
161
+ },
162
+ };
163
+ }
164
+ // ── Help text ───────────────────────────────────────────────────
165
+ export const WORKFLOW_RUN_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).";
166
+ export const WORKFLOW_RUN_OPTIONS = [
167
+ [
168
+ "--timeout <ms>",
169
+ "Whole-run wall-clock timeout in ms. Applies to batch runs (.sweny/e2e/) and to a single workflow file (default: 3600000 = 60 min; 0 = no wall-clock budget)",
170
+ ],
171
+ [
172
+ "--max-steps <n>",
173
+ "Hard cap on total node executions for a single workflow file, including eval-failure retries (default: 200)",
174
+ ],
175
+ ["-y, --yes", "Skip the batch confirmation prompt (for CI)"],
176
+ [
177
+ "--dry-run",
178
+ "Run with read-only tools only: write tools, external MCP servers, and shell/file-edit tools are withheld, so nothing is created, posted, or sent. Stops at the first conditional edge. To inspect nodes without running, use --list-nodes.",
179
+ ],
180
+ ["--list-nodes", "Validate, print nodes and skills, and exit without running"],
181
+ ["--json", "Output result as JSON on stdout; suppress progress output"],
182
+ ["--stream", "Stream NDJSON events to stdout (for Studio / automation)"],
183
+ [
184
+ "--verbose",
185
+ "Show raw log lines and each tool call's input and output inline (human-readable, truncated). Use --stream for full untruncated NDJSON.",
186
+ ],
187
+ ["--mermaid", "Output a Mermaid diagram with execution state after run"],
188
+ ["--input <json>", "JSON string of input data to pass to the workflow"],
189
+ ];
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Missing-credential reporting for skills.
3
+ *
4
+ * A built-in skill whose required env var is unset is NOT an unknown skill.
5
+ * These helpers turn that state into one actionable line per skill
6
+ * (`skill "github" needs GITHUB_TOKEN (set it in .env)`), shared by
7
+ * `workflow run` (hard fail) and `workflow validate` (warning).
8
+ */
9
+ import type { SkillValidationResult } from "../skills/index.js";
10
+ import type { Skill } from "../types.js";
11
+ export interface SkillEnvGap {
12
+ id: string;
13
+ missingEnv: string[];
14
+ }
15
+ /**
16
+ * Built-in skills referenced by a workflow that are not usable because a
17
+ * required env var is unset. `availableIds` is the configured skill set;
18
+ * inline and unknown ids never produce a gap.
19
+ */
20
+ export declare function findSkillEnvGaps(skillIds: Iterable<string>, availableIds: ReadonlySet<string>, inlineIds?: ReadonlySet<string>): SkillEnvGap[];
21
+ export declare function formatSkillEnvGap(gap: SkillEnvGap): string;
22
+ /**
23
+ * Lines to print for a failed `validateWorkflowSkills`: one env line per
24
+ * built-in skill with missing env, plus node-level errors that carry extra
25
+ * information (alternatives among several skills, or unknown ids).
26
+ */
27
+ export declare function formatMissingSkillLines(validation: SkillValidationResult): string[];
28
+ /** Warnings for `workflow validate`: referenced skills whose env is missing. */
29
+ export declare function skillEnvWarnings(workflow: {
30
+ nodes: Record<string, {
31
+ skills: string[];
32
+ }>;
33
+ skills?: Record<string, unknown>;
34
+ }, env: Record<string, string | undefined>, available: Skill[]): string[];
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Missing-credential reporting for skills.
3
+ *
4
+ * A built-in skill whose required env var is unset is NOT an unknown skill.
5
+ * These helpers turn that state into one actionable line per skill
6
+ * (`skill "github" needs GITHUB_TOKEN (set it in .env)`), shared by
7
+ * `workflow run` (hard fail) and `workflow validate` (warning).
8
+ */
9
+ import { builtinSkills, isSkillConfigured } from "../skills/index.js";
10
+ /**
11
+ * Built-in skills referenced by a workflow that are not usable because a
12
+ * required env var is unset. `availableIds` is the configured skill set;
13
+ * inline and unknown ids never produce a gap.
14
+ */
15
+ export function findSkillEnvGaps(skillIds, availableIds, inlineIds = new Set()) {
16
+ const gaps = [];
17
+ const seen = new Set();
18
+ for (const id of skillIds) {
19
+ if (seen.has(id))
20
+ continue;
21
+ seen.add(id);
22
+ if (availableIds.has(id) || inlineIds.has(id))
23
+ continue;
24
+ const builtin = builtinSkills.find((s) => s.id === id);
25
+ if (!builtin)
26
+ continue;
27
+ const fields = Object.values(builtin.config).filter((f) => f.env);
28
+ const required = fields.filter((f) => f.required).map((f) => f.env);
29
+ const missingEnv = required.length > 0 ? required : fields.map((f) => f.env);
30
+ if (missingEnv.length === 0)
31
+ continue;
32
+ gaps.push({ id, missingEnv });
33
+ }
34
+ return gaps;
35
+ }
36
+ export function formatSkillEnvGap(gap) {
37
+ return `skill "${gap.id}" needs ${gap.missingEnv.join(", ")} (set it in .env)`;
38
+ }
39
+ /**
40
+ * Lines to print for a failed `validateWorkflowSkills`: one env line per
41
+ * built-in skill with missing env, plus node-level errors that carry extra
42
+ * information (alternatives among several skills, or unknown ids).
43
+ */
44
+ export function formatMissingSkillLines(validation) {
45
+ const lines = [];
46
+ const envGaps = validation.missing.filter((m) => m.category !== "unknown" && m.missingEnv.length > 0);
47
+ const unknownIds = validation.missing.filter((m) => m.category === "unknown").map((m) => m.id);
48
+ for (const m of envGaps)
49
+ lines.push(formatSkillEnvGap({ id: m.id, missingEnv: m.missingEnv }));
50
+ for (const err of validation.errors) {
51
+ const listed = /needs one of: (.+)\)$/.exec(err)?.[1]?.split(", ") ?? [];
52
+ const involvesUnknown = listed.some((id) => unknownIds.includes(id));
53
+ if (involvesUnknown || listed.length > 1)
54
+ lines.push(err);
55
+ }
56
+ return lines;
57
+ }
58
+ /** Warnings for `workflow validate`: referenced skills whose env is missing. */
59
+ export function skillEnvWarnings(workflow, env, available) {
60
+ const availableIds = new Set(available.filter((s) => isSkillConfigured(s, env)).map((s) => s.id));
61
+ const ids = Object.values(workflow.nodes).flatMap((n) => n.skills);
62
+ const inline = new Set(Object.keys(workflow.skills ?? {}));
63
+ return findSkillEnvGaps(ids, availableIds, inline).map(formatSkillEnvGap);
64
+ }