From 8bbe833058f74a3d4ba755cec43047f9992dd006 Mon Sep 17 00:00:00 2001 From: Eddie A Tejeda <669988+eddietejeda@users.noreply.github.com> Date: Wed, 29 Apr 2026 08:20:46 -0700 Subject: [PATCH 1/6] feat(skills): add optional geospatial agent skill Ships a separate hotdata-geospatial skill and installs it alongside the base hotdata skill so agents can load geospatial guidance only when relevant. --- skills/hotdata-geospatial/SKILL.md | 282 +++++++++++++++++++++++++++++ src/skill.rs | 198 ++++++++++++-------- 2 files changed, 402 insertions(+), 78 deletions(-) create mode 100644 skills/hotdata-geospatial/SKILL.md diff --git a/skills/hotdata-geospatial/SKILL.md b/skills/hotdata-geospatial/SKILL.md new file mode 100644 index 0000000..9ce5239 --- /dev/null +++ b/skills/hotdata-geospatial/SKILL.md @@ -0,0 +1,282 @@ +--- +name: hotdata-geospatial +description: Use this skill only when the user is working with geospatial data in Hotdata (PostGIS-style SQL like ST_* functions, geometry/WKB, bbox filtering, point-in-polygon, distance/area, lat/lon, spatial joins, “geospatial”, “GIS”, “PostGIS”). Do not load this skill for non-geospatial SQL or general Hotdata usage. +version: 0.1.14 +--- + +# Hotdata Geospatial Skill + +Use this skill when working with geospatial data in Hotdata. Hotdata supports a subset of PostGIS-style functions using **PostgreSQL dialect SQL**. This reference is dataset-agnostic — apply it to any table with geometry columns. + +--- + +## Geometry Columns + +Most geospatial datasets in Hotdata carry one or both of: + +| Column | Type | Description | +|---|---|---| +| `wkb_geometry` | `Binary` | WKB-encoded geometry (polygon, point, multipolygon, etc.) | +| `wkb_geometry_bbox` | `Struct` | Precomputed bounding box with fields `xmin`, `ymin`, `xmax`, `ymax` (Float32) | + +**Always parse `wkb_geometry` with `ST_GeomFromWKB()` before using it in any spatial function:** + +```sql +ST_GeomFromWKB(wkb_geometry) +``` + +**Access `wkb_geometry_bbox` fields with bracket notation** (dot access is not supported): + +```sql +wkb_geometry_bbox['xmin'] -- ✓ works +(wkb_geometry_bbox).xmin -- ✗ not supported +``` + +Discover geometry columns with: + +```sql +hotdata tables list --connection-id +``` + +--- + +## Supported Functions + +### Input / Construction + +| Function | Example | +|---|---| +| `ST_GeomFromWKB(col)` | `ST_GeomFromWKB(wkb_geometry)` | +| `ST_GeomFromText(wkt)` | `ST_GeomFromText('POLYGON((...))')` | +| `ST_MakePoint(lon, lat)` | `ST_MakePoint(-122.27, 37.80)` | + +### Output + +| Function | Example | +|---|---| +| `ST_AsText(geom)` | `ST_AsText(ST_GeomFromWKB(wkb_geometry))` → WKT string | +| `ST_AsBinary(geom)` | `ST_AsBinary(ST_GeomFromWKB(wkb_geometry))` → WKB binary | + +### Accessors / Inspection + +| Function | Returns | +|---|---| +| `ST_GeometryType(geom)` | e.g. `ST_Polygon`, `ST_MultiPolygon`, `ST_Point` | +| `ST_IsValid(geom)` | boolean | +| `ST_NumPoints(geom)` | integer | +| `ST_NPoints(geom)` | integer (alias for ST_NumPoints) | +| `ST_X(point)` | longitude (float) | +| `ST_Y(point)` | latitude (float) | +| `ST_Centroid(geom)` | point geometry | + +### Measurement + +| Function | Unit | Notes | +|---|---|---| +| `ST_Area(geom)` | degrees² | Multiply by `111000 * 111000` for m², then `* 10.7639` for ft² | +| `ST_Length(geom)` | degrees | Multiply by `111000` for approximate meters | +| `ST_Distance(geom_a, geom_b)` | degrees | Multiply by `111000` for approximate meters | + +> **No meter-native measurements:** `::geography` cast is not supported. All measurements are in decimal degrees. The conversion factor ~111,000 m/degree is accurate at mid-latitudes (~30–50°N/S) and degrades toward the poles. + +### Spatial Relationships + +All return `boolean`: + +| Function | Meaning | +|---|---| +| `ST_Within(a, b)` | `a` is completely inside `b` | +| `ST_Contains(a, b)` | `a` contains `b` | +| `ST_Covers(a, b)` | `a` covers `b` (includes boundary) | +| `ST_CoveredBy(a, b)` | `a` is covered by `b` | +| `ST_Intersects(a, b)` | geometries share any space | +| `ST_Overlaps(a, b)` | geometries overlap (same dimension) | +| `ST_Touches(a, b)` | share boundary only, no interior overlap | +| `ST_Crosses(a, b)` | geometries cross (different dimensions) | +| `ST_Disjoint(a, b)` | geometries share no space | +| `ST_Equals(a, b)` | geometries are spatially identical | + +### Processing / Geometry Operations + +| Function | Notes | +|---|---| +| `ST_ConvexHull(geom)` | Returns convex hull polygon | +| `ST_Simplify(geom, tolerance)` | Douglas-Peucker simplification; tolerance in degrees | +| `ST_OrientedEnvelope(geom)` | Minimum oriented bounding box | + +--- + +## Not Supported + +| Category | Not Supported | Workaround | +|---|---|---| +| Output | `ST_AsGeoJSON`, `ST_AsEWKT` | Use `ST_AsText`; parse WKT client-side | +| Cast | `::geography` | Multiply degrees by ~111,000 for meters | +| Input | `ST_MakeEnvelope`, `ST_GeomFromGeoJSON`, `ST_MakeLine` | Use `ST_GeomFromText('POLYGON(...)')` for envelopes | +| Accessors | `ST_SRID`, `ST_IsEmpty`, `ST_NumGeometries`, `ST_GeometryN`, `ST_ExteriorRing`, `ST_PointN`, `ST_StartPoint`, `ST_EndPoint` | — | +| Measurement | `ST_Perimeter`, `ST_MaxDistance` | — | +| Relationships | `ST_DWithin` | Use `ST_Within` + `ST_GeomFromText('POLYGON(...)')` | +| Processing | `ST_Buffer`, `ST_Envelope`, `ST_Boundary`, `ST_Union`, `ST_Intersection`, `ST_Difference`, `ST_SymDifference`, `ST_Collect`, `ST_ClosestPoint`, `ST_Snap`, `ST_BoundingDiagonal`, `ST_Expand` | Use `ST_OrientedEnvelope` instead of `ST_Envelope` | +| Projection | `ST_Transform`, `ST_SetSRID`, `ST_FlipCoordinates` | — | + +--- + +## Common Patterns + +### Check geometry types in a table + +```sql +SELECT ST_GeometryType(ST_GeomFromWKB(wkb_geometry)) AS geom_type, COUNT(*) +FROM +WHERE wkb_geometry IS NOT NULL +GROUP BY 1 +``` + +### Bounding box filter (replaces ST_MakeEnvelope / ST_DWithin) + +Use `ST_GeomFromText` with a closed WKT polygon ring: + +```sql +WHERE ST_Within( + ST_Centroid(ST_GeomFromWKB(wkb_geometry)), + ST_GeomFromText('POLYGON((minLon minLat, maxLon minLat, maxLon maxLat, minLon maxLat, minLon minLat))') +) +``` + +**Vertex order:** `(minLon minLat, maxLon minLat, maxLon maxLat, minLon maxLat, minLon minLat)` — close the ring by repeating the first point. + +**Faster alternative** using the precomputed bbox struct (no WKB parsing): + +```sql +WHERE wkb_geometry_bbox['xmin'] >= + AND wkb_geometry_bbox['xmax'] <= + AND wkb_geometry_bbox['ymin'] >= + AND wkb_geometry_bbox['ymax'] <= +``` + +Use the bbox approach for large tables where WKB parsing is expensive; use `ST_Within` when you need centroid-in-polygon precision. + +### Point-in-polygon test + +```sql +SELECT * +FROM
+WHERE ST_Contains( + ST_GeomFromWKB(wkb_geometry), + ST_MakePoint(, ) +) +``` + +### Nearest neighbors (closest N features to a point) + +```sql +SELECT + , + ST_Distance( + ST_Centroid(ST_GeomFromWKB(wkb_geometry)), + ST_MakePoint(, ) + ) * 111000 AS dist_meters +FROM
+WHERE wkb_geometry IS NOT NULL +ORDER BY dist_meters +LIMIT 10 +``` + +### Distance between two known points + +```sql +SELECT + ST_Distance(ST_MakePoint(, ), ST_MakePoint(, )) * 111000 AS dist_meters, + ST_Distance(ST_MakePoint(, ), ST_MakePoint(, )) * 69.0 AS dist_miles +``` + +### Area of polygon features + +```sql +SELECT + , + ST_Area(ST_GeomFromWKB(wkb_geometry)) * 111000 * 111000 AS area_sqm, + ST_Area(ST_GeomFromWKB(wkb_geometry)) * 111000 * 111000 * 10.7639 AS area_sqft, + ST_Area(ST_GeomFromWKB(wkb_geometry)) * 111000 * 111000 / 4047 AS area_acres +FROM
+WHERE wkb_geometry IS NOT NULL +``` + +### Centroid coordinates + +```sql +SELECT + , + ST_X(ST_Centroid(ST_GeomFromWKB(wkb_geometry))) AS lon, + ST_Y(ST_Centroid(ST_GeomFromWKB(wkb_geometry))) AS lat +FROM
+WHERE wkb_geometry IS NOT NULL +``` + +### Convert to WKT for export or inspection + +```sql +SELECT , ST_AsText(ST_GeomFromWKB(wkb_geometry)) AS wkt +FROM
+WHERE wkb_geometry IS NOT NULL +LIMIT 10 +``` + +### Simplify geometry for faster rendering + +```sql +SELECT , ST_AsText(ST_Simplify(ST_GeomFromWKB(wkb_geometry), 0.0001)) AS simplified_wkt +FROM
+WHERE wkb_geometry IS NOT NULL +``` + +Tolerance is in degrees (~11 m at mid-latitudes). Increase for coarser simplification, decrease for finer. + +--- + +## Unit Conversion Reference + +| To get | Multiply degrees by | +|---|---| +| Meters (distance) | × 111,000 | +| Kilometers (distance) | × 111 | +| Miles (distance) | × 69.0 | +| Feet (distance) | × 364,173 | +| m² (area) | × 111,000² = × 12,321,000,000 | +| ft² (area) | × 111,000² × 10.7639 | +| Acres (area) | × 111,000² ÷ 4,047 | + +> These conversions assume ~37°N latitude. They are approximations — accuracy decreases significantly above 60°N or below 60°S. + +--- + +## Workflow: Exploring a New Geospatial Dataset + +1. **Check for geometry columns:** + ``` + hotdata tables list --connection-id + ``` + Look for `Binary` (WKB) or `Struct` (bbox) typed columns. + +2. **Verify geometry types:** + ```sql + SELECT ST_GeometryType(ST_GeomFromWKB(wkb_geometry)) AS type, COUNT(*) + FROM
WHERE wkb_geometry IS NOT NULL GROUP BY 1 + ``` + +3. **Check coverage (bounding box of entire dataset):** + ```sql + SELECT + MIN(wkb_geometry_bbox['xmin']) AS min_lon, + MIN(wkb_geometry_bbox['ymin']) AS min_lat, + MAX(wkb_geometry_bbox['xmax']) AS max_lon, + MAX(wkb_geometry_bbox['ymax']) AS max_lat + FROM
+ WHERE wkb_geometry_bbox IS NOT NULL + ``` + +4. **Sample WKT to understand geometry structure:** + ```sql + SELECT ST_AsText(ST_GeomFromWKB(wkb_geometry)) FROM
+ WHERE wkb_geometry IS NOT NULL LIMIT 3 + ``` diff --git a/src/skill.rs b/src/skill.rs index c26d19f..96fde9a 100644 --- a/src/skill.rs +++ b/src/skill.rs @@ -5,11 +5,12 @@ use std::fs; use std::path::PathBuf; const REPO: &str = "hotdata-dev/hotdata-cli"; -const SKILL_NAME: &str = "hotdata"; +const PRIMARY_SKILL_NAME: &str = "hotdata"; +const SKILL_NAMES: &[&str] = &["hotdata", "hotdata-geospatial"]; 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 -> ~/.agents/skills/hotdata +/// If the root dir exists, we create /skills/ -> ~/.agents/skills/ const AGENT_ROOTS: &[&str] = &[".claude", ".pi"]; fn home_dir() -> PathBuf { @@ -19,15 +20,20 @@ fn home_dir() -> PathBuf { .to_path_buf() } -/// The canonical install location: ~/.agents/skills/hotdata -/// Source of truth: ~/.hotdata/skills/hotdata -fn skill_store_path() -> PathBuf { - home_dir().join(".hotdata").join("skills").join(SKILL_NAME) +/// The canonical store location: ~/.hotdata/skills/ +fn skill_store_path(skill_name: &str) -> PathBuf { + home_dir() + .join(".hotdata") + .join("skills") + .join(skill_name) } -/// Canonical agents layer: ~/.agents/skills/hotdata -fn agents_skill_path() -> PathBuf { - home_dir().join(".agents").join("skills").join(SKILL_NAME) +/// Canonical agents layer: ~/.agents/skills/ +fn agents_skill_path(skill_name: &str) -> PathBuf { + home_dir() + .join(".agents") + .join("skills") + .join(skill_name) } fn agents_lock_path() -> PathBuf { @@ -39,14 +45,14 @@ fn download_url() -> String { } /// Returns agent skill paths for all agent roots that exist on disk. -fn detected_agent_skill_paths() -> Vec<(String, PathBuf)> { +fn detected_agent_skill_paths(skill_name: &str) -> 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))) + Some((root.to_string(), root_path.join("skills").join(skill_name))) } else { None } @@ -65,7 +71,7 @@ fn parse_version_from_skill_md(content: &str) -> Option { } fn read_installed_version() -> Option { - let content = fs::read_to_string(skill_store_path().join("SKILL.md")).ok()?; + let content = fs::read_to_string(skill_store_path(PRIMARY_SKILL_NAME).join("SKILL.md")).ok()?; parse_version_from_skill_md(&content) } @@ -78,7 +84,7 @@ fn is_managed_by_skills_agent() -> bool { Ok(v) => v, Err(_) => return false, }; - json.get(SKILL_NAME).is_some() + json.get(PRIMARY_SKILL_NAME).is_some() } fn download_and_extract() -> Result<(), String> { @@ -183,18 +189,25 @@ fn ensure_symlink_or_copy(src: &PathBuf, link_path: &PathBuf) -> Result 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 -> ~/.hotdata/skills/hotdata - 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 - 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)); + for skill_name in SKILL_NAMES { + let store_path = skill_store_path(skill_name); + let agents_path = agents_skill_path(skill_name); + + // First: ~/.agents/skills/ -> ~/.hotdata/skills/ + let agents_result = ensure_symlink_or_copy(&store_path, &agents_path); + results.push(( + format!("~/.agents ({skill_name})"), + agents_path.clone(), + agents_result, + )); + + // Then: each detected agent root -> ~/.agents/skills/ + for (root, link_path) in detected_agent_skill_paths(skill_name) { + let result = ensure_symlink_or_copy(&agents_path, &link_path); + results.push((format!("~/{root} ({skill_name})"), link_path, result)); + } } results @@ -202,7 +215,6 @@ fn ensure_symlinks() -> Vec<(String, PathBuf, Result)> { 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() { @@ -228,59 +240,72 @@ pub fn install_project() { } let cwd = std::env::current_dir().expect("could not determine current directory"); - let project_agents = cwd.join(".agents").join("skills").join(SKILL_NAME); + let project_skills_root = cwd.join(".agents").join("skills"); - // Always copy (not symlink) from store to .agents/skills/hotdata - 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() { + // Always copy (not symlink) from store to .agents/skills/ + if let Some(parent) = project_skills_root.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); + for skill_name in SKILL_NAMES { + let store_path = skill_store_path(skill_name); + let project_agents = project_skills_root.join(skill_name); + + 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:", - rel_agents.display().to_string().cyan() - ); + println!("{:<20}{}", "Location:", ".agents/skills".cyan()); - // For .claude and .pi in cwd: symlink (fallback copy) from .agents/skills/hotdata + // For .claude and .pi in cwd: symlink (fallback copy) from .agents/skills/ 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); + if !root_path.exists() { + continue; + } + for skill_name in SKILL_NAMES { + let project_agents = project_skills_root.join(skill_name); + 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}:"), + format!("./{root} ({skill_name}):"), rel_link.display().to_string().cyan() ), Ok(false) => println!( "{:<20}{} (copied)", - format!("./{root}:"), + format!("./{root} ({skill_name}):"), rel_link.display().to_string().cyan() ), - Err(e) => eprintln!("{}", format!("./{root}: failed: {e}").red()), + Err(e) => eprintln!( + "{}", + format!("./{root} ({skill_name}): failed: {e}").red() + ), } } } @@ -336,7 +361,18 @@ pub fn install() { "{}", format!("Skill installed successfully (v{current}).").green() ); - println!("{:<20}{}", "Location:", skill_store_path().display()); + println!( + "{:<20}{}", + "Location:", + "~/.hotdata/skills/".dark_grey() + ); + for skill_name in SKILL_NAMES { + println!( + "{:<20}{}", + format!("{skill_name}:"), + skill_store_path(skill_name).display().to_string().cyan() + ); + } for (label, path, result) in &symlinks { let status = match result { @@ -349,18 +385,29 @@ pub fn install() { } 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()); + let all_exist = SKILL_NAMES + .iter() + .all(|name| skill_store_path(name).exists()); + + if !all_exist { + row("Installed", &"Partial".yellow().to_string()); + for skill_name in SKILL_NAMES { + let ok = skill_store_path(skill_name).exists(); + let status = if ok { + "Yes".green().to_string() + } else { + "No".red().to_string() + }; + row(&format!("{skill_name}"), &status); + } println!("\nRun 'hotdata skills install' to install."); return; } @@ -382,28 +429,23 @@ pub fn status() { } 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}")); + for skill_name in SKILL_NAMES { + let mut installed_agents: Vec = Vec::new(); + if agents_skill_path(skill_name).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}")); + } + } + let label = format!("Agent Skills ({skill_name})"); + if installed_agents.is_empty() { + row(&label, &"none".dark_grey().to_string()); + } else { + row(&label, &installed_agents.join(", ").cyan().to_string()); } - } - - 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.is_some_and(|v| v < current) { From 2d939185a05c34ea5390518bf7f3f5085d26488e Mon Sep 17 00:00:00 2001 From: Eddie A Tejeda <669988+eddietejeda@users.noreply.github.com> Date: Wed, 29 Apr 2026 09:04:21 -0700 Subject: [PATCH 2/6] chore(release): bump geospatial skill version on release --- Cargo.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/Cargo.toml b/Cargo.toml index 86f9621..7d93438 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,6 +47,7 @@ pre-release-hook = ["git-cliff", "-o", "CHANGELOG.md", "--tag", "v{{version}}" ] publish = false pre-release-replacements = [ { file = "skills/hotdata/SKILL.md", search = "^version: .+", replace = "version: {{version}}", exactly = 1 }, + { file = "skills/hotdata-geospatial/SKILL.md", search = "^version: .+", replace = "version: {{version}}", exactly = 1 }, { file = "README.md", search = "version-[0-9.]+-blue", replace = "version-{{version}}-blue", exactly = 1 }, ] From 133616ebcbe217f54bc2e24ab23248a84400a1af Mon Sep 17 00:00:00 2001 From: Eddie A Tejeda <669988+eddietejeda@users.noreply.github.com> Date: Wed, 29 Apr 2026 09:05:27 -0700 Subject: [PATCH 3/6] fix(skills): complete partial installs and improve status output Re-download when the primary skill version is current but a skill store directory is missing. Show agent skill symlink rows after partial install, and omit agent rows for skills not present in the store. --- src/skill.rs | 80 +++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 58 insertions(+), 22 deletions(-) diff --git a/src/skill.rs b/src/skill.rs index 96fde9a..3ff348d 100644 --- a/src/skill.rs +++ b/src/skill.rs @@ -75,6 +75,12 @@ fn read_installed_version() -> Option { parse_version_from_skill_md(&content) } +fn all_skill_stores_present() -> bool { + SKILL_NAMES + .iter() + .all(|name| skill_store_path(name).exists()) +} + fn is_managed_by_skills_agent() -> bool { let content = match fs::read_to_string(agents_lock_path()) { Ok(c) => c, @@ -218,7 +224,18 @@ pub fn install_project() { // Ensure skill files exist locally first match read_installed_version() { - Some(ref v) if *v >= current => {} + Some(ref v) if *v >= current && all_skill_stores_present() => {} + Some(ref v) if *v >= current => { + println!( + "{}", + format!("Incomplete skills in ~/.hotdata/skills, downloading v{current}...") + .yellow() + ); + if let Err(e) = download_and_extract() { + eprintln!("{}", e.red()); + std::process::exit(1); + } + } Some(ref v) => { println!( "{}", @@ -316,10 +333,18 @@ pub fn install() { let needs_download = if is_managed_by_skills_agent() { match read_installed_version() { - Some(ref v) if *v >= current => { + Some(ref v) if *v >= current && all_skill_stores_present() => { println!("Managed by skills agent — already up to date (v{v})."); false } + Some(ref v) if *v >= current => { + println!( + "{}", + format!("Managed by skills agent — completing skill install (v{current})...") + .yellow() + ); + true + } Some(ref v) => { println!( "{}", @@ -335,10 +360,17 @@ pub fn install() { } } else { match read_installed_version() { - Some(ref v) if *v >= current => { + Some(ref v) if *v >= current && all_skill_stores_present() => { println!("Already up to date (v{v})."); false } + Some(ref v) if *v >= current => { + println!( + "{}", + format!("Completing skill install (v{current})...").yellow() + ); + true + } Some(ref v) => { println!("Updating from v{v} to v{current}..."); true @@ -408,28 +440,31 @@ pub fn status() { }; row(&format!("{skill_name}"), &status); } - println!("\nRun 'hotdata skills 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() - ), - ); + } else { + 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()), } - Some(v) => row("Version", &v.to_string().green().to_string()), - None => row("Version", &"unknown".dark_grey().to_string()), } let home = home_dir(); for skill_name in SKILL_NAMES { + let label = format!("Agent Skills ({skill_name})"); + if !skill_store_path(skill_name).exists() { + row(&label, &"—".dark_grey().to_string()); + continue; + } let mut installed_agents: Vec = Vec::new(); if agents_skill_path(skill_name).exists() { installed_agents.push("~/.agents".to_string()); @@ -440,7 +475,6 @@ pub fn status() { installed_agents.push(format!("~/{root}")); } } - let label = format!("Agent Skills ({skill_name})"); if installed_agents.is_empty() { row(&label, &"none".dark_grey().to_string()); } else { @@ -448,7 +482,9 @@ pub fn status() { } } - if installed_version.is_some_and(|v| v < current) { + if !all_exist { + println!("\nRun 'hotdata skills install' to install."); + } else if installed_version.is_some_and(|v| v < current) { println!("\nRun 'hotdata skills install' to update."); } } From c4e84f7736de78aa774523f9508d59f9767f1392 Mon Sep 17 00:00:00 2001 From: Eddie A Tejeda <669988+eddietejeda@users.noreply.github.com> Date: Wed, 29 Apr 2026 09:09:49 -0700 Subject: [PATCH 4/6] feat(skills): auto-update bundled agent skills after CLI upgrade When ~/.hotdata/skills/hotdata exists but the bundle is older than the CLI or incomplete, refresh from the matching release tarball before other commands. Skip if skills were never installed, if ~/.agents/.skill-lock.json manages installs, or when HOTDATA_SKILLS_AUTO_UPDATE is disabled. Route skill download progress to stderr so stdout stays clean for JSON/piped output. --- src/main.rs | 6 ++++++ src/skill.rs | 52 +++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index 9a7da72..dcca35b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -91,6 +91,12 @@ fn main() { util::set_debug(true); } + let skip_skill_auto_update = + cli.command.is_none() || matches!(&cli.command, Some(Commands::Skills { .. })); + if !skip_skill_auto_update { + skill::maybe_auto_update_after_cli_upgrade(); + } + match cli.command { None => { use clap::CommandFactory; diff --git a/src/skill.rs b/src/skill.rs index 3ff348d..3e2e272 100644 --- a/src/skill.rs +++ b/src/skill.rs @@ -81,6 +81,56 @@ fn all_skill_stores_present() -> bool { .all(|name| skill_store_path(name).exists()) } +/// When unset or non-zero: allow automatic skill refresh after CLI upgrade (`maybe_auto_update_after_cli_upgrade`). +/// Set to `0`, `false`, or `no` to disable. +fn skills_auto_update_env_enabled() -> bool { + match std::env::var("HOTDATA_SKILLS_AUTO_UPDATE") { + Ok(s) if s.trim().is_empty() => true, + Ok(s) => !matches!( + s.trim().to_ascii_lowercase().as_str(), + "0" | "false" | "no" + ), + Err(_) => true, + } +} + +/// If the user has previously installed agent skills (`~/.hotdata/skills/hotdata` exists) but the on-disk +/// bundle is older than this CLI or incomplete, download the matching release tarball and refresh symlinks. +/// Does nothing when skills were never installed, when [`is_managed_by_skills_agent`] is true, or when +/// [`skills_auto_update_env_enabled`] is false. Download failures print a warning and do not exit. +pub fn maybe_auto_update_after_cli_upgrade() { + if !skills_auto_update_env_enabled() || is_managed_by_skills_agent() { + return; + } + if !skill_store_path(PRIMARY_SKILL_NAME).exists() { + return; + } + + let current = Version::parse(CURRENT_VERSION).expect("invalid package version"); + let needs_refresh = match read_installed_version() { + Some(v) if v >= current && all_skill_stores_present() => false, + _ => true, + }; + if !needs_refresh { + return; + } + + if let Err(e) = download_and_extract() { + eprintln!( + "{}", + format!("warning: could not auto-update agent skills: {e}").yellow() + ); + return; + } + + let _symlinks = ensure_symlinks(); + + eprintln!( + "{}", + format!("Agent skills updated to v{current}.").green() + ); +} + fn is_managed_by_skills_agent() -> bool { let content = match fs::read_to_string(agents_lock_path()) { Ok(c) => c, @@ -95,7 +145,7 @@ fn is_managed_by_skills_agent() -> bool { fn download_and_extract() -> Result<(), String> { let url = download_url(); - println!("Downloading skill..."); + eprintln!("Downloading skill..."); // Binary download — can't route through `send_debug` (which calls // `resp.text()` and would corrupt the gzip stream). Log the From 4978791e86806920de2b9a55bab51886dac3fed7 Mon Sep 17 00:00:00 2001 From: Eddie A Tejeda <669988+eddietejeda@users.noreply.github.com> Date: Wed, 29 Apr 2026 09:45:51 -0700 Subject: [PATCH 5/6] refactor(skills): always auto-update skills when eligible (remove env opt-out) --- src/skill.rs | 19 +++---------------- 1 file changed, 3 insertions(+), 16 deletions(-) diff --git a/src/skill.rs b/src/skill.rs index 3e2e272..3d53e0b 100644 --- a/src/skill.rs +++ b/src/skill.rs @@ -81,25 +81,12 @@ fn all_skill_stores_present() -> bool { .all(|name| skill_store_path(name).exists()) } -/// When unset or non-zero: allow automatic skill refresh after CLI upgrade (`maybe_auto_update_after_cli_upgrade`). -/// Set to `0`, `false`, or `no` to disable. -fn skills_auto_update_env_enabled() -> bool { - match std::env::var("HOTDATA_SKILLS_AUTO_UPDATE") { - Ok(s) if s.trim().is_empty() => true, - Ok(s) => !matches!( - s.trim().to_ascii_lowercase().as_str(), - "0" | "false" | "no" - ), - Err(_) => true, - } -} - /// If the user has previously installed agent skills (`~/.hotdata/skills/hotdata` exists) but the on-disk /// bundle is older than this CLI or incomplete, download the matching release tarball and refresh symlinks. -/// Does nothing when skills were never installed, when [`is_managed_by_skills_agent`] is true, or when -/// [`skills_auto_update_env_enabled`] is false. Download failures print a warning and do not exit. +/// Does nothing when skills were never installed or when [`is_managed_by_skills_agent`] is true. +/// Download failures print a warning and do not exit. pub fn maybe_auto_update_after_cli_upgrade() { - if !skills_auto_update_env_enabled() || is_managed_by_skills_agent() { + if is_managed_by_skills_agent() { return; } if !skill_store_path(PRIMARY_SKILL_NAME).exists() { From 2070f5e38d2e7fcd62863ffb8851e470a942bb86 Mon Sep 17 00:00:00 2001 From: Eddie A Tejeda <669988+eddietejeda@users.noreply.github.com> Date: Wed, 29 Apr 2026 10:11:00 -0700 Subject: [PATCH 6/6] fix(skills): show Installed: No when no skill store exists --- src/skill.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/skill.rs b/src/skill.rs index 3d53e0b..d981e99 100644 --- a/src/skill.rs +++ b/src/skill.rs @@ -462,10 +462,19 @@ pub fn status() { println!("{:<20}{}", format!("{label}:"), value); } + let any_exist = SKILL_NAMES + .iter() + .any(|name| skill_store_path(name).exists()); let all_exist = SKILL_NAMES .iter() .all(|name| skill_store_path(name).exists()); + if !any_exist { + row("Installed", &"No".red().to_string()); + println!("\nRun 'hotdata skills install' to install."); + return; + } + if !all_exist { row("Installed", &"Partial".yellow().to_string()); for skill_name in SKILL_NAMES {