@pharaoh-so/mcp 0.3.46 → 0.3.49

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.
@@ -5,12 +5,13 @@
5
5
  * through `claude plugin`) and OpenClaw skills in ~/.openclaw/skills/. Also
6
6
  * merges the Pharaoh MCP server config into the target platform.
7
7
  */
8
- import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
8
+ import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
9
9
  import { homedir } from "node:os";
10
10
  import { dirname, join } from "node:path";
11
11
  import { fileURLToPath } from "node:url";
12
+ import { ensurePharaohHome } from "./credentials.js";
12
13
  import { claudeCliFor } from "./setup.js";
13
- import { getPackageVersion } from "./version.js";
14
+ import { getPackageVersion, PACKAGE_NAME, pinnedPackageSpec } from "./version.js";
14
15
  const __dirname = dirname(fileURLToPath(import.meta.url));
15
16
  /** Path to the bundled package root (one level up from dist/). */
16
17
  const PKG_ROOT = join(__dirname, "..");
@@ -388,34 +389,81 @@ function cleanupRemovedSkills(srcDir, dstDir, oldManifest) {
388
389
  return deleted;
389
390
  }
390
391
  /**
391
- * Copy bundled slash command templates (`.md` files in the package's
392
- * `commands/` directory) to `~/.claude/commands/`, unconditionally
393
- * overwriting any existing file with the same name.
394
- *
395
- * Overwriting is intentional: the install contract is "slash commands just
396
- * work out of the box," so the canonical shipped versions take precedence
397
- * over user customizations. Users who want custom command behavior should
398
- * use different filenames.
392
+ * Marker the installer writes into the front-matter of every command file it
393
+ * installs, as a YAML comment Claude Code ignores. A file in
394
+ * `~/.claude/commands/` is rewritten only when it is Pharaoh's: it carries
395
+ * this marker, or the exact header and `$ARGUMENTS` footer every installer
396
+ * before the marker shipped. Any other file with the same name is the user's
397
+ * and is left alone (security review SC-7).
398
+ */
399
+ export const PHARAOH_COMMAND_MARKER = "# managed-by: pharaoh-mcp";
400
+ /** The full marker line, with the one thing a reader of the file needs to know. */
401
+ const COMMAND_MARKER_LINE = `${PHARAOH_COMMAND_MARKER} (rewritten by npx ${PACKAGE_NAME} on upgrade; rename this file to keep your own version)`;
402
+ /** The front-matter block of a bundled command, `---` to `---` inclusive. */
403
+ function commandHeader(bundled) {
404
+ const closing = bundled.indexOf("\n---\n", 4);
405
+ if (!bundled.startsWith("---\n") || closing === -1) {
406
+ throw new Error("bundled command file has no front-matter — rebuild the package");
407
+ }
408
+ return bundled.slice(0, closing + "\n---\n".length);
409
+ }
410
+ /** The bundled command with the marker as the last front-matter line. */
411
+ function markCommand(bundled) {
412
+ const header = commandHeader(bundled);
413
+ const closingAt = header.length - "---\n".length;
414
+ return `${header.slice(0, closingAt)}${COMMAND_MARKER_LINE}\n${header.slice(closingAt)}${bundled.slice(header.length)}`;
415
+ }
416
+ /**
417
+ * Whether an existing command file is Pharaoh's and may be rewritten: marked
418
+ * by this installer, or shaped exactly like the file an earlier installer
419
+ * copied (its header and the `$ARGUMENTS` footer the generator appends).
420
+ */
421
+ function isPharaohCommand(existing, bundled) {
422
+ if (existing.includes(PHARAOH_COMMAND_MARKER))
423
+ return true;
424
+ return existing.startsWith(commandHeader(bundled)) && existing.trimEnd().endsWith("$ARGUMENTS");
425
+ }
426
+ /**
427
+ * Install the bundled slash commands (`.md` files in the package's `commands/`
428
+ * directory) to `~/.claude/commands/`. A destination that is missing or is
429
+ * Pharaoh's own file is written; anything else (the user's file, or a symlink,
430
+ * which is never followed) is kept and reported.
399
431
  *
400
432
  * @param home - Home directory override (defaults to os.homedir()).
401
- * @returns Number of commands installed.
433
+ * @returns How many commands were written, and the names of the files kept.
402
434
  */
403
435
  function installClaudeCodeCommands(home) {
436
+ const result = { installed: 0, kept: [] };
404
437
  if (!existsSync(BUNDLED_COMMANDS_DIR))
405
- return 0;
438
+ return result;
406
439
  const commandsDst = join(home, ".claude", "commands");
407
440
  mkdirSync(commandsDst, { recursive: true });
408
- const entries = readdirSync(BUNDLED_COMMANDS_DIR, { withFileTypes: true });
409
- let count = 0;
410
- for (const entry of entries) {
441
+ for (const entry of readdirSync(BUNDLED_COMMANDS_DIR, { withFileTypes: true })) {
411
442
  if (!entry.isFile() || !entry.name.endsWith(".md"))
412
443
  continue;
413
- const src = join(BUNDLED_COMMANDS_DIR, entry.name);
444
+ const bundled = readFileSync(join(BUNDLED_COMMANDS_DIR, entry.name), "utf-8");
414
445
  const dst = join(commandsDst, entry.name);
415
- cpSync(src, dst, { force: true });
416
- count++;
446
+ const existing = lstatSync(dst, { throwIfNoEntry: false });
447
+ if (existing &&
448
+ (!existing.isFile() || !isPharaohCommand(readFileSync(dst, "utf-8"), bundled))) {
449
+ result.kept.push(entry.name);
450
+ continue;
451
+ }
452
+ writeFileSync(dst, markCommand(bundled));
453
+ result.installed++;
417
454
  }
418
- return count;
455
+ return result;
456
+ }
457
+ /**
458
+ * The one-line notice for command files the installer left alone, or null
459
+ * when there were none.
460
+ *
461
+ * @param kept - File names from `runInstallSkills().keptCommands`.
462
+ */
463
+ export function formatKeptCommands(kept) {
464
+ if (kept.length === 0)
465
+ return null;
466
+ return `Pharaoh: kept your own ~/.claude/commands/${kept.join(", ")} (not Pharaoh's file). Delete one and re-run to take Pharaoh's version.`;
419
467
  }
420
468
  // ── OpenClaw installer ──────────────────────────────────────────────
421
469
  /**
@@ -449,12 +497,14 @@ export function installSkills(home = homedir()) {
449
497
  return count;
450
498
  }
451
499
  /**
452
- * Read ~/.openclaw/openclaw.json, add the Pharaoh MCP server under `mcpServers`,
453
- * and write it back. Creates the file if it does not exist. Does NOT overwrite
454
- * an existing `pharaoh` entry — skips if already present.
500
+ * Read ~/.openclaw/openclaw.json, set the Pharaoh MCP server under `mcpServers`
501
+ * to `npx @pharaoh-so/mcp@<this version>`, and write it back. Creates the file
502
+ * if it does not exist. An existing `pharaoh` entry is re-pinned when it is an
503
+ * `npx @pharaoh-so/mcp` command (extra arguments kept) and left alone when it
504
+ * is anything else.
455
505
  *
456
506
  * @param home - Home directory override (defaults to os.homedir()).
457
- * @returns True if the config was written/updated, false if pharaoh was already present.
507
+ * @returns True if the config was written, false if it was already current or is not ours.
458
508
  */
459
509
  export function mergeOpenClawConfig(home = homedir()) {
460
510
  const configPath = join(home, ".openclaw", "openclaw.json");
@@ -476,74 +526,125 @@ export function mergeOpenClawConfig(home = homedir()) {
476
526
  if (!config.mcpServers) {
477
527
  config.mcpServers = {};
478
528
  }
479
- if (config.mcpServers.pharaoh) {
480
- return false;
529
+ const pinned = pinnedPackageSpec();
530
+ const current = config.mcpServers.pharaoh;
531
+ if (current) {
532
+ const spec = Array.isArray(current.args) ? current.args[0] : undefined;
533
+ const ours = current.command === "npx" &&
534
+ (spec === PACKAGE_NAME || (spec?.startsWith(`${PACKAGE_NAME}@`) ?? false));
535
+ if (!ours || spec === pinned)
536
+ return false;
481
537
  }
482
538
  config.mcpServers.pharaoh = {
483
539
  command: "npx",
484
- args: ["@pharaoh-so/mcp"],
540
+ args: [pinned, ...(current?.args.slice(1) ?? [])],
485
541
  };
486
542
  writeFileSync(configPath, JSON.stringify(config, null, "\t"));
487
543
  return true;
488
544
  }
489
- // ── Claude Code permission auto-configuration ──────────────────────
490
- /** Permission prefix that matches all mcp__pharaoh__* tools. */
491
- const PHARAOH_PERMISSION = "mcp__pharaoh";
545
+ // ── Claude Code permissions ─────────────────────────────────────────
546
+ /**
547
+ * Rules the interactive setup adds to `permissions.allow` in
548
+ * ~/.claude/settings.json: the six read-only tools and `pharaoh_account`,
549
+ * each by full name. Never a prefix rule — `mcp__pharaoh` also auto-approves
550
+ * account deletion and `pharaoh_admin`, so a prompt injection could run them
551
+ * with no human in the loop (security review SC-6). Those tools keep prompting.
552
+ */
553
+ export const PHARAOH_ALLOW_RULES = [
554
+ "mcp__pharaoh__get_codebase_map",
555
+ "mcp__pharaoh__get_module_context",
556
+ "mcp__pharaoh__search_functions",
557
+ "mcp__pharaoh__get_blast_radius",
558
+ "mcp__pharaoh__get_unused_code",
559
+ "mcp__pharaoh__pharaoh_recon",
560
+ "mcp__pharaoh__pharaoh_account",
561
+ ];
562
+ /** The prefix rule proxies 0.3.46 and earlier wrote on every start; replaced by the list above. */
563
+ const LEGACY_ALLOW_RULE = "mcp__pharaoh";
492
564
  /**
493
- * Add "mcp__pharaoh" to ~/.claude/settings.json permissions.allow so that
494
- * Pharaoh MCP tools auto-approve without prompting the user.
565
+ * Add `PHARAOH_ALLOW_RULES` to `permissions.allow` in ~/.claude/settings.json,
566
+ * replacing the blanket `mcp__pharaoh` rule an earlier version wrote. Runs
567
+ * only from the interactive setup, which prints what happened; never on a
568
+ * proxy start. Never creates the file: with no settings.json the tools
569
+ * simply prompt on first use.
495
570
  *
496
571
  * @param home - Home directory override (defaults to os.homedir()).
497
- * @returns "added" if the permission was inserted, "exists" if already present, "error" on failure.
572
+ * @returns "added" or "migrated" when the file was written, "current" when
573
+ * nothing was missing, "no-settings" when there is no file, "error" when
574
+ * the file is not valid JSON (left untouched).
498
575
  */
499
- function configureClaudeCodePermissions(home = homedir()) {
576
+ export function allowReadTools(home = homedir()) {
500
577
  const settingsPath = join(home, ".claude", "settings.json");
501
- let settings = {};
502
- if (existsSync(settingsPath)) {
503
- try {
504
- settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
505
- }
506
- catch {
507
- return "error";
508
- }
509
- }
510
- if (!settings.permissions) {
511
- settings.permissions = {};
512
- }
513
- if (!Array.isArray(settings.permissions.allow)) {
514
- settings.permissions.allow = [];
578
+ if (!existsSync(settingsPath))
579
+ return "no-settings";
580
+ let settings;
581
+ try {
582
+ settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
515
583
  }
516
- if (settings.permissions.allow.includes(PHARAOH_PERMISSION)) {
517
- return "exists";
584
+ catch {
585
+ // ALLOWED-CATCH: a file we cannot parse is a file we must not rewrite
586
+ return "error";
518
587
  }
519
- settings.permissions.allow.push(PHARAOH_PERMISSION);
588
+ const allow = Array.isArray(settings.permissions?.allow) ? settings.permissions.allow : [];
589
+ const migrated = allow.includes(LEGACY_ALLOW_RULE);
590
+ const kept = allow.filter((rule) => rule !== LEGACY_ALLOW_RULE);
591
+ const next = [...kept, ...PHARAOH_ALLOW_RULES.filter((rule) => !kept.includes(rule))];
592
+ if (!migrated && next.length === allow.length)
593
+ return "current";
594
+ settings.permissions = { ...settings.permissions, allow: next };
520
595
  writeFileSync(settingsPath, JSON.stringify(settings, null, "\t"));
521
- return "added";
596
+ return migrated ? "migrated" : "added";
597
+ }
598
+ /**
599
+ * The line the interactive setup prints for an `allowReadTools` result, so
600
+ * the user always learns what was done to their settings.
601
+ *
602
+ * @param result - What `allowReadTools` returned.
603
+ */
604
+ export function describeAllowReadTools(result) {
605
+ const count = PHARAOH_ALLOW_RULES.length;
606
+ switch (result) {
607
+ case "added":
608
+ return `Pharaoh: allowed ${count} tools by name in ~/.claude/settings.json (permissions.allow): the read-only tools and pharaoh_account. Account deletion and pharaoh_admin still prompt.`;
609
+ case "migrated":
610
+ return `Pharaoh: replaced the blanket mcp__pharaoh rule in ~/.claude/settings.json with ${count} tools by name. Account deletion and pharaoh_admin prompt again.`;
611
+ case "current":
612
+ return `Pharaoh: ~/.claude/settings.json already allows the ${count} read-only and account tools by name.`;
613
+ case "no-settings":
614
+ return "Pharaoh: no ~/.claude/settings.json yet, so Pharaoh's tools ask before they run. The rules to allow the read-only tools are in the README (Security).";
615
+ case "error":
616
+ return "Pharaoh: ~/.claude/settings.json is not valid JSON; left as is. Pharaoh's tools ask before they run.";
617
+ }
522
618
  }
523
- // ── Main entry point ────────────────────────────────────────────────
524
619
  /**
525
- * Main entry point for --install-skills.
620
+ * Main entry point for --install-skills, also run on every proxy start.
526
621
  *
527
622
  * Detection priority: Claude Code > OpenClaw > manual instructions.
528
- * Installs to all detected platforms (a user may use both).
623
+ * Installs to all detected platforms (a user may use both). Never touches
624
+ * Claude Code permissions: that is `allowReadTools`, interactive setup only.
529
625
  *
530
626
  * @param home - Home directory override (defaults to os.homedir()).
531
627
  * @param options.silent - Print nothing (the interactive setup prints its own summary).
532
628
  * @param options.claude - `claude` CLI runner (defaults to the real CLI for `home`).
629
+ * @returns What was kept, for the caller's own summary.
533
630
  */
534
631
  export function runInstallSkills(home = homedir(), options) {
535
632
  const silent = options?.silent ?? false;
536
633
  const hasClaudeCode = detectClaudeCode(home);
537
634
  const hasOpenClaw = detectOpenClaw(home);
538
635
  let installed = false;
636
+ const keptCommands = [];
539
637
  // ── Claude Code ──
540
638
  if (hasClaudeCode) {
639
+ // The marketplace lives under ~/.pharaoh, which may not exist yet: create
640
+ // it 0700 (and keep it 0700) before anything is written there.
641
+ ensurePharaohHome(join(home, ".pharaoh"));
541
642
  removeLegacyClaudePlugin(home);
542
643
  const skillCount = writeClaudeMarketplace(home);
543
644
  const registration = registerClaudePlugin(home, options?.claude ?? claudeCliFor(home));
544
- const commandCount = installClaudeCodeCommands(home);
545
- // Silently ensure Pharaoh tools auto-approve (no user prompt on first use)
546
- configureClaudeCodePermissions(home);
645
+ const commands = installClaudeCodeCommands(home);
646
+ keptCommands.push(...commands.kept);
647
+ const keptLine = formatKeptCommands(commands.kept);
547
648
  if (!silent) {
548
649
  const registrationLine = {
549
650
  installed: " → Installed through `claude plugin` — start a new Claude Code session to load it",
@@ -554,11 +655,12 @@ export function runInstallSkills(home = homedir(), options) {
554
655
  process.stderr.write([
555
656
  `Pharaoh: Claude Code plugin with ${skillCount} skills and the Grep routing hook`,
556
657
  registrationLine[registration],
557
- ...(commandCount > 0
658
+ ...(commands.installed > 0
558
659
  ? [
559
- ` → Installed ${commandCount} slash command${commandCount === 1 ? "" : "s"} to ~/.claude/commands/`,
660
+ ` → Installed ${commands.installed} slash command${commands.installed === 1 ? "" : "s"} to ~/.claude/commands/`,
560
661
  ]
561
662
  : []),
663
+ ...(keptLine ? [` → ${keptLine}`] : []),
562
664
  "",
563
665
  ].join("\n"));
564
666
  }
@@ -595,4 +697,5 @@ export function runInstallSkills(home = homedir(), options) {
595
697
  "",
596
698
  ].join("\n"));
597
699
  }
700
+ return { keptCommands };
598
701
  }
@@ -34,6 +34,10 @@ export declare class ProxyServer {
34
34
  private transport;
35
35
  private backend;
36
36
  private started;
37
+ private stopping;
38
+ private clientCapabilities;
39
+ private clientInitialized;
40
+ private capabilityUpdate;
37
41
  private transportClosed;
38
42
  private transportClosedResolve;
39
43
  constructor(initialBackend: Backend);
@@ -27,6 +27,8 @@ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextpro
27
27
  import { AuthRequiredBackend, createSseBackend } from "./backend.js";
28
28
  import { loadBundledCatalog } from "./catalog.js";
29
29
  import { createGateway } from "./gateway.js";
30
+ import { TenantSuspendedError, TokenExpiredError } from "./proxy.js";
31
+ import { registerTraceResource } from "./trace-resource.js";
30
32
  import { getPackageVersion } from "./version.js";
31
33
  /**
32
34
  * Stdio MCP server with a swappable backend. The Server instance is
@@ -37,6 +39,10 @@ export class ProxyServer {
37
39
  transport = null;
38
40
  backend;
39
41
  started = false;
42
+ stopping = false;
43
+ clientCapabilities = {};
44
+ clientInitialized = false;
45
+ capabilityUpdate = Promise.resolve();
40
46
  transportClosed;
41
47
  transportClosedResolve;
42
48
  constructor(initialBackend) {
@@ -53,6 +59,30 @@ export class ProxyServer {
53
59
  this.transportClosedResolve = resolve;
54
60
  });
55
61
  this.registerHandlers();
62
+ registerTraceResource(this.server);
63
+ this.server.oninitialized = () => {
64
+ if (this.clientInitialized)
65
+ return;
66
+ this.clientInitialized = true;
67
+ this.clientCapabilities = this.server.getClientCapabilities() ?? {};
68
+ const next = this.backend.withClientCapabilities?.(this.clientCapabilities) ?? this.backend;
69
+ if (next === this.backend)
70
+ return;
71
+ // Warm startup happens before the frontend initializes. Replace that
72
+ // conservative connection only when real UI support is negotiated.
73
+ this.capabilityUpdate = this.setBackend(next).catch((error) => {
74
+ if (error instanceof TokenExpiredError || error instanceof TenantSuspendedError)
75
+ throw error;
76
+ // UI negotiation is optional: keep the already-started plain-text
77
+ // backend healthy when its replacement hits a transient failure.
78
+ process.stderr.write("Pharaoh: compact UI negotiation unavailable; continuing with text responses.\n");
79
+ });
80
+ this.capabilityUpdate.catch(() => {
81
+ // Tool handlers and waitForLifetime consume this error, including
82
+ // typed auth failures that must reach the existing recovery loop.
83
+ });
84
+ this.backendSwappedSignal();
85
+ };
56
86
  }
57
87
  /**
58
88
  * Wire up list/call handlers. Each handler reads this.backend fresh at
@@ -61,10 +91,12 @@ export class ProxyServer {
61
91
  * makes mutable-backend swaps safe.
62
92
  */
63
93
  registerHandlers() {
64
- this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
65
- tools: await this.backend.listTools(),
66
- }));
94
+ this.server.setRequestHandler(ListToolsRequestSchema, async () => {
95
+ await this.capabilityUpdate;
96
+ return { tools: await this.backend.listTools() };
97
+ });
67
98
  this.server.setRequestHandler(CallToolRequestSchema, async (req) => {
99
+ await this.capabilityUpdate;
68
100
  const { name } = req.params;
69
101
  const args = req.params.arguments ?? {};
70
102
  const backend = this.backend;
@@ -131,6 +163,9 @@ export class ProxyServer {
131
163
  this.transportClosed.then(() => "closed"),
132
164
  backendExit.then(() => "exit"),
133
165
  swapSignal,
166
+ // A successful negotiation is not the end of the connection. Its
167
+ // failure must still reach the supervisor if no tool is in flight.
168
+ this.capabilityUpdate.then(() => new Promise(() => { })),
134
169
  ]);
135
170
  if (outcome === "swap")
136
171
  continue; // re-sample waitForExit against the new backend
@@ -151,6 +186,7 @@ export class ProxyServer {
151
186
  * to whichever backend is current at dispatch time.
152
187
  */
153
188
  async setBackend(next) {
189
+ next = next.withClientCapabilities?.(this.clientCapabilities) ?? next;
154
190
  const prev = this.backend;
155
191
  // Start the new backend BEFORE swapping the reference. If start() rejects
156
192
  // (e.g. SSE connect fails), `prev` stays active — otherwise the proxy
@@ -160,7 +196,12 @@ export class ProxyServer {
160
196
  // reference swap and a slow SSE handshake.
161
197
  if (next.start)
162
198
  await next.start();
199
+ if (this.stopping) {
200
+ await next.stop?.();
201
+ return;
202
+ }
163
203
  this.backend = next;
204
+ this.capabilityUpdate = Promise.resolve();
164
205
  // Fire the notification after the swap so a client that races a list
165
206
  // request against it sees the new catalog.
166
207
  await this.server.sendToolListChanged();
@@ -179,6 +220,7 @@ export class ProxyServer {
179
220
  }
180
221
  }
181
222
  async stop() {
223
+ this.stopping = true;
182
224
  if (this.backend.stop) {
183
225
  await this.backend.stop().catch((err) => {
184
226
  const msg = err instanceof Error ? err.message : String(err);
package/dist/setup.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ /** Runs one `claude` CLI command; true on success. */
2
+ export type ClaudeCli = (args: string[]) => boolean;
1
3
  /**
2
4
  * A `claude` CLI runner for the Claude Code config under `home`: true when the
3
5
  * command succeeded, false when it failed or `claude` isn't installed. Another
@@ -5,7 +7,7 @@
5
7
  *
6
8
  * @param home - Home whose `.claude` config the commands act on.
7
9
  */
8
- export declare function claudeCliFor(home?: string): (args: string[]) => boolean;
10
+ export declare function claudeCliFor(home?: string): ClaudeCli;
9
11
  /**
10
12
  * Remove all traces of pharaoh from Claude Code's internal config (~/.claude.json).
11
13
  *
@@ -18,11 +20,18 @@ export declare function claudeCliFor(home?: string): (args: string[]) => boolean
18
20
  */
19
21
  export declare function cleanSlate(home?: string): void;
20
22
  /**
21
- * Full automated setup: clean slate removal, register MCP server globally,
22
- * install skills. Requires valid credentials (caller handles auth first).
23
+ * Full automated setup: clean slate removal, then register the MCP server
24
+ * globally as `npx @pharaoh-so/mcp@<this version>`, so a new release on npm
25
+ * runs only after the user upgrades on purpose (security review SC-2).
26
+ * Requires valid credentials (caller handles auth first).
23
27
  *
24
- * @returns true if setup succeeded, false if Claude CLI not found.
28
+ * @param options.silent - Print nothing on success.
29
+ * @param options.home - Home whose Claude Code config is edited (tests).
30
+ * @param options.claude - `claude` CLI runner (defaults to the real CLI for `home`).
31
+ * @returns true if setup succeeded, false if Claude CLI not found or registration failed.
25
32
  */
26
33
  export declare function runSetup(options?: {
27
34
  silent?: boolean;
35
+ home?: string;
36
+ claude?: ClaudeCli;
28
37
  }): boolean;
package/dist/setup.js CHANGED
@@ -10,6 +10,7 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs";
10
10
  import { homedir } from "node:os";
11
11
  import { join } from "node:path";
12
12
  import { NPX_COMMAND, printLines } from "./helpers.js";
13
+ import { pinnedPackageSpec } from "./version.js";
13
14
  /** Check if `claude` CLI is available in PATH. */
14
15
  function hasClaude() {
15
16
  try {
@@ -113,41 +114,40 @@ export function cleanSlate(home = homedir()) {
113
114
  }
114
115
  }
115
116
  /**
116
- * Full automated setup: clean slate removal, register MCP server globally,
117
- * install skills. Requires valid credentials (caller handles auth first).
117
+ * Full automated setup: clean slate removal, then register the MCP server
118
+ * globally as `npx @pharaoh-so/mcp@<this version>`, so a new release on npm
119
+ * runs only after the user upgrades on purpose (security review SC-2).
120
+ * Requires valid credentials (caller handles auth first).
118
121
  *
119
- * @returns true if setup succeeded, false if Claude CLI not found.
122
+ * @param options.silent - Print nothing on success.
123
+ * @param options.home - Home whose Claude Code config is edited (tests).
124
+ * @param options.claude - `claude` CLI runner (defaults to the real CLI for `home`).
125
+ * @returns true if setup succeeded, false if Claude CLI not found or registration failed.
120
126
  */
121
127
  export function runSetup(options) {
122
128
  const silent = options?.silent ?? false;
123
- if (!hasClaude()) {
129
+ const home = options?.home ?? homedir();
130
+ if (!options?.claude && !hasClaude()) {
124
131
  printLines("Pharaoh: Claude Code CLI not found.", "", "Install Claude Code first: https://claude.ai/download", `Then re-run: ${NPX_COMMAND}`, "");
125
132
  return false;
126
133
  }
134
+ const claude = options?.claude ?? claudeCliFor(home);
127
135
  if (!silent)
128
136
  printLines("Pharaoh: setting up...");
129
137
  // Step 1: Clean slate — remove pharaoh from all scopes, disabled lists,
130
138
  // and project-scoped configs. Direct file edit handles what CLI cannot.
131
- cleanSlate();
139
+ cleanSlate(home);
132
140
  // Step 2: Belt-and-suspenders — also remove via CLI (both scopes)
133
- runClaude(["mcp", "remove", "pharaoh", "-s", "user"]);
134
- runClaude(["mcp", "remove", "pharaoh", "-s", "project"]);
135
- // Step 3: Register as global stdio MCP server
136
- const added = runClaude([
137
- "mcp",
138
- "add",
139
- "--scope",
140
- "user",
141
- "pharaoh",
142
- "--",
143
- "npx",
144
- "@pharaoh-so/mcp",
145
- ]);
141
+ claude(["mcp", "remove", "pharaoh", "-s", "user"]);
142
+ claude(["mcp", "remove", "pharaoh", "-s", "project"]);
143
+ // Step 3: Register as global stdio MCP server, pinned to the running version
144
+ const spec = pinnedPackageSpec();
145
+ const added = claude(["mcp", "add", "--scope", "user", "pharaoh", "--", "npx", spec]);
146
146
  if (!added) {
147
147
  printLines("Pharaoh: failed to register MCP server.", `Try manually: ${NPX_COMMAND}`, "");
148
148
  return false;
149
149
  }
150
150
  if (!silent)
151
- printLines("Pharaoh: registered as global MCP server (scope: user)");
151
+ printLines(`Pharaoh: registered as global MCP server (scope: user) running ${spec}`);
152
152
  return true;
153
153
  }
@@ -0,0 +1,3 @@
1
+ import type { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
+ export declare const TRACE_MIME_TYPE = "text/html;profile=mcp-app";
3
+ export declare function registerTraceResource(server: Server): void;
@@ -0,0 +1,41 @@
1
+ /** The bundled, tenant-neutral renderer. Resource reads never touch a backend. */
2
+ import { readFileSync } from "node:fs";
3
+ import { ErrorCode, ListResourcesRequestSchema, McpError, ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
4
+ const TRACE_RESOURCE_URI = "ui://pharaoh/trace/v1.1.html";
5
+ export const TRACE_MIME_TYPE = "text/html;profile=mcp-app";
6
+ const resourceMeta = {
7
+ ui: {
8
+ prefersBorder: false,
9
+ csp: { connectDomains: [], resourceDomains: [], frameDomains: [], baseUriDomains: [] },
10
+ permissions: {},
11
+ },
12
+ };
13
+ let html;
14
+ export function registerTraceResource(server) {
15
+ server.registerCapabilities({ resources: {} });
16
+ server.setRequestHandler(ListResourcesRequestSchema, async () => ({
17
+ resources: [
18
+ {
19
+ name: "pharaoh-trace",
20
+ title: "Pharaoh Trace",
21
+ description: "Compact, expandable evidence from Pharaoh tool results.",
22
+ uri: TRACE_RESOURCE_URI,
23
+ mimeType: TRACE_MIME_TYPE,
24
+ _meta: resourceMeta,
25
+ },
26
+ ],
27
+ }));
28
+ server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
29
+ if (request.params.uri !== TRACE_RESOURCE_URI) {
30
+ throw new McpError(ErrorCode.InvalidParams, "Unknown Trace resource");
31
+ }
32
+ // Package-root assets are shared by src/ and dist/. The request URI is
33
+ // never used as a filesystem path or forwarded to the authenticated server.
34
+ html ??= readFileSync(new URL("../trace-app.html", import.meta.url), "utf8");
35
+ return {
36
+ contents: [
37
+ { uri: TRACE_RESOURCE_URI, mimeType: TRACE_MIME_TYPE, text: html, _meta: resourceMeta },
38
+ ],
39
+ };
40
+ });
41
+ }
package/dist/version.d.ts CHANGED
@@ -1,2 +1,32 @@
1
+ /** The npm package name, as `npx` resolves it. */
2
+ export declare const PACKAGE_NAME = "@pharaoh-so/mcp";
3
+ /** The deliberate upgrade: re-runs setup with the newest release, which re-pins every registration. */
4
+ export declare const UPGRADE_COMMAND = "npx @pharaoh-so/mcp@latest";
1
5
  /** Read the mcp-proxy package version from package.json. */
2
6
  export declare function getPackageVersion(): string;
7
+ /**
8
+ * The package spec a registration runs: this exact version, so a new release
9
+ * on npm changes nothing on the user's machine until they upgrade on purpose
10
+ * (security review SC-2). Falls back to the bare name only when the version
11
+ * is unreadable, which would otherwise pin a version that does not exist.
12
+ *
13
+ * @returns `@pharaoh-so/mcp@<version>` as `npx` accepts it.
14
+ */
15
+ export declare function pinnedPackageSpec(): string;
16
+ /**
17
+ * Whether `candidate` is a strictly newer release than `current`. Anything
18
+ * that is not a plain `major.minor.patch` compares as not newer.
19
+ *
20
+ * @param candidate - Version reported by the registry.
21
+ * @param current - Version this process runs.
22
+ */
23
+ export declare function isNewerVersion(candidate: string, current: string): boolean;
24
+ /**
25
+ * The newest version on the npm registry, or null when it cannot be learned
26
+ * (offline, slow, or an unexpected answer). Called only by the interactive
27
+ * setup, never on a proxy start, and sends nothing but the request itself.
28
+ *
29
+ * @param fetchImpl - `fetch` stand-in for tests.
30
+ * @param timeoutMs - How long to wait for the registry before giving up.
31
+ */
32
+ export declare function fetchLatestVersion(fetchImpl?: typeof fetch, timeoutMs?: number): Promise<string | null>;