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
11 changes: 11 additions & 0 deletions GLOSSARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ Every public export, command, error, route, and document uses these terms.
| `skilld update` | Skill update |
| `skilld update --check --json` | update relation check |
| `skilld verify` | source verification |
| `skilld outdated` | outdated Skill report |
| `skilld install skilld --global` | global skilld Skill install |
| `skilld auth login` | account login |
| `skilld auth status` | account authentication status |
Expand Down Expand Up @@ -204,6 +205,16 @@ None recorded.

**Casing:** `Agent target` in prose, `AgentTarget` in types.

### Outdated Skill report

**Is:** the per Skill status produced by `skilld outdated`.

**Use for:** current, outdated, unverified, local, and unmanaged Skill states.

**Never:** version check, drift report, health check.

**Casing:** `Outdated Skill report` in prose, `outdated` in commands.

## Banned

| Never | Use instead | Why |
Expand Down
187 changes: 181 additions & 6 deletions crates/skilld-command/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
mod config;
mod local_store;
mod outdated;
mod output;
mod remote;

use std::collections::BTreeSet;
use std::collections::{BTreeMap, BTreeSet};
use std::ffi::OsString;
use std::fmt;
use std::fs;
Expand Down Expand Up @@ -101,9 +102,18 @@ enum Command {
conflicts_with_all = ["skill", "check", "json", "plain"]
)]
interactive: bool,
/// Update Skills in the global scope.
#[arg(long)]
global: bool,
},
/// Verify a Skill source.
Verify { skill: Option<String> },
/// Report outdated and unmanaged Skills.
Outdated {
/// Scan every Agent target on this system.
#[arg(long)]
system: bool,
},
/// Manage account authentication.
Auth {
#[command(subcommand)]
Expand Down Expand Up @@ -173,7 +183,11 @@ pub trait Host {
))
}

fn update(&self, _name: Option<&str>) -> Result<Vec<String>, CommandError> {
fn update(
&self,
_name: Option<&str>,
_scope: InstallScope,
) -> Result<Vec<String>, CommandError> {
Err(CommandError::unsupported_host(
"Skill update is unavailable on this host",
))
Expand All @@ -192,6 +206,12 @@ pub trait Host {
))
}

fn outdated(&self, _system: bool) -> Result<Vec<String>, CommandError> {
Err(CommandError::unsupported_host(
"Outdated Skill reports are unavailable on this host",
))
}

fn config_get(&self, _key: &str) -> Result<String, CommandError> {
Err(CommandError::unsupported_host(
"configuration is unavailable on this host",
Expand Down Expand Up @@ -718,6 +738,7 @@ fn dispatch<H: Host>(command: Command, host: &H) -> Result<CommandOutput, Comman
skill,
check,
interactive,
global,
} => {
if interactive {
Err(CommandError::unsupported_host(
Expand All @@ -727,10 +748,12 @@ fn dispatch<H: Host>(command: Command, host: &H) -> Result<CommandOutput, Comman
host.update_check(skill.as_deref())
.map(CommandOutput::UpdateCheck)
} else {
host.update(skill.as_deref()).map(CommandOutput::Lines)
host.update(skill.as_deref(), scope(global))
.map(CommandOutput::Lines)
}
}
Command::Verify { skill } => host.verify(skill.as_deref()).map(CommandOutput::Lines),
Command::Outdated { system } => host.outdated(system).map(CommandOutput::Lines),
}
}

Expand Down Expand Up @@ -1314,8 +1337,11 @@ impl Host for LocalHost {
Ok(lines)
}

fn update(&self, requested: Option<&str>) -> Result<Vec<String>, CommandError> {
let scope = InstallScope::Project;
fn update(
&self,
requested: Option<&str>,
scope: InstallScope,
) -> Result<Vec<String>, CommandError> {
let known = self.known_targets(scope)?;
let store = self.store(scope);
let names = selected_names(&store, &known, requested)?;
Expand Down Expand Up @@ -1659,6 +1685,80 @@ impl Host for LocalHost {
let plan = UpdatePlan::new(items).map_err(update_model_error)?;
Ok(UpdatePlanV1::new(plan))
}

fn outdated(&self, system: bool) -> Result<Vec<String>, CommandError> {
let scopes = if system {
vec![InstallScope::Project, InstallScope::Global]
} else {
vec![InstallScope::Project]
};
let mut lines = Vec::new();
let mut managed = BTreeMap::<String, Vec<PathBuf>>::new();
let mut store_roots = Vec::new();
let mut scan = Vec::new();
for scope in scopes {
let known = self.known_targets(scope)?;
let store = self.store(scope);
let names = match store.list(&known) {
Ok(names) => names,
Err(error) => {
// Without a readable lockfile, managed copies cannot be told from unmanaged ones.
lines.push(format!(
"Skill store unavailable in {} scope: {}",
scope.as_str(),
CommandError::store(error).message
));
continue;
}
};
for name in names {
let skill_name =
skilld_core::SkillName::parse(name.clone()).map_err(CommandError::domain)?;
let view = match store.view(&skill_name, &known) {
Ok(view) => view,
Err(error) => {
lines.push(format!(
"Skill {name} details unavailable: {}",
CommandError::store(error).message
));
continue;
}
};
let mut paths = vec![view.canonical_path.clone()];
for locked in &view.skill.targets {
if let Some(target) = known.iter().find(|target| target.agent == locked.agent) {
paths.push(target.root.join(name.as_str()));
}
}
managed
.entry(name.clone())
.or_default()
.extend(paths.iter().cloned());
lines.extend(self.report_outdated_view(&view, scope));
}
if system {
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)))
}
Ok(None) => lines.extend(outdated::render_unmanaged(&skill, None)),
Err(error) => {
lines.push(outdated::render_search_failure(&skill, &error.message));
}
}
}
}
if lines.is_empty() {
lines.push("No installed Skills found.".to_owned());
}
Ok(lines)
}
}

struct PendingUpdateComparison {
Expand Down Expand Up @@ -2024,6 +2124,80 @@ fn update_apply_failure(name: &str, outcome: RemoteComparisonOutcome) -> Command
}
}

impl LocalHost {
fn report_outdated_view(&self, view: &SkillView, scope: InstallScope) -> Vec<String> {
let name = &view.name;
let global = if scope == InstallScope::Global {
" --global"
} else {
""
};
match (&view.skill.source, &view.skill.source_status) {
(
LockedSource::Remote {
source, commit_sha, ..
},
skilld_core::SourceStatus::Verified { artifact_id, .. },
) => {
let state = skilld_core::RemoteSelector::parse(source)
.map_err(CommandError::remote)
.and_then(|selector| {
self.remote_provider()?
.source_state(&selector, artifact_id, commit_sha)
.map_err(CommandError::remote)
});
match state {
Ok(RemoteSourceState::Current) => {
vec![format!("Current Skill {name}.")]
}
Ok(RemoteSourceState::Stale { .. }) => {
vec![format!(
"Outdated Skill {name}. Run skilld update {name}{global}."
)]
}
Err(error) => {
vec![format!(
"Source state unavailable for Skill {name}: {}.",
error.message
)]
}
}
}
(LockedSource::Remote { source, .. }, skilld_core::SourceStatus::Unverified { .. }) => {
let agents = view
.skill
.targets
.iter()
.map(|locked| locked.agent)
.collect::<Vec<_>>();
let agent_flags = outdated::agent_flags(&agents);
vec![format!(
"Unverified Skill {name}. Run skilld install {source} --direct{global}{agent_flags} to update it."
)]
}
(LockedSource::BundledSkilld, _) => vec![format!("skilld-maintained Skill {name}.")],
_ => vec![format!("Local Skill {name}.")],
}
}

fn search_candidate(
&self,
name: &str,
) -> Result<Option<outdated::SkillCandidate>, CommandError> {
let results = self
.remote_provider()?
.search(name, 5)
.map_err(CommandError::remote)?;
let Some(result) = results.items.into_iter().find(|result| result.name == name) else {
return Ok(None);
};
let selector = result.selector().map_err(CommandError::remote)?;
Ok(Some(outdated::SkillCandidate {
selector: selector.canonical(),
stargazer_count: result.stargazer_count,
}))
}
}
struct StagedRemote {
_directory: tempfile::TempDir,
skill: PathBuf,
Expand Down Expand Up @@ -2301,7 +2475,8 @@ mod tests {
assert_eq!(
command_names(),
[
"search", "install", "list", "view", "remove", "update", "verify", "auth", "config"
"search", "install", "list", "view", "remove", "update", "verify", "outdated",
"auth", "config"
]
);
}
Expand Down
2 changes: 1 addition & 1 deletion crates/skilld-command/src/local_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1615,7 +1615,7 @@ fn stale_update_plan() -> StoreError {
StoreError::StalePlan("The Skill store changed while the update was preparing".to_owned())
}

fn normalize_path(path: &Path) -> PathBuf {
pub(crate) fn normalize_path(path: &Path) -> PathBuf {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
Expand Down
Loading