{"id":"b9562d33-9e80-4d39-ad02-5022940a7ee1","slug":"marcoarnulfo-notion-cli","kind":"skill","name":"notion-cli","summary":"A small, opinionated Go CLI to keep a Notion task-tracking database in sync from your terminal and CI — integration-token auth, idempotent upsert, CI-friendly JSON and exit codes.","owner":"marcoarnulfo","repoUrl":"https://github.com/marcoarnulfo/notion-cli","license":"MIT","licenseTier":"permissive","trust":"community","stars":3,"match":null,"fewStars":true,"format":"skill","createdAt":"2026-07-23T20:28:11.000Z","addedAt":"2026-10-21T23:32:09.788Z","logoUrl":"https://avatars.githubusercontent.com/u/103679419?u=db5e3c6042e142f689d6f207be41dc2c2c92a552&v=4","linkOnly":false,"collection":null,"skillPath":null,"categories":["productivity","files-documents","databases"],"tags":[],"runtime":"go","local":true,"cloudCapable":false,"cloudCapability":"unknown","cloudReadyOnPlatform":false,"needsApiKey":false,"reviewed":false,"syncedAt":"2026-10-26T03:42:14.918Z","sample":false,"url":"https://conduits.dev/tools/marcoarnulfo-notion-cli","language":"Go","transports":["stdio"],"install":{},"requirements":{"env":[],"upstreamMayCharge":false},"tools":null,"readme":"**English** · [Italiano](README.it.md)\n\n# notion-track\n\n[![CI](https://github.com/marcoarnulfo/notion-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/marcoarnulfo/notion-cli/actions/workflows/ci.yml)\n[![Latest release](https://img.shields.io/github/v/release/marcoarnulfo/notion-cli)](https://github.com/marcoarnulfo/notion-cli/releases)\n[![Go version](https://img.shields.io/github/go-mod/go-version/marcoarnulfo/notion-cli)](go.mod)\n[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)\n[![PRs welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](CONTRIBUTING.md)\n\n> A small, opinionated Go CLI to keep a Notion task-tracking database in sync from your terminal — and from CI. Free and open-source (MIT).\n\n`notion-track` knows about one thing: a Notion database where each row is a ticket, identified by a ticket key, with a status and a handful of other properties. Its core operation is an **idempotent upsert** — given a ticket key, find the row and update it, or create it if it doesn't exist. Run it twice, get one row.\n\nIt authenticates with a Notion **internal integration token** only — no browser OAuth. That token is a bot with its own, narrowly-shared permissions, which is what makes it work identically for workspace members and workspace guests alike, and what keeps it usable behind firewalls that block Notion's hosted MCP endpoint.\n\n## Features\n\n- **Idempotent upsert** (`upsert`) — create-or-update a ticket row by ticket key. Two runs, one row.\n- **Update-only write** (`set`) — fails with a distinct exit code if the ticket doesn't exist yet, instead of silently creating it.\n- **Read** (`get`, `list`) — one row or many, optionally filtered by status, assignee or priority, human-readable or `--json`; `get --body`/`--body-only` reads the page body back as Markdown too.\n- **Interactive browsing** (`notion-track` with no arguments, at a terminal) — a TUI over the tracked rows: filter by status, change a status inline, open a row in Notion, create one without leaving the view.\n- **Diagnostics** (`doctor`) — checks the token, data source access, the property mapping (including type drift since `init`), scans the whole data source for duplicate ticket keys, and warns if a git-tracked file looks like it carries your integration token.\n- **Guided setup** (`init`) — a bare `notion-track init` at a terminal opens a wizard that picks the data source and proposes the property mapping for you; the flag form writes the same profile non-interactively, validated against the data source's live schema before anything is saved. `init --list` discovers the data source ids your integration can see. At an interactive terminal it also offers to collect and save the integration token if none is found (see [Configuration](#configuration)).\n- **Profiles** — several named database configurations in one YAML config file, selectable by flag, environment variable, or a configured default.\n- **Bulk writes** (`apply`) — many upserts and sets from one JSON or CSV manifest, applied in order, stopping at the first failure.\n- **Dry run** (`--dry-run` on `upsert`/`set`) — reports whether it would create or update, which row and which columns, and writes nothing.\n- **Non-destructive append** (`--append-file` on `upsert`/`set`) — adds Markdown to the end of a page body without touching what is already there, as an alternative to `--body-file`'s replace.\n- **`--json` everywhere** — every command that produces output (`get`, `list`, `doctor`, `upsert`, `set`) can emit machine-readable JSON with a documented, stable shape.\n- **CI-friendly by design** — quiet on success, a distinct exit code per failure class (auth, not found, duplicate, usage, generic), no interactive prompts.\n- **Retries with backoff** on Notion's rate limiting (429) and transient 502/503/504/529 responses, honoring `Retry-After` when Notion sends one.\n- **A single static Go binary** — no Node runtime, no Python venv.\n\n## Requirements\n\n- **[Go](https://go.dev/dl/) 1.26 or newer** — needed to build or install from source. Not needed once a release is published: tagged releases ship prebuilt binaries (see [Installation](#installation)).\n- A **Notion internal integration token** (`ntn_...`), created by a **Workspace Owner** at <https://www.notion.so/my-integrations>.\n- A Notion database **shared with that integration**.\n\n## Installation\n\n`go install` is how the team installs `notion-track` — and how it updates: rerunning the exact same command always fetches the newest release.\n\n```bash\ngo install github.com/marcoarnulfo/notion-cli/cmd/notion-track@latest\n```\n\nIt needs a Go toolchain (see [Requirements](#requirements) above) and puts the binary at `$(go env GOPATH)/bin/notion-track` — make sure that directory is on your `PATH`. Pin an exact release instead of `@latest` — e.g. `@v0.6.0` — when you need to reproduce something against a known version rather than whatever is newest. Either way, `notion-track --version` reports the version you actually have installed, which is what makes \"what version do you have?\" a useful question to ask.\n\n<details>\n<summary>Build from source</summary>\n\n```bash\ngit clone https://github.com/marcoarnulfo/notion-cli.git\ncd notion-cli\ngo build -o notion-track ./cmd/notion-track\n./notion-track --help\n```\n</details>\n\n<details>\n<summary>Prebuilt binaries</summary>\n\nEvery tagged release publishes static binaries for macOS, Linux and Windows (amd64 and arm64) on the [releases page](https://github.com/marcoarnulfo/notion-cli/releases), with a `checksums.txt` to verify them against:\n\n```bash\ntag=v0.6.0            # pick the release you want\nos=linux arch=amd64   # or darwin/arm64\narchive=\"notion-track_${tag#v}_${os}_${arch}.tar.gz\"\n\ngh release download \"$tag\" --repo marcoarnulfo/notion-cli \\\n  --pattern \"$archive\" --pattern checksums.txt\ngrep -E \"  ${archive}\\$\" checksums.txt > expected.txt   # `sha256sum -c --ignore-missing` on the raw file can exit 0 without checking anything if the archive name matches no line — this makes a missing line a hard error instead\nsha256sum --check expected.txt   # or: shasum -a 256 --check expected.txt\ntar -xzf \"$archive\" notion-track\n```\n\nWindows ships as a `.zip` of the same name — swap the pattern and unzip it instead.\n\nThe binaries carry no cgo, so they run on any image with or without a libc. `notion-track --version` reports the release tag on a prebuilt binary or through `go install`, and on a local build either a commit-derived pseudo-version or `dev`, depending on the checkout it came from.\n\nmacOS Gatekeeper: a binary downloaded through a browser carries the `com.apple.quarantine` attribute and gets blocked on first run (\"cannot verify the developer\"); downloading it with `gh` or `curl`, as above, never sets that attribute, so it just runs.\n\nNote: `go install` above needs no release at all — it builds straight from source through the Go module proxy — so it always works, regardless of whether this repository has a tagged release yet.\n</details>\n\n## Quick start\n\n1. **Create the integration.** A Workspace Owner goes to <https://www.notion.so/my-integrations>, creates a new **internal** integration, and copies its token (`ntn_...`). Only a Workspace Owner can do this step.\n2. **Share the database with it.** Still as a Workspace Owner, open the tracking database in Notion → **•••** (top right) → **Connections** → add the integration. Without this step every request `notion-track` makes will 404, token or no token.\n3. **Give `notion-track` the token.** Either export it yourself:\n   ```bash\n   export NOTION_TOKEN=ntn_...\n   ```\n   or skip this step and let `init` (step 5) ask for it interactively — at a real terminal it prompts without echoing the token, and offers to save it to `credentials.yml` so you don't have to export it again next session. See [Configuration](#configuration) for where that file lives and how it differs from `config.yml`.\n4. **Find the data source id.** A database can hold more than one data source, so ask the integration what it can see:\n   ```bash\n   notion-track init --list\n   ```\n5. **Configure a profile**, mapping notion-track's concepts onto your database's real property names — `init` validates every property against the live schema before writing anything:\n   ```bash\n   notion-track init \\\n     --data-source-id <id> \\\n     --ticket-prop Ticket \\\n     --status-prop Status \\\n     --title-prop Name\n   ```\n6. **Sanity-check the setup:**\n   ```bash\n   notion-track doctor\n   ```\n7. **Create or update a row** — this is the command you'll actually run day to day:\n   ```bash\n   notion-track upsert --ticket TASK-231 --title \"Hardening\" --status \"In progress\"\n   notion-track upsert --ticket TASK-231 --status \"Done\"  # updates the same row, no duplicate\n   ```\n8. **(Optional) Track who owns each row.** Map a `select` column with `--assignee-prop`, and save your own identity alongside it with `--me` (see [Usage](#usage) below), and `me` works everywhere `--assignee` is accepted:\n   ```bash\n   notion-track init --data-source-id <id> --ticket-prop Ticket --status-prop Status --title-prop Name \\\n     --assignee-prop Referente --me \"Jordan Lee\"\n\n   notion-track set --ticket TASK-231 --status \"In progress\" --assignee me\n   notion-track list --assignee me --status \"To do\"\n   notion-track list --unassigned\n   ```\n9. **(Optional) Track how urgent each row is.** Map a `select` column with `--priority-prop` (see [Usage](#usage) below); there's no identity to configure for this one, so it's ready to use as soon as it's mapped:\n   ```bash\n   notion-track list --priority ALTA --status \"To do\"\n   notion-track list --priority ALTA --assignee me\n   notion-track set --ticket TASK-1 --priority alta --assignee sam\n   ```\n\n## Usage\n\nGlobal flags, available on every command:\n\n| Flag | Meaning |\n|---|---|\n| `--profile string` | config profile to use (see [Configuration](#configuration)) |\n| `--config string` | path to an explicit config file, instead of the default OS location |\n\n### `notion-track` — the browsing TUI\n\n```\nnotion-track\n```\n\nWith no arguments at a terminal, `notion-track` opens an interactive view over the tracked rows: one line each, showing the ticket key, the title, the status and the due date. `enter` opens a full-screen detail; `s` moves the selected row to another status, picked from the values the schema actually accepts; `f` narrows the list to one status; `n` creates a row without leaving the view; `o` opens it in Notion, `y` copies its URL, `r` reloads, `/` filters by text, `q` quits.\n\nIt is a view over the same `internal/service` layer every command uses — no separate logic, and nothing it can do that the flags cannot.\n\nCreating a row while a status filter is active gives it that status, so the new row lands in the view you are looking at. A write that fails leaves the list on screen and reports the reason in one line, rather than tearing the UI down: the rows are still readable and still correct.\n\nWithout a terminal — piped, redirected, in CI — `notion-track` with no arguments prints help and exits, exactly as before.\n\n### `init` — configure a profile\n\nTwo forms. At a terminal, with nothing else on the command line:\n\n```\nnotion-track init\n```\n\nopens a wizard: it picks up your token (asking for it only if there isn't one yet), lists the data sources shared with your integration, and lets you choose one with the arrow keys. It then proposes a property mapping, guessed from your column names and types, for you to confirm or change — each role offering only columns it can actually use, so a mapping that would break on first use cannot be chosen. `enter` saves; `esc` or `Ctrl-C` cancels, writing nothing and exiting non-zero so a script can tell the two apart. `--profile` and `--config` work here too: they say where the profile goes, not what is in it.\n\nThe wizard needs a terminal *and* an otherwise-bare command line. Passing any configuring flag, or running without a TTY — CI, a pipe, an agent — takes the explicit form below, unchanged:\n\n```\nnotion-track init --data-source-id <id> --ticket-prop <name> --status-prop <name> --title-prop <name> [--due-prop <name>] [--assignee-prop <name>] [--priority-prop <name>] [--id-prop <name>] [--me <value>] [--database-id <id>] [--list]\n```\n\n| Flag | Meaning |\n|---|---|\n| `--data-source-id string` | data source id (required, unless `--list`) |\n| `--ticket-prop string` | property holding the ticket key — must be `rich_text` or `title` (required) |\n| `--status-prop string` | property holding the status — must be `status` or `select` (required) |\n| `--title-prop string` | title property (required) |\n| `--due-prop string` | date property (optional) |\n| `--assignee-prop string` | `select` property naming who owns the row (optional) |\n| `--priority-prop string` | `select` property ranking how urgent the row is (optional) |\n| `--id-prop string` | `unique_id` property holding the row's board id, e.g. `TASK-271` (optional) |\n| `--me string` | the value `--assignee me` resolves to; resolved and validated against the assignee column's options before being saved (optional — needs `--assignee-prop` as part of a full `init`, and nothing else at all when passed on its own, see below) |\n| `--database-id string` | database id, recorded for reference only — every read/write is keyed off `--data-source-id`, not this |\n| `--list` | list the data source ids shared with the integration, and exit |\n\nEach mapped property is checked against the data source's live schema; `init` refuses to write a profile that would break on first use (wrong type, or a property that doesn't exist). `--ticket-prop`, `--status-prop`, and `--title-prop` are required in practice — `init` returns a usage error naming which one is missing — even though `--due-prop`, `--assignee-prop` and `--priority-prop` are optional. The profile is written under the name given by `--profile` (default `\"default\"`); if this is the first profile in the file it also becomes `default_profile`. Running `init` again with the same `--profile` name overwrites that profile without touching the others.\n\n`--assignee-prop` behaves like `--due-prop`: a board that tracks nobody in particular simply leaves it unmapped, and every command behaves exactly as it did before this feature. `--me` resolves its value against `--assignee-prop`'s options the same way `--assignee me` does, so a typo can't reach the file, and saves the canonical name — to `credentials.yml`, not `config.yml`, since the identity is personal and `config.yml` is meant to be committed and shared (see [Environment variables](#environment-variables)).\n\n**Setting only the identity.** `--me` on its own is a command of its own:\n\n```\nnotion-track init --me \"Jordan Lee\"\n```\n\nIt configures no profile and writes nothing to `config.yml`. It reads the profile you are already using, resolves the name against the assignee column that profile maps, and saves the canonical spelling to `credentials.yml` under that profile's name — printing which profile it was saved for. This is the form every \"run `notion-track init --me <name>`\" message in the tool points at: `doctor`'s warning about an identity still living in `config.yml`, and the error `--assignee me` gives when nothing says who you are. The profile is the resolved one (`--profile` → `NOTION_TRACK_PROFILE` → `default_profile`), i.e. the same one every other command reads the identity back under — whereas a full `init`, which *creates* a profile, files the identity under the profile it just wrote. It's a usage error if no profile is configured yet, or if the one in use maps no assignee column.\n\n`--priority-prop` behaves like `--due-prop` too: a board with no notion of urgency simply leaves it unmapped, and every command behaves exactly as it did before this feature. Unlike `--assignee-prop`, there is no `--priority-me` equivalent — a priority belongs to no one, so there is no identity to resolve it against.\n\n`--id-prop` maps Notion's own row identifier — a `unique_id` column, the kind that renders as `TASK-271` on the board — so rows can be addressed by that short id instead of by ticket key or page id (see `--id` under `get` and `set` below). It behaves like `--due-prop`: a board with no such column simply leaves it unmapped, and `--id` is then unavailable — the row is still reachable the other two ways. `init` requires the mapped property to actually be `unique_id`, the same way `--ticket-prop` requires `rich_text` or `title`.\n\n**Token prompt.** If no token is found in `NOTION_TOKEN` or `credentials.yml`, `init` behaves differently depending on how it's run:\n\n- **At an interactive terminal**, it asks for the token (input isn't echoed to the screen) and offers to save it to `credentials.yml` — bare Enter accepts the recommended default. Decline and it prints the `export NOTION_TOKEN=...` line to run for the current session instead, without ever printing the token itself: a child process has no way to modify its parent shell's environment, so this is the closest it can get to doing that for you.\n- **Non-interactively** — CI, a pipe, a script, an agent — it never prompts. Same as every other command: exit code 5 and a message pointing at `NOTION_TOKEN`.\n\n### `upsert` — create or update a row by ticket key\n\n```\nnotion-track upsert --ticket <key> [--title <title>] [--status <status>] [--due YYYY-MM-DD] [--assignee <value>] [--unassign] [--priority <value>] [--json]\n```\n\nThe flagship command. Queries the data source for the row whose ticket property equals `--ticket`: updates it if found, creates it otherwise. `0` matches → create, `1` match → update, `>1` matches → fails with exit code 4 (see [Limitations](#limitations)). Silent on success; with `--json` it prints `{\"action\": \"created\"|\"updated\", \"page\": {...}}`.\n\n### `set` — update an existing row only\n\n```\nnotion-track set (--ticket <key> | --id <board-id> | --page-id <id>) [--title <title>] [--status <status>] [--due YYYY-MM-DD] [--assignee <value>] [--unassign] [--priority <value>] [--json]\n```\n\nSame fields as `upsert`, but fails with exit code 3 if the row doesn't exist yet, instead of creating it. Use this where a missing row is a symptom worth surfacing rather than something to paper over.\n\n```bash\nnotion-track set --id TASK-271 --status \"Done\"\n```\n\n`--ticket`, `--id` and `--page-id` are mutually exclusive and exactly one is required. `--page-id` addresses a row directly by its Notion page id — no query by ticket key at all — which is faster and unambiguous when you already have it (e.g. from a prior `--json` call's `page_id`, see [JSON output](#json-output)). It accepts the full page URL you'd copy out of Notion's browser address bar, a bare 32-character hex id, or a dashed UUID; any other input fails immediately with exit code 2, before any request is made. Because `GET`ting a page by id works for anything shared with the integration — not just rows of the configured data source — a page id that resolves to a *different* data source than the active profile is rejected with exit code 2 rather than left to fail later with a confusing property-name error from Notion. `set --page-id` also rejects, with the same exit code, a page whose parent carries no data source at all — its membership can never be confirmed, and a write must not proceed on a page that cannot prove it belongs to this profile.\n\n`--id` addresses a row by its **board id** — the short identifier Notion shows on the row and the one people read aloud (`TASK-271`, or the bare number `271` on its own) — resolved with a query against the mapped `unique_id` column, the same way `--ticket` resolves against the ticket property; Notion's API filters on `unique_id` natively, so this needs no client-side scan. It needs a `unique_id` property mapped first (`init --id-prop`, see `init` above); without one mapped, `--id` fails with exit code 2 — the same class of mistake as running before `notion-track init` — naming the fix. An empty `--id` fails the same way, before any request is made. A malformed `--id` — the wrong prefix, or not a number at all — is exit code 2 too, but not that early: telling a bad prefix from a good one needs the data source's schema first, so a malformed `--id` costs one request before it fails, short of the row query itself.\n\n### `--assignee` / `--unassign` — set or clear who owns a row\n\n```bash\nnotion-track set --ticket TASK-231 --assignee \"Sam Rivera\"\nnotion-track set --ticket TASK-231 --assignee sam     # a partial name is enough when it's unambiguous\nnotion-track set --ticket TASK-231 --assignee me       # resolves to your configured identity — see below\nnotion-track set --ticket TASK-231 --unassign           # clears the column\n```\n\nAvailable on `upsert` and `set`. `--assignee` resolves what you type against the mapped column's options, trying an exact match, then an exact case-insensitive match, then a case-insensitive substring match, and stopping at whichever pass finds exactly one candidate — so `sam` reaches Notion as `Sam Rivera`. Zero matches and more than one are both usage errors (exit code 2): the first names the values the column actually offers, the second names which ones matched and asks for more of the name.\n\n`me` is a reserved value: before resolution runs, it is replaced by the configured identity — `NOTION_TRACK_ME` if set, otherwise the value `notion-track init --me <name>` (or the wizard) saved to `credentials.yml`, otherwise the profile's legacy `me:` field (see [Environment variables](#environment-variables) for the full precedence). `NOTION_TRACK_ME=jordan` therefore overrides whatever is on file, which is what CI and one-off runs want. Using `me` with no identity configured anywhere is a usage error naming the fix.\n\nNot passing `--assignee` at all leaves the column untouched — the same \"empty means leave it alone\" rule every other field follows. `--assignee \"\"` is therefore a usage error, not a way to clear the column; use `--unassign` for that. `--assignee` and `--unassign` are mutually exclusive, and a select column holds one value, so `--assignee` cannot be repeated.\n\nIf the role isn't mapped, passing `--assignee` or `--unassign` fails the same way any other unmapped role does — exit code 1, not 2, see [Exit codes](#exit-codes) — with a message pointing at `init --assignee-prop`.\n\n### `--priority` — how urgent a row is\n\n```bash\nnotion-track set --ticket TASK-231 --priority ALTA\nnotion-track set --ticket TASK-231 --priority alta   # a partial value is enough when it's unambiguous\nnotion-track list --priority ALTA\n```\n\nAvailable on `upsert` and `set` to write it, and on `list` to filter by it. `--priority` resolves what you type against the mapped column's options the same way `--assignee` does: an exact match, then an exact case-insensitive match, then a case-insensitive substring match, stopping at whichever pass finds exactly one candidate — so `alta` reaches Notion as `ALTA`. Zero matches and more than one are both usage errors (exit code 2), naming the values the column actually offers or which ones matched, exactly like `--assignee`.\n\nNot passing `--priority` at all leaves the column untouched — the same \"empty means leave it alone\" rule every other field follows.\n\nIf the role isn't mapped, passing `--priority` fails the same way any other unmapped role does — exit code 1, not 2, see [Exit codes](#exit-codes) — with a message pointing at `init --priority-prop`.\n\n**What it does not have, unlike `--assignee`:** there is no `--unpriority` flag — nothing in this tool can clear a priority once set; that has to be done in Notion. There is no `list --unprioritized` to find rows with none, the way `--unassigned` does for the assignee. And there is no `me`-like reserved value: a priority belongs to no one, so there is no identity to resolve.\n\n### `--body-file` — write the page body from Markdown\n\n```\nnotion-track upsert --ticket <key> --body-file notes.md\nnotion-track set --page-id <id> --body-file -\n```\n\nAvailable on both `upsert` and `set`. `--body-file` takes a path to a Markdown file, or `-` to read from stdin; its content becomes the row's Notion page body, converted to native blocks. Properties (`--title`, `--status`, `--due`) and the body are independent — pass both, either, or neither.\n\n**Replace semantics.** `--body-file` **owns the page body**: every run makes the body equal to the file's content, deleting whatever blocks were already there — including anything added by hand in Notion since the last run. Running it twice on the same file yields the same body, not a duplicate. There is no undo, so treat the file as the single source of truth for that page — but there is a way to see what a run would replace before running it: `get --body` (see [Reading the page body](#reading-the-page-body) below) prints the current body without opening Notion. If replacing everything is more than you want, `--append-file` (see below) adds to the body instead of replacing it. Sub-pages and child databases sitting at the top level of the body are never touched — they're skipped rather than archived, and a warning on stderr names each one that was kept. That skip only reaches the body's direct children: a sub-page nested inside a toggle, a column or a callout goes when the block containing it goes.\n\n**Supported Markdown.** Headings (`#`/`##`/`###`, deeper levels flatten to h3), paragraphs, bulleted and numbered lists, task checkboxes (`- [ ]` / `- [x]`), fenced and indented code blocks, blockquotes, `---` dividers, and inline **bold**, *italic*, `code`, ~~strikethrough~~, and links. List and quote nesting is supported to 2 levels. Tables, images, raw HTML, and nesting past 2 levels aren't dropped — each **degrades** to the closest supported block (a table becomes a plain-text code block, an image becomes a link, deeper nesting is promoted up a level) and prints a warning to stderr naming what happened, so nothing silently disappears but nothing blocks the write either. A file over 1 MiB is rejected before any request is made (exit code 2).\n\n**Cost.** There's no bulk-delete endpoint in Notion's API, so replacing a body is `O(n)` in the number of blocks already on the page: append the new content, then delete the old blocks one by one. A page with a lot of existing content takes correspondingly longer, and `notion-track` prints progress lines to stderr (blocks appended, blocks deleted so far) so a long run doesn't look hung.\n\n**Placeholders (`--expand`).** With `--expand`, `{{ticket}}` and `{{date}}` in the body file are substituted before the file is parsed — `{{date}}` being today, as `YYYY-MM-DD`. Whitespace inside the braces is fine (`{{ ticket }}`).\n\n```bash\nnotion-track upsert --ticket TASK-231 --body-file release-notes.md --expand\n```\n\nA placeholder nothing can fill in is a usage error naming the line, rather than a body reaching Notion with a literal `{{tikcet}}` in it that nobody notices until they read the page. Expansion is off by default and there is no escape syntax: a body that legitimately contains braces — a document about templating, a snippet of Handlebars — simply does not pass the flag. Addressing a row with `--page-id` or `--id` leaves `{{ticket}}` empty, since no ticket key was given.\n\n**Concurrency.** Two `--body-file` runs against the same page racing each other can both append before either deletes, leaving the body duplicated — there's no lock to take on a Notion page. Don't run concurrent body writes against one page.\n\nWith `--json`, a successful write adds a `body` object: `{\"blocks_written\": N, \"blocks_deleted\": N}`. If the properties write succeeds but the body replace fails partway, the command still exits 1 (not 0), and `--json` prints `body: {\"written\": false, \"error\": \"...\", \"blocks_written\": N, \"blocks_deleted\": N}` — `written` tells you the body is *not* in the state the file describes, while `page` in the same output still reflects whatever properties were applied, since those are two separate Notion API calls and the first can succeed even if the second doesn't.\n\n### `--append-file` — add to the page body without replacing it\n\n```\nnotion-track upsert --ticket <key> --append-file notes.md\nnotion-track set --page-id <id> --append-file -\n```\n\nAvailable on both `upsert` and `set`, `--append-file` takes a path to a Markdown file, or `-` for stdin, same as `--body-file`. `--expand` works on it exactly as it does on `--body-file`.\n\n**The size limit is measured on the request, not on the file.** `--body-file` is parsed into blocks and sent as several batched requests, so its 1 MiB cap is a total. An append is sent verbatim as a single `insert_content` payload, and [Notion caps one payload at 500 KB](https://developers.notion.com/reference/request-limits) — so what counts is the size of the serialized request, which is always larger than the file it came from: Markdown is escaped into a JSON string, so every newline costs two bytes, and `--expand` may have grown the text before that. A 450 KB changelog of short lines can serialize past 500 KB.\n\n`notion-track` therefore builds the payload and measures it, after expansion, before making any request. Over the limit is exit code 2 with the request size named; an append can't be split, so send it in two runs or use `--body-file`.\n\n**Append semantics, not replace.** `--append-file` adds the file's content to the **end** of the page body and deletes nothing — the opposite of `--body-file`'s \"make the body equal to this file\" rule. That makes it the right choice for a status update, a changelog entry or a comment that should accumulate rather than overwrite whatever a person added by hand in Notion. `--body-file` and `--append-file` are mutually exclusive: a single run picks one page-body strategy or the other. An empty file is a usage error (exit code 2), the same as an empty `--body-file` — not a silent no-op, so a broken build step that produces an empty file fails loudly instead of doing nothing and reporting success.\n\n**Not idempotent.** Unlike `--body-file`, running `--append-file` twice with the same content appends it twice — there is no \"already there\" check. Wire it into a retry loop with that in mind.\n\n**Ambiguous failures do not retry themselves.** An append makes exactly one API call, so there is no partially-applied state to converge the way a failed replace has. But if that call itself fails ambiguously (a transport error, a 502/504), `notion-track` cannot tell whether Notion applied it before failing. In that case it exits with the outcome reported as unknown and says so — it does **not** retry automatically, because retrying an append that did land duplicates the content. Check the page (`get --body` is one way) before re-running.\n\nWith `--json`, a successful append adds `body: {\"appended\": true}` — a different shape from `--body-file`'s `blocks_written`/`blocks_deleted`, since an append either landed or it did not; there is no per-block count to report.\n\nOn failure `--json` reports `body: {\"appended\": false}`, and on the ambiguous path above it adds `\"ambiguous\": true`. Branch on that key rather than on `appended` alone: `appended: false` covers both \"Notion refused it, nothing changed\" and \"the outcome is unknown\", and those want opposite responses — the first is safe to re-run, the second is the one that duplicates.\n\n### `get` — read one row\n\n```\nnotion-track get (--ticket <key> | --id <board-id> | --page-id <id>) [--json]\n```\n\n```bash\n# by exact title (or ticket key)\nnotion-track get --ticket \"Sistemare visualizzazione da telefono\"\n\n# by board id, the one people say out loud\nnotion-track get --id TASK-271\n\n# by Notion page id or URL, stable across renames\nnotion-track get --page-id https://notion.so/...\n```\n\nPrints the row's board id (when mapped), ticket, title, status and URL. `--ticket`, `--id` and `--page-id` are mutually exclusive and exactly one is required — see `set` above for what `--id` and `--page-id` accept and how they're validated. Fails with exit code 3 if not found (Notion's 404 doesn't distinguish \"no such page\" from \"never shared with this integration\" — the error message says so), 4 if a ticket key matches more than one row (see [Limitations](#limitations)), or 2 for a malformed `--id` or `--page-id`, an id role that isn't mapped, or a page id outside the active profile's data source. Unlike `set`, `get --page-id` accepts a page whose parent carries no data source at all — a read cannot do any harm with an unconfirmed page the way a write could.\n\n### Reading the page body\n\n```\nnotion-track get --ticket <key> --body\nnotion-track get --page-id <id> --body-only > notes.md\n```\n\n`--body` and `--body-only` add the page body, as Markdown, to what `get` prints — and they work with all three addressing forms, `--ticket`, `--id` or `--page-id`. `--body` prints the row as usual, then the body underneath; `--body-only` prints **only** the Markdown, nothing else, so `notion-track get --ticket TASK-231 --body-only > notes.md` produces a valid Markdown file rather than one with a row summary glued to the top of it. `--body` and `--body-only` are mutually exclusive.\n\nWith `--json`, `--body` nests the existing row shape under `page` and adds the body under `body`: `{\"page\": {...}, \"body\": {\"markdown\": \"...\", \"truncated\": false, \"unknown_block_ids\": []}}`. Without `--body`, the JSON is unchanged from before this feature — flat, no `page`/`body` wrapper — so existing scripts parsing `get --json` keep working untouched. `--body-only --json` prints the `body` object alone, unwrapped, matching what `--body-only` does for the human-readable form: the body and nothing else.\n\n**`truncated` and `unknown_block_ids`.** Notion truncates a page body around 20,000 blocks; `truncated: true` means the Markdown is real but incomplete, not that something went wrong. `unknown_block_ids` lists blocks Notion cannot render as Markdown at all — a bookmark, an embed, a link preview, a breadcrumb, a template button — which show up in the Markdown as `<unknown/>`. Both cases print a warning to stderr (never stdout, so `--body-only > notes.md` stays clean). Neither means content was lost: an `<unknown/>` block is still on the page, exactly as it was; only the text export of it doesn't exist.\n\n**Reading is for inspection, not a round trip.** The Markdown `get --body` returns is not guaranteed to reproduce the page if fed back through `--body-file`: URLs for uploaded files and images in the returned Markdown are pre-signed by Notion and expire, and an `<unknown/>` block has no Markdown form to write back in the first place. Use `--body`/`--body-only` to see what's on a page — including, per the note above, to check what a `--body-file` run would replace before running it — not to download a page, edit the file, and re-upload it as a general workflow.\n\n### `list` — read many rows\n\n```\nnotion-track list [--status <status>] [--assignee <value>] [--unassigned] [--priority <value>] [--json]\n```\n\nLists every row, or narrows it by `--status`, by `--assignee`, to `--unassigned` rows, or by `--priority` — `--assignee` and `--unassigned` are mutually exclusive. An unknown status, assignee or priority value fails fast with exit code 2, naming the values Notion actually allows for that property; `--assignee` resolves partial names and `me` exactly as it does on `upsert`/`set`, and `--priority` resolves partial values the same way (see `--assignee` / `--unassign` and `--priority` under [Usage](#usage) above). Filtering by assignee or priority on a profile that doesn't map the role fails like any other unmapped role (exit code 1). Unlike `--unassigned`, there is no `--unprioritized` to find rows with no priority.\n\nThe human-readable form appends `  !<value>` and `  @<name>` to a row that has them; rows with neither, and every row on a profile that doesn't map either role at all, print exactly as they did before this feature. When the id role is mapped, each row is also prefixed with its board id, the same way `get` shows it.\n\nWhen nothing matches, the human-readable form prints `no matching tasks` **to stderr** and exits 0 — stdout stays empty, so `list | wc -l` counts rows and nothing else. `list --json` prints `[]` and says nothing on stderr.\n\n### `apply` — many writes from one manifest\n\n```\nnotion-track apply --file tasks.json [--dry-run] [--expand] [--json]\n```\n\nApplies a list of writes from a JSON or CSV file, one entry at a time, in order. The format is chosen from the extension.\n\n```json\n[\n  {\"op\": \"upsert\", \"ticket\": \"TASK-1\", \"title\": \"Hardening\", \"status\": \"In progress\", \"assignee\": \"sam\", \"priority\": \"alta\"},\n  {\"op\": \"set\", \"ticket\": \"TASK-2\", \"status\": \"Done\", \"unassign\": true}\n]\n```\n\n```csv\nop,ticket,title,status,due,body_file,assignee,unassign,priority\nupsert,TASK-1,Hardening,In progress,2026-08-01,notes.md,sam,,alta\nset,TASK-2,,Done,,,,true,\n```\n\nFields: `op` (`upsert` or `set`, defaulting to `upsert` — the idempotent one, so a manifest run twice by mistake leaves the board as it was), `ticket` (required), `title`, `status`, `due`, `body_file`, `assignee`, `unassign`, `priority`. An unknown field is an error rather than something quietly ignored: a manifest with `stuats` in it would otherwise leave every row's status unset and say nothing.\n\n`assignee` accepts the same partial names and the reserved `me` that `--assignee` does; `unassign` accepts `true`/`false`/empty (case-insensitive) and is registered in both formats, so it is just as legal in CSV as in JSON. Passing both `assignee` and `unassign: true` on the same entry is rejected the same way `--assignee` and `--unassign` are on the flags. `priority` accepts the same partial values that `--priority` does; there is no `unpriority` field, the same way there is no `--unpriority` flag.\n\n`body_file` paths are resolved **relative to the manifest**, not to your working directory, so a manifest and the files it names travel together.\n\n**It stops at the first entry that fails**, reports which one and how many were applied, and exits with that entry's own code — so a pipeline branching on 3 (not found) or 4 (duplicate) still learns why the run stopped. Entries are applied sequentially, never in parallel: two writes racing on the same ticket key can create a duplicate, and a manifest is exactly where the same key is most likely to appear twice.\n\n```\n1/3 upsert TASK-1 updated\n2/3 upsert TASK-2 failed: unknown status \"Nonexistent\"; allowed values are: To do, In progress, Done\nstopped at entry 2 of 3: 1 applied, 2 not applied\n```\n\n`--dry-run` and `--expand` work here exactly as they do on `upsert` and `set`, which makes `apply --dry-run` the way to check a manifest before running it for real.\n\n### `--dry-run` — see what a write would do\n\n```bash\nnotion-track upsert --ticket TASK-231 --status Done --dry-run\n```\n\n```\nwould update 1f2e3d4c-...\n  Ticket               TASK-231\n  Stato                Done\n  https://notion.so/...\n```\n\nAvailable on `upsert` and `set`. It reports whether the row would be created or updated, which row, and which columns would be written — and writes nothing. With `--json` the output is `{\"dry_run\": true, \"plan\": {...}}`, so a script can tell it apart from a write that actually happened.\n\n`--unassign --dry-run` prints a `clear` line naming the column instead of a value — without it, clearing the assignee would be the one write a dry run has nothing to say about, the most destructive one in this feature and invisible in the very command that exists to show it:\n\n```\n$ notion-track set --ticket TASK-231 --unassign --dry-run\nwould update 1f2e3d4c-...\n  clear                Referente\n  https://notion.so/...\n```\n\n\"Without touching the API\" can only mean without *writing*: whether a ticket key resolves to a create or an update, and whether a status value even exists, are questions only the live data source can answer. A dry run therefore makes the same reads a real run does and stops before the first write — including the same validation, so a status your board would reject fails now rather than on the run you were about to do for real.\n\n### `doctor` — check the setup\n\n```\nnotion-track doctor [--json]\n```\n\nRuns five checks — `token`, `data_source`, `properties`, `duplicates`, `secrets` — plus a sixth, `assignee`, between `properties` and `duplicates`, when the role is mapped; each prints as `ok`, `warn`, or `fail` with an actionable detail message. A `warn` (e.g. the status property's type changed since `init` ran) does not fail the command; any `fail` makes it exit non-zero. Of the checks that talk to Notion, only `duplicates` still runs when `data_source` fails — it doesn't need the schema, so a broken setup at least gets scanned for duplicate ticket keys instead of stopping there; `properties` and `assignee` both need the live schema, so a `data_source` failure skips them until it's fixed. (`secrets` also still runs, but it never talks to Notion in the first place — see below.)\n\n`assignee` verifies that the configured identity still resolves to an option the mapped column offers — an option renamed in Notion would otherwise turn every `--assignee me` into a runtime failure discovered only when a write is attempted. It only ever reports `ok` or `warn`, never `fail`; the one thing it warns about, beyond an identity that no longer resolves, is an identity still coming from `config.yml`'s legacy `me:` field instead of `credentials.yml` or `NOTION_TRACK_ME` — see [Environment variables](#environment-variables) for why that's worth moving. It also warns when `credentials.yml` exists but can't be read: an identity may well be in there, and reporting \"none configured\" would send you to set up something you already have.\n\n`secrets` is the only check that looks at your machine rather than at Notion: it scans the files the current git repository *tracks* for anything shaped like an integration token, and warns with the file and line number — never with the matched text, which would leak the secret a second time into scrollback and CI logs. Untracked files are left alone: a token in an ignored `.env` is not the mistake this is for. Running outside a repository, or without git installed, reports `ok` with the reason rather than a warning nobody can act on.\n\n## Configuration\n\n`notion-track` keeps two files side by side in `os.UserConfigDir()/notion-track/` — respecting `$XDG_CONFIG_HOME` on Linux:\n\n| File | Holds | Safe to commit? |\n|---|---|---|\n| `config.yml` | profiles: data source id, property mapping | Yes — no secret |\n| `credentials.yml` | the integration token | **No — never commit this** |\n\nThey're two files, not one, for exactly this reason: `config.yml` is meant to be committed to a project repo so CI and every teammate share the same property mapping (see [CI usage](#ci-usage)); `credentials.yml` holds the one thing that must never end up in that repo. Splitting them makes \"the token can't leak through the committed config\" a property of the file layout, not a rule someone has to remember while editing YAML.\n\n| OS | Default directory |\n|---|---|\n| macOS | `~/Library/Application Support/notion-track/` |\n| Linux | `~/.config/notion-track/` |\n| Windows | `%AppData%\\notion-track\\` |\n\nPass `--config /path/to/file.yml` to point `config.yml` at a different file entirely — this is how you use a config file **committed to a project repo** instead of the per-user default (see [CI usage](#ci-usage)). There is no equivalent flag for `credentials.yml`: it is deliberately always the per-user, per-machine default location, never something a project repo points at.\n\n```yaml\n# config.yml\nschema_version: 1        # written by `init`; don't hand-edit it\ndefault_profile: work    # used when --profile and NOTION_TRACK_PROFILE are both unset\nprofiles:\n  work:\n    database_id: \"1a2b3c4d...\"     # optional, informational only — nothing reads it\n    data_source_id: \"5e6f7a8b...\"  # required — every query, create and update is keyed off this\n    status_type: status            # \"status\" or \"select\", recorded by `init`; see doctor's \"properties\" check\n    properties:\n      ticket: Ticket        # rich_text or title property holding the ticket key\n      status: Status        # status or select property\n      title: Name           # title property\n      due: Due              # optional: date property\n      assignee: Referente   # optional: select property naming who owns the row\n      priority: Urgenza     # optional: select property ranking how urgent the row is\n      id: ID                # optional: unique_id property holding the row's board id\n```\n\n```yaml\n# credentials.yml — never commit this file\nschema_version: 1\ntoken: ntn_...\nidentities:\n  work: Jordan Lee   # what `--assignee me` resolves to for the \"work\" profile; written by `init --me`\n```\n\nBoth files are replaced atomically (a temporary file in the same directory, then a rename), but only `credentials.yml` is guaranteed `0600`: its temp file has a random suffix and its permissions are set explicitly, immune to anything already sitting at a guessable temp path. `config.yml`'s temp file has a fixed name and its permissions are not forced onto a pre-existing file there, so a leftover `config.yml.tmp` from an earlier run can leave it at whatever mode that leftover already had (e.g. `0644`) — acceptable only because, unlike `credentials.yml`, it holds no secret of its own.\n\n`credentials.yml` is written in exactly one place: `init`, when it runs at an interactive terminal, finds no token in `NOTION_TOKEN` or the file already, and you accept the \"save it?\" prompt (the default — see [Quick start](#quick-start)). Nothing writes a token to `config.yml`, ever.\n\n### Environment variables\n\n| Variable | Effect |\n|---|---|\n| `NOTION_TOKEN` | the integration token. Always wins over `credentials.yml` when set — this is what lets CI pass a token that never touches disk. |\n| `NOTION_TRACK_PROFILE` | which profile to resolve, unless `--profile` is also given |\n| `NOTION_TRACK_DB` | overrides the resolved profile's `database_id` |\n| `NOTION_TRACK_DATA_SOURCE` | overrides the resolved profile's `data_source_id` |\n| `NOTION_TRACK_ME` | overrides the configured identity — the value `--assignee me` resolves to |\n\nPrecedence:\n\n- **Profile selection:** `--profile` flag → `NOTION_TRACK_PROFILE` → `default_profile` in the config file.\n- **`database_id` / `data_source_id`:** the env vars above always override whatever the resolved profile has on file, regardless of how that profile was chosen — this is what lets a CI job point an existing profile at a different data source without touching the committed file.\n- **Identity (`--assignee me`):** `NOTION_TRACK_ME` → `credentials.yml`'s `identities` map for the resolved profile → the profile's `me:` field in `config.yml` (legacy). `notion-track init --me <name>` (or the wizard, offered whenever an assignee column is mapped) is the default way to set it up, and it writes to `credentials.yml` — the per-user file — never to `config.yml`, which is meant to be committed and shared (see [Configuration](#configuration)). `NOTION_TRACK_ME` is the override on top of that, for CI and one-off runs. The profile's `me:` field still works forever, for configurations written before the identity moved — but a `me:` in a committed `config.yml` is *everyone's* identity, so `doctor` warns when a profile's identity is still coming from there, naming `init --me` as the fix.\n- **Token:** `NOTION_TOKEN` → `credentials.yml`. A token read from the environment is never written back to `credentials.yml` — a CI secret can never leak onto disk through a normal run. Run `notion-track doctor` if you need to see which source actually won.\n- **Config file location:** `--config` flag → the OS default path above. There is no environment variable for the path itself, and no equivalent flag for `credentials.yml`.\n\n## JSON output\n\nEvery `--json` shape below is a **documented, stable scripting contract**: a key is never renamed or removed without a breaking-change announcement.\n\nA row (`get --json`, and each entry of `list --json`):\n\n```json\n{\n  \"id\": \"TASK-271\",\n  \"ticket\": \"TASK-231\",\n  \"title\": \"Hardening\",\n  \"status\": \"In progress\",\n  \"page_id\": \"1a2b3c4d-...\",\n  \"url\": \"https://www.notion.so/...\",\n  \"last_edited_time\": \"2026-07-23T10:15:00Z\",\n  \"assignee\": \"Sam Rivera\",\n  \"priority\": \"ALTA\"\n}\n```\n\n`id` comes first because it is the row's identity, the same order the board displays it in. If the configured property mapping names a column the row doesn't actually carry, the corresponding field comes back as an empty string rather than an error — a broken mapping is `doctor`'s job to report, not a reason to fail every read. `id` follows the same rule as `assignee` and `priority` below: always present, empty whenever the row carries no value or the role isn't mapped, so a script never has to branch on whether the key is present — only on whether it's empty. `assignee` follows the same rule and is additionally empty whenever nobody is assigned. `priority` follows the same rule too: always present, empty whenever the row carries no value or the role isn't mapped.\n\n`upsert --json` / `set --json`:\n\n```json\n{\n  \"action\": \"created\",\n  \"page\": { \"id\": \"TASK-271\", \"ticket\": \"TASK-231\", \"title\": \"Hardening\", \"status\": \"In progress\", \"page_id\": \"...\", \"url\": \"...\", \"last_edited_time\": \"...\", \"assignee\": \"Sam Rivera\", \"priority\": \"ALTA\" }\n}\n```\n\n`action` is `\"created\"` or `\"updated\"`.\n\n`doctor --json` — an array of checks, one per `token` / `data_source` / `properties` / `assignee` (only when the role is mapped) / `duplicates` / `secrets`:\n\n```json\n[\n  { \"name\": \"token\", \"status\": \"ok\", \"detail\": \"token from environment\\n  authenticated as notion-track\" },\n  { \"name\": \"data_source\", \"status\": \"ok\", \"detail\": \"reachable: Tasks\" },\n  { \"name\": \"properties\", \"status\": \"ok\", \"detail\": \"all mapped properties exist with the expected types\" },\n  { \"name\": \"assignee\", \"status\": \"ok\", \"detail\": \"--assignee me resolves to Sam Rivera\" },\n  { \"name\": \"duplicates\", \"status\": \"ok\", \"detail\": \"42 rows, no repeated ticket keys\" },\n  { \"name\": \"secrets\", \"status\": \"ok\", \"detail\": \"37 tracked files scanned, no token-looking strings\" }\n]\n```\n\n`status` is `\"ok\"`, `\"warn\"`, or `\"fail\"`; `detail` is omitted only when empty, which does not happen in practice.\n\n## Exit codes\n\nPipelines can branch on these without parsing any message text:\n\n| Code | Name | Meaning |\n|---|---|---|\n| `0` | OK | success |\n| `1` | Error | a generic failure — a network/API error, `doctor` reporting a failed check other than `token`, or a value passed for `--assignee`, `--priority` or `--due` when that role isn't mapped in the active profile (an unmapped `id` role is the one exception — it's exit code 2, see below) |\n| `2` | Usage | the invocation cannot work as written: a missing/invalid flag, more than one of `--ticket`/`--id`/`--page-id` given or none of the three given, an unknown command, no config yet (`notion-track init` was never run), a status value the data source doesn't allow, a malformed `--page-id` or `--id`, an `--id` used on a profile with no id role mapped, a `--page-id` that resolves outside the active profile's data source, an `--assignee` value that resolves to zero or more than one option, an empty `--assignee`, `--assignee me` with no configured identity, `--assignee` combined with `--unassign` (or with `--unassigned` on `list`), a `--priority` value that resolves to zero or more than one option, or an empty `--priority` |\n| `3` | Not found | the requested ticket, board id, or page id has no matching row (or, for a page id, one not shared with this integration) (`get`, `set`) |\n| `4` | Duplicate | the ticket key matches more than one row (`upsert`, `set`, `get`) |\n| `5` | Auth | no token was found (including a `credentials.yml` that exists but can't be read), or Notion rejected it (401/403) — including `doctor`, when its `token` check is the only one that failed. `--assignee me` also exits 5 when `credentials.yml` can't be read: an identity may be in there, and falling back to the profile's shared `me:` would assign the row to somebody else |\n\n## CI usage\n\nBecause the config file has no secret in it, the common pattern is to **commit it to the repository** and point at it explicitly with `--config`, while the token comes from a CI secret:\n\n```yaml\n# .github/workflows/notion.yml\n- name: Install notion-track\n  uses: marcoarnulfo/notion-cli/action@main\n  with:\n    version: v0.6.0   # or \"latest\"\n\n- name: Mark the ticket done\n  run: notion-track upsert --ticket \"$TICKET\" --status \"Done\" --config notion-track.yml\n  env:\n    NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }}\n    TICKET: ${{ github.event.inputs.ticket }}\n```\n\nThe action downloads the release archive for the runner it is on, checks it against the release's `checksums.txt`, and puts the binary on `PATH` — no Go toolchain, no compile. Linux, macOS and Windows runners (Windows through Git Bash), amd64 and arm64; anywhere else it fails with a message that says so. It installs from a published release matching the `version` input, so it needs at least one `v*` tag pushed to this repository before it has anything to install; `go install github.com/marcoarnulfo/notion-cli/cmd/notion-track@latest` needs none of that and always works.\n\n`@main` is a moving reference: you get whatever is on the branch at the time. Pin it to a commit SHA if you want a workflow that cannot change under you — a `@v1` tag will exist once this project reaches 1.0.\n\n## Limitations\n\nThese are current, deliberate tradeoffs — not bugs to be surprised by:\n\n1. **Every change is attributed to the integration, not to you.** Notion records edits made through the API as made by the integration's bot identity. If you check a page's edit history in Notion, you will see the integration's name, never the human or CI job that ran the command.\n2. **`upsert` and `get` fail on duplicate ticket keys instead of picking one.** If more than one row shares the same ticket key, `notion-track` refuses to guess which one you meant — it exits with code 4 and lists the offending rows. Run `notion-track doctor` to find and clean them up.\n3. **Two concurrent jobs creating the same new ticket can race into a duplicate.** `upsert`'s create-or-update decision reads the current rows, then writes; the Notion API offers no unique-constraint or compare-and-swap primitive to close that window. This is not preventable client-side — `doctor`'s duplicate scan is the mitigation, not a fix.\n4. **Only a Workspace Owner can set this up.** Creating the integration and sharing a database with it both require Workspace Owner permissions in Notion. A workspace guest — one of the reasons this tool exists in the first place — cannot do either step, but can use the tool freely once someone with Owner rights has.\n5. **`--body-file` replaces the whole page body, with no lock and no undo.** It owns the body: each run overwrites everything there, hand-edited content included, and two runs racing the same page can duplicate it. See `--body-file` under [Usage](#usage) above.\n\n## Use it from an AI agent\n\nBecause the tool is quiet on success, speaks `--json` with a stable schema, and returns [differentiated exit codes](#exit-codes), an agent can drive it as reliably as a script does — no scraping of human output. A ready-made [Claude Code](https://claude.com/claude-code) skill lives in **[`skills/notion-track/`](skills/notion-track/)**: it teaches an agent which command to reach for and how to stay safe (read before writing, never invent a status, branch on exit codes). Install it by copying its `SKILL.md` into `~/.claude/skills/notion-track/`, then ask your agent to \"mark that task done on Notion\". For hosts that speak MCP rather than the shell, **`notion-track mcp`** serves the same operations as tools over stdio:\n\n```json\n{\n  \"mcpServers\": {\n    \"notion-track\": { \"command\": \"notion-track\", \"args\": [\"mcp\"] }\n  }\n}\n```\n\nIt exposes `upsert_task`, `set_task`, `get_task` and `list_tasks`, returning the same JSON shape documented above. `upsert_task` and `set_task` both take `ticket`, `title`, `status`, `due`, `assignee`, `unassign` and `priority` — one argument per CLI flag; `get_task` takes only `ticket`. `assignee` and `unassign` resolve exactly like the CLI's flags do — a partial name, or the reserved `me`; `list_tasks` accepts `assignee` and `unassigned`, mutually exclusive with each other. `priority` resolves the same way `--priority` does — a partial value is enough when unambiguous; `list_tasks` accepts `priority` too, narrowing to rows carrying that value. There is no `unpriority` argument, the same way the CLI has no `--unpriority` flag. It is an adapter, not a second implementation: every tool reaches the same code the CLI commands do, so the duplicate check, the status validation and the property mapping behave identically for an agent. `stdout` carries the JSON-RPC protocol and nothing else.\n\nAddressing is the one place the two surfaces diverge: over MCP a row is found **only** by ticket key — `get_task`'s and `set_task`'s only argument for finding a row is `ticket` (`set_task`'s other arguments say what to write, not which row to write it to). The CLI's `--id` and `--page-id` have no MCP equivalent, even though the JSON a tool returns still carries the board id under `id`, same as `--json` does on the CLI.\n\nThis does not contradict the reason this tool exists. Notion's *hosted* MCP endpoint is the one blocked by corporate firewalls; a *local* server, running on your machine with your own integration token, reaches agents exactly where the hosted one cannot.\n\n## Contributing\n\nContributions are welcome — this is a free, open-source project. See **[CONTRIBUTING.md](CONTRIBUTING.md)** for the development setup, the checks to run before opening a PR, and the project's non-negotiable architectural rules. Please also read the [Code of Conduct](CODE_OF_CONDUCT.md). Found a security issue? See [SECURITY.md](SECURITY.md) instead of opening a public issue.\n\n## Roadmap\n\nImplemented today: `init` (interactive wizard and flag-driven, with `--list`), the browsing TUI, `upsert`, `set`, `get`, `list`, `doctor`; `--dry-run` on `upsert`/`set`; `apply` for bulk writes from a manifest; `--body-file` on `upsert`/`set` to write the page body from Markdown, with `--expand` for `{{ticket}}`/`{{date}}` placeholders; `--append-file` on `upsert`/`set` to add Markdown to the end of a page body without replacing it; `get --body`/`--body-only` to read a page body back as Markdown; `--json` on every command that produces output; `mcp` to serve the same operations as MCP tools; an optional `assignee` role with `--assignee`/`--unassign`, `list --assignee`/`--unassigned` and the `me` identity; an optional `priority` role with `--priority` on `upsert`/`set`/`list`; an optional `id` role mapped with `init --id-prop`, addressing a row by its Notion board id with `--id` on `get`/`set`; profiles; retry with backoff.\n\nRelease automation: the **GoReleaser pipeline** (`.goreleaser.yaml` plus a release workflow triggered on `v*` tags) builds and publishes the binaries and `checksums.txt` described under [Installation](#installation) every time a tag is pushed. The **composite GitHub Action** in [`action/`](action/) installs from whichever release its `version` input resolves to (see [CI usage](#ci-usage)). Both are exercised, in two places. The release workflow's own `smoke` job installs each release it has just published, with the action, on Linux, macOS and Windows — so the archive names GoReleaser writes are checked against the ones the action reconstructs, every time. Separately, the `action-smoke` workflow installs the current release whenever the action changes, weekly against the drifting runner images, and through the `marcoarnulfo/notion-cli/action@main` reference this README documents. Two published archives are outside both: `windows/arm64` has no GitHub-hosted runner to install on, and `darwin/amd64` has no row in either matrix — tracked in [#30](https://github.com/marcoarnulfo/notion-cli/issues/30).\n\n## License\n\n[MIT](LICENSE)\n"}