dump-shitty-claude-md 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/main.rs ADDED
@@ -0,0 +1,550 @@
1
+ mod gitops;
2
+ mod plan;
3
+ mod scan;
4
+
5
+ use clap::Parser;
6
+ use dialoguer::{Confirm, MultiSelect, Select};
7
+ use plan::{Action, Plan};
8
+ use scan::{Format, FoundFile};
9
+ use serde::Serialize;
10
+ use std::collections::{BTreeMap, BTreeSet};
11
+ use std::io::IsTerminal;
12
+ use std::path::PathBuf;
13
+
14
+ #[derive(Parser)]
15
+ #[command(
16
+ name = "dump-shitty-claude-md",
17
+ about = "Consolidate agent instruction files (CLAUDE.md and friends) into AGENTS.md\n\
18
+ across all repos in your home directory. One file serves every agent.",
19
+ version
20
+ )]
21
+ struct Cli {
22
+ /// Directory to scan [default: $HOME]
23
+ path: Option<PathBuf>,
24
+
25
+ /// Apply file changes (default: dry run)
26
+ #[arg(long)]
27
+ apply: bool,
28
+
29
+ /// Apply + commit on a branch + push + open a PR (implies --apply).
30
+ /// The migration lives on the PR branch; your checkout is left untouched.
31
+ #[arg(long)]
32
+ pr: bool,
33
+
34
+ /// Git strategy for --pr instead of prompting
35
+ #[arg(long, value_enum)]
36
+ strategy: Option<gitops::Strategy>,
37
+
38
+ /// Non-interactive: accept recommended defaults (CLAUDE.md only, worktree)
39
+ #[arg(short = 'y', long)]
40
+ yes: bool,
41
+
42
+ /// Extra instruction formats to migrate alongside CLAUDE.md (repeatable,
43
+ /// comma-separated): cursor, windsurf, cline, copilot, gemini, zed,
44
+ /// aider, roo, kilo, goose, warp
45
+ #[arg(long, value_enum, value_delimiter = ',')]
46
+ migrate: Vec<Format>,
47
+
48
+ /// Migrate every supported format (equivalent to picking all in the prompt)
49
+ #[arg(long)]
50
+ all_formats: bool,
51
+
52
+ /// Only lossless actions (rename + dedupe) — merges are report-only
53
+ #[arg(long)]
54
+ trivial: bool,
55
+
56
+ /// Move stray CLAUDE.md files (not inside a git repo) to the Trash.
57
+ /// Without this flag an interactive run asks once; -y alone never trashes.
58
+ #[arg(long)]
59
+ trash_strays: bool,
60
+
61
+ /// Emit the plan as JSON
62
+ #[arg(long)]
63
+ json: bool,
64
+
65
+ /// Extra directory to exclude (repeatable)
66
+ #[arg(long)]
67
+ exclude: Vec<PathBuf>,
68
+
69
+ /// Descend into symlinked directories (default: skip — avoids loops)
70
+ #[arg(long)]
71
+ follow_links: bool,
72
+ }
73
+
74
+ #[derive(Serialize)]
75
+ struct Row {
76
+ repo: PathBuf,
77
+ file: PathBuf,
78
+ format: Format,
79
+ action: plan::Action,
80
+ reason: String,
81
+ warnings: Vec<String>,
82
+ nested: Vec<PathBuf>,
83
+ #[serde(skip_serializing_if = "Option::is_none")]
84
+ pr: Option<gitops::PrOutcome>,
85
+ }
86
+
87
+ fn home() -> PathBuf {
88
+ dirs::home_dir().unwrap_or_default()
89
+ }
90
+
91
+ fn shorten(p: &std::path::Path) -> String {
92
+ let h = home();
93
+ match p.strip_prefix(&h) {
94
+ Ok(rest) => format!("~/{}", rest.display()),
95
+ Err(_) => p.display().to_string(),
96
+ }
97
+ }
98
+
99
+ /// Which formats to migrate. CLAUDE.md is always in scope; the rest are
100
+ /// opt-in via --migrate / --all-formats / the interactive picker.
101
+ fn select_formats(cli: &Cli, found: &[FoundFile]) -> BTreeSet<Format> {
102
+ let mut selected: BTreeSet<Format> = cli.migrate.iter().copied().collect();
103
+ if cli.all_formats {
104
+ selected.extend(Format::OPT_IN.iter().copied());
105
+ }
106
+ selected.insert(Format::Claude);
107
+
108
+ // Interactive picker only when something beyond CLAUDE.md exists and the
109
+ // user didn't pre-answer via flags / -y / --json / non-tty.
110
+ let present: BTreeSet<Format> = found
111
+ .iter()
112
+ .filter(|f| f.is_repo_root_file())
113
+ .map(|f| f.format)
114
+ .collect();
115
+ let extra: Vec<Format> = present
116
+ .iter()
117
+ .copied()
118
+ .filter(|f| *f != Format::Claude)
119
+ .collect();
120
+ let interactive = std::io::stdin().is_terminal()
121
+ && std::io::stdout().is_terminal()
122
+ && !cli.yes
123
+ && !cli.json
124
+ && cli.migrate.is_empty()
125
+ && !cli.all_formats;
126
+ if interactive && !extra.is_empty() {
127
+ let labels: Vec<String> = std::iter::once(Format::Claude)
128
+ .chain(extra.iter().copied())
129
+ .map(|f| {
130
+ let n = found
131
+ .iter()
132
+ .filter(|x| x.format == f && x.is_repo_root_file())
133
+ .count();
134
+ format!("{} ({} repo{})", f.label(), n, if n == 1 { "" } else { "s" })
135
+ })
136
+ .collect();
137
+ let defaults: Vec<bool> = labels
138
+ .iter()
139
+ .enumerate()
140
+ .map(|(i, _)| i == 0) // only CLAUDE.md pre-checked
141
+ .collect();
142
+ if let Ok(picks) = MultiSelect::new()
143
+ .with_prompt("Instruction files to consolidate into AGENTS.md")
144
+ .items(&labels)
145
+ .defaults(&defaults)
146
+ .interact()
147
+ {
148
+ let all: Vec<Format> = std::iter::once(Format::Claude)
149
+ .chain(extra.iter().copied())
150
+ .collect();
151
+ selected = picks.into_iter().map(|i| all[i]).collect();
152
+ }
153
+ }
154
+ selected
155
+ }
156
+
157
+ /// Group scan hits into per-repo plans + strays (CLAUDE.md outside repos).
158
+ fn build_plans(
159
+ found: Vec<FoundFile>,
160
+ selected: &BTreeSet<Format>,
161
+ ignored_agents: &BTreeSet<PathBuf>,
162
+ ) -> (Vec<Plan>, Vec<PathBuf>) {
163
+ let mut roots: BTreeMap<PathBuf, Vec<FoundFile>> = BTreeMap::new();
164
+ let mut strays = Vec::new();
165
+
166
+ for f in found {
167
+ match &f.repo_root {
168
+ Some(r) => roots.entry(r.clone()).or_default().push(f),
169
+ None => strays.push(f.path),
170
+ }
171
+ }
172
+
173
+ let home_dir = home();
174
+ let mut plans = Vec::new();
175
+ for (root, files) in roots {
176
+ let nested: Vec<PathBuf> = files
177
+ .iter()
178
+ .filter(|f| !f.is_repo_root_file())
179
+ .map(|f| f.path.clone())
180
+ .collect();
181
+ let mut root_files: Vec<&FoundFile> = files
182
+ .iter()
183
+ .filter(|f| f.is_repo_root_file() && selected.contains(&f.format))
184
+ .collect();
185
+ // CLAUDE.md first (canonical), then other formats by path — so the
186
+ // primary file wins the rename and later sources merge into AGENTS.md.
187
+ root_files.sort_by(|a, b| {
188
+ (a.format != Format::Claude, a.rel()).cmp(&(b.format != Format::Claude, b.rel()))
189
+ });
190
+
191
+ if root_files.is_empty() {
192
+ if !nested.is_empty() {
193
+ plans.push(Plan {
194
+ repo: root.clone(),
195
+ file: PathBuf::new(),
196
+ format: Format::Claude,
197
+ action: Action::Skip,
198
+ reason: "no root instruction file".into(),
199
+ warnings: vec![],
200
+ nested,
201
+ });
202
+ }
203
+ continue;
204
+ }
205
+
206
+ let mut agents_touched = false;
207
+ for (i, f) in root_files.iter().enumerate() {
208
+ // $HOME as a repo (bare-dotfiles setups): instruction files there
209
+ // are global-ish — never touch them.
210
+ if root == home_dir {
211
+ plans.push(Plan {
212
+ repo: root.clone(),
213
+ file: f.rel(),
214
+ format: f.format,
215
+ action: Action::Skip,
216
+ reason: "repo root is $HOME — treated as global, skipped".into(),
217
+ warnings: vec![],
218
+ nested: if i == 0 { nested.clone() } else { vec![] },
219
+ });
220
+ continue;
221
+ }
222
+ match plan::classify(
223
+ &root,
224
+ &f.path,
225
+ f.format,
226
+ if i == 0 { nested.clone() } else { vec![] },
227
+ ignored_agents.contains(&root),
228
+ ) {
229
+ Ok(mut p) => {
230
+ // Several sources may all want to rename/overwrite into a
231
+ // not-yet-existing AGENTS.md — only the first may; the
232
+ // rest must append or earlier content would be clobbered.
233
+ if agents_touched
234
+ && matches!(p.action, Action::Rename | Action::OverwriteAgents)
235
+ {
236
+ p.action = Action::Merge;
237
+ p.reason = format!(
238
+ "AGENTS.md written by an earlier migration — append {}",
239
+ p.file.display()
240
+ );
241
+ }
242
+ if matches!(
243
+ p.action,
244
+ Action::Rename | Action::OverwriteAgents | Action::Merge
245
+ ) {
246
+ agents_touched = true;
247
+ }
248
+ if p.nested
249
+ .iter()
250
+ .any(|n| n.components().any(|c| c.as_os_str() == ".claude"))
251
+ {
252
+ p.warnings.push(
253
+ "repo also has .claude/CLAUDE.md — still read by Claude Code after migration"
254
+ .into(),
255
+ );
256
+ }
257
+ plans.push(p);
258
+ }
259
+ Err(e) => plans.push(Plan {
260
+ repo: root.clone(),
261
+ file: f.rel(),
262
+ format: f.format,
263
+ action: Action::Skip,
264
+ reason: format!("{e:#}"),
265
+ warnings: vec![],
266
+ nested: vec![],
267
+ }),
268
+ }
269
+ }
270
+ }
271
+ (plans, strays)
272
+ }
273
+
274
+ fn print_row(repo: &str, file: &str, label: &str, reason: &str, warnings: &[String], nested: &[PathBuf]) {
275
+ let mark = match label {
276
+ "skip" => "!",
277
+ "merge" | "replace" => "~",
278
+ _ => "✓",
279
+ };
280
+ println!("{mark} {label:<8} {repo:<44} {reason}");
281
+ if file != "CLAUDE.md" && !file.is_empty() {
282
+ println!(" {:<10} {:<44} file: {file}", "", "");
283
+ }
284
+ for w in warnings {
285
+ println!(" {:<10} {:<44} ⚠ {w}", "", "");
286
+ }
287
+ for n in nested.iter().take(5) {
288
+ println!(" {:<10} {:<44} nested: {}", "", "", shorten(n));
289
+ }
290
+ if nested.len() > 5 {
291
+ println!(" {:<10} {:<44} … +{} more nested", "", "", nested.len() - 5);
292
+ }
293
+ }
294
+
295
+ fn choose_strategy(repo: &std::path::Path, clean: bool) -> Option<gitops::Strategy> {
296
+ let items = if clean {
297
+ vec![
298
+ "worktree (recommended — your checkout is never touched)",
299
+ "in-place (branch in this checkout, restored afterwards)",
300
+ "skip this repo",
301
+ ]
302
+ } else {
303
+ vec![
304
+ "worktree (recommended — dirty tree is never touched)",
305
+ "in-place (uncommitted files stay untouched, only migration is committed)",
306
+ "skip this repo",
307
+ ]
308
+ };
309
+ let pick = Select::new()
310
+ .with_prompt(format!("{} — strategy?", shorten(repo)))
311
+ .items(&items)
312
+ .default(0)
313
+ .interact()
314
+ .ok()?;
315
+ match pick {
316
+ 0 => Some(gitops::Strategy::Worktree),
317
+ 1 => Some(gitops::Strategy::InPlace),
318
+ _ => None,
319
+ }
320
+ }
321
+
322
+ fn main() -> anyhow::Result<()> {
323
+ let cli = Cli::parse();
324
+ let root = cli.path.clone().unwrap_or_else(home);
325
+ let root = root.canonicalize().unwrap_or(root);
326
+
327
+ let scan = scan::scan(&root, &cli.exclude, cli.follow_links)?;
328
+ let selected = select_formats(&cli, &scan.found);
329
+ let (plans, strays) = build_plans(scan.found, &selected, &scan.ignored_agents);
330
+ let dry = !cli.apply && !cli.pr;
331
+
332
+ if cli.json {
333
+ let rows: Vec<Row> = plans
334
+ .iter()
335
+ .map(|p| Row {
336
+ repo: p.repo.clone(),
337
+ file: p.file.clone(),
338
+ format: p.format,
339
+ action: p.action,
340
+ reason: p.reason.clone(),
341
+ warnings: p.warnings.clone(),
342
+ nested: p.nested.clone(),
343
+ pr: None,
344
+ })
345
+ .collect();
346
+ println!(
347
+ "{}",
348
+ serde_json::to_string_pretty(&serde_json::json!({
349
+ "plans": rows,
350
+ "strays": strays,
351
+ "variants": scan.variants,
352
+ "protected": scan.protected,
353
+ "rule_dirs": scan.rule_dirs,
354
+ "gitignored": scan.gitignored,
355
+ "ignored_agents": scan.ignored_agents,
356
+ "scan_errors": scan.errors,
357
+ }))?
358
+ );
359
+ return Ok(());
360
+ }
361
+
362
+ println!(
363
+ "{} — {} instruction file{} in repos, {} stray{}",
364
+ if dry { "DRY RUN" } else { "RUN" },
365
+ plans.len(),
366
+ if plans.len() == 1 { "" } else { "s" },
367
+ strays.len(),
368
+ if strays.len() == 1 { "" } else { "s" }
369
+ );
370
+
371
+ for p in &plans {
372
+ print_row(
373
+ &shorten(&p.repo),
374
+ &p.file.to_string_lossy(),
375
+ p.action.label(),
376
+ &p.reason,
377
+ &p.warnings,
378
+ &p.nested,
379
+ );
380
+ }
381
+ for s in &strays {
382
+ println!("! stray {:<44} not inside a git repo", shorten(s));
383
+ }
384
+ for v in &scan.variants {
385
+ println!("! variant {:<44} non-standard spelling — no agent reads this", shorten(v));
386
+ }
387
+ for p in &scan.protected {
388
+ println!("· global {:<44} protected — left alone", shorten(p));
389
+ }
390
+ for g in &scan.gitignored {
391
+ println!("· ignored {:<44} gitignored — personal file, left alone", shorten(g));
392
+ }
393
+ for d in &scan.rule_dirs {
394
+ println!("! rules {:<44} tool-specific rule dir — not migratable", shorten(d));
395
+ }
396
+ for e in &scan.errors {
397
+ eprintln!(" scan error: {e}");
398
+ }
399
+
400
+ if dry {
401
+ println!(
402
+ "\ndry run — rerun with --apply (files) or --pr (files + branch + PR){}",
403
+ if strays.is_empty() {
404
+ ""
405
+ } else {
406
+ " · --trash-strays removes strays"
407
+ }
408
+ );
409
+ return Ok(());
410
+ }
411
+
412
+ // --apply / --pr — filter actionable plans, then group by repo so all
413
+ // selected files in a repo migrate in a single commit.
414
+ let mut counts = (0usize, 0usize, 0usize); // done, pr'd, skipped
415
+ let mut failures = 0usize;
416
+ let mut actionable: Vec<&Plan> = Vec::new();
417
+ for p in &plans {
418
+ if !p.action.mutates() {
419
+ continue;
420
+ }
421
+ if cli.trivial && !p.action.is_trivial() {
422
+ println!("- held {:<44} merge — rerun without --trivial", shorten(&p.repo));
423
+ counts.2 += 1;
424
+ continue;
425
+ }
426
+ actionable.push(p);
427
+ }
428
+
429
+ if !cli.pr {
430
+ for p in actionable {
431
+ match plan::apply(&p.repo, &p.file, p.action) {
432
+ Ok(()) => {
433
+ counts.0 += 1;
434
+ println!("✓ {:<8} {}{}", p.action.label(), shorten(&p.repo), file_suffix(p));
435
+ }
436
+ Err(e) => {
437
+ failures += 1;
438
+ eprintln!("✗ {:<44} {e:#}", shorten(&p.repo));
439
+ }
440
+ }
441
+ }
442
+ } else {
443
+ let mut by_repo: BTreeMap<PathBuf, Vec<&Plan>> = BTreeMap::new();
444
+ for p in actionable {
445
+ by_repo.entry(p.repo.clone()).or_default().push(p);
446
+ }
447
+ for (repo, repo_plans) in by_repo {
448
+ let clean = gitops::is_clean(&repo);
449
+ let strategy = match cli.strategy.or_else(|| {
450
+ if cli.yes || !std::io::stdout().is_terminal() {
451
+ Some(gitops::Strategy::Worktree)
452
+ } else {
453
+ choose_strategy(&repo, clean)
454
+ }
455
+ }) {
456
+ Some(s) => s,
457
+ None => {
458
+ println!("- skipped {}", shorten(&repo));
459
+ counts.2 += 1;
460
+ continue;
461
+ }
462
+ };
463
+ if strategy == gitops::Strategy::InPlace && !clean {
464
+ println!(
465
+ "! {:<44} dirty tree — in-place refused, use worktree",
466
+ shorten(&repo)
467
+ );
468
+ counts.2 += 1;
469
+ continue;
470
+ }
471
+
472
+ let outcome = gitops::run(&repo, &repo_plans, strategy);
473
+ if outcome.pr_url.is_some() {
474
+ counts.1 += 1;
475
+ } else {
476
+ counts.0 += 1;
477
+ }
478
+ let mut line = format!("✓ migrated {}", shorten(&repo));
479
+ if outcome.committed {
480
+ line.push_str(&format!(" → {}", outcome.branch.clone().unwrap_or_default()));
481
+ }
482
+ if outcome.pushed {
483
+ line.push_str(" pushed");
484
+ }
485
+ if let Some(u) = &outcome.pr_url {
486
+ line.push_str(&format!(" PR: {u}"));
487
+ }
488
+ println!("{line}");
489
+ for w in &outcome.warnings {
490
+ println!(" ⚠ {w}");
491
+ }
492
+ }
493
+ }
494
+
495
+ // Strays: never migrated (not repos), optionally trashed.
496
+ let mut trashed = 0usize;
497
+ if !strays.is_empty() {
498
+ let trash = cli.trash_strays
499
+ || (!cli.yes
500
+ && std::io::stdout().is_terminal()
501
+ && Confirm::new()
502
+ .with_prompt(format!(
503
+ "move {} stray CLAUDE.md (not in git repos) to Trash?",
504
+ strays.len()
505
+ ))
506
+ .default(false)
507
+ .interact()
508
+ .unwrap_or(false));
509
+ if trash {
510
+ for s in &strays {
511
+ match trash::delete(s) {
512
+ Ok(()) => {
513
+ trashed += 1;
514
+ println!("🗑 stray {} → Trash", shorten(s));
515
+ }
516
+ Err(e) => {
517
+ failures += 1;
518
+ eprintln!("✗ {:<44} trash failed: {e}", shorten(s));
519
+ }
520
+ }
521
+ }
522
+ }
523
+ }
524
+
525
+ println!(
526
+ "\ndone: {} migrated, {} PRs, {} skipped{}",
527
+ counts.0,
528
+ counts.1,
529
+ counts.2,
530
+ if trashed > 0 {
531
+ format!(", {trashed} trashed")
532
+ } else {
533
+ String::new()
534
+ }
535
+ );
536
+ if failures > 0 {
537
+ std::process::exit(1);
538
+ }
539
+ Ok(())
540
+ }
541
+
542
+ /// `/filename` suffix shown next to a repo when the migrated file isn't
543
+ /// CLAUDE.md itself.
544
+ fn file_suffix(p: &Plan) -> String {
545
+ if p.format == Format::Claude {
546
+ String::new()
547
+ } else {
548
+ format!(" ({})", p.file.display())
549
+ }
550
+ }