phaedo-mcp 0.1.0
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/LICENSE +202 -0
- package/README.md +301 -0
- package/agreement.js +95 -0
- package/build-package.mjs +22 -0
- package/cache.js +76 -0
- package/consult-core.js +439 -0
- package/consult-policies.sample.json +36 -0
- package/consult-policy.js +136 -0
- package/consult.js +118 -0
- package/errors.js +23 -0
- package/fingerprint-source.js +71 -0
- package/hooks/session-start.mjs +48 -0
- package/install.mjs +368 -0
- package/installers/linux/install-phaedo-mcp.sh +32 -0
- package/installers/macos/Install Phaedo MCP.command +37 -0
- package/installers/windows/Install Phaedo MCP.bat +39 -0
- package/lib/node-vault.js +34 -0
- package/lib/phaedo-crypto.js +36 -0
- package/package.json +49 -0
- package/pair.js +29 -0
- package/pairing.js +184 -0
- package/pairing.sample.json +8 -0
- package/phone-source.js +141 -0
- package/policy-editor.html +146 -0
- package/projection.js +115 -0
- package/pull.js +26 -0
- package/receipts.js +211 -0
- package/sample-fingerprint.json +107 -0
- package/server.js +262 -0
- package/server.json +19 -0
- package/vendor/context-block.js +599 -0
- package/vendor/envelope.js +130 -0
- package/vendor/identity.js +122 -0
- package/vendor/kdf.js +114 -0
- package/vendor/protocol.js +56 -0
package/install.mjs
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// `phaedo-mcp` one-click installer — wires the Phaedo MCP server into the MCP
|
|
3
|
+
// clients you already have (Claude Desktop, Cursor, Claude Code), so injection
|
|
4
|
+
// "just works" without hand-editing JSON.
|
|
5
|
+
//
|
|
6
|
+
// What it does:
|
|
7
|
+
// 1. Find each MCP client's config file for this OS.
|
|
8
|
+
// 2. Merge a `phaedo` server entry into its `mcpServers` map (never clobbering
|
|
9
|
+
// your other servers or settings — the file is backed up first).
|
|
10
|
+
// 3. Point that entry at THIS checkout's server.js, run by THIS node binary
|
|
11
|
+
// (absolute path — a GUI-launched client often has no `node` on PATH).
|
|
12
|
+
// 4. Optionally run the one-time pairing (`--pair`) and print next steps.
|
|
13
|
+
//
|
|
14
|
+
// Pure Node builtins — runs before `npm install`. (Pairing/serving need the deps;
|
|
15
|
+
// the platform launchers in installers/ run `npm install` first.)
|
|
16
|
+
//
|
|
17
|
+
// Usage:
|
|
18
|
+
// node install.mjs configure every detected client
|
|
19
|
+
// node install.mjs --pair configure, then run the pairing flow
|
|
20
|
+
// node install.mjs --uninstall remove the phaedo entry from every client
|
|
21
|
+
// node install.mjs --dry-run show what would change, write nothing
|
|
22
|
+
// node install.mjs --only cursor restrict to one client id
|
|
23
|
+
// node install.mjs --help
|
|
24
|
+
|
|
25
|
+
import { readFileSync, writeFileSync, existsSync, copyFileSync, mkdirSync } from 'fs';
|
|
26
|
+
import { dirname, join, resolve } from 'path';
|
|
27
|
+
import { homedir } from 'os';
|
|
28
|
+
import { fileURLToPath } from 'url';
|
|
29
|
+
import { spawnSync } from 'child_process';
|
|
30
|
+
|
|
31
|
+
const BASE_DIR = dirname(fileURLToPath(import.meta.url));
|
|
32
|
+
const SERVER_PATH = join(BASE_DIR, 'server.js');
|
|
33
|
+
const HOOK_PATH = join(BASE_DIR, 'hooks', 'session-start.mjs');
|
|
34
|
+
const ENTRY_KEY = 'phaedo';
|
|
35
|
+
const HOOK_MARKER = 'hooks/session-start.mjs'; // identifies our SessionStart entry
|
|
36
|
+
|
|
37
|
+
// ── Client registry ──────────────────────────────────────────────────────────
|
|
38
|
+
// Every supported client stores MCP servers as a top-level `mcpServers` object,
|
|
39
|
+
// so one merge shape covers them all. `configPath` is resolved per-OS from an
|
|
40
|
+
// injectable env (so tests can drive every platform without touching real files).
|
|
41
|
+
export function detectClients({ platform = process.platform, home = homedir(), env = process.env } = {}) {
|
|
42
|
+
const appData = env.APPDATA || join(home, 'AppData', 'Roaming');
|
|
43
|
+
const macSupport = join(home, 'Library', 'Application Support');
|
|
44
|
+
const xdgConfig = env.XDG_CONFIG_HOME || join(home, '.config');
|
|
45
|
+
|
|
46
|
+
const claudeDesktop = {
|
|
47
|
+
win32: join(appData, 'Claude', 'claude_desktop_config.json'),
|
|
48
|
+
darwin: join(macSupport, 'Claude', 'claude_desktop_config.json'),
|
|
49
|
+
linux: join(xdgConfig, 'Claude', 'claude_desktop_config.json'),
|
|
50
|
+
}[platform];
|
|
51
|
+
|
|
52
|
+
const clients = [
|
|
53
|
+
{ id: 'claude-desktop', name: 'Claude Desktop', configPath: claudeDesktop, createIfMissing: true,
|
|
54
|
+
restartHint: 'Quit and reopen Claude Desktop.' },
|
|
55
|
+
// Cursor: global MCP config in ~/.cursor/mcp.json.
|
|
56
|
+
{ id: 'cursor', name: 'Cursor', configPath: join(home, '.cursor', 'mcp.json'), createIfMissing: true,
|
|
57
|
+
restartHint: 'Restart Cursor (or toggle the MCP server in Settings → MCP).' },
|
|
58
|
+
// Claude Code: the CLI manages ~/.claude.json. We only merge into it when it
|
|
59
|
+
// already exists — never hand-create that large, CLI-owned file.
|
|
60
|
+
{ id: 'claude-code', name: 'Claude Code', configPath: join(home, '.claude.json'), createIfMissing: false,
|
|
61
|
+
restartHint: 'Start a new Claude Code session (or run `/mcp` to confirm).' },
|
|
62
|
+
];
|
|
63
|
+
return clients.filter((c) => Boolean(c.configPath));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// The server entry written into a client's mcpServers map. Uses the absolute
|
|
67
|
+
// node binary (process.execPath) because GUI-launched clients often lack a PATH
|
|
68
|
+
// that resolves bare `node`. `nodePath`/`serverPath`/`env` injectable for tests.
|
|
69
|
+
export function serverEntry({ nodePath = process.execPath, serverPath = SERVER_PATH, env } = {}) {
|
|
70
|
+
const entry = { command: nodePath, args: [serverPath] };
|
|
71
|
+
if (env && Object.keys(env).length) entry.env = env;
|
|
72
|
+
return entry;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── Consult model judge config (on by default) ────────────────────────────────
|
|
76
|
+
// The MCP server runs the consult model judge when PHAEDO_CONSULT_MODEL is set;
|
|
77
|
+
// it falls back to the local rule engine if the model/Ollama isn't there, so
|
|
78
|
+
// defaulting it ON is safe. We write a LOCAL endpoint only (privacy-clean); a
|
|
79
|
+
// frontier (off-device) judge is intentionally not wired here.
|
|
80
|
+
const DEFAULT_CONSULT_MODEL = 'llama3.1:8b';
|
|
81
|
+
const DEFAULT_CONSULT_URL = 'http://localhost:11434';
|
|
82
|
+
|
|
83
|
+
export function buildConsultEnv({ model = DEFAULT_CONSULT_MODEL, url = DEFAULT_CONSULT_URL, disabled = false } = {}) {
|
|
84
|
+
if (disabled) return {};
|
|
85
|
+
return { PHAEDO_CONSULT_MODEL: model, PHAEDO_CONSULT_MODEL_URL: url };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Probe local Ollama so "on by default" works with whatever model the user has.
|
|
89
|
+
// Picks the first installed general-purpose model (skips the phaedo extraction
|
|
90
|
+
// fine-tune). Falls back to the default string + reachable:false on any failure.
|
|
91
|
+
export async function detectConsultModel({ url = DEFAULT_CONSULT_URL, fallback = DEFAULT_CONSULT_MODEL, fetchImpl = globalThis.fetch } = {}) {
|
|
92
|
+
try {
|
|
93
|
+
const res = await fetchImpl(`${url.replace(/\/$/, '')}/api/tags`, { signal: AbortSignal.timeout(1500) });
|
|
94
|
+
if (!res.ok) return { model: fallback, reachable: false, hadModels: false };
|
|
95
|
+
const data = await res.json();
|
|
96
|
+
const names = (data.models || []).map((m) => m.name || m.model).filter(Boolean);
|
|
97
|
+
const general = names.find((n) => !/phaedo|extract/i.test(n));
|
|
98
|
+
return { model: general || fallback, reachable: true, hadModels: names.length > 0, picked: !!general };
|
|
99
|
+
} catch {
|
|
100
|
+
return { model: fallback, reachable: false, hadModels: false };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function readConfig(path) {
|
|
105
|
+
// Returns { config, existed }. Throws on malformed JSON so the caller can
|
|
106
|
+
// refuse to overwrite a file it can't safely parse (never destroy hand-edits).
|
|
107
|
+
if (!existsSync(path)) return { config: {}, existed: false };
|
|
108
|
+
const raw = readFileSync(path, 'utf8');
|
|
109
|
+
if (raw.trim() === '') return { config: {}, existed: true };
|
|
110
|
+
try {
|
|
111
|
+
const config = JSON.parse(raw);
|
|
112
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) {
|
|
113
|
+
throw new Error('top-level value is not a JSON object');
|
|
114
|
+
}
|
|
115
|
+
return { config, existed: true };
|
|
116
|
+
} catch (err) {
|
|
117
|
+
throw new Error(`malformed JSON (${err.message})`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Merge our entry under mcpServers.phaedo, preserving every other server and
|
|
122
|
+
// every other top-level key. Returns { config, changed }.
|
|
123
|
+
export function mergeServerEntry(config, entry, key = ENTRY_KEY) {
|
|
124
|
+
const next = { ...config, mcpServers: { ...(config.mcpServers || {}) } };
|
|
125
|
+
const before = JSON.stringify(next.mcpServers[key] || null);
|
|
126
|
+
next.mcpServers[key] = entry;
|
|
127
|
+
return { config: next, changed: JSON.stringify(entry) !== before };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Remove our entry. Returns { config, changed }.
|
|
131
|
+
export function removeServerEntry(config, key = ENTRY_KEY) {
|
|
132
|
+
if (!config.mcpServers || !(key in config.mcpServers)) return { config, changed: false };
|
|
133
|
+
const servers = { ...config.mcpServers };
|
|
134
|
+
delete servers[key];
|
|
135
|
+
return { config: { ...config, mcpServers: servers }, changed: true };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ── Claude Code SessionStart hook (--code-hook) ───────────────────────────────
|
|
139
|
+
// True auto-injection for Claude Code: ~/.claude/settings.json runs our hook
|
|
140
|
+
// script at every session start. Merged non-destructively into settings.hooks.
|
|
141
|
+
export function claudeSettingsPath({ home = homedir() } = {}) {
|
|
142
|
+
return join(home, '.claude', 'settings.json');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function hookCommand({ nodePath = process.execPath, hookPath = HOOK_PATH } = {}) {
|
|
146
|
+
// Quote both — node path / checkout path may contain spaces; the hook runs in a shell.
|
|
147
|
+
return `"${nodePath}" "${hookPath}"`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const isPhaedoHookEntry = (e) =>
|
|
151
|
+
e && Array.isArray(e.hooks) &&
|
|
152
|
+
e.hooks.some((h) => typeof h.command === 'string' && h.command.includes(HOOK_MARKER));
|
|
153
|
+
|
|
154
|
+
export function mergeSessionStartHook(settings, command) {
|
|
155
|
+
const next = { ...settings, hooks: { ...(settings.hooks || {}) } };
|
|
156
|
+
const prior = Array.isArray(next.hooks.SessionStart) ? next.hooks.SessionStart : [];
|
|
157
|
+
const others = prior.filter((e) => !isPhaedoHookEntry(e)); // drop a stale phaedo entry (path refresh)
|
|
158
|
+
const existing = prior.find(isPhaedoHookEntry);
|
|
159
|
+
const already = !!existing && existing.hooks.some((h) => h.command === command);
|
|
160
|
+
next.hooks.SessionStart = [...others, { hooks: [{ type: 'command', command }] }];
|
|
161
|
+
return { settings: next, changed: !already };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function removeSessionStartHook(settings) {
|
|
165
|
+
if (!settings.hooks || !Array.isArray(settings.hooks.SessionStart)) return { settings, changed: false };
|
|
166
|
+
const prior = settings.hooks.SessionStart;
|
|
167
|
+
const others = prior.filter((e) => !isPhaedoHookEntry(e));
|
|
168
|
+
if (others.length === prior.length) return { settings, changed: false };
|
|
169
|
+
const hooks = { ...settings.hooks };
|
|
170
|
+
if (others.length) hooks.SessionStart = others; else delete hooks.SessionStart;
|
|
171
|
+
const next = { ...settings, hooks };
|
|
172
|
+
if (Object.keys(next.hooks).length === 0) delete next.hooks;
|
|
173
|
+
return { settings: next, changed: true };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// action: 'install' | 'uninstall'. Returns a result record. settings.json is a
|
|
177
|
+
// small, safe-to-create file (unlike the CLI-owned ~/.claude.json).
|
|
178
|
+
export function applyCodeHook({ action, dryRun = false, home = homedir() } = {}) {
|
|
179
|
+
const path = claudeSettingsPath({ home });
|
|
180
|
+
const name = 'Claude Code hook';
|
|
181
|
+
let read;
|
|
182
|
+
try {
|
|
183
|
+
read = readConfig(path);
|
|
184
|
+
} catch (err) {
|
|
185
|
+
return { name, status: 'error', detail: `skipped — ${err.message}; not touched` };
|
|
186
|
+
}
|
|
187
|
+
if (action === 'uninstall') {
|
|
188
|
+
if (!read.existed) return { name, status: 'absent', detail: 'no settings.json' };
|
|
189
|
+
const { settings, changed } = removeSessionStartHook(read.config);
|
|
190
|
+
if (!changed) return { name, status: 'absent', detail: 'no phaedo hook to remove' };
|
|
191
|
+
if (!dryRun) { backup(path); writeConfig(path, settings); }
|
|
192
|
+
return { name, status: dryRun ? 'would-remove' : 'removed', detail: path };
|
|
193
|
+
}
|
|
194
|
+
const { settings, changed } = mergeSessionStartHook(read.config, hookCommand());
|
|
195
|
+
if (!changed) return { name, status: 'unchanged', detail: 'already configured' };
|
|
196
|
+
if (!dryRun) { if (read.existed) backup(path); writeConfig(path, settings); }
|
|
197
|
+
return { name, status: dryRun ? 'would-write' : 'configured', detail: path };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function backup(path) {
|
|
201
|
+
const bak = `${path}.phaedo.bak`;
|
|
202
|
+
copyFileSync(path, bak);
|
|
203
|
+
return bak;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function writeConfig(path, config) {
|
|
207
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
208
|
+
writeFileSync(path, JSON.stringify(config, null, 2) + '\n', 'utf8');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ── Per-client apply ───────────────────────────────────────────────────────-
|
|
212
|
+
// action: 'install' | 'uninstall'. Returns a result record for reporting.
|
|
213
|
+
export function applyToClient(client, { action, entry, dryRun = false } = {}) {
|
|
214
|
+
const { configPath } = client;
|
|
215
|
+
let read;
|
|
216
|
+
try {
|
|
217
|
+
read = readConfig(configPath);
|
|
218
|
+
} catch (err) {
|
|
219
|
+
return { ...client, status: 'error', detail: `skipped — ${err.message}; not touched` };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (action === 'uninstall') {
|
|
223
|
+
if (!read.existed) return { ...client, status: 'absent', detail: 'no config file' };
|
|
224
|
+
const { config, changed } = removeServerEntry(read.config);
|
|
225
|
+
if (!changed) return { ...client, status: 'absent', detail: 'no phaedo entry to remove' };
|
|
226
|
+
if (!dryRun) { backup(configPath); writeConfig(configPath, config); }
|
|
227
|
+
return { ...client, status: dryRun ? 'would-remove' : 'removed', detail: configPath };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// install
|
|
231
|
+
if (!read.existed && !client.createIfMissing) {
|
|
232
|
+
return { ...client, status: 'absent', detail: 'not installed (config file not found)' };
|
|
233
|
+
}
|
|
234
|
+
const { config, changed } = mergeServerEntry(read.config, entry);
|
|
235
|
+
if (!changed) return { ...client, status: 'unchanged', detail: 'already configured' };
|
|
236
|
+
if (!dryRun) {
|
|
237
|
+
if (read.existed) backup(configPath);
|
|
238
|
+
writeConfig(configPath, config);
|
|
239
|
+
}
|
|
240
|
+
return { ...client, status: dryRun ? 'would-write' : 'configured', detail: configPath };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function parseArgs(argv) {
|
|
244
|
+
const opts = { pair: false, uninstall: false, dryRun: false, only: null, codeHook: false, help: false };
|
|
245
|
+
for (let i = 0; i < argv.length; i++) {
|
|
246
|
+
const a = argv[i];
|
|
247
|
+
if (a === '--pair') opts.pair = true;
|
|
248
|
+
else if (a === '--uninstall') opts.uninstall = true;
|
|
249
|
+
else if (a === '--dry-run') opts.dryRun = true;
|
|
250
|
+
else if (a === '--only') opts.only = argv[++i];
|
|
251
|
+
else if (a === '--code-hook') opts.codeHook = true;
|
|
252
|
+
else if (a === '--consult-model') opts.consultModel = argv[++i];
|
|
253
|
+
else if (a === '--consult-url') opts.consultUrl = argv[++i];
|
|
254
|
+
else if (a === '--no-model-judge') opts.noModelJudge = true;
|
|
255
|
+
else if (a === '--help' || a === '-h') opts.help = true;
|
|
256
|
+
else throw new Error(`unknown option: ${a}`);
|
|
257
|
+
}
|
|
258
|
+
return opts;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const HELP = `Phaedo MCP installer
|
|
262
|
+
|
|
263
|
+
node install.mjs configure every detected MCP client
|
|
264
|
+
node install.mjs --pair configure, then run the one-time pairing
|
|
265
|
+
node install.mjs --code-hook also add the Claude Code SessionStart hook
|
|
266
|
+
(true auto-injection; ~/.claude/settings.json)
|
|
267
|
+
node install.mjs --uninstall remove the phaedo entry (and hook) everywhere
|
|
268
|
+
node install.mjs --dry-run preview changes, write nothing
|
|
269
|
+
node install.mjs --only <id> one client only: claude-desktop | cursor | claude-code
|
|
270
|
+
|
|
271
|
+
Consult model judge (ON by default, local Ollama, falls back to the rule engine):
|
|
272
|
+
node install.mjs --consult-model <id> pin the model (e.g. llama3.1:8b)
|
|
273
|
+
node install.mjs --consult-url <url> endpoint (default http://localhost:11434)
|
|
274
|
+
node install.mjs --no-model-judge rule engine only (no model env)
|
|
275
|
+
`;
|
|
276
|
+
|
|
277
|
+
function runPairing() {
|
|
278
|
+
if (!existsSync(join(BASE_DIR, 'node_modules'))) {
|
|
279
|
+
console.log('\n⚠ Dependencies not installed — run `npm install` in this folder first,');
|
|
280
|
+
console.log(' then `npm run pair`. (The platform launcher does this for you.)');
|
|
281
|
+
return 1;
|
|
282
|
+
}
|
|
283
|
+
console.log('\n— Pairing with your phone (one time) —\n');
|
|
284
|
+
const r = spawnSync(process.execPath, [join(BASE_DIR, 'pair.js')], { stdio: 'inherit' });
|
|
285
|
+
return r.status ?? 1;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function main(argv) {
|
|
289
|
+
let opts;
|
|
290
|
+
try { opts = parseArgs(argv); }
|
|
291
|
+
catch (err) { console.error(err.message + '\n\n' + HELP); return 2; }
|
|
292
|
+
if (opts.help) { console.log(HELP); return 0; }
|
|
293
|
+
|
|
294
|
+
let clients = detectClients();
|
|
295
|
+
if (opts.only) {
|
|
296
|
+
clients = clients.filter((c) => c.id === opts.only);
|
|
297
|
+
if (!clients.length) { console.error(`No such client id: ${opts.only}`); return 2; }
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const action = opts.uninstall ? 'uninstall' : 'install';
|
|
301
|
+
|
|
302
|
+
// Consult model judge: ON by default. Pin it if --consult-model is given,
|
|
303
|
+
// otherwise probe local Ollama and pick an installed general model. Off with
|
|
304
|
+
// --no-model-judge. Always falls back to the rule engine at runtime, so a
|
|
305
|
+
// missing model never breaks consultation.
|
|
306
|
+
let env = {}, consultNote = '';
|
|
307
|
+
if (action === 'install' && !opts.noModelJudge) {
|
|
308
|
+
const url = opts.consultUrl || DEFAULT_CONSULT_URL;
|
|
309
|
+
if (opts.consultModel) {
|
|
310
|
+
env = buildConsultEnv({ model: opts.consultModel, url });
|
|
311
|
+
consultNote = `model judge ON → ${opts.consultModel} @ ${url} (pinned)`;
|
|
312
|
+
} else {
|
|
313
|
+
const d = await detectConsultModel({ url });
|
|
314
|
+
env = buildConsultEnv({ model: d.model, url });
|
|
315
|
+
consultNote = d.reachable
|
|
316
|
+
? `model judge ON → ${d.model} @ ${url} (${d.picked ? 'detected in Ollama' : 'default; `ollama pull ' + d.model + '`'})`
|
|
317
|
+
: `model judge ON → ${d.model} @ ${url} (Ollama not detected — falls back to the rule engine until it's running)`;
|
|
318
|
+
}
|
|
319
|
+
} else if (action === 'install') {
|
|
320
|
+
consultNote = 'model judge OFF (rule engine only)';
|
|
321
|
+
}
|
|
322
|
+
const entry = serverEntry({ env });
|
|
323
|
+
console.log(`Phaedo MCP — ${action}${opts.dryRun ? ' (dry run)' : ''}`);
|
|
324
|
+
console.log(`Server: ${SERVER_PATH}`);
|
|
325
|
+
console.log(`Node: ${process.execPath}`);
|
|
326
|
+
if (consultNote) console.log(`Consult: ${consultNote}`);
|
|
327
|
+
console.log('');
|
|
328
|
+
|
|
329
|
+
const results = clients.map((c) => applyToClient(c, { action, entry, dryRun: opts.dryRun }));
|
|
330
|
+
// The Claude Code SessionStart hook: opt-in on install (--code-hook), always
|
|
331
|
+
// cleaned up on uninstall.
|
|
332
|
+
if (opts.codeHook || action === 'uninstall') {
|
|
333
|
+
results.push(applyCodeHook({ action, dryRun: opts.dryRun }));
|
|
334
|
+
}
|
|
335
|
+
const touched = [];
|
|
336
|
+
for (const r of results) {
|
|
337
|
+
const mark = { configured: '✓', removed: '✓', 'would-write': '·', 'would-remove': '·',
|
|
338
|
+
unchanged: '=', absent: '–', error: '✗' }[r.status] || '?';
|
|
339
|
+
console.log(` ${mark} ${r.name.padEnd(16)} ${r.detail}`);
|
|
340
|
+
if (r.status === 'configured' || r.status === 'would-write') touched.push(r);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (action === 'install') {
|
|
344
|
+
if (!touched.length) {
|
|
345
|
+
console.log('\nNo clients were updated. Are Claude Desktop / Cursor / Claude Code installed?');
|
|
346
|
+
} else if (!opts.dryRun) {
|
|
347
|
+
console.log('\nNext:');
|
|
348
|
+
console.log(' 1. Pair with your phone: npm run pair (or re-run with --pair)');
|
|
349
|
+
console.log(' 2. Restart any client that was already open:');
|
|
350
|
+
for (const r of touched) console.log(` • ${r.name}: ${r.restartHint}`);
|
|
351
|
+
console.log(' 3. Inject your fingerprint at session start:');
|
|
352
|
+
console.log(' • any client: run the `/phaedo` prompt (one click), or call');
|
|
353
|
+
console.log(' the `phaedo_request_injection` tool.');
|
|
354
|
+
if (opts.codeHook) {
|
|
355
|
+
console.log(' • Claude Code: AUTOMATIC — the SessionStart hook injects it for you.');
|
|
356
|
+
} else {
|
|
357
|
+
console.log(' • Claude Code: `--code-hook` makes it automatic at every session start.');
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
if (opts.pair && !opts.dryRun && touched.length) return runPairing();
|
|
361
|
+
}
|
|
362
|
+
return 0;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Only run when invoked directly (so test-install.mjs can import the helpers).
|
|
366
|
+
if (resolve(process.argv[1] || '') === resolve(fileURLToPath(import.meta.url))) {
|
|
367
|
+
main(process.argv.slice(2)).then((code) => process.exit(code));
|
|
368
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Run this to install Phaedo into your AI apps (Claude Desktop, Cursor, Claude
|
|
3
|
+
# Code) and pair with your phone: bash install-phaedo-mcp.sh
|
|
4
|
+
# Safe to run again any time — it updates, never clobbers.
|
|
5
|
+
set -e
|
|
6
|
+
|
|
7
|
+
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
8
|
+
MCP_DIR="$(cd "$HERE/../.." && pwd)"
|
|
9
|
+
cd "$MCP_DIR"
|
|
10
|
+
|
|
11
|
+
echo "Phaedo MCP installer"
|
|
12
|
+
echo "Folder: $MCP_DIR"
|
|
13
|
+
echo
|
|
14
|
+
|
|
15
|
+
if ! command -v node >/dev/null 2>&1; then
|
|
16
|
+
echo "✗ Node.js isn't installed. Install it from https://nodejs.org or your package"
|
|
17
|
+
echo " manager (e.g. 'sudo apt install nodejs npm'), then run this again."
|
|
18
|
+
exit 1
|
|
19
|
+
fi
|
|
20
|
+
echo "Using node: $(node --version) at $(command -v node)"
|
|
21
|
+
echo
|
|
22
|
+
|
|
23
|
+
if [ ! -d node_modules ]; then
|
|
24
|
+
echo "Installing dependencies (one time)…"
|
|
25
|
+
npm install --omit=dev
|
|
26
|
+
echo
|
|
27
|
+
fi
|
|
28
|
+
|
|
29
|
+
node install.mjs --pair
|
|
30
|
+
|
|
31
|
+
echo
|
|
32
|
+
echo "Done. If a client was already open, restart it so it picks up Phaedo."
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Double-click this file to install Phaedo into your AI apps (Claude Desktop,
|
|
3
|
+
# Cursor, Claude Code) and pair with your phone. It opens in Terminal and runs
|
|
4
|
+
# the installer for you. Safe to run again any time — it updates, never clobbers.
|
|
5
|
+
set -e
|
|
6
|
+
|
|
7
|
+
# Resolve the mcp/ folder relative to this script (installers/macos/ -> mcp/).
|
|
8
|
+
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
9
|
+
MCP_DIR="$(cd "$HERE/../.." && pwd)"
|
|
10
|
+
cd "$MCP_DIR"
|
|
11
|
+
|
|
12
|
+
echo "Phaedo MCP installer"
|
|
13
|
+
echo "Folder: $MCP_DIR"
|
|
14
|
+
echo
|
|
15
|
+
|
|
16
|
+
if ! command -v node >/dev/null 2>&1; then
|
|
17
|
+
echo "✗ Node.js isn't installed."
|
|
18
|
+
echo " Install it from https://nodejs.org (the LTS button), then double-click this again."
|
|
19
|
+
echo
|
|
20
|
+
read -n 1 -s -r -p "Press any key to close."
|
|
21
|
+
exit 1
|
|
22
|
+
fi
|
|
23
|
+
echo "Using node: $(node --version) at $(command -v node)"
|
|
24
|
+
echo
|
|
25
|
+
|
|
26
|
+
if [ ! -d node_modules ]; then
|
|
27
|
+
echo "Installing dependencies (one time)…"
|
|
28
|
+
npm install --omit=dev
|
|
29
|
+
echo
|
|
30
|
+
fi
|
|
31
|
+
|
|
32
|
+
# Configure every detected client, then run the one-time phone pairing.
|
|
33
|
+
node install.mjs --pair
|
|
34
|
+
|
|
35
|
+
echo
|
|
36
|
+
echo "Done. If a client was already open, restart it so it picks up Phaedo."
|
|
37
|
+
read -n 1 -s -r -p "Press any key to close."
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
@echo off
|
|
2
|
+
REM Double-click to install Phaedo into your AI apps (Claude Desktop, Cursor,
|
|
3
|
+
REM Claude Code) and pair with your phone. Safe to run again any time.
|
|
4
|
+
setlocal
|
|
5
|
+
|
|
6
|
+
REM Resolve the mcp\ folder relative to this script (installers\windows\ -> mcp\).
|
|
7
|
+
set "HERE=%~dp0"
|
|
8
|
+
pushd "%HERE%..\.." || (echo Could not find the mcp folder & pause & exit /b 1)
|
|
9
|
+
|
|
10
|
+
echo Phaedo MCP installer
|
|
11
|
+
echo Folder: %CD%
|
|
12
|
+
echo.
|
|
13
|
+
|
|
14
|
+
where node >nul 2>nul
|
|
15
|
+
if errorlevel 1 (
|
|
16
|
+
echo Node.js isn't installed.
|
|
17
|
+
echo Install it from https://nodejs.org ^(the LTS button^), then double-click this again.
|
|
18
|
+
echo.
|
|
19
|
+
pause
|
|
20
|
+
popd
|
|
21
|
+
exit /b 1
|
|
22
|
+
)
|
|
23
|
+
for /f "delims=" %%v in ('node --version') do echo Using node %%v
|
|
24
|
+
echo.
|
|
25
|
+
|
|
26
|
+
if not exist node_modules (
|
|
27
|
+
echo Installing dependencies ^(one time^)...
|
|
28
|
+
call npm install --omit=dev
|
|
29
|
+
echo.
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
REM Configure every detected client, then run the one-time phone pairing.
|
|
33
|
+
node install.mjs --pair
|
|
34
|
+
|
|
35
|
+
echo.
|
|
36
|
+
echo Done. If a client was already open, restart it so it picks up Phaedo.
|
|
37
|
+
pause
|
|
38
|
+
popd
|
|
39
|
+
endlocal
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Minimal `globalThis.phaedoVault` shim for Node, so the repo-root identity.js
|
|
2
|
+
// (which reads/writes globalThis.phaedoVault) can persist the MCP server's
|
|
3
|
+
// long-term X25519 identity outside a browser.
|
|
4
|
+
//
|
|
5
|
+
// D2 (signed off 2026-06-05): a restricted-perms (0600) JSON file. The
|
|
6
|
+
// `privateKeyJwk` it holds is a long-term secret — same class as `pair_key` in
|
|
7
|
+
// pairing.json. OS keychain is the documented upgrade path.
|
|
8
|
+
//
|
|
9
|
+
// identity.js treats a falsy read() as "vault locked" and throws; this shim
|
|
10
|
+
// always returns a truthy object (empty {} when the file doesn't exist yet), so
|
|
11
|
+
// getOrCreate() generates a fresh identity and update()s it on first use.
|
|
12
|
+
|
|
13
|
+
import { readFileSync, writeFileSync, existsSync, chmodSync } from 'fs';
|
|
14
|
+
import { resolve } from 'path';
|
|
15
|
+
|
|
16
|
+
export function installNodeVault(filePath) {
|
|
17
|
+
const path = resolve(filePath);
|
|
18
|
+
|
|
19
|
+
const read = () => {
|
|
20
|
+
if (!existsSync(path)) return {};
|
|
21
|
+
try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return {}; }
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
globalThis.phaedoVault = {
|
|
25
|
+
async read() { return read(); },
|
|
26
|
+
async update(patch) {
|
|
27
|
+
const next = { ...read(), ...patch };
|
|
28
|
+
writeFileSync(path, JSON.stringify(next, null, 2), { mode: 0o600 });
|
|
29
|
+
try { chmodSync(path, 0o600); } catch {} // enforce perms if the file pre-existed
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
return path;
|
|
34
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Loads the repo-root Phaedo crypto modules into Node and exposes them.
|
|
2
|
+
//
|
|
3
|
+
// They're classic scripts that set globalThis.Phaedo.* and (verified) load in
|
|
4
|
+
// Node with zero shims — Node has global crypto.subtle / TextEncoder / btoa.
|
|
5
|
+
// Load order matters: protocol → envelope → kdf → identity (envelope.js and
|
|
6
|
+
// kdf.js read Phaedo.Protocol at load).
|
|
7
|
+
//
|
|
8
|
+
// Option B (1b-i) uses only the STATELESS path: Kdf.* + Identity.generateEphemeral
|
|
9
|
+
// / deriveEphemeralShared + Envelope.unwrap. The vault-backed Identity functions
|
|
10
|
+
// (getOrCreate/getPublicKey) are NOT called here, so no phaedoVault shim is needed.
|
|
11
|
+
|
|
12
|
+
import { createRequire } from 'module';
|
|
13
|
+
import { existsSync } from 'fs';
|
|
14
|
+
import { fileURLToPath } from 'url';
|
|
15
|
+
import { dirname, resolve } from 'path';
|
|
16
|
+
|
|
17
|
+
const require = createRequire(import.meta.url);
|
|
18
|
+
const HERE = dirname(fileURLToPath(import.meta.url)); // mcp/lib
|
|
19
|
+
const VENDOR = resolve(HERE, '..', 'vendor'); // mcp/vendor — bundled (npm package)
|
|
20
|
+
const ROOT = resolve(HERE, '..', '..'); // repo root — dev
|
|
21
|
+
|
|
22
|
+
// Prefer the bundled copies (self-contained package) over the repo root (dev).
|
|
23
|
+
// Load order matters: protocol → envelope → kdf → identity.
|
|
24
|
+
for (const f of ['protocol.js', 'envelope.js', 'kdf.js', 'identity.js']) {
|
|
25
|
+
const vf = resolve(VENDOR, f);
|
|
26
|
+
require(existsSync(vf) ? vf : resolve(ROOT, f));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const Phaedo = globalThis.Phaedo;
|
|
30
|
+
if (!Phaedo?.Envelope || !Phaedo?.Kdf || !Phaedo?.Identity || !Phaedo?.Protocol) {
|
|
31
|
+
throw new Error('Phaedo crypto modules did not load (Protocol/Envelope/Kdf/Identity).');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// base64url <-> Uint8Array (Node-native), matching the extension's wire encoding.
|
|
35
|
+
export const b64uEnc = (u8) => Buffer.from(u8).toString('base64url');
|
|
36
|
+
export const b64uDec = (s) => new Uint8Array(Buffer.from(s, 'base64url'));
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "phaedo-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"description": "Phaedo MCP server — inject your cognitive fingerprint into any MCP client, and consult your decision pattern at agent decision points (docs/protocol/mcp-binding.md).",
|
|
8
|
+
"mcpName": "io.github.galavoxx/phaedo",
|
|
9
|
+
"homepage": "https://getphaedo.com",
|
|
10
|
+
"repository": { "type": "git", "url": "https://github.com/galavoxx/phaedo", "directory": "mcp" },
|
|
11
|
+
"keywords": ["mcp", "model-context-protocol", "personalization", "fingerprint", "consultation", "agent"],
|
|
12
|
+
"bin": { "phaedo-mcp": "server.js" },
|
|
13
|
+
"files": [
|
|
14
|
+
"server.js", "server.json", "fingerprint-source.js", "projection.js", "errors.js",
|
|
15
|
+
"consult.js", "consult-core.js", "consult-policy.js", "receipts.js", "agreement.js", "phone-source.js",
|
|
16
|
+
"cache.js", "pairing.js", "pair.js", "pull.js",
|
|
17
|
+
"install.mjs", "build-package.mjs", "vendor/", "lib/", "hooks/", "installers/",
|
|
18
|
+
"policy-editor.html", "sample-fingerprint.json", "pairing.sample.json",
|
|
19
|
+
"consult-policies.sample.json", "README.md"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"start": "node server.js",
|
|
23
|
+
"build": "node build-package.mjs",
|
|
24
|
+
"prepack": "node build-package.mjs",
|
|
25
|
+
"setup": "node install.mjs",
|
|
26
|
+
"setup:pair": "node install.mjs --pair",
|
|
27
|
+
"setup:code-hook": "node install.mjs --code-hook",
|
|
28
|
+
"unsetup": "node install.mjs --uninstall",
|
|
29
|
+
"pair": "node pair.js",
|
|
30
|
+
"pull": "node pull.js",
|
|
31
|
+
"test": "node test-smoke.mjs && node test-phone-boundary.mjs && node test-cache.mjs && node test-pairing.mjs && node test-revoke.mjs && node test-install.mjs && node test-consult.mjs && node test-consult-policy.mjs && node test-receipts.mjs && node test-agreement.mjs",
|
|
32
|
+
"receipts": "node receipts.js list",
|
|
33
|
+
"agreement": "node agreement.js",
|
|
34
|
+
"smoke": "node test-smoke.mjs",
|
|
35
|
+
"smoke:phone": "node test-phone-boundary.mjs",
|
|
36
|
+
"smoke:cache": "node test-cache.mjs",
|
|
37
|
+
"smoke:pair": "node test-pairing.mjs",
|
|
38
|
+
"smoke:revoke": "node test-revoke.mjs",
|
|
39
|
+
"smoke:install": "node test-install.mjs",
|
|
40
|
+
"smoke:consult": "node test-consult.mjs",
|
|
41
|
+
"smoke:policy": "node test-consult-policy.mjs",
|
|
42
|
+
"smoke:receipts": "node test-receipts.mjs",
|
|
43
|
+
"smoke:agreement": "node test-agreement.mjs"
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
47
|
+
"zod": "^3.23.0"
|
|
48
|
+
}
|
|
49
|
+
}
|
package/pair.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// `phaedo-mcp pair` — pair this MCP server with the phone vault as its own
|
|
3
|
+
// SAS-approved, revocable client (plan: mcp-revocable-approval-plan.md, P3).
|
|
4
|
+
//
|
|
5
|
+
// Writes the server's long-term identity to identity.json (0600) and the
|
|
6
|
+
// resulting pairing record to pairing.json (0600) — the file phone-source.js
|
|
7
|
+
// reads. Run once; the server then authorizes autonomously.
|
|
8
|
+
//
|
|
9
|
+
// node pair.js
|
|
10
|
+
// PHAEDO_IDENTITY=/secure/id.json PHAEDO_PAIRING=/secure/pairing.json node pair.js
|
|
11
|
+
|
|
12
|
+
import { fileURLToPath } from 'url';
|
|
13
|
+
import { dirname, resolve } from 'path';
|
|
14
|
+
import { pair } from './pairing.js';
|
|
15
|
+
|
|
16
|
+
const BASE_DIR = dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const identityPath = process.env.PHAEDO_IDENTITY
|
|
18
|
+
? resolve(process.cwd(), process.env.PHAEDO_IDENTITY)
|
|
19
|
+
: resolve(BASE_DIR, 'identity.json');
|
|
20
|
+
const pairingPath = process.env.PHAEDO_PAIRING
|
|
21
|
+
? resolve(process.cwd(), process.env.PHAEDO_PAIRING)
|
|
22
|
+
: resolve(BASE_DIR, 'pairing.json');
|
|
23
|
+
|
|
24
|
+
pair({ identityPath, pairingPath, log: (m) => process.stdout.write(m + '\n') })
|
|
25
|
+
.then(() => process.exit(0))
|
|
26
|
+
.catch((err) => {
|
|
27
|
+
process.stderr.write(`\n✗ Pairing failed: ${err.message}\n`);
|
|
28
|
+
process.exit(1);
|
|
29
|
+
});
|