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/src/index.js CHANGED
@@ -1,14 +1,28 @@
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, surfaces them as DSH skills so the
6
- // `/skill-name` slash trigger, the `skill` tool, and the model-visible catalog
7
- // pick them up natively. Only name+description load at discovery; the body
8
- // loads on demand — same contract as the shipped filesystem provider.
9
- // 2. System prompt section — injects <projectRoot>/.claude/rules/*.md as ordered
10
- // guidance. CLAUDE.md / AGENTS.md are already handled by dsh-agent-instructions,
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
- // Rules text is re-read every system-prompt assembly (per model step), so rule
21
- // edits take effect without a DSH restart. No file watcher — the cost of
22
- // stat'ing a handful of small markdown files is negligible per step.
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 { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
25
- import { readFile, readdir, stat } from 'node:fs/promises';
26
- import { join, dirname, resolve } from 'node:path';
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
- // 150: between project-dsh (100) and project-agents (200). DSH-native skills
38
- // win over Claude skills; Claude skills win over user-level.
39
- skillRank: z.number().default(150),
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 ?? 150;
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
- for (const c of await discoverSkills(join(claudeDir, 'skills'), this.name, this.source, this.skillRank)) {
78
- candidates.push(c);
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
- for (const c of await discoverCommands(join(claudeDir, 'commands'), this.name, this.source, this.skillRank)) {
81
- candidates.push(c);
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
- 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
+ }
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: .claude/skills (recursive, ≤3 levels) ────────────────────────
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 pathExists(rootDir))) return out;
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: .claude/commands (flat) ──────────────────────────────────────
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 pathExists(rootDir))) return out;
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
- let total = 0;
251
- for (const f of files) {
252
- let raw;
253
- try { raw = readFileSync(f, 'utf8'); } catch { continue; }
254
- const basename = f.split('/').pop();
255
- const chunk = `## ${basename}\n\n${raw.trim()}\n`;
256
- if (total + chunk.length > maxBytes) break;
257
- parts.push(chunk);
258
- total += chunk.length;
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 findProjectRoot(cwd, markers = ['.git']) {
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
+ }