Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
93 changes: 92 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 @@ -1201,6 +1201,93 @@ 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`.
*/
// Map each tracked provider (`.claude`, `.opencode`, ...) to the on-disk
// directory it ships to. Sourced from `scripts/lib/transformers/providers.js`
// so a single config edit in the build keeps both in sync.
const PROVIDER_CONFIG_DIRS = {
'.claude': '.claude',
'.cursor': '.cursor',
'.gemini': '.gemini',
'.codex': '.codex',
'.agents': '.agents',
'.agent': '.agent',
'.github': '.github',
'.grok': '.grok',
'.kiro': '.kiro',
'.opencode': '.opencode',
'.pi': '.pi',
'.qoder': '.qoder',
'.trae': '.trae',
'.trae-cn': '.trae-cn',
'.rovodev': '.rovodev',
'.vibe': '.vibe',
};
function providerConfigDir(provider) {
return PROVIDER_CONFIG_DIRS[provider] || provider.replace(/^\./, '.');
}

function copyProviderCommands(bundleDir, root, targets, { scope } = {}) {
let written = 0;
for (const target of targets) {
const providerEntry = PROVIDER_DIRS.includes(`.${target}`)
? `.${target}`
: target;
const configDir = providerConfigDir(providerEntry);
const srcDir = join(bundleDir, providerEntry, 'commands');
if (!existsSync(srcDir)) continue;
const localCommandsDir = scope === 'user'
? join(opencodeGlobalConfigDir(root), 'commands')
: join(root, configDir, 'commands');
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 @@ -1873,6 +1960,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 @@ -2144,6 +2232,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 @@ -2179,6 +2268,7 @@ function copyDirSync(src, dest) {
export {
collectInstallDetections,
copyProviderAgents,
copyProviderCommands,
copyProviderHooks,
copyProviderSkills,
decideHookInstall,
Expand All @@ -2188,6 +2278,7 @@ export {
linkProviderSkills,
mergeHookManifests,
migrateUnprefixImpeccable,
opencodeGlobalConfigDir,
resolveInstallTargets,
resolveLinkSource,
};
Expand Down
12 changes: 7 additions & 5 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,6 +131,7 @@ 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` `` |
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
Expand Down
34 changes: 34 additions & 0 deletions scripts/lib/transformers/factory.js
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,40 @@ export function createTransformer(config) {
}
}

// Emit a slash-command bridge for harnesses that need an explicit user-invoked
// surface distinct from the skill. OpenCode 1.18.10 ignores Claude-style
// `user-invocable: true`, so we ship a sibling `commands/<name>.md` file that
// routes to the same skill via the skill tool. The schema is intentionally
// restricted to what OpenCode recognises (description, agent, model,
// variant, subtask); see opencode/packages/core/src/v1/config/command.ts:5-13.
// Body text is kept portable: `<skill-base-dir>` is a placeholder the LLM
// resolves from the skill tool's response, so the same file works for
// project (.opencode/skills/impeccable/) and global
// (~/.config/opencode/skills/impeccable/) installs (issue #406).
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}" }) to load the \`${skill.name}\` skill, then follow the skill's \`Setup\` and \`Commands\` sections.

Always run the skill's mandatory setup first:

1. \`node <skill-base-dir>/scripts/context.mjs\` — \`<skill-base-dir>\` is the directory that contains the skill \`SKILL.md\`. Discover it from the \`skill\` tool's response, or fall back to the project's \`.opencode/skills/${skill.name}/\` or the global \`~/.config/opencode/skills/${skill.name}/\`.
2. Load \`reference/routing.md\` from the skill to map sub-commands.
3. If $ARGUMENTS is empty, present the context-aware menu described in \`routing.md\`; never auto-run a sub-command.
4. Otherwise, treat $ARGUMENTS as the sub-command plus optional target, load the matching \`reference/<sub-command>.md\`, and follow it.

The skill's \`allowed-tools\`, \`version\`, and \`argument-hint\` frontmatter fields are Claude-specific extensions and are silently ignored by OpenCode. Do not rely on them.
`;
const bridgeFrontmatter = generateYamlFrontmatter({
description: `${skill.description} (slash command bridge: load the skill and run $ARGUMENTS)`,
agent: 'build',
subtask: true,
});
writeFile(path.join(commandsDir, `${skill.name}.md`), `${bridgeFrontmatter}\n\n${bridgeBody}`.replace(/\n+$/, '\n'));
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
}
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
4 changes: 3 additions & 1 deletion scripts/test-suites.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export const SUITES = {
/^skill\/(SKILL\.src\.md|agents\/|reference\/|scripts\/(cleanup-deprecated|concept-seed|context|context-signals|critique-storage|design-parser|doctor|hook|impeccable-paths|is-generated|lib\/(artifact-schema|composition-catalog|concept-catalog|provider|staleness|staleness-deep|staleness-notice|surface-briefs|target-slug|template-extensions)|pin|surface-brief))/,
/^README(\.npm)?\.md$/,
/^cli\/bin\//,
/^tests\/(build|cleanup-deprecated|cli-args|cli-ignores|concept-seed|context|context-signals|critique-storage|design-parser|doctor|github-sheriff|hook|hook-build|impeccable-paths|openai-plugin|pin|skills-cli|staleness|surface-brief|target-args|template-extensions|test-suites|windows-path-fix|zip)\.test\.(js|mjs)$/,
/^tests\/(build|cleanup-deprecated|cli-args|cli-ignores|concept-seed|context|context-signals|copy-provider-commands|critique-storage|design-parser|doctor|github-sheriff|hook|hook-build|impeccable-paths|openai-plugin|pin|skills-cli|staleness|surface-brief|target-args|template-extensions|test-suites|windows-path-fix|zip)\.test\.(js|mjs)$/,
/^tests\/lib\//,
],
commands: [
Expand All @@ -38,12 +38,14 @@ 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/skills-cli.test.js',
'tests/validate-plugin-versions.test.js',
Expand Down
83 changes: 81 additions & 2 deletions skill/scripts/pin.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
*/

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';

const __dirname = dirname(fileURLToPath(import.meta.url));
Expand Down Expand Up @@ -112,6 +112,39 @@ 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 routes through
// the parent bridge so /impeccable-<cmd> and /impeccable <cmd> end up
// calling the same workflow.
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 \`reference/${command}.md\` and follow it.
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated

$ARGUMENTS
`;
}

function findOpencodeCommandsDir(projectRoot) {
const candidate = join(projectRoot, '.opencode', 'commands');
// Only pin when OpenCode is actually in use; the parent dir must hold the
// impeccable skill install (mirrors findHarnessDirs' guard).
return existsSync(join(projectRoot, '.opencode', 'skills', 'impeccable'))
? candidate
: null;
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
}

/**
* Pin a command: create shortcut skill in all harness dirs.
*/
Expand All @@ -126,7 +159,12 @@ function pin(command, projectRoot) {

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;
Comment thread
cursor[bot] marked this conversation as resolved.
const commandPrefix = commandPrefixForSkillsDir(skillsDir);
const content = generatePinnedSkill(command, metadata, commandPrefix);
// Check if skill already exists (and isn't a pin)
Expand All @@ -148,6 +186,27 @@ function pin(command, projectRoot) {
created++;
}

// OpenCode: write a slash command bridge, not a skill shortcut.
const opencodeCommandsDir = findOpencodeCommandsDir(projectRoot);
if (opencodeCommandsDir) {
const commandFile = join(opencodeCommandsDir, `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)`);
} else {
writeFileSync(commandFile, generatePinnedOpencodeCommand(command, metadata));
console.log(` + ${commandFile}`);
created++;
}
} else {
mkdirSync(opencodeCommandsDir, { recursive: true });
writeFileSync(commandFile, generatePinnedOpencodeCommand(command, metadata));
console.log(` + ${commandFile}`);
created++;
}
}

Comment thread
greptile-apps[bot] marked this conversation as resolved.
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.');
Expand All @@ -157,13 +216,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;

Expand All @@ -182,6 +245,22 @@ function unpin(command, projectRoot) {
removed++;
}

// OpenCode: remove the pinned command file if it's one of ours.
const opencodeCommandsDir = findOpencodeCommandsDir(projectRoot);
if (opencodeCommandsDir) {
const commandFile = join(opencodeCommandsDir, `impeccable-${command}.md`);
if (existsSync(commandFile)) {
const content = readFileSync(commandFile, 'utf-8');
if (content.includes(OPENCODE_PIN_MARKER)) {
rmSync(commandFile, { force: true });
console.log(` - ${commandFile}`);
removed++;
} else {
console.log(` SKIP: ${commandFile} (not a pinned command)`);
}
}
}

if (removed > 0) {
console.log(`\nUnpinned '${command}' from ${removed} location(s).`);
console.log(`Use Impeccable's '${command}' workflow directly to access it.`);
Expand Down
Loading