use std::collections::HashMap;
use std::path::Path;
use console::style;
use skillfile_core::error::SkillfileError;
use skillfile_core::lock::{lock_key, read_lock};
use skillfile_core::models::{short_sha, EntityType, Entry, LockEntry, Manifest, SourceFields};
use skillfile_core::parser::MANIFEST_NAME;
use skillfile_core::patch::{
apply_patch_pure, dir_patch_path, has_dir_patch, has_patch, read_patch, walkdir,
};
use skillfile_deploy::paths::{installed_dir_file_sets, installed_paths};
use skillfile_sources::strategy::{content_file, is_cached_dir_entry, meta_sha};
use skillfile_sources::sync::vendor_dir_for;
struct DirCheckCtx<'a> {
entry: &'a Entry,
vdir: &'a Path,
installed: &'a HashMap<String, std::path::PathBuf>,
repo_root: &'a Path,
}
fn is_cache_file_modified(cache_file: &Path, ctx: &DirCheckCtx<'_>) -> Result<bool, ()> {
let filename = cache_file
.strip_prefix(ctx.vdir)
.map_err(|_| ())?
.to_string_lossy()
.to_string();
let inst_path = match ctx.installed.get(&filename) {
Some(p) if p.exists() => p,
_ => return Ok(false),
};
let cache_text = std::fs::read_to_string(cache_file).map_err(|_| ())?;
let expected_text = expected_dir_file_text(&filename, &cache_text, ctx)?;
let installed_text = std::fs::read_to_string(inst_path).map_err(|_| ())?;
Ok(installed_text != expected_text)
}
fn check_dir_files_modified(
entry: &Entry,
manifest: &Manifest,
repo_root: &Path,
) -> Result<bool, ()> {
let installed_sets = installed_dir_file_sets(entry, manifest, repo_root).map_err(|_| ())?;
if installed_sets.iter().all(HashMap::is_empty) {
return Ok(false);
}
let vdir = vendor_dir_for(entry, repo_root);
if !vdir.is_dir() {
return Ok(false);
}
let cache_files: Vec<_> = walkdir(&vdir)
.into_iter()
.filter(|cache_file| cache_file.file_name().is_some_and(|n| n != ".meta"))
.collect();
for installed in &installed_sets {
if installed.is_empty() {
continue;
}
let ctx = DirCheckCtx {
entry,
vdir: &vdir,
installed,
repo_root,
};
if installed_set_modified(&cache_files, &ctx)? {
return Ok(true);
}
}
Ok(false)
}
fn installed_set_modified(
cache_files: &[std::path::PathBuf],
ctx: &DirCheckCtx<'_>,
) -> Result<bool, ()> {
for cache_file in cache_files {
if is_cache_file_modified(cache_file, ctx)? {
return Ok(true);
}
}
Ok(false)
}
fn is_dir_modified_local(entry: &Entry, manifest: &Manifest, repo_root: &Path) -> bool {
check_dir_files_modified(entry, manifest, repo_root).unwrap_or(false)
}
fn check_single_file_modified(
entry: &Entry,
manifest: &Manifest,
repo_root: &Path,
) -> Result<bool, ()> {
let installed_paths = installed_paths(entry, manifest, repo_root).map_err(|_| ())?;
if installed_paths.iter().all(|path| !path.exists()) {
return Ok(false);
}
let vdir = vendor_dir_for(entry, repo_root);
let cf = content_file(entry);
if cf.is_empty() {
return Ok(false);
}
let cache_file = vdir.join(&cf);
if !cache_file.exists() {
return Ok(false);
}
let cache_text = std::fs::read_to_string(&cache_file).map_err(|_| ())?;
let expected_text = expected_single_file_text(entry, &cache_text, repo_root)?;
for installed_path in installed_paths {
if !installed_path.exists() {
continue;
}
let installed_text = std::fs::read_to_string(&installed_path).map_err(|_| ())?;
if installed_text != expected_text {
return Ok(true);
}
}
Ok(false)
}
fn expected_single_file_text(
entry: &Entry,
cache_text: &str,
repo_root: &Path,
) -> Result<String, ()> {
if !has_patch(entry, repo_root) {
return Ok(cache_text.to_string());
}
let patch_text = read_patch(entry, repo_root).map_err(|_| ())?;
apply_patch_pure(cache_text, &patch_text).map_err(|_| ())
}
fn expected_dir_file_text(
filename: &str,
cache_text: &str,
ctx: &DirCheckCtx<'_>,
) -> Result<String, ()> {
let patch_path = dir_patch_path(ctx.entry, filename, ctx.repo_root);
if !patch_path.exists() {
return Ok(cache_text.to_string());
}
let patch_text = std::fs::read_to_string(patch_path).map_err(|_| ())?;
apply_patch_pure(cache_text, &patch_text).map_err(|_| ())
}
pub(crate) fn is_modified_local(entry: &Entry, manifest: &Manifest, repo_root: &Path) -> bool {
if matches!(entry.source, SourceFields::Local { .. }) {
return false;
}
let vdir = vendor_dir_for(entry, repo_root);
if is_cached_dir_entry(entry, &vdir) {
return is_dir_modified_local(entry, manifest, repo_root);
}
check_single_file_modified(entry, manifest, repo_root).unwrap_or(false)
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum InstallState {
Installed,
PartiallyInstalled,
NotInstalled,
}
fn install_state(entry: &Entry, manifest: &Manifest, repo_root: &Path) -> InstallState {
let vdir = vendor_dir_for(entry, repo_root);
if is_cached_dir_entry(entry, &vdir) {
dir_install_state(entry, manifest, repo_root).unwrap_or(InstallState::NotInstalled)
} else {
single_file_install_state(entry, manifest, repo_root).unwrap_or(InstallState::NotInstalled)
}
}
fn single_file_install_state(
entry: &Entry,
manifest: &Manifest,
repo_root: &Path,
) -> Result<InstallState, ()> {
let paths = installed_paths(entry, manifest, repo_root).map_err(|_| ())?;
Ok(install_state_from_counts(
paths.len(),
paths.iter().filter(|path| path.exists()).count(),
))
}
fn dir_install_state(
entry: &Entry,
manifest: &Manifest,
repo_root: &Path,
) -> Result<InstallState, ()> {
let file_sets = installed_dir_file_sets(entry, manifest, repo_root).map_err(|_| ())?;
Ok(install_state_from_counts(
file_sets.len(),
file_sets.iter().filter(|files| !files.is_empty()).count(),
))
}
fn install_state_from_counts(total: usize, present: usize) -> InstallState {
if total == 0 || present == 0 {
return InstallState::NotInstalled;
}
if present == total {
InstallState::Installed
} else {
InstallState::PartiallyInstalled
}
}
struct StatusContext<'a> {
manifest: &'a Manifest,
repo_root: &'a Path,
locked: &'a std::collections::BTreeMap<String, LockEntry>,
check_upstream: bool,
sha_cache: &'a mut HashMap<(String, String), String>,
col_w: usize,
}
fn resolve_upstream_sha(
ctx: &mut StatusContext<'_>,
owner_repo: &str,
ref_: &str,
) -> Result<String, SkillfileError> {
let cache_key = (owner_repo.to_string(), ref_.to_string());
if let Some(cached) = ctx.sha_cache.get(&cache_key) {
return Ok(cached.clone());
}
let client = skillfile_sources::http::UreqClient::new();
let resolved = skillfile_sources::resolver::resolve_github_sha(&client, owner_repo, ref_)?;
ctx.sha_cache.insert(cache_key, resolved.clone());
Ok(resolved)
}
fn upstream_status_for_github(
ctx: &mut StatusContext<'_>,
entry: &Entry,
sha: &str,
) -> Result<String, SkillfileError> {
let SourceFields::Github {
owner_repo, ref_, ..
} = &entry.source
else {
let sha_short = short_sha(sha);
return Ok(format!(
"{} {}",
style("locked").dim(),
style(format!("sha={sha_short}")).dim(),
));
};
let owner_repo = owner_repo.clone();
let ref_ = ref_.clone();
let upstream_sha = resolve_upstream_sha(ctx, &owner_repo, &ref_)?;
let sha_short = short_sha(sha);
if upstream_sha == sha {
Ok(format!(
"{} {}",
style("up to date").green(),
style(format!("sha={sha_short}")).dim(),
))
} else {
let upstream_short = short_sha(&upstream_sha);
Ok(format!(
"{} locked={} upstream={}",
style("outdated").yellow(),
style(sha_short).dim(),
style(upstream_short).cyan(),
))
}
}
fn build_annotation(entry: &Entry, ctx: &StatusContext<'_>) -> String {
let mut parts: Vec<String> = Vec::new();
match install_state(entry, ctx.manifest, ctx.repo_root) {
InstallState::Installed => {}
InstallState::PartiallyInstalled => {
parts.push(style("[partial install]").yellow().to_string());
}
InstallState::NotInstalled => {
parts.push(style("[not installed]").red().to_string());
}
}
if has_patch(entry, ctx.repo_root) || has_dir_patch(entry, ctx.repo_root) {
parts.push(style("[pinned]").cyan().to_string());
}
if is_modified_local(entry, ctx.manifest, ctx.repo_root) {
parts.push(style("[modified]").yellow().to_string());
}
if parts.is_empty() {
String::new()
} else {
format!(" {}", parts.join(" "))
}
}
fn format_entry_status(
entry: &Entry,
ctx: &mut StatusContext<'_>,
) -> Result<String, SkillfileError> {
let key = lock_key(entry);
let name = &entry.name;
let col_w = ctx.col_w;
if let SourceFields::Local { path } = &entry.source {
let status = if ctx.repo_root.join(path).exists() {
style("local").green().to_string()
} else {
format!(
"{} {} path missing: {path}",
style("local").green(),
style("\u{2717}").red(),
)
};
return Ok(format!("{name:<col_w$} {status}"));
}
let Some(locked_info) = ctx.locked.get(&key) else {
return Ok(format!("{name:<col_w$} {}", style("unlocked").yellow()));
};
let sha = &locked_info.sha;
let vdir = vendor_dir_for(entry, ctx.repo_root);
let meta = meta_sha(&vdir);
let sha_short = short_sha(sha);
let base_status = if meta.as_deref() != Some(sha.as_str()) {
format!(
"{} {}",
style("locked").dim(),
style(format!("sha={sha_short} (missing or stale)")).yellow(),
)
} else if ctx.check_upstream {
upstream_status_for_github(ctx, entry, sha)?
} else {
format!(
"{} {}",
style("locked").dim(),
style(format!("sha={sha_short}")).dim(),
)
};
let annotation = build_annotation(entry, ctx);
Ok(format!("{name:<col_w$} {base_status}{annotation}"))
}
pub fn cmd_status(repo_root: &Path, check_upstream: bool) -> Result<(), SkillfileError> {
let manifest_path = repo_root.join(MANIFEST_NAME);
if !manifest_path.exists() {
return Err(SkillfileError::Manifest(format!(
"{MANIFEST_NAME} not found in {}. Create one and run `skillfile init`.",
repo_root.display()
)));
}
let manifest = crate::config::parse_and_resolve(&manifest_path)?;
let locked = read_lock(repo_root)?;
let col_w = manifest
.entries
.iter()
.map(|e| e.name.len())
.max()
.unwrap_or(10)
+ 2;
let mut ctx = StatusContext {
manifest: &manifest,
repo_root,
locked: &locked,
check_upstream,
sha_cache: &mut HashMap::new(),
col_w,
};
for entry in &manifest.entries {
let line = format_entry_status(entry, &mut ctx)?;
println!("{line}");
}
if !manifest.entries.is_empty() {
let summary = format_summary(&manifest, repo_root);
println!("\n{summary}");
}
Ok(())
}
struct StatusCounts {
skills: usize,
agents: usize,
pinned: usize,
modified: usize,
}
fn count_entries(manifest: &Manifest, repo_root: &Path) -> StatusCounts {
let mut counts = StatusCounts {
skills: 0,
agents: 0,
pinned: 0,
modified: 0,
};
for entry in &manifest.entries {
match entry.entity_type {
EntityType::Skill => counts.skills += 1,
EntityType::Agent => counts.agents += 1,
}
if has_patch(entry, repo_root) || has_dir_patch(entry, repo_root) {
counts.pinned += 1;
}
if is_modified_local(entry, manifest, repo_root) {
counts.modified += 1;
}
}
counts
}
fn format_summary(manifest: &Manifest, repo_root: &Path) -> String {
use std::fmt::Write;
let counts = count_entries(manifest, repo_root);
let mut parts = Vec::new();
if counts.skills > 0 {
parts.push(format!(
"{} skill{}",
counts.skills,
if counts.skills == 1 { "" } else { "s" }
));
}
if counts.agents > 0 {
parts.push(format!(
"{} agent{}",
counts.agents,
if counts.agents == 1 { "" } else { "s" }
));
}
let mut summary = parts.join(", ");
if counts.pinned > 0 {
let _ = write!(summary, " · {} pinned", counts.pinned);
}
if counts.modified > 0 {
let _ = write!(summary, " · {} modified", counts.modified);
}
let mut lines = format!(" {summary}");
if !manifest.install_targets.is_empty() {
let targets: Vec<String> = manifest
.install_targets
.iter()
.map(ToString::to_string)
.collect();
let _ = write!(lines, "\n Install targets: {}", targets.join(", "));
}
lines
}
#[cfg(test)]
mod tests {
use super::*;
use skillfile_core::models::{EntityType, InstallTarget, Scope, SourceFields};
fn write_manifest(dir: &Path, content: &str) {
std::fs::write(dir.join(MANIFEST_NAME), content).unwrap();
}
fn write_lock(dir: &Path, data: &serde_json::Value) {
std::fs::write(
dir.join("Skillfile.lock"),
serde_json::to_string_pretty(data).unwrap(),
)
.unwrap();
}
struct VendorEntry<'a> {
entity_type: &'a str,
name: &'a str,
}
fn write_meta(dir: &Path, ve: &VendorEntry<'_>, sha: &str) {
let vdir = dir
.join(".skillfile/cache")
.join(format!("{}s", ve.entity_type))
.join(ve.name);
std::fs::create_dir_all(&vdir).unwrap();
std::fs::write(
vdir.join(".meta"),
serde_json::json!({"sha": sha}).to_string(),
)
.unwrap();
}
struct VendorFile<'a> {
entry: &'a VendorEntry<'a>,
filename: &'a str,
}
fn write_vendor_content(dir: &Path, vf: &VendorFile<'_>, content: &str) {
let vdir = dir
.join(".skillfile/cache")
.join(format!("{}s", vf.entry.entity_type))
.join(vf.entry.name);
std::fs::create_dir_all(&vdir).unwrap();
std::fs::write(vdir.join(vf.filename), content).unwrap();
}
const SHA: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
const ORIGINAL: &str = "# Agent\n\nUpstream content.\n";
const MODIFIED: &str = "# Agent\n\nUpstream content.\n\n## Custom Section\n\nAdded by user.\n";
const VE_AGENT: VendorEntry<'_> = VendorEntry {
entity_type: "agent",
name: "my-agent",
};
fn agent_locked_map(sha: &str) -> std::collections::BTreeMap<String, LockEntry> {
std::collections::BTreeMap::from([(
"github/agent/my-agent".to_string(),
LockEntry {
sha: sha.to_string(),
raw_url: "https://example.com".to_string(),
},
)])
}
fn local_entry(name: &str, path: &str) -> Entry {
Entry {
entity_type: EntityType::Skill,
name: name.into(),
source: SourceFields::Local { path: path.into() },
}
}
fn claude_local_target() -> InstallTarget {
InstallTarget {
adapter: "claude-code".into(),
scope: Scope::Local,
}
}
fn agent_manifest() -> Manifest {
Manifest {
entries: vec![Entry {
entity_type: EntityType::Agent,
name: "my-agent".into(),
source: SourceFields::Github {
owner_repo: "owner/repo".into(),
path_in_repo: "agents/agent.md".into(),
ref_: "main".into(),
},
}],
install_targets: vec![claude_local_target()],
}
}
fn dir_skill_manifest() -> Manifest {
Manifest {
entries: vec![Entry {
entity_type: EntityType::Skill,
name: "my-dir".into(),
source: SourceFields::Github {
owner_repo: "owner/repo".into(),
path_in_repo: "skills/my-dir".into(),
ref_: "main".into(),
},
}],
install_targets: vec![claude_local_target()],
}
}
#[test]
fn no_manifest() {
let dir = tempfile::tempdir().unwrap();
let result = cmd_status(dir.path(), false);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("not found"));
}
#[test]
fn local_entry_path_exists_shows_local() {
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("skills/foo.md");
std::fs::create_dir_all(source.parent().unwrap()).unwrap();
std::fs::write(&source, "# Foo").unwrap();
write_manifest(dir.path(), "local skill foo skills/foo.md\n");
cmd_status(dir.path(), false).unwrap();
}
#[test]
fn local_entry_path_missing_shows_status_without_error() {
let dir = tempfile::tempdir().unwrap();
write_manifest(dir.path(), "local skill foo skills/foo.md\n");
cmd_status(dir.path(), false).unwrap();
}
#[test]
fn github_entry_unlocked() {
let dir = tempfile::tempdir().unwrap();
write_manifest(
dir.path(),
"github agent my-agent owner/repo agents/agent.md main\n",
);
cmd_status(dir.path(), false).unwrap();
}
#[test]
fn github_entry_locked_vendor_matches() {
let dir = tempfile::tempdir().unwrap();
let sha = "87321636a1c666283d8f17398b45c2644395044b";
write_manifest(
dir.path(),
"github agent my-agent owner/repo agents/agent.md main\n",
);
write_lock(
dir.path(),
&serde_json::json!({"github/agent/my-agent": {"sha": sha, "raw_url": "https://example.com"}}),
);
write_meta(dir.path(), &VE_AGENT, sha);
cmd_status(dir.path(), false).unwrap();
}
#[test]
fn github_entry_locked_vendor_missing() {
let dir = tempfile::tempdir().unwrap();
let sha = "87321636a1c666283d8f17398b45c2644395044b";
write_manifest(
dir.path(),
"github agent my-agent owner/repo agents/agent.md main\n",
);
write_lock(
dir.path(),
&serde_json::json!({"github/agent/my-agent": {"sha": sha, "raw_url": "https://example.com"}}),
);
cmd_status(dir.path(), false).unwrap();
}
#[test]
fn modified_shows_for_changed_installed_file() {
let dir = tempfile::tempdir().unwrap();
write_lock(
dir.path(),
&serde_json::json!({"github/agent/my-agent": {"sha": SHA, "raw_url": "https://example.com"}}),
);
write_meta(dir.path(), &VE_AGENT, SHA);
write_vendor_content(
dir.path(),
&VendorFile {
entry: &VE_AGENT,
filename: "agent.md",
},
ORIGINAL,
);
let installed = dir.path().join(".claude/agents");
std::fs::create_dir_all(&installed).unwrap();
std::fs::write(installed.join("my-agent.md"), MODIFIED).unwrap();
let manifest = agent_manifest();
let entry = &manifest.entries[0];
assert!(is_modified_local(entry, &manifest, dir.path()));
}
#[test]
fn modified_not_shown_for_clean_entry() {
let dir = tempfile::tempdir().unwrap();
write_lock(
dir.path(),
&serde_json::json!({"github/agent/my-agent": {"sha": SHA, "raw_url": "https://example.com"}}),
);
write_meta(dir.path(), &VE_AGENT, SHA);
write_vendor_content(
dir.path(),
&VendorFile {
entry: &VE_AGENT,
filename: "agent.md",
},
ORIGINAL,
);
let installed = dir.path().join(".claude/agents");
std::fs::create_dir_all(&installed).unwrap();
std::fs::write(installed.join("my-agent.md"), ORIGINAL).unwrap();
let manifest = agent_manifest();
let entry = &manifest.entries[0];
assert!(!is_modified_local(entry, &manifest, dir.path()));
}
#[test]
fn modified_not_shown_when_not_installed() {
let dir = tempfile::tempdir().unwrap();
write_lock(
dir.path(),
&serde_json::json!({"github/agent/my-agent": {"sha": SHA, "raw_url": "https://example.com"}}),
);
write_meta(dir.path(), &VE_AGENT, SHA);
write_vendor_content(
dir.path(),
&VendorFile {
entry: &VE_AGENT,
filename: "agent.md",
},
ORIGINAL,
);
let manifest = agent_manifest();
let entry = &manifest.entries[0];
assert!(!is_modified_local(entry, &manifest, dir.path()));
}
#[test]
fn modified_not_shown_without_vendor_cache() {
let dir = tempfile::tempdir().unwrap();
write_lock(
dir.path(),
&serde_json::json!({"github/agent/my-agent": {"sha": SHA, "raw_url": "https://example.com"}}),
);
write_meta(dir.path(), &VE_AGENT, SHA);
let installed = dir.path().join(".claude/agents");
std::fs::create_dir_all(&installed).unwrap();
std::fs::write(installed.join("my-agent.md"), MODIFIED).unwrap();
let manifest = agent_manifest();
let entry = &manifest.entries[0];
assert!(!is_modified_local(entry, &manifest, dir.path()));
}
fn setup_dir_entry(dir: &Path, installed_content: Option<&str>, cache_content: &str) {
write_lock(
dir,
&serde_json::json!({"github/skill/my-dir": {"sha": SHA, "raw_url": "https://example.com"}}),
);
let vdir = dir.join(".skillfile/cache").join("skills").join("my-dir");
std::fs::create_dir_all(&vdir).unwrap();
std::fs::write(vdir.join("tool.md"), cache_content).unwrap();
std::fs::write(
vdir.join(".meta"),
serde_json::json!({"sha": SHA}).to_string(),
)
.unwrap();
if let Some(content) = installed_content {
let installed_dir = dir.join(".claude/skills/my-dir");
std::fs::create_dir_all(&installed_dir).unwrap();
std::fs::write(installed_dir.join("tool.md"), content).unwrap();
}
}
#[test]
fn dir_entry_modified_shows_modified() {
let dir = tempfile::tempdir().unwrap();
setup_dir_entry(dir.path(), Some(MODIFIED), ORIGINAL);
let manifest = dir_skill_manifest();
let entry = &manifest.entries[0];
assert!(
is_modified_local(entry, &manifest, dir.path()),
"expected modified=true when installed content differs from cache"
);
}
#[test]
fn dir_entry_clean_shows_not_modified() {
let dir = tempfile::tempdir().unwrap();
setup_dir_entry(dir.path(), Some(ORIGINAL), ORIGINAL);
let manifest = dir_skill_manifest();
let entry = &manifest.entries[0];
assert!(
!is_modified_local(entry, &manifest, dir.path()),
"expected modified=false when installed content matches cache"
);
}
#[test]
fn dir_entry_missing_vendor_dir_not_modified() {
let dir = tempfile::tempdir().unwrap();
write_lock(
dir.path(),
&serde_json::json!({"github/skill/my-dir": {"sha": SHA, "raw_url": "https://example.com"}}),
);
let manifest = dir_skill_manifest();
let entry = &manifest.entries[0];
assert!(
!is_modified_local(entry, &manifest, dir.path()),
"expected modified=false when vendor cache dir is absent"
);
}
#[test]
fn local_entry_always_not_modified() {
let dir = tempfile::tempdir().unwrap();
let manifest = Manifest {
entries: vec![local_entry("foo", "skills/foo.md")],
..Manifest::default()
};
let entry = &manifest.entries[0];
assert!(
!is_modified_local(entry, &manifest, dir.path()),
"local entries must always report modified=false"
);
}
#[test]
fn pinned_entry_not_modified() {
let dir = tempfile::tempdir().unwrap();
write_lock(
dir.path(),
&serde_json::json!({"github/agent/my-agent": {"sha": SHA, "raw_url": "https://example.com"}}),
);
write_meta(dir.path(), &VE_AGENT, SHA);
write_vendor_content(
dir.path(),
&VendorFile {
entry: &VE_AGENT,
filename: "agent.md",
},
ORIGINAL,
);
let installed = dir.path().join(".claude/agents");
std::fs::create_dir_all(&installed).unwrap();
std::fs::write(installed.join("my-agent.md"), MODIFIED).unwrap();
let patches_dir = dir.path().join(".skillfile/patches/agents");
std::fs::create_dir_all(&patches_dir).unwrap();
std::fs::write(
patches_dir.join("my-agent.patch"),
"--- a/my-agent.md\n+++ b/my-agent.md\n@@ -1,3 +1,7 @@\n # Agent\n \n Upstream content.\n+\n+## Custom Section\n+\n+Added by user.\n",
)
.unwrap();
let manifest = agent_manifest();
let entry = &manifest.entries[0];
assert!(
!is_modified_local(entry, &manifest, dir.path()),
"pinned entries must not report as modified"
);
}
#[test]
fn dir_entry_pinned_not_modified() {
let dir = tempfile::tempdir().unwrap();
setup_dir_entry(dir.path(), Some(MODIFIED), ORIGINAL);
let patches_dir = dir.path().join(".skillfile/patches/skills/my-dir");
std::fs::create_dir_all(&patches_dir).unwrap();
std::fs::write(
patches_dir.join("tool.md.patch"),
"--- a/tool.md\n+++ b/tool.md\n@@ -1,3 +1,7 @@\n # Agent\n \n Upstream content.\n+\n+## Custom Section\n+\n+Added by user.\n",
)
.unwrap();
let manifest = dir_skill_manifest();
let entry = &manifest.entries[0];
assert!(
!is_modified_local(entry, &manifest, dir.path()),
"pinned dir entries must not report as modified"
);
}
#[test]
fn pinned_entry_with_extra_edits_reports_modified() {
let dir = tempfile::tempdir().unwrap();
write_lock(
dir.path(),
&serde_json::json!({"github/agent/my-agent": {"sha": SHA, "raw_url": "https://example.com"}}),
);
write_meta(dir.path(), &VE_AGENT, SHA);
write_vendor_content(
dir.path(),
&VendorFile {
entry: &VE_AGENT,
filename: "agent.md",
},
ORIGINAL,
);
let installed = dir.path().join(".claude/agents");
std::fs::create_dir_all(&installed).unwrap();
std::fs::write(
installed.join("my-agent.md"),
format!("{MODIFIED}\nExtra drift.\n"),
)
.unwrap();
let patches_dir = dir.path().join(".skillfile/patches/agents");
std::fs::create_dir_all(&patches_dir).unwrap();
std::fs::write(
patches_dir.join("my-agent.patch"),
"--- a/my-agent.md\n+++ b/my-agent.md\n@@ -1,3 +1,7 @@\n # Agent\n \n Upstream content.\n+\n+## Custom Section\n+\n+Added by user.\n",
)
.unwrap();
let manifest = agent_manifest();
let entry = &manifest.entries[0];
assert!(
is_modified_local(entry, &manifest, dir.path()),
"pinned entries with extra unsaved edits must report modified"
);
}
#[test]
fn locked_remote_entry_without_install_shows_not_installed() {
let dir = tempfile::tempdir().unwrap();
write_lock(
dir.path(),
&serde_json::json!({"github/agent/my-agent": {"sha": SHA, "raw_url": "https://example.com"}}),
);
write_meta(dir.path(), &VE_AGENT, SHA);
write_vendor_content(
dir.path(),
&VendorFile {
entry: &VE_AGENT,
filename: "agent.md",
},
ORIGINAL,
);
let manifest = agent_manifest();
let mut sha_cache = HashMap::new();
let mut ctx = StatusContext {
manifest: &manifest,
repo_root: dir.path(),
locked: &agent_locked_map(SHA),
check_upstream: false,
sha_cache: &mut sha_cache,
col_w: 10,
};
let line = format_entry_status(&manifest.entries[0], &mut ctx).unwrap();
assert!(
line.contains("not installed"),
"expected missing install annotation, got: {line}"
);
}
#[test]
fn local_entry_existing_path_formats_as_local() {
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("skills/foo.md");
std::fs::create_dir_all(source.parent().unwrap()).unwrap();
std::fs::write(&source, "# Foo").unwrap();
let manifest = Manifest {
entries: vec![local_entry("foo", "skills/foo.md")],
..Manifest::default()
};
let locked = std::collections::BTreeMap::new();
let mut sha_cache = HashMap::new();
let mut ctx = StatusContext {
manifest: &manifest,
repo_root: dir.path(),
locked: &locked,
check_upstream: false,
sha_cache: &mut sha_cache,
col_w: 12,
};
let line = format_entry_status(&manifest.entries[0], &mut ctx).unwrap();
assert!(
line.contains("local") && !line.contains("path missing"),
"existing path should show 'local' without warning, got: {line}"
);
}
#[test]
fn local_entry_missing_path_formats_with_warning() {
let dir = tempfile::tempdir().unwrap();
let manifest = Manifest {
entries: vec![local_entry("foo", "skills/foo.md")],
..Manifest::default()
};
let locked = std::collections::BTreeMap::new();
let mut sha_cache = HashMap::new();
let mut ctx = StatusContext {
manifest: &manifest,
repo_root: dir.path(),
locked: &locked,
check_upstream: false,
sha_cache: &mut sha_cache,
col_w: 12,
};
let line = format_entry_status(&manifest.entries[0], &mut ctx).unwrap();
assert!(
line.contains("path missing"),
"missing path should show warning, got: {line}"
);
assert!(
line.contains("skills/foo.md"),
"warning should include the path, got: {line}"
);
}
#[test]
fn summary_counts_skills_and_agents() {
let dir = tempfile::tempdir().unwrap();
let manifest = Manifest {
entries: vec![
local_entry("a", "a.md"),
local_entry("b", "b.md"),
Entry {
entity_type: EntityType::Agent,
name: "c".into(),
source: SourceFields::Github {
owner_repo: "o/r".into(),
path_in_repo: "c.md".into(),
ref_: "main".into(),
},
},
],
install_targets: vec![],
};
let out = format_summary(&manifest, dir.path());
assert!(
out.contains("2 skills, 1 agent"),
"expected skill/agent counts, got: {out}"
);
}
#[test]
fn summary_shows_install_targets() {
let dir = tempfile::tempdir().unwrap();
let manifest = Manifest {
entries: vec![local_entry("x", "x.md")],
install_targets: vec![
claude_local_target(),
InstallTarget {
adapter: "cursor".into(),
scope: Scope::Global,
},
],
};
let out = format_summary(&manifest, dir.path());
assert!(
out.contains("Install targets: claude-code (local), cursor (global)"),
"expected install targets line, got: {out}"
);
}
#[test]
fn summary_shows_pinned_and_modified() {
let dir = tempfile::tempdir().unwrap();
write_lock(
dir.path(),
&serde_json::json!({"github/agent/my-agent": {"sha": SHA, "raw_url": "https://example.com"}}),
);
write_meta(dir.path(), &VE_AGENT, SHA);
write_vendor_content(
dir.path(),
&VendorFile {
entry: &VE_AGENT,
filename: "agent.md",
},
ORIGINAL,
);
let installed = dir.path().join(".claude/agents");
std::fs::create_dir_all(&installed).unwrap();
std::fs::write(installed.join("my-agent.md"), MODIFIED).unwrap();
let manifest = agent_manifest();
let out = format_summary(&manifest, dir.path());
assert!(out.contains("1 agent"), "expected agent count, got: {out}");
assert!(
out.contains("1 modified"),
"expected modified flag, got: {out}"
);
}
#[test]
fn modified_detects_second_install_target_for_single_file_entry() {
let dir = tempfile::tempdir().unwrap();
let entry = Entry {
entity_type: EntityType::Skill,
name: "my-skill".into(),
source: SourceFields::Github {
owner_repo: "owner/repo".into(),
path_in_repo: "skills/my-skill.md".into(),
ref_: "main".into(),
},
};
let manifest = Manifest {
entries: vec![entry.clone()],
install_targets: vec![
claude_local_target(),
InstallTarget {
adapter: "copilot".into(),
scope: Scope::Local,
},
],
};
write_lock(
dir.path(),
&serde_json::json!({"github/skill/my-skill": {"sha": SHA, "raw_url": "https://example.com"}}),
);
write_meta(
dir.path(),
&VendorEntry {
entity_type: "skill",
name: "my-skill",
},
SHA,
);
write_vendor_content(
dir.path(),
&VendorFile {
entry: &VendorEntry {
entity_type: "skill",
name: "my-skill",
},
filename: "my-skill.md",
},
ORIGINAL,
);
let installed = dir.path().join(".github/skills/my-skill");
std::fs::create_dir_all(&installed).unwrap();
std::fs::write(installed.join("SKILL.md"), MODIFIED).unwrap();
assert!(is_modified_local(&entry, &manifest, dir.path()));
let out = format_summary(&manifest, dir.path());
assert!(
out.contains("1 modified"),
"expected second-target modification to count, got: {out}"
);
}
#[test]
fn modified_detects_second_install_target_for_directory_entry() {
let dir = tempfile::tempdir().unwrap();
write_lock(
dir.path(),
&serde_json::json!({"github/skill/my-dir": {"sha": SHA, "raw_url": "https://example.com"}}),
);
let vdir = dir.path().join(".skillfile/cache/skills/my-dir");
std::fs::create_dir_all(&vdir).unwrap();
std::fs::write(vdir.join("tool.md"), ORIGINAL).unwrap();
std::fs::write(
vdir.join(".meta"),
serde_json::json!({"sha": SHA}).to_string(),
)
.unwrap();
let installed = dir.path().join(".github/skills/my-dir");
std::fs::create_dir_all(&installed).unwrap();
std::fs::write(installed.join("tool.md"), MODIFIED).unwrap();
let manifest = Manifest {
entries: dir_skill_manifest().entries,
install_targets: vec![
claude_local_target(),
InstallTarget {
adapter: "copilot".into(),
scope: Scope::Local,
},
],
};
let entry = &manifest.entries[0];
assert!(is_modified_local(entry, &manifest, dir.path()));
let out = format_summary(&manifest, dir.path());
assert!(
out.contains("1 modified"),
"expected second-target directory modification to count, got: {out}"
);
}
#[test]
fn summary_singular_skill() {
let dir = tempfile::tempdir().unwrap();
let manifest = Manifest {
entries: vec![local_entry("solo", "solo.md")],
install_targets: vec![],
};
let out = format_summary(&manifest, dir.path());
assert!(
out.contains("1 skill") && !out.contains("1 skills"),
"expected singular 'skill', got: {out}"
);
}
#[test]
fn annotation_shows_pinned_for_patched_entry() {
let dir = tempfile::tempdir().unwrap();
write_lock(
dir.path(),
&serde_json::json!({"github/agent/my-agent": {"sha": SHA, "raw_url": "https://example.com"}}),
);
write_meta(dir.path(), &VE_AGENT, SHA);
let patch_dir = dir.path().join(".skillfile/patches/agents");
std::fs::create_dir_all(&patch_dir).unwrap();
std::fs::write(patch_dir.join("my-agent.patch"), "--- a\n+++ b\n").unwrap();
let manifest = agent_manifest();
let locked = agent_locked_map(SHA);
let mut sha_cache = HashMap::new();
let mut ctx = StatusContext {
manifest: &manifest,
repo_root: dir.path(),
locked: &locked,
check_upstream: false,
sha_cache: &mut sha_cache,
col_w: 12,
};
let line = format_entry_status(&manifest.entries[0], &mut ctx).unwrap();
assert!(
line.contains("pinned"),
"expected '[pinned]' annotation, got: {line}"
);
}
#[test]
fn upstream_up_to_date_shows_green() {
let dir = tempfile::tempdir().unwrap();
let sha = SHA;
write_lock(
dir.path(),
&serde_json::json!({"github/agent/my-agent": {"sha": sha, "raw_url": "https://example.com"}}),
);
write_meta(dir.path(), &VE_AGENT, sha);
let manifest = agent_manifest();
let locked = agent_locked_map(sha);
let mut sha_cache = HashMap::new();
sha_cache.insert(
("owner/repo".to_string(), "main".to_string()),
sha.to_string(),
);
let mut ctx = StatusContext {
manifest: &manifest,
repo_root: dir.path(),
locked: &locked,
check_upstream: true,
sha_cache: &mut sha_cache,
col_w: 12,
};
let line = format_entry_status(&manifest.entries[0], &mut ctx).unwrap();
assert!(
line.contains("up to date"),
"expected 'up to date', got: {line}"
);
}
#[test]
fn upstream_outdated_shows_yellow() {
let dir = tempfile::tempdir().unwrap();
let manifest = agent_manifest();
let entry = &manifest.entries[0];
let locked = std::collections::BTreeMap::new();
let mut sha_cache = HashMap::new();
sha_cache.insert(
("owner/repo".to_string(), "main".to_string()),
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(),
);
let mut ctx = StatusContext {
manifest: &manifest,
repo_root: dir.path(),
locked: &locked,
check_upstream: true,
sha_cache: &mut sha_cache,
col_w: 12,
};
let locked_sha = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
let result = upstream_status_for_github(&mut ctx, entry, locked_sha).unwrap();
assert!(
result.contains("outdated"),
"expected 'outdated', got: {result}"
);
}
#[test]
fn github_entry_unlocked_shows_unlocked() {
let dir = tempfile::tempdir().unwrap();
let manifest = agent_manifest();
let locked = std::collections::BTreeMap::new();
let mut sha_cache = HashMap::new();
let mut ctx = StatusContext {
manifest: &manifest,
repo_root: dir.path(),
locked: &locked,
check_upstream: false,
sha_cache: &mut sha_cache,
col_w: 12,
};
let line = format_entry_status(&manifest.entries[0], &mut ctx).unwrap();
assert!(
line.contains("unlocked"),
"expected 'unlocked' in output, got: {line}"
);
}
#[test]
fn github_entry_locked_stale_meta_shows_warning() {
let dir = tempfile::tempdir().unwrap();
let sha = SHA;
write_manifest(
dir.path(),
"github agent my-agent owner/repo agents/agent.md main\n",
);
write_lock(
dir.path(),
&serde_json::json!({"github/agent/my-agent": {"sha": sha, "raw_url": "https://example.com"}}),
);
let manifest = agent_manifest();
let locked = agent_locked_map(sha);
let mut sha_cache = HashMap::new();
let mut ctx = StatusContext {
manifest: &manifest,
repo_root: dir.path(),
locked: &locked,
check_upstream: false,
sha_cache: &mut sha_cache,
col_w: 12,
};
let line = format_entry_status(&manifest.entries[0], &mut ctx).unwrap();
assert!(
line.contains("missing or stale"),
"expected 'missing or stale' in output, got: {line}"
);
}
#[test]
fn github_entry_locked_clean_shows_sha() {
let dir = tempfile::tempdir().unwrap();
let sha = SHA;
write_lock(
dir.path(),
&serde_json::json!({"github/agent/my-agent": {"sha": sha, "raw_url": "https://example.com"}}),
);
write_meta(dir.path(), &VE_AGENT, sha);
let manifest = agent_manifest();
let locked = agent_locked_map(sha);
let mut sha_cache = HashMap::new();
let mut ctx = StatusContext {
manifest: &manifest,
repo_root: dir.path(),
locked: &locked,
check_upstream: false,
sha_cache: &mut sha_cache,
col_w: 12,
};
let line = format_entry_status(&manifest.entries[0], &mut ctx).unwrap();
assert!(
line.contains("locked") && line.contains(&sha[..12]),
"expected 'locked' with sha, got: {line}"
);
}
#[test]
fn annotation_shows_modified_for_changed_file() {
let dir = tempfile::tempdir().unwrap();
write_lock(
dir.path(),
&serde_json::json!({"github/agent/my-agent": {"sha": SHA, "raw_url": "https://example.com"}}),
);
write_meta(dir.path(), &VE_AGENT, SHA);
write_vendor_content(
dir.path(),
&VendorFile {
entry: &VE_AGENT,
filename: "agent.md",
},
ORIGINAL,
);
let installed = dir.path().join(".claude/agents");
std::fs::create_dir_all(&installed).unwrap();
std::fs::write(installed.join("my-agent.md"), MODIFIED).unwrap();
let manifest = agent_manifest();
let locked = agent_locked_map(SHA);
let mut sha_cache = HashMap::new();
let mut ctx = StatusContext {
manifest: &manifest,
repo_root: dir.path(),
locked: &locked,
check_upstream: false,
sha_cache: &mut sha_cache,
col_w: 12,
};
let line = format_entry_status(&manifest.entries[0], &mut ctx).unwrap();
assert!(
line.contains("modified"),
"expected '[modified]' annotation, got: {line}"
);
}
}