pi-code 1.0.61 → 1.0.63
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 +2 -0
- package/extensions/commands.ts +45 -11
- package/extensions/context-imports.ts +43 -21
- package/extensions/env-settings.ts +19 -1
- package/extensions/git-checkpoint.ts +4 -0
- package/extensions/hooks/claude-tools.ts +9 -7
- package/extensions/hooks/config.ts +20 -3
- package/extensions/hooks/index.ts +14 -3
- package/extensions/hooks/matcher.ts +36 -8
- package/extensions/hooks/runners.ts +45 -2
- package/extensions/internal/claude-tool-names.ts +70 -0
- package/extensions/internal/command-file.ts +50 -38
- package/extensions/internal/mcp-oauth.ts +29 -4
- package/extensions/internal/path-rules.ts +62 -32
- package/extensions/internal/plugins.ts +11 -0
- package/extensions/internal/scope-rules.ts +117 -0
- package/extensions/internal/shell-resolve.ts +16 -5
- package/extensions/internal/web-transport.ts +7 -0
- package/extensions/mcp/config.ts +5 -1
- package/extensions/mcp/index.ts +51 -22
- package/extensions/mcp/oauth-flow.ts +1 -1
- package/extensions/mcp/transport.ts +29 -3
- package/extensions/question.ts +147 -12
- package/extensions/session-title.ts +5 -0
- package/extensions/skills.ts +26 -3
- package/extensions/status-line.ts +4 -2
- package/extensions/subagent/child.ts +17 -2
- package/extensions/subagent/index.ts +4 -2
- package/extensions/subagent/run.ts +32 -7
- package/extensions/thinking.ts +14 -8
- package/extensions/web.ts +74 -21
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -60,6 +60,8 @@ Slash commands: `/init`, `/context`, `/goal`, `/memory`, `/todos`, `/rewind`, `/
|
|
|
60
60
|
|
|
61
61
|
pi has no general permission system, so most of what Claude routes through a permission prompt maps to hard behavior here: `allowed-tools` restricts the turn's tool set instead of pre-approving calls, and a hook that times out on PreToolUse or UserPromptSubmit fails closed. A hook's `permissionDecision: "ask"` is the exception: it shows a confirm dialog and lets the call through when you approve (a headless run has no dialog, so it blocks). Where a Claude restriction cannot be expressed at all (an argument-scoped grant in an agent's `tools:`), the definition is rejected rather than widened.
|
|
62
62
|
|
|
63
|
+
Trust is the other place pi-code is deliberately stricter. Claude states that "a `claude -p` run never shows the trust dialog" and loads the project's hooks, MCP servers, agents, commands, skills and rules anyway. pi-code refuses instead: with no stored decision and no UI to ask, a headless run in a project you have not already trusted loads none of them. A repository would otherwise get to run its own hooks and MCP servers in any CI job that checks it out, with nobody present to decline.
|
|
64
|
+
|
|
63
65
|
`CLAUDE.md` itself needs no extension: pi loads `CLAUDE.md` / `AGENTS.md` context files natively (global + walking cwd to root). `context-imports.ts` only adds the `@import` resolution pi's loader lacks, appending the imported files without re-injecting the base. Setting `CLAUDE_CONFIG_DIR` relocates the entire home config scope (settings, commands, agents, skills, plugins, output styles, memory, and the user `CLAUDE.md`); a project's own `.claude/` is a separate scope and is unaffected.
|
|
64
66
|
|
|
65
67
|
[`extensions/internal/`](extensions/internal) holds the shared modules pi's loader must not treat as extensions (each file's header says what it owns); only `internal/` keeps them out of pi's extension scan.
|
package/extensions/commands.ts
CHANGED
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
* `allowed-tools`, `argument-hint` and `model` frontmatter (`model` switches the
|
|
11
11
|
* session model for the command's run via `pi.setModel`, restored on agent_end).
|
|
12
12
|
* `shell: powershell` runs a command's injected spans through PowerShell when a
|
|
13
|
-
* pwsh binary is installed, falling back to
|
|
14
|
-
* without one.
|
|
13
|
+
* pwsh binary is installed, falling back to the default shell path so the command
|
|
14
|
+
* still works without one: /bin/sh on POSIX, Git Bash on Windows.
|
|
15
15
|
*
|
|
16
16
|
* Commands are also exposed to the model through a `slash_command` tool
|
|
17
17
|
* (Claude's SlashCommand tool), listing every discovered command whose file
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
* A project command body is repository-controlled text that can run shell
|
|
34
34
|
* commands and read files, so project commands load only once the project is
|
|
35
35
|
* approved. That closes the "skills / commands are not trust-gated" limitation
|
|
36
|
-
* for commands; skills
|
|
36
|
+
* for commands; skills.ts gates project skills on the same approval.
|
|
37
37
|
*
|
|
38
38
|
* Docs: https://code.claude.com/docs/en/slash-commands.md
|
|
39
39
|
*/
|
|
@@ -54,6 +54,7 @@ import { matchesPathRules } from './internal/path-rules.js'
|
|
|
54
54
|
import { type InstalledPlugin, installedPlugins, pluginComponentPath } from './internal/plugins.js'
|
|
55
55
|
import { isProjectApproved } from './internal/project-approval.js'
|
|
56
56
|
import { ancestorDirs, repoRoot } from './internal/project-root.js'
|
|
57
|
+
import { agentNamesIn, matchesAgentRules, matchesDomainRules, matchesSkillRules } from './internal/scope-rules.js'
|
|
57
58
|
import { claudeSettingsChain } from './internal/settings-chain.js'
|
|
58
59
|
import { createTurnOverride } from './internal/turn-override.js'
|
|
59
60
|
import { errorMessage, isDirectory } from './internal/values.js'
|
|
@@ -85,6 +86,15 @@ function substitutePathRule(rule: string, vars: Record<string, string | undefine
|
|
|
85
86
|
return rule.trimStart().startsWith('${CLAUDE_') && path.isAbsolute(substituted) ? `/${substituted}` : substituted
|
|
86
87
|
}
|
|
87
88
|
|
|
89
|
+
/** One scalar argument scope checked against one call: undefined when the tool is
|
|
90
|
+
* unscoped for this run or the call matches, otherwise a block naming the rules in
|
|
91
|
+
* force and the value that failed them. */
|
|
92
|
+
function scopeVerdict(rules: string[] | undefined, tool: string, subjectLabel: string, subject: string, matches: (rules: string[]) => boolean): { block: true; reason: string } | undefined {
|
|
93
|
+
if (!rules) return undefined
|
|
94
|
+
if (matches(rules)) return undefined
|
|
95
|
+
return { block: true, reason: `allowed-tools: ${tool} is scoped for this command.\nAllowed: ${rules.join(', ')}\n${subjectLabel}: ${subject}` }
|
|
96
|
+
}
|
|
97
|
+
|
|
88
98
|
/** The path rules that survive an allowed-tools intersection: only the tools the
|
|
89
99
|
* grant kept get their scopes, each rule ${CLAUDE_*}-substituted like the body. */
|
|
90
100
|
function scopedPathRules(pathRules: Partial<Record<PathRuleTool, string[]>>, granted: string[], vars: Record<string, string | undefined>): Partial<Record<PathRuleTool, string[]>> {
|
|
@@ -310,6 +320,12 @@ export default function commandsExtension(pi: ExtensionAPI) {
|
|
|
310
320
|
let pendingRestore: string[] | undefined
|
|
311
321
|
/** `Bash(...)` scopes enforced while that run lasts; lifted with the restriction. */
|
|
312
322
|
let pendingBashRules: string[] | undefined
|
|
323
|
+
/** `WebFetch(domain:...)` scopes enforced the same way. */
|
|
324
|
+
let pendingDomainRules: string[] | undefined
|
|
325
|
+
/** `Agent(...)`/`Task(...)` agent-name scopes enforced the same way. */
|
|
326
|
+
let pendingAgentRules: string[] | undefined
|
|
327
|
+
/** `Skill(...)` name-pattern scopes enforced the same way. */
|
|
328
|
+
let pendingSkillRules: string[] | undefined
|
|
313
329
|
/** Read/Edit path scopes enforced the same way, per pi file tool. */
|
|
314
330
|
let pendingPathRules: Partial<Record<PathRuleTool, string[]>> | undefined
|
|
315
331
|
/** The session model to restore after a command's `model:` override drove its run,
|
|
@@ -342,6 +358,9 @@ export default function commandsExtension(pi: ExtensionAPI) {
|
|
|
342
358
|
// continuation remains, which is the grant's true clearing point.
|
|
343
359
|
pi.on('agent_settled', async () => {
|
|
344
360
|
pendingBashRules = undefined
|
|
361
|
+
pendingDomainRules = undefined
|
|
362
|
+
pendingAgentRules = undefined
|
|
363
|
+
pendingSkillRules = undefined
|
|
345
364
|
pendingPathRules = undefined
|
|
346
365
|
modelOverride.settle()
|
|
347
366
|
effortOverride.settle()
|
|
@@ -354,18 +373,27 @@ export default function commandsExtension(pi: ExtensionAPI) {
|
|
|
354
373
|
// The active-tool set has no argument dimension, so a scoped grant hands the turn
|
|
355
374
|
// the whole tool; the scope is enforced here instead, when the call arrives. Same
|
|
356
375
|
// steering-not-sandbox caveat as plan mode's guard.
|
|
376
|
+
//
|
|
377
|
+
// PAIRED WITH hooks/matcher.ts's matchesToolPattern, which dispatches the same tools
|
|
378
|
+
// to the same matchers for hook `if` filters. Deliberately not merged: bash differs
|
|
379
|
+
// (here every segment of a compound command must match a rule; there the filter is
|
|
380
|
+
// best effort and errs toward running the hook). A new scoped tool must be added in
|
|
381
|
+
// both; the shared roster is ARG_RULE_TOOLS in internal/command-file.ts.
|
|
357
382
|
pi.on('tool_call', async (event, ctx) => {
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
383
|
+
const input = event.input as Record<string, unknown>
|
|
384
|
+
const text = (value: unknown): string => (typeof value === 'string' ? value : '')
|
|
385
|
+
// The four scalar-scope tools share one shape: rules in force, the part of the call
|
|
386
|
+
// they judge, and the sentence naming both when it blocks. One helper keeps the arms
|
|
387
|
+
// from drifting; only the subject and the matcher differ.
|
|
388
|
+
if (event.toolName === 'bash') return scopeVerdict(pendingBashRules, 'bash', 'Command', text(input.command), (rules) => matchesBashRules(text(input.command), rules))
|
|
389
|
+
if (event.toolName === 'web_fetch') return scopeVerdict(pendingDomainRules, 'web_fetch', 'Url', text(input.url), (rules) => matchesDomainRules(text(input.url), rules))
|
|
390
|
+
if (event.toolName === 'subagent') {
|
|
391
|
+
const names = agentNamesIn(input)
|
|
392
|
+
return scopeVerdict(pendingAgentRules, 'subagent', 'Agents', names.join(', '), (rules) => matchesAgentRules(names, rules))
|
|
365
393
|
}
|
|
394
|
+
if (event.toolName === 'slash_command') return scopeVerdict(pendingSkillRules, 'slash_command', 'Command', text(input.command), (rules) => matchesSkillRules(text(input.command), rules))
|
|
366
395
|
const rules = pendingPathRules?.[event.toolName as PathRuleTool]
|
|
367
396
|
if (!rules) return
|
|
368
|
-
const input = event.input as Record<string, unknown>
|
|
369
397
|
const filePath = typeof input.path === 'string' ? input.path : ''
|
|
370
398
|
const anchors = { cwd: ctx.cwd, projectRoot: repoRoot(ctx.cwd) ?? ctx.cwd, home: os.homedir() }
|
|
371
399
|
if (filePath && matchesPathRules(filePath, rules, anchors)) return
|
|
@@ -403,6 +431,9 @@ export default function commandsExtension(pi: ExtensionAPI) {
|
|
|
403
431
|
// the same ${CLAUDE_*} substitution as the body, so a rule can name a bundled
|
|
404
432
|
// script by its real path, as the skills docs show.
|
|
405
433
|
pendingBashRules = granted.includes('bash') ? parsed.bashRules?.map((rule) => substituteVars(rule, vars)) : undefined
|
|
434
|
+
pendingDomainRules = granted.includes('web_fetch') ? parsed.domainRules : undefined
|
|
435
|
+
pendingAgentRules = granted.includes('subagent') ? parsed.agentRules : undefined
|
|
436
|
+
pendingSkillRules = granted.includes('slash_command') ? parsed.skillRules : undefined
|
|
406
437
|
pendingPathRules = parsed.pathRules ? scopedPathRules(parsed.pathRules, granted, vars) : undefined
|
|
407
438
|
}
|
|
408
439
|
|
|
@@ -482,6 +513,9 @@ export default function commandsExtension(pi: ExtensionAPI) {
|
|
|
482
513
|
// no setActiveTools/setModel/setThinkingLevel, since the new session owns its own state.
|
|
483
514
|
pendingRestore = undefined
|
|
484
515
|
pendingBashRules = undefined
|
|
516
|
+
pendingDomainRules = undefined
|
|
517
|
+
pendingAgentRules = undefined
|
|
518
|
+
pendingSkillRules = undefined
|
|
485
519
|
pendingPathRules = undefined
|
|
486
520
|
modelOverride.reset()
|
|
487
521
|
effortOverride.reset()
|
|
@@ -943,22 +943,25 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
|
|
|
943
943
|
type: 'string',
|
|
944
944
|
})
|
|
945
945
|
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
946
|
+
/** The session-scope memory session_start loads: the user's own CLAUDE.md, plus
|
|
947
|
+
* CLAUDE.local.md and ./.claude/CLAUDE.md from an approved project. Claude:
|
|
948
|
+
* CLAUDE_CODE_DISABLE_CLAUDE_MDS "prevent[s] loading any CLAUDE.md memory files
|
|
949
|
+
* into context, including user, project, and auto memory files", so a disabled run
|
|
950
|
+
* returns empty state and skips isProjectApproved's trust prompt entirely: there is
|
|
951
|
+
* nothing left for it to gate. Extracted so session_start itself stays a thin
|
|
952
|
+
* dispatcher; before_agent_start's own excluded() check covers pi's native files,
|
|
953
|
+
* which this cannot reach since pi loads those itself. */
|
|
954
|
+
async function loadSessionMemory(ctx: ExtensionContext): Promise<{ userContext?: { path: string; content: string }; localContexts: Array<{ path: string; content: string }>; projectDotClaude?: { path: string; content: string } }> {
|
|
955
|
+
if (process.env.CLAUDE_CODE_DISABLE_CLAUDE_MDS === '1') return { localContexts: [] }
|
|
956
956
|
|
|
957
957
|
// ~/.claude/CLAUDE.md, Claude's user-scope memory. The user's own file, so no
|
|
958
958
|
// project approval is required; a missing file simply leaves it unset.
|
|
959
959
|
const userClaudeMd = path.join(claudeConfigDir(os.homedir()), 'CLAUDE.md')
|
|
960
960
|
const userContent = readContextFile(userClaudeMd)
|
|
961
|
-
|
|
961
|
+
const memory: { userContext?: { path: string; content: string }; localContexts: Array<{ path: string; content: string }>; projectDotClaude?: { path: string; content: string } } = {
|
|
962
|
+
userContext: userContent !== undefined ? { path: userClaudeMd, content: userContent } : undefined,
|
|
963
|
+
localContexts: [],
|
|
964
|
+
}
|
|
962
965
|
|
|
963
966
|
// CLAUDE.local.md is Claude Code's personal sidecar of CLAUDE.md; pi's own loader
|
|
964
967
|
// skips it. A cloned repo can ship one, so it is gated like other project config.
|
|
@@ -969,16 +972,30 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
|
|
|
969
972
|
// ride the one approval decision.
|
|
970
973
|
const candidates = ancestorFiles(ctx.cwd, 'CLAUDE.local.md')
|
|
971
974
|
const dotClaudeMd = findNearestFile(ctx.cwd, path.join('.claude', 'CLAUDE.md'))
|
|
972
|
-
if ((candidates.length
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
}
|
|
977
|
-
if (dotClaudeMd !== null) {
|
|
978
|
-
const content = readContextFile(dotClaudeMd)
|
|
979
|
-
if (content !== undefined) projectDotClaude = { path: dotClaudeMd, content }
|
|
980
|
-
}
|
|
975
|
+
if ((candidates.length === 0 && dotClaudeMd === null) || !(await isProjectApproved(ctx))) return memory
|
|
976
|
+
|
|
977
|
+
for (const candidate of candidates) {
|
|
978
|
+
const content = readContextFile(candidate)
|
|
979
|
+
if (content !== undefined) memory.localContexts.push({ path: candidate, content })
|
|
981
980
|
}
|
|
981
|
+
if (dotClaudeMd !== null) {
|
|
982
|
+
const content = readContextFile(dotClaudeMd)
|
|
983
|
+
if (content !== undefined) memory.projectDotClaude = { path: dotClaudeMd, content }
|
|
984
|
+
}
|
|
985
|
+
return memory
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
pi.on('session_start', async (_event, ctx) => {
|
|
989
|
+
// pi's ctx.reload() rebuilds extension instances, so this set never survives
|
|
990
|
+
// a reload anyway; a reload simply re-fires InstructionsLoaded once per file,
|
|
991
|
+
// which is fine, since a reload re-loads the instruction files.
|
|
992
|
+
announced.clear()
|
|
993
|
+
envCache = undefined
|
|
994
|
+
importMemo = undefined
|
|
995
|
+
const memory = await loadSessionMemory(ctx)
|
|
996
|
+
localContexts = memory.localContexts
|
|
997
|
+
userContext = memory.userContext
|
|
998
|
+
projectDotClaude = memory.projectDotClaude
|
|
982
999
|
// Read after the local-context flow so an approval it just recorded is honored.
|
|
983
1000
|
projectApproved = isProjectApprovedSilently(ctx)
|
|
984
1001
|
gatedFilesApproved = isGatedFileApproved(ctx)
|
|
@@ -1014,7 +1031,12 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
|
|
|
1014
1031
|
envCache = { cwd, managed: managedNow, excludeGlobs: readClaudeMdExcludes(claudeMdExcludeFiles(cwd, home, projectApproved), managedNow), projectRoot: repoRoot(cwd) ?? cwd }
|
|
1015
1032
|
}
|
|
1016
1033
|
const { managed, excludeGlobs, projectRoot } = envCache
|
|
1017
|
-
|
|
1034
|
+
// CLAUDE_CODE_DISABLE_CLAUDE_MDS also covers pi's own auto-discovered native context
|
|
1035
|
+
// files ("including... auto memory files"), which session_start's gate cannot reach
|
|
1036
|
+
// since pi loads them itself. Routing through the exclusion path already used for
|
|
1037
|
+
// claudeMdExcludes drops the block, strips the InstructionsLoaded event, and skips
|
|
1038
|
+
// import expansion for it, exactly as an excluded file already does.
|
|
1039
|
+
const excluded = (absPath: string): boolean => process.env.CLAUDE_CODE_DISABLE_CLAUDE_MDS === '1' || isExcludedPath(absPath, excludeGlobs, home)
|
|
1018
1040
|
|
|
1019
1041
|
// claudeMdExcludes drops an excluded file's block from the assembled prompt and
|
|
1020
1042
|
// from import expansion; surviving blocks get block-level comments stripped.
|
|
@@ -40,6 +40,7 @@ import { claudeConfigDir } from './internal/config-dir.js'
|
|
|
40
40
|
import { readManagedSettings } from './internal/managed-settings.js'
|
|
41
41
|
import { isProjectApprovedSilently } from './internal/project-approval.js'
|
|
42
42
|
import { claudeSettingsChain } from './internal/settings-chain.js'
|
|
43
|
+
import { watchSettingsFiles } from './internal/settings-watch.js'
|
|
43
44
|
import { isRecord } from './internal/values.js'
|
|
44
45
|
|
|
45
46
|
/** The `env` object of one settings scope, coerced to string values. A string is kept
|
|
@@ -145,6 +146,8 @@ function projectEnv(cwd: string, home: string): Record<string, string> {
|
|
|
145
146
|
|
|
146
147
|
export default function envSettingsExtension(pi: ExtensionAPI) {
|
|
147
148
|
const owned = new Map<string, string | undefined>()
|
|
149
|
+
/** Stops the watcher of the previous session, as the hooks extension does. */
|
|
150
|
+
let disposeWatch: () => void = () => {}
|
|
148
151
|
|
|
149
152
|
const apply = (home: string, project: Record<string, string>): void => {
|
|
150
153
|
applyEnvSettings(mergeEnvScopes(envFromSettings(readManagedSettings()), userEnv(home), project), process.env, owned)
|
|
@@ -155,6 +158,21 @@ export default function envSettingsExtension(pi: ExtensionAPI) {
|
|
|
155
158
|
apply(os.homedir(), {})
|
|
156
159
|
|
|
157
160
|
pi.on('session_start', async (_event, ctx: ExtensionContext) => {
|
|
158
|
-
|
|
161
|
+
const home = os.homedir()
|
|
162
|
+
const approved = isProjectApprovedSilently(ctx)
|
|
163
|
+
const reapply = (): void => apply(home, approved ? projectEnv(ctx.cwd, home) : {})
|
|
164
|
+
reapply()
|
|
165
|
+
// Claude: "Claude Code watches your settings files and reloads them when they change,
|
|
166
|
+
// so it applies most edits to the running session without a restart." `env` is not
|
|
167
|
+
// one of the restart-only keys (model, effortLevel/modelSettings, outputStyle), so an
|
|
168
|
+
// edit has to reach process.env now rather than at the next session. applyEnvSettings
|
|
169
|
+
// tracks what it owns, so a key removed from the file is restored, not left behind.
|
|
170
|
+
disposeWatch()
|
|
171
|
+
disposeWatch = watchSettingsFiles(claudeSettingsChain(ctx.cwd, home, approved), reapply)
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
pi.on('session_shutdown', async () => {
|
|
175
|
+
disposeWatch()
|
|
176
|
+
disposeWatch = () => {}
|
|
159
177
|
})
|
|
160
178
|
}
|
|
@@ -331,6 +331,10 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
|
|
|
331
331
|
pi.on('turn_start', async () => {
|
|
332
332
|
if (!runNeedsSnapshot) return
|
|
333
333
|
runNeedsSnapshot = false
|
|
334
|
+
// Claude: "Set to 1 to disable file checkpointing. The /rewind command will not be
|
|
335
|
+
// able to restore code changes." No snapshot means turn_end's `if (!snap) return`
|
|
336
|
+
// always fires, so no checkpoint is ever recorded.
|
|
337
|
+
if (process.env.CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING === '1') return
|
|
334
338
|
pending = await snapshot()
|
|
335
339
|
})
|
|
336
340
|
|
|
@@ -10,6 +10,14 @@
|
|
|
10
10
|
* node_modules/@earendil-works/pi-coding-agent/dist/core/tools and Claude's hooks
|
|
11
11
|
* reference (per-tool input tables, PostToolUse response shapes).
|
|
12
12
|
*
|
|
13
|
+
* NAMES and SHAPES cover different sets on purpose. Names come from
|
|
14
|
+
* internal/claude-tool-names.ts and cover every pi tool the tools reference has a
|
|
15
|
+
* counterpart for, because a hook matcher written in Claude's vocabulary has to fire.
|
|
16
|
+
* Shapes are translated only for the file and shell tools below, because those are
|
|
17
|
+
* the only tools the hooks reference gives a per-tool input table for; for the rest
|
|
18
|
+
* there is no documented Claude shape to conform to, so the pi input passes through
|
|
19
|
+
* unchanged and a hook reads the fields pi's own schema defines.
|
|
20
|
+
*
|
|
13
21
|
* Translation choices the shapes force, each documented on its function:
|
|
14
22
|
* - pi's multi-entry `edits[]` maps to Claude's single Edit with the first entry in
|
|
15
23
|
* `old_string`/`new_string` and the full array carried alongside as `edits`.
|
|
@@ -21,13 +29,7 @@
|
|
|
21
29
|
import * as os from 'node:os'
|
|
22
30
|
import * as path from 'node:path'
|
|
23
31
|
|
|
24
|
-
|
|
25
|
-
* the alias bus instead; pi tools with no Claude counterpart (ls) stay untranslated. */
|
|
26
|
-
const CLAUDE_NAMES: Record<string, string> = { bash: 'Bash', edit: 'Edit', write: 'Write', read: 'Read', grep: 'Grep', find: 'Glob' }
|
|
27
|
-
|
|
28
|
-
export function claudeToolName(piName: string): string | undefined {
|
|
29
|
-
return CLAUDE_NAMES[piName]
|
|
30
|
-
}
|
|
32
|
+
export { claudeToolName } from '../internal/claude-tool-names.js'
|
|
31
33
|
|
|
32
34
|
/** Claude file-tool paths are always absolute with `~` expanded before hooks run,
|
|
33
35
|
* so a path guard cannot be bypassed by a relative or `~` spelling of the same path. */
|
|
@@ -33,6 +33,11 @@ export interface HookCommand {
|
|
|
33
33
|
/** Claude's permission-rule filter (`"Bash(git *)"`, `"Edit(*.ts)"`): evaluated only
|
|
34
34
|
* on tool events; on any other event a hook carrying `if` never runs. */
|
|
35
35
|
if?: string
|
|
36
|
+
/** The declaring plugin's paths, exported to the hook process. Claude: "All three
|
|
37
|
+
* are exported as environment variables to hook processes and to MCP and LSP server
|
|
38
|
+
* subprocesses", so a script can read them instead of relying on inline substitution. */
|
|
39
|
+
pluginRoot?: string
|
|
40
|
+
pluginDataDir?: string
|
|
36
41
|
/** http entries: the endpoint POSTed to; `command` mirrors it for dedup and display. */
|
|
37
42
|
url?: string
|
|
38
43
|
headers?: Record<string, string>
|
|
@@ -181,7 +186,18 @@ function stampOrigin(entries: HookMatcher[], origin: string): void {
|
|
|
181
186
|
}
|
|
182
187
|
}
|
|
183
188
|
|
|
184
|
-
|
|
189
|
+
/** Stamp the declaring plugin's paths onto every hook it contributes, so the runner
|
|
190
|
+
* can export them without re-deriving which plugin a hook came from. */
|
|
191
|
+
function stampPluginPaths(entries: HookMatcher[], plugin: { root: string; dataDir: string }): void {
|
|
192
|
+
for (const entry of entries) {
|
|
193
|
+
for (const hook of entry.hooks ?? []) {
|
|
194
|
+
hook.pluginRoot = plugin.root
|
|
195
|
+
hook.pluginDataDir = plugin.dataDir
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function mergeHooksJson(config: HooksConfig, raw: string, source: string, sources?: Map<HookMatcher, string>, origin?: string, plugin?: { root: string; dataDir: string }): void {
|
|
185
201
|
let parsed: { hooks?: HooksConfig }
|
|
186
202
|
try {
|
|
187
203
|
parsed = JSON.parse(raw)
|
|
@@ -200,6 +216,7 @@ function mergeHooksJson(config: HooksConfig, raw: string, source: string, source
|
|
|
200
216
|
const usable = matchers.filter((entry) => isUsableMatcher(entry, source, event))
|
|
201
217
|
if (usable.length === 0) continue
|
|
202
218
|
if (origin !== undefined) stampOrigin(usable, origin)
|
|
219
|
+
if (plugin !== undefined) stampPluginPaths(usable, plugin)
|
|
203
220
|
config[event] = [...(config[event] ?? []), ...usable]
|
|
204
221
|
// Each parse produces fresh entry objects, so object identity keys the /hooks
|
|
205
222
|
// viewer's source attribution without touching the entries themselves.
|
|
@@ -261,13 +278,13 @@ export function loadPluginHooks(config: HooksConfig, plugins: InstalledPlugin[],
|
|
|
261
278
|
// silently registering nothing.
|
|
262
279
|
if (declared !== null && typeof declared === 'object' && !Array.isArray(declared)) {
|
|
263
280
|
const inlineSource = `${plugin.name} (plugin.json)`
|
|
264
|
-
mergeHooksJson(config, substitutePluginVars(withoutUserConfigShellCommands(JSON.stringify({ hooks: declared }), inlineSource), plugin, jsonEscape), inlineSource, sources, `plugin:${plugin.name}
|
|
281
|
+
mergeHooksJson(config, substitutePluginVars(withoutUserConfigShellCommands(JSON.stringify({ hooks: declared }), inlineSource), plugin, jsonEscape), inlineSource, sources, `plugin:${plugin.name}`, plugin)
|
|
265
282
|
continue
|
|
266
283
|
}
|
|
267
284
|
const file = pluginComponentPath(plugin, typeof declared === 'string' ? declared : path.join('hooks', 'hooks.json'))
|
|
268
285
|
if (file === undefined) continue
|
|
269
286
|
try {
|
|
270
|
-
mergeHooksJson(config, substitutePluginVars(withoutUserConfigShellCommands(fs.readFileSync(file, 'utf-8'), file), plugin, jsonEscape), file, sources, `plugin:${plugin.name}
|
|
287
|
+
mergeHooksJson(config, substitutePluginVars(withoutUserConfigShellCommands(fs.readFileSync(file, 'utf-8'), file), plugin, jsonEscape), file, sources, `plugin:${plugin.name}`, plugin)
|
|
271
288
|
} catch {
|
|
272
289
|
// a plugin without hooks contributes nothing
|
|
273
290
|
}
|
|
@@ -102,7 +102,7 @@ import { readManagedSettings } from '../internal/managed-settings.js'
|
|
|
102
102
|
import { isMcpToolAliases, MCP_TOOLS_CHANNEL } from '../internal/mcp-alias.js'
|
|
103
103
|
import { resolveModelOverride } from '../internal/model-lookup.js'
|
|
104
104
|
import { isPlanModeState, PLAN_MODE_CHANNEL } from '../internal/plan-mode-state.js'
|
|
105
|
-
import { installedPlugins } from '../internal/plugins.js'
|
|
105
|
+
import { installedPlugins, managedForceEnabled } from '../internal/plugins.js'
|
|
106
106
|
import { isProjectApproved } from '../internal/project-approval.js'
|
|
107
107
|
import { repoRoot } from '../internal/project-root.js'
|
|
108
108
|
import { watchSettingsFiles } from '../internal/settings-watch.js'
|
|
@@ -283,7 +283,7 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
283
283
|
if (hook.type === 'prompt') return runPromptHook(hook, merged, resolveHookModel(ctx, hook.model), ms)
|
|
284
284
|
if (hook.type === 'agent') return runAgentHook(hook, merged, ms, (ctx.model as { id?: string } | undefined)?.id)
|
|
285
285
|
if (hook.type === 'mcp_tool') return runMcpToolHook(hook, merged, ms)
|
|
286
|
-
return runHookCommand(hook.command, merged, ms, projectDir, hook.args, onChild, hook.shell)
|
|
286
|
+
return runHookCommand(hook.command, merged, ms, { projectDir, args: hook.args, onChild, shell: hook.shell, plugin: hook.pluginRoot !== undefined && hook.pluginDataDir !== undefined ? { root: hook.pluginRoot, dataDir: hook.pluginDataDir } : undefined, sessionId: merged.session_id as string | undefined })
|
|
287
287
|
}
|
|
288
288
|
// Claude's `once` (skill-frontmatter hooks only): removed after the first
|
|
289
289
|
// successful run; a failure, block, or timeout leaves it in place.
|
|
@@ -414,7 +414,18 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
414
414
|
// Claude's allowManagedHooksOnly: user, project, local, plugin, and skill
|
|
415
415
|
// hooks are blocked; only the managed set runs.
|
|
416
416
|
managedHooksOnly = managedSettings.allowManagedHooksOnly === true
|
|
417
|
-
|
|
417
|
+
// The escape hatch is checked FIRST, and deliberately: a settings-level
|
|
418
|
+
// disableAllHooks turns off every non-managed hook, and a plugin's hooks are
|
|
419
|
+
// non-managed however the plugin came to be enabled. Only the managed policy hooks
|
|
420
|
+
// loaded above survive it. Putting the force-enable exemption ahead of this let an
|
|
421
|
+
// administrator's enabledPlugins entry defeat the user's own escape hatch.
|
|
422
|
+
if (readSettingsDisableAllHooks(files)) return
|
|
423
|
+
if (managedHooksOnly) {
|
|
424
|
+
// Claude: "Hooks from plugins force-enabled in managed settings `enabledPlugins`
|
|
425
|
+
// are exempt." That exemption is from allowManagedHooksOnly, not from the hatch.
|
|
426
|
+
loadPluginHooks(config, managedForceEnabled(installedPlugins(os.homedir())), hookSources)
|
|
427
|
+
return
|
|
428
|
+
}
|
|
418
429
|
for (const [eventName, matchers] of Object.entries(loadHooks(files, hookSources))) config[eventName] = [...(config[eventName] ?? []), ...matchers]
|
|
419
430
|
// Plugins are user-installed and enabled by user settings (see installedPlugins),
|
|
420
431
|
// so a checked-out repo cannot toggle which code-bearing plugin hooks run.
|
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { matchesBashIfFilter } from '../internal/bash-rules.js'
|
|
8
|
+
import { CLAUDE_TOOL_MAP } from '../internal/claude-tool-names.js'
|
|
8
9
|
import { matchesPathRules, type PathAnchors } from '../internal/path-rules.js'
|
|
10
|
+
import { agentNamesIn, matchesAgentRules, matchesDomainRules, matchesSkillRules } from '../internal/scope-rules.js'
|
|
9
11
|
import { errorMessage } from '../internal/values.js'
|
|
10
12
|
import type { HookCommand, HookMatcher } from './config.js'
|
|
11
13
|
|
|
@@ -179,13 +181,39 @@ export function passesIfFilter(hook: HookCommand, target: IfFilterTarget | undef
|
|
|
179
181
|
if (!toolMatches) return false
|
|
180
182
|
const pattern = parsed[2]
|
|
181
183
|
if (pattern === undefined) return true
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
184
|
+
// Either spelling can arrive; normalize to the pi name the specifier engines take.
|
|
185
|
+
const tool = CLAUDE_TOOL_MAP[fold(target.piName)] ?? CLAUDE_TOOL_MAP[fold(target.claudeName ?? '')] ?? fold(target.piName)
|
|
186
|
+
return matchesToolPattern(tool, target.input as Record<string, unknown> | null, pattern, target.anchors)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** One `if` pattern against a call's arguments. Claude evaluates the rule "against
|
|
190
|
+
* the tool name and arguments together" in permission-rule syntax, so each tool uses
|
|
191
|
+
* the same specifier engine its permission rules use:
|
|
192
|
+
*
|
|
193
|
+
* PAIRED WITH commands.ts's tool_call guard, which dispatches the same tools to the
|
|
194
|
+
* same matchers for `allowed-tools` scopes. The two are deliberately NOT merged: bash
|
|
195
|
+
* differs on purpose (an allow scope requires every segment to match, an `if` filter is
|
|
196
|
+
* best effort and errs toward running the hook), and merging would hide that. They are
|
|
197
|
+
* two lists that must agree, so a new scoped tool has to be added in both places; the
|
|
198
|
+
* shared roster is ARG_RULE_TOOLS in internal/command-file.ts.
|
|
199
|
+
* a command pattern for Bash, a
|
|
200
|
+
* `domain:` host for WebFetch, an agent name for Agent, a skill name for Skill, and a
|
|
201
|
+
* path rule for the file tools. A tool with no specifier syntax matches nothing,
|
|
202
|
+
* which is also what an unparseable rule does. */
|
|
203
|
+
function matchesToolPattern(piName: string, input: Record<string, unknown> | null, pattern: string, anchors: PathAnchors): boolean {
|
|
204
|
+
const str = (value: unknown): string => (typeof value === 'string' ? value : '')
|
|
205
|
+
switch (piName) {
|
|
206
|
+
case 'bash':
|
|
207
|
+
return str(input?.command).length > 0 && matchesBashIfFilter(str(input?.command), pattern)
|
|
208
|
+
case 'web_fetch':
|
|
209
|
+
return matchesDomainRules(str(input?.url), [pattern])
|
|
210
|
+
case 'subagent':
|
|
211
|
+
return matchesAgentRules(agentNamesIn(input), [pattern])
|
|
212
|
+
case 'slash_command':
|
|
213
|
+
return matchesSkillRules(str(input?.command), [pattern])
|
|
214
|
+
default: {
|
|
215
|
+
const filePath = str(input?.path) || str(input?.file_path)
|
|
216
|
+
return filePath.length > 0 && matchesPathRules(filePath, [pattern], anchors)
|
|
217
|
+
}
|
|
186
218
|
}
|
|
187
|
-
let filePath = ''
|
|
188
|
-
if (typeof input?.path === 'string') filePath = input.path
|
|
189
|
-
else if (typeof input?.file_path === 'string') filePath = input.file_path
|
|
190
|
-
return filePath.length > 0 && matchesPathRules(filePath, [pattern], target.anchors)
|
|
191
219
|
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { type ChildProcess, spawn } from 'node:child_process'
|
|
8
|
+
import * as fs from 'node:fs'
|
|
8
9
|
import * as path from 'node:path'
|
|
9
10
|
import type { Api, Model } from '@earendil-works/pi-ai'
|
|
10
11
|
import { runAgent } from '../internal/agent-run.js'
|
|
@@ -44,7 +45,24 @@ export type HookRunner = (hook: HookCommand, payload: unknown, timeoutMs: number
|
|
|
44
45
|
* `args` array it becomes the exec path: `command` is spawned directly with those args.
|
|
45
46
|
* `onChild` hands the caller a kill for the spawned tree, so a background hook that is
|
|
46
47
|
* still running at session end can be reaped (Claude kills async hooks at teardown). */
|
|
47
|
-
|
|
48
|
+
/** How one command hook is spawned, beyond the payload and its budget. Grouped rather
|
|
49
|
+
* than trailing off the parameter list: the exec form, the shell choice and the
|
|
50
|
+
* declaring plugin are all per-hook fields that arrive together from one HookCommand. */
|
|
51
|
+
export interface HookSpawnOptions {
|
|
52
|
+
projectDir?: string
|
|
53
|
+
/** exec-form argv; shell-form when absent. */
|
|
54
|
+
args?: string[]
|
|
55
|
+
onChild?: (kill: () => void) => void
|
|
56
|
+
shell?: string
|
|
57
|
+
/** The declaring plugin's paths, exported to the child. */
|
|
58
|
+
plugin?: { root: string; dataDir: string }
|
|
59
|
+
/** Claude: "set automatically to the current session ID in ... hook command
|
|
60
|
+
* subprocesses ... this matches the session_id field in the hook JSON input and
|
|
61
|
+
* is updated on /clear." Absent in a stub context with no session manager. */
|
|
62
|
+
sessionId?: string
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type HookCommandRunner = (command: string, payload: unknown, timeoutMs: number, options?: HookSpawnOptions) => Promise<HookRunResult>
|
|
48
66
|
|
|
49
67
|
/** Above 2^31-1 ms Node clamps a timer to 1ms, which would kill the hook instantly. */
|
|
50
68
|
const MAX_TIMEOUT_S = 2_147_483
|
|
@@ -104,6 +122,17 @@ function killTree(child: ChildProcess): void {
|
|
|
104
122
|
child.kill('SIGKILL')
|
|
105
123
|
}
|
|
106
124
|
|
|
125
|
+
/** Create the plugin data directory the moment its path is handed to a child. Claude
|
|
126
|
+
* describes it as "created on first reference"; a best-effort mkdir, since a hook whose
|
|
127
|
+
* data dir cannot be created should still run. */
|
|
128
|
+
function ensureDir(dir: string): void {
|
|
129
|
+
try {
|
|
130
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
131
|
+
} catch {
|
|
132
|
+
// The hook runs anyway; a script that needs the directory reports its own failure.
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
107
136
|
/** The shell invocation for a shell-form command, or undefined when this machine has no
|
|
108
137
|
* shell for it (Windows with neither Git Bash nor PowerShell). */
|
|
109
138
|
function shellInvocation(command: string, shell: string | undefined): { file: string; spawnArgs: string[] } | undefined {
|
|
@@ -111,7 +140,7 @@ function shellInvocation(command: string, shell: string | undefined): { file: st
|
|
|
111
140
|
return resolved ? { file: resolved.file, spawnArgs: resolved.argsFor(command) } : undefined
|
|
112
141
|
}
|
|
113
142
|
|
|
114
|
-
export const runHookCommand: HookCommandRunner = (command, payload, timeoutMs, projectDir, args, onChild, shell) =>
|
|
143
|
+
export const runHookCommand: HookCommandRunner = (command, payload, timeoutMs, { projectDir, args, onChild, shell, plugin, sessionId } = {}) =>
|
|
115
144
|
new Promise((resolve) => {
|
|
116
145
|
// /bin/sh by absolute path off Windows, so the shell can't be resolved through an
|
|
117
146
|
// attacker-controlled PATH; on Windows the resolver follows Claude's documented Git
|
|
@@ -125,6 +154,20 @@ export const runHookCommand: HookCommandRunner = (command, payload, timeoutMs, p
|
|
|
125
154
|
// detection cannot see the captured terminal.
|
|
126
155
|
const env: NodeJS.ProcessEnv = { ...process.env, CLAUDECODE: '1', CLAUDE_CODE_CHILD_SESSION: '1' }
|
|
127
156
|
if (projectDir) env.CLAUDE_PROJECT_DIR = projectDir
|
|
157
|
+
// Claude: "Claude Code sets this to its own process ID in the subprocesses it
|
|
158
|
+
// spawns: Bash and PowerShell tool commands and hook commands." Set unconditionally,
|
|
159
|
+
// since every hook child qualifies.
|
|
160
|
+
env.CLAUDE_PID = String(process.pid)
|
|
161
|
+
if (sessionId) env.CLAUDE_CODE_SESSION_ID = sessionId
|
|
162
|
+
// Claude: "All three are exported as environment variables to hook processes and to
|
|
163
|
+
// MCP and LSP server subprocesses", so a plugin script can read them rather than
|
|
164
|
+
// depend on inline substitution. The data directory is "created on first reference",
|
|
165
|
+
// and exporting the path is that reference: a script should not have to mkdir it.
|
|
166
|
+
if (plugin) {
|
|
167
|
+
env.CLAUDE_PLUGIN_ROOT = plugin.root
|
|
168
|
+
env.CLAUDE_PLUGIN_DATA = plugin.dataDir
|
|
169
|
+
ensureDir(plugin.dataDir)
|
|
170
|
+
}
|
|
128
171
|
if (process.stdout.columns) env.COLUMNS = String(process.stdout.columns)
|
|
129
172
|
if (process.stdout.rows) env.LINES = String(process.stdout.rows)
|
|
130
173
|
// An exec-form hook (an `args` array) spawns the executable directly with those args
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place pi tool names and Claude tool names are paired.
|
|
3
|
+
*
|
|
4
|
+
* Two independent tables used to encode this: a Claude->pi map for resolving
|
|
5
|
+
* `allowed-tools` entries, and a pi->Claude map for hook payloads and matchers.
|
|
6
|
+
* They disagreed. The second one listed six tools, so a hook matcher written in
|
|
7
|
+
* Claude's vocabulary never fired for `web_fetch`, `subagent`, `question`, `todo`,
|
|
8
|
+
* `slash_command`, `web_search` or `plan_mode_complete`, even though the first table
|
|
9
|
+
* had known those pairings all along. Both directions now derive from this list, so
|
|
10
|
+
* a tool cannot be nameable in one direction and invisible in the other.
|
|
11
|
+
*
|
|
12
|
+
* Canonical names are the tools reference's own spellings. `claude: null` marks a pi
|
|
13
|
+
* tool the reference has no counterpart for: it stays untranslated in payloads, and
|
|
14
|
+
* no Claude name resolves to it beyond its own spelling.
|
|
15
|
+
*
|
|
16
|
+
* Input SHAPES are a separate question and deliberately not driven from here. The
|
|
17
|
+
* hooks reference documents per-tool input tables only for the file and shell tools,
|
|
18
|
+
* so those are the only ones claude-tools.ts translates; for the rest there is no
|
|
19
|
+
* documented Claude shape to conform to and the pi input is passed through.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
interface ToolNamePair {
|
|
23
|
+
/** Canonical Claude tool name, or null when the reference has no such tool. */
|
|
24
|
+
claude: string | null
|
|
25
|
+
/** pi's registered tool name. */
|
|
26
|
+
pi: string
|
|
27
|
+
/** Further Claude spellings accepted when reading a grant: renamed tools and
|
|
28
|
+
* secondary tools that share one pi implementation. Never used for payloads. */
|
|
29
|
+
aliases?: string[]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const TOOL_NAMES: ToolNamePair[] = [
|
|
33
|
+
{ claude: 'Read', pi: 'read' },
|
|
34
|
+
{ claude: 'Write', pi: 'write' },
|
|
35
|
+
{ claude: 'Edit', pi: 'edit' },
|
|
36
|
+
{ claude: 'Bash', pi: 'bash' },
|
|
37
|
+
{ claude: 'Grep', pi: 'grep' },
|
|
38
|
+
{ claude: 'Glob', pi: 'find' },
|
|
39
|
+
{ claude: 'WebFetch', pi: 'web_fetch' },
|
|
40
|
+
{ claude: 'WebSearch', pi: 'web_search' },
|
|
41
|
+
// `Task` was renamed `Agent`; both spellings resolve, the payload reports `Agent`.
|
|
42
|
+
{ claude: 'Agent', pi: 'subagent', aliases: ['Task'] },
|
|
43
|
+
{ claude: 'AskUserQuestion', pi: 'question' },
|
|
44
|
+
{ claude: 'ExitPlanMode', pi: 'plan_mode_complete' },
|
|
45
|
+
// Custom commands were folded into skills and the reference no longer lists a
|
|
46
|
+
// SlashCommand tool, so `Skill` is canonical and `SlashCommand` is the legacy name.
|
|
47
|
+
{ claude: 'Skill', pi: 'slash_command', aliases: ['SlashCommand'] },
|
|
48
|
+
// pi's single todo tool serves Claude's whole todo/task-list family. Claude now
|
|
49
|
+
// prefers TaskCreate/TaskGet/TaskList/TaskUpdate over TodoWrite/TodoRead; every
|
|
50
|
+
// spelling resolves so an allowed-tools entry naming the current tools still grants
|
|
51
|
+
// something, and TodoWrite stays the name a payload reports.
|
|
52
|
+
{ claude: 'TodoWrite', pi: 'todo', aliases: ['TodoRead', 'TaskCreate', 'TaskGet', 'TaskList', 'TaskUpdate'] },
|
|
53
|
+
// pi tools the tools reference has no entry for.
|
|
54
|
+
{ claude: null, pi: 'ls', aliases: ['LS'] },
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
const fold = (name: string): string => name.trim().toLowerCase()
|
|
58
|
+
|
|
59
|
+
/** Every Claude spelling -> pi tool name, for reading a grant list. Includes each
|
|
60
|
+
* pi name itself so a list written in pi's own vocabulary still resolves. */
|
|
61
|
+
export const CLAUDE_TOOL_MAP: Record<string, string> = Object.fromEntries(TOOL_NAMES.flatMap(({ claude, pi, aliases }) => [...(claude === null ? [] : [[fold(claude), pi]]), ...(aliases ?? []).map((alias) => [fold(alias), pi]), [fold(pi), pi]]))
|
|
62
|
+
|
|
63
|
+
const PI_TO_CLAUDE: Record<string, string> = Object.fromEntries(TOOL_NAMES.filter((pair): pair is ToolNamePair & { claude: string } => pair.claude !== null).map(({ claude, pi }) => [pi, claude]))
|
|
64
|
+
|
|
65
|
+
/** pi built-in -> canonical Claude tool name for hook payloads and matchers, or
|
|
66
|
+
* undefined for a pi tool the reference has no counterpart for. MCP tools ride the
|
|
67
|
+
* alias bus instead and never reach here. */
|
|
68
|
+
export function claudeToolName(piName: string): string | undefined {
|
|
69
|
+
return PI_TO_CLAUDE[piName]
|
|
70
|
+
}
|