dsh-cc-loader 0.3.2 → 0.3.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-cc-loader",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "Shared parse layer for the dsh-cc ecosystem: parses Claude Code .claude/ (project + global) into a standalone .dsh intermediate representation (IR), classifying every component DIRECT/ADAPTED/UNSUPPORTED/BLOCKED and filtering unsupported ones out.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/agents.js CHANGED
@@ -57,7 +57,7 @@ export async function discoverAgents(agentsDir, scope, rank, warnings = []) {
57
57
  }
58
58
  const raw = await readTextSafe(path)
59
59
  if (raw === undefined) continue
60
- const parsed = parseFrontmatter(raw)
60
+ const parsed = parseFrontmatter(raw, warnings, `agent "${path}"`)
61
61
  if (parsed === undefined) {
62
62
  warnings.push(`agent "${path}" skipped: no frontmatter`)
63
63
  continue
package/src/load.js CHANGED
@@ -66,8 +66,9 @@ export async function loadClaude(opts = {}) {
66
66
  }
67
67
 
68
68
  // Agents: merge project + global into one catalog (project wins on name clash).
69
+ // mergeAgentCatalog reports into the array it was handed, so there is nothing
70
+ // to merge back — repeating that push duplicated every agent warning.
69
71
  const catalog = await mergeAgentCatalog(agentRoots, warnings)
70
- warnings.push(...catalog.warnings)
71
72
 
72
73
  // Permissions: discover the three settings files and merge.
73
74
  const discovered = await discoverSettings(cwd, {
package/src/plugin.js CHANGED
@@ -445,7 +445,7 @@ async function isFile(path) {
445
445
  async function discoverCommandFile(filePath, source, rank, warnings = []) {
446
446
  const raw = await readTextSafe(filePath)
447
447
  if (raw === undefined) return undefined
448
- const parsed = parseFrontmatter(raw)
448
+ const parsed = parseFrontmatter(raw, warnings, `command "${filePath}"`)
449
449
  const stem = filePath.split(/[\\/]/).pop().replace(/\.md$/, '')
450
450
  if (!isSkillName(stem)) {
451
451
  warnings.push(`command "${filePath}" skipped: name not kebab-case`)
@@ -478,7 +478,7 @@ async function discoverCommandFile(filePath, source, rank, warnings = []) {
478
478
  async function discoverAgentFile(filePath, scope, rank, warnings = []) {
479
479
  const raw = await readTextSafe(filePath)
480
480
  if (raw === undefined) return undefined
481
- const parsed = parseFrontmatter(raw)
481
+ const parsed = parseFrontmatter(raw, warnings, `agent "${filePath}"`)
482
482
  if (parsed === undefined) {
483
483
  warnings.push(`agent "${filePath}" skipped: no frontmatter`)
484
484
  return undefined
package/src/skills.js CHANGED
@@ -81,10 +81,11 @@ export async function readTextSafe(path) {
81
81
  /**
82
82
  * Collect skills + commands from one `.claude` dir into `out` (project or
83
83
  * global). Each entry carries its IR status; unsupported entries are filtered.
84
+ * @param {string[]} [warnings] - collector, shared with the callers that have
85
+ * their own warnings to report (load.js). Omit to get a fresh array back.
84
86
  * @returns {Promise<{skills: object[], commands: object[], warnings: string[]}>}
85
87
  */
86
- export async function collectClaudeDir(claudeDir, source, rank) {
87
- const warnings = []
88
+ export async function collectClaudeDir(claudeDir, source, rank, warnings = []) {
88
89
  const skills = await discoverSkills(join(claudeDir, 'skills'), source, rank, warnings)
89
90
  const commands = await discoverCommands(join(claudeDir, 'commands'), source, rank, warnings)
90
91
  return { skills, commands, warnings }
@@ -156,7 +157,7 @@ export async function discoverCommands(rootDir, source, rank, warnings = []) {
156
157
  }
157
158
  const raw = await readTextSafe(path)
158
159
  if (raw === undefined) continue
159
- const parsed = parseFrontmatter(raw)
160
+ const parsed = parseFrontmatter(raw, warnings, `command "${entry.name}"`)
160
161
  const description = parsed === undefined ? stem : (stringField(parsed.data, 'description') ?? stem)
161
162
  // Command tool-scope fields (CC kebab-case, same shape as skills).
162
163
  const allowedTools = parsed === undefined ? [] : stringList(parsed.data, 'allowed-tools')
@@ -185,7 +186,7 @@ export async function discoverCommands(rootDir, source, rank, warnings = []) {
185
186
  async function parseSkillCandidateFile(path, source, rank, flatName, warnings = []) {
186
187
  const raw = await readTextSafe(path)
187
188
  if (raw === undefined) return undefined
188
- const parsed = parseFrontmatter(raw)
189
+ const parsed = parseFrontmatter(raw, warnings, `skill "${path}"`)
189
190
  if (parsed === undefined) {
190
191
  warnings.push(`skill "${path}" skipped: no frontmatter`)
191
192
  return undefined
@@ -235,20 +236,83 @@ async function parseSkillCandidateFile(path, source, rank, flatName, warnings =
235
236
 
236
237
  // ─── frontmatter helpers ─────────────────────────────────────────────────────
237
238
 
238
- export function parseFrontmatter(raw) {
239
+ /**
240
+ * Read the `---`-delimited block at the top of a `.claude` markdown file.
241
+ *
242
+ * Claude Code tolerates frontmatter that strict YAML rejects, so dropping a
243
+ * file on a parse error loses assets CC reads happily. The recurring shape is
244
+ * an unquoted `description` carrying a second `: ` — community agent files
245
+ * produce it by putting `Context: …` or `user: '…'` inside `<example>` blocks.
246
+ * A failed strict parse therefore falls back to flat `key: value` lines, and
247
+ * says so through `warnings` rather than degrading in silence.
248
+ *
249
+ * @param {string} raw - whole file.
250
+ * @param {string[]} [warnings] - collector for the lenient-parse notice.
251
+ * @param {string} [label] - what to call the file in that notice. Callers own
252
+ * the path, so they supply it (`agent "/…/x.md"`).
253
+ * @returns {{ data: object, body: string } | undefined}
254
+ */
255
+ export function parseFrontmatter(raw, warnings, label) {
239
256
  const firstLineEnd = raw.indexOf('\n')
240
257
  if (firstLineEnd < 0) return undefined
241
258
  if (raw.slice(0, firstLineEnd).replace(/\r$/, '') !== '---') return undefined
242
259
  const start = firstLineEnd + 1
243
260
  const closing = findClosingFrontmatter(raw, start)
244
261
  if (closing === undefined) return undefined
262
+ const block = raw.slice(start, closing.start)
245
263
  let parsed
246
- try { parsed = parse(raw.slice(start, closing.start)) }
247
- catch { return undefined }
264
+ let reason
265
+ try {
266
+ parsed = parse(block)
267
+ } catch (error) {
268
+ parsed = parseLenient(block)
269
+ if (parsed === undefined) return undefined
270
+ reason = firstLineOf(String(error?.message ?? error))
271
+ }
248
272
  if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return undefined
273
+ if (reason !== undefined && warnings !== undefined) {
274
+ warnings.push(`${label ?? 'frontmatter'} has frontmatter strict YAML rejects (${reason}) — read as flat key: value; only top-level fields survive`)
275
+ }
249
276
  return { data: parsed, body: raw.slice(closing.bodyStart) }
250
277
  }
251
278
 
279
+ // Fields Claude Code documents as comma-separated lists. Strict YAML turns them
280
+ // into real arrays on its own, so this set matters only on the lenient path —
281
+ // without it `tools: Read, Bash` arrives as one bogus entry named "Read, Bash".
282
+ const COMMA_LIST_FIELDS = new Set([
283
+ 'tools', 'disallowedTools', 'skills', 'allowed-tools', 'disallowed-tools',
284
+ ])
285
+
286
+ /**
287
+ * Flat `key: value` reader for a frontmatter block strict YAML rejected. Every
288
+ * value stays a string except the documented comma lists above — coercing
289
+ * numbers would turn `description: 2024` into a number and lose the field. Only
290
+ * top-level unindented lines are read, so anything needing nesting or
291
+ * multi-line syntax is lost; this is a last resort, not a general YAML parser.
292
+ */
293
+ function parseLenient(block) {
294
+ const data = {}
295
+ let found = false
296
+ for (const line of block.split('\n')) {
297
+ const m = /^([A-Za-z][\w-]*):[ \t]?(.*)$/.exec(line.replace(/\r$/, ''))
298
+ if (m === null) continue
299
+ const value = m[2].trim()
300
+ if (value.length === 0) continue
301
+ found = true
302
+ data[m[1]] = COMMA_LIST_FIELDS.has(m[1])
303
+ ? value.split(',').map((s) => s.trim()).filter((s) => s.length > 0)
304
+ : value
305
+ }
306
+ return found ? data : undefined
307
+ }
308
+
309
+ function firstLineOf(message) {
310
+ const end = message.indexOf('\n')
311
+ const line = (end < 0 ? message : message.slice(0, end)).trim()
312
+ // YAML's first line ends with the colon that introduces its code frame.
313
+ return line.endsWith(':') ? line.slice(0, -1).trim() : line
314
+ }
315
+
252
316
  function findClosingFrontmatter(raw, start) {
253
317
  let lineStart = start
254
318
  while (lineStart <= raw.length) {