Ver código fonte

fix(opencode): strip quote pairs after joining multi-line frontmatter values

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Drew Ritter 2 dias atrás
pai
commit
01c004da99

+ 11 - 3
.opencode/plugins/superpowers.js

@@ -25,8 +25,9 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
 const superpowersSkillsDir = path.resolve(__dirname, '../../skills');
 
 // Simple frontmatter extraction (avoid dependency on skills-core for
-// bootstrap). Handles plain `key: value` lines, quoted values, YAML block
-// scalar markers (`>`, `|`) with indented continuation lines, and CRLF line
+// bootstrap). Handles plain `key: value` lines, quoted values (including
+// quotes that close on an indented continuation line), YAML block scalar
+// markers (`>`, `|`) with indented continuation lines, and CRLF line
 // endings. Not a full YAML parser — nested maps flatten into their parent
 // key's value, which is fine for the name/description fields consumed here.
 const extractAndStripFrontmatter = (content) => {
@@ -43,7 +44,7 @@ const extractAndStripFrontmatter = (content) => {
     const colonIdx = line.indexOf(':');
     if (colonIdx > 0 && !/^\s/.test(line)) {
       const key = line.slice(0, colonIdx).trim();
-      const value = line.slice(colonIdx + 1).trim().replace(/^["']|["']$/g, '');
+      const value = line.slice(colonIdx + 1).trim();
       // Block scalar markers (>, |, optionally with +/- chomping) carry no
       // value themselves; the indented lines that follow do.
       frontmatter[key] = /^(>[+-]?|\|[+-]?)$/.test(value) ? '' : value;
@@ -56,6 +57,13 @@ const extractAndStripFrontmatter = (content) => {
     }
   }
 
+  // A quoted value may close on a continuation line, so unquote only once
+  // the value is fully assembled: strip exactly one matching surrounding
+  // pair and leave unbalanced quotes alone.
+  for (const key of Object.keys(frontmatter)) {
+    frontmatter[key] = frontmatter[key].replace(/^(["'])([\s\S]*)\1$/, '$2');
+  }
+
   return { frontmatter, content: body };
 };
 

+ 59 - 0
tests/opencode/test-skill-registration.mjs

@@ -1,4 +1,5 @@
 import fs from 'fs';
+import os from 'os';
 import path from 'path';
 import { pathToFileURL } from 'url';
 
@@ -65,6 +66,11 @@ for (const skill of added) {
   if (typeof skill.content !== 'string' || !skill.content.trim()) failures.push(`skill "${skill.id}" missing non-empty "content"`);
   if ('description' in skill && typeof skill.description !== 'string') {
     failures.push(`skill "${skill.id}": "description" must be a string when present`);
+  } else if ('description' in skill && /["']$/.test(skill.description)) {
+    failures.push(`skill "${skill.id}": description ends with a dangling quote: ${JSON.stringify(skill.description)}`);
+  }
+  if (typeof skill.content === 'string' && skill.content.startsWith('---')) {
+    failures.push(`skill "${skill.id}": content still starts with the frontmatter delimiter`);
   }
 }
 
@@ -112,6 +118,59 @@ if (typeof survivingContextHook !== 'function') {
   if (count !== 1) failures.push(`expected surviving bootstrap once, got ${count}`);
 }
 
+// --- Run 3: quoted and multi-line frontmatter values ---------------------
+// The description is what the host shows in its skill list. A quoted value
+// that wraps onto indented continuation lines must register as one unquoted
+// line, so exercise each layout against a synthetic install: a copy of the
+// plugin next to fixture skills, laid out like a real package root.
+const frontmatterFixtures = {
+  'multi-line-double': {
+    frontmatter: 'description: "Use when foo happens\n  and bar continues\n  and baz ends"',
+    expected: 'Use when foo happens and bar continues and baz ends',
+  },
+  'multi-line-single': {
+    frontmatter: "description: 'Use when foo happens\n  and bar continues\n  and baz ends'",
+    expected: 'Use when foo happens and bar continues and baz ends',
+  },
+  'single-line-quoted': {
+    frontmatter: 'description: "Plain quoted"',
+    expected: 'Plain quoted',
+  },
+  'block-scalar': {
+    frontmatter: 'description: >\n  Folded line one\n  line two',
+    expected: 'Folded line one line two',
+  },
+};
+const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'superpowers-frontmatter-'));
+try {
+  const fixturePlugin = path.join(fixtureRoot, '.opencode', 'plugins', 'superpowers.js');
+  fs.mkdirSync(path.dirname(fixturePlugin), { recursive: true });
+  fs.copyFileSync(pluginPath, fixturePlugin);
+  for (const [id, { frontmatter }] of Object.entries(frontmatterFixtures)) {
+    const skillDir = path.join(fixtureRoot, 'skills', id);
+    fs.mkdirSync(skillDir, { recursive: true });
+    fs.writeFileSync(path.join(skillDir, 'SKILL.md'), `---\nname: ${id}\n${frontmatter}\n---\n# Title\n\nBody.\n`);
+  }
+  const fixtureMod = await import(pathToFileURL(fixturePlugin).href);
+  const fixtureAdded = [];
+  await fixtureMod.default.setup(makeCtx({ add: (skill) => fixtureAdded.push(skill) }));
+  for (const [id, { expected }] of Object.entries(frontmatterFixtures)) {
+    const skill = fixtureAdded.find((s) => s.id === id);
+    if (!skill) {
+      failures.push(`fixture "${id}": expected setup() to register it`);
+      continue;
+    }
+    if (skill.description !== expected) {
+      failures.push(`fixture "${id}": expected description ${JSON.stringify(expected)}, got ${JSON.stringify(skill.description)}`);
+    }
+    if (skill.content.startsWith('---')) {
+      failures.push(`fixture "${id}": content still starts with the frontmatter delimiter`);
+    }
+  }
+} finally {
+  fs.rmSync(fixtureRoot, { recursive: true, force: true });
+}
+
 const result = {
   registered: added.length,
   ids: added.map((s) => s.id),

+ 1 - 0
tests/opencode/test-skill-registration.sh

@@ -17,5 +17,6 @@ node "$SCRIPT_DIR/test-skill-registration.mjs" "$OPENCODE_CONFIG_DIR/plugins/sup
 
 echo "  [PASS] Skill payloads match the 2.0.4 Skill.Info contract"
 echo "  [PASS] A rejected draft.add() skips one skill without aborting the rest"
+echo "  [PASS] Quoted and multi-line frontmatter values register unquoted"
 echo ""
 echo "=== All skill registration tests passed ==="