skillrepo 3.2.0 → 4.0.0
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 +90 -27
- package/bin/skillrepo.mjs +5 -5
- package/package.json +1 -1
- package/src/commands/add.mjs +21 -6
- package/src/commands/get.mjs +20 -4
- package/src/commands/init-session-sync.mjs +1 -1
- package/src/commands/init.mjs +435 -111
- package/src/commands/list.mjs +1 -1
- package/src/commands/remove.mjs +10 -2
- package/src/commands/uninstall.mjs +1 -1
- package/src/commands/update.mjs +15 -3
- package/src/lib/agent-registry.mjs +215 -0
- package/src/lib/cli-config.mjs +146 -44
- package/src/lib/detect-agents.mjs +112 -0
- package/src/lib/file-write.mjs +162 -77
- package/src/lib/mcp-merge.mjs +17 -36
- package/src/lib/mergers/gitignore.mjs +55 -28
- package/src/lib/paths.mjs +27 -25
- package/src/lib/prompt-multiselect.mjs +324 -0
- package/src/lib/sync.mjs +18 -19
- package/src/test/commands/add.test.mjs +18 -3
- package/src/test/commands/init-picker.test.mjs +144 -0
- package/src/test/commands/init.test.mjs +228 -42
- package/src/test/commands/remove.test.mjs +4 -1
- package/src/test/commands/update.test.mjs +13 -3
- package/src/test/e2e/cli-agent-permutations.test.mjs +631 -0
- package/src/test/e2e/cli-commands.test.mjs +39 -13
- package/src/test/integration/file-write.integration.test.mjs +31 -10
- package/src/test/lib/agent-registry.test.mjs +215 -0
- package/src/test/lib/cli-config.test.mjs +222 -38
- package/src/test/lib/detect-agents.test.mjs +336 -0
- package/src/test/lib/file-write-placement.test.mjs +264 -0
- package/src/test/lib/file-write.test.mjs +231 -30
- package/src/test/lib/mcp-merge.test.mjs +23 -15
- package/src/test/lib/paths.test.mjs +53 -17
- package/src/test/lib/prompt-multiselect.test.mjs +448 -0
- package/src/test/lib/sync.test.mjs +157 -0
- package/src/lib/detect-ides.mjs +0 -44
- package/src/test/detect-ides.test.mjs +0 -65
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-agent placement-target tests for file-write.mjs (#1234).
|
|
3
|
+
*
|
|
4
|
+
* The cohort-dedupe assertion is the central piece: every vendor
|
|
5
|
+
* except claudeCode resolves to `agentsProject`, and the result of
|
|
6
|
+
* `placementTargetsFor` for a multi-vendor cohort must be one entry,
|
|
7
|
+
* not six. This is what makes `--agent cursor,windsurf,gemini,codex,
|
|
8
|
+
* cline,copilot` produce a single write instead of duplicating the
|
|
9
|
+
* skill across paths nobody reads.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
13
|
+
import assert from "node:assert/strict";
|
|
14
|
+
import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { tmpdir, homedir } from "node:os";
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
placementTargetsFor,
|
|
20
|
+
resolvePlacementDir,
|
|
21
|
+
} from "../../lib/file-write.mjs";
|
|
22
|
+
import { CliError, EXIT_VALIDATION } from "../../lib/errors.mjs";
|
|
23
|
+
import {
|
|
24
|
+
captureHome,
|
|
25
|
+
setSandboxHome,
|
|
26
|
+
restoreHome,
|
|
27
|
+
} from "../helpers/sandbox-home.mjs";
|
|
28
|
+
|
|
29
|
+
let sandbox;
|
|
30
|
+
let originalCwd;
|
|
31
|
+
/** @type {import("../helpers/sandbox-home.mjs").HomeEnvSnapshot} */
|
|
32
|
+
let originalHomeEnv;
|
|
33
|
+
|
|
34
|
+
function setupSandbox() {
|
|
35
|
+
sandbox = mkdtempSync(join(tmpdir(), "cli-fw-place-"));
|
|
36
|
+
mkdirSync(join(sandbox, "project"), { recursive: true });
|
|
37
|
+
mkdirSync(join(sandbox, "home"), { recursive: true });
|
|
38
|
+
originalCwd = process.cwd();
|
|
39
|
+
originalHomeEnv = captureHome();
|
|
40
|
+
process.chdir(join(sandbox, "project"));
|
|
41
|
+
setSandboxHome(join(sandbox, "home"));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function teardownSandbox() {
|
|
45
|
+
process.chdir(originalCwd);
|
|
46
|
+
restoreHome(originalHomeEnv);
|
|
47
|
+
if (sandbox) rmSync(sandbox, { recursive: true, force: true });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe("placementTargetsFor — project scope", () => {
|
|
51
|
+
beforeEach(setupSandbox);
|
|
52
|
+
afterEach(teardownSandbox);
|
|
53
|
+
|
|
54
|
+
it("claudeCode → [claudeProject]", () => {
|
|
55
|
+
assert.deepEqual(
|
|
56
|
+
placementTargetsFor({ vendors: ["claudeCode"] }),
|
|
57
|
+
["claudeProject"],
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("cursor → [agentsProject]", () => {
|
|
62
|
+
assert.deepEqual(
|
|
63
|
+
placementTargetsFor({ vendors: ["cursor"] }),
|
|
64
|
+
["agentsProject"],
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("windsurf → [agentsProject]", () => {
|
|
69
|
+
assert.deepEqual(
|
|
70
|
+
placementTargetsFor({ vendors: ["windsurf"] }),
|
|
71
|
+
["agentsProject"],
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("gemini → [agentsProject]", () => {
|
|
76
|
+
assert.deepEqual(
|
|
77
|
+
placementTargetsFor({ vendors: ["gemini"] }),
|
|
78
|
+
["agentsProject"],
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("codex → [agentsProject]", () => {
|
|
83
|
+
assert.deepEqual(
|
|
84
|
+
placementTargetsFor({ vendors: ["codex"] }),
|
|
85
|
+
["agentsProject"],
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("cline → [agentsProject]", () => {
|
|
90
|
+
assert.deepEqual(
|
|
91
|
+
placementTargetsFor({ vendors: ["cline"] }),
|
|
92
|
+
["agentsProject"],
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("copilot → [agentsProject]", () => {
|
|
97
|
+
assert.deepEqual(
|
|
98
|
+
placementTargetsFor({ vendors: ["copilot"] }),
|
|
99
|
+
["agentsProject"],
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("claudeCode + cursor → [claudeProject, agentsProject]", () => {
|
|
104
|
+
assert.deepEqual(
|
|
105
|
+
placementTargetsFor({ vendors: ["claudeCode", "cursor"] }),
|
|
106
|
+
["claudeProject", "agentsProject"],
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("cohort dedupe: cursor + windsurf + gemini + codex + cline + copilot → [agentsProject]", () => {
|
|
111
|
+
const targets = placementTargetsFor({
|
|
112
|
+
vendors: ["cursor", "windsurf", "gemini", "codex", "cline", "copilot"],
|
|
113
|
+
});
|
|
114
|
+
assert.deepEqual(
|
|
115
|
+
targets,
|
|
116
|
+
["agentsProject"],
|
|
117
|
+
"six cohort vendors must collapse to ONE write",
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("preserves order: first-seen-target wins", () => {
|
|
122
|
+
const targets = placementTargetsFor({
|
|
123
|
+
vendors: ["cursor", "claudeCode", "windsurf"],
|
|
124
|
+
});
|
|
125
|
+
// cursor pushes agentsProject first, claudeCode pushes claudeProject
|
|
126
|
+
// second, windsurf is a dedupe of agentsProject.
|
|
127
|
+
assert.deepEqual(targets, ["agentsProject", "claudeProject"]);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("placementTargetsFor — global scope", () => {
|
|
132
|
+
beforeEach(setupSandbox);
|
|
133
|
+
afterEach(teardownSandbox);
|
|
134
|
+
|
|
135
|
+
it("--global claudeCode → [claudeGlobal]", () => {
|
|
136
|
+
assert.deepEqual(
|
|
137
|
+
placementTargetsFor({ global: true, vendors: ["claudeCode"] }),
|
|
138
|
+
["claudeGlobal"],
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("--global cursor → [agentsGlobal]", () => {
|
|
143
|
+
assert.deepEqual(
|
|
144
|
+
placementTargetsFor({ global: true, vendors: ["cursor"] }),
|
|
145
|
+
["agentsGlobal"],
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("--global windsurf → [windsurfGlobal] (vendor-specific personal path)", () => {
|
|
150
|
+
assert.deepEqual(
|
|
151
|
+
placementTargetsFor({ global: true, vendors: ["windsurf"] }),
|
|
152
|
+
["windsurfGlobal"],
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("--global gemini → [agentsGlobal]", () => {
|
|
157
|
+
assert.deepEqual(
|
|
158
|
+
placementTargetsFor({ global: true, vendors: ["gemini"] }),
|
|
159
|
+
["agentsGlobal"],
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("--global codex → [agentsGlobal]", () => {
|
|
164
|
+
assert.deepEqual(
|
|
165
|
+
placementTargetsFor({ global: true, vendors: ["codex"] }),
|
|
166
|
+
["agentsGlobal"],
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("--global cline → [agentsGlobal]", () => {
|
|
171
|
+
assert.deepEqual(
|
|
172
|
+
placementTargetsFor({ global: true, vendors: ["cline"] }),
|
|
173
|
+
["agentsGlobal"],
|
|
174
|
+
);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("--global copilot → throws validationError (no personal scope)", () => {
|
|
178
|
+
assert.throws(
|
|
179
|
+
() => placementTargetsFor({ global: true, vendors: ["copilot"] }),
|
|
180
|
+
(err) =>
|
|
181
|
+
err instanceof CliError &&
|
|
182
|
+
err.exitCode === EXIT_VALIDATION &&
|
|
183
|
+
/has no documented personal scope/.test(err.message),
|
|
184
|
+
);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("--global cohort dedupes the agents cohort to [agentsGlobal, windsurfGlobal]", () => {
|
|
188
|
+
const targets = placementTargetsFor({
|
|
189
|
+
global: true,
|
|
190
|
+
vendors: ["cursor", "windsurf", "gemini", "codex", "cline"],
|
|
191
|
+
});
|
|
192
|
+
// cursor/gemini/codex/cline → agentsGlobal; windsurf → windsurfGlobal.
|
|
193
|
+
// Order is first-seen.
|
|
194
|
+
assert.deepEqual(targets, ["agentsGlobal", "windsurfGlobal"]);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe("placementTargetsFor — error paths", () => {
|
|
199
|
+
beforeEach(setupSandbox);
|
|
200
|
+
afterEach(teardownSandbox);
|
|
201
|
+
|
|
202
|
+
it("rejects an empty vendors list when not --global", () => {
|
|
203
|
+
assert.throws(
|
|
204
|
+
() => placementTargetsFor({ vendors: [] }),
|
|
205
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
|
|
206
|
+
);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("rejects an unknown vendor", () => {
|
|
210
|
+
assert.throws(
|
|
211
|
+
() => placementTargetsFor({ vendors: ["jetbrains"] }),
|
|
212
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
|
|
213
|
+
);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("rejects the deleted projectFallback target", () => {
|
|
217
|
+
assert.throws(
|
|
218
|
+
() => resolvePlacementDir("projectFallback", "pdf-helper"),
|
|
219
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
|
|
220
|
+
);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("rejects bare --global with no vendors", () => {
|
|
224
|
+
// Every caller (init, add, get, update, remove) routes through
|
|
225
|
+
// `effectiveVendors`, which guarantees a non-empty vendor list
|
|
226
|
+
// before placementTargetsFor sees it. An empty-vendors call here
|
|
227
|
+
// is a programming error in a future caller, not a user input —
|
|
228
|
+
// surface it loudly rather than silently routing to claudeGlobal.
|
|
229
|
+
assert.throws(
|
|
230
|
+
() => placementTargetsFor({ global: true, vendors: [] }),
|
|
231
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
|
|
232
|
+
);
|
|
233
|
+
assert.throws(
|
|
234
|
+
() => placementTargetsFor({ global: true }),
|
|
235
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
|
|
236
|
+
);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
describe("resolvePlacementDir — cross-platform paths", () => {
|
|
241
|
+
beforeEach(setupSandbox);
|
|
242
|
+
afterEach(teardownSandbox);
|
|
243
|
+
|
|
244
|
+
it("agentsProject resolves under cwd/.agents/skills/<name>", () => {
|
|
245
|
+
assert.equal(
|
|
246
|
+
resolvePlacementDir("agentsProject", "pdf-helper"),
|
|
247
|
+
join(process.cwd(), ".agents", "skills", "pdf-helper"),
|
|
248
|
+
);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("agentsGlobal resolves under HOME/.agents/skills/<name>", () => {
|
|
252
|
+
assert.equal(
|
|
253
|
+
resolvePlacementDir("agentsGlobal", "pdf-helper"),
|
|
254
|
+
join(homedir(), ".agents", "skills", "pdf-helper"),
|
|
255
|
+
);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("windsurfGlobal resolves under HOME/.codeium/windsurf/skills/<name>", () => {
|
|
259
|
+
assert.equal(
|
|
260
|
+
resolvePlacementDir("windsurfGlobal", "pdf-helper"),
|
|
261
|
+
join(homedir(), ".codeium", "windsurf", "skills", "pdf-helper"),
|
|
262
|
+
);
|
|
263
|
+
});
|
|
264
|
+
});
|
|
@@ -148,6 +148,61 @@ describe("validateFilePath", () => {
|
|
|
148
148
|
const err = validateFilePath("%E0%A4%A");
|
|
149
149
|
assert.match(err, /URL encoding/);
|
|
150
150
|
});
|
|
151
|
+
|
|
152
|
+
// QA cross-PR review (#1252): lock the current behavior of the
|
|
153
|
+
// safety pre-check against backslash + Unicode-encoded traversal
|
|
154
|
+
// patterns. validateFilePath uses an ASCII `..` substring check —
|
|
155
|
+
// backslash variants reduce to ASCII-`..` after decode and ARE
|
|
156
|
+
// caught; the full-width-period U+FF0E variant is NOT caught.
|
|
157
|
+
// Both behaviors are intentional:
|
|
158
|
+
// • Catching the backslash forms is correct and we lock it so a
|
|
159
|
+
// future regex tightening doesn't accidentally drop them.
|
|
160
|
+
// • NOT catching the full-width form is a documented limitation
|
|
161
|
+
// of the path-preserving CLI — the registry-side validator at
|
|
162
|
+
// `src/lib/skills/file-validation.ts` is the authoritative
|
|
163
|
+
// security boundary. Locking the gap with this test forces a
|
|
164
|
+
// future author who tries to "fix" this without the depth
|
|
165
|
+
// check or registry validator backing them up to confront the
|
|
166
|
+
// actual invariant.
|
|
167
|
+
it("rejects backslash-style path traversal (..\\\\etc\\\\passwd)", () => {
|
|
168
|
+
// Backslash-style traversal still contains the literal `..`
|
|
169
|
+
// substring, so the existing ASCII check catches it.
|
|
170
|
+
const err = validateFilePath("..\\etc\\passwd");
|
|
171
|
+
assert.match(err, /traversal/);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("rejects URL-encoded backslash path traversal (..%5Cetc%5Cpasswd)", () => {
|
|
175
|
+
// After decodeURIComponent, this becomes `..\etc\passwd`. The
|
|
176
|
+
// ASCII `..` substring check still fires — locking that this
|
|
177
|
+
// wrapping doesn't bypass the check.
|
|
178
|
+
const err = validateFilePath("..%5Cetc%5Cpasswd");
|
|
179
|
+
assert.match(err, /traversal/);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("does NOT catch full-width period Unicode traversal (../etc/passwd) — known gap", () => {
|
|
183
|
+
// U+FF0E (FULLWIDTH FULL STOP) is visually identical to the
|
|
184
|
+
// ASCII period but is a different codepoint. The ASCII `..`
|
|
185
|
+
// substring check does NOT match this sequence. The CLI's
|
|
186
|
+
// validateFilePath returns null (accepts the path) because:
|
|
187
|
+
// • The substring check fires only on ASCII `..`.
|
|
188
|
+
// • There is no traversal at the resolved-path level — `..`
|
|
189
|
+
// is just a normal directory name on disk.
|
|
190
|
+
// • Depth here is 2 segments, well under MAX_PATH_DEPTH=5.
|
|
191
|
+
//
|
|
192
|
+
// The registry-side validator at
|
|
193
|
+
// `src/lib/skills/file-validation.ts` MUST also reject paths with
|
|
194
|
+
// homoglyph-style traversal attempts. The CLI is path-preserving
|
|
195
|
+
// and is NOT the security boundary; the registry is. This test
|
|
196
|
+
// pins the current behavior and exists as a forcing function:
|
|
197
|
+
// any future contributor who reads it and assumes the CLI is the
|
|
198
|
+
// authority should be redirected to the server-side validator.
|
|
199
|
+
const result = validateFilePath("../etc/passwd");
|
|
200
|
+
assert.equal(
|
|
201
|
+
result,
|
|
202
|
+
null,
|
|
203
|
+
"Locking known limitation: the CLI's ASCII substring check does not catch full-width-period traversal. The server-side validator is the authoritative security boundary. See file-validation.ts.",
|
|
204
|
+
);
|
|
205
|
+
});
|
|
151
206
|
});
|
|
152
207
|
|
|
153
208
|
// ── isValidSkillName ────────────────────────────────────────────────────
|
|
@@ -220,31 +275,34 @@ describe("placementTargetsFor", () => {
|
|
|
220
275
|
beforeEach(setupSandbox);
|
|
221
276
|
afterEach(teardownSandbox);
|
|
222
277
|
|
|
223
|
-
it("returns [claudeGlobal] for --global", () => {
|
|
224
|
-
assert.deepEqual(
|
|
278
|
+
it("returns [claudeGlobal] for --global claudeCode", () => {
|
|
279
|
+
assert.deepEqual(
|
|
280
|
+
placementTargetsFor({ global: true, vendors: ["claudeCode"] }),
|
|
281
|
+
["claudeGlobal"],
|
|
282
|
+
);
|
|
225
283
|
});
|
|
226
284
|
|
|
227
285
|
it("returns [claudeProject] for vendor=claudeCode only", () => {
|
|
228
286
|
assert.deepEqual(placementTargetsFor({ vendors: ["claudeCode"] }), ["claudeProject"]);
|
|
229
287
|
});
|
|
230
288
|
|
|
231
|
-
it("returns [
|
|
232
|
-
assert.deepEqual(placementTargetsFor({ vendors: ["cursor"] }), ["
|
|
289
|
+
it("returns [agentsProject] for vendor=cursor only", () => {
|
|
290
|
+
assert.deepEqual(placementTargetsFor({ vendors: ["cursor"] }), ["agentsProject"]);
|
|
233
291
|
});
|
|
234
292
|
|
|
235
|
-
it("returns [claudeProject,
|
|
293
|
+
it("returns [claudeProject, agentsProject] for both", () => {
|
|
236
294
|
assert.deepEqual(
|
|
237
295
|
placementTargetsFor({ vendors: ["claudeCode", "cursor"] }),
|
|
238
|
-
["claudeProject", "
|
|
296
|
+
["claudeProject", "agentsProject"],
|
|
239
297
|
);
|
|
240
298
|
});
|
|
241
299
|
|
|
242
|
-
it("dedupes the
|
|
300
|
+
it("dedupes the agents target when multiple cohort vendors are present", () => {
|
|
243
301
|
const targets = placementTargetsFor({
|
|
244
|
-
vendors: ["cursor", "windsurf", "
|
|
302
|
+
vendors: ["cursor", "windsurf", "copilot"],
|
|
245
303
|
});
|
|
246
|
-
// Only one
|
|
247
|
-
assert.equal(targets.filter((t) => t === "
|
|
304
|
+
// Only one agentsProject — not three
|
|
305
|
+
assert.equal(targets.filter((t) => t === "agentsProject").length, 1);
|
|
248
306
|
});
|
|
249
307
|
|
|
250
308
|
it("throws on empty vendors without --global", () => {
|
|
@@ -282,9 +340,9 @@ describe("resolvePlacementDir", () => {
|
|
|
282
340
|
);
|
|
283
341
|
});
|
|
284
342
|
|
|
285
|
-
it("resolves
|
|
286
|
-
const dir = resolvePlacementDir("
|
|
287
|
-
assert.equal(dir, join(process.cwd(), "skills", "pdf-helper"));
|
|
343
|
+
it("resolves agentsProject under cwd/.agents/skills/<name>", () => {
|
|
344
|
+
const dir = resolvePlacementDir("agentsProject", "pdf-helper");
|
|
345
|
+
assert.equal(dir, join(process.cwd(), ".agents", "skills", "pdf-helper"));
|
|
288
346
|
});
|
|
289
347
|
|
|
290
348
|
it("throws on unknown target", () => {
|
|
@@ -455,35 +513,39 @@ describe("writeSkillDir — happy path", () => {
|
|
|
455
513
|
}
|
|
456
514
|
});
|
|
457
515
|
|
|
458
|
-
it("writes to global home dir with --global", () => {
|
|
516
|
+
it("writes to global home dir with --global --agent claudeCode", () => {
|
|
459
517
|
const skill = minimalSkill();
|
|
460
|
-
const result = writeSkillDir(skill, {
|
|
518
|
+
const result = writeSkillDir(skill, {
|
|
519
|
+
global: true,
|
|
520
|
+
vendors: ["claudeCode"],
|
|
521
|
+
});
|
|
461
522
|
assert.equal(result.written.length, 1);
|
|
462
523
|
assert.ok(result.written[0].startsWith(process.env.HOME));
|
|
463
524
|
});
|
|
464
525
|
|
|
465
|
-
it("
|
|
526
|
+
it("writes to .agents/skills/ when only a cohort vendor is requested", () => {
|
|
466
527
|
const skill = minimalSkill();
|
|
467
528
|
const result = writeSkillDir(skill, { vendors: ["cursor"] });
|
|
468
529
|
assert.equal(result.written.length, 1);
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
// gitignore
|
|
474
|
-
// forward slashes
|
|
530
|
+
assert.ok(
|
|
531
|
+
result.written[0].includes(join(".agents", "skills", "pdf-helper")),
|
|
532
|
+
`expected dir under .agents/skills/, got ${result.written[0]}`,
|
|
533
|
+
);
|
|
534
|
+
// .gitignore should now contain `.agents/skills/` — the gitignore
|
|
535
|
+
// pattern uses forward slashes on every platform per git's
|
|
536
|
+
// conventions.
|
|
475
537
|
const gi = readFileSync(join(process.cwd(), ".gitignore"), "utf-8");
|
|
476
|
-
assert.match(gi,
|
|
538
|
+
assert.match(gi, /\.agents\/skills\//);
|
|
477
539
|
assert.match(gi, /SkillRepo/);
|
|
478
540
|
});
|
|
479
541
|
|
|
480
|
-
it("idempotent .gitignore management — does not double-add /skills/", () => {
|
|
542
|
+
it("idempotent .gitignore management — does not double-add .agents/skills/", () => {
|
|
481
543
|
const skill = minimalSkill();
|
|
482
544
|
writeSkillDir(skill, { vendors: ["cursor"] });
|
|
483
545
|
writeSkillDir(skill, { vendors: ["cursor"] });
|
|
484
546
|
const gi = readFileSync(join(process.cwd(), ".gitignore"), "utf-8");
|
|
485
|
-
const matches = gi.match(
|
|
486
|
-
assert.equal(matches.length, 1, ".gitignore should have exactly one /skills/ entry");
|
|
547
|
+
const matches = gi.match(/\.agents\/skills\//g) || [];
|
|
548
|
+
assert.equal(matches.length, 1, ".gitignore should have exactly one .agents/skills/ entry");
|
|
487
549
|
});
|
|
488
550
|
|
|
489
551
|
it("appends to an existing .gitignore without clobbering", () => {
|
|
@@ -492,7 +554,7 @@ describe("writeSkillDir — happy path", () => {
|
|
|
492
554
|
const gi = readFileSync(join(process.cwd(), ".gitignore"), "utf-8");
|
|
493
555
|
assert.match(gi, /node_modules/);
|
|
494
556
|
assert.match(gi, /\.env/);
|
|
495
|
-
assert.match(gi,
|
|
557
|
+
assert.match(gi, /\.agents\/skills\//);
|
|
496
558
|
});
|
|
497
559
|
});
|
|
498
560
|
|
|
@@ -601,6 +663,95 @@ describe("writeSkillDir — update path", () => {
|
|
|
601
663
|
assert.ok(existsSync(join(result.written[0], "SKILL.md")));
|
|
602
664
|
assert.ok(!existsSync(`${targetDir}.tmp`));
|
|
603
665
|
});
|
|
666
|
+
|
|
667
|
+
// QA cross-PR review (#1252): the existing tests cover stale `.tmp/`
|
|
668
|
+
// recovery (a previous crash before the rename dance completed) and
|
|
669
|
+
// the `.tmp/`-occupied-by-a-file edge case, but NOT the symmetric
|
|
670
|
+
// case where `.old/` is left behind by a previous crash. The .old/
|
|
671
|
+
// dir is created in step 2 of the rename dance and removed in
|
|
672
|
+
// step 4 — a crash between the two leaves it stranded. The next
|
|
673
|
+
// writeSkillDir call detects-and-cleans it via the
|
|
674
|
+
// `if (existsSync(oldDir))` branch in writeSkillToDir.
|
|
675
|
+
//
|
|
676
|
+
// Skipping on Windows: the POSIX-only rename dance is the code path
|
|
677
|
+
// that exposes the stale-`.old/` branch. Windows uses a different
|
|
678
|
+
// `rmSync(targetDir) → renameSync(tmpDir, targetDir)` flow with no
|
|
679
|
+
// intermediate `.old/` step at all — there's nothing equivalent to
|
|
680
|
+
// verify.
|
|
681
|
+
it("succeeds when a stale .old/ from a prior crash is present", { skip: process.platform === "win32" }, () => {
|
|
682
|
+
// Pre-create the live target with v1 content.
|
|
683
|
+
writeSkillDir(minimalSkill(), { vendors: ["claudeCode"] });
|
|
684
|
+
|
|
685
|
+
const targetDir = resolvePlacementDir("claudeProject", "pdf-helper");
|
|
686
|
+
const oldDir = `${targetDir}.old`;
|
|
687
|
+
|
|
688
|
+
// Simulate a previous crash: a stale `.old/` survives next to the
|
|
689
|
+
// live target. The next write must detect-and-clean it before
|
|
690
|
+
// staging the current target into place. The crash artifact is a
|
|
691
|
+
// directory with a marker file we'll confirm is gone after the
|
|
692
|
+
// write; if the cleanup branch were skipped, the stale `.old/`
|
|
693
|
+
// would block the renameSync(targetDir, oldDir) step (POSIX
|
|
694
|
+
// rename onto an existing directory fails with ENOTEMPTY).
|
|
695
|
+
mkdirSync(oldDir, { recursive: true });
|
|
696
|
+
writeFileSync(join(oldDir, "ghost.txt"), "leftover from a prior crash");
|
|
697
|
+
|
|
698
|
+
// The actual write — should clean the stale `.old/` and complete.
|
|
699
|
+
const updated = minimalSkill({
|
|
700
|
+
files: [
|
|
701
|
+
{
|
|
702
|
+
path: "SKILL.md",
|
|
703
|
+
content: "---\nname: pdf-helper\ndescription: After recovery.\n---\n\nv2.\n",
|
|
704
|
+
},
|
|
705
|
+
],
|
|
706
|
+
});
|
|
707
|
+
const result = writeSkillDir(updated, { vendors: ["claudeCode"] });
|
|
708
|
+
|
|
709
|
+
// The new content is at the live target.
|
|
710
|
+
const skillMd = readFileSync(join(result.written[0], "SKILL.md"), "utf-8");
|
|
711
|
+
assert.match(skillMd, /v2/);
|
|
712
|
+
|
|
713
|
+
// The stale `.old/` is gone — including the ghost file marker.
|
|
714
|
+
assert.ok(!existsSync(oldDir), "stale .old/ must be cleaned during the next write");
|
|
715
|
+
// Step 4 of the rename dance also cleans the post-rename .old/,
|
|
716
|
+
// so the post-state is "no .old/ at all" regardless of which
|
|
717
|
+
// branch fired. We assert the absence; the marker-file path
|
|
718
|
+
// proves the cleanup was the pre-flight branch (a no-op cleanup
|
|
719
|
+
// would have left ghost.txt in place because the live target's
|
|
720
|
+
// own .old/ is fresh content from THIS write, not the marker).
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
it("succeeds when both stale .tmp/ AND stale .old/ from a prior crash are present", { skip: process.platform === "win32" }, () => {
|
|
724
|
+
// Compound recovery scenario: a prior write crashed mid-dance,
|
|
725
|
+
// leaving BOTH a stale .tmp/ (from step 1, populating new files)
|
|
726
|
+
// AND a stale .old/ (from step 2, after the live target was
|
|
727
|
+
// moved aside but step 3's rename never landed). The next write
|
|
728
|
+
// must clean both before proceeding.
|
|
729
|
+
writeSkillDir(minimalSkill(), { vendors: ["claudeCode"] });
|
|
730
|
+
|
|
731
|
+
const targetDir = resolvePlacementDir("claudeProject", "pdf-helper");
|
|
732
|
+
const tmpDir = `${targetDir}.tmp`;
|
|
733
|
+
const oldDir = `${targetDir}.old`;
|
|
734
|
+
|
|
735
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
736
|
+
writeFileSync(join(tmpDir, "tmp-leftover.txt"), "from a crashed step 1");
|
|
737
|
+
mkdirSync(oldDir, { recursive: true });
|
|
738
|
+
writeFileSync(join(oldDir, "old-leftover.txt"), "from a crashed step 2");
|
|
739
|
+
|
|
740
|
+
const updated = minimalSkill({
|
|
741
|
+
files: [
|
|
742
|
+
{
|
|
743
|
+
path: "SKILL.md",
|
|
744
|
+
content: "---\nname: pdf-helper\ndescription: Recovered.\n---\n\nv3.\n",
|
|
745
|
+
},
|
|
746
|
+
],
|
|
747
|
+
});
|
|
748
|
+
const result = writeSkillDir(updated, { vendors: ["claudeCode"] });
|
|
749
|
+
|
|
750
|
+
const skillMd = readFileSync(join(result.written[0], "SKILL.md"), "utf-8");
|
|
751
|
+
assert.match(skillMd, /v3/);
|
|
752
|
+
assert.ok(!existsSync(tmpDir), "stale .tmp/ must be cleaned");
|
|
753
|
+
assert.ok(!existsSync(oldDir), "stale .old/ must be cleaned");
|
|
754
|
+
});
|
|
604
755
|
});
|
|
605
756
|
|
|
606
757
|
describe("writeSkillDir — return shape", () => {
|
|
@@ -770,14 +921,14 @@ describe("cleanupOrphans", () => {
|
|
|
770
921
|
// Drop one orphan in each root we know about. .tmp/ entries need
|
|
771
922
|
// a live sibling so the safety invariant doesn't preserve them.
|
|
772
923
|
const claudeRoot = join(process.cwd(), ".claude", "skills");
|
|
773
|
-
const
|
|
924
|
+
const agentsRoot = join(process.cwd(), ".agents", "skills");
|
|
774
925
|
const globalRoot = join(process.env.HOME, ".claude", "skills");
|
|
775
926
|
mkdirSync(claudeRoot, { recursive: true });
|
|
776
|
-
mkdirSync(
|
|
927
|
+
mkdirSync(agentsRoot, { recursive: true });
|
|
777
928
|
mkdirSync(globalRoot, { recursive: true });
|
|
778
929
|
mkdirSync(join(claudeRoot, "ghost")); // live sibling for the .tmp below
|
|
779
930
|
mkdirSync(join(claudeRoot, "ghost.tmp"));
|
|
780
|
-
mkdirSync(join(
|
|
931
|
+
mkdirSync(join(agentsRoot, "ghost.old")); // .old has no invariant — always cleaned
|
|
781
932
|
mkdirSync(join(globalRoot, "ghost")); // live sibling for the .tmp below
|
|
782
933
|
mkdirSync(join(globalRoot, "ghost.tmp"));
|
|
783
934
|
|
|
@@ -795,4 +946,54 @@ describe("cleanupOrphans", () => {
|
|
|
795
946
|
const result = cleanupOrphans({ vendors: ["claudeCode"] });
|
|
796
947
|
assert.deepEqual(result.cleaned, []);
|
|
797
948
|
});
|
|
949
|
+
|
|
950
|
+
// QA cross-PR review (#1252): the permutation where BOTH .tmp/ and
|
|
951
|
+
// .old/ exist but the live target is MISSING was uncovered. This
|
|
952
|
+
// tests the divergence between the two recovery invariants:
|
|
953
|
+
// • .tmp/ — the user's only recovered copy of a crashed-mid-write
|
|
954
|
+
// skill when live target is missing → MUST be preserved
|
|
955
|
+
// (invariant #2 in the cleanupOrphans header docstring).
|
|
956
|
+
// • .old/ — transient state of the rename dance with no recovery
|
|
957
|
+
// value when the live target is missing AND a .tmp/ exists
|
|
958
|
+
// (the .tmp/ IS the recovery) → must be cleaned.
|
|
959
|
+
//
|
|
960
|
+
// If this test fails because cleanupOrphans cleans .tmp/ when
|
|
961
|
+
// live target is missing, that's a real regression of invariant #2
|
|
962
|
+
// — DO NOT change the test to match wrong behavior.
|
|
963
|
+
it("preserves .tmp/ but cleans .old/ when live target is missing", () => {
|
|
964
|
+
const root = join(process.cwd(), ".claude", "skills");
|
|
965
|
+
mkdirSync(root, { recursive: true });
|
|
966
|
+
|
|
967
|
+
// Stage the permutation: <name>.tmp/ + <name>.old/ + NO live <name>/
|
|
968
|
+
const tmpDir = join(root, "ghost.tmp");
|
|
969
|
+
const oldDir = join(root, "ghost.old");
|
|
970
|
+
mkdirSync(tmpDir);
|
|
971
|
+
writeFileSync(join(tmpDir, "tmp-content.txt"), "user's only copy of a crashed-mid-write skill");
|
|
972
|
+
mkdirSync(oldDir);
|
|
973
|
+
writeFileSync(join(oldDir, "old-content.txt"), "transient state with no recovery value");
|
|
974
|
+
|
|
975
|
+
// Live target is intentionally absent
|
|
976
|
+
assert.ok(!existsSync(join(root, "ghost")), "precondition: no live target");
|
|
977
|
+
|
|
978
|
+
const result = cleanupOrphans({ vendors: ["claudeCode"] });
|
|
979
|
+
|
|
980
|
+
// .tmp/ is preserved (recovery invariant)
|
|
981
|
+
assert.ok(
|
|
982
|
+
existsSync(tmpDir),
|
|
983
|
+
".tmp/ must be preserved when live target is missing — it is the user's only copy",
|
|
984
|
+
);
|
|
985
|
+
assert.ok(
|
|
986
|
+
existsSync(join(tmpDir, "tmp-content.txt")),
|
|
987
|
+
"the .tmp/ contents must survive intact",
|
|
988
|
+
);
|
|
989
|
+
|
|
990
|
+
// .old/ is cleaned (no recovery value because the .tmp/ IS the
|
|
991
|
+
// recovery; .old/ would be the pre-replacement live target,
|
|
992
|
+
// which is precisely what's missing here)
|
|
993
|
+
assert.ok(!existsSync(oldDir), ".old/ must be cleaned regardless of live-target presence");
|
|
994
|
+
|
|
995
|
+
// The summary reports exactly the .old/ cleanup
|
|
996
|
+
assert.equal(result.cleaned.length, 1, "exactly one orphan cleaned (.old/, not .tmp/)");
|
|
997
|
+
assert.match(result.cleaned[0], /ghost\.old$/);
|
|
998
|
+
});
|
|
798
999
|
});
|
|
@@ -19,7 +19,7 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync } from "nod
|
|
|
19
19
|
import { join } from "node:path";
|
|
20
20
|
import { tmpdir } from "node:os";
|
|
21
21
|
|
|
22
|
-
import { mergeMcpForVendors
|
|
22
|
+
import { mergeMcpForVendors } from "../../lib/mcp-merge.mjs";
|
|
23
23
|
import { createCaptureStream } from "../helpers/capture-stream.mjs";
|
|
24
24
|
import {
|
|
25
25
|
captureHome,
|
|
@@ -302,7 +302,8 @@ describe("mergeMcpForVendors — failure handling", () => {
|
|
|
302
302
|
});
|
|
303
303
|
|
|
304
304
|
it("dedupes duplicate vendors (round-1 review fix)", async () => {
|
|
305
|
-
// --
|
|
305
|
+
// --agent claude,claude used to run the merger twice. Now dedupes
|
|
306
|
+
// both at parse time and inside mergeMcpForVendors as defense-in-depth.
|
|
306
307
|
const results = await mergeMcpForVendors({
|
|
307
308
|
vendors: ["claudeCode", "claudeCode", "cursor", "cursor"],
|
|
308
309
|
mcpUrl: "https://x.com/mcp",
|
|
@@ -313,6 +314,26 @@ describe("mergeMcpForVendors — failure handling", () => {
|
|
|
313
314
|
assert.equal(results[0].vendor, "claudeCode");
|
|
314
315
|
assert.equal(results[1].vendor, "cursor");
|
|
315
316
|
});
|
|
317
|
+
|
|
318
|
+
it("silently skips registry vendors with hasMcp:false (gemini, codex, cline)", async () => {
|
|
319
|
+
// Cohort vendors without a documented MCP merger must NOT report
|
|
320
|
+
// 'failed' — they're a deliberate registry classification.
|
|
321
|
+
const results = await mergeMcpForVendors({
|
|
322
|
+
vendors: ["claudeCode", "gemini", "codex", "cline", "cursor"],
|
|
323
|
+
mcpUrl: "https://x.com/mcp",
|
|
324
|
+
yes: true,
|
|
325
|
+
io: { stdout, stderr },
|
|
326
|
+
});
|
|
327
|
+
const reportedKeys = results.map((r) => r.vendor);
|
|
328
|
+
assert.deepEqual(
|
|
329
|
+
reportedKeys,
|
|
330
|
+
["claudeCode", "cursor"],
|
|
331
|
+
"only the two MCP-supported vendors should appear in results",
|
|
332
|
+
);
|
|
333
|
+
for (const r of results) {
|
|
334
|
+
assert.equal(r.outcome, "merged", `${r.vendor} should be merged, got ${r.outcome}`);
|
|
335
|
+
}
|
|
336
|
+
});
|
|
316
337
|
});
|
|
317
338
|
|
|
318
339
|
// ── mergeMcpForVendors — input validation ──────────────────────────────
|
|
@@ -336,16 +357,3 @@ describe("mergeMcpForVendors — input validation", () => {
|
|
|
336
357
|
});
|
|
337
358
|
});
|
|
338
359
|
|
|
339
|
-
// ── printManualMcpInstructions ─────────────────────────────────────────
|
|
340
|
-
|
|
341
|
-
describe("printManualMcpInstructions", () => {
|
|
342
|
-
it("prints a copy-pasteable MCP config blob", () => {
|
|
343
|
-
const out = createCaptureStream();
|
|
344
|
-
printManualMcpInstructions("https://skillrepo.dev/api/mcp", { stdout: out });
|
|
345
|
-
const text = out.text();
|
|
346
|
-
assert.match(text, /mcpServers/);
|
|
347
|
-
assert.match(text, /skillrepo/);
|
|
348
|
-
assert.match(text, /https:\/\/skillrepo\.dev\/api\/mcp/);
|
|
349
|
-
assert.match(text, /SKILLREPO_ACCESS_KEY/);
|
|
350
|
-
});
|
|
351
|
-
});
|