pi-code 1.0.21 → 1.0.23

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.
@@ -52,7 +52,7 @@ import { capForContext } from './internal/output-guard.js'
52
52
  import { matchesPathRules } from './internal/path-rules.js'
53
53
  import { type InstalledPlugin, installedPlugins } from './internal/plugins.js'
54
54
  import { isProjectApproved } from './internal/project-approval.js'
55
- import { findNearestDir, repoRoot } from './internal/project-root.js'
55
+ import { ancestorDirs, repoRoot } from './internal/project-root.js'
56
56
  import { claudeSettingsChain } from './internal/settings-chain.js'
57
57
  import { createTurnOverride } from './internal/turn-override.js'
58
58
 
@@ -120,7 +120,10 @@ function isDirectory(target: string): boolean {
120
120
  * the approval walk) and is included only for approved projects. */
121
121
  export function commandDirs(cwd: string, home: string, trusted: boolean): string[] {
122
122
  const candidates = [path.join(claudeConfigDir(home), 'commands')]
123
- if (trusted) candidates.push(findNearestDir(cwd, path.join('.claude', 'commands')) ?? path.join(cwd, '.claude', 'commands'))
123
+ // Claude scans every .claude/commands between cwd and the repository root, the
124
+ // nearest winning a name clash: collectCommands lets later directories win, so
125
+ // the project list goes root-first with the nearest last.
126
+ if (trusted) candidates.push(...ancestorDirs(cwd, path.join('.claude', 'commands')).reverse())
124
127
  const dirs: string[] = []
125
128
  for (const dir of candidates) {
126
129
  if (!dirs.includes(dir) && isDirectory(dir)) dirs.push(dir)
@@ -13,19 +13,21 @@
13
13
  * approval-gated on purpose: a checked-out repository's env can redirect providers
14
14
  * (ANTHROPIC_BASE_URL and friends), so an untrusted repo must not reach process.env.
15
15
  *
16
- * Precedence is per key, managed > user > project, matching Claude's merge: a scope
17
- * only supplies keys it names and never wipes another scope's keys. Values must be
18
- * strings; a number or boolean is coerced via String, anything else is skipped.
16
+ * Precedence is per key, managed > project (settings.local.json overlaying
17
+ * settings.json inside the project scope) > user, matching Claude's settings
18
+ * precedence: a scope only supplies keys it names and never wipes another scope's
19
+ * keys. Values must be strings; a number or boolean is coerced via String,
20
+ * anything else is skipped.
19
21
  *
20
- * A variable already present in the real environment that this module did not set is
21
- * left untouched for the user and project scopes: a shell `export` outranks them. The
22
- * managed scope is the exception: an org policy env must win over an ambient export, so
23
- * a managed key overwrites a preexisting shell value. The keys this module sets are
24
- * recorded so a later refresh can update them without clobbering unrelated env, and a
25
- * key an earlier apply set that the current merge no longer defines is unset (deleted
26
- * from process.env), so an approved project's env cannot leak into a later session or
27
- * project that does not define it. A managed overwrite of a shell var is owned like any
28
- * other set key; its original shell value cannot be restored, so on unset it is deleted.
22
+ * A settings value replaces a value inherited from the shell, as Claude documents
23
+ * ("Claude Code writes each env entry into the process environment, replacing the
24
+ * value inherited from the shell"), and an empty string is the documented way to
25
+ * override an export that cannot be unset. The original value of each key is
26
+ * recorded so a later apply that no longer defines the key restores the shell's
27
+ * value (or deletes a key the shell never had), so an approved project's env
28
+ * cannot leak into a later session or project that does not define it. The keys a
29
+ * repository must not control are dropped from the project scope before any of
30
+ * this (see sanitizeProjectEnv).
29
31
  *
30
32
  * Docs: https://code.claude.com/docs/en/settings.md
31
33
  */
@@ -57,35 +59,31 @@ export function envFromSettings(settings: unknown): Record<string, string> {
57
59
  return out
58
60
  }
59
61
 
60
- /** Merge the three env scopes with Claude's per-key precedence managed > user >
61
- * project: lower scopes are laid down first and higher ones overlay, so each key
62
- * takes its highest-precedence value and no scope wipes another's keys. */
62
+ /** Merge the three env scopes with Claude's per-key settings precedence managed >
63
+ * project > user: lower scopes are laid down first and higher ones overlay, so each
64
+ * key takes its highest-precedence value and no scope wipes another's keys. */
63
65
  export function mergeEnvScopes(managed: Record<string, string>, user: Record<string, string>, project: Record<string, string>): Record<string, string> {
64
- return { ...project, ...user, ...managed }
66
+ return { ...user, ...project, ...managed }
65
67
  }
66
68
 
67
- /** Assign the merged env into `env`, recording each key set into `owned`. A key already
68
- * present that this module did not set (a shell export) is left untouched for user and
69
- * project keys; a `managedKeys` entry overwrites it (an org policy outranks the shell).
70
- * A key this module set before is updated. A previously-owned key that the current
71
- * `merged` no longer defines is unset (deleted from `env` and `owned`), so an approved
72
- * project's env cannot leak into a later apply that does not define it. */
73
- export function applyEnvSettings(merged: Record<string, string>, env: NodeJS.ProcessEnv, owned: Set<string>, managedKeys: ReadonlySet<string> = new Set()): void {
74
- // Unset any key an earlier apply set that the current merge dropped. Iterate a copy
75
- // since `owned` is mutated. A shell export this module never owned is left in place.
76
- for (const key of Array.from(owned)) {
77
- if (!(key in merged)) {
78
- delete env[key]
79
- owned.delete(key)
80
- }
69
+ /** Assign the merged env into `env`. Every settings value applies, replacing a
70
+ * shell-inherited value, as Claude documents; an empty string is the documented
71
+ * override for an export that cannot be unset. `owned` records each key's original
72
+ * value at first ownership, so a later apply that drops the key restores the
73
+ * shell's value (or deletes a key the shell never had) rather than leaking a stale
74
+ * setting into the rest of the process. */
75
+ export function applyEnvSettings(merged: Record<string, string>, env: NodeJS.ProcessEnv, owned: Map<string, string | undefined>): void {
76
+ // Restore any key an earlier apply set that the current merge dropped. Iterate a
77
+ // copy since `owned` is mutated.
78
+ for (const [key, original] of Array.from(owned.entries())) {
79
+ if (key in merged) continue
80
+ if (original === undefined) delete env[key]
81
+ else env[key] = original
82
+ owned.delete(key)
81
83
  }
82
84
  for (const [key, value] of Object.entries(merged)) {
83
- // A shell export outranks user/project (skip), but a managed key outranks even the
84
- // shell. Once owned, updates always apply. A managed overwrite is owned like any
85
- // set key: its shell value is gone and cannot be restored, so on unset it is deleted.
86
- if (key in env && !owned.has(key) && !managedKeys.has(key)) continue
85
+ if (!owned.has(key)) owned.set(key, env[key])
87
86
  env[key] = value
88
- owned.add(key)
89
87
  }
90
88
  }
91
89
 
@@ -148,11 +146,10 @@ function projectEnv(cwd: string): Record<string, string> {
148
146
  }
149
147
 
150
148
  export default function envSettingsExtension(pi: ExtensionAPI) {
151
- const owned = new Set<string>()
149
+ const owned = new Map<string, string | undefined>()
152
150
 
153
151
  const apply = (home: string, project: Record<string, string>): void => {
154
- const managed = envFromSettings(readManagedSettings())
155
- applyEnvSettings(mergeEnvScopes(managed, userEnv(home), project), process.env, owned, new Set(Object.keys(managed)))
152
+ applyEnvSettings(mergeEnvScopes(envFromSettings(readManagedSettings()), userEnv(home), project), process.env, owned)
156
153
  }
157
154
 
158
155
  // Factory time: managed + user only. Approval needs the session ctx, so the project
@@ -60,6 +60,24 @@ export function findNearestFile(cwd: string, relative: string): string | null {
60
60
  return findNearest(cwd, relative, false)
61
61
  }
62
62
 
63
+ /** Every `relative` directory between cwd and the repository root, nearest first,
64
+ * matching Claude's "every .claude/<kind> between the working directory and the
65
+ * repository root" discovery where the entry closest to cwd wins a name clash. */
66
+ export function ancestorDirs(cwd: string, relative: string): string[] {
67
+ const boundary = repoRoot(cwd) ?? cwd
68
+ const found: string[] = []
69
+ let currentDir = cwd
70
+ while (true) {
71
+ const candidate = path.join(currentDir, relative)
72
+ if (statOf(candidate)?.isDirectory()) found.push(candidate)
73
+ if (currentDir === boundary) break
74
+ const parentDir = path.dirname(currentDir)
75
+ if (parentDir === currentDir) break
76
+ currentDir = parentDir
77
+ }
78
+ return found
79
+ }
80
+
63
81
  /** Every `relative` file between the repository root and cwd, ordered root first,
64
82
  * matching Claude's root-down ordering for hierarchy-loaded context. */
65
83
  export function ancestorFiles(cwd: string, relative: string): string[] {
@@ -27,7 +27,7 @@ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
27
27
  import { claudeConfigDir } from './internal/config-dir.js'
28
28
  import { installedPlugins } from './internal/plugins.js'
29
29
  import { isProjectApproved } from './internal/project-approval.js'
30
- import { findNearestDir, findNearestFile } from './internal/project-root.js'
30
+ import { ancestorDirs, findNearestDir, findNearestFile } from './internal/project-root.js'
31
31
  import { claudeSettingsChain } from './internal/settings-chain.js'
32
32
 
33
33
  export interface OutputStyle {
@@ -85,7 +85,10 @@ function isDirectory(target: string): boolean {
85
85
  */
86
86
  export function styleDirs(cwd: string, home: string, trusted: boolean): string[] {
87
87
  const dirs = [path.join(claudeConfigDir(home), 'output-styles')]
88
- if (trusted) dirs.push(findNearestDir(cwd, path.join('.claude', 'output-styles')) ?? path.join(cwd, '.claude', 'output-styles'))
88
+ // Claude loads every .claude/output-styles between cwd and the repository root,
89
+ // using the one closest to cwd for a name clash: styleForName is first-match,
90
+ // so the nearest directory goes first.
91
+ if (trusted) dirs.push(...ancestorDirs(cwd, path.join('.claude', 'output-styles')))
89
92
  return dirs.filter((dir) => isDirectory(dir))
90
93
  }
91
94
 
@@ -29,7 +29,7 @@ import { parseCommandFile } from './internal/command-file.js'
29
29
  import { claudeConfigDir } from './internal/config-dir.js'
30
30
  import { installedPlugins } from './internal/plugins.js'
31
31
  import { isProjectApprovedSilently } from './internal/project-approval.js'
32
- import { findNearestDir } from './internal/project-root.js'
32
+ import { ancestorDirs } from './internal/project-root.js'
33
33
 
34
34
  function isDirectory(target: string): boolean {
35
35
  try {
@@ -53,7 +53,10 @@ export function skillDirs(cwd: string, home: string, trusted: boolean): string[]
53
53
  const dirs = Array.isArray(declared) ? declared : [typeof declared === 'string' ? declared : 'skills']
54
54
  candidates.push(...dirs.map((dir) => path.resolve(plugin.root, String(dir))))
55
55
  }
56
- if (trusted) candidates.push(findNearestDir(cwd, path.join('.claude', 'skills')) ?? path.join(cwd, '.claude', 'skills'))
56
+ // Claude loads skills from every .claude/skills between cwd and the repository
57
+ // root; the list goes nearest-first so findClaudeSkill's first match is the
58
+ // closest definition (pi's loader receives the same order).
59
+ if (trusted) candidates.push(...ancestorDirs(cwd, path.join('.claude', 'skills')))
57
60
  const dirs: string[] = []
58
61
  for (const dir of candidates) {
59
62
  if (!dirs.includes(dir) && isDirectory(dir)) dirs.push(dir)
@@ -13,7 +13,7 @@ import { getAgentDir, parseFrontmatter, stripFrontmatter } from '@earendil-works
13
13
  import { parseToolGrants } from '../internal/command-file.js'
14
14
  import { claudeConfigDir } from '../internal/config-dir.js'
15
15
  import { installedPlugins } from '../internal/plugins.js'
16
- import { findNearestDir } from '../internal/project-root.js'
16
+ import { ancestorDirs, findNearestDir } from '../internal/project-root.js'
17
17
 
18
18
  /**
19
19
  * `tools:` may be a comma-separated string (the Claude Code format) or a YAML block
@@ -337,13 +337,16 @@ export function discoverAgents(cwd: string, scope: AgentScope): AgentDiscoveryRe
337
337
  const userDir = path.join(getAgentDir(), 'agents')
338
338
  const claudeUserDir = path.join(claudeConfigDir(os.homedir()), 'agents')
339
339
  const projectPiDir = findNearestDir(cwd, path.join('.pi', 'agents'))
340
- const projectClaudeDir = findNearestDir(cwd, path.join('.claude', 'agents'))
340
+ // Claude scans every .claude/agents between cwd and the repository root, the
341
+ // definition closest to cwd winning a name clash; root-first load order makes
342
+ // the nearer directory's entry overwrite in the map below.
343
+ const projectClaudeDirs = ancestorDirs(cwd, path.join('.claude', 'agents')).reverse()
341
344
 
342
345
  // Plugins load after builtins and before the user's own dirs, so a user agent
343
346
  // wins a name clash with a plugin's, and ~/.pi/agent/agents wins over ~/.claude.
344
347
  const userAgents = scope === 'project' ? [] : [...loadAgentsFromDir(BUILTIN_AGENTS_DIR, 'builtin'), ...pluginAgentDirs(os.homedir()).flatMap((dir) => loadAgentsFromDir(dir, 'plugin')), ...loadAgentsFromDir(claudeUserDir, 'user'), ...loadAgentsFromDir(userDir, 'user')]
345
348
  // project .claude/agents loads first so project .pi/agents wins on name conflicts
346
- const projectAgents = scope === 'user' ? [] : [...(projectClaudeDir ? loadAgentsFromDir(projectClaudeDir, 'project') : []), ...(projectPiDir ? loadAgentsFromDir(projectPiDir, 'project') : [])]
349
+ const projectAgents = scope === 'user' ? [] : [...projectClaudeDirs.flatMap((dir) => loadAgentsFromDir(dir, 'project')), ...(projectPiDir ? loadAgentsFromDir(projectPiDir, 'project') : [])]
347
350
 
348
351
  const agentMap = buildAgentMap(userAgents, projectAgents, scope)
349
352
  return { agents: Array.from(agentMap.values()), projectAgentsDir: projectPiDir }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "1.0.21",
3
+ "version": "1.0.23",
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",