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 ADDED
@@ -0,0 +1,28 @@
1
+ # dsh-cc-loader
2
+
3
+ Shared parse layer for the dsh-cc ecosystem: parses Claude Code `.claude/` assets (project + global `~/.claude`) **and Claude Code plugins** (`plugin.json` / `marketplace.json` / plugin root) into a standalone in-memory IR.
4
+
5
+ - **Memory IR, zero-write path**: nothing is written to disk; the source of truth stays the `.claude` files and plugin manifests themselves, so DSH stays in sync with Claude Code.
6
+ - **Component classification**: every component is DIRECT / ADAPTED / UNSUPPORTED / BLOCKED; unsupported and blocked components never reach the adapters.
7
+ - **Permission engine**: CC `settings.json` allow/deny/ask rule parsing and deny → ask → allow folding (bare names, command globs, path anchors, domains, params, skill/agent names).
8
+ - **Plugin discovery (M4)**: `parsePluginManifest`, `parseMarketplace`, `discoverPluginRoot` (single entry point inventorying a plugin's skills/commands/agents/mcp/lsp/hooks), `discoverMarketplace`, `pluginComponentName` (`plugin-<plugin>-<component>` DSH-safe namespacing).
9
+
10
+ Consumed by [dsh-cc-skills](../cc-skills), [dsh-cc-permissions](../cc-permissions), [dsh-cc-agents](../cc-agents), [dsh-cc-hooks](../cc-hooks) and [dsh-cc-mcp](../cc-mcp).
11
+
12
+ ## Install
13
+
14
+ ```sh
15
+ npm install dsh-cc-loader
16
+ ```
17
+
18
+ ## Quick use
19
+
20
+ ```js
21
+ import { loadClaude } from 'dsh-cc-loader'
22
+
23
+ const ir = await loadClaude({ cwd: process.cwd(), pluginRoots: ['/path/to/my-plugin'] })
24
+ console.log(ir.report) // DIRECT/ADAPTED/UNSUPPORTED counts
25
+ console.log(ir.components.plugins) // per-plugin IR blocks
26
+ ```
27
+
28
+ MIT — discovery logic derived from [dsh-claude-compat](https://github.com/biedongbin/dsh-claude-compat) (MIT, © biedongbin).
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "dsh-cc-loader",
3
+ "version": "0.1.0",
4
+ "description": "Shared parse layer for the dsh-cc ecosystem: parses Claude Code .claude/ (project + global) into a standalone .dsh intermediate representation (IR), classifying every component DIRECT/ADAPTED/UNSUPPORTED/BLOCKED and filtering unsupported ones out.",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "exports": {
8
+ ".": "./src/index.js"
9
+ },
10
+ "files": [
11
+ "src",
12
+ "README.md"
13
+ ],
14
+ "keywords": [
15
+ "dsh",
16
+ "deepseek-harness",
17
+ "claude-code",
18
+ "loader",
19
+ "parser"
20
+ ],
21
+ "license": "MIT",
22
+ "dependencies": {
23
+ "yaml": "^2.0.0"
24
+ },
25
+ "engines": {
26
+ "node": ">=20"
27
+ },
28
+ "repository": "git+https://github.com/Bcy2020/dsh-cc-ecosystem.git",
29
+ "homepage": "https://github.com/Bcy2020/dsh-cc-ecosystem"
30
+ }
package/src/agents.js ADDED
@@ -0,0 +1,296 @@
1
+ // .claude agents discovery + IR: project .claude/agents/*.md and global
2
+ // ~/.claude/agents/*.md → in-memory agent catalog (CC "first kind" agents:
3
+ // frontmatter + system-prompt body, delegated via the persona channel).
4
+ //
5
+ // CC scope precedence: project > global (plugin agents arrive with the plugin
6
+ // source in M4; the loader already tolerates an extra root).
7
+ //
8
+ // Every entry is classified DIRECT / ADAPTED / UNSUPPORTED / BLOCKED.
9
+ // BLOCKED entries (e.g. isolation: worktree) never reach the adapter.
10
+
11
+ import { readdir, readFile, stat } from 'node:fs/promises'
12
+ import { join } from 'node:path'
13
+ import { isSkillName, parseFrontmatter, pathExists, readTextSafe } from './skills.js'
14
+
15
+ /**
16
+ * CC agent frontmatter fields we map to a delegation (DIRECT).
17
+ *
18
+ * Verified against the official subagents reference (16 fields): name,
19
+ * description, tools, disallowedTools, model, permissionMode, mcpServers,
20
+ * hooks, maxTurns, skills, initialPrompt, memory, effort, background,
21
+ * isolation, color. `context` is not in the official list but is used by
22
+ * community agents as extra system-prompt material — we extract it and append
23
+ * it to the persona (see buildAgentEntry). `agent` is likewise non-official
24
+ * (a leftover in the old whitelist); it is extracted and reported, never
25
+ * treated as DIRECT.
26
+ */
27
+ const DIRECT_FIELDS = [
28
+ 'name', 'description', 'tools', 'disallowedTools', 'model', 'effort',
29
+ 'maxTurns', 'skills', 'background', 'initialPrompt', 'context',
30
+ ]
31
+ /** CC fields DSH cannot honor on this kind of agent (reported, never fatal). */
32
+ const UNSUPPORTED_FIELDS = ['permissionMode', 'mcpServers', 'hooks', 'isolation']
33
+
34
+ /**
35
+ * Discover `.claude/agents/*.md` under one root (project or global user dir).
36
+ * Flat files only, like CC. Each agent needs `name` (frontmatter or file stem)
37
+ * and `description`; the body is the delegation system prompt.
38
+ * @param {string} agentsDir - path to the agents directory.
39
+ * @param {string} scope - 'project' | 'global'.
40
+ * @param {number} rank - precedence rank (lower wins).
41
+ * @param {string[]} [warnings] - collected warnings.
42
+ * @returns {Promise<object[]>} IR agent entries.
43
+ */
44
+ export async function discoverAgents(agentsDir, scope, rank, warnings = []) {
45
+ const out = []
46
+ if (!(await pathExists(agentsDir))) return out
47
+ let entries
48
+ try { entries = await readdir(agentsDir, { withFileTypes: true, encoding: 'utf8' }) }
49
+ catch { return out }
50
+ for (const entry of entries) {
51
+ if (!entry.isFile() || !entry.name.endsWith('.md')) continue
52
+ const path = join(agentsDir, entry.name)
53
+ const stem = entry.name.slice(0, -3)
54
+ if (!isSkillName(stem)) {
55
+ warnings.push(`agent "${entry.name}" skipped: name not kebab-case`)
56
+ continue
57
+ }
58
+ const raw = await readTextSafe(path)
59
+ if (raw === undefined) continue
60
+ const parsed = parseFrontmatter(raw)
61
+ if (parsed === undefined) {
62
+ warnings.push(`agent "${path}" skipped: no frontmatter`)
63
+ continue
64
+ }
65
+ const fm = parsed.data
66
+ const name = stringField(fm, 'name') ?? stem
67
+ if (!isSkillName(name)) {
68
+ warnings.push(`agent "${path}" skipped: name "${name}" not kebab-case`)
69
+ continue
70
+ }
71
+ const description = stringField(fm, 'description')
72
+ if (description === undefined) {
73
+ warnings.push(`agent "${path}" skipped: no description`)
74
+ continue
75
+ }
76
+ const body = parsed.body.trim()
77
+ if (body.length === 0) {
78
+ warnings.push(`agent "${path}" skipped: empty system prompt body`)
79
+ continue
80
+ }
81
+ out.push(buildAgentEntry({ path, directory: agentsDir, name, description, body, fm, scope, rank, warnings }))
82
+ }
83
+ return out
84
+ }
85
+
86
+ /**
87
+ * Build one IR agent entry from a parsed `.md` file, classifying every
88
+ * frontmatter field. Never throws on an unknown field — it lands in `notes`.
89
+ */
90
+ export function buildAgentEntry({ path, directory, name, description, body, fm, scope, rank, warnings }) {
91
+ const notes = []
92
+ const status = classifyAgentFields(fm, notes)
93
+
94
+ const tools = stringList(fm, 'tools')
95
+ const disallowedTools = stringList(fm, 'disallowedTools')
96
+ const skills = stringList(fm, 'skills')
97
+ const model = stringField(fm, 'model')
98
+ const effort = stringField(fm, 'effort')
99
+ const maxTurns = fm['maxTurns']
100
+ const background = truthy(fm['background'])
101
+ const initialPrompt = stringField(fm, 'initialPrompt')
102
+ const isolation = stringField(fm, 'isolation')
103
+ const memory = stringField(fm, 'memory')
104
+ const color = stringField(fm, 'color')
105
+ // Community context field (not in the official 16): extra system-prompt
106
+ // material appended to the persona by the cc-agents adapter.
107
+ const context = stringList(fm, 'context')
108
+ // Non-official leftover field: no DSH parent-identity mapping exists, so the
109
+ // raw value is kept for the report and flagged in notes.
110
+ const agentField = stringField(fm, 'agent')
111
+
112
+ if (isolation === 'worktree') {
113
+ notes.push('isolation:worktree has no DSH equivalent — agent not delegatable')
114
+ } else if (isolation !== undefined) {
115
+ notes.push(`isolation:${isolation} unknown — ignored`)
116
+ }
117
+ if (maxTurns !== undefined && typeof maxTurns !== 'number') {
118
+ warnings?.push(`agent "${name}": maxTurns not a number — ignored`)
119
+ notes.push('maxTurns not numeric — ignored')
120
+ }
121
+
122
+ return {
123
+ kind: 'agent',
124
+ name,
125
+ description,
126
+ scope,
127
+ rank,
128
+ source: path,
129
+ locator: { path, directory },
130
+ frontmatter: fm,
131
+ systemPrompt: body,
132
+ tools,
133
+ disallowedTools,
134
+ skills,
135
+ ...(model !== undefined ? { model } : {}),
136
+ ...(effort !== undefined ? { effort } : {}),
137
+ ...(maxTurns !== undefined && typeof maxTurns === 'number' ? { maxTurns } : {}),
138
+ ...(background ? { background: true } : {}),
139
+ ...(initialPrompt !== undefined ? { initialPrompt } : {}),
140
+ ...(isolation !== undefined ? { isolation } : {}),
141
+ ...(memory !== undefined ? { memory } : {}),
142
+ ...(color !== undefined ? { color } : {}),
143
+ ...(context.length > 0 ? { context } : {}),
144
+ ...(agentField !== undefined ? { agent: agentField } : {}),
145
+ status,
146
+ notes,
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Classify an agent's frontmatter: DIRECT when only delegatable fields are
152
+ * present; ADAPTED when a field has a degraded mapping (memory → report);
153
+ * UNSUPPORTED when a field has no DSH equivalent (permissionMode/mcpServers/
154
+ * hooks — CC itself forbids these on plugin agents); BLOCKED when the agent
155
+ * cannot be delegated at all (isolation: worktree).
156
+ */
157
+ export function classifyAgentFields(fm, notes = []) {
158
+ let status = 'DIRECT'
159
+ for (const key of Object.keys(fm)) {
160
+ if (DIRECT_FIELDS.includes(key)) continue
161
+ if (key === 'memory') {
162
+ status = worse(status, 'ADAPTED')
163
+ notes.push(`memory:${fm[key]} — mapped to a report, no DSH persistent-dir bridge yet`)
164
+ continue
165
+ }
166
+ if (key === 'isolation') {
167
+ if (fm[key] === 'worktree') return 'BLOCKED'
168
+ status = worse(status, 'ADAPTED')
169
+ notes.push(`isolation:${fm[key]} — ignored`)
170
+ continue
171
+ }
172
+ if (key === 'Agent') {
173
+ status = worse(status, 'ADAPTED')
174
+ notes.push('Agent(agent_type) applies to main-thread agents only — reported')
175
+ continue
176
+ }
177
+ if (key === 'color') {
178
+ status = worse(status, 'ADAPTED')
179
+ notes.push('color affects CC UI display only — reported, no DSH equivalent')
180
+ continue
181
+ }
182
+ if (key === 'agent') {
183
+ status = worse(status, 'ADAPTED')
184
+ notes.push('agent field is not a CC standard field — reported, no DSH parent-identity mapping')
185
+ continue
186
+ }
187
+ if (UNSUPPORTED_FIELDS.includes(key)) {
188
+ status = worse(status, 'UNSUPPORTED')
189
+ notes.push(`${key} has no DSH delegation equivalent — ignored (CC forbids it on plugin agents too)`)
190
+ continue
191
+ }
192
+ status = worse(status, 'ADAPTED')
193
+ notes.push(`unknown frontmatter field "${key}" — ignored`)
194
+ }
195
+ return status
196
+ }
197
+
198
+ /**
199
+ * Expand a CC tool name (agent frontmatter `tools`/`disallowedTools`) into
200
+ * candidate DSH global tool names. `mcp__…` names pass through (MCP tools are
201
+ * registered per server); exact DSH names pass through; bucket names expand.
202
+ * Names containing `*` cannot be enumerated here → empty + note.
203
+ * @param {string} ccName
204
+ * @param {string[]} [notes]
205
+ * @returns {string[]}
206
+ */
207
+ export function expandCcToolToDsh(ccName, notes = []) {
208
+ if (typeof ccName !== 'string' || ccName.length === 0) return []
209
+ if (ccName.includes('*')) {
210
+ notes.push(`tools entry "${ccName}" uses a glob — cannot enumerate, skipped`)
211
+ return []
212
+ }
213
+ const expanded = CC_TO_DSH[ccName]
214
+ if (expanded !== undefined) return [...expanded]
215
+ // mcp__server / mcp__server__tool / exact DSH tool names pass through.
216
+ return [ccName]
217
+ }
218
+
219
+ /** CC agent tool bucket → candidate DSH global tool names (verified inventory). */
220
+ const CC_TO_DSH = Object.freeze({
221
+ Bash: ['bash', 'pwsh',
222
+ 'terminal_open', 'terminal_send', 'terminal_read', 'terminal_signal', 'terminal_close', 'terminal_list'],
223
+ PowerShell: ['pwsh'],
224
+ Read: ['read', 'read_image'],
225
+ Write: ['write'],
226
+ Edit: ['edit', 'str_replace_editor'],
227
+ Glob: ['glob'],
228
+ Grep: ['grep'],
229
+ WebFetch: ['web_fetch'],
230
+ Agent: ['subagent'],
231
+ Skill: ['skill'],
232
+ AskUserQuestion: ['ask_user_question'],
233
+ })
234
+
235
+ /**
236
+ * Merge agents from multiple roots into one catalog, resolving name conflicts
237
+ * by scope precedence (project beats global). Later roots lose and warn.
238
+ * @param {Array<{root: string, scope: string, rank: number}>} roots
239
+ * @returns {Promise<{agents: object[], warnings: string[]}>}
240
+ */
241
+ export async function mergeAgentCatalog(roots, warnings = []) {
242
+ const byName = new Map()
243
+ for (const { root, scope, rank } of roots) {
244
+ const found = await discoverAgents(root, scope, rank, warnings)
245
+ for (const agent of found) {
246
+ const existing = byName.get(agent.name)
247
+ if (existing === undefined) {
248
+ byName.set(agent.name, agent)
249
+ continue
250
+ }
251
+ // Same name: lower rank wins (project < global). Lose → warn (fail loud).
252
+ if (agent.rank < existing.rank) {
253
+ warnings.push(`agent "${agent.name}": ${scope} overrides ${existing.scope} (same name)`)
254
+ byName.set(agent.name, agent)
255
+ } else if (agent.rank > existing.rank) {
256
+ warnings.push(`agent "${agent.name}": ${scope} conflicts with ${existing.scope} — ${existing.scope} wins`)
257
+ }
258
+ // Equal rank: first root wins silently (same scope, duplicate dirs).
259
+ }
260
+ }
261
+ return { agents: [...byName.values()], warnings }
262
+ }
263
+
264
+ // ─── helpers ────────────────────────────────────────────────────────────────
265
+
266
+ function stringField(data, key) {
267
+ const v = data[key]
268
+ return typeof v === 'string' && v.length > 0 ? v : undefined
269
+ }
270
+
271
+ function stringList(data, key) {
272
+ const v = data[key]
273
+ if (v === undefined) return []
274
+ if (typeof v === 'string') return [v]
275
+ if (Array.isArray(v)) return v.filter((x) => typeof x === 'string' && x.length > 0)
276
+ return []
277
+ }
278
+
279
+ function truthy(v) {
280
+ if (typeof v === 'boolean') return v
281
+ if (typeof v === 'string') {
282
+ const s = v.toLowerCase()
283
+ return s === 'true' || s === 'yes' || s === 'on' || s === '1'
284
+ }
285
+ if (typeof v === 'number') return v !== 0
286
+ return false
287
+ }
288
+
289
+ /** Strictest-status wins: BLOCKED > UNSUPPORTED > ADAPTED > DIRECT. */
290
+ function worse(a, b) {
291
+ const order = { DIRECT: 0, ADAPTED: 1, UNSUPPORTED: 2, BLOCKED: 3 }
292
+ return order[a] >= order[b] ? a : b
293
+ }
294
+
295
+ /** Re-exported read helpers the adapter also uses. */
296
+ export { pathExists, readTextSafe }