obsidian-plugin-dev
Skill0 starsChanged 5 months agoMITView on GitHub
Guides Claude through building Obsidian plugins that pass the official automated review system. Trigger this skill any time the user mentions "Obsidian plugin" — even a passing mention, a vague idea, or a question. If unsure whether the user wants to build one, ask. Do not wait for the user to mention review, ESLint, submission, or code — trigger on the phrase alone.
- Reads credentials
- Installs packages
- Commits and pushes
2 other projects use a version of this.
Adopted by
Most recently taken up by
- yui-synth-lab/obsidian-weaklog-processortheir own version, first seen Oct 11, 2026
- aresbit/claude-skillstheir own version, first seen Jul 12, 2026
Other versions2 projects use a version of this.
Word for word · 0
None found.
Edited · 0
None found.
Wrote their own version · 2
- aresbit/claude-skills★ 0same name
- yui-synth-lab/obsidian-weaklog-processor★ 0same name
How matches are found. Word for word: the exact same text. Edited: shares passages with this file (“99% same” means 99% of its five-word phrases also appear here; 10% or more counts). Their own version: similar meaning, or, weakest, the same name. We can’t tell which project had it first. Projects are counted among the 200 most-starred candidates, and mentions are links to the repository we found, up to 50 per site, so both counts are a floor.
In this repository3 other files it gives its agents.
- design-explorerSkill
- figma-write-to-figmaSkill
- pareto-analysisSkill
The file
Show its rules and text
We split this skill into the rules it gives an agent
21 rules. About 0 are common, 9 are distinctive, and 12 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈9
Distinctive · estimate
This author's own choices. Worth a look.
12
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 16
Always set up the linter at the start of a new plugin.
- Very specific: line 45
Only add builtin-modules (and spread it into esbuild's external array) if the plugin actually uses Node.js/Electron built-ins (see Mobile …
- Very specific: line 47
Run with npx eslint . before any submission or release.
- Distinctive: line 64
Do not include the word "command" in command IDs or names
- Distinctive: line 65
Do not include the plugin name in the command name (Obsidian adds it in the hotkey UI)
- Very specific: line 66
Never set default hotkeys — causes conflicts between plugins and OS shortcuts
- Distinctive: line 67
Use the right callback type:
- Very specific: line 77
Always use this.app, never the global app.
- Very specific: line 124
If your plugin uses Node.js or Electron APIs (fs, path, crypto, os, child_process, etc.):
- Distinctive: line 147
Use Obsidian's DOM helpers instead of native browser APIs.
- Distinctive: line 160
Never set inline styles on DOM elements.
- Very specific: line 173
Never pass user-controlled strings to innerHTML, outerHTML, or insertAdjacentHTML.
- Very specific: line 184
Use el.empty() to clear element contents instead of el.innerHTML = "".
- Distinctive: line 242
Prefer the Vault API over the raw Adapter API.
- Very specific: line 252
Always call normalizePath() on any path that comes from user input or string concatenation:
- Very specific: line 316
Don't add a top-level heading if there's only one section.
- Distinctive: line 317
All names/descriptions are sentence case — "Template folder location", not "Template Folder Location".
- Very specific: line 318
Never build headings with raw containerEl.createEl("h2", ...).
- Very specific: line 324
Use window. prefixed timer functions for popout window compatibility:
- Very specific: line 333
Use window or activeWindow, not global or globalThis.
- Distinctive: line 390
Before creating a GitHub release:
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Obsidian Plugin Development
The goal here is to write plugin code that passes Obsidian's automated review on the first try. The review runs eslint-plugin-obsidianmd plus manifest validation on every release. Failures block the plugin from appearing in the community directory within 24 hours. Most violations are mechanical and predictable — avoid them from the start rather than patching them at submission time.
ESLint Setup
Always set up the linter at the start of a new plugin. Run it before every submission.
npm install eslint-plugin-obsidianmd --save-deveslint.config.mjs (ESLint v9+):
import tsparser from "@typescript-eslint/parser";
import { defineConfig } from "eslint/config";
import obsidianmd from "eslint-plugin-obsidianmd";
export default defineConfig([
{ ignores: ["main.js", "node_modules/**"] },
...obsidianmd.configs.recommended,
{
files: ["**/*.ts"],
languageOptions: {
parser: tsparser,
parserOptions: { project: "./tsconfig.json" },
},
},
]);The ignores entry is not optional in practice: without it, ESLint also lints the built main.js bundle as a plain .js file with no parserOptions.project set, and type-aware rules like @typescript-eslint/no-deprecated crash outright ("you have used a rule which requires type information...").
package.json devDependencies: use @typescript-eslint/parser and @typescript-eslint/eslint-plugin at ^8.0.0, not ^7.x — v7 declares a peer dependency on ESLint ^8.56.0 and conflicts (ERESOLVE) with ESLint v9.
Only add builtin-modules (and spread it into esbuild's external array) if the plugin actually uses Node.js/Electron built-ins (see Mobile Compatibility below). If it doesn't, skip the dependency entirely — an unused builtin-modules in package.json trips the depend/ban-dependencies rule.
Run with npx eslint . before any submission or release.
Commands
Obsidian automatically prepends the plugin ID to every command ID. Never do it manually.
// Wrong — results in "my-plugin:my-plugin:do-thing"
this.addCommand({ id: "my-plugin:do-thing", name: "Do thing", ... });
// Correct — results in "my-plugin:do-thing"
this.addCommand({ id: "do-thing", name: "Do thing", ... });Other command rules:
- Do not include the word "command" in command IDs or names
- Do not include the plugin name in the command name (Obsidian adds it in the hotkey UI)
- Never set default
hotkeys— causes conflicts between plugins and OS shortcuts - Use the right callback type:
callback— always availablecheckCallback— conditionally available (returnfalseto hide)editorCallback— only in markdown editors
- Command names use sentence case: "Open image browser", not "Open Image Browser"
this.app vs Global app
Always use this.app, never the global app. The global exists only for debugging in the developer console and may be removed in future versions.
// Wrong
const file = app.vault.getActiveFile();
// Correct
const file = this.app.vault.getActiveFile();Manifest (manifest.json)
Key rules the automated validator checks:
| Field | Requirement |
id | Must not contain the word obsidian |
description | Max 250 chars; ends with .; no emoji; sentence case; not starting with "This is a plugin" |
minAppVersion | Must reflect actual minimum supported version |
isDesktopOnly | Must be true if you use any Node.js or Electron APIs |
fundingUrl | Only allowed if you actually accept donations; link only to donation/sponsorship services |
version | Semantic versioning x.y.z — no v prefix |
Determining minAppVersion isn't a guess. The automated review runs obsidianmd/no-unsupported-api, which statically flags any API call newer than the declared minimum. Before finalizing the manifest, check every non-trivial API you called against its @since tag:
grep -n "@since" node_modules/obsidian/obsidian.d.ts | grep -B5 "getSettingDefinitions\|<the API you used>"Set minAppVersion to the highest @since version found among APIs actually in use. Bump it (and cut a new release) any time you adopt a newer API — don't leave a stale, lower value in place.
Description format:
{
"description": "Translates selected text using DeepL API and replaces it in the editor."
}- Starts with an action verb
- Sentence case (only the first word and proper nouns are capitalised)
- Ends with a period
Mobile Compatibility
If your plugin uses Node.js or Electron APIs (fs, path, crypto, os, child_process, etc.):
Option 1 — Mark as desktop-only in manifest.json:
{ "isDesktopOnly": true }Option 2 — Guard with Platform:
import { Platform } from "obsidian";
if (Platform.isDesktop) {
const fs = require("fs");
// ...
}Also avoid regex lookbehind expressions — unsupported on some iOS versions.
DOM Creation
Use Obsidian's DOM helpers instead of native browser APIs. This is both an ESLint error and a readability convention.
// Wrong
const div = document.createElement("div");
div.className = "my-class";
container.appendChild(div);
// Correct
const div = container.createDiv({ cls: "my-class" });
// or: createEl, createSpan, createSvg, createFragmentNever set inline styles on DOM elements. Use CSS classes with Obsidian's CSS variables:
// Wrong
el.style.color = "red";
// Correct — add a CSS class in styles.css instead
el.addClass("my-error-text");Security: No innerHTML with User Input
Never pass user-controlled strings to innerHTML, outerHTML, or insertAdjacentHTML. This is an XSS vulnerability.
// Wrong — XSS risk
el.innerHTML = userInput;
// Correct — use DOM methods or sanitised helpers
el.setText(userInput);
el.createSpan({ text: userInput });Use el.empty() to clear element contents instead of el.innerHTML = "".
Memory and Resource Management
Register everything through Obsidian's lifecycle methods so it gets cleaned up automatically on plugin unload:
// Event listeners
this.registerEvent(
this.app.vault.on("modify", (file) => { ... })
);
// DOM event listeners
this.registerDomEvent(document, "click", (evt) => { ... });
// Intervals
this.registerInterval(
window.setInterval(() => { ... }, 5000)
);Do not:
- Detach leaves in
onunload()— Obsidian will reinitialise them during updates - Store custom view references directly on the plugin class (memory leak)
- Pass the plugin instance to
MarkdownRenderer.renderas a component
// Wrong — stores view reference on plugin
this.view = new MyView(leaf);
// Correct — access views via workspace when needed
const leaves = this.app.workspace.getLeavesOfType(MY_VIEW_TYPE);Workspace API
// Wrong
const leaf = this.app.workspace.activeLeaf;
// Correct
const view = this.app.workspace.getActiveViewOfType(MarkdownView);
const editor = this.app.workspace.activeEditor?.editor;Register views like this — don't store the instance:
this.registerView(MY_VIEW_TYPE, (leaf) => new MyView(leaf));Vault API
Prefer the Vault API over the raw Adapter API. The Vault API handles caching and events correctly.
// Wrong — iterate to find a file
const file = this.app.vault.getFiles().find(f => f.path === "notes/foo.md");
// Correct
const file = this.app.vault.getFileByPath("notes/foo.md");Always call normalizePath() on any path that comes from user input or string concatenation:
import { normalizePath } from "obsidian";
const path = normalizePath(`${folderInput}/${filename}`);
const file = this.app.vault.getFileByPath(path);For editing the active note, use the Editor interface (preserves cursor position):
// Wrong for active note — loses cursor position, triggers full re-render
await this.app.vault.modify(file, newContent);
// Correct for active note
editor.setValue(newContent);
// Correct for background file edits (atomic, race-condition safe)
await this.app.vault.process(file, (content) => newContent);Parse frontmatter via the API, never manually:
await this.app.fileManager.processFrontMatter(file, (frontmatter) => {
frontmatter["my-key"] = "value";
});Settings Tab UI
Since Obsidian 1.13.0, PluginSettingTab.display() is deprecated. The current API is declarative: override getSettingDefinitions() to return an array of setting definitions, and Obsidian renders them itself — which is also what makes them show up in Obsidian's built-in settings search. getControlValue/setControlValue default to reading/writing this.plugin.settings[key], so no boilerplate accessors are needed unless you have side effects to run on change (e.g. re-rendering something live) or a non-default data source.
// Correct — declarative, searchable, current API
getSettingDefinitions(): SettingDefinitionItem[] {
return [
{
name: "Template folder location",
desc: "Choose a folder.",
control: { type: "text", key: "templateFolder" },
},
];
}
// Only override this if you need side effects beyond persisting the value
async setControlValue(key: string, value: unknown): Promise<void> {
await super.setControlValue(key, value);
if (key === "templateFolder") this.plugin.refreshSomething();
}// Wrong — deprecated since 1.13.0; only implement this if you need to
// support Obsidian versions older than 1.13.0 as a genuine fallback
display(): void {
containerEl.empty();
new Setting(containerEl).setName("Template Folder Location").setDesc("Choose Folder");
}If you do keep display() as a pre-1.13 fallback, know that Obsidian skips calling it whenever getSettingDefinitions() returns a non-empty array — so it only ever runs on Obsidian versions old enough not to know about the declarative API. If your minAppVersion is already ≥ 1.13.0, delete display() entirely; keeping it around when every supported version has the declarative API is itself a lint warning (obsidianmd/settings-tab/no-deprecated-display).
Whichever approach you use, keep these standing rules:
- Don't add a top-level heading if there's only one section; don't use "Settings", "General", or the plugin name as a heading (
.setHeading()for imperative groups; aSettingDefinitionGroup'snamefor declarative groups). - All names/descriptions are sentence case — "Template folder location", not "Template Folder Location". Exceptions: proper nouns and acronyms keep their casing (Obsidian, Markdown, PDF, API, GitHub).
- Never build headings with raw
containerEl.createEl("h2", ...).
Timers and Globals
Use window. prefixed timer functions for popout window compatibility:
// Wrong
setTimeout(() => { ... }, 1000);
// Correct
window.setTimeout(() => { ... }, 1000);Use window or activeWindow, not global or globalThis.
Release Automation & Artifact Attestations
The community review recommends (not yet required, but worth doing from the start) that release assets carry a GitHub artifact attestation — a Sigstore-signed, verifiable link between the release binary and the exact commit/workflow that built it. This can't be produced by building locally and running gh release create yourself; it needs an in-Actions OIDC token tied to the repo. Set up a workflow once, then release by just pushing a tag:
.github/workflows/release.yml:
name: Release
on:
push:
tags:
- "*"
permissions:
contents: write
id-token: write
attestations: write
jobs:
build-and-release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: npm ci
- run: npm run build
- name: Attest build provenance
uses: actions/attest-build-provenance@v1
with:
subject-path: |
main.js
manifest.json
styles.css
- name: Create or update release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release create "${{ github.ref_name }}" main.js manifest.json styles.css \
--title "${{ github.ref_name }}" \
--generate-notes \
|| gh release upload "${{ github.ref_name }}" main.js manifest.json styles.css --clobberReleasing is then: bump version in manifest.json/package.json (and add an entry to versions.json), commit, git tag X.Y.Z && git push origin X.Y.Z — CI builds, attests, and publishes. Verify an attestation with gh attestation verify <path-to-downloaded-asset> -R owner/repo.
Submission Checklist
Before creating a GitHub release:
npx eslint .passes with zero errors- All placeholder names replaced (
MyPlugin,MyPluginSettings,SampleSettingTab) - Template/sample code removed
manifest.jsondescription: ≤250 chars, ends with., sentence case, no emojimanifest.jsoniddoes not contain "obsidian"manifest.jsonisDesktopOnly: trueif Node.js/Electron APIs are usedmanifest.jsonminAppVersionmatches the highest@sincetag among APIs actually used (see Manifest section)- README's title/heading matches
manifest.json'snamefield exactly - No leftover deprecated
display()ifminAppVersionis already ≥ 1.13.0 andgetSettingDefinitions()is implemented README.mdandLICENSEpresent in repo root- Release tag matches
manifest.jsonversion exactly (e.g.,1.0.0, notv1.0.0) - Release assets include
main.jsandmanifest.json(andstyles.cssif applicable) - Release assets carry GitHub artifact attestations (see Release Automation section)
- If network use, external services are documented in README
- If server-side telemetry, privacy policy linked in README
Submission is via community.obsidian.md — connect GitHub, enter repo URL, submit.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

