pi-code 1.0.43 → 1.0.45

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.
@@ -158,7 +158,10 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
158
158
 
159
159
  function gitShadow(args: string[]): ReturnType<ExtensionAPI['exec']> {
160
160
  if (!shadowDir || !workTree) return Promise.resolve({ stdout: '', stderr: 'shadow repo not initialized', code: 1, killed: false })
161
- return pi.exec('git', ['--git-dir', shadowDir, '--work-tree', workTree, ...args], { cwd: workTree })
161
+ // A snapshot layer must be byte-faithful: with the host's autocrlf (the
162
+ // Windows default) the shadow checkout would rewrite every restored file's
163
+ // line endings, so conversion is pinned off for every shadow operation.
164
+ return pi.exec('git', ['-c', 'core.autocrlf=false', '--git-dir', shadowDir, '--work-tree', workTree, ...args], { cwd: workTree })
162
165
  }
163
166
 
164
167
  async function ensureShadow(ctx: ExtensionContext): Promise<void> {
@@ -218,6 +218,11 @@ function mergeHooksJson(config: HooksConfig, raw: string, source: string, source
218
218
  /** Each enabled plugin's hooks (hooks/hooks.json, or wherever the manifest points),
219
219
  * with ${CLAUDE_PLUGIN_ROOT}/${CLAUDE_PLUGIN_DATA} substituted before parsing so a
220
220
  * hook can name its bundled scripts by real path. */
221
+ /** The substitution here lands inside raw JSON, so values must arrive escaped:
222
+ * an unescaped Windows root injected \U-style sequences, the parse threw, and
223
+ * every hook the plugin declared silently vanished. */
224
+ const jsonEscape = (value: string): string => JSON.stringify(value).slice(1, -1)
225
+
221
226
  export function loadPluginHooks(config: HooksConfig, plugins: InstalledPlugin[], sources?: Map<HookMatcher, string>): void {
222
227
  for (const plugin of plugins) {
223
228
  const declared = plugin.manifest.hooks
@@ -225,12 +230,12 @@ export function loadPluginHooks(config: HooksConfig, plugins: InstalledPlugin[],
225
230
  // numeric event keys), so it falls through to the default path rather than
226
231
  // silently registering nothing.
227
232
  if (declared !== null && typeof declared === 'object' && !Array.isArray(declared)) {
228
- mergeHooksJson(config, substitutePluginVars(JSON.stringify({ hooks: declared }), plugin), `${plugin.name} (plugin.json)`, sources, `plugin:${plugin.name}`)
233
+ mergeHooksJson(config, substitutePluginVars(JSON.stringify({ hooks: declared }), plugin, jsonEscape), `${plugin.name} (plugin.json)`, sources, `plugin:${plugin.name}`)
229
234
  continue
230
235
  }
231
236
  const file = path.resolve(plugin.root, typeof declared === 'string' ? declared : path.join('hooks', 'hooks.json'))
232
237
  try {
233
- mergeHooksJson(config, substitutePluginVars(fs.readFileSync(file, 'utf-8'), plugin), file, sources, `plugin:${plugin.name}`)
238
+ mergeHooksJson(config, substitutePluginVars(fs.readFileSync(file, 'utf-8'), plugin, jsonEscape), file, sources, `plugin:${plugin.name}`)
234
239
  } catch {
235
240
  // a plugin without hooks contributes nothing
236
241
  }
@@ -169,6 +169,11 @@ export function globToRegExpSource(pattern: string): string {
169
169
 
170
170
  /** A rule resolved to an absolute glob per its anchor form. */
171
171
  function resolveRule(rule: string, anchors: PathAnchors): string {
172
+ // A ${CLAUDE_*}-substituted rule arrives already absolute in the platform's
173
+ // own spelling; on Windows that starts with a drive letter (bare, or behind
174
+ // the / that substitutePathRule prefixes to mark absoluteness), which the
175
+ // POSIX anchor forms below would misread and bury under an anchor.
176
+ if (/^\/?[A-Za-z]:[\\/]/.test(rule)) return rule.replace(/^\//, '')
172
177
  if (rule.startsWith('//')) return rule.slice(1)
173
178
  if (rule.startsWith('~/')) return path.join(anchors.home, rule.slice(2))
174
179
  if (rule.startsWith('/')) return path.join(anchors.projectRoot, rule.slice(1))
@@ -230,14 +235,24 @@ export function matchesCompiledGlobs(relPath: string, globs: CompiledGlob[]): bo
230
235
 
231
236
  /** Whether the accessed file matches at least one rule. No rules means no match:
232
237
  * a granted-but-scoped tool with an empty scope set stays blocked, never open. */
238
+ /** Both comparison sides in posix form. On Windows, resolve stamps the drive on
239
+ * the target while join-built rules stay drive-less, and backslash separators
240
+ * collide with glob syntax, so unnormalized rules could never match. */
241
+ const toPosix = (target: string): string => {
242
+ const withSlashes = target.split(path.sep).join('/')
243
+ // The drive letter goes (case-insensitively) so rule and target agree even
244
+ // when only one side carries C:.
245
+ return withSlashes.replace(/^\/?[A-Za-z]:\//, '/')
246
+ }
247
+
233
248
  export function matchesPathRules(filePath: string, rules: string[], anchors: PathAnchors): boolean {
234
- const target = path.resolve(anchors.cwd, filePath)
249
+ const target = toPosix(path.resolve(anchors.cwd, filePath))
235
250
  return rules.some((rule) => {
236
251
  const trimmed = rule.trim()
237
252
  // An empty specifier (`Read()`) matches nothing, so the tool stays blocked
238
253
  // rather than falling open, mirroring `Bash()`.
239
254
  if (trimmed === '') return false
240
- const resolved = resolveRule(trimmed, anchors)
255
+ const resolved = toPosix(resolveRule(trimmed, anchors))
241
256
  return new RegExp(`^${globToRegExpSource(resolved)}$`).test(target)
242
257
  })
243
258
  }
@@ -239,10 +239,13 @@ function resolvePlugin(home: string, cacheDir: string, marketplace: string, plug
239
239
  }
240
240
 
241
241
  /** The two plugin path variables, textually substituted into plugin-shipped
242
- * config (hook commands, MCP server definitions, command bodies). */
243
- export function substitutePluginVars(value: string, plugin: InstalledPlugin): string {
242
+ * config (hook commands, MCP server definitions, command bodies). A caller
243
+ * substituting into text that is still raw JSON must pass an escapeValue that
244
+ * JSON-escapes: a Windows root (C:\Users\...) inserted verbatim injects invalid
245
+ * escape sequences and the subsequent parse throws. */
246
+ export function substitutePluginVars(value: string, plugin: InstalledPlugin, escapeValue: (substituted: string) => string = (substituted) => substituted): string {
244
247
  return value
245
- .replaceAll('${CLAUDE_PLUGIN_ROOT}', plugin.root)
246
- .replaceAll('${CLAUDE_PLUGIN_DATA}', plugin.dataDir)
247
- .replace(/\$\{user_config\.(\w+)\}/g, (_, key: string) => plugin.userConfig?.[key] ?? '')
248
+ .replaceAll('${CLAUDE_PLUGIN_ROOT}', escapeValue(plugin.root))
249
+ .replaceAll('${CLAUDE_PLUGIN_DATA}', escapeValue(plugin.dataDir))
250
+ .replace(/\$\{user_config\.(\w+)\}/g, (_, key: string) => escapeValue(plugin.userConfig?.[key] ?? ''))
248
251
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "1.0.43",
3
+ "version": "1.0.45",
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",
@@ -37,8 +37,8 @@
37
37
  ]
38
38
  },
39
39
  "scripts": {
40
- "biome": "npx @biomejs/biome check",
41
- "biome:fix": "npx @biomejs/biome check --write .",
40
+ "biome": "npx --no-install @biomejs/biome check",
41
+ "biome:fix": "npx --no-install @biomejs/biome check --write .",
42
42
  "type:check": "tsc --noEmit",
43
43
  "test": "vitest run --coverage",
44
44
  "check": "npm run biome && npm run type:check && npm run test",