CLI Reference

minuta is the command-line companion to the macOS/iOS app. It reads and writes the same on-disk Automerge files, takes the same cross-process write lock, and speaks the same schema.

Source: Shared/Sources/MinutaCLI/. Build: cd Shared && swift build -c release --product minuta.

Installation

Install via the Homebrew tap. The formula bundles the binary, man pages, and bash/zsh/fish completions.

brew tap ramil-k/minuta
brew install minuta              # CLI
brew install --cask minuta       # desktop app

Verify: minuta --version. Man pages: man minuta.

The CLI resolves its storage folder automatically (it reads the path the app writes to ~/Library/Application Support/minuta/storage-path.txt), or set MINUTA_STORAGE / pass --storage <path> to override. The sandboxed Mac App Store build of the app never writes that file, so with it the CLI needs --storage or MINUTA_STORAGE; see Storage discovery under App Sandbox.

Gatekeeper note

Since app 1.0.3 / CLI 0.1.2 (2026-10-02) releases are signed with the holder’s Developer ID and notarized by Apple (app stapled, CLI ticket fetched online), so both open without a Gatekeeper prompt; see 919-plan-mac-distribution. Earlier builds were signed with a development certificate only: the cask cleared the quarantine attribute on install, and for a direct download run xattr -dr com.apple.quarantine /Applications/Minuta.app or right-click the app and choose Open once.

Global flags

Every subcommand accepts these:

FlagDescription
--storage <path>Override storage discovery.
--jsonMachine-readable JSON on stdout.
-v, --verboseDiagnostics on stderr: the storage folder and where it was found (--storage flag, MINUTA_STORAGE env var, the app’s pointer, the default), the .minuta-version marker and whether this command wrote it. doctor -v also lists duplicate records and unreadable files.
-q, --quietSuppress non-error stderr output: confirmations (added: ..., deleted ..., wrote N record(s) to ...), import progress, storage path metadata, warnings about unreadable files, doctor’s warning count. error: lines stay. Wins over -v.

Storage discovery order (first match wins):

  1. --storage <path> flag
  2. MINUTA_STORAGE environment variable
  3. <realHome>/Library/Application Support/minuta/storage-path.txt written by the app
  4. ~/Documents/Minuta default

If step 3 points at a folder that is not accessible, the CLI exits with code 2 instead of falling through to the default.

See Cross-Process Safety for the discovery mechanics (including the Catalyst container trap).

Run minuta storage path to see which source produced the current path.

Storage discovery under App Sandbox

Step 3 only works with a non-sandboxed app build (Homebrew cask / direct download). The Mac App Store build runs under App Sandbox and never writes storage-path.txt: StorageLocationManager.publishResolvedStoragePath() skips the pointer when APP_SANDBOX_CONTAINER_ID is set in the environment, because the file lives outside the app’s container. With that build the CLI therefore does not learn which folder the app uses:

  • with no pointer file, discovery falls through to step 4, the real ~/Documents/Minuta. The sandboxed app’s own default is the Documents folder inside its container (see StorageLocationManager), so the two defaults are different folders;
  • a pointer file left behind by an earlier non-sandboxed build is still read at step 3, but the sandboxed app no longer updates it.

Pass --storage <path> or set MINUTA_STORAGE to the folder selected in the app’s Settings, and check the result with minuta storage path.

Output contract

  • stdout: data only (tables, --json, --csv, primary IDs).
  • stderr: progress, warnings, -v diagnostics.

This keeps pipelines like minuta export csv --today | sort clean even with -v.

Exit codes

CodeMeaning
0success
1generic error (not found, unexpected failure)
2storage unreachable — path resolution failed or target is inaccessible
3validation error (bad flag, end before start, unparseable date)
4reserved — concurrent-write retry limit (unreachable under current locking)
5write refused: .minuta-version names a newer storage version than this CLI supports (update the CLI), or is not a version number (check the folder, fix or remove the file); the marker and the data are left untouched

Write commands check .minuta-version first. An absent marker (a new folder, or one from before the marker existed) is created and the write goes on; an older version is upgraded. Read commands never check it. The app writes the marker the same way at launch: it never downgrades a newer version or overwrites a marker that is not a version number (StorageVersionMarker.writeCurrent throws, the app logs it).

Unreadable storage files

A month file or the tags file can be unreadable for a while: truncated or half-downloaded by a sync service, zero bytes, no permission. Storage does not fail on it. The file is never overwritten, renamed or deleted; the other files are served; new records for an unreadable month go to a parallel recovery file records/YYYY/MM.recovery.automerge, which is merged back and removed once the month file reads again. A record that is inside the unreadable file cannot be edited or deleted until then. Mechanics: AutomergeStorageService.

So start, stop and status keep working when the current month’s file is unreadable, and a command lists the records it can read. To keep that from being silent, the CLI says so on stderr, and it refuses the commands whose result would be wrong without the hidden records, see Refused while records are hidden.

The stderr warning

A command that read storage prints one line on stderr, after its normal output, when the loads it did met an unreadable file:

warning: 1 month file could not be read (2026-04); its records are not shown. Run 'minuta doctor'.

The line is built from up to three sentences, in this order, followed by Run 'minuta doctor'.:

UnreadableSentence
one month file1 month file could not be read (2026-04); its records are not shown.
several month files2 month files could not be read (2026-03, 2026-04); their records are not shown.
one recovery file1 recovery file could not be read (2026-04); the records in it are not shown.
several recovery files2 recovery files could not be read (2026-03, 2026-04); the records in them are not shown.
the tags filethe tags file (tags.automerge) could not be read; tag names are not shown.

The tags sentence starts with a capital The when it follows another sentence. A list of more than six months is cut to the first six plus , and N more. Example with a month and the tags file:

warning: 1 month file could not be read (2026-04); its records are not shown. The tags file (tags.automerge) could not be read; tag names are not shown. Run 'minuta doctor'.

Details:

  • The exit code is not changed by the warning, and stdout is not touched (--json output stays valid).
  • -q suppresses it.
  • It is printed only when the command succeeded; a failing command prints its error: line instead.
  • It covers the files the command visited, nothing else is scanned. status and stop read every month. records list --from ... --to ... and the exports read only the months in the range, so an unreadable month outside the range is not mentioned.
  • Commands that print it: start, stop, status, records list|show|add|edit|delete, records image add|list|remove|export, export csv|report, import hey, tags delete. doctor reports the same files in its own rows.

The not-found hint

A record that lives inside an unreadable month file cannot be found by id. “Not found” would be wrong there, so the error (exit code 1) gets a second sentence when a month file could not be read:

error: No record with ID 3B13041F-E156-4C0B-9A57-0E3D5C2F7A11. It may be in a month file that could not be read (2026-04). Run 'minuta doctor'.

With several unreadable month files the sentence is It may be in one of 2 month files that could not be read (2026-03, 2026-04). Run 'minuta doctor'. The hint is added to No record with ID ... and No record matching '...' (records show|edit|delete, records image ...) and to No running timer with ID ... (stop --id).

This is also what records delete and records edit report for a record inside an unreadable month file: the CLI looks the record up first and does not find it, so nothing is written. Delete or edit it again once the file can be read. Storage would refuse both as well (recordInUnreadableMonth, recordMayBeInUnreadableMonth, see AutomergeStorageService).

Refused while records are hidden

Some commands would give a wrong result, not just a shorter one, when records cannot be seen. They fail with exit code 1 and write nothing. In the messages, one month reads the month file for 2026-04, several read the month files for 2026-03, 2026-04 (more than six are cut to the first six plus , and N more).

CommandRefused whenMessage
tags delete, with or without --forceany month file or recovery file is unreadableTag 'Work' was not deleted: the month file for 2026-04 could not be read, so records that use the tag may be in it. Repair the file (run 'minuta doctor') and delete the tag again.
import hey --resume, --dry-run includeda month file or recovery file is unreadable inside the window from the earliest start to the latest end of the CSV’s rowsNothing was imported: the month file for 2026-04 could not be read, so --resume cannot tell which rows are already there. Repair the file (run 'minuta doctor') and run the import again.
export csvthe date range includes a month whose month file is unreadableThe export was not written: the month file for 2026-04 could not be read, so its records would be missing. Narrow --from/--to to leave that month out, or repair the file (run 'minuta doctor').
export reportsamethe same sentence starting The report was not written:
records list --csvsamethe same sentence starting The CSV was not written:

Details:

  • With several months the tag sentence ends may be in them. and the export sentence reads the month files for 2026-03, 2026-04 could not be read, so their records would be missing. Narrow --from/--to to leave those months out, or repair the files (run 'minuta doctor').
  • tags delete is refused because the hidden records may use the tag: they would keep its id with no tag behind it, and they cannot be counted for the --force check.
  • import hey --resume is refused because rows already imported into the unreadable month are not visible and would be imported a second time. Without --resume the import runs.
  • The exports look only at month files (MM.automerge). An unreadable recovery file does not stop them; it is reported by the stderr warning. tags delete and import hey --resume count a month with an unreadable recovery file too, and name it with the same the month file for ... wording.
  • records list as a table or with --json is not refused: it prints what it can read, with the stderr warning.
  • stop is not refused either, but it cannot see a running timer inside an unreadable file, see minuta stop.

When the tags file is unreadable

There is no recovery file for tags, so tags cannot be read or changed until tags.automerge reads again. Commands split into two groups.

Refused, exit code 1, nothing written (error: Tags cannot be read or changed: tags.automerge is unreadable (...). The file was not changed.):

CommandWhy
every tags subcommandthey read or change tags
start --tag, records add --tag, records edit --tagthe tag has to be looked up or created first; no record is created or changed. This holds for a tag name that already exists too
export csv, records list --csvthe CSV would have an empty Tag column and nothing in it to say so
records list --tagsthe filter matches by tag name and would match nothing
export reportthe report groups time by tag and would show all of it as untagged
import hey when the CSV has at least one category, or with --resumethe categories could not be mapped to tags, and --resume compares by category. Checked before anything is written, --dry-run included

Still working, without tag names, with the warning above:

  • status, stop, start without --tag;
  • records list without --csv and --tags, records show;
  • records add and records edit without --tag (--clear-tag works), records delete;
  • records image add|list|remove|export;
  • import hey of a CSV with no categories, without --resume.

In these, a record’s tag is printed as if it had none ("tag": null in JSON). The record’s tagId on disk is not changed.

Default subcommands

Four command groups run a subcommand when called bare:

CommandRuns
minuta recordsminuta records list
minuta tagsminuta tags list
minuta conflictsminuta conflicts resolve
minuta storageminuta storage path

export, import and records image have no default and need an explicit subcommand.

Date and time values

Every date or datetime argument (start --at, records add|edit --start/--end, --from/--to) goes through one parser (Output/DateParser.swift) that accepts these shapes, tried in order:

FormatExample
yyyy-MM-dd'T'HH:mm:ss + UTC offset2026-04-22T14:30:00Z, 2026-04-22T14:30:00+02:00
yyyy-MM-dd'T'HH:mm:ss2026-04-22T14:30:00
yyyy-MM-dd HH:mm:ss2026-04-22 14:30:00
yyyy-MM-dd HH:mm2026-04-22 14:30
yyyy-MM-dd2026-04-22

Values without an offset are read in the process’s local time zone (TZ); a date-only value means midnight at the start of that day. Surrounding whitespace is trimmed. Anything else is a validation error (exit code 3).

For --to, a date-only value (exactly yyyy-MM-dd) covers that whole day; a value with a time is used as given.

Timer commands

minuta start

minuta start [--tag <name>] [--comment <text>] [--at <iso-datetime>]

Start a new timer. Unknown tag names are created with an auto-generated color (via getOrCreateTag). Prints the new record ID to stdout.

If the current month’s file is unreadable the timer is saved to that month’s recovery file and the command still exits 0, with the stderr warning. If the tags file is unreadable, start without --tag works and start --tag fails with exit code 1 and starts nothing; see When the tags file is unreadable.

minuta stop

minuta stop [--id <record-id>]

Stops running timers. Default behavior stops every running timer. Prints stopped IDs and durations.

--id takes a full UUID only. The 8-character prefix accepted by records show|edit|delete is not resolved here: anything that is not a complete UUID exits with code 3 (“Invalid record ID”), and a valid UUID that is not a running timer exits with code 1.

With nothing to stop, stop prints no running timers on stderr and exits 0. If a month file or a recovery file could not be read, the line is instead:

no running timers found; running timers in the month file for 2026-04, which could not be read, cannot be seen or stopped

The exit code is still 0. With --json neither line is printed; the output is the empty list of stopped records. Timers that were found are stopped as usual.

minuta status

Shows currently-running timers with live duration. One-shot; exits after printing.

Record commands

minuta records list

minuta records list [filters] [--json] [--csv]

Filters (shared with export csv):

  • --from <date> / --to <date> (inclusive; local time zone; formats in Date and time values). Either side may be omitted for an open-ended range.
  • --tags <name>... (one or more tag names after the flag, up to the next option; repeatable; names match case-insensitively). The flag is plural: singular --tag exists only on start, records add and records edit, where it sets the record’s tag.
  • --untagged
  • --running / --no-running
  • --today / --this-week / --this-month / --last-week / --last-month. A preset takes precedence: when one is given, --from/--to are not used.

JSON schema (stable):

{
  "records": [
    {
      "id": "3B13041F-E156-…",
      "startTime": "2026-04-22T04:00:00Z",
      "endTime": "2026-04-22T05:30:00Z",
      "duration": 5400,
      "running": false,
      "tag": {
        "id": "…",
        "name": "Coding",
        "color": "#AC5843",
        "archived": false
      },
      "comment": "wrote tests",
      "images": []
    }
  ]
}

Times are always UTC ISO 8601 in JSON. Display mode shows local time.

minuta records show <id>

Prints the full detail of a single record. ID can be a full UUID or the 8-char prefix shown by records list.

minuta records add

minuta records add --start <datetime> [--end <datetime>] [--tag <name>] [--comment <text>]

Creates a completed or running record at arbitrary times. --end omitted produces a running timer. Tag is auto-created.

minuta records edit <id>

minuta records edit <id> [--start <dt>] [--end <dt>] [--tag <name>] [--comment <text>]
                         [--clear-tag] [--clear-comment] [--clear-end]

Updates fields in place. --clear-end reopens a completed record as a running timer.

minuta records delete <id>

minuta records delete <id> [--force]

Delete a record and its images. Refuses to delete running timers without --force (stop them first).

Images

minuta records image add    <record-id> <path-to-image>
minuta records image list   <record-id>
minuta records image remove <record-id> <index>
minuta records image export <record-id> <index> <output-path>

Images get UUID-scheme filenames ({recordId}_{uuid}.{ext}) and are stored alongside the record’s month file.

Tag commands

minuta tags list [--archived] [--all]
minuta tags search <query>
minuta tags add <name>
minuta tags rename <name> <new-name>
minuta tags archive <name>
minuta tags unarchive <name>
minuta tags delete <name> [--force]

tags delete refuses to remove a tag that’s still referenced by records unless --force is set. Records keep their tagId field; the tag simply disappears and those records become untagged.

While any month file or recovery file is unreadable tags delete is refused, --force included, because the records that cannot be read may use the tag. See Refused while records are hidden.

Tag colors are not set by hand: the COLOR column of tags list and tags search (and color in --json) is computed from the whole tag list, active tags spread evenly in name order, as in the app (204-color-palette). Adding, renaming, archiving or deleting a tag changes the colors of the others.

Duplicate tags add (case-insensitive) exits 0, not an error — it prints the existing tag’s ID so idempotent scripts work cleanly.

JSON schema:

{
  "tags": [
    { "id": "…", "name": "Work", "color": "#8B5EA4", "archived": false }
  ]
}

Export

minuta export csv

minuta export csv [filters] [--layout minuta|toggl] [-o <path>]

Writes CSV to stdout, or to -o file. Shares filters with records list; records list --csv prints the same columns.

If the date range includes a month whose file could not be read, nothing is written and the command exits with code 1; the same holds for records list --csv and export report. See Refused while records are hidden.

The header is always, in this order:

Date,Start Time,End Time,Duration,Tag,Comment
2026-04-22,09:30,11:45,2:15,"Acme, Inc.",wrote tests

Every value is CSV-escaped, tag names as well as comments: a value containing a comma, a double quote or a line break is wrapped in double quotes and inner quotes are doubled. Date is yyyy-MM-dd, times are HH:mm in the local time zone, Duration is H:MM, and a running record has ongoing as its End Time. Rows are sorted by start time, oldest first. Format details: Export and Import Services.

--layout toggl writes Toggl Track’s time entries layout instead (ExportOptions.CSVLayout.toggl), as Toggl’s own export does: UTF-8 BOM, every field quoted, the 13 columns User,Email,Client,Project,Task,Description,Billable,Start date,Start time,End date,End time,Duration,Tags. Project is the record’s tag; Tags, Client, Task and Billable come from the metadata import toggl kept (otherwise empty, Billable No); User and Email are empty; dates yyyy-MM-dd, times and Duration HH:mm:ss. Running records are left out. A Toggl file imported and exported again matches the original except User, Email and the zero-length rows (exported one second long).

minuta export report

minuta export report [--format png|svg] [--from <date> --to <date> | --prev-month]
    [--tags <name>...] [--untagged] [--title <text>] [--with-images]
    [-o <path>] [--open]

Renders a time report. Defaults: PNG for the current month.

PNG rendering uses AppKit’s native NSImage SVG loader — the output is A4 landscape at 2x scale (1684×1190). SVG rendering is pure Swift and can be piped into other converters (rsvg-convert, inkscape) for PDF or higher-resolution output.

Date range:

  • Default — when neither --from/--to nor --prev-month is passed, the current calendar month is used. The report title auto-fills to “April 2026”.
  • --prev-month — previous calendar month. Mutually exclusive with --from/--to.
  • --from <date> --to <date> — explicit inclusive range. Both must be given together; passing only one, an unparseable value, or a --to earlier than --from exits with code 3.

Output:

  • -o <path> — write to the given path. Extension doesn’t matter; the format is determined by --format.
  • No -o, PNG format — writes to a temp file in $TMPDIR and prints the path to stdout.
  • No -o, SVG format — writes SVG directly to stdout (pipe-friendly).

Other flags:

  • --open — after writing, launch the result in Preview via open(1). If -o was omitted, the temp file is used. Works with both PNG and SVG.
  • --with-images — embed up to 8 record images in the SVG as data: URIs. Requires --format svg: with PNG (the default format, so also when --format is omitted) the command fails with a validation error, exit code 3 (“—with-images is only supported for —format svg”), and renders nothing.
  • --title — override the auto-generated heading.

Examples:

minuta export report                         # current month PNG → temp path
minuta export report --open                  # …and open it in Preview
minuta export report --prev-month --open     # previous month
minuta export report --format svg -o r.svg   # write an SVG
minuta export report --format svg | rsvg-convert -f pdf -o r.pdf

Import

minuta import hey <path-to-hey.csv> [--dry-run] [--resume]
minuta import toggl <path-to-toggl.csv> [--dry-run] [--resume]

hey parses HEY email time-tracking CSV (Start,End,Duration,Category,Notes with YYYY-MM-DD HH:mm timestamps). Creates new tags as needed, then creates records.

toggl parses a Toggl Track time entries CSV (TogglImportService). Columns are found by header name; Start date, Start time, End date and End time are required, otherwise the file is refused with exit 3. A leading UTF-8 BOM is fine.

  • Project becomes the tag (an empty project imports untagged); Description the comment; Duration is ignored and recomputed.
  • Every other column except User and Email is kept on the record as metadata, under toggl.<column lowercased> (toggl.tags, toggl.billable, toggl.client, unknown columns too), plus source = toggl. Empty values are left out. The Toggl Tags column does not create Minuta tags.
  • Times have no zone and are read in the local time zone.
  • A row whose end equals its start is imported one second long (Minuta rejects an empty range); a row ending before it starts or with an unreadable time is skipped. Both counts go to stderr (skipped: ..., zero length: ...) and into --json as skippedRows and zeroDurationRows.

Both formats:

  • --dry-run: parse and report, write nothing.
  • --resume: skip records already present with the same start/end/tag tuple (idempotent re-runs). Refused when a month file or recovery file inside the CSV’s time window could not be read, see Refused while records are hidden.

Tags are written first (so records can reference them by ID), then records. Progress goes to stderr every 100 records.

Conflicts

minuta conflicts resolve [--dry-run]

Scans the storage folder for sync-service conflict files:

  • Dropbox: name (conflicted copy 2026-04-22).automerge
  • Google Drive: name (1).automerge
  • iCloud numeric suffix: name 2.automerge

Each conflict is merged into the base document via Automerge’s CRDT merge (commutative). Resolved conflict files are deleted. Each group is merged under the storage write lock, and the base document is written before the merged copies are removed. A copy that cannot be read or merged is left on disk and reported as error: ... on stderr (in the errors array with --json); see ConflictResolutionService.

Two cases beyond the plain merge:

  • The base document cannot be read (corrupt, or zero bytes). The group fails: the base document is not changed, its copies are left in place, and the error names the file, for example error: 04.automerge cannot be read (Deserialization failed: the file is empty); it was not changed and its 2 conflict copy file(s) were left in place.

  • The base document is missing and only copies are on disk. The newest readable copy is renamed to the base name and the other copies are merged into it. This applies to any .automerge file in the folder whose name matches a conflict pattern.

  • --dry-run: print the groups that would be resolved; touch nothing.

Output. Without conflicts, stdout is no conflicts. Otherwise stdout lists each group, the base document’s path followed by its copies; a missing base document is marked:

/Users/me/Documents/Minuta/records/2026/04.automerge
  + 04 (conflicted copy 2026-04-22).automerge
/Users/me/Documents/Minuta/records/2026/05.automerge (missing, created from the newest copy)
  + 05 (1).automerge
  + 05 (2).automerge

The summary goes to stderr: resolved 3 file(s), followed by one error: ... line per error. The count is the number of copies merged plus one for each copy renamed to a missing base document. With --dry-run the summary is dry run: would resolve 3 file(s) in 2 group(s), counting every copy found; the (missing, created from the newest copy) marker is printed in a dry run as well, although nothing is created.

With --json:

{
  "dryRun": false,
  "errors": [],
  "groups": [
    {
      "conflicts": ["/Users/me/Documents/Minuta/records/2026/05 (1).automerge"],
      "primary": "/Users/me/Documents/Minuta/records/2026/05.automerge",
      "primaryExists": false
    }
  ],
  "mergedCount": 1
}

groups is the scan taken before resolving, so primaryExists says whether the base document was there at that moment. mergedCount is the resolved count described above, 0 in a dry run.

Runs automatically on app launch; the CLI surfaces it for headless/cron use.

Doctor

minuta doctor [--json]

Runs diagnostic checks:

  1. storage reachable — does the resolved path exist?
  2. writable — create and delete a sentinel file.
  3. tags file — does tags.automerge load?
  4. record files — load every month file; name the ones that cannot be read and count the rest.
  5. duplicate records — record ids present in more than one month document (see below).
  6. recovery files — MM.recovery.automerge files: holding records for an unreadable month, waiting to be merged, or unreadable themselves.
  7. image integrity — count image files on disk that no record references (orphans).
  8. sync conflicts — count (conflicted copy) files. Points at conflicts resolve.
  9. .minuta-version — present (PASS), absent (WARN: the first write creates it), newer than this CLI supports or not a version number (FAIL: writes are refused with exit code 5).
  10. writelock — LOCK_EX|LOCK_NB probe. If held, another process is writing.
  11. environment — effective TZ and Calendar.current.identifier, so cron/launchd users can diagnose day-boundary surprises.

Exit code 0 if no FAIL checks, 1 if any check is FAIL. WARN entries don’t fail the command but are surfaced on stderr (doctor: N warning(s), unless -q).

Unreadable files

An unreadable file does not stop the other checks: the rows after it are still produced. doctor changes nothing on disk, apart from its write probe. In the texts below (reason) stands for the read error, for example Deserialization failed: the file is empty for a zero-byte file.

tags file

StatusDetail
WARNtags.automerge not present (empty storage)
PASS4 tag(s)
FAILtags.automerge could not be read (reason). Nothing was changed. Tags cannot be shown or edited until the file can be read; if it is still syncing, wait.

record files

StatusDetail
WARNno records/ directory
PASS3 month file(s), 120 record(s)
FAIL1 of 3 month file(s) unreadable: 2026-04 (reason). 80 record(s) in the readable months. Nothing was changed. If a file is still syncing, wait for it to finish. If it is lost, move or delete it; its recovery file then takes its place.

Several unreadable months are listed as 2026-03 (reason), 2026-04 (reason). The record count covers the readable months only; records saved to a recovery file during the outage are reported by the recovery files row. When none of the unreadable months has a recovery file, the text ends with If it is lost, move or delete it.

recovery files

StatusDetail
PASSnone
WARN1 recovery file(s) holding the records saved while the month file is unreadable (2026-04): merged into the month file once it can be read.
WARN1 recovery file(s) waiting (2026-04): merged into the month file on the next write to that month.
FAIL1 recovery file(s) unreadable: 2026-04 (reason). Nothing was changed; the records in them are not shown.

A recovery file is “holding” while its month file is unreadable and “waiting” when the month file reads again (or is gone) and no write has consolidated it yet. When more than one of the three applies, the sentences are joined in one detail, in the order unreadable, holding, waiting; the row is FAIL if any recovery file is unreadable, WARN otherwise.

image integrity

StatusDetail
PASSno orphan image files
PASSno orphan image files; 2 image file(s) not checked (month unreadable)
WARN3 orphan image file(s)
WARN3 orphan image file(s); 2 image file(s) not checked (month unreadable)

Under a month whose month file or recovery file is unreadable the references are unknown, so an unreferenced image there is counted as not checked instead of orphan. Images attached to records in a readable recovery file count as referenced.

With -v (and without --json) each unreadable file is listed on stderr with its full path:

unreadable month file /Users/me/Documents/Minuta/records/2026/04.automerge: Deserialization failed: the file is empty
unreadable recovery file /Users/me/Documents/Minuta/records/2026/04.recovery.automerge: ...
unreadable tags file /Users/me/Documents/Minuta/tags.automerge: ...

With --json the top-level object gains an unreadable array next to checks, present only when there is at least one unreadable file, sorted by month with the tags file last:

{
  "checks": [ ... ],
  "unreadable": [
    {
      "hasRecoveryFile": true,
      "kind": "month",
      "month": "2026-04",
      "path": "/Users/me/Documents/Minuta/records/2026/04.automerge",
      "reason": "Deserialization failed: the file is empty",
      "transient": false
    }
  ]
}
FieldValue
kind"month", "monthRecovery" or "tags"
month"YYYY-MM", or null for the tags file
pathabsolute path of the file
reasonthe read error
transienttrue when the file could not be opened (permissions, I/O) rather than parsed
hasRecoveryFiletrue when the month has a recovery file on disk; always false for the tags file

What to do: if the file is still syncing, wait. If it is lost for good, move or delete it by hand; for a month file with a recovery file, the recovery file becomes the month’s file on the next write to that month.

Duplicate records

A record belongs to the month document of its start time. The same record id in two month documents is a leftover of an older build (which did not move a record when its start time changed month) or of a sync merge. The check always runs, also when a month file is unreadable (that month is then compared by what its recovery file holds), and reports:

  • PASS: no record in more than one month
  • WARN: N record(s) in more than one month. Edit and save a record to keep one copy.

It is a warning, so it does not change the exit code. doctor does not repair it: nothing says which copy is newer. Saving the record (in the app, or minuta records edit) writes it to the month of its start time and removes the other copies; see AutomergeStorageService.

With -v (and without --json) each affected record is listed on stderr, months sorted:

duplicate record 3B13041F-E156-4C0B-9A57-0E3D5C2F7A11: 2026-03, 2026-04

With --json the check object gains a duplicates array (present only when there are duplicates, sorted by id):

{
  "name": "duplicate records",
  "status": "WARN",
  "detail": "1 record(s) in more than one month. Edit and save a record to keep one copy.",
  "duplicates": [
    { "id": "3B13041F-E156-4C0B-9A57-0E3D5C2F7A11", "months": ["2026-03", "2026-04"] }
  ]
}

The recommended cron pattern is TZ=America/Los_Angeles minuta export csv --today — otherwise launchd’s default TZ=UTC will roll over at the wrong moment.

Storage

minuta storage path

Prints the resolved storage path to stdout and metadata (source, version marker, TZ, calendar) to stderr. --json flattens it into a single object.

Useful for debugging discovery mismatches between the app and CLI. The source field is one of "--storage flag", "MINUTA_STORAGE env var", "~/Library/Application Support/minuta/storage-path.txt", or "default (~/Documents/Minuta)" (the raw values of StorageResolver.Source).

Shell completion

swift-argument-parser generates completion scripts from the command tree. The CLI ships generated copies at scripts/cli-completions/minuta.{bash,zsh,fish}, regenerated by scripts/generate-cli-manuals.sh.

Install per shell:

# zsh (add to ~/.zshrc)
source /path/to/minuta/scripts/cli-completions/minuta.zsh

# bash (add to ~/.bashrc)
source /path/to/minuta/scripts/cli-completions/minuta.bash

# fish
cp /path/to/minuta/scripts/cli-completions/minuta.fish ~/.config/fish/completions/minuta.fish

Or regenerate on demand:

minuta --generate-completion-script zsh > /usr/local/share/zsh/site-functions/_minuta

Man pages

Man pages are generated from the same swift-argument-parser definitions via the GenerateManual plugin. Run:

./scripts/generate-cli-manuals.sh

This produces Shared/.build/plugins/GenerateManual/outputs/minuta/*.1 (one per subcommand, plus a top-level minuta.1). Install:

cp Shared/.build/plugins/GenerateManual/outputs/minuta/*.1 /usr/local/share/man/man1/
man minuta
man minuta-export-report

Homebrew installation (Phase 4) will handle man pages and completions automatically.

Automation

minuta is designed for cron/launchd/CI use. Key patterns:

Time zone pitfall. launchd and cron commonly run with TZ=UTC and a stripped locale. minuta export csv --today at 03:00 local under TZ=UTC returns yesterday’s window. Set TZ explicitly:

# ~/.config/launchd or crontab
TZ=America/Los_Angeles
0 18 * * *   /usr/local/bin/minuta export csv --today -o /tmp/today.csv

minuta storage path and minuta doctor both print the effective TimeZone.current.identifier so you can verify.

Daily report email.

TZ=America/Los_Angeles minuta export report --from $(date -v-1d +%Y-%m-%d) --to $(date -v-1d +%Y-%m-%d) -o /tmp/yesterday.svg

Pipeline contract. stdout is data only; -v and progress go to stderr. So this works:

minuta export csv --this-month | duckdb -c "SELECT Tag, SUM(Duration) FROM read_csv_auto('/dev/stdin') GROUP BY Tag"

Health check. In CI or nightly scripts:

minuta doctor --json | jq -e '.checks[] | select(.status == "FAIL") | .name'
# exits nonzero if any FAIL exists