@systemdox/mcp-server 0.1.45 → 0.1.47
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 +115 -0
- package/dist/cli.js +2319 -219
- package/dist/http.js +1270 -73
- package/dist/index.js +1270 -73
- 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/memory-sync.d.ts +150 -0
- package/dist/lib/memory-sync.js +363 -0
- 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/setup-team.d.ts +80 -0
- package/dist/setup-team.js +1593 -0
- package/dist/sync-memory.d.ts +53 -0
- package/dist/sync-memory.js +1219 -0
- package/dist/tools/skills.js +15 -12
- package/dist/tools/team-setup.d.ts +37 -0
- package/dist/tools/team-setup.js +1152 -0
- package/package.json +1 -1
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/lib/memory-sync.ts
|
|
4
|
+
import { createHash } from "crypto";
|
|
5
|
+
|
|
6
|
+
// src/lib/person-values.ts
|
|
7
|
+
var PHONE = "phone";
|
|
8
|
+
var EMAIL = "email";
|
|
9
|
+
var HOME_PATH = "home-path";
|
|
10
|
+
var NATIONAL_PREFIX = "+44";
|
|
11
|
+
var W = "[\\p{L}\\p{N}_]";
|
|
12
|
+
var D = "\\p{Nd}";
|
|
13
|
+
var WORD_OR = (extra) => `[\\p{L}\\p{N}_${extra}]`;
|
|
14
|
+
var PERSON_ID = "(?:(?:con|ctc)_[0-9A-Za-z]{10,40}|usr_[0-9A-Za-z-]{10,40})";
|
|
15
|
+
var PERSON_TOKEN = `\`${PERSON_ID}\`(?:[ \\t]*\\([^()\\n]*\\))?`;
|
|
16
|
+
var BEFORE = `(?<!${WORD_OR("+.:/\\\\\\-")})(?<!${D}[ -])`;
|
|
17
|
+
var AFTER = `(?!${WORD_OR(":/\\\\")}|\\.${D}|[ -]?${D}|-${W})`;
|
|
18
|
+
var E164 = `${BEFORE}\\+${D}+(?:[ -]${D}+)*${AFTER}`;
|
|
19
|
+
var NATIONAL = `${BEFORE}0${D}*(?:[ -]${D}+)*${AFTER}`;
|
|
20
|
+
var GROUP_SIZES = /* @__PURE__ */ new Set([2, 3, 4, 5, 6]);
|
|
21
|
+
var ATEXT = "[A-Za-z0-9!#$%&'*+/=?^_{|}~\\-]";
|
|
22
|
+
var EMAIL_RE = "(?<![A-Za-z0-9!#$%&*+/=?^_{|}~.@\\-])[A-Za-z0-9_]" + ATEXT + "*(?:\\." + ATEXT + "+)*@(?:[A-Za-z0-9](?:[A-Za-z0-9\\-]{0,61}[A-Za-z0-9])?\\.)+[A-Za-z]{2,63}(?![A-Za-z0-9\\-]|\\.[A-Za-z0-9])";
|
|
23
|
+
var HOME_PREFIX = "(?:[A-Za-z]:\\\\+Users\\\\+|[A-Za-z]:/+Users/+|/Users/|/home/)";
|
|
24
|
+
var HOME = `(?<!${WORD_OR(".~:/\\\\$}\\-")})${HOME_PREFIX}[^\\\\/\\s"'\`<>|:*?]+`;
|
|
25
|
+
var KEY_PREFIX = `(?<!${W})(?:pweb_[a-z]+_key_|sk-ant-|sk_live_|rk_live_|github_pat_|gh[pousr]_|sntrys_|xox[baprs]-)[A-Za-z0-9_\\-]{4,}`;
|
|
26
|
+
var global = (source, flags = "") => new RegExp(source, `gu${flags}`);
|
|
27
|
+
var whole = (source, flags = "") => new RegExp(`^(?:${source})$`, `u${flags}`);
|
|
28
|
+
function lineOf(text, start) {
|
|
29
|
+
let line = 1;
|
|
30
|
+
for (let i = text.indexOf("\n"); i !== -1 && i < start; i = text.indexOf("\n", i + 1)) line++;
|
|
31
|
+
return line;
|
|
32
|
+
}
|
|
33
|
+
function phoneShape(value) {
|
|
34
|
+
const groups = value.replace(/^\++/, "").split(/[ -]/);
|
|
35
|
+
const digits = [...groups.join("")];
|
|
36
|
+
if (value.startsWith("+")) return digits.length >= 8 && digits.length <= 15 && digits[0] !== "0";
|
|
37
|
+
if (digits.length < 10 || digits.length > 11) return false;
|
|
38
|
+
return groups.length === 1 || groups.slice(1).every((g) => GROUP_SIZES.has([...g].length));
|
|
39
|
+
}
|
|
40
|
+
function normalise(kind, value) {
|
|
41
|
+
const v = typeof value === "string" ? value.trim() : "";
|
|
42
|
+
if (kind === PHONE) {
|
|
43
|
+
if (!(whole(E164).test(v) || whole(NATIONAL).test(v)) || !phoneShape(v)) {
|
|
44
|
+
throw new Error("not a phone number");
|
|
45
|
+
}
|
|
46
|
+
const digits = v.replace(/[ -]/g, "");
|
|
47
|
+
return digits.startsWith("+") ? digits : NATIONAL_PREFIX + digits.slice(1);
|
|
48
|
+
}
|
|
49
|
+
if (kind === EMAIL) {
|
|
50
|
+
if (!whole(EMAIL_RE).test(v)) throw new Error("not an email address");
|
|
51
|
+
return v.toLowerCase();
|
|
52
|
+
}
|
|
53
|
+
throw new Error(`not a value kind: ${JSON.stringify(kind)}`);
|
|
54
|
+
}
|
|
55
|
+
function findPersonValues(text) {
|
|
56
|
+
if (typeof text !== "string" || !text) return [];
|
|
57
|
+
const skipped = [...text.matchAll(global(PERSON_TOKEN))].map((m) => [m.index, m.index + m[0].length]);
|
|
58
|
+
const found = [];
|
|
59
|
+
const add = (kind, m, normalised) => {
|
|
60
|
+
const start = m.index;
|
|
61
|
+
const end = start + m[0].length;
|
|
62
|
+
if (skipped.some(([s, e]) => s <= start && end <= e)) return;
|
|
63
|
+
if (found.some((f) => f.start < end && start < f.end)) return;
|
|
64
|
+
found.push({ kind, value: m[0], normalised, line: lineOf(text, start), start, end });
|
|
65
|
+
};
|
|
66
|
+
for (const m of text.matchAll(global(EMAIL_RE))) add(EMAIL, m, m[0].toLowerCase());
|
|
67
|
+
for (const source of [E164, NATIONAL]) {
|
|
68
|
+
for (const m of text.matchAll(global(source))) {
|
|
69
|
+
if (phoneShape(m[0])) add(PHONE, m, normalise(PHONE, m[0]));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
for (const m of text.matchAll(global(HOME, "i"))) add(HOME_PATH, m, m[0]);
|
|
73
|
+
return found.sort((a, b) => a.start - b.start);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// src/lib/memory-sync.ts
|
|
77
|
+
var INDEX_FILE = "MEMORY.md";
|
|
78
|
+
var SHAREABLE_TYPES = ["feedback", "project", "reference"];
|
|
79
|
+
var FILE_PATH_RE = /^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/;
|
|
80
|
+
var CONFLICT_COPY_RE = /\.local-\d{4}-\d{2}-\d{2}(?:-\d+)?\.md$/;
|
|
81
|
+
function sha256(text) {
|
|
82
|
+
return createHash("sha256").update(text, "utf8").digest("hex");
|
|
83
|
+
}
|
|
84
|
+
function isFilePath(path) {
|
|
85
|
+
return path.length <= 512 && FILE_PATH_RE.test(path) && !path.split("/").some((s) => s === "." || s === "..");
|
|
86
|
+
}
|
|
87
|
+
function isConflictCopy(path) {
|
|
88
|
+
return CONFLICT_COPY_RE.test(path.split("/").pop() ?? path);
|
|
89
|
+
}
|
|
90
|
+
var BLOCK_RE = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/;
|
|
91
|
+
var YAML_FALSE = /* @__PURE__ */ new Set(["false", "False", "FALSE", "no", "No", "NO", "off", "Off", "OFF"]);
|
|
92
|
+
function readFrontmatter(text) {
|
|
93
|
+
const m = BLOCK_RE.exec(text);
|
|
94
|
+
if (!m) return void 0;
|
|
95
|
+
const front = { shareFalse: false };
|
|
96
|
+
for (const line of m[1].split(/\r?\n/)) {
|
|
97
|
+
const kv = /^([A-Za-z_][\w-]*)[ \t]*:[ \t]*(.*?)[ \t]*$/.exec(line);
|
|
98
|
+
if (!kv) continue;
|
|
99
|
+
const raw = kv[2].replace(/[ \t]+#.*$/, "");
|
|
100
|
+
const quoted = /^(["'])(.*)\1$/.exec(raw);
|
|
101
|
+
const value = quoted ? quoted[2] : raw;
|
|
102
|
+
if (kv[1] === "type") front.type = value.trim().toLowerCase();
|
|
103
|
+
if (kv[1] === "share") front.shareFalse = quoted ? value.trim().toLowerCase() === "false" : YAML_FALSE.has(value);
|
|
104
|
+
}
|
|
105
|
+
return front;
|
|
106
|
+
}
|
|
107
|
+
function isShareable(path, text) {
|
|
108
|
+
if (isConflictCopy(path) || !path.endsWith(".md")) return false;
|
|
109
|
+
const front = readFrontmatter(text);
|
|
110
|
+
if (front?.shareFalse) return false;
|
|
111
|
+
if (path === INDEX_FILE) return true;
|
|
112
|
+
return !!front && SHAREABLE_TYPES.includes(front.type ?? "");
|
|
113
|
+
}
|
|
114
|
+
function addShareFalse(text) {
|
|
115
|
+
const eol = text.includes("\r\n") ? "\r\n" : "\n";
|
|
116
|
+
const m = BLOCK_RE.exec(text);
|
|
117
|
+
if (!m) return `---${eol}share: false${eol}---${eol}${text}`;
|
|
118
|
+
const lines = m[1].split(/\r?\n/).filter((l) => !/^share[ \t]*:/.test(l));
|
|
119
|
+
lines.push("share: false");
|
|
120
|
+
const closing = m[0].slice(m[0].lastIndexOf("---"));
|
|
121
|
+
return `---${eol}${lines.join(eol)}${eol}${closing}${text.slice(m[0].length)}`;
|
|
122
|
+
}
|
|
123
|
+
function localDate(now) {
|
|
124
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
125
|
+
return `${now.getFullYear()}-${p(now.getMonth() + 1)}-${p(now.getDate())}`;
|
|
126
|
+
}
|
|
127
|
+
function conflictCopyName(path, date, taken) {
|
|
128
|
+
const stem = path.replace(/\.md$/, "");
|
|
129
|
+
for (let n = 1; ; n++) {
|
|
130
|
+
const name = `${stem}.local-${date}${n === 1 ? "" : `-${n}`}.md`;
|
|
131
|
+
if (!taken.has(name.toLowerCase())) return name;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
function decide(path, base, team, local) {
|
|
135
|
+
if (team === local) return "unchanged";
|
|
136
|
+
if (team === base) return local === void 0 ? "delete-team" : "local";
|
|
137
|
+
if (local === base) return team === void 0 ? "delete-local" : "team";
|
|
138
|
+
if (path === INDEX_FILE && team !== void 0 && local !== void 0) return "index";
|
|
139
|
+
if (team === void 0) return "local";
|
|
140
|
+
if (local === void 0) return "team";
|
|
141
|
+
return "conflict";
|
|
142
|
+
}
|
|
143
|
+
function decideAll(input) {
|
|
144
|
+
const localHashes = Object.fromEntries(Object.entries(input.local).map(([p, t]) => [p, sha256(t)]));
|
|
145
|
+
const paths = /* @__PURE__ */ new Set([...Object.keys(input.base), ...Object.keys(input.team), ...Object.keys(localHashes)]);
|
|
146
|
+
const rows = {};
|
|
147
|
+
const needed = /* @__PURE__ */ new Set();
|
|
148
|
+
for (const path of [...paths].sort()) {
|
|
149
|
+
if (input.excluded.has(path)) continue;
|
|
150
|
+
const row = decide(path, input.base[path], input.team[path], localHashes[path]);
|
|
151
|
+
rows[path] = row;
|
|
152
|
+
if (row === "team" || row === "conflict" || row === "index") needed.add(input.team[path]);
|
|
153
|
+
if (row === "index" && input.base[path]) needed.add(input.base[path]);
|
|
154
|
+
}
|
|
155
|
+
return { rows, needed: [...needed] };
|
|
156
|
+
}
|
|
157
|
+
function planMerge(input, rows, texts) {
|
|
158
|
+
const plan = {
|
|
159
|
+
manifest: {},
|
|
160
|
+
upload: {},
|
|
161
|
+
writes: {},
|
|
162
|
+
deletes: [],
|
|
163
|
+
pulled: [],
|
|
164
|
+
pushed: [],
|
|
165
|
+
deletedHere: [],
|
|
166
|
+
deletedFromTeam: [],
|
|
167
|
+
conflicts: [],
|
|
168
|
+
indexTeamLines: []
|
|
169
|
+
};
|
|
170
|
+
const taken = new Set(input.taken);
|
|
171
|
+
const teamText = (path) => {
|
|
172
|
+
const text = texts[input.team[path]];
|
|
173
|
+
if (text === void 0) throw new Error(`the team's ${path} could not be read`);
|
|
174
|
+
return text;
|
|
175
|
+
};
|
|
176
|
+
for (const path of input.excluded) if (input.team[path]) plan.manifest[path] = input.team[path];
|
|
177
|
+
for (const [path, row] of Object.entries(rows)) {
|
|
178
|
+
const team = input.team[path];
|
|
179
|
+
const local = input.local[path];
|
|
180
|
+
switch (row) {
|
|
181
|
+
case "unchanged":
|
|
182
|
+
if (team) plan.manifest[path] = team;
|
|
183
|
+
break;
|
|
184
|
+
case "local":
|
|
185
|
+
plan.manifest[path] = sha256(local);
|
|
186
|
+
plan.upload[path] = local;
|
|
187
|
+
plan.pushed.push(path);
|
|
188
|
+
break;
|
|
189
|
+
case "delete-team":
|
|
190
|
+
plan.deletedFromTeam.push(path);
|
|
191
|
+
break;
|
|
192
|
+
case "team":
|
|
193
|
+
plan.manifest[path] = team;
|
|
194
|
+
plan.writes[path] = teamText(path);
|
|
195
|
+
plan.pulled.push(path);
|
|
196
|
+
break;
|
|
197
|
+
case "delete-local":
|
|
198
|
+
plan.deletes.push(path);
|
|
199
|
+
plan.deletedHere.push(path);
|
|
200
|
+
break;
|
|
201
|
+
case "conflict": {
|
|
202
|
+
const copy = conflictCopyName(path, input.date, taken);
|
|
203
|
+
taken.add(copy.toLowerCase());
|
|
204
|
+
plan.manifest[path] = team;
|
|
205
|
+
plan.writes[path] = teamText(path);
|
|
206
|
+
plan.writes[copy] = local;
|
|
207
|
+
plan.conflicts.push({ path, copy });
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
case "index": {
|
|
211
|
+
const base = input.base[path] ? texts[input.base[path]] : void 0;
|
|
212
|
+
const merged = mergeIndex(base, teamText(path), local);
|
|
213
|
+
const hash = sha256(merged.text);
|
|
214
|
+
plan.manifest[path] = hash;
|
|
215
|
+
if (merged.text !== local) {
|
|
216
|
+
plan.writes[path] = merged.text;
|
|
217
|
+
plan.pulled.push(path);
|
|
218
|
+
}
|
|
219
|
+
if (hash !== team) {
|
|
220
|
+
plan.upload[path] = merged.text;
|
|
221
|
+
plan.pushed.push(path);
|
|
222
|
+
}
|
|
223
|
+
plan.indexTeamLines.push(...merged.teamLines);
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return plan;
|
|
229
|
+
}
|
|
230
|
+
var LINK_RE = /\]\(\s*<?([^)\s>]+)>?(?:\s+["'(][^)]*)?\)/;
|
|
231
|
+
function entries(lines) {
|
|
232
|
+
const seen = /* @__PURE__ */ new Map();
|
|
233
|
+
return lines.map((line) => {
|
|
234
|
+
const link = LINK_RE.exec(line);
|
|
235
|
+
const id = link ? `link:${link[1]}` : `text:${line}`;
|
|
236
|
+
const n = seen.get(id) ?? 0;
|
|
237
|
+
seen.set(id, n + 1);
|
|
238
|
+
return { key: n ? `${id}#${n}` : id, line };
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
var splitLines = (text) => text.split(/\r?\n/);
|
|
242
|
+
function mergeIndex(base, team, local) {
|
|
243
|
+
const eol = local.includes("\r\n") ? "\r\n" : "\n";
|
|
244
|
+
const b = new Map(entries(base === void 0 ? [] : splitLines(base)).map((e) => [e.key, e.line]));
|
|
245
|
+
const teamEntries = entries(splitLines(team));
|
|
246
|
+
const t = new Map(teamEntries.map((e) => [e.key, e.line]));
|
|
247
|
+
const localEntries = entries(splitLines(local));
|
|
248
|
+
const result = [];
|
|
249
|
+
const teamLines = [];
|
|
250
|
+
for (const e of localEntries) {
|
|
251
|
+
const was = b.get(e.key);
|
|
252
|
+
const theirs = t.get(e.key);
|
|
253
|
+
if (theirs === e.line || theirs === was) result.push(e);
|
|
254
|
+
else if (e.line === was) {
|
|
255
|
+
if (theirs !== void 0) result.push({ key: e.key, line: theirs });
|
|
256
|
+
} else {
|
|
257
|
+
result.push(e);
|
|
258
|
+
if (theirs !== void 0) teamLines.push(theirs);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
const baseEntries = entries(base === void 0 ? [] : splitLines(base));
|
|
262
|
+
const localKeys = new Set(localEntries.map((e) => e.key));
|
|
263
|
+
const replacedHere = /* @__PURE__ */ new Map();
|
|
264
|
+
baseEntries.forEach((e, i) => {
|
|
265
|
+
if (e.key.startsWith("text:") && !t.has(e.key) && !localKeys.has(e.key)) {
|
|
266
|
+
replacedHere.set(e.key, i ? baseEntries[i - 1].key : "");
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
const inResult = (key) => result.findIndex((e) => e.key === key);
|
|
270
|
+
teamEntries.forEach((e, i) => {
|
|
271
|
+
if (inResult(e.key) !== -1) return;
|
|
272
|
+
const was = b.get(e.key);
|
|
273
|
+
const before = i ? teamEntries[i - 1].key : "";
|
|
274
|
+
const both = e.key.startsWith("text:") && [...replacedHere].find(([, prev]) => prev === before);
|
|
275
|
+
if (was === void 0 && both) {
|
|
276
|
+
replacedHere.delete(both[0]);
|
|
277
|
+
teamLines.push(e.line);
|
|
278
|
+
} else if (was === void 0) {
|
|
279
|
+
let at = -1;
|
|
280
|
+
for (let j = i - 1; j >= 0 && at === -1; j--) at = inResult(teamEntries[j].key);
|
|
281
|
+
result.splice(at + 1, 0, e);
|
|
282
|
+
} else if (e.line !== was) {
|
|
283
|
+
teamLines.push(e.line);
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
return { text: result.map((e) => e.line).join(eol), teamLines };
|
|
287
|
+
}
|
|
288
|
+
var groupKey = (kind, normalised) => `${kind}:${normalised}`;
|
|
289
|
+
function collectValues(files) {
|
|
290
|
+
const groups = /* @__PURE__ */ new Map();
|
|
291
|
+
const homePaths = [];
|
|
292
|
+
const findings = {};
|
|
293
|
+
for (const path of Object.keys(files).sort()) {
|
|
294
|
+
const found = findPersonValues(files[path]);
|
|
295
|
+
findings[path] = found;
|
|
296
|
+
for (const f of found) {
|
|
297
|
+
if (f.kind === HOME_PATH) {
|
|
298
|
+
if (!homePaths.includes(path)) homePaths.push(path);
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
const key = groupKey(f.kind, f.normalised);
|
|
302
|
+
const group = groups.get(key);
|
|
303
|
+
if (group) {
|
|
304
|
+
if (!group.files.includes(path)) group.files.push(path);
|
|
305
|
+
} else {
|
|
306
|
+
groups.set(key, {
|
|
307
|
+
kind: f.kind,
|
|
308
|
+
value: f.value,
|
|
309
|
+
normalised: f.normalised,
|
|
310
|
+
files: [path],
|
|
311
|
+
first: { path, line: f.line, text: splitLines(files[path])[f.line - 1].trim() }
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return { groups: [...groups.values()], homePaths, findings };
|
|
317
|
+
}
|
|
318
|
+
function personToken(personId, label) {
|
|
319
|
+
return label ? `\`${personId}\` (${label})` : `\`${personId}\``;
|
|
320
|
+
}
|
|
321
|
+
function rewriteValues(text, findings, replacement) {
|
|
322
|
+
let out = "";
|
|
323
|
+
let last = 0;
|
|
324
|
+
for (const f of [...findings].sort((a, b) => a.start - b.start)) {
|
|
325
|
+
const to = replacement(f);
|
|
326
|
+
if (to === void 0) continue;
|
|
327
|
+
out += text.slice(last, f.start) + to;
|
|
328
|
+
last = f.end;
|
|
329
|
+
}
|
|
330
|
+
return out + text.slice(last);
|
|
331
|
+
}
|
|
332
|
+
function lineDiff(before, after) {
|
|
333
|
+
const a = splitLines(before);
|
|
334
|
+
const b = splitLines(after);
|
|
335
|
+
const out = [];
|
|
336
|
+
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
|
337
|
+
if (a[i] === b[i]) continue;
|
|
338
|
+
if (a[i] !== void 0) out.push(`- ${a[i]}`);
|
|
339
|
+
if (b[i] !== void 0) out.push(`+ ${b[i]}`);
|
|
340
|
+
}
|
|
341
|
+
return out;
|
|
342
|
+
}
|
|
343
|
+
export {
|
|
344
|
+
INDEX_FILE,
|
|
345
|
+
SHAREABLE_TYPES,
|
|
346
|
+
addShareFalse,
|
|
347
|
+
collectValues,
|
|
348
|
+
conflictCopyName,
|
|
349
|
+
decide,
|
|
350
|
+
decideAll,
|
|
351
|
+
groupKey,
|
|
352
|
+
isConflictCopy,
|
|
353
|
+
isFilePath,
|
|
354
|
+
isShareable,
|
|
355
|
+
lineDiff,
|
|
356
|
+
localDate,
|
|
357
|
+
mergeIndex,
|
|
358
|
+
personToken,
|
|
359
|
+
planMerge,
|
|
360
|
+
readFrontmatter,
|
|
361
|
+
rewriteValues,
|
|
362
|
+
sha256
|
|
363
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Find a person's details in a team memory file (ADR-217 Amendment 2).
|
|
3
|
+
*
|
|
4
|
+
* The client's half of the one detector of specs/changes/team-setup/contract.md
|
|
5
|
+
* ("People"), held with its twin `backend/layers/common/common/person_values.py`
|
|
6
|
+
* to the shared vectors in `specs/changes/team-setup/person-values.json`
|
|
7
|
+
* (test/person-values.test.mjs here, backend/tests/test_person_values.py there).
|
|
8
|
+
* Change one and the other in the same pull request. It finds:
|
|
9
|
+
*
|
|
10
|
+
* - **a phone number**: an E.164 number (`+44 7700 900123`), or a national
|
|
11
|
+
* number of 10 or 11 digits starting with 0 in the usual groupings
|
|
12
|
+
* (`07700 900123`, `020 7946 0321`), normalised to E.164 (+44);
|
|
13
|
+
* - **an email address**: an RFC 5322 addr-spec in its dot-atom form, without
|
|
14
|
+
* comments or a quoted local part, normalised to lower case;
|
|
15
|
+
* - **a home-folder path**: `C:\Users\<name>`, `/Users/<name>` or
|
|
16
|
+
* `/home/<name>`, which the sync rewrites to `~`.
|
|
17
|
+
*
|
|
18
|
+
* Anything inside a person token, `` `<con_|ctc_|usr_…>` (<label>) ``, is
|
|
19
|
+
* skipped, so a file the sync rewrote is never found again. The patterns are
|
|
20
|
+
* the backend's, character for character; where Python's `\w` and `\d` are
|
|
21
|
+
* Unicode-aware, these spell the same classes out (`u` flag).
|
|
22
|
+
*
|
|
23
|
+
* `findSecrets` mirrors the backend's `secret.key-prefix` rule
|
|
24
|
+
* (common/authored_skills.py): the first characters of a key, even cut short,
|
|
25
|
+
* are a secret. The server's other secret rules refuse a whole key at upload
|
|
26
|
+
* (SYD-K005); a whole key also matches this prefix rule, so the client skips
|
|
27
|
+
* it either way.
|
|
28
|
+
*
|
|
29
|
+
* Pure: no disk, no network, no logging.
|
|
30
|
+
*/
|
|
31
|
+
export declare const PHONE = "phone";
|
|
32
|
+
export declare const EMAIL = "email";
|
|
33
|
+
export declare const HOME_PATH = "home-path";
|
|
34
|
+
export type PersonValueKind = typeof PHONE | typeof EMAIL | typeof HOME_PATH;
|
|
35
|
+
/** The kinds a value can be answered for (openapi ValueKind); a home path is
|
|
36
|
+
* never answered, only rewritten. */
|
|
37
|
+
export declare const VALUE_KINDS: readonly ["phone", "email"];
|
|
38
|
+
export type ValueKind = (typeof VALUE_KINDS)[number];
|
|
39
|
+
/** The country a national number (leading 0) belongs to. */
|
|
40
|
+
export declare const NATIONAL_PREFIX = "+44";
|
|
41
|
+
export interface Finding {
|
|
42
|
+
kind: PersonValueKind;
|
|
43
|
+
/** As written in the file. */
|
|
44
|
+
value: string;
|
|
45
|
+
/** E.164 for a phone number, lower case for an email address; for a home
|
|
46
|
+
* path, the value itself (it is rewritten, never answered). */
|
|
47
|
+
normalised: string;
|
|
48
|
+
/** 1-based. */
|
|
49
|
+
line: number;
|
|
50
|
+
start: number;
|
|
51
|
+
end: number;
|
|
52
|
+
}
|
|
53
|
+
export interface SecretFinding {
|
|
54
|
+
kind: "key-prefix";
|
|
55
|
+
line: number;
|
|
56
|
+
}
|
|
57
|
+
/** openapi PersonId, unanchored: THE one copy of the pattern on the client.
|
|
58
|
+
* The person token below and `resolve_person`'s input check
|
|
59
|
+
* (team-setup-types PERSON_ID_RE) are both built from it, so a change to the
|
|
60
|
+
* contract's PersonId is this one line (and the backend's `_PERSON_TOKEN`). */
|
|
61
|
+
export declare const PERSON_ID = "(?:(?:con|ctc)_[0-9A-Za-z]{10,40}|usr_[0-9A-Za-z-]{10,40})";
|
|
62
|
+
/** `con_…` (label), `ctc_…` (label), `usr_…` (label): a PersonId in
|
|
63
|
+
* backticks, with its role label after it. Skipped by the detector. */
|
|
64
|
+
export declare const PERSON_TOKEN = "`(?:(?:con|ctc)_[0-9A-Za-z]{10,40}|usr_[0-9A-Za-z-]{10,40})`(?:[ \\t]*\\([^()\\n]*\\))?";
|
|
65
|
+
/**
|
|
66
|
+
* The form an answer is keyed by: E.164 for a phone number, lower case for an
|
|
67
|
+
* email address. Throws when `value` is not one of `kind` (the server answers
|
|
68
|
+
* SYD-K020 for the same value).
|
|
69
|
+
*/
|
|
70
|
+
export declare function normalise(kind: string, value: string): string;
|
|
71
|
+
/** Every phone number, email address and home-folder path in `text`, in the
|
|
72
|
+
* order they appear, skipping person tokens. */
|
|
73
|
+
export declare function findPersonValues(text: string): Finding[];
|
|
74
|
+
/** `text` with every home-folder path found replaced by `~`: what the sync
|
|
75
|
+
* sends instead (`C:\Users\jbloggs\.claude` → `~\.claude`). */
|
|
76
|
+
export declare function rewriteHomePaths(text: string): string;
|
|
77
|
+
/** The kind and line of every key prefix in `text`; never the value. */
|
|
78
|
+
export declare function findSecrets(text: string): SecretFinding[];
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/lib/person-values.ts
|
|
4
|
+
var PHONE = "phone";
|
|
5
|
+
var EMAIL = "email";
|
|
6
|
+
var HOME_PATH = "home-path";
|
|
7
|
+
var VALUE_KINDS = [PHONE, EMAIL];
|
|
8
|
+
var NATIONAL_PREFIX = "+44";
|
|
9
|
+
var W = "[\\p{L}\\p{N}_]";
|
|
10
|
+
var D = "\\p{Nd}";
|
|
11
|
+
var WORD_OR = (extra) => `[\\p{L}\\p{N}_${extra}]`;
|
|
12
|
+
var PERSON_ID = "(?:(?:con|ctc)_[0-9A-Za-z]{10,40}|usr_[0-9A-Za-z-]{10,40})";
|
|
13
|
+
var PERSON_TOKEN = `\`${PERSON_ID}\`(?:[ \\t]*\\([^()\\n]*\\))?`;
|
|
14
|
+
var BEFORE = `(?<!${WORD_OR("+.:/\\\\\\-")})(?<!${D}[ -])`;
|
|
15
|
+
var AFTER = `(?!${WORD_OR(":/\\\\")}|\\.${D}|[ -]?${D}|-${W})`;
|
|
16
|
+
var E164 = `${BEFORE}\\+${D}+(?:[ -]${D}+)*${AFTER}`;
|
|
17
|
+
var NATIONAL = `${BEFORE}0${D}*(?:[ -]${D}+)*${AFTER}`;
|
|
18
|
+
var GROUP_SIZES = /* @__PURE__ */ new Set([2, 3, 4, 5, 6]);
|
|
19
|
+
var ATEXT = "[A-Za-z0-9!#$%&'*+/=?^_{|}~\\-]";
|
|
20
|
+
var EMAIL_RE = "(?<![A-Za-z0-9!#$%&*+/=?^_{|}~.@\\-])[A-Za-z0-9_]" + ATEXT + "*(?:\\." + ATEXT + "+)*@(?:[A-Za-z0-9](?:[A-Za-z0-9\\-]{0,61}[A-Za-z0-9])?\\.)+[A-Za-z]{2,63}(?![A-Za-z0-9\\-]|\\.[A-Za-z0-9])";
|
|
21
|
+
var HOME_PREFIX = "(?:[A-Za-z]:\\\\+Users\\\\+|[A-Za-z]:/+Users/+|/Users/|/home/)";
|
|
22
|
+
var HOME = `(?<!${WORD_OR(".~:/\\\\$}\\-")})${HOME_PREFIX}[^\\\\/\\s"'\`<>|:*?]+`;
|
|
23
|
+
var KEY_PREFIX = `(?<!${W})(?:pweb_[a-z]+_key_|sk-ant-|sk_live_|rk_live_|github_pat_|gh[pousr]_|sntrys_|xox[baprs]-)[A-Za-z0-9_\\-]{4,}`;
|
|
24
|
+
var global = (source, flags = "") => new RegExp(source, `gu${flags}`);
|
|
25
|
+
var whole = (source, flags = "") => new RegExp(`^(?:${source})$`, `u${flags}`);
|
|
26
|
+
function lineOf(text, start) {
|
|
27
|
+
let line = 1;
|
|
28
|
+
for (let i = text.indexOf("\n"); i !== -1 && i < start; i = text.indexOf("\n", i + 1)) line++;
|
|
29
|
+
return line;
|
|
30
|
+
}
|
|
31
|
+
function phoneShape(value) {
|
|
32
|
+
const groups = value.replace(/^\++/, "").split(/[ -]/);
|
|
33
|
+
const digits = [...groups.join("")];
|
|
34
|
+
if (value.startsWith("+")) return digits.length >= 8 && digits.length <= 15 && digits[0] !== "0";
|
|
35
|
+
if (digits.length < 10 || digits.length > 11) return false;
|
|
36
|
+
return groups.length === 1 || groups.slice(1).every((g) => GROUP_SIZES.has([...g].length));
|
|
37
|
+
}
|
|
38
|
+
function normalise(kind, value) {
|
|
39
|
+
const v = typeof value === "string" ? value.trim() : "";
|
|
40
|
+
if (kind === PHONE) {
|
|
41
|
+
if (!(whole(E164).test(v) || whole(NATIONAL).test(v)) || !phoneShape(v)) {
|
|
42
|
+
throw new Error("not a phone number");
|
|
43
|
+
}
|
|
44
|
+
const digits = v.replace(/[ -]/g, "");
|
|
45
|
+
return digits.startsWith("+") ? digits : NATIONAL_PREFIX + digits.slice(1);
|
|
46
|
+
}
|
|
47
|
+
if (kind === EMAIL) {
|
|
48
|
+
if (!whole(EMAIL_RE).test(v)) throw new Error("not an email address");
|
|
49
|
+
return v.toLowerCase();
|
|
50
|
+
}
|
|
51
|
+
throw new Error(`not a value kind: ${JSON.stringify(kind)}`);
|
|
52
|
+
}
|
|
53
|
+
function findPersonValues(text) {
|
|
54
|
+
if (typeof text !== "string" || !text) return [];
|
|
55
|
+
const skipped = [...text.matchAll(global(PERSON_TOKEN))].map((m) => [m.index, m.index + m[0].length]);
|
|
56
|
+
const found = [];
|
|
57
|
+
const add = (kind, m, normalised) => {
|
|
58
|
+
const start = m.index;
|
|
59
|
+
const end = start + m[0].length;
|
|
60
|
+
if (skipped.some(([s, e]) => s <= start && end <= e)) return;
|
|
61
|
+
if (found.some((f) => f.start < end && start < f.end)) return;
|
|
62
|
+
found.push({ kind, value: m[0], normalised, line: lineOf(text, start), start, end });
|
|
63
|
+
};
|
|
64
|
+
for (const m of text.matchAll(global(EMAIL_RE))) add(EMAIL, m, m[0].toLowerCase());
|
|
65
|
+
for (const source of [E164, NATIONAL]) {
|
|
66
|
+
for (const m of text.matchAll(global(source))) {
|
|
67
|
+
if (phoneShape(m[0])) add(PHONE, m, normalise(PHONE, m[0]));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
for (const m of text.matchAll(global(HOME, "i"))) add(HOME_PATH, m, m[0]);
|
|
71
|
+
return found.sort((a, b) => a.start - b.start);
|
|
72
|
+
}
|
|
73
|
+
function rewriteHomePaths(text) {
|
|
74
|
+
let out = "";
|
|
75
|
+
let last = 0;
|
|
76
|
+
for (const f of findPersonValues(text)) {
|
|
77
|
+
if (f.kind !== HOME_PATH) continue;
|
|
78
|
+
out += text.slice(last, f.start) + "~";
|
|
79
|
+
last = f.end;
|
|
80
|
+
}
|
|
81
|
+
return out + text.slice(last);
|
|
82
|
+
}
|
|
83
|
+
function findSecrets(text) {
|
|
84
|
+
if (typeof text !== "string" || !text) return [];
|
|
85
|
+
return [...text.matchAll(global(KEY_PREFIX))].map((m) => ({ kind: "key-prefix", line: lineOf(text, m.index) }));
|
|
86
|
+
}
|
|
87
|
+
export {
|
|
88
|
+
EMAIL,
|
|
89
|
+
HOME_PATH,
|
|
90
|
+
NATIONAL_PREFIX,
|
|
91
|
+
PERSON_ID,
|
|
92
|
+
PERSON_TOKEN,
|
|
93
|
+
PHONE,
|
|
94
|
+
VALUE_KINDS,
|
|
95
|
+
findPersonValues,
|
|
96
|
+
findSecrets,
|
|
97
|
+
normalise,
|
|
98
|
+
rewriteHomePaths
|
|
99
|
+
};
|
package/dist/lib/skills.d.ts
CHANGED
|
@@ -63,6 +63,26 @@ export declare function isValidPath(path: string): boolean;
|
|
|
63
63
|
export declare function frontmatterName(skillMd: string): string | undefined;
|
|
64
64
|
/** The default install folder: personal skills, every project (ADR-216 §6 Q1). */
|
|
65
65
|
export declare function defaultSkillDir(name: string): string;
|
|
66
|
+
export interface Walked {
|
|
67
|
+
files: string[];
|
|
68
|
+
/** Symlinks and junctions: never followed, never written through. */
|
|
69
|
+
links: string[];
|
|
70
|
+
/** Dot-entries a publish leaves out (.env, .vscode/…): never part of a skill. */
|
|
71
|
+
hidden: string[];
|
|
72
|
+
}
|
|
73
|
+
/** `skipHidden`: a publish leaves dot-entries out; an install must see them,
|
|
74
|
+
* or an edited dotfile a version ships would be overwritten unseen. */
|
|
75
|
+
export declare function walk(root: string, { skipHidden }: {
|
|
76
|
+
skipHidden: boolean;
|
|
77
|
+
}, dir?: string, out?: Walked): Promise<Walked>;
|
|
78
|
+
/**
|
|
79
|
+
* Read `paths` (relative, forward slashes) under `root` exactly as they are
|
|
80
|
+
* on disk, bytes decoded as UTF-8, held to the server's limits (ADR-216):
|
|
81
|
+
* at most MAX_FILES files, each text with no NUL and at most MAX_FILE_BYTES,
|
|
82
|
+
* all together at most MAX_TOTAL_BYTES. Throws naming the first file at
|
|
83
|
+
* fault. `what` names the thing in the message ("a skill", "the hooks part").
|
|
84
|
+
*/
|
|
85
|
+
export declare function readTextFiles(root: string, paths: string[], what: string): Promise<SkillFileIn[]>;
|
|
66
86
|
/**
|
|
67
87
|
* Read a skill folder exactly as it is on disk (bytes decoded as UTF-8, never
|
|
68
88
|
* re-typed), ready to publish. Throws naming the first file that cannot be
|
package/dist/lib/skills.js
CHANGED
|
@@ -56,18 +56,9 @@ async function walk(root, { skipHidden }, dir = root, out = { files: [], links:
|
|
|
56
56
|
out.files.sort();
|
|
57
57
|
return out;
|
|
58
58
|
}
|
|
59
|
-
async function
|
|
60
|
-
const root = resolve(directory);
|
|
61
|
-
if (!existsSync(join(root, ENTRY_FILE))) {
|
|
62
|
-
throw new Error(`${root} has no ${ENTRY_FILE}: a skill folder holds SKILL.md at its root`);
|
|
63
|
-
}
|
|
64
|
-
const walked = await walk(root, { skipHidden: true });
|
|
65
|
-
if (walked.links.length) {
|
|
66
|
-
throw new Error(`${walked.links.join(", ")}: links are not followed; copy the file into the skill folder`);
|
|
67
|
-
}
|
|
68
|
-
const paths = walked.files;
|
|
59
|
+
async function readTextFiles(root, paths, what) {
|
|
69
60
|
if (paths.length > MAX_FILES) {
|
|
70
|
-
throw new Error(`${root} holds ${paths.length} files:
|
|
61
|
+
throw new Error(`${root} holds ${paths.length} files: ${what} holds at most ${MAX_FILES}`);
|
|
71
62
|
}
|
|
72
63
|
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
73
64
|
const files = [];
|
|
@@ -77,7 +68,7 @@ async function readSkillDirectory(directory) {
|
|
|
77
68
|
throw new Error(`${path}: file names may use letters, digits, ".", "_" and "-" only`);
|
|
78
69
|
}
|
|
79
70
|
const raw = await readFile(join(root, ...path.split("/")));
|
|
80
|
-
if (raw.includes(0)) throw new Error(`${path} is binary:
|
|
71
|
+
if (raw.includes(0)) throw new Error(`${path} is binary: ${what} holds text files only`);
|
|
81
72
|
if (raw.length > MAX_FILE_BYTES) {
|
|
82
73
|
throw new Error(`${path} is ${raw.length} bytes: a file holds at most ${MAX_FILE_BYTES}`);
|
|
83
74
|
}
|
|
@@ -93,6 +84,18 @@ async function readSkillDirectory(directory) {
|
|
|
93
84
|
}
|
|
94
85
|
files.push({ path, content });
|
|
95
86
|
}
|
|
87
|
+
return files;
|
|
88
|
+
}
|
|
89
|
+
async function readSkillDirectory(directory) {
|
|
90
|
+
const root = resolve(directory);
|
|
91
|
+
if (!existsSync(join(root, ENTRY_FILE))) {
|
|
92
|
+
throw new Error(`${root} has no ${ENTRY_FILE}: a skill folder holds SKILL.md at its root`);
|
|
93
|
+
}
|
|
94
|
+
const walked = await walk(root, { skipHidden: true });
|
|
95
|
+
if (walked.links.length) {
|
|
96
|
+
throw new Error(`${walked.links.join(", ")}: links are not followed; copy the file into the skill folder`);
|
|
97
|
+
}
|
|
98
|
+
const files = await readTextFiles(root, walked.files, "a skill");
|
|
96
99
|
const skillMd = files.find((f) => f.path === ENTRY_FILE);
|
|
97
100
|
const name = frontmatterName(skillMd.content);
|
|
98
101
|
if (!name) throw new Error(`${ENTRY_FILE} has no frontmatter name`);
|
|
@@ -205,5 +208,7 @@ export {
|
|
|
205
208
|
isValidName,
|
|
206
209
|
isValidPath,
|
|
207
210
|
readSkillDirectory,
|
|
208
|
-
|
|
211
|
+
readTextFiles,
|
|
212
|
+
sha256,
|
|
213
|
+
walk
|
|
209
214
|
};
|