pi-code 1.0.5 → 1.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/extensions/commands.ts +303 -85
- package/extensions/context-imports.ts +162 -91
- package/extensions/git-checkpoint.ts +40 -0
- package/extensions/hooks.ts +203 -36
- package/extensions/internal/command-file.ts +91 -48
- package/extensions/internal/html-markdown.ts +11 -1
- package/extensions/internal/instruction-events.ts +2 -2
- package/extensions/internal/managed-settings.ts +1 -1
- package/extensions/internal/mcp-oauth.ts +44 -8
- package/extensions/internal/path-rules.ts +69 -2
- package/extensions/internal/plugins.ts +29 -16
- package/extensions/internal/strip-comments.ts +56 -33
- package/extensions/mcp.ts +462 -84
- package/extensions/memory.ts +29 -19
- package/extensions/notify.ts +1 -2
- package/extensions/status-line.ts +8 -3
- package/extensions/subagent/background.ts +11 -0
- package/extensions/subagent/index.ts +119 -5
- package/extensions/web.ts +39 -26
- package/package.json +1 -1
package/extensions/commands.ts
CHANGED
|
@@ -7,12 +7,29 @@
|
|
|
7
7
|
* subdirectories (`frontend/build.md` is `/frontend:build`), `$ARGUMENTS` and
|
|
8
8
|
* positional substitution, `` !`cmd` `` bash output, `@file` inlining, and the
|
|
9
9
|
* `allowed-tools`, `argument-hint` and `model` frontmatter (`model` switches the
|
|
10
|
-
* session model for the command's
|
|
10
|
+
* session model for the command's run via `pi.setModel`, restored on agent_end).
|
|
11
11
|
* `shell: powershell` runs a command's injected spans through PowerShell when a
|
|
12
12
|
* pwsh binary is installed, falling back to /bin/sh so the command still works
|
|
13
|
-
* without one.
|
|
13
|
+
* without one.
|
|
14
14
|
*
|
|
15
|
-
*
|
|
15
|
+
* Commands are also exposed to the model through a `slash_command` tool
|
|
16
|
+
* (Claude's SlashCommand tool), listing every discovered command whose file
|
|
17
|
+
* does not set `disable-model-invocation: true`. Only the command files this
|
|
18
|
+
* extension discovers are listed: pi built-ins and pi-code's own UI commands
|
|
19
|
+
* are user surfaces, not model surfaces. The model path is expansion only: the
|
|
20
|
+
* expanded body comes back as the tool result (sendUserMessage would spawn a
|
|
21
|
+
* second turn), `allowed-tools`/`disallowed-tools`/`model:` are not applied
|
|
22
|
+
* (applying them from inside a tool call would narrow the running batch and
|
|
23
|
+
* scope unrelated parallel tool calls, and the agent_end restore would lift
|
|
24
|
+
* the grant before the next model step could rely on it), and `!` spans are
|
|
25
|
+
* never executed on the model's demand: pi has no permission engine to gate
|
|
26
|
+
* repo-authored shell, so each span is replaced with Claude's
|
|
27
|
+
* "[shell command execution disabled by policy]" placeholder. The same
|
|
28
|
+
* placeholder is applied on every path when the `disableSkillShellExecution`
|
|
29
|
+
* settings key is set (user settings always, project settings when trusted,
|
|
30
|
+
* managed-settings.json as policy).
|
|
31
|
+
*
|
|
32
|
+
* A project command body is repository-controlled text that can run shell
|
|
16
33
|
* commands and read files, so project commands load only once the project is
|
|
17
34
|
* approved. That closes the "skills / commands are not trust-gated" limitation
|
|
18
35
|
* for commands; skills remain pi-loader territory.
|
|
@@ -24,13 +41,15 @@ import * as fs from 'node:fs'
|
|
|
24
41
|
import * as os from 'node:os'
|
|
25
42
|
import * as path from 'node:path'
|
|
26
43
|
import type { ExtensionAPI, ExtensionCommandContext } from '@earendil-works/pi-coding-agent'
|
|
44
|
+
import { Type } from 'typebox'
|
|
27
45
|
|
|
28
46
|
import { matchesBashRules } from './internal/bash-rules.js'
|
|
29
|
-
import { type DiscoveredCommand, discoverCommandFiles, expandDynamicContent, type ParsedCommand, parseCommandFile, resolvePowershellBinary, spanExec, substituteArgsDetailed, substituteVars } from './internal/command-file.js'
|
|
47
|
+
import { type CommandExec, type DiscoveredCommand, discoverCommandFiles, expandDynamicContent, type ParsedCommand, parseCommandFile, resolvePowershellBinary, spanExec, substituteArgsDetailed, substituteVars } from './internal/command-file.js'
|
|
48
|
+
import { readManagedSettings } from './internal/managed-settings.js'
|
|
30
49
|
import { matchesPathRules } from './internal/path-rules.js'
|
|
31
50
|
import { type InstalledPlugin, installedPlugins } from './internal/plugins.js'
|
|
32
51
|
import { isProjectApproved } from './internal/project-approval.js'
|
|
33
|
-
import { findNearestDir, repoRoot } from './internal/project-root.js'
|
|
52
|
+
import { findNearestDir, findNearestFile, repoRoot } from './internal/project-root.js'
|
|
34
53
|
|
|
35
54
|
type PathRuleTool = 'read' | 'edit' | 'write'
|
|
36
55
|
|
|
@@ -38,6 +57,7 @@ type PathRuleTool = 'read' | 'edit' | 'write'
|
|
|
38
57
|
interface ModelLike {
|
|
39
58
|
id: string
|
|
40
59
|
name?: string
|
|
60
|
+
contextWindow?: number
|
|
41
61
|
}
|
|
42
62
|
|
|
43
63
|
/** Resolve a command's `model:` frontmatter to an available model. Claude accepts a
|
|
@@ -60,6 +80,16 @@ function substitutePathRule(rule: string, vars: Record<string, string | undefine
|
|
|
60
80
|
return rule.trimStart().startsWith('${CLAUDE_') && path.isAbsolute(substituted) ? `/${substituted}` : substituted
|
|
61
81
|
}
|
|
62
82
|
|
|
83
|
+
/** The path rules that survive an allowed-tools intersection: only the tools the
|
|
84
|
+
* grant kept get their scopes, each rule ${CLAUDE_*}-substituted like the body. */
|
|
85
|
+
function scopedPathRules(pathRules: Partial<Record<PathRuleTool, string[]>>, granted: string[], vars: Record<string, string | undefined>): Partial<Record<PathRuleTool, string[]>> {
|
|
86
|
+
const result: Partial<Record<PathRuleTool, string[]>> = {}
|
|
87
|
+
for (const [tool, rules] of Object.entries(pathRules)) {
|
|
88
|
+
if (granted.includes(tool)) result[tool as PathRuleTool] = rules.map((rule) => substitutePathRule(rule, vars))
|
|
89
|
+
}
|
|
90
|
+
return result
|
|
91
|
+
}
|
|
92
|
+
|
|
63
93
|
/** Wall-clock budget for one injected span: the Bash tool's documented 2-minute
|
|
64
94
|
* default, which is what Claude runs these commands under. */
|
|
65
95
|
const BASH_TIMEOUT_MS = 120_000
|
|
@@ -118,18 +148,170 @@ export function pluginCommands(plugins: InstalledPlugin[]): DiscoveredCommand[]
|
|
|
118
148
|
return found
|
|
119
149
|
}
|
|
120
150
|
|
|
151
|
+
/** Claude's replacement text for a `!` span it refuses to execute. */
|
|
152
|
+
export const SHELL_DISABLED_PLACEHOLDER = '[shell command execution disabled by policy]'
|
|
153
|
+
|
|
154
|
+
type CommandPlugin = NonNullable<DiscoveredCommand['plugin']>
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Whether `disableSkillShellExecution` is set: the Claude settings key that
|
|
158
|
+
* replaces every `` !`cmd` `` and ```` ```! ```` span in skills and custom
|
|
159
|
+
* commands with SHELL_DISABLED_PLACEHOLDER instead of executing it. Read from
|
|
160
|
+
* user settings always, the project's settings.json/settings.local.json only
|
|
161
|
+
* when the project is trusted, and managed-settings.json as policy. Any layer
|
|
162
|
+
* setting it true wins: a repository's `false` must not lift the user's or the
|
|
163
|
+
* organization's policy, so this fails closed rather than last-file-wins.
|
|
164
|
+
*/
|
|
165
|
+
export function shellExecutionDisabled(cwd: string, home: string, trusted: boolean): boolean {
|
|
166
|
+
if (readManagedSettings().disableSkillShellExecution === true) return true
|
|
167
|
+
const files = [path.join(home, '.claude', 'settings.json')]
|
|
168
|
+
if (trusted) {
|
|
169
|
+
for (const name of ['settings.json', 'settings.local.json']) {
|
|
170
|
+
files.push(findNearestFile(cwd, path.join('.claude', name)) ?? path.join(cwd, '.claude', name))
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return files.some((file) => {
|
|
174
|
+
try {
|
|
175
|
+
return (JSON.parse(fs.readFileSync(file, 'utf-8')) as Record<string, unknown>).disableSkillShellExecution === true
|
|
176
|
+
} catch {
|
|
177
|
+
return false // missing or invalid file: not a policy statement
|
|
178
|
+
}
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The ${CLAUDE_*} substitution sources for one command invocation. */
|
|
183
|
+
function commandVars(ctx: { cwd: string }, filePath: string, plugin?: CommandPlugin): Record<string, string | undefined> {
|
|
184
|
+
const varCtx = ctx as unknown as VarContext
|
|
185
|
+
return {
|
|
186
|
+
CLAUDE_SESSION_ID: varCtx.sessionManager?.getSessionId?.(),
|
|
187
|
+
CLAUDE_EFFORT: varCtx.thinkingLevel,
|
|
188
|
+
CLAUDE_SKILL_DIR: path.dirname(filePath),
|
|
189
|
+
CLAUDE_PROJECT_DIR: repoRoot(ctx.cwd) ?? ctx.cwd,
|
|
190
|
+
CLAUDE_PLUGIN_ROOT: plugin?.root,
|
|
191
|
+
CLAUDE_PLUGIN_DATA: plugin?.dataDir,
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** The exec seam expandCommand runs spans through; pi itself satisfies it. */
|
|
196
|
+
interface SpanRunner {
|
|
197
|
+
exec(command: string, args: string[], options?: { cwd?: string; timeout?: number }): Promise<{ stdout: string; stderr: string; code: number }>
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* A command body expanded to the text a turn (or a tool result) carries:
|
|
202
|
+
* argument and named substitution, ${CLAUDE_*} and plugin variables, `!` spans
|
|
203
|
+
* and `@file` inlining, plus Claude's `ARGUMENTS:` append when the body never
|
|
204
|
+
* read what was passed. Throws when an injected span fails, so a caller never
|
|
205
|
+
* sees a half-expanded body. With `allowShell: false` no span executes at all;
|
|
206
|
+
* each is replaced by SHELL_DISABLED_PLACEHOLDER, which is how both the model
|
|
207
|
+
* path and the disableSkillShellExecution setting keep repo-authored shell
|
|
208
|
+
* from running.
|
|
209
|
+
*/
|
|
210
|
+
export async function expandCommand(runner: SpanRunner, parsed: ParsedCommand, args: string, ctx: { cwd: string }, filePath: string, plugin?: CommandPlugin, options?: { allowShell?: boolean }): Promise<string> {
|
|
211
|
+
const projectRoot = repoRoot(ctx.cwd) ?? ctx.cwd
|
|
212
|
+
const vars = commandVars(ctx, filePath, plugin)
|
|
213
|
+
const { text: withArgs, consumed } = substituteArgsDetailed(parsed.body, args, parsed.argumentNames ?? [])
|
|
214
|
+
// `${user_config.KEY}` is a plugin-command variable only; leave it literal in an
|
|
215
|
+
// ordinary command so a body that happens to contain the syntax is not stripped.
|
|
216
|
+
const substituted = substituteVars(withArgs, vars)
|
|
217
|
+
const withVars = plugin ? substituted.replace(/\$\{user_config\.(\w+)\}/g, (_, key: string) => plugin.userConfig?.[key] ?? '') : substituted
|
|
218
|
+
|
|
219
|
+
const exec: CommandExec =
|
|
220
|
+
(options?.allowShell ?? true)
|
|
221
|
+
? async (script) => {
|
|
222
|
+
// Hooks get CLAUDE_PROJECT_DIR, and a command's shell span is the same kind of
|
|
223
|
+
// project-scoped script. pi.exec takes no env, so it is set in the script.
|
|
224
|
+
// stderr merges into stdout, as the Bash tool runs these for Claude. The
|
|
225
|
+
// resolver is passed by its imported binding so tests can stub the lookup.
|
|
226
|
+
const run = spanExec(parsed.shell, projectRoot, script, resolvePowershellBinary)
|
|
227
|
+
const result = await runner.exec(run.command, run.args, { cwd: ctx.cwd, timeout: BASH_TIMEOUT_MS })
|
|
228
|
+
// pwsh cannot merge a native command's stderr in-script (spanExec sets
|
|
229
|
+
// mergeStreams), so it is appended here; the sh script merges via 2>&1.
|
|
230
|
+
const stdout = run.mergeStreams ? result.stdout + result.stderr : result.stdout
|
|
231
|
+
return { stdout, stderr: result.stderr, code: result.code }
|
|
232
|
+
}
|
|
233
|
+
: async () => ({ stdout: SHELL_DISABLED_PLACEHOLDER, stderr: '', code: 0 })
|
|
234
|
+
let expanded = await expandDynamicContent(withVars, ctx.cwd, exec)
|
|
235
|
+
|
|
236
|
+
// Claude appends the raw arguments when the command never read them, so what
|
|
237
|
+
// the user typed still reaches the model.
|
|
238
|
+
if (args.trim().length > 0 && !consumed) expanded += `\n\nARGUMENTS: ${args.trim()}`
|
|
239
|
+
return expanded
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export interface SlashCommandEntry {
|
|
243
|
+
name: string
|
|
244
|
+
description: string
|
|
245
|
+
argumentHint?: string
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** One listed command's cap inside the tool description, as Claude cuts an
|
|
249
|
+
* oversized description rather than dropping the command. */
|
|
250
|
+
const ENTRY_CHAR_CAP = 1536
|
|
251
|
+
|
|
252
|
+
/** Claude's default character budget for the SlashCommand tool description. */
|
|
253
|
+
const DEFAULT_TOOL_CHAR_BUDGET = 15_000
|
|
254
|
+
|
|
255
|
+
/** The description budget: SLASH_COMMAND_TOOL_CHAR_BUDGET when set, else about
|
|
256
|
+
* 1% of the model's context window (1% of the tokens at ~4 characters per token
|
|
257
|
+
* is window / 25), else Claude's documented default. */
|
|
258
|
+
export function slashCommandBudget(contextWindow: number | undefined, env: Record<string, string | undefined> = process.env): number {
|
|
259
|
+
const override = Number.parseInt(env.SLASH_COMMAND_TOOL_CHAR_BUDGET ?? '', 10)
|
|
260
|
+
if (Number.isInteger(override) && override > 0) return override
|
|
261
|
+
if (contextWindow && contextWindow > 0) return Math.floor(contextWindow / 25)
|
|
262
|
+
return DEFAULT_TOOL_CHAR_BUDGET
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** The slash_command tool description: usage framing plus the budgeted command
|
|
266
|
+
* list, each entry `/name - description (argument-hint)`. */
|
|
267
|
+
export function slashCommandToolDescription(commands: SlashCommandEntry[], budget: number): string {
|
|
268
|
+
const lines: string[] = []
|
|
269
|
+
let used = 0
|
|
270
|
+
let omitted = 0
|
|
271
|
+
for (const command of commands) {
|
|
272
|
+
const hintSuffix = command.argumentHint ? ` (${command.argumentHint})` : ''
|
|
273
|
+
const entry = `/${command.name} - ${command.description}${hintSuffix}`.slice(0, ENTRY_CHAR_CAP)
|
|
274
|
+
if (used + entry.length + 1 > budget) {
|
|
275
|
+
omitted++
|
|
276
|
+
continue // a shorter later entry may still fit the remaining budget
|
|
277
|
+
}
|
|
278
|
+
used += entry.length + 1
|
|
279
|
+
lines.push(entry)
|
|
280
|
+
}
|
|
281
|
+
if (omitted > 0) lines.push(`(${omitted} more ${omitted === 1 ? 'command was' : 'commands were'} omitted: raise SLASH_COMMAND_TOOL_CHAR_BUDGET to list them)`)
|
|
282
|
+
return ["Execute a custom slash command on the user's behalf. The command expands to instructions for you to follow in this conversation.", '', 'Available commands:', ...lines].join('\n')
|
|
283
|
+
}
|
|
284
|
+
|
|
121
285
|
export default function commandsExtension(pi: ExtensionAPI) {
|
|
122
286
|
const registered = new Set<string>()
|
|
123
|
-
/**
|
|
287
|
+
/** Every command file discovered for the current session, by name, for the
|
|
288
|
+
* slash_command tool to resolve against. Rebuilt on each session_start (a resume,
|
|
289
|
+
* fork, or new session can land on a different project) so the model never resolves
|
|
290
|
+
* a command left over from a previous project's session. Kept fresh even for names
|
|
291
|
+
* already registered. */
|
|
292
|
+
const discovered = new Map<string, DiscoveredCommand>()
|
|
293
|
+
/** pi has no unregister, so the slash_command tool registers once per process. */
|
|
294
|
+
let toolRegistered = false
|
|
295
|
+
/** The session_start approval decision, reused for per-invocation settings reads. */
|
|
296
|
+
let projectApproved = false
|
|
297
|
+
/** Tool set to put back once the run a restricted command drove has ended. */
|
|
124
298
|
let pendingRestore: string[] | undefined
|
|
125
|
-
/** `Bash(...)` scopes enforced while that
|
|
299
|
+
/** `Bash(...)` scopes enforced while that run lasts; lifted with the restriction. */
|
|
126
300
|
let pendingBashRules: string[] | undefined
|
|
127
301
|
/** Read/Edit path scopes enforced the same way, per pi file tool. */
|
|
128
302
|
let pendingPathRules: Partial<Record<PathRuleTool, string[]>> | undefined
|
|
129
|
-
/** The session model to restore after a command's `model:` override drove its
|
|
303
|
+
/** The session model to restore after a command's `model:` override drove its run. */
|
|
130
304
|
let pendingModelRestore: ModelLike | undefined
|
|
131
305
|
|
|
132
|
-
|
|
306
|
+
// Claude's contract is "the grant clears when you send your next message", and
|
|
307
|
+
// pi's turn_end fires after every assistant step: restoring there stripped a
|
|
308
|
+
// multi-step command's tool and model scoping as soon as its first tool batch
|
|
309
|
+
// came back. agent_end is not the end either: it fires once per agent loop, ahead
|
|
310
|
+
// of an automatic retry, an auto-compaction-and-retry, or a Stop-hook continuation,
|
|
311
|
+
// so restoring there lifts the scoping before that continued run executes.
|
|
312
|
+
// agent_settled fires exactly once, after the run has fully settled and no such
|
|
313
|
+
// continuation remains, which is the grant's true clearing point.
|
|
314
|
+
pi.on('agent_settled', async () => {
|
|
133
315
|
pendingBashRules = undefined
|
|
134
316
|
pendingPathRules = undefined
|
|
135
317
|
if (pendingModelRestore) {
|
|
@@ -165,37 +347,64 @@ export default function commandsExtension(pi: ExtensionAPI) {
|
|
|
165
347
|
}
|
|
166
348
|
})
|
|
167
349
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
350
|
+
/** Take the tool set to restore when this run ends, captured once per turn so a
|
|
351
|
+
* second restricted command narrows against the original unrestricted set. */
|
|
352
|
+
function captureRestorePoint(): string[] {
|
|
353
|
+
const original = pendingRestore ?? pi.getActiveTools()
|
|
354
|
+
pendingRestore = original
|
|
355
|
+
return original
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Apply a command's allowed-tools to the run it drives: intersect with the tools
|
|
359
|
+
* pi actually has, keep the bash and path scopes for the tool_call guard, and let
|
|
360
|
+
* agent_settled restore the previous set. */
|
|
361
|
+
function applyAllowedTools(parsed: ParsedCommand, vars: Record<string, string | undefined>): void {
|
|
362
|
+
const allowed = parsed.allowedTools
|
|
363
|
+
if (!allowed) return
|
|
364
|
+
// Only the first restriction in a turn sees the unrestricted set; a second
|
|
365
|
+
// command must grant and restore against that original set, or its own tools
|
|
366
|
+
// are intersected away by the first command's narrowing.
|
|
367
|
+
const original = captureRestorePoint()
|
|
368
|
+
const granted = allowed.filter((tool) => original.includes(tool))
|
|
369
|
+
// `allowed-tools: []` says no tools, and is honored. A non-empty list that
|
|
370
|
+
// intersects to nothing named only tools pi has none of: that restriction cannot
|
|
371
|
+
// be expressed, and applying it as "no tools" is not what the command asked for.
|
|
372
|
+
if (granted.length > 0 || allowed.length === 0) pi.setActiveTools(granted)
|
|
373
|
+
// The latest restricted command speaks for the turn: a later unscoped grant
|
|
374
|
+
// lifts an earlier command's scopes rather than stacking under them. Rules get
|
|
375
|
+
// the same ${CLAUDE_*} substitution as the body, so a rule can name a bundled
|
|
376
|
+
// script by its real path, as the skills docs show.
|
|
377
|
+
pendingBashRules = granted.includes('bash') ? parsed.bashRules?.map((rule) => substituteVars(rule, vars)) : undefined
|
|
378
|
+
pendingPathRules = parsed.pathRules ? scopedPathRules(parsed.pathRules, granted, vars) : undefined
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Claude removes disallowed-tools from the pool while the command is active; with
|
|
382
|
+
* both fields present the removal wins, as in subagent tool lists. */
|
|
383
|
+
function applyDisallowedTools(parsed: ParsedCommand): void {
|
|
384
|
+
const disallowed = parsed.disallowedTools
|
|
385
|
+
if (!disallowed || disallowed.length === 0) return
|
|
386
|
+
captureRestorePoint()
|
|
387
|
+
pi.setActiveTools(pi.getActiveTools().filter((tool) => !disallowed.includes(tool)))
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Claude's `model:` frontmatter overrides the model for this run only, then the
|
|
391
|
+
* session model resumes; restore happens on agent_settled like the tool-set restore.
|
|
392
|
+
* Applied before sendUserMessage so the run it drives happens on the new model. */
|
|
393
|
+
async function applyModelOverride(parsed: ParsedCommand, varCtx: VarContext): Promise<void> {
|
|
394
|
+
const target = resolveCommandModel(parsed.model, varCtx.modelRegistry?.getAvailable() ?? [])
|
|
395
|
+
if (target && varCtx.model && target.id !== varCtx.model.id) {
|
|
396
|
+
pendingModelRestore = pendingModelRestore ?? varCtx.model
|
|
397
|
+
await pi.setModel(target as Parameters<typeof pi.setModel>[0])
|
|
178
398
|
}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
const
|
|
183
|
-
const
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async function runCommand(parsed: ParsedCommand, args: string, ctx: ExtensionCommandContext, filePath: string, plugin?: CommandPlugin): Promise<void> {
|
|
402
|
+
const varCtx = ctx as unknown as VarContext
|
|
403
|
+
const vars = commandVars(ctx, filePath, plugin)
|
|
184
404
|
|
|
185
405
|
let expanded: string
|
|
186
406
|
try {
|
|
187
|
-
expanded = await
|
|
188
|
-
// Hooks get CLAUDE_PROJECT_DIR, and a command's shell span is the same kind of
|
|
189
|
-
// project-scoped script. pi.exec takes no env, so it is set in the script.
|
|
190
|
-
// stderr merges into stdout, as the Bash tool runs these for Claude. The
|
|
191
|
-
// resolver is passed by its imported binding so tests can stub the lookup.
|
|
192
|
-
const run = spanExec(parsed.shell, projectRoot, script, resolvePowershellBinary)
|
|
193
|
-
const result = await pi.exec(run.command, run.args, { cwd: ctx.cwd, timeout: BASH_TIMEOUT_MS })
|
|
194
|
-
// pwsh cannot merge a native command's stderr in-script (spanExec sets
|
|
195
|
-
// mergeStreams), so it is appended here; the sh script merges via 2>&1.
|
|
196
|
-
const stdout = run.mergeStreams ? result.stdout + result.stderr : result.stdout
|
|
197
|
-
return { stdout, stderr: result.stderr, code: result.code }
|
|
198
|
-
})
|
|
407
|
+
expanded = await expandCommand(pi, parsed, args, ctx, filePath, plugin, { allowShell: !shellExecutionDisabled(ctx.cwd, os.homedir(), projectApproved) })
|
|
199
408
|
} catch (error) {
|
|
200
409
|
// A failed injected command aborts the invocation; the model never sees a
|
|
201
410
|
// half-expanded body. The notify carries Claude's failure message format.
|
|
@@ -203,73 +412,45 @@ export default function commandsExtension(pi: ExtensionAPI) {
|
|
|
203
412
|
return
|
|
204
413
|
}
|
|
205
414
|
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
if (args.trim().length > 0 && !consumed) expanded += `\n\nARGUMENTS: ${args.trim()}`
|
|
209
|
-
|
|
210
|
-
// allowed-tools restricts the turn the command drives, and the previous set is
|
|
211
|
-
// restored when that turn ends. Restoring inline does not work: sendUserMessage is
|
|
415
|
+
// allowed-tools restricts the run the command drives, and the previous set is
|
|
416
|
+
// restored when that run ends. Restoring inline does not work: sendUserMessage is
|
|
212
417
|
// fire-and-forget, so the restore would land before the agent ever read the tool
|
|
213
418
|
// list, leaving the command running with everything enabled.
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
// are intersected away by the first command's narrowing.
|
|
218
|
-
const original = pendingRestore ?? pi.getActiveTools()
|
|
219
|
-
pendingRestore = original
|
|
220
|
-
const granted = parsed.allowedTools.filter((tool) => original.includes(tool))
|
|
221
|
-
// `allowed-tools: []` says no tools, and is honored. A non-empty list that
|
|
222
|
-
// intersects to nothing named only tools pi has none of: that restriction cannot
|
|
223
|
-
// be expressed, and applying it as "no tools" is not what the command asked for.
|
|
224
|
-
if (granted.length > 0 || parsed.allowedTools.length === 0) pi.setActiveTools(granted)
|
|
225
|
-
// The latest restricted command speaks for the turn: a later unscoped grant
|
|
226
|
-
// lifts an earlier command's scopes rather than stacking under them. Rules get
|
|
227
|
-
// the same ${CLAUDE_*} substitution as the body, so a rule can name a bundled
|
|
228
|
-
// script by its real path, as the skills docs show.
|
|
229
|
-
pendingBashRules = granted.includes('bash') ? parsed.bashRules?.map((rule) => substituteVars(rule, vars)) : undefined
|
|
230
|
-
pendingPathRules = undefined
|
|
231
|
-
if (parsed.pathRules) {
|
|
232
|
-
pendingPathRules = {}
|
|
233
|
-
for (const [tool, rules] of Object.entries(parsed.pathRules)) {
|
|
234
|
-
if (granted.includes(tool)) pendingPathRules[tool as PathRuleTool] = rules.map((rule) => substitutePathRule(rule, vars))
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
// Claude removes disallowed-tools from the pool while the skill is active;
|
|
239
|
-
// with both fields present the removal wins, as in subagent tool lists.
|
|
240
|
-
if (parsed.disallowedTools && parsed.disallowedTools.length > 0) {
|
|
241
|
-
const original = pendingRestore ?? pi.getActiveTools()
|
|
242
|
-
pendingRestore = original
|
|
243
|
-
const disallowed = parsed.disallowedTools
|
|
244
|
-
pi.setActiveTools(pi.getActiveTools().filter((tool) => !disallowed.includes(tool)))
|
|
245
|
-
}
|
|
246
|
-
// Claude's `model:` frontmatter overrides the model for this turn only, then the
|
|
247
|
-
// session model resumes; restore happens on turn_end like the tool-set restore.
|
|
248
|
-
// Applied before sendUserMessage so the turn it drives runs on the new model.
|
|
249
|
-
const target = resolveCommandModel(parsed.model, varCtx.modelRegistry?.getAvailable() ?? [])
|
|
250
|
-
if (target && varCtx.model && target.id !== varCtx.model.id) {
|
|
251
|
-
pendingModelRestore = pendingModelRestore ?? varCtx.model
|
|
252
|
-
await pi.setModel(target as Parameters<typeof pi.setModel>[0])
|
|
253
|
-
}
|
|
419
|
+
applyAllowedTools(parsed, vars)
|
|
420
|
+
applyDisallowedTools(parsed)
|
|
421
|
+
await applyModelOverride(parsed, varCtx)
|
|
254
422
|
pi.sendUserMessage(expanded)
|
|
255
423
|
}
|
|
256
424
|
|
|
257
425
|
pi.on('session_start', async (_event, ctx) => {
|
|
258
426
|
const trusted = await isProjectApproved(ctx)
|
|
427
|
+
projectApproved = trusted
|
|
428
|
+
// A resume/fork/new session can switch projects in-process. pi cannot unregister a
|
|
429
|
+
// command or re-describe the slash_command tool, so a name registered in an earlier
|
|
430
|
+
// project keeps its user-path binding and the tool description stays frozen at the
|
|
431
|
+
// first session's list; that is a pi limitation. What must not persist is the map
|
|
432
|
+
// the model resolves against, so it is rebuilt from scratch here: a stale command
|
|
433
|
+
// from a previous project resolves to "unknown" rather than being expanded.
|
|
434
|
+
discovered.clear()
|
|
259
435
|
// Plugins are user-installed and enabled by user settings; a checked-out repo
|
|
260
436
|
// must not silently flip which code-bearing plugins run, so enablement is
|
|
261
437
|
// user-scoped and never reads the project settings chain (see installedPlugins).
|
|
262
438
|
const plugins = pluginCommands(installedPlugins(os.homedir()))
|
|
439
|
+
const invocable: SlashCommandEntry[] = []
|
|
263
440
|
for (const command of [...collectCommands(commandDirs(ctx.cwd, os.homedir(), trusted)), ...plugins]) {
|
|
264
|
-
// pi has no unregister, so a command already registered this process keeps its
|
|
265
|
-
// original file binding; re-registering would only add a numbered duplicate.
|
|
266
|
-
if (registered.has(command.name)) continue
|
|
267
441
|
let parsed: ParsedCommand
|
|
268
442
|
try {
|
|
269
443
|
parsed = parseCommandFile(fs.readFileSync(command.filePath, 'utf-8'))
|
|
270
444
|
} catch {
|
|
271
445
|
continue // an unreadable command file must not take down session start
|
|
272
446
|
}
|
|
447
|
+
discovered.set(command.name, command)
|
|
448
|
+
// A user-only command stays off the tool description; it is still in the
|
|
449
|
+
// map so a model attempt gets the explicit refusal, not "unknown command".
|
|
450
|
+
if (!parsed.disableModelInvocation) invocable.push({ name: command.name, description: parsed.description, argumentHint: parsed.argumentHint })
|
|
451
|
+
// pi has no unregister, so a command already registered this process keeps its
|
|
452
|
+
// original file binding; re-registering would only add a numbered duplicate.
|
|
453
|
+
if (registered.has(command.name)) continue
|
|
273
454
|
registered.add(command.name)
|
|
274
455
|
pi.registerCommand(command.name, {
|
|
275
456
|
description: parsed.argumentHint ? `${parsed.description} ${parsed.argumentHint}` : parsed.description,
|
|
@@ -285,5 +466,42 @@ export default function commandsExtension(pi: ExtensionAPI) {
|
|
|
285
466
|
},
|
|
286
467
|
})
|
|
287
468
|
}
|
|
469
|
+
|
|
470
|
+
// Claude's SlashCommand tool, registered only when there is something for the
|
|
471
|
+
// model to call: an empty tool would spend context saying "nothing available".
|
|
472
|
+
if (toolRegistered || invocable.length === 0) return
|
|
473
|
+
toolRegistered = true
|
|
474
|
+
pi.registerTool({
|
|
475
|
+
name: 'slash_command',
|
|
476
|
+
label: 'SlashCommand',
|
|
477
|
+
description: slashCommandToolDescription(invocable, slashCommandBudget((ctx as unknown as VarContext).model?.contextWindow)),
|
|
478
|
+
parameters: Type.Object({ command: Type.String({ description: 'The command to run with args, e.g. "/deploy staging"' }) }),
|
|
479
|
+
async execute(_toolCallId, params, _signal, _onUpdate, execCtx) {
|
|
480
|
+
const line = params.command.trim().replace(/^\//, '')
|
|
481
|
+
const space = line.search(/\s/)
|
|
482
|
+
const name = space === -1 ? line : line.slice(0, space)
|
|
483
|
+
const args = space === -1 ? '' : line.slice(space + 1).trim()
|
|
484
|
+
// pi marks a tool result as an error only when execute() throws, so the
|
|
485
|
+
// failure paths below throw rather than return.
|
|
486
|
+
const command = discovered.get(name)
|
|
487
|
+
if (!name || !command) throw new Error(`Unknown command: /${name || params.command}. Only the custom commands listed in the slash_command tool description can be run.`)
|
|
488
|
+
// Live-edit parity with the user path: re-read on every invocation, so a
|
|
489
|
+
// just-added disable-model-invocation takes effect immediately too.
|
|
490
|
+
let current: ParsedCommand
|
|
491
|
+
try {
|
|
492
|
+
current = parseCommandFile(fs.readFileSync(command.filePath, 'utf-8'))
|
|
493
|
+
} catch {
|
|
494
|
+
throw new Error(`/${name}: the command file could not be read (${command.filePath}).`)
|
|
495
|
+
}
|
|
496
|
+
if (current.disableModelInvocation) {
|
|
497
|
+
throw new Error(`/${name} is user-only (disable-model-invocation: true) and was not run. Do not reproduce this command's steps or try to achieve its effect another way; only the user can invoke it.`)
|
|
498
|
+
}
|
|
499
|
+
// Expansion only, never sendUserMessage (that would spawn a second turn):
|
|
500
|
+
// the tool result is the channel, and frontmatter scoping stays user-path
|
|
501
|
+
// territory (see the header).
|
|
502
|
+
const expanded = await expandCommand(pi, current, args, execCtx, command.filePath, command.plugin, { allowShell: false })
|
|
503
|
+
return { content: [{ type: 'text' as const, text: `Contents of /${name} (expanded):\n\n${expanded}` }], details: {} }
|
|
504
|
+
},
|
|
505
|
+
})
|
|
288
506
|
})
|
|
289
507
|
}
|