From 27d0f383b73a1ce035ba5bbe5150718707c43c38 Mon Sep 17 00:00:00 2001 From: Kunjan Dalal Date: Sun, 2 Aug 2026 11:22:50 +0530 Subject: [PATCH] feat: wire up --version and fail gracefully without a TTY --version was never enabled, so it was absent from --help and errored out. Bare `irona` with no TTY died with a raw "No such device or address (os error 6)" from enable_raw_mode; now it explains the problem and points at --clean and --mcp, which both still work headless. README documents all three modes, the full flag set, the MCP tool schemas, and fixes the Claude Code Stop hook example to the real settings.json shape. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 51 +++++++++++++++++++++++++++++++++++++++++------ src/main.rs | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 100 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index c876faf..6a98331 100644 --- a/README.md +++ b/README.md @@ -37,13 +37,34 @@ cargo install --path . ## Usage +irona has three modes: an interactive TUI, a headless `--clean` sweep, and an MCP server. + +```sh +irona [PATH] # interactive TUI (PATH defaults to the current directory) +irona --clean [PATH] # headless sweep, one summary line, no TUI +irona --mcp # Model Context Protocol server over stdio +irona --version # print version +irona --help # print all flags +``` + +| Flag | Mode | Description | +|---|---|---| +| `--clean` | headless | Scan and delete every artifact found, then print a summary | +| `--dry-run` | headless | With `--clean`: report what would be freed, delete nothing | +| `--include-gitignored` | headless | With `--clean`: also delete directories matched only by `.gitignore` | +| `--mcp` | server | Serve `scan_artifacts` and `clean_artifacts` over stdio | + +### Interactive TUI + - **↑ / ↓** — navigate entries - **Space** — select / deselect entry - **a** — select / deselect all - **d** — delete selected entries - **q / Esc** — quit -irona scans your home directory for build artifact folders and shows their size. Select what you want to clean up and press `d` to delete. +irona scans `PATH` for build artifact folders and shows their size. Select what you want to clean up and press `d` to delete. + +The TUI needs a real terminal. If stdin or stdout is not a TTY — in a pipe, a CI job, or an editor task runner — irona exits with code `1` and points you at `--clean` and `--mcp` instead of failing with a raw OS error. ## Headless mode @@ -72,7 +93,13 @@ Sweep build artifacts every time a session ends, via `settings.json`: ```json { "hooks": { - "Stop": [{ "command": "irona --clean ~/Workspace" }] + "Stop": [ + { + "hooks": [ + { "type": "command", "command": "irona --clean ~/Workspace" } + ] + } + ] } } ``` @@ -103,15 +130,27 @@ Using `irona` from `PATH` keeps MCP client configs portable and avoids hardcodin } ``` +In Claude Code the equivalent one-liner is: + +```bash +claude mcp add irona -- irona --mcp +``` + The MCP server exposes two tools: -| Tool | Description | -|---|---| -| `scan_artifacts` | Scan a path and return artifact directories with sizes and detected language/source | -| `clean_artifacts` | Delete a provided list of artifact directories and return per-path results plus freed bytes | +| Tool | Input | Returns | +|---|---|---| +| `scan_artifacts` | `path` (optional, defaults to the working directory) | `root`, `count`, `total_size_bytes`, and an `artifacts` array of `{path, language, size_bytes}` | +| `clean_artifacts` | `paths` (array, at least one) | `requested_count`, `deleted_count`, `total_freed_bytes`, and a `results` array of `{path, deleted, size_bytes, elapsed_ms, error?}` | + +Both tools return structured content alongside a text summary, and both carry MCP annotations — `scan_artifacts` is marked `readOnlyHint`, `clean_artifacts` `destructiveHint` — so clients that treat destructive tools differently can tell them apart. + +`scan_artifacts` reports everything the TUI would show, including `.gitignore`-only matches, each labelled by source in the `language` field. This is the opposite default from `irona --clean`, which skips gitignore-only matches unless you pass `--include-gitignored`; the MCP flow shows them and leaves the decision to you. `clean_artifacts` refuses any path irona would not itself classify as an artifact. A directory only qualifies if a marker file sits beside it (`target/` next to a `Cargo.toml`) or a `.gitignore` rule matches it. Handing it an arbitrary path fails the whole call and deletes nothing, so a wrong path from the model cannot take out your work. +The intended flow is two calls: `scan_artifacts` to see what exists and how much it costs, then `clean_artifacts` with the subset you approve. + ## Supported Languages | Language / Ecosystem | Marker file(s) | Artifact folder(s) | diff --git a/src/main.rs b/src/main.rs index 4fc3c78..4a58399 100644 --- a/src/main.rs +++ b/src/main.rs @@ -23,18 +23,35 @@ use model::{update, AppMsg, AppStatus, DeleteMsg, ListMsg}; use ratatui::{backend::CrosstermBackend, widgets::ListState, Terminal}; use render::render; use scanner::ScanMessage; -use std::{io, path::PathBuf, sync::mpsc, thread, time::Duration}; +use std::{ + io::{self, IsTerminal}, + path::PathBuf, + sync::mpsc, + thread, + time::Duration, +}; use tuirealm::{ event::NoUserEvent, Application, EventListenerCfg, PollStrategy, Sub, SubClause, SubEventClause, }; +const NO_TTY_HINT: &str = "\ +irona: the interactive TUI needs a terminal, but stdin or stdout is not a TTY. + For non-interactive use: + irona --clean delete artifacts and print a one-line summary + irona --mcp run as a Model Context Protocol server over stdio"; + #[derive(Parser)] -#[command(name = "irona", about = "Reclaim disk space from build artifacts")] +#[command( + name = "irona", + version, + about = "Reclaim disk space from build artifacts" +)] struct Args { /// Run irona as a Model Context Protocol server over stdio. #[arg(long)] mcp: bool, + /// Directory to scan #[arg(default_value = ".")] path: PathBuf, @@ -75,6 +92,11 @@ fn main() -> Result<()> { return Ok(()); } + if let Err(hint) = tui_precondition(io::stdin().is_terminal(), io::stdout().is_terminal()) { + eprintln!("{hint}"); + std::process::exit(1); + } + let original_hook = std::panic::take_hook(); std::panic::set_hook(Box::new(move |info| { let _ = disable_raw_mode(); @@ -99,6 +121,14 @@ fn main() -> Result<()> { result } +fn tui_precondition(stdin_is_tty: bool, stdout_is_tty: bool) -> Result<(), &'static str> { + if stdin_is_tty && stdout_is_tty { + Ok(()) + } else { + Err(NO_TTY_HINT) + } +} + fn run(terminal: &mut Terminal>, root: PathBuf) -> Result<()> { let (scan_tx, scan_rx) = unbounded::(); let root_clone = root.clone(); @@ -251,4 +281,27 @@ mod tests { let args = Args::try_parse_from(["irona", "/tmp/w"]).unwrap(); assert!(!args.clean); } + + #[test] + fn version_flag_reports_crate_version() { + let err = Args::try_parse_from(["irona", "--version"]) + .err() + .expect("--version should short-circuit parsing"); + assert_eq!(err.kind(), clap::error::ErrorKind::DisplayVersion); + assert!(err.to_string().contains(env!("CARGO_PKG_VERSION"))); + } + + #[test] + fn tui_runs_only_when_both_streams_are_ttys() { + assert!(tui_precondition(true, true).is_ok()); + assert!(tui_precondition(false, true).is_err()); + assert!(tui_precondition(true, false).is_err()); + assert!(tui_precondition(false, false).is_err()); + } + + #[test] + fn no_tty_hint_points_at_headless_modes() { + assert!(NO_TTY_HINT.contains("--clean")); + assert!(NO_TTY_HINT.contains("--mcp")); + } }