dsh-claude-compat 0.2.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/README.md +71 -21
- package/README.zh-CN.md +71 -21
- package/package.json +24 -8
- package/scripts/dsh-restart.sh +107 -0
- package/src/agents.js +77 -0
- package/src/hooks.js +275 -0
- package/src/index.js +228 -139
- package/src/lib.js +123 -0
- package/src/mcp.js +133 -0
- package/src/plugin-manager.js +305 -0
- package/src/plugins.js +170 -0
package/src/index.js
CHANGED
|
@@ -1,14 +1,28 @@
|
|
|
1
1
|
// dsh-claude-compat — bridge Claude Code's .claude/ into DSH.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
// 1. Skill provider on ctx.skills — scans <projectRoot>/.claude/skills/**/SKILL.md
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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.
|
|
13
|
+
// 2. Rules message-stream injection — injects <projectRoot>/.claude/rules/*.md
|
|
14
|
+
// and ~/.claude/rules/*.md as ONE user-role <system-reminder> message
|
|
15
|
+
// prepended at the front of the message array, once per session
|
|
16
|
+
// (agent/pre-step). This mirrors Claude Code's prependUserContext channel,
|
|
17
|
+
// which models follow reliably. Same-name rule files are deduped,
|
|
18
|
+
// project .claude winning over ~/.claude.
|
|
19
|
+
// CLAUDE.md / AGENTS.md are already handled by dsh-agent-instructions,
|
|
11
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).
|
|
12
26
|
//
|
|
13
27
|
// Skill name flattening: .claude/skills/gitnexus/gitnexus-guide/SKILL.md →
|
|
14
28
|
// "gitnexus-gitnexus-guide" (DSH skill names must be kebab-case; the shipped
|
|
@@ -17,30 +31,70 @@
|
|
|
17
31
|
// Commands: .claude/commands/commit-changes.md → skill "commit-changes",
|
|
18
32
|
// user-invocable forced true so `/commit-changes` works in the slash menu.
|
|
19
33
|
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
34
|
+
// Precedence (DSH registry semantics: candidates sort by rank ascending and
|
|
35
|
+
// duplicate skill names are first-wins — LOWER rank wins). The native ladder
|
|
36
|
+
// is project-dsh 100, project-agents 200, custom 300, user-dsh 400,
|
|
37
|
+
// user-agents 500, DSH bundled 600 (fixed BUNDLED_SKILL_RANK). This plugin
|
|
38
|
+
// emits project .claude at rank 50 and ~/.claude at rank 700, so conflicts
|
|
39
|
+
// resolve as: project .claude > DSH native (600) > ~/.claude.
|
|
40
|
+
//
|
|
41
|
+
// Rules are read once per session (cached per session cwd, from
|
|
42
|
+
// agent.session.header.cwd — NOT process.cwd(), the DSH process may be
|
|
43
|
+
// launched from anywhere). Editing a rule mid-session takes effect in the
|
|
44
|
+
// next session.
|
|
23
45
|
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
46
|
+
import { readFileSync } from 'node:fs';
|
|
47
|
+
import { stat } from 'node:fs/promises';
|
|
48
|
+
import { dirname, join } from 'node:path';
|
|
27
49
|
import z from '@deepseek-ai/schemastery';
|
|
28
|
-
import { parse } from 'yaml';
|
|
29
50
|
import { isSkillName } from '@deepseek-ai/dsh-skill';
|
|
30
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';
|
|
31
65
|
|
|
32
66
|
export const name = 'claude-compat';
|
|
33
67
|
export const inject = ['skills'];
|
|
34
68
|
|
|
35
69
|
export const Config = z.object({
|
|
36
70
|
projectRootMarkers: z.array(z.string()).default(['.git']),
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
|
|
71
|
+
// DSH dedupes by rank ascending (lower wins). 50 beats the DSH-native
|
|
72
|
+
// project roots (100/200) and the bundled root (600) — project .claude wins
|
|
73
|
+
// every collision. 700 loses to bundled 600 — DSH native wins over
|
|
74
|
+
// ~/.claude. Final priority: project .claude > DSH native > ~/.claude.
|
|
75
|
+
skillRank: z.number().default(50),
|
|
40
76
|
skillSource: z.string().default('project-claude'),
|
|
77
|
+
userSkillRank: z.number().default(700),
|
|
78
|
+
userSkillSource: z.string().default('user-claude'),
|
|
79
|
+
userClaudeDir: z.string().default('~/.claude'),
|
|
41
80
|
rulesMaxBytes: z.number().default(65536),
|
|
81
|
+
userRulesMaxBytes: z.number().default(65536),
|
|
42
82
|
enableRules: z.boolean().default(true),
|
|
43
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),
|
|
44
98
|
});
|
|
45
99
|
|
|
46
100
|
export function apply(ctx, config = {}) {
|
|
@@ -51,6 +105,36 @@ export function apply(ctx, config = {}) {
|
|
|
51
105
|
if (config.enableRules !== false) {
|
|
52
106
|
registerRulesSection(ctx, config);
|
|
53
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
|
+
}
|
|
54
138
|
}
|
|
55
139
|
|
|
56
140
|
// ─── skill provider ──────────────────────────────────────────────────────────
|
|
@@ -60,35 +144,118 @@ class ClaudeCompatSkillProvider {
|
|
|
60
144
|
this.ctx = ctx;
|
|
61
145
|
this.name = 'claude-compat';
|
|
62
146
|
this.config = config;
|
|
63
|
-
this.skillRank = config.skillRank ??
|
|
147
|
+
this.skillRank = config.skillRank ?? 50;
|
|
64
148
|
this.source = config.skillSource ?? 'project-claude';
|
|
149
|
+
this.userSkillRank = config.userSkillRank ?? 700;
|
|
150
|
+
this.userSkillSource = config.userSkillSource ?? 'user-claude';
|
|
151
|
+
this.userClaudeDir = resolveUserClaudeDir(config.userClaudeDir);
|
|
152
|
+
this.control = control;
|
|
65
153
|
control.signal.addEventListener('abort', () => {}, { once: true });
|
|
66
154
|
}
|
|
67
155
|
|
|
68
156
|
async list(options) {
|
|
69
157
|
const cwd = options?.cwd;
|
|
70
|
-
if (cwd === undefined || cwd === null) return [];
|
|
71
|
-
const projectRoot = await findProjectRoot(cwd, this.config.projectRootMarkers);
|
|
72
|
-
if (projectRoot === undefined) return [];
|
|
73
|
-
const claudeDir = join(projectRoot, '.claude');
|
|
74
|
-
if (!(await pathExists(claudeDir))) return [];
|
|
75
|
-
|
|
76
158
|
const candidates = [];
|
|
77
|
-
|
|
78
|
-
|
|
159
|
+
// Project .claude needs a cwd to locate the project root; user ~/.claude
|
|
160
|
+
// is cwd-independent (mirrors DSH's own user-dsh/user-agents roots, which
|
|
161
|
+
// are scanned unconditionally).
|
|
162
|
+
if (cwd !== undefined && cwd !== null) {
|
|
163
|
+
const projectRoot = await findProjectRoot(cwd, this.config.projectRootMarkers);
|
|
164
|
+
if (projectRoot !== undefined) {
|
|
165
|
+
await this.addRootCandidates(candidates, join(projectRoot, '.claude'), this.source, this.skillRank);
|
|
166
|
+
}
|
|
79
167
|
}
|
|
80
|
-
|
|
81
|
-
|
|
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);
|
|
82
184
|
}
|
|
83
185
|
return candidates;
|
|
84
186
|
}
|
|
85
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
|
+
|
|
233
|
+
async addRootCandidates(candidates, claudeDir, source, rank) {
|
|
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));
|
|
242
|
+
}
|
|
243
|
+
const batches = await Promise.all(tasks);
|
|
244
|
+
for (const batch of batches) candidates.push(...batch);
|
|
245
|
+
}
|
|
246
|
+
|
|
86
247
|
async get(candidate) {
|
|
87
248
|
const locator = candidate.locator;
|
|
88
249
|
const raw = await readTextSafe(locator.path);
|
|
89
250
|
if (raw === undefined) return undefined;
|
|
90
251
|
const parsed = parseFrontmatter(raw);
|
|
91
|
-
|
|
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
|
+
}
|
|
92
259
|
return {
|
|
93
260
|
name: candidate.name,
|
|
94
261
|
description: candidate.description,
|
|
@@ -103,11 +270,12 @@ class ClaudeCompatSkillProvider {
|
|
|
103
270
|
}
|
|
104
271
|
}
|
|
105
272
|
|
|
106
|
-
// ─── discovery:
|
|
273
|
+
// ─── discovery: <root>/.claude/skills (recursive, ≤3 levels) ─────────────────
|
|
107
274
|
|
|
108
|
-
async function discoverSkills(rootDir, providerName, source, rank) {
|
|
275
|
+
export async function discoverSkills(rootDir, providerName, source, rank) {
|
|
276
|
+
const { readdir } = await import('node:fs/promises');
|
|
109
277
|
const out = [];
|
|
110
|
-
if (!(await
|
|
278
|
+
if (!(await pathExistsSafe(rootDir))) return out;
|
|
111
279
|
await walk(rootDir, '', 0);
|
|
112
280
|
return out;
|
|
113
281
|
|
|
@@ -132,11 +300,12 @@ async function discoverSkills(rootDir, providerName, source, rank) {
|
|
|
132
300
|
}
|
|
133
301
|
}
|
|
134
302
|
|
|
135
|
-
// ─── discovery:
|
|
303
|
+
// ─── discovery: <root>/.claude/commands (flat) ───────────────────────────────
|
|
136
304
|
|
|
137
|
-
async function discoverCommands(rootDir, providerName, source, rank) {
|
|
305
|
+
export async function discoverCommands(rootDir, providerName, source, rank) {
|
|
306
|
+
const { readdir } = await import('node:fs/promises');
|
|
138
307
|
const out = [];
|
|
139
|
-
if (!(await
|
|
308
|
+
if (!(await pathExistsSafe(rootDir))) return out;
|
|
140
309
|
let entries;
|
|
141
310
|
try {
|
|
142
311
|
entries = await readdir(rootDir, { withFileTypes: true, encoding: 'utf8' });
|
|
@@ -241,22 +410,29 @@ function registerRulesSection(ctx, config) {
|
|
|
241
410
|
}
|
|
242
411
|
|
|
243
412
|
function buildRulesText(cwd, config, maxBytes) {
|
|
244
|
-
const projectRoot = findProjectRootSync(cwd, config.projectRootMarkers);
|
|
245
|
-
if (projectRoot === undefined) return '';
|
|
246
|
-
const rulesDir = join(projectRoot, '.claude', 'rules');
|
|
247
|
-
const files = listMdFilesSync(rulesDir);
|
|
248
|
-
if (files.length === 0) return '';
|
|
249
413
|
const parts = [];
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
let
|
|
253
|
-
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
414
|
+
const seen = new Set();
|
|
415
|
+
const pushRoot = (rootDir, cap) => {
|
|
416
|
+
let remaining = cap;
|
|
417
|
+
const files = listMdFilesSync(rootDir);
|
|
418
|
+
for (const f of files) {
|
|
419
|
+
const basename = f.split('/').pop();
|
|
420
|
+
if (seen.has(basename)) continue; // higher-priority root already included
|
|
421
|
+
let raw;
|
|
422
|
+
try { raw = readFileSync(f, 'utf8'); } catch { continue; }
|
|
423
|
+
const chunk = `## ${basename}\n\n${raw.trim()}\n`;
|
|
424
|
+
if (chunk.length > remaining) break;
|
|
425
|
+
remaining -= chunk.length;
|
|
426
|
+
seen.add(basename);
|
|
427
|
+
parts.push(chunk);
|
|
428
|
+
}
|
|
429
|
+
};
|
|
430
|
+
// Project .claude/rules first: wins same-name collisions against ~/.claude.
|
|
431
|
+
const projectRoot = findProjectRootSync(cwd, config.projectRootMarkers);
|
|
432
|
+
if (projectRoot !== undefined) {
|
|
433
|
+
pushRoot(join(projectRoot, '.claude', 'rules'), maxBytes);
|
|
259
434
|
}
|
|
435
|
+
pushRoot(join(resolveUserClaudeDir(config.userClaudeDir), 'rules'), config.userRulesMaxBytes ?? maxBytes);
|
|
260
436
|
if (parts.length === 0) return '';
|
|
261
437
|
// Exact envelope Claude Code uses in prependUserContext (api.ts):
|
|
262
438
|
// user-role <system-reminder> with "# claudeMd" framing.
|
|
@@ -267,99 +443,12 @@ ${parts.join('\n')}
|
|
|
267
443
|
</system-reminder>`;
|
|
268
444
|
}
|
|
269
445
|
|
|
270
|
-
function findProjectRootSync(cwd, markers = ['.git']) {
|
|
271
|
-
let current = resolve(cwd);
|
|
272
|
-
while (true) {
|
|
273
|
-
for (const marker of markers) {
|
|
274
|
-
if (existsSync(join(current, marker))) return current;
|
|
275
|
-
}
|
|
276
|
-
const parent = dirname(current);
|
|
277
|
-
if (parent === current) return undefined;
|
|
278
|
-
current = parent;
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
function listMdFilesSync(dir) {
|
|
283
|
-
let entries;
|
|
284
|
-
try { entries = readdirSync(dir); } catch { return []; }
|
|
285
|
-
const files = [];
|
|
286
|
-
for (const name of entries) {
|
|
287
|
-
if (!name.endsWith('.md')) continue;
|
|
288
|
-
const p = join(dir, name);
|
|
289
|
-
try { if (statSync(p).isFile()) files.push(p); } catch {}
|
|
290
|
-
}
|
|
291
|
-
files.sort();
|
|
292
|
-
return files;
|
|
293
|
-
}
|
|
294
|
-
|
|
295
446
|
// ─── helpers ─────────────────────────────────────────────────────────────────
|
|
296
447
|
|
|
297
|
-
async function
|
|
298
|
-
let current = resolve(cwd);
|
|
299
|
-
while (true) {
|
|
300
|
-
for (const marker of markers) {
|
|
301
|
-
if (await pathExists(join(current, marker))) return current;
|
|
302
|
-
}
|
|
303
|
-
const parent = dirname(current);
|
|
304
|
-
if (parent === current) return undefined;
|
|
305
|
-
current = parent;
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
async function pathExists(path) {
|
|
448
|
+
async function pathExistsSafe(path) {
|
|
310
449
|
try { await stat(path); return true; } catch { return false; }
|
|
311
450
|
}
|
|
312
451
|
|
|
313
|
-
async function readTextSafe(path) {
|
|
314
|
-
try { return await readFile(path, { encoding: 'utf8' }); }
|
|
315
|
-
catch { return undefined; }
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
async function listMdFiles(dir) {
|
|
319
|
-
let entries;
|
|
320
|
-
try { entries = await readdir(dir, { withFileTypes: true, encoding: 'utf8' }); }
|
|
321
|
-
catch { return []; }
|
|
322
|
-
const files = [];
|
|
323
|
-
for (const e of entries) {
|
|
324
|
-
if (e.isFile() && e.name.endsWith('.md')) files.push(join(dir, e.name));
|
|
325
|
-
}
|
|
326
|
-
files.sort();
|
|
327
|
-
return files;
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
function parseFrontmatter(raw) {
|
|
331
|
-
const firstLineEnd = raw.indexOf('\n');
|
|
332
|
-
if (firstLineEnd < 0) return undefined;
|
|
333
|
-
if (raw.slice(0, firstLineEnd).replace(/\r$/, '') !== '---') return undefined;
|
|
334
|
-
const start = firstLineEnd + 1;
|
|
335
|
-
const closing = findClosingFrontmatter(raw, start);
|
|
336
|
-
if (closing === undefined) return undefined;
|
|
337
|
-
let parsed;
|
|
338
|
-
try { parsed = parse(raw.slice(start, closing.start)); }
|
|
339
|
-
catch { return undefined; }
|
|
340
|
-
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return undefined;
|
|
341
|
-
return { data: parsed, body: raw.slice(closing.bodyStart) };
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
function findClosingFrontmatter(raw, start) {
|
|
345
|
-
let lineStart = start;
|
|
346
|
-
while (lineStart <= raw.length) {
|
|
347
|
-
const nextNewline = raw.indexOf('\n', lineStart);
|
|
348
|
-
const lineEnd = nextNewline < 0 ? raw.length : nextNewline;
|
|
349
|
-
if (raw.slice(lineStart, lineEnd).replace(/\r$/, '') === '---') {
|
|
350
|
-
return { start: lineStart, bodyStart: nextNewline < 0 ? raw.length : nextNewline + 1 };
|
|
351
|
-
}
|
|
352
|
-
if (nextNewline < 0) return undefined;
|
|
353
|
-
lineStart = nextNewline + 1;
|
|
354
|
-
}
|
|
355
|
-
return undefined;
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
function stringField(data, key) {
|
|
359
|
-
const v = data[key];
|
|
360
|
-
return typeof v === 'string' && v.length > 0 ? v : undefined;
|
|
361
|
-
}
|
|
362
|
-
|
|
363
452
|
function parseInvocationPolicy(data) {
|
|
364
453
|
const miv = data['disable-model-invocation'];
|
|
365
454
|
const uiv = data['user-invocable'];
|
|
@@ -377,4 +466,4 @@ function truthy(v) {
|
|
|
377
466
|
}
|
|
378
467
|
if (typeof v === 'number') return v !== 0;
|
|
379
468
|
return false;
|
|
380
|
-
}
|
|
469
|
+
}
|
package/src/lib.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Shared helpers for dsh-claude-compat (path resolution, sync/async root
|
|
2
|
+
// discovery, reader utilities, frontmatter parsing). Extracted from index.js
|
|
3
|
+
// so mcp.js / hooks.js / agents.js can reuse them without circular imports.
|
|
4
|
+
|
|
5
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
6
|
+
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
7
|
+
import { dirname, join, resolve } from 'node:path';
|
|
8
|
+
import { homedir } from 'node:os';
|
|
9
|
+
import { parse } from 'yaml';
|
|
10
|
+
|
|
11
|
+
export function resolveUserClaudeDir(dir) {
|
|
12
|
+
if (dir === undefined || dir === null || dir === '') return join(homedir(), '.claude');
|
|
13
|
+
if (dir === '~') return homedir();
|
|
14
|
+
if (dir.startsWith('~/')) return join(homedir(), dir.slice(2));
|
|
15
|
+
return resolve(dir);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function findProjectRoot(cwd, markers = ['.git']) {
|
|
19
|
+
let current = resolve(cwd);
|
|
20
|
+
while (true) {
|
|
21
|
+
for (const marker of markers) {
|
|
22
|
+
if (await pathExists(join(current, marker))) return current;
|
|
23
|
+
}
|
|
24
|
+
const parent = dirname(current);
|
|
25
|
+
if (parent === current) return undefined;
|
|
26
|
+
current = parent;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function findProjectRootSync(cwd, markers = ['.git']) {
|
|
31
|
+
let current = resolve(cwd);
|
|
32
|
+
while (true) {
|
|
33
|
+
for (const marker of markers) {
|
|
34
|
+
if (existsSync(join(current, marker))) return current;
|
|
35
|
+
}
|
|
36
|
+
const parent = dirname(current);
|
|
37
|
+
if (parent === current) return undefined;
|
|
38
|
+
current = parent;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function pathExists(path) {
|
|
43
|
+
try { await stat(path); return true; } catch { return false; }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function readTextSafe(path) {
|
|
47
|
+
try { return await readFile(path, { encoding: 'utf8' }); }
|
|
48
|
+
catch { return undefined; }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function readTextSafeSync(path) {
|
|
52
|
+
try { return readFileSync(path, 'utf8'); } catch { return undefined; }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function listMdFiles(dir) {
|
|
56
|
+
let entries;
|
|
57
|
+
try { entries = await readdir(dir, { withFileTypes: true, encoding: 'utf8' }); }
|
|
58
|
+
catch { return []; }
|
|
59
|
+
const files = [];
|
|
60
|
+
for (const e of entries) {
|
|
61
|
+
if (e.isFile() && e.name.endsWith('.md')) files.push(join(dir, e.name));
|
|
62
|
+
}
|
|
63
|
+
files.sort();
|
|
64
|
+
return files;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function listMdFilesSync(dir) {
|
|
68
|
+
let entries;
|
|
69
|
+
try { entries = readdirSync(dir); } catch { return []; }
|
|
70
|
+
const files = [];
|
|
71
|
+
for (const name of entries) {
|
|
72
|
+
if (!name.endsWith('.md')) continue;
|
|
73
|
+
const p = join(dir, name);
|
|
74
|
+
try { if (statSync(p).isFile()) files.push(p); } catch {}
|
|
75
|
+
}
|
|
76
|
+
files.sort();
|
|
77
|
+
return files;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Frontmatter: `---` line, YAML block, closing `---` line, body after. Returns
|
|
81
|
+
// { data, body } on success, undefined on anything malformed.
|
|
82
|
+
export function parseFrontmatter(raw) {
|
|
83
|
+
const firstLineEnd = raw.indexOf('\n');
|
|
84
|
+
if (firstLineEnd < 0) return undefined;
|
|
85
|
+
if (raw.slice(0, firstLineEnd).replace(/\r$/, '') !== '---') return undefined;
|
|
86
|
+
const start = firstLineEnd + 1;
|
|
87
|
+
const closing = findClosingFrontmatter(raw, start);
|
|
88
|
+
if (closing === undefined) return undefined;
|
|
89
|
+
let parsed;
|
|
90
|
+
try { parsed = parse(raw.slice(start, closing.start)); }
|
|
91
|
+
catch { return undefined; }
|
|
92
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return undefined;
|
|
93
|
+
return { data: parsed, body: raw.slice(closing.bodyStart) };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function stringField(data, key) {
|
|
97
|
+
const v = data[key];
|
|
98
|
+
return typeof v === 'string' && v.length > 0 ? v : undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function truthy(v) {
|
|
102
|
+
if (typeof v === 'boolean') return v;
|
|
103
|
+
if (typeof v === 'string') {
|
|
104
|
+
const s = v.toLowerCase();
|
|
105
|
+
return s === 'true' || s === 'yes' || s === 'on' || s === '1';
|
|
106
|
+
}
|
|
107
|
+
if (typeof v === 'number') return v !== 0;
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function findClosingFrontmatter(raw, start) {
|
|
112
|
+
let lineStart = start;
|
|
113
|
+
while (lineStart <= raw.length) {
|
|
114
|
+
const nextNewline = raw.indexOf('\n', lineStart);
|
|
115
|
+
const lineEnd = nextNewline < 0 ? raw.length : nextNewline;
|
|
116
|
+
if (raw.slice(lineStart, lineEnd).replace(/\r$/, '') === '---') {
|
|
117
|
+
return { start: lineStart, bodyStart: nextNewline < 0 ? raw.length : nextNewline + 1 };
|
|
118
|
+
}
|
|
119
|
+
if (nextNewline < 0) return undefined;
|
|
120
|
+
lineStart = nextNewline + 1;
|
|
121
|
+
}
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|