pi-code 1.0.55 → 1.0.57

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.
Files changed (42) hide show
  1. package/README.md +2 -2
  2. package/extensions/commands.ts +6 -12
  3. package/extensions/context-imports.ts +2 -10
  4. package/extensions/env-settings.ts +1 -5
  5. package/extensions/git-checkpoint.ts +4 -12
  6. package/extensions/goal.ts +2 -2
  7. package/extensions/hooks/config.ts +6 -21
  8. package/extensions/hooks/decisions.ts +3 -2
  9. package/extensions/hooks/index.ts +4 -16
  10. package/extensions/hooks/matcher.ts +2 -1
  11. package/extensions/hooks/runners.ts +10 -4
  12. package/extensions/internal/command-file.ts +7 -241
  13. package/extensions/internal/command-spans.ts +246 -0
  14. package/extensions/internal/managed-settings.ts +3 -5
  15. package/extensions/internal/plugins.ts +2 -2
  16. package/extensions/internal/settings-chain.ts +19 -0
  17. package/extensions/internal/values.ts +38 -0
  18. package/extensions/mcp/index.ts +6 -5
  19. package/extensions/mcp/listing.ts +2 -1
  20. package/extensions/mcp/oauth-flow.ts +2 -1
  21. package/extensions/mcp/policy.ts +9 -2
  22. package/extensions/memory.ts +10 -15
  23. package/extensions/output-styles.ts +4 -17
  24. package/extensions/plan-mode/index.ts +9 -9
  25. package/extensions/plan-mode/utils.ts +31 -0
  26. package/extensions/session-title.ts +2 -12
  27. package/extensions/skills.ts +6 -18
  28. package/extensions/status-line.ts +2 -8
  29. package/extensions/subagent/README.md +15 -5
  30. package/extensions/subagent/agents.ts +2 -2
  31. package/extensions/subagent/background.ts +2 -1
  32. package/extensions/subagent/child.ts +197 -0
  33. package/extensions/subagent/concurrency.ts +23 -0
  34. package/extensions/subagent/index.ts +36 -1426
  35. package/extensions/subagent/modes.ts +405 -0
  36. package/extensions/subagent/params.ts +56 -0
  37. package/extensions/subagent/registry-text.ts +105 -0
  38. package/extensions/subagent/render-result.ts +306 -0
  39. package/extensions/subagent/run.ts +375 -0
  40. package/extensions/subagent/types.ts +41 -0
  41. package/extensions/subagent/worktree.ts +2 -1
  42. package/package.json +1 -1
@@ -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 (namespaced
6
- * subdirectories, `allowed-tools`, `model`, `!` bash blocks, `@file` refs) lives
7
- * here and is applied by commands.ts when it registers each command itself.
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 (isRecordValue(base) && isRecordValue(next)) {
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
@@ -15,9 +15,9 @@
15
15
  import * as crypto from 'node:crypto'
16
16
  import * as fs from 'node:fs'
17
17
  import * as path from 'node:path'
18
-
19
18
  import { claudeConfigDir } from './config-dir.js'
20
19
  import { readManagedSettings } from './managed-settings.js'
20
+ import { errorMessage } from './values.js'
21
21
 
22
22
  export interface InstalledPlugin {
23
23
  name: string
@@ -43,7 +43,7 @@ function readJson(file: string): Record<string, unknown> {
43
43
  } catch (error) {
44
44
  // A manifest that does not parse leaves the plugin with no components at all, and
45
45
  // settings that do not parse drop the enablement or configuration they carried.
46
- console.warn(`pi-code-plugins: ignoring ${file}: ${error instanceof Error ? error.message : String(error)}`)
46
+ console.warn(`pi-code-plugins: ignoring ${file}: ${errorMessage(error)}`)
47
47
  return {}
48
48
  }
49
49
  }
@@ -7,9 +7,11 @@
7
7
  * and the skill-shell policy all resolve their files through this one chain.
8
8
  */
9
9
 
10
+ import * as fs from 'node:fs'
10
11
  import * as path from 'node:path'
11
12
  import { claudeConfigDir } from './config-dir.js'
12
13
  import { repoRoot } from './project-root.js'
14
+ import { isRecord } from './values.js'
13
15
 
14
16
  /** The user settings.json, then (only when `includeProject`) the project files by
15
17
  * Claude's placement rules: the shared `.claude/settings.json` is read from the
@@ -28,3 +30,20 @@ export function claudeSettingsChain(cwd: string, home: string, includeProject: b
28
30
  files.push(path.join(localDir, '.claude', 'settings.local.json'))
29
31
  return files
30
32
  }
33
+
34
+ /** Every readable settings object in the chain, in order, so the last one a caller
35
+ * sees for a key is the one that wins. A file that is missing, unparseable, or not a
36
+ * JSON object is skipped: a corrupt settings.json must not end the chain, or the
37
+ * user-level values behind it would silently vanish along with it. Lazy, so a caller
38
+ * that stops early does not read the rest. */
39
+ export function* readSettingsChain(files: readonly string[]): Generator<Record<string, unknown>> {
40
+ for (const file of files) {
41
+ let parsed: unknown
42
+ try {
43
+ parsed = JSON.parse(fs.readFileSync(file, 'utf-8'))
44
+ } catch {
45
+ continue
46
+ }
47
+ if (isRecord(parsed)) yield parsed
48
+ }
49
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The shapes every extension here needed its own copy of: an error's message, a plain
3
+ * object check, whether a path is a directory, and the text of a message content. Each
4
+ * was written three to five times with the same body, and the error one appeared in
5
+ * seventeen files.
6
+ */
7
+
8
+ import * as fs from 'node:fs'
9
+
10
+ /** The message of a thrown value, whatever was thrown. */
11
+ export const errorMessage = (error: unknown): string => (error instanceof Error ? error.message : String(error))
12
+
13
+ /** A JSON object, as opposed to null, an array, or a primitive. */
14
+ export function isRecord(value: unknown): value is Record<string, unknown> {
15
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
16
+ }
17
+
18
+ /** Whether the path is a directory today. A missing or unreadable path is not one. */
19
+ export function isDirectory(target: string): boolean {
20
+ try {
21
+ return fs.statSync(target).isDirectory()
22
+ } catch {
23
+ return false
24
+ }
25
+ }
26
+
27
+ /** The text of a message or tool-result content, dropping thinking and tool parts. A
28
+ * plain-string content is already the text. The separator is the caller's: Claude's
29
+ * last_assistant_message concatenates, a hook payload joins with newlines, a title or a
30
+ * prompt snippet with spaces. */
31
+ export function contentText(content: unknown, separator = ''): string {
32
+ if (typeof content === 'string') return content
33
+ if (!Array.isArray(content)) return ''
34
+ return content
35
+ .filter((part): part is { type: 'text'; text: string } => isRecord(part) && part.type === 'text' && typeof part.text === 'string')
36
+ .map((part) => part.text)
37
+ .join(separator)
38
+ }