From 7ebf09b4df9a6b298e79e3408bb56aea73729f58 Mon Sep 17 00:00:00 2001 From: zgq Date: Sun, 20 Sep 2026 17:58:55 +0800 Subject: [PATCH] Let skill status and remove forget missing installations --- README.md | 1 + crates/cli/src/main.rs | 6 ++ crates/cli/src/skill.rs | 131 +++++++++++++++++++++++++++++++++++++++- skills/sqlx/SKILL.md | 2 +- 4 files changed, 138 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index bee2197..c4b734d 100644 --- a/README.md +++ b/README.md @@ -215,6 +215,7 @@ For an agent or script, provide credentials through environment variables or a c | Install the Skill | `sqlx skill install --target codex`, `--target claude`, `--target dsh` or `--target pi` | | Install to another skill directory | `sqlx skill install --path /path/to/skills/sqlx` | | Inspect/update managed Skills | `sqlx skill status`, `sqlx skill update` | +| Stop managing a Skill installation | `sqlx skill remove --path /path/to/skills/sqlx` (files are kept) | | Help/version | `sqlx --help`, `sqlx --version` | `--id` and `--datasource` accept a stable datasource UUID or its unique name. Supported database type names are `mysql`, `postgresql` (`postgres`/`pgsql`), `oracle`, and `sqlserver` (`mssql`). Oracle requires `--service`. TLS defaults to certificate verification; `--tls disable` is available for explicitly unencrypted connections. The first authentication profile is username/password. diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 0a09bbd..6f8cab0 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -212,6 +212,11 @@ enum SkillCommand { }, Update, Status, + /// Stop managing a Skill installation; its files are left in place. + Remove { + #[arg(long)] + path: PathBuf, + }, } fn main() { let cli = Cli::parse(); @@ -478,6 +483,7 @@ fn run(cli: Cli) -> Result { } SkillCommand::Update => skill::update(&manager)?, SkillCommand::Status => skill::status(&manager)?, + SkillCommand::Remove { path } => skill::remove(&manager, path)?, }; print(value); } diff --git a/crates/cli/src/skill.rs b/crates/cli/src/skill.rs index d478214..aa7b94d 100644 --- a/crates/cli/src/skill.rs +++ b/crates/cli/src/skill.rs @@ -41,9 +41,61 @@ pub fn target_path(target: Option, path: Option) -> Result Result { Ok( - json!({"installations":records(manager)?.into_iter().map(|i|json!({"path":i.path,"version":i.version,"intact":files(&i.path).is_ok_and(|f|f==i.files)})).collect::>()}), + json!({"installations":records(manager)?.into_iter().map(|i|{let missing=!i.path.exists();json!({"path":i.path,"version":i.version,"missing":missing,"intact":!missing&&files(&i.path).is_ok_and(|f|f==i.files)})}).collect::>()}), ) } +/// Canonicalize the nearest existing ancestor and re-append the missing tail, so a +/// record stays addressable after its directory was deleted (for example under /tmp). +fn resolved_path(path: &Path) -> PathBuf { + if let Ok(canonical) = path.canonicalize() { + return canonical; + } + let mut missing = Vec::new(); + let mut current = path; + loop { + match current.canonicalize() { + Ok(base) => { + let mut resolved = base; + for part in missing.iter().rev() { + resolved.push(part); + } + return resolved; + } + Err(_) => match (current.file_name(), current.parent()) { + (Some(name), Some(parent)) => { + missing.push(name.to_os_string()); + current = parent; + } + _ => return path.to_path_buf(), + }, + } + } +} +/// Stop managing a recorded Skill installation. Its files are never deleted here. +pub fn remove(manager: &Components, path: PathBuf) -> Result { + let lock = open_private(&manager.root.join("skill-installs.lock"))?; + lock.lock_exclusive()?; + let mut installs = records(manager)?; + let absolute = if path.is_absolute() { + path + } else { + std::env::current_dir()?.join(path) + }; + let target = resolved_path(&absolute); + let before = installs.len(); + installs.retain(|i| i.path != target && i.path != absolute); + if installs.len() == before { + bail!( + "no SQLX-managed Skill is recorded at {}; run `sqlx skill status` to list them", + absolute.display() + ); + } + atomic_write( + &manager.root.join("skill-installs.json"), + &serde_json::to_vec_pretty(&installs)?, + )?; + Ok(json!({"removed":true,"path":target,"directory_exists":target.exists()})) +} pub fn install(manager: &Components, path: PathBuf) -> Result { let manifest = manager.manifest(false)?; let asset = manager.asset(&manifest, "skill", "any")?; @@ -176,4 +228,81 @@ mod tests { .to_string(); assert!(error.contains("codex, claude, dsh, pi"), "{error}"); } + #[test] + fn remove_forgets_one_record_and_keeps_its_files() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().to_path_buf(); + let manager = Components::new(root.clone(), "unused".into()); + let recorded = root.join("skills/sqlx"); + let other = root.join("other/sqlx"); + fs::create_dir_all(&recorded).unwrap(); + fs::create_dir_all(&other).unwrap(); + let entry = |path: PathBuf| Installation { + path, + version: "0.1.6".into(), + files: BTreeMap::new(), + }; + atomic_write( + &root.join("skill-installs.json"), + &serde_json::to_vec_pretty(&[entry(recorded.clone()), entry(other.clone())]).unwrap(), + ) + .unwrap(); + let value = remove(&manager, recorded.clone()).unwrap(); + assert_eq!(value["removed"], json!(true)); + assert_eq!(value["directory_exists"], json!(true)); + assert!(recorded.exists(), "removal must not delete files"); + let left = records(&manager).unwrap(); + assert_eq!(left.len(), 1); + assert_eq!(left[0].path, other); + let error = remove(&manager, recorded).unwrap_err().to_string(); + assert!( + error.contains("no SQLX-managed Skill is recorded"), + "{error}" + ); + } + #[test] + fn remove_resolves_a_path_whose_directory_is_gone() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().to_path_buf(); + let manager = Components::new(root.clone(), "unused".into()); + let recorded = root.join("gone/sqlx"); + fs::create_dir_all(&recorded).unwrap(); + // The installer records the canonical path; remove must still find it after deletion. + let canonical = recorded.canonicalize().unwrap(); + atomic_write( + &root.join("skill-installs.json"), + &serde_json::to_vec_pretty(&[Installation { + path: canonical, + version: "0.1.6".into(), + files: BTreeMap::new(), + }]) + .unwrap(), + ) + .unwrap(); + fs::remove_dir_all(&recorded).unwrap(); + let value = remove(&manager, recorded).unwrap(); + assert_eq!(value["removed"], json!(true)); + assert_eq!(value["directory_exists"], json!(false)); + assert!(records(&manager).unwrap().is_empty()); + } + #[test] + fn status_reports_a_missing_installation() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().to_path_buf(); + let manager = Components::new(root.clone(), "unused".into()); + let gone = root.join("gone/sqlx"); + atomic_write( + &root.join("skill-installs.json"), + &serde_json::to_vec_pretty(&[Installation { + path: gone.clone(), + version: "0.1.6".into(), + files: BTreeMap::new(), + }]) + .unwrap(), + ) + .unwrap(); + let value = status(&manager).unwrap(); + assert_eq!(value["installations"][0]["missing"], json!(true)); + assert_eq!(value["installations"][0]["intact"], json!(false)); + } } diff --git a/skills/sqlx/SKILL.md b/skills/sqlx/SKILL.md index 099959d..86175c5 100644 --- a/skills/sqlx/SKILL.md +++ b/skills/sqlx/SKILL.md @@ -85,7 +85,7 @@ Local page URLs can be reopened without an authorization deadline. Run `sqlx ui When the user wants to inspect data visually, append `--view` to the already prepared SQL execution command. Apply the approval gate above before starting the query. It returns a local result URL and starts the query once in the local service. The user does not need to paste or rerun the SQL. Browser reload and pagination read the same cached execution. In SQLX 0.1.3, the page's **Refresh** action explicitly reruns all original SQL statements in order; apply the approval gate again before a state-changing or unknown refresh. The optional refresh interval does the same; do not enable it unless the user explicitly approves repeated execution and its side effects. SQL is not classified or rewritten, so writes in the original batch run again too. UI results are retained locally for 24 hours; do not automatically rerun expired or interrupted queries. `--no-open` returns a link without launching a browser. These pages open on the machine running SQLX. See [local pages](references/local-ui.md) for credential editing, UI plugin installation and selection, lifecycle, and limitations. -Skill installation and updates use `sqlx skill install --target codex`, `--target claude`, `--target dsh`, `--target pi`, or `--path `, followed by the target agent's discovery/reload mechanism. Codex and dsh share `~/.agents/skills`; Pi uses `~/.pi/agent/skills`. `sqlx skill status` and `sqlx skill update` operate on SQLX-managed installations and preserve locally modified skill files. +Skill installation and updates use `sqlx skill install --target codex`, `--target claude`, `--target dsh`, `--target pi`, or `--path `, followed by the target agent's discovery/reload mechanism. Codex and dsh share `~/.agents/skills`; Pi uses `~/.pi/agent/skills`. `sqlx skill status` and `sqlx skill update` operate on SQLX-managed installations and preserve locally modified skill files. `status` marks a record whose directory no longer exists with `missing: true`, and `sqlx skill remove --path ` stops managing that installation without deleting any files. ## Check and install CLI updates