# Doma

> Use doma both to (1) discover directory paths the user has already tagged so you can operate on them in bulk, and (2) register or bookmark new directories when the user's intent calls for persistence ("track this", "bookmark this", "I'll come back later"). Trigger when the user references "my X projects" / "all directories tagged Y" / "every repo I marked as Z", asks for a category-spanning chore ("update CI for all my Crystal projects"), or asks to save/track/remember a path. Skip when the user names explicit paths for one-shot work, or when `which doma` shows it isn't installed.

- Skill: `hahwul/doma` (Agent Skill)
- Install (CLI): `npx skillmds@latest add hahwul/doma`
- Raw SKILL.md: https://api.skillmd.com/api/skills/hahwul/doma/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: hahwul (https://skillmd.com/u/hahwul)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/hahwul/doma

---


# doma: Find, Tag, or Bookmark Directories

doma is a directory tag manager. The user attaches tags to directories
(`crystal`, `work/proj-a`, `bookmark`, etc.) and persists them in a
SQLite database under `~/.config/doma/`. As an agent you have two
jobs: query that database to drive other operations, **and** add to it
when the user's intent calls for it.

## When the user wants to discover paths

The request mentions a category of directories rather than specific
ones. Check doma before guessing:

```bash
doma tags --names              # what tags exist on this machine?
doma list -t crystal --paths   # paths under that tag, one per line
```

If `doma tags --names` doesn't list a tag matching the user's category,
fall back to whatever discovery method you'd use otherwise (filesystem
search, asking the user). Don't guess at a tag name that wasn't
returned — `doma list -t guess` returns nothing for a non-existent
tag, and silent emptiness is worse than asking.

### Read modes

| Want | Command | Why |
|---|---|---|
| One path per line, for `while read` / `xargs` | `doma list -t TAG --paths` | The default newline-separated form |
| NUL-separated, paths with spaces | `doma list -t TAG -0` | Pipe to `xargs -0`. Safer than `--paths` when paths might contain spaces |
| Structured JSON (`short_id`, `path`, `basename`, `tags`, `created_at`, `last_used_at`) | `doma list -t TAG --json` | Timestamps are unix epoch seconds — filter your own recency window instead of relying on `--by recent` ordering (`last_used_at == 0` means never used). TTL'd tags add an `expirations` map (`tag → unix epoch`); `--check` adds a boolean `exists` |
| Substring across path/basename/tag | `doma list <query>` | Single substring match. Combines with `-t` for intersection. Multiple positional args are joined by a space — they are *not* AND-ed |
| Sorted by recency | `doma list --by recent` | Most-recently-used first; aliases: `used`, `recency`. Useful when "the project I was just working on" is in scope |
| Mark missing paths inline | `doma list --check` | Tags entries whose path is gone with `[gone]`. Without it, the footer just counts them |
| Just the tag names | `doma tags --names` | Cheap probe before committing to a tag. `doma tags --tree` shows the `work/proj/...` hierarchy; `doma tags -0` is NUL-separated for `xargs -0` |
| Git state across a tagged set | `doma status -t TAG --json` | Per-repo `branch`, `ahead`/`behind`, `dirty`, `clean`, `untracked`, etc. Use for "which of my X repos have uncommitted changes?" — filter `.[] \| select(.dirty > 0)`. Add `--dirty` to pre-filter. Shells out to `git` |

## When the user wants to register or bookmark paths

Write operations are real state changes; do them when the user's
intent clearly maps to "remember this", not as a side effect of
unrelated work.

| User says... | Command | Notes |
|---|---|---|
| "Track this project" / "I'll be working on it" | `doma add <path> -t <category>` (or `--json` for structured result incl. short_id) | Permanent (no TTL) |
| "Track all of these as `<name>`" | `doma add <path1> <path2> ... -t <name>` | Multi-path is one command |
| "Bookmark this for review" / "Remember this for later" | `doma mark <bookmark-name>` | cwd + 7-day TTL — equivalent to `add . -t NAME --tmp`, just shorter |
| "Mark these for the auth review session" | `doma mark -p <each-path> auth-review` | One call per path; tags accumulate. `-p` skips the cd dance |
| "Save this for the next week" | `doma add . -t reading --tmp` | Or `doma mark reading` |
| "Save this for two days" | `doma add . -t reading --ttl 2d` | Custom duration; `mark` only covers the 7d default |
| "Untag this" | `doma rm <path> -t <tag>` | Removes the tag; the path entry stays if it has other tags |
| "Forget this directory" | `doma rm <path>` | Soft-delete: routes to trash, recoverable for 7d via `doma trash restore <id>` |
| "Delete permanently, skip the trash" | `doma rm <path> --hard` | Same `--hard` is available on `doma prune --gone` for sweeping dead paths irrecoverably |

`mark` is the right tool for transient session-style organization
(code review, refactor sweep, debugging deep-dive). `add` is for
durable categorization that survives multiple sessions.

### Multi-tag and multi-path forms

```bash
doma add /path        -t crystal -t cli           # multiple tags, one path
doma add /a /b /c     -t shared                   # one tag, many paths
doma add . -t crystal --auto-tag --git-tag        # +basename, +github/repo derived
doma mark -p /elsewhere spike                     # mark a path other than cwd
doma mark spike skim review                       # multiple temp tags on cwd at once
```

## Recovering from `rm` (the trash)

`doma rm <path>` defaults to a soft-delete: the row + tags are
snapshotted into a trash store under `~/.config/doma/trash/` and the
path disappears from `list` output. Anything older than 7 days is
auto-pruned on the next trash op. `--hard` on either `rm` or
`prune --gone` skips the snapshot and makes the deletion permanent.

| User says... | Command | Notes |
|---|---|---|
| "What can I recover?" | `doma trash list` or `doma trash list --json` | Human table (newest first) or structured JSON array with `short_id`, `path`, `tags`, `deleted_at`, `expirations`, etc. Prefer `--json` in agents. |
| "Bring it back" | `doma trash restore <short_id>` | 7-char prefix from `trash list`. Add `--merge` if the path was re-registered in the meantime |
| "Empty the trash" | `doma trash empty` | Confirmation prompt unless `-y` / `--yes` / `DOMA_YES=1` |
| "Just clean up old trash" | `doma trash empty --older 7d` | Same duration grammar as `--ttl` |

A short_id printed by `rm` (e.g. `trashed /foo (recover with doma
trash restore abc1234)`) is the same id `list` and `info` use; copy-
paste works across all three.

## Operating on read results

Two patterns. Pick based on whether the operation needs to step inside
each directory or just needs the path string.

**Pattern A — iterate paths in your own loop (most common):**

```bash
doma list -t crystal --paths | while read -r dir; do
  # use $dir however — Read tool, Edit, Bash with cwd=$dir, etc.
done
```

For paths-with-spaces safety, prefer NUL-separated:

```bash
doma list -t crystal -0 | xargs -0 -I{} sh -c 'cd "{}" && grep -l TODO **/*.cr'
```

**Pattern B — let doma run a command per directory:**

```bash
doma run crystal --json -- shards build       # per-directory results, attributable (prefer this)
doma run crystal -- shards build              # sequential, streams to the terminal
doma run crystal --parallel -- ...            # concurrent; without --json the output interleaves
doma run crystal --parallel --jobs 4 -- ...   # cap concurrency (default: CPU count)
doma run crystal --fail-fast -- ...           # halt on first non-zero exit (sequential only)
doma run crystal --dry-run -- rm -rf build    # print the target set without running anything
doma run crystal --no-header -- pwd           # suppress ▶/✓ chrome (failures still surface)
```

**Prefer `--json` in an agent.** Without it, every directory's output
lands on one shared stream, and under `--parallel` there is no way left
to tell which repo produced which line. `--json` captures each child
separately and emits one row per directory, in the same order as
`list --paths`, whether or not the sweep ran in parallel:

```json
[{"path":"/a","exit_code":0,"stdout":"ok\n","stderr":"","dry_run":false},
 {"path":"/b","exit_code":1,"stdout":"","stderr":"boom\n","dry_run":false}]
```

Answering "which of my repos failed?" is then `jq '.[] | select(.exit_code != 0) | .path'`
rather than a guess at interleaved text. The process exit code is still
1 if any directory failed, so the old `&&` semantics hold. Notes:

- `--json` suppresses the `▶`/`✓` chrome; the rows carry the status.
- `--json --dry-run` previews as `[{"path":…,"dry_run":true}]` — no
  `exit_code` key, because nothing ran. Reach for it before any
  destructive sweep to confirm the tag resolved to what you expect.
- `--fail-fast` truncates the array to the directories that actually
  ran; the rest are simply absent.
- A dead path becomes a row with `exit_code: 127` and doma's
  explanation in `stderr`, not a hard error.

Use `doma run` only when the operation is uniform enough to express as
a single shell command. For per-directory logic that involves reading
files or making decisions, Pattern A keeps the work in your hands.

## Exit codes

Every failure class has its own code, so you can branch on the status
instead of matching on error text (which is human-facing and will be
reworded).

| Code | Means | Typical cause |
|---|---|---|
| 0 | Success — **including an empty result set** | `list -t X` matched nothing |
| 1 | Generic failure | bad flag; `run` had at least one directory exit non-zero |
| 2 | Validation | invalid tag name, conflicting output flags, `add` given a non-directory |
| 3 | Not found | `info`/`rm` on an unregistered path; `list --pick` matched nothing |
| 4 | Conflict / ambiguous | `list --pick` matched N directories with no TTY and no `--first` |
| 5 | Config | unreadable or invalid `config.yml` |
| 6 | Import | malformed snapshot file |
| 130 | Cancelled | user hit Ctrl-C in an interactive picker |

Two of these matter most in practice:

- **0 with empty stdout is a real, successful outcome** — not an error.
  Check for empty output before iterating.
- **4 from `--pick` means "I refuse to guess"** — narrow the filter,
  pass `--first`, or use `list -t TAG --paths` if you actually wanted
  every match.

## Pitfalls

- **`doma cd` is a shell function, not a binary subcommand.** Calling
  the bare binary with `cd` prints an error pointing at
  `doma setup install`. The agent-friendly equivalent is
  `path=$(doma list -t <tag> --pick)`:
    - 0 matches → exit 3 with a hint
    - 1 match → prints the path
    - N matches + TTY → interactive picker
    - N matches + non-TTY → exits 4 (refuses to silently auto-pick).
      Pass `--first` to take the most-recent match, or set the default
      with `doma config set selector first`. `--builtin` forces the
      interactive picker even without a TTY.

  When you need every path, use `doma list -t TAG --paths`.

- **Never invoke bare `doma` or `doma tui`.** With a terminal attached
  they open a full-screen interactive finder and wait for a keypress —
  which will hang you if your shell runs on a pty. Set `DOMA_NO_TUI=1`
  if you need bare `doma` to be safe (it prints help instead), and use
  `doma list` for everything you'd otherwise browse.

- **Destructive ops refuse to run unattended.** `rm --hard`,
  `prune --gone --hard`, `import --replace`, and `trash empty` exit 1
  with "requires --yes" when there's no TTY. That guard is deliberate:
  treat it as a signal to go back to the user for confirmation, not as
  something to defeat by adding `-y` / `DOMA_YES=1` on your own
  initiative.

- **Symlinks are resolved.** doma stores the canonical real path, so a
  registered `/var/foo` will surface as `/private/var/foo` on macOS.
  Don't be alarmed if the listed paths look "different" from what the
  user might type.

- **Expired tags are hidden by default.** When the user uses TTL tags
  (`--ttl 7d`, `--tmp`, `mark`), an expired row vanishes from
  `list -t TAG` but the directory itself remains under any non-expired
  tags. Add `--include-expired` if the user explicitly asked to audit.

- **Empty result is a real outcome, not an error.** `doma list -t X`
  with no matches exits 0 with a one-line stderr. Check for empty
  stdout before iterating; don't proceed assuming you have paths.

- **Re-tagging refreshes / clears the TTL.** `doma add . -t reading
  --tmp` resets the timer; the same call without `--tmp` reverts the
  tag to permanent. Be aware if you're scripting both operations on
  the same path.

- **Bulk destructive ops need explicit user intent.** `prune --gone`,
  `prune --expired`, and `import --replace` are sweeping operations.
  Don't reach for them as housekeeping unless the user asked. The
  per-path forms (`rm <path>`, `rm <path> -t TAG`) are fine when the
  user pointed at something specific.

- **`add`/`mark` are state changes — match them to intent.** Saying "look
  at this directory" is not the same as "track this directory."
  Persist only when the user's wording clearly implies "remember this
  for later" or "I want to come back here." When in doubt, ask.

## Common request shapes

| User says... | First doma call |
|---|---|
| "Update Crystal version in CI for all my Crystal projects" | `doma list -t crystal --paths` |
| "Check git status across my work repos" | `doma list -t 'work/*' --paths` (glob applies to `list -t` and `run`) |
| "Find that bookmarked thing about auth" | `doma list -t bookmark auth` (tag filter ∩ substring `auth`) |
| "What was I working on last?" | `doma list --by recent` (top entries are most-recent cd targets) |
| "Is this directory registered? with what tags?" | `doma info --json` (cwd), or with `<path>` / `<short_id>` / `<name>` (substring fallback). Always emits JSON — `{"found":true,…}` or `{"found":false,"error":…,"hint":…}` with exit 3. Branch on `found`; no need to scrape stderr |
| "Run specs across all the Crystal projects in parallel" | `doma run crystal --parallel --json -- crystal spec` (`--json` is what makes each repo's output attributable; cap concurrency with `--jobs N`) |
| "Which of my Crystal projects fail to build?" | `doma run crystal --json -- shards build \| jq -r '.[] \| select(.exit_code != 0) \| .path'` |
| "What have I touched in the last week?" | `doma list --json` → filter `.last_used_at` against your own epoch cutoff |
| "I'll be working on this project for a while" | `doma add . -t <category>` (use `--json` to capture the new short_id immediately) |
| "Bookmark this so I come back later" | `doma mark <name>` |
| "Mark these dirs for the auth review" | `doma mark -p <each-path> auth-review` (no need to cd around) |
| "Forget the bookmark" | `doma rm <path> -t bookmark` (or wait for the TTL) |
| "Show me what's expiring soon" | `doma list --include-expired` (then filter by `expires_at` in `--json`) |
| "I deleted the wrong directory, get it back" | `doma trash list --json` (or human table) → `doma trash restore <short_id>` (within 7d) |
| "Sweep dead paths" / "Sweep expired tags" | `doma prune --gone` / `doma prune --expired` (both reversible from trash unless `--hard`) |

## Stable identifiers

Every directory has a 7-char `short_id` (visible in `list` output and
in `--json`). It survives re-tagging and renames, so when you need to
refer to a specific entry across multiple steps, store the short_id —
not the path (which can be moved) and not the index (which changes
when the list reorders).

```bash
ID=$(doma list -t crystal --json | jq -r '.[] | select(.basename=="doma") | .short_id')
# ... later, after the user moved the directory ...
PATH_NOW=$(doma list --json | jq -r --arg id "$ID" '.[] | select(.short_id==$id) | .path')
```

short_ids are accepted by `rm <id>`, `info <id>`, and
`trash restore <id>`. They are *not* accepted by `list --pick` (use
the tag), and the bare binary's `cd` subcommand has been removed in
favor of the shell-wrapper + `list --pick` split.

## When NOT to invoke

- The user named explicit paths (`/Users/me/projects/foo`, `./bar`)
  for one-shot work. Just operate on those — doma adds nothing.
- `which doma` returns nothing, or `doma --version` errors. Fall back
  to filesystem discovery (`fd`, `find`) and tell the user doma isn't
  available.
- The operation only touches the current directory and the user
  didn't ask to track it. doma is for cross-directory work and for
  persisting intent — one-shot tasks don't need it.

