fix(skills): support SSH git remotes for skills install (#1035)
This commit is contained in:
parent
f10bb998e0
commit
db2d9acd22
4 changed files with 92 additions and 7 deletions
|
|
@ -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`
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue