@pharaoh-so/mcp 0.3.51 → 0.3.53

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/.mcp.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "pharaoh": {
4
4
  "command": "npx",
5
5
  "args": [
6
- "@pharaoh-so/mcp@0.3.51"
6
+ "@pharaoh-so/mcp@0.3.53"
7
7
  ]
8
8
  }
9
9
  }
package/CHANGELOG.md CHANGED
@@ -5,6 +5,28 @@ All notable changes to `@pharaoh-so/mcp` will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.3.53] - 2026-10-23
9
+
10
+ ### Changed
11
+ - The Trace card is a compact receipt: the finding with its counts, a verdict such as risk, and one bar showing where the results sit (callers by module, dead or possibly unused, matches by module, code by module). The evidence opens on Details. A single-tool card is about 80px tall instead of 290 to 2,200px.
12
+ - While a tool runs, the card says what Pharaoh is doing ("Tracing callers of parseEvent…"). Caveats such as an incomplete graph or a stale map stay visible while the evidence is closed.
13
+
14
+ ### Fixed
15
+ - With no `claude` CLI on PATH, `npx @pharaoh-so/mcp` stops before sign-in and says why: setup registers Pharaoh through that CLI, and the Claude desktop app's built-in Claude Code isn't on PATH. The message gives the CLI installer. It used to link the desktop app download, write the slash commands anyway and end with "Pharaoh is ready".
16
+ - When `claude mcp add` fails, setup ends without "Pharaoh is ready" and names the `claude mcp add` command to run by hand.
17
+
18
+ ## [0.3.52] - 2026-10-23
19
+
20
+ ### Added
21
+ - The proxy tells Pharaoh which client it serves (its `initialize` name and version) and whether Pharaoh's Claude Code plugin is enabled in `~/.claude/settings.json`, once per upstream session. Pharaoh stores it with that session, to compare adoption with and without the plugin. Nothing else is sent.
22
+
23
+ ### Removed
24
+ - The `sessions` and `parallel` skills, the `/sessions` slash command and their MCP prompts (`session-decomposition`, `parallel-agents`). Claude Code and Codex now run subagents and parallel sessions on their own.
25
+ - The installer deletes the `~/.claude/commands/sessions.md` it wrote, only while it carries the `# managed-by: pharaoh-mcp` marker. A file of your own with that name, or a symlink, is kept.
26
+
27
+ ### Fixed
28
+ - `npx @pharaoh-so/mcp --version` (or `-v`) prints the version and exits, as README § Custom Server says. It used to start setup: in a terminal it deleted saved credentials and signed in again against mcp.pharaoh.so; on a pipe it started the proxy.
29
+
8
30
  ## [0.3.51] - 2026-10-23
9
31
 
10
32
  ### Added
package/README.md CHANGED
@@ -166,9 +166,10 @@ claude mcp add --scope user pharaoh -- npx @pharaoh-so/mcp@<version> --server ht
166
166
  | `~/.pharaoh/` | `0700`, set again on every start | Always | Credentials and the Claude Code plugin marketplace |
167
167
  | `~/.pharaoh/credentials.json` | `0600` | After sign-in | Access token, refresh token, expiry |
168
168
  | `~/.pharaoh/claude-marketplace/` | — | `~/.claude/` exists | The one-plugin marketplace Claude Code installs as `pharaoh@pharaoh`: the skills and the Grep routing hook. Rewritten when the package version changes |
169
- | `~/.claude/commands/plan.md`, `review.md`, `sessions.md` | — | `~/.claude/` exists | Slash commands. A file is written only when it is absent or is Pharaoh's: it carries the front-matter comment `# managed-by: pharaoh-mcp`, or has the exact header and `$ARGUMENTS` footer of a Pharaoh command from 0.3.46 or earlier. Any other file with that name is yours: it is kept, a symlink is never followed, and the setup output names it. Rename your file to keep both |
169
+ | `~/.claude/commands/plan.md`, `review.md` | — | `~/.claude/` exists | Slash commands. A file is written only when it is absent or is Pharaoh's: it carries the front-matter comment `# managed-by: pharaoh-mcp`, or has the exact header and `$ARGUMENTS` footer of a Pharaoh command from 0.3.46 or earlier. Any other file with that name is yours: it is kept, a symlink is never followed, and the setup output names it. Rename your file to keep both. 0.3.51 and earlier also wrote `sessions.md`; later versions delete it while it carries the marker |
170
170
  | `~/.claude.json` | — | Interactive setup | Removes earlier `pharaoh` entries, then `claude mcp add --scope user pharaoh -- npx @pharaoh-so/mcp@<version>` |
171
171
  | `~/.claude/settings.json` | — | Interactive setup, and only if the file already exists | Adds to `permissions.allow`, each by full name: `mcp__pharaoh__get_codebase_map`, `mcp__pharaoh__get_module_context`, `mcp__pharaoh__search_functions`, `mcp__pharaoh__get_blast_radius`, `mcp__pharaoh__get_unused_code`, `mcp__pharaoh__pharaoh_recon`, `mcp__pharaoh__pharaoh_account`. The setup prints a line saying what it did. The proxy never creates this file and never touches it on a start |
172
+ | `~/.agents/skills/pharaoh-review/` | — | `~/.codex/` exists | The review skill for Codex (`$pharaoh-review`), rewritten on every start. Other tools share `~/.agents/skills/`, so nothing else there is touched |
172
173
  | `~/.openclaw/skills/`, `~/.openclaw/openclaw.json` | — | `~/.openclaw/` exists | Skills prefixed `pharaoh-`, and an `mcpServers.pharaoh` entry running `npx @pharaoh-so/mcp@<version>`. An entry that is not an `npx @pharaoh-so/mcp` command is left alone |
173
174
 
174
175
  Versions 0.3.46 and earlier wrote one rule, `mcp__pharaoh`, on every proxy start. That prefix auto-approved every Pharaoh tool, including account deletion and `pharaoh_admin`, so a prompt injection could run them without a human prompt. The next interactive setup replaces that rule with the list above; account deletion, `pharaoh_admin` and any tool not listed prompt before they run. To take the blanket rule out by hand, delete `mcp__pharaoh` from `permissions.allow`.
package/dist/backend.d.ts CHANGED
@@ -17,6 +17,22 @@
17
17
  */
18
18
  import type { CallToolResult, ClientCapabilities, Tool } from "@modelcontextprotocol/sdk/types.js";
19
19
  import type { DeviceFlowGateway } from "./gateway.js";
20
+ /**
21
+ * Notification that tells Pharaoh which client the proxy serves. Pharaoh sees
22
+ * the proxy itself as `pharaoh-proxy`; this lands on the session's row.
23
+ * Mirrors PROXY_CLIENT_NOTIFICATION in src/mcp/server.ts.
24
+ */
25
+ export declare const CLIENT_NOTIFICATION = "notifications/pharaoh/client";
26
+ /** What the proxy reports about the client behind it. */
27
+ export type DownstreamClient = {
28
+ /** The client's own `initialize` clientInfo (e.g. claude-code 2.1.3). */
29
+ client: {
30
+ name?: string;
31
+ version?: string;
32
+ };
33
+ /** Pharaoh's Claude Code plugin as ~/.claude/settings.json lists it. */
34
+ plugin: "enabled" | "disabled" | "absent";
35
+ };
20
36
  /** The contract ProxyServer uses to delegate tool handling. */
21
37
  export interface Backend {
22
38
  readonly name: "auth-required" | "sse";
@@ -34,6 +50,8 @@ export interface Backend {
34
50
  waitForExit?(): Promise<void>;
35
51
  /** Return an unstarted replacement only if the negotiated UI support changed. */
36
52
  withClientCapabilities?(capabilities: ClientCapabilities): Backend;
53
+ /** Report the client behind the proxy to every upstream session, reconnects included. */
54
+ identifyClient?(downstream: DownstreamClient): void;
37
55
  }
38
56
  /**
39
57
  * Pre-auth backend. Every tool call triggers a device-flow state step via the
@@ -78,7 +96,20 @@ export declare class SseProxyBackend implements Backend {
78
96
  private stopped;
79
97
  private reconnectPromise;
80
98
  private readonly clientCapabilities;
99
+ private downstream;
100
+ /** The client whose `connect` has finished; null while connecting or after its transport closed. */
101
+ private connectedClient;
102
+ /** Set once a report fails; see reportClient. */
103
+ private reportsOff;
81
104
  constructor(sseUrl: string, token: string, capabilities?: ClientCapabilities);
105
+ identifyClient(downstream: DownstreamClient): void;
106
+ /**
107
+ * Tell the connected session which client the proxy serves. Never awaited.
108
+ * The SDK also hands a failed POST to the transport's onerror, which
109
+ * reconnects, so after one failure this backend stops reporting: a server
110
+ * that rejects reports costs one reconnect, not a loop.
111
+ */
112
+ private reportClient;
82
113
  withClientCapabilities(capabilities: ClientCapabilities): Backend;
83
114
  start(): Promise<void>;
84
115
  /**
package/dist/backend.js CHANGED
@@ -21,6 +21,12 @@ import { loadBundledCatalog } from "./catalog.js";
21
21
  import { classifySSEError, TenantSuspendedError, TokenExpiredError } from "./proxy.js";
22
22
  import { TRACE_MIME_TYPE } from "./trace-resource.js";
23
23
  import { getPackageVersion } from "./version.js";
24
+ /**
25
+ * Notification that tells Pharaoh which client the proxy serves. Pharaoh sees
26
+ * the proxy itself as `pharaoh-proxy`; this lands on the session's row.
27
+ * Mirrors PROXY_CLIENT_NOTIFICATION in src/mcp/server.ts.
28
+ */
29
+ export const CLIENT_NOTIFICATION = "notifications/pharaoh/client";
24
30
  /**
25
31
  * Pre-auth backend. Every tool call triggers a device-flow state step via the
26
32
  * gateway; returns a structured auth-required response. Once the gateway
@@ -145,6 +151,11 @@ export class SseProxyBackend {
145
151
  stopped = false;
146
152
  reconnectPromise = null;
147
153
  clientCapabilities;
154
+ downstream = null;
155
+ /** The client whose `connect` has finished; null while connecting or after its transport closed. */
156
+ connectedClient = null;
157
+ /** Set once a report fails; see reportClient. */
158
+ reportsOff = false;
148
159
  constructor(sseUrl, token, capabilities = {}) {
149
160
  this.sseUrl = sseUrl;
150
161
  this.token = token;
@@ -156,6 +167,29 @@ export class SseProxyBackend {
156
167
  ? { extensions: { "io.modelcontextprotocol/ui": { mimeTypes: [TRACE_MIME_TYPE] } } }
157
168
  : {};
158
169
  }
170
+ identifyClient(downstream) {
171
+ this.downstream = downstream;
172
+ void this.reportClient();
173
+ }
174
+ /**
175
+ * Tell the connected session which client the proxy serves. Never awaited.
176
+ * The SDK also hands a failed POST to the transport's onerror, which
177
+ * reconnects, so after one failure this backend stops reporting: a server
178
+ * that rejects reports costs one reconnect, not a loop.
179
+ */
180
+ async reportClient() {
181
+ const client = this.connectedClient;
182
+ if (!client || !this.downstream || this.reportsOff)
183
+ return;
184
+ await client
185
+ .notification({ method: CLIENT_NOTIFICATION, params: this.downstream })
186
+ .catch((error) => {
187
+ // ALLOWED-CATCH: telemetry; reportsOff bounds the reconnect a failed POST triggers
188
+ this.reportsOff = true;
189
+ const msg = error instanceof Error ? error.message : String(error);
190
+ process.stderr.write(`Pharaoh: client report not sent, not retrying (${msg})\n`);
191
+ });
192
+ }
159
193
  withClientCapabilities(capabilities) {
160
194
  const next = new SseProxyBackend(this.sseUrl, this.token, capabilities);
161
195
  return JSON.stringify(next.clientCapabilities) === JSON.stringify(this.clientCapabilities)
@@ -207,6 +241,13 @@ export class SseProxyBackend {
207
241
  classifySSEError(error);
208
242
  throw error;
209
243
  }
244
+ // A stop or a newer connect while this one was in flight owns the backend now.
245
+ if (this.stopped || this.client !== client)
246
+ return;
247
+ this.connectedClient = client;
248
+ // Every new upstream session (reconnects too) hears which client this is,
249
+ // without holding up startup or reconnect supervision.
250
+ void this.reportClient();
210
251
  }
211
252
  /** Wait for the current transport to close, then decide what to do. */
212
253
  waitForClose() {
@@ -216,8 +257,14 @@ export class SseProxyBackend {
216
257
  return;
217
258
  }
218
259
  const sse = this.transport;
219
- sse.onclose = () => resolve();
260
+ // The client is gone the moment its transport closes or errors,
261
+ // whichever way the loop then leaves (backoff or typed recovery).
262
+ sse.onclose = () => {
263
+ this.connectedClient = null;
264
+ resolve();
265
+ };
220
266
  sse.onerror = (err) => {
267
+ this.connectedClient = null;
221
268
  try {
222
269
  classifySSEError(err);
223
270
  }
@@ -302,6 +349,7 @@ export class SseProxyBackend {
302
349
  }
303
350
  async stop() {
304
351
  this.stopped = true;
352
+ this.connectedClient = null;
305
353
  if (this.transport) {
306
354
  await this.transport.close().catch(() => { });
307
355
  this.transport = null;
package/dist/helpers.js CHANGED
@@ -42,7 +42,7 @@ export function parseArgs(argv = process.argv.slice(2)) {
42
42
  return { server, logout };
43
43
  }
44
44
  export function printUsage() {
45
- printLines("Usage: pharaoh-mcp [options]", "", "Options:", " --server <url> Pharaoh server URL (default: https://mcp.pharaoh.so)", " --logout Clear credentials, MCP config and the Claude Code plugin, then exit", " --clean Remove Pharaoh's MCP entries and plugin from Claude Code config", " --install-skills Reinstall the Claude Code plugin and OpenClaw skills", " --help, -h Show this help", "", "Get started:", ` ${NPX_COMMAND}`, "");
45
+ printLines("Usage: pharaoh-mcp [options]", "", "Options:", " --server <url> Pharaoh server URL (default: https://mcp.pharaoh.so)", " --logout Clear credentials, MCP config and the Claude Code plugin, then exit", " --clean Remove Pharaoh's MCP entries and plugin from Claude Code config", " --install-skills Reinstall the Claude Code plugin and OpenClaw skills", " --version, -v Print the version", " --help, -h Show this help", "", "Get started:", ` ${NPX_COMMAND}`, "");
46
46
  }
47
47
  /**
48
48
  * Validate that a server-supplied SSE URL shares the same origin as the configured server.
package/dist/index.js CHANGED
@@ -37,6 +37,11 @@ async function main() {
37
37
  printUsage();
38
38
  process.exit(0);
39
39
  }
40
+ // Stdout, alone on its line, so a script can capture the version to pin
41
+ if (args.includes("--version") || args.includes("-v")) {
42
+ process.stdout.write(`${getPackageVersion()}\n`);
43
+ return;
44
+ }
40
45
  // Inspect mode — serve tool schemas over stdio for Glama directory inspection
41
46
  if (args.includes("--inspect")) {
42
47
  const { runInspect } = await import("./inspect.js");
@@ -75,6 +80,13 @@ async function main() {
75
80
  // Running `npx @pharaoh-so/mcp` is the only command a user needs.
76
81
  if (isInteractive) {
77
82
  process.stderr.write(`${BANNER}\n`);
83
+ // Setup registers everything through the `claude` CLI on PATH. Without it,
84
+ // stop before sign-in: nothing is written and saved credentials stay.
85
+ const { claudeCliMissing, hasClaude, runSetup } = await import("./setup.js");
86
+ if (!hasClaude()) {
87
+ printLines(...claudeCliMissing());
88
+ process.exit(1);
89
+ }
78
90
  // Always re-authenticate for a fresh session
79
91
  deleteCredentials();
80
92
  const deviceCode = await requestDeviceCode(server);
@@ -87,10 +99,11 @@ async function main() {
87
99
  const newCreds = tokenToCredentials(token, sseUrl);
88
100
  writeCredentials(newCreds);
89
101
  printAuthSuccess(token.github_login ?? null, token.tenant_name ?? null, token.repos?.length ?? 0);
90
- // Register MCP server + install skills (silent — user doesn't need internals)
102
+ // Register MCP server + install skills (silent — user doesn't need internals).
103
+ // runSetup has printed the failure and the command to run by hand; no success line.
104
+ if (!runSetup({ silent: true }))
105
+ process.exit(1);
91
106
  process.stderr.write(" Setting up...");
92
- const { runSetup } = await import("./setup.js");
93
- runSetup({ silent: true });
94
107
  const { keptCommands } = runInstallSkills(undefined, { silent: true });
95
108
  process.stderr.write(" done.\n");
96
109
  // Two things the user must hear about: their settings.json (the only
@@ -1,11 +1,11 @@
1
1
  import { type ClaudeCli } from "./setup.js";
2
2
  /**
3
- * Detect whether OpenClaw is installed by checking for ~/.openclaw/.
3
+ * Pharaoh's Claude Code plugin as ~/.claude/settings.json lists it. The proxy
4
+ * reports this to Pharaoh, so adoption can be compared with and without it.
4
5
  *
5
6
  * @param home - Home directory override (defaults to os.homedir()).
6
- * @returns True if ~/.openclaw/ exists.
7
7
  */
8
- export declare function detectOpenClaw(home?: string): boolean;
8
+ export declare function claudePluginState(home?: string): "enabled" | "disabled" | "absent";
9
9
  /**
10
10
  * Uninstall Pharaoh's Claude Code plugin: through the CLI, then the local
11
11
  * marketplace directory and any pre-marketplace install.
@@ -11,7 +11,7 @@ import { homedir } from "node:os";
11
11
  import { dirname, join } from "node:path";
12
12
  import { fileURLToPath } from "node:url";
13
13
  import { ensurePharaohHome } from "./credentials.js";
14
- import { claudeCliFor } from "./setup.js";
14
+ import { CLAUDE_CLI_SETUP_URL, claudeCliFor } from "./setup.js";
15
15
  import { getPackageVersion, PACKAGE_NAME, pinnedPackageSpec } from "./version.js";
16
16
  const __dirname = dirname(fileURLToPath(import.meta.url));
17
17
  /** Path to the bundled package root (one level up from dist/). */
@@ -63,7 +63,7 @@ function detectCodex(home = homedir()) {
63
63
  * @param home - Home directory override (defaults to os.homedir()).
64
64
  * @returns True if ~/.openclaw/ exists.
65
65
  */
66
- export function detectOpenClaw(home = homedir()) {
66
+ function detectOpenClaw(home = homedir()) {
67
67
  return existsSync(join(home, ".openclaw"));
68
68
  }
69
69
  // ── Claude Code plugin ──────────────────────────────────────────────
@@ -151,6 +151,18 @@ function pluginSetting(home) {
151
151
  return undefined;
152
152
  }
153
153
  }
154
+ /**
155
+ * Pharaoh's Claude Code plugin as ~/.claude/settings.json lists it. The proxy
156
+ * reports this to Pharaoh, so adoption can be compared with and without it.
157
+ *
158
+ * @param home - Home directory override (defaults to os.homedir()).
159
+ */
160
+ export function claudePluginState(home = homedir()) {
161
+ const setting = pluginSetting(home);
162
+ if (setting === undefined)
163
+ return "absent";
164
+ return setting ? "enabled" : "disabled";
165
+ }
154
166
  /**
155
167
  * Install the plugin through Claude Code's CLI (both commands are idempotent).
156
168
  * Skipped when it's already enabled, and when the user disabled it: that choice stands.
@@ -408,6 +420,13 @@ function cleanupRemovedSkills(srcDir, dstDir, oldManifest) {
408
420
  * and is left alone (security review SC-7).
409
421
  */
410
422
  export const PHARAOH_COMMAND_MARKER = "# managed-by: pharaoh-mcp";
423
+ /**
424
+ * Commands an earlier installer wrote that this version no longer ships. Each
425
+ * is deleted only while it is still Pharaoh's marked file; a user's own file
426
+ * or a symlink with the name stays. A pre-marker copy (0.3.46 and earlier) is
427
+ * left too: without the bundled file there is nothing to prove it is ours.
428
+ */
429
+ const RETIRED_COMMANDS = ["sessions.md"];
411
430
  /** The full marker line, with the one thing a reader of the file needs to know. */
412
431
  const COMMAND_MARKER_LINE = `${PHARAOH_COMMAND_MARKER} (rewritten by npx ${PACKAGE_NAME} on upgrade; rename this file to keep your own version)`;
413
432
  /** The front-matter block of a bundled command, `---` to `---` inclusive. */
@@ -438,13 +457,14 @@ function isPharaohCommand(existing, bundled) {
438
457
  * Install the bundled slash commands (`.md` files in the package's `commands/`
439
458
  * directory) to `~/.claude/commands/`. A destination that is missing or is
440
459
  * Pharaoh's own file is written; anything else (the user's file, or a symlink,
441
- * which is never followed) is kept and reported.
460
+ * which is never followed) is kept and reported. Pharaoh's copies of
461
+ * `RETIRED_COMMANDS` are removed.
442
462
  *
443
463
  * @param home - Home directory override (defaults to os.homedir()).
444
- * @returns How many commands were written, and the names of the files kept.
464
+ * @returns How many commands were written, and the names of the files kept and removed.
445
465
  */
446
466
  function installClaudeCodeCommands(home) {
447
- const result = { installed: 0, kept: [] };
467
+ const result = { installed: 0, kept: [], removed: [] };
448
468
  if (!existsSync(BUNDLED_COMMANDS_DIR))
449
469
  return result;
450
470
  const commandsDst = join(home, ".claude", "commands");
@@ -463,6 +483,15 @@ function installClaudeCodeCommands(home) {
463
483
  writeFileSync(dst, markCommand(bundled));
464
484
  result.installed++;
465
485
  }
486
+ for (const name of RETIRED_COMMANDS) {
487
+ const dst = join(commandsDst, name);
488
+ if (!lstatSync(dst, { throwIfNoEntry: false })?.isFile())
489
+ continue;
490
+ if (!readFileSync(dst, "utf-8").includes(PHARAOH_COMMAND_MARKER))
491
+ continue;
492
+ rmSync(dst);
493
+ result.removed.push(name);
494
+ }
466
495
  return result;
467
496
  }
468
497
  /**
@@ -691,6 +720,7 @@ export function runInstallSkills(home = homedir(), options) {
691
720
  ]
692
721
  : []),
693
722
  ...(keptLine ? [` → ${keptLine}`] : []),
723
+ ...commands.removed.map((name) => ` → Removed retired ~/.claude/commands/${name}`),
694
724
  "",
695
725
  ].join("\n"));
696
726
  }
@@ -736,7 +766,7 @@ export function runInstallSkills(home = homedir(), options) {
736
766
  "Pharaoh: no supported skill platform detected.",
737
767
  "",
738
768
  "Supported platforms:",
739
- " • Claude Code — install from https://claude.ai/download",
769
+ ` • Claude Code — install the CLI from ${CLAUDE_CLI_SETUP_URL}`,
740
770
  " • Codex — install from https://developers.openai.com/codex",
741
771
  " • OpenClaw — install from https://openclaw.dev/install",
742
772
  "",
@@ -37,6 +37,7 @@ export declare class ProxyServer {
37
37
  private stopping;
38
38
  private clientCapabilities;
39
39
  private clientInitialized;
40
+ private downstream;
40
41
  private capabilityUpdate;
41
42
  private transportClosed;
42
43
  private transportClosedResolve;
@@ -24,9 +24,10 @@
24
24
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
25
25
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
26
26
  import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
27
- import { AuthRequiredBackend, createSseBackend } from "./backend.js";
27
+ import { AuthRequiredBackend, createSseBackend, } from "./backend.js";
28
28
  import { loadBundledCatalog } from "./catalog.js";
29
29
  import { createGateway } from "./gateway.js";
30
+ import { claudePluginState } from "./install-skills.js";
30
31
  import { TenantSuspendedError, TokenExpiredError } from "./proxy.js";
31
32
  import { registerTraceResource } from "./trace-resource.js";
32
33
  import { getPackageVersion } from "./version.js";
@@ -42,6 +43,7 @@ export class ProxyServer {
42
43
  stopping = false;
43
44
  clientCapabilities = {};
44
45
  clientInitialized = false;
46
+ downstream = null;
45
47
  capabilityUpdate = Promise.resolve();
46
48
  transportClosed;
47
49
  transportClosedResolve;
@@ -64,10 +66,21 @@ export class ProxyServer {
64
66
  if (this.clientInitialized)
65
67
  return;
66
68
  this.clientInitialized = true;
69
+ const info = this.server.getClientVersion();
70
+ const downstream = {
71
+ client: { name: info?.name, version: info?.version },
72
+ plugin: claudePluginState(),
73
+ };
74
+ this.downstream = downstream;
67
75
  this.clientCapabilities = this.server.getClientCapabilities() ?? {};
68
76
  const next = this.backend.withClientCapabilities?.(this.clientCapabilities) ?? this.backend;
69
- if (next === this.backend)
77
+ // Only the backend that serves this client is told about it: a warm
78
+ // session replaced below never takes a tool call. setBackend names the
79
+ // client to its replacement once that has started.
80
+ if (next === this.backend) {
81
+ this.backend.identifyClient?.(downstream);
70
82
  return;
83
+ }
71
84
  // Warm startup happens before the frontend initializes. Replace that
72
85
  // conservative connection only when real UI support is negotiated.
73
86
  this.capabilityUpdate = this.setBackend(next).catch((error) => {
@@ -75,6 +88,7 @@ export class ProxyServer {
75
88
  throw error;
76
89
  // UI negotiation is optional: keep the already-started plain-text
77
90
  // backend healthy when its replacement hits a transient failure.
91
+ this.backend.identifyClient?.(downstream);
78
92
  process.stderr.write("Pharaoh: compact UI negotiation unavailable; continuing with text responses.\n");
79
93
  });
80
94
  this.capabilityUpdate.catch(() => {
@@ -201,6 +215,9 @@ export class ProxyServer {
201
215
  return;
202
216
  }
203
217
  this.backend = next;
218
+ // Read the client after start: it may have initialized during the await.
219
+ if (this.downstream)
220
+ next.identifyClient?.(this.downstream);
204
221
  this.capabilityUpdate = Promise.resolve();
205
222
  // Fire the notification after the swap so a client that races a list
206
223
  // request against it sees the new catalog.
package/dist/setup.d.ts CHANGED
@@ -1,5 +1,24 @@
1
1
  /** Runs one `claude` CLI command; true on success. */
2
2
  export type ClaudeCli = (args: string[]) => boolean;
3
+ /** Anthropic's Claude Code CLI install page: every platform and install method. */
4
+ export declare const CLAUDE_CLI_SETUP_URL = "https://code.claude.com/docs/en/setup";
5
+ /**
6
+ * Whether a `claude` CLI on PATH starts. It is launched the way `runClaude`
7
+ * launches it, so the check and the commands agree, with no `which` (minimal
8
+ * containers lack it). The Claude desktop app's bundled copy isn't on PATH.
9
+ *
10
+ * @returns True when `claude --version` runs.
11
+ */
12
+ export declare function hasClaude(): boolean;
13
+ /**
14
+ * What setup prints when no `claude` CLI is on PATH: npx registers everything
15
+ * through that CLI, the desktop app's copy doesn't count, and the CLI shares
16
+ * ~/.claude with the desktop app, so installing it covers the Code tab too.
17
+ *
18
+ * @param platform - Picks the one-line installer (PowerShell on Windows).
19
+ * @returns The lines to print.
20
+ */
21
+ export declare function claudeCliMissing(platform?: NodeJS.Platform): string[];
3
22
  /**
4
23
  * A `claude` CLI runner for the Claude Code config under `home`: true when the
5
24
  * command succeeded, false when it failed or `claude` isn't installed. Another
package/dist/setup.js CHANGED
@@ -11,20 +11,54 @@ import { homedir } from "node:os";
11
11
  import { join } from "node:path";
12
12
  import { NPX_COMMAND, printLines } from "./helpers.js";
13
13
  import { pinnedPackageSpec } from "./version.js";
14
- /** Check if `claude` CLI is available in PATH. */
15
- function hasClaude() {
14
+ /** Anthropic's Claude Code CLI install page: every platform and install method. */
15
+ export const CLAUDE_CLI_SETUP_URL = "https://code.claude.com/docs/en/setup";
16
+ /** Pharaoh's setup guide for agents other than Claude Code (stdio server, sign-in on first call). */
17
+ const HEADLESS_SETUP_URL = "https://pharaoh.so/docs/connect/headless";
18
+ /**
19
+ * Whether a `claude` CLI on PATH starts. It is launched the way `runClaude`
20
+ * launches it, so the check and the commands agree, with no `which` (minimal
21
+ * containers lack it). The Claude desktop app's bundled copy isn't on PATH.
22
+ *
23
+ * @returns True when `claude --version` runs.
24
+ */
25
+ export function hasClaude() {
16
26
  try {
17
- const which = process.platform === "win32" ? "where" : "which";
18
- execFileSync(which, ["claude"], {
19
- encoding: "utf-8",
20
- stdio: ["pipe", "pipe", "pipe"],
21
- });
27
+ execFileSync("claude", ["--version"], { stdio: "pipe", timeout: 10_000 });
22
28
  return true;
23
29
  }
24
30
  catch {
31
+ // A `claude` that can't be started is one setup can't use either.
25
32
  return false;
26
33
  }
27
34
  }
35
+ /**
36
+ * What setup prints when no `claude` CLI is on PATH: npx registers everything
37
+ * through that CLI, the desktop app's copy doesn't count, and the CLI shares
38
+ * ~/.claude with the desktop app, so installing it covers the Code tab too.
39
+ *
40
+ * @param platform - Picks the one-line installer (PowerShell on Windows).
41
+ * @returns The lines to print.
42
+ */
43
+ export function claudeCliMissing(platform = process.platform) {
44
+ // The shell goes in the label: anything after the command gets pasted with it.
45
+ const [shell, installer] = platform === "win32"
46
+ ? [" (PowerShell)", "irm https://claude.ai/install.ps1 | iex"]
47
+ : ["", "curl -fsSL https://claude.ai/install.sh | bash"];
48
+ return [
49
+ "Pharaoh: the claude command isn't on your PATH, so setup stopped.",
50
+ "",
51
+ "npx adds Pharaoh to Claude Code through the Claude Code CLI. The desktop app runs its own copy of Claude Code, and that copy isn't on your PATH.",
52
+ "",
53
+ ` Install the CLI${shell}: ${installer}`,
54
+ ` Other ways: ${CLAUDE_CLI_SETUP_URL}`,
55
+ ` Then, in a new terminal: ${NPX_COMMAND}`,
56
+ "",
57
+ "The CLI and the desktop app share ~/.claude, so the desktop app's Code tab gets Pharaoh too.",
58
+ `Not using Claude Code? OpenClaw: ${NPX_COMMAND} --install-skills. Other agents: ${HEADLESS_SETUP_URL}`,
59
+ "",
60
+ ];
61
+ }
28
62
  /** Run a claude CLI command, returning true on success. Surfaces stderr on failure. */
29
63
  function runClaude(args, env = process.env) {
30
64
  try {
@@ -128,7 +162,7 @@ export function runSetup(options) {
128
162
  const silent = options?.silent ?? false;
129
163
  const home = options?.home ?? homedir();
130
164
  if (!options?.claude && !hasClaude()) {
131
- printLines("Pharaoh: Claude Code CLI not found.", "", "Install Claude Code first: https://claude.ai/download", `Then re-run: ${NPX_COMMAND}`, "");
165
+ printLines(...claudeCliMissing());
132
166
  return false;
133
167
  }
134
168
  const claude = options?.claude ?? claudeCliFor(home);
@@ -142,9 +176,9 @@ export function runSetup(options) {
142
176
  claude(["mcp", "remove", "pharaoh", "-s", "project"]);
143
177
  // Step 3: Register as global stdio MCP server, pinned to the running version
144
178
  const spec = pinnedPackageSpec();
145
- const added = claude(["mcp", "add", "--scope", "user", "pharaoh", "--", "npx", spec]);
146
- if (!added) {
147
- printLines("Pharaoh: failed to register MCP server.", `Try manually: ${NPX_COMMAND}`, "");
179
+ const addArgs = ["mcp", "add", "--scope", "user", "pharaoh", "--", "npx", spec];
180
+ if (!claude(addArgs)) {
181
+ printLines("Pharaoh: failed to register MCP server.", `Try manually: claude ${addArgs.join(" ")}`, "");
148
182
  return false;
149
183
  }
150
184
  if (!silent)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@pharaoh-so/mcp",
3
3
  "mcpName": "so.pharaoh/pharaoh",
4
- "version": "0.3.51",
4
+ "version": "0.3.53",
5
5
  "description": "MCP proxy for Pharaoh — maps codebases into queryable knowledge graphs for AI agents. Enables Claude Code in headless environments (VPS, SSH, CI) via device flow auth.",
6
6
  "type": "module",
7
7
  "main": "dist/index.js",