← cd /blog

Article

vaultctl: Config and Frontmatter

·
buildstools

Built a CLI to manage my Obsidian vault. The first thing you had to do after installing it was open your shell profile and add an environment variable. Classic.

Worse: it doesn't work in non-interactive shells. Agents, cron jobs, CI pipelines. None of them source your .zshrc. The exact environments vaultctl was built for couldn't find the vault without --vault on every single call. Somehow still telling people to edit dotfiles.

The config fix

npm install -g vaultctl
vaultctl config set vault ~/Documents/ObsidianVault

That's the entire setup now. config set resolves the path, validates that .obsidian/ exists, and writes it to ~/.vaultctlrc. File reads work everywhere: interactive, non-interactive, agents, cron, CI.

How it works

const RC_PATH = join(homedir(), '.vaultctlrc');

// config set vault <path>
const abs = resolve(value);
if (!existsSync(join(abs, '.obsidian'))) {
  console.error(`No .obsidian directory found at: ${abs}`);
  process.exit(1);
}
writeFileSync(RC_PATH, abs + '\n', 'utf-8');

The discovery chain already supported ~/.vaultctlrc as step 4. There just wasn't a command to write it. Now there is.

config show

$ vaultctl config show
{
  "rc_file": "/Users/alfie/.vaultctlrc",
  "rc_value": "/Users/alfie/Documents/ObsidianVault",
  "env_var": null,
  "resolved_by": "~/.vaultctlrc",
  "resolved_path": "/Users/alfie/Documents/ObsidianVault"
}

Shows the current config, which discovery method resolved, and whether the env var is set. Useful for debugging "why can't vaultctl find my vault," which was the most common question, mostly from me.

The discovery chain

For reference, vaultctl resolves your vault in this order:

  1. --vault /path flag (highest precedence)
  2. VAULTCTL_PATH environment variable
  3. Walk up from current directory looking for .obsidian/
  4. ~/.vaultctlrc config file

Four steps to answer "where are your markdown files." config set writes to step 4. The others still work and take precedence, so existing setups aren't broken.

meta: frontmatter without opening the note

The other gap was frontmatter. vaultctl could search notes, manage tags, check health, but couldn't read or write a single frontmatter field without the caller doing their own YAML parsing. Callers were using filesystem Edit with exact string matching to update fields like status and updated. Fragile.

# Read a field
vaultctl meta get 03_Resources/tools/my-tool.md status
# → { "path": "...", "field": "status", "value": "active" }

# Set one or more fields
vaultctl meta set 01_Projects/my-project.md updated=2026-02-17 status=active

# Type coercion happens automatically
vaultctl meta set note.md count=5          # → number
vaultctl meta set note.md draft=true       # → boolean
vaultctl meta set note.md tags=a,b,c       # → string array

The core functions live in @vaultctl/core, pure TypeScript, no I/O. The CLI just wires them to the filesystem:

export function setMetaFields(fileContent: string, fields: Record<string, MetaValue>): string {
  const { frontmatter, body } = parseFrontmatter(fileContent);
  const updatedFrontmatter = normalizeDates({ ...frontmatter, ...fields });
  return stringifyFrontmatter(updatedFrontmatter as Record<string, MetaValue>, body);
}

That spread operator and normalizeDates call are doing more work than they look like.

gray-matter will corrupt your dates

gray-matter uses js-yaml under the hood. js-yaml parses unquoted YAML dates as JavaScript Date objects:

created: 2026-01-01    # ← parsed as Date, not string
updated: 2026-01-15    # ← also parsed as Date
status: active         # ← string, fine

When you read a field, you get a Date object that JSON-serializes as 2026-01-01T00:00:00.000Z. Annoying but manageable. The real problem is writes.

If you update any field (say, status) and re-serialize the frontmatter, gray-matter re-stringifies every Date object as an ISO string. Fields you didn't touch get corrupted:

# Before: valid Obsidian frontmatter
created: 2026-01-01
updated: 2026-01-15

# After setting status=paused (without the fix):
created: 2026-01-01T00:00:00.000Z   # corrupted
updated: 2026-01-15T00:00:00.000Z   # corrupted
status: paused

Three date fields you never asked to change, now full of ISO garbage. Obsidian doesn't care. It'll parse either format. But your Dataview queries, your templates, and your sanity do.

The fix

Walk the frontmatter object before stringifying and convert any Date instances back to YYYY-MM-DD:

function normalizeDates(fm: Record<string, unknown>): Record<string, unknown> {
  const result: Record<string, unknown> = {};
  for (const [key, value] of Object.entries(fm)) {
    result[key] = value instanceof Date ? value.toISOString().slice(0, 10) : value;
  }
  return result;
}

After the fix, dates come out as quoted strings (created: '2026-01-01'), which is a one-time format change. Same semantics. YAML doesn't care. Obsidian doesn't care. Your tests might. Use toMatch(/created:.*2026-01-01/) instead of toContain if you're asserting on YAML output.

The other gray-matter trap

gray-matter caches parsed results keyed by input string. If you mutate the returned frontmatter object (say, with Object.assign(data, fields)) you corrupt the cache. Every subsequent call on the same string sees the mutated state. Tests pass individually, fail as a suite. The fix is the spread: { ...frontmatter, ...fields } creates a new object instead of mutating the cached one.

v0.4.0

Available on npm. 102 tests. Two new command groups, zero new dependencies.

npm install -g vaultctl