@sweny-ai/core 0.1.28 → 0.1.30
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-index.test.js +4 -4
- package/dist/__tests__/skills.test.js +120 -0
- package/dist/cli/main.js +3 -3
- package/dist/skills/index.d.ts +15 -6
- package/dist/skills/index.js +82 -15
- package/package.json +1 -1
|
@@ -65,11 +65,11 @@ describe("skills registry", () => {
|
|
|
65
65
|
expect(validCategories).toContain(skill.category);
|
|
66
66
|
}
|
|
67
67
|
});
|
|
68
|
-
it("isSkillConfigured checks env vars", () => {
|
|
68
|
+
it("isSkillConfigured checks required env vars", () => {
|
|
69
69
|
expect(isSkillConfigured(github, { GITHUB_TOKEN: "ghp_xxx" })).toBe(true);
|
|
70
|
-
expect(isSkillConfigured(github, {})).toBe(false);
|
|
71
|
-
// Slack has all-optional config —
|
|
72
|
-
expect(isSkillConfigured(slack, {})).toBe(
|
|
70
|
+
expect(isSkillConfigured(github, {})).toBe(false); // GITHUB_TOKEN is required
|
|
71
|
+
// Slack has all-optional config — always configured, tools validate at call time
|
|
72
|
+
expect(isSkillConfigured(slack, {})).toBe(true);
|
|
73
73
|
expect(isSkillConfigured(slack, { SLACK_WEBHOOK_URL: "https://hooks.slack.com/..." })).toBe(true);
|
|
74
74
|
expect(isSkillConfigured(slack, { SLACK_BOT_TOKEN: "xoxb-..." })).toBe(true);
|
|
75
75
|
});
|
|
@@ -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
|
+
const skills = configuredSkills({}, tmpDir);
|
|
395
|
+
const ids = skills.map((s) => s.id);
|
|
396
|
+
expect(ids).toContain("my-custom");
|
|
397
|
+
// Skills with only optional config are always configured
|
|
398
|
+
expect(ids).toContain("notification");
|
|
399
|
+
expect(ids).toContain("slack");
|
|
400
|
+
});
|
|
401
|
+
it("built-in skills take precedence over custom skills with same ID", () => {
|
|
402
|
+
writeSkill("notification", `---\nname: notification\ndescription: Custom override attempt\n---\nBody`);
|
|
403
|
+
const skills = configuredSkills({}, 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.
|
|
@@ -32,16 +41,16 @@ export declare function allSkills(): Map<string, Skill>;
|
|
|
32
41
|
/**
|
|
33
42
|
* Check if a skill is usable given the available environment.
|
|
34
43
|
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
* - Skills with no env-backed config are always considered configured.
|
|
44
|
+
* A skill is configured when all its *required* config fields are set.
|
|
45
|
+
* Optional fields are validated by individual tool handlers at call time,
|
|
46
|
+
* so missing optional config never disqualifies the skill.
|
|
39
47
|
*/
|
|
40
48
|
export declare function isSkillConfigured(skill: Skill, env?: Record<string, string | undefined>): boolean;
|
|
41
49
|
/**
|
|
42
|
-
* From all builtins, return only the skills that
|
|
50
|
+
* From all builtins + custom repo skills, return only the skills that are usable.
|
|
51
|
+
* Built-in skills are filtered by env vars; custom skills are always included.
|
|
43
52
|
*/
|
|
44
|
-
export declare function configuredSkills(env?: Record<string, string | undefined
|
|
53
|
+
export declare function configuredSkills(env?: Record<string, string | undefined>, cwd?: string): Skill[];
|
|
45
54
|
/**
|
|
46
55
|
* Validate that a workflow's skill requirements are satisfiable.
|
|
47
56
|
*
|
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.
|
|
@@ -44,28 +106,33 @@ export function allSkills() {
|
|
|
44
106
|
/**
|
|
45
107
|
* Check if a skill is usable given the available environment.
|
|
46
108
|
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
* - Skills with no env-backed config are always considered configured.
|
|
109
|
+
* A skill is configured when all its *required* config fields are set.
|
|
110
|
+
* Optional fields are validated by individual tool handlers at call time,
|
|
111
|
+
* so missing optional config never disqualifies the skill.
|
|
51
112
|
*/
|
|
52
113
|
export function isSkillConfigured(skill, env = process.env) {
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
return true; // no env needed
|
|
56
|
-
// All required fields must be set
|
|
57
|
-
for (const field of envFields) {
|
|
58
|
-
if (field.required && !env[field.env])
|
|
114
|
+
for (const field of Object.values(skill.config)) {
|
|
115
|
+
if (field.required && field.env && !env[field.env])
|
|
59
116
|
return false;
|
|
60
117
|
}
|
|
61
|
-
|
|
62
|
-
return envFields.some((f) => env[f.env]);
|
|
118
|
+
return true;
|
|
63
119
|
}
|
|
64
120
|
/**
|
|
65
|
-
* From all builtins, return only the skills that
|
|
121
|
+
* From all builtins + custom repo skills, return only the skills that are usable.
|
|
122
|
+
* Built-in skills are filtered by env vars; custom skills are always included.
|
|
66
123
|
*/
|
|
67
|
-
export function configuredSkills(env = process.env) {
|
|
68
|
-
|
|
124
|
+
export function configuredSkills(env = process.env, cwd) {
|
|
125
|
+
const configured = builtinSkills.filter((s) => isSkillConfigured(s, env));
|
|
126
|
+
const custom = loadCustomSkills(cwd);
|
|
127
|
+
// Deduplicate: built-in skills take precedence over custom skills with the same ID
|
|
128
|
+
const ids = new Set(configured.map((s) => s.id));
|
|
129
|
+
for (const skill of custom) {
|
|
130
|
+
if (!ids.has(skill.id)) {
|
|
131
|
+
configured.push(skill);
|
|
132
|
+
ids.add(skill.id);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return configured;
|
|
69
136
|
}
|
|
70
137
|
export function validateWorkflowSkills(workflow, available) {
|
|
71
138
|
const configured = [];
|