pi-code 1.0.56 → 1.0.58
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/extensions/claude-rules.ts +3 -4
- package/extensions/commands.ts +6 -12
- package/extensions/context-imports.ts +250 -22
- package/extensions/env-settings.ts +1 -5
- package/extensions/git-checkpoint.ts +4 -12
- package/extensions/goal.ts +2 -2
- package/extensions/hooks/config.ts +6 -21
- package/extensions/hooks/decisions.ts +3 -2
- package/extensions/hooks/index.ts +4 -16
- package/extensions/hooks/matcher.ts +2 -1
- package/extensions/hooks/runners.ts +4 -3
- package/extensions/internal/command-file.ts +7 -241
- package/extensions/internal/command-spans.ts +246 -0
- package/extensions/internal/managed-settings.ts +3 -5
- package/extensions/internal/plugins.ts +2 -2
- package/extensions/internal/project-approval.ts +14 -0
- package/extensions/internal/settings-chain.ts +19 -0
- package/extensions/internal/tool-target.ts +23 -0
- package/extensions/internal/values.ts +38 -0
- package/extensions/mcp/index.ts +6 -5
- package/extensions/mcp/listing.ts +2 -1
- package/extensions/mcp/oauth-flow.ts +2 -1
- package/extensions/mcp/policy.ts +9 -2
- package/extensions/memory.ts +10 -15
- package/extensions/output-styles.ts +4 -17
- package/extensions/plan-mode/index.ts +9 -9
- package/extensions/plan-mode/utils.ts +31 -0
- package/extensions/session-title.ts +2 -12
- package/extensions/skills.ts +6 -18
- package/extensions/status-line.ts +2 -8
- package/extensions/subagent/README.md +12 -2
- package/extensions/subagent/agents.ts +2 -2
- package/extensions/subagent/background.ts +2 -1
- package/extensions/subagent/child.ts +197 -0
- package/extensions/subagent/concurrency.ts +23 -0
- package/extensions/subagent/index.ts +36 -1426
- package/extensions/subagent/modes.ts +405 -0
- package/extensions/subagent/params.ts +56 -0
- package/extensions/subagent/registry-text.ts +105 -0
- package/extensions/subagent/render-result.ts +306 -0
- package/extensions/subagent/run.ts +375 -0
- package/extensions/subagent/types.ts +41 -0
- package/extensions/subagent/worktree.ts +2 -1
- package/package.json +1 -1
|
@@ -109,6 +109,7 @@ import { watchSettingsFiles } from '../internal/settings-watch.js'
|
|
|
109
109
|
import { isSkillHooksEvent, SKILL_HOOKS_CHANNEL } from '../internal/skill-hooks.js'
|
|
110
110
|
import { isSubagentPhaseEvent, SUBAGENT_CHANNEL } from '../internal/subagent-events.js'
|
|
111
111
|
import { setSubagentStartHookRunner } from '../internal/subagent-hooks.js'
|
|
112
|
+
import { contentText } from '../internal/values.js'
|
|
112
113
|
import { claudeToolInput, claudeToolName, claudeToolResponse, piToolOutput } from './claude-tools.js'
|
|
113
114
|
import { formatHooksSummary, type HookCommand, type HookMatcher, type HooksConfig, hookFiles, isBackgroundHook, loadHooks, loadManagedHooks, loadPluginHooks, mergeAgentEnvHooks, mergeSkillHooks, readAllowedHttpHookUrls, readDisableAllHooks, readSettingsDisableAllHooks } from './config.js'
|
|
114
115
|
import { blockedToolCall, jsonBlockVerdict, postToolFeedback, promptContext, runPreToolUse, runUserPromptSubmit, surfaceSystemMessages, tryParseJson } from './decisions.js'
|
|
@@ -127,12 +128,7 @@ export function lastAssistantText(messages: ReadonlyArray<{ role: string; conten
|
|
|
127
128
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
128
129
|
const message = messages[i]
|
|
129
130
|
if (message.role !== 'assistant') continue
|
|
130
|
-
|
|
131
|
-
if (!Array.isArray(message.content)) return ''
|
|
132
|
-
return message.content
|
|
133
|
-
.filter((part): part is { type: 'text'; text: string } => typeof part === 'object' && part !== null && (part as { type?: unknown }).type === 'text')
|
|
134
|
-
.map((part) => part.text)
|
|
135
|
-
.join('')
|
|
131
|
+
return contentText(message.content)
|
|
136
132
|
}
|
|
137
133
|
return ''
|
|
138
134
|
}
|
|
@@ -157,14 +153,6 @@ async function runNotifyHooks(commands: HookCommand[], payload: unknown, runner:
|
|
|
157
153
|
return await Promise.all(runnable.map((command) => runner(command, payload, timeoutMs(command))))
|
|
158
154
|
}
|
|
159
155
|
|
|
160
|
-
/** The text of a result's content blocks, for Claude-shaped tool_response fields. */
|
|
161
|
-
function textContent(content: ReadonlyArray<{ type: string; text?: string }>): string {
|
|
162
|
-
return content
|
|
163
|
-
.filter((block) => block.type === 'text' && typeof block.text === 'string')
|
|
164
|
-
.map((block) => block.text)
|
|
165
|
-
.join('\n')
|
|
166
|
-
}
|
|
167
|
-
|
|
168
156
|
/** pi's lifecycle vocabularies differ from Claude's documented ones. The matcher is
|
|
169
157
|
* offered both spellings so existing configs keep firing either way, and the payload
|
|
170
158
|
* reports the Claude value, which is what a Claude-written hook script parses. */
|
|
@@ -531,13 +519,13 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
531
519
|
const commands = matchingCommands(event.isError ? config.PostToolUseFailure : config.PostToolUse, names).filter((command) => passesIfFilter(command, target))
|
|
532
520
|
if (commands.length === 0 && pending.length === 0) return
|
|
533
521
|
const translatedInput = alias === undefined ? claudeToolInput(event.toolName, event.input, ctx.cwd) : undefined
|
|
534
|
-
const response = (alias === undefined && !event.isError ? claudeToolResponse(event.toolName, event.input,
|
|
522
|
+
const response = (alias === undefined && !event.isError ? claudeToolResponse(event.toolName, event.input, contentText(event.content, '\n'), event.isError, ctx.cwd) : undefined) ?? { content: event.content, details: event.details, isError: event.isError }
|
|
535
523
|
const startedAt = toolStartTimes.get(event.toolCallId)
|
|
536
524
|
toolStartTimes.delete(event.toolCallId)
|
|
537
525
|
// Claude delivers a failure as top-level fields rather than a tool_response: "error
|
|
538
526
|
// information as top-level fields ... error ... is_interrupt". is_interrupt is false
|
|
539
527
|
// here because pi reports a cancelled tool through the result, not this event.
|
|
540
|
-
const failure = event.isError ? { error:
|
|
528
|
+
const failure = event.isError ? { error: contentText(event.content, '\n'), is_interrupt: false } : { tool_response: response }
|
|
541
529
|
const payload = { hook_event_name: eventName, tool_name: translatedName ?? event.toolName, tool_input: translatedInput ?? event.input, ...failure, ...(startedAt === undefined ? {} : { duration_ms: Date.now() - startedAt }) }
|
|
542
530
|
const run = boundRunner(ctx, { tool_use_id: event.toolCallId })
|
|
543
531
|
const results = await Promise.all(commands.map((command) => run(command, payload, timeoutMs(command))))
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { matchesBashIfFilter } from '../internal/bash-rules.js'
|
|
8
8
|
import { matchesPathRules, type PathAnchors } from '../internal/path-rules.js'
|
|
9
|
+
import { errorMessage } from '../internal/values.js'
|
|
9
10
|
import type { HookCommand, HookMatcher } from './config.js'
|
|
10
11
|
|
|
11
12
|
/** Claude's rule: a matcher of only letters, digits, `_`, `-`, spaces, `,` and `|`
|
|
@@ -62,7 +63,7 @@ function compileMatcher(matcher: string): CompiledMatcher {
|
|
|
62
63
|
} catch (error) {
|
|
63
64
|
// The fallback matches the literal text, which almost never matches a tool name, so
|
|
64
65
|
// the hook simply never fires. Say so: the matcher reads as merely wrong otherwise.
|
|
65
|
-
console.warn(`pi-code-hooks: matcher ${matcher} is not a valid regular expression (${
|
|
66
|
+
console.warn(`pi-code-hooks: matcher ${matcher} is not a valid regular expression (${errorMessage(error)}); it will only match a tool of that exact name`)
|
|
66
67
|
compiled = { tokens: exactTokens(matcher) }
|
|
67
68
|
}
|
|
68
69
|
}
|
|
@@ -11,6 +11,7 @@ import { runAgent } from '../internal/agent-run.js'
|
|
|
11
11
|
import { callMcpTool } from '../internal/mcp-call.js'
|
|
12
12
|
import { completeText } from '../internal/model-complete.js'
|
|
13
13
|
import { resolveShell } from '../internal/shell-resolve.js'
|
|
14
|
+
import { errorMessage } from '../internal/values.js'
|
|
14
15
|
import { type HookCommand, httpUrlAllowed, isBackgroundHook } from './config.js'
|
|
15
16
|
|
|
16
17
|
// Claude's defaults vary by type and event (600s for command/http/mcp_tool, 30s
|
|
@@ -228,7 +229,7 @@ export async function runHttpHook(hook: { type?: string; command: string; url?:
|
|
|
228
229
|
// event fails closed on it, exactly as a command hook that ran out of time does: the
|
|
229
230
|
// hook never answered, whichever transport it used.
|
|
230
231
|
const name = error instanceof Error ? error.name : ''
|
|
231
|
-
return { code: 1, stdout: '', stderr:
|
|
232
|
+
return { code: 1, stdout: '', stderr: errorMessage(error), timedOut: name === 'TimeoutError' || name === 'AbortError' }
|
|
232
233
|
}
|
|
233
234
|
}
|
|
234
235
|
|
|
@@ -262,7 +263,7 @@ function substituteArguments(prompt: string | undefined, payload: unknown): stri
|
|
|
262
263
|
* produced no verdict and is non-blocking. */
|
|
263
264
|
function abortAwareFailure(signal: AbortSignal, error: unknown): HookRunResult {
|
|
264
265
|
const aborted = signal.aborted || (error instanceof Error && (error.name === 'AbortError' || error.name === 'TimeoutError'))
|
|
265
|
-
return { code: aborted ? TIMEOUT_EXIT_CODE : 1, stdout: '', stderr:
|
|
266
|
+
return { code: aborted ? TIMEOUT_EXIT_CODE : 1, stdout: '', stderr: errorMessage(error), timedOut: aborted }
|
|
266
267
|
}
|
|
267
268
|
|
|
268
269
|
export async function runPromptHook(hook: HookCommand, payload: unknown, model: Model<Api> | undefined, timeoutMs: number): Promise<HookRunResult> {
|
|
@@ -328,7 +329,7 @@ export async function runMcpToolHook(hook: HookCommand, payload: unknown, timeou
|
|
|
328
329
|
})
|
|
329
330
|
const call = callMcpTool(hook.server, hook.tool, input)
|
|
330
331
|
.then((result): HookRunResult => ({ code: result.isError ? 1 : 0, stdout: result.text, stderr: '', timedOut: false }))
|
|
331
|
-
.catch((error): HookRunResult => ({ code: 1, stdout: '', stderr:
|
|
332
|
+
.catch((error): HookRunResult => ({ code: 1, stdout: '', stderr: errorMessage(error), timedOut: false }))
|
|
332
333
|
try {
|
|
333
334
|
return await Promise.race([call, deadline])
|
|
334
335
|
} finally {
|
|
@@ -2,9 +2,13 @@
|
|
|
2
2
|
* Parsing and discovery for Claude Code slash-command files.
|
|
3
3
|
*
|
|
4
4
|
* pi's own prompt-template loader reads only `description` and `argument-hint`
|
|
5
|
-
* from one flat directory, so the rest of Claude's command contract
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* from one flat directory, so the rest of Claude's command contract lives here and is
|
|
6
|
+
* applied by commands.ts when it registers each command itself: namespaced
|
|
7
|
+
* subdirectories, `allowed-tools`, `model`, and the argument substitutions.
|
|
8
|
+
*
|
|
9
|
+
* Running what a body carries, the `!` bash blocks and `@file` references, is the other
|
|
10
|
+
* half and lives in command-spans.ts; skills.ts and the subagent loader import this file
|
|
11
|
+
* alone and never pull a shell resolver in.
|
|
8
12
|
*
|
|
9
13
|
* Docs: https://code.claude.com/docs/en/slash-commands.md
|
|
10
14
|
*/
|
|
@@ -13,8 +17,6 @@ import * as fs from 'node:fs'
|
|
|
13
17
|
import * as path from 'node:path'
|
|
14
18
|
|
|
15
19
|
import { parseFrontmatter } from '@earendil-works/pi-coding-agent'
|
|
16
|
-
import { splitSegments } from './shell-split.js'
|
|
17
|
-
import { type Fence, fenceMarker, stepFence } from './strip-comments.js'
|
|
18
20
|
|
|
19
21
|
/** The pi file tools a Claude path rule can govern. */
|
|
20
22
|
export type PathRuleTool = 'read' | 'edit' | 'write'
|
|
@@ -427,239 +429,3 @@ export function discoverCommandFiles(root: string): DiscoveredCommand[] {
|
|
|
427
429
|
walk(root, '')
|
|
428
430
|
return found
|
|
429
431
|
}
|
|
430
|
-
|
|
431
|
-
export type CommandExec = (command: string) => Promise<{ stdout: string; stderr: string; code: number; killed?: boolean }>
|
|
432
|
-
|
|
433
|
-
/** PowerShell single-quote escaping: inside a '...' literal the only special
|
|
434
|
-
* characters are the quote delimiters themselves, written doubled. PowerShell's
|
|
435
|
-
* lexer treats U+2018 through U+201B as single quotes too, so each is doubled the
|
|
436
|
-
* same way; leaving them bare let a projectDir like `Alex’s Projects` end the
|
|
437
|
-
* literal mid-path with a ParserError. sh's '\'' form must not be used here,
|
|
438
|
-
* since PowerShell would keep the backslash and reopen the string. */
|
|
439
|
-
export function powershellQuote(value: string): string {
|
|
440
|
-
return value.replaceAll(/['‘’‚‛]/g, '$&$&')
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
import { bashBinary } from './shell-resolve.js'
|
|
444
|
-
|
|
445
|
-
export { resolvePowershellBinary } from './shell-resolve.js'
|
|
446
|
-
|
|
447
|
-
export interface SpanExec {
|
|
448
|
-
command: string
|
|
449
|
-
args: string[]
|
|
450
|
-
/** Set when the shell cannot merge stderr into stdout in-script (pwsh 7 drops a
|
|
451
|
-
* native command's stderr from `& { } 2>&1`), asking the caller to append the
|
|
452
|
-
* exec result's stderr to its stdout instead. The sh path merges in-script and
|
|
453
|
-
* leaves this unset. */
|
|
454
|
-
mergeStreams?: boolean
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
/** The sh invocation for a span: CLAUDE_PROJECT_DIR and CLAUDECODE=1 exported in-script
|
|
458
|
-
* (pi.exec takes no env; CLAUDECODE marks every subprocess Claude spawns), stderr merged
|
|
459
|
-
* with 2>&1. The group opens with a `:` null command: `{ }` around an empty or
|
|
460
|
-
* comment-only span is a hard sh syntax error (exit 2) that aborted the whole
|
|
461
|
-
* invocation, and `:` keeps such a span the harmless no-op it was on HEAD while the
|
|
462
|
-
* group still merges stderr for real spans. */
|
|
463
|
-
function shSpan(binary: string, projectDir: string, script: string): SpanExec {
|
|
464
|
-
const quoted = projectDir.replaceAll("'", String.raw`'\''`)
|
|
465
|
-
return { command: binary, args: ['-c', `export CLAUDE_PROJECT_DIR='${quoted}'\nexport CLAUDECODE=1\n{ :\n${script}\n} 2>&1`] }
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
/** The PowerShell invocation for a span. No in-script 2>&1: under pwsh 7 it does not
|
|
469
|
-
* merge a native command's stderr on a script block, so mergeStreams has the caller
|
|
470
|
-
* append it. The trailing exit forwards a failed native command's code, which pwsh
|
|
471
|
-
* -Command otherwise swallows (the process exited 0 and a failure never aborted the
|
|
472
|
-
* invocation). An empty or cmdlet-only span leaves $LASTEXITCODE unset and exits 0.
|
|
473
|
-
* Residual gap vs sh: a failing cmdlet sets no exit code, so it cannot abort; its
|
|
474
|
-
* error text still reaches the model through the merged stderr. */
|
|
475
|
-
function powershellSpan(binary: string, projectDir: string, script: string): SpanExec {
|
|
476
|
-
const preamble = `$ErrorActionPreference='Continue'\n$env:CLAUDE_PROJECT_DIR='${powershellQuote(projectDir)}'\n$env:CLAUDECODE='1'`
|
|
477
|
-
return { command: binary, args: ['-NoProfile', '-NonInteractive', '-Command', `${preamble}\n& {\n${script}\n}\nexit $LASTEXITCODE`], mergeStreams: true }
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
/**
|
|
481
|
-
* The exec invocation for one injected span, honoring the `shell:` frontmatter per
|
|
482
|
-
* Claude's shell matrix (skills.md). `powershell` runs through a PowerShell binary when
|
|
483
|
-
* one resolves. Otherwise the span runs through bash: /bin/sh off Windows, Git Bash on
|
|
484
|
-
* Windows. Without Git Bash, a skill that declared `shell: bash` fails before any
|
|
485
|
-
* command runs ("requires bash"), an undeclared one falls to PowerShell, and with
|
|
486
|
-
* neither shell the invocation fails. Both paths export CLAUDE_PROJECT_DIR (each
|
|
487
|
-
* shell's own quoting) and merge stderr into stdout, as the Bash tool does when it
|
|
488
|
-
* runs these for Claude: the sh script in-line with 2>&1, the pwsh path via
|
|
489
|
-
* mergeStreams in the caller.
|
|
490
|
-
*
|
|
491
|
-
* The resolvers are parameters so a caller (or test) controls the lookups: the
|
|
492
|
-
* PowerShell one is passed as an imported binding, the bash one defaults to the
|
|
493
|
-
* platform rule.
|
|
494
|
-
*/
|
|
495
|
-
export function spanExec(shell: string | undefined, projectDir: string, script: string, resolveBinary: () => string | undefined, resolveBash: () => string | undefined = bashBinary): SpanExec {
|
|
496
|
-
if (shell === 'powershell') {
|
|
497
|
-
const binary = resolveBinary()
|
|
498
|
-
if (binary !== undefined) return powershellSpan(binary, projectDir, script)
|
|
499
|
-
}
|
|
500
|
-
const bash = resolveBash()
|
|
501
|
-
if (bash !== undefined) return shSpan(bash, projectDir, script)
|
|
502
|
-
if (shell === 'bash') throw new Error('shell: bash requires Git Bash, which was not found (install Git for Windows or set CLAUDE_CODE_GIT_BASH_PATH)')
|
|
503
|
-
const binary = resolveBinary()
|
|
504
|
-
if (binary !== undefined) return powershellSpan(binary, projectDir, script)
|
|
505
|
-
throw new Error('no shell found for the injected commands: install Git for Windows or PowerShell')
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
interface FenceBlock {
|
|
509
|
-
start: number
|
|
510
|
-
end: number
|
|
511
|
-
/** A fence opened with ```! runs its content as one script; any other fence protects. */
|
|
512
|
-
exec: boolean
|
|
513
|
-
content: string
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
/** Fenced blocks of a body: Claude's dynamic syntax is literal text inside a plain
|
|
517
|
-
* fence, while a ```! fence is itself a placeholder that executes. Fences follow
|
|
518
|
-
* CommonMark: any indentation, closed only by the opener's character in a run at
|
|
519
|
-
* least as long, so a tilde line or a shorter fence inside stays content. */
|
|
520
|
-
function fenceBlocks(body: string): FenceBlock[] {
|
|
521
|
-
const blocks: FenceBlock[] = []
|
|
522
|
-
let fence: Fence | null = null
|
|
523
|
-
let open: { index: number; exec: boolean; contentStart: number } | undefined
|
|
524
|
-
let offset = 0
|
|
525
|
-
for (const line of body.split('\n')) {
|
|
526
|
-
const trimmed = line.trimStart()
|
|
527
|
-
const step = stepFence(fence, trimmed, fenceMarker(trimmed))
|
|
528
|
-
const lineEnd = offset + line.length
|
|
529
|
-
if (fence === null && step.fence !== null) {
|
|
530
|
-
// Only the exact, unindented ```! opener executes, as Claude documents it.
|
|
531
|
-
open = { index: offset, exec: line.startsWith('```') && step.fence.length === 3 && trimmed.slice(3).trim() === '!', contentStart: lineEnd + 1 }
|
|
532
|
-
} else if (fence !== null && step.fence === null && open !== undefined) {
|
|
533
|
-
blocks.push({ start: open.index, end: lineEnd, exec: open.exec, content: body.slice(Math.min(open.contentStart, offset), offset).replace(/\n$/, '') })
|
|
534
|
-
open = undefined
|
|
535
|
-
}
|
|
536
|
-
fence = step.fence
|
|
537
|
-
offset = lineEnd + 1
|
|
538
|
-
}
|
|
539
|
-
// An unterminated fence protects to the end of the body rather than executing.
|
|
540
|
-
if (open !== undefined) blocks.push({ start: open.index, end: body.length, exec: false, content: '' })
|
|
541
|
-
return blocks
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
/** Exit 1 is a normal result for Claude's documented search and comparison commands
|
|
545
|
-
* (no matches, files differ); exit 2 and up fails even for these. The PowerShell
|
|
546
|
-
* shell uses a different set, which "includes grep and git diff but not find or
|
|
547
|
-
* diff" (test/[ are bash builtins and do not apply there either). */
|
|
548
|
-
const EXIT_ONE_OK = new Set(['grep', 'rg', 'egrep', 'fgrep', 'find', 'diff', 'test', '['])
|
|
549
|
-
const EXIT_ONE_OK_POWERSHELL = new Set(['grep', 'rg', 'egrep', 'fgrep'])
|
|
550
|
-
|
|
551
|
-
export type SpanShell = 'bash' | 'powershell'
|
|
552
|
-
|
|
553
|
-
const isCarveoutSegment = (segment: string, shell: SpanShell): boolean => {
|
|
554
|
-
const words = segment.trim().split(/\s+/)
|
|
555
|
-
if (words[0] === 'git') return words[1] === 'diff' || words[1] === 'grep'
|
|
556
|
-
return (shell === 'powershell' ? EXIT_ONE_OK_POWERSHELL : EXIT_ONE_OK).has(words[0])
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
export function benignExitOne(command: string, shell: SpanShell = 'bash'): boolean {
|
|
560
|
-
const segments = splitSegments(command)
|
|
561
|
-
if (segments.length === 0) return false
|
|
562
|
-
// A `&&`/`||` chain can short-circuit, so an earlier segment's exit 1 becomes the
|
|
563
|
-
// result and the last segment is not the one that set the code: `cd nope && grep x`
|
|
564
|
-
// exits 1 from cd, not a benign grep miss. Only when every segment is a carveout is
|
|
565
|
-
// the exit benign whichever ran last. Without short-circuit operators the exit is
|
|
566
|
-
// the last segment's (a `|` pipeline exits with its final command, `;`/newline with
|
|
567
|
-
// the last statement), so the last segment decides.
|
|
568
|
-
if (/&&|\|\|/.test(command)) return segments.every((segment) => isCarveoutSegment(segment, shell))
|
|
569
|
-
return isCarveoutSegment(segments.at(-1) ?? '', shell)
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
/** Run one injected span. A failure aborts the whole invocation, as Claude
|
|
573
|
-
* documents: the model never sees a half-expanded body. */
|
|
574
|
-
async function runSpan(exec: CommandExec, command: string, pattern: string, shell: SpanShell): Promise<string> {
|
|
575
|
-
const result = await exec(command)
|
|
576
|
-
// A timeout kill arrives as killed:true with code 0 (a signal death has no exit code),
|
|
577
|
-
// so the code alone would paste the partial output as a success. Claude kills a span
|
|
578
|
-
// at the Bash timeout and that failure aborts the invocation.
|
|
579
|
-
if (result.killed) throw new Error(`Shell command timed out for pattern "${pattern}"`)
|
|
580
|
-
if (result.code !== 0 && !(result.code === 1 && benignExitOne(command, shell))) {
|
|
581
|
-
throw new Error(`Shell command failed for pattern "${pattern}"\n[stderr]\n${(result.stderr || result.stdout).trim()}`)
|
|
582
|
-
}
|
|
583
|
-
return result.stdout.trimEnd()
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
const inRanges = (ranges: Array<[number, number]>, index: number): boolean => ranges.some(([start, end]) => index >= start && index < end)
|
|
587
|
-
|
|
588
|
-
/** Read a `@path` reference, confined to the working directory. Returns undefined
|
|
589
|
-
* when the path escapes it or cannot be read, so the reference stays literal. */
|
|
590
|
-
function readReference(cwd: string, reference: string): string | undefined {
|
|
591
|
-
try {
|
|
592
|
-
// Both sides canonicalised: on macOS /var is itself a symlink, so comparing a
|
|
593
|
-
// resolved path against an unresolved root rejects every legitimate read.
|
|
594
|
-
const root = fs.realpathSync(cwd)
|
|
595
|
-
// Confinement is checked after symlinks resolve: a lexical check passes a link
|
|
596
|
-
// that points outside the project, and the read would follow it.
|
|
597
|
-
const real = fs.realpathSync(path.resolve(cwd, reference))
|
|
598
|
-
if (real !== root && !real.startsWith(root + path.sep)) return undefined
|
|
599
|
-
if (!fs.statSync(real).isFile()) return undefined
|
|
600
|
-
return fs.readFileSync(real, 'utf-8')
|
|
601
|
-
} catch {
|
|
602
|
-
return undefined
|
|
603
|
-
}
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
interface DynamicSpan {
|
|
607
|
-
start: number
|
|
608
|
-
end: number
|
|
609
|
-
run: () => Promise<string>
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
/** Claude's dynamic command content: `` !`cmd` `` runs a shell command and pastes
|
|
613
|
-
* its output (recognized only at a word start), a ```! fenced block runs its lines
|
|
614
|
-
* as one script, and `@path` inlines a file. Inline spans and `@` refs are skipped
|
|
615
|
-
* inside plain fenced code blocks. A failed command rejects, aborting the
|
|
616
|
-
* invocation, per the skills docs.
|
|
617
|
-
*
|
|
618
|
-
* Every placeholder is located in the ORIGINAL body and the whole body is expanded
|
|
619
|
-
* in one pass, so a command's output (or a file's content) is inserted verbatim and
|
|
620
|
-
* never re-scanned for further placeholders. Re-scanning was both a parity break
|
|
621
|
-
* (Claude expands once) and a command-injection path: output of a `` ```! `` block
|
|
622
|
-
* such as a commit message could smuggle its own `` !`cmd` `` for a later pass. */
|
|
623
|
-
export async function expandDynamicContent(body: string, cwd: string, exec: CommandExec, shell: SpanShell = 'bash'): Promise<string> {
|
|
624
|
-
const blocks = fenceBlocks(body)
|
|
625
|
-
const protectedRanges = blocks.filter((block) => !block.exec).map((block): [number, number] => [block.start, block.end])
|
|
626
|
-
const execRanges = blocks.filter((block) => block.exec).map((block): [number, number] => [block.start, block.end])
|
|
627
|
-
// An inline span or @ ref inside a ```! block is part of that block's script, not a
|
|
628
|
-
// placeholder of its own; the block already covers those bytes.
|
|
629
|
-
const literal = (index: number): boolean => inRanges(protectedRanges, index) || inRanges(execRanges, index)
|
|
630
|
-
|
|
631
|
-
const spans: DynamicSpan[] = []
|
|
632
|
-
for (const block of blocks) {
|
|
633
|
-
if (block.exec) spans.push({ start: block.start, end: block.end, run: () => runSpan(exec, block.content, '```!', shell) })
|
|
634
|
-
}
|
|
635
|
-
// `!` counts only at the start of a line or after whitespace; `KEY=!`cmd`` is literal.
|
|
636
|
-
const bashPattern = /(^|\s)!`([^`]+)`/g
|
|
637
|
-
for (let m = bashPattern.exec(body); m !== null; m = bashPattern.exec(body)) {
|
|
638
|
-
if (literal(m.index)) continue
|
|
639
|
-
const [span, lead, command] = m
|
|
640
|
-
spans.push({ start: m.index, end: m.index + span.length, run: async () => lead + (await runSpan(exec, command, `!\`${command}\``, shell)) })
|
|
641
|
-
}
|
|
642
|
-
const atPattern = /(^|\s)@(\S+)/g
|
|
643
|
-
for (let m = atPattern.exec(body); m !== null; m = atPattern.exec(body)) {
|
|
644
|
-
if (literal(m.index)) continue
|
|
645
|
-
const [whole, lead, reference] = m
|
|
646
|
-
spans.push({
|
|
647
|
-
start: m.index,
|
|
648
|
-
end: m.index + whole.length,
|
|
649
|
-
run: async () => {
|
|
650
|
-
const content = readReference(cwd, reference)
|
|
651
|
-
return content === undefined ? whole : `${lead}\n<file path="${reference}">\n${content.trimEnd()}\n</file>\n`
|
|
652
|
-
},
|
|
653
|
-
})
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
spans.sort((a, b) => a.start - b.start)
|
|
657
|
-
let out = ''
|
|
658
|
-
let cursor = 0
|
|
659
|
-
for (const span of spans) {
|
|
660
|
-
if (span.start < cursor) continue // a rare @/inline overlap: keep the first, skip the nested
|
|
661
|
-
out += body.slice(cursor, span.start) + (await span.run())
|
|
662
|
-
cursor = span.end
|
|
663
|
-
}
|
|
664
|
-
return out + body.slice(cursor)
|
|
665
|
-
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Running the `!` command spans and `@path` references a command or skill body carries.
|
|
3
|
+
*
|
|
4
|
+
* Split from the parsing half: only commands.ts drives spans, while skills.ts and the
|
|
5
|
+
* subagent loader import parsing alone and have no business pulling a shell resolver in.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as fs from 'node:fs'
|
|
9
|
+
import * as path from 'node:path'
|
|
10
|
+
|
|
11
|
+
import { bashBinary } from './shell-resolve.js'
|
|
12
|
+
import { splitSegments } from './shell-split.js'
|
|
13
|
+
import { type Fence, fenceMarker, stepFence } from './strip-comments.js'
|
|
14
|
+
export type CommandExec = (command: string) => Promise<{ stdout: string; stderr: string; code: number; killed?: boolean }>
|
|
15
|
+
|
|
16
|
+
/** PowerShell single-quote escaping: inside a '...' literal the only special
|
|
17
|
+
* characters are the quote delimiters themselves, written doubled. PowerShell's
|
|
18
|
+
* lexer treats U+2018 through U+201B as single quotes too, so each is doubled the
|
|
19
|
+
* same way; leaving them bare let a projectDir like `Alex’s Projects` end the
|
|
20
|
+
* literal mid-path with a ParserError. sh's '\'' form must not be used here,
|
|
21
|
+
* since PowerShell would keep the backslash and reopen the string. */
|
|
22
|
+
export function powershellQuote(value: string): string {
|
|
23
|
+
return value.replaceAll(/['‘’‚‛]/g, '$&$&')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export { resolvePowershellBinary } from './shell-resolve.js'
|
|
27
|
+
|
|
28
|
+
export interface SpanExec {
|
|
29
|
+
command: string
|
|
30
|
+
args: string[]
|
|
31
|
+
/** Set when the shell cannot merge stderr into stdout in-script (pwsh 7 drops a
|
|
32
|
+
* native command's stderr from `& { } 2>&1`), asking the caller to append the
|
|
33
|
+
* exec result's stderr to its stdout instead. The sh path merges in-script and
|
|
34
|
+
* leaves this unset. */
|
|
35
|
+
mergeStreams?: boolean
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The sh invocation for a span: CLAUDE_PROJECT_DIR and CLAUDECODE=1 exported in-script
|
|
39
|
+
* (pi.exec takes no env; CLAUDECODE marks every subprocess Claude spawns), stderr merged
|
|
40
|
+
* with 2>&1. The group opens with a `:` null command: `{ }` around an empty or
|
|
41
|
+
* comment-only span is a hard sh syntax error (exit 2) that aborted the whole
|
|
42
|
+
* invocation, and `:` keeps such a span the harmless no-op it was on HEAD while the
|
|
43
|
+
* group still merges stderr for real spans. */
|
|
44
|
+
function shSpan(binary: string, projectDir: string, script: string): SpanExec {
|
|
45
|
+
const quoted = projectDir.replaceAll("'", String.raw`'\''`)
|
|
46
|
+
return { command: binary, args: ['-c', `export CLAUDE_PROJECT_DIR='${quoted}'\nexport CLAUDECODE=1\n{ :\n${script}\n} 2>&1`] }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The PowerShell invocation for a span. No in-script 2>&1: under pwsh 7 it does not
|
|
50
|
+
* merge a native command's stderr on a script block, so mergeStreams has the caller
|
|
51
|
+
* append it. The trailing exit forwards a failed native command's code, which pwsh
|
|
52
|
+
* -Command otherwise swallows (the process exited 0 and a failure never aborted the
|
|
53
|
+
* invocation). An empty or cmdlet-only span leaves $LASTEXITCODE unset and exits 0.
|
|
54
|
+
* Residual gap vs sh: a failing cmdlet sets no exit code, so it cannot abort; its
|
|
55
|
+
* error text still reaches the model through the merged stderr. */
|
|
56
|
+
function powershellSpan(binary: string, projectDir: string, script: string): SpanExec {
|
|
57
|
+
const preamble = `$ErrorActionPreference='Continue'\n$env:CLAUDE_PROJECT_DIR='${powershellQuote(projectDir)}'\n$env:CLAUDECODE='1'`
|
|
58
|
+
return { command: binary, args: ['-NoProfile', '-NonInteractive', '-Command', `${preamble}\n& {\n${script}\n}\nexit $LASTEXITCODE`], mergeStreams: true }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The exec invocation for one injected span, honoring the `shell:` frontmatter per
|
|
63
|
+
* Claude's shell matrix (skills.md). `powershell` runs through a PowerShell binary when
|
|
64
|
+
* one resolves. Otherwise the span runs through bash: /bin/sh off Windows, Git Bash on
|
|
65
|
+
* Windows. Without Git Bash, a skill that declared `shell: bash` fails before any
|
|
66
|
+
* command runs ("requires bash"), an undeclared one falls to PowerShell, and with
|
|
67
|
+
* neither shell the invocation fails. Both paths export CLAUDE_PROJECT_DIR (each
|
|
68
|
+
* shell's own quoting) and merge stderr into stdout, as the Bash tool does when it
|
|
69
|
+
* runs these for Claude: the sh script in-line with 2>&1, the pwsh path via
|
|
70
|
+
* mergeStreams in the caller.
|
|
71
|
+
*
|
|
72
|
+
* The resolvers are parameters so a caller (or test) controls the lookups: the
|
|
73
|
+
* PowerShell one is passed as an imported binding, the bash one defaults to the
|
|
74
|
+
* platform rule.
|
|
75
|
+
*/
|
|
76
|
+
export function spanExec(shell: string | undefined, projectDir: string, script: string, resolveBinary: () => string | undefined, resolveBash: () => string | undefined = bashBinary): SpanExec {
|
|
77
|
+
if (shell === 'powershell') {
|
|
78
|
+
const binary = resolveBinary()
|
|
79
|
+
if (binary !== undefined) return powershellSpan(binary, projectDir, script)
|
|
80
|
+
}
|
|
81
|
+
const bash = resolveBash()
|
|
82
|
+
if (bash !== undefined) return shSpan(bash, projectDir, script)
|
|
83
|
+
if (shell === 'bash') throw new Error('shell: bash requires Git Bash, which was not found (install Git for Windows or set CLAUDE_CODE_GIT_BASH_PATH)')
|
|
84
|
+
const binary = resolveBinary()
|
|
85
|
+
if (binary !== undefined) return powershellSpan(binary, projectDir, script)
|
|
86
|
+
throw new Error('no shell found for the injected commands: install Git for Windows or PowerShell')
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
interface FenceBlock {
|
|
90
|
+
start: number
|
|
91
|
+
end: number
|
|
92
|
+
/** A fence opened with ```! runs its content as one script; any other fence protects. */
|
|
93
|
+
exec: boolean
|
|
94
|
+
content: string
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Fenced blocks of a body: Claude's dynamic syntax is literal text inside a plain
|
|
98
|
+
* fence, while a ```! fence is itself a placeholder that executes. Fences follow
|
|
99
|
+
* CommonMark: any indentation, closed only by the opener's character in a run at
|
|
100
|
+
* least as long, so a tilde line or a shorter fence inside stays content. */
|
|
101
|
+
function fenceBlocks(body: string): FenceBlock[] {
|
|
102
|
+
const blocks: FenceBlock[] = []
|
|
103
|
+
let fence: Fence | null = null
|
|
104
|
+
let open: { index: number; exec: boolean; contentStart: number } | undefined
|
|
105
|
+
let offset = 0
|
|
106
|
+
for (const line of body.split('\n')) {
|
|
107
|
+
const trimmed = line.trimStart()
|
|
108
|
+
const step = stepFence(fence, trimmed, fenceMarker(trimmed))
|
|
109
|
+
const lineEnd = offset + line.length
|
|
110
|
+
if (fence === null && step.fence !== null) {
|
|
111
|
+
// Only the exact, unindented ```! opener executes, as Claude documents it.
|
|
112
|
+
open = { index: offset, exec: line.startsWith('```') && step.fence.length === 3 && trimmed.slice(3).trim() === '!', contentStart: lineEnd + 1 }
|
|
113
|
+
} else if (fence !== null && step.fence === null && open !== undefined) {
|
|
114
|
+
blocks.push({ start: open.index, end: lineEnd, exec: open.exec, content: body.slice(Math.min(open.contentStart, offset), offset).replace(/\n$/, '') })
|
|
115
|
+
open = undefined
|
|
116
|
+
}
|
|
117
|
+
fence = step.fence
|
|
118
|
+
offset = lineEnd + 1
|
|
119
|
+
}
|
|
120
|
+
// An unterminated fence protects to the end of the body rather than executing.
|
|
121
|
+
if (open !== undefined) blocks.push({ start: open.index, end: body.length, exec: false, content: '' })
|
|
122
|
+
return blocks
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Exit 1 is a normal result for Claude's documented search and comparison commands
|
|
126
|
+
* (no matches, files differ); exit 2 and up fails even for these. The PowerShell
|
|
127
|
+
* shell uses a different set, which "includes grep and git diff but not find or
|
|
128
|
+
* diff" (test/[ are bash builtins and do not apply there either). */
|
|
129
|
+
const EXIT_ONE_OK = new Set(['grep', 'rg', 'egrep', 'fgrep', 'find', 'diff', 'test', '['])
|
|
130
|
+
const EXIT_ONE_OK_POWERSHELL = new Set(['grep', 'rg', 'egrep', 'fgrep'])
|
|
131
|
+
|
|
132
|
+
export type SpanShell = 'bash' | 'powershell'
|
|
133
|
+
|
|
134
|
+
const isCarveoutSegment = (segment: string, shell: SpanShell): boolean => {
|
|
135
|
+
const words = segment.trim().split(/\s+/)
|
|
136
|
+
if (words[0] === 'git') return words[1] === 'diff' || words[1] === 'grep'
|
|
137
|
+
return (shell === 'powershell' ? EXIT_ONE_OK_POWERSHELL : EXIT_ONE_OK).has(words[0])
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function benignExitOne(command: string, shell: SpanShell = 'bash'): boolean {
|
|
141
|
+
const segments = splitSegments(command)
|
|
142
|
+
if (segments.length === 0) return false
|
|
143
|
+
// A `&&`/`||` chain can short-circuit, so an earlier segment's exit 1 becomes the
|
|
144
|
+
// result and the last segment is not the one that set the code: `cd nope && grep x`
|
|
145
|
+
// exits 1 from cd, not a benign grep miss. Only when every segment is a carveout is
|
|
146
|
+
// the exit benign whichever ran last. Without short-circuit operators the exit is
|
|
147
|
+
// the last segment's (a `|` pipeline exits with its final command, `;`/newline with
|
|
148
|
+
// the last statement), so the last segment decides.
|
|
149
|
+
if (/&&|\|\|/.test(command)) return segments.every((segment) => isCarveoutSegment(segment, shell))
|
|
150
|
+
return isCarveoutSegment(segments.at(-1) ?? '', shell)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Run one injected span. A failure aborts the whole invocation, as Claude
|
|
154
|
+
* documents: the model never sees a half-expanded body. */
|
|
155
|
+
async function runSpan(exec: CommandExec, command: string, pattern: string, shell: SpanShell): Promise<string> {
|
|
156
|
+
const result = await exec(command)
|
|
157
|
+
// A timeout kill arrives as killed:true with code 0 (a signal death has no exit code),
|
|
158
|
+
// so the code alone would paste the partial output as a success. Claude kills a span
|
|
159
|
+
// at the Bash timeout and that failure aborts the invocation.
|
|
160
|
+
if (result.killed) throw new Error(`Shell command timed out for pattern "${pattern}"`)
|
|
161
|
+
if (result.code !== 0 && !(result.code === 1 && benignExitOne(command, shell))) {
|
|
162
|
+
throw new Error(`Shell command failed for pattern "${pattern}"\n[stderr]\n${(result.stderr || result.stdout).trim()}`)
|
|
163
|
+
}
|
|
164
|
+
return result.stdout.trimEnd()
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const inRanges = (ranges: Array<[number, number]>, index: number): boolean => ranges.some(([start, end]) => index >= start && index < end)
|
|
168
|
+
|
|
169
|
+
/** Read a `@path` reference, confined to the working directory. Returns undefined
|
|
170
|
+
* when the path escapes it or cannot be read, so the reference stays literal. */
|
|
171
|
+
function readReference(cwd: string, reference: string): string | undefined {
|
|
172
|
+
try {
|
|
173
|
+
// Both sides canonicalised: on macOS /var is itself a symlink, so comparing a
|
|
174
|
+
// resolved path against an unresolved root rejects every legitimate read.
|
|
175
|
+
const root = fs.realpathSync(cwd)
|
|
176
|
+
// Confinement is checked after symlinks resolve: a lexical check passes a link
|
|
177
|
+
// that points outside the project, and the read would follow it.
|
|
178
|
+
const real = fs.realpathSync(path.resolve(cwd, reference))
|
|
179
|
+
if (real !== root && !real.startsWith(root + path.sep)) return undefined
|
|
180
|
+
if (!fs.statSync(real).isFile()) return undefined
|
|
181
|
+
return fs.readFileSync(real, 'utf-8')
|
|
182
|
+
} catch {
|
|
183
|
+
return undefined
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
interface DynamicSpan {
|
|
188
|
+
start: number
|
|
189
|
+
end: number
|
|
190
|
+
run: () => Promise<string>
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Claude's dynamic command content: `` !`cmd` `` runs a shell command and pastes
|
|
194
|
+
* its output (recognized only at a word start), a ```! fenced block runs its lines
|
|
195
|
+
* as one script, and `@path` inlines a file. Inline spans and `@` refs are skipped
|
|
196
|
+
* inside plain fenced code blocks. A failed command rejects, aborting the
|
|
197
|
+
* invocation, per the skills docs.
|
|
198
|
+
*
|
|
199
|
+
* Every placeholder is located in the ORIGINAL body and the whole body is expanded
|
|
200
|
+
* in one pass, so a command's output (or a file's content) is inserted verbatim and
|
|
201
|
+
* never re-scanned for further placeholders. Re-scanning was both a parity break
|
|
202
|
+
* (Claude expands once) and a command-injection path: output of a `` ```! `` block
|
|
203
|
+
* such as a commit message could smuggle its own `` !`cmd` `` for a later pass. */
|
|
204
|
+
export async function expandDynamicContent(body: string, cwd: string, exec: CommandExec, shell: SpanShell = 'bash'): Promise<string> {
|
|
205
|
+
const blocks = fenceBlocks(body)
|
|
206
|
+
const protectedRanges = blocks.filter((block) => !block.exec).map((block): [number, number] => [block.start, block.end])
|
|
207
|
+
const execRanges = blocks.filter((block) => block.exec).map((block): [number, number] => [block.start, block.end])
|
|
208
|
+
// An inline span or @ ref inside a ```! block is part of that block's script, not a
|
|
209
|
+
// placeholder of its own; the block already covers those bytes.
|
|
210
|
+
const literal = (index: number): boolean => inRanges(protectedRanges, index) || inRanges(execRanges, index)
|
|
211
|
+
|
|
212
|
+
const spans: DynamicSpan[] = []
|
|
213
|
+
for (const block of blocks) {
|
|
214
|
+
if (block.exec) spans.push({ start: block.start, end: block.end, run: () => runSpan(exec, block.content, '```!', shell) })
|
|
215
|
+
}
|
|
216
|
+
// `!` counts only at the start of a line or after whitespace; `KEY=!`cmd`` is literal.
|
|
217
|
+
const bashPattern = /(^|\s)!`([^`]+)`/g
|
|
218
|
+
for (let m = bashPattern.exec(body); m !== null; m = bashPattern.exec(body)) {
|
|
219
|
+
if (literal(m.index)) continue
|
|
220
|
+
const [span, lead, command] = m
|
|
221
|
+
spans.push({ start: m.index, end: m.index + span.length, run: async () => lead + (await runSpan(exec, command, `!\`${command}\``, shell)) })
|
|
222
|
+
}
|
|
223
|
+
const atPattern = /(^|\s)@(\S+)/g
|
|
224
|
+
for (let m = atPattern.exec(body); m !== null; m = atPattern.exec(body)) {
|
|
225
|
+
if (literal(m.index)) continue
|
|
226
|
+
const [whole, lead, reference] = m
|
|
227
|
+
spans.push({
|
|
228
|
+
start: m.index,
|
|
229
|
+
end: m.index + whole.length,
|
|
230
|
+
run: async () => {
|
|
231
|
+
const content = readReference(cwd, reference)
|
|
232
|
+
return content === undefined ? whole : `${lead}\n<file path="${reference}">\n${content.trimEnd()}\n</file>\n`
|
|
233
|
+
},
|
|
234
|
+
})
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
spans.sort((a, b) => a.start - b.start)
|
|
238
|
+
let out = ''
|
|
239
|
+
let cursor = 0
|
|
240
|
+
for (const span of spans) {
|
|
241
|
+
if (span.start < cursor) continue // a rare @/inline overlap: keep the first, skip the nested
|
|
242
|
+
out += body.slice(cursor, span.start) + (await span.run())
|
|
243
|
+
cursor = span.end
|
|
244
|
+
}
|
|
245
|
+
return out + body.slice(cursor)
|
|
246
|
+
}
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
import * as fs from 'node:fs'
|
|
13
13
|
import * as path from 'node:path'
|
|
14
14
|
|
|
15
|
+
import { isRecord } from './values.js'
|
|
16
|
+
|
|
15
17
|
/** The OS managed-settings.json path Claude Code documents per platform. */
|
|
16
18
|
export function managedSettingsPath(platform: NodeJS.Platform = process.platform): string {
|
|
17
19
|
if (platform === 'darwin') return '/Library/Application Support/ClaudeCode/managed-settings.json'
|
|
@@ -42,16 +44,12 @@ function readOneSettingsFile(file: string): Record<string, unknown> {
|
|
|
42
44
|
return {}
|
|
43
45
|
}
|
|
44
46
|
|
|
45
|
-
function isRecordValue(value: unknown): value is Record<string, unknown> {
|
|
46
|
-
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
47
|
-
}
|
|
48
|
-
|
|
49
47
|
/** Claude's managed-settings.d merge rules: a later single value replaces, lists
|
|
50
48
|
* combine with duplicates removed, and nested blocks merge key by key with each
|
|
51
49
|
* key following these same rules. */
|
|
52
50
|
function mergeManagedKey(base: unknown, next: unknown): unknown {
|
|
53
51
|
if (Array.isArray(base) && Array.isArray(next)) return [...new Set([...base, ...next])]
|
|
54
|
-
if (
|
|
52
|
+
if (isRecord(base) && isRecord(next)) {
|
|
55
53
|
const merged: Record<string, unknown> = { ...base }
|
|
56
54
|
for (const [key, value] of Object.entries(next)) merged[key] = key in merged ? mergeManagedKey(merged[key], value) : value
|
|
57
55
|
return merged
|