*ns-cli user guide
jun 18 2026

thedwncmpy/ns-cli

`ns-cli` syncs Markdown files in a local notes tree with a Notion database through the `ns` command.

this guide is based on the current implementation in `lib/`, not on intended behavior.


what the cli does

- uses exact filename-to-page-title matching.

- uses first-level directory mappings to scope relation-based sync.

- stores project config in `.ns-cli/config.json` under your notes root.

- stores downloaded page metadata in `.ns-cli/pages/**/*.json`.

- uploads Markdown to Notion.

- downloads Notion pages to Markdown.

- fails hard on ambiguous matches.


requirements

- `zsh`

- `python3`

- `jq`

- `curl`

- a Notion integration token with access to the target database


authentication

set `NOTION_TOKEN` in either of these places:

export NOTION_TOKEN="secret_xxx"

or in `~/.config/ns-cli/secrets.zsh`:

export NOTION_TOKEN="secret_xxx"

environment variables take precedence over the secrets file.


project setup

initialize a notes tree:

ns init --database-id <database_id> --notes-root ./notes

if your Notion database title property is not named `Name`, set it explicitly:

ns init --database-id <database_id> --notes-root ./notes --title-property Title

this creates:

notes/
  .ns-cli/
    config.json

directory mapping

`ns link` maps a first-level subdirectory under `notes_root` to a Notion relation page id and the relation property name used on database pages.

example:

ns link project rel_123 notebook

- files under `notes/project/` sync against pages whose title equals the Markdown filename stem.

- those pages must also have a `notebook` relation containing `rel_123`.

- only first-level directories are mapped.

- `notes/project/daily/today.md` still uses the `project` mapping.


config format

example `.ns-cli/config.json`:

{
  "version": 1,
  "database_id": "db_test",
  "notes_root": "/absolute/path/to/notes",
  "title_property": "Name",
  "mappings": {
    "project": {
      "relation_page_id": "rel_123",
      "relation_property": "notebook"
    }
  },
  "watch": {
    "default_cooldown_seconds": 60,
    "files": {
      "project/today.md": {
        "enabled": true,
        "cooldown_seconds": 60,
        "last_uploaded_at": 1781899705
      }
    }
  }
}

legacy mapping values are still accepted:

{
  "mappings": {
    "project": "rel_123"
  }
}

in that case the relation property defaults to `notebook`.


command reference

`ns init`

ns init --database-id <id> --notes-root <path> [--title-property <name>] [--force]

- creates `.ns-cli/config.json` inside the notes root.

- `--force` overwrites an existing config.

`ns link`

ns link <subdir> <relation_page_id> <relation_property> [--force]

- `subdir` must already exist under `notes_root`.

- `--force` overwrites an existing mapping.

`ns status`

ns status <file.md>

- shows title, notes root, mapping directory, relation page id, relation property, and exact query filter used for sync.

- with no file argument, it prints the project config JSON.

`ns upload`

ns upload [--dry-run] <file.md>

- file must exist, end in `.md`, and be inside `notes_root`.

- if the file is under a subdirectory, that first-level directory must be mapped.

- root-level files are allowed without a mapping.

- mapped files query by exact title plus exact relation membership.

- root-level files query by exact title only.

- if a single match exists, the existing remote page is archived, a new page is created, and Markdown blocks are appended to the new page.

- if no match exists, a new page is created.

- if multiple matches exist, the command fails.

- `--dry-run` prints intent only.

`ns upload-sync`

ns upload-sync [--dry-run]

- uploads all Markdown files under the current directory recursively.

`ns rename`

ns rename <old-path.md> <new-path.md>

- renames a note locally and in Notion.

- example: `ns rename project/today.md archive/weekly-summary.md`

`ns watch`

ns watch [<file.md>] [--enable|--disable] [--cooldown-seconds <n>]

- `ns watch <file.md> --enable` enables auto-upload for one Markdown file.

- `ns watch <file.md> --disable` disables auto-upload for one Markdown file.

- bare `ns watch` runs the watcher loop.

- the watcher scans `notes_root` for changed `.md` files but only uploads files that are explicitly enabled in config.

- it reuses the existing `ns upload` flow for each changed file.

- it stores per-file state in `watch.files[<relative-path>]`.

- it stores per-file `last_uploaded_at` timestamps in project config.

- it skips re-uploading the same file until the cooldown window expires.

- successful sync operations append a hidden audit line to `.ns-cli/sync.log`.

`ns watch-upload`

ns watch-upload <file.md>

- uploads one Markdown file only if that file has watch enabled in config.

- it reuses the same cooldown and `last_uploaded_at` behavior as `ns watch`.

- it is intended for editor save hooks such as Neovim `BufWritePost`.

- it resolves the matching `ns` project from the saved file path, so it does not depend on the editor's current working directory.

`ns download`

ns download [--dry-run] <file.md>

- target path must end in `.md` and be inside `notes_root`.

- if the path is under a subdirectory, that first-level directory must be mapped.

- root-level targets are allowed without a mapping.

- mapped files query by exact title plus exact relation membership.

- root-level files query by exact title only.

- if a single match exists, the remote page is converted to Markdown, the target file is created or overwritten, and page properties and icon metadata are written to `.ns-cli/pages/...json`.

- if no match exists, the command fails.

- if multiple matches exist, the command fails.

- `--dry-run` prints intent only.

`ns delete`

ns delete [--dry-run] <file.md>

- target path must end in `.md` and be inside `notes_root`.

- if the path is under a subdirectory, that first-level directory must be mapped.

- root-level targets are allowed without a mapping.

- mapped files query by exact title plus exact relation membership.

- root-level file queries use exact title only.

- if a single match exists, the remote page is archived, the local Markdown file is deleted if present, and the matching `.ns-cli/pages/...json` sidecar is deleted if present.

- if no match exists, the command fails.

- if multiple matches exist, the command fails.

- `--dry-run` prints intent only.

`ns download-all`

ns download-all [--dry-run]

- downloads every remote page in the current sync scope.

- from `notes_root`, it queries the full database.

- inside a mapped first-level directory, it queries only pages whose mapped relation contains that directory's relation page id.

- in a mapped scope, files download into that mapped directory.

- in root scope, the CLI tries to infer a mapped directory from each page's relations.

- if a page matches multiple directory mappings, the command fails for that page.

- if no mapping matches, the page downloads to the root of `notes_root`.

`ns download-sync`

ns download-sync [--dry-run]

- downloads all Markdown files under the current directory recursively by calling `ns download` for each file path found locally.

- this command does not discover remote-only pages.

`ns completion`

ns completion <zsh|bash>

eval "$(ns completion zsh)"

`ns version`

ns version

sync rules

title matching

- the page title is always the filename stem.

notes/project/today.md -> today

- no frontmatter title override exists.

mapping rules

- only the first path segment under `notes_root` is used for relation mapping.

- unmapped nested files fail.

- root-level files do not require a mapping.

ambiguity

- more than one matching page is an error.

- missing required mapping is an error.

- missing config is an error.

- target outside `notes_root` is an error.


metadata storage

downloaded page properties and icon metadata are stored in sidecar JSON files under:

.ns-cli/pages/

for example:

notes/.ns-cli/pages/project/today.json

the current upload flow reads these sidecars and uses them when recreating a page.

- downloaded Markdown files are written as plain Markdown body only.

- the CLI currently does not embed `<!-- notion-properties ... -->` metadata blocks into the Markdown file body.

- sidecar JSON is the active metadata source when present.


markdown support

supported Markdown-to-Notion conversions include:

- paragraphs

- headings `#`, `##`, `###`

- toggle headings via `[toggle] `

- bulleted lists

- todo items `- [ ]` and `- [x]`

- quotes

- callouts using blockquote alert markers

- fenced code blocks

- dividers `---`

- `[TOC]`

- `[[link_to_page page_id:...]]`

- `[[link_to_page database_id:...]]`

toggle headings

use `[toggle] ` at the start of a heading text:

### [toggle] Section

  Paragraph inside toggle
  - Nested item

nested content is determined by indentation. the parser uses an indent width of 2 spaces.

callouts

these blockquote markers map to Notion callouts:

> [!NOTE] Text
> [!WARNING] Text
> [!ERROR] Text
> [!INFO] Text
> [!SUCCESS] Text

code blocks

fenced code blocks are supported. unknown languages are normalized to `plain text`.

language aliases include:

- `zsh` -> `shell`

- `sh` -> `shell`

- `py` -> `python`

- `js` -> `javascript`

- `ts` -> `typescript`

- `yml` -> `yaml`

- `md` -> `markdown`


common workflows

initialize and upload one file:

export NOTION_TOKEN="secret_xxx"
ns init --database-id <db_id> --notes-root ./notes
ns link project <relation_page_id> notebook
ns upload ./notes/project/today.md

inspect what a file will do before syncing:

ns status ./notes/project/today.md
ns upload --dry-run ./notes/project/today.md
ns download --dry-run ./notes/project/today.md

download all pages for one mapped directory:

cd ./notes/project
ns download-all

download the full database into the notes tree:

cd ./notes
ns download-all

watch one file and run the watcher:

ns watch project/today.md --enable --cooldown-seconds 60
ns watch
ns watch project/today.md --disable

use `watch-upload` from an editor save hook:

ns watch-upload project/today.md

known current behaviors

- `download-sync` works from local file discovery, not remote page discovery.

- uploading a matched page archives the old page and recreates it instead of patching blocks in place.

- Markdown property blocks are parsed if present, but normal downloads currently store metadata in sidecar JSON instead of writing those blocks back into Markdown.