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.
- package/README.md +28 -0
- package/package.json +30 -0
- package/src/agents.js +296 -0
- package/src/classify.js +465 -0
- package/src/index.js +41 -0
- package/src/load.js +174 -0
- package/src/lsp.js +84 -0
- package/src/map-tools.js +122 -0
- package/src/mcp.js +218 -0
- package/src/parse-rule.js +98 -0
- package/src/patterns.js +121 -0
- package/src/plugin.js +750 -0
- package/src/settings.js +123 -0
- package/src/skills.js +250 -0
package/src/lsp.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// dsh-cc-loader — LSP server config discovery (.lsp.json) — PLACEHOLDER.
|
|
2
|
+
//
|
|
3
|
+
// Claude Code plugins may ship `.lsp.json` at the plugin root:
|
|
4
|
+
// { "<language>": { "command": "typescript-language-server", "args": ["--stdio"],
|
|
5
|
+
// "extensionToLanguage": { "tsx": "typescript" } } }
|
|
6
|
+
//
|
|
7
|
+
// The LSP adapter (dsh-cc-lsp, M3b) is deferred: the ecosystem has ~zero
|
|
8
|
+
// .lsp.json plugins today, and the bridge (mcpls) is only Windows-verified.
|
|
9
|
+
// This module still PARSES and CLASSIFIES the component so the IR inventory
|
|
10
|
+
// is complete and a future adapter has the parse layer ready. Every entry is
|
|
11
|
+
// classified UNSUPPORTED with a clear reason — never silently ignored.
|
|
12
|
+
|
|
13
|
+
import { readFile } from 'node:fs/promises'
|
|
14
|
+
import { join } from 'node:path'
|
|
15
|
+
import { pathExists } from './skills.js'
|
|
16
|
+
import { STATUS } from './classify.js'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Parse `.lsp.json` text into classified LSP entries. Every entry is
|
|
20
|
+
* UNSUPPORTED (LSP adapter deferred, M3b) — parsed for IR inventory only.
|
|
21
|
+
* @param {string} text - raw .lsp.json text.
|
|
22
|
+
* @param {string} [sourcePath] - for warning messages.
|
|
23
|
+
* @param {(msg: string) => void} [warn]
|
|
24
|
+
* @returns {object[]} entries, each { language, command?, args, extensionToLanguage?, status, reason? }
|
|
25
|
+
*/
|
|
26
|
+
export function parseLspText(text, sourcePath = '.lsp.json', warn = () => {}) {
|
|
27
|
+
let parsed
|
|
28
|
+
try {
|
|
29
|
+
parsed = JSON.parse(text)
|
|
30
|
+
} catch (error) {
|
|
31
|
+
warn(`${sourcePath}: invalid JSON: ${error.message}`)
|
|
32
|
+
return []
|
|
33
|
+
}
|
|
34
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
35
|
+
warn(`${sourcePath}: config must be a JSON object`)
|
|
36
|
+
return []
|
|
37
|
+
}
|
|
38
|
+
const servers = []
|
|
39
|
+
for (const [language, raw] of Object.entries(parsed)) {
|
|
40
|
+
const entry = {
|
|
41
|
+
language,
|
|
42
|
+
command: typeof raw?.command === 'string' ? raw.command : undefined,
|
|
43
|
+
args: Array.isArray(raw?.args) ? raw.args.map(String) : [],
|
|
44
|
+
extensionToLanguage: (typeof raw?.extensionToLanguage === 'object' && raw.extensionToLanguage !== null && !Array.isArray(raw.extensionToLanguage))
|
|
45
|
+
? raw.extensionToLanguage : undefined,
|
|
46
|
+
status: STATUS.UNSUPPORTED,
|
|
47
|
+
reason: 'LSP adapter not implemented (M3b deferred; bridge candidate mcpls)',
|
|
48
|
+
}
|
|
49
|
+
if (entry.command === undefined) {
|
|
50
|
+
warn(`${sourcePath}: language "${language}" missing "command" — recorded with reason`)
|
|
51
|
+
}
|
|
52
|
+
servers.push(entry)
|
|
53
|
+
}
|
|
54
|
+
return servers
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** @returns {Promise<{ servers: object[], sources: string[], warnings: string[] }>} */
|
|
58
|
+
export async function discoverLspConfig(dir, opts = {}) {
|
|
59
|
+
const warn = opts.warn ?? (() => {})
|
|
60
|
+
const warnings = []
|
|
61
|
+
const localWarn = (m) => { warnings.push(m); warn(m) }
|
|
62
|
+
const sources = []
|
|
63
|
+
const servers = []
|
|
64
|
+
|
|
65
|
+
const lspPath = join(dir, '.lsp.json')
|
|
66
|
+
if (!(await pathExists(lspPath))) return { servers, sources, warnings }
|
|
67
|
+
|
|
68
|
+
let text
|
|
69
|
+
try {
|
|
70
|
+
text = await readFile(lspPath, 'utf8')
|
|
71
|
+
} catch (error) {
|
|
72
|
+
warnings.push(`cannot read ${lspPath}: ${String(error)}`)
|
|
73
|
+
warn(`cannot read ${lspPath}: ${String(error)}`)
|
|
74
|
+
return { servers, sources, warnings }
|
|
75
|
+
}
|
|
76
|
+
sources.push(lspPath)
|
|
77
|
+
|
|
78
|
+
const entries = parseLspText(text, lspPath, localWarn)
|
|
79
|
+
for (const entry of entries) {
|
|
80
|
+
servers.push(entry)
|
|
81
|
+
localWarn(`LSP language "${entry.language}" (command ${entry.command ?? '?'}) — ${entry.reason}`)
|
|
82
|
+
}
|
|
83
|
+
return { servers, sources, warnings }
|
|
84
|
+
}
|
package/src/map-tools.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Map DSH tool names to Claude Code tool buckets.
|
|
2
|
+
//
|
|
3
|
+
// CC permission rules are written against CC tool names (Bash, Read, Edit,
|
|
4
|
+
// WebFetch, Agent, Skill, MCP servers…). DSH tools use their own names
|
|
5
|
+
// (bash, pwsh, read, write, glob, grep, web_fetch, subagent, terminal_*…).
|
|
6
|
+
// This module owns the mapping in both directions.
|
|
7
|
+
//
|
|
8
|
+
// Verified against DSH's shipped tool inventory (packages/core/tools
|
|
9
|
+
// gen-tool-catalog) and CC permissions reference (code.claude.com/docs/permissions).
|
|
10
|
+
|
|
11
|
+
/** Exact-name mapping: DSH tool name → CC bucket. */
|
|
12
|
+
const BUCKET_BY_TOOL = Object.freeze({
|
|
13
|
+
bash: 'Bash',
|
|
14
|
+
pwsh: 'PowerShell',
|
|
15
|
+
read: 'Read',
|
|
16
|
+
read_image: 'Read',
|
|
17
|
+
write: 'Write',
|
|
18
|
+
edit: 'Edit',
|
|
19
|
+
str_replace_editor: 'Edit',
|
|
20
|
+
glob: 'Glob',
|
|
21
|
+
grep: 'Grep',
|
|
22
|
+
web_fetch: 'WebFetch',
|
|
23
|
+
subagent: 'Agent',
|
|
24
|
+
skill: 'Skill',
|
|
25
|
+
ask_user_question: 'AskUserQuestion',
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
/** Tools CC's Read rules best-effort cover (read-like built-in tools). */
|
|
29
|
+
const READ_COVERED = Object.freeze(['read', 'read_image', 'glob', 'grep'])
|
|
30
|
+
/** Tools CC's Edit rules cover (write/edit-like built-in tools). */
|
|
31
|
+
const EDIT_COVERED = Object.freeze(['edit', 'str_replace_editor', 'write'])
|
|
32
|
+
|
|
33
|
+
/** Bash file commands CC's Read deny rules intercept (cat, head, tail, sed…). */
|
|
34
|
+
const BASH_READ_COMMANDS = new Set([
|
|
35
|
+
'cat', 'head', 'tail', 'sed', 'less', 'more', 'nl', 'od', 'strings', 'tac', 'wc',
|
|
36
|
+
])
|
|
37
|
+
/** Bash file commands CC's Edit deny rules intercept (write-capable). */
|
|
38
|
+
const BASH_WRITE_COMMANDS = new Set([
|
|
39
|
+
'sed', 'awk', 'tee', 'dd', 'cp', 'mv', 'rm', 'truncate', 'touch', 'mkdir', 'ln',
|
|
40
|
+
])
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Map one DSH tool name to the CC bucket its rules were written for.
|
|
44
|
+
* MCP tools are their own bucket (rule tool names are full `mcp__…` names).
|
|
45
|
+
* @returns {string|null} CC bucket name, or null when unmappable (rules may
|
|
46
|
+
* still match by exact DSH tool name).
|
|
47
|
+
*/
|
|
48
|
+
export function ccBucket(toolName) {
|
|
49
|
+
if (typeof toolName !== 'string') return null
|
|
50
|
+
if (toolName.startsWith('mcp__')) return toolName
|
|
51
|
+
if (toolName.startsWith('terminal_')) return 'Bash'
|
|
52
|
+
return BUCKET_BY_TOOL[toolName] ?? null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Whether a rule's tool target (CC bucket or exact name, may contain `*`)
|
|
57
|
+
* denotes `toolName`. Used for bare rules and tool-glob rules.
|
|
58
|
+
*/
|
|
59
|
+
export function ruleTargetsTool(ruleTool, toolName) {
|
|
60
|
+
if (ruleTool.includes('*')) {
|
|
61
|
+
let out = ''
|
|
62
|
+
for (const ch of ruleTool) out += ch === '*' ? '.*' : ch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
63
|
+
return new RegExp(`^${out}$`).test(toolName)
|
|
64
|
+
}
|
|
65
|
+
if (ruleTool.startsWith('mcp__')) {
|
|
66
|
+
// `mcp__server` matches every tool of that server (`mcp__server__tool`).
|
|
67
|
+
return toolName === ruleTool || toolName.startsWith(`${ruleTool}__`)
|
|
68
|
+
}
|
|
69
|
+
const bucket = ccBucket(toolName)
|
|
70
|
+
return ruleTool === toolName || (bucket !== null && ruleTool === bucket)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Is `toolName` in the set CC's `Read` rules cover (incl. read-like tools)? */
|
|
74
|
+
export function isReadCoveredTool(toolName) {
|
|
75
|
+
return READ_COVERED.includes(toolName)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Is `toolName` in the set CC's `Edit` rules cover (incl. write tools)? */
|
|
79
|
+
export function isEditCoveredTool(toolName) {
|
|
80
|
+
return EDIT_COVERED.includes(toolName)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Is this a Bash file command whose paths CC's Read deny rules intercept? */
|
|
84
|
+
export function isBashReadCommand(command) {
|
|
85
|
+
return BASH_READ_COMMANDS.has(firstWord(command))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Is this a Bash file command whose paths CC's Edit deny rules intercept? */
|
|
89
|
+
export function isBashWriteCommand(command) {
|
|
90
|
+
return BASH_WRITE_COMMANDS.has(firstWord(command))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Extract candidate file paths from a Bash command's arguments, skipping
|
|
95
|
+
* flags. Best-effort, mirroring CC's "file commands Claude Code recognizes
|
|
96
|
+
* in Bash".
|
|
97
|
+
*/
|
|
98
|
+
export function extractBashPaths(command) {
|
|
99
|
+
if (typeof command !== 'string') return []
|
|
100
|
+
const tokens = command.split(/\s+/).filter((t) => t.length > 0)
|
|
101
|
+
const out = []
|
|
102
|
+
let skipNext = false
|
|
103
|
+
for (const tok of tokens) {
|
|
104
|
+
if (skipNext) { skipNext = false; continue }
|
|
105
|
+
if (tok === '--') continue
|
|
106
|
+
if (tok.startsWith('-') && tok.length > 1) {
|
|
107
|
+
// Flags that take a value consume the next token (`-n 5`); inline
|
|
108
|
+
// values (`--count=5`) already carry theirs.
|
|
109
|
+
if (/^-[a-zA-Z](?!=)/.test(tok) && !tok.includes('=')) skipNext = true
|
|
110
|
+
continue
|
|
111
|
+
}
|
|
112
|
+
out.push(tok)
|
|
113
|
+
}
|
|
114
|
+
return out
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** First whitespace-separated word of a command string. */
|
|
118
|
+
function firstWord(command) {
|
|
119
|
+
if (typeof command !== 'string') return ''
|
|
120
|
+
const w = command.trim().split(/\s+/)[0]
|
|
121
|
+
return w ?? ''
|
|
122
|
+
}
|
package/src/mcp.js
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// dsh-cc-loader — MCP server config discovery (.mcp.json / plugin.json inline).
|
|
2
|
+
//
|
|
3
|
+
// Claude Code surfaces MCP servers in three places, all sharing the mcpServers
|
|
4
|
+
// JSON shape used by Cursor and VS Code:
|
|
5
|
+
// 1. Project root .mcp.json — { "mcpServers": { "<name>": {...} } }
|
|
6
|
+
// 2. Plugin root .mcp.json — official plugin form is a BARE server
|
|
7
|
+
// map { "<name>": {...} }; community plugins also wrap in mcpServers
|
|
8
|
+
// 3. plugin.json inline — { ..., "mcpServers": { "<name>": {...} } }
|
|
9
|
+
//
|
|
10
|
+
// This module accepts BOTH the bare-map and the mcpServers-wrapped forms
|
|
11
|
+
// everywhere (detected structurally), so the same code covers project and
|
|
12
|
+
// plugin sources. It is a pure parse layer: nothing is written, env values
|
|
13
|
+
// are NEVER inlined — `${NAME}` placeholders are kept verbatim and the names
|
|
14
|
+
// they reference are recorded (envNames) for the adapter to bind at runtime.
|
|
15
|
+
//
|
|
16
|
+
// Classification (same vocabulary as the rest of the loader):
|
|
17
|
+
// DIRECT — stdio (command) and http (url) transports DSH can bridge
|
|
18
|
+
// UNSUPPORTED — sse / ws transports the DSH MCP client does not speak
|
|
19
|
+
// INVALID — shape errors (skipped with a warning, never fatal)
|
|
20
|
+
|
|
21
|
+
import { readFile } from 'node:fs/promises'
|
|
22
|
+
import { join } from 'node:path'
|
|
23
|
+
import { pathExists } from './skills.js'
|
|
24
|
+
import { STATUS } from './classify.js'
|
|
25
|
+
|
|
26
|
+
/** CC server names are simple identifiers; same bound as dsh-mcp-client. */
|
|
27
|
+
export const VALID_SERVER_NAME = /^[A-Za-z0-9_-]{1,32}$/
|
|
28
|
+
/** Environment placeholders CC expands: ${NAME}. */
|
|
29
|
+
export const ENV_PLACEHOLDER = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Parse .mcp.json text into the raw servers map, accepting both forms:
|
|
33
|
+
* { "mcpServers": {...} } (wrapped; project-level + community plugins)
|
|
34
|
+
* { "<name>": {...} } (bare; official plugin form)
|
|
35
|
+
* @returns {Record<string, unknown>} servers map
|
|
36
|
+
* @throws {Error} invalid JSON / missing map
|
|
37
|
+
*/
|
|
38
|
+
export function parseMcpText(text) {
|
|
39
|
+
let parsed
|
|
40
|
+
try {
|
|
41
|
+
parsed = JSON.parse(text)
|
|
42
|
+
} catch (error) {
|
|
43
|
+
throw new Error(`invalid JSON: ${error.message}`)
|
|
44
|
+
}
|
|
45
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
46
|
+
throw new Error('config must be a JSON object')
|
|
47
|
+
}
|
|
48
|
+
if (typeof parsed.mcpServers === 'object' && parsed.mcpServers !== null && !Array.isArray(parsed.mcpServers)) {
|
|
49
|
+
return parsed.mcpServers
|
|
50
|
+
}
|
|
51
|
+
// Bare map form: every value is a server config object (no "mcpServers" key).
|
|
52
|
+
const values = Object.values(parsed)
|
|
53
|
+
if (values.some((v) => typeof v !== 'object' || v === null || Array.isArray(v))) {
|
|
54
|
+
throw new Error('expected "mcpServers" object or a bare server map')
|
|
55
|
+
}
|
|
56
|
+
return parsed
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Normalize one servers map into ordered, classified server entries.
|
|
61
|
+
* Invalid entries are skipped with warnings (never thrown).
|
|
62
|
+
* @param {Record<string, unknown>} servers - raw map from parseMcpText
|
|
63
|
+
* @param {object} [opts] - { pluginName?, warn? }
|
|
64
|
+
* @returns {object[]} entries, each:
|
|
65
|
+
* { serverName, pluginName?, transport: 'stdio'|'http'|'sse'|'ws',
|
|
66
|
+
* command?, args?, cwd?, env (placeholders kept), envNames: string[],
|
|
67
|
+
* url?, headers?, toolCallTimeoutMs?, idleTimeoutMs?, override?,
|
|
68
|
+
* status, reason? }
|
|
69
|
+
*/
|
|
70
|
+
export function serverEntries(servers, opts = {}) {
|
|
71
|
+
const warn = opts.warn ?? (() => {})
|
|
72
|
+
const entries = []
|
|
73
|
+
for (const [serverName, raw] of Object.entries(servers)) {
|
|
74
|
+
const base = { serverName, pluginName: opts.pluginName }
|
|
75
|
+
if (!VALID_SERVER_NAME.test(serverName)) {
|
|
76
|
+
warn(`MCP server name ${JSON.stringify(serverName)} invalid (need 1-32 of [A-Za-z0-9_-]) — skipped`)
|
|
77
|
+
continue
|
|
78
|
+
}
|
|
79
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
80
|
+
warn(`MCP server ${serverName}: config must be an object — skipped`)
|
|
81
|
+
continue
|
|
82
|
+
}
|
|
83
|
+
const hasCommand = typeof raw.command === 'string' && raw.command.length > 0
|
|
84
|
+
const hasUrl = typeof raw.url === 'string' && raw.url.length > 0
|
|
85
|
+
if (hasCommand && hasUrl) {
|
|
86
|
+
warn(`MCP server ${serverName}: provide exactly one of "command" (stdio) or "url" (remote) — skipped`)
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
if (!hasCommand && !hasUrl) {
|
|
90
|
+
warn(`MCP server ${serverName}: missing both "command" and "url" — skipped`)
|
|
91
|
+
continue
|
|
92
|
+
}
|
|
93
|
+
const type = typeof raw.type === 'string' ? raw.type.toLowerCase() : undefined
|
|
94
|
+
let transport
|
|
95
|
+
if (hasCommand) transport = 'stdio'
|
|
96
|
+
else if (type === 'sse') transport = 'sse'
|
|
97
|
+
else if (type === 'ws' || type === 'websocket') transport = 'ws'
|
|
98
|
+
else transport = 'http' // url without type, or type http → streamable-http
|
|
99
|
+
|
|
100
|
+
// env: keep placeholders verbatim, record referenced names only.
|
|
101
|
+
const env = {}
|
|
102
|
+
const envNames = []
|
|
103
|
+
if (raw.env !== undefined) {
|
|
104
|
+
if (typeof raw.env !== 'object' || raw.env === null || Array.isArray(raw.env)) {
|
|
105
|
+
warn(`MCP server ${serverName}: env must be an object — skipped`)
|
|
106
|
+
continue
|
|
107
|
+
}
|
|
108
|
+
for (const [k, v] of Object.entries(raw.env)) {
|
|
109
|
+
const value = String(v)
|
|
110
|
+
env[k] = value
|
|
111
|
+
for (const match of value.matchAll(ENV_PLACEHOLDER)) {
|
|
112
|
+
if (!envNames.includes(match[1])) envNames.push(match[1])
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const headers = {}
|
|
117
|
+
if (raw.headers !== undefined) {
|
|
118
|
+
if (typeof raw.headers !== 'object' || raw.headers === null || Array.isArray(raw.headers)) {
|
|
119
|
+
warn(`MCP server ${serverName}: headers must be an object — skipped`)
|
|
120
|
+
continue
|
|
121
|
+
}
|
|
122
|
+
for (const [k, v] of Object.entries(raw.headers)) headers[k] = String(v)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const entry = {
|
|
126
|
+
...base,
|
|
127
|
+
transport,
|
|
128
|
+
command: hasCommand ? raw.command : undefined,
|
|
129
|
+
args: Array.isArray(raw.args) ? raw.args.map(String) : [],
|
|
130
|
+
cwd: typeof raw.cwd === 'string' && raw.cwd.length > 0 ? raw.cwd : undefined,
|
|
131
|
+
env,
|
|
132
|
+
envNames,
|
|
133
|
+
url: hasUrl ? raw.url : undefined,
|
|
134
|
+
headers,
|
|
135
|
+
toolCallTimeoutMs: Number.isFinite(raw.toolCallTimeoutMs) ? raw.toolCallTimeoutMs : undefined,
|
|
136
|
+
idleTimeoutMs: Number.isFinite(raw.idleTimeoutMs) ? raw.idleTimeoutMs : undefined,
|
|
137
|
+
override: raw.override === true,
|
|
138
|
+
status: transport === 'stdio' || transport === 'http' ? STATUS.DIRECT : STATUS.UNSUPPORTED,
|
|
139
|
+
reason: transport === 'stdio' || transport === 'http' ? undefined
|
|
140
|
+
: `transport "${transport}" is not supported by the DSH MCP client (stdio/streamable-http only)`,
|
|
141
|
+
}
|
|
142
|
+
entries.push(entry)
|
|
143
|
+
}
|
|
144
|
+
return entries
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Discover MCP config from one directory: `.mcp.json` first, then plugin.json
|
|
149
|
+
* inline `mcpServers` (unless opts.includePluginJson === false). Both parse
|
|
150
|
+
* under the same dual-form parser.
|
|
151
|
+
* @param {string} dir - project root or plugin root
|
|
152
|
+
* @param {object} [opts] - { pluginName?, includePluginJson?, warn? }
|
|
153
|
+
* @returns {Promise<{ servers: object[], sources: string[], warnings: string[] }>}
|
|
154
|
+
*/
|
|
155
|
+
export async function discoverMcpConfig(dir, opts = {}) {
|
|
156
|
+
const warn = opts.warn ?? (() => {})
|
|
157
|
+
const includePluginJson = opts.includePluginJson !== false
|
|
158
|
+
const warnings = []
|
|
159
|
+
const localWarn = (m) => { warnings.push(m); warn(m) }
|
|
160
|
+
const sources = []
|
|
161
|
+
const servers = []
|
|
162
|
+
|
|
163
|
+
const mcpPath = join(dir, '.mcp.json')
|
|
164
|
+
if (await pathExists(mcpPath)) {
|
|
165
|
+
let text
|
|
166
|
+
try {
|
|
167
|
+
text = await readFile(mcpPath, 'utf8')
|
|
168
|
+
} catch (error) {
|
|
169
|
+
warnings.push(`cannot read ${mcpPath}: ${String(error)}`)
|
|
170
|
+
warn(`cannot read ${mcpPath}: ${String(error)}`)
|
|
171
|
+
}
|
|
172
|
+
if (text !== undefined) {
|
|
173
|
+
try {
|
|
174
|
+
const map = parseMcpText(text)
|
|
175
|
+
servers.push(...serverEntries(map, { pluginName: opts.pluginName, warn: localWarn }))
|
|
176
|
+
sources.push(mcpPath)
|
|
177
|
+
} catch (error) {
|
|
178
|
+
warnings.push(`${mcpPath}: ${error.message}`)
|
|
179
|
+
warn(`${mcpPath}: ${error.message}`)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const pluginJsonPath = join(dir, 'plugin.json')
|
|
185
|
+
if (includePluginJson && await pathExists(pluginJsonPath)) {
|
|
186
|
+
let text
|
|
187
|
+
try {
|
|
188
|
+
text = await readFile(pluginJsonPath, 'utf8')
|
|
189
|
+
} catch (error) {
|
|
190
|
+
warnings.push(`cannot read ${pluginJsonPath}: ${String(error)}`)
|
|
191
|
+
warn(`cannot read ${pluginJsonPath}: ${String(error)}`)
|
|
192
|
+
}
|
|
193
|
+
if (text !== undefined) {
|
|
194
|
+
let parsed
|
|
195
|
+
try {
|
|
196
|
+
parsed = JSON.parse(text)
|
|
197
|
+
} catch (error) {
|
|
198
|
+
warnings.push(`${pluginJsonPath}: invalid JSON: ${error.message}`)
|
|
199
|
+
warn(`${pluginJsonPath}: invalid JSON: ${error.message}`)
|
|
200
|
+
}
|
|
201
|
+
if (parsed && typeof parsed.mcpServers === 'object' && parsed.mcpServers !== null && !Array.isArray(parsed.mcpServers)) {
|
|
202
|
+
servers.push(...serverEntries(parsed.mcpServers, { pluginName: opts.pluginName, warn: localWarn }))
|
|
203
|
+
sources.push(pluginJsonPath)
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return { servers, sources, warnings }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Project-level MCP: `<projectRoot>/.mcp.json` (plugin.json is a
|
|
213
|
+
* plugin-package concern, excluded here).
|
|
214
|
+
* @returns {Promise<{ servers: object[], sources: string[], warnings: string[] }>}
|
|
215
|
+
*/
|
|
216
|
+
export async function discoverProjectMcp(projectRoot, opts = {}) {
|
|
217
|
+
return discoverMcpConfig(projectRoot, { ...opts, includePluginJson: false })
|
|
218
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// CC permission rule syntax parser: `Tool` or `Tool(specifier)`.
|
|
2
|
+
//
|
|
3
|
+
// Verified against code.claude.com/docs/permissions ("Permission rule syntax",
|
|
4
|
+
// "Match by input parameter", "Tool name wildcards", per-tool rules).
|
|
5
|
+
// A rule is parsed into a structured object with a `kind`:
|
|
6
|
+
// bare — `Bash` / `Bash(*)` / any tool without specifier
|
|
7
|
+
// tool-glob — glob in tool-name position (`mcp__*`, `B*`…)
|
|
8
|
+
// command — Bash/PowerShell command pattern
|
|
9
|
+
// path — Read/Edit/Cd gitignore path pattern
|
|
10
|
+
// domain — WebFetch `domain:…`
|
|
11
|
+
// agent-name — `Agent(Name)` subagent restriction
|
|
12
|
+
// param — `Tool(param:value)` top-level input-parameter match
|
|
13
|
+
// unknown — spec we cannot faithfully evaluate (reported, not enforced)
|
|
14
|
+
|
|
15
|
+
import { compileCommandPattern, compilePathPattern, compileDomainPattern } from './patterns.js'
|
|
16
|
+
|
|
17
|
+
const TOOL_RE = /^([A-Za-z_*][A-Za-z0-9_*]*(?:__[A-Za-z0-9_*:-]+)*)(?:\(([^)]*)\))?$/
|
|
18
|
+
|
|
19
|
+
/** Tool input fields that cannot be matched via `Tool(param:value)` (CC warns and ignores). */
|
|
20
|
+
const PRIMARY_FIELDS = new Set(['command', 'file_path', 'path', 'url', 'notebook_path'])
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Parse one CC rule string.
|
|
24
|
+
* @param {string} raw
|
|
25
|
+
* @param {{ scope?: string, path?: string }} [meta] - source scope/file for reporting.
|
|
26
|
+
* @returns {object|null} structured rule, or null when unparseable.
|
|
27
|
+
*/
|
|
28
|
+
export function parseRule(raw, meta = {}) {
|
|
29
|
+
const text = String(raw).trim()
|
|
30
|
+
const m = TOOL_RE.exec(text)
|
|
31
|
+
if (!m) return { raw: text, tool: null, spec: null, kind: 'unknown', invalid: true, reason: `unparseable rule "${text}"`, ...meta }
|
|
32
|
+
const tool = m[1]
|
|
33
|
+
const spec = m[2] === undefined ? null : m[2].trim()
|
|
34
|
+
const base = { raw: text, tool, spec, scope: meta.scope, path: meta.path }
|
|
35
|
+
|
|
36
|
+
// Tool-name glob (`*`, `mcp__*`, `mcp__server__*`, `B*`).
|
|
37
|
+
if (tool.includes('*')) return { ...base, kind: 'tool-glob', glob: compileToolGlob(tool) }
|
|
38
|
+
|
|
39
|
+
if (spec === null || spec === '') return { ...base, kind: 'bare' }
|
|
40
|
+
|
|
41
|
+
// `Tool(*)` on Bash/PowerShell is equivalent to a bare rule.
|
|
42
|
+
if ((tool === 'Bash' || tool === 'PowerShell') && (spec === '*' || spec === ':*')) {
|
|
43
|
+
return { ...base, kind: 'bare' }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Tool-specific specifiers FIRST so they win over the generic param:value
|
|
47
|
+
// shape: `Bash(ls:*)` is a command pattern (the `:*` trailing suffix), and
|
|
48
|
+
// `WebFetch(domain:…)` is a domain rule — neither is a param match.
|
|
49
|
+
if (tool === 'Bash' || tool === 'PowerShell') {
|
|
50
|
+
if (spec.endsWith(':*')) {
|
|
51
|
+
return { ...base, kind: 'command', command: compileCommandPattern(spec, { icase: tool === 'PowerShell' }), icase: tool === 'PowerShell' }
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (tool === 'WebFetch' && spec.startsWith('domain:')) {
|
|
55
|
+
return { ...base, kind: 'domain', domain: spec.slice(7), domainRe: compileDomainPattern(spec.slice(7)) }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// param:value — before remaining tool-specific handling so `Agent(model:opus)`
|
|
59
|
+
// and `Bash(run_in_background:true)` parse as param rules.
|
|
60
|
+
const pm = /^([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.*)$/.exec(spec)
|
|
61
|
+
if (pm) {
|
|
62
|
+
const param = pm[1]
|
|
63
|
+
const value = pm[2]
|
|
64
|
+
if (PRIMARY_FIELDS.has(param)) {
|
|
65
|
+
return { ...base, kind: 'unknown', invalid: true, reason: `cannot match primary field via param syntax (${tool}(${param}:…))` }
|
|
66
|
+
}
|
|
67
|
+
return { ...base, kind: 'param', param, value }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
switch (tool) {
|
|
71
|
+
case 'WebFetch':
|
|
72
|
+
return { ...base, kind: 'unknown', invalid: true, reason: `WebFetch rules require a domain: specifier` }
|
|
73
|
+
case 'Agent':
|
|
74
|
+
// Agent(AgentName) restricts a subagent by name.
|
|
75
|
+
return { ...base, kind: 'agent-name', name: spec }
|
|
76
|
+
case 'Skill':
|
|
77
|
+
// Skill(name) restricts a skill invocation by name.
|
|
78
|
+
return { ...base, kind: 'skill-name', name: spec }
|
|
79
|
+
case 'Read':
|
|
80
|
+
case 'Edit':
|
|
81
|
+
case 'Cd':
|
|
82
|
+
return { ...base, kind: 'path', pathPattern: compilePathPattern(spec) }
|
|
83
|
+
case 'Bash':
|
|
84
|
+
case 'PowerShell':
|
|
85
|
+
return { ...base, kind: 'command', command: compileCommandPattern(spec, { icase: tool === 'PowerShell' }), icase: tool === 'PowerShell' }
|
|
86
|
+
default:
|
|
87
|
+
// A spec on an unknown tool: CC only supports param:value there (handled
|
|
88
|
+
// above) or tool-specific syntax we do not know — report, don't enforce.
|
|
89
|
+
return { ...base, kind: 'unknown', invalid: true, reason: `unsupported specifier on tool ${tool}` }
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function compileToolGlob(tool) {
|
|
94
|
+
// Full-name match; `*` spans everything (CC: pattern must match the full tool name).
|
|
95
|
+
let out = ''
|
|
96
|
+
for (const ch of tool) out += ch === '*' ? '.*' : ch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
97
|
+
return new RegExp(`^${out}$`)
|
|
98
|
+
}
|
package/src/patterns.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// Glob pattern compilation for CC permission rules.
|
|
2
|
+
//
|
|
3
|
+
// Three pattern dialects:
|
|
4
|
+
// - Command (Bash/PowerShell): `*` spans any chars incl. spaces; a trailing
|
|
5
|
+
// `:*` is equivalent to a trailing ` *`; a trailing ` *` (space-star)
|
|
6
|
+
// requires the prefix to be followed by a space or end-of-string.
|
|
7
|
+
// - Path (Read/Edit/Cd): gitignore-style. `**` spans directories, `*` spans
|
|
8
|
+
// one path segment, `?` one char, `[...]` char classes. Anchors: `//` =
|
|
9
|
+
// filesystem root, `~/` = home, `/` = relative to the settings source,
|
|
10
|
+
// `./` or bare = relative to cwd.
|
|
11
|
+
// - Domain (WebFetch): `*` matches text between dots (a leading `*.` spans
|
|
12
|
+
// any subdomain depth; a bare `*` matches everything).
|
|
13
|
+
|
|
14
|
+
/** Escape a literal string for use inside a RegExp. */
|
|
15
|
+
export function escapeRegExp(s) {
|
|
16
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Translate a glob to a regex body (no anchors).
|
|
21
|
+
* @param {string} pattern
|
|
22
|
+
* @param {{ segment?: string|null }} [opts] - segment separator: '/' for
|
|
23
|
+
* paths, '.' for domains, null for commands (`*` then spans everything).
|
|
24
|
+
*/
|
|
25
|
+
export function globToRegexBody(pattern, { segment = '/' } = {}) {
|
|
26
|
+
let out = ''
|
|
27
|
+
let i = 0
|
|
28
|
+
const seg = segment === null ? null : segment
|
|
29
|
+
while (i < pattern.length) {
|
|
30
|
+
const ch = pattern[i]
|
|
31
|
+
if (ch === '*') {
|
|
32
|
+
if (pattern[i + 1] === '*') { out += '.*'; i += 2; continue }
|
|
33
|
+
out += seg === null ? '.*' : `[^${seg}]*`
|
|
34
|
+
i++
|
|
35
|
+
} else if (ch === '?') {
|
|
36
|
+
out += seg === null ? '.' : `[^${seg}]`
|
|
37
|
+
i++
|
|
38
|
+
} else if (ch === '[') {
|
|
39
|
+
const close = pattern.indexOf(']', i + 1)
|
|
40
|
+
if (close > 0) { out += pattern.slice(i, close + 1); i = close + 1 }
|
|
41
|
+
else { out += '\\['; i++ }
|
|
42
|
+
} else {
|
|
43
|
+
out += escapeRegExp(ch)
|
|
44
|
+
i++
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return out
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Compile a Bash/PowerShell command pattern.
|
|
52
|
+
*
|
|
53
|
+
* CC semantics for a trailing ` *` (space-star, or the `:*` suffix): it
|
|
54
|
+
* matches commands STARTING WITH the prefix — with or without following
|
|
55
|
+
* arguments — while still enforcing a word boundary (a rule `ls *` matches
|
|
56
|
+
* `ls`, `ls -la`, but not `lsof`).
|
|
57
|
+
* @param {string} pattern
|
|
58
|
+
* @param {{ icase?: boolean }} [opts] - PowerShell matching is case-insensitive.
|
|
59
|
+
*/
|
|
60
|
+
export function compileCommandPattern(pattern, { icase = false } = {}) {
|
|
61
|
+
let p = pattern
|
|
62
|
+
if (p.endsWith(':*')) p = p.slice(0, -2) + ' *'
|
|
63
|
+
let trailing = false
|
|
64
|
+
if (p.endsWith(' *')) { trailing = true; p = p.slice(0, -2) }
|
|
65
|
+
const body = globToRegexBody(p, { segment: null })
|
|
66
|
+
const re = trailing
|
|
67
|
+
? new RegExp(`^${body}(?: .*)?$`, icase ? 'i' : undefined)
|
|
68
|
+
: new RegExp(`^${body}$`, icase ? 'i' : undefined)
|
|
69
|
+
return re
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Compile a Read/Edit/Cd path pattern into its anchored shape.
|
|
74
|
+
* @returns {{ kind: 'absolute'|'home'|'source'|'cwd', re: RegExp,
|
|
75
|
+
* singleSegment: boolean, prefixAny: boolean }}
|
|
76
|
+
*/
|
|
77
|
+
export function compilePathPattern(pattern) {
|
|
78
|
+
let p = pattern
|
|
79
|
+
let kind = 'cwd'
|
|
80
|
+
if (p.startsWith('//')) { kind = 'absolute'; p = p.slice(2) }
|
|
81
|
+
else if (p.startsWith('~/')) { kind = 'home'; p = p.slice(2) }
|
|
82
|
+
else if (p.startsWith('/')) { kind = 'source'; p = p.slice(1) }
|
|
83
|
+
else if (p.startsWith('./')) { kind = 'cwd'; p = p.slice(2) }
|
|
84
|
+
let trailingAny = false
|
|
85
|
+
if (p.endsWith('/**')) { trailingAny = true; p = p.slice(0, -3) }
|
|
86
|
+
let prefixAny = false
|
|
87
|
+
if (p.startsWith('**/')) { prefixAny = true; p = p.slice(3) }
|
|
88
|
+
const singleSegment = kind === 'cwd' && !p.includes('/')
|
|
89
|
+
const body = globToRegexBody(p, { segment: '/' })
|
|
90
|
+
// Absolute patterns match the full POSIX path, so keep the leading slash.
|
|
91
|
+
const head = kind === 'absolute' ? '/' : ''
|
|
92
|
+
const re = trailingAny ? new RegExp(`^${head}${body}(?:/.*)?$`) : new RegExp(`^${head}${body}$`)
|
|
93
|
+
return { kind, re, singleSegment, prefixAny }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Compile a WebFetch domain pattern.
|
|
98
|
+
* @param {string} domain - the text after `domain:`.
|
|
99
|
+
*/
|
|
100
|
+
export function compileDomainPattern(domain) {
|
|
101
|
+
const d = String(domain).trim().toLowerCase().replace(/\.$/, '')
|
|
102
|
+
if (d === '*') return /.*/
|
|
103
|
+
if (d.startsWith('*.')) {
|
|
104
|
+
const rest = escapeRegExp(d.slice(2))
|
|
105
|
+
// any subdomain at any depth, but not the bare domain
|
|
106
|
+
return new RegExp(`^(.+\\.)+${rest}$`)
|
|
107
|
+
}
|
|
108
|
+
const body = globToRegexBody(d, { segment: '.' })
|
|
109
|
+
return new RegExp(`^${body}$`)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Normalize a Windows path to POSIX form for path-rule matching
|
|
114
|
+
* (`C:\Users\alice` → `/c/Users/alice`), mirroring CC's Windows behavior.
|
|
115
|
+
*/
|
|
116
|
+
export function winPathToPosix(p) {
|
|
117
|
+
if (typeof p !== 'string') return p
|
|
118
|
+
if (!/^[A-Za-z]:[\\/]/.test(p)) return p.replace(/\\/g, '/')
|
|
119
|
+
const drive = p[0].toLowerCase()
|
|
120
|
+
return `/${drive}${p.slice(2).replace(/\\/g, '/')}`
|
|
121
|
+
}
|