@sweny-ai/core 0.1.54 → 0.1.56

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 (41) hide show
  1. package/dist/__tests__/executor.test.js +2 -1
  2. package/dist/__tests__/integration/sources.integration.test.d.ts +1 -0
  3. package/dist/__tests__/integration/sources.integration.test.js +123 -0
  4. package/dist/__tests__/workflow-yaml.test.js +12 -5
  5. package/dist/cli/__tests__/report-cloud.test.d.ts +1 -0
  6. package/dist/cli/__tests__/report-cloud.test.js +165 -0
  7. package/dist/cli/cloud-report.d.ts +12 -0
  8. package/dist/cli/cloud-report.js +79 -0
  9. package/dist/cli/config-file.d.ts +3 -3
  10. package/dist/cli/config-file.js +18 -0
  11. package/dist/cli/config.d.ts +3 -0
  12. package/dist/cli/config.js +25 -2
  13. package/dist/cli/config.test.js +65 -1
  14. package/dist/cli/e2e.js +4 -2
  15. package/dist/cli/e2e.test.js +9 -2
  16. package/dist/cli/main.js +44 -12
  17. package/dist/cli/output.test.js +1 -0
  18. package/dist/cli/publish.test.d.ts +1 -0
  19. package/dist/cli/publish.test.js +610 -0
  20. package/dist/executor.d.ts +8 -0
  21. package/dist/executor.js +21 -3
  22. package/dist/executor.test.js +56 -1
  23. package/dist/index.d.ts +2 -2
  24. package/dist/index.js +1 -1
  25. package/dist/mermaid.test.js +2 -0
  26. package/dist/schema.d.ts +175 -46
  27. package/dist/schema.js +31 -2
  28. package/dist/sources.d.ts +70 -0
  29. package/dist/sources.js +194 -0
  30. package/dist/sources.test.d.ts +1 -0
  31. package/dist/sources.test.js +264 -0
  32. package/dist/sources.validation.test.d.ts +12 -0
  33. package/dist/sources.validation.test.js +360 -0
  34. package/dist/templates.d.ts +6 -2
  35. package/dist/templates.js +36 -46
  36. package/dist/testing.js +4 -1
  37. package/dist/types.d.ts +8 -1
  38. package/dist/types.js +0 -5
  39. package/dist/workflows/browser.js +1 -1
  40. package/dist/workflows/triage.yml +1 -23
  41. package/package.json +1 -1
@@ -89,8 +89,9 @@ describe("executor", () => {
89
89
  expect(results.get("c")?.status).toBe("success");
90
90
  // Verify file was written
91
91
  expect(existsSync(path.join(outputDir, "out.json"))).toBe(true);
92
- // Events
92
+ // Events — workflow:start fires first, then sources:resolved (per spec)
93
93
  expect(events[0]).toEqual({ type: "workflow:start", workflow: "linear" });
94
+ expect(events[1].type).toBe("sources:resolved");
94
95
  expect(events.filter((e) => e.type === "node:enter")).toHaveLength(3);
95
96
  expect(events[events.length - 1].type).toBe("workflow:end");
96
97
  });
@@ -0,0 +1,123 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { mkdirSync, writeFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import * as path from "node:path";
5
+ import { randomBytes } from "node:crypto";
6
+ import { execute } from "../../executor.js";
7
+ import { MockClaude } from "../../testing.js";
8
+ import { createSkillMap } from "../../skills/index.js";
9
+ function freshDir(name) {
10
+ const dir = path.join(tmpdir(), `sweny-sources-int-${name}-${Date.now()}-${randomBytes(4).toString("hex")}`);
11
+ rmSync(dir, { recursive: true, force: true });
12
+ mkdirSync(dir, { recursive: true });
13
+ return dir;
14
+ }
15
+ describe("integration: Source resolution across a workflow", () => {
16
+ beforeEach(() => {
17
+ vi.restoreAllMocks();
18
+ });
19
+ it("resolves inline, file, and url instructions across three nodes", async () => {
20
+ const dir = freshDir("three-kinds");
21
+ writeFileSync(path.join(dir, "b.md"), "Step B instruction from file.");
22
+ vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response("Step C instruction from URL.", { status: 200 }));
23
+ const workflow = {
24
+ id: "three-kinds",
25
+ name: "Three Kinds",
26
+ description: "",
27
+ entry: "a",
28
+ nodes: {
29
+ a: { name: "A", instruction: "Step A inline", skills: [] },
30
+ b: { name: "B", instruction: { file: path.join(dir, "b.md") }, skills: [] },
31
+ c: { name: "C", instruction: { url: "https://example.com/c.md" }, skills: [] },
32
+ },
33
+ edges: [
34
+ { from: "a", to: "b" },
35
+ { from: "b", to: "c" },
36
+ ],
37
+ };
38
+ const skills = createSkillMap([]);
39
+ const result = await execute(workflow, {}, {
40
+ skills,
41
+ claude: new MockClaude({
42
+ responses: {
43
+ a: { data: {} },
44
+ b: { data: {} },
45
+ c: { data: {} },
46
+ },
47
+ }),
48
+ cwd: dir,
49
+ });
50
+ // All three nodes should have executed
51
+ expect(result.results.size).toBe(3);
52
+ // Trace should have all sources
53
+ expect(result.trace.sources["nodes.a.instruction"].kind).toBe("inline");
54
+ expect(result.trace.sources["nodes.a.instruction"].content).toBe("Step A inline");
55
+ expect(result.trace.sources["nodes.b.instruction"].kind).toBe("file");
56
+ expect(result.trace.sources["nodes.b.instruction"].content).toBe("Step B instruction from file.");
57
+ expect(result.trace.sources["nodes.c.instruction"].kind).toBe("url");
58
+ expect(result.trace.sources["nodes.c.instruction"].content).toBe("Step C instruction from URL.");
59
+ expect(result.trace.sources["nodes.c.instruction"].fetchedAt).toBeDefined();
60
+ // Every source has a hash
61
+ for (const rs of Object.values(result.trace.sources)) {
62
+ expect(rs.hash).toMatch(/^[0-9a-f]{16}$/);
63
+ }
64
+ });
65
+ it("fails fast with SOURCE_OFFLINE_REQUIRES_FETCH when offline and URL present", async () => {
66
+ const workflow = {
67
+ id: "offline-fail",
68
+ name: "Offline fail",
69
+ description: "",
70
+ entry: "a",
71
+ nodes: {
72
+ a: { name: "A", instruction: { url: "https://example.com/x" }, skills: [] },
73
+ },
74
+ edges: [],
75
+ };
76
+ await expect(execute(workflow, {}, {
77
+ skills: createSkillMap([]),
78
+ claude: new MockClaude({ responses: { a: { data: {} } } }),
79
+ offline: true,
80
+ })).rejects.toThrow(/SOURCE_OFFLINE_REQUIRES_FETCH/);
81
+ });
82
+ it("fails fast with SOURCE_FILE_NOT_FOUND and includes field path", async () => {
83
+ const dir = freshDir("missing");
84
+ const workflow = {
85
+ id: "file-missing",
86
+ name: "File missing",
87
+ description: "",
88
+ entry: "bad",
89
+ nodes: {
90
+ bad: { name: "Bad", instruction: { file: "./does-not-exist.md" }, skills: [] },
91
+ },
92
+ edges: [],
93
+ };
94
+ await expect(execute(workflow, {}, {
95
+ skills: createSkillMap([]),
96
+ claude: new MockClaude({ responses: { bad: { data: {} } } }),
97
+ cwd: dir,
98
+ })).rejects.toThrow(/SOURCE_FILE_NOT_FOUND.*nodes\.bad\.instruction/);
99
+ });
100
+ it("emits workflow:start before sources:resolved (per spec)", async () => {
101
+ const events = [];
102
+ const workflow = {
103
+ id: "evt-test",
104
+ name: "Event test",
105
+ description: "",
106
+ entry: "only",
107
+ nodes: { only: { name: "Only", instruction: "Go.", skills: [] } },
108
+ edges: [],
109
+ };
110
+ await execute(workflow, {}, {
111
+ skills: createSkillMap([]),
112
+ claude: new MockClaude({ responses: { only: { data: {} } } }),
113
+ observer: (e) => events.push(e),
114
+ });
115
+ const srcEvt = events.find((e) => e.type === "sources:resolved");
116
+ expect(srcEvt).toBeDefined();
117
+ expect(srcEvt.sources["nodes.only.instruction"].content).toBe("Go.");
118
+ // workflow:start should come before sources:resolved (per spec)
119
+ const startIdx = events.findIndex((e) => e.type === "workflow:start");
120
+ const srcIdx = events.findIndex((e) => e.type === "sources:resolved");
121
+ expect(startIdx).toBeLessThan(srcIdx);
122
+ });
123
+ });
@@ -70,7 +70,14 @@ describe("loaded workflow objects", () => {
70
70
  });
71
71
  it("every node has a non-empty instruction", () => {
72
72
  for (const [nodeId, node] of Object.entries(workflow.nodes)) {
73
- expect(node.instruction.length, `node "${nodeId}" has empty instruction`).toBeGreaterThan(0);
73
+ const instr = node.instruction;
74
+ if (typeof instr === "string") {
75
+ expect(instr.length, `node "${nodeId}" has empty instruction`).toBeGreaterThan(0);
76
+ }
77
+ else {
78
+ // Object-form Source — always truthy if it exists
79
+ expect(instr, `node "${nodeId}" has falsy instruction`).toBeTruthy();
80
+ }
74
81
  }
75
82
  });
76
83
  it("every node has a name", () => {
@@ -110,11 +117,11 @@ describe("loaded workflow objects", () => {
110
117
  });
111
118
  // ─── Triage workflow specifics ──────────────────────────────────
112
119
  describe("triage workflow specifics", () => {
113
- it("has 8 nodes", () => {
114
- expect(Object.keys(triageWorkflow.nodes).length).toBe(8);
120
+ it("has 7 nodes", () => {
121
+ expect(Object.keys(triageWorkflow.nodes).length).toBe(7);
115
122
  });
116
- it("starts at prepare", () => {
117
- expect(triageWorkflow.entry).toBe("prepare");
123
+ it("starts at gather", () => {
124
+ expect(triageWorkflow.entry).toBe("gather");
118
125
  });
119
126
  it("has conditional edges from investigate", () => {
120
127
  const investigateEdges = triageWorkflow.edges.filter((e) => e.from === "investigate");
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,165 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ describe("reportToCloud", () => {
3
+ let reportToCloud;
4
+ const fetchMock = vi.fn();
5
+ beforeEach(async () => {
6
+ fetchMock.mockReset();
7
+ fetchMock.mockResolvedValue(new Response(JSON.stringify({ run_url: "https://cloud.sweny.ai/runs/abc" }), { status: 200 }));
8
+ vi.spyOn(globalThis, "fetch").mockImplementation(fetchMock);
9
+ // Dynamic import to handle any module-level side effects
10
+ const mod = await import("../cloud-report.js");
11
+ reportToCloud = mod.reportToCloud;
12
+ });
13
+ afterEach(() => {
14
+ vi.restoreAllMocks();
15
+ });
16
+ function makeConfig(overrides = {}) {
17
+ return {
18
+ cloudToken: "",
19
+ githubToken: "",
20
+ repository: "acme/widget",
21
+ codingAgentProvider: "claude",
22
+ anthropicApiKey: "",
23
+ claudeOauthToken: "",
24
+ openaiApiKey: "",
25
+ geminiApiKey: "",
26
+ observabilityProvider: "",
27
+ observabilityCredentials: {},
28
+ issueTrackerProvider: "",
29
+ linearApiKey: "",
30
+ linearTeamId: "",
31
+ linearBugLabelId: "",
32
+ linearTriageLabelId: "",
33
+ linearStateBacklog: "",
34
+ linearStateInProgress: "",
35
+ linearStatePeerReview: "",
36
+ timeRange: "",
37
+ severityFocus: "",
38
+ serviceFilter: "",
39
+ investigationDepth: "",
40
+ maxInvestigateTurns: 50,
41
+ maxImplementTurns: 40,
42
+ baseBranch: "main",
43
+ prLabels: [],
44
+ issueLabels: [],
45
+ dryRun: false,
46
+ reviewMode: "review",
47
+ noveltyMode: false,
48
+ issueOverride: "",
49
+ additionalInstructions: "",
50
+ serviceMapPath: "",
51
+ botToken: "",
52
+ sourceControlProvider: "",
53
+ jiraBaseUrl: "",
54
+ jiraEmail: "",
55
+ jiraApiToken: "",
56
+ gitlabToken: "",
57
+ gitlabProjectId: "",
58
+ gitlabBaseUrl: "",
59
+ notificationProvider: "",
60
+ notificationWebhookUrl: "",
61
+ sendgridApiKey: "",
62
+ emailFrom: "",
63
+ emailTo: "",
64
+ webhookSigningSecret: "",
65
+ repositoryOwner: "",
66
+ json: false,
67
+ stream: false,
68
+ bell: false,
69
+ cacheDir: "",
70
+ cacheTtl: 0,
71
+ noCache: false,
72
+ outputDir: "",
73
+ mcpServers: {},
74
+ workspaceTools: [],
75
+ rules: [],
76
+ context: [],
77
+ offline: false,
78
+ fetchAuth: {},
79
+ ...overrides,
80
+ };
81
+ }
82
+ function makeResults() {
83
+ return new Map([
84
+ [
85
+ "investigate",
86
+ {
87
+ status: "success",
88
+ data: { findings: [{ id: "F1" }], recommendation: "implement" },
89
+ toolCalls: [],
90
+ },
91
+ ],
92
+ [
93
+ "create_pr",
94
+ {
95
+ status: "success",
96
+ data: { prUrl: "https://github.com/acme/widget/pull/7", prNumber: 7 },
97
+ toolCalls: [],
98
+ },
99
+ ],
100
+ ]);
101
+ }
102
+ it("does NOT call fetch when cloudToken is empty", async () => {
103
+ await reportToCloud(makeResults(), 1000, makeConfig({ cloudToken: "" }), "triage");
104
+ expect(fetchMock).not.toHaveBeenCalled();
105
+ });
106
+ it("does NOT call fetch when repository is missing", async () => {
107
+ const orig = process.env.GITHUB_REPOSITORY;
108
+ delete process.env.GITHUB_REPOSITORY;
109
+ try {
110
+ await reportToCloud(makeResults(), 1000, makeConfig({ cloudToken: "sweny_pk_x", repository: "" }), "triage");
111
+ expect(fetchMock).not.toHaveBeenCalled();
112
+ }
113
+ finally {
114
+ if (orig !== undefined)
115
+ process.env.GITHUB_REPOSITORY = orig;
116
+ }
117
+ });
118
+ it("calls fetch with Bearer cloudToken when token is set", async () => {
119
+ await reportToCloud(makeResults(), 1000, makeConfig({ cloudToken: "sweny_pk_abc" }), "triage");
120
+ expect(fetchMock).toHaveBeenCalledTimes(1);
121
+ const [, init] = fetchMock.mock.calls[0];
122
+ expect(init.headers.Authorization).toBe("Bearer sweny_pk_abc");
123
+ });
124
+ it("does NOT send GITHUB_TOKEN in Authorization header", async () => {
125
+ await reportToCloud(makeResults(), 1000, makeConfig({ cloudToken: "sweny_pk_abc", githubToken: "ghs_evil" }), "triage");
126
+ const [, init] = fetchMock.mock.calls[0];
127
+ expect(init.headers.Authorization).not.toContain("ghs_evil");
128
+ expect(init.headers.Authorization).not.toMatch(/^token /);
129
+ });
130
+ it("posts to SWENY_CLOUD_URL override when set", async () => {
131
+ process.env.SWENY_CLOUD_URL = "https://cloud.example.test";
132
+ try {
133
+ await reportToCloud(makeResults(), 1000, makeConfig({ cloudToken: "sweny_pk_abc" }), "triage");
134
+ const [url] = fetchMock.mock.calls[0];
135
+ expect(url).toBe("https://cloud.example.test/api/report");
136
+ }
137
+ finally {
138
+ delete process.env.SWENY_CLOUD_URL;
139
+ }
140
+ });
141
+ it("silently swallows network failures", async () => {
142
+ fetchMock.mockRejectedValue(new Error("ECONNREFUSED"));
143
+ await expect(reportToCloud(makeResults(), 1000, makeConfig({ cloudToken: "sweny_pk_abc" }), "triage")).resolves.toBeUndefined();
144
+ });
145
+ it("prints the run URL when response includes one", async () => {
146
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => { });
147
+ await reportToCloud(makeResults(), 1000, makeConfig({ cloudToken: "sweny_pk_abc" }), "triage");
148
+ const output = logSpy.mock.calls.flat().join(" ");
149
+ expect(output).toContain("cloud.sweny.ai/runs/abc");
150
+ logSpy.mockRestore();
151
+ });
152
+ it("payload contains owner, repo, workflow, duration_ms, findings, nodes", async () => {
153
+ await reportToCloud(makeResults(), 1234, makeConfig({ cloudToken: "sweny_pk_abc" }), "triage");
154
+ const [, init] = fetchMock.mock.calls[0];
155
+ const body = JSON.parse(init.body);
156
+ expect(body).toMatchObject({
157
+ owner: "acme",
158
+ repo: "widget",
159
+ workflow: "triage",
160
+ duration_ms: 1234,
161
+ });
162
+ expect(Array.isArray(body.findings)).toBe(true);
163
+ expect(Array.isArray(body.nodes)).toBe(true);
164
+ });
165
+ });
@@ -0,0 +1,12 @@
1
+ import type { NodeResult } from "../types.js";
2
+ import type { CliConfig } from "./config.js";
3
+ /**
4
+ * Opt-in run reporting to SWEny Cloud.
5
+ *
6
+ * Fires only when `config.cloudToken` (from SWENY_CLOUD_TOKEN or .sweny.yml)
7
+ * is set. Authenticates using the user's cloud token — the user's GITHUB_TOKEN
8
+ * is never forwarded to sweny.ai.
9
+ *
10
+ * Failure is silent; reporting never blocks a workflow run.
11
+ */
12
+ export declare function reportToCloud(results: Map<string, NodeResult>, durationMs: number, config: CliConfig, workflow: string): Promise<void>;
@@ -0,0 +1,79 @@
1
+ import { createRequire } from "node:module";
2
+ import { c } from "./output.js";
3
+ const _require = createRequire(import.meta.url);
4
+ const { version } = _require("../../package.json");
5
+ // ── Cloud reporting ────────────────────────────────────────────────
6
+ const CLOUD_URL_DEFAULT = "https://cloud.sweny.ai";
7
+ /**
8
+ * Opt-in run reporting to SWEny Cloud.
9
+ *
10
+ * Fires only when `config.cloudToken` (from SWENY_CLOUD_TOKEN or .sweny.yml)
11
+ * is set. Authenticates using the user's cloud token — the user's GITHUB_TOKEN
12
+ * is never forwarded to sweny.ai.
13
+ *
14
+ * Failure is silent; reporting never blocks a workflow run.
15
+ */
16
+ export async function reportToCloud(results, durationMs, config, workflow) {
17
+ const cloudToken = config.cloudToken;
18
+ if (!cloudToken)
19
+ return;
20
+ const repo = config.repository || process.env.GITHUB_REPOSITORY || "";
21
+ const [owner, name] = repo.split("/");
22
+ if (!owner || !name)
23
+ return;
24
+ const investigateData = results.get("investigate")?.data;
25
+ const createPrData = results.get("create_pr")?.data;
26
+ const createIssueData = results.get("create_issue")?.data ?? results.get("create-issue")?.data;
27
+ const findings = investigateData?.findings ?? [];
28
+ const hasFailed = [...results.values()].some((r) => r.status === "failed");
29
+ const nodes = [...results.entries()].map(([id, r]) => ({
30
+ id,
31
+ name: id,
32
+ status: r.status === "success"
33
+ ? "success"
34
+ : r.status === "failed"
35
+ ? "failed"
36
+ : "skipped",
37
+ durationMs: undefined,
38
+ }));
39
+ const body = {
40
+ owner,
41
+ repo: name,
42
+ status: hasFailed ? "failed" : "completed",
43
+ workflow,
44
+ duration_ms: durationMs,
45
+ recommendation: investigateData?.recommendation,
46
+ findings,
47
+ highest_severity: investigateData?.highest_severity,
48
+ novel_count: investigateData?.novel_count,
49
+ pr_url: createPrData?.prUrl,
50
+ pr_number: createPrData?.prNumber,
51
+ issue_url: (createIssueData?.issueUrl ?? createPrData?.issueUrl),
52
+ issue_identifier: (createIssueData?.issueIdentifier ?? createPrData?.issueIdentifier),
53
+ issues_found: findings.length > 0,
54
+ nodes,
55
+ action_version: version,
56
+ runner_os: process.env.RUNNER_OS,
57
+ };
58
+ const cloudUrl = process.env.SWENY_CLOUD_URL || CLOUD_URL_DEFAULT;
59
+ try {
60
+ const res = await fetch(`${cloudUrl}/api/report`, {
61
+ method: "POST",
62
+ headers: {
63
+ "Content-Type": "application/json",
64
+ Authorization: `Bearer ${cloudToken}`,
65
+ },
66
+ body: JSON.stringify(body),
67
+ signal: AbortSignal.timeout(10_000),
68
+ });
69
+ if (res.ok) {
70
+ const data = (await res.json().catch(() => ({})));
71
+ if (data.run_url) {
72
+ console.log(c.subtle(` cloud: ${data.run_url}`));
73
+ }
74
+ }
75
+ }
76
+ catch {
77
+ // Never block the workflow on a reporting failure.
78
+ }
79
+ }
@@ -3,8 +3,8 @@
3
3
  * Sets `process.env[KEY]` only if not already defined (real env vars win).
4
4
  */
5
5
  export declare function loadDotenv(cwd?: string): void;
6
- /** Parsed config file — flat strings for scalar fields, arrays for list fields. */
7
- export type FileConfig = Record<string, string | string[]>;
6
+ /** Parsed config file — flat strings for scalar fields, arrays for list fields, objects for nested blocks. */
7
+ export type FileConfig = Record<string, string | string[] | Record<string, unknown>>;
8
8
  /**
9
9
  * Search upward from `cwd` for `.sweny.yml` and parse it.
10
10
  * Scalar values are strings, list values (rules, context) are string arrays.
@@ -12,4 +12,4 @@ export type FileConfig = Record<string, string | string[]>;
12
12
  */
13
13
  export declare function loadConfigFile(cwd?: string): FileConfig;
14
14
  /** Starter config written by `sweny new`. */
15
- export declare const STARTER_CONFIG = "# .sweny.yml \u2014 SWEny project configuration\n# Commit this file. Secrets (API keys, tokens) go in .env (gitignored).\n#\n# Every key matches a CLI flag: \"time-range: 4h\" is the same as \"--time-range 4h\".\n# CLI flags override this file; env vars override this file; this file overrides defaults.\n\n# \u2500\u2500 Providers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n# observability-provider: datadog # datadog | sentry | cloudwatch | splunk | elastic | newrelic | loki | prometheus | pagerduty | heroku | opsgenie | vercel | supabase | netlify | fly | render | file\n# issue-tracker-provider: github-issues # github-issues | linear | jira\n# source-control-provider: github # github | gitlab\n# coding-agent-provider: claude # claude | codex | gemini\n# notification-provider: console # console | slack | teams | discord | email | webhook\n\n# \u2500\u2500 Investigation \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n# time-range: 24h\n# severity-focus: errors\n# service-filter: \"*\"\n# investigation-depth: standard # quick | standard | thorough\n\n# \u2500\u2500 PR / branch \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n# base-branch: main\n# pr-labels: agent,triage,needs-review\n\n# \u2500\u2500 Paths \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n# service-map-path: .github/service-map.yml\n# log-file: ./logs/errors.json # required when observability-provider is \"file\"\n\n# \u2500\u2500 Cache \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n# cache-dir: .sweny/cache\n# cache-ttl: 86400\n\n# \u2500\u2500 MCP servers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n# Extend the coding agent with additional tools via MCP.\n# Value is a JSON object \u2014 each key is a server name you choose.\n# See docs/mcp-servers.md for a full catalog with copy-paste configs.\n#\n# Example: GitHub MCP server (query PRs, issues, CI run logs)\n# mcp-servers-json: '{\"github\":{\"type\":\"stdio\",\"command\":\"npx\",\"args\":[\"-y\",\"@modelcontextprotocol/server-github@latest\"],\"env\":{\"GITHUB_PERSONAL_ACCESS_TOKEN\":\"ghp_...\"}}}'\n\n# \u2500\u2500 Local-only quick start \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n# Uncomment to run without any external services (just an LLM API key):\n# observability-provider: file\n# log-file: ./sample-errors.json\n# issue-tracker-provider: file\n# source-control-provider: file\n# notification-provider: file\n# output-dir: .sweny/output\n\n# \u2500\u2500 Credentials (.env) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n# Copy the relevant block into your .env file and fill in the values.\n#\n# Claude (coding agent) \u2014 https://console.anthropic.com/settings/api-keys\n# ANTHROPIC_API_KEY=sk-ant-...\n#\n# GitHub (source control + issue tracker)\n# GITHUB_TOKEN=ghp_... # https://github.com/settings/tokens (repo + issues scopes)\n#\n# Datadog (observability) \u2014 https://app.datadoghq.com/organization-settings\n# DD_API_KEY=... # Organization Settings > API Keys\n# DD_APP_KEY=... # Organization Settings > Application Keys\n# DD_SITE=datadoghq.com # or datadoghq.eu, us3.datadoghq.com, etc.\n#\n# Sentry (observability) \u2014 https://sentry.io/settings/auth-tokens/\n# SENTRY_AUTH_TOKEN=sntrys_...\n# SENTRY_ORG=your-org-slug # from sentry.io/organizations/<slug>/\n# SENTRY_PROJECT=your-project # Project Settings > General > Project Slug\n#\n# Linear (issue tracker) \u2014 https://linear.app/settings/api\n# LINEAR_API_KEY=lin_api_...\n# LINEAR_TEAM_ID=... # Settings > Workspace > Teams > [team] > copy ID from URL\n# LINEAR_BUG_LABEL_ID=... # Settings > Labels > [label] > copy ID from URL\n#\n# Jira (issue tracker) \u2014 https://your-org.atlassian.net\n# JIRA_BASE_URL=https://your-org.atlassian.net\n# JIRA_EMAIL=you@company.com # your Atlassian account email\n# JIRA_API_TOKEN=... # https://id.atlassian.com/manage-profile/security/api-tokens\n#\n# Vercel (observability) \u2014 https://vercel.com/account/tokens\n# VERCEL_TOKEN=...\n# VERCEL_PROJECT_ID=prj_... # Project Settings > General > Project ID\n# VERCEL_TEAM_ID=team_... # optional, for team-owned projects\n#\n# Supabase (observability) \u2014 https://supabase.com/dashboard/account/tokens\n# SUPABASE_MANAGEMENT_KEY=...\n# SUPABASE_PROJECT_REF=... # Project Settings > General > Reference ID\n#\n# Netlify (observability) \u2014 https://app.netlify.com/user/applications#personal-access-tokens\n# NETLIFY_TOKEN=...\n# NETLIFY_SITE_ID=... # Site Settings > General > Site ID\n#\n# Fly.io (observability) \u2014 https://fly.io/user/personal_access_tokens\n# FLY_TOKEN=...\n# FLY_APP_NAME=... # the name of your Fly.io application\n#\n# Render (observability) \u2014 https://dashboard.render.com/u/settings\n# RENDER_API_KEY=...\n# RENDER_SERVICE_ID=srv-... # from your service's Settings page\n#\n# Prometheus (observability) \u2014 self-hosted or Grafana Cloud\n# PROMETHEUS_URL=http://prometheus.internal:9090\n# PROMETHEUS_TOKEN=... # optional, for secured instances\n#\n# PagerDuty (observability) \u2014 https://your-account.pagerduty.com/api_keys\n# PAGERDUTY_API_KEY=...\n#\n# Honeycomb (observability) \u2014 https://docs.honeycomb.io/api/\n# HONEYCOMB_API_KEY=...\n# HONEYCOMB_DATASET=... # dataset name (e.g. production)\n#\n# Heroku (observability) \u2014 https://devcenter.heroku.com/articles/platform-api-reference\n# HEROKU_API_KEY=... # https://dashboard.heroku.com/account\n# HEROKU_APP_NAME=... # the name of your Heroku application\n#\n# OpsGenie (observability) \u2014 https://support.atlassian.com/opsgenie/docs/api-key-management/\n# OPSGENIE_API_KEY=...\n# OPSGENIE_REGION=us # or eu for EU-hosted accounts\n#\n# Slack (notifications) \u2014 https://api.slack.com/apps\n# NOTIFICATION_WEBHOOK_URL=https://hooks.slack.com/services/...\n# # or use a bot token: SLACK_BOT_TOKEN=xoxb-...\n";
15
+ export declare const STARTER_CONFIG = "# .sweny.yml \u2014 SWEny project configuration\n# Commit this file. Secrets (API keys, tokens) go in .env (gitignored).\n#\n# Every key matches a CLI flag: \"time-range: 4h\" is the same as \"--time-range 4h\".\n# CLI flags override this file; env vars override this file; this file overrides defaults.\n\n# \u2500\u2500 Providers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n# observability-provider: datadog # datadog | sentry | cloudwatch | splunk | elastic | newrelic | loki | prometheus | pagerduty | heroku | opsgenie | vercel | supabase | netlify | fly | render | file\n# issue-tracker-provider: github-issues # github-issues | linear | jira\n# source-control-provider: github # github | gitlab\n# coding-agent-provider: claude # claude | codex | gemini\n# notification-provider: console # console | slack | teams | discord | email | webhook\n\n# \u2500\u2500 Investigation \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n# time-range: 24h\n# severity-focus: errors\n# service-filter: \"*\"\n# investigation-depth: standard # quick | standard | thorough\n\n# \u2500\u2500 PR / branch \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n# base-branch: main\n# pr-labels: agent,triage,needs-review\n\n# \u2500\u2500 Paths \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n# service-map-path: .github/service-map.yml\n# log-file: ./logs/errors.json # required when observability-provider is \"file\"\n\n# \u2500\u2500 Cache \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n# cache-dir: .sweny/cache\n# cache-ttl: 86400\n\n# \u2500\u2500 Cloud reporting \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n# Opt-in: paste your project token from cloud.sweny.ai to enable run reporting.\n# cloud-token: sweny_pk_...\n# Or set SWENY_CLOUD_TOKEN in your environment.\n\n# \u2500\u2500 MCP servers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n# Extend the coding agent with additional tools via MCP.\n# Value is a JSON object \u2014 each key is a server name you choose.\n# See docs/mcp-servers.md for a full catalog with copy-paste configs.\n#\n# Example: GitHub MCP server (query PRs, issues, CI run logs)\n# mcp-servers-json: '{\"github\":{\"type\":\"stdio\",\"command\":\"npx\",\"args\":[\"-y\",\"@modelcontextprotocol/server-github@latest\"],\"env\":{\"GITHUB_PERSONAL_ACCESS_TOKEN\":\"ghp_...\"}}}'\n\n# \u2500\u2500 Local-only quick start \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n# Uncomment to run without any external services (just an LLM API key):\n# observability-provider: file\n# log-file: ./sample-errors.json\n# issue-tracker-provider: file\n# source-control-provider: file\n# notification-provider: file\n# output-dir: .sweny/output\n\n# \u2500\u2500 Credentials (.env) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n# Copy the relevant block into your .env file and fill in the values.\n#\n# Claude (coding agent) \u2014 https://console.anthropic.com/settings/api-keys\n# ANTHROPIC_API_KEY=sk-ant-...\n#\n# GitHub (source control + issue tracker)\n# GITHUB_TOKEN=ghp_... # https://github.com/settings/tokens (repo + issues scopes)\n#\n# Datadog (observability) \u2014 https://app.datadoghq.com/organization-settings\n# DD_API_KEY=... # Organization Settings > API Keys\n# DD_APP_KEY=... # Organization Settings > Application Keys\n# DD_SITE=datadoghq.com # or datadoghq.eu, us3.datadoghq.com, etc.\n#\n# Sentry (observability) \u2014 https://sentry.io/settings/auth-tokens/\n# SENTRY_AUTH_TOKEN=sntrys_...\n# SENTRY_ORG=your-org-slug # from sentry.io/organizations/<slug>/\n# SENTRY_PROJECT=your-project # Project Settings > General > Project Slug\n#\n# Linear (issue tracker) \u2014 https://linear.app/settings/api\n# LINEAR_API_KEY=lin_api_...\n# LINEAR_TEAM_ID=... # Settings > Workspace > Teams > [team] > copy ID from URL\n# LINEAR_BUG_LABEL_ID=... # Settings > Labels > [label] > copy ID from URL\n#\n# Jira (issue tracker) \u2014 https://your-org.atlassian.net\n# JIRA_BASE_URL=https://your-org.atlassian.net\n# JIRA_EMAIL=you@company.com # your Atlassian account email\n# JIRA_API_TOKEN=... # https://id.atlassian.com/manage-profile/security/api-tokens\n#\n# Vercel (observability) \u2014 https://vercel.com/account/tokens\n# VERCEL_TOKEN=...\n# VERCEL_PROJECT_ID=prj_... # Project Settings > General > Project ID\n# VERCEL_TEAM_ID=team_... # optional, for team-owned projects\n#\n# Supabase (observability) \u2014 https://supabase.com/dashboard/account/tokens\n# SUPABASE_MANAGEMENT_KEY=...\n# SUPABASE_PROJECT_REF=... # Project Settings > General > Reference ID\n#\n# Netlify (observability) \u2014 https://app.netlify.com/user/applications#personal-access-tokens\n# NETLIFY_TOKEN=...\n# NETLIFY_SITE_ID=... # Site Settings > General > Site ID\n#\n# Fly.io (observability) \u2014 https://fly.io/user/personal_access_tokens\n# FLY_TOKEN=...\n# FLY_APP_NAME=... # the name of your Fly.io application\n#\n# Render (observability) \u2014 https://dashboard.render.com/u/settings\n# RENDER_API_KEY=...\n# RENDER_SERVICE_ID=srv-... # from your service's Settings page\n#\n# Prometheus (observability) \u2014 self-hosted or Grafana Cloud\n# PROMETHEUS_URL=http://prometheus.internal:9090\n# PROMETHEUS_TOKEN=... # optional, for secured instances\n#\n# PagerDuty (observability) \u2014 https://your-account.pagerduty.com/api_keys\n# PAGERDUTY_API_KEY=...\n#\n# Honeycomb (observability) \u2014 https://docs.honeycomb.io/api/\n# HONEYCOMB_API_KEY=...\n# HONEYCOMB_DATASET=... # dataset name (e.g. production)\n#\n# Heroku (observability) \u2014 https://devcenter.heroku.com/articles/platform-api-reference\n# HEROKU_API_KEY=... # https://dashboard.heroku.com/account\n# HEROKU_APP_NAME=... # the name of your Heroku application\n#\n# OpsGenie (observability) \u2014 https://support.atlassian.com/opsgenie/docs/api-key-management/\n# OPSGENIE_API_KEY=...\n# OPSGENIE_REGION=us # or eu for EU-hosted accounts\n#\n# Slack (notifications) \u2014 https://api.slack.com/apps\n# NOTIFICATION_WEBHOOK_URL=https://hooks.slack.com/services/...\n# # or use a bot token: SLACK_BOT_TOKEN=xoxb-...\n";
@@ -62,6 +62,19 @@ export function loadConfigFile(cwd = process.cwd()) {
62
62
  if (Array.isArray(value)) {
63
63
  config[key] = value.map(String);
64
64
  }
65
+ else if (value && typeof value === "object") {
66
+ for (const [subKey, subVal] of Object.entries(value)) {
67
+ if (subVal && typeof subVal === "object" && !Array.isArray(subVal)) {
68
+ config[`${key}.${subKey}`] = subVal;
69
+ }
70
+ else if (Array.isArray(subVal)) {
71
+ config[`${key}.${subKey}`] = subVal.map(String);
72
+ }
73
+ else if (subVal != null && subVal !== "") {
74
+ config[`${key}.${subKey}`] = String(subVal);
75
+ }
76
+ }
77
+ }
65
78
  else if (value != null && value !== "") {
66
79
  config[key] = String(value);
67
80
  }
@@ -118,6 +131,11 @@ export const STARTER_CONFIG = `# .sweny.yml — SWEny project configuration
118
131
  # cache-dir: .sweny/cache
119
132
  # cache-ttl: 86400
120
133
 
134
+ # ── Cloud reporting ───────────────────────────────────────────────────
135
+ # Opt-in: paste your project token from cloud.sweny.ai to enable run reporting.
136
+ # cloud-token: sweny_pk_...
137
+ # Or set SWENY_CLOUD_TOKEN in your environment.
138
+
121
139
  # ── MCP servers ───────────────────────────────────────────────────────
122
140
  # Extend the coding agent with additional tools via MCP.
123
141
  # Value is a JSON object — each key is a server name you choose.
@@ -34,6 +34,7 @@ export interface CliConfig {
34
34
  serviceMapPath: string;
35
35
  githubToken: string;
36
36
  botToken: string;
37
+ cloudToken: string;
37
38
  sourceControlProvider: string;
38
39
  jiraBaseUrl: string;
39
40
  jiraEmail: string;
@@ -60,6 +61,8 @@ export interface CliConfig {
60
61
  workspaceTools: string[];
61
62
  rules: string[];
62
63
  context: string[];
64
+ offline: boolean;
65
+ fetchAuth: Record<string, string>;
63
66
  }
64
67
  export declare function registerTriageCommand(program: Command): Command;
65
68
  export declare function parseCliInputs(options: Record<string, unknown>, fileConfig?: FileConfig): CliConfig;
@@ -61,7 +61,8 @@ export function registerTriageCommand(program) {
61
61
  .option("--cache-ttl <seconds>", "Cache TTL in seconds, 0 = infinite (default: 86400)")
62
62
  .option("--no-cache", "Disable step cache")
63
63
  .option("--output-dir <path>", "Output directory for file providers (default: .sweny/output)")
64
- .option("--workspace-tools <tools>", "Comma-separated workspace tool integrations to enable (slack, notion, pagerduty, monday)");
64
+ .option("--workspace-tools <tools>", "Comma-separated workspace tool integrations to enable (slack, notion, pagerduty, monday)")
65
+ .option("--offline", "Skip URL Sources and fail fast if any are referenced", false);
65
66
  }
66
67
  export function parseCliInputs(options, fileConfig = {}) {
67
68
  const env = process.env;
@@ -126,6 +127,14 @@ export function parseCliInputs(options, fileConfig = {}) {
126
127
  serviceMapPath: options.serviceMapPath || f("service-map-path") || ".github/service-map.yml",
127
128
  githubToken: env.GITHUB_TOKEN || "",
128
129
  botToken: env.BOT_TOKEN || "",
130
+ cloudToken: (() => {
131
+ const fromEnv = env.SWENY_CLOUD_TOKEN || "";
132
+ const fromFile = f("cloud-token") || "";
133
+ if (!fromEnv && fromFile) {
134
+ console.warn("\x1b[33m⚠ cloud-token is set in .sweny.yml — consider using SWENY_CLOUD_TOKEN env var instead to avoid accidental commits.\x1b[0m");
135
+ }
136
+ return fromEnv || fromFile;
137
+ })(),
129
138
  sourceControlProvider: options.sourceControlProvider || f("source-control-provider") || "github",
130
139
  // Secrets: env only
131
140
  jiraBaseUrl: env.JIRA_BASE_URL || "",
@@ -158,8 +167,21 @@ export function parseCliInputs(options, fileConfig = {}) {
158
167
  .filter(Boolean),
159
168
  rules: fa("rules"),
160
169
  context: fa("context"),
170
+ offline: Boolean(options.offline),
171
+ fetchAuth: parseFetchAuth(fileConfig),
161
172
  };
162
173
  }
174
+ function parseFetchAuth(fileConfig) {
175
+ const raw = fileConfig["fetch.auth"];
176
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
177
+ return {};
178
+ const out = {};
179
+ for (const [host, envVar] of Object.entries(raw)) {
180
+ if (typeof envVar === "string" && envVar)
181
+ out[host] = envVar;
182
+ }
183
+ return out;
184
+ }
163
185
  /**
164
186
  * All recognized workspace tool names. Update here when adding a new Category B MCP server.
165
187
  *
@@ -557,7 +579,8 @@ export function registerImplementCommand(program) {
557
579
  .option("--workspace-tools <tools>", "Comma-separated workspace tool integrations to enable (slack, notion, pagerduty, monday)")
558
580
  .option("--review-mode <mode>", "PR merge behavior: auto (GitHub auto-merge when CI passes) | review (human approval, default)", "review")
559
581
  .option("--additional-instructions <text>", "Extra instructions for the coding agent")
560
- .option("--stream", "Stream NDJSON events to stdout (for Studio / automation)", false);
582
+ .option("--stream", "Stream NDJSON events to stdout (for Studio / automation)", false)
583
+ .option("--offline", "Skip URL Sources and fail fast if any are referenced", false);
561
584
  }
562
585
  /**
563
586
  * Parse a JSON string into MCP server configs.