diff --git a/docs/agents/zcode.md b/docs/agents/zcode.md index ced551e428..f24f61758a 100644 --- a/docs/agents/zcode.md +++ b/docs/agents/zcode.md @@ -23,6 +23,46 @@ Images and embedded resources follow the bridge's negotiated ACP capabilities. P The bridge accepts up to 100 content blocks and 50 MiB of decoded content per prompt, with a 10 MiB limit per image. Image attachments must be PNG, JPEG, WebP, GIF, BMP or SVG. Other image formats, invalid base64 and oversized prompts return an error. Pause any native goal loop before sending binary attachments; queued goal prompts accept only text. +## Skills + +Codeg's Skills settings can list, read, create, edit, delete and import ZCode +directory bundles (`/SKILL.md`). ZCode also appears in the custom, expert, +science, Office and UI Render skill installation matrices. + +Codeg uses the folders the official ZCode runtime loads skills from. Global +skills live in `~/.zcode/skills/` and then the shared `~/.agents/skills/`, both +in your home directory. New global skills and Codeg installation links go into +`~/.zcode/skills/`, which keeps them separate from the shared store. Flat `.md` +files are not ZCode skill bundles. + +Project skills are created in `/.zcode/skills/`, where ZCode's +own settings create them. Codeg also lists and edits the other project skills +ZCode loads: those in `/.agents/skills/` and, when the session cwd +is inside a Git repository, those in both folders of every parent folder up to +the repository root. A skill from a parent folder is edited where it is. When +several of these folders contain the same skill folder, Codeg lists and edits +only the first: the one nearest the session cwd, and within one folder the +`.zcode` copy. ZCode ranks these folders in the same order, but it picks a skill +by the `name` in its `SKILL.md`, and prefers a global skill with the same name. +Of these project folders, the bridge's command list includes only +`/.agents/skills/`; ZCode loads skills from all of them. + +`ZCODE_HOME` does not move these folders. The runtime's global skill folders +always hang off the home directory, and its project folders off the session +cwd. The bridge's command list reads `$ZCODE_HOME/skills/` instead of +`~/.zcode/skills/`. With `ZCODE_HOME` pointing elsewhere, it can therefore +offer commands for skills the runtime cannot load, and leave out skills Codeg +installed. Extra folders added with ZCode's `skills.roots` setting are not +listed. Codeg finds the home directory the way ZCode's Node runtime does, from +`HOME`, or `USERPROFILE` on Windows, in Codeg's own process environment. It does +not read the agent's environment overrides. + +Codeg manages user-owned files and installation links; the bridge continues to +decide which skills appear as commands. Native per-path disable flags in +`cli/config.json` still apply. Codeg does not rewrite those flags or edit +plugin caches. Reconnect the conversation after changing skills so the bridge +publishes its current commands. + ## Sessions and tools A new session explicitly confirms Build mode before sending its first prompt. A valid saved mode, including Yolo, takes precedence. Confirming the mode makes the bridge create the ZCode backend session when the conversation connects rather than at its first prompt. Existing sessions use the normal restore and preference flow. Model and thought selectors follow ACP configuration updates rather than a hardcoded model list. diff --git a/src-tauri/src/commands/acp.rs b/src-tauri/src/commands/acp.rs index 6264fe34e0..7f4ea9c0c8 100644 --- a/src-tauri/src/commands/acp.rs +++ b/src-tauri/src/commands/acp.rs @@ -6246,6 +6246,26 @@ fn account_home_dir() -> Option { dirs::home_dir() } +/// What `os.homedir()` answers in a Node child that inherits codeg's own +/// environment: `HOME` (`USERPROFILE` on Windows) when it is set, otherwise the +/// account's home. `dirs::home_dir()` gives the same answer on unix, but on +/// Windows it asks the OS for the profile folder and never reads `USERPROFILE`, +/// so a codeg started with `USERPROFILE` pointing elsewhere would name another +/// home than its Node children use. +fn inherited_node_home_dir() -> PathBuf { + node_home_from(std::env::var_os(CHILD_HOME_KEY)) +} + +/// [`inherited_node_home_dir`] with codeg's home variable handed in. An empty +/// value counts as unset: it names no folder codeg could manage. +fn node_home_from(variable: Option) -> PathBuf { + variable + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .or_else(account_home_dir) + .unwrap_or_else(home_dir_or_default) +} + /// Read the launch's agent-directory `enabledModels` preference. Missing, /// malformed or empty settings leave the model selector unrestricted. /// @@ -9515,6 +9535,48 @@ pub(crate) fn skill_storage_spec(agent_type: AgentType) -> Option/.zcode/skills` then `/.agents/skills`, and for + // every directory from the cwd up to the Git worktree root, + // `/.zcode/skills` then `/.agents/skills`, plus enabled + // plugin caches, which stay ZCode's. Bundles only (`/SKILL.md`). + // [`project_skill_bases`] walks the same project folders. + // + // The OS home is what Node's `os.homedir()` answers, which on Windows + // is `USERPROFILE`, not the profile folder `dirs::home_dir()` asks the + // OS for ([`inherited_node_home_dir`]). It is never `ZCODE_HOME`: no + // runtime caller passes a home directory to that function, and the + // bridge hands `ZCODE_HOME` to the runtime only as its data root + // (`ZCODE_DATA_BASE_DIR`, and only when the path ends in `.zcode`), + // which the skill roots do not use. The bridge's command list reads + // `$ZCODE_HOME/skills` instead, so with `ZCODE_HOME` pointing anywhere + // but `~/.zcode`, a skill installed there would be offered as a + // command that none of the runtime's default roots contains. + // + // `.zcode/skills` leads both pairs. It is where ZCode's own settings + // create skills, global and project alike; the runtime prefers it over + // `.agents/skills` in the same folder, so with a copy in each, the one + // listed and edited here is the one ZCode uses; and installing there + // leaves the shared store, which other agents read, alone. Of the + // project folders, the bridge's command list reads only the cwd's + // `.agents/skills`, so project skills created here are not offered as + // commands, though the runtime loads them. Codeg runs the agent in the + // session cwd, which is what both the bridge and the runtime scan from. + AgentType::ZCode => { + let home = inherited_node_home_dir(); + Some(SkillStorageSpec { + kind: SkillStorageKind::SkillDirectoryOnly, + global_dirs: vec![ + home.join(".zcode").join("skills"), + home.join(".agents").join("skills"), + ], + project_rel_dirs: vec![".zcode/skills", ".agents/skills"], + }) + } // codeg cannot detect where an arbitrary ACP agent loads skills from, // so custom agents are gated on the user's own declaration: that the // agent reads the shared `.agents/skills` store (the cross-agent @@ -9528,7 +9590,6 @@ pub(crate) fn skill_storage_spec(agent_type: AgentType) -> Option None, AgentType::Custom(id) => { let decl = crate::acp::custom_registry::skills_decl(id); let mut global_dirs = Vec::new(); @@ -9612,49 +9673,81 @@ pub(crate) fn scoped_skill_dirs( .ok_or_else(|| { AcpError::protocol("workspace_path is required for project scoped skills") })?; - let base = project_skill_base(agent_type, workspace); - Ok(spec - .project_rel_dirs - .iter() - .map(|relative| base.join(relative)) - .collect()) + Ok(project_skill_dirs(agent_type, &spec, workspace)) } } } -/// The directory an agent's PROJECT-relative skill dirs hang off. +/// Every directory an agent's PROJECT-scoped skills are read from, in the +/// order it reads them: its `project_rel_dirs`, in their declared order, under +/// each base [`project_skill_bases`] names, nearest base first. The first one +/// is where a new project skill goes. +fn project_skill_dirs( + agent_type: AgentType, + spec: &SkillStorageSpec, + workspace: &str, +) -> Vec { + project_skill_bases(agent_type, workspace) + .iter() + .flat_map(|base| { + spec.project_rel_dirs + .iter() + .map(move |relative| base.join(relative)) + }) + .collect() +} + +/// The directories an agent's PROJECT-relative skill dirs hang off, nearest +/// first. /// -/// Normally the workspace itself. DeepSeek and Kimi Code are the exceptions: -/// both walk up from the session cwd to the nearest ancestor containing `.git` -/// before joining their project-relative skill dirs, falling back to the cwd -/// when they reach the filesystem root — DeepSeek in `dsh-skill-filesystem`'s -/// `findProjectRoot`, Kimi in `features/skill/catalog/skillRoots.ts`'s -/// `projectRoots` → `findUpwardRoot(workDir, ".git", exists)`. Opening a -/// subdirectory of a repo as the workspace would otherwise make codeg create -/// and list `/.dsh/skills` / `/.kimi-code/skills` — a directory -/// the agent never scans, so the skill would simply never load, with nothing on -/// screen saying so. +/// Normally just the workspace itself. DeepSeek and Kimi Code use one other +/// directory instead: both walk up from the session cwd to the nearest ancestor +/// containing `.git` before joining their project-relative skill dirs, falling +/// back to the cwd when they reach the filesystem root — DeepSeek in +/// `dsh-skill-filesystem`'s `findProjectRoot`, Kimi in +/// `features/skill/catalog/skillRoots.ts`'s `projectRoots` → +/// `findUpwardRoot(workDir, ".git", exists)`. Opening a subdirectory of a repo +/// as the workspace would otherwise make codeg create and list +/// `/.dsh/skills` / `/.kimi-code/skills` — a directory the agent +/// never scans, so the skill would simply never load, with nothing on screen +/// saying so. /// /// Kimi's half was confirmed live: `kimi acp` launched with `cwd` at /// `/sub` advertised the skills under `/.kimi-code/skills` and /// `/.agents/skills` and ignored the ones under `/sub/...`. /// +/// ZCode scans every directory on that walk: `resolveProjectSkillDirectories` +/// in its runtime's `skills/roots.ts` returns each one from the cwd up to the +/// nearest ancestor containing `.git`, the cwd first, and the cwd alone when no +/// ancestor has one. A skill it loads from a parent folder is then listed and +/// edited where it is, and the copy listed for an id is the one the runtime +/// ranks first among its project folders. It loads a skill by its declared +/// `name`, though, and ranks user skills above project ones. +/// /// `.git` is matched as a plain path, file or directory: in a linked worktree -/// (which codeg creates routinely) it is a FILE, and both upstreams' existence -/// probes (`pathExists` / `stat`) accept that too. -fn project_skill_base(agent_type: AgentType, workspace: &str) -> PathBuf { +/// (which codeg creates routinely) it is a FILE, and all three upstreams' +/// existence probes (`pathExists` / `stat`) accept that too. +fn project_skill_bases(agent_type: AgentType, workspace: &str) -> Vec { let workspace = PathBuf::from(workspace); - if !matches!(agent_type, AgentType::DeepSeek | AgentType::KimiCode) { - return workspace; - } + let every_level = match agent_type { + AgentType::DeepSeek | AgentType::KimiCode => false, + AgentType::ZCode => true, + _ => return vec![workspace], + }; + let mut walked = Vec::new(); let mut current = workspace.as_path(); loop { + walked.push(current.to_path_buf()); if current.join(".git").exists() { - return current.to_path_buf(); + return if every_level { + walked + } else { + vec![current.to_path_buf()] + }; } match current.parent() { Some(parent) if parent != current => current = parent, - _ => return workspace.clone(), + _ => return vec![workspace.clone()], } } } @@ -14470,14 +14563,13 @@ pub async fn acp_list_agent_skills( if let Some(workspace) = workspace_path.as_deref().map(str::trim) { if !workspace.is_empty() { - // Same base the WRITE path resolves through `scoped_skill_dirs` — - // for DeepSeek and Kimi Code that is the repo root, not the - // workspace. Joining onto the workspace here instead would make a - // skill saved from a nested workspace vanish from the list that is - // meant to show it. - let base = project_skill_base(agent_type, workspace); - for relative in &spec.project_rel_dirs { - let project_dir = base.join(relative); + // The same directories, in the same order, that the WRITE path + // resolves through `scoped_skill_dirs` — for DeepSeek and Kimi Code + // under the repo root, not the workspace, and for ZCode under every + // folder up to it. Listing the workspace's own dirs instead would + // make a skill saved from a nested workspace vanish from the list + // that is meant to show it. + for project_dir in project_skill_dirs(agent_type, &spec, workspace) { locations.push(AgentSkillLocation { scope: AgentSkillScope::Project, path: project_dir.to_string_lossy().to_string(), @@ -14933,6 +15025,464 @@ pub(crate) async fn codex_poll_device_code_core( }) } +#[cfg(test)] +mod zcode_skills_tests { + use super::*; + + fn write_skill(root: &Path, id: &str, body: &str) { + fs::create_dir_all(root.join(id)).unwrap(); + fs::write(root.join(id).join("SKILL.md"), body).unwrap(); + } + + /// The ZCode runtime reads user skills from the home Node's `os.homedir()` + /// names, `HOME` or on Windows `USERPROFILE`, and ignores `ZCODE_HOME`, so + /// the managed stores follow that variable and not `ZCODE_HOME`. On + /// Windows this also proves `USERPROFILE` wins over the profile folder + /// `dirs::home_dir()` would have named. + #[test] + fn zcode_skill_roots_hang_off_the_os_home_not_zcode_home() { + let tmp = tempfile::tempdir().unwrap(); + let (home, relocated) = (tmp.path().join("home"), tmp.path().join("relocated")); + temp_env::with_vars( + [ + (CHILD_HOME_KEY, Some(&home)), + ("ZCODE_HOME", Some(&relocated)), + ], + || { + let spec = skill_storage_spec(AgentType::ZCode).expect("ZCode declares a store"); + assert_eq!(spec.kind, SkillStorageKind::SkillDirectoryOnly); + assert_eq!( + spec.global_dirs, + vec![ + home.join(".zcode").join("skills"), + home.join(".agents").join("skills"), + ] + ); + assert_eq!( + spec.project_rel_dirs, + vec![".zcode/skills", ".agents/skills"] + ); + }, + ); + } + + /// Node falls back to the account's home only when the variable is unset; + /// an empty one is treated the same, since it names no folder to manage. + #[test] + fn zcode_home_is_the_home_variable_or_the_account_home() { + let account = account_home_dir().unwrap_or_else(home_dir_or_default); + assert_eq!(node_home_from(None), account); + assert_eq!(node_home_from(Some("".into())), account); + let pinned = std::env::temp_dir().join("codeg-zcode-home"); + assert_eq!(node_home_from(Some(pinned.clone().into())), pinned); + } + + #[tokio::test] + async fn zcode_skills_manage_directory_bundles_in_native_then_shared_order() { + let tmp = tempfile::tempdir().unwrap(); + let native = tmp.path().join(".zcode"); + let relocated = tmp.path().join("relocated"); + temp_env::async_with_vars( + [ + (CHILD_HOME_KEY, Some(tmp.path())), + ("ZCODE_HOME", Some(relocated.as_path())), + ], + async { + let shared = tmp.path().join(".agents/skills"); + let native_skills = native.join("skills"); + write_skill(&native_skills, "duplicate", "native"); + write_skill(&shared, "duplicate", "shared"); + write_skill(&shared, "shared-only", "shared only"); + write_skill(&relocated.join("skills"), "relocated-only", "menu only"); + fs::write(native_skills.join("flat.md"), "not a directory bundle").unwrap(); + fs::create_dir_all(native.join("cli")).unwrap(); + let config = r#"{"skills":{"disabled":{"enable":false}},"other":"preserve"}"#; + fs::write(native.join("cli/config.json"), config).unwrap(); + let plugin = native.join("cli/plugins/cache/market/plugin/1.0.0/skills"); + write_skill(&plugin, "plugin-owned", "plugin bytes"); + + let listed = acp_list_agent_skills(AgentType::ZCode, None).await.unwrap(); + assert!(listed.supported); + assert_eq!(listed.locations[0].path, native_skills.to_string_lossy()); + let mut ids: Vec<_> = listed.skills.iter().map(|s| s.id.as_str()).collect(); + ids.sort_unstable(); + assert_eq!( + ids, + vec!["duplicate", "shared-only"], + "flat files, plugin caches and $ZCODE_HOME/skills stay out" + ); + let duplicate = listed.skills.iter().find(|s| s.id == "duplicate").unwrap(); + assert_eq!(Path::new(&duplicate.path), native_skills.join("duplicate")); + + let saved = acp_save_agent_skill( + AgentType::ZCode, + AgentSkillScope::Global, + "new-skill".into(), + "---\nname: new-skill\ndescription: Test\n---\nbody".into(), + None, + Some(AgentSkillLayout::MarkdownFile), + ) + .await + .unwrap(); + assert_eq!(saved.layout, AgentSkillLayout::SkillDirectory); + assert_eq!(Path::new(&saved.path), native_skills.join("new-skill")); + assert!(native_skills.join("new-skill/SKILL.md").is_file()); + assert!( + !shared.join("new-skill").exists(), + "installs use the native store" + ); + assert!( + !relocated.join("skills/new-skill").exists(), + "installs never follow ZCODE_HOME" + ); + let read = acp_read_agent_skill( + AgentType::ZCode, + AgentSkillScope::Global, + "new-skill".into(), + None, + ) + .await + .unwrap(); + assert!(read.content.ends_with("body")); + acp_save_agent_skill( + AgentType::ZCode, + AgentSkillScope::Global, + "new-skill".into(), + "edited".into(), + None, + None, + ) + .await + .unwrap(); + assert_eq!( + fs::read_to_string(native_skills.join("new-skill/SKILL.md")).unwrap(), + "edited" + ); + acp_delete_agent_skill( + AgentType::ZCode, + AgentSkillScope::Global, + "new-skill".into(), + None, + ) + .await + .unwrap(); + assert!(!native_skills.join("new-skill").exists()); + assert_eq!( + fs::read_to_string(shared.join("duplicate/SKILL.md")).unwrap(), + "shared" + ); + assert_eq!( + fs::read_to_string(native.join("cli/config.json")).unwrap(), + config + ); + assert_eq!( + fs::read_to_string(plugin.join("plugin-owned/SKILL.md")).unwrap(), + "plugin bytes" + ); + }, + ) + .await; + } + + #[tokio::test] + async fn zcode_native_skill_crud_does_not_rewrite_cli_policy() { + let tmp = tempfile::tempdir().unwrap(); + let native = tmp.path().join(".zcode"); + fs::create_dir_all(native.join("cli")).unwrap(); + let config = r#"{"skills":{"keep-disabled":{"enable":false}}}"#; + fs::write(native.join("cli/config.json"), config).unwrap(); + temp_env::async_with_vars([(CHILD_HOME_KEY, Some(tmp.path()))], async { + let id = "codeg-zcode-crud-fixture"; + // An existing bundle is saved in place, whatever layout is asked for. + write_skill(&native.join("skills"), id, "before"); + let skill = acp_save_agent_skill( + AgentType::ZCode, + AgentSkillScope::Global, + id.into(), + "body".into(), + None, + Some(AgentSkillLayout::MarkdownFile), + ) + .await + .unwrap(); + assert_eq!(skill.layout, AgentSkillLayout::SkillDirectory); + assert_eq!(Path::new(&skill.path), native.join("skills").join(id)); + let listed = acp_list_agent_skills(AgentType::ZCode, None).await.unwrap(); + assert!(listed.skills.iter().any(|s| s.id == id)); + let content = + acp_read_agent_skill(AgentType::ZCode, AgentSkillScope::Global, id.into(), None) + .await + .unwrap(); + assert_eq!(content.content, "body"); + acp_save_agent_skill( + AgentType::ZCode, + AgentSkillScope::Global, + id.into(), + "edited".into(), + None, + None, + ) + .await + .unwrap(); + assert_eq!( + fs::read_to_string(native.join("skills").join(id).join("SKILL.md")).unwrap(), + "edited" + ); + acp_delete_agent_skill(AgentType::ZCode, AgentSkillScope::Global, id.into(), None) + .await + .unwrap(); + assert!(!native.join("skills").join(id).exists()); + assert_eq!( + fs::read_to_string(native.join("cli/config.json")).unwrap(), + config + ); + }) + .await; + } + + /// Project skills are created in the session cwd's `.zcode/skills`, never + /// at the Git root. A skill only in `.agents/skills` is edited where it is, + /// and with a copy in each folder the `.zcode` one, which the runtime + /// loads, is the one listed and edited. + #[tokio::test] + async fn zcode_project_skills_live_in_the_session_cwd_with_the_runtimes_precedence() { + let tmp = tempfile::tempdir().unwrap(); + let workspace = tmp.path().join("repo/nested"); + let native = workspace.join(".zcode/skills"); + let shared = workspace.join(".agents/skills"); + fs::create_dir_all(&workspace).unwrap(); + fs::create_dir_all(tmp.path().join("repo/.git")).unwrap(); + write_skill(&shared, "shared-only", "shared"); + write_skill(&native, "both", "native copy"); + write_skill(&shared, "both", "shared copy"); + temp_env::async_with_vars([(CHILD_HOME_KEY, Some(tmp.path().join("home")))], async { + let workspace_arg = Some(workspace.to_string_lossy().into_owned()); + let save = |id: &'static str, body: &'static str| { + acp_save_agent_skill( + AgentType::ZCode, + AgentSkillScope::Project, + id.into(), + body.into(), + workspace_arg.clone(), + None, + ) + }; + let saved = save("project-test", "project").await.unwrap(); + assert_eq!(Path::new(&saved.path), native.join("project-test")); + assert!(!tmp.path().join("repo/.zcode/skills/project-test").exists()); + assert!(!shared.join("project-test").exists()); + + let listed = acp_list_agent_skills(AgentType::ZCode, workspace_arg.clone()) + .await + .unwrap(); + let project_path = |id: &str| { + listed + .skills + .iter() + .find(|s| s.scope == AgentSkillScope::Project && s.id == id) + .map(|s| PathBuf::from(&s.path)) + }; + assert_eq!( + project_path("project-test"), + Some(native.join("project-test")) + ); + assert_eq!( + project_path("shared-only"), + Some(shared.join("shared-only")) + ); + assert_eq!(project_path("both"), Some(native.join("both"))); + + save("shared-only", "edited shared").await.unwrap(); + assert_eq!( + fs::read_to_string(shared.join("shared-only/SKILL.md")).unwrap(), + "edited shared" + ); + assert!(!native.join("shared-only").exists()); + save("both", "edited native").await.unwrap(); + assert_eq!( + fs::read_to_string(native.join("both/SKILL.md")).unwrap(), + "edited native" + ); + assert_eq!( + fs::read_to_string(shared.join("both/SKILL.md")).unwrap(), + "shared copy" + ); + + assert!(acp_save_agent_skill( + AgentType::ZCode, + AgentSkillScope::Project, + "bad".into(), + "body".into(), + None, + None, + ) + .await + .is_err()); + assert!(acp_save_agent_skill( + AgentType::ZCode, + AgentSkillScope::Global, + "../escape".into(), + "body".into(), + None, + None, + ) + .await + .is_err()); + acp_delete_agent_skill( + AgentType::ZCode, + AgentSkillScope::Project, + "project-test".into(), + workspace_arg, + ) + .await + .unwrap(); + assert!(!native.join("project-test").exists()); + }) + .await; + } + + /// The runtime scans `.zcode/skills` then `.agents/skills` in every folder + /// from the cwd up to the nearest `.git`, nearest first, and in the cwd + /// alone without one. A parent folder's skill is listed and edited where + /// it is, an id lists its nearest copy, and new skills still go to the cwd. + #[tokio::test] + async fn zcode_project_skills_reach_every_folder_up_to_the_git_root() { + let tmp = tempfile::tempdir().unwrap(); + let repo = tmp.path().join("repo"); + let pkg = repo.join("pkg"); + let workspace = pkg.join("app"); + fs::create_dir_all(&workspace).unwrap(); + // A linked worktree's `.git` is a file, which the runtime's `stat` + // accepts too. + fs::write(repo.join(".git"), "gitdir: /elsewhere\n").unwrap(); + write_skill(&tmp.path().join(".zcode/skills"), "above-root", "outside"); + write_skill(&repo.join(".agents/skills"), "root-shared", "root"); + write_skill(&repo.join(".zcode/skills"), "near", "root copy"); + write_skill(&pkg.join(".zcode/skills"), "level", "pkg native"); + write_skill(&pkg.join(".agents/skills"), "level", "pkg shared"); + write_skill(&pkg.join(".agents/skills"), "near", "pkg copy"); + write_skill(&workspace.join(".agents/skills"), "near", "cwd copy"); + temp_env::async_with_vars([(CHILD_HOME_KEY, Some(tmp.path().join("home")))], async { + let workspace_arg = Some(workspace.to_string_lossy().into_owned()); + let expected: Vec = [&workspace, &pkg, &repo] + .iter() + .flat_map(|base| [base.join(".zcode/skills"), base.join(".agents/skills")]) + .collect(); + let dirs = scoped_skill_dirs( + AgentType::ZCode, + AgentSkillScope::Project, + workspace_arg.as_deref(), + ) + .unwrap(); + assert_eq!(dirs, expected); + + let listed = acp_list_agent_skills(AgentType::ZCode, workspace_arg.clone()) + .await + .unwrap(); + let locations: Vec = listed + .locations + .iter() + .filter(|l| l.scope == AgentSkillScope::Project) + .map(|l| PathBuf::from(&l.path)) + .collect(); + assert_eq!(locations, expected); + let project: BTreeMap<&str, PathBuf> = listed + .skills + .iter() + .filter(|s| s.scope == AgentSkillScope::Project) + .map(|s| (s.id.as_str(), PathBuf::from(&s.path))) + .collect(); + assert_eq!( + project, + BTreeMap::from([ + ("level", pkg.join(".zcode/skills/level")), + ("near", workspace.join(".agents/skills/near")), + ("root-shared", repo.join(".agents/skills/root-shared")), + ]), + "nearest folder first, `.zcode` before `.agents`, nothing above the repo" + ); + + let save = |id: &'static str, body: &'static str| { + acp_save_agent_skill( + AgentType::ZCode, + AgentSkillScope::Project, + id.into(), + body.into(), + workspace_arg.clone(), + None, + ) + }; + save("root-shared", "edited").await.unwrap(); + assert_eq!( + fs::read_to_string(repo.join(".agents/skills/root-shared/SKILL.md")).unwrap(), + "edited" + ); + assert!(!workspace.join(".zcode/skills/root-shared").exists()); + let created = save("fresh", "new").await.unwrap(); + assert_eq!( + Path::new(&created.path), + workspace.join(".zcode/skills/fresh") + ); + + // Deleting the copy in use uncovers the next one, as in the runtime. + acp_delete_agent_skill( + AgentType::ZCode, + AgentSkillScope::Project, + "level".into(), + workspace_arg.clone(), + ) + .await + .unwrap(); + assert!(!pkg.join(".zcode/skills/level").exists()); + let read = acp_read_agent_skill( + AgentType::ZCode, + AgentSkillScope::Project, + "level".into(), + workspace_arg.clone(), + ) + .await + .unwrap(); + assert_eq!( + Path::new(&read.skill.path), + pkg.join(".agents/skills/level") + ); + assert_eq!(read.content, "pkg shared"); + + let bare = tmp.path().join("bare/ws"); + fs::create_dir_all(&bare).unwrap(); + write_skill(&tmp.path().join("bare/.zcode/skills"), "parent", "no repo"); + let dirs = scoped_skill_dirs(AgentType::ZCode, AgentSkillScope::Project, bare.to_str()) + .unwrap(); + assert_eq!( + dirs, + vec![bare.join(".zcode/skills"), bare.join(".agents/skills")], + "without a `.git` above, only the cwd counts" + ); + }) + .await; + } + + #[tokio::test] + async fn zcode_has_authoritative_rows_in_every_bundled_skill_matrix() { + let tmp = tempfile::tempdir().unwrap(); + temp_env::async_with_vars([(CHILD_HOME_KEY, Some(tmp.path()))], async { + let rows = crate::commands::experts::experts_list_all_install_statuses() + .await + .unwrap(); + assert!(rows.iter().any(|r| r.agent_type == AgentType::ZCode)); + let rows = crate::commands::science::science_list_all_install_statuses() + .await + .unwrap(); + assert!(rows.iter().any(|r| r.agent_type == AgentType::ZCode)); + let rows = crate::commands::office_tools::officecli_skill_list_all_install_statuses() + .await + .unwrap(); + assert!(rows.iter().any(|r| r.agent_type == AgentType::ZCode)); + }) + .await; + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/commands/custom_skills.rs b/src-tauri/src/commands/custom_skills.rs index b5fadeda0c..be9aacf649 100644 --- a/src-tauri/src/commands/custom_skills.rs +++ b/src-tauri/src/commands/custom_skills.rs @@ -834,6 +834,30 @@ mod tests { assert!(agents.iter().all(|a| skill_storage_spec(*a).is_some())); } + /// ZCode's runtime loads user skills from `/.zcode/skills` whatever + /// `ZCODE_HOME` says, so a matrix link must land there and not follow it. + /// Its home is the one Node reads, `HOME` or on Windows `USERPROFILE`; + /// both are pinned so the link lands in the fixture on every platform. + #[test] + fn zcode_joins_the_custom_skill_matrix_at_its_os_home_store() { + let tmp = tempfile::tempdir().unwrap(); + let (home, relocated) = (tmp.path().join("home"), tmp.path().join("relocated")); + temp_env::with_vars( + [ + ("HOME", Some(&home)), + ("USERPROFILE", Some(&home)), + ("ZCODE_HOME", Some(&relocated)), + ], + || { + assert!(supported_agents().contains(&AgentType::ZCode)); + assert_eq!( + agent_link_path(AgentType::ZCode, "example").unwrap(), + home.join(".zcode").join("skills").join("example") + ); + }, + ); + } + #[test] fn a_custom_agent_joins_the_matrix_by_declaring_a_store() { use crate::acp::custom_registry::{ @@ -1106,6 +1130,83 @@ mod tests { .await; } + #[cfg(unix)] + #[tokio::test] + async fn fs_zcode_import_and_matrix_links_use_its_os_home_store() { + let tmp = tempfile::tempdir().unwrap(); + // The runtime's user store; a relocated `ZCODE_HOME` must not move it. + let native = tmp.path().join(".zcode"); + let relocated = tmp.path().join("relocated"); + temp_env::async_with_vars( + [ + ("HOME", Some(tmp.path())), + ("ZCODE_HOME", Some(relocated.as_path())), + ], + async { + assert!(central_experts_dir().starts_with(tmp.path())); + let source = native.join("skills/zcode-import-fixture"); + fs::create_dir_all(source.join("references")).unwrap(); + fs::write( + source.join("SKILL.md"), + "---\nname: Original\ndescription: Example\n---\nbody", + ) + .unwrap(); + fs::write(source.join("references/proof.txt"), "asset").unwrap(); + let imported = + custom_import_from_agent(AgentType::ZCode, vec!["zcode-import-fixture".into()]) + .await + .unwrap(); + assert!(imported[0].ok, "{imported:?}"); + assert_eq!( + fs::read_to_string( + central_experts_dir().join("zcode-import-fixture/references/proof.txt") + ) + .unwrap(), + "asset" + ); + + let id = "zcode-managed-fixture"; + custom_create_skill( + id.into(), + "---\nname: Managed\ndescription: Test\n---\nbody".into(), + ) + .await + .unwrap(); + let rows = custom_apply_links(vec![LinkOp { + expert_id: id.into(), + agent_type: AgentType::ZCode, + enable: true, + }]) + .await + .unwrap(); + assert!(rows[0].ok, "{rows:?}"); + assert_eq!( + rows[0].status.as_ref().unwrap().state, + ExpertLinkState::LinkedToCodeg + ); + let link = native.join("skills").join(id); + assert!(path_is_symlink(&link)); + let rows = custom_list_all_install_statuses().await.unwrap(); + assert!(rows.iter().any(|r| r.expert_id == id + && r.agent_type == AgentType::ZCode + && r.state == ExpertLinkState::LinkedToCodeg)); + let rows = custom_apply_links(vec![LinkOp { + expert_id: id.into(), + agent_type: AgentType::ZCode, + enable: false, + }]) + .await + .unwrap(); + assert!(rows[0].ok, "{rows:?}"); + assert!(!link.exists() && !path_is_symlink(&link)); + assert!(central_experts_dir().join(id).join("SKILL.md").is_file()); + assert!(source.join("references/proof.txt").is_file()); + assert!(!relocated.exists(), "nothing is written under ZCODE_HOME"); + }, + ) + .await; + } + /// The generative-UI skill shares the central store but is only Codeg's /// once its marker is there; until then a `ui-render` is the user's. #[cfg(unix)] diff --git a/src-tauri/src/commands/experts.rs b/src-tauri/src/commands/experts.rs index e300544dc7..1190eac7d6 100644 --- a/src-tauri/src/commands/experts.rs +++ b/src-tauri/src/commands/experts.rs @@ -807,6 +807,7 @@ fn supported_agents() -> Vec { AgentType::Qoder, AgentType::Antigravity, AgentType::Kiro, + AgentType::ZCode, AgentType::Trae, AgentType::CommandCode, ]; diff --git a/src-tauri/src/commands/office_tools.rs b/src-tauri/src/commands/office_tools.rs index 02e7274357..a8c329cce9 100644 --- a/src-tauri/src/commands/office_tools.rs +++ b/src-tauri/src/commands/office_tools.rs @@ -1064,6 +1064,7 @@ fn supported_agents() -> Vec { AgentType::Qoder, AgentType::Antigravity, AgentType::Kiro, + AgentType::ZCode, AgentType::Trae, AgentType::CommandCode, ]; diff --git a/src-tauri/src/commands/science.rs b/src-tauri/src/commands/science.rs index 8e82cc7c2a..9a4d8d8d14 100644 --- a/src-tauri/src/commands/science.rs +++ b/src-tauri/src/commands/science.rs @@ -631,6 +631,7 @@ fn supported_agents() -> Vec { AgentType::Qoder, AgentType::Antigravity, AgentType::Kiro, + AgentType::ZCode, AgentType::Trae, AgentType::CommandCode, ]; diff --git a/src-tauri/src/web/handlers/acp.rs b/src-tauri/src/web/handlers/acp.rs index 0441f7d3eb..0cb564fe95 100644 --- a/src-tauri/src/web/handlers/acp.rs +++ b/src-tauri/src/web/handlers/acp.rs @@ -9,8 +9,8 @@ use crate::acp::error::AcpError; use crate::acp::opencode_plugins::PluginCheckSummary; use crate::acp::preflight::PreflightResult; use crate::acp::types::{ - AcpAgentInfo, AcpAgentStatus, AgentDiagnosticsReport, AgentSkillContent, AgentSkillLayout, - AgentSkillScope, AgentSkillsListResult, ConnectionInfo, ForkResultInfo, + AcpAgentInfo, AcpAgentStatus, AgentDiagnosticsReport, AgentSkillContent, AgentSkillItem, + AgentSkillLayout, AgentSkillScope, AgentSkillsListResult, ConnectionInfo, ForkResultInfo, }; use crate::app_error::{AppCommandError, AppErrorCode}; use crate::app_state::AppState; @@ -294,8 +294,8 @@ pub struct AcpSaveAgentSkillParams { pub async fn acp_save_agent_skill( Json(params): Json, -) -> Result, AppCommandError> { - acp_commands::acp_save_agent_skill( +) -> Result, AppCommandError> { + let skill = acp_commands::acp_save_agent_skill( params.agent_type, params.scope, params.skill_id, @@ -305,7 +305,7 @@ pub async fn acp_save_agent_skill( ) .await .map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?; - Ok(Json(())) + Ok(Json(skill)) } #[derive(Deserialize)] @@ -1513,6 +1513,36 @@ mod tests { use crate::acp::agent_mentions::append_agent_routes; + /// The editor reopens what it just saved from this response, so the web + /// endpoint must return the saved item as the desktop command does. + #[tokio::test] + async fn skill_save_returns_the_item_the_web_editor_needs() { + let tmp = tempfile::tempdir().unwrap(); + let params = serde_json::from_value(serde_json::json!({ + "agentType": "zcode", + "scope": "project", + "skillId": "web-skill", + "content": "---\nname: web-skill\ndescription: Test\n---\nbody", + "workspacePath": tmp.path().to_string_lossy(), + "layout": "markdown_file" + })) + .unwrap(); + let response = acp_save_agent_skill(Json(params)).await.unwrap(); + let value = serde_json::to_value(response.0).unwrap(); + assert_eq!(value["id"], "web-skill"); + assert_eq!(value["scope"], "project"); + assert_eq!(value["layout"], "skill_directory"); + assert_eq!(value["description"], "Test"); + let directory = tmp.path().join(".zcode/skills/web-skill"); + assert_eq!( + std::path::Path::new(value["path"].as_str().unwrap()), + directory + ); + assert!(std::fs::read_to_string(directory.join("SKILL.md")) + .unwrap() + .ends_with("body")); + } + #[test] fn prompt_params_deserialize_camel_case_fields() { let params: AcpPromptParams = serde_json::from_value(serde_json::json!({ diff --git a/src/components/settings/acp-agent-settings.zcode.test.tsx b/src/components/settings/acp-agent-settings.zcode.test.tsx index b2c2a6c3c6..2d3e600332 100644 --- a/src/components/settings/acp-agent-settings.zcode.test.tsx +++ b/src/components/settings/acp-agent-settings.zcode.test.tsx @@ -26,10 +26,10 @@ describe("ZCode settings panel", () => { agent_type: "zcode", name: "ZCode", registry_id: "codeg-zcode-acp", - registry_version: "0.69.2", - installed_version: "0.69.2", + registry_version: "0.76.2", + installed_version: "0.76.2", description: "Official ZCode runtime via the community ACP bridge", - skills_capable: false, + skills_capable: true, supports_custom_version: true, available: true, distribution_type: "npx", diff --git a/src/components/settings/skills-settings.new-skill.test.tsx b/src/components/settings/skills-settings.new-skill.test.tsx new file mode 100644 index 0000000000..9bf3ebced1 --- /dev/null +++ b/src/components/settings/skills-settings.new-skill.test.tsx @@ -0,0 +1,135 @@ +import { render, screen, waitFor } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { NextIntlClientProvider } from "next-intl" +import type { ReactNode } from "react" +import { toast } from "sonner" +import { beforeEach, describe, expect, it, vi } from "vitest" + +import enMessages from "@/i18n/messages/en.json" +import { acpSaveAgentSkill } from "@/lib/api" +import type { AgentType } from "@/lib/types" +import { SkillsSettings } from "./skills-settings" + +const LOCATION = "C:\\Agent Home\\skills" + +const state = vi.hoisted(() => ({ agentType: "zcode" as string })) + +vi.mock("@/lib/api", () => ({ + acpListAgents: async () => [ + { + agent_type: state.agentType, + name: "Agent", + enabled: true, + skills_capable: true, + env: {}, + sort_order: 0, + }, + ], + loadFolderHistory: async () => [], + acpListAgentSkills: async () => ({ + supported: true, + message: null, + locations: [{ scope: "global", path: LOCATION, exists: false }], + skills: [], + }), + acpSaveAgentSkill: vi.fn(async () => ({ + id: "new-skill", + name: "new-skill", + scope: "global", + layout: "skill_directory", + path: `${LOCATION}\\new-skill`, + read_only: false, + description: null, + })), + acpReadAgentSkill: async () => ({ + skill: { + id: "new-skill", + name: "new-skill", + scope: "global", + layout: "skill_directory", + path: `${LOCATION}\\new-skill`, + read_only: false, + description: null, + }, + content: "---\nname: new-skill\ndescription: Test\n---\nbody", + }), + acpDeleteAgentSkill: vi.fn(), + openFolder: vi.fn(), +})) +vi.mock("@/hooks/use-agent-skills", () => ({ + invalidateAgentSkillsCache: vi.fn(), +})) +vi.mock("sonner", () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})) +vi.mock("@/components/ui/resizable", () => ({ + ResizablePanelGroup: ({ children }: { children: ReactNode }) => children, + ResizablePanel: ({ children }: { children: ReactNode }) => children, + ResizableHandle: () => null, +})) + +const t = enMessages.SkillsSettings + +async function draftNewSkill(agentType: AgentType) { + state.agentType = agentType + const user = userEvent.setup() + render( + + + + ) + const create = await screen.findByRole("button", { + name: t.actions.newSkill, + }) + await user.click(create) + await user.type( + screen.getByPlaceholderText(t.skillIdPlaceholder), + "new-skill" + ) + return user +} + +describe("new skill authoring", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("previews and saves a ZCode directory bundle rather than an unused flat file", async () => { + const user = await draftNewSkill("zcode") + expect( + await screen.findByText(/C:\\Agent Home\\skills\\new-skill\\SKILL\.md/) + ).toBeInTheDocument() + await user.click(screen.getByRole("button", { name: t.actions.save })) + await waitFor(() => { + expect(acpSaveAgentSkill).toHaveBeenCalledWith( + expect.objectContaining({ + agentType: "zcode", + scope: "global", + skillId: "new-skill", + layout: "skill_directory", + }) + ) + expect(toast.success).toHaveBeenCalledWith(t.toasts.created) + }) + expect(toast.error).not.toHaveBeenCalled() + }) + + // These stores take only `/SKILL.md` bundles, so the backend writes one + // whatever layout is sent; the preview has to name that same file. + it.each(["trae", "kimi_code", "command_code", "custom:my-agent"] as const)( + "previews the bundle %s actually gets", + async (agentType) => { + await draftNewSkill(agentType) + expect( + await screen.findByText(/C:\\Agent Home\\skills\\new-skill\\SKILL\.md/) + ).toBeInTheDocument() + } + ) + + it("keeps pi's flat-file default", async () => { + await draftNewSkill("pi") + expect( + await screen.findByText(/C:\\Agent Home\\skills\\new-skill\.md/) + ).toBeInTheDocument() + }) +}) diff --git a/src/components/settings/skills-settings.tsx b/src/components/settings/skills-settings.tsx index 0423351186..1da151df83 100644 --- a/src/components/settings/skills-settings.tsx +++ b/src/components/settings/skills-settings.tsx @@ -96,20 +96,21 @@ function defaultSkillContent( return t("templates.default") } +/** + * The layout a new skill is written in, which the path preview shows. Every + * agent's store takes `/SKILL.md` bundles, and the backend writes a bundle + * for any agent whose store takes nothing else (custom agents included), so + * that is the default. Only pi and DeepSeek Harness get no layout: their stores + * also read flat `.md` files, which the backend then writes. Codex reads + * flat files too, but its new skills are bundles. + */ function defaultSkillLayoutForAgent( agentType: AgentType, existingLayout?: AgentSkillLayout | null ): AgentSkillLayout | null { if (existingLayout) return existingLayout - if (agentType === "claude_code") return "skill_directory" - if (agentType === "open_code") return "skill_directory" - if (agentType === "codex") return "skill_directory" - if (agentType === "gemini") return "skill_directory" - if (agentType === "open_claw") return "skill_directory" - if (agentType === "cline") return "skill_directory" - if (agentType === "hermes") return "skill_directory" - if (agentType === "code_buddy") return "skill_directory" - return null + if (agentType === "pi" || agentType === "deepseek") return null + return "skill_directory" } function buildDraftPathPreview(params: {