// dsh-plugin-module-driven-dev — self-contained Cordis bundle plugin. // // Registers a `ctx.skills` provider that serves the SKILL.md bundles shipped // inside this package's own `skills/` directory. The skills root is located // from `import.meta.url`, so the package works from any install location // (git dependency, tarball, registry) on any machine — no hardcoded paths. // // This replaces the previous cordis.patch.yml approach that pointed // `customSkillDirs` at an absolute path, which could not travel. import { readFile, readdir, watch } from 'node:fs'; import { readFile as readFileAsync } from 'node:fs/promises'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; export const name = 'dsh-plugin-module-driven-dev' export const inject = ['skills'] /** Provider registration name, unique within the host skill-registry layer. */ const PROVIDER_NAME = 'module-driven-dev' /** * Standard precedence rank for packaged skill providers and local bundled * roots (`BUNDLED_SKILL_RANK` in @deepseek-ai/dsh-skill): lowest precedence, * so a user's own project/user copies of the skill win over this bundled one. */ const RANK = 600 /** Absolute path of this package's `skills/` directory. */ const SKILLS_ROOT = fileURLToPath(new URL('./skills/', import.meta.url)) /** Register the bundled-skill provider on the host skill registry. */ export function apply(ctx) { const unregister = ctx.skills.registerProvider((control) => new BundledSkillProvider(ctx, control)) ctx.effect(() => unregister, 'dsh-plugin-module-driven-dev skill provider') } class BundledSkillProvider { constructor(ctx, control) { this.ctx = ctx this.control = control this.disposal = undefined this.watchPending = false } get providerName() { return PROVIDER_NAME } /** * Discover skill bundles and flat Markdown skills inside `skills/`. * @param {object} options - lookup options (`cwd`, `signal`) from the registry. * @returns candidates (complete discovery) or `{ candidates, complete: false }`. */ async list(options) { const signal = options?.signal let entries try { entries = await readdirAsync(SKILLS_ROOT, { withFileTypes: true, encoding: 'utf8' }) } catch (error) { if (isAbsentPathError(error)) return [] throw error } const candidates = [] for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { const locator = entry.isDirectory() ? { path: join(SKILLS_ROOT, entry.name, 'SKILL.md'), directory: join(SKILLS_ROOT, entry.name) } : entry.isFile() && entry.name.endsWith('.md') ? { path: join(SKILLS_ROOT, entry.name), directory: SKILLS_ROOT } : undefined if (locator === undefined) continue signal?.throwIfAborted() const parsed = await this.parseSkillFile(locator.path, signal) if (parsed === undefined) continue candidates.push({ name: parsed.name, description: parsed.description, ...(parsed.whenToUse !== undefined ? { whenToUse: parsed.whenToUse } : {}), invocation: parsed.invocation, provider: PROVIDER_NAME, source: 'bundled', rank: RANK, locator, resourceBase: { kind: 'directory', path: locator.directory }, path: locator.path, ...(parsed.metadata !== undefined ? { metadata: parsed.metadata } : {}) }) } return candidates } /** * Load a full skill body for the winning candidate. * @param {object} candidate - the candidate this provider returned earlier. * @param {object} options - lookup options from the registry. * @returns the full skill definition, or `undefined` when the file vanished. */ async get(candidate, options) { const signal = options?.signal const parsed = await this.parseSkillFile(candidate.locator.path, signal) if (parsed === undefined) return undefined return { name: parsed.name, description: parsed.description, ...(parsed.whenToUse !== undefined ? { whenToUse: parsed.whenToUse } : {}), invocation: parsed.invocation, source: 'bundled', provider: PROVIDER_NAME, resourceBase: { kind: 'directory', path: candidate.locator.directory }, path: candidate.locator.path, ...(parsed.metadata !== undefined ? { metadata: parsed.metadata } : {}), content: parsed.content } } /** Invalidate the catalog when skill files under `skills/` change on disk. */ observe() { if (this.disposal !== undefined) return const watcher = watch(SKILLS_ROOT, { persistent: false }, (_event, filename) => { if (typeof filename !== 'string' || !isPotentialSkillPath(filename)) return if (this.watchPending) return this.watchPending = true queueMicrotask(() => { this.watchPending = false this.control.invalidate() }) }) watcher.on('error', () => { /* watcher is best-effort; discovery still works */ }) this.disposal = () => { try { watcher.close() } catch { /* already closed */ } } } dispose() { if (this.disposal !== undefined) { this.disposal() this.disposal = undefined } } /** * Read and parse one skill file: frontmatter `name`/`description` required. * @param {string} path - absolute skill file path. * @param {AbortSignal} [signal] */ async parseSkillFile(path, signal) { signal?.throwIfAborted() let raw try { raw = await readFileAsync(path, { encoding: 'utf8', signal }) } catch (error) { signal?.throwIfAborted() if (isAbsentPathError(error)) return undefined throw error } const parsed = parseFrontmatter(raw) if (parsed === undefined) return undefined const name = stringField(parsed.data, 'name') const description = stringField(parsed.data, 'description') if (name === undefined || description === undefined || !isSkillName(name)) return undefined let invocation try { invocation = parseInvocationPolicy(parsed.data) } catch { return undefined } return { name, description, ...optionalString(parsed.data, 'whenToUse'), invocation, ...optionalMetadata(parsed.data), content: parsed.body.trim() } } } // ── small helpers ──────────────────────────────────────────────────────────── function readdirAsync(path, options) { return new Promise((resolve, reject) => readdir(path, options, (error, entries) => { if (error) reject(error) else resolve(entries) })) } /** `---`-delimited YAML-lite frontmatter for the simple key: value headers we ship. */ function parseFrontmatter(raw) { const firstLineEnd = raw.indexOf('\n') if (firstLineEnd < 0) return undefined if (raw.slice(0, firstLineEnd).replace(/\r$/, '') !== '---') return undefined const start = firstLineEnd + 1 let lineStart = start let closing while (lineStart <= raw.length) { const nextNewline = raw.indexOf('\n', lineStart) const lineEnd = nextNewline < 0 ? raw.length : nextNewline if (raw.slice(lineStart, lineEnd).replace(/\r$/, '') === '---') { closing = { start: lineStart, bodyStart: nextNewline < 0 ? raw.length : nextNewline + 1 } break } if (nextNewline < 0) return undefined lineStart = nextNewline + 1 } if (closing === undefined) return undefined const data = {} for (const line of raw.slice(start, closing.start).split('\n')) { const trimmed = line.trim() if (trimmed.length === 0 || trimmed.startsWith('#')) continue const colon = trimmed.indexOf(':') if (colon < 0) continue const key = trimmed.slice(0, colon).trim() let value = trimmed.slice(colon + 1).trim() if (value.length >= 2 && ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))) { value = value.slice(1, -1) } data[key] = value } return { data, body: raw.slice(closing.bodyStart) } } function stringField(data, key) { const value = data[key] return typeof value === 'string' && value.length > 0 ? value : undefined } function optionalString(data, key) { const value = data[key] return typeof value === 'string' && value.length > 0 ? { [key]: value } : {} } function optionalMetadata(data) { const value = data.metadata return typeof value === 'object' && value !== null && !Array.isArray(value) ? { metadata: value } : {} } /** Public kebab-case skill-name grammar. */ function isSkillName(name) { return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name) } function parseInvocationPolicy(data) { const disableModelInvocation = frontmatterBoolean(data, 'disable-model-invocation') const userInvocable = frontmatterBoolean(data, 'user-invocable') return { modelInvocable: disableModelInvocation !== true, userInvocable: userInvocable !== false } } function frontmatterBoolean(data, key) { if (!Object.hasOwn(data, key)) return undefined const value = data[key] if (typeof value === 'boolean') return value if (value === 1 || value === '1') return true if (value === 0 || value === '0') return false if (typeof value === 'string') { switch (value.toLowerCase()) { case 'true': case 'yes': case 'on': return true case 'false': case 'no': case 'off': return false } } throw new TypeError(`frontmatter field "${key}" must be a boolean`) } function isPotentialSkillPath(relativePath) { const segments = relativePath.split(/[\\/]/).filter((s) => s.length > 0) if (segments.length === 0 || segments.length > 2) return false return segments.length === 1 ? segments[0].endsWith('.md') : segments[1] === 'SKILL.md' } function isAbsentPathError(error) { return hasErrorCode(error, 'ENOENT') || hasErrorCode(error, 'ENOTDIR') } function hasErrorCode(error, code) { return typeof error === 'object' && error !== null && 'code' in error && error.code === code }