@sweny-ai/core 0.1.62 → 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.
- package/dist/__tests__/cascade.test.d.ts +11 -0
- package/dist/__tests__/cascade.test.js +449 -0
- package/dist/cli/main.js +4 -4
- package/dist/cli/marketplace.d.ts +103 -0
- package/dist/cli/marketplace.js +290 -0
- package/dist/cli/marketplace.test.d.ts +1 -0
- package/dist/cli/marketplace.test.js +355 -0
- package/dist/cli/new.d.ts +35 -2
- package/dist/cli/new.js +156 -51
- package/dist/cli/new.test.js +215 -2
- package/dist/executor.js +105 -26
- package/dist/schema.d.ts +724 -0
- package/dist/schema.js +51 -1
- package/dist/source-resolver.d.ts +22 -0
- package/dist/source-resolver.js +174 -0
- package/dist/sources.d.ts +9 -6
- package/dist/sources.js +13 -146
- package/dist/sources.test.js +2 -1
- package/dist/sources.validation.test.js +2 -1
- package/dist/templates.js +2 -1
- package/dist/types.d.ts +20 -0
- package/package.json +1 -1
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
// packages/core/src/cli/marketplace.ts
|
|
2
|
+
/**
|
|
3
|
+
* Marketplace install — fetch workflows from swenyai/workflows and
|
|
4
|
+
* adapt them to the user's .sweny.yml providers.
|
|
5
|
+
*
|
|
6
|
+
* Pure functions for fetch/mismatch/adapt; file writes delegate to
|
|
7
|
+
* helpers in ./new.ts.
|
|
8
|
+
*/
|
|
9
|
+
import * as p from "@clack/prompts";
|
|
10
|
+
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
|
11
|
+
import { refineWorkflow } from "../workflow-builder.js";
|
|
12
|
+
import { DagRenderer } from "./renderer.js";
|
|
13
|
+
import { workflowZ, validateWorkflow } from "../schema.js";
|
|
14
|
+
import { loadConfigFile } from "./config-file.js";
|
|
15
|
+
import { extractSkillsFromYaml, collectCredentialsForSkills, writeSwenyYmlIfMissing, appendMissingEnvKeys, writeWorkflowFile, } from "./new.js";
|
|
16
|
+
export const MARKETPLACE_REPO = "swenyai/workflows";
|
|
17
|
+
export const MARKETPLACE_RAW_BASE = `https://raw.githubusercontent.com/${MARKETPLACE_REPO}/main`;
|
|
18
|
+
export async function fetchMarketplaceWorkflow(id) {
|
|
19
|
+
const url = `${MARKETPLACE_RAW_BASE}/workflows/${id}.yml`;
|
|
20
|
+
let res;
|
|
21
|
+
try {
|
|
22
|
+
res = await fetch(url);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
const err = new Error(`Could not reach github.com — check your connection`);
|
|
26
|
+
err.kind = "network";
|
|
27
|
+
throw err;
|
|
28
|
+
}
|
|
29
|
+
if (res.status === 404) {
|
|
30
|
+
const err = new Error(`Workflow "${id}" not found in ${MARKETPLACE_REPO}. See https://marketplace.sweny.ai for available workflows.`);
|
|
31
|
+
err.kind = "not-found";
|
|
32
|
+
throw err;
|
|
33
|
+
}
|
|
34
|
+
if (res.status === 403 && res.headers.get("X-RateLimit-Remaining") === "0") {
|
|
35
|
+
const reset = res.headers.get("X-RateLimit-Reset");
|
|
36
|
+
const err = new Error(`GitHub rate limit hit. Set GITHUB_TOKEN to raise the limit, or retry later.`);
|
|
37
|
+
err.kind = "rate-limit";
|
|
38
|
+
if (reset)
|
|
39
|
+
err.retryAfter = parseInt(reset, 10);
|
|
40
|
+
throw err;
|
|
41
|
+
}
|
|
42
|
+
if (!res.ok) {
|
|
43
|
+
const err = new Error(`Fetch failed with status ${res.status}`);
|
|
44
|
+
err.kind = "unknown";
|
|
45
|
+
throw err;
|
|
46
|
+
}
|
|
47
|
+
return { id, yaml: await res.text() };
|
|
48
|
+
}
|
|
49
|
+
export async function fetchMarketplaceIndex() {
|
|
50
|
+
const url = `${MARKETPLACE_RAW_BASE}/index.json`;
|
|
51
|
+
let res;
|
|
52
|
+
try {
|
|
53
|
+
res = await fetch(url);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
const err = new Error(`Could not reach github.com`);
|
|
57
|
+
err.kind = "network";
|
|
58
|
+
throw err;
|
|
59
|
+
}
|
|
60
|
+
if (res.status === 404) {
|
|
61
|
+
const err = new Error(`Marketplace index not found`);
|
|
62
|
+
err.kind = "not-found";
|
|
63
|
+
throw err;
|
|
64
|
+
}
|
|
65
|
+
if (!res.ok) {
|
|
66
|
+
const err = new Error(`Fetch failed with status ${res.status}`);
|
|
67
|
+
err.kind = "unknown";
|
|
68
|
+
throw err;
|
|
69
|
+
}
|
|
70
|
+
const raw = (await res.json());
|
|
71
|
+
if (!Array.isArray(raw)) {
|
|
72
|
+
const err = new Error(`Marketplace index is not an array`);
|
|
73
|
+
err.kind = "bad-yaml";
|
|
74
|
+
throw err;
|
|
75
|
+
}
|
|
76
|
+
return raw;
|
|
77
|
+
}
|
|
78
|
+
const CATEGORY_TO_CONFIG_KEY = {
|
|
79
|
+
git: "source-control-provider",
|
|
80
|
+
tasks: "issue-tracker-provider",
|
|
81
|
+
observability: "observability-provider",
|
|
82
|
+
notification: "notification-provider",
|
|
83
|
+
};
|
|
84
|
+
export function computeProviderMismatch(workflowSkills, fileConfig, availableSkills) {
|
|
85
|
+
const skillMap = new Map(availableSkills.map((s) => [s.id, s]));
|
|
86
|
+
const out = [];
|
|
87
|
+
for (const skillId of workflowSkills) {
|
|
88
|
+
const skill = skillMap.get(skillId);
|
|
89
|
+
if (!skill)
|
|
90
|
+
continue;
|
|
91
|
+
const configKey = CATEGORY_TO_CONFIG_KEY[skill.category];
|
|
92
|
+
if (!configKey)
|
|
93
|
+
continue;
|
|
94
|
+
const userProvider = fileConfig[configKey];
|
|
95
|
+
if (typeof userProvider !== "string")
|
|
96
|
+
continue;
|
|
97
|
+
if (userProvider === skillId)
|
|
98
|
+
continue;
|
|
99
|
+
out.push({
|
|
100
|
+
category: skill.category,
|
|
101
|
+
configKey,
|
|
102
|
+
workflowSkill: skillId,
|
|
103
|
+
userProvider,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
export function buildAdaptPrompt(mismatches) {
|
|
109
|
+
const swaps = mismatches
|
|
110
|
+
.map((m) => `- Replace the \`${m.workflowSkill}\` skill (${m.configKey}) with \`${m.userProvider}\`, preserving the intent of each node.`)
|
|
111
|
+
.join("\n");
|
|
112
|
+
return [
|
|
113
|
+
`The target project's .sweny.yml declares different providers than this workflow uses.`,
|
|
114
|
+
`Rewrite the workflow so every node uses the target project's providers:`,
|
|
115
|
+
"",
|
|
116
|
+
swaps,
|
|
117
|
+
"",
|
|
118
|
+
`Keep node IDs, edge structure, and instruction intent. Only change skill references and any node instructions that name the old provider by name.`,
|
|
119
|
+
].join("\n");
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Render the workflow DAG and run an accept/refine/cancel loop.
|
|
123
|
+
* Returns the accepted workflow or null on cancel.
|
|
124
|
+
*
|
|
125
|
+
* Applies `initialRefinement` (if provided) before the first render.
|
|
126
|
+
*/
|
|
127
|
+
export async function adaptWorkflowInteractive(workflow, options) {
|
|
128
|
+
const { claude, skills, logger, initialRefinement } = options;
|
|
129
|
+
let current = workflow;
|
|
130
|
+
if (initialRefinement) {
|
|
131
|
+
const spinner = p.spinner();
|
|
132
|
+
spinner.start("Adapting workflow to your project…");
|
|
133
|
+
try {
|
|
134
|
+
current = await refineWorkflow(current, initialRefinement, { claude, skills, logger });
|
|
135
|
+
spinner.stop("Adapted");
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
spinner.stop("Adaptation failed");
|
|
139
|
+
p.log.error(` ${err instanceof Error ? err.message : String(err)}`);
|
|
140
|
+
// fall through — user sees the un-adapted workflow
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
while (true) {
|
|
144
|
+
console.log("");
|
|
145
|
+
console.log(new DagRenderer(current, { animate: false }).renderToString());
|
|
146
|
+
console.log("");
|
|
147
|
+
const action = await p.select({
|
|
148
|
+
message: "Looks good?",
|
|
149
|
+
options: [
|
|
150
|
+
{ value: "accept", label: "Yes — use this workflow" },
|
|
151
|
+
{ value: "refine", label: "Refine — describe what to change" },
|
|
152
|
+
{ value: "cancel", label: "Cancel" },
|
|
153
|
+
],
|
|
154
|
+
});
|
|
155
|
+
if (p.isCancel(action) || action === "cancel")
|
|
156
|
+
return null;
|
|
157
|
+
if (action === "accept")
|
|
158
|
+
return current;
|
|
159
|
+
if (action === "refine") {
|
|
160
|
+
const refinement = await p.text({
|
|
161
|
+
message: "What would you like to change?",
|
|
162
|
+
validate: (v) => (v && v.trim().length > 0 ? undefined : "Refinement is required"),
|
|
163
|
+
});
|
|
164
|
+
if (p.isCancel(refinement))
|
|
165
|
+
return null;
|
|
166
|
+
const rspin = p.spinner();
|
|
167
|
+
rspin.start("Refining…");
|
|
168
|
+
try {
|
|
169
|
+
current = await refineWorkflow(current, refinement, { claude, skills, logger });
|
|
170
|
+
rspin.stop("Refined");
|
|
171
|
+
}
|
|
172
|
+
catch (err) {
|
|
173
|
+
rspin.stop("Failed");
|
|
174
|
+
p.log.error(` ${err instanceof Error ? err.message : String(err)}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Fetch a workflow from swenyai/workflows, adapt if provider mismatch is detected
|
|
181
|
+
* AND an agent is available, then write files idempotently.
|
|
182
|
+
*
|
|
183
|
+
* When `overwrite` is false (default) and the workflow file already exists,
|
|
184
|
+
* returns `{ installed: false, alreadyExists: true, workflowPath }` without
|
|
185
|
+
* touching the file. The caller (runNew) is responsible for prompting the user.
|
|
186
|
+
*
|
|
187
|
+
* Pre-computed provider fields (`inferredSourceControl`, `inferredIssueTracker`,
|
|
188
|
+
* `inferredObservability`) take precedence over skill-based inference when
|
|
189
|
+
* provided. Omit them to use the original skill-based fallback (backward-compat
|
|
190
|
+
* for direct callers that don't have git-remote information).
|
|
191
|
+
*/
|
|
192
|
+
export async function installMarketplaceWorkflow(id, options) {
|
|
193
|
+
const { cwd, availableSkills, claude, logger } = options;
|
|
194
|
+
const overwrite = options.overwrite ?? false;
|
|
195
|
+
// 1. Fetch
|
|
196
|
+
const fetched = await fetchMarketplaceWorkflow(id);
|
|
197
|
+
// 2. Load existing config to detect mismatches
|
|
198
|
+
const fileConfig = loadConfigFile(cwd);
|
|
199
|
+
const workflowSkills = extractSkillsFromYaml(fetched.yaml);
|
|
200
|
+
const mismatches = computeProviderMismatch(workflowSkills, fileConfig, availableSkills);
|
|
201
|
+
// 3. Adapt if needed + possible
|
|
202
|
+
let finalYaml = fetched.yaml;
|
|
203
|
+
let adapted = false;
|
|
204
|
+
if (mismatches.length > 0 && claude) {
|
|
205
|
+
try {
|
|
206
|
+
const parsed = workflowZ.parse(parseYaml(fetched.yaml));
|
|
207
|
+
const errs = validateWorkflow(parsed);
|
|
208
|
+
if (errs.length > 0) {
|
|
209
|
+
throw new Error(`Marketplace workflow has schema errors: ${errs.map((e) => e.message).join("; ")}`);
|
|
210
|
+
}
|
|
211
|
+
const refined = await adaptWorkflowInteractive(parsed, {
|
|
212
|
+
claude,
|
|
213
|
+
skills: availableSkills,
|
|
214
|
+
logger,
|
|
215
|
+
initialRefinement: buildAdaptPrompt(mismatches),
|
|
216
|
+
});
|
|
217
|
+
if (refined === null) {
|
|
218
|
+
// user cancelled — signal no install
|
|
219
|
+
return {
|
|
220
|
+
installed: false,
|
|
221
|
+
adapted: false,
|
|
222
|
+
workflowPath: "",
|
|
223
|
+
mismatches,
|
|
224
|
+
addedEnvKeys: 0,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
finalYaml = stringifyYaml(refined, { indent: 2, lineWidth: 120 });
|
|
228
|
+
adapted = true;
|
|
229
|
+
}
|
|
230
|
+
catch (err) {
|
|
231
|
+
logger.warn(`Adaptation failed, installing as-is: ${err instanceof Error ? err.message : String(err)}`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
else if (mismatches.length > 0) {
|
|
235
|
+
const swapDesc = mismatches.map((m) => `${m.workflowSkill} → ${m.userProvider}`).join(", ");
|
|
236
|
+
logger.warn(`Workflow uses providers different from your .sweny.yml (${swapDesc}). ` +
|
|
237
|
+
`Install as-is and edit manually, or set ANTHROPIC_API_KEY and re-run to adapt automatically.`);
|
|
238
|
+
}
|
|
239
|
+
// 4. Infer providers for fresh-project .sweny.yml.
|
|
240
|
+
// Pre-computed values from the caller (git-remote-based) take precedence;
|
|
241
|
+
// fall back to skill-based inference for backward-compat with direct callers.
|
|
242
|
+
const finalSkills = extractSkillsFromYaml(finalYaml);
|
|
243
|
+
const sourceControl = options.inferredSourceControl !== undefined
|
|
244
|
+
? options.inferredSourceControl
|
|
245
|
+
: finalSkills.includes("gitlab")
|
|
246
|
+
? "gitlab"
|
|
247
|
+
: "github";
|
|
248
|
+
const issueTracker = options.inferredIssueTracker !== undefined
|
|
249
|
+
? options.inferredIssueTracker
|
|
250
|
+
: finalSkills.includes("linear")
|
|
251
|
+
? "linear"
|
|
252
|
+
: finalSkills.includes("jira")
|
|
253
|
+
? "jira"
|
|
254
|
+
: "github-issues";
|
|
255
|
+
const observability = options.inferredObservability !== undefined
|
|
256
|
+
? options.inferredObservability
|
|
257
|
+
: finalSkills.includes("datadog")
|
|
258
|
+
? "datadog"
|
|
259
|
+
: finalSkills.includes("sentry")
|
|
260
|
+
? "sentry"
|
|
261
|
+
: finalSkills.includes("betterstack")
|
|
262
|
+
? "betterstack"
|
|
263
|
+
: finalSkills.includes("newrelic")
|
|
264
|
+
? "newrelic"
|
|
265
|
+
: null;
|
|
266
|
+
writeSwenyYmlIfMissing(cwd, sourceControl, observability, issueTracker);
|
|
267
|
+
// 5. Credentials
|
|
268
|
+
const creds = collectCredentialsForSkills(finalSkills, availableSkills);
|
|
269
|
+
const addedEnvKeys = appendMissingEnvKeys(cwd, creds);
|
|
270
|
+
// 6. Workflow file — respect the overwrite flag.
|
|
271
|
+
const write = writeWorkflowFile(cwd, id, finalYaml, { overwrite });
|
|
272
|
+
if (!write.written) {
|
|
273
|
+
// File exists and overwrite was false — signal the caller.
|
|
274
|
+
return {
|
|
275
|
+
installed: false,
|
|
276
|
+
adapted,
|
|
277
|
+
workflowPath: write.path,
|
|
278
|
+
mismatches,
|
|
279
|
+
addedEnvKeys,
|
|
280
|
+
alreadyExists: true,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
return {
|
|
284
|
+
installed: true,
|
|
285
|
+
adapted,
|
|
286
|
+
workflowPath: write.path,
|
|
287
|
+
mismatches,
|
|
288
|
+
addedEnvKeys,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -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
|
+
});
|