import fs from 'fs'; import path from 'path'; import { pathToFileURL } from 'url'; // Verifies the V2 skill registration payload matches OpenCode 2.0.4's // Skill.Info contract (packages/schema/src/skill.ts): // { id, name, description?, autoinvoke?, path, content } // Upstream commit 199aabe9e2 (first released in v2.0.4) renamed the required // file field `location` -> `path`. A wrong field name makes draft.add() // throw inside the host's transform rebuild, which asynchronously disables // the whole plugin ("Plugin disabled after skill.transform failed") and // takes the bootstrap hook down with it — see PR #2106 review by 80avin. const [, , inputPath] = process.argv; if (!inputPath) { console.error('Usage: node test-skill-registration.mjs PLUGIN_PATH'); process.exit(2); } const pluginPath = fs.realpathSync(inputPath); const skillsDir = path.resolve(path.dirname(pluginPath), '../../skills'); const mod = await import(pathToFileURL(pluginPath).href); const failures = []; // --- Run 1: passive capture of every draft.add payload ------------------- const added = []; await mod.default.setup(makeCtx({ add: (skill) => added.push(skill) })); const expectedIds = fs.existsSync(skillsDir) ? fs.readdirSync(skillsDir, { withFileTypes: true }) .filter((e) => e.isDirectory() && !e.name.startsWith('.')) .filter((e) => fs.existsSync(path.join(skillsDir, e.name, 'SKILL.md'))) .map((e) => e.name) .sort() : []; if (added.length === 0) { failures.push('expected setup() to register at least one skill via draft.add()'); } if (JSON.stringify(added.map((s) => s.id).sort()) !== JSON.stringify(expectedIds)) { failures.push(`expected draft.add() ids to match skills dir contents, got ${JSON.stringify(added.map((s) => s.id))}`); } for (const skill of added) { if (typeof skill.path !== 'string' || !path.isAbsolute(skill.path)) { failures.push(`skill "${skill.id}": expected required absolute Skill.Info field "path", got ${JSON.stringify(skill.path)}`); } else if (skill.path !== path.join(skillsDir, skill.id, 'SKILL.md')) { failures.push(`skill "${skill.id}": expected path ${path.join(skillsDir, skill.id, 'SKILL.md')}, got ${skill.path}`); } else if (!fs.existsSync(skill.path)) { failures.push(`skill "${skill.id}": path does not exist on disk: ${skill.path}`); } // Stale 2.0.3-era fields must not leak into the payload: the host strips // unknown keys, but keeping them would silently mask a future regression // to a schema that no longer accepts `path`. if ('location' in skill) { failures.push(`skill "${skill.id}": payload still carries the pre-2.0.4 field "location"`); } if ('slash' in skill) { failures.push(`skill "${skill.id}": payload carries "slash", removed from Skill.Info in 2.0.4`); } if (typeof skill.id !== 'string' || skill.id.length === 0) failures.push(`skill payload missing non-empty "id"`); if (typeof skill.name !== 'string' || skill.name.length === 0) failures.push(`skill "${skill.id}" missing non-empty "name"`); 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`); } } // --- Run 2: hostile draft.add must not abort the remaining registrations -- // The real host swallows a throw escaping the transform callback and then // hard-disables the plugin asynchronously. Locally we can only observe the // synchronous half of that contract: when draft.add() rejects one skill, the // plugin must keep registering the rest instead of aborting the loop. const hostileId = added.length > 1 ? added[Math.floor(added.length / 2)].id : null; const survived = []; let setupThrew = null; let survivingContextHook; try { await mod.default.setup(makeCtx({ add: (skill) => { if (skill.id === hostileId) throw new Error('Simulated Skill.Info decode failure'); survived.push(skill.id); }, onHook: (name, callback) => { if (name === 'context') survivingContextHook = callback; }, })); } catch (err) { setupThrew = err; } if (setupThrew) { failures.push(`expected setup() to contain draft.add() failures, but it threw: ${setupThrew.message}`); } else if (hostileId) { const expectedSurvivors = added.map((s) => s.id).filter((id) => id !== hostileId); if (JSON.stringify(survived.sort()) !== JSON.stringify(expectedSurvivors.sort())) { failures.push(`expected all non-rejected skills to still register when one draft.add() throws, got ${JSON.stringify(survived)}`); } } if (typeof survivingContextHook !== 'function') { failures.push('expected bootstrap hook to survive a rejected skill'); } else { const event = { sessionID: 'registration-survival-root', messages: [{ role: 'user', content: [{ type: 'text', text: 'Continue' }] }], }; await survivingContextHook(event); const count = event.messages.flatMap((message) => message.content).filter( (part) => part.type === 'text' && part.text.startsWith('\nYou have superpowers.') ).length; if (count !== 1) failures.push(`expected surviving bootstrap once, got ${count}`); } const result = { registered: added.length, ids: added.map((s) => s.id), allPathsValid: added.every((s) => s.path === path.join(skillsDir, s.id, 'SKILL.md') && fs.existsSync(s.path)), staleLocationField: added.some((s) => 'location' in s), hostileRejectedId: hostileId, survivedHostileAdd: JSON.stringify(survived.sort()) === JSON.stringify(added.map((s) => s.id).filter((id) => id !== hostileId).sort()), }; if (failures.length > 0) { console.error(JSON.stringify(result, null, 2)); for (const failure of failures) { console.error(`FAIL: ${failure}`); } process.exit(1); } console.log(JSON.stringify(result, null, 2)); function makeCtx({ add, onHook = () => {} }) { return { skill: { transform: async (fn) => { await fn({ list: () => [], get: () => undefined, add, update: () => {}, remove: () => {} }); }, }, session: { hook: async (name, callback) => onHook(name, callback), get: async ({ sessionID }) => ({ id: sessionID }), // top-level: no parentID }, }; }