@sweny-ai/core 0.1.51 → 0.1.52

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 @@
1
+ export {};
@@ -0,0 +1,146 @@
1
+ import { describe, it, expect, beforeAll, afterAll } 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 { validateWorkflowFile, validateSkillDir } from "../cli/publish.js";
6
+ describe("validateWorkflowFile", () => {
7
+ let tmpDir;
8
+ beforeAll(() => {
9
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "sweny-publish-test-"));
10
+ });
11
+ afterAll(() => {
12
+ fs.rmSync(tmpDir, { recursive: true, force: true });
13
+ });
14
+ it("validates a correct workflow file", () => {
15
+ const file = path.join(tmpDir, "good.yml");
16
+ fs.writeFileSync(file, `id: test-workflow
17
+ name: Test
18
+ description: A test workflow
19
+ entry: start
20
+ nodes:
21
+ start:
22
+ name: Start
23
+ instruction: Do something.
24
+ skills: [github]
25
+ edges: []
26
+ `);
27
+ const result = validateWorkflowFile(file);
28
+ expect(result.valid).toBe(true);
29
+ expect(result.id).toBe("test-workflow");
30
+ expect(result.name).toBe("Test");
31
+ expect(result.nodeCount).toBe(1);
32
+ expect(result.edgeCount).toBe(0);
33
+ expect(result.errors).toEqual([]);
34
+ });
35
+ it("rejects a non-existent file", () => {
36
+ const result = validateWorkflowFile(path.join(tmpDir, "nope.yml"));
37
+ expect(result.valid).toBe(false);
38
+ expect(result.errors[0]).toBe("File not found");
39
+ });
40
+ it("rejects invalid YAML", () => {
41
+ const file = path.join(tmpDir, "bad.yml");
42
+ fs.writeFileSync(file, "{{invalid yaml");
43
+ const result = validateWorkflowFile(file);
44
+ expect(result.valid).toBe(false);
45
+ expect(result.errors[0]).toMatch(/Invalid YAML/);
46
+ });
47
+ it("rejects a workflow missing required fields", () => {
48
+ const file = path.join(tmpDir, "incomplete.yml");
49
+ fs.writeFileSync(file, "id: test\nname: Test\n");
50
+ const result = validateWorkflowFile(file);
51
+ expect(result.valid).toBe(false);
52
+ expect(result.errors.length).toBeGreaterThan(0);
53
+ });
54
+ });
55
+ describe("validateSkillDir", () => {
56
+ let tmpDir;
57
+ beforeAll(() => {
58
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "sweny-skill-test-"));
59
+ });
60
+ afterAll(() => {
61
+ fs.rmSync(tmpDir, { recursive: true, force: true });
62
+ });
63
+ it("validates a correct instruction skill", () => {
64
+ const skillDir = path.join(tmpDir, "my-skill");
65
+ fs.mkdirSync(skillDir, { recursive: true });
66
+ fs.writeFileSync(path.join(skillDir, "SKILL.md"), `---
67
+ name: my-skill
68
+ description: A test skill
69
+ ---
70
+
71
+ When reviewing code, check for security issues first.
72
+ `);
73
+ const result = validateSkillDir(skillDir);
74
+ expect(result.valid).toBe(true);
75
+ expect(result.id).toBe("my-skill");
76
+ expect(result.hasInstruction).toBe(true);
77
+ expect(result.hasMcp).toBe(false);
78
+ });
79
+ it("validates a correct MCP skill", () => {
80
+ const skillDir = path.join(tmpDir, "mcp-skill");
81
+ fs.mkdirSync(skillDir, { recursive: true });
82
+ fs.writeFileSync(path.join(skillDir, "SKILL.md"), `---
83
+ name: mcp-skill
84
+ description: A test MCP skill
85
+ mcp:
86
+ command: npx
87
+ args: ["-y", "@company/server"]
88
+ ---
89
+ `);
90
+ const result = validateSkillDir(skillDir);
91
+ expect(result.valid).toBe(true);
92
+ expect(result.hasMcp).toBe(true);
93
+ });
94
+ it("rejects a directory without SKILL.md", () => {
95
+ const emptyDir = path.join(tmpDir, "empty");
96
+ fs.mkdirSync(emptyDir, { recursive: true });
97
+ const result = validateSkillDir(emptyDir);
98
+ expect(result.valid).toBe(false);
99
+ expect(result.errors[0]).toBe("SKILL.md not found in directory");
100
+ });
101
+ it("rejects a skill with no frontmatter", () => {
102
+ const skillDir = path.join(tmpDir, "no-fm");
103
+ fs.mkdirSync(skillDir, { recursive: true });
104
+ fs.writeFileSync(path.join(skillDir, "SKILL.md"), "Just some text");
105
+ const result = validateSkillDir(skillDir);
106
+ expect(result.valid).toBe(false);
107
+ expect(result.errors[0]).toBe("No YAML frontmatter found");
108
+ });
109
+ it("rejects an invalid skill ID", () => {
110
+ const skillDir = path.join(tmpDir, "Bad-Name");
111
+ fs.mkdirSync(skillDir, { recursive: true });
112
+ fs.writeFileSync(path.join(skillDir, "SKILL.md"), `---
113
+ name: Bad-Name
114
+ ---
115
+
116
+ Some instruction.
117
+ `);
118
+ const result = validateSkillDir(skillDir);
119
+ expect(result.valid).toBe(false);
120
+ expect(result.errors[0]).toMatch(/Invalid skill ID/);
121
+ });
122
+ it("rejects a skill with mismatched directory name", () => {
123
+ const skillDir = path.join(tmpDir, "wrong-dir");
124
+ fs.mkdirSync(skillDir, { recursive: true });
125
+ fs.writeFileSync(path.join(skillDir, "SKILL.md"), `---
126
+ name: correct-name
127
+ ---
128
+
129
+ Some instruction.
130
+ `);
131
+ const result = validateSkillDir(skillDir);
132
+ expect(result.valid).toBe(false);
133
+ expect(result.errors).toContain(`Directory name "wrong-dir" doesn't match skill name "correct-name"`);
134
+ });
135
+ it("rejects a skill with no instruction and no MCP", () => {
136
+ const skillDir = path.join(tmpDir, "empty-skill");
137
+ fs.mkdirSync(skillDir, { recursive: true });
138
+ fs.writeFileSync(path.join(skillDir, "SKILL.md"), `---
139
+ name: empty-skill
140
+ ---
141
+ `);
142
+ const result = validateSkillDir(skillDir);
143
+ expect(result.valid).toBe(false);
144
+ expect(result.errors).toContain("Skill must have an instruction body or an mcp config");
145
+ });
146
+ });
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { workflowZ, nodeZ, edgeZ, skillZ, toolZ, parseWorkflow, validateWorkflow, workflowJsonSchema, } from "../schema.js";
2
+ import { workflowZ, nodeZ, edgeZ, skillZ, toolZ, mcpServerConfigZ, skillDefinitionZ, parseWorkflow, validateWorkflow, workflowJsonSchema, } from "../schema.js";
3
3
  import { triageWorkflow, implementWorkflow } from "../workflows/index.js";
4
4
  // ─── Test fixtures ───────────────────────────────────────────────
5
5
  const validWorkflow = {
@@ -100,6 +100,128 @@ describe("Zod schemas", () => {
100
100
  expect(result.tools).toHaveLength(1);
101
101
  });
102
102
  });
103
+ describe("mcpServerConfigZ", () => {
104
+ it("accepts stdio config", () => {
105
+ const result = mcpServerConfigZ.parse({ command: "npx", args: ["-y", "@company/tool-server"] });
106
+ expect(result.command).toBe("npx");
107
+ });
108
+ it("accepts http config", () => {
109
+ const result = mcpServerConfigZ.parse({
110
+ url: "https://mcp.example.com",
111
+ headers: { Authorization: "Bearer token" },
112
+ });
113
+ expect(result.url).toBe("https://mcp.example.com");
114
+ });
115
+ it("accepts explicit type", () => {
116
+ const result = mcpServerConfigZ.parse({ type: "stdio", command: "node", args: ["server.js"] });
117
+ expect(result.type).toBe("stdio");
118
+ });
119
+ it("accepts env field", () => {
120
+ const result = mcpServerConfigZ.parse({
121
+ command: "npx",
122
+ args: ["-y", "server"],
123
+ env: { API_KEY: "Company API key" },
124
+ });
125
+ expect(result.env).toEqual({ API_KEY: "Company API key" });
126
+ });
127
+ it("rejects config with neither command nor url", () => {
128
+ expect(() => mcpServerConfigZ.parse({ args: ["foo"] })).toThrow("MCP server must have either command (stdio) or url (HTTP)");
129
+ });
130
+ });
131
+ describe("skillDefinitionZ", () => {
132
+ it("accepts instruction-only skill", () => {
133
+ const result = skillDefinitionZ.parse({
134
+ name: "Code Standards",
135
+ instruction: "Follow our coding conventions...",
136
+ });
137
+ expect(result.instruction).toBe("Follow our coding conventions...");
138
+ });
139
+ it("accepts mcp-only skill", () => {
140
+ const result = skillDefinitionZ.parse({ mcp: { command: "npx", args: ["-y", "server"] } });
141
+ expect(result.mcp?.command).toBe("npx");
142
+ });
143
+ it("accepts both instruction and mcp", () => {
144
+ const result = skillDefinitionZ.parse({
145
+ instruction: "Use this server for...",
146
+ mcp: { url: "https://mcp.example.com" },
147
+ });
148
+ expect(result.instruction).toBeDefined();
149
+ expect(result.mcp).toBeDefined();
150
+ });
151
+ it("rejects skill with neither instruction nor mcp", () => {
152
+ expect(() => skillDefinitionZ.parse({ name: "Empty" })).toThrow("Inline skill must provide instruction, mcp, or both");
153
+ });
154
+ });
155
+ describe("skillZ with new fields", () => {
156
+ it("accepts skill with instruction and no tools", () => {
157
+ const result = skillZ.parse({
158
+ id: "code-standards",
159
+ name: "Code Standards",
160
+ description: "Team coding conventions",
161
+ category: "general",
162
+ instruction: "Follow camelCase naming...",
163
+ });
164
+ expect(result.tools).toEqual([]);
165
+ expect(result.instruction).toBe("Follow camelCase naming...");
166
+ });
167
+ it("accepts skill with mcp and no tools", () => {
168
+ const result = skillZ.parse({
169
+ id: "our-crm",
170
+ name: "Our CRM",
171
+ description: "CRM integration",
172
+ category: "general",
173
+ mcp: { url: "https://crm.example.com/mcp" },
174
+ });
175
+ expect(result.tools).toEqual([]);
176
+ expect(result.mcp?.url).toBe("https://crm.example.com/mcp");
177
+ });
178
+ it("rejects skill with no tools, no instruction, no mcp", () => {
179
+ expect(() => skillZ.parse({ id: "empty", name: "Empty", description: "Nothing", category: "general" })).toThrow("Skill must provide at least one of: tools, instruction, or mcp");
180
+ });
181
+ it("defaults tools to empty array", () => {
182
+ const result = skillZ.parse({
183
+ id: "x",
184
+ name: "X",
185
+ description: "X",
186
+ category: "general",
187
+ instruction: "do stuff",
188
+ });
189
+ expect(result.tools).toEqual([]);
190
+ });
191
+ it("defaults config to empty object", () => {
192
+ const result = skillZ.parse({
193
+ id: "x",
194
+ name: "X",
195
+ description: "X",
196
+ category: "general",
197
+ instruction: "do stuff",
198
+ });
199
+ expect(result.config).toEqual({});
200
+ });
201
+ });
202
+ describe("workflowZ with skills", () => {
203
+ it("parses workflow with inline skills", () => {
204
+ const result = workflowZ.parse({
205
+ id: "test",
206
+ name: "Test",
207
+ entry: "a",
208
+ nodes: { a: { name: "A", instruction: "Do A", skills: ["my-skill"] } },
209
+ edges: [],
210
+ skills: { "my-skill": { name: "My Skill", instruction: "Custom guidance here" } },
211
+ });
212
+ expect(result.skills?.["my-skill"]?.instruction).toBe("Custom guidance here");
213
+ });
214
+ it("defaults skills to empty object", () => {
215
+ const result = workflowZ.parse({
216
+ id: "x",
217
+ name: "X",
218
+ entry: "a",
219
+ nodes: { a: { name: "A", instruction: "Do A" } },
220
+ edges: [],
221
+ });
222
+ expect(result.skills).toEqual({});
223
+ });
224
+ });
103
225
  describe("workflowZ", () => {
104
226
  it("parses a valid workflow", () => {
105
227
  const raw = JSON.parse(JSON.stringify(validWorkflow));
@@ -251,6 +373,39 @@ describe("validateWorkflow", () => {
251
373
  const errors = validateWorkflow(empty);
252
374
  expect(errors).toContainEqual(expect.objectContaining({ code: "MISSING_ENTRY" }));
253
375
  });
376
+ it("recognizes inline workflow skills as known", () => {
377
+ const wf = {
378
+ ...validWorkflow,
379
+ nodes: { ...validWorkflow.nodes, a: { ...validWorkflow.nodes.a, skills: ["custom-rubric"] } },
380
+ skills: { "custom-rubric": { instruction: "Score things 1-5" } },
381
+ };
382
+ const errors = validateWorkflow(wf, new Set(["github"]));
383
+ expect(errors.filter((e) => e.code === "UNKNOWN_SKILL")).toEqual([]);
384
+ });
385
+ it("rejects inline skills with neither instruction nor mcp", () => {
386
+ const wf = {
387
+ ...validWorkflow,
388
+ skills: { "bad-skill": { name: "Bad" } },
389
+ };
390
+ const errors = validateWorkflow(wf);
391
+ expect(errors).toContainEqual(expect.objectContaining({ code: "INVALID_INLINE_SKILL" }));
392
+ });
393
+ it("accepts inline skills with instruction only", () => {
394
+ const wf = {
395
+ ...validWorkflow,
396
+ skills: { "ok-skill": { instruction: "Do something" } },
397
+ };
398
+ const errors = validateWorkflow(wf);
399
+ expect(errors.filter((e) => e.code === "INVALID_INLINE_SKILL")).toEqual([]);
400
+ });
401
+ it("accepts inline skills with mcp only", () => {
402
+ const wf = {
403
+ ...validWorkflow,
404
+ skills: { "mcp-skill": { mcp: { command: "npx", args: ["-y", "server"] } } },
405
+ };
406
+ const errors = validateWorkflow(wf);
407
+ expect(errors.filter((e) => e.code === "INVALID_INLINE_SKILL")).toEqual([]);
408
+ });
254
409
  });
255
410
  // ─── JSON Schema shape tests ─────────────────────────────────────
256
411
  describe("workflowJsonSchema", () => {
@@ -9,7 +9,7 @@ import { sentry } from "../skills/sentry.js";
9
9
  import { datadog } from "../skills/datadog.js";
10
10
  import { notification } from "../skills/notification.js";
11
11
  import { createSkillMap } from "../skills/index.js";
12
- import { loadCustomSkills, configuredSkills } from "../skills/custom-loader.js";
12
+ import { loadCustomSkills, discoverSkills, configuredSkills } from "../skills/custom-loader.js";
13
13
  // ─── Helpers ─────────────────────────────────────────────────────
14
14
  const mockCtx = (config = {}) => ({
15
15
  config,
@@ -399,13 +399,15 @@ describe("configuredSkills with custom skills", () => {
399
399
  expect(ids).toContain("notification");
400
400
  expect(ids).toContain("slack");
401
401
  });
402
- it("built-in skills take precedence over custom skills with same ID", () => {
403
- writeSkill("notification", `---\nname: notification\ndescription: Custom override attempt\n---\nBody`);
402
+ it("custom skills override built-in ID but merge tools", () => {
403
+ writeSkill("notification", `---\nname: notification\ndescription: Custom override\n---\nCustom notification guidance.`);
404
404
  const skills = configuredSkills({}, tmpDir);
405
405
  const notif = skills.filter((s) => s.id === "notification");
406
406
  expect(notif).toHaveLength(1);
407
- // Should be the built-in (has tools), not the custom one (tools: [])
407
+ // Built-in tools are preserved
408
408
  expect(notif[0].tools.length).toBeGreaterThan(0);
409
+ // Custom instruction is added
410
+ expect(notif[0].instruction).toBe("Custom notification guidance.");
409
411
  });
410
412
  it("custom skills are usable in createSkillMap", () => {
411
413
  writeSkill("extract-colors", `---\nname: extract-colors\ndescription: Route color extraction\n---\nBody`);
@@ -415,3 +417,132 @@ describe("configuredSkills with custom skills", () => {
415
417
  expect(map.get("extract-colors").tools).toEqual([]);
416
418
  });
417
419
  });
420
+ // ─── Multi-directory discovery tests ─────────────────────────────
421
+ describe("discoverSkills (multi-directory)", () => {
422
+ let tmpDir;
423
+ beforeEach(() => {
424
+ tmpDir = mkdtempSync(join(tmpdir(), "sweny-discover-test-"));
425
+ });
426
+ afterEach(() => {
427
+ rmSync(tmpDir, { recursive: true, force: true });
428
+ });
429
+ function writeSkillAt(base, dirName, content) {
430
+ const dir = join(tmpDir, base, "skills", dirName);
431
+ mkdirSync(dir, { recursive: true });
432
+ writeFileSync(join(dir, "SKILL.md"), content);
433
+ }
434
+ it("discovers skills from .sweny/skills/", () => {
435
+ writeSkillAt(".sweny", "my-skill", `---\nname: my-skill\ndescription: A skill\n---\nDo things well.`);
436
+ const skills = discoverSkills(tmpDir);
437
+ expect(skills).toHaveLength(1);
438
+ expect(skills[0].id).toBe("my-skill");
439
+ });
440
+ it("discovers skills from .claude/skills/", () => {
441
+ writeSkillAt(".claude", "claude-skill", `---\nname: claude-skill\ndescription: Claude\n---\nInstructions.`);
442
+ const skills = discoverSkills(tmpDir);
443
+ expect(skills).toHaveLength(1);
444
+ expect(skills[0].id).toBe("claude-skill");
445
+ });
446
+ it("discovers skills from .agents/skills/", () => {
447
+ writeSkillAt(".agents", "agent-skill", `---\nname: agent-skill\ndescription: Agent\n---\nBody.`);
448
+ const skills = discoverSkills(tmpDir);
449
+ expect(skills).toHaveLength(1);
450
+ });
451
+ it("discovers skills from .gemini/skills/", () => {
452
+ writeSkillAt(".gemini", "gemini-skill", `---\nname: gemini-skill\ndescription: Gemini\n---\nBody.`);
453
+ const skills = discoverSkills(tmpDir);
454
+ expect(skills).toHaveLength(1);
455
+ });
456
+ it("higher-priority directory wins on ID collision", () => {
457
+ writeSkillAt(".sweny", "shared", `---\nname: shared\ndescription: From sweny\n---\nSweny instructions.`);
458
+ writeSkillAt(".claude", "shared", `---\nname: shared\ndescription: From claude\n---\nClaude instructions.`);
459
+ const skills = discoverSkills(tmpDir);
460
+ const shared = skills.find((s) => s.id === "shared");
461
+ expect(shared.description).toBe("From sweny");
462
+ expect(shared.instruction).toBe("Sweny instructions.");
463
+ });
464
+ it("captures markdown body as instruction", () => {
465
+ writeSkillAt(".sweny", "code-standards", `---
466
+ name: code-standards
467
+ description: Coding conventions
468
+ ---
469
+
470
+ When writing TypeScript:
471
+ - Use camelCase for variables
472
+ - Use PascalCase for types`);
473
+ const skills = discoverSkills(tmpDir);
474
+ expect(skills[0].instruction).toContain("Use camelCase for variables");
475
+ expect(skills[0].instruction).toContain("Use PascalCase for types");
476
+ });
477
+ it("parses mcp from frontmatter", () => {
478
+ writeSkillAt(".sweny", "our-crm", `---
479
+ name: our-crm
480
+ description: CRM integration
481
+ mcp:
482
+ command: npx
483
+ args:
484
+ - -y
485
+ - "@company/crm-server"
486
+ env:
487
+ API_KEY: CRM API key
488
+ ---
489
+
490
+ Use the CRM tools to look up customer data.`);
491
+ const skills = discoverSkills(tmpDir);
492
+ expect(skills[0].mcp).toEqual({
493
+ type: "stdio",
494
+ command: "npx",
495
+ args: ["-y", "@company/crm-server"],
496
+ env: { API_KEY: "CRM API key" },
497
+ });
498
+ expect(skills[0].instruction).toContain("Use the CRM tools");
499
+ });
500
+ it("parses mcp with url (HTTP transport)", () => {
501
+ writeSkillAt(".sweny", "http-tool", `---
502
+ name: http-tool
503
+ description: HTTP MCP
504
+ mcp:
505
+ url: https://mcp.example.com
506
+ headers:
507
+ Authorization: Bearer token
508
+ ---
509
+
510
+ Instructions.`);
511
+ const skills = discoverSkills(tmpDir);
512
+ expect(skills[0].mcp?.url).toBe("https://mcp.example.com");
513
+ });
514
+ it("returns empty array when no skill directories exist", () => {
515
+ const skills = discoverSkills(tmpDir);
516
+ expect(skills).toEqual([]);
517
+ });
518
+ it("merges skills from all directories", () => {
519
+ writeSkillAt(".sweny", "skill-a", `---\nname: skill-a\ndescription: A\n---\nA.`);
520
+ writeSkillAt(".claude", "skill-b", `---\nname: skill-b\ndescription: B\n---\nB.`);
521
+ writeSkillAt(".agents", "skill-c", `---\nname: skill-c\ndescription: C\n---\nC.`);
522
+ const skills = discoverSkills(tmpDir);
523
+ expect(skills).toHaveLength(3);
524
+ const ids = skills.map((s) => s.id).sort();
525
+ expect(ids).toEqual(["skill-a", "skill-b", "skill-c"]);
526
+ });
527
+ it("rejects skill IDs with invalid characters", () => {
528
+ writeSkillAt(".sweny", "valid-skill", `---\nname: ../../etc/passwd\ndescription: malicious\n---\nBody`);
529
+ const skills = discoverSkills(tmpDir);
530
+ expect(skills).toHaveLength(0);
531
+ });
532
+ it("rejects skill IDs with uppercase", () => {
533
+ writeSkillAt(".sweny", "MySkill", `---\nname: MySkill\ndescription: bad\n---\nBody`);
534
+ const skills = discoverSkills(tmpDir);
535
+ expect(skills).toHaveLength(0);
536
+ });
537
+ it("rejects skill IDs with consecutive hyphens", () => {
538
+ writeSkillAt(".sweny", "bad--name", `---\nname: bad--name\ndescription: bad\n---\nBody`);
539
+ const skills = discoverSkills(tmpDir);
540
+ expect(skills).toHaveLength(0);
541
+ });
542
+ it("rejects skill IDs longer than 64 chars", () => {
543
+ const longId = "a".repeat(65);
544
+ writeSkillAt(".sweny", longId, `---\nname: ${longId}\ndescription: long\n---\nBody`);
545
+ const skills = discoverSkills(tmpDir);
546
+ expect(skills).toHaveLength(0);
547
+ });
548
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,49 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { parse } from "yaml";
3
+ import { WORKFLOW_TEMPLATES } from "../cli/templates.js";
4
+ import { parseWorkflow, validateWorkflow } from "../schema.js";
5
+ describe("WORKFLOW_TEMPLATES", () => {
6
+ it("has at least 4 templates", () => {
7
+ expect(WORKFLOW_TEMPLATES.length).toBeGreaterThanOrEqual(4);
8
+ });
9
+ it("each template has unique id, name, and description", () => {
10
+ const ids = new Set();
11
+ for (const t of WORKFLOW_TEMPLATES) {
12
+ expect(t.id).toBeTruthy();
13
+ expect(t.name).toBeTruthy();
14
+ expect(t.description).toBeTruthy();
15
+ expect(ids.has(t.id)).toBe(false);
16
+ ids.add(t.id);
17
+ }
18
+ });
19
+ for (const template of WORKFLOW_TEMPLATES) {
20
+ describe(`template: ${template.id}`, () => {
21
+ it("parses as valid YAML", () => {
22
+ const parsed = parse(template.yaml);
23
+ expect(parsed).toBeDefined();
24
+ expect(parsed.id).toBe(template.id);
25
+ });
26
+ it("passes parseWorkflow validation", () => {
27
+ const parsed = parse(template.yaml);
28
+ const workflow = parseWorkflow(parsed);
29
+ expect(workflow.id).toBe(template.id);
30
+ expect(Object.keys(workflow.nodes).length).toBeGreaterThan(0);
31
+ });
32
+ it("passes validateWorkflow with no errors", () => {
33
+ const parsed = parse(template.yaml);
34
+ const workflow = parseWorkflow(parsed);
35
+ const errors = validateWorkflow(workflow);
36
+ expect(errors).toEqual([]);
37
+ });
38
+ it("uses only the github skill", () => {
39
+ const parsed = parse(template.yaml);
40
+ const workflow = parseWorkflow(parsed);
41
+ for (const node of Object.values(workflow.nodes)) {
42
+ for (const skill of node.skills) {
43
+ expect(skill).toBe("github");
44
+ }
45
+ }
46
+ });
47
+ });
48
+ }
49
+ });
@@ -96,8 +96,8 @@ describe("README", () => {
96
96
  it("does not use the old Recipes heading", () => {
97
97
  expect(readme).not.toMatch(/^## Recipes$/m);
98
98
  });
99
- it("has an Actions section with the multi-repo table", () => {
100
- expect(readme).toMatch(/^## Actions$/m);
99
+ it("has a GitHub Actions section with the multi-repo table", () => {
100
+ expect(readme).toMatch(/^## GitHub Actions$/m);
101
101
  expect(readme).toContain("swenyai/triage@v1");
102
102
  expect(readme).toContain("swenyai/e2e@v1");
103
103
  expect(readme).toContain(`swenyai/sweny@${CURRENT_ACTION_VERSION}`);
@@ -1,6 +1,9 @@
1
1
  /**
2
2
  * sweny init — Interactive setup wizard
3
3
  *
4
+ * Workflow-first: pick a template or describe what you want, then SWEny
5
+ * infers which skills are needed and only asks for those credentials.
6
+ *
4
7
  * Pure functions for file generation + thin @clack/prompts interactive layer.
5
8
  * Tests cover the pure functions; the interactive wizard is thin glue.
6
9
  */
@@ -22,10 +25,25 @@ export interface GitRemoteInfo {
22
25
  provider: "github" | "gitlab";
23
26
  remote: string;
24
27
  }
28
+ /**
29
+ * Maps skill IDs (as used in workflow nodes) to the credentials they require.
30
+ * When a workflow uses a skill, we collect only those credentials.
31
+ */
32
+ export declare const SKILL_CREDENTIALS: Record<string, Credential[]>;
25
33
  export declare const PROVIDER_CREDENTIALS: Record<string, Credential[]>;
34
+ /**
35
+ * Extract all unique skill IDs from a workflow template YAML string.
36
+ */
37
+ export declare function extractSkillsFromYaml(yaml: string): string[];
38
+ /**
39
+ * Gather credentials required for a set of skills.
40
+ * Always includes ANTHROPIC_API_KEY. Deduplicates by key name.
41
+ */
42
+ export declare function collectCredentialsForSkills(skills: string[]): Credential[];
26
43
  /**
27
44
  * Gather all unique credentials required for the selected providers.
28
45
  * Always includes ANTHROPIC_API_KEY. Deduplicates by key name.
46
+ * @deprecated Use collectCredentialsForSkills instead — kept for backward compat.
29
47
  */
30
48
  export declare function collectCredentials(selections: InitSelections): Credential[];
31
49
  /**
@@ -34,9 +52,9 @@ export declare function collectCredentials(selections: InitSelections): Credenti
34
52
  */
35
53
  export declare function detectGitRemote(cwd: string): GitRemoteInfo | null;
36
54
  /**
37
- * Generate .sweny.yml content from selections.
55
+ * Generate .sweny.yml content from inferred providers.
38
56
  */
39
- export declare function buildSwenyYml(selections: InitSelections): string;
57
+ export declare function buildSwenyYml(sourceControl: string, observability: string | null, issueTracker: string): string;
40
58
  /**
41
59
  * Generate .env template with comments for each credential.
42
60
  */
@@ -47,7 +65,12 @@ export declare function buildEnvTemplate(credentials: Credential[]): string;
47
65
  */
48
66
  export declare function buildActionWorkflow(credentials: Credential[], cronExpression: string): string;
49
67
  /**
50
- * Interactive setup wizard — prompts the user through provider selection,
51
- * then generates `.sweny.yml`, `.env`, and an optional GitHub Action workflow.
68
+ * Interactive setup wizard — workflow-first approach.
69
+ *
70
+ * 1. Detect git remote (infer source control)
71
+ * 2. Pick a workflow template
72
+ * 3. Infer providers from workflow skills
73
+ * 4. Collect only the credentials those skills need
74
+ * 5. Generate files
52
75
  */
53
76
  export declare function runInit(): Promise<void>;