From 4cbcbeb7c7985d4b8c114fc32a987df9d45a1f5b Mon Sep 17 00:00:00 2001 From: Paul Thurlow Date: Wed, 11 Mar 2026 13:06:00 -0700 Subject: [PATCH 1/4] skill install command for cli --- Cargo.lock | 96 +++++++++- Cargo.toml | 6 + dist-workspace.toml | 4 + skills/hotdata-cli/SKILL.md | 2 +- src/command.rs | 18 ++ src/main.rs | 9 +- src/skill.rs | 346 ++++++++++++++++++++++++++++++++++++ 7 files changed, 478 insertions(+), 3 deletions(-) create mode 100644 src/skill.rs diff --git a/Cargo.lock b/Cargo.lock index 5d8c477..4b69ae8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "anstream" version = "0.6.21" @@ -217,6 +223,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossterm" version = "0.28.1" @@ -354,12 +369,33 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fnv" version = "1.0.7" @@ -530,13 +566,16 @@ dependencies = [ "crossterm 0.28.1", "directories", "dotenvy", + "flate2", "open", "rand", "reqwest", + "semver", "serde", "serde_json", "serde_yaml", "sha2", + "tar", "tiny_http", ] @@ -859,7 +898,10 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" dependencies = [ + "bitflags", "libc", + "plain", + "redox_syscall 0.7.3", ] [[package]] @@ -913,6 +955,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.1.1" @@ -1033,7 +1085,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link", ] @@ -1068,6 +1120,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "potential_utf" version = "0.1.4" @@ -1159,6 +1217,15 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_syscall" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" +dependencies = [ + "bitflags", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -1457,6 +1524,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + [[package]] name = "slab" version = "0.4.12" @@ -1549,6 +1622,17 @@ dependencies = [ "libc", ] +[[package]] +name = "tar" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "tempfile" version = "3.26.0" @@ -2242,6 +2326,16 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix 1.1.4", +] + [[package]] name = "yoke" version = "0.8.1" diff --git a/Cargo.toml b/Cargo.toml index 46909f6..2e77777 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,10 +27,16 @@ rand = "0.8" sha2 = "0.10" tiny_http = "0.12" comfy-table = "7" +flate2 = "1" +tar = "0.4" +semver = "1" [package.metadata.release] pre-release-hook = ["git", "cliff", "-o", "CHANGELOG.md", "--tag", "{{version}}" ] publish = false +pre-release-replacements = [ + { file = "skills/hotdata-cli/SKILL.md", search = "^version: .+", replace = "version: {{version}}", exactly = 1 }, +] # The profile that 'dist' will build with [profile.dist] diff --git a/dist-workspace.toml b/dist-workspace.toml index 28e1109..4743f41 100644 --- a/dist-workspace.toml +++ b/dist-workspace.toml @@ -20,3 +20,7 @@ install-path = "~/.hotdata/cli" publish-jobs = ["homebrew"] # Whether to install an updater program install-updater = false + +[[dist.extra-artifacts]] +artifacts = ["skills.tar.gz"] +build = ["tar", "-czf", "skills.tar.gz", "skills/"] diff --git a/skills/hotdata-cli/SKILL.md b/skills/hotdata-cli/SKILL.md index 71faaad..b4bcae2 100644 --- a/skills/hotdata-cli/SKILL.md +++ b/skills/hotdata-cli/SKILL.md @@ -1,7 +1,7 @@ --- name: hotdata-cli description: Use this skill when the user wants to run hotdata CLI commands, query the HotData API, list workspaces, list connections, list tables, execute SQL queries, or interact with the hotdata service. Activate when the user says "run hotdata", "query hotdata", "list workspaces", "list connections", "list tables", "execute a query", or asks you to use the hotdata CLI. -version: 1.0.0 +version: 0.1.3 --- # HotData CLI Skill diff --git a/src/command.rs b/src/command.rs index 9d29fea..28c48ba 100644 --- a/src/command.rs +++ b/src/command.rs @@ -62,6 +62,12 @@ pub enum Commands { command: TablesCommands, }, + /// Manage the hotdata-cli agent skill + Skill { + #[command(subcommand)] + command: SkillCommands, + }, + /// Retrieve a stored query result by ID Results { /// Result ID @@ -428,6 +434,18 @@ pub enum ConnectionsCommands { }, } +#[derive(Subcommand)] +pub enum SkillCommands { + /// Install or update the hotdata-cli skill into agent directories + Install { + /// Install into the current project directory instead of globally + #[arg(long)] + project: bool, + }, + /// Show the installation status of the hotdata-cli skill + Status, +} + #[derive(Subcommand)] pub enum TablesCommands { /// List all tables in a workspace diff --git a/src/main.rs b/src/main.rs index d90c761..ffe841d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,13 +5,14 @@ mod connections; mod init; mod query; mod results; +mod skill; mod tables; mod util; mod workspace; use anstyle::AnsiColor; use clap::{Parser, builder::Styles}; -use command::{AuthCommands, Commands, ConnectionsCommands, TablesCommands, WorkspaceCommands}; +use command::{AuthCommands, Commands, ConnectionsCommands, SkillCommands, TablesCommands, WorkspaceCommands}; #[derive(Parser)] #[command(name = "hotdata", version, about = concat!("HotData CLI - Command line interface for HotData (v", env!("CARGO_PKG_VERSION"), ")"), long_about = None, disable_version_flag = true)] @@ -82,6 +83,12 @@ fn main() { tables::list(&workspace_id, connection_id.as_deref(), schema.as_deref(), table.as_deref(), limit, cursor.as_deref(), &format) } }, + Commands::Skill { command } => match command { + SkillCommands::Install { project } => { + if project { skill::install_project() } else { skill::install() } + } + SkillCommands::Status => skill::status(), + }, Commands::Results { result_id, workspace_id, format } => { let workspace_id = resolve_workspace(workspace_id); results::get(&result_id, &workspace_id, &format) diff --git a/src/skill.rs b/src/skill.rs new file mode 100644 index 0000000..541c9ba --- /dev/null +++ b/src/skill.rs @@ -0,0 +1,346 @@ +use crossterm::style::Stylize; +use directories::UserDirs; +use semver::Version; +use std::fs; +use std::path::PathBuf; + +const REPO: &str = "hotdata-dev/hotdata-cli"; +const SKILL_NAME: &str = "hotdata-cli"; +const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// Agent root directories to check for symlink installation. +/// If the root dir exists, we create /skills/hotdata-cli -> ~/.agents/skills/hotdata-cli +const AGENT_ROOTS: &[&str] = &[".claude", ".pi"]; + +fn home_dir() -> PathBuf { + UserDirs::new() + .expect("could not determine home directory") + .home_dir() + .to_path_buf() +} + +/// The canonical install location: ~/.agents/skills/hotdata-cli +/// Source of truth: ~/.hotdata/skills/hotdata-cli +fn skill_store_path() -> PathBuf { + home_dir().join(".hotdata").join("skills").join(SKILL_NAME) +} + +/// Canonical agents layer: ~/.agents/skills/hotdata-cli +fn agents_skill_path() -> PathBuf { + home_dir().join(".agents").join("skills").join(SKILL_NAME) +} + +fn agents_lock_path() -> PathBuf { + home_dir().join(".agents").join(".skill-lock.json") +} + +fn download_url() -> String { + format!("https://github.com/{REPO}/releases/download/v{CURRENT_VERSION}/skills.tar.gz") +} + +/// Returns agent skill paths for all agent roots that exist on disk. +fn detected_agent_skill_paths() -> Vec<(String, PathBuf)> { + let home = home_dir(); + AGENT_ROOTS + .iter() + .filter_map(|root| { + let root_path = home.join(root); + if root_path.exists() { + Some((root.to_string(), root_path.join("skills").join(SKILL_NAME))) + } else { + None + } + }) + .collect() +} + +fn parse_version_from_skill_md(content: &str) -> Option { + let inner = content.strip_prefix("---\n")?.split("\n---").next()?; + for line in inner.lines() { + if let Some(v) = line.strip_prefix("version:") { + return Version::parse(v.trim()).ok(); + } + } + None +} + +fn read_installed_version() -> Option { + let content = fs::read_to_string(skill_store_path().join("SKILL.md")).ok()?; + parse_version_from_skill_md(&content) +} + +fn is_managed_by_skills_agent() -> bool { + let content = match fs::read_to_string(agents_lock_path()) { + Ok(c) => c, + Err(_) => return false, + }; + let json: serde_json::Value = match serde_json::from_str(&content) { + Ok(v) => v, + Err(_) => return false, + }; + json.get(SKILL_NAME).is_some() +} + +fn download_and_extract() -> Result<(), String> { + let url = download_url(); + println!("Downloading from {url}..."); + + let client = reqwest::blocking::Client::new(); + let resp = client + .get(&url) + .send() + .map_err(|e| format!("error downloading skill: {e}"))?; + + if !resp.status().is_success() { + return Err(format!("error downloading skill: HTTP {}", resp.status())); + } + + let bytes = resp.bytes().map_err(|e| format!("error reading response: {e}"))?; + + // Extract into ~/.hotdata/skills/ + let store_dir = home_dir().join(".hotdata").join("skills"); + fs::create_dir_all(&store_dir).map_err(|e| format!("error creating directory: {e}"))?; + + let gz = flate2::read::GzDecoder::new(std::io::Cursor::new(bytes)); + let mut archive = tar::Archive::new(gz); + + for entry in archive.entries().map_err(|e| format!("error reading archive: {e}"))? { + let mut entry = entry.map_err(|e| format!("error reading archive entry: {e}"))?; + let path = entry.path().map_err(|e| format!("error reading entry path: {e}"))?.into_owned(); + + // Strip leading "skills/" prefix so we extract into store_dir + let rel = match path.strip_prefix("skills/") { + Ok(r) if !r.as_os_str().is_empty() => r.to_path_buf(), + _ => continue, + }; + + let dest = store_dir.join(&rel); + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent).map_err(|e| format!("error creating directory: {e}"))?; + } + entry.unpack(&dest).map_err(|e| format!("error extracting {}: {e}", rel.display()))?; + } + + Ok(()) +} + + +fn copy_dir_recursive(src: &PathBuf, dst: &PathBuf) -> Result<(), String> { + fs::create_dir_all(dst).map_err(|e| format!("error creating directory: {e}"))?; + for entry in fs::read_dir(src).map_err(|e| format!("error reading directory: {e}"))? { + let entry = entry.map_err(|e| format!("error reading entry: {e}"))?; + let dest = dst.join(entry.file_name()); + if entry.file_type().map_err(|e| format!("{e}"))?.is_dir() { + copy_dir_recursive(&entry.path(), &dest)?; + } else { + fs::copy(entry.path(), &dest).map_err(|e| format!("error copying file: {e}"))?; + } + } + Ok(()) +} + +fn ensure_symlink_or_copy(src: &PathBuf, link_path: &PathBuf) -> Result { + if let Some(parent) = link_path.parent() { + fs::create_dir_all(parent) + .map_err(|e| format!("error creating {}: {e}", parent.display()))?; + } + + // Remove existing symlink (handles updates); leave real directories alone + if link_path.symlink_metadata().is_ok() && link_path.is_symlink() { + fs::remove_file(link_path) + .map_err(|e| format!("error removing old symlink: {e}"))?; + } + + if link_path.exists() { + return Ok(false); // real directory, leave it + } + + // Try symlink first, fall back to copy + #[cfg(unix)] + match std::os::unix::fs::symlink(src, link_path) { + Ok(_) => return Ok(true), + Err(_) => {} + } + + #[cfg(windows)] + match std::os::windows::fs::symlink_dir(src, link_path) { + Ok(_) => return Ok(true), + Err(_) => {} + } + + copy_dir_recursive(src, link_path)?; + Ok(false) // false = copied, not symlinked +} + +fn ensure_symlinks() -> Vec<(String, PathBuf, Result)> { + let store_path = skill_store_path(); + let agents_path = agents_skill_path(); + let mut results = Vec::new(); + + // First: ~/.agents/skills/hotdata-cli -> ~/.hotdata/skills/hotdata-cli + let agents_result = ensure_symlink_or_copy(&store_path, &agents_path); + results.push(("~/.agents".to_string(), agents_path.clone(), agents_result)); + + // Then: each detected agent root -> ~/.agents/skills/hotdata-cli + for (root, link_path) in detected_agent_skill_paths() { + let result = ensure_symlink_or_copy(&agents_path, &link_path); + results.push((format!("~/{root}"), link_path, result)); + } + + results +} + +pub fn install_project() { + let current = Version::parse(CURRENT_VERSION).expect("invalid package version"); + let store_path = skill_store_path(); + + // Ensure skill files exist locally first + match read_installed_version() { + Some(ref v) if *v >= current => {} + Some(ref v) => { + println!("{}", format!("Global skill is outdated (v{v}), downloading v{current} first...").yellow()); + if let Err(e) = download_and_extract() { + eprintln!("{}", e.red()); + std::process::exit(1); + } + } + None => { + println!("Skill not installed globally, downloading v{current}..."); + if let Err(e) = download_and_extract() { + eprintln!("{}", e.red()); + std::process::exit(1); + } + } + } + + let cwd = std::env::current_dir().expect("could not determine current directory"); + let project_agents = cwd.join(".agents").join("skills").join(SKILL_NAME); + + // Always copy (not symlink) from store to .agents/skills/hotdata-cli + if project_agents.exists() { + fs::remove_dir_all(&project_agents) + .unwrap_or_else(|e| { eprintln!("{}", format!("error removing existing directory: {e}").red()); std::process::exit(1); }); + } + if let Some(parent) = project_agents.parent() { + fs::create_dir_all(parent).unwrap_or_else(|e| { eprintln!("{}", format!("error creating directory: {e}").red()); std::process::exit(1); }); + } + copy_dir_recursive(&store_path, &project_agents).unwrap_or_else(|e| { eprintln!("{}", e.red()); std::process::exit(1); }); + + println!("{}", format!("Skill installed to project (v{current}).").green()); + println!("{:<20}{}", "Location:", project_agents.display().to_string().cyan()); + + // For .claude and .pi in cwd: symlink (fallback copy) from .agents/skills/hotdata-cli + for root in AGENT_ROOTS { + let root_path = cwd.join(root); + if root_path.exists() { + let link_path = root_path.join("skills").join(SKILL_NAME); + match ensure_symlink_or_copy(&project_agents, &link_path) { + Ok(true) => println!("{:<20}{}", format!("~/{root}:"), link_path.display().to_string().cyan()), + Ok(false) => println!("{:<20}{} (copied)", format!("~/{root}:"), link_path.display().to_string().cyan()), + Err(e) => eprintln!("{}", format!("~/{root}: failed: {e}").red()), + } + } + } +} + +pub fn install() { + let current = Version::parse(CURRENT_VERSION).expect("invalid package version"); + + if is_managed_by_skills_agent() { + match read_installed_version() { + Some(ref v) if *v >= current => { + println!("Managed by skills agent — already up to date (v{v})."); + return; + } + Some(ref v) => { + println!("{}", format!("Managed by skills agent — updating from v{v} to v{current}...").yellow()); + } + None => { + println!("Managed by skills agent — skipping."); + return; + } + } + } else { + match read_installed_version() { + Some(ref v) if *v >= current => { + println!("Already up to date (v{v})."); + return; + } + Some(ref v) => println!("Updating from v{v} to v{current}..."), + None => println!("Installing hotdata-cli skill v{current}..."), + } + } + + if let Err(e) = download_and_extract() { + eprintln!("{}", e.red()); + std::process::exit(1); + } + + let symlinks = ensure_symlinks(); + + println!("{}", format!("Skill installed successfully (v{current}).").green()); + println!("{:<20}{}", "Location:", skill_store_path().display()); + + for (label, path, result) in &symlinks { + let status = match result { + Ok(true) => format!("{} (symlinked)", path.display().to_string().cyan()), + Ok(false) => format!("{} (copied)", path.display().to_string().cyan()), + Err(e) => format!("failed: {e}").red().to_string(), + }; + println!("{:<20}{}", format!("{label}:"), status); + } +} + +pub fn status() { + let store_path = skill_store_path(); + let current = Version::parse(CURRENT_VERSION).expect("invalid package version"); + + let managed = is_managed_by_skills_agent(); + let installed_version = read_installed_version(); + let exists = store_path.exists(); + + fn row(label: &str, value: &str) { + println!("{:<20}{}", format!("{label}:"), value); + } + + if !exists { + row("Installed", &"No".red().to_string()); + println!("\nRun 'hotdata skill install' to install."); + return; + } + + row("Installed", &"Yes".green().to_string()); + + match &installed_version { + Some(v) if *v < current => { + row("Version", &format!("{} (outdated, current is v{current})", v.to_string().yellow())); + } + Some(v) => row("Version", &v.to_string().green().to_string()), + None => row("Version", &"unknown".dark_grey().to_string()), + } + + row("Location", &store_path.display().to_string().cyan().to_string()); + row("Managed by", &if managed { "skills agent".to_string() } else { "direct".dark_grey().to_string() }); + + // Show symlink status for each agent root + let home = home_dir(); + for root in AGENT_ROOTS { + let root_path = home.join(root); + let link_path = root_path.join("skills").join(SKILL_NAME); + let label = format!("~/{root}"); + + if !root_path.exists() { + row(&label, &"not installed".dark_grey().to_string()); + } else if link_path.is_symlink() { + row(&label, &link_path.display().to_string().cyan().to_string()); + } else if link_path.exists() { + row(&label, &"installed (not symlinked)".yellow().to_string()); + } else { + row(&label, &"agent detected, not symlinked".yellow().to_string()); + } + } + + if installed_version.map_or(false, |v| v < current) { + println!("\nRun 'hotdata skill install' to update."); + } +} From 2c06ffd8971074ee14bd1c92d6c711f1782f64f8 Mon Sep 17 00:00:00 2001 From: Paul Thurlow Date: Wed, 11 Mar 2026 14:17:59 -0700 Subject: [PATCH 2/4] clean skill directories on new install --- src/skill.rs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/skill.rs b/src/skill.rs index 541c9ba..0382bee 100644 --- a/src/skill.rs +++ b/src/skill.rs @@ -108,7 +108,6 @@ fn download_and_extract() -> Result<(), String> { let mut entry = entry.map_err(|e| format!("error reading archive entry: {e}"))?; let path = entry.path().map_err(|e| format!("error reading entry path: {e}"))?.into_owned(); - // Strip leading "skills/" prefix so we extract into store_dir let rel = match path.strip_prefix("skills/") { Ok(r) if !r.as_os_str().is_empty() => r.to_path_buf(), _ => continue, @@ -145,14 +144,15 @@ fn ensure_symlink_or_copy(src: &PathBuf, link_path: &PathBuf) -> Result Date: Wed, 11 Mar 2026 14:52:24 -0700 Subject: [PATCH 3/4] update download print statement --- src/skill.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/skill.rs b/src/skill.rs index 0382bee..407d5d4 100644 --- a/src/skill.rs +++ b/src/skill.rs @@ -83,7 +83,7 @@ fn is_managed_by_skills_agent() -> bool { fn download_and_extract() -> Result<(), String> { let url = download_url(); - println!("Downloading from {url}..."); + println!("Downloading skill..."); let client = reqwest::blocking::Client::new(); let resp = client From 498e53fdb963896862a39e1874af587b1d2ecd11 Mon Sep 17 00:00:00 2001 From: Paul Thurlow Date: Wed, 11 Mar 2026 15:03:33 -0700 Subject: [PATCH 4/4] Clean up status and install messages --- src/skill.rs | 119 +++++++++++++++++++++++++++++++++++---------------- 1 file changed, 82 insertions(+), 37 deletions(-) diff --git a/src/skill.rs b/src/skill.rs index 407d5d4..76fc2b2 100644 --- a/src/skill.rs +++ b/src/skill.rs @@ -95,7 +95,9 @@ fn download_and_extract() -> Result<(), String> { return Err(format!("error downloading skill: HTTP {}", resp.status())); } - let bytes = resp.bytes().map_err(|e| format!("error reading response: {e}"))?; + let bytes = resp + .bytes() + .map_err(|e| format!("error reading response: {e}"))?; // Extract into ~/.hotdata/skills/ let store_dir = home_dir().join(".hotdata").join("skills"); @@ -104,9 +106,15 @@ fn download_and_extract() -> Result<(), String> { let gz = flate2::read::GzDecoder::new(std::io::Cursor::new(bytes)); let mut archive = tar::Archive::new(gz); - for entry in archive.entries().map_err(|e| format!("error reading archive: {e}"))? { + for entry in archive + .entries() + .map_err(|e| format!("error reading archive: {e}"))? + { let mut entry = entry.map_err(|e| format!("error reading archive entry: {e}"))?; - let path = entry.path().map_err(|e| format!("error reading entry path: {e}"))?.into_owned(); + let path = entry + .path() + .map_err(|e| format!("error reading entry path: {e}"))? + .into_owned(); let rel = match path.strip_prefix("skills/") { Ok(r) if !r.as_os_str().is_empty() => r.to_path_buf(), @@ -117,13 +125,14 @@ fn download_and_extract() -> Result<(), String> { if let Some(parent) = dest.parent() { fs::create_dir_all(parent).map_err(|e| format!("error creating directory: {e}"))?; } - entry.unpack(&dest).map_err(|e| format!("error extracting {}: {e}", rel.display()))?; + entry + .unpack(&dest) + .map_err(|e| format!("error extracting {}: {e}", rel.display()))?; } Ok(()) } - fn copy_dir_recursive(src: &PathBuf, dst: &PathBuf) -> Result<(), String> { fs::create_dir_all(dst).map_err(|e| format!("error creating directory: {e}"))?; for entry in fs::read_dir(src).map_err(|e| format!("error reading directory: {e}"))? { @@ -147,8 +156,7 @@ fn ensure_symlink_or_copy(src: &PathBuf, link_path: &PathBuf) -> Result= current => {} Some(ref v) => { - println!("{}", format!("Global skill is outdated (v{v}), downloading v{current} first...").yellow()); + println!( + "{}", + format!("Global skill is outdated (v{v}), downloading v{current} first...") + .yellow() + ); if let Err(e) = download_and_extract() { eprintln!("{}", e.red()); std::process::exit(1); @@ -218,26 +230,43 @@ pub fn install_project() { // Always copy (not symlink) from store to .agents/skills/hotdata-cli if project_agents.exists() { - fs::remove_dir_all(&project_agents) - .unwrap_or_else(|e| { eprintln!("{}", format!("error removing existing directory: {e}").red()); std::process::exit(1); }); + fs::remove_dir_all(&project_agents).unwrap_or_else(|e| { + eprintln!( + "{}", + format!("error removing existing directory: {e}").red() + ); + std::process::exit(1); + }); } if let Some(parent) = project_agents.parent() { - fs::create_dir_all(parent).unwrap_or_else(|e| { eprintln!("{}", format!("error creating directory: {e}").red()); std::process::exit(1); }); + fs::create_dir_all(parent).unwrap_or_else(|e| { + eprintln!("{}", format!("error creating directory: {e}").red()); + std::process::exit(1); + }); } - copy_dir_recursive(&store_path, &project_agents).unwrap_or_else(|e| { eprintln!("{}", e.red()); std::process::exit(1); }); + copy_dir_recursive(&store_path, &project_agents).unwrap_or_else(|e| { + eprintln!("{}", e.red()); + std::process::exit(1); + }); + + let rel_agents = project_agents.strip_prefix(&cwd).unwrap_or(&project_agents); - println!("{}", format!("Skill installed to project (v{current}).").green()); - println!("{:<20}{}", "Location:", project_agents.display().to_string().cyan()); + println!( + "{}", + format!("Skill installed to project (v{current}).").green() + ); + println!("{:<20}{}", "Location:", rel_agents.display().to_string().cyan()); // For .claude and .pi in cwd: symlink (fallback copy) from .agents/skills/hotdata-cli for root in AGENT_ROOTS { let root_path = cwd.join(root); if root_path.exists() { let link_path = root_path.join("skills").join(SKILL_NAME); + let rel_link = link_path.strip_prefix(&cwd).unwrap_or(&link_path); match ensure_symlink_or_copy(&project_agents, &link_path) { - Ok(true) => println!("{:<20}{}", format!("~/{root}:"), link_path.display().to_string().cyan()), - Ok(false) => println!("{:<20}{} (copied)", format!("~/{root}:"), link_path.display().to_string().cyan()), - Err(e) => eprintln!("{}", format!("~/{root}: failed: {e}").red()), + Ok(true) => println!("{:<20}{}", format!("./{root}:"), rel_link.display().to_string().cyan()), + Ok(false) => println!("{:<20}{} (copied)", format!("./{root}:"), rel_link.display().to_string().cyan()), + Err(e) => eprintln!("{}", format!("./{root}: failed: {e}").red()), } } } @@ -253,7 +282,11 @@ pub fn install() { return; } Some(ref v) => { - println!("{}", format!("Managed by skills agent — updating from v{v} to v{current}...").yellow()); + println!( + "{}", + format!("Managed by skills agent — updating from v{v} to v{current}...") + .yellow() + ); } None => { println!("Managed by skills agent — skipping."); @@ -278,7 +311,10 @@ pub fn install() { let symlinks = ensure_symlinks(); - println!("{}", format!("Skill installed successfully (v{current}).").green()); + println!( + "{}", + format!("Skill installed successfully (v{current}).").green() + ); println!("{:<20}{}", "Location:", skill_store_path().display()); for (label, path, result) in &symlinks { @@ -295,7 +331,6 @@ pub fn status() { let store_path = skill_store_path(); let current = Version::parse(CURRENT_VERSION).expect("invalid package version"); - let managed = is_managed_by_skills_agent(); let installed_version = read_installed_version(); let exists = store_path.exists(); @@ -313,33 +348,43 @@ pub fn status() { match &installed_version { Some(v) if *v < current => { - row("Version", &format!("{} (outdated, current is v{current})", v.to_string().yellow())); + row( + "Version", + &format!( + "{} (outdated, current is v{current})", + v.to_string().yellow() + ), + ); } Some(v) => row("Version", &v.to_string().green().to_string()), None => row("Version", &"unknown".dark_grey().to_string()), } - row("Location", &store_path.display().to_string().cyan().to_string()); - row("Managed by", &if managed { "skills agent".to_string() } else { "direct".dark_grey().to_string() }); - - // Show symlink status for each agent root let home = home_dir(); + + // Collect installed agent skill paths + let agents_path = agents_skill_path(); + let mut installed_agents: Vec = Vec::new(); + + if agents_path.exists() { + installed_agents.push("~/.agents".to_string()); + } for root in AGENT_ROOTS { - let root_path = home.join(root); - let link_path = root_path.join("skills").join(SKILL_NAME); - let label = format!("~/{root}"); - - if !root_path.exists() { - row(&label, &"not installed".dark_grey().to_string()); - } else if link_path.is_symlink() { - row(&label, &link_path.display().to_string().cyan().to_string()); - } else if link_path.exists() { - row(&label, &"installed (not symlinked)".yellow().to_string()); - } else { - row(&label, &"agent detected, not symlinked".yellow().to_string()); + let link_path = home.join(root).join("skills").join(SKILL_NAME); + if link_path.exists() { + installed_agents.push(format!("~/{root}")); } } + if installed_agents.is_empty() { + row("Agent Skills", &"none".dark_grey().to_string()); + } else { + row( + "Agent Skills Added", + &installed_agents.join(", ").cyan().to_string(), + ); + } + if installed_version.map_or(false, |v| v < current) { println!("\nRun 'hotdata skill install' to update."); }