A single-binary task tracker that keeps its state in a Logseq graph, so tasks stay queryable, linkable and editable in Logseq itself rather than being locked inside another tool's database.
go build -o logseq-cli .Point the CLI at your graph once:
logseq-cli config set graph ~/NotesThen bind a directory to a project. This is discovered by walking up from the
working directory, the way git finds .git, so it applies to subdirectories too:
cd ~/code/acme
logseq-cli init --project AcmeProject names may be namespaced. --project Acme/Clippy tracks its tasks on
[[Acme/Clippy/Tasks]], nested under Acme in the graph. A nested
project is fully independent of its parent: separate index page, separate task
pages, separate ID sequence. Since project lookup walks up from the working
directory and stops at the first .logseq-cli.toml, a subdirectory can carry
its own project while the directories above it keep theirs.
Configuration lives in two places:
| File | Holds |
|---|---|
~/.config/logseq-cli/config.toml |
graph, default_status, journal_tasks, journal_time |
<project>/.logseq-cli.toml |
project, tags |
LOGSEQ_CLI_CONFIG overrides the global config path, and LOGSEQ_CLI_LOCK_DIR
overrides where lock files are kept.
logseq-cli tasks # open tasks (TODO, DOING, LATER)
logseq-cli tasks list --all # including DONE and CANCELED
logseq-cli tasks list --search neh # title or body contains "neh"
logseq-cli tasks add "Title" # prints the new ID
logseq-cli tasks read 001
logseq-cli tasks edit 001 --title … --append … --add-tag …
logseq-cli tasks start|done|later|cancel|reopen 001
logseq-cli tasks rm 001
logseq-cli journal add "Text" # an entry on today's journal page
logseq-cli projects # every project in the graph, with counts
logseq-cli doctor # check the index against the task pages
logseq-cli skill --install # drop an agent guide into .claude/skills
--project is global, so any command can be pointed at a project other than
the one bound to the working directory — useful from a scratch directory, or
for a weekly sweep across everything:
logseq-cli --project Acme tasks list --all
logseq-cli --project Acme/Clippy doctorNote that a project reached this way has no project file behind it, so the
per-project default tags do not apply to tasks created through it.
tasks list --all-projects goes one further and lists every project's tasks in
one go, for a caller with no working directory to resolve a project from:
logseq-cli tasks list --all-projectsTask IDs restart at 001 in each project, so every task carries the project it
belongs to; in this listing that is what tells two 001s apart.
Every read command takes --json. --body and --append accept - to read
standard input, which is the intended way to write up a long issue:
logseq-cli tasks add "Fix the NEH importer" --body - <<'EOF'
- **Context**
- The importer assumes `updated_at` is non-null.
- **Repro**
- `make seed && go test ./importer`
EOFNote that an inline value beginning with - must use --body=…, since flag
parsing would otherwise read it as another flag.
A project's tasks are an index page plus one page per task. For project
Acme, pages/Acme___Tasks.md holds:
- DOING [[Acme/Tasks/001 Fix the NEH importer]]
tags:: #backend
created:: 2026-08-01
- LATER [[Acme/Tasks/002 Write the migration guide]]
created:: 2026-08-02
- DONE [[Acme/Tasks/003 Audit the query layer]]
created:: 2026-07-28
completed:: 2026-08-03
SCHEDULED: <2026-08-10 Mon>and pages/Acme___Tasks___001 Fix the NEH importer.md holds the body.
Page titles are namespaced, so Logseq shows the hierarchy Acme →
Acme/Tasks → each task, with working breadcrumbs and a namespace listing on
the index page. Files in pages/ are flat with ___ separators because that is
Logseq's own filename format.
The Logseq task marker lives only on the index entry, never duplicated as a
property on the task page. That keeps a single source of truth and means the
built-in journal queries and any TODO dashboard page pick these tasks up with
no extra configuration.
Task IDs are allocated as one past the highest currently in use, scanning both
the index and the pages directory so that a page left behind by
rm --keep-page does not have its number reused. Deleting a task outright can
free its ID for reuse; prefer cancel to keep the history.
created:: is stamped when a task is added, and completed:: when it first
moves into a closed status — DONE or CANCELED. Reopening a task clears
completed:: again; moving between two closed statuses keeps the date the task
was first closed, so a work log does not drift forwards.
Both are plain ISO dates, which sort as strings and read well in Logseq, and
both are ordinary block properties: {{query (property completed "2026-08-03")}}
works, as does anything else the graph already does with properties.
Tasks written before these properties existed, or by hand in Logseq, simply have no dates. Nothing backfills them, since a guessed date is worse than an absent one.
Anything the CLI does to a task is also recorded on the day's journal page, so the journal reads as a log of what actually happened:
- **08:15** Paired with Sam on the importer
- **09:42** Added task [[Acme/Tasks/001 Fix the NEH importer]] #Acme
- **11:03** Started task [[Acme/Tasks/001 Fix the NEH importer]] #Acme
- **16:38** Completed task [[Acme/Tasks/001 Fix the NEH importer]] #AcmeBoth the task and its project are linked, so the project page's linked
references become an activity stream without any extra configuration. A
namespaced project keeps its namespace in the tag — #Acme/Clippy — because a
slash continues a Logseq hashtag rather than ending it.
An entry is written when a task is added and whenever its status changes. Re-running a status verb on a task already in that status changes nothing and writes nothing. Deleting a task is not recorded, since the page the entry would link to is gone.
The task is saved first and journalled after, on its own lock. A journal that cannot be written — the page starts with Logseq-style page properties, say — produces a warning on stderr and leaves the command successful, because the task change it describes has already happened.
Set journal_tasks to false to keep the journal to what you write yourself,
or pass --no-journal to leave a single command out of it:
logseq-cli config set journal_tasks false
logseq-cli --no-journal tasks add "Not worth a line in the journal"journal add writes an entry directly, which is the useful shape for a
launcher, a shortcut or a shell alias:
logseq-cli journal add "Paired with Sam on the importer"
logseq-cli journal "Same thing, fewer keystrokes"
logseq-cli journal add "Wrote up the incident" --date 2026-08-01 --time 09:00Entries take the same input as a task body: an indented outline nests, and
plain prose becomes one block per paragraph. Piping on stdin avoids the flag
parsing problem that a leading - otherwise causes:
logseq-cli journal add - <<'EOF'
- Reviewed the migration plan
- Split the backfill out
- Ship the read path first
EOFEntries are timestamped and filed among the day's existing ones by time, so
they stay in order however they arrive — including entries written in Logseq
itself, and entries backdated with --time. journal_time chooses the format:
24h (the default), 12h, or off for no timestamps at all. Changing it does
not rewrite existing entries, and entries stamped in the old format sort as if
they had no timestamp, so it is worth settling on one.
logseq-cli doctor compares the index page against the task pages on disk and
reports what it cannot reconcile:
| Kind | Meaning |
|---|---|
missing_page |
the index links to a page that is not on disk |
orphan_page |
a task page with no index entry, as rm --keep-page leaves |
duplicate_id |
two index entries claiming one ID; commands resolve the first |
unwritable_page |
a page starting with page properties, which the CLI refuses to rewrite |
untracked_entry |
a block with a task marker that does not link to a task page |
It only reads, and exits zero whether or not it finds anything — a finding is not a failed command, and most of these have more than one sensible fix. Run it after editing tasks by hand in Logseq, or when a command reports something that does not match what you see in the graph.
raycast/ holds a personal Raycast extension built on this CLI: a no-view
command for capturing a journal entry, a searchable list of every project's
tasks, and a form for filing one. It shells out to the binary and parses
--json rather than touching the graph itself. See raycast/README.md.
Several invocations may run at once — from a shell, a script, or multiple agents — against the same graph:
- mutations take an exclusive advisory lock, keyed on the page they serialise on — the project's index page, or the day's journal page — and stored outside the graph so no lock files sync to your other machines;
- reads take a shared lock, since pages are not written atomically;
- ID allocation happens under the exclusive lock, so concurrent
addcalls always receive distinct IDs; - if Logseq or file sync changes a page underneath a mutation, the save is retried rather than clobbering the change.
The graph is opened without logseq-go's search index. Nothing here needs search, and skipping it avoids both a full walk of the graph on every invocation and the single-writer lock an on-disk index would impose on parallel runs.
- Pages with Logseq-style page properties cannot be rewritten. Given a file
beginning with bare
key:: valuelines, logseq-go re-parents every following top-level block underneath the properties block, collapsing the outline. The CLI therefore writes no page properties of its own, and refuses to write any page that starts with them rather than corrupting it. If you add page properties to a task page in Logseq,logseq-cliwill decline to touch that page until they are moved into a leading bullet. - Writes are not atomic. Pages are written with a plain truncate-and-write, so a reader without the shared lock — Logseq itself, for instance — could in principle observe a partial file.
SCHEDULED:is not modelled, so the CLI treats it as raw block text. It round-trips correctly, including across status changes.
go test ./...The end-to-end tests build the binary and run it against a throwaway graph in a temp directory, including cases that exercise a dozen concurrent processes. No test reads or writes a real graph or the real config file.