skillrepo 4.9.2 → 4.11.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 +30 -3
- package/package.json +1 -1
- package/src/commands/init.mjs +18 -4
- package/src/lib/artifact-registry.mjs +28 -10
- package/src/lib/constants.mjs +11 -0
- package/src/lib/errors.mjs +23 -0
- package/src/lib/file-write.mjs +47 -47
- package/src/lib/foreign-content.mjs +461 -0
- package/src/lib/gitignore-section.mjs +223 -0
- package/src/lib/mergers/gitignore.mjs +22 -65
- package/src/lib/paths.mjs +11 -0
- package/src/lib/removers/gitignore.mjs +38 -3
- package/src/lib/skillset-declaration.mjs +477 -0
- package/src/lib/sync.mjs +179 -0
- package/src/test/commands/add.test.mjs +24 -0
- package/src/test/commands/get.test.mjs +24 -0
- package/src/test/commands/init.test.mjs +37 -0
- package/src/test/commands/update.test.mjs +194 -0
- package/src/test/e2e/cli-commands.test.mjs +63 -0
- package/src/test/lib/errors.test.mjs +28 -0
- package/src/test/lib/file-write.test.mjs +1 -1
- package/src/test/lib/foreign-content.test.mjs +522 -0
- package/src/test/lib/gitignore-section.test.mjs +213 -0
- package/src/test/lib/skillset-declaration.test.mjs +514 -0
- package/src/test/lib/sync.test.mjs +635 -0
- package/src/test/mergers/uninstall-gitignore.test.mjs +65 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for src/lib/gitignore-section.mjs (#2361).
|
|
3
|
+
*
|
|
4
|
+
* The single marked-section writer shared by the init merger and the
|
|
5
|
+
* sync-time pre-flight. Attribution-sensitive: every CLI-authored line
|
|
6
|
+
* must land inside the ONE marked section (never a duplicate header),
|
|
7
|
+
* the retired pre-4.10.0 sync-time section must be healed into it, and
|
|
8
|
+
* user content must survive byte-exact outside the CLI's own lines.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
import { mkdtempSync, writeFileSync, readFileSync, rmSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
ensureGitignoreEntries,
|
|
19
|
+
ensureManagedRootIgnores,
|
|
20
|
+
MANAGED_ROOT_ENTRIES,
|
|
21
|
+
} from "../../lib/gitignore-section.mjs";
|
|
22
|
+
import {
|
|
23
|
+
GITIGNORE_SECTION_HEADER,
|
|
24
|
+
LEGACY_GITIGNORE_SECTION_HEADER,
|
|
25
|
+
} from "../../lib/artifact-registry.mjs";
|
|
26
|
+
|
|
27
|
+
let originalCwd;
|
|
28
|
+
let tempDir;
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
originalCwd = process.cwd;
|
|
32
|
+
tempDir = mkdtempSync(join(tmpdir(), "cli-gi-section-"));
|
|
33
|
+
process.cwd = () => tempDir;
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
process.cwd = originalCwd;
|
|
38
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
function gitignore() {
|
|
42
|
+
return readFileSync(join(tempDir, ".gitignore"), "utf-8");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe("ensureGitignoreEntries — section management", () => {
|
|
46
|
+
it("creates a fresh file with one marked section", () => {
|
|
47
|
+
const result = ensureGitignoreEntries([".claude/skills/"]);
|
|
48
|
+
assert.equal(result.action, "created");
|
|
49
|
+
assert.deepEqual(result.added, [".claude/skills/"]);
|
|
50
|
+
const content = gitignore();
|
|
51
|
+
assert.equal(
|
|
52
|
+
content,
|
|
53
|
+
`${GITIGNORE_SECTION_HEADER}\n.claude/skills/\n`,
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("inserts missing entries INTO an existing marked section — no duplicate header", () => {
|
|
58
|
+
writeFileSync(
|
|
59
|
+
join(tempDir, ".gitignore"),
|
|
60
|
+
`node_modules/\n\n${GITIGNORE_SECTION_HEADER}\n.env.local\n\nuser-tail/\n`,
|
|
61
|
+
);
|
|
62
|
+
const result = ensureGitignoreEntries([".agents/skills/"]);
|
|
63
|
+
assert.equal(result.action, "updated");
|
|
64
|
+
const content = gitignore();
|
|
65
|
+
const headers = content.match(
|
|
66
|
+
new RegExp(GITIGNORE_SECTION_HEADER.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"),
|
|
67
|
+
);
|
|
68
|
+
assert.equal(headers.length, 1, "must never write a second header");
|
|
69
|
+
// The new entry sits inside the section: after .env.local, before
|
|
70
|
+
// the blank line that closes the section.
|
|
71
|
+
assert.match(content, /\.env\.local\n\.agents\/skills\/\n\nuser-tail\//);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("appends a new section when no header exists, separated by one blank line", () => {
|
|
75
|
+
writeFileSync(join(tempDir, ".gitignore"), "node_modules/\n");
|
|
76
|
+
ensureGitignoreEntries([".claude/skills/"]);
|
|
77
|
+
assert.equal(
|
|
78
|
+
gitignore(),
|
|
79
|
+
`node_modules/\n\n${GITIGNORE_SECTION_HEADER}\n.claude/skills/\n`,
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("is idempotent — a second identical call reports skipped and does not rewrite", () => {
|
|
84
|
+
ensureGitignoreEntries([".claude/skills/"]);
|
|
85
|
+
const before = gitignore();
|
|
86
|
+
const result = ensureGitignoreEntries([".claude/skills/"]);
|
|
87
|
+
assert.equal(result.action, "skipped");
|
|
88
|
+
assert.equal(gitignore(), before);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("honors an entry the user already has anywhere in the file", () => {
|
|
92
|
+
writeFileSync(join(tempDir, ".gitignore"), ".claude/skills/\n");
|
|
93
|
+
const result = ensureGitignoreEntries([".claude/skills/"]);
|
|
94
|
+
assert.equal(result.action, "skipped");
|
|
95
|
+
assert.equal(gitignore(), ".claude/skills/\n");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("preserves CRLF line endings", () => {
|
|
99
|
+
writeFileSync(
|
|
100
|
+
join(tempDir, ".gitignore"),
|
|
101
|
+
"node_modules/\r\n.env\r\n",
|
|
102
|
+
);
|
|
103
|
+
ensureGitignoreEntries([".claude/skills/"]);
|
|
104
|
+
const content = gitignore();
|
|
105
|
+
assert.ok(content.includes("\r\n"), "CRLF must be preserved");
|
|
106
|
+
assert.ok(
|
|
107
|
+
!/[^\r]\n/.test(content.replace(/\r\n/g, "")),
|
|
108
|
+
"no mixed line endings introduced",
|
|
109
|
+
);
|
|
110
|
+
assert.match(content, /\.claude\/skills\//);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("does not treat a comment mentioning an entry as the entry", () => {
|
|
114
|
+
writeFileSync(join(tempDir, ".gitignore"), "# .claude/skills/ is synced\n");
|
|
115
|
+
const result = ensureGitignoreEntries([".claude/skills/"]);
|
|
116
|
+
assert.equal(result.action, "updated");
|
|
117
|
+
assert.deepEqual(result.added, [".claude/skills/"]);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("ensureGitignoreEntries — legacy sync-time section heal", () => {
|
|
122
|
+
it("migrates the exact legacy shape into the marked section", () => {
|
|
123
|
+
writeFileSync(
|
|
124
|
+
join(tempDir, ".gitignore"),
|
|
125
|
+
`node_modules/\n\n${LEGACY_GITIGNORE_SECTION_HEADER}\n.agents/skills/\n`,
|
|
126
|
+
);
|
|
127
|
+
const result = ensureGitignoreEntries([".agents/skills/"]);
|
|
128
|
+
assert.equal(result.action, "updated");
|
|
129
|
+
const content = gitignore();
|
|
130
|
+
assert.ok(
|
|
131
|
+
!content.includes(LEGACY_GITIGNORE_SECTION_HEADER),
|
|
132
|
+
"legacy header must be healed away",
|
|
133
|
+
);
|
|
134
|
+
assert.match(content, /\.agents\/skills\//);
|
|
135
|
+
assert.ok(content.includes(GITIGNORE_SECTION_HEADER));
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("reports updated (and heals) even when no entries are missing", () => {
|
|
139
|
+
writeFileSync(
|
|
140
|
+
join(tempDir, ".gitignore"),
|
|
141
|
+
`${GITIGNORE_SECTION_HEADER}\n.agents/skills/\n\n${LEGACY_GITIGNORE_SECTION_HEADER}\n.agents/skills/\n`,
|
|
142
|
+
);
|
|
143
|
+
const result = ensureGitignoreEntries([".agents/skills/"]);
|
|
144
|
+
assert.equal(result.action, "updated");
|
|
145
|
+
assert.deepEqual(result.added, []);
|
|
146
|
+
const content = gitignore();
|
|
147
|
+
assert.ok(!content.includes(LEGACY_GITIGNORE_SECTION_HEADER));
|
|
148
|
+
// Exactly one .agents/skills/ line remains (the marked-section one).
|
|
149
|
+
const matches = content.match(/\.agents\/skills\//g);
|
|
150
|
+
assert.equal(matches.length, 1);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("never removes user content sitting under the legacy header", () => {
|
|
154
|
+
writeFileSync(
|
|
155
|
+
join(tempDir, ".gitignore"),
|
|
156
|
+
`${LEGACY_GITIGNORE_SECTION_HEADER}\nmy-own-line/\n.agents/skills/\n`,
|
|
157
|
+
);
|
|
158
|
+
ensureGitignoreEntries([".agents/skills/"]);
|
|
159
|
+
const content = gitignore();
|
|
160
|
+
// Only the header vanished — the user line survives, and the
|
|
161
|
+
// `.agents/skills/` line NOT directly under the header survives
|
|
162
|
+
// too (it satisfies the ensure, so no insertion happened).
|
|
163
|
+
assert.match(content, /my-own-line\//);
|
|
164
|
+
assert.match(content, /\.agents\/skills\//);
|
|
165
|
+
assert.ok(!content.includes(LEGACY_GITIGNORE_SECTION_HEADER));
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe("ensureManagedRootIgnores — target mapping", () => {
|
|
170
|
+
it("writes both project-root entries for claudeProject + agentsProject", () => {
|
|
171
|
+
const result = ensureManagedRootIgnores(["claudeProject", "agentsProject"]);
|
|
172
|
+
assert.equal(result.action, "created");
|
|
173
|
+
assert.deepEqual(
|
|
174
|
+
[...result.added].sort(),
|
|
175
|
+
[".agents/skills/", ".claude/skills/"],
|
|
176
|
+
);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("is a no-op for global-only targets", () => {
|
|
180
|
+
const result = ensureManagedRootIgnores(["claudeGlobal", "agentsGlobal", "windsurfGlobal"]);
|
|
181
|
+
assert.equal(result.action, "skipped");
|
|
182
|
+
assert.deepEqual(result.added, []);
|
|
183
|
+
assert.throws(() => readFileSync(join(tempDir, ".gitignore")));
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("is a no-op for an empty or missing target list", () => {
|
|
187
|
+
assert.equal(ensureManagedRootIgnores([]).action, "skipped");
|
|
188
|
+
assert.equal(ensureManagedRootIgnores(undefined).action, "skipped");
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("every managed-root entry is in the artifact registry's expected set", async () => {
|
|
192
|
+
// Cross-consistency: the uninstall remover strips the whole marked
|
|
193
|
+
// section, but GITIGNORE_REQUIRED_ENTRIES is the documented
|
|
194
|
+
// expected-set contract — a managed-root entry the registry does
|
|
195
|
+
// not know about is drift (#885 / #2361).
|
|
196
|
+
const { GITIGNORE_REQUIRED_ENTRIES } = await import(
|
|
197
|
+
"../../lib/artifact-registry.mjs"
|
|
198
|
+
);
|
|
199
|
+
for (const entry of Object.values(MANAGED_ROOT_ENTRIES)) {
|
|
200
|
+
assert.ok(
|
|
201
|
+
GITIGNORE_REQUIRED_ENTRIES.includes(entry),
|
|
202
|
+
`${entry} missing from GITIGNORE_REQUIRED_ENTRIES`,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("covers every project-scope managed root exactly once in MANAGED_ROOT_ENTRIES", () => {
|
|
208
|
+
assert.deepEqual(Object.keys(MANAGED_ROOT_ENTRIES).sort(), [
|
|
209
|
+
"agentsProject",
|
|
210
|
+
"claudeProject",
|
|
211
|
+
]);
|
|
212
|
+
});
|
|
213
|
+
});
|