Install
openclaw skills install obsidian-plugin-dev-skillComprehensive skill for Obsidian plugin development with TypeScript. Covers plugin lifecycle, CodeMirror 6 editor extensions, React/Svelte integration, Vault API patterns, settings with migration pipelines, SecretStorage, CSS theming, CLI debugging workflow, testing, CI/CD, and community plugin submission. Trigger on: create obsidian plugin, obsidian plugin dev, obsidian API, obsidian editor extension, obsidian CM6, obsidian view, obsidian modal, obsidian settings, obsidian command, obsidian manifest, obsidian publish, obsidian submit plugin, obsidian plugin test, obsidian vite config, obsidian react, obsidian theme, obsidian CLI debug.
openclaw skills install obsidian-plugin-dev-skillUse this skill when the user is:
| # | Rule | Why |
|---|---|---|
| 1 | Never use global app — use this.app | Global app breaks in multi-window; submission rejected |
| 2 | Never use innerHTML/outerHTML — use createEl(), createDiv(), setText() | XSS vulnerability; instant rejection |
| 3 | Use registerEvent() for all event subscriptions | Auto-cleanup on unload; prevents memory leaks |
| 4 | No default hotkeys — let users configure | Hotkey conflicts with other plugins |
| 5 | Use requestUrl() over fetch() | Bypasses CORS; works on mobile |
| 6 | Use normalizePath() for user-provided paths | Handles cross-platform path differences |
| 7 | Prefer vault.process() over vault.modify() | Atomic operation; safe with concurrent edits |
| 8 | Use FileManager.processFrontMatter() for YAML | Never parse/serialize YAML manually |
| 9 | Use Sentence case for all UI text | Obsidian convention; submission requirement |
| 10 | Use setHeading() not <h1>/<h2> | Semantic; supports RTL; submission requirement |
| 11 | Import only what you use — no unused classes | Cleaner code; easier audits; submission reviewers check this |
| 12 | Use checkCallback when command depends on context | callback = always available; checkCallback = conditionally shown; editorCallback = needs editor |
| 13 | Always provide .theme-dark / .theme-light CSS variants | Obsidian CSS vars auto-adapt, but explicit theme blocks ensure edge cases render correctly; submission reviewers check this |
| 14 | No regex lookbehind — (?!...) OK, (?<=...) NOT OK | Breaks on iOS Safari < 16.4; submission rejected |
| 15 | All interactive elements keyboard accessible | Tab navigation + Enter/Space; submission requirement |
| 16 | ARIA labels on all icon-only buttons | Screen reader support; submission requirement |
| 17 | Touch targets ≥ 44×44px | Mobile usability; submission requirement |
| 18 | Use vault.configDir not .obsidian | Cross-platform compatibility; submission requirement |
| 19 | Use fileManager.trashFile() not vault.delete() | Respects user trash settings |
| 20 | Use AbstractInputSuggest not TextInputSuggest | Built-in API; Liam's copy-pasted implementation is banned |
| 21 | Create versions.json — maps plugin version → min Obsidian version | Submission bot checks for it; auto-reject if missing |
| 22 | Version your settings schema — _settingsVersion field | Enables migration pipeline on upgrade; prevents data loss |
import { Plugin } from 'obsidian'
export default class MyPlugin extends Plugin {
async onload() {
// 1. Load settings FIRST
await this.loadSettings()
// 2. Add settings tab
this.addSettingTab(new MySettingTab(this.app, this))
// 3. Register commands
this.addCommand({ id: 'my-command', name: 'My command', callback: () => {} })
// 4. Register views
this.registerView(MY_VIEW_TYPE, (leaf) => new MyView(leaf))
// 5. Register editor extensions
this.registerEditorExtension(myExtension)
// 6. Register events
this.registerEvent(this.app.vault.on('modify', (file) => {}))
this.registerDomEvent(document, 'click', (evt) => {})
this.registerInterval(window.setInterval(() => {}, 1000))
}
async onunload() {
// Resources registered with register*() are auto-cleaned
// Manual cleanup needed for: MutationObserver, React root, vault.on() in React
}
}
| Need | API |
|---|---|
| Get active file | this.app.workspace.getActiveFile() |
| Read file | this.app.vault.cachedRead(file) |
| Modify file (background) | this.app.vault.process(file, (data) => data) |
| Modify file (editor) | editor.replaceSelection(), editor.getRange() |
| Create file | this.app.vault.create(path, content) |
| Delete file | this.app.fileManager.trashFile(file) |
| Rename file | this.app.fileManager.renameFile(file, newPath) |
| Read frontmatter | this.app.metadataCache.getFileCache(file)?.frontmatter |
| Write frontmatter | this.app.fileManager.processFrontMatter(file, (fm) => {}) |
| Show notification | new Notice('message', duration) |
| Open modal | new MyModal(this.app).open() |
| Get active editor | this.app.workspace.activeEditor?.editor |
| Platform check | Platform.isMacOS, Platform.isMobile, Platform.isDesktop |
| Network request | requestUrl({ url, method, headers, body }) |
| Persist data | this.loadData() / this.saveData(data) |
| Secure storage | this.app.secretStorage.setSecret(id, value) (v1.11.4+) |
| Detect theme | document.body.classList.contains('theme-dark') |
Does the command need an active editor?
├─ YES → editorCallback
│ (automatically hidden when no editor; gives you editor + view)
│
└─ NO → Does it need any context to run? (active file, leaf, etc.)
├─ YES → checkCallback
│ (return true when available; run action on !checking)
│
└─ NO → callback
(always visible, always runs)
Examples:
// Always available — no conditions
this.addCommand({
id: 'open-settings',
name: 'Open plugin settings',
callback: () => { this.openSettings() }
})
// Needs active file — use checkCallback
this.addCommand({
id: 'copy-stats',
name: 'Copy note statistics',
checkCallback: (checking) => {
const file = this.app.workspace.getActiveFile()
if (file) {
if (!checking) this.copyStats(file)
return true
}
return false
}
})
// Needs editor — use editorCallback
this.addCommand({
id: 'wrap-callout',
name: 'Wrap selection in callout',
editorCallback: (editor) => {
const sel = editor.getSelection()
editor.replaceSelection(`> [!note]\n> ${sel}`)
}
})
Only import what you actually use. Submission reviewers flag unused imports.
// Good — only what's needed
import { MarkdownView, Notice, Plugin, PluginSettingTab, Setting } from 'obsidian'
// Bad — unused imports
import { App, Editor, Modal, Notice, Plugin, PluginSettingTab, Setting } from 'obsidian'
// ^^^ ^^^^^^ ^^^^^ — never used
getLeavesOfType() on demandthis.addChild() insteadMyPlugin, SampleSettingTab must be renamedvault.modify() on active file → use Editor API insteadprocessFrontMatter() insteadfetch() for API calls → use requestUrl() insteadvar(--text-normal), etc.navigator.platform → use Platform.isMacOS insteadvar declarations → use const/let insteadasync/await insteadconsole.log in production → remove or use console.debug with conditional(?<=...) → breaks on iOS Safari < 16.4; use alternative patternsObject.assign(defaults, saved) → mutates defaults; use Object.assign({}, defaults, saved).obsidian path → use this.app.vault.configDir insteadvault.delete() for removing files → use fileManager.trashFile() to respect user settingsTextInputSuggest → use built-in AbstractInputSuggest insteadstyles.css → create empty file if no styles (submission bot checks for it)versions.json → create with { "1.0.0": "1.0.0" } (submission bot checks for it)_settingsVersion to settings interface for migration support| Topic | File | When to Load |
|---|---|---|
| Lifecycle & Core API | reference/lifecycle.md | Always; building any plugin feature |
| ESLint Rules (28 rules) | reference/eslint-rules.md | ESLint setup, pre-submission audit, rule reference |
| Accessibility (MANDATORY) | reference/accessibility.md | Keyboard nav, ARIA labels, focus indicators, touch targets |
| CodeMirror 6 Editor Extensions | reference/editor-extensions.md | Editor decorations, syntax highlighting, live preview |
| React / Svelte / Vue Integration | reference/frameworks.md | Using React/Vue/Svelte in views or settings |
| Vault & File Operations | reference/vault-operations.md | File CRUD, frontmatter, events, caching |
| Settings & Data Migration | reference/settings-migration.md | Settings UI, load/save, deep merge, migration pipelines |
| Security & SecretStorage | reference/security.md | API keys, credentials, XSS prevention, network requests |
| CSS Styling | reference/css-accessibility.md | Theming, CSS variables, scoping, mobile styles |
| Dev Workflow & CLI | reference/dev-workflow.md | Build, hot-reload, CLI debugging, Obsidian CLI, ESLint config |
| Testing | reference/testing.md | Unit tests, mocking Obsidian API, Jest/Vitest |
| CI/CD & Release | reference/cicd-release.md | GitHub Actions, version bump, community submission |
# Build and hot-reload
npm run build && obsidian plugin:reload id=<plugin-id>
# Check for errors
obsidian dev:errors
# Inspect DOM
obsidian dev:dom selector=".my-plugin-view"
# Take screenshot
obsidian dev:screenshot
# Evaluate JS in Obsidian context
obsidian eval code="app.plugins.plugins"
# Build and copy to test vault
npm run build && cp main.js manifest.json styles.css /path/to/TestVault/.obsidian/plugins/<plugin-id>/
# Then reload in Obsidian: Ctrl+P → "Reload app without saving"
Before creating a release or submitting to community plugins, verify:
id in manifest.json does not contain "obsidian"; doesn't end with "plugin"; lowercase onlyname does not contain "Obsidian"; doesn't end with "Plugin"; doesn't start with "Obsi" or end with "dian"description does not contain "Obsidian" or "This plugin"; must end with .?!) punctuation; max 250 charsmanifest.json id, name, description match submission entry in community-plugins.jsonLICENSE file present; copyright holder ≠ "Dynalist Inc."; year is currentstyles.css exists (empty if no styles)versions.json exists with correct version mappingmain.js, manifest.json, styles.css attachedMyPlugin, SampleSettingTab, SampleModal)innerHTML/outerHTML anywhere in codeconsole.log in production (remove or use conditional console.debug)setHeading() used instead of <h2> in settingsthis.app used everywhere (not global app)onunload()Object.assign(defaults, saved) — use Object.assign({}, defaults, saved)fileManager.trashFile() not vault.delete()(?<=...)) — breaks on iOSvault.configDir not hardcoded .obsidian:focus-visible styled with Obsidian CSS variableseslint-plugin-obsidianmd (npx eslint .)manifest.json version correct, minAppVersion setisDesktopOnly: true only if using Node/Electron APIs| Reference File | Primary Sources | Last Verified |
|---|---|---|
lifecycle.md | obsidian API docs, gapmiss/obsidian-plugin-skill | 2026-03 |
eslint-rules.md | obsidianmd/eslint-plugin v0.1.9, gapmiss/obsidian-plugin-skill | 2026-03 |
accessibility.md | gapmiss/obsidian-plugin-skill, obsidian plugin guidelines | 2026-03 |
editor-extensions.md | CM6 docs, @codemirror/view source | 2026-03 |
frameworks.md | Leonezz/obsidian-plugin-dev-skill, React docs | 2026-03 |
vault-operations.md | obsidian API docs, official plugin guidelines | 2026-03 |
settings-migration.md | Leonezz/obsidian-plugin-dev-skill | 2026-03 |
security.md | gapmiss/obsidian-plugin-skill, obsidian developer policies | 2026-03 |
css-accessibility.md | davidvkimball/obsidian-dev-skills, obsidian sample theme | 2026-03 |
dev-workflow.md | adriangrantdotorg/Obsidian-Skills, obsidian CLI docs | 2026-03 |
testing.md | Leonezz/obsidian-plugin-dev-skill | 2026-03 |
cicd-release.md | Leonezz/obsidian-plugin-dev-skill, obsidian submission docs | 2026-03 |
To update references: check each source for new content, cross-reference with obsidian developer docs changelog.