mod analyze;
mod fix;
mod inventory;
mod paths;
mod report;
mod transcript;
mod util;
use std::io::{BufRead, IsTerminal, Write};
use std::path::PathBuf;
use std::process::ExitCode;
use anyhow::{Result, bail};
use clap::{Parser, Subcommand};
use serde_json::json;
use crate::fix::PlanOptions;
use crate::paths::Paths;
#[derive(Parser)]
#[command(name = "deadweight", version, about, long_about = None)]
struct Cli {
#[command(subcommand)]
command: Option<Command>,
#[arg(long, global = true, default_value_t = 30, value_name = "N")]
days: u64,
#[arg(long, global = true, value_name = "DIR")]
config_dir: Option<PathBuf>,
#[arg(long, global = true)]
json: bool,
#[arg(long, short)]
all: bool,
}
#[derive(Subcommand)]
enum Command {
Fix {
#[arg(long, short)]
yes: bool,
#[arg(long)]
dry_run: bool,
#[arg(long)]
include_hooked: bool,
#[arg(long, value_name = "NAME")]
keep: Vec<String>,
#[arg(long)]
force: bool,
},
Undo {
#[arg(long, short)]
yes: bool,
},
}
fn main() -> ExitCode {
match run(Cli::parse()) {
Ok(code) => code,
Err(e) => {
anstream::eprintln!("deadweight: {e:#}");
ExitCode::FAILURE
}
}
}
fn run(cli: Cli) -> Result<ExitCode> {
let paths = Paths::resolve(cli.config_dir.clone())?;
match cli.command {
None => audit(&paths, &cli),
Some(Command::Fix {
yes,
dry_run,
include_hooked,
ref keep,
force,
}) => {
let opts = PlanOptions {
include_hooked,
keep: keep.clone(),
};
run_fix(&paths, &cli, &opts, yes, dry_run, force)
}
Some(Command::Undo { yes }) => run_undo(&paths, cli.json, yes),
}
}
fn load(paths: &Paths, days: u64) -> Result<(inventory::Inventory, analyze::Analysis)> {
if !paths.claude_dir.is_dir() {
bail!(
"no Claude Code config found at {} (set --config-dir or CLAUDE_CONFIG_DIR)",
paths.claude_dir.display()
);
}
let inv = inventory::Inventory::load(paths);
let history = transcript::load(&paths.projects(), days);
match analyze::analyze(&inv, &history, days) {
Some(a) => Ok((inv, a)),
None => bail!(
"no Claude Code session transcripts found under {}. Start a session, then run deadweight again.",
paths.projects().display()
),
}
}
fn audit(paths: &Paths, cli: &Cli) -> Result<ExitCode> {
let (inv, a) = load(paths, cli.days)?;
let plan = fix::plan(&a, &inv, &PlanOptions::default());
if cli.json {
println!(
"{}",
serde_json::to_string_pretty(&json!({ "analysis": a, "plan": plan }))?
);
} else {
report::print(&a, &plan, cli.all);
}
Ok(ExitCode::SUCCESS)
}
fn run_fix(
paths: &Paths,
cli: &Cli,
opts: &PlanOptions,
yes: bool,
dry_run: bool,
force: bool,
) -> Result<ExitCode> {
let (inv, a) = load(paths, cli.days)?;
if a.sessions < 5 && !force {
bail!(
"only {} session(s) in the last {} days; too little history to call anything unused (use --force to override)",
a.sessions,
cli.days
);
}
let plan = fix::plan(&a, &inv, opts);
if plan.is_empty() {
if cli.json {
println!("{}", json!({ "applied": false, "plan": plan }));
} else {
report::note("Nothing to fix: everything listed was used recently.");
}
return Ok(ExitCode::SUCCESS);
}
if dry_run {
if cli.json {
println!(
"{}",
serde_json::to_string_pretty(&json!({ "applied": false, "plan": plan }))?
);
} else {
report::print_plan(&plan);
}
return Ok(ExitCode::SUCCESS);
}
if !cli.json {
report::print_plan(&plan);
}
if !yes && !confirm("Apply these changes?")? {
report::note("Nothing changed.");
return Ok(ExitCode::SUCCESS);
}
let dir = fix::apply(&plan, paths)?;
if cli.json {
println!(
"{}",
serde_json::to_string_pretty(&json!({ "applied": true, "backup": dir, "plan": plan }))?
);
} else {
report::success(&format!("Done. Backup: {}", dir.display()));
report::note(
"Restart Claude Code to load the slimmer setup. Revert with `deadweight undo`.",
);
}
Ok(ExitCode::SUCCESS)
}
fn run_undo(paths: &Paths, json_out: bool, yes: bool) -> Result<ExitCode> {
let Some((dir, manifest)) = fix::latest_backup(paths)? else {
if json_out {
println!("{}", json!({ "restored": false }));
} else {
report::note("No deadweight changes to undo.");
}
return Ok(ExitCode::SUCCESS);
};
if !json_out {
report::note(&format!(
"Backup {}: {} plugin(s), {} skill(s)",
dir.display(),
manifest.plugins.len(),
manifest.skills.len()
));
}
if !yes && !confirm("Restore it?")? {
report::note("Nothing changed.");
return Ok(ExitCode::SUCCESS);
}
let m = fix::undo(paths, &dir, manifest)?;
if json_out {
println!(
"{}",
json!({ "restored": true, "backup": dir, "plugins": m.plugins.len(), "skills": m.skills.len() })
);
} else {
report::success("Restored. Restart Claude Code to pick it up.");
}
Ok(ExitCode::SUCCESS)
}
fn confirm(question: &str) -> Result<bool> {
if !std::io::stdin().is_terminal() {
bail!("not running in a terminal; pass --yes to apply without asking");
}
print!("\n {question} [y/N] ");
std::io::stdout().flush()?;
let mut answer = String::new();
std::io::stdin().lock().read_line(&mut answer)?;
Ok(matches!(answer.trim(), "y" | "Y" | "yes" | "Yes"))
}