pi-code 0.8.0 → 0.9.0

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 CHANGED
@@ -36,7 +36,7 @@ One `pi install` and everything below loads on the next start. `pi list` shows w
36
36
  | Feature | Reads / provides | Extension |
37
37
  |---|---|---|
38
38
  | Global + project rules | `~/.claude/rules`, `.claude/rules` (+ `paths:` frontmatter scoping) | `claude-rules.ts` |
39
- | Custom slash commands | `.claude/commands/*.md` pi prompt templates | `commands.ts` |
39
+ | Custom slash commands | `.claude/commands/**/*.md` (namespaced `/dir:name`), `$ARGUMENTS`/`$1`, `` !`cmd` `` bash output, `@file` inlining, `allowed-tools`/`model`/`argument-hint` frontmatter; project commands gated on approval | `commands.ts` |
40
40
  | Skills | `.claude/skills` → pi skill discovery (pi reads `name`, `description`, `disable-model-invocation`; `allowed-tools` is inert in pi's loader) | `skills.ts` |
41
41
  | 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` |
42
42
  | Output styles | `.claude/output-styles` + active `outputStyle`; Claude replace semantics with `keep-coding-instructions`; bundled Explanatory/Learning/Proactive; `/output-style [name]` | `output-styles.ts` |
@@ -1,15 +1,18 @@
1
1
  /**
2
2
  * Claude Commands Extension
3
3
  *
4
- * Bridges Claude Code's custom slash commands into pi. On resources_discover
5
- * it hands pi the existing `.claude/commands` directories (user then project)
6
- * as prompt-template paths, so `/name` invokes `.claude/commands/name.md` the
7
- * same way pi loads its own `.pi/prompts`. pi's `$ARGUMENTS` / `$1` / `${1:-x}`
8
- * substitution overlaps Claude Code's, so most command files work unchanged.
4
+ * Registers Claude Code's custom slash commands with pi directly, rather than
5
+ * handing `.claude/commands` to pi's prompt-template loader. Owning registration
6
+ * is what makes the rest of Claude's command contract reachable: namespaced
7
+ * subdirectories (`frontend/build.md` is `/frontend:build`), `$ARGUMENTS` and
8
+ * positional substitution, `` !`cmd` `` bash output, `@file` inlining, and the
9
+ * `allowed-tools` / `model` / `argument-hint` / `disable-model-invocation`
10
+ * frontmatter.
9
11
  *
10
- * Not bridged (pi's prompt engine ignores them): `!` bash execution, `@` file
11
- * refs, `allowed-tools`/`model` frontmatter, and namespaced subdirectories
12
- * (discovery is non-recursive).
12
+ * A project command body is repository-controlled text that can now run shell
13
+ * commands and read files, so project commands load only once the project is
14
+ * approved. That closes the "skills / commands are not trust-gated" limitation
15
+ * for commands; skills remain pi-loader territory.
13
16
  *
14
17
  * Docs: https://code.claude.com/docs/en/slash-commands.md
15
18
  */
@@ -17,7 +20,13 @@
17
20
  import * as fs from 'node:fs'
18
21
  import * as os from 'node:os'
19
22
  import * as path from 'node:path'
20
- import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
23
+ import type { ExtensionAPI, ExtensionCommandContext } from '@earendil-works/pi-coding-agent'
24
+
25
+ import { type DiscoveredCommand, discoverCommandFiles, expandDynamicContent, type ParsedCommand, parseCommandFile, substituteArgs } from './internal/command-file.js'
26
+ import { isProjectApproved } from './internal/project-approval.js'
27
+
28
+ /** Wall-clock budget for one `` !`cmd` `` span; a hung command must not wedge a turn. */
29
+ const BASH_TIMEOUT_MS = 30_000
21
30
 
22
31
  function isDirectory(target: string): boolean {
23
32
  try {
@@ -27,9 +36,11 @@ function isDirectory(target: string): boolean {
27
36
  }
28
37
  }
29
38
 
30
- /** Existing `.claude/commands` directories, user first then project. */
31
- export function commandDirs(cwd: string, home: string): string[] {
32
- const candidates = [path.join(home, '.claude', 'commands'), path.join(cwd, '.claude', 'commands')]
39
+ /** Existing `.claude/commands` directories, user first then project. The project
40
+ * directory is included only for approved projects. */
41
+ export function commandDirs(cwd: string, home: string, trusted: boolean): string[] {
42
+ const candidates = [path.join(home, '.claude', 'commands')]
43
+ if (trusted) candidates.push(path.join(cwd, '.claude', 'commands'))
33
44
  const dirs: string[] = []
34
45
  for (const dir of candidates) {
35
46
  if (!dirs.includes(dir) && isDirectory(dir)) dirs.push(dir)
@@ -37,9 +48,64 @@ export function commandDirs(cwd: string, home: string): string[] {
37
48
  return dirs
38
49
  }
39
50
 
51
+ /** All commands across the given directories, later directories winning by name. */
52
+ export function collectCommands(dirs: string[]): DiscoveredCommand[] {
53
+ const byName = new Map<string, DiscoveredCommand>()
54
+ for (const dir of dirs) {
55
+ for (const found of discoverCommandFiles(dir)) byName.set(found.name, found)
56
+ }
57
+ return [...byName.values()]
58
+ }
59
+
40
60
  export default function commandsExtension(pi: ExtensionAPI) {
41
- pi.on('resources_discover', async (_event, ctx) => {
42
- const promptPaths = commandDirs(ctx.cwd, os.homedir())
43
- return promptPaths.length > 0 ? { promptPaths } : undefined
61
+ const registered = new Set<string>()
62
+
63
+ async function runCommand(parsed: ParsedCommand, args: string, ctx: ExtensionCommandContext): Promise<void> {
64
+ const withArgs = substituteArgs(parsed.body, args)
65
+ const expanded = await expandDynamicContent(withArgs, ctx.cwd, async (shell) => {
66
+ const result = await pi.exec('/bin/sh', ['-c', shell], { timeout: BASH_TIMEOUT_MS })
67
+ return { stdout: result.stdout, stderr: result.stderr, code: result.code }
68
+ })
69
+
70
+ // allowed-tools restricts the turn the command drives, then the previous set is
71
+ // restored: the restriction belongs to the command, not to the rest of the session.
72
+ const saved = parsed.allowedTools ? pi.getActiveTools() : undefined
73
+ if (parsed.allowedTools && saved) {
74
+ pi.setActiveTools(parsed.allowedTools.filter((tool) => saved.includes(tool)))
75
+ }
76
+ try {
77
+ pi.sendUserMessage(expanded)
78
+ } finally {
79
+ if (saved) pi.setActiveTools(saved)
80
+ }
81
+ }
82
+
83
+ pi.on('session_start', async (_event, ctx) => {
84
+ const trusted = await isProjectApproved(ctx)
85
+ for (const command of collectCommands(commandDirs(ctx.cwd, os.homedir(), trusted))) {
86
+ // pi has no unregister, so a command already registered this process keeps its
87
+ // original file binding; re-registering would only add a numbered duplicate.
88
+ if (registered.has(command.name)) continue
89
+ let parsed: ParsedCommand
90
+ try {
91
+ parsed = parseCommandFile(fs.readFileSync(command.filePath, 'utf-8'))
92
+ } catch {
93
+ continue // an unreadable command file must not take down session start
94
+ }
95
+ registered.add(command.name)
96
+ pi.registerCommand(command.name, {
97
+ description: parsed.argumentHint ? `${parsed.description} ${parsed.argumentHint}` : parsed.description,
98
+ handler: async (args, commandCtx) => {
99
+ // Re-read on invocation so an edited command file takes effect without a reload.
100
+ let current = parsed
101
+ try {
102
+ current = parseCommandFile(fs.readFileSync(command.filePath, 'utf-8'))
103
+ } catch {
104
+ // fall back to what was parsed at registration
105
+ }
106
+ await runCommand(current, args, commandCtx)
107
+ },
108
+ })
109
+ }
44
110
  })
45
111
  }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Parsing and discovery for Claude Code slash-command files.
3
+ *
4
+ * pi's own prompt-template loader reads only `description` and `argument-hint`
5
+ * from one flat directory, so the rest of Claude's command contract (namespaced
6
+ * subdirectories, `allowed-tools`, `model`, `!` bash blocks, `@file` refs) lives
7
+ * here and is applied by commands.ts when it registers each command itself.
8
+ *
9
+ * Docs: https://code.claude.com/docs/en/slash-commands.md
10
+ */
11
+
12
+ import * as fs from 'node:fs'
13
+ import * as path from 'node:path'
14
+
15
+ export interface ParsedCommand {
16
+ description: string
17
+ argumentHint?: string
18
+ allowedTools?: string[]
19
+ model?: string
20
+ disableModelInvocation: boolean
21
+ body: string
22
+ }
23
+
24
+ export interface DiscoveredCommand {
25
+ /** Claude's namespaced name: a nested file is `dir:name`. */
26
+ name: string
27
+ filePath: string
28
+ }
29
+
30
+ /** Claude tool names are PascalCase; pi's are lowercase. */
31
+ function normalizeToolName(name: string): string {
32
+ return name.trim().toLowerCase()
33
+ }
34
+
35
+ function field(frontmatter: string, key: string): string {
36
+ const match = new RegExp(String.raw`^\s*${key}\s*:\s*(.+)$`, 'm').exec(frontmatter)
37
+ return match ? match[1].trim().replace(/^["']|["']$/g, '') : ''
38
+ }
39
+
40
+ export function parseCommandFile(content: string): ParsedCommand {
41
+ const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(content)
42
+ const frontmatter = match ? match[1] : ''
43
+ const body = (match ? content.slice(match[0].length) : content).trim()
44
+ const tools = field(frontmatter, 'allowed-tools')
45
+ const firstLine = body.split('\n').find((line) => line.trim().length > 0) ?? ''
46
+ return {
47
+ description: field(frontmatter, 'description') || firstLine.slice(0, 60),
48
+ argumentHint: field(frontmatter, 'argument-hint') || undefined,
49
+ allowedTools: tools ? tools.split(',').map(normalizeToolName).filter(Boolean) : undefined,
50
+ model: field(frontmatter, 'model') || undefined,
51
+ disableModelInvocation: field(frontmatter, 'disable-model-invocation') === 'true',
52
+ body,
53
+ }
54
+ }
55
+
56
+ /** Split a raw argument string, keeping quoted runs together. */
57
+ export function splitArgs(args: string): string[] {
58
+ const out: string[] = []
59
+ const pattern = /"([^"]*)"|'([^']*)'|(\S+)/g
60
+ let match = pattern.exec(args)
61
+ while (match !== null) {
62
+ out.push(match[1] ?? match[2] ?? match[3])
63
+ match = pattern.exec(args)
64
+ }
65
+ return out
66
+ }
67
+
68
+ /** Claude's substitutions: `$ARGUMENTS`, `$@`, `$1`..`$n`, `${n:-default}`. An
69
+ * unfilled positional becomes empty rather than leaking its literal token. */
70
+ export function substituteArgs(body: string, args: string): string {
71
+ const parts = splitArgs(args)
72
+ return body
73
+ .replaceAll(/\$\{(\d+):-([^}]*)\}/g, (_m, index: string, fallback: string) => parts[Number(index) - 1] ?? fallback)
74
+ .replaceAll(/\$\{ARGUMENTS:-([^}]*)\}/g, (_m, fallback: string) => (args.trim() ? args.trim() : fallback))
75
+ .replaceAll(/\$ARGUMENTS\b/g, args.trim())
76
+ .replaceAll('$@', args.trim())
77
+ .replaceAll(/\$(\d+)/g, (_m, index: string) => parts[Number(index) - 1] ?? '')
78
+ }
79
+
80
+ /** `a/b/c.md` becomes Claude's `a:b:c`. */
81
+ export function commandNameFor(relativePath: string): string {
82
+ return relativePath.replace(/\.md$/, '').split(path.sep).join(':')
83
+ }
84
+
85
+ /** Every `*.md` under a commands directory, including nested ones. */
86
+ export function discoverCommandFiles(root: string): DiscoveredCommand[] {
87
+ const found: DiscoveredCommand[] = []
88
+ const walk = (dir: string, prefix: string): void => {
89
+ let entries: fs.Dirent[]
90
+ try {
91
+ entries = fs.readdirSync(dir, { withFileTypes: true })
92
+ } catch {
93
+ return
94
+ }
95
+ for (const entry of entries) {
96
+ const full = path.join(dir, entry.name)
97
+ if (entry.isDirectory()) walk(full, path.join(prefix, entry.name))
98
+ else if (entry.name.endsWith('.md')) found.push({ name: commandNameFor(path.join(prefix, entry.name)), filePath: full })
99
+ }
100
+ }
101
+ walk(root, '')
102
+ return found
103
+ }
104
+
105
+ export type CommandExec = (command: string) => Promise<{ stdout: string; stderr: string; code: number }>
106
+
107
+ /** Spans of a body that are inside a fenced code block, where Claude's dynamic
108
+ * syntax is literal text rather than an instruction. */
109
+ function fencedRanges(body: string): Array<[number, number]> {
110
+ const ranges: Array<[number, number]> = []
111
+ const fence = /^(```|~~~)[^\n]*$/gm
112
+ let open: number | undefined
113
+ let match = fence.exec(body)
114
+ while (match !== null) {
115
+ if (open === undefined) open = match.index
116
+ else {
117
+ ranges.push([open, match.index + match[0].length])
118
+ open = undefined
119
+ }
120
+ match = fence.exec(body)
121
+ }
122
+ if (open !== undefined) ranges.push([open, body.length])
123
+ return ranges
124
+ }
125
+
126
+ const inRanges = (ranges: Array<[number, number]>, index: number): boolean => ranges.some(([start, end]) => index >= start && index < end)
127
+
128
+ /** Read a `@path` reference, confined to the working directory. Returns undefined
129
+ * when the path escapes it or cannot be read, so the reference stays literal. */
130
+ function readReference(cwd: string, reference: string): string | undefined {
131
+ const resolved = path.resolve(cwd, reference)
132
+ const root = path.resolve(cwd)
133
+ if (resolved !== root && !resolved.startsWith(root + path.sep)) return undefined
134
+ try {
135
+ if (!fs.statSync(resolved).isFile()) return undefined
136
+ return fs.readFileSync(resolved, 'utf-8')
137
+ } catch {
138
+ return undefined
139
+ }
140
+ }
141
+
142
+ /** Claude's dynamic command content: `` !`cmd` `` runs a shell command and pastes
143
+ * its output, `@path` inlines a file. Both are skipped inside fenced code blocks. */
144
+ export async function expandDynamicContent(body: string, cwd: string, exec: CommandExec): Promise<string> {
145
+ const fenced = fencedRanges(body)
146
+
147
+ const commands: Array<{ span: string; command: string; index: number }> = []
148
+ const bashPattern = /!`([^`]+)`/g
149
+ let bashMatch = bashPattern.exec(body)
150
+ while (bashMatch !== null) {
151
+ if (!inRanges(fenced, bashMatch.index)) commands.push({ span: bashMatch[0], command: bashMatch[1], index: bashMatch.index })
152
+ bashMatch = bashPattern.exec(body)
153
+ }
154
+
155
+ let expanded = body
156
+ for (const entry of commands) {
157
+ const result = await exec(entry.command)
158
+ const output = result.code === 0 ? result.stdout.trimEnd() : `(command failed: ${entry.command})\n${result.stderr.trim() || result.stdout.trim()}`
159
+ expanded = expanded.replace(entry.span, output)
160
+ }
161
+
162
+ // Ranges are recomputed: command output can change offsets.
163
+ const fencedAfter = fencedRanges(expanded)
164
+ return expanded.replaceAll(/(^|\s)@(\S+)/g, (whole, lead: string, reference: string, offset: number) => {
165
+ if (inRanges(fencedAfter, offset)) return whole
166
+ const content = readReference(cwd, reference)
167
+ if (content === undefined) return whole
168
+ return `${lead}\n<file path="${reference}">\n${content.trimEnd()}\n</file>\n`
169
+ })
170
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Claude Code experience for the pi coding agent: reads your .claude config (rules, commands, skills, hooks, output styles, MCP servers, agents) and adds todo, checkpoints, memory, web, and subagents",
5
5
  "keywords": [
6
6
  "pi",