pi-code 1.0.13 → 1.0.15
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/README.md +1 -1
- package/extensions/commands.ts +27 -30
- package/extensions/context-imports.ts +6 -14
- package/extensions/hooks/config.ts +234 -0
- package/extensions/hooks/decisions.ts +169 -0
- package/extensions/hooks/index.ts +497 -0
- package/extensions/hooks/matcher.ts +126 -0
- package/extensions/hooks/runners.ts +269 -0
- package/extensions/internal/command-file.ts +1 -1
- package/extensions/internal/managed-settings.ts +5 -0
- package/extensions/internal/output-guard.ts +1 -1
- package/extensions/internal/settings-chain.ts +24 -0
- package/extensions/internal/stat-token.ts +14 -0
- package/extensions/internal/turn-override.ts +73 -0
- package/extensions/mcp/config.ts +159 -0
- package/extensions/mcp/index.ts +513 -0
- package/extensions/mcp/listing.ts +92 -0
- package/extensions/mcp/mapping.ts +173 -0
- package/extensions/mcp/oauth-flow.ts +80 -0
- package/extensions/mcp/policy.ts +140 -0
- package/extensions/mcp/transport.ts +258 -0
- package/extensions/memory.ts +6 -10
- package/extensions/output-styles.ts +2 -6
- package/extensions/plan-mode/utils.ts +1 -1
- package/extensions/question.ts +2 -2
- package/extensions/status-line.ts +1 -1
- package/extensions/subagent/agents.ts +1 -1
- package/extensions/subagent/index.ts +6 -123
- package/extensions/subagent/render.ts +131 -0
- package/extensions/thinking.ts +25 -31
- package/package.json +1 -1
- package/extensions/hooks.ts +0 -1179
- package/extensions/mcp.ts +0 -1358
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hook runners: one per hook type (shell command incl. exec-form, http POST,
|
|
3
|
+
* in-process prompt, mcp_tool call, agent subagent), plus the timeout budget and
|
|
4
|
+
* the process-group kill used by the shell runner.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { type ChildProcess, spawn } from 'node:child_process'
|
|
8
|
+
import type { Api, Model } from '@earendil-works/pi-ai'
|
|
9
|
+
import { runAgent } from '../internal/agent-run.js'
|
|
10
|
+
import { callMcpTool } from '../internal/mcp-call.js'
|
|
11
|
+
import { completeText } from '../internal/model-complete.js'
|
|
12
|
+
import { type HookCommand, httpUrlAllowed, isBackgroundHook } from './config.js'
|
|
13
|
+
|
|
14
|
+
// Claude defaults to 600s and lets a timed-out hook proceed; here a timed-out
|
|
15
|
+
// PreToolUse or UserPromptSubmit hook fails closed (pi has no permission prompt
|
|
16
|
+
// to fall back on), so ten minutes of default budget would wedge the turn for
|
|
17
|
+
// ten minutes on a hung hook. Hooks that legitimately run long can raise their
|
|
18
|
+
// own per-hook `timeout`.
|
|
19
|
+
const DEFAULT_TIMEOUT_S = 60
|
|
20
|
+
|
|
21
|
+
export interface HookRunResult {
|
|
22
|
+
code: number
|
|
23
|
+
stdout: string
|
|
24
|
+
stderr: string
|
|
25
|
+
/** The hook was killed at its timeout, so its exit code carries no verdict. */
|
|
26
|
+
timedOut: boolean
|
|
27
|
+
/** The process errored before delivering a verdict (spawn failure, EIO). */
|
|
28
|
+
spawnFailed?: boolean
|
|
29
|
+
}
|
|
30
|
+
/** Runs one configured hook entry, whatever its type; boundRunner dispatches. */
|
|
31
|
+
export type HookRunner = (hook: HookCommand, payload: unknown, timeoutMs: number) => Promise<HookRunResult>
|
|
32
|
+
/** The shell path specifically; the statusline reuses it for its own command. With an
|
|
33
|
+
* `args` array it becomes the exec path: `command` is spawned directly with those args.
|
|
34
|
+
* `onChild` hands the caller a kill for the spawned tree, so a background hook that is
|
|
35
|
+
* still running at session end can be reaped (Claude kills async hooks at teardown). */
|
|
36
|
+
export type HookCommandRunner = (command: string, payload: unknown, timeoutMs: number, projectDir?: string, args?: string[], onChild?: (kill: () => void) => void) => Promise<HookRunResult>
|
|
37
|
+
|
|
38
|
+
/** Above 2^31-1 ms Node clamps a timer to 1ms, which would kill the hook instantly. */
|
|
39
|
+
const MAX_TIMEOUT_S = 2_147_483
|
|
40
|
+
|
|
41
|
+
export function timeoutMs(command: HookCommand): number {
|
|
42
|
+
// Claude does not enforce `timeout` on an `async` command hook (it does on
|
|
43
|
+
// `asyncRewake`), so the budget is the Node timer ceiling: the timer exists only
|
|
44
|
+
// so the delay never clamps, not as a deadline. Still-running background hooks
|
|
45
|
+
// are killed at session end instead.
|
|
46
|
+
if (isBackgroundHook(command) && command.asyncRewake !== true) return MAX_TIMEOUT_S * 1000
|
|
47
|
+
// Non-positive values fall back to the default: a 0ms timer would fire before the
|
|
48
|
+
// hook runs, and a timed-out PreToolUse hook fails closed, bricking the tool.
|
|
49
|
+
const declared = command.timeout
|
|
50
|
+
const seconds = typeof declared === 'number' && declared > 0 ? Math.min(declared, MAX_TIMEOUT_S) : DEFAULT_TIMEOUT_S
|
|
51
|
+
return seconds * 1000
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Memory backstop for a runaway hook. A decision payload is orders of magnitude smaller. */
|
|
55
|
+
const MAX_HOOK_OUTPUT = 1_000_000
|
|
56
|
+
|
|
57
|
+
/** Conventional exit code for a killed-on-timeout command, as `timeout(1)` reports it. */
|
|
58
|
+
const TIMEOUT_EXIT_CODE = 124
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Kill the shell and everything it spawned. `sh -c 'a; b'` forks, so signalling the
|
|
62
|
+
* direct child alone leaves a grandchild alive holding stdout/stderr.
|
|
63
|
+
*/
|
|
64
|
+
function killTree(child: ChildProcess): void {
|
|
65
|
+
try {
|
|
66
|
+
// Negative pid targets the whole process group, which `detached` gave the shell.
|
|
67
|
+
if (child.pid) {
|
|
68
|
+
process.kill(-child.pid, 'SIGKILL')
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
} catch {
|
|
72
|
+
// Group already reaped, or the platform refused it; fall through to the direct kill.
|
|
73
|
+
}
|
|
74
|
+
child.kill('SIGKILL')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const runHookCommand: HookCommandRunner = (command, payload, timeoutMs, projectDir, args, onChild) =>
|
|
78
|
+
new Promise((resolve) => {
|
|
79
|
+
// Absolute path so the shell can't be resolved through an attacker-controlled PATH.
|
|
80
|
+
// `detached` makes the shell its own process group leader so the timeout can kill
|
|
81
|
+
// the descendants too. CLAUDE_PROJECT_DIR is Claude's documented way for a hook to
|
|
82
|
+
// reference project files regardless of the shell's cwd. CLAUDECODE=1 marks every
|
|
83
|
+
// subprocess Claude spawns, so it is set on the child unconditionally.
|
|
84
|
+
const env: NodeJS.ProcessEnv = { ...process.env, CLAUDECODE: '1' }
|
|
85
|
+
if (projectDir) env.CLAUDE_PROJECT_DIR = projectDir
|
|
86
|
+
// An exec-form hook (an `args` array) spawns the executable directly with those args
|
|
87
|
+
// and no shell, so shell metacharacters in the args arrive literally; $ARGUMENTS in
|
|
88
|
+
// each arg is replaced with the event JSON by a replacer function (so $$/$& in the
|
|
89
|
+
// payload survive verbatim). Without args it stays the shell path. Both share the
|
|
90
|
+
// same detached process group, so killTree reaches the descendants either way.
|
|
91
|
+
const file = Array.isArray(args) ? command : '/bin/sh'
|
|
92
|
+
const spawnArgs = Array.isArray(args) ? args.map((arg) => substituteArguments(arg, payload)) : ['-c', command]
|
|
93
|
+
const child = spawn(file, spawnArgs, { stdio: ['pipe', 'pipe', 'pipe'], detached: true, env })
|
|
94
|
+
onChild?.(() => killTree(child))
|
|
95
|
+
let stdout = ''
|
|
96
|
+
let stderr = ''
|
|
97
|
+
let settled = false
|
|
98
|
+
const finish = (result: HookRunResult): void => {
|
|
99
|
+
if (settled) return
|
|
100
|
+
settled = true
|
|
101
|
+
clearTimeout(timer)
|
|
102
|
+
resolve(result)
|
|
103
|
+
}
|
|
104
|
+
// Resolve from the timer itself rather than waiting for `close`: `close` fires only
|
|
105
|
+
// once every stdio pipe is closed, and a grandchild that inherited them can hold the
|
|
106
|
+
// promise pending long past the timeout, stalling the tool call that awaits it.
|
|
107
|
+
const timer = setTimeout(() => {
|
|
108
|
+
killTree(child)
|
|
109
|
+
finish({ code: TIMEOUT_EXIT_CODE, stdout, stderr, timedOut: true })
|
|
110
|
+
}, timeoutMs)
|
|
111
|
+
// Decode on the stream: concatenating Buffers as strings mangles a multi-byte
|
|
112
|
+
// character split across chunks, and a mangled byte in a hook's deny decision makes
|
|
113
|
+
// it unparseable, which reads as an allow.
|
|
114
|
+
child.stdout?.setEncoding('utf8')
|
|
115
|
+
child.stderr?.setEncoding('utf8')
|
|
116
|
+
child.stdout?.on('data', (chunk: string) => {
|
|
117
|
+
if (stdout.length < MAX_HOOK_OUTPUT) stdout += chunk
|
|
118
|
+
})
|
|
119
|
+
child.stderr?.on('data', (chunk: string) => {
|
|
120
|
+
if (stderr.length < MAX_HOOK_OUTPUT) stderr += chunk
|
|
121
|
+
})
|
|
122
|
+
child.on('close', (code) => finish({ code: code ?? 0, stdout, stderr, timedOut: false }))
|
|
123
|
+
// Marked rather than silently read as a clean run: under fd exhaustion a
|
|
124
|
+
// deny-list guard that never spawned would otherwise pass as an allow.
|
|
125
|
+
child.on('error', (error) => finish({ code: 0, stdout, stderr: stderr || error.message, timedOut: false, spawnFailed: true }))
|
|
126
|
+
// A hook that exits without reading stdin (e.g. `exit 2`) closes the pipe first,
|
|
127
|
+
// so ignore EPIPE on this write rather than crashing the host process.
|
|
128
|
+
child.stdin?.on('error', () => {})
|
|
129
|
+
child.stdin?.end(JSON.stringify(payload))
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
/** `$VAR` / `${VAR}` in header values, from allowlisted env vars only; a reference
|
|
133
|
+
* to an unlisted variable becomes an empty string, as Claude documents. */
|
|
134
|
+
function interpolateHeaders(headers: Record<string, string> | undefined, allowed: string[] | undefined): Record<string, string> {
|
|
135
|
+
const allowedSet = new Set(allowed ?? [])
|
|
136
|
+
const out: Record<string, string> = {}
|
|
137
|
+
for (const [key, value] of Object.entries(headers ?? {})) {
|
|
138
|
+
out[key] = value.replace(/\$(?:\{([A-Za-z_]\w*)\}|([A-Za-z_]\w*))/g, (_token, braced?: string, bare?: string) => {
|
|
139
|
+
const name = braced ?? bare ?? ''
|
|
140
|
+
return allowedSet.has(name) ? (process.env[name] ?? '') : ''
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
return out
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Claude's `type: "http"` hook: the payload POSTs as JSON and only a 2xx response
|
|
148
|
+
* with a valid JSON body renders a decision, read exactly like command stdout.
|
|
149
|
+
* Everything else, including non-2xx statuses, connection failures and timeouts,
|
|
150
|
+
* is a non-blocking error by contract, so none of these outcomes ever reports
|
|
151
|
+
* `timedOut`, which PreToolUse fails closed on. Claude's `allowedHttpHookUrls`
|
|
152
|
+
* allowlist gates the fetch itself: a URL matching no entry is never contacted,
|
|
153
|
+
* so a settings file cannot point a hook at an arbitrary endpoint and exfiltrate
|
|
154
|
+
* the payload; when the setting is absent there are no restrictions, as Claude
|
|
155
|
+
* documents. A blocked hook renders no decision, like every other http failure.
|
|
156
|
+
*/
|
|
157
|
+
export async function runHttpHook(hook: { type?: string; command: string; url?: string; headers?: Record<string, string>; allowedEnvVars?: string[] }, payload: unknown, timeoutMs: number, allowedUrls?: string[]): Promise<HookRunResult> {
|
|
158
|
+
const url = hook.url ?? hook.command
|
|
159
|
+
if (!httpUrlAllowed(url, allowedUrls)) return { code: 1, stdout: '', stderr: `${url} does not match allowedHttpHookUrls; the hook was not called`, timedOut: false }
|
|
160
|
+
try {
|
|
161
|
+
const response = await fetch(url, {
|
|
162
|
+
method: 'POST',
|
|
163
|
+
headers: { 'content-type': 'application/json', ...interpolateHeaders(hook.headers, hook.allowedEnvVars) },
|
|
164
|
+
body: JSON.stringify(payload),
|
|
165
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
166
|
+
})
|
|
167
|
+
const body = (await response.text()).slice(0, MAX_HOOK_OUTPUT)
|
|
168
|
+
if (!response.ok) return { code: 1, stdout: '', stderr: `HTTP ${response.status} from ${url}`, timedOut: false }
|
|
169
|
+
if (body.trim().length === 0) return { code: 0, stdout: '', stderr: '', timedOut: false }
|
|
170
|
+
try {
|
|
171
|
+
JSON.parse(body)
|
|
172
|
+
} catch {
|
|
173
|
+
return { code: 1, stdout: '', stderr: `non-JSON response from ${url}`, timedOut: false }
|
|
174
|
+
}
|
|
175
|
+
return { code: 0, stdout: body, stderr: '', timedOut: false }
|
|
176
|
+
} catch (error) {
|
|
177
|
+
return { code: 1, stdout: '', stderr: error instanceof Error ? error.message : String(error), timedOut: false }
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** System prompt turning a prompt hook into a structured decision, so its reply
|
|
182
|
+
* flows through interpretHookResult exactly like a command hook's stdout. */
|
|
183
|
+
const PROMPT_HOOK_SYSTEM = [
|
|
184
|
+
'You are a Claude Code hook evaluating whether an action should proceed.',
|
|
185
|
+
'Respond with ONLY a JSON object and nothing else:',
|
|
186
|
+
'{"hookSpecificOutput":{"permissionDecision":"allow"|"deny"|"ask","permissionDecisionReason":"<short reason>"}}',
|
|
187
|
+
'Use "allow" to let the action proceed, "deny" to block it, "ask" to require the user to confirm.',
|
|
188
|
+
].join('\n')
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Claude's `type: "prompt"` hook: the prompt (with `$ARGUMENTS` replaced by the
|
|
192
|
+
* event JSON) is evaluated by the model, which returns a JSON decision. pi runs it
|
|
193
|
+
* in-process via completeText and returns the reply as stdout so the existing
|
|
194
|
+
* decision parser handles it. No model (headless) or a provider error is
|
|
195
|
+
* non-blocking; only an abort at the timeout fails closed, like the other hooks.
|
|
196
|
+
*/
|
|
197
|
+
/** Replace `$ARGUMENTS` with the event JSON via a replacer function, so `$`-sequences
|
|
198
|
+
* in the payload (`$$`, `$&`, `` $` ``, `$'`) are inserted literally, not read as
|
|
199
|
+
* `String.replace` patterns. Prompt and agent hooks feed the result to the model. */
|
|
200
|
+
function substituteArguments(prompt: string | undefined, payload: unknown): string {
|
|
201
|
+
const json = JSON.stringify(payload)
|
|
202
|
+
return (prompt ?? '').replaceAll('$ARGUMENTS', () => json)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Classify a model/agent failure: the deadline is authoritative via the signal (the
|
|
206
|
+
* subagent runner rejects with a plain Error on abort, so an error-name check alone
|
|
207
|
+
* fails open), so a fired signal is a timeout (PreToolUse fails closed); anything else
|
|
208
|
+
* produced no verdict and is non-blocking. */
|
|
209
|
+
function abortAwareFailure(signal: AbortSignal, error: unknown): HookRunResult {
|
|
210
|
+
const aborted = signal.aborted || (error instanceof Error && (error.name === 'AbortError' || error.name === 'TimeoutError'))
|
|
211
|
+
return { code: aborted ? TIMEOUT_EXIT_CODE : 1, stdout: '', stderr: error instanceof Error ? error.message : String(error), timedOut: aborted }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export async function runPromptHook(hook: HookCommand, payload: unknown, model: Model<Api> | undefined, timeoutMs: number): Promise<HookRunResult> {
|
|
215
|
+
if (!model) return { code: 1, stdout: '', stderr: 'no model available for prompt hook', timedOut: false }
|
|
216
|
+
// A replacer function, so `$$`/`$&`/`` $` ``/`$'` inside the payload JSON are inserted
|
|
217
|
+
// verbatim rather than read as replacement patterns (a Bash `echo $$` is a common trigger).
|
|
218
|
+
const prompt = substituteArguments(hook.prompt, payload)
|
|
219
|
+
const signal = AbortSignal.timeout(timeoutMs)
|
|
220
|
+
try {
|
|
221
|
+
const { text: answer } = await completeText(model, prompt, { system: PROMPT_HOOK_SYSTEM, maxTokens: 512, signal })
|
|
222
|
+
return { code: 0, stdout: answer, stderr: '', timedOut: false }
|
|
223
|
+
} catch (error) {
|
|
224
|
+
return abortAwareFailure(signal, error)
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Claude's `type: "mcp_tool"` hook: call a tool on an already-connected MCP server
|
|
230
|
+
* and treat its text output like command stdout. pi reaches the server through the
|
|
231
|
+
* mcp-call seam the mcp extension registers. Like http, it never fails closed: a
|
|
232
|
+
* missing server, a tool error, or the deadline is non-blocking.
|
|
233
|
+
*/
|
|
234
|
+
export async function runMcpToolHook(hook: HookCommand, payload: unknown, timeoutMs: number): Promise<HookRunResult> {
|
|
235
|
+
if (!hook.server || !hook.tool) return { code: 1, stdout: '', stderr: 'mcp_tool hook needs server and tool', timedOut: false }
|
|
236
|
+
const input = hook.input && typeof hook.input === 'object' ? hook.input : (payload as Record<string, unknown>)
|
|
237
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
238
|
+
const deadline = new Promise<HookRunResult>((resolve) => {
|
|
239
|
+
timer = setTimeout(() => resolve({ code: 1, stdout: '', stderr: `mcp_tool hook timed out after ${timeoutMs}ms`, timedOut: false }), timeoutMs)
|
|
240
|
+
})
|
|
241
|
+
const call = callMcpTool(hook.server, hook.tool, input)
|
|
242
|
+
.then((result): HookRunResult => ({ code: result.isError ? 1 : 0, stdout: result.text, stderr: '', timedOut: false }))
|
|
243
|
+
.catch((error): HookRunResult => ({ code: 1, stdout: '', stderr: error instanceof Error ? error.message : String(error), timedOut: false }))
|
|
244
|
+
try {
|
|
245
|
+
return await Promise.race([call, deadline])
|
|
246
|
+
} finally {
|
|
247
|
+
// Left running, the deadline timer pins the event loop for the full timeout
|
|
248
|
+
// after the call resolves, delaying exit in a one-shot headless run.
|
|
249
|
+
clearTimeout(timer)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Claude's experimental `type: "agent"` hook: spawn a subagent (Read/Grep/Glob) to
|
|
255
|
+
* verify a condition, then return its final text as a JSON decision, parsed by the
|
|
256
|
+
* same interpreter as a command hook. pi reaches the subagent through the agent-run
|
|
257
|
+
* seam the subagent extension registers. Like the prompt hook, only an abort at the
|
|
258
|
+
* deadline fails closed; a missing runner or a crashed agent is non-blocking.
|
|
259
|
+
*/
|
|
260
|
+
export async function runAgentHook(hook: HookCommand, payload: unknown, timeoutMs: number, sessionModelId: string | undefined): Promise<HookRunResult> {
|
|
261
|
+
const prompt = substituteArguments(hook.prompt, payload)
|
|
262
|
+
const signal = AbortSignal.timeout(timeoutMs)
|
|
263
|
+
try {
|
|
264
|
+
const answer = await runAgent({ prompt, model: hook.model ?? sessionModelId, systemPrompt: hook.systemPrompt, signal })
|
|
265
|
+
return { code: 0, stdout: answer, stderr: '', timedOut: false }
|
|
266
|
+
} catch (error) {
|
|
267
|
+
return abortAwareFailure(signal, error)
|
|
268
|
+
}
|
|
269
|
+
}
|
|
@@ -107,7 +107,7 @@ export function normalizeToolName(name: string): string {
|
|
|
107
107
|
* Scanned rather than matched with a regex: the pattern form is quadratic on an input
|
|
108
108
|
* of unclosed parens, and a command file comes from the repository.
|
|
109
109
|
*/
|
|
110
|
-
|
|
110
|
+
function toolEntries(raw: string): string[] {
|
|
111
111
|
const entries: string[] = []
|
|
112
112
|
let current = ''
|
|
113
113
|
let depth = 0
|
|
@@ -26,6 +26,11 @@ export function setManagedSettingsPath(file?: string): void {
|
|
|
26
26
|
managedSettingsFileOverride = file
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/** The managed-settings.json path in effect, honoring the test-seam override. */
|
|
30
|
+
export function managedSettingsFile(): string {
|
|
31
|
+
return managedSettingsFileOverride ?? managedSettingsPath()
|
|
32
|
+
}
|
|
33
|
+
|
|
29
34
|
/** The parsed managed settings object, or {} when absent or malformed. */
|
|
30
35
|
export function readManagedSettings(file: string = managedSettingsFileOverride ?? managedSettingsPath()): Record<string, unknown> {
|
|
31
36
|
try {
|
|
@@ -19,7 +19,7 @@ import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, truncateHead } from '
|
|
|
19
19
|
* Shorter input comes back whole and a negative budget yields nothing, so callers
|
|
20
20
|
* need no length check of their own.
|
|
21
21
|
*/
|
|
22
|
-
|
|
22
|
+
function sliceBytes(text: string, maxBytes: number): string {
|
|
23
23
|
return Buffer.from(text, 'utf-8').subarray(0, Math.max(0, maxBytes)).toString('utf-8')
|
|
24
24
|
}
|
|
25
25
|
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared Claude settings chain: the ordered settings.json files a home-and-project
|
|
3
|
+
* setting is read from, newest winning. User settings always lead; the project's
|
|
4
|
+
* settings.json and settings.local.json (each the nearest of its name at or above cwd,
|
|
5
|
+
* falling back to cwd's own `.claude/`) follow only when the project is included, the
|
|
6
|
+
* trust gate every caller applies. Hooks, output styles, memory, the CLAUDE.md excludes,
|
|
7
|
+
* and the skill-shell policy all resolve their files through this one chain.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import * as path from 'node:path'
|
|
11
|
+
import { claudeConfigDir } from './config-dir.js'
|
|
12
|
+
import { findNearestFile } from './project-root.js'
|
|
13
|
+
|
|
14
|
+
/** The user settings.json, then (only when `includeProject`) the nearest project
|
|
15
|
+
* settings.json and settings.local.json at or above cwd, with cwd's own `.claude/` as
|
|
16
|
+
* the fallback for each. Later files win. */
|
|
17
|
+
export function claudeSettingsChain(cwd: string, home: string, includeProject: boolean): string[] {
|
|
18
|
+
const files = [path.join(claudeConfigDir(home), 'settings.json')]
|
|
19
|
+
if (!includeProject) return files
|
|
20
|
+
for (const name of ['settings.json', 'settings.local.json']) {
|
|
21
|
+
files.push(findNearestFile(cwd, path.join('.claude', name)) ?? path.join(cwd, '.claude', name))
|
|
22
|
+
}
|
|
23
|
+
return files
|
|
24
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A cheap freshness token for a file: its mtime and size joined, so a same-mtime rewrite
|
|
3
|
+
* of a different length still invalidates. The per-turn caches that stat a file instead of
|
|
4
|
+
* re-reading it (the CLAUDE.md import memo, the memory index cache) compare this token.
|
|
5
|
+
* Throws when the file cannot be stat'd; callers that treat "missing" as a value catch it.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as fs from 'node:fs'
|
|
9
|
+
|
|
10
|
+
/** `${mtimeMs}:${size}` for `file`. Throws if the file cannot be stat'd. */
|
|
11
|
+
export function statToken(file: string): string {
|
|
12
|
+
const stat = fs.statSync(file)
|
|
13
|
+
return `${stat.mtimeMs}:${stat.size}`
|
|
14
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A per-turn value override: capture the prior value once, restore it when the turn
|
|
3
|
+
* settles. This is the shape shared by commands.ts's `model:`/`effort:` overrides and
|
|
4
|
+
* thinking.ts's keyword escalation. Each captures what to restore the first time a turn
|
|
5
|
+
* overrides (so back-to-back overrides in one turn still restore the original session
|
|
6
|
+
* value, not an intermediate one), drops that capture without restoring at a session
|
|
7
|
+
* boundary, and restores on settle.
|
|
8
|
+
*
|
|
9
|
+
* A conditional override restores only when the current value still equals the target it
|
|
10
|
+
* moved to, so a later owner (another extension's restore, a manual change) is not
|
|
11
|
+
* clobbered; a current that cannot be read restores unconditionally, the best-effort the
|
|
12
|
+
* consumers relied on. The escalating set itself stays at the call site, since who reads
|
|
13
|
+
* "current" and how the value is applied (sync, async-with-catch) differ per consumer.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export interface TurnOverride<T> {
|
|
17
|
+
/** The value to restore, captured only on the first arm of a turn; undefined when
|
|
18
|
+
* nothing is armed. */
|
|
19
|
+
readonly prior: T | undefined
|
|
20
|
+
/** The value last armed as the move target, for a conditional restore. */
|
|
21
|
+
readonly target: T | undefined
|
|
22
|
+
/** Record the value to restore (kept only the first time per turn) and, optionally, the
|
|
23
|
+
* value moved to for a conditional restore. Does not apply the override. */
|
|
24
|
+
arm(prior: T, target?: T): void
|
|
25
|
+
/** Drop the pending capture without restoring: a session boundary. */
|
|
26
|
+
reset(): void
|
|
27
|
+
/** Restore the captured prior via `set` and clear the capture. A conditional override
|
|
28
|
+
* stands down when the current value has moved off the target. No-op when nothing is
|
|
29
|
+
* armed. */
|
|
30
|
+
settle(): void
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function createTurnOverride<T>(opts: {
|
|
34
|
+
/** Applies the restore value. Consumers wrap their setter here (async-with-catch for
|
|
35
|
+
* commands' model, plain for the thinking level). */
|
|
36
|
+
set: (value: T) => void
|
|
37
|
+
/** Reads the current value, for a conditional restore. Only consulted when
|
|
38
|
+
* `conditional` is set. */
|
|
39
|
+
get?: () => T | undefined
|
|
40
|
+
/** When set, restore only if the current value still equals the armed target. */
|
|
41
|
+
conditional?: boolean
|
|
42
|
+
}): TurnOverride<T> {
|
|
43
|
+
let prior: T | undefined
|
|
44
|
+
let target: T | undefined
|
|
45
|
+
return {
|
|
46
|
+
get prior() {
|
|
47
|
+
return prior
|
|
48
|
+
},
|
|
49
|
+
get target() {
|
|
50
|
+
return target
|
|
51
|
+
},
|
|
52
|
+
arm(priorValue, targetValue) {
|
|
53
|
+
prior = prior ?? priorValue
|
|
54
|
+
target = targetValue
|
|
55
|
+
},
|
|
56
|
+
reset() {
|
|
57
|
+
prior = undefined
|
|
58
|
+
target = undefined
|
|
59
|
+
},
|
|
60
|
+
settle() {
|
|
61
|
+
if (prior === undefined) return
|
|
62
|
+
const restore = prior
|
|
63
|
+
const movedTo = target
|
|
64
|
+
prior = undefined
|
|
65
|
+
target = undefined
|
|
66
|
+
if (opts.conditional) {
|
|
67
|
+
const current = opts.get?.()
|
|
68
|
+
if (current !== undefined && current !== movedTo) return
|
|
69
|
+
}
|
|
70
|
+
opts.set(restore)
|
|
71
|
+
},
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP server configuration: the config types, ${VAR} interpolation, the user and
|
|
3
|
+
* project config paths and their loading, and the servers declared by plugins.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import * as fs from 'node:fs'
|
|
7
|
+
import * as os from 'node:os'
|
|
8
|
+
import * as path from 'node:path'
|
|
9
|
+
import { claudeConfigDir } from '../internal/config-dir.js'
|
|
10
|
+
import { type InstalledPlugin, substitutePluginVars } from '../internal/plugins.js'
|
|
11
|
+
import { findNearestFile } from '../internal/project-root.js'
|
|
12
|
+
|
|
13
|
+
export interface StdioServerConfig {
|
|
14
|
+
type?: 'stdio'
|
|
15
|
+
command: string
|
|
16
|
+
args?: string[]
|
|
17
|
+
env?: Record<string, string>
|
|
18
|
+
cwd?: string
|
|
19
|
+
/** Per-call wall-clock budget in ms, overriding MCP_TOOL_TIMEOUT for this server. */
|
|
20
|
+
timeout?: number
|
|
21
|
+
/** Plugin servers alias their tools mcp__plugin_<plugin>_<server>__<tool>. */
|
|
22
|
+
aliasPrefix?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface HttpServerConfig {
|
|
26
|
+
type?: 'http' | 'streamable-http' | 'sse' | 'ws' | 'websocket'
|
|
27
|
+
url: string
|
|
28
|
+
headers?: Record<string, string>
|
|
29
|
+
bearerToken?: string
|
|
30
|
+
bearerTokenEnv?: string
|
|
31
|
+
/** A command whose JSON stdout is merged into the connect headers, for auth
|
|
32
|
+
* schemes other than OAuth/static tokens (Claude's headersHelper). */
|
|
33
|
+
headersHelper?: string
|
|
34
|
+
/** Per-call wall-clock budget in ms, overriding MCP_TOOL_TIMEOUT for this server. */
|
|
35
|
+
timeout?: number
|
|
36
|
+
/** Plugin servers alias their tools mcp__plugin_<plugin>_<server>__<tool>. */
|
|
37
|
+
aliasPrefix?: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type ServerConfig = StdioServerConfig | HttpServerConfig
|
|
41
|
+
|
|
42
|
+
/** Claude's .mcp.json expansion: ${VAR}, and ${VAR:-default}. The syntax borrows
|
|
43
|
+
* shell's `:-`, which substitutes when the variable is unset OR empty. */
|
|
44
|
+
export function interpolateEnv(value: string, env: NodeJS.ProcessEnv = process.env, onMissing?: (name: string) => void): string {
|
|
45
|
+
return value.replace(/\$\{(\w+)(:-([^}]*))?\}/g, (fullMatch, name, hasDefault, fallback) => {
|
|
46
|
+
const current = env[name]
|
|
47
|
+
if (hasDefault !== undefined) return current || fallback
|
|
48
|
+
if (current === undefined) {
|
|
49
|
+
// A referenced variable with no value and no default: keep the literal ${VAR} and
|
|
50
|
+
// report it, matching Claude, rather than silently substituting an empty string that
|
|
51
|
+
// turns `Bearer ${TOKEN}` into a confusing `Bearer ` and a mystery 401.
|
|
52
|
+
onMissing?.(name)
|
|
53
|
+
return fullMatch
|
|
54
|
+
}
|
|
55
|
+
return current
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The user's ~/.claude.json (top-level mcpServers plus the per-project `projects` map).
|
|
60
|
+
* When CLAUDE_CONFIG_DIR is set, Claude relocates .claude.json inside that directory; by
|
|
61
|
+
* default it stays at the home root, since .claude.json does NOT live inside ~/.claude. A
|
|
62
|
+
* blank value is treated as unset, matching claudeConfigDir. */
|
|
63
|
+
function claudeJsonPath(home: string): string {
|
|
64
|
+
const override = process.env.CLAUDE_CONFIG_DIR
|
|
65
|
+
return override && override.trim().length > 0 ? path.join(claudeConfigDir(home), '.claude.json') : path.join(home, '.claude.json')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** User-scoped MCP config (the user's own; safe to load without project trust). The .pi
|
|
69
|
+
* tree is pi's own and is not relocated by CLAUDE_CONFIG_DIR. */
|
|
70
|
+
export function userConfigPaths(home: string): string[] {
|
|
71
|
+
return [claudeJsonPath(home), path.join(home, '.pi', 'agent', 'mcp.json')]
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Project-scoped MCP config, each file the nearest of its name at or above cwd
|
|
75
|
+
* (bounded at the repository root, matching the approval walk). Loaded only for
|
|
76
|
+
* trusted projects: a server's `command` runs on connect. */
|
|
77
|
+
export function projectConfigPaths(cwd: string): string[] {
|
|
78
|
+
return ['.mcp.json', path.join('.pi', 'mcp.json')].map((rel) => findNearestFile(cwd, rel) ?? path.join(cwd, rel))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function loadConfigFrom(files: string[]): Record<string, ServerConfig> {
|
|
82
|
+
const servers: Record<string, ServerConfig> = {}
|
|
83
|
+
for (const file of files) {
|
|
84
|
+
try {
|
|
85
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf-8'))
|
|
86
|
+
Object.assign(servers, parsed.mcpServers ?? {})
|
|
87
|
+
} catch {
|
|
88
|
+
// missing or invalid file: skip silently, /mcp reports what loaded
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return servers
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* All user-owned servers for this session: the global user servers plus Claude's "local"
|
|
96
|
+
* scope, the per-project user servers under `projects[cwd].mcpServers` in ~/.claude.json.
|
|
97
|
+
* Both are the user's own config, so neither needs project trust; local wins on a name
|
|
98
|
+
* clash (Claude's precedence is local over user).
|
|
99
|
+
*/
|
|
100
|
+
export function loadUserScope(home: string, cwd: string): Record<string, ServerConfig> {
|
|
101
|
+
const servers = loadConfigFrom(userConfigPaths(home))
|
|
102
|
+
try {
|
|
103
|
+
const claudeJson = JSON.parse(fs.readFileSync(claudeJsonPath(home), 'utf-8'))
|
|
104
|
+
Object.assign(servers, claudeJson.projects?.[cwd]?.mcpServers ?? {})
|
|
105
|
+
} catch {
|
|
106
|
+
// missing or invalid ~/.claude.json: the top-level user servers already loaded
|
|
107
|
+
}
|
|
108
|
+
return servers
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The mcpServers one plugin declares: an inline map on the manifest, or the file it
|
|
112
|
+
* points to (default .mcp.json at the plugin root), with ${CLAUDE_PLUGIN_*} substituted
|
|
113
|
+
* before parsing. Malformed or missing JSON yields no entries. */
|
|
114
|
+
function pluginServerEntries(plugin: InstalledPlugin): Record<string, ServerConfig> {
|
|
115
|
+
const declared = plugin.manifest.mcpServers
|
|
116
|
+
// An inline map of name -> config; an array is not a valid mcpServers map (it
|
|
117
|
+
// would register a server named '0'), so it falls through to the path branch.
|
|
118
|
+
if (declared !== null && typeof declared === 'object' && !Array.isArray(declared)) {
|
|
119
|
+
try {
|
|
120
|
+
return JSON.parse(substitutePluginVars(JSON.stringify(declared), plugin))
|
|
121
|
+
} catch {
|
|
122
|
+
return {}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const file = path.resolve(plugin.root, typeof declared === 'string' ? declared : '.mcp.json')
|
|
126
|
+
try {
|
|
127
|
+
const parsed = JSON.parse(substitutePluginVars(fs.readFileSync(file, 'utf-8'), plugin))
|
|
128
|
+
return parsed.mcpServers ?? {}
|
|
129
|
+
} catch {
|
|
130
|
+
return {}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Servers shipped by enabled plugins (.mcp.json or the manifest's `mcpServers`,
|
|
135
|
+
* inline or by path), with ${CLAUDE_PLUGIN_*} substituted before parsing. Their
|
|
136
|
+
* tools alias as mcp__plugin_<plugin>_<server>__<tool> for hook matchers, as
|
|
137
|
+
* Claude scopes them. */
|
|
138
|
+
export function loadPluginServers(plugins: InstalledPlugin[]): Record<string, ServerConfig> {
|
|
139
|
+
const fold = (name: string): string => name.replaceAll('-', '_')
|
|
140
|
+
const servers: Record<string, ServerConfig> = {}
|
|
141
|
+
for (const plugin of plugins) {
|
|
142
|
+
for (const [name, config] of Object.entries(pluginServerEntries(plugin))) {
|
|
143
|
+
servers[name] = { ...config, aliasPrefix: `mcp__plugin_${fold(plugin.name)}_${fold(name)}__` }
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return servers
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** A server cwd expands ${VAR} then a leading ~, or stays unset. */
|
|
150
|
+
export function expandCwd(cwd: string | undefined): string | undefined {
|
|
151
|
+
if (!cwd) return undefined
|
|
152
|
+
return interpolateEnv(cwd).replace(/^~(?=\/|$)/, os.homedir())
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function warnOnTypelessUrl(name: string, config: ServerConfig): void {
|
|
156
|
+
if ('url' in config && config.type === undefined) {
|
|
157
|
+
console.warn(`pi-code-mcp: server ${name} declares a url with no "type"; add "type": "http" or "sse"`)
|
|
158
|
+
}
|
|
159
|
+
}
|