dsh-cc-loader 0.1.0

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.
@@ -0,0 +1,465 @@
1
+ // Evaluation engine: CC deny → ask → allow folding over one tool call,
2
+ // plus tool-removal detection and component classification.
3
+ //
4
+ // Verified against code.claude.com/docs/permissions:
5
+ // - Rules are evaluated in order deny, then ask, then allow; the first match
6
+ // in that order determines the outcome; specificity does not change order.
7
+ // - Bare tool-name deny removes the tool from context entirely.
8
+ // - Compound Bash commands: a rule must match each subcommand independently
9
+ // (allow) / any subcommand (deny/ask); wrappers and leading env assignments
10
+ // are stripped before matching.
11
+ // - Read deny rules also block Edit/Write tools on the same path and Bash
12
+ // file commands (cat, head, tail, sed…).
13
+
14
+ import { parseRule } from './parse-rule.js'
15
+ import {
16
+ ccBucket, ruleTargetsTool, isReadCoveredTool, isEditCoveredTool,
17
+ isBashReadCommand, isBashWriteCommand, extractBashPaths,
18
+ } from './map-tools.js'
19
+ import { winPathToPosix } from './patterns.js'
20
+
21
+ /** Shell wrappers CC strips before matching (docs: "process wrappers"). */
22
+ const WRAPPERS = new Set(['timeout', 'time', 'nice', 'nohup', 'stdbuf', 'command', 'builtin', 'noglob'])
23
+
24
+ /** Leading env assignments allow rules match past (a conservative subset). */
25
+ const SAFE_ENV = new Set(['NODE_ENV', 'CI', 'DEBUG', 'TERM', 'COLUMNS', 'LINES', 'EDITOR', 'PAGER', 'FORCE_COLOR', 'NO_COLOR', 'LANG', 'LC_ALL'])
26
+
27
+ /**
28
+ * Parse raw rule strings from a merged permissions IR block into structured
29
+ * rules, classifying each as supported/invalid.
30
+ * @param {{deny: {raw:string}[], ask: {raw:string}[], allow: {raw:string}[]}} perm
31
+ * @returns {{ deny: object[], ask: object[], allow: object[], invalid: object[] }}
32
+ */
33
+ export function parseRulesFor(perm) {
34
+ const out = { deny: [], ask: [], allow: [], invalid: [] }
35
+ for (const bucket of ['deny', 'ask', 'allow']) {
36
+ for (const r of perm[bucket] ?? []) {
37
+ const rule = parseRule(r.raw, { scope: r.scope, path: r.path })
38
+ if (rule === null || rule.invalid) { out.invalid.push(rule ?? { raw: r.raw, reason: 'unparseable' }); continue }
39
+ rule.bucket = bucket
40
+ out[bucket].push(rule)
41
+ }
42
+ }
43
+ return out
44
+ }
45
+
46
+ /**
47
+ * Evaluate one tool call against parsed permission rules.
48
+ * @param {{ deny: object[], ask: object[], allow: object[] }} parsed - output of parseRulesFor.
49
+ * @param {{ tool: string, args: Record<string, unknown> }} call
50
+ * @param {object} env - { cwd, homeDir, settingsDirs: {user, project, local} }
51
+ * @returns {{ decision: 'deny'|'ask'|'allow'|'none', reason?: string, rule?: object }}
52
+ */
53
+ export function evaluateCall(parsed, call, env) {
54
+ // 0. Bare-name / tool-glob deny removes the tool → any call is denied.
55
+ for (const rule of parsed.deny) {
56
+ if ((rule.kind === 'bare' || rule.kind === 'tool-glob') && matchBareTarget(rule, call.tool)) {
57
+ return { decision: 'deny', reason: `tool "${call.tool}" is denied by rule "${rule.raw}" (removed from context)`, rule }
58
+ }
59
+ }
60
+ // 1. deny
61
+ for (const rule of parsed.deny) {
62
+ if (matchRule(rule, call, env)) {
63
+ return { decision: 'deny', reason: `denied by "${rule.raw}"`, rule }
64
+ }
65
+ }
66
+ // 2. ask
67
+ for (const rule of parsed.ask) {
68
+ if (matchRule(rule, call, env)) {
69
+ return { decision: 'ask', reason: `a Claude Code permission rule ("${rule.raw}") requests confirmation`, rule }
70
+ }
71
+ }
72
+ // 3. allow
73
+ for (const rule of parsed.allow) {
74
+ if (matchRule(rule, call, env)) {
75
+ return { decision: 'allow', rule }
76
+ }
77
+ }
78
+ return { decision: 'none' }
79
+ }
80
+
81
+ /** Bare rules and tool-glob rules match by tool name only. */
82
+ function matchBareTarget(rule, toolName) {
83
+ if (rule.kind === 'tool-glob') return rule.glob.test(toolName)
84
+ return ruleTargetsTool(rule.tool, toolName)
85
+ }
86
+
87
+ function matchRule(rule, call, env) {
88
+ switch (rule.kind) {
89
+ case 'bare':
90
+ case 'tool-glob':
91
+ return matchBareTarget(rule, call.tool)
92
+ case 'command':
93
+ return matchCommandRule(rule, call, env)
94
+ case 'path':
95
+ return matchPathRule(rule, call, env)
96
+ case 'domain':
97
+ return matchDomainRule(rule, call)
98
+ case 'param':
99
+ return matchParamRule(rule, call)
100
+ case 'agent-name':
101
+ // DSH's subagent tool carries no CC-style agent type name → never
102
+ // matches; the rule stays in the report for transparency.
103
+ return false
104
+ case 'skill-name':
105
+ return ccBucket(call.tool) === 'Skill' && call.args?.name === rule.name
106
+ default:
107
+ return false
108
+ }
109
+ }
110
+
111
+ // ─── command (Bash/PowerShell) ───────────────────────────────────────────────
112
+
113
+ function matchCommandRule(rule, call, env) {
114
+ const bucket = ccBucket(call.tool)
115
+ if (bucket !== 'Bash' && bucket !== 'PowerShell') return false
116
+ if (typeof call.args?.command !== 'string') return false
117
+ const subcommands = splitSubcommands(call.args.command)
118
+ const allowMode = rule.bucket === 'allow'
119
+ const stripped = subcommands.map((s) => {
120
+ const w = stripWrapper(s, { allowEnv: false })
121
+ // CC canonicalizes PowerShell aliases before matching: a rule written for
122
+ // the cmdlet name (Remove-Item) also matches its aliases (del, rm, ri…).
123
+ return bucket === 'PowerShell' ? canonicalizePowerShell(w) : w
124
+ })
125
+ if (allowMode) return stripped.length > 0 && stripped.every((s) => rule.command.test(s))
126
+ return stripped.some((s) => rule.command.test(s))
127
+ }
128
+
129
+ /** Common PowerShell aliases → cmdlet names (CC canonicalizes these before matching). */
130
+ const PS_ALIASES = new Map([
131
+ ['ri', 'Remove-Item'], ['rm', 'Remove-Item'], ['del', 'Remove-Item'], ['erase', 'Remove-Item'], ['rd', 'Remove-Item'],
132
+ ['gci', 'Get-ChildItem'], ['ls', 'Get-ChildItem'], ['dir', 'Get-ChildItem'],
133
+ ['cat', 'Get-Content'], ['gc', 'Get-Content'], ['type', 'Get-Content'],
134
+ ['cp', 'Copy-Item'], ['copy', 'Copy-Item'], ['cpi', 'Copy-Item'],
135
+ ['mv', 'Move-Item'], ['move', 'Move-Item'], ['mi', 'Move-Item'],
136
+ ['ni', 'New-Item'], ['ii', 'Invoke-Item'], ['gi', 'Get-Item'],
137
+ ['sl', 'Set-Location'], ['cd', 'Set-Location'], ['chdir', 'Set-Location'],
138
+ ['pwd', 'Get-Location'], ['gl', 'Get-Location'],
139
+ ])
140
+
141
+ /** Rewrite a PowerShell command's leading alias to its canonical cmdlet name. */
142
+ function canonicalizePowerShell(cmd) {
143
+ const m = /^(\S+)(\s+.*)?$/.exec(cmd.trim())
144
+ if (!m) return cmd
145
+ const canonical = PS_ALIASES.get(m[1].toLowerCase())
146
+ if (canonical === undefined) return cmd
147
+ return m[2] !== undefined ? `${canonical}${m[2]}` : canonical
148
+ }
149
+
150
+ /** Split a compound command on CC-recognized separators, quote-aware. */
151
+ export function splitSubcommands(command) {
152
+ const parts = []
153
+ let current = ''
154
+ let quote = null
155
+ let i = 0
156
+ const n = command.length
157
+ while (i < n) {
158
+ const ch = command[i]
159
+ if (quote !== null) {
160
+ current += ch
161
+ if (ch === quote) quote = null
162
+ i++
163
+ continue
164
+ }
165
+ if (ch === '"' || ch === "'" || ch === '`') { quote = ch; current += ch; i++; continue }
166
+ if (ch === '\\') { current += ch + (command[i + 1] ?? ''); i += 2; continue }
167
+ const two = command.slice(i, i + 2)
168
+ if (two === '&&' || two === '||' || two === '|&') {
169
+ parts.push(current.trim()); current = ''; i += 2; continue
170
+ }
171
+ if (ch === '&' || ch === '|' || ch === ';' || ch === '\n' || ch === '\r') {
172
+ parts.push(current.trim()); current = ''; i++; continue
173
+ }
174
+ current += ch
175
+ i++
176
+ }
177
+ parts.push(current.trim())
178
+ return parts.filter((p) => p.length > 0)
179
+ }
180
+
181
+ /** Strip leading env assignments, known wrappers and wrapper arguments. */
182
+ function stripWrapper(cmd, { allowEnv = false } = {}) {
183
+ let t = cmd.trim()
184
+ let changed = true
185
+ while (changed && t.length > 0) {
186
+ changed = false
187
+ const envm = /^([A-Za-z_][A-Za-z0-9_]*)=("[^"]*"|'[^']*'|\S+)\s+/.exec(t)
188
+ if (envm) {
189
+ if (allowEnv && !SAFE_ENV.has(envm[1])) return t // allow rules don't pass unknown env
190
+ t = t.slice(envm[0].length).trim()
191
+ changed = true
192
+ continue
193
+ }
194
+ const wm = /^(\S+)\s+/.exec(t)
195
+ if (wm && WRAPPERS.has(wm[1])) {
196
+ t = t.slice(wm[0].length).trim()
197
+ // Strip wrapper arguments: leading flags (with values) and a numeric
198
+ // duration (`timeout 30 npm test` → `npm test`, `nice -n 5 npm test` → …).
199
+ t = stripWrapperArgs(t)
200
+ changed = true
201
+ continue
202
+ }
203
+ if (wm && wm[1] === 'xargs' && !t.slice(wm[0].length).trim().startsWith('-')) {
204
+ t = t.slice(wm[0].length).trim()
205
+ changed = true
206
+ continue
207
+ }
208
+ }
209
+ return t
210
+ }
211
+
212
+ /** Strip leading flag tokens and a trailing numeric duration after a wrapper. */
213
+ function stripWrapperArgs(t) {
214
+ let out = t
215
+ let again = true
216
+ while (again && out.length > 0) {
217
+ again = false
218
+ const fm = /^-\w+(?:=\S+)?(\s+\S+)?/.exec(out)
219
+ if (fm) {
220
+ out = out.slice(fm[0].length).trim()
221
+ again = true
222
+ continue
223
+ }
224
+ const nm = /^\d+(?:\.\d+)?[a-z]*(?:\s+|$)/.exec(out)
225
+ if (nm) {
226
+ out = out.slice(nm[0].length).trim()
227
+ again = true
228
+ }
229
+ }
230
+ return out
231
+ }
232
+
233
+ // ─── path (Read/Edit/Cd) ─────────────────────────────────────────────────────
234
+
235
+ function matchPathRule(rule, call, env) {
236
+ // Which tools this path rule applies to:
237
+ // Read rules → read-like tools AND Edit/Write tools (CC: a Read deny rule
238
+ // also blocks Edit/Write on the same path) AND Bash read file commands.
239
+ // Edit rules → edit/write-like tools AND Bash write file commands.
240
+ const bucket = ccBucket(call.tool)
241
+ const isFileTool = isReadCoveredTool(call.tool) || isEditCoveredTool(call.tool)
242
+ const isBash = bucket === 'Bash' || bucket === 'PowerShell'
243
+ if (isFileTool) {
244
+ const paths = extractFilePaths(call.args)
245
+ return paths.some((p) => matchPath(rule, p, env))
246
+ }
247
+ if (isBash && typeof call.args?.command === 'string') {
248
+ const cmd = call.args.command
249
+ const reads = rule.tool === 'Read' && isBashReadCommand(cmd)
250
+ const writes = rule.tool === 'Edit' && isBashWriteCommand(cmd)
251
+ if (!reads && !writes) return false
252
+ return extractBashPaths(cmd).some((p) => matchPath(rule, p, env))
253
+ }
254
+ return false
255
+ }
256
+
257
+ /** Extract candidate file paths from a file tool's arguments. */
258
+ function extractFilePaths(args) {
259
+ if (typeof args !== 'object' || args === null) return []
260
+ const out = []
261
+ for (const key of ['file_path', 'path', 'notebook_path']) {
262
+ const v = args[key]
263
+ if (typeof v === 'string' && v.length > 0) out.push(v)
264
+ }
265
+ return out
266
+ }
267
+
268
+ /**
269
+ * Match one file path against a compiled path rule with anchor resolution.
270
+ * `denyAskDepth` mirrors CC: a relative single-segment pattern (`src/**`)
271
+ * matches at any depth for deny/ask rules but only at the anchor for allow.
272
+ */
273
+ function matchPath(rule, filePath, env) {
274
+ const compiled = rule.pathPattern
275
+ const target = winPathToPosix(filePath)
276
+ let rel
277
+ const base = anchorBase(rule, env)
278
+ if (base === undefined) return false
279
+ if (compiled.kind === 'absolute') rel = target
280
+ else if (target === base) rel = ''
281
+ else if (target.startsWith(base.endsWith('/') ? base : `${base}/`)) rel = target.slice(base.length).replace(/^\//, '')
282
+ else return false
283
+ const denyAsk = rule.bucket !== 'allow'
284
+ let source = compiled.re.source
285
+ if (compiled.prefixAny || (compiled.singleSegment && denyAsk)) {
286
+ // Insert an any-depth prefix after the anchor: `^secrets…` → `^(?:.*/)?secrets…`.
287
+ source = source.replace(/^\^/, '^') // keep the ^ if present
288
+ if (source.startsWith('^')) source = `^(?:.*/)?${source.slice(1)}`
289
+ else source = `(?:.*/)?${source}`
290
+ }
291
+ return new RegExp(source).test(rel)
292
+ }
293
+
294
+ /** The anchor directory (POSIX) for a rule's `kind`, by its source scope. */
295
+ function anchorBase(rule, env) {
296
+ const scope = rule.scope
297
+ const home = posix(env.homeDir)
298
+ switch (rule.pathPattern.kind) {
299
+ case 'absolute': return ''
300
+ case 'home': return home
301
+ case 'source':
302
+ if (scope === 'user') return `${home}/.claude`
303
+ if (scope === 'local') return posix(env.cwd)
304
+ return posix(env.projectRoot ?? env.cwd)
305
+ case 'cwd':
306
+ default:
307
+ return posix(env.cwd)
308
+ }
309
+ }
310
+
311
+ function posix(p) {
312
+ if (typeof p !== 'string') return '/'
313
+ return winPathToPosix(p).replace(/\/+$/, '') || '/'
314
+ }
315
+
316
+ // ─── domain (WebFetch) ───────────────────────────────────────────────────────
317
+
318
+ function matchDomainRule(rule, call) {
319
+ if (ccBucket(call.tool) !== 'WebFetch') return false
320
+ if (rule.domainRe === undefined) return false
321
+ if (typeof call.args?.url !== 'string') return false
322
+ let host
323
+ try { host = new URL(call.args.url).hostname } catch { return false }
324
+ return rule.domainRe.test(host.toLowerCase().replace(/\.$/, ''))
325
+ }
326
+
327
+ // ─── param (Tool(param:value)) ───────────────────────────────────────────────
328
+
329
+ function matchParamRule(rule, call) {
330
+ const args = call.args
331
+ if (typeof args !== 'object' || args === null) return false
332
+ const v = args[rule.param]
333
+ if (v === undefined) return false // an omitted param never matches (CC)
334
+ const value = String(v)
335
+ if (rule.value.includes('*')) {
336
+ let out = ''
337
+ for (const ch of rule.value) out += ch === '*' ? '.*' : ch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
338
+ return new RegExp(`^${out}$`).test(value)
339
+ }
340
+ return value === rule.value
341
+ }
342
+
343
+ /**
344
+ * CC hook `if`-field matching: does the rule select this call?
345
+ *
346
+ * CC evaluates `if` with permission-rule syntax but as a FILTER, not a
347
+ * decision: the hook runs when the rule matches, and stays silent otherwise.
348
+ * Two deliberate semantic choices, both documented in CC's hooks reference:
349
+ * - Bash/PowerShell command rules match when ANY subcommand matches (CC: "each
350
+ * subcommand is checked; git push matches"), which is the deny/ask (some)
351
+ * reading, not the allow (every) reading used by permission folding.
352
+ * - Path rules match anchored like allow rules (CC v2.1.214+: a single-segment
353
+ * pattern `Edit(src/**)` matches only the working-directory anchor, not any
354
+ * depth), so the rule is evaluated with bucket 'allow'.
355
+ *
356
+ * Fail-open is the CALLER's job: a rule that cannot be parsed must run the
357
+ * hook anyway (CC: "The filter also fails open … when the Bash command can't
358
+ * be parsed"), so this function assumes a parsed, valid rule.
359
+ *
360
+ * @param {object} rule - structured rule from {@link parseRule}.
361
+ * @param {{ tool: string, args: Record<string, unknown> }} call
362
+ * @param {object} env - { cwd, homeDir } matching context.
363
+ * @returns {boolean} whether the rule selects the call.
364
+ */
365
+ export function matchesIfRule(rule, call, env) {
366
+ switch (rule.kind) {
367
+ case 'bare':
368
+ case 'tool-glob':
369
+ return matchBareTarget(rule, call.tool)
370
+ case 'command':
371
+ // any-subcommand semantics (deny bucket) — see header comment.
372
+ return matchCommandRule({ ...rule, bucket: 'deny' }, call, env)
373
+ case 'path':
374
+ // anchored (allow bucket) semantics — see header comment.
375
+ return matchPathRule({ ...rule, bucket: 'allow' }, call, env)
376
+ case 'domain':
377
+ return matchDomainRule(rule, call)
378
+ case 'param':
379
+ return matchParamRule(rule, call)
380
+ case 'skill-name':
381
+ return ccBucket(call.tool) === 'Skill' && call.args?.name === rule.name
382
+ case 'agent-name':
383
+ // `Agent(Name)` restricts a subagent by name; no tool call carries it.
384
+ return false
385
+ default:
386
+ return false
387
+ }
388
+ }
389
+
390
+ // ─── tool removal (bare-name deny) ───────────────────────────────────────────
391
+
392
+ /**
393
+ * Compute the tool names a settings file removes from context (bare-name deny
394
+ * and tool-glob deny rules). Adapters use this to hide tools per-agent.
395
+ * @returns {{ names: string[], globs: string[] }}
396
+ */
397
+ export function removedToolNames(parsed) {
398
+ const names = new Set()
399
+ const globs = new Set()
400
+ for (const rule of parsed.deny) {
401
+ if (rule.kind === 'bare') names.add(rule.tool)
402
+ else if (rule.kind === 'tool-glob') globs.add(rule.tool)
403
+ }
404
+ return { names: [...names], globs: [...globs] }
405
+ }
406
+
407
+ // ─── component classification ────────────────────────────────────────────────
408
+
409
+ /** Component status values used across the IR. */
410
+ export const STATUS = {
411
+ DIRECT: 'DIRECT',
412
+ ADAPTED: 'ADAPTED',
413
+ UNSUPPORTED: 'UNSUPPORTED',
414
+ BLOCKED: 'BLOCKED',
415
+ }
416
+
417
+ /**
418
+ * Classify parsed components and build the compatibility report.
419
+ * @param {object} ir - the assembled IR (components + warnings).
420
+ * @returns {object} report { total, direct, adapted, unsupported, blocked, warnings }
421
+ */
422
+ export function classifyComponents(ir) {
423
+ const counts = { total: 0, direct: 0, adapted: 0, unsupported: 0, blocked: 0 }
424
+ const unsupported = []
425
+ const consider = (status, kind, name, reason) => {
426
+ counts.total++
427
+ counts[status.toLowerCase()]++
428
+ if (status === STATUS.UNSUPPORTED || status === STATUS.BLOCKED) {
429
+ unsupported.push({ kind, name, status, reason })
430
+ }
431
+ }
432
+ for (const s of ir.components.skills ?? []) consider(s.status ?? STATUS.DIRECT, 'skill', s.name, s.reason)
433
+ for (const c of ir.components.commands ?? []) consider(c.status ?? STATUS.DIRECT, 'command', c.name, c.reason)
434
+ for (const r of ir.components.rules ?? []) consider(r.status ?? STATUS.DIRECT, 'rule', r.name, r.reason)
435
+ for (const a of ir.components.agents ?? []) consider(a.status ?? STATUS.DIRECT, 'agent', a.name, a.notes?.join('; ') || undefined)
436
+ for (const s of ir.components.mcp?.servers ?? []) consider(s.status ?? STATUS.DIRECT, 'mcp-server', s.serverName, s.reason)
437
+ for (const s of ir.components.lsp?.servers ?? []) consider(s.status ?? STATUS.DIRECT, 'lsp-server', s.language, s.reason)
438
+ for (const p of ir.components.plugins ?? []) {
439
+ consider(STATUS.DIRECT, 'plugin', p.name, undefined)
440
+ for (const u of p.components?.unsupported ?? []) {
441
+ counts.total++
442
+ counts.unsupported++
443
+ unsupported.push({ ...u, plugin: p.name })
444
+ }
445
+ }
446
+ for (const mp of ir.components.marketplaces ?? []) {
447
+ if (mp.marketplace === undefined) continue
448
+ consider(STATUS.DIRECT, 'marketplace', mp.marketplace.name, undefined)
449
+ for (const entry of mp.plugins ?? []) {
450
+ consider(entry.status ?? STATUS.UNSUPPORTED, 'marketplace-plugin', entry.name, entry.reason)
451
+ }
452
+ }
453
+ const perm = ir.components.permissions
454
+ if (perm !== undefined) {
455
+ if (perm.status === STATUS.DIRECT || perm.status === STATUS.UNSUPPORTED) {
456
+ consider(perm.status, 'permissions', 'settings.json', perm.status === STATUS.UNSUPPORTED ? 'no permission rules found' : undefined)
457
+ }
458
+ }
459
+ for (const u of ir.components.unsupported ?? []) {
460
+ counts.total++
461
+ counts.unsupported++
462
+ unsupported.push(u)
463
+ }
464
+ return { ...counts, unsupported, warnings: ir.warnings ?? [] }
465
+ }
package/src/index.js ADDED
@@ -0,0 +1,41 @@
1
+ // dsh-cc-loader — shared parse layer for the dsh-cc ecosystem.
2
+ //
3
+ // Parses Claude Code `.claude/` (project + global user dir) into a standalone
4
+ // in-memory IR. The IR is a plain JSON-serializable object; adapters consume
5
+ // it and never touch `.claude` details directly. Nothing is written to disk:
6
+ // the source of truth stays the `.claude` files themselves, so DSH stays in
7
+ // sync with Claude Code (which also scans `.claude` per session).
8
+ //
9
+ // Every component is classified DIRECT / ADAPTED / UNSUPPORTED / BLOCKED;
10
+ // UNSUPPORTED and BLOCKED components never reach the adapters.
11
+
12
+ export { loadClaude, loadPermissions } from './load.js'
13
+ export {
14
+ discoverSkills, discoverCommands, collectClaudeDir, discoverRules,
15
+ findProjectRoot, parseFrontmatter, isSkillName, pathExists, readTextSafe,
16
+ } from './skills.js'
17
+ export { discoverSettings, mergeSettings } from './settings.js'
18
+ export {
19
+ discoverAgents, mergeAgentCatalog, buildAgentEntry, classifyAgentFields,
20
+ expandCcToolToDsh,
21
+ } from './agents.js'
22
+ export {
23
+ parseMcpText, serverEntries, discoverMcpConfig, discoverProjectMcp,
24
+ VALID_SERVER_NAME, ENV_PLACEHOLDER,
25
+ } from './mcp.js'
26
+ export { discoverLspConfig, parseLspText } from './lsp.js'
27
+ export {
28
+ parsePluginManifest, parseMarketplace, discoverPluginRoot, discoverMarketplace,
29
+ normalizePluginSource, pluginNameOf, pluginComponentName,
30
+ PLUGIN_NAME_RE, RESERVED_MARKETPLACE_NAMES,
31
+ } from './plugin.js'
32
+ export { parseRule } from './parse-rule.js'
33
+ export { compileCommandPattern, compilePathPattern, compileDomainPattern, winPathToPosix, escapeRegExp } from './patterns.js'
34
+ export {
35
+ ccBucket, ruleTargetsTool, extractBashPaths, isBashReadCommand, isBashWriteCommand,
36
+ isReadCoveredTool, isEditCoveredTool,
37
+ } from './map-tools.js'
38
+ export {
39
+ evaluateCall, parseRulesFor, splitSubcommands, removedToolNames,
40
+ classifyComponents, STATUS, matchesIfRule,
41
+ } from './classify.js'
package/src/load.js ADDED
@@ -0,0 +1,174 @@
1
+ // loadClaude: assemble the full IR from a cwd (project + global .claude).
2
+
3
+ import { homedir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { findProjectRoot, collectClaudeDir, discoverRules } from './skills.js'
6
+ import { discoverSettings, mergeSettings } from './settings.js'
7
+ import { parseRulesFor, removedToolNames, classifyComponents } from './classify.js'
8
+ import { STATUS } from './classify.js'
9
+ import { mergeAgentCatalog } from './agents.js'
10
+ import { discoverProjectMcp } from './mcp.js'
11
+ import { discoverPluginRoot, discoverMarketplace } from './plugin.js'
12
+
13
+ /**
14
+ * Load Claude Code `.claude/` assets (project + optional global ~/.claude)
15
+ * into a standalone IR. Pure parse: no writes, no side effects beyond reads.
16
+ * @param {object} [opts]
17
+ * @param {string} opts.cwd - session workspace (project root discovery starts here).
18
+ * @param {string} [opts.homeDir]
19
+ * @param {string[]} [opts.projectRootMarkers]
20
+ * @param {boolean} [opts.enableGlobal=true]
21
+ * @param {string} [opts.globalClaudeDir]
22
+ * @param {number} [opts.projectSkillRank=150]
23
+ * @param {number} [opts.globalSkillRank=160]
24
+ * @param {string[]} [opts.pluginRoots] - plugin dirs to inventory (M4).
25
+ * @param {string[]} [opts.marketplaceRoots] - marketplace dirs to inventory (M4).
26
+ * @param {number} [opts.pluginSkillRank=170] - plugin component rank (higher
27
+ * than global 160 so project/global entries win on name clashes).
28
+ * @returns {Promise<object>} IR { cwd, projectRoot, components, warnings, report }
29
+ */
30
+ export async function loadClaude(opts = {}) {
31
+ const cwd = opts.cwd ?? process.cwd()
32
+ const homeDir = opts.homeDir ?? homedir()
33
+ const markers = opts.projectRootMarkers ?? ['.git']
34
+ const projectSkillRank = opts.projectSkillRank ?? 150
35
+ const globalSkillRank = opts.globalSkillRank ?? 160
36
+ const warnings = []
37
+
38
+ const projectRoot = await findProjectRoot(cwd, markers)
39
+ const skills = []
40
+ const commands = []
41
+ const rules = []
42
+ const agentRoots = []
43
+
44
+ if (projectRoot !== undefined) {
45
+ const claudeDir = join(projectRoot, '.claude')
46
+ const dir = await collectClaudeDir(claudeDir, 'project-claude', projectSkillRank, warnings)
47
+ skills.push(...dir.skills)
48
+ commands.push(...dir.commands)
49
+ rules.push(...await discoverRules(join(claudeDir, 'rules'), 'project'))
50
+ agentRoots.push({ root: join(claudeDir, 'agents'), scope: 'project', rank: projectSkillRank })
51
+ }
52
+
53
+ if (opts.enableGlobal !== false) {
54
+ const globalDir = opts.globalClaudeDir ?? join(homeDir, '.claude')
55
+ if (globalDir !== '' && globalDir !== '.claude') {
56
+ const dir = await collectClaudeDir(globalDir, 'user-claude', globalSkillRank, warnings)
57
+ skills.push(...dir.skills)
58
+ commands.push(...dir.commands)
59
+ rules.push(...await discoverRules(join(globalDir, 'rules'), 'user'))
60
+ agentRoots.push({ root: join(globalDir, 'agents'), scope: 'global', rank: globalSkillRank })
61
+ }
62
+ }
63
+
64
+ // Agents: merge project + global into one catalog (project wins on name clash).
65
+ const catalog = await mergeAgentCatalog(agentRoots, warnings)
66
+ warnings.push(...catalog.warnings)
67
+
68
+ // Permissions: discover the three settings files and merge.
69
+ const discovered = await discoverSettings(cwd, { homeDir, projectRootMarkers: markers })
70
+ const merged = mergeSettings(discovered)
71
+ const parsed = parseRulesFor(merged)
72
+ for (const invalid of parsed.invalid) {
73
+ warnings.push(`permission rule ignored: ${invalid.raw ?? ''} — ${invalid.reason}`)
74
+ }
75
+ const permissions = {
76
+ status: merged.status === STATUS.DIRECT ? STATUS.DIRECT : STATUS.UNSUPPORTED,
77
+ raw: { deny: merged.deny, ask: merged.ask, allow: merged.allow },
78
+ parsed,
79
+ removed: removedToolNames(parsed),
80
+ defaultMode: merged.defaultMode,
81
+ additionalDirectories: merged.additionalDirectories,
82
+ disableBypassPermissionsMode: merged.disableBypassPermissionsMode,
83
+ model: merged.model,
84
+ env: merged.env,
85
+ statusLine: merged.statusLine,
86
+ outputStyle: merged.outputStyle,
87
+ enableAllProjectMcpServers: merged.enableAllProjectMcpServers,
88
+ sources: merged.sources,
89
+ }
90
+
91
+ // MCP servers: project-root .mcp.json (CC project-level config).
92
+ const mcp = { servers: [], sources: [] }
93
+ if (projectRoot !== undefined) {
94
+ const found = await discoverProjectMcp(projectRoot, { warn: (m) => warnings.push(m) })
95
+ mcp.servers.push(...found.servers)
96
+ mcp.sources.push(...found.sources)
97
+ warnings.push(...found.warnings)
98
+ }
99
+
100
+ // LSP servers: plugin-root .lsp.json — discovered by the plugin scanner
101
+ // (M4); loadClaude itself has no plugin roots, so this stays empty here.
102
+ // discoverLspConfig() is exported for adapters that do scan plugin dirs.
103
+ const lsp = { servers: [], sources: [] }
104
+
105
+ // Plugins + marketplaces (M4): each plugin root is inventoried into its own
106
+ // IR block. Plugin components stay namespaced by plugin (components.plugins)
107
+ // and are NOT merged into the top-level skills/commands/agents — adapters
108
+ // consume them through the plugin block and apply CC's <plugin>:<name>
109
+ // namespacing themselves.
110
+ const plugins = []
111
+ const marketplaces = []
112
+ const pluginSkillRank = opts.pluginSkillRank ?? 170
113
+ for (const root of opts.pluginRoots ?? []) {
114
+ const plugin = await discoverPluginRoot(root, { skillRank: pluginSkillRank, warn: (m) => warnings.push(m) })
115
+ warnings.push(...plugin.warnings)
116
+ plugins.push(plugin)
117
+ }
118
+ for (const root of opts.marketplaceRoots ?? []) {
119
+ const mp = await discoverMarketplace(root, { warn: (m) => warnings.push(m) })
120
+ warnings.push(...mp.warnings)
121
+ marketplaces.push(mp)
122
+ }
123
+
124
+ const components = {
125
+ skills: skills.map((s) => ({ ...s, status: s.status ?? STATUS.DIRECT })),
126
+ commands: commands.map((c) => ({ ...c, status: c.status ?? STATUS.DIRECT })),
127
+ rules: rules.map((r) => ({ ...r, status: r.status ?? STATUS.DIRECT })),
128
+ agents: catalog.agents,
129
+ mcp,
130
+ lsp,
131
+ permissions,
132
+ plugins,
133
+ marketplaces,
134
+ unsupported: [], // future: workflows/monitors/themes/bin classification lands here
135
+ }
136
+
137
+ const ir = { cwd, projectRoot, components, warnings }
138
+ ir.report = classifyComponents(ir)
139
+ return ir
140
+ }
141
+
142
+ /**
143
+ * Load only the permission slice (settings.json → merged → parsed rules).
144
+ * Cheaper than loadClaude; used by the permission gate on every tool call.
145
+ * @returns {Promise<object>} { cwd, projectRoot, permissions, warnings }
146
+ */
147
+ export async function loadPermissions(opts = {}) {
148
+ const cwd = opts.cwd ?? process.cwd()
149
+ const homeDir = opts.homeDir ?? homedir()
150
+ const markers = opts.projectRootMarkers ?? ['.git']
151
+ const warnings = []
152
+ const discovered = await discoverSettings(cwd, { homeDir, projectRootMarkers: markers })
153
+ const merged = mergeSettings(discovered)
154
+ const parsed = parseRulesFor(merged)
155
+ for (const invalid of parsed.invalid) {
156
+ warnings.push(`permission rule ignored: ${invalid.raw ?? ''} — ${invalid.reason}`)
157
+ }
158
+ const permissions = {
159
+ status: merged.status === STATUS.DIRECT ? STATUS.DIRECT : STATUS.UNSUPPORTED,
160
+ raw: { deny: merged.deny, ask: merged.ask, allow: merged.allow },
161
+ parsed,
162
+ removed: removedToolNames(parsed),
163
+ defaultMode: merged.defaultMode,
164
+ additionalDirectories: merged.additionalDirectories,
165
+ disableBypassPermissionsMode: merged.disableBypassPermissionsMode,
166
+ model: merged.model,
167
+ env: merged.env,
168
+ statusLine: merged.statusLine,
169
+ outputStyle: merged.outputStyle,
170
+ enableAllProjectMcpServers: merged.enableAllProjectMcpServers,
171
+ sources: merged.sources,
172
+ }
173
+ return { cwd, projectRoot: discovered.projectRoot, permissions, warnings }
174
+ }