pi-code 1.0.4 → 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 +179 -21
- package/extensions/context-imports.ts +353 -39
- package/extensions/hooks.ts +351 -63
- 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 +373 -59
- 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 +272 -28
- package/extensions/memory.ts +129 -16
- package/extensions/notify.ts +77 -4
- package/extensions/output-styles.ts +34 -6
- 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 +93 -3
- package/extensions/subagent/agents.ts +72 -61
- package/extensions/subagent/background.ts +25 -6
- package/extensions/subagent/index.ts +194 -29
- package/extensions/web.ts +80 -15
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -37,23 +37,27 @@ One `pi install` and everything below loads on the next start. `pi list` shows w
|
|
|
37
37
|
|
|
38
38
|
| Feature | Reads / provides | Extension |
|
|
39
39
|
|---|---|---|
|
|
40
|
-
| Global + project rules | `~/.claude/rules`, `.claude/rules` (
|
|
41
|
-
| Custom slash commands | `.claude/commands/**/*.md` (namespaced `/dir:name`), `$ARGUMENTS`/`$
|
|
40
|
+
| Global + project rules | `~/.claude/rules`, `.claude/rules` (nearest at or above cwd); unscoped rules inlined in full, `paths:`-scoped rules surfaced as pointers and auto-attached (the rule body is appended to a read/edit/write result when a matching file is touched, once per rule per session) | `claude-rules.ts` |
|
|
41
|
+
| Custom slash commands | `.claude/commands/**/*.md` (namespaced `/dir:name`); `$ARGUMENTS` (with the `ARGUMENTS:` append when unused), 0-based `$ARGUMENTS[N]`/`$N`, named `arguments:` frontmatter, `${CLAUDE_SESSION_ID}`/`${CLAUDE_EFFORT}`/`${CLAUDE_SKILL_DIR}`/`${CLAUDE_PROJECT_DIR}` (in bodies and `allowed-tools` rules); `` !`cmd` `` and multi-line ```` ```! ```` bash (whitespace-bounded, merged stderr, 2-minute budget, a failure aborts the invocation with the documented exit-1 carveout), `@file` inlining; `allowed-tools` with `Bash(...)` and `Read`/`Edit`/`Write` path scopes enforced at call time (gitignore anchors, Edit governs writes), `disallowed-tools`, `argument-hint`, `model` (switches the session model for the command's turn, restored after), `shell: powershell` (runs the command's injected spans through PowerShell when a pwsh binary is present, else falls back to /bin/sh); `disable-model-invocation` is parsed but not applied; project commands gated on approval | `commands.ts` |
|
|
42
|
+
| `/init` | generates a project context file: detects an existing `AGENTS.md`/`CLAUDE.md` (proposes improvements) or none (creates `AGENTS.md`, pi's preferred name), ingesting `.cursor/rules`, `.cursorrules`, and `.github/copilot-instructions.md` when present; drives the main agent with full tools via a prompt (not a tool-less completion) so it analyzes the codebase and writes the file itself | `init.ts` |
|
|
42
43
|
| Skills | `.claude/skills` → pi skill discovery, project skills gated on approval (pi reads `name`, `description`, `disable-model-invocation`; `allowed-tools` is inert in pi's loader) | `skills.ts` |
|
|
43
|
-
| Hooks | `.claude/settings.json` hooks: PreToolUse (blocks, rewrites input via `updatedInput`), PostToolUse (feedback and `additionalContext` land next to the tool result), PostToolUseFailure, SessionStart (context injection), UserPromptSubmit (blocks and injects context), Stop (a block continues the conversation), SubagentStart/SubagentStop, PreCompact, PostCompact, SessionEnd; Claude matcher semantics incl. `mcp__server__tool` names; payloads carry session_id, transcript_path, cwd, permission_mode, effort | `hooks.ts` |
|
|
44
|
-
| Output styles | `.claude/output-styles` + active `outputStyle`; Claude replace semantics with `keep-coding-instructions`; bundled Explanatory/Learning/Proactive; `/output-style [name]` | `output-styles.ts` |
|
|
45
|
-
| CLAUDE.md `@imports` | resolves `@path` imports pi's native loader skips; loads `CLAUDE.local.md` (approval-gated) | `context-imports.ts` |
|
|
46
|
-
| MCP servers | user `~/.claude.json` (incl. per-project `projects[cwd]` local scope), `~/.pi/agent/mcp.json`; project `.mcp.json`, `.pi/mcp.json` (once approved; `enabledMcpjsonServers`/`disabledMcpjsonServers`/`enableAllProjectMcpServers` honored, consent keys only from non-repo settings); stdio/HTTP/SSE by `type
|
|
44
|
+
| Hooks | `.claude/settings.json` hooks: PreToolUse (blocks, rewrites input via `updatedInput`), PostToolUse (feedback and `additionalContext` land next to the tool result), PostToolUseFailure, SessionStart (context injection), UserPromptSubmit (blocks and injects context), Stop (a block continues the conversation), SubagentStart/SubagentStop, PreCompact, PostCompact, SessionEnd, Notification (idle_prompt, the type pi can source), InstructionsLoaded (observational: fires per loaded context file at session start, plus `path_glob_match` on a scoped-rule attach and `include` per resolved `@import`; deduped per session; `nested_traversal`/`compact` reasons never fire since pi does not lazily load nested CLAUDE.md or reload after compaction); `type: http` entries POST the payload (a 2xx JSON body renders the decision, everything else is non-blocking per Claude's contract), `type: prompt` evaluates in-process against the session model, `type: mcp_tool` calls a connected server's tool, and `type: agent` (experimental) spawns a read-only Read/Grep/Glob subagent that returns the JSON decision (a missing model/server/runner is non-blocking, only a PreToolUse timeout fails closed); Claude matcher semantics incl. `mcp__server__tool` names; payloads carry session_id, transcript_path, cwd, permission_mode, effort; `permissionDecision: "ask"` prompts via a confirm dialog (blocks when headless), SubagentStop/PostToolUseFailure are notify-only, and a timed-out PreToolUse/UserPromptSubmit hook fails closed at a 60s default (Claude: 600s, non-blocking) since pi has no permission backstop | `hooks.ts` |
|
|
45
|
+
| Output styles | `.claude/output-styles` + active `outputStyle`; plus styles shipped by enabled plugins (manifest `outputStyles`, default `output-styles/`, ranked below the user's and project's own); Claude replace semantics with `keep-coding-instructions`; bundled Explanatory/Learning/Proactive; `/output-style [name]` | `output-styles.ts` |
|
|
46
|
+
| CLAUDE.md `@imports` and rewriting | resolves `@path` imports pi's native loader skips (4-hop depth, budget-capped); loads every `CLAUDE.local.md` from the repo root down to cwd (approval-gated, root first); injects managed `claudeMd` from `managed-settings.json` at the top of context; honors `claudeMdExcludes` (glob/absolute-path skip list from user, approved-project, and managed settings, merged; managed content never excluded); strips block-level HTML comments from CLAUDE.md, rule, and imported bodies (fenced-code comments preserved), so a commented-out `@import` does not expand; with `CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD` set, loads `CLAUDE.md`/`.claude/CLAUDE.md`/`.claude/rules/*.md`/`CLAUDE.local.md` from each `--add-dir` directory (comma-separated for several, since pi's flag is single-value) | `context-imports.ts` |
|
|
47
|
+
| MCP servers | user `~/.claude.json` (incl. per-project `projects[cwd]` local scope), `~/.pi/agent/mcp.json`; project `.mcp.json`, `.pi/mcp.json` (once approved; `enabledMcpjsonServers`/`disabledMcpjsonServers`/`enableAllProjectMcpServers` honored, consent keys only from non-repo settings); stdio/HTTP/SSE/WebSocket by `type` (WebSocket is url-only: any `headers`/`bearerToken`/`headersHelper` on a ws server is ignored with a warning); `${VAR:-default}` expansion; a `headersHelper` command whose stdout JSON merges into the transport headers (http/sse); managed `allowedMcpServers`/`deniedMcpServers` read from `managed-settings.json` only (`{serverName}` entries, applied globally across scopes, allow list exclusive with an empty array = lockdown, deny wins); `MCP_TIMEOUT`/`MCP_TOOL_TIMEOUT`; tools refresh on `list_changed`; bearer tokens, or OAuth for remote servers (browser login on 401 after a confirm, tokens under `~/.pi/agent/mcp-oauth`, silent refresh later) | `mcp.ts` |
|
|
47
48
|
| Project trust | prompts before loading project config (MCP servers, hooks, agents, rules, output styles, commands, skills) that pi would otherwise trust silently | `internal/project-approval.ts` |
|
|
48
|
-
| Subagents / Task | builtin Explore/Plan/general-purpose agents, `~/.claude/agents` and `~/.pi/agent/agents`, plus project `.claude/agents` and `.pi/agents
|
|
49
|
+
| Subagents / Task | builtin Explore/Plan/general-purpose agents, `~/.claude/agents` and `~/.pi/agent/agents`, plus project `.claude/agents` and `.pi/agents` (scanned recursively into subfolders) merged by default once the project is trusted (project wins on a name clash); frontmatter `tools`/`disallowedTools`/`model` (sonnet/opus/haiku/fable tier aliases or a concrete id)/`effort`/`skills` preload/`permissionMode: plan`/`maxTurns`/`memory` (`user`/`project`/`local` give the child its own persistent store under `.claude/agent-memory[-local]`, injected with Read/Write/Edit enabled, gated on auto memory; the parent conversation's memory is never loaded into a subagent, matching Claude); parallel and chain modes (pi extensions), one nesting level; background runs with cancel and resume | `subagent/` |
|
|
49
50
|
| Plan mode | `plan_mode_complete` tool, tool snapshot/restore that survives `/reload` | `plan-mode/` |
|
|
50
51
|
| Todo list | persistent overlay, status machine, compaction-safe | `todo.ts` |
|
|
51
52
|
| Checkpoints / rewind | shadow-repo snapshots; restore overwrites checkpointed files, keeps files created later; 100 per session, repos pruned after 30 days | `git-checkpoint.ts` |
|
|
52
|
-
| Persistent memory | per-
|
|
53
|
-
| WebSearch / WebFetch | key-free DuckDuckGo search
|
|
54
|
-
| AskUserQuestion | 1-4 questions per call (asked in sequence), each with `header
|
|
55
|
-
| Statusline | Claude `statusLine` command contract (stdin JSON, `padding`, `refreshInterval
|
|
56
|
-
| Notifications |
|
|
53
|
+
| Persistent memory | per-repo memories under `~/.pi/agent/memory` keyed on the repository root (subdirectory sessions share one store, as Claude does; pi's own store, separate from Claude's), index injected each session within Claude's 200-line/25KB bound (YAML frontmatter and block HTML comments stripped before it counts or loads); a save that would overflow it reports why; a memory written with frontmatter gets a `modified:` ISO timestamp; honors `autoMemoryEnabled` (settings) and `CLAUDE_CODE_DISABLE_AUTO_MEMORY` (env) to turn it off, and `autoMemoryDirectory` (absolute or `~/`) to relocate the store | `memory.ts` |
|
|
54
|
+
| WebSearch / WebFetch | key-free DuckDuckGo search (with `allowed_domains`/`blocked_domains`); SSRF-guarded fetch that prefers markdown via `Accept` then converts HTML, with Claude's 15-minute per-URL cache and an optional `prompt` that runs the page through the model in-process and returns the answer (falls back to markdown when headless or on error) | `web.ts` |
|
|
55
|
+
| AskUserQuestion | 1-4 questions per call (asked in sequence), each with `header` and 2-4 options, single- or `multiSelect`, plus free-text | `question.ts` |
|
|
56
|
+
| Statusline | Claude `statusLine` command contract (stdin JSON incl. `version`, `hook_event_name`, `session_name`, cost durations and line counters, context percentages; `padding`, `refreshInterval`, 300ms debounce); built-in turn state + session cost fallback | `status-line.ts` |
|
|
57
|
+
| Notifications | terminal notification when a turn ends (OSC 777 / Kitty OSC 99 / Windows toast); honors `preferredNotifChannel` (`terminal_bell`, `notifications_disabled`, `iterm2_with_bell`, else desktop) from user settings; fires only when you "appear to be away" (approximated by turn duration, since pi exposes no terminal-focus signal) | `notify.ts` |
|
|
58
|
+
| Claude plugins | installed marketplace plugins (`~/.claude/plugins/cache`), active per `enabledPlugins` in user settings only (a checked-out repo cannot flip which code-bearing plugins run, so project settings never toggle them): commands as `/plugin:name`, agents, hooks and MCP servers (tools aliased `mcp__plugin_<plugin>_<server>__<tool>`) and output styles with `${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PLUGIN_DATA}` and `${user_config.KEY}` (from `pluginConfigs[id].options` in user settings) substituted; skill dirs contribute too, though pi's loader names them without the plugin prefix | `internal/plugins.ts` |
|
|
59
|
+
|
|
60
|
+
pi has no general permission system, so most of what Claude routes through a permission prompt maps to hard behavior here: `allowed-tools` restricts the turn's tool set instead of pre-approving calls, and a hook that times out on PreToolUse or UserPromptSubmit fails closed. A hook's `permissionDecision: "ask"` is the exception: it shows a confirm dialog and lets the call through when you approve (a headless run has no dialog, so it blocks). Where a Claude restriction cannot be expressed at all (an argument-scoped grant in an agent's `tools:`), the definition is rejected rather than widened.
|
|
57
61
|
|
|
58
62
|
`CLAUDE.md` itself needs no extension: pi loads `CLAUDE.md` / `AGENTS.md` context files natively (global + walking cwd to root). `context-imports.ts` only adds the `@import` resolution pi's loader lacks, appending the imported files without re-injecting the base.
|
|
59
63
|
|
|
@@ -71,4 +75,12 @@ scripts/e2e-full.sh # every README feature end to end, model turns included
|
|
|
71
75
|
scripts/record-demos.sh # re-records demos/*.tape with vhs at low thinking
|
|
72
76
|
```
|
|
73
77
|
|
|
74
|
-
Extensions live in `extensions/`, tests in `tests/`. Install a local checkout with `pi install ./pi-code`, then `/reload` after edits.
|
|
78
|
+
Extensions live in `extensions/`, tests in `tests/`. Install a local checkout with `pi install ./pi-code`, then `/reload` after edits. Development needs Node `>=22.19`.
|
|
79
|
+
|
|
80
|
+
## Contributing
|
|
81
|
+
|
|
82
|
+
Issues and pull requests are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for setup, the test and review conventions, and the release process, and the [Code of Conduct](CODE_OF_CONDUCT.md) for community expectations. Report security issues privately as described in [SECURITY.md](SECURITY.md). Release notes are on the [Releases page](https://github.com/ilovepixelart/pi-code/releases).
|
|
83
|
+
|
|
84
|
+
## License
|
|
85
|
+
|
|
86
|
+
[MIT](LICENSE).
|
|
@@ -2,14 +2,16 @@
|
|
|
2
2
|
* Claude Rules Extension
|
|
3
3
|
*
|
|
4
4
|
* Replicates Claude Code's rules loading:
|
|
5
|
-
* - Unscoped
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* Path-scoped rules: a rule file may declare `paths:` frontmatter
|
|
10
|
-
* list of globs).
|
|
11
|
-
*
|
|
12
|
-
*
|
|
5
|
+
* - Unscoped rules are inlined in full into the system prompt, global
|
|
6
|
+
* (~/.claude/rules/*.md) and approved-project (.claude/rules/*.md) alike:
|
|
7
|
+
* Claude loads rules without `paths:` frontmatter at launch with the same
|
|
8
|
+
* priority as .claude/CLAUDE.md.
|
|
9
|
+
* - Path-scoped rules auto-attach: a rule file may declare `paths:` frontmatter
|
|
10
|
+
* (a glob or list of globs). Its scope is surfaced upfront as a pointer, and
|
|
11
|
+
* when a read/edit/write touches a file the globs cover, the rule body is
|
|
12
|
+
* appended to that tool's result, once per rule per session. This mirrors
|
|
13
|
+
* Claude Code, which attaches a scoped rule when a matching file is touched
|
|
14
|
+
* rather than inlining it everywhere. Frontmatter is stripped from rule text.
|
|
13
15
|
*
|
|
14
16
|
* Adapted from the pi v0.74.2 claude-rules example.
|
|
15
17
|
*/
|
|
@@ -19,7 +21,11 @@ import * as os from 'node:os'
|
|
|
19
21
|
import * as path from 'node:path'
|
|
20
22
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
21
23
|
|
|
24
|
+
import { publishInstructionLoad } from './internal/instruction-events.js'
|
|
25
|
+
import { globToRegExpSource } from './internal/path-rules.js'
|
|
22
26
|
import { isProjectApproved } from './internal/project-approval.js'
|
|
27
|
+
import { findNearestDir } from './internal/project-root.js'
|
|
28
|
+
import { stripBlockComments } from './internal/strip-comments.js'
|
|
23
29
|
|
|
24
30
|
export interface Frontmatter {
|
|
25
31
|
paths: string[]
|
|
@@ -64,6 +70,32 @@ export function parseFrontmatter(content: string): Frontmatter {
|
|
|
64
70
|
return { paths: parsePaths(match[1]), body: content.slice(match[0].length) }
|
|
65
71
|
}
|
|
66
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Whether a file path matches at least one of a rule's `paths:` globs. `*` stays in
|
|
75
|
+
* a segment, `**` crosses directories, a slashless pattern (`*.ts`) matches the
|
|
76
|
+
* basename at any depth (gitignore-style), and a trailing slash (`docs/`) scopes to
|
|
77
|
+
* that directory's contents. `./` and a leading `/` are stripped so a project-root
|
|
78
|
+
* anchored glob resolves the same as a bare one. A bare directory name without a
|
|
79
|
+
* trailing slash or `**` matches a file of that name, not the directory's contents;
|
|
80
|
+
* write `dir/**` to scope to a directory. `relPath` is the touched file relative to
|
|
81
|
+
* the rule set's root.
|
|
82
|
+
*/
|
|
83
|
+
export function pathMatchesGlobs(relPath: string, globs: string[]): boolean {
|
|
84
|
+
const posix = relPath.split(path.sep).join('/')
|
|
85
|
+
const base = posix.split('/').pop() ?? posix
|
|
86
|
+
return globs.some((raw) => {
|
|
87
|
+
let glob = raw.trim()
|
|
88
|
+
if (!glob) return false
|
|
89
|
+
if (glob.startsWith('./')) glob = glob.slice(2)
|
|
90
|
+
else if (glob.startsWith('/')) glob = glob.slice(1)
|
|
91
|
+
// A trailing slash means the directory's contents, like gitignore; `docs/` alone
|
|
92
|
+
// would compile to `^docs/$` and match nothing.
|
|
93
|
+
if (glob.endsWith('/')) glob += '**'
|
|
94
|
+
const target = glob.includes('/') ? posix : base
|
|
95
|
+
return new RegExp(`^${globToRegExpSource(glob)}$`).test(target)
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
|
|
67
99
|
/** A rule pointer line, annotated with its path scope when present. */
|
|
68
100
|
export function formatRulePointer(rel: string, paths: string[], base = '.claude/rules'): string {
|
|
69
101
|
const ref = `- ${base}/${rel}`
|
|
@@ -112,83 +144,155 @@ function findMarkdownFiles(dir: string, basePath = '', visited = new Set<string>
|
|
|
112
144
|
return results
|
|
113
145
|
}
|
|
114
146
|
|
|
115
|
-
interface
|
|
147
|
+
interface ScopedRule {
|
|
116
148
|
rel: string
|
|
117
149
|
paths: string[]
|
|
150
|
+
/** The rule text, attached when a matching file is touched. */
|
|
151
|
+
body: string
|
|
118
152
|
}
|
|
119
153
|
|
|
120
|
-
interface
|
|
121
|
-
inline: string
|
|
122
|
-
scoped:
|
|
154
|
+
interface RuleSet {
|
|
155
|
+
inline: string[]
|
|
156
|
+
scoped: ScopedRule[]
|
|
123
157
|
}
|
|
124
158
|
|
|
125
|
-
|
|
159
|
+
const EMPTY_RULES: RuleSet = { inline: [], scoped: [] }
|
|
160
|
+
|
|
161
|
+
/** Unscoped rules are inlined; path-scoped ones keep their scope as pointers,
|
|
126
162
|
* mirroring Claude Code, where scoped rules attach only to matching files. */
|
|
127
|
-
function
|
|
163
|
+
function readRules(rulesDir: string): RuleSet {
|
|
128
164
|
const inline: string[] = []
|
|
129
|
-
const scoped:
|
|
130
|
-
for (const file of findMarkdownFiles(
|
|
165
|
+
const scoped: ScopedRule[] = []
|
|
166
|
+
for (const file of findMarkdownFiles(rulesDir)) {
|
|
131
167
|
let parsed: Frontmatter
|
|
132
168
|
try {
|
|
133
|
-
parsed = parseFrontmatter(fs.readFileSync(path.join(
|
|
169
|
+
parsed = parseFrontmatter(fs.readFileSync(path.join(rulesDir, file), 'utf-8'))
|
|
134
170
|
} catch {
|
|
135
171
|
continue // one unreadable rule must not take down session start
|
|
136
172
|
}
|
|
137
|
-
|
|
138
|
-
|
|
173
|
+
// Rule bodies get the same block-level comment strip as CLAUDE.md files
|
|
174
|
+
// before they reach the prompt or attach to a tool result.
|
|
175
|
+
const body = stripBlockComments(parsed.body).trim()
|
|
176
|
+
// A body that strips to nothing has nothing to inline or attach; attaching
|
|
177
|
+
// an empty text block to a tool result is rejected by the API when the
|
|
178
|
+
// result's content is a block array (image-bearing results).
|
|
179
|
+
if (body.length === 0) continue
|
|
180
|
+
if (parsed.paths.length > 0) scoped.push({ rel: file, paths: parsed.paths, body })
|
|
181
|
+
else inline.push(body)
|
|
139
182
|
}
|
|
140
|
-
return { inline
|
|
183
|
+
return { inline, scoped }
|
|
141
184
|
}
|
|
142
185
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
186
|
+
/** The system-prompt section for one rule set: inlined bodies, then scoped pointers. */
|
|
187
|
+
function rulesSection(title: string, rules: RuleSet, base: string): string {
|
|
188
|
+
if (rules.inline.length === 0 && rules.scoped.length === 0) return ''
|
|
189
|
+
let section = `\n\n## ${title}`
|
|
190
|
+
if (rules.inline.length > 0) {
|
|
191
|
+
section += `\n\nThese rules always apply:\n\n${rules.inline.join('\n\n')}`
|
|
192
|
+
}
|
|
193
|
+
if (rules.scoped.length > 0) {
|
|
194
|
+
const scopedList = rules.scoped.map((rule) => formatRulePointer(rule.rel, rule.paths, base)).join('\n')
|
|
195
|
+
section += `\n\nPath-scoped rules, available in ${base}/:\n\n${scopedList}\n\nRead the relevant rule file with the read tool before working on the files it covers.`
|
|
196
|
+
}
|
|
197
|
+
return section
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** A scoped rule resolved to the root its globs match against, ready to attach. */
|
|
201
|
+
interface AttachTarget {
|
|
202
|
+
key: string
|
|
203
|
+
globs: string[]
|
|
204
|
+
body: string
|
|
205
|
+
/** The absolute directory `paths:` globs are matched relative to. */
|
|
206
|
+
root: string
|
|
207
|
+
/** The rule file's absolute path, reported on the instruction-events bus. */
|
|
208
|
+
file: string
|
|
209
|
+
/** Claude's memory_type for the rule's origin: global rules are User config. */
|
|
210
|
+
memoryType: 'User' | 'Project'
|
|
151
211
|
}
|
|
152
212
|
|
|
153
213
|
export default function claudeRulesExtension(pi: ExtensionAPI) {
|
|
154
214
|
const globalRulesDir = path.join(os.homedir(), '.claude', 'rules')
|
|
155
|
-
let globalRules:
|
|
156
|
-
let projectRules:
|
|
215
|
+
let globalRules: RuleSet = EMPTY_RULES
|
|
216
|
+
let projectRules: RuleSet = EMPTY_RULES
|
|
217
|
+
// The base a scoped-rule pointer is written against, so the model's read resolves.
|
|
218
|
+
// The project rules dir may sit at an ancestor of cwd, where a cwd-relative
|
|
219
|
+
// '.claude/rules' would point the read at a path that does not exist.
|
|
220
|
+
let projectRulesBase = '.claude/rules'
|
|
221
|
+
// Scoped rules ready to attach when a matching file is touched, and the set of
|
|
222
|
+
// rules already attached this session so each attaches at most once.
|
|
223
|
+
let attachTargets: AttachTarget[] = []
|
|
224
|
+
const attached = new Set<string>()
|
|
157
225
|
|
|
158
226
|
pi.on('session_start', async (_event, ctx) => {
|
|
159
|
-
globalRules =
|
|
160
|
-
// Project
|
|
161
|
-
// isProjectTrusted alone is true for a repo
|
|
227
|
+
globalRules = readRules(globalRulesDir)
|
|
228
|
+
// Project rules are repository text landing in the system prompt, so they load
|
|
229
|
+
// only once the project is approved. isProjectTrusted alone is true for a repo
|
|
230
|
+
// pi never asked about; see project-approval.
|
|
162
231
|
const approved = await isProjectApproved(ctx)
|
|
163
|
-
|
|
232
|
+
// Nearest at-or-above cwd, so a subdirectory session still reads the rules the
|
|
233
|
+
// approval walk gated on.
|
|
234
|
+
const projectRulesDir = approved ? findNearestDir(ctx.cwd, path.join('.claude', 'rules')) : null
|
|
235
|
+
projectRules = projectRulesDir ? readRules(projectRulesDir) : EMPTY_RULES
|
|
236
|
+
|
|
237
|
+
// Global globs are relative to cwd; project globs to the project root (the dir
|
|
238
|
+
// holding .claude), so `db/**` in a repo rule matches repo-relative paths even
|
|
239
|
+
// from a subdirectory session. Reset per session so a re-run re-attaches.
|
|
240
|
+
attached.clear()
|
|
241
|
+
const projectRoot = projectRulesDir ? path.dirname(path.dirname(projectRulesDir)) : ctx.cwd
|
|
242
|
+
attachTargets = [
|
|
243
|
+
...globalRules.scoped.map((rule) => ({ key: `global:${rule.rel}`, globs: rule.paths, body: rule.body, root: ctx.cwd, file: path.join(globalRulesDir, rule.rel), memoryType: 'User' as const })),
|
|
244
|
+
...projectRules.scoped.map((rule) => ({ key: `project:${rule.rel}`, globs: rule.paths, body: rule.body, root: projectRoot, file: path.join(projectRulesDir ?? path.join(ctx.cwd, '.claude', 'rules'), rule.rel), memoryType: 'Project' as const })),
|
|
245
|
+
]
|
|
246
|
+
// Relative to cwd, which the read tool resolves: an ancestor dir yields a
|
|
247
|
+
// `../…/.claude/rules` the model can follow, where a bare '.claude/rules'
|
|
248
|
+
// would point at a nonexistent path under the subdirectory.
|
|
249
|
+
projectRulesBase = projectRulesDir === null ? '.claude/rules' : path.relative(ctx.cwd, projectRulesDir) || '.claude/rules'
|
|
164
250
|
|
|
165
251
|
const hasGlobal = globalRules.inline.length > 0 || globalRules.scoped.length > 0
|
|
166
|
-
|
|
167
|
-
|
|
252
|
+
const projectCount = projectRules.inline.length + projectRules.scoped.length
|
|
253
|
+
if (hasGlobal || projectCount > 0) {
|
|
254
|
+
ctx.ui.notify(`Rules loaded: global ${hasGlobal ? 'yes' : 'no'}, project ${projectCount}`, 'info')
|
|
168
255
|
}
|
|
169
256
|
})
|
|
170
257
|
|
|
171
258
|
pi.on('before_agent_start', async (event) => {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
addition += `\n\n## Global Rules`
|
|
176
|
-
if (globalRules.inline.length > 0) {
|
|
177
|
-
addition += `\n\nThese rules always apply:\n\n${globalRules.inline}`
|
|
178
|
-
}
|
|
179
|
-
if (globalRules.scoped.length > 0) {
|
|
180
|
-
const scopedList = globalRules.scoped.map((rule) => formatRulePointer(rule.rel, rule.paths, '~/.claude/rules')).join('\n')
|
|
181
|
-
addition += `\n\nPath-scoped global rules, available in ~/.claude/rules/:\n\n${scopedList}\n\nRead the relevant rule file with the read tool before working on the files it covers.`
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
if (projectRules.length > 0) {
|
|
186
|
-
const rulesList = projectRules.map((rule) => formatRulePointer(rule.rel, rule.paths)).join('\n')
|
|
187
|
-
addition += `\n\n## Project Rules\n\nThe following project rules are available in .claude/rules/:\n\n${rulesList}\n\nRead the relevant rule file with the read tool before working on the files it covers; rules with an "applies when" scope are path-scoped.`
|
|
188
|
-
}
|
|
189
|
-
|
|
259
|
+
// Global first: Claude loads user-level rules before project rules, so project
|
|
260
|
+
// rules read later and take priority.
|
|
261
|
+
const addition = rulesSection('Global Rules', globalRules, '~/.claude/rules') + rulesSection('Project Rules', projectRules, projectRulesBase)
|
|
190
262
|
if (addition.length === 0) return
|
|
191
263
|
|
|
192
264
|
return { systemPrompt: event.systemPrompt + addition }
|
|
193
265
|
})
|
|
266
|
+
|
|
267
|
+
// Lazy attach: when a file tool touches a path a scoped rule covers, append the
|
|
268
|
+
// rule body to that tool's result so it enters context, once per rule per session.
|
|
269
|
+
// This mirrors Claude Code, which attaches a scoped rule when a matching file is
|
|
270
|
+
// read or edited rather than inlining it upfront.
|
|
271
|
+
pi.on('tool_result', async (event, ctx) => {
|
|
272
|
+
if (attachTargets.length === 0) return
|
|
273
|
+
if (event.isError) return
|
|
274
|
+
if (event.toolName !== 'read' && event.toolName !== 'edit' && event.toolName !== 'write') return
|
|
275
|
+
const rel = (event.input as { path?: unknown } | undefined)?.path
|
|
276
|
+
if (typeof rel !== 'string' || rel.length === 0) return
|
|
277
|
+
const abs = path.resolve(ctx.cwd, rel)
|
|
278
|
+
|
|
279
|
+
const bodies: string[] = []
|
|
280
|
+
for (const target of attachTargets) {
|
|
281
|
+
if (attached.has(target.key)) continue
|
|
282
|
+
const relativeToRoot = path.relative(target.root, abs)
|
|
283
|
+
// A file outside the rule root cannot match its project-relative globs. Test for
|
|
284
|
+
// a real parent-traversal segment, not a leading '..' (a file named `..config` is
|
|
285
|
+
// inside the root).
|
|
286
|
+
if (relativeToRoot === '..' || relativeToRoot.startsWith(`..${path.sep}`) || path.isAbsolute(relativeToRoot)) continue
|
|
287
|
+
if (!pathMatchesGlobs(relativeToRoot, target.globs)) continue
|
|
288
|
+
attached.add(target.key)
|
|
289
|
+
bodies.push(target.body)
|
|
290
|
+
// The lazy attach is Claude's path_glob_match instruction load; the hooks
|
|
291
|
+
// extension bridges the bus event to the InstructionsLoaded hook. The
|
|
292
|
+
// once-per-session attach set above also bounds the events to one per rule.
|
|
293
|
+
publishInstructionLoad(pi.events, { file_path: target.file, memory_type: target.memoryType, load_reason: 'path_glob_match', globs: target.globs, trigger_file_path: abs })
|
|
294
|
+
}
|
|
295
|
+
if (bodies.length === 0) return
|
|
296
|
+
return { content: [...event.content, ...bodies.map((text) => ({ type: 'text' as const, text }))] }
|
|
297
|
+
})
|
|
194
298
|
}
|
package/extensions/commands.ts
CHANGED
|
@@ -6,10 +6,11 @@
|
|
|
6
6
|
* is what makes the rest of Claude's command contract reachable: namespaced
|
|
7
7
|
* subdirectories (`frontend/build.md` is `/frontend:build`), `$ARGUMENTS` and
|
|
8
8
|
* positional substitution, `` !`cmd` `` bash output, `@file` inlining, and the
|
|
9
|
-
* `allowed-tools
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
9
|
+
* `allowed-tools`, `argument-hint` and `model` frontmatter (`model` switches the
|
|
10
|
+
* session model for the command's turn via `pi.setModel`, restored on turn_end).
|
|
11
|
+
* `shell: powershell` runs a command's injected spans through PowerShell when a
|
|
12
|
+
* pwsh binary is installed, falling back to /bin/sh so the command still works
|
|
13
|
+
* without one. `disable-model-invocation` is parsed but not applied yet.
|
|
13
14
|
*
|
|
14
15
|
* A project command body is repository-controlled text that can now run shell
|
|
15
16
|
* commands and read files, so project commands load only once the project is
|
|
@@ -24,11 +25,52 @@ import * as os from 'node:os'
|
|
|
24
25
|
import * as path from 'node:path'
|
|
25
26
|
import type { ExtensionAPI, ExtensionCommandContext } from '@earendil-works/pi-coding-agent'
|
|
26
27
|
|
|
27
|
-
import {
|
|
28
|
+
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'
|
|
30
|
+
import { matchesPathRules } from './internal/path-rules.js'
|
|
31
|
+
import { type InstalledPlugin, installedPlugins } from './internal/plugins.js'
|
|
28
32
|
import { isProjectApproved } from './internal/project-approval.js'
|
|
33
|
+
import { findNearestDir, repoRoot } from './internal/project-root.js'
|
|
29
34
|
|
|
30
|
-
|
|
31
|
-
|
|
35
|
+
type PathRuleTool = 'read' | 'edit' | 'write'
|
|
36
|
+
|
|
37
|
+
/** Just enough of pi's Model to match and restore; getAvailable returns these. */
|
|
38
|
+
interface ModelLike {
|
|
39
|
+
id: string
|
|
40
|
+
name?: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Resolve a command's `model:` frontmatter to an available model. Claude accepts a
|
|
44
|
+
* tier alias (sonnet/opus/haiku/fable), a concrete id, or `inherit`; pi matches by
|
|
45
|
+
* exact id first, then a substring of the id or name (the same fuzzy rule the
|
|
46
|
+
* subagent uses). `inherit` and an unresolvable name leave the model unchanged. */
|
|
47
|
+
function resolveCommandModel(model: string | undefined, available: ReadonlyArray<ModelLike>): ModelLike | undefined {
|
|
48
|
+
if (!model || model.toLowerCase() === 'inherit') return undefined
|
|
49
|
+
const needle = model.toLowerCase()
|
|
50
|
+
return available.find((m) => m.id.toLowerCase() === needle) ?? available.find((m) => m.id.toLowerCase().includes(needle) || (m.name ?? '').toLowerCase().includes(needle))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Substitute ${CLAUDE_*} into a path rule. A variable that expands to an absolute
|
|
54
|
+
* path at the anchor position (e.g. Read(${CLAUDE_PROJECT_DIR}/docs/**)) names that
|
|
55
|
+
* exact location, so it is marked with Claude's `//` filesystem-absolute anchor;
|
|
56
|
+
* otherwise resolveRule reads the single leading slash as project-relative and
|
|
57
|
+
* re-anchors it under the project root, where it can never match. */
|
|
58
|
+
function substitutePathRule(rule: string, vars: Record<string, string | undefined>): string {
|
|
59
|
+
const substituted = substituteVars(rule, vars)
|
|
60
|
+
return rule.trimStart().startsWith('${CLAUDE_') && path.isAbsolute(substituted) ? `/${substituted}` : substituted
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Wall-clock budget for one injected span: the Bash tool's documented 2-minute
|
|
64
|
+
* default, which is what Claude runs these commands under. */
|
|
65
|
+
const BASH_TIMEOUT_MS = 120_000
|
|
66
|
+
|
|
67
|
+
/** Context fields pi provides that Claude's ${CLAUDE_*} variables read from. */
|
|
68
|
+
interface VarContext {
|
|
69
|
+
sessionManager?: { getSessionId?: () => string }
|
|
70
|
+
thinkingLevel?: string
|
|
71
|
+
model?: ModelLike
|
|
72
|
+
modelRegistry?: { getAvailable(): ReadonlyArray<ModelLike> }
|
|
73
|
+
}
|
|
32
74
|
|
|
33
75
|
function isDirectory(target: string): boolean {
|
|
34
76
|
try {
|
|
@@ -39,10 +81,11 @@ function isDirectory(target: string): boolean {
|
|
|
39
81
|
}
|
|
40
82
|
|
|
41
83
|
/** Existing `.claude/commands` directories, user first then project. The project
|
|
42
|
-
* directory is
|
|
84
|
+
* directory is the nearest at or above cwd (bounded at the repository root, matching
|
|
85
|
+
* the approval walk) and is included only for approved projects. */
|
|
43
86
|
export function commandDirs(cwd: string, home: string, trusted: boolean): string[] {
|
|
44
87
|
const candidates = [path.join(home, '.claude', 'commands')]
|
|
45
|
-
if (trusted) candidates.push(path.join(cwd, '.claude', 'commands'))
|
|
88
|
+
if (trusted) candidates.push(findNearestDir(cwd, path.join('.claude', 'commands')) ?? path.join(cwd, '.claude', 'commands'))
|
|
46
89
|
const dirs: string[] = []
|
|
47
90
|
for (const dir of candidates) {
|
|
48
91
|
if (!dirs.includes(dir) && isDirectory(dir)) dirs.push(dir)
|
|
@@ -59,27 +102,110 @@ export function collectCommands(dirs: string[]): DiscoveredCommand[] {
|
|
|
59
102
|
return [...byName.values()]
|
|
60
103
|
}
|
|
61
104
|
|
|
105
|
+
/** A plugin's command files, namespaced `plugin:name` as Claude registers them.
|
|
106
|
+
* The manifest may point `commands` somewhere else; the default is `commands/`. */
|
|
107
|
+
export function pluginCommands(plugins: InstalledPlugin[]): DiscoveredCommand[] {
|
|
108
|
+
const found: DiscoveredCommand[] = []
|
|
109
|
+
for (const plugin of plugins) {
|
|
110
|
+
const declared = plugin.manifest.commands
|
|
111
|
+
const dirs = (Array.isArray(declared) ? declared : [typeof declared === 'string' ? declared : 'commands']).map((entry) => path.resolve(plugin.root, String(entry)))
|
|
112
|
+
for (const dir of dirs) {
|
|
113
|
+
for (const command of discoverCommandFiles(dir)) {
|
|
114
|
+
found.push({ name: `${plugin.name}:${command.name}`, filePath: command.filePath, plugin: { root: plugin.root, dataDir: plugin.dataDir, ...(plugin.userConfig ? { userConfig: plugin.userConfig } : {}) } })
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return found
|
|
119
|
+
}
|
|
120
|
+
|
|
62
121
|
export default function commandsExtension(pi: ExtensionAPI) {
|
|
63
122
|
const registered = new Set<string>()
|
|
64
123
|
/** Tool set to put back once the turn a restricted command drove has ended. */
|
|
65
124
|
let pendingRestore: string[] | undefined
|
|
125
|
+
/** `Bash(...)` scopes enforced while that turn runs; lifted with the restriction. */
|
|
126
|
+
let pendingBashRules: string[] | undefined
|
|
127
|
+
/** Read/Edit path scopes enforced the same way, per pi file tool. */
|
|
128
|
+
let pendingPathRules: Partial<Record<PathRuleTool, string[]>> | undefined
|
|
129
|
+
/** The session model to restore after a command's `model:` override drove its turn. */
|
|
130
|
+
let pendingModelRestore: ModelLike | undefined
|
|
66
131
|
|
|
67
132
|
pi.on('turn_end', async () => {
|
|
133
|
+
pendingBashRules = undefined
|
|
134
|
+
pendingPathRules = undefined
|
|
135
|
+
if (pendingModelRestore) {
|
|
136
|
+
void pi.setModel(pendingModelRestore as Parameters<typeof pi.setModel>[0])
|
|
137
|
+
pendingModelRestore = undefined
|
|
138
|
+
}
|
|
68
139
|
if (!pendingRestore) return
|
|
69
140
|
pi.setActiveTools(pendingRestore)
|
|
70
141
|
pendingRestore = undefined
|
|
71
142
|
})
|
|
72
143
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
144
|
+
// The active-tool set has no argument dimension, so a scoped grant hands the turn
|
|
145
|
+
// the whole tool; the scope is enforced here instead, when the call arrives. Same
|
|
146
|
+
// steering-not-sandbox caveat as plan mode's guard.
|
|
147
|
+
pi.on('tool_call', async (event, ctx) => {
|
|
148
|
+
if (pendingBashRules && event.toolName === 'bash') {
|
|
149
|
+
const command = typeof event.input.command === 'string' ? event.input.command : ''
|
|
150
|
+
if (matchesBashRules(command, pendingBashRules)) return
|
|
151
|
+
return {
|
|
152
|
+
block: true,
|
|
153
|
+
reason: `allowed-tools: bash is scoped for this command.\nAllowed: ${pendingBashRules.join(', ')}\nCommand: ${command}`,
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const rules = pendingPathRules?.[event.toolName as PathRuleTool]
|
|
157
|
+
if (!rules) return
|
|
158
|
+
const input = event.input as Record<string, unknown>
|
|
159
|
+
const filePath = typeof input.path === 'string' ? input.path : ''
|
|
160
|
+
const anchors = { cwd: ctx.cwd, projectRoot: repoRoot(ctx.cwd) ?? ctx.cwd, home: os.homedir() }
|
|
161
|
+
if (filePath && matchesPathRules(filePath, rules, anchors)) return
|
|
162
|
+
return {
|
|
163
|
+
block: true,
|
|
164
|
+
reason: `allowed-tools: ${event.toolName} is scoped for this command.\nAllowed: ${rules.join(', ')}\nPath: ${filePath}`,
|
|
165
|
+
}
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
async function runCommand(parsed: ParsedCommand, args: string, ctx: ExtensionCommandContext, filePath: string, plugin?: { root: string; dataDir: string; userConfig?: Record<string, string> }): Promise<void> {
|
|
169
|
+
const varCtx = ctx as unknown as VarContext
|
|
170
|
+
const projectRoot = repoRoot(ctx.cwd) ?? ctx.cwd
|
|
171
|
+
const vars: Record<string, string | undefined> = {
|
|
172
|
+
CLAUDE_SESSION_ID: varCtx.sessionManager?.getSessionId?.(),
|
|
173
|
+
CLAUDE_EFFORT: varCtx.thinkingLevel,
|
|
174
|
+
CLAUDE_SKILL_DIR: path.dirname(filePath),
|
|
175
|
+
CLAUDE_PROJECT_DIR: projectRoot,
|
|
176
|
+
CLAUDE_PLUGIN_ROOT: plugin?.root,
|
|
177
|
+
CLAUDE_PLUGIN_DATA: plugin?.dataDir,
|
|
178
|
+
}
|
|
179
|
+
const { text: withArgs, consumed } = substituteArgsDetailed(parsed.body, args, parsed.argumentNames ?? [])
|
|
180
|
+
// `${user_config.KEY}` is a plugin-command variable only; leave it literal in an
|
|
181
|
+
// ordinary command so a body that happens to contain the syntax is not stripped.
|
|
182
|
+
const substituted = substituteVars(withArgs, vars)
|
|
183
|
+
const withVars = plugin ? substituted.replace(/\$\{user_config\.([A-Za-z0-9_]+)\}/g, (_, key: string) => plugin.userConfig?.[key] ?? '') : substituted
|
|
184
|
+
|
|
185
|
+
let expanded: string
|
|
186
|
+
try {
|
|
187
|
+
expanded = await expandDynamicContent(withVars, ctx.cwd, async (script) => {
|
|
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
|
+
})
|
|
199
|
+
} catch (error) {
|
|
200
|
+
// A failed injected command aborts the invocation; the model never sees a
|
|
201
|
+
// half-expanded body. The notify carries Claude's failure message format.
|
|
202
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), 'error')
|
|
203
|
+
return
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Claude appends the raw arguments when the command never read them, so what
|
|
207
|
+
// the user typed still reaches the model.
|
|
208
|
+
if (args.trim().length > 0 && !consumed) expanded += `\n\nARGUMENTS: ${args.trim()}`
|
|
83
209
|
|
|
84
210
|
// allowed-tools restricts the turn the command drives, and the previous set is
|
|
85
211
|
// restored when that turn ends. Restoring inline does not work: sendUserMessage is
|
|
@@ -96,13 +222,45 @@ export default function commandsExtension(pi: ExtensionAPI) {
|
|
|
96
222
|
// intersects to nothing named only tools pi has none of: that restriction cannot
|
|
97
223
|
// be expressed, and applying it as "no tools" is not what the command asked for.
|
|
98
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])
|
|
99
253
|
}
|
|
100
254
|
pi.sendUserMessage(expanded)
|
|
101
255
|
}
|
|
102
256
|
|
|
103
257
|
pi.on('session_start', async (_event, ctx) => {
|
|
104
258
|
const trusted = await isProjectApproved(ctx)
|
|
105
|
-
|
|
259
|
+
// Plugins are user-installed and enabled by user settings; a checked-out repo
|
|
260
|
+
// must not silently flip which code-bearing plugins run, so enablement is
|
|
261
|
+
// user-scoped and never reads the project settings chain (see installedPlugins).
|
|
262
|
+
const plugins = pluginCommands(installedPlugins(os.homedir()))
|
|
263
|
+
for (const command of [...collectCommands(commandDirs(ctx.cwd, os.homedir(), trusted)), ...plugins]) {
|
|
106
264
|
// pi has no unregister, so a command already registered this process keeps its
|
|
107
265
|
// original file binding; re-registering would only add a numbered duplicate.
|
|
108
266
|
if (registered.has(command.name)) continue
|
|
@@ -123,7 +281,7 @@ export default function commandsExtension(pi: ExtensionAPI) {
|
|
|
123
281
|
} catch {
|
|
124
282
|
// fall back to what was parsed at registration
|
|
125
283
|
}
|
|
126
|
-
await runCommand(current, args, commandCtx)
|
|
284
|
+
await runCommand(current, args, commandCtx, command.filePath, command.plugin)
|
|
127
285
|
},
|
|
128
286
|
})
|
|
129
287
|
}
|