pi-code 1.0.4 → 1.0.6

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 (35) hide show
  1. package/README.md +25 -13
  2. package/extensions/claude-rules.ts +158 -54
  3. package/extensions/commands.ts +417 -41
  4. package/extensions/context-imports.ts +446 -61
  5. package/extensions/hooks.ts +473 -73
  6. package/extensions/init.ts +81 -0
  7. package/extensions/internal/agent-run.ts +42 -0
  8. package/extensions/internal/bash-rules.ts +27 -0
  9. package/extensions/internal/command-file.ts +423 -66
  10. package/extensions/internal/html-markdown.ts +71 -0
  11. package/extensions/internal/instruction-events.ts +70 -0
  12. package/extensions/internal/managed-settings.ts +38 -0
  13. package/extensions/internal/mcp-call.ts +28 -0
  14. package/extensions/internal/mcp-oauth.ts +177 -0
  15. package/extensions/internal/model-complete.ts +68 -0
  16. package/extensions/internal/path-rules.ts +80 -0
  17. package/extensions/internal/plugins.ts +138 -0
  18. package/extensions/internal/project-approval.ts +2 -3
  19. package/extensions/internal/project-root.ts +78 -0
  20. package/extensions/internal/shell-split.ts +65 -0
  21. package/extensions/internal/strip-comments.ts +100 -0
  22. package/extensions/internal/web-transport.ts +3 -1
  23. package/extensions/mcp.ts +579 -30
  24. package/extensions/memory.ts +158 -35
  25. package/extensions/notify.ts +76 -4
  26. package/extensions/output-styles.ts +34 -6
  27. package/extensions/plan-mode/utils.ts +3 -57
  28. package/extensions/question.ts +2 -2
  29. package/extensions/skills.ts +11 -1
  30. package/extensions/status-line.ts +100 -5
  31. package/extensions/subagent/agents.ts +72 -61
  32. package/extensions/subagent/background.ts +25 -6
  33. package/extensions/subagent/index.ts +310 -31
  34. package/extensions/web.ts +93 -15
  35. package/package.json +1 -1
@@ -14,19 +14,37 @@ import * as path from 'node:path'
14
14
 
15
15
  import { parseFrontmatter } from '@earendil-works/pi-coding-agent'
16
16
 
17
+ import { splitSegments } from './shell-split.js'
18
+
19
+ /** The pi file tools a Claude path rule can govern. */
20
+ export type PathRuleTool = 'read' | 'edit' | 'write'
21
+
17
22
  export interface ParsedCommand {
18
23
  description: string
19
24
  argumentHint?: string
20
25
  allowedTools?: string[]
26
+ /** Claude `Bash(...)` specifiers, present only when every bash grant is scoped. */
27
+ bashRules?: string[]
28
+ /** Claude path rules per pi file tool, from Read(...)/Edit(...)/Write(...) grants. */
29
+ pathRules?: Partial<Record<PathRuleTool, string[]>>
30
+ /** Names from the `arguments:` frontmatter list, mapped to positions in order. */
31
+ argumentNames?: string[]
32
+ /** Tools removed from the pool while the command's turn runs. */
33
+ disallowedTools?: string[]
34
+ /** `shell:` frontmatter: `bash` (the default) or `powershell`, choosing how the
35
+ * command's injected spans run (see spanExec). */
36
+ shell?: string
21
37
  model?: string
22
38
  disableModelInvocation: boolean
23
39
  body: string
24
40
  }
25
41
 
26
42
  export interface DiscoveredCommand {
27
- /** Claude's namespaced name: a nested file is `dir:name`. */
43
+ /** Claude's namespaced name: a nested file is `dir:name`, a plugin's is `plugin:name`. */
28
44
  name: string
29
45
  filePath: string
46
+ /** Set for plugin commands, carrying the ${CLAUDE_PLUGIN_*} and ${user_config.*} substitution sources. */
47
+ plugin?: { root: string; dataDir: string; userConfig?: Record<string, string> }
30
48
  }
31
49
 
32
50
  /** Claude tool names are PascalCase and do not all exist in pi: `Glob` is pi's
@@ -51,14 +69,18 @@ const CLAUDE_TOOL_MAP: Record<string, string> = {
51
69
  task: 'subagent',
52
70
  askuserquestion: 'question',
53
71
  exitplanmode: 'plan_mode_complete',
72
+ // Claude's name for the tool this package registers so the model can run user slash
73
+ // commands; without it `allowed-tools: SlashCommand` matched nothing and the grant
74
+ // could neither keep nor drop the tool.
75
+ slashcommand: 'slash_command',
54
76
  }
55
77
 
56
78
  /**
57
- * Claude scopes a grant to arguments: `Bash(git add:*)` allows exactly those commands.
58
- * pi's active-tool list is per tool, with no argument dimension, so the scope is
59
- * dropped and the base tool is granted. Keeping the scope in the name matched nothing
60
- * when the list was intersected with the active tools, which left a command declaring
61
- * only scoped grants running with no tools at all.
79
+ * The pi tool name for one grant entry, scope and all: `Bash(git add:*)` is `bash`.
80
+ * Keeping the scope in the name matched nothing when the list was intersected with
81
+ * the active tools, which left a command declaring only scoped grants running with
82
+ * no tools at all. The scope itself is not dropped: parseToolGrants keeps bash
83
+ * scopes for call-time enforcement.
62
84
  */
63
85
  export function normalizeToolName(name: string): string {
64
86
  const lower = name.trim().toLowerCase()
@@ -94,19 +116,110 @@ export function toolEntries(raw: string): string[] {
94
116
  return entries.map((entry) => entry.trim()).filter(Boolean)
95
117
  }
96
118
 
97
- /**
98
- * A tool grant is either a comma-separated string or a YAML list, and the two mean the
99
- * same thing. An empty list is not the same as an absent one: it says no tools, so it
100
- * comes back as an empty array rather than undefined.
101
- */
102
- export function parseToolList(raw: unknown): string[] | undefined {
103
- if (raw === undefined || raw === null) return undefined
119
+ export interface ToolGrants {
120
+ /** pi tool names to grant, deduplicated in first-seen order. */
121
+ tools: string[]
122
+ /** Claude `Bash(...)` specifiers, present only when every bash grant is scoped:
123
+ * an unscoped `Bash` entry is the wider grant and wins over its scoped siblings. */
124
+ bashRules?: string[]
125
+ /** Claude path rules per pi file tool, absent for a tool with an unscoped grant.
126
+ * Edit scopes govern writes too, as Claude documents; Write scopes are honored
127
+ * rather than Claude's accept-and-warn-then-ignore, which would fail open here. */
128
+ pathRules?: Partial<Record<PathRuleTool, string[]>>
129
+ /** Entries that carried an argument scope, in their original spelling. */
130
+ scopedEntries: string[]
131
+ }
132
+
133
+ /** The pi file tools one Claude path-ruled entry governs. */
134
+ const PATH_RULE_TOOLS: Record<string, Array<PathRuleTool>> = {
135
+ read: ['read'],
136
+ edit: ['edit', 'write'],
137
+ write: ['write'],
138
+ }
139
+
140
+ /** The tools, scopes, and path rules accumulated while scanning one grant list. */
141
+ interface GrantAccumulator {
142
+ tools: string[]
143
+ scopedEntries: string[]
144
+ bashRules: string[]
145
+ bashUnscoped: boolean
146
+ pathScopes: Record<PathRuleTool, string[]>
147
+ pathUnscoped: Set<PathRuleTool>
148
+ }
149
+
150
+ function createGrantAccumulator(): GrantAccumulator {
151
+ return { tools: [], scopedEntries: [], bashRules: [], bashUnscoped: false, pathScopes: { read: [], edit: [], write: [] }, pathUnscoped: new Set() }
152
+ }
153
+
154
+ /** Coerce a raw grant value to its string entries: a YAML list stays a list, a
155
+ * comma-separated string is split, and anything else (or a list with a non-string
156
+ * member) is rejected as undefined, the same "not a grant" signal as an absent field. */
157
+ function coerceGrantItems(raw: unknown): string[] | undefined {
104
158
  let items: unknown[]
105
159
  if (Array.isArray(raw)) items = raw
106
160
  else if (typeof raw === 'string') items = toolEntries(raw)
107
161
  else return undefined
108
162
  if (items.some((item) => typeof item !== 'string')) return undefined
109
- return [...new Set((items as string[]).map(normalizeToolName).filter(Boolean))]
163
+ return items as string[]
164
+ }
165
+
166
+ /** Fold one grant entry into the accumulator: the base tool is granted, an unscoped
167
+ * entry marks its tools wide, and a scoped entry records the scope for bash and the
168
+ * file tools it governs. */
169
+ function addGrantEntry(acc: GrantAccumulator, item: string): void {
170
+ const entry = item.trim()
171
+ const name = normalizeToolName(entry)
172
+ if (!name) return
173
+ if (!acc.tools.includes(name)) acc.tools.push(name)
174
+ const open = entry.indexOf('(')
175
+ if (open === -1) {
176
+ if (name === 'bash') acc.bashUnscoped = true
177
+ for (const tool of PATH_RULE_TOOLS[name] ?? []) acc.pathUnscoped.add(tool)
178
+ return
179
+ }
180
+ acc.scopedEntries.push(entry)
181
+ const scope = entry.slice(open + 1, entry.endsWith(')') ? -1 : undefined).trim()
182
+ // An empty specifier (`Bash()`, `Read()`) matches nothing and must not read as
183
+ // the unscoped grant it explicitly is not: it is recorded so the tool stays
184
+ // restricted, and the matchers treat an empty rule as matching no input.
185
+ if (name === 'bash') acc.bashRules.push(scope)
186
+ for (const tool of PATH_RULE_TOOLS[name] ?? []) acc.pathScopes[tool].push(scope)
187
+ }
188
+
189
+ /** The per-tool path rules from a scan: a tool with any unscoped grant is omitted
190
+ * (it is wide), one with only scoped grants keeps them, and the whole map is absent
191
+ * when no tool carries a rule. */
192
+ function buildPathRules(acc: GrantAccumulator): ToolGrants['pathRules'] {
193
+ const pathRules: NonNullable<ToolGrants['pathRules']> = {}
194
+ for (const tool of ['read', 'edit', 'write'] as const) {
195
+ if (!acc.pathUnscoped.has(tool) && acc.pathScopes[tool].length > 0) pathRules[tool] = acc.pathScopes[tool]
196
+ }
197
+ return Object.keys(pathRules).length > 0 ? pathRules : undefined
198
+ }
199
+
200
+ /**
201
+ * A tool grant is either a comma-separated string or a YAML list, and the two mean the
202
+ * same thing. An empty list is not the same as an absent one: it says no tools, so it
203
+ * comes back with an empty `tools` array rather than as undefined.
204
+ *
205
+ * Claude scopes a grant to arguments: `Bash(git add:*)` allows exactly those commands.
206
+ * pi's active-tool list is per tool, with no argument dimension, so the base tool is
207
+ * granted and the scope is kept: commands.ts enforces bash scopes at tool_call time,
208
+ * and the subagent's frontmatter parsing rejects a scoped grant it cannot express.
209
+ * A scope on any other tool is dropped, which widens that grant; bash is the one
210
+ * whose widening reaches everything, so it is the one enforced.
211
+ */
212
+ export function parseToolGrants(raw: unknown): ToolGrants | undefined {
213
+ const items = coerceGrantItems(raw)
214
+ if (items === undefined) return undefined
215
+ const acc = createGrantAccumulator()
216
+ for (const item of items) addGrantEntry(acc, item)
217
+ return {
218
+ tools: acc.tools,
219
+ scopedEntries: acc.scopedEntries,
220
+ bashRules: !acc.bashUnscoped && acc.bashRules.length > 0 ? acc.bashRules : undefined,
221
+ pathRules: buildPathRules(acc),
222
+ }
110
223
  }
111
224
 
112
225
  /** YAML types a bare scalar, so a model named `3.5` arrives as a number, not a string. */
@@ -115,9 +228,33 @@ const text = (value: unknown): string => {
115
228
  return typeof value === 'number' || typeof value === 'boolean' ? String(value) : ''
116
229
  }
117
230
 
231
+ /** YAML's affirmative boolean spellings. Claude documents `disable-model-invocation:
232
+ * true`, but a command file is hand-written YAML where `yes`, `on`, and `1` are all
233
+ * ordinary spellings of true, and pi's parser hands those back as the raw string or
234
+ * number rather than a boolean. A flag that gates a command off from the model has to
235
+ * honor them, or a command the user marked off-limits is silently offered to it. */
236
+ const YAML_TRUE = new Set(['true', 'yes', 'on', 'y', '1'])
237
+ const isFlagEnabled = (value: unknown): boolean => value === true || YAML_TRUE.has(text(value).toLowerCase())
238
+
118
239
  /** Claude writes `argument-hint: [pr]`, which YAML reads as a list; render it back. */
119
240
  const hint = (value: unknown): string => (Array.isArray(value) ? `[${value.join(', ')}]` : text(value))
120
241
 
242
+ const ARGUMENT_NAME = /^[A-Za-z_]\w*$/
243
+
244
+ /** The `arguments:` frontmatter: a YAML list or a space- or comma-separated string
245
+ * of names mapping to positions in order. Invalid names are dropped, and ARGUMENTS
246
+ * itself is reserved by the built-in placeholder. */
247
+ function parseArgumentNames(raw: unknown): string[] | undefined {
248
+ let items: string[]
249
+ if (Array.isArray(raw)) items = raw.map(String)
250
+ else if (typeof raw === 'string') items = raw.split(/[\s,]+/)
251
+ else return undefined
252
+ const names = items.map((name) => name.trim()).filter((name) => ARGUMENT_NAME.test(name) && name !== 'ARGUMENTS')
253
+ return names.length > 0 ? names : undefined
254
+ }
255
+
256
+ const SHELLS = new Set(['bash', 'powershell'])
257
+
121
258
  export function parseCommandFile(content: string): ParsedCommand {
122
259
  // pi's own parser, rather than a hand-rolled one: it reads the YAML shapes Claude
123
260
  // command files actually use (flow sequences, block lists, quoted and multi-line
@@ -126,12 +263,20 @@ export function parseCommandFile(content: string): ParsedCommand {
126
263
  const body = raw.trim()
127
264
  const firstLine = body.split('\n').find((line) => line.trim().length > 0) ?? ''
128
265
  const disable = frontmatter['disable-model-invocation']
266
+ const grants = parseToolGrants(frontmatter['allowed-tools'])
267
+ const shell = text(frontmatter.shell).toLowerCase()
129
268
  return {
130
269
  description: text(frontmatter.description) || firstLine.slice(0, 60),
131
270
  argumentHint: hint(frontmatter['argument-hint']) || undefined,
132
- allowedTools: parseToolList(frontmatter['allowed-tools']),
271
+ allowedTools: grants?.tools,
272
+ bashRules: grants?.bashRules,
273
+ pathRules: grants?.pathRules,
274
+ argumentNames: parseArgumentNames(frontmatter.arguments),
275
+ // A scope on a disallow entry only denies more than asked, so the drop is safe.
276
+ disallowedTools: parseToolGrants(frontmatter['disallowed-tools'])?.tools,
277
+ shell: SHELLS.has(shell) ? shell : undefined,
133
278
  model: text(frontmatter.model) || undefined,
134
- disableModelInvocation: disable === true || text(disable) === 'true',
279
+ disableModelInvocation: isFlagEnabled(disable),
135
280
  body,
136
281
  }
137
282
  }
@@ -148,21 +293,80 @@ export function splitArgs(args: string): string[] {
148
293
  return out
149
294
  }
150
295
 
151
- /** Claude's substitutions: `$ARGUMENTS`, `$@`, `$1`..`$n`, `${n:-default}`, and `\$`
152
- * for a literal dollar. An unfilled positional becomes empty rather than leaking its
153
- * literal token. One pass with a replacer function: sequential string passes both
154
- * interpreted `$&`-style metacharacters in the arguments and re-scanned substituted
155
- * text, so `$` sequences the user typed were consumed as tokens. */
156
- export function substituteArgs(body: string, args: string): string {
296
+ const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`)
297
+
298
+ /** One alternation covering every argument placeholder plus the two escape forms.
299
+ * Alternation order is load-bearing: escapes first (so `\$1` never expands), the
300
+ * bracketed forms before `$ARGUMENTS` (so `$ARGUMENTS[0]` is not read as the bare
301
+ * placeholder plus literal brackets). `(?!)` never matches, standing in when no
302
+ * names are declared. */
303
+ function argPattern(names: string[]): RegExp {
304
+ const nameAlt = names.length > 0 ? names.map(escapeRegExp).join('|') : '(?!)'
305
+ return new RegExp(
306
+ String.raw`\\{2}(?=\$)` + // doubled backslash: both stay, the token after still expands
307
+ String.raw`|\\\$(?=\d|@|\{|ARGUMENTS\b|(?:${nameAlt})\b)` + // escape: before any placeholder this expands, incl. $@ and ${...:-}
308
+ String.raw`|\$ARGUMENTS\[(\d+)\]` +
309
+ String.raw`|\$\{(\d+):-([^}]*)\}` +
310
+ String.raw`|\$\{ARGUMENTS:-([^}]*)\}` +
311
+ String.raw`|\$ARGUMENTS\b` +
312
+ String.raw`|\$@` +
313
+ String.raw`|\$(\d+)` +
314
+ String.raw`|\$(${nameAlt})\b`,
315
+ 'g',
316
+ )
317
+ }
318
+
319
+ export interface SubstitutedArgs {
320
+ text: string
321
+ /** Whether any placeholder actually read the arguments; drives Claude's
322
+ * `ARGUMENTS: <value>` append when a command never looks at what was passed. */
323
+ consumed: boolean
324
+ }
325
+
326
+ /**
327
+ * Claude's substitutions, per the current skills docs: `$ARGUMENTS`,
328
+ * `$ARGUMENTS[N]` and its `$N` shorthand (0-based: `$0` is the first argument),
329
+ * `$name` for names declared in `arguments:` frontmatter, plus the pi extras `$@`
330
+ * and `${N:-default}`/`${ARGUMENTS:-default}`. An unfilled indexed placeholder
331
+ * stays literal; a declared name with no argument becomes empty; `\$` escapes only
332
+ * a real placeholder and a doubled backslash keeps both while still expanding.
333
+ * One pass with a replacer function: sequential string passes both interpreted
334
+ * `$&`-style metacharacters in the arguments and re-scanned substituted text.
335
+ */
336
+ export function substituteArgsDetailed(body: string, args: string, names: string[] = []): SubstitutedArgs {
157
337
  const parts = splitArgs(args)
158
338
  const all = args.trim()
159
- return body.replaceAll(/\\\$|\$\{(\d+):-([^}]*)\}|\$\{ARGUMENTS:-([^}]*)\}|\$ARGUMENTS\b|\$@|\$(\d+)/g, (token, index?: string, fallback?: string, argsFallback?: string, position?: string) => {
160
- if (token === '\\$') return '$'
161
- if (index !== undefined) return parts[Number(index) - 1] ?? fallback ?? ''
162
- if (argsFallback !== undefined) return all || argsFallback
163
- if (position !== undefined) return parts[Number(position) - 1] ?? ''
164
- return all
339
+ let consumed = false
340
+ const fill = (value: string | undefined, orElse: string): string => {
341
+ if (value === undefined) return orElse
342
+ consumed = true
343
+ return value
344
+ }
345
+ const text = body.replaceAll(argPattern(names), (token, bracketIdx?: string, defIdx?: string, defVal?: string, argsDefault?: string, shorthandIdx?: string, name?: string) => {
346
+ if (token === String.raw`\\`) return token
347
+ if (token === String.raw`\$`) return '$'
348
+ if (bracketIdx !== undefined) return fill(parts[Number(bracketIdx)], token)
349
+ if (defIdx !== undefined) return fill(parts[Number(defIdx)], defVal ?? '')
350
+ if (argsDefault !== undefined) {
351
+ consumed = true
352
+ return all || argsDefault
353
+ }
354
+ if (shorthandIdx !== undefined) return fill(parts[Number(shorthandIdx)], token)
355
+ if (name !== undefined) return fill(parts[names.indexOf(name)], '')
356
+ consumed = true
357
+ return all // $ARGUMENTS or $@
165
358
  })
359
+ return { text, consumed }
360
+ }
361
+
362
+ export function substituteArgs(body: string, args: string, names: string[] = []): string {
363
+ return substituteArgsDetailed(body, args, names).text
364
+ }
365
+
366
+ /** Claude's `${CLAUDE_*}` string substitutions. A backslash does not prevent these,
367
+ * per the docs, and an unknown variable stays literal. */
368
+ export function substituteVars(text: string, vars: Record<string, string | undefined>): string {
369
+ return text.replaceAll(/\$\{(CLAUDE_[A-Z0-9_]+)\}/g, (token, name: string) => vars[name] ?? token)
166
370
  }
167
371
 
168
372
  /** `a/b/c.md` becomes Claude's `a:b:c`. */
@@ -192,23 +396,150 @@ export function discoverCommandFiles(root: string): DiscoveredCommand[] {
192
396
 
193
397
  export type CommandExec = (command: string) => Promise<{ stdout: string; stderr: string; code: number }>
194
398
 
195
- /** Spans of a body that are inside a fenced code block, where Claude's dynamic
196
- * syntax is literal text rather than an instruction. */
197
- function fencedRanges(body: string): Array<[number, number]> {
198
- const ranges: Array<[number, number]> = []
199
- const fence = /^(```|~~~)[^\n]*$/gm
200
- let open: number | undefined
399
+ /** PowerShell single-quote escaping: inside a '...' literal the only special
400
+ * characters are the quote delimiters themselves, written doubled. PowerShell's
401
+ * lexer treats U+2018 through U+201B as single quotes too, so each is doubled the
402
+ * same way; leaving them bare let a projectDir like `Alex’s Projects` end the
403
+ * literal mid-path with a ParserError. sh's '\'' form must not be used here,
404
+ * since PowerShell would keep the backslash and reopen the string. */
405
+ export function powershellQuote(value: string): string {
406
+ return value.replaceAll(/['‘’‚‛]/g, '$&$&')
407
+ }
408
+
409
+ /** The PowerShell names worth trying: pwsh everywhere it installs, plus the
410
+ * Windows spellings on win32, where powershell.exe ships with the OS. */
411
+ const powershellCandidates = (platform: string): string[] => (platform === 'win32' ? ['pwsh', 'pwsh.exe', 'powershell.exe'] : ['pwsh'])
412
+
413
+ /** First PowerShell binary found on PATH, or undefined when none is installed. */
414
+ export function resolvePowershellBinary(platform: string = process.platform, env: Record<string, string | undefined> = process.env): string | undefined {
415
+ const dirs = (env.PATH ?? '').split(path.delimiter).filter(Boolean)
416
+ for (const candidate of powershellCandidates(platform)) {
417
+ for (const dir of dirs) {
418
+ const full = path.join(dir, candidate)
419
+ try {
420
+ fs.accessSync(full, fs.constants.X_OK)
421
+ if (fs.statSync(full).isFile()) return full
422
+ } catch {
423
+ // not here; keep looking
424
+ }
425
+ }
426
+ }
427
+ return undefined
428
+ }
429
+
430
+ export interface SpanExec {
431
+ command: string
432
+ args: string[]
433
+ /** Set when the shell cannot merge stderr into stdout in-script (pwsh 7 drops a
434
+ * native command's stderr from `& { } 2>&1`), asking the caller to append the
435
+ * exec result's stderr to its stdout instead. The sh path merges in-script and
436
+ * leaves this unset. */
437
+ mergeStreams?: boolean
438
+ }
439
+
440
+ /**
441
+ * The exec invocation for one injected span, honoring the `shell:` frontmatter.
442
+ * The default (absent or `bash`) runs through /bin/sh; `powershell` resolves a
443
+ * PowerShell binary and runs the span with -Command, falling back to /bin/sh when
444
+ * none is installed so the command still works, per Claude's shell matrix. Both
445
+ * paths export CLAUDE_PROJECT_DIR (each shell's own quoting) and merge stderr
446
+ * into stdout, as the Bash tool does when it runs these for Claude: the sh script
447
+ * in-line with 2>&1, the pwsh path via mergeStreams in the caller.
448
+ *
449
+ * The resolver is a parameter rather than a default so the caller passes its own
450
+ * imported binding, which keeps the lookup mockable in tests.
451
+ */
452
+ export function spanExec(shell: string | undefined, projectDir: string, script: string, resolveBinary: () => string | undefined): SpanExec {
453
+ if (shell === 'powershell') {
454
+ const binary = resolveBinary()
455
+ if (binary !== undefined) {
456
+ const preamble = `$ErrorActionPreference='Continue'\n$env:CLAUDE_PROJECT_DIR='${powershellQuote(projectDir)}'`
457
+ // No in-script 2>&1: under pwsh 7 it does not merge a native command's
458
+ // stderr on a script block, so mergeStreams has the caller append it. The
459
+ // trailing exit forwards a failed native command's code, which pwsh
460
+ // -Command otherwise swallows (the process exited 0 and a failure never
461
+ // aborted the invocation). An empty or cmdlet-only span leaves
462
+ // $LASTEXITCODE unset and exits 0. Residual gap vs sh: a failing cmdlet
463
+ // sets no exit code, so it cannot abort; its error text still reaches the
464
+ // model through the merged stderr.
465
+ return { command: binary, args: ['-NoProfile', '-NonInteractive', '-Command', `${preamble}\n& {\n${script}\n}\nexit $LASTEXITCODE`], mergeStreams: true }
466
+ }
467
+ }
468
+ const quoted = projectDir.replaceAll("'", String.raw`'\''`)
469
+ // The group opens with a `:` null command: `{ }` around an empty or
470
+ // comment-only span is a hard sh syntax error (exit 2) that aborted the whole
471
+ // invocation, and `:` keeps such a span the harmless no-op it was on HEAD
472
+ // while the group still merges stderr for real spans.
473
+ return { command: '/bin/sh', args: ['-c', `export CLAUDE_PROJECT_DIR='${quoted}'\n{ :\n${script}\n} 2>&1`] }
474
+ }
475
+
476
+ interface FenceBlock {
477
+ start: number
478
+ end: number
479
+ /** A fence opened with ```! runs its content as one script; any other fence protects. */
480
+ exec: boolean
481
+ content: string
482
+ }
483
+
484
+ /** Fenced blocks of a body: Claude's dynamic syntax is literal text inside a plain
485
+ * fence, while a ```! fence is itself a placeholder that executes. */
486
+ function fenceBlocks(body: string): FenceBlock[] {
487
+ const blocks: FenceBlock[] = []
488
+ const fence = /^(```|~~~)([^\n]*)$/gm
489
+ let open: { index: number; exec: boolean; contentStart: number } | undefined
201
490
  let match = fence.exec(body)
202
491
  while (match !== null) {
203
- if (open === undefined) open = match.index
204
- else {
205
- ranges.push([open, match.index + match[0].length])
492
+ if (open === undefined) {
493
+ open = { index: match.index, exec: match[1] === '```' && match[2].trim() === '!', contentStart: match.index + match[0].length + 1 }
494
+ } else {
495
+ blocks.push({ start: open.index, end: match.index + match[0].length, exec: open.exec, content: body.slice(Math.min(open.contentStart, match.index), match.index).replace(/\n$/, '') })
206
496
  open = undefined
207
497
  }
208
498
  match = fence.exec(body)
209
499
  }
210
- if (open !== undefined) ranges.push([open, body.length])
211
- return ranges
500
+ // An unterminated fence protects to the end of the body rather than executing.
501
+ if (open !== undefined) blocks.push({ start: open.index, end: body.length, exec: false, content: '' })
502
+ return blocks
503
+ }
504
+
505
+ /** Spans of a body inside a protective fenced code block. */
506
+ function fencedRanges(body: string): Array<[number, number]> {
507
+ return fenceBlocks(body)
508
+ .filter((block) => !block.exec)
509
+ .map((block) => [block.start, block.end])
510
+ }
511
+
512
+ /** Exit 1 is a normal result for Claude's documented search and comparison commands
513
+ * (no matches, files differ); exit 2 and up fails even for these. */
514
+ const EXIT_ONE_OK = new Set(['grep', 'rg', 'egrep', 'fgrep', 'find', 'diff', 'test', '['])
515
+
516
+ const isCarveoutSegment = (segment: string): boolean => {
517
+ const words = segment.trim().split(/\s+/)
518
+ if (words[0] === 'git') return words[1] === 'diff' || words[1] === 'grep'
519
+ return EXIT_ONE_OK.has(words[0])
520
+ }
521
+
522
+ function benignExitOne(command: string): boolean {
523
+ const segments = splitSegments(command)
524
+ if (segments.length === 0) return false
525
+ // A `&&`/`||` chain can short-circuit, so an earlier segment's exit 1 becomes the
526
+ // result and the last segment is not the one that set the code: `cd nope && grep x`
527
+ // exits 1 from cd, not a benign grep miss. Only when every segment is a carveout is
528
+ // the exit benign whichever ran last. Without short-circuit operators the exit is
529
+ // the last segment's (a `|` pipeline exits with its final command, `;`/newline with
530
+ // the last statement), so the last segment decides.
531
+ if (/&&|\|\|/.test(command)) return segments.every(isCarveoutSegment)
532
+ return isCarveoutSegment(segments.at(-1) ?? '')
533
+ }
534
+
535
+ /** Run one injected span. A failure aborts the whole invocation, as Claude
536
+ * documents: the model never sees a half-expanded body. */
537
+ async function runSpan(exec: CommandExec, command: string, pattern: string): Promise<string> {
538
+ const result = await exec(command)
539
+ if (result.code !== 0 && !(result.code === 1 && benignExitOne(command))) {
540
+ throw new Error(`Shell command failed for pattern "${pattern}"\n[stderr]\n${(result.stderr || result.stdout).trim()}`)
541
+ }
542
+ return result.stdout.trimEnd()
212
543
  }
213
544
 
214
545
  const inRanges = (ranges: Array<[number, number]>, index: number): boolean => ranges.some(([start, end]) => index >= start && index < end)
@@ -231,37 +562,63 @@ function readReference(cwd: string, reference: string): string | undefined {
231
562
  }
232
563
  }
233
564
 
565
+ interface DynamicSpan {
566
+ start: number
567
+ end: number
568
+ run: () => Promise<string>
569
+ }
570
+
234
571
  /** Claude's dynamic command content: `` !`cmd` `` runs a shell command and pastes
235
- * its output, `@path` inlines a file. Both are skipped inside fenced code blocks. */
572
+ * its output (recognized only at a word start), a ```! fenced block runs its lines
573
+ * as one script, and `@path` inlines a file. Inline spans and `@` refs are skipped
574
+ * inside plain fenced code blocks. A failed command rejects, aborting the
575
+ * invocation, per the skills docs.
576
+ *
577
+ * Every placeholder is located in the ORIGINAL body and the whole body is expanded
578
+ * in one pass, so a command's output (or a file's content) is inserted verbatim and
579
+ * never re-scanned for further placeholders. Re-scanning was both a parity break
580
+ * (Claude expands once) and a command-injection path: output of a `` ```! `` block
581
+ * such as a commit message could smuggle its own `` !`cmd` `` for a later pass. */
236
582
  export async function expandDynamicContent(body: string, cwd: string, exec: CommandExec): Promise<string> {
237
- const fenced = fencedRanges(body)
238
-
239
- const commands: Array<{ span: string; command: string; index: number }> = []
240
- const bashPattern = /!`([^`]+)`/g
241
- let bashMatch = bashPattern.exec(body)
242
- while (bashMatch !== null) {
243
- if (!inRanges(fenced, bashMatch.index)) commands.push({ span: bashMatch[0], command: bashMatch[1], index: bashMatch.index })
244
- bashMatch = bashPattern.exec(body)
583
+ const blocks = fenceBlocks(body)
584
+ const protectedRanges = blocks.filter((block) => !block.exec).map((block): [number, number] => [block.start, block.end])
585
+ const execRanges = blocks.filter((block) => block.exec).map((block): [number, number] => [block.start, block.end])
586
+ // An inline span or @ ref inside a ```! block is part of that block's script, not a
587
+ // placeholder of its own; the block already covers those bytes.
588
+ const literal = (index: number): boolean => inRanges(protectedRanges, index) || inRanges(execRanges, index)
589
+
590
+ const spans: DynamicSpan[] = []
591
+ for (const block of blocks) {
592
+ if (block.exec) spans.push({ start: block.start, end: block.end, run: () => runSpan(exec, block.content, '```!') })
593
+ }
594
+ // `!` counts only at the start of a line or after whitespace; `KEY=!`cmd`` is literal.
595
+ const bashPattern = /(^|\s)!`([^`]+)`/g
596
+ for (let m = bashPattern.exec(body); m !== null; m = bashPattern.exec(body)) {
597
+ if (literal(m.index)) continue
598
+ const [span, lead, command] = m
599
+ spans.push({ start: m.index, end: m.index + span.length, run: async () => lead + (await runSpan(exec, command, `!\`${command}\``)) })
600
+ }
601
+ const atPattern = /(^|\s)@(\S+)/g
602
+ for (let m = atPattern.exec(body); m !== null; m = atPattern.exec(body)) {
603
+ if (literal(m.index)) continue
604
+ const [whole, lead, reference] = m
605
+ spans.push({
606
+ start: m.index,
607
+ end: m.index + whole.length,
608
+ run: async () => {
609
+ const content = readReference(cwd, reference)
610
+ return content === undefined ? whole : `${lead}\n<file path="${reference}">\n${content.trimEnd()}\n</file>\n`
611
+ },
612
+ })
245
613
  }
246
614
 
247
- // Splice by recorded position: a textual replace would interpret `$` sequences in
248
- // the command's output and could hit an identical fenced copy of the span instead.
249
- let expanded = ''
615
+ spans.sort((a, b) => a.start - b.start)
616
+ let out = ''
250
617
  let cursor = 0
251
- for (const entry of commands) {
252
- const result = await exec(entry.command)
253
- const output = result.code === 0 ? result.stdout.trimEnd() : `(command failed: ${entry.command})\n${result.stderr.trim() || result.stdout.trim()}`
254
- expanded += body.slice(cursor, entry.index) + output
255
- cursor = entry.index + entry.span.length
618
+ for (const span of spans) {
619
+ if (span.start < cursor) continue // a rare @/inline overlap: keep the first, skip the nested
620
+ out += body.slice(cursor, span.start) + (await span.run())
621
+ cursor = span.end
256
622
  }
257
- expanded += body.slice(cursor)
258
-
259
- // Ranges are recomputed: command output can change offsets.
260
- const fencedAfter = fencedRanges(expanded)
261
- return expanded.replaceAll(/(^|\s)@(\S+)/g, (whole, lead: string, reference: string, offset: number) => {
262
- if (inRanges(fencedAfter, offset)) return whole
263
- const content = readReference(cwd, reference)
264
- if (content === undefined) return whole
265
- return `${lead}\n<file path="${reference}">\n${content.trimEnd()}\n</file>\n`
266
- })
623
+ return out + body.slice(cursor)
267
624
  }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * HTML to markdown conversion for web_fetch, mirroring Claude's WebFetch, which
3
+ * converts pages to markdown before the model reads them.
4
+ *
5
+ * A regex pipeline, not a DOM: pi ships no HTML parser and the output is prose
6
+ * for a model, not a rendering. Every pattern bounds its tag matches with
7
+ * [^<>]* so a failed match stops at the next tag instead of rescanning to the
8
+ * end of input, keeping the pass linear on hostile pages.
9
+ */
10
+
11
+ const NAMED_ENTITIES: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' }
12
+
13
+ function decodeAllEntities(text: string): string {
14
+ return text.replace(/&(?:#x([0-9a-fA-F]+)|#(\d+)|(amp|lt|gt|quot|apos|nbsp));/g, (token, hex?: string, dec?: string, named?: string) => {
15
+ if (named) return NAMED_ENTITIES[named] ?? token
16
+ const code = hex ? Number.parseInt(hex, 16) : Number(dec)
17
+ return Number.isFinite(code) && code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : token
18
+ })
19
+ }
20
+
21
+ const stripInnerTags = (html: string): string => html.replace(/<[^<>]*>/g, '')
22
+
23
+ // Strip leading and trailing newline runs in linear time. The equivalent
24
+ // /^\n+|\n+$/g backtracks super-linearly on a long run of newlines (S8786).
25
+ const trimNewlines = (value: string): string => {
26
+ let start = 0
27
+ let end = value.length
28
+ while (start < end && value[start] === '\n') start++
29
+ while (end > start && value[end - 1] === '\n') end--
30
+ return value.slice(start, end)
31
+ }
32
+
33
+ export function htmlToMarkdown(html: string): string {
34
+ // Pre blocks are lifted out first so no later transform touches their content.
35
+ const preBodies: string[] = []
36
+ let work = html
37
+ .replace(/<!--[\s\S]*?-->/g, ' ')
38
+ .replace(/<(script|style|noscript|head|svg)\b[^<>]*>[\s\S]*?<\/\1[^<>]*>/gi, ' ')
39
+ .replace(/<pre\b[^<>]*>([\s\S]*?)<\/pre>/gi, (_whole, inner: string) => {
40
+ preBodies.push(trimNewlines(decodeAllEntities(stripInnerTags(inner))))
41
+ return `\n\n\uE000PRE${preBodies.length - 1}\uE000\n\n`
42
+ })
43
+
44
+ work = work
45
+ .replace(/<code\b[^<>]*>([\s\S]*?)<\/code>/gi, (_whole, inner: string) => `\`${stripInnerTags(inner)}\``)
46
+ // Only real web links become markdown links; fragment and javascript hrefs
47
+ // keep their label and lose the target.
48
+ .replace(/<a\b[^<>]*?href=(?:"([^"]*)"|'([^']*)')[^<>]*>([\s\S]*?)<\/a>/gi, (_whole, dq: string | undefined, sq: string | undefined, inner: string) => {
49
+ const href = decodeAllEntities(dq ?? sq ?? '')
50
+ const label = stripInnerTags(inner).trim()
51
+ if (!label) return ' '
52
+ return /^https?:\/\//i.test(href) ? `[${label}](${href})` : label
53
+ })
54
+ .replace(/<(strong|b)\b[^<>]*>([\s\S]*?)<\/\1>/gi, (_whole, _tag, inner: string) => `**${stripInnerTags(inner).trim()}**`)
55
+ .replace(/<(em|i)\b[^<>]*>([\s\S]*?)<\/\1>/gi, (_whole, _tag, inner: string) => `*${stripInnerTags(inner).trim()}*`)
56
+ .replace(/<h([1-6])\b[^<>]*>([\s\S]*?)<\/h\1>/gi, (_whole, level: string, inner: string) => `\n\n${'#'.repeat(Number(level))} ${stripInnerTags(inner).trim()}\n\n`)
57
+ .replace(/<img\b[^<>]*?alt=(?:"([^"]*)"|'([^']*)')[^<>]*>/gi, (_whole, dq?: string, sq?: string) => dq ?? sq ?? '')
58
+ .replace(/<li\b[^<>]*>/gi, '\n- ')
59
+ .replace(/<blockquote\b[^<>]*>/gi, '\n\n> ')
60
+ .replace(/<\/(?:td|th)>/gi, ' | ')
61
+ .replace(/<(?:br|hr)\b[^<>]*>/gi, '\n')
62
+ .replace(/<\/(?:p|div|section|article|ul|ol|li|table|tr|blockquote|tbody|thead|header|footer|main|nav)[^<>]*>/gi, '\n\n')
63
+
64
+ const text = decodeAllEntities(work.replace(/<[^<>]*>/g, ''))
65
+ .replace(/[ \t]+/g, ' ')
66
+ .replace(/ ?\n ?/g, '\n')
67
+ .replace(/\n{3,}/g, '\n\n')
68
+ .trim()
69
+
70
+ return text.replace(/\uE000PRE(\d+)\uE000/g, (_whole, index: string) => `\`\`\`\n${preBodies[Number(index)]}\n\`\`\``)
71
+ }