@systemdox/mcp-server 0.1.46 → 0.1.48
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 +69 -0
- package/dist/cli.js +1575 -158
- package/dist/http.js +927 -17
- package/dist/index.js +927 -17
- package/dist/lib/memory-sync.d.ts +150 -0
- package/dist/lib/memory-sync.js +363 -0
- package/dist/lib/person-values.d.ts +6 -3
- package/dist/lib/person-values.js +1 -1
- 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/team-setup.d.ts +5 -1
- package/dist/tools/team-setup.js +920 -10
- package/package.json +1 -1
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The team memory's merge (ADR-217 §2.3, specs/changes/team-setup/contract.md
|
|
3
|
+
* "sync-memory and the MCP tool sync_memory"), pure: no disk, no network.
|
|
4
|
+
* `src/sync-memory.ts` reads the folders and calls the API around it.
|
|
5
|
+
*
|
|
6
|
+
* - **What is shared** (`isShareable`): a `*.md` file whose frontmatter `type`
|
|
7
|
+
* is `feedback`, `project` or `reference`, and `MEMORY.md`; never a file
|
|
8
|
+
* with `share: false` or a `<name>.local-<date>.md` conflict copy.
|
|
9
|
+
* - **The merge** (`decide`, `planMerge`): three-way per path, with the base
|
|
10
|
+
* (the manifest of the last sync), the team's latest version and the local
|
|
11
|
+
* folder, row by row as the contract's table says.
|
|
12
|
+
* - **`MEMORY.md`** (`mergeIndex`): line by line, keyed by the file each line
|
|
13
|
+
* links to; never a `.local-` copy.
|
|
14
|
+
* - **People** (`collectValues`, `rewriteValues`, `personToken`): every value
|
|
15
|
+
* the detector finds, grouped by its normalised form, and the rewrite the
|
|
16
|
+
* member agrees to.
|
|
17
|
+
*/
|
|
18
|
+
import { type Finding, type ValueKind } from "./person-values.js";
|
|
19
|
+
export declare const INDEX_FILE = "MEMORY.md";
|
|
20
|
+
export declare const SHAREABLE_TYPES: readonly ["feedback", "project", "reference"];
|
|
21
|
+
/** `{path: sha256}` (openapi Manifest). */
|
|
22
|
+
export type Manifest = Record<string, string>;
|
|
23
|
+
export declare function sha256(text: string): string;
|
|
24
|
+
export declare function isFilePath(path: string): boolean;
|
|
25
|
+
export declare function isConflictCopy(path: string): boolean;
|
|
26
|
+
export interface Frontmatter {
|
|
27
|
+
type?: string;
|
|
28
|
+
/** True when the block says `share: false` (or another YAML false). */
|
|
29
|
+
shareFalse: boolean;
|
|
30
|
+
}
|
|
31
|
+
/** The frontmatter's `type` and `share`, or undefined when the file has no
|
|
32
|
+
* leading block. Only top-level `key: value` lines are read. */
|
|
33
|
+
export declare function readFrontmatter(text: string): Frontmatter | undefined;
|
|
34
|
+
/**
|
|
35
|
+
* Shared with the team: `MEMORY.md` at the folder's root, or a `*.md` file
|
|
36
|
+
* whose frontmatter `type` is feedback, project or reference. Never a file
|
|
37
|
+
* with `share: false`, a `.local-` conflict copy, or anything else.
|
|
38
|
+
*/
|
|
39
|
+
export declare function isShareable(path: string, text: string): boolean;
|
|
40
|
+
/** `text` with `share: false` in its frontmatter: the line is added before the
|
|
41
|
+
* block's closing `---` (or replaces a `share:` line), or a block is added. */
|
|
42
|
+
export declare function addShareFalse(text: string): string;
|
|
43
|
+
/** The member's calendar date, `YYYY-MM-DD`. */
|
|
44
|
+
export declare function localDate(now: Date): string;
|
|
45
|
+
/** `a.md` → `a.local-<date>.md`, then `a.local-<date>-2.md`, `-3` … when the
|
|
46
|
+
* name is taken. */
|
|
47
|
+
export declare function conflictCopyName(path: string, date: string, taken: Set<string>): string;
|
|
48
|
+
/**
|
|
49
|
+
* One path's row of the contract's table (base → team, base → local):
|
|
50
|
+
* - `unchanged`: both sides hold the same (or neither holds it);
|
|
51
|
+
* - `local`: the team did not change it and the member changed or added it;
|
|
52
|
+
* also a file the team deleted and the member changed (the member's text
|
|
53
|
+
* is kept, never lost);
|
|
54
|
+
* - `delete-team`: the team did not change it and the member deleted it;
|
|
55
|
+
* - `team`: the member did not change it and the team changed or added it;
|
|
56
|
+
* also a file the member deleted and the team changed (the team's text
|
|
57
|
+
* comes back, never lost);
|
|
58
|
+
* - `delete-local`: the member did not change it and the team deleted it;
|
|
59
|
+
* - `conflict`: both changed it differently;
|
|
60
|
+
* - `index`: `MEMORY.md` changed on both sides, merged line by line.
|
|
61
|
+
*/
|
|
62
|
+
export type Row = "unchanged" | "local" | "delete-team" | "team" | "delete-local" | "conflict" | "index";
|
|
63
|
+
export declare function decide(path: string, base: string | undefined, team: string | undefined, local: string | undefined): Row;
|
|
64
|
+
export interface MergeInput {
|
|
65
|
+
base: Manifest;
|
|
66
|
+
team: Manifest;
|
|
67
|
+
/** The local folder's shared files, `{path: text}`. */
|
|
68
|
+
local: Record<string, string>;
|
|
69
|
+
/** Paths left as they are on both sides: a file held back this run, or a
|
|
70
|
+
* local file that is not shared. The team's entry is kept; nothing is
|
|
71
|
+
* written to the path. */
|
|
72
|
+
excluded: Set<string>;
|
|
73
|
+
/** Every file name in the folder (lower case), so a conflict copy never
|
|
74
|
+
* takes a name in use. */
|
|
75
|
+
taken: Set<string>;
|
|
76
|
+
date: string;
|
|
77
|
+
}
|
|
78
|
+
/** The rows by path, and the team hashes whose text the plan needs. */
|
|
79
|
+
export declare function decideAll(input: Omit<MergeInput, "taken" | "date">): {
|
|
80
|
+
rows: Record<string, Row>;
|
|
81
|
+
needed: string[];
|
|
82
|
+
};
|
|
83
|
+
export interface MergePlan {
|
|
84
|
+
/** The manifest to push. */
|
|
85
|
+
manifest: Manifest;
|
|
86
|
+
/** The text of each path whose hash the team does not hold yet. */
|
|
87
|
+
upload: Record<string, string>;
|
|
88
|
+
/** Files to write in the local folder (pulled, conflict pairs, the index). */
|
|
89
|
+
writes: Record<string, string>;
|
|
90
|
+
/** Files to delete in the local folder. */
|
|
91
|
+
deletes: string[];
|
|
92
|
+
pulled: string[];
|
|
93
|
+
pushed: string[];
|
|
94
|
+
deletedHere: string[];
|
|
95
|
+
deletedFromTeam: string[];
|
|
96
|
+
conflicts: {
|
|
97
|
+
path: string;
|
|
98
|
+
copy: string;
|
|
99
|
+
}[];
|
|
100
|
+
/** `MEMORY.md` lines the team changed where the member's line was kept. */
|
|
101
|
+
indexTeamLines: string[];
|
|
102
|
+
}
|
|
103
|
+
/** The plan for the rows of `decideAll`, given the text of every hash it
|
|
104
|
+
* named (`texts`, `{sha256: text}`). A base `MEMORY.md` no longer readable is
|
|
105
|
+
* merged as if empty. */
|
|
106
|
+
export declare function planMerge(input: MergeInput, rows: Record<string, Row>, texts: Record<string, string>): MergePlan;
|
|
107
|
+
/**
|
|
108
|
+
* `MEMORY.md` merged line by line, keyed by the file each line links to
|
|
109
|
+
* (ADR-217 §2.3.3):
|
|
110
|
+
* - a team line for a new file is added under the same heading (after the
|
|
111
|
+
* line it follows in the team's copy);
|
|
112
|
+
* - a line the team removed and the member did not change is removed;
|
|
113
|
+
* - a line with no link takes the team's text unless the member changed it;
|
|
114
|
+
* - when both changed the same line, the member's line is kept and the
|
|
115
|
+
* team's is returned in `teamLines`, to be listed.
|
|
116
|
+
*/
|
|
117
|
+
export declare function mergeIndex(base: string | undefined, team: string, local: string): {
|
|
118
|
+
text: string;
|
|
119
|
+
teamLines: string[];
|
|
120
|
+
};
|
|
121
|
+
export interface ValueGroup {
|
|
122
|
+
kind: ValueKind;
|
|
123
|
+
/** As first written. */
|
|
124
|
+
value: string;
|
|
125
|
+
normalised: string;
|
|
126
|
+
files: string[];
|
|
127
|
+
/** Where it first appears: the file, the line number and the line. */
|
|
128
|
+
first: {
|
|
129
|
+
path: string;
|
|
130
|
+
line: number;
|
|
131
|
+
text: string;
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
export declare const groupKey: (kind: string, normalised: string) => string;
|
|
135
|
+
/** Every phone number and email address in `files`, grouped by normalised
|
|
136
|
+
* value in the order first found; and the files holding a home-folder path. */
|
|
137
|
+
export declare function collectValues(files: Record<string, string>): {
|
|
138
|
+
groups: ValueGroup[];
|
|
139
|
+
homePaths: string[];
|
|
140
|
+
findings: Record<string, Finding[]>;
|
|
141
|
+
};
|
|
142
|
+
/** `` `<personId>` (<label>) ``: the identifier exactly as the server
|
|
143
|
+
* returned it, and the role label when there is one. */
|
|
144
|
+
export declare function personToken(personId: string, label?: string): string;
|
|
145
|
+
/** `text` with each finding replaced as `replacement` says (undefined leaves
|
|
146
|
+
* it as written). */
|
|
147
|
+
export declare function rewriteValues(text: string, findings: Finding[], replacement: (f: Finding) => string | undefined): string;
|
|
148
|
+
/** The lines that differ between two versions of a file, as `- old` and
|
|
149
|
+
* `+ new` pairs, in order. */
|
|
150
|
+
export declare function lineDiff(before: string, after: string): string[];
|
|
@@ -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
|
+
};
|
|
@@ -57,11 +57,14 @@ export interface SecretFinding {
|
|
|
57
57
|
/** openapi PersonId, unanchored: THE one copy of the pattern on the client.
|
|
58
58
|
* The person token below and `resolve_person`'s input check
|
|
59
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 `
|
|
61
|
-
|
|
60
|
+
* contract's PersonId is this one line (and the backend's `PERSON_ID` in
|
|
61
|
+
* common/person_values.py). A `con_` (a ULID) or `ctc_` is letters and
|
|
62
|
+
* digits; a `usr_` is shared-auth's `usr_<Cognito sub>`, a UUID with its
|
|
63
|
+
* hyphens (proposal item 22). */
|
|
64
|
+
export declare const PERSON_ID = "(?:(?:con|ctc)_[0-9A-Za-z]{10,40}|usr_[0-9A-Za-z-]{10,40})";
|
|
62
65
|
/** `con_…` (label), `ctc_…` (label), `usr_…` (label): a PersonId in
|
|
63
66
|
* backticks, with its role label after it. Skipped by the detector. */
|
|
64
|
-
export declare const PERSON_TOKEN = "`(?:con|ctc
|
|
67
|
+
export declare const PERSON_TOKEN = "`(?:(?:con|ctc)_[0-9A-Za-z]{10,40}|usr_[0-9A-Za-z-]{10,40})`(?:[ \\t]*\\([^()\\n]*\\))?";
|
|
65
68
|
/**
|
|
66
69
|
* The form an answer is keyed by: E.164 for a phone number, lower case for an
|
|
67
70
|
* email address. Throws when `value` is not one of `kind` (the server answers
|
|
@@ -9,7 +9,7 @@ var NATIONAL_PREFIX = "+44";
|
|
|
9
9
|
var W = "[\\p{L}\\p{N}_]";
|
|
10
10
|
var D = "\\p{Nd}";
|
|
11
11
|
var WORD_OR = (extra) => `[\\p{L}\\p{N}_${extra}]`;
|
|
12
|
-
var PERSON_ID = "(?:con|ctc
|
|
12
|
+
var PERSON_ID = "(?:(?:con|ctc)_[0-9A-Za-z]{10,40}|usr_[0-9A-Za-z-]{10,40})";
|
|
13
13
|
var PERSON_TOKEN = `\`${PERSON_ID}\`(?:[ \\t]*\\([^()\\n]*\\))?`;
|
|
14
14
|
var BEFORE = `(?<!${WORD_OR("+.:/\\\\\\-")})(?<!${D}[ -])`;
|
|
15
15
|
var AFTER = `(?!${WORD_OR(":/\\\\")}|\\.${D}|[ -]?${D}|-${W})`;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The team setup that `systemdox-mcp-server setup [--project <name>]
|
|
3
|
+
* [--dir <checkout>] [--yes] [--overwrite]` runs after it has written the MCP
|
|
4
|
+
* server into an AI tool's config (ADR-217 with Amendments 1 and 2,
|
|
5
|
+
* specs/changes/team-setup/contract.md "setup").
|
|
6
|
+
*
|
|
7
|
+
* 1. Check: `GET /team-setup` names the workspace; `claude --version` must be
|
|
8
|
+
* at least `minClaudeCodeVersion` (exit 3 otherwise, nothing changed).
|
|
9
|
+
* 2. Show, then ask once (y/N). On an update only what changed is shown, with
|
|
10
|
+
* a line diff for hooks and instructions. `--yes` without a terminal goes
|
|
11
|
+
* no further than what the member accepted before (exit 4 otherwise).
|
|
12
|
+
* 3. Do, in this order, stopping at a failed step: credentials.json; the team
|
|
13
|
+
* instructions (exit 2 if the member edited them, unless --overwrite); the
|
|
14
|
+
* import block in ~/.claude/CLAUDE.md; the marketplace in
|
|
15
|
+
* ~/.claude/settings.json; the project's `autoMemoryDirectory` in
|
|
16
|
+
* <checkout>/.claude/settings.local.json, then its sync.
|
|
17
|
+
* 4. Finish: the two steps a person must take in Claude Code.
|
|
18
|
+
*
|
|
19
|
+
* It never runs `claude plugin marketplace add` and never sets
|
|
20
|
+
* `enabledPlugins`. The key is written to credentials.json only.
|
|
21
|
+
*
|
|
22
|
+
* Exit codes: 0 done; 1 usage, no key or the API refused; 2 an edited team
|
|
23
|
+
* CLAUDE.md would be replaced; 3 Claude Code missing or too old; 4 not
|
|
24
|
+
* confirmed.
|
|
25
|
+
*/
|
|
26
|
+
import { SystemDoxApiClient } from "./lib/api-client.js";
|
|
27
|
+
import { type SyncClient } from "./sync-memory.js";
|
|
28
|
+
export declare const SETUP_USAGE = "Usage: systemdox-mcp-server setup [--project <name>] [--dir <checkout>] [--yes] [--overwrite]";
|
|
29
|
+
export declare const HEADERS_HELPER = "npx -y @systemdox/mcp-server marketplace-auth";
|
|
30
|
+
export interface TeamSetupArgs {
|
|
31
|
+
project?: string;
|
|
32
|
+
dir?: string;
|
|
33
|
+
yes: boolean;
|
|
34
|
+
overwrite: boolean;
|
|
35
|
+
}
|
|
36
|
+
export type TeamSetupClient = SyncClient & Pick<SystemDoxApiClient, "getPartVersion" | "getPlugin">;
|
|
37
|
+
export interface TeamSetupOptions extends TeamSetupArgs {
|
|
38
|
+
client: TeamSetupClient;
|
|
39
|
+
apiKey: string;
|
|
40
|
+
/** Where the key came from: a key read from credentials.json is not written back. */
|
|
41
|
+
keySource: "prompt" | "environment" | "credentials";
|
|
42
|
+
out: (line: string) => void;
|
|
43
|
+
/** Asks the member a question. Absent: nobody can answer. */
|
|
44
|
+
ask?: (question: string) => Promise<string>;
|
|
45
|
+
/** stdin is a terminal (`--yes` counts only when it is not). */
|
|
46
|
+
terminal: boolean;
|
|
47
|
+
/** The checkout when `--dir` is absent. */
|
|
48
|
+
cwd?: string;
|
|
49
|
+
/** `claude --version`'s output, or undefined when Claude Code is not found. */
|
|
50
|
+
claudeVersion?: () => Promise<string | undefined>;
|
|
51
|
+
now?: () => Date;
|
|
52
|
+
}
|
|
53
|
+
export declare function parseSetupArgs(argv: string[]): TeamSetupArgs;
|
|
54
|
+
/** `claude --version`'s output, or undefined when it cannot be run. On
|
|
55
|
+
* Windows through the shell, so `claude.cmd` (npm) and `claude.exe` both resolve. */
|
|
56
|
+
export declare function claudeCodeVersion(): Promise<string | undefined>;
|
|
57
|
+
/**
|
|
58
|
+
* `text` (a JSON object) with `keys` set to `value`, every other byte kept:
|
|
59
|
+
* an existing value is replaced in place, a missing member is added after the
|
|
60
|
+
* last one in the file's own indentation and line ending.
|
|
61
|
+
*/
|
|
62
|
+
export declare function setJsonMember(text: string, keys: string[], value: unknown): string;
|
|
63
|
+
export declare function importBlock(workspace: string, eol?: string): string;
|
|
64
|
+
/** `text` with the workspace's marked block added at the end, or replaced
|
|
65
|
+
* where it is; nothing outside the block changes. */
|
|
66
|
+
export declare function withImportBlock(text: string, workspace: string): string;
|
|
67
|
+
/**
|
|
68
|
+
* The team setup for the key's workspace. Returns the exit code; every line
|
|
69
|
+
* goes to `opts.out`.
|
|
70
|
+
*/
|
|
71
|
+
export declare function runTeamSetup(opts: TeamSetupOptions): Promise<number>;
|
|
72
|
+
/** The command, after the MCP server step: the key and URL that step took. */
|
|
73
|
+
export declare function runTeamSetupCommand(args: TeamSetupArgs, key: {
|
|
74
|
+
apiKey: string;
|
|
75
|
+
apiUrl: string;
|
|
76
|
+
}, io: {
|
|
77
|
+
out: (line: string) => void;
|
|
78
|
+
ask?: (question: string) => Promise<string>;
|
|
79
|
+
terminal: boolean;
|
|
80
|
+
}): Promise<number>;
|