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/settings.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// settings.json discovery + scope merge for permission rules.
|
|
2
|
+
//
|
|
3
|
+
// CC scopes: managed > CLI > local > project > user. We discover user
|
|
4
|
+
// (~/.claude/settings.json), project (<root>/.claude/settings.json) and local
|
|
5
|
+
// (<root>/.claude/settings.local.json); managed is out of scope for now
|
|
6
|
+
// (reported, not enforced). Rules arrays are concatenated user → project →
|
|
7
|
+
// local; evaluation order deny → ask → allow is enforced by the evaluator,
|
|
8
|
+
// not by array order.
|
|
9
|
+
|
|
10
|
+
import { join } from 'node:path'
|
|
11
|
+
import { homedir } from 'node:os'
|
|
12
|
+
import { findProjectRoot, pathExists, readTextSafe } from './skills.js'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Discover the three settings files for a cwd.
|
|
16
|
+
* @param {string} cwd
|
|
17
|
+
* @param {{ homeDir?: string, projectRootMarkers?: string[] }} [opts]
|
|
18
|
+
* @returns {Promise<{ projectRoot?: string, user?: Source, project?: Source, local?: Source }>}
|
|
19
|
+
* Source = { scope, path, data } or undefined when absent/unreadable.
|
|
20
|
+
*/
|
|
21
|
+
export async function discoverSettings(cwd, opts = {}) {
|
|
22
|
+
const homeDir = opts.homeDir ?? homedir()
|
|
23
|
+
const projectRoot = await findProjectRoot(cwd, opts.projectRootMarkers)
|
|
24
|
+
const userPath = join(homeDir, '.claude', 'settings.json')
|
|
25
|
+
const out = {}
|
|
26
|
+
if (projectRoot !== undefined) {
|
|
27
|
+
out.projectRoot = projectRoot
|
|
28
|
+
const projectPath = join(projectRoot, '.claude', 'settings.json')
|
|
29
|
+
const localPath = join(projectRoot, '.claude', 'settings.local.json')
|
|
30
|
+
out.project = await readSource('project', projectPath)
|
|
31
|
+
out.local = await readSource('local', localPath)
|
|
32
|
+
}
|
|
33
|
+
out.user = await readSource('user', userPath)
|
|
34
|
+
return out
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function readSource(scope, path) {
|
|
38
|
+
if (!(await pathExists(path))) return undefined
|
|
39
|
+
const raw = await readTextSafe(path)
|
|
40
|
+
if (raw === undefined) return undefined
|
|
41
|
+
let data
|
|
42
|
+
try { data = JSON.parse(raw) } catch (error) {
|
|
43
|
+
return { scope, path, data: null, error: `invalid JSON: ${String(error)}` }
|
|
44
|
+
}
|
|
45
|
+
if (typeof data !== 'object' || data === null || Array.isArray(data)) {
|
|
46
|
+
return { scope, path, data: null, error: 'settings root must be an object' }
|
|
47
|
+
}
|
|
48
|
+
return { scope, path, data }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Merge discovered settings into one permissions IR block.
|
|
53
|
+
* Rule arrays are concatenated across scopes; each rule keeps its source scope.
|
|
54
|
+
* @returns {object} IR permissions component:
|
|
55
|
+
* { status, allow: Rule[], deny: Rule[], ask: Rule[], defaultMode,
|
|
56
|
+
* additionalDirectories: string[], disableBypassPermissionsMode,
|
|
57
|
+
* model, env, statusLine, outputStyle, enableAllProjectMcpServers,
|
|
58
|
+
* sources: Source[], warnings: string[] }
|
|
59
|
+
*
|
|
60
|
+
* The extra keys (model/env/statusLine/outputStyle/enableAllProjectMcpServers)
|
|
61
|
+
* are parsed into the IR for the compatibility report only — this is a
|
|
62
|
+
* read-only bridge and DSH never rewrites .claude. The only enforcement is
|
|
63
|
+
* permissions folding (allow/deny/ask) plus the flags below. `model` maps to a
|
|
64
|
+
* default-model hint, `enableAllProjectMcpServers` to a project-MCP approval
|
|
65
|
+
* hint; both are surfaced to adapters, never forced.
|
|
66
|
+
*/
|
|
67
|
+
export function mergeSettings(discovered) {
|
|
68
|
+
const sources = [discovered.user, discovered.project, discovered.local].filter(Boolean)
|
|
69
|
+
const warnings = []
|
|
70
|
+
const perm = {
|
|
71
|
+
allow: [], deny: [], ask: [], warnings, sources,
|
|
72
|
+
defaultMode: undefined, additionalDirectories: [], disableBypassPermissionsMode: undefined,
|
|
73
|
+
model: undefined, env: undefined, statusLine: undefined, outputStyle: undefined,
|
|
74
|
+
enableAllProjectMcpServers: undefined,
|
|
75
|
+
}
|
|
76
|
+
if (sources.length === 0) {
|
|
77
|
+
perm.status = 'UNSUPPORTED' // no settings files → nothing to enforce
|
|
78
|
+
return perm
|
|
79
|
+
}
|
|
80
|
+
for (const src of sources) {
|
|
81
|
+
if (src.error !== undefined) {
|
|
82
|
+
warnings.push(`settings ${src.path}: ${src.error}`)
|
|
83
|
+
continue
|
|
84
|
+
}
|
|
85
|
+
const p = src.data.permissions
|
|
86
|
+
if (p !== undefined && (typeof p !== 'object' || p === null || Array.isArray(p))) {
|
|
87
|
+
warnings.push(`settings ${src.path}: permissions must be an object`)
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
if (p !== undefined) {
|
|
91
|
+
for (const bucket of ['allow', 'deny', 'ask']) {
|
|
92
|
+
const rules = p[bucket]
|
|
93
|
+
if (rules === undefined) continue
|
|
94
|
+
if (!Array.isArray(rules) || rules.some((r) => typeof r !== 'string')) {
|
|
95
|
+
warnings.push(`settings ${src.path}: permissions.${bucket} must be an array of strings`)
|
|
96
|
+
continue
|
|
97
|
+
}
|
|
98
|
+
for (const raw of rules) perm[bucket].push({ raw, scope: src.scope, path: src.path })
|
|
99
|
+
}
|
|
100
|
+
// additionalDirectories / disableBypassPermissionsMode live under permissions.
|
|
101
|
+
if (Array.isArray(p.additionalDirectories)) {
|
|
102
|
+
perm.additionalDirectories.push(...p.additionalDirectories.filter((d) => typeof d === 'string'))
|
|
103
|
+
}
|
|
104
|
+
if (p.disableBypassPermissionsMode !== undefined) perm.disableBypassPermissionsMode = p.disableBypassPermissionsMode
|
|
105
|
+
}
|
|
106
|
+
// defaultMode lives at the settings root (CC settings shape).
|
|
107
|
+
if (typeof src.data.defaultMode === 'string') perm.defaultMode = src.data.defaultMode
|
|
108
|
+
// Report-only keys (read-only bridge; parsed for the compatibility report).
|
|
109
|
+
if (typeof src.data.model === 'string' && perm.model === undefined) perm.model = src.data.model
|
|
110
|
+
if (typeof src.data.env === 'object' && src.data.env !== null && !Array.isArray(src.data.env) && perm.env === undefined) {
|
|
111
|
+
perm.env = { ...src.data.env }
|
|
112
|
+
}
|
|
113
|
+
if (typeof src.data.statusLine === 'object' && src.data.statusLine !== null && !Array.isArray(src.data.statusLine) && perm.statusLine === undefined) {
|
|
114
|
+
perm.statusLine = { ...src.data.statusLine }
|
|
115
|
+
}
|
|
116
|
+
if (typeof src.data.outputStyle === 'string' && perm.outputStyle === undefined) perm.outputStyle = src.data.outputStyle
|
|
117
|
+
if (src.data.enableAllProjectMcpServers !== undefined && perm.enableAllProjectMcpServers === undefined) {
|
|
118
|
+
perm.enableAllProjectMcpServers = src.data.enableAllProjectMcpServers
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
perm.status = perm.deny.length + perm.ask.length + perm.allow.length > 0 ? 'DIRECT' : 'UNSUPPORTED'
|
|
122
|
+
return perm
|
|
123
|
+
}
|
package/src/skills.js
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// .claude directory discovery: skills, commands, rules, settings.json.
|
|
2
|
+
// Extracted from dsh-claude-compat's provider (MIT, biedongbin) and generalized
|
|
3
|
+
// to multiple roots (project + global) with a shared IR shape.
|
|
4
|
+
|
|
5
|
+
import { readdir, stat, readFile } from 'node:fs/promises'
|
|
6
|
+
import { join, dirname, resolve } from 'node:path'
|
|
7
|
+
import { parse } from 'yaml'
|
|
8
|
+
|
|
9
|
+
/** DSH skill-name grammar (kebab-case), same as @deepseek-ai/dsh-skill. */
|
|
10
|
+
const SKILL_NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
|
11
|
+
export function isSkillName(name) {
|
|
12
|
+
return SKILL_NAME_RE.test(name)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function findProjectRoot(cwd, markers = ['.git']) {
|
|
16
|
+
let current = resolve(cwd)
|
|
17
|
+
while (true) {
|
|
18
|
+
for (const marker of markers) {
|
|
19
|
+
if (await pathExists(join(current, marker))) return current
|
|
20
|
+
}
|
|
21
|
+
const parent = dirname(current)
|
|
22
|
+
if (parent === current) return undefined
|
|
23
|
+
current = parent
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function pathExists(path) {
|
|
28
|
+
try { await stat(path); return true } catch { return false }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function readTextSafe(path) {
|
|
32
|
+
try { return await readFile(path, { encoding: 'utf8' }) } catch { return undefined }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Collect skills + commands from one `.claude` dir into `out` (project or
|
|
37
|
+
* global). Each entry carries its IR status; unsupported entries are filtered.
|
|
38
|
+
* @returns {Promise<{skills: object[], commands: object[], warnings: string[]}>}
|
|
39
|
+
*/
|
|
40
|
+
export async function collectClaudeDir(claudeDir, source, rank) {
|
|
41
|
+
const warnings = []
|
|
42
|
+
const skills = await discoverSkills(join(claudeDir, 'skills'), source, rank, warnings)
|
|
43
|
+
const commands = await discoverCommands(join(claudeDir, 'commands'), source, rank, warnings)
|
|
44
|
+
return { skills, commands, warnings }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Rules dir `.claude/rules/*.md` — plain markdown, ordered by filename. */
|
|
48
|
+
export async function discoverRules(rulesDir, scope) {
|
|
49
|
+
let entries
|
|
50
|
+
try { entries = await readdir(rulesDir, { withFileTypes: true, encoding: 'utf8' }) }
|
|
51
|
+
catch { return [] }
|
|
52
|
+
const rules = []
|
|
53
|
+
for (const e of entries) {
|
|
54
|
+
if (!e.isFile() || !e.name.endsWith('.md')) continue
|
|
55
|
+
const p = join(rulesDir, e.name)
|
|
56
|
+
rules.push({
|
|
57
|
+
path: p,
|
|
58
|
+
name: e.name,
|
|
59
|
+
scope,
|
|
60
|
+
status: 'DIRECT',
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
rules.sort((a, b) => a.name.localeCompare(b.name))
|
|
64
|
+
return rules
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ─── skills: recursive, ≤3 levels, bundle stops descent ─────────────────────
|
|
68
|
+
|
|
69
|
+
export async function discoverSkills(rootDir, source, rank, warnings = []) {
|
|
70
|
+
const out = []
|
|
71
|
+
if (!(await pathExists(rootDir))) return out
|
|
72
|
+
await walk(rootDir, '', 0)
|
|
73
|
+
return out
|
|
74
|
+
|
|
75
|
+
async function walk(dir, prefix, depth) {
|
|
76
|
+
let entries
|
|
77
|
+
try { entries = await readdir(dir, { withFileTypes: true, encoding: 'utf8' }) }
|
|
78
|
+
catch { return }
|
|
79
|
+
if (entries.some((e) => e.isFile() && e.name === 'SKILL.md')) {
|
|
80
|
+
const skillPath = join(dir, 'SKILL.md')
|
|
81
|
+
const c = await parseSkillCandidateFile(skillPath, source, rank, prefix || undefined, warnings)
|
|
82
|
+
if (c !== undefined) out.push(c)
|
|
83
|
+
return // bundle — don't descend
|
|
84
|
+
}
|
|
85
|
+
if (depth >= 3) return
|
|
86
|
+
for (const entry of entries) {
|
|
87
|
+
if (!entry.isDirectory()) continue
|
|
88
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules' || entry.name === '__pycache__') continue
|
|
89
|
+
const childPrefix = prefix ? `${prefix}-${entry.name}` : entry.name
|
|
90
|
+
await walk(join(dir, entry.name), childPrefix, depth + 1)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ─── commands: flat ──────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
export async function discoverCommands(rootDir, source, rank, warnings = []) {
|
|
98
|
+
const out = []
|
|
99
|
+
if (!(await pathExists(rootDir))) return out
|
|
100
|
+
let entries
|
|
101
|
+
try { entries = await readdir(rootDir, { withFileTypes: true, encoding: 'utf8' }) }
|
|
102
|
+
catch { return out }
|
|
103
|
+
for (const entry of entries) {
|
|
104
|
+
if (!entry.isFile() || !entry.name.endsWith('.md')) continue
|
|
105
|
+
const path = join(rootDir, entry.name)
|
|
106
|
+
const stem = entry.name.slice(0, -3)
|
|
107
|
+
if (!isSkillName(stem)) {
|
|
108
|
+
warnings.push(`command "${entry.name}" skipped: name not kebab-case`)
|
|
109
|
+
continue
|
|
110
|
+
}
|
|
111
|
+
const raw = await readTextSafe(path)
|
|
112
|
+
if (raw === undefined) continue
|
|
113
|
+
const parsed = parseFrontmatter(raw)
|
|
114
|
+
const description = parsed === undefined ? stem : (stringField(parsed.data, 'description') ?? stem)
|
|
115
|
+
// Command tool-scope fields (CC kebab-case, same shape as skills).
|
|
116
|
+
const allowedTools = parsed === undefined ? [] : stringList(parsed.data, 'allowed-tools')
|
|
117
|
+
const disallowedTools = parsed === undefined ? [] : stringList(parsed.data, 'disallowed-tools')
|
|
118
|
+
out.push({
|
|
119
|
+
kind: 'command',
|
|
120
|
+
name: stem,
|
|
121
|
+
description,
|
|
122
|
+
whenToUse: undefined,
|
|
123
|
+
invocation: { modelInvocable: true, userInvocable: true },
|
|
124
|
+
source,
|
|
125
|
+
rank,
|
|
126
|
+
locator: { path, directory: rootDir },
|
|
127
|
+
resourceBase: { kind: 'directory', path: rootDir },
|
|
128
|
+
frontmatter: parsed?.data ?? null,
|
|
129
|
+
allowedTools,
|
|
130
|
+
disallowedTools,
|
|
131
|
+
status: 'DIRECT',
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
return out
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ─── SKILL.md candidate parsing ──────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
async function parseSkillCandidateFile(path, source, rank, flatName, warnings = []) {
|
|
140
|
+
const raw = await readTextSafe(path)
|
|
141
|
+
if (raw === undefined) return undefined
|
|
142
|
+
const parsed = parseFrontmatter(raw)
|
|
143
|
+
if (parsed === undefined) {
|
|
144
|
+
warnings.push(`skill "${path}" skipped: no frontmatter`)
|
|
145
|
+
return undefined
|
|
146
|
+
}
|
|
147
|
+
const fmName = stringField(parsed.data, 'name')
|
|
148
|
+
const description = stringField(parsed.data, 'description')
|
|
149
|
+
if (description === undefined) {
|
|
150
|
+
warnings.push(`skill "${path}" skipped: no description`)
|
|
151
|
+
return undefined
|
|
152
|
+
}
|
|
153
|
+
// Prefer frontmatter name when it is a valid kebab-case skill name. Some
|
|
154
|
+
// Claude skills use names with ':' or other chars DSH rejects (e.g.
|
|
155
|
+
// "salus:ai-robot-coding-env-check"); for those, fall back to the flattened
|
|
156
|
+
// directory name, which is virtually always kebab-case.
|
|
157
|
+
let name
|
|
158
|
+
if (fmName !== undefined && isSkillName(fmName)) name = fmName
|
|
159
|
+
else if (flatName !== undefined && isSkillName(flatName)) name = flatName
|
|
160
|
+
if (name === undefined) {
|
|
161
|
+
warnings.push(`skill "${path}" skipped: name "${fmName ?? flatName}" not a valid DSH skill name`)
|
|
162
|
+
return undefined
|
|
163
|
+
}
|
|
164
|
+
let invocation
|
|
165
|
+
try { invocation = parseInvocationPolicy(parsed.data) }
|
|
166
|
+
catch { invocation = { modelInvocable: true, userInvocable: true } }
|
|
167
|
+
const whenToUse = stringField(parsed.data, 'whenToUse')
|
|
168
|
+
// Skill frontmatter tool-scope fields (CC kebab-case, unlike agent camelCase).
|
|
169
|
+
// allowed-tools: tools that run without approval while the skill is active;
|
|
170
|
+
// disallowed-tools: tools removed from the pool while the skill is active.
|
|
171
|
+
const allowedTools = stringList(parsed.data, 'allowed-tools')
|
|
172
|
+
const disallowedTools = stringList(parsed.data, 'disallowed-tools')
|
|
173
|
+
return {
|
|
174
|
+
kind: 'skill',
|
|
175
|
+
name,
|
|
176
|
+
description,
|
|
177
|
+
...(whenToUse !== undefined ? { whenToUse } : {}),
|
|
178
|
+
invocation,
|
|
179
|
+
source,
|
|
180
|
+
rank,
|
|
181
|
+
locator: { path, directory: dirname(path) },
|
|
182
|
+
resourceBase: { kind: 'directory', path: dirname(path) },
|
|
183
|
+
frontmatter: parsed.data,
|
|
184
|
+
allowedTools,
|
|
185
|
+
disallowedTools,
|
|
186
|
+
status: 'DIRECT',
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ─── frontmatter helpers ─────────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
export function parseFrontmatter(raw) {
|
|
193
|
+
const firstLineEnd = raw.indexOf('\n')
|
|
194
|
+
if (firstLineEnd < 0) return undefined
|
|
195
|
+
if (raw.slice(0, firstLineEnd).replace(/\r$/, '') !== '---') return undefined
|
|
196
|
+
const start = firstLineEnd + 1
|
|
197
|
+
const closing = findClosingFrontmatter(raw, start)
|
|
198
|
+
if (closing === undefined) return undefined
|
|
199
|
+
let parsed
|
|
200
|
+
try { parsed = parse(raw.slice(start, closing.start)) }
|
|
201
|
+
catch { return undefined }
|
|
202
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return undefined
|
|
203
|
+
return { data: parsed, body: raw.slice(closing.bodyStart) }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function findClosingFrontmatter(raw, start) {
|
|
207
|
+
let lineStart = start
|
|
208
|
+
while (lineStart <= raw.length) {
|
|
209
|
+
const nextNewline = raw.indexOf('\n', lineStart)
|
|
210
|
+
const lineEnd = nextNewline < 0 ? raw.length : nextNewline
|
|
211
|
+
if (raw.slice(lineStart, lineEnd).replace(/\r$/, '') === '---') {
|
|
212
|
+
return { start: lineStart, bodyStart: nextNewline < 0 ? raw.length : nextNewline + 1 }
|
|
213
|
+
}
|
|
214
|
+
if (nextNewline < 0) return undefined
|
|
215
|
+
lineStart = nextNewline + 1
|
|
216
|
+
}
|
|
217
|
+
return undefined
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function stringField(data, key) {
|
|
221
|
+
const v = data[key]
|
|
222
|
+
return typeof v === 'string' && v.length > 0 ? v : undefined
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function stringList(data, key) {
|
|
226
|
+
const v = data[key]
|
|
227
|
+
if (v === undefined) return []
|
|
228
|
+
if (typeof v === 'string') return [v]
|
|
229
|
+
if (Array.isArray(v)) return v.filter((x) => typeof x === 'string' && x.length > 0)
|
|
230
|
+
return []
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function parseInvocationPolicy(data) {
|
|
234
|
+
const miv = data['disable-model-invocation']
|
|
235
|
+
const uiv = data['user-invocable']
|
|
236
|
+
return {
|
|
237
|
+
modelInvocable: !truthy(miv),
|
|
238
|
+
userInvocable: uiv === undefined ? true : truthy(uiv),
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function truthy(v) {
|
|
243
|
+
if (typeof v === 'boolean') return v
|
|
244
|
+
if (typeof v === 'string') {
|
|
245
|
+
const s = v.toLowerCase()
|
|
246
|
+
return s === 'true' || s === 'yes' || s === 'on' || s === '1'
|
|
247
|
+
}
|
|
248
|
+
if (typeof v === 'number') return v !== 0
|
|
249
|
+
return false
|
|
250
|
+
}
|