skillrepo 2.0.0 → 3.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.
Files changed (49) hide show
  1. package/README.md +215 -150
  2. package/bin/skillrepo.mjs +210 -36
  3. package/package.json +6 -3
  4. package/src/commands/add.mjs +176 -0
  5. package/src/commands/get.mjs +116 -0
  6. package/src/commands/init.mjs +471 -143
  7. package/src/commands/list.mjs +176 -0
  8. package/src/commands/remove.mjs +167 -0
  9. package/src/commands/search.mjs +188 -0
  10. package/src/commands/update.mjs +67 -0
  11. package/src/lib/cli-config.mjs +230 -0
  12. package/src/lib/config.mjs +238 -0
  13. package/src/lib/detect-ides.mjs +0 -19
  14. package/src/lib/errors.mjs +264 -0
  15. package/src/lib/file-write.mjs +705 -0
  16. package/src/lib/http.mjs +817 -37
  17. package/src/lib/identifier.mjs +153 -0
  18. package/src/lib/mcp-merge.mjs +275 -0
  19. package/src/lib/mergers/gitignore.mjs +73 -18
  20. package/src/lib/paths.mjs +46 -17
  21. package/src/lib/prompt.mjs +11 -44
  22. package/src/lib/sync.mjs +305 -0
  23. package/src/test/commands/add.test.mjs +285 -0
  24. package/src/test/commands/get.test.mjs +176 -0
  25. package/src/test/commands/init.test.mjs +486 -0
  26. package/src/test/commands/list.test.mjs +172 -0
  27. package/src/test/commands/remove.test.mjs +234 -0
  28. package/src/test/commands/search.test.mjs +204 -0
  29. package/src/test/commands/update.test.mjs +164 -0
  30. package/src/test/detect-ides.test.mjs +9 -14
  31. package/src/test/dispatcher.test.mjs +224 -0
  32. package/src/test/e2e/cli-commands.test.mjs +576 -0
  33. package/src/test/e2e/mock-server.mjs +364 -22
  34. package/src/test/helpers/capture-stream.mjs +48 -0
  35. package/src/test/integration/file-write.integration.test.mjs +279 -0
  36. package/src/test/lib/cli-config.test.mjs +407 -0
  37. package/src/test/lib/config.test.mjs +257 -0
  38. package/src/test/lib/errors.test.mjs +359 -0
  39. package/src/test/lib/file-write.test.mjs +784 -0
  40. package/src/test/lib/http.test.mjs +1198 -0
  41. package/src/test/lib/identifier.test.mjs +157 -0
  42. package/src/test/lib/mcp-merge.test.mjs +345 -0
  43. package/src/test/lib/paths.test.mjs +83 -0
  44. package/src/test/lib/sync.test.mjs +514 -0
  45. package/src/test/mergers/gitignore.test.mjs +145 -20
  46. package/src/lib/write-configs.mjs +0 -202
  47. package/src/test/e2e/HANDOFF.md +0 -223
  48. package/src/test/e2e/cli-init.test.mjs +0 -213
  49. package/src/test/e2e/payload-factory.mjs +0 -22
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Skill identifier parsing — extracts owner + name from CLI arguments.
3
+ *
4
+ * Architects's PR2 review flagged that every command (`get`, `add`,
5
+ * `remove`) needs to parse `@owner/name` into separate strings, and
6
+ * suggested a shared helper. This module is that helper.
7
+ *
8
+ * Accepted formats:
9
+ * - `@owner/name` (canonical)
10
+ * - `owner/name` (without the leading `@` — convenience)
11
+ *
12
+ * Rejected:
13
+ * - bare `name` (no slash)
14
+ * - `@owner` (no slash)
15
+ * - `owner/name/extra` (extra slash)
16
+ * - empty owner or empty name
17
+ * - whitespace
18
+ * - any segment that fails the agentskills.io spec rules
19
+ *
20
+ * The owner segment matches the same `name` rules from the spec
21
+ * (lowercase alphanumeric + hyphens, no leading/trailing/consecutive
22
+ * hyphens, 1-100 chars). The name segment matches the spec exactly
23
+ * (1-64 chars). These mirror the server-side validation and prevent
24
+ * a malformed identifier from reaching the API.
25
+ *
26
+ * Note: the spec says skill `name` is up to 64 chars. Server-side
27
+ * `accounts.slug` (the owner) is up to 100 chars. We use 100 for
28
+ * owners and 64 for names to match each upstream constraint.
29
+ */
30
+
31
+ import { validationError } from "./errors.mjs";
32
+
33
+ const OWNER_RE = /^[a-z0-9-]+$/;
34
+ const NAME_RE = /^[a-z0-9-]+$/;
35
+
36
+ /**
37
+ * @typedef {Object} ParsedIdentifier
38
+ * @property {string} owner
39
+ * @property {string} name
40
+ */
41
+
42
+ /**
43
+ * Parse a CLI identifier into { owner, name }.
44
+ *
45
+ * Throws `validationError` (exit 5) on any malformed input with a
46
+ * specific message pointing at the failing rule. The error hint
47
+ * points at the canonical format.
48
+ *
49
+ * @param {string} raw
50
+ * @returns {ParsedIdentifier}
51
+ */
52
+ export function parseIdentifier(raw) {
53
+ if (typeof raw !== "string" || raw.trim() === "") {
54
+ throw validationError("Skill identifier is required.", {
55
+ hint: "Pass an identifier like @owner/name or owner/name.",
56
+ });
57
+ }
58
+
59
+ const trimmed = raw.trim();
60
+
61
+ // Strip optional leading `@`
62
+ const stripped = trimmed.startsWith("@") ? trimmed.slice(1) : trimmed;
63
+
64
+ const slashCount = (stripped.match(/\//g) || []).length;
65
+ if (slashCount === 0) {
66
+ throw validationError(
67
+ `Invalid identifier "${raw}": missing owner.`,
68
+ { hint: "Use @owner/name format." },
69
+ );
70
+ }
71
+ if (slashCount > 1) {
72
+ throw validationError(
73
+ `Invalid identifier "${raw}": too many segments.`,
74
+ { hint: "Use @owner/name format with exactly one slash." },
75
+ );
76
+ }
77
+
78
+ const [owner, name] = stripped.split("/");
79
+
80
+ if (owner === "") {
81
+ throw validationError(
82
+ `Invalid identifier "${raw}": empty owner.`,
83
+ { hint: "Use @owner/name format." },
84
+ );
85
+ }
86
+ if (name === "") {
87
+ throw validationError(
88
+ `Invalid identifier "${raw}": empty name.`,
89
+ { hint: "Use @owner/name format." },
90
+ );
91
+ }
92
+
93
+ validateOwner(owner, raw);
94
+ validateName(name, raw);
95
+
96
+ return { owner, name };
97
+ }
98
+
99
+ /**
100
+ * Format a `{ owner, name }` pair back into the canonical
101
+ * `@owner/name` string. Used by command summaries and error messages.
102
+ */
103
+ export function formatIdentifier({ owner, name }) {
104
+ return `@${owner}/${name}`;
105
+ }
106
+
107
+ // ── Internals ───────────────────────────────────────────────────────────
108
+
109
+ function validateOwner(owner, raw) {
110
+ if (owner.length > 100) {
111
+ throw validationError(
112
+ `Invalid identifier "${raw}": owner exceeds 100 characters.`,
113
+ );
114
+ }
115
+ if (!OWNER_RE.test(owner)) {
116
+ throw validationError(
117
+ `Invalid identifier "${raw}": owner "${owner}" must be lowercase alphanumeric with hyphens.`,
118
+ );
119
+ }
120
+ if (owner.startsWith("-") || owner.endsWith("-")) {
121
+ throw validationError(
122
+ `Invalid identifier "${raw}": owner "${owner}" must not start or end with a hyphen.`,
123
+ );
124
+ }
125
+ if (owner.includes("--")) {
126
+ throw validationError(
127
+ `Invalid identifier "${raw}": owner "${owner}" must not contain consecutive hyphens.`,
128
+ );
129
+ }
130
+ }
131
+
132
+ function validateName(name, raw) {
133
+ if (name.length > 64) {
134
+ throw validationError(
135
+ `Invalid identifier "${raw}": name exceeds 64 characters.`,
136
+ );
137
+ }
138
+ if (!NAME_RE.test(name)) {
139
+ throw validationError(
140
+ `Invalid identifier "${raw}": name "${name}" must be lowercase alphanumeric with hyphens.`,
141
+ );
142
+ }
143
+ if (name.startsWith("-") || name.endsWith("-")) {
144
+ throw validationError(
145
+ `Invalid identifier "${raw}": name "${name}" must not start or end with a hyphen.`,
146
+ );
147
+ }
148
+ if (name.includes("--")) {
149
+ throw validationError(
150
+ `Invalid identifier "${raw}": name "${name}" must not contain consecutive hyphens.`,
151
+ );
152
+ }
153
+ }
@@ -0,0 +1,275 @@
1
+ /**
2
+ * MCP auto-merge for detected IDEs (#682).
3
+ *
4
+ * Wraps the existing per-vendor MCP config mergers (claude-mcp,
5
+ * cursor-mcp, windsurf-mcp, vscode-mcp) with a user-visible
6
+ * confirmation prompt showing what will change before any file
7
+ * write. The underlying mergers are UNCHANGED — this module is a
8
+ * thin coordinator that:
9
+ *
10
+ * 1. For each detected vendor, reads the current config
11
+ * 2. Builds a structured preview of what the merge would do
12
+ * (created | updated | skipped — no actual text diff)
13
+ * 3. Prompts y/n via prompt.mjs (unless --yes)
14
+ * 4. On yes: calls the existing merger, which writes the file
15
+ * 5. On no: skips this vendor and continues with the next
16
+ * 6. On merger error: records the failure and continues with the
17
+ * next vendor — one broken IDE config doesn't abort init
18
+ *
19
+ * For undetected IDEs, the function prints the MCP config JSON
20
+ * blob that the user can paste into their own IDE's MCP settings,
21
+ * per the plan's requirement for undetected-vendor output.
22
+ *
23
+ * Architectural rationale (from the PR2 plan review):
24
+ *
25
+ * The plan called for a "structured key-level diff". A full text
26
+ * diff is noisy for JSON (indentation changes trigger spurious
27
+ * deltas), so we describe the change at the key level: "Adding
28
+ * mcpServers.skillrepo to .mcp.json. Existing entries preserved."
29
+ *
30
+ * Failures are per-vendor. If writing the Claude Code config
31
+ * fails, we still try Cursor, Windsurf, VS Code. The final
32
+ * summary reports which vendors succeeded, which were skipped by
33
+ * the user, and which errored.
34
+ */
35
+
36
+ import { readFileSafe } from "./fs-utils.mjs";
37
+ import {
38
+ claudeMcpJson,
39
+ cursorMcpJson,
40
+ windsurfMcpJson,
41
+ vscodeMcpJson,
42
+ } from "./paths.mjs";
43
+ import { mergeClaudeMcpConfig } from "./mergers/claude-mcp.mjs";
44
+ import { mergeCursorMcpConfig } from "./mergers/cursor-mcp.mjs";
45
+ import { mergeWindsurfMcpConfig } from "./mergers/windsurf-mcp.mjs";
46
+ import { mergeVscodeMcpConfig } from "./mergers/vscode-mcp.mjs";
47
+ import { confirm as realConfirm } from "./prompt.mjs";
48
+ import { validationError } from "./errors.mjs";
49
+
50
+ /**
51
+ * @typedef {Object} McpMergeResult
52
+ * @property {string} vendor
53
+ * @property {string} path - User-facing path (relative for project, absolute for global)
54
+ * @property {"merged" | "skipped" | "failed"} outcome
55
+ * @property {string} [reason] - Present when outcome is "skipped" or "failed"
56
+ * @property {"created" | "updated" | "merged"} [action] - From the
57
+ * underlying merger. NOTE: claude-mcp.mjs currently returns
58
+ * `"merged"` for the "added a skillrepo entry to an existing
59
+ * file" case, while cursor-mcp.mjs and friends return `"merged"`
60
+ * for the inverse case. The round-1 review flagged this
61
+ * inconsistency; the typedef widens the union to accept all
62
+ * three values so the mcp-merge layer doesn't lie about the
63
+ * underlying return. The mergers themselves will be normalized
64
+ * in a future PR outside #646's scope.
65
+ */
66
+
67
+ /**
68
+ * Run MCP auto-merge for every vendor in `vendors`, prompting the
69
+ * user for each one unless `yes` is true.
70
+ *
71
+ * @param {object} options
72
+ * @param {string[]} options.vendors - Vendor keys: claudeCode | cursor | windsurf | vscode
73
+ * @param {string} options.mcpUrl - The SkillRepo MCP endpoint URL
74
+ * @param {boolean} [options.yes] - Skip prompts (auto-accept everything)
75
+ * @param {object} [options.io] - Injected streams for testability
76
+ * @param {NodeJS.WritableStream} [options.io.stdout=process.stdout]
77
+ * @param {NodeJS.WritableStream} [options.io.stderr=process.stderr]
78
+ * @param {(prompt: string, defaultYes?: boolean) => Promise<boolean>} [options.confirmFn]
79
+ * Optional injection point for the y/n prompt. Defaults to
80
+ * the real `confirm` from prompt.mjs. Tests pass a stub to
81
+ * avoid spawning a readline interface. This dependency is
82
+ * injected rather than monkey-patched because ESM module
83
+ * exports are frozen and cannot be reassigned.
84
+ * @returns {Promise<McpMergeResult[]>}
85
+ */
86
+ export async function mergeMcpForVendors(options) {
87
+ const { vendors, mcpUrl, yes = false, io = {}, confirmFn = realConfirm } = options;
88
+ const stdout = io.stdout ?? process.stdout;
89
+ const stderr = io.stderr ?? process.stderr;
90
+
91
+ if (!Array.isArray(vendors) || vendors.length === 0) {
92
+ return [];
93
+ }
94
+ if (typeof mcpUrl !== "string" || !mcpUrl) {
95
+ // Use validationError (exit 5) rather than a bare Error so the
96
+ // dispatcher's top-level catch maps this to the right exit
97
+ // code. A bare Error would fall through to exit 1 (network),
98
+ // which would mislead a caller that passed a broken mcpUrl.
99
+ throw validationError("mergeMcpForVendors: mcpUrl is required");
100
+ }
101
+
102
+ // Deduplicate vendors so a caller passing `["claudeCode",
103
+ // "claudeCode"]` doesn't run the merger twice. The round-1 review
104
+ // flagged this as a latent caller trap — `cli-config.mjs`'s
105
+ // parseVendorList doesn't dedupe either, so `--ide claude,claude`
106
+ // would reach this loop twice for the same vendor. Idempotent on
107
+ // a single vendor key (Set preserves first-seen insertion order).
108
+ const uniqueVendors = Array.from(new Set(vendors));
109
+
110
+ const results = [];
111
+ for (const vendor of uniqueVendors) {
112
+ const vendorInfo = VENDOR_INFO[vendor];
113
+ if (!vendorInfo) {
114
+ // Unknown vendor — skip with a recorded failure
115
+ results.push({
116
+ vendor,
117
+ path: "(unknown)",
118
+ outcome: "failed",
119
+ reason: `Unknown vendor: ${vendor}`,
120
+ });
121
+ continue;
122
+ }
123
+
124
+ const filePath = vendorInfo.pathFn();
125
+ const displayPath = vendorInfo.displayPath;
126
+ const existing = readFileSafe(filePath);
127
+ const preview = buildPreview(displayPath, existing);
128
+
129
+ // Print the preview
130
+ stdout.write(`\n ${displayPath}: ${preview}\n`);
131
+
132
+ // Prompt (unless --yes)
133
+ let shouldMerge = yes;
134
+ if (!yes) {
135
+ shouldMerge = await confirmFn(` Update ${displayPath}?`, true);
136
+ }
137
+
138
+ if (!shouldMerge) {
139
+ results.push({
140
+ vendor,
141
+ path: displayPath,
142
+ outcome: "skipped",
143
+ reason: "user declined",
144
+ });
145
+ continue;
146
+ }
147
+
148
+ // Call the underlying merger
149
+ try {
150
+ const mergerResult = vendorInfo.merger(mcpUrl);
151
+ results.push({
152
+ vendor,
153
+ path: displayPath,
154
+ outcome: "merged",
155
+ action: mergerResult.action,
156
+ });
157
+ } catch (err) {
158
+ // One vendor's failure does not abort the rest. Record the
159
+ // failure, emit a warning on the injected stderr stream, and
160
+ // continue. The init command's summary will list each failed
161
+ // vendor so the user can fix them manually.
162
+ stderr.write(` ⚠ Failed to update ${displayPath}: ${err.message}\n`);
163
+ results.push({
164
+ vendor,
165
+ path: displayPath,
166
+ outcome: "failed",
167
+ reason: err.message,
168
+ });
169
+ }
170
+ }
171
+
172
+ return results;
173
+ }
174
+
175
+ /**
176
+ * Print MCP config JSON for undetected IDEs so the user can paste
177
+ * it into their own IDE's MCP settings manually. Called by init
178
+ * when no IDEs were detected or when the user wants to see the
179
+ * raw config shape.
180
+ *
181
+ * @param {string} mcpUrl
182
+ * @param {object} [io]
183
+ * @param {NodeJS.WritableStream} [io.stdout=process.stdout]
184
+ */
185
+ export function printManualMcpInstructions(mcpUrl, io = {}) {
186
+ const stdout = io.stdout ?? process.stdout;
187
+ const config = {
188
+ mcpServers: {
189
+ skillrepo: {
190
+ type: "http",
191
+ url: mcpUrl,
192
+ headers: {
193
+ Authorization: "Bearer ${SKILLREPO_ACCESS_KEY}",
194
+ },
195
+ },
196
+ },
197
+ };
198
+ stdout.write("\n To configure MCP manually, add this to your IDE's MCP settings:\n\n");
199
+ stdout.write(JSON.stringify(config, null, 2).replace(/^/gm, " ") + "\n\n");
200
+ stdout.write(" The SKILLREPO_ACCESS_KEY env var is set in .env.local.\n\n");
201
+ }
202
+
203
+ // ── Internals ──────────────────────────────────────────────────────────
204
+
205
+ /**
206
+ * Per-vendor metadata: the merge function, the file path resolver,
207
+ * and the display-path string used in prompts.
208
+ */
209
+ const VENDOR_INFO = {
210
+ claudeCode: {
211
+ merger: mergeClaudeMcpConfig,
212
+ pathFn: claudeMcpJson,
213
+ displayPath: ".mcp.json",
214
+ },
215
+ cursor: {
216
+ merger: mergeCursorMcpConfig,
217
+ pathFn: cursorMcpJson,
218
+ displayPath: ".cursor/mcp.json",
219
+ },
220
+ windsurf: {
221
+ merger: mergeWindsurfMcpConfig,
222
+ pathFn: windsurfMcpJson,
223
+ displayPath: "~/.codeium/windsurf/mcp_config.json",
224
+ },
225
+ vscode: {
226
+ merger: mergeVscodeMcpConfig,
227
+ pathFn: vscodeMcpJson,
228
+ displayPath: ".vscode/mcp.json",
229
+ },
230
+ };
231
+
232
+ /**
233
+ * Build a user-facing preview string for the merge action. We don't
234
+ * compute a text diff — JSON re-serialization produces noisy diffs
235
+ * that the user doesn't care about. Instead, we describe the
236
+ * change at the key level:
237
+ *
238
+ * - No file yet: "Will create"
239
+ * - File exists, no entry: "Will add mcpServers.skillrepo (N existing servers preserved)"
240
+ * - File exists, has entry:"Will update existing mcpServers.skillrepo entry"
241
+ * - File corrupt: "Will refuse to write — invalid JSON"
242
+ */
243
+ function buildPreview(displayPath, existingContent) {
244
+ if (existingContent === null) {
245
+ return "will create (file does not exist)";
246
+ }
247
+ let config;
248
+ try {
249
+ config = JSON.parse(existingContent);
250
+ } catch {
251
+ return "WARNING: existing file has invalid JSON — merge will throw";
252
+ }
253
+ if (!config || typeof config !== "object") {
254
+ return "WARNING: existing file is not a JSON object — merge will throw";
255
+ }
256
+ // All four vendors (claude-mcp, cursor-mcp, windsurf-mcp,
257
+ // vscode-mcp) use the `mcpServers` key. No vendor uses a
258
+ // `servers` alias. The round-1 review caught the extra
259
+ // `?? config.servers` fallback as dead code; removed.
260
+ const mcpServers = config.mcpServers ?? {};
261
+ const otherServers = Object.keys(mcpServers).filter((k) => k !== "skillrepo");
262
+ const existingEntry = mcpServers.skillrepo;
263
+
264
+ if (existingEntry) {
265
+ if (otherServers.length === 0) {
266
+ return "will update existing skillrepo entry (no other servers)";
267
+ }
268
+ return `will update existing skillrepo entry (${otherServers.length} other server${otherServers.length === 1 ? "" : "s"} preserved: ${otherServers.join(", ")})`;
269
+ }
270
+
271
+ if (otherServers.length === 0) {
272
+ return "will add skillrepo entry to empty mcpServers";
273
+ }
274
+ return `will add skillrepo entry (${otherServers.length} existing server${otherServers.length === 1 ? "" : "s"} preserved: ${otherServers.join(", ")})`;
275
+ }
@@ -1,39 +1,94 @@
1
1
  /**
2
- * Merger for .gitignore — adds SkillRepo rules file patterns.
2
+ * Merger for .gitignore — adds the three paths `skillrepo init` writes
3
+ * that must not be committed.
3
4
  *
4
- * Skills delivered via .claude/rules/skillrepo-*.md are user-specific
5
- * (based on library subscription) and should not be committed to git.
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:
10
+ *
11
+ * - `.env.local` — contains SKILLREPO_ACCESS_KEY, a live credential
12
+ * - `.claude/skills/` — per-developer synced library content
13
+ * - `.claude/settings.local.json` — Claude Code per-user settings
14
+ *
15
+ * The `.env.local` entry is the security-critical one: without it, a
16
+ * developer running `skillrepo init` in a fresh project could commit
17
+ * their access key on the next `git add .`. PR4 round-3 review caught
18
+ * that the docs promised this behavior but the CLI never actually
19
+ * wrote the entries — this merger closes the gap.
20
+ *
21
+ * Idempotent: entries already present are skipped. A single call
22
+ * either adds all missing entries in one grouped section or exits
23
+ * without writing if they're all already present.
6
24
  */
7
25
 
8
26
  import { readFileSafe, writeFileSafe } from "../fs-utils.mjs";
9
27
  import { join } from "node:path";
10
28
 
11
- const GITIGNORE_ENTRY = ".claude/rules/skillrepo-*.md";
12
- const SECTION_HEADER = "# SkillRepo (auto-generated rules files)";
29
+ const SECTION_HEADER = "# SkillRepo CLI (added by `skillrepo init`)";
30
+ const REQUIRED_ENTRIES = [
31
+ ".env.local",
32
+ ".claude/skills/",
33
+ ".claude/settings.local.json",
34
+ ];
13
35
 
14
36
  /**
15
- * Add SkillRepo rules file pattern to .gitignore.
16
- * Creates .gitignore if it doesn't exist. Idempotent — skips if already present.
37
+ * Ensure the three init-required paths are in .gitignore. Creates the
38
+ * file if it doesn't exist. Idempotent — returns `"skipped"` if every
39
+ * required entry is already present, `"created"` if the file didn't
40
+ * exist, `"updated"` if it did and at least one entry was missing.
17
41
  *
18
- * @returns {{ path: string; action: string }}
42
+ * @returns {{ path: string; action: "created" | "updated" | "skipped"; added: string[] }}
19
43
  */
20
44
  export function mergeGitignore() {
21
45
  const gitignorePath = join(process.cwd(), ".gitignore");
22
46
  const existing = readFileSafe(gitignorePath);
23
47
 
24
- if (existing !== null && existing.includes(GITIGNORE_ENTRY)) {
25
- return { path: ".gitignore", action: "skipped" };
48
+ if (existing === null) {
49
+ // Fresh .gitignore write all required entries as one section.
50
+ const content = renderSection(REQUIRED_ENTRIES);
51
+ writeFileSafe(gitignorePath, content);
52
+ return {
53
+ path: ".gitignore",
54
+ action: "created",
55
+ added: [...REQUIRED_ENTRIES],
56
+ };
26
57
  }
27
58
 
28
- const newBlock = `\n${SECTION_HEADER}\n${GITIGNORE_ENTRY}\n`;
59
+ // Line-exact match check. `.env.local` as a line is distinct from
60
+ // `.env.local.backup` or a comment `# .env.local` — split on
61
+ // newlines and trim so we match the entry literally.
62
+ const lines = existing.split(/\r?\n/).map((l) => l.trim());
63
+ const missing = REQUIRED_ENTRIES.filter((entry) => !lines.includes(entry));
29
64
 
30
- if (existing === null) {
31
- writeFileSafe(gitignorePath, newBlock.trimStart());
32
- return { path: ".gitignore", action: "created" };
65
+ if (missing.length === 0) {
66
+ return {
67
+ path: ".gitignore",
68
+ action: "skipped",
69
+ added: [],
70
+ };
33
71
  }
34
72
 
35
- // Append to existing .gitignore
36
- const content = existing.endsWith("\n") ? existing + newBlock : existing + "\n" + newBlock;
37
- writeFileSafe(gitignorePath, content);
38
- return { path: ".gitignore", action: "updated" };
73
+ // Append only the missing entries as a new section. Preserve the
74
+ // original line-ending style.
75
+ const lineEnding = existing.includes("\r\n") ? "\r\n" : "\n";
76
+ const separator = existing.endsWith("\n") ? "" : lineEnding;
77
+ const block = renderSection(missing, lineEnding);
78
+ writeFileSafe(gitignorePath, existing + separator + lineEnding + block);
79
+
80
+ return {
81
+ path: ".gitignore",
82
+ action: "updated",
83
+ added: missing,
84
+ };
85
+ }
86
+
87
+ /**
88
+ * Render the SkillRepo section with the given entries. The leading
89
+ * blank line separates the section from whatever precedes it when
90
+ * appending, and the trailing newline keeps the file POSIX-clean.
91
+ */
92
+ function renderSection(entries, lineEnding = "\n") {
93
+ return SECTION_HEADER + lineEnding + entries.join(lineEnding) + lineEnding;
39
94
  }
package/src/lib/paths.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  * Uses Node built-ins only — no dependencies.
4
4
  */
5
5
 
6
- import { join, resolve } from "node:path";
6
+ import { join } from "node:path";
7
7
  import { homedir } from "node:os";
8
8
 
9
9
  const cwd = () => process.cwd();
@@ -11,19 +11,10 @@ const cwd = () => process.cwd();
11
11
  // Claude Code
12
12
  export const claudeMcpJson = () => join(cwd(), ".mcp.json");
13
13
  export const claudeDir = () => join(cwd(), ".claude");
14
- export const claudeSettingsLocal = () => join(cwd(), ".claude", "settings.local.json");
15
- export const claudeSkillrepoMd = () => join(cwd(), ".claude", "skillrepo.md");
16
- export const claudeSkillrepoIndex = () => join(cwd(), ".claude", "skillrepo-index.json");
17
- export const claudeSkillrepoConfig = () => join(cwd(), ".claude", "skillrepo-config.json");
18
14
 
19
15
  // Cursor
20
16
  export const cursorDir = () => join(cwd(), ".cursor");
21
17
  export const cursorMcpJson = () => join(cwd(), ".cursor", "mcp.json");
22
- export const cursorRulesDir = () => join(cwd(), ".cursor", "rules");
23
- export const cursorHooksDir = () => join(cwd(), ".cursor", "hooks");
24
- export const cursorSkillrepoMdc = () => join(cwd(), ".cursor", "rules", "skillrepo.mdc");
25
- export const cursorSkillrepoIndex = () => join(cwd(), ".cursor", "skillrepo-index.json");
26
- export const cursorHooksJson = () => join(cwd(), ".cursor", "hooks.json");
27
18
 
28
19
  // Windsurf (always global — no project-level config)
29
20
  export const windsurfDir = () => join(homedir(), ".codeium", "windsurf");
@@ -34,15 +25,53 @@ export const vscodeDir = () => join(cwd(), ".vscode");
34
25
  export const vscodeMcpJson = () => join(cwd(), ".vscode", "mcp.json");
35
26
 
36
27
  // Global SkillRepo cache (shared across projects, lives in user home)
37
- export const globalSkillrepoDir = () => join(homedir(), ".claude", "skillrepo");
38
28
  export const globalConfigPath = () => join(homedir(), ".claude", "skillrepo", "config.json");
39
29
  export const globalLastSyncPath = () => join(homedir(), ".claude", "skillrepo", ".last-sync");
40
- export const globalIndexPath = () => join(homedir(), ".claude", "skillrepo", "index.json");
41
- export const globalSkillsDir = () => join(homedir(), ".claude", "skillrepo", "skills");
42
30
 
43
- // Project-level rules directories (deterministic skill delivery)
44
- export const claudeRulesDir = () => join(cwd(), ".claude", "rules");
31
+ // ── Skill placement targets (added in #646 / PR1) ─────────────────────
32
+ //
33
+ // Claude Code documents two skill discovery locations at
34
+ // https://code.claude.com/docs/en/skills:
35
+ //
36
+ // Personal: ~/.claude/skills/<name>/SKILL.md
37
+ // Project: <cwd>/.claude/skills/<name>/SKILL.md
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.
50
+
51
+ /** Claude Code project-local skill directory for a specific skill name. */
52
+ export const claudeSkillsProject = (name) => join(cwd(), ".claude", "skills", name);
53
+
54
+ /** Claude Code personal/global skill directory for a specific skill name. */
55
+ export const claudeSkillsGlobal = (name) => join(homedir(), ".claude", "skills", name);
56
+
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
+ /** Parent directory of the project-local Claude Code skills (used by orphan cleanup). */
64
+ export const claudeSkillsProjectRoot = () => join(cwd(), ".claude", "skills");
65
+
66
+ /** Parent directory of the personal/global Claude Code skills (used by orphan cleanup). */
67
+ export const claudeSkillsGlobalRoot = () => join(homedir(), ".claude", "skills");
68
+
69
+ // ── Shared ────────────────────────────────────────────────────────────
45
70
 
46
- // Shared
47
71
  export const envLocal = () => join(cwd(), ".env.local");
48
- export const projectRoot = () => resolve(cwd());
72
+
73
+ /**
74
+ * Project .gitignore — used by the file-write pipeline to ensure the
75
+ * project /skills/ fallback directory is gitignored on first write.
76
+ */
77
+ export const gitignorePath = () => join(cwd(), ".gitignore");