@systemdox/mcp-server 0.1.41 → 0.1.43

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/dist/cli.js CHANGED
@@ -122,6 +122,16 @@ var init_api_client = __esm({
122
122
  this.cache.set(cacheKey, data);
123
123
  return data;
124
124
  }
125
+ /** A read that skips the cache: an install must take the version that is
126
+ * latest now, not the one cached before someone published another. */
127
+ async getFresh(path) {
128
+ const res = await this.fetchRaw(path);
129
+ if (!res.ok) {
130
+ const body = await res.text();
131
+ throw new Error(`SystemDox API error ${res.status}: ${body}`);
132
+ }
133
+ return await res.json();
134
+ }
125
135
  async post(path, body) {
126
136
  return this.mutate("POST", path, body);
127
137
  }
@@ -142,8 +152,8 @@ var init_api_client = __esm({
142
152
  body: JSON.stringify(body)
143
153
  });
144
154
  if (!res.ok) {
145
- const text = await res.text();
146
- throw new Error(`SystemDox API error ${res.status}: ${text}`);
155
+ const text2 = await res.text();
156
+ throw new Error(`SystemDox API error ${res.status}: ${text2}`);
147
157
  }
148
158
  return await res.json();
149
159
  }
@@ -178,6 +188,287 @@ var init_api_client = __esm({
178
188
  }
179
189
  });
180
190
 
191
+ // src/lib/skills.ts
192
+ import { createHash } from "crypto";
193
+ import { lstat, mkdir, readdir, readFile, rm, writeFile } from "fs/promises";
194
+ import { existsSync } from "fs";
195
+ import { homedir } from "os";
196
+ import { dirname, join, relative, resolve, sep } from "path";
197
+ function sha256(content) {
198
+ return createHash("sha256").update(content, "utf8").digest("hex");
199
+ }
200
+ function isValidName(name) {
201
+ return name.length <= 64 && NAME_RE.test(name);
202
+ }
203
+ function isValidPath(path) {
204
+ return path.length > 0 && path.length <= 512 && !path.startsWith("/") && path.split("/").every((s) => SEGMENT_RE.test(s) && s !== "." && s !== "..");
205
+ }
206
+ function frontmatterName(skillMd) {
207
+ const block = /^---\r?\n([\s\S]*?)\r?\n---/.exec(skillMd);
208
+ if (!block) return void 0;
209
+ const line = /^name:[ \t]*(.+?)[ \t]*$/m.exec(block[1]);
210
+ return line ? line[1].replace(/^(['"])(.*)\1$/, "$2") : void 0;
211
+ }
212
+ function defaultSkillDir(name) {
213
+ return join(homedir(), ".claude", "skills", name);
214
+ }
215
+ async function walk(root, { skipHidden }, dir = root, out = { files: [], links: [], hidden: [] }) {
216
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
217
+ if (SKIPPED.has(entry.name) || entry.name.endsWith(".pyc")) continue;
218
+ const full = join(dir, entry.name);
219
+ const rel = relative(root, full).split(sep).join("/");
220
+ if (entry.isSymbolicLink()) out.links.push(rel);
221
+ else if (skipHidden && entry.name.startsWith(".")) out.hidden.push(rel);
222
+ else if (entry.isDirectory()) await walk(root, { skipHidden }, full, out);
223
+ else if (entry.isFile()) out.files.push(rel);
224
+ }
225
+ out.files.sort();
226
+ return out;
227
+ }
228
+ async function readSkillDirectory(directory) {
229
+ const root = resolve(directory);
230
+ if (!existsSync(join(root, ENTRY_FILE))) {
231
+ throw new Error(`${root} has no ${ENTRY_FILE}: a skill folder holds SKILL.md at its root`);
232
+ }
233
+ const walked = await walk(root, { skipHidden: true });
234
+ if (walked.links.length) {
235
+ throw new Error(`${walked.links.join(", ")}: links are not followed; copy the file into the skill folder`);
236
+ }
237
+ const paths = walked.files;
238
+ if (paths.length > MAX_FILES) {
239
+ throw new Error(`${root} holds ${paths.length} files: a skill holds at most ${MAX_FILES}`);
240
+ }
241
+ const decoder = new TextDecoder("utf-8", { fatal: true });
242
+ const files = [];
243
+ let total = 0;
244
+ for (const path of paths) {
245
+ if (!isValidPath(path)) {
246
+ throw new Error(`${path}: file names may use letters, digits, ".", "_" and "-" only`);
247
+ }
248
+ const raw = await readFile(join(root, ...path.split("/")));
249
+ if (raw.includes(0)) throw new Error(`${path} is binary: a skill holds text files only`);
250
+ if (raw.length > MAX_FILE_BYTES) {
251
+ throw new Error(`${path} is ${raw.length} bytes: a file holds at most ${MAX_FILE_BYTES}`);
252
+ }
253
+ total += raw.length;
254
+ if (total > MAX_TOTAL_BYTES) {
255
+ throw new Error(`the files pass ${MAX_TOTAL_BYTES} bytes at ${path}`);
256
+ }
257
+ let content;
258
+ try {
259
+ content = decoder.decode(raw);
260
+ } catch {
261
+ throw new Error(`${path} is not UTF-8 text`);
262
+ }
263
+ files.push({ path, content });
264
+ }
265
+ const skillMd = files.find((f) => f.path === ENTRY_FILE);
266
+ const name = frontmatterName(skillMd.content);
267
+ if (!name) throw new Error(`${ENTRY_FILE} has no frontmatter name`);
268
+ return { name, files, skipped: walked.hidden };
269
+ }
270
+ function within(dest, path) {
271
+ if (!isValidPath(path)) throw new Error(`refusing to write ${JSON.stringify(path)}: not a skill path`);
272
+ const full = resolve(dest, ...path.split("/"));
273
+ if (!full.startsWith(resolve(dest) + sep)) throw new Error(`refusing to write outside ${dest}: ${path}`);
274
+ return full;
275
+ }
276
+ async function readManifest(dest) {
277
+ try {
278
+ return JSON.parse(await readFile(join(dest, MANIFEST_FILE), "utf8"));
279
+ } catch {
280
+ return void 0;
281
+ }
282
+ }
283
+ async function installSkill(version, dest, { overwrite = false } = {}) {
284
+ if (!isValidName(version.name)) throw new Error(`refusing to install ${JSON.stringify(version.name)}: not a skill name`);
285
+ const incoming = new Map(version.files.map((f) => [f.path, f]));
286
+ for (const f of version.files) within(dest, f.path);
287
+ if (existsSync(dest) && (await lstat(dest)).isSymbolicLink()) {
288
+ throw new Error(`${dest} is a link: install into a real folder`);
289
+ }
290
+ const walked = existsSync(dest) ? await walk(dest, { skipHidden: false }) : { files: [], links: [], hidden: [] };
291
+ if (walked.links.length) {
292
+ throw new Error(`${dest} holds links (${walked.links.join(", ")}): remove them, nothing was written`);
293
+ }
294
+ const present = walked.files;
295
+ const manifest = await readManifest(dest);
296
+ const known = Object.fromEntries(
297
+ Object.entries(manifest?.files ?? {}).filter(([p, h]) => isValidPath(p) && typeof h === "string")
298
+ );
299
+ const edited = [];
300
+ const untracked = [];
301
+ for (const path of present) {
302
+ const content = await readFile(join(dest, ...path.split("/")), "utf8");
303
+ if (Object.hasOwn(known, path)) {
304
+ if (sha256(content) !== known[path]) edited.push(path);
305
+ } else if (!incoming.has(path) || sha256(content) !== incoming.get(path).sha256) {
306
+ if (incoming.has(path) || !manifest) edited.push(path);
307
+ else untracked.push(path);
308
+ }
309
+ }
310
+ const blocking = edited.filter((p) => incoming.has(p) || !manifest);
311
+ if (blocking.length && !overwrite) throw new SkillInstallRefused(dest, blocking);
312
+ const written = [];
313
+ for (const f of version.files) {
314
+ const full = within(dest, f.path);
315
+ await mkdir(dirname(full), { recursive: true });
316
+ await writeFile(full, f.content, "utf8");
317
+ written.push(f.path);
318
+ }
319
+ const removed = [];
320
+ const keptEdited = [];
321
+ for (const path of Object.keys(known)) {
322
+ if (incoming.has(path) || !present.includes(path)) continue;
323
+ if (edited.includes(path)) {
324
+ keptEdited.push(path);
325
+ continue;
326
+ }
327
+ await rm(within(dest, path));
328
+ removed.push(path);
329
+ }
330
+ const next = {
331
+ name: version.name,
332
+ version: version.version,
333
+ installedAt: (/* @__PURE__ */ new Date()).toISOString(),
334
+ files: Object.fromEntries(version.files.map((f) => [f.path, f.sha256]))
335
+ };
336
+ await writeFile(join(dest, MANIFEST_FILE), JSON.stringify(next, null, 2) + "\n", "utf8");
337
+ return {
338
+ dest,
339
+ version: version.version,
340
+ written,
341
+ removed,
342
+ keptUntracked: untracked.filter((p) => !incoming.has(p)),
343
+ keptEdited
344
+ };
345
+ }
346
+ function fenceFor(text2) {
347
+ const longest = Math.max(0, ...(text2.match(/`+/g) ?? []).map((r) => r.length));
348
+ return "`".repeat(Math.max(3, longest + 1));
349
+ }
350
+ function describeInstall(name, r) {
351
+ const lines = [
352
+ `Installed ${name} version ${r.version} into ${r.dest} (${r.written.length} file(s)).`
353
+ ];
354
+ if (r.removed.length) lines.push(`Removed (no longer in the skill): ${r.removed.join(", ")}`);
355
+ if (r.keptEdited.length) {
356
+ lines.push(`Kept, edited here and no longer in the skill: ${r.keptEdited.join(", ")}`);
357
+ }
358
+ if (r.keptUntracked.length) lines.push(`Kept, added here: ${r.keptUntracked.join(", ")}`);
359
+ lines.push("Start a new Claude Code session to load it.");
360
+ return lines.join("\n");
361
+ }
362
+ var MANIFEST_FILE, ENTRY_FILE, MAX_FILES, MAX_FILE_BYTES, MAX_TOTAL_BYTES, NAME_RE, SEGMENT_RE, SKIPPED, SkillInstallRefused;
363
+ var init_skills = __esm({
364
+ "src/lib/skills.ts"() {
365
+ "use strict";
366
+ MANIFEST_FILE = ".systemdox-skill.json";
367
+ ENTRY_FILE = "SKILL.md";
368
+ MAX_FILES = 100;
369
+ MAX_FILE_BYTES = 256 * 1024;
370
+ MAX_TOTAL_BYTES = 1024 * 1024;
371
+ NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
372
+ SEGMENT_RE = /^[A-Za-z0-9._-]+$/;
373
+ SKIPPED = /* @__PURE__ */ new Set([MANIFEST_FILE, ".git", "node_modules", "__pycache__", ".DS_Store"]);
374
+ SkillInstallRefused = class extends Error {
375
+ constructor(dest, edited) {
376
+ super(
377
+ `${dest} has local changes in ${edited.length} file(s): ${edited.join(", ")}. Nothing was written. Move your changes aside, or install again with overwrite.`
378
+ );
379
+ this.dest = dest;
380
+ this.edited = edited;
381
+ }
382
+ dest;
383
+ edited;
384
+ };
385
+ }
386
+ });
387
+
388
+ // src/install-skill.ts
389
+ var install_skill_exports = {};
390
+ __export(install_skill_exports, {
391
+ INSTALL_USAGE: () => INSTALL_USAGE,
392
+ parseInstallArgs: () => parseInstallArgs,
393
+ runInstallSkill: () => runInstallSkill
394
+ });
395
+ import { basename, resolve as resolve2 } from "path";
396
+ function parseInstallArgs(argv) {
397
+ const args2 = { overwrite: false };
398
+ for (let i = 0; i < argv.length; i++) {
399
+ const a = argv[i];
400
+ if (a === "--overwrite") args2.overwrite = true;
401
+ else if (a === "--version" || a === "--target") {
402
+ const value = argv[++i];
403
+ if (!value) throw new Error(`${a} needs a value
404
+ ${INSTALL_USAGE}`);
405
+ if (a === "--target") args2.target = value;
406
+ else if (/^[1-9]\d*$/.test(value)) args2.version = Number(value);
407
+ else throw new Error(`--version must be a whole number, got ${value}`);
408
+ } else if (a.startsWith("-")) throw new Error(`Unknown option ${a}
409
+ ${INSTALL_USAGE}`);
410
+ else if (!args2.name) args2.name = a;
411
+ else throw new Error(`Unexpected argument ${a}
412
+ ${INSTALL_USAGE}`);
413
+ }
414
+ if (!args2.name) throw new Error(INSTALL_USAGE);
415
+ if (!isValidName(args2.name)) throw new Error(`${args2.name} is not a skill name`);
416
+ if (args2.target && basename(resolve2(args2.target)) !== args2.name) {
417
+ throw new Error(`--target must be a folder named ${args2.name} (e.g. .claude/skills/${args2.name})`);
418
+ }
419
+ return args2;
420
+ }
421
+ async function runInstallSkill(argv, io) {
422
+ let args2;
423
+ try {
424
+ args2 = parseInstallArgs(argv);
425
+ } catch (e) {
426
+ io.err(e.message);
427
+ return 1;
428
+ }
429
+ let client = io.client;
430
+ if (!client) {
431
+ try {
432
+ client = new SystemDoxApiClient(loadConfig());
433
+ } catch (e) {
434
+ io.err(
435
+ `${e.message}
436
+ Set SYSTEMDOX_API_KEY in your environment (create a key under Settings \u203A API keys in SystemDox), then run this again.`
437
+ );
438
+ return 1;
439
+ }
440
+ }
441
+ let version;
442
+ try {
443
+ version = await client.getFresh(
444
+ `/v1/standards/skills/${encodeURIComponent(args2.name)}/versions/${args2.version ?? "latest"}`
445
+ );
446
+ } catch (e) {
447
+ io.err(e.message);
448
+ return 1;
449
+ }
450
+ try {
451
+ const result = await installSkill(version, args2.target ?? defaultSkillDir(version.name), {
452
+ overwrite: args2.overwrite
453
+ });
454
+ io.out(describeInstall(version.name, result));
455
+ return 0;
456
+ } catch (e) {
457
+ io.err(e.message);
458
+ return e instanceof SkillInstallRefused ? 2 : 1;
459
+ }
460
+ }
461
+ var INSTALL_USAGE;
462
+ var init_install_skill = __esm({
463
+ "src/install-skill.ts"() {
464
+ "use strict";
465
+ init_api_client();
466
+ init_config();
467
+ init_skills();
468
+ INSTALL_USAGE = "Usage: systemdox-mcp-server install-skill <name> [--version <n>] [--target <dir>] [--overwrite]";
469
+ }
470
+ });
471
+
181
472
  // src/lib/language.ts
182
473
  function detectLanguage(filePath) {
183
474
  const ext = filePath.slice(filePath.lastIndexOf("."));
@@ -310,12 +601,12 @@ function registerGetGuardrails(server2, apiClient2) {
310
601
  );
311
602
  }
312
603
  if (filtered.length === 0) {
313
- const text2 = guardrails.length === 0 ? `No checks found. Add checks at ${configLink("/docs/guardrails", await workspaceBase(apiClient2))}` : `No checks found${lang ? ` for language "${lang}"` : ""}${category ? ` in category "${category}"` : ""}.`;
604
+ const text3 = guardrails.length === 0 ? `No checks found. Add checks at ${configLink("/docs/guardrails", await workspaceBase(apiClient2))}` : `No checks found${lang ? ` for language "${lang}"` : ""}${category ? ` in category "${category}"` : ""}.`;
314
605
  return {
315
- content: [{ type: "text", text: text2 }]
606
+ content: [{ type: "text", text: text3 }]
316
607
  };
317
608
  }
318
- const text = filtered.map(
609
+ const text2 = filtered.map(
319
610
  (g) => `## ${g.title} [${g.severity}]
320
611
 
321
612
  ${g.description}
@@ -339,7 +630,7 @@ ${g.fix}
339
630
  type: "text",
340
631
  text: `Found ${filtered.length} check(s):
341
632
 
342
- ${text}`
633
+ ${text2}`
343
634
  }
344
635
  ]
345
636
  };
@@ -450,7 +741,7 @@ ${spec.taskContent}`);
450
741
  ]
451
742
  };
452
743
  }
453
- const text = filtered.map(
744
+ const text2 = filtered.map(
454
745
  (s) => `- **${s.name}** (${s.draftId}) \u2014 ${s.status}, ${s.requirementCount} requirements` + (s.repoFullName ? `, repo: ${s.repoFullName}` : "")
455
746
  ).join("\n");
456
747
  return {
@@ -459,7 +750,7 @@ ${spec.taskContent}`);
459
750
  type: "text",
460
751
  text: `Found ${filtered.length} spec(s):
461
752
 
462
- ${text}`
753
+ ${text2}`
463
754
  }
464
755
  ]
465
756
  };
@@ -517,18 +808,18 @@ function registerGetDocs(server2, apiClient2) {
517
808
  isError: true
518
809
  };
519
810
  }
520
- let text2 = doc.content;
811
+ let text3 = doc.content;
521
812
  if (anchor && doc.section_resolved === false) {
522
- text2 = `_Section "${anchor}" not found \u2014 returning the full document._
813
+ text3 = `_Section "${anchor}" not found \u2014 returning the full document._
523
814
 
524
- ` + text2;
815
+ ` + text3;
525
816
  } else if (anchor && doc.heading) {
526
- text2 = `## ${doc.heading}
817
+ text3 = `## ${doc.heading}
527
818
 
528
819
  ${doc.content}`;
529
820
  }
530
821
  return {
531
- content: [{ type: "text", text: text2 }]
822
+ content: [{ type: "text", text: text3 }]
532
823
  };
533
824
  }
534
825
  const params = {};
@@ -552,7 +843,7 @@ ${doc.content}`;
552
843
  ]
553
844
  };
554
845
  }
555
- const text = items.map((d) => {
846
+ const text2 = items.map((d) => {
556
847
  const sections = (d.outline ?? []).slice(0, 8);
557
848
  const sectionHint = sections.length > 0 ? `
558
849
  sections: ${sections.map((s) => s.anchor).join(", ")}` : "";
@@ -566,7 +857,7 @@ ${doc.content}`;
566
857
  type: "text",
567
858
  text: `Found ${items.length} document(s) (${response.total} total):
568
859
 
569
- ${text}
860
+ ${text2}
570
861
 
571
862
  Use doc_id for full content, or doc_id + section to fetch one section.`
572
863
  }
@@ -911,13 +1202,13 @@ var init_get_plan_board = __esm({
911
1202
  });
912
1203
 
913
1204
  // src/lib/scoring.ts
914
- function estimateTokens(text) {
915
- return Math.ceil(text.length / CHARS_PER_TOKEN);
1205
+ function estimateTokens(text2) {
1206
+ return Math.ceil(text2.length / CHARS_PER_TOKEN);
916
1207
  }
917
- function truncateToTokens(text, maxTokens) {
1208
+ function truncateToTokens(text2, maxTokens) {
918
1209
  const maxChars = Math.max(0, maxTokens) * CHARS_PER_TOKEN;
919
- if (text.length <= maxChars) return { text, truncated: false };
920
- const head = text.slice(0, maxChars);
1210
+ if (text2.length <= maxChars) return { text: text2, truncated: false };
1211
+ const head = text2.slice(0, maxChars);
921
1212
  const floor = Math.floor(maxChars * 0.75);
922
1213
  const paragraph = head.lastIndexOf("\n\n");
923
1214
  const line = head.lastIndexOf("\n");
@@ -962,7 +1253,7 @@ function scoreSeverity(item) {
962
1253
  if (!item.severity) return 0.5;
963
1254
  return SEVERITY_SCORES[item.severity] ?? 0.5;
964
1255
  }
965
- function extractKeywords(text) {
1256
+ function extractKeywords(text2) {
966
1257
  const stopWords = /* @__PURE__ */ new Set([
967
1258
  "the",
968
1259
  "a",
@@ -1047,7 +1338,7 @@ function extractKeywords(text) {
1047
1338
  "implement",
1048
1339
  "create"
1049
1340
  ]);
1050
- return text.toLowerCase().replace(/[^a-z0-9\s-]/g, " ").split(/\s+/).filter((w) => w.length > 2 && !stopWords.has(w));
1341
+ return text2.toLowerCase().replace(/[^a-z0-9\s-]/g, " ").split(/\s+/).filter((w) => w.length > 2 && !stopWords.has(w));
1051
1342
  }
1052
1343
  function scoreItems(items, taskDescription, filePaths) {
1053
1344
  const languages = detectLanguages(filePaths);
@@ -1100,20 +1391,20 @@ var init_scoring = __esm({
1100
1391
 
1101
1392
  // src/lib/context-builder.ts
1102
1393
  function formatGuardrail(g) {
1103
- let text = `## ${g.title} [${g.severity}]
1394
+ let text2 = `## ${g.title} [${g.severity}]
1104
1395
 
1105
1396
  ${g.description}`;
1106
- if (g.pattern) text += `
1397
+ if (g.pattern) text2 += `
1107
1398
 
1108
1399
  **Bad:** \`\`\`
1109
1400
  ${g.pattern}
1110
1401
  \`\`\``;
1111
- if (g.fix) text += `
1402
+ if (g.fix) text2 += `
1112
1403
 
1113
1404
  **Good:** \`\`\`
1114
1405
  ${g.fix}
1115
1406
  \`\`\``;
1116
- return text;
1407
+ return text2;
1117
1408
  }
1118
1409
  function formatSpecHeadline(s) {
1119
1410
  return `### ${s.name}
@@ -1135,19 +1426,19 @@ function formatRules(rules) {
1135
1426
  if (!byCategory.has(cat)) byCategory.set(cat, []);
1136
1427
  byCategory.get(cat).push(r);
1137
1428
  }
1138
- let text = "";
1429
+ let text2 = "";
1139
1430
  for (const [cat, catRules] of byCategory) {
1140
- text += `### ${cat.charAt(0).toUpperCase() + cat.slice(1)}
1431
+ text2 += `### ${cat.charAt(0).toUpperCase() + cat.slice(1)}
1141
1432
  `;
1142
1433
  for (const r of catRules) {
1143
- text += `- ${r.text}
1434
+ text2 += `- ${r.text}
1144
1435
  `;
1145
1436
  }
1146
- text += "\n";
1437
+ text2 += "\n";
1147
1438
  }
1148
1439
  return `## Project Rules (${rules.length})
1149
1440
 
1150
- ${text.trim()}`;
1441
+ ${text2.trim()}`;
1151
1442
  }
1152
1443
  async function fetchSpecBody(api, draftId) {
1153
1444
  const spec = await api.get(
@@ -1501,13 +1792,13 @@ function registerGetContext(server2, apiClient2) {
1501
1792
  }
1502
1793
  },
1503
1794
  async ({ task_description, file_paths, max_tokens }) => {
1504
- const { text } = await buildContext(apiClient2, {
1795
+ const { text: text2 } = await buildContext(apiClient2, {
1505
1796
  taskDescription: task_description,
1506
1797
  filePaths: file_paths,
1507
1798
  maxTokens: max_tokens
1508
1799
  });
1509
1800
  return {
1510
- content: [{ type: "text", text }]
1801
+ content: [{ type: "text", text: text2 }]
1511
1802
  };
1512
1803
  }
1513
1804
  );
@@ -1770,9 +2061,9 @@ function registerGetProjectRules(server2, apiClient2) {
1770
2061
  );
1771
2062
  }
1772
2063
  if (filtered.length === 0) {
1773
- const text2 = rules.length === 0 ? `No project rules found. Add rules at ${configLink("/docs/rules", await workspaceBase(apiClient2))}` : `No project rules found${category ? ` in category "${category}"` : ""}.`;
2064
+ const text3 = rules.length === 0 ? `No project rules found. Add rules at ${configLink("/docs/rules", await workspaceBase(apiClient2))}` : `No project rules found${category ? ` in category "${category}"` : ""}.`;
1774
2065
  return {
1775
- content: [{ type: "text", text: text2 }]
2066
+ content: [{ type: "text", text: text3 }]
1776
2067
  };
1777
2068
  }
1778
2069
  const grouped = /* @__PURE__ */ new Map();
@@ -1781,25 +2072,25 @@ function registerGetProjectRules(server2, apiClient2) {
1781
2072
  if (!grouped.has(cat)) grouped.set(cat, []);
1782
2073
  grouped.get(cat).push(rule);
1783
2074
  }
1784
- let text = `Found ${filtered.length} project rule(s):
2075
+ let text2 = `Found ${filtered.length} project rule(s):
1785
2076
 
1786
2077
  `;
1787
2078
  for (const [cat, catRules] of grouped) {
1788
2079
  const label = CATEGORY_LABELS[cat] ?? cat;
1789
- text += `## ${label}
2080
+ text2 += `## ${label}
1790
2081
 
1791
2082
  `;
1792
2083
  for (const rule of catRules) {
1793
- text += `- ${rule.text}
2084
+ text2 += `- ${rule.text}
1794
2085
  `;
1795
2086
  }
1796
- text += "\n";
2087
+ text2 += "\n";
1797
2088
  }
1798
2089
  return {
1799
2090
  content: [
1800
2091
  {
1801
2092
  type: "text",
1802
- text
2093
+ text: text2
1803
2094
  }
1804
2095
  ]
1805
2096
  };
@@ -1840,10 +2131,10 @@ function registerCreateProjectRule(server2, apiClient2) {
1840
2131
  readOnlyHint: false
1841
2132
  }
1842
2133
  },
1843
- async ({ text, category }) => {
2134
+ async ({ text: text2, category }) => {
1844
2135
  const saved = await apiClient2.post(
1845
2136
  "/v1/standards/rules",
1846
- { text, category }
2137
+ { text: text2, category }
1847
2138
  );
1848
2139
  apiClient2.invalidateCache("/v1/standards/rules");
1849
2140
  return {
@@ -1886,7 +2177,7 @@ function registerGetPromptTemplates(server2, apiClient2) {
1886
2177
  ]
1887
2178
  };
1888
2179
  }
1889
- const text = templates.map(
2180
+ const text2 = templates.map(
1890
2181
  (t) => `## ${t.name}
1891
2182
 
1892
2183
  ` + (t.description ? `*${t.description}*
@@ -1901,7 +2192,7 @@ ${t.prompt}
1901
2192
  type: "text",
1902
2193
  text: `Found ${templates.length} prompt template(s):
1903
2194
 
1904
- ${text}`
2195
+ ${text2}`
1905
2196
  }
1906
2197
  ]
1907
2198
  };
@@ -1959,9 +2250,151 @@ var init_create_prompt_template = __esm({
1959
2250
  }
1960
2251
  });
1961
2252
 
2253
+ // src/tools/skills.ts
2254
+ import { z as z13 } from "zod";
2255
+ import { homedir as homedir2 } from "os";
2256
+ import { basename as basename2, resolve as resolve3 } from "path";
2257
+ function expandHome(path) {
2258
+ return path.replace(/^~(?=$|[\\/])/, homedir2());
2259
+ }
2260
+ function text(t) {
2261
+ return { content: [{ type: "text", text: t }] };
2262
+ }
2263
+ function registerSkillTools(server2, apiClient2, { localFiles }) {
2264
+ const fileSchema = z13.object({
2265
+ path: z13.string().describe('Path inside the skill folder, forward slashes, e.g. "references/guide.md"'),
2266
+ content: z13.string().describe("The file's full text, exactly as it is")
2267
+ });
2268
+ server2.registerTool(
2269
+ "publish_skill",
2270
+ {
2271
+ title: "Publish Skill",
2272
+ description: localFiles ? "Publish a Claude Code skill folder (SKILL.md plus its references and scripts) to SystemDox so it can be installed on any computer. Pass `directory` (e.g. ~/.claude/skills/<name>): the server reads the files itself, byte for byte. Publishing a name again makes a new version. Never retype the files into `files` when the folder is on this computer." : "Publish a Claude Code skill (SKILL.md plus its references and scripts) to SystemDox so it can be installed on any computer. Pass every file of the folder in `files`, exactly as it is. Publishing a name again makes a new version.",
2273
+ inputSchema: {
2274
+ ...localFiles ? {
2275
+ directory: z13.string().optional().describe("The skill folder on this computer, holding SKILL.md at its root")
2276
+ } : {},
2277
+ files: z13.array(fileSchema).optional().describe(localFiles ? "Only when there is no folder to read" : "Every file of the skill folder"),
2278
+ name: z13.string().optional().describe("The skill's name; defaults to SKILL.md's frontmatter name and must match it"),
2279
+ spaceId: z13.string().optional().describe("Space for a NEW skill (default: your default space)")
2280
+ },
2281
+ annotations: { readOnlyHint: false }
2282
+ },
2283
+ async (args2) => {
2284
+ let files;
2285
+ let name;
2286
+ let skipped = [];
2287
+ if (localFiles && args2.directory) {
2288
+ ({ name, files, skipped } = await readSkillDirectory(expandHome(args2.directory)));
2289
+ } else if (args2.files?.length) {
2290
+ files = args2.files;
2291
+ const entry = files.find((f) => f.path === ENTRY_FILE);
2292
+ name = entry ? frontmatterName(entry.content) : void 0;
2293
+ } else {
2294
+ throw new Error(localFiles ? "Pass `directory` (or `files`)." : "Pass `files`.");
2295
+ }
2296
+ name = args2.name ?? name;
2297
+ if (!name) throw new Error(`${ENTRY_FILE} has no frontmatter name; pass \`name\`.`);
2298
+ const saved = await apiClient2.post(
2299
+ `${SKILLS}/${encodeURIComponent(name)}/versions`,
2300
+ { files, ...args2.spaceId ? { spaceId: args2.spaceId } : {} }
2301
+ );
2302
+ apiClient2.invalidateCache(SKILLS);
2303
+ return text(
2304
+ `Published **${saved.name}** version ${saved.version} (${saved.fileCount} file(s), ${saved.totalBytes} bytes).
2305
+ Install it anywhere with: npx -y @systemdox/mcp-server install-skill ${saved.name}` + (skipped.length ? `
2306
+ Left out (hidden files are never part of a skill): ${skipped.join(", ")}` : "")
2307
+ );
2308
+ }
2309
+ );
2310
+ server2.registerTool(
2311
+ "list_skills",
2312
+ {
2313
+ title: "List Skills",
2314
+ description: "List the Claude Code skills published to SystemDox that you can install: name, description, latest version. Use install_skill to install one.",
2315
+ inputSchema: {},
2316
+ annotations: { readOnlyHint: true }
2317
+ },
2318
+ async () => {
2319
+ const { skills } = await apiClient2.get(SKILLS);
2320
+ if (!skills.length) {
2321
+ return text(
2322
+ `No skills published yet. Publish one with publish_skill; they appear at ${configLink("/docs/skills", await workspaceBase(apiClient2))}`
2323
+ );
2324
+ }
2325
+ return text(
2326
+ `${skills.length} skill(s):
2327
+
2328
+ ` + skills.map((s) => `- **${s.name}** (version ${s.latestVersion}, updated ${s.updatedAt.slice(0, 10)}): ${s.description}`).join("\n")
2329
+ );
2330
+ }
2331
+ );
2332
+ server2.registerTool(
2333
+ "install_skill",
2334
+ {
2335
+ title: "Install Skill",
2336
+ description: localFiles ? "Install a skill published to SystemDox onto this computer (default ~/.claude/skills/<name>/). Refuses, naming the files, when a file there was edited since it was installed, unless overwrite is true. Never runs anything." : "Fetch a skill published to SystemDox so you can install it: returns every file and the folder to write them into (default ~/.claude/skills/<name>/). Write each file exactly, then the manifest it shows.",
2337
+ inputSchema: {
2338
+ name: z13.string().describe("The skill's name"),
2339
+ version: z13.number().int().positive().optional().describe("A version number (default: the latest)"),
2340
+ target: z13.string().optional().describe("Folder to install into, e.g. a repo's .claude/skills/<name>"),
2341
+ ...localFiles ? { overwrite: z13.boolean().optional().describe("Replace files edited since they were installed") } : {}
2342
+ },
2343
+ annotations: { readOnlyHint: !localFiles }
2344
+ },
2345
+ async (args2) => {
2346
+ const version = await apiClient2.getFresh(
2347
+ `${SKILLS}/${encodeURIComponent(args2.name)}/versions/${args2.version ?? "latest"}`
2348
+ );
2349
+ if (localFiles) {
2350
+ const dest2 = args2.target ? expandHome(args2.target) : defaultSkillDir(version.name);
2351
+ if (basename2(resolve3(dest2)) !== version.name) {
2352
+ throw new Error(`target must be a folder named ${version.name} (e.g. .claude/skills/${version.name}); got ${dest2}`);
2353
+ }
2354
+ const result = await installSkill(version, dest2, { overwrite: args2.overwrite });
2355
+ return text(describeInstall(version.name, result));
2356
+ }
2357
+ const dest = args2.target ?? `~/.claude/skills/${version.name}`;
2358
+ const manifest = {
2359
+ name: version.name,
2360
+ version: version.version,
2361
+ installedAt: "<now, ISO 8601>",
2362
+ files: Object.fromEntries(version.files.map((f) => [f.path, f.sha256]))
2363
+ };
2364
+ const body = version.files.map((f) => {
2365
+ const fence = fenceFor(f.content);
2366
+ return `### ${f.path}
2367
+
2368
+ ${fence}
2369
+ ${f.content}${f.content.endsWith("\n") ? "" : "\n"}${fence}`;
2370
+ }).join("\n\n");
2371
+ return text(
2372
+ `Skill **${version.name}** version ${version.version}: write these ${version.files.length} file(s) into ${dest}/ exactly as shown (the content between the fences), creating folders as needed. If a file there differs from ${MANIFEST_FILE}'s hash for it, it was edited: ask before replacing it. Then write ${MANIFEST_FILE} with this content, and tell the user to start a new Claude Code session.
2373
+
2374
+ ### ${MANIFEST_FILE}
2375
+
2376
+ \`\`\`json
2377
+ ${JSON.stringify(manifest, null, 2)}
2378
+ \`\`\`
2379
+
2380
+ ${body}`
2381
+ );
2382
+ }
2383
+ );
2384
+ }
2385
+ var SKILLS;
2386
+ var init_skills2 = __esm({
2387
+ "src/tools/skills.ts"() {
2388
+ "use strict";
2389
+ init_config_links();
2390
+ init_skills();
2391
+ SKILLS = "/v1/standards/skills";
2392
+ }
2393
+ });
2394
+
1962
2395
  // src/server.ts
1963
2396
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
1964
- function createServer(apiClient2) {
2397
+ function createServer(apiClient2, { localFiles = false } = {}) {
1965
2398
  const server2 = new McpServer({
1966
2399
  name: "systemdox-mcp-server",
1967
2400
  version: SERVER_VERSION
@@ -1980,6 +2413,7 @@ function createServer(apiClient2) {
1980
2413
  registerReportDecision(server2, apiClient2);
1981
2414
  registerCreateProjectRule(server2, apiClient2);
1982
2415
  registerCreatePromptTemplate(server2, apiClient2);
2416
+ registerSkillTools(server2, apiClient2, { localFiles });
1983
2417
  return server2;
1984
2418
  }
1985
2419
  var SERVER_VERSION;
@@ -2000,7 +2434,8 @@ var init_server = __esm({
2000
2434
  init_create_project_rule();
2001
2435
  init_get_prompt_templates();
2002
2436
  init_create_prompt_template();
2003
- SERVER_VERSION = true ? "0.1.41" : "0.0.0-dev";
2437
+ init_skills2();
2438
+ SERVER_VERSION = true ? "0.1.43" : "0.0.0-dev";
2004
2439
  }
2005
2440
  });
2006
2441
 
@@ -2027,24 +2462,24 @@ var init_index = __esm({
2027
2462
  init_server();
2028
2463
  config = loadConfig();
2029
2464
  apiClient = new SystemDoxApiClient(config);
2030
- server = createServer(apiClient);
2465
+ server = createServer(apiClient, { localFiles: true });
2031
2466
  main();
2032
2467
  }
2033
2468
  });
2034
2469
 
2035
2470
  // src/cli.ts
2036
2471
  init_config();
2037
- import { readFile, writeFile, chmod } from "fs/promises";
2038
- import { existsSync } from "fs";
2039
- import { homedir } from "os";
2040
- import { join } from "path";
2472
+ import { readFile as readFile2, writeFile as writeFile2, chmod } from "fs/promises";
2473
+ import { existsSync as existsSync2 } from "fs";
2474
+ import { homedir as homedir3 } from "os";
2475
+ import { join as join2 } from "path";
2041
2476
  import { createInterface } from "readline";
2042
2477
  function prompt(question) {
2043
2478
  const rl = createInterface({ input: process.stdin, output: process.stdout });
2044
- return new Promise((resolve) => {
2479
+ return new Promise((resolve4) => {
2045
2480
  rl.question(question, (answer) => {
2046
2481
  rl.close();
2047
- resolve(answer.trim());
2482
+ resolve4(answer.trim());
2048
2483
  });
2049
2484
  });
2050
2485
  }
@@ -2064,45 +2499,45 @@ async function validateApiKey(apiKey, apiUrl) {
2064
2499
  }
2065
2500
  function findConfigTargets() {
2066
2501
  const targets = [];
2067
- const projectMcp = join(process.cwd(), ".mcp.json");
2502
+ const projectMcp = join2(process.cwd(), ".mcp.json");
2068
2503
  targets.push({
2069
2504
  name: "Claude Code (project)",
2070
2505
  path: projectMcp,
2071
2506
  detected: true
2072
2507
  // Always available
2073
2508
  });
2074
- const claudeUser = join(homedir(), ".claude.json");
2509
+ const claudeUser = join2(homedir3(), ".claude.json");
2075
2510
  targets.push({
2076
2511
  name: "Claude Code (user \u2014 all projects)",
2077
2512
  path: claudeUser,
2078
2513
  detected: true
2079
2514
  // Always available
2080
2515
  });
2081
- const cursorProject = join(process.cwd(), ".cursor", "mcp.json");
2082
- const cursorDir = join(process.cwd(), ".cursor");
2516
+ const cursorProject = join2(process.cwd(), ".cursor", "mcp.json");
2517
+ const cursorDir = join2(process.cwd(), ".cursor");
2083
2518
  targets.push({
2084
2519
  name: "Cursor (project)",
2085
2520
  path: cursorProject,
2086
- detected: existsSync(cursorDir)
2521
+ detected: existsSync2(cursorDir)
2087
2522
  });
2088
- const vscodeProject = join(process.cwd(), ".vscode", "mcp.json");
2089
- const vscodeDir = join(process.cwd(), ".vscode");
2523
+ const vscodeProject = join2(process.cwd(), ".vscode", "mcp.json");
2524
+ const vscodeDir = join2(process.cwd(), ".vscode");
2090
2525
  targets.push({
2091
2526
  name: "VS Code (project)",
2092
2527
  path: vscodeProject,
2093
- detected: existsSync(vscodeDir)
2528
+ detected: existsSync2(vscodeDir)
2094
2529
  });
2095
- const cursorGlobal = join(homedir(), ".cursor", "mcp.json");
2530
+ const cursorGlobal = join2(homedir3(), ".cursor", "mcp.json");
2096
2531
  targets.push({
2097
2532
  name: "Cursor (global)",
2098
2533
  path: cursorGlobal,
2099
- detected: existsSync(join(homedir(), ".cursor"))
2534
+ detected: existsSync2(join2(homedir3(), ".cursor"))
2100
2535
  });
2101
2536
  return targets;
2102
2537
  }
2103
2538
  async function readOrCreateConfig(path) {
2104
2539
  try {
2105
- const content = await readFile(path, "utf-8");
2540
+ const content = await readFile2(path, "utf-8");
2106
2541
  return JSON.parse(content);
2107
2542
  } catch {
2108
2543
  return { mcpServers: {} };
@@ -2110,11 +2545,11 @@ async function readOrCreateConfig(path) {
2110
2545
  }
2111
2546
  async function writeConfig(path, config2) {
2112
2547
  const dir = path.substring(0, path.lastIndexOf("/"));
2113
- if (!existsSync(dir)) {
2114
- const { mkdir } = await import("fs/promises");
2115
- await mkdir(dir, { recursive: true });
2548
+ if (!existsSync2(dir)) {
2549
+ const { mkdir: mkdir2 } = await import("fs/promises");
2550
+ await mkdir2(dir, { recursive: true });
2116
2551
  }
2117
- await writeFile(path, JSON.stringify(config2, null, 2) + "\n", { mode: 384 });
2552
+ await writeFile2(path, JSON.stringify(config2, null, 2) + "\n", { mode: 384 });
2118
2553
  try {
2119
2554
  await chmod(path, 384);
2120
2555
  } catch {
@@ -2192,7 +2627,7 @@ async function setup() {
2192
2627
  console.log(` Configured: ${target.name}`);
2193
2628
  }
2194
2629
  console.log(
2195
- "\n Done! Restart your AI coding tool to activate the SystemDox MCP server.\n The server provides 14 tools: get_context, get_checks,\n get_project_rules, get_prompt_templates, get_specs, get_docs,\n get_tech_stack, get_planning_context, get_plan_board, create_check,\n create_project_rule, create_prompt_template, update_spec,\n report_decision.\n\n get_guardrails and create_guardrail remain registered as deprecated\n aliases of get_checks and create_check \u2014 existing configs keep working.\n"
2630
+ "\n Done! Restart your AI coding tool to activate the SystemDox MCP server.\n The server provides 17 tools: get_context, get_checks,\n get_project_rules, get_prompt_templates, get_specs, get_docs,\n get_tech_stack, get_planning_context, get_plan_board, create_check,\n create_project_rule, create_prompt_template, update_spec,\n report_decision, publish_skill, list_skills, install_skill.\n\n get_guardrails and create_guardrail remain registered as deprecated\n aliases of get_checks and create_check \u2014 existing configs keep working.\n"
2196
2631
  );
2197
2632
  }
2198
2633
  var args = process.argv.slice(2);
@@ -2204,6 +2639,10 @@ if (command === "setup") {
2204
2639
  `);
2205
2640
  process.exit(1);
2206
2641
  });
2642
+ } else if (command === "install-skill") {
2643
+ Promise.resolve().then(() => (init_install_skill(), install_skill_exports)).then(
2644
+ ({ runInstallSkill: runInstallSkill2 }) => runInstallSkill2(args.slice(1), { out: console.log, err: console.error })
2645
+ ).then((code) => process.exit(code));
2207
2646
  } else if (command === "--help" || command === "-h") {
2208
2647
  console.log(`
2209
2648
  SystemDox MCP Server
@@ -2211,6 +2650,9 @@ if (command === "setup") {
2211
2650
  Usage:
2212
2651
  systemdox-mcp-server Start the MCP server (stdio transport)
2213
2652
  systemdox-mcp-server setup Configure the MCP server in your AI coding tool
2653
+ systemdox-mcp-server install-skill <name> [--version n] [--target dir] [--overwrite]
2654
+ Install a skill published to SystemDox
2655
+ (default folder: ~/.claude/skills/<name>)
2214
2656
  systemdox-mcp-server --help Show this help message
2215
2657
 
2216
2658
  Environment variables: