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
16 changes: 16 additions & 0 deletions CHANGELOG
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@

### Breaking Changes

- **Content digests now have a versioned form, `sha256-tree-v2:<hex>`.** Every `checksum` in
`skills.lock` and `global-skills.lock`, every bundle release and member digest, and every new
bundle artifact is written in this form. Digests written by earlier releases (64 bare hex
characters) are still accepted when they match, with a one-time warning, and are replaced the
next time fastskill rewrites that record; `project install --lock` keeps working without a
manual edit. Earlier releases cannot verify the new form and report a checksum mismatch, so
upgrade every machine and CI job that shares a project. File names that are not valid UTF-8 are
now refused. See [ADR-0017](docs/adr/0017-versioned-content-digests.md).

- **`skill-project.toml` is now written with `schema_version = "2"`.** In schema 2 every git
origin `url` is a plain clone URL: the branch or tag lives only in `ref`, the subdirectory
only in `subdir`. Manifests that declare `"1"` — or declare nothing — are still read and are
Expand Down Expand Up @@ -97,6 +106,13 @@

### Fixed

- **Two different skill directories could share a content digest.** The digest hashed each
file's contents without their length, so the boundary between one file and the next was
ambiguous, and content that differed from what a Lock or bundle pinned could pass
verification. Each path and each file's contents are now length-prefixed. Legacy digests are
still accepted until their record is rewritten (see Breaking Changes); rebuild bundles with
`fastskill bundle build` to give them current-form digests.

- **`project install` reports every dependency that fails to resolve, not just the first.** A
missing local path, a moved git subdirectory or a renamed skill used to stop the run at the first
bad entry, so fixing a large manifest took one run per error. All root dependencies are now
Expand Down
8 changes: 8 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,14 @@ _Avoid_: project file, config.
Used by `project install --lock` for reproducible installs.
_Avoid_: lockfile (in prose), pin file.

**Content digest**:
The string that names the exact files of a skill directory: `sha256-tree-v2:` plus a SHA-256
over every file's relative path and contents, each length-prefixed. A Lock pins it as `checksum`;
bundle records and artifacts pin it per member. A **legacy digest** (64 bare hex characters,
written before ADR-0017) is still accepted with a warning and is replaced when its record is
rewritten. See [ADR-0017](./docs/adr/0017-versioned-content-digests.md).
_Avoid_: hash, checksum (except as the Lock field name).

**Installed skill**:
A skill physically present in the **skills directory** (`.claude/skills/` by default). The skills
directory — not the Manifest — is the source of truth for what `skill list` reports.
Expand Down
5 changes: 3 additions & 2 deletions crates/fastskill-cli/src/commands/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -646,10 +646,11 @@ async fn build_install_targets(
let unchanged = current.as_ref().is_some_and(|installed| {
installed.version == target.resolved_version
&& target.checksum.as_ref().is_some_and(|checksum| {
fastskill_core::core::install::content_digest(
fastskill_core::core::content_digest::content_digest_matches(
checksum,
&service.config().skill_storage_path.join(&target.id),
)
.is_ok_and(|actual| &actual == checksum)
.unwrap_or(false)
})
});
let current_revision = current.map(|installed| installed.version);
Expand Down
4 changes: 2 additions & 2 deletions crates/fastskill-cli/src/commands/install/change.rs
Original file line number Diff line number Diff line change
Expand Up @@ -295,8 +295,8 @@ fn candidate_matches_lock_and_install(
.zip(source.canonicalize().ok())
.is_some_and(|(installed, source)| installed == source),
_ => entry.resolved.checksum.as_ref().is_some_and(|expected| {
fastskill_core::core::install::content_digest(&installed)
.is_ok_and(|actual| &actual == expected)
fastskill_core::core::content_digest::content_digest_matches(expected, &installed)
.unwrap_or(false)
}),
}
}
Expand Down
4 changes: 2 additions & 2 deletions crates/fastskill-cli/src/commands/install/global.rs
Original file line number Diff line number Diff line change
Expand Up @@ -317,8 +317,8 @@ fn installed_matches(path: &Path, entry: &GlobalLockedSkillEntry) -> bool {
.zip(source.canonicalize().ok())
.is_some_and(|(installed, source)| installed == source),
_ => entry.resolved.checksum.as_ref().is_some_and(|expected| {
fastskill_core::core::install::content_digest(path)
.is_ok_and(|actual| &actual == expected)
fastskill_core::core::content_digest::content_digest_matches(expected, path)
.unwrap_or(false)
}),
}
}
Expand Down
5 changes: 4 additions & 1 deletion crates/fastskill-cli/src/commands/install/plan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -406,7 +406,10 @@ pub(crate) fn validate_declared_bundle_members(
.filter(|member| member.id == planned.prepared.id())
{
if !member.overridable
&& planned.prepared.resolved().checksum.as_deref() != Some(member.digest.as_str())
&& !planned
.prepared
.checksum_matches(&member.digest)
.map_err(CliError::Service)?
{
return Err(CliError::Config(format!(
"individual skill '{}' conflicts with member content from bundle '{}'",
Expand Down
49 changes: 22 additions & 27 deletions crates/fastskill-cli/src/commands/list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,12 @@ use cli_framework::command::{FromArgValueMap, IntoCommandSpec};
use cli_framework::spec::arg_spec::{ArgKind, ArgSpec, ArgValueType, Cardinality};
use cli_framework::spec::command_tree::CommandSpec;
use cli_framework::spec::value::ArgValue;
use fastskill_core::core::content_digest::{all_match, recorded_digests_conflict};
use fastskill_core::core::lock::ProjectSkillsLock;
use fastskill_core::core::manifest::SkillProjectToml;
use fastskill_core::core::origin::Origin;
use fastskill_core::core::ownership::ProjectOwnership;
use fastskill_core::core::project::resolve_project_file;
use fastskill_core::core::project_removal::managed_tree_digest;
use fastskill_core::core::service::FastSkillService;
use fastskill_core::OutputFormat;
use std::collections::{HashMap, HashSet};
Expand Down Expand Up @@ -435,44 +435,30 @@ pub async fn execute_list(
} else if mutable {
ReconciliationStatus::Ok
} else if let Some(expected) = &locked.resolved.checksum {
match managed_tree_digest(&service.config().skill_storage_path.join(&id)) {
Ok(actual) if &actual == expected => ReconciliationStatus::Ok,
Ok(_) => ReconciliationStatus::ContentMismatch,
Err(_) => ReconciliationStatus::IntegrityError,
}
content_status(&[expected], &service.config().skill_storage_path.join(&id))
} else {
ReconciliationStatus::InsufficientIntegrity
}
} else if selected && (!bundle_members.is_empty() || override_entry.is_some()) {
let expected = override_entry
.map(|entry| entry.digest.as_str())
.or_else(|| bundle_members.first().map(|member| member.digest.as_str()));
let owners_agree = override_entry.is_some()
|| bundle_members
let expected: Vec<&str> = match override_entry {
Some(entry) => vec![entry.digest.as_str()],
None => bundle_members
.iter()
.all(|member| Some(member.digest.as_str()) == expected);
match (owners_agree, expected) {
(false, _) => ReconciliationStatus::OwnershipConflict,
(true, Some(expected)) => {
match managed_tree_digest(&service.config().skill_storage_path.join(&id)) {
Ok(actual) if actual == expected => ReconciliationStatus::Ok,
Ok(_) => ReconciliationStatus::ContentMismatch,
Err(_) => ReconciliationStatus::IntegrityError,
}
}
_ => ReconciliationStatus::InsufficientIntegrity,
.map(|member| member.digest.as_str())
.collect(),
};
if recorded_digests_conflict(expected.iter().copied()) {
ReconciliationStatus::OwnershipConflict
} else {
content_status(&expected, &service.config().skill_storage_path.join(&id))
}
} else if let (Some(locked), Some(actual)) = (locked_entry, installed_map.get(&id)) {
if actual.version != locked.resolved.version {
ReconciliationStatus::RevisionMismatch
} else if mutable {
ReconciliationStatus::Ok
} else if let Some(expected) = &locked.resolved.checksum {
match managed_tree_digest(&service.config().skill_storage_path.join(&id)) {
Ok(actual) if &actual == expected => ReconciliationStatus::Ok,
Ok(_) => ReconciliationStatus::ContentMismatch,
Err(_) => ReconciliationStatus::IntegrityError,
}
content_status(&[expected], &service.config().skill_storage_path.join(&id))
} else {
ReconciliationStatus::InsufficientIntegrity
}
Expand Down Expand Up @@ -542,6 +528,15 @@ pub async fn execute_list(
clippy::panic,
clippy::await_holding_lock
)]
/// Compare installed content with every recorded digest; a legacy digest is accepted.
fn content_status(expected: &[&str], installed: &std::path::Path) -> ReconciliationStatus {
match all_match(expected, installed) {
Ok(true) => ReconciliationStatus::Ok,
Ok(false) => ReconciliationStatus::ContentMismatch,
Err(_) => ReconciliationStatus::IntegrityError,
}
}

#[cfg(test)]
#[path = "list/tests.rs"]
mod tests;
10 changes: 3 additions & 7 deletions crates/fastskill-cli/src/commands/list/global.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
use super::{format_source_info, origin_location_label, ListArgs};
use super::{content_status, format_source_info, origin_location_label, ListArgs};
use crate::error::{CliError, CliResult};
use fastskill_core::core::lock::{global_lock_path, GlobalSkillsLock};
use fastskill_core::core::project_removal::managed_tree_digest;
use fastskill_core::core::Origin;
use fastskill_core::{FastSkillService, OutputFormat};
use std::collections::{BTreeSet, HashMap, HashSet};
Expand Down Expand Up @@ -193,11 +192,7 @@ fn reconcile(
(Some(_), Some(_)) if mutable => ReconciliationStatus::Ok,
(Some(locked), Some(_)) => match &locked.resolved.checksum {
Some(expected) => {
match managed_tree_digest(&service.config().skill_storage_path.join(id)) {
Ok(actual) if &actual == expected => ReconciliationStatus::Ok,
Ok(_) => ReconciliationStatus::ContentMismatch,
Err(_) => ReconciliationStatus::IntegrityError,
}
content_status(&[expected], &service.config().skill_storage_path.join(id))
}
None => ReconciliationStatus::InsufficientIntegrity,
},
Expand All @@ -213,6 +208,7 @@ mod tests {
use chrono::Utc;
use fastskill_core::core::lock::GlobalLockedSkillEntry;
use fastskill_core::core::origin::Resolved;
use fastskill_core::core::project_removal::managed_tree_digest;
use fastskill_core::{ServiceConfig, SkillDefinition, SkillId};
use std::path::Path;
use tempfile::TempDir;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use super::*;
use fastskill_core::core::lock::{
ProjectLockedBundleEntry, ProjectLockedBundleMember, ProjectLockedPersonalOverride,
};
use fastskill_core::core::project_removal::managed_tree_digest;
use serde_json::Value;

struct ProjectFixture {
Expand Down
1 change: 1 addition & 0 deletions crates/fastskill-cli/src/commands/list/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use super::*;
use fastskill_core::core::lock::{ProjectLockedSkillEntry, ProjectSkillsLock};
use fastskill_core::core::origin::GitRef;
use fastskill_core::core::origin::Resolved;
use fastskill_core::core::project_removal::managed_tree_digest;
use fastskill_core::{FastSkillService, ServiceConfig};
use std::fs;
use tempfile::TempDir;
Expand Down
6 changes: 3 additions & 3 deletions crates/fastskill-cli/src/commands/remove/global.rs
Original file line number Diff line number Diff line change
Expand Up @@ -198,9 +198,9 @@ fn validate_unmodified(
"global skill '{id}' has no locked content digest; restore it before removal"
))
})?;
let actual =
fastskill_core::core::install::content_digest(&path).map_err(CliError::Service)?;
if &actual != expected {
if !fastskill_core::core::content_digest::content_digest_matches(expected, &path)
.map_err(CliError::Service)?
{
return Err(CliError::Config(format!(
"global skill '{id}' is locally modified; FastSkill will not delete it"
)));
Expand Down
11 changes: 5 additions & 6 deletions crates/fastskill-cli/src/commands/update/global.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ use crate::config::create_service_config;
use crate::error::{CliError, CliResult};
use crate::utils::messages;
use fastskill_core::core::contained_path::{remove_contained, ContainedPath};
use fastskill_core::core::content_digest::content_digest_matches;
use fastskill_core::core::install::PreparedSkill;
use fastskill_core::core::lock::{global_lock_path, GlobalLockedSkillEntry, GlobalSkillsLock};
use fastskill_core::core::project_removal::managed_tree_digest;
use fastskill_core::core::resolution::{
prepare_resolution, prepare_resolution_preview, ResolutionRoot,
};
Expand Down Expand Up @@ -142,8 +142,7 @@ pub(crate) fn validate_global_replacement_content(
entry.id
))
})?;
let actual = managed_tree_digest(&installed).map_err(CliError::Service)?;
if &actual != expected {
if !content_digest_matches(expected, &installed).map_err(CliError::Service)? {
return Err(CliError::Config(format!(
"installed global skill '{}' was modified; restore or remove local edits before replacement",
entry.id
Expand Down Expand Up @@ -525,9 +524,9 @@ pub(super) async fn execute_update_global(
));
continue;
};
match managed_tree_digest(&installed) {
Ok(actual) if &actual == expected => {}
Ok(_) => {
match content_digest_matches(expected, &installed) {
Ok(true) => {}
Ok(false) => {
failures.push(format!(
"{}: installed content was modified; restore it before updating",
entry.id
Expand Down
1 change: 1 addition & 0 deletions crates/fastskill-cli/src/commands/update/global_tests.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use super::*;
use fastskill_core::core::project_removal::managed_tree_digest;

struct EnvGuard {
name: &'static str,
Expand Down
Loading
Loading