/** * Configuration loading for obsidian-cli extension. * * Reads the `obsidianCli` key from pi's main settings.json files: * - global: $PI_CODING_AGENT_DIR/settings.json (~/.config/pi/agent/settings.json) * - project: /.pi/settings.json (only when project is trusted) * * Project settings merge on top of global settings. `exclude` patterns are * concatenated, while other project values (including permissionMode and * include) override global values when the project is trusted. */ import { readFileSync } from "node:fs"; import { join } from "node:path"; export type PermissionMode = "all" | "read-only" | "custom"; export type ContextMode = "active-tab" | "manual"; /** A named tool-level permission profile that scopes what obsidian * commands an `obsidian_` variant tool may execute. Profiles * inherit from the global config and only override permission fields. */ export interface ToolProfile { permissionMode: PermissionMode; include?: string[]; exclude?: string[]; confirmDestructive?: boolean; } export interface ObsidianCliConfig { /** Path/name of the Obsidian CLI binary. Default: "obsidian" */ binary: string; /** When true, allow the CLI to launch Obsidian if it is not running. Default: false. */ autoLaunch: boolean; /** Default vault (name or id) to run commands against. */ vault?: string; /** * Permission mode: * - "all" → every discovered command (minus exclude) * - "read-only" → only commands classified as read-only * - "custom" → only commands matching `include` (minus exclude) */ permissionMode: PermissionMode; /** Glob-ish patterns (`dev:*`, `sync:*`, exact names). Only used in "custom" mode. */ include: string[]; /** Patterns that are always blocked. Wins over everything. */ exclude: string[]; /** Also register one dedicated read-only tool per command (obsidian_read, ...). */ exposeReadOnlyTools: boolean; /** * Give each dedicated tool a one-line promptSnippet so it appears in the * system prompt "Available tools" section (like read/bash/edit/write). * Disable to save ~600 prompt tokens; tools stay callable regardless. */ promptSnippets: boolean; /** Ask for interactive confirmation before destructive/dangerous commands. */ confirmDestructive: boolean; /** Per-invocation timeout of the obsidian binary. */ timeoutMs: number; /** Max bytes of CLI output sent back to the LLM. */ maxOutputBytes: number; /** Show vault + permission mode in the footer status bar. */ statusBar: boolean; /** * When true, dedicated tools request format=json where the CLI supports * it, then parse and reformat into compact aligned text for the LLM. * TSV-only commands still get formatTsv applied. Default: true. */ preferJson: boolean; /** * How tools resolve implicit file/path parameters: * - "active-tab" → use the file currently open in Obsidian as default * - "manual" → never inject context; params must be explicit * The active-file probe uses an audited fixed script when * `allowFixedScripts` is enabled. */ contextMode: ContextMode; /** * When true, dedicated tools that accept file/path will resolve * [[wikilinks]] to actual vault paths before executing. Default: true. */ resolveWikilinks: boolean; /** * Named permission profiles that generate additional `obsidian_` * tool variants. Empty by default — write-capable profiles are opt-in. * Copy `DEFAULT_TOOL_PROFILES` into settings.json if planner/documenter * subagents need `obsidian_readwrite` / `obsidian_full`. * * Example: * "toolProfiles": { * "readwrite": { "permissionMode": "custom", "include": ["read", "search", "create"] }, * "full": { "permissionMode": "all" } * } * * Registers tools: obsidian_readwrite, obsidian_full. */ toolProfiles?: Record; /** * Run audited, immutable eval wrappers (Excalidraw, Dataview DQL, Tasks * cache, active-file, resolve-link, daily create) without allowing the * generic `obsidian` tool to execute `command=eval`. Does not remove * `eval` from `exclude`. Default: true. */ allowFixedScripts: boolean; /** Allow user-supplied DataviewJS execution. Default: false. */ allowDataviewJs: boolean; } /** * User-configurable default exclusions. These are applied when the user does * not explicitly set `obsidianCli.exclude`. In `custom` mode a user can * override this list entirely to enable powerful commands such as `eval`. */ export const DEFAULT_EXCLUDE = [ "eval", // arbitrary JS execution inside the renderer "command", // executes arbitrary Obsidian commands "dev:cdp", // raw Chrome DevTools protocol "dev:debug", "restart", "reload", "plugin:install", "plugin:uninstall", "theme:install", "theme:uninstall", ]; /** Opt-in tool profiles. Not applied unless the user copies them into * `obsidianCli.toolProfiles`. Kept here so README / subagent docs can * reference a known-good readwrite + full pair. */ export const DEFAULT_TOOL_PROFILES: Record = { readwrite: { permissionMode: "custom", include: [ "read", "search*", "files", "folders", "folder", "tags", "tasks", "backlinks", "links", "unresolved", "orphans", "deadends", "outline", "daily*", "template*", "history*", "sync*", "base*", "random*", "recents", "vault*", "version", "help", "commands", "workspace", "tabs", "plugins*", "snippets*", "themes*", "hotkey*", "create", "append", "prepend", "move", "property:set", "property:remove", ], confirmDestructive: true, }, full: { permissionMode: "all", confirmDestructive: true, }, }; export const DEFAULT_CONFIG: ObsidianCliConfig = { binary: "obsidian", autoLaunch: false, vault: undefined, permissionMode: "read-only", include: [], exclude: [...DEFAULT_EXCLUDE], exposeReadOnlyTools: true, promptSnippets: true, confirmDestructive: true, timeoutMs: 30_000, maxOutputBytes: 50 * 1024, statusBar: true, preferJson: true, contextMode: "active-tab", resolveWikilinks: true, toolProfiles: {}, allowFixedScripts: true, allowDataviewJs: false, }; const VALID_MODES: PermissionMode[] = ["all", "read-only", "custom"]; function readSettingsFile(path: string): Record | undefined { try { return JSON.parse(readFileSync(path, "utf8")) as Record; } catch { return undefined; } } function pickObsidianCli(raw: Record | undefined): Partial | undefined { const section = raw?.["obsidianCli"]; if (!section || typeof section !== "object" || Array.isArray(section)) return undefined; return section as Partial; } function sanitize(partial: Partial): Partial { const out: Partial = { ...partial }; if (out.permissionMode && !VALID_MODES.includes(out.permissionMode)) { delete out.permissionMode; } if (out.include && !Array.isArray(out.include)) delete out.include; if (out.exclude && !Array.isArray(out.exclude)) delete out.exclude; if (out.exposeReadOnlyTools !== undefined && typeof out.exposeReadOnlyTools !== "boolean") { delete out.exposeReadOnlyTools; } if (out.promptSnippets !== undefined && typeof out.promptSnippets !== "boolean") { delete out.promptSnippets; } if (out.autoLaunch !== undefined && typeof out.autoLaunch !== "boolean") { delete out.autoLaunch; } if (out.timeoutMs !== undefined && (typeof out.timeoutMs !== "number" || out.timeoutMs <= 0)) { delete out.timeoutMs; } if (out.maxOutputBytes !== undefined && (typeof out.maxOutputBytes !== "number" || out.maxOutputBytes <= 0)) { delete out.maxOutputBytes; } if (out.preferJson !== undefined && typeof out.preferJson !== "boolean") { delete out.preferJson; } if (out.contextMode !== undefined && !["active-tab", "manual"].includes(out.contextMode)) { delete out.contextMode; } if (out.resolveWikilinks !== undefined && typeof out.resolveWikilinks !== "boolean") { delete out.resolveWikilinks; } if (out.allowFixedScripts !== undefined && typeof out.allowFixedScripts !== "boolean") { delete out.allowFixedScripts; } if (out.allowDataviewJs !== undefined && typeof out.allowDataviewJs !== "boolean") { delete out.allowDataviewJs; } return out; } export interface LoadConfigOptions { /** pi agent dir (PI_CODING_AGENT_DIR). */ agentDir: string; /** Current working directory (for project settings). */ cwd: string; /** Whether project-local settings may be honored. */ projectTrusted: boolean; /** Config directory name (".pi" normally, rebrands may differ). */ configDirName: string; } export function loadConfig(opts: LoadConfigOptions): ObsidianCliConfig { const globalSettings = readSettingsFile(join(opts.agentDir, "settings.json")); const globalSection = sanitize(pickObsidianCli(globalSettings) ?? {}); let projectSection: Partial = {}; if (opts.projectTrusted) { const projectSettings = readSettingsFile(join(opts.cwd, opts.configDirName, "settings.json")); projectSection = sanitize(pickObsidianCli(projectSettings) ?? {}); } // Global exclude list: if the user explicitly sets one, honor it; // otherwise fall back to the safe defaults. Project excludes are merged on // top and therefore always tighten the effective block list. const globalExcludes = globalSection.exclude ?? DEFAULT_CONFIG.exclude; const projectExcludes = projectSection.exclude ?? []; // Profiles are opt-in. Omitting the key (or `{}`) registers none. const toolProfiles = projectSection.toolProfiles ?? globalSection.toolProfiles ?? {}; const merged: ObsidianCliConfig = { ...DEFAULT_CONFIG, ...globalSection, ...projectSection, exclude: [...globalExcludes, ...projectExcludes].filter((v, i, a) => a.indexOf(v) === i), // include: project replaces global (defines the exact surface in custom mode) include: projectSection.include ?? globalSection.include ?? [], toolProfiles, }; // In custom mode with an empty include list, fall back to read-only to // avoid accidentally exposing nothing-useful or everything. if (merged.permissionMode === "custom" && merged.include.length === 0) { merged.permissionMode = "read-only"; } return merged; } /** Simple glob matching: exact, `prefix*`, `*suffix`, `*contains*`. */ export function matchPattern(pattern: string, value: string): boolean { if (pattern === value) return true; const starts = pattern.startsWith("*"); const ends = pattern.endsWith("*"); const core = pattern.replace(/^\*+|\*+$/g, ""); if (!core) return false; if (starts && ends) return value.includes(core); if (ends) return value.startsWith(core); if (starts) return value.endsWith(core); return false; } export function matchesAny(patterns: string[], value: string): boolean { return patterns.some((p) => matchPattern(p, value)); }