pi-code 1.0.20 → 1.0.21
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.
|
@@ -104,12 +104,47 @@ function userEnv(home: string): Record<string, string> {
|
|
|
104
104
|
return envFromSettings(readSettingsFile(path.join(claudeConfigDir(home), 'settings.json')))
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
/** Keys a checked-out repository must not control even once trusted, per Claude's
|
|
108
|
+
* documented drop list: variables that choose where config and files are written
|
|
109
|
+
* (redirecting later home-scope reads and every subprocess), variables that export
|
|
110
|
+
* session content, and variables that change how the agent starts or syncs.
|
|
111
|
+
* PI_CODING_AGENT_DIR is pi's own config-dir analogue of CLAUDE_CONFIG_DIR. */
|
|
112
|
+
const REPO_HOSTILE_ENV_KEYS = new Set([
|
|
113
|
+
'CLAUDE_CONFIG_DIR',
|
|
114
|
+
'CLAUDE_CODE_TMPDIR',
|
|
115
|
+
'HOME',
|
|
116
|
+
'TMPDIR',
|
|
117
|
+
'TMP',
|
|
118
|
+
'TEMP',
|
|
119
|
+
'OTEL_LOG_RAW_API_BODIES',
|
|
120
|
+
'ENABLE_BETA_TRACING_DETAILED',
|
|
121
|
+
'BETA_TRACING_ENDPOINT',
|
|
122
|
+
'CLAUDE_CODE_PROCESS_WRAPPER',
|
|
123
|
+
'CLAUDE_CODE_SYNC_SKILLS',
|
|
124
|
+
'CLAUDE_CODE_SYNC_PLUGINS',
|
|
125
|
+
'CLAUDE_CODE_PLUGIN_CACHE_DIR',
|
|
126
|
+
'CLAUDE_CODE_PLUGIN_SEED_DIR',
|
|
127
|
+
'PI_CODING_AGENT_DIR',
|
|
128
|
+
])
|
|
129
|
+
|
|
130
|
+
/** Drop the keys a repository's settings must not set, warning each, as Claude
|
|
131
|
+
* documents ("Claude Code drops each one and logs a warning"). Set them in the
|
|
132
|
+
* shell, user settings, or managed settings instead. */
|
|
133
|
+
export function sanitizeProjectEnv(env: Record<string, string>, warn: (key: string) => void = (key) => console.warn(`pi-code-env: dropping ${key} from project settings env (a checked-out repository must not control it; set it in user or managed settings)`)): Record<string, string> {
|
|
134
|
+
const kept: Record<string, string> = {}
|
|
135
|
+
for (const [key, value] of Object.entries(env)) {
|
|
136
|
+
if (REPO_HOSTILE_ENV_KEYS.has(key) || key.startsWith('XDG_')) warn(key)
|
|
137
|
+
else kept[key] = value
|
|
138
|
+
}
|
|
139
|
+
return kept
|
|
140
|
+
}
|
|
141
|
+
|
|
107
142
|
/** The project scope's env: .claude/settings.json with settings.local.json overlaid,
|
|
108
143
|
* each the nearest of its name at or above cwd (matching the hooks settings chain). */
|
|
109
144
|
function projectEnv(cwd: string): Record<string, string> {
|
|
110
145
|
const base = envFromSettings(readSettingsFile(findNearestFile(cwd, path.join('.claude', 'settings.json')) ?? path.join(cwd, '.claude', 'settings.json')))
|
|
111
146
|
const local = envFromSettings(readSettingsFile(findNearestFile(cwd, path.join('.claude', 'settings.local.json')) ?? path.join(cwd, '.claude', 'settings.local.json')))
|
|
112
|
-
return { ...base, ...local }
|
|
147
|
+
return sanitizeProjectEnv({ ...base, ...local })
|
|
113
148
|
}
|
|
114
149
|
|
|
115
150
|
export default function envSettingsExtension(pi: ExtensionAPI) {
|
package/extensions/mcp/config.ts
CHANGED
|
@@ -7,7 +7,7 @@ import * as fs from 'node:fs'
|
|
|
7
7
|
import * as os from 'node:os'
|
|
8
8
|
import * as path from 'node:path'
|
|
9
9
|
import { claudeConfigDir } from '../internal/config-dir.js'
|
|
10
|
-
import {
|
|
10
|
+
import type { InstalledPlugin } from '../internal/plugins.js'
|
|
11
11
|
import { findNearestFile } from '../internal/project-root.js'
|
|
12
12
|
|
|
13
13
|
export interface StdioServerConfig {
|
|
@@ -108,39 +108,73 @@ export function loadUserScope(home: string, cwd: string): Record<string, ServerC
|
|
|
108
108
|
return servers
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
/** The mcpServers one plugin declares: an inline map on
|
|
112
|
-
* points to (default .mcp.json at the plugin root)
|
|
113
|
-
*
|
|
114
|
-
|
|
111
|
+
/** The mcpServers one plugin declares, parsed WITHOUT substitution: an inline map on
|
|
112
|
+
* the manifest, or the file it points to (default .mcp.json at the plugin root).
|
|
113
|
+
* Malformed or missing JSON yields no entries. Substitution happens per field
|
|
114
|
+
* afterwards, so a user_config value with JSON-breaking characters cannot corrupt
|
|
115
|
+
* the parse and headersHelper can be shielded. */
|
|
116
|
+
function rawPluginServerEntries(plugin: InstalledPlugin): Record<string, unknown> {
|
|
115
117
|
const declared = plugin.manifest.mcpServers
|
|
116
118
|
// An inline map of name -> config; an array is not a valid mcpServers map (it
|
|
117
119
|
// would register a server named '0'), so it falls through to the path branch.
|
|
118
120
|
if (declared !== null && typeof declared === 'object' && !Array.isArray(declared)) {
|
|
119
|
-
|
|
120
|
-
return JSON.parse(substitutePluginVars(JSON.stringify(declared), plugin))
|
|
121
|
-
} catch {
|
|
122
|
-
return {}
|
|
123
|
-
}
|
|
121
|
+
return { ...(declared as Record<string, unknown>) }
|
|
124
122
|
}
|
|
125
123
|
const file = path.resolve(plugin.root, typeof declared === 'string' ? declared : '.mcp.json')
|
|
126
124
|
try {
|
|
127
|
-
const parsed = JSON.parse(
|
|
125
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf-8'))
|
|
128
126
|
return parsed.mcpServers ?? {}
|
|
129
127
|
} catch {
|
|
130
128
|
return {}
|
|
131
129
|
}
|
|
132
130
|
}
|
|
133
131
|
|
|
132
|
+
/** Every string in the value mapped through `substitute`, arrays and objects walked. */
|
|
133
|
+
function mapStrings(value: unknown, substitute: (text: string) => string): unknown {
|
|
134
|
+
if (typeof value === 'string') return substitute(value)
|
|
135
|
+
if (Array.isArray(value)) return value.map((entry) => mapStrings(entry, substitute))
|
|
136
|
+
if (value !== null && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, mapStrings(entry, substitute)]))
|
|
137
|
+
return value
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** One plugin server with Claude's substitutions applied: the plugin path variables
|
|
141
|
+
* and ${CLAUDE_PROJECT_DIR} everywhere, ${user_config.*} everywhere EXCEPT
|
|
142
|
+
* headersHelper (the command runs through a shell, so Claude reports such a server
|
|
143
|
+
* as misconfigured rather than substituting a user-supplied value into it; pi-code
|
|
144
|
+
* skips it with a warning). */
|
|
145
|
+
function substitutedPluginServer(plugin: InstalledPlugin, name: string, config: unknown, projectDir: string | undefined): ServerConfig | undefined {
|
|
146
|
+
if (config === null || typeof config !== 'object') return undefined
|
|
147
|
+
const helper = (config as { headersHelper?: unknown }).headersHelper
|
|
148
|
+
if (typeof helper === 'string' && /\$\{user_config\./.test(helper)) {
|
|
149
|
+
console.warn(`pi-code-mcp: plugin ${plugin.name} server ${name} is misconfigured: headersHelper references \${user_config.*}, which cannot be substituted into a shell command; the server was not loaded`)
|
|
150
|
+
return undefined
|
|
151
|
+
}
|
|
152
|
+
const pathVars = (text: string): string => {
|
|
153
|
+
const withPlugin = substitutePathPluginVars(text, plugin)
|
|
154
|
+
return projectDir === undefined ? withPlugin : withPlugin.replaceAll('${CLAUDE_PROJECT_DIR}', projectDir)
|
|
155
|
+
}
|
|
156
|
+
const full = (text: string): string => pathVars(text).replace(/\$\{user_config\.(\w+)\}/g, (_, key: string) => plugin.userConfig?.[key] ?? '')
|
|
157
|
+
const substituted = mapStrings(config, full) as ServerConfig
|
|
158
|
+
if (typeof helper === 'string') (substituted as { headersHelper?: string }).headersHelper = pathVars(helper)
|
|
159
|
+
return substituted
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The plugin path variables only, without user_config. */
|
|
163
|
+
function substitutePathPluginVars(text: string, plugin: InstalledPlugin): string {
|
|
164
|
+
return text.replaceAll('${CLAUDE_PLUGIN_ROOT}', plugin.root).replaceAll('${CLAUDE_PLUGIN_DATA}', plugin.dataDir)
|
|
165
|
+
}
|
|
166
|
+
|
|
134
167
|
/** Servers shipped by enabled plugins (.mcp.json or the manifest's `mcpServers`,
|
|
135
168
|
* inline or by path), with ${CLAUDE_PLUGIN_*} substituted before parsing. Their
|
|
136
169
|
* tools alias as mcp__plugin_<plugin>_<server>__<tool> for hook matchers, as
|
|
137
170
|
* Claude scopes them. */
|
|
138
|
-
export function loadPluginServers(plugins: InstalledPlugin[]): Record<string, ServerConfig> {
|
|
171
|
+
export function loadPluginServers(plugins: InstalledPlugin[], projectDir?: string): Record<string, ServerConfig> {
|
|
139
172
|
const fold = (name: string): string => name.replaceAll('-', '_')
|
|
140
173
|
const servers: Record<string, ServerConfig> = {}
|
|
141
174
|
for (const plugin of plugins) {
|
|
142
|
-
for (const [name, config] of Object.entries(
|
|
143
|
-
|
|
175
|
+
for (const [name, config] of Object.entries(rawPluginServerEntries(plugin))) {
|
|
176
|
+
const substituted = substitutedPluginServer(plugin, name, config, projectDir)
|
|
177
|
+
if (substituted) servers[name] = { ...substituted, aliasPrefix: `mcp__plugin_${fold(plugin.name)}_${fold(name)}__` }
|
|
144
178
|
}
|
|
145
179
|
}
|
|
146
180
|
return servers
|
package/extensions/mcp/index.ts
CHANGED
|
@@ -41,6 +41,7 @@ import { setMcpToolCaller } from '../internal/mcp-call.js'
|
|
|
41
41
|
import { capForContext } from '../internal/output-guard.js'
|
|
42
42
|
import { installedPlugins } from '../internal/plugins.js'
|
|
43
43
|
import { isProjectApproved, isProjectApprovedSilently } from '../internal/project-approval.js'
|
|
44
|
+
import { repoRoot } from '../internal/project-root.js'
|
|
44
45
|
import { claudeSettingsChain } from '../internal/settings-chain.js'
|
|
45
46
|
import { loadConfigFrom, loadPluginServers, loadUserScope, projectConfigPaths, type ServerConfig, warnOnTypelessUrl } from './config.js'
|
|
46
47
|
import { collectServerResourceEntries, listAllPrompts, listAllTools, type McpToolInfo, resourceServerFilter } from './listing.js'
|
|
@@ -424,7 +425,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
424
425
|
async function connectNormalScopes(ctx: ExtensionContext, policy: McpPolicy, authUi?: AuthUi): Promise<void> {
|
|
425
426
|
// Plugin servers merge under the user scope (plugins are user-installed);
|
|
426
427
|
// the user's own entry wins a name clash with a plugin's.
|
|
427
|
-
const pluginServers = loadPluginServers(installedPlugins(os.homedir()))
|
|
428
|
+
const pluginServers = loadPluginServers(installedPlugins(os.homedir()), repoRoot(ctx.cwd) ?? ctx.cwd)
|
|
428
429
|
const scoped = applyServerPolicy({ ...pluginServers, ...loadUserScope(os.homedir(), ctx.cwd) }, policy)
|
|
429
430
|
// Claude's precedence is project over user for a duplicate name. A project .mcp.json
|
|
430
431
|
// server only outranks the user's own when it will actually connect (the user already
|
|
@@ -213,6 +213,30 @@ function parseIsolationField(raw: unknown): 'worktree' | undefined | null {
|
|
|
213
213
|
return null
|
|
214
214
|
}
|
|
215
215
|
|
|
216
|
+
/** Claude's MCP server-level patterns in agent `tools`/`disallowedTools`:
|
|
217
|
+
* `mcp__<server>` or `mcp__<server>__*` covers every tool of that server, and
|
|
218
|
+
* `mcp__*` every MCP tool from any server. pi registers MCP tools under its own
|
|
219
|
+
* `<server>_<tool>` names, so the parent's alias list is the translation table;
|
|
220
|
+
* matching folds case and dashes like hook matchers do. A pattern that matches
|
|
221
|
+
* nothing is kept verbatim (harmless to pi's exact-name filter), so a server that
|
|
222
|
+
* failed to connect is not silently dropped from a deny list. */
|
|
223
|
+
export function expandMcpToolPatterns(entries: string[], aliases: ReadonlyArray<{ pi: string; claude: string }>): string[] {
|
|
224
|
+
const fold = (name: string): string => name.toLowerCase().replaceAll('-', '_')
|
|
225
|
+
const expanded: string[] = []
|
|
226
|
+
for (const entry of entries) {
|
|
227
|
+
const folded = fold(entry)
|
|
228
|
+
if (!folded.startsWith('mcp__')) {
|
|
229
|
+
expanded.push(entry)
|
|
230
|
+
continue
|
|
231
|
+
}
|
|
232
|
+
const server = folded === 'mcp__*' ? undefined : folded.replace(/__\*$/, '')
|
|
233
|
+
const matches = aliases.filter((alias) => (server === undefined ? true : fold(alias.claude).startsWith(`${server}__`))).map((alias) => alias.pi)
|
|
234
|
+
if (matches.length === 0) expanded.push(entry)
|
|
235
|
+
else expanded.push(...matches)
|
|
236
|
+
}
|
|
237
|
+
return [...new Set(expanded)]
|
|
238
|
+
}
|
|
239
|
+
|
|
216
240
|
/** Claude's `maxTurns`: a positive integer cap on the subagent's agentic turns.
|
|
217
241
|
* Anything else (0, negative, non-number) is ignored, so the run is uncapped. */
|
|
218
242
|
function parseMaxTurns(raw: unknown): number | undefined {
|
|
@@ -25,13 +25,14 @@ import { Container, Markdown, Spacer, Text } from '@earendil-works/pi-tui'
|
|
|
25
25
|
import { type Static, Type } from 'typebox'
|
|
26
26
|
import { type AgentRunRequest, setAgentRunner } from '../internal/agent-run.js'
|
|
27
27
|
import { claudeConfigDir } from '../internal/config-dir.js'
|
|
28
|
+
import { isMcpToolAliases, MCP_TOOLS_CHANNEL } from '../internal/mcp-alias.js'
|
|
28
29
|
import { capForContext } from '../internal/output-guard.js'
|
|
29
30
|
import { isProjectApproved, isProjectApprovedSilently } from '../internal/project-approval.js'
|
|
30
31
|
import { repoRoot } from '../internal/project-root.js'
|
|
31
32
|
import { SUBAGENT_CHANNEL } from '../internal/subagent-events.js'
|
|
32
33
|
import { autoMemoryEnabled, capIndexForPrompt, INDEX_MAX_BYTES, INDEX_MAX_LINES, memorySettingsFiles, readMemorySettings } from '../memory.js'
|
|
33
34
|
import { skillDirs } from '../skills.js'
|
|
34
|
-
import { type AgentConfig, type AgentMemoryScope, type AgentScope, type AgentSource, discoverAgents, resolveModelAlias, withPreloadedSkills } from './agents.js'
|
|
35
|
+
import { type AgentConfig, type AgentMemoryScope, type AgentScope, type AgentSource, discoverAgents, expandMcpToolPatterns, resolveModelAlias, withPreloadedSkills } from './agents.js'
|
|
35
36
|
import { activeBackgroundRuns, type BackgroundRun, backgroundRun, backgroundStatusText, cancelAllBackgroundRuns, cancelBackgroundRun, MAX_BACKGROUND_RUNS, resumeBackgroundRun, startBackgroundRun } from './background.js'
|
|
36
37
|
import { type DisplayItem, formatToolCall, formatUsageStats, getDisplayItems, getFinalOutput } from './render.js'
|
|
37
38
|
import { type AgentWorktree, cleanupAgentWorktree, createAgentWorktree } from './worktree.js'
|
|
@@ -680,6 +681,15 @@ function childPromptBody(agent: AgentConfig, skillRoots: string[], memorySection
|
|
|
680
681
|
return [prompt, memorySection].filter((part) => part.trim()).join('\n\n')
|
|
681
682
|
}
|
|
682
683
|
|
|
684
|
+
/** The parent's MCP tool aliases, published by the mcp extension on the shared bus;
|
|
685
|
+
* the module-level seam matches setMcpToolCaller's. Children read the same MCP config
|
|
686
|
+
* files, so the parent's roster is the translation table for server-level patterns. */
|
|
687
|
+
let knownMcpAliases: ReadonlyArray<{ pi: string; claude: string }> = []
|
|
688
|
+
|
|
689
|
+
export function setKnownMcpAliases(aliases: ReadonlyArray<{ pi: string; claude: string }>): void {
|
|
690
|
+
knownMcpAliases = aliases
|
|
691
|
+
}
|
|
692
|
+
|
|
683
693
|
/** CLI args shared by foreground and background children, from the agent's config. */
|
|
684
694
|
function agentInvocationArgs(agent: AgentConfig, aliasModel?: string): string[] {
|
|
685
695
|
const args: string[] = ['--mode', 'json', '-p', '--no-session']
|
|
@@ -689,8 +699,11 @@ function agentInvocationArgs(agent: AgentConfig, aliasModel?: string): string[]
|
|
|
689
699
|
const model = agent.model ?? aliasModel
|
|
690
700
|
if (model) args.push('--model', agent.effort ? `${model}:${agent.effort}` : model)
|
|
691
701
|
else if (agent.effort) args.push('--thinking', agent.effort)
|
|
692
|
-
|
|
693
|
-
|
|
702
|
+
// Claude's mcp__<server> / mcp__* patterns expand against the parent's MCP roster;
|
|
703
|
+
// without this a server-level deny removed nothing (fail open) and a server-level
|
|
704
|
+
// grant granted nothing.
|
|
705
|
+
if (agent.tools && agent.tools.length > 0) args.push('--tools', expandMcpToolPatterns(agent.tools, knownMcpAliases).join(','))
|
|
706
|
+
if (agent.disallowedTools && agent.disallowedTools.length > 0) args.push('--exclude-tools', expandMcpToolPatterns(agent.disallowedTools, knownMcpAliases).join(','))
|
|
694
707
|
return args
|
|
695
708
|
}
|
|
696
709
|
|
|
@@ -1280,6 +1293,13 @@ function renderParallelResult(results: SingleResult[], expanded: boolean, theme:
|
|
|
1280
1293
|
}
|
|
1281
1294
|
|
|
1282
1295
|
export default function subagentExtension(pi: ExtensionAPI) {
|
|
1296
|
+
// Claude's mcp__<server> tool patterns translate against the parent's MCP roster,
|
|
1297
|
+
// published by the mcp extension on the shared bus. Optional-chained so a minimal
|
|
1298
|
+
// test stub without an event bus can still register the extension.
|
|
1299
|
+
pi.events?.on(MCP_TOOLS_CHANNEL, (data) => {
|
|
1300
|
+
if (isMcpToolAliases(data)) setKnownMcpAliases(data)
|
|
1301
|
+
})
|
|
1302
|
+
|
|
1283
1303
|
// /tasks resolves these against the registry at print time. background.ts owns the
|
|
1284
1304
|
// run records but does not enumerate them, so the ids started here are remembered;
|
|
1285
1305
|
// a run the registry has since evicted simply drops out of the listing.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-code",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.21",
|
|
4
4
|
"description": "Claude Code experience for the pi coding agent: reads your .claude config (rules, commands, skills, hooks, output styles, MCP servers, agents) and adds todo, checkpoints, memory, web, and subagents",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi",
|