Use the Obsidian CLI to debug, inspect, and test Obsidian plugins during development. Covers plugin reloading, console inspection, runtime evaluation, driving the UI (commands, CDP, screenshots, mobile emulation), frontmatter properties, and common debugging recipes for the gemini-scribe plugin.
Install
npx skillscat add allenhutchison/obsidian-gemini/obsidian-cli Install via the SkillsCat registry.
Obsidian CLI
The Obsidian CLI (obsidian command) provides direct access to a running Obsidian instance from the terminal. It's invaluable for plugin development — you can reload plugins, inspect state, evaluate expressions, drive the UI (open modals, click, type, screenshot), toggle mobile emulation, and view console output without leaving your editor.
The CLI surface is large (100+ commands) and growing. Run obsidian help periodically to spot new capabilities, and obsidian help <command> for per-command flags. Never use obsidian <command> --help — the CLI runs the command rather than printing its help (see Footguns). This skill documents the parts most useful for plugin work.
When to use this skill
- Debugging runtime errors during plugin development
- Verifying plugin state after code changes
- Testing migration logic or settings changes
- Inspecting secrets, settings, and vault state
- Reloading the plugin after a rebuild
- Viewing console errors without opening DevTools
- Driving UI surfaces (open modals, click buttons, type into inputs) for automated testing
- Taking screenshots for regression checks or PR descriptions
- Testing mobile-only code paths from a desktop session
Quick reference
Plugin development essentials
# Reload the plugin after rebuilding (use after `npm run build` or `npm run dev`)
obsidian plugin:reload id=gemini-scribe
# Enable DevTools debugger (programmatic CDP attach — does NOT open the DevTools window)
obsidian dev:debug on
# Open the Electron DevTools window itself (toggle — see footgun note below)
obsidian devtools
# View recent console output (errors, warnings, logs)
obsidian dev:console
obsidian dev:console level=error
obsidian dev:console level=warn
obsidian dev:console limit=100
# View captured errors
obsidian dev:errors
# Clear console buffer
obsidian dev:console clearEvaluating expressions
Use eval to run JavaScript against the live Obsidian instance. The expression has access to the full app object.
# Basic eval
obsidian eval code="app.vault.getName()"
# Check plugin is loaded
obsidian eval code="app.plugins.plugins['gemini-scribe'] !== undefined"
# Read plugin settings
obsidian eval code="JSON.stringify(app.plugins.plugins['gemini-scribe'].settings, null, 2)"
# Check the plugin's API key (via SecretStorage getter)
obsidian eval code="app.plugins.plugins['gemini-scribe'].apiKey"Driving the UI (commands, CDP, screenshots)
The command and dev:cdp commands turn the CLI into a remote control. Combined with dev:screenshot, this is how you exercise UI surfaces from a script (or an agent).
# Execute any registered Obsidian command (palette entry) by ID
obsidian command id=gemini-scribe:open-scheduler
obsidian command id=command-palette:open
# List all available commands (use to verify an ID exists before depending on it)
obsidian commands
obsidian commands filter=gemini-scribe
# Take a screenshot of the current Obsidian window.
# path= is resolved against the VAULT root, not your shell's cwd — pass an absolute path
# unless you really want the PNG inside the vault.
obsidian dev:screenshot path="$PWD/debug-screenshot.png"
# Inspect the DOM
obsidian dev:dom selector=".gemini-agent-view"
obsidian dev:dom selector=".gemini-agent-input" text
obsidian dev:dom selector=".gemini-agent-view" css=display
obsidian dev:dom selector=".scheduler-row" total
# Drive the UI via Chrome DevTools Protocol (clicks, key events, raw evals)
obsidian dev:cdp method=Input.dispatchMouseEvent params='{"type":"mousePressed","x":100,"y":200,"button":"left","clickCount":1}'
obsidian dev:cdp method=Input.dispatchMouseEvent params='{"type":"mouseReleased","x":100,"y":200,"button":"left","clickCount":1}'
obsidian dev:cdp method=Input.insertText params='{"text":"Hello"}'dev:cdp is escape-hatch level — if command + eval + dev:dom get you what you need, prefer those for readability. Reach for CDP when you need precise mouse coordinates (e.g. clicking inside a canvas) or keyboard events the DOM API doesn't expose well.
Mobile emulation
Toggles desktop into mobile-emulated mode so platform-gated code paths (Platform.isMobile, mobile-only CSS, mobile UI affordances) become testable.
obsidian dev:mobile on # Enable mobile emulation. The app reloads automatically.
obsidian dev:mobile off # Disable. The app reloads automatically.Because each toggle reloads the whole app, every open modal and all in-memory plugin state are gone afterwards, and CLI calls issued mid-reload can hang. Wait for it to settle and confirm the mode actually applied before testing anything. Poll with a bounded helper: each call is wrapped in the Perl alarm (see Footguns), a call that times out or errors counts as "not ready yet", and the loop gives up with a clear message instead of spinning forever:
# wait_for_eval <expected> <js>: poll `obsidian eval` until it prints <expected>.
# Each call is capped at 10s by the Perl alarm (macOS has no `timeout`); a hung or failed
# call is treated as "not ready yet". Gives up after 20 attempts (~4 min worst case).
wait_for_eval() {
expected=$1; js=$2; out=""
for attempt in $(seq 1 20); do
out=$(perl -e 'alarm shift; exec @ARGV' 10 obsidian eval code="$js" 2>/dev/null | sed 's/^=> //')
[ "$out" = "$expected" ] && return 0
sleep 2
done
echo "Gave up after 20 attempts: '$js' never printed '$expected' (last output: '$out')." >&2
return 1
}
# After `dev:mobile on`: mobile class present and the plugin is back
wait_for_eval true "document.body.classList.contains('is-mobile') && Boolean(app.plugins.plugins['gemini-scribe'])"
# After `dev:mobile off`: mobile class gone and the plugin is back
wait_for_eval true "document.body.classList.contains('is-mobile') === false && Boolean(app.plugins.plugins['gemini-scribe'])"If wait_for_eval gives up, stop and investigate (check the app window, obsidian dev:errors) rather than testing against a half-applied toggle.
Footgun: invoking obsidian dev:mobile with no argument toggles the current state — that's how you accidentally enable it. Always pass on or off explicitly. Always toggle off when you're done — the flag persists across CLI invocations and silently changes the app's behaviour for whoever next opens it.
Frontmatter properties
Use the property:* family for frontmatter reads/writes — it goes through Obsidian's property cache, so it stays in sync with the metadata index. Don't reach for read + manual YAML parsing for properties.
# Read all unique property names known to the vault
obsidian properties
# Read a single property from a file
obsidian property:read name=schedule path="gemini-scribe/Scheduled-Tasks/daily-summary.md"
# Set a property (creates it if it doesn't exist)
obsidian property:set name=enabled value=true type=checkbox path="gemini-scribe/Scheduled-Tasks/daily-summary.md"
# Remove a property
obsidian property:remove name=lastRunAt path="gemini-scribe/Scheduled-Tasks/daily-summary.md"type= is one of text|list|number|checkbox|date|datetime. For frontmatter changes from a plugin, prefer app.fileManager.processFrontMatter() (project convention — see AGENTS.md); the CLI is for ad-hoc testing.
Hotkeys
obsidian hotkey id=gemini-scribe:open-scheduler # what's bound to this command?
obsidian hotkeys # all hotkeys
obsidian hotkeys all verbose # include commands without hotkeys, mark custom vs defaultSecret storage
# List all secrets in the vault
obsidian eval code="app.secretStorage.listSecrets()"
# Read a specific secret value
obsidian eval code="app.secretStorage.getSecret('my-secret-name')"
# Set a secret
obsidian eval code="app.secretStorage.setSecret('my-secret-name', 'my-secret-value')"Vault and file operations
# Vault info
obsidian vault
# List vaults (desktop only)
obsidian vaults
# List files
obsidian files
obsidian files folder=gemini-scribe
obsidian files ext=md total
# Read a file
obsidian read path="gemini-scribe/Agent-Sessions/session.md"
# CRUD (useful for setting up test fixtures)
obsidian create name=test-task path="gemini-scribe/Scheduled-Tasks/test-task.md" content="..."
obsidian append path="some-file.md" content="more text"
obsidian prepend path="some-file.md" content="prefix"
obsidian rename path="old.md" name=new.md
obsidian move path="some-file.md" folder=archive
obsidian delete path="some-file.md"
# Open a file in the editor
obsidian open path="gemini-scribe/Agent-Sessions/session.md" newtab
# Search vault contents
obsidian search query="apiKey"
obsidian search:context query="apiKey" limit=5
# Check file info
obsidian file path="data.json"
# Tab management
obsidian tabs
obsidian tab:open path="..."Plugin management
# List all plugins
obsidian plugins
obsidian plugins filter=community versions
# Get plugin info
obsidian plugin id=gemini-scribe
# Enable/disable
obsidian plugin:enable id=gemini-scribe
obsidian plugin:disable id=gemini-scribe
# Reload after code changes
obsidian plugin:reload id=gemini-scribe
# Install/uninstall community plugins (for testing compatibility)
obsidian plugin:install id=some-plugin enable
obsidian plugin:uninstall id=some-plugin
# Restricted mode (toggle or check)
obsidian plugins:restrict on
obsidian plugins:restrict off
obsidian plugins:restrict # report current stateApp lifecycle
obsidian reload # Reload the current vault (preserves Obsidian process)
obsidian restart # Restart the Obsidian app entirelyplugin:reload id=gemini-scribe is almost always what you want during development. Reach for reload / restart only when investigating something that survives a per-plugin reload.
CSS snippets and themes
Useful when verifying the plugin doesn't break with non-default themes or third-party styling.
obsidian themes # installed themes
obsidian theme:set name="Catppuccin" # switch theme
obsidian snippets # installed snippets
obsidian snippets:enabled # only enabled
obsidian snippet:enable name="my-overrides"
obsidian snippet:disable name="my-overrides"Common recipes
Test a fresh install
Remove data.json to simulate a new install, then reload:
# Remove plugin settings (simulates fresh install)
obsidian eval code="app.vault.adapter.remove('.obsidian/plugins/gemini-scribe/data.json')"
# Reload the plugin
obsidian plugin:reload id=gemini-scribe
# Verify settings are defaults
obsidian eval code="JSON.stringify(app.plugins.plugins['gemini-scribe'].settings, null, 2)"Debug a settings migration
# Check current settings before migration
obsidian eval code="JSON.stringify(app.plugins.plugins['gemini-scribe'].settings, null, 2)"
# Rebuild and reload
npm run build && obsidian plugin:reload id=gemini-scribe
# Check settings after migration
obsidian eval code="JSON.stringify(app.plugins.plugins['gemini-scribe'].settings, null, 2)"
# Check console for migration logs
obsidian dev:console level=logVerify a documented command exists
Useful before depending on a command ID in a doc, test, or skill:
obsidian commands filter=gemini-scribe # quick visual check
obsidian commands filter=gemini-scribe-ope # narrower
obsidian eval code="Boolean(app.commands.findCommand('gemini-scribe:open-scheduler'))"Open a UI surface and screenshot it
Pattern: trigger → settle → screenshot → inspect → close.
# Trigger
obsidian command id=gemini-scribe:open-scheduler
# Settle (animations, lazy renders)
sleep 1
# Screenshot (absolute path — path= is vault-relative otherwise)
obsidian dev:screenshot path="$PWD/scheduler-modal.png"
# Inspect DOM
obsidian dev:dom selector=".gemini-scheduler-schedule-row" text
# Close: Escape at the focused element (there is no .modal-close-button to click)
obsidian eval code="(document.activeElement||document.body).dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',code:'Escape',bubbles:true}))"
obsidian eval code="document.querySelectorAll('.modal-container').length" # expect 0; repeat Escape per stacked modalTest a mobile-only code path
# wait_for_eval: the bounded poll helper defined under "Mobile emulation" above
obsidian dev:mobile on # reloads the whole app — platform-gated code re-runs on its own
sleep 3
wait_for_eval true "document.body.classList.contains('is-mobile') && Boolean(app.plugins.plugins['gemini-scribe'])" \
|| exit 1 # never test while the toggle is half-applied
obsidian dev:screenshot path="$PWD/mobile-view.png"
# … exercise the mobile path …
obsidian dev:mobile off # ALWAYS revert (reloads again)
sleep 3
wait_for_eval true "document.body.classList.contains('is-mobile') === false && Boolean(app.plugins.plugins['gemini-scribe'])" || exit 1Click a specific button via the DOM
Most of the time command + eval is enough. When you need an actual click (e.g. an event handler that requires a real mouse event), grab the element's bounding rect and use CDP:
obsidian eval code="(() => { const el = document.querySelector('.gemini-scheduler-new-btn'); if (!el) return null; const r = el.getBoundingClientRect(); return {x: r.x + r.width/2, y: r.y + r.height/2}; })()"
# Then dispatch a mouse press + release at those coords via dev:cdp Input.dispatchMouseEventFor most click-equivalent needs, eval code="document.querySelector('...').click()" is simpler and works fine.
Simulate the catch-up modal
Useful for exercising PR #723's auto-open-on-mobile behavior or any catch-up code path without waiting for real overdue tasks:
# 1. Snapshot current state file (so you can restore it)
obsidian read path="gemini-scribe/Scheduled-Tasks/scheduled-tasks-state.json" > /tmp/state-backup.json
# 2. Plant an overdue nextRunAt
obsidian eval code="(async () => {
const path = 'gemini-scribe/Scheduled-Tasks/scheduled-tasks-state.json';
const cur = JSON.parse(await app.vault.adapter.read(path));
Object.values(cur)[0].nextRunAt = new Date(Date.now() - 60_000).toISOString();
await app.vault.adapter.write(path, JSON.stringify(cur, null, 2));
})()"
# 3. Reload the plugin to trigger handleCatchUp()
obsidian plugin:reload id=gemini-scribe
# 4. Restore (after you're done observing)
obsidian eval code="app.vault.adapter.write('gemini-scribe/Scheduled-Tasks/scheduled-tasks-state.json', $(cat /tmp/state-backup.json | jq -Rs .))"
obsidian plugin:reload id=gemini-scribeInspect agent session state
# List session files
obsidian files folder=gemini-scribe/Agent-Sessions
# Check current session context
obsidian eval code="JSON.stringify(app.plugins.plugins['gemini-scribe'].agentView?.currentSession?.context, null, 2)"Check for errors after a change
# Full cycle: build, reload, check for errors
npm run build && obsidian plugin:reload id=gemini-scribe && sleep 1 && obsidian dev:errorsRead or modify a frontmatter property
obsidian property:read name=schedule path="gemini-scribe/Scheduled-Tasks/daily-summary.md"
obsidian property:set name=enabled value=false type=checkbox path="gemini-scribe/Scheduled-Tasks/daily-summary.md"
obsidian property:remove name=outputPath path="gemini-scribe/Scheduled-Tasks/daily-summary.md"Target a specific vault
Critical: empirically (verified May 2026) the vault=<name> flag does not actually route by name. The CLI always targets the currently focused Obsidian window, regardless of what's passed. obsidian vaults lists every registered vault; that list is informational only — you cannot redirect a CLI call to a non-focused vault. This may change in a future Obsidian release — re-verify periodically.
What this means in practice:
- To target a specific vault, make it the focused Obsidian window first (click it, or use macOS
Cmd-Tab/ WindowsAlt-Tab). - The
vault=<name>flag is effectively decorative right now. Continue passing it (it documents intent and may start working in a future release), but don't trust it. - Always preflight with
obsidian eval code="app.vault.getName()"— novault=flag, just read what's actually focused.
obsidian vaults # all registered vaults (open or not)
obsidian eval code="app.vault.getName()" # what the CLI will ACTUALLY hit (focused vault)Canonical preflight guard for any script or skill that's about to do destructive work:
EXPECTED="Test Vault"
ACTIVE=$(obsidian eval code="app.vault.getName()" | sed 's/^=> //')
if [ "$ACTIVE" != "$EXPECTED" ]; then
echo "Aborting: focused vault is \"$ACTIVE\", expected \"$EXPECTED\"." >&2
echo "Switch your Obsidian focus to \"$EXPECTED\" (Cmd-Tab on macOS) and retry." >&2
exit 1
fiMultiple Obsidian windows can run simultaneously (one per vault). Open the test vault in its own window and switch to it before running automated checks against it.
CLI syntax notes
- Arguments use
key=valueformat (no dashes) - Quote values containing spaces:
code="app.vault.getName()" - Boolean flags are bare keywords:
obsidian files total - File resolution:
file=resolves by name (like wikilinks),path=is exact - Most commands default to the active file when
file/pathis omitted - Use
\nfor newline and\tfor tab in content values obsidian help <cmd>prints the command's full parameter list — discover this rather than guess.obsidian helplists every command. Noteobsidian help <unknown>printsNo commands matching "<unknown>".and still exits 0, so check its output, not$?
Footguns
- Never probe a command with
--help.obsidian <cmd> --helpis not a help flag — the CLI runs the command (empirical, Sep 2026:dev:mobile --helptoggled mobile emulation and reloaded the app). Treat every command (includingplugin:reloadanddev:debug) as unsafe to probe that way. Useobsidian help <cmd>, which only prints. dev:mobiletoggles when called with no argument. Always passonoroff. The state persists across CLI invocations and across Obsidian restarts. Toggle off as soon as you're done with the mobile sub-pass.dev:mobile on|offreloads the app. Open modals and in-memory state are lost, and calls issued mid-reload can hang. Polldocument.body.classList.contains('is-mobile')with the boundedwait_for_evalhelper (see Mobile emulation) until it matches the mode you asked for before testing — never record results after a half-applied toggle.dev:screenshot path=is vault-relative. A relative path lands the PNG inside the vault (empirical, Sep 2026). Pass an absolute path.- Read toggle state from the label, not the input. Obsidian's settings toggles are a
label.checkbox-containerthat gainsis-enabledwhen on; the hiddeninput.checkedisn't a reliable read of what the user sees (empirical, Sep 2026). Assert withel.classList.contains('is-enabled'). - CLI calls can hang. A call issued while the app is reloading can block indefinitely, and macOS has no
timeoutbinary. Wrap calls in scripts with a Perl alarm:perl -e 'alarm shift; exec @ARGV' 20 obsidian eval code="..."(exits non-zero if the alarm fires). devtoolstoggles when called with no argument. Same pattern — leaves the DevTools window open or shut depending on prior state. If you need a known state, query first viaobsidian eval code="!!document.querySelector('.is-developer-tools-open')"or just call it twice.dev:debug on≠ opening DevTools. It attaches a Chrome DevTools Protocol debugger so commands likedev:cdpwork. To open the actual DevTools window, usedevtools.plugin:reloadreturns success even when the plugin'sonloadthrew. Always follow withobsidian dev:errors(ordev:console level=error) to confirm a clean load.commandslists every Obsidian command, not just plugin-owned ones. Filter withfilter=<prefix>to narrow.vaultsis desktop-only. Returns "only available on desktop" if invoked in a non-desktop context.- Some commands are plugin-conditional. For example,
dev:cssexists only when a particular dev plugin is enabled; the CLI returns "Command 'dev:css' not found. It may require a plugin to be enabled." Don't depend on conditional commands without first checking they're available. vault=<name>does not actually route by name. Empirically the CLI always targets the focused Obsidian window regardless of whatvault=is set to. The flag does not error on a bogus value, does not error on a real-but-different-vault value — it just silently hits the focused window. To target a specific vault, focus its Obsidian window first. Always preflight withobsidian eval code="app.vault.getName()"(novault=flag — read the truth) and assert it matches the expected vault before any destructive command. See "Target a specific vault" for the canonical guard.- There is no
.modal-close-button. Clicking it is a silent no-op (empirical, Sep 2026). Close a modal by dispatching Escape at the focused element:obsidian eval code="(document.activeElement||document.body).dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',code:'Escape',bubbles:true}))". - Modals stack. If a previous test left a modal open, the next screenshot will be wrong. One Escape closes only the topmost modal, so repeat the dispatch until
document.querySelectorAll('.modal-container').lengthis 0 before opening a new surface. - Screenshot timing. DOM updates are async.
sleep 1is the floor; for animations or first-time renders,sleep 2. If a screenshot looks blank, retry with a longer settle. reloadreloads the vault;restartrestarts the app.plugin:reload id=...is what you almost always want during development. Don't reach forreload/restartunless investigating something that survives a per-plugin reload.
Troubleshooting
CLI not found
The Obsidian CLI requires Obsidian desktop. Ensure it's installed and accessible from your terminal. Check obsidian version to verify.
Eval returns undefined
The expression may not return a value. Wrap in JSON.stringify() for objects, or ensure the expression actually produces a result. For async expressions, wrap in an IIFE: code="(async () => { … return result; })()".
Plugin not found after reload
Check that the plugin ID is correct (gemini-scribe, not obsidian-gemini) and that the plugin is enabled:
obsidian plugins:enabled filter=communityCommand ID does not exist
Use obsidian commands filter=<prefix> to confirm the ID. Plugin commands are namespaced — gemini-scribe:open-scheduler, not open-scheduler. The plugin must be enabled for its commands to register.
Screenshot is blank or stale
Settle longer (sleep 2+) and confirm the DOM actually has what you expect via dev:dom selector=.... If the screenshot still looks wrong, take a baseline first (dev:screenshot path="$PWD/before.png") so you can diff against the expected state.
Mobile emulation appears stuck on
Run obsidian dev:mobile off and then reload. If you don't know whether it was last toggled on or off, obsidian eval code="document.body.classList.contains('is-mobile')" will tell you.