Agent Skills: Obsidian Core Workflow A: Create a Plugin from Scratch

|

UncategorizedID: jeremylongshore/claude-code-plugins-plus-skills/obsidian-core-workflow-a

Install this agent skill to your local

pnpm dlx add-skill https://github.com/jeremylongshore/claude-code-plugins-plus-skills/tree/HEAD/plugins/saas-packs/obsidian-pack/skills/obsidian-core-workflow-a

Skill Files

Browse the full folder contents for obsidian-core-workflow-a.

Download Skill

Loading file tree…

plugins/saas-packs/obsidian-pack/skills/obsidian-core-workflow-a/SKILL.md

Skill Metadata

Name
obsidian-core-workflow-a
Description
|

Obsidian Core Workflow A: Create a Plugin from Scratch

Overview

Build a complete Obsidian plugin from an empty directory. By the end you will have a working plugin with a ribbon icon, command palette entries, a settings tab, and a production esbuild build. Every file is shown in full -- no stubs.

Prerequisites

  • Node.js 18+ installed
  • Obsidian desktop app installed
  • A vault to test in (create a fresh vault at ~/ObsidianDev if needed)

Instructions

Step 1: Scaffold the project

set -euo pipefail

PLUGIN_NAME="my-obsidian-plugin"
mkdir -p "$PLUGIN_NAME/src"
cd "$PLUGIN_NAME"

# Initialize Node project
npm init -y

# Install Obsidian types and build tool
npm install --save-dev obsidian@latest typescript@latest esbuild@latest \
  @types/node@latest tslib@latest

# TypeScript config
cat > tsconfig.json << 'TSEOF'
{
  "compilerOptions": {
    "baseUrl": ".",
    "inlineSourceMap": true,
    "inlineSources": true,
    "module": "ESNext",
    "target": "ES2018",
    "allowJs": true,
    "noImplicitAny": true,
    "moduleResolution": "node",
    "importHelpers": true,
    "isolatedModules": true,
    "strictNullChecks": true,
    "lib": ["DOM", "ES2018", "ES2021.String"]
  },
  "include": ["src/**/*.ts"]
}
TSEOF

echo "Scaffolding complete."

Step 2: Create manifest.json

Every Obsidian plugin needs a manifest.json at the project root. This is what Obsidian reads to register the plugin.

{
  "id": "my-obsidian-plugin",
  "name": "My Obsidian Plugin",
  "version": "1.0.0",
  "minAppVersion": "1.0.0",
  "description": "A starter Obsidian plugin.",
  "author": "Your Name",
  "isDesktopOnly": false
}

Step 3: Write the esbuild config

// esbuild.config.mjs
import esbuild from "esbuild";
import process from "process";

const prod = process.argv[2] === "production";

const context = await esbuild.context({
  entryPoints: ["src/main.ts"],
  bundle: true,
  external: [
    "obsidian",
    "electron",
    "@codemirror/autocomplete",
    "@codemirror/collab",
    "@codemirror/commands",
    "@codemirror/language",
    "@codemirror/lint",
    "@codemirror/search",
    "@codemirror/state",
    "@codemirror/view",
    "@lezer/common",
    "@lezer/highlight",
    "@lezer/lr",
  ],
  format: "cjs",
  target: "es2018",
  logLevel: "info",
  sourcemap: prod ? false : "inline",
  treeShaking: true,
  outfile: "main.js",
});

if (prod) {
  await context.rebuild();
  process.exit(0);
} else {
  await context.watch();
}

Step 4: Write main.ts -- the full plugin

This single file contains the Plugin subclass, a settings interface with defaults, a settings tab, and three commands.

// src/main.ts
import {
  App,
  Editor,
  MarkdownView,
  Notice,
  Plugin,
  PluginSettingTab,
  Setting,
} from "obsidian";

// ── Settings ────────────────────────────────────────────────────────
interface MyPluginSettings {
  greeting: string;
  showRibbon: boolean;
}

const DEFAULT_SETTINGS: MyPluginSettings = {
  greeting: "Hello from My Plugin!",
  showRibbon: true,
};

// ── Plugin ──────────────────────────────────────────────────────────
export default class MyPlugin extends Plugin {
  settings: MyPluginSettings;

  async onload() {
    await this.loadSettings();

    // Ribbon icon -- shows a Notice when clicked
    if (this.settings.showRibbon) {
      this.addRibbonIcon("sparkles", "My Plugin: Greet", () => {
        new Notice(this.settings.greeting);
      });
    }

    // Command: show greeting as Notice
    this.addCommand({
      id: "show-greeting",
      name: "Show greeting",
      callback: () => {
        new Notice(this.settings.greeting);
      },
    });

    // Command: insert greeting at cursor (only available in editor)
    this.addCommand({
      id: "insert-greeting",
      name: "Insert greeting at cursor",
      editorCallback: (editor: Editor, view: MarkdownView) => {
        editor.replaceSelection(this.settings.greeting);
      },
    });

    // Command: count words in current note
    this.addCommand({
      id: "count-words",
      name: "Count words in current note",
      editorCallback: (editor: Editor) => {
        const text = editor.getValue();
        const count = text.split(/\s+/).filter(Boolean).length;
        new Notice(`Word count: ${count}`);
      },
    });

    // Status bar item
    const statusEl = this.addStatusBarItem();
    statusEl.setText("Plugin loaded");

    // Settings tab
    this.addSettingTab(new MyPluginSettingTab(this.app, this));

    console.log("MyPlugin loaded");
  }

  onunload() {
    console.log("MyPlugin unloaded");
  }

  async loadSettings() {
    this.settings = Object.assign(
      {},
      DEFAULT_SETTINGS,
      await this.loadData()
    );
  }

  async saveSettings() {
    await this.saveData(this.settings);
  }
}

// ── Settings Tab ────────────────────────────────────────────────────
class MyPluginSettingTab extends PluginSettingTab {
  plugin: MyPlugin;

  constructor(app: App, plugin: MyPlugin) {
    super(app, plugin);
    this.plugin = plugin;
  }

  display(): void {
    const { containerEl } = this;
    containerEl.empty();

    new Setting(containerEl)
      .setName("Greeting message")
      .setDesc("Text shown by the greet command and ribbon icon.")
      .addText((text) =>
        text
          .setPlaceholder("Hello from My Plugin!")
          .setValue(this.plugin.settings.greeting)
          .onChange(async (value) => {
            this.plugin.settings.greeting = value;
            await this.plugin.saveSettings();
          })
      );

    new Setting(containerEl)
      .setName("Show ribbon icon")
      .setDesc("Toggle the sparkles icon in the left ribbon.")
      .addToggle((toggle) =>
        toggle
          .setValue(this.plugin.settings.showRibbon)
          .onChange(async (value) => {
            this.plugin.settings.showRibbon = value;
            await this.plugin.saveSettings();
            new Notice("Reload plugin to apply ribbon change.");
          })
      );
  }
}

Step 5: Add npm scripts and build

Add these scripts to package.json:

{
  "scripts": {
    "dev": "node esbuild.config.mjs",
    "build": "node esbuild.config.mjs production"
  }
}

Build the plugin:

set -euo pipefail
npm run build
# Output: main.js at project root
ls -la main.js manifest.json

Step 6: Install into your vault and test

set -euo pipefail

VAULT="$HOME/ObsidianDev"
PLUGIN_ID="my-obsidian-plugin"

# Create plugin directory in vault
mkdir -p "$VAULT/.obsidian/plugins/$PLUGIN_ID"

# Copy build artifacts
cp main.js manifest.json "$VAULT/.obsidian/plugins/$PLUGIN_ID/"

echo "Plugin installed. Open Obsidian, enable it in Settings > Community plugins."

In Obsidian:

  1. Settings > Community plugins > Enable community plugins
  2. Find "My Obsidian Plugin" in the list, toggle it on
  3. Click the sparkles icon in the left ribbon
  4. Open command palette (Ctrl/Cmd+P), search "Show greeting"
  5. Open Settings > My Obsidian Plugin to change the greeting text

Output

A complete plugin directory containing:

  • manifest.json -- plugin metadata Obsidian reads
  • src/main.ts -- Plugin subclass with commands, ribbon icon, settings tab
  • esbuild.config.mjs -- bundler with watch mode support
  • main.js -- production build output
  • package.json + tsconfig.json -- standard Node/TS project files

Error Handling

| Error | Cause | Fix | |-------|-------|-----| | Cannot find module 'obsidian' | Missing dev dependency | npm install --save-dev obsidian | | Plugin not in list | manifest.json missing or invalid | Verify id field matches folder name | | Ribbon icon missing | Invalid icon name | Use a Lucide icon name (sparkles, file-text, search, etc.) | | Settings not persisting | Forgot await this.saveData() | Always await saveData in onChange | | editorCallback command greyed out | No active editor | Open a markdown note first | | Build fails with external error | Forgot to externalize obsidian | Check external array in esbuild config |

Examples

Minimal manifest.json for community submission:

{
  "id": "my-obsidian-plugin",
  "name": "My Obsidian Plugin",
  "version": "1.0.0",
  "minAppVersion": "1.0.0",
  "description": "Does one useful thing.",
  "author": "Your Name",
  "authorUrl": "https://github.com/yourname",
  "isDesktopOnly": false
}

Adding a hotkey-enabled command:

this.addCommand({
  id: "toggle-sidebar",
  name: "Toggle custom sidebar",
  // Users can assign a hotkey in Settings > Hotkeys
  callback: () => this.toggleSidebar(),
});

Resources

Next Steps

  • Add custom views and modals: see obsidian-core-workflow-b
  • Set up hot-reload development: see obsidian-local-dev-loop
  • Apply production patterns: see obsidian-sdk-patterns