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.
- package/README.md +40 -3
- package/README.zh.md +40 -3
- package/dist/adapters/channel-adapters.js +11 -3
- package/dist/adapters/channel-adapters.js.map +1 -1
- package/dist/cli.js +69 -37
- package/dist/cli.js.map +1 -1
- package/dist/domain/types.d.ts +54 -1
- package/dist/services/config-coordinator.d.ts +38 -0
- package/dist/services/config-coordinator.js +81 -0
- package/dist/services/config-coordinator.js.map +1 -0
- package/dist/services/doctor-service.d.ts +2 -0
- package/dist/services/doctor-service.js +62 -0
- package/dist/services/doctor-service.js.map +1 -1
- package/dist/services/inventory-service.d.ts +3 -1
- package/dist/services/inventory-service.js +12 -5
- package/dist/services/inventory-service.js.map +1 -1
- package/dist/services/skill-flow.d.ts +50 -26
- package/dist/services/skill-flow.js +502 -89
- package/dist/services/skill-flow.js.map +1 -1
- package/dist/services/source-service.d.ts +20 -10
- package/dist/services/source-service.js +359 -75
- package/dist/services/source-service.js.map +1 -1
- package/dist/services/workflow-service.d.ts +2 -2
- package/dist/services/workflow-service.js +17 -4
- package/dist/services/workflow-service.js.map +1 -1
- package/dist/services/workspace-bootstrap-service.d.ts +25 -0
- package/dist/services/workspace-bootstrap-service.js +140 -0
- package/dist/services/workspace-bootstrap-service.js.map +1 -0
- package/dist/state/store.d.ts +16 -0
- package/dist/state/store.js +93 -18
- package/dist/state/store.js.map +1 -1
- package/dist/tests/clawhub.test.d.ts +1 -0
- package/dist/tests/clawhub.test.js +63 -0
- package/dist/tests/clawhub.test.js.map +1 -0
- package/dist/tests/cli-utils.test.d.ts +1 -0
- package/dist/tests/cli-utils.test.js +15 -0
- package/dist/tests/cli-utils.test.js.map +1 -0
- package/dist/tests/config-coordinator.test.d.ts +1 -0
- package/dist/tests/config-coordinator.test.js +172 -0
- package/dist/tests/config-coordinator.test.js.map +1 -0
- package/dist/tests/config-integration.test.d.ts +1 -0
- package/dist/tests/config-integration.test.js +238 -0
- package/dist/tests/config-integration.test.js.map +1 -0
- package/dist/tests/config-ui-utils.test.d.ts +1 -0
- package/dist/tests/config-ui-utils.test.js +389 -0
- package/dist/tests/config-ui-utils.test.js.map +1 -0
- package/dist/tests/find-and-naming-utils.test.d.ts +1 -0
- package/dist/tests/find-and-naming-utils.test.js +127 -0
- package/dist/tests/find-and-naming-utils.test.js.map +1 -0
- package/dist/tests/skill-flow.test.js +334 -881
- package/dist/tests/skill-flow.test.js.map +1 -1
- package/dist/tests/source-lifecycle.test.d.ts +1 -0
- package/dist/tests/source-lifecycle.test.js +605 -0
- package/dist/tests/source-lifecycle.test.js.map +1 -0
- package/dist/tests/target-definitions.test.d.ts +1 -0
- package/dist/tests/target-definitions.test.js +51 -0
- package/dist/tests/target-definitions.test.js.map +1 -0
- package/dist/tests/test-helpers.d.ts +18 -0
- package/dist/tests/test-helpers.js +123 -0
- package/dist/tests/test-helpers.js.map +1 -0
- package/dist/tui/config-app.d.ts +147 -24
- package/dist/tui/config-app.js +1081 -335
- package/dist/tui/config-app.js.map +1 -1
- package/dist/tui/find-app.d.ts +1 -1
- package/dist/tui/find-app.js +36 -4
- package/dist/tui/find-app.js.map +1 -1
- package/dist/utils/clawhub.d.ts +3 -0
- package/dist/utils/clawhub.js +32 -3
- package/dist/utils/clawhub.js.map +1 -1
- package/dist/utils/cli.d.ts +1 -0
- package/dist/utils/cli.js +15 -0
- package/dist/utils/cli.js.map +1 -0
- package/dist/utils/constants.d.ts +4 -0
- package/dist/utils/constants.js +31 -0
- package/dist/utils/constants.js.map +1 -1
- package/dist/utils/fs.d.ts +5 -0
- package/dist/utils/fs.js +52 -1
- package/dist/utils/fs.js.map +1 -1
- package/dist/utils/naming.d.ts +1 -0
- package/dist/utils/naming.js +7 -1
- package/dist/utils/naming.js.map +1 -1
- package/dist/utils/source-id.js +4 -0
- package/dist/utils/source-id.js.map +1 -1
- 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 {
|
|
5
|
-
import {
|
|
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
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
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
|
-
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
457
|
-
const
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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
|
-
|
|
469
|
-
|
|
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
|
|
477
|
-
|
|
478
|
-
expect(
|
|
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(
|
|
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(
|
|
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(
|
|
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).
|
|
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(
|
|
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(
|
|
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
|
|
983
|
-
|
|
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("
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
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
|