pi-code 0.1.0 → 0.2.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.
@@ -27,7 +27,7 @@ const DESTRUCTIVE_PATTERNS = [
27
27
  /\bpip\s+(install|uninstall)/i,
28
28
  /\bapt(-get)?\s+(install|remove|purge|update|upgrade)/i,
29
29
  /\bbrew\s+(install|uninstall|upgrade)/i,
30
- /\bgit\s+(add|commit|push|pull|merge|rebase|reset|checkout|branch\s+-[dD]|stash|cherry-pick|revert|tag|init|clone)/i,
30
+ /\bgit\s+(add|commit|push|pull|merge|rebase|reset|checkout|branch\s+-d|stash|cherry-pick|revert|tag|init|clone)/i,
31
31
  /\bsudo\b/i,
32
32
  /\bsu\b/i,
33
33
  /\bkill\b/i,
@@ -40,7 +40,9 @@ const DESTRUCTIVE_PATTERNS = [
40
40
  /\b(vim?|nano|emacs|code|subl)\b/i,
41
41
  ]
42
42
 
43
- // Safe read-only commands allowed in plan mode
43
+ // Safe read-only commands allowed in plan mode. Deliberately excludes env/printenv
44
+ // (secret disclosure, and env is an exec wrapper), curl/wget (fetch plus -o writes),
45
+ // awk (system()) and sed (w/W/e write even under -n).
44
46
  const SAFE_PATTERNS = [
45
47
  /^\s*cat\b/,
46
48
  /^\s*head\b/,
@@ -65,8 +67,6 @@ const SAFE_PATTERNS = [
65
67
  /^\s*which\b/,
66
68
  /^\s*whereis\b/,
67
69
  /^\s*type\b/,
68
- /^\s*env\b/,
69
- /^\s*printenv\b/,
70
70
  /^\s*uname\b/,
71
71
  /^\s*whoami\b/,
72
72
  /^\s*id\b/,
@@ -83,21 +83,44 @@ const SAFE_PATTERNS = [
83
83
  /^\s*yarn\s+(list|info|why|audit)/i,
84
84
  /^\s*node\s+--version/i,
85
85
  /^\s*python\s+--version/i,
86
- /^\s*curl\s/i,
87
- /^\s*wget\s+-O\s*-/i,
88
86
  /^\s*jq\b/,
89
- /^\s*sed\s+-n/i,
90
- /^\s*awk\b/,
91
87
  /^\s*rg\b/,
92
88
  /^\s*fd\b/,
93
89
  /^\s*bat\b/,
94
90
  /^\s*eza\b/,
95
91
  ]
96
92
 
93
+ // The shell can hide an arbitrary command inside any of these, so they are refused
94
+ // outright rather than parsed.
95
+ const SUBSTITUTION = /\$\(|`|<\(|>\(/
96
+
97
+ // Claude Code's separator set (code.claude.com/docs/en/permissions): every subcommand
98
+ // must qualify on its own, otherwise an allowlisted first token buys the rest of the line.
99
+ const SEPARATORS = /\|\||&&|\|&|[;|&\n]/
100
+
101
+ // find is allowlisted for traversal only; these actions run commands or delete.
102
+ const FIND_ACTIONS = /\s-(exec|execdir|ok|okdir|delete|fls|fprint|fprintf)\b/
103
+
104
+ function isSafeSegment(segment: string): boolean {
105
+ if (DESTRUCTIVE_PATTERNS.some((p) => p.test(segment))) return false
106
+ if (!SAFE_PATTERNS.some((p) => p.test(segment))) return false
107
+ return !(/^\s*find\b/.test(segment) && FIND_ACTIONS.test(segment))
108
+ }
109
+
110
+ /**
111
+ * Whether plan mode should let this bash command run.
112
+ *
113
+ * Model steering, not a sandbox: an allowlisted interpreter can still read and write
114
+ * whatever the user can, so this narrows the blast radius of a wrong turn rather than
115
+ * containing a determined one. Only OS-level isolation would be a boundary.
116
+ */
97
117
  export function isSafeCommand(command: string): boolean {
98
- const isDestructive = DESTRUCTIVE_PATTERNS.some((p) => p.test(command))
99
- const isSafe = SAFE_PATTERNS.some((p) => p.test(command))
100
- return !isDestructive && isSafe
118
+ if (SUBSTITUTION.test(command)) return false
119
+ const segments = command
120
+ .split(SEPARATORS)
121
+ .map((s) => s.trim())
122
+ .filter(Boolean)
123
+ return segments.length > 0 && segments.every(isSafeSegment)
101
124
  }
102
125
 
103
126
  export interface TodoItem {
@@ -123,16 +146,53 @@ export function cleanStepText(text: string): string {
123
146
  return cleaned
124
147
  }
125
148
 
149
+ // Horizontal whitespace before the newline: \s would include \n itself and overlap
150
+ // the following \n, which is what backtracks super-linearly.
151
+ const PLAN_HEADER = /\*{0,2}Plan:\*{0,2}[^\S\n]*\n/i
152
+
153
+ const isBlank = (ch: string | undefined): boolean => ch !== undefined && ch !== '\n' && ch.trim() === ''
154
+
155
+ /**
156
+ * Text of a `1. step` / `2) step` line, stopping at an inline `*`, or undefined when
157
+ * the line is not a numbered step. Scanned rather than matched: the equivalent
158
+ * pattern needs adjacent quantifiers over overlapping classes, which backtracks
159
+ * super-linearly on a long line that turns out not to be a step.
160
+ */
161
+ function numberedStepText(line: string): string | undefined {
162
+ let i = 0
163
+ while (isBlank(line[i])) i++
164
+
165
+ const digitsStart = i
166
+ while (line[i] >= '0' && line[i] <= '9') i++
167
+ if (i === digitsStart) return undefined
168
+
169
+ if (line[i] !== '.' && line[i] !== ')') return undefined
170
+ i++
171
+
172
+ const spaceStart = i
173
+ while (isBlank(line[i])) i++
174
+ if (i === spaceStart) return undefined // the marker must be followed by whitespace
175
+
176
+ for (let stars = 0; stars < 2 && line[i] === '*'; stars++) i++
177
+ const first = line[i]
178
+ if (first === undefined || first === '*' || isBlank(first)) return undefined
179
+
180
+ const rest = line.slice(i)
181
+ const star = rest.indexOf('*')
182
+ return star === -1 ? rest : rest.slice(0, star)
183
+ }
184
+
126
185
  export function extractTodoItems(message: string): TodoItem[] {
127
186
  const items: TodoItem[] = []
128
- const headerMatch = message.match(/\*{0,2}Plan:\*{0,2}\s*\n/i)
187
+ const headerMatch = PLAN_HEADER.exec(message)
129
188
  if (!headerMatch) return items
130
189
 
131
190
  const planSection = message.slice(message.indexOf(headerMatch[0]) + headerMatch[0].length)
132
- const numberedPattern = /^\s*(\d+)[.)]\s+\*{0,2}([^*\n]+)/gm
133
191
 
134
- for (const match of planSection.matchAll(numberedPattern)) {
135
- const text = match[2]
192
+ for (const line of planSection.split('\n')) {
193
+ const captured = numberedStepText(line)
194
+ if (captured === undefined) continue
195
+ const text = captured
136
196
  .trim()
137
197
  .replace(/\*{1,2}$/, '')
138
198
  .trim()
@@ -170,6 +230,6 @@ export function markCompletedSteps(text: string, items: TodoItem[]): number {
170
230
  * the tool input is already known to be the plan itself.
171
231
  */
172
232
  export function planToTodos(plan: string): TodoItem[] {
173
- const withHeader = /\*{0,2}Plan:\*{0,2}\s*\n/i.test(plan) ? plan : `Plan:\n${plan}`
233
+ const withHeader = PLAN_HEADER.test(plan) ? plan : `Plan:\n${plan}`
174
234
  return extractTodoItems(withHeader)
175
235
  }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Project Trust Extension
3
+ *
4
+ * pi decides whether to prompt for trust from `hasTrustRequiringProjectResources`,
5
+ * which looks only at entries under `cwd/.pi` and at `.agents/skills`. A repository
6
+ * that ships only Claude Code shaped config, `.claude/` plus `.mcp.json`, matches
7
+ * none of them, so pi trusts it without asking.
8
+ *
9
+ * That is exactly the shape pi-code exists to load, and the other extensions gate
10
+ * their project input on `ctx.isProjectTrusted()`. Without this handler that flag is
11
+ * true for a freshly cloned repo nobody was asked about, and a project `.mcp.json`
12
+ * server command, project hooks and project agents all run.
13
+ *
14
+ * Only user/global and CLI extensions receive `project_trust`, so this works when
15
+ * pi-code is installed with `pi install npm:pi-code`. A project-local install
16
+ * (`pi install -l`) is not loaded until trust is already resolved.
17
+ *
18
+ * Docs: node_modules/@earendil-works/pi-coding-agent/docs/extensions.md (project_trust)
19
+ */
20
+
21
+ import * as fs from 'node:fs'
22
+ import * as path from 'node:path'
23
+ import { type ExtensionAPI, getAgentDir, hasTrustRequiringProjectResources, ProjectTrustStore } from '@earendil-works/pi-coding-agent'
24
+
25
+ /** Project files pi-code acts on that pi's own trust check does not look for. */
26
+ const CLAUDE_SHAPED = [path.join('.claude', 'settings.json'), path.join('.claude', 'settings.local.json'), path.join('.claude', 'agents'), path.join('.claude', 'hooks'), path.join('.claude', 'output-styles'), '.mcp.json', path.join('.pi', 'mcp.json'), path.join('.pi', 'agents')]
27
+
28
+ export function hasClaudeShapedConfig(cwd: string): boolean {
29
+ return CLAUDE_SHAPED.some((entry) => fs.existsSync(path.join(cwd, entry)))
30
+ }
31
+
32
+ export type TrustDecision = { trusted: 'yes' | 'no' | 'undecided'; remember?: boolean }
33
+
34
+ interface TrustDeps {
35
+ hasClaudeShaped: (cwd: string) => boolean
36
+ piWouldAsk: (cwd: string) => boolean
37
+ savedDecision: (cwd: string) => boolean | null
38
+ }
39
+
40
+ const defaultDeps: TrustDeps = {
41
+ hasClaudeShaped: hasClaudeShapedConfig,
42
+ piWouldAsk: hasTrustRequiringProjectResources,
43
+ savedDecision: (cwd) => new ProjectTrustStore(getAgentDir()).get(cwd),
44
+ }
45
+
46
+ /**
47
+ * Whether to take over the trust decision for this project.
48
+ *
49
+ * Returns `undecided` wherever pi already resolves things correctly, so a remembered
50
+ * decision is never overridden and a headless run keeps whatever `defaultProjectTrust`
51
+ * says rather than being forced closed.
52
+ */
53
+ export async function decideTrust(cwd: string, hasUI: boolean, confirm: (title: string, body: string) => Promise<boolean>, deps: TrustDeps = defaultDeps): Promise<TrustDecision> {
54
+ if (!deps.hasClaudeShaped(cwd)) return { trusted: 'undecided' }
55
+ if (deps.piWouldAsk(cwd)) return { trusted: 'undecided' } // pi prompts on its own
56
+ if (deps.savedDecision(cwd) !== null) return { trusted: 'undecided' } // apply the stored answer
57
+ if (!hasUI) return { trusted: 'undecided' } // cannot ask; leave pi's default in charge
58
+
59
+ const approved = await confirm('Trust this project?', `${cwd}\n\nIt ships Claude Code configuration that pi-code loads: MCP servers, hooks and agents can run commands from this repository.`)
60
+ return { trusted: approved ? 'yes' : 'no', remember: true }
61
+ }
62
+
63
+ export default function projectTrustExtension(pi: ExtensionAPI) {
64
+ pi.on('project_trust', async (event, ctx) => decideTrust(event.cwd, ctx.hasUI, (title, body) => ctx.ui.confirm(title, body)))
65
+ }
@@ -4,7 +4,7 @@
4
4
  * Escape in editor returns to options, Escape in options cancels
5
5
  */
6
6
 
7
- import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
7
+ import type { ExtensionAPI, Theme } from '@earendil-works/pi-coding-agent'
8
8
  import { Editor, type EditorTheme, Key, matchesKey, Text, truncateToWidth } from '@earendil-works/pi-tui'
9
9
  import { Type } from 'typebox'
10
10
 
@@ -33,6 +33,60 @@ const QuestionParams = Type.Object({
33
33
  options: Type.Array(OptionSchema, { description: 'Options for the user to choose from' }),
34
34
  })
35
35
 
36
+ function optionLine(opt: DisplayOption, index: number, selected: boolean, editMode: boolean, theme: Theme): string {
37
+ const label = `${index + 1}. ${opt.label}`
38
+ const prefix = selected ? theme.fg('accent', '> ') : ' '
39
+ if (opt.isOther === true && editMode) {
40
+ return prefix + theme.fg('accent', `${label} ✎`)
41
+ }
42
+ if (selected) {
43
+ return prefix + theme.fg('accent', label)
44
+ }
45
+ return ` ${theme.fg('text', label)}`
46
+ }
47
+
48
+ interface QuestionView {
49
+ width: number
50
+ question: string
51
+ options: DisplayOption[]
52
+ optionIndex: number
53
+ editMode: boolean
54
+ editor: Editor
55
+ theme: Theme
56
+ }
57
+
58
+ function buildQuestionLines(view: QuestionView): string[] {
59
+ const { width, question, options, optionIndex, editMode, editor, theme } = view
60
+ const lines: string[] = []
61
+ const add = (s: string) => lines.push(truncateToWidth(s, width))
62
+
63
+ add(theme.fg('accent', '─'.repeat(width)))
64
+ add(theme.fg('text', ` ${question}`))
65
+ lines.push('')
66
+
67
+ for (let i = 0; i < options.length; i++) {
68
+ const opt = options[i]
69
+ add(optionLine(opt, i, i === optionIndex, editMode, theme))
70
+ if (opt.description) {
71
+ add(` ${theme.fg('muted', opt.description)}`)
72
+ }
73
+ }
74
+
75
+ if (editMode) {
76
+ lines.push('')
77
+ add(theme.fg('muted', ' Your answer:'))
78
+ for (const line of editor.render(width - 2)) {
79
+ add(` ${line}`)
80
+ }
81
+ }
82
+
83
+ lines.push('')
84
+ add(theme.fg('dim', editMode ? ' Enter to submit • Esc to go back' : ' ↑↓ navigate • Enter to select • Esc to cancel'))
85
+ add(theme.fg('accent', '─'.repeat(width)))
86
+
87
+ return lines
88
+ }
89
+
36
90
  export default function question(pi: ExtensionAPI) {
37
91
  pi.registerTool({
38
92
  name: 'question',
@@ -65,6 +119,7 @@ export default function question(pi: ExtensionAPI) {
65
119
  let optionIndex = 0
66
120
  let editMode = false
67
121
  let cachedLines: string[] | undefined
122
+ let cachedWidth: number | undefined
68
123
 
69
124
  const editorTheme: EditorTheme = {
70
125
  borderColor: (s) => theme.fg('accent', s),
@@ -135,58 +190,16 @@ export default function question(pi: ExtensionAPI) {
135
190
  }
136
191
 
137
192
  function render(width: number): string[] {
138
- if (cachedLines) return cachedLines
139
-
140
- const lines: string[] = []
141
- const add = (s: string) => lines.push(truncateToWidth(s, width))
142
-
143
- add(theme.fg('accent', '─'.repeat(width)))
144
- add(theme.fg('text', ` ${params.question}`))
145
- lines.push('')
146
-
147
- for (let i = 0; i < allOptions.length; i++) {
148
- const opt = allOptions[i]
149
- const selected = i === optionIndex
150
- const isOther = opt.isOther === true
151
- const prefix = selected ? theme.fg('accent', '> ') : ' '
152
-
153
- if (isOther && editMode) {
154
- add(prefix + theme.fg('accent', `${i + 1}. ${opt.label} ✎`))
155
- } else if (selected) {
156
- add(prefix + theme.fg('accent', `${i + 1}. ${opt.label}`))
157
- } else {
158
- add(` ${theme.fg('text', `${i + 1}. ${opt.label}`)}`)
159
- }
160
-
161
- // Show description if present
162
- if (opt.description) {
163
- add(` ${theme.fg('muted', opt.description)}`)
164
- }
165
- }
166
-
167
- if (editMode) {
168
- lines.push('')
169
- add(theme.fg('muted', ' Your answer:'))
170
- for (const line of editor.render(width - 2)) {
171
- add(` ${line}`)
172
- }
173
- }
174
-
175
- lines.push('')
176
- if (editMode) {
177
- add(theme.fg('dim', ' Enter to submit • Esc to go back'))
178
- } else {
179
- add(theme.fg('dim', ' ↑↓ navigate • Enter to select • Esc to cancel'))
180
- }
181
- add(theme.fg('accent', '─'.repeat(width)))
182
-
183
- cachedLines = lines
184
- return lines
193
+ if (cachedLines && cachedWidth === width) return cachedLines
194
+ cachedWidth = width
195
+ cachedLines = buildQuestionLines({ width, question: params.question, options: allOptions, optionIndex, editMode, editor, theme })
196
+ return cachedLines
185
197
  }
186
198
 
187
199
  return {
188
200
  render,
189
201
  invalidate: () => {
202
+ cachedWidth = undefined
190
203
  cachedLines = undefined
191
204
  },
192
205
  handleInput,
@@ -231,7 +244,8 @@ export default function question(pi: ExtensionAPI) {
231
244
  if (opts.length) {
232
245
  const labels = opts.map((o: OptionWithDesc) => o.label)
233
246
  const numbered = [...labels, 'Type something.'].map((o, i) => `${i + 1}. ${o}`)
234
- text += `\n${theme.fg('dim', ` Options: ${numbered.join(', ')}`)}`
247
+ const optionsLine = ` Options: ${numbered.join(', ')}`
248
+ text += `\n${theme.fg('dim', optionsLine)}`
235
249
  }
236
250
  return new Text(text, 0, 0)
237
251
  },
@@ -111,6 +111,17 @@ function findNearestDir(cwd: string, relative: string): string | null {
111
111
  }
112
112
  }
113
113
 
114
+ function buildAgentMap(userAgents: AgentConfig[], projectAgents: AgentConfig[], scope: AgentScope): Map<string, AgentConfig> {
115
+ const agentMap = new Map<string, AgentConfig>()
116
+ const register = (agents: AgentConfig[]): void => {
117
+ for (const agent of agents) agentMap.set(agent.name, agent)
118
+ }
119
+ // user agents first so project agents win on name conflicts
120
+ if (scope !== 'project') register(userAgents)
121
+ if (scope !== 'user') register(projectAgents)
122
+ return agentMap
123
+ }
124
+
114
125
  export function discoverAgents(cwd: string, scope: AgentScope): AgentDiscoveryResult {
115
126
  const userDir = path.join(getAgentDir(), 'agents')
116
127
  const claudeUserDir = path.join(os.homedir(), '.claude', 'agents')
@@ -122,17 +133,7 @@ export function discoverAgents(cwd: string, scope: AgentScope): AgentDiscoveryRe
122
133
  // project .claude/agents loads first so project .pi/agents wins on name conflicts
123
134
  const projectAgents = scope === 'user' ? [] : [...(projectClaudeDir ? loadAgentsFromDir(projectClaudeDir, 'project') : []), ...(projectPiDir ? loadAgentsFromDir(projectPiDir, 'project') : [])]
124
135
 
125
- const agentMap = new Map<string, AgentConfig>()
126
-
127
- if (scope === 'both') {
128
- for (const agent of userAgents) agentMap.set(agent.name, agent)
129
- for (const agent of projectAgents) agentMap.set(agent.name, agent)
130
- } else if (scope === 'user') {
131
- for (const agent of userAgents) agentMap.set(agent.name, agent)
132
- } else {
133
- for (const agent of projectAgents) agentMap.set(agent.name, agent)
134
- }
135
-
136
+ const agentMap = buildAgentMap(userAgents, projectAgents, scope)
136
137
  return { agents: Array.from(agentMap.values()), projectAgentsDir: projectPiDir }
137
138
  }
138
139