@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.
@@ -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({
@@ -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 have their required env vars set.
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>): Skill[];
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
  *
@@ -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 have their required env vars set.
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
- return builtinSkills.filter((s) => isSkillConfigured(s, env));
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 = [];
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.29",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "sweny": "./dist/cli/main.js"