Skip to content

Commit 2ab980a

Browse files
committed
fix(skills): distribute every rule instead of the ones someone remembered
`rustmotion skills install` is the only channel through which a generating agent receives the rules. `SKILL_FILES` embedded 30 of the 47 on disk. The 17 absentees were not a random sample. They included `geometry-safety.md`, `world-view.md` and `audio-reactive.md` — the three CLAUDE.md cites by name — and `html-css-mental-model.md`, which PR #156 had just corrected because it taught JSON forms that delete the component from the video. That file had never been in the list, before or after the fix: the content was repaired and the pipe was not. The list is now generated by a build script that walks the skills tree, so "every rule is embedded" holds by construction rather than by memory. Adding 17 entries by hand would have left the next rule just as invisible. Two details the generation had to preserve: the table is still ordered with SKILL.md first, and `show()` no longer depends on that — it locates SKILL.md by path, where it previously indexed position 0. Verified by running the installed binary into an empty directory: 49 files written, 47 of them rules. A test now compares what is embedded against the directory and fails naming any file that drifts out.
1 parent 7f06053 commit 2ab980a

3 files changed

Lines changed: 299 additions & 132 deletions

File tree

crates/rustmotion-cli/build.rs

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
//! Generates the `SKILL_FILES` table embedded into the `rustmotion` binary by
2+
//! `src/skills.rs`.
3+
//!
4+
//! `rustmotion skills install` is the only channel through which an LLM agent
5+
//! receives the generation rules under `.claude/skills/rustmotion/`. Before
6+
//! this build script existed, the embedded table was a hand-maintained
7+
//! literal list: any rule file added to disk was invisible to `install`
8+
//! until someone remembered to add a matching entry. That silent gap let 17
9+
//! of 47 rule files — including ones explicitly named by CLAUDE.md — never
10+
//! reach an installed project (issue #165).
11+
//!
12+
//! Walking the directory at build time instead of listing files by hand
13+
//! makes "every `.md` file under `.claude/skills/rustmotion/` is embedded"
14+
//! true by construction, not by memory. `cargo:rerun-if-changed` on the
15+
//! directory (Cargo scans it recursively) means adding, removing, or editing
16+
//! a rule file triggers a rebuild of the generated table on the next build.
17+
18+
use std::env;
19+
use std::fs;
20+
use std::path::{Path, PathBuf};
21+
22+
/// Recursively collect every `.md` file under `dir`, sorted by file name at
23+
/// each directory level so the generated table has a stable, reproducible
24+
/// order across machines and OSes.
25+
fn collect_md_files(dir: &Path, out: &mut Vec<PathBuf>) {
26+
let mut entries: Vec<_> = fs::read_dir(dir)
27+
.unwrap_or_else(|e| panic!("failed to read directory {}: {e}", dir.display()))
28+
.filter_map(|e| e.ok())
29+
.collect();
30+
entries.sort_by_key(|e| e.file_name());
31+
32+
for entry in entries {
33+
let path = entry.path();
34+
if path.is_dir() {
35+
collect_md_files(&path, out);
36+
} else if path.extension().and_then(|e| e.to_str()) == Some("md") {
37+
out.push(path);
38+
}
39+
}
40+
}
41+
42+
fn main() {
43+
let manifest_dir =
44+
PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set by cargo"));
45+
// crates/rustmotion-cli -> crates -> <workspace root>
46+
let workspace_root = manifest_dir
47+
.parent()
48+
.and_then(Path::parent)
49+
.unwrap_or_else(|| {
50+
panic!(
51+
"expected {} to live at <workspace>/crates/rustmotion-cli",
52+
manifest_dir.display()
53+
)
54+
})
55+
.to_path_buf();
56+
57+
let skills_root = workspace_root.join(".claude/skills/rustmotion");
58+
59+
// Rerun whenever any file under the skills tree is added, removed, or
60+
// edited — Cargo scans directories given to rerun-if-changed recursively.
61+
println!("cargo:rerun-if-changed={}", skills_root.display());
62+
// Once any rerun-if-changed is emitted, Cargo stops rebuilding on build.rs
63+
// changes implicitly, so it must be listed explicitly too.
64+
println!("cargo:rerun-if-changed=build.rs");
65+
66+
let skill_md = skills_root.join("SKILL.md");
67+
assert!(
68+
skill_md.is_file(),
69+
"expected {} to exist — is the workspace layout intact?",
70+
skill_md.display()
71+
);
72+
73+
let rules_dir = skills_root.join("rules");
74+
let mut rule_files = Vec::new();
75+
collect_md_files(&rules_dir, &mut rule_files);
76+
77+
// SKILL.md first — `skills::show("skill")` and `skills::list()` treat it
78+
// as the main skill definition — followed by every rule file,
79+
// alphabetically. This mirrors the order of the previous hand-written
80+
// list, but nothing downstream is allowed to depend on it: `show()`
81+
// locates SKILL.md by path, not by position.
82+
let mut all_files = vec![skill_md];
83+
all_files.extend(rule_files);
84+
85+
let mut generated = String::from("&[\n");
86+
for path in &all_files {
87+
let rel_path = path
88+
.strip_prefix(&workspace_root)
89+
.unwrap_or(path)
90+
.to_str()
91+
.unwrap_or_else(|| panic!("non-UTF-8 skill file path: {}", path.display()))
92+
.replace('\\', "/"); // normalize separators if built on Windows
93+
let abs_path = path
94+
.to_str()
95+
.unwrap_or_else(|| panic!("non-UTF-8 skill file path: {}", path.display()));
96+
generated.push_str(&format!(
97+
" SkillFile {{ path: {rel_path:?}, content: include_str!({abs_path:?}) }},\n"
98+
));
99+
}
100+
generated.push_str("]\n");
101+
102+
let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR is set by cargo"));
103+
fs::write(out_dir.join("skill_files.rs"), generated).expect("failed to write skill_files.rs");
104+
}

crates/rustmotion-cli/src/skills.rs

Lines changed: 18 additions & 132 deletions
Original file line numberDiff line numberDiff line change
@@ -13,135 +13,17 @@ struct SkillFile {
1313
const CLAUDE_MD: &str = include_str!("../../../CLAUDE.md");
1414

1515
/// All skill files embedded at compile time.
16-
const SKILL_FILES: &[SkillFile] = &[
17-
SkillFile {
18-
path: ".claude/skills/rustmotion/SKILL.md",
19-
content: include_str!("../../../.claude/skills/rustmotion/SKILL.md"),
20-
},
21-
// Rules
22-
SkillFile {
23-
path: ".claude/skills/rustmotion/rules/3d-perspective.md",
24-
content: include_str!("../../../.claude/skills/rustmotion/rules/3d-perspective.md"),
25-
},
26-
SkillFile {
27-
path: ".claude/skills/rustmotion/rules/captions-workflow.md",
28-
content: include_str!("../../../.claude/skills/rustmotion/rules/captions-workflow.md"),
29-
},
30-
SkillFile {
31-
path: ".claude/skills/rustmotion/rules/card-flex-layout.md",
32-
content: include_str!("../../../.claude/skills/rustmotion/rules/card-flex-layout.md"),
33-
},
34-
SkillFile {
35-
path: ".claude/skills/rustmotion/rules/chart-types.md",
36-
content: include_str!("../../../.claude/skills/rustmotion/rules/chart-types.md"),
37-
},
38-
SkillFile {
39-
path: ".claude/skills/rustmotion/rules/continuous-presets.md",
40-
content: include_str!("../../../.claude/skills/rustmotion/rules/continuous-presets.md"),
41-
},
42-
SkillFile {
43-
path: ".claude/skills/rustmotion/rules/counter-standalone.md",
44-
content: include_str!("../../../.claude/skills/rustmotion/rules/counter-standalone.md"),
45-
},
46-
SkillFile {
47-
path: ".claude/skills/rustmotion/rules/data-viz-components.md",
48-
content: include_str!("../../../.claude/skills/rustmotion/rules/data-viz-components.md"),
49-
},
50-
SkillFile {
51-
path: ".claude/skills/rustmotion/rules/dot-map-coordinates.md",
52-
content: include_str!("../../../.claude/skills/rustmotion/rules/dot-map-coordinates.md"),
53-
},
54-
SkillFile {
55-
path: ".claude/skills/rustmotion/rules/easing-guidelines.md",
56-
content: include_str!("../../../.claude/skills/rustmotion/rules/easing-guidelines.md"),
57-
},
58-
SkillFile {
59-
path: ".claude/skills/rustmotion/rules/even-dimensions.md",
60-
content: include_str!("../../../.claude/skills/rustmotion/rules/even-dimensions.md"),
61-
},
62-
SkillFile {
63-
path: ".claude/skills/rustmotion/rules/gradient-quality.md",
64-
content: include_str!("../../../.claude/skills/rustmotion/rules/gradient-quality.md"),
65-
},
66-
SkillFile {
67-
path: ".claude/skills/rustmotion/rules/grid-card-height.md",
68-
content: include_str!("../../../.claude/skills/rustmotion/rules/grid-card-height.md"),
69-
},
70-
SkillFile {
71-
path: ".claude/skills/rustmotion/rules/hex-colors.md",
72-
content: include_str!("../../../.claude/skills/rustmotion/rules/hex-colors.md"),
73-
},
74-
SkillFile {
75-
path: ".claude/skills/rustmotion/rules/icon-format.md",
76-
content: include_str!("../../../.claude/skills/rustmotion/rules/icon-format.md"),
77-
},
78-
SkillFile {
79-
path: ".claude/skills/rustmotion/rules/layer-order.md",
80-
content: include_str!("../../../.claude/skills/rustmotion/rules/layer-order.md"),
81-
},
82-
SkillFile {
83-
path: ".claude/skills/rustmotion/rules/module-structure.md",
84-
content: include_str!("../../../.claude/skills/rustmotion/rules/module-structure.md"),
85-
},
86-
SkillFile {
87-
path: ".claude/skills/rustmotion/rules/notification-stacking.md",
88-
content: include_str!("../../../.claude/skills/rustmotion/rules/notification-stacking.md"),
89-
},
90-
SkillFile {
91-
path: ".claude/skills/rustmotion/rules/paint-context.md",
92-
content: include_str!("../../../.claude/skills/rustmotion/rules/paint-context.md"),
93-
},
94-
SkillFile {
95-
path: ".claude/skills/rustmotion/rules/prefer-presets.md",
96-
content: include_str!("../../../.claude/skills/rustmotion/rules/prefer-presets.md"),
97-
},
98-
SkillFile {
99-
path: ".claude/skills/rustmotion/rules/responsive-device-sizing.md",
100-
content: include_str!(
101-
"../../../.claude/skills/rustmotion/rules/responsive-device-sizing.md"
102-
),
103-
},
104-
SkillFile {
105-
path: ".claude/skills/rustmotion/rules/stagger-animations.md",
106-
content: include_str!("../../../.claude/skills/rustmotion/rules/stagger-animations.md"),
107-
},
108-
SkillFile {
109-
path: ".claude/skills/rustmotion/rules/stat-cards.md",
110-
content: include_str!("../../../.claude/skills/rustmotion/rules/stat-cards.md"),
111-
},
112-
SkillFile {
113-
path: ".claude/skills/rustmotion/rules/text-background.md",
114-
content: include_str!("../../../.claude/skills/rustmotion/rules/text-background.md"),
115-
},
116-
SkillFile {
117-
path: ".claude/skills/rustmotion/rules/timeline-sequencing.md",
118-
content: include_str!("../../../.claude/skills/rustmotion/rules/timeline-sequencing.md"),
119-
},
120-
SkillFile {
121-
path: ".claude/skills/rustmotion/rules/timing-constraints.md",
122-
content: include_str!("../../../.claude/skills/rustmotion/rules/timing-constraints.md"),
123-
},
124-
SkillFile {
125-
path: ".claude/skills/rustmotion/rules/ui-controls.md",
126-
content: include_str!("../../../.claude/skills/rustmotion/rules/ui-controls.md"),
127-
},
128-
SkillFile {
129-
path: ".claude/skills/rustmotion/rules/validate-json.md",
130-
content: include_str!("../../../.claude/skills/rustmotion/rules/validate-json.md"),
131-
},
132-
SkillFile {
133-
path: ".claude/skills/rustmotion/rules/vertical-align.md",
134-
content: include_str!("../../../.claude/skills/rustmotion/rules/vertical-align.md"),
135-
},
136-
SkillFile {
137-
path: ".claude/skills/rustmotion/rules/video-wizard.md",
138-
content: include_str!("../../../.claude/skills/rustmotion/rules/video-wizard.md"),
139-
},
140-
SkillFile {
141-
path: ".claude/skills/rustmotion/rules/wiggle-additive.md",
142-
content: include_str!("../../../.claude/skills/rustmotion/rules/wiggle-additive.md"),
143-
},
144-
];
16+
///
17+
/// Generated by `build.rs`, which walks `.claude/skills/rustmotion/` at
18+
/// build time and embeds every `.md` file it finds via `include_str!`. This
19+
/// is intentionally not a hand-maintained literal: a manually curated list
20+
/// silently drops any rule file nobody remembered to add (issue #165). See
21+
/// `tests/skill_files_match_disk.rs` for the guard that keeps this table and
22+
/// the on-disk rule set from drifting apart again.
23+
///
24+
/// SKILL.md is always the first entry (see `build.rs`), but code here must
25+
/// not rely on that position — locate it by path instead.
26+
const SKILL_FILES: &[SkillFile] = include!(concat!(env!("OUT_DIR"), "/skill_files.rs"));
14527

14628
/// Resolve the target directory for skill installation.
14729
fn resolve_target(global: bool) -> Result<PathBuf> {
@@ -271,10 +153,14 @@ pub fn show(name: &str) -> Result<()> {
271153
}
272154
}
273155

274-
// Special case: SKILL.md
156+
// Special case: SKILL.md. Matched by path rather than position — the
157+
// generated table happens to put SKILL.md first, but nothing here should
158+
// depend on that ordering to keep working if it ever changes.
275159
if needle.eq_ignore_ascii_case("skill") {
276-
print!("{}", SKILL_FILES[0].content);
277-
return Ok(());
160+
if let Some(sf) = SKILL_FILES.iter().find(|sf| sf.path.ends_with("/SKILL.md")) {
161+
print!("{}", sf.content);
162+
return Ok(());
163+
}
278164
}
279165

280166
Err(RustmotionError::UnknownSkill {

0 commit comments

Comments
 (0)