@systemdox/mcp-server 0.1.45 → 0.1.46
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 +46 -0
- package/dist/cli.js +793 -110
- package/dist/http.js +356 -69
- package/dist/index.js +356 -69
- package/dist/install-skill.js +117 -12
- package/dist/lib/api-client.d.ts +81 -0
- package/dist/lib/api-client.js +119 -13
- package/dist/lib/person-values.d.ts +78 -0
- package/dist/lib/person-values.js +99 -0
- package/dist/lib/skills.d.ts +20 -0
- package/dist/lib/skills.js +18 -13
- package/dist/lib/team-setup-files.d.ts +88 -0
- package/dist/lib/team-setup-files.js +191 -0
- package/dist/lib/team-setup-types.d.ts +153 -0
- package/dist/plugin-path.d.ts +55 -0
- package/dist/plugin-path.js +544 -0
- package/dist/tools/skills.js +15 -12
- package/dist/tools/team-setup.d.ts +33 -0
- package/dist/tools/team-setup.js +242 -0
- package/package.json +1 -1
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/plugin-path.ts
|
|
4
|
+
import { createHash, randomBytes } from "crypto";
|
|
5
|
+
import { existsSync } from "fs";
|
|
6
|
+
import { mkdir as mkdir2, rename as rename2, writeFile as writeFile2 } from "fs/promises";
|
|
7
|
+
import { dirname as dirname2, join as join2, resolve, sep } from "path";
|
|
8
|
+
import { inflateRawSync } from "zlib";
|
|
9
|
+
|
|
10
|
+
// src/lib/cache.ts
|
|
11
|
+
var ResponseCache = class {
|
|
12
|
+
store = /* @__PURE__ */ new Map();
|
|
13
|
+
ttlMs;
|
|
14
|
+
constructor(ttlMs) {
|
|
15
|
+
this.ttlMs = ttlMs;
|
|
16
|
+
}
|
|
17
|
+
get(key) {
|
|
18
|
+
const entry = this.store.get(key);
|
|
19
|
+
if (!entry) return void 0;
|
|
20
|
+
if (Date.now() > entry.expiresAt) {
|
|
21
|
+
this.store.delete(key);
|
|
22
|
+
return void 0;
|
|
23
|
+
}
|
|
24
|
+
return entry.data;
|
|
25
|
+
}
|
|
26
|
+
set(key, data) {
|
|
27
|
+
this.store.set(key, {
|
|
28
|
+
data,
|
|
29
|
+
expiresAt: Date.now() + this.ttlMs
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
invalidate(key) {
|
|
33
|
+
this.store.delete(key);
|
|
34
|
+
}
|
|
35
|
+
clear() {
|
|
36
|
+
this.store.clear();
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// src/lib/api-client.ts
|
|
41
|
+
var SystemDoxApiError = class extends Error {
|
|
42
|
+
constructor(status, text) {
|
|
43
|
+
super(`SystemDox API error ${status}: ${text}`);
|
|
44
|
+
this.status = status;
|
|
45
|
+
this.text = text;
|
|
46
|
+
let body;
|
|
47
|
+
try {
|
|
48
|
+
body = JSON.parse(text);
|
|
49
|
+
} catch {
|
|
50
|
+
body = void 0;
|
|
51
|
+
}
|
|
52
|
+
this.body = body;
|
|
53
|
+
const code = body?.code;
|
|
54
|
+
this.code = typeof code === "string" ? code : /\b(SYD-[A-Z]\d{3})\b/.exec(text)?.[1];
|
|
55
|
+
}
|
|
56
|
+
status;
|
|
57
|
+
text;
|
|
58
|
+
code;
|
|
59
|
+
body;
|
|
60
|
+
};
|
|
61
|
+
var TEAM_SETUP = "/v1/team-setup";
|
|
62
|
+
var seg = (s) => encodeURIComponent(String(s));
|
|
63
|
+
var SystemDoxApiClient = class {
|
|
64
|
+
config;
|
|
65
|
+
cache;
|
|
66
|
+
constructor(config) {
|
|
67
|
+
this.config = config;
|
|
68
|
+
this.cache = new ResponseCache(config.cacheTtlMs);
|
|
69
|
+
}
|
|
70
|
+
async validate() {
|
|
71
|
+
const res = await this.fetchRaw("/v1/health");
|
|
72
|
+
if (!res.ok) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`SystemDox API authentication failed (${res.status}). Check your SYSTEMDOX_API_KEY.`
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
async get(path, params) {
|
|
79
|
+
const url = this.buildUrl(path, params);
|
|
80
|
+
const cacheKey = url.toString();
|
|
81
|
+
const cached = this.cache.get(cacheKey);
|
|
82
|
+
if (cached !== void 0) return cached;
|
|
83
|
+
const res = await this.fetchRaw(path, params);
|
|
84
|
+
if (!res.ok) throw new SystemDoxApiError(res.status, await res.text());
|
|
85
|
+
const data = await res.json();
|
|
86
|
+
this.cache.set(cacheKey, data);
|
|
87
|
+
return data;
|
|
88
|
+
}
|
|
89
|
+
/** A read that skips the cache: an install must take the version that is
|
|
90
|
+
* latest now, not the one cached before someone published another. */
|
|
91
|
+
async getFresh(path) {
|
|
92
|
+
const res = await this.fetchRaw(path);
|
|
93
|
+
if (!res.ok) throw new SystemDoxApiError(res.status, await res.text());
|
|
94
|
+
return await res.json();
|
|
95
|
+
}
|
|
96
|
+
async post(path, body) {
|
|
97
|
+
return this.mutate("POST", path, body);
|
|
98
|
+
}
|
|
99
|
+
async put(path, body) {
|
|
100
|
+
return this.mutate("PUT", path, body);
|
|
101
|
+
}
|
|
102
|
+
async mutate(method, path, body) {
|
|
103
|
+
const url = this.buildUrl(path);
|
|
104
|
+
const res = await fetch(url, {
|
|
105
|
+
method,
|
|
106
|
+
headers: {
|
|
107
|
+
// The API Gateway authorizer only reads the Authorization header;
|
|
108
|
+
// X-API-Key is kept for backwards compatibility.
|
|
109
|
+
Authorization: `Bearer ${this.config.apiKey}`,
|
|
110
|
+
"X-API-Key": this.config.apiKey,
|
|
111
|
+
"Content-Type": "application/json"
|
|
112
|
+
},
|
|
113
|
+
body: JSON.stringify(body)
|
|
114
|
+
});
|
|
115
|
+
if (!res.ok) throw new SystemDoxApiError(res.status, await res.text());
|
|
116
|
+
return await res.json();
|
|
117
|
+
}
|
|
118
|
+
// ── Team setup (ADR-217, specs/changes/team-setup/openapi.yaml) ──────────
|
|
119
|
+
// One method per operationId. Every read skips the cache: setup, sync and
|
|
120
|
+
// plugin-path act on what the workspace holds now.
|
|
121
|
+
/** getOwnTeamSetup: the key's own workspace, named in `workspace`. */
|
|
122
|
+
getOwnTeamSetup() {
|
|
123
|
+
return this.getFresh(TEAM_SETUP);
|
|
124
|
+
}
|
|
125
|
+
getTeamSetup(workspace) {
|
|
126
|
+
return this.getFresh(`${TEAM_SETUP}/${seg(workspace)}`);
|
|
127
|
+
}
|
|
128
|
+
getMarketplace(workspace) {
|
|
129
|
+
return this.getFresh(`${TEAM_SETUP}/${seg(workspace)}/marketplace.json`);
|
|
130
|
+
}
|
|
131
|
+
getPart(workspace, part) {
|
|
132
|
+
return this.getFresh(`${TEAM_SETUP}/${seg(workspace)}/parts/${seg(part)}`);
|
|
133
|
+
}
|
|
134
|
+
publishPartVersion(workspace, part, files) {
|
|
135
|
+
return this.post(`${TEAM_SETUP}/${seg(workspace)}/parts/${seg(part)}/versions`, { files });
|
|
136
|
+
}
|
|
137
|
+
getPartVersion(workspace, part, version) {
|
|
138
|
+
return this.getFresh(
|
|
139
|
+
`${TEAM_SETUP}/${seg(workspace)}/parts/${seg(part)}/versions/${seg(version)}`
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
getSetupSkills(workspace) {
|
|
143
|
+
return this.getFresh(`${TEAM_SETUP}/${seg(workspace)}/skills`);
|
|
144
|
+
}
|
|
145
|
+
setSetupSkills(workspace, skills) {
|
|
146
|
+
return this.put(`${TEAM_SETUP}/${seg(workspace)}/skills`, { skills });
|
|
147
|
+
}
|
|
148
|
+
getPlugin(workspace) {
|
|
149
|
+
return this.getFresh(`${TEAM_SETUP}/${seg(workspace)}/plugin`);
|
|
150
|
+
}
|
|
151
|
+
/** getPluginZip: the zip's bytes as served, and its X-Content-SHA256
|
|
152
|
+
* header. The caller checks the BYTES against the hash it was given. */
|
|
153
|
+
async getPluginZip(workspace, version) {
|
|
154
|
+
const res = await fetch(
|
|
155
|
+
this.buildUrl(`${TEAM_SETUP}/${seg(workspace)}/plugin/versions/${seg(version)}/zip`),
|
|
156
|
+
{
|
|
157
|
+
headers: {
|
|
158
|
+
Authorization: `Bearer ${this.config.apiKey}`,
|
|
159
|
+
"X-API-Key": this.config.apiKey,
|
|
160
|
+
Accept: "application/zip"
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
);
|
|
164
|
+
if (!res.ok) throw new SystemDoxApiError(res.status, await res.text());
|
|
165
|
+
return { bytes: Buffer.from(await res.arrayBuffer()), sha256: res.headers.get("x-content-sha256") };
|
|
166
|
+
}
|
|
167
|
+
listMemoryProjects(workspace) {
|
|
168
|
+
return this.getFresh(`${TEAM_SETUP}/${seg(workspace)}/memory`);
|
|
169
|
+
}
|
|
170
|
+
getMemory(workspace, project) {
|
|
171
|
+
return this.getFresh(`${TEAM_SETUP}/${seg(workspace)}/memory/${seg(project)}`);
|
|
172
|
+
}
|
|
173
|
+
/** pushMemoryVersion: a stale base throws SystemDoxApiError with status 409,
|
|
174
|
+
* code SYD-K010 and `body.currentVersion`. */
|
|
175
|
+
pushMemoryVersion(workspace, project, body) {
|
|
176
|
+
return this.post(`${TEAM_SETUP}/${seg(workspace)}/memory/${seg(project)}/versions`, body);
|
|
177
|
+
}
|
|
178
|
+
getMemoryVersion(workspace, project, version) {
|
|
179
|
+
return this.getFresh(
|
|
180
|
+
`${TEAM_SETUP}/${seg(workspace)}/memory/${seg(project)}/versions/${seg(version)}`
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
findMissingBlobs(workspace, project, hashes) {
|
|
184
|
+
return this.post(
|
|
185
|
+
`${TEAM_SETUP}/${seg(workspace)}/memory/${seg(project)}/blobs/missing`,
|
|
186
|
+
{ hashes }
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
uploadBlobs(workspace, project, blobs) {
|
|
190
|
+
return this.post(`${TEAM_SETUP}/${seg(workspace)}/memory/${seg(project)}/blobs`, { blobs });
|
|
191
|
+
}
|
|
192
|
+
readBlobs(workspace, project, hashes) {
|
|
193
|
+
return this.post(`${TEAM_SETUP}/${seg(workspace)}/memory/${seg(project)}/blobs/read`, { hashes });
|
|
194
|
+
}
|
|
195
|
+
listValueAnswers(workspace) {
|
|
196
|
+
return this.getFresh(`${TEAM_SETUP}/${seg(workspace)}/values`);
|
|
197
|
+
}
|
|
198
|
+
lookupValues(workspace, values) {
|
|
199
|
+
return this.post(`${TEAM_SETUP}/${seg(workspace)}/values`, { values });
|
|
200
|
+
}
|
|
201
|
+
answerValues(workspace, answers) {
|
|
202
|
+
return this.put(`${TEAM_SETUP}/${seg(workspace)}/values`, { answers });
|
|
203
|
+
}
|
|
204
|
+
fileMemoryPeople(workspace, people) {
|
|
205
|
+
return this.post(`${TEAM_SETUP}/${seg(workspace)}/people`, { people });
|
|
206
|
+
}
|
|
207
|
+
resolvePerson(workspace, personId) {
|
|
208
|
+
return this.getFresh(`${TEAM_SETUP}/${seg(workspace)}/people/${seg(personId)}`);
|
|
209
|
+
}
|
|
210
|
+
invalidateCache(path) {
|
|
211
|
+
if (path) {
|
|
212
|
+
this.cache.invalidate(this.buildUrl(path).toString());
|
|
213
|
+
} else {
|
|
214
|
+
this.cache.clear();
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
buildUrl(path, params) {
|
|
218
|
+
const base = this.config.apiUrl.replace(/\/+$/, "");
|
|
219
|
+
const url = new URL(`${base}${path.startsWith("/") ? "" : "/"}${path}`);
|
|
220
|
+
if (params) {
|
|
221
|
+
for (const [key, value] of Object.entries(params)) {
|
|
222
|
+
url.searchParams.set(key, value);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return url;
|
|
226
|
+
}
|
|
227
|
+
async fetchRaw(path, params) {
|
|
228
|
+
const url = this.buildUrl(path, params);
|
|
229
|
+
return fetch(url, {
|
|
230
|
+
headers: {
|
|
231
|
+
Authorization: `Bearer ${this.config.apiKey}`,
|
|
232
|
+
"X-API-Key": this.config.apiKey,
|
|
233
|
+
Accept: "application/json"
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
// src/lib/team-setup-files.ts
|
|
240
|
+
import { execFile } from "child_process";
|
|
241
|
+
import { chmod, mkdir, readFile, rename, rm, writeFile } from "fs/promises";
|
|
242
|
+
import { homedir, userInfo } from "os";
|
|
243
|
+
import { dirname, join } from "path";
|
|
244
|
+
import { promisify } from "util";
|
|
245
|
+
|
|
246
|
+
// src/lib/config.ts
|
|
247
|
+
var API_URL_DEFAULT = "https://api.puglieseweb.com/systemdox";
|
|
248
|
+
function requireHttpsApiUrl(apiUrl) {
|
|
249
|
+
let parsed;
|
|
250
|
+
try {
|
|
251
|
+
parsed = new URL(apiUrl);
|
|
252
|
+
} catch {
|
|
253
|
+
throw new Error(
|
|
254
|
+
`SYSTEMDOX_API_URL is not a valid URL: ${apiUrl}`
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
if (parsed.protocol !== "https:") {
|
|
258
|
+
throw new Error(
|
|
259
|
+
`SYSTEMDOX_API_URL must use https:// \u2014 refusing to send the API key over ${parsed.protocol}// in cleartext. Got: ${apiUrl}`
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
return apiUrl;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// src/lib/team-setup-files.ts
|
|
266
|
+
var SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
267
|
+
function isWorkspace(value) {
|
|
268
|
+
return typeof value === "string" && value.length <= 64 && SLUG_RE.test(value);
|
|
269
|
+
}
|
|
270
|
+
function homeDir() {
|
|
271
|
+
return homedir();
|
|
272
|
+
}
|
|
273
|
+
function systemdoxDir() {
|
|
274
|
+
return join(homeDir(), ".claude", "systemdox");
|
|
275
|
+
}
|
|
276
|
+
function credentialsPath() {
|
|
277
|
+
return join(systemdoxDir(), "credentials.json");
|
|
278
|
+
}
|
|
279
|
+
function workspaceDir(workspace) {
|
|
280
|
+
if (!isWorkspace(workspace)) throw new Error(`${JSON.stringify(workspace)} is not a workspace name`);
|
|
281
|
+
return join(systemdoxDir(), workspace);
|
|
282
|
+
}
|
|
283
|
+
function statePath(workspace) {
|
|
284
|
+
return join(workspaceDir(workspace), "state.json");
|
|
285
|
+
}
|
|
286
|
+
function pluginRoot(workspace) {
|
|
287
|
+
return join(workspaceDir(workspace), "plugin");
|
|
288
|
+
}
|
|
289
|
+
function pluginDir(workspace, version) {
|
|
290
|
+
return join(pluginRoot(workspace), String(version));
|
|
291
|
+
}
|
|
292
|
+
async function readJson(path, empty) {
|
|
293
|
+
let text;
|
|
294
|
+
try {
|
|
295
|
+
text = await readFile(path, "utf8");
|
|
296
|
+
} catch (e) {
|
|
297
|
+
if (e.code === "ENOENT") return empty;
|
|
298
|
+
throw e;
|
|
299
|
+
}
|
|
300
|
+
try {
|
|
301
|
+
return JSON.parse(text);
|
|
302
|
+
} catch {
|
|
303
|
+
throw new Error(`${path} is not valid JSON: fix or remove it, then run this again`);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
async function writeJsonAtomically(path, value, mode) {
|
|
307
|
+
await mkdir(dirname(path), { recursive: true });
|
|
308
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
309
|
+
await writeFile(tmp, JSON.stringify(value, null, 2) + "\n", mode === void 0 ? "utf8" : { encoding: "utf8", mode });
|
|
310
|
+
await rename(tmp, path);
|
|
311
|
+
}
|
|
312
|
+
async function readCredentials() {
|
|
313
|
+
return readJson(credentialsPath(), {});
|
|
314
|
+
}
|
|
315
|
+
var run = promisify(execFile);
|
|
316
|
+
async function apiKeyFor(workspace, env = process.env) {
|
|
317
|
+
if (env.SYSTEMDOX_API_KEY) return env.SYSTEMDOX_API_KEY;
|
|
318
|
+
const entry = (await readCredentials())[workspace];
|
|
319
|
+
return typeof entry?.apiKey === "string" && entry.apiKey ? entry.apiKey : void 0;
|
|
320
|
+
}
|
|
321
|
+
function apiUrlFrom(env = process.env) {
|
|
322
|
+
return requireHttpsApiUrl(env.SYSTEMDOX_API_URL || API_URL_DEFAULT);
|
|
323
|
+
}
|
|
324
|
+
async function readState(workspace) {
|
|
325
|
+
return readJson(statePath(workspace), {});
|
|
326
|
+
}
|
|
327
|
+
async function writeState(workspace, state) {
|
|
328
|
+
await writeJsonAtomically(statePath(workspace), state);
|
|
329
|
+
}
|
|
330
|
+
async function updateState(workspace, change) {
|
|
331
|
+
const state = await readState(workspace);
|
|
332
|
+
change(state);
|
|
333
|
+
await writeState(workspace, state);
|
|
334
|
+
return state;
|
|
335
|
+
}
|
|
336
|
+
async function removeFolder(path) {
|
|
337
|
+
await rm(path, { recursive: true, force: true });
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// src/plugin-path.ts
|
|
341
|
+
var PLUGIN_PATH_USAGE = "Usage: systemdox-mcp-server plugin-path <workspace> --version <n> --sha256 <hash>";
|
|
342
|
+
var MAX_UNPACKED_BYTES = 20 * 1024 * 1024;
|
|
343
|
+
var SHA256_RE = /^[0-9a-f]{64}$/;
|
|
344
|
+
function parsePluginPathArgs(argv) {
|
|
345
|
+
const args = {};
|
|
346
|
+
for (let i = 0; i < argv.length; i++) {
|
|
347
|
+
const a = argv[i];
|
|
348
|
+
if (a === "--version" || a === "--sha256") {
|
|
349
|
+
const value = argv[++i];
|
|
350
|
+
if (!value) throw new Error(`${a} needs a value
|
|
351
|
+
${PLUGIN_PATH_USAGE}`);
|
|
352
|
+
if (a === "--sha256") {
|
|
353
|
+
if (!SHA256_RE.test(value)) throw new Error(`--sha256 must be 64 lowercase hex characters, got ${value}`);
|
|
354
|
+
args.sha256 = value;
|
|
355
|
+
} else if (/^[1-9]\d{0,8}$/.test(value)) args.version = Number(value);
|
|
356
|
+
else throw new Error(`--version must be a whole number, got ${value}`);
|
|
357
|
+
} else if (a.startsWith("-")) throw new Error(`Unknown option ${a}
|
|
358
|
+
${PLUGIN_PATH_USAGE}`);
|
|
359
|
+
else if (!args.workspace) args.workspace = a;
|
|
360
|
+
else throw new Error(`Unexpected argument ${a}
|
|
361
|
+
${PLUGIN_PATH_USAGE}`);
|
|
362
|
+
}
|
|
363
|
+
if (!args.workspace || !args.version || !args.sha256) throw new Error(PLUGIN_PATH_USAGE);
|
|
364
|
+
if (!isWorkspace(args.workspace)) throw new Error(`${args.workspace} is not a workspace name`);
|
|
365
|
+
return args;
|
|
366
|
+
}
|
|
367
|
+
var PluginRefused = class extends Error {
|
|
368
|
+
};
|
|
369
|
+
var EOCD = 101010256;
|
|
370
|
+
var CENTRAL = 33639248;
|
|
371
|
+
var LOCAL = 67324752;
|
|
372
|
+
var S_IFMT = 61440;
|
|
373
|
+
var S_IFLNK = 40960;
|
|
374
|
+
var S_IFDIR = 16384;
|
|
375
|
+
var S_IFREG = 32768;
|
|
376
|
+
function refuse(name, why) {
|
|
377
|
+
throw new PluginRefused(`refused the plugin entry ${JSON.stringify(name)}: it ${why}. Nothing was unpacked.`);
|
|
378
|
+
}
|
|
379
|
+
function readZipEntries(zip) {
|
|
380
|
+
let eocd = -1;
|
|
381
|
+
for (let i = zip.length - 22; i >= Math.max(0, zip.length - 22 - 65535); i--) {
|
|
382
|
+
if (zip.readUInt32LE(i) === EOCD) {
|
|
383
|
+
eocd = i;
|
|
384
|
+
break;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
if (eocd < 0) throw new PluginRefused("the download is not a zip. Nothing was unpacked.");
|
|
388
|
+
const count = zip.readUInt16LE(eocd + 10);
|
|
389
|
+
let at = zip.readUInt32LE(eocd + 16);
|
|
390
|
+
if (count === 65535 || at === 4294967295) throw new PluginRefused("the zip is a ZIP64 archive, which a plugin never is. Nothing was unpacked.");
|
|
391
|
+
const entries = [];
|
|
392
|
+
let total = 0;
|
|
393
|
+
for (let n = 0; n < count; n++) {
|
|
394
|
+
if (at + 46 > zip.length || zip.readUInt32LE(at) !== CENTRAL) {
|
|
395
|
+
throw new PluginRefused("the zip's directory is damaged. Nothing was unpacked.");
|
|
396
|
+
}
|
|
397
|
+
const madeBy = zip.readUInt16LE(at + 4);
|
|
398
|
+
const flags = zip.readUInt16LE(at + 8);
|
|
399
|
+
const method = zip.readUInt16LE(at + 10);
|
|
400
|
+
const compressedSize = zip.readUInt32LE(at + 20);
|
|
401
|
+
const size = zip.readUInt32LE(at + 24);
|
|
402
|
+
const nameLength = zip.readUInt16LE(at + 28);
|
|
403
|
+
const extraLength = zip.readUInt16LE(at + 30);
|
|
404
|
+
const commentLength = zip.readUInt16LE(at + 32);
|
|
405
|
+
const external = zip.readUInt32LE(at + 38);
|
|
406
|
+
const offset = zip.readUInt32LE(at + 42);
|
|
407
|
+
const name = zip.subarray(at + 46, at + 46 + nameLength).toString(flags & 2048 ? "utf8" : "latin1");
|
|
408
|
+
at += 46 + nameLength + extraLength + commentLength;
|
|
409
|
+
const unixMode = madeBy >> 8 === 3 ? external >>> 16 : 0;
|
|
410
|
+
const segments = name.split(/[\\/]/);
|
|
411
|
+
if (!name || /^[\\/]/.test(name) || /^[A-Za-z]:/.test(name)) refuse(name, "is absolute");
|
|
412
|
+
if (segments.includes("..")) refuse(name, "climbs out of the plugin folder (..)");
|
|
413
|
+
if (name.includes(":") || name.includes("\0")) refuse(name, "is not a plain relative path");
|
|
414
|
+
if ((unixMode & S_IFMT) === S_IFLNK) refuse(name, "is a link");
|
|
415
|
+
const directory = name.endsWith("/") || (unixMode & S_IFMT) === S_IFDIR;
|
|
416
|
+
if (!directory && unixMode && (unixMode & S_IFMT) !== S_IFREG) refuse(name, "is not a regular file");
|
|
417
|
+
if (flags & 1) refuse(name, "is encrypted");
|
|
418
|
+
if (!directory && method !== 0 && method !== 8) refuse(name, `uses compression method ${method}`);
|
|
419
|
+
total += size;
|
|
420
|
+
if (total > MAX_UNPACKED_BYTES) refuse(name, `takes the unpacked size past ${MAX_UNPACKED_BYTES} bytes`);
|
|
421
|
+
entries.push({ name, directory, mode: unixMode ? unixMode & 511 : void 0, method, compressedSize, size, offset });
|
|
422
|
+
}
|
|
423
|
+
return entries;
|
|
424
|
+
}
|
|
425
|
+
function entryData(zip, entry) {
|
|
426
|
+
const at = entry.offset;
|
|
427
|
+
if (at + 30 > zip.length || zip.readUInt32LE(at) !== LOCAL) refuse(entry.name, "has a damaged header");
|
|
428
|
+
const start = at + 30 + zip.readUInt16LE(at + 26) + zip.readUInt16LE(at + 28);
|
|
429
|
+
const raw = zip.subarray(start, start + entry.compressedSize);
|
|
430
|
+
let data;
|
|
431
|
+
try {
|
|
432
|
+
data = entry.method === 0 ? raw : inflateRawSync(raw, { maxOutputLength: Math.max(1, entry.size) + 1 });
|
|
433
|
+
} catch (e) {
|
|
434
|
+
refuse(entry.name, `cannot be unpacked (${e.message})`);
|
|
435
|
+
}
|
|
436
|
+
if (data.length !== entry.size) refuse(entry.name, "unpacks to a size other than the one it declares");
|
|
437
|
+
return data;
|
|
438
|
+
}
|
|
439
|
+
async function unpackZip(zip, dest) {
|
|
440
|
+
const entries = readZipEntries(zip);
|
|
441
|
+
const root = resolve(dest);
|
|
442
|
+
const targets = entries.map((e) => {
|
|
443
|
+
const full = resolve(root, ...e.name.split(/[\\/]/).filter(Boolean));
|
|
444
|
+
if (full !== root && !full.startsWith(root + sep)) refuse(e.name, "climbs out of the plugin folder");
|
|
445
|
+
return full;
|
|
446
|
+
});
|
|
447
|
+
const data = entries.map((e) => e.directory ? void 0 : entryData(zip, e));
|
|
448
|
+
await mkdir2(root, { recursive: true });
|
|
449
|
+
for (const [i, entry] of entries.entries()) {
|
|
450
|
+
if (entry.directory) {
|
|
451
|
+
await mkdir2(targets[i], { recursive: true });
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
await mkdir2(dirname2(targets[i]), { recursive: true });
|
|
455
|
+
await writeFile2(targets[i], data[i], { mode: entry.mode ?? 420 });
|
|
456
|
+
}
|
|
457
|
+
return entries.filter((e) => !e.directory).length;
|
|
458
|
+
}
|
|
459
|
+
async function runPluginPath(argv, io) {
|
|
460
|
+
let args;
|
|
461
|
+
try {
|
|
462
|
+
args = parsePluginPathArgs(argv);
|
|
463
|
+
} catch (e) {
|
|
464
|
+
io.err(e.message);
|
|
465
|
+
return 1;
|
|
466
|
+
}
|
|
467
|
+
const { workspace, version, sha256 } = args;
|
|
468
|
+
const folder = pluginDir(workspace, version);
|
|
469
|
+
try {
|
|
470
|
+
const state = await readState(workspace);
|
|
471
|
+
if (existsSync(folder) && state.plugin?.[String(version)]?.sha256 === sha256) {
|
|
472
|
+
io.out(folder);
|
|
473
|
+
return 0;
|
|
474
|
+
}
|
|
475
|
+
} catch (e) {
|
|
476
|
+
io.err(e.message);
|
|
477
|
+
return 1;
|
|
478
|
+
}
|
|
479
|
+
let client = io.client;
|
|
480
|
+
if (!client) {
|
|
481
|
+
try {
|
|
482
|
+
const apiUrl = apiUrlFrom();
|
|
483
|
+
const apiKey = await apiKeyFor(workspace);
|
|
484
|
+
if (!apiKey) {
|
|
485
|
+
io.err(
|
|
486
|
+
`No SystemDox key for ${workspace}. Set SYSTEMDOX_API_KEY, or run npx -y @systemdox/mcp-server setup, then run this again.`
|
|
487
|
+
);
|
|
488
|
+
return 1;
|
|
489
|
+
}
|
|
490
|
+
client = new SystemDoxApiClient({ apiKey, apiUrl, cacheTtlMs: 0, defaultOrg: void 0 });
|
|
491
|
+
} catch (e) {
|
|
492
|
+
io.err(e.message);
|
|
493
|
+
return 1;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
let zip;
|
|
497
|
+
try {
|
|
498
|
+
({ bytes: zip } = await client.getPluginZip(workspace, version));
|
|
499
|
+
} catch (e) {
|
|
500
|
+
io.err(e.message);
|
|
501
|
+
return 1;
|
|
502
|
+
}
|
|
503
|
+
const got = createHash("sha256").update(zip).digest("hex");
|
|
504
|
+
if (got !== sha256) {
|
|
505
|
+
io.err(
|
|
506
|
+
`The download of ${workspace}'s team plugin version ${version} has SHA-256 ${got}, not the ${sha256} Claude Code named. Nothing was unpacked.`
|
|
507
|
+
);
|
|
508
|
+
return 2;
|
|
509
|
+
}
|
|
510
|
+
const tmp = join2(pluginRoot(workspace), `.${version}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`);
|
|
511
|
+
try {
|
|
512
|
+
await unpackZip(zip, tmp);
|
|
513
|
+
if (existsSync(folder) && (await readState(workspace)).plugin?.[String(version)]?.sha256 === sha256) {
|
|
514
|
+
await removeFolder(tmp);
|
|
515
|
+
} else {
|
|
516
|
+
if (existsSync(folder)) await removeFolder(folder);
|
|
517
|
+
try {
|
|
518
|
+
await rename2(tmp, folder);
|
|
519
|
+
} catch (e) {
|
|
520
|
+
if (!existsSync(folder)) throw e;
|
|
521
|
+
await removeFolder(tmp);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
await updateState(workspace, (state) => {
|
|
525
|
+
state.plugin = { ...state.plugin, [String(version)]: { sha256, installedAt: (/* @__PURE__ */ new Date()).toISOString() } };
|
|
526
|
+
});
|
|
527
|
+
} catch (e) {
|
|
528
|
+
await removeFolder(tmp);
|
|
529
|
+
io.err(e.message);
|
|
530
|
+
return e instanceof PluginRefused ? 2 : 1;
|
|
531
|
+
}
|
|
532
|
+
io.err(`Unpacked ${workspace}'s team plugin version ${version}.`);
|
|
533
|
+
io.out(folder);
|
|
534
|
+
return 0;
|
|
535
|
+
}
|
|
536
|
+
export {
|
|
537
|
+
MAX_UNPACKED_BYTES,
|
|
538
|
+
PLUGIN_PATH_USAGE,
|
|
539
|
+
PluginRefused,
|
|
540
|
+
parsePluginPathArgs,
|
|
541
|
+
readZipEntries,
|
|
542
|
+
runPluginPath,
|
|
543
|
+
unpackZip
|
|
544
|
+
};
|
package/dist/tools/skills.js
CHANGED
|
@@ -77,18 +77,9 @@ async function walk(root, { skipHidden }, dir = root, out = { files: [], links:
|
|
|
77
77
|
out.files.sort();
|
|
78
78
|
return out;
|
|
79
79
|
}
|
|
80
|
-
async function
|
|
81
|
-
const root = resolve(directory);
|
|
82
|
-
if (!existsSync(join(root, ENTRY_FILE))) {
|
|
83
|
-
throw new Error(`${root} has no ${ENTRY_FILE}: a skill folder holds SKILL.md at its root`);
|
|
84
|
-
}
|
|
85
|
-
const walked = await walk(root, { skipHidden: true });
|
|
86
|
-
if (walked.links.length) {
|
|
87
|
-
throw new Error(`${walked.links.join(", ")}: links are not followed; copy the file into the skill folder`);
|
|
88
|
-
}
|
|
89
|
-
const paths = walked.files;
|
|
80
|
+
async function readTextFiles(root, paths, what) {
|
|
90
81
|
if (paths.length > MAX_FILES) {
|
|
91
|
-
throw new Error(`${root} holds ${paths.length} files:
|
|
82
|
+
throw new Error(`${root} holds ${paths.length} files: ${what} holds at most ${MAX_FILES}`);
|
|
92
83
|
}
|
|
93
84
|
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
94
85
|
const files = [];
|
|
@@ -98,7 +89,7 @@ async function readSkillDirectory(directory) {
|
|
|
98
89
|
throw new Error(`${path}: file names may use letters, digits, ".", "_" and "-" only`);
|
|
99
90
|
}
|
|
100
91
|
const raw = await readFile(join(root, ...path.split("/")));
|
|
101
|
-
if (raw.includes(0)) throw new Error(`${path} is binary:
|
|
92
|
+
if (raw.includes(0)) throw new Error(`${path} is binary: ${what} holds text files only`);
|
|
102
93
|
if (raw.length > MAX_FILE_BYTES) {
|
|
103
94
|
throw new Error(`${path} is ${raw.length} bytes: a file holds at most ${MAX_FILE_BYTES}`);
|
|
104
95
|
}
|
|
@@ -114,6 +105,18 @@ async function readSkillDirectory(directory) {
|
|
|
114
105
|
}
|
|
115
106
|
files.push({ path, content });
|
|
116
107
|
}
|
|
108
|
+
return files;
|
|
109
|
+
}
|
|
110
|
+
async function readSkillDirectory(directory) {
|
|
111
|
+
const root = resolve(directory);
|
|
112
|
+
if (!existsSync(join(root, ENTRY_FILE))) {
|
|
113
|
+
throw new Error(`${root} has no ${ENTRY_FILE}: a skill folder holds SKILL.md at its root`);
|
|
114
|
+
}
|
|
115
|
+
const walked = await walk(root, { skipHidden: true });
|
|
116
|
+
if (walked.links.length) {
|
|
117
|
+
throw new Error(`${walked.links.join(", ")}: links are not followed; copy the file into the skill folder`);
|
|
118
|
+
}
|
|
119
|
+
const files = await readTextFiles(root, walked.files, "a skill");
|
|
117
120
|
const skillMd = files.find((f) => f.path === ENTRY_FILE);
|
|
118
121
|
const name = frontmatterName(skillMd.content);
|
|
119
122
|
if (!name) throw new Error(`${ENTRY_FILE} has no frontmatter name`);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import type { SystemDoxApiClient } from "../lib/api-client.js";
|
|
3
|
+
import { type FileIn, type PartName } from "../lib/team-setup-types.js";
|
|
4
|
+
/**
|
|
5
|
+
* The team setup over MCP (ADR-217 with Amendments 1 and 2,
|
|
6
|
+
* specs/changes/team-setup/contract.md):
|
|
7
|
+
*
|
|
8
|
+
* - `publish_setup_part` publishes one part of the team setup (hooks, MCP
|
|
9
|
+
* servers, agents, commands, instructions), with publish_skill's reading
|
|
10
|
+
* rules: the local server reads the folder itself, only the paths the part
|
|
11
|
+
* accepts, and refuses a link, a binary file or a file past ADR-216's limits
|
|
12
|
+
* before anything is sent; the hosted server takes `files`.
|
|
13
|
+
* - `resolve_person` reads back a person a memory names by token, for this
|
|
14
|
+
* session only. It writes nothing and keeps nothing.
|
|
15
|
+
*
|
|
16
|
+
* Both act on the key's own workspace, which `GET /team-setup` names.
|
|
17
|
+
*/
|
|
18
|
+
type Client = Pick<SystemDoxApiClient, "getOwnTeamSetup" | "publishPartVersion" | "resolvePerson">;
|
|
19
|
+
/**
|
|
20
|
+
* Read the files `part` accepts from `directory`, and nothing else. A link on
|
|
21
|
+
* the way to one of them (the file, its folder, anything inside a folder read
|
|
22
|
+
* whole) stops the read before any file is read. Returns the files and what
|
|
23
|
+
* was left out inside the folders read (hidden files, and files that are not
|
|
24
|
+
* `*.md` where only those are accepted).
|
|
25
|
+
*/
|
|
26
|
+
export declare function readPartDirectory(part: PartName, directory: string): Promise<{
|
|
27
|
+
files: FileIn[];
|
|
28
|
+
leftOut: string[];
|
|
29
|
+
}>;
|
|
30
|
+
export declare function registerTeamSetupTools(server: McpServer, apiClient: Client, { localFiles }: {
|
|
31
|
+
localFiles: boolean;
|
|
32
|
+
}): void;
|
|
33
|
+
export {};
|