@sweny-ai/core 0.1.59 → 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.
- package/dist/__tests__/custom-loader.test.d.ts +1 -0
- package/dist/__tests__/custom-loader.test.js +116 -0
- package/dist/__tests__/publish.test.js +34 -0
- package/dist/__tests__/skill-config-integration.test.d.ts +1 -0
- package/dist/__tests__/skill-config-integration.test.js +204 -0
- package/dist/__tests__/studio.test.js +158 -0
- package/dist/__tests__/workflow-builder.test.d.ts +1 -0
- package/dist/__tests__/workflow-builder.test.js +110 -0
- package/dist/cli/new.d.ts +18 -3
- package/dist/cli/new.js +86 -49
- package/dist/cli/new.test.js +36 -0
- package/dist/cli/publish.d.ts +1 -0
- package/dist/cli/publish.js +42 -4
- package/dist/skills/custom-loader.js +17 -2
- package/dist/workflow-builder.d.ts +11 -0
- package/dist/workflow-builder.js +41 -1
- package/package.json +1 -1
|
@@ -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
|
+
});
|
|
@@ -139,6 +139,157 @@ describe("workflowToFlow", () => {
|
|
|
139
139
|
});
|
|
140
140
|
});
|
|
141
141
|
});
|
|
142
|
+
describe("edge IDs and edgeIndex", () => {
|
|
143
|
+
it("generates unique edge IDs using array index", () => {
|
|
144
|
+
const { edges } = workflowToFlow(testWorkflow);
|
|
145
|
+
const ids = edges.map((e) => e.id);
|
|
146
|
+
expect(ids[0]).toBe("edge-0");
|
|
147
|
+
expect(ids[1]).toBe("edge-1");
|
|
148
|
+
// All IDs are unique
|
|
149
|
+
expect(new Set(ids).size).toBe(ids.length);
|
|
150
|
+
});
|
|
151
|
+
it("assigns correct edgeIndex matching array position", () => {
|
|
152
|
+
const { edges } = workflowToFlow(testWorkflow);
|
|
153
|
+
edges.forEach((edge, i) => {
|
|
154
|
+
expect(edge.edgeIndex).toBe(i);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
it("produces unique IDs for multiple edges between the same node pair", () => {
|
|
158
|
+
const wf = {
|
|
159
|
+
id: "multi-edge",
|
|
160
|
+
name: "Multi-Edge",
|
|
161
|
+
description: "",
|
|
162
|
+
entry: "a",
|
|
163
|
+
nodes: {
|
|
164
|
+
a: { name: "A", instruction: "A", skills: [] },
|
|
165
|
+
b: { name: "B", instruction: "B", skills: [] },
|
|
166
|
+
},
|
|
167
|
+
edges: [
|
|
168
|
+
{ from: "a", to: "b" },
|
|
169
|
+
{ from: "a", to: "b", when: "error detected" },
|
|
170
|
+
{ from: "a", to: "b", when: "retry needed" },
|
|
171
|
+
],
|
|
172
|
+
};
|
|
173
|
+
const { edges } = workflowToFlow(wf);
|
|
174
|
+
expect(edges).toHaveLength(3);
|
|
175
|
+
const ids = edges.map((e) => e.id);
|
|
176
|
+
expect(new Set(ids).size).toBe(3);
|
|
177
|
+
expect(ids).toEqual(["edge-0", "edge-1", "edge-2"]);
|
|
178
|
+
});
|
|
179
|
+
it("produces stable IDs across multiple conversions", () => {
|
|
180
|
+
const first = workflowToFlow(testWorkflow).edges.map((e) => e.id);
|
|
181
|
+
const second = workflowToFlow(testWorkflow).edges.map((e) => e.id);
|
|
182
|
+
expect(first).toEqual(second);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
describe("max_iterations in conversion", () => {
|
|
186
|
+
const wfWithMaxIter = {
|
|
187
|
+
id: "retry-loop",
|
|
188
|
+
name: "Retry Loop",
|
|
189
|
+
description: "",
|
|
190
|
+
entry: "check",
|
|
191
|
+
nodes: {
|
|
192
|
+
check: { name: "Check", instruction: "Check it", skills: [] },
|
|
193
|
+
fix: { name: "Fix", instruction: "Fix it", skills: [] },
|
|
194
|
+
},
|
|
195
|
+
edges: [
|
|
196
|
+
{ from: "check", to: "fix", when: "issue found" },
|
|
197
|
+
{ from: "fix", to: "check", when: "needs re-check", max_iterations: 3 },
|
|
198
|
+
],
|
|
199
|
+
};
|
|
200
|
+
it("includes max_iterations in flow edge data", () => {
|
|
201
|
+
const { edges } = workflowToFlow(wfWithMaxIter);
|
|
202
|
+
const retryEdge = edges.find((e) => e.source === "fix" && e.target === "check");
|
|
203
|
+
expect(retryEdge?.data.max_iterations).toBe(3);
|
|
204
|
+
});
|
|
205
|
+
it("omits max_iterations when not set", () => {
|
|
206
|
+
const { edges } = workflowToFlow(wfWithMaxIter);
|
|
207
|
+
const firstEdge = edges.find((e) => e.source === "check" && e.target === "fix");
|
|
208
|
+
expect(firstEdge?.data.max_iterations).toBeUndefined();
|
|
209
|
+
});
|
|
210
|
+
it("round-trips max_iterations through flowToWorkflow", () => {
|
|
211
|
+
const { nodes, edges } = workflowToFlow(wfWithMaxIter);
|
|
212
|
+
const rebuilt = flowToWorkflow({
|
|
213
|
+
id: wfWithMaxIter.id,
|
|
214
|
+
name: wfWithMaxIter.name,
|
|
215
|
+
description: wfWithMaxIter.description,
|
|
216
|
+
entry: wfWithMaxIter.entry,
|
|
217
|
+
}, nodes, edges);
|
|
218
|
+
const retryEdge = rebuilt.edges.find((e) => e.from === "fix" && e.to === "check");
|
|
219
|
+
expect(retryEdge?.max_iterations).toBe(3);
|
|
220
|
+
// Non-retry edge should not have max_iterations
|
|
221
|
+
const normalEdge = rebuilt.edges.find((e) => e.from === "check" && e.to === "fix");
|
|
222
|
+
expect(normalEdge).not.toHaveProperty("max_iterations");
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
describe("Source type preservation", () => {
|
|
226
|
+
it("preserves inline string instructions through round-trip", () => {
|
|
227
|
+
const { nodes, edges } = workflowToFlow(testWorkflow);
|
|
228
|
+
const rebuilt = flowToWorkflow({ id: "x", name: "X", description: "", entry: "a" }, nodes, edges);
|
|
229
|
+
expect(rebuilt.nodes.a.instruction).toBe("Do A");
|
|
230
|
+
});
|
|
231
|
+
it("preserves {file:} Source through round-trip", () => {
|
|
232
|
+
const wf = {
|
|
233
|
+
id: "file-src",
|
|
234
|
+
name: "File Source",
|
|
235
|
+
description: "",
|
|
236
|
+
entry: "a",
|
|
237
|
+
nodes: {
|
|
238
|
+
a: { name: "A", instruction: { file: "./instructions/step-a.md" }, skills: [] },
|
|
239
|
+
},
|
|
240
|
+
edges: [],
|
|
241
|
+
};
|
|
242
|
+
const { nodes, edges } = workflowToFlow(wf);
|
|
243
|
+
const rebuilt = flowToWorkflow({ id: wf.id, name: wf.name, description: "", entry: "a" }, nodes, edges);
|
|
244
|
+
expect(rebuilt.nodes.a.instruction).toEqual({ file: "./instructions/step-a.md" });
|
|
245
|
+
});
|
|
246
|
+
it("preserves {url:} Source through round-trip", () => {
|
|
247
|
+
const wf = {
|
|
248
|
+
id: "url-src",
|
|
249
|
+
name: "URL Source",
|
|
250
|
+
description: "",
|
|
251
|
+
entry: "a",
|
|
252
|
+
nodes: {
|
|
253
|
+
a: { name: "A", instruction: { url: "https://example.com/instruction.md" }, skills: [] },
|
|
254
|
+
},
|
|
255
|
+
edges: [],
|
|
256
|
+
};
|
|
257
|
+
const { nodes, edges } = workflowToFlow(wf);
|
|
258
|
+
const rebuilt = flowToWorkflow({ id: wf.id, name: wf.name, description: "", entry: "a" }, nodes, edges);
|
|
259
|
+
expect(rebuilt.nodes.a.instruction).toEqual({ url: "https://example.com/instruction.md" });
|
|
260
|
+
});
|
|
261
|
+
it("preserves {inline:} Source through round-trip", () => {
|
|
262
|
+
const wf = {
|
|
263
|
+
id: "inline-src",
|
|
264
|
+
name: "Inline Source",
|
|
265
|
+
description: "",
|
|
266
|
+
entry: "a",
|
|
267
|
+
nodes: {
|
|
268
|
+
a: { name: "A", instruction: { inline: "Explicit inline text" }, skills: [] },
|
|
269
|
+
},
|
|
270
|
+
edges: [],
|
|
271
|
+
};
|
|
272
|
+
const { nodes, edges } = workflowToFlow(wf);
|
|
273
|
+
const rebuilt = flowToWorkflow({ id: wf.id, name: wf.name, description: "", entry: "a" }, nodes, edges);
|
|
274
|
+
expect(rebuilt.nodes.a.instruction).toEqual({ inline: "Explicit inline text" });
|
|
275
|
+
});
|
|
276
|
+
it("preserves {url:, type:} Source through round-trip", () => {
|
|
277
|
+
const wf = {
|
|
278
|
+
id: "url-type-src",
|
|
279
|
+
name: "URL Type Source",
|
|
280
|
+
description: "",
|
|
281
|
+
entry: "a",
|
|
282
|
+
nodes: {
|
|
283
|
+
a: { name: "A", instruction: { url: "https://example.com/data", type: "fetch" }, skills: [] },
|
|
284
|
+
},
|
|
285
|
+
edges: [],
|
|
286
|
+
};
|
|
287
|
+
const { nodes, edges } = workflowToFlow(wf);
|
|
288
|
+
const rebuilt = flowToWorkflow({ id: wf.id, name: wf.name, description: "", entry: "a" }, nodes, edges);
|
|
289
|
+
const src = rebuilt.nodes.a.instruction;
|
|
290
|
+
expect(src).toEqual({ url: "https://example.com/data", type: "fetch" });
|
|
291
|
+
});
|
|
292
|
+
});
|
|
142
293
|
describe("flowToWorkflow", () => {
|
|
143
294
|
it("round-trips correctly", () => {
|
|
144
295
|
const { nodes, edges } = workflowToFlow(testWorkflow);
|
|
@@ -160,6 +311,13 @@ describe("flowToWorkflow", () => {
|
|
|
160
311
|
const unconditional = rebuilt.edges.find((e) => e.from === "a" && e.to === "b");
|
|
161
312
|
expect(unconditional).not.toHaveProperty("when");
|
|
162
313
|
});
|
|
314
|
+
it("omits max_iterations when not set", () => {
|
|
315
|
+
const { nodes, edges } = workflowToFlow(testWorkflow);
|
|
316
|
+
const rebuilt = flowToWorkflow({ id: "x", name: "X", description: "", entry: "a" }, nodes, edges);
|
|
317
|
+
for (const edge of rebuilt.edges) {
|
|
318
|
+
expect(edge).not.toHaveProperty("max_iterations");
|
|
319
|
+
}
|
|
320
|
+
});
|
|
163
321
|
});
|
|
164
322
|
describe("applyExecutionEvent", () => {
|
|
165
323
|
function makeNodeDataMap() {
|
|
@@ -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
|
-
*
|
|
30
|
-
*
|
|
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(
|
|
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
|
|
23
|
+
// ── Skill → Credential extras ─────────────────────────────────────────
|
|
23
24
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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(),
|
package/dist/cli/new.test.js
CHANGED
|
@@ -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
|
//
|
package/dist/cli/publish.d.ts
CHANGED
package/dist/cli/publish.js
CHANGED
|
@@ -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
|
-
|
|
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
|
*
|
package/dist/workflow-builder.js
CHANGED
|
@@ -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
|
|
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.
|