dsh-claude-compat 0.3.0 → 0.6.2

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/src/hooks.js ADDED
@@ -0,0 +1,275 @@
1
+ // dsh-claude-compat: Claude Code hooks subset (PreToolUse / PostToolUse /
2
+ // UserPromptSubmit) bridged onto DSH's tools/pre-execute, tools/post-execute,
3
+ // and agent/pre-step waterfalls.
4
+ //
5
+ // Hooks are read from <projectRoot>/.claude/settings.json (and ~/.claude/
6
+ // settings.json; project wins on duplicate command strings). Commands run via
7
+ // /bin/sh -c with a Claude-style JSON payload on stdin. Decisions follow Claude
8
+ // Code semantics (exit code 2 = deny/block, hookSpecificOutput override,
9
+ // timeouts allowed-through with a warning).
10
+
11
+ import { execFile } from 'node:child_process';
12
+ import { readFileSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+ import { createUserMessage } from '@deepseek-ai/dsh-llm';
15
+ import {
16
+ findProjectRootSync,
17
+ resolveUserClaudeDir,
18
+ } from './lib.js';
19
+
20
+ // Compile a Claude Code hook matcher ("Bash", "Bash|Edit", ".*") into a
21
+ // predicate. Exact names match exactly; `*` / `.*` are wildcards; `|`
22
+ // alternates.
23
+ export function compileMatcher(matcher) {
24
+ if (typeof matcher !== 'string') return () => false;
25
+ const trimmed = matcher.trim();
26
+ if (trimmed === '' || trimmed === '*' || trimmed === '.*') return () => true;
27
+ try {
28
+ const pattern = trimmed
29
+ .split('|')
30
+ .map((part) => part.trim().split('*').join('.*'))
31
+ .join('|');
32
+ const re = new RegExp(`^(?:${pattern})$`);
33
+ return (name) => re.test(name);
34
+ } catch {
35
+ return () => false;
36
+ }
37
+ }
38
+
39
+ // ----- payloads ---------------------------------------------------------------
40
+
41
+ function preToolPayload(exec) {
42
+ return JSON.stringify({
43
+ session_id: String(exec.callId),
44
+ tool_name: exec.name,
45
+ tool_input: exec.arguments ?? {},
46
+ });
47
+ }
48
+
49
+ function postToolPayload(exec, result) {
50
+ return JSON.stringify({
51
+ tool_name: exec.name,
52
+ tool_input: exec.arguments ?? {},
53
+ tool_response: result,
54
+ });
55
+ }
56
+
57
+ function promptPayload(agent) {
58
+ let prompt = '';
59
+ const events = agent.session?.events ?? {};
60
+ for (const event of Object.values(events)) {
61
+ const data = event?.data;
62
+ if (event?.type?.endsWith('user/message') && typeof data?.content === 'string') {
63
+ prompt = data.content;
64
+ }
65
+ }
66
+ return JSON.stringify({ prompt });
67
+ }
68
+
69
+ // ----- decision mapping (pure, exported for tests) -----------------------------
70
+
71
+ // PreToolUse: exit 0 → allow; exit 2 → deny (stdout reason); other codes →
72
+ // allow with warning. stdout JSON {"hookSpecificOutput": {...permissionDecision}}
73
+ // overrides: "deny" → deny, "ask" → ask (best effort).
74
+ export function mapPreHookOutput(exitCode, stdout, { warn = console.warn } = {}) {
75
+ if (exitCode === 0) {
76
+ const specific = tryHookJson(stdout)?.hookSpecificOutput;
77
+ if (specific?.permissionDecision === 'deny') {
78
+ return { kind: 'deny', reason: String(specific.permissionDecisionReason ?? fallbackReason(stdout)) };
79
+ }
80
+ if (specific?.permissionDecision === 'ask') {
81
+ return { kind: 'ask', reason: String(specific.permissionDecisionReason ?? 'hook requests approval') };
82
+ }
83
+ return { kind: 'allow' };
84
+ }
85
+ if (exitCode === 2) {
86
+ return { kind: 'deny', reason: fallbackReason(stdout) };
87
+ }
88
+ warn(`dsh-claude-compat: PreToolUse hook exited with code ${exitCode}, allowing`);
89
+ return { kind: 'allow' };
90
+ }
91
+
92
+ // PostToolUse: exit 2 → block (stdout feedback); exit 0 with JSON stdout
93
+ // carrying an "additionalContext" string → accept with that context as an
94
+ // additional user message; anything else → accept, stdout log-only.
95
+ export function mapPostHookOutput(exitCode, stdout, { warn = console.warn } = {}) {
96
+ if (exitCode === 2) {
97
+ return { kind: 'block', feedback: [{ type: 'text', text: fallbackReason(stdout) }] };
98
+ }
99
+ if (exitCode !== 0) {
100
+ warn(`dsh-claude-compat: PostToolUse hook exited with code ${exitCode}, accepting`);
101
+ return { kind: 'accept' };
102
+ }
103
+ const trimmed = stdout.trim();
104
+ if (trimmed === '') return { kind: 'accept' };
105
+ const additional = tryHookJson(stdout)?.additionalContext;
106
+ if (typeof additional === 'string' && additional.length > 0) {
107
+ return {
108
+ kind: 'accept',
109
+ additionalContexts: [createUserMessage({
110
+ content: [{ type: 'text', text: additional }],
111
+ source: { kind: 'claude-compat', form: 'hook-context' },
112
+ })],
113
+ };
114
+ }
115
+ console.log(`dsh-claude-compat: PostToolUse hook stdout (logged, not injected): ${trimmed}`);
116
+ return { kind: 'accept' };
117
+ }
118
+
119
+ function fallbackReason(stdout) {
120
+ const trimmed = stdout.trim();
121
+ return trimmed !== '' ? trimmed : 'blocked by hook';
122
+ }
123
+
124
+ function tryHookJson(stdout) {
125
+ const s = stdout.trim();
126
+ if (s === '') return undefined;
127
+ try { return JSON.parse(s); } catch { return undefined; }
128
+ }
129
+
130
+ // ----- runner ---------------------------------------------------------------
131
+
132
+ function runHookCommand(command, stdin, { timeoutMs = 60_000 } = {}) {
133
+ return new Promise((resolve) => {
134
+ let stdout = '';
135
+ let settled = false;
136
+ const child = execFile('/bin/sh', ['-c', command], {
137
+ maxBuffer: 4 * 1024 * 1024,
138
+ });
139
+ const timer = setTimeout(() => {
140
+ if (settled) return;
141
+ settled = true;
142
+ child.kill('SIGKILL');
143
+ console.warn(`dsh-claude-compat: hook timed out after ${timeoutMs}ms, killed: ${command}`);
144
+ resolve({ exitCode: 1, stdout }); // treat as non-2, allow-through
145
+ }, timeoutMs);
146
+ child.stdout.on('data', (chunk) => { stdout += chunk; });
147
+ child.stderr.on('data', (chunk) => { stdout += chunk; }); // surface stderr in feedback
148
+ child.on('error', () => {
149
+ if (settled) return;
150
+ settled = true;
151
+ clearTimeout(timer);
152
+ console.warn(`dsh-claude-compat: hook command failed to start: ${command}`);
153
+ resolve({ exitCode: 1, stdout });
154
+ });
155
+ child.on('close', (code) => {
156
+ if (settled) return;
157
+ settled = true;
158
+ clearTimeout(timer);
159
+ resolve({ exitCode: code ?? 1, stdout });
160
+ });
161
+ child.stdin.on('error', () => {});
162
+ child.stdin.end(stdin);
163
+ });
164
+ }
165
+
166
+ // ----- settings loading ------------------------------------------------------
167
+
168
+ const EVENTS = { PreToolUse: 'pre-tool-use', PostToolUse: 'post-tool-use', UserPromptSubmit: 'user-prompt-submit' };
169
+
170
+ // Combined hooks map. Project .claude/settings.json is applied LAST so it wins
171
+ // same (event, matcher, command) keys against ~/.claude/settings.json.
172
+ export function loadHookSettings(config) {
173
+ const combined = {};
174
+ applySettingsFile(combined, join(resolveUserClaudeDir(config.userClaudeDir), 'settings.json'));
175
+ const projectRoot = findProjectRootSync(process.cwd(), config.projectRootMarkers);
176
+ if (projectRoot !== undefined) {
177
+ applySettingsFile(combined, join(projectRoot, '.claude', 'settings.json'));
178
+ }
179
+ return combined;
180
+ }
181
+
182
+ function applySettingsFile(combined, path) {
183
+ let raw;
184
+ try { raw = readFileSync(path, 'utf8'); } catch { return; }
185
+ let parsed;
186
+ try { parsed = JSON.parse(raw); } catch {
187
+ console.warn(`dsh-claude-compat: unparseable settings.json: ${path}`);
188
+ return;
189
+ }
190
+ const hooks = parsed?.hooks;
191
+ if (typeof hooks !== 'object' || hooks === null || Array.isArray(hooks)) return;
192
+ for (const [eventName, entries] of Object.entries(hooks)) {
193
+ const event = EVENTS[eventName];
194
+ if (event === undefined || !Array.isArray(entries)) continue;
195
+ for (const entry of entries) {
196
+ if (typeof entry !== 'object' || entry === null) continue;
197
+ const matcher = typeof entry.matcher === 'string' ? entry.matcher : '.*';
198
+ const commands = Array.isArray(entry.hooks)
199
+ ? entry.hooks
200
+ .filter((h) => typeof h === 'object' && h !== null && typeof h.command === 'string')
201
+ .map((h) => h.command)
202
+ : [];
203
+ for (const command of commands) {
204
+ combined[`${event}\u0000${matcher}\u0000${command}`] = { event, matcher, command };
205
+ }
206
+ }
207
+ }
208
+ }
209
+
210
+ // ----- listeners -------------------------------------------------------------
211
+
212
+ export function registerHooks(ctx, config) {
213
+ const settings = loadHookSettings(config);
214
+ const matching = (hooks, name) =>
215
+ hooks.filter((h) => compileMatcher(h.matcher)(name));
216
+
217
+ const pre = Object.values(settings).filter((h) => h.event === 'pre-tool-use');
218
+ if (pre.length > 0) {
219
+ ctx.on('tools/pre-execute', async (exec) => {
220
+ for (const hook of matching(pre, exec.name)) {
221
+ const payload = preToolPayload(exec);
222
+ const run = await runHookCommand(hook.command, payload, { timeoutMs: config.hooksTimeoutMs ?? 60_000 });
223
+ const decision = mapPreHookOutput(run.exitCode, run.stdout);
224
+ if (decision.kind !== 'allow') return decision;
225
+ }
226
+ return { kind: 'allow' };
227
+ });
228
+ }
229
+
230
+ const post = Object.values(settings).filter((h) => h.event === 'post-tool-use');
231
+ if (post.length > 0) {
232
+ ctx.on('tools/post-execute', async (exec, result) => {
233
+ let accept;
234
+ for (const hook of matching(post, exec.name)) {
235
+ const payload = postToolPayload(exec, result);
236
+ const run = await runHookCommand(hook.command, payload, { timeoutMs: config.hooksTimeoutMs ?? 60_000 });
237
+ const decision = mapPostHookOutput(run.exitCode, run.stdout);
238
+ if (decision.kind === 'block') return decision;
239
+ if (decision.additionalContexts !== undefined) accept = decision;
240
+ }
241
+ return accept ?? { kind: 'accept' };
242
+ });
243
+ }
244
+
245
+ const prompt = Object.values(settings).filter((h) => h.event === 'user-prompt-submit');
246
+ if (prompt.length > 0) {
247
+ ctx.on('agent/pre-step', async ({ agent, messages }, next) => {
248
+ const decision = await next();
249
+ if (decision.kind !== 'enter') return decision;
250
+ // First enter of the turn: the DSH loop hands the step the user claims,
251
+ // which is where Claude Code would have fired UserPromptSubmit. Hook
252
+ // output becomes an extra user message appended after the claimed ones.
253
+ const extra = await runPromptHooks(prompt, agent, config);
254
+ if (extra === undefined) return decision;
255
+ return { kind: 'enter', messages: decision.messages.concat(extra) };
256
+ });
257
+ }
258
+ }
259
+
260
+ async function runPromptHooks(hooks, agent, config) {
261
+ const timeoutMs = Math.min(config.hooksTimeoutMs ?? 60_000, 10_000); // never block the loop longer than 10s
262
+ const payload = promptPayload(agent);
263
+ const extra = [];
264
+ for (const hook of hooks) {
265
+ const run = await runHookCommand(hook.command, payload, { timeoutMs });
266
+ if (run.exitCode !== 0) continue; // non-zero: skip, never block a prompt
267
+ const text = run.stdout.trim();
268
+ if (text === '') continue;
269
+ extra.push(createUserMessage({
270
+ content: [{ type: 'text', text }],
271
+ source: { kind: 'claude-compat', form: 'hook-context' },
272
+ }));
273
+ }
274
+ return extra.length > 0 ? extra : undefined;
275
+ }
package/src/index.js CHANGED
@@ -1,13 +1,15 @@
1
1
  // dsh-claude-compat — bridge Claude Code's .claude/ into DSH.
2
2
  //
3
- // Two contributions to the live runtime:
4
- // 1. Skill provider on ctx.skills — scans <projectRoot>/.claude/skills/**/SKILL.md
5
- // and <projectRoot>/.claude/commands/*.md (plus the user-level
6
- // ~/.claude/skills and ~/.claude/commands), surfaces them as DSH skills so
7
- // the `/skill-name` slash trigger, the `skill` tool, and the model-visible
8
- // catalog pick them up natively. Only name+description load at discovery;
9
- // the body loads on demand — same contract as the shipped filesystem
10
- // provider.
3
+ // Contributions to the live runtime:
4
+ // 1. Skill provider on ctx.skills — scans <projectRoot>/.claude/skills/**/SKILL.md,
5
+ // <projectRoot>/.claude/commands/*.md and <projectRoot>/.claude/agents/*.md
6
+ // (plus the user-level ~/.claude equivalents), surfaces them as DSH skills
7
+ // so the `/skill-name` slash trigger, the `skill` tool, and the
8
+ // model-visible catalog pick them up natively. Only name+description load
9
+ // at discovery; the body loads on demand — same contract as the shipped
10
+ // filesystem provider. Agents are a delegation shim: DSH has no markdown
11
+ // subagent format, so each agent file becomes a skill whose body leads
12
+ // with an explicit "delegate with this persona" instruction.
11
13
  // 2. Rules message-stream injection — injects <projectRoot>/.claude/rules/*.md
12
14
  // and ~/.claude/rules/*.md as ONE user-role <system-reminder> message
13
15
  // prepended at the front of the message array, once per session
@@ -16,6 +18,11 @@
16
18
  // project .claude winning over ~/.claude.
17
19
  // CLAUDE.md / AGENTS.md are already handled by dsh-agent-instructions,
18
20
  // so we do NOT re-inject them here.
21
+ // 3. Hooks (Claude Code subset PreToolUse/PostToolUse/UserPromptSubmit) from
22
+ // .claude/settings.json — bridged onto tools/pre-execute,
23
+ // tools/post-execute and agent/pre-step (see src/hooks.js).
24
+ // 4. MCP servers from <projectRoot>/.mcp.json — mounted as dsh-mcp-client
25
+ // plugin instances at startup from the launch workspace (see src/mcp.js).
19
26
  //
20
27
  // Skill name flattening: .claude/skills/gitnexus/gitnexus-guide/SKILL.md →
21
28
  // "gitnexus-gitnexus-guide" (DSH skill names must be kebab-case; the shipped
@@ -36,14 +43,25 @@
36
43
  // launched from anywhere). Editing a rule mid-session takes effect in the
37
44
  // next session.
38
45
 
39
- import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
40
- import { readFile, readdir, stat } from 'node:fs/promises';
41
- import { join, dirname, resolve } from 'node:path';
42
- import { homedir } from 'node:os';
46
+ import { readFileSync } from 'node:fs';
47
+ import { stat } from 'node:fs/promises';
48
+ import { dirname, join } from 'node:path';
43
49
  import z from '@deepseek-ai/schemastery';
44
- import { parse } from 'yaml';
45
50
  import { isSkillName } from '@deepseek-ai/dsh-skill';
46
51
  import { createUserMessage } from '@deepseek-ai/dsh-llm';
52
+ import { discoverAgents, renderAgentContent } from './agents.js';
53
+ import { registerHooks } from './hooks.js';
54
+ import { mountMcpServers, mountMcpConfigs } from './mcp.js';
55
+ import { discoverPluginContent, readInstalledClaudePlugins, translatePluginMcp } from './plugins.js';
56
+ import {
57
+ findProjectRoot,
58
+ findProjectRootSync,
59
+ listMdFilesSync,
60
+ parseFrontmatter,
61
+ readTextSafe,
62
+ resolveUserClaudeDir,
63
+ stringField,
64
+ } from './lib.js';
47
65
 
48
66
  export const name = 'claude-compat';
49
67
  export const inject = ['skills'];
@@ -63,6 +81,20 @@ export const Config = z.object({
63
81
  userRulesMaxBytes: z.number().default(65536),
64
82
  enableRules: z.boolean().default(true),
65
83
  enableSkills: z.boolean().default(true),
84
+ enableMcp: z.boolean().default(true),
85
+ mcpFailOnStartupError: z.boolean().default(false),
86
+ enableHooks: z.boolean().default(true),
87
+ hooksTimeoutMs: z.number().default(60000),
88
+ enableAgents: z.boolean().default(true),
89
+ // Claude Code plugin-marketplace installs (~/.claude/plugins). Skills,
90
+ // commands and agents surface at pluginSkillRank (750: the long tail —
91
+ // everything else wins collisions). Plugin MCP servers are opt-in via
92
+ // enablePluginMcp (mounting third-party MCP is a bigger trust step).
93
+ enablePlugins: z.boolean().default(true),
94
+ pluginSkillRank: z.number().default(750),
95
+ pluginSkillSource: z.string().default('claude-plugin'),
96
+ pluginsRoot: z.string().default('~/.claude/plugins'),
97
+ enablePluginMcp: z.boolean().default(false),
66
98
  });
67
99
 
68
100
  export function apply(ctx, config = {}) {
@@ -73,6 +105,36 @@ export function apply(ctx, config = {}) {
73
105
  if (config.enableRules !== false) {
74
106
  registerRulesSection(ctx, config);
75
107
  }
108
+ if (config.enableHooks !== false) {
109
+ registerHooks(ctx, config);
110
+ }
111
+ if (config.enableMcp !== false) {
112
+ // Process-lifetime mount — deliberately NOT session-scoped (see src/mcp.js).
113
+ mountMcpServers(ctx, config).catch((error) => {
114
+ console.warn('dsh-claude-compat: MCP mount failed:', error?.message ?? error);
115
+ });
116
+ if (config.enablePlugins !== false && config.enablePluginMcp === true) {
117
+ mountPluginMcp(ctx, config).catch((error) => {
118
+ console.warn('dsh-claude-compat: plugin MCP mount failed:', error?.message ?? error);
119
+ });
120
+ }
121
+ }
122
+ }
123
+
124
+ // Opt-in: translate every installed plugin's MCP config and mount alongside
125
+ // the project .mcp.json servers (see src/mcp.js mountMcpConfigs).
126
+ async function mountPluginMcp(ctx, config) {
127
+ for (const plugin of readInstalledClaudePlugins(config.pluginsRoot)) {
128
+ const raw = await readTextSafe(join(plugin.installPath, '.claude-plugin', 'plugin.json'));
129
+ if (raw === undefined) continue;
130
+ let manifest;
131
+ try { manifest = JSON.parse(raw); } catch { continue; }
132
+ const { servers, warns } = translatePluginMcp(manifest, plugin.installPath, {
133
+ failOnStartupError: config.mcpFailOnStartupError ?? false,
134
+ });
135
+ warns.forEach((w) => console.warn(`dsh-claude-compat: plugin ${plugin.key}: ${w}`));
136
+ if (servers.length > 0) await mountMcpConfigs(ctx, servers);
137
+ }
76
138
  }
77
139
 
78
140
  // ─── skill provider ──────────────────────────────────────────────────────────
@@ -87,6 +149,7 @@ class ClaudeCompatSkillProvider {
87
149
  this.userSkillRank = config.userSkillRank ?? 700;
88
150
  this.userSkillSource = config.userSkillSource ?? 'user-claude';
89
151
  this.userClaudeDir = resolveUserClaudeDir(config.userClaudeDir);
152
+ this.control = control;
90
153
  control.signal.addEventListener('abort', () => {}, { once: true });
91
154
  }
92
155
 
@@ -103,17 +166,82 @@ class ClaudeCompatSkillProvider {
103
166
  }
104
167
  }
105
168
  await this.addRootCandidates(candidates, this.userClaudeDir, this.userSkillSource, this.userSkillRank);
169
+ if (this.config.enablePluginManager !== false) {
170
+ candidates.push(this.managerSkillCandidate('cc-plugin',
171
+ 'Manage Claude Code plugins and marketplaces: list, install, uninstall, enable, disable, update, search.',
172
+ 'User wants to install, list, enable/disable, update, or uninstall Claude Code plugins, or manage plugin marketplaces. Usage: /cc-plugin, /cc-plugin <name>[@marketplace], /cc-plugin install|uninstall|enable|disable|update|search ..., /cc-plugin marketplace list|add|remove|update.',
173
+ (await import('./plugin-manager.js')).PLUGIN_SKILL_BODY));
174
+ candidates.push(this.reloadSkillDef('reload-cc-plugins',
175
+ 'Hot-reload the skill catalog: pick up newly installed/removed Claude Code plugin skills without a new session.'));
176
+ candidates.push(this.reloadSkillDef('reload-skills',
177
+ 'Hot-reload the skill catalog (alias of /reload-cc-plugins).'));
178
+ }
179
+ if (this.config.enablePlugins !== false) {
180
+ const pluginCandidates = await discoverPluginContent(
181
+ this.config.pluginsRoot, this.name, this.config.pluginSkillSource ?? 'claude-plugin',
182
+ this.config.pluginSkillRank ?? 750, { enableAgents: this.config.enableAgents });
183
+ candidates.push(...pluginCandidates);
184
+ }
106
185
  return candidates;
107
186
  }
108
187
 
188
+ // Invalidate fires when the definition is actually loaded (get()), not at
189
+ // list() time — loading the reload skill IS the reload.
190
+ reloadSkillDef(name, description) {
191
+ return {
192
+ name,
193
+ description,
194
+ invocation: { modelInvocable: true, userInvocable: true },
195
+ provider: 'claude-compat',
196
+ source: 'claude-compat-manager',
197
+ rank: this.config.pluginManagerRank ?? 40,
198
+ locator: { kind: 'inline', path: import.meta.url },
199
+ get: async () => {
200
+ try { this.control?.invalidate?.(); } catch { /* best-effort */ }
201
+ return {
202
+ name,
203
+ description,
204
+ body: `# Catalog reloaded ✅
205
+
206
+ The DSH skill catalog cache was just dropped and observers notified.
207
+ Freshly installed/removed skills are now visible in this session (the skill
208
+ picker refreshes automatically on the next catalog read).
209
+
210
+ Notes:
211
+ - New skills from \`/plugin install\` or edits under \`.claude\` are live now.
212
+ - MCP servers shipped by plugins still require a DSH restart (process-lifetime mount).
213
+ - \`/reload-cc-plugins\` and \`/reload-skills\` are aliases.`,
214
+ };
215
+ },
216
+ };
217
+ }
218
+
219
+ managerSkillCandidate(name, description, whenToUse, body) {
220
+ return {
221
+ name,
222
+ description,
223
+ whenToUse,
224
+ invocation: { modelInvocable: true, userInvocable: true },
225
+ provider: 'claude-compat',
226
+ source: 'claude-compat-manager',
227
+ rank: this.config.pluginManagerRank ?? 40,
228
+ locator: { kind: 'inline', path: import.meta.url },
229
+ get: async () => ({ name, description, body }),
230
+ };
231
+ }
232
+
109
233
  async addRootCandidates(candidates, claudeDir, source, rank) {
110
- if (!(await pathExists(claudeDir))) return;
111
- for (const c of await discoverSkills(join(claudeDir, 'skills'), this.name, source, rank)) {
112
- candidates.push(c);
113
- }
114
- for (const c of await discoverCommands(join(claudeDir, 'commands'), this.name, source, rank)) {
115
- candidates.push(c);
234
+ const provider = this.name;
235
+ const tasks = [discoverSkills(join(claudeDir, 'skills'), provider, source, rank)];
236
+ for (const [dir, fn] of [
237
+ ['commands', discoverCommands],
238
+ ['agents', discoverAgents],
239
+ ]) {
240
+ if (dir === 'agents' && this.config.enableAgents === false) continue;
241
+ tasks.push(fn(join(claudeDir, dir), provider, source, rank));
116
242
  }
243
+ const batches = await Promise.all(tasks);
244
+ for (const batch of batches) candidates.push(...batch);
117
245
  }
118
246
 
119
247
  async get(candidate) {
@@ -121,7 +249,13 @@ class ClaudeCompatSkillProvider {
121
249
  const raw = await readTextSafe(locator.path);
122
250
  if (raw === undefined) return undefined;
123
251
  const parsed = parseFrontmatter(raw);
124
- const content = parsed === undefined ? raw.trim() : parsed.body.trim();
252
+ let content;
253
+ if (candidate.agentTools !== undefined) {
254
+ // Agent-backed candidate: lead the body with the delegation instruction.
255
+ content = renderAgentContent(candidate, (parsed?.body ?? raw).trim());
256
+ } else {
257
+ content = parsed === undefined ? raw.trim() : parsed.body.trim();
258
+ }
125
259
  return {
126
260
  name: candidate.name,
127
261
  description: candidate.description,
@@ -138,9 +272,10 @@ class ClaudeCompatSkillProvider {
138
272
 
139
273
  // ─── discovery: <root>/.claude/skills (recursive, ≤3 levels) ─────────────────
140
274
 
141
- async function discoverSkills(rootDir, providerName, source, rank) {
275
+ export async function discoverSkills(rootDir, providerName, source, rank) {
276
+ const { readdir } = await import('node:fs/promises');
142
277
  const out = [];
143
- if (!(await pathExists(rootDir))) return out;
278
+ if (!(await pathExistsSafe(rootDir))) return out;
144
279
  await walk(rootDir, '', 0);
145
280
  return out;
146
281
 
@@ -167,9 +302,10 @@ async function discoverSkills(rootDir, providerName, source, rank) {
167
302
 
168
303
  // ─── discovery: <root>/.claude/commands (flat) ───────────────────────────────
169
304
 
170
- async function discoverCommands(rootDir, providerName, source, rank) {
305
+ export async function discoverCommands(rootDir, providerName, source, rank) {
306
+ const { readdir } = await import('node:fs/promises');
171
307
  const out = [];
172
- if (!(await pathExists(rootDir))) return out;
308
+ if (!(await pathExistsSafe(rootDir))) return out;
173
309
  let entries;
174
310
  try {
175
311
  entries = await readdir(rootDir, { withFileTypes: true, encoding: 'utf8' });
@@ -307,106 +443,12 @@ ${parts.join('\n')}
307
443
  </system-reminder>`;
308
444
  }
309
445
 
310
- function findProjectRootSync(cwd, markers = ['.git']) {
311
- let current = resolve(cwd);
312
- while (true) {
313
- for (const marker of markers) {
314
- if (existsSync(join(current, marker))) return current;
315
- }
316
- const parent = dirname(current);
317
- if (parent === current) return undefined;
318
- current = parent;
319
- }
320
- }
321
-
322
- function listMdFilesSync(dir) {
323
- let entries;
324
- try { entries = readdirSync(dir); } catch { return []; }
325
- const files = [];
326
- for (const name of entries) {
327
- if (!name.endsWith('.md')) continue;
328
- const p = join(dir, name);
329
- try { if (statSync(p).isFile()) files.push(p); } catch {}
330
- }
331
- files.sort();
332
- return files;
333
- }
334
-
335
446
  // ─── helpers ─────────────────────────────────────────────────────────────────
336
447
 
337
- function resolveUserClaudeDir(dir) {
338
- if (dir === undefined || dir === null || dir === '') return join(homedir(), '.claude');
339
- if (dir === '~') return homedir();
340
- if (dir.startsWith('~/')) return join(homedir(), dir.slice(2));
341
- return resolve(dir);
342
- }
343
-
344
- async function findProjectRoot(cwd, markers = ['.git']) {
345
- let current = resolve(cwd);
346
- while (true) {
347
- for (const marker of markers) {
348
- if (await pathExists(join(current, marker))) return current;
349
- }
350
- const parent = dirname(current);
351
- if (parent === current) return undefined;
352
- current = parent;
353
- }
354
- }
355
-
356
- async function pathExists(path) {
448
+ async function pathExistsSafe(path) {
357
449
  try { await stat(path); return true; } catch { return false; }
358
450
  }
359
451
 
360
- async function readTextSafe(path) {
361
- try { return await readFile(path, { encoding: 'utf8' }); }
362
- catch { return undefined; }
363
- }
364
-
365
- async function listMdFiles(dir) {
366
- let entries;
367
- try { entries = await readdir(dir, { withFileTypes: true, encoding: 'utf8' }); }
368
- catch { return []; }
369
- const files = [];
370
- for (const e of entries) {
371
- if (e.isFile() && e.name.endsWith('.md')) files.push(join(dir, e.name));
372
- }
373
- files.sort();
374
- return files;
375
- }
376
-
377
- function parseFrontmatter(raw) {
378
- const firstLineEnd = raw.indexOf('\n');
379
- if (firstLineEnd < 0) return undefined;
380
- if (raw.slice(0, firstLineEnd).replace(/\r$/, '') !== '---') return undefined;
381
- const start = firstLineEnd + 1;
382
- const closing = findClosingFrontmatter(raw, start);
383
- if (closing === undefined) return undefined;
384
- let parsed;
385
- try { parsed = parse(raw.slice(start, closing.start)); }
386
- catch { return undefined; }
387
- if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return undefined;
388
- return { data: parsed, body: raw.slice(closing.bodyStart) };
389
- }
390
-
391
- function findClosingFrontmatter(raw, start) {
392
- let lineStart = start;
393
- while (lineStart <= raw.length) {
394
- const nextNewline = raw.indexOf('\n', lineStart);
395
- const lineEnd = nextNewline < 0 ? raw.length : nextNewline;
396
- if (raw.slice(lineStart, lineEnd).replace(/\r$/, '') === '---') {
397
- return { start: lineStart, bodyStart: nextNewline < 0 ? raw.length : nextNewline + 1 };
398
- }
399
- if (nextNewline < 0) return undefined;
400
- lineStart = nextNewline + 1;
401
- }
402
- return undefined;
403
- }
404
-
405
- function stringField(data, key) {
406
- const v = data[key];
407
- return typeof v === 'string' && v.length > 0 ? v : undefined;
408
- }
409
-
410
452
  function parseInvocationPolicy(data) {
411
453
  const miv = data['disable-model-invocation'];
412
454
  const uiv = data['user-invocable'];
@@ -424,4 +466,4 @@ function truthy(v) {
424
466
  }
425
467
  if (typeof v === 'number') return v !== 0;
426
468
  return false;
427
- }
469
+ }