diff --git a/GLOSSARY.md b/GLOSSARY.md index 48d1191c..986dd5f1 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -33,6 +33,7 @@ Every public export, command, error, route, and document uses these terms. | `skilld update --check --json` | update relation check | | `skilld verify` | source verification | | `skilld outdated` | outdated Skill report | +| `skilld outdated --all` | system-wide outdated Skill report | | `skilld install skilld --global` | global skilld Skill install | | `skilld auth login` | account login | | `skilld auth status` | account authentication status | diff --git a/crates/skilld-command/src/lib.rs b/crates/skilld-command/src/lib.rs index b01f0ca7..3890cc41 100644 --- a/crates/skilld-command/src/lib.rs +++ b/crates/skilld-command/src/lib.rs @@ -1,6 +1,7 @@ mod config; mod local_store; mod outdated; +pub use outdated::{NoOutdatedProgress, OutdatedProgress, ancestor_roots}; mod output; mod remote; @@ -132,9 +133,9 @@ enum Command { Verify { skill: Option }, /// Report outdated and unmanaged Skills. Outdated { - /// Scan every Agent target on this system. + /// Check both scopes and every Agent target directory. #[arg(long)] - system: bool, + all: bool, }, /// Manage account authentication. Auth { @@ -228,7 +229,7 @@ pub trait Host { )) } - fn outdated(&self, _system: bool) -> Result, CommandError> { + fn outdated(&self, _all: bool) -> Result, CommandError> { Err(CommandError::unsupported_host( "Outdated Skill reports are unavailable on this host", )) @@ -793,7 +794,7 @@ fn dispatch(command: Command, host: &H) -> Result host.verify(skill.as_deref()).map(CommandOutput::Lines), - Command::Outdated { system } => host.outdated(system).map(CommandOutput::Lines), + Command::Outdated { all } => host.outdated(all).map(CommandOutput::Lines), } } @@ -893,6 +894,7 @@ pub struct LocalHost { bundled_skill: Option>, remote: Option>, account: Option>, + outdated_progress: Arc, } impl LocalHost { @@ -913,6 +915,7 @@ impl LocalHost { bundled_skill: None, remote: None, account: None, + outdated_progress: Arc::new(outdated::NoOutdatedProgress), } } @@ -945,6 +948,11 @@ impl LocalHost { self } + pub fn with_outdated_progress(mut self, progress: Arc) -> Self { + self.outdated_progress = progress; + self + } + fn store(&self, scope: InstallScope) -> LocalStore { match scope { InstallScope::Project => LocalStore::new(self.project_root.join(".skills")), @@ -1726,16 +1734,19 @@ impl Host for LocalHost { Ok(UpdatePlanV1::new(plan)) } - fn outdated(&self, system: bool) -> Result, CommandError> { - let scopes = if system { + fn outdated(&self, all: bool) -> Result, CommandError> { + let scopes = if all { vec![InstallScope::Project, InstallScope::Global] } else { vec![InstallScope::Project] }; + let progress = self.outdated_progress.as_ref(); let mut lines = Vec::new(); let mut managed = BTreeMap::>::new(); let mut store_roots = Vec::new(); let mut scan = Vec::new(); + let mut suppressed_roots = BTreeSet::new(); + let mut views = Vec::new(); for scope in scopes { let known = self.known_targets(scope)?; let store = self.store(scope); @@ -1748,6 +1759,10 @@ impl Host for LocalHost { scope.as_str(), CommandError::store(error).message )); + if all { + // The ancestor scan must not report Skills this scope cannot verify. + suppressed_roots.extend(known.iter().map(|target| target.root.clone())); + } continue; } }; @@ -1774,26 +1789,68 @@ impl Host for LocalHost { .entry(name.clone()) .or_default() .extend(paths.iter().cloned()); - lines.extend(self.report_outdated_view(&view, scope)); + progress.found(&format!("{name} ({} scope)", scope.as_str())); + views.push((view, scope)); } - if system { + if all { store_roots.push(store.root().to_path_buf()); scan.push((scope, known)); } } - if system { - for skill in outdated::scan_unmanaged(&scan, &store_roots, &managed) { - match self.search_candidate(&skill.name) { - Ok(Some(candidate)) => { - lines.extend(outdated::render_unmanaged(&skill, Some(&candidate))) + if all { + // Global Agent directories keep the global scope, so ancestor roots + // scan after them and skip roots another scope already claimed. + let mut claimed = scan + .iter() + .flat_map(|(_, targets)| targets.iter().map(|target| target.root.clone())) + .collect::>(); + claimed.extend(suppressed_roots); + for root in outdated::ancestor_roots(&self.project_root, &self.target_roots.home) { + for target in skilld_core::AGENT_TARGETS { + if let Ok(resolved) = + ResolvedTarget::new(target.id, root.join(target.project_skills_dir)) + && !claimed.contains(&resolved.root) + { + scan.push((InstallScope::Project, vec![resolved])); } - Ok(None) => lines.extend(outdated::render_unmanaged(&skill, None)), - Err(error) => { - lines.push(outdated::render_search_failure(&skill, &error.message)); + } + } + } + let unmanaged = if all { + outdated::scan_unmanaged(&scan, &store_roots, &managed) + } else { + Vec::new() + }; + for skill in &unmanaged { + progress.found(&outdated::found_line(skill)); + } + for (view, scope) in &views { + progress.checking(&view.name); + lines.extend(self.report_outdated_view(view, *scope)); + } + if all { + #[cfg(not(target_os = "wasi"))] + let results = search_candidates_parallel(self, &unmanaged); + #[cfg(target_os = "wasi")] + let results = unmanaged + .iter() + .map(|skill| self.search_candidate(&skill.name)) + .collect::>(); + let mut no_match = Vec::new(); + let mut failures = BTreeMap::>::new(); + for (skill, result) in unmanaged.iter().zip(results) { + match result { + Ok(Some(candidate)) => { + lines.extend(outdated::render_unmanaged(skill, Some(&candidate))) } + Ok(None) => no_match.push(skill), + Err(error) => failures.entry(error.message).or_default().push(skill), } } + lines.extend(outdated::render_no_match(&no_match)); + lines.extend(outdated::render_search_failures(&failures)); } + progress.finish(); if lines.is_empty() { lines.push("No installed Skills found.".to_owned()); } @@ -1801,6 +1858,50 @@ impl Host for LocalHost { } } +#[cfg(not(target_os = "wasi"))] +fn search_candidates_parallel( + host: &LocalHost, + skills: &[outdated::UnmanagedSkill], +) -> Vec, CommandError>> { + use std::sync::Mutex; + + const MAX_CONCURRENT_SEARCHES: usize = 8; + let next = std::sync::atomic::AtomicUsize::new(0); + let slots = Mutex::new( + skills + .iter() + .map(|_| None) + .collect::, CommandError>>>>(), + ); + if !skills.is_empty() { + std::thread::scope(|scope| { + let workers = skills.len().min(MAX_CONCURRENT_SEARCHES); + let mut handles = Vec::with_capacity(workers); + for _ in 0..workers { + handles.push(scope.spawn(|| { + loop { + let index = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + if index >= skills.len() { + break; + } + host.outdated_progress.checking(&skills[index].name); + let result = host.search_candidate(&skills[index].name); + slots.lock().unwrap()[index] = Some(result); + } + })); + } + for handle in handles { + let _ = handle.join(); + } + }); + } + let filled = slots.into_inner().unwrap(); + filled + .into_iter() + .map(|slot| slot.expect("every search slot is filled")) + .collect() +} + struct PendingUpdateComparison { name: skilld_core::SkillName, locked_commit_sha: CommitSha, diff --git a/crates/skilld-command/src/outdated.rs b/crates/skilld-command/src/outdated.rs index 19679e04..4440222b 100644 --- a/crates/skilld-command/src/outdated.rs +++ b/crates/skilld-command/src/outdated.rs @@ -1,12 +1,41 @@ use std::collections::BTreeMap; use std::fs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use skilld_core::{AgentTargetId, InstallScope, SkillName}; use crate::ResolvedTarget; use crate::local_store::normalize_path; +pub trait OutdatedProgress: Send + Sync { + fn found(&self, _line: &str) {} + fn checking(&self, _name: &str) {} + fn finish(&self) {} +} + +pub struct NoOutdatedProgress; + +impl OutdatedProgress for NoOutdatedProgress {} + +/// Directories from `start` up to and including `stop`. +/// When `stop` is not an ancestor of `start`, every ancestor up to the +/// filesystem root is included, so a project outside the home directory +/// still reports its own Skills. +pub fn ancestor_roots(start: &Path, stop: &Path) -> Vec { + let start = normalize_path(start); + let stop = normalize_path(stop); + let mut roots = Vec::new(); + let mut current = start.as_path(); + loop { + roots.push(current.to_path_buf()); + if current == stop || current.parent().is_none() { + break; + } + current = current.parent().expect("a checked parent exists"); + } + roots +} + pub(crate) struct UnmanagedSkill { pub name: String, pub path: PathBuf, @@ -89,24 +118,64 @@ pub(crate) fn scan_unmanaged( skills } -pub(crate) fn render_search_failure(skill: &UnmanagedSkill, message: &str) -> String { +pub(crate) fn found_line(skill: &UnmanagedSkill) -> String { format!( - "Unmanaged Skill {} ({}). Skill search unavailable: {message}.", + "{} ({}, unmanaged)", skill.name, - agent_list(skill) + skill + .agents + .iter() + .map(|agent| agent.as_str()) + .collect::>() + .join(", ") ) } +pub(crate) fn render_no_match(skills: &[&UnmanagedSkill]) -> Vec { + if skills.is_empty() { + return vec![]; + } + vec![format!( + "No Repository match for {} ({}).", + skill_count(skills.len()), + name_list(skills) + )] +} + +pub(crate) fn render_search_failures( + failures: &BTreeMap>, +) -> Vec { + failures + .iter() + .map(|(message, skills)| { + format!( + "Skill search unavailable for {} ({}): {message}.", + skill_count(skills.len()), + name_list(skills) + ) + }) + .collect() +} + +fn name_list(skills: &[&UnmanagedSkill]) -> String { + skills + .iter() + .map(|skill| format!("{} ({})", skill.name, agent_list(skill))) + .collect::>() + .join(", ") +} + +fn skill_count(count: usize) -> String { + format!("{count} {}", if count == 1 { "Skill" } else { "Skills" }) +} + pub(crate) fn render_unmanaged( skill: &UnmanagedSkill, candidate: Option<&SkillCandidate>, ) -> Vec { let agents = agent_list(skill); let Some(candidate) = candidate else { - return vec![format!( - "Unmanaged Skill {} ({agents}). No Repository match found.", - skill.name - )]; + return vec![]; }; let global = if skill.scope == InstallScope::Global { " --global" diff --git a/crates/skilld-command/tests/outdated.rs b/crates/skilld-command/tests/outdated.rs index 2749e02c..a6503fe1 100644 --- a/crates/skilld-command/tests/outdated.rs +++ b/crates/skilld-command/tests/outdated.rs @@ -1,6 +1,8 @@ use std::fs; use std::path::Path; +use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; +use std::time::Duration; use sha2::{Digest, Sha256}; use skilld_command::{ @@ -21,6 +23,10 @@ struct Provider { fail_state: Mutex, search_results: Mutex>, fail_search: Mutex, + search_calls: AtomicUsize, + search_in_flight: AtomicUsize, + search_max_in_flight: Mutex, + delay_search: Option, } impl Provider { @@ -31,9 +37,18 @@ impl Provider { fail_state: Mutex::new(false), search_results: Mutex::new(vec![]), fail_search: Mutex::new(false), + search_calls: std::sync::atomic::AtomicUsize::new(0), + search_in_flight: std::sync::atomic::AtomicUsize::new(0), + search_max_in_flight: Mutex::new(0), + delay_search: None, } } + fn with_search_delay(mut self, delay: Duration) -> Self { + self.delay_search = Some(delay); + self + } + fn search_result(name: &str) -> SearchResult { SearchResult { name: name.to_owned(), @@ -67,17 +82,32 @@ fn installed_digest(file: &PreparedFile) -> String { impl RemoteProvider for Provider { fn search(&self, _query: &str, _limit: u8) -> Result { - if *self.fail_search.lock().unwrap() { - return Err(RemoteError::new( - "INVALID_RESPONSE", - "Skill search returned invalid JSON", - )); + self.search_calls.fetch_add(1, Ordering::Relaxed); + let in_flight = self.search_in_flight.fetch_add(1, Ordering::SeqCst) + 1; + { + let mut max = self.search_max_in_flight.lock().unwrap(); + if in_flight > *max { + *max = in_flight; + } } - let items = self.search_results.lock().unwrap().clone(); - Ok(SearchResponse { - total: items.len() as u64, - items, - }) + let outcome = (|| { + if let Some(delay) = self.delay_search { + std::thread::sleep(delay); + } + if *self.fail_search.lock().unwrap() { + return Err(RemoteError::new( + "INVALID_RESPONSE", + "Skill search returned invalid JSON", + )); + } + let items = self.search_results.lock().unwrap().clone(); + Ok(SearchResponse { + total: items.len() as u64, + items, + }) + })(); + self.search_in_flight.fetch_sub(1, Ordering::SeqCst); + outcome } fn prepare( @@ -283,7 +313,7 @@ fn outdated_system_reports_a_stale_global_skill_with_the_global_update() { let mut stderr = Vec::new(); let result = run( - ["skilld", "outdated", "--system"], + ["skilld", "outdated", "--all"], &host, &mut stdout, &mut stderr, @@ -310,7 +340,7 @@ fn outdated_system_links_unmanaged_skills_to_a_repository() { let mut stderr = Vec::new(); let result = run( - ["skilld", "outdated", "--system"], + ["skilld", "outdated", "--all"], &host, &mut stdout, &mut stderr, @@ -339,7 +369,7 @@ fn outdated_system_reports_unmanaged_skills_without_a_match() { let mut stderr = Vec::new(); let result = run( - ["skilld", "outdated", "--system"], + ["skilld", "outdated", "--all"], &host, &mut stdout, &mut stderr, @@ -348,7 +378,7 @@ fn outdated_system_reports_unmanaged_skills_without_a_match() { assert_eq!(result.exit_code, 0); assert_eq!( String::from_utf8(stdout).unwrap(), - "Unmanaged Skill private-skill (codex). No Repository match found.\n" + "No Repository match for 1 Skill (private-skill (codex)).\n" ); } @@ -368,7 +398,7 @@ fn outdated_system_surfaces_a_search_failure_and_keeps_scanning() { let mut stderr = Vec::new(); let result = run( - ["skilld", "outdated", "--system"], + ["skilld", "outdated", "--all"], &host, &mut stdout, &mut stderr, @@ -377,7 +407,7 @@ fn outdated_system_surfaces_a_search_failure_and_keeps_scanning() { assert_eq!(result.exit_code, 0); assert_eq!( String::from_utf8(stdout).unwrap(), - "Unmanaged Skill other-skill (codex). Skill search unavailable: Skill search returned invalid JSON.\nUnmanaged Skill vue-testing (claude-code). Skill search unavailable: Skill search returned invalid JSON.\n" + "Skill search unavailable for 2 Skills (other-skill (codex), vue-testing (claude-code)): Skill search returned invalid JSON.\n" ); } @@ -396,7 +426,7 @@ fn outdated_system_reports_a_managed_skill_once() { let mut stderr = Vec::new(); let result = run( - ["skilld", "outdated", "--system"], + ["skilld", "outdated", "--all"], &host, &mut stdout, &mut stderr, @@ -503,7 +533,7 @@ fn outdated_system_survives_a_corrupt_global_store() { let mut stderr = Vec::new(); let result = run( - ["skilld", "outdated", "--system"], + ["skilld", "outdated", "--all"], &host, &mut stdout, &mut stderr, @@ -515,11 +545,7 @@ fn outdated_system_survives_a_corrupt_global_store() { output.starts_with("Skill store unavailable in global scope: "), "expected a store failure line, got: {output}" ); - assert!( - output.contains( - "Unmanaged Skill unmanaged-project (amp, codex). No Repository match found.\n" - ) - ); + assert!(output.contains("No Repository match for 1 Skill (unmanaged-project (amp, codex)).\n")); assert!( !output.contains("hidden-global"), "a scope with an unreadable lockfile must not report its Skills: {output}" @@ -539,7 +565,7 @@ fn outdated_system_groups_agents_sharing_one_directory() { let mut stderr = Vec::new(); let result = run( - ["skilld", "outdated", "--system"], + ["skilld", "outdated", "--all"], &host, &mut stdout, &mut stderr, @@ -548,7 +574,7 @@ fn outdated_system_groups_agents_sharing_one_directory() { assert_eq!(result.exit_code, 0); let output = String::from_utf8(stdout).unwrap(); assert!( - output.contains("Unmanaged Skill shared (amp, codex). No Repository match found.\n"), + output.contains("No Repository match for 1 Skill (shared (amp, codex)).\n"), "expected both agents sharing .agents/skills, got: {output}" ); } @@ -611,7 +637,7 @@ fn outdated_reports_the_bundled_skill_by_its_source() { let mut stderr = Vec::new(); let result = run( - ["skilld", "outdated", "--system"], + ["skilld", "outdated", "--all"], &host, &mut stdout, &mut stderr, @@ -623,3 +649,150 @@ fn outdated_reports_the_bundled_skill_by_its_source() { "skilld-maintained Skill skilld.\n" ); } + +#[test] +fn outdated_system_runs_candidate_searches_in_parallel_with_a_bound() { + use std::time::Instant; + + let temporary = tempfile::tempdir().unwrap(); + let project = temporary.path().join("project"); + fs::create_dir_all(&project).unwrap(); + let home = temporary.path(); + for index in 0..16 { + unmanaged_skill(home, ".claude", &format!("parallel-{index:02}")); + } + let provider = Arc::new( + Provider::new("---\nname: example\n---\n").with_search_delay(Duration::from_millis(50)), + ); + *provider.search_max_in_flight.lock().unwrap() = 0; + let host = LocalHost::new(project, home.join("data")).with_remote_provider(provider.clone()); + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + + let started = Instant::now(); + let result = run( + ["skilld", "outdated", "--all"], + &host, + &mut stdout, + &mut stderr, + ); + let elapsed = started.elapsed(); + + assert_eq!(result.exit_code, 0); + assert_eq!(provider.search_calls.load(Ordering::Relaxed), 16); + assert!( + *provider.search_max_in_flight.lock().unwrap() <= 8, + "the concurrency bound was exceeded" + ); + assert!( + elapsed < Duration::from_millis(16 * 50), + "16 delayed searches finished far slower than a bounded parallel run: {elapsed:?}" + ); + let output = String::from_utf8(stdout).unwrap(); + assert!(output.contains("No Repository match for 16 Skills")); +} + +#[test] +fn ancestor_roots_stop_at_home() { + let home = Path::new("/home/user"); + let roots = skilld_command::ancestor_roots(&home.join("pkg/app"), home); + assert_eq!( + roots, + vec![ + Path::new("/home/user/pkg/app").to_path_buf(), + Path::new("/home/user/pkg").to_path_buf(), + Path::new("/home/user").to_path_buf(), + ] + ); +} + +#[test] +fn ancestor_roots_continue_to_the_root_outside_home() { + let roots = skilld_command::ancestor_roots(Path::new("/tmp/work/app"), Path::new("/home/user")); + assert_eq!(roots.first().unwrap(), Path::new("/tmp/work/app")); + assert_eq!(roots.last().unwrap(), Path::new("/")); + assert_eq!(roots.len(), 4); +} + +#[test] +fn outdated_system_finds_skills_in_parent_directories() { + let temporary = tempfile::tempdir().unwrap(); + let nested = temporary.path().join("work/app"); + fs::create_dir_all(&nested).unwrap(); + unmanaged_skill(temporary.path(), ".claude", "parent-skill"); + let provider = Arc::new(Provider::new("---\nname: example\n---\n")); + let host = LocalHost::new(nested, temporary.path().join("data")).with_remote_provider(provider); + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + + let result = run( + ["skilld", "outdated", "--all"], + &host, + &mut stdout, + &mut stderr, + ); + + assert_eq!(result.exit_code, 0); + let output = String::from_utf8(stdout).unwrap(); + assert!( + output.contains("No Repository match for 1 Skill (parent-skill (claude-code))."), + "expected the parent directory Skill, got: {output}" + ); +} + +struct RecordingProgress { + found: Mutex>, + checking: Mutex>, + finished: Mutex, +} + +impl skilld_command::OutdatedProgress for RecordingProgress { + fn found(&self, line: &str) { + self.found.lock().unwrap().push(line.to_owned()); + } + + fn checking(&self, name: &str) { + self.checking.lock().unwrap().push(name.to_owned()); + } + + fn finish(&self) { + *self.finished.lock().unwrap() = true; + } +} + +#[test] +fn outdated_reports_found_skills_before_remote_checks() { + let temporary = tempfile::tempdir().unwrap(); + let project = temporary.path().join("project"); + fs::create_dir_all(&project).unwrap(); + let home = temporary.path(); + unmanaged_skill(home, ".claude", "vue-testing"); + let provider = Arc::new(Provider::new( + "---\nname: example\ndescription: first\n---\n", + )); + let progress = Arc::new(RecordingProgress { + found: Mutex::new(vec![]), + checking: Mutex::new(vec![]), + finished: Mutex::new(false), + }); + let host = LocalHost::new(project, home.join("data")) + .with_remote_provider(provider) + .with_outdated_progress(progress.clone()); + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + + let result = run( + ["skilld", "outdated", "--all"], + &host, + &mut stdout, + &mut stderr, + ); + + assert_eq!(result.exit_code, 0); + assert_eq!( + *progress.found.lock().unwrap(), + vec!["vue-testing (claude-code, unmanaged)".to_owned()] + ); + assert_eq!(*progress.checking.lock().unwrap(), vec!["vue-testing"]); + assert!(*progress.finished.lock().unwrap()); +} diff --git a/crates/skilld-native/src/main.rs b/crates/skilld-native/src/main.rs index 8c8a7e2e..2687eec9 100644 --- a/crates/skilld-native/src/main.rs +++ b/crates/skilld-native/src/main.rs @@ -60,18 +60,26 @@ fn main() -> ExitCode { let global_root = global_root(); let detection = detection_environment(); let account = Arc::new(NativeAccount::new()); - let host = Arc::new( - LocalHost::new(project_root, global_root) - .with_target_roots(target_roots()) - .with_detection_environment(detection.clone()) - .with_bundled_provider(Arc::new(EmbeddedSkilld::new())) - .with_account_provider(account.clone()) - .with_remote_provider(Arc::new(SkilldRemote::new( - Arc::new(NativeHttpAdapter::new()), - account, - native_remote_config(), - ))), - ); + let host = LocalHost::new(project_root, global_root) + .with_target_roots(target_roots()) + .with_detection_environment(detection.clone()) + .with_bundled_provider(Arc::new(EmbeddedSkilld::new())) + .with_account_provider(account.clone()) + .with_remote_provider(Arc::new(SkilldRemote::new( + Arc::new(NativeHttpAdapter::new()), + account, + native_remote_config(), + ))); + let host = if args.iter().skip(1).any(|arg| arg == "outdated") + && !args.iter().any(|arg| arg == "--json" || arg == "--plain") + { + host.with_outdated_progress(Arc::new(status::OutdatedProgressLine::for_terminal( + std::io::stderr().is_terminal(), + ))) + } else { + host + }; + let host = Arc::new(host); if interactive { let interactive_host = Arc::new(CommandInteractiveUpdateHost::new(host)); diff --git a/crates/skilld-native/src/status.rs b/crates/skilld-native/src/status.rs index 74ea7e50..7545e94a 100644 --- a/crates/skilld-native/src/status.rs +++ b/crates/skilld-native/src/status.rs @@ -1,13 +1,14 @@ +use skilld_command::OutputContext; + use std::io::Write; use std::sync::Arc; use std::sync::Mutex; +use std::sync::atomic::Ordering; use std::thread; use std::thread::JoinHandle; use std::time::Duration; use std::time::Instant; -use skilld_command::OutputContext; - const ERASE_LINE: &[u8] = b"\r\x1b[2K"; struct StatusState { @@ -161,6 +162,60 @@ where } } +const SPINNER_FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + +/// Streams `skilld outdated` progress to a terminal: found Skills print as +/// lines, remote verification rewrites one spinner line, and `finish` erases +/// it so only results remain. +pub struct OutdatedProgressLine { + enabled: bool, + frame: std::sync::atomic::AtomicUsize, +} + +impl OutdatedProgressLine { + pub fn for_terminal(is_terminal: bool) -> Self { + Self { + enabled: is_terminal, + frame: std::sync::atomic::AtomicUsize::new(0), + } + } + + fn erase(&self) { + if self.enabled { + let mut stderr = std::io::stderr().lock(); + let _ = stderr.write_all(b"\r\x1b[2K"); + let _ = stderr.flush(); + } + } +} + +impl skilld_command::OutdatedProgress for OutdatedProgressLine { + fn found(&self, line: &str) { + if !self.enabled { + return; + } + self.erase(); + let mut stderr = std::io::stderr().lock(); + let _ = writeln!(stderr, "• {line}"); + let _ = stderr.flush(); + } + + fn checking(&self, name: &str) { + if !self.enabled { + return; + } + let frame = + SPINNER_FRAMES[self.frame.fetch_add(1, Ordering::Relaxed) % SPINNER_FRAMES.len()]; + let mut stderr = std::io::stderr().lock(); + let _ = write!(stderr, "\r\x1b[2K{frame} Checking {name}…"); + let _ = stderr.flush(); + } + + fn finish(&self) { + self.erase(); + } +} + #[cfg(test)] mod tests { use super::{GatedStderr, OutputContext, StatusLine, status_label};