diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cdea800eb..3ad52830a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -137,7 +137,7 @@ jobs: # What `bun run build` writes, which runs on every PR. # extension/detector/ is gitignored (built by `cargo xtask bundle`); # it stays listed so a stray tracked copy shows up here. - run: git diff --exit-code -- .agents .claude .cursor .gemini .github/skills plugin extension/detector + run: git diff --exit-code -- .agents .claude .cursor .dsh .gemini .github/skills plugin extension/detector - name: Upload build artifacts uses: actions/upload-artifact@v7 diff --git a/.github/workflows/sync-generated-output.yml b/.github/workflows/sync-generated-output.yml index 1c2d17908..dff62f9cc 100644 --- a/.github/workflows/sync-generated-output.yml +++ b/.github/workflows/sync-generated-output.yml @@ -26,6 +26,7 @@ env: .codex .claude .cursor + .dsh .gemini .github/agents .github/hooks diff --git a/AGENTS.md b/AGENTS.md index aa9cdabc4..38afae21f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,7 @@ Run `bun run build` after changing anything in `skill/`, transformer code, or us ## Generated Provider Output Policy -The root harness folders (`.agents/skills/`, `.claude/skills/`, `.cursor/skills/`, `.gemini/skills/`, `.github/skills/`, `.grok/skills/`, `.hermes/skills/`, `.kiro/skills/`, `.opencode/skills/`, `.pi/skills/`, `.qoder/skills/`, `.rovodev/skills/`, `.trae*/skills/`, `.vibe/skills/`) and `plugin/` stay tracked so `main` remains installable for direct GitHub, `npx skills`, and submodule users. They are still generated artifacts. +The root harness folders (`.agents/skills/`, `.claude/skills/`, `.cursor/skills/`, `.dsh/skills/`, `.gemini/skills/`, `.github/skills/`, `.grok/skills/`, `.hermes/skills/`, `.kiro/skills/`, `.opencode/skills/`, `.pi/skills/`, `.qoder/skills/`, `.rovodev/skills/`, `.trae*/skills/`, `.vibe/skills/`) and `plugin/` stay tracked so `main` remains installable for direct GitHub, `npx skills`, and submodule users. They are still generated artifacts. Normal development should be source-first: stage changes in `crates/`, `browser-bundle/`, `skill/`, `scripts/`, `cli/`, `extension/`, and `tests/`; leave generated harness churn unstaged unless the user asked for it. After source changes land on `main`, `.github/workflows/sync-generated-output.yml` runs `bun run build:release` and commits generated provider output directly back to `main`. Treat generated harness diffs as release artifacts and keep them out of feature PRs unless they are the point of the PR. The two tracked engine assets under `crates/live/assets/` follow the rule-change workflow below instead. diff --git a/README.md b/README.md index cf97eeebc..92541b799 100644 --- a/README.md +++ b/README.md @@ -184,6 +184,18 @@ cp -r dist/claude-code/.claude/* ~/.claude/ cp -r dist/opencode/.opencode your-project/ ``` +**DeepSeek Harness:** +```bash +# Project-specific +cp -r dist/dsh/.dsh your-project/ + +# Or global (applies to all projects) +mkdir -p "${DSH_HOME:-$HOME/.dsh}/skills" +cp -r dist/dsh/.dsh/skills/* "${DSH_HOME:-$HOME/.dsh}/skills/" +``` + +The CLI honors `DSH_HOME` only when it resolves inside your home directory (or to home itself); otherwise it uses `~/.dsh`. An outside-home manual copy is not managed by `impeccable install/update`. + **Hermes Agent:** ```bash # Global (applies to all projects; uses the active profile, or ~/.hermes by default) @@ -444,6 +456,7 @@ Full detector docs: [impeccable.style/docs/detector](https://impeccable.style/do - [Cursor](https://cursor.com) - [Claude Code](https://claude.ai/code) - [GitHub Copilot](https://github.com/features/copilot) +- [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) - [Gemini CLI](https://github.com/google-gemini/gemini-cli) - [Codex CLI](https://github.com/openai/codex) - [Grok Build](https://x.ai/cli) diff --git a/crates/context/src/pin.rs b/crates/context/src/pin.rs index a27d1143b..16902402b 100644 --- a/crates/context/src/pin.rs +++ b/crates/context/src/pin.rs @@ -8,8 +8,8 @@ use impeccable_common::Io; /// keep in sync with the public repo). pub const COMMAND_METADATA_JSON: &str = include_str!("command-metadata.json"); -const HARNESS_DIRS: [&str; 17] = [ - ".claude", ".cursor", ".gemini", ".codex", ".agents", ".agent", ".github", ".grok", ".hermes", ".trae", ".trae-cn", +const HARNESS_DIRS: [&str; 18] = [ + ".claude", ".cursor", ".dsh", ".gemini", ".codex", ".agents", ".agent", ".github", ".grok", ".hermes", ".trae", ".trae-cn", ".pi", ".opencode", ".kiro", ".rovodev", ".vibe", ".qoder", ]; const CODEX_HARNESSES: [&str; 2] = [".codex", ".agents"]; diff --git a/crates/context/src/provider.rs b/crates/context/src/provider.rs index ab0591b0d..d95e961a4 100644 --- a/crates/context/src/provider.rs +++ b/crates/context/src/provider.rs @@ -62,6 +62,7 @@ fn provider_from_skill_dir(skill_dir: &str) -> Option<&'static str> { Some(match harness.as_str() { ".claude" => "claude-code", ".cursor" => "cursor", + ".dsh" => "dsh", ".gemini" => "gemini", ".codex" => "codex", ".agents" => "agents", diff --git a/crates/skills/src/bundle.rs b/crates/skills/src/bundle.rs index 3c388ee73..379fa73f8 100644 --- a/crates/skills/src/bundle.rs +++ b/crates/skills/src/bundle.rs @@ -405,7 +405,7 @@ pub fn list_skill_tree_files(root: &str) -> Vec { } static PROVIDER_PATH_RE: Lazy = Lazy::new(|| { - Regex::new(r"\.(claude|cursor|agents|agent|github|gemini|codex|grok|hermes|kiro|opencode|pi|qoder|trae|trae-cn|rovodev|vibe)/skills/").unwrap() + Regex::new(r"\.(claude|cursor|dsh|agents|agent|github|gemini|codex|grok|hermes|kiro|opencode|pi|qoder|trae|trae-cn|rovodev|vibe)/skills/").unwrap() }); /// JS: normalizeForHash(content) diff --git a/crates/skills/src/providers.rs b/crates/skills/src/providers.rs index 7c56b4533..a399d6e00 100644 --- a/crates/skills/src/providers.rs +++ b/crates/skills/src/providers.rs @@ -8,7 +8,7 @@ use crate::util::{self, jsp, Env}; pub const API_BASE: &str = "https://impeccable.style"; pub const PROVIDER_DIRS: &[&str] = &[ - ".claude", ".cursor", ".gemini", ".agents", ".agent", ".github", ".grok", ".hermes", ".kiro", + ".claude", ".cursor", ".dsh", ".gemini", ".agents", ".agent", ".github", ".grok", ".hermes", ".kiro", ".opencode", ".pi", ".qoder", ".trae", ".trae-cn", ".rovodev", ".vibe", ]; @@ -21,6 +21,9 @@ const PROVIDER_ALIASES: &[(&str, &str)] = &[ ("codex", ".agents"), ("copilot", ".github"), ("cursor", ".cursor"), + ("deepseek", ".dsh"), + ("deepseek-harness", ".dsh"), + ("dsh", ".dsh"), ("gemini", ".gemini"), ("github", ".github"), ("grok", ".grok"), @@ -43,6 +46,7 @@ const PROVIDER_DISPLAY: &[(&str, &str, &str)] = &[ (".agents", "Codex CLI", "codex"), (".claude", "Claude Code", "claude"), (".cursor", "Cursor", "cursor"), + (".dsh", "DeepSeek Harness", "dsh"), (".gemini", "Gemini CLI", "gemini"), (".github", "GitHub Copilot", "github"), (".grok", "Grok Build", "grok"), @@ -58,7 +62,7 @@ const PROVIDER_DISPLAY: &[(&str, &str, &str)] = &[ ]; pub const PROVIDER_INPUT_ORDER: &[&str] = &[ - "antigravity", "claude", "codex", "cursor", "gemini", "github", "grok", "hermes", "kiro", + "antigravity", "claude", "codex", "cursor", "dsh", "gemini", "github", "grok", "hermes", "kiro", "opencode", "pi", "qoder", "trae", "trae-cn", "rovo-dev", "vibe", ]; @@ -89,10 +93,30 @@ pub fn hermes_global_home(env: &Env, cwd: &str, home: &str) -> String { jsp::join(&[home, ".hermes"]) } +/// JS: dshGlobalHome(home): honor $DSH_HOME only when it sits under `home` +/// (resolved against cwd like `path.resolve`), mirroring hermesGlobalHome. +pub fn dsh_global_home(env: &Env, cwd: &str, home: &str) -> String { + if let Some(env_home) = env.get("DSH_HOME").filter(|v| !v.is_empty()) { + let resolved_env = jsp::resolve(cwd, &[env_home]); + let resolved_home = jsp::resolve(cwd, &[home]); + // Native path.relative handles Windows separators, drive/UNC roots, + // and case folding without accepting a sibling with the same prefix. + let relative = jsp::relative(cwd, &resolved_home, &resolved_env); + if !jsp::is_absolute(&relative) + && relative != ".." + && !relative.starts_with(&format!("..{}", jsp::SEP)) + { + return resolved_env; + } + } + jsp::join(&[home, ".dsh"]) +} + /// JS: HOME_SKILLS_DIR_OVERRIDES[provider]?.(home) fn home_skills_dir_override(env: &Env, cwd: &str, provider: &str, home: &str) -> Option { match provider { ".agent" => Some(jsp::join(&[home, ".gemini", "config", "skills"])), + ".dsh" => Some(jsp::join(&[&dsh_global_home(env, cwd, home), "skills"])), ".hermes" => Some(jsp::join(&[&hermes_global_home(env, cwd, home), "skills"])), ".pi" => Some(jsp::join(&[home, ".pi", "agent", "skills"])), ".opencode" => Some(jsp::join(&[&opencode_global_config_dir(env, home), "skills"])), @@ -101,7 +125,7 @@ fn home_skills_dir_override(env: &Env, cwd: &str, provider: &str, home: &str) -> } fn has_home_override(provider: &str) -> bool { - matches!(provider, ".agent" | ".hermes" | ".pi" | ".opencode") + matches!(provider, ".agent" | ".dsh" | ".hermes" | ".pi" | ".opencode") } /// Everything the scans need from the process: env, cwd, and the resolved @@ -309,6 +333,16 @@ impl Sys { has_real_skills: has_real_skill_entries(&jsp::join(&[root, provider, "skills"])), }); } + // Shared probe pair for env-relocated config dirs (OpenCode, DSH): + // the harness-specific user skills dir plus a direct + // `/skills` fallback. + let config_dir_detection = |provider: &'static str, found: String| { + let probes = unique_paths(vec![ + self.user_provider_skills_dir(home, provider), + jsp::join(&[&found, "skills"]), + ]); + (found, probes) + }; for hint in GLOBAL_HARNESS_HINTS { let (found_path, probe_paths) = match hint { Hint::Home(rel, provider) => { @@ -320,12 +354,10 @@ impl Sys { (found, probes) } Hint::OpencodeConfig(provider) => { - let found = opencode_global_config_dir(&self.env, home); - let probes = unique_paths(vec![ - self.user_provider_skills_dir(home, provider), - jsp::join(&[&found, "skills"]), - ]); - (found, probes) + config_dir_detection(provider, opencode_global_config_dir(&self.env, home)) + } + Hint::DshHome(provider) => { + config_dir_detection(provider, dsh_global_home(&self.env, &self.cwd, home)) } }; if !util::exists(&found_path) { @@ -415,12 +447,16 @@ pub enum UpdateTarget { enum Hint { Home(&'static str, &'static str), OpencodeConfig(&'static str), + /// `$DSH_HOME` relocates the whole config root, so detection must probe + /// the resolved home (which falls back to `~/.dsh`) rather than a fixed + /// relative path; a plain `Home` hint would miss a DSH_HOME-only setup. + DshHome(&'static str), } impl Hint { fn provider(&self) -> &'static str { match self { - Hint::Home(_, p) | Hint::OpencodeConfig(p) => p, + Hint::Home(_, p) | Hint::OpencodeConfig(p) | Hint::DshHome(p) => p, } } } @@ -433,6 +469,7 @@ const GLOBAL_HARNESS_HINTS: &[Hint] = &[ Hint::Home(".claude", ".claude"), Hint::Home(".codex", ".agents"), Hint::Home(".cursor", ".cursor"), + Hint::DshHome(".dsh"), Hint::Home(".gemini", ".gemini"), Hint::Home(".grok", ".grok"), Hint::Home(".hermes", ".hermes"), @@ -758,6 +795,66 @@ mod tests { assert_eq!(invalid, vec!["zzz"]); } + #[test] + fn dsh_provider_resolves() { + assert_eq!(normalize_provider_name("dsh"), Some(".dsh")); + assert_eq!(normalize_provider_name("deepseek"), Some(".dsh")); + assert_eq!(normalize_provider_name("deepseek-harness"), Some(".dsh")); + assert_eq!(normalize_provider_name(".dsh"), Some(".dsh")); + assert_eq!(provider_display_name(".dsh"), "DeepSeek Harness"); + assert_eq!(provider_input_name(".dsh"), "dsh"); + + // Default global skills dir is ~/.dsh/skills; $DSH_HOME wins only + // when it sits under home, like $HERMES_HOME for .hermes. + let cwd = if cfg!(windows) { r"C:\work" } else { "/work" }; + let home = if cfg!(windows) { r"C:\Users\u" } else { "/home/u" }; + let default = jsp::join(&[home, ".dsh"]); + let custom = jsp::join(&[home, "custom-dsh"]); + let env = Env::new(); + assert_eq!(dsh_global_home(&env, cwd, home), default); + let mut env = Env::new(); + env.insert("DSH_HOME".into(), custom.clone()); + assert_eq!(dsh_global_home(&env, cwd, home), custom); + env.insert("DSH_HOME".into(), "/elsewhere/dsh".into()); + assert_eq!(dsh_global_home(&env, cwd, home), default); + } + + #[test] + fn dsh_home_respects_resolved_path_boundaries() { + let home = if cfg!(windows) { r"C:\Users\Test User" } else { "/home/Test User" }; + let cwd = jsp::join(&[home, "project"]); + let default = jsp::join(&[home, ".dsh"]); + for (value, expected) in [ + ("../custom dsh".to_string(), jsp::join(&[home, "custom dsh"])), + (home.to_string(), home.to_string()), + (String::new(), default.clone()), + (format!("{home}-other/dsh"), default.clone()), + (format!("{home}/../outside"), default), + ] { + let env = Env::from([("DSH_HOME".into(), value.clone())]); + assert_eq!(dsh_global_home(&env, &cwd, home), expected, "DSH_HOME={value}"); + } + // A drive/filesystem root has no extra separator to append. + let root = if cfg!(windows) { "C:\\" } else { "/" }; + let child = jsp::join(&[root, "custom dsh"]); + let env = Env::from([("DSH_HOME".into(), child.clone())]); + assert_eq!(dsh_global_home(&env, root, root), child); + } + + #[cfg(windows)] + #[test] + fn dsh_home_handles_windows_case_separators_and_devices() { + for (home, value, expected) in [ + (r"C:\Users\Alice", "c:/users/alice/custom", r"c:\users\alice\custom"), + (r"C:\Users\Alice", r"D:\Users\Alice\custom", r"C:\Users\Alice\.dsh"), + (r"\\server\share\Alice", r"\\server\share\Alice\custom", r"\\server\share\Alice\custom"), + (r"\\server\share\Alice", r"\\server\other\Alice\custom", r"\\server\share\Alice\.dsh"), + ] { + let env = Env::from([("DSH_HOME".into(), value.into())]); + assert_eq!(dsh_global_home(&env, home, home), expected); + } + } + #[test] fn version_extraction() { // Values recorded from origin/main's parseSkillFrontmatterVersion (#703). diff --git a/crates/skills/tests/drift_ports_tests.rs b/crates/skills/tests/drift_ports_tests.rs index 7fc5f440f..dfd8b7049 100644 --- a/crates/skills/tests/drift_ports_tests.rs +++ b/crates/skills/tests/drift_ports_tests.rs @@ -397,6 +397,40 @@ fn claude_install_and_update_backfill_bundled_agents() { std::fs::remove_dir_all(&root).ok(); } +#[test] +fn dsh_install_and_update_use_relocated_home_without_touching_project_skills() { + let root = temp_root("dsh-install-update"); + let project = jsp::join(&[&root, "project"]); + let home = jsp::join(&[&root, "home"]); + let tmpdir = jsp::join(&[&root, "tmp"]); + let dsh_home = jsp::join(&[&home, "custom dsh"]); + for dir in [&project, &home, &tmpdir, &dsh_home] { + std::fs::create_dir_all(dir).unwrap(); + } + std::fs::create_dir_all(jsp::join(&[&project, ".git"])).unwrap(); + let bundle = create_fake_universal_bundle(&root, &[".dsh"]); + let mut env = base_env(&home, &tmpdir, &bundle); + env.insert("DSH_HOME".into(), dsh_home.clone()); + let project_skill = jsp::join(&[&project, ".dsh", "skills", "impeccable", "SKILL.md"]); + write(&project_skill, "project skill must stay untouched"); + let installed = jsp::join(&[&dsh_home, "skills", "impeccable", "SKILL.md"]); + let source = jsp::join(&[&bundle, ".dsh", "skills", "impeccable", "SKILL.md"]); + + let r = run_cli(&["install", "-y", "--providers=deepseek-harness", "--scope=global"], &project, &env); + assert_eq!(r.code, 0, "{}\n{}", r.stdout, r.stderr); + assert_eq!(read(&installed), read(&source)); + assert!(!std::path::Path::new(&jsp::join(&[&home, ".dsh"])).exists()); + + write(&source, "---\nname: impeccable\nversion: 9.9.10-local\n---\n\nUpdated DSH fixture.\n"); + let r = run_cli(&["update", "-y", "--providers=dsh", "--scope=global"], &project, &env); + assert_eq!(r.code, 0, "{}\n{}", r.stdout, r.stderr); + assert_eq!(read(&installed), read(&source)); + assert_eq!(read(&project_skill), "project skill must stay untouched"); + assert!(!std::path::Path::new(&jsp::join(&[&dsh_home, "agents"])).exists()); + assert!(!std::path::Path::new(&jsp::join(&[&dsh_home, "hooks"])).exists()); + std::fs::remove_dir_all(&root).ok(); +} + // ─── home-scoped agent freshness (16a218e6) ────────────────────────────────── #[test] diff --git a/crates/skills/tests/install_detection_tests.rs b/crates/skills/tests/install_detection_tests.rs new file mode 100644 index 000000000..572e59124 --- /dev/null +++ b/crates/skills/tests/install_detection_tests.rs @@ -0,0 +1,77 @@ +//! Detection coverage for env-relocated global harness dirs: `$DSH_HOME` +//! (DeepSeek Harness) must be detected even when `~/.dsh` itself does not +//! exist, and must be ignored when it points outside home. + +use std::collections::HashMap; + +use impeccable_skills::providers::{Scope, Sys}; +use impeccable_common::jsp; + +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"); + let real = dir.canonicalize().unwrap().to_string_lossy().into_owned(); + real.strip_prefix(r"\\?\").map(str::to_string).unwrap_or(real) +} + +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()) +} + +fn dsh_detections(sys: &Sys, root: &str) -> Vec { + sys.collect_install_detections(root) + .into_iter() + .filter(|d| d.provider == ".dsh" && d.scope == Scope::User) + .map(|d| d.found_path) + .collect() +} + +#[test] +fn dsh_home_only_setup_is_detected() { + let home = temp_root("dsh-home-detect"); + let project = temp_root("dsh-home-project"); + let dsh_home = jsp::join(&[&home, "custom-dsh"]); + std::fs::create_dir_all(&dsh_home).unwrap(); + let sys = sys_with(&home, &[("DSH_HOME", &dsh_home)]); + + let found = dsh_detections(&sys, &project); + assert_eq!(found, vec![dsh_home.clone()]); +} + +#[test] +fn default_dot_dsh_is_detected_without_env() { + let home = temp_root("dsh-default-detect"); + let project = temp_root("dsh-default-project"); + std::fs::create_dir_all(jsp::join(&[&home, ".dsh"])).unwrap(); + let sys = sys_with(&home, &[]); + + let found = dsh_detections(&sys, &project); + assert_eq!(found, vec![jsp::join(&[&home, ".dsh"])]); +} + +#[test] +fn dsh_home_outside_home_falls_back_to_dot_dsh() { + let home = temp_root("dsh-outside-detect"); + let project = temp_root("dsh-outside-project"); + let outside = temp_root("dsh-outside-elsewhere"); + std::fs::create_dir_all(&outside).unwrap(); + let sys = sys_with(&home, &[("DSH_HOME", &outside)]); + + // No ~/.dsh and the env override is refused: not detected. + assert!(dsh_detections(&sys, &project).is_empty()); + + // With ~/.dsh present the fallback detects the default location. + std::fs::create_dir_all(jsp::join(&[&home, ".dsh"])).unwrap(); + let found = dsh_detections(&sys, &project); + assert_eq!(found, vec![jsp::join(&[&home, ".dsh"])]); +} diff --git a/docs/CLI-CONTRACT.md b/docs/CLI-CONTRACT.md index 35651349a..05af8ab6b 100644 --- a/docs/CLI-CONTRACT.md +++ b/docs/CLI-CONTRACT.md @@ -354,7 +354,7 @@ retain their local-development trust behavior. See [bundle signing](BUNDLE-SIGNI - **Invoked from**: README.md ("npx impeccable install / update"), README.npm.md Quick Start (`npx impeccable skills install`, `... install -y --providers=claude,codex --scope=project`, `... update`, `... install --no-hooks`, `... link --source=.impeccable --providers=claude,cursor`, `... skills help`), `README.md:360` (hook consent explanation). - `run(args)`: `args[0]` ∈ `undefined|help|--help|-h` → `showHelp()`; `install` → `install(rest)`; `link`; `update`; `check` (ignores flags); else `stderr> Unknown skills command: ${sub}` + `Run 'impeccable --help' for available commands.`, `exit 1`. -- Constants: `API_BASE = 'https://impeccable.style'`; `PROVIDER_DIRS = ['.claude','.cursor','.gemini','.agents','.agent','.github','.grok','.hermes','.kiro','.opencode','.pi','.qoder','.trae','.trae-cn','.rovodev','.vibe']`; aliases (`agent`→`.agent`, `agents`/`codex`→`.agents`, `antigravity`→`.agent`, `claude`/`claude-code`→`.claude`, `copilot`/`github`→`.github`, `cursor`, `gemini`, `grok`/`grok-build`/`xai`→`.grok`, `hermes`, `kiro`, `opencode`, `pi`, `qoder`, `rovo-dev`/`rovodev`→`.rovodev`, `trae`, `trae-cn`, `vibe`); leading `.` stripped and lowercased before alias lookup; a literal PROVIDER_DIR value is accepted as-is. `DEFAULT_TARGETS = ['.claude','.agents']`. User-scope skill dir overrides: `.agent`→`~/.gemini/config/skills`, `.hermes`→`$HERMES_HOME/skills` (only when HERMES_HOME under home) else `~/.hermes/skills`, `.pi`→`~/.pi/agent/skills`, `.opencode`→`$OPENCODE_CONFIG_DIR|$XDG_CONFIG_HOME/opencode|~/.config/opencode` + `/skills`; others `~//skills`. Project scope: `//skills`. +- Constants: `API_BASE = 'https://impeccable.style'`; `PROVIDER_DIRS = ['.claude','.cursor','.dsh','.gemini','.agents','.agent','.github','.grok','.hermes','.kiro','.opencode','.pi','.qoder','.trae','.trae-cn','.rovodev','.vibe']`; aliases (`agent`→`.agent`, `agents`/`codex`→`.agents`, `antigravity`→`.agent`, `claude`/`claude-code`→`.claude`, `copilot`/`github`→`.github`, `cursor`, `deepseek`/`deepseek-harness`/`dsh`→`.dsh`, `gemini`, `grok`/`grok-build`/`xai`→`.grok`, `hermes`, `kiro`, `opencode`, `pi`, `qoder`, `rovo-dev`/`rovodev`→`.rovodev`, `trae`, `trae-cn`, `vibe`); leading `.` stripped and lowercased before alias lookup; a literal PROVIDER_DIR value is accepted as-is. `DEFAULT_TARGETS = ['.claude','.agents']`. User-scope skill dir overrides: `.agent`→`~/.gemini/config/skills`, `.dsh`→`$DSH_HOME/skills` (only when DSH_HOME under home) else `~/.dsh/skills`, `.hermes`→`$HERMES_HOME/skills` (only when HERMES_HOME under home) else `~/.hermes/skills`, `.pi`→`~/.pi/agent/skills`, `.opencode`→`$OPENCODE_CONFIG_DIR|$XDG_CONFIG_HOME/opencode|~/.config/opencode` + `/skills`; others `~//skills`. Project scope: `//skills`. - **help**: `fetch('https://impeccable.style/api/commands')` → JSON array `[{id, description}]`; failure → `stderr> Could not fetch command list from impeccable.style. Check your network connection.`, `exit 1`. Prints: ``` @@ -375,7 +375,7 @@ retain their local-development trust behavior. See [bundle signing](BUNDLE-SIGNI ``` sorted by `id.localeCompare`. - **Flag parsing** (`getFlagValue`): `--name=value` or `--name value` (next arg not starting with `-`). Boolean flags via `includes`. -- **install flags**: `--force`, `-y|--yes`, `--no-hooks`, `--providers=`, scope: `--user|--home|--global` → user; `--project|--local` → project; `--scope=`/`--install-scope=` normalized (`u|user|home|global`→user, `p|project|local|repo`→project; unknown → error `Unknown install scope: ${v}. Use --scope=project or --scope=global.`). Project root = nearest ancestor with `.git`, else cwd. Detection: project harness dirs present in root, plus `GLOBAL_HARNESS_HINTS` under home (`.agent`, `.gemini/antigravity*`→`.agent`, `.claude`, `.codex`→`.agents`, `.cursor`, `.gemini`, `.grok`, `.hermes`, `.kiro`, `.opencode`, opencode config dir, `.pi`, `.qoder`, `.rovodev`, `.vibe`). Targets: explicit list wins (invalid names → `Unknown provider(s): ...`); `-y` → detected project providers, else detected user providers, else DEFAULT_TARGETS; interactive → prints "Detected harnesses:" table then radio/checkbox prompts (raw-mode TTY) or line prompts (`Install target: [1] Detected only (...) [2] Customize [1]: `, `Select harnesses (comma-separated: ...)`). Scope: explicit; `-y` → project; interactive prompt `Install location` (Project/Global). Hooks: `decideHookInstall` reads `hook.consent` in config(.local).json; declined→false, accepted→true; all targets already have hook markers→true; `-y` or non-TTY→true; else prints HOOK_EXPLAINER and asks `Install the design hook? (Y/n) `, storing consent in `.impeccable/config.local.json`. +- **install flags**: `--force`, `-y|--yes`, `--no-hooks`, `--providers=`, scope: `--user|--home|--global` → user; `--project|--local` → project; `--scope=`/`--install-scope=` normalized (`u|user|home|global`→user, `p|project|local|repo`→project; unknown → error `Unknown install scope: ${v}. Use --scope=project or --scope=global.`). Project root = nearest ancestor with `.git`, else cwd. Detection: project harness dirs present in root, plus `GLOBAL_HARNESS_HINTS` under home (`.agent`, `.gemini/antigravity*`→`.agent`, `.claude`, `.codex`→`.agents`, `.cursor`, `.dsh` (resolved `$DSH_HOME`, fallback `~/.dsh`), `.gemini`, `.grok`, `.hermes`, `.kiro`, `.opencode`, opencode config dir, `.pi`, `.qoder`, `.rovodev`, `.vibe`). Targets: explicit list wins (invalid names → `Unknown provider(s): ...`); `-y` → detected project providers, else detected user providers, else DEFAULT_TARGETS; interactive → prints "Detected harnesses:" table then radio/checkbox prompts (raw-mode TTY) or line prompts (`Install target: [1] Detected only (...) [2] Customize [1]: `, `Select harnesses (comma-separated: ...)`). Scope: explicit; `-y` → project; interactive prompt `Install location` (Project/Global). Hooks: `decideHookInstall` reads `hook.consent` in config(.local).json; declined→false, accepted→true; all targets already have hook markers→true; `-y` or non-TTY→true; else prints HOOK_EXPLAINER and asks `Install the design hook? (Y/n) `, storing consent in `.impeccable/config.local.json`. Bundle: `IMPECCABLE_BUNDLE_PATH` (dir or zip) else download `https://impeccable.style/api/download/bundle/universal` (https `get`, one redirect followed, non-200 → `HTTP ${status}`) to `${tmpdir}/impeccable-update-${Date.now()}.zip`, extracted with `fflate.unzipSync` into `${tmpdir}/impeccable-update-${Date.now()}` (zip-slip guarded: `Refusing to extract entry outside target dir: ${entry}`). Bundle layout `//skills//...`, `//agents/*.md`, hook manifests `/.claude/settings.json`, `.cursor/hooks.json`, `.codex/hooks.json`, `.github/hooks/impeccable.json`, `.grok/hooks/impeccable.json`. Fresh install: `stdout> \nDownloading impeccable skills...`; migrate `*-impeccable` prefixed dirs → `impeccable`; copy each skill dir (rm existing dest first; drop an in-project cross-provider symlinked skills dir); copy agents (`.github`→`.github/agents` or `~/.copilot/agents`; `.cursor`→`.cursor/agents` or `~/.cursor/agents`); write hooks (dest: `.claude/settings.local.json` [skips if `.claude/settings.json` already carries the marker and prunes the local copy], `.cursor/hooks.json`, `.codex/hooks.json` for `.agents`, `.github/hooks/impeccable.json`, `.grok/hooks/impeccable.json`); merged with existing JSON (`mergeHookManifests`: strips existing impeccable entries by markers `skills/impeccable/scripts/hook-probe.mjs|hook.mjs|hook-before-edit.mjs|hook-after-edit.mjs|hook-stop.mjs`, then appends fresh); invalid existing JSON → error `Existing hook manifest is not valid JSON: ${dest}. Re-run with --force to replace it.` (with `--force`, `.bak` written). Hook command rewriting: `[ ! -f '' ] || node ''` (POSIX, single-quote-escaped) when absolute (user scope or global skill), else `[ ! -f "" ] || node ""` where rel = `${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs` (Claude), `.agents/skills/impeccable/scripts/hook.mjs` (Codex; plus `commandWindows: if exist "

" (node "

" & exit /b)`), `.cursor/skills/impeccable/scripts/hook-before-edit.mjs`; on win32 for non-Codex: `node -e "" ""`. Output: `Installed impeccable into: ${targets.join(', ')} (${'global'|'project'})`, optional `Installed agents into: ` (+ shadow warning), `Installed hooks into: ...`, then `\nDone! Now type /impeccable init in your AI coding agent's chat (not in this terminal) to set up design context.\n`. Errors: `Download failed: ...` / `Install failed: ...` / `Nothing was installed: the bundle had no variants for ...` → exit 1. Already installed (and not `--force`): `Impeccable skills are already installed (found in ${provider}/).`; compares tree hashes (`sha256` of file content with `\.(claude|cursor|...)\/skills\/` normalized to `.PROVIDER/skills/`); if differs → refresh + `Updated ${n} skill(s) to v${v}.`; missing hooks repaired; else `Skills are up to date (v${v}).` + `Run with --force to reinstall.`; offline → `Could not check for skill updates: ${msg}` + `Existing skills were left unchanged.`; ends `Done!` or the above; `exit 0`. Version read from `^version:\s*(.+)$` in installed `impeccable/SKILL.md`. diff --git a/docs/DEVELOP.md b/docs/DEVELOP.md index f7b37b2cd..755196120 100644 --- a/docs/DEVELOP.md +++ b/docs/DEVELOP.md @@ -162,6 +162,7 @@ The skill-behavior suite runs three providers (claude-haiku-4-5, gpt-5.4-mini, g - [HARNESSES.md](HARNESSES.md) - Provider capabilities matrix - [Cursor Skills](https://cursor.com/docs/context/skills) - [Claude Code Skills](https://code.claude.com/docs/en/skills) +- [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) - [Gemini CLI Skills](https://geminicli.com/docs/cli/skills/) - [Codex CLI Skills](https://developers.openai.com/codex/skills/) - [VS Code Copilot Skills](https://code.visualstudio.com/docs/copilot/customization/agent-skills) diff --git a/docs/HARNESSES.md b/docs/HARNESSES.md index 1f14fc8bd..83817abed 100644 --- a/docs/HARNESSES.md +++ b/docs/HARNESSES.md @@ -3,7 +3,7 @@ Source of truth for what each AI coding harness supports in terms of agent skills. Used to inform provider configs in `scripts/lib/transformers/providers.js`. -Last verified: 2026-04-28 (subagent landscape spot-checked 2026-06-28; Mistral Vibe row verified 2026-07-16; Grok Build skills row verified 2026-07-21; Grok Build hook stdin captured 2026-08-24) +Last verified: 2026-04-28 (subagent landscape spot-checked 2026-06-28; Mistral Vibe row verified 2026-07-16; Grok Build skills row verified 2026-07-21; Grok Build hook stdin captured 2026-08-24; DeepSeek Harness row verified 2026-09-06) > This file is point-in-time. Capabilities move fast; verify live before relying > on any "only X supports Y" claim. Notably, the subagent table below lists @@ -15,6 +15,7 @@ Last verified: 2026-04-28 (subagent landscape spot-checked 2026-06-28; Mistral V |---------|----------| | Claude Code | https://code.claude.com/docs/en/skills | | Cursor | https://cursor.com/docs/context/skills | +| DeepSeek Harness | https://github.com/deepseek-ai/deepseek-harness | | Gemini CLI | https://geminicli.com/docs/cli/skills/ | | Codex CLI | https://developers.openai.com/codex/skills | | GitHub Copilot (Agents) | https://code.visualstudio.com/docs/copilot/customization/agent-skills | @@ -39,22 +40,22 @@ Provider-specific extensions beyond the spec: `user-invocable`, `argument-hint`, Fields marked with * are spec-standard. Others are provider extensions. -| Field | Claude Code | Cursor | Gemini | Codex | Copilot | Grok | Hermes | Kiro | OpenCode | Pi | Qoder | Rovo Dev | Mistral Vibe | Antigravity | -|-------|:-----------:|:------:|:------:|:-----:|:-------:|:----:|:------:|:----:|:--------:|:--:|:-----:|:--------:|:------------:|:-----------:| -| `name`* | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| `description`* | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| `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 | 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 | 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 | No | No | No | No | No | No | -| `hooks` | Yes | No | No | Yes | No | Yes | No | No | No | No | No | No | No | No | +| Field | Claude Code | Cursor | Gemini | Codex | Copilot | Grok | Hermes | Kiro | OpenCode | Pi | Qoder | Rovo Dev | Mistral Vibe | Antigravity | DSH | +|-------|:-----------:|:------:|:------:|:-----:|:-------:|:----:|:------:|:----:|:--------:|:--:|:-----:|:--------:|:------------:|:-----------:|:------:| +| `name`* | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | +| `description`* | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | +| `license`* | Yes | Yes | Ignored | No | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Ignored | +| `compatibility`* | Yes | Yes | Ignored | No | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Ignored | +| `metadata`* | Yes | Yes | Ignored | No | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | +| `allowed-tools`* | Yes | No | Ignored | No | No | Yes | No | No | No | Yes | Yes | Yes | Yes | Yes | No | +| `user-invocable` | Yes | No | No | No | Yes | Yes | No | No | No | No | Yes | Yes | Yes | No | Yes | +| `argument-hint` | Yes | No | No | No | Yes | Yes | No | No | No | No | Yes | Yes | No | No | No | +| `disable-model-invocation` | Yes | Yes | No | No | Yes | Yes | No | No | Yes | Yes | TBD | TBD | No | No | Yes | +| `model` | Yes | No | No | No | No | Yes | No | No | No | No | No | No | No | No | No | +| `effort` | Yes | No | No | No | No | Yes | No | No | No | No | No | No | No | No | No | +| `context` | Yes | No | No | No | No | No | No | No | No | No | No | No | No | No | No | +| `agent` | Yes | No | 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 | No | Notes: - Gemini CLI validates only `name` and `description`; other spec fields are parsed but ignored. @@ -65,6 +66,7 @@ Notes: - Kiro recognizes `user-invocable` and `disable-model-invocation` per community reports but does not formally document them. - Antigravity supports standard Agent Skills spec frontmatter fields (`name`, `description`, `license`, `compatibility`, `metadata`, `allowed-tools`). - OpenCode 1.18.10 recognises only the spec subset on SKILL.md (`name`, `description`, `license`, `compatibility`, `metadata`). Claude-style extensions (`user-invocable`, `argument-hint`, `allowed-tools`, `model`, `agent`) are silently ignored; Impeccable still emits them today for other harnesses, but they have no effect in OpenCode. Use `commands/.md` (see Placeholder / Variable Substitution below) for slash UX; OpenCode honours only `description`, `agent`, `model`, `variant`, `subtask` on command files. +- DeepSeek Harness parses the Agent Skills frontmatter and requires `name` and `description`; it reads `metadata`, `user-invocable`, and `disable-model-invocation`. Spec fields it does not consume (`license`, `compatibility`, `allowed-tools`) and Claude-style extensions (`argument-hint`, `model`, `effort`, `context`, `agent`, `hooks`) are silently ignored. Hooks are in-process plugins configured via cordis.yml, not on-disk manifests, so there is no hook surface to install. Subagents exist but are composed from preset config, not an on-disk skill-adjacent format. Verified against the [filesystem skill provider](https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/skill/skill-filesystem/README.md). - Unknown fields are silently ignored by all harnesses. ## Hook surface used by Impeccable @@ -83,6 +85,7 @@ Notes: |---------|-----------------|------------| | Claude Code | `.claude/skills/` | - | | Cursor | `.cursor/skills/` | `.agents/skills/`, `.claude/skills/` | +| DeepSeek Harness | `.dsh/skills/` (project), `~/.dsh/skills/` (global; `$DSH_HOME/skills` when set) | `.agents/skills/` (project), `~/.agents/skills/` (global) | | Gemini CLI | `.gemini/skills/` | `.agents/skills/` | | Codex CLI | `.agents/skills/` (primary) | - | | GitHub Copilot | `.github/skills/` | `.agents/skills/`, `.claude/skills/` | diff --git a/scripts/build.js b/scripts/build.js index 34eeb9f8b..75444c75a 100644 --- a/scripts/build.js +++ b/scripts/build.js @@ -641,6 +641,7 @@ This folder contains skills for all supported tools: .cursor/ -> Cursor .claude/ -> Claude Code + .dsh/ -> DeepSeek Harness .gemini/ -> Gemini CLI .codex/ -> Codex custom agents (Codex skills use .agents/) .agents/ -> Codex CLI diff --git a/scripts/lib/transformers/providers.js b/scripts/lib/transformers/providers.js index 5a72fb36b..37b960892 100644 --- a/scripts/lib/transformers/providers.js +++ b/scripts/lib/transformers/providers.js @@ -44,6 +44,20 @@ export const PROVIDERS = { displayName: 'Gemini', frontmatterFields: [], }, + dsh: { + provider: 'dsh', + providerTags: ['dsh'], + configDir: '.dsh', + displayName: 'DeepSeek Harness', + // DeepSeek Harness reads the Agent Skills spec subset (`name`, + // `description`, `license`, `compatibility`, `metadata`) plus + // `user-invocable` and `disable-model-invocation`; unknown keys are + // silently ignored. No hook surface (hooks are in-process plugins, not + // on-disk manifests) and no native subagent file format, so no + // emitHooks / agentFormat. Global skills live at ~/.dsh/skills + // ($DSH_HOME/skills when set), matching the engine's home override. + frontmatterFields: ['user-invocable', 'license', 'compatibility', 'metadata'], + }, codex: { provider: 'codex', providerTags: ['codex'], diff --git a/scripts/lib/utils.js b/scripts/lib/utils.js index 2efd2aaba..ebf928f38 100644 --- a/scripts/lib/utils.js +++ b/scripts/lib/utils.js @@ -424,6 +424,12 @@ export const PROVIDER_PLACEHOLDERS = { ask_instruction: 'Ask the user directly to clarify what you cannot infer.', command_prefix: '/' }, + 'dsh': { + model: 'DeepSeek', + config_file: 'AGENTS.md', + ask_instruction: 'STOP and call the ask_user_question tool to clarify.', + command_prefix: '/' + }, 'gemini': { model: 'Gemini', config_file: 'GEMINI.md', @@ -524,6 +530,7 @@ export const PROVIDER_BLOCK_TAGS = new Set([ 'claude-code', 'codex', 'cursor', + 'dsh', 'gemini', 'github', 'grok', diff --git a/tests/lib/transformers/providers.test.js b/tests/lib/transformers/providers.test.js index a9b2bd991..91ea6faa8 100644 --- a/tests/lib/transformers/providers.test.js +++ b/tests/lib/transformers/providers.test.js @@ -65,6 +65,29 @@ for (const [key, config] of Object.entries(PROVIDERS)) { expect(fs.existsSync(refPath)).toBe(true); }); + if (key === 'dsh') { + test('uses DSH tools and resource paths without installing unsupported hooks or agents', () => { + transform([{ + name: 'impeccable', + description: 'Test', + userInvocable: true, + allowedTools: 'Bash', + body: '{{ask_instruction}} Run `{{scripts_path}}/impeccable context`.', + references: [{ name: 'polish', content: 'Run `{{scripts_path}}/impeccable detect`.', filePath: '/fake/polish.md' }], + }], TEST_DIR); + const content = fs.readFileSync(skillPath(config, 'impeccable'), 'utf8'); + expect(content).toContain('call the ask_user_question tool'); + expect(content).toContain('.dsh/skills/impeccable/scripts/impeccable context'); + expect(parseFrontmatter(content).frontmatter['allowed-tools']).toBeUndefined(); + const root = path.join(TEST_DIR, 'dsh', '.dsh'); + expect(fs.readFileSync(path.join(root, 'skills/impeccable/reference/polish.md'), 'utf8')) + .toContain('.dsh/skills/impeccable/scripts/impeccable detect'); + expect(fs.readdirSync(root)).toEqual(['skills']); + expect(config.emitHooks).toBeUndefined(); + expect(config.agentFormat).toBeUndefined(); + }); + } + test('should emit skillsVersion in generated skill frontmatter', () => { const skills = [{ name: 'test', description: 'Test', body: 'Body' }]; transform(skills, TEST_DIR, { skillsVersion: '1.2.3-test' });