@pharaoh-so/mcp 0.3.52 → 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 +1 -1
- package/CHANGELOG.md +20 -0
- package/dist/helpers.d.ts +14 -4
- package/dist/helpers.js +54 -1
- package/dist/index.js +22 -8
- package/dist/install-skills.d.ts +26 -2
- package/dist/install-skills.js +200 -5
- package/dist/setup.d.ts +19 -0
- package/dist/setup.js +45 -11
- package/package.json +1 -1
- package/trace-app.html +299 -112
package/.mcp.json
CHANGED
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,26 @@ 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
|
+
|
|
18
|
+
## [0.3.53] - 2026-10-23
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
- 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.
|
|
22
|
+
- 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.
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
- 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".
|
|
26
|
+
- When `claude mcp add` fails, setup ends without "Pharaoh is ready" and names the `claude mcp add` command to run by hand.
|
|
27
|
+
|
|
8
28
|
## [0.3.52] - 2026-10-23
|
|
9
29
|
|
|
10
30
|
### Added
|
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
|
|
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:
|
|
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
|
-
|
|
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);
|
|
@@ -80,6 +81,13 @@ async function main() {
|
|
|
80
81
|
// Running `npx @pharaoh-so/mcp` is the only command a user needs.
|
|
81
82
|
if (isInteractive) {
|
|
82
83
|
process.stderr.write(`${BANNER}\n`);
|
|
84
|
+
// Setup registers everything through the `claude` CLI on PATH. Without it,
|
|
85
|
+
// stop before sign-in: nothing is written and saved credentials stay.
|
|
86
|
+
const { claudeCliMissing, hasClaude, runSetup } = await import("./setup.js");
|
|
87
|
+
if (!hasClaude()) {
|
|
88
|
+
printLines(...claudeCliMissing());
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
83
91
|
// Always re-authenticate for a fresh session
|
|
84
92
|
deleteCredentials();
|
|
85
93
|
const deviceCode = await requestDeviceCode(server);
|
|
@@ -92,10 +100,11 @@ async function main() {
|
|
|
92
100
|
const newCreds = tokenToCredentials(token, sseUrl);
|
|
93
101
|
writeCredentials(newCreds);
|
|
94
102
|
printAuthSuccess(token.github_login ?? null, token.tenant_name ?? null, token.repos?.length ?? 0);
|
|
95
|
-
// Register MCP server + install skills (silent — user doesn't need internals)
|
|
103
|
+
// Register MCP server + install skills (silent — user doesn't need internals).
|
|
104
|
+
// runSetup has printed the failure and the command to run by hand; no success line.
|
|
105
|
+
if (!runSetup({ silent: true }))
|
|
106
|
+
process.exit(1);
|
|
96
107
|
process.stderr.write(" Setting up...");
|
|
97
|
-
const { runSetup } = await import("./setup.js");
|
|
98
|
-
runSetup({ silent: true });
|
|
99
108
|
const { keptCommands } = runInstallSkills(undefined, { silent: true });
|
|
100
109
|
process.stderr.write(" done.\n");
|
|
101
110
|
// Two things the user must hear about: their settings.json (the only
|
|
@@ -105,6 +114,11 @@ async function main() {
|
|
|
105
114
|
const kept = formatKeptCommands(keptCommands);
|
|
106
115
|
if (kept)
|
|
107
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);
|
|
108
122
|
// The registration is pinned to this version; upgrading is a deliberate
|
|
109
123
|
// re-run. Interactive only, and quiet when the registry can't be reached.
|
|
110
124
|
const latest = await fetchLatestVersion();
|
package/dist/install-skills.d.ts
CHANGED
|
@@ -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
|
|
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. */
|
package/dist/install-skills.js
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
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";
|
|
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/). */
|
|
@@ -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
|
|
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
|
*
|
|
@@ -766,7 +961,7 @@ export function runInstallSkills(home = homedir(), options) {
|
|
|
766
961
|
"Pharaoh: no supported skill platform detected.",
|
|
767
962
|
"",
|
|
768
963
|
"Supported platforms:",
|
|
769
|
-
|
|
964
|
+
` • Claude Code — install the CLI from ${CLAUDE_CLI_SETUP_URL}`,
|
|
770
965
|
" • Codex — install from https://developers.openai.com/codex",
|
|
771
966
|
" • OpenClaw — install from https://openclaw.dev/install",
|
|
772
967
|
"",
|
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
|
-
/**
|
|
15
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
146
|
-
if (!
|
|
147
|
-
printLines("Pharaoh: failed to register MCP server.", `Try manually: ${
|
|
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.
|
|
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",
|
package/trace-app.html
CHANGED
|
@@ -6,60 +6,117 @@
|
|
|
6
6
|
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'none'; img-src 'none'; font-src 'none'; object-src 'none'; frame-src 'none'; base-uri 'none'; form-action 'none'">
|
|
7
7
|
<title>Pharaoh Trace</title>
|
|
8
8
|
<style>
|
|
9
|
-
:root { color-scheme:dark; --
|
|
10
|
-
|
|
9
|
+
:root { color-scheme:dark; --stone:#2a2520; --rule:#3a352e; --track:#342e28; --hover:#332d27; --gold:#d4a843; --papyrus:#e8dcc8; --text:#c4b99a; --muted:#9d927b; --red:#f87171; --amber:#f0c855; --green:#4ade80;
|
|
10
|
+
--s0:#d4a843; --s1:#b08a3a; --s2:#8c6e31; --s3:#6e5728; --s4:#574522; --s-muted:#5f574a; --s-warning:#f0c855; --s-error:#ef4444;
|
|
11
|
+
--sans:"DM Sans",system-ui,-apple-system,"Segoe UI",sans-serif; --mono:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace; }
|
|
12
|
+
:root[data-theme="light"] { color-scheme:light; --stone:#f7f3ea; --rule:#e2d8c4; --track:#ece4d3; --hover:#efe8da; --gold:#946c0f; --papyrus:#2a2520; --text:#4a4235; --muted:#71675a; --red:#b42318; --amber:#9a5b00; --green:#15803d;
|
|
13
|
+
--s0:#b08515; --s1:#c9a24a; --s2:#d9bb72; --s3:#e6cf98; --s4:#efdfb8; --s-muted:#cfc4ad; --s-warning:#e0a526; --s-error:#d93f2f; }
|
|
11
14
|
* { box-sizing:border-box; }
|
|
12
|
-
body { margin:0; min-width:0; }
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
#
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
.
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
.
|
|
37
|
-
|
|
38
|
-
.
|
|
39
|
-
.
|
|
40
|
-
.
|
|
41
|
-
.
|
|
42
|
-
.
|
|
43
|
-
.
|
|
44
|
-
.
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
15
|
+
html,body { margin:0; background:transparent; min-width:0; }
|
|
16
|
+
.label,.detail,.badge { overflow-wrap:anywhere; min-width:0; unicode-bidi:plaintext; }
|
|
17
|
+
.mark-s { font:600 12px var(--mono); width:1ch; }
|
|
18
|
+
#trace { background:var(--stone); color:var(--text); border:1px solid var(--rule); border-radius:6px; padding:9px 12px; font:13px/1.45 var(--sans); min-width:0; }
|
|
19
|
+
.head { display:flex; flex-wrap:wrap; align-items:baseline; gap:2px 10px; }
|
|
20
|
+
.mark { color:var(--gold); font-size:14px; line-height:1; }
|
|
21
|
+
.headline { flex:1 1 16em; margin:0; min-width:0; overflow-wrap:anywhere; }
|
|
22
|
+
#kind { color:var(--muted); }
|
|
23
|
+
#target { color:var(--papyrus); font:600 13px/1.45 var(--mono); }
|
|
24
|
+
#target,.legend > span,.rows .label { unicode-bidi:isolate; }
|
|
25
|
+
#title { color:var(--papyrus); font-weight:600; }
|
|
26
|
+
#summary { margin-left:8px; }
|
|
27
|
+
.num { color:var(--gold); font:600 12.5px var(--mono); }
|
|
28
|
+
.meta { margin-left:auto; display:flex; gap:10px; white-space:nowrap; font-size:12px; }
|
|
29
|
+
#verdict[data-state="error"] { color:var(--red); } #verdict[data-state="warning"] { color:var(--amber); } #verdict[data-state="ok"] { color:var(--green); }
|
|
30
|
+
#sha { color:var(--muted); font:11.5px var(--mono); }
|
|
31
|
+
.bar { position:relative; display:flex; gap:2px; height:6px; margin:8px 0 0; border-radius:3px; overflow:hidden; background:var(--track); }
|
|
32
|
+
.bar i,.mini i { display:block; flex:0 1 0; min-width:0; }
|
|
33
|
+
.bar i[data-shown] { min-width:3px; }
|
|
34
|
+
.s0 { background:var(--s0); } .s1 { background:var(--s1); } .s2 { background:var(--s2); } .s3 { background:var(--s3); } .s4 { background:var(--s4); }
|
|
35
|
+
.muted { background:var(--s-muted); } .warning { background:var(--s-warning); } .error { background:var(--s-error); }
|
|
36
|
+
.foot { display:flex; align-items:baseline; gap:4px 14px; margin-top:6px; flex-wrap:wrap; }
|
|
37
|
+
.legend { display:flex; flex-wrap:wrap; gap:2px 14px; margin:0; color:var(--muted); font-size:12px; min-width:0; flex:1 1 auto; }
|
|
38
|
+
#trace[data-reveal] .legend { opacity:0; }
|
|
39
|
+
.legend b { color:var(--text); font:500 11.5px var(--mono); margin-left:4px; }
|
|
40
|
+
.sw { display:inline-block; width:8px; height:8px; border-radius:1px; margin-right:5px; }
|
|
41
|
+
.toggle { margin-left:auto; display:flex; align-items:baseline; gap:4px; border:0; background:none; padding:2px 0; color:var(--gold); font:12px var(--sans); cursor:pointer; white-space:nowrap; }
|
|
42
|
+
.toggle:hover { color:var(--papyrus); }
|
|
43
|
+
.toggle:focus-visible,summary:focus-visible { outline:2px solid var(--gold); outline-offset:2px; border-radius:2px; }
|
|
44
|
+
.chev { display:inline-block; font-size:10px; }
|
|
45
|
+
.toggle[aria-expanded="true"] .chev { transform:rotate(90deg); }
|
|
46
|
+
.caveat { margin:6px 0 0; color:var(--amber); font-size:12px; overflow-wrap:anywhere; }
|
|
47
|
+
.caveat span + span::before { content:" · "; color:var(--muted); }
|
|
48
|
+
.rows { list-style:none; margin:4px 0 0; padding:0; }
|
|
49
|
+
.rows li { display:flex; flex-wrap:wrap; align-items:baseline; gap:0 8px; padding:4px 0 5px; border-top:1px solid var(--rule); min-width:0; }
|
|
50
|
+
.rows li:first-child { border-top:0; }
|
|
51
|
+
.rows .kind { color:var(--muted); font-size:12.5px; }
|
|
52
|
+
.rows .label { color:var(--papyrus); font:12.5px var(--mono); }
|
|
53
|
+
.rows .prose { color:var(--text); font:12.5px var(--sans); }
|
|
54
|
+
.rows .detail { color:var(--text); font-size:12.5px; }
|
|
55
|
+
.rows .end { margin-left:auto; font-size:12px; white-space:nowrap; }
|
|
56
|
+
.rows .more { color:var(--muted); font-size:12px; }
|
|
57
|
+
.rows .mini { display:flex; gap:1px; flex:1 0 100%; height:3px; margin-top:3px; border-radius:2px; overflow:hidden; background:var(--track); }
|
|
58
|
+
[data-state="ok"] > .mark-s { color:var(--green); } [data-state="warning"] > .mark-s, [data-state="pending"] > .mark-s { color:var(--amber); } [data-state="error"] > .mark-s, .end[data-state="error"] { color:var(--red); } .end[data-state="warning"] { color:var(--amber); }
|
|
59
|
+
.drawer { display:grid; grid-template-rows:0fr; }
|
|
60
|
+
.drawer[data-open] { grid-template-rows:1fr; }
|
|
61
|
+
.drawer-inner { overflow:hidden; min-height:0; }
|
|
62
|
+
.drawer[data-open] .drawer-inner { border-top:1px solid var(--rule); margin-top:8px; padding-top:4px; }
|
|
63
|
+
ul.tree,.tree ul { list-style:none; margin:0; padding:0; }
|
|
64
|
+
.tree ul { margin:0 0 4px 6px; padding-left:10px; border-left:1px solid var(--rule); }
|
|
65
|
+
.tree summary,.leaf { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:10px; align-items:baseline; padding:3px 0; font-size:12.5px; min-width:0; }
|
|
66
|
+
.tree summary { list-style:none; cursor:pointer; color:var(--text); }
|
|
67
|
+
.tree summary::-webkit-details-marker { display:none; }
|
|
68
|
+
.tree summary:hover { background:var(--hover); }
|
|
69
|
+
.tree summary .label::before { content:"▸"; display:inline-block; color:var(--gold); font-size:10px; width:12px; }
|
|
70
|
+
.tree details[open] > summary .label::before { transform:rotate(90deg); }
|
|
71
|
+
.tree details[open] > summary .label { color:var(--gold); }
|
|
72
|
+
.leaf .label { color:var(--papyrus); font:12px var(--mono); }
|
|
73
|
+
.tree > li > .leaf { padding-left:12px; }
|
|
74
|
+
.tree > li > .leaf .label { color:var(--text); font:12.5px var(--sans); }
|
|
75
|
+
.tree .label .detail { color:var(--muted); font:11px var(--mono); margin-left:8px; }
|
|
76
|
+
.tree .label .caveat { color:var(--amber); font:11.5px var(--sans); margin:0 0 0 8px; }
|
|
77
|
+
.badge { color:var(--muted); font:11.5px var(--mono); text-align:right; white-space:nowrap; }
|
|
78
|
+
.badge[data-state="ok"] { color:var(--green); } .badge[data-state="warning"],.badge[data-state="pending"] { color:var(--amber); } .badge[data-state="error"] { color:var(--red); }
|
|
79
|
+
.meter { display:block; height:3px; margin-top:3px; border-radius:2px; background:var(--track); max-width:240px; }
|
|
80
|
+
.meter i { display:block; height:100%; border-radius:2px; background:var(--gold); }
|
|
81
|
+
.notes,.provenance { color:var(--muted); font-size:11.5px; margin:6px 0 2px; }
|
|
82
|
+
.notes p { margin:2px 0; overflow-wrap:anywhere; white-space:pre-wrap; }
|
|
83
|
+
#fallback { font:11.5px/1.5 var(--mono); color:var(--text); margin:6px 0 0; white-space:pre-wrap; overflow-wrap:anywhere; unicode-bidi:plaintext; }
|
|
84
|
+
@media(max-width:400px) { #trace { padding:8px 10px; } #summary { margin-left:0; } #summary:not([hidden]) { display:block; } .tree summary,.leaf { grid-template-columns:minmax(0,1fr); gap:0; } .badge { text-align:left; } }
|
|
85
|
+
/* Motion only for people who have not asked to reduce it. */
|
|
86
|
+
@media(prefers-reduced-motion:no-preference) {
|
|
87
|
+
.bar i,.mini i { transition:flex-grow .7s cubic-bezier(.22,.8,.24,1); }
|
|
88
|
+
.legend { transition:opacity .4s ease .2s; }
|
|
89
|
+
.chev { transition:transform .3s ease; }
|
|
90
|
+
.drawer { transition:grid-template-rows .3s ease; }
|
|
91
|
+
.tree summary .label::before { transition:transform .2s ease; }
|
|
92
|
+
#trace[data-state="pending"] .bar::after,#trace[data-state="waiting"] .bar::after { content:""; position:absolute; top:0; bottom:0; left:0; width:35%; background:linear-gradient(90deg,transparent,var(--gold),transparent); opacity:.55; animation:sweep 1.3s ease-in-out infinite; }
|
|
93
|
+
@keyframes sweep { from { transform:translateX(-100%); } to { transform:translateX(290%); } }
|
|
94
|
+
}
|
|
48
95
|
[hidden] { display:none; }
|
|
49
|
-
@media(max-width:400px) { #trace { padding:8px 10px 7px; } summary,.row { grid-template-columns:15px minmax(0,1fr); gap:4px; } .badge { grid-column:2; text-align:left; } .children { margin-left:9px; padding-left:7px; } }
|
|
50
|
-
@media(prefers-reduced-motion:reduce) { #trace[data-pending="true"] #status { animation:none; } }
|
|
51
96
|
</style>
|
|
52
97
|
</head>
|
|
53
98
|
<body>
|
|
54
|
-
<main id="trace" data-
|
|
55
|
-
<
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
<
|
|
61
|
-
<
|
|
62
|
-
|
|
99
|
+
<main id="trace" data-state="waiting">
|
|
100
|
+
<div class="head">
|
|
101
|
+
<span class="mark" aria-hidden="true">☥</span>
|
|
102
|
+
<p class="headline"><span id="kind"></span><span id="target"></span><span id="title">Pharaoh</span><span id="summary" role="status" aria-live="polite">Waiting for the tool</span></p>
|
|
103
|
+
<span id="meta" class="meta"><span id="verdict" hidden></span><span id="sha" hidden></span></span>
|
|
104
|
+
</div>
|
|
105
|
+
<div id="bar" class="bar" aria-hidden="true"></div>
|
|
106
|
+
<div id="foot" class="foot">
|
|
107
|
+
<p id="legend" class="legend"></p>
|
|
108
|
+
<button id="toggle" class="toggle" type="button" aria-expanded="false" aria-controls="drawer" hidden><span id="toggle-label">Details</span><span class="chev" aria-hidden="true">▸</span></button>
|
|
109
|
+
</div>
|
|
110
|
+
<p id="caveat" class="caveat" hidden></p>
|
|
111
|
+
<ul id="rows" class="rows" hidden></ul>
|
|
112
|
+
<div id="drawer" class="drawer" inert>
|
|
113
|
+
<div class="drawer-inner">
|
|
114
|
+
<ul id="tree" class="tree" aria-label="Result evidence"></ul>
|
|
115
|
+
<pre id="fallback" hidden></pre>
|
|
116
|
+
<div id="notes" class="notes" hidden></div>
|
|
117
|
+
<p id="provenance" class="provenance" hidden></p>
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
63
120
|
</main>
|
|
64
121
|
<script>
|
|
65
122
|
(() => {
|
|
@@ -67,11 +124,18 @@
|
|
|
67
124
|
const META_KEY = "so.pharaoh/trace";
|
|
68
125
|
const PROTOCOL_VERSION = "2026-01-26";
|
|
69
126
|
// Kept in parity with TRACE_LIMITS in trace-contract.ts by the resource test.
|
|
70
|
-
const LIMITS = { bytes:65536, nodes:120, depth:6, label:512, detail:2048, badge:128, summary:1024, notes:16, note:1024, repo:256 };
|
|
127
|
+
const LIMITS = { bytes:65536, nodes:120, depth:6, label:512, detail:2048, badge:128, summary:1024, notes:16, note:1024, repo:256, segments:8 };
|
|
71
128
|
const MAX_TEXT = 32768;
|
|
129
|
+
const MAX_ROWS = 5;
|
|
130
|
+
const TONES = ["warning","error","muted"];
|
|
131
|
+
const STATES = ["ok","pending","warning","error"];
|
|
132
|
+
// What each tool is doing while it runs, shown with the argument it targets.
|
|
133
|
+
const VERBS = { get_blast_radius:"Tracing callers of", get_unused_code:"Checking reachability in", search_functions:"Searching for", get_codebase_map:"Mapping", get_module_context:"Reading module", pharaoh_recon:"Running recon on", pharaoh_account:"Checking account" };
|
|
72
134
|
const root = document.getElementById("trace");
|
|
73
|
-
const fields = Object.fromEntries(["
|
|
135
|
+
const fields = Object.fromEntries(["kind","target","title","summary","meta","verdict","sha","bar","caveat","rows","foot","legend","toggle","toggle-label","drawer","tree","fallback","notes","provenance"].map(id => [id,document.getElementById(id)]));
|
|
136
|
+
const compact = new Intl.NumberFormat("en",{notation:"compact",maximumFractionDigits:1});
|
|
74
137
|
const initId = "pharaoh-trace-initialize";
|
|
138
|
+
let tool = "";
|
|
75
139
|
let ready = false;
|
|
76
140
|
let closed = false;
|
|
77
141
|
let cancelled = false;
|
|
@@ -81,37 +145,76 @@
|
|
|
81
145
|
const record = value => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
82
146
|
const string = (value,max) => typeof value === "string" && value.length <= max;
|
|
83
147
|
const send = message => { if (!closed) window.parent.postMessage({jsonrpc:"2.0",...message},"*"); };
|
|
84
|
-
function
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
148
|
+
function element(tag,className,text) {
|
|
149
|
+
const node = document.createElement(tag);
|
|
150
|
+
if (className) node.className = className;
|
|
151
|
+
if (text !== undefined) node.textContent = text;
|
|
152
|
+
return node;
|
|
153
|
+
}
|
|
154
|
+
// Counts read at a glance: digit runs become gold monospace spans, everything else stays text.
|
|
155
|
+
function rich(target,text) {
|
|
156
|
+
target.replaceChildren();
|
|
157
|
+
for (const part of String(text || "").split(/(\b\d[\d,.]*\b)/)) {
|
|
158
|
+
if (part) target.append(/^\d/.test(part) ? element("span","num",part) : part);
|
|
159
|
+
}
|
|
88
160
|
}
|
|
89
161
|
function display(field,text) {
|
|
90
162
|
fields[field].textContent = text || "";
|
|
91
163
|
fields[field].hidden = !text;
|
|
92
164
|
}
|
|
165
|
+
// "Impact: attachTraceView" reads as a muted kind and a monospace target.
|
|
166
|
+
const splitTitle = title => /^([^:]{1,40}): (.+)$/.exec(title);
|
|
167
|
+
function headline(title,summary) {
|
|
168
|
+
const split = splitTitle(title);
|
|
169
|
+
display("kind",split ? `${split[1]} ` : "");
|
|
170
|
+
display("target",split ? split[2] : "");
|
|
171
|
+
display("title",split ? "" : title);
|
|
172
|
+
rich(fields.summary,summary);
|
|
173
|
+
fields.summary.hidden = !summary;
|
|
174
|
+
}
|
|
175
|
+
// The toggle ends the legend line, or the headline when there is no legend.
|
|
176
|
+
function place(withLegend) {
|
|
177
|
+
(withLegend ? fields.foot : fields.meta).append(fields.toggle);
|
|
178
|
+
fields.foot.hidden = !withLegend;
|
|
179
|
+
}
|
|
180
|
+
function setOpen(open) {
|
|
181
|
+
fields.drawer.toggleAttribute("data-open",open);
|
|
182
|
+
fields.drawer.toggleAttribute("inert",!open);
|
|
183
|
+
fields.toggle.setAttribute("aria-expanded",String(open));
|
|
184
|
+
fields["toggle-label"].textContent = open ? "Hide" : "Details";
|
|
185
|
+
}
|
|
186
|
+
function clearResult() {
|
|
187
|
+
setOpen(false);
|
|
188
|
+
for (const field of ["bar","rows","legend","tree","notes"]) fields[field].replaceChildren();
|
|
189
|
+
for (const field of ["verdict","sha","caveat","provenance","fallback"]) display(field,"");
|
|
190
|
+
delete fields.verdict.dataset.state;
|
|
191
|
+
fields.sha.title = "";
|
|
192
|
+
fields.bar.hidden = true;
|
|
193
|
+
fields.rows.hidden = true;
|
|
194
|
+
fields.notes.hidden = true;
|
|
195
|
+
fields.toggle.hidden = true;
|
|
196
|
+
}
|
|
93
197
|
function notes(items) {
|
|
94
|
-
fields.notes.
|
|
95
|
-
for (const text of items) {
|
|
96
|
-
const paragraph = document.createElement("p");
|
|
97
|
-
paragraph.textContent = text;
|
|
98
|
-
fields.notes.append(paragraph);
|
|
99
|
-
}
|
|
198
|
+
for (const text of items) fields.notes.append(element("p","",text));
|
|
100
199
|
fields.notes.hidden = items.length === 0;
|
|
101
200
|
}
|
|
102
|
-
function
|
|
103
|
-
fields.
|
|
104
|
-
fields.
|
|
105
|
-
display("summary","");
|
|
106
|
-
display("provenance","");
|
|
107
|
-
notes([]);
|
|
201
|
+
function caveats(items) {
|
|
202
|
+
fields.caveat.replaceChildren(...items.map(text => element("span","",text)));
|
|
203
|
+
fields.caveat.hidden = items.length === 0;
|
|
108
204
|
}
|
|
109
205
|
function fallback(title,text,notice,state="neutral") {
|
|
110
206
|
clearResult();
|
|
111
|
-
|
|
207
|
+
root.dataset.state = state === "error" ? "error" : "ready";
|
|
208
|
+
const first = text.split("\n").find(line => line.trim()) || "";
|
|
209
|
+
headline(title,first.length > 160 ? `${first.slice(0,159)}…` : first);
|
|
112
210
|
display("fallback",text);
|
|
113
|
-
|
|
211
|
+
if (state === "error" || state === "cancelled") {
|
|
212
|
+
fields.verdict.dataset.state = state === "error" ? "error" : "warning";
|
|
213
|
+
display("verdict",state === "error" ? "Failed" : "Cancelled");
|
|
214
|
+
}
|
|
114
215
|
if (notice) notes([notice]);
|
|
216
|
+
fields.toggle.hidden = !text;
|
|
217
|
+
place(false);
|
|
115
218
|
}
|
|
116
219
|
function readableText(result) {
|
|
117
220
|
if (!record(result) || !Array.isArray(result.content)) return {text:"No readable text was returned. Read the tool response in chat.",omitted:false};
|
|
@@ -129,12 +232,20 @@
|
|
|
129
232
|
}
|
|
130
233
|
return {text:text || "No readable text was returned. Read the tool response in chat.",omitted};
|
|
131
234
|
}
|
|
235
|
+
function validGlance(glance) {
|
|
236
|
+
if (glance === undefined) return true;
|
|
237
|
+
if (!record(glance)) return false;
|
|
238
|
+
if (glance.segments !== undefined && (!Array.isArray(glance.segments) || glance.segments.length > LIMITS.segments || !glance.segments.every(s => record(s) && string(s.label,LIMITS.badge) && Number.isFinite(s.value) && s.value >= 0 && (s.tone === undefined || TONES.includes(s.tone))))) return false;
|
|
239
|
+
if (glance.verdict !== undefined && (!record(glance.verdict) || !string(glance.verdict.label,LIMITS.badge) || (glance.verdict.status !== undefined && !["ok","warning","error"].includes(glance.verdict.status)))) return false;
|
|
240
|
+
return glance.caveat === undefined || string(glance.caveat,LIMITS.note);
|
|
241
|
+
}
|
|
132
242
|
function validView(view) {
|
|
133
243
|
try {
|
|
134
244
|
if (!record(view) || view.version !== 1 || !string(view.title,LIMITS.label) || !view.title.trim() || !Array.isArray(view.nodes)) return false;
|
|
135
245
|
if (new TextEncoder().encode(JSON.stringify(view)).byteLength > LIMITS.bytes) return false;
|
|
136
246
|
if (view.repo !== undefined && !string(view.repo,LIMITS.repo)) return false;
|
|
137
247
|
if (view.summary !== undefined && !string(view.summary,LIMITS.summary)) return false;
|
|
248
|
+
if (!validGlance(view.glance)) return false;
|
|
138
249
|
if (view.notes !== undefined && (!Array.isArray(view.notes) || view.notes.length > LIMITS.notes || !view.notes.every(note => string(note,LIMITS.note)))) return false;
|
|
139
250
|
if (view.freshness !== undefined) {
|
|
140
251
|
const value = view.freshness;
|
|
@@ -145,71 +256,145 @@
|
|
|
145
256
|
if (++count > LIMITS.nodes || depth > LIMITS.depth || !record(node) || !string(node.label,LIMITS.label) || !node.label.trim()) return false;
|
|
146
257
|
if (node.detail !== undefined && !string(node.detail,LIMITS.detail)) return false;
|
|
147
258
|
if (node.badge !== undefined && !string(node.badge,LIMITS.badge)) return false;
|
|
148
|
-
if (node.status !== undefined && !
|
|
259
|
+
if (node.status !== undefined && !STATES.includes(node.status)) return false;
|
|
149
260
|
if (node.meter !== undefined && (!record(node.meter) || !Number.isFinite(node.meter.value) || !Number.isFinite(node.meter.total) || node.meter.total <= 0 || node.meter.value < 0 || node.meter.value > node.meter.total)) return false;
|
|
261
|
+
if (!validGlance(node.glance)) return false;
|
|
150
262
|
return node.children === undefined || (Array.isArray(node.children) && node.children.every(child => visit(child,depth + 1)));
|
|
151
263
|
};
|
|
152
264
|
return view.nodes.every(node => visit(node,1));
|
|
153
265
|
} catch { return false; }
|
|
154
266
|
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
if (
|
|
159
|
-
|
|
267
|
+
// Segments start empty and grow to their share once painted: the one motion on arrival.
|
|
268
|
+
function segments(target,items) {
|
|
269
|
+
const total = items.reduce((sum,item) => sum + item.value,0);
|
|
270
|
+
if (!total) return [];
|
|
271
|
+
let shade = 0;
|
|
272
|
+
return items.filter(item => item.value > 0).map((item,index) => {
|
|
273
|
+
const slice = element("i",item.tone || `s${Math.min(shade++,4)}`);
|
|
274
|
+
slice.style.transitionDelay = `${index * 70}ms`;
|
|
275
|
+
slice.dataset.share = String(item.value / total);
|
|
276
|
+
target.append(slice);
|
|
277
|
+
return slice;
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
function grow(slices) {
|
|
281
|
+
requestAnimationFrame(() => requestAnimationFrame(() => {
|
|
282
|
+
for (const slice of slices) { slice.style.flexGrow = slice.dataset.share; slice.dataset.shown = ""; }
|
|
283
|
+
delete root.dataset.reveal;
|
|
284
|
+
}));
|
|
285
|
+
}
|
|
286
|
+
function legend(items) {
|
|
287
|
+
let shade = 0;
|
|
288
|
+
for (const item of items.filter(item => item.value > 0)) {
|
|
289
|
+
const entry = element("span");
|
|
290
|
+
entry.append(element("span",`sw ${item.tone || `s${Math.min(shade++,4)}`}`),item.label,element("b","",compact.format(item.value)));
|
|
291
|
+
fields.legend.append(entry);
|
|
292
|
+
}
|
|
160
293
|
}
|
|
161
|
-
function
|
|
294
|
+
function verdict(target,value) {
|
|
295
|
+
if (!value) return;
|
|
296
|
+
target.textContent = value.label;
|
|
297
|
+
if (value.status) target.dataset.state = value.status;
|
|
298
|
+
target.hidden = false;
|
|
299
|
+
}
|
|
300
|
+
// Sections (recon) and status lines (states, named checks) stay visible; groups open on demand.
|
|
301
|
+
function rowFor(node,slices) {
|
|
302
|
+
const item = element("li");
|
|
303
|
+
if (node.status) {
|
|
304
|
+
item.dataset.state = node.status;
|
|
305
|
+
item.append(element("span","mark-s",{ok:"✓",pending:"…",warning:"!",error:"×"}[node.status]));
|
|
306
|
+
}
|
|
307
|
+
const split = splitTitle(node.label);
|
|
308
|
+
if (split) item.append(element("span","kind",split[1]));
|
|
309
|
+
// Names read in monospace; sentences (state messages) read as prose.
|
|
310
|
+
item.append(element("span",split || !/\s/.test(node.label) ? "label" : "label prose",split ? split[2] : node.label));
|
|
311
|
+
if (node.detail) { const detail = element("span","detail"); rich(detail,node.detail); item.append(detail); }
|
|
312
|
+
const end = element("span","end",node.badge || "");
|
|
313
|
+
end.hidden = !node.badge;
|
|
314
|
+
if (node.glance?.verdict) { end.hidden = true; verdict(end,node.glance.verdict); }
|
|
315
|
+
item.append(end);
|
|
316
|
+
if (node.glance?.caveat) item.append(element("span","detail caveat",node.glance.caveat));
|
|
317
|
+
if (node.glance?.segments?.length) {
|
|
318
|
+
const mini = element("span","mini");
|
|
319
|
+
mini.setAttribute("aria-hidden","true");
|
|
320
|
+
slices.push(...segments(mini,node.glance.segments));
|
|
321
|
+
item.append(mini);
|
|
322
|
+
}
|
|
323
|
+
return item;
|
|
324
|
+
}
|
|
325
|
+
function renderNode(node) {
|
|
162
326
|
const item = element("li");
|
|
163
327
|
const group = Array.isArray(node.children) && node.children.length > 0;
|
|
164
|
-
const
|
|
165
|
-
if (group && open) container.open = true;
|
|
166
|
-
const row = element(group ? "summary" : "div",group ? "" : "row");
|
|
167
|
-
const branch = element("span","branch",group ? "" : last ? "└─" : "├─");
|
|
168
|
-
branch.setAttribute("aria-hidden","true");
|
|
328
|
+
const row = element(group ? "summary" : "div",group ? "" : "leaf");
|
|
169
329
|
const label = element("span","label",node.label);
|
|
170
330
|
if (node.detail) label.append(element("span","detail",node.detail));
|
|
171
|
-
const symbol = {ok:"[ok]",pending:"[...]",warning:"[!]",error:"[!]"}[node.status] || "";
|
|
172
|
-
const badge = element("span","badge",[symbol,node.badge].filter(Boolean).join(" "));
|
|
173
|
-
if (node.status) badge.dataset.state = node.status;
|
|
174
|
-
row.append(branch,label,badge);
|
|
175
|
-
container.append(row);
|
|
176
331
|
if (node.meter) {
|
|
177
|
-
const filled = Math.round(node.meter.value / node.meter.total * 12);
|
|
178
332
|
const meter = element("span","meter");
|
|
179
|
-
meter.
|
|
333
|
+
meter.title = `${node.meter.value} / ${node.meter.total}`;
|
|
334
|
+
const fill = element("i");
|
|
335
|
+
fill.style.width = `${Math.max(1,Math.round(node.meter.value / node.meter.total * 100))}%`;
|
|
336
|
+
meter.append(fill);
|
|
180
337
|
label.append(meter);
|
|
181
338
|
}
|
|
182
|
-
if (
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
339
|
+
if (node.glance?.caveat) label.append(element("span","caveat",node.glance.caveat));
|
|
340
|
+
const symbol = {ok:"✓",pending:"…",warning:"!",error:"×"}[node.status] || "";
|
|
341
|
+
const badge = element("span","badge",[symbol,node.badge || node.glance?.verdict?.label].filter(Boolean).join(" "));
|
|
342
|
+
const state = node.status || node.glance?.verdict?.status;
|
|
343
|
+
if (state) badge.dataset.state = state;
|
|
344
|
+
row.append(label,badge);
|
|
345
|
+
if (!group) { item.append(row); return item; }
|
|
346
|
+
const details = element("details");
|
|
347
|
+
const children = element("ul");
|
|
348
|
+
for (const child of node.children) children.append(renderNode(child));
|
|
349
|
+
details.append(row,children);
|
|
350
|
+
item.append(details);
|
|
188
351
|
return item;
|
|
189
352
|
}
|
|
190
353
|
function renderView(view) {
|
|
191
354
|
clearResult();
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
355
|
+
cancelled = false;
|
|
356
|
+
root.dataset.state = "ready";
|
|
357
|
+
root.dataset.reveal = "";
|
|
358
|
+
const glance = view.glance || {};
|
|
359
|
+
headline(view.title,view.summary);
|
|
360
|
+
verdict(fields.verdict,glance.verdict);
|
|
361
|
+
const freshness = view.freshness;
|
|
362
|
+
if (freshness?.mappedCommit) {
|
|
363
|
+
display("sha",`@${/^[0-9a-f]{7,}$/i.test(freshness.mappedCommit) ? freshness.mappedCommit.slice(0,7) : freshness.mappedCommit.slice(0,12)}`);
|
|
364
|
+
fields.sha.title = `Mapped ${freshness.mappedCommit}${freshness.lastAnalyzed ? ` as of ${freshness.lastAnalyzed}` : ""}`;
|
|
199
365
|
}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
366
|
+
if (freshness) display("provenance",[freshness.mappedCommit ? `Mapped ${freshness.mappedCommit}` : "Mapped commit unknown",freshness.lastAnalyzed ? `as of ${freshness.lastAnalyzed}` : "map time unknown",`status: ${freshness.status}`].join(" · "));
|
|
367
|
+
caveats([glance.caveat,freshness?.status === "stale" ? "Map is behind the latest push" : ""].filter(Boolean));
|
|
368
|
+
const slices = segments(fields.bar,glance.segments || []);
|
|
369
|
+
fields.bar.hidden = slices.length === 0;
|
|
370
|
+
legend(glance.segments || []);
|
|
371
|
+
// Up to MAX_ROWS rows, warnings and verdicts first, shown in their original order.
|
|
372
|
+
const candidates = view.nodes.filter(node => node.glance || node.status);
|
|
373
|
+
const urgent = node => node.status === "error" || node.status === "warning" || node.glance?.verdict || node.glance?.caveat;
|
|
374
|
+
const chosen = new Set([...new Set([...candidates.filter(urgent),...candidates])].slice(0,MAX_ROWS));
|
|
375
|
+
const visible = candidates.filter(node => chosen.has(node));
|
|
376
|
+
for (const node of visible) fields.rows.append(rowFor(node,slices));
|
|
377
|
+
if (candidates.length > visible.length) fields.rows.append(element("li","more",`+${candidates.length - visible.length} more in Details`));
|
|
378
|
+
fields.rows.hidden = visible.length === 0;
|
|
379
|
+
for (const node of view.nodes) if (!visible.includes(node) || node.children?.length) fields.tree.append(renderNode(node));
|
|
380
|
+
// One click on Details shows evidence: the first group is already open inside the drawer.
|
|
381
|
+
fields.tree.querySelector("details")?.setAttribute("open","");
|
|
203
382
|
notes(view.notes || []);
|
|
204
|
-
|
|
383
|
+
fields.toggle.hidden = !fields.tree.childElementCount && fields.notes.hidden && fields.provenance.hidden;
|
|
384
|
+
place(fields.legend.childElementCount > 0);
|
|
385
|
+
grow(slices);
|
|
205
386
|
}
|
|
206
387
|
function input(params) {
|
|
207
388
|
cancelled = false;
|
|
208
389
|
clearResult();
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
390
|
+
root.dataset.state = "pending";
|
|
391
|
+
const args = record(params.arguments) ? params.arguments : {};
|
|
392
|
+
const target = ["entity","query","module","repo"].map(key => args[key]).find(value => string(value,LIMITS.repo) && value.trim());
|
|
393
|
+
const verb = Object.hasOwn(VERBS,tool) ? VERBS[tool] : "Querying the Pharaoh graph";
|
|
394
|
+
headline("","");
|
|
395
|
+
display("kind",target ? `${verb} ` : `${verb}…`);
|
|
396
|
+
display("target",target ? `${target}…` : "");
|
|
397
|
+
fields.bar.hidden = false;
|
|
213
398
|
}
|
|
214
399
|
function result(params) {
|
|
215
400
|
if (cancelled) return;
|
|
@@ -226,7 +411,7 @@
|
|
|
226
411
|
function context(params) {
|
|
227
412
|
if (params.theme === "dark" || params.theme === "light") document.documentElement.dataset.theme = params.theme;
|
|
228
413
|
const name = params.toolInfo?.tool?.name;
|
|
229
|
-
if (string(name,128))
|
|
414
|
+
if (string(name,128)) tool = name;
|
|
230
415
|
}
|
|
231
416
|
function size() {
|
|
232
417
|
if (!ready || closed || resizeFrame) return;
|
|
@@ -244,7 +429,9 @@
|
|
|
244
429
|
const observer = typeof ResizeObserver === "function" ? new ResizeObserver(size) : null;
|
|
245
430
|
observer?.observe(root);
|
|
246
431
|
root.addEventListener("toggle",size,true);
|
|
432
|
+
root.addEventListener("transitionend",size);
|
|
247
433
|
window.addEventListener("resize",size);
|
|
434
|
+
fields.toggle.addEventListener("click",() => { setOpen(fields.toggle.getAttribute("aria-expanded") !== "true"); size(); });
|
|
248
435
|
function receive(event) {
|
|
249
436
|
if (closed || event.source !== window.parent || !record(event.data) || event.data.jsonrpc !== "2.0") return;
|
|
250
437
|
const message = event.data;
|
|
@@ -271,7 +458,7 @@
|
|
|
271
458
|
clearTimeout(initTimeout);
|
|
272
459
|
cancelAnimationFrame(resizeFrame);
|
|
273
460
|
observer?.disconnect();
|
|
274
|
-
root.dataset.
|
|
461
|
+
root.dataset.state = "ready";
|
|
275
462
|
window.removeEventListener("message",receive);
|
|
276
463
|
} else send({id:message.id,error:{code:-32601,message:"Unsupported request"}});
|
|
277
464
|
return;
|
|
@@ -289,7 +476,7 @@
|
|
|
289
476
|
const initTimeout = setTimeout(() => {
|
|
290
477
|
if (!ready && !closed) fallback("Text response available","Read the tool response in chat.","Trace has not received a host connection.");
|
|
291
478
|
},10000);
|
|
292
|
-
send({id:initId,method:"ui/initialize",params:{appInfo:{name:"Pharaoh Trace",version:"1.
|
|
479
|
+
send({id:initId,method:"ui/initialize",params:{appInfo:{name:"Pharaoh Trace",version:"1.2.0"},appCapabilities:{availableDisplayModes:["inline"]},protocolVersion:PROTOCOL_VERSION}});
|
|
293
480
|
})();
|
|
294
481
|
</script>
|
|
295
482
|
</body>
|