Skip to content

Add headless disktree scan --json and a read-only MCP server - #79

Open
rsonnad wants to merge 1 commit into
tobi:mainfrom
rsonnad:headless-scan-mcp
Open

rsonnad wants to merge 1 commit into
tobi:mainfrom
rsonnad:headless-scan-mcp

Conversation

@rsonnad

@rsonnad rsonnad commented Oct 10, 2026 •

Copy link
Copy Markdown

Why

Agents like Claude Code and Cursor are increasingly asked to "free up some disk space". Today their only options are slow du pipelines or guesswork. This PR lets them ask disktree instead: with no window, through the scanner the app already uses, and with no way to change anything. Every command and tool in this PR only reads and reports.

It also tells people who download the app that this exists: the window gets an AI agent… button that hands over a ready-to-paste prompt, and an Export JSON… button.

What it adds

disktree scan [PATH] [--json] Scans PATH (default ~) and prints a report: plain text, or documented JSON with --json
disktree summary [PATH] [--json] Capacity, used, free and available space of PATH's volume. Nothing is scanned
disktree mcp An MCP server on stdio with six tools, returning the same JSON as the CLI

The MCP tools:

tool answers
scan_path the full report: where the space is and what can go
reclaimable_space "what can I clean up?"
largest_dirs the biggest folders, with filters
largest_files the biggest files, with filters
list_files files matching filters, e.g. over 1 GB or untouched for a year
disk_summary how full the disk is

Options and filters (CLI flags; MCP arguments are the same names)

  • Measuring: --apparent-size (vs on-disk), -x/-X (stay on one filesystem, the default, or cross), --exclude PATTERN (repeatable; excluded folders are never walked), --no-hidden, --follow-links.
  • Shaping the lists: --top N, --depth N, and --only overview,reclaim,dirs,files.
    • --depth only limits what is listed; sizes always count everything beneath.
    • --only keeps a report to the parts that were asked for.
  • Filters: --min-size 1G, --ext iso,dmg, --older-than 6mo or a date, --name '*backup*', --category cache, --reclaim any|build-output|….
    • A category or reclaim filter lists only the outermost matching folder: --category cache gives ~/.cache, not every folder inside it.

The report is built for agents

An agent shouldn't spend tokens adding up numbers, or risk adding up numbers that overlap, so the report does that work. Abridged, from a real home directory:

{
  "total":   { "bytes": 417559109632, "size": "389 GiB", "files": 7614519, "directories": 718228 },
  "volume":  { "available": { "size": "359 GiB" }, "used_percent": 61.2, "root_percent_of_used": 68.6 },
  "breakdown": {
    "children":    [ { "path": "/Users/me/Library", "size": "172 GiB", "percent": 41.2, "age_days": 0 } ],
    "by_category": [ { "category": "cache", "size": "167 GiB", "percent": 42.9, "files": 3901233 } ],
    "by_age":      [ { "age": "under_30_days", "size": "196 GiB", "percent": 50.4 } ]
  },
  "reclaimable": {
    "size": "186 GiB", "percent": 47.9, "places": 10580,
    "available_after": { "size": "545 GiB" },
    "by_reason": [ { "reason": "reinstallable", "size": "83 GiB", "places": 155,
                     "description": "Installed dependencies beside their manifest; reinstall with the project's package manager." } ],
    "largest":   [ { "path": "/Users/me/Library/Developer/Xcode/DerivedData", "size": "52 GiB", "reclaim": "build-output" } ]
  },
  "largest_dirs": [ { "path": "/Users/me/Documents/codingprojects", "size": "128 GiB", "nested_in": "/Users/me/Documents" } ]
}
  • Every size, three ways: exact bytes, human size, and percent of the total. Every time is also given as age_days; for a folder, that's the days since anything inside it changed.
  • Three breakdowns that add up exactly: breakdown splits the total three ways (top-level folders plus a remainder row, kind of data, age). In each, the parts don't overlap and sum to the total to the byte.
  • Reclaimable space, counted once: reclaimable covers caches, build output, node_modules, trash and temp files, counted once at the outermost folder. Totals come by reason, with one plain-language description per reason, plus the volume's free space before and after.
  • No double-counting nested folders: nested_in marks entries in largest_dirs that are already inside another listed entry.
  • Readable order: keys come short answers first, long lists last.
  • Documented and versioned: every field is described in docs/agent-json.md, and the format carries a schema_version.

One deliberate difference from the window. For an unrecognised top-level folder, the window borrows the kind of its largest recognisable child, so ~/Library is coloured as cache because it holds Caches. That makes a good colour but a wrong total: it would put all 172 GiB of ~/Library under "cache". Reports leave such folders as other. The window's colouring is unchanged.

In the window

Someone who downloads the app won't know it can be driven from an agent, so the window says so:

  • AI agent… (top bar, a, or File › Use with an AI Agent…) opens a panel with:
    • a prompt to paste into Claude, Codex or Cursor, written for this machine: the full path to this copy of disktree and the scanned root, with every path single-quoted so a folder named $(…) stays a name;
    • buttons that copy the MCP settings (Claude Desktop, Cursor) and the Claude Code command.
  • Export JSON… (top bar, e / ⌘E, or File › Export Report as JSON…) saves the scan on screen as the same report disktree scan --json prints.
    • It's built off the UI thread from the tree already in memory, so there's no second scan.
    • While a scan runs, the button is greyed out and its tooltip says why.
  • Top-bar behaviour: both buttons have a filled background and border, so they read as buttons beside the breadcrumb. At narrow widths they step aside so the view settings never move. A harness test measures the drawn top bar at eight widths from 900 to 1800 px.
  • Version: disktree › About disktree, disktree --version, and the ? overlay's footer all show it.

Read-only, and how that's enforced

  • Separate crate: all of this lives in a new crate, crates/disktree-agent. It uses disktree-core only for scanning and never names removal or export.
  • Source check: tests/read_only.rs reads the crate's own source and fails the build if anything that could delete, move, trash, rename or write a file appears in it (remove_file, fs::write, OpenOptions, trash::, Command::new, …).
  • Tool annotations: every MCP tool is marked readOnlyHint: true, destructiveHint: false, and a test checks no tool name suggests otherwise.
  • Early hand-off: the subcommands are handled at the very top of main. That's before the Power Efficiency settings are loaded or saved, and before the cmux re-spawn, which would point stdout at /dev/null.

Changes outside the new crate

  • disktree-core: a new ScanOptions::exclude (exclude.rs).
    • It's checked as the walk lists each entry, so excluded folders are never entered, and it costs nothing when unused.
    • Pattern rules: a pattern without / matches a name, and one with / matches a path. * stays within one path component, ** crosses components, and ? matches one character.
    • The matcher can't blow up on patterns full of stars: it's a bit-set automaton, so it never backtracks.
    • On Windows, the fast MFT reader falls back to the normal walk when patterns are set, as it already does for follow_links.
  • disktree-app:
    • main.rs hands off the headless subcommands as described above, and adds --version. A folder literally named scan still opens as disktree ./scan.
    • The window's additions are listed under "In the window"; its existing behaviour is unchanged.
  • Dependencies: serde_json (with preserve_order), dunce and chrono. All are already in the lockfile, so nothing new is downloaded.

Try it

cargo run -p disktree-app -- scan ~ --only overview          # the text version
cargo run -p disktree-app -- scan ~ --json --only reclaim    # what can be freed
cargo run -p disktree-app -- scan ~ --json --only files --min-size 1G

MCP client config (Claude Desktop, Cursor):

{ "mcpServers": { "disktree": { "command": "/absolute/path/to/disktree", "args": ["mcp"] } } }

Testing

  • Lint and tests on macOS (Apple silicon): cargo xtask lint (fmt check, then clippy with -D warnings) and cargo xtask test pass. That covers 39 new agent tests, 5 new core tests, and 75 window-harness tests (71 existing, 4 new).
    • The new tests include the invariants: every breakdown sums to the total, reclaimable reasons sum to the reclaimable total, nested folders aren't counted twice, and a folder holding Caches isn't itself counted as cache.
  • Cross-platform lint: cargo clippy -p disktree-core -p disktree-agent --all-targets -- -D warnings is clean for x86_64-pc-windows-msvc and x86_64-unknown-linux-gnu. disktree-app can't be cross-checked for Windows from a Mac (GPUI's build script needs the Windows SDK), so CI is the first Windows build of main.rs.
  • Real home directory (389 GiB, 7.6M files): the scan is headless and takes about 45 s. All three breakdowns and the reclaimable reasons matched the total to the byte. A full scan_path overview is about 6k tokens.
  • Live MCP session over stdio against the built binary:
    • initialize, then tools/list;
    • scan_path, with progress notifications;
    • follow-up calls answered from the five-minute cache in under a second;
    • reclaimable_space, largest_dirs and disk_summary;
    • idle eviction of the cache (tested).
  • GUI check: the window opens normally with a path. I checked the new top-bar buttons, the agent panel and the About panel by eye in a release build.

Review

A Codex (gpt-6.1-sol) read-only review found no path from the CLI or MCP into removal, export or file-writing code. It did find six issues, all fixed, five of them with regression tests:

  1. Copied commands only double-quoted paths. Now single-quoted, and tested through a real sh with a name containing $(…), backticks, quotes, $HOME and a newline.
  2. available_after projected apparent sizes and followed links as freed space. It's now null in both modes.
  3. --exclude could hide a bind-mount alias of an excluded mount on Linux. Excludes now join the hidden/depth guard on duplicate-mount suppression.
  4. Cancelling a queued MCP request was lost. Cancellations are now kept by request ID, and a cancelled request gets no reply.
  5. The GUI export picked top-level children in the window's ranking (file count). It now always picks them by bytes.
  6. Malformed JSON-RPC messages were accepted. They now get -32600.

Not covered by tests

  • Finding 3 has no regression test. repeated_mounts_for reads the real mount table, so it needs Linux; the fix extends the existing guard.
  • Windows PowerShell quoting (& '…', '' escaping) is compiled by the cross-target clippy run but never executed.
  • The export's actual file write goes through the native save dialog, which the harness can't drive. I tested it by hand. The report content comes from the same json_text the CLI uses and that tests/report.rs covers.

One behaviour choice to note

When stdin closes, the MCP server finishes and answers the requests it already has instead of abandoning them, so echo '<request>' | disktree mcp gets its answer.

🤖 Generated with Claude Code

…the window

AI agents and scripts can now ask disktree where the space went, through
the scanner the window already uses, with no way to change anything.

Headless, read-only:
- `disktree scan [PATH] [--json]` prints a documented report
  (docs/agent-json.md, versioned by `schema_version`) built for agents:
  every size as bytes, human size and percent of the total, every time
  also as `age_days`; the volume's free space; three breakdowns (top-level
  folders, kind of data, age) that each sum to the total exactly; a
  reclaimable summary counted once at the outermost folder, by reason,
  with the free space it would give back; and the largest directories
  (marked `nested_in` when already counted) and files. Options for top-N,
  listing depth, apparent size, excludes, one filesystem and `--only`
  sections; filters for minimum size, extension, age, name, category and
  reclaim reason.
- `disktree summary [PATH]` gives a volume's capacity and free space.
- `disktree mcp` serves six read-only tools over stdio (scan_path,
  reclaimable_space, largest_dirs, largest_files, list_files,
  disk_summary) returning the same JSON, with a five-minute scan cache,
  progress notifications and cancellation.

The new crate `disktree-agent` never names `removal` or `export`; a test
reads its source and fails on any call that could write, move or delete
a file. Report categories leave out the window's guess for unrecognised
top-level folders, so `~/Library` is not counted as cache.

In the window:
- An "AI agent…" button, `a` and File > Use with an AI Agent open a panel
  with a prompt to paste into Claude, Codex or Cursor, written for this
  machine, plus the MCP settings and the Claude Code command. Paths are
  single-quoted so a folder named `$(…)` stays a name.
- "Export JSON…", `e`/Cmd-E and File > Export Report as JSON save the scan
  on screen as the same report, built off the UI thread.
- Both sit in the top bar and step aside at narrow widths so the view
  settings never move; About disktree, `--version` and the keyboard
  overlay show the version.

In disktree-core, `ScanOptions::exclude` leaves matching names or paths
out of the walk; duplicate-mount suppression and the MFT reader stand
aside when patterns are set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant