@sweny-ai/core 0.1.60 → 0.1.61

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,116 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { readdirSync, readFileSync, existsSync } from "node:fs";
3
+ vi.mock("node:fs", () => ({
4
+ readdirSync: vi.fn(),
5
+ readFileSync: vi.fn(),
6
+ existsSync: vi.fn(),
7
+ }));
8
+ const mockedExistsSync = vi.mocked(existsSync);
9
+ const mockedReaddirSync = vi.mocked(readdirSync);
10
+ const mockedReadFileSync = vi.mocked(readFileSync);
11
+ // Import after mocking
12
+ const { discoverSkills } = await import("../skills/custom-loader.js");
13
+ describe("custom-loader", () => {
14
+ beforeEach(() => {
15
+ vi.clearAllMocks();
16
+ });
17
+ describe("config parsing from SKILL.md frontmatter", () => {
18
+ it("parses config fields from frontmatter", () => {
19
+ const skillMd = `---
20
+ name: tax-tool
21
+ description: Processes tax filings
22
+ config:
23
+ MY_TAX_API_KEY:
24
+ description: API key for the tax service
25
+ required: true
26
+ MY_TAX_REGION:
27
+ description: Tax region code
28
+ required: false
29
+ ---
30
+ Do tax stuff.
31
+ `;
32
+ mockedExistsSync.mockImplementation((p) => {
33
+ const s = String(p);
34
+ return s.endsWith(".sweny/skills") || s.endsWith("tax-tool/SKILL.md");
35
+ });
36
+ mockedReaddirSync.mockReturnValue([{ name: "tax-tool", isDirectory: () => true }]);
37
+ mockedReadFileSync.mockReturnValue(skillMd);
38
+ const skills = discoverSkills("/fake");
39
+ expect(skills).toHaveLength(1);
40
+ const skill = skills[0];
41
+ expect(skill.id).toBe("tax-tool");
42
+ expect(skill.config).toEqual({
43
+ MY_TAX_API_KEY: {
44
+ description: "API key for the tax service",
45
+ required: true,
46
+ env: "MY_TAX_API_KEY",
47
+ },
48
+ MY_TAX_REGION: {
49
+ description: "Tax region code",
50
+ required: false,
51
+ env: "MY_TAX_REGION",
52
+ },
53
+ });
54
+ });
55
+ it("produces config: {} when no config block in frontmatter", () => {
56
+ const skillMd = `---
57
+ name: simple-skill
58
+ description: A simple skill
59
+ ---
60
+ Instructions here.
61
+ `;
62
+ mockedExistsSync.mockImplementation((p) => {
63
+ const s = String(p);
64
+ return s.endsWith(".sweny/skills") || s.endsWith("simple-skill/SKILL.md");
65
+ });
66
+ mockedReaddirSync.mockReturnValue([{ name: "simple-skill", isDirectory: () => true }]);
67
+ mockedReadFileSync.mockReturnValue(skillMd);
68
+ const skills = discoverSkills("/fake");
69
+ expect(skills).toHaveLength(1);
70
+ expect(skills[0].config).toEqual({});
71
+ });
72
+ it("sets env field to the key name automatically", () => {
73
+ const skillMd = `---
74
+ name: my-api
75
+ description: API skill
76
+ config:
77
+ CUSTOM_API_TOKEN:
78
+ description: Token for the API
79
+ required: true
80
+ ---
81
+ Use the API.
82
+ `;
83
+ mockedExistsSync.mockImplementation((p) => {
84
+ const s = String(p);
85
+ return s.endsWith(".sweny/skills") || s.endsWith("my-api/SKILL.md");
86
+ });
87
+ mockedReaddirSync.mockReturnValue([{ name: "my-api", isDirectory: () => true }]);
88
+ mockedReadFileSync.mockReturnValue(skillMd);
89
+ const skills = discoverSkills("/fake");
90
+ expect(skills[0].config.CUSTOM_API_TOKEN.env).toBe("CUSTOM_API_TOKEN");
91
+ });
92
+ it("handles config entries with missing description gracefully", () => {
93
+ const skillMd = `---
94
+ name: sparse-config
95
+ description: Sparse config skill
96
+ config:
97
+ SOME_KEY:
98
+ required: true
99
+ ---
100
+ Body.
101
+ `;
102
+ mockedExistsSync.mockImplementation((p) => {
103
+ const s = String(p);
104
+ return s.endsWith(".sweny/skills") || s.endsWith("sparse-config/SKILL.md");
105
+ });
106
+ mockedReaddirSync.mockReturnValue([{ name: "sparse-config", isDirectory: () => true }]);
107
+ mockedReadFileSync.mockReturnValue(skillMd);
108
+ const skills = discoverSkills("/fake");
109
+ expect(skills).toHaveLength(1);
110
+ // Falls back to key name as description
111
+ expect(skills[0].config.SOME_KEY.description).toBe("SOME_KEY");
112
+ expect(skills[0].config.SOME_KEY.required).toBe(true);
113
+ expect(skills[0].config.SOME_KEY.env).toBe("SOME_KEY");
114
+ });
115
+ });
116
+ });
@@ -51,6 +51,40 @@ edges: []
51
51
  expect(result.valid).toBe(false);
52
52
  expect(result.errors.length).toBeGreaterThan(0);
53
53
  });
54
+ it("rejects a workflow with unknown skill references", () => {
55
+ const file = path.join(tmpDir, "unknown-skill.yml");
56
+ fs.writeFileSync(file, `id: unknown-skill-test
57
+ name: Unknown Skill Test
58
+ description: Test
59
+ entry: start
60
+ nodes:
61
+ start:
62
+ name: Start
63
+ instruction: Do something.
64
+ skills: [unicorn]
65
+ edges: []
66
+ `);
67
+ const result = validateWorkflowFile(file);
68
+ expect(result.valid).toBe(false);
69
+ expect(result.errors.some((e) => e.includes("unicorn"))).toBe(true);
70
+ });
71
+ it("returns config warnings for skills with required env vars", () => {
72
+ const file = path.join(tmpDir, "with-github.yml");
73
+ fs.writeFileSync(file, `id: github-test
74
+ name: GitHub Test
75
+ description: Test
76
+ entry: start
77
+ nodes:
78
+ start:
79
+ name: Start
80
+ instruction: Do something.
81
+ skills: [github]
82
+ edges: []
83
+ `);
84
+ const result = validateWorkflowFile(file);
85
+ expect(result.valid).toBe(true);
86
+ expect(result.warnings.some((w) => w.includes("GITHUB_TOKEN"))).toBe(true);
87
+ });
54
88
  });
55
89
  describe("validateSkillDir", () => {
56
90
  let tmpDir;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Integration tests validating the skill config system end-to-end:
3
+ * - Every builtin skill has a non-empty config
4
+ * - Config fields have correct structure (description, required, env)
5
+ * - deriveWorkflowVariables produces correct output for real skills
6
+ * - collectCredentialsForSkills works with real builtins
7
+ * - Custom skill config merge works correctly
8
+ * - publish validation catches real problems
9
+ */
10
+ import { describe, it, expect } from "vitest";
11
+ import { builtinSkills } from "../skills/index.js";
12
+ import { deriveWorkflowVariables } from "../workflow-builder.js";
13
+ import { collectCredentialsForSkills } from "../cli/new.js";
14
+ // ── Builtin skill config completeness ────────────────────────────────
15
+ describe("builtin skill config completeness", () => {
16
+ const SKILLS_WITH_CONFIG = [
17
+ "github",
18
+ "linear",
19
+ "slack",
20
+ "sentry",
21
+ "datadog",
22
+ "betterstack",
23
+ "notification",
24
+ "supabase",
25
+ ];
26
+ it("all expected skills exist in builtinSkills", () => {
27
+ const ids = builtinSkills.map((s) => s.id);
28
+ for (const expected of SKILLS_WITH_CONFIG) {
29
+ expect(ids, `missing builtin skill: ${expected}`).toContain(expected);
30
+ }
31
+ });
32
+ it("every builtin skill has at least one config field", () => {
33
+ for (const skill of builtinSkills) {
34
+ if (!SKILLS_WITH_CONFIG.includes(skill.id))
35
+ continue;
36
+ expect(Object.keys(skill.config).length, `${skill.id} should have config fields`).toBeGreaterThan(0);
37
+ }
38
+ });
39
+ it("every config field has description, required boolean, and env string", () => {
40
+ for (const skill of builtinSkills) {
41
+ for (const [key, field] of Object.entries(skill.config)) {
42
+ expect(typeof field.description, `${skill.id}.${key}.description`).toBe("string");
43
+ expect(field.description.length, `${skill.id}.${key}.description is empty`).toBeGreaterThan(0);
44
+ expect(typeof field.required, `${skill.id}.${key}.required`).toBe("boolean");
45
+ expect(typeof field.env, `${skill.id}.${key}.env`).toBe("string");
46
+ expect(field.env, `${skill.id}.${key}.env should match key`).toBe(key);
47
+ }
48
+ }
49
+ });
50
+ it("github has GITHUB_TOKEN as required", () => {
51
+ const github = builtinSkills.find((s) => s.id === "github");
52
+ expect(github.config.GITHUB_TOKEN).toBeDefined();
53
+ expect(github.config.GITHUB_TOKEN.required).toBe(true);
54
+ });
55
+ it("sentry has SENTRY_AUTH_TOKEN, SENTRY_ORG (required) and SENTRY_BASE_URL (optional)", () => {
56
+ const sentry = builtinSkills.find((s) => s.id === "sentry");
57
+ expect(sentry.config.SENTRY_AUTH_TOKEN.required).toBe(true);
58
+ expect(sentry.config.SENTRY_ORG.required).toBe(true);
59
+ expect(sentry.config.SENTRY_BASE_URL.required).toBe(false);
60
+ });
61
+ it("datadog has DD_API_KEY, DD_APP_KEY (required) and DD_SITE (optional)", () => {
62
+ const dd = builtinSkills.find((s) => s.id === "datadog");
63
+ expect(dd.config.DD_API_KEY.required).toBe(true);
64
+ expect(dd.config.DD_APP_KEY.required).toBe(true);
65
+ expect(dd.config.DD_SITE.required).toBe(false);
66
+ });
67
+ it("betterstack has 4 required config fields", () => {
68
+ const bs = builtinSkills.find((s) => s.id === "betterstack");
69
+ expect(Object.keys(bs.config)).toHaveLength(4);
70
+ expect(bs.config.BETTERSTACK_API_TOKEN.required).toBe(true);
71
+ expect(bs.config.BETTERSTACK_QUERY_ENDPOINT.required).toBe(true);
72
+ expect(bs.config.BETTERSTACK_QUERY_USERNAME.required).toBe(true);
73
+ expect(bs.config.BETTERSTACK_QUERY_PASSWORD.required).toBe(true);
74
+ });
75
+ it("notification has all optional config fields", () => {
76
+ const notif = builtinSkills.find((s) => s.id === "notification");
77
+ for (const field of Object.values(notif.config)) {
78
+ expect(field.required).toBe(false);
79
+ }
80
+ });
81
+ });
82
+ // ── deriveWorkflowVariables integration ──────────────────────────────
83
+ describe("deriveWorkflowVariables with real builtins", () => {
84
+ const makeWorkflow = (skills) => ({
85
+ id: "test",
86
+ name: "test",
87
+ description: "test",
88
+ entry: "step1",
89
+ nodes: { step1: { name: "Step 1", instruction: "do it", skills } },
90
+ edges: [],
91
+ });
92
+ it("produces ANTHROPIC_API_KEY + all github config fields", () => {
93
+ const vars = deriveWorkflowVariables(makeWorkflow(["github"]), builtinSkills);
94
+ const names = vars.map((v) => v.name);
95
+ expect(names[0]).toBe("ANTHROPIC_API_KEY");
96
+ expect(names).toContain("GITHUB_TOKEN");
97
+ });
98
+ it("handles multi-skill workflow correctly", () => {
99
+ const vars = deriveWorkflowVariables(makeWorkflow(["github", "sentry", "slack"]), builtinSkills);
100
+ const names = vars.map((v) => v.name);
101
+ expect(names).toContain("GITHUB_TOKEN");
102
+ expect(names).toContain("SENTRY_AUTH_TOKEN");
103
+ expect(names).toContain("SENTRY_ORG");
104
+ expect(names).toContain("SENTRY_BASE_URL");
105
+ expect(names).toContain("SLACK_WEBHOOK_URL");
106
+ expect(names).toContain("SLACK_BOT_TOKEN");
107
+ });
108
+ it("deduplicates when same skill appears in multiple nodes", () => {
109
+ const workflow = {
110
+ id: "test",
111
+ name: "test",
112
+ description: "test",
113
+ entry: "a",
114
+ nodes: {
115
+ a: { name: "A", instruction: "a", skills: ["github"] },
116
+ b: { name: "B", instruction: "b", skills: ["github"] },
117
+ },
118
+ edges: [{ from: "a", to: "b" }],
119
+ };
120
+ const vars = deriveWorkflowVariables(workflow, builtinSkills);
121
+ const tokenVars = vars.filter((v) => v.name === "GITHUB_TOKEN");
122
+ expect(tokenVars).toHaveLength(1);
123
+ });
124
+ it("tracks skill attribution correctly", () => {
125
+ const vars = deriveWorkflowVariables(makeWorkflow(["github", "sentry"]), builtinSkills);
126
+ expect(vars.find((v) => v.name === "GITHUB_TOKEN")?.skill).toBe("github");
127
+ expect(vars.find((v) => v.name === "SENTRY_AUTH_TOKEN")?.skill).toBe("sentry");
128
+ });
129
+ it("marks required/optional correctly", () => {
130
+ const vars = deriveWorkflowVariables(makeWorkflow(["sentry"]), builtinSkills);
131
+ expect(vars.find((v) => v.name === "SENTRY_AUTH_TOKEN")?.required).toBe(true);
132
+ expect(vars.find((v) => v.name === "SENTRY_BASE_URL")?.required).toBe(false);
133
+ });
134
+ });
135
+ // ── collectCredentialsForSkills integration ──────────────────────────
136
+ describe("collectCredentialsForSkills with real builtins", () => {
137
+ it("always includes ANTHROPIC_API_KEY first", () => {
138
+ const creds = collectCredentialsForSkills(["github"], builtinSkills);
139
+ expect(creds[0].key).toBe("ANTHROPIC_API_KEY");
140
+ });
141
+ it("derives credentials from skill.config when availableSkills provided", () => {
142
+ const creds = collectCredentialsForSkills(["github"], builtinSkills);
143
+ expect(creds.some((c) => c.key === "GITHUB_TOKEN")).toBe(true);
144
+ });
145
+ it("includes all betterstack credentials", () => {
146
+ const creds = collectCredentialsForSkills(["betterstack"], builtinSkills);
147
+ const keys = creds.map((c) => c.key);
148
+ expect(keys).toContain("BETTERSTACK_API_TOKEN");
149
+ expect(keys).toContain("BETTERSTACK_QUERY_ENDPOINT");
150
+ expect(keys).toContain("BETTERSTACK_QUERY_USERNAME");
151
+ expect(keys).toContain("BETTERSTACK_QUERY_PASSWORD");
152
+ });
153
+ it("deduplicates across multiple skills", () => {
154
+ const creds = collectCredentialsForSkills(["github", "github"], builtinSkills);
155
+ const tokenCreds = creds.filter((c) => c.key === "GITHUB_TOKEN");
156
+ expect(tokenCreds).toHaveLength(1);
157
+ });
158
+ it("falls back to SKILL_CREDENTIALS for unknown skills", () => {
159
+ // "gitlab" isn't in builtinSkills but is in SKILL_CREDENTIALS
160
+ const creds = collectCredentialsForSkills(["gitlab"], builtinSkills);
161
+ expect(creds.some((c) => c.key === "GITLAB_TOKEN")).toBe(true);
162
+ });
163
+ });
164
+ // ── Custom skill config merge ────────────────────────────────────────
165
+ describe("custom skill config merge", () => {
166
+ it("custom skill config fields are preserved in deriveWorkflowVariables", () => {
167
+ const customSkill = {
168
+ id: "my-tax-tool",
169
+ name: "Tax Tool",
170
+ description: "Tax processing",
171
+ category: "general",
172
+ config: {
173
+ TAX_API_KEY: { description: "Tax service API key", required: true, env: "TAX_API_KEY" },
174
+ },
175
+ tools: [],
176
+ };
177
+ const workflow = {
178
+ id: "test",
179
+ name: "test",
180
+ description: "test",
181
+ entry: "start",
182
+ nodes: { start: { name: "Start", instruction: "process", skills: ["my-tax-tool"] } },
183
+ edges: [],
184
+ };
185
+ const vars = deriveWorkflowVariables(workflow, [customSkill]);
186
+ expect(vars.find((v) => v.name === "TAX_API_KEY")).toBeDefined();
187
+ expect(vars.find((v) => v.name === "TAX_API_KEY")?.skill).toBe("my-tax-tool");
188
+ expect(vars.find((v) => v.name === "TAX_API_KEY")?.required).toBe(true);
189
+ });
190
+ it("collectCredentialsForSkills works with custom skills", () => {
191
+ const customSkill = {
192
+ id: "my-db",
193
+ name: "DB",
194
+ description: "Database",
195
+ category: "general",
196
+ config: {
197
+ DB_URL: { description: "Database URL", required: true, env: "DB_URL" },
198
+ },
199
+ tools: [],
200
+ };
201
+ const creds = collectCredentialsForSkills(["my-db"], [customSkill]);
202
+ expect(creds.some((c) => c.key === "DB_URL")).toBe(true);
203
+ });
204
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,110 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { buildSystemPrompt, deriveWorkflowVariables } from "../workflow-builder.js";
3
+ const fakeSkill = (id, config = {}) => ({
4
+ id,
5
+ name: id,
6
+ description: `${id} skill`,
7
+ category: "general",
8
+ config,
9
+ tools: [],
10
+ });
11
+ describe("buildSystemPrompt", () => {
12
+ it("includes config fields in the skill list", () => {
13
+ const skills = [
14
+ fakeSkill("github", {
15
+ GITHUB_TOKEN: { description: "GitHub token", required: true, env: "GITHUB_TOKEN" },
16
+ }),
17
+ ];
18
+ const prompt = buildSystemPrompt(skills);
19
+ expect(prompt).toContain("- github: github skill");
20
+ expect(prompt).toContain("Config: GITHUB_TOKEN (required)");
21
+ });
22
+ it("shows optional config fields", () => {
23
+ const skills = [
24
+ fakeSkill("slack", {
25
+ SLACK_WEBHOOK_URL: { description: "Webhook URL", required: false, env: "SLACK_WEBHOOK_URL" },
26
+ SLACK_BOT_TOKEN: { description: "Bot token", required: false, env: "SLACK_BOT_TOKEN" },
27
+ }),
28
+ ];
29
+ const prompt = buildSystemPrompt(skills);
30
+ expect(prompt).toContain("SLACK_WEBHOOK_URL (optional)");
31
+ expect(prompt).toContain("SLACK_BOT_TOKEN (optional)");
32
+ });
33
+ it("omits Config line for skills with no config", () => {
34
+ const skills = [fakeSkill("simple")];
35
+ const prompt = buildSystemPrompt(skills);
36
+ expect(prompt).toContain("- simple: simple skill");
37
+ expect(prompt).not.toContain("Config:");
38
+ });
39
+ });
40
+ describe("deriveWorkflowVariables", () => {
41
+ const workflow = {
42
+ id: "test",
43
+ name: "test",
44
+ description: "test workflow",
45
+ entry: "gather",
46
+ nodes: {
47
+ gather: {
48
+ name: "Gather",
49
+ instruction: "gather data",
50
+ skills: ["github", "slack"],
51
+ },
52
+ },
53
+ edges: [],
54
+ };
55
+ it("always includes ANTHROPIC_API_KEY first", () => {
56
+ const vars = deriveWorkflowVariables(workflow, []);
57
+ expect(vars[0].name).toBe("ANTHROPIC_API_KEY");
58
+ expect(vars[0].required).toBe(true);
59
+ });
60
+ it("derives variables from referenced skills", () => {
61
+ const skills = [
62
+ fakeSkill("github", {
63
+ GITHUB_TOKEN: { description: "GitHub token", required: true, env: "GITHUB_TOKEN" },
64
+ }),
65
+ fakeSkill("slack", {
66
+ SLACK_WEBHOOK_URL: { description: "Webhook URL", required: false, env: "SLACK_WEBHOOK_URL" },
67
+ }),
68
+ ];
69
+ const vars = deriveWorkflowVariables(workflow, skills);
70
+ const names = vars.map((v) => v.name);
71
+ expect(names).toContain("GITHUB_TOKEN");
72
+ expect(names).toContain("SLACK_WEBHOOK_URL");
73
+ });
74
+ it("deduplicates env vars across skills", () => {
75
+ const multiNodeWorkflow = {
76
+ ...workflow,
77
+ nodes: {
78
+ a: { name: "A", instruction: "a", skills: ["github"] },
79
+ b: { name: "B", instruction: "b", skills: ["github"] },
80
+ },
81
+ };
82
+ const skills = [
83
+ fakeSkill("github", {
84
+ GITHUB_TOKEN: { description: "GitHub token", required: true, env: "GITHUB_TOKEN" },
85
+ }),
86
+ ];
87
+ const vars = deriveWorkflowVariables(multiNodeWorkflow, skills);
88
+ const tokenCount = vars.filter((v) => v.name === "GITHUB_TOKEN").length;
89
+ expect(tokenCount).toBe(1);
90
+ });
91
+ it("skips skills not in the available list", () => {
92
+ const vars = deriveWorkflowVariables(workflow, []);
93
+ // Only ANTHROPIC_API_KEY
94
+ expect(vars).toHaveLength(1);
95
+ });
96
+ it("includes custom skills with config", () => {
97
+ const customWorkflow = {
98
+ ...workflow,
99
+ nodes: { tax: { name: "Tax", instruction: "do taxes", skills: ["my-tax-tool"] } },
100
+ };
101
+ const skills = [
102
+ fakeSkill("my-tax-tool", {
103
+ MY_TAX_KEY: { description: "Tax API key", required: true, env: "MY_TAX_KEY" },
104
+ }),
105
+ ];
106
+ const vars = deriveWorkflowVariables(customWorkflow, skills);
107
+ expect(vars.find((v) => v.name === "MY_TAX_KEY")).toBeDefined();
108
+ expect(vars.find((v) => v.name === "MY_TAX_KEY")?.skill).toBe("my-tax-tool");
109
+ });
110
+ });
package/dist/cli/new.d.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  * Pure functions for file generation + thin @clack/prompts interactive layer.
8
8
  * Tests cover the pure functions; the interactive wizard is thin glue.
9
9
  */
10
+ import { type Skill } from "../types.js";
10
11
  export interface Credential {
11
12
  key: string;
12
13
  hint?: string;
@@ -26,8 +27,18 @@ export interface GitRemoteInfo {
26
27
  remote: string;
27
28
  }
28
29
  /**
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.
30
+ * Supplementary metadata (URLs, hints, defaults) for env vars.
31
+ * skill.config has description/required but not setup URLs or default values.
32
+ */
33
+ export declare const SKILL_EXTRAS: Record<string, {
34
+ url?: string;
35
+ hint?: string;
36
+ default?: string;
37
+ }>;
38
+ /**
39
+ * Static fallback credential map, used when skill objects are unavailable.
40
+ * When availableSkills is provided, collectCredentialsForSkills derives
41
+ * credentials from skill.config instead.
31
42
  */
32
43
  export declare const SKILL_CREDENTIALS: Record<string, Credential[]>;
33
44
  export declare const PROVIDER_CREDENTIALS: Record<string, Credential[]>;
@@ -48,8 +59,12 @@ export declare function extractSkillsFromYaml(yaml: string): string[];
48
59
  /**
49
60
  * Gather credentials required for a set of skills.
50
61
  * Always includes ANTHROPIC_API_KEY. Deduplicates by key name.
62
+ *
63
+ * When `availableSkills` is provided, derives credentials from each skill's
64
+ * `.config` fields (works for both builtins and custom skills from Task 01).
65
+ * Falls back to `SKILL_CREDENTIALS` for skills not found in the available set.
51
66
  */
52
- export declare function collectCredentialsForSkills(skills: string[]): Credential[];
67
+ export declare function collectCredentialsForSkills(skillIds: string[], availableSkills?: Skill[]): Credential[];
53
68
  /**
54
69
  * Gather all unique credentials required for the selected providers.
55
70
  * Always includes ANTHROPIC_API_KEY. Deduplicates by key name.
package/dist/cli/new.js CHANGED
@@ -17,27 +17,48 @@ import { buildWorkflow, refineWorkflow } from "../workflow-builder.js";
17
17
  import { ClaudeClient } from "../claude.js";
18
18
  import { consoleLogger } from "../types.js";
19
19
  import { configuredSkills } from "../skills/custom-loader.js";
20
+ import { builtinSkills } from "../skills/index.js";
20
21
  import { DagRenderer } from "./renderer.js";
21
22
  import { runE2eInit } from "./e2e.js";
22
- // ── Skill → Credential mapping ────────────────────────────────────────
23
+ // ── Skill → Credential extras ─────────────────────────────────────────
23
24
  /**
24
- * Maps skill IDs (as used in workflow nodes) to the credentials they require.
25
- * When a workflow uses a skill, we collect only those credentials.
25
+ * Supplementary metadata (URLs, hints, defaults) for env vars.
26
+ * skill.config has description/required but not setup URLs or default values.
27
+ */
28
+ export const SKILL_EXTRAS = {
29
+ GITHUB_TOKEN: { url: "https://github.com/settings/tokens", hint: "repo + issues scopes" },
30
+ GITLAB_TOKEN: { url: "https://gitlab.com/-/profile/personal_access_tokens", hint: "api scope" },
31
+ GITLAB_URL: { hint: "e.g. https://gitlab.com", default: "https://gitlab.com" },
32
+ DD_API_KEY: { url: "https://app.datadoghq.com/organization-settings", hint: "Organization Settings > API Keys" },
33
+ DD_APP_KEY: { hint: "Organization Settings > Application Keys" },
34
+ DD_SITE: { hint: "datadoghq.com, datadoghq.eu, etc.", default: "datadoghq.com" },
35
+ SENTRY_AUTH_TOKEN: { url: "https://sentry.io/settings/auth-tokens/" },
36
+ SENTRY_ORG: { hint: "sentry.io/organizations/slug/" },
37
+ BETTERSTACK_API_TOKEN: { url: "https://betterstack.com/docs/logs/api" },
38
+ NR_API_KEY: { url: "https://one.newrelic.com/api-keys" },
39
+ LINEAR_API_KEY: { url: "https://linear.app/settings/api" },
40
+ LINEAR_TEAM_ID: { hint: "Settings > Teams > copy ID from URL" },
41
+ JIRA_BASE_URL: { hint: "e.g. https://your-org.atlassian.net" },
42
+ JIRA_EMAIL: { hint: "your Atlassian account email" },
43
+ JIRA_API_TOKEN: { url: "https://id.atlassian.com/manage-profile/security/api-tokens" },
44
+ SLACK_BOT_TOKEN: { url: "https://api.slack.com/apps" },
45
+ SLACK_WEBHOOK_URL: { hint: "Incoming webhook URL" },
46
+ DISCORD_WEBHOOK_URL: { hint: "Server Settings > Integrations > Webhooks" },
47
+ NOTIFICATION_WEBHOOK_URL: { hint: "Your webhook endpoint URL" },
48
+ TEAMS_WEBHOOK_URL: { hint: "Channel > Connectors > Incoming Webhook" },
49
+ SUPABASE_URL: { hint: "Supabase project URL" },
50
+ SUPABASE_SERVICE_ROLE_KEY: { hint: "Settings > API > service_role key" },
51
+ BETTERSTACK_QUERY_ENDPOINT: { hint: "ClickHouse HTTP endpoint" },
52
+ };
53
+ /**
54
+ * Static fallback credential map, used when skill objects are unavailable.
55
+ * When availableSkills is provided, collectCredentialsForSkills derives
56
+ * credentials from skill.config instead.
26
57
  */
27
58
  export const SKILL_CREDENTIALS = {
28
- github: [
29
- {
30
- key: "GITHUB_TOKEN",
31
- url: "https://github.com/settings/tokens",
32
- hint: "repo + issues scopes",
33
- },
34
- ],
59
+ github: [{ key: "GITHUB_TOKEN", url: "https://github.com/settings/tokens", hint: "repo + issues scopes" }],
35
60
  gitlab: [
36
- {
37
- key: "GITLAB_TOKEN",
38
- hint: "api scope",
39
- url: "https://gitlab.com/-/profile/personal_access_tokens",
40
- },
61
+ { key: "GITLAB_TOKEN", hint: "api scope", url: "https://gitlab.com/-/profile/personal_access_tokens" },
41
62
  { key: "GITLAB_URL", hint: "e.g. https://gitlab.com", default: "https://gitlab.com" },
42
63
  ],
43
64
  datadog: [
@@ -50,36 +71,19 @@ export const SKILL_CREDENTIALS = {
50
71
  { key: "DD_SITE", hint: "datadoghq.com, datadoghq.eu, etc.", default: "datadoghq.com" },
51
72
  ],
52
73
  sentry: [
53
- {
54
- key: "SENTRY_AUTH_TOKEN",
55
- url: "https://sentry.io/settings/auth-tokens/",
56
- },
74
+ { key: "SENTRY_AUTH_TOKEN", url: "https://sentry.io/settings/auth-tokens/" },
57
75
  { key: "SENTRY_ORG", hint: "sentry.io/organizations/slug/" },
58
76
  ],
59
- betterstack: [
60
- {
61
- key: "BETTERSTACK_API_TOKEN",
62
- url: "https://betterstack.com/docs/logs/api",
63
- },
64
- ],
77
+ betterstack: [{ key: "BETTERSTACK_API_TOKEN", url: "https://betterstack.com/docs/logs/api" }],
65
78
  newrelic: [{ key: "NR_API_KEY", url: "https://one.newrelic.com/api-keys" }],
66
79
  linear: [
67
- {
68
- key: "LINEAR_API_KEY",
69
- url: "https://linear.app/settings/api",
70
- },
71
- {
72
- key: "LINEAR_TEAM_ID",
73
- hint: "Settings > Teams > copy ID from URL",
74
- },
80
+ { key: "LINEAR_API_KEY", url: "https://linear.app/settings/api" },
81
+ { key: "LINEAR_TEAM_ID", hint: "Settings > Teams > copy ID from URL" },
75
82
  ],
76
83
  jira: [
77
84
  { key: "JIRA_BASE_URL", hint: "e.g. https://your-org.atlassian.net" },
78
85
  { key: "JIRA_EMAIL", hint: "your Atlassian account email" },
79
- {
80
- key: "JIRA_API_TOKEN",
81
- url: "https://id.atlassian.com/manage-profile/security/api-tokens",
82
- },
86
+ { key: "JIRA_API_TOKEN", url: "https://id.atlassian.com/manage-profile/security/api-tokens" },
83
87
  ],
84
88
  slack: [{ key: "SLACK_BOT_TOKEN", url: "https://api.slack.com/apps" }],
85
89
  discord: [{ key: "DISCORD_WEBHOOK_URL", hint: "Server Settings > Integrations > Webhooks" }],
@@ -151,19 +155,47 @@ export function extractSkillsFromYaml(yaml) {
151
155
  /**
152
156
  * Gather credentials required for a set of skills.
153
157
  * Always includes ANTHROPIC_API_KEY. Deduplicates by key name.
158
+ *
159
+ * When `availableSkills` is provided, derives credentials from each skill's
160
+ * `.config` fields (works for both builtins and custom skills from Task 01).
161
+ * Falls back to `SKILL_CREDENTIALS` for skills not found in the available set.
154
162
  */
155
- export function collectCredentialsForSkills(skills) {
163
+ export function collectCredentialsForSkills(skillIds, availableSkills) {
156
164
  const seen = new Map();
157
165
  for (const cred of ALWAYS_CREDENTIALS) {
158
166
  seen.set(cred.key, cred);
159
167
  }
160
- for (const skill of skills) {
161
- const creds = SKILL_CREDENTIALS[skill];
162
- if (!creds)
163
- continue;
164
- for (const cred of creds) {
165
- if (!seen.has(cred.key)) {
166
- seen.set(cred.key, cred);
168
+ const skillMap = new Map();
169
+ if (availableSkills) {
170
+ for (const s of availableSkills)
171
+ skillMap.set(s.id, s);
172
+ }
173
+ for (const id of skillIds) {
174
+ const skill = skillMap.get(id);
175
+ if (skill && Object.keys(skill.config).length > 0) {
176
+ // Derive from skill.config
177
+ for (const [envKey, field] of Object.entries(skill.config)) {
178
+ const key = field.env ?? envKey;
179
+ if (seen.has(key))
180
+ continue;
181
+ const extras = SKILL_EXTRAS[key];
182
+ seen.set(key, {
183
+ key,
184
+ hint: extras?.hint ?? field.description,
185
+ url: extras?.url,
186
+ default: extras?.default,
187
+ });
188
+ }
189
+ }
190
+ else {
191
+ // Fallback to SKILL_CREDENTIALS for unknown skills or skills without config
192
+ const creds = SKILL_CREDENTIALS[id];
193
+ if (!creds)
194
+ continue;
195
+ for (const cred of creds) {
196
+ if (!seen.has(cred.key)) {
197
+ seen.set(cred.key, cred);
198
+ }
167
199
  }
168
200
  }
169
201
  }
@@ -399,6 +431,12 @@ export async function runNew() {
399
431
  if (gitInfo && !hasExistingConfig) {
400
432
  p.log.info(`Detected: ${chalk.cyan(gitInfo.remote)} (${gitInfo.provider})`);
401
433
  }
434
+ // ── Discover all skills (builtins + custom) ────────────────────────
435
+ const allSkills = configuredSkills(process.env, cwd);
436
+ const customSkills = allSkills.filter((s) => !builtinSkills.some((b) => b.id === s.id));
437
+ if (customSkills.length > 0) {
438
+ p.log.info(`Found ${customSkills.length} custom skill(s): ${chalk.cyan(customSkills.map((s) => s.id).join(", "))}`);
439
+ }
402
440
  // ── Step 1: Pick a workflow ─────────────────────────────────────────
403
441
  const templateChoice = await p.select({
404
442
  message: "What do you want to do?",
@@ -423,7 +461,7 @@ export async function runNew() {
423
461
  // ── Step 2: Resolve the chosen workflow ─────────────────────────────
424
462
  let template;
425
463
  if (templateChoice === "__custom") {
426
- template = (await runCustomWorkflowBuilder()) ?? undefined;
464
+ template = (await runCustomWorkflowBuilder(allSkills)) ?? undefined;
427
465
  if (!template) {
428
466
  p.cancel("Setup cancelled.");
429
467
  process.exit(0);
@@ -437,7 +475,7 @@ export async function runNew() {
437
475
  const issueTracker = inferIssueTracker(workflowSkills, sourceControl);
438
476
  const observability = inferObservability(workflowSkills);
439
477
  // Collect credentials for the workflow's skills (+ always ANTHROPIC_API_KEY)
440
- const credentials = collectCredentialsForSkills(workflowSkills);
478
+ const credentials = collectCredentialsForSkills(workflowSkills, allSkills);
441
479
  // ── Step 3: Summary + confirm ───────────────────────────────────────
442
480
  const files = [];
443
481
  if (!hasExistingConfig)
@@ -586,7 +624,7 @@ export async function runNew() {
586
624
  *
587
625
  * Consumed by the `__custom` branch of `runNew`'s picker.
588
626
  */
589
- async function runCustomWorkflowBuilder() {
627
+ async function runCustomWorkflowBuilder(skills) {
590
628
  const description = await p.text({
591
629
  message: "Describe the workflow you want",
592
630
  placeholder: "e.g. Review Python PRs for security issues and post a comment",
@@ -600,7 +638,6 @@ async function runCustomWorkflowBuilder() {
600
638
  });
601
639
  if (p.isCancel(description))
602
640
  return null;
603
- const skills = configuredSkills();
604
641
  const claude = new ClaudeClient({
605
642
  maxTurns: 3,
606
643
  cwd: process.cwd(),
@@ -681,6 +681,42 @@ describe("collectCredentialsForSkills", () => {
681
681
  const creds = collectCredentialsForSkills(["datadog", "github"]);
682
682
  expect(creds[0].key).toBe("ANTHROPIC_API_KEY");
683
683
  });
684
+ it("derives credentials from skill.config when availableSkills provided", () => {
685
+ const fakeSkill = {
686
+ id: "my-tax-tool",
687
+ name: "my-tax-tool",
688
+ description: "Tax tool",
689
+ category: "general",
690
+ config: {
691
+ MY_TAX_KEY: { description: "Tax API key", required: true, env: "MY_TAX_KEY" },
692
+ MY_TAX_REGION: { description: "Region code", required: false, env: "MY_TAX_REGION" },
693
+ },
694
+ tools: [],
695
+ };
696
+ const creds = collectCredentialsForSkills(["my-tax-tool"], [fakeSkill]);
697
+ const keys = creds.map((c) => c.key);
698
+ expect(keys).toContain("ANTHROPIC_API_KEY");
699
+ expect(keys).toContain("MY_TAX_KEY");
700
+ expect(keys).toContain("MY_TAX_REGION");
701
+ });
702
+ it("falls back to SKILL_CREDENTIALS when skill not in availableSkills", () => {
703
+ const creds = collectCredentialsForSkills(["github"], []);
704
+ const keys = creds.map((c) => c.key);
705
+ expect(keys).toContain("GITHUB_TOKEN");
706
+ });
707
+ it("uses SKILL_EXTRAS for url/hint when deriving from config", () => {
708
+ const githubSkill = {
709
+ id: "github",
710
+ name: "github",
711
+ description: "GitHub",
712
+ category: "git",
713
+ config: { GITHUB_TOKEN: { description: "GitHub token", required: true, env: "GITHUB_TOKEN" } },
714
+ tools: [],
715
+ };
716
+ const creds = collectCredentialsForSkills(["github"], [githubSkill]);
717
+ const token = creds.find((c) => c.key === "GITHUB_TOKEN");
718
+ expect(token?.url).toBe("https://github.com/settings/tokens");
719
+ });
684
720
  });
685
721
  // ── extractSkillsFromYaml: block-style arrays + malformed input ───────
686
722
  //
@@ -21,6 +21,7 @@ export declare function validateWorkflowFile(filePath: string): {
21
21
  nodeCount?: number;
22
22
  edgeCount?: number;
23
23
  errors: string[];
24
+ warnings: string[];
24
25
  };
25
26
  export declare function validateSkillDir(dirPath: string): {
26
27
  valid: boolean;
@@ -13,14 +13,17 @@ import * as p from "@clack/prompts";
13
13
  import chalk from "chalk";
14
14
  import { parse as parseYaml } from "yaml";
15
15
  import { parseWorkflow, validateWorkflow } from "../schema.js";
16
+ import { builtinSkills } from "../skills/index.js";
17
+ import { discoverSkills } from "../skills/custom-loader.js";
16
18
  // ── Types ──────────────────────────────────────────────────────────────
17
19
  const VALID_CATEGORIES = ["triage", "security", "devops", "code-review", "testing", "content", "ops"];
18
20
  const VALID_SKILL_ID = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
19
21
  // ── Validation helpers ─────────────────────────────────────────────────
20
22
  export function validateWorkflowFile(filePath) {
21
23
  const errors = [];
24
+ const warnings = [];
22
25
  if (!fs.existsSync(filePath)) {
23
- return { valid: false, errors: ["File not found"] };
26
+ return { valid: false, errors: ["File not found"], warnings };
24
27
  }
25
28
  const raw = fs.readFileSync(filePath, "utf-8");
26
29
  let parsed;
@@ -28,19 +31,48 @@ export function validateWorkflowFile(filePath) {
28
31
  parsed = parseYaml(raw);
29
32
  }
30
33
  catch (e) {
31
- return { valid: false, errors: [`Invalid YAML: ${e instanceof Error ? e.message : e}`] };
34
+ return { valid: false, errors: [`Invalid YAML: ${e instanceof Error ? e.message : e}`], warnings };
32
35
  }
33
36
  try {
34
37
  const workflow = parseWorkflow(parsed);
35
- const schemaErrors = validateWorkflow(workflow);
38
+ // Build known skill IDs from builtins + custom + inline workflow skills
39
+ const customSkills = discoverSkills();
40
+ const allSkillIds = new Set([...builtinSkills.map((s) => s.id), ...customSkills.map((s) => s.id)]);
41
+ // Inline skills defined in the workflow's skills block (a Record<id, def>) are also valid
42
+ if (workflow.skills) {
43
+ for (const skillId of Object.keys(workflow.skills)) {
44
+ allSkillIds.add(skillId);
45
+ }
46
+ }
47
+ const schemaErrors = validateWorkflow(workflow, allSkillIds);
36
48
  if (schemaErrors.length > 0) {
37
49
  return {
38
50
  valid: false,
39
51
  id: workflow.id,
40
52
  name: workflow.name,
41
53
  errors: schemaErrors.map((e) => e.message),
54
+ warnings,
42
55
  };
43
56
  }
57
+ // Config completeness warnings
58
+ const allSkills = [...builtinSkills, ...customSkills];
59
+ const skillMap = new Map(allSkills.map((s) => [s.id, s]));
60
+ const referencedSkillIds = new Set();
61
+ for (const node of Object.values(workflow.nodes)) {
62
+ for (const id of node.skills)
63
+ referencedSkillIds.add(id);
64
+ }
65
+ for (const id of referencedSkillIds) {
66
+ const skill = skillMap.get(id);
67
+ if (!skill)
68
+ continue;
69
+ const requiredEnvs = Object.entries(skill.config)
70
+ .filter(([, f]) => f.required && f.env)
71
+ .map(([, f]) => f.env);
72
+ if (requiredEnvs.length > 0) {
73
+ warnings.push(`Skill "${id}" requires: ${requiredEnvs.join(", ")}`);
74
+ }
75
+ }
44
76
  return {
45
77
  valid: true,
46
78
  id: workflow.id,
@@ -48,10 +80,11 @@ export function validateWorkflowFile(filePath) {
48
80
  nodeCount: Object.keys(workflow.nodes).length,
49
81
  edgeCount: workflow.edges.length,
50
82
  errors: [],
83
+ warnings,
51
84
  };
52
85
  }
53
86
  catch (e) {
54
- return { valid: false, errors: [`Schema error: ${e instanceof Error ? e.message : e}`] };
87
+ return { valid: false, errors: [`Schema error: ${e instanceof Error ? e.message : e}`], warnings };
55
88
  }
56
89
  }
57
90
  export function validateSkillDir(dirPath) {
@@ -171,6 +204,11 @@ async function publishWorkflow() {
171
204
  return null;
172
205
  }
173
206
  p.log.success(`Validated: ${chalk.cyan(validation.name)} — ${validation.nodeCount} nodes, ${validation.edgeCount} edges`);
207
+ if (validation.warnings.length > 0) {
208
+ for (const warn of validation.warnings) {
209
+ p.log.warn(warn);
210
+ }
211
+ }
174
212
  // Step 3: Metadata
175
213
  const raw = parseYaml(fs.readFileSync(absPath, "utf-8"));
176
214
  let author = raw.author;
@@ -79,12 +79,26 @@ function parseSkillMd(content) {
79
79
  };
80
80
  }
81
81
  }
82
+ // Parse config fields from frontmatter
83
+ const config = {};
84
+ if (fm.config && typeof fm.config === "object") {
85
+ for (const [key, value] of Object.entries(fm.config)) {
86
+ if (value && typeof value === "object") {
87
+ const v = value;
88
+ config[key] = {
89
+ description: typeof v.description === "string" ? v.description : key,
90
+ required: v.required === true,
91
+ env: key,
92
+ };
93
+ }
94
+ }
95
+ }
82
96
  return {
83
97
  id,
84
98
  name: id,
85
99
  description: typeof fm.description === "string" ? fm.description : `Custom skill: ${id}`,
86
100
  category: "general",
87
- config: {},
101
+ config,
88
102
  tools: [],
89
103
  instruction: instruction || undefined,
90
104
  mcp,
@@ -128,9 +142,10 @@ export function configuredSkills(env = process.env, cwd) {
128
142
  for (const skill of custom) {
129
143
  const existing = configuredMap.get(skill.id);
130
144
  if (existing) {
131
- // Merge: built-in tools + custom instruction/mcp
145
+ // Merge: built-in tools + config, custom instruction/mcp + extra config
132
146
  configuredMap.set(skill.id, {
133
147
  ...existing,
148
+ config: { ...existing.config, ...skill.config },
134
149
  instruction: skill.instruction ?? existing.instruction,
135
150
  mcp: skill.mcp ?? existing.mcp,
136
151
  });
@@ -27,6 +27,17 @@ export declare function buildSystemPrompt(skills: Skill[], existingWorkflow?: Wo
27
27
  * and any other non-workflow keys before parsing.
28
28
  */
29
29
  export declare function extractWorkflow(data: Record<string, unknown>): Workflow;
30
+ /**
31
+ * Derive the required environment variables for a workflow from its skills.
32
+ * Deterministic — doesn't depend on Claude generating a variables block.
33
+ */
34
+ export interface DerivedVariable {
35
+ name: string;
36
+ description: string;
37
+ required: boolean;
38
+ skill: string;
39
+ }
40
+ export declare function deriveWorkflowVariables(workflow: Workflow, skills: Skill[]): DerivedVariable[];
30
41
  /**
31
42
  * Generate a complete workflow from a natural language description.
32
43
  *
@@ -28,7 +28,16 @@ Good: "Query Sentry for unresolved errors from the last 24 hours. Group by issue
28
28
  * guidance, rules, and optionally an existing workflow to refine.
29
29
  */
30
30
  export function buildSystemPrompt(skills, existingWorkflow) {
31
- const skillList = skills.map((s) => `- ${s.id}: ${s.description}`).join("\n");
31
+ const skillList = skills
32
+ .map((s) => {
33
+ const configEntries = Object.entries(s.config)
34
+ .filter(([, f]) => f.env)
35
+ .map(([, f]) => `${f.env} (${f.required ? "required" : "optional"})`)
36
+ .join(", ");
37
+ const configLine = configEntries ? `\n Config: ${configEntries}` : "";
38
+ return `- ${s.id}: ${s.description}${configLine}`;
39
+ })
40
+ .join("\n");
32
41
  const parts = [
33
42
  "You generate SWEny workflow definitions as JSON.",
34
43
  "",
@@ -73,6 +82,37 @@ export function extractWorkflow(data) {
73
82
  }
74
83
  return parsed;
75
84
  }
85
+ export function deriveWorkflowVariables(workflow, skills) {
86
+ const vars = [
87
+ { name: "ANTHROPIC_API_KEY", description: "Claude API key", required: true, skill: "core" },
88
+ ];
89
+ const seen = new Set(["ANTHROPIC_API_KEY"]);
90
+ const skillMap = new Map(skills.map((s) => [s.id, s]));
91
+ // Collect all skill IDs referenced by workflow nodes
92
+ const referencedSkills = new Set();
93
+ for (const node of Object.values(workflow.nodes)) {
94
+ for (const id of node.skills)
95
+ referencedSkills.add(id);
96
+ }
97
+ for (const id of referencedSkills) {
98
+ const skill = skillMap.get(id);
99
+ if (!skill)
100
+ continue;
101
+ for (const [envKey, field] of Object.entries(skill.config)) {
102
+ const name = field.env ?? envKey;
103
+ if (seen.has(name))
104
+ continue;
105
+ seen.add(name);
106
+ vars.push({
107
+ name,
108
+ description: field.description,
109
+ required: field.required ?? false,
110
+ skill: id,
111
+ });
112
+ }
113
+ }
114
+ return vars;
115
+ }
76
116
  // ─── Public API ───────────────────────────────────────────────────
77
117
  /**
78
118
  * Generate a complete workflow from a natural language description.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sweny-ai/core",
3
- "version": "0.1.60",
3
+ "version": "0.1.61",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "sweny": "./dist/cli/main.js"