pi-code 1.0.5 → 1.0.7

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.
@@ -15,7 +15,7 @@ export const INSTRUCTIONS_CHANNEL = 'pi-code:instructions'
15
15
  /** Claude's memory_type vocabulary for InstructionsLoaded payloads. */
16
16
  export type InstructionMemoryType = 'User' | 'Project' | 'Local' | 'Managed'
17
17
 
18
- const MEMORY_TYPES: readonly string[] = ['User', 'Project', 'Local', 'Managed']
18
+ const MEMORY_TYPES: ReadonlySet<string> = new Set(['User', 'Project', 'Local', 'Managed'])
19
19
 
20
20
  export interface InstructionLoadEvent {
21
21
  /** Absolute path of the instruction file that entered context. */
@@ -35,7 +35,7 @@ export function isInstructionLoadEvent(data: unknown): data is InstructionLoadEv
35
35
  const event = data as InstructionLoadEvent | null
36
36
  if (event === null || typeof event !== 'object') return false
37
37
  if (typeof event.file_path !== 'string' || typeof event.load_reason !== 'string') return false
38
- if (!MEMORY_TYPES.includes(event.memory_type)) return false
38
+ if (!MEMORY_TYPES.has(event.memory_type)) return false
39
39
  if (event.globs !== undefined && !(Array.isArray(event.globs) && event.globs.every((glob) => typeof glob === 'string'))) return false
40
40
  if (event.trigger_file_path !== undefined && typeof event.trigger_file_path !== 'string') return false
41
41
  if (event.parent_file_path !== undefined && typeof event.parent_file_path !== 'string') return false
@@ -15,7 +15,7 @@ import * as fs from 'node:fs'
15
15
  export function managedSettingsPath(platform: NodeJS.Platform = process.platform): string {
16
16
  if (platform === 'darwin') return '/Library/Application Support/ClaudeCode/managed-settings.json'
17
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'
18
+ if (platform === 'win32') return String.raw`C:\Program Files\ClaudeCode\managed-settings.json`
19
19
  return '/etc/claude-code/managed-settings.json'
20
20
  }
21
21
 
@@ -32,19 +32,28 @@ interface StoredAuth {
32
32
  * the store directory and distinct names from colliding after sanitization. */
33
33
  function storeFileFor(serverName: string): string {
34
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'
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'
40
44
  return path.join(getAgentDir(), 'mcp-oauth', `${safe}-${digest}.json`)
41
45
  }
42
46
 
43
47
  export class FileOAuthProvider implements OAuthClientProvider {
44
48
  private readonly storePath: string
45
- private data: StoredAuth
49
+ private readonly data: StoredAuth
46
50
  private port = 0
47
51
  private readonly onRedirect: (authorizationUrl: URL) => void
52
+ // A fresh random CSRF token per login attempt. The SDK puts it in the authorization
53
+ // URL's `state` param, the server echoes it back on the redirect, and waitForAuthCode
54
+ // rejects any callback that does not carry it, so another local process or an open web
55
+ // page cannot inject an authorization code into this login (RFC 8252 8.9).
56
+ private readonly loginState = crypto.randomBytes(16).toString('hex')
48
57
 
49
58
  constructor(serverName: string, onRedirect: (authorizationUrl: URL) => void) {
50
59
  this.storePath = storeFileFor(serverName)
@@ -113,6 +122,12 @@ export class FileOAuthProvider implements OAuthClientProvider {
113
122
  return this.data.tokens !== undefined
114
123
  }
115
124
 
125
+ /** The CSRF token the SDK adds to the authorization URL as `state`; waitForAuthCode
126
+ * verifies the redirect echoes exactly this value. */
127
+ state(): string {
128
+ return this.loginState
129
+ }
130
+
116
131
  redirectToAuthorization(authorizationUrl: URL): void {
117
132
  this.onRedirect(authorizationUrl)
118
133
  }
@@ -143,11 +158,30 @@ export async function startCallbackServer(preferredPort?: number): Promise<{ ser
143
158
  return { server, port: (server.address() as { port: number }).port }
144
159
  }
145
160
 
146
- export function waitForAuthCode(server: http.Server, timeoutMs: number): Promise<string> {
161
+ export function waitForAuthCode(server: http.Server, timeoutMs: number, expectedState?: string): Promise<string> {
147
162
  return new Promise((resolve, reject) => {
148
163
  const timer = setTimeout(() => reject(new Error(`authorization timed out after ${timeoutMs}ms`)), timeoutMs)
164
+ // Do not let the pending timer keep the process alive on its own: if the login is
165
+ // abandoned or resolved out of band, the event loop can still drain.
166
+ timer.unref?.()
149
167
  server.on('request', (request, response) => {
150
168
  const url = new URL(request.url ?? '/', 'http://127.0.0.1')
169
+ // Only the redirect path settles the login. A stray request (a favicon fetch, a
170
+ // local port scan, or a forged redirect from another process or an open web page)
171
+ // is answered but ignored, so it can neither inject a code nor abort the login by
172
+ // rejecting the promise (a repeatable DoS on a stable, guessable loopback port).
173
+ if (url.pathname !== '/callback') {
174
+ response.writeHead(404, { 'content-type': 'text/plain' })
175
+ response.end('not found')
176
+ return
177
+ }
178
+ // The CSRF check: a callback that does not echo this login's state is rejected
179
+ // without settling, so an attacker who cannot read the state cannot complete it.
180
+ if (expectedState !== undefined && url.searchParams.get('state') !== expectedState) {
181
+ response.writeHead(400, { 'content-type': 'text/plain' })
182
+ response.end('state mismatch')
183
+ return
184
+ }
151
185
  const code = url.searchParams.get('code')
152
186
  const error = url.searchParams.get('error')
153
187
  response.writeHead(200, { 'content-type': 'text/html' })
@@ -161,7 +195,9 @@ export function waitForAuthCode(server: http.Server, timeoutMs: number): Promise
161
195
 
162
196
  /** Best-effort browser launch; the caller also surfaces the URL as text. */
163
197
  export function openBrowser(url: string): void {
164
- const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open'
198
+ let command = 'xdg-open'
199
+ if (process.platform === 'darwin') command = 'open'
200
+ else if (process.platform === 'win32') command = 'cmd'
165
201
  const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url]
166
202
  try {
167
203
  spawn(command, args, { stdio: 'ignore', detached: true }).unref()
@@ -21,8 +21,66 @@ export interface PathAnchors {
21
21
 
22
22
  const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`)
23
23
 
24
- /** One gitignore-style pattern as an anchored regular expression source. */
25
- export function globToRegExpSource(pattern: string): string {
24
+ /** Cap on brace-expanded alternatives per pattern, mirroring Claude's ~1000
25
+ * budget; an over-budget pattern is used unexpanded. */
26
+ const BRACE_EXPANSION_LIMIT = 1000
27
+
28
+ interface BraceGroup {
29
+ start: number
30
+ end: number
31
+ options: string[]
32
+ }
33
+
34
+ /** The `{...}` group opening at `open`, or null when it is unmatched or carries no
35
+ * top-level comma (literal braces). Options are split on commas at the group's own
36
+ * depth so a nested group stays inside one option. */
37
+ function parseBraceGroup(pattern: string, open: number): BraceGroup | null {
38
+ let depth = 1
39
+ let optionStart = open + 1
40
+ const options: string[] = []
41
+ for (let i = open + 1; i < pattern.length; i += 1) {
42
+ const ch = pattern[i]
43
+ if (ch === '{') depth += 1
44
+ else if (ch === ',' && depth === 1) {
45
+ options.push(pattern.slice(optionStart, i))
46
+ optionStart = i + 1
47
+ } else if (ch === '}' && --depth === 0) {
48
+ if (options.length === 0) return null // no top-level comma: literal braces
49
+ options.push(pattern.slice(optionStart, i))
50
+ return { start: open, end: i, options }
51
+ }
52
+ }
53
+ return null
54
+ }
55
+
56
+ /** The first expandable `{...}` group. A comma-less or unmatched `{` is skipped as
57
+ * literal, so the scan can still find an expandable group nested inside it. */
58
+ function findBraceGroup(pattern: string): BraceGroup | null {
59
+ for (let open = pattern.indexOf('{'); open !== -1; open = pattern.indexOf('{', open + 1)) {
60
+ const group = parseBraceGroup(pattern, open)
61
+ if (group) return group
62
+ }
63
+ return null
64
+ }
65
+
66
+ /** Bash-style brace expansion of one pattern into its alternatives: each group
67
+ * multiplies out (Cartesian across groups, nested groups recurse). Returns null
68
+ * when the expansion would exceed the budget. */
69
+ function expandBraces(pattern: string): string[] | null {
70
+ const group = findBraceGroup(pattern)
71
+ if (group === null) return [pattern]
72
+ const expanded: string[] = []
73
+ for (const option of group.options) {
74
+ const branch = expandBraces(pattern.slice(0, group.start) + option + pattern.slice(group.end + 1))
75
+ if (branch === null) return null
76
+ expanded.push(...branch)
77
+ if (expanded.length > BRACE_EXPANSION_LIMIT) return null
78
+ }
79
+ return expanded
80
+ }
81
+
82
+ /** One glob pattern, braces already expanded, as a regular expression source. */
83
+ function translateGlob(pattern: string): string {
26
84
  let out = ''
27
85
  let i = 0
28
86
  while (i < pattern.length) {
@@ -54,6 +112,15 @@ export function globToRegExpSource(pattern: string): string {
54
112
  return out
55
113
  }
56
114
 
115
+ /** One gitignore-style pattern as an anchored regular expression source. Brace
116
+ * groups (`{ts,tsx}`, nested, Cartesian across groups) expand into ORed
117
+ * alternatives; an over-budget expansion falls back to the literal pattern. */
118
+ export function globToRegExpSource(pattern: string): string {
119
+ const alternatives = expandBraces(pattern) ?? [pattern]
120
+ if (alternatives.length === 1) return translateGlob(alternatives[0])
121
+ return `(?:${alternatives.map(translateGlob).join('|')})`
122
+ }
123
+
57
124
  /** A rule resolved to an absolute glob per its anchor form. */
58
125
  function resolveRule(rule: string, anchors: PathAnchors): string {
59
126
  if (rule.startsWith('//')) return rule.slice(1)
@@ -64,6 +64,15 @@ function enabledMap(settingsFiles: string[]): Record<string, boolean> {
64
64
  return merged
65
65
  }
66
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
+
67
76
  /** `pluginConfigs[id].options` per plugin id, later files winning per key. */
68
77
  function pluginConfigsMap(settingsFiles: string[]): Record<string, Record<string, string>> {
69
78
  const merged: Record<string, Record<string, string>> = {}
@@ -74,10 +83,7 @@ function pluginConfigsMap(settingsFiles: string[]): Record<string, Record<string
74
83
  const options = (config as Record<string, unknown>)?.options
75
84
  if (options === null || typeof options !== 'object') continue
76
85
  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
- }
86
+ mergeOptionValues(values, options)
81
87
  merged[id] = values
82
88
  }
83
89
  }
@@ -99,27 +105,34 @@ export function installedPlugins(home: string, extraSettingsFiles: string[] = []
99
105
  const plugins: InstalledPlugin[] = []
100
106
  for (const marketplace of listDirs(cacheDir)) {
101
107
  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 } : {}) })
108
+ const plugin = resolvePlugin(home, cacheDir, marketplace, pluginDir, enabled, configs)
109
+ if (plugin) plugins.push(plugin)
113
110
  }
114
111
  }
115
112
  return plugins
116
113
  }
117
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
+
118
131
  /** The two plugin path variables, textually substituted into plugin-shipped
119
132
  * config (hook commands, MCP server definitions, command bodies). */
120
133
  export function substitutePluginVars(value: string, plugin: InstalledPlugin): string {
121
134
  return value
122
135
  .replaceAll('${CLAUDE_PLUGIN_ROOT}', plugin.root)
123
136
  .replaceAll('${CLAUDE_PLUGIN_DATA}', plugin.dataDir)
124
- .replace(/\$\{user_config\.([A-Za-z0-9_]+)\}/g, (_, key: string) => plugin.userConfig?.[key] ?? '')
137
+ .replace(/\$\{user_config\.(\w+)\}/g, (_, key: string) => plugin.userConfig?.[key] ?? '')
125
138
  }
@@ -25,51 +25,74 @@ function fenceLength(lineStart: string, marker: string): number {
25
25
  return length
26
26
  }
27
27
 
28
+ // CommonMark closes a fenced block only with a fence of the same character that
29
+ // is at least as long as the opener, so both are tracked: a shorter same-char
30
+ // fence line (the classic 3-backtick block quoted inside a 4-backtick one) is
31
+ // content, not a closer.
32
+ interface Fence {
33
+ marker: string
34
+ length: number
35
+ }
36
+
37
+ /** The fence state after a line, plus whether the line is fenced code (opener,
38
+ * body, or closer) and so emitted verbatim rather than scanned for comments. */
39
+ function stepFence(fence: Fence | null, trimmed: string, marker: string | null): { fence: Fence | null; fenced: boolean } {
40
+ if (marker !== null && fence === null) {
41
+ return { fence: { marker, length: fenceLength(trimmed, marker) }, fenced: true }
42
+ }
43
+ if (fence !== null) {
44
+ const closes = marker === fence.marker && fenceLength(trimmed, marker) >= fence.length
45
+ return { fence: closes ? null : fence, fenced: true } // closer included: comments are content, not maintainer notes
46
+ }
47
+ return { fence, fenced: false }
48
+ }
49
+
50
+ /** Advance a line while inside an open multi-line comment. Emits any content that
51
+ * trails the closer onto its own line; returns whether the comment stays open. */
52
+ function continueOpenComment(out: string[], line: string): boolean {
53
+ const close = line.indexOf('-->')
54
+ if (close === -1) return true // still inside the comment: the line goes with it
55
+ const rest = line.slice(close + 3)
56
+ // Content trailing the closer keeps its line; a bare closer line is dropped.
57
+ if (rest.trim().length > 0) out.push(rest.trimStart())
58
+ return false
59
+ }
60
+
61
+ /** Fold line-starting HTML comments off `trimmed`; several may share one line.
62
+ * `allComment` means the line held nothing but comments and whitespace, so it is
63
+ * dropped; `opensBlock` means the last comment opened a multi-line block. */
64
+ function consumeLineComments(trimmed: string): { allComment: boolean; opensBlock: boolean } {
65
+ let rest = trimmed
66
+ let sawComment = false
67
+ while (rest.startsWith('<!--')) {
68
+ sawComment = true
69
+ const close = rest.indexOf('-->')
70
+ if (close === -1) return { allComment: true, opensBlock: true } // opens a multi-line comment
71
+ rest = rest.slice(close + 3).trimStart()
72
+ }
73
+ return { allComment: sawComment && rest.length === 0, opensBlock: false }
74
+ }
75
+
28
76
  /** Remove whole-line HTML comments, keeping fenced code and inline comments. */
29
77
  export function stripBlockComments(text: string): string {
30
78
  const out: string[] = []
31
- // CommonMark closes a fenced block only with a fence of the same character
32
- // that is at least as long as the opener, so both are tracked: a shorter
33
- // same-char fence line (the classic 3-backtick block quoted inside a
34
- // 4-backtick one) is content, not a closer.
35
- let fence: { marker: string; length: number } | null = null
79
+ let fence: Fence | null = null
36
80
  let inComment = false
37
81
  for (const line of text.split('\n')) {
38
82
  if (inComment) {
39
- const close = line.indexOf('-->')
40
- if (close === -1) continue // still inside the comment: the line goes with it
41
- inComment = false
42
- const rest = line.slice(close + 3)
43
- // Content trailing the closer keeps its line; a bare closer line is dropped.
44
- if (rest.trim().length > 0) out.push(rest.trimStart())
83
+ inComment = continueOpenComment(out, line)
45
84
  continue
46
85
  }
47
86
  const trimmed = line.trimStart()
48
- const marker = fenceMarker(trimmed)
49
- if (marker !== null && fence === null) {
50
- fence = { marker, length: fenceLength(trimmed, marker) }
87
+ const step = stepFence(fence, trimmed, fenceMarker(trimmed))
88
+ fence = step.fence
89
+ if (step.fenced) {
51
90
  out.push(line)
52
91
  continue
53
92
  }
54
- if (fence !== null) {
55
- if (marker === fence.marker && fenceLength(trimmed, marker) >= fence.length) fence = null
56
- out.push(line) // fenced code, closer included: comments are content, not maintainer notes
57
- continue
58
- }
59
- // Consume comments anchored at the line start; several may share one line.
60
- let rest = trimmed
61
- let sawComment = false
62
- while (rest.startsWith('<!--')) {
63
- sawComment = true
64
- const close = rest.indexOf('-->')
65
- if (close === -1) {
66
- inComment = true // opens a multi-line comment
67
- rest = ''
68
- break
69
- }
70
- rest = rest.slice(close + 3).trimStart()
71
- }
72
- if (sawComment && rest.length === 0) continue // the whole line was comment
93
+ const { allComment, opensBlock } = consumeLineComments(trimmed)
94
+ if (opensBlock) inComment = true
95
+ if (allComment) continue // the whole line was comment
73
96
  // No comment, or a line-starting comment followed by content: inline, verbatim.
74
97
  out.push(line)
75
98
  }