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.
Files changed (35) hide show
  1. package/README.md +25 -13
  2. package/extensions/claude-rules.ts +158 -54
  3. package/extensions/commands.ts +179 -21
  4. package/extensions/context-imports.ts +353 -39
  5. package/extensions/hooks.ts +351 -63
  6. package/extensions/init.ts +81 -0
  7. package/extensions/internal/agent-run.ts +42 -0
  8. package/extensions/internal/bash-rules.ts +27 -0
  9. package/extensions/internal/command-file.ts +373 -59
  10. package/extensions/internal/html-markdown.ts +61 -0
  11. package/extensions/internal/instruction-events.ts +70 -0
  12. package/extensions/internal/managed-settings.ts +38 -0
  13. package/extensions/internal/mcp-call.ts +28 -0
  14. package/extensions/internal/mcp-oauth.ts +171 -0
  15. package/extensions/internal/model-complete.ts +68 -0
  16. package/extensions/internal/path-rules.ts +80 -0
  17. package/extensions/internal/plugins.ts +125 -0
  18. package/extensions/internal/project-approval.ts +2 -3
  19. package/extensions/internal/project-root.ts +78 -0
  20. package/extensions/internal/shell-split.ts +65 -0
  21. package/extensions/internal/strip-comments.ts +77 -0
  22. package/extensions/internal/web-transport.ts +3 -1
  23. package/extensions/mcp.ts +272 -28
  24. package/extensions/memory.ts +129 -16
  25. package/extensions/notify.ts +77 -4
  26. package/extensions/output-styles.ts +34 -6
  27. package/extensions/plan-mode/utils.ts +3 -57
  28. package/extensions/question.ts +2 -2
  29. package/extensions/skills.ts +11 -1
  30. package/extensions/status-line.ts +93 -3
  31. package/extensions/subagent/agents.ts +72 -61
  32. package/extensions/subagent/background.ts +25 -6
  33. package/extensions/subagent/index.ts +194 -29
  34. package/extensions/web.ts +80 -15
  35. 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` (+ `paths:` frontmatter scoping) | `claude-rules.ts` |
41
- | Custom slash commands | `.claude/commands/**/*.md` (namespaced `/dir:name`), `$ARGUMENTS`/`$1`, `` !`cmd` `` bash output, `@file` inlining, `allowed-tools`/`argument-hint` frontmatter (`model` is parsed but not yet applied); project commands gated on approval | `commands.ts` |
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`; `${VAR:-default}` expansion; `MCP_TIMEOUT`/`MCP_TOOL_TIMEOUT`; tools refresh on `list_changed` | `mcp.ts` |
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`; agent roster with descriptions in the system prompt; `skills` preload; background runs with cancel and resume | `subagent/` |
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-project memories, index injected each session within Claude's 200-line/25KB bound; a save that would overflow it reports why | `memory.ts` |
53
- | WebSearch / WebFetch | key-free DuckDuckGo search, SSRF-guarded fetch | `web.ts` |
54
- | AskUserQuestion | 1-4 questions per call (asked in sequence), each with `header`, single- or `multiSelect` options, plus free-text | `question.ts` |
55
- | Statusline | Claude `statusLine` command contract (stdin JSON, `padding`, `refreshInterval`); built-in turn state + session cost fallback | `status-line.ts` |
56
- | Notifications | vendored example | `notify.ts` |
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 global rules (~/.claude/rules/*.md) are inlined in full into the system prompt.
6
- * - Path-scoped global rules and all project rules (.claude/rules/*.md) are listed as
7
- * pointers the agent reads on demand.
8
- *
9
- * Path-scoped rules: a rule file may declare `paths:` frontmatter (a glob or
10
- * list of globs). Pointers surface that scope so the agent knows to read the
11
- * rule when working on matching files. Frontmatter is stripped from inlined
12
- * global rules.
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 ProjectRule {
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 GlobalRules {
121
- inline: string
122
- scoped: ProjectRule[]
154
+ interface RuleSet {
155
+ inline: string[]
156
+ scoped: ScopedRule[]
123
157
  }
124
158
 
125
- /** Unscoped global rules are inlined; path-scoped ones keep their scope as pointers,
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 readGlobalRules(globalRulesDir: string): GlobalRules {
163
+ function readRules(rulesDir: string): RuleSet {
128
164
  const inline: string[] = []
129
- const scoped: ProjectRule[] = []
130
- for (const file of findMarkdownFiles(globalRulesDir)) {
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(globalRulesDir, file), 'utf-8'))
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
- if (parsed.paths.length > 0) scoped.push({ rel: file, paths: parsed.paths })
138
- else if (parsed.body.trim().length > 0) inline.push(parsed.body.trim())
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: inline.join('\n\n'), scoped }
183
+ return { inline, scoped }
141
184
  }
142
185
 
143
- function readProjectRules(projectRulesDir: string): ProjectRule[] {
144
- return findMarkdownFiles(projectRulesDir).map((rel) => {
145
- try {
146
- return { rel, paths: parseFrontmatter(fs.readFileSync(path.join(projectRulesDir, rel), 'utf-8')).paths }
147
- } catch {
148
- return { rel, paths: [] }
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: GlobalRules = { inline: '', scoped: [] }
156
- let projectRules: ProjectRule[] = []
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 = readGlobalRules(globalRulesDir)
160
- // Project rule filenames and their paths: frontmatter are surfaced in the system prompt.
161
- // isProjectTrusted alone is true for a repo pi never asked about; see project-approval.
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
- projectRules = approved ? readProjectRules(path.join(ctx.cwd, '.claude', 'rules')) : []
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
- if (hasGlobal || projectRules.length > 0) {
167
- ctx.ui.notify(`Rules loaded: global ${hasGlobal ? 'yes' : 'no'}, project ${projectRules.length}`, 'info')
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
- let addition = ''
173
-
174
- if (globalRules.inline.length > 0 || globalRules.scoped.length > 0) {
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
  }
@@ -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` and `argument-hint` frontmatter. `model` and
10
- * `disable-model-invocation` are parsed but not applied yet: pi has seams for both
11
- * (`pi.setModel`, and commands are user-invoked anyway), so they are a gap rather
12
- * than an impossibility.
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 { type DiscoveredCommand, discoverCommandFiles, expandDynamicContent, type ParsedCommand, parseCommandFile, substituteArgs } from './internal/command-file.js'
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
- /** Wall-clock budget for one `` !`cmd` `` span; a hung command must not wedge a turn. */
31
- const BASH_TIMEOUT_MS = 30_000
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 included only for approved projects. */
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
- async function runCommand(parsed: ParsedCommand, args: string, ctx: ExtensionCommandContext): Promise<void> {
74
- const withArgs = substituteArgs(parsed.body, args)
75
- const expanded = await expandDynamicContent(withArgs, ctx.cwd, async (shell) => {
76
- // Hooks get CLAUDE_PROJECT_DIR, and a command's bash span is the same kind of
77
- // project-scoped script. pi.exec takes no env, so it is exported in the script.
78
- const projectDir = ctx.cwd.replaceAll("'", String.raw`'\''`)
79
- const script = `export CLAUDE_PROJECT_DIR='${projectDir}'; ${shell}`
80
- const result = await pi.exec('/bin/sh', ['-c', script], { cwd: ctx.cwd, timeout: BASH_TIMEOUT_MS })
81
- return { stdout: result.stdout, stderr: result.stderr, code: result.code }
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
- for (const command of collectCommands(commandDirs(ctx.cwd, os.homedir(), trusted))) {
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
  }