1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
//! Every directory a bundled skill can be deployed into (issue #4605).
//!
//! Why: skills, unlike agents (#4409), have no single canonical destination.
//! `tm install` writes the operator's `~/.claude/skills`,
//! `standalone::global_config::ensure_global_config_dir` writes the tm-managed
//! `$CLAUDE_CONFIG_DIR/skills`, and a managed session writes its workspace's
//! `<project>/.claude/skills`. The #4605 defect — a bundled skill absent from
//! its target's manifest is classified project-custom and silently dropped
//! from every deploy — occurs independently at each one, and it was measured
//! at two of them simultaneously. A reporter or reconcile that covered only
//! the tier it happened to be invoked from would leave the others permanently
//! stale, which is the defect again with a smaller blast radius.
//!
//! What: [`skill_deploy_tiers`] enumerates those directories, deduplicated and
//! labelled, from a resolved [`FrameworkPaths`] plus an optional project
//! directory. Pure path arithmetic — it never reads or creates anything, and a
//! tier that does not exist on disk is still listed (its scan simply finds
//! nothing).
//!
//! Test: the `tests` module below.
use std::path::{Path, PathBuf};
use crate::core::paths::FrameworkPaths;
/// One directory bundled skills deploy into.
///
/// Why: every #4605 report is per-tier — "unmanaged at `~/.claude/skills`" and
/// "unmanaged at `$CLAUDE_CONFIG_DIR/skills`" are different facts needing
/// different remediation scope — so the human label travels with the path.
/// What: a short stable label for output and the directory itself.
/// Test: `tiers_include_the_managed_config_dir`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SkillDeployTier {
/// Short label for operator-facing output (`managed config`, `operator
/// home`, `project`).
pub label: &'static str,
/// The `skills/` directory itself.
pub dir: PathBuf,
/// Whether the WHOLE bundled roster is expected here (#7423).
///
/// Why: only [`FrameworkPaths::skill_deploy_dir`] receives every bundled
/// skill — the 2026-09-01 ruling (#6586) made the operator home and the
/// project tier user-custom-only, and `tm install` declines bundled stems
/// at both. So "a bundled skill is absent here" is a finding at exactly one
/// tier and normal everywhere else; carrying the answer on the tier keeps
/// the audit from asserting a roster the deploy would never write.
/// What: `true` for the managed-config tier alone.
/// Test: `only_the_managed_tier_expects_the_bundled_roster`.
pub receives_bundled_roster: bool,
}
/// The project tier's skills directory: `<project_dir>/.claude/skills`.
///
/// Why (#6586): three call sites spelled this join by hand — the tier
/// enumeration below, the stray sweep, and the `skill_project_tier` probe — and
/// the sweep's deferral has to name the SAME directory the enumeration yields
/// or the redeploy skips nothing. One spelling, one answer.
/// What: `<project_dir>/.claude/skills`. Pure path arithmetic; touches no disk.
/// Test: `tiers_add_the_project_tier`.
pub fn project_skill_tier(project_dir: &Path) -> PathBuf {
project_dir.join(".claude").join("skills")
}
/// Enumerate every skill deploy target, deduplicated, highest-reach first.
///
/// Why: see the module doc — one call site listing the tiers keeps `tm
/// install`'s reporter, `tm install --reconcile-skills`, and `tm doctor` from
/// each covering a different subset and disagreeing about which tiers are
/// clean.
/// What: `$CLAUDE_CONFIG_DIR/skills` (the tm-global roster EVERY managed
/// session reads — derived as the `skills` sibling of
/// [`FrameworkPaths::agent_deploy_dir`], which #4409 pins to the managed
/// `claude-config` dir and which `for_managed_project`/`for_managed_workspace`
/// deliberately never rewrite), then [`FrameworkPaths::claude_skills_dir`]
/// (the operator's `~/.claude/skills`, or the workspace's own when `paths` was
/// resolved for a managed workspace), then `<project_dir>/.claude/skills` when
/// a project is supplied. Later duplicates are dropped, so a managed-workspace
/// `paths` whose `claude_skills_dir` already IS the project tier yields one
/// entry, not two.
/// Test: `tiers_include_the_managed_config_dir`, `tiers_add_the_project_tier`,
/// `tiers_deduplicate_identical_paths`.
pub fn skill_deploy_tiers(
paths: &FrameworkPaths,
project_dir: Option<&Path>,
) -> Vec<SkillDeployTier> {
// #6586: one derivation, on `FrameworkPaths`, shared with the session
// launch's bundled deploy and `deploy_validate`'s probe.
let managed = paths.skill_deploy_dir();
// #7423: the third element says whether the whole bundled roster belongs
// here. Only the managed tier — see `SkillDeployTier::receives_bundled_roster`.
let candidates = [
Some(("managed config", managed, true)),
Some(("operator home", paths.claude_skills_dir(), false)),
project_dir.map(|dir| ("project", project_skill_tier(dir), false)),
];
let mut tiers: Vec<SkillDeployTier> = Vec::new();
for (label, dir, receives_bundled_roster) in candidates.into_iter().flatten() {
if tiers.iter().any(|t| t.dir == dir) {
continue;
}
tiers.push(SkillDeployTier {
label,
dir,
receives_bundled_roster,
});
}
tiers
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn tiers_include_the_managed_config_dir() {
// The tm-global roster every managed session reads must always be
// covered — it is the tier #4605 was measured stale at.
let base = TempDir::new().unwrap();
let paths = FrameworkPaths::under(base.path());
let tiers = skill_deploy_tiers(&paths, None);
assert_eq!(tiers[0].label, "managed config");
assert_eq!(tiers[0].dir, paths.skill_deploy_dir());
assert!(tiers.iter().any(|t| t.dir == paths.claude_skills_dir()));
}
// #7423: a bundled skill missing from the managed tier is a finding; the
// same skill missing from the operator home or the project tier is the
// 2026-09-01 ruling working as intended (#6586).
#[test]
fn only_the_managed_tier_expects_the_bundled_roster() {
let base = TempDir::new().unwrap();
let project = TempDir::new().unwrap();
let paths = FrameworkPaths::under(base.path());
let tiers = skill_deploy_tiers(&paths, Some(project.path()));
let expecting: Vec<&str> = tiers
.iter()
.filter(|t| t.receives_bundled_roster)
.map(|t| t.label)
.collect();
assert_eq!(expecting, vec!["managed config"], "tiers: {tiers:?}");
}
#[test]
fn tiers_add_the_project_tier() {
let base = TempDir::new().unwrap();
let project = TempDir::new().unwrap();
let paths = FrameworkPaths::under(base.path());
let tiers = skill_deploy_tiers(&paths, Some(project.path()));
assert_eq!(tiers.len(), 3);
assert_eq!(tiers[2].label, "project");
assert_eq!(tiers[2].dir, project.path().join(".claude").join("skills"));
}
#[test]
fn tiers_deduplicate_identical_paths() {
// A managed-workspace `paths` resolves `claude_skills_dir` to the
// project's own `.claude/skills`; listing it twice would double every
// finding reported against it.
let base = TempDir::new().unwrap();
let project = TempDir::new().unwrap();
let paths =
FrameworkPaths::for_managed_project(base.path().join(".trusty-mpm"), project.path());
let tiers = skill_deploy_tiers(&paths, Some(project.path()));
assert_eq!(tiers.len(), 2, "expected dedup, got {tiers:?}");
assert_eq!(tiers[1].dir, project.path().join(".claude").join("skills"));
}
}