pi-code 1.0.3 → 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 (37) hide show
  1. package/README.md +25 -13
  2. package/extensions/claude-rules.ts +158 -54
  3. package/extensions/commands.ts +185 -27
  4. package/extensions/context-imports.ts +358 -41
  5. package/extensions/git-checkpoint.ts +22 -1
  6. package/extensions/hooks.ts +397 -79
  7. package/extensions/init.ts +81 -0
  8. package/extensions/internal/agent-run.ts +42 -0
  9. package/extensions/internal/bash-rules.ts +27 -0
  10. package/extensions/internal/command-file.ts +377 -53
  11. package/extensions/internal/html-markdown.ts +61 -0
  12. package/extensions/internal/instruction-events.ts +70 -0
  13. package/extensions/internal/managed-settings.ts +38 -0
  14. package/extensions/internal/mcp-call.ts +28 -0
  15. package/extensions/internal/mcp-oauth.ts +171 -0
  16. package/extensions/internal/model-complete.ts +68 -0
  17. package/extensions/internal/path-rules.ts +80 -0
  18. package/extensions/internal/plugins.ts +125 -0
  19. package/extensions/internal/project-approval.ts +2 -3
  20. package/extensions/internal/project-root.ts +78 -0
  21. package/extensions/internal/shell-split.ts +65 -0
  22. package/extensions/internal/strip-comments.ts +77 -0
  23. package/extensions/internal/web-transport.ts +3 -1
  24. package/extensions/mcp.ts +290 -31
  25. package/extensions/memory.ts +168 -23
  26. package/extensions/notify.ts +78 -5
  27. package/extensions/output-styles.ts +34 -6
  28. package/extensions/plan-mode/index.ts +55 -9
  29. package/extensions/plan-mode/utils.ts +3 -57
  30. package/extensions/question.ts +2 -2
  31. package/extensions/skills.ts +11 -1
  32. package/extensions/status-line.ts +97 -4
  33. package/extensions/subagent/agents.ts +72 -61
  34. package/extensions/subagent/background.ts +114 -25
  35. package/extensions/subagent/index.ts +227 -44
  36. package/extensions/web.ts +87 -16
  37. package/package.json +1 -1
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Managed settings: Claude's enterprise policy file.
3
+ *
4
+ * Claude Code reads managed-settings.json from an OS-level location that IT
5
+ * deploys and that is not user- or repo-writable in the normal flow, so keys
6
+ * sourced from it carry organizational authority (managed `claudeMd`, exclusion
7
+ * lists, MCP allow/deny). Managed-only keys are honored from this file alone:
8
+ * the same key in user or project settings is ignored, so a repository or a
9
+ * local settings edit can neither impersonate nor override the policy.
10
+ */
11
+
12
+ import * as fs from 'node:fs'
13
+
14
+ /** The OS managed-settings.json path Claude Code documents per platform. */
15
+ export function managedSettingsPath(platform: NodeJS.Platform = process.platform): string {
16
+ if (platform === 'darwin') return '/Library/Application Support/ClaudeCode/managed-settings.json'
17
+ // The legacy C:\ProgramData\ClaudeCode path was dropped in Claude Code v2.1.75.
18
+ if (platform === 'win32') return 'C:\\Program Files\\ClaudeCode\\managed-settings.json'
19
+ return '/etc/claude-code/managed-settings.json'
20
+ }
21
+
22
+ let managedSettingsFileOverride: string | undefined
23
+
24
+ /** Test seam: override the managed-settings.json path readers consult. */
25
+ export function setManagedSettingsPath(file?: string): void {
26
+ managedSettingsFileOverride = file
27
+ }
28
+
29
+ /** The parsed managed settings object, or {} when absent or malformed. */
30
+ export function readManagedSettings(file: string = managedSettingsFileOverride ?? managedSettingsPath()): Record<string, unknown> {
31
+ try {
32
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf-8'))
33
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed as Record<string, unknown>
34
+ } catch {
35
+ // No managed policy on this machine.
36
+ }
37
+ return {}
38
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * A one-function seam letting the hooks extension invoke a tool on a server the
3
+ * mcp extension has connected, without the two extensions importing each other.
4
+ *
5
+ * The mcp extension registers a caller once its clients exist; anything that needs
6
+ * to call an MCP tool (today: Claude's `type: "mcp_tool"` hooks) goes through
7
+ * callMcpTool. It is the direct-call analogue of the MCP_TOOLS_CHANNEL alias bus.
8
+ */
9
+
10
+ export interface McpToolResult {
11
+ text: string
12
+ isError: boolean
13
+ }
14
+
15
+ export type McpToolCaller = (server: string, tool: string, input: Record<string, unknown>) => Promise<McpToolResult>
16
+
17
+ let caller: McpToolCaller | undefined
18
+
19
+ /** The mcp extension registers its caller here; pass undefined to clear it. */
20
+ export function setMcpToolCaller(fn: McpToolCaller | undefined): void {
21
+ caller = fn
22
+ }
23
+
24
+ /** Invoke a tool on a connected MCP server. Throws when no server is connected. */
25
+ export function callMcpTool(server: string, tool: string, input: Record<string, unknown>): Promise<McpToolResult> {
26
+ if (!caller) return Promise.reject(new Error(`no MCP server connected to serve ${server}/${tool}`))
27
+ return caller(server, tool, input)
28
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * OAuth for remote MCP servers, through the MCP SDK's authProvider seam.
3
+ *
4
+ * Claude Code authenticates remote HTTP servers via OAuth from its /mcp panel;
5
+ * here the flow runs at connect time: a 401 surfaces as UnauthorizedError, the
6
+ * user approves opening the browser, a one-shot localhost listener catches the
7
+ * redirect, and the SDK's finishAuth exchanges the code. Tokens, the dynamic
8
+ * client registration and the PKCE verifier persist per server under pi's agent
9
+ * directory with owner-only permissions, so later sessions reconnect silently
10
+ * and refresh through the SDK without a browser round-trip.
11
+ */
12
+
13
+ import { spawn } from 'node:child_process'
14
+ import * as crypto from 'node:crypto'
15
+ import * as fs from 'node:fs'
16
+ import * as http from 'node:http'
17
+ import * as path from 'node:path'
18
+ import { getAgentDir } from '@earendil-works/pi-coding-agent'
19
+ import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'
20
+ import type { OAuthClientInformationMixed, OAuthClientMetadata, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js'
21
+
22
+ interface StoredAuth {
23
+ client?: OAuthClientInformationMixed
24
+ tokens?: OAuthTokens
25
+ verifier?: string
26
+ /** The loopback port a prior login registered, reused so a strict server that
27
+ * pins redirect_uris still accepts a re-login after tokens are revoked. */
28
+ redirectPort?: number
29
+ }
30
+
31
+ /** A server name is config-controlled text; the digest keeps hostile names inside
32
+ * the store directory and distinct names from colliding after sanitization. */
33
+ function storeFileFor(serverName: string): string {
34
+ const digest = crypto.createHash('sha256').update(serverName).digest('hex').slice(0, 8)
35
+ const safe =
36
+ serverName
37
+ .replace(/[^A-Za-z0-9_-]+/g, '-')
38
+ .replace(/^-+|-+$/g, '')
39
+ .slice(0, 40) || 'server'
40
+ return path.join(getAgentDir(), 'mcp-oauth', `${safe}-${digest}.json`)
41
+ }
42
+
43
+ export class FileOAuthProvider implements OAuthClientProvider {
44
+ private readonly storePath: string
45
+ private data: StoredAuth
46
+ private port = 0
47
+ private readonly onRedirect: (authorizationUrl: URL) => void
48
+
49
+ constructor(serverName: string, onRedirect: (authorizationUrl: URL) => void) {
50
+ this.storePath = storeFileFor(serverName)
51
+ this.onRedirect = onRedirect
52
+ try {
53
+ this.data = JSON.parse(fs.readFileSync(this.storePath, 'utf-8'))
54
+ } catch {
55
+ this.data = {}
56
+ }
57
+ }
58
+
59
+ private persist(): void {
60
+ fs.mkdirSync(path.dirname(this.storePath), { recursive: true })
61
+ fs.writeFileSync(this.storePath, JSON.stringify(this.data), { mode: 0o600 })
62
+ }
63
+
64
+ /** The port a prior login registered, so a re-login can bind the same one. */
65
+ savedRedirectPort(): number | undefined {
66
+ return this.data.redirectPort
67
+ }
68
+
69
+ /** Record the loopback port the callback server actually bound; the redirect
70
+ * URL and the registered redirect_uri both derive from it. */
71
+ bindRedirectPort(port: number): void {
72
+ this.port = port
73
+ if (this.data.redirectPort !== port) {
74
+ this.data.redirectPort = port
75
+ this.persist()
76
+ }
77
+ }
78
+
79
+ get redirectUrl(): string {
80
+ return `http://127.0.0.1:${this.port}/callback`
81
+ }
82
+
83
+ get clientMetadata(): OAuthClientMetadata {
84
+ return {
85
+ client_name: 'pi-code',
86
+ redirect_uris: [this.redirectUrl],
87
+ grant_types: ['authorization_code', 'refresh_token'],
88
+ response_types: ['code'],
89
+ // A local CLI is a public client; PKCE carries the proof instead of a secret.
90
+ token_endpoint_auth_method: 'none',
91
+ }
92
+ }
93
+
94
+ clientInformation(): OAuthClientInformationMixed | undefined {
95
+ return this.data.client
96
+ }
97
+
98
+ saveClientInformation(client: OAuthClientInformationMixed): void {
99
+ this.data.client = client
100
+ this.persist()
101
+ }
102
+
103
+ tokens(): OAuthTokens | undefined {
104
+ return this.data.tokens
105
+ }
106
+
107
+ saveTokens(tokens: OAuthTokens): void {
108
+ this.data.tokens = tokens
109
+ this.persist()
110
+ }
111
+
112
+ hasTokens(): boolean {
113
+ return this.data.tokens !== undefined
114
+ }
115
+
116
+ redirectToAuthorization(authorizationUrl: URL): void {
117
+ this.onRedirect(authorizationUrl)
118
+ }
119
+
120
+ saveCodeVerifier(verifier: string): void {
121
+ this.data.verifier = verifier
122
+ this.persist()
123
+ }
124
+
125
+ codeVerifier(): string {
126
+ if (!this.data.verifier) throw new Error('no code verifier saved for this authorization')
127
+ return this.data.verifier
128
+ }
129
+ }
130
+
131
+ /** A one-shot loopback listener for the authorization redirect. Loopback redirect
132
+ * URIs are the RFC 8252 pattern for native apps. A preferred port (from a prior
133
+ * login) is tried first so a re-login keeps the registered redirect_uri; if it is
134
+ * taken, an ephemeral port is used. */
135
+ export async function startCallbackServer(preferredPort?: number): Promise<{ server: http.Server; port: number }> {
136
+ const server = http.createServer()
137
+ const listen = (port: number): Promise<void> => new Promise((resolve, reject) => server.listen(port, '127.0.0.1', resolve).once('error', reject))
138
+ try {
139
+ await listen(preferredPort ?? 0)
140
+ } catch {
141
+ await listen(0)
142
+ }
143
+ return { server, port: (server.address() as { port: number }).port }
144
+ }
145
+
146
+ export function waitForAuthCode(server: http.Server, timeoutMs: number): Promise<string> {
147
+ return new Promise((resolve, reject) => {
148
+ const timer = setTimeout(() => reject(new Error(`authorization timed out after ${timeoutMs}ms`)), timeoutMs)
149
+ server.on('request', (request, response) => {
150
+ const url = new URL(request.url ?? '/', 'http://127.0.0.1')
151
+ const code = url.searchParams.get('code')
152
+ const error = url.searchParams.get('error')
153
+ response.writeHead(200, { 'content-type': 'text/html' })
154
+ response.end('<html><body>pi-code: you can close this tab and return to the terminal.</body></html>')
155
+ clearTimeout(timer)
156
+ if (code) resolve(code)
157
+ else reject(new Error(`authorization failed: ${error ?? 'no code in redirect'} ${url.searchParams.get('error_description') ?? ''}`.trim()))
158
+ })
159
+ })
160
+ }
161
+
162
+ /** Best-effort browser launch; the caller also surfaces the URL as text. */
163
+ export function openBrowser(url: string): void {
164
+ const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open'
165
+ const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url]
166
+ try {
167
+ spawn(command, args, { stdio: 'ignore', detached: true }).unref()
168
+ } catch {
169
+ // the notified URL is the fallback
170
+ }
171
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * One-off, in-process model completions for extensions.
3
+ *
4
+ * pi exposes ModelRuntime.completeSimple(model, context, options), so an extension
5
+ * can run a single tool-less prompt through a model without spawning a subprocess
6
+ * (the way the subagent does) or standing up its own HTTP client. The runtime
7
+ * resolves auth from the same credential store the session uses, and is created
8
+ * once and cached. This is the shared foundation for model-backed features that
9
+ * Claude has and pi otherwise cannot express: WebFetch's prompt-over-page answer,
10
+ * a hook's `type: prompt` evaluation, and similar.
11
+ *
12
+ * Every consumer must treat a completion as best-effort: it costs a model call and
13
+ * can fail (no credentials, headless with no model, a provider error), so failures
14
+ * throw and the caller falls back to its non-model behavior.
15
+ */
16
+
17
+ import type { Api, AssistantMessage, Context, Model, ModelsSimpleStreamOptions } from '@earendil-works/pi-ai'
18
+ import { ModelRuntime } from '@earendil-works/pi-coding-agent'
19
+
20
+ /** The completion backend: model + context -> assistant message. Overridable for tests. */
21
+ export type CompleteFn = (model: Model<Api>, context: Context, options: ModelsSimpleStreamOptions) => Promise<AssistantMessage>
22
+
23
+ let backend: Promise<CompleteFn> | null = null
24
+
25
+ async function realBackend(): Promise<CompleteFn> {
26
+ // allowModelNetwork stays false (the default): a completion must not stall on a
27
+ // catalog refresh. The runtime reads the same auth/models files as the session.
28
+ const runtime = await ModelRuntime.create()
29
+ return (model, context, options) => runtime.completeSimple(model, context, options)
30
+ }
31
+
32
+ /** Replace the completion backend, or reset to the real runtime with null. Tests only. */
33
+ export function setCompleteBackend(fn: CompleteFn | null): void {
34
+ backend = fn ? Promise.resolve(fn) : null
35
+ }
36
+
37
+ /** The text of an assistant message, thinking and tool calls dropped. */
38
+ export function assistantText(message: AssistantMessage): string {
39
+ return message.content
40
+ .filter((part): part is { type: 'text'; text: string } => part.type === 'text')
41
+ .map((part) => part.text)
42
+ .join('')
43
+ .trim()
44
+ }
45
+
46
+ export interface CompleteOptions {
47
+ /** System prompt for the one-off turn. */
48
+ system?: string
49
+ /** Output cap; a summary/decision does not need the model's full budget. */
50
+ maxTokens?: number
51
+ signal?: AbortSignal
52
+ }
53
+
54
+ /**
55
+ * Run `prompt` through `model` as a single user turn and return the reply text.
56
+ * Throws on any failure so the caller can fall back; never returns a partial or a
57
+ * tool call, only assistant text.
58
+ */
59
+ export async function completeText(model: Model<Api>, prompt: string, options: CompleteOptions = {}): Promise<string> {
60
+ backend ??= realBackend()
61
+ const complete = await backend
62
+ const context: Context = {
63
+ systemPrompt: options.system,
64
+ messages: [{ role: 'user', content: prompt, timestamp: Date.now() }],
65
+ }
66
+ const message = await complete(model, context, { maxTokens: options.maxTokens ?? 1024, signal: options.signal })
67
+ return assistantText(message)
68
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Claude's Read/Edit path-rule matching, applied to allowed-tools grants.
3
+ *
4
+ * Claude consults Read(path) and Edit(path) rules for file access, with Edit
5
+ * rules also governing writes. Patterns follow gitignore syntax with four anchor
6
+ * forms: `//abs` from the filesystem root, `~/` from home, `/` from the project
7
+ * root (the settings source), and bare or `./` from the current directory. As
8
+ * allow rules, a single-segment directory pattern anchors at cwd; a bare
9
+ * filename matches at any depth. `*` stays within one segment, `**` crosses
10
+ * directories. Matching is lexical, on resolved paths; bracket expressions are
11
+ * not supported and match literally, which can only over-block, never widen.
12
+ */
13
+
14
+ import * as path from 'node:path'
15
+
16
+ export interface PathAnchors {
17
+ cwd: string
18
+ projectRoot: string
19
+ home: string
20
+ }
21
+
22
+ const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`)
23
+
24
+ /** One gitignore-style pattern as an anchored regular expression source. */
25
+ export function globToRegExpSource(pattern: string): string {
26
+ let out = ''
27
+ let i = 0
28
+ while (i < pattern.length) {
29
+ const ch = pattern[i]
30
+ if (ch === '*') {
31
+ if (pattern[i + 1] === '*') {
32
+ const prevSlash = i === 0 || pattern[i - 1] === '/'
33
+ if (prevSlash && pattern[i + 2] === '/') {
34
+ out += '(?:[^/]+/)*' // `**/` spans zero or more whole directories
35
+ i += 3
36
+ continue
37
+ }
38
+ out += '.*'
39
+ i += 2
40
+ continue
41
+ }
42
+ out += '[^/]*'
43
+ i += 1
44
+ continue
45
+ }
46
+ if (ch === '?') {
47
+ out += '[^/]'
48
+ i += 1
49
+ continue
50
+ }
51
+ out += escapeRegExp(ch)
52
+ i += 1
53
+ }
54
+ return out
55
+ }
56
+
57
+ /** A rule resolved to an absolute glob per its anchor form. */
58
+ function resolveRule(rule: string, anchors: PathAnchors): string {
59
+ if (rule.startsWith('//')) return rule.slice(1)
60
+ if (rule.startsWith('~/')) return path.join(anchors.home, rule.slice(2))
61
+ if (rule.startsWith('/')) return path.join(anchors.projectRoot, rule.slice(1))
62
+ const rel = rule.startsWith('./') ? rule.slice(2) : rule
63
+ // A bare filename follows gitignore semantics and matches at any depth under cwd.
64
+ if (!rel.includes('/')) return path.join(anchors.cwd, '**', rel)
65
+ return path.join(anchors.cwd, rel)
66
+ }
67
+
68
+ /** Whether the accessed file matches at least one rule. No rules means no match:
69
+ * a granted-but-scoped tool with an empty scope set stays blocked, never open. */
70
+ export function matchesPathRules(filePath: string, rules: string[], anchors: PathAnchors): boolean {
71
+ const target = path.resolve(anchors.cwd, filePath)
72
+ return rules.some((rule) => {
73
+ const trimmed = rule.trim()
74
+ // An empty specifier (`Read()`) matches nothing, so the tool stays blocked
75
+ // rather than falling open, mirroring `Bash()`.
76
+ if (trimmed === '') return false
77
+ const resolved = resolveRule(trimmed, anchors)
78
+ return new RegExp(`^${globToRegExpSource(resolved)}$`).test(target)
79
+ })
80
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Discovery for installed Claude Code plugins.
3
+ *
4
+ * Marketplace installs live under ~/.claude/plugins/cache/<marketplace>/<name>/
5
+ * <version>/, with an optional .claude-plugin/plugin.json manifest, and are
6
+ * active only when `enabledPlugins` in the settings chain says true, under the
7
+ * bare name or the marketplace-qualified `name@marketplace`. Only an explicit
8
+ * true enables: Claude writes the entry on install, so a cached plugin with no
9
+ * entry is not one the user turned on. With no version index on disk, the
10
+ * newest version directory wins, matching the update-then-grace-period layout.
11
+ * The persistent data directory (${CLAUDE_PLUGIN_DATA}) survives updates at
12
+ * ~/.claude/plugins/data/<id>, id being the qualified name folded to dashes.
13
+ */
14
+
15
+ import * as fs from 'node:fs'
16
+ import * as path from 'node:path'
17
+
18
+ export interface InstalledPlugin {
19
+ name: string
20
+ /** The version directory: ${CLAUDE_PLUGIN_ROOT}. */
21
+ root: string
22
+ /** ${CLAUDE_PLUGIN_DATA}; may not exist yet. */
23
+ dataDir: string
24
+ manifest: Record<string, unknown>
25
+ /** Resolved `pluginConfigs[id].options` values, exposed as `${user_config.KEY}`. */
26
+ userConfig?: Record<string, string>
27
+ }
28
+
29
+ function readJson(file: string): Record<string, unknown> {
30
+ try {
31
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf-8'))
32
+ return parsed !== null && typeof parsed === 'object' ? parsed : {}
33
+ } catch {
34
+ return {}
35
+ }
36
+ }
37
+
38
+ function listDirs(dir: string): string[] {
39
+ try {
40
+ return fs
41
+ .readdirSync(dir, { withFileTypes: true })
42
+ .filter((entry) => entry.isDirectory())
43
+ .map((entry) => entry.name)
44
+ } catch {
45
+ return []
46
+ }
47
+ }
48
+
49
+ /** Version directories sort numerically segment-wise, so 1.10.0 beats 1.9.0. */
50
+ function newestVersion(versions: string[]): string | undefined {
51
+ return [...versions].sort((a, b) => a.localeCompare(b, 'en', { numeric: true })).at(-1)
52
+ }
53
+
54
+ /** The enablement map, later files winning per key, as settings scopes merge. */
55
+ function enabledMap(settingsFiles: string[]): Record<string, boolean> {
56
+ const merged: Record<string, boolean> = {}
57
+ for (const file of settingsFiles) {
58
+ const entry = readJson(file).enabledPlugins
59
+ if (entry === null || typeof entry !== 'object') continue
60
+ for (const [name, value] of Object.entries(entry)) {
61
+ if (typeof value === 'boolean') merged[name] = value
62
+ }
63
+ }
64
+ return merged
65
+ }
66
+
67
+ /** `pluginConfigs[id].options` per plugin id, later files winning per key. */
68
+ function pluginConfigsMap(settingsFiles: string[]): Record<string, Record<string, string>> {
69
+ const merged: Record<string, Record<string, string>> = {}
70
+ for (const file of settingsFiles) {
71
+ const entry = readJson(file).pluginConfigs
72
+ if (entry === null || typeof entry !== 'object') continue
73
+ for (const [id, config] of Object.entries(entry)) {
74
+ const options = (config as Record<string, unknown>)?.options
75
+ if (options === null || typeof options !== 'object') continue
76
+ const values = merged[id] ?? {}
77
+ for (const [key, value] of Object.entries(options)) {
78
+ if (typeof value === 'string') values[key] = value
79
+ else if (typeof value === 'number' || typeof value === 'boolean') values[key] = String(value)
80
+ }
81
+ merged[id] = values
82
+ }
83
+ }
84
+ return merged
85
+ }
86
+
87
+ /**
88
+ * Enabled plugins from the cache. Enablement is decided by the user's own
89
+ * settings only: plugins install to the user's machine and carry code (hook
90
+ * scripts, MCP server commands), so a checked-out repo must not be able to flip
91
+ * which of them run. `extraSettingsFiles`, when given, are additional
92
+ * user-controlled settings sources, not project files.
93
+ */
94
+ export function installedPlugins(home: string, extraSettingsFiles: string[] = []): InstalledPlugin[] {
95
+ const cacheDir = path.join(home, '.claude', 'plugins', 'cache')
96
+ const settingsFiles = [path.join(home, '.claude', 'settings.json'), ...extraSettingsFiles]
97
+ const enabled = enabledMap(settingsFiles)
98
+ const configs = pluginConfigsMap(settingsFiles)
99
+ const plugins: InstalledPlugin[] = []
100
+ for (const marketplace of listDirs(cacheDir)) {
101
+ for (const pluginDir of listDirs(path.join(cacheDir, marketplace))) {
102
+ const qualified = `${pluginDir}@${marketplace}`
103
+ const state = enabled[qualified] ?? enabled[pluginDir]
104
+ if (state !== true) continue
105
+ const version = newestVersion(listDirs(path.join(cacheDir, marketplace, pluginDir)))
106
+ if (!version) continue
107
+ const root = path.join(cacheDir, marketplace, pluginDir, version)
108
+ const manifest = readJson(path.join(root, '.claude-plugin', 'plugin.json'))
109
+ const name = typeof manifest.name === 'string' && manifest.name.length > 0 ? manifest.name : pluginDir
110
+ const id = qualified.replace(/[^A-Za-z0-9]+/g, '-')
111
+ const userConfig = configs[qualified] ?? configs[pluginDir] ?? configs[name]
112
+ plugins.push({ name, root, dataDir: path.join(home, '.claude', 'plugins', 'data', id), manifest, ...(userConfig ? { userConfig } : {}) })
113
+ }
114
+ }
115
+ return plugins
116
+ }
117
+
118
+ /** The two plugin path variables, textually substituted into plugin-shipped
119
+ * config (hook commands, MCP server definitions, command bodies). */
120
+ export function substitutePluginVars(value: string, plugin: InstalledPlugin): string {
121
+ return value
122
+ .replaceAll('${CLAUDE_PLUGIN_ROOT}', plugin.root)
123
+ .replaceAll('${CLAUDE_PLUGIN_DATA}', plugin.dataDir)
124
+ .replace(/\$\{user_config\.([A-Za-z0-9_]+)\}/g, (_, key: string) => plugin.userConfig?.[key] ?? '')
125
+ }
@@ -21,6 +21,8 @@ import * as fs from 'node:fs'
21
21
  import * as path from 'node:path'
22
22
  import { getAgentDir, hasTrustRequiringProjectResources, ProjectTrustStore } from '@earendil-works/pi-coding-agent'
23
23
 
24
+ import { ROOT_MARKERS } from './project-root.js'
25
+
24
26
  /** Project files pi-code acts on that pi's own trust check does not look for. */
25
27
  const CLAUDE_SHAPED = [
26
28
  path.join('.claude', 'settings.json'),
@@ -37,9 +39,6 @@ const CLAUDE_SHAPED = [
37
39
  path.join('.pi', 'agents'),
38
40
  ]
39
41
 
40
- /** Markers that end the upward walk, matching the subagent's own discovery bound. */
41
- const ROOT_MARKERS = ['.git', 'package.json']
42
-
43
42
  /** Claude-shaped config anywhere between `cwd` and the repository root.
44
43
  *
45
44
  * The walk matters: agent discovery already searches upward, so starting pi in a
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Upward search for project configuration, shared across extensions.
3
+ *
4
+ * Claude anchors project config (.claude/*, .mcp.json, CLAUDE.local.md) at the
5
+ * project root, so a session started in a subdirectory must still find it. The
6
+ * walk runs from cwd up to the repository root and no further: without the bound,
7
+ * config planted in a world-writable ancestor such as /tmp would be offered to
8
+ * every session beneath it. With no project marker the extent is unknown, so only
9
+ * cwd is considered. The project-approval walk uses the same markers, so whatever
10
+ * these find is exactly what that walk gated.
11
+ */
12
+
13
+ import * as fs from 'node:fs'
14
+ import * as path from 'node:path'
15
+
16
+ /** Project root markers ending the walk. `.git` is a file in worktrees and submodules. */
17
+ export const ROOT_MARKERS = ['.git', 'package.json']
18
+
19
+ /** Project root at or above `from`, or undefined when no marker is found. */
20
+ export function repoRoot(from: string): string | undefined {
21
+ let currentDir = from
22
+ while (true) {
23
+ if (ROOT_MARKERS.some((marker) => fs.existsSync(path.join(currentDir, marker)))) return currentDir
24
+ const parentDir = path.dirname(currentDir)
25
+ if (parentDir === currentDir) return undefined
26
+ currentDir = parentDir
27
+ }
28
+ }
29
+
30
+ function statOf(target: string): fs.Stats | null {
31
+ try {
32
+ return fs.statSync(target)
33
+ } catch {
34
+ return null
35
+ }
36
+ }
37
+
38
+ function findNearest(cwd: string, relative: string, wantDir: boolean): string | null {
39
+ const boundary = repoRoot(cwd) ?? cwd
40
+ let currentDir = cwd
41
+ while (true) {
42
+ const candidate = path.join(currentDir, relative)
43
+ const stat = statOf(candidate)
44
+ if (stat && (wantDir ? stat.isDirectory() : stat.isFile())) return candidate
45
+
46
+ if (currentDir === boundary) return null
47
+ const parentDir = path.dirname(currentDir)
48
+ if (parentDir === currentDir) return null
49
+ currentDir = parentDir
50
+ }
51
+ }
52
+
53
+ /** Nearest `relative` directory at or above `cwd`, stopping at the repository root. */
54
+ export function findNearestDir(cwd: string, relative: string): string | null {
55
+ return findNearest(cwd, relative, true)
56
+ }
57
+
58
+ /** Nearest `relative` file at or above `cwd`, stopping at the repository root. */
59
+ export function findNearestFile(cwd: string, relative: string): string | null {
60
+ return findNearest(cwd, relative, false)
61
+ }
62
+
63
+ /** Every `relative` file between the repository root and cwd, ordered root first,
64
+ * matching Claude's root-down ordering for hierarchy-loaded context. */
65
+ export function ancestorFiles(cwd: string, relative: string): string[] {
66
+ const boundary = repoRoot(cwd) ?? cwd
67
+ const found: string[] = []
68
+ let currentDir = cwd
69
+ while (true) {
70
+ const candidate = path.join(currentDir, relative)
71
+ if (statOf(candidate)?.isFile()) found.push(candidate)
72
+ if (currentDir === boundary) break
73
+ const parentDir = path.dirname(currentDir)
74
+ if (parentDir === currentDir) break
75
+ currentDir = parentDir
76
+ }
77
+ return found.reverse()
78
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Quote-aware splitting of a shell command into its top-level segments.
3
+ *
4
+ * Shared by plan mode's bash guard and the commands extension's allowed-tools
5
+ * scope enforcement: both vet each subcommand on its own, and both refuse to
6
+ * guess when the shell could be hiding another command.
7
+ */
8
+
9
+ // The shell can hide an arbitrary command inside any of these, so callers refuse
10
+ // such a command outright rather than parse it.
11
+ const SUBSTITUTION = /\$\(|`|<\(|>\(/
12
+
13
+ export const hasSubstitution = (command: string): boolean => SUBSTITUTION.test(command)
14
+
15
+ /** Length of the separator at `i`, or 0 when there is none. */
16
+ function separatorAt(command: string, i: number): number {
17
+ const pair = command.slice(i, i + 2)
18
+ if (pair === '&&' || pair === '||' || pair === '|&') return 2
19
+ const ch = command[i]
20
+ return ch === ';' || ch === '|' || ch === '&' || ch === '\n' ? 1 : 0
21
+ }
22
+
23
+ /**
24
+ * Split on the shell separators Claude Code documents (`&&`, `||`, `;`, `|`, `|&`, `&`,
25
+ * newline) so every subcommand is checked on its own, ignoring separators inside quotes:
26
+ * `grep 'a|b'` is one read, not a pipe. Returns nothing on an unbalanced quote, which
27
+ * fails the caller closed rather than guessing at the intended split.
28
+ *
29
+ * A shell AST would be exact; this is the honest approximation for a quoting-only concern.
30
+ */
31
+ export function splitSegments(command: string): string[] {
32
+ const segments: string[] = []
33
+ let current = ''
34
+ let quote: "'" | '"' | undefined
35
+
36
+ for (let i = 0; i < command.length; i++) {
37
+ const ch = command[i]
38
+ if (quote !== undefined) {
39
+ current += ch
40
+ if (ch === quote) quote = undefined
41
+ continue
42
+ }
43
+ if (ch === "'" || ch === '"') {
44
+ quote = ch
45
+ current += ch
46
+ continue
47
+ }
48
+ if (ch === '\\' && i + 1 < command.length) {
49
+ current += ch + command[++i]
50
+ continue
51
+ }
52
+ const separator = separatorAt(command, i)
53
+ if (separator > 0) {
54
+ segments.push(current)
55
+ current = ''
56
+ i += separator - 1
57
+ continue
58
+ }
59
+ current += ch
60
+ }
61
+
62
+ if (quote !== undefined) return []
63
+ segments.push(current)
64
+ return segments.map((segment) => segment.trim()).filter(Boolean)
65
+ }