AgentStack
Browse Sign in
Browse Why AgentStack Sell Docs
Sign in
SKILL verified MIT Self-run

Settings

skill-liamcain-obsidian-dev-skills-settings · by liamcain

Author or migrate community-plugin setting tabs (PluginSettingTab) in Obsidian. Covers the declarative getSettingDefinitions() API introduced in 1.13.0 and the recommended pattern for supporting older app versions alongside it. Use when working on a class that extends PluginSettingTab, when migrating an imperative display() override, when adding a new settings page, or when adopting new control t…

No reviews yet
0 installs
25 views
0.0% view→install

Install

$ agentstack add skill-liamcain-obsidian-dev-skills-settings

✓ scanned · ✓ verified, works with Claude Code, Cursor, and more.

Security review

✓ Passed

No issues found. Passed automated security review. · v0.1.0 How review works →

  • Prompt-injection patterns
  • Secret / credential exfiltration
  • Dangerous shell & filesystem operations
  • Untrusted network calls
  • Known-malicious package signatures

What it can access

  • Network access No
  • Filesystem access No
  • Shell / process execution No
  • Environment & secrets No
  • Dynamic code execution No

From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.

View the full security report →

Verified badge

Passed review? Show it. Paste this badge into your README, it links to the public security report.

AgentStack Verified badge Links to your public security report.
[![AgentStack Verified](https://agentstack.voostack.com/badges/verified.svg)](https://agentstack.voostack.com/security/report/skill-liamcain-obsidian-dev-skills-settings)

Reliability & compatibility

Security review passed
0 installs to date
no reviews yet
3mo ago

Declared compatibility

Claude CodeClaude Desktop

Compatibility is declared by the source manifest. End-to-end runtime verification is coming, see below.

Preview Execution monitoring

We're building live execution health for every listing: tool-call success rate, median latency, uptime, and last-checked timestamps, measured, not self-reported. It isn't live yet, so we don't show numbers we can't stand behind.

How agent discovery & health will work →
Are you the author of Settings? Claim this listing to set pricing, connect Stripe payouts, and keep 70% of every sale.
Sign up to claim

About

Authoring Obsidian plugin settings

This skill is for community plugins that extend PluginSettingTab. Obsidian 1.13.0 introduced a declarative API (getSettingDefinitions()) that replaces the imperative display() body for the common case. The framework still calls display() on older versions and as a fallback when no definitions are returned, so plugins can adopt the new API without dropping support for older app versions.

Quick decision

| Your plugin's minAppVersion | What to do | |---|---| | >= 1.13.0 | Implement getSettingDefinitions() only. Delete display(). | | ` f.extension === 'md' } }

{ name: 'Output folder', control: { type: 'folder', key: 'outputDir', includeRoot: true } }

{ name: 'Accent color', control: { type: 'color', key: 'accent' } }


`defaultValue` is the fallback when the stored value is `undefined`/`null`.

**Control types:**

| Type | Stored value | Notes |
|---|---|---|
| `toggle` | `boolean` | |
| `dropdown` | `string` | `options: { value: 'Display', … }` |
| `text` | `string` | `placeholder?` |
| `textarea` | `string` | `placeholder?`, `rows?` |
| `number` | `number` | `min?`, `max?`, `step?`, `placeholder?`. Commits on blur/Enter. Out-of-range and unparseable input shows an inline error and rejects the change. |
| `slider` | `number` | `min`, `max`, `step` all required |
| `file` | `string` (path) | `filter?: (file: TFile) => boolean`, `placeholder?` |
| `folder` | `string` (path) | `filter?: (folder: TFolder) => boolean`, `includeRoot?` (default `false`), `placeholder?` |
| `color` | `string` (hex) | |

## `validate`: reject invalid values

Every `control` accepts an optional `validate` callback. Return a non-empty string to reject the change and surface it as an inline error below the input. Return `void`/`undefined`/empty string to accept and persist.

```ts
{
    name: 'File extension',
    control: {
        type: 'text',
        key: 'extension',
        validate: (value) => /\s/.test(value) ? 'Extension cannot contain spaces.' : undefined,
    },
}

Async validators work too — return a Promise.

Important semantics:

  • validate is a UI gate, not a data invariant. The stored value may already be invalid when the setting is rendered (e.g. data from an older version of your plugin). The framework runs validate once on mount and shows the message if the seeded value fails; it does not modify or replace the stored value.
  • If your plugin needs to enforce invariants on stored data, validate again when reading your settings — don't rely on validate alone.
  • Most useful on text-bearing controls (text, textarea, number, file, folder).

Conditional visibility and disabled state

Two predicates toggle a setting's state without rebuilding the tab:

  • visible on any definition (including groups, lists, and pages) — hides the row when false. A hidden row is also excluded from global settings search for that render.
  • disabled on a control or on an action definition — disables interaction without hiding the row.

Both accept boolean | (() => boolean). The function form is re-evaluated on every DOM-state refresh. For control definitions the framework refreshes automatically after every change. After mutating dependent state from a render callback or other imperative path, call this.refreshDomState() to re-run the predicates without a full re-render.

getSettingDefinitions() {
    return [
        { name: 'Enable advanced mode', control: { type: 'toggle', key: 'advanced' } },
        {
            name: 'Debug log level',
            desc: 'Only relevant when advanced mode is on.',
            visible: () => this.plugin.settings.advanced,
            control: { type: 'dropdown', key: 'logLevel', options: { info: 'Info', verbose: 'Verbose' } },
        },
        {
            name: 'Cache size',
            control: {
                type: 'number',
                key: 'cacheMb',
                min: 1,
                disabled: () => !this.plugin.settings.advanced,
            },
        },
    ];
}

visible vs disabled: use visible when the setting is irrelevant in the current configuration (nothing meaningful for the user to read or change). Use disabled when the setting is meaningful but currently locked (a prerequisite isn't met, a paid feature isn't unlocked) — keep it visible so the user understands the option exists.

When to use update() instead: refreshDomState only re-evaluates predicates on already-rendered items. If the set of definitions changes (rows added or removed), call this.update() to rebuild from getSettingDefinitions().

Use render for everything else

When a setting needs anything beyond a simple bind — side effects, custom UI, suggesters not covered by file/folder, custom controls — use a render callback. For hiding rows based on another setting, use the [visible predicate](#conditional-visibility-and-disabled-state) instead.

{
    name: 'Enable feature X',
    render: (setting) => {
        setting.addToggle(toggle => toggle
            .setValue(this.plugin.settings.featureX)
            .onChange(async (value) => {
                this.plugin.settings.featureX = value;
                this.plugin.applyFeatureX();
                await this.plugin.saveData(this.plugin.settings);
            }));
    },
}

Always await this.plugin.saveData(this.plugin.settings) after mutating settings inside render — the framework only saves automatically for control bindings.

When the set of definitions changes (rows added or removed), call this.update() from the parent's onChange to rebuild. (Don't use this.display() for this — see [Common pitfalls](#common-pitfalls).) For pure show/hide, prefer the [visible predicate](#conditional-visibility-and-disabled-state).

Cleanup

If the render callback subscribes to anything that outlives the DOM — a ResizeObserver, a MutationObserver, a setInterval, or anything that wouldn't be garbage-collected when the row is removed — return a cleanup function. The framework invokes it before the row is torn down (re-render, page navigation, tab switch, or modal close).

{
    name: 'Live preview',
    render: (setting) => {
        let previewEl = setting.controlEl.createDiv('preview');
        let observer = new ResizeObserver(() => {
            previewEl.setText(`${previewEl.clientWidth}px`);
        });
        observer.observe(previewEl);
        return () => observer.disconnect();
    },
}
  • Don't clean up plain DOM listeners attached to elements inside the Setting row — they go with the DOM.
  • Don't register workspace/vault events in render — they should live as long as the plugin. Register them on the plugin instance instead. See [Reacting to external state changes](#reacting-to-external-state-changes).
  • Cleanup is not guaranteed when the host window is destroyed (e.g. renderer crash). For state that must be released, register it on the plugin.

Custom storage with getControlValue / setControlValue

control definitions read and write this.plugin.settings by default — key: 'foo' corresponds to this.plugin.settings.foo, and the framework calls this.plugin.saveData(this.plugin.settings) on every change. If your plugin keeps settings somewhere else (a Svelte store, a reactive proxy, an immutable update mechanism), override the two binding hooks:

class MyTab extends PluginSettingTab {
    plugin: MyPlugin;

    getControlValue(key: string): unknown {
        return this.plugin.getStateValue(key);
    }

    async setControlValue(key: string, value: unknown): Promise {
        await this.plugin.updateState(key, value);
    }

    getSettingDefinitions() { /* … */ }
}

The framework calls getControlValue(key) on every render and setControlValue(key, value) on every user change. Returning a Promise from setControlValue is supported — the framework awaits it. Predicates like visible and disabled don't go through these hooks; they read state directly.

For nested settings ('editor.fontSize' style dot-paths), see [examples/nested-settings.md](examples/nested-settings.md).

Group with SettingDefinitionGroup

Inline groups give a heading and shared layout to related settings.

{
    type: 'group',
    heading: 'Advanced',
    items: [
        { name: 'Debug logging', control: { type: 'toggle', key: 'debug' } },
        { name: 'Cache size', control: { type: 'number', key: 'cacheMb', min: 1 } },
    ],
}

Groups also accept search (a search input in the header), extraButtons (header-level action buttons), cls (extra CSS class on the group element), and visible (hide the group entirely).

Mutable lists with type: 'list'

For collections the user adds, removes, or reorders, use type: 'list' instead of 'group'. A list is rendered with a denser visual style and supports emptyState, onDelete, onReorder, and addItem (a platform-appropriate add affordance — a + button in the header on desktop, a tappable row below the list on mobile).

{
    type: 'list',
    heading: 'Watched folders',
    emptyState: 'No folders being watched yet.',
    addItem: {
        name: 'Add folder',
        action: () => this.openAddFolderModal(),
    },
    onReorder: async (oldIndex, newIndex) => {
        let folders = this.plugin.settings.folders;
        let [moved] = folders.splice(oldIndex, 1);
        folders.splice(newIndex, 0, moved);
        await this.plugin.saveData(this.plugin.settings);
    },
    onDelete: async (idx) => {
        this.plugin.settings.folders.splice(idx, 1);
        await this.plugin.saveData(this.plugin.settings);
        this.update();
    },
    items: this.plugin.settings.folders.map((path) => ({
        name: path,
        searchable: false,
    })),
}

See [examples/mutable-list.md](examples/mutable-list.md) for the full canonical pattern. When new entries need a multi-field form (not a single inline input), open a Modal from addItem.action — see [examples/mutable-list-with-form.md](examples/mutable-list-with-form.md).

Sub-pages with SettingDefinitionPage

Use sparingly — only break a section out into a sub-page when the parent tab is too long to scan, or the section is a self-contained concept.

Two forms:

  • Declarative (items) — content is a list of definitions. The framework renders the page automatically.
  • Imperative (page) — content is rendered by a SettingPage subclass. Use when the page's UI is dynamic or can't be expressed as a list of definitions.

Pages can be nested. Page names must be unique among their siblings at the same depth (the framework warns at the console when duplicates are detected).

For imperative pages, override hide() to release anything that outlives the DOM (timers, observers). It runs when the user navigates away, the containing tab is switched, or the settings modal is closed — but not when the host window is destroyed without a graceful close.

See [examples/page-navigation.md](examples/page-navigation.md).

Reacting to external state changes

If the tab displays state that changes elsewhere — vault contents, the list of enabled plugins, a value the plugin computes in the background — the tab goes stale while the user has it open. Call this.update() to re-run getSettingDefinitions() and rebuild.

Register the listeners on the plugin, not the settings tab. Plugin is a Component; registerEvent ties the listener's lifetime to plugin unload. A settings tab is built and discarded on every modal open — registering events on the tab would leak or require manual bookkeeping.

import { Plugin, debounce } from 'obsidian';

export default class MyPlugin extends Plugin {
    settingTab: MyTab;

    async onload() {
        await this.loadSettings();
        this.settingTab = new MyTab(this.app, this);
        this.addSettingTab(this.settingTab);

        let refresh = debounce(() => this.settingTab.update(), 200, true);
        this.registerEvent(this.app.vault.on('create', refresh));
        this.registerEvent(this.app.vault.on('delete', refresh));
        this.registerEvent(this.app.vault.on('rename', refresh));
    }
}

update() is safe to call when the modal is closed — it refreshes the tab's stored definitions and the search index. The next modal open shows fresh content. Debounce bursty events so a folder-wide rename doesn't trigger one re-render per file.

Don't reach for external events to hide a row based on another setting's value — that's what the [visible predicate](#conditional-visibility-and-disabled-state) is for. External events are for state your plugin doesn't itself own.

Style guide

Sentence case for all UI text

Names, descriptions, headings, button labels, placeholders — anything the user reads in your tab. Only the first word and proper nouns are capitalized.

  • ✅ "Template folder location" — ❌ "Template Folder Location"
  • ✅ "Create new note" — ❌ "Create New Note"

No top-level heading

Don't add a "General", "Settings", or plugin-name heading at the top of the tab. The tab title in the sidebar already names the plugin.

// ❌ don't
return [
    { type: 'group', heading: 'My Plugin', items: [/* … */] },
];

// ✅ do
return [
    { name: 'Foo', control: { type: 'toggle', key: 'foo' } },
    { name: 'Bar', control: { type: 'toggle', key: 'bar' } },
];

Headings only when there are multiple sections

If the whole tab is one section, don't put any group heading. Add headings only once you have two or more distinct sections to separate.

When there are multiple sections and one is "general", leave the general settings at the top with no heading and start headings at the second section. (Mirrors what Obsidian's core tabs do — see Settings → Appearance.)

return [
    // General — no heading
    { name: 'Default folder', control: { type: 'folder', key: 'folder' } },
    { name: 'Open on launch', control: { type: 'toggle', key: 'openOnLaunch' } },

    // Subsequent sections get headings
    { type: 'group', heading: 'Appearance', items: [/* … */] },
    { type: 'group', heading: 'Advanced', items: [/* … */] },
];

Don't repeat "settings" in headings

Everything under the tab is settings; saying so in every heading is redundant.

  • ✅ "Advanced" — ❌ "Advanced settings"
  • ✅ "Templates" — ❌ "Settings for templates"

Save on change, not on submit

A setting in the tab persists the moment the user changes it. control definitions auto-save; in render, await this.plugin.saveData(this.plugin.settings) from inside onChange. Never gate persistence on the user navigating away.

If a setting is too complex to commit per keystroke — multiple required fields, cross-field validation, an entry that's only meaningful when fully constructed — it doesn't belong directly in the tab. Surface it through a Modal with explicit Save/Cancel and have the tab store the result. Settings tabs and sub-pages aren't forms.

One control per setting row

Each row should have a single mutable control.

  • ✅ One row → one toggle, one dropdown, one text input.
  • ❌ Two text inputs in one row, or a text input next to a dropdown.

Multiple controls per row stack vertically on mobile, breaking the tab's visual rhythm and harming readability. When you genuinely need to capture multiple values together (name + path, start + end), use the [mutable list with a form modal](examples/mutable-list-with-form.md) pattern: the tab shows a list of finished entries, and an Add button opens the modal that builds one.

Avoid textareas in the main tab

A textarea is much taller than every other control and disrupts the regular row rhythm of the tab. If you need to collect multi-line text, move it into a form modal (see [examples/mutable-list-with-form.md](examples/mutable-list-with-form.md)) or — when the textarea has to live on the tab

Source & license

This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.

Install and usage instructions live in the source repository linked above.

Reviews

No reviews yet, be the first.

Versions

  • v0.1.0 Imported from the upstream source.