test-skill-registration.mjs 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205
  1. import fs from 'fs';
  2. import os from 'os';
  3. import path from 'path';
  4. import { pathToFileURL } from 'url';
  5. // Verifies the V2 skill registration payload matches OpenCode 2.0.4's
  6. // Skill.Info contract (packages/schema/src/skill.ts):
  7. // { id, name, description?, autoinvoke?, path, content }
  8. // Upstream commit 199aabe9e2 (first released in v2.0.4) renamed the required
  9. // file field `location` -> `path`. A wrong field name makes draft.add()
  10. // throw inside the host's transform rebuild, which asynchronously disables
  11. // the whole plugin ("Plugin disabled after skill.transform failed") and
  12. // takes the bootstrap hook down with it — see PR #2106 review by 80avin.
  13. const [, , inputPath] = process.argv;
  14. if (!inputPath) {
  15. console.error('Usage: node test-skill-registration.mjs PLUGIN_PATH');
  16. process.exit(2);
  17. }
  18. const pluginPath = fs.realpathSync(inputPath);
  19. const skillsDir = path.resolve(path.dirname(pluginPath), '../../skills');
  20. const mod = await import(pathToFileURL(pluginPath).href);
  21. const failures = [];
  22. // --- Run 1: passive capture of every draft.add payload -------------------
  23. const added = [];
  24. await mod.default.setup(makeCtx({ add: (skill) => added.push(skill) }));
  25. const expectedIds = fs.existsSync(skillsDir)
  26. ? fs.readdirSync(skillsDir, { withFileTypes: true })
  27. .filter((e) => e.isDirectory() && !e.name.startsWith('.'))
  28. .filter((e) => fs.existsSync(path.join(skillsDir, e.name, 'SKILL.md')))
  29. .map((e) => e.name)
  30. .sort()
  31. : [];
  32. if (added.length === 0) {
  33. failures.push('expected setup() to register at least one skill via draft.add()');
  34. }
  35. if (JSON.stringify(added.map((s) => s.id).sort()) !== JSON.stringify(expectedIds)) {
  36. failures.push(`expected draft.add() ids to match skills dir contents, got ${JSON.stringify(added.map((s) => s.id))}`);
  37. }
  38. for (const skill of added) {
  39. if (typeof skill.path !== 'string' || !path.isAbsolute(skill.path)) {
  40. failures.push(`skill "${skill.id}": expected required absolute Skill.Info field "path", got ${JSON.stringify(skill.path)}`);
  41. } else if (skill.path !== path.join(skillsDir, skill.id, 'SKILL.md')) {
  42. failures.push(`skill "${skill.id}": expected path ${path.join(skillsDir, skill.id, 'SKILL.md')}, got ${skill.path}`);
  43. } else if (!fs.existsSync(skill.path)) {
  44. failures.push(`skill "${skill.id}": path does not exist on disk: ${skill.path}`);
  45. }
  46. // Stale 2.0.3-era fields must not leak into the payload: the host strips
  47. // unknown keys, but keeping them would silently mask a future regression
  48. // to a schema that no longer accepts `path`.
  49. if ('location' in skill) {
  50. failures.push(`skill "${skill.id}": payload still carries the pre-2.0.4 field "location"`);
  51. }
  52. if ('slash' in skill) {
  53. failures.push(`skill "${skill.id}": payload carries "slash", removed from Skill.Info in 2.0.4`);
  54. }
  55. if (typeof skill.id !== 'string' || skill.id.length === 0) failures.push(`skill payload missing non-empty "id"`);
  56. if (typeof skill.name !== 'string' || skill.name.length === 0) failures.push(`skill "${skill.id}" missing non-empty "name"`);
  57. if (typeof skill.content !== 'string' || !skill.content.trim()) failures.push(`skill "${skill.id}" missing non-empty "content"`);
  58. if ('description' in skill && typeof skill.description !== 'string') {
  59. failures.push(`skill "${skill.id}": "description" must be a string when present`);
  60. } else if ('description' in skill && /["']$/.test(skill.description)) {
  61. failures.push(`skill "${skill.id}": description ends with a dangling quote: ${JSON.stringify(skill.description)}`);
  62. }
  63. if (typeof skill.content === 'string' && skill.content.startsWith('---')) {
  64. failures.push(`skill "${skill.id}": content still starts with the frontmatter delimiter`);
  65. }
  66. }
  67. // --- Run 2: hostile draft.add must not abort the remaining registrations --
  68. // The real host swallows a throw escaping the transform callback and then
  69. // hard-disables the plugin asynchronously. Locally we can only observe the
  70. // synchronous half of that contract: when draft.add() rejects one skill, the
  71. // plugin must keep registering the rest instead of aborting the loop.
  72. const hostileId = added.length > 1 ? added[Math.floor(added.length / 2)].id : null;
  73. const survived = [];
  74. let setupThrew = null;
  75. let survivingContextHook;
  76. try {
  77. await mod.default.setup(makeCtx({
  78. add: (skill) => {
  79. if (skill.id === hostileId) throw new Error('Simulated Skill.Info decode failure');
  80. survived.push(skill.id);
  81. },
  82. onHook: (name, callback) => {
  83. if (name === 'context') survivingContextHook = callback;
  84. },
  85. }));
  86. } catch (err) {
  87. setupThrew = err;
  88. }
  89. if (setupThrew) {
  90. failures.push(`expected setup() to contain draft.add() failures, but it threw: ${setupThrew.message}`);
  91. } else if (hostileId) {
  92. const expectedSurvivors = added.map((s) => s.id).filter((id) => id !== hostileId);
  93. if (JSON.stringify(survived.sort()) !== JSON.stringify(expectedSurvivors.sort())) {
  94. failures.push(`expected all non-rejected skills to still register when one draft.add() throws, got ${JSON.stringify(survived)}`);
  95. }
  96. }
  97. if (typeof survivingContextHook !== 'function') {
  98. failures.push('expected bootstrap hook to survive a rejected skill');
  99. } else {
  100. const event = {
  101. sessionID: 'registration-survival-root',
  102. messages: [{ role: 'user', content: [{ type: 'text', text: 'Continue' }] }],
  103. };
  104. await survivingContextHook(event);
  105. const count = event.messages.flatMap((message) => message.content).filter(
  106. (part) => part.type === 'text' && part.text.startsWith('<EXTREMELY_IMPORTANT>\nYou have superpowers.')
  107. ).length;
  108. if (count !== 1) failures.push(`expected surviving bootstrap once, got ${count}`);
  109. }
  110. // --- Run 3: quoted and multi-line frontmatter values ---------------------
  111. // The description is what the host shows in its skill list. A quoted value
  112. // that wraps onto indented continuation lines must register as one unquoted
  113. // line, so exercise each layout against a synthetic install: a copy of the
  114. // plugin next to fixture skills, laid out like a real package root.
  115. const frontmatterFixtures = {
  116. 'multi-line-double': {
  117. frontmatter: 'description: "Use when foo happens\n and bar continues\n and baz ends"',
  118. expected: 'Use when foo happens and bar continues and baz ends',
  119. },
  120. 'multi-line-single': {
  121. frontmatter: "description: 'Use when foo happens\n and bar continues\n and baz ends'",
  122. expected: 'Use when foo happens and bar continues and baz ends',
  123. },
  124. 'single-line-quoted': {
  125. frontmatter: 'description: "Plain quoted"',
  126. expected: 'Plain quoted',
  127. },
  128. 'block-scalar': {
  129. frontmatter: 'description: >\n Folded line one\n line two',
  130. expected: 'Folded line one line two',
  131. },
  132. };
  133. const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'superpowers-frontmatter-'));
  134. try {
  135. const fixturePlugin = path.join(fixtureRoot, '.opencode', 'plugins', 'superpowers.js');
  136. fs.mkdirSync(path.dirname(fixturePlugin), { recursive: true });
  137. fs.copyFileSync(pluginPath, fixturePlugin);
  138. for (const [id, { frontmatter }] of Object.entries(frontmatterFixtures)) {
  139. const skillDir = path.join(fixtureRoot, 'skills', id);
  140. fs.mkdirSync(skillDir, { recursive: true });
  141. fs.writeFileSync(path.join(skillDir, 'SKILL.md'), `---\nname: ${id}\n${frontmatter}\n---\n# Title\n\nBody.\n`);
  142. }
  143. const fixtureMod = await import(pathToFileURL(fixturePlugin).href);
  144. const fixtureAdded = [];
  145. await fixtureMod.default.setup(makeCtx({ add: (skill) => fixtureAdded.push(skill) }));
  146. for (const [id, { expected }] of Object.entries(frontmatterFixtures)) {
  147. const skill = fixtureAdded.find((s) => s.id === id);
  148. if (!skill) {
  149. failures.push(`fixture "${id}": expected setup() to register it`);
  150. continue;
  151. }
  152. if (skill.description !== expected) {
  153. failures.push(`fixture "${id}": expected description ${JSON.stringify(expected)}, got ${JSON.stringify(skill.description)}`);
  154. }
  155. if (skill.content.startsWith('---')) {
  156. failures.push(`fixture "${id}": content still starts with the frontmatter delimiter`);
  157. }
  158. }
  159. } finally {
  160. fs.rmSync(fixtureRoot, { recursive: true, force: true });
  161. }
  162. const result = {
  163. registered: added.length,
  164. ids: added.map((s) => s.id),
  165. allPathsValid: added.every((s) => s.path === path.join(skillsDir, s.id, 'SKILL.md') && fs.existsSync(s.path)),
  166. staleLocationField: added.some((s) => 'location' in s),
  167. hostileRejectedId: hostileId,
  168. survivedHostileAdd: JSON.stringify(survived.sort()) === JSON.stringify(added.map((s) => s.id).filter((id) => id !== hostileId).sort()),
  169. };
  170. if (failures.length > 0) {
  171. console.error(JSON.stringify(result, null, 2));
  172. for (const failure of failures) {
  173. console.error(`FAIL: ${failure}`);
  174. }
  175. process.exit(1);
  176. }
  177. console.log(JSON.stringify(result, null, 2));
  178. function makeCtx({ add, onHook = () => {} }) {
  179. return {
  180. skill: {
  181. transform: async (fn) => {
  182. await fn({ list: () => [], get: () => undefined, add, update: () => {}, remove: () => {} });
  183. },
  184. },
  185. session: {
  186. hook: async (name, callback) => onHook(name, callback),
  187. get: async ({ sessionID }) => ({ id: sessionID }), // top-level: no parentID
  188. },
  189. };
  190. }