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,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
+ }