From 6d53bcf6d350b6e82ef25aee2f2e3cfd50b072df Mon Sep 17 00:00:00 2001 From: jackie-cqz <2557911191@qq.com> Date: Thu, 22 Oct 2026 23:34:06 +0800 Subject: [PATCH 1/5] feat(zcode): add unified Skills management --- docs/agents/zcode.md | 25 ++ src-tauri/src/acp/zcode.rs | 53 ++++ src-tauri/src/commands/acp.rs | 276 +++++++++++++++++- src-tauri/src/commands/custom_skills.rs | 86 ++++++ src-tauri/src/commands/experts.rs | 1 + src-tauri/src/commands/office_tools.rs | 1 + src-tauri/src/commands/science.rs | 1 + src-tauri/src/web/handlers/acp.rs | 40 ++- .../acp-agent-settings.zcode.test.tsx | 6 +- src/components/settings/skills-settings.tsx | 1 + .../settings/skills-settings.zcode.test.tsx | 102 +++++++ 11 files changed, 583 insertions(+), 9 deletions(-) create mode 100644 src/components/settings/skills-settings.zcode.test.tsx diff --git a/docs/agents/zcode.md b/docs/agents/zcode.md index ced551e428..903234e06e 100644 --- a/docs/agents/zcode.md +++ b/docs/agents/zcode.md @@ -23,6 +23,31 @@ 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 and Office skill installation matrices. + +Global discovery checks the native ZCode data home's `skills/` first, then the +OS home directory's `.agents/skills/`. New global skills and Codeg installation +links go into the native store, keeping installs separate from the shared +store. Project skills use `/.agents/skills/`, including when that +directory is below a Git root. Flat `.md` files are not ZCode skill bundles. + +The native data home follows the bridge: `ZCODE_HOME`, otherwise +`$HOME/.zcode` (or `$USERPROFILE/.zcode` when HOME is unset). To relocate the +managed store, set an absolute `ZCODE_HOME` before starting Codeg. The shared +file manager uses Codeg's process environment, not per-agent environment +overrides; keep the bridge on that same data home. A relative `ZCODE_HOME` +cannot identify one global store, so Skills management is unavailable for it. + +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/acp/zcode.rs b/src-tauri/src/acp/zcode.rs index 3dd54264da..2902f387fa 100644 --- a/src-tauri/src/acp/zcode.rs +++ b/src-tauri/src/acp/zcode.rs @@ -9,6 +9,34 @@ pub struct ZCodeRuntime { pub node: PathBuf, } +/// Match the bridge's `zcodeHomeDir`: ZCODE_HOME, then HOME/USERPROFILE. +/// Its native data home differs from Node's OS home used by shared skills. +/// A relative data home changes with each session cwd and cannot be exposed +/// as one global Skills store by Codeg. +pub(crate) fn skills_home_dir() -> Option { + skills_home_from_env( + std::env::var_os("ZCODE_HOME"), + std::env::var_os("HOME"), + std::env::var_os("USERPROFILE"), + ) +} + +fn skills_home_from_env( + zcode_home: Option, + home: Option, + userprofile: Option, +) -> Option { + let root = zcode_home + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .or_else(|| { + home.filter(|value| !value.is_empty()) + .or_else(|| userprofile.filter(|value| !value.is_empty())) + .map(|home| PathBuf::from(home).join(".zcode")) + })?; + root.is_absolute().then_some(root) +} + #[derive(Debug, thiserror::Error)] pub enum RuntimeError { #[error("Official ZCode CLI could not be found. Install ZCode and sign in, or set ZCODE_BIN to its zcode.cjs entry in Agent Settings.")] @@ -378,6 +406,31 @@ pub fn initial_mode( mod tests { use super::*; + #[test] + fn skills_home_matches_the_bridges_environment_precedence() { + let tmp = tempfile::tempdir().unwrap(); + let native = tmp.path().join("Native ZCode"); + assert_eq!( + skills_home_from_env(Some(native.into_os_string()), Some(tmp.path().into()), None), + Some(tmp.path().join("Native ZCode")), + ); + assert_eq!( + skills_home_from_env(Some("".into()), Some(tmp.path().into()), None), + Some(tmp.path().join(".zcode")), + ); + assert_eq!( + skills_home_from_env(None, None, Some(tmp.path().into())), + Some(tmp.path().join(".zcode")), + ); + } + + #[test] + fn relative_skills_homes_are_not_a_global_store() { + for value in ["relative-zcode", "~/zcode", "../zcode"] { + assert!(skills_home_from_env(Some(value.into()), None, None).is_none()); + } + } + fn file(path: &Path) { std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write(path, "").unwrap(); diff --git a/src-tauri/src/commands/acp.rs b/src-tauri/src/commands/acp.rs index 6264fe34e0..8aa0139087 100644 --- a/src-tauri/src/commands/acp.rs +++ b/src-tauri/src/commands/acp.rs @@ -9515,6 +9515,19 @@ pub(crate) fn skill_storage_spec(agent_type: AgentType) -> Option Some(SkillStorageSpec { + kind: SkillStorageKind::SkillDirectoryOnly, + global_dirs: vec![ + crate::acp::zcode::skills_home_dir()?.join("skills"), + home_dir_or_default().join(".agents").join("skills"), + ], + project_rel_dirs: vec![".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 +9541,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(); @@ -14933,6 +14945,268 @@ 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(); + } + + #[cfg(unix)] + #[tokio::test] + async fn zcode_skills_manage_directory_bundles_in_native_then_shared_order() { + let tmp = tempfile::tempdir().unwrap(); + let native = tmp.path().join("Native ZCode"); + temp_env::async_with_vars( + [ + ("ZCODE_HOME", Some(native.as_path())), + ("HOME", Some(tmp.path())), + ("USERPROFILE", Some(tmp.path())), + ], + async { + let os_home = home_dir_or_default(); + assert!( + os_home.starts_with(tmp.path()), + "fixture must isolate OS home" + ); + let shared = os_home.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"); + 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()); + assert_eq!( + listed.skills.len(), + 2, + "flat files and plugin caches 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" + ); + 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(); + fs::create_dir_all(tmp.path().join("cli")).unwrap(); + let config = r#"{"skills":{"keep-disabled":{"enable":false}}}"#; + fs::write(tmp.path().join("cli/config.json"), config).unwrap(); + temp_env::async_with_vars([("ZCODE_HOME", Some(tmp.path()))], async { + let id = "codeg-zcode-crud-fixture"; + // Seed the native entry so the generic global lookup never falls + // through to a same-id skill in the developer's shared store. + write_skill(&tmp.path().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), tmp.path().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(tmp.path().join("skills").join(id).join("SKILL.md")).unwrap(), + "edited" + ); + acp_delete_agent_skill(AgentType::ZCode, AgentSkillScope::Global, id.into(), None) + .await + .unwrap(); + assert!(!tmp.path().join("skills").join(id).exists()); + assert_eq!( + fs::read_to_string(tmp.path().join("cli/config.json")).unwrap(), + config + ); + }) + .await; + } + + #[tokio::test] + async fn zcode_project_skills_follow_the_session_cwd_not_its_git_ancestor() { + let tmp = tempfile::tempdir().unwrap(); + let workspace = tmp.path().join("repo/nested"); + fs::create_dir_all(&workspace).unwrap(); + fs::create_dir_all(tmp.path().join("repo/.git")).unwrap(); + write_skill( + &tmp.path().join("repo/.agents/skills"), + "ancestor-only", + "parent", + ); + temp_env::async_with_vars([("ZCODE_HOME", Some(tmp.path()))], async { + let workspace_arg = Some(workspace.to_string_lossy().into_owned()); + let saved = acp_save_agent_skill( + AgentType::ZCode, + AgentSkillScope::Project, + "project-test".into(), + "project".into(), + workspace_arg.clone(), + None, + ) + .await + .unwrap(); + assert_eq!( + Path::new(&saved.path), + workspace.join(".agents/skills/project-test") + ); + let listed = acp_list_agent_skills(AgentType::ZCode, workspace_arg.clone()) + .await + .unwrap(); + let project: Vec<_> = listed + .skills + .iter() + .filter(|s| s.scope == AgentSkillScope::Project) + .collect(); + assert_eq!(project.len(), 1); + assert_eq!(project[0].id, "project-test"); + 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!(!workspace.join(".agents/skills/project-test").exists()); + }) + .await; + } + + #[tokio::test] + async fn zcode_has_authoritative_rows_in_every_bundled_skill_matrix() { + let tmp = tempfile::tempdir().unwrap(); + temp_env::async_with_vars([("ZCODE_HOME", 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..4583fdd485 100644 --- a/src-tauri/src/commands/custom_skills.rs +++ b/src-tauri/src/commands/custom_skills.rs @@ -834,6 +834,18 @@ mod tests { assert!(agents.iter().all(|a| skill_storage_spec(*a).is_some())); } + #[test] + fn zcode_joins_the_custom_skill_matrix_at_its_native_store() { + let tmp = tempfile::tempdir().unwrap(); + temp_env::with_var("ZCODE_HOME", Some(tmp.path()), || { + assert!(supported_agents().contains(&AgentType::ZCode)); + assert_eq!( + agent_link_path(AgentType::ZCode, "example").unwrap(), + tmp.path().join("skills/example") + ); + }); + } + #[test] fn a_custom_agent_joins_the_matrix_by_declaring_a_store() { use crate::acp::custom_registry::{ @@ -1106,6 +1118,80 @@ mod tests { .await; } + #[cfg(unix)] + #[tokio::test] + async fn fs_zcode_import_and_matrix_links_use_its_native_store() { + let tmp = tempfile::tempdir().unwrap(); + let native = tmp.path().join("native data"); + temp_env::async_with_vars( + [ + ("HOME", Some(tmp.path())), + ("ZCODE_HOME", Some(native.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()); + }, + ) + .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..5ed42ac670 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; + #[tokio::test] + async fn zcode_skill_save_returns_the_item_the_web_editor_needs() { + let tmp = tempfile::tempdir().unwrap(); + temp_env::async_with_vars([("ZCODE_HOME", Some(tmp.path()))], async { + 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"); + let directory = tmp.path().join(".agents/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")); + }) + .await; + } + #[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.tsx b/src/components/settings/skills-settings.tsx index 0423351186..6cbc67fe8e 100644 --- a/src/components/settings/skills-settings.tsx +++ b/src/components/settings/skills-settings.tsx @@ -109,6 +109,7 @@ function defaultSkillLayoutForAgent( if (agentType === "cline") return "skill_directory" if (agentType === "hermes") return "skill_directory" if (agentType === "code_buddy") return "skill_directory" + if (agentType === "zcode") return "skill_directory" return null } diff --git a/src/components/settings/skills-settings.zcode.test.tsx b/src/components/settings/skills-settings.zcode.test.tsx new file mode 100644 index 0000000000..7fa61e5457 --- /dev/null +++ b/src/components/settings/skills-settings.zcode.test.tsx @@ -0,0 +1,102 @@ +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 { describe, expect, it, vi } from "vitest" + +import enMessages from "@/i18n/messages/en.json" +import { acpSaveAgentSkill } from "@/lib/api" +import { SkillsSettings } from "./skills-settings" + +vi.mock("@/lib/api", () => ({ + acpListAgents: async () => [ + { + agent_type: "zcode", + name: "ZCode", + enabled: true, + skills_capable: true, + env: {}, + sort_order: 0, + }, + ], + loadFolderHistory: async () => [], + acpListAgentSkills: async () => ({ + supported: true, + message: null, + locations: [ + { scope: "global", path: "C:\\Native ZCode\\skills", exists: false }, + ], + skills: [], + }), + acpSaveAgentSkill: vi.fn(async () => ({ + id: "new-skill", + name: "new-skill", + scope: "global", + layout: "skill_directory", + path: "C:\\Native ZCode\\skills\\new-skill", + read_only: false, + description: null, + })), + acpReadAgentSkill: async () => ({ + skill: { + id: "new-skill", + name: "new-skill", + scope: "global", + layout: "skill_directory", + path: "C:\\Native ZCode\\skills\\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, +})) + +describe("ZCode Skills authoring", () => { + it("previews and saves a directory bundle rather than an unused flat file", async () => { + const user = userEvent.setup() + const t = enMessages.SkillsSettings + render( + + + + ) + const create = await screen.findByRole("button", { + name: t.actions.newSkill, + }) + await user.click(create) + await user.type( + screen.getByPlaceholderText(t.skillIdPlaceholder), + "new-skill" + ) + expect( + await screen.findByText(/C:\\Native ZCode\\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() + }) +}) From 594b2c3aafef177106ec7cc6365a8299b01f74eb Mon Sep 17 00:00:00 2001 From: xintaofei Date: Tue, 27 Oct 2026 02:14:47 +0800 Subject: [PATCH 2/5] fix(zcode): manage the skill folders the ZCode runtime loads The skills a ZCode session can use are the ones the official runtime (`zcode app-server`, spawned by the bridge) loads, not the bridge's own command list. The runtime reads user skills from `~/.zcode/skills` and `~/.agents/skills` in the OS home whatever `ZCODE_HOME` says, so the native store no longer follows `ZCODE_HOME`: with it relocated, installs landed where the bridge offered them as commands but the runtime never loaded them. Project skills now lead with `/.zcode/skills`, where ZCode's own settings create them and which the runtime prefers over `.agents/skills` in the same folder, so with a copy in each Codeg lists and edits the one ZCode uses. Skills in `/.agents/skills` are still listed and edited in place. The docs describe the runtime's folders, what `ZCODE_HOME` and the bridge's command list do, and that parent folders up to the Git root are not listed. Tests that wrote the native store are unix-only with HOME pinned, since `dirs::home_dir()` ignores HOME on Windows. --- docs/agents/zcode.md | 38 +++-- src-tauri/src/acp/zcode.rs | 53 ------- src-tauri/src/commands/acp.rs | 198 +++++++++++++++++------- src-tauri/src/commands/custom_skills.rs | 37 +++-- src-tauri/src/web/handlers/acp.rs | 52 +++---- 5 files changed, 221 insertions(+), 157 deletions(-) diff --git a/docs/agents/zcode.md b/docs/agents/zcode.md index 903234e06e..24ebe7c261 100644 --- a/docs/agents/zcode.md +++ b/docs/agents/zcode.md @@ -27,20 +27,30 @@ The bridge accepts up to 100 content blocks and 50 MiB of decoded content per pr 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 and Office skill installation matrices. - -Global discovery checks the native ZCode data home's `skills/` first, then the -OS home directory's `.agents/skills/`. New global skills and Codeg installation -links go into the native store, keeping installs separate from the shared -store. Project skills use `/.agents/skills/`, including when that -directory is below a Git root. Flat `.md` files are not ZCode skill bundles. - -The native data home follows the bridge: `ZCODE_HOME`, otherwise -`$HOME/.zcode` (or `$USERPROFILE/.zcode` when HOME is unset). To relocate the -managed store, set an absolute `ZCODE_HOME` before starting Codeg. The shared -file manager uses Codeg's process environment, not per-agent environment -overrides; keep the bridge on that same data home. A relative `ZCODE_HOME` -cannot identify one global store, so Skills management is unavailable for it. +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 skills in +`/.agents/skills/`. When both folders contain the same skill +folder, ZCode uses the `.zcode` copy, and that is the copy Codeg lists and +edits. The runtime loads project skills from both folders, but the bridge's +command list includes only the `.agents` ones. ZCode also loads project skills +from parent folders up to the Git root, which Codeg does not list. + +`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 resolves the home directory from its own process environment, +not from 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 diff --git a/src-tauri/src/acp/zcode.rs b/src-tauri/src/acp/zcode.rs index 2902f387fa..3dd54264da 100644 --- a/src-tauri/src/acp/zcode.rs +++ b/src-tauri/src/acp/zcode.rs @@ -9,34 +9,6 @@ pub struct ZCodeRuntime { pub node: PathBuf, } -/// Match the bridge's `zcodeHomeDir`: ZCODE_HOME, then HOME/USERPROFILE. -/// Its native data home differs from Node's OS home used by shared skills. -/// A relative data home changes with each session cwd and cannot be exposed -/// as one global Skills store by Codeg. -pub(crate) fn skills_home_dir() -> Option { - skills_home_from_env( - std::env::var_os("ZCODE_HOME"), - std::env::var_os("HOME"), - std::env::var_os("USERPROFILE"), - ) -} - -fn skills_home_from_env( - zcode_home: Option, - home: Option, - userprofile: Option, -) -> Option { - let root = zcode_home - .filter(|value| !value.is_empty()) - .map(PathBuf::from) - .or_else(|| { - home.filter(|value| !value.is_empty()) - .or_else(|| userprofile.filter(|value| !value.is_empty())) - .map(|home| PathBuf::from(home).join(".zcode")) - })?; - root.is_absolute().then_some(root) -} - #[derive(Debug, thiserror::Error)] pub enum RuntimeError { #[error("Official ZCode CLI could not be found. Install ZCode and sign in, or set ZCODE_BIN to its zcode.cjs entry in Agent Settings.")] @@ -406,31 +378,6 @@ pub fn initial_mode( mod tests { use super::*; - #[test] - fn skills_home_matches_the_bridges_environment_precedence() { - let tmp = tempfile::tempdir().unwrap(); - let native = tmp.path().join("Native ZCode"); - assert_eq!( - skills_home_from_env(Some(native.into_os_string()), Some(tmp.path().into()), None), - Some(tmp.path().join("Native ZCode")), - ); - assert_eq!( - skills_home_from_env(Some("".into()), Some(tmp.path().into()), None), - Some(tmp.path().join(".zcode")), - ); - assert_eq!( - skills_home_from_env(None, None, Some(tmp.path().into())), - Some(tmp.path().join(".zcode")), - ); - } - - #[test] - fn relative_skills_homes_are_not_a_global_store() { - for value in ["relative-zcode", "~/zcode", "../zcode"] { - assert!(skills_home_from_env(Some(value.into()), None, None).is_none()); - } - } - fn file(path: &Path) { std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write(path, "").unwrap(); diff --git a/src-tauri/src/commands/acp.rs b/src-tauri/src/commands/acp.rs index 8aa0139087..e5d7c72dc5 100644 --- a/src-tauri/src/commands/acp.rs +++ b/src-tauri/src/commands/acp.rs @@ -9515,18 +9515,42 @@ 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`). + // + // The user roots hang off the OS home, 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. The + // bridge's command list reads a project's `.agents/skills` but not its + // `.zcode/skills`, so project skills created here are not offered as + // commands, though the runtime loads them. Parent folders up to the + // Git root are not listed. Codeg runs the agent in the session cwd, + // which is what both the bridge and the runtime scan from. AgentType::ZCode => Some(SkillStorageSpec { kind: SkillStorageKind::SkillDirectoryOnly, global_dirs: vec![ - crate::acp::zcode::skills_home_dir()?.join("skills"), + home_dir_or_default().join(".zcode").join("skills"), home_dir_or_default().join(".agents").join("skills"), ], - project_rel_dirs: vec![".agents/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 @@ -14954,16 +14978,45 @@ mod zcode_skills_tests { fs::write(root.join(id).join("SKILL.md"), body).unwrap(); } + /// The ZCode runtime reads user skills from the OS home and ignores + /// `ZCODE_HOME`, so the managed stores must not move with it. Expected + /// paths come from the same `home_dir_or_default()` production uses, so + /// this holds on Windows too, where `dirs::home_dir()` ignores `$HOME`. + #[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( + [("HOME", Some(&home)), ("ZCODE_HOME", Some(&relocated))], + || { + let spec = skill_storage_spec(AgentType::ZCode).expect("ZCode declares a store"); + let os_home = home_dir_or_default(); + assert_eq!(spec.kind, SkillStorageKind::SkillDirectoryOnly); + assert_eq!( + spec.global_dirs, + vec![ + os_home.join(".zcode").join("skills"), + os_home.join(".agents").join("skills"), + ] + ); + assert_eq!( + spec.project_rel_dirs, + vec![".zcode/skills", ".agents/skills"] + ); + }, + ); + } + #[cfg(unix)] #[tokio::test] async fn zcode_skills_manage_directory_bundles_in_native_then_shared_order() { let tmp = tempfile::tempdir().unwrap(); - let native = tmp.path().join("Native ZCode"); + let native = tmp.path().join(".zcode"); + let relocated = tmp.path().join("relocated"); temp_env::async_with_vars( [ - ("ZCODE_HOME", Some(native.as_path())), ("HOME", Some(tmp.path())), - ("USERPROFILE", Some(tmp.path())), + ("ZCODE_HOME", Some(relocated.as_path())), ], async { let os_home = home_dir_or_default(); @@ -14976,6 +15029,7 @@ mod zcode_skills_tests { 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"}"#; @@ -14986,10 +15040,12 @@ mod zcode_skills_tests { 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!( - listed.skills.len(), - 2, - "flat files and plugin caches stay out" + 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")); @@ -15011,6 +15067,10 @@ mod zcode_skills_tests { !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, @@ -15060,17 +15120,18 @@ mod zcode_skills_tests { .await; } + #[cfg(unix)] #[tokio::test] async fn zcode_native_skill_crud_does_not_rewrite_cli_policy() { let tmp = tempfile::tempdir().unwrap(); - fs::create_dir_all(tmp.path().join("cli")).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(tmp.path().join("cli/config.json"), config).unwrap(); - temp_env::async_with_vars([("ZCODE_HOME", Some(tmp.path()))], async { + fs::write(native.join("cli/config.json"), config).unwrap(); + temp_env::async_with_vars([("HOME", Some(tmp.path()))], async { let id = "codeg-zcode-crud-fixture"; - // Seed the native entry so the generic global lookup never falls - // through to a same-id skill in the developer's shared store. - write_skill(&tmp.path().join("skills"), id, "before"); + // 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, @@ -15082,7 +15143,7 @@ mod zcode_skills_tests { .await .unwrap(); assert_eq!(skill.layout, AgentSkillLayout::SkillDirectory); - assert_eq!(Path::new(&skill.path), tmp.path().join("skills").join(id)); + 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 = @@ -15101,58 +15162,89 @@ mod zcode_skills_tests { .await .unwrap(); assert_eq!( - fs::read_to_string(tmp.path().join("skills").join(id).join("SKILL.md")).unwrap(), + 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!(!tmp.path().join("skills").join(id).exists()); + assert!(!native.join("skills").join(id).exists()); assert_eq!( - fs::read_to_string(tmp.path().join("cli/config.json")).unwrap(), + 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_follow_the_session_cwd_not_its_git_ancestor() { + 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( - &tmp.path().join("repo/.agents/skills"), - "ancestor-only", - "parent", - ); - temp_env::async_with_vars([("ZCODE_HOME", Some(tmp.path()))], async { + write_skill(&shared, "shared-only", "shared"); + write_skill(&native, "both", "native copy"); + write_skill(&shared, "both", "shared copy"); + temp_env::async_with_vars([("HOME", Some(tmp.path().join("home")))], async { let workspace_arg = Some(workspace.to_string_lossy().into_owned()); - let saved = acp_save_agent_skill( - AgentType::ZCode, - AgentSkillScope::Project, - "project-test".into(), - "project".into(), - workspace_arg.clone(), - None, - ) - .await - .unwrap(); - assert_eq!( - Path::new(&saved.path), - workspace.join(".agents/skills/project-test") - ); + 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: Vec<_> = listed - .skills - .iter() - .filter(|s| s.scope == AgentSkillScope::Project) - .collect(); - assert_eq!(project.len(), 1); - assert_eq!(project[0].id, "project-test"); + 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, @@ -15181,7 +15273,7 @@ mod zcode_skills_tests { ) .await .unwrap(); - assert!(!workspace.join(".agents/skills/project-test").exists()); + assert!(!native.join("project-test").exists()); }) .await; } @@ -15189,7 +15281,7 @@ mod zcode_skills_tests { #[tokio::test] async fn zcode_has_authoritative_rows_in_every_bundled_skill_matrix() { let tmp = tempfile::tempdir().unwrap(); - temp_env::async_with_vars([("ZCODE_HOME", Some(tmp.path()))], async { + temp_env::async_with_vars([("HOME", Some(tmp.path()))], async { let rows = crate::commands::experts::experts_list_all_install_statuses() .await .unwrap(); diff --git a/src-tauri/src/commands/custom_skills.rs b/src-tauri/src/commands/custom_skills.rs index 4583fdd485..4491ad25c8 100644 --- a/src-tauri/src/commands/custom_skills.rs +++ b/src-tauri/src/commands/custom_skills.rs @@ -834,16 +834,28 @@ 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. + /// The expected path comes from the same `dirs::home_dir()` production + /// uses, so it holds on Windows too, where that call ignores `$HOME`. #[test] - fn zcode_joins_the_custom_skill_matrix_at_its_native_store() { + fn zcode_joins_the_custom_skill_matrix_at_its_os_home_store() { let tmp = tempfile::tempdir().unwrap(); - temp_env::with_var("ZCODE_HOME", Some(tmp.path()), || { - assert!(supported_agents().contains(&AgentType::ZCode)); - assert_eq!( - agent_link_path(AgentType::ZCode, "example").unwrap(), - tmp.path().join("skills/example") - ); - }); + let (home, relocated) = (tmp.path().join("home"), tmp.path().join("relocated")); + temp_env::with_vars( + [("HOME", Some(&home)), ("ZCODE_HOME", Some(&relocated))], + || { + assert!(supported_agents().contains(&AgentType::ZCode)); + assert_eq!( + agent_link_path(AgentType::ZCode, "example").unwrap(), + dirs::home_dir() + .unwrap() + .join(".zcode") + .join("skills") + .join("example") + ); + }, + ); } #[test] @@ -1120,13 +1132,15 @@ mod tests { #[cfg(unix)] #[tokio::test] - async fn fs_zcode_import_and_matrix_links_use_its_native_store() { + async fn fs_zcode_import_and_matrix_links_use_its_os_home_store() { let tmp = tempfile::tempdir().unwrap(); - let native = tmp.path().join("native data"); + // 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(native.as_path())), + ("ZCODE_HOME", Some(relocated.as_path())), ], async { assert!(central_experts_dir().starts_with(tmp.path())); @@ -1187,6 +1201,7 @@ mod tests { 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; diff --git a/src-tauri/src/web/handlers/acp.rs b/src-tauri/src/web/handlers/acp.rs index 5ed42ac670..0cb564fe95 100644 --- a/src-tauri/src/web/handlers/acp.rs +++ b/src-tauri/src/web/handlers/acp.rs @@ -1513,34 +1513,34 @@ 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 zcode_skill_save_returns_the_item_the_web_editor_needs() { + async fn skill_save_returns_the_item_the_web_editor_needs() { let tmp = tempfile::tempdir().unwrap(); - temp_env::async_with_vars([("ZCODE_HOME", Some(tmp.path()))], async { - 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"); - let directory = tmp.path().join(".agents/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")); - }) - .await; + 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] From 9562e4fba41cd370aa4344a53231cacfe4055c48 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Tue, 27 Oct 2026 02:14:47 +0800 Subject: [PATCH 3/5] fix(skills): preview the bundle path for every bundle-only agent The new-skill path preview used a hand-kept list of agents that get `/SKILL.md` bundles, which missed every agent added since (Kimi Code, Grok, Cursor, Qoder, Antigravity, Kiro, TRAE CLI, Command Code) and all custom agents: the preview named `.md` while the backend wrote a bundle. Bundles are now the default, and only pi and DeepSeek Harness, whose stores also take flat files, keep the flat-file default. --- ...tsx => skills-settings.new-skill.test.tsx} | 85 +++++++++++++------ src/components/settings/skills-settings.tsx | 20 ++--- 2 files changed, 69 insertions(+), 36 deletions(-) rename src/components/settings/{skills-settings.zcode.test.tsx => skills-settings.new-skill.test.tsx} (53%) diff --git a/src/components/settings/skills-settings.zcode.test.tsx b/src/components/settings/skills-settings.new-skill.test.tsx similarity index 53% rename from src/components/settings/skills-settings.zcode.test.tsx rename to src/components/settings/skills-settings.new-skill.test.tsx index 7fa61e5457..9bf3ebced1 100644 --- a/src/components/settings/skills-settings.zcode.test.tsx +++ b/src/components/settings/skills-settings.new-skill.test.tsx @@ -3,17 +3,22 @@ import userEvent from "@testing-library/user-event" import { NextIntlClientProvider } from "next-intl" import type { ReactNode } from "react" import { toast } from "sonner" -import { describe, expect, it, vi } from "vitest" +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: "zcode", - name: "ZCode", + agent_type: state.agentType, + name: "Agent", enabled: true, skills_capable: true, env: {}, @@ -24,9 +29,7 @@ vi.mock("@/lib/api", () => ({ acpListAgentSkills: async () => ({ supported: true, message: null, - locations: [ - { scope: "global", path: "C:\\Native ZCode\\skills", exists: false }, - ], + locations: [{ scope: "global", path: LOCATION, exists: false }], skills: [], }), acpSaveAgentSkill: vi.fn(async () => ({ @@ -34,7 +37,7 @@ vi.mock("@/lib/api", () => ({ name: "new-skill", scope: "global", layout: "skill_directory", - path: "C:\\Native ZCode\\skills\\new-skill", + path: `${LOCATION}\\new-skill`, read_only: false, description: null, })), @@ -44,7 +47,7 @@ vi.mock("@/lib/api", () => ({ name: "new-skill", scope: "global", layout: "skill_directory", - path: "C:\\Native ZCode\\skills\\new-skill", + path: `${LOCATION}\\new-skill`, read_only: false, description: null, }, @@ -65,25 +68,36 @@ vi.mock("@/components/ui/resizable", () => ({ ResizableHandle: () => null, })) -describe("ZCode Skills authoring", () => { - it("previews and saves a directory bundle rather than an unused flat file", async () => { - const user = userEvent.setup() - const t = enMessages.SkillsSettings - render( - - - - ) - const create = await screen.findByRole("button", { - name: t.actions.newSkill, - }) - await user.click(create) - await user.type( - screen.getByPlaceholderText(t.skillIdPlaceholder), - "new-skill" - ) +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:\\Native ZCode\\skills\\new-skill\\SKILL\.md/) + await screen.findByText(/C:\\Agent Home\\skills\\new-skill\\SKILL\.md/) ).toBeInTheDocument() await user.click(screen.getByRole("button", { name: t.actions.save })) await waitFor(() => { @@ -99,4 +113,23 @@ describe("ZCode Skills authoring", () => { }) 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 6cbc67fe8e..1da151df83 100644 --- a/src/components/settings/skills-settings.tsx +++ b/src/components/settings/skills-settings.tsx @@ -96,21 +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" - if (agentType === "zcode") return "skill_directory" - return null + if (agentType === "pi" || agentType === "deepseek") return null + return "skill_directory" } function buildDraftPathPreview(params: { From b79385f39ffb9e80c7792af92f63df8fea57431b Mon Sep 17 00:00:00 2001 From: xintaofei Date: Tue, 27 Oct 2026 08:17:40 +0800 Subject: [PATCH 4/5] fix(zcode): list parent-folder project skills and read the home Node uses The ZCode runtime loads project skills from `.zcode/skills` and `.agents/skills` in every folder from the session cwd up to the Git worktree root, nearest first, and from the cwd alone outside a repository. Settings -> Skills now lists those folders in that order, so a skill in a parent folder is shown and edited where it is, and an id lists the copy ZCode uses. New project skills still go to `/.zcode/skills`, and DeepSeek and Kimi Code keep their Git-root base. ZCode's user folders hang off Node's `os.homedir()`, which on Windows is `USERPROFILE`, while `dirs::home_dir()` asks the OS for the profile folder. The store now reads `USERPROFILE` (`HOME` elsewhere) from Codeg's environment before falling back to the account's home, so the Settings tests that write ZCode's home store run on Windows too. --- docs/agents/zcode.md | 19 +- src-tauri/src/commands/acp.rs | 322 ++++++++++++++++++------ src-tauri/src/commands/custom_skills.rs | 16 +- 3 files changed, 271 insertions(+), 86 deletions(-) diff --git a/docs/agents/zcode.md b/docs/agents/zcode.md index 24ebe7c261..d532dee6aa 100644 --- a/docs/agents/zcode.md +++ b/docs/agents/zcode.md @@ -36,12 +36,14 @@ in your home directory. New global skills and Codeg installation links go into 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 skills in -`/.agents/skills/`. When both folders contain the same skill -folder, ZCode uses the `.zcode` copy, and that is the copy Codeg lists and -edits. The runtime loads project skills from both folders, but the bridge's -command list includes only the `.agents` ones. ZCode also loads project skills -from parent folders up to the Git root, which Codeg does not list. +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 +the copy ZCode uses: the one nearest the session cwd, and within one folder the +`.zcode` copy. 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 @@ -49,8 +51,9 @@ 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 resolves the home directory from its own process environment, -not from the agent's environment overrides. +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 diff --git a/src-tauri/src/commands/acp.rs b/src-tauri/src/commands/acp.rs index e5d7c72dc5..a5945c496e 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. /// @@ -9524,10 +9544,13 @@ pub(crate) fn skill_storage_spec(agent_type: AgentType) -> Option/.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 user roots hang off the OS home, 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 + // 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 @@ -9538,20 +9561,22 @@ pub(crate) fn skill_storage_spec(agent_type: AgentType) -> Option Some(SkillStorageSpec { - kind: SkillStorageKind::SkillDirectoryOnly, - global_dirs: vec![ - home_dir_or_default().join(".zcode").join("skills"), - home_dir_or_default().join(".agents").join("skills"), - ], - project_rel_dirs: vec![".zcode/skills", ".agents/skills"], - }), + // 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 @@ -9648,49 +9673,79 @@ 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 it uses. +/// /// `.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()], } } } @@ -14506,14 +14561,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(), @@ -14978,25 +15032,28 @@ mod zcode_skills_tests { fs::write(root.join(id).join("SKILL.md"), body).unwrap(); } - /// The ZCode runtime reads user skills from the OS home and ignores - /// `ZCODE_HOME`, so the managed stores must not move with it. Expected - /// paths come from the same `home_dir_or_default()` production uses, so - /// this holds on Windows too, where `dirs::home_dir()` ignores `$HOME`. + /// 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( - [("HOME", Some(&home)), ("ZCODE_HOME", Some(&relocated))], + [ + (CHILD_HOME_KEY, Some(&home)), + ("ZCODE_HOME", Some(&relocated)), + ], || { let spec = skill_storage_spec(AgentType::ZCode).expect("ZCode declares a store"); - let os_home = home_dir_or_default(); assert_eq!(spec.kind, SkillStorageKind::SkillDirectoryOnly); assert_eq!( spec.global_dirs, vec![ - os_home.join(".zcode").join("skills"), - os_home.join(".agents").join("skills"), + home.join(".zcode").join("skills"), + home.join(".agents").join("skills"), ] ); assert_eq!( @@ -15007,7 +15064,17 @@ mod zcode_skills_tests { ); } - #[cfg(unix)] + /// 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(); @@ -15015,16 +15082,11 @@ mod zcode_skills_tests { let relocated = tmp.path().join("relocated"); temp_env::async_with_vars( [ - ("HOME", Some(tmp.path())), + (CHILD_HOME_KEY, Some(tmp.path())), ("ZCODE_HOME", Some(relocated.as_path())), ], async { - let os_home = home_dir_or_default(); - assert!( - os_home.starts_with(tmp.path()), - "fixture must isolate OS home" - ); - let shared = os_home.join(".agents/skills"); + let shared = tmp.path().join(".agents/skills"); let native_skills = native.join("skills"); write_skill(&native_skills, "duplicate", "native"); write_skill(&shared, "duplicate", "shared"); @@ -15120,7 +15182,6 @@ mod zcode_skills_tests { .await; } - #[cfg(unix)] #[tokio::test] async fn zcode_native_skill_crud_does_not_rewrite_cli_policy() { let tmp = tempfile::tempdir().unwrap(); @@ -15128,7 +15189,7 @@ mod zcode_skills_tests { 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([("HOME", Some(tmp.path()))], async { + 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"); @@ -15192,7 +15253,7 @@ mod zcode_skills_tests { write_skill(&shared, "shared-only", "shared"); write_skill(&native, "both", "native copy"); write_skill(&shared, "both", "shared copy"); - temp_env::async_with_vars([("HOME", Some(tmp.path().join("home")))], async { + 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( @@ -15278,10 +15339,131 @@ mod zcode_skills_tests { .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([("HOME", Some(tmp.path()))], async { + temp_env::async_with_vars([(CHILD_HOME_KEY, Some(tmp.path()))], async { let rows = crate::commands::experts::experts_list_all_install_statuses() .await .unwrap(); diff --git a/src-tauri/src/commands/custom_skills.rs b/src-tauri/src/commands/custom_skills.rs index 4491ad25c8..be9aacf649 100644 --- a/src-tauri/src/commands/custom_skills.rs +++ b/src-tauri/src/commands/custom_skills.rs @@ -836,23 +836,23 @@ mod tests { /// ZCode's runtime loads user skills from `/.zcode/skills` whatever /// `ZCODE_HOME` says, so a matrix link must land there and not follow it. - /// The expected path comes from the same `dirs::home_dir()` production - /// uses, so it holds on Windows too, where that call ignores `$HOME`. + /// 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)), ("ZCODE_HOME", Some(&relocated))], + [ + ("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(), - dirs::home_dir() - .unwrap() - .join(".zcode") - .join("skills") - .join("example") + home.join(".zcode").join("skills").join("example") ); }, ); From 042a653e35d8c67ce27521c7cdb41b39bd6d8829 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Tue, 27 Oct 2026 09:27:00 +0800 Subject: [PATCH 5/5] docs(zcode): say which duplicate project skill is listed, not that ZCode uses it The ZCode notes and the project_skill_bases comment said that, of several project folders holding the same skill folder, Codeg lists "the copy ZCode uses". Codeg lists the first one in the runtime's folder order, but the runtime looks a skill up by the `name` in its SKILL.md, and resolveDefaultSkillRoots ranks the user roots above every project root, so a global skill with that name is the one ZCode loads. Say exactly that. --- docs/agents/zcode.md | 6 ++++-- src-tauri/src/commands/acp.rs | 4 +++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/agents/zcode.md b/docs/agents/zcode.md index d532dee6aa..f24f61758a 100644 --- a/docs/agents/zcode.md +++ b/docs/agents/zcode.md @@ -41,8 +41,10 @@ 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 -the copy ZCode uses: the one nearest the session cwd, and within one folder the -`.zcode` copy. Of these project folders, the bridge's command list includes only +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 diff --git a/src-tauri/src/commands/acp.rs b/src-tauri/src/commands/acp.rs index a5945c496e..7f4ea9c0c8 100644 --- a/src-tauri/src/commands/acp.rs +++ b/src-tauri/src/commands/acp.rs @@ -9720,7 +9720,9 @@ fn project_skill_dirs( /// 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 it uses. +/// 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 all three upstreams'