pi-code 1.0.3 → 1.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -13
- package/extensions/claude-rules.ts +158 -54
- package/extensions/commands.ts +185 -27
- package/extensions/context-imports.ts +358 -41
- package/extensions/git-checkpoint.ts +22 -1
- package/extensions/hooks.ts +397 -79
- package/extensions/init.ts +81 -0
- package/extensions/internal/agent-run.ts +42 -0
- package/extensions/internal/bash-rules.ts +27 -0
- package/extensions/internal/command-file.ts +377 -53
- package/extensions/internal/html-markdown.ts +61 -0
- package/extensions/internal/instruction-events.ts +70 -0
- package/extensions/internal/managed-settings.ts +38 -0
- package/extensions/internal/mcp-call.ts +28 -0
- package/extensions/internal/mcp-oauth.ts +171 -0
- package/extensions/internal/model-complete.ts +68 -0
- package/extensions/internal/path-rules.ts +80 -0
- package/extensions/internal/plugins.ts +125 -0
- package/extensions/internal/project-approval.ts +2 -3
- package/extensions/internal/project-root.ts +78 -0
- package/extensions/internal/shell-split.ts +65 -0
- package/extensions/internal/strip-comments.ts +77 -0
- package/extensions/internal/web-transport.ts +3 -1
- package/extensions/mcp.ts +290 -31
- package/extensions/memory.ts +168 -23
- package/extensions/notify.ts +78 -5
- package/extensions/output-styles.ts +34 -6
- package/extensions/plan-mode/index.ts +55 -9
- package/extensions/plan-mode/utils.ts +3 -57
- package/extensions/question.ts +2 -2
- package/extensions/skills.ts +11 -1
- package/extensions/status-line.ts +97 -4
- package/extensions/subagent/agents.ts +72 -61
- package/extensions/subagent/background.ts +114 -25
- package/extensions/subagent/index.ts +227 -44
- package/extensions/web.ts +87 -16
- package/package.json +1 -1
|
@@ -14,19 +14,34 @@ 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
|
+
|
|
17
19
|
export interface ParsedCommand {
|
|
18
20
|
description: string
|
|
19
21
|
argumentHint?: string
|
|
20
22
|
allowedTools?: string[]
|
|
23
|
+
/** Claude `Bash(...)` specifiers, present only when every bash grant is scoped. */
|
|
24
|
+
bashRules?: string[]
|
|
25
|
+
/** Claude path rules per pi file tool, from Read(...)/Edit(...)/Write(...) grants. */
|
|
26
|
+
pathRules?: Partial<Record<'read' | 'edit' | 'write', string[]>>
|
|
27
|
+
/** Names from the `arguments:` frontmatter list, mapped to positions in order. */
|
|
28
|
+
argumentNames?: string[]
|
|
29
|
+
/** Tools removed from the pool while the command's turn runs. */
|
|
30
|
+
disallowedTools?: string[]
|
|
31
|
+
/** `shell:` frontmatter: `bash` (the default) or `powershell`, choosing how the
|
|
32
|
+
* command's injected spans run (see spanExec). */
|
|
33
|
+
shell?: string
|
|
21
34
|
model?: string
|
|
22
35
|
disableModelInvocation: boolean
|
|
23
36
|
body: string
|
|
24
37
|
}
|
|
25
38
|
|
|
26
39
|
export interface DiscoveredCommand {
|
|
27
|
-
/** Claude's namespaced name: a nested file is `dir:name`. */
|
|
40
|
+
/** Claude's namespaced name: a nested file is `dir:name`, a plugin's is `plugin:name`. */
|
|
28
41
|
name: string
|
|
29
42
|
filePath: string
|
|
43
|
+
/** Set for plugin commands, carrying the ${CLAUDE_PLUGIN_*} and ${user_config.*} substitution sources. */
|
|
44
|
+
plugin?: { root: string; dataDir: string; userConfig?: Record<string, string> }
|
|
30
45
|
}
|
|
31
46
|
|
|
32
47
|
/** Claude tool names are PascalCase and do not all exist in pi: `Glob` is pi's
|
|
@@ -54,11 +69,11 @@ const CLAUDE_TOOL_MAP: Record<string, string> = {
|
|
|
54
69
|
}
|
|
55
70
|
|
|
56
71
|
/**
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
72
|
+
* The pi tool name for one grant entry, scope and all: `Bash(git add:*)` is `bash`.
|
|
73
|
+
* Keeping the scope in the name matched nothing when the list was intersected with
|
|
74
|
+
* the active tools, which left a command declaring only scoped grants running with
|
|
75
|
+
* no tools at all. The scope itself is not dropped: parseToolGrants keeps bash
|
|
76
|
+
* scopes for call-time enforcement.
|
|
62
77
|
*/
|
|
63
78
|
export function normalizeToolName(name: string): string {
|
|
64
79
|
const lower = name.trim().toLowerCase()
|
|
@@ -94,19 +109,82 @@ export function toolEntries(raw: string): string[] {
|
|
|
94
109
|
return entries.map((entry) => entry.trim()).filter(Boolean)
|
|
95
110
|
}
|
|
96
111
|
|
|
112
|
+
export interface ToolGrants {
|
|
113
|
+
/** pi tool names to grant, deduplicated in first-seen order. */
|
|
114
|
+
tools: string[]
|
|
115
|
+
/** Claude `Bash(...)` specifiers, present only when every bash grant is scoped:
|
|
116
|
+
* an unscoped `Bash` entry is the wider grant and wins over its scoped siblings. */
|
|
117
|
+
bashRules?: string[]
|
|
118
|
+
/** Claude path rules per pi file tool, absent for a tool with an unscoped grant.
|
|
119
|
+
* Edit scopes govern writes too, as Claude documents; Write scopes are honored
|
|
120
|
+
* rather than Claude's accept-and-warn-then-ignore, which would fail open here. */
|
|
121
|
+
pathRules?: Partial<Record<'read' | 'edit' | 'write', string[]>>
|
|
122
|
+
/** Entries that carried an argument scope, in their original spelling. */
|
|
123
|
+
scopedEntries: string[]
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The pi file tools one Claude path-ruled entry governs. */
|
|
127
|
+
const PATH_RULE_TOOLS: Record<string, Array<'read' | 'edit' | 'write'>> = {
|
|
128
|
+
read: ['read'],
|
|
129
|
+
edit: ['edit', 'write'],
|
|
130
|
+
write: ['write'],
|
|
131
|
+
}
|
|
132
|
+
|
|
97
133
|
/**
|
|
98
134
|
* A tool grant is either a comma-separated string or a YAML list, and the two mean the
|
|
99
135
|
* same thing. An empty list is not the same as an absent one: it says no tools, so it
|
|
100
|
-
* comes back
|
|
136
|
+
* comes back with an empty `tools` array rather than as undefined.
|
|
137
|
+
*
|
|
138
|
+
* Claude scopes a grant to arguments: `Bash(git add:*)` allows exactly those commands.
|
|
139
|
+
* pi's active-tool list is per tool, with no argument dimension, so the base tool is
|
|
140
|
+
* granted and the scope is kept: commands.ts enforces bash scopes at tool_call time,
|
|
141
|
+
* and the subagent's frontmatter parsing rejects a scoped grant it cannot express.
|
|
142
|
+
* A scope on any other tool is dropped, which widens that grant; bash is the one
|
|
143
|
+
* whose widening reaches everything, so it is the one enforced.
|
|
101
144
|
*/
|
|
102
|
-
export function
|
|
145
|
+
export function parseToolGrants(raw: unknown): ToolGrants | undefined {
|
|
103
146
|
if (raw === undefined || raw === null) return undefined
|
|
104
147
|
let items: unknown[]
|
|
105
148
|
if (Array.isArray(raw)) items = raw
|
|
106
149
|
else if (typeof raw === 'string') items = toolEntries(raw)
|
|
107
150
|
else return undefined
|
|
108
151
|
if (items.some((item) => typeof item !== 'string')) return undefined
|
|
109
|
-
|
|
152
|
+
|
|
153
|
+
const tools: string[] = []
|
|
154
|
+
const scopedEntries: string[] = []
|
|
155
|
+
const bashRules: string[] = []
|
|
156
|
+
let bashUnscoped = false
|
|
157
|
+
const pathScopes: Record<'read' | 'edit' | 'write', string[]> = { read: [], edit: [], write: [] }
|
|
158
|
+
const pathUnscoped = new Set<'read' | 'edit' | 'write'>()
|
|
159
|
+
for (const item of items as string[]) {
|
|
160
|
+
const entry = item.trim()
|
|
161
|
+
const name = normalizeToolName(entry)
|
|
162
|
+
if (!name) continue
|
|
163
|
+
if (!tools.includes(name)) tools.push(name)
|
|
164
|
+
const open = entry.indexOf('(')
|
|
165
|
+
if (open === -1) {
|
|
166
|
+
if (name === 'bash') bashUnscoped = true
|
|
167
|
+
for (const tool of PATH_RULE_TOOLS[name] ?? []) pathUnscoped.add(tool)
|
|
168
|
+
continue
|
|
169
|
+
}
|
|
170
|
+
scopedEntries.push(entry)
|
|
171
|
+
const scope = entry.slice(open + 1, entry.endsWith(')') ? -1 : undefined).trim()
|
|
172
|
+
// An empty specifier (`Bash()`, `Read()`) matches nothing and must not read as
|
|
173
|
+
// the unscoped grant it explicitly is not: it is recorded so the tool stays
|
|
174
|
+
// restricted, and the matchers treat an empty rule as matching no input.
|
|
175
|
+
if (name === 'bash') bashRules.push(scope)
|
|
176
|
+
for (const tool of PATH_RULE_TOOLS[name] ?? []) pathScopes[tool].push(scope)
|
|
177
|
+
}
|
|
178
|
+
const pathRules: ToolGrants['pathRules'] = {}
|
|
179
|
+
for (const tool of ['read', 'edit', 'write'] as const) {
|
|
180
|
+
if (!pathUnscoped.has(tool) && pathScopes[tool].length > 0) pathRules[tool] = pathScopes[tool]
|
|
181
|
+
}
|
|
182
|
+
return {
|
|
183
|
+
tools,
|
|
184
|
+
scopedEntries,
|
|
185
|
+
bashRules: !bashUnscoped && bashRules.length > 0 ? bashRules : undefined,
|
|
186
|
+
pathRules: Object.keys(pathRules).length > 0 ? pathRules : undefined,
|
|
187
|
+
}
|
|
110
188
|
}
|
|
111
189
|
|
|
112
190
|
/** YAML types a bare scalar, so a model named `3.5` arrives as a number, not a string. */
|
|
@@ -118,6 +196,22 @@ const text = (value: unknown): string => {
|
|
|
118
196
|
/** Claude writes `argument-hint: [pr]`, which YAML reads as a list; render it back. */
|
|
119
197
|
const hint = (value: unknown): string => (Array.isArray(value) ? `[${value.join(', ')}]` : text(value))
|
|
120
198
|
|
|
199
|
+
const ARGUMENT_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
200
|
+
|
|
201
|
+
/** The `arguments:` frontmatter: a YAML list or a space- or comma-separated string
|
|
202
|
+
* of names mapping to positions in order. Invalid names are dropped, and ARGUMENTS
|
|
203
|
+
* itself is reserved by the built-in placeholder. */
|
|
204
|
+
function parseArgumentNames(raw: unknown): string[] | undefined {
|
|
205
|
+
let items: string[]
|
|
206
|
+
if (Array.isArray(raw)) items = raw.map((entry) => String(entry))
|
|
207
|
+
else if (typeof raw === 'string') items = raw.split(/[\s,]+/)
|
|
208
|
+
else return undefined
|
|
209
|
+
const names = items.map((name) => name.trim()).filter((name) => ARGUMENT_NAME.test(name) && name !== 'ARGUMENTS')
|
|
210
|
+
return names.length > 0 ? names : undefined
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const SHELLS = new Set(['bash', 'powershell'])
|
|
214
|
+
|
|
121
215
|
export function parseCommandFile(content: string): ParsedCommand {
|
|
122
216
|
// pi's own parser, rather than a hand-rolled one: it reads the YAML shapes Claude
|
|
123
217
|
// command files actually use (flow sequences, block lists, quoted and multi-line
|
|
@@ -126,10 +220,18 @@ export function parseCommandFile(content: string): ParsedCommand {
|
|
|
126
220
|
const body = raw.trim()
|
|
127
221
|
const firstLine = body.split('\n').find((line) => line.trim().length > 0) ?? ''
|
|
128
222
|
const disable = frontmatter['disable-model-invocation']
|
|
223
|
+
const grants = parseToolGrants(frontmatter['allowed-tools'])
|
|
224
|
+
const shell = text(frontmatter.shell).toLowerCase()
|
|
129
225
|
return {
|
|
130
226
|
description: text(frontmatter.description) || firstLine.slice(0, 60),
|
|
131
227
|
argumentHint: hint(frontmatter['argument-hint']) || undefined,
|
|
132
|
-
allowedTools:
|
|
228
|
+
allowedTools: grants?.tools,
|
|
229
|
+
bashRules: grants?.bashRules,
|
|
230
|
+
pathRules: grants?.pathRules,
|
|
231
|
+
argumentNames: parseArgumentNames(frontmatter.arguments),
|
|
232
|
+
// A scope on a disallow entry only denies more than asked, so the drop is safe.
|
|
233
|
+
disallowedTools: parseToolGrants(frontmatter['disallowed-tools'])?.tools,
|
|
234
|
+
shell: SHELLS.has(shell) ? shell : undefined,
|
|
133
235
|
model: text(frontmatter.model) || undefined,
|
|
134
236
|
disableModelInvocation: disable === true || text(disable) === 'true',
|
|
135
237
|
body,
|
|
@@ -148,16 +250,80 @@ export function splitArgs(args: string): string[] {
|
|
|
148
250
|
return out
|
|
149
251
|
}
|
|
150
252
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
253
|
+
const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`)
|
|
254
|
+
|
|
255
|
+
/** One alternation covering every argument placeholder plus the two escape forms.
|
|
256
|
+
* Alternation order is load-bearing: escapes first (so `\$1` never expands), the
|
|
257
|
+
* bracketed forms before `$ARGUMENTS` (so `$ARGUMENTS[0]` is not read as the bare
|
|
258
|
+
* placeholder plus literal brackets). `(?!)` never matches, standing in when no
|
|
259
|
+
* names are declared. */
|
|
260
|
+
function argPattern(names: string[]): RegExp {
|
|
261
|
+
const nameAlt = names.length > 0 ? names.map(escapeRegExp).join('|') : '(?!)'
|
|
262
|
+
return new RegExp(
|
|
263
|
+
String.raw`\\{2}(?=\$)` + // doubled backslash: both stay, the token after still expands
|
|
264
|
+
String.raw`|\\\$(?=\d|@|\{|ARGUMENTS\b|(?:${nameAlt})\b)` + // escape: before any placeholder this expands, incl. $@ and ${...:-}
|
|
265
|
+
String.raw`|\$ARGUMENTS\[(\d+)\]` +
|
|
266
|
+
String.raw`|\$\{(\d+):-([^}]*)\}` +
|
|
267
|
+
String.raw`|\$\{ARGUMENTS:-([^}]*)\}` +
|
|
268
|
+
String.raw`|\$ARGUMENTS\b` +
|
|
269
|
+
String.raw`|\$@` +
|
|
270
|
+
String.raw`|\$(\d+)` +
|
|
271
|
+
String.raw`|\$(${nameAlt})\b`,
|
|
272
|
+
'g',
|
|
273
|
+
)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export interface SubstitutedArgs {
|
|
277
|
+
text: string
|
|
278
|
+
/** Whether any placeholder actually read the arguments; drives Claude's
|
|
279
|
+
* `ARGUMENTS: <value>` append when a command never looks at what was passed. */
|
|
280
|
+
consumed: boolean
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Claude's substitutions, per the current skills docs: `$ARGUMENTS`,
|
|
285
|
+
* `$ARGUMENTS[N]` and its `$N` shorthand (0-based: `$0` is the first argument),
|
|
286
|
+
* `$name` for names declared in `arguments:` frontmatter, plus the pi extras `$@`
|
|
287
|
+
* and `${N:-default}`/`${ARGUMENTS:-default}`. An unfilled indexed placeholder
|
|
288
|
+
* stays literal; a declared name with no argument becomes empty; `\$` escapes only
|
|
289
|
+
* a real placeholder and a doubled backslash keeps both while still expanding.
|
|
290
|
+
* One pass with a replacer function: sequential string passes both interpreted
|
|
291
|
+
* `$&`-style metacharacters in the arguments and re-scanned substituted text.
|
|
292
|
+
*/
|
|
293
|
+
export function substituteArgsDetailed(body: string, args: string, names: string[] = []): SubstitutedArgs {
|
|
154
294
|
const parts = splitArgs(args)
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
295
|
+
const all = args.trim()
|
|
296
|
+
let consumed = false
|
|
297
|
+
const fill = (value: string | undefined, orElse: string): string => {
|
|
298
|
+
if (value === undefined) return orElse
|
|
299
|
+
consumed = true
|
|
300
|
+
return value
|
|
301
|
+
}
|
|
302
|
+
const text = body.replaceAll(argPattern(names), (token, bracketIdx?: string, defIdx?: string, defVal?: string, argsDefault?: string, shorthandIdx?: string, name?: string) => {
|
|
303
|
+
if (token === '\\\\') return token
|
|
304
|
+
if (token === '\\$') return '$'
|
|
305
|
+
if (bracketIdx !== undefined) return fill(parts[Number(bracketIdx)], token)
|
|
306
|
+
if (defIdx !== undefined) return fill(parts[Number(defIdx)], defVal ?? '')
|
|
307
|
+
if (argsDefault !== undefined) {
|
|
308
|
+
consumed = true
|
|
309
|
+
return all || argsDefault
|
|
310
|
+
}
|
|
311
|
+
if (shorthandIdx !== undefined) return fill(parts[Number(shorthandIdx)], token)
|
|
312
|
+
if (name !== undefined) return fill(parts[names.indexOf(name)], '')
|
|
313
|
+
consumed = true
|
|
314
|
+
return all // $ARGUMENTS or $@
|
|
315
|
+
})
|
|
316
|
+
return { text, consumed }
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export function substituteArgs(body: string, args: string, names: string[] = []): string {
|
|
320
|
+
return substituteArgsDetailed(body, args, names).text
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Claude's `${CLAUDE_*}` string substitutions. A backslash does not prevent these,
|
|
324
|
+
* per the docs, and an unknown variable stays literal. */
|
|
325
|
+
export function substituteVars(text: string, vars: Record<string, string | undefined>): string {
|
|
326
|
+
return text.replaceAll(/\$\{(CLAUDE_[A-Z0-9_]+)\}/g, (token, name: string) => vars[name] ?? token)
|
|
161
327
|
}
|
|
162
328
|
|
|
163
329
|
/** `a/b/c.md` becomes Claude's `a:b:c`. */
|
|
@@ -187,23 +353,150 @@ export function discoverCommandFiles(root: string): DiscoveredCommand[] {
|
|
|
187
353
|
|
|
188
354
|
export type CommandExec = (command: string) => Promise<{ stdout: string; stderr: string; code: number }>
|
|
189
355
|
|
|
190
|
-
/**
|
|
191
|
-
*
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
356
|
+
/** PowerShell single-quote escaping: inside a '...' literal the only special
|
|
357
|
+
* characters are the quote delimiters themselves, written doubled. PowerShell's
|
|
358
|
+
* lexer treats U+2018 through U+201B as single quotes too, so each is doubled the
|
|
359
|
+
* same way; leaving them bare let a projectDir like `Alex’s Projects` end the
|
|
360
|
+
* literal mid-path with a ParserError. sh's '\'' form must not be used here,
|
|
361
|
+
* since PowerShell would keep the backslash and reopen the string. */
|
|
362
|
+
export function powershellQuote(value: string): string {
|
|
363
|
+
return value.replaceAll(/['‘’‚‛]/g, '$&$&')
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** The PowerShell names worth trying: pwsh everywhere it installs, plus the
|
|
367
|
+
* Windows spellings on win32, where powershell.exe ships with the OS. */
|
|
368
|
+
const powershellCandidates = (platform: string): string[] => (platform === 'win32' ? ['pwsh', 'pwsh.exe', 'powershell.exe'] : ['pwsh'])
|
|
369
|
+
|
|
370
|
+
/** First PowerShell binary found on PATH, or undefined when none is installed. */
|
|
371
|
+
export function resolvePowershellBinary(platform: string = process.platform, env: Record<string, string | undefined> = process.env): string | undefined {
|
|
372
|
+
const dirs = (env.PATH ?? '').split(path.delimiter).filter(Boolean)
|
|
373
|
+
for (const candidate of powershellCandidates(platform)) {
|
|
374
|
+
for (const dir of dirs) {
|
|
375
|
+
const full = path.join(dir, candidate)
|
|
376
|
+
try {
|
|
377
|
+
fs.accessSync(full, fs.constants.X_OK)
|
|
378
|
+
if (fs.statSync(full).isFile()) return full
|
|
379
|
+
} catch {
|
|
380
|
+
// not here; keep looking
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return undefined
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export interface SpanExec {
|
|
388
|
+
command: string
|
|
389
|
+
args: string[]
|
|
390
|
+
/** Set when the shell cannot merge stderr into stdout in-script (pwsh 7 drops a
|
|
391
|
+
* native command's stderr from `& { } 2>&1`), asking the caller to append the
|
|
392
|
+
* exec result's stderr to its stdout instead. The sh path merges in-script and
|
|
393
|
+
* leaves this unset. */
|
|
394
|
+
mergeStreams?: boolean
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* The exec invocation for one injected span, honoring the `shell:` frontmatter.
|
|
399
|
+
* The default (absent or `bash`) runs through /bin/sh; `powershell` resolves a
|
|
400
|
+
* PowerShell binary and runs the span with -Command, falling back to /bin/sh when
|
|
401
|
+
* none is installed so the command still works, per Claude's shell matrix. Both
|
|
402
|
+
* paths export CLAUDE_PROJECT_DIR (each shell's own quoting) and merge stderr
|
|
403
|
+
* into stdout, as the Bash tool does when it runs these for Claude: the sh script
|
|
404
|
+
* in-line with 2>&1, the pwsh path via mergeStreams in the caller.
|
|
405
|
+
*
|
|
406
|
+
* The resolver is a parameter rather than a default so the caller passes its own
|
|
407
|
+
* imported binding, which keeps the lookup mockable in tests.
|
|
408
|
+
*/
|
|
409
|
+
export function spanExec(shell: string | undefined, projectDir: string, script: string, resolveBinary: () => string | undefined): SpanExec {
|
|
410
|
+
if (shell === 'powershell') {
|
|
411
|
+
const binary = resolveBinary()
|
|
412
|
+
if (binary !== undefined) {
|
|
413
|
+
const preamble = `$ErrorActionPreference='Continue'\n$env:CLAUDE_PROJECT_DIR='${powershellQuote(projectDir)}'`
|
|
414
|
+
// No in-script 2>&1: under pwsh 7 it does not merge a native command's
|
|
415
|
+
// stderr on a script block, so mergeStreams has the caller append it. The
|
|
416
|
+
// trailing exit forwards a failed native command's code, which pwsh
|
|
417
|
+
// -Command otherwise swallows (the process exited 0 and a failure never
|
|
418
|
+
// aborted the invocation). An empty or cmdlet-only span leaves
|
|
419
|
+
// $LASTEXITCODE unset and exits 0. Residual gap vs sh: a failing cmdlet
|
|
420
|
+
// sets no exit code, so it cannot abort; its error text still reaches the
|
|
421
|
+
// model through the merged stderr.
|
|
422
|
+
return { command: binary, args: ['-NoProfile', '-NonInteractive', '-Command', `${preamble}\n& {\n${script}\n}\nexit $LASTEXITCODE`], mergeStreams: true }
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
const quoted = projectDir.replaceAll("'", String.raw`'\''`)
|
|
426
|
+
// The group opens with a `:` null command: `{ }` around an empty or
|
|
427
|
+
// comment-only span is a hard sh syntax error (exit 2) that aborted the whole
|
|
428
|
+
// invocation, and `:` keeps such a span the harmless no-op it was on HEAD
|
|
429
|
+
// while the group still merges stderr for real spans.
|
|
430
|
+
return { command: '/bin/sh', args: ['-c', `export CLAUDE_PROJECT_DIR='${quoted}'\n{ :\n${script}\n} 2>&1`] }
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
interface FenceBlock {
|
|
434
|
+
start: number
|
|
435
|
+
end: number
|
|
436
|
+
/** A fence opened with ```! runs its content as one script; any other fence protects. */
|
|
437
|
+
exec: boolean
|
|
438
|
+
content: string
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Fenced blocks of a body: Claude's dynamic syntax is literal text inside a plain
|
|
442
|
+
* fence, while a ```! fence is itself a placeholder that executes. */
|
|
443
|
+
function fenceBlocks(body: string): FenceBlock[] {
|
|
444
|
+
const blocks: FenceBlock[] = []
|
|
445
|
+
const fence = /^(```|~~~)([^\n]*)$/gm
|
|
446
|
+
let open: { index: number; exec: boolean; contentStart: number } | undefined
|
|
196
447
|
let match = fence.exec(body)
|
|
197
448
|
while (match !== null) {
|
|
198
|
-
if (open === undefined)
|
|
199
|
-
|
|
200
|
-
|
|
449
|
+
if (open === undefined) {
|
|
450
|
+
open = { index: match.index, exec: match[1] === '```' && match[2].trim() === '!', contentStart: match.index + match[0].length + 1 }
|
|
451
|
+
} else {
|
|
452
|
+
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$/, '') })
|
|
201
453
|
open = undefined
|
|
202
454
|
}
|
|
203
455
|
match = fence.exec(body)
|
|
204
456
|
}
|
|
205
|
-
|
|
206
|
-
|
|
457
|
+
// An unterminated fence protects to the end of the body rather than executing.
|
|
458
|
+
if (open !== undefined) blocks.push({ start: open.index, end: body.length, exec: false, content: '' })
|
|
459
|
+
return blocks
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** Spans of a body inside a protective fenced code block. */
|
|
463
|
+
function fencedRanges(body: string): Array<[number, number]> {
|
|
464
|
+
return fenceBlocks(body)
|
|
465
|
+
.filter((block) => !block.exec)
|
|
466
|
+
.map((block) => [block.start, block.end])
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** Exit 1 is a normal result for Claude's documented search and comparison commands
|
|
470
|
+
* (no matches, files differ); exit 2 and up fails even for these. */
|
|
471
|
+
const EXIT_ONE_OK = new Set(['grep', 'rg', 'egrep', 'fgrep', 'find', 'diff', 'test', '['])
|
|
472
|
+
|
|
473
|
+
const isCarveoutSegment = (segment: string): boolean => {
|
|
474
|
+
const words = segment.trim().split(/\s+/)
|
|
475
|
+
if (words[0] === 'git') return words[1] === 'diff' || words[1] === 'grep'
|
|
476
|
+
return EXIT_ONE_OK.has(words[0])
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function benignExitOne(command: string): boolean {
|
|
480
|
+
const segments = splitSegments(command)
|
|
481
|
+
if (segments.length === 0) return false
|
|
482
|
+
// A `&&`/`||` chain can short-circuit, so an earlier segment's exit 1 becomes the
|
|
483
|
+
// result and the last segment is not the one that set the code: `cd nope && grep x`
|
|
484
|
+
// exits 1 from cd, not a benign grep miss. Only when every segment is a carveout is
|
|
485
|
+
// the exit benign whichever ran last. Without short-circuit operators the exit is
|
|
486
|
+
// the last segment's (a `|` pipeline exits with its final command, `;`/newline with
|
|
487
|
+
// the last statement), so the last segment decides.
|
|
488
|
+
if (/&&|\|\|/.test(command)) return segments.every(isCarveoutSegment)
|
|
489
|
+
return isCarveoutSegment(segments.at(-1) ?? '')
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** Run one injected span. A failure aborts the whole invocation, as Claude
|
|
493
|
+
* documents: the model never sees a half-expanded body. */
|
|
494
|
+
async function runSpan(exec: CommandExec, command: string, pattern: string): Promise<string> {
|
|
495
|
+
const result = await exec(command)
|
|
496
|
+
if (result.code !== 0 && !(result.code === 1 && benignExitOne(command))) {
|
|
497
|
+
throw new Error(`Shell command failed for pattern "${pattern}"\n[stderr]\n${(result.stderr || result.stdout).trim()}`)
|
|
498
|
+
}
|
|
499
|
+
return result.stdout.trimEnd()
|
|
207
500
|
}
|
|
208
501
|
|
|
209
502
|
const inRanges = (ranges: Array<[number, number]>, index: number): boolean => ranges.some(([start, end]) => index >= start && index < end)
|
|
@@ -226,32 +519,63 @@ function readReference(cwd: string, reference: string): string | undefined {
|
|
|
226
519
|
}
|
|
227
520
|
}
|
|
228
521
|
|
|
522
|
+
interface DynamicSpan {
|
|
523
|
+
start: number
|
|
524
|
+
end: number
|
|
525
|
+
run: () => Promise<string>
|
|
526
|
+
}
|
|
527
|
+
|
|
229
528
|
/** Claude's dynamic command content: `` !`cmd` `` runs a shell command and pastes
|
|
230
|
-
* its output
|
|
529
|
+
* its output (recognized only at a word start), a ```! fenced block runs its lines
|
|
530
|
+
* as one script, and `@path` inlines a file. Inline spans and `@` refs are skipped
|
|
531
|
+
* inside plain fenced code blocks. A failed command rejects, aborting the
|
|
532
|
+
* invocation, per the skills docs.
|
|
533
|
+
*
|
|
534
|
+
* Every placeholder is located in the ORIGINAL body and the whole body is expanded
|
|
535
|
+
* in one pass, so a command's output (or a file's content) is inserted verbatim and
|
|
536
|
+
* never re-scanned for further placeholders. Re-scanning was both a parity break
|
|
537
|
+
* (Claude expands once) and a command-injection path: output of a `` ```! `` block
|
|
538
|
+
* such as a commit message could smuggle its own `` !`cmd` `` for a later pass. */
|
|
231
539
|
export async function expandDynamicContent(body: string, cwd: string, exec: CommandExec): Promise<string> {
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
if (!inRanges(fenced, bashMatch.index)) commands.push({ span: bashMatch[0], command: bashMatch[1], index: bashMatch.index })
|
|
239
|
-
bashMatch = bashPattern.exec(body)
|
|
240
|
-
}
|
|
540
|
+
const blocks = fenceBlocks(body)
|
|
541
|
+
const protectedRanges = blocks.filter((block) => !block.exec).map((block): [number, number] => [block.start, block.end])
|
|
542
|
+
const execRanges = blocks.filter((block) => block.exec).map((block): [number, number] => [block.start, block.end])
|
|
543
|
+
// An inline span or @ ref inside a ```! block is part of that block's script, not a
|
|
544
|
+
// placeholder of its own; the block already covers those bytes.
|
|
545
|
+
const literal = (index: number): boolean => inRanges(protectedRanges, index) || inRanges(execRanges, index)
|
|
241
546
|
|
|
242
|
-
|
|
243
|
-
for (const
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
547
|
+
const spans: DynamicSpan[] = []
|
|
548
|
+
for (const block of blocks) {
|
|
549
|
+
if (block.exec) spans.push({ start: block.start, end: block.end, run: () => runSpan(exec, block.content, '```!') })
|
|
550
|
+
}
|
|
551
|
+
// `!` counts only at the start of a line or after whitespace; `KEY=!`cmd`` is literal.
|
|
552
|
+
const bashPattern = /(^|\s)!`([^`]+)`/g
|
|
553
|
+
for (let m = bashPattern.exec(body); m !== null; m = bashPattern.exec(body)) {
|
|
554
|
+
if (literal(m.index)) continue
|
|
555
|
+
const [span, lead, command] = m
|
|
556
|
+
spans.push({ start: m.index, end: m.index + span.length, run: async () => lead + (await runSpan(exec, command, `!\`${command}\``)) })
|
|
557
|
+
}
|
|
558
|
+
const atPattern = /(^|\s)@(\S+)/g
|
|
559
|
+
for (let m = atPattern.exec(body); m !== null; m = atPattern.exec(body)) {
|
|
560
|
+
if (literal(m.index)) continue
|
|
561
|
+
const [whole, lead, reference] = m
|
|
562
|
+
spans.push({
|
|
563
|
+
start: m.index,
|
|
564
|
+
end: m.index + whole.length,
|
|
565
|
+
run: async () => {
|
|
566
|
+
const content = readReference(cwd, reference)
|
|
567
|
+
return content === undefined ? whole : `${lead}\n<file path="${reference}">\n${content.trimEnd()}\n</file>\n`
|
|
568
|
+
},
|
|
569
|
+
})
|
|
247
570
|
}
|
|
248
571
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
}
|
|
572
|
+
spans.sort((a, b) => a.start - b.start)
|
|
573
|
+
let out = ''
|
|
574
|
+
let cursor = 0
|
|
575
|
+
for (const span of spans) {
|
|
576
|
+
if (span.start < cursor) continue // a rare @/inline overlap: keep the first, skip the nested
|
|
577
|
+
out += body.slice(cursor, span.start) + (await span.run())
|
|
578
|
+
cursor = span.end
|
|
579
|
+
}
|
|
580
|
+
return out + body.slice(cursor)
|
|
257
581
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
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
|
+
export function htmlToMarkdown(html: string): string {
|
|
24
|
+
// Pre blocks are lifted out first so no later transform touches their content.
|
|
25
|
+
const preBodies: string[] = []
|
|
26
|
+
let work = html
|
|
27
|
+
.replace(/<!--[\s\S]*?-->/g, ' ')
|
|
28
|
+
.replace(/<(script|style|noscript|head|svg)\b[^<>]*>[\s\S]*?<\/\1[^<>]*>/gi, ' ')
|
|
29
|
+
.replace(/<pre\b[^<>]*>([\s\S]*?)<\/pre>/gi, (_whole, inner: string) => {
|
|
30
|
+
preBodies.push(decodeAllEntities(stripInnerTags(inner)).replace(/^\n+|\n+$/g, ''))
|
|
31
|
+
return `\n\n\uE000PRE${preBodies.length - 1}\uE000\n\n`
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
work = work
|
|
35
|
+
.replace(/<code\b[^<>]*>([\s\S]*?)<\/code>/gi, (_whole, inner: string) => `\`${stripInnerTags(inner)}\``)
|
|
36
|
+
// Only real web links become markdown links; fragment and javascript hrefs
|
|
37
|
+
// keep their label and lose the target.
|
|
38
|
+
.replace(/<a\b[^<>]*?href=(?:"([^"]*)"|'([^']*)')[^<>]*>([\s\S]*?)<\/a>/gi, (_whole, dq: string | undefined, sq: string | undefined, inner: string) => {
|
|
39
|
+
const href = decodeAllEntities(dq ?? sq ?? '')
|
|
40
|
+
const label = stripInnerTags(inner).trim()
|
|
41
|
+
if (!label) return ' '
|
|
42
|
+
return /^https?:\/\//i.test(href) ? `[${label}](${href})` : label
|
|
43
|
+
})
|
|
44
|
+
.replace(/<(strong|b)\b[^<>]*>([\s\S]*?)<\/\1>/gi, (_whole, _tag, inner: string) => `**${stripInnerTags(inner).trim()}**`)
|
|
45
|
+
.replace(/<(em|i)\b[^<>]*>([\s\S]*?)<\/\1>/gi, (_whole, _tag, inner: string) => `*${stripInnerTags(inner).trim()}*`)
|
|
46
|
+
.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`)
|
|
47
|
+
.replace(/<img\b[^<>]*?alt=(?:"([^"]*)"|'([^']*)')[^<>]*>/gi, (_whole, dq?: string, sq?: string) => dq ?? sq ?? '')
|
|
48
|
+
.replace(/<li\b[^<>]*>/gi, '\n- ')
|
|
49
|
+
.replace(/<blockquote\b[^<>]*>/gi, '\n\n> ')
|
|
50
|
+
.replace(/<\/(?:td|th)>/gi, ' | ')
|
|
51
|
+
.replace(/<(?:br|hr)\b[^<>]*>/gi, '\n')
|
|
52
|
+
.replace(/<\/(?:p|div|section|article|ul|ol|li|table|tr|blockquote|tbody|thead|header|footer|main|nav)[^<>]*>/gi, '\n\n')
|
|
53
|
+
|
|
54
|
+
const text = decodeAllEntities(work.replace(/<[^<>]*>/g, ''))
|
|
55
|
+
.replace(/[ \t]+/g, ' ')
|
|
56
|
+
.replace(/ ?\n ?/g, '\n')
|
|
57
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
58
|
+
.trim()
|
|
59
|
+
|
|
60
|
+
return text.replace(/\uE000PRE(\d+)\uE000/g, (_whole, index: string) => `\`\`\`\n${preBodies[Number(index)]}\n\`\`\``)
|
|
61
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Channel and payload for instruction-file loads published on pi's shared extension
|
|
3
|
+
* event bus. Producers are claude-rules (a scoped rule lazily attaching on a matching
|
|
4
|
+
* file touch, load_reason `path_glob_match`) and context-imports (resolved `@imports`,
|
|
5
|
+
* load_reason `include`, and CLAUDE.local.md loads, load_reason `session_start`).
|
|
6
|
+
* Hooks bridge them to Claude's InstructionsLoaded event, which is strictly
|
|
7
|
+
* observational. pi loads extensions without a shared module cache, so state rides
|
|
8
|
+
* the bus, mirroring subagent-events.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import * as path from 'node:path'
|
|
12
|
+
|
|
13
|
+
export const INSTRUCTIONS_CHANNEL = 'pi-code:instructions'
|
|
14
|
+
|
|
15
|
+
/** Claude's memory_type vocabulary for InstructionsLoaded payloads. */
|
|
16
|
+
export type InstructionMemoryType = 'User' | 'Project' | 'Local' | 'Managed'
|
|
17
|
+
|
|
18
|
+
const MEMORY_TYPES: readonly string[] = ['User', 'Project', 'Local', 'Managed']
|
|
19
|
+
|
|
20
|
+
export interface InstructionLoadEvent {
|
|
21
|
+
/** Absolute path of the instruction file that entered context. */
|
|
22
|
+
file_path: string
|
|
23
|
+
memory_type: InstructionMemoryType
|
|
24
|
+
/** What caused the load; InstructionsLoaded matchers run against this. */
|
|
25
|
+
load_reason: string
|
|
26
|
+
/** The rule's `paths:` globs; present only for path_glob_match. */
|
|
27
|
+
globs?: string[]
|
|
28
|
+
/** The file whose access triggered a lazy load. */
|
|
29
|
+
trigger_file_path?: string
|
|
30
|
+
/** The importing file, for include loads. */
|
|
31
|
+
parent_file_path?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function isInstructionLoadEvent(data: unknown): data is InstructionLoadEvent {
|
|
35
|
+
const event = data as InstructionLoadEvent | null
|
|
36
|
+
if (event === null || typeof event !== 'object') return false
|
|
37
|
+
if (typeof event.file_path !== 'string' || typeof event.load_reason !== 'string') return false
|
|
38
|
+
if (!MEMORY_TYPES.includes(event.memory_type)) return false
|
|
39
|
+
if (event.globs !== undefined && !(Array.isArray(event.globs) && event.globs.every((glob) => typeof glob === 'string'))) return false
|
|
40
|
+
if (event.trigger_file_path !== undefined && typeof event.trigger_file_path !== 'string') return false
|
|
41
|
+
if (event.parent_file_path !== undefined && typeof event.parent_file_path !== 'string') return false
|
|
42
|
+
return true
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Claude's memory_type from a file's location: CLAUDE.local.md is Local wherever it
|
|
46
|
+
* sits; a file under home but outside the project is User; everything else, the
|
|
47
|
+
* project itself included (which commonly lives under home), is Project. */
|
|
48
|
+
export function memoryTypeForPath(filePath: string, home: string, projectRoot: string): InstructionMemoryType {
|
|
49
|
+
if (path.basename(filePath) === 'CLAUDE.local.md') return 'Local'
|
|
50
|
+
const isUnder = (root: string): boolean => root.length > 0 && (filePath === root || filePath.startsWith(root + path.sep))
|
|
51
|
+
if (isUnder(projectRoot)) return 'Project'
|
|
52
|
+
// Monorepo: repoRoot stops at the nearest .git OR package.json, so a git-root
|
|
53
|
+
// CLAUDE.md can sit above the projectRoot a subpackage session reports. A file
|
|
54
|
+
// whose directory is a strict ancestor of the project root is still project
|
|
55
|
+
// memory. The home directory itself stays User: a home-level context file is
|
|
56
|
+
// user config even when the project lives under home.
|
|
57
|
+
const dir = path.dirname(filePath)
|
|
58
|
+
if (dir !== home && (projectRoot === dir || projectRoot.startsWith(dir + path.sep))) return 'Project'
|
|
59
|
+
if (isUnder(home)) return 'User'
|
|
60
|
+
return 'Project'
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The emit half of pi's EventBus; producers may run under stub hosts without one. */
|
|
64
|
+
export interface InstructionBus {
|
|
65
|
+
emit(channel: string, data: unknown): void
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function publishInstructionLoad(events: InstructionBus | undefined, event: InstructionLoadEvent): void {
|
|
69
|
+
events?.emit(INSTRUCTIONS_CHANNEL, event)
|
|
70
|
+
}
|