vaultctl config set vault <path> saves the vault path to ~/.vaultctlrc, where agents and cron jobs find it without a shell profile. It shipped in v0.4.0. v0.5.0 added meta for frontmatter. Without a fix, gray-matter writes every unquoted date back as a full ISO timestamp, including dates you never touched.
npm install -g vaultctl
vaultctl config set vault ~/ObsidianVault
config set checks that the folder has an .obsidian/ directory, then writes its absolute path to ~/.vaultctlrc. Both releases are on npm, with no new dependencies.
Don't put the vault path in .zshrc
The README used to say to export VAULTCTL_PATH from ~/.zshrc or ~/.bashrc. zsh reads .zshrc only in interactive shells. Agents, cron jobs and CI pipelines often run non-interactive ones, so the variable may never reach them. vaultctl reads ~/.vaultctlrc itself, so no profile has to be sourced first.
The command validates, then writes one line:
const RC_PATH = join(homedir(), '.vaultctlrc');
// ... inside `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');
Before v0.4.0, vault discovery already read ~/.vaultctlrc, but no command wrote it. The error message told you to create the file yourself.
The rc file is the last place vaultctl looks
vaultctl resolves the vault in this order:
--vault /pathflagVAULTCTL_PATHenvironment variable- Walk up from the current directory looking for
.obsidian/ ~/.vaultctlrc
Anything above step 4 still wins, so existing setups keep working. When all four miss, the error lists them: "Could not find an Obsidian vault. Use --vault, set VAULTCTL_PATH, run from inside a vault, or create ~/.vaultctlrc with the vault path."
Note: A
VAULTCTL_PATHthat points at the wrong folder stops the chain. vaultctl throwsVAULTCTL_PATH set to <path> but no .obsidian directory foundand never reaches the rc file. Unset a stale variable after runningconfig set.
config show doesn't check your current directory
$ vaultctl config show
{
"rc_file": "/Users/me/.vaultctlrc",
"rc_value": "/Users/me/ObsidianVault",
"env_var": null,
"resolved_by": "~/.vaultctlrc",
"resolved_path": "/Users/me/ObsidianVault"
}
It checks the flag, the env var and the rc file. It never walks up from the working directory. It also skips a VAULTCTL_PATH with no .obsidian/, which makes every other command fail unless you pass --vault. With no flag or env var set, run it inside a second vault and it still says ~/.vaultctlrc, while meta uses the vault you are in. When the two disagree, check your working directory and VAULTCTL_PATH.
meta reads and writes one field
Before v0.5.0, read printed the whole note as JSON, and tags add and tags remove changed the tags field. Any other field meant parsing the YAML yourself.
# 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
meta set coerces each value: true and false become booleans, digits only become a number, anything with a comma becomes an array, and everything else stays a string. With no pairs, it exits with Error: No key=value pairs provided. The guard stops a call with nothing to set from rewriting the file.
Note: The comma rule has no escape in v0.5.0.
"title=Hello, world"is saved as the array["Hello", "world"], andid=007is saved as the number7.
gray-matter rewrites dates you didn't touch
gray-matter 4.0.3 parses YAML with js-yaml 3.14.2, which turns an unquoted date into a JavaScript Date. matter.stringify then writes every Date back as a full ISO timestamp, whether you changed that field or not:
# 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
vaultctl walks the frontmatter and replaces every Date with the first ten characters of its ISO string:
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;
}
Note:
normalizeDatesonly looks at top-level keys. A date inside a list or a nested object still comes out as2026-02-01T00:00:00.000Z.
Call it on a copy, after merging the new fields:
export function setMetaFields(
fileContent: string,
fields: Record<string, MetaValue>
): string {
const { frontmatter, body } = parseFrontmatter(fileContent);
// Spread to avoid mutating the gray-matter cached parse result, then normalize
// Date objects back to YYYY-MM-DD strings before re-serializing
const updatedFrontmatter = normalizeDates({ ...frontmatter, ...fields });
return stringifyFrontmatter(updatedFrontmatter as Record<string, MetaValue>, body);
}
Untouched dates now come out quoted, as created: '2026-01-01'. The first write adds the quotes. Later writes leave the line alone. meta get runs the same conversion on read, so it prints 2026-01-01, not the timestamp. In tests, assert with toMatch(/created:.*2026-01-01/). toContain('created: 2026-01-01') fails on the quotes.
gray-matter hands back the same data object on every cache hit
gray-matter caches each parse by input string, and a cache hit returns a shallow copy whose data is the original object, so mutating it changes what the next parse of that string returns. Object.assign(data, fields) counts, and so does a plain assignment. In a test suite, that shows up as results that depend on run order.
vaultctl's health fix engine made the same mistake until 19 February. The fix is one spread:
- const { frontmatter, body } = parseFrontmatter(content);
+ const { frontmatter: rawFm, body } = parseFrontmatter(content);
+ const frontmatter: Record<string, unknown> = { ...rawFm };
gray-matter only caches when you call it without options, and matter.clearCache() empties the cache.