#notes #tags #artificial-intelligence #properties-file #querying #markdown #ai-agent #obsidian #template-file #command-line-tool

bin+lib markbase

A high-performance CLI tool for indexing and querying Markdown files for AI agent. Obsidian-compatible.

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

MIT license

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.

Ask DeepWiki

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., #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.namename column
note.* Frontmatter JSON extraction note.authorproperties->"author"
bare (no prefix) Frontmatter JSON extraction (shorthand for note.*) authorproperties->"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

Dependencies

~36MB
~515K SLoC