dsh-cc-skills 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,42 @@
1
+ # dsh-cc-skills
2
+
3
+ Load Claude Code's `.claude/` skills, commands and rules into DSH.
4
+
5
+ Fork of [dsh-claude-compat](https://github.com/biedongbin/dsh-claude-compat) (MIT, © biedongbin),
6
+ extended with **global user-level discovery** (`~/.claude`) alongside the original
7
+ project-level scan.
8
+
9
+ ## What it does
10
+
11
+ - **Skills**: `<project>/.claude/skills/**/SKILL.md` + `~/.claude/skills/**/SKILL.md`
12
+ surface as native DSH skills (slash trigger, `skill` tool, model catalog).
13
+ - **Commands**: `.claude/commands/*.md` → user-invocable skills (`/name`).
14
+ - **Rules**: `.claude/rules/*.md` (project first, then global) are injected once
15
+ per session as a user-role `<system-reminder>` (Claude Code `prependUserContext`
16
+ envelope).
17
+
18
+ Project entries outrank global ones via skill rank (project 150 < global 160;
19
+ lower ranks win duplicate names in DSH).
20
+
21
+ ## Install
22
+
23
+ ```sh
24
+ dsh plugin --profile <name> add dsh-cc-skills
25
+ ```
26
+
27
+ ## Config
28
+
29
+ | Key | Default | Meaning |
30
+ | --- | --- | --- |
31
+ | `enableSkills` | `true` | Surface skills/commands |
32
+ | `enableRules` | `true` | Inject rules |
33
+ | `enableGlobal` | `true` | Also scan `~/.claude` |
34
+ | `globalClaudeDir` | `<home>/.claude` | Global Claude dir override |
35
+ | `globalSkillRank` | `160` | Global rank (must be > project `skillRank` 150) |
36
+ | `projectRootMarkers` | `['.git']` | Project root discovery markers |
37
+ | `skillRank` | `150` | Project skill rank |
38
+ | `rulesMaxBytes` | `65536` | Rules injection size cap |
39
+
40
+ ## License
41
+
42
+ MIT — original work © biedongbin (dsh-claude-compat), extensions © dsh-cc ecosystem.
@@ -0,0 +1,7 @@
1
+ - insert:
2
+ - id: cc-skills
3
+ name: 'dsh-cc-skills'
4
+ config:
5
+ enableSkills: true
6
+ enableRules: true
7
+ enableGlobal: true
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "dsh-cc-skills",
3
+ "version": "0.1.0",
4
+ "description": "Load Claude Code .claude/ skills, commands and rules (project + global ~/.claude) into DSH. Fork of dsh-claude-compat (MIT) by biedongbin.",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "exports": {
8
+ ".": "./src/index.js"
9
+ },
10
+ "dsh": {
11
+ "bundle": {
12
+ "patch": "./cordis.patch.yml"
13
+ }
14
+ },
15
+ "files": [
16
+ "src",
17
+ "README.md",
18
+ "cordis.patch.yml"
19
+ ],
20
+ "keywords": [
21
+ "dsh",
22
+ "deepseek-harness",
23
+ "claude-code",
24
+ "claude",
25
+ "skills",
26
+ "slash-commands",
27
+ "rules"
28
+ ],
29
+ "license": "MIT",
30
+ "dependencies": {
31
+ "yaml": "^2.0.0",
32
+ "dsh-cc-loader": "^0.1.0"
33
+ },
34
+ "peerDependencies": {
35
+ "@deepseek-ai/cordis": "*",
36
+ "@deepseek-ai/dsh-skill": "*",
37
+ "@deepseek-ai/dsh-llm": "*",
38
+ "@deepseek-ai/schemastery": "*"
39
+ },
40
+ "peerDependenciesMeta": {
41
+ "@deepseek-ai/cordis": {
42
+ "optional": true
43
+ },
44
+ "@deepseek-ai/dsh-skill": {
45
+ "optional": true
46
+ },
47
+ "@deepseek-ai/dsh-llm": {
48
+ "optional": true
49
+ },
50
+ "@deepseek-ai/schemastery": {
51
+ "optional": true
52
+ }
53
+ },
54
+ "engines": {
55
+ "node": ">=20"
56
+ },
57
+ "repository": "git+https://github.com/Bcy2020/dsh-cc-ecosystem.git",
58
+ "homepage": "https://github.com/Bcy2020/dsh-cc-ecosystem"
59
+ }
package/src/index.js ADDED
@@ -0,0 +1,404 @@
1
+ // dsh-cc-skills — adapt Claude Code .claude/ skills, commands and rules into
2
+ // DSH, driven by the dsh-cc-loader IR.
3
+ //
4
+ // Fork lineage: this package started as a fork of dsh-claude-compat (MIT, ©
5
+ // biedongbin); the discovery/parsing logic now lives in dsh-cc-loader so every
6
+ // dsh-cc adapter shares one parse layer. This adapter only registers:
7
+ // - ctx.skills provider: IR skills/commands (project + global ~/.claude)
8
+ // - rules message-stream injection (project first, then global)
9
+ //
10
+ // Project entries outrank global ones via skill rank (project 150 < global
11
+ // 160; lower ranks win duplicate names in DSH).
12
+
13
+ import { readFile } from 'node:fs/promises'
14
+ import { homedir } from 'node:os'
15
+ import z from '@deepseek-ai/schemastery'
16
+ import { createUserMessage } from '@deepseek-ai/dsh-llm'
17
+ import { loadClaude, parseFrontmatter, expandCcToolToDsh, pluginComponentName } from 'dsh-cc-loader'
18
+
19
+ export const name = 'cc-skills'
20
+ export const inject = ['skills']
21
+
22
+ export const Config = z.object({
23
+ projectRootMarkers: z.array(z.string()).default(['.git']),
24
+ // 150: between project-dsh (100) and project-agents (200). DSH-native skills
25
+ // win over Claude skills; Claude skills win over user-level.
26
+ skillRank: z.number().default(150),
27
+ skillSource: z.string().default('project-claude'),
28
+ rulesMaxBytes: z.number().default(65536),
29
+ enableRules: z.boolean().default(true),
30
+ enableSkills: z.boolean().default(true),
31
+ // ── global user-level ~/.claude ───────────────────────────────────────────
32
+ enableGlobal: z.boolean().default(true),
33
+ globalClaudeDir: z.string(),
34
+ globalSkillRank: z.number().default(160),
35
+ globalSkillSource: z.string().default('user-claude'),
36
+ homeDir: z.string(),
37
+ // ── plugin roots (M4c) ────────────────────────────────────────────────────
38
+ // Plugin skills/commands are exposed as `plugin-<plugin>-<name>` (DSH skill
39
+ // names are strict kebab-case; CC's `<plugin>:<name>` colon does not fit).
40
+ // Rank defaults above global (160) so project/global entries win clashes.
41
+ pluginRoots: z.array(z.string()).default([]),
42
+ pluginSkillRank: z.number().default(170),
43
+ // ── skill tool-scope (allowed-tools / disallowed-tools) ──────────────────
44
+ // CC semantics: while a skill is active (the round it was invoked in), tools
45
+ // listed in allowed-tools run without approval and tools listed in
46
+ // disallowed-tools are removed from the pool. "Active" ends at the next user
47
+ // message (CC: cleared on the next message).
48
+ enableToolScope: z.boolean().default(true),
49
+ })
50
+
51
+ export function apply(ctx, config = {}) {
52
+ const homeDir = config.homeDir ?? homedir()
53
+ const loaderOpts = () => ({
54
+ cwd: process.cwd(), // replaced per-call by list/get
55
+ homeDir,
56
+ projectRootMarkers: config.projectRootMarkers,
57
+ enableGlobal: config.enableGlobal,
58
+ globalClaudeDir: config.globalClaudeDir,
59
+ projectSkillRank: config.skillRank,
60
+ globalSkillRank: config.globalSkillRank,
61
+ pluginRoots: config.pluginRoots,
62
+ pluginSkillRank: config.pluginSkillRank,
63
+ })
64
+ ctx.logger.info('cc-skills: provider registered (project .claude + global ~/.claude)')
65
+
66
+ if (config.enableSkills !== false) {
67
+ ctx.skills.registerProvider((control) =>
68
+ new CcSkillsProvider(ctx, control, config, loaderOpts))
69
+ }
70
+ if (config.enableRules !== false) {
71
+ registerRulesSection(ctx, config, loaderOpts)
72
+ }
73
+ if (config.enableToolScope !== false) {
74
+ registerToolScope(ctx, config, loaderOpts)
75
+ }
76
+ }
77
+
78
+ // ─── skill provider ──────────────────────────────────────────────────────────
79
+
80
+ class CcSkillsProvider {
81
+ constructor(ctx, control, config, loaderOpts) {
82
+ this.ctx = ctx
83
+ this.name = 'cc-skills'
84
+ this.config = config
85
+ this.loaderOpts = loaderOpts
86
+ control.signal.addEventListener('abort', () => {}, { once: true })
87
+ }
88
+
89
+ async list(options) {
90
+ const cwd = options?.cwd
91
+ if (cwd === undefined || cwd === null) return []
92
+ const ir = await loadClaude({ ...this.loaderOpts(), cwd })
93
+ const out = []
94
+ for (const s of ir.components.skills) {
95
+ if (s.status !== 'DIRECT' && s.status !== 'ADAPTED') continue
96
+ out.push({
97
+ name: s.name,
98
+ description: s.description,
99
+ ...(s.whenToUse !== undefined ? { whenToUse: s.whenToUse } : {}),
100
+ invocation: s.invocation,
101
+ source: s.source,
102
+ provider: this.name,
103
+ rank: s.rank,
104
+ locator: s.locator,
105
+ resourceBase: s.resourceBase,
106
+ path: s.locator.path,
107
+ })
108
+ }
109
+ for (const c of ir.components.commands) {
110
+ if (c.status !== 'DIRECT' && c.status !== 'ADAPTED') continue
111
+ out.push({
112
+ name: c.name,
113
+ description: c.description,
114
+ invocation: c.invocation,
115
+ source: c.source,
116
+ provider: this.name,
117
+ rank: c.rank,
118
+ locator: c.locator,
119
+ resourceBase: c.resourceBase,
120
+ path: c.locator.path,
121
+ })
122
+ }
123
+ // Plugin skills/commands (M4c): namespaced `plugin-<plugin>-<name>`.
124
+ for (const plugin of ir.components.plugins) {
125
+ const pushPlugin = (entry) => {
126
+ if (entry.status !== 'DIRECT' && entry.status !== 'ADAPTED') return
127
+ const name = pluginComponentName(plugin.name, entry.name)
128
+ out.push({
129
+ name,
130
+ description: entry.description,
131
+ ...(entry.whenToUse !== undefined ? { whenToUse: entry.whenToUse } : {}),
132
+ invocation: entry.invocation,
133
+ source: entry.source,
134
+ provider: this.name,
135
+ rank: entry.rank,
136
+ locator: entry.locator,
137
+ resourceBase: entry.resourceBase,
138
+ path: entry.locator.path,
139
+ })
140
+ }
141
+ for (const s of plugin.components.skills) pushPlugin(s)
142
+ for (const c of plugin.components.commands) pushPlugin(c)
143
+ }
144
+ for (const w of ir.warnings) this.ctx.logger.warn(`cc-skills: ${w}`)
145
+ return out
146
+ }
147
+
148
+ async get(candidate) {
149
+ const locator = candidate.locator
150
+ let raw
151
+ try { raw = await readFile(locator.path, { encoding: 'utf8' }) }
152
+ catch { return undefined }
153
+ const parsed = parseFrontmatter(raw)
154
+ const content = parsed === undefined ? raw.trim() : parsed.body.trim()
155
+ return {
156
+ name: candidate.name,
157
+ description: candidate.description,
158
+ ...(candidate.whenToUse !== undefined ? { whenToUse: candidate.whenToUse } : {}),
159
+ invocation: candidate.invocation,
160
+ source: candidate.source,
161
+ provider: this.name,
162
+ resourceBase: { kind: 'directory', path: locator.directory },
163
+ path: locator.path,
164
+ content,
165
+ }
166
+ }
167
+ }
168
+
169
+ // ─── rules section ───────────────────────────────────────────────────────────
170
+
171
+ function registerRulesSection(ctx, config, loaderOpts) {
172
+ const maxBytes = config.rulesMaxBytes ?? 65536
173
+ // Cache built messages per session cwd — pre-step fires every step.
174
+ const cache = new Map()
175
+ ctx.on('agent/pre-step', async ({ agent, messages }, next) => {
176
+ const decision = await next()
177
+ if (decision.kind !== 'enter') return decision
178
+ const present = (list) => list.some((m) => m?.source?.kind === 'cc-skills')
179
+ const alreadyInjected = present(messages)
180
+ || present(decision.messages)
181
+ || agent.session.surface.nodes.some((seq) => {
182
+ const event = agent.session.events[seq]
183
+ return event?.type === 'user/message'
184
+ && event.data?.source?.kind === 'cc-skills'
185
+ })
186
+ if (alreadyInjected) return decision
187
+ const cwd = agent.session.header?.cwd ?? process.cwd()
188
+ if (!cache.has(cwd)) {
189
+ const text = await buildRulesText(cwd, loaderOpts, maxBytes, ctx)
190
+ if (text.length === 0) return decision
191
+ cache.set(cwd, createUserMessage({
192
+ content: [{ type: 'text', text }],
193
+ source: { kind: 'cc-skills', form: 'rules' },
194
+ }))
195
+ }
196
+ return {
197
+ kind: 'enter',
198
+ messages: decision.messages.toSpliced(0, 0, cache.get(cwd)),
199
+ }
200
+ })
201
+ }
202
+
203
+ /** Project rules first, then global user rules (project priority in the text). */
204
+ async function buildRulesText(cwd, loaderOpts, maxBytes, ctx) {
205
+ const ir = await loadClaude({ ...loaderOpts(), cwd })
206
+ const rules = ir.components.rules.filter((r) => r.status === 'DIRECT' || r.status === 'ADAPTED')
207
+ const parts = []
208
+ let total = 0
209
+ for (const rule of rules) {
210
+ let raw
211
+ try { raw = await readFile(rule.path, { encoding: 'utf8' }) } catch { continue }
212
+ const chunk = `## ${rule.name}\n\n${raw.trim()}\n`
213
+ if (total + chunk.length > maxBytes) break
214
+ parts.push(chunk)
215
+ total += chunk.length
216
+ }
217
+ if (parts.length === 0) return ''
218
+ return `<system-reminder>
219
+ As you answer the user's questions, you can use the following context:
220
+ # claudeMd
221
+ ${parts.join('\n')}
222
+ </system-reminder>`
223
+ }
224
+
225
+ // ─── skill tool-scope (allowed-tools / disallowed-tools) ─────────────────────
226
+
227
+ // Per-agent activation state. "Active" spans from the skill invocation (the
228
+ // `skill` tool call or a `/name` gesture) to the next user message, matching
229
+ // CC's "cleared on the next message" semantics.
230
+ //
231
+ // agentScope: Map<agentId, { skill, allowed: Set, disallowed: Set, disposers: [] }>
232
+ const agentScopes = new Map()
233
+
234
+ function log(ctx, level, message) {
235
+ try { ctx.logger?.[level]?.(`cc-skills: ${message}`) } catch { /* logger absence is not fatal */ }
236
+ }
237
+
238
+ /**
239
+ * Activate a skill's tool scope for an agent. Looks the skill up in the IR
240
+ * (same discovery the provider uses), expands CC tool names to DSH names, and
241
+ * hides disallowed tools via the agent's scoped tool registry. Never throws —
242
+ * a missing skill or an unavailable registry degrades to "no scope".
243
+ */
244
+ async function activateSkillScope(ctx, agent, skillName, loaderOpts) {
245
+ if (!agent || typeof skillName !== 'string' || skillName.length === 0) return
246
+ const agentId = agent.id ?? String(agent)
247
+ const cwd = agent.session?.header?.cwd ?? process.cwd()
248
+ let ir
249
+ try { ir = await loadClaude({ ...loaderOpts(), cwd }) } catch (error) {
250
+ log(ctx, 'warn', `skill "${skillName}": discovery failed — no tool scope: ${String(error)}`)
251
+ return
252
+ }
253
+ // Both skills and commands carry allowed-tools / disallowed-tools in CC
254
+ // frontmatter (commands are user-invocable slash commands with the same
255
+ // tool-scope semantics). Look up by the DSH-facing name: project/global
256
+ // entries match directly; plugin entries match their namespaced
257
+ // `plugin-<plugin>-<name>` form (M4c).
258
+ const skill = ir.components.skills.find((s) => s.name === skillName)
259
+ ?? ir.components.commands.find((c) => c.name === skillName)
260
+ ?? findPluginComponent(ir, skillName)
261
+ if (skill === undefined) {
262
+ log(ctx, 'info', `skill/command "${skillName}" not in IR — no tool scope`)
263
+ return
264
+ }
265
+ const notes = []
266
+ const allowed = new Set(skill.allowedTools.flatMap((name) => expandCcToolToDsh(name, notes)))
267
+ const disallowed = new Set(skill.disallowedTools.flatMap((name) => expandCcToolToDsh(name, notes)))
268
+ for (const n of notes) log(ctx, 'warn', `skill "${skillName}": ${n}`)
269
+
270
+ // Hide disallowed tools from the model via the agent's scoped registry.
271
+ // NOTE (verified in dsh-tools view()): restrict() only admits INHERITED
272
+ // global tools — the global layer plus ancestor preset layers. A tool
273
+ // registered in the agent's OWN scope layer (e.g. fs tools the agent
274
+ // preset mounts per-session) is never restrictable: "A restriction filters
275
+ // what a scope inherits … and never what its OWN layer registers." Those
276
+ // still get denied at pre-execute (the gate below), but stay visible.
277
+ const disposers = []
278
+ if (disallowed.size > 0 && typeof agent.ctx?.tools?.restrict === 'function') {
279
+ for (const name of disallowed) {
280
+ try {
281
+ disposers.push(agent.ctx.tools.restrict({ deny: [name] }))
282
+ } catch (error) {
283
+ log(ctx, 'warn', `skill "${skillName}": restrict "${name}" failed (still denied at pre-execute): ${String(error)}`)
284
+ }
285
+ }
286
+ }
287
+ clearAgentScope(agentId, disposers)
288
+ agentScopes.set(agentId, { skill: skillName, allowed, disallowed, disposers })
289
+ log(ctx, 'info', `skill "${skillName}" active for agent ${agentId} — ${allowed.size} allowed, ${disallowed.size} disallowed tool(s)`)
290
+ }
291
+
292
+ /** Dispose a previous scope's restrict disposers, then record new ones. */
293
+ function clearAgentScope(agentId, newDisposers = []) {
294
+ const prev = agentScopes.get(agentId)
295
+ if (prev !== undefined) {
296
+ for (const dispose of prev.disposers) {
297
+ try { dispose() } catch { /* best effort */ }
298
+ }
299
+ }
300
+ if (newDisposers.length === 0) agentScopes.delete(agentId)
301
+ }
302
+
303
+ /**
304
+ * Find a plugin skill/command by its namespaced DSH name (`plugin-<plugin>-<n>`).
305
+ * Returns the IR entry (name still the raw CC name; the adapter namespaces).
306
+ */
307
+ function findPluginComponent(ir, skillName) {
308
+ for (const plugin of ir.components.plugins) {
309
+ for (const entry of [...plugin.components.skills, ...plugin.components.commands]) {
310
+ if (pluginComponentName(plugin.name, entry.name) === skillName) return entry
311
+ }
312
+ }
313
+ return undefined
314
+ }
315
+
316
+ /**
317
+ * Register the tool-scope gate:
318
+ * - agent/pre-step clears the previous round's activation (CC: "cleared on
319
+ * the next message") and activates a skill named by a `/name` gesture
320
+ * (tool-skill injects a message with source.kind === 'skill-invocation').
321
+ * - tools/pre-execute activates on the `skill` tool call, then gates: a
322
+ * disallowed tool is denied outright; an allowed tool that the downstream
323
+ * chain would `ask` about is allowed instead (deny from the downstream
324
+ * chain is preserved — CC order is deny > ask > allow).
325
+ *
326
+ * Both listeners call next() when they are not the decision owner (waterfall
327
+ * discipline), and apply() never throws synchronously.
328
+ */
329
+ function registerToolScope(ctx, config, loaderOpts) {
330
+ // Clear the previous round's activation in pre-step. NOTE: this is
331
+ // intentionally NOT moved to agent/turn-stopping — that event is serial
332
+ // mode, where an earlier listener's non-undefined return value bails the
333
+ // chain and our clear never runs, leaving the scope stuck forever (tools
334
+ // stay hidden). Pre-step is a waterfall where our prepend listener always
335
+ // runs. Trade-off: the agent loop assembles the model-facing tool list
336
+ // (systemPrompt.assemble) BEFORE the pre-step waterfall, so a clear here
337
+ // takes effect from the NEXT round's assembly — the model sees the hidden
338
+ // tools for one extra round (skill round + next round hidden, visible from
339
+ // the third). That lag is accepted (verified 2026-08-17).
340
+ ctx.on('agent/pre-step', async ({ agent, messages }, next) => {
341
+ const decision = await next()
342
+ if (decision.kind !== 'enter') return decision
343
+ try {
344
+ if (agent === undefined || agent === null) return decision
345
+ const agentId = agent.id ?? String(agent)
346
+ // CC: the scope is cleared on the next user message. A non-empty claimed
347
+ // batch is a user message; an empty batch is a tool-continuation step,
348
+ // during which the active scope must stay active.
349
+ if ((messages?.length ?? 0) > 0) clearAgentScope(agentId)
350
+ // A /name gesture: tool-skill injected a skill-invocation message into
351
+ // the final decision (source.kind === 'skill-invocation').
352
+ for (const message of decision.messages ?? []) {
353
+ const source = message?.source
354
+ if (source?.kind !== 'skill-invocation' || typeof source.name !== 'string') continue
355
+ await activateSkillScope(ctx, agent, source.name, loaderOpts)
356
+ }
357
+ } catch (error) {
358
+ log(ctx, 'warn', `pre-step tool-scope handling failed: ${String(error)}`)
359
+ }
360
+ return decision
361
+ }, { prepend: true })
362
+
363
+ ctx.on('tools/pre-execute', async (exec, next) => {
364
+ try {
365
+ const agent = exec?.agent
366
+ if (agent === undefined || agent === null) return next()
367
+ const agentId = agent.id ?? String(agent)
368
+ // The `skill` tool call itself activates the skill for this round.
369
+ if (exec?.name === 'skill' && typeof exec.arguments?.name === 'string') {
370
+ await activateSkillScope(ctx, agent, exec.arguments.name, loaderOpts)
371
+ }
372
+ const scope = agentScopes.get(agentId)
373
+ if (scope === undefined) return next()
374
+ if (scope.disallowed.has(exec.name)) {
375
+ return {
376
+ kind: 'deny',
377
+ reason: `tool "${exec.name}" is disallowed while skill "${scope.skill}" is active (disallowed-tools)`,
378
+ }
379
+ }
380
+ if (scope.allowed.has(exec.name)) {
381
+ // Preserve a downstream deny; upgrade an ask to allow (CC: allowed-tools
382
+ // runs without approval). Only the decision owner short-circuits.
383
+ const decision = await next()
384
+ if (decision.kind === 'ask') return { kind: 'allow' }
385
+ return decision
386
+ }
387
+ return next()
388
+ } catch (error) {
389
+ log(ctx, 'warn', `pre-execute tool-scope handling failed: ${String(error)}`)
390
+ return next()
391
+ }
392
+ })
393
+
394
+ ctx.effect(() => () => {
395
+ for (const scope of agentScopes.values()) {
396
+ for (const dispose of scope.disposers) {
397
+ try { dispose() } catch { /* best effort */ }
398
+ }
399
+ }
400
+ agentScopes.clear()
401
+ })
402
+
403
+ log(ctx, 'info', 'tool-scope gate registered (skill allowed-tools / disallowed-tools)')
404
+ }