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.
- package/README.md +215 -150
- package/bin/skillrepo.mjs +210 -36
- package/package.json +6 -3
- package/src/commands/add.mjs +176 -0
- package/src/commands/get.mjs +116 -0
- package/src/commands/init.mjs +471 -143
- package/src/commands/list.mjs +176 -0
- package/src/commands/remove.mjs +167 -0
- package/src/commands/search.mjs +188 -0
- package/src/commands/update.mjs +67 -0
- package/src/lib/cli-config.mjs +230 -0
- package/src/lib/config.mjs +238 -0
- package/src/lib/detect-ides.mjs +0 -19
- package/src/lib/errors.mjs +264 -0
- package/src/lib/file-write.mjs +705 -0
- package/src/lib/http.mjs +817 -37
- package/src/lib/identifier.mjs +153 -0
- package/src/lib/mcp-merge.mjs +275 -0
- package/src/lib/mergers/gitignore.mjs +73 -18
- package/src/lib/paths.mjs +46 -17
- package/src/lib/prompt.mjs +11 -44
- package/src/lib/sync.mjs +305 -0
- package/src/test/commands/add.test.mjs +285 -0
- package/src/test/commands/get.test.mjs +176 -0
- package/src/test/commands/init.test.mjs +486 -0
- package/src/test/commands/list.test.mjs +172 -0
- package/src/test/commands/remove.test.mjs +234 -0
- package/src/test/commands/search.test.mjs +204 -0
- package/src/test/commands/update.test.mjs +164 -0
- package/src/test/detect-ides.test.mjs +9 -14
- package/src/test/dispatcher.test.mjs +224 -0
- package/src/test/e2e/cli-commands.test.mjs +576 -0
- package/src/test/e2e/mock-server.mjs +364 -22
- package/src/test/helpers/capture-stream.mjs +48 -0
- package/src/test/integration/file-write.integration.test.mjs +279 -0
- package/src/test/lib/cli-config.test.mjs +407 -0
- package/src/test/lib/config.test.mjs +257 -0
- package/src/test/lib/errors.test.mjs +359 -0
- package/src/test/lib/file-write.test.mjs +784 -0
- package/src/test/lib/http.test.mjs +1198 -0
- package/src/test/lib/identifier.test.mjs +157 -0
- package/src/test/lib/mcp-merge.test.mjs +345 -0
- package/src/test/lib/paths.test.mjs +83 -0
- package/src/test/lib/sync.test.mjs +514 -0
- package/src/test/mergers/gitignore.test.mjs +145 -20
- package/src/lib/write-configs.mjs +0 -202
- package/src/test/e2e/HANDOFF.md +0 -223
- package/src/test/e2e/cli-init.test.mjs +0 -213
- 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
|
|
2
|
+
* Merger for .gitignore — adds the three paths `skillrepo init` writes
|
|
3
|
+
* that must not be committed.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
|
12
|
-
const
|
|
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
|
-
*
|
|
16
|
-
*
|
|
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
|
|
25
|
-
|
|
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
|
-
|
|
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 (
|
|
31
|
-
|
|
32
|
-
|
|
65
|
+
if (missing.length === 0) {
|
|
66
|
+
return {
|
|
67
|
+
path: ".gitignore",
|
|
68
|
+
action: "skipped",
|
|
69
|
+
added: [],
|
|
70
|
+
};
|
|
33
71
|
}
|
|
34
72
|
|
|
35
|
-
// Append
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
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
|
-
//
|
|
44
|
-
|
|
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
|
-
|
|
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");
|