A small, self-contained read/discovery CLI over a Reflect graph (Plan 14). It
reads the graph's markdown files directly and opens .reflect/index.sqlite
strictly read-only — no running desktop app required. It never writes: markdown
edits are the write path, and the index is refreshed by the desktop app.
reflect today # print today's daily note
reflect today --path # its absolute path (works before the file exists)
reflect search <query> # ranked full-text search over the index
reflect show <note> # print a note by date, path, title, or alias
reflect path <note> # resolve a note to its absolute path
reflect open <note> # open a note in the app (reflect:// deep link)
Built from apps/cli (cargo build -p reflect-cli); bundled with the desktop
app as a Tauri sidecar (macOS: Reflect.app/Contents/MacOS/reflect, Linux
.deb: /usr/bin/reflect). For local development:
cargo install --path apps/cli.
First match wins:
--graph <path>— must contain a.reflect/directory.$REFLECT_GRAPH— same requirement.- The nearest ancestor of the current directory containing
.reflect/(git-style walk-up).
There is deliberately no fallback to the desktop app's recent-graphs config: the CLI stays deterministic for scripts and agents.
Notes with private: true frontmatter are invisible through the CLI — no
content, no paths, no search hits — and there is no flag that overrides this.
search filters them out; show/today/path print nothing to stdout,
explain on stderr, and exit 3. The check reads the resolved file's own
frontmatter (never just the index row), so a stale index can't leak a
just-flagged note.
- stdout carries only data (note content, paths, or JSON); all warnings and errors go to stderr.
--jsonemits the stable shapes below — they are the agent/scripting contract and are locked by tests (apps/cli/tests/cli.rs).
| Exit code | Meaning |
|---|---|
| 0 | success |
| 1 | runtime error (no graph, IO/SQL failure) |
| 2 | usage error |
| 3 | note not found, or note is private |
| 4 | search index missing or unusable (search only) |
Prints today's daily note (daily/YYYY-MM-DD.md, local timezone). File-only —
works with no index. A missing daily is exit 3; with --path the would-be
path is printed even before the file exists (dailies are created lazily, so
this is how editors/scripts create them).
Search over note titles and bodies, ranked like the app: exact, prefix, and
per-term title matches lead, followed by title-boosted bm25 matches. Title
and body terms match at word starts (car finds Car log, never Oscar party);
terms in scripts written without spaces (Japanese, Chinese, Korean, Thai, …)
match anywhere in the title, since FTS alone cannot see inside their
uninterrupted title runs. A partial query such as authent migr finds
authentication migration, and terms can match across the title and body; body
matches include snippets. Terms are matched literally (FTS5 operators in the
query have no special meaning); a term the tokenizer finds no word in, such as
a lone -, is ignored entirely. JSON results without a body match have an empty
snippet; title-prefix recall scores 0, while tokenizer-normalized title matches
retain their bm25 score. Requires the index: if .reflect/index.sqlite is missing
the exit code is 4 — open the graph in Reflect to build it; the CLI never runs the
indexer. If files on disk diverge from the index (checked by mtime, then content
hash), a staleness warning goes to stderr and "stale": true is set — results
still return.
// reflect search "meeting notes" --json
{
"query": "meeting notes",
"stale": false,
"results": [
{ "path": "notes/standup.md", "title": "Standup", "snippet": "…meeting notes…", "score": -1.94 }
]
}Resolves <note> and prints the raw markdown. Resolution order:
- A calendar-valid
YYYY-MM-DD→ that daily note. - An explicit path (graph-relative like
notes/foo.md, or absolute inside the graph). - A title match (case-insensitive, trimmed).
- An alias match (from
aliases:frontmatter, or a v1 subject-alias segment of a//title likeCharlotte MacCaw // Mum).
Works with or without the index — when the index is missing, titles/aliases are derived by scanning the files. Ambiguous matches resolve to the first path alphabetically and list the others on stderr.
// reflect show "Project X" --json ("date" is null for non-dailies)
{ "date": null, "path": "notes/project-x.md", "absolutePath": "…", "title": "Project X", "content": "…" }Same resolution, but prints only the absolute path — for piping into editors
and tools ($EDITOR "$(reflect path 'Project X')"). A YYYY-MM-DD argument
prints the would-be daily path even before the file exists.
// reflect path 2099-01-01 --json ("date" only appears for dailies)
{ "date": "2099-01-01", "path": "daily/2099-01-01.md", "absolutePath": "…", "exists": false }Same resolution, then navigates the Reflect app there by handing the OS URL
opener the note's reflect:// deep link (docs/deep-links.md).
The URL prefers the most durable address the note has: the date form for
dailies (which need not exist yet — navigation creates them lazily), the
frontmatter id form when the note carries one (it survives renames), else
the graph-relative path form. The CLI never writes, so it does not mint ids —
"Copy deep link" in the app does that.
The URL is always printed to stdout; --print skips launching the opener —
the scriptable half. Private notes are refused (exit 3) like every other
CLI surface, before their address leaks.
// reflect open "Project X" --json --print ("date" only appears for dailies)
{ "path": "notes/project-x.md", "url": "reflect://note/01hzy3…", "launched": false }The five commands plus --json are the supported automation surface (e.g.
~/.agents discovery workflows). The JSON field names and exit codes above
are stable; new fields may be added, existing ones won't change meaning.
Reading a private note is not possible through this surface by design — don't
work around it by reading graph files directly unless the user asked for that.
Settings → Agents installs a per-graph agent skill
(~/.agents/skills/reflect-<graph-slug>/SKILL.md) that teaches coding agents
this contract: the graph's root, the bundled CLI's path, the commands, and
the privacy rules. The file carries a reflect-managed sha256 marker so the
app can refresh its own installs without ever overwriting a hand-edited one
(apps/desktop/src-tauri/src/skill.rs).
- The CLI deliberately duplicates a thin read-side contract from
@reflect/core(path conventions, fold keys, frontmatter coercions, title derivation, SHA-256 hashing, FTS match syntax). Each Rust module names its TS counterpart, and the contract is pinned by the shared parity corpus infixtures/parity/: TS generatesexpected.jsonfrom the real core pipeline, the Rust tests assert against it, so neither side can change without the other following in the same PR. Don't grow the surface. - The sidecar is staged by
apps/desktop/scripts/build-sidecar.mjsintoapps/desktop/src-tauri/binaries/(gitignored), which Tauri'sbundle.externalBin(desktop platform overlay configs) picks up. tauri-build requires that file to exist before the desktop crate compiles —pnpm tauri dev/buildstage it automatically; before a barecargo build/test --workspace, runpnpm --filter @reflect/desktop sidecaronce.