UNPKG

aiwg

Version:

Deployment tool and support utility for AI context. Copies agents, skills, commands, rules, and behaviors into the paths each AI platform reads (Claude Code, Codex, Copilot, Cursor, Warp, OpenClaw, and 6 more) so one source of truth works across 10 platfo

233 lines 8.88 kB
/** * Obsidian Storage Adapter * * Writes and reads markdown files in an Obsidian vault on disk. The * adapter is filesystem-shaped — Obsidian vaults are directories of * `.md` files plus a hidden `.obsidian/` config dir we never touch. * * About the official Obsidian CLI: as of Feb 2026 Obsidian ships an * official CLI for vault interactions. Its primary surface is opening * notes / running commands against a *running* Obsidian instance, not * arbitrary external file writes. Obsidian's own file watcher handles * picking up external markdown changes within a few seconds, so direct * fs writes work cleanly when Obsidian is closed and converge quickly * when it's open. * * What this adapter does: * - Reads / writes / lists / deletes markdown files via fs * - Optional CLI reachability probe (when `useCli: true`) — emits a * one-time warning if the user opted into CLI mode but the * `obsidian` binary is missing * - Refuses paths that resolve into the vault's `.obsidian/` config * - Forwards `WriteMeta.frontmatter` into the markdown's YAML * frontmatter when provided * - Does NOT implement `query()` — Obsidian's search is not exposed * via the CLI as of research date * * @design @.aiwg/architecture/storage-design.md (§5.2) * @issue #934 * @issue #957 */ import { existsSync } from 'fs'; import { mkdir, readFile, readdir, rm, stat, writeFile } from 'fs/promises'; import { dirname, join, relative, resolve, sep } from 'path'; import { execSync } from 'child_process'; const OBSIDIAN_CONFIG_DIR = '.obsidian'; export class ObsidianAdapter { /** Absolute path to the root the adapter writes into (vault[/folder]). */ root; /** Absolute path to the vault — used to enforce the .obsidian/ guard. */ vault; useCli; /** Cached result of the CLI reachability probe so we only warn once. */ cliWarned = false; constructor(config) { this.vault = resolve(expandHome(config.vault)); this.root = config.folder ? join(this.vault, config.folder) : this.vault; this.useCli = config.useCli ?? true; } /** * Validate a subsystem-relative path. Rejects traversal, absolute * paths, backslashes, and any path that resolves into the vault's * `.obsidian/` config directory. */ resolveSafe(relPath) { if (typeof relPath !== 'string' || relPath.length === 0) { throw new Error('storage(obsidian): path must be a non-empty string'); } if (relPath.includes('\\')) { throw new Error(`storage(obsidian): backslash not allowed in path "${relPath}"`); } if (relPath.startsWith('/') || relPath.startsWith('~')) { throw new Error(`storage(obsidian): absolute paths not allowed: "${relPath}"`); } const segments = relPath.split('/'); if (segments.some((s) => s === '..')) { throw new Error(`storage(obsidian): ".." traversal not allowed in "${relPath}"`); } const abs = resolve(this.root, relPath); // Must stay under root if (abs !== this.root && !abs.startsWith(this.root + sep)) { throw new Error(`storage(obsidian): resolved path escapes folder root: "${relPath}"`); } // Must never enter .obsidian/ const relFromVault = relative(this.vault, abs); if (relFromVault === OBSIDIAN_CONFIG_DIR || relFromVault.startsWith(OBSIDIAN_CONFIG_DIR + sep)) { throw new Error(`storage(obsidian): refusing to operate on .obsidian/ vault config (${relPath})`); } return abs; } async init() { if (!existsSync(this.vault)) { throw new Error(`storage(obsidian): vault does not exist: ${this.vault}`); } if (this.useCli) this.probeCli(); } /** * One-shot best-effort `obsidian --version` probe. We only warn * (never fail) — direct fs writes still work without the CLI. */ probeCli() { if (this.cliWarned) return; try { execSync('obsidian --version', { stdio: ['ignore', 'pipe', 'pipe'], timeout: 2000, }); } catch { this.cliWarned = true; process.stderr.write(`storage(obsidian): CLI requested (useCli: true) but the \`obsidian\` binary was not found.\n` + ` AIWG will continue with direct file writes against the vault.\n` + ` Set "useCli": false in storage.config to suppress this warning.\n`); } } async read(path) { const abs = this.resolveSafe(path); if (!existsSync(abs)) return null; return readFile(abs, 'utf-8'); } async write(path, content, meta) { const abs = this.resolveSafe(path); await mkdir(dirname(abs), { recursive: true }); // Merge supplied frontmatter into the markdown payload (only when // there isn't already YAML frontmatter at the top — we don't want // to overwrite user-written frontmatter inadvertently). let body = content; if (meta?.frontmatter && Object.keys(meta.frontmatter).length > 0 && !startsWithFrontmatter(content)) { body = renderFrontmatter(meta.frontmatter) + content; } await writeFile(abs, body, 'utf-8'); } async list(prefix) { if (typeof prefix !== 'string') { throw new Error('storage(obsidian): list prefix must be a string'); } if (prefix.length > 0) this.resolveSafe(prefix); if (!existsSync(this.root)) return []; const out = []; await walkInto(this.root, this.root, this.vault, prefix, out); return out; } async delete(path) { const abs = this.resolveSafe(path); if (!existsSync(abs)) return; await rm(abs, { force: true }); } } async function walkInto(root, dir, vault, prefix, out) { let entries; try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; } for (const e of entries) { // Skip the vault config directory anywhere under the walk if (e.name === OBSIDIAN_CONFIG_DIR) { const fullForCheck = join(dir, e.name); // Only skip when this is *the* vault's .obsidian/ — a user-created // folder named ".obsidian" inside content would still be skipped // out of paranoia, which matches the safety stance. if (relative(vault, fullForCheck) === OBSIDIAN_CONFIG_DIR) continue; continue; // be conservative: skip any .obsidian directory } const full = join(dir, e.name); const rel = relative(root, full).split(sep).join('/'); if (e.isDirectory()) { if (prefix === '' || rel.startsWith(prefix) || prefix.startsWith(rel + '/')) { await walkInto(root, full, vault, prefix, out); } continue; } if (!e.isFile()) continue; if (prefix !== '' && !rel.startsWith(prefix)) continue; let size; let modifiedAt; try { const s = await stat(full); size = s.size; modifiedAt = s.mtime; } catch { // ignore } const entry = { path: rel }; if (size !== undefined) entry.size = size; if (modifiedAt !== undefined) entry.modifiedAt = modifiedAt; out.push(entry); } } function expandHome(p) { if (p.startsWith('~/')) { const { homedir } = require('os'); return join(homedir(), p.slice(2)); } if (p === '~') { const { homedir } = require('os'); return homedir(); } return p; } function startsWithFrontmatter(content) { // Obsidian frontmatter convention: starts with `---` on a line by itself return /^---\s*\r?\n/.test(content); } function renderFrontmatter(fm) { const lines = ['---']; for (const [k, v] of Object.entries(fm)) { lines.push(`${k}: ${formatYamlScalar(v)}`); } lines.push('---', ''); return lines.join('\n'); } function formatYamlScalar(v) { if (v === null || v === undefined) return ''; if (typeof v === 'boolean' || typeof v === 'number') return String(v); if (Array.isArray(v)) return '[' + v.map((x) => formatYamlScalar(x)).join(', ') + ']'; if (typeof v === 'string') { // Quote strings containing YAML special characters if (/[:\#\n\r\t&*?{}\[\]|>'"%@`!,]/.test(v) || /^\s|\s$/.test(v) || v.length === 0) { return JSON.stringify(v); } return v; } return JSON.stringify(v); } //# sourceMappingURL=obsidian.js.map