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..76fc2b2 --- /dev/null +++ b/src/skill.rs @@ -0,0 +1,391 @@ +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 skill..."); + + 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(); + + 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 any existing symlink or directory so we can (re)create it + if link_path.symlink_metadata().is_ok() { + if link_path.is_symlink() { + fs::remove_file(link_path).map_err(|e| format!("error removing old symlink: {e}"))?; + } else { + fs::remove_dir_all(link_path) + .map_err(|e| format!("error removing old directory: {e}"))?; + } + } + + // 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); + }); + + let rel_agents = project_agents.strip_prefix(&cwd).unwrap_or(&project_agents); + + 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}:"), 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()), + } + } + } +} + +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 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()), + } + + 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 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."); + } +}