Skip to content
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
9efccb9
feat(opencode): emit commands/impeccable.md slash bridge alongside sk…
4nibhal Aug 1, 2026
0d5da7b
feat(cli): install commands/ to .opencode/ with #417 migration guards
4nibhal Aug 1, 2026
b5a929b
feat(pin): OpenCode branch writes command bridge instead of skill sho…
4nibhal Aug 1, 2026
508a8ee
docs(harnesses): correct OpenCode frontmatter row + add commands subs…
4nibhal Aug 1, 2026
2d2b647
test(suites): register opencode command bridge tests in core suite
4nibhal Aug 2, 2026
11d92e3
fix(cli): backfill OpenCode command bridge on reinstall/update of cur…
4nibhal Aug 2, 2026
ced2b77
docs(harnesses): drop duplicated OpenCode substitution row
4nibhal Aug 2, 2026
263bf23
fix(cli): write OpenCode command bridge for linked installs
4nibhal Aug 2, 2026
e410bea
fix(cli): resolve command freshness next to the matched skills dir
4nibhal Aug 2, 2026
78edf1e
fix(build): sync provider commands into tracked root harness folders
4nibhal Aug 2, 2026
0706796
fix(pin): support user-scope OpenCode installs for pinned commands
4nibhal Aug 2, 2026
053722f
Merge main into opencode/slash-command-bridge
4nibhal Aug 4, 2026
26ae983
refactor(opencode): slim command bridge to a delegating one-liner
4nibhal Aug 4, 2026
4e37c10
fix(pin): unpin cleans OpenCode commands after skill removal
4nibhal Aug 4, 2026
782a117
fix(pin): prefix the pinned reference path with <skill-base-dir>
4nibhal Aug 4, 2026
74449f0
Merge origin/main (local review-only merge)
pbakaus Aug 10, 2026
85f307d
Harden the pinned OpenCode command body and fix a stale comment
pbakaus Aug 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 103 additions & 1 deletion cli/bin/commands/skills.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 <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;
}
}
}
return true;
}
Expand Down Expand Up @@ -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
* `<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');
}
Comment thread
cursor[bot] marked this conversation as resolved.

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 `<repo>/.github/agents/`, user-level
Expand Down Expand Up @@ -1788,6 +1877,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`);
Expand Down Expand Up @@ -1850,6 +1946,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 });
Comment thread
cursor[bot] marked this conversation as resolved.
const v = getSkillsVersion(installRoot, scope);
console.log(`Updated ${updated} skill(s)${v ? ` to v${v}` : ''}.`);
}
Expand Down Expand Up @@ -1910,6 +2007,7 @@ async function install(flags) {
try {
written = copyProviderSkills(bundleDir, installRoot, targets, { scope });
agentResults = copyProviderAgents(bundleDir, installRoot, targets, { scope });
copyProviderCommands(bundleDir, installRoot, targets, { scope });
Comment thread
cursor[bot] marked this conversation as resolved.
hookTargets = wantHooks ? copyProviderHooks(bundleDir, hookRoot, targets, { force, skillRoot: installRoot }) : [];
} catch (e) {
rmSync(bundleDir, { recursive: true, force: true });
Expand Down Expand Up @@ -2181,6 +2279,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 }) : [];

Expand Down Expand Up @@ -2216,15 +2315,18 @@ function copyDirSync(src, dest) {
export {
collectInstallDetections,
copyProviderAgents,
copyProviderCommands,
copyProviderHooks,
copyProviderSkills,
decideHookInstall,
expectedHookDests,
extractZip,
formatInstallDetectionLines,
isUpToDate,
linkProviderSkills,
mergeHookManifests,
migrateUnprefixImpeccable,
opencodeGlobalConfigDir,
resolveInstallTargets,
resolveLinkSource,
};
Expand Down
13 changes: 7 additions & 6 deletions docs/HARNESSES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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/<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
Expand Down Expand Up @@ -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}}`.
6 changes: 6 additions & 0 deletions scripts/build.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(', ')}`);
Expand Down
27 changes: 27 additions & 0 deletions scripts/lib/root-commands-sync.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
Comment thread
cursor[bot] marked this conversation as resolved.
22 changes: 22 additions & 0 deletions scripts/lib/transformers/factory.js
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,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'));
}
Comment thread
cursor[bot] marked this conversation as resolved.
}

if (config.agentFormat) {
const agentsDir = path.join(providerDir, `${configDir}/agents`);
for (const skill of skills) {
Expand Down
3 changes: 3 additions & 0 deletions scripts/test-suites.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Comment thread
cursor[bot] marked this conversation as resolved.
'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',
Expand Down
Loading