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 +42 -0
- package/cordis.patch.yml +7 -0
- package/package.json +59 -0
- package/src/index.js +404 -0
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.
|
package/cordis.patch.yml
ADDED
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
|
+
}
|