Readme
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 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
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
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 table renders 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 json prints [ ]
- o table prints 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-name and new-name must 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 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:
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 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):
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 containment
list_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