Install
$ agentstack add skill-liamcain-obsidian-dev-skills-settings ✓ scanned · ✓ verified, works with Claude Code, Cursor, and more.
Security review
✓ PassedNo 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.
Verified badge
Passed review? Show it. Paste this badge into your README, it links to the public security report.
Reliability & compatibility
Declared compatibility
Compatibility is declared by the source manifest. End-to-end runtime verification is coming, see below.
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 →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:
validateis 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 runsvalidateonce 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
validatealone. - 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:
visibleon any definition (including groups, lists, and pages) — hides the row whenfalse. A hidden row is also excluded from global settings search for that render.disabledon acontrolor on anactiondefinition — 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
Settingrow — 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 aSettingPagesubclass. 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.
- Author: liamcain
- Source: liamcain/obsidian-dev-skills
- License: MIT
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet, be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.