pi-code 1.0.5 → 1.0.6
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/extensions/commands.ts +303 -85
- package/extensions/context-imports.ts +162 -91
- package/extensions/hooks.ts +139 -27
- package/extensions/internal/command-file.ts +91 -48
- package/extensions/internal/html-markdown.ts +11 -1
- package/extensions/internal/instruction-events.ts +2 -2
- package/extensions/internal/managed-settings.ts +1 -1
- package/extensions/internal/mcp-oauth.ts +13 -7
- package/extensions/internal/plugins.ts +29 -16
- package/extensions/internal/strip-comments.ts +56 -33
- package/extensions/mcp.ts +371 -66
- package/extensions/memory.ts +29 -19
- package/extensions/notify.ts +1 -2
- package/extensions/status-line.ts +8 -3
- package/extensions/subagent/index.ts +119 -5
- package/extensions/web.ts +39 -26
- package/package.json +1 -1
|
@@ -16,6 +16,9 @@ import { parseFrontmatter } from '@earendil-works/pi-coding-agent'
|
|
|
16
16
|
|
|
17
17
|
import { splitSegments } from './shell-split.js'
|
|
18
18
|
|
|
19
|
+
/** The pi file tools a Claude path rule can govern. */
|
|
20
|
+
export type PathRuleTool = 'read' | 'edit' | 'write'
|
|
21
|
+
|
|
19
22
|
export interface ParsedCommand {
|
|
20
23
|
description: string
|
|
21
24
|
argumentHint?: string
|
|
@@ -23,7 +26,7 @@ export interface ParsedCommand {
|
|
|
23
26
|
/** Claude `Bash(...)` specifiers, present only when every bash grant is scoped. */
|
|
24
27
|
bashRules?: string[]
|
|
25
28
|
/** Claude path rules per pi file tool, from Read(...)/Edit(...)/Write(...) grants. */
|
|
26
|
-
pathRules?: Partial<Record<
|
|
29
|
+
pathRules?: Partial<Record<PathRuleTool, string[]>>
|
|
27
30
|
/** Names from the `arguments:` frontmatter list, mapped to positions in order. */
|
|
28
31
|
argumentNames?: string[]
|
|
29
32
|
/** Tools removed from the pool while the command's turn runs. */
|
|
@@ -66,6 +69,10 @@ const CLAUDE_TOOL_MAP: Record<string, string> = {
|
|
|
66
69
|
task: 'subagent',
|
|
67
70
|
askuserquestion: 'question',
|
|
68
71
|
exitplanmode: 'plan_mode_complete',
|
|
72
|
+
// Claude's name for the tool this package registers so the model can run user slash
|
|
73
|
+
// commands; without it `allowed-tools: SlashCommand` matched nothing and the grant
|
|
74
|
+
// could neither keep nor drop the tool.
|
|
75
|
+
slashcommand: 'slash_command',
|
|
69
76
|
}
|
|
70
77
|
|
|
71
78
|
/**
|
|
@@ -118,18 +125,78 @@ export interface ToolGrants {
|
|
|
118
125
|
/** Claude path rules per pi file tool, absent for a tool with an unscoped grant.
|
|
119
126
|
* Edit scopes govern writes too, as Claude documents; Write scopes are honored
|
|
120
127
|
* rather than Claude's accept-and-warn-then-ignore, which would fail open here. */
|
|
121
|
-
pathRules?: Partial<Record<
|
|
128
|
+
pathRules?: Partial<Record<PathRuleTool, string[]>>
|
|
122
129
|
/** Entries that carried an argument scope, in their original spelling. */
|
|
123
130
|
scopedEntries: string[]
|
|
124
131
|
}
|
|
125
132
|
|
|
126
133
|
/** The pi file tools one Claude path-ruled entry governs. */
|
|
127
|
-
const PATH_RULE_TOOLS: Record<string, Array<
|
|
134
|
+
const PATH_RULE_TOOLS: Record<string, Array<PathRuleTool>> = {
|
|
128
135
|
read: ['read'],
|
|
129
136
|
edit: ['edit', 'write'],
|
|
130
137
|
write: ['write'],
|
|
131
138
|
}
|
|
132
139
|
|
|
140
|
+
/** The tools, scopes, and path rules accumulated while scanning one grant list. */
|
|
141
|
+
interface GrantAccumulator {
|
|
142
|
+
tools: string[]
|
|
143
|
+
scopedEntries: string[]
|
|
144
|
+
bashRules: string[]
|
|
145
|
+
bashUnscoped: boolean
|
|
146
|
+
pathScopes: Record<PathRuleTool, string[]>
|
|
147
|
+
pathUnscoped: Set<PathRuleTool>
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function createGrantAccumulator(): GrantAccumulator {
|
|
151
|
+
return { tools: [], scopedEntries: [], bashRules: [], bashUnscoped: false, pathScopes: { read: [], edit: [], write: [] }, pathUnscoped: new Set() }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Coerce a raw grant value to its string entries: a YAML list stays a list, a
|
|
155
|
+
* comma-separated string is split, and anything else (or a list with a non-string
|
|
156
|
+
* member) is rejected as undefined, the same "not a grant" signal as an absent field. */
|
|
157
|
+
function coerceGrantItems(raw: unknown): string[] | undefined {
|
|
158
|
+
let items: unknown[]
|
|
159
|
+
if (Array.isArray(raw)) items = raw
|
|
160
|
+
else if (typeof raw === 'string') items = toolEntries(raw)
|
|
161
|
+
else return undefined
|
|
162
|
+
if (items.some((item) => typeof item !== 'string')) return undefined
|
|
163
|
+
return items as string[]
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Fold one grant entry into the accumulator: the base tool is granted, an unscoped
|
|
167
|
+
* entry marks its tools wide, and a scoped entry records the scope for bash and the
|
|
168
|
+
* file tools it governs. */
|
|
169
|
+
function addGrantEntry(acc: GrantAccumulator, item: string): void {
|
|
170
|
+
const entry = item.trim()
|
|
171
|
+
const name = normalizeToolName(entry)
|
|
172
|
+
if (!name) return
|
|
173
|
+
if (!acc.tools.includes(name)) acc.tools.push(name)
|
|
174
|
+
const open = entry.indexOf('(')
|
|
175
|
+
if (open === -1) {
|
|
176
|
+
if (name === 'bash') acc.bashUnscoped = true
|
|
177
|
+
for (const tool of PATH_RULE_TOOLS[name] ?? []) acc.pathUnscoped.add(tool)
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
acc.scopedEntries.push(entry)
|
|
181
|
+
const scope = entry.slice(open + 1, entry.endsWith(')') ? -1 : undefined).trim()
|
|
182
|
+
// An empty specifier (`Bash()`, `Read()`) matches nothing and must not read as
|
|
183
|
+
// the unscoped grant it explicitly is not: it is recorded so the tool stays
|
|
184
|
+
// restricted, and the matchers treat an empty rule as matching no input.
|
|
185
|
+
if (name === 'bash') acc.bashRules.push(scope)
|
|
186
|
+
for (const tool of PATH_RULE_TOOLS[name] ?? []) acc.pathScopes[tool].push(scope)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** The per-tool path rules from a scan: a tool with any unscoped grant is omitted
|
|
190
|
+
* (it is wide), one with only scoped grants keeps them, and the whole map is absent
|
|
191
|
+
* when no tool carries a rule. */
|
|
192
|
+
function buildPathRules(acc: GrantAccumulator): ToolGrants['pathRules'] {
|
|
193
|
+
const pathRules: NonNullable<ToolGrants['pathRules']> = {}
|
|
194
|
+
for (const tool of ['read', 'edit', 'write'] as const) {
|
|
195
|
+
if (!acc.pathUnscoped.has(tool) && acc.pathScopes[tool].length > 0) pathRules[tool] = acc.pathScopes[tool]
|
|
196
|
+
}
|
|
197
|
+
return Object.keys(pathRules).length > 0 ? pathRules : undefined
|
|
198
|
+
}
|
|
199
|
+
|
|
133
200
|
/**
|
|
134
201
|
* A tool grant is either a comma-separated string or a YAML list, and the two mean the
|
|
135
202
|
* same thing. An empty list is not the same as an absent one: it says no tools, so it
|
|
@@ -143,47 +210,15 @@ const PATH_RULE_TOOLS: Record<string, Array<'read' | 'edit' | 'write'>> = {
|
|
|
143
210
|
* whose widening reaches everything, so it is the one enforced.
|
|
144
211
|
*/
|
|
145
212
|
export function parseToolGrants(raw: unknown): ToolGrants | undefined {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
else return undefined
|
|
151
|
-
if (items.some((item) => typeof item !== 'string')) return undefined
|
|
152
|
-
|
|
153
|
-
const tools: string[] = []
|
|
154
|
-
const scopedEntries: string[] = []
|
|
155
|
-
const bashRules: string[] = []
|
|
156
|
-
let bashUnscoped = false
|
|
157
|
-
const pathScopes: Record<'read' | 'edit' | 'write', string[]> = { read: [], edit: [], write: [] }
|
|
158
|
-
const pathUnscoped = new Set<'read' | 'edit' | 'write'>()
|
|
159
|
-
for (const item of items as string[]) {
|
|
160
|
-
const entry = item.trim()
|
|
161
|
-
const name = normalizeToolName(entry)
|
|
162
|
-
if (!name) continue
|
|
163
|
-
if (!tools.includes(name)) tools.push(name)
|
|
164
|
-
const open = entry.indexOf('(')
|
|
165
|
-
if (open === -1) {
|
|
166
|
-
if (name === 'bash') bashUnscoped = true
|
|
167
|
-
for (const tool of PATH_RULE_TOOLS[name] ?? []) pathUnscoped.add(tool)
|
|
168
|
-
continue
|
|
169
|
-
}
|
|
170
|
-
scopedEntries.push(entry)
|
|
171
|
-
const scope = entry.slice(open + 1, entry.endsWith(')') ? -1 : undefined).trim()
|
|
172
|
-
// An empty specifier (`Bash()`, `Read()`) matches nothing and must not read as
|
|
173
|
-
// the unscoped grant it explicitly is not: it is recorded so the tool stays
|
|
174
|
-
// restricted, and the matchers treat an empty rule as matching no input.
|
|
175
|
-
if (name === 'bash') bashRules.push(scope)
|
|
176
|
-
for (const tool of PATH_RULE_TOOLS[name] ?? []) pathScopes[tool].push(scope)
|
|
177
|
-
}
|
|
178
|
-
const pathRules: ToolGrants['pathRules'] = {}
|
|
179
|
-
for (const tool of ['read', 'edit', 'write'] as const) {
|
|
180
|
-
if (!pathUnscoped.has(tool) && pathScopes[tool].length > 0) pathRules[tool] = pathScopes[tool]
|
|
181
|
-
}
|
|
213
|
+
const items = coerceGrantItems(raw)
|
|
214
|
+
if (items === undefined) return undefined
|
|
215
|
+
const acc = createGrantAccumulator()
|
|
216
|
+
for (const item of items) addGrantEntry(acc, item)
|
|
182
217
|
return {
|
|
183
|
-
tools,
|
|
184
|
-
scopedEntries,
|
|
185
|
-
bashRules: !bashUnscoped && bashRules.length > 0 ? bashRules : undefined,
|
|
186
|
-
pathRules:
|
|
218
|
+
tools: acc.tools,
|
|
219
|
+
scopedEntries: acc.scopedEntries,
|
|
220
|
+
bashRules: !acc.bashUnscoped && acc.bashRules.length > 0 ? acc.bashRules : undefined,
|
|
221
|
+
pathRules: buildPathRules(acc),
|
|
187
222
|
}
|
|
188
223
|
}
|
|
189
224
|
|
|
@@ -193,17 +228,25 @@ const text = (value: unknown): string => {
|
|
|
193
228
|
return typeof value === 'number' || typeof value === 'boolean' ? String(value) : ''
|
|
194
229
|
}
|
|
195
230
|
|
|
231
|
+
/** YAML's affirmative boolean spellings. Claude documents `disable-model-invocation:
|
|
232
|
+
* true`, but a command file is hand-written YAML where `yes`, `on`, and `1` are all
|
|
233
|
+
* ordinary spellings of true, and pi's parser hands those back as the raw string or
|
|
234
|
+
* number rather than a boolean. A flag that gates a command off from the model has to
|
|
235
|
+
* honor them, or a command the user marked off-limits is silently offered to it. */
|
|
236
|
+
const YAML_TRUE = new Set(['true', 'yes', 'on', 'y', '1'])
|
|
237
|
+
const isFlagEnabled = (value: unknown): boolean => value === true || YAML_TRUE.has(text(value).toLowerCase())
|
|
238
|
+
|
|
196
239
|
/** Claude writes `argument-hint: [pr]`, which YAML reads as a list; render it back. */
|
|
197
240
|
const hint = (value: unknown): string => (Array.isArray(value) ? `[${value.join(', ')}]` : text(value))
|
|
198
241
|
|
|
199
|
-
const ARGUMENT_NAME = /^[A-Za-z_]
|
|
242
|
+
const ARGUMENT_NAME = /^[A-Za-z_]\w*$/
|
|
200
243
|
|
|
201
244
|
/** The `arguments:` frontmatter: a YAML list or a space- or comma-separated string
|
|
202
245
|
* of names mapping to positions in order. Invalid names are dropped, and ARGUMENTS
|
|
203
246
|
* itself is reserved by the built-in placeholder. */
|
|
204
247
|
function parseArgumentNames(raw: unknown): string[] | undefined {
|
|
205
248
|
let items: string[]
|
|
206
|
-
if (Array.isArray(raw)) items = raw.map(
|
|
249
|
+
if (Array.isArray(raw)) items = raw.map(String)
|
|
207
250
|
else if (typeof raw === 'string') items = raw.split(/[\s,]+/)
|
|
208
251
|
else return undefined
|
|
209
252
|
const names = items.map((name) => name.trim()).filter((name) => ARGUMENT_NAME.test(name) && name !== 'ARGUMENTS')
|
|
@@ -233,7 +276,7 @@ export function parseCommandFile(content: string): ParsedCommand {
|
|
|
233
276
|
disallowedTools: parseToolGrants(frontmatter['disallowed-tools'])?.tools,
|
|
234
277
|
shell: SHELLS.has(shell) ? shell : undefined,
|
|
235
278
|
model: text(frontmatter.model) || undefined,
|
|
236
|
-
disableModelInvocation:
|
|
279
|
+
disableModelInvocation: isFlagEnabled(disable),
|
|
237
280
|
body,
|
|
238
281
|
}
|
|
239
282
|
}
|
|
@@ -300,8 +343,8 @@ export function substituteArgsDetailed(body: string, args: string, names: string
|
|
|
300
343
|
return value
|
|
301
344
|
}
|
|
302
345
|
const text = body.replaceAll(argPattern(names), (token, bracketIdx?: string, defIdx?: string, defVal?: string, argsDefault?: string, shorthandIdx?: string, name?: string) => {
|
|
303
|
-
if (token ===
|
|
304
|
-
if (token ===
|
|
346
|
+
if (token === String.raw`\\`) return token
|
|
347
|
+
if (token === String.raw`\$`) return '$'
|
|
305
348
|
if (bracketIdx !== undefined) return fill(parts[Number(bracketIdx)], token)
|
|
306
349
|
if (defIdx !== undefined) return fill(parts[Number(defIdx)], defVal ?? '')
|
|
307
350
|
if (argsDefault !== undefined) {
|
|
@@ -20,6 +20,16 @@ function decodeAllEntities(text: string): string {
|
|
|
20
20
|
|
|
21
21
|
const stripInnerTags = (html: string): string => html.replace(/<[^<>]*>/g, '')
|
|
22
22
|
|
|
23
|
+
// Strip leading and trailing newline runs in linear time. The equivalent
|
|
24
|
+
// /^\n+|\n+$/g backtracks super-linearly on a long run of newlines (S8786).
|
|
25
|
+
const trimNewlines = (value: string): string => {
|
|
26
|
+
let start = 0
|
|
27
|
+
let end = value.length
|
|
28
|
+
while (start < end && value[start] === '\n') start++
|
|
29
|
+
while (end > start && value[end - 1] === '\n') end--
|
|
30
|
+
return value.slice(start, end)
|
|
31
|
+
}
|
|
32
|
+
|
|
23
33
|
export function htmlToMarkdown(html: string): string {
|
|
24
34
|
// Pre blocks are lifted out first so no later transform touches their content.
|
|
25
35
|
const preBodies: string[] = []
|
|
@@ -27,7 +37,7 @@ export function htmlToMarkdown(html: string): string {
|
|
|
27
37
|
.replace(/<!--[\s\S]*?-->/g, ' ')
|
|
28
38
|
.replace(/<(script|style|noscript|head|svg)\b[^<>]*>[\s\S]*?<\/\1[^<>]*>/gi, ' ')
|
|
29
39
|
.replace(/<pre\b[^<>]*>([\s\S]*?)<\/pre>/gi, (_whole, inner: string) => {
|
|
30
|
-
preBodies.push(decodeAllEntities(stripInnerTags(inner))
|
|
40
|
+
preBodies.push(trimNewlines(decodeAllEntities(stripInnerTags(inner))))
|
|
31
41
|
return `\n\n\uE000PRE${preBodies.length - 1}\uE000\n\n`
|
|
32
42
|
})
|
|
33
43
|
|
|
@@ -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:
|
|
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.
|
|
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
|
|
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,17 +32,21 @@ 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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
48
52
|
|
|
@@ -161,7 +165,9 @@ export function waitForAuthCode(server: http.Server, timeoutMs: number): Promise
|
|
|
161
165
|
|
|
162
166
|
/** Best-effort browser launch; the caller also surfaces the URL as text. */
|
|
163
167
|
export function openBrowser(url: string): void {
|
|
164
|
-
|
|
168
|
+
let command = 'xdg-open'
|
|
169
|
+
if (process.platform === 'darwin') command = 'open'
|
|
170
|
+
else if (process.platform === 'win32') command = 'cmd'
|
|
165
171
|
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url]
|
|
166
172
|
try {
|
|
167
173
|
spawn(command, args, { stdio: 'ignore', detached: true }).unref()
|
|
@@ -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
|
-
|
|
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
|
|
103
|
-
|
|
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\.(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
}
|