skillrepo 3.2.0 → 4.1.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 +137 -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-cohort-hooks.mjs +127 -0
- package/src/commands/init-session-sync.mjs +1 -1
- package/src/commands/init.mjs +480 -117
- package/src/commands/list.mjs +1 -1
- package/src/commands/remove.mjs +10 -2
- package/src/commands/uninstall.mjs +13 -2
- package/src/commands/update.mjs +112 -19
- package/src/lib/agent-hook-merge.mjs +203 -0
- package/src/lib/agent-registry.mjs +399 -0
- package/src/lib/artifact-registry.mjs +111 -2
- 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/fs-utils.mjs +16 -1
- package/src/lib/mcp-merge.mjs +17 -36
- package/src/lib/mergers/agent-hook-claude-shape.mjs +519 -0
- package/src/lib/mergers/agent-hook-cursor-shape.mjs +318 -0
- 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/removers/agent-hooks.mjs +83 -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 +508 -41
- package/src/test/commands/remove.test.mjs +4 -1
- package/src/test/commands/update.test.mjs +148 -3
- package/src/test/e2e/cli-agent-permutations.test.mjs +631 -0
- package/src/test/e2e/cli-cohort-hooks.test.mjs +393 -0
- package/src/test/e2e/cli-commands.test.mjs +39 -13
- package/src/test/integration/agent-hooks.integration.test.mjs +340 -0
- package/src/test/integration/file-write.integration.test.mjs +31 -10
- package/src/test/lib/agent-hook-merge.test.mjs +172 -0
- package/src/test/lib/agent-registry.test.mjs +215 -0
- package/src/test/lib/artifact-registry.test.mjs +39 -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/test/mergers/agent-hook-claude-shape.test.mjs +518 -0
- package/src/test/mergers/agent-hook-cursor-shape.test.mjs +306 -0
- package/src/test/removers/agent-hooks.test.mjs +206 -0
- package/src/lib/detect-ides.mjs +0 -44
- package/src/test/detect-ides.test.mjs +0 -65
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SessionStart-hook installer/uninstaller for cursor-shape vendors
|
|
3
|
+
* (#1240). Currently only Cursor itself, but the shape is documented
|
|
4
|
+
* separately so a future vendor that ships the same flat-array
|
|
5
|
+
* sessionStart schema slots in without code changes.
|
|
6
|
+
*
|
|
7
|
+
* Cursor docs 2026-05 specify the file shape:
|
|
8
|
+
*
|
|
9
|
+
* {
|
|
10
|
+
* "version": 1,
|
|
11
|
+
* "hooks": {
|
|
12
|
+
* "sessionStart": [
|
|
13
|
+
* { "command": "..." }
|
|
14
|
+
* ]
|
|
15
|
+
* }
|
|
16
|
+
* }
|
|
17
|
+
*
|
|
18
|
+
* Differences from the claude-shape merger:
|
|
19
|
+
*
|
|
20
|
+
* - `version: 1` at the top level. The merger preserves it on
|
|
21
|
+
* re-write and creates it for fresh files. A future Cursor
|
|
22
|
+
* `version: 2` is a real possibility — when that lands, the
|
|
23
|
+
* migration is a separate PR that bumps the writer here AND
|
|
24
|
+
* adds an upgrade-path test that proves both shapes round-trip
|
|
25
|
+
* through the remover.
|
|
26
|
+
* - Single-level `hooks.<event>` array (no nested `{ hooks: [...] }`
|
|
27
|
+
* groups). Walks are correspondingly shallower.
|
|
28
|
+
* - Event-name casing differs: lowercase `sessionStart` per Cursor
|
|
29
|
+
* docs. Comes from the agent registry's `agentHook.eventName`.
|
|
30
|
+
* - Multi-tool merge surface: 1Password, Snyk, and Apiiro all
|
|
31
|
+
* extend `~/.cursor/hooks.json`. The walk MUST preserve unknown
|
|
32
|
+
* entries — round-trip preservation is the load-bearing
|
|
33
|
+
* idempotency test.
|
|
34
|
+
*
|
|
35
|
+
* The shared concerns (atomic writes, fingerprint matching, idempotent
|
|
36
|
+
* actions, dispatcher integration) are intentionally implemented
|
|
37
|
+
* twice — once here, once in `agent-hook-claude-shape.mjs` — rather
|
|
38
|
+
* than abstracted. The architect review on #1240 specifically called
|
|
39
|
+
* out that a single-function `variant` switch becomes load-bearing
|
|
40
|
+
* config that no type system enforces; per-shape modules are the
|
|
41
|
+
* idiom that scales as new variants land.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
45
|
+
import { writeFileAtomic } from "../fs-utils.mjs";
|
|
46
|
+
import {
|
|
47
|
+
AGENT_HOOK_COMMAND,
|
|
48
|
+
AGENT_HOOK_FINGERPRINT,
|
|
49
|
+
} from "../artifact-registry.mjs";
|
|
50
|
+
import { getAgentByKey } from "../agent-registry.mjs";
|
|
51
|
+
import { diskError, validationError } from "../errors.mjs";
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Build the per-hook entry the cursor-shape merger writes. The
|
|
55
|
+
* `entryFields` from the agent registry's `agentHook.entryFields` are
|
|
56
|
+
* spread BEFORE `command` so the registry can NEVER override the
|
|
57
|
+
* canonical command via a typo. Cursor itself has no extra entry
|
|
58
|
+
* fields today, but the spread is symmetrical with the claude-shape
|
|
59
|
+
* merger to keep both code paths consistent.
|
|
60
|
+
*
|
|
61
|
+
* @param {object} entryFields
|
|
62
|
+
* @returns {Record<string, unknown>}
|
|
63
|
+
*/
|
|
64
|
+
export function buildCursorShapeEntry(entryFields = {}) {
|
|
65
|
+
return {
|
|
66
|
+
...entryFields,
|
|
67
|
+
command: AGENT_HOOK_COMMAND,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Install (or refresh) the cohort SessionStart hook in a cursor-shape
|
|
73
|
+
* vendor's hook config file. Creates the file with `version: 1` at
|
|
74
|
+
* the top level if it doesn't exist.
|
|
75
|
+
*
|
|
76
|
+
* @param {object} options
|
|
77
|
+
* @param {string} options.vendorKey
|
|
78
|
+
* @returns {{
|
|
79
|
+
* path: string;
|
|
80
|
+
* action: "installed" | "updated" | "unchanged";
|
|
81
|
+
* command: string;
|
|
82
|
+
* }}
|
|
83
|
+
*/
|
|
84
|
+
export function mergeCursorShapeAgentHook({ vendorKey }) {
|
|
85
|
+
const { hookSpec } = resolveAgent(vendorKey);
|
|
86
|
+
const filePath = hookSpec.pathFn();
|
|
87
|
+
const eventName = hookSpec.eventName;
|
|
88
|
+
const desiredEntry = buildCursorShapeEntry(hookSpec.entryFields);
|
|
89
|
+
|
|
90
|
+
const config = readConfigOrEmpty(filePath, hookSpec.displayPath);
|
|
91
|
+
|
|
92
|
+
// Preserve any existing `version` value if the file already had one;
|
|
93
|
+
// otherwise default to 1 per Cursor's documented shape. A future
|
|
94
|
+
// user manually setting `version: 2` does NOT get silently demoted
|
|
95
|
+
// — we only set the default on a brand-new file.
|
|
96
|
+
if (config.version === undefined) {
|
|
97
|
+
config.version = 1;
|
|
98
|
+
}
|
|
99
|
+
if (!config.hooks || typeof config.hooks !== "object") {
|
|
100
|
+
config.hooks = {};
|
|
101
|
+
}
|
|
102
|
+
if (!Array.isArray(config.hooks[eventName])) {
|
|
103
|
+
config.hooks[eventName] = [];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Exhaustive walk: drop any prior SkillRepo entries beyond the
|
|
107
|
+
// first match. The remover strips ALL fingerprint-matching entries,
|
|
108
|
+
// so the installer also has to handle ALL of them — otherwise a
|
|
109
|
+
// prior buggy run that left two ghosts would leave one behind on
|
|
110
|
+
// re-install. Cheap defense; aligns the installer-remover
|
|
111
|
+
// invariant ("exactly one SkillRepo entry per cohort hook config").
|
|
112
|
+
let primaryHandled = false;
|
|
113
|
+
let foundChanged = false;
|
|
114
|
+
let foundUnchangedExact = false;
|
|
115
|
+
|
|
116
|
+
const survivors = [];
|
|
117
|
+
let arrayMutated = false;
|
|
118
|
+
for (const inner of config.hooks[eventName]) {
|
|
119
|
+
if (
|
|
120
|
+
inner &&
|
|
121
|
+
typeof inner === "object" &&
|
|
122
|
+
typeof inner.command === "string" &&
|
|
123
|
+
inner.command.includes(AGENT_HOOK_FINGERPRINT)
|
|
124
|
+
) {
|
|
125
|
+
if (!primaryHandled) {
|
|
126
|
+
primaryHandled = true;
|
|
127
|
+
if (entriesEqual(inner, desiredEntry)) {
|
|
128
|
+
foundUnchangedExact = true;
|
|
129
|
+
survivors.push(inner);
|
|
130
|
+
} else {
|
|
131
|
+
foundChanged = true;
|
|
132
|
+
arrayMutated = true; // in-place replacement at same index
|
|
133
|
+
survivors.push(desiredEntry);
|
|
134
|
+
}
|
|
135
|
+
} else {
|
|
136
|
+
// Duplicate from a prior buggy run — drop it.
|
|
137
|
+
foundChanged = true;
|
|
138
|
+
arrayMutated = true; // length-changing drop
|
|
139
|
+
}
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
survivors.push(inner);
|
|
143
|
+
}
|
|
144
|
+
// Write back when we mutated the array — either by replacing the
|
|
145
|
+
// primary entry in place (same length) or by dropping duplicates
|
|
146
|
+
// (smaller length). A length-only check would miss the
|
|
147
|
+
// replacement case.
|
|
148
|
+
if (arrayMutated) {
|
|
149
|
+
config.hooks[eventName] = survivors;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
let matchedAction;
|
|
153
|
+
if (!primaryHandled) {
|
|
154
|
+
config.hooks[eventName].push(desiredEntry);
|
|
155
|
+
matchedAction = "installed";
|
|
156
|
+
} else if (foundChanged) {
|
|
157
|
+
matchedAction = "updated";
|
|
158
|
+
} else if (foundUnchangedExact) {
|
|
159
|
+
matchedAction = "unchanged";
|
|
160
|
+
} else {
|
|
161
|
+
matchedAction = "updated";
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (matchedAction === "unchanged") {
|
|
165
|
+
return {
|
|
166
|
+
path: hookSpec.displayPath,
|
|
167
|
+
action: "unchanged",
|
|
168
|
+
command: AGENT_HOOK_COMMAND,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
writeFileAtomic(filePath, JSON.stringify(config, null, 2) + "\n");
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
path: hookSpec.displayPath,
|
|
176
|
+
action: matchedAction,
|
|
177
|
+
command: AGENT_HOOK_COMMAND,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Strip the SkillRepo cohort hook from a cursor-shape vendor's hook
|
|
183
|
+
* config. Idempotent — non-SkillRepo entries and unknown event arrays
|
|
184
|
+
* are never touched.
|
|
185
|
+
*
|
|
186
|
+
* @param {object} options
|
|
187
|
+
* @param {string} options.vendorKey
|
|
188
|
+
* @param {boolean} [options.dryRun=false]
|
|
189
|
+
* @returns {{
|
|
190
|
+
* path: string;
|
|
191
|
+
* action: "removed" | "would-remove" | "skipped" | "unchanged";
|
|
192
|
+
* error?: string;
|
|
193
|
+
* }}
|
|
194
|
+
*/
|
|
195
|
+
export function unmergeCursorShapeAgentHook({ vendorKey, dryRun = false }) {
|
|
196
|
+
const { hookSpec } = resolveAgent(vendorKey);
|
|
197
|
+
const filePath = hookSpec.pathFn();
|
|
198
|
+
const eventName = hookSpec.eventName;
|
|
199
|
+
|
|
200
|
+
if (!existsSync(filePath)) {
|
|
201
|
+
return { path: hookSpec.displayPath, action: "skipped" };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
205
|
+
if (raw.trim().length === 0) {
|
|
206
|
+
return { path: hookSpec.displayPath, action: "unchanged" };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
let config;
|
|
210
|
+
try {
|
|
211
|
+
config = JSON.parse(raw);
|
|
212
|
+
} catch (err) {
|
|
213
|
+
return {
|
|
214
|
+
path: hookSpec.displayPath,
|
|
215
|
+
action: "skipped",
|
|
216
|
+
error: `Cannot parse ${hookSpec.displayPath}: ${err.message}. Fix or delete the file and re-run.`,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (
|
|
221
|
+
!config ||
|
|
222
|
+
typeof config !== "object" ||
|
|
223
|
+
!config.hooks ||
|
|
224
|
+
typeof config.hooks !== "object" ||
|
|
225
|
+
!Array.isArray(config.hooks[eventName])
|
|
226
|
+
) {
|
|
227
|
+
return { path: hookSpec.displayPath, action: "unchanged" };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const beforeCount = config.hooks[eventName].length;
|
|
231
|
+
const survivors = config.hooks[eventName].filter((h) => {
|
|
232
|
+
if (!h || typeof h !== "object" || typeof h.command !== "string") {
|
|
233
|
+
return true;
|
|
234
|
+
}
|
|
235
|
+
return !h.command.includes(AGENT_HOOK_FINGERPRINT);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
if (survivors.length === beforeCount) {
|
|
239
|
+
return { path: hookSpec.displayPath, action: "unchanged" };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (dryRun) {
|
|
243
|
+
return { path: hookSpec.displayPath, action: "would-remove" };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (survivors.length === 0) {
|
|
247
|
+
delete config.hooks[eventName];
|
|
248
|
+
} else {
|
|
249
|
+
config.hooks[eventName] = survivors;
|
|
250
|
+
}
|
|
251
|
+
if (Object.keys(config.hooks).length === 0) {
|
|
252
|
+
delete config.hooks;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
writeFileAtomic(filePath, JSON.stringify(config, null, 2) + "\n");
|
|
256
|
+
return { path: hookSpec.displayPath, action: "removed" };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ── Internal helpers ───────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
function resolveAgent(vendorKey) {
|
|
262
|
+
const entry = getAgentByKey(vendorKey);
|
|
263
|
+
if (!entry) {
|
|
264
|
+
throw validationError(
|
|
265
|
+
`Unknown agent key: ${vendorKey}. Add it to AGENT_REGISTRY.`,
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
if (!entry.agentHook) {
|
|
269
|
+
throw validationError(
|
|
270
|
+
`Agent "${vendorKey}" has no agentHook spec — cannot install a SessionStart hook.`,
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
if (entry.agentHook.shape !== "cursor-shape") {
|
|
274
|
+
throw validationError(
|
|
275
|
+
`Agent "${vendorKey}" has shape "${entry.agentHook.shape}", expected "cursor-shape".`,
|
|
276
|
+
{
|
|
277
|
+
hint: "Use the matching shape merger (e.g. mergeClaudeShapeAgentHook).",
|
|
278
|
+
},
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
return { entry, hookSpec: entry.agentHook };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function readConfigOrEmpty(filePath, displayPath) {
|
|
285
|
+
if (!existsSync(filePath)) return {};
|
|
286
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
287
|
+
if (raw.trim().length === 0) return {};
|
|
288
|
+
let parsed;
|
|
289
|
+
try {
|
|
290
|
+
parsed = JSON.parse(raw);
|
|
291
|
+
} catch (err) {
|
|
292
|
+
throw diskError(
|
|
293
|
+
`Cannot parse ${displayPath}: ${err.message}. Fix or delete the file, then re-run.`,
|
|
294
|
+
{ cause: err },
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
298
|
+
throw diskError(
|
|
299
|
+
`${displayPath} must be a JSON object at the top level.`,
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
return parsed;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function entriesEqual(a, b) {
|
|
306
|
+
return stableJson(a) === stableJson(b);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function stableJson(value) {
|
|
310
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
311
|
+
return JSON.stringify(value);
|
|
312
|
+
}
|
|
313
|
+
const sortedKeys = Object.keys(value).sort();
|
|
314
|
+
const parts = sortedKeys.map(
|
|
315
|
+
(k) => `${JSON.stringify(k)}:${stableJson(value[k])}`,
|
|
316
|
+
);
|
|
317
|
+
return `{${parts.join(",")}}`;
|
|
318
|
+
}
|
|
@@ -1,22 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Merger for .gitignore — adds the
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* This module is v3.0.0-rewritten. The v2.0.0 version added a single
|
|
6
|
-
* `.claude/rules/skillrepo-*.md` pattern for the now-deleted rules
|
|
7
|
-
* delivery flow. The hooks were removed in #835 and the rules-delivery
|
|
8
|
-
* model was replaced with direct skill syncing to `.claude/skills/`.
|
|
9
|
-
* The three paths this merger adds are:
|
|
2
|
+
* Merger for .gitignore — adds the paths `skillrepo init` writes that
|
|
3
|
+
* must not be committed.
|
|
10
4
|
*
|
|
5
|
+
* Always added:
|
|
11
6
|
* - `.env.local` — contains SKILLREPO_ACCESS_KEY, a live credential
|
|
12
|
-
* - `.claude/skills/` — per-developer synced library content
|
|
13
7
|
* - `.claude/settings.local.json` — Claude Code per-user settings
|
|
14
8
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* that the docs promised this behavior but the CLI never actually
|
|
19
|
-
* wrote the entries — this merger closes the gap.
|
|
9
|
+
* Conditionally added based on the `vendors` option:
|
|
10
|
+
* - `.claude/skills/` — when any vendor maps to `claudeProject`
|
|
11
|
+
* - `.agents/skills/` — when any vendor maps to `agentsProject`
|
|
20
12
|
*
|
|
21
13
|
* Idempotent: entries already present are skipped. A single call
|
|
22
14
|
* either adds all missing entries in one grouped section or exits
|
|
@@ -24,35 +16,70 @@
|
|
|
24
16
|
*/
|
|
25
17
|
|
|
26
18
|
import { readFileSafe, writeFileSafe } from "../fs-utils.mjs";
|
|
27
|
-
import {
|
|
19
|
+
import { gitignorePath } from "../paths.mjs";
|
|
20
|
+
import { placementTargetsFor } from "../file-write.mjs";
|
|
28
21
|
|
|
29
22
|
const SECTION_HEADER = "# SkillRepo CLI (added by `skillrepo init`)";
|
|
30
|
-
const
|
|
23
|
+
const ALWAYS_ENTRIES = Object.freeze([
|
|
31
24
|
".env.local",
|
|
32
|
-
".claude/skills/",
|
|
33
25
|
".claude/settings.local.json",
|
|
34
|
-
];
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Compute the gitignore entries to ensure based on the placement
|
|
30
|
+
* targets the caller will write to. When no vendors are passed, fall
|
|
31
|
+
* back to the historical default of `.claude/skills/` so existing
|
|
32
|
+
* call sites that haven't been threaded through `vendors` keep
|
|
33
|
+
* working.
|
|
34
|
+
*
|
|
35
|
+
* @param {{ vendors?: string[], global?: boolean }} options
|
|
36
|
+
* @returns {string[]}
|
|
37
|
+
*/
|
|
38
|
+
function entriesFor(options) {
|
|
39
|
+
const entries = [...ALWAYS_ENTRIES];
|
|
40
|
+
if (!Array.isArray(options.vendors) || options.vendors.length === 0) {
|
|
41
|
+
entries.push(".claude/skills/");
|
|
42
|
+
return entries;
|
|
43
|
+
}
|
|
44
|
+
let targets;
|
|
45
|
+
try {
|
|
46
|
+
targets = placementTargetsFor({
|
|
47
|
+
vendors: options.vendors,
|
|
48
|
+
global: !!options.global,
|
|
49
|
+
});
|
|
50
|
+
} catch {
|
|
51
|
+
// Caller passed an unknown vendor; fall back to the always-set
|
|
52
|
+
// entries plus `.claude/skills/` so we never under-protect.
|
|
53
|
+
entries.push(".claude/skills/");
|
|
54
|
+
return entries;
|
|
55
|
+
}
|
|
56
|
+
if (targets.includes("claudeProject")) entries.push(".claude/skills/");
|
|
57
|
+
if (targets.includes("agentsProject")) entries.push(".agents/skills/");
|
|
58
|
+
return entries;
|
|
59
|
+
}
|
|
35
60
|
|
|
36
61
|
/**
|
|
37
|
-
* Ensure the
|
|
38
|
-
*
|
|
62
|
+
* Ensure the init-required paths are in .gitignore. Creates the file
|
|
63
|
+
* if it doesn't exist. Idempotent — returns `"skipped"` if every
|
|
39
64
|
* required entry is already present, `"created"` if the file didn't
|
|
40
65
|
* exist, `"updated"` if it did and at least one entry was missing.
|
|
41
66
|
*
|
|
67
|
+
* @param {{ vendors?: string[], global?: boolean }} [options]
|
|
42
68
|
* @returns {{ path: string; action: "created" | "updated" | "skipped"; added: string[] }}
|
|
43
69
|
*/
|
|
44
|
-
export function mergeGitignore() {
|
|
45
|
-
const
|
|
46
|
-
const
|
|
70
|
+
export function mergeGitignore(options = {}) {
|
|
71
|
+
const requiredEntries = entriesFor(options);
|
|
72
|
+
const filePath = gitignorePath();
|
|
73
|
+
const existing = readFileSafe(filePath);
|
|
47
74
|
|
|
48
75
|
if (existing === null) {
|
|
49
76
|
// Fresh .gitignore — write all required entries as one section.
|
|
50
|
-
const content = renderSection(
|
|
51
|
-
writeFileSafe(
|
|
77
|
+
const content = renderSection(requiredEntries);
|
|
78
|
+
writeFileSafe(filePath, content);
|
|
52
79
|
return {
|
|
53
80
|
path: ".gitignore",
|
|
54
81
|
action: "created",
|
|
55
|
-
added: [...
|
|
82
|
+
added: [...requiredEntries],
|
|
56
83
|
};
|
|
57
84
|
}
|
|
58
85
|
|
|
@@ -60,7 +87,7 @@ export function mergeGitignore() {
|
|
|
60
87
|
// `.env.local.backup` or a comment `# .env.local` — split on
|
|
61
88
|
// newlines and trim so we match the entry literally.
|
|
62
89
|
const lines = existing.split(/\r?\n/).map((l) => l.trim());
|
|
63
|
-
const missing =
|
|
90
|
+
const missing = requiredEntries.filter((entry) => !lines.includes(entry));
|
|
64
91
|
|
|
65
92
|
if (missing.length === 0) {
|
|
66
93
|
return {
|
|
@@ -75,7 +102,7 @@ export function mergeGitignore() {
|
|
|
75
102
|
const lineEnding = existing.includes("\r\n") ? "\r\n" : "\n";
|
|
76
103
|
const separator = existing.endsWith("\n") ? "" : lineEnding;
|
|
77
104
|
const block = renderSection(missing, lineEnding);
|
|
78
|
-
writeFileSafe(
|
|
105
|
+
writeFileSafe(filePath, existing + separator + lineEnding + block);
|
|
79
106
|
|
|
80
107
|
return {
|
|
81
108
|
path: ".gitignore",
|
package/src/lib/paths.mjs
CHANGED
|
@@ -28,25 +28,13 @@ export const vscodeMcpJson = () => join(cwd(), ".vscode", "mcp.json");
|
|
|
28
28
|
export const globalConfigPath = () => join(homedir(), ".claude", "skillrepo", "config.json");
|
|
29
29
|
export const globalLastSyncPath = () => join(homedir(), ".claude", "skillrepo", ".last-sync");
|
|
30
30
|
|
|
31
|
-
// ── Skill placement targets
|
|
31
|
+
// ── Skill placement targets ────────────────────────────────────────────
|
|
32
32
|
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
// The `name` segment must match the `name` field in the SKILL.md
|
|
40
|
-
// frontmatter per the agentskills.io spec — the file-write pipeline
|
|
41
|
-
// enforces this at write time.
|
|
42
|
-
//
|
|
43
|
-
// Other detected vendors (Cursor, Windsurf, VS Code Copilot) do not
|
|
44
|
-
// currently document an on-disk skill discovery convention. For those
|
|
45
|
-
// vendors, the file-write pipeline writes to a project-level fallback
|
|
46
|
-
// at `<cwd>/skills/<name>/`, with an entry added to .gitignore on
|
|
47
|
-
// first write so the user-specific skill set never leaks into the repo
|
|
48
|
-
// history. See follow-up issue #876 for tracking when those IDEs
|
|
49
|
-
// publish their own conventions.
|
|
33
|
+
// Per-vendor placement decisions live in `agent-registry.mjs`. This
|
|
34
|
+
// module exposes the path resolvers each placement target maps to;
|
|
35
|
+
// `file-write.mjs` switches on the registry's PlacementTarget union to
|
|
36
|
+
// pick the right resolver. See `packages/cli/docs/vendor-paths.md` for
|
|
37
|
+
// the verified vendor-by-vendor reference and primary-source citations.
|
|
50
38
|
|
|
51
39
|
/** Claude Code project-local skill directory for a specific skill name. */
|
|
52
40
|
export const claudeSkillsProject = (name) => join(cwd(), ".claude", "skills", name);
|
|
@@ -54,25 +42,39 @@ export const claudeSkillsProject = (name) => join(cwd(), ".claude", "skills", na
|
|
|
54
42
|
/** Claude Code personal/global skill directory for a specific skill name. */
|
|
55
43
|
export const claudeSkillsGlobal = (name) => join(homedir(), ".claude", "skills", name);
|
|
56
44
|
|
|
57
|
-
/** Project-local fallback skills root (used when --ide includes a vendor without a documented convention). */
|
|
58
|
-
export const projectSkillsFallbackRoot = () => join(cwd(), "skills");
|
|
59
|
-
|
|
60
|
-
/** Project-local fallback for a specific skill name. */
|
|
61
|
-
export const projectSkillsFallback = (name) => join(cwd(), "skills", name);
|
|
62
|
-
|
|
63
45
|
/** Parent directory of the project-local Claude Code skills (used by orphan cleanup). */
|
|
64
46
|
export const claudeSkillsProjectRoot = () => join(cwd(), ".claude", "skills");
|
|
65
47
|
|
|
66
48
|
/** Parent directory of the personal/global Claude Code skills (used by orphan cleanup). */
|
|
67
49
|
export const claudeSkillsGlobalRoot = () => join(homedir(), ".claude", "skills");
|
|
68
50
|
|
|
51
|
+
/** Cross-vendor `.agents/skills/<name>/` project-local placement (cursor, windsurf, gemini, codex, cline, copilot). */
|
|
52
|
+
export const agentsSkillsProject = (name) => join(cwd(), ".agents", "skills", name);
|
|
53
|
+
|
|
54
|
+
/** Parent of the project-local `.agents/skills/` cohort root (used by orphan cleanup). */
|
|
55
|
+
export const agentsSkillsProjectRoot = () => join(cwd(), ".agents", "skills");
|
|
56
|
+
|
|
57
|
+
/** Cross-vendor personal `.agents/skills/<name>/` placement (cursor, gemini, codex, cline). */
|
|
58
|
+
export const agentsSkillsGlobal = (name) => join(homedir(), ".agents", "skills", name);
|
|
59
|
+
|
|
60
|
+
/** Parent of the personal `.agents/skills/` cohort root (used by orphan cleanup). */
|
|
61
|
+
export const agentsSkillsGlobalRoot = () => join(homedir(), ".agents", "skills");
|
|
62
|
+
|
|
63
|
+
/** Windsurf's vendor-specific personal placement under `~/.codeium/windsurf/skills/<name>/`. */
|
|
64
|
+
export const windsurfSkillsGlobal = (name) =>
|
|
65
|
+
join(homedir(), ".codeium", "windsurf", "skills", name);
|
|
66
|
+
|
|
67
|
+
/** Parent of the Windsurf personal skills root (used by orphan cleanup). */
|
|
68
|
+
export const windsurfSkillsGlobalRoot = () =>
|
|
69
|
+
join(homedir(), ".codeium", "windsurf", "skills");
|
|
70
|
+
|
|
69
71
|
// ── Shared ────────────────────────────────────────────────────────────
|
|
70
72
|
|
|
71
73
|
export const envLocal = () => join(cwd(), ".env.local");
|
|
72
74
|
|
|
73
75
|
/**
|
|
74
76
|
* Project .gitignore — used by the file-write pipeline to ensure the
|
|
75
|
-
*
|
|
77
|
+
* `.agents/skills/` cohort directory is gitignored on first write.
|
|
76
78
|
*/
|
|
77
79
|
export const gitignorePath = () => join(cwd(), ".gitignore");
|
|
78
80
|
|