| name | fmql |
| description | Use fmql whenever the user works with a directory of markdown or frontmatter files — notes, tasks, Zettelkasten, an Obsidian vault, a project-management packet collection, a knowledge base, a "digital garden" — and wants to query, aggregate, traverse, or bulk-edit YAML frontmatter fields. Reach for this instead of grep whenever the user's question involves structured fields (status, priority, tags, due_date, assignees, uuid, dependency links like blocked_by or belongs_to) or whenever a mutation needs to apply across many files at once. Also triggers on "find all docs where…", "set status on every overdue task", "describe this workspace", "what's blocking task-X", "cycle detection", "traverse blocked_by", "bulk-edit frontmatter", "query my notes". If the user mentions "frontmatter", "YAML header", "markdown metadata", or points at a directory of .md files, load this skill. |
fmql
fmql (FrontMatter utilities) is a schemaless query engine and editor for directories of markdown files with YAML frontmatter. Think of a folder of .md files as a document collection: fmql lets you query it like a database, traverse references between files like a graph, and edit YAML properties across the whole result set in one command — without any schema setup.
When to reach for fmql (vs. grep or ad-hoc scripts)
Use fmql when any of the following apply:
- The user's filter involves typed comparisons on frontmatter fields (
t.priority > 2, t.due_date < today+0d, t.status IN [...]). grep only does substrings; fmql compares the parsed YAML value.
- The user wants to edit the same field across many files (e.g. "escalate every overdue active task"). fmql preserves comments, key order, and quoting; grep + sed does not.
- The user wants to follow references between files (
blocked_by, belongs_to, in_sprint) — transitively, or to detect cycles.
- The user wants to understand an unfamiliar workspace (what fields exist, what types, what values).
- The user wants to combine a search hit list with a structured filter (e.g. "pages matching 'auth rewrite' that are also
status = in_progress").
Use grep instead when the user just wants a literal substring match in body text and doesn't care about frontmatter structure. Use fmql's search subcommand (with --backend grep — the default) when you want grep semantics but also want the result to pipe into other fmql commands.
Invocation
The CLI entry point is fmql. Install with pip install fmql (or uv run fmql … from inside the fmql source tree). Python 3.11+. No configuration, no init step — point it at any directory of .md files with YAML frontmatter and it works.
For programmatic use (when writing a script rather than running shell commands):
from fmql import Workspace, Query
ws = Workspace("./project")
for packet in Query(ws).where(status="active", priority__gt=2):
print(packet.id, packet.frontmatter)
A "packet" is fmql's word for a single frontmatter file. Each packet has an id (path relative to the workspace root), a frontmatter dict, and a body string.
Command surface
Every command takes --workspace/-w ROOT for the workspace; if omitted, the cwd is used.
Read:
| Command | Purpose | Example |
|---|
query | Run a Cypher query (filter, traversal, aggregation, optional SET/REMOVE) | fmql query 'MATCH (t) WHERE t.status = "active" AND t.priority > 2 RETURN t' -w ./proj |
describe | Introspect a workspace (fields, types, samples) | fmql describe -w ./proj --format json --top 10 |
subgraph | Reachability closure as {nodes, edges} JSON | fmql subgraph 'MATCH (t) WHERE t.uuid = "task-1" RETURN t' -w ./proj --follow blocked_by |
search | Run a search backend (default: grep) | fmql search "alice" -w ./proj -k 10 |
Edit (always Cypher-shaped, applies to whatever MATCH selects):
| Command | Purpose | Example |
|---|
update | Pattern-match and edit packets (MATCH ... [SET|REMOVE]) | fmql update 'MATCH (t) WHERE t.status = "todo" SET t.status = "done"' -w ./proj |
Index / discovery / utility:
| Command | Purpose | Example |
|---|
index | Build an index for an indexed search backend | fmql index --backend semantic -w ./proj |
list-backends | Enumerate installed search backends | fmql list-backends --format json |
version | Print fmql version | fmql version |
Run fmql <command> --help for the full flag list on any command.
Cypher cheatsheet
The whole expression is a single shell argument, so wrap it in quotes. Keywords are case-insensitive.
Pattern: MATCH (var) for "every packet"; MATCH (a)-[:field]->(b) for one hop; *, *N..M for transitive / bounded multi-hop.
Logical operators: AND, OR, NOT, parentheses.
Comparisons: =, != (or <>), <, <=, >, >=.
String / list / existence:
CONTAINS "sub" — substring in string field, or element in list field.
MATCHES "regex" — regex against string.
IN ["a", "b", 3] — membership.
IS EMPTY / IS NOT EMPTY — field missing, null, or empty-sequence.
IS NULL — field explicitly null.
Values: quoted strings ("active"), integers (42), floats (3.14), booleans (true/false), ISO dates (2026-05-01).
Date sentinels: today+0d, today-7d, now+1h, today+30d (the offset suffix is required).
Examples:
fmql query 'MATCH (t) WHERE t.status = "active" AND t.priority > 2 RETURN t' -w ./proj
fmql query 'MATCH (t) WHERE t.due_date < today+0d AND t.status != "done" RETURN t' -w ./proj
fmql query 'MATCH (t) WHERE t.tags CONTAINS "urgent" OR t.priority >= 3 RETURN t' -w ./proj
fmql query 'MATCH (t) WHERE t.status IN ["todo", "in_progress"] RETURN t' -w ./proj
fmql query 'MATCH (t) WHERE NOT (t.assigned_to IS EMPTY) RETURN t' -w ./proj
fmql query 'MATCH (t) WHERE t.title MATCHES "^\\[WIP\\]" RETURN t' -w ./proj
Type honesty — critical: t.priority > 2 only matches packets where priority is an int/float greater than 2. If one file has priority: high (string), it's silently excluded from the result — not coerced, not an error. This is intentional: mixed-type workspaces stay queryable without accidental conversions. If the user is confused about missing results, run fmql describe and look at the observed types per field.
Use MATCH (t) RETURN t to match every packet — handy when you want to start from "everything" and then --follow.
Traversal (--follow)
Once you have a result set, you can walk references out of it:
fmql query 'MATCH (t) WHERE t.uuid = "task-42" RETURN t' -w ./proj --follow blocked_by --depth 1
fmql query 'MATCH (t) WHERE t.uuid = "task-42" RETURN t' -w ./proj --follow blocked_by --depth '*'
fmql query 'MATCH (t) WHERE t.uuid = "task-42" RETURN t' -w ./proj --follow blocked_by --direction reverse
Pick a resolver with --resolver:
path (default) — blocked_by: ../tasks/task-41.md resolves relative to the packet's file.
uuid — blocked_by: task-41 matches the packet whose frontmatter has uuid: task-41.
slug — same idea but matching a slug field.
Unresolvable references are dropped silently. --include-origin keeps the starting packets in the output.
For anything beyond simple one-field walks (multi-hop patterns, multiple relationship types, cycle detection, WHERE clauses on both ends), express the traversal directly in MATCH.
Cypher patterns (multi-hop and graph shapes)
MATCH (a)-[:field]->(b) # single hop
MATCH (a)-[:field*]->(b) # transitive
MATCH (a)-[:field*1..5]->(b) # bounded depth
MATCH (a)-[:blocked_by*]->(a) # cycle detection
WHERE a.status = "active" AND b.priority > 2
RETURN a
RETURN a, b
RETURN a.title
RETURN count(a)
Node labels are parsed but ignored (fmql is schemaless). For single-variable RETURN, output defaults to paths; for multi-var or count/field returns, default is TSV (rows). Use --format json for structured parsing.
fmql query 'MATCH (a)-[:blocked_by*]->(a) RETURN a' -w ./proj
fmql query 'MATCH (a)-[:belongs_to]->(e) WHERE e.type = "epic" RETURN a, e' -w ./proj
Virtual properties
Three computed fields are exposed on every packet — t.path, t.filename, t.slug. They behave like frontmatter fields in WHERE / SET / RETURN. Use them when the user wants to filter or rewrite by file identity:
fmql query 'MATCH (t) WHERE t.path = "tasks/task-42.md" RETURN t' -w ./proj
fmql update 'MATCH (t) WHERE t.title IS EMPTY SET t.title = t.slug' -w ./proj
Frontmatter wins on conflict — if a packet has its own path field, that value shadows the virtual one.
Workspace introspection — always start here
When you're handed an unfamiliar workspace, run describe first. It tells you which fields actually exist, what types they take, and a sample of distinct values. Writing Cypher by guessing field names leads to empty results you'll blame on a bug; describe removes the guesswork:
fmql describe -w ./proj --format json --top 10
JSON output gives you {field, types, sample_values, count} per field — easy to scan, easy to parse.
Bulk edits — fmql update
All edits go through fmql update. Encode the which packets part as a MATCH ... WHERE ... filter, and the what to change part as SET / REMOVE. Required: at least one of SET or REMOVE. Forbidden in update: RETURN and ORDER BY — use fmql query if you need to write and project in one shot.
fmql update 'MATCH (t) WHERE t.due_date < today() AND t.status != "done" SET t.status = "escalated"' \
-w ./proj --dry-run
fmql update 'MATCH (t) WHERE t.due_date < today() AND t.status != "done" SET t.status = "escalated"' \
-w ./proj --yes
SET operators:
SET t.f = expr — replace.
SET t.f += expr — append to a list (creates [expr] if missing).
SET t.f = NOT t.f — toggle a boolean (broadcasts element-wise over lists).
SET t.f = [x IN t.list WHERE pred (| projection)?] — Neo4j-style list comprehension (filter + optional project).
REMOVE clause: REMOVE t.f, t.g, … deletes those keys from each matched packet.
Renames (no native syntax — compose SET + REMOVE):
fmql update 'MATCH (t) WHERE t.assignee IS NOT EMPTY
SET t.assigned_to = t.assignee REMOVE t.assignee' -w ./proj --yes
(The new key lands at the end of the YAML map; the old slot's position/comments are not preserved.)
Drop one item from a list:
fmql update 'MATCH (t) SET t.tags = [x IN t.tags WHERE x <> "deprecated"]' -w ./proj
Safety model. Edits are format-preserving — ruamel.yaml round-trips the file, so comments, key order, quoting, and body bytes on untouched keys survive intact. But the files still get rewritten. Two guardrails:
--dry-run shows a unified diff without writing. Use this first on any result set you can't eyeball manually.
- Without
--yes, the diff prints and the command waits for confirmation at /dev/tty. In a CI/non-TTY environment, pass --yes or the edit will hang.
Search backends
fmql search and fmql query --search run pluggable search backends. The default is grep (literal substring match, zero setup). Check what's available:
fmql list-backends
fmql list-backends --format json
You can combine search with a structured filter in one pipeline:
fmql query 'MATCH (t) WHERE t.status = "in_progress" RETURN t' -w ./proj --search "auth rewrite" --index grep
For meaning-based / hybrid retrieval over a notes vault, the separate fmql-semantic package registers a semantic backend — load the fmql-semantic skill when the user wants semantic search, RAG, embeddings, or hybrid BM25+dense retrieval.
Output formats for agent parsing
--format paths — one packet id per line. Default on query when RETURN is a single packet variable. Default on search. Requires single-packet-var RETURN on query.
--format json — one JSON object per line (NDJSON-ish). query emits {id, frontmatter} for single-packet-var RETURN, otherwise {columns, row}; search emits {id, score, snippet}; describe emits one object describing the whole workspace.
--format rows — TSV. Default on query for multi-var / count / field returns. Available on search.
When you need to parse results, prefer JSON over paths: paths lose the field values, forcing a second query to recover them.
Example workflows
1. First look at an unfamiliar workspace
fmql describe -w ./vault --format json --top 10
Read the output, identify the interesting fields, then narrow down with query.
2. Bulk escalate stale work
fmql update 'MATCH (t) WHERE t.status IN ["todo","in_progress"] AND t.due_date < today() SET t.status = "escalated"' \
-w ./proj --dry-run
fmql update 'MATCH (t) WHERE t.status IN ["todo","in_progress"] AND t.due_date < today() SET t.status = "escalated"' \
-w ./proj --yes
3. "What's blocking task-42?"
fmql query 'MATCH (t) WHERE t.uuid = "task-42" RETURN t' -w ./proj \
--follow blocked_by --depth '*' --resolver uuid --format json
4. Find dependency cycles
fmql query 'MATCH (a)-[:blocked_by*]->(a) RETURN a' -w ./proj
5. Count completed tasks per sprint
from fmql import Workspace, Query, Count
ws = Workspace("./proj")
(
Query(ws)
.where(type="task", status="done")
.group_by("in_sprint")
.aggregate(count=Count())
)
Gotchas
- No TTY → must pass
--yes. Piping into an edit in CI or a sandboxed shell with no /dev/tty will hang at the confirmation prompt.
- Regex backslashes in shells.
MATCHES "^\\[WIP\\]" — the double backslash is needed so the shell passes a single backslash through to the regex engine.
IN list literals use JSON syntax: IN ["a", "b"], commas separate, strings quoted.
- Malformed frontmatter is silently skipped. If
describe reports fewer packets than you expect, a file's YAML probably doesn't parse. Open it manually.
query default format depends on RETURN. Single-packet-var RETURN defaults to paths; multi-var / count / field defaults to TSV. If you pipe to jq, pass --format json.
- Reserved-looking field names are fine. fmql has no schema;
type, status, id are just keys. (Note: fmql uses the file path as the packet id, independent of any id field inside the frontmatter.)
- Python API uses
field__op=value (Django-style) — priority__gt=2, tags__contains="urgent", assigned_to__not_empty=True.
Quick reference — Python operators
When writing a Python script instead of shell commands:
| Operator suffix | Meaning |
|---|
(none) / eq | equals |
ne / not | present and not equal |
gt, gte, lt, lte | typed ordering |
in, not_in | list membership |
contains, icontains | substring (string field) or element (list field); i = case-insensitive |
startswith, endswith | string prefix/suffix |
matches | regex |
exists | field is present |
not_empty | present and non-empty |
is_null | value is explicitly None |
type | type-name match ("int", "str", "list", "date", …) |
Query(ws).where(...) is lazy — iterate it, call .group_by().aggregate(...), chain .follow(...), or call .set(...).dry_run() / .apply() for edits.
If the user's question isn't really about frontmatter-structured data — e.g. they just want to grep through some source files — don't force fmql into it. This skill is for the cases where treating the directory as a queryable collection pays off.