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
105 changes: 105 additions & 0 deletions crates/tui/src/skills/audit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,77 @@ pub fn scan_with_configured(
}
}

/// Expand an owned-only inventory to compatible roots without re-reading the
/// unchanged owned packages.
///
/// The manager uses this for its interactive scan-mode toggle. Package audits
/// include bounded content hashing, so re-auditing every bundled owned skill
/// can make a simple keypress appear lost on a cold filesystem. Reusing rows by
/// root keeps the result ordered by catalog precedence while newly eligible
/// external roots are still read from disk.
#[must_use]
pub fn expand_owned_scan_to_compatible(
workspace: &Path,
home: Option<&Path>,
configured_skills_dir: Option<&Path>,
owned_skills: &[AuditedSkill],
readiness: Option<&dyn SkillReadinessProvider>,
) -> SkillAuditSnapshot {
let catalog = SkillRootCatalog::build(workspace, home, configured_skills_dir);
let root_refs: Vec<SkillRootDescriptor> = catalog
.audit_compatible_directories()
.into_iter()
.cloned()
.collect();
let reusable_root_ids: HashSet<SkillRootId> = owned_skills
.iter()
.map(|skill| skill.id.root_id.clone())
.collect();

let mut skills = Vec::new();
for root in &root_refs {
if reusable_root_ids.contains(&root.id) {
skills.extend(
owned_skills
.iter()
.filter(|skill| skill.id.root_id == root.id)
.cloned(),
);
} else {
skills.extend(scan_root(root, workspace, home));
}
}

// The owned rows carried their previous cross-root result. Recompute it
// against the expanded inventory so precedence/conflict/import actions are
// exactly the same as a fresh compatible scan.
for skill in &mut skills {
skill.precedence = if skill.root.active_for_runtime {
PrecedenceState::Unknown
} else {
PrecedenceState::InactiveSource
};
skill.exact_duplicate_of = None;
skill.conflicts_with.clear();
skill.import_candidate = false;
skill.available_actions.clear();
}
classify_cross_root(&mut skills);
for skill in &mut skills {
skill.readiness = readiness
.and_then(|provider| provider.readiness_for(&skill.id))
.unwrap_or(ReadinessState::Unknown);
skill.available_actions = action_policy(skill);
}

SkillAuditSnapshot {
scan_mode: SkillAuditMode::Compatible,
roots: root_refs,
skills,
generated_at: SystemTime::now(),
}
}

/// Compute available mutations for one audited row (UI and controller share this).
#[must_use]
pub fn action_policy(skill: &AuditedSkill) -> Vec<SkillActionKind> {
Expand Down Expand Up @@ -1012,6 +1083,40 @@ mod tests {
assert_eq!(owned.precedence, PrecedenceState::Active);
}

#[test]
fn expanding_owned_scan_matches_fresh_compatible_scan() {
let tmp = TempDir::new().unwrap();
let workspace = tmp.path().join("ws");
let home = tmp.path().join("home");
write_skill(
&workspace.join(".codewhale").join("skills"),
"shared",
"owned",
"owned-body",
);
write_skill(
&workspace.join(".agents").join("skills"),
"shared",
"external conflict",
"external-body",
);
write_skill(
&workspace.join(".codex").join("skills"),
"candidate",
"import candidate",
"candidate-body",
);

let owned = scan(&workspace, Some(&home), SkillAuditMode::OwnedOnly, None);
let expanded =
expand_owned_scan_to_compatible(&workspace, Some(&home), None, &owned.skills, None);
let fresh = scan(&workspace, Some(&home), SkillAuditMode::Compatible, None);

assert_eq!(expanded.scan_mode, SkillAuditMode::Compatible);
assert_eq!(expanded.roots, fresh.roots);
assert_eq!(expanded.skills, fresh.skills);
}

#[test]
fn detects_shadow_duplicate_and_conflict() {
let tmp = TempDir::new().unwrap();
Expand Down
30 changes: 21 additions & 9 deletions crates/tui/src/tui/views/skills_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ use crate::palette;
use crate::skills::audit::{
AuditedSkill, AuditedSkillId, DigestState, IntegrityState, ParserState, PrecedenceState,
ProvenanceState, SkillActionKind, SkillAuditMode, SkillAuditSnapshot, SkillSourceKind,
TrustState, scan_with_configured,
TrustState, expand_owned_scan_to_compatible, scan_with_configured,
};
use crate::skills::mutation::{ConflictPolicy, SkillMutationRequest, SkillTargetScope};
use crate::skills::roots::SkillRootKind;
Expand Down Expand Up @@ -151,18 +151,30 @@ impl SkillsManagerView {
}

fn toggle_mode(&mut self, app: &App) {
self.mode = match self.mode {
let next_mode = match self.mode {
ManagerMode::OwnedOnly => ManagerMode::Compatible,
ManagerMode::Compatible => ManagerMode::OwnedOnly,
};
let snap = scan_with_configured(
&app.workspace,
crate::config::effective_home_dir().as_deref(),
Some(&app.skills_dir),
self.mode.audit_mode(),
None,
);
let focus = self.selected_skill().map(|s| s.id.clone());
let home = crate::config::effective_home_dir();
let snap = if self.mode == ManagerMode::OwnedOnly {
expand_owned_scan_to_compatible(
&app.workspace,
home.as_deref(),
Some(&app.skills_dir),
&self.skills,
None,
)
} else {
scan_with_configured(
&app.workspace,
home.as_deref(),
Some(&app.skills_dir),
next_mode.audit_mode(),
None,
)
};
self.mode = next_mode;
self.skills = snap.skills;
self.pending = None;
self.detail_scroll = 0;
Expand Down
Loading