@sweny-ai/core 0.1.60 → 0.1.62

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/claude.d.ts CHANGED
@@ -43,6 +43,7 @@ export declare class ClaudeClient implements Claude {
43
43
  tools: Tool[];
44
44
  outputSchema?: JSONSchema;
45
45
  onProgress?: (message: string) => void;
46
+ maxTurns?: number;
46
47
  }): Promise<NodeResult>;
47
48
  evaluate(opts: {
48
49
  question: string;
package/dist/claude.js CHANGED
@@ -40,7 +40,7 @@ export class ClaudeClient {
40
40
  return env;
41
41
  }
42
42
  async run(opts) {
43
- const { instruction, context, tools, outputSchema, onProgress } = opts;
43
+ const { instruction, context, tools, outputSchema, onProgress, maxTurns } = opts;
44
44
  const toolCalls = [];
45
45
  // Convert core tools to SDK MCP tools
46
46
  const sdkTools = tools.map((t) => coreToolToSdkTool(t, this.defaultContext, toolCalls));
@@ -68,7 +68,7 @@ export class ClaudeClient {
68
68
  const stream = query({
69
69
  prompt,
70
70
  options: {
71
- maxTurns: this.maxTurns,
71
+ maxTurns: maxTurns ?? this.maxTurns,
72
72
  systemPrompt: SYSTEM_PROMPT,
73
73
  cwd: this.cwd,
74
74
  env,
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;
package/dist/executor.js CHANGED
@@ -97,6 +97,7 @@ export async function execute(workflow, input, options) {
97
97
  context,
98
98
  tools: trackedTools,
99
99
  outputSchema: node.output,
100
+ maxTurns: node.max_turns,
100
101
  onProgress: (message) => {
101
102
  safeObserve(observer, { type: "node:progress", node: currentId, message }, logger);
102
103
  },
package/dist/schema.d.ts CHANGED
@@ -359,6 +359,7 @@ export declare const nodeZ: z.ZodObject<{
359
359
  }>]>;
360
360
  skills: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
361
361
  output: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
362
+ max_turns: z.ZodOptional<z.ZodNumber>;
362
363
  }, "strip", z.ZodTypeAny, {
363
364
  instruction: string | {
364
365
  inline: string;
@@ -371,6 +372,7 @@ export declare const nodeZ: z.ZodObject<{
371
372
  name: string;
372
373
  skills: string[];
373
374
  output?: Record<string, unknown> | undefined;
375
+ max_turns?: number | undefined;
374
376
  }, {
375
377
  instruction: string | {
376
378
  inline: string;
@@ -383,6 +385,7 @@ export declare const nodeZ: z.ZodObject<{
383
385
  name: string;
384
386
  output?: Record<string, unknown> | undefined;
385
387
  skills?: string[] | undefined;
388
+ max_turns?: number | undefined;
386
389
  }>;
387
390
  export declare const edgeZ: z.ZodObject<{
388
391
  from: z.ZodString;
@@ -430,6 +433,7 @@ export declare const workflowZ: z.ZodObject<{
430
433
  }>]>;
431
434
  skills: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
432
435
  output: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
436
+ max_turns: z.ZodOptional<z.ZodNumber>;
433
437
  }, "strip", z.ZodTypeAny, {
434
438
  instruction: string | {
435
439
  inline: string;
@@ -442,6 +446,7 @@ export declare const workflowZ: z.ZodObject<{
442
446
  name: string;
443
447
  skills: string[];
444
448
  output?: Record<string, unknown> | undefined;
449
+ max_turns?: number | undefined;
445
450
  }, {
446
451
  instruction: string | {
447
452
  inline: string;
@@ -454,6 +459,7 @@ export declare const workflowZ: z.ZodObject<{
454
459
  name: string;
455
460
  output?: Record<string, unknown> | undefined;
456
461
  skills?: string[] | undefined;
462
+ max_turns?: number | undefined;
457
463
  }>>;
458
464
  edges: z.ZodArray<z.ZodObject<{
459
465
  from: z.ZodString;
@@ -590,6 +596,7 @@ export declare const workflowZ: z.ZodObject<{
590
596
  name: string;
591
597
  skills: string[];
592
598
  output?: Record<string, unknown> | undefined;
599
+ max_turns?: number | undefined;
593
600
  }>;
594
601
  edges: {
595
602
  to: string;
@@ -613,6 +620,7 @@ export declare const workflowZ: z.ZodObject<{
613
620
  name: string;
614
621
  output?: Record<string, unknown> | undefined;
615
622
  skills?: string[] | undefined;
623
+ max_turns?: number | undefined;
616
624
  }>;
617
625
  edges: {
618
626
  to: string;
@@ -666,6 +674,7 @@ export declare function parseWorkflow(raw: unknown): {
666
674
  name: string;
667
675
  skills: string[];
668
676
  output?: Record<string, unknown> | undefined;
677
+ max_turns?: number | undefined;
669
678
  }>;
670
679
  edges: {
671
680
  to: string;
@@ -787,6 +796,11 @@ export declare const workflowJsonSchema: {
787
796
  readonly type: "object";
788
797
  readonly description: "Optional JSON Schema for structured output";
789
798
  };
799
+ readonly max_turns: {
800
+ readonly type: "integer";
801
+ readonly minimum: 1;
802
+ readonly description: "Max AI model turns for this node. When absent, the executor's default applies.";
803
+ };
790
804
  };
791
805
  };
792
806
  };
package/dist/schema.js CHANGED
@@ -68,6 +68,7 @@ export const nodeZ = z.object({
68
68
  instruction: sourceZ,
69
69
  skills: z.array(z.string()).default([]),
70
70
  output: jsonSchemaZ.optional(),
71
+ max_turns: z.number().int().min(1).optional(),
71
72
  });
72
73
  export const edgeZ = z.object({
73
74
  from: z.string().min(1),
@@ -292,6 +293,11 @@ export const workflowJsonSchema = {
292
293
  type: "object",
293
294
  description: "Optional JSON Schema for structured output",
294
295
  },
296
+ max_turns: {
297
+ type: "integer",
298
+ minimum: 1,
299
+ description: "Max AI model turns for this node. When absent, the executor's default applies.",
300
+ },
295
301
  },
296
302
  },
297
303
  },
@@ -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
  });
package/dist/types.d.ts CHANGED
@@ -47,6 +47,8 @@ export interface Node {
47
47
  skills: string[];
48
48
  /** Optional structured output schema */
49
49
  output?: JSONSchema;
50
+ /** Max AI model turns for this node. When absent, the executor's default applies. */
51
+ max_turns?: number;
50
52
  }
51
53
  /** An edge connecting two nodes */
52
54
  export interface Edge {
@@ -170,6 +172,8 @@ export interface Claude {
170
172
  outputSchema?: JSONSchema;
171
173
  /** Called with status messages while Claude is working (tool name, etc.) */
172
174
  onProgress?: (message: string) => void;
175
+ /** Per-node turn limit. Overrides the client default when set. */
176
+ maxTurns?: number;
173
177
  }): Promise<NodeResult>;
174
178
  /** Evaluate a routing condition — pick one of N choices */
175
179
  evaluate(opts: {
@@ -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.62",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "sweny": "./dist/cli/main.js"