@systemdox/mcp-server 0.1.42 → 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/index.js CHANGED
@@ -99,6 +99,16 @@ var SystemDoxApiClient = class {
99
99
  this.cache.set(cacheKey, data);
100
100
  return data;
101
101
  }
102
+ /** A read that skips the cache: an install must take the version that is
103
+ * latest now, not the one cached before someone published another. */
104
+ async getFresh(path) {
105
+ const res = await this.fetchRaw(path);
106
+ if (!res.ok) {
107
+ const body = await res.text();
108
+ throw new Error(`SystemDox API error ${res.status}: ${body}`);
109
+ }
110
+ return await res.json();
111
+ }
102
112
  async post(path, body) {
103
113
  return this.mutate("POST", path, body);
104
114
  }
@@ -119,8 +129,8 @@ var SystemDoxApiClient = class {
119
129
  body: JSON.stringify(body)
120
130
  });
121
131
  if (!res.ok) {
122
- const text = await res.text();
123
- throw new Error(`SystemDox API error ${res.status}: ${text}`);
132
+ const text2 = await res.text();
133
+ throw new Error(`SystemDox API error ${res.status}: ${text2}`);
124
134
  }
125
135
  return await res.json();
126
136
  }
@@ -285,12 +295,12 @@ function registerGetGuardrails(server2, apiClient2) {
285
295
  );
286
296
  }
287
297
  if (filtered.length === 0) {
288
- 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}"` : ""}.`;
298
+ 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}"` : ""}.`;
289
299
  return {
290
- content: [{ type: "text", text: text2 }]
300
+ content: [{ type: "text", text: text3 }]
291
301
  };
292
302
  }
293
- const text = filtered.map(
303
+ const text2 = filtered.map(
294
304
  (g) => `## ${g.title} [${g.severity}]
295
305
 
296
306
  ${g.description}
@@ -314,7 +324,7 @@ ${g.fix}
314
324
  type: "text",
315
325
  text: `Found ${filtered.length} check(s):
316
326
 
317
- ${text}`
327
+ ${text2}`
318
328
  }
319
329
  ]
320
330
  };
@@ -404,7 +414,7 @@ ${spec.taskContent}`);
404
414
  ]
405
415
  };
406
416
  }
407
- const text = filtered.map(
417
+ const text2 = filtered.map(
408
418
  (s) => `- **${s.name}** (${s.draftId}) \u2014 ${s.status}, ${s.requirementCount} requirements` + (s.repoFullName ? `, repo: ${s.repoFullName}` : "")
409
419
  ).join("\n");
410
420
  return {
@@ -413,7 +423,7 @@ ${spec.taskContent}`);
413
423
  type: "text",
414
424
  text: `Found ${filtered.length} spec(s):
415
425
 
416
- ${text}`
426
+ ${text2}`
417
427
  }
418
428
  ]
419
429
  };
@@ -466,18 +476,18 @@ function registerGetDocs(server2, apiClient2) {
466
476
  isError: true
467
477
  };
468
478
  }
469
- let text2 = doc.content;
479
+ let text3 = doc.content;
470
480
  if (anchor && doc.section_resolved === false) {
471
- text2 = `_Section "${anchor}" not found \u2014 returning the full document._
481
+ text3 = `_Section "${anchor}" not found \u2014 returning the full document._
472
482
 
473
- ` + text2;
483
+ ` + text3;
474
484
  } else if (anchor && doc.heading) {
475
- text2 = `## ${doc.heading}
485
+ text3 = `## ${doc.heading}
476
486
 
477
487
  ${doc.content}`;
478
488
  }
479
489
  return {
480
- content: [{ type: "text", text: text2 }]
490
+ content: [{ type: "text", text: text3 }]
481
491
  };
482
492
  }
483
493
  const params = {};
@@ -501,7 +511,7 @@ ${doc.content}`;
501
511
  ]
502
512
  };
503
513
  }
504
- const text = items.map((d) => {
514
+ const text2 = items.map((d) => {
505
515
  const sections = (d.outline ?? []).slice(0, 8);
506
516
  const sectionHint = sections.length > 0 ? `
507
517
  sections: ${sections.map((s) => s.anchor).join(", ")}` : "";
@@ -515,7 +525,7 @@ ${doc.content}`;
515
525
  type: "text",
516
526
  text: `Found ${items.length} document(s) (${response.total} total):
517
527
 
518
- ${text}
528
+ ${text2}
519
529
 
520
530
  Use doc_id for full content, or doc_id + section to fetch one section.`
521
531
  }
@@ -850,13 +860,13 @@ var SEVERITY_SCORES = {
850
860
  low: 0.25
851
861
  };
852
862
  var CHARS_PER_TOKEN = 4;
853
- function estimateTokens(text) {
854
- return Math.ceil(text.length / CHARS_PER_TOKEN);
863
+ function estimateTokens(text2) {
864
+ return Math.ceil(text2.length / CHARS_PER_TOKEN);
855
865
  }
856
- function truncateToTokens(text, maxTokens) {
866
+ function truncateToTokens(text2, maxTokens) {
857
867
  const maxChars = Math.max(0, maxTokens) * CHARS_PER_TOKEN;
858
- if (text.length <= maxChars) return { text, truncated: false };
859
- const head = text.slice(0, maxChars);
868
+ if (text2.length <= maxChars) return { text: text2, truncated: false };
869
+ const head = text2.slice(0, maxChars);
860
870
  const floor = Math.floor(maxChars * 0.75);
861
871
  const paragraph = head.lastIndexOf("\n\n");
862
872
  const line = head.lastIndexOf("\n");
@@ -901,7 +911,7 @@ function scoreSeverity(item) {
901
911
  if (!item.severity) return 0.5;
902
912
  return SEVERITY_SCORES[item.severity] ?? 0.5;
903
913
  }
904
- function extractKeywords(text) {
914
+ function extractKeywords(text2) {
905
915
  const stopWords = /* @__PURE__ */ new Set([
906
916
  "the",
907
917
  "a",
@@ -986,7 +996,7 @@ function extractKeywords(text) {
986
996
  "implement",
987
997
  "create"
988
998
  ]);
989
- return text.toLowerCase().replace(/[^a-z0-9\s-]/g, " ").split(/\s+/).filter((w) => w.length > 2 && !stopWords.has(w));
999
+ return text2.toLowerCase().replace(/[^a-z0-9\s-]/g, " ").split(/\s+/).filter((w) => w.length > 2 && !stopWords.has(w));
990
1000
  }
991
1001
  function scoreItems(items, taskDescription, filePaths) {
992
1002
  const languages = detectLanguages(filePaths);
@@ -1029,20 +1039,20 @@ var MAX_TAIL = 10;
1029
1039
  var TAIL_SHARE = 0.15;
1030
1040
  var TRUNCATION_NOTICE_TOKENS = 30;
1031
1041
  function formatGuardrail(g) {
1032
- let text = `## ${g.title} [${g.severity}]
1042
+ let text2 = `## ${g.title} [${g.severity}]
1033
1043
 
1034
1044
  ${g.description}`;
1035
- if (g.pattern) text += `
1045
+ if (g.pattern) text2 += `
1036
1046
 
1037
1047
  **Bad:** \`\`\`
1038
1048
  ${g.pattern}
1039
1049
  \`\`\``;
1040
- if (g.fix) text += `
1050
+ if (g.fix) text2 += `
1041
1051
 
1042
1052
  **Good:** \`\`\`
1043
1053
  ${g.fix}
1044
1054
  \`\`\``;
1045
- return text;
1055
+ return text2;
1046
1056
  }
1047
1057
  function formatSpecHeadline(s) {
1048
1058
  return `### ${s.name}
@@ -1064,19 +1074,19 @@ function formatRules(rules) {
1064
1074
  if (!byCategory.has(cat)) byCategory.set(cat, []);
1065
1075
  byCategory.get(cat).push(r);
1066
1076
  }
1067
- let text = "";
1077
+ let text2 = "";
1068
1078
  for (const [cat, catRules] of byCategory) {
1069
- text += `### ${cat.charAt(0).toUpperCase() + cat.slice(1)}
1079
+ text2 += `### ${cat.charAt(0).toUpperCase() + cat.slice(1)}
1070
1080
  `;
1071
1081
  for (const r of catRules) {
1072
- text += `- ${r.text}
1082
+ text2 += `- ${r.text}
1073
1083
  `;
1074
1084
  }
1075
- text += "\n";
1085
+ text2 += "\n";
1076
1086
  }
1077
1087
  return `## Project Rules (${rules.length})
1078
1088
 
1079
- ${text.trim()}`;
1089
+ ${text2.trim()}`;
1080
1090
  }
1081
1091
  async function fetchSpecBody(api, draftId) {
1082
1092
  const spec = await api.get(
@@ -1413,13 +1423,13 @@ function registerGetContext(server2, apiClient2) {
1413
1423
  }
1414
1424
  },
1415
1425
  async ({ task_description, file_paths, max_tokens }) => {
1416
- const { text } = await buildContext(apiClient2, {
1426
+ const { text: text2 } = await buildContext(apiClient2, {
1417
1427
  taskDescription: task_description,
1418
1428
  filePaths: file_paths,
1419
1429
  maxTokens: max_tokens
1420
1430
  });
1421
1431
  return {
1422
- content: [{ type: "text", text }]
1432
+ content: [{ type: "text", text: text2 }]
1423
1433
  };
1424
1434
  }
1425
1435
  );
@@ -1667,9 +1677,9 @@ function registerGetProjectRules(server2, apiClient2) {
1667
1677
  );
1668
1678
  }
1669
1679
  if (filtered.length === 0) {
1670
- 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}"` : ""}.`;
1680
+ 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}"` : ""}.`;
1671
1681
  return {
1672
- content: [{ type: "text", text: text2 }]
1682
+ content: [{ type: "text", text: text3 }]
1673
1683
  };
1674
1684
  }
1675
1685
  const grouped = /* @__PURE__ */ new Map();
@@ -1678,25 +1688,25 @@ function registerGetProjectRules(server2, apiClient2) {
1678
1688
  if (!grouped.has(cat)) grouped.set(cat, []);
1679
1689
  grouped.get(cat).push(rule);
1680
1690
  }
1681
- let text = `Found ${filtered.length} project rule(s):
1691
+ let text2 = `Found ${filtered.length} project rule(s):
1682
1692
 
1683
1693
  `;
1684
1694
  for (const [cat, catRules] of grouped) {
1685
1695
  const label = CATEGORY_LABELS[cat] ?? cat;
1686
- text += `## ${label}
1696
+ text2 += `## ${label}
1687
1697
 
1688
1698
  `;
1689
1699
  for (const rule of catRules) {
1690
- text += `- ${rule.text}
1700
+ text2 += `- ${rule.text}
1691
1701
  `;
1692
1702
  }
1693
- text += "\n";
1703
+ text2 += "\n";
1694
1704
  }
1695
1705
  return {
1696
1706
  content: [
1697
1707
  {
1698
1708
  type: "text",
1699
- text
1709
+ text: text2
1700
1710
  }
1701
1711
  ]
1702
1712
  };
@@ -1722,10 +1732,10 @@ function registerCreateProjectRule(server2, apiClient2) {
1722
1732
  readOnlyHint: false
1723
1733
  }
1724
1734
  },
1725
- async ({ text, category }) => {
1735
+ async ({ text: text2, category }) => {
1726
1736
  const saved = await apiClient2.post(
1727
1737
  "/v1/standards/rules",
1728
- { text, category }
1738
+ { text: text2, category }
1729
1739
  );
1730
1740
  apiClient2.invalidateCache("/v1/standards/rules");
1731
1741
  return {
@@ -1763,7 +1773,7 @@ function registerGetPromptTemplates(server2, apiClient2) {
1763
1773
  ]
1764
1774
  };
1765
1775
  }
1766
- const text = templates.map(
1776
+ const text2 = templates.map(
1767
1777
  (t) => `## ${t.name}
1768
1778
 
1769
1779
  ` + (t.description ? `*${t.description}*
@@ -1778,7 +1788,7 @@ ${t.prompt}
1778
1788
  type: "text",
1779
1789
  text: `Found ${templates.length} prompt template(s):
1780
1790
 
1781
- ${text}`
1791
+ ${text2}`
1782
1792
  }
1783
1793
  ]
1784
1794
  };
@@ -1825,9 +1835,336 @@ function registerCreatePromptTemplate(server2, apiClient2) {
1825
1835
  );
1826
1836
  }
1827
1837
 
1838
+ // src/tools/skills.ts
1839
+ import { z as z13 } from "zod";
1840
+ import { homedir as homedir2 } from "os";
1841
+ import { basename, resolve as resolve2 } from "path";
1842
+
1843
+ // src/lib/skills.ts
1844
+ import { createHash } from "crypto";
1845
+ import { lstat, mkdir, readdir, readFile, rm, writeFile } from "fs/promises";
1846
+ import { existsSync } from "fs";
1847
+ import { homedir } from "os";
1848
+ import { dirname, join, relative, resolve, sep } from "path";
1849
+ var MANIFEST_FILE = ".systemdox-skill.json";
1850
+ var ENTRY_FILE = "SKILL.md";
1851
+ var MAX_FILES = 100;
1852
+ var MAX_FILE_BYTES = 256 * 1024;
1853
+ var MAX_TOTAL_BYTES = 1024 * 1024;
1854
+ var NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
1855
+ var SEGMENT_RE = /^[A-Za-z0-9._-]+$/;
1856
+ var SKIPPED = /* @__PURE__ */ new Set([MANIFEST_FILE, ".git", "node_modules", "__pycache__", ".DS_Store"]);
1857
+ var SkillInstallRefused = class extends Error {
1858
+ constructor(dest, edited) {
1859
+ super(
1860
+ `${dest} has local changes in ${edited.length} file(s): ${edited.join(", ")}. Nothing was written. Move your changes aside, or install again with overwrite.`
1861
+ );
1862
+ this.dest = dest;
1863
+ this.edited = edited;
1864
+ }
1865
+ dest;
1866
+ edited;
1867
+ };
1868
+ function sha256(content) {
1869
+ return createHash("sha256").update(content, "utf8").digest("hex");
1870
+ }
1871
+ function isValidName(name) {
1872
+ return name.length <= 64 && NAME_RE.test(name);
1873
+ }
1874
+ function isValidPath(path) {
1875
+ return path.length > 0 && path.length <= 512 && !path.startsWith("/") && path.split("/").every((s) => SEGMENT_RE.test(s) && s !== "." && s !== "..");
1876
+ }
1877
+ function frontmatterName(skillMd) {
1878
+ const block = /^---\r?\n([\s\S]*?)\r?\n---/.exec(skillMd);
1879
+ if (!block) return void 0;
1880
+ const line = /^name:[ \t]*(.+?)[ \t]*$/m.exec(block[1]);
1881
+ return line ? line[1].replace(/^(['"])(.*)\1$/, "$2") : void 0;
1882
+ }
1883
+ function defaultSkillDir(name) {
1884
+ return join(homedir(), ".claude", "skills", name);
1885
+ }
1886
+ async function walk(root, { skipHidden }, dir = root, out = { files: [], links: [], hidden: [] }) {
1887
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
1888
+ if (SKIPPED.has(entry.name) || entry.name.endsWith(".pyc")) continue;
1889
+ const full = join(dir, entry.name);
1890
+ const rel = relative(root, full).split(sep).join("/");
1891
+ if (entry.isSymbolicLink()) out.links.push(rel);
1892
+ else if (skipHidden && entry.name.startsWith(".")) out.hidden.push(rel);
1893
+ else if (entry.isDirectory()) await walk(root, { skipHidden }, full, out);
1894
+ else if (entry.isFile()) out.files.push(rel);
1895
+ }
1896
+ out.files.sort();
1897
+ return out;
1898
+ }
1899
+ async function readSkillDirectory(directory) {
1900
+ const root = resolve(directory);
1901
+ if (!existsSync(join(root, ENTRY_FILE))) {
1902
+ throw new Error(`${root} has no ${ENTRY_FILE}: a skill folder holds SKILL.md at its root`);
1903
+ }
1904
+ const walked = await walk(root, { skipHidden: true });
1905
+ if (walked.links.length) {
1906
+ throw new Error(`${walked.links.join(", ")}: links are not followed; copy the file into the skill folder`);
1907
+ }
1908
+ const paths = walked.files;
1909
+ if (paths.length > MAX_FILES) {
1910
+ throw new Error(`${root} holds ${paths.length} files: a skill holds at most ${MAX_FILES}`);
1911
+ }
1912
+ const decoder = new TextDecoder("utf-8", { fatal: true });
1913
+ const files = [];
1914
+ let total = 0;
1915
+ for (const path of paths) {
1916
+ if (!isValidPath(path)) {
1917
+ throw new Error(`${path}: file names may use letters, digits, ".", "_" and "-" only`);
1918
+ }
1919
+ const raw = await readFile(join(root, ...path.split("/")));
1920
+ if (raw.includes(0)) throw new Error(`${path} is binary: a skill holds text files only`);
1921
+ if (raw.length > MAX_FILE_BYTES) {
1922
+ throw new Error(`${path} is ${raw.length} bytes: a file holds at most ${MAX_FILE_BYTES}`);
1923
+ }
1924
+ total += raw.length;
1925
+ if (total > MAX_TOTAL_BYTES) {
1926
+ throw new Error(`the files pass ${MAX_TOTAL_BYTES} bytes at ${path}`);
1927
+ }
1928
+ let content;
1929
+ try {
1930
+ content = decoder.decode(raw);
1931
+ } catch {
1932
+ throw new Error(`${path} is not UTF-8 text`);
1933
+ }
1934
+ files.push({ path, content });
1935
+ }
1936
+ const skillMd = files.find((f) => f.path === ENTRY_FILE);
1937
+ const name = frontmatterName(skillMd.content);
1938
+ if (!name) throw new Error(`${ENTRY_FILE} has no frontmatter name`);
1939
+ return { name, files, skipped: walked.hidden };
1940
+ }
1941
+ function within(dest, path) {
1942
+ if (!isValidPath(path)) throw new Error(`refusing to write ${JSON.stringify(path)}: not a skill path`);
1943
+ const full = resolve(dest, ...path.split("/"));
1944
+ if (!full.startsWith(resolve(dest) + sep)) throw new Error(`refusing to write outside ${dest}: ${path}`);
1945
+ return full;
1946
+ }
1947
+ async function readManifest(dest) {
1948
+ try {
1949
+ return JSON.parse(await readFile(join(dest, MANIFEST_FILE), "utf8"));
1950
+ } catch {
1951
+ return void 0;
1952
+ }
1953
+ }
1954
+ async function installSkill(version, dest, { overwrite = false } = {}) {
1955
+ if (!isValidName(version.name)) throw new Error(`refusing to install ${JSON.stringify(version.name)}: not a skill name`);
1956
+ const incoming = new Map(version.files.map((f) => [f.path, f]));
1957
+ for (const f of version.files) within(dest, f.path);
1958
+ if (existsSync(dest) && (await lstat(dest)).isSymbolicLink()) {
1959
+ throw new Error(`${dest} is a link: install into a real folder`);
1960
+ }
1961
+ const walked = existsSync(dest) ? await walk(dest, { skipHidden: false }) : { files: [], links: [], hidden: [] };
1962
+ if (walked.links.length) {
1963
+ throw new Error(`${dest} holds links (${walked.links.join(", ")}): remove them, nothing was written`);
1964
+ }
1965
+ const present = walked.files;
1966
+ const manifest = await readManifest(dest);
1967
+ const known = Object.fromEntries(
1968
+ Object.entries(manifest?.files ?? {}).filter(([p, h]) => isValidPath(p) && typeof h === "string")
1969
+ );
1970
+ const edited = [];
1971
+ const untracked = [];
1972
+ for (const path of present) {
1973
+ const content = await readFile(join(dest, ...path.split("/")), "utf8");
1974
+ if (Object.hasOwn(known, path)) {
1975
+ if (sha256(content) !== known[path]) edited.push(path);
1976
+ } else if (!incoming.has(path) || sha256(content) !== incoming.get(path).sha256) {
1977
+ if (incoming.has(path) || !manifest) edited.push(path);
1978
+ else untracked.push(path);
1979
+ }
1980
+ }
1981
+ const blocking = edited.filter((p) => incoming.has(p) || !manifest);
1982
+ if (blocking.length && !overwrite) throw new SkillInstallRefused(dest, blocking);
1983
+ const written = [];
1984
+ for (const f of version.files) {
1985
+ const full = within(dest, f.path);
1986
+ await mkdir(dirname(full), { recursive: true });
1987
+ await writeFile(full, f.content, "utf8");
1988
+ written.push(f.path);
1989
+ }
1990
+ const removed = [];
1991
+ const keptEdited = [];
1992
+ for (const path of Object.keys(known)) {
1993
+ if (incoming.has(path) || !present.includes(path)) continue;
1994
+ if (edited.includes(path)) {
1995
+ keptEdited.push(path);
1996
+ continue;
1997
+ }
1998
+ await rm(within(dest, path));
1999
+ removed.push(path);
2000
+ }
2001
+ const next = {
2002
+ name: version.name,
2003
+ version: version.version,
2004
+ installedAt: (/* @__PURE__ */ new Date()).toISOString(),
2005
+ files: Object.fromEntries(version.files.map((f) => [f.path, f.sha256]))
2006
+ };
2007
+ await writeFile(join(dest, MANIFEST_FILE), JSON.stringify(next, null, 2) + "\n", "utf8");
2008
+ return {
2009
+ dest,
2010
+ version: version.version,
2011
+ written,
2012
+ removed,
2013
+ keptUntracked: untracked.filter((p) => !incoming.has(p)),
2014
+ keptEdited
2015
+ };
2016
+ }
2017
+ function fenceFor(text2) {
2018
+ const longest = Math.max(0, ...(text2.match(/`+/g) ?? []).map((r) => r.length));
2019
+ return "`".repeat(Math.max(3, longest + 1));
2020
+ }
2021
+ function describeInstall(name, r) {
2022
+ const lines = [
2023
+ `Installed ${name} version ${r.version} into ${r.dest} (${r.written.length} file(s)).`
2024
+ ];
2025
+ if (r.removed.length) lines.push(`Removed (no longer in the skill): ${r.removed.join(", ")}`);
2026
+ if (r.keptEdited.length) {
2027
+ lines.push(`Kept, edited here and no longer in the skill: ${r.keptEdited.join(", ")}`);
2028
+ }
2029
+ if (r.keptUntracked.length) lines.push(`Kept, added here: ${r.keptUntracked.join(", ")}`);
2030
+ lines.push("Start a new Claude Code session to load it.");
2031
+ return lines.join("\n");
2032
+ }
2033
+
2034
+ // src/tools/skills.ts
2035
+ var SKILLS = "/v1/standards/skills";
2036
+ function expandHome(path) {
2037
+ return path.replace(/^~(?=$|[\\/])/, homedir2());
2038
+ }
2039
+ function text(t) {
2040
+ return { content: [{ type: "text", text: t }] };
2041
+ }
2042
+ function registerSkillTools(server2, apiClient2, { localFiles }) {
2043
+ const fileSchema = z13.object({
2044
+ path: z13.string().describe('Path inside the skill folder, forward slashes, e.g. "references/guide.md"'),
2045
+ content: z13.string().describe("The file's full text, exactly as it is")
2046
+ });
2047
+ server2.registerTool(
2048
+ "publish_skill",
2049
+ {
2050
+ title: "Publish Skill",
2051
+ 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.",
2052
+ inputSchema: {
2053
+ ...localFiles ? {
2054
+ directory: z13.string().optional().describe("The skill folder on this computer, holding SKILL.md at its root")
2055
+ } : {},
2056
+ files: z13.array(fileSchema).optional().describe(localFiles ? "Only when there is no folder to read" : "Every file of the skill folder"),
2057
+ name: z13.string().optional().describe("The skill's name; defaults to SKILL.md's frontmatter name and must match it"),
2058
+ spaceId: z13.string().optional().describe("Space for a NEW skill (default: your default space)")
2059
+ },
2060
+ annotations: { readOnlyHint: false }
2061
+ },
2062
+ async (args) => {
2063
+ let files;
2064
+ let name;
2065
+ let skipped = [];
2066
+ if (localFiles && args.directory) {
2067
+ ({ name, files, skipped } = await readSkillDirectory(expandHome(args.directory)));
2068
+ } else if (args.files?.length) {
2069
+ files = args.files;
2070
+ const entry = files.find((f) => f.path === ENTRY_FILE);
2071
+ name = entry ? frontmatterName(entry.content) : void 0;
2072
+ } else {
2073
+ throw new Error(localFiles ? "Pass `directory` (or `files`)." : "Pass `files`.");
2074
+ }
2075
+ name = args.name ?? name;
2076
+ if (!name) throw new Error(`${ENTRY_FILE} has no frontmatter name; pass \`name\`.`);
2077
+ const saved = await apiClient2.post(
2078
+ `${SKILLS}/${encodeURIComponent(name)}/versions`,
2079
+ { files, ...args.spaceId ? { spaceId: args.spaceId } : {} }
2080
+ );
2081
+ apiClient2.invalidateCache(SKILLS);
2082
+ return text(
2083
+ `Published **${saved.name}** version ${saved.version} (${saved.fileCount} file(s), ${saved.totalBytes} bytes).
2084
+ Install it anywhere with: npx -y @systemdox/mcp-server install-skill ${saved.name}` + (skipped.length ? `
2085
+ Left out (hidden files are never part of a skill): ${skipped.join(", ")}` : "")
2086
+ );
2087
+ }
2088
+ );
2089
+ server2.registerTool(
2090
+ "list_skills",
2091
+ {
2092
+ title: "List Skills",
2093
+ description: "List the Claude Code skills published to SystemDox that you can install: name, description, latest version. Use install_skill to install one.",
2094
+ inputSchema: {},
2095
+ annotations: { readOnlyHint: true }
2096
+ },
2097
+ async () => {
2098
+ const { skills } = await apiClient2.get(SKILLS);
2099
+ if (!skills.length) {
2100
+ return text(
2101
+ `No skills published yet. Publish one with publish_skill; they appear at ${configLink("/docs/skills", await workspaceBase(apiClient2))}`
2102
+ );
2103
+ }
2104
+ return text(
2105
+ `${skills.length} skill(s):
2106
+
2107
+ ` + skills.map((s) => `- **${s.name}** (version ${s.latestVersion}, updated ${s.updatedAt.slice(0, 10)}): ${s.description}`).join("\n")
2108
+ );
2109
+ }
2110
+ );
2111
+ server2.registerTool(
2112
+ "install_skill",
2113
+ {
2114
+ title: "Install Skill",
2115
+ 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.",
2116
+ inputSchema: {
2117
+ name: z13.string().describe("The skill's name"),
2118
+ version: z13.number().int().positive().optional().describe("A version number (default: the latest)"),
2119
+ target: z13.string().optional().describe("Folder to install into, e.g. a repo's .claude/skills/<name>"),
2120
+ ...localFiles ? { overwrite: z13.boolean().optional().describe("Replace files edited since they were installed") } : {}
2121
+ },
2122
+ annotations: { readOnlyHint: !localFiles }
2123
+ },
2124
+ async (args) => {
2125
+ const version = await apiClient2.getFresh(
2126
+ `${SKILLS}/${encodeURIComponent(args.name)}/versions/${args.version ?? "latest"}`
2127
+ );
2128
+ if (localFiles) {
2129
+ const dest2 = args.target ? expandHome(args.target) : defaultSkillDir(version.name);
2130
+ if (basename(resolve2(dest2)) !== version.name) {
2131
+ throw new Error(`target must be a folder named ${version.name} (e.g. .claude/skills/${version.name}); got ${dest2}`);
2132
+ }
2133
+ const result = await installSkill(version, dest2, { overwrite: args.overwrite });
2134
+ return text(describeInstall(version.name, result));
2135
+ }
2136
+ const dest = args.target ?? `~/.claude/skills/${version.name}`;
2137
+ const manifest = {
2138
+ name: version.name,
2139
+ version: version.version,
2140
+ installedAt: "<now, ISO 8601>",
2141
+ files: Object.fromEntries(version.files.map((f) => [f.path, f.sha256]))
2142
+ };
2143
+ const body = version.files.map((f) => {
2144
+ const fence = fenceFor(f.content);
2145
+ return `### ${f.path}
2146
+
2147
+ ${fence}
2148
+ ${f.content}${f.content.endsWith("\n") ? "" : "\n"}${fence}`;
2149
+ }).join("\n\n");
2150
+ return text(
2151
+ `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.
2152
+
2153
+ ### ${MANIFEST_FILE}
2154
+
2155
+ \`\`\`json
2156
+ ${JSON.stringify(manifest, null, 2)}
2157
+ \`\`\`
2158
+
2159
+ ${body}`
2160
+ );
2161
+ }
2162
+ );
2163
+ }
2164
+
1828
2165
  // src/server.ts
1829
- var SERVER_VERSION = true ? "0.1.42" : "0.0.0-dev";
1830
- function createServer(apiClient2) {
2166
+ var SERVER_VERSION = true ? "0.1.43" : "0.0.0-dev";
2167
+ function createServer(apiClient2, { localFiles = false } = {}) {
1831
2168
  const server2 = new McpServer({
1832
2169
  name: "systemdox-mcp-server",
1833
2170
  version: SERVER_VERSION
@@ -1846,13 +2183,14 @@ function createServer(apiClient2) {
1846
2183
  registerReportDecision(server2, apiClient2);
1847
2184
  registerCreateProjectRule(server2, apiClient2);
1848
2185
  registerCreatePromptTemplate(server2, apiClient2);
2186
+ registerSkillTools(server2, apiClient2, { localFiles });
1849
2187
  return server2;
1850
2188
  }
1851
2189
 
1852
2190
  // src/index.ts
1853
2191
  var config = loadConfig();
1854
2192
  var apiClient = new SystemDoxApiClient(config);
1855
- var server = createServer(apiClient);
2193
+ var server = createServer(apiClient, { localFiles: true });
1856
2194
  async function main() {
1857
2195
  await apiClient.validate();
1858
2196
  const transport = new StdioServerTransport();
@@ -0,0 +1,25 @@
1
+ /**
2
+ * `systemdox-mcp-server install-skill <name>` — what the console's Install
3
+ * button copies (ADR-216 §2.3). Installs a published skill onto this computer
4
+ * with no model in the loop, through the same code and the same edit check as
5
+ * the stdio server's install_skill tool.
6
+ *
7
+ * Exit codes: 0 installed; 1 usage, no key, or the API refused; 2 refused
8
+ * because files in the folder were edited since they were installed.
9
+ */
10
+ import { SystemDoxApiClient } from "./lib/api-client.js";
11
+ export declare const INSTALL_USAGE = "Usage: systemdox-mcp-server install-skill <name> [--version <n>] [--target <dir>] [--overwrite]";
12
+ export interface InstallArgs {
13
+ name: string;
14
+ version?: number;
15
+ target?: string;
16
+ overwrite: boolean;
17
+ }
18
+ export declare function parseInstallArgs(argv: string[]): InstallArgs;
19
+ interface Io {
20
+ out: (line: string) => void;
21
+ err: (line: string) => void;
22
+ client?: Pick<SystemDoxApiClient, "getFresh">;
23
+ }
24
+ export declare function runInstallSkill(argv: string[], io: Io): Promise<number>;
25
+ export {};