pi-code 1.0.4 → 1.0.6

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 +417 -41
  4. package/extensions/context-imports.ts +446 -61
  5. package/extensions/hooks.ts +473 -73
  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 +423 -66
  10. package/extensions/internal/html-markdown.ts +71 -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 +177 -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 +138 -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 +100 -0
  22. package/extensions/internal/web-transport.ts +3 -1
  23. package/extensions/mcp.ts +579 -30
  24. package/extensions/memory.ts +158 -35
  25. package/extensions/notify.ts +76 -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 +100 -5
  31. package/extensions/subagent/agents.ts +72 -61
  32. package/extensions/subagent/background.ts +25 -6
  33. package/extensions/subagent/index.ts +310 -31
  34. package/extensions/web.ts +93 -15
  35. package/package.json +1 -1
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Channel and payload for instruction-file loads published on pi's shared extension
3
+ * event bus. Producers are claude-rules (a scoped rule lazily attaching on a matching
4
+ * file touch, load_reason `path_glob_match`) and context-imports (resolved `@imports`,
5
+ * load_reason `include`, and CLAUDE.local.md loads, load_reason `session_start`).
6
+ * Hooks bridge them to Claude's InstructionsLoaded event, which is strictly
7
+ * observational. pi loads extensions without a shared module cache, so state rides
8
+ * the bus, mirroring subagent-events.
9
+ */
10
+
11
+ import * as path from 'node:path'
12
+
13
+ export const INSTRUCTIONS_CHANNEL = 'pi-code:instructions'
14
+
15
+ /** Claude's memory_type vocabulary for InstructionsLoaded payloads. */
16
+ export type InstructionMemoryType = 'User' | 'Project' | 'Local' | 'Managed'
17
+
18
+ const MEMORY_TYPES: ReadonlySet<string> = new Set(['User', 'Project', 'Local', 'Managed'])
19
+
20
+ export interface InstructionLoadEvent {
21
+ /** Absolute path of the instruction file that entered context. */
22
+ file_path: string
23
+ memory_type: InstructionMemoryType
24
+ /** What caused the load; InstructionsLoaded matchers run against this. */
25
+ load_reason: string
26
+ /** The rule's `paths:` globs; present only for path_glob_match. */
27
+ globs?: string[]
28
+ /** The file whose access triggered a lazy load. */
29
+ trigger_file_path?: string
30
+ /** The importing file, for include loads. */
31
+ parent_file_path?: string
32
+ }
33
+
34
+ export function isInstructionLoadEvent(data: unknown): data is InstructionLoadEvent {
35
+ const event = data as InstructionLoadEvent | null
36
+ if (event === null || typeof event !== 'object') return false
37
+ if (typeof event.file_path !== 'string' || typeof event.load_reason !== 'string') return false
38
+ if (!MEMORY_TYPES.has(event.memory_type)) return false
39
+ if (event.globs !== undefined && !(Array.isArray(event.globs) && event.globs.every((glob) => typeof glob === 'string'))) return false
40
+ if (event.trigger_file_path !== undefined && typeof event.trigger_file_path !== 'string') return false
41
+ if (event.parent_file_path !== undefined && typeof event.parent_file_path !== 'string') return false
42
+ return true
43
+ }
44
+
45
+ /** Claude's memory_type from a file's location: CLAUDE.local.md is Local wherever it
46
+ * sits; a file under home but outside the project is User; everything else, the
47
+ * project itself included (which commonly lives under home), is Project. */
48
+ export function memoryTypeForPath(filePath: string, home: string, projectRoot: string): InstructionMemoryType {
49
+ if (path.basename(filePath) === 'CLAUDE.local.md') return 'Local'
50
+ const isUnder = (root: string): boolean => root.length > 0 && (filePath === root || filePath.startsWith(root + path.sep))
51
+ if (isUnder(projectRoot)) return 'Project'
52
+ // Monorepo: repoRoot stops at the nearest .git OR package.json, so a git-root
53
+ // CLAUDE.md can sit above the projectRoot a subpackage session reports. A file
54
+ // whose directory is a strict ancestor of the project root is still project
55
+ // memory. The home directory itself stays User: a home-level context file is
56
+ // user config even when the project lives under home.
57
+ const dir = path.dirname(filePath)
58
+ if (dir !== home && (projectRoot === dir || projectRoot.startsWith(dir + path.sep))) return 'Project'
59
+ if (isUnder(home)) return 'User'
60
+ return 'Project'
61
+ }
62
+
63
+ /** The emit half of pi's EventBus; producers may run under stub hosts without one. */
64
+ export interface InstructionBus {
65
+ emit(channel: string, data: unknown): void
66
+ }
67
+
68
+ export function publishInstructionLoad(events: InstructionBus | undefined, event: InstructionLoadEvent): void {
69
+ events?.emit(INSTRUCTIONS_CHANNEL, event)
70
+ }
@@ -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 String.raw`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,177 @@
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
+ // Collapse disallowed runs to a single hyphen, then strip leading and trailing
36
+ // hyphens by index. The old /^-+|-+$/g trim rescanned on every hyphen of a long run
37
+ // (its trailing-anchored branch backtracks per start position), which is quadratic.
38
+ const collapsed = serverName.replace(/[^A-Za-z0-9_-]+/g, '-')
39
+ let start = 0
40
+ let end = collapsed.length
41
+ while (start < end && collapsed[start] === '-') start++
42
+ while (end > start && collapsed[end - 1] === '-') end--
43
+ const safe = collapsed.slice(start, end).slice(0, 40) || 'server'
44
+ return path.join(getAgentDir(), 'mcp-oauth', `${safe}-${digest}.json`)
45
+ }
46
+
47
+ export class FileOAuthProvider implements OAuthClientProvider {
48
+ private readonly storePath: string
49
+ private readonly data: StoredAuth
50
+ private port = 0
51
+ private readonly onRedirect: (authorizationUrl: URL) => void
52
+
53
+ constructor(serverName: string, onRedirect: (authorizationUrl: URL) => void) {
54
+ this.storePath = storeFileFor(serverName)
55
+ this.onRedirect = onRedirect
56
+ try {
57
+ this.data = JSON.parse(fs.readFileSync(this.storePath, 'utf-8'))
58
+ } catch {
59
+ this.data = {}
60
+ }
61
+ }
62
+
63
+ private persist(): void {
64
+ fs.mkdirSync(path.dirname(this.storePath), { recursive: true })
65
+ fs.writeFileSync(this.storePath, JSON.stringify(this.data), { mode: 0o600 })
66
+ }
67
+
68
+ /** The port a prior login registered, so a re-login can bind the same one. */
69
+ savedRedirectPort(): number | undefined {
70
+ return this.data.redirectPort
71
+ }
72
+
73
+ /** Record the loopback port the callback server actually bound; the redirect
74
+ * URL and the registered redirect_uri both derive from it. */
75
+ bindRedirectPort(port: number): void {
76
+ this.port = port
77
+ if (this.data.redirectPort !== port) {
78
+ this.data.redirectPort = port
79
+ this.persist()
80
+ }
81
+ }
82
+
83
+ get redirectUrl(): string {
84
+ return `http://127.0.0.1:${this.port}/callback`
85
+ }
86
+
87
+ get clientMetadata(): OAuthClientMetadata {
88
+ return {
89
+ client_name: 'pi-code',
90
+ redirect_uris: [this.redirectUrl],
91
+ grant_types: ['authorization_code', 'refresh_token'],
92
+ response_types: ['code'],
93
+ // A local CLI is a public client; PKCE carries the proof instead of a secret.
94
+ token_endpoint_auth_method: 'none',
95
+ }
96
+ }
97
+
98
+ clientInformation(): OAuthClientInformationMixed | undefined {
99
+ return this.data.client
100
+ }
101
+
102
+ saveClientInformation(client: OAuthClientInformationMixed): void {
103
+ this.data.client = client
104
+ this.persist()
105
+ }
106
+
107
+ tokens(): OAuthTokens | undefined {
108
+ return this.data.tokens
109
+ }
110
+
111
+ saveTokens(tokens: OAuthTokens): void {
112
+ this.data.tokens = tokens
113
+ this.persist()
114
+ }
115
+
116
+ hasTokens(): boolean {
117
+ return this.data.tokens !== undefined
118
+ }
119
+
120
+ redirectToAuthorization(authorizationUrl: URL): void {
121
+ this.onRedirect(authorizationUrl)
122
+ }
123
+
124
+ saveCodeVerifier(verifier: string): void {
125
+ this.data.verifier = verifier
126
+ this.persist()
127
+ }
128
+
129
+ codeVerifier(): string {
130
+ if (!this.data.verifier) throw new Error('no code verifier saved for this authorization')
131
+ return this.data.verifier
132
+ }
133
+ }
134
+
135
+ /** A one-shot loopback listener for the authorization redirect. Loopback redirect
136
+ * URIs are the RFC 8252 pattern for native apps. A preferred port (from a prior
137
+ * login) is tried first so a re-login keeps the registered redirect_uri; if it is
138
+ * taken, an ephemeral port is used. */
139
+ export async function startCallbackServer(preferredPort?: number): Promise<{ server: http.Server; port: number }> {
140
+ const server = http.createServer()
141
+ const listen = (port: number): Promise<void> => new Promise((resolve, reject) => server.listen(port, '127.0.0.1', resolve).once('error', reject))
142
+ try {
143
+ await listen(preferredPort ?? 0)
144
+ } catch {
145
+ await listen(0)
146
+ }
147
+ return { server, port: (server.address() as { port: number }).port }
148
+ }
149
+
150
+ export function waitForAuthCode(server: http.Server, timeoutMs: number): Promise<string> {
151
+ return new Promise((resolve, reject) => {
152
+ const timer = setTimeout(() => reject(new Error(`authorization timed out after ${timeoutMs}ms`)), timeoutMs)
153
+ server.on('request', (request, response) => {
154
+ const url = new URL(request.url ?? '/', 'http://127.0.0.1')
155
+ const code = url.searchParams.get('code')
156
+ const error = url.searchParams.get('error')
157
+ response.writeHead(200, { 'content-type': 'text/html' })
158
+ response.end('<html><body>pi-code: you can close this tab and return to the terminal.</body></html>')
159
+ clearTimeout(timer)
160
+ if (code) resolve(code)
161
+ else reject(new Error(`authorization failed: ${error ?? 'no code in redirect'} ${url.searchParams.get('error_description') ?? ''}`.trim()))
162
+ })
163
+ })
164
+ }
165
+
166
+ /** Best-effort browser launch; the caller also surfaces the URL as text. */
167
+ export function openBrowser(url: string): void {
168
+ let command = 'xdg-open'
169
+ if (process.platform === 'darwin') command = 'open'
170
+ else if (process.platform === 'win32') command = 'cmd'
171
+ const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url]
172
+ try {
173
+ spawn(command, args, { stdio: 'ignore', detached: true }).unref()
174
+ } catch {
175
+ // the notified URL is the fallback
176
+ }
177
+ }
@@ -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,138 @@
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
+ /** Copy one plugin config's `options` into `target`, coercing scalars to strings and
68
+ * ignoring the rest, later keys winning. */
69
+ function mergeOptionValues(target: Record<string, string>, options: object): void {
70
+ for (const [key, value] of Object.entries(options)) {
71
+ if (typeof value === 'string') target[key] = value
72
+ else if (typeof value === 'number' || typeof value === 'boolean') target[key] = String(value)
73
+ }
74
+ }
75
+
76
+ /** `pluginConfigs[id].options` per plugin id, later files winning per key. */
77
+ function pluginConfigsMap(settingsFiles: string[]): Record<string, Record<string, string>> {
78
+ const merged: Record<string, Record<string, string>> = {}
79
+ for (const file of settingsFiles) {
80
+ const entry = readJson(file).pluginConfigs
81
+ if (entry === null || typeof entry !== 'object') continue
82
+ for (const [id, config] of Object.entries(entry)) {
83
+ const options = (config as Record<string, unknown>)?.options
84
+ if (options === null || typeof options !== 'object') continue
85
+ const values = merged[id] ?? {}
86
+ mergeOptionValues(values, options)
87
+ merged[id] = values
88
+ }
89
+ }
90
+ return merged
91
+ }
92
+
93
+ /**
94
+ * Enabled plugins from the cache. Enablement is decided by the user's own
95
+ * settings only: plugins install to the user's machine and carry code (hook
96
+ * scripts, MCP server commands), so a checked-out repo must not be able to flip
97
+ * which of them run. `extraSettingsFiles`, when given, are additional
98
+ * user-controlled settings sources, not project files.
99
+ */
100
+ export function installedPlugins(home: string, extraSettingsFiles: string[] = []): InstalledPlugin[] {
101
+ const cacheDir = path.join(home, '.claude', 'plugins', 'cache')
102
+ const settingsFiles = [path.join(home, '.claude', 'settings.json'), ...extraSettingsFiles]
103
+ const enabled = enabledMap(settingsFiles)
104
+ const configs = pluginConfigsMap(settingsFiles)
105
+ const plugins: InstalledPlugin[] = []
106
+ for (const marketplace of listDirs(cacheDir)) {
107
+ for (const pluginDir of listDirs(path.join(cacheDir, marketplace))) {
108
+ const plugin = resolvePlugin(home, cacheDir, marketplace, pluginDir, enabled, configs)
109
+ if (plugin) plugins.push(plugin)
110
+ }
111
+ }
112
+ return plugins
113
+ }
114
+
115
+ /** Resolve one cached plugin directory into an enabled InstalledPlugin, or null to skip
116
+ * it: not turned on in settings, or no version directory on disk yet. */
117
+ function resolvePlugin(home: string, cacheDir: string, marketplace: string, pluginDir: string, enabled: Record<string, boolean>, configs: Record<string, Record<string, string>>): InstalledPlugin | null {
118
+ const qualified = `${pluginDir}@${marketplace}`
119
+ const state = enabled[qualified] ?? enabled[pluginDir]
120
+ if (state !== true) return null
121
+ const version = newestVersion(listDirs(path.join(cacheDir, marketplace, pluginDir)))
122
+ if (!version) return null
123
+ const root = path.join(cacheDir, marketplace, pluginDir, version)
124
+ const manifest = readJson(path.join(root, '.claude-plugin', 'plugin.json'))
125
+ const name = typeof manifest.name === 'string' && manifest.name.length > 0 ? manifest.name : pluginDir
126
+ const id = qualified.replace(/[^A-Za-z0-9]+/g, '-')
127
+ const userConfig = configs[qualified] ?? configs[pluginDir] ?? configs[name]
128
+ return { name, root, dataDir: path.join(home, '.claude', 'plugins', 'data', id), manifest, ...(userConfig ? { userConfig } : {}) }
129
+ }
130
+
131
+ /** The two plugin path variables, textually substituted into plugin-shipped
132
+ * config (hook commands, MCP server definitions, command bodies). */
133
+ export function substitutePluginVars(value: string, plugin: InstalledPlugin): string {
134
+ return value
135
+ .replaceAll('${CLAUDE_PLUGIN_ROOT}', plugin.root)
136
+ .replaceAll('${CLAUDE_PLUGIN_DATA}', plugin.dataDir)
137
+ .replace(/\$\{user_config\.(\w+)\}/g, (_, key: string) => plugin.userConfig?.[key] ?? '')
138
+ }
@@ -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