@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/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
|
|
123
|
-
throw new Error(`SystemDox API error ${res.status}: ${
|
|
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
|
|
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:
|
|
300
|
+
content: [{ type: "text", text: text3 }]
|
|
291
301
|
};
|
|
292
302
|
}
|
|
293
|
-
const
|
|
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
|
-
${
|
|
327
|
+
${text2}`
|
|
318
328
|
}
|
|
319
329
|
]
|
|
320
330
|
};
|
|
@@ -404,7 +414,7 @@ ${spec.taskContent}`);
|
|
|
404
414
|
]
|
|
405
415
|
};
|
|
406
416
|
}
|
|
407
|
-
const
|
|
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
|
-
${
|
|
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
|
|
479
|
+
let text3 = doc.content;
|
|
470
480
|
if (anchor && doc.section_resolved === false) {
|
|
471
|
-
|
|
481
|
+
text3 = `_Section "${anchor}" not found \u2014 returning the full document._
|
|
472
482
|
|
|
473
|
-
` +
|
|
483
|
+
` + text3;
|
|
474
484
|
} else if (anchor && doc.heading) {
|
|
475
|
-
|
|
485
|
+
text3 = `## ${doc.heading}
|
|
476
486
|
|
|
477
487
|
${doc.content}`;
|
|
478
488
|
}
|
|
479
489
|
return {
|
|
480
|
-
content: [{ type: "text", text:
|
|
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
|
|
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
|
-
${
|
|
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(
|
|
854
|
-
return Math.ceil(
|
|
863
|
+
function estimateTokens(text2) {
|
|
864
|
+
return Math.ceil(text2.length / CHARS_PER_TOKEN);
|
|
855
865
|
}
|
|
856
|
-
function truncateToTokens(
|
|
866
|
+
function truncateToTokens(text2, maxTokens) {
|
|
857
867
|
const maxChars = Math.max(0, maxTokens) * CHARS_PER_TOKEN;
|
|
858
|
-
if (
|
|
859
|
-
const head =
|
|
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(
|
|
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
|
|
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
|
|
1042
|
+
let text2 = `## ${g.title} [${g.severity}]
|
|
1033
1043
|
|
|
1034
1044
|
${g.description}`;
|
|
1035
|
-
if (g.pattern)
|
|
1045
|
+
if (g.pattern) text2 += `
|
|
1036
1046
|
|
|
1037
1047
|
**Bad:** \`\`\`
|
|
1038
1048
|
${g.pattern}
|
|
1039
1049
|
\`\`\``;
|
|
1040
|
-
if (g.fix)
|
|
1050
|
+
if (g.fix) text2 += `
|
|
1041
1051
|
|
|
1042
1052
|
**Good:** \`\`\`
|
|
1043
1053
|
${g.fix}
|
|
1044
1054
|
\`\`\``;
|
|
1045
|
-
return
|
|
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
|
|
1077
|
+
let text2 = "";
|
|
1068
1078
|
for (const [cat, catRules] of byCategory) {
|
|
1069
|
-
|
|
1079
|
+
text2 += `### ${cat.charAt(0).toUpperCase() + cat.slice(1)}
|
|
1070
1080
|
`;
|
|
1071
1081
|
for (const r of catRules) {
|
|
1072
|
-
|
|
1082
|
+
text2 += `- ${r.text}
|
|
1073
1083
|
`;
|
|
1074
1084
|
}
|
|
1075
|
-
|
|
1085
|
+
text2 += "\n";
|
|
1076
1086
|
}
|
|
1077
1087
|
return `## Project Rules (${rules.length})
|
|
1078
1088
|
|
|
1079
|
-
${
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
|
|
1696
|
+
text2 += `## ${label}
|
|
1687
1697
|
|
|
1688
1698
|
`;
|
|
1689
1699
|
for (const rule of catRules) {
|
|
1690
|
-
|
|
1700
|
+
text2 += `- ${rule.text}
|
|
1691
1701
|
`;
|
|
1692
1702
|
}
|
|
1693
|
-
|
|
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
|
|
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
|
-
${
|
|
1791
|
+
${text2}`
|
|
1782
1792
|
}
|
|
1783
1793
|
]
|
|
1784
1794
|
};
|
|
@@ -1825,9 +1835,345 @@ 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
|
+
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")`),
|
|
2060
|
+
baseVersion: z13.number().int().positive().optional().describe(
|
|
2061
|
+
"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"
|
|
2062
|
+
)
|
|
2063
|
+
},
|
|
2064
|
+
annotations: { readOnlyHint: false }
|
|
2065
|
+
},
|
|
2066
|
+
async (args) => {
|
|
2067
|
+
let files;
|
|
2068
|
+
let name;
|
|
2069
|
+
let skipped = [];
|
|
2070
|
+
if (localFiles && args.directory) {
|
|
2071
|
+
({ name, files, skipped } = await readSkillDirectory(expandHome(args.directory)));
|
|
2072
|
+
} else if (args.files?.length) {
|
|
2073
|
+
files = args.files;
|
|
2074
|
+
const entry = files.find((f) => f.path === ENTRY_FILE);
|
|
2075
|
+
name = entry ? frontmatterName(entry.content) : void 0;
|
|
2076
|
+
} else {
|
|
2077
|
+
throw new Error(localFiles ? "Pass `directory` (or `files`)." : "Pass `files`.");
|
|
2078
|
+
}
|
|
2079
|
+
name = args.name ?? name;
|
|
2080
|
+
if (!name) throw new Error(`${ENTRY_FILE} has no frontmatter name; pass \`name\`.`);
|
|
2081
|
+
const saved = await apiClient2.post(
|
|
2082
|
+
`${SKILLS}/${encodeURIComponent(name)}/versions`,
|
|
2083
|
+
{
|
|
2084
|
+
files,
|
|
2085
|
+
...args.spaceId ? { spaceId: args.spaceId } : {},
|
|
2086
|
+
...args.note ? { note: args.note } : {},
|
|
2087
|
+
...args.baseVersion ? { baseVersion: args.baseVersion } : {}
|
|
2088
|
+
}
|
|
2089
|
+
);
|
|
2090
|
+
apiClient2.invalidateCache(SKILLS);
|
|
2091
|
+
return text(
|
|
2092
|
+
`Published **${saved.name}** version ${saved.version} (${saved.fileCount} file(s), ${saved.totalBytes} bytes).
|
|
2093
|
+
Install it anywhere with: npx -y @systemdox/mcp-server install-skill ${saved.name}` + (skipped.length ? `
|
|
2094
|
+
Left out (hidden files are never part of a skill): ${skipped.join(", ")}` : "")
|
|
2095
|
+
);
|
|
2096
|
+
}
|
|
2097
|
+
);
|
|
2098
|
+
server2.registerTool(
|
|
2099
|
+
"list_skills",
|
|
2100
|
+
{
|
|
2101
|
+
title: "List Skills",
|
|
2102
|
+
description: "List the Claude Code skills published to SystemDox that you can install: name, description, latest version. Use install_skill to install one.",
|
|
2103
|
+
inputSchema: {},
|
|
2104
|
+
annotations: { readOnlyHint: true }
|
|
2105
|
+
},
|
|
2106
|
+
async () => {
|
|
2107
|
+
const { skills } = await apiClient2.get(SKILLS);
|
|
2108
|
+
if (!skills.length) {
|
|
2109
|
+
return text(
|
|
2110
|
+
`No skills published yet. Publish one with publish_skill; they appear at ${configLink("/docs/skills", await workspaceBase(apiClient2))}`
|
|
2111
|
+
);
|
|
2112
|
+
}
|
|
2113
|
+
return text(
|
|
2114
|
+
`${skills.length} skill(s):
|
|
2115
|
+
|
|
2116
|
+
` + skills.map((s) => `- **${s.name}** (version ${s.latestVersion}, updated ${s.updatedAt.slice(0, 10)}): ${s.description}`).join("\n")
|
|
2117
|
+
);
|
|
2118
|
+
}
|
|
2119
|
+
);
|
|
2120
|
+
server2.registerTool(
|
|
2121
|
+
"install_skill",
|
|
2122
|
+
{
|
|
2123
|
+
title: "Install Skill",
|
|
2124
|
+
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.",
|
|
2125
|
+
inputSchema: {
|
|
2126
|
+
name: z13.string().describe("The skill's name"),
|
|
2127
|
+
version: z13.number().int().positive().optional().describe("A version number (default: the latest)"),
|
|
2128
|
+
target: z13.string().optional().describe("Folder to install into, e.g. a repo's .claude/skills/<name>"),
|
|
2129
|
+
...localFiles ? { overwrite: z13.boolean().optional().describe("Replace files edited since they were installed") } : {}
|
|
2130
|
+
},
|
|
2131
|
+
annotations: { readOnlyHint: !localFiles }
|
|
2132
|
+
},
|
|
2133
|
+
async (args) => {
|
|
2134
|
+
const version = await apiClient2.getFresh(
|
|
2135
|
+
`${SKILLS}/${encodeURIComponent(args.name)}/versions/${args.version ?? "latest"}`
|
|
2136
|
+
);
|
|
2137
|
+
if (localFiles) {
|
|
2138
|
+
const dest2 = args.target ? expandHome(args.target) : defaultSkillDir(version.name);
|
|
2139
|
+
if (basename(resolve2(dest2)) !== version.name) {
|
|
2140
|
+
throw new Error(`target must be a folder named ${version.name} (e.g. .claude/skills/${version.name}); got ${dest2}`);
|
|
2141
|
+
}
|
|
2142
|
+
const result = await installSkill(version, dest2, { overwrite: args.overwrite });
|
|
2143
|
+
return text(describeInstall(version.name, result));
|
|
2144
|
+
}
|
|
2145
|
+
const dest = args.target ?? `~/.claude/skills/${version.name}`;
|
|
2146
|
+
const manifest = {
|
|
2147
|
+
name: version.name,
|
|
2148
|
+
version: version.version,
|
|
2149
|
+
installedAt: "<now, ISO 8601>",
|
|
2150
|
+
files: Object.fromEntries(version.files.map((f) => [f.path, f.sha256]))
|
|
2151
|
+
};
|
|
2152
|
+
const body = version.files.map((f) => {
|
|
2153
|
+
const fence = fenceFor(f.content);
|
|
2154
|
+
return `### ${f.path}
|
|
2155
|
+
|
|
2156
|
+
${fence}
|
|
2157
|
+
${f.content}${f.content.endsWith("\n") ? "" : "\n"}${fence}`;
|
|
2158
|
+
}).join("\n\n");
|
|
2159
|
+
return text(
|
|
2160
|
+
`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.
|
|
2161
|
+
|
|
2162
|
+
### ${MANIFEST_FILE}
|
|
2163
|
+
|
|
2164
|
+
\`\`\`json
|
|
2165
|
+
${JSON.stringify(manifest, null, 2)}
|
|
2166
|
+
\`\`\`
|
|
2167
|
+
|
|
2168
|
+
${body}`
|
|
2169
|
+
);
|
|
2170
|
+
}
|
|
2171
|
+
);
|
|
2172
|
+
}
|
|
2173
|
+
|
|
1828
2174
|
// src/server.ts
|
|
1829
|
-
var SERVER_VERSION = true ? "0.1.
|
|
1830
|
-
function createServer(apiClient2) {
|
|
2175
|
+
var SERVER_VERSION = true ? "0.1.44" : "0.0.0-dev";
|
|
2176
|
+
function createServer(apiClient2, { localFiles = false } = {}) {
|
|
1831
2177
|
const server2 = new McpServer({
|
|
1832
2178
|
name: "systemdox-mcp-server",
|
|
1833
2179
|
version: SERVER_VERSION
|
|
@@ -1846,13 +2192,14 @@ function createServer(apiClient2) {
|
|
|
1846
2192
|
registerReportDecision(server2, apiClient2);
|
|
1847
2193
|
registerCreateProjectRule(server2, apiClient2);
|
|
1848
2194
|
registerCreatePromptTemplate(server2, apiClient2);
|
|
2195
|
+
registerSkillTools(server2, apiClient2, { localFiles });
|
|
1849
2196
|
return server2;
|
|
1850
2197
|
}
|
|
1851
2198
|
|
|
1852
2199
|
// src/index.ts
|
|
1853
2200
|
var config = loadConfig();
|
|
1854
2201
|
var apiClient = new SystemDoxApiClient(config);
|
|
1855
|
-
var server = createServer(apiClient);
|
|
2202
|
+
var server = createServer(apiClient, { localFiles: true });
|
|
1856
2203
|
async function main() {
|
|
1857
2204
|
await apiClient.validate();
|
|
1858
2205
|
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 {};
|