@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
package/dist/http.js
CHANGED
|
@@ -63,6 +63,16 @@ var SystemDoxApiClient = class {
|
|
|
63
63
|
this.cache.set(cacheKey, data);
|
|
64
64
|
return data;
|
|
65
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
|
+
}
|
|
66
76
|
async post(path, body) {
|
|
67
77
|
return this.mutate("POST", path, body);
|
|
68
78
|
}
|
|
@@ -83,8 +93,8 @@ var SystemDoxApiClient = class {
|
|
|
83
93
|
body: JSON.stringify(body)
|
|
84
94
|
});
|
|
85
95
|
if (!res.ok) {
|
|
86
|
-
const
|
|
87
|
-
throw new Error(`SystemDox API error ${res.status}: ${
|
|
96
|
+
const text2 = await res.text();
|
|
97
|
+
throw new Error(`SystemDox API error ${res.status}: ${text2}`);
|
|
88
98
|
}
|
|
89
99
|
return await res.json();
|
|
90
100
|
}
|
|
@@ -303,12 +313,12 @@ function registerGetGuardrails(server, apiClient) {
|
|
|
303
313
|
);
|
|
304
314
|
}
|
|
305
315
|
if (filtered.length === 0) {
|
|
306
|
-
const
|
|
316
|
+
const text3 = guardrails.length === 0 ? `No checks found. Add checks at ${configLink("/docs/guardrails", await workspaceBase(apiClient))}` : `No checks found${lang ? ` for language "${lang}"` : ""}${category ? ` in category "${category}"` : ""}.`;
|
|
307
317
|
return {
|
|
308
|
-
content: [{ type: "text", text:
|
|
318
|
+
content: [{ type: "text", text: text3 }]
|
|
309
319
|
};
|
|
310
320
|
}
|
|
311
|
-
const
|
|
321
|
+
const text2 = filtered.map(
|
|
312
322
|
(g) => `## ${g.title} [${g.severity}]
|
|
313
323
|
|
|
314
324
|
${g.description}
|
|
@@ -332,7 +342,7 @@ ${g.fix}
|
|
|
332
342
|
type: "text",
|
|
333
343
|
text: `Found ${filtered.length} check(s):
|
|
334
344
|
|
|
335
|
-
${
|
|
345
|
+
${text2}`
|
|
336
346
|
}
|
|
337
347
|
]
|
|
338
348
|
};
|
|
@@ -422,7 +432,7 @@ ${spec.taskContent}`);
|
|
|
422
432
|
]
|
|
423
433
|
};
|
|
424
434
|
}
|
|
425
|
-
const
|
|
435
|
+
const text2 = filtered.map(
|
|
426
436
|
(s) => `- **${s.name}** (${s.draftId}) \u2014 ${s.status}, ${s.requirementCount} requirements` + (s.repoFullName ? `, repo: ${s.repoFullName}` : "")
|
|
427
437
|
).join("\n");
|
|
428
438
|
return {
|
|
@@ -431,7 +441,7 @@ ${spec.taskContent}`);
|
|
|
431
441
|
type: "text",
|
|
432
442
|
text: `Found ${filtered.length} spec(s):
|
|
433
443
|
|
|
434
|
-
${
|
|
444
|
+
${text2}`
|
|
435
445
|
}
|
|
436
446
|
]
|
|
437
447
|
};
|
|
@@ -484,18 +494,18 @@ function registerGetDocs(server, apiClient) {
|
|
|
484
494
|
isError: true
|
|
485
495
|
};
|
|
486
496
|
}
|
|
487
|
-
let
|
|
497
|
+
let text3 = doc.content;
|
|
488
498
|
if (anchor && doc.section_resolved === false) {
|
|
489
|
-
|
|
499
|
+
text3 = `_Section "${anchor}" not found \u2014 returning the full document._
|
|
490
500
|
|
|
491
|
-
` +
|
|
501
|
+
` + text3;
|
|
492
502
|
} else if (anchor && doc.heading) {
|
|
493
|
-
|
|
503
|
+
text3 = `## ${doc.heading}
|
|
494
504
|
|
|
495
505
|
${doc.content}`;
|
|
496
506
|
}
|
|
497
507
|
return {
|
|
498
|
-
content: [{ type: "text", text:
|
|
508
|
+
content: [{ type: "text", text: text3 }]
|
|
499
509
|
};
|
|
500
510
|
}
|
|
501
511
|
const params = {};
|
|
@@ -519,7 +529,7 @@ ${doc.content}`;
|
|
|
519
529
|
]
|
|
520
530
|
};
|
|
521
531
|
}
|
|
522
|
-
const
|
|
532
|
+
const text2 = items.map((d) => {
|
|
523
533
|
const sections = (d.outline ?? []).slice(0, 8);
|
|
524
534
|
const sectionHint = sections.length > 0 ? `
|
|
525
535
|
sections: ${sections.map((s) => s.anchor).join(", ")}` : "";
|
|
@@ -533,7 +543,7 @@ ${doc.content}`;
|
|
|
533
543
|
type: "text",
|
|
534
544
|
text: `Found ${items.length} document(s) (${response.total} total):
|
|
535
545
|
|
|
536
|
-
${
|
|
546
|
+
${text2}
|
|
537
547
|
|
|
538
548
|
Use doc_id for full content, or doc_id + section to fetch one section.`
|
|
539
549
|
}
|
|
@@ -868,13 +878,13 @@ var SEVERITY_SCORES = {
|
|
|
868
878
|
low: 0.25
|
|
869
879
|
};
|
|
870
880
|
var CHARS_PER_TOKEN = 4;
|
|
871
|
-
function estimateTokens(
|
|
872
|
-
return Math.ceil(
|
|
881
|
+
function estimateTokens(text2) {
|
|
882
|
+
return Math.ceil(text2.length / CHARS_PER_TOKEN);
|
|
873
883
|
}
|
|
874
|
-
function truncateToTokens(
|
|
884
|
+
function truncateToTokens(text2, maxTokens) {
|
|
875
885
|
const maxChars = Math.max(0, maxTokens) * CHARS_PER_TOKEN;
|
|
876
|
-
if (
|
|
877
|
-
const head =
|
|
886
|
+
if (text2.length <= maxChars) return { text: text2, truncated: false };
|
|
887
|
+
const head = text2.slice(0, maxChars);
|
|
878
888
|
const floor = Math.floor(maxChars * 0.75);
|
|
879
889
|
const paragraph = head.lastIndexOf("\n\n");
|
|
880
890
|
const line = head.lastIndexOf("\n");
|
|
@@ -919,7 +929,7 @@ function scoreSeverity(item) {
|
|
|
919
929
|
if (!item.severity) return 0.5;
|
|
920
930
|
return SEVERITY_SCORES[item.severity] ?? 0.5;
|
|
921
931
|
}
|
|
922
|
-
function extractKeywords(
|
|
932
|
+
function extractKeywords(text2) {
|
|
923
933
|
const stopWords = /* @__PURE__ */ new Set([
|
|
924
934
|
"the",
|
|
925
935
|
"a",
|
|
@@ -1004,7 +1014,7 @@ function extractKeywords(text) {
|
|
|
1004
1014
|
"implement",
|
|
1005
1015
|
"create"
|
|
1006
1016
|
]);
|
|
1007
|
-
return
|
|
1017
|
+
return text2.toLowerCase().replace(/[^a-z0-9\s-]/g, " ").split(/\s+/).filter((w) => w.length > 2 && !stopWords.has(w));
|
|
1008
1018
|
}
|
|
1009
1019
|
function scoreItems(items, taskDescription, filePaths) {
|
|
1010
1020
|
const languages = detectLanguages(filePaths);
|
|
@@ -1047,20 +1057,20 @@ var MAX_TAIL = 10;
|
|
|
1047
1057
|
var TAIL_SHARE = 0.15;
|
|
1048
1058
|
var TRUNCATION_NOTICE_TOKENS = 30;
|
|
1049
1059
|
function formatGuardrail(g) {
|
|
1050
|
-
let
|
|
1060
|
+
let text2 = `## ${g.title} [${g.severity}]
|
|
1051
1061
|
|
|
1052
1062
|
${g.description}`;
|
|
1053
|
-
if (g.pattern)
|
|
1063
|
+
if (g.pattern) text2 += `
|
|
1054
1064
|
|
|
1055
1065
|
**Bad:** \`\`\`
|
|
1056
1066
|
${g.pattern}
|
|
1057
1067
|
\`\`\``;
|
|
1058
|
-
if (g.fix)
|
|
1068
|
+
if (g.fix) text2 += `
|
|
1059
1069
|
|
|
1060
1070
|
**Good:** \`\`\`
|
|
1061
1071
|
${g.fix}
|
|
1062
1072
|
\`\`\``;
|
|
1063
|
-
return
|
|
1073
|
+
return text2;
|
|
1064
1074
|
}
|
|
1065
1075
|
function formatSpecHeadline(s) {
|
|
1066
1076
|
return `### ${s.name}
|
|
@@ -1082,19 +1092,19 @@ function formatRules(rules) {
|
|
|
1082
1092
|
if (!byCategory.has(cat)) byCategory.set(cat, []);
|
|
1083
1093
|
byCategory.get(cat).push(r);
|
|
1084
1094
|
}
|
|
1085
|
-
let
|
|
1095
|
+
let text2 = "";
|
|
1086
1096
|
for (const [cat, catRules] of byCategory) {
|
|
1087
|
-
|
|
1097
|
+
text2 += `### ${cat.charAt(0).toUpperCase() + cat.slice(1)}
|
|
1088
1098
|
`;
|
|
1089
1099
|
for (const r of catRules) {
|
|
1090
|
-
|
|
1100
|
+
text2 += `- ${r.text}
|
|
1091
1101
|
`;
|
|
1092
1102
|
}
|
|
1093
|
-
|
|
1103
|
+
text2 += "\n";
|
|
1094
1104
|
}
|
|
1095
1105
|
return `## Project Rules (${rules.length})
|
|
1096
1106
|
|
|
1097
|
-
${
|
|
1107
|
+
${text2.trim()}`;
|
|
1098
1108
|
}
|
|
1099
1109
|
async function fetchSpecBody(api, draftId) {
|
|
1100
1110
|
const spec = await api.get(
|
|
@@ -1431,13 +1441,13 @@ function registerGetContext(server, apiClient) {
|
|
|
1431
1441
|
}
|
|
1432
1442
|
},
|
|
1433
1443
|
async ({ task_description, file_paths, max_tokens }) => {
|
|
1434
|
-
const { text } = await buildContext(apiClient, {
|
|
1444
|
+
const { text: text2 } = await buildContext(apiClient, {
|
|
1435
1445
|
taskDescription: task_description,
|
|
1436
1446
|
filePaths: file_paths,
|
|
1437
1447
|
maxTokens: max_tokens
|
|
1438
1448
|
});
|
|
1439
1449
|
return {
|
|
1440
|
-
content: [{ type: "text", text }]
|
|
1450
|
+
content: [{ type: "text", text: text2 }]
|
|
1441
1451
|
};
|
|
1442
1452
|
}
|
|
1443
1453
|
);
|
|
@@ -1685,9 +1695,9 @@ function registerGetProjectRules(server, apiClient) {
|
|
|
1685
1695
|
);
|
|
1686
1696
|
}
|
|
1687
1697
|
if (filtered.length === 0) {
|
|
1688
|
-
const
|
|
1698
|
+
const text3 = rules.length === 0 ? `No project rules found. Add rules at ${configLink("/docs/rules", await workspaceBase(apiClient))}` : `No project rules found${category ? ` in category "${category}"` : ""}.`;
|
|
1689
1699
|
return {
|
|
1690
|
-
content: [{ type: "text", text:
|
|
1700
|
+
content: [{ type: "text", text: text3 }]
|
|
1691
1701
|
};
|
|
1692
1702
|
}
|
|
1693
1703
|
const grouped = /* @__PURE__ */ new Map();
|
|
@@ -1696,25 +1706,25 @@ function registerGetProjectRules(server, apiClient) {
|
|
|
1696
1706
|
if (!grouped.has(cat)) grouped.set(cat, []);
|
|
1697
1707
|
grouped.get(cat).push(rule);
|
|
1698
1708
|
}
|
|
1699
|
-
let
|
|
1709
|
+
let text2 = `Found ${filtered.length} project rule(s):
|
|
1700
1710
|
|
|
1701
1711
|
`;
|
|
1702
1712
|
for (const [cat, catRules] of grouped) {
|
|
1703
1713
|
const label = CATEGORY_LABELS[cat] ?? cat;
|
|
1704
|
-
|
|
1714
|
+
text2 += `## ${label}
|
|
1705
1715
|
|
|
1706
1716
|
`;
|
|
1707
1717
|
for (const rule of catRules) {
|
|
1708
|
-
|
|
1718
|
+
text2 += `- ${rule.text}
|
|
1709
1719
|
`;
|
|
1710
1720
|
}
|
|
1711
|
-
|
|
1721
|
+
text2 += "\n";
|
|
1712
1722
|
}
|
|
1713
1723
|
return {
|
|
1714
1724
|
content: [
|
|
1715
1725
|
{
|
|
1716
1726
|
type: "text",
|
|
1717
|
-
text
|
|
1727
|
+
text: text2
|
|
1718
1728
|
}
|
|
1719
1729
|
]
|
|
1720
1730
|
};
|
|
@@ -1740,10 +1750,10 @@ function registerCreateProjectRule(server, apiClient) {
|
|
|
1740
1750
|
readOnlyHint: false
|
|
1741
1751
|
}
|
|
1742
1752
|
},
|
|
1743
|
-
async ({ text, category }) => {
|
|
1753
|
+
async ({ text: text2, category }) => {
|
|
1744
1754
|
const saved = await apiClient.post(
|
|
1745
1755
|
"/v1/standards/rules",
|
|
1746
|
-
{ text, category }
|
|
1756
|
+
{ text: text2, category }
|
|
1747
1757
|
);
|
|
1748
1758
|
apiClient.invalidateCache("/v1/standards/rules");
|
|
1749
1759
|
return {
|
|
@@ -1781,7 +1791,7 @@ function registerGetPromptTemplates(server, apiClient) {
|
|
|
1781
1791
|
]
|
|
1782
1792
|
};
|
|
1783
1793
|
}
|
|
1784
|
-
const
|
|
1794
|
+
const text2 = templates.map(
|
|
1785
1795
|
(t) => `## ${t.name}
|
|
1786
1796
|
|
|
1787
1797
|
` + (t.description ? `*${t.description}*
|
|
@@ -1796,7 +1806,7 @@ ${t.prompt}
|
|
|
1796
1806
|
type: "text",
|
|
1797
1807
|
text: `Found ${templates.length} prompt template(s):
|
|
1798
1808
|
|
|
1799
|
-
${
|
|
1809
|
+
${text2}`
|
|
1800
1810
|
}
|
|
1801
1811
|
]
|
|
1802
1812
|
};
|
|
@@ -1843,9 +1853,345 @@ function registerCreatePromptTemplate(server, apiClient) {
|
|
|
1843
1853
|
);
|
|
1844
1854
|
}
|
|
1845
1855
|
|
|
1856
|
+
// src/tools/skills.ts
|
|
1857
|
+
import { z as z13 } from "zod";
|
|
1858
|
+
import { homedir as homedir2 } from "os";
|
|
1859
|
+
import { basename, resolve as resolve2 } from "path";
|
|
1860
|
+
|
|
1861
|
+
// src/lib/skills.ts
|
|
1862
|
+
import { createHash } from "crypto";
|
|
1863
|
+
import { lstat, mkdir, readdir, readFile, rm, writeFile } from "fs/promises";
|
|
1864
|
+
import { existsSync } from "fs";
|
|
1865
|
+
import { homedir } from "os";
|
|
1866
|
+
import { dirname, join, relative, resolve, sep } from "path";
|
|
1867
|
+
var MANIFEST_FILE = ".systemdox-skill.json";
|
|
1868
|
+
var ENTRY_FILE = "SKILL.md";
|
|
1869
|
+
var MAX_FILES = 100;
|
|
1870
|
+
var MAX_FILE_BYTES = 256 * 1024;
|
|
1871
|
+
var MAX_TOTAL_BYTES = 1024 * 1024;
|
|
1872
|
+
var NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
1873
|
+
var SEGMENT_RE = /^[A-Za-z0-9._-]+$/;
|
|
1874
|
+
var SKIPPED = /* @__PURE__ */ new Set([MANIFEST_FILE, ".git", "node_modules", "__pycache__", ".DS_Store"]);
|
|
1875
|
+
var SkillInstallRefused = class extends Error {
|
|
1876
|
+
constructor(dest, edited) {
|
|
1877
|
+
super(
|
|
1878
|
+
`${dest} has local changes in ${edited.length} file(s): ${edited.join(", ")}. Nothing was written. Move your changes aside, or install again with overwrite.`
|
|
1879
|
+
);
|
|
1880
|
+
this.dest = dest;
|
|
1881
|
+
this.edited = edited;
|
|
1882
|
+
}
|
|
1883
|
+
dest;
|
|
1884
|
+
edited;
|
|
1885
|
+
};
|
|
1886
|
+
function sha256(content) {
|
|
1887
|
+
return createHash("sha256").update(content, "utf8").digest("hex");
|
|
1888
|
+
}
|
|
1889
|
+
function isValidName(name) {
|
|
1890
|
+
return name.length <= 64 && NAME_RE.test(name);
|
|
1891
|
+
}
|
|
1892
|
+
function isValidPath(path) {
|
|
1893
|
+
return path.length > 0 && path.length <= 512 && !path.startsWith("/") && path.split("/").every((s) => SEGMENT_RE.test(s) && s !== "." && s !== "..");
|
|
1894
|
+
}
|
|
1895
|
+
function frontmatterName(skillMd) {
|
|
1896
|
+
const block = /^---\r?\n([\s\S]*?)\r?\n---/.exec(skillMd);
|
|
1897
|
+
if (!block) return void 0;
|
|
1898
|
+
const line = /^name:[ \t]*(.+?)[ \t]*$/m.exec(block[1]);
|
|
1899
|
+
return line ? line[1].replace(/^(['"])(.*)\1$/, "$2") : void 0;
|
|
1900
|
+
}
|
|
1901
|
+
function defaultSkillDir(name) {
|
|
1902
|
+
return join(homedir(), ".claude", "skills", name);
|
|
1903
|
+
}
|
|
1904
|
+
async function walk(root, { skipHidden }, dir = root, out = { files: [], links: [], hidden: [] }) {
|
|
1905
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
1906
|
+
if (SKIPPED.has(entry.name) || entry.name.endsWith(".pyc")) continue;
|
|
1907
|
+
const full = join(dir, entry.name);
|
|
1908
|
+
const rel = relative(root, full).split(sep).join("/");
|
|
1909
|
+
if (entry.isSymbolicLink()) out.links.push(rel);
|
|
1910
|
+
else if (skipHidden && entry.name.startsWith(".")) out.hidden.push(rel);
|
|
1911
|
+
else if (entry.isDirectory()) await walk(root, { skipHidden }, full, out);
|
|
1912
|
+
else if (entry.isFile()) out.files.push(rel);
|
|
1913
|
+
}
|
|
1914
|
+
out.files.sort();
|
|
1915
|
+
return out;
|
|
1916
|
+
}
|
|
1917
|
+
async function readSkillDirectory(directory) {
|
|
1918
|
+
const root = resolve(directory);
|
|
1919
|
+
if (!existsSync(join(root, ENTRY_FILE))) {
|
|
1920
|
+
throw new Error(`${root} has no ${ENTRY_FILE}: a skill folder holds SKILL.md at its root`);
|
|
1921
|
+
}
|
|
1922
|
+
const walked = await walk(root, { skipHidden: true });
|
|
1923
|
+
if (walked.links.length) {
|
|
1924
|
+
throw new Error(`${walked.links.join(", ")}: links are not followed; copy the file into the skill folder`);
|
|
1925
|
+
}
|
|
1926
|
+
const paths = walked.files;
|
|
1927
|
+
if (paths.length > MAX_FILES) {
|
|
1928
|
+
throw new Error(`${root} holds ${paths.length} files: a skill holds at most ${MAX_FILES}`);
|
|
1929
|
+
}
|
|
1930
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
1931
|
+
const files = [];
|
|
1932
|
+
let total = 0;
|
|
1933
|
+
for (const path of paths) {
|
|
1934
|
+
if (!isValidPath(path)) {
|
|
1935
|
+
throw new Error(`${path}: file names may use letters, digits, ".", "_" and "-" only`);
|
|
1936
|
+
}
|
|
1937
|
+
const raw = await readFile(join(root, ...path.split("/")));
|
|
1938
|
+
if (raw.includes(0)) throw new Error(`${path} is binary: a skill holds text files only`);
|
|
1939
|
+
if (raw.length > MAX_FILE_BYTES) {
|
|
1940
|
+
throw new Error(`${path} is ${raw.length} bytes: a file holds at most ${MAX_FILE_BYTES}`);
|
|
1941
|
+
}
|
|
1942
|
+
total += raw.length;
|
|
1943
|
+
if (total > MAX_TOTAL_BYTES) {
|
|
1944
|
+
throw new Error(`the files pass ${MAX_TOTAL_BYTES} bytes at ${path}`);
|
|
1945
|
+
}
|
|
1946
|
+
let content;
|
|
1947
|
+
try {
|
|
1948
|
+
content = decoder.decode(raw);
|
|
1949
|
+
} catch {
|
|
1950
|
+
throw new Error(`${path} is not UTF-8 text`);
|
|
1951
|
+
}
|
|
1952
|
+
files.push({ path, content });
|
|
1953
|
+
}
|
|
1954
|
+
const skillMd = files.find((f) => f.path === ENTRY_FILE);
|
|
1955
|
+
const name = frontmatterName(skillMd.content);
|
|
1956
|
+
if (!name) throw new Error(`${ENTRY_FILE} has no frontmatter name`);
|
|
1957
|
+
return { name, files, skipped: walked.hidden };
|
|
1958
|
+
}
|
|
1959
|
+
function within(dest, path) {
|
|
1960
|
+
if (!isValidPath(path)) throw new Error(`refusing to write ${JSON.stringify(path)}: not a skill path`);
|
|
1961
|
+
const full = resolve(dest, ...path.split("/"));
|
|
1962
|
+
if (!full.startsWith(resolve(dest) + sep)) throw new Error(`refusing to write outside ${dest}: ${path}`);
|
|
1963
|
+
return full;
|
|
1964
|
+
}
|
|
1965
|
+
async function readManifest(dest) {
|
|
1966
|
+
try {
|
|
1967
|
+
return JSON.parse(await readFile(join(dest, MANIFEST_FILE), "utf8"));
|
|
1968
|
+
} catch {
|
|
1969
|
+
return void 0;
|
|
1970
|
+
}
|
|
1971
|
+
}
|
|
1972
|
+
async function installSkill(version, dest, { overwrite = false } = {}) {
|
|
1973
|
+
if (!isValidName(version.name)) throw new Error(`refusing to install ${JSON.stringify(version.name)}: not a skill name`);
|
|
1974
|
+
const incoming = new Map(version.files.map((f) => [f.path, f]));
|
|
1975
|
+
for (const f of version.files) within(dest, f.path);
|
|
1976
|
+
if (existsSync(dest) && (await lstat(dest)).isSymbolicLink()) {
|
|
1977
|
+
throw new Error(`${dest} is a link: install into a real folder`);
|
|
1978
|
+
}
|
|
1979
|
+
const walked = existsSync(dest) ? await walk(dest, { skipHidden: false }) : { files: [], links: [], hidden: [] };
|
|
1980
|
+
if (walked.links.length) {
|
|
1981
|
+
throw new Error(`${dest} holds links (${walked.links.join(", ")}): remove them, nothing was written`);
|
|
1982
|
+
}
|
|
1983
|
+
const present = walked.files;
|
|
1984
|
+
const manifest = await readManifest(dest);
|
|
1985
|
+
const known = Object.fromEntries(
|
|
1986
|
+
Object.entries(manifest?.files ?? {}).filter(([p, h]) => isValidPath(p) && typeof h === "string")
|
|
1987
|
+
);
|
|
1988
|
+
const edited = [];
|
|
1989
|
+
const untracked = [];
|
|
1990
|
+
for (const path of present) {
|
|
1991
|
+
const content = await readFile(join(dest, ...path.split("/")), "utf8");
|
|
1992
|
+
if (Object.hasOwn(known, path)) {
|
|
1993
|
+
if (sha256(content) !== known[path]) edited.push(path);
|
|
1994
|
+
} else if (!incoming.has(path) || sha256(content) !== incoming.get(path).sha256) {
|
|
1995
|
+
if (incoming.has(path) || !manifest) edited.push(path);
|
|
1996
|
+
else untracked.push(path);
|
|
1997
|
+
}
|
|
1998
|
+
}
|
|
1999
|
+
const blocking = edited.filter((p) => incoming.has(p) || !manifest);
|
|
2000
|
+
if (blocking.length && !overwrite) throw new SkillInstallRefused(dest, blocking);
|
|
2001
|
+
const written = [];
|
|
2002
|
+
for (const f of version.files) {
|
|
2003
|
+
const full = within(dest, f.path);
|
|
2004
|
+
await mkdir(dirname(full), { recursive: true });
|
|
2005
|
+
await writeFile(full, f.content, "utf8");
|
|
2006
|
+
written.push(f.path);
|
|
2007
|
+
}
|
|
2008
|
+
const removed = [];
|
|
2009
|
+
const keptEdited = [];
|
|
2010
|
+
for (const path of Object.keys(known)) {
|
|
2011
|
+
if (incoming.has(path) || !present.includes(path)) continue;
|
|
2012
|
+
if (edited.includes(path)) {
|
|
2013
|
+
keptEdited.push(path);
|
|
2014
|
+
continue;
|
|
2015
|
+
}
|
|
2016
|
+
await rm(within(dest, path));
|
|
2017
|
+
removed.push(path);
|
|
2018
|
+
}
|
|
2019
|
+
const next = {
|
|
2020
|
+
name: version.name,
|
|
2021
|
+
version: version.version,
|
|
2022
|
+
installedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2023
|
+
files: Object.fromEntries(version.files.map((f) => [f.path, f.sha256]))
|
|
2024
|
+
};
|
|
2025
|
+
await writeFile(join(dest, MANIFEST_FILE), JSON.stringify(next, null, 2) + "\n", "utf8");
|
|
2026
|
+
return {
|
|
2027
|
+
dest,
|
|
2028
|
+
version: version.version,
|
|
2029
|
+
written,
|
|
2030
|
+
removed,
|
|
2031
|
+
keptUntracked: untracked.filter((p) => !incoming.has(p)),
|
|
2032
|
+
keptEdited
|
|
2033
|
+
};
|
|
2034
|
+
}
|
|
2035
|
+
function fenceFor(text2) {
|
|
2036
|
+
const longest = Math.max(0, ...(text2.match(/`+/g) ?? []).map((r) => r.length));
|
|
2037
|
+
return "`".repeat(Math.max(3, longest + 1));
|
|
2038
|
+
}
|
|
2039
|
+
function describeInstall(name, r) {
|
|
2040
|
+
const lines = [
|
|
2041
|
+
`Installed ${name} version ${r.version} into ${r.dest} (${r.written.length} file(s)).`
|
|
2042
|
+
];
|
|
2043
|
+
if (r.removed.length) lines.push(`Removed (no longer in the skill): ${r.removed.join(", ")}`);
|
|
2044
|
+
if (r.keptEdited.length) {
|
|
2045
|
+
lines.push(`Kept, edited here and no longer in the skill: ${r.keptEdited.join(", ")}`);
|
|
2046
|
+
}
|
|
2047
|
+
if (r.keptUntracked.length) lines.push(`Kept, added here: ${r.keptUntracked.join(", ")}`);
|
|
2048
|
+
lines.push("Start a new Claude Code session to load it.");
|
|
2049
|
+
return lines.join("\n");
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
// src/tools/skills.ts
|
|
2053
|
+
var SKILLS = "/v1/standards/skills";
|
|
2054
|
+
function expandHome(path) {
|
|
2055
|
+
return path.replace(/^~(?=$|[\\/])/, homedir2());
|
|
2056
|
+
}
|
|
2057
|
+
function text(t) {
|
|
2058
|
+
return { content: [{ type: "text", text: t }] };
|
|
2059
|
+
}
|
|
2060
|
+
function registerSkillTools(server, apiClient, { localFiles }) {
|
|
2061
|
+
const fileSchema = z13.object({
|
|
2062
|
+
path: z13.string().describe('Path inside the skill folder, forward slashes, e.g. "references/guide.md"'),
|
|
2063
|
+
content: z13.string().describe("The file's full text, exactly as it is")
|
|
2064
|
+
});
|
|
2065
|
+
server.registerTool(
|
|
2066
|
+
"publish_skill",
|
|
2067
|
+
{
|
|
2068
|
+
title: "Publish Skill",
|
|
2069
|
+
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.",
|
|
2070
|
+
inputSchema: {
|
|
2071
|
+
...localFiles ? {
|
|
2072
|
+
directory: z13.string().optional().describe("The skill folder on this computer, holding SKILL.md at its root")
|
|
2073
|
+
} : {},
|
|
2074
|
+
files: z13.array(fileSchema).optional().describe(localFiles ? "Only when there is no folder to read" : "Every file of the skill folder"),
|
|
2075
|
+
name: z13.string().optional().describe("The skill's name; defaults to SKILL.md's frontmatter name and must match it"),
|
|
2076
|
+
spaceId: z13.string().optional().describe("Space for a NEW skill (default: your default space)"),
|
|
2077
|
+
note: z13.string().max(500).optional().describe(`A short change note for this version, shown in the console's history (e.g. "Fix the GA4 step")`),
|
|
2078
|
+
baseVersion: z13.number().int().positive().optional().describe(
|
|
2079
|
+
"The version these files were edited from. When given, the publish is refused (SYD-K017) if someone published a newer version since, instead of replacing their changes"
|
|
2080
|
+
)
|
|
2081
|
+
},
|
|
2082
|
+
annotations: { readOnlyHint: false }
|
|
2083
|
+
},
|
|
2084
|
+
async (args) => {
|
|
2085
|
+
let files;
|
|
2086
|
+
let name;
|
|
2087
|
+
let skipped = [];
|
|
2088
|
+
if (localFiles && args.directory) {
|
|
2089
|
+
({ name, files, skipped } = await readSkillDirectory(expandHome(args.directory)));
|
|
2090
|
+
} else if (args.files?.length) {
|
|
2091
|
+
files = args.files;
|
|
2092
|
+
const entry = files.find((f) => f.path === ENTRY_FILE);
|
|
2093
|
+
name = entry ? frontmatterName(entry.content) : void 0;
|
|
2094
|
+
} else {
|
|
2095
|
+
throw new Error(localFiles ? "Pass `directory` (or `files`)." : "Pass `files`.");
|
|
2096
|
+
}
|
|
2097
|
+
name = args.name ?? name;
|
|
2098
|
+
if (!name) throw new Error(`${ENTRY_FILE} has no frontmatter name; pass \`name\`.`);
|
|
2099
|
+
const saved = await apiClient.post(
|
|
2100
|
+
`${SKILLS}/${encodeURIComponent(name)}/versions`,
|
|
2101
|
+
{
|
|
2102
|
+
files,
|
|
2103
|
+
...args.spaceId ? { spaceId: args.spaceId } : {},
|
|
2104
|
+
...args.note ? { note: args.note } : {},
|
|
2105
|
+
...args.baseVersion ? { baseVersion: args.baseVersion } : {}
|
|
2106
|
+
}
|
|
2107
|
+
);
|
|
2108
|
+
apiClient.invalidateCache(SKILLS);
|
|
2109
|
+
return text(
|
|
2110
|
+
`Published **${saved.name}** version ${saved.version} (${saved.fileCount} file(s), ${saved.totalBytes} bytes).
|
|
2111
|
+
Install it anywhere with: npx -y @systemdox/mcp-server install-skill ${saved.name}` + (skipped.length ? `
|
|
2112
|
+
Left out (hidden files are never part of a skill): ${skipped.join(", ")}` : "")
|
|
2113
|
+
);
|
|
2114
|
+
}
|
|
2115
|
+
);
|
|
2116
|
+
server.registerTool(
|
|
2117
|
+
"list_skills",
|
|
2118
|
+
{
|
|
2119
|
+
title: "List Skills",
|
|
2120
|
+
description: "List the Claude Code skills published to SystemDox that you can install: name, description, latest version. Use install_skill to install one.",
|
|
2121
|
+
inputSchema: {},
|
|
2122
|
+
annotations: { readOnlyHint: true }
|
|
2123
|
+
},
|
|
2124
|
+
async () => {
|
|
2125
|
+
const { skills } = await apiClient.get(SKILLS);
|
|
2126
|
+
if (!skills.length) {
|
|
2127
|
+
return text(
|
|
2128
|
+
`No skills published yet. Publish one with publish_skill; they appear at ${configLink("/docs/skills", await workspaceBase(apiClient))}`
|
|
2129
|
+
);
|
|
2130
|
+
}
|
|
2131
|
+
return text(
|
|
2132
|
+
`${skills.length} skill(s):
|
|
2133
|
+
|
|
2134
|
+
` + skills.map((s) => `- **${s.name}** (version ${s.latestVersion}, updated ${s.updatedAt.slice(0, 10)}): ${s.description}`).join("\n")
|
|
2135
|
+
);
|
|
2136
|
+
}
|
|
2137
|
+
);
|
|
2138
|
+
server.registerTool(
|
|
2139
|
+
"install_skill",
|
|
2140
|
+
{
|
|
2141
|
+
title: "Install Skill",
|
|
2142
|
+
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.",
|
|
2143
|
+
inputSchema: {
|
|
2144
|
+
name: z13.string().describe("The skill's name"),
|
|
2145
|
+
version: z13.number().int().positive().optional().describe("A version number (default: the latest)"),
|
|
2146
|
+
target: z13.string().optional().describe("Folder to install into, e.g. a repo's .claude/skills/<name>"),
|
|
2147
|
+
...localFiles ? { overwrite: z13.boolean().optional().describe("Replace files edited since they were installed") } : {}
|
|
2148
|
+
},
|
|
2149
|
+
annotations: { readOnlyHint: !localFiles }
|
|
2150
|
+
},
|
|
2151
|
+
async (args) => {
|
|
2152
|
+
const version = await apiClient.getFresh(
|
|
2153
|
+
`${SKILLS}/${encodeURIComponent(args.name)}/versions/${args.version ?? "latest"}`
|
|
2154
|
+
);
|
|
2155
|
+
if (localFiles) {
|
|
2156
|
+
const dest2 = args.target ? expandHome(args.target) : defaultSkillDir(version.name);
|
|
2157
|
+
if (basename(resolve2(dest2)) !== version.name) {
|
|
2158
|
+
throw new Error(`target must be a folder named ${version.name} (e.g. .claude/skills/${version.name}); got ${dest2}`);
|
|
2159
|
+
}
|
|
2160
|
+
const result = await installSkill(version, dest2, { overwrite: args.overwrite });
|
|
2161
|
+
return text(describeInstall(version.name, result));
|
|
2162
|
+
}
|
|
2163
|
+
const dest = args.target ?? `~/.claude/skills/${version.name}`;
|
|
2164
|
+
const manifest = {
|
|
2165
|
+
name: version.name,
|
|
2166
|
+
version: version.version,
|
|
2167
|
+
installedAt: "<now, ISO 8601>",
|
|
2168
|
+
files: Object.fromEntries(version.files.map((f) => [f.path, f.sha256]))
|
|
2169
|
+
};
|
|
2170
|
+
const body = version.files.map((f) => {
|
|
2171
|
+
const fence = fenceFor(f.content);
|
|
2172
|
+
return `### ${f.path}
|
|
2173
|
+
|
|
2174
|
+
${fence}
|
|
2175
|
+
${f.content}${f.content.endsWith("\n") ? "" : "\n"}${fence}`;
|
|
2176
|
+
}).join("\n\n");
|
|
2177
|
+
return text(
|
|
2178
|
+
`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.
|
|
2179
|
+
|
|
2180
|
+
### ${MANIFEST_FILE}
|
|
2181
|
+
|
|
2182
|
+
\`\`\`json
|
|
2183
|
+
${JSON.stringify(manifest, null, 2)}
|
|
2184
|
+
\`\`\`
|
|
2185
|
+
|
|
2186
|
+
${body}`
|
|
2187
|
+
);
|
|
2188
|
+
}
|
|
2189
|
+
);
|
|
2190
|
+
}
|
|
2191
|
+
|
|
1846
2192
|
// src/server.ts
|
|
1847
|
-
var SERVER_VERSION = true ? "0.1.
|
|
1848
|
-
function createServer(apiClient) {
|
|
2193
|
+
var SERVER_VERSION = true ? "0.1.44" : "0.0.0-dev";
|
|
2194
|
+
function createServer(apiClient, { localFiles = false } = {}) {
|
|
1849
2195
|
const server = new McpServer({
|
|
1850
2196
|
name: "systemdox-mcp-server",
|
|
1851
2197
|
version: SERVER_VERSION
|
|
@@ -1864,6 +2210,7 @@ function createServer(apiClient) {
|
|
|
1864
2210
|
registerReportDecision(server, apiClient);
|
|
1865
2211
|
registerCreateProjectRule(server, apiClient);
|
|
1866
2212
|
registerCreatePromptTemplate(server, apiClient);
|
|
2213
|
+
registerSkillTools(server, apiClient, { localFiles });
|
|
1867
2214
|
return server;
|
|
1868
2215
|
}
|
|
1869
2216
|
|