agentleFS
strombraatenstrombraaten/claude-skills/skills/obsidian-plugin-dev/SKILL.md

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

  • Other versions2 projects use a version of this.

    Word for word · 0

    None found.

    Edited · 0

    None found.

    Wrote their own version · 2

    1. aresbit/claude-skills★ 0same name
    2. 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.

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
  1. Distinctive:

    Always set up the linter at the start of a new plugin.

    line 16
  2. Very specific:

    Only add builtin-modules (and spread it into esbuild's external array) if the plugin actually uses Node.js/Electron built-ins (see Mobile …

    line 45
  3. Very specific:

    Run with npx eslint . before any submission or release.

    line 47
  4. Distinctive:

    Do not include the word "command" in command IDs or names

    line 64
  5. Distinctive:

    Do not include the plugin name in the command name (Obsidian adds it in the hotkey UI)

    line 65
  6. Very specific:

    Never set default hotkeys — causes conflicts between plugins and OS shortcuts

    line 66
  7. Distinctive:

    Use the right callback type:

    line 67
  8. Very specific:

    Always use this.app, never the global app.

    line 77
  9. Very specific:

    If your plugin uses Node.js or Electron APIs (fs, path, crypto, os, child_process, etc.):

    line 124
  10. Distinctive:

    Use Obsidian's DOM helpers instead of native browser APIs.

    line 147
  11. Distinctive:

    Never set inline styles on DOM elements.

    line 160
  12. Very specific:

    Never pass user-controlled strings to innerHTML, outerHTML, or insertAdjacentHTML.

    line 173
  13. Very specific:

    Use el.empty() to clear element contents instead of el.innerHTML = "".

    line 184
  14. Distinctive:

    Prefer the Vault API over the raw Adapter API.

    line 242
  15. Very specific:

    Always call normalizePath() on any path that comes from user input or string concatenation:

    line 252
  16. Very specific:

    Don't add a top-level heading if there's only one section.

    line 316
  17. Distinctive:

    All names/descriptions are sentence case — "Template folder location", not "Template Folder Location".

    line 317
  18. Very specific:

    Never build headings with raw containerEl.createEl("h2", ...).

    line 318
  19. Very specific:

    Use window. prefixed timer functions for popout window compatibility:

    line 324
  20. Very specific:

    Use window or activeWindow, not global or globalThis.

    line 333
  21. Distinctive:

    Before creating a GitHub release:

    line 390

≈ “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.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

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-dev

eslint.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 available
    • checkCallback — conditionally available (return false to 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:

FieldRequirement
idMust not contain the word obsidian
descriptionMax 250 chars; ends with .; no emoji; sentence case; not starting with "This is a plugin"
minAppVersionMust reflect actual minimum supported version
isDesktopOnlyMust be true if you use any Node.js or Electron APIs
fundingUrlOnly allowed if you actually accept donations; link only to donation/sponsorship services
versionSemantic 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, createFragment

Never 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.render as 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; a SettingDefinitionGroup's name for 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 --clobber

Releasing 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.json description: ≤250 chars, ends with ., sentence case, no emoji
  • manifest.json id does not contain "obsidian"
  • manifest.json isDesktopOnly: true if Node.js/Electron APIs are used
  • manifest.json minAppVersion matches the highest @since tag among APIs actually used (see Manifest section)
  • README's title/heading matches manifest.json's name field exactly
  • No leftover deprecated display() if minAppVersion is already ≥ 1.13.0 and getSettingDefinitions() is implemented
  • README.md and LICENSE present in repo root
  • Release tag matches manifest.json version exactly (e.g., 1.0.0, not v1.0.0)
  • Release assets include main.js and manifest.json (and styles.css if 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.

Download now (ZIP)

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
What did you use it for? e.g. Reviewing pull requests in a Rails app

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.