@pharaoh-so/mcp 0.1.5 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +41 -0
- package/LICENSE +21 -0
- package/README.md +237 -13
- package/dist/helpers.d.ts +36 -0
- package/dist/helpers.js +124 -0
- package/dist/index.js +70 -136
- package/dist/inspect.d.ts +11 -0
- package/dist/inspect.js +45 -0
- package/dist/install-skills.d.ts +33 -0
- package/dist/install-skills.js +121 -0
- package/dist/proxy.d.ts +4 -0
- package/dist/proxy.js +14 -17
- package/inspect-tools.json +12 -2
- package/package.json +64 -32
- package/skills/.gitkeep +0 -0
- package/skills/pharaoh/SKILL.md +81 -0
- package/skills/pharaoh-audit-tests/SKILL.md +88 -0
- package/skills/pharaoh-brainstorm/SKILL.md +73 -0
- package/skills/pharaoh-debt/SKILL.md +33 -0
- package/skills/pharaoh-debug/SKILL.md +69 -0
- package/skills/pharaoh-execute/SKILL.md +57 -0
- package/skills/pharaoh-explore/SKILL.md +32 -0
- package/skills/pharaoh-finish/SKILL.md +79 -0
- package/skills/pharaoh-health/SKILL.md +36 -0
- package/skills/pharaoh-investigate/SKILL.md +34 -0
- package/skills/pharaoh-onboard/SKILL.md +32 -0
- package/skills/pharaoh-parallel/SKILL.md +74 -0
- package/skills/pharaoh-plan/SKILL.md +74 -0
- package/skills/pharaoh-pr/SKILL.md +52 -0
- package/skills/pharaoh-refactor/SKILL.md +36 -0
- package/skills/pharaoh-review/SKILL.md +61 -0
- package/skills/pharaoh-review-codex/SKILL.md +80 -0
- package/skills/pharaoh-review-receive/SKILL.md +81 -0
- package/skills/pharaoh-sessions/SKILL.md +85 -0
- package/skills/pharaoh-tdd/SKILL.md +104 -0
- package/skills/pharaoh-verify/SKILL.md +72 -0
- package/skills/pharaoh-wiring/SKILL.md +34 -0
- package/skills/pharaoh-worktree/SKILL.md +85 -0
- package/dist/auth.js.map +0 -1
- package/dist/credentials.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/proxy.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -5,170 +5,104 @@
|
|
|
5
5
|
* Presents as an MCP server on stdio (for Claude Code) and relays messages
|
|
6
6
|
* to a remote Pharaoh SSE server. Authenticates via RFC 8628 device flow
|
|
7
7
|
* so the user can authorize on any device with a browser.
|
|
8
|
+
*
|
|
9
|
+
* Two modes determined by whether stdin is a TTY:
|
|
10
|
+
* - Interactive (TTY): authenticate, print setup instructions, exit.
|
|
11
|
+
* - Proxy (pipe): require pre-existing credentials, bridge stdio ↔ SSE.
|
|
8
12
|
*/
|
|
9
13
|
import { printActivationPrompt, printAuthSuccess, pollForToken, requestDeviceCode, } from "./auth.js";
|
|
10
|
-
import { deleteCredentials, isExpired, readCredentials, writeCredentials
|
|
14
|
+
import { deleteCredentials, isExpired, readCredentials, writeCredentials } from "./credentials.js";
|
|
15
|
+
import { formatIdentity, formatTtl, parseArgs, printLines, printSetupInstructions, printUsage, resolveSseUrl, tokenToCredentials, } from "./helpers.js";
|
|
11
16
|
import { TokenExpiredError, TenantSuspendedError, startProxy } from "./proxy.js";
|
|
12
|
-
|
|
13
|
-
/** Parse CLI arguments. */
|
|
14
|
-
function parseArgs() {
|
|
17
|
+
async function main() {
|
|
15
18
|
const args = process.argv.slice(2);
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
if (args[i] === "--server" && args[i + 1]) {
|
|
20
|
-
server = args[i + 1];
|
|
21
|
-
i++;
|
|
22
|
-
}
|
|
23
|
-
else if (args[i] === "--logout") {
|
|
24
|
-
logout = true;
|
|
25
|
-
}
|
|
26
|
-
else if (args[i] === "--help" || args[i] === "-h") {
|
|
27
|
-
printUsage();
|
|
28
|
-
process.exit(0);
|
|
29
|
-
}
|
|
19
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
20
|
+
printUsage();
|
|
21
|
+
process.exit(0);
|
|
30
22
|
}
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
process.stderr.write([
|
|
37
|
-
"Usage: pharaoh-mcp [options]",
|
|
38
|
-
"",
|
|
39
|
-
"Options:",
|
|
40
|
-
" --server <url> Pharaoh server URL (default: https://mcp.pharaoh.so)",
|
|
41
|
-
" --logout Clear stored credentials and exit",
|
|
42
|
-
" --help, -h Show this help",
|
|
43
|
-
"",
|
|
44
|
-
"Add to Claude Code:",
|
|
45
|
-
" claude mcp add pharaoh -- npx @pharaoh-so/mcp",
|
|
46
|
-
"",
|
|
47
|
-
].join("\n") + "\n");
|
|
48
|
-
}
|
|
49
|
-
/** Run the device flow and return a token response. */
|
|
50
|
-
async function authenticate(server) {
|
|
51
|
-
const deviceCode = await requestDeviceCode(server);
|
|
52
|
-
printActivationPrompt(deviceCode.user_code, deviceCode.verification_uri);
|
|
53
|
-
return pollForToken(server, deviceCode.device_code, deviceCode.interval);
|
|
54
|
-
}
|
|
55
|
-
/**
|
|
56
|
-
* Validate that a server-supplied SSE URL shares the same origin as the configured server.
|
|
57
|
-
* Prevents a compromised auth response from redirecting the Bearer token to an attacker's host.
|
|
58
|
-
* Falls back to `${server}/sse` if the URL is missing, malformed, or cross-origin.
|
|
59
|
-
*/
|
|
60
|
-
function resolveSseUrl(tokenSseUrl, server) {
|
|
61
|
-
const fallback = `${server}/sse`;
|
|
62
|
-
if (!tokenSseUrl)
|
|
63
|
-
return fallback;
|
|
64
|
-
try {
|
|
65
|
-
const sseOrigin = new URL(tokenSseUrl).origin;
|
|
66
|
-
const serverOrigin = new URL(server).origin;
|
|
67
|
-
if (sseOrigin !== serverOrigin) {
|
|
68
|
-
process.stderr.write(`Pharaoh: ignoring cross-origin sse_url (${sseOrigin} ≠ ${serverOrigin})\n`);
|
|
69
|
-
return fallback;
|
|
70
|
-
}
|
|
71
|
-
return tokenSseUrl;
|
|
23
|
+
// Inspect mode — serve tool schemas over stdio for Glama directory inspection
|
|
24
|
+
if (args.includes("--inspect")) {
|
|
25
|
+
const { runInspect } = await import("./inspect.js");
|
|
26
|
+
await runInspect();
|
|
27
|
+
return;
|
|
72
28
|
}
|
|
73
|
-
|
|
74
|
-
|
|
29
|
+
// Install-skills mode — copy bundled skills to ~/.openclaw/skills/
|
|
30
|
+
if (args.includes("--install-skills")) {
|
|
31
|
+
const { runInstallSkills } = await import("./install-skills.js");
|
|
32
|
+
runInstallSkills();
|
|
33
|
+
return;
|
|
75
34
|
}
|
|
76
|
-
}
|
|
77
|
-
/** Convert a token response to storable credentials. */
|
|
78
|
-
function tokenToCredentials(token, sseUrl) {
|
|
79
|
-
return {
|
|
80
|
-
version: 1,
|
|
81
|
-
access_token: token.access_token,
|
|
82
|
-
expires_at: new Date(Date.now() + token.expires_in * 1000).toISOString(),
|
|
83
|
-
expires_in: token.expires_in,
|
|
84
|
-
sse_url: sseUrl,
|
|
85
|
-
github_login: token.github_login ?? null,
|
|
86
|
-
tenant_name: token.tenant_name ?? null,
|
|
87
|
-
repos: token.repos ?? [],
|
|
88
|
-
};
|
|
89
|
-
}
|
|
90
|
-
/** Format remaining TTL as human-readable string (e.g. "5d 12h"). */
|
|
91
|
-
function formatTtl(expiresAt) {
|
|
92
|
-
const remainingMs = new Date(expiresAt).getTime() - Date.now();
|
|
93
|
-
if (remainingMs <= 0)
|
|
94
|
-
return "expired";
|
|
95
|
-
const hours = Math.floor(remainingMs / 3_600_000);
|
|
96
|
-
const days = Math.floor(hours / 24);
|
|
97
|
-
const remHours = hours % 24;
|
|
98
|
-
if (days > 0)
|
|
99
|
-
return `${days}d ${remHours}h`;
|
|
100
|
-
if (hours > 0)
|
|
101
|
-
return `${hours}h`;
|
|
102
|
-
return `${Math.floor(remainingMs / 60_000)}m`;
|
|
103
|
-
}
|
|
104
|
-
async function main() {
|
|
105
|
-
const { server, logout } = parseArgs();
|
|
106
|
-
// --logout: clear credentials and exit
|
|
35
|
+
const { server, logout } = parseArgs(args);
|
|
107
36
|
if (logout) {
|
|
108
37
|
deleteCredentials();
|
|
109
|
-
|
|
38
|
+
printLines("Pharaoh: credentials cleared");
|
|
110
39
|
process.exit(0);
|
|
111
40
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
process.stderr.write("Pharaoh: token rejected by server — re-authenticating\n");
|
|
123
|
-
deleteCredentials();
|
|
124
|
-
creds = null;
|
|
125
|
-
}
|
|
126
|
-
else if (err instanceof TenantSuspendedError) {
|
|
127
|
-
process.stderr.write(`Pharaoh: ${err.message}\n`);
|
|
128
|
-
process.exit(1);
|
|
129
|
-
}
|
|
130
|
-
else {
|
|
131
|
-
throw err;
|
|
132
|
-
}
|
|
41
|
+
const creds = readCredentials();
|
|
42
|
+
const isInteractive = Boolean(process.stdin.isTTY);
|
|
43
|
+
// ── Interactive mode (user running in a terminal) ──
|
|
44
|
+
// Authenticate if needed, print setup instructions, and exit.
|
|
45
|
+
// The proxy is useless without Claude Code on the other end of stdin.
|
|
46
|
+
if (isInteractive) {
|
|
47
|
+
if (creds && !isExpired(creds)) {
|
|
48
|
+
printLines(`Pharaoh: authenticated as ${formatIdentity(creds)} — token valid for ${formatTtl(creds.expires_at)}, ${creds.repos.length} repo${creds.repos.length === 1 ? "" : "s"} connected`);
|
|
49
|
+
printSetupInstructions();
|
|
50
|
+
process.exit(0);
|
|
133
51
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
const token = await
|
|
52
|
+
// No valid credentials — run device flow
|
|
53
|
+
printLines("Pharaoh: no valid credentials — starting device authorization");
|
|
54
|
+
const deviceCode = await requestDeviceCode(server);
|
|
55
|
+
printActivationPrompt(deviceCode.user_code, deviceCode.verification_uri);
|
|
56
|
+
const token = await pollForToken(server, deviceCode.device_code, deviceCode.interval);
|
|
139
57
|
if (token.provisional) {
|
|
140
|
-
|
|
58
|
+
printLines(`Pharaoh: provisional access — install the GitHub App to map your repos: ${token.install_url ?? ""}`);
|
|
141
59
|
}
|
|
142
60
|
const sseUrl = resolveSseUrl(token.sse_url, server);
|
|
143
|
-
|
|
144
|
-
writeCredentials(
|
|
61
|
+
const newCreds = tokenToCredentials(token, sseUrl);
|
|
62
|
+
writeCredentials(newCreds);
|
|
145
63
|
printAuthSuccess(token.github_login ?? null, token.tenant_name ?? null, token.repos?.length ?? 0);
|
|
64
|
+
printSetupInstructions();
|
|
65
|
+
process.exit(0);
|
|
66
|
+
}
|
|
67
|
+
// ── Proxy mode (Claude Code spawned us as a stdio MCP server) ──
|
|
68
|
+
// If no credentials, we can't run the device flow (no TTY for user interaction).
|
|
69
|
+
if (!creds || isExpired(creds)) {
|
|
70
|
+
printLines("Pharaoh: no valid credentials — cannot start proxy.", "Run this command first to authenticate:", " npx @pharaoh-so/mcp", "");
|
|
71
|
+
process.exit(1);
|
|
146
72
|
}
|
|
147
|
-
//
|
|
73
|
+
// Valid credentials — start the proxy
|
|
74
|
+
printLines(`Pharaoh: token valid for ${formatTtl(creds.expires_at)} — connecting`);
|
|
148
75
|
try {
|
|
149
76
|
await startProxy(creds.sse_url, creds.access_token);
|
|
150
77
|
}
|
|
151
78
|
catch (err) {
|
|
152
79
|
if (err instanceof TokenExpiredError) {
|
|
153
|
-
|
|
154
|
-
process.stderr.write("Pharaoh: session expired — re-authenticating\n");
|
|
80
|
+
printLines("Pharaoh: token expired or revoked.", "Run this command to re-authenticate:", " npx @pharaoh-so/mcp", "");
|
|
155
81
|
deleteCredentials();
|
|
156
|
-
const token = await authenticate(server);
|
|
157
|
-
const sseUrl = resolveSseUrl(token.sse_url, server);
|
|
158
|
-
creds = tokenToCredentials(token, sseUrl);
|
|
159
|
-
writeCredentials(creds);
|
|
160
|
-
await startProxy(creds.sse_url, creds.access_token);
|
|
161
|
-
}
|
|
162
|
-
else if (err instanceof TenantSuspendedError) {
|
|
163
|
-
process.stderr.write(`Pharaoh: ${err.message}\n`);
|
|
164
82
|
process.exit(1);
|
|
165
83
|
}
|
|
166
|
-
|
|
167
|
-
|
|
84
|
+
if (err instanceof TenantSuspendedError) {
|
|
85
|
+
printLines(`Pharaoh: ${err.message}`);
|
|
86
|
+
process.exit(1);
|
|
168
87
|
}
|
|
88
|
+
throw err;
|
|
169
89
|
}
|
|
170
90
|
}
|
|
171
91
|
main().catch((err) => {
|
|
172
|
-
|
|
92
|
+
// Default: print only error name/code to avoid leaking tokens, internal URLs,
|
|
93
|
+
// or stack fragments that persist in CI logs. Full message behind PHARAOH_DEBUG.
|
|
94
|
+
if (process.env.PHARAOH_DEBUG === "1" && err instanceof Error) {
|
|
95
|
+
// Use main's comprehensive redaction patterns when showing the full message
|
|
96
|
+
const safeMsg = err.message
|
|
97
|
+
.replace(/Bearer\s+\S+/gi, "Bearer [REDACTED]")
|
|
98
|
+
.replace(/(?:phat|phrt|ghp|gho|ghs|ghu)_\S+/gi, "[REDACTED_TOKEN]")
|
|
99
|
+
.replace(/[?&](?:code|token|key|secret|password|state)=[^&\s]+/gi, "?[REDACTED_PARAM]")
|
|
100
|
+
.replace(/\b[0-9a-f]{32,}\b/gi, "[REDACTED_HEX]");
|
|
101
|
+
printLines(`Pharaoh: fatal — ${safeMsg}`);
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
const label = err instanceof Error ? err.name : "Error";
|
|
105
|
+
printLines(`Pharaoh: fatal — ${label}. Set PHARAOH_DEBUG=1 for details.`);
|
|
106
|
+
}
|
|
173
107
|
process.exit(1);
|
|
174
108
|
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inspect mode — serves Pharaoh's tool schemas over stdio for Glama inspection.
|
|
3
|
+
*
|
|
4
|
+
* Reads pre-generated tool schemas from inspect-tools.json (generated at build
|
|
5
|
+
* time by the main repo's `generate-inspect-schemas.ts`). Registers each tool
|
|
6
|
+
* on an MCP server that returns a "connect to mcp.pharaoh.so" message if invoked.
|
|
7
|
+
*
|
|
8
|
+
* Zero-drift: schemas are extracted from the same registerXxx() code that the
|
|
9
|
+
* production server uses, serialized to JSON during build, and shipped in the npm package.
|
|
10
|
+
*/
|
|
11
|
+
export declare function runInspect(): Promise<void>;
|
package/dist/inspect.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inspect mode — serves Pharaoh's tool schemas over stdio for Glama inspection.
|
|
3
|
+
*
|
|
4
|
+
* Reads pre-generated tool schemas from inspect-tools.json (generated at build
|
|
5
|
+
* time by the main repo's `generate-inspect-schemas.ts`). Registers each tool
|
|
6
|
+
* on an MCP server that returns a "connect to mcp.pharaoh.so" message if invoked.
|
|
7
|
+
*
|
|
8
|
+
* Zero-drift: schemas are extracted from the same registerXxx() code that the
|
|
9
|
+
* production server uses, serialized to JSON during build, and shipped in the npm package.
|
|
10
|
+
*/
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
14
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
15
|
+
const INSPECT_MSG = "This is Pharaoh's inspection server for directory listings. " +
|
|
16
|
+
"Connect to https://mcp.pharaoh.so for actual usage.";
|
|
17
|
+
export async function runInspect() {
|
|
18
|
+
// Load pre-generated schemas (shipped in the npm package)
|
|
19
|
+
const schemasPath = join(import.meta.dirname, "..", "inspect-tools.json");
|
|
20
|
+
let schemas;
|
|
21
|
+
try {
|
|
22
|
+
schemas = JSON.parse(readFileSync(schemasPath, "utf-8"));
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
process.stderr.write("Pharaoh: inspect-tools.json not found — rebuild the package.\n");
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
const server = new McpServer({ name: "pharaoh", version: "0.1.0" }, {
|
|
29
|
+
instructions: "Pharaoh turns codebases into knowledge graphs that AI agents can think with. " +
|
|
30
|
+
"Connect at https://mcp.pharaoh.so for full functionality.",
|
|
31
|
+
});
|
|
32
|
+
// Register each tool with pre-serialized JSON Schema.
|
|
33
|
+
// Cast inputSchema to satisfy TypeScript — at runtime registerTool passes it through as-is.
|
|
34
|
+
for (const tool of schemas.tools) {
|
|
35
|
+
// biome-ignore lint/suspicious/noExplicitAny: JSON Schema from inspect-tools.json, not Zod
|
|
36
|
+
const schema = tool.inputSchema;
|
|
37
|
+
server.registerTool(tool.name, {
|
|
38
|
+
description: tool.description,
|
|
39
|
+
inputSchema: schema,
|
|
40
|
+
annotations: tool.annotations,
|
|
41
|
+
}, async () => ({ content: [{ type: "text", text: INSPECT_MSG }] }));
|
|
42
|
+
}
|
|
43
|
+
const transport = new StdioServerTransport();
|
|
44
|
+
await server.connect(transport);
|
|
45
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detect whether OpenClaw is installed by checking for ~/.openclaw/.
|
|
3
|
+
*
|
|
4
|
+
* @param home - Home directory override (defaults to os.homedir()).
|
|
5
|
+
* @returns True if ~/.openclaw/ exists.
|
|
6
|
+
*/
|
|
7
|
+
export declare function detectOpenClaw(home?: string): boolean;
|
|
8
|
+
/**
|
|
9
|
+
* Copy all bundled skill directories to ~/.openclaw/skills/.
|
|
10
|
+
* Overwrites existing skill dirs on reinstall (cpSync recursive + force).
|
|
11
|
+
*
|
|
12
|
+
* @param home - Home directory override (defaults to os.homedir()).
|
|
13
|
+
* @returns Number of skill directories copied.
|
|
14
|
+
*/
|
|
15
|
+
export declare function installSkills(home?: string): number;
|
|
16
|
+
/**
|
|
17
|
+
* Read ~/.openclaw/openclaw.json, add the Pharaoh MCP server under `mcpServers`,
|
|
18
|
+
* and write it back. Creates the file if it does not exist. Does NOT overwrite
|
|
19
|
+
* an existing `pharaoh` entry — skips if already present.
|
|
20
|
+
*
|
|
21
|
+
* @param home - Home directory override (defaults to os.homedir()).
|
|
22
|
+
* @returns True if the config was written/updated, false if pharaoh was already present.
|
|
23
|
+
*/
|
|
24
|
+
export declare function mergeOpenClawConfig(home?: string): boolean;
|
|
25
|
+
/**
|
|
26
|
+
* Main entry point for --install-skills.
|
|
27
|
+
*
|
|
28
|
+
* Detects OpenClaw, copies skills, merges config, and prints a summary.
|
|
29
|
+
* If OpenClaw is not detected, prints manual installation instructions instead.
|
|
30
|
+
*
|
|
31
|
+
* @param home - Home directory override (defaults to os.homedir()).
|
|
32
|
+
*/
|
|
33
|
+
export declare function runInstallSkills(home?: string): void;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* --install-skills implementation for the Pharaoh MCP proxy CLI.
|
|
3
|
+
*
|
|
4
|
+
* Copies bundled skill directories to ~/.openclaw/skills/ and merges
|
|
5
|
+
* the Pharaoh MCP server entry into ~/.openclaw/openclaw.json.
|
|
6
|
+
* If OpenClaw is not detected, prints manual installation instructions.
|
|
7
|
+
*/
|
|
8
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { dirname, join } from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
/** Path to the bundled skills directory (one level up from dist/). */
|
|
14
|
+
const BUNDLED_SKILLS_DIR = join(__dirname, "..", "skills");
|
|
15
|
+
/**
|
|
16
|
+
* Detect whether OpenClaw is installed by checking for ~/.openclaw/.
|
|
17
|
+
*
|
|
18
|
+
* @param home - Home directory override (defaults to os.homedir()).
|
|
19
|
+
* @returns True if ~/.openclaw/ exists.
|
|
20
|
+
*/
|
|
21
|
+
export function detectOpenClaw(home = homedir()) {
|
|
22
|
+
return existsSync(join(home, ".openclaw"));
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Copy all bundled skill directories to ~/.openclaw/skills/.
|
|
26
|
+
* Overwrites existing skill dirs on reinstall (cpSync recursive + force).
|
|
27
|
+
*
|
|
28
|
+
* @param home - Home directory override (defaults to os.homedir()).
|
|
29
|
+
* @returns Number of skill directories copied.
|
|
30
|
+
*/
|
|
31
|
+
export function installSkills(home = homedir()) {
|
|
32
|
+
const targetDir = join(home, ".openclaw", "skills");
|
|
33
|
+
if (!existsSync(targetDir)) {
|
|
34
|
+
mkdirSync(targetDir, { recursive: true });
|
|
35
|
+
}
|
|
36
|
+
const entries = readdirSync(BUNDLED_SKILLS_DIR, { withFileTypes: true });
|
|
37
|
+
const skillDirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
38
|
+
for (const skillName of skillDirs) {
|
|
39
|
+
const src = join(BUNDLED_SKILLS_DIR, skillName);
|
|
40
|
+
const dst = join(targetDir, skillName);
|
|
41
|
+
cpSync(src, dst, { recursive: true, force: true });
|
|
42
|
+
}
|
|
43
|
+
return skillDirs.length;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Read ~/.openclaw/openclaw.json, add the Pharaoh MCP server under `mcpServers`,
|
|
47
|
+
* and write it back. Creates the file if it does not exist. Does NOT overwrite
|
|
48
|
+
* an existing `pharaoh` entry — skips if already present.
|
|
49
|
+
*
|
|
50
|
+
* @param home - Home directory override (defaults to os.homedir()).
|
|
51
|
+
* @returns True if the config was written/updated, false if pharaoh was already present.
|
|
52
|
+
*/
|
|
53
|
+
export function mergeOpenClawConfig(home = homedir()) {
|
|
54
|
+
const configPath = join(home, ".openclaw", "openclaw.json");
|
|
55
|
+
let config = {};
|
|
56
|
+
if (existsSync(configPath)) {
|
|
57
|
+
try {
|
|
58
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
59
|
+
config = JSON.parse(raw);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// Corrupted JSON — refuse to overwrite to avoid losing existing config
|
|
63
|
+
process.stderr.write([
|
|
64
|
+
"Pharaoh: ~/.openclaw/openclaw.json exists but is not valid JSON.",
|
|
65
|
+
"Fix or delete it manually, then re-run --install-skills.",
|
|
66
|
+
"",
|
|
67
|
+
].join("\n"));
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (!config.mcpServers) {
|
|
72
|
+
config.mcpServers = {};
|
|
73
|
+
}
|
|
74
|
+
// Don't overwrite an existing pharaoh entry
|
|
75
|
+
if (config.mcpServers.pharaoh) {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
config.mcpServers.pharaoh = {
|
|
79
|
+
command: "npx",
|
|
80
|
+
args: ["@pharaoh-so/mcp"],
|
|
81
|
+
};
|
|
82
|
+
writeFileSync(configPath, JSON.stringify(config, null, "\t"));
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Main entry point for --install-skills.
|
|
87
|
+
*
|
|
88
|
+
* Detects OpenClaw, copies skills, merges config, and prints a summary.
|
|
89
|
+
* If OpenClaw is not detected, prints manual installation instructions instead.
|
|
90
|
+
*
|
|
91
|
+
* @param home - Home directory override (defaults to os.homedir()).
|
|
92
|
+
*/
|
|
93
|
+
export function runInstallSkills(home = homedir()) {
|
|
94
|
+
const hasOpenClaw = detectOpenClaw(home);
|
|
95
|
+
if (!hasOpenClaw) {
|
|
96
|
+
process.stderr.write([
|
|
97
|
+
"Pharaoh: OpenClaw not detected (~/.openclaw/ not found).",
|
|
98
|
+
"",
|
|
99
|
+
"To install OpenClaw: https://openclaw.dev/install",
|
|
100
|
+
"",
|
|
101
|
+
"Or install skills manually:",
|
|
102
|
+
` 1. Copy the skills/ directory from this package to ~/.openclaw/skills/`,
|
|
103
|
+
` 2. Add Pharaoh to ~/.openclaw/openclaw.json under mcpServers:`,
|
|
104
|
+
` "pharaoh": { "command": "npx", "args": ["@pharaoh-so/mcp"] }`,
|
|
105
|
+
"",
|
|
106
|
+
].join("\n"));
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const count = installSkills(home);
|
|
110
|
+
const configUpdated = mergeOpenClawConfig(home);
|
|
111
|
+
const configMsg = configUpdated
|
|
112
|
+
? "Pharaoh MCP server added to ~/.openclaw/openclaw.json"
|
|
113
|
+
: "Pharaoh already present in ~/.openclaw/openclaw.json — skipped";
|
|
114
|
+
process.stderr.write([
|
|
115
|
+
`Pharaoh: installed ${count} skills to ~/.openclaw/skills/`,
|
|
116
|
+
configMsg,
|
|
117
|
+
"",
|
|
118
|
+
"Restart OpenClaw to pick up the new skills.",
|
|
119
|
+
"",
|
|
120
|
+
].join("\n"));
|
|
121
|
+
}
|
package/dist/proxy.d.ts
CHANGED
|
@@ -18,6 +18,10 @@ export declare function classifySSEError(err: Error): void;
|
|
|
18
18
|
* Claude Code → stdin → StdioServerTransport.onmessage → SSEClientTransport.send → POST /message
|
|
19
19
|
* Pharaoh → SSE stream → SSEClientTransport.onmessage → StdioServerTransport.send → stdout
|
|
20
20
|
*
|
|
21
|
+
* CRITICAL ordering: SSE must be connected BEFORE stdio starts reading stdin.
|
|
22
|
+
* Otherwise the MCP `initialize` message arrives before the remote transport is ready,
|
|
23
|
+
* gets permanently lost ("send error — Not connected"), and the client times out.
|
|
24
|
+
*
|
|
21
25
|
* On SSE disconnect, attempts reconnect with exponential backoff (5 retries, ~62s total).
|
|
22
26
|
* Throws TokenExpiredError on 401, TenantSuspendedError on 403.
|
|
23
27
|
*/
|
package/dist/proxy.js
CHANGED
|
@@ -67,6 +67,10 @@ function createSSETransport(sseUrl, token) {
|
|
|
67
67
|
* Claude Code → stdin → StdioServerTransport.onmessage → SSEClientTransport.send → POST /message
|
|
68
68
|
* Pharaoh → SSE stream → SSEClientTransport.onmessage → StdioServerTransport.send → stdout
|
|
69
69
|
*
|
|
70
|
+
* CRITICAL ordering: SSE must be connected BEFORE stdio starts reading stdin.
|
|
71
|
+
* Otherwise the MCP `initialize` message arrives before the remote transport is ready,
|
|
72
|
+
* gets permanently lost ("send error — Not connected"), and the client times out.
|
|
73
|
+
*
|
|
70
74
|
* On SSE disconnect, attempts reconnect with exponential backoff (5 retries, ~62s total).
|
|
71
75
|
* Throws TokenExpiredError on 401, TenantSuspendedError on 403.
|
|
72
76
|
*/
|
|
@@ -74,6 +78,7 @@ export async function startProxy(sseUrl, token) {
|
|
|
74
78
|
const stdio = new StdioServerTransport();
|
|
75
79
|
let sse = createSSETransport(sseUrl, token);
|
|
76
80
|
let reconnectAttempt = 0;
|
|
81
|
+
let stdioStarted = false;
|
|
77
82
|
/** Wire up bidirectional message relay between the two transports. */
|
|
78
83
|
function bridge(sseTransport) {
|
|
79
84
|
stdio.onmessage = (msg) => {
|
|
@@ -89,22 +94,7 @@ export async function startProxy(sseUrl, token) {
|
|
|
89
94
|
}
|
|
90
95
|
/** Handle SSE errors — delegates to classifySSEError for 401/403 detection. */
|
|
91
96
|
const handleSSEError = classifySSEError;
|
|
92
|
-
//
|
|
93
|
-
bridge(sse);
|
|
94
|
-
// Handle SSE connection drops with reconnect
|
|
95
|
-
sse.onerror = (err) => {
|
|
96
|
-
try {
|
|
97
|
-
handleSSEError(err);
|
|
98
|
-
}
|
|
99
|
-
catch (typed) {
|
|
100
|
-
// TokenExpiredError or TenantSuspendedError — propagate via close
|
|
101
|
-
sse.close().catch(() => { });
|
|
102
|
-
throw typed;
|
|
103
|
-
}
|
|
104
|
-
};
|
|
105
|
-
// Start both transports
|
|
106
|
-
await stdio.start();
|
|
107
|
-
// Connect SSE with reconnect loop
|
|
97
|
+
// Connect SSE with reconnect loop — stdio starts AFTER first successful connection
|
|
108
98
|
await connectWithReconnect();
|
|
109
99
|
/** Attempt SSE connection with exponential backoff on failure. */
|
|
110
100
|
async function connectWithReconnect() {
|
|
@@ -115,10 +105,17 @@ export async function startProxy(sseUrl, token) {
|
|
|
115
105
|
process.stderr.write(`Pharaoh: reconnecting in ${delay / 1000}s (attempt ${reconnectAttempt}/${BACKOFF_MS.length})...\n`);
|
|
116
106
|
await sleep(delay);
|
|
117
107
|
sse = createSSETransport(sseUrl, token);
|
|
118
|
-
bridge(sse);
|
|
119
108
|
}
|
|
120
109
|
await sse.start();
|
|
121
110
|
reconnectAttempt = 0; // Reset on successful connection
|
|
111
|
+
// Bridge AFTER SSE is connected — messages can now be forwarded
|
|
112
|
+
bridge(sse);
|
|
113
|
+
// Start stdio AFTER first SSE connection — prevents initialize race
|
|
114
|
+
if (!stdioStarted) {
|
|
115
|
+
process.stderr.write("Pharaoh: connected\n");
|
|
116
|
+
await stdio.start();
|
|
117
|
+
stdioStarted = true;
|
|
118
|
+
}
|
|
122
119
|
// Wait for close — this promise resolves when SSE disconnects
|
|
123
120
|
await new Promise((resolve, reject) => {
|
|
124
121
|
sse.onclose = () => resolve();
|
package/inspect-tools.json
CHANGED
|
@@ -633,11 +633,21 @@
|
|
|
633
633
|
},
|
|
634
634
|
{
|
|
635
635
|
"name": "setup_environment",
|
|
636
|
-
"description": "Set up the optimal
|
|
636
|
+
"description": "Set up the optimal development environment for this codebase — install Pharaoh's curated plugin bundle.\n\nCALL THIS WHEN:\n• The user says \"set up my environment\", \"install plugins\", or \"optimize my setup\"\n• You want to proactively improve the development experience with proven plugins\n• After noticing the user could benefit from LSP, security scanning, or code review tools\n• The user is on OpenClaw or Cursor and wants to install Pharaoh skills/prompts\n\nRETURNS: A curated list of recommended plugins and install instructions, tailored to the client platform (claude-code, openclaw, or cursor) and languages in this codebase.\n\nThis is a one-time setup. Once installed, plugins persist across sessions.",
|
|
637
637
|
"inputSchema": {
|
|
638
638
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
639
639
|
"type": "object",
|
|
640
|
-
"properties": {
|
|
640
|
+
"properties": {
|
|
641
|
+
"client": {
|
|
642
|
+
"description": "Client platform — determines install instructions format. Defaults to claude-code.",
|
|
643
|
+
"type": "string",
|
|
644
|
+
"enum": [
|
|
645
|
+
"claude-code",
|
|
646
|
+
"openclaw",
|
|
647
|
+
"cursor"
|
|
648
|
+
]
|
|
649
|
+
}
|
|
650
|
+
}
|
|
641
651
|
},
|
|
642
652
|
"annotations": {
|
|
643
653
|
"title": "Environment Setup",
|
package/package.json
CHANGED
|
@@ -1,33 +1,65 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
2
|
+
"name": "@pharaoh-so/mcp",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"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.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"bin": {
|
|
9
|
+
"pharaoh-mcp": "./dist/index.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist",
|
|
13
|
+
"skills",
|
|
14
|
+
"inspect-tools.json",
|
|
15
|
+
"README.md",
|
|
16
|
+
"CHANGELOG.md",
|
|
17
|
+
"LICENSE"
|
|
18
|
+
],
|
|
19
|
+
"keywords": [
|
|
20
|
+
"mcp",
|
|
21
|
+
"model-context-protocol",
|
|
22
|
+
"pharaoh",
|
|
23
|
+
"codebase",
|
|
24
|
+
"knowledge-graph",
|
|
25
|
+
"code-analysis",
|
|
26
|
+
"architecture",
|
|
27
|
+
"ai-agent",
|
|
28
|
+
"claude",
|
|
29
|
+
"claude-code",
|
|
30
|
+
"developer-tools",
|
|
31
|
+
"static-analysis",
|
|
32
|
+
"dependency-graph"
|
|
33
|
+
],
|
|
34
|
+
"author": "Pharaoh <hello@pharaoh.so> (https://pharaoh.so)",
|
|
35
|
+
"license": "MIT",
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "git+https://github.com/Pharaoh-so/pharaoh-mcp.git"
|
|
39
|
+
},
|
|
40
|
+
"homepage": "https://pharaoh.so",
|
|
41
|
+
"bugs": {
|
|
42
|
+
"url": "https://github.com/Pharaoh-so/pharaoh-mcp/issues"
|
|
43
|
+
},
|
|
44
|
+
"funding": {
|
|
45
|
+
"type": "individual",
|
|
46
|
+
"url": "https://pharaoh.so"
|
|
47
|
+
},
|
|
48
|
+
"engines": {
|
|
49
|
+
"node": ">=18"
|
|
50
|
+
},
|
|
51
|
+
"dependencies": {
|
|
52
|
+
"@modelcontextprotocol/sdk": "^1.26.0"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@biomejs/biome": "^2.3.15",
|
|
56
|
+
"typescript": "^5.9.3",
|
|
57
|
+
"vitest": "^4.0.18"
|
|
58
|
+
},
|
|
59
|
+
"scripts": {
|
|
60
|
+
"build": "tsc && node -e \"const fs=require('fs'),f='dist/index.js',c=fs.readFileSync(f,'utf8');if(!c.startsWith('#!'))fs.writeFileSync(f,'#!/usr/bin/env node\\n'+c);fs.chmodSync(f,0o755)\"",
|
|
61
|
+
"typecheck": "tsc --noEmit",
|
|
62
|
+
"test": "vitest run",
|
|
63
|
+
"lint": "biome check src/"
|
|
64
|
+
}
|
|
65
|
+
}
|
package/skills/.gitkeep
ADDED
|
File without changes
|