Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 45 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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" }
]
}
]
}
}
```
Expand Down Expand Up @@ -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) |
Expand Down
57 changes: 55 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path> 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,

Expand Down Expand Up @@ -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();
Expand All @@ -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<CrosstermBackend<io::Stdout>>, root: PathBuf) -> Result<()> {
let (scan_tx, scan_rx) = unbounded::<ScanMessage>();
let root_clone = root.clone();
Expand Down Expand Up @@ -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"));
}
}
Loading