pi-code 1.0.4 → 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.
- package/README.md +25 -13
- package/extensions/claude-rules.ts +158 -54
- package/extensions/commands.ts +179 -21
- package/extensions/context-imports.ts +353 -39
- package/extensions/hooks.ts +351 -63
- package/extensions/init.ts +81 -0
- package/extensions/internal/agent-run.ts +42 -0
- package/extensions/internal/bash-rules.ts +27 -0
- package/extensions/internal/command-file.ts +373 -59
- package/extensions/internal/html-markdown.ts +61 -0
- package/extensions/internal/instruction-events.ts +70 -0
- package/extensions/internal/managed-settings.ts +38 -0
- package/extensions/internal/mcp-call.ts +28 -0
- package/extensions/internal/mcp-oauth.ts +171 -0
- package/extensions/internal/model-complete.ts +68 -0
- package/extensions/internal/path-rules.ts +80 -0
- package/extensions/internal/plugins.ts +125 -0
- package/extensions/internal/project-approval.ts +2 -3
- package/extensions/internal/project-root.ts +78 -0
- package/extensions/internal/shell-split.ts +65 -0
- package/extensions/internal/strip-comments.ts +77 -0
- package/extensions/internal/web-transport.ts +3 -1
- package/extensions/mcp.ts +272 -28
- package/extensions/memory.ts +129 -16
- package/extensions/notify.ts +77 -4
- package/extensions/output-styles.ts +34 -6
- package/extensions/plan-mode/utils.ts +3 -57
- package/extensions/question.ts +2 -2
- package/extensions/skills.ts +11 -1
- package/extensions/status-line.ts +93 -3
- package/extensions/subagent/agents.ts +72 -61
- package/extensions/subagent/background.ts +25 -6
- package/extensions/subagent/index.ts +194 -29
- package/extensions/web.ts +80 -15
- 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: readonly string[] = ['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.includes(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 '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
|
+
}
|