diff --git a/crates/skills/src/bundle.rs b/crates/skills/src/bundle.rs index 0dd5b1f57..7eb476b31 100644 --- a/crates/skills/src/bundle.rs +++ b/crates/skills/src/bundle.rs @@ -13,7 +13,9 @@ use once_cell::sync::Lazy; use regex::Regex; use sha2::{Digest, Sha256}; -use crate::providers::{provider_display_name, Scope, Sys, API_BASE, PROVIDER_DIRS}; +use crate::providers::{ + opencode_global_config_dir, provider_display_name, Scope, Sys, API_BASE, PROVIDER_DIRS, +}; use crate::util::{self, jsp}; /// Ceiling on any single download this crate performs (triage C4). The @@ -423,6 +425,31 @@ pub fn is_up_to_date(sys: &Sys, root: &str, providers: &[&str], bundle_dir: &str } } } + // Provider command artifacts (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 (#483). Only bundle-shipped files are checked, so pinned or + // user commands never affect freshness. The commands dir sits next to + // the matched skills dir, so deriving it from `local_skills_dir` stays + // correct for every layout `copy_provider_commands` can write. + let bundle_commands_dir = jsp::join(&[bundle_dir, provider, "commands"]); + if util::exists(&bundle_commands_dir) { + let local_commands_dir = jsp::join(&[&jsp::dirname(&local_skills_dir), "commands"]); + for entry in util::read_dir_names(&bundle_commands_dir).unwrap_or_default() { + let bundle_file = jsp::join(&[&bundle_commands_dir, &entry]); + if !util::is_file(&bundle_file) { + continue; + } + let local_file = jsp::join(&[&local_commands_dir, &entry]); + if !util::exists(&local_file) { + return Ok(false); + } + if hash_skill_file(&bundle_file)? != hash_skill_file(&local_file)? { + return Ok(false); + } + } + } if !provider_agents_up_to_date(bundle_dir, root, provider, agent_scope)? { return Ok(false); } @@ -497,6 +524,77 @@ pub fn copy_provider_skills(sys: &Sys, bundle_dir: &str, root: &str, targets: &[ Ok(written) } +/// JS: skills.mjs#providerCommandsDir. Project installs land at +/// `//commands`; a user-scope OpenCode install must target +/// the config dir OpenCode actually scans. +fn provider_commands_dir(sys: &Sys, root: &str, provider_entry: &str, scope: Option) -> String { + if scope == Some(Scope::User) { + jsp::join(&[&opencode_global_config_dir(&sys.env, root), "commands"]) + } else { + jsp::join(&[root, provider_entry, "commands"]) + } +} + +/// JS: skills.mjs#copyProviderCommands(bundleDir, root, targets, {scope}). +/// +/// OpenCode discovers custom commands from `{command,commands}/**.md` under +/// any active config dir, so this mirrors `copy_provider_skills`: project +/// scope writes `//commands/`, user scope writes +/// `opencode_global_config_dir(home)/commands`. +/// +/// 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 is left alone. +pub fn copy_provider_commands(sys: &Sys, bundle_dir: &str, root: &str, targets: &[&str], scope: Option) -> usize { + let mut written = 0usize; + for target in targets { + let dotted = format!(".{target}"); + let provider_entry: &str = if PROVIDER_DIRS.contains(&dotted.as_str()) { + &dotted + } else { + target + }; + let src_dir = jsp::join(&[bundle_dir, provider_entry, "commands"]); + if !util::exists(&src_dir) { + continue; + } + let local_commands_dir = provider_commands_dir(sys, root, provider_entry, scope); + let _ = std::fs::create_dir_all(&local_commands_dir); + let entries = util::read_dir_names(&src_dir).unwrap_or_default(); + for entry in &entries { + let src = jsp::join(&[&src_dir, entry]); + if !util::is_file(&src) { + continue; + } + let dest = jsp::join(&[&local_commands_dir, entry]); + util::rm_rf(&dest); + if std::fs::copy(&src, &dest).is_ok() { + written += 1; + } + } + if scope == Some(Scope::User) && provider_entry == ".opencode" { + let legacy_dir = jsp::join(&[root, ".opencode", "commands"]); + let migratable = util::exists(&legacy_dir) + && !util::is_symlink(&legacy_dir) + && util::realpath(&legacy_dir) != util::realpath(&local_commands_dir) + && !util::exists(&jsp::join(&[root, ".git"])); + if migratable { + for entry in &entries { + if !util::is_file(&jsp::join(&[&src_dir, entry])) { + continue; + } + util::rm_rf(&jsp::join(&[&legacy_dir, entry])); + } + util::rmdir(&legacy_dir); + } + } + } + written +} + /// JS: refreshProviderSkills(bundleDir, root, providers, scope). Returns the /// skill directories refreshed. pub fn refresh_provider_skills(sys: &Sys, bundle_dir: &str, root: &str, providers: &[&str], scope: Option) -> Result, String> { diff --git a/crates/skills/src/commands.rs b/crates/skills/src/commands.rs index 3572ab419..5a9313054 100644 --- a/crates/skills/src/commands.rs +++ b/crates/skills/src/commands.rs @@ -480,6 +480,12 @@ fn link(flags: &[String], io: &mut Io) -> R<()> { } } let result = bundle::link_provider_skills(io, &source.bundle_root, &root, &targets, force).map_err(Flow::Throw)?; + // 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 (#483). + bundle::copy_provider_commands(&sys, &source.bundle_root, &root, &targets, Some(Scope::Project)); if result.linked == 0 && result.already == 0 { if result.skipped > 0 { err(io, "Nothing was linked because matching skill folders already exist."); @@ -598,6 +604,7 @@ fn install(flags: &[String], io: &mut Io) -> R<()> { updated = refreshed.len(); let agents = bundle::copy_provider_agents(&sys, &bdir, &install_root, ©_targets, scope_opt)?; bundle::report_provider_agents(&sys, io, &agents); + bundle::copy_provider_commands(&sys, &bdir, &install_root, ©_targets, scope_opt); let v = sys.get_skills_version(&install_root, scope_opt); out(io, &format!("Updated {updated} skill(s){}.", to_version_suffix(&v))); install_engine_binaries(&sys, io, &refreshed); @@ -682,6 +689,7 @@ fn install(flags: &[String], io: &mut Io) -> R<()> { let outcome: Result<(Vec, Vec, Vec<&'static str>), String> = (|| { let written = bundle::copy_provider_skills(&sys, &bundle_dir, &install_root, &targets, scope_opt)?; let agents = bundle::copy_provider_agents(&sys, &bundle_dir, &install_root, &targets, scope_opt)?; + bundle::copy_provider_commands(&sys, &bundle_dir, &install_root, &targets, scope_opt); let hooks = if want_hooks { copy_provider_hooks(&sys, &bundle_dir, &hook_root, &targets, force, Some(&install_root))? } else { @@ -862,6 +870,7 @@ fn update(flags: &[String], io: &mut Io) -> R<()> { let refreshed = bundle::refresh_provider_skills(&sys, &tmp_dir, &root, ©_providers, scope).map_err(Flow::Throw)?; let agents = bundle::copy_provider_agents(&sys, &tmp_dir, &root, ©_providers, agent_scope).map_err(Flow::Throw)?; bundle::report_provider_agents(&sys, io, &agents); + bundle::copy_provider_commands(&sys, &tmp_dir, &root, ©_providers, scope); // Repair any stale pre-launcher (`node .../hook.mjs`) manifest a v3 // install left behind, regardless of hook consent (triage E8). hook_manifest::repair_stale_hook_manifests(&sys, &root, ©_providers, None).map_err(Flow::Throw)?; diff --git a/crates/skills/src/util.rs b/crates/skills/src/util.rs index 20505e5fc..8deed8026 100644 --- a/crates/skills/src/util.rs +++ b/crates/skills/src/util.rs @@ -71,6 +71,11 @@ pub fn is_dir(p: &str) -> bool { std::fs::metadata(p).map(|m| m.is_dir()).unwrap_or(false) } +/// `fs.statSync(p).isFile()`; false on error. +pub fn is_file(p: &str) -> bool { + std::fs::metadata(p).map(|m| m.is_file()).unwrap_or(false) +} + /// `fs.lstatSync(p).isSymbolicLink()`; false on error. pub fn is_symlink(p: &str) -> bool { std::fs::symlink_metadata(p) diff --git a/crates/skills/tests/provider_commands_tests.rs b/crates/skills/tests/provider_commands_tests.rs new file mode 100644 index 000000000..b50354d5b --- /dev/null +++ b/crates/skills/tests/provider_commands_tests.rs @@ -0,0 +1,193 @@ +//! Port of `tests/copy-provider-commands.test.js` (upstream 9736a9f6, #483): +//! the OpenCode command bridge the installer writes, and the command +//! awareness `isUpToDate` gained so a drifted or missing bridge is not +//! reported as current. + +use std::collections::HashMap; + +use impeccable_skills::bundle::{copy_provider_commands, is_up_to_date}; +use impeccable_skills::providers::{Scope, Sys}; + +fn temp_root(name: &str) -> String { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let dir = std::env::temp_dir().join(format!("impeccable-{name}-{}-{nanos}", std::process::id())); + std::fs::create_dir_all(&dir).expect("temp root"); + dir.canonicalize().unwrap().to_string_lossy().into_owned() +} + +fn write(path: &str, content: &str) { + let p = std::path::Path::new(path); + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(p, content).unwrap(); +} + +fn exists(path: &str) -> bool { + std::path::Path::new(path).exists() +} + +fn sys_with(home: &str, extra: &[(&str, &str)]) -> Sys { + let mut env: HashMap = HashMap::new(); + env.insert("HOME".into(), home.to_string()); + env.insert("USERPROFILE".into(), home.to_string()); + for (k, v) in extra { + env.insert((*k).to_string(), (*v).to_string()); + } + Sys::new(env, home.to_string()) +} + +/// JS: setupBundleWithCommand(bundleDir, providerName, commandNames) +fn bundle_with_commands(bundle: &str, provider: &str, names: &[&str]) { + for name in names { + write( + &format!("{bundle}/{provider}/commands/{name}.md"), + &format!("description: Impeccable {name} bridge\nagent: build\nsubtask: true\n\nbody {name}\n"), + ); + } +} + +fn bundle_with_skill(bundle: &str, provider: &str, body: &str) { + write(&format!("{bundle}/{provider}/skills/impeccable/SKILL.md"), body); +} + +#[test] +fn writes_commands_to_project_config_dir_by_default() { + let root = temp_root("cmd-project"); + let bundle = format!("{root}/bundle"); + let project = format!("{root}/project"); + std::fs::create_dir_all(&project).unwrap(); + bundle_with_commands(&bundle, ".opencode", &["impeccable"]); + let sys = sys_with(&root, &[]); + + let written = copy_provider_commands(&sys, &bundle, &project, &["opencode"], Some(Scope::Project)); + assert_eq!(written, 1); + let dest = format!("{project}/.opencode/commands/impeccable.md"); + assert!(exists(&dest)); + assert!(std::fs::read_to_string(&dest).unwrap().contains("impeccable bridge")); +} + +#[test] +fn user_scope_resolves_the_config_dir_opencode_scans() { + // Default: /.config/opencode/commands. + let root = temp_root("cmd-user"); + let bundle = format!("{root}/bundle"); + let home = format!("{root}/home"); + std::fs::create_dir_all(&home).unwrap(); + bundle_with_commands(&bundle, ".opencode", &["impeccable"]); + let sys = sys_with(&home, &[]); + assert_eq!( + copy_provider_commands(&sys, &bundle, &home, &["opencode"], Some(Scope::User)), + 1 + ); + assert!(exists(&format!("{home}/.config/opencode/commands/impeccable.md"))); + + // OPENCODE_CONFIG_DIR wins, and the default location stays untouched. + let root = temp_root("cmd-user-env"); + let bundle = format!("{root}/bundle"); + let home = format!("{root}/home"); + let custom = format!("{root}/custom"); + std::fs::create_dir_all(&home).unwrap(); + std::fs::create_dir_all(&custom).unwrap(); + bundle_with_commands(&bundle, ".opencode", &["impeccable"]); + let sys = sys_with(&home, &[("OPENCODE_CONFIG_DIR", &custom)]); + assert_eq!( + copy_provider_commands(&sys, &bundle, &home, &["opencode"], Some(Scope::User)), + 1 + ); + assert!(exists(&format!("{custom}/commands/impeccable.md"))); + assert!(!exists(&format!("{home}/.config/opencode/commands"))); + + // XDG_CONFIG_HOME/opencode when OPENCODE_CONFIG_DIR is unset. + let root = temp_root("cmd-user-xdg"); + let bundle = format!("{root}/bundle"); + let home = format!("{root}/home"); + let xdg = format!("{root}/xdg"); + std::fs::create_dir_all(&home).unwrap(); + std::fs::create_dir_all(&xdg).unwrap(); + bundle_with_commands(&bundle, ".opencode", &["impeccable"]); + let sys = sys_with(&home, &[("XDG_CONFIG_HOME", &xdg)]); + assert_eq!( + copy_provider_commands(&sys, &bundle, &home, &["opencode"], Some(Scope::User)), + 1 + ); + assert!(exists(&format!("{xdg}/opencode/commands/impeccable.md"))); +} + +#[test] +fn migrates_legacy_global_commands_without_disturbing_siblings() { + let root = temp_root("cmd-migrate"); + let bundle = format!("{root}/bundle"); + let home = format!("{root}/home"); + bundle_with_commands(&bundle, ".opencode", &["impeccable"]); + write(&format!("{home}/.opencode/commands/impeccable.md"), "stale\n"); + write(&format!("{home}/.opencode/commands/mine.md"), "keep me\n"); + let sys = sys_with(&home, &[]); + + assert_eq!( + copy_provider_commands(&sys, &bundle, &home, &["opencode"], Some(Scope::User)), + 1 + ); + assert!(exists(&format!("{home}/.config/opencode/commands/impeccable.md"))); + // The stranded copy loses only what was just written. + assert!(!exists(&format!("{home}/.opencode/commands/impeccable.md"))); + assert!(exists(&format!("{home}/.opencode/commands/mine.md"))); +} + +#[test] +fn a_home_rooted_git_repo_is_a_project_install_and_is_left_alone() { + let root = temp_root("cmd-git"); + let bundle = format!("{root}/bundle"); + let home = format!("{root}/home"); + bundle_with_commands(&bundle, ".opencode", &["impeccable"]); + write(&format!("{home}/.opencode/commands/impeccable.md"), "project copy\n"); + std::fs::create_dir_all(format!("{home}/.git")).unwrap(); + let sys = sys_with(&home, &[]); + + copy_provider_commands(&sys, &bundle, &home, &["opencode"], Some(Scope::User)); + assert!(exists(&format!("{home}/.opencode/commands/impeccable.md"))); +} + +#[test] +fn a_provider_without_a_commands_dir_writes_nothing() { + let root = temp_root("cmd-none"); + let bundle = format!("{root}/bundle"); + let project = format!("{root}/project"); + std::fs::create_dir_all(&project).unwrap(); + bundle_with_skill(&bundle, ".claude", "---\nname: impeccable\n---\n"); + let sys = sys_with(&root, &[]); + assert_eq!( + copy_provider_commands(&sys, &bundle, &project, &["claude"], Some(Scope::Project)), + 0 + ); + assert!(!exists(&format!("{project}/.claude/commands"))); +} + +#[test] +fn is_up_to_date_tracks_the_command_bridge() { + let root = temp_root("cmd-fresh"); + let bundle = format!("{root}/bundle"); + let project = format!("{root}/project"); + let skill = "---\nname: impeccable\nversion: 1.0.0\n---\n"; + bundle_with_skill(&bundle, ".opencode", skill); + bundle_with_commands(&bundle, ".opencode", &["impeccable"]); + write(&format!("{project}/.opencode/skills/impeccable/SKILL.md"), skill); + let sys = sys_with(&root, &[]); + + // Skills match but the bridge is missing. + assert!(!is_up_to_date(&sys, &project, &[".opencode"], &bundle, Some(Scope::Project), Some(Scope::Project)).unwrap()); + + // Bridge in place and identical. + copy_provider_commands(&sys, &bundle, &project, &["opencode"], Some(Scope::Project)); + assert!(is_up_to_date(&sys, &project, &[".opencode"], &bundle, Some(Scope::Project), Some(Scope::Project)).unwrap()); + + // Drifted content is not current. + write(&format!("{project}/.opencode/commands/impeccable.md"), "drifted\n"); + assert!(!is_up_to_date(&sys, &project, &[".opencode"], &bundle, Some(Scope::Project), Some(Scope::Project)).unwrap()); + + // A local-only command (a pinned shortcut) does not affect freshness. + copy_provider_commands(&sys, &bundle, &project, &["opencode"], Some(Scope::Project)); + write(&format!("{project}/.opencode/commands/impeccable-polish.md"), "pinned\n"); + assert!(is_up_to_date(&sys, &project, &[".opencode"], &bundle, Some(Scope::Project), Some(Scope::Project)).unwrap()); +} diff --git a/scripts/test-suites.mjs b/scripts/test-suites.mjs index 0d7dc137b..c22fe05d6 100644 --- a/scripts/test-suites.mjs +++ b/scripts/test-suites.mjs @@ -36,7 +36,6 @@ export const SUITES = { runner: 'bun', files: [ 'tests/build.test.js', - 'tests/copy-provider-commands.test.js', 'tests/lib/provider-blocks.test.js', 'tests/lib/transformers/provider-blocks.test.js', 'tests/lib/utils.test.js', diff --git a/tests/copy-provider-commands.test.js b/tests/copy-provider-commands.test.js deleted file mode 100644 index d9cd33de5..000000000 --- a/tests/copy-provider-commands.test.js +++ /dev/null @@ -1,306 +0,0 @@ -/** - * Tests for copyProviderCommands. Mirrors the PR #417 migration guards for the - * skills path, applied to /commands. OpenCode discovers custom - * commands from {command,commands}/**.md in the active config dir, so a - * global install must target $OPENCODE_CONFIG_DIR/commands, $XDG_CONFIG_HOME/ - * opencode/commands, or ~/.config/opencode/commands (in that order), never - * ~/.opencode/commands which OpenCode does not scan. - */ -import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; -import fs from 'fs'; -import path from 'path'; -import os from 'os'; -import { - mkdtempSync, - mkdirSync, - writeFileSync, - readFileSync, - existsSync, - symlinkSync, - rmSync, - realpathSync, - lstatSync, -} from 'fs'; -import { tmpdir } from 'os'; - -import { - copyProviderCommands, - isUpToDate, - opencodeGlobalConfigDir, -} from '../cli/bin/commands/skills.mjs'; - -function setupBundleWithCommand(bundleDir, providerName, commandNames) { - mkdirSync(path.join(bundleDir, providerName, 'commands'), { recursive: true }); - for (const name of commandNames) { - const file = path.join(bundleDir, providerName, 'commands', `${name}.md`); - writeFileSync( - file, - `description: Impeccable ${name} bridge\nagent: build\nsubtask: true\n\nbody ${name}\n`, - ); - } -} - -beforeEach(() => { - process.env.IMPECCABLE_BUNDLE_PATH = ''; - delete process.env.OPENCODE_CONFIG_DIR; - delete process.env.XDG_CONFIG_HOME; -}); - -afterEach(() => { - delete process.env.OPENCODE_CONFIG_DIR; - delete process.env.XDG_CONFIG_HOME; -}); - -describe('copyProviderCommands', () => { - test('writes commands to project .opencode/commands by default', () => { - const bundle = mkdtempSync(path.join(tmpdir(), 'imp-cmd-bundle-')); - const project = mkdtempSync(path.join(tmpdir(), 'imp-cmd-proj-')); - setupBundleWithCommand(bundle, '.opencode', ['impeccable']); - try { - const written = copyProviderCommands(bundle, project, ['opencode'], { scope: 'project' }); - expect(written).toBe(1); - const dest = path.join(project, '.opencode', 'commands', 'impeccable.md'); - expect(existsSync(dest)).toBe(true); - expect(readFileSync(dest, 'utf8')).toContain('impeccable bridge'); - } finally { - rmSync(bundle, { recursive: true, force: true }); - rmSync(project, { recursive: true, force: true }); - } - }); - - test('writes commands to ~/.config/opencode/commands for global scope', () => { - const bundle = mkdtempSync(path.join(tmpdir(), 'imp-cmd-bundle-')); - const home = mkdtempSync(path.join(tmpdir(), 'imp-cmd-home-')); - setupBundleWithCommand(bundle, '.opencode', ['impeccable']); - try { - const written = copyProviderCommands(bundle, home, ['opencode'], { scope: 'user' }); - expect(written).toBe(1); - const dest = path.join(home, '.config', 'opencode', 'commands', 'impeccable.md'); - expect(existsSync(dest)).toBe(true); - } finally { - rmSync(bundle, { recursive: true, force: true }); - rmSync(home, { recursive: true, force: true }); - } - }); - - test('honours OPENCODE_CONFIG_DIR for global scope', () => { - const bundle = mkdtempSync(path.join(tmpdir(), 'imp-cmd-bundle-')); - const home = mkdtempSync(path.join(tmpdir(), 'imp-cmd-home-')); - const customDir = mkdtempSync(path.join(tmpdir(), 'imp-cmd-custom-')); - setupBundleWithCommand(bundle, '.opencode', ['impeccable']); - try { - process.env.OPENCODE_CONFIG_DIR = customDir; - const written = copyProviderCommands(bundle, home, ['opencode'], { scope: 'user' }); - expect(written).toBe(1); - const dest = path.join(customDir, 'commands', 'impeccable.md'); - expect(existsSync(dest)).toBe(true); - expect(existsSync(path.join(home, '.config', 'opencode', 'commands'))).toBe(false); - } finally { - rmSync(bundle, { recursive: true, force: true }); - rmSync(home, { recursive: true, force: true }); - rmSync(customDir, { recursive: true, force: true }); - } - }); - - test('honours XDG_CONFIG_HOME/opencode/commands when OPENCODE_CONFIG_DIR is unset', () => { - const bundle = mkdtempSync(path.join(tmpdir(), 'imp-cmd-bundle-')); - const home = mkdtempSync(path.join(tmpdir(), 'imp-cmd-home-')); - const xdgRoot = mkdtempSync(path.join(tmpdir(), 'imp-cmd-xdg-')); - setupBundleWithCommand(bundle, '.opencode', ['impeccable']); - try { - process.env.XDG_CONFIG_HOME = xdgRoot; - const written = copyProviderCommands(bundle, home, ['opencode'], { scope: 'user' }); - expect(written).toBe(1); - const dest = path.join(xdgRoot, 'opencode', 'commands', 'impeccable.md'); - expect(existsSync(dest)).toBe(true); - } finally { - rmSync(bundle, { recursive: true, force: true }); - rmSync(home, { recursive: true, force: true }); - rmSync(xdgRoot, { recursive: true, force: true }); - } - }); - - test('migrates legacy ~/.opencode/commands entries without disturbing siblings', () => { - const bundle = mkdtempSync(path.join(tmpdir(), 'imp-cmd-bundle-')); - const home = mkdtempSync(path.join(tmpdir(), 'imp-cmd-home-')); - setupBundleWithCommand(bundle, '.opencode', ['impeccable']); - // Pre-seed a legacy copy with both a command we want to replace and a - // sibling the install must NOT touch. - const legacyDir = path.join(home, '.opencode', 'commands'); - mkdirSync(legacyDir, { recursive: true }); - writeFileSync(path.join(legacyDir, 'impeccable.md'), 'stale impeccable\n'); - writeFileSync(path.join(legacyDir, 'unrelated-command.md'), 'unrelated\n'); - try { - const written = copyProviderCommands(bundle, home, ['opencode'], { scope: 'user' }); - expect(written).toBe(1); - const dest = path.join(home, '.config', 'opencode', 'commands', 'impeccable.md'); - expect(existsSync(dest)).toBe(true); - expect(existsSync(path.join(legacyDir, 'impeccable.md'))).toBe(false); - expect(existsSync(path.join(legacyDir, 'unrelated-command.md'))).toBe(true); - expect(readFileSync(path.join(legacyDir, 'unrelated-command.md'), 'utf8')).toBe('unrelated\n'); - } finally { - rmSync(bundle, { recursive: true, force: true }); - rmSync(home, { recursive: true, force: true }); - } - }); - - test('does not migrate a symlinked legacy dir (shared storage)', () => { - const bundle = mkdtempSync(path.join(tmpdir(), 'imp-cmd-bundle-')); - const home = mkdtempSync(path.join(tmpdir(), 'imp-cmd-home-')); - const shared = mkdtempSync(path.join(tmpdir(), 'imp-cmd-shared-')); - setupBundleWithCommand(bundle, '.opencode', ['impeccable']); - mkdirSync(path.join(home, '.opencode'), { recursive: true }); - symlinkSync(shared, path.join(home, '.opencode', 'commands'), 'dir'); - writeFileSync(path.join(shared, 'unrelated-command.md'), 'unrelated\n'); - try { - copyProviderCommands(bundle, home, ['opencode'], { scope: 'user' }); - expect(existsSync(path.join(shared, 'unrelated-command.md'))).toBe(true); - expect(lstatSync(path.join(home, '.opencode', 'commands')).isSymbolicLink()).toBe(true); - } finally { - rmSync(bundle, { recursive: true, force: true }); - rmSync(home, { recursive: true, force: true }); - rmSync(shared, { recursive: true, force: true }); - } - }); - - test('returns 0 when the bundle has no commands dir', () => { - const bundle = mkdtempSync(path.join(tmpdir(), 'imp-cmd-bundle-')); - const project = mkdtempSync(path.join(tmpdir(), 'imp-cmd-proj-')); - try { - const written = copyProviderCommands(bundle, project, ['opencode'], { scope: 'project' }); - expect(written).toBe(0); - expect(existsSync(path.join(project, '.opencode', 'commands'))).toBe(false); - } finally { - rmSync(bundle, { recursive: true, force: true }); - rmSync(project, { recursive: true, force: true }); - } - }); - - test('ignores providers without a commands directory', () => { - const bundle = mkdtempSync(path.join(tmpdir(), 'imp-cmd-bundle-')); - const project = mkdtempSync(path.join(tmpdir(), 'imp-cmd-proj-')); - mkdirSync(path.join(bundle, 'claude'), { recursive: true }); - try { - const written = copyProviderCommands(bundle, project, ['claude'], { scope: 'project' }); - expect(written).toBe(0); - } finally { - rmSync(bundle, { recursive: true, force: true }); - rmSync(project, { recursive: true, force: true }); - } - }); -}); - -describe('isUpToDate command awareness', () => { - function setupBundleWithSkill(bundleDir, providerName, { withCommands = true } = {}) { - const skillDir = path.join(bundleDir, providerName, 'skills', 'impeccable'); - mkdirSync(path.join(skillDir, 'scripts'), { recursive: true }); - writeFileSync(path.join(skillDir, 'SKILL.md'), '---\nname: impeccable\n---\nBundle skill.\n'); - writeFileSync(path.join(skillDir, 'scripts', 'context.mjs'), 'console.log("bundle");\n'); - if (withCommands) setupBundleWithCommand(bundleDir, providerName, ['impeccable']); - } - - function mirrorBundleSkills(bundleDir, root, providerName) { - fs.cpSync( - path.join(bundleDir, providerName, 'skills'), - path.join(root, providerName, 'skills'), - { recursive: true }, - ); - } - - function mirrorBundleCommands(bundleDir, root, providerName) { - fs.cpSync( - path.join(bundleDir, providerName, 'commands'), - path.join(root, providerName, 'commands'), - { recursive: true }, - ); - } - - test('returns false when skills match but the command bridge is missing', () => { - const bundle = mkdtempSync(path.join(tmpdir(), 'imp-cmd-bundle-')); - const project = mkdtempSync(path.join(tmpdir(), 'imp-cmd-proj-')); - setupBundleWithSkill(bundle, '.opencode'); - mirrorBundleSkills(bundle, project, '.opencode'); - try { - expect(isUpToDate(project, ['.opencode'], bundle, 'project')).toBe(false); - } finally { - rmSync(bundle, { recursive: true, force: true }); - rmSync(project, { recursive: true, force: true }); - } - }); - - test('returns true when skills and commands match the bundle', () => { - const bundle = mkdtempSync(path.join(tmpdir(), 'imp-cmd-bundle-')); - const project = mkdtempSync(path.join(tmpdir(), 'imp-cmd-proj-')); - setupBundleWithSkill(bundle, '.opencode'); - mirrorBundleSkills(bundle, project, '.opencode'); - mirrorBundleCommands(bundle, project, '.opencode'); - try { - expect(isUpToDate(project, ['.opencode'], bundle, 'project')).toBe(true); - } finally { - rmSync(bundle, { recursive: true, force: true }); - rmSync(project, { recursive: true, force: true }); - } - }); - - test('returns false when the command bridge content drifted', () => { - const bundle = mkdtempSync(path.join(tmpdir(), 'imp-cmd-bundle-')); - const project = mkdtempSync(path.join(tmpdir(), 'imp-cmd-proj-')); - setupBundleWithSkill(bundle, '.opencode'); - mirrorBundleSkills(bundle, project, '.opencode'); - mirrorBundleCommands(bundle, project, '.opencode'); - writeFileSync(path.join(project, '.opencode', 'commands', 'impeccable.md'), 'user edit drift\n'); - try { - expect(isUpToDate(project, ['.opencode'], bundle, 'project')).toBe(false); - } finally { - rmSync(bundle, { recursive: true, force: true }); - rmSync(project, { recursive: true, force: true }); - } - }); - - test('ignores local-only command files such as pinned shortcuts', () => { - const bundle = mkdtempSync(path.join(tmpdir(), 'imp-cmd-bundle-')); - const project = mkdtempSync(path.join(tmpdir(), 'imp-cmd-proj-')); - setupBundleWithSkill(bundle, '.opencode'); - mirrorBundleSkills(bundle, project, '.opencode'); - mirrorBundleCommands(bundle, project, '.opencode'); - writeFileSync(path.join(project, '.opencode', 'commands', 'impeccable-audit.md'), 'pinned\n'); - try { - expect(isUpToDate(project, ['.opencode'], bundle, 'project')).toBe(true); - } finally { - rmSync(bundle, { recursive: true, force: true }); - rmSync(project, { recursive: true, force: true }); - } - }); - - test('ignores providers whose bundle has no commands directory', () => { - const bundle = mkdtempSync(path.join(tmpdir(), 'imp-cmd-bundle-')); - const project = mkdtempSync(path.join(tmpdir(), 'imp-cmd-proj-')); - setupBundleWithSkill(bundle, '.opencode', { withCommands: false }); - mirrorBundleSkills(bundle, project, '.opencode'); - try { - expect(isUpToDate(project, ['.opencode'], bundle, 'project')).toBe(true); - } finally { - rmSync(bundle, { recursive: true, force: true }); - rmSync(project, { recursive: true, force: true }); - } - }); - - test('user scope resolves the commands dir via OPENCODE_CONFIG_DIR', () => { - const bundle = mkdtempSync(path.join(tmpdir(), 'imp-cmd-bundle-')); - const home = mkdtempSync(path.join(tmpdir(), 'imp-cmd-home-')); - const custom = mkdtempSync(path.join(tmpdir(), 'imp-cmd-custom-')); - setupBundleWithSkill(bundle, '.opencode'); - process.env.OPENCODE_CONFIG_DIR = custom; - // User-scope OpenCode skills live at /skills (HOME_SKILLS_DIR_OVERRIDES). - fs.cpSync(path.join(bundle, '.opencode', 'skills'), path.join(custom, 'skills'), { recursive: true }); - try { - expect(isUpToDate(home, ['.opencode'], bundle, 'user')).toBe(false); - fs.cpSync(path.join(bundle, '.opencode', 'commands'), path.join(custom, 'commands'), { recursive: true }); - expect(isUpToDate(home, ['.opencode'], bundle, 'user')).toBe(true); - } finally { - rmSync(bundle, { recursive: true, force: true }); - rmSync(home, { recursive: true, force: true }); - rmSync(custom, { recursive: true, force: true }); - } - }); -});