plugins/obsidian-development/skills/obsidian-plugin-development/SKILL.md
Ensures compliance with Obsidian's automated plugin review (community.obsidian.md), eslint-plugin-obsidianmd rules, and official Obsidian plugin guidelines. TRIGGER WHEN: writing, reviewing, or fixing Obsidian community plugin code DO NOT TRIGGER WHEN: the task is outside the specific scope of this component.
npx skillsauth add acaprino/anvil-toolset obsidian-plugin-developmentInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Write Obsidian plugin code that passes Obsidian's automated plugin review on first submission. Since May 2026, plugins are submitted and reviewed through the Community hub at community.obsidian.md (the old PR workflow to obsidianmd/obsidian-releases, gated by ObsidianReviewBot, is retired); every GitHub release is scanned automatically. All rules below are enforced via eslint-plugin-obsidianmd and @typescript-eslint. Violations labeled "Required" block approval, and a failing version of an already-listed plugin is removed from directory search within 24 hours.
Every user-visible string: sentence case only.
// NO
'Block Settings'
'Add Block'
'Recent Files'
// YES
'Block settings'
'Add block'
'Recent files'
Applies to: Setting.setName(), Setting.setDesc(), createEl() text, button labels, modal titles, notices, menu items, tooltips. Proper nouns and acronyms (e.g. "API", "GitHub", "Obsidian") keep their casing.
Never assign element.style.* directly. Use CSS classes.
// NO
el.style.display = 'flex';
el.style.transform = 'scale(0.9)';
el.style.opacity = '0';
// YES -- use CSS classes
el.addClass('hp-flex-container');
el.toggleClass('hp-scaled', true);
el.toggleClass('hp-hidden', true);
// For dynamic CSS custom properties, use setCssProps or setCssStyles:
el.setCssStyles({ '--my-var': value });
Flagged properties include: display, transform, opacity, width, height, margin, padding, cursor, fontSize, fontFamily, flexDirection, alignItems, flexShrink, borderRadius, backdropFilter, background, borderWidth, borderStyle, transition, gridTemplateRows, transformOrigin, and all others.
Don't as Type when it doesn't change the type.
// NO -- assertion is redundant with ?? fallback
draft.url as string ?? ''
draft.showDate as boolean ?? true
// YES
String(draft.url ?? '')
Boolean(draft.showDate ?? true)
// or just
(draft.url ?? '') as string // assertion AFTER coalescing
Every Promise must be: awaited, .catch()ed, .then() with rejection handler, or voided.
// NO
someAsyncFn();
this.app.vault.read(file).then(text => { ... });
// YES
await someAsyncFn();
void someAsyncFn();
this.app.vault.read(file).then(text => { ... }, err => console.error(err));
this.app.vault.read(file).then(text => { ... }).catch(console.error);
Remove async from methods that don't use await.
// NO
async onOpen() { this.render(); }
// YES
onOpen() { this.render(); }
Don't return a Promise in callbacks expecting void.
// NO -- event callback expects void
this.registerEvent(this.app.vault.on('modify', async (file) => {
await this.reload();
}));
// YES
this.registerEvent(this.app.vault.on('modify', (file) => {
void this.reload();
}));
Ensure values won't stringify as [object Object].
// NO -- if draft is Record<string,unknown>, draft.mode could be an object
`Value: ${draft.mode ?? 'default'}`
// YES
`Value: ${String(draft.mode ?? 'default')}`
Don't create HTML headings. Use Setting.setHeading().
// NO
contentEl.createEl('h2', { text: 'My settings' });
// YES
new Setting(contentEl).setName('My settings').setHeading();
Obsidian handles leaf cleanup. Detaching resets user's layout.
// NO
onunload() {
this.app.workspace.detachLeavesOfType(VIEW_TYPE);
}
// YES
onunload() {
// Obsidian cleans up leaves automatically
}
Use instanceof instead of type casting.
// NO
const file = abstractFile as TFile;
// YES
if (abstractFile instanceof TFile) { ... }
Don't create <style> or <link> elements dynamically.
Don't pass this (plugin) to MarkdownRenderer.render(). Use a Component instance.
// NO
MarkdownRenderer.render(this.app, md, el, '', this);
// YES -- use a Component subclass or this view/block
MarkdownRenderer.render(this.app, md, el, '', this.component);
Don't store view references in plugin properties (memory leak).
Don't hardcode .obsidian. Use this.app.vault.configDir.
Use Platform API, not navigator.userAgent.
// NO
if (navigator.userAgent.includes('Mac')) { ... }
// YES
import { Platform } from 'obsidian';
if (Platform.isMacOS) { ... }
Lookbehinds break on some iOS versions. Avoid unless isDesktopOnly: true.
FileManager.trashFile() instead of Vault.trash()/Vault.delete()getAbstractFileByPath()normalizePath() for user-provided pathsRemove MyPlugin, SampleModal, template code from obsidian-sample-plugin.
Don't use Object.assign(this.settings, data) to mutate defaults.
manifest.json must have valid structureLICENSE must have correct copyright holder and current year. ? ! )Use the popout-safe globals. Bare document and global timers target the main window only and break in popout windows.
// NO
document.body.appendChild(el);
const t = setTimeout(cb, 500);
clearTimeout(t);
globalThis.myFlag = true;
// YES
activeDocument.body.appendChild(el);
const t = activeWindow.setTimeout(cb, 500);
activeWindow.clearTimeout(t);
Rules: obsidianmd/prefer-active-doc (warn), obsidianmd/prefer-window-timers (error, also covers setInterval and requestAnimationFrame), obsidianmd/no-global-this (error).
Every API the code calls must exist in the Obsidian version declared as minAppVersion. obsidianmd/no-unsupported-api cross-checks usage against the manifest.
// NO -- revealLeaf needs v1.7.2 but manifest says minAppVersion 1.5.0
await this.app.workspace.revealLeaf(leaf);
// YES -- raise minAppVersion to 1.7.2, or gate the call behind a version check
// NO
document.createElement('iframe');
document.createDocumentFragment();
el.createEl('span', { text: file.path });
// YES
createEl('iframe');
createFragment();
el.createSpan({ text: file.path });
Rule obsidianmd/prefer-create-el. The review platform enforces it, but the rule is not yet in the npm release, so local ESLint misses it: check these patterns manually.
Every eslint-disable directive needs a description, and some rules cannot be disabled at all (obsidianmd/no-static-styles-assignment, obsidianmd/ui/sentence-case).
// NO
// eslint-disable-next-line obsidianmd/prefer-active-doc
// YES
// eslint-disable-next-line obsidianmd/prefer-active-doc -- main-window-only startup code
Enforced platform-side via @eslint-community/eslint-comments/require-description and no-restricted-disable; add the same plugin locally to catch them before release.
AbstractInputSuggest instead of copied TextInputSuggestlocalStorage or sessionStorage. Use Plugin.loadData()/saveData() for plugin data (stored in the plugin's data.json, synced with the vault). For device-specific per-vault values (window positions, device caches), use App.loadLocalStorage()/App.saveLocalStorage(), which scope keys to the current vault. Raw localStorage is shared across all vaults on the device and never synced; sessionStorage does not survive a restart.| Practice | Details |
|----------|---------|
| No innerHTML/outerHTML | Use createEl, setText, sanitizeHTMLToDom |
| Use requestUrl() | Instead of fetch() for network requests |
| CSS variables for theming | --background-secondary, --text-muted, etc. |
| Scope CSS | All plugin CSS scoped to plugin containers |
| Accessibility | aria-label on icon buttons, keyboard nav, focus indicators |
| Touch targets | Min 44x44px on mobile |
| Auto-cleanup | registerEvent(), registerInterval(), register() |
| No production logging | No console.log in onload()/onunload() |
See references/obsidian-api-reference.md in this skill directory for a condensed TypeScript API reference covering all key classes: Plugin, App, Vault, Workspace, MetadataCache, FileManager, Component, View, Modal, Setting, Menu, MarkdownRenderer, Platform, DOM helpers, and more.
For the full type definitions, read node_modules/obsidian/obsidian.d.ts in the project.
npm install --save-dev eslint-plugin-obsidianmd @eslint-community/eslint-plugin-eslint-comments
Configure ESLint with the obsidianmd recommended config plus the eslint-comments rules (require-description, no-restricted-disable). Lint src/ AND package.json (the dependency rules only fire when package.json is linted). Run before publishing a release: the platform scans every GitHub release automatically.
| Mistake | Fix |
|---------|-----|
| Title Case in UI text | Sentence case everything |
| el.style.display = 'none' | el.addClass('hp-hidden') |
| as string ?? '' | String(x ?? '') |
| createEl('h2', ...) in modal | new Setting(el).setName(...).setHeading() |
| Async onOpen without await | Remove async keyword |
| Unhandled promise | Add void, await, or .catch() |
| detachLeavesOfType in onunload | Remove -- Obsidian handles it |
| abstractFile as TFile | if (x instanceof TFile) |
| setTimeout(cb, ms) | activeWindow.setTimeout(cb, ms) |
| document.body | activeDocument.body |
| el.createEl('span', {...}) | el.createSpan({...}) |
| Bare // eslint-disable-next-line | Append -- reason description |
| New API with old minAppVersion | Raise minAppVersion to the API's version |
| localStorage.setItem('key', v) | this.app.saveLocalStorage('key', v) or this.saveData(...) |
development
Quality gates for multi-reviewer code review pipelines: adversarial verification panel, completeness critic, reviewer pipeline conventions, and the context sharing pattern for parallel reviewers. TRIGGER WHEN: running /senior-review:team-review quality gates; running /senior-review:code-review Steps 4b/4c (adversarial verification and completeness check); consolidating or deduplicating findings from multiple parallel reviewers. DO NOT TRIGGER WHEN: single-reviewer style review without a consolidation phase, or generic team coordination (the upstream agent-teams skills cover that).
development
Knowledge base for pure-architecture decisions on when to unify duplicated logic into a shared abstraction versus leave it duplicated. Covers the canonical theory (Rule of Three, DRY/WET/AHA, Wrong Abstraction, Locality of Behaviour, Bounded Contexts, Tidy First options framing, CUPID vs SOLID), 12 essential-duplication patterns that justify unification, 12 wrong-abstraction patterns that justify inlining or decomposition, an operational decision frame, and a verified reading list. TRIGGER WHEN: the user is making an architectural decision about whether to centralize, extract, or remove a layer; reviewing an abstraction for premature generality; auditing scattered cross-cutting concerns; spawned by the abstraction-architect agent during /abstraction-architect:audit or as the Abstraction dimension of /senior-review:team-review or /senior-review:code-review; the user asks "should I extract this into a service" / "is this DRY enough" / "is this wrong abstraction". DO NOT TRIGGER WHEN: the task is code formatting and readability cleanup (use clean-code:clean-code), Python-specific refactoring with metrics (use python-development:python-refactor), generic dead-code removal (use senior-review:cleanup-dead-code), security review (use senior-review:security-auditor), or pure pattern-consistency review without an architecture lens (use senior-review:code-auditor).
development
Unified web frontend knowledge base covering CSS architecture, UX psychology, UI components, distinctive aesthetics, and interface design generation. TRIGGER WHEN: working on web styling, design systems, component decisions, responsive strategy, distinctive frontend aesthetics, or exploring multiple interface designs. DO NOT TRIGGER WHEN: the task is purely backend or unrelated to web frontend.
development
Stripe payments knowledge base - API patterns, checkout optimization, subscription lifecycle, pricing strategies, webhook reliability, Firebase integration, cost analysis, and revenue modeling. Loaded by stripe-integrator and revenue-optimizer agents; also consumable directly when the user asks for Stripe-specific patterns without needing an agent. TRIGGER WHEN: working with Stripe API (Payment Intents, Customers, Subscriptions, Checkout Sessions, Connect, webhooks, tax, usage-based billing), pricing strategy, or revenue modeling. DO NOT TRIGGER WHEN: payment work is non-Stripe (PayPal, Square, crypto) or the task is generic e-commerce unrelated to payments.