mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 14:16:28 +03:00
Fix OpenCode slash command bridge (#483)
Add a first-class OpenCode command bridge across builds, installs, updates, linked installs, and pinned shortcuts. Preserve current provider behavior while backfilling missing or drifted command files.\n\nAI assistance: contributor and maintainer work used AI tools as disclosed in the PR discussion and commits.
This commit is contained in:
+103
-1
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { existsSync, readFileSync, readdirSync, statSync, accessSync, constants, lstatSync, unlinkSync, mkdirSync, mkdtempSync, writeFileSync, rmSync, rmdirSync, renameSync, createWriteStream, realpathSync, symlinkSync, readlinkSync, cpSync } from 'node:fs';
|
||||
import { existsSync, readFileSync, readdirSync, statSync, accessSync, constants, lstatSync, unlinkSync, mkdirSync, mkdtempSync, writeFileSync, rmSync, rmdirSync, renameSync, createWriteStream, realpathSync, symlinkSync, readlinkSync, cpSync, copyFileSync } from 'node:fs';
|
||||
import { join, resolve, dirname, relative, isAbsolute, sep, delimiter } from 'node:path';
|
||||
import { createInterface, emitKeypressEvents } from 'node:readline';
|
||||
import { Readable } from 'node:stream';
|
||||
@@ -738,6 +738,27 @@ function isUpToDate(root, providers, bundleDir, scope, agentScope = scope) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 <root>/.opencode, user <config>, 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;
|
||||
}
|
||||
}
|
||||
|
||||
if (!providerAgentsUpToDate(bundleDir, root, provider, agentScope)) return false;
|
||||
}
|
||||
return true;
|
||||
@@ -1290,6 +1311,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
|
||||
* `<root>/<configDir>/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 (`<configDir>/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
|
||||
// <root>/<configDir>/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. Claude Code's live at `.claude/agents/impeccable-*.md`; project
|
||||
// agents take precedence over user agents. GitHub Copilot's live at
|
||||
@@ -1918,6 +2007,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`);
|
||||
@@ -1987,6 +2083,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}` : ''}.`);
|
||||
}
|
||||
@@ -2058,6 +2155,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 });
|
||||
@@ -2340,6 +2438,7 @@ async function update(flags = []) {
|
||||
|
||||
const updated = refreshProviderSkills(tmpDir, root, copyProviders, scope);
|
||||
reportProviderAgents(copyProviderAgents(tmpDir, root, copyProviders, { scope: agentScope }));
|
||||
copyProviderCommands(tmpDir, root, copyProviders, { scope });
|
||||
const wantHooks = installHooks && await decideHookInstall(root, providers, { yes });
|
||||
const hookTargets = wantHooks ? copyProviderHooks(tmpDir, root, providers, { force }) : [];
|
||||
|
||||
@@ -2375,6 +2474,7 @@ function copyDirSync(src, dest) {
|
||||
export {
|
||||
collectInstallDetections,
|
||||
copyProviderAgents,
|
||||
copyProviderCommands,
|
||||
copyProviderHooks,
|
||||
copyProviderSkills,
|
||||
decideHookInstall,
|
||||
@@ -2383,11 +2483,13 @@ export {
|
||||
expectedHookDests,
|
||||
extractZip,
|
||||
formatInstallDetectionLines,
|
||||
isUpToDate,
|
||||
hermesGlobalHome,
|
||||
HOME_SKILLS_DIR_OVERRIDES,
|
||||
linkProviderSkills,
|
||||
mergeHookManifests,
|
||||
migrateUnprefixImpeccable,
|
||||
opencodeGlobalConfigDir,
|
||||
resolveInstallTargets,
|
||||
resolveLinkSource,
|
||||
};
|
||||
|
||||
+7
-6
@@ -46,14 +46,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 | Yes |
|
||||
| `compatibility`* | Yes | Yes | Ignored | No | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
|
||||
| `metadata`* | Yes | Yes | Ignored | No | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
|
||||
| `allowed-tools`* | Yes | No | Ignored | No | No | Yes | No | No | Yes | Yes | Yes | Yes | Yes | Yes |
|
||||
| `user-invocable` | Yes | No | No | No | Yes | Yes | No | No | Yes | No | Yes | Yes | Yes | No |
|
||||
| `argument-hint` | Yes | No | No | No | Yes | Yes | No | No | Yes | No | Yes | Yes | No | No |
|
||||
| `allowed-tools`* | Yes | No | Ignored | No | No | Yes | No | No | No | Yes | Yes | Yes | Yes | Yes |
|
||||
| `user-invocable` | Yes | No | No | No | Yes | Yes | No | No | No | No | Yes | Yes | Yes | No |
|
||||
| `argument-hint` | Yes | No | No | No | Yes | Yes | No | No | No | No | Yes | Yes | No | No |
|
||||
| `disable-model-invocation` | Yes | Yes | No | No | Yes | Yes | No | No | Yes | Yes | TBD | TBD | No | No |
|
||||
| `model` | Yes | No | No | No | No | Yes | No | No | Yes | No | No | No | No | No |
|
||||
| `model` | Yes | No | No | No | No | Yes | No | No | No | No | No | No | No | No |
|
||||
| `effort` | Yes | No | No | No | No | Yes | No | No | No | No | No | No | No | No |
|
||||
| `context` | Yes | No | No | No | No | No | No | No | No | No | No | No | No | No |
|
||||
| `agent` | Yes | No | 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 | No |
|
||||
| `hooks` | Yes | No | No | Yes | No | Yes | No | No | No | No | No | No | No | No |
|
||||
|
||||
Notes:
|
||||
@@ -64,6 +64,7 @@ Notes:
|
||||
- Hermes Agent reads the Agent Skills spec as-is. Spec-defined fields (`name`, `description`, `license`, `compatibility`, `metadata`) are parsed and stored; harness-specific extensions (`user-invocable`, `argument-hint`, `allowed-tools`, `disable-model-invocation`, `model`, `effort`, `context`, `agent`, `hooks`) are unknown keys and silently ignored. Hermes has no hook surface, no per-skill tool ACL, and no slash-command equivalent of `user-invocable` (skills are loaded via `/skill <name>` or auto-loaded; sub-commands like `/impeccable polish` are routed from the skill body, not declared in frontmatter). Hermes adds two frontmatter fields not in the spec: `platforms:` (OS filter; default = all) and `environments:` (relevance gate over `kanban`, `docker`, `s6`). Unknown fields are silently ignored.
|
||||
- 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/<name>.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
|
||||
@@ -133,8 +134,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}}`.
|
||||
|
||||
@@ -20,6 +20,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';
|
||||
@@ -657,6 +658,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(', ')}`);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
+119
-3
@@ -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));
|
||||
|
||||
@@ -115,21 +116,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-<cmd>.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-<cmd> runs the same workflow /impeccable <cmd> routes to.
|
||||
const OPENCODE_PIN_MARKER = '<!-- impeccable-pinned-command -->';
|
||||
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 <skill-base-dir>/scripts/context.mjs\`, then load \`<skill-base-dir>/reference/${command}.md\` and follow it. \`<skill-base-dir>\` 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/<cmd>/SKILL.md` that OpenCode
|
||||
// would never surface as `/<cmd>`.
|
||||
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)
|
||||
@@ -151,6 +250,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.');
|
||||
@@ -160,13 +265,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/<cmd>/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;
|
||||
|
||||
@@ -185,6 +294,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.`);
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* Tests for copyProviderCommands. Mirrors the PR #417 migration guards for the
|
||||
* skills path, applied to <provider>/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 <config>/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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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, /<skill-base-dir>\/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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
+135
-1
@@ -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, chmodSync, rmSync, lstatSync, realpathSync, readlinkSync, symlinkSync, statSync } from 'fs';
|
||||
import { mkdtempSync, existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync, chmodSync, rmSync, lstatSync, realpathSync, readlinkSync, symlinkSync, statSync, cpSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import {
|
||||
@@ -69,6 +69,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']) {
|
||||
@@ -111,6 +123,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'));
|
||||
}
|
||||
if (providers.includes('.grok')) {
|
||||
mkdirSync(join(bundleRoot, '.grok', 'hooks'), { recursive: true });
|
||||
writeFileSync(join(bundleRoot, '.grok', 'hooks', 'impeccable.json'), JSON.stringify({
|
||||
@@ -560,6 +584,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 });
|
||||
@@ -1754,6 +1795,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
|
||||
// <home>/.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 });
|
||||
|
||||
Reference in New Issue
Block a user