Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions GLOSSARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
133 changes: 117 additions & 16 deletions crates/skilld-command/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
mod config;
mod local_store;
mod outdated;
pub use outdated::{NoOutdatedProgress, OutdatedProgress, ancestor_roots};
mod output;
mod remote;

Expand Down Expand Up @@ -132,9 +133,9 @@ enum Command {
Verify { skill: Option<String> },
/// 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 {
Expand Down Expand Up @@ -228,7 +229,7 @@ pub trait Host {
))
}

fn outdated(&self, _system: bool) -> Result<Vec<String>, CommandError> {
fn outdated(&self, _all: bool) -> Result<Vec<String>, CommandError> {
Err(CommandError::unsupported_host(
"Outdated Skill reports are unavailable on this host",
))
Expand Down Expand Up @@ -793,7 +794,7 @@ fn dispatch<H: Host>(command: Command, host: &H) -> Result<CommandOutput, Comman
}
}
Command::Verify { skill } => 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),
}
}

Expand Down Expand Up @@ -893,6 +894,7 @@ pub struct LocalHost {
bundled_skill: Option<Arc<dyn BundledSkillProvider>>,
remote: Option<Arc<dyn RemoteProvider>>,
account: Option<Arc<dyn AccountProvider>>,
outdated_progress: Arc<dyn outdated::OutdatedProgress>,
}

impl LocalHost {
Expand All @@ -913,6 +915,7 @@ impl LocalHost {
bundled_skill: None,
remote: None,
account: None,
outdated_progress: Arc::new(outdated::NoOutdatedProgress),
}
}

Expand Down Expand Up @@ -945,6 +948,11 @@ impl LocalHost {
self
}

pub fn with_outdated_progress(mut self, progress: Arc<dyn outdated::OutdatedProgress>) -> Self {
self.outdated_progress = progress;
self
}

fn store(&self, scope: InstallScope) -> LocalStore {
match scope {
InstallScope::Project => LocalStore::new(self.project_root.join(".skills")),
Expand Down Expand Up @@ -1726,16 +1734,19 @@ impl Host for LocalHost {
Ok(UpdatePlanV1::new(plan))
}

fn outdated(&self, system: bool) -> Result<Vec<String>, CommandError> {
let scopes = if system {
fn outdated(&self, all: bool) -> Result<Vec<String>, 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::<String, Vec<PathBuf>>::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);
Expand All @@ -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;
}
};
Expand All @@ -1774,33 +1789,119 @@ 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::<BTreeSet<_>>();
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::<Vec<_>>();
let mut no_match = Vec::new();
let mut failures = BTreeMap::<String, Vec<&outdated::UnmanagedSkill>>::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());
}
Ok(lines)
}
}

#[cfg(not(target_os = "wasi"))]
fn search_candidates_parallel(
host: &LocalHost,
skills: &[outdated::UnmanagedSkill],
) -> Vec<Result<Option<outdated::SkillCandidate>, 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::<Vec<Option<Result<Option<outdated::SkillCandidate>, 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,
Expand Down
85 changes: 77 additions & 8 deletions crates/skilld-command/src/outdated.rs
Original file line number Diff line number Diff line change
@@ -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<PathBuf> {
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,
Expand Down Expand Up @@ -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::<Vec<_>>()
.join(", ")
)
}

pub(crate) fn render_no_match(skills: &[&UnmanagedSkill]) -> Vec<String> {
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<String, Vec<&UnmanagedSkill>>,
) -> Vec<String> {
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::<Vec<_>>()
.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<String> {
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"
Expand Down
Loading