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:
| Flag | Description |
|---|---|
--storage <path> | Override storage discovery. |
--json | Machine-readable JSON on stdout. |
-v, --verbose | Diagnostics 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, --quiet | Suppress 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):
--storage <path>flagMINUTA_STORAGEenvironment variable<realHome>/Library/Application Support/minuta/storage-path.txtwritten by the app~/Documents/Minutadefault
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 theDocumentsfolder 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,
-vdiagnostics.
This keeps pipelines like minuta export csv --today | sort clean even with -v.
Exit codes
| Code | Meaning |
|---|---|
| 0 | success |
| 1 | generic error (not found, unexpected failure) |
| 2 | storage unreachable — path resolution failed or target is inaccessible |
| 3 | validation error (bad flag, end before start, unparseable date) |
| 4 | reserved — concurrent-write retry limit (unreachable under current locking) |
| 5 | write 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'.:
| Unreadable | Sentence |
|---|---|
| one month file | 1 month file could not be read (2026-04); its records are not shown. |
| several month files | 2 month files could not be read (2026-03, 2026-04); their records are not shown. |
| one recovery file | 1 recovery file could not be read (2026-04); the records in it are not shown. |
| several recovery files | 2 recovery files could not be read (2026-03, 2026-04); the records in them are not shown. |
| the tags file | the 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 (
--jsonoutput stays valid). -qsuppresses 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.
statusandstopread 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.doctorreports 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).
| Command | Refused when | Message |
|---|---|---|
tags delete, with or without --force | any month file or recovery file is unreadable | Tag '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 included | a month file or recovery file is unreadable inside the window from the earliest start to the latest end of the CSV’s rows | Nothing 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 csv | the date range includes a month whose month file is unreadable | The 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 report | same | the same sentence starting The report was not written: |
records list --csv | same | the same sentence starting The CSV was not written: |
Details:
- With several months the tag sentence ends
may be in them.and the export sentence readsthe 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 deleteis 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--forcecheck.import hey --resumeis refused because rows already imported into the unreadable month are not visible and would be imported a second time. Without--resumethe 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 deleteandimport hey --resumecount a month with an unreadable recovery file too, and name it with the samethe month file for ...wording. records listas a table or with--jsonis not refused: it prints what it can read, with the stderr warning.stopis not refused either, but it cannot see a running timer inside an unreadable file, seeminuta 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.):
| Command | Why |
|---|---|
every tags subcommand | they read or change tags |
start --tag, records add --tag, records edit --tag | the 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 --csv | the CSV would have an empty Tag column and nothing in it to say so |
records list --tags | the filter matches by tag name and would match nothing |
export report | the 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 --resume | the 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,startwithout--tag;records listwithout--csvand--tags,records show;records addandrecords editwithout--tag(--clear-tagworks),records delete;records image add|list|remove|export;import heyof 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:
| Command | Runs |
|---|---|
minuta records | minuta records list |
minuta tags | minuta tags list |
minuta conflicts | minuta conflicts resolve |
minuta storage | minuta 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:
| Format | Example |
|---|---|
yyyy-MM-dd'T'HH:mm:ss + UTC offset | 2026-04-22T14:30:00Z, 2026-04-22T14:30:00+02:00 |
yyyy-MM-dd'T'HH:mm:ss | 2026-04-22T14:30:00 |
yyyy-MM-dd HH:mm:ss | 2026-04-22 14:30:00 |
yyyy-MM-dd HH:mm | 2026-04-22 14:30 |
yyyy-MM-dd | 2026-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--tagexists only onstart,records addandrecords 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/--toare 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/--tonor--prev-monthis 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--toearlier than--fromexits with code3.
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$TMPDIRand 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 viaopen(1). If-owas omitted, the temp file is used. Works with both PNG and SVG.--with-images— embed up to 8 record images in the SVG asdata:URIs. Requires--format svg: with PNG (the default format, so also when--formatis omitted) the command fails with a validation error, exit code3(“—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.
Projectbecomes the tag (an empty project imports untagged);Descriptionthe comment;Durationis ignored and recomputed.- Every other column except
UserandEmailis kept on the record as metadata, undertoggl.<column lowercased>(toggl.tags,toggl.billable,toggl.client, unknown columns too), plussource = toggl. Empty values are left out. The TogglTagscolumn 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--jsonasskippedRowsandzeroDurationRows.
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
.automergefile 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:
- storage reachable — does the resolved path exist?
- writable — create and delete a sentinel file.
- tags file — does
tags.automergeload? - record files — load every month file; name the ones that cannot be read and count the rest.
- duplicate records — record ids present in more than one month document (see below).
- recovery files —
MM.recovery.automergefiles: holding records for an unreadable month, waiting to be merged, or unreadable themselves. - image integrity — count image files on disk that no record references (orphans).
- sync conflicts — count
(conflicted copy)files. Points atconflicts resolve. - .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).
- writelock —
LOCK_EX|LOCK_NBprobe. If held, another process is writing. - environment — effective
TZandCalendar.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
| Status | Detail |
|---|---|
| WARN | tags.automerge not present (empty storage) |
| PASS | 4 tag(s) |
| FAIL | tags.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
| Status | Detail |
|---|---|
| WARN | no records/ directory |
| PASS | 3 month file(s), 120 record(s) |
| FAIL | 1 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
| Status | Detail |
|---|---|
| PASS | none |
| WARN | 1 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. |
| WARN | 1 recovery file(s) waiting (2026-04): merged into the month file on the next write to that month. |
| FAIL | 1 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
| Status | Detail |
|---|---|
| PASS | no orphan image files |
| PASS | no orphan image files; 2 image file(s) not checked (month unreadable) |
| WARN | 3 orphan image file(s) |
| WARN | 3 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
}
]
} | Field | Value |
|---|---|
kind | "month", "monthRecovery" or "tags" |
month | "YYYY-MM", or null for the tags file |
path | absolute path of the file |
reason | the read error |
transient | true when the file could not be opened (permissions, I/O) rather than parsed |
hasRecoveryFile | true 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 Related
- Cross-Process Safety — how the app and CLI share one storage folder.
- Automerge Storage Architecture — the on-disk format both tools read and write.