diff --git a/cli/bin/commands/skills.mjs b/cli/bin/commands/skills.mjs index c57c4d0a6..d149fc43a 100644 --- a/cli/bin/commands/skills.mjs +++ b/cli/bin/commands/skills.mjs @@ -9,7 +9,7 @@ */ import { execSync } from 'node:child_process'; -import { existsSync, readFileSync, readdirSync, statSync, lstatSync, unlinkSync, mkdirSync, writeFileSync, rmSync, rmdirSync, renameSync, createWriteStream, realpathSync, symlinkSync, readlinkSync, cpSync } from 'node:fs'; +import { existsSync, readFileSync, readdirSync, statSync, lstatSync, unlinkSync, mkdirSync, writeFileSync, rmSync, rmdirSync, renameSync, createWriteStream, realpathSync, symlinkSync, readlinkSync, cpSync, copyFileSync } from 'node:fs'; import { join, resolve, dirname, relative, isAbsolute, sep } from 'node:path'; import { createInterface, emitKeypressEvents } from 'node:readline'; import { fileURLToPath } from 'node:url'; @@ -675,6 +675,27 @@ function isUpToDate(root, providers, bundleDir, scope) { if (bundleHash !== localHash) return false; } } + + // Provider command artifacts (e.g. OpenCode's commands/impeccable.md) are + // part of "current" too: an install whose skills match but whose bridge is + // missing or drifted must refresh, otherwise reinstall/update report + // success while the slash command stays absent (#474 backfill). Only + // bundle-shipped files are checked, so pinned or user commands never + // affect freshness. The commands dir sits next to the matched skills dir + // (project /.opencode, user , home-dir global override), so + // deriving it from localSkillsDir stays correct for every layout + // copyProviderCommands can write. + const bundleCommandsDir = join(bundleDir, provider, 'commands'); + if (existsSync(bundleCommandsDir)) { + const localCommandsDir = join(dirname(localSkillsDir), 'commands'); + for (const entry of readdirSync(bundleCommandsDir)) { + const bundleFile = join(bundleCommandsDir, entry); + if (!statSync(bundleFile).isFile()) continue; + const localFile = join(localCommandsDir, entry); + if (!existsSync(localFile)) return false; + if (hashSkillFile(bundleFile) !== hashSkillFile(localFile)) return false; + } + } } return true; } @@ -1201,6 +1222,74 @@ function copyProviderSkills(bundleDir, root, targets, { scope } = {}) { return written; } +/** + * Copy each target provider's compiled command variant from an extracted + * bundle into the project or global config dir. OpenCode 1.18.10 discovers + * custom commands from `{command,commands}/**.md` under any active config + * dir, so the install mirrors `copyProviderSkills`: project scope writes + * `//commands/`, user scope writes + * `opencodeGlobalConfigDir(home)/commands` with the same + * `OPENCODE_CONFIG_DIR` → `$XDG_CONFIG_HOME/opencode` → `~/.config/opencode` + * precedence PR #417 established for skills. + * + * Migration guard: a pre-#406 global OpenCode install at + * `~/.opencode/commands/` is not scanned by OpenCode. After a global + * install, the commands just written are removed from the stranded + * legacy copy, sibling commands stay put, symlinked legacy dirs are + * skipped (deleting through a symlink would empty the real target), and + * a home-rooted git repo (`/commands/` IS a project install) + * is left alone. Symmetric to `copyProviderSkills` at + * `skills.mjs:1168-1186`. + */ +// Local commands dir for a provider. Project installs land at +// //commands; user-scope OpenCode installs must target the +// config dir OpenCode actually scans (OPENCODE_CONFIG_DIR → XDG → ~/.config). +function providerCommandsDir(root, providerEntry, scope) { + return scope === 'user' + ? join(opencodeGlobalConfigDir(root), 'commands') + : join(root, providerEntry.replace(/^\./, '.'), 'commands'); +} + +function copyProviderCommands(bundleDir, root, targets, { scope } = {}) { + let written = 0; + for (const target of targets) { + const providerEntry = PROVIDER_DIRS.includes(`.${target}`) + ? `.${target}` + : target; + const srcDir = join(bundleDir, providerEntry, 'commands'); + if (!existsSync(srcDir)) continue; + const localCommandsDir = providerCommandsDir(root, providerEntry, scope); + mkdirSync(localCommandsDir, { recursive: true }); + for (const entry of readdirSync(srcDir)) { + const src = join(srcDir, entry); + if (!statSync(src).isFile()) continue; + const dest = join(localCommandsDir, entry); + rmSync(dest, { recursive: true, force: true }); + copyFileSync(src, dest); + written++; + } + if (scope === 'user' && providerEntry === '.opencode') { + const legacyDir = join(root, '.opencode', 'commands'); + let migratable = false; + try { + migratable = existsSync(legacyDir) + && !lstatSync(legacyDir).isSymbolicLink() + && realpathSync(legacyDir) !== realpathSync(localCommandsDir) + && !existsSync(join(root, '.git')); + } catch { migratable = false; } + if (migratable) { + for (const entry of readdirSync(srcDir)) { + const src = join(srcDir, entry); + if (!statSync(src).isFile()) continue; + rmSync(join(legacyDir, entry), { recursive: true, force: true }); + } + try { rmdirSync(legacyDir); } catch { /* not empty: siblings stay */ } + } + } + } + return written; +} + // Native subagent definitions that ship in the bundle next to a provider's // skills. GitHub Copilot's live at `.github/agents/impeccable-*.agent.md`: // project installs commit them at `/.github/agents/`, user-level @@ -1804,6 +1893,13 @@ async function link(flags) { process.exit(1); } + // Linked installs are excluded from install/update refreshes (overwriting a + // symlink would destroy the link), so this is the only path that can deliver + // the OpenCode command bridge to them. A copy, not a symlink: the bridge is + // static and OpenCode scans the real commands dir. No-ops when the source + // checkout has no built commands (e.g. dist/ not built yet). + copyProviderCommands(source.bundleRoot, root, targets, { scope: 'project' }); + const parts = []; if (result.linked > 0) parts.push(`${result.linked} linked`); if (result.already > 0) parts.push(`${result.already} already linked`); @@ -1873,6 +1969,7 @@ async function install(flags) { migrateUnprefixImpeccable(installRoot, scope); updated = refreshProviderSkills(bundleDir, installRoot, copyTargets, scope); reportProviderAgents(copyProviderAgents(bundleDir, installRoot, copyTargets, { scope })); + copyProviderCommands(bundleDir, installRoot, copyTargets, { scope }); const v = getSkillsVersion(installRoot, scope); console.log(`Updated ${updated} skill(s)${v ? ` to v${v}` : ''}.`); } @@ -1944,6 +2041,7 @@ async function install(flags) { try { written = copyProviderSkills(bundleDir, installRoot, targets, { scope }); agentResults = copyProviderAgents(bundleDir, installRoot, targets, { scope }); + copyProviderCommands(bundleDir, installRoot, targets, { scope }); hookTargets = wantHooks ? copyProviderHooks(bundleDir, hookRoot, targets, { force, skillRoot: installRoot }) : []; } catch (e) { rmSync(bundleDir, { recursive: true, force: true }); @@ -2215,6 +2313,7 @@ async function update(flags = []) { const updated = refreshProviderSkills(tmpDir, root, copyProviders, scope); reportProviderAgents(copyProviderAgents(tmpDir, root, copyProviders, { scope })); + copyProviderCommands(tmpDir, root, copyProviders, { scope }); const wantHooks = installHooks && await decideHookInstall(root, providers, { yes }); const hookTargets = wantHooks ? copyProviderHooks(tmpDir, root, providers, { force }) : []; @@ -2250,15 +2349,18 @@ function copyDirSync(src, dest) { export { collectInstallDetections, copyProviderAgents, + copyProviderCommands, copyProviderHooks, copyProviderSkills, decideHookInstall, expectedHookDests, extractZip, formatInstallDetectionLines, + isUpToDate, linkProviderSkills, mergeHookManifests, migrateUnprefixImpeccable, + opencodeGlobalConfigDir, resolveInstallTargets, resolveLinkSource, }; diff --git a/docs/HARNESSES.md b/docs/HARNESSES.md index 68907e8aa..cffa0d500 100644 --- a/docs/HARNESSES.md +++ b/docs/HARNESSES.md @@ -45,14 +45,14 @@ Fields marked with * are spec-standard. Others are provider extensions. | `license`* | Yes | Yes | Ignored | No | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | | `compatibility`* | Yes | Yes | Ignored | No | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | | `metadata`* | Yes | Yes | Ignored | No | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| `allowed-tools`* | Yes | No | Ignored | No | No | Yes | No | Yes | Yes | Yes | Yes | Yes | Yes | -| `user-invocable` | Yes | No | No | No | Yes | Yes | No | Yes | No | Yes | Yes | Yes | No | -| `argument-hint` | Yes | No | No | No | Yes | Yes | No | Yes | No | Yes | Yes | No | No | +| `allowed-tools`* | Yes | No | Ignored | No | No | Yes | No | No | Yes | Yes | Yes | Yes | Yes | +| `user-invocable` | Yes | No | No | No | Yes | Yes | No | No | No | Yes | Yes | Yes | No | +| `argument-hint` | Yes | No | No | No | Yes | Yes | No | No | No | Yes | Yes | No | No | | `disable-model-invocation` | Yes | Yes | No | No | Yes | Yes | No | Yes | Yes | TBD | TBD | No | No | -| `model` | Yes | No | No | No | No | Yes | No | Yes | No | No | No | No | No | +| `model` | Yes | No | No | No | No | Yes | No | No | No | No | No | No | No | | `effort` | Yes | No | No | No | No | Yes | No | No | No | No | No | No | No | | `context` | Yes | No | No | No | No | No | No | No | No | No | No | No | No | -| `agent` | Yes | No | No | No | No | No | No | Yes | No | No | No | No | No | +| `agent` | Yes | No | No | No | No | No | No | No | No | No | No | No | No | | `hooks` | Yes | No | No | Yes | No | Yes | No | No | No | No | No | No | No | Notes: @@ -62,6 +62,7 @@ Notes: - Grok Build is Claude Code compatible with zero config: it also reads `.claude/skills/`, `.claude/settings.json` hooks, and Claude plugin layouts. Native paths are `.grok/skills/`, `.grok/hooks/*.json`, and `.grok/agents/`. Skill frontmatter supports `when-to-use` in addition to the fields above. Project hooks require `/hooks-trust` (or `--trust`). See https://docs.x.ai/build/features/skills-plugins-marketplaces and https://docs.x.ai/build/features/hooks. - Kiro recognizes `user-invocable` and `disable-model-invocation` per community reports but does not formally document them. - Antigravity supports standard Agent Skills spec frontmatter fields (`name`, `description`, `license`, `compatibility`, `metadata`, `allowed-tools`). +- OpenCode 1.18.10 recognises only the spec subset on SKILL.md (`name`, `description`, `license`, `compatibility`, `metadata`). Claude-style extensions (`user-invocable`, `argument-hint`, `allowed-tools`, `model`, `agent`) are silently ignored; Impeccable still emits them today for other harnesses, but they have no effect in OpenCode. Use `commands/.md` (see Placeholder / Variable Substitution below) for slash UX; OpenCode honours only `description`, `agent`, `model`, `variant`, `subtask` on command files. - Unknown fields are silently ignored by all harnesses. ## Hook surface used by Impeccable @@ -130,8 +131,8 @@ Some harnesses have separate "custom commands" systems (distinct from skills) wi | Harness | Command system | Substitution syntax | |---------|---------------|-------------------| +| OpenCode | `.opencode/commands/` (Markdown) | `$ARGUMENTS`, `$1`-`$N`, `` !`shell` ``, `@file` | | Gemini CLI | `.gemini/commands/` (TOML) | `{{args}}`, `!{shell}`, `@{file}` | | Codex CLI | `.codex/prompts/` | `$ARGNAME` | -| OpenCode | `.opencode/commands/` | `$ARGUMENTS`, `$1`-`$N`, `` !`shell` `` | Our build system handles cross-provider placeholders at compile time via `replacePlaceholders()` for `{{model}}`, `{{config_file}}`, `{{ask_instruction}}`, and `{{available_commands}}`. diff --git a/scripts/build.js b/scripts/build.js index 445b22752..34d226044 100644 --- a/scripts/build.js +++ b/scripts/build.js @@ -19,6 +19,7 @@ import path from 'path'; import fs from 'fs'; import { fileURLToPath } from 'url'; import { readSourceFiles, readPatterns, stashPerProjectArtifacts, restorePerProjectArtifacts } from './lib/utils.js'; +import { syncRootCommands } from './lib/root-commands-sync.mjs'; import { createTransformer, PROVIDERS } from './lib/transformers/index.js'; import { hooksJsonFor, buildClaudePluginHooksManifest } from './lib/transformers/hooks.js'; import { createAllZips, createProviderZip } from './lib/zip.js'; @@ -597,6 +598,11 @@ async function build() { } } + const syncedCommands = syncRootCommands(DIST_DIR, ROOT_DIR, syncConfigs); + if (syncedCommands.length > 0) { + console.log(`📟 Synced provider commands to: ${syncedCommands.join(', ')}`); + } + const syncedHooks = syncRootHookManifests(ROOT_DIR); if (syncedHooks.length > 0) { console.log(`🪝 Synced hook manifests to: ${syncedHooks.join(', ')}`); diff --git a/scripts/lib/root-commands-sync.mjs b/scripts/lib/root-commands-sync.mjs new file mode 100644 index 000000000..ae31f83d3 --- /dev/null +++ b/scripts/lib/root-commands-sync.mjs @@ -0,0 +1,27 @@ +/** + * Mirror generated provider command files (e.g. OpenCode's + * commands/impeccable.md) from dist/ into the tracked root harness folders. + * Without this, the release sync ships skills/agents/hooks but no slash + * command bridge, so direct GitHub, npx-skills, and submodule installs of + * OpenCode stay bridge-less (#483). Per-entry copy like the skills sync: + * the destination directory is never removed, so repo-local or pinned + * command files are preserved. + */ +import fs from 'node:fs'; +import path from 'node:path'; + +export function syncRootCommands(distDir, rootDir, providers) { + const synced = []; + for (const { provider, configDir } of providers) { + const src = path.join(distDir, provider, configDir, 'commands'); + if (!fs.existsSync(src)) continue; + const dest = path.join(rootDir, configDir, 'commands'); + fs.mkdirSync(dest, { recursive: true }); + for (const entry of fs.readdirSync(src, { withFileTypes: true })) { + if (!entry.isFile()) continue; + fs.copyFileSync(path.join(src, entry.name), path.join(dest, entry.name)); + } + synced.push(configDir); + } + return synced; +} diff --git a/scripts/lib/transformers/factory.js b/scripts/lib/transformers/factory.js index 195e1b159..5dfc19c30 100644 --- a/scripts/lib/transformers/factory.js +++ b/scripts/lib/transformers/factory.js @@ -379,6 +379,28 @@ export function createTransformer(config) { } } + // Ship an explicit slash-command surface for OpenCode. OpenCode registers + // skill commands natively but its TUI autocomplete hides them by deliberate + // design (anomalyco/opencode#25439); this file also pins execution policy + // (agent: build, subtask: true) and routes through OpenCode's skill tool, + // which resolves the skill base dir for any install scope. Menu visibility + // is the only part contingent on OpenCode's design; the rest is intentional. + // Schema restricted to what OpenCode recognises (description, agent, model, + // variant, subtask). + if (provider === 'opencode' && skills.length > 0) { + const commandsDir = path.join(providerDir, `${configDir}/commands`); + ensureDir(commandsDir); + for (const skill of skills) { + const bridgeBody = `Call skill({ name: "${skill.name}" }) and follow its \`Setup\` and \`Commands\` sections to handle $ARGUMENTS.\n`; + const bridgeFrontmatter = generateYamlFrontmatter({ + description: skill.description, + agent: 'build', + subtask: true, + }); + writeFile(path.join(commandsDir, `${skill.name}.md`), `${bridgeFrontmatter}\n${bridgeBody}`.replace(/\n+$/, '\n')); + } + } + if (config.agentFormat) { const agentsDir = path.join(providerDir, `${configDir}/agents`); for (const skill of skills) { diff --git a/scripts/test-suites.mjs b/scripts/test-suites.mjs index 608cea352..da76ce15f 100644 --- a/scripts/test-suites.mjs +++ b/scripts/test-suites.mjs @@ -36,13 +36,16 @@ export const SUITES = { files: [ 'tests/build.test.js', 'tests/cli-ignores.test.js', + 'tests/copy-provider-commands.test.js', 'tests/windows-path-fix.test.js', 'tests/lib/provider-blocks.test.js', 'tests/lib/transformers/provider-blocks.test.js', 'tests/lib/utils.test.js', 'tests/lib/impeccable-config.test.js', 'tests/lib/transformers/factory.test.js', + 'tests/lib/transformers/opencode-commands.test.js', 'tests/lib/transformers/providers.test.js', + 'tests/root-commands-sync.test.js', 'tests/skills-cli.test.js', 'tests/validate-plugin-versions.test.js', 'tests/validate-plugin-manifest.test.js', diff --git a/skill/scripts/pin.mjs b/skill/scripts/pin.mjs index 27eb8be35..a81435442 100644 --- a/skill/scripts/pin.mjs +++ b/skill/scripts/pin.mjs @@ -14,8 +14,9 @@ */ import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync, readdirSync } from 'node:fs'; -import { basename, join, resolve, dirname } from 'node:path'; +import { basename, join, resolve, dirname, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { homedir } from 'node:os'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -114,21 +115,119 @@ Invoke ${commandPrefix}impeccable ${command}, passing along any arguments provid `; } +// OpenCode 1.18.10 does not honor `user-invocable: true` on SKILL.md frontmatter +// (see docs/HARNESSES.md and opencode/packages/core/src/v1/config/command.ts), +// so a pinned skill there shows up in `opencode debug skill` but never in the +// slash menu. The fix is a sibling `commands/impeccable-.md` that uses the +// OpenCode command schema (description, agent, subtask). Body loads the skill +// via the skill tool and then the sub-command's reference file directly, so +// /impeccable- runs the same workflow /impeccable routes to. +const OPENCODE_PIN_MARKER = ''; +function generatePinnedOpencodeCommand(command, metadata) { + const desc = metadata[command]?.description || `Impeccable sub-command shortcut; runs the ${command} workflow via /impeccable.`; + return `--- +description: "${desc}" +agent: build +subtask: true +--- + +${OPENCODE_PIN_MARKER} + +Load the \`impeccable\` skill via the skill tool (name: "impeccable"), then run \`node /scripts/context.mjs\`, then load \`/reference/${command}.md\` and follow it. \`\` is the skill's base directory as reported by the skill tool response; substitute the actual absolute path before running or reading anything. + +$ARGUMENTS +`; +} + +// OpenCode's user-scope config dir. Mirrors the CLI's opencodeGlobalConfigDir +// precedence (OPENCODE_CONFIG_DIR → XDG_CONFIG_HOME/opencode → +// ~/.config/opencode); duplicated here because this script ships inside the +// installed skill and cannot import the CLI. +function opencodeUserConfigDir() { + if (process.env.OPENCODE_CONFIG_DIR) return process.env.OPENCODE_CONFIG_DIR; + if (process.env.XDG_CONFIG_HOME) return join(process.env.XDG_CONFIG_HOME, 'opencode'); + return join(homedir(), '.config', 'opencode'); +} + +/** + * Resolve every commands dir that should receive an OpenCode pin: the + * project-local dir when the project has the skill, plus the user config dir + * when Impeccable is installed globally (#406 layout). A user-scope skill is + * visible from every project, so its pinned commands belong next to it. + * With `forCleanup`, both commands dirs are included even when the skill is + * gone, so unpin can still reach a pin left behind by a removed install; + * removal stays safe because removePinnedOpencodeCommand is marker-guarded. + */ +function findOpencodeCommandsDirs(projectRoot, { forCleanup = false } = {}) { + const dirs = []; + const seen = new Set(); + const push = (commandsDir) => { + const key = resolve(commandsDir); + if (!seen.has(key)) { + seen.add(key); + dirs.push(commandsDir); + } + }; + if (forCleanup || existsSync(join(projectRoot, '.opencode', 'skills', 'impeccable'))) { + push(join(projectRoot, '.opencode', 'commands')); + } + const userConfig = opencodeUserConfigDir(); + if (forCleanup || existsSync(join(userConfig, 'skills', 'impeccable'))) { + push(join(userConfig, 'commands')); + } + return dirs; +} + +function writePinnedOpencodeCommand(commandsDir, command, metadata) { + const commandFile = join(commandsDir, `impeccable-${command}.md`); + if (existsSync(commandFile)) { + const existing = readFileSync(commandFile, 'utf-8'); + if (!existing.includes(OPENCODE_PIN_MARKER)) { + console.log(` SKIP: ${commandFile} (non-pinned command already exists)`); + return false; + } + } else { + mkdirSync(commandsDir, { recursive: true }); + } + writeFileSync(commandFile, generatePinnedOpencodeCommand(command, metadata)); + console.log(` + ${commandFile}`); + return true; +} + +function removePinnedOpencodeCommand(commandsDir, command) { + const commandFile = join(commandsDir, `impeccable-${command}.md`); + if (!existsSync(commandFile)) return false; + const content = readFileSync(commandFile, 'utf-8'); + if (!content.includes(OPENCODE_PIN_MARKER)) { + console.log(` SKIP: ${commandFile} (not a pinned command)`); + return false; + } + rmSync(commandFile, { force: true }); + console.log(` - ${commandFile}`); + return true; +} + /** * Pin a command: create shortcut skill in all harness dirs. */ function pin(command, projectRoot) { const metadata = loadCommandMetadata(); const harnessDirs = findHarnessDirs(projectRoot); + const opencodeCommandsDirs = findOpencodeCommandsDirs(projectRoot); - if (harnessDirs.length === 0) { + if (harnessDirs.length === 0 && opencodeCommandsDirs.length === 0) { console.log('No harness directories with impeccable installed found.'); return false; } let created = 0; + // OpenCode is handled separately below because its shortcut format is a + // slash command, not a SKILL.md. Excluding it from the skill loop here + // prevents a duplicate `.opencode/skills//SKILL.md` that OpenCode + // would never surface as `/`. for (const skillsDir of harnessDirs) { + if (skillsDir.includes(`${sep}.opencode${sep}`)) continue; const commandPrefix = commandPrefixForSkillsDir(skillsDir); const content = generatePinnedSkill(command, metadata, commandPrefix, commandPrefix === '$'); // Check if skill already exists (and isn't a pin) @@ -150,6 +249,12 @@ function pin(command, projectRoot) { created++; } + // OpenCode: write a slash command bridge, not a skill shortcut. Covers both + // project installs and user-scope (global config) installs. + for (const commandsDir of opencodeCommandsDirs) { + if (writePinnedOpencodeCommand(commandsDir, command, metadata)) created++; + } + if (created > 0) { console.log(`\nPinned '${command}' as a standalone shortcut in ${created} location(s).`); console.log('Use the pinned command directly in each harness.'); @@ -159,13 +264,17 @@ function pin(command, projectRoot) { } /** - * Unpin a command: remove shortcut skill from all harness dirs. + * Unpin a command: remove shortcut skill in all harness dirs. */ function unpin(command, projectRoot) { const harnessDirs = findHarnessDirs(projectRoot); let removed = 0; + // OpenCode has its own cleanup path below; skip the skill loop here so a + // stray `.opencode/skills//SKILL.md` written by an older Impeccable + // version is never silently dropped here. for (const skillsDir of harnessDirs) { + if (skillsDir.includes(`${sep}.opencode${sep}`)) continue; const skillDir = join(skillsDir, command); if (!existsSync(skillDir)) continue; @@ -184,6 +293,13 @@ function unpin(command, projectRoot) { removed++; } + // OpenCode: remove the pinned command file if it's one of ours, in every + // scope it could have been written to — even when the skill itself is + // already gone, since removal is marker-guarded. + for (const commandsDir of findOpencodeCommandsDirs(projectRoot, { forCleanup: true })) { + if (removePinnedOpencodeCommand(commandsDir, command)) removed++; + } + if (removed > 0) { console.log(`\nUnpinned '${command}' from ${removed} location(s).`); console.log(`Use Impeccable's '${command}' workflow directly to access it.`); diff --git a/tests/copy-provider-commands.test.js b/tests/copy-provider-commands.test.js new file mode 100644 index 000000000..d9cd33de5 --- /dev/null +++ b/tests/copy-provider-commands.test.js @@ -0,0 +1,306 @@ +/** + * Tests for copyProviderCommands. Mirrors the PR #417 migration guards for the + * skills path, applied to /commands. OpenCode discovers custom + * commands from {command,commands}/**.md in the active config dir, so a + * global install must target $OPENCODE_CONFIG_DIR/commands, $XDG_CONFIG_HOME/ + * opencode/commands, or ~/.config/opencode/commands (in that order), never + * ~/.opencode/commands which OpenCode does not scan. + */ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; +import { + mkdtempSync, + mkdirSync, + writeFileSync, + readFileSync, + existsSync, + symlinkSync, + rmSync, + realpathSync, + lstatSync, +} from 'fs'; +import { tmpdir } from 'os'; + +import { + copyProviderCommands, + isUpToDate, + opencodeGlobalConfigDir, +} from '../cli/bin/commands/skills.mjs'; + +function setupBundleWithCommand(bundleDir, providerName, commandNames) { + mkdirSync(path.join(bundleDir, providerName, 'commands'), { recursive: true }); + for (const name of commandNames) { + const file = path.join(bundleDir, providerName, 'commands', `${name}.md`); + writeFileSync( + file, + `description: Impeccable ${name} bridge\nagent: build\nsubtask: true\n\nbody ${name}\n`, + ); + } +} + +beforeEach(() => { + process.env.IMPECCABLE_BUNDLE_PATH = ''; + delete process.env.OPENCODE_CONFIG_DIR; + delete process.env.XDG_CONFIG_HOME; +}); + +afterEach(() => { + delete process.env.OPENCODE_CONFIG_DIR; + delete process.env.XDG_CONFIG_HOME; +}); + +describe('copyProviderCommands', () => { + test('writes commands to project .opencode/commands by default', () => { + const bundle = mkdtempSync(path.join(tmpdir(), 'imp-cmd-bundle-')); + const project = mkdtempSync(path.join(tmpdir(), 'imp-cmd-proj-')); + setupBundleWithCommand(bundle, '.opencode', ['impeccable']); + try { + const written = copyProviderCommands(bundle, project, ['opencode'], { scope: 'project' }); + expect(written).toBe(1); + const dest = path.join(project, '.opencode', 'commands', 'impeccable.md'); + expect(existsSync(dest)).toBe(true); + expect(readFileSync(dest, 'utf8')).toContain('impeccable bridge'); + } finally { + rmSync(bundle, { recursive: true, force: true }); + rmSync(project, { recursive: true, force: true }); + } + }); + + test('writes commands to ~/.config/opencode/commands for global scope', () => { + const bundle = mkdtempSync(path.join(tmpdir(), 'imp-cmd-bundle-')); + const home = mkdtempSync(path.join(tmpdir(), 'imp-cmd-home-')); + setupBundleWithCommand(bundle, '.opencode', ['impeccable']); + try { + const written = copyProviderCommands(bundle, home, ['opencode'], { scope: 'user' }); + expect(written).toBe(1); + const dest = path.join(home, '.config', 'opencode', 'commands', 'impeccable.md'); + expect(existsSync(dest)).toBe(true); + } finally { + rmSync(bundle, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); + } + }); + + test('honours OPENCODE_CONFIG_DIR for global scope', () => { + const bundle = mkdtempSync(path.join(tmpdir(), 'imp-cmd-bundle-')); + const home = mkdtempSync(path.join(tmpdir(), 'imp-cmd-home-')); + const customDir = mkdtempSync(path.join(tmpdir(), 'imp-cmd-custom-')); + setupBundleWithCommand(bundle, '.opencode', ['impeccable']); + try { + process.env.OPENCODE_CONFIG_DIR = customDir; + const written = copyProviderCommands(bundle, home, ['opencode'], { scope: 'user' }); + expect(written).toBe(1); + const dest = path.join(customDir, 'commands', 'impeccable.md'); + expect(existsSync(dest)).toBe(true); + expect(existsSync(path.join(home, '.config', 'opencode', 'commands'))).toBe(false); + } finally { + rmSync(bundle, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); + rmSync(customDir, { recursive: true, force: true }); + } + }); + + test('honours XDG_CONFIG_HOME/opencode/commands when OPENCODE_CONFIG_DIR is unset', () => { + const bundle = mkdtempSync(path.join(tmpdir(), 'imp-cmd-bundle-')); + const home = mkdtempSync(path.join(tmpdir(), 'imp-cmd-home-')); + const xdgRoot = mkdtempSync(path.join(tmpdir(), 'imp-cmd-xdg-')); + setupBundleWithCommand(bundle, '.opencode', ['impeccable']); + try { + process.env.XDG_CONFIG_HOME = xdgRoot; + const written = copyProviderCommands(bundle, home, ['opencode'], { scope: 'user' }); + expect(written).toBe(1); + const dest = path.join(xdgRoot, 'opencode', 'commands', 'impeccable.md'); + expect(existsSync(dest)).toBe(true); + } finally { + rmSync(bundle, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); + rmSync(xdgRoot, { recursive: true, force: true }); + } + }); + + test('migrates legacy ~/.opencode/commands entries without disturbing siblings', () => { + const bundle = mkdtempSync(path.join(tmpdir(), 'imp-cmd-bundle-')); + const home = mkdtempSync(path.join(tmpdir(), 'imp-cmd-home-')); + setupBundleWithCommand(bundle, '.opencode', ['impeccable']); + // Pre-seed a legacy copy with both a command we want to replace and a + // sibling the install must NOT touch. + const legacyDir = path.join(home, '.opencode', 'commands'); + mkdirSync(legacyDir, { recursive: true }); + writeFileSync(path.join(legacyDir, 'impeccable.md'), 'stale impeccable\n'); + writeFileSync(path.join(legacyDir, 'unrelated-command.md'), 'unrelated\n'); + try { + const written = copyProviderCommands(bundle, home, ['opencode'], { scope: 'user' }); + expect(written).toBe(1); + const dest = path.join(home, '.config', 'opencode', 'commands', 'impeccable.md'); + expect(existsSync(dest)).toBe(true); + expect(existsSync(path.join(legacyDir, 'impeccable.md'))).toBe(false); + expect(existsSync(path.join(legacyDir, 'unrelated-command.md'))).toBe(true); + expect(readFileSync(path.join(legacyDir, 'unrelated-command.md'), 'utf8')).toBe('unrelated\n'); + } finally { + rmSync(bundle, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); + } + }); + + test('does not migrate a symlinked legacy dir (shared storage)', () => { + const bundle = mkdtempSync(path.join(tmpdir(), 'imp-cmd-bundle-')); + const home = mkdtempSync(path.join(tmpdir(), 'imp-cmd-home-')); + const shared = mkdtempSync(path.join(tmpdir(), 'imp-cmd-shared-')); + setupBundleWithCommand(bundle, '.opencode', ['impeccable']); + mkdirSync(path.join(home, '.opencode'), { recursive: true }); + symlinkSync(shared, path.join(home, '.opencode', 'commands'), 'dir'); + writeFileSync(path.join(shared, 'unrelated-command.md'), 'unrelated\n'); + try { + copyProviderCommands(bundle, home, ['opencode'], { scope: 'user' }); + expect(existsSync(path.join(shared, 'unrelated-command.md'))).toBe(true); + expect(lstatSync(path.join(home, '.opencode', 'commands')).isSymbolicLink()).toBe(true); + } finally { + rmSync(bundle, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); + rmSync(shared, { recursive: true, force: true }); + } + }); + + test('returns 0 when the bundle has no commands dir', () => { + const bundle = mkdtempSync(path.join(tmpdir(), 'imp-cmd-bundle-')); + const project = mkdtempSync(path.join(tmpdir(), 'imp-cmd-proj-')); + try { + const written = copyProviderCommands(bundle, project, ['opencode'], { scope: 'project' }); + expect(written).toBe(0); + expect(existsSync(path.join(project, '.opencode', 'commands'))).toBe(false); + } finally { + rmSync(bundle, { recursive: true, force: true }); + rmSync(project, { recursive: true, force: true }); + } + }); + + test('ignores providers without a commands directory', () => { + const bundle = mkdtempSync(path.join(tmpdir(), 'imp-cmd-bundle-')); + const project = mkdtempSync(path.join(tmpdir(), 'imp-cmd-proj-')); + mkdirSync(path.join(bundle, 'claude'), { recursive: true }); + try { + const written = copyProviderCommands(bundle, project, ['claude'], { scope: 'project' }); + expect(written).toBe(0); + } finally { + rmSync(bundle, { recursive: true, force: true }); + rmSync(project, { recursive: true, force: true }); + } + }); +}); + +describe('isUpToDate command awareness', () => { + function setupBundleWithSkill(bundleDir, providerName, { withCommands = true } = {}) { + const skillDir = path.join(bundleDir, providerName, 'skills', 'impeccable'); + mkdirSync(path.join(skillDir, 'scripts'), { recursive: true }); + writeFileSync(path.join(skillDir, 'SKILL.md'), '---\nname: impeccable\n---\nBundle skill.\n'); + writeFileSync(path.join(skillDir, 'scripts', 'context.mjs'), 'console.log("bundle");\n'); + if (withCommands) setupBundleWithCommand(bundleDir, providerName, ['impeccable']); + } + + function mirrorBundleSkills(bundleDir, root, providerName) { + fs.cpSync( + path.join(bundleDir, providerName, 'skills'), + path.join(root, providerName, 'skills'), + { recursive: true }, + ); + } + + function mirrorBundleCommands(bundleDir, root, providerName) { + fs.cpSync( + path.join(bundleDir, providerName, 'commands'), + path.join(root, providerName, 'commands'), + { recursive: true }, + ); + } + + test('returns false when skills match but the command bridge is missing', () => { + const bundle = mkdtempSync(path.join(tmpdir(), 'imp-cmd-bundle-')); + const project = mkdtempSync(path.join(tmpdir(), 'imp-cmd-proj-')); + setupBundleWithSkill(bundle, '.opencode'); + mirrorBundleSkills(bundle, project, '.opencode'); + try { + expect(isUpToDate(project, ['.opencode'], bundle, 'project')).toBe(false); + } finally { + rmSync(bundle, { recursive: true, force: true }); + rmSync(project, { recursive: true, force: true }); + } + }); + + test('returns true when skills and commands match the bundle', () => { + const bundle = mkdtempSync(path.join(tmpdir(), 'imp-cmd-bundle-')); + const project = mkdtempSync(path.join(tmpdir(), 'imp-cmd-proj-')); + setupBundleWithSkill(bundle, '.opencode'); + mirrorBundleSkills(bundle, project, '.opencode'); + mirrorBundleCommands(bundle, project, '.opencode'); + try { + expect(isUpToDate(project, ['.opencode'], bundle, 'project')).toBe(true); + } finally { + rmSync(bundle, { recursive: true, force: true }); + rmSync(project, { recursive: true, force: true }); + } + }); + + test('returns false when the command bridge content drifted', () => { + const bundle = mkdtempSync(path.join(tmpdir(), 'imp-cmd-bundle-')); + const project = mkdtempSync(path.join(tmpdir(), 'imp-cmd-proj-')); + setupBundleWithSkill(bundle, '.opencode'); + mirrorBundleSkills(bundle, project, '.opencode'); + mirrorBundleCommands(bundle, project, '.opencode'); + writeFileSync(path.join(project, '.opencode', 'commands', 'impeccable.md'), 'user edit drift\n'); + try { + expect(isUpToDate(project, ['.opencode'], bundle, 'project')).toBe(false); + } finally { + rmSync(bundle, { recursive: true, force: true }); + rmSync(project, { recursive: true, force: true }); + } + }); + + test('ignores local-only command files such as pinned shortcuts', () => { + const bundle = mkdtempSync(path.join(tmpdir(), 'imp-cmd-bundle-')); + const project = mkdtempSync(path.join(tmpdir(), 'imp-cmd-proj-')); + setupBundleWithSkill(bundle, '.opencode'); + mirrorBundleSkills(bundle, project, '.opencode'); + mirrorBundleCommands(bundle, project, '.opencode'); + writeFileSync(path.join(project, '.opencode', 'commands', 'impeccable-audit.md'), 'pinned\n'); + try { + expect(isUpToDate(project, ['.opencode'], bundle, 'project')).toBe(true); + } finally { + rmSync(bundle, { recursive: true, force: true }); + rmSync(project, { recursive: true, force: true }); + } + }); + + test('ignores providers whose bundle has no commands directory', () => { + const bundle = mkdtempSync(path.join(tmpdir(), 'imp-cmd-bundle-')); + const project = mkdtempSync(path.join(tmpdir(), 'imp-cmd-proj-')); + setupBundleWithSkill(bundle, '.opencode', { withCommands: false }); + mirrorBundleSkills(bundle, project, '.opencode'); + try { + expect(isUpToDate(project, ['.opencode'], bundle, 'project')).toBe(true); + } finally { + rmSync(bundle, { recursive: true, force: true }); + rmSync(project, { recursive: true, force: true }); + } + }); + + test('user scope resolves the commands dir via OPENCODE_CONFIG_DIR', () => { + const bundle = mkdtempSync(path.join(tmpdir(), 'imp-cmd-bundle-')); + const home = mkdtempSync(path.join(tmpdir(), 'imp-cmd-home-')); + const custom = mkdtempSync(path.join(tmpdir(), 'imp-cmd-custom-')); + setupBundleWithSkill(bundle, '.opencode'); + process.env.OPENCODE_CONFIG_DIR = custom; + // User-scope OpenCode skills live at /skills (HOME_SKILLS_DIR_OVERRIDES). + fs.cpSync(path.join(bundle, '.opencode', 'skills'), path.join(custom, 'skills'), { recursive: true }); + try { + expect(isUpToDate(home, ['.opencode'], bundle, 'user')).toBe(false); + fs.cpSync(path.join(bundle, '.opencode', 'commands'), path.join(custom, 'commands'), { recursive: true }); + expect(isUpToDate(home, ['.opencode'], bundle, 'user')).toBe(true); + } finally { + rmSync(bundle, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); + rmSync(custom, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/lib/transformers/opencode-commands.test.js b/tests/lib/transformers/opencode-commands.test.js new file mode 100644 index 000000000..90d04c1b9 --- /dev/null +++ b/tests/lib/transformers/opencode-commands.test.js @@ -0,0 +1,109 @@ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import fs from 'fs'; +import path from 'path'; +import { createTransformer } from '../../../scripts/lib/transformers/factory.js'; +import { PROVIDERS } from '../../../scripts/lib/transformers/providers.js'; + +const config = PROVIDERS.opencode; +const transform = createTransformer(config); + +const TEST_DIR = path.join(process.cwd(), 'test-tmp-opencode-commands'); +const COMMAND_PATH = path.join( + TEST_DIR, + `${config.provider}/${config.configDir}/commands/impeccable.md`, +); + +const SAMPLE_SKILL = { + name: 'impeccable', + description: 'Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface.', + body: '# Impeccable\n\nSkill body here.', + references: [], + scripts: [], + agents: [], +}; + +beforeEach(() => { + if (fs.existsSync(TEST_DIR)) { + fs.rmSync(TEST_DIR, { recursive: true, force: true }); + } +}); + +afterEach(() => { + if (fs.existsSync(TEST_DIR)) { + fs.rmSync(TEST_DIR, { recursive: true, force: true }); + } +}); + +describe('opencode commands bridge', () => { + test('emits .opencode/commands/impeccable.md alongside the skill', () => { + transform([SAMPLE_SKILL], TEST_DIR); + expect(fs.existsSync(COMMAND_PATH)).toBe(true); + }); + + test('command frontmatter uses only fields OpenCode recognises', () => { + transform([SAMPLE_SKILL], TEST_DIR); + const content = fs.readFileSync(COMMAND_PATH, 'utf-8'); + const fm = content.match(/^---\n([\s\S]*?)\n---/); + expect(fm).not.toBeNull(); + const lines = fm[1].split('\n').map(l => l.trim()).filter(Boolean); + const keys = lines.map(l => l.split(':')[0]); + // OpenCode only recognises: description, agent, model, variant, subtask (per + // opencode/packages/core/src/v1/config/command.ts:5-13). + const allowed = new Set(['description', 'agent', 'model', 'variant', 'subtask']); + for (const key of keys) { + expect(allowed.has(key)).toBe(true); + } + }); + + test('command description mirrors the skill description exactly', () => { + transform([SAMPLE_SKILL], TEST_DIR); + const content = fs.readFileSync(COMMAND_PATH, 'utf-8'); + const fm = content.match(/^---\n([\s\S]*?)\n---/)[1]; + const line = fm.split('\n').find(l => l.startsWith('description:')); + const value = line.slice('description:'.length).trim().replace(/^"(.*)"$/, '$1'); + expect(value).toBe(SAMPLE_SKILL.description); + }); + + test('command body delegates to the impeccable skill', () => { + transform([SAMPLE_SKILL], TEST_DIR); + const content = fs.readFileSync(COMMAND_PATH, 'utf-8'); + const body = content.replace(/^---\n[\s\S]*?\n---\n/, ''); + expect(body).toContain('skill({'); + expect(body).toContain("name: \"impeccable\""); + expect(body).toContain('Setup'); + expect(body).toContain('Commands'); + expect(body).toContain('$ARGUMENTS'); + }); + + test('command declares agent: build and subtask: true', () => { + transform([SAMPLE_SKILL], TEST_DIR); + const content = fs.readFileSync(COMMAND_PATH, 'utf-8'); + expect(content).toMatch(/^agent: build$/m); + expect(content).toMatch(/^subtask: true$/m); + }); + + test('does not emit Claude-only frontmatter fields on the command', () => { + transform([SAMPLE_SKILL], TEST_DIR); + const content = fs.readFileSync(COMMAND_PATH, 'utf-8'); + const fm = content.match(/^---\n([\s\S]*?)\n---/)[1]; + expect(fm).not.toMatch(/^version:/m); + expect(fm).not.toMatch(/^user-invocable:/m); + expect(fm).not.toMatch(/^argument-hint:/m); + expect(fm).not.toMatch(/^allowed-tools:/m); + }); + + test('emits no command when the skill is empty', () => { + transform([], TEST_DIR); + expect(fs.existsSync(path.dirname(COMMAND_PATH))).toBe(false); + }); + + test('keeps emitting the skill alongside the command', () => { + transform([SAMPLE_SKILL], TEST_DIR); + const skillPath = path.join( + TEST_DIR, + `${config.provider}/${config.configDir}/skills/impeccable/SKILL.md`, + ); + expect(fs.existsSync(skillPath)).toBe(true); + expect(fs.existsSync(COMMAND_PATH)).toBe(true); + }); +}); diff --git a/tests/pin.test.mjs b/tests/pin.test.mjs index 8c6012458..f97212735 100644 --- a/tests/pin.test.mjs +++ b/tests/pin.test.mjs @@ -8,6 +8,18 @@ import { spawnSync } from 'node:child_process'; const ROOT = process.cwd(); const PIN_SCRIPT = path.join(ROOT, 'skill', 'scripts', 'pin.mjs'); +// Neutralize any real user-scope OpenCode config so tests never write into the +// developer's actual global install. Points the resolution at a path that does +// not exist unless a test creates it. +function cleanEnv(overrides = {}) { + return { + ...process.env, + OPENCODE_CONFIG_DIR: path.join(os.tmpdir(), 'impeccable-pin-no-config'), + XDG_CONFIG_HOME: path.join(os.tmpdir(), 'impeccable-pin-no-xdg'), + ...overrides, + }; +} + describe('pin command provider syntax', () => { let project; @@ -27,6 +39,7 @@ describe('pin command provider syntax', () => { const result = spawnSync(process.execPath, [PIN_SCRIPT, 'pin', 'audit'], { cwd: project, encoding: 'utf8', + env: cleanEnv(), }); assert.equal(result.status, 0, result.stderr || result.stdout); @@ -49,3 +62,198 @@ describe('pin command provider syntax', () => { } }); }); + +describe('pin command OpenCode target', () => { + let project; + + beforeEach(() => { + project = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-pin-oc-')); + fs.writeFileSync(path.join(project, 'package.json'), '{}\n'); + fs.mkdirSync(path.join(project, '.opencode', 'skills', 'impeccable'), { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(project, { recursive: true, force: true }); + }); + + it('writes a slash command bridge for OpenCode, not a skill shortcut', () => { + const result = spawnSync(process.execPath, [PIN_SCRIPT, 'pin', 'audit'], { + cwd: project, + encoding: 'utf8', + env: cleanEnv(), + }); + + assert.equal(result.status, 0, result.stderr || result.stdout); + + const commandPath = path.join(project, '.opencode', 'commands', 'impeccable-audit.md'); + assert.ok(fs.existsSync(commandPath), `expected ${commandPath}`); + const content = fs.readFileSync(commandPath, 'utf8'); + assert.match(content, /---\ndescription:.*audit/); + assert.match(content, /agent: build/); + assert.match(content, /subtask: true/); + assert.match(content, /\/reference\/audit\.md/); + assert.doesNotMatch(content, /user-invocable:/); + assert.doesNotMatch(content, /argument-hint:/); + + const skillPath = path.join(project, '.opencode', 'skills', 'audit', 'SKILL.md'); + assert.equal(fs.existsSync(skillPath), false, 'OpenCode pin must not create a skill shortcut'); + }); + + it('unpin removes only the OpenCode command bridge', () => { + spawnSync(process.execPath, [PIN_SCRIPT, 'pin', 'audit'], { cwd: project, encoding: 'utf8', env: cleanEnv() }); + const commandPath = path.join(project, '.opencode', 'commands', 'impeccable-audit.md'); + assert.ok(fs.existsSync(commandPath)); + + const result = spawnSync(process.execPath, [PIN_SCRIPT, 'unpin', 'audit'], { + cwd: project, + encoding: 'utf8', + env: cleanEnv(), + }); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.equal(fs.existsSync(commandPath), false); + }); + + it('unpin cleans the project command after the skill was removed', () => { + spawnSync(process.execPath, [PIN_SCRIPT, 'pin', 'audit'], { cwd: project, encoding: 'utf8', env: cleanEnv() }); + const commandPath = path.join(project, '.opencode', 'commands', 'impeccable-audit.md'); + assert.ok(fs.existsSync(commandPath)); + + // Skill removed before unpin (e.g. uninstall): cleanup must still find the pin. + fs.rmSync(path.join(project, '.opencode', 'skills', 'impeccable'), { recursive: true, force: true }); + + const result = spawnSync(process.execPath, [PIN_SCRIPT, 'unpin', 'audit'], { + cwd: project, + encoding: 'utf8', + env: cleanEnv(), + }); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.equal(fs.existsSync(commandPath), false, 'stale pin must be removed after skill removal'); + }); + + it('unpin after skill removal leaves non-pinned user commands alone', () => { + const commandsDir = path.join(project, '.opencode', 'commands'); + fs.mkdirSync(commandsDir, { recursive: true }); + const commandPath = path.join(commandsDir, 'impeccable-audit.md'); + fs.writeFileSync(commandPath, 'my own command, not a pin\n'); + fs.rmSync(path.join(project, '.opencode', 'skills', 'impeccable'), { recursive: true, force: true }); + + const result = spawnSync(process.execPath, [PIN_SCRIPT, 'unpin', 'audit'], { + cwd: project, + encoding: 'utf8', + env: cleanEnv(), + }); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.ok(fs.existsSync(commandPath), 'non-pinned user command must survive cleanup'); + }); +}); + +describe('pin command OpenCode user scope', () => { + let project; + let config; + + beforeEach(() => { + project = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-pin-usr-')); + fs.writeFileSync(path.join(project, 'package.json'), '{}\n'); + config = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-pin-cfg-')); + }); + + afterEach(() => { + fs.rmSync(project, { recursive: true, force: true }); + fs.rmSync(config, { recursive: true, force: true }); + }); + + function installUserScopeSkill(dir = config) { + fs.mkdirSync(path.join(dir, 'skills', 'impeccable'), { recursive: true }); + } + + it('pins into the user config dir when only a global install exists', () => { + installUserScopeSkill(); + const result = spawnSync(process.execPath, [PIN_SCRIPT, 'pin', 'audit'], { + cwd: project, + encoding: 'utf8', + env: cleanEnv({ OPENCODE_CONFIG_DIR: config }), + }); + + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.doesNotMatch(result.stdout, /No harness directories/); + const commandPath = path.join(config, 'commands', 'impeccable-audit.md'); + assert.ok(fs.existsSync(commandPath), `expected ${commandPath}`); + assert.match(fs.readFileSync(commandPath, 'utf8'), /impeccable-pinned-command/); + assert.equal( + fs.existsSync(path.join(project, '.opencode', 'commands')), + false, + 'must not create a project commands dir for a user-scope install', + ); + }); + + it('unpin removes the user-scope pinned command', () => { + installUserScopeSkill(); + spawnSync(process.execPath, [PIN_SCRIPT, 'pin', 'audit'], { + cwd: project, + encoding: 'utf8', + env: cleanEnv({ OPENCODE_CONFIG_DIR: config }), + }); + const commandPath = path.join(config, 'commands', 'impeccable-audit.md'); + assert.ok(fs.existsSync(commandPath)); + + const result = spawnSync(process.execPath, [PIN_SCRIPT, 'unpin', 'audit'], { + cwd: project, + encoding: 'utf8', + env: cleanEnv({ OPENCODE_CONFIG_DIR: config }), + }); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.equal(fs.existsSync(commandPath), false); + }); + + it('unpin removes the user-scope pinned command after the global skill was removed', () => { + installUserScopeSkill(); + spawnSync(process.execPath, [PIN_SCRIPT, 'pin', 'audit'], { + cwd: project, + encoding: 'utf8', + env: cleanEnv({ OPENCODE_CONFIG_DIR: config }), + }); + const commandPath = path.join(config, 'commands', 'impeccable-audit.md'); + assert.ok(fs.existsSync(commandPath)); + + // Global skill removed before unpin: cleanup must still find the pin. + fs.rmSync(path.join(config, 'skills', 'impeccable'), { recursive: true, force: true }); + + const result = spawnSync(process.execPath, [PIN_SCRIPT, 'unpin', 'audit'], { + cwd: project, + encoding: 'utf8', + env: cleanEnv({ OPENCODE_CONFIG_DIR: config }), + }); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.equal(fs.existsSync(commandPath), false, 'stale user-scope pin must be removed after skill removal'); + }); + + it('pins in both scopes when project and user installs coexist', () => { + installUserScopeSkill(); + fs.mkdirSync(path.join(project, '.opencode', 'skills', 'impeccable'), { recursive: true }); + const result = spawnSync(process.execPath, [PIN_SCRIPT, 'pin', 'audit'], { + cwd: project, + encoding: 'utf8', + env: cleanEnv({ OPENCODE_CONFIG_DIR: config }), + }); + + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.ok(fs.existsSync(path.join(config, 'commands', 'impeccable-audit.md')), 'user-scope pin'); + assert.ok(fs.existsSync(path.join(project, '.opencode', 'commands', 'impeccable-audit.md')), 'project pin'); + }); + + it('honours XDG_CONFIG_HOME when OPENCODE_CONFIG_DIR is unset', () => { + const xdg = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-pin-xdg-')); + installUserScopeSkill(path.join(xdg, 'opencode')); + try { + const result = spawnSync(process.execPath, [PIN_SCRIPT, 'pin', 'audit'], { + cwd: project, + encoding: 'utf8', + env: cleanEnv({ OPENCODE_CONFIG_DIR: undefined, XDG_CONFIG_HOME: xdg }), + }); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.ok(fs.existsSync(path.join(xdg, 'opencode', 'commands', 'impeccable-audit.md'))); + } finally { + fs.rmSync(xdg, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/root-commands-sync.test.js b/tests/root-commands-sync.test.js new file mode 100644 index 000000000..389607e15 --- /dev/null +++ b/tests/root-commands-sync.test.js @@ -0,0 +1,73 @@ +/** + * Tests for syncRootCommands. The post-merge release sync must mirror + * generated provider command files (e.g. OpenCode's commands/impeccable.md) + * into the tracked root harness folders, or direct GitHub / submodule / + * npx-skills installs ship OpenCode without the slash command bridge (#483). + */ +import { describe, test, expect } from 'bun:test'; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; + +import { syncRootCommands } from '../scripts/lib/root-commands-sync.mjs'; + +function setupDist(distDir, provider, configDir, commands) { + if (commands === null) return; + const dir = join(distDir, provider, configDir, 'commands'); + mkdirSync(dir, { recursive: true }); + for (const [name, body] of Object.entries(commands)) { + writeFileSync(join(dir, name), body); + } +} + +describe('syncRootCommands', () => { + test('mirrors generated command files into the root harness folder', () => { + const dist = mkdtempSync(join(tmpdir(), 'imp-sync-dist-')); + const root = mkdtempSync(join(tmpdir(), 'imp-sync-root-')); + setupDist(dist, 'opencode', '.opencode', { 'impeccable.md': 'bridge v1\n' }); + try { + const synced = syncRootCommands(dist, root, [{ provider: 'opencode', configDir: '.opencode' }]); + expect(synced).toEqual(['.opencode']); + expect(readFileSync(join(root, '.opencode', 'commands', 'impeccable.md'), 'utf8')).toBe('bridge v1\n'); + } finally { + rmSync(dist, { recursive: true, force: true }); + rmSync(root, { recursive: true, force: true }); + } + }); + + test('preserves repo-local or pinned command files already at the destination', () => { + const dist = mkdtempSync(join(tmpdir(), 'imp-sync-dist-')); + const root = mkdtempSync(join(tmpdir(), 'imp-sync-root-')); + setupDist(dist, 'opencode', '.opencode', { 'impeccable.md': 'bridge v2\n' }); + const destDir = join(root, '.opencode', 'commands'); + mkdirSync(destDir, { recursive: true }); + writeFileSync(join(destDir, 'impeccable-audit.md'), 'pinned by user\n'); + writeFileSync(join(destDir, 'impeccable.md'), 'stale bridge\n'); + try { + syncRootCommands(dist, root, [{ provider: 'opencode', configDir: '.opencode' }]); + expect(readFileSync(join(destDir, 'impeccable.md'), 'utf8')).toBe('bridge v2\n'); + expect(readFileSync(join(destDir, 'impeccable-audit.md'), 'utf8')).toBe('pinned by user\n'); + } finally { + rmSync(dist, { recursive: true, force: true }); + rmSync(root, { recursive: true, force: true }); + } + }); + + test('skips providers whose dist variant has no commands dir', () => { + const dist = mkdtempSync(join(tmpdir(), 'imp-sync-dist-')); + const root = mkdtempSync(join(tmpdir(), 'imp-sync-root-')); + setupDist(dist, 'opencode', '.opencode', { 'impeccable.md': 'bridge\n' }); + setupDist(dist, 'claude-code', '.claude', null); + try { + const synced = syncRootCommands(dist, root, [ + { provider: 'opencode', configDir: '.opencode' }, + { provider: 'claude-code', configDir: '.claude' }, + ]); + expect(synced).toEqual(['.opencode']); + expect(existsSync(join(root, '.claude', 'commands'))).toBe(false); + } finally { + rmSync(dist, { recursive: true, force: true }); + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/skills-cli.test.js b/tests/skills-cli.test.js index 427f33e25..0bacda56e 100644 --- a/tests/skills-cli.test.js +++ b/tests/skills-cli.test.js @@ -11,7 +11,7 @@ */ import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; import { execSync, execFileSync } from 'child_process'; -import { mkdtempSync, existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync, rmSync, lstatSync, realpathSync, readlinkSync, symlinkSync } from 'fs'; +import { mkdtempSync, existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync, rmSync, lstatSync, realpathSync, readlinkSync, symlinkSync, cpSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; import { @@ -66,6 +66,18 @@ function createFakeLinkSource(root, providers = ['.claude']) { for (const provider of providers) { writeSkill(join(root, '.impeccable', 'dist', 'universal'), provider, 'impeccable'); } + if (providers.includes('.opencode')) { + const commandsDir = join(root, '.impeccable', 'dist', 'universal', '.opencode', 'commands'); + mkdirSync(commandsDir, { recursive: true }); + writeFileSync(join(commandsDir, 'impeccable.md'), [ + 'description: Impeccable impeccable bridge', + 'agent: build', + 'subtask: true', + '', + 'body impeccable', + '', + ].join('\n')); + } } function createFakeUniversalBundle(root, providers = ['.claude', '.agents', '.cursor']) { @@ -106,6 +118,18 @@ function createFakeUniversalBundle(root, providers = ['.claude', '.agents', '.cu hooks: { PostToolUse: [{ matcher: 'apply_patch', hooks: [{ type: 'command', command: 'node ".codex/skills/impeccable/scripts/hook.mjs"' }] }] }, }, null, 2)); } + if (providers.includes('.opencode')) { + const commandsDir = join(bundleRoot, '.opencode', 'commands'); + mkdirSync(commandsDir, { recursive: true }); + writeFileSync(join(commandsDir, 'impeccable.md'), [ + 'description: Impeccable impeccable bridge', + 'agent: build', + 'subtask: true', + '', + 'body impeccable', + '', + ].join('\n')); + } // Native subagent definitions, mirroring the build's provider agents output. if (providers.includes('.github')) { mkdirSync(join(bundleRoot, '.github', 'agents'), { recursive: true }); @@ -465,6 +489,23 @@ describe('skills install: already-installed detection', () => { // ─── Submodule/link installs ──────────────────────────────────────────────── describe('skills link: submodule installs', () => { + test('writes the OpenCode command bridge alongside linked skills', () => { + const tmp = mkdtempSync(join(tmpdir(), 'imp-test-link-bridge-')); + execSync('git init', { cwd: tmp }); + createFakeLinkSource(tmp, ['.opencode']); + + const output = run('skills link --source=.impeccable --providers=opencode -y', { cwd: tmp }); + expect(output).toContain('Linked impeccable into: .opencode'); + + const dest = join(tmp, '.opencode', 'skills', 'impeccable'); + expect(lstatSync(dest).isSymbolicLink()).toBe(true); + const bridge = join(tmp, '.opencode', 'commands', 'impeccable.md'); + expect(existsSync(bridge)).toBe(true); + expect(readFileSync(bridge, 'utf8')).toContain('impeccable bridge'); + + rmSync(tmp, { recursive: true, force: true }); + }, 15000); + test('creates relative skill symlinks from dist/universal', () => { const tmp = mkdtempSync(join(tmpdir(), 'imp-test-link-')); execSync('git init', { cwd: tmp }); @@ -1577,6 +1618,99 @@ describe('skills install/update: local universal bundle e2e', () => { rmSync(tmp, { recursive: true, force: true }); }, 15000); + test('reinstall backfills a missing OpenCode command bridge when skills are current', () => { + const tmp = mkdtempSync(join(tmpdir(), 'imp-test-reinstall-backfill-')); + execSync('git init', { cwd: tmp }); + const bundleRoot = createFakeUniversalBundle(tmp, ['.opencode']); + const env = { ...process.env, IMPECCABLE_BUNDLE_PATH: bundleRoot }; + + run('skills install -y --providers=opencode --no-hooks', { cwd: tmp, env }); + const bridge = join(tmp, '.opencode', 'commands', 'impeccable.md'); + expect(existsSync(bridge)).toBe(true); + rmSync(bridge); + + const output = run('skills install -y --providers=opencode --no-hooks', { cwd: tmp, env }); + expect(output).toContain('already installed'); + expect(existsSync(bridge)).toBe(true); + + rmSync(tmp, { recursive: true, force: true }); + }, 15000); + + test('skills update backfills a missing OpenCode command bridge when skills are current', () => { + const tmp = mkdtempSync(join(tmpdir(), 'imp-test-update-backfill-')); + execSync('git init', { cwd: tmp }); + const bundleRoot = createFakeUniversalBundle(tmp, ['.opencode']); + const env = { ...process.env, IMPECCABLE_BUNDLE_PATH: bundleRoot }; + + run('skills install -y --providers=opencode --no-hooks', { cwd: tmp, env }); + const bridge = join(tmp, '.opencode', 'commands', 'impeccable.md'); + expect(existsSync(bridge)).toBe(true); + rmSync(bridge); + + run('skills update -y --no-hooks', { cwd: tmp, env }); + expect(existsSync(bridge)).toBe(true); + + rmSync(tmp, { recursive: true, force: true }); + }, 15000); + + test('skills update restores a command bridge whose content drifted', () => { + const tmp = mkdtempSync(join(tmpdir(), 'imp-test-update-drifted-bridge-')); + execSync('git init', { cwd: tmp }); + const bundleRoot = createFakeUniversalBundle(tmp, ['.opencode']); + const env = { ...process.env, IMPECCABLE_BUNDLE_PATH: bundleRoot }; + + run('skills install -y --providers=opencode --no-hooks', { cwd: tmp, env }); + const bridge = join(tmp, '.opencode', 'commands', 'impeccable.md'); + writeFileSync(bridge, 'user edit drift\n'); + + run('skills update -y --no-hooks', { cwd: tmp, env }); + expect(readFileSync(bridge, 'utf8')).toContain('impeccable bridge'); + + rmSync(tmp, { recursive: true, force: true }); + }, 15000); + + test('skills check from the home dir recognises a global OpenCode install as current', () => { + // Bugbot scenario: `skills check` runs scope-less, so a home-rooted run + // matches the GLOBAL skills dir via HOME_SKILLS_DIR_OVERRIDES. The command + // bridge must be resolved next to that matched skills dir, not at + // /.opencode/commands. os.homedir() only honours HOME at process + // start, so this must run through the CLI subprocess, not in-process. + const home = mkdtempSync(join(tmpdir(), 'imp-test-check-home-')); + execSync('git init', { cwd: home }); + const bundleRoot = createFakeUniversalBundle(home, ['.opencode']); + const configHome = mkdtempSync(join(tmpdir(), 'imp-test-check-config-')); + cpSync(join(bundleRoot, '.opencode', 'skills'), join(configHome, 'skills'), { recursive: true }); + cpSync(join(bundleRoot, '.opencode', 'commands'), join(configHome, 'commands'), { recursive: true }); + + const output = run('skills check', { + cwd: home, + env: { ...process.env, HOME: home, OPENCODE_CONFIG_DIR: configHome, IMPECCABLE_BUNDLE_PATH: bundleRoot }, + }); + expect(output).toContain('Skills are up to date'); + + rmSync(home, { recursive: true, force: true }); + rmSync(configHome, { recursive: true, force: true }); + }, 15000); + + test('skills update leaves an intact command bridge and pinned siblings alone', () => { + const tmp = mkdtempSync(join(tmpdir(), 'imp-test-update-bridge-intact-')); + execSync('git init', { cwd: tmp }); + const bundleRoot = createFakeUniversalBundle(tmp, ['.opencode']); + const env = { ...process.env, IMPECCABLE_BUNDLE_PATH: bundleRoot }; + + run('skills install -y --providers=opencode --no-hooks', { cwd: tmp, env }); + const bridge = join(tmp, '.opencode', 'commands', 'impeccable.md'); + const pinned = join(tmp, '.opencode', 'commands', 'impeccable-audit.md'); + writeFileSync(pinned, 'pinned by user\n'); + + const output = run('skills update -y --no-hooks', { cwd: tmp, env }); + expect(output).toContain('Skills are up to date'); + expect(readFileSync(bridge, 'utf8')).toContain('impeccable bridge'); + expect(readFileSync(pinned, 'utf8')).toBe('pinned by user\n'); + + rmSync(tmp, { recursive: true, force: true }); + }, 15000); + test('skills update reports malformed hook manifests cleanly on the up-to-date path', () => { const tmp = mkdtempSync(join(tmpdir(), 'imp-test-update-bad-hooks-')); execSync('git init', { cwd: tmp });