skill-flow 1.0.3 → 1.0.5

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.
Files changed (84) hide show
  1. package/README.md +40 -3
  2. package/README.zh.md +40 -3
  3. package/dist/adapters/channel-adapters.js +11 -3
  4. package/dist/adapters/channel-adapters.js.map +1 -1
  5. package/dist/cli.js +69 -37
  6. package/dist/cli.js.map +1 -1
  7. package/dist/domain/types.d.ts +54 -1
  8. package/dist/services/config-coordinator.d.ts +38 -0
  9. package/dist/services/config-coordinator.js +81 -0
  10. package/dist/services/config-coordinator.js.map +1 -0
  11. package/dist/services/doctor-service.d.ts +2 -0
  12. package/dist/services/doctor-service.js +62 -0
  13. package/dist/services/doctor-service.js.map +1 -1
  14. package/dist/services/inventory-service.d.ts +3 -1
  15. package/dist/services/inventory-service.js +12 -5
  16. package/dist/services/inventory-service.js.map +1 -1
  17. package/dist/services/skill-flow.d.ts +50 -26
  18. package/dist/services/skill-flow.js +502 -89
  19. package/dist/services/skill-flow.js.map +1 -1
  20. package/dist/services/source-service.d.ts +20 -10
  21. package/dist/services/source-service.js +359 -75
  22. package/dist/services/source-service.js.map +1 -1
  23. package/dist/services/workflow-service.d.ts +2 -2
  24. package/dist/services/workflow-service.js +17 -4
  25. package/dist/services/workflow-service.js.map +1 -1
  26. package/dist/services/workspace-bootstrap-service.d.ts +25 -0
  27. package/dist/services/workspace-bootstrap-service.js +140 -0
  28. package/dist/services/workspace-bootstrap-service.js.map +1 -0
  29. package/dist/state/store.d.ts +16 -0
  30. package/dist/state/store.js +93 -18
  31. package/dist/state/store.js.map +1 -1
  32. package/dist/tests/clawhub.test.d.ts +1 -0
  33. package/dist/tests/clawhub.test.js +63 -0
  34. package/dist/tests/clawhub.test.js.map +1 -0
  35. package/dist/tests/cli-utils.test.d.ts +1 -0
  36. package/dist/tests/cli-utils.test.js +15 -0
  37. package/dist/tests/cli-utils.test.js.map +1 -0
  38. package/dist/tests/config-coordinator.test.d.ts +1 -0
  39. package/dist/tests/config-coordinator.test.js +172 -0
  40. package/dist/tests/config-coordinator.test.js.map +1 -0
  41. package/dist/tests/config-integration.test.d.ts +1 -0
  42. package/dist/tests/config-integration.test.js +238 -0
  43. package/dist/tests/config-integration.test.js.map +1 -0
  44. package/dist/tests/config-ui-utils.test.d.ts +1 -0
  45. package/dist/tests/config-ui-utils.test.js +389 -0
  46. package/dist/tests/config-ui-utils.test.js.map +1 -0
  47. package/dist/tests/find-and-naming-utils.test.d.ts +1 -0
  48. package/dist/tests/find-and-naming-utils.test.js +127 -0
  49. package/dist/tests/find-and-naming-utils.test.js.map +1 -0
  50. package/dist/tests/skill-flow.test.js +334 -881
  51. package/dist/tests/skill-flow.test.js.map +1 -1
  52. package/dist/tests/source-lifecycle.test.d.ts +1 -0
  53. package/dist/tests/source-lifecycle.test.js +605 -0
  54. package/dist/tests/source-lifecycle.test.js.map +1 -0
  55. package/dist/tests/target-definitions.test.d.ts +1 -0
  56. package/dist/tests/target-definitions.test.js +51 -0
  57. package/dist/tests/target-definitions.test.js.map +1 -0
  58. package/dist/tests/test-helpers.d.ts +18 -0
  59. package/dist/tests/test-helpers.js +123 -0
  60. package/dist/tests/test-helpers.js.map +1 -0
  61. package/dist/tui/config-app.d.ts +147 -24
  62. package/dist/tui/config-app.js +1081 -335
  63. package/dist/tui/config-app.js.map +1 -1
  64. package/dist/tui/find-app.d.ts +1 -1
  65. package/dist/tui/find-app.js +36 -4
  66. package/dist/tui/find-app.js.map +1 -1
  67. package/dist/utils/clawhub.d.ts +3 -0
  68. package/dist/utils/clawhub.js +32 -3
  69. package/dist/utils/clawhub.js.map +1 -1
  70. package/dist/utils/cli.d.ts +1 -0
  71. package/dist/utils/cli.js +15 -0
  72. package/dist/utils/cli.js.map +1 -0
  73. package/dist/utils/constants.d.ts +4 -0
  74. package/dist/utils/constants.js +31 -0
  75. package/dist/utils/constants.js.map +1 -1
  76. package/dist/utils/fs.d.ts +5 -0
  77. package/dist/utils/fs.js +52 -1
  78. package/dist/utils/fs.js.map +1 -1
  79. package/dist/utils/naming.d.ts +1 -0
  80. package/dist/utils/naming.js +7 -1
  81. package/dist/utils/naming.js.map +1 -1
  82. package/dist/utils/source-id.js +4 -0
  83. package/dist/utils/source-id.js.map +1 -1
  84. package/package.json +1 -1
@@ -1,488 +1,47 @@
1
1
  import fs from "node:fs/promises";
2
- import os from "node:os";
3
2
  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";
3
+ import { describe, expect, test } from "vitest";
4
+ import { DoctorService } from "../services/doctor-service.js";
7
5
  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";
6
+ import { createBareRemote, createRepo, git, pathExists, skillDoc, useSkillFlowSandbox, writeRepoFiles, } from "./test-helpers.js";
17
7
  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",
8
+ const sandbox = useSkillFlowSandbox();
9
+ test("uninstall removes managed copied projections even when they drifted", async () => {
10
+ const repoPath = await createRepo(sandbox.sandboxRoot, {
11
+ "browse/SKILL.md": skillDoc("browse", "Browser flow."),
138
12
  });
139
13
  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");
14
+ const added = await app.addSource(repoPath, { project: false });
173
15
  expect(added.ok).toBe(true);
174
16
  if (!added.ok) {
175
17
  return;
176
18
  }
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
- `,
19
+ const sourceId = added.data.manifest.id;
20
+ const leafId = `${sourceId}:browse`;
21
+ const applied = await app.applyDraft(sourceId, {
22
+ enabledTargets: ["openclaw"],
23
+ selectedLeafIds: [leafId],
455
24
  });
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) {
25
+ expect(applied.ok).toBe(true);
26
+ const lock = await app.store.readLock();
27
+ const deployment = lock.deployments.find((item) => item.sourceId === sourceId && item.leafId === leafId && item.target === "openclaw");
28
+ expect(deployment).toBeTruthy();
29
+ if (!deployment) {
466
30
  return;
467
31
  }
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."),
32
+ await writeRepoFiles(path.dirname(deployment.targetPath), {
33
+ [`${path.basename(deployment.targetPath)}/SKILL.md`]: "# Drifted\nChanged content.",
475
34
  });
476
- const inventory = new InventoryService();
477
- const scanned = await inventory.scanSource("garrytan-gstack", repoPath, "gstack");
478
- expect(scanned.leafs[0]?.linkName).toBe("gstack");
35
+ const removed = await app.uninstall([sourceId]);
36
+ expect(removed.ok).toBe(true);
37
+ expect(await pathExists(deployment.targetPath)).toBe(false);
479
38
  });
480
39
  test("renames preview target when foreign content already exists at target path", async () => {
481
- const repoPath = await createRepo(sandboxRoot, {
40
+ const repoPath = await createRepo(sandbox.sandboxRoot, {
482
41
  "good/SKILL.md": skillDoc("good", "Good description."),
483
42
  });
484
43
  const app = new SkillFlowApp();
485
- const added = await app.addSource(repoPath);
44
+ const added = await app.addSource(repoPath, { project: false });
486
45
  expect(added.ok).toBe(true);
487
46
  if (!added.ok) {
488
47
  return;
@@ -505,11 +64,11 @@ description: |
505
64
  expect(action?.targetPath).not.toBe(path.join(process.env.SKILL_FLOW_TARGET_CLAUDE_CODE, "good"));
506
65
  });
507
66
  test("replaces identical external directory content at target path", async () => {
508
- const repoPath = await createRepo(sandboxRoot, {
67
+ const repoPath = await createRepo(sandbox.sandboxRoot, {
509
68
  "browse/SKILL.md": skillDoc("browse", "Browser flow."),
510
69
  });
511
70
  const app = new SkillFlowApp();
512
- const added = await app.addSource(repoPath);
71
+ const added = await app.addSource(repoPath, { project: false });
513
72
  expect(added.ok).toBe(true);
514
73
  if (!added.ok) {
515
74
  return;
@@ -530,14 +89,14 @@ description: |
530
89
  expect(await pathExists(path.join(targetPath, "local.txt"))).toBe(false);
531
90
  });
532
91
  test("replaces identical external symlink content at target path", async () => {
533
- const repoPath = await createRepo(sandboxRoot, {
92
+ const repoPath = await createRepo(sandbox.sandboxRoot, {
534
93
  "browse/SKILL.md": skillDoc("browse", "Browser flow."),
535
94
  });
536
- const externalRepo = await createRepo(sandboxRoot, {
95
+ const externalRepo = await createRepo(sandbox.sandboxRoot, {
537
96
  "browse/SKILL.md": skillDoc("browse", "Browser flow."),
538
97
  });
539
98
  const app = new SkillFlowApp();
540
- const added = await app.addSource(repoPath);
99
+ const added = await app.addSource(repoPath, { project: false });
541
100
  expect(added.ok).toBe(true);
542
101
  if (!added.ok) {
543
102
  return;
@@ -551,14 +110,14 @@ description: |
551
110
  selectedLeafIds: [leafId],
552
111
  });
553
112
  expect(applied.ok).toBe(true);
554
- expect(path.resolve(await fs.readlink(targetPath))).toBe(path.join(stateRoot, "source", "git", sourceId, "browse"));
113
+ expect(path.resolve(await fs.readlink(targetPath))).toBe(path.join(added.data.lock.checkoutPath, "browse"));
555
114
  });
556
115
  test("keeps external different-content skill and renames our projection instead", async () => {
557
- const repoPath = await createRepo(sandboxRoot, {
116
+ const repoPath = await createRepo(sandbox.sandboxRoot, {
558
117
  "browse/SKILL.md": skillDoc("browse", "Managed browser flow."),
559
118
  });
560
119
  const app = new SkillFlowApp();
561
- const added = await app.addSource(repoPath);
120
+ const added = await app.addSource(repoPath, { project: false });
562
121
  expect(added.ok).toBe(true);
563
122
  if (!added.ok) {
564
123
  return;
@@ -581,12 +140,45 @@ description: |
581
140
  expect(deployment?.targetPath).not.toBe(path.join(targetRoot, "browse"));
582
141
  expect(await pathExists(deployment?.targetPath ?? "")).toBe(true);
583
142
  });
143
+ test("repairState clamps removed deployment count at zero when rebuilding records", async () => {
144
+ const repoPath = await createRepo(sandbox.sandboxRoot, {
145
+ "browse/SKILL.md": skillDoc("browse", "Browser flow."),
146
+ });
147
+ const app = new SkillFlowApp();
148
+ const added = await app.addSource(repoPath, { project: false });
149
+ expect(added.ok).toBe(true);
150
+ if (!added.ok) {
151
+ return;
152
+ }
153
+ const sourceId = added.data.manifest.id;
154
+ const leafId = `${sourceId}:browse`;
155
+ const applied = await app.applyDraft(sourceId, {
156
+ enabledTargets: ["claude-code"],
157
+ selectedLeafIds: [leafId],
158
+ });
159
+ expect(applied.ok).toBe(true);
160
+ const lock = await app.store.readLock();
161
+ lock.deployments = lock.deployments.filter((deployment) => !(deployment.sourceId === sourceId &&
162
+ deployment.leafId === leafId &&
163
+ deployment.target === "claude-code"));
164
+ await app.store.writeLock(lock);
165
+ const repaired = await app.repairState([sourceId]);
166
+ expect(repaired.ok).toBe(true);
167
+ if (!repaired.ok) {
168
+ return;
169
+ }
170
+ expect(repaired.data.removedDeploymentCount).toBe(0);
171
+ const nextLock = await app.store.readLock();
172
+ expect(nextLock.deployments.some((deployment) => deployment.sourceId === sourceId &&
173
+ deployment.leafId === leafId &&
174
+ deployment.target === "claude-code")).toBe(true);
175
+ });
584
176
  test("relocates external skill when our fallback names are fully occupied", async () => {
585
- const repoPath = await createRepo(sandboxRoot, {
177
+ const repoPath = await createRepo(sandbox.sandboxRoot, {
586
178
  "browse/SKILL.md": skillDoc("browse", "Managed browser flow."),
587
179
  });
588
180
  const app = new SkillFlowApp();
589
- const added = await app.addSource(repoPath);
181
+ const added = await app.addSource(repoPath, { project: false });
590
182
  expect(added.ok).toBe(true);
591
183
  if (!added.ok) {
592
184
  return;
@@ -616,11 +208,11 @@ description: |
616
208
  expect(await fs.readFile(path.join(targetRoot, "browse-external", "external.txt"), "utf8")).toBe("keep me");
617
209
  });
618
210
  test("doctor detects broken symlinks", async () => {
619
- const repoPath = await createRepo(sandboxRoot, {
211
+ const repoPath = await createRepo(sandbox.sandboxRoot, {
620
212
  "good/SKILL.md": skillDoc("good", "Good description."),
621
213
  });
622
214
  const app = new SkillFlowApp();
623
- const added = await app.addSource(repoPath);
215
+ const added = await app.addSource(repoPath, { project: false });
624
216
  expect(added.ok).toBe(true);
625
217
  if (!added.ok) {
626
218
  return;
@@ -632,7 +224,7 @@ description: |
632
224
  selectedLeafIds: [leafId],
633
225
  });
634
226
  expect(applied.ok).toBe(true);
635
- await fs.rm(path.join(stateRoot, "source", "git", sourceId, "good"), {
227
+ await fs.rm(path.join(added.data.lock.checkoutPath, "good"), {
636
228
  recursive: true,
637
229
  force: true,
638
230
  });
@@ -643,8 +235,140 @@ description: |
643
235
  }
644
236
  expect(doctor.data.issues.some((issue) => issue.code === "BROKEN_SYMLINK")).toBe(true);
645
237
  });
238
+ test("doctor reports invalidated selected leafs as errors", async () => {
239
+ const repoPath = await createRepo(sandbox.sandboxRoot, {
240
+ "good/SKILL.md": skillDoc("good", "Good description."),
241
+ "broken/SKILL.md": "broken",
242
+ });
243
+ const app = new SkillFlowApp();
244
+ const added = await app.addSource(repoPath, { project: false });
245
+ expect(added.ok).toBe(true);
246
+ if (!added.ok) {
247
+ return;
248
+ }
249
+ const sourceId = added.data.manifest.id;
250
+ const manifest = await app.store.readManifest();
251
+ manifest.bindings[sourceId] = {
252
+ targets: {
253
+ "claude-code": {
254
+ enabled: true,
255
+ leafIds: [`${sourceId}:broken`],
256
+ },
257
+ },
258
+ };
259
+ await app.store.writeManifest(manifest);
260
+ const doctor = await new DoctorService().run(await app.store.readManifest(), await app.store.readLock());
261
+ expect(doctor.ok).toBe(true);
262
+ if (!doctor.ok) {
263
+ return;
264
+ }
265
+ expect(doctor.data.issues.some((issue) => issue.code === "INVALIDATED_SELECTED_LEAF" &&
266
+ issue.severity === "error" &&
267
+ issue.sourceId === sourceId)).toBe(true);
268
+ });
269
+ test("doctor reports unmanaged external target skills", async () => {
270
+ const unmanagedRoot = path.join(process.env.SKILL_FLOW_TARGET_CLAUDE_CODE, "unmanaged");
271
+ await writeRepoFiles(unmanagedRoot, {
272
+ "SKILL.md": skillDoc("unmanaged", "Unmanaged skill."),
273
+ });
274
+ const app = new SkillFlowApp();
275
+ await app.store.init();
276
+ const doctor = await new DoctorService().run(await app.store.readManifest(), await app.store.readLock());
277
+ expect(doctor.ok).toBe(true);
278
+ if (!doctor.ok) {
279
+ return;
280
+ }
281
+ expect(doctor.data.issues.some((issue) => issue.code === "UNMANAGED_EXTERNAL_TARGET_SKILL" &&
282
+ issue.severity === "warning" &&
283
+ issue.target === "claude-code")).toBe(true);
284
+ });
285
+ test("repairTargets recreates missing managed projections without touching unmanaged content", async () => {
286
+ const repoPath = await createRepo(sandbox.sandboxRoot, {
287
+ "browse/SKILL.md": skillDoc("browse", "Browser flow."),
288
+ });
289
+ const app = new SkillFlowApp();
290
+ const added = await app.addSource(repoPath, { project: false });
291
+ expect(added.ok).toBe(true);
292
+ if (!added.ok) {
293
+ return;
294
+ }
295
+ const sourceId = added.data.manifest.id;
296
+ const leafId = `${sourceId}:browse`;
297
+ const managedTargetPath = path.join(process.env.SKILL_FLOW_TARGET_CLAUDE_CODE, "browse");
298
+ const unmanagedTargetPath = path.join(process.env.SKILL_FLOW_TARGET_CLAUDE_CODE, "unmanaged");
299
+ await app.applyDraft(sourceId, {
300
+ enabledTargets: ["claude-code"],
301
+ selectedLeafIds: [leafId],
302
+ });
303
+ await fs.rm(managedTargetPath, { recursive: true, force: true });
304
+ await writeRepoFiles(unmanagedTargetPath, {
305
+ "SKILL.md": skillDoc("unmanaged", "Unmanaged skill."),
306
+ });
307
+ const repaired = await app.repairTargets([sourceId]);
308
+ expect(repaired.ok).toBe(true);
309
+ if (!repaired.ok) {
310
+ return;
311
+ }
312
+ expect(await pathExists(managedTargetPath)).toBe(true);
313
+ expect(await pathExists(unmanagedTargetPath)).toBe(true);
314
+ });
315
+ test("repairState rebuilds source-side lock data and keeps unmanaged target content intact", async () => {
316
+ const repoPath = await createRepo(sandbox.sandboxRoot, {
317
+ "browse/SKILL.md": skillDoc("browse", "Browser flow."),
318
+ "extra/SKILL.md": skillDoc("extra", "Extra flow."),
319
+ });
320
+ const app = new SkillFlowApp();
321
+ const added = await app.addSource(repoPath, { project: false });
322
+ expect(added.ok).toBe(true);
323
+ if (!added.ok) {
324
+ return;
325
+ }
326
+ const sourceId = added.data.manifest.id;
327
+ const checkoutPath = app.store.getSourceCheckoutPath("local", sourceId);
328
+ const unmanagedTargetPath = path.join(process.env.SKILL_FLOW_TARGET_CLAUDE_CODE, "unmanaged");
329
+ await app.applyDraft(sourceId, {
330
+ enabledTargets: ["openclaw"],
331
+ selectedLeafIds: [`${sourceId}:browse`],
332
+ });
333
+ const lockWithManaged = await app.store.readLock();
334
+ const managedDeployment = lockWithManaged.deployments.find((deployment) => deployment.sourceId === sourceId &&
335
+ deployment.leafId === `${sourceId}:browse` &&
336
+ deployment.target === "openclaw");
337
+ expect(managedDeployment).toBeTruthy();
338
+ if (!managedDeployment) {
339
+ return;
340
+ }
341
+ await fs.rm(path.join(checkoutPath, "extra"), { recursive: true, force: true });
342
+ await writeRepoFiles(unmanagedTargetPath, {
343
+ "SKILL.md": skillDoc("unmanaged", "Unmanaged skill."),
344
+ });
345
+ lockWithManaged.deployments = lockWithManaged.deployments.filter((deployment) => !(deployment.sourceId === sourceId &&
346
+ deployment.leafId === `${sourceId}:browse` &&
347
+ deployment.target === "openclaw"));
348
+ lockWithManaged.deployments.push({
349
+ sourceId,
350
+ leafId: `${sourceId}:ghost`,
351
+ target: "claude-code",
352
+ targetPath: path.join(process.env.SKILL_FLOW_TARGET_CLAUDE_CODE, "ghost"),
353
+ strategy: "symlink",
354
+ status: "active",
355
+ contentHash: "ghost",
356
+ appliedAt: "2026-03-23T00:00:00.000Z",
357
+ });
358
+ await app.store.writeLock(lockWithManaged);
359
+ const repaired = await app.repairState();
360
+ expect(repaired.ok).toBe(true);
361
+ const lockAfter = await app.store.readLock();
362
+ expect(lockAfter.leafInventory.map((leaf) => leaf.id)).not.toContain(`${sourceId}:extra`);
363
+ expect(lockAfter.deployments.some((deployment) => deployment.leafId === `${sourceId}:ghost`)).toBe(false);
364
+ expect(lockAfter.deployments.some((deployment) => deployment.sourceId === sourceId &&
365
+ deployment.leafId === `${sourceId}:browse` &&
366
+ deployment.target === "openclaw" &&
367
+ deployment.targetPath === managedDeployment.targetPath)).toBe(true);
368
+ expect(await pathExists(unmanagedTargetPath)).toBe(true);
369
+ });
646
370
  test("scans host directories too, but keeps the first discovered duplicate only", async () => {
647
- const repoPath = await createRepo(sandboxRoot, {
371
+ const repoPath = await createRepo(sandbox.sandboxRoot, {
648
372
  "browse/SKILL.md": skillDoc("browse", "Browser flow."),
649
373
  ".agents/skills/gstack-browse/SKILL.md": skillDoc("browse", "Browser flow."),
650
374
  });
@@ -664,9 +388,23 @@ description: |
664
388
  expect(list.data.summaries[0]?.leafs.map((leaf) => leaf.relativePath)).toEqual([
665
389
  "browse",
666
390
  ]);
391
+ expect(list.data.summaries[0]?.lock?.invalidLeafs).toEqual([]);
392
+ });
393
+ test("reports when a source has no SKILL.md files at all", async () => {
394
+ const repoPath = await createRepo(sandbox.sandboxRoot, {
395
+ "engineering/engineering-senior-developer.md": "# Agent",
396
+ });
397
+ const app = new SkillFlowApp();
398
+ const result = await app.addSource(repoPath);
399
+ expect(result.ok).toBe(false);
400
+ if (result.ok) {
401
+ return;
402
+ }
403
+ expect(result.errors[0]?.code).toBe("NO_VALID_LEAFS");
404
+ expect(result.errors[0]?.message).toContain("No SKILL.md files were found");
667
405
  });
668
406
  test("discovers a unique skill from a host directory when no earlier duplicate exists", async () => {
669
- const repoPath = await createRepo(sandboxRoot, {
407
+ const repoPath = await createRepo(sandbox.sandboxRoot, {
670
408
  ".agents/skills/gstack-browse/SKILL.md": skillDoc("gstack-browse", "Host directory skill."),
671
409
  });
672
410
  const app = new SkillFlowApp();
@@ -684,7 +422,7 @@ description: |
684
422
  expect(list.data.summaries[0]?.leafs[0]?.relativePath).toBe(".agents/skills/gstack-browse");
685
423
  });
686
424
  test("prefers visible second-level skill directories before hidden second-level directories", async () => {
687
- const repoPath = await createRepo(sandboxRoot, {
425
+ const repoPath = await createRepo(sandbox.sandboxRoot, {
688
426
  "catalog/browse/SKILL.md": skillDoc("browse", "Browser flow."),
689
427
  "catalog/.generated/browse/SKILL.md": skillDoc("browse", "Browser flow."),
690
428
  });
@@ -704,7 +442,7 @@ description: |
704
442
  expect(result.warnings.some((warning) => warning.message.includes("catalog/.generated/browse"))).toBe(true);
705
443
  });
706
444
  test("dedupes skills by metadata name and description", async () => {
707
- const repoPath = await createRepo(sandboxRoot, {
445
+ const repoPath = await createRepo(sandbox.sandboxRoot, {
708
446
  "browse/SKILL.md": `---
709
447
  name: browse
710
448
  description: |
@@ -730,7 +468,7 @@ description: |
730
468
  expect(result.warnings.some((warning) => warning.message.includes("Duplicate skill content"))).toBe(true);
731
469
  });
732
470
  test("keeps same-name skills when descriptions differ", async () => {
733
- const repoPath = await createRepo(sandboxRoot, {
471
+ const repoPath = await createRepo(sandbox.sandboxRoot, {
734
472
  "browse/SKILL.md": skillDoc("browse", "Canonical browse skill."),
735
473
  "copy-of-browse/SKILL.md": skillDoc("browse", "Different browse skill."),
736
474
  });
@@ -743,11 +481,11 @@ description: |
743
481
  expect(result.data.leafCount).toBe(2);
744
482
  });
745
483
  test("apply uses natural skill names and removes legacy prefixed paths", async () => {
746
- const repoPath = await createRepo(sandboxRoot, {
484
+ const repoPath = await createRepo(sandbox.sandboxRoot, {
747
485
  "browse/SKILL.md": skillDoc("browse", "Browser flow."),
748
486
  });
749
487
  const app = new SkillFlowApp();
750
- const added = await app.addSource(repoPath);
488
+ const added = await app.addSource(`file://${repoPath}`);
751
489
  expect(added.ok).toBe(true);
752
490
  if (!added.ok) {
753
491
  return;
@@ -755,8 +493,8 @@ description: |
755
493
  const sourceId = added.data.manifest.id;
756
494
  const leafId = `${sourceId}:browse`;
757
495
  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");
496
+ await fs.symlink(path.join(sandbox.stateRoot, "source", "git", sourceId, "browse"), legacyPath, "junction");
497
+ const lockPath = path.join(sandbox.stateRoot, "lock.json");
760
498
  const lockFile = JSON.parse(await fs.readFile(lockPath, "utf8"));
761
499
  lockFile.deployments.push({
762
500
  sourceId,
@@ -778,10 +516,10 @@ description: |
778
516
  expect(await pathExists(path.join(process.env.SKILL_FLOW_TARGET_CLAUDE_CODE, "browse"))).toBe(true);
779
517
  });
780
518
  test("keeps the earlier selected cross-group duplicate when linkName name and description all match", async () => {
781
- const repoA = await createRepo(sandboxRoot, {
519
+ const repoA = await createRepo(sandbox.sandboxRoot, {
782
520
  "browse/SKILL.md": skillDoc("browse", "Browser flow."),
783
521
  });
784
- const repoB = await createRepo(sandboxRoot, {
522
+ const repoB = await createRepo(sandbox.sandboxRoot, {
785
523
  "browse/SKILL.md": skillDoc("browse", "Browser flow."),
786
524
  });
787
525
  const app = new SkillFlowApp();
@@ -815,16 +553,16 @@ description: |
815
553
  }
816
554
  expect(secondApply.data.draft.selectedLeafIds).toEqual([]);
817
555
  expect(await pathExists(path.join(process.env.SKILL_FLOW_TARGET_CLAUDE_CODE, "browse"))).toBe(true);
818
- const lockPath = path.join(stateRoot, "lock.json");
556
+ const lockPath = path.join(sandbox.stateRoot, "lock.json");
819
557
  const lock = JSON.parse(await fs.readFile(lockPath, "utf8"));
820
558
  expect(lock.deployments.filter((deployment) => deployment.targetPath.endsWith(path.join("claude", "browse")))).toHaveLength(1);
821
559
  expect(lock.deployments[0]?.sourceId).toBe(sourceA);
822
560
  });
823
561
  test("renames cross-group projections when linkName matches but content differs", async () => {
824
- const repoA = await createRepo(sandboxRoot, {
562
+ const repoA = await createRepo(sandbox.sandboxRoot, {
825
563
  "browse/SKILL.md": skillDoc("browse", "Browser flow from A."),
826
564
  });
827
- const repoB = await createRepo(sandboxRoot, {
565
+ const repoB = await createRepo(sandbox.sandboxRoot, {
828
566
  "browse/SKILL.md": skillDoc("browse", "Browser flow from B."),
829
567
  });
830
568
  const app = new SkillFlowApp();
@@ -859,11 +597,11 @@ description: |
859
597
  expect(await pathExists(path.join(process.env.SKILL_FLOW_TARGET_CLAUDE_CODE, `${sourceB}-browse`))).toBe(true);
860
598
  });
861
599
  test("doctor reports unavailable target paths", async () => {
862
- const repoPath = await createRepo(sandboxRoot, {
600
+ const repoPath = await createRepo(sandbox.sandboxRoot, {
863
601
  "good/SKILL.md": skillDoc("good", "Good description."),
864
602
  });
865
603
  const app = new SkillFlowApp();
866
- const added = await app.addSource(repoPath);
604
+ const added = await app.addSource(`file://${repoPath}`);
867
605
  expect(added.ok).toBe(true);
868
606
  if (!added.ok) {
869
607
  return;
@@ -883,30 +621,59 @@ description: |
883
621
  expect(doctor.data.plan.blocked[0]?.reason).toContain("Target directory not found");
884
622
  });
885
623
  test("update detects added skills", async () => {
886
- const repoPath = await createRepo(sandboxRoot, {
624
+ const repoPath = await createRepo(sandbox.sandboxRoot, {
887
625
  "good/SKILL.md": skillDoc("good", "Good description."),
888
626
  });
627
+ const remotePath = await createBareRemote(repoPath, sandbox.sandboxRoot);
889
628
  const app = new SkillFlowApp();
890
- const added = await app.addSource(repoPath);
629
+ const added = await app.addSource(`file://${remotePath}`);
891
630
  expect(added.ok).toBe(true);
892
631
  await writeRepoFiles(repoPath, {
893
632
  "extra/SKILL.md": skillDoc("extra", "Extra description."),
894
633
  });
895
634
  git(repoPath, ["add", "."]);
896
635
  git(repoPath, ["commit", "-m", "add extra"]);
636
+ git(repoPath, ["push", "origin", "HEAD"]);
897
637
  const updated = await app.updateSources([added.ok ? added.data.manifest.id : ""]);
898
638
  expect(updated.ok).toBe(true);
899
639
  if (!updated.ok) {
900
640
  return;
901
641
  }
902
- expect(updated.data.updated[0]?.addedLeafIds).toHaveLength(1);
642
+ expect(updated.data.updated[0]?.addedLeafIds.some((id) => id.endsWith(":extra"))).toBe(true);
643
+ });
644
+ test("local source update re-copies owned checkout from the original locator", async () => {
645
+ const repoPath = await createRepo(sandbox.sandboxRoot, {
646
+ "browse/SKILL.md": skillDoc("browse", "Browser flow."),
647
+ });
648
+ const app = new SkillFlowApp();
649
+ const added = await app.addSource(repoPath);
650
+ expect(added.ok).toBe(true);
651
+ if (!added.ok) {
652
+ return;
653
+ }
654
+ const sourceId = added.data.manifest.id;
655
+ const checkoutPath = app.store.getSourceCheckoutPath("local", sourceId);
656
+ await writeRepoFiles(repoPath, {
657
+ "browse/SKILL.md": skillDoc("browse", "Browser flow, updated upstream."),
658
+ });
659
+ await writeRepoFiles(checkoutPath, {
660
+ "browse/SKILL.md": skillDoc("browse", "Stale checkout content."),
661
+ });
662
+ const updated = await app.updateSources([sourceId]);
663
+ expect(updated.ok).toBe(true);
664
+ if (!updated.ok) {
665
+ return;
666
+ }
667
+ expect(updated.data.updated[0]?.changed).toBe(true);
668
+ expect(await fs.readFile(path.join(checkoutPath, "browse", "SKILL.md"), "utf8")).toContain("Browser flow, updated upstream.");
903
669
  });
904
670
  test("update removes projections for deleted skills", async () => {
905
- const repoPath = await createRepo(sandboxRoot, {
671
+ const repoPath = await createRepo(sandbox.sandboxRoot, {
906
672
  "good/SKILL.md": skillDoc("good", "Good description."),
907
673
  });
674
+ const remotePath = await createBareRemote(repoPath, sandbox.sandboxRoot);
908
675
  const app = new SkillFlowApp();
909
- const added = await app.addSource(repoPath);
676
+ const added = await app.addSource(`file://${remotePath}`);
910
677
  expect(added.ok).toBe(true);
911
678
  if (!added.ok) {
912
679
  return;
@@ -920,6 +687,7 @@ description: |
920
687
  await fs.rm(path.join(repoPath, "good"), { recursive: true, force: true });
921
688
  git(repoPath, ["add", "."]);
922
689
  git(repoPath, ["commit", "-m", "remove good"]);
690
+ git(repoPath, ["push", "origin", "HEAD"]);
923
691
  const updated = await app.updateSources([sourceId]);
924
692
  expect(updated.ok).toBe(true);
925
693
  if (!updated.ok) {
@@ -929,11 +697,12 @@ description: |
929
697
  expect(await pathExists(path.join(process.env.SKILL_FLOW_TARGET_CLAUDE_CODE, "good"))).toBe(false);
930
698
  });
931
699
  test("update surfaces invalidated skills", async () => {
932
- const repoPath = await createRepo(sandboxRoot, {
700
+ const repoPath = await createRepo(sandbox.sandboxRoot, {
933
701
  "good/SKILL.md": skillDoc("good", "Good description."),
934
702
  });
703
+ const remotePath = await createBareRemote(repoPath, sandbox.sandboxRoot);
935
704
  const app = new SkillFlowApp();
936
- const added = await app.addSource(repoPath);
705
+ const added = await app.addSource(`file://${remotePath}`);
937
706
  expect(added.ok).toBe(true);
938
707
  if (!added.ok) {
939
708
  return;
@@ -943,6 +712,7 @@ description: |
943
712
  });
944
713
  git(repoPath, ["add", "."]);
945
714
  git(repoPath, ["commit", "-m", "invalidate"]);
715
+ git(repoPath, ["push", "origin", "HEAD"]);
946
716
  const updated = await app.updateSources([added.data.manifest.id]);
947
717
  expect(updated.ok).toBe(true);
948
718
  if (!updated.ok) {
@@ -950,21 +720,8 @@ description: |
950
720
  }
951
721
  expect(updated.data.updated[0]?.invalidatedLeafIds).toHaveLength(1);
952
722
  });
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
723
  test("doctor detects drift in copied projections", async () => {
967
- const repoPath = await createRepo(sandboxRoot, {
724
+ const repoPath = await createRepo(sandbox.sandboxRoot, {
968
725
  "good/SKILL.md": skillDoc("good", "Good description."),
969
726
  });
970
727
  const app = new SkillFlowApp();
@@ -979,8 +736,13 @@ description: |
979
736
  enabledTargets: ["openclaw"],
980
737
  selectedLeafIds: [leafId],
981
738
  });
982
- await writeRepoFiles(process.env.SKILL_FLOW_TARGET_OPENCLAW, {
983
- ["good/SKILL.md"]: "# Good\nMutated copy.",
739
+ const lock = await app.store.readLock();
740
+ const deployment = lock.deployments.find((item) => item.sourceId === sourceId && item.leafId === leafId && item.target === "openclaw");
741
+ if (!deployment) {
742
+ throw new Error("expected deployment for openclaw");
743
+ }
744
+ await writeRepoFiles(path.dirname(deployment.targetPath), {
745
+ [`${path.basename(deployment.targetPath)}/SKILL.md`]: "# Good\nMutated copy.",
984
746
  });
985
747
  const doctor = await app.doctor();
986
748
  expect(doctor.ok).toBe(true);
@@ -989,304 +751,8 @@ description: |
989
751
  }
990
752
  expect(doctor.data.issues.some((issue) => issue.code === "DRIFT_COPY")).toBe(true);
991
753
  });
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
754
  test("supports cursor and pi target projections", async () => {
1289
- const repoPath = await createRepo(sandboxRoot, {
755
+ const repoPath = await createRepo(sandbox.sandboxRoot, {
1290
756
  "browse/SKILL.md": skillDoc("browse", "Browser flow."),
1291
757
  });
1292
758
  const app = new SkillFlowApp();
@@ -1306,7 +772,7 @@ description: |
1306
772
  expect(await pathExists(path.join(process.env.SKILL_FLOW_TARGET_PI, "browse"))).toBe(true);
1307
773
  });
1308
774
  test("supports additional global agent target projections", async () => {
1309
- const repoPath = await createRepo(sandboxRoot, {
775
+ const repoPath = await createRepo(sandbox.sandboxRoot, {
1310
776
  "browse/SKILL.md": skillDoc("browse", "Browser flow."),
1311
777
  });
1312
778
  const app = new SkillFlowApp();
@@ -1357,71 +823,58 @@ description: |
1357
823
  "kiro",
1358
824
  ]);
1359
825
  });
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"));
826
+ test("explicit target mode ignores non-overridden targets even when default roots exist", async () => {
827
+ const previousTargets = {
828
+ claude: process.env.SKILL_FLOW_TARGET_CLAUDE_CODE,
829
+ codex: process.env.SKILL_FLOW_TARGET_CODEX,
830
+ cursor: process.env.SKILL_FLOW_TARGET_CURSOR,
831
+ githubCopilot: process.env.SKILL_FLOW_TARGET_GITHUB_COPILOT,
832
+ geminiCli: process.env.SKILL_FLOW_TARGET_GEMINI_CLI,
833
+ opencode: process.env.SKILL_FLOW_TARGET_OPENCODE,
834
+ openclaw: process.env.SKILL_FLOW_TARGET_OPENCLAW,
835
+ pi: process.env.SKILL_FLOW_TARGET_PI,
836
+ windsurf: process.env.SKILL_FLOW_TARGET_WINDSURF,
837
+ rooCode: process.env.SKILL_FLOW_TARGET_ROO_CODE,
838
+ cline: process.env.SKILL_FLOW_TARGET_CLINE,
839
+ amp: process.env.SKILL_FLOW_TARGET_AMP,
840
+ kiro: process.env.SKILL_FLOW_TARGET_KIRO,
841
+ };
842
+ const previousHome = process.env.HOME;
843
+ try {
844
+ process.env.HOME = sandbox.sandboxRoot;
845
+ delete process.env.SKILL_FLOW_TARGET_CODEX;
846
+ delete process.env.SKILL_FLOW_TARGET_CURSOR;
847
+ delete process.env.SKILL_FLOW_TARGET_GITHUB_COPILOT;
848
+ delete process.env.SKILL_FLOW_TARGET_GEMINI_CLI;
849
+ delete process.env.SKILL_FLOW_TARGET_OPENCODE;
850
+ delete process.env.SKILL_FLOW_TARGET_OPENCLAW;
851
+ delete process.env.SKILL_FLOW_TARGET_PI;
852
+ delete process.env.SKILL_FLOW_TARGET_WINDSURF;
853
+ delete process.env.SKILL_FLOW_TARGET_ROO_CODE;
854
+ delete process.env.SKILL_FLOW_TARGET_CLINE;
855
+ delete process.env.SKILL_FLOW_TARGET_AMP;
856
+ delete process.env.SKILL_FLOW_TARGET_KIRO;
857
+ await fs.mkdir(path.join(sandbox.sandboxRoot, ".agents", "skills"), { recursive: true });
858
+ const app = new SkillFlowApp();
859
+ const targets = await app.getAvailableTargets();
860
+ expect(targets).toEqual(["claude-code"]);
861
+ }
862
+ finally {
863
+ process.env.HOME = previousHome;
864
+ process.env.SKILL_FLOW_TARGET_CLAUDE_CODE = previousTargets.claude;
865
+ process.env.SKILL_FLOW_TARGET_CODEX = previousTargets.codex;
866
+ process.env.SKILL_FLOW_TARGET_CURSOR = previousTargets.cursor;
867
+ process.env.SKILL_FLOW_TARGET_GITHUB_COPILOT = previousTargets.githubCopilot;
868
+ process.env.SKILL_FLOW_TARGET_GEMINI_CLI = previousTargets.geminiCli;
869
+ process.env.SKILL_FLOW_TARGET_OPENCODE = previousTargets.opencode;
870
+ process.env.SKILL_FLOW_TARGET_OPENCLAW = previousTargets.openclaw;
871
+ process.env.SKILL_FLOW_TARGET_PI = previousTargets.pi;
872
+ process.env.SKILL_FLOW_TARGET_WINDSURF = previousTargets.windsurf;
873
+ process.env.SKILL_FLOW_TARGET_ROO_CODE = previousTargets.rooCode;
874
+ process.env.SKILL_FLOW_TARGET_CLINE = previousTargets.cline;
875
+ process.env.SKILL_FLOW_TARGET_AMP = previousTargets.amp;
876
+ process.env.SKILL_FLOW_TARGET_KIRO = previousTargets.kiro;
877
+ }
1380
878
  });
1381
879
  });
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
880
  //# sourceMappingURL=skill-flow.test.js.map