skill-flow 1.0.2 → 1.0.3
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/README.md +24 -22
- package/README.zh.md +25 -24
- package/dist/cli.js +4 -4
- package/dist/cli.js.map +1 -1
- package/dist/services/doctor-service.js +1 -1
- package/dist/services/doctor-service.js.map +1 -1
- package/dist/services/skill-flow.js +1 -1
- package/dist/services/skill-flow.js.map +1 -1
- package/dist/services/source-service.js +4 -4
- package/dist/services/source-service.js.map +1 -1
- package/dist/tests/skill-flow.test.js +1427 -0
- package/dist/tests/skill-flow.test.js.map +1 -1
- package/dist/tui/config-app.js +1 -1
- package/dist/tui/config-app.js.map +1 -1
- package/dist/utils/format.js +1 -1
- package/dist/utils/format.js.map +1 -1
- package/package.json +10 -1
- package/.gstack/browse-network.log +0 -1
- package/.gstack/browse.json +0 -7
- package/.gstack/qa-reports/base-branch.txt +0 -1
- package/.gstack/qa-reports/qa-report-skill-flow-cli-2026-03-22.md +0 -159
- package/.gstack/qa-reports/qa-report-skill-manager-2026-03-22.md +0 -60
- package/docs/DESIGN.md +0 -407
- package/docs/PRD/PRD-1.0.0.md +0 -1862
- package/docs/PRD/renew/PRD-0.0.0.md +0 -26
- package/docs/PRD/renew/PRD-0.0.1.md +0 -408
- package/docs/PRD/renew/PRD-0.0.2.md +0 -705
- package/docs/PRD/renew/PRD-0.0.3.md +0 -740
- package/docs/PRD/renew/PRD-0.0.4.md +0 -1494
- package/docs/README.md +0 -242
- package/docs/plan/PLAN_v1.0.0.md +0 -663
- package/docs/plan/PLAN_v1.0.1.md +0 -845
- package/docs/refrences/README.md +0 -9
- package/docs/refrences/agent-skill-paths.md +0 -274
- package/docs/refrences/config-state-reconciliation.md +0 -199
- package/docs/refrences/naming-dedupe-warning-rules.md +0 -482
- package/img/img-1.jpg +0 -0
|
@@ -0,0 +1,1427 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { execFileSync } from "node:child_process";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
6
|
+
import { InventoryService } from "../services/inventory-service.js";
|
|
7
|
+
import { SkillFlowApp } from "../services/skill-flow.js";
|
|
8
|
+
import * as clawhubUtils from "../utils/clawhub.js";
|
|
9
|
+
import * as builtinGitSources from "../utils/builtin-git-sources.js";
|
|
10
|
+
import * as githubCatalog from "../utils/github-catalog.js";
|
|
11
|
+
import { buildFindCommand } from "../utils/find-command.js";
|
|
12
|
+
import { buildProjectionWarningMap, buildCommandBar, buildContextBar, buildSaveLabel, draftsEqual, getPaneWidths, getPaneViewportCount, getSaveDisplayPhase, } from "../tui/config-app.js";
|
|
13
|
+
import { TARGET_COMPAT_READ_CANDIDATES, TARGET_DEFINITIONS, TARGET_PATH_CANDIDATES, } from "../utils/constants.js";
|
|
14
|
+
import { buildProjectedSkillName, formatGroupLabel, resolveProjectedSkillNames, } from "../utils/naming.js";
|
|
15
|
+
import { getParentSelectionState, toggleChild, toggleParent, } from "../tui/selection-state.js";
|
|
16
|
+
import { deriveSourceId } from "../utils/source-id.js";
|
|
17
|
+
describe.sequential("skill-flow", () => {
|
|
18
|
+
let sandboxRoot;
|
|
19
|
+
let stateRoot;
|
|
20
|
+
let targetsRoot;
|
|
21
|
+
beforeEach(async () => {
|
|
22
|
+
sandboxRoot = await fs.mkdtemp(path.join(os.tmpdir(), "skill-flow-test-"));
|
|
23
|
+
stateRoot = path.join(sandboxRoot, "state");
|
|
24
|
+
targetsRoot = path.join(sandboxRoot, "targets");
|
|
25
|
+
await fs.mkdir(targetsRoot, { recursive: true });
|
|
26
|
+
process.env.SKILL_FLOW_STATE_ROOT = stateRoot;
|
|
27
|
+
process.env.SKILL_FLOW_TARGET_CLAUDE_CODE = path.join(targetsRoot, "claude");
|
|
28
|
+
process.env.SKILL_FLOW_TARGET_CODEX = path.join(targetsRoot, "codex");
|
|
29
|
+
process.env.SKILL_FLOW_TARGET_CURSOR = path.join(targetsRoot, "cursor");
|
|
30
|
+
process.env.SKILL_FLOW_TARGET_GITHUB_COPILOT = path.join(targetsRoot, "github-copilot");
|
|
31
|
+
process.env.SKILL_FLOW_TARGET_GEMINI_CLI = path.join(targetsRoot, "gemini-cli");
|
|
32
|
+
process.env.SKILL_FLOW_TARGET_OPENCODE = path.join(targetsRoot, "opencode");
|
|
33
|
+
process.env.SKILL_FLOW_TARGET_OPENCLAW = path.join(targetsRoot, "openclaw");
|
|
34
|
+
process.env.SKILL_FLOW_TARGET_PI = path.join(targetsRoot, "pi");
|
|
35
|
+
process.env.SKILL_FLOW_TARGET_WINDSURF = path.join(targetsRoot, "windsurf");
|
|
36
|
+
process.env.SKILL_FLOW_TARGET_ROO_CODE = path.join(targetsRoot, "roo-code");
|
|
37
|
+
process.env.SKILL_FLOW_TARGET_CLINE = path.join(targetsRoot, "cline");
|
|
38
|
+
process.env.SKILL_FLOW_TARGET_AMP = path.join(targetsRoot, "amp");
|
|
39
|
+
process.env.SKILL_FLOW_TARGET_KIRO = path.join(targetsRoot, "kiro");
|
|
40
|
+
await fs.mkdir(process.env.SKILL_FLOW_TARGET_CLAUDE_CODE, { recursive: true });
|
|
41
|
+
await fs.mkdir(process.env.SKILL_FLOW_TARGET_CODEX, { recursive: true });
|
|
42
|
+
await fs.mkdir(process.env.SKILL_FLOW_TARGET_CURSOR, { recursive: true });
|
|
43
|
+
await fs.mkdir(process.env.SKILL_FLOW_TARGET_GITHUB_COPILOT, { recursive: true });
|
|
44
|
+
await fs.mkdir(process.env.SKILL_FLOW_TARGET_GEMINI_CLI, { recursive: true });
|
|
45
|
+
await fs.mkdir(process.env.SKILL_FLOW_TARGET_OPENCODE, { recursive: true });
|
|
46
|
+
await fs.mkdir(process.env.SKILL_FLOW_TARGET_OPENCLAW, { recursive: true });
|
|
47
|
+
await fs.mkdir(process.env.SKILL_FLOW_TARGET_PI, { recursive: true });
|
|
48
|
+
await fs.mkdir(process.env.SKILL_FLOW_TARGET_WINDSURF, { recursive: true });
|
|
49
|
+
await fs.mkdir(process.env.SKILL_FLOW_TARGET_ROO_CODE, { recursive: true });
|
|
50
|
+
await fs.mkdir(process.env.SKILL_FLOW_TARGET_CLINE, { recursive: true });
|
|
51
|
+
await fs.mkdir(process.env.SKILL_FLOW_TARGET_AMP, { recursive: true });
|
|
52
|
+
await fs.mkdir(process.env.SKILL_FLOW_TARGET_KIRO, { recursive: true });
|
|
53
|
+
});
|
|
54
|
+
afterEach(async () => {
|
|
55
|
+
vi.restoreAllMocks();
|
|
56
|
+
delete process.env.SKILL_FLOW_STATE_ROOT;
|
|
57
|
+
delete process.env.SKILL_FLOW_TARGET_CLAUDE_CODE;
|
|
58
|
+
delete process.env.SKILL_FLOW_TARGET_CODEX;
|
|
59
|
+
delete process.env.SKILL_FLOW_TARGET_CURSOR;
|
|
60
|
+
delete process.env.SKILL_FLOW_TARGET_GITHUB_COPILOT;
|
|
61
|
+
delete process.env.SKILL_FLOW_TARGET_GEMINI_CLI;
|
|
62
|
+
delete process.env.SKILL_FLOW_TARGET_OPENCODE;
|
|
63
|
+
delete process.env.SKILL_FLOW_TARGET_OPENCLAW;
|
|
64
|
+
delete process.env.SKILL_FLOW_TARGET_PI;
|
|
65
|
+
delete process.env.SKILL_FLOW_TARGET_WINDSURF;
|
|
66
|
+
delete process.env.SKILL_FLOW_TARGET_ROO_CODE;
|
|
67
|
+
delete process.env.SKILL_FLOW_TARGET_CLINE;
|
|
68
|
+
delete process.env.SKILL_FLOW_TARGET_AMP;
|
|
69
|
+
delete process.env.SKILL_FLOW_TARGET_KIRO;
|
|
70
|
+
await fs.rm(sandboxRoot, { recursive: true, force: true });
|
|
71
|
+
});
|
|
72
|
+
test("adds a git source and discovers valid skills", async () => {
|
|
73
|
+
const repoPath = await createRepo(sandboxRoot, {
|
|
74
|
+
"frontend/SKILL.md": skillDoc("frontend", "Build frontend flows."),
|
|
75
|
+
"ops/SKILL.md": skillDoc("ops", "Run operator workflows."),
|
|
76
|
+
});
|
|
77
|
+
const app = new SkillFlowApp();
|
|
78
|
+
const result = await app.addSource(repoPath);
|
|
79
|
+
expect(result.ok).toBe(true);
|
|
80
|
+
if (!result.ok) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
expect(result.data.leafCount).toBe(2);
|
|
84
|
+
expect(result.warnings).toHaveLength(0);
|
|
85
|
+
const manifest = await app.store.readManifest();
|
|
86
|
+
const binding = manifest.bindings[result.data.manifest.id];
|
|
87
|
+
expect(Object.keys(binding?.targets ?? {})).toEqual([
|
|
88
|
+
"claude-code",
|
|
89
|
+
"codex",
|
|
90
|
+
"cursor",
|
|
91
|
+
"github-copilot",
|
|
92
|
+
"gemini-cli",
|
|
93
|
+
"opencode",
|
|
94
|
+
"openclaw",
|
|
95
|
+
"pi",
|
|
96
|
+
"windsurf",
|
|
97
|
+
"roo-code",
|
|
98
|
+
"cline",
|
|
99
|
+
"amp",
|
|
100
|
+
"kiro",
|
|
101
|
+
]);
|
|
102
|
+
expect(binding?.targets["claude-code"]?.leafIds).toEqual([
|
|
103
|
+
`${result.data.manifest.id}:frontend`,
|
|
104
|
+
`${result.data.manifest.id}:ops`,
|
|
105
|
+
]);
|
|
106
|
+
});
|
|
107
|
+
test("adds a git source with path filtering but only preselects matching skills", async () => {
|
|
108
|
+
const repoPath = await createRepo(sandboxRoot, {
|
|
109
|
+
"skills/find-skills/SKILL.md": skillDoc("find-skills", "Find skills."),
|
|
110
|
+
"skills/review/SKILL.md": skillDoc("review", "Review code."),
|
|
111
|
+
});
|
|
112
|
+
const app = new SkillFlowApp();
|
|
113
|
+
const result = await app.addSource(repoPath, { path: "skills/find-skills" });
|
|
114
|
+
expect(result.ok).toBe(true);
|
|
115
|
+
if (!result.ok) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
expect(result.data.leafCount).toBe(2);
|
|
119
|
+
const list = await app.listWorkflows();
|
|
120
|
+
expect(list.ok).toBe(true);
|
|
121
|
+
if (!list.ok) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
expect(list.data.summaries[0]?.leafs.map((leaf) => leaf.relativePath)).toEqual([
|
|
125
|
+
"skills/find-skills",
|
|
126
|
+
"skills/review",
|
|
127
|
+
]);
|
|
128
|
+
const manifest = await app.store.readManifest();
|
|
129
|
+
const binding = manifest.bindings[result.data.manifest.id];
|
|
130
|
+
expect(binding?.targets["claude-code"]?.leafIds).toEqual([
|
|
131
|
+
`${result.data.manifest.id}:skills/find-skills`,
|
|
132
|
+
]);
|
|
133
|
+
});
|
|
134
|
+
test("rejects add path when it does not resolve to a valid skill", async () => {
|
|
135
|
+
const repoPath = await createRepo(sandboxRoot, {
|
|
136
|
+
"skills/find-skills/SKILL.md": skillDoc("find-skills", "Find skills."),
|
|
137
|
+
"docs/readme.md": "hello",
|
|
138
|
+
});
|
|
139
|
+
const app = new SkillFlowApp();
|
|
140
|
+
const result = await app.addSource(repoPath, { path: "docs" });
|
|
141
|
+
expect(result.ok).toBe(false);
|
|
142
|
+
if (result.ok) {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
expect(result.errors[0]?.code).toBe("SOURCE_PATH_NOT_FOUND");
|
|
146
|
+
});
|
|
147
|
+
test("parses GitHub tree URLs as repo sources with path filtering", async () => {
|
|
148
|
+
const app = new SkillFlowApp();
|
|
149
|
+
const result = await app.addSource("https://github.com/vercel-labs/skills/tree/main/skills/find-skills");
|
|
150
|
+
expect(result.ok).toBe(true);
|
|
151
|
+
if (!result.ok) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
expect(result.data.manifest.id).toBe("vercel-labs-skills");
|
|
155
|
+
expect(result.data.manifest.requestedPath).toBe("skills/find-skills");
|
|
156
|
+
}, 30000);
|
|
157
|
+
test("adds a ClawHub source and stores ClawHub lock metadata", async () => {
|
|
158
|
+
const app = new SkillFlowApp();
|
|
159
|
+
const result = await app.addSource("clawhub:find-skills-skill");
|
|
160
|
+
expect(result.ok).toBe(true);
|
|
161
|
+
if (!result.ok) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
expect(result.data.manifest.kind).toBe("clawhub");
|
|
165
|
+
expect(result.data.lock.kind).toBe("clawhub");
|
|
166
|
+
expect(result.data.lock.packageSlug).toBe("find-skills-skill");
|
|
167
|
+
expect(result.data.lock.resolvedVersion).toBeTruthy();
|
|
168
|
+
expect(result.data.leafCount).toBeGreaterThan(0);
|
|
169
|
+
}, 20000);
|
|
170
|
+
test("keeps a pinned ClawHub source unchanged on update", async () => {
|
|
171
|
+
const app = new SkillFlowApp();
|
|
172
|
+
const added = await app.addSource("clawhub:find-skills-skill@1.0.0");
|
|
173
|
+
expect(added.ok).toBe(true);
|
|
174
|
+
if (!added.ok) {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
expect(added.data.lock.versionMode).toBe("pinned");
|
|
178
|
+
const updated = await app.updateSources([added.data.manifest.id]);
|
|
179
|
+
expect(updated.ok).toBe(true);
|
|
180
|
+
if (!updated.ok) {
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
expect(updated.data.updated[0]?.changed).toBe(false);
|
|
184
|
+
}, 20000);
|
|
185
|
+
test("keeps a floating ClawHub source unchanged when no newer version exists", async () => {
|
|
186
|
+
const app = new SkillFlowApp();
|
|
187
|
+
const added = await app.addSource("clawhub:find-skills-skill");
|
|
188
|
+
expect(added.ok).toBe(true);
|
|
189
|
+
if (!added.ok) {
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
expect(added.data.lock.versionMode).toBe("floating");
|
|
193
|
+
const updated = await app.updateSources([added.data.manifest.id]);
|
|
194
|
+
expect(updated.ok).toBe(true);
|
|
195
|
+
if (!updated.ok) {
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
expect(updated.data.updated[0]?.changed).toBe(false);
|
|
199
|
+
}, 40000);
|
|
200
|
+
test("find prefers local results, then built-in git, then ClawHub", async () => {
|
|
201
|
+
vi.spyOn(clawhubUtils, "searchClawHubSkills").mockResolvedValueOnce([
|
|
202
|
+
{ slug: "browse-skill", title: "Browse Skill", score: 0.92 },
|
|
203
|
+
]);
|
|
204
|
+
const repoPath = await createRepo(sandboxRoot, {
|
|
205
|
+
"browse/SKILL.md": skillDoc("browse", "Local browse skill."),
|
|
206
|
+
});
|
|
207
|
+
const app = new SkillFlowApp();
|
|
208
|
+
const added = await app.addSource(repoPath);
|
|
209
|
+
expect(added.ok).toBe(true);
|
|
210
|
+
await seedBuiltinCatalog(app);
|
|
211
|
+
const builtin = builtinGitSources.getBuiltinGitSources()[0];
|
|
212
|
+
const builtinSourceId = deriveSourceId(builtin.locator);
|
|
213
|
+
await fs.mkdir(path.join(app.store.getCatalogCheckoutPath(builtinSourceId), "skills", "browse"), { recursive: true });
|
|
214
|
+
await fs.writeFile(path.join(app.store.getCatalogCheckoutPath(builtinSourceId), "skills", "browse", "SKILL.md"), skillDoc("browse", "Built-in browse skill."), "utf8");
|
|
215
|
+
const result = await app.findSkills("browse");
|
|
216
|
+
expect(result.ok).toBe(true);
|
|
217
|
+
if (!result.ok) {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
expect(result.data.candidates.map((candidate) => candidate.source)).toEqual([
|
|
221
|
+
"local",
|
|
222
|
+
"builtin-git",
|
|
223
|
+
"clawhub",
|
|
224
|
+
]);
|
|
225
|
+
}, 10000);
|
|
226
|
+
test("find falls back to stale built-in catalog cache with a warning", async () => {
|
|
227
|
+
vi.spyOn(clawhubUtils, "searchClawHubSkills").mockResolvedValueOnce([]);
|
|
228
|
+
vi.spyOn(builtinGitSources, "getBuiltinGitSources").mockReturnValue([
|
|
229
|
+
{ locator: "https://github.com/example/catalog.git", branch: "main" },
|
|
230
|
+
]);
|
|
231
|
+
vi.spyOn(githubCatalog, "fetchGitHubSkillPaths").mockRejectedValueOnce(new Error("offline"));
|
|
232
|
+
const app = new SkillFlowApp();
|
|
233
|
+
const sourceId = deriveSourceId("https://github.com/example/catalog.git");
|
|
234
|
+
await fs.mkdir(app.store.catalogRoot, { recursive: true });
|
|
235
|
+
await fs.writeFile(app.store.getCatalogIndexPath(sourceId), `${JSON.stringify({
|
|
236
|
+
locator: "https://github.com/example/catalog.git",
|
|
237
|
+
branch: "main",
|
|
238
|
+
skillPaths: ["skills/browse/SKILL.md"],
|
|
239
|
+
updatedAt: "2020-01-01T00:00:00.000Z",
|
|
240
|
+
}, null, 2)}\n`, "utf8");
|
|
241
|
+
const result = await app.findSkills("browse");
|
|
242
|
+
expect(result.ok).toBe(true);
|
|
243
|
+
if (!result.ok) {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
expect(result.data.candidates[0]?.source).toBe("builtin-git");
|
|
247
|
+
expect(result.warnings.some((warning) => warning.code === "BUILTIN_SOURCE_STALE_CACHE_USED")).toBe(true);
|
|
248
|
+
});
|
|
249
|
+
test("find builds repo-level add commands for built-in Git results", async () => {
|
|
250
|
+
vi.spyOn(clawhubUtils, "searchClawHubSkills").mockResolvedValueOnce([]);
|
|
251
|
+
const app = new SkillFlowApp();
|
|
252
|
+
await seedBuiltinCatalog(app);
|
|
253
|
+
const builtin = builtinGitSources.getBuiltinGitSources()[0];
|
|
254
|
+
const builtinSourceId = deriveSourceId(builtin.locator);
|
|
255
|
+
await fs.mkdir(path.join(app.store.getCatalogCheckoutPath(builtinSourceId), "skills", "find-skills"), { recursive: true });
|
|
256
|
+
await fs.writeFile(path.join(app.store.getCatalogCheckoutPath(builtinSourceId), "skills", "find-skills", "SKILL.md"), skillDoc("find-skills", "Find skills from a built-in repo."), "utf8");
|
|
257
|
+
const result = await app.findSkills("find skills");
|
|
258
|
+
expect(result.ok).toBe(true);
|
|
259
|
+
if (!result.ok) {
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
const candidate = result.data.candidates[0];
|
|
263
|
+
expect(candidate?.source).toBe("builtin-git");
|
|
264
|
+
expect(buildFindCommand(candidate)).toBe(`skill-flow add ${builtin.locator} --path skills/find-skills`);
|
|
265
|
+
}, 10000);
|
|
266
|
+
test("returns a clear error when git fetch fails", async () => {
|
|
267
|
+
const app = new SkillFlowApp();
|
|
268
|
+
const result = await app.addSource(path.join(sandboxRoot, "missing-repo"));
|
|
269
|
+
expect(result.ok).toBe(false);
|
|
270
|
+
if (result.ok) {
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
expect(result.errors[0]?.code).toBe("GIT_CLONE_FAILED");
|
|
274
|
+
});
|
|
275
|
+
test("normalizes GitHub locators to the same source id across supported formats", () => {
|
|
276
|
+
const locators = [
|
|
277
|
+
"https://github.com/garrytan/gstack",
|
|
278
|
+
"https://github.com/garrytan/gstack.git",
|
|
279
|
+
"git@github.com:garrytan/gstack.git",
|
|
280
|
+
"garrytan/gstack",
|
|
281
|
+
];
|
|
282
|
+
expect(locators.map((locator) => deriveSourceId(locator))).toEqual([
|
|
283
|
+
"garrytan-gstack",
|
|
284
|
+
"garrytan-gstack",
|
|
285
|
+
"garrytan-gstack",
|
|
286
|
+
"garrytan-gstack",
|
|
287
|
+
]);
|
|
288
|
+
});
|
|
289
|
+
test("normalizes ClawHub locators to the same source id across version forms", () => {
|
|
290
|
+
expect(deriveSourceId("clawhub:find-skills")).toBe("clawhub-find-skills");
|
|
291
|
+
expect(deriveSourceId("clawhub:find-skills@1.2.3")).toBe("clawhub-find-skills");
|
|
292
|
+
});
|
|
293
|
+
test("builds follow-up commands for search candidates", () => {
|
|
294
|
+
expect(buildFindCommand({
|
|
295
|
+
id: "builtin:1",
|
|
296
|
+
title: "find-skills",
|
|
297
|
+
description: "Find skills",
|
|
298
|
+
source: "builtin-git",
|
|
299
|
+
sourceLabel: "skills(@anthropics)",
|
|
300
|
+
sourceId: "anthropics-skills",
|
|
301
|
+
sourceKind: "git",
|
|
302
|
+
locator: "https://github.com/anthropics/skills.git",
|
|
303
|
+
relativePath: "skills/find-skills",
|
|
304
|
+
installed: false,
|
|
305
|
+
action: {
|
|
306
|
+
type: "add-git",
|
|
307
|
+
locator: "https://github.com/anthropics/skills.git",
|
|
308
|
+
requestedPath: "skills/find-skills",
|
|
309
|
+
},
|
|
310
|
+
})).toBe("skill-flow add https://github.com/anthropics/skills.git --path skills/find-skills");
|
|
311
|
+
expect(buildFindCommand({
|
|
312
|
+
id: "clawhub:1",
|
|
313
|
+
title: "Find Skills",
|
|
314
|
+
description: "Find skills",
|
|
315
|
+
source: "clawhub",
|
|
316
|
+
sourceLabel: "ClawHub",
|
|
317
|
+
sourceId: "clawhub-find-skills",
|
|
318
|
+
sourceKind: "clawhub",
|
|
319
|
+
locator: "clawhub:find-skills",
|
|
320
|
+
installed: false,
|
|
321
|
+
action: {
|
|
322
|
+
type: "add-clawhub",
|
|
323
|
+
slug: "find-skills",
|
|
324
|
+
version: "1.2.3",
|
|
325
|
+
},
|
|
326
|
+
})).toBe("skill-flow add clawhub:find-skills@1.2.3");
|
|
327
|
+
});
|
|
328
|
+
test("fails find when no local results exist and all remote search backends are unavailable", async () => {
|
|
329
|
+
vi.spyOn(builtinGitSources, "getBuiltinGitSources").mockReturnValue([]);
|
|
330
|
+
vi.spyOn(clawhubUtils, "searchClawHubSkills").mockRejectedValueOnce(new Error("offline"));
|
|
331
|
+
const app = new SkillFlowApp();
|
|
332
|
+
const result = await app.findSkills("browse");
|
|
333
|
+
expect(result.ok).toBe(false);
|
|
334
|
+
if (result.ok) {
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
expect(result.errors[0]?.code).toBe("FIND_UNAVAILABLE");
|
|
338
|
+
expect(result.warnings[0]?.code).toBe("CLAWHUB_SEARCH_FAILED");
|
|
339
|
+
});
|
|
340
|
+
test("formats GitHub groups as groupName(@owner)", () => {
|
|
341
|
+
expect(formatGroupLabel({
|
|
342
|
+
id: "garrytan-gstack",
|
|
343
|
+
locator: "git@github.com:garrytan/gstack.git",
|
|
344
|
+
displayName: "gstack",
|
|
345
|
+
})).toBe("gstack(@garrytan)");
|
|
346
|
+
});
|
|
347
|
+
test("prefers groupName-skillName for projected collisions", () => {
|
|
348
|
+
const projected = resolveProjectedSkillNames([
|
|
349
|
+
{
|
|
350
|
+
leafId: "a:browse",
|
|
351
|
+
groupId: "garrytan-gstack",
|
|
352
|
+
groupName: "gstack",
|
|
353
|
+
groupAuthor: "garrytan",
|
|
354
|
+
skillName: "browse",
|
|
355
|
+
},
|
|
356
|
+
{
|
|
357
|
+
leafId: "b:browse",
|
|
358
|
+
groupId: "alice-toolkit",
|
|
359
|
+
groupName: "toolkit",
|
|
360
|
+
groupAuthor: "alice",
|
|
361
|
+
skillName: "browse",
|
|
362
|
+
},
|
|
363
|
+
]);
|
|
364
|
+
expect(projected.get("a:browse")).toBe(buildProjectedSkillName("gstack", "browse", "garrytan"));
|
|
365
|
+
expect(projected.get("b:browse")).toBe(buildProjectedSkillName("toolkit", "browse", "alice"));
|
|
366
|
+
});
|
|
367
|
+
test("falls back to groupId-skillName when projected names still collide", () => {
|
|
368
|
+
const projected = resolveProjectedSkillNames([
|
|
369
|
+
{
|
|
370
|
+
leafId: "a:browse",
|
|
371
|
+
groupId: "alice-gstack",
|
|
372
|
+
groupName: "gstack",
|
|
373
|
+
groupAuthor: "alice",
|
|
374
|
+
skillName: "browse",
|
|
375
|
+
},
|
|
376
|
+
{
|
|
377
|
+
leafId: "b:browse",
|
|
378
|
+
groupId: "garrytan-gstack",
|
|
379
|
+
groupName: "gstack",
|
|
380
|
+
groupAuthor: "garrytan",
|
|
381
|
+
skillName: "browse",
|
|
382
|
+
},
|
|
383
|
+
]);
|
|
384
|
+
expect(projected.get("a:browse")).toBe("gstack(alice)-browse");
|
|
385
|
+
expect(projected.get("b:browse")).toBe("gstack(garrytan)-browse");
|
|
386
|
+
});
|
|
387
|
+
test("prefers author prefix when repo prefix would repeat the skill prefix", () => {
|
|
388
|
+
const projected = resolveProjectedSkillNames([
|
|
389
|
+
{
|
|
390
|
+
leafId: "a:skill-creator",
|
|
391
|
+
groupId: "anthropic-skill",
|
|
392
|
+
groupName: "skill",
|
|
393
|
+
groupAuthor: "anthropic",
|
|
394
|
+
skillName: "skill-creator",
|
|
395
|
+
},
|
|
396
|
+
{
|
|
397
|
+
leafId: "b:skill-creator",
|
|
398
|
+
groupId: "openai-skill",
|
|
399
|
+
groupName: "skill",
|
|
400
|
+
groupAuthor: "openai",
|
|
401
|
+
skillName: "skill-creator",
|
|
402
|
+
},
|
|
403
|
+
]);
|
|
404
|
+
expect(projected.get("a:skill-creator")).toBe("anthropic-skill-creator");
|
|
405
|
+
expect(projected.get("b:skill-creator")).toBe("openai-skill-creator");
|
|
406
|
+
});
|
|
407
|
+
test("rejects uninstall for an unknown skills group", async () => {
|
|
408
|
+
const app = new SkillFlowApp();
|
|
409
|
+
const result = await app.uninstall(["missing-source"]);
|
|
410
|
+
expect(result.ok).toBe(false);
|
|
411
|
+
if (result.ok) {
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
expect(result.errors[0]?.code).toBe("SOURCE_NOT_FOUND");
|
|
415
|
+
});
|
|
416
|
+
test("rejects a source with zero valid skills", async () => {
|
|
417
|
+
const repoPath = await createRepo(sandboxRoot, {
|
|
418
|
+
"broken/SKILL.md": "No heading here",
|
|
419
|
+
});
|
|
420
|
+
const app = new SkillFlowApp();
|
|
421
|
+
const result = await app.addSource(repoPath);
|
|
422
|
+
expect(result.ok).toBe(false);
|
|
423
|
+
if (result.ok) {
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
expect(result.errors[0]?.code).toBe("NO_VALID_LEAFS");
|
|
427
|
+
});
|
|
428
|
+
test("keeps valid skills and warns about invalid ones", async () => {
|
|
429
|
+
const repoPath = await createRepo(sandboxRoot, {
|
|
430
|
+
"good/SKILL.md": skillDoc("good", "Good description."),
|
|
431
|
+
"bad/SKILL.md": "Broken file",
|
|
432
|
+
});
|
|
433
|
+
const app = new SkillFlowApp();
|
|
434
|
+
const result = await app.addSource(repoPath);
|
|
435
|
+
expect(result.ok).toBe(true);
|
|
436
|
+
if (!result.ok) {
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
expect(result.data.leafCount).toBe(1);
|
|
440
|
+
expect(result.warnings).toHaveLength(1);
|
|
441
|
+
});
|
|
442
|
+
test("accepts skills that use YAML frontmatter metadata", async () => {
|
|
443
|
+
const repoPath = await createRepo(sandboxRoot, {
|
|
444
|
+
"browse/SKILL.md": `---
|
|
445
|
+
name: browse
|
|
446
|
+
version: 1.1.0
|
|
447
|
+
description: |
|
|
448
|
+
Fast headless browser for QA testing and site dogfooding.
|
|
449
|
+
Opens pages and validates flows.
|
|
450
|
+
---
|
|
451
|
+
<!-- generated -->
|
|
452
|
+
|
|
453
|
+
## Preamble
|
|
454
|
+
`,
|
|
455
|
+
});
|
|
456
|
+
const app = new SkillFlowApp();
|
|
457
|
+
const result = await app.addSource(repoPath);
|
|
458
|
+
expect(result.ok).toBe(true);
|
|
459
|
+
if (!result.ok) {
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
expect(result.data.leafCount).toBe(1);
|
|
463
|
+
const listResult = await app.listWorkflows();
|
|
464
|
+
expect(listResult.ok).toBe(true);
|
|
465
|
+
if (!listResult.ok) {
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
expect(listResult.data.summaries[0]?.leafs[0]?.title).toBe("browse");
|
|
469
|
+
expect(listResult.data.summaries[0]?.leafs[0]?.description).toContain("Fast headless browser");
|
|
470
|
+
expect(listResult.data.summaries[0]?.leafs[0]?.name).toBe("browse");
|
|
471
|
+
});
|
|
472
|
+
test("uses repository display name for a root-level skill link name", async () => {
|
|
473
|
+
const repoPath = await createRepo(sandboxRoot, {
|
|
474
|
+
"SKILL.md": skillDoc("gstack", "Root skill."),
|
|
475
|
+
});
|
|
476
|
+
const inventory = new InventoryService();
|
|
477
|
+
const scanned = await inventory.scanSource("garrytan-gstack", repoPath, "gstack");
|
|
478
|
+
expect(scanned.leafs[0]?.linkName).toBe("gstack");
|
|
479
|
+
});
|
|
480
|
+
test("renames preview target when foreign content already exists at target path", async () => {
|
|
481
|
+
const repoPath = await createRepo(sandboxRoot, {
|
|
482
|
+
"good/SKILL.md": skillDoc("good", "Good description."),
|
|
483
|
+
});
|
|
484
|
+
const app = new SkillFlowApp();
|
|
485
|
+
const added = await app.addSource(repoPath);
|
|
486
|
+
expect(added.ok).toBe(true);
|
|
487
|
+
if (!added.ok) {
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
const sourceId = added.data.manifest.id;
|
|
491
|
+
const leafId = `${sourceId}:good`;
|
|
492
|
+
await fs.mkdir(path.join(process.env.SKILL_FLOW_TARGET_CLAUDE_CODE, "good"), {
|
|
493
|
+
recursive: true,
|
|
494
|
+
});
|
|
495
|
+
const preview = await app.previewDraft(sourceId, {
|
|
496
|
+
enabledTargets: ["claude-code"],
|
|
497
|
+
selectedLeafIds: [leafId],
|
|
498
|
+
});
|
|
499
|
+
expect(preview.ok).toBe(true);
|
|
500
|
+
if (!preview.ok) {
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
expect(preview.data.plan.blocked).toHaveLength(0);
|
|
504
|
+
const action = preview.data.plan.actions.find((item) => item.leafId === leafId);
|
|
505
|
+
expect(action?.targetPath).not.toBe(path.join(process.env.SKILL_FLOW_TARGET_CLAUDE_CODE, "good"));
|
|
506
|
+
});
|
|
507
|
+
test("replaces identical external directory content at target path", async () => {
|
|
508
|
+
const repoPath = await createRepo(sandboxRoot, {
|
|
509
|
+
"browse/SKILL.md": skillDoc("browse", "Browser flow."),
|
|
510
|
+
});
|
|
511
|
+
const app = new SkillFlowApp();
|
|
512
|
+
const added = await app.addSource(repoPath);
|
|
513
|
+
expect(added.ok).toBe(true);
|
|
514
|
+
if (!added.ok) {
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
const sourceId = added.data.manifest.id;
|
|
518
|
+
const leafId = `${sourceId}:browse`;
|
|
519
|
+
const targetPath = path.join(process.env.SKILL_FLOW_TARGET_CLAUDE_CODE, "browse");
|
|
520
|
+
await writeRepoFiles(targetPath, {
|
|
521
|
+
"SKILL.md": skillDoc("browse", "Browser flow."),
|
|
522
|
+
"local.txt": "external",
|
|
523
|
+
});
|
|
524
|
+
const applied = await app.applyDraft(sourceId, {
|
|
525
|
+
enabledTargets: ["claude-code"],
|
|
526
|
+
selectedLeafIds: [leafId],
|
|
527
|
+
});
|
|
528
|
+
expect(applied.ok).toBe(true);
|
|
529
|
+
expect(await fs.lstat(targetPath)).toSatisfy((stats) => stats.isSymbolicLink());
|
|
530
|
+
expect(await pathExists(path.join(targetPath, "local.txt"))).toBe(false);
|
|
531
|
+
});
|
|
532
|
+
test("replaces identical external symlink content at target path", async () => {
|
|
533
|
+
const repoPath = await createRepo(sandboxRoot, {
|
|
534
|
+
"browse/SKILL.md": skillDoc("browse", "Browser flow."),
|
|
535
|
+
});
|
|
536
|
+
const externalRepo = await createRepo(sandboxRoot, {
|
|
537
|
+
"browse/SKILL.md": skillDoc("browse", "Browser flow."),
|
|
538
|
+
});
|
|
539
|
+
const app = new SkillFlowApp();
|
|
540
|
+
const added = await app.addSource(repoPath);
|
|
541
|
+
expect(added.ok).toBe(true);
|
|
542
|
+
if (!added.ok) {
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
const sourceId = added.data.manifest.id;
|
|
546
|
+
const leafId = `${sourceId}:browse`;
|
|
547
|
+
const targetPath = path.join(process.env.SKILL_FLOW_TARGET_CLAUDE_CODE, "browse");
|
|
548
|
+
await fs.symlink(path.join(externalRepo, "browse"), targetPath, "junction");
|
|
549
|
+
const applied = await app.applyDraft(sourceId, {
|
|
550
|
+
enabledTargets: ["claude-code"],
|
|
551
|
+
selectedLeafIds: [leafId],
|
|
552
|
+
});
|
|
553
|
+
expect(applied.ok).toBe(true);
|
|
554
|
+
expect(path.resolve(await fs.readlink(targetPath))).toBe(path.join(stateRoot, "source", "git", sourceId, "browse"));
|
|
555
|
+
});
|
|
556
|
+
test("keeps external different-content skill and renames our projection instead", async () => {
|
|
557
|
+
const repoPath = await createRepo(sandboxRoot, {
|
|
558
|
+
"browse/SKILL.md": skillDoc("browse", "Managed browser flow."),
|
|
559
|
+
});
|
|
560
|
+
const app = new SkillFlowApp();
|
|
561
|
+
const added = await app.addSource(repoPath);
|
|
562
|
+
expect(added.ok).toBe(true);
|
|
563
|
+
if (!added.ok) {
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
const sourceId = added.data.manifest.id;
|
|
567
|
+
const leafId = `${sourceId}:browse`;
|
|
568
|
+
const targetRoot = process.env.SKILL_FLOW_TARGET_CLAUDE_CODE;
|
|
569
|
+
await writeRepoFiles(path.join(targetRoot, "browse"), {
|
|
570
|
+
"SKILL.md": skillDoc("browse", "External browser flow."),
|
|
571
|
+
});
|
|
572
|
+
const applied = await app.applyDraft(sourceId, {
|
|
573
|
+
enabledTargets: ["claude-code"],
|
|
574
|
+
selectedLeafIds: [leafId],
|
|
575
|
+
});
|
|
576
|
+
expect(applied.ok).toBe(true);
|
|
577
|
+
expect(await fs.lstat(path.join(targetRoot, "browse"))).toSatisfy((stats) => !stats.isSymbolicLink());
|
|
578
|
+
const lock = await app.store.readLock();
|
|
579
|
+
const deployment = lock.deployments.find((item) => item.sourceId === sourceId && item.leafId === leafId && item.target === "claude-code");
|
|
580
|
+
expect(deployment).toBeTruthy();
|
|
581
|
+
expect(deployment?.targetPath).not.toBe(path.join(targetRoot, "browse"));
|
|
582
|
+
expect(await pathExists(deployment?.targetPath ?? "")).toBe(true);
|
|
583
|
+
});
|
|
584
|
+
test("relocates external skill when our fallback names are fully occupied", async () => {
|
|
585
|
+
const repoPath = await createRepo(sandboxRoot, {
|
|
586
|
+
"browse/SKILL.md": skillDoc("browse", "Managed browser flow."),
|
|
587
|
+
});
|
|
588
|
+
const app = new SkillFlowApp();
|
|
589
|
+
const added = await app.addSource(repoPath);
|
|
590
|
+
expect(added.ok).toBe(true);
|
|
591
|
+
if (!added.ok) {
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
const sourceId = added.data.manifest.id;
|
|
595
|
+
const source = added.data.manifest;
|
|
596
|
+
const leafId = `${sourceId}:browse`;
|
|
597
|
+
const targetRoot = process.env.SKILL_FLOW_TARGET_CLAUDE_CODE;
|
|
598
|
+
await writeRepoFiles(path.join(targetRoot, "browse"), {
|
|
599
|
+
"SKILL.md": skillDoc("browse", "External browser flow."),
|
|
600
|
+
"external.txt": "keep me",
|
|
601
|
+
});
|
|
602
|
+
await writeRepoFiles(path.join(targetRoot, `${source.displayName}-browse`), {
|
|
603
|
+
"SKILL.md": skillDoc("other", "Occupy first fallback."),
|
|
604
|
+
});
|
|
605
|
+
if (`${source.displayName}-browse` !== `${sourceId}-browse`) {
|
|
606
|
+
await writeRepoFiles(path.join(targetRoot, `${sourceId}-browse`), {
|
|
607
|
+
"SKILL.md": skillDoc("other-two", "Occupy second fallback."),
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
const applied = await app.applyDraft(sourceId, {
|
|
611
|
+
enabledTargets: ["claude-code"],
|
|
612
|
+
selectedLeafIds: [leafId],
|
|
613
|
+
});
|
|
614
|
+
expect(applied.ok).toBe(true);
|
|
615
|
+
expect(await fs.lstat(path.join(targetRoot, "browse"))).toSatisfy((stats) => stats.isSymbolicLink());
|
|
616
|
+
expect(await fs.readFile(path.join(targetRoot, "browse-external", "external.txt"), "utf8")).toBe("keep me");
|
|
617
|
+
});
|
|
618
|
+
test("doctor detects broken symlinks", async () => {
|
|
619
|
+
const repoPath = await createRepo(sandboxRoot, {
|
|
620
|
+
"good/SKILL.md": skillDoc("good", "Good description."),
|
|
621
|
+
});
|
|
622
|
+
const app = new SkillFlowApp();
|
|
623
|
+
const added = await app.addSource(repoPath);
|
|
624
|
+
expect(added.ok).toBe(true);
|
|
625
|
+
if (!added.ok) {
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
const sourceId = added.data.manifest.id;
|
|
629
|
+
const leafId = `${sourceId}:good`;
|
|
630
|
+
const applied = await app.applyDraft(sourceId, {
|
|
631
|
+
enabledTargets: ["claude-code"],
|
|
632
|
+
selectedLeafIds: [leafId],
|
|
633
|
+
});
|
|
634
|
+
expect(applied.ok).toBe(true);
|
|
635
|
+
await fs.rm(path.join(stateRoot, "source", "git", sourceId, "good"), {
|
|
636
|
+
recursive: true,
|
|
637
|
+
force: true,
|
|
638
|
+
});
|
|
639
|
+
const doctor = await app.doctor();
|
|
640
|
+
expect(doctor.ok).toBe(true);
|
|
641
|
+
if (!doctor.ok) {
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
expect(doctor.data.issues.some((issue) => issue.code === "BROKEN_SYMLINK")).toBe(true);
|
|
645
|
+
});
|
|
646
|
+
test("scans host directories too, but keeps the first discovered duplicate only", async () => {
|
|
647
|
+
const repoPath = await createRepo(sandboxRoot, {
|
|
648
|
+
"browse/SKILL.md": skillDoc("browse", "Browser flow."),
|
|
649
|
+
".agents/skills/gstack-browse/SKILL.md": skillDoc("browse", "Browser flow."),
|
|
650
|
+
});
|
|
651
|
+
const app = new SkillFlowApp();
|
|
652
|
+
const result = await app.addSource(repoPath);
|
|
653
|
+
expect(result.ok).toBe(true);
|
|
654
|
+
if (!result.ok) {
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
expect(result.data.leafCount).toBe(1);
|
|
658
|
+
expect(result.warnings.some((warning) => warning.message.includes("Duplicate skill content"))).toBe(true);
|
|
659
|
+
const list = await app.listWorkflows();
|
|
660
|
+
expect(list.ok).toBe(true);
|
|
661
|
+
if (!list.ok) {
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
expect(list.data.summaries[0]?.leafs.map((leaf) => leaf.relativePath)).toEqual([
|
|
665
|
+
"browse",
|
|
666
|
+
]);
|
|
667
|
+
});
|
|
668
|
+
test("discovers a unique skill from a host directory when no earlier duplicate exists", async () => {
|
|
669
|
+
const repoPath = await createRepo(sandboxRoot, {
|
|
670
|
+
".agents/skills/gstack-browse/SKILL.md": skillDoc("gstack-browse", "Host directory skill."),
|
|
671
|
+
});
|
|
672
|
+
const app = new SkillFlowApp();
|
|
673
|
+
const result = await app.addSource(repoPath);
|
|
674
|
+
expect(result.ok).toBe(true);
|
|
675
|
+
if (!result.ok) {
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
expect(result.data.leafCount).toBe(1);
|
|
679
|
+
const list = await app.listWorkflows();
|
|
680
|
+
expect(list.ok).toBe(true);
|
|
681
|
+
if (!list.ok) {
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
expect(list.data.summaries[0]?.leafs[0]?.relativePath).toBe(".agents/skills/gstack-browse");
|
|
685
|
+
});
|
|
686
|
+
test("prefers visible second-level skill directories before hidden second-level directories", async () => {
|
|
687
|
+
const repoPath = await createRepo(sandboxRoot, {
|
|
688
|
+
"catalog/browse/SKILL.md": skillDoc("browse", "Browser flow."),
|
|
689
|
+
"catalog/.generated/browse/SKILL.md": skillDoc("browse", "Browser flow."),
|
|
690
|
+
});
|
|
691
|
+
const app = new SkillFlowApp();
|
|
692
|
+
const result = await app.addSource(repoPath);
|
|
693
|
+
expect(result.ok).toBe(true);
|
|
694
|
+
if (!result.ok) {
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
expect(result.data.leafCount).toBe(1);
|
|
698
|
+
const list = await app.listWorkflows();
|
|
699
|
+
expect(list.ok).toBe(true);
|
|
700
|
+
if (!list.ok) {
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
expect(list.data.summaries[0]?.leafs[0]?.relativePath).toBe("catalog/browse");
|
|
704
|
+
expect(result.warnings.some((warning) => warning.message.includes("catalog/.generated/browse"))).toBe(true);
|
|
705
|
+
});
|
|
706
|
+
test("dedupes skills by metadata name and description", async () => {
|
|
707
|
+
const repoPath = await createRepo(sandboxRoot, {
|
|
708
|
+
"browse/SKILL.md": `---
|
|
709
|
+
name: browse
|
|
710
|
+
description: |
|
|
711
|
+
Canonical browse skill.
|
|
712
|
+
---
|
|
713
|
+
## Body
|
|
714
|
+
`,
|
|
715
|
+
"copy-of-browse/SKILL.md": `---
|
|
716
|
+
name: browse
|
|
717
|
+
description: |
|
|
718
|
+
Canonical browse skill.
|
|
719
|
+
---
|
|
720
|
+
## Body
|
|
721
|
+
`,
|
|
722
|
+
});
|
|
723
|
+
const app = new SkillFlowApp();
|
|
724
|
+
const result = await app.addSource(repoPath);
|
|
725
|
+
expect(result.ok).toBe(true);
|
|
726
|
+
if (!result.ok) {
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
expect(result.data.leafCount).toBe(1);
|
|
730
|
+
expect(result.warnings.some((warning) => warning.message.includes("Duplicate skill content"))).toBe(true);
|
|
731
|
+
});
|
|
732
|
+
test("keeps same-name skills when descriptions differ", async () => {
|
|
733
|
+
const repoPath = await createRepo(sandboxRoot, {
|
|
734
|
+
"browse/SKILL.md": skillDoc("browse", "Canonical browse skill."),
|
|
735
|
+
"copy-of-browse/SKILL.md": skillDoc("browse", "Different browse skill."),
|
|
736
|
+
});
|
|
737
|
+
const app = new SkillFlowApp();
|
|
738
|
+
const result = await app.addSource(repoPath);
|
|
739
|
+
expect(result.ok).toBe(true);
|
|
740
|
+
if (!result.ok) {
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
expect(result.data.leafCount).toBe(2);
|
|
744
|
+
});
|
|
745
|
+
test("apply uses natural skill names and removes legacy prefixed paths", async () => {
|
|
746
|
+
const repoPath = await createRepo(sandboxRoot, {
|
|
747
|
+
"browse/SKILL.md": skillDoc("browse", "Browser flow."),
|
|
748
|
+
});
|
|
749
|
+
const app = new SkillFlowApp();
|
|
750
|
+
const added = await app.addSource(repoPath);
|
|
751
|
+
expect(added.ok).toBe(true);
|
|
752
|
+
if (!added.ok) {
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
const sourceId = added.data.manifest.id;
|
|
756
|
+
const leafId = `${sourceId}:browse`;
|
|
757
|
+
const legacyPath = path.join(process.env.SKILL_FLOW_TARGET_CLAUDE_CODE, `${sourceId}--browse`);
|
|
758
|
+
await fs.symlink(path.join(stateRoot, "source", "git", sourceId, "browse"), legacyPath, "junction");
|
|
759
|
+
const lockPath = path.join(stateRoot, "lock.json");
|
|
760
|
+
const lockFile = JSON.parse(await fs.readFile(lockPath, "utf8"));
|
|
761
|
+
lockFile.deployments.push({
|
|
762
|
+
sourceId,
|
|
763
|
+
leafId,
|
|
764
|
+
target: "claude-code",
|
|
765
|
+
targetPath: legacyPath,
|
|
766
|
+
strategy: "symlink",
|
|
767
|
+
status: "active",
|
|
768
|
+
contentHash: "legacy",
|
|
769
|
+
appliedAt: new Date().toISOString(),
|
|
770
|
+
});
|
|
771
|
+
await fs.writeFile(lockPath, `${JSON.stringify(lockFile, null, 2)}\n`, "utf8");
|
|
772
|
+
const applied = await app.applyDraft(sourceId, {
|
|
773
|
+
enabledTargets: ["claude-code"],
|
|
774
|
+
selectedLeafIds: [leafId],
|
|
775
|
+
});
|
|
776
|
+
expect(applied.ok).toBe(true);
|
|
777
|
+
expect(await pathExists(legacyPath)).toBe(false);
|
|
778
|
+
expect(await pathExists(path.join(process.env.SKILL_FLOW_TARGET_CLAUDE_CODE, "browse"))).toBe(true);
|
|
779
|
+
});
|
|
780
|
+
test("keeps the earlier selected cross-group duplicate when linkName name and description all match", async () => {
|
|
781
|
+
const repoA = await createRepo(sandboxRoot, {
|
|
782
|
+
"browse/SKILL.md": skillDoc("browse", "Browser flow."),
|
|
783
|
+
});
|
|
784
|
+
const repoB = await createRepo(sandboxRoot, {
|
|
785
|
+
"browse/SKILL.md": skillDoc("browse", "Browser flow."),
|
|
786
|
+
});
|
|
787
|
+
const app = new SkillFlowApp();
|
|
788
|
+
const addedA = await app.addSource(repoA);
|
|
789
|
+
const addedB = await app.addSource(repoB);
|
|
790
|
+
expect(addedA.ok).toBe(true);
|
|
791
|
+
expect(addedB.ok).toBe(true);
|
|
792
|
+
if (!addedA.ok || !addedB.ok) {
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
const sourceA = addedA.data.manifest.id;
|
|
796
|
+
const sourceB = addedB.data.manifest.id;
|
|
797
|
+
const leafA = `${sourceA}:browse`;
|
|
798
|
+
const leafB = `${sourceB}:browse`;
|
|
799
|
+
const manifest = await app.store.readManifest();
|
|
800
|
+
manifest.bindings[sourceA] = { targets: {} };
|
|
801
|
+
manifest.bindings[sourceB] = { targets: {} };
|
|
802
|
+
await app.store.writeManifest(manifest);
|
|
803
|
+
const firstApply = await app.applyDraft(sourceA, {
|
|
804
|
+
enabledTargets: ["claude-code"],
|
|
805
|
+
selectedLeafIds: [leafA],
|
|
806
|
+
});
|
|
807
|
+
expect(firstApply.ok).toBe(true);
|
|
808
|
+
const secondApply = await app.applyDraft(sourceB, {
|
|
809
|
+
enabledTargets: ["claude-code"],
|
|
810
|
+
selectedLeafIds: [leafB],
|
|
811
|
+
});
|
|
812
|
+
expect(secondApply.ok).toBe(true);
|
|
813
|
+
if (!secondApply.ok) {
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
expect(secondApply.data.draft.selectedLeafIds).toEqual([]);
|
|
817
|
+
expect(await pathExists(path.join(process.env.SKILL_FLOW_TARGET_CLAUDE_CODE, "browse"))).toBe(true);
|
|
818
|
+
const lockPath = path.join(stateRoot, "lock.json");
|
|
819
|
+
const lock = JSON.parse(await fs.readFile(lockPath, "utf8"));
|
|
820
|
+
expect(lock.deployments.filter((deployment) => deployment.targetPath.endsWith(path.join("claude", "browse")))).toHaveLength(1);
|
|
821
|
+
expect(lock.deployments[0]?.sourceId).toBe(sourceA);
|
|
822
|
+
});
|
|
823
|
+
test("renames cross-group projections when linkName matches but content differs", async () => {
|
|
824
|
+
const repoA = await createRepo(sandboxRoot, {
|
|
825
|
+
"browse/SKILL.md": skillDoc("browse", "Browser flow from A."),
|
|
826
|
+
});
|
|
827
|
+
const repoB = await createRepo(sandboxRoot, {
|
|
828
|
+
"browse/SKILL.md": skillDoc("browse", "Browser flow from B."),
|
|
829
|
+
});
|
|
830
|
+
const app = new SkillFlowApp();
|
|
831
|
+
const addedA = await app.addSource(repoA);
|
|
832
|
+
const addedB = await app.addSource(repoB);
|
|
833
|
+
expect(addedA.ok).toBe(true);
|
|
834
|
+
expect(addedB.ok).toBe(true);
|
|
835
|
+
if (!addedA.ok || !addedB.ok) {
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
const sourceA = addedA.data.manifest.id;
|
|
839
|
+
const sourceB = addedB.data.manifest.id;
|
|
840
|
+
const leafA = `${sourceA}:browse`;
|
|
841
|
+
const leafB = `${sourceB}:browse`;
|
|
842
|
+
const manifest = await app.store.readManifest();
|
|
843
|
+
manifest.bindings[sourceA] = { targets: {} };
|
|
844
|
+
manifest.bindings[sourceB] = { targets: {} };
|
|
845
|
+
await app.store.writeManifest(manifest);
|
|
846
|
+
const firstApply = await app.applyDraft(sourceA, {
|
|
847
|
+
enabledTargets: ["claude-code"],
|
|
848
|
+
selectedLeafIds: [leafA],
|
|
849
|
+
});
|
|
850
|
+
expect(firstApply.ok).toBe(true);
|
|
851
|
+
expect(await pathExists(path.join(process.env.SKILL_FLOW_TARGET_CLAUDE_CODE, "browse"))).toBe(true);
|
|
852
|
+
const secondApply = await app.applyDraft(sourceB, {
|
|
853
|
+
enabledTargets: ["claude-code"],
|
|
854
|
+
selectedLeafIds: [leafB],
|
|
855
|
+
});
|
|
856
|
+
expect(secondApply.ok).toBe(true);
|
|
857
|
+
expect(await pathExists(path.join(process.env.SKILL_FLOW_TARGET_CLAUDE_CODE, "browse"))).toBe(false);
|
|
858
|
+
expect(await pathExists(path.join(process.env.SKILL_FLOW_TARGET_CLAUDE_CODE, `${sourceA}-browse`))).toBe(true);
|
|
859
|
+
expect(await pathExists(path.join(process.env.SKILL_FLOW_TARGET_CLAUDE_CODE, `${sourceB}-browse`))).toBe(true);
|
|
860
|
+
});
|
|
861
|
+
test("doctor reports unavailable target paths", async () => {
|
|
862
|
+
const repoPath = await createRepo(sandboxRoot, {
|
|
863
|
+
"good/SKILL.md": skillDoc("good", "Good description."),
|
|
864
|
+
});
|
|
865
|
+
const app = new SkillFlowApp();
|
|
866
|
+
const added = await app.addSource(repoPath);
|
|
867
|
+
expect(added.ok).toBe(true);
|
|
868
|
+
if (!added.ok) {
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
await fs.rm(process.env.SKILL_FLOW_TARGET_CLAUDE_CODE, {
|
|
872
|
+
recursive: true,
|
|
873
|
+
force: true,
|
|
874
|
+
});
|
|
875
|
+
const doctor = await app.previewDraft(added.data.manifest.id, {
|
|
876
|
+
enabledTargets: ["claude-code"],
|
|
877
|
+
selectedLeafIds: [`${added.data.manifest.id}:good`],
|
|
878
|
+
});
|
|
879
|
+
expect(doctor.ok).toBe(true);
|
|
880
|
+
if (!doctor.ok) {
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
expect(doctor.data.plan.blocked[0]?.reason).toContain("Target directory not found");
|
|
884
|
+
});
|
|
885
|
+
test("update detects added skills", async () => {
|
|
886
|
+
const repoPath = await createRepo(sandboxRoot, {
|
|
887
|
+
"good/SKILL.md": skillDoc("good", "Good description."),
|
|
888
|
+
});
|
|
889
|
+
const app = new SkillFlowApp();
|
|
890
|
+
const added = await app.addSource(repoPath);
|
|
891
|
+
expect(added.ok).toBe(true);
|
|
892
|
+
await writeRepoFiles(repoPath, {
|
|
893
|
+
"extra/SKILL.md": skillDoc("extra", "Extra description."),
|
|
894
|
+
});
|
|
895
|
+
git(repoPath, ["add", "."]);
|
|
896
|
+
git(repoPath, ["commit", "-m", "add extra"]);
|
|
897
|
+
const updated = await app.updateSources([added.ok ? added.data.manifest.id : ""]);
|
|
898
|
+
expect(updated.ok).toBe(true);
|
|
899
|
+
if (!updated.ok) {
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
expect(updated.data.updated[0]?.addedLeafIds).toHaveLength(1);
|
|
903
|
+
});
|
|
904
|
+
test("update removes projections for deleted skills", async () => {
|
|
905
|
+
const repoPath = await createRepo(sandboxRoot, {
|
|
906
|
+
"good/SKILL.md": skillDoc("good", "Good description."),
|
|
907
|
+
});
|
|
908
|
+
const app = new SkillFlowApp();
|
|
909
|
+
const added = await app.addSource(repoPath);
|
|
910
|
+
expect(added.ok).toBe(true);
|
|
911
|
+
if (!added.ok) {
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
const sourceId = added.data.manifest.id;
|
|
915
|
+
const leafId = `${sourceId}:good`;
|
|
916
|
+
await app.applyDraft(sourceId, {
|
|
917
|
+
enabledTargets: ["claude-code"],
|
|
918
|
+
selectedLeafIds: [leafId],
|
|
919
|
+
});
|
|
920
|
+
await fs.rm(path.join(repoPath, "good"), { recursive: true, force: true });
|
|
921
|
+
git(repoPath, ["add", "."]);
|
|
922
|
+
git(repoPath, ["commit", "-m", "remove good"]);
|
|
923
|
+
const updated = await app.updateSources([sourceId]);
|
|
924
|
+
expect(updated.ok).toBe(true);
|
|
925
|
+
if (!updated.ok) {
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
expect(updated.data.updated[0]?.removedLeafIds).toEqual([leafId]);
|
|
929
|
+
expect(await pathExists(path.join(process.env.SKILL_FLOW_TARGET_CLAUDE_CODE, "good"))).toBe(false);
|
|
930
|
+
});
|
|
931
|
+
test("update surfaces invalidated skills", async () => {
|
|
932
|
+
const repoPath = await createRepo(sandboxRoot, {
|
|
933
|
+
"good/SKILL.md": skillDoc("good", "Good description."),
|
|
934
|
+
});
|
|
935
|
+
const app = new SkillFlowApp();
|
|
936
|
+
const added = await app.addSource(repoPath);
|
|
937
|
+
expect(added.ok).toBe(true);
|
|
938
|
+
if (!added.ok) {
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
await writeRepoFiles(repoPath, {
|
|
942
|
+
"good/SKILL.md": "Broken now",
|
|
943
|
+
});
|
|
944
|
+
git(repoPath, ["add", "."]);
|
|
945
|
+
git(repoPath, ["commit", "-m", "invalidate"]);
|
|
946
|
+
const updated = await app.updateSources([added.data.manifest.id]);
|
|
947
|
+
expect(updated.ok).toBe(true);
|
|
948
|
+
if (!updated.ok) {
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
expect(updated.data.updated[0]?.invalidatedLeafIds).toHaveLength(1);
|
|
952
|
+
});
|
|
953
|
+
test("selection state machine handles parent child partial transitions", () => {
|
|
954
|
+
let state = {
|
|
955
|
+
allLeafIds: ["a", "b"],
|
|
956
|
+
selectedLeafIds: [],
|
|
957
|
+
};
|
|
958
|
+
expect(getParentSelectionState(state)).toBe("empty");
|
|
959
|
+
state = toggleChild(state, "a");
|
|
960
|
+
expect(getParentSelectionState(state)).toBe("partial");
|
|
961
|
+
state = toggleParent(state);
|
|
962
|
+
expect(getParentSelectionState(state)).toBe("full");
|
|
963
|
+
state = toggleChild(state, "b");
|
|
964
|
+
expect(getParentSelectionState(state)).toBe("partial");
|
|
965
|
+
});
|
|
966
|
+
test("doctor detects drift in copied projections", async () => {
|
|
967
|
+
const repoPath = await createRepo(sandboxRoot, {
|
|
968
|
+
"good/SKILL.md": skillDoc("good", "Good description."),
|
|
969
|
+
});
|
|
970
|
+
const app = new SkillFlowApp();
|
|
971
|
+
const added = await app.addSource(repoPath);
|
|
972
|
+
expect(added.ok).toBe(true);
|
|
973
|
+
if (!added.ok) {
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
const sourceId = added.data.manifest.id;
|
|
977
|
+
const leafId = `${sourceId}:good`;
|
|
978
|
+
await app.applyDraft(sourceId, {
|
|
979
|
+
enabledTargets: ["openclaw"],
|
|
980
|
+
selectedLeafIds: [leafId],
|
|
981
|
+
});
|
|
982
|
+
await writeRepoFiles(process.env.SKILL_FLOW_TARGET_OPENCLAW, {
|
|
983
|
+
["good/SKILL.md"]: "# Good\nMutated copy.",
|
|
984
|
+
});
|
|
985
|
+
const doctor = await app.doctor();
|
|
986
|
+
expect(doctor.ok).toBe(true);
|
|
987
|
+
if (!doctor.ok) {
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
expect(doctor.data.issues.some((issue) => issue.code === "DRIFT_COPY")).toBe(true);
|
|
991
|
+
});
|
|
992
|
+
test("keeps metadata warnings on valid skills", async () => {
|
|
993
|
+
const repoPath = await createRepo(sandboxRoot, {
|
|
994
|
+
"folder-name/SKILL.md": skillDoc("bad--name", "x".repeat(1025)),
|
|
995
|
+
});
|
|
996
|
+
const app = new SkillFlowApp();
|
|
997
|
+
const result = await app.addSource(repoPath);
|
|
998
|
+
expect(result.ok).toBe(true);
|
|
999
|
+
if (!result.ok) {
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
const list = await app.listWorkflows();
|
|
1003
|
+
expect(list.ok).toBe(true);
|
|
1004
|
+
if (!list.ok) {
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
expect(list.data.summaries[0]?.leafs[0]?.metadataWarnings.length).toBeGreaterThan(0);
|
|
1008
|
+
});
|
|
1009
|
+
test("reads old lock entries without metadata fields", async () => {
|
|
1010
|
+
const repoPath = await createRepo(sandboxRoot, {
|
|
1011
|
+
"browse/SKILL.md": skillDoc("browse", "Browser flow."),
|
|
1012
|
+
});
|
|
1013
|
+
const app = new SkillFlowApp();
|
|
1014
|
+
const added = await app.addSource(repoPath);
|
|
1015
|
+
expect(added.ok).toBe(true);
|
|
1016
|
+
if (!added.ok) {
|
|
1017
|
+
return;
|
|
1018
|
+
}
|
|
1019
|
+
const lockPath = path.join(stateRoot, "lock.json");
|
|
1020
|
+
const lock = JSON.parse(await fs.readFile(lockPath, "utf8"));
|
|
1021
|
+
lock.leafInventory = lock.leafInventory.map((leaf) => {
|
|
1022
|
+
const next = { ...leaf };
|
|
1023
|
+
delete next.metadataWarnings;
|
|
1024
|
+
delete next.linkName;
|
|
1025
|
+
return next;
|
|
1026
|
+
});
|
|
1027
|
+
await fs.writeFile(lockPath, `${JSON.stringify(lock, null, 2)}\n`, "utf8");
|
|
1028
|
+
const list = await app.listWorkflows();
|
|
1029
|
+
expect(list.ok).toBe(true);
|
|
1030
|
+
if (!list.ok) {
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
expect(list.data.summaries[0]?.leafs[0]?.metadataWarnings).toEqual([]);
|
|
1034
|
+
expect(list.data.summaries[0]?.leafs[0]?.linkName).toBe("browse");
|
|
1035
|
+
});
|
|
1036
|
+
test("previewDraft is read-only and does not reconcile inventory on its own", async () => {
|
|
1037
|
+
const repoPath = await createRepo(sandboxRoot, {
|
|
1038
|
+
"browse/SKILL.md": skillDoc("browse", "Browser flow."),
|
|
1039
|
+
});
|
|
1040
|
+
const app = new SkillFlowApp();
|
|
1041
|
+
const added = await app.addSource(repoPath);
|
|
1042
|
+
expect(added.ok).toBe(true);
|
|
1043
|
+
if (!added.ok) {
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
1046
|
+
const sourceId = added.data.manifest.id;
|
|
1047
|
+
const lockPath = path.join(stateRoot, "lock.json");
|
|
1048
|
+
const lock = JSON.parse(await fs.readFile(lockPath, "utf8"));
|
|
1049
|
+
const existingLeaf = lock.leafInventory[0];
|
|
1050
|
+
const generatedLeafId = `${sourceId}:.agents/skills/generated`;
|
|
1051
|
+
lock.sources[0].leafIds.push(generatedLeafId);
|
|
1052
|
+
lock.leafInventory.push({
|
|
1053
|
+
...existingLeaf,
|
|
1054
|
+
id: generatedLeafId,
|
|
1055
|
+
relativePath: ".agents/skills/generated",
|
|
1056
|
+
absolutePath: path.join(stateRoot, "source", "git", sourceId, ".agents/skills/generated"),
|
|
1057
|
+
skillFilePath: path.join(stateRoot, "source", "git", sourceId, ".agents/skills/generated/SKILL.md"),
|
|
1058
|
+
linkName: "generated",
|
|
1059
|
+
name: "generated",
|
|
1060
|
+
title: "generated",
|
|
1061
|
+
});
|
|
1062
|
+
const mutatedLock = `${JSON.stringify(lock, null, 2)}\n`;
|
|
1063
|
+
await fs.writeFile(lockPath, mutatedLock, "utf8");
|
|
1064
|
+
const preview = await app.previewDraft(sourceId, {
|
|
1065
|
+
enabledTargets: ["claude-code"],
|
|
1066
|
+
selectedLeafIds: [`${sourceId}:browse`],
|
|
1067
|
+
});
|
|
1068
|
+
expect(preview.ok).toBe(true);
|
|
1069
|
+
expect(await fs.readFile(lockPath, "utf8")).toBe(mutatedLock);
|
|
1070
|
+
});
|
|
1071
|
+
test("config helpers derive save, command, and context states", () => {
|
|
1072
|
+
expect(draftsEqual({
|
|
1073
|
+
enabledTargets: ["codex", "claude-code"],
|
|
1074
|
+
selectedLeafIds: ["b", "a"],
|
|
1075
|
+
}, {
|
|
1076
|
+
enabledTargets: ["claude-code", "codex"],
|
|
1077
|
+
selectedLeafIds: ["a", "b"],
|
|
1078
|
+
})).toBe(true);
|
|
1079
|
+
expect(getSaveDisplayPhase("idle", true)).toBe("dirty");
|
|
1080
|
+
expect(buildSaveLabel("dirty", 3)).toContain("DIRTY");
|
|
1081
|
+
expect(getPaneViewportCount(16, 1)).toBe(10);
|
|
1082
|
+
expect(getPaneWidths(100).reduce((sum, width) => sum + width, 0)).toBeLessThanOrEqual(98);
|
|
1083
|
+
expect(buildCommandBar({
|
|
1084
|
+
changeCount: 3,
|
|
1085
|
+
focus: "groups",
|
|
1086
|
+
saveFocused: false,
|
|
1087
|
+
savePhase: "dirty",
|
|
1088
|
+
})).toContain("inspect skills");
|
|
1089
|
+
expect(buildContextBar({
|
|
1090
|
+
blockedCount: 0,
|
|
1091
|
+
changeCount: 3,
|
|
1092
|
+
previewError: undefined,
|
|
1093
|
+
previewLoading: false,
|
|
1094
|
+
savePhase: "clean",
|
|
1095
|
+
saveMessage: undefined,
|
|
1096
|
+
selectedLeafName: "gstack",
|
|
1097
|
+
selectedLeafWarnings: ["description should be at most 1024 characters"],
|
|
1098
|
+
skippedLeafs: 21,
|
|
1099
|
+
sourceLabel: "gstack(@garrytan)",
|
|
1100
|
+
})).toContain("gstack(@garrytan)");
|
|
1101
|
+
});
|
|
1102
|
+
test("getConfigData normalizes per-target bindings to the config draft model", async () => {
|
|
1103
|
+
const repoPath = await createRepo(sandboxRoot, {
|
|
1104
|
+
"browse/SKILL.md": skillDoc("browse", "Browser flow."),
|
|
1105
|
+
"review/SKILL.md": skillDoc("review", "Review flow."),
|
|
1106
|
+
});
|
|
1107
|
+
const app = new SkillFlowApp();
|
|
1108
|
+
const added = await app.addSource(repoPath);
|
|
1109
|
+
expect(added.ok).toBe(true);
|
|
1110
|
+
if (!added.ok) {
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
const sourceId = added.data.manifest.id;
|
|
1114
|
+
const manifest = await app.store.readManifest();
|
|
1115
|
+
manifest.bindings[sourceId] = {
|
|
1116
|
+
targets: {
|
|
1117
|
+
"claude-code": {
|
|
1118
|
+
enabled: true,
|
|
1119
|
+
leafIds: [`${sourceId}:browse`],
|
|
1120
|
+
},
|
|
1121
|
+
codex: {
|
|
1122
|
+
enabled: true,
|
|
1123
|
+
leafIds: [`${sourceId}:review`],
|
|
1124
|
+
},
|
|
1125
|
+
},
|
|
1126
|
+
};
|
|
1127
|
+
await app.store.writeManifest(manifest);
|
|
1128
|
+
const result = await app.getConfigData();
|
|
1129
|
+
expect(result.ok).toBe(true);
|
|
1130
|
+
if (!result.ok) {
|
|
1131
|
+
return;
|
|
1132
|
+
}
|
|
1133
|
+
const normalizedManifest = await app.store.readManifest();
|
|
1134
|
+
expect(normalizedManifest.bindings[sourceId]).toEqual({
|
|
1135
|
+
targets: {
|
|
1136
|
+
"claude-code": {
|
|
1137
|
+
enabled: true,
|
|
1138
|
+
leafIds: [`${sourceId}:browse`, `${sourceId}:review`],
|
|
1139
|
+
},
|
|
1140
|
+
codex: {
|
|
1141
|
+
enabled: true,
|
|
1142
|
+
leafIds: [`${sourceId}:browse`, `${sourceId}:review`],
|
|
1143
|
+
},
|
|
1144
|
+
},
|
|
1145
|
+
});
|
|
1146
|
+
expect(result.data.summaries.find((summary) => summary.source.id === sourceId)?.bindings).toEqual(normalizedManifest.bindings[sourceId]);
|
|
1147
|
+
});
|
|
1148
|
+
test("projection warning helper marks identical cross-group skills as skipped", () => {
|
|
1149
|
+
const warnings = buildProjectionWarningMap({
|
|
1150
|
+
drafts: {
|
|
1151
|
+
alpha: { enabledTargets: ["claude-code"], selectedLeafIds: ["alpha:browse"] },
|
|
1152
|
+
beta: { enabledTargets: ["claude-code"], selectedLeafIds: ["beta:browse"] },
|
|
1153
|
+
},
|
|
1154
|
+
summaries: [
|
|
1155
|
+
{
|
|
1156
|
+
source: {
|
|
1157
|
+
id: "alpha",
|
|
1158
|
+
locator: "alpha",
|
|
1159
|
+
kind: "git",
|
|
1160
|
+
displayName: "alpha",
|
|
1161
|
+
addedAt: "",
|
|
1162
|
+
},
|
|
1163
|
+
lock: undefined,
|
|
1164
|
+
bindings: { targets: {} },
|
|
1165
|
+
activeTargetCount: 0,
|
|
1166
|
+
health: "ACTIVE",
|
|
1167
|
+
leafs: [
|
|
1168
|
+
{
|
|
1169
|
+
id: "alpha:browse",
|
|
1170
|
+
sourceId: "alpha",
|
|
1171
|
+
name: "browse",
|
|
1172
|
+
linkName: "browse",
|
|
1173
|
+
title: "browse",
|
|
1174
|
+
description: "Browser flow.",
|
|
1175
|
+
relativePath: "browse",
|
|
1176
|
+
absolutePath: "/tmp/alpha/browse",
|
|
1177
|
+
skillFilePath: "/tmp/alpha/browse/SKILL.md",
|
|
1178
|
+
contentHash: "a",
|
|
1179
|
+
metadataWarnings: [],
|
|
1180
|
+
valid: true,
|
|
1181
|
+
},
|
|
1182
|
+
],
|
|
1183
|
+
},
|
|
1184
|
+
{
|
|
1185
|
+
source: {
|
|
1186
|
+
id: "beta",
|
|
1187
|
+
locator: "beta",
|
|
1188
|
+
kind: "git",
|
|
1189
|
+
displayName: "beta",
|
|
1190
|
+
addedAt: "",
|
|
1191
|
+
},
|
|
1192
|
+
lock: undefined,
|
|
1193
|
+
bindings: { targets: {} },
|
|
1194
|
+
activeTargetCount: 0,
|
|
1195
|
+
health: "ACTIVE",
|
|
1196
|
+
leafs: [
|
|
1197
|
+
{
|
|
1198
|
+
id: "beta:browse",
|
|
1199
|
+
sourceId: "beta",
|
|
1200
|
+
name: "browse",
|
|
1201
|
+
linkName: "browse",
|
|
1202
|
+
title: "browse",
|
|
1203
|
+
description: "Browser flow.",
|
|
1204
|
+
relativePath: "browse",
|
|
1205
|
+
absolutePath: "/tmp/beta/browse",
|
|
1206
|
+
skillFilePath: "/tmp/beta/browse/SKILL.md",
|
|
1207
|
+
contentHash: "b",
|
|
1208
|
+
metadataWarnings: [],
|
|
1209
|
+
valid: true,
|
|
1210
|
+
},
|
|
1211
|
+
],
|
|
1212
|
+
},
|
|
1213
|
+
],
|
|
1214
|
+
sourceId: "beta",
|
|
1215
|
+
});
|
|
1216
|
+
expect(warnings["beta:browse"]?.[0]).toContain("will be skipped");
|
|
1217
|
+
});
|
|
1218
|
+
test("projection warning helper marks cross-group name collisions as renamed", () => {
|
|
1219
|
+
const warnings = buildProjectionWarningMap({
|
|
1220
|
+
drafts: {
|
|
1221
|
+
alpha: { enabledTargets: ["claude-code"], selectedLeafIds: ["alpha:browse"] },
|
|
1222
|
+
beta: { enabledTargets: ["claude-code"], selectedLeafIds: ["beta:browse"] },
|
|
1223
|
+
},
|
|
1224
|
+
summaries: [
|
|
1225
|
+
{
|
|
1226
|
+
source: {
|
|
1227
|
+
id: "alpha",
|
|
1228
|
+
locator: "alpha",
|
|
1229
|
+
kind: "git",
|
|
1230
|
+
displayName: "alpha",
|
|
1231
|
+
addedAt: "",
|
|
1232
|
+
},
|
|
1233
|
+
lock: undefined,
|
|
1234
|
+
bindings: { targets: {} },
|
|
1235
|
+
activeTargetCount: 0,
|
|
1236
|
+
health: "ACTIVE",
|
|
1237
|
+
leafs: [
|
|
1238
|
+
{
|
|
1239
|
+
id: "alpha:browse",
|
|
1240
|
+
sourceId: "alpha",
|
|
1241
|
+
name: "browse",
|
|
1242
|
+
linkName: "browse",
|
|
1243
|
+
title: "browse",
|
|
1244
|
+
description: "Browser flow A.",
|
|
1245
|
+
relativePath: "browse",
|
|
1246
|
+
absolutePath: "/tmp/alpha/browse",
|
|
1247
|
+
skillFilePath: "/tmp/alpha/browse/SKILL.md",
|
|
1248
|
+
contentHash: "a",
|
|
1249
|
+
metadataWarnings: [],
|
|
1250
|
+
valid: true,
|
|
1251
|
+
},
|
|
1252
|
+
],
|
|
1253
|
+
},
|
|
1254
|
+
{
|
|
1255
|
+
source: {
|
|
1256
|
+
id: "beta",
|
|
1257
|
+
locator: "beta",
|
|
1258
|
+
kind: "git",
|
|
1259
|
+
displayName: "beta",
|
|
1260
|
+
addedAt: "",
|
|
1261
|
+
},
|
|
1262
|
+
lock: undefined,
|
|
1263
|
+
bindings: { targets: {} },
|
|
1264
|
+
activeTargetCount: 0,
|
|
1265
|
+
health: "ACTIVE",
|
|
1266
|
+
leafs: [
|
|
1267
|
+
{
|
|
1268
|
+
id: "beta:browse",
|
|
1269
|
+
sourceId: "beta",
|
|
1270
|
+
name: "browse",
|
|
1271
|
+
linkName: "browse",
|
|
1272
|
+
title: "browse",
|
|
1273
|
+
description: "Browser flow B.",
|
|
1274
|
+
relativePath: "browse",
|
|
1275
|
+
absolutePath: "/tmp/beta/browse",
|
|
1276
|
+
skillFilePath: "/tmp/beta/browse/SKILL.md",
|
|
1277
|
+
contentHash: "b",
|
|
1278
|
+
metadataWarnings: [],
|
|
1279
|
+
valid: true,
|
|
1280
|
+
},
|
|
1281
|
+
],
|
|
1282
|
+
},
|
|
1283
|
+
],
|
|
1284
|
+
sourceId: "beta",
|
|
1285
|
+
});
|
|
1286
|
+
expect(warnings["beta:browse"]?.[0]).toContain("will deploy as beta-browse");
|
|
1287
|
+
});
|
|
1288
|
+
test("supports cursor and pi target projections", async () => {
|
|
1289
|
+
const repoPath = await createRepo(sandboxRoot, {
|
|
1290
|
+
"browse/SKILL.md": skillDoc("browse", "Browser flow."),
|
|
1291
|
+
});
|
|
1292
|
+
const app = new SkillFlowApp();
|
|
1293
|
+
const added = await app.addSource(repoPath);
|
|
1294
|
+
expect(added.ok).toBe(true);
|
|
1295
|
+
if (!added.ok) {
|
|
1296
|
+
return;
|
|
1297
|
+
}
|
|
1298
|
+
const sourceId = added.data.manifest.id;
|
|
1299
|
+
const leafId = `${sourceId}:browse`;
|
|
1300
|
+
const applied = await app.applyDraft(sourceId, {
|
|
1301
|
+
enabledTargets: ["cursor", "pi"],
|
|
1302
|
+
selectedLeafIds: [leafId],
|
|
1303
|
+
});
|
|
1304
|
+
expect(applied.ok).toBe(true);
|
|
1305
|
+
expect(await pathExists(path.join(process.env.SKILL_FLOW_TARGET_CURSOR, "browse"))).toBe(true);
|
|
1306
|
+
expect(await pathExists(path.join(process.env.SKILL_FLOW_TARGET_PI, "browse"))).toBe(true);
|
|
1307
|
+
});
|
|
1308
|
+
test("supports additional global agent target projections", async () => {
|
|
1309
|
+
const repoPath = await createRepo(sandboxRoot, {
|
|
1310
|
+
"browse/SKILL.md": skillDoc("browse", "Browser flow."),
|
|
1311
|
+
});
|
|
1312
|
+
const app = new SkillFlowApp();
|
|
1313
|
+
const added = await app.addSource(repoPath);
|
|
1314
|
+
expect(added.ok).toBe(true);
|
|
1315
|
+
if (!added.ok) {
|
|
1316
|
+
return;
|
|
1317
|
+
}
|
|
1318
|
+
const sourceId = added.data.manifest.id;
|
|
1319
|
+
const leafId = `${sourceId}:browse`;
|
|
1320
|
+
const applied = await app.applyDraft(sourceId, {
|
|
1321
|
+
enabledTargets: [
|
|
1322
|
+
"github-copilot",
|
|
1323
|
+
"gemini-cli",
|
|
1324
|
+
"windsurf",
|
|
1325
|
+
"roo-code",
|
|
1326
|
+
"cline",
|
|
1327
|
+
"amp",
|
|
1328
|
+
"kiro",
|
|
1329
|
+
],
|
|
1330
|
+
selectedLeafIds: [leafId],
|
|
1331
|
+
});
|
|
1332
|
+
expect(applied.ok).toBe(true);
|
|
1333
|
+
expect(await pathExists(path.join(process.env.SKILL_FLOW_TARGET_GITHUB_COPILOT, "browse"))).toBe(true);
|
|
1334
|
+
expect(await pathExists(path.join(process.env.SKILL_FLOW_TARGET_GEMINI_CLI, "browse"))).toBe(true);
|
|
1335
|
+
expect(await pathExists(path.join(process.env.SKILL_FLOW_TARGET_WINDSURF, "browse"))).toBe(true);
|
|
1336
|
+
expect(await pathExists(path.join(process.env.SKILL_FLOW_TARGET_ROO_CODE, "browse"))).toBe(true);
|
|
1337
|
+
expect(await pathExists(path.join(process.env.SKILL_FLOW_TARGET_CLINE, "browse"))).toBe(true);
|
|
1338
|
+
expect(await pathExists(path.join(process.env.SKILL_FLOW_TARGET_AMP, "browse"))).toBe(true);
|
|
1339
|
+
expect(await pathExists(path.join(process.env.SKILL_FLOW_TARGET_KIRO, "browse"))).toBe(true);
|
|
1340
|
+
});
|
|
1341
|
+
test("discovers all configured global targets with isolated roots", async () => {
|
|
1342
|
+
const app = new SkillFlowApp();
|
|
1343
|
+
const targets = await app.getAvailableTargets();
|
|
1344
|
+
expect(targets).toEqual([
|
|
1345
|
+
"claude-code",
|
|
1346
|
+
"codex",
|
|
1347
|
+
"cursor",
|
|
1348
|
+
"github-copilot",
|
|
1349
|
+
"gemini-cli",
|
|
1350
|
+
"opencode",
|
|
1351
|
+
"openclaw",
|
|
1352
|
+
"pi",
|
|
1353
|
+
"windsurf",
|
|
1354
|
+
"roo-code",
|
|
1355
|
+
"cline",
|
|
1356
|
+
"amp",
|
|
1357
|
+
"kiro",
|
|
1358
|
+
]);
|
|
1359
|
+
});
|
|
1360
|
+
test("includes config-based OpenCode skills directory in default detection paths", () => {
|
|
1361
|
+
expect(TARGET_PATH_CANDIDATES.opencode).toContain(path.join(os.homedir(), ".config", "opencode", "skills"));
|
|
1362
|
+
expect(TARGET_PATH_CANDIDATES["github-copilot"]).toContain(path.join(os.homedir(), ".copilot", "skills"));
|
|
1363
|
+
expect(TARGET_PATH_CANDIDATES["gemini-cli"]).toContain(path.join(os.homedir(), ".gemini", "skills"));
|
|
1364
|
+
expect(TARGET_PATH_CANDIDATES.windsurf).toContain(path.join(os.homedir(), ".codeium", "windsurf", "skills"));
|
|
1365
|
+
expect(TARGET_PATH_CANDIDATES["roo-code"]).toContain(path.join(os.homedir(), ".roo", "skills"));
|
|
1366
|
+
expect(TARGET_PATH_CANDIDATES.cline).toContain(path.join(os.homedir(), ".cline", "skills"));
|
|
1367
|
+
expect(TARGET_PATH_CANDIDATES.amp).toContain(path.join(os.homedir(), ".config", "agents", "skills"));
|
|
1368
|
+
expect(TARGET_PATH_CANDIDATES.kiro).toContain(path.join(os.homedir(), ".kiro", "skills"));
|
|
1369
|
+
});
|
|
1370
|
+
test("classifies shared global roots as compatibility reads instead of write roots", () => {
|
|
1371
|
+
expect(TARGET_DEFINITIONS.codex.writerKey).toBe("agents-skills");
|
|
1372
|
+
expect(TARGET_PATH_CANDIDATES.codex).toContain(path.join(os.homedir(), ".agents", "skills"));
|
|
1373
|
+
expect(TARGET_COMPAT_READ_CANDIDATES["gemini-cli"]).toContain(path.join(os.homedir(), ".agents", "skills"));
|
|
1374
|
+
expect(TARGET_COMPAT_READ_CANDIDATES["github-copilot"]).toContain(path.join(os.homedir(), ".agents", "skills"));
|
|
1375
|
+
expect(TARGET_COMPAT_READ_CANDIDATES.cursor).toContain(path.join(os.homedir(), ".claude", "skills"));
|
|
1376
|
+
expect(TARGET_COMPAT_READ_CANDIDATES.pi).toContain(path.join(os.homedir(), ".claude", "skills"));
|
|
1377
|
+
expect(TARGET_COMPAT_READ_CANDIDATES.amp).toContain(path.join(os.homedir(), ".claude", "skills"));
|
|
1378
|
+
expect(TARGET_PATH_CANDIDATES["gemini-cli"]).not.toContain(path.join(os.homedir(), ".agents", "skills"));
|
|
1379
|
+
expect(TARGET_PATH_CANDIDATES["github-copilot"]).not.toContain(path.join(os.homedir(), ".claude", "skills"));
|
|
1380
|
+
});
|
|
1381
|
+
});
|
|
1382
|
+
async function createRepo(root, files) {
|
|
1383
|
+
const repoPath = await fs.mkdtemp(path.join(root, "repo-"));
|
|
1384
|
+
git(repoPath, ["init"]);
|
|
1385
|
+
git(repoPath, ["config", "user.email", "test@example.com"]);
|
|
1386
|
+
git(repoPath, ["config", "user.name", "Skill Flow Test"]);
|
|
1387
|
+
await writeRepoFiles(repoPath, files);
|
|
1388
|
+
git(repoPath, ["add", "."]);
|
|
1389
|
+
git(repoPath, ["commit", "-m", "initial"]);
|
|
1390
|
+
return repoPath;
|
|
1391
|
+
}
|
|
1392
|
+
async function seedBuiltinCatalog(app) {
|
|
1393
|
+
for (const builtin of builtinGitSources.getBuiltinGitSources()) {
|
|
1394
|
+
await fs.mkdir(app.store.getCatalogCheckoutPath(deriveSourceId(builtin.locator)), {
|
|
1395
|
+
recursive: true,
|
|
1396
|
+
});
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
async function writeRepoFiles(root, files) {
|
|
1400
|
+
for (const [relativePath, content] of Object.entries(files)) {
|
|
1401
|
+
const absolutePath = path.join(root, relativePath);
|
|
1402
|
+
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
|
|
1403
|
+
await fs.writeFile(absolutePath, content, "utf8");
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
function skillDoc(name, description, heading) {
|
|
1407
|
+
return `---
|
|
1408
|
+
name: ${name}
|
|
1409
|
+
description: |
|
|
1410
|
+
${description}
|
|
1411
|
+
---
|
|
1412
|
+
${heading ? `\n# ${heading}\n` : ""}
|
|
1413
|
+
`;
|
|
1414
|
+
}
|
|
1415
|
+
function git(cwd, args) {
|
|
1416
|
+
execFileSync("git", args, { cwd, stdio: "pipe" });
|
|
1417
|
+
}
|
|
1418
|
+
async function pathExists(targetPath) {
|
|
1419
|
+
try {
|
|
1420
|
+
await fs.lstat(targetPath);
|
|
1421
|
+
return true;
|
|
1422
|
+
}
|
|
1423
|
+
catch {
|
|
1424
|
+
return false;
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
//# sourceMappingURL=skill-flow.test.js.map
|