Claude-code-plugins-plus-skills obsidian-reference-architecture
install
source · Clone the upstream repo
git clone https://github.com/jeremylongshore/claude-code-plugins-plus-skills
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/jeremylongshore/claude-code-plugins-plus-skills "$T" && mkdir -p ~/.claude/skills && cp -r "$T/plugins/saas-packs/obsidian-pack/skills/obsidian-reference-architecture" ~/.claude/skills/jeremylongshore-claude-code-plugins-plus-skills-obsidian-reference-architecture && rm -rf "$T"
manifest:
plugins/saas-packs/obsidian-pack/skills/obsidian-reference-architecture/SKILL.mdsource content
Obsidian Reference Architecture
Overview
Architecture patterns for complex Obsidian plugins: modular project structure with separate files for views, commands, settings, and services; state management; a service layer for vault operations; command registry; view manager; and CSS scoping.
Prerequisites
- TypeScript and Obsidian API familiarity
- Working build pipeline (esbuild recommended)
- Plugin scaffolding complete (
,manifest.json
,package.json
)tsconfig.json
Instructions
Step 1: Project Structure
my-plugin/ ├── src/ │ ├── main.ts # Plugin entry — thin orchestrator │ ├── types.ts # Shared interfaces and type definitions │ ├── constants.ts # Plugin-wide constants │ ├── commands/ │ │ ├── index.ts # Command registry │ │ ├── insert-template.ts │ │ └── toggle-sidebar.ts │ ├── views/ │ │ ├── index.ts # View registry │ │ ├── sidebar-view.ts │ │ └── modal-view.ts │ ├── settings/ │ │ ├── settings.ts # Settings interface and defaults │ │ └── settings-tab.ts # Settings UI tab │ └── services/ │ ├── vault-service.ts # File read/write/search operations │ ├── metadata-service.ts # Frontmatter and cache operations │ └── sync-service.ts # External sync or background tasks ├── styles.css ├── manifest.json ├── versions.json ├── package.json ├── tsconfig.json └── esbuild.config.mjs
Step 2: Thin Main Entry Point
// src/main.ts — orchestrates, does not implement import { Plugin } from 'obsidian'; import { MyPluginSettings, DEFAULT_SETTINGS } from './settings/settings'; import { MySettingTab } from './settings/settings-tab'; import { registerCommands } from './commands'; import { registerViews } from './views'; import { VaultService } from './services/vault-service'; export default class MyPlugin extends Plugin { settings: MyPluginSettings; vaultService: VaultService; async onload() { await this.loadSettings(); this.vaultService = new VaultService(this.app); registerCommands(this); registerViews(this); this.addSettingTab(new MySettingTab(this.app, this)); } onunload() { // Services clean up their own resources this.vaultService.destroy(); } async loadSettings() { this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); } async saveSettings() { await this.saveData(this.settings); } }
Step 3: Command Registry Pattern
// src/commands/index.ts import type MyPlugin from '../main'; import { insertTemplate } from './insert-template'; import { toggleSidebar } from './toggle-sidebar'; export function registerCommands(plugin: MyPlugin) { plugin.addCommand({ id: 'insert-template', name: 'Insert Template', editorCallback: (editor, view) => insertTemplate(plugin, editor, view), }); plugin.addCommand({ id: 'toggle-sidebar', name: 'Toggle Sidebar', callback: () => toggleSidebar(plugin), }); }
// src/commands/insert-template.ts import { Editor, MarkdownView } from 'obsidian'; import type MyPlugin from '../main'; export function insertTemplate(plugin: MyPlugin, editor: Editor, view: MarkdownView) { const template = plugin.settings.defaultTemplate; editor.replaceSelection(template); }
Step 4: View Manager
// src/views/index.ts import type MyPlugin from '../main'; import { SidebarView, VIEW_TYPE_SIDEBAR } from './sidebar-view'; export function registerViews(plugin: MyPlugin) { plugin.registerView(VIEW_TYPE_SIDEBAR, (leaf) => new SidebarView(leaf, plugin)); // Add ribbon icon to activate view plugin.addRibbonIcon('layout-sidebar-right', 'Open Sidebar', () => { activateView(plugin); }); } async function activateView(plugin: MyPlugin) { const { workspace } = plugin.app; let leaf = workspace.getLeavesOfType(VIEW_TYPE_SIDEBAR)[0]; if (!leaf) { const rightLeaf = workspace.getRightLeaf(false); if (rightLeaf) { await rightLeaf.setViewState({ type: VIEW_TYPE_SIDEBAR, active: true }); leaf = rightLeaf; } } if (leaf) { workspace.revealLeaf(leaf); } }
// src/views/sidebar-view.ts import { ItemView, WorkspaceLeaf } from 'obsidian'; import type MyPlugin from '../main'; export const VIEW_TYPE_SIDEBAR = 'my-plugin-sidebar'; export class SidebarView extends ItemView { plugin: MyPlugin; constructor(leaf: WorkspaceLeaf, plugin: MyPlugin) { super(leaf); this.plugin = plugin; } getViewType(): string { return VIEW_TYPE_SIDEBAR; } getDisplayText(): string { return 'My Plugin'; } getIcon(): string { return 'layout-sidebar-right'; } async onOpen() { const container = this.containerEl.children[1]; container.empty(); container.addClass('my-plugin-sidebar'); container.createEl('h3', { text: 'My Plugin' }); const list = container.createEl('ul'); // Populate from service layer const files = await this.plugin.vaultService.getRecentFiles(10); for (const file of files) { list.createEl('li', { text: file.basename }); } } async onClose() { // Clean up DOM references this.containerEl.empty(); } }
Step 5: Service Layer for Vault Operations
// src/services/vault-service.ts import { App, TFile, TFolder, CachedMetadata } from 'obsidian'; export class VaultService { constructor(private app: App) {} // File operations async readFile(path: string): Promise<string> { const file = this.app.vault.getAbstractFileByPath(path); if (!(file instanceof TFile)) throw new Error(`Not a file: ${path}`); return this.app.vault.read(file); } async writeFile(path: string, content: string): Promise<void> { const file = this.app.vault.getAbstractFileByPath(path); if (file instanceof TFile) { await this.app.vault.modify(file, content); } else { await this.app.vault.create(path, content); } } // Search operations getFilesInFolder(folderPath: string): TFile[] { const folder = this.app.vault.getAbstractFileByPath(folderPath); if (!(folder instanceof TFolder)) return []; return folder.children.filter((f): f is TFile => f instanceof TFile); } getRecentFiles(limit: number): TFile[] { return this.app.vault.getMarkdownFiles() .sort((a, b) => b.stat.mtime - a.stat.mtime) .slice(0, limit); } // Metadata operations getMetadata(file: TFile): CachedMetadata | null { return this.app.metadataCache.getFileCache(file); } getFrontmatter(file: TFile): Record<string, unknown> | undefined { return this.getMetadata(file)?.frontmatter; } // Cleanup destroy() { // Release any held references } }
Step 6: Settings Architecture
// src/settings/settings.ts export interface MyPluginSettings { version: number; defaultTemplate: string; showStatusBar: boolean; syncInterval: number; } export const DEFAULT_SETTINGS: MyPluginSettings = { version: 1, defaultTemplate: '## New Section\n\n', showStatusBar: true, syncInterval: 300, };
// src/settings/settings-tab.ts import { App, PluginSettingTab, Setting } from 'obsidian'; import type MyPlugin from '../main'; export class MySettingTab 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('Default template') .setDesc('Content inserted by the Insert Template command') .addTextArea(text => text .setValue(this.plugin.settings.defaultTemplate) .onChange(async (value) => { this.plugin.settings.defaultTemplate = value; await this.plugin.saveSettings(); })); new Setting(containerEl) .setName('Show status bar') .setDesc('Display plugin status in the bottom bar') .addToggle(toggle => toggle .setValue(this.plugin.settings.showStatusBar) .onChange(async (value) => { this.plugin.settings.showStatusBar = value; await this.plugin.saveSettings(); })); new Setting(containerEl) .setName('Sync interval') .setDesc('Seconds between background syncs (0 to disable)') .addSlider(slider => slider .setLimits(0, 3600, 60) .setValue(this.plugin.settings.syncInterval) .setDynamicTooltip() .onChange(async (value) => { this.plugin.settings.syncInterval = value; await this.plugin.saveSettings(); })); } }
Step 7: CSS Architecture with Plugin-Scoped Classes
/* styles.css — all classes prefixed with plugin id */ /* Layout */ .my-plugin-sidebar { padding: 8px 12px; } .my-plugin-sidebar h3 { margin: 0 0 12px; font-size: var(--font-ui-medium); color: var(--text-normal); } .my-plugin-sidebar ul { list-style: none; padding: 0; margin: 0; } .my-plugin-sidebar li { padding: 4px 8px; border-radius: var(--radius-s); cursor: pointer; color: var(--text-muted); } .my-plugin-sidebar li:hover { background: var(--background-modifier-hover); color: var(--text-normal); } /* Modal styles */ .my-plugin-modal .modal-content { padding: 16px; } /* Use Obsidian CSS variables — never hardcode colors */ .my-plugin-highlight { background: var(--background-modifier-success); color: var(--text-on-accent); border-radius: var(--radius-s); padding: 2px 6px; } /* Responsive: Obsidian handles mobile layout, but adjust spacing */ .is-mobile .my-plugin-sidebar { padding: 4px 8px; } .is-mobile .my-plugin-sidebar li { padding: 8px; /* Larger touch targets */ }
Key CSS rules:
- Prefix every class with your plugin id to avoid collisions
- Use Obsidian CSS variables (
,--text-normal
, etc.) for theme compatibility--background-modifier-hover - Use
for mobile-specific overrides.is-mobile - Never use
— it breaks theme compatibility!important
Step 8: State Management Pattern
For plugins with complex state (multiple views, background sync, shared data):
// src/services/state-service.ts import { Events } from 'obsidian'; interface PluginState { isProcessing: boolean; lastSync: number | null; activeItems: string[]; } export class StateService extends Events { private state: PluginState = { isProcessing: false, lastSync: null, activeItems: [], }; get(): Readonly<PluginState> { return this.state; } update(partial: Partial<PluginState>) { Object.assign(this.state, partial); this.trigger('state-changed', this.state); } // Views subscribe to state changes // In a view: plugin.stateService.on('state-changed', (state) => this.refresh(state)); }
Output
- Modular
directory with separate folders for commands, views, settings, and servicessrc/ - Thin
that wires components together without implementing business logicmain.ts - Command registry that scales to dozens of commands without cluttering main.ts
- View manager with proper lifecycle (onOpen/onClose) and leaf management
- Service layer abstracting vault operations behind a clean API
- Type-safe settings with defaults and migration support
- Plugin-scoped CSS using Obsidian's CSS variable system
Error Handling
| Issue | Cause | Solution |
|---|---|---|
| Circular dependencies | Services importing main, main importing services | Use imports or interface segregation |
| Missing types at runtime | Interface-only imports | Create for shared interfaces |
| Event listener leaks | Direct calls | Use for automatic cleanup |
| Settings lost on upgrade | No migration path | Version your settings interface (see ) |
| CSS conflicts with other plugins | Generic class names | Prefix all classes with your plugin id |
| View not restoring on restart | Missing | Register in , Obsidian restores state from layout |
| Service holds stale references | No cleanup on unload | Call on services in |
Examples
Adding a New Command
// 1. Create src/commands/export-notes.ts import type MyPlugin from '../main'; export async function exportNotes(plugin: MyPlugin) { const files = plugin.vaultService.getRecentFiles(50); // ... export logic } // 2. Register in src/commands/index.ts import { exportNotes } from './export-notes'; // Add to registerCommands(): plugin.addCommand({ id: 'export-notes', name: 'Export Recent Notes', callback: () => exportNotes(plugin), });
Adding a New Service
// src/services/search-service.ts import { App, TFile, PreparedQuery, prepareQuery, fuzzySearch } from 'obsidian'; export class SearchService { constructor(private app: App) {} fuzzyFind(query: string): TFile[] { const prepared: PreparedQuery = prepareQuery(query); return this.app.vault.getMarkdownFiles() .filter(f => fuzzySearch(prepared, f.basename)?.score !== undefined) .sort((a, b) => { const sa = fuzzySearch(prepared, a.basename)?.score ?? -Infinity; const sb = fuzzySearch(prepared, b.basename)?.score ?? -Infinity; return sb - sa; }); } }
Resources
Next Steps
SDK patterns:
obsidian-sdk-patterns. Production readiness: obsidian-prod-checklist.