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
9 changes: 9 additions & 0 deletions Packaging/bundle.sh
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@ rm -rf "$FRAMEWORK/Versions/B/XPCServices" "$FRAMEWORK/XPCServices" \
"$FRAMEWORK/Versions/B/PrivateHeaders" "$FRAMEWORK/PrivateHeaders" \
"$FRAMEWORK/Versions/B/Modules" "$FRAMEWORK/Modules"

# Claude skill (SPEC-14 phase 4). Shipped inside the bundle so "Set up the
# Claude integration" can install it without a download; it is the method half
# of the pairing whose data half is the --mcp server.
if [ -d "Packaging/skills" ]; then
echo "Embedding Claude skills..."
mkdir -p "$APP/Contents/Resources/skills"
cp -R Packaging/skills/* "$APP/Contents/Resources/skills/"
fi

# App icon (J1.2). Regenerate with ./Packaging/make-icon.sh if the look changes.
ICON_KEY=""
if [ -f "Packaging/AppIcon.icns" ]; then
Expand Down
89 changes: 89 additions & 0 deletions Packaging/skills/space/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
---
name: space
description: Analyse what is filling a Mac's disk and produce a delete plan, using the SpaceMatters MCP server. Use when the user asks why their disk is full, what is taking up space, what they can safely delete, or asks to clean up / free up disk space.
---

# Disk space analysis

The `spacematters` MCP server exposes one filesystem scan, read-only. It measures;
you classify. It knows how many bytes are in a folder and when they were last
written — it does not know that `workspaceStorage` holds state for repositories
deleted two years ago, or that 38 GiB of `.raw` is a photo library that belongs
on an external drive. That judgement is the entire value you add.

## Procedure

1. **`overview` first, always.** One call returns the totals, the exact file-type
table, and a size-budgeted tree. Read it before calling anything else.
2. **`find` before you drill.** Patterns scattered across the disk almost always
reorder the priorities: `node_modules`, `.venv`, `target`, `build`, `bin`,
`obj`, `DerivedData`, `__pycache__`, `.gradle`. One `node_modules` is 400 MB
and invisible in a tree; nineteen of them are 6.7 GiB and the top finding.
Nested matches are counted once, so the total is honest.
3. **Drill the top three by share** with `tree`. Detail follows size, not depth.
4. **`aged` for every candidate.** Regenerable *and* cold is the only signal
strong enough to act on unprompted. A build cache written yesterday is in
active use; the same cache untouched for a year is dead weight. `aged`
reports the outermost cold folder only — everything inside one is at least
as cold.
5. **`explain` before proposing anything.** It tells you whether the folder is a
target SpaceMatters cleans itself, whether its size is real or sparse, and
when it was last written.
6. **`annotate` as you go**, whenever the tool is offered (it appears only when
the app is running). This is how a conclusion reaches the user: the folder
and its whole region take the verdict's colour on their treemap, with your
reason on hover. A finding that exists only in this transcript is a finding
they have to re-read; one on the map is one they can see.

## Reading the numbers

- **Sizes are on-disk bytes** — allocated blocks, what deleting actually frees.
- **`apparent` far above on-disk means sparse or compressed.** A 512 GiB VM
image occupying 64 MiB is correct, not a bug. Never quote the apparent size as
what deleting would free; `explain` spells out the cause.
- **`cold:8mo` is inherited**, not repeated: it means nothing anywhere in that
subtree has been written for that long, and everything nested inside is at
least as cold. No mark means recent — or timestamps unknown, which happens on
VM and SSH scans.
- **`types` is exact only for the whole scan.** For a subtree it is
reconstructed from per-folder dominant types: the ranking is reliable, the
individual totals are not. Never quote a subtree figure as measured.
- **`find` matches directory names only.** Files are not tracked individually,
so `*.raw` finds nothing — use `types` for file extensions.
- **A note about unreadable locations means the totals are lower bounds.**
Say so in your conclusions rather than presenting them as complete.

## Safety rules

- **Never propose `rm -rf` for a path `explain` reports as a cleanup target.**
SpaceMatters empties those itself, fenced to the user's home, never following
symlinks, and journalled. Point at Low-Hanging Fruits in the app instead.
- **Never call anything "safe" that you have not run `explain` on.**
- **Documents, photos, music and videos are never `safe`.** They are `review`
at most, and the recommendation is to move them, not to delete them.
- **Application Support is not a cache directory.** Some of it is state the app
cannot refetch. See `references/directories.md` before touching any of it.
- The server cannot delete anything and neither should you. Produce a plan; the
user acts on it.

## Output

A table, most bytes first:

| Path | Size | Verdict | Why | How |
|---|---|---|---|---|

- **Why** is the evidence, not a restatement of the size: what the folder is,
when it was last written, whether it regenerates.
- **How** is the concrete action: the app's cleanup pass, a shell command, a
tool's own command (`docker system prune`, `git gc`, `brew cleanup`), or
"move to external drive".
- Total up what the plan actually frees, and be honest about the uncertainty
when locations were unreadable.

Then call `annotate` once per row so the plan is on their map too.

## Reference

`references/directories.md` — what the usual suspects on a Mac actually are,
which regenerate, and which look like caches but hold state.
86 changes: 86 additions & 0 deletions Packaging/skills/space/references/directories.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# What the usual suspects on a Mac actually are

The scanner gives bytes and dates. This is the part it cannot give you. When a
folder is not listed here, say you are unsure rather than guessing — an
authoritative-sounding wrong answer about someone's disk is the failure mode
that matters.

## Regenerates on demand — safe when cold

| Path | What it is | Cost of deleting |
|---|---|---|
| `~/Library/Caches/Homebrew`, `~/Library/Caches/homebrew` | Downloaded bottles and casks | Re-download. `brew cleanup` is the tool's own way. |
| `~/.npm/_cacache` | npm's content-addressable cache | Re-download. `npm cache clean --force`. |
| `~/Library/Caches/Yarn`, `~/Library/pnpm/store`, `~/.pnpm-store` | JS package stores | Re-download. pnpm's store is shared by every project — deleting it forces a refetch for all of them. |
| `~/.cargo/registry` | Rust crate sources and caches | Re-download. `~/.cargo/bin` is **not** this: it holds installed binaries. |
| `~/go/pkg/mod`, `~/Library/Caches/go-build` | Go module cache and build cache | Re-download / rebuild. `go clean -modcache`, `go clean -cache`. |
| `~/.m2/repository`, `~/.gradle/caches` | JVM dependency caches | Re-download, and these are slow to refill. |
| `~/.nuget/packages` | NuGet packages | Re-download. |
| `~/Library/Caches/pip`, `~/Library/Caches/uv` | Python wheel caches | Re-download. |
| `~/Library/Developer/Xcode/DerivedData` | Per-project build products, indexes | Rebuild, and the first build after is slow. |
| `~/Library/Caches/org.swift.swiftpm`, `~/Library/Caches/CocoaPods` | Dependency caches | Re-download. |
| `node_modules`, `.venv`, `target`, `build`, `bin`, `obj`, `__pycache__`, `.next`, `dist` | Per-project build/dependency trees | Reinstall or rebuild. Individually small, collectively often the single largest finding — always `find` them. |

## Grows without bound, nobody notices

- **`~/Library/Application Support/Code/User/workspaceStorage`** — one folder per
workspace VS Code has ever opened, keyed by a hash. Holds editor state, chat
sessions (`chatSessions`, `.jsonl`) and per-extension databases (`.vscdb`).
**It keeps folders for repositories deleted years ago** and never prunes them.
Frequently gigabytes. Deleting a hash folder loses that workspace's local
history and chat transcripts, not the code — that is the real trade-off to
present, not "it's a cache".
- **`~/Library/Caches/JetBrains/<Product><Version>`** — one tree per IDE version
ever installed. Old versions are pure waste once uninstalled; the current one
regenerates but reindexing is slow. `~/Library/Application Support/JetBrains`
is different: settings and installed plugins, not a cache.
- **`~/Library/Developer/CoreSimulator/Devices`** — every simulator ever created,
each a full disk image. `xcrun simctl delete unavailable` removes the ones
tied to runtimes you no longer have.
- **`~/Library/Developer/Xcode/iOS DeviceSupport`** — symbol caches per device
and iOS version, one folder per combination, regenerated on next connect.
- **Electron app caches** (`Slack`, `Notion`, `Discord`, …) under
`Application Support/<app>/{Cache, Code Cache, GPUCache, Service Worker}` —
refetched. Their siblings are not: see below.

## Looks like a cache, holds state

Deleting these loses data the app cannot get back.

- `Application Support/*/IndexedDB`, `Local Storage`, `Session Storage`,
`Databases` — app state, drafts, unsynced edits.
- `Application Support/*/File System`, `blob_storage` — offline attachments.
- `Cookies`, `Login Data` — logs the user out of everything.
- Browser **profiles** (`Firefox/Profiles/*`, `Chrome/Default`): the `Cache`,
`Code Cache` and `Service Worker/CacheStorage` inside them are safe; the
profile as a whole is history, passwords, extensions and open tabs. Never
recommend deleting a profile directory.
- `~/Library/Containers/<bundle-id>/Data` — a sandboxed app's entire home. Not a
cache in any sense.
- `~/Library/Mail`, `~/Library/Messages` — the actual mail and messages.

## Never "safe"

- **Photo and video libraries** (`.photoslibrary`, folders of `.raw`, `.dng`,
`.mov`, `.mp4`). A `.photoslibrary` contains a `resources/caches` subtree that
looks deletable and is not — it is managed by Photos. The right advice for a
large media library is to move it to an external drive, or to offload
originals to iCloud, never to delete.
- **`.git`** — and especially not `.git/objects` or `*.pack` files, which *are*
the repository. When a `.git` is genuinely huge, the answer is `git gc
--aggressive --prune=now`, or `git worktree`/shallow clones going forward.
- **Time Machine local snapshots** — they show as disk usage and are managed by
the system, which reclaims them under pressure. `tmutil thinlocalsnapshots`
exists, but macOS handles this on its own; recommending manual deletion is
rarely right.
- Anything under `/System`, `/private/var/db`, or another user's home.

## Sparse — the size is not what you think

Container and VM disk images declare a huge length and allocate a fraction of
it: `~/.colima`, `~/.lima`, `~/Library/Containers/com.docker.docker`,
`~/Library/Application Support/com.apple.container`, Parallels and VMware
bundles, `.ext4`/`.raw`/`.qcow2` files. `explain` reports the gap. Deleting
frees the on-disk figure, not the apparent one — and the tool's own reclaim
command (`docker system prune`, `podman system prune`, `colima delete`) is
almost always the better answer than removing files under it.
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,26 @@ Memory stays low because the tree keeps one [`FSNode`](Sources/SpaceMatters/Mode

Sizes are atomic counters propagated up the ancestor chain as each directory completes, and the UI reads them ten times per second. That is what makes the live view possible.

## Asking an LLM about your disk

Two ways, both local — nothing is uploaded, and SpaceMatters still makes no network request beyond its update feed.

**Copy a briefing.** `⌘⇧C` puts a compact digest of the current view on the clipboard: totals, file types, and a tree budgeted to a few thousand tokens. Detail follows size rather than depth, single-child chains collapse, small siblings roll up, and folders carry what a size alone can't say — `cold:8mo` when nothing inside has been written since, `sparse`, or `cache:npm` when it's a location the app can safely empty itself. Paste it into any assistant.

**Or let a Claude session query the scan directly**, through a read-only MCP server. The ✦ button in the analysis toolbar sets it up — showing exactly what it will write first — or do it yourself:

```sh
claude mcp add -s user spacematters -- /Applications/SpaceMatters.app/Contents/MacOS/SpaceMatters --mcp
```

Setup also installs a skill that teaches the session how to read a scan and what the usual directories on a Mac actually are: which caches regenerate, which only *look* like caches and hold state you can't get back, and what is never safe to delete.

The session gets `overview`, `tree`, `top`, `types`, `find`, `aged`, `explain` and `cleanup_targets`. `find` is the one with no cheap shell equivalent — "19 `node_modules`, 6.7 GiB between them" is a single walk over the scan and a `find | xargs du` storm otherwise. `aged` answers the other half of any delete decision: regenerable *and* untouched for a year.

**If SpaceMatters is open, the session attaches to it** — no second scan, and it sees exactly the tree on your screen. Two more tools appear: `focus` moves the app to the folder being discussed, and `annotate` paints a verdict onto the treemap and the sunburst. Mark a folder `safe`, `review` or `keep` and its whole region takes that colour, with the reason on hover — a colour alone is never the argument, so the sentence behind it is required. Edit › Clear LLM Verdicts undoes the lot. Close the app and the same command falls back to scanning on its own.

The server scans once on the first call (`$HOME` by default, or pass a path) and answers from memory after that. **It cannot delete anything** — no such tool exists. Its output is a plan; the cleanup pass in the app is what acts on it, fenced and journalled. Anything it could not read, it says so rather than quietly under-reporting.

## Download

Grab the latest `.dmg` from the [Releases page](../../releases/latest), open it, and drag SpaceMatters into Applications.
Expand Down
107 changes: 107 additions & 0 deletions Sources/SpaceMatters/App/ClaudeIntegration.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import Foundation
import AppKit

/// One-click setup for the Claude integration — SPEC-14 phase 4.
///
/// Two halves that are useless apart: the MCP server is the data, the skill is
/// the method (procedure, how to read the numbers, and the directory knowledge a
/// model cannot infer from bytes and dates).
///
/// Nothing is written before the user has read exactly what will be written.
/// Registration goes through `claude mcp add` rather than editing the CLI's own
/// config file: that file is the CLI's business, its shape can change, and a
/// GUI app rewriting it is how someone loses their MCP setup.
@MainActor
enum ClaudeIntegration {

nonisolated static let serverName = "spacematters"

struct Plan {
/// `nil` when the CLI could not be found — the skill can still be
/// installed and the command still shown for the user to run themselves.
var cliPath: String?
var skillSource: URL?
var skillDestination: URL
var skillAlreadyInstalled: Bool
var executablePath: String

var registerCommand: String {
"claude mcp add -s user \(serverName) -- \"\(executablePath)\" --mcp"
}
}

/// GUI apps inherit a minimal `PATH`, so `claude` is looked for where it
/// actually installs rather than resolved through the shell.
private static let cliCandidates = [
NSHomeDirectory() + "/.local/bin/claude",
NSHomeDirectory() + "/.claude/local/claude",
"/opt/homebrew/bin/claude",
"/usr/local/bin/claude",
]

static func plan() -> Plan {
let destination = URL(fileURLWithPath: NSHomeDirectory() + "/.claude/skills/space")
return Plan(
cliPath: cliCandidates.first { FileManager.default.isExecutableFile(atPath: $0) },
skillSource: Bundle.main.url(forResource: "space", withExtension: nil,
subdirectory: "skills"),
skillDestination: destination,
skillAlreadyInstalled: FileManager.default.fileExists(atPath: destination.path),
executablePath: Bundle.main.executablePath ?? CommandLine.arguments[0])
}

/// Is the server registered at user scope?
///
/// Read from the config rather than asked of `claude mcp list`: that command
/// *health-checks every server the user has*, including remote ones over the
/// network, which took seconds of spinner to answer a question about one
/// local entry. Reading is also safe in a way writing is not — registration
/// still goes through the CLI, because that is the half that must survive a
/// format change.
nonisolated static func isRegistered() -> Bool {
let path = NSHomeDirectory() + "/.claude.json"
guard let data = FileManager.default.contents(atPath: path),
let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let servers = root["mcpServers"] as? [String: Any] else { return false }
return servers[serverName] != nil
}

// MARK: Doing it

enum Outcome {
case done(registered: Bool)
case failed(String)
}

static func apply(_ plan: Plan) -> Outcome {
if let source = plan.skillSource {
do {
let fm = FileManager.default
try fm.createDirectory(at: plan.skillDestination.deletingLastPathComponent(),
withIntermediateDirectories: true)
if fm.fileExists(atPath: plan.skillDestination.path) {
try fm.removeItem(at: plan.skillDestination)
}
try fm.copyItem(at: source, to: plan.skillDestination)
} catch {
return .failed("Could not install the skill: \(error.localizedDescription)")
}
}

guard let cli = plan.cliPath else { return .done(registered: false) }
let result = ProcessRunner.runSync(
cli, ["mcp", "add", "-s", "user", serverName, "--", plan.executablePath, "--mcp"],
timeout: 30)
guard result.exitCode == 0 else {
// Report what the CLI actually said — an "already exists" is a very
// different problem from a broken install, and guessing which would
// send the user down the wrong path.
let detail = [result.stderrString, result.stdoutString]
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.first { !$0.isEmpty } ?? "exit code \(result.exitCode)"
return .failed("The skill is installed, but registering the server failed:\n\n\(detail)"
+ "\n\nRun this yourself to see more:\n \(plan.registerCommand)")
}
return .done(registered: true)
}
}
Loading
Loading