@pharaoh-so/mcp 0.3.53 → 0.3.54

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.53"
6
+ "@pharaoh-so/mcp@0.3.54"
7
7
  ]
8
8
  }
9
9
  }
package/CHANGELOG.md CHANGED
@@ -5,6 +5,16 @@ 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.54] - 2026-10-24
9
+
10
+ ### Added
11
+ - The interactive setup (`npx @pharaoh-so/mcp` in a terminal) asks once whether to add one line to `~/.claude/CLAUDE.md`: "In repos Pharaoh has mapped, before searching or reading files, ask Pharaoh where the relevant code is, what calls it, and whether what you need already exists." Enter means yes. In Pharaoh's adoption benchmark, Claude Code called Pharaoh first when this line was in a CLAUDE.md, and rarely when the same sentence came from the server instructions or a plugin hook.
12
+ - The line sits between `<!-- pharaoh-mcp:start … -->` and `<!-- pharaoh-mcp:end -->` markers. Setup appends that block or replaces the line inside it, and nothing else in the file changes. A symlinked file is left alone, and a proxy start (Claude Code launching the server) never asks or writes. Your answer is saved in `~/.pharaoh/claude-md-answer`, so a no stands.
13
+
14
+ ### Changed
15
+ - `--clean` and `--logout` also remove that block, leaving the rest of the file byte for byte, and delete the saved answer so the next setup asks again.
16
+ - `--logout` deletes the saved credentials first, so a cleanup step that fails can't leave them behind.
17
+
8
18
  ## [0.3.53] - 2026-10-23
9
19
 
10
20
  ### Changed
package/dist/helpers.d.ts CHANGED
@@ -1,13 +1,23 @@
1
- /**
2
- * Pure helper functions for the mcp-proxy CLI — no side effects on import.
3
- * Separated from index.ts so tests can import without triggering main().
4
- */
5
1
  import type { TokenResponse } from "./auth.js";
6
2
  import type { Credentials } from "./credentials.js";
7
3
  /** The npx command users run to set up Pharaoh. Single source of truth for all output. */
8
4
  export declare const NPX_COMMAND = "npx @pharaoh-so/mcp";
9
5
  /** Write one or more lines to stderr. */
10
6
  export declare function printLines(...lines: string[]): void;
7
+ /**
8
+ * Ask a yes/no question that defaults to yes: Enter, `y` or `yes` is yes; `n`
9
+ * or `no` is no; anything else asks again. Ctrl-C exits (130). On a terminal,
10
+ * keys pressed before the question showed (an Enter while sign-in waited) are
11
+ * dropped, so only a key pressed after reading it answers.
12
+ *
13
+ * @param question - The prompt, ending in something like `[Y/n] `.
14
+ * @param input - Where the answer is read (defaults to stdin).
15
+ * @param output - Where the prompt is written (defaults to stderr, like every other line).
16
+ * @returns The answer, or null when input ended before one.
17
+ */
18
+ export declare function askYesNo(question: string, input?: NodeJS.ReadableStream & {
19
+ isTTY?: boolean;
20
+ }, output?: NodeJS.WritableStream): Promise<boolean | null>;
11
21
  /** Parse CLI arguments. */
12
22
  export declare function parseArgs(argv?: string[]): {
13
23
  server: string;
package/dist/helpers.js CHANGED
@@ -1,3 +1,8 @@
1
+ /**
2
+ * Pure helper functions for the mcp-proxy CLI — no side effects on import.
3
+ * Separated from index.ts so tests can import without triggering main().
4
+ */
5
+ import { createInterface } from "node:readline/promises";
1
6
  import { isExpired } from "./credentials.js";
2
7
  const DEFAULT_SERVER = "https://mcp.pharaoh.so";
3
8
  /** The npx command users run to set up Pharaoh. Single source of truth for all output. */
@@ -6,6 +11,54 @@ export const NPX_COMMAND = "npx @pharaoh-so/mcp";
6
11
  export function printLines(...lines) {
7
12
  process.stderr.write(`${lines.join("\n")}\n`);
8
13
  }
14
+ /** How long to read and drop input that was typed before a question showed. */
15
+ const TYPE_AHEAD_MS = 50;
16
+ /**
17
+ * Ask a yes/no question that defaults to yes: Enter, `y` or `yes` is yes; `n`
18
+ * or `no` is no; anything else asks again. Ctrl-C exits (130). On a terminal,
19
+ * keys pressed before the question showed (an Enter while sign-in waited) are
20
+ * dropped, so only a key pressed after reading it answers.
21
+ *
22
+ * @param question - The prompt, ending in something like `[Y/n] `.
23
+ * @param input - Where the answer is read (defaults to stdin).
24
+ * @param output - Where the prompt is written (defaults to stderr, like every other line).
25
+ * @returns The answer, or null when input ended before one.
26
+ */
27
+ export async function askYesNo(question, input = process.stdin, output = process.stderr) {
28
+ if (input.isTTY)
29
+ await dropTypedAhead(input);
30
+ const rl = createInterface({ input, output });
31
+ rl.on("SIGINT", () => {
32
+ output.write("\n");
33
+ process.exit(130);
34
+ });
35
+ rl.setPrompt(question);
36
+ rl.prompt();
37
+ try {
38
+ // The iterator ends on EOF; rl.question never settles then.
39
+ for await (const reply of rl) {
40
+ const answer = reply.trim().toLowerCase();
41
+ if (answer === "" || answer === "y" || answer === "yes")
42
+ return true;
43
+ if (answer === "n" || answer === "no")
44
+ return false;
45
+ rl.prompt();
46
+ }
47
+ return null;
48
+ }
49
+ finally {
50
+ rl.close();
51
+ }
52
+ }
53
+ /** Read and drop the input already waiting, in raw mode so a half-typed line goes too. */
54
+ async function dropTypedAhead(input) {
55
+ input.setRawMode?.(true);
56
+ const drop = () => { };
57
+ input.on("data", drop);
58
+ await new Promise((resolve) => setTimeout(resolve, TYPE_AHEAD_MS));
59
+ input.off("data", drop);
60
+ input.pause();
61
+ }
9
62
  /** Parse CLI arguments. */
10
63
  export function parseArgs(argv = process.argv.slice(2)) {
11
64
  let server = DEFAULT_SERVER;
@@ -42,7 +95,7 @@ export function parseArgs(argv = process.argv.slice(2)) {
42
95
  return { server, logout };
43
96
  }
44
97
  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", " --version, -v Print the version", " --help, -h Show this help", "", "Get started:", ` ${NPX_COMMAND}`, "");
98
+ printLines("Usage: pharaoh-mcp [options]", "", "Options:", " --server <url> Pharaoh server URL (default: https://mcp.pharaoh.so)", " --logout Clear credentials, MCP config, the Claude Code plugin and the CLAUDE.md line, then exit", " --clean Remove Pharaoh's MCP entries, plugin and CLAUDE.md line from Claude Code", " --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
99
  }
47
100
  /**
48
101
  * Validate that a server-supplied SSE URL shares the same origin as the configured server.
package/dist/index.js CHANGED
@@ -16,8 +16,8 @@ import { pollForToken, printActivationPrompt, printAuthSuccess, requestDeviceCod
16
16
  import { AuthRequiredBackend, createSseBackend } from "./backend.js";
17
17
  import { deleteCredentials, readCredentials, writeCredentials, } from "./credentials.js";
18
18
  import { createGateway } from "./gateway.js";
19
- import { formatTtl, parseArgs, printLines, printUsage, resolveSseUrl, tokenToCredentials, } from "./helpers.js";
20
- import { allowReadTools, describeAllowReadTools, formatKeptCommands, runInstallSkills, } from "./install-skills.js";
19
+ import { askYesNo, formatTtl, parseArgs, printLines, printUsage, resolveSseUrl, tokenToCredentials, } from "./helpers.js";
20
+ import { allowReadTools, CLAUDE_MD_QUESTION, describeAllowReadTools, describeClaudeMdOutcome, formatKeptCommands, offerClaudeMdLine, runInstallSkills, } from "./install-skills.js";
21
21
  import { handleRefreshFailure, tryMidSessionRefresh } from "./lifecycle.js";
22
22
  import { TenantSuspendedError, TokenExpiredError } from "./proxy.js";
23
23
  import { startInAuthRequiredMode, startInSseMode } from "./proxy-server.js";
@@ -61,17 +61,18 @@ async function main() {
61
61
  const { uninstallClaudePlugin } = await import("./install-skills.js");
62
62
  cleanSlate();
63
63
  uninstallClaudePlugin();
64
- printLines("Pharaoh: cleaned all MCP entries and the plugin from Claude Code config");
64
+ printLines("Pharaoh: removed its MCP entries, plugin and ~/.claude/CLAUDE.md line from Claude Code");
65
65
  process.exit(0);
66
66
  }
67
67
  const { server, logout } = parseArgs(args);
68
68
  if (logout) {
69
69
  const { cleanSlate } = await import("./setup.js");
70
70
  const { uninstallClaudePlugin } = await import("./install-skills.js");
71
+ // Credentials first: a cleanup step that fails must never leave the tokens behind.
72
+ deleteCredentials();
71
73
  cleanSlate();
72
74
  uninstallClaudePlugin();
73
- deleteCredentials();
74
- printLines("Pharaoh: credentials, MCP config and the Claude Code plugin cleared");
75
+ printLines("Pharaoh: credentials, MCP config, the Claude Code plugin and the ~/.claude/CLAUDE.md line cleared");
75
76
  process.exit(0);
76
77
  }
77
78
  const isInteractive = Boolean(process.stdin.isTTY);
@@ -113,6 +114,11 @@ async function main() {
113
114
  const kept = formatKeptCommands(keptCommands);
114
115
  if (kept)
115
116
  printLines(kept);
117
+ // The one line agents act on (bench #1305) goes in the user's own
118
+ // ~/.claude/CLAUDE.md, so it's asked about once, here, never on a start.
119
+ const claudeMd = describeClaudeMdOutcome(await offerClaudeMdLine(() => askYesNo(CLAUDE_MD_QUESTION), Boolean(process.stdin.isTTY && process.stderr.isTTY)));
120
+ if (claudeMd)
121
+ printLines(claudeMd);
116
122
  // The registration is pinned to this version; upgrading is a deliberate
117
123
  // re-run. Interactive only, and quiet when the registry can't be reached.
118
124
  const latest = await fetchLatestVersion();
@@ -7,8 +7,9 @@ import { type ClaudeCli } from "./setup.js";
7
7
  */
8
8
  export declare function claudePluginState(home?: string): "enabled" | "disabled" | "absent";
9
9
  /**
10
- * Uninstall Pharaoh's Claude Code plugin: through the CLI, then the local
11
- * marketplace directory and any pre-marketplace install.
10
+ * Uninstall Pharaoh's Claude Code plugin (through the CLI, then the local
11
+ * marketplace directory and any pre-marketplace install) and its block in
12
+ * ~/.claude/CLAUDE.md. Run by --clean and --logout.
12
13
  *
13
14
  * @param home - Home directory override (defaults to os.homedir()).
14
15
  * @param claude - `claude` CLI runner (defaults to the real CLI for `home`).
@@ -83,6 +84,29 @@ export declare function allowReadTools(home?: string): AllowReadToolsResult;
83
84
  * @param result - What `allowReadTools` returned.
84
85
  */
85
86
  export declare function describeAllowReadTools(result: AllowReadToolsResult): string;
87
+ /** What the interactive setup asks before writing the line. */
88
+ export declare const CLAUDE_MD_QUESTION = "Pharaoh: add one line to ~/.claude/CLAUDE.md so Claude checks Pharaoh before searching your code?\n \"In repos Pharaoh has mapped, before searching or reading files, ask Pharaoh where the relevant code is, what calls it, and whether what you need already exists.\"\n[Y/n] ";
89
+ /** What `offerClaudeMdLine` did; `describeClaudeMdOutcome` words it. */
90
+ export type ClaudeMdOutcome = "added" | "updated" | "declined" | "kept" | "failed" | "current" | "answered" | "cancelled" | "no-terminal" | "no-claude";
91
+ /**
92
+ * Offer the Pharaoh-first line in ~/.claude/CLAUDE.md. Interactive setup only,
93
+ * never a proxy start. Asks only when the block is absent and the user hasn't
94
+ * answered before; a stale block (an earlier yes) is updated without asking.
95
+ * The line is appended inside markers, and nothing else in the file changes.
96
+ *
97
+ * @param ask - Asks the user: true for yes, false for no, null when input ended.
98
+ * @param isTerminal - Whether stdin and stderr are a terminal; false asks and writes nothing.
99
+ * @param home - Home directory override (defaults to os.homedir()).
100
+ * @returns What happened, for `describeClaudeMdOutcome`.
101
+ */
102
+ export declare function offerClaudeMdLine(ask: () => Promise<boolean | null>, isTerminal: boolean, home?: string): Promise<ClaudeMdOutcome>;
103
+ /**
104
+ * The line the interactive setup prints for an `offerClaudeMdLine` outcome, or
105
+ * null when there is nothing to say (already current, already answered, no terminal).
106
+ *
107
+ * @param outcome - What `offerClaudeMdLine` returned.
108
+ */
109
+ export declare function describeClaudeMdOutcome(outcome: ClaudeMdOutcome): string | null;
86
110
  /** What `runInstallSkills` wants the caller to tell the user. */
87
111
  export interface InstallResult {
88
112
  /** Files in ~/.claude/commands/ left as they were because they are not Pharaoh's. */
@@ -6,7 +6,7 @@
6
6
  * and OpenClaw skills in ~/.openclaw/skills/. Also merges the Pharaoh MCP
7
7
  * server config into OpenClaw.
8
8
  */
9
- import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
9
+ import { closeSync, constants, cpSync, existsSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
10
10
  import { homedir } from "node:os";
11
11
  import { dirname, join } from "node:path";
12
12
  import { fileURLToPath } from "node:url";
@@ -202,8 +202,9 @@ function removeLegacyClaudePlugin(home) {
202
202
  /** Plugin id the pre-marketplace installer wrote into installed_plugins.json. */
203
203
  const LEGACY_PLUGIN_ID = "pharaoh@pharaoh-so";
204
204
  /**
205
- * Uninstall Pharaoh's Claude Code plugin: through the CLI, then the local
206
- * marketplace directory and any pre-marketplace install.
205
+ * Uninstall Pharaoh's Claude Code plugin (through the CLI, then the local
206
+ * marketplace directory and any pre-marketplace install) and its block in
207
+ * ~/.claude/CLAUDE.md. Run by --clean and --logout.
207
208
  *
208
209
  * @param home - Home directory override (defaults to os.homedir()).
209
210
  * @param claude - `claude` CLI runner (defaults to the real CLI for `home`).
@@ -213,6 +214,7 @@ export function uninstallClaudePlugin(home = homedir(), claude = claudeCliFor(ho
213
214
  claude(["plugin", "marketplace", "remove", MARKETPLACE_NAME]);
214
215
  rmSync(claudeMarketplaceDir(home), { recursive: true, force: true });
215
216
  removeLegacyClaudePlugin(home);
217
+ removeClaudeMdBlock(home);
216
218
  }
217
219
  /**
218
220
  * Copy every skill directory from `srcDir` to `dstDir`, prefixing both the
@@ -675,6 +677,199 @@ export function describeAllowReadTools(result) {
675
677
  return "Pharaoh: ~/.claude/settings.json is not valid JSON; left as is. Pharaoh's tools ask before they run.";
676
678
  }
677
679
  }
680
+ // ── Claude Code instructions (~/.claude/CLAUDE.md) ──────────────────
681
+ //
682
+ // Agents acted on the Pharaoh-first line only from a CLAUDE.md, not from server
683
+ // instructions or a hook (benchmarks/adoption/results/2026-10-23-everyday-auto-line.md).
684
+ // The file is the user's: the interactive setup adds the line on a yes, inside
685
+ // markers, and --clean/--logout remove exactly what it wrote.
686
+ /** The Pharaoh-first line; same text as PHARAOH_SCOPED_LINE in scripts/adoption-grade.ts. */
687
+ const PHARAOH_CLAUDE_MD_LINE = "In repos Pharaoh has mapped, before searching or reading files, ask Pharaoh where the relevant code is, what calls it, and whether what you need already exists.";
688
+ /** What the interactive setup asks before writing the line. */
689
+ export const CLAUDE_MD_QUESTION = `Pharaoh: add one line to ~/.claude/CLAUDE.md so Claude checks Pharaoh before searching your code?\n "${PHARAOH_CLAUDE_MD_LINE}"\n[Y/n] `;
690
+ /** File under ~/.pharaoh holding the user's answer, so setup asks once. */
691
+ const CLAUDE_MD_ANSWER_FILE = "claude-md-answer";
692
+ /**
693
+ * Open flags that refuse a symlink even if one is swapped in after the check
694
+ * (SC-7; O_NOFOLLOW is undefined on Windows). A new block is appended, so the
695
+ * user's bytes are never rewritten; a stale block or a removal rewrites the file.
696
+ */
697
+ const NO_FOLLOW = constants.O_NOFOLLOW ?? 0;
698
+ const APPEND_NO_FOLLOW = constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND | NO_FOLLOW;
699
+ const REWRITE_NO_FOLLOW = constants.O_WRONLY | constants.O_TRUNC | NO_FOLLOW;
700
+ /** Write `text` to `path` opened with `flags` (APPEND_NO_FOLLOW or REWRITE_NO_FOLLOW). */
701
+ function writeNoFollow(path, text, flags) {
702
+ const fd = openSync(path, flags);
703
+ try {
704
+ writeFileSync(fd, text);
705
+ }
706
+ finally {
707
+ closeSync(fd);
708
+ }
709
+ }
710
+ /**
711
+ * Pharaoh's block. `pharaoh-mcp:start+nl` records that the installer also added
712
+ * the newline before it (the file didn't end in one), so removal takes it back too.
713
+ */
714
+ function claudeMdBlock(addedNewline) {
715
+ const start = `<!-- pharaoh-mcp:start${addedNewline ? "+nl" : ""} (managed by npx ${PACKAGE_NAME}; --clean or --logout removes it) -->`;
716
+ return `${start}\n${PHARAOH_CLAUDE_MD_LINE}\n<!-- pharaoh-mcp:end -->\n`;
717
+ }
718
+ /**
719
+ * Find the one Pharaoh block in `text`: null when there is none, "broken"
720
+ * when a marker is missing its pair, repeated or out of order.
721
+ */
722
+ function findClaudeMdBlock(text) {
723
+ const starts = [...text.matchAll(/^<!-- pharaoh-mcp:start(\+nl)?\b[^\n]*\n/gm)];
724
+ const ends = [...text.matchAll(/^<!-- pharaoh-mcp:end -->\n?/gm)];
725
+ if (starts.length === 0 && ends.length === 0)
726
+ return null;
727
+ const [start, end] = [starts[0], ends[0]];
728
+ const body = start ? start.index + start[0].length : 0;
729
+ if (starts.length !== 1 || ends.length !== 1 || end.index < body)
730
+ return "broken";
731
+ return {
732
+ from: start.index,
733
+ body,
734
+ to: end.index,
735
+ through: end.index + end[0].length,
736
+ addedNewline: start[1] !== undefined,
737
+ };
738
+ }
739
+ /** Read ~/.claude/CLAUDE.md without following a symlink. */
740
+ function readClaudeMd(home) {
741
+ const path = join(home, ".claude", "CLAUDE.md");
742
+ const dir = lstatSync(join(home, ".claude"), { throwIfNoEntry: false });
743
+ if (!dir)
744
+ return { state: "no-claude", path, text: "", block: null };
745
+ const file = lstatSync(path, { throwIfNoEntry: false });
746
+ if (!dir.isDirectory() || (file && !file.isFile())) {
747
+ return { state: "kept", path, text: "", block: null };
748
+ }
749
+ const text = file ? readFileSync(path, "utf-8") : "";
750
+ const block = findClaudeMdBlock(text);
751
+ if (block === "broken")
752
+ return { state: "kept", path, text, block: null };
753
+ if (!block) {
754
+ return {
755
+ state: text.includes(PHARAOH_CLAUDE_MD_LINE) ? "current" : "absent",
756
+ path,
757
+ text,
758
+ block,
759
+ };
760
+ }
761
+ const current = text.slice(block.body, block.to) === `${PHARAOH_CLAUDE_MD_LINE}\n`;
762
+ return { state: current ? "current" : "stale", path, text, block };
763
+ }
764
+ /**
765
+ * Offer the Pharaoh-first line in ~/.claude/CLAUDE.md. Interactive setup only,
766
+ * never a proxy start. Asks only when the block is absent and the user hasn't
767
+ * answered before; a stale block (an earlier yes) is updated without asking.
768
+ * The line is appended inside markers, and nothing else in the file changes.
769
+ *
770
+ * @param ask - Asks the user: true for yes, false for no, null when input ended.
771
+ * @param isTerminal - Whether stdin and stderr are a terminal; false asks and writes nothing.
772
+ * @param home - Home directory override (defaults to os.homedir()).
773
+ * @returns What happened, for `describeClaudeMdOutcome`.
774
+ */
775
+ export async function offerClaudeMdLine(ask, isTerminal, home = homedir()) {
776
+ if (!isTerminal)
777
+ return "no-terminal";
778
+ const answerPath = join(home, ".pharaoh", CLAUDE_MD_ANSWER_FILE);
779
+ try {
780
+ let file = readClaudeMd(home);
781
+ // A saved answer also silences the add-it-yourself line of "kept": a no stands.
782
+ const answered = lstatSync(answerPath, { throwIfNoEntry: false }) !== undefined;
783
+ if (answered && (file.state === "absent" || file.state === "kept"))
784
+ return "answered";
785
+ if (file.state === "absent") {
786
+ const yes = await ask();
787
+ if (yes === null)
788
+ return "cancelled";
789
+ if (!yes) {
790
+ saveClaudeMdAnswer(home, answerPath, "no");
791
+ return "declined";
792
+ }
793
+ file = readClaudeMd(home); // again: it may have changed while the user answered
794
+ }
795
+ if (file.state === "absent") {
796
+ const newline = file.text !== "" && !file.text.endsWith("\n");
797
+ writeNoFollow(file.path, `${newline ? "\n" : ""}${claudeMdBlock(newline)}`, APPEND_NO_FOLLOW);
798
+ saveClaudeMdAnswer(home, answerPath, "yes");
799
+ return "added";
800
+ }
801
+ if (file.state === "stale") {
802
+ const { body, to } = file.block;
803
+ const next = `${file.text.slice(0, body)}${PHARAOH_CLAUDE_MD_LINE}\n${file.text.slice(to)}`;
804
+ writeNoFollow(file.path, next, REWRITE_NO_FOLLOW);
805
+ return "updated";
806
+ }
807
+ return file.state;
808
+ }
809
+ catch {
810
+ // ALLOWED-CATCH: setup is done; the user gets the line to add by hand, and no saved answer, so the next setup asks again
811
+ return "failed";
812
+ }
813
+ }
814
+ /** Save the user's answer (0600). Never through a symlink: `wx` refuses any existing path. */
815
+ function saveClaudeMdAnswer(home, answerPath, answer) {
816
+ try {
817
+ ensurePharaohHome(join(home, ".pharaoh"));
818
+ writeFileSync(answerPath, `${answer}\n`, { mode: 0o600, flag: "wx" });
819
+ }
820
+ catch {
821
+ // ALLOWED-CATCH: an answer that can't be saved only means the next setup asks again
822
+ }
823
+ }
824
+ /**
825
+ * The line the interactive setup prints for an `offerClaudeMdLine` outcome, or
826
+ * null when there is nothing to say (already current, already answered, no terminal).
827
+ *
828
+ * @param outcome - What `offerClaudeMdLine` returned.
829
+ */
830
+ export function describeClaudeMdOutcome(outcome) {
831
+ const byHand = `To have Claude check Pharaoh first, add this line to it yourself:\n ${PHARAOH_CLAUDE_MD_LINE}`;
832
+ switch (outcome) {
833
+ case "added":
834
+ return `Pharaoh: added the line to ~/.claude/CLAUDE.md, between pharaoh-mcp markers. npx ${PACKAGE_NAME} --clean removes it.`;
835
+ case "updated":
836
+ return "Pharaoh: updated its line in ~/.claude/CLAUDE.md.";
837
+ case "declined":
838
+ return "Pharaoh: left ~/.claude/CLAUDE.md as it is. Setup won't ask again.";
839
+ case "kept":
840
+ return `Pharaoh: left ~/.claude/CLAUDE.md as it is (it's a symlink, or its Pharaoh markers don't pair up). ${byHand}`;
841
+ case "failed":
842
+ return `Pharaoh: couldn't write ~/.claude/CLAUDE.md. ${byHand}`;
843
+ default:
844
+ return null;
845
+ }
846
+ }
847
+ /**
848
+ * Remove Pharaoh's block from ~/.claude/CLAUDE.md, taking out only bytes the
849
+ * installer wrote, and forget the answer so the next setup asks again. A file
850
+ * left empty stays: there's no telling whether the user had it before. Never
851
+ * throws: --clean and --logout go on to their other steps.
852
+ *
853
+ * @param home - Home directory override (defaults to os.homedir()).
854
+ */
855
+ function removeClaudeMdBlock(home) {
856
+ try {
857
+ const { path, text, block } = readClaudeMd(home);
858
+ if (block) {
859
+ // The newline before a start+nl marker is Pharaoh's only while the block
860
+ // ends the file; once the user writes below it, it ends their line.
861
+ const ownsNewline = block.addedNewline && block.from > 0 && block.through === text.length;
862
+ const from = ownsNewline ? block.from - 1 : block.from;
863
+ writeNoFollow(path, text.slice(0, from) + text.slice(block.through), REWRITE_NO_FOLLOW);
864
+ }
865
+ rmSync(join(home, ".pharaoh", CLAUDE_MD_ANSWER_FILE), { force: true });
866
+ }
867
+ catch (err) {
868
+ // ALLOWED-CATCH: the other cleanup steps (credentials on --logout) must still run; the user is told what's left
869
+ const code = err.code;
870
+ process.stderr.write(`Pharaoh: couldn't remove its line from ~/.claude/CLAUDE.md${code ? ` (${code})` : ""}. Delete the pharaoh-mcp block by hand.\n`);
871
+ }
872
+ }
678
873
  /**
679
874
  * Main entry point for --install-skills, also run on every proxy start.
680
875
  *
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.53",
4
+ "version": "0.3.54",
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",