Skip to content

Commit 8908371

Browse files
author
The Nerfed Lab
committed
feat(cli): morphis skills install --url <url> --sha256 <hex>
CLI subcommand that downloads a .tar.gz, verifies its sha256, extracts into a tempdir, locates the skill root, and delegates to SkillStore::install_local (so signing-bundle checks and dedup all flow through the existing W8.1 / W8.2 path). Design notes: - --sha256 is required for --url. V0.5 never downloads-without- integrity-check; that contract holds until V1 wires the registry + trust roots which can serve checksums alongside the tarball. - locate_skill_root accepts manifest.yaml at the archive root OR under exactly one top-level subdirectory (cargo-package style). Anything else is malformed. - Case-insensitive + whitespace-trimmed hash comparison so users can paste from `shasum` / `sha256sum` or a release note. Deps added: reqwest (already in workspace via morphis-llm), sha2 / tar / flate2 / tempfile (leaf crates). Tests +6 (offline, no network): sha256 accept matching + trim/case, sha256 reject mismatch, extract tar.gz, locate_skill_root finds root manifest, finds single-subdir manifest, refuses malformed archives. CLI suite 5 -> 11. Signed-off-by: The Nerfed Lab <maintainers@nerfedlab.dev>
1 parent ca3311b commit 8908371

3 files changed

Lines changed: 236 additions & 4 deletions

File tree

‎Cargo.lock‎

Lines changed: 36 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎morphis-cli/Cargo.toml‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,12 @@ morphis-voice = { path = "../morphis-voice" }
2929
morphis-llm = { path = "../morphis-llm" }
3030
morphis-memory = { path = "../morphis-memory" }
3131
morphis-sandbox = { path = "../morphis-sandbox" }
32+
33+
# `morphis skills install --url <url>` — download tarball + verify sha256.
34+
# reqwest comes from the workspace's existing TLS tree (morphis-llm already
35+
# pulled it in); sha2 / tar / flate2 are leaf crates.
36+
reqwest = { workspace = true }
37+
sha2 = "0.10"
38+
tar = "0.4"
39+
flate2 = "1.0"
40+
tempfile = "3.13"

‎morphis-cli/src/main.rs‎

Lines changed: 191 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -80,11 +80,22 @@ enum Command {
8080

8181
#[derive(Subcommand, Debug)]
8282
enum SkillsAction {
83-
/// Install a skill from a local directory.
83+
/// Install a skill from a local directory or a downloadable tarball.
8484
Install {
8585
/// Path to the skill directory (must contain SKILL.md + manifest.yaml).
86+
/// Mutually exclusive with --url.
87+
#[arg(long, conflicts_with = "url", required_unless_present = "url")]
88+
local: Option<std::path::PathBuf>,
89+
/// URL of a .tar.gz containing the skill at its root (i.e. manifest.yaml
90+
/// sits at the top of the archive, not under a subdir).
91+
/// Requires --sha256.
92+
#[arg(long, requires = "sha256")]
93+
url: Option<String>,
94+
/// Hex sha256 of the bytes returned by --url. V0.5 always requires
95+
/// integrity verification — no plain HTTP downloads land without a
96+
/// declared checksum.
8697
#[arg(long)]
87-
local: std::path::PathBuf,
98+
sha256: Option<String>,
8899
},
89100
/// Remove an installed skill by name.
90101
Uninstall {
@@ -174,7 +185,16 @@ fn main() -> anyhow::Result<()> {
174185
VoiceAction::Test => run_voice_test().await?,
175186
},
176187
Command::Skills { action } => match action {
177-
SkillsAction::Install { local } => run_skills_install(&local).await?,
188+
SkillsAction::Install { local, url, sha256 } => match (local, url) {
189+
(Some(path), None) => run_skills_install_local(&path).await?,
190+
(None, Some(url)) => {
191+
let hash = sha256.ok_or_else(|| {
192+
anyhow::anyhow!("--url requires --sha256 for integrity check")
193+
})?;
194+
run_skills_install_url(&url, &hash).await?;
195+
}
196+
_ => anyhow::bail!("specify exactly one of --local or --url"),
197+
},
178198
SkillsAction::Uninstall { name } => run_skills_uninstall(&name).await?,
179199
SkillsAction::List => run_skills_list().await?,
180200
},
@@ -283,7 +303,7 @@ async fn build_llm_router() -> anyhow::Result<Arc<dyn LLMProvider>> {
283303
Ok(Arc::new(router))
284304
}
285305

286-
async fn run_skills_install(src: &std::path::Path) -> anyhow::Result<()> {
306+
async fn run_skills_install_local(src: &std::path::Path) -> anyhow::Result<()> {
287307
let store = SkillStore::default_for_user()?;
288308
let skill = store.install_local(src).await?;
289309
println!(
@@ -295,6 +315,87 @@ async fn run_skills_install(src: &std::path::Path) -> anyhow::Result<()> {
295315
Ok(())
296316
}
297317

318+
async fn run_skills_install_url(url: &str, expected_sha256: &str) -> anyhow::Result<()> {
319+
let bytes = download_to_bytes(url).await?;
320+
verify_sha256(&bytes, expected_sha256)?;
321+
let staging = tempfile::tempdir()?;
322+
extract_tar_gz(&bytes, staging.path())?;
323+
let skill_root = locate_skill_root(staging.path())?;
324+
let store = SkillStore::default_for_user()?;
325+
let skill = store.install_local(&skill_root).await?;
326+
println!(
327+
"installed {} v{} at {} (from {url})",
328+
skill.name(),
329+
skill.version(),
330+
skill.root.display()
331+
);
332+
Ok(())
333+
}
334+
335+
async fn download_to_bytes(url: &str) -> anyhow::Result<Vec<u8>> {
336+
let resp = reqwest::get(url).await?;
337+
let status = resp.status();
338+
if !status.is_success() {
339+
anyhow::bail!("download {url}: HTTP {status}");
340+
}
341+
let bytes = resp.bytes().await?;
342+
Ok(bytes.to_vec())
343+
}
344+
345+
fn verify_sha256(bytes: &[u8], expected_hex: &str) -> anyhow::Result<()> {
346+
use sha2::{Digest, Sha256};
347+
let mut hasher = Sha256::new();
348+
hasher.update(bytes);
349+
let got = hasher.finalize();
350+
let got_hex = hex_encode(&got);
351+
let expected_lc = expected_hex.trim().to_ascii_lowercase();
352+
if got_hex != expected_lc {
353+
anyhow::bail!("sha256 mismatch: declared {expected_lc} but downloaded bytes are {got_hex}");
354+
}
355+
Ok(())
356+
}
357+
358+
fn hex_encode(bytes: &[u8]) -> String {
359+
let mut out = String::with_capacity(bytes.len() * 2);
360+
for b in bytes {
361+
out.push_str(&format!("{b:02x}"));
362+
}
363+
out
364+
}
365+
366+
fn extract_tar_gz(bytes: &[u8], dest: &std::path::Path) -> anyhow::Result<()> {
367+
let gz = flate2::read::GzDecoder::new(bytes);
368+
let mut archive = tar::Archive::new(gz);
369+
archive.set_preserve_permissions(false);
370+
archive.set_overwrite(true);
371+
archive.unpack(dest)?;
372+
Ok(())
373+
}
374+
375+
/// The downloaded tarball may have the skill at its root OR under a single
376+
/// top-level directory (the cargo-package convention). We accept either: if
377+
/// `dest/manifest.yaml` exists, that's the root; else, if there is exactly one
378+
/// subdirectory and it contains manifest.yaml, use that. Anything else is a
379+
/// malformed archive.
380+
fn locate_skill_root(dest: &std::path::Path) -> anyhow::Result<std::path::PathBuf> {
381+
if dest.join("manifest.yaml").is_file() {
382+
return Ok(dest.to_path_buf());
383+
}
384+
let mut subdirs: Vec<std::path::PathBuf> = Vec::new();
385+
for entry in std::fs::read_dir(dest)? {
386+
let entry = entry?;
387+
if entry.file_type()?.is_dir() {
388+
subdirs.push(entry.path());
389+
}
390+
}
391+
if subdirs.len() == 1 && subdirs[0].join("manifest.yaml").is_file() {
392+
return Ok(subdirs.into_iter().next().unwrap());
393+
}
394+
anyhow::bail!(
395+
"downloaded tarball doesn't contain manifest.yaml at root or under a single subdir"
396+
)
397+
}
398+
298399
async fn run_skills_uninstall(name: &str) -> anyhow::Result<()> {
299400
let store = SkillStore::default_for_user()?;
300401
let removed = store.uninstall(name).await?;
@@ -334,3 +435,89 @@ async fn run_voice_test() -> anyhow::Result<()> {
334435
);
335436
Ok(())
336437
}
438+
439+
#[cfg(test)]
440+
mod skill_install_url_tests {
441+
use super::*;
442+
use flate2::write::GzEncoder;
443+
use flate2::Compression;
444+
445+
fn build_tar_gz(entries: &[(&str, &[u8])]) -> Vec<u8> {
446+
let mut gz = GzEncoder::new(Vec::new(), Compression::default());
447+
{
448+
let mut tar = tar::Builder::new(&mut gz);
449+
for (name, body) in entries {
450+
let mut header = tar::Header::new_gnu();
451+
header.set_size(body.len() as u64);
452+
header.set_mode(0o644);
453+
header.set_cksum();
454+
tar.append_data(&mut header, name, *body).unwrap();
455+
}
456+
tar.finish().unwrap();
457+
}
458+
gz.finish().unwrap()
459+
}
460+
461+
#[test]
462+
fn verify_sha256_accepts_matching_hash() {
463+
let bytes = b"morphis skill bytes";
464+
// sha256(b"morphis skill bytes"):
465+
use sha2::{Digest, Sha256};
466+
let mut h = Sha256::new();
467+
h.update(bytes);
468+
let hex = hex_encode(&h.finalize());
469+
assert!(verify_sha256(bytes, &hex).is_ok());
470+
// Case insensitivity + whitespace trim.
471+
let upper = format!(" {} ", hex.to_uppercase());
472+
assert!(verify_sha256(bytes, &upper).is_ok());
473+
}
474+
475+
#[test]
476+
fn verify_sha256_rejects_mismatch() {
477+
let err = verify_sha256(b"x", "deadbeef").unwrap_err();
478+
assert!(err.to_string().contains("sha256 mismatch"));
479+
}
480+
481+
#[test]
482+
fn extract_tar_gz_unpacks_a_skill_at_archive_root() {
483+
let dir = tempfile::tempdir().unwrap();
484+
let bytes = build_tar_gz(&[
485+
(
486+
"manifest.yaml",
487+
b"name: from-url\nversion: 0.1.0\nsandbox: wasmtime\n",
488+
),
489+
("SKILL.md", b"# from-url\nfrom-url body\n"),
490+
]);
491+
extract_tar_gz(&bytes, dir.path()).unwrap();
492+
assert!(dir.path().join("manifest.yaml").is_file());
493+
assert!(dir.path().join("SKILL.md").is_file());
494+
}
495+
496+
#[test]
497+
fn locate_skill_root_finds_root_manifest() {
498+
let dir = tempfile::tempdir().unwrap();
499+
std::fs::write(dir.path().join("manifest.yaml"), "name: x").unwrap();
500+
let root = locate_skill_root(dir.path()).unwrap();
501+
assert_eq!(root, dir.path());
502+
}
503+
504+
#[test]
505+
fn locate_skill_root_finds_single_subdir_manifest() {
506+
let dir = tempfile::tempdir().unwrap();
507+
let sub = dir.path().join("inner-skill");
508+
std::fs::create_dir_all(&sub).unwrap();
509+
std::fs::write(sub.join("manifest.yaml"), "name: y").unwrap();
510+
let root = locate_skill_root(dir.path()).unwrap();
511+
assert_eq!(root, sub);
512+
}
513+
514+
#[test]
515+
fn locate_skill_root_errors_when_archive_is_malformed() {
516+
let dir = tempfile::tempdir().unwrap();
517+
std::fs::create_dir_all(dir.path().join("a")).unwrap();
518+
std::fs::create_dir_all(dir.path().join("b")).unwrap();
519+
// Two subdirs, no root manifest, neither has manifest.yaml — refuse.
520+
let err = locate_skill_root(dir.path()).unwrap_err();
521+
assert!(err.to_string().contains("doesn't contain manifest.yaml"));
522+
}
523+
}

0 commit comments

Comments
 (0)