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 +1 -1
- package/extensions/commands.ts +81 -15
- package/extensions/internal/command-file.ts +170 -0
- package/package.json +1 -1
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
|
|
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` |
|
package/extensions/commands.ts
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Claude Commands Extension
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* substitution
|
|
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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
|
|
32
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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.
|
|
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",
|