@sweny-ai/core 0.1.63 → 0.1.64

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.
@@ -0,0 +1,355 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import * as os from "node:os";
5
+ import { fetchMarketplaceWorkflow, fetchMarketplaceIndex, computeProviderMismatch, buildAdaptPrompt, adaptWorkflowInteractive, installMarketplaceWorkflow, MARKETPLACE_RAW_BASE, } from "./marketplace.js";
6
+ vi.mock("@clack/prompts", () => ({
7
+ select: vi.fn(),
8
+ text: vi.fn(),
9
+ spinner: () => ({ start: vi.fn(), stop: vi.fn() }),
10
+ log: { message: vi.fn(), error: vi.fn() },
11
+ isCancel: (v) => v === Symbol.for("cancel"),
12
+ }));
13
+ vi.mock("../workflow-builder.js", () => ({
14
+ refineWorkflow: vi.fn(),
15
+ }));
16
+ describe("fetchMarketplaceWorkflow", () => {
17
+ let fetchSpy;
18
+ beforeEach(() => {
19
+ fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async () => {
20
+ return new Response("id: pr-review\nname: PR Review\n", { status: 200 });
21
+ });
22
+ });
23
+ afterEach(() => fetchSpy.mockRestore());
24
+ it("fetches workflow YAML from raw GitHub", async () => {
25
+ const result = await fetchMarketplaceWorkflow("pr-review");
26
+ expect(result.id).toBe("pr-review");
27
+ expect(result.yaml).toContain("id: pr-review");
28
+ expect(fetchSpy).toHaveBeenCalledWith(`${MARKETPLACE_RAW_BASE}/workflows/pr-review.yml`);
29
+ });
30
+ });
31
+ describe("fetchMarketplaceWorkflow errors", () => {
32
+ let fetchSpy;
33
+ afterEach(() => fetchSpy?.mockRestore());
34
+ it("throws not-found on 404", async () => {
35
+ fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response("", { status: 404 }));
36
+ await expect(fetchMarketplaceWorkflow("missing")).rejects.toMatchObject({
37
+ kind: "not-found",
38
+ message: expect.stringContaining("missing"),
39
+ });
40
+ });
41
+ it("throws rate-limit on 403 with X-RateLimit-Remaining: 0", async () => {
42
+ fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response("", {
43
+ status: 403,
44
+ headers: { "X-RateLimit-Remaining": "0", "X-RateLimit-Reset": "1234567890" },
45
+ }));
46
+ await expect(fetchMarketplaceWorkflow("pr-review")).rejects.toMatchObject({
47
+ kind: "rate-limit",
48
+ retryAfter: 1234567890,
49
+ });
50
+ });
51
+ it("throws network error when fetch rejects", async () => {
52
+ fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async () => {
53
+ throw new TypeError("fetch failed");
54
+ });
55
+ await expect(fetchMarketplaceWorkflow("pr-review")).rejects.toMatchObject({
56
+ kind: "network",
57
+ });
58
+ });
59
+ });
60
+ describe("fetchMarketplaceIndex", () => {
61
+ afterEach(() => vi.restoreAllMocks());
62
+ it("fetches and parses index.json", async () => {
63
+ const body = JSON.stringify([
64
+ { id: "pr-review", name: "PR Review", description: "Reviews PRs", skills: ["github"] },
65
+ { id: "issue-triage", name: "Issue Triage", description: "Triages issues", skills: ["github"] },
66
+ ]);
67
+ vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response(body, { status: 200 }));
68
+ const entries = await fetchMarketplaceIndex();
69
+ expect(entries).toHaveLength(2);
70
+ expect(entries[0]).toMatchObject({ id: "pr-review", name: "PR Review" });
71
+ });
72
+ it("throws not-found when index.json is missing", async () => {
73
+ vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response("", { status: 404 }));
74
+ await expect(fetchMarketplaceIndex()).rejects.toMatchObject({ kind: "not-found" });
75
+ });
76
+ });
77
+ const testSkills = [
78
+ { id: "linear", name: "Linear", description: "", category: "tasks", config: {}, tools: [] },
79
+ {
80
+ id: "github-issues",
81
+ name: "GitHub Issues",
82
+ description: "",
83
+ category: "tasks",
84
+ config: {},
85
+ tools: [],
86
+ },
87
+ { id: "github", name: "GitHub", description: "", category: "git", config: {}, tools: [] },
88
+ {
89
+ id: "datadog",
90
+ name: "Datadog",
91
+ description: "",
92
+ category: "observability",
93
+ config: {},
94
+ tools: [],
95
+ },
96
+ { id: "sentry", name: "Sentry", description: "", category: "observability", config: {}, tools: [] },
97
+ ];
98
+ describe("computeProviderMismatch", () => {
99
+ it("returns empty array when all workflow skills match config", () => {
100
+ const mismatches = computeProviderMismatch(["github", "github-issues"], { "source-control-provider": "github", "issue-tracker-provider": "github-issues" }, testSkills);
101
+ expect(mismatches).toEqual([]);
102
+ });
103
+ it("detects tasks mismatch (workflow=linear, config=github-issues)", () => {
104
+ const mismatches = computeProviderMismatch(["linear"], { "issue-tracker-provider": "github-issues" }, testSkills);
105
+ expect(mismatches).toEqual([
106
+ {
107
+ category: "tasks",
108
+ configKey: "issue-tracker-provider",
109
+ workflowSkill: "linear",
110
+ userProvider: "github-issues",
111
+ },
112
+ ]);
113
+ });
114
+ it("detects observability mismatch and ignores general-category skills", () => {
115
+ const mismatches = computeProviderMismatch(["datadog"], { "observability-provider": "sentry" }, testSkills);
116
+ expect(mismatches).toEqual([
117
+ {
118
+ category: "observability",
119
+ configKey: "observability-provider",
120
+ workflowSkill: "datadog",
121
+ userProvider: "sentry",
122
+ },
123
+ ]);
124
+ });
125
+ it("skips mismatch when the user has no provider set for that role", () => {
126
+ const mismatches = computeProviderMismatch(["linear"], {}, testSkills);
127
+ expect(mismatches).toEqual([]);
128
+ });
129
+ it("ignores workflow skills not in the available skill registry", () => {
130
+ const mismatches = computeProviderMismatch(["unknown-skill"], { "issue-tracker-provider": "github-issues" }, testSkills);
131
+ expect(mismatches).toEqual([]);
132
+ });
133
+ });
134
+ describe("buildAdaptPrompt", () => {
135
+ it("describes single mismatch", () => {
136
+ const prompt = buildAdaptPrompt([
137
+ {
138
+ category: "tasks",
139
+ configKey: "issue-tracker-provider",
140
+ workflowSkill: "linear",
141
+ userProvider: "github-issues",
142
+ },
143
+ ]);
144
+ expect(prompt).toContain("linear");
145
+ expect(prompt).toContain("github-issues");
146
+ expect(prompt).toContain("issue-tracker");
147
+ expect(prompt.toLowerCase()).toContain("rewrite");
148
+ });
149
+ it("describes multiple mismatches", () => {
150
+ const prompt = buildAdaptPrompt([
151
+ {
152
+ category: "tasks",
153
+ configKey: "issue-tracker-provider",
154
+ workflowSkill: "linear",
155
+ userProvider: "github-issues",
156
+ },
157
+ {
158
+ category: "observability",
159
+ configKey: "observability-provider",
160
+ workflowSkill: "datadog",
161
+ userProvider: "sentry",
162
+ },
163
+ ]);
164
+ expect(prompt).toContain("linear");
165
+ expect(prompt).toContain("datadog");
166
+ expect(prompt).toContain("sentry");
167
+ });
168
+ });
169
+ describe("adaptWorkflowInteractive", () => {
170
+ beforeEach(() => vi.clearAllMocks());
171
+ const sampleWorkflow = {
172
+ id: "pr-review",
173
+ name: "PR Review",
174
+ description: "",
175
+ entry: "start",
176
+ nodes: { start: { name: "Start", instruction: "x", skills: [] } },
177
+ edges: [],
178
+ };
179
+ it("returns the workflow when user accepts on first render", async () => {
180
+ const p = await import("@clack/prompts");
181
+ p.select.mockResolvedValueOnce("accept");
182
+ const claudeStub = { run: vi.fn() };
183
+ const result = await adaptWorkflowInteractive(sampleWorkflow, {
184
+ claude: claudeStub,
185
+ skills: [],
186
+ logger: { debug: () => { }, info: () => { }, warn: () => { }, error: () => { } },
187
+ });
188
+ expect(result).toEqual(sampleWorkflow);
189
+ });
190
+ it("returns null when user cancels", async () => {
191
+ const p = await import("@clack/prompts");
192
+ p.select.mockResolvedValueOnce("cancel");
193
+ const claudeStub = { run: vi.fn() };
194
+ const result = await adaptWorkflowInteractive(sampleWorkflow, {
195
+ claude: claudeStub,
196
+ skills: [],
197
+ logger: { debug: () => { }, info: () => { }, warn: () => { }, error: () => { } },
198
+ });
199
+ expect(result).toBeNull();
200
+ });
201
+ it("calls refineWorkflow when user picks 'refine'", async () => {
202
+ const p = await import("@clack/prompts");
203
+ const wb = await import("../workflow-builder.js");
204
+ p.select.mockResolvedValueOnce("refine").mockResolvedValueOnce("accept");
205
+ p.text.mockResolvedValueOnce("make it better");
206
+ wb.refineWorkflow.mockResolvedValueOnce(sampleWorkflow);
207
+ const claudeStub = { run: vi.fn() };
208
+ const result = await adaptWorkflowInteractive(sampleWorkflow, {
209
+ claude: claudeStub,
210
+ skills: [],
211
+ logger: { debug: () => { }, info: () => { }, warn: () => { }, error: () => { } },
212
+ });
213
+ expect(wb.refineWorkflow).toHaveBeenCalledWith(sampleWorkflow, "make it better", expect.any(Object));
214
+ expect(result).toEqual(sampleWorkflow);
215
+ });
216
+ });
217
+ describe("installMarketplaceWorkflow", () => {
218
+ const tmpDirs = [];
219
+ afterEach(() => {
220
+ for (const d of tmpDirs)
221
+ fs.rmSync(d, { recursive: true, force: true });
222
+ tmpDirs.length = 0;
223
+ vi.restoreAllMocks();
224
+ });
225
+ function tmp() {
226
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "sweny-install-"));
227
+ tmpDirs.push(dir);
228
+ return dir;
229
+ }
230
+ const sampleYaml = "id: pr-review\nname: PR Review\ndescription: Review PRs\nentry: start\n" +
231
+ "nodes:\n start:\n name: Start\n instruction: hi\n skills: [github]\nedges: []\n";
232
+ it("installs workflow as-is when no mismatch and no .sweny.yml", async () => {
233
+ const cwd = tmp();
234
+ vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response(sampleYaml, { status: 200 }));
235
+ const result = await installMarketplaceWorkflow("pr-review", {
236
+ cwd,
237
+ availableSkills: testSkills,
238
+ claude: null,
239
+ logger: { debug: () => { }, info: () => { }, warn: () => { }, error: () => { } },
240
+ });
241
+ expect(result.installed).toBe(true);
242
+ expect(result.adapted).toBe(false);
243
+ expect(fs.existsSync(path.join(cwd, ".sweny.yml"))).toBe(true);
244
+ expect(fs.existsSync(path.join(cwd, ".sweny", "workflows", "pr-review.yml"))).toBe(true);
245
+ });
246
+ it("warns and installs as-is when mismatch detected but no agent", async () => {
247
+ const cwd = tmp();
248
+ fs.writeFileSync(path.join(cwd, ".sweny.yml"), "issue-tracker-provider: github-issues\n");
249
+ const yamlWithLinear = sampleYaml.replace("[github]", "[linear]");
250
+ vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response(yamlWithLinear, { status: 200 }));
251
+ const warnings = [];
252
+ const result = await installMarketplaceWorkflow("pr-review", {
253
+ cwd,
254
+ availableSkills: testSkills,
255
+ claude: null,
256
+ logger: {
257
+ debug: () => { },
258
+ info: () => { },
259
+ warn: (m) => warnings.push(m),
260
+ error: () => { },
261
+ },
262
+ });
263
+ expect(result.installed).toBe(true);
264
+ expect(result.adapted).toBe(false);
265
+ expect(result.mismatches).toHaveLength(1);
266
+ expect(warnings.some((w) => w.includes("linear"))).toBe(true);
267
+ });
268
+ it("throws with not-found kind when workflow missing", async () => {
269
+ const cwd = tmp();
270
+ vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response("", { status: 404 }));
271
+ await expect(installMarketplaceWorkflow("nope", {
272
+ cwd,
273
+ availableSkills: testSkills,
274
+ claude: null,
275
+ logger: { debug: () => { }, info: () => { }, warn: () => { }, error: () => { } },
276
+ })).rejects.toMatchObject({ kind: "not-found" });
277
+ });
278
+ // ── I1: pre-computed provider inference ────────────────────────────────
279
+ it("uses inferredSourceControl instead of skill-based inference for .sweny.yml", async () => {
280
+ // Workflow references github skill, but caller says the remote is gitlab.
281
+ // The written .sweny.yml must say gitlab, not github.
282
+ const cwd = tmp();
283
+ vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response(sampleYaml, { status: 200 }));
284
+ await installMarketplaceWorkflow("pr-review", {
285
+ cwd,
286
+ availableSkills: testSkills,
287
+ claude: null,
288
+ logger: { debug: () => { }, info: () => { }, warn: () => { }, error: () => { } },
289
+ inferredSourceControl: "gitlab",
290
+ inferredIssueTracker: "github-issues",
291
+ inferredObservability: null,
292
+ });
293
+ const swenyYml = fs.readFileSync(path.join(cwd, ".sweny.yml"), "utf-8");
294
+ expect(swenyYml).toContain("source-control-provider: gitlab");
295
+ // skill-based fallback would have written "github" — verify it did NOT
296
+ expect(swenyYml).not.toContain("source-control-provider: github");
297
+ });
298
+ it("falls back to skill-based inference when inferredSourceControl is absent", async () => {
299
+ // Workflow references github skill, no pre-computed values → should infer github
300
+ const cwd = tmp();
301
+ vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response(sampleYaml, { status: 200 }));
302
+ await installMarketplaceWorkflow("pr-review", {
303
+ cwd,
304
+ availableSkills: testSkills,
305
+ claude: null,
306
+ logger: { debug: () => { }, info: () => { }, warn: () => { }, error: () => { } },
307
+ });
308
+ const swenyYml = fs.readFileSync(path.join(cwd, ".sweny.yml"), "utf-8");
309
+ expect(swenyYml).toContain("source-control-provider: github");
310
+ });
311
+ // ── I2: overwrite protection ────────────────────────────────────────────
312
+ it("returns alreadyExists=true without overwriting when overwrite is false (default)", async () => {
313
+ const cwd = tmp();
314
+ // Pre-create the workflow file with distinct content
315
+ const wfDir = path.join(cwd, ".sweny", "workflows");
316
+ fs.mkdirSync(wfDir, { recursive: true });
317
+ const wfPath = path.join(wfDir, "pr-review.yml");
318
+ fs.writeFileSync(wfPath, "# original content\n", "utf-8");
319
+ vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response(sampleYaml, { status: 200 }));
320
+ const result = await installMarketplaceWorkflow("pr-review", {
321
+ cwd,
322
+ availableSkills: testSkills,
323
+ claude: null,
324
+ logger: { debug: () => { }, info: () => { }, warn: () => { }, error: () => { } },
325
+ // overwrite: false is the default — omitting it intentionally
326
+ });
327
+ expect(result.installed).toBe(false);
328
+ expect(result.alreadyExists).toBe(true);
329
+ expect(result.workflowPath).toBe(wfPath);
330
+ // File must be unchanged
331
+ expect(fs.readFileSync(wfPath, "utf-8")).toBe("# original content\n");
332
+ });
333
+ it("overwrites existing workflow file when overwrite is true", async () => {
334
+ const cwd = tmp();
335
+ // Pre-create the workflow file with stale content
336
+ const wfDir = path.join(cwd, ".sweny", "workflows");
337
+ fs.mkdirSync(wfDir, { recursive: true });
338
+ const wfPath = path.join(wfDir, "pr-review.yml");
339
+ fs.writeFileSync(wfPath, "# stale content\n", "utf-8");
340
+ vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response(sampleYaml, { status: 200 }));
341
+ const result = await installMarketplaceWorkflow("pr-review", {
342
+ cwd,
343
+ availableSkills: testSkills,
344
+ claude: null,
345
+ logger: { debug: () => { }, info: () => { }, warn: () => { }, error: () => { } },
346
+ overwrite: true,
347
+ });
348
+ expect(result.installed).toBe(true);
349
+ expect(result.alreadyExists).toBeUndefined();
350
+ // File must contain the new content
351
+ const written = fs.readFileSync(wfPath, "utf-8");
352
+ expect(written).toContain("PR Review");
353
+ expect(written).not.toContain("stale content");
354
+ });
355
+ });
package/dist/cli/new.d.ts CHANGED
@@ -76,6 +76,23 @@ export declare function collectCredentials(selections: InitSelections): Credenti
76
76
  * Returns provider + normalised remote (e.g. "github.com/owner/repo"), or null.
77
77
  */
78
78
  export declare function detectGitRemote(cwd: string): GitRemoteInfo | null;
79
+ /**
80
+ * Infer source-control-provider from git remote detection.
81
+ */
82
+ export declare function inferSourceControl(gitInfo: GitRemoteInfo | null): string;
83
+ /**
84
+ * Infer issue-tracker-provider from skills in the workflow.
85
+ *
86
+ * Explicit issue-tracker skills win. Otherwise we default to `github-issues`
87
+ * regardless of source-control provider: we don't ship a gitlab issues
88
+ * provider (yet), and `github-issues` is a sensible "use the same platform
89
+ * as source control" default for the supported case.
90
+ */
91
+ export declare function inferIssueTracker(skills: string[], _sourceControl: string): string;
92
+ /**
93
+ * Infer observability-provider from skills in the workflow.
94
+ */
95
+ export declare function inferObservability(skills: string[]): string | null;
79
96
  /**
80
97
  * Generate .sweny.yml content from inferred providers.
81
98
  */
@@ -89,12 +106,25 @@ export declare function buildEnvTemplate(credentials: Credential[]): string;
89
106
  * Only includes credentials WITHOUT defaults as secrets.
90
107
  */
91
108
  export declare function buildActionWorkflow(credentials: Credential[], cronExpression: string): string;
109
+ /** Write .sweny.yml if missing. Returns true if created, false if file existed. */
110
+ export declare function writeSwenyYmlIfMissing(cwd: string, sourceControl: string, observability: string | null, issueTracker: string): boolean;
111
+ /** Append credential keys to .env that aren't already present. Returns count appended. */
112
+ export declare function appendMissingEnvKeys(cwd: string, credentials: Credential[]): number;
113
+ /** Write workflow YAML into .sweny/workflows/<id>.yml. Returns info about what happened. */
114
+ export declare function writeWorkflowFile(cwd: string, id: string, yaml: string, options?: {
115
+ overwrite?: boolean;
116
+ }): {
117
+ written: boolean;
118
+ exists?: boolean;
119
+ path: string;
120
+ };
92
121
  /**
93
122
  * Interactive workflow creation wizard — workflow-first approach.
94
123
  *
95
124
  * Flow:
96
125
  * 1. Detect git remote (used to infer source-control provider)
97
- * 2. Pick one of four branches:
126
+ * 2. Pick one of five branches:
127
+ * - `__marketplace` → browse published workflows at swenyai/workflows, then delegate to `installMarketplaceWorkflow`
98
128
  * - A built-in `WORKFLOW_TEMPLATES` entry → write that workflow file
99
129
  * - `__custom` → LLM generates a workflow from a user description
100
130
  * - `__e2e` → delegate to the e2e browser-testing wizard in `./e2e.ts`
@@ -108,4 +138,7 @@ export declare function buildActionWorkflow(credentials: Credential[], cronExpre
108
138
  *
109
139
  * Safe to re-run in an existing project to add additional workflows.
110
140
  */
111
- export declare function runNew(): Promise<void>;
141
+ export declare function runNew(options?: {
142
+ marketplaceId?: string;
143
+ skipIntro?: boolean;
144
+ }): Promise<void>;