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: 9 additions & 2 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,15 +60,18 @@ The comparison of the three states — Manifest (desired), Lock (pinned), skills
(actual) — producing exactly one **reconciliation status** per skill. Owned by `skill list`.

**Reconciliation status**:
A closed vocabulary of eleven values. Two are **settled** — the skill needs no action:
A closed vocabulary of thirteen values. Two are **settled** — the skill needs no action:

- `ok` — the three states agree.
- `excluded` — the skill is required by something, but is outside the current selection.

The other nine each name a specific disagreement, and `skill list --check` fails on any of
The other eleven each name a specific disagreement, and `skill list --check` fails on any of
them. Because `--check` is how callers gate a build, this vocabulary is a compatibility
surface rather than an internal detail:

- `managed-blocked` — the **managed state** blocks the installed content's digest.
- `managed-not-allowed` — the managed state allows only listed content, and the installed
content isn't listed.
- `missing-lock` — in the Manifest, absent from the Lock.
- `missing-content` — selected, but absent from the skills directory.
- `intent-mismatch` — the Manifest and the Lock disagree about where the skill comes from.
Expand All @@ -79,6 +82,10 @@ surface rather than an internal detail:
- `ownership-conflict` — two **dependency roots** require the same skill at different content.
- `extraneous` — installed, but no dependency root requires it.

The two `managed-` values appear only on a machine that follows a verified managed state, and
take precedence over every other value; `managed-blocked` comes first. `--check` fails on
them even when the skill is outside the current selection.

_Avoid_: `missing` and `mismatch` — neither is a status; name which one.

**Installation scope**:
Expand Down
14 changes: 12 additions & 2 deletions crates/fastskill-cli/src/commands/list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,7 @@ pub async fn execute_list(
.cloned()
.collect();

let situation = service.managed_gate().situation();
let mut check_failures = Vec::new();
let mut rows: Vec<ListRow> = all_ids
.into_iter()
Expand Down Expand Up @@ -418,7 +419,13 @@ pub async fn execute_list(
let desired_entry = desired_entries.get(&id);
let mutable = locked_entry
.is_some_and(|entry| matches!(entry.origin, Origin::Local { editable: true, .. }));
let reconciliation = if !selected && !owners.is_empty() {
let storage = service.config().skill_storage_path.join(&id);
let managed = installed
.then(|| ReconciliationStatus::managed(&situation, &storage, mutable))
.flatten();
let reconciliation = if let Some(status) = managed {
status
} else if !selected && !owners.is_empty() {
ReconciliationStatus::Excluded
} else if selected && desired_entry.is_some() && locked_entry.is_none() {
ReconciliationStatus::MissingLock
Expand Down Expand Up @@ -467,7 +474,10 @@ pub async fn execute_list(
} else {
ReconciliationStatus::Ok
};
if args.check && selected && !reconciliation.is_settled() {
if args.check
&& (selected || reconciliation.is_managed())
&& !reconciliation.is_settled()
{
check_failures.push(format!("{id}: {reconciliation}"));
}
let desired_constraint = desired_entry.map(|entry| match &entry.origin {
Expand Down
66 changes: 65 additions & 1 deletion crates/fastskill-cli/src/commands/list/global.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ pub(super) async fn execute_global_list(
.cloned()
.collect::<Vec<_>>();
let selected_ids = closure(&lock, &selected_roots);
let situation = service.managed_gate().situation();
let mut failures = Vec::new();
let mut rows = Vec::new();
for id in ids {
Expand All @@ -74,7 +75,12 @@ pub(super) async fn execute_global_list(
let selected = selected_ids.contains(&id);
let mutable = locked
.is_some_and(|entry| matches!(entry.origin, Origin::Local { editable: true, .. }));
let reconciliation = if locked.is_some() && !selected {
let storage = service.config().skill_storage_path.join(&id);
let managed =
actual.and_then(|_| ReconciliationStatus::managed(&situation, &storage, mutable));
let reconciliation = if let Some(status) = managed {
status
} else if locked.is_some() && !selected {
ReconciliationStatus::Excluded
} else {
reconcile(service, &id, locked, actual, mutable)
Expand Down Expand Up @@ -414,4 +420,62 @@ mod tests {
Err(CliError::Config(message)) if message.contains("missing-content")
));
}

#[tokio::test]
async fn global_list_reports_blocked_content_first() {
use fastskill_core::core::managed::{ManagedGate, ManagedSource, ManagedState, Situation};
let _lock = fastskill_core::test_utils::DIR_MUTEX
.lock()
.unwrap_or_else(|error| error.into_inner());
let temp = TempDir::new().unwrap();
let _xdg = EnvGuard::set("XDG_CONFIG_HOME", &temp.path().join("config"));
let storage = temp.path().join("skills");
let installed = storage.join("demo");
std::fs::create_dir_all(&installed).unwrap();
std::fs::write(
installed.join("SKILL.md"),
"---\nname: demo\nversion: 1.0.0\ndescription: demo\n---\n# demo\n",
)
.unwrap();
let digest = managed_tree_digest(&installed).unwrap();
let path = global_lock_path().unwrap();
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let mut lock = GlobalSkillsLock::new_empty();
lock.covered_roots.push("demo".to_string());
lock.skills
.push(entry("demo", "1.0.0", Some(digest.clone())));
lock.save_to_file(&path).unwrap();
let source = ManagedSource::File(temp.path().join("state.dsse"));
let state = serde_json::json!({
"format_version": 1,
"issued_at": "2026-10-09T11:00:00Z",
"expires_at": "2026-10-16T11:00:00Z",
"source": source.as_str(),
"subject": "team-a",
"skills": [],
"allowed": "any",
"blocked": [{ "digest": digest }],
});
let situation = Situation::State {
state: Box::new(ManagedState::parse(&serde_json::to_vec(&state).unwrap()).unwrap()),
source,
expired: false,
};
let service = service(&storage)
.await
.with_managed_gate(ManagedGate::fixed(situation));
let args = ListArgs {
format: None,
json: false,
details: false,
check: true,
only: None,
without: None,
skills_dir: None,
};
assert!(matches!(
execute_global_list(&service, args, OutputFormat::Json).await,
Err(CliError::Config(message)) if message.contains("demo: managed-blocked")
));
}
}
56 changes: 56 additions & 0 deletions crates/fastskill-cli/src/commands/list/reconciliation_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -271,3 +271,59 @@ async fn selected_roots_check_transitive_content_and_exclude_the_same_closure_by
let rows: Vec<Value> = serde_json::from_str(&output).unwrap();
assert_eq!(row(&rows, "edited")["reconciliation"], "excluded");
}

fn managed_situation(listed: &str, blocked: &str) -> fastskill_core::core::managed::Situation {
use fastskill_core::core::managed::{ManagedSource, ManagedState, Situation};
let source = ManagedSource::File(env::temp_dir().join("state.dsse"));
let state = serde_json::json!({
"format_version": 1,
"issued_at": "2026-10-09T11:00:00Z",
"expires_at": "2026-10-16T11:00:00Z",
"source": source.as_str(),
"subject": "team-a",
"skills": [{ "id": "listed", "digest": listed, "artifact": "listed.zip" }],
"allowed": "listed",
"blocked": [{ "digest": blocked }],
});
Situation::State {
state: Box::new(ManagedState::parse(&serde_json::to_vec(&state).unwrap()).unwrap()),
source,
expired: false,
}
}

// DIR_MUTEX is deliberately held across awaits so cwd-changing tests never run concurrently.
#[allow(clippy::await_holding_lock)]
#[tokio::test]
async fn managed_statuses_take_precedence_and_fail_the_check() {
use fastskill_core::core::content_digest::content_digest;
let _lock = fastskill_core::test_utils::DIR_MUTEX
.lock()
.unwrap_or_else(|error| error.into_inner());
let fixture = ProjectFixture::new("");
let listed = fixture.skill("listed", "1.0.0");
fixture.skill("blocked", "1.0.0");
fixture.skill("stray", "1.0.0");
let mut lock = ProjectSkillsLock::new_empty();
lock.bundles = vec![bundle(
"team",
&[("listed", &listed), ("blocked", "recorded-elsewhere")],
)];
let listed_digest = content_digest(&fixture.storage.join("listed")).unwrap();
let blocked_digest = content_digest(&fixture.storage.join("blocked")).unwrap();
let service = fixture.service(&lock).await.with_managed_gate(
fastskill_core::core::managed::ManagedGate::fixed(managed_situation(
&listed_digest,
&blocked_digest,
)),
);

let (result, output) = crate::output::capture(execute_list(&service, json_args(), false)).await;
let error = result.unwrap_err().to_string();
let rows: Vec<Value> = serde_json::from_str(&output).unwrap();
assert_eq!(row(&rows, "listed")["reconciliation"], "ok");
assert_eq!(row(&rows, "blocked")["reconciliation"], "managed-blocked");
assert_eq!(row(&rows, "stray")["reconciliation"], "managed-not-allowed");
assert!(error.contains("blocked: managed-blocked"), "{error}");
assert!(error.contains("stray: managed-not-allowed"), "{error}");
}
29 changes: 28 additions & 1 deletion crates/fastskill-cli/src/commands/list/status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,17 @@
//! the renderers and the `--check` gate to account for it instead of silently
//! treating it as a failure.

use fastskill_core::core::managed::{ManagedSkillStatus, Situation};
use std::path::Path;

/// A single skill's status from comparing manifest (desired), lock (pinned) and
/// skills directory (actual).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum ReconciliationStatus {
/// The managed state blocks the installed content's digest.
ManagedBlocked,
/// The managed state allows only listed content, and the installed content isn't listed.
ManagedNotAllowed,
/// Desired, pinned and actual agree.
Ok,
/// Owned by a bundle or override that the current selection excludes.
Expand Down Expand Up @@ -38,7 +45,9 @@ pub(super) enum ReconciliationStatus {
impl ReconciliationStatus {
/// Every status, so tests can assert over the whole vocabulary.
#[cfg(test)]
pub(super) const ALL: [Self; 11] = [
pub(super) const ALL: [Self; 13] = [
Self::ManagedBlocked,
Self::ManagedNotAllowed,
Self::Ok,
Self::Excluded,
Self::MissingLock,
Expand All @@ -56,6 +65,8 @@ impl ReconciliationStatus {
/// table, JSON and XML renderings cannot drift apart.
pub(super) fn as_str(self) -> &'static str {
match self {
Self::ManagedBlocked => "managed-blocked",
Self::ManagedNotAllowed => "managed-not-allowed",
Self::Ok => "ok",
Self::Excluded => "excluded",
Self::MissingLock => "missing-lock",
Expand All @@ -70,6 +81,22 @@ impl ReconciliationStatus {
}
}

/// The managed status of installed content, which takes precedence over every other status.
/// Content that can't be digested is left to the content checks.
pub(super) fn managed(situation: &Situation, installed: &Path, mutable: bool) -> Option<Self> {
match situation.status_of(installed, mutable) {
Ok(Some(ManagedSkillStatus::Blocked)) => Some(Self::ManagedBlocked),
Ok(Some(ManagedSkillStatus::NotAllowed)) => Some(Self::ManagedNotAllowed),
Ok(None) | Err(_) => None,
}
}

/// Whether this status comes from the managed state. `skill list --check` fails on these
/// whether or not the current selection includes the skill.
pub(super) fn is_managed(self) -> bool {
matches!(self, Self::ManagedBlocked | Self::ManagedNotAllowed)
}

/// Whether `skill list --check` accepts this status. `excluded` counts as
/// settled because the skill is deliberately outside the current selection.
pub(super) fn is_settled(self) -> bool {
Expand Down
82 changes: 82 additions & 0 deletions crates/fastskill-cli/src/registration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ use std::future::Future;
use std::sync::Arc;

use crate::output::{self, Mode};
use chrono::Utc;
use fastskill_core::core::managed::{CommandPolicy, Situation};

pub mod surface;

Expand Down Expand Up @@ -60,6 +62,7 @@ where
let spec = Arc::new(T::command_spec());
let id: Arc<str> = Arc::from(path.leaf().unwrap_or(""));
let handler = Arc::new(handler);
let managed = follows_managed_policy(path);

Command {
id,
Expand All @@ -76,6 +79,9 @@ where
// drain below.
let fut = handler(ctx, typed);
Box::pin(async move {
if managed {
managed_policy(Situation::for_current_user(Utc::now()).command_policy())?;
}
match output::mode() {
Mode::Direct => fut.await,
Mode::Capture if output::has_active_sink() => fut.await,
Expand All @@ -94,6 +100,41 @@ where
}
}

/// Command groups that read or change skills. They follow the managed state's command policy;
/// `managed`, `cache`, `repo` and `cli` stay available so a machine can always recover.
const SKILL_GROUPS: &[&str] = &[
"skill",
"bundle",
"project",
"analysis",
"index",
"eval",
"optimization",
"marketplace",
];

/// Whether the command at `path` follows the managed command policy.
fn follows_managed_policy(path: &CommandPath) -> bool {
match path.0.as_slice() {
[group, ..] if SKILL_GROUPS.contains(&group.as_str()) => true,
[group, leaf] => leaf == "serve" && (group == "server" || group == "mcp"),
_ => false,
}
}

/// Apply a command policy: refuse with a configuration error, or print the warning to stderr
/// and run.
fn managed_policy(policy: CommandPolicy) -> anyhow::Result<()> {
match policy {
CommandPolicy::Run => Ok(()),
CommandPolicy::Warn(message) => {
eprintln!("warning: {message}");
Ok(())
}
CommandPolicy::Refuse(message) => Err(crate::error::CliError::Config(message).into()),
}
}

impl AppBuilderExt for AppBuilder {
fn register_out<T, F, Fut>(self, path: CommandPath, handler: F) -> anyhow::Result<Self>
where
Expand All @@ -115,3 +156,44 @@ impl AppBuilderExt for AppBuilder {
self.register_command_at(&path, command)
}
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;

#[test]
fn skill_commands_follow_the_managed_policy_and_recovery_commands_do_not() {
let path = |segments: &[&str]| CommandPath::new(segments).unwrap();
for segments in [
&["skill", "add"][..],
&["bundle", "override"],
&["project", "install"],
&["eval", "run"],
&["server", "serve"],
&["mcp", "serve"],
] {
assert!(follows_managed_policy(&path(segments)), "{segments:?}");
}
for segments in [
&["managed", "apply"][..],
&["cache", "clean"],
&["repo", "list"],
&["cli", "doctor"],
&["mcp", "install"],
&["server"],
] {
assert!(!follows_managed_policy(&path(segments)), "{segments:?}");
}
}

#[test]
fn a_refusal_is_a_configuration_error_and_a_warning_runs() {
assert!(managed_policy(CommandPolicy::Run).is_ok());
assert!(managed_policy(CommandPolicy::Warn("expired".to_string())).is_ok());
let error = managed_policy(CommandPolicy::Refuse("required".to_string())).unwrap_err();
let error = error.downcast_ref::<crate::error::CliError>().unwrap();
assert_eq!(error.exit_code(), 2);
assert!(error.to_string().contains("required"));
}
}
Loading
Loading