@sweny-ai/core 0.1.28 → 0.1.29
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__/skills.test.js +120 -0
- package/dist/cli/main.js +3 -3
- package/dist/skills/index.d.ts +12 -2
- package/dist/skills/index.js +76 -3
- package/package.json +1 -1
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
2
5
|
import { github } from "../skills/github.js";
|
|
3
6
|
import { linear } from "../skills/linear.js";
|
|
4
7
|
import { slack } from "../skills/slack.js";
|
|
5
8
|
import { sentry } from "../skills/sentry.js";
|
|
6
9
|
import { datadog } from "../skills/datadog.js";
|
|
7
10
|
import { notification } from "../skills/notification.js";
|
|
11
|
+
import { loadCustomSkills, configuredSkills, createSkillMap } from "../skills/index.js";
|
|
8
12
|
// ─── Helpers ─────────────────────────────────────────────────────
|
|
9
13
|
const mockCtx = (config = {}) => ({
|
|
10
14
|
config,
|
|
@@ -294,3 +298,119 @@ describe("notification skill", () => {
|
|
|
294
298
|
expect(fetchMock.mock.calls[0][1].signal).toBeDefined();
|
|
295
299
|
});
|
|
296
300
|
});
|
|
301
|
+
// ─── Custom skill discovery tests ──────────────────────────────
|
|
302
|
+
describe("loadCustomSkills", () => {
|
|
303
|
+
let tmpDir;
|
|
304
|
+
beforeEach(() => {
|
|
305
|
+
tmpDir = mkdtempSync(join(tmpdir(), "sweny-skills-test-"));
|
|
306
|
+
});
|
|
307
|
+
afterEach(() => {
|
|
308
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
309
|
+
});
|
|
310
|
+
function writeSkill(dirName, content) {
|
|
311
|
+
const dir = join(tmpDir, ".claude", "skills", dirName);
|
|
312
|
+
mkdirSync(dir, { recursive: true });
|
|
313
|
+
writeFileSync(join(dir, "SKILL.md"), content);
|
|
314
|
+
}
|
|
315
|
+
it("returns empty array when .claude/skills/ does not exist", () => {
|
|
316
|
+
const skills = loadCustomSkills(tmpDir);
|
|
317
|
+
expect(skills).toEqual([]);
|
|
318
|
+
});
|
|
319
|
+
it("returns empty array when .claude/skills/ is empty", () => {
|
|
320
|
+
mkdirSync(join(tmpDir, ".claude", "skills"), { recursive: true });
|
|
321
|
+
const skills = loadCustomSkills(tmpDir);
|
|
322
|
+
expect(skills).toEqual([]);
|
|
323
|
+
});
|
|
324
|
+
it("discovers a valid custom skill from SKILL.md frontmatter", () => {
|
|
325
|
+
writeSkill("my-skill", `---
|
|
326
|
+
name: my-skill
|
|
327
|
+
description: A test skill
|
|
328
|
+
---
|
|
329
|
+
|
|
330
|
+
Instructions for Claude go here.
|
|
331
|
+
`);
|
|
332
|
+
const skills = loadCustomSkills(tmpDir);
|
|
333
|
+
expect(skills).toHaveLength(1);
|
|
334
|
+
expect(skills[0]).toMatchObject({
|
|
335
|
+
id: "my-skill",
|
|
336
|
+
name: "my-skill",
|
|
337
|
+
description: "A test skill",
|
|
338
|
+
category: "general",
|
|
339
|
+
});
|
|
340
|
+
expect(skills[0].tools).toEqual([]);
|
|
341
|
+
expect(skills[0].config).toEqual({});
|
|
342
|
+
});
|
|
343
|
+
it("discovers multiple skills", () => {
|
|
344
|
+
writeSkill("skill-a", `---\nname: skill-a\ndescription: First\n---\nBody`);
|
|
345
|
+
writeSkill("skill-b", `---\nname: skill-b\ndescription: Second\n---\nBody`);
|
|
346
|
+
const skills = loadCustomSkills(tmpDir);
|
|
347
|
+
expect(skills).toHaveLength(2);
|
|
348
|
+
const ids = skills.map((s) => s.id).sort();
|
|
349
|
+
expect(ids).toEqual(["skill-a", "skill-b"]);
|
|
350
|
+
});
|
|
351
|
+
it("skips directories without SKILL.md", () => {
|
|
352
|
+
mkdirSync(join(tmpDir, ".claude", "skills", "no-skill-file"), { recursive: true });
|
|
353
|
+
writeFileSync(join(tmpDir, ".claude", "skills", "no-skill-file", "README.md"), "not a skill");
|
|
354
|
+
const skills = loadCustomSkills(tmpDir);
|
|
355
|
+
expect(skills).toEqual([]);
|
|
356
|
+
});
|
|
357
|
+
it("skips SKILL.md without frontmatter", () => {
|
|
358
|
+
writeSkill("no-frontmatter", "Just plain text, no YAML frontmatter.");
|
|
359
|
+
const skills = loadCustomSkills(tmpDir);
|
|
360
|
+
expect(skills).toEqual([]);
|
|
361
|
+
});
|
|
362
|
+
it("skips SKILL.md with frontmatter missing name field", () => {
|
|
363
|
+
writeSkill("no-name", `---\ndescription: Missing name\n---\nBody`);
|
|
364
|
+
const skills = loadCustomSkills(tmpDir);
|
|
365
|
+
expect(skills).toEqual([]);
|
|
366
|
+
});
|
|
367
|
+
it("uses fallback description when description is missing", () => {
|
|
368
|
+
writeSkill("name-only", `---\nname: name-only\n---\nBody`);
|
|
369
|
+
const skills = loadCustomSkills(tmpDir);
|
|
370
|
+
expect(skills).toHaveLength(1);
|
|
371
|
+
expect(skills[0].description).toBe("Custom skill: name-only");
|
|
372
|
+
});
|
|
373
|
+
it("skips malformed YAML in frontmatter", () => {
|
|
374
|
+
writeSkill("bad-yaml", `---\n: [invalid yaml\n---\nBody`);
|
|
375
|
+
const skills = loadCustomSkills(tmpDir);
|
|
376
|
+
expect(skills).toEqual([]);
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
describe("configuredSkills with custom skills", () => {
|
|
380
|
+
let tmpDir;
|
|
381
|
+
beforeEach(() => {
|
|
382
|
+
tmpDir = mkdtempSync(join(tmpdir(), "sweny-configured-test-"));
|
|
383
|
+
});
|
|
384
|
+
afterEach(() => {
|
|
385
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
386
|
+
});
|
|
387
|
+
function writeSkill(dirName, content) {
|
|
388
|
+
const dir = join(tmpDir, ".claude", "skills", dirName);
|
|
389
|
+
mkdirSync(dir, { recursive: true });
|
|
390
|
+
writeFileSync(join(dir, "SKILL.md"), content);
|
|
391
|
+
}
|
|
392
|
+
it("includes custom skills alongside configured built-ins", () => {
|
|
393
|
+
writeSkill("my-custom", `---\nname: my-custom\ndescription: Custom\n---\nBody`);
|
|
394
|
+
// Provide a notification webhook so at least one built-in is configured
|
|
395
|
+
const skills = configuredSkills({ NOTIFICATION_WEBHOOK_URL: "https://example.com" }, tmpDir);
|
|
396
|
+
const ids = skills.map((s) => s.id);
|
|
397
|
+
expect(ids).toContain("my-custom");
|
|
398
|
+
expect(ids).toContain("notification");
|
|
399
|
+
});
|
|
400
|
+
it("built-in skills take precedence over custom skills with same ID", () => {
|
|
401
|
+
// notification is configured when a webhook URL is set
|
|
402
|
+
writeSkill("notification", `---\nname: notification\ndescription: Custom override attempt\n---\nBody`);
|
|
403
|
+
const skills = configuredSkills({ NOTIFICATION_WEBHOOK_URL: "https://example.com" }, tmpDir);
|
|
404
|
+
const notif = skills.filter((s) => s.id === "notification");
|
|
405
|
+
expect(notif).toHaveLength(1);
|
|
406
|
+
// Should be the built-in (has tools), not the custom one (tools: [])
|
|
407
|
+
expect(notif[0].tools.length).toBeGreaterThan(0);
|
|
408
|
+
});
|
|
409
|
+
it("custom skills are usable in createSkillMap", () => {
|
|
410
|
+
writeSkill("extract-colors", `---\nname: extract-colors\ndescription: Route color extraction\n---\nBody`);
|
|
411
|
+
const skills = configuredSkills({}, tmpDir);
|
|
412
|
+
const map = createSkillMap(skills);
|
|
413
|
+
expect(map.has("extract-colors")).toBe(true);
|
|
414
|
+
expect(map.get("extract-colors").tools).toEqual([]);
|
|
415
|
+
});
|
|
416
|
+
});
|
package/dist/cli/main.js
CHANGED
|
@@ -194,7 +194,7 @@ triageCmd.action(async (options) => {
|
|
|
194
194
|
console.log(formatBanner(config, version));
|
|
195
195
|
}
|
|
196
196
|
// ── Build skill map + MCP servers + Claude client ──────────
|
|
197
|
-
const skills = createSkillMap(configuredSkills());
|
|
197
|
+
const skills = createSkillMap(configuredSkills(process.env, process.cwd()));
|
|
198
198
|
const mcpAutoConfig = buildMcpAutoConfig(config);
|
|
199
199
|
const mcpServers = buildAutoMcpServers(mcpAutoConfig);
|
|
200
200
|
const claude = new ClaudeClient({
|
|
@@ -412,7 +412,7 @@ implementCmd.action(async (issueId, options) => {
|
|
|
412
412
|
fileConfig["output-dir"] ||
|
|
413
413
|
".sweny/output",
|
|
414
414
|
};
|
|
415
|
-
const skills = createSkillMap(configuredSkills());
|
|
415
|
+
const skills = createSkillMap(configuredSkills(process.env, process.cwd()));
|
|
416
416
|
const mcpAutoConfig = buildMcpAutoConfig(config);
|
|
417
417
|
const mcpServers = buildAutoMcpServers(mcpAutoConfig);
|
|
418
418
|
const claude = new ClaudeClient({
|
|
@@ -546,7 +546,7 @@ export async function workflowRunAction(file, options) {
|
|
|
546
546
|
const config = parseCliInputs(options, fileConfig);
|
|
547
547
|
const isJson = Boolean(options.json);
|
|
548
548
|
const isTTY = !isJson && (process.stderr.isTTY ?? false);
|
|
549
|
-
const skills = createSkillMap(configuredSkills());
|
|
549
|
+
const skills = createSkillMap(configuredSkills(process.env, process.cwd()));
|
|
550
550
|
const mcpAutoConfig = buildMcpAutoConfig(config);
|
|
551
551
|
const mcpServers = buildAutoMcpServers(mcpAutoConfig);
|
|
552
552
|
const claude = new ClaudeClient({
|
package/dist/skills/index.d.ts
CHANGED
|
@@ -14,6 +14,15 @@ import { notification } from "./notification.js";
|
|
|
14
14
|
import { supabase } from "./supabase.js";
|
|
15
15
|
export declare const builtinSkills: Skill[];
|
|
16
16
|
export { github, linear, slack, sentry, datadog, betterstack, notification, supabase };
|
|
17
|
+
/**
|
|
18
|
+
* Load custom skills from a `.claude/skills/` directory.
|
|
19
|
+
*
|
|
20
|
+
* Custom skills are instruction-only (no tools or config) — they provide
|
|
21
|
+
* guidance documents that the coding agent reads from the filesystem.
|
|
22
|
+
* Registering them here ensures workflow validation recognises the skill IDs
|
|
23
|
+
* so nodes referencing them don't produce spurious warnings.
|
|
24
|
+
*/
|
|
25
|
+
export declare function loadCustomSkills(cwd?: string): Skill[];
|
|
17
26
|
/**
|
|
18
27
|
* Build a skill map from an array of skills.
|
|
19
28
|
* Pass to `execute()` as the `skills` option.
|
|
@@ -39,9 +48,10 @@ export declare function allSkills(): Map<string, Skill>;
|
|
|
39
48
|
*/
|
|
40
49
|
export declare function isSkillConfigured(skill: Skill, env?: Record<string, string | undefined>): boolean;
|
|
41
50
|
/**
|
|
42
|
-
* From all builtins, return only the skills that
|
|
51
|
+
* From all builtins + custom repo skills, return only the skills that are usable.
|
|
52
|
+
* Built-in skills are filtered by env vars; custom skills are always included.
|
|
43
53
|
*/
|
|
44
|
-
export declare function configuredSkills(env?: Record<string, string | undefined
|
|
54
|
+
export declare function configuredSkills(env?: Record<string, string | undefined>, cwd?: string): Skill[];
|
|
45
55
|
/**
|
|
46
56
|
* Validate that a workflow's skill requirements are satisfiable.
|
|
47
57
|
*
|
package/dist/skills/index.js
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* All built-in skills + helpers to build a skill map from config.
|
|
5
5
|
*/
|
|
6
|
+
import { readdirSync, readFileSync, existsSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import YAML from "yaml";
|
|
6
9
|
import { github } from "./github.js";
|
|
7
10
|
import { linear } from "./linear.js";
|
|
8
11
|
import { slack } from "./slack.js";
|
|
@@ -14,6 +17,65 @@ import { supabase } from "./supabase.js";
|
|
|
14
17
|
// ─── Built-in skill catalog ─────────────────────────────────────
|
|
15
18
|
export const builtinSkills = [github, linear, slack, sentry, datadog, betterstack, notification, supabase];
|
|
16
19
|
export { github, linear, slack, sentry, datadog, betterstack, notification, supabase };
|
|
20
|
+
// ─── Custom skill discovery ─────────────────────────────────────
|
|
21
|
+
/**
|
|
22
|
+
* Load custom skills from a `.claude/skills/` directory.
|
|
23
|
+
*
|
|
24
|
+
* Custom skills are instruction-only (no tools or config) — they provide
|
|
25
|
+
* guidance documents that the coding agent reads from the filesystem.
|
|
26
|
+
* Registering them here ensures workflow validation recognises the skill IDs
|
|
27
|
+
* so nodes referencing them don't produce spurious warnings.
|
|
28
|
+
*/
|
|
29
|
+
export function loadCustomSkills(cwd = process.cwd()) {
|
|
30
|
+
const skillsDir = join(cwd, ".claude", "skills");
|
|
31
|
+
if (!existsSync(skillsDir))
|
|
32
|
+
return [];
|
|
33
|
+
const skills = [];
|
|
34
|
+
let entries;
|
|
35
|
+
try {
|
|
36
|
+
entries = readdirSync(skillsDir, { withFileTypes: true })
|
|
37
|
+
.filter((d) => d.isDirectory())
|
|
38
|
+
.map((d) => d.name);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
for (const dirName of entries) {
|
|
44
|
+
const skillFile = join(skillsDir, dirName, "SKILL.md");
|
|
45
|
+
if (!existsSync(skillFile))
|
|
46
|
+
continue;
|
|
47
|
+
try {
|
|
48
|
+
const content = readFileSync(skillFile, "utf-8");
|
|
49
|
+
const frontmatter = parseFrontmatter(content);
|
|
50
|
+
if (!frontmatter?.name)
|
|
51
|
+
continue;
|
|
52
|
+
skills.push({
|
|
53
|
+
id: frontmatter.name,
|
|
54
|
+
name: frontmatter.name,
|
|
55
|
+
description: frontmatter.description ?? `Custom skill: ${frontmatter.name}`,
|
|
56
|
+
category: "general",
|
|
57
|
+
config: {},
|
|
58
|
+
tools: [],
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// Skip malformed skill files
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return skills;
|
|
66
|
+
}
|
|
67
|
+
/** Extract YAML frontmatter from a markdown file (between --- delimiters). */
|
|
68
|
+
function parseFrontmatter(content) {
|
|
69
|
+
const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
70
|
+
if (!match)
|
|
71
|
+
return null;
|
|
72
|
+
try {
|
|
73
|
+
return YAML.parse(match[1]);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
17
79
|
// ─── Registry helpers ───────────────────────────────────────────
|
|
18
80
|
/**
|
|
19
81
|
* Build a skill map from an array of skills.
|
|
@@ -62,10 +124,21 @@ export function isSkillConfigured(skill, env = process.env) {
|
|
|
62
124
|
return envFields.some((f) => env[f.env]);
|
|
63
125
|
}
|
|
64
126
|
/**
|
|
65
|
-
* From all builtins, return only the skills that
|
|
127
|
+
* From all builtins + custom repo skills, return only the skills that are usable.
|
|
128
|
+
* Built-in skills are filtered by env vars; custom skills are always included.
|
|
66
129
|
*/
|
|
67
|
-
export function configuredSkills(env = process.env) {
|
|
68
|
-
|
|
130
|
+
export function configuredSkills(env = process.env, cwd) {
|
|
131
|
+
const configured = builtinSkills.filter((s) => isSkillConfigured(s, env));
|
|
132
|
+
const custom = loadCustomSkills(cwd);
|
|
133
|
+
// Deduplicate: built-in skills take precedence over custom skills with the same ID
|
|
134
|
+
const ids = new Set(configured.map((s) => s.id));
|
|
135
|
+
for (const skill of custom) {
|
|
136
|
+
if (!ids.has(skill.id)) {
|
|
137
|
+
configured.push(skill);
|
|
138
|
+
ids.add(skill.id);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return configured;
|
|
69
142
|
}
|
|
70
143
|
export function validateWorkflowSkills(workflow, available) {
|
|
71
144
|
const configured = [];
|