# Markdown Base CLI (markbase)
A high-performance CLI tool for indexing and querying Markdown notes, designed for both AI agents and human users with Obsidian compatibility in mind.
[](https://deepwiki.com/flyisland/markbase)
## Installation
**From crates.io (recommended):**
```bash
cargo install markbase
```
**Build from source:**
```bash
git clone <repository-url>
cd markbase
cargo build --release
./target/release/markbase --help
```
**Prerequisites:** Rust 1.85+ (DuckDB is bundled)
## Quick Start
```bash
export MARKBASE_BASE_DIR=/path/to/your/notes
markbase query "author == 'Tom'"
markbase query "SELECT file.path, file.name FROM notes WHERE list_contains(file.tags, 'todo')"
```
## Environment Variables
| `MARKBASE_BASE_DIR` | Vault directory | `.` (current directory) |
| `MARKBASE_INDEX_LOG_LEVEL` | Automatic indexing output (`off`, `summary`, `verbose`) | `off` |
| `MARKBASE_COMPUTE_BACKLINKS` | Compute `file.backlinks` during automatic indexing | disabled |
**Priority:** CLI args > Environment variables > Defaults
```bash
export MARKBASE_BASE_DIR=/path/to/notes
markbase query "list_contains(file.tags, 'design')"
```
## Concepts
### Note Properties
Each indexed note has two namespaces for properties:
**File Properties** (`file.*` prefix):
Access native database columns representing file metadata:
| `file.path` | TEXT | File path relative to base-dir |
| `file.folder` | TEXT | Directory path relative to base-dir |
| `file.name` | TEXT | File name without extension |
| `file.ext` | TEXT | File extension |
| `file.size` | INTEGER | File size in bytes |
| `file.ctime` | TIMESTAMPTZ | Created time |
| `file.mtime` | TIMESTAMPTZ | Modified time |
| `file.tags` | VARCHAR[] | Tags from content (`#tag`) and frontmatter |
| `file.links` | VARCHAR[] | Wiki-links `[[link]]` + embeds `![[embed]]` from body and frontmatter |
| `file.backlinks` | VARCHAR[] | Notes linking to this note (reverse of links); empty unless backlinks computation is enabled |
| `file.embeds` | VARCHAR[] | Embeds `![[embed]]` from body only |
**Note Properties** (`note.*` prefix or bare):
Access YAML frontmatter fields:
```yaml
---
title: My Note
author: John
status: in-progress
---
```
Query using explicit prefix or bare shorthand:
```bash
markbase query "note.author == 'John'" # explicit
markbase query "author == 'John'" # shorthand (same result)
```
### Tags
Tags are extracted from two sources:
**Content tags** (`#tag` in note body):
- Obsidian format: `#` followed by alphanumeric characters, underscores, hyphens, and forward slashes
- Must contain at least one non-numerical character (e.g., `#1984` is invalid, `#y1984` is valid)
- Case-insensitive (e.g., `#tag` and `#TAG` are identical)
- Supports nested tags using `/` separator (e.g., `#project/2024/q1`)
**Frontmatter tags**:
- YAML list format: `tags: [tag1, tag2]` or `tags: [project/2024]`
All tags are merged into `file.tags` and can be queried with `list_contains(file.tags, 'tag-name')`.
### Field Resolution
| `file.*` | Native database column | `file.name` → `name` column |
| `note.*` | Frontmatter JSON extraction | `note.author` → `properties->"author"` |
| bare (no prefix) | Frontmatter JSON extraction (shorthand for `note.*`) | `author` → `properties->"author"` |
The `file.*` and `note.*` namespaces are completely separate — no naming conflicts.
### Name Uniqueness
Note names must be unique across the entire vault, regardless of their directory location.
- **Index**: When indexing, if two notes have the same name (different paths), a warning is shown and the duplicate is skipped
- **Create**: Creating a note fails if a note with that name already exists
- **Rename**: Renaming a note fails if a note with the target name already exists
### Link Format (Obsidian Style)
Always use the **filename only** — no path, no extension:
```markdown
# ✅ Correct
[[中国移动]]
[[张三]]
# ❌ Wrong
[[entities/中国移动.md]]
[[people/张三]]
```
Wiki-links in **frontmatter properties** must additionally be wrapped in quotes:
```yaml
# ✅ Correct
related_customer: "[[中石油]]"
attendees_internal: ["[[张三]]", "[[李四]]"]
# ❌ Wrong
related_customer: [[中国移动]]
attendees_internal: [[[张三]], [[李四]]]
```
## Commands
### `query`
Query notes in your vault.
**Two input modes:**
```bash
# Expression mode (WHERE clause only)
markbase query "note.author == 'Tom'" # frontmatter (explicit)
markbase query "author == 'Tom'" # frontmatter (shorthand)
markbase query "file.mtime > '2024-01-01'" # file metadata
markbase query "list_contains(file.tags, 'project')" # file array field
markbase query "author == 'Tom' ORDER BY file.mtime DESC LIMIT 10"
# Backlinks are disabled by default to keep indexing fast
markbase query "list_contains(file.backlinks, 'source')"
markbase --compute-backlinks query "list_contains(file.backlinks, 'source')"
# SQL mode (full SELECT statement)
markbase query "SELECT file.path, note.author FROM notes WHERE note.author = 'Tom'"
```
`file.backlinks` is empty unless backlinks computation is enabled with
`--compute-backlinks` or `MARKBASE_COMPUTE_BACKLINKS`.
Default columns for empty input or expression mode: `file.path`, `file.name`, `description`, `file.mtime`, `file.size`, `file.tags`.
**Output formats:**
- default output is `json`, optimized for agents and scripts
- `-o table` renders compact Markdown tables for humans
```bash
markbase query "SELECT file.name, title FROM notes" -o table
```
```md
| readme | README |
| todo | Todo List |
```
```bash
markbase query "SELECT file.name, title, file.tags FROM notes"
```
```json
[
{
"file.name": "readme",
"title": "README",
"file.tags": ["documentation", "important"]
},
{
"file.name": "todo",
"title": "Todo List",
"file.tags": ["todo", "work"]
}
]
```
Empty results stay machine-friendly:
- default `json` prints `[]`
- `-o table` prints just the header row and separator
**Debug:**
```bash
markbase query --dry-run "author == 'Tom'" # Show translated SQL
```
**Type casts for non-string comparisons:**
```bash
markbase query "note.year::INTEGER >= 2024"
markbase query "note.created::TIMESTAMP > '2024-01-01'"
# or using bare shorthand:
markbase query "year::INTEGER >= 2024"
```
### `note`
Create and manage notes.
**Create a note:**
Without a template, `markbase note new` creates a Markdown note in `base-dir/inbox` with a default frontmatter field: `description: 临时笔记`.
```bash
markbase note new my-note # Create in base-dir/inbox
markbase note new my-note --template daily # Create in base-dir/inbox if template has no location
markbase note new customer --template company # Create in _schema.location if template defines one
```
`name` must be a pure note name and cannot include directory components.
On success, `markbase note new` prints only the note path relative to `base-dir`.
**Rename a note:**
```bash
markbase note rename old-name new-name
```
Behavior:
- Looks up note by name (not path)
- Fails if name is ambiguous or new name exists
- Updates all `[[old-name]]` links and `![[old-name]]` embeds across the vault (body and frontmatter)
- Preserves aliases, section anchors, and block IDs
- Reindexes the vault immediately after the rename completes
**Resolve one or more entity names to notes:**
```bash
markbase note resolve "acme"
markbase note resolve "张伟" "阿里"
```
Outputs JSON by default for agent-friendly entity alignment. Each input returns `query`, `status`, and `matches`.
Statuses:
- `exact` — one note matched by `file.name`
- `alias` — one note matched by frontmatter `aliases`
- `multiple` — more than one candidate matched; disambiguate before linking
- `missing` — no matching note or alias found
Each match includes `name`, `path`, `type`, `description`, and `matched_by`. Missing descriptions are emitted as `null`, not omitted.
A single `exact` or `alias` match is still only a low-cost alignment hint: compare `description` and context before reusing the note. If the description is clearly about a different thing, prefer creating a new note instead of forcing reuse.
**Verify a note against its template schema:**
```bash
markbase note verify <name>
```
Checks that the note conforms to all constraints defined in its referenced MTS template(s), and also runs a global `description` check before template validation:
- Global frontmatter `description` exists, is a string, and is not blank (reported as WARN)
- Directory location matches `_schema.location`
- Required frontmatter fields are present
- Field types and enum values are correct
- Link fields point to notes of the expected `type`
Warnings are reported to stderr. For issue output, the header includes `file.path`, and each schema-related issue includes a compact `Definition:` line so agents can repair notes with the expected type/constraints. Exit code is non-zero only on errors (e.g. missing note or template file).
**Render a note (expand .base embeds):**
```bash
markbase note render <n> # Markdown with embedded JSON blocks (default)
markbase note render <n> -o table # Markdown tables for embedded Base views
markbase note render <n> --dry-run # show SQL without executing
```
Renders the note body to stdout. Each `![[*.base]]` embed is replaced with
query results from the corresponding Obsidian Base file. Non-`.base` embeds
are passed through unchanged.
For `-o table`, each rendered Base view becomes a compact Markdown table:
```md
> **Open Tasks**
| [[task-a]] | high |
| [[task-b]] | medium |
```
By default, the same view is wrapped in a JSON code fence so agents can parse it directly from the rendered Markdown:
````md
> **Open Tasks**
```json
[
{
"name": "[[task-a]]",
"priority": "high"
},
{
"name": "[[task-b]]",
"priority": "medium"
}
]
```
````
Supported filters: `link(this)`, `link("name")`, `file.hasLink(this.file)`,
`file.hasTag()`, `file.inFolder()`, date comparisons, `isEmpty()`, `contains()`.
Warnings (unsupported filters, missing base files) go to stderr.
Exit code is non-zero only on hard errors (e.g. note not found).
### `template`
Manage MTS templates.
```bash
markbase template list # JSON (default, agent-first)
markbase template list -o table # Compact Markdown table
markbase template describe daily # Show normalized template content
```
Templates are stored in `templates/` under base-dir. `template describe` shows the normalized template view used by the CLI, including auto-injected `description` schema/default fields when older templates omit them. For new templates, prefer declaring all three `description` layers explicitly:
```yaml
description: ""
_schema:
description: 用于匹配客户公司资料的模板
required:
- description
properties:
description:
type: text
description: 一句话说明这个 note 是什么
```
Here, outer frontmatter `description` is the instance note field, `_schema.description` is the template routing prompt, and `_schema.properties.description` is the schema definition for that instance field.
## Query Syntax
markbase translates field names using explicit namespaces (`file.*` for file metadata, `note.*` or bare for frontmatter) to DuckDB queries. All DuckDB SQL keywords and operators are supported natively.
**Commonly Used Functions:**
- `list_contains(field, value)` - Array containment
- `list_contains(file.tags, 'todo')` - file array field (native)
- `list_contains(note.categories, 'work')` - frontmatter array (cast to VARCHAR[])
**Field Prefix Reference:**
| `file.` | File properties | Metadata columns | `file.name`, `file.mtime`, `file.size` |
| `note.` | Note properties | Frontmatter fields | `note.author`, `note.status` |
| (bare) | Note properties | Shorthand for `note.*` | `author`, `status` |
**Examples:**
```bash
# File metadata queries (require file.* prefix)
markbase query "file.folder == './notes'"
markbase query "file.mtime > '2024-01-01'"
markbase query "file.size > 10000"
markbase query "file.name LIKE '%meeting%'"
markbase query "list_contains(file.tags, 'todo')"
# Frontmatter queries (note.* prefix or bare)
markbase query "note.author == 'John'"
markbase query "author == 'John'" # same as above
markbase query "note.status == 'active'"
markbase query "author IS NOT NULL"
# Combined queries
markbase query "author == 'John' AND file.mtime > '2024-01-01'"
markbase query "list_contains(file.tags, 'todo') AND status == 'active'"
```
## License
MIT