fix(skills): support SSH git remotes for skills install (#1035)

This commit is contained in:
Chummy 2026-02-20 12:25:47 +08:00 committed by GitHub
parent f10bb998e0
commit db2d9acd22
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 92 additions and 7 deletions

View file

@ -110,6 +110,8 @@ Channel runtime also watches `config.toml` and hot-applies updates to:
- `zeroclaw skills install <source>` - `zeroclaw skills install <source>`
- `zeroclaw skills remove <name>` - `zeroclaw skills remove <name>`
`<source>` 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. 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` ### `migrate`

View file

@ -142,9 +142,9 @@ Examples:
pub enum SkillCommands { pub enum SkillCommands {
/// List all installed skills /// List all installed skills
List, 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 { Install {
/// Source URL or local path /// Source git URL (HTTPS/SSH) or local path
source: String, source: String,
}, },
/// Remove an installed skill /// Remove an installed skill

View file

@ -598,9 +598,9 @@ enum ChannelCommands {
enum SkillCommands { enum SkillCommands {
/// List installed skills /// List installed skills
List, List,
/// Install a skill from a GitHub URL or local path /// Install a skill from a git URL (HTTPS/SSH) or local path
Install { Install {
/// GitHub URL or local path /// Git URL (HTTPS/SSH) or local path
source: String, source: String,
}, },
/// Remove an installed skill /// Remove an installed skill

View file

@ -470,7 +470,7 @@ pub fn init_skills_dir(workspace_dir: &Path) -> Result<()> {
The agent will read it and follow the instructions.\n\n\ The agent will read it and follow the instructions.\n\n\
## Installing community skills\n\n\ ## Installing community skills\n\n\
```bash\n\ ```bash\n\
zeroclaw skills install <github-url>\n\ zeroclaw skills install <source>\n\
zeroclaw skills list\n\ zeroclaw skills list\n\
```\n", ```\n",
)?; )?;
@ -479,6 +479,50 @@ pub fn init_skills_dir(workspace_dir: &Path) -> Result<()> {
Ok(()) 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) /// Recursively copy a directory (used as fallback when symlinks aren't available)
#[cfg(any(windows, not(unix)))] #[cfg(any(windows, not(unix)))]
fn copy_dir_recursive(src: &Path, dest: &Path) -> Result<()> { 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!(" Create one: mkdir -p ~/.zeroclaw/workspace/skills/my-skill");
println!(" echo '# My Skill' > ~/.zeroclaw/workspace/skills/my-skill/SKILL.md"); println!(" echo '# My Skill' > ~/.zeroclaw/workspace/skills/my-skill/SKILL.md");
println!(); println!();
println!(" Or install: zeroclaw skills install <github-url>"); println!(" Or install: zeroclaw skills install <source>");
} else { } else {
println!("Installed skills ({}):", skills.len()); println!("Installed skills ({}):", skills.len());
println!(); println!();
@ -544,7 +588,7 @@ pub fn handle_command(command: crate::SkillCommands, workspace_dir: &Path) -> Re
let skills_path = skills_dir(workspace_dir); let skills_path = skills_dir(workspace_dir);
std::fs::create_dir_all(&skills_path)?; std::fs::create_dir_all(&skills_path)?;
if source.starts_with("https://") || source.starts_with("http://") { if is_git_source(&source) {
// Git clone // Git clone
let output = std::process::Command::new("git") let output = std::process::Command::new("git")
.args(["clone", "--depth", "1", &source]) .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] #[test]
fn skills_dir_path() { fn skills_dir_path() {
let base = std::path::Path::new("/home/user/.zeroclaw"); let base = std::path::Path::new("/home/user/.zeroclaw");