@systemdox/mcp-server 0.1.42 → 0.1.44
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 +15 -0
- package/dist/cli.js +520 -69
- package/dist/http.js +393 -46
- package/dist/index.js +394 -47
- package/dist/install-skill.d.ts +25 -0
- package/dist/install-skill.js +374 -0
- package/dist/lib/api-client.d.ts +3 -0
- package/dist/lib/api-client.js +10 -0
- package/dist/lib/skills.d.ts +87 -0
- package/dist/lib/skills.js +209 -0
- package/dist/server.d.ts +7 -1
- package/dist/tools/skills.d.ts +5 -0
- package/dist/tools/skills.js +356 -0
- package/package.json +1 -1
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/install-skill.ts
|
|
4
|
+
import { basename, resolve as resolve2 } from "path";
|
|
5
|
+
|
|
6
|
+
// src/lib/cache.ts
|
|
7
|
+
var ResponseCache = class {
|
|
8
|
+
store = /* @__PURE__ */ new Map();
|
|
9
|
+
ttlMs;
|
|
10
|
+
constructor(ttlMs) {
|
|
11
|
+
this.ttlMs = ttlMs;
|
|
12
|
+
}
|
|
13
|
+
get(key) {
|
|
14
|
+
const entry = this.store.get(key);
|
|
15
|
+
if (!entry) return void 0;
|
|
16
|
+
if (Date.now() > entry.expiresAt) {
|
|
17
|
+
this.store.delete(key);
|
|
18
|
+
return void 0;
|
|
19
|
+
}
|
|
20
|
+
return entry.data;
|
|
21
|
+
}
|
|
22
|
+
set(key, data) {
|
|
23
|
+
this.store.set(key, {
|
|
24
|
+
data,
|
|
25
|
+
expiresAt: Date.now() + this.ttlMs
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
invalidate(key) {
|
|
29
|
+
this.store.delete(key);
|
|
30
|
+
}
|
|
31
|
+
clear() {
|
|
32
|
+
this.store.clear();
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// src/lib/api-client.ts
|
|
37
|
+
var SystemDoxApiClient = class {
|
|
38
|
+
config;
|
|
39
|
+
cache;
|
|
40
|
+
constructor(config) {
|
|
41
|
+
this.config = config;
|
|
42
|
+
this.cache = new ResponseCache(config.cacheTtlMs);
|
|
43
|
+
}
|
|
44
|
+
async validate() {
|
|
45
|
+
const res = await this.fetchRaw("/v1/health");
|
|
46
|
+
if (!res.ok) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`SystemDox API authentication failed (${res.status}). Check your SYSTEMDOX_API_KEY.`
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
async get(path, params) {
|
|
53
|
+
const url = this.buildUrl(path, params);
|
|
54
|
+
const cacheKey = url.toString();
|
|
55
|
+
const cached = this.cache.get(cacheKey);
|
|
56
|
+
if (cached !== void 0) return cached;
|
|
57
|
+
const res = await this.fetchRaw(path, params);
|
|
58
|
+
if (!res.ok) {
|
|
59
|
+
const body = await res.text();
|
|
60
|
+
throw new Error(`SystemDox API error ${res.status}: ${body}`);
|
|
61
|
+
}
|
|
62
|
+
const data = await res.json();
|
|
63
|
+
this.cache.set(cacheKey, data);
|
|
64
|
+
return data;
|
|
65
|
+
}
|
|
66
|
+
/** A read that skips the cache: an install must take the version that is
|
|
67
|
+
* latest now, not the one cached before someone published another. */
|
|
68
|
+
async getFresh(path) {
|
|
69
|
+
const res = await this.fetchRaw(path);
|
|
70
|
+
if (!res.ok) {
|
|
71
|
+
const body = await res.text();
|
|
72
|
+
throw new Error(`SystemDox API error ${res.status}: ${body}`);
|
|
73
|
+
}
|
|
74
|
+
return await res.json();
|
|
75
|
+
}
|
|
76
|
+
async post(path, body) {
|
|
77
|
+
return this.mutate("POST", path, body);
|
|
78
|
+
}
|
|
79
|
+
async put(path, body) {
|
|
80
|
+
return this.mutate("PUT", path, body);
|
|
81
|
+
}
|
|
82
|
+
async mutate(method, path, body) {
|
|
83
|
+
const url = this.buildUrl(path);
|
|
84
|
+
const res = await fetch(url, {
|
|
85
|
+
method,
|
|
86
|
+
headers: {
|
|
87
|
+
// The API Gateway authorizer only reads the Authorization header;
|
|
88
|
+
// X-API-Key is kept for backwards compatibility.
|
|
89
|
+
Authorization: `Bearer ${this.config.apiKey}`,
|
|
90
|
+
"X-API-Key": this.config.apiKey,
|
|
91
|
+
"Content-Type": "application/json"
|
|
92
|
+
},
|
|
93
|
+
body: JSON.stringify(body)
|
|
94
|
+
});
|
|
95
|
+
if (!res.ok) {
|
|
96
|
+
const text = await res.text();
|
|
97
|
+
throw new Error(`SystemDox API error ${res.status}: ${text}`);
|
|
98
|
+
}
|
|
99
|
+
return await res.json();
|
|
100
|
+
}
|
|
101
|
+
invalidateCache(path) {
|
|
102
|
+
if (path) {
|
|
103
|
+
this.cache.invalidate(this.buildUrl(path).toString());
|
|
104
|
+
} else {
|
|
105
|
+
this.cache.clear();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
buildUrl(path, params) {
|
|
109
|
+
const base = this.config.apiUrl.replace(/\/+$/, "");
|
|
110
|
+
const url = new URL(`${base}${path.startsWith("/") ? "" : "/"}${path}`);
|
|
111
|
+
if (params) {
|
|
112
|
+
for (const [key, value] of Object.entries(params)) {
|
|
113
|
+
url.searchParams.set(key, value);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return url;
|
|
117
|
+
}
|
|
118
|
+
async fetchRaw(path, params) {
|
|
119
|
+
const url = this.buildUrl(path, params);
|
|
120
|
+
return fetch(url, {
|
|
121
|
+
headers: {
|
|
122
|
+
Authorization: `Bearer ${this.config.apiKey}`,
|
|
123
|
+
"X-API-Key": this.config.apiKey,
|
|
124
|
+
Accept: "application/json"
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
// src/lib/config.ts
|
|
131
|
+
var API_URL_DEFAULT = "https://api.puglieseweb.com/systemdox";
|
|
132
|
+
function requireHttpsApiUrl(apiUrl) {
|
|
133
|
+
let parsed;
|
|
134
|
+
try {
|
|
135
|
+
parsed = new URL(apiUrl);
|
|
136
|
+
} catch {
|
|
137
|
+
throw new Error(
|
|
138
|
+
`SYSTEMDOX_API_URL is not a valid URL: ${apiUrl}`
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
if (parsed.protocol !== "https:") {
|
|
142
|
+
throw new Error(
|
|
143
|
+
`SYSTEMDOX_API_URL must use https:// \u2014 refusing to send the API key over ${parsed.protocol}// in cleartext. Got: ${apiUrl}`
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
return apiUrl;
|
|
147
|
+
}
|
|
148
|
+
function loadConfig() {
|
|
149
|
+
const apiKey = process.env.SYSTEMDOX_API_KEY;
|
|
150
|
+
if (!apiKey) {
|
|
151
|
+
throw new Error(
|
|
152
|
+
"SYSTEMDOX_API_KEY is required. Generate one at your SystemDox settings page."
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
const apiUrl = requireHttpsApiUrl(
|
|
156
|
+
process.env.SYSTEMDOX_API_URL ?? API_URL_DEFAULT
|
|
157
|
+
);
|
|
158
|
+
return {
|
|
159
|
+
apiKey,
|
|
160
|
+
apiUrl,
|
|
161
|
+
cacheTtlMs: parseInt(process.env.SYSTEMDOX_CACHE_TTL ?? "300", 10) * 1e3,
|
|
162
|
+
defaultOrg: process.env.SYSTEMDOX_DEFAULT_ORG
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// src/lib/skills.ts
|
|
167
|
+
import { createHash } from "crypto";
|
|
168
|
+
import { lstat, mkdir, readdir, readFile, rm, writeFile } from "fs/promises";
|
|
169
|
+
import { existsSync } from "fs";
|
|
170
|
+
import { homedir } from "os";
|
|
171
|
+
import { dirname, join, relative, resolve, sep } from "path";
|
|
172
|
+
var MANIFEST_FILE = ".systemdox-skill.json";
|
|
173
|
+
var MAX_FILE_BYTES = 256 * 1024;
|
|
174
|
+
var MAX_TOTAL_BYTES = 1024 * 1024;
|
|
175
|
+
var NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
176
|
+
var SEGMENT_RE = /^[A-Za-z0-9._-]+$/;
|
|
177
|
+
var SKIPPED = /* @__PURE__ */ new Set([MANIFEST_FILE, ".git", "node_modules", "__pycache__", ".DS_Store"]);
|
|
178
|
+
var SkillInstallRefused = class extends Error {
|
|
179
|
+
constructor(dest, edited) {
|
|
180
|
+
super(
|
|
181
|
+
`${dest} has local changes in ${edited.length} file(s): ${edited.join(", ")}. Nothing was written. Move your changes aside, or install again with overwrite.`
|
|
182
|
+
);
|
|
183
|
+
this.dest = dest;
|
|
184
|
+
this.edited = edited;
|
|
185
|
+
}
|
|
186
|
+
dest;
|
|
187
|
+
edited;
|
|
188
|
+
};
|
|
189
|
+
function sha256(content) {
|
|
190
|
+
return createHash("sha256").update(content, "utf8").digest("hex");
|
|
191
|
+
}
|
|
192
|
+
function isValidName(name) {
|
|
193
|
+
return name.length <= 64 && NAME_RE.test(name);
|
|
194
|
+
}
|
|
195
|
+
function isValidPath(path) {
|
|
196
|
+
return path.length > 0 && path.length <= 512 && !path.startsWith("/") && path.split("/").every((s) => SEGMENT_RE.test(s) && s !== "." && s !== "..");
|
|
197
|
+
}
|
|
198
|
+
function defaultSkillDir(name) {
|
|
199
|
+
return join(homedir(), ".claude", "skills", name);
|
|
200
|
+
}
|
|
201
|
+
async function walk(root, { skipHidden }, dir = root, out = { files: [], links: [], hidden: [] }) {
|
|
202
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
203
|
+
if (SKIPPED.has(entry.name) || entry.name.endsWith(".pyc")) continue;
|
|
204
|
+
const full = join(dir, entry.name);
|
|
205
|
+
const rel = relative(root, full).split(sep).join("/");
|
|
206
|
+
if (entry.isSymbolicLink()) out.links.push(rel);
|
|
207
|
+
else if (skipHidden && entry.name.startsWith(".")) out.hidden.push(rel);
|
|
208
|
+
else if (entry.isDirectory()) await walk(root, { skipHidden }, full, out);
|
|
209
|
+
else if (entry.isFile()) out.files.push(rel);
|
|
210
|
+
}
|
|
211
|
+
out.files.sort();
|
|
212
|
+
return out;
|
|
213
|
+
}
|
|
214
|
+
function within(dest, path) {
|
|
215
|
+
if (!isValidPath(path)) throw new Error(`refusing to write ${JSON.stringify(path)}: not a skill path`);
|
|
216
|
+
const full = resolve(dest, ...path.split("/"));
|
|
217
|
+
if (!full.startsWith(resolve(dest) + sep)) throw new Error(`refusing to write outside ${dest}: ${path}`);
|
|
218
|
+
return full;
|
|
219
|
+
}
|
|
220
|
+
async function readManifest(dest) {
|
|
221
|
+
try {
|
|
222
|
+
return JSON.parse(await readFile(join(dest, MANIFEST_FILE), "utf8"));
|
|
223
|
+
} catch {
|
|
224
|
+
return void 0;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
async function installSkill(version, dest, { overwrite = false } = {}) {
|
|
228
|
+
if (!isValidName(version.name)) throw new Error(`refusing to install ${JSON.stringify(version.name)}: not a skill name`);
|
|
229
|
+
const incoming = new Map(version.files.map((f) => [f.path, f]));
|
|
230
|
+
for (const f of version.files) within(dest, f.path);
|
|
231
|
+
if (existsSync(dest) && (await lstat(dest)).isSymbolicLink()) {
|
|
232
|
+
throw new Error(`${dest} is a link: install into a real folder`);
|
|
233
|
+
}
|
|
234
|
+
const walked = existsSync(dest) ? await walk(dest, { skipHidden: false }) : { files: [], links: [], hidden: [] };
|
|
235
|
+
if (walked.links.length) {
|
|
236
|
+
throw new Error(`${dest} holds links (${walked.links.join(", ")}): remove them, nothing was written`);
|
|
237
|
+
}
|
|
238
|
+
const present = walked.files;
|
|
239
|
+
const manifest = await readManifest(dest);
|
|
240
|
+
const known = Object.fromEntries(
|
|
241
|
+
Object.entries(manifest?.files ?? {}).filter(([p, h]) => isValidPath(p) && typeof h === "string")
|
|
242
|
+
);
|
|
243
|
+
const edited = [];
|
|
244
|
+
const untracked = [];
|
|
245
|
+
for (const path of present) {
|
|
246
|
+
const content = await readFile(join(dest, ...path.split("/")), "utf8");
|
|
247
|
+
if (Object.hasOwn(known, path)) {
|
|
248
|
+
if (sha256(content) !== known[path]) edited.push(path);
|
|
249
|
+
} else if (!incoming.has(path) || sha256(content) !== incoming.get(path).sha256) {
|
|
250
|
+
if (incoming.has(path) || !manifest) edited.push(path);
|
|
251
|
+
else untracked.push(path);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
const blocking = edited.filter((p) => incoming.has(p) || !manifest);
|
|
255
|
+
if (blocking.length && !overwrite) throw new SkillInstallRefused(dest, blocking);
|
|
256
|
+
const written = [];
|
|
257
|
+
for (const f of version.files) {
|
|
258
|
+
const full = within(dest, f.path);
|
|
259
|
+
await mkdir(dirname(full), { recursive: true });
|
|
260
|
+
await writeFile(full, f.content, "utf8");
|
|
261
|
+
written.push(f.path);
|
|
262
|
+
}
|
|
263
|
+
const removed = [];
|
|
264
|
+
const keptEdited = [];
|
|
265
|
+
for (const path of Object.keys(known)) {
|
|
266
|
+
if (incoming.has(path) || !present.includes(path)) continue;
|
|
267
|
+
if (edited.includes(path)) {
|
|
268
|
+
keptEdited.push(path);
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
await rm(within(dest, path));
|
|
272
|
+
removed.push(path);
|
|
273
|
+
}
|
|
274
|
+
const next = {
|
|
275
|
+
name: version.name,
|
|
276
|
+
version: version.version,
|
|
277
|
+
installedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
278
|
+
files: Object.fromEntries(version.files.map((f) => [f.path, f.sha256]))
|
|
279
|
+
};
|
|
280
|
+
await writeFile(join(dest, MANIFEST_FILE), JSON.stringify(next, null, 2) + "\n", "utf8");
|
|
281
|
+
return {
|
|
282
|
+
dest,
|
|
283
|
+
version: version.version,
|
|
284
|
+
written,
|
|
285
|
+
removed,
|
|
286
|
+
keptUntracked: untracked.filter((p) => !incoming.has(p)),
|
|
287
|
+
keptEdited
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
function describeInstall(name, r) {
|
|
291
|
+
const lines = [
|
|
292
|
+
`Installed ${name} version ${r.version} into ${r.dest} (${r.written.length} file(s)).`
|
|
293
|
+
];
|
|
294
|
+
if (r.removed.length) lines.push(`Removed (no longer in the skill): ${r.removed.join(", ")}`);
|
|
295
|
+
if (r.keptEdited.length) {
|
|
296
|
+
lines.push(`Kept, edited here and no longer in the skill: ${r.keptEdited.join(", ")}`);
|
|
297
|
+
}
|
|
298
|
+
if (r.keptUntracked.length) lines.push(`Kept, added here: ${r.keptUntracked.join(", ")}`);
|
|
299
|
+
lines.push("Start a new Claude Code session to load it.");
|
|
300
|
+
return lines.join("\n");
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// src/install-skill.ts
|
|
304
|
+
var INSTALL_USAGE = "Usage: systemdox-mcp-server install-skill <name> [--version <n>] [--target <dir>] [--overwrite]";
|
|
305
|
+
function parseInstallArgs(argv) {
|
|
306
|
+
const args = { overwrite: false };
|
|
307
|
+
for (let i = 0; i < argv.length; i++) {
|
|
308
|
+
const a = argv[i];
|
|
309
|
+
if (a === "--overwrite") args.overwrite = true;
|
|
310
|
+
else if (a === "--version" || a === "--target") {
|
|
311
|
+
const value = argv[++i];
|
|
312
|
+
if (!value) throw new Error(`${a} needs a value
|
|
313
|
+
${INSTALL_USAGE}`);
|
|
314
|
+
if (a === "--target") args.target = value;
|
|
315
|
+
else if (/^[1-9]\d*$/.test(value)) args.version = Number(value);
|
|
316
|
+
else throw new Error(`--version must be a whole number, got ${value}`);
|
|
317
|
+
} else if (a.startsWith("-")) throw new Error(`Unknown option ${a}
|
|
318
|
+
${INSTALL_USAGE}`);
|
|
319
|
+
else if (!args.name) args.name = a;
|
|
320
|
+
else throw new Error(`Unexpected argument ${a}
|
|
321
|
+
${INSTALL_USAGE}`);
|
|
322
|
+
}
|
|
323
|
+
if (!args.name) throw new Error(INSTALL_USAGE);
|
|
324
|
+
if (!isValidName(args.name)) throw new Error(`${args.name} is not a skill name`);
|
|
325
|
+
if (args.target && basename(resolve2(args.target)) !== args.name) {
|
|
326
|
+
throw new Error(`--target must be a folder named ${args.name} (e.g. .claude/skills/${args.name})`);
|
|
327
|
+
}
|
|
328
|
+
return args;
|
|
329
|
+
}
|
|
330
|
+
async function runInstallSkill(argv, io) {
|
|
331
|
+
let args;
|
|
332
|
+
try {
|
|
333
|
+
args = parseInstallArgs(argv);
|
|
334
|
+
} catch (e) {
|
|
335
|
+
io.err(e.message);
|
|
336
|
+
return 1;
|
|
337
|
+
}
|
|
338
|
+
let client = io.client;
|
|
339
|
+
if (!client) {
|
|
340
|
+
try {
|
|
341
|
+
client = new SystemDoxApiClient(loadConfig());
|
|
342
|
+
} catch (e) {
|
|
343
|
+
io.err(
|
|
344
|
+
`${e.message}
|
|
345
|
+
Set SYSTEMDOX_API_KEY in your environment (create a key under Settings \u203A API keys in SystemDox), then run this again.`
|
|
346
|
+
);
|
|
347
|
+
return 1;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
let version;
|
|
351
|
+
try {
|
|
352
|
+
version = await client.getFresh(
|
|
353
|
+
`/v1/standards/skills/${encodeURIComponent(args.name)}/versions/${args.version ?? "latest"}`
|
|
354
|
+
);
|
|
355
|
+
} catch (e) {
|
|
356
|
+
io.err(e.message);
|
|
357
|
+
return 1;
|
|
358
|
+
}
|
|
359
|
+
try {
|
|
360
|
+
const result = await installSkill(version, args.target ?? defaultSkillDir(version.name), {
|
|
361
|
+
overwrite: args.overwrite
|
|
362
|
+
});
|
|
363
|
+
io.out(describeInstall(version.name, result));
|
|
364
|
+
return 0;
|
|
365
|
+
} catch (e) {
|
|
366
|
+
io.err(e.message);
|
|
367
|
+
return e instanceof SkillInstallRefused ? 2 : 1;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
export {
|
|
371
|
+
INSTALL_USAGE,
|
|
372
|
+
parseInstallArgs,
|
|
373
|
+
runInstallSkill
|
|
374
|
+
};
|
package/dist/lib/api-client.d.ts
CHANGED
|
@@ -5,6 +5,9 @@ export declare class SystemDoxApiClient {
|
|
|
5
5
|
constructor(config: ServerConfig);
|
|
6
6
|
validate(): Promise<void>;
|
|
7
7
|
get<T>(path: string, params?: Record<string, string>): Promise<T>;
|
|
8
|
+
/** A read that skips the cache: an install must take the version that is
|
|
9
|
+
* latest now, not the one cached before someone published another. */
|
|
10
|
+
getFresh<T>(path: string): Promise<T>;
|
|
8
11
|
post<T>(path: string, body: unknown): Promise<T>;
|
|
9
12
|
put<T>(path: string, body: unknown): Promise<T>;
|
|
10
13
|
private mutate;
|
package/dist/lib/api-client.js
CHANGED
|
@@ -60,6 +60,16 @@ var SystemDoxApiClient = class {
|
|
|
60
60
|
this.cache.set(cacheKey, data);
|
|
61
61
|
return data;
|
|
62
62
|
}
|
|
63
|
+
/** A read that skips the cache: an install must take the version that is
|
|
64
|
+
* latest now, not the one cached before someone published another. */
|
|
65
|
+
async getFresh(path) {
|
|
66
|
+
const res = await this.fetchRaw(path);
|
|
67
|
+
if (!res.ok) {
|
|
68
|
+
const body = await res.text();
|
|
69
|
+
throw new Error(`SystemDox API error ${res.status}: ${body}`);
|
|
70
|
+
}
|
|
71
|
+
return await res.json();
|
|
72
|
+
}
|
|
63
73
|
async post(path, body) {
|
|
64
74
|
return this.mutate("POST", path, body);
|
|
65
75
|
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authored skills on the local disk (ADR-216): read a skill folder to publish
|
|
3
|
+
* it, and install a published version into a folder without silently
|
|
4
|
+
* overwriting what someone edited there.
|
|
5
|
+
*
|
|
6
|
+
* Shared by the stdio server's publish_skill / install_skill tools and by the
|
|
7
|
+
* `install-skill` CLI command, so a skill installed either way leaves the same
|
|
8
|
+
* manifest and is refused on the same edits. The hosted (HTTP) server never
|
|
9
|
+
* calls the disk half: it cannot see the user's computer.
|
|
10
|
+
*
|
|
11
|
+
* The manifest `.systemdox-skill.json` records the version and every file's
|
|
12
|
+
* SHA-256 as installed. A file whose hash no longer matches was edited here;
|
|
13
|
+
* an install stops and names it unless asked to overwrite.
|
|
14
|
+
*/
|
|
15
|
+
export declare const MANIFEST_FILE = ".systemdox-skill.json";
|
|
16
|
+
export declare const ENTRY_FILE = "SKILL.md";
|
|
17
|
+
/** The server's limits (backend common/authored_skills.py), checked early so
|
|
18
|
+
* a local mistake is named before anything is sent. */
|
|
19
|
+
export declare const MAX_FILES = 100;
|
|
20
|
+
export declare const MAX_FILE_BYTES: number;
|
|
21
|
+
export declare const MAX_TOTAL_BYTES: number;
|
|
22
|
+
export interface SkillFileIn {
|
|
23
|
+
path: string;
|
|
24
|
+
content: string;
|
|
25
|
+
}
|
|
26
|
+
export interface SkillFileOut extends SkillFileIn {
|
|
27
|
+
bytes: number;
|
|
28
|
+
sha256: string;
|
|
29
|
+
}
|
|
30
|
+
export interface SkillVersionFiles {
|
|
31
|
+
name: string;
|
|
32
|
+
version: number;
|
|
33
|
+
description: string;
|
|
34
|
+
publishedAt: string;
|
|
35
|
+
publishedBy?: string;
|
|
36
|
+
files: SkillFileOut[];
|
|
37
|
+
}
|
|
38
|
+
export interface SkillManifest {
|
|
39
|
+
name: string;
|
|
40
|
+
version: number;
|
|
41
|
+
installedAt: string;
|
|
42
|
+
files: Record<string, string>;
|
|
43
|
+
}
|
|
44
|
+
export interface InstallResult {
|
|
45
|
+
dest: string;
|
|
46
|
+
version: number;
|
|
47
|
+
written: string[];
|
|
48
|
+
removed: string[];
|
|
49
|
+
/** Files someone added here that the skill does not ship: left alone. */
|
|
50
|
+
keptUntracked: string[];
|
|
51
|
+
/** Edited files the new version no longer ships: left alone. */
|
|
52
|
+
keptEdited: string[];
|
|
53
|
+
}
|
|
54
|
+
export declare class SkillInstallRefused extends Error {
|
|
55
|
+
readonly dest: string;
|
|
56
|
+
readonly edited: string[];
|
|
57
|
+
constructor(dest: string, edited: string[]);
|
|
58
|
+
}
|
|
59
|
+
export declare function sha256(content: string): string;
|
|
60
|
+
export declare function isValidName(name: string): boolean;
|
|
61
|
+
export declare function isValidPath(path: string): boolean;
|
|
62
|
+
/** `name` from SKILL.md's frontmatter, or undefined. */
|
|
63
|
+
export declare function frontmatterName(skillMd: string): string | undefined;
|
|
64
|
+
/** The default install folder: personal skills, every project (ADR-216 §6 Q1). */
|
|
65
|
+
export declare function defaultSkillDir(name: string): string;
|
|
66
|
+
/**
|
|
67
|
+
* Read a skill folder exactly as it is on disk (bytes decoded as UTF-8, never
|
|
68
|
+
* re-typed), ready to publish. Throws naming the first file that cannot be
|
|
69
|
+
* part of a skill.
|
|
70
|
+
*/
|
|
71
|
+
export declare function readSkillDirectory(directory: string): Promise<{
|
|
72
|
+
name: string;
|
|
73
|
+
files: SkillFileIn[];
|
|
74
|
+
skipped: string[];
|
|
75
|
+
}>;
|
|
76
|
+
/**
|
|
77
|
+
* Write a published version into `dest`. Stops before writing anything when
|
|
78
|
+
* a file there was edited since it was installed (or the folder holds files
|
|
79
|
+
* with no manifest to vouch for them), unless `overwrite`.
|
|
80
|
+
*/
|
|
81
|
+
export declare function installSkill(version: SkillVersionFiles, dest: string, { overwrite }?: {
|
|
82
|
+
overwrite?: boolean;
|
|
83
|
+
}): Promise<InstallResult>;
|
|
84
|
+
/** A fence longer than any backtick run in `text`, so content cannot close it. */
|
|
85
|
+
export declare function fenceFor(text: string): string;
|
|
86
|
+
/** Human summary of an install, shared by the tool and the CLI. */
|
|
87
|
+
export declare function describeInstall(name: string, r: InstallResult): string;
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/lib/skills.ts
|
|
4
|
+
import { createHash } from "crypto";
|
|
5
|
+
import { lstat, mkdir, readdir, readFile, rm, writeFile } from "fs/promises";
|
|
6
|
+
import { existsSync } from "fs";
|
|
7
|
+
import { homedir } from "os";
|
|
8
|
+
import { dirname, join, relative, resolve, sep } from "path";
|
|
9
|
+
var MANIFEST_FILE = ".systemdox-skill.json";
|
|
10
|
+
var ENTRY_FILE = "SKILL.md";
|
|
11
|
+
var MAX_FILES = 100;
|
|
12
|
+
var MAX_FILE_BYTES = 256 * 1024;
|
|
13
|
+
var MAX_TOTAL_BYTES = 1024 * 1024;
|
|
14
|
+
var NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
15
|
+
var SEGMENT_RE = /^[A-Za-z0-9._-]+$/;
|
|
16
|
+
var SKIPPED = /* @__PURE__ */ new Set([MANIFEST_FILE, ".git", "node_modules", "__pycache__", ".DS_Store"]);
|
|
17
|
+
var SkillInstallRefused = class extends Error {
|
|
18
|
+
constructor(dest, edited) {
|
|
19
|
+
super(
|
|
20
|
+
`${dest} has local changes in ${edited.length} file(s): ${edited.join(", ")}. Nothing was written. Move your changes aside, or install again with overwrite.`
|
|
21
|
+
);
|
|
22
|
+
this.dest = dest;
|
|
23
|
+
this.edited = edited;
|
|
24
|
+
}
|
|
25
|
+
dest;
|
|
26
|
+
edited;
|
|
27
|
+
};
|
|
28
|
+
function sha256(content) {
|
|
29
|
+
return createHash("sha256").update(content, "utf8").digest("hex");
|
|
30
|
+
}
|
|
31
|
+
function isValidName(name) {
|
|
32
|
+
return name.length <= 64 && NAME_RE.test(name);
|
|
33
|
+
}
|
|
34
|
+
function isValidPath(path) {
|
|
35
|
+
return path.length > 0 && path.length <= 512 && !path.startsWith("/") && path.split("/").every((s) => SEGMENT_RE.test(s) && s !== "." && s !== "..");
|
|
36
|
+
}
|
|
37
|
+
function frontmatterName(skillMd) {
|
|
38
|
+
const block = /^---\r?\n([\s\S]*?)\r?\n---/.exec(skillMd);
|
|
39
|
+
if (!block) return void 0;
|
|
40
|
+
const line = /^name:[ \t]*(.+?)[ \t]*$/m.exec(block[1]);
|
|
41
|
+
return line ? line[1].replace(/^(['"])(.*)\1$/, "$2") : void 0;
|
|
42
|
+
}
|
|
43
|
+
function defaultSkillDir(name) {
|
|
44
|
+
return join(homedir(), ".claude", "skills", name);
|
|
45
|
+
}
|
|
46
|
+
async function walk(root, { skipHidden }, dir = root, out = { files: [], links: [], hidden: [] }) {
|
|
47
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
48
|
+
if (SKIPPED.has(entry.name) || entry.name.endsWith(".pyc")) continue;
|
|
49
|
+
const full = join(dir, entry.name);
|
|
50
|
+
const rel = relative(root, full).split(sep).join("/");
|
|
51
|
+
if (entry.isSymbolicLink()) out.links.push(rel);
|
|
52
|
+
else if (skipHidden && entry.name.startsWith(".")) out.hidden.push(rel);
|
|
53
|
+
else if (entry.isDirectory()) await walk(root, { skipHidden }, full, out);
|
|
54
|
+
else if (entry.isFile()) out.files.push(rel);
|
|
55
|
+
}
|
|
56
|
+
out.files.sort();
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
async function readSkillDirectory(directory) {
|
|
60
|
+
const root = resolve(directory);
|
|
61
|
+
if (!existsSync(join(root, ENTRY_FILE))) {
|
|
62
|
+
throw new Error(`${root} has no ${ENTRY_FILE}: a skill folder holds SKILL.md at its root`);
|
|
63
|
+
}
|
|
64
|
+
const walked = await walk(root, { skipHidden: true });
|
|
65
|
+
if (walked.links.length) {
|
|
66
|
+
throw new Error(`${walked.links.join(", ")}: links are not followed; copy the file into the skill folder`);
|
|
67
|
+
}
|
|
68
|
+
const paths = walked.files;
|
|
69
|
+
if (paths.length > MAX_FILES) {
|
|
70
|
+
throw new Error(`${root} holds ${paths.length} files: a skill holds at most ${MAX_FILES}`);
|
|
71
|
+
}
|
|
72
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
73
|
+
const files = [];
|
|
74
|
+
let total = 0;
|
|
75
|
+
for (const path of paths) {
|
|
76
|
+
if (!isValidPath(path)) {
|
|
77
|
+
throw new Error(`${path}: file names may use letters, digits, ".", "_" and "-" only`);
|
|
78
|
+
}
|
|
79
|
+
const raw = await readFile(join(root, ...path.split("/")));
|
|
80
|
+
if (raw.includes(0)) throw new Error(`${path} is binary: a skill holds text files only`);
|
|
81
|
+
if (raw.length > MAX_FILE_BYTES) {
|
|
82
|
+
throw new Error(`${path} is ${raw.length} bytes: a file holds at most ${MAX_FILE_BYTES}`);
|
|
83
|
+
}
|
|
84
|
+
total += raw.length;
|
|
85
|
+
if (total > MAX_TOTAL_BYTES) {
|
|
86
|
+
throw new Error(`the files pass ${MAX_TOTAL_BYTES} bytes at ${path}`);
|
|
87
|
+
}
|
|
88
|
+
let content;
|
|
89
|
+
try {
|
|
90
|
+
content = decoder.decode(raw);
|
|
91
|
+
} catch {
|
|
92
|
+
throw new Error(`${path} is not UTF-8 text`);
|
|
93
|
+
}
|
|
94
|
+
files.push({ path, content });
|
|
95
|
+
}
|
|
96
|
+
const skillMd = files.find((f) => f.path === ENTRY_FILE);
|
|
97
|
+
const name = frontmatterName(skillMd.content);
|
|
98
|
+
if (!name) throw new Error(`${ENTRY_FILE} has no frontmatter name`);
|
|
99
|
+
return { name, files, skipped: walked.hidden };
|
|
100
|
+
}
|
|
101
|
+
function within(dest, path) {
|
|
102
|
+
if (!isValidPath(path)) throw new Error(`refusing to write ${JSON.stringify(path)}: not a skill path`);
|
|
103
|
+
const full = resolve(dest, ...path.split("/"));
|
|
104
|
+
if (!full.startsWith(resolve(dest) + sep)) throw new Error(`refusing to write outside ${dest}: ${path}`);
|
|
105
|
+
return full;
|
|
106
|
+
}
|
|
107
|
+
async function readManifest(dest) {
|
|
108
|
+
try {
|
|
109
|
+
return JSON.parse(await readFile(join(dest, MANIFEST_FILE), "utf8"));
|
|
110
|
+
} catch {
|
|
111
|
+
return void 0;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
async function installSkill(version, dest, { overwrite = false } = {}) {
|
|
115
|
+
if (!isValidName(version.name)) throw new Error(`refusing to install ${JSON.stringify(version.name)}: not a skill name`);
|
|
116
|
+
const incoming = new Map(version.files.map((f) => [f.path, f]));
|
|
117
|
+
for (const f of version.files) within(dest, f.path);
|
|
118
|
+
if (existsSync(dest) && (await lstat(dest)).isSymbolicLink()) {
|
|
119
|
+
throw new Error(`${dest} is a link: install into a real folder`);
|
|
120
|
+
}
|
|
121
|
+
const walked = existsSync(dest) ? await walk(dest, { skipHidden: false }) : { files: [], links: [], hidden: [] };
|
|
122
|
+
if (walked.links.length) {
|
|
123
|
+
throw new Error(`${dest} holds links (${walked.links.join(", ")}): remove them, nothing was written`);
|
|
124
|
+
}
|
|
125
|
+
const present = walked.files;
|
|
126
|
+
const manifest = await readManifest(dest);
|
|
127
|
+
const known = Object.fromEntries(
|
|
128
|
+
Object.entries(manifest?.files ?? {}).filter(([p, h]) => isValidPath(p) && typeof h === "string")
|
|
129
|
+
);
|
|
130
|
+
const edited = [];
|
|
131
|
+
const untracked = [];
|
|
132
|
+
for (const path of present) {
|
|
133
|
+
const content = await readFile(join(dest, ...path.split("/")), "utf8");
|
|
134
|
+
if (Object.hasOwn(known, path)) {
|
|
135
|
+
if (sha256(content) !== known[path]) edited.push(path);
|
|
136
|
+
} else if (!incoming.has(path) || sha256(content) !== incoming.get(path).sha256) {
|
|
137
|
+
if (incoming.has(path) || !manifest) edited.push(path);
|
|
138
|
+
else untracked.push(path);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const blocking = edited.filter((p) => incoming.has(p) || !manifest);
|
|
142
|
+
if (blocking.length && !overwrite) throw new SkillInstallRefused(dest, blocking);
|
|
143
|
+
const written = [];
|
|
144
|
+
for (const f of version.files) {
|
|
145
|
+
const full = within(dest, f.path);
|
|
146
|
+
await mkdir(dirname(full), { recursive: true });
|
|
147
|
+
await writeFile(full, f.content, "utf8");
|
|
148
|
+
written.push(f.path);
|
|
149
|
+
}
|
|
150
|
+
const removed = [];
|
|
151
|
+
const keptEdited = [];
|
|
152
|
+
for (const path of Object.keys(known)) {
|
|
153
|
+
if (incoming.has(path) || !present.includes(path)) continue;
|
|
154
|
+
if (edited.includes(path)) {
|
|
155
|
+
keptEdited.push(path);
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
await rm(within(dest, path));
|
|
159
|
+
removed.push(path);
|
|
160
|
+
}
|
|
161
|
+
const next = {
|
|
162
|
+
name: version.name,
|
|
163
|
+
version: version.version,
|
|
164
|
+
installedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
165
|
+
files: Object.fromEntries(version.files.map((f) => [f.path, f.sha256]))
|
|
166
|
+
};
|
|
167
|
+
await writeFile(join(dest, MANIFEST_FILE), JSON.stringify(next, null, 2) + "\n", "utf8");
|
|
168
|
+
return {
|
|
169
|
+
dest,
|
|
170
|
+
version: version.version,
|
|
171
|
+
written,
|
|
172
|
+
removed,
|
|
173
|
+
keptUntracked: untracked.filter((p) => !incoming.has(p)),
|
|
174
|
+
keptEdited
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
function fenceFor(text) {
|
|
178
|
+
const longest = Math.max(0, ...(text.match(/`+/g) ?? []).map((r) => r.length));
|
|
179
|
+
return "`".repeat(Math.max(3, longest + 1));
|
|
180
|
+
}
|
|
181
|
+
function describeInstall(name, r) {
|
|
182
|
+
const lines = [
|
|
183
|
+
`Installed ${name} version ${r.version} into ${r.dest} (${r.written.length} file(s)).`
|
|
184
|
+
];
|
|
185
|
+
if (r.removed.length) lines.push(`Removed (no longer in the skill): ${r.removed.join(", ")}`);
|
|
186
|
+
if (r.keptEdited.length) {
|
|
187
|
+
lines.push(`Kept, edited here and no longer in the skill: ${r.keptEdited.join(", ")}`);
|
|
188
|
+
}
|
|
189
|
+
if (r.keptUntracked.length) lines.push(`Kept, added here: ${r.keptUntracked.join(", ")}`);
|
|
190
|
+
lines.push("Start a new Claude Code session to load it.");
|
|
191
|
+
return lines.join("\n");
|
|
192
|
+
}
|
|
193
|
+
export {
|
|
194
|
+
ENTRY_FILE,
|
|
195
|
+
MANIFEST_FILE,
|
|
196
|
+
MAX_FILES,
|
|
197
|
+
MAX_FILE_BYTES,
|
|
198
|
+
MAX_TOTAL_BYTES,
|
|
199
|
+
SkillInstallRefused,
|
|
200
|
+
defaultSkillDir,
|
|
201
|
+
describeInstall,
|
|
202
|
+
fenceFor,
|
|
203
|
+
frontmatterName,
|
|
204
|
+
installSkill,
|
|
205
|
+
isValidName,
|
|
206
|
+
isValidPath,
|
|
207
|
+
readSkillDirectory,
|
|
208
|
+
sha256
|
|
209
|
+
};
|