Skill: davidvkimball/obsidian-plugin-dev (version 1.0.0)

--- name: obsidian-plugin description: Create and develop Obsidian plugins from scratch. Use when building a new Obsidian plugin, scaffolding from the sample-plugin-plus template, or developing plugin features. Covers project setup, manifest configuration, TypeScript development, settings UI, commands, ribbons, modals, and Obsidian API patterns. --- # Obsidian Plugin Development Build production-ready Obsidian plugins using the [obsidian-sample-plugin-plus](https://github.com/davidvkimball/obsidian-sample-plugin-plus) template. ## Quick Start: New Plugin ### 1. Create from Template ```bash # Clone the template (or use GitHub's "Use this template" button) gh repo create my-plugin --template davidvkimball/obsidian-sample-plugin-plus --public --clone cd my-plugin # Or clone directly git clone https://github.com/davidvkimball/obsidian-sample-plugin-plus.git my-plugin cd my-plugin rm -rf .git && git init ``` ### 2. Configure Plugin Identity Update these files with your plugin's info: **manifest.json:** ```json { "id": "my-plugin", "name": "My Plugin", "version": "0.0.1", "minAppVersion": "1.5.0", "description": "What your plugin does", "author": "Your Name", "authorUrl": "https://yoursite.com", "isDesktopOnly": false } ``` **package.json:** Update `name`, `description`, `author`, `license`. **README.md:** Replace template content with your plugin's documentation. ### 3. Initialize Development Environment ```bash pnpm install pnpm obsidian-dev-skills # Initialize AI skills ./scripts/setup-ref-links.sh # Unix # or: scripts\setup-ref-links.bat # Windows ``` ### 4. Clean Boilerplate In `src/main.ts`: - Remove sample ribbon icon, status bar, commands, modal, and DOM event - Keep the settings tab if needed, or remove it - Rename `MyPlugin` class to your plugin name Delete `styles.css` if your plugin doesn't need custom styles. ## Development Workflow ### Build & Test ```bash pnpm dev # Watch mode — rebuilds on changes pnpm build # Production build pnpm lint # Check for issues pnpm lint:fix # Auto-fix issues pnpm test # Run unit tests ``` ### Install in Obsidian Copy build output to your vault: ```bash # Unix cp main.js manifest.json styles.css ~/.obsidian/plugins/my-plugin/ # Or create a symlink for development ln -s $(pwd) ~/.obsidian/plugins/my-plugin ``` Enable the plugin in Obsidian Settings → Community Plugins. Use [Hot Reload](https://github.com/pjeby/hot-reload) plugin for automatic reloading during development. ## Plugin Architecture ### Entry Point (`src/main.ts`) ```typescript import { Plugin } from 'obsidian'; export default class MyPlugin extends Plugin { settings: MyPluginSettings; async onload() { await this.loadSettings(); // Register commands, ribbons, events, views } onunload() { // Cleanup: remove event listeners, views, DOM elements } async loadSettings() { this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); } async saveSettings() { await this.saveData(this.settings); } } ``` ### Settings Pattern See [references/settings.md](references/settings.md) for the complete settings UI pattern. ### Common Patterns See [references/patterns.md](references/patterns.md) for: - Commands (simple, editor, check callbacks) - Ribbon icons - Modals - Events and lifecycle - File operations - Editor manipulation ## Constraints - **No auto-git**: Never run `git commit` or `git push` without explicit approval - **No eslint-disable**: Fix lint issues properly, don't suppress them - **No `any` types**: Use proper TypeScript types - **Sentence case**: UI text uses sentence case (ESLint may false-positive on this — ignore if so) ## Release Checklist 1. Update version in `manifest.json` and `package.json` 2. Update `versions.json` with `"version": "minAppVersion"` 3. Run `pnpm build` — zero errors 4. Run `pnpm lint` — zero issues 5. Create GitHub release with tag matching version (no `v` prefix) 6. Upload: `main.js`, `manifest.json`, `styles.css` (if used) ## References - [Settings UI](references/settings.md) — Complete settings tab implementation - [Common Patterns](references/patterns.md) — Commands, modals, events, file operations - [Obsidian API Docs](https://docs.obsidian.md) — Official documentation

path: references/patterns.md
content: # Common Obsidian Plugin Patterns

## Table of Contents
- [Commands](#commands)
- [Ribbon Icons](#ribbon-icons)
- [Modals](#modals)
- [Events & Lifecycle](#events--lifecycle)
- [File Operations](#file-operations)
- [Editor Manipulation](#editor-manipulation)
- [Status Bar](#status-bar)
- [Context Menus](#context-menus)
- [Views](#views)

## Commands

### Simple Command

```typescript
this.addCommand({
  id: 'do-something',
  name: 'Do something',
  callback: () => {
    new Notice('Command executed!');
  }
});
```

### Editor Command

Only available when an editor is active:

```typescript
this.addCommand({
  id: 'insert-text',
  name: 'Insert text at cursor',
  editorCallback: (editor: Editor, view: MarkdownView) => {
    editor.replaceSelection('Inserted text');
  }
});
```

### Check Command

Conditionally available based on app state:

```typescript
this.addCommand({
  id: 'process-file',
  name: 'Process current file',
  checkCallback: (checking: boolean) => {
    const file = this.app.workspace.getActiveFile();
    if (file?.extension === 'md') {
      if (!checking) {
        // Execute the command
        this.processFile(file);
      }
      return true; // Command is available
    }
    return false; // Command not available
  }
});
```

### Hotkey Defaults

```typescript
this.addCommand({
  id: 'quick-action',
  name: 'Quick action',
  hotkeys: [{ modifiers: ['Mod', 'Shift'], key: 'p' }],
  callback: () => { /* ... */ }
});
```

## Ribbon Icons

```typescript
// Add ribbon icon (left sidebar)
const ribbonEl = this.addRibbonIcon('dice', 'My Plugin', (evt: MouseEvent) => {
  new Notice('Ribbon clicked!');
});

// Add CSS class for styling
ribbonEl.addClass('my-plugin-ribbon');

// Remove ribbon on unload (automatic if using this.addRibbonIcon)
```

Available icons: Use any [Lucide](https://lucide.dev/icons/) icon name.

## Modals

### Basic Modal

```typescript
import { App, Modal } from 'obsidian';

class MyModal extends Modal {
  result: string;
  onSubmit: (result: string) => void;

  constructor(app: App, onSubmit: (result: string) => void) {
    super(app);
    this.onSubmit = onSubmit;
  }

  onOpen() {
    const { contentEl } = this;
    contentEl.createEl('h2', { text: 'Enter a value' });

    const inputEl = contentEl.createEl('input', { type: 'text' });
    inputEl.addEventListener('keydown', (e) => {
      if (e.key === 'Enter') {
        this.result = inputEl.value;
        this.close();
        this.onSubmit(this.result);
      }
    });

    const buttonEl = contentEl.createEl('button', { text: 'Submit' });
    buttonEl.addEventListener('click', () => {
      this.result = inputEl.value;
      this.close();
      this.onSubmit(this.result);
    });
  }

  onClose() {
    const { contentEl } = this;
    contentEl.empty();
  }
}

// Usage
new MyModal(this.app, (result) => {
  new Notice(`You entered: ${result}`);
}).open();
```

### Confirmation Modal

```typescript
import { App, Modal, Setting } from 'obsidian';

class ConfirmModal extends Modal {
  constructor(
    app: App,
    private message: string,
    private onConfirm: () => void
  ) {
    super(app);
  }

  onOpen() {
    const { contentEl } = this;
    contentEl.createEl('p', { text: this.message });

    new Setting(contentEl)
      .addButton(btn => btn
        .setButtonText('Cancel')
        .onClick(() => this.close()))
      .addButton(btn => btn
        .setButtonText('Confirm')
        .setWarning()
        .onClick(() => {
          this.close();
          this.onConfirm();
        }));
  }

  onClose() {
    this.contentEl.empty();
  }
}
```

## Events & Lifecycle

### File Events

```typescript
// File created
this.registerEvent(
  this.app.vault.on('create', (file) => {
    if (file instanceof TFile) {
      console.log('File created:', file.path);
    }
  })
);

// File modified
this.registerEvent(
  this.app.vault.on('modify', (file) => {
    if (file instanceof TFile) {
      console.log('File modified:', file.path);
    }
  })
);

// File deleted
this.registerEvent(
  this.app.vault.on('delete', (file) => {
    console.log('File deleted:', file.path);
  })
);

// File renamed
this.registerEvent(
  this.app.vault.on('rename', (file, oldPath) => {
    console.log(`Renamed: ${oldPath} -> ${file.path}`);
  })
);
```

### Layout Events

```typescript
// Active file changed
this.registerEvent(
  this.app.workspace.on('active-leaf-change', (leaf) => {
    const file = this.app.workspace.getActiveFile();
    if (file) {
      console.log('Active file:', file.path);
    }
  })
);

// Layout changed
this.registerEvent(
  this.app.workspace.on('layout-change', () => {
    console.log('Layout changed');
  })
);
```

### Intervals

```typescript
// Auto-cleared on plugin unload
this.registerInterval(
  window.setInterval(() => {
    console.log('Periodic task');
  }, 60000) // Every minute
);
```

### DOM Events

```typescript
// Auto-removed on plugin unload
this.registerDomEvent(document, 'click', (evt: MouseEvent) => {
  console.log('Document clicked');
});
```

## File Operations

### Read File

```typescript
const file = this.app.vault.getAbstractFileByPath('path/to/file.md');
if (file instanceof TFile) {
  const content = await this.app.vault.read(file);
  // or cached read (faster, may be stale)
  const cachedContent = await this.app.vault.cachedRead(file);
}
```

### Write File

```typescript
const file = this.app.vault.getAbstractFileByPath('path/to/file.md');
if (file instanceof TFile) {
  await this.app.vault.modify(file, 'New content');
}
```

### Create File

```typescript
// Create with content
const newFile = await this.app.vault.create('path/to/new.md', 'Initial content');

// Create folder if needed
await this.app.vault.createFolder('path/to/folder');
```

### Delete File

```typescript
const file = this.app.vault.getAbstractFileByPath('path/to/file.md');
if (file instanceof TFile) {
  await this.app.vault.delete(file);
  // or move to trash
  await this.app.vault.trash(file, true);
}
```

### Get All Files

```typescript
const markdownFiles = this.app.vault.getMarkdownFiles();
const allFiles = this.app.vault.getFiles();
```

## Editor Manipulation

### Get/Set Selection

```typescript
editorCallback: (editor: Editor) => {
  // Get selection
  const selection = editor.getSelection();
  
  // Replace selection
  editor.replaceSelection('new text');
  
  // Get cursor position
  const cursor = editor.getCursor();
  
  // Set cursor position
  editor.setCursor({ line: 0, ch: 0 });
  
  // Get line content
  const line = editor.getLine(cursor.line);
  
  // Replace range
  editor.replaceRange('text', 
    { line: 0, ch: 0 }, 
    { line: 0, ch: 5 }
  );
}
```

### Get Full Document

```typescript
editorCallback: (editor: Editor) => {
  const content = editor.getValue();
  editor.setValue('Completely new content');
}
```

## Status Bar

```typescript
// Add status bar item
const statusBarEl = this.addStatusBarItem();
statusBarEl.setText('Status: Ready');

// Update later
statusBarEl.setText('Status: Processing...');

// Note: Status bar not available on mobile
```

## Context Menus

### File Menu

```typescript
this.registerEvent(
  this.app.workspace.on('file-menu', (menu, file) => {
    menu.addItem((item) => {
      item
        .setTitle('My action')
        .setIcon('star')
        .onClick(() => {
          new Notice(`Action on ${file.path}`);
        });
    });
  })
);
```

### Editor Menu

```typescript
this.registerEvent(
  this.app.workspace.on('editor-menu', (menu, editor, view) => {
    menu.addItem((item) => {
      item
        .setTitle('Process selection')
        .setIcon('wand')
        .onClick(() => {
          const selection = editor.getSelection();
          // Process selection
        });
    });
  })
);
```

## Views

### Custom View

```typescript
import { ItemView, WorkspaceLeaf } from 'obsidian';

const VIEW_TYPE = 'my-custom-view';

class MyView extends ItemView {
  constructor(leaf: WorkspaceLeaf) {
    super(leaf);
  }

  getViewType(): string {
    return VIEW_TYPE;
  }

  getDisplayText(): string {
    return 'My View';
  }

  getIcon(): string {
    return 'star';
  }

  async onOpen() {
    const container = this.containerEl.children[1];
    container.empty();
    container.createEl('h2', { text: 'My Custom View' });
  }

  async onClose() {
    // Cleanup
  }
}

// Register in plugin
this.registerView(VIEW_TYPE, (leaf) => new MyView(leaf));

// Open the view
this.app.workspace.getRightLeaf(false)?.setViewState({
  type: VIEW_TYPE,
  active: true,
});
```

### Activate Existing View

```typescript
async activateView() {
  const { workspace } = this.app;
  
  let leaf = workspace.getLeavesOfType(VIEW_TYPE)[0];
  
  if (!leaf) {
    const rightLeaf = workspace.getRightLeaf(false);
    if (rightLeaf) {
      await rightLeaf.setViewState({ type: VIEW_TYPE, active: true });
      leaf = rightLeaf;
    }
  }
  
  if (leaf) {
    workspace.revealLeaf(leaf);
  }
}
```


path: references/settings.md
content: # Settings UI Pattern

Complete implementation of the Obsidian settings tab pattern.

## Settings Interface & Defaults

```typescript
// src/settings.ts
import { App, PluginSettingTab, Setting } from 'obsidian';
import MyPlugin from './main';

export interface MyPluginSettings {
  mySetting: string;
  enableFeature: boolean;
  selectedOption: 'option1' | 'option2' | 'option3';
  numericValue: number;
}

export const DEFAULT_SETTINGS: MyPluginSettings = {
  mySetting: 'default',
  enableFeature: true,
  selectedOption: 'option1',
  numericValue: 10,
};
```

## Settings Tab Implementation

```typescript
export class MyPluginSettingTab extends PluginSettingTab {
  plugin: MyPlugin;

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

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

    // Section heading
    containerEl.createEl('h2', { text: 'My Plugin Settings' });

    // Text input
    new Setting(containerEl)
      .setName('Text setting')
      .setDesc('Enter a value')
      .addText(text => text
        .setPlaceholder('Enter value')
        .setValue(this.plugin.settings.mySetting)
        .onChange(async (value) => {
          this.plugin.settings.mySetting = value;
          await this.plugin.saveSettings();
        }));

    // Toggle
    new Setting(containerEl)
      .setName('Enable feature')
      .setDesc('Turn this feature on or off')
      .addToggle(toggle => toggle
        .setValue(this.plugin.settings.enableFeature)
        .onChange(async (value) => {
          this.plugin.settings.enableFeature = value;
          await this.plugin.saveSettings();
        }));

    // Dropdown
    new Setting(containerEl)
      .setName('Select option')
      .setDesc('Choose from available options')
      .addDropdown(dropdown => dropdown
        .addOption('option1', 'Option 1')
        .addOption('option2', 'Option 2')
        .addOption('option3', 'Option 3')
        .setValue(this.plugin.settings.selectedOption)
        .onChange(async (value: 'option1' | 'option2' | 'option3') => {
          this.plugin.settings.selectedOption = value;
          await this.plugin.saveSettings();
        }));

    // Slider
    new Setting(containerEl)
      .setName('Numeric value')
      .setDesc('Adjust the value (1-100)')
      .addSlider(slider => slider
        .setLimits(1, 100, 1)
        .setValue(this.plugin.settings.numericValue)
        .setDynamicTooltip()
        .onChange(async (value) => {
          this.plugin.settings.numericValue = value;
          await this.plugin.saveSettings();
        }));

    // Button
    new Setting(containerEl)
      .setName('Reset settings')
      .setDesc('Restore default values')
      .addButton(button => button
        .setButtonText('Reset')
        .setWarning()
        .onClick(async () => {
          this.plugin.settings = { ...DEFAULT_SETTINGS };
          await this.plugin.saveSettings();
          this.display(); // Refresh UI
        }));

    // Text area (multi-line)
    new Setting(containerEl)
      .setName('Notes')
      .setDesc('Enter multiple lines of text')
      .addTextArea(text => text
        .setPlaceholder('Enter notes here...')
        .setValue(this.plugin.settings.notes || '')
        .onChange(async (value) => {
          this.plugin.settings.notes = value;
          await this.plugin.saveSettings();
        }));
  }
}
```

## Register in Main Plugin

```typescript
// src/main.ts
import { MyPluginSettingTab } from './settings';

export default class MyPlugin extends Plugin {
  async onload() {
    await this.loadSettings();
    this.addSettingTab(new MyPluginSettingTab(this.app, this));
  }
}
```

## Advanced: Conditional Settings

Show/hide settings based on other values:

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

  new Setting(containerEl)
    .setName('Enable advanced mode')
    .addToggle(toggle => toggle
      .setValue(this.plugin.settings.advancedMode)
      .onChange(async (value) => {
        this.plugin.settings.advancedMode = value;
        await this.plugin.saveSettings();
        this.display(); // Re-render to show/hide dependent settings
      }));

  // Only show if advanced mode is enabled
  if (this.plugin.settings.advancedMode) {
    new Setting(containerEl)
      .setName('Advanced option')
      .setDesc('Only visible in advanced mode')
      .addText(text => text
        .setValue(this.plugin.settings.advancedOption)
        .onChange(async (value) => {
          this.plugin.settings.advancedOption = value;
          await this.plugin.saveSettings();
        }));
  }
}
```

## Settings Migration

Handle settings changes between versions:

```typescript
async loadSettings() {
  const data = await this.loadData();
  this.settings = Object.assign({}, DEFAULT_SETTINGS, data);
  
  // Migrate old settings
  if (data?.oldSettingName !== undefined) {
    this.settings.newSettingName = data.oldSettingName;
    delete (this.settings as any).oldSettingName;
    await this.saveSettings();
  }
}
```


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: clean (LOW)