---
ai_assisted: true
title: "Disk Utilities Through Two Surfaces — zsh Aliases and a Claude Skill"
description: "One set of zsh disk functions, two surfaces: aliases (dh, dgh, dol) for people and a Claude Skill for agents, with a stale-while-revalidate cache."
pubDate: 2026-05-20
category: "Development"
tags: ["zsh", "Claude Code", "Skill", "Dotfiles", "Disk Management", "stale-while-revalidate", "Dev Workflow"]
heroImage: ""
serial: 19
draft: false
published: true
---

A small set of disk utilities exposed through two interfaces at once — zsh aliases (`dh`, `dgh`, `dol`, ...) for human use, and a Claude Skill for agent use. The same shell functions answer both surfaces.

---

## Two interfaces, one logic

The canonical version of the disk management code lives in `~/.oh-my-zsh/custom/disk_utils.zsh`. It defines about a dozen functions — `disk_home`, `disk_github`, `disk_ollama`, `disk_ml_cache`, and so on — and then binds short aliases to each one: `dh`, `dgh`, `dol`, `dml`. That is the human surface.

The second surface is `~/.claude/skills/disk-manager/SKILL.md`. It registers the same alias vocabulary with Claude Code so the agent can invoke `dh` or `ddiff` in response to natural-language requests like "show me what is taking up space in my GitHub folder" or "how much disk did I free this week." The agent does not reimplement the logic — it calls the same shell commands the human would call.

The split exists because the two callers have different needs. A human running `dh` from the terminal wants output immediately, can tolerate a one-line stale notice, and will press `dref` if the cache looks wrong. An agent calling `ddiff` inside a longer troubleshooting session needs the same data but triggers it through text — there is no shell prompt to read, no menu to navigate. The Skill layer translates natural language into the right alias and handles the output interpretation. The zsh layer stays unchanged.

---

## The zsh side

`disk_utils.zsh` organizes the aliases into three groups: read-only queries, history commands, and cleanup commands.

**Read-only queries:**

```bash
disks    # full interactive dashboard (numeric menu)
dh       # home directory top 15 items
dgh      # GitHub projects by size
dicl     # iCloud Drive
dcol     # Colima VM — Docker images with project references
dol      # Ollama installed models
das      # Library > Application Support
dlc      # Library > Caches
dct      # ~/.cache (CLI/dev tools)
ddt      # dev tools: npm / Bun / nvm / pyenv / Cursor / Gradle / GHCup
dml      # ML caches: HuggingFace / uv / PyTorch / Keras / TensorFlow / pip
```

**History commands (SQLite-backed):**

```bash
dsnap    # take an immediate snapshot (launchd runs this every 6 hours automatically)
ddiff    # usage delta vs. N hours ago (default 24h — ddiff 48 works too)
dhist    # total usage trend chart; dhist colima 30 for a specific category
```

`dhist` categories: `colima`, `ollama`, `uv_cache`, `hf_cache`, `github`, `icloud`, `lib_caches`, `lib_appsupport`.

**Cleanup commands:**

```bash
dcache   # prompt-confirmed wipe of ~/.cache/*
dnpm     # prompt-confirmed npm cache clean --force
dref     # invalidate disk_cache files and re-run disk_home
```

The interactive `disks` dashboard is useful for an overview session. The individual aliases are faster for one-off lookups or for scripting. The history commands (`ddiff`, `dhist`) require a snapshot record in `~/.disk_cache/disk_history.db` — run `dsnap` at least once, or let launchd accumulate a few runs first.

The cache directory is `~/.disk_cache/`. Each cached query writes to a named file there (`home_usage`, `github_usage`, `icloud_usage`, and so on). TTL is 21600 seconds — six hours. The `_cache_valid` function compares the file's mtime against the current epoch:

```zsh
_cache_valid() {
  local cache_file="$1"
  [[ ! -f "$cache_file" ]] && return 1
  local age=$(($(date +%s) - $(stat -f %m "$cache_file" 2>/dev/null || echo 0)))
  [[ $age -lt $CACHE_TTL ]]
}
```

---

## The Claude Skill side

The Skill is registered at `~/.claude/skills/disk-manager/SKILL.md`. Its description field (the text Claude Code matches against incoming requests) reads:

> "디스크 사용량을 모니터링하고 정리합니다. 대용량 파일 찾기, 폴더별 크기 분석, 캐시 정리, ML 프레임워크 모니터링, Docker 이미지 분석 등에 사용합니다. dust/fd 기반 고속 스캔, stale-while-revalidate 캐싱, launchd 주기적 인덱싱."

When the agent matches a request against that description, it has the full alias list and the `disk-utils.sh` script available. It does not need to reconstruct what `dml` does — the Skill tells it: `dml` = ML framework caches (HuggingFace / uv / PyTorch). The agent runs the alias in a Bash tool call and reads the output.

Beyond the simple alias calls, the Skill includes a structured investigation protocol — the order in which to narrow down a reported disk spike. It starts with `df -h | grep /System/Volumes/Data`, drills into `dust -d 1 -n 15 ~/Library`, then walks through `Library/Containers` for Docker Desktop or UTM disks. The agent follows that sequence rather than jumping to cleanup commands prematurely.

The Skill also carries caveats the agent needs to surface: `docker images`'s `CREATED` field reflects build time, not pull time; Docker image deletion on macOS requires `colima ssh -- sudo fstrim -av` before the filesystem reclaims space; `dref` shows stale data during the TTL window and should not be trusted for post-cleanup verification. These are things the human running `dh` would know from experience but the agent would not infer from shell output alone.

---

## Shared logic

`disk_utils.zsh` is the single source of truth. `disk-utils.sh` — the companion script in the Skill directory — is an earlier version of the same functions, now superseded. The current production file is the zsh one. Both the human alias path and the agent invocation path point to the same zsh source.

The `_get_or_update_cache` function is where both surfaces converge at runtime:

```zsh
_get_or_update_cache() {
  local cache_file="$1"
  local cmd="$2"

  if [[ -f "$cache_file" ]]; then
    cat "$cache_file"
    if ! _cache_valid "$cache_file"; then
      (eval "$cmd" > "${cache_file}.tmp" && mv "${cache_file}.tmp" "$cache_file") &!
    fi
  else
    eval "$cmd" | tee "$cache_file"
  fi
}
```

When a human runs `dgh`, this function runs. When the agent calls `dgh` through a Bash tool, this function runs. The cache file is the same file. There is no separate cache for agent calls.

The underlying scan command for most queries is `dust` — a Rust-based `du` replacement that is faster on large directory trees and produces sortable, human-readable output. `dust -d 1 -n 30 $GITHUB_DIR` scans one level deep and returns the top 30 entries. The Colima section calls `docker images` and `docker system df` directly when Colima is running, falling back to a grep across docker-compose files when it is not.

---

## Stale-while-revalidate in a shell

The cache pattern used here is stale-while-revalidate: if a cache file exists, return it immediately regardless of age, then check whether it has expired. If it has, start a background refresh.

```zsh
if [[ -f "$cache_file" ]]; then
  cat "$cache_file"                          # return stale data now
  if ! _cache_valid "$cache_file"; then
    (eval "$cmd" > "${cache_file}.tmp" \
      && mv "${cache_file}.tmp" "$cache_file") &!   # refresh in background
  fi
else
  eval "$cmd" | tee "$cache_file"            # no cache: block and populate
fi
```

The `&!` suffix in zsh disowns the background job so it survives the terminal session without being tracked in the jobs table. The `tmp`-then-`mv` pattern prevents a reader from seeing a partially written cache file.

This means `dh` returns in under a second even when the underlying `dust` scan of the home directory would take several seconds. The tradeoff is that the numbers shown may be up to six hours old. For the common use case — checking whether disk usage is in the expected range — stale data is fine. For post-cleanup verification, `dref` discards the cache files and forces a synchronous rescan.

The exception is `_check_space_change`, which runs at the start of `disk_home` (and thus `dh`). It calls `df /System/Volumes/Data` — a cheap syscall, not a directory scan — and compares the result against the last recorded value stored in `~/.disk_cache/.last_used_space`. If the delta exceeds 5120 MB (5 GB), it logs a warning, calls `_invalidate_cache` to delete all the named cache files, and returns a non-zero exit code. On the next call, those files do not exist, so the code falls through to the synchronous scan branch instead of returning stale data.

The six-hour TTL and five-GB threshold are rough calibrations for a machine that is actively in use but not continuously churning disk. The launchd agent (`~/Library/LaunchAgents/com.jaesolshin.disk-index.plist`) runs a refresh script every six hours regardless of whether any alias has been called, so the cache tends to be warm by the time a query arrives.

---

## Operational notes

**Permissions.** `dust` requires read access to the directories it scans. Most home-directory paths work without elevation. `~/Library/Containers` and `~/Library/Application Support` have some subdirectories that require Full Disk Access granted in System Settings. Commands that hit those paths silently skip inaccessible entries — the size totals will be understated without a visible error.

**Cache staleness and agent calls.** When the agent calls `dh` or `dml`, it receives whatever is in the cache at that moment. If the cache was last updated four hours ago and a large model download happened in the meantime, the agent will see the pre-download numbers. The Skill notes this explicitly: for post-cleanup verification, run `dref` first or use `df` directly. The agent knows not to treat `dh` output as real-time.

**Registering the Skill.** The Skill lives at `~/.claude/skills/disk-manager/SKILL.md`. Claude Code picks it up automatically on next start if the skills directory is on the configured path. No additional registration step is required beyond placing the file.

**Concurrent calls from both surfaces.** If a human runs `dgh` in a terminal at the same moment the agent calls `dgh` in a Bash tool, both reads hit the same cache file. Both will get the cached output. If the cache is stale, both will attempt a background refresh — two concurrent `dust` processes scanning the same directory, writing to `github_usage.tmp`, and racing to `mv` the result. In practice this is harmless: both processes produce the same output, and whichever `mv` completes second overwrites the first with an identical file. The scan output is deterministic for a given directory state. The risk would be a half-written `.tmp` file if both processes were interrupted simultaneously, but the `tmp`-then-`mv` pattern makes that non-destructive — the next call would miss the cache file and do a fresh blocking scan.

**SQLite history.** `ddiff` and `dhist` query `~/.disk_cache/disk_history.db`. The database retains 90 days of snapshots. The schema has two tables: `snapshots` (category, size\_mb, captured\_at) and `disk_free` (total\_mb, free\_mb, captured\_at). `ddiff` compares the latest row against the row closest to N hours ago; `dhist` draws an ASCII bar chart from daily-latest values using a Python inline script. Both commands check for the database file first and print a prompt to run `dsnap` if it does not exist yet.
