`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 ./notesif your Notion database title property is not named `Name`, set it explicitly:
ns init --database-id <database_id> --notes-root ./notes --title-property Titlethis creates:
notes/
.ns-cli/
config.jsondirectory 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 versionsync 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.jsonthe 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 itemnested 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] Textcode 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.mdinspect 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.mddownload all pages for one mapped directory:
cd ./notes/project
ns download-alldownload the full database into the notes tree:
cd ./notes
ns download-allwatch one file and run the watcher:
ns watch project/today.md --enable --cooldown-seconds 60
ns watch
ns watch project/today.md --disableuse `watch-upload` from an editor save hook:
ns watch-upload project/today.mdknown 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.