13 releases (4 breaking)
Uses new Rust 2024
| new 0.5.3 | Mar 13, 2026 |
|---|---|
| 0.5.2 | Mar 9, 2026 |
| 0.4.0 | Mar 8, 2026 |
| 0.3.2 | Mar 6, 2026 |
| 0.1.0-alpha.2 | Feb 28, 2026 |
#334 in Database interfaces
285KB
7.5K
SLoC
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.
Installation
From crates.io (recommended):
cargo install markbase
Build from source:
git clone <repository-url>
cd markbase
cargo build --release
./target/release/markbase --help
Prerequisites: Rust 1.85+ (DuckDB is bundled)
Quick Start
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
| Variable | Description | Default |
|---|---|---|
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
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:
| Field | Type | Description |
|---|---|---|
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:
---
title: My Note
author: John
status: in-progress
---
Query using explicit prefix or bare shorthand:
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.,
#1984is invalid,#y1984is valid) - Case-insensitive (e.g.,
#tagand#TAGare identical) - Supports nested tags using
/separator (e.g.,#project/2024/q1)
Frontmatter tags:
- YAML list format:
tags: [tag1, tag2]ortags: [project/2024]
All tags are merged into file.tags and can be queried with list_contains(file.tags, 'tag-name').
Field Resolution
| Syntax | Resolves To | Example |
|---|---|---|
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:
# ✅ Correct
[[中国移动]]
[[张三]]
# ❌ Wrong
[[entities/中国移动.md]]
[[people/张三]]
Wiki-links in frontmatter properties must additionally be wrapped in quotes:
# ✅ Correct
related_customer: "[[中石油]]"
attendees_internal: ["[[张三]]", "[[李四]]"]
# ❌ Wrong
related_customer: [[中国移动]]
attendees_internal: [[[张三]], [[李四]]]
Commands
query
Query notes in your vault.
Two input modes:
# 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 tablerenders compact Markdown tables for humans
markbase query "SELECT file.name, title FROM notes" -o table
| file.name | title |
| --- | --- |
| readme | README |
| todo | Todo List |
markbase query "SELECT file.name, title, file.tags FROM notes"
[
{
"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
jsonprints[] -o tableprints just the header row and separator
Debug:
markbase query --dry-run "author == 'Tom'" # Show translated SQL
Type casts for non-string comparisons:
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: 临时笔记.
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: no directory components and no file extension.
On success, markbase note new prints only the note path relative to base-dir.
Rename a note:
markbase note rename old-name new-name
Behavior:
old-nameandnew-namemust be names only (no path components)- 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
Extensions are allowed when renaming resource-style files such as aaa.jpeg; the forbidden part is the path, not the suffix.
Resolve one or more entity names to notes:
markbase note resolve "acme"
markbase note resolve "张伟" "阿里"
Outputs JSON by default for agent-friendly entity alignment. Each input returns query, status, and matches.
Each resolve input must be a name or alias only, never a path or file-style name with an extension.
Statuses:
exact— one note matched byfile.namealias— one note matched by frontmatteraliasesmultiple— more than one candidate matched; disambiguate before linkingmissing— 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:
markbase note verify <name>
<name> must be a note name only: no path and no file extension.
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
descriptionexists, 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):
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
<n> must be either a note name (no extension) or a .base filename, never a path.
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:
<!-- start: [markbase] rendered from tasks.base -->
> **Open Tasks**
| name | priority |
| --- | --- |
| [[task-a]] | high |
| [[task-b]] | medium |
<!-- end: [markbase] rendered from tasks.base -->
By default, the same view is wrapped in a JSON code fence so agents can parse it directly from the rendered Markdown:
<!-- start: [markbase] rendered from tasks.base -->
> **Open Tasks**
```json
[
{
"name": "[[task-a]]",
"priority": "high"
},
{
"name": "[[task-b]]",
"priority": "medium"
}
]
```
<!-- end: [markbase] rendered from tasks.base -->
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.
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:
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 containmentlist_contains(file.tags, 'todo')- file array field (native)list_contains(note.categories, 'work')- frontmatter array (cast to VARCHAR[])
Field Prefix Reference:
| Prefix | Namespace | Use For | Example |
|---|---|---|---|
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:
# 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
Dependencies
~36MB
~515K SLoC