@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.
@@ -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 — needs at least one to be usable
72
- expect(isSkillConfigured(slack, {})).toBe(false);
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({
@@ -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
- * - All required config fields must have values.
36
- * - If a skill has env-backed config fields (even optional ones),
37
- * at least one must be set — otherwise the skill has zero credentials.
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 have their required env vars set.
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>): Skill[];
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
  *
@@ -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
- * - All required config fields must have values.
48
- * - If a skill has env-backed config fields (even optional ones),
49
- * at least one must be set — otherwise the skill has zero credentials.
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 envFields = Object.values(skill.config).filter((f) => f.env);
54
- if (envFields.length === 0)
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
- // At least one env-backed field must be set (handles all-optional skills like slack)
62
- return envFields.some((f) => env[f.env]);
118
+ return true;
63
119
  }
64
120
  /**
65
- * From all builtins, return only the skills that have their required env vars set.
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
- return builtinSkills.filter((s) => isSkillConfigured(s, env));
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 = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sweny-ai/core",
3
- "version": "0.1.28",
3
+ "version": "0.1.30",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "sweny": "./dist/cli/main.js"