diff --git a/docs/commands-reference.md b/docs/commands-reference.md index 91ad25e..a693c81 100644 --- a/docs/commands-reference.md +++ b/docs/commands-reference.md @@ -110,6 +110,8 @@ Channel runtime also watches `config.toml` and hot-applies updates to: - `zeroclaw skills install ` - `zeroclaw skills remove ` +`` accepts git remotes (`https://...`, `http://...`, `ssh://...`, and `git@host:owner/repo.git`) or a local filesystem path. + Skill manifests (`SKILL.toml`) support `prompts` and `[[tools]]`; both are injected into the agent system prompt at runtime, so the model can follow skill instructions without manually reading skill files. ### `migrate` diff --git a/src/lib.rs b/src/lib.rs index 40e364e..cdf2801 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -142,9 +142,9 @@ Examples: pub enum SkillCommands { /// List all installed skills List, - /// Install a new skill from a URL or local path + /// Install a new skill from a git URL (HTTPS/SSH) or local path Install { - /// Source URL or local path + /// Source git URL (HTTPS/SSH) or local path source: String, }, /// Remove an installed skill diff --git a/src/main.rs b/src/main.rs index ff41e5b..414a4f5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -598,9 +598,9 @@ enum ChannelCommands { enum SkillCommands { /// List installed skills List, - /// Install a skill from a GitHub URL or local path + /// Install a skill from a git URL (HTTPS/SSH) or local path Install { - /// GitHub URL or local path + /// Git URL (HTTPS/SSH) or local path source: String, }, /// Remove an installed skill diff --git a/src/skills/mod.rs b/src/skills/mod.rs index 4a1edd8..0c6e47c 100644 --- a/src/skills/mod.rs +++ b/src/skills/mod.rs @@ -470,7 +470,7 @@ pub fn init_skills_dir(workspace_dir: &Path) -> Result<()> { The agent will read it and follow the instructions.\n\n\ ## Installing community skills\n\n\ ```bash\n\ - zeroclaw skills install \n\ + zeroclaw skills install \n\ zeroclaw skills list\n\ ```\n", )?; @@ -479,6 +479,50 @@ pub fn init_skills_dir(workspace_dir: &Path) -> Result<()> { Ok(()) } +fn is_git_source(source: &str) -> bool { + is_git_scheme_source(source, "https://") + || is_git_scheme_source(source, "http://") + || is_git_scheme_source(source, "ssh://") + || is_git_scheme_source(source, "git://") + || is_git_scp_source(source) +} + +fn is_git_scheme_source(source: &str, scheme: &str) -> bool { + let Some(rest) = source.strip_prefix(scheme) else { + return false; + }; + if rest.is_empty() || rest.starts_with('/') { + return false; + } + + let host = rest.split(['/', '?', '#']).next().unwrap_or_default(); + !host.is_empty() +} + +fn is_git_scp_source(source: &str) -> bool { + // SCP-like syntax accepted by git, e.g. git@host:owner/repo.git + // Keep this strict enough to avoid treating local paths as git remotes. + let Some((user_host, remote_path)) = source.split_once(':') else { + return false; + }; + if remote_path.is_empty() { + return false; + } + if source.contains("://") { + return false; + } + + let Some((user, host)) = user_host.split_once('@') else { + return false; + }; + !user.is_empty() + && !host.is_empty() + && !user.contains('/') + && !user.contains('\\') + && !host.contains('/') + && !host.contains('\\') +} + /// Recursively copy a directory (used as fallback when symlinks aren't available) #[cfg(any(windows, not(unix)))] fn copy_dir_recursive(src: &Path, dest: &Path) -> Result<()> { @@ -508,7 +552,7 @@ pub fn handle_command(command: crate::SkillCommands, workspace_dir: &Path) -> Re println!(" Create one: mkdir -p ~/.zeroclaw/workspace/skills/my-skill"); println!(" echo '# My Skill' > ~/.zeroclaw/workspace/skills/my-skill/SKILL.md"); println!(); - println!(" Or install: zeroclaw skills install "); + println!(" Or install: zeroclaw skills install "); } else { println!("Installed skills ({}):", skills.len()); println!(); @@ -544,7 +588,7 @@ pub fn handle_command(command: crate::SkillCommands, workspace_dir: &Path) -> Re let skills_path = skills_dir(workspace_dir); std::fs::create_dir_all(&skills_path)?; - if source.starts_with("https://") || source.starts_with("http://") { + if is_git_source(&source) { // Git clone let output = std::process::Command::new("git") .args(["clone", "--depth", "1", &source]) @@ -963,6 +1007,45 @@ description = "Bare minimum" )); } + #[test] + fn git_source_detection_accepts_remote_protocols_and_scp_style() { + let sources = [ + "https://github.com/some-org/some-skill.git", + "http://github.com/some-org/some-skill.git", + "ssh://git@github.com/some-org/some-skill.git", + "git://github.com/some-org/some-skill.git", + "git@github.com:some-org/some-skill.git", + "git@localhost:skills/some-skill.git", + ]; + + for source in sources { + assert!( + is_git_source(source), + "expected git source detection for '{source}'" + ); + } + } + + #[test] + fn git_source_detection_rejects_local_paths_and_invalid_inputs() { + let sources = [ + "./skills/local-skill", + "/tmp/skills/local-skill", + "C:\\skills\\local-skill", + "git@github.com", + "ssh://", + "not-a-url", + "dir/git@github.com:org/repo.git", + ]; + + for source in sources { + assert!( + !is_git_source(source), + "expected local/invalid source detection for '{source}'" + ); + } + } + #[test] fn skills_dir_path() { let base = std::path::Path::new("/home/user/.zeroclaw");