feat: initial release - AI agent skills manager for Obsidian

Discover, organize, and edit AI coding agent skills across Claude Code,
Cursor, Codex, Windsurf, Copilot, and more from inside Obsidian.

- Multi-tool skill discovery (13 tools supported)
- Three-column view: sidebar, list, detail
- Real-time file watching with debounce
- YAML frontmatter parsing and metadata display
- Built-in editor with Cmd+S save
- Full-text search across all skills
- Token/char count estimation
- Real SVG logos from tryelements.dev registry
- Filter by tool, type, favorites, collections
This commit is contained in:
Railly 2026-03-28 08:16:54 -05:00
commit 7306bddb0a
20 changed files with 2191 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
node_modules/
data.json

97
bun.lock Normal file
View file

@ -0,0 +1,97 @@
{
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "obsidian-agent-skills",
"devDependencies": {
"@types/node": "^22.0.0",
"esbuild": "^0.25.0",
"obsidian": "latest",
"typescript": "^5.7.0",
},
},
},
"packages": {
"@codemirror/state": ["@codemirror/state@6.5.0", "", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw=="],
"@codemirror/view": ["@codemirror/view@6.38.6", "", { "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
"@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.2", "", {}, "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g=="],
"@types/codemirror": ["@types/codemirror@5.60.8", "", { "dependencies": { "@types/tern": "*" } }, "sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="],
"@types/tern": ["@types/tern@0.23.9", "", { "dependencies": { "@types/estree": "*" } }, "sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw=="],
"crelt": ["crelt@1.0.6", "", {}, "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="],
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
"moment": ["moment@2.29.4", "", {}, "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w=="],
"obsidian": ["obsidian@1.12.3", "", { "dependencies": { "@types/codemirror": "5.60.8", "moment": "2.29.4" }, "peerDependencies": { "@codemirror/state": "6.5.0", "@codemirror/view": "6.38.6" } }, "sha512-HxWqe763dOqzXjnNiHmAJTRERN8KILBSqxDSEqbeSr7W8R8Jxezzbca+nz1LiiqXnMpM8lV2jzAezw3CZ4xNUw=="],
"style-mod": ["style-mod@4.1.3", "", {}, "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="],
}
}

39
esbuild.config.mjs Normal file
View file

@ -0,0 +1,39 @@
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",
],
platform: "node",
format: "cjs",
target: "es2022",
logLevel: "info",
sourcemap: prod ? false : "inline",
treeShaking: true,
outfile: "main.js",
minify: prod,
});
if (prod) {
await context.rebuild();
process.exit(0);
} else {
await context.watch();
}

1
main.js Normal file

File diff suppressed because one or more lines are too long

10
manifest.json Normal file
View file

@ -0,0 +1,10 @@
{
"id": "obsidian-agent-skills",
"name": "Agent Skills",
"version": "0.1.0",
"minAppVersion": "1.4.11",
"description": "Discover, organize, and edit AI coding agent skills across Claude Code, Cursor, Codex, Windsurf, and more.",
"author": "Railly Hugo",
"authorUrl": "https://railly.dev",
"isDesktopOnly": true
}

16
package.json Normal file
View file

@ -0,0 +1,16 @@
{
"name": "obsidian-agent-skills",
"version": "0.1.0",
"description": "AI Skills Manager for Obsidian",
"main": "main.js",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "node esbuild.config.mjs production"
},
"devDependencies": {
"@types/node": "^22.0.0",
"esbuild": "^0.25.0",
"obsidian": "latest",
"typescript": "^5.7.0"
}
}

86
src/main.ts Normal file
View file

@ -0,0 +1,86 @@
import { Plugin, type WorkspaceLeaf } from "obsidian";
import { AgentSkillsView, VIEW_TYPE } from "./views/main-view";
import { SkillStore } from "./store";
import { SkillWatcher } from "./watcher";
import { getWatchPaths } from "./scanner";
import { AgentSkillsSettingTab } from "./settings";
import { DEFAULT_SETTINGS, type ChopsSettings } from "./types";
export default class AgentSkillsPlugin extends Plugin {
settings: ChopsSettings = DEFAULT_SETTINGS;
store: SkillStore = new SkillStore();
private watcher: SkillWatcher | null = null;
async onload(): Promise<void> {
await this.loadSettings();
this.registerView(VIEW_TYPE, (leaf) =>
new AgentSkillsView(
leaf,
this.store,
this.settings,
() => this.saveSettings()
)
);
this.addRibbonIcon("cpu", "Agent Skills", () => this.activateView());
this.addCommand({
id: "open-agent-skills",
name: "Open Agent Skills",
callback: () => this.activateView(),
});
this.addSettingTab(new AgentSkillsSettingTab(this.app, this));
this.refreshStore();
this.startWatcher();
}
onunload(): void {
this.stopWatcher();
}
refreshStore(): void {
this.store.refresh(this.settings);
}
startWatcher(): void {
if (!this.settings.watchEnabled) return;
this.watcher = new SkillWatcher(this.settings.watchDebounceMs, () =>
this.refreshStore()
);
this.watcher.watchPaths(getWatchPaths());
}
stopWatcher(): void {
if (this.watcher) {
this.watcher.close();
this.watcher = null;
}
}
restartWatcher(): void {
this.stopWatcher();
this.startWatcher();
}
async activateView(): Promise<void> {
const existing = this.app.workspace.getLeavesOfType(VIEW_TYPE);
if (existing.length > 0) {
this.app.workspace.revealLeaf(existing[0]);
return;
}
const leaf = this.app.workspace.getLeaf("tab");
await leaf.setViewState({ type: VIEW_TYPE, active: true });
this.app.workspace.revealLeaf(leaf);
}
async loadSettings(): Promise<void> {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings(): Promise<void> {
await this.saveData(this.settings);
}
}

243
src/scanner.ts Normal file
View file

@ -0,0 +1,243 @@
import {
existsSync,
readdirSync,
readFileSync,
lstatSync,
realpathSync,
statSync,
} from "fs";
import { join, basename, extname } from "path";
import { parseYaml } from "obsidian";
import { createHash } from "crypto";
import { TOOL_CONFIGS } from "./tool-configs";
import type { SkillItem, SkillPath, SkillType, ChopsSettings } from "./types";
const IGNORED_FILES = new Set([
"readme.md",
"license",
"license.md",
"changelog.md",
".ds_store",
"thumbs.db",
]);
function hashPath(p: string): string {
return createHash("sha256").update(p).digest("hex").slice(0, 12);
}
function parseFrontmatter(raw: string): {
frontmatter: Record<string, unknown>;
content: string;
} {
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
if (!match) {
return { frontmatter: {}, content: raw };
}
try {
const parsed = parseYaml(match[1]);
return {
frontmatter: typeof parsed === "object" && parsed ? parsed : {},
content: match[2],
};
} catch {
return { frontmatter: {}, content: raw };
}
}
function extractName(
frontmatter: Record<string, unknown>,
content: string,
filePath: string
): string {
if (typeof frontmatter.name === "string" && frontmatter.name) {
return frontmatter.name;
}
const h1 = content.match(/^#\s+(.+)$/m);
if (h1) return h1[1].trim();
const name = basename(filePath, extname(filePath));
if (name === "SKILL") return basename(join(filePath, ".."));
return name;
}
function scanDirectoryWithSkillMd(
baseDir: string,
type: SkillType,
toolId: string
): SkillItem[] {
if (!existsSync(baseDir)) return [];
const items: SkillItem[] = [];
for (const entry of readdirSync(baseDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const skillFile = join(baseDir, entry.name, "SKILL.md");
if (!existsSync(skillFile)) continue;
const item = parseSkillFile(skillFile, type, toolId);
if (item) items.push(item);
}
return items;
}
function scanFlatMd(
baseDir: string,
type: SkillType,
toolId: string
): SkillItem[] {
if (!existsSync(baseDir)) return [];
const items: SkillItem[] = [];
for (const entry of readdirSync(baseDir, { withFileTypes: true })) {
if (entry.isDirectory()) {
const skillFile = join(baseDir, entry.name, "SKILL.md");
if (existsSync(skillFile)) {
const item = parseSkillFile(skillFile, type, toolId);
if (item) items.push(item);
continue;
}
const mdFiles = readdirSync(join(baseDir, entry.name)).filter(
(f) => f.endsWith(".md") && !IGNORED_FILES.has(f.toLowerCase())
);
const preferred =
mdFiles.find(
(f) => f.toLowerCase() === `${entry.name.toLowerCase()}.md`
) || mdFiles[0];
if (preferred) {
const item = parseSkillFile(
join(baseDir, entry.name, preferred),
type,
toolId
);
if (item) items.push(item);
}
continue;
}
const name = entry.name.toLowerCase();
if (!name.endsWith(".md") || IGNORED_FILES.has(name)) continue;
const item = parseSkillFile(join(baseDir, entry.name), type, toolId);
if (item) items.push(item);
}
return items;
}
function scanMdc(
baseDir: string,
type: SkillType,
toolId: string
): SkillItem[] {
if (!existsSync(baseDir)) return [];
const items: SkillItem[] = [];
for (const entry of readdirSync(baseDir, { withFileTypes: true })) {
if (!entry.name.endsWith(".mdc") && !entry.name.endsWith(".md")) continue;
if (entry.isDirectory()) continue;
const item = parseSkillFile(join(baseDir, entry.name), type, toolId);
if (item) items.push(item);
}
return items;
}
function parseSkillFile(
filePath: string,
type: SkillType,
toolId: string
): SkillItem | null {
try {
const raw = readFileSync(filePath, "utf-8");
const stat = statSync(filePath);
const { frontmatter, content } = parseFrontmatter(raw);
const name = extractName(frontmatter, content, filePath);
const description =
typeof frontmatter.description === "string"
? frontmatter.description
: "";
let realPath: string;
try {
realPath = realpathSync(filePath);
} catch {
realPath = filePath;
}
return {
id: hashPath(realPath),
name,
description,
type,
tools: [toolId],
filePath,
realPath,
dirPath: join(filePath, ".."),
content: raw,
frontmatter,
lastModified: stat.mtimeMs,
fileSize: stat.size,
isFavorite: false,
collections: [],
};
} catch {
return null;
}
}
function scanPath(sp: SkillPath, toolId: string): SkillItem[] {
switch (sp.pattern) {
case "directory-with-skillmd":
return scanDirectoryWithSkillMd(sp.baseDir, sp.type, toolId);
case "flat-md":
return scanFlatMd(sp.baseDir, sp.type, toolId);
case "mdc":
return scanMdc(sp.baseDir, sp.type, toolId);
}
}
export function scanAll(settings: ChopsSettings): Map<string, SkillItem> {
const items = new Map<string, SkillItem>();
for (const tool of TOOL_CONFIGS) {
if (!tool.isInstalled()) continue;
const toolSettings = settings.tools[tool.id];
if (toolSettings && !toolSettings.enabled) continue;
const allPaths = [...tool.paths, ...tool.agentPaths];
for (const sp of allPaths) {
for (const item of scanPath(sp, tool.id)) {
const existing = items.get(item.id);
if (existing) {
if (!existing.tools.includes(tool.id)) {
existing.tools.push(tool.id);
}
} else {
item.isFavorite = settings.favorites.includes(item.id);
for (const [colName, colIds] of Object.entries(
settings.collections
)) {
if (colIds.includes(item.id)) {
item.collections.push(colName);
}
}
items.set(item.id, item);
}
}
}
}
return items;
}
export function getInstalledTools(): string[] {
return TOOL_CONFIGS.filter((t) => t.isInstalled()).map((t) => t.id);
}
export function getWatchPaths(): string[] {
const paths: string[] = [];
for (const tool of TOOL_CONFIGS) {
if (!tool.isInstalled()) continue;
for (const sp of [...tool.paths, ...tool.agentPaths]) {
if (existsSync(sp.baseDir)) {
paths.push(sp.baseDir);
}
}
}
return paths;
}

75
src/settings.ts Normal file
View file

@ -0,0 +1,75 @@
import { PluginSettingTab, Setting, type App } from "obsidian";
import { TOOL_CONFIGS } from "./tool-configs";
import type { ChopsSettings } from "./types";
import type AgentSkillsPlugin from "./main";
export class AgentSkillsSettingTab extends PluginSettingTab {
plugin: AgentSkillsPlugin;
constructor(app: App, plugin: AgentSkillsPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
containerEl.createEl("h2", { text: "Agent Skills" });
new Setting(containerEl)
.setName("File watching")
.setDesc("Automatically detect changes to skill files")
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.watchEnabled)
.onChange(async (value) => {
this.plugin.settings.watchEnabled = value;
await this.plugin.saveSettings();
this.plugin.restartWatcher();
})
);
new Setting(containerEl)
.setName("Watch debounce (ms)")
.setDesc("Delay before re-scanning after file changes")
.addText((text) =>
text
.setValue(String(this.plugin.settings.watchDebounceMs))
.onChange(async (value) => {
const n = parseInt(value);
if (!isNaN(n) && n >= 100) {
this.plugin.settings.watchDebounceMs = n;
await this.plugin.saveSettings();
}
})
);
containerEl.createEl("h3", { text: "Tools" });
for (const tool of TOOL_CONFIGS) {
const installed = tool.isInstalled();
const toolSettings = this.plugin.settings.tools[tool.id] || {
enabled: true,
customPaths: [],
};
new Setting(containerEl)
.setName(tool.name)
.setDesc(installed ? "Installed" : "Not detected")
.addToggle((toggle) =>
toggle
.setValue(installed && toolSettings.enabled)
.setDisabled(!installed)
.onChange(async (value) => {
this.plugin.settings.tools[tool.id] = {
...toolSettings,
enabled: value,
};
await this.plugin.saveSettings();
this.plugin.refreshStore();
})
);
}
}
}

105
src/store.ts Normal file
View file

@ -0,0 +1,105 @@
import { Events } from "obsidian";
import type { SkillItem, SidebarFilter, ChopsSettings } from "./types";
import { scanAll } from "./scanner";
export class SkillStore extends Events {
private items: Map<string, SkillItem> = new Map();
private _filter: SidebarFilter = { kind: "all" };
private _searchQuery = "";
get filter(): SidebarFilter {
return this._filter;
}
get searchQuery(): string {
return this._searchQuery;
}
get allItems(): SkillItem[] {
return Array.from(this.items.values());
}
get filteredItems(): SkillItem[] {
let result = this.allItems;
switch (this._filter.kind) {
case "favorites":
result = result.filter((i) => i.isFavorite);
break;
case "tool":
result = result.filter((i) =>
i.tools.includes(this._filter.toolId)
);
break;
case "type":
result = result.filter((i) => i.type === this._filter.type);
break;
case "collection":
result = result.filter((i) =>
i.collections.includes(this._filter.name)
);
break;
}
if (this._searchQuery) {
const q = this._searchQuery.toLowerCase();
result = result.filter(
(i) =>
i.name.toLowerCase().includes(q) ||
i.description.toLowerCase().includes(q) ||
i.content.toLowerCase().includes(q)
);
}
return result.sort((a, b) => a.name.localeCompare(b.name));
}
getItem(id: string): SkillItem | undefined {
return this.items.get(id);
}
refresh(settings: ChopsSettings): void {
this.items = scanAll(settings);
this.trigger("updated");
}
setFilter(filter: SidebarFilter): void {
this._filter = filter;
this.trigger("updated");
}
setSearch(query: string): void {
this._searchQuery = query;
this.trigger("updated");
}
toggleFavorite(id: string, settings: ChopsSettings): void {
const item = this.items.get(id);
if (!item) return;
item.isFavorite = !item.isFavorite;
if (item.isFavorite) {
if (!settings.favorites.includes(id)) settings.favorites.push(id);
} else {
settings.favorites = settings.favorites.filter((f) => f !== id);
}
this.trigger("updated");
}
getToolCounts(): Map<string, number> {
const counts = new Map<string, number>();
for (const item of this.items.values()) {
for (const tool of item.tools) {
counts.set(tool, (counts.get(tool) || 0) + 1);
}
}
return counts;
}
getTypeCounts(): Map<string, number> {
const counts = new Map<string, number>();
for (const item of this.items.values()) {
counts.set(item.type, (counts.get(item.type) || 0) + 1);
}
return counts;
}
}

258
src/tool-configs.ts Normal file
View file

@ -0,0 +1,258 @@
import { homedir } from "os";
import { existsSync } from "fs";
import { join } from "path";
import type { ToolConfig } from "./types";
const HOME = homedir();
const XDG_CONFIG = process.env.XDG_CONFIG_HOME || join(HOME, ".config");
function appExists(name: string): boolean {
return (
existsSync(`/Applications/${name}.app`) ||
existsSync(join(HOME, "Applications", `${name}.app`))
);
}
function cliExists(name: string): boolean {
const paths = [
`/usr/local/bin/${name}`,
`/opt/homebrew/bin/${name}`,
join(HOME, ".local", "bin", name),
];
for (const p of paths) {
if (existsSync(p)) return true;
}
const nvmDir = join(HOME, ".nvm", "versions", "node");
try {
const { readdirSync } = require("fs");
for (const d of readdirSync(nvmDir)) {
if (existsSync(join(nvmDir, d, "bin", name))) return true;
}
} catch {}
return false;
}
export const TOOL_CONFIGS: ToolConfig[] = [
{
id: "claude-code",
name: "Claude Code",
color: "#f97316",
icon: "brain",
paths: [
{
baseDir: join(HOME, ".claude", "skills"),
type: "skill",
pattern: "directory-with-skillmd",
},
{
baseDir: join(HOME, ".claude", "commands"),
type: "command",
pattern: "flat-md",
},
],
agentPaths: [
{
baseDir: join(HOME, ".claude", "agents"),
type: "agent",
pattern: "flat-md",
},
],
isInstalled: () =>
existsSync(join(HOME, ".claude", "settings.json")) ||
existsSync(join(HOME, ".claude", "CLAUDE.md")) ||
cliExists("claude"),
},
{
id: "cursor",
name: "Cursor",
color: "#3b82f6",
icon: "mouse-pointer",
paths: [
{
baseDir: join(HOME, ".cursor", "skills"),
type: "skill",
pattern: "directory-with-skillmd",
},
{
baseDir: join(HOME, ".cursor", "rules"),
type: "rule",
pattern: "flat-md",
},
],
agentPaths: [
{
baseDir: join(HOME, ".cursor", "agents"),
type: "agent",
pattern: "flat-md",
},
],
isInstalled: () =>
appExists("Cursor") ||
existsSync(join(HOME, ".cursor", "argv.json")),
},
{
id: "windsurf",
name: "Windsurf",
color: "#14b8a6",
icon: "wind",
paths: [
{
baseDir: join(HOME, ".codeium", "windsurf", "memories"),
type: "memory",
pattern: "flat-md",
},
{
baseDir: join(HOME, ".windsurf", "rules"),
type: "rule",
pattern: "flat-md",
},
],
agentPaths: [],
isInstalled: () =>
appExists("Windsurf") ||
existsSync(join(HOME, ".codeium", "windsurf", "argv.json")),
},
{
id: "codex",
name: "Codex",
color: "#22c55e",
icon: "book",
paths: [
{
baseDir: join(HOME, ".codex", "skills"),
type: "skill",
pattern: "directory-with-skillmd",
},
],
agentPaths: [
{
baseDir: join(HOME, ".codex", "agents"),
type: "agent",
pattern: "flat-md",
},
],
isInstalled: () =>
existsSync(join(HOME, ".codex", "config.toml")) ||
existsSync(join(HOME, ".codex", "auth.json")) ||
cliExists("codex"),
},
{
id: "copilot",
name: "Copilot",
color: "#a855f7",
icon: "plane",
paths: [
{
baseDir: join(HOME, ".copilot", "skills"),
type: "skill",
pattern: "directory-with-skillmd",
},
],
agentPaths: [],
isInstalled: () =>
existsSync(join(HOME, ".copilot")) || cliExists("copilot"),
},
{
id: "amp",
name: "Amp",
color: "#ec4899",
icon: "zap",
paths: [
{
baseDir: join(XDG_CONFIG, "amp", "skills"),
type: "skill",
pattern: "directory-with-skillmd",
},
],
agentPaths: [],
isInstalled: () =>
existsSync(join(XDG_CONFIG, "amp", "config.json")) ||
existsSync(join(XDG_CONFIG, "amp", "settings.json")) ||
cliExists("amp"),
},
{
id: "opencode",
name: "OpenCode",
color: "#ef4444",
icon: "terminal",
paths: [
{
baseDir: join(XDG_CONFIG, "opencode", "skills"),
type: "skill",
pattern: "directory-with-skillmd",
},
],
agentPaths: [],
isInstalled: () =>
appExists("OpenCode") ||
existsSync(join(XDG_CONFIG, "opencode", "opencode.json")) ||
existsSync(join(XDG_CONFIG, "opencode", "opencode.jsonc")) ||
cliExists("opencode"),
},
{
id: "pi",
name: "Pi",
color: "#06b6d4",
icon: "sparkles",
paths: [
{
baseDir: join(HOME, ".pi", "agent", "skills"),
type: "skill",
pattern: "directory-with-skillmd",
},
],
agentPaths: [],
isInstalled: () => cliExists("pi"),
},
{
id: "antigravity",
name: "Antigravity",
color: "#ef4444",
icon: "arrow-up-circle",
paths: [
{
baseDir: join(HOME, ".gemini", "antigravity", "skills"),
type: "skill",
pattern: "directory-with-skillmd",
},
],
agentPaths: [],
isInstalled: () =>
appExists("Antigravity") ||
existsSync(join(HOME, ".gemini", "antigravity", "skills")) ||
cliExists("antigravity"),
},
{
id: "claude-desktop",
name: "Claude Desktop",
color: "#f97316",
icon: "monitor",
paths: [],
agentPaths: [],
isInstalled: () => appExists("Claude"),
},
{
id: "global-agents",
name: "Global",
color: "#a3e635",
icon: "globe",
paths: [
{
baseDir: join(HOME, ".agents", "skills"),
type: "skill",
pattern: "directory-with-skillmd",
},
],
agentPaths: [],
isInstalled: () => existsSync(join(HOME, ".agents", "skills")),
},
{
id: "aider",
name: "Aider",
color: "#eab308",
icon: "wrench",
paths: [],
agentPaths: [],
isInstalled: () => cliExists("aider"),
},
];

52
src/tool-icons.ts Normal file
View file

@ -0,0 +1,52 @@
export const TOOL_SVGS: Record<string, { viewBox: string; paths: string }> = {
"claude-code": {
viewBox: "0 0 24 24",
paths: `<path d="M20.998 10.949H24v3.102h-3v3.028h-1.487V20H18v-2.921h-1.487V20H15v-2.921H9V20H7.488v-2.921H6V20H4.487v-2.921H3V14.05H0V10.95h3V5h17.998v5.949zM6 10.949h1.488V8.102H6v2.847zm10.51 0H18V8.102h-1.49v2.847z" fill="currentColor"/>`,
},
cursor: {
viewBox: "0 0 466.73 532.09",
paths: `<path d="M457.43 125.94 244.42 2.96a22.127 22.127 0 0 0-22.12 0L9.3 125.94C3.55 129.26 0 135.4 0 142.05v247.99c0 6.65 3.55 12.79 9.3 16.11l213.01 122.98a22.127 22.127 0 0 0 22.12 0l213.01-122.98c5.75-3.32 9.3-9.46 9.3-16.11V142.05c0-6.65-3.55-12.79-9.3-16.11h-.01Zm-13.38 26.05L238.42 508.15c-1.39 2.4-5.06 1.42-5.06-1.36V273.58c0-4.66-2.49-8.97-6.53-11.31L24.87 145.67c-2.4-1.39-1.42-5.06 1.36-5.06h411.26c5.84 0 9.49 6.33 6.57 11.39h-.01Z" fill="currentColor"/>`,
},
windsurf: {
viewBox: "0 0 24 24",
paths: `<path d="M23.55 5.067c-1.2038-.002-2.1806.973-2.1806 2.1765v4.8676c0 .972-.8035 1.7594-1.7597 1.7594-.568 0-1.1352-.286-1.4718-.7659l-4.9713-7.1003c-.4125-.5896-1.0837-.941-1.8103-.941-1.1334 0-2.1533.9635-2.1533 2.153v4.8957c0 .972-.7969 1.7594-1.7596 1.7594-.57 0-1.1363-.286-1.4728-.7658L.4076 5.1598C.2822 4.9798 0 5.0688 0 5.2882v4.2452c0 .2147.0656.4228.1884.599l5.4748 7.8183c.3234.462.8006.8052 1.3509.9298 1.3771.313 2.6446-.747 2.6446-2.0977v-4.893c0-.972.7875-1.7593 1.7596-1.7593h.003a1.798 1.798 0 0 1 1.4718.7658l4.9723 7.0994c.4135.5905 1.05.941 1.8093.941 1.1587 0 2.1515-.9645 2.1515-2.153v-4.8948c0-.972.7875-1.7594 1.7596-1.7594h.194a.22.22 0 0 0 .2204-.2202v-4.622a.22.22 0 0 0-.2203-.2203Z" fill="currentColor"/>`,
},
codex: {
viewBox: "0 0 24 24",
paths: `<path d="M8.086.457a6.105 6.105 0 013.046-.415c1.333.153 2.521.72 3.564 1.7a.117.117 0 00.107.029c1.408-.346 2.762-.224 4.061.366l.063.03.154.076c1.357.703 2.33 1.77 2.918 3.198.278.679.418 1.388.421 2.126a5.655 5.655 0 01-.18 1.631.167.167 0 00.04.155 5.982 5.982 0 011.578 2.891c.385 1.901-.01 3.615-1.183 5.14l-.182.22a6.063 6.063 0 01-2.934 1.851.162.162 0 00-.108.102c-.255.736-.511 1.364-.987 1.992-1.199 1.582-2.962 2.462-4.948 2.451-1.583-.008-2.986-.587-4.21-1.736a.145.145 0 00-.14-.032c-.518.167-1.04.191-1.604.185a5.924 5.924 0 01-2.595-.622 6.058 6.058 0 01-2.146-1.781c-.203-.269-.404-.522-.551-.821a7.74 7.74 0 01-.495-1.283 6.11 6.11 0 01-.017-3.064.166.166 0 00.008-.074.115.115 0 00-.037-.064 5.958 5.958 0 01-1.38-2.202 5.196 5.196 0 01-.333-1.589 6.915 6.915 0 01.188-2.132c.45-1.484 1.309-2.648 2.577-3.493.282-.188.55-.334.802-.438.286-.12.573-.22.861-.304a.129.129 0 00.087-.087A6.016 6.016 0 015.635 2.31C6.315 1.464 7.132.846 8.086.457zm-.804 7.85a.848.848 0 00-1.473.842l1.694 2.965-1.688 2.848a.849.849 0 001.46.864l1.94-3.272a.849.849 0 00.007-.854l-1.94-3.393zm5.446 6.24a.849.849 0 000 1.695h4.848a.849.849 0 000-1.696h-4.848z" fill="currentColor"/>`,
},
copilot: {
viewBox: "0 0 24 24",
paths: `<path d="M23.922 16.997C23.061 18.492 18.063 22.02 12 22.02 5.937 22.02.939 18.492.078 16.997A.641.641 0 0 1 0 16.741v-2.869a.883.883 0 0 1 .053-.22c.372-.935 1.347-2.292 2.605-2.656.167-.429.414-1.055.644-1.517a10.098 10.098 0 0 1-.052-1.086c0-1.331.282-2.499 1.132-3.368.397-.406.89-.717 1.474-.952C7.255 2.937 9.248 1.98 11.978 1.98c2.731 0 4.767.957 6.166 2.093.584.235 1.077.546 1.474.952.85.869 1.132 2.037 1.132 3.368 0 .368-.014.733-.052 1.086.23.462.477 1.088.644 1.517 1.258.364 2.233 1.721 2.605 2.656a.841.841 0 0 1 .053.22v2.869a.641.641 0 0 1-.078.256Zm-11.75-5.992h-.344a4.359 4.359 0 0 1-.355.508c-.77.947-1.918 1.492-3.508 1.492-1.725 0-2.989-.359-3.782-1.259a2.137 2.137 0 0 1-.085-.104L4 11.746v6.585c1.435.779 4.514 2.179 8 2.179 3.486 0 6.565-1.4 8-2.179v-6.585l-.098-.104s-.033.045-.085.104c-.793.9-2.057 1.259-3.782 1.259-1.59 0-2.738-.545-3.508-1.492a4.359 4.359 0 0 1-.355-.508Zm2.328 3.25c.549 0 1 .451 1 1v2c0 .549-.451 1-1 1-.549 0-1-.451-1-1v-2c0-.549.451-1 1-1Zm-5 0c.549 0 1 .451 1 1v2c0 .549-.451 1-1 1-.549 0-1-.451-1-1v-2c0-.549.451-1 1-1Zm3.313-6.185c.136 1.057.403 1.913.878 2.497.442.544 1.134.938 2.344.938 1.573 0 2.292-.337 2.657-.751.384-.435.558-1.15.558-2.361 0-1.14-.243-1.847-.705-2.319-.477-.488-1.319-.862-2.824-1.025-1.487-.161-2.192.138-2.533.529-.269.307-.437.808-.438 1.578v.021c0 .265.021.562.063.893Zm-1.626 0c.042-.331.063-.628.063-.894v-.02c-.001-.77-.169-1.271-.438-1.578-.341-.391-1.046-.69-2.533-.529-1.505.163-2.347.537-2.824 1.025-.462.472-.705 1.179-.705 2.319 0 1.211.175 1.926.558 2.361.365.414 1.084.751 2.657.751 1.21 0 1.902-.394 2.344-.938.475-.584.742-1.44.878-2.497Z" fill="currentColor"/>`,
},
opencode: {
viewBox: "0 0 24 36",
paths: `<path d="M18 30H6V18H18V30Z" fill="currentColor" opacity="0.4"/><path d="M18 12H6V30H18V12ZM24 36H0V6H24V36Z" fill="currentColor" opacity="0.6"/>`,
},
"claude-desktop": {
viewBox: "0 0 24 24",
paths: `<path d="M4.709 12.739l3.986-7.639a.209.209 0 01.37 0l3.987 7.639a.209.209 0 01-.185.308H4.894a.209.209 0 01-.185-.308zm6.262-10.658L5.158 13.376a.209.209 0 00.185.308h11.628a.209.209 0 00.185-.308L11.341 2.08a.209.209 0 00-.37 0z" fill="currentColor"/>`,
},
"global-agents": {
viewBox: "0 0 24 24",
paths: `<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z" fill="currentColor"/>`,
},
};
export function renderToolIcon(
container: HTMLElement,
toolId: string,
size = 16
): void {
const svgData = TOOL_SVGS[toolId];
if (!svgData) return;
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("viewBox", svgData.viewBox);
svg.setAttribute("width", String(size));
svg.setAttribute("height", String(size));
svg.setAttribute("fill", "none");
svg.classList.add("as-tool-svg");
svg.innerHTML = svgData.paths;
container.appendChild(svg);
}

61
src/types.ts Normal file
View file

@ -0,0 +1,61 @@
export interface ToolConfig {
id: string;
name: string;
color: string;
icon: string;
svg?: string;
paths: SkillPath[];
agentPaths: SkillPath[];
isInstalled: () => boolean;
}
export interface SkillPath {
baseDir: string;
type: SkillType;
pattern: ScanPattern;
}
export type SkillType = "skill" | "command" | "agent" | "rule" | "memory";
export type ScanPattern = "directory-with-skillmd" | "flat-md" | "mdc";
export interface SkillItem {
id: string;
name: string;
description: string;
type: SkillType;
tools: string[];
filePath: string;
realPath: string;
dirPath: string;
content: string;
frontmatter: Record<string, unknown>;
lastModified: number;
fileSize: number;
isFavorite: boolean;
collections: string[];
}
export type SidebarFilter =
| { kind: "all" }
| { kind: "favorites" }
| { kind: "tool"; toolId: string }
| { kind: "type"; type: SkillType }
| { kind: "collection"; name: string };
export interface ChopsSettings {
tools: Record<string, { enabled: boolean; customPaths: string[] }>;
watchEnabled: boolean;
watchDebounceMs: number;
favorites: string[];
collections: Record<string, string[]>;
customScanPaths: string[];
}
export const DEFAULT_SETTINGS: ChopsSettings = {
tools: {},
watchEnabled: true,
watchDebounceMs: 500,
favorites: [],
collections: {},
customScanPaths: [],
};

235
src/views/detail.ts Normal file
View file

@ -0,0 +1,235 @@
import { MarkdownRenderer, setIcon, Component } from "obsidian";
import { writeFileSync } from "fs";
import type { SkillItem, ChopsSettings } from "../types";
import type { SkillStore } from "../store";
import { TOOL_CONFIGS } from "../tool-configs";
import { TOOL_SVGS, renderToolIcon } from "../tool-icons";
function estimateTokens(text: string): number {
return Math.ceil(text.length / 4);
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function formatNumber(n: number): string {
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
return String(n);
}
function formatDate(ms: number): string {
return new Date(ms).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
});
}
export class DetailPanel {
private containerEl: HTMLElement;
private store: SkillStore;
private settings: ChopsSettings;
private saveSettings: () => Promise<void>;
private currentItem: SkillItem | null = null;
private isEditing = false;
private component: Component;
constructor(
containerEl: HTMLElement,
store: SkillStore,
settings: ChopsSettings,
saveSettings: () => Promise<void>,
component: Component
) {
this.containerEl = containerEl;
this.store = store;
this.settings = settings;
this.saveSettings = saveSettings;
this.component = component;
}
show(item: SkillItem): void {
this.currentItem = item;
this.isEditing = false;
this.render();
}
clear(): void {
this.currentItem = null;
this.containerEl.empty();
this.containerEl.addClass("as-detail");
const empty = this.containerEl.createDiv("as-detail-empty");
setIcon(empty.createDiv("as-detail-empty-icon"), "file-text");
empty.createDiv({ text: "Select a skill to view" });
}
private render(): void {
this.containerEl.empty();
this.containerEl.addClass("as-detail");
const item = this.currentItem;
if (!item) return this.clear();
this.renderToolbar(item);
if (this.isEditing) {
this.renderEditor(item);
} else {
this.renderPreview(item);
}
}
private renderToolbar(item: SkillItem): void {
const toolbar = this.containerEl.createDiv("as-detail-toolbar");
const topRow = toolbar.createDiv("as-toolbar-top");
const left = topRow.createDiv("as-toolbar-left");
left.createSpan({ cls: "as-detail-title", text: item.name });
for (const toolId of item.tools) {
const tool = TOOL_CONFIGS.find((t) => t.id === toolId);
if (!tool) continue;
const badge = left.createSpan("as-tool-name-badge");
badge.style.borderColor = tool.color;
badge.style.color = tool.color;
if (TOOL_SVGS[toolId]) {
renderToolIcon(badge, toolId, 12);
}
badge.createSpan({ text: tool.name });
}
const right = topRow.createDiv("as-toolbar-right");
const favBtn = right.createEl("button", {
cls: "as-toolbar-btn",
attr: { "aria-label": "Toggle favorite" },
});
setIcon(favBtn, item.isFavorite ? "star" : "star-off");
favBtn.addEventListener("click", () => {
this.store.toggleFavorite(item.id, this.settings);
this.saveSettings();
this.render();
});
const editBtn = right.createEl("button", {
cls: "as-toolbar-btn",
attr: { "aria-label": this.isEditing ? "Preview" : "Edit" },
});
setIcon(editBtn, this.isEditing ? "eye" : "pencil");
editBtn.addEventListener("click", () => {
this.isEditing = !this.isEditing;
this.render();
});
const openBtn = right.createEl("button", {
cls: "as-toolbar-btn",
attr: { "aria-label": "Open in Finder" },
});
setIcon(openBtn, "folder-open");
openBtn.addEventListener("click", () => {
const { shell } = require("electron");
shell.showItemInFolder(item.filePath);
});
const meta = toolbar.createDiv("as-detail-meta-bar");
const tokens = estimateTokens(item.content);
const chars = item.content.length;
meta.createSpan({ cls: "as-meta-item", text: formatSize(item.fileSize) });
meta.createSpan({ cls: "as-meta-item", text: `${formatNumber(chars)} chars` });
meta.createSpan({ cls: "as-meta-item", text: `~${formatNumber(tokens)} tokens` });
meta.createSpan({ cls: "as-meta-item", text: formatDate(item.lastModified) });
meta.createSpan({ cls: "as-meta-item as-meta-type", text: item.type });
}
private renderFrontmatter(container: HTMLElement, item: SkillItem): void {
const keys = Object.keys(item.frontmatter);
if (keys.length === 0) return;
const section = container.createDiv("as-frontmatter");
for (const key of keys) {
const value = item.frontmatter[key];
if (value === undefined || value === null) continue;
const prop = section.createDiv("as-fm-prop");
prop.createSpan({ cls: "as-fm-key", text: key });
const valStr =
typeof value === "object" ? JSON.stringify(value) : String(value);
if (valStr.length > 200) {
const valEl = prop.createDiv({ cls: "as-fm-value-long" });
valEl.setText(valStr);
} else {
const valEl = prop.createSpan({ cls: "as-fm-value" });
valEl.setText(valStr);
}
}
}
private renderPreview(item: SkillItem): void {
const body = this.containerEl.createDiv("as-detail-body");
this.renderFrontmatter(body, item);
const previewEl = body.createDiv("as-detail-preview markdown-rendered");
MarkdownRenderer.render(
(this.component as any).app || (globalThis as any).app,
item.content,
previewEl,
item.filePath,
this.component
);
}
private renderEditor(item: SkillItem): void {
const body = this.containerEl.createDiv("as-detail-body as-detail-body-editor");
const textarea = body.createEl("textarea", {
cls: "as-editor-textarea",
});
textarea.value = item.content;
textarea.spellcheck = false;
textarea.addEventListener("keydown", (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
e.preventDefault();
this.saveFile(item, textarea.value);
}
if (e.key === "Tab") {
e.preventDefault();
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
textarea.value =
textarea.value.substring(0, start) +
"\t" +
textarea.value.substring(end);
textarea.selectionStart = textarea.selectionEnd = start + 1;
}
});
const saveBar = body.createDiv("as-save-bar");
const saveBtn = saveBar.createEl("button", {
cls: "as-save-btn",
text: "Save",
});
saveBtn.addEventListener("click", () => {
this.saveFile(item, textarea.value);
});
saveBar.createSpan({ cls: "as-save-hint", text: "Cmd+S to save" });
}
private saveFile(item: SkillItem, content: string): void {
try {
writeFileSync(item.filePath, content, "utf-8");
item.content = content;
const { Notice } = require("obsidian");
new Notice(`Saved ${item.name}`);
} catch (e: unknown) {
const { Notice } = require("obsidian");
new Notice(`Failed to save: ${e instanceof Error ? e.message : String(e)}`);
}
}
}

107
src/views/list.ts Normal file
View file

@ -0,0 +1,107 @@
import { setIcon } from "obsidian";
import { TOOL_CONFIGS } from "../tool-configs";
import { TOOL_SVGS, renderToolIcon } from "../tool-icons";
import type { SkillStore } from "../store";
import type { SkillItem } from "../types";
export class ListPanel {
private containerEl: HTMLElement;
private store: SkillStore;
private onSelect: (item: SkillItem) => void;
private selectedId: string | null = null;
constructor(
containerEl: HTMLElement,
store: SkillStore,
onSelect: (item: SkillItem) => void
) {
this.containerEl = containerEl;
this.store = store;
this.onSelect = onSelect;
}
setSelected(id: string | null): void {
this.selectedId = id;
}
render(): void {
this.containerEl.empty();
this.containerEl.addClass("as-list");
const searchContainer = this.containerEl.createDiv("as-search");
const input = searchContainer.createEl("input", {
type: "text",
placeholder: "Search skills...",
cls: "as-search-input",
});
input.value = this.store.searchQuery;
input.addEventListener("input", () => {
this.store.setSearch(input.value);
});
const listContainer = this.containerEl.createDiv("as-list-items");
const items = this.store.filteredItems;
if (items.length === 0) {
listContainer.createDiv({
cls: "as-list-empty",
text: "No skills found",
});
return;
}
for (const item of items) {
this.renderCard(listContainer, item);
}
}
private renderCard(container: HTMLElement, item: SkillItem): void {
const card = container.createDiv("as-skill-card");
if (item.id === this.selectedId) card.addClass("is-selected");
const header = card.createDiv("as-skill-header");
const nameEl = header.createSpan({ cls: "as-skill-name", text: item.name });
if (item.isFavorite) {
const star = header.createSpan("as-skill-star");
setIcon(star, "star");
}
if (item.description) {
card.createDiv({
cls: "as-skill-desc",
text:
item.description.length > 80
? item.description.slice(0, 80) + "..."
: item.description,
});
}
const meta = card.createDiv("as-skill-meta");
const typeTag = meta.createSpan({
cls: `as-type-tag as-type-${item.type}`,
text: item.type,
});
for (const toolId of item.tools) {
const tool = TOOL_CONFIGS.find((t) => t.id === toolId);
if (!tool) continue;
const badge = meta.createSpan("as-tool-badge");
badge.title = tool.name;
badge.setAttribute("aria-label", tool.name);
if (TOOL_SVGS[toolId]) {
badge.style.color = tool.color;
renderToolIcon(badge, toolId, 12);
} else {
badge.style.backgroundColor = tool.color;
badge.addClass("as-tool-badge-dot");
}
}
card.addEventListener("click", () => {
this.selectedId = item.id;
this.onSelect(item);
});
}
}

89
src/views/main-view.ts Normal file
View file

@ -0,0 +1,89 @@
import { ItemView, type WorkspaceLeaf } from "obsidian";
import type { SkillStore } from "../store";
import type { SkillItem, ChopsSettings } from "../types";
import { SidebarPanel } from "./sidebar";
import { ListPanel } from "./list";
import { DetailPanel } from "./detail";
export const VIEW_TYPE = "agent-skills-view";
export class AgentSkillsView extends ItemView {
private store: SkillStore;
private settings: ChopsSettings;
private saveSettings: () => Promise<void>;
private sidebarPanel!: SidebarPanel;
private listPanel!: ListPanel;
private detailPanel!: DetailPanel;
private sidebarEl!: HTMLElement;
private listEl!: HTMLElement;
private detailEl!: HTMLElement;
constructor(
leaf: WorkspaceLeaf,
store: SkillStore,
settings: ChopsSettings,
saveSettings: () => Promise<void>
) {
super(leaf);
this.store = store;
this.settings = settings;
this.saveSettings = saveSettings;
}
getViewType(): string {
return VIEW_TYPE;
}
getDisplayText(): string {
return "Agent Skills";
}
getIcon(): string {
return "cpu";
}
async onOpen(): Promise<void> {
const container = this.contentEl;
container.empty();
container.addClass("as-container");
this.sidebarEl = container.createDiv("as-panel as-panel-sidebar");
this.listEl = container.createDiv("as-panel as-panel-list");
this.detailEl = container.createDiv("as-panel as-panel-detail");
this.sidebarPanel = new SidebarPanel(this.sidebarEl, this.store);
this.listPanel = new ListPanel(this.listEl, this.store, (item) =>
this.onSelectItem(item)
);
this.detailPanel = new DetailPanel(
this.detailEl,
this.store,
this.settings,
this.saveSettings,
this
);
this.store.on("updated", () => this.renderAll());
this.renderAll();
}
private renderAll(): void {
this.sidebarPanel.render();
this.listPanel.render();
if (!this.store.filteredItems.length) {
this.detailPanel.clear();
}
}
private onSelectItem(item: SkillItem): void {
this.listPanel.setSelected(item.id);
this.listPanel.render();
this.detailPanel.show(item);
}
async onClose(): Promise<void> {
this.store.offref(this.store.on("updated", () => {}));
}
}

159
src/views/sidebar.ts Normal file
View file

@ -0,0 +1,159 @@
import { setIcon } from "obsidian";
import { TOOL_CONFIGS } from "../tool-configs";
import { TOOL_SVGS, renderToolIcon } from "../tool-icons";
import type { SkillStore } from "../store";
import type { SidebarFilter } from "../types";
export class SidebarPanel {
private containerEl: HTMLElement;
private store: SkillStore;
constructor(containerEl: HTMLElement, store: SkillStore) {
this.containerEl = containerEl;
this.store = store;
}
render(): void {
this.containerEl.empty();
this.containerEl.addClass("as-sidebar");
this.renderSection("Library", [
{ label: "All Skills", icon: "layers", filter: { kind: "all" } },
{
label: "Favorites",
icon: "star",
filter: { kind: "favorites" },
},
]);
this.renderTypeSection();
this.renderToolSection();
this.renderCollectionSection();
}
private renderSection(
title: string,
items: { label: string; icon: string; filter: SidebarFilter; count?: number }[]
): void {
const section = this.containerEl.createDiv("as-sidebar-section");
section.createDiv({ cls: "as-sidebar-title", text: title });
for (const item of items) {
const row = section.createDiv("as-sidebar-item");
if (this.isActive(item.filter)) row.addClass("is-active");
const iconEl = row.createSpan("as-sidebar-icon");
setIcon(iconEl, item.icon);
row.createSpan({ cls: "as-sidebar-label", text: item.label });
if (item.count !== undefined) {
row.createSpan({
cls: "as-sidebar-count",
text: String(item.count),
});
}
row.addEventListener("click", () => {
this.store.setFilter(item.filter);
});
}
}
private renderTypeSection(): void {
const typeCounts = this.store.getTypeCounts();
const types: { label: string; icon: string; type: string }[] = [
{ label: "Skills", icon: "sparkles", type: "skill" },
{ label: "Commands", icon: "terminal", type: "command" },
{ label: "Agents", icon: "bot", type: "agent" },
{ label: "Rules", icon: "scroll", type: "rule" },
];
const items = types
.filter((t) => typeCounts.has(t.type))
.map((t) => ({
label: t.label,
icon: t.icon,
filter: { kind: "type" as const, type: t.type as "skill" | "command" | "agent" | "rule" },
count: typeCounts.get(t.type) || 0,
}));
if (items.length > 0) {
this.renderSection("Types", items);
}
}
private renderToolSection(): void {
const toolCounts = this.store.getToolCounts();
const tools = TOOL_CONFIGS.filter(
(t) => t.isInstalled() && toolCounts.has(t.id)
);
if (tools.length === 0) return;
const section = this.containerEl.createDiv("as-sidebar-section");
section.createDiv({ cls: "as-sidebar-title", text: "Tools" });
for (const tool of tools) {
const filter: SidebarFilter = { kind: "tool", toolId: tool.id };
const row = section.createDiv("as-sidebar-item");
if (this.isActive(filter)) row.addClass("is-active");
const iconEl = row.createSpan("as-sidebar-icon");
if (TOOL_SVGS[tool.id]) {
renderToolIcon(iconEl, tool.id, 14);
} else {
setIcon(iconEl, tool.icon);
}
row.createSpan({ cls: "as-sidebar-label", text: tool.name });
row.createSpan({
cls: "as-sidebar-count",
text: String(toolCounts.get(tool.id) || 0),
});
row.addEventListener("click", () => this.store.setFilter(filter));
}
}
private renderCollectionSection(): void {
const section = this.containerEl.createDiv("as-sidebar-section");
section.createDiv({ cls: "as-sidebar-title", text: "Collections" });
const collections = new Set<string>();
for (const item of this.store.allItems) {
for (const c of item.collections) collections.add(c);
}
if (collections.size === 0) {
section.createDiv({
cls: "as-sidebar-empty",
text: "No collections yet",
});
return;
}
for (const name of collections) {
const filter: SidebarFilter = { kind: "collection", name };
const row = section.createDiv("as-sidebar-item");
if (this.isActive(filter)) row.addClass("is-active");
const iconEl = row.createSpan("as-sidebar-icon");
setIcon(iconEl, "folder");
row.createSpan({ cls: "as-sidebar-label", text: name });
row.addEventListener("click", () => this.store.setFilter(filter));
}
}
private isActive(filter: SidebarFilter): boolean {
const current = this.store.filter;
if (current.kind !== filter.kind) return false;
if (current.kind === "tool" && filter.kind === "tool")
return current.toolId === filter.toolId;
if (current.kind === "type" && filter.kind === "type")
return current.type === filter.type;
if (current.kind === "collection" && filter.kind === "collection")
return current.name === filter.name;
return true;
}
}

44
src/watcher.ts Normal file
View file

@ -0,0 +1,44 @@
import { watch, type FSWatcher } from "fs";
export class SkillWatcher {
private watchers: FSWatcher[] = [];
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
private debounceMs: number;
private onChange: () => void;
constructor(debounceMs: number, onChange: () => void) {
this.debounceMs = debounceMs;
this.onChange = onChange;
}
watchPaths(paths: string[]): void {
this.close();
for (const p of paths) {
try {
const w = watch(p, { recursive: true }, () => this.scheduleUpdate());
this.watchers.push(w);
} catch {}
}
}
private scheduleUpdate(): void {
if (this.debounceTimer) clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(() => {
this.debounceTimer = null;
this.onChange();
}, this.debounceMs);
}
close(): void {
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
this.debounceTimer = null;
}
for (const w of this.watchers) {
try {
w.close();
} catch {}
}
this.watchers = [];
}
}

495
styles.css Normal file
View file

@ -0,0 +1,495 @@
.as-container {
display: grid;
grid-template-columns: 200px 280px 1fr;
height: 100%;
overflow: hidden;
}
.as-panel {
overflow-y: auto;
height: 100%;
}
.as-panel-sidebar {
border-right: 1px solid var(--background-modifier-border);
padding: 8px 0;
}
.as-panel-list {
border-right: 1px solid var(--background-modifier-border);
}
.as-panel-detail {
display: flex;
flex-direction: column;
overflow: hidden;
}
/* Sidebar */
.as-sidebar-section {
margin-bottom: 8px;
}
.as-sidebar-title {
font-size: var(--font-smallest);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
padding: 4px 16px;
margin-bottom: 2px;
}
.as-sidebar-item {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 16px;
cursor: pointer;
font-size: var(--font-ui-small);
color: var(--text-normal);
border-radius: var(--radius-s);
margin: 0 4px;
}
.as-sidebar-item:hover {
background: var(--background-modifier-hover);
}
.as-sidebar-item.is-active {
background: var(--interactive-accent);
color: var(--text-on-accent);
}
.as-sidebar-item.is-active .as-sidebar-icon {
color: var(--text-on-accent);
}
.as-sidebar-item.is-active .as-sidebar-count {
color: var(--text-on-accent);
opacity: 0.8;
}
.as-sidebar-icon {
display: flex;
align-items: center;
color: var(--text-muted);
width: 16px;
height: 16px;
}
.as-sidebar-icon svg {
width: 14px;
height: 14px;
}
.as-sidebar-label {
flex: 1;
}
.as-sidebar-count {
font-size: var(--font-smallest);
color: var(--text-faint);
min-width: 20px;
text-align: right;
}
.as-sidebar-empty {
font-size: var(--font-smallest);
color: var(--text-faint);
padding: 4px 16px;
font-style: italic;
}
/* Search */
.as-search {
padding: 8px;
border-bottom: 1px solid var(--background-modifier-border);
}
.as-search-input {
width: 100%;
padding: 6px 10px;
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-s);
background: var(--background-primary);
color: var(--text-normal);
font-size: var(--font-ui-small);
outline: none;
}
.as-search-input:focus {
border-color: var(--interactive-accent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--interactive-accent) 25%, transparent);
}
/* List */
.as-list-items {
padding: 4px;
}
.as-list-empty {
padding: 24px 16px;
text-align: center;
color: var(--text-faint);
font-size: var(--font-ui-small);
}
.as-skill-card {
padding: 8px 12px;
border-radius: var(--radius-s);
cursor: pointer;
margin-bottom: 2px;
}
.as-skill-card:hover {
background: var(--background-modifier-hover);
}
.as-skill-card.is-selected {
background: var(--background-modifier-active-hover);
}
.as-skill-header {
display: flex;
align-items: center;
gap: 4px;
}
.as-skill-name {
font-size: var(--font-ui-small);
font-weight: 500;
color: var(--text-normal);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.as-skill-star {
color: var(--color-yellow);
display: flex;
align-items: center;
}
.as-skill-star svg {
width: 12px;
height: 12px;
}
.as-skill-desc {
font-size: var(--font-smallest);
color: var(--text-muted);
margin-top: 2px;
line-height: 1.4;
}
.as-skill-meta {
display: flex;
align-items: center;
gap: 4px;
margin-top: 4px;
}
.as-type-tag {
font-size: 10px;
padding: 1px 6px;
border-radius: var(--radius-s);
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.03em;
background: var(--background-modifier-hover);
color: var(--text-muted);
border: 1px solid var(--background-modifier-border);
}
.as-tool-badge {
display: inline-flex;
align-items: center;
justify-content: center;
}
.as-tool-badge-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.as-tool-svg {
display: block;
}
.as-tool-name-badge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: var(--font-ui-smaller);
padding: 2px 8px;
border: 1px solid;
border-radius: 10px;
font-weight: 500;
white-space: nowrap;
flex-shrink: 0;
}
/* Detail — toolbar */
.as-detail {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.as-detail-toolbar {
padding: 10px 16px 8px;
border-bottom: 1px solid var(--background-modifier-border);
flex-shrink: 0;
}
.as-toolbar-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.as-toolbar-left {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex: 1;
}
.as-detail-title {
font-size: var(--font-ui-medium);
font-weight: 600;
color: var(--text-normal);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.as-toolbar-right {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.as-toolbar-btn {
background: none;
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-s);
padding: 4px 8px;
cursor: pointer;
color: var(--text-muted);
display: flex;
align-items: center;
}
.as-toolbar-btn:hover {
background: var(--background-modifier-hover);
color: var(--text-normal);
}
.as-toolbar-btn svg {
width: 14px;
height: 14px;
}
.as-detail-meta-bar {
display: flex;
gap: 8px;
margin-top: 6px;
flex-wrap: wrap;
}
.as-meta-item {
font-size: var(--font-ui-smaller);
color: var(--text-muted);
background: var(--background-secondary);
padding: 2px 8px;
border-radius: var(--radius-s);
}
.as-meta-type {
text-transform: uppercase;
letter-spacing: 0.03em;
font-weight: 500;
}
/* Detail — empty */
.as-detail-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
color: var(--text-faint);
gap: 8px;
font-size: var(--font-ui-small);
}
.as-detail-empty-icon svg {
width: 48px;
height: 48px;
opacity: 0.3;
}
/* Detail — frontmatter */
.as-frontmatter {
border-bottom: 1px solid var(--background-modifier-border);
padding: 12px 20px;
display: flex;
flex-direction: column;
gap: 6px;
background: var(--background-secondary);
}
.as-fm-prop {
display: flex;
align-items: baseline;
gap: 8px;
line-height: 1.5;
}
.as-fm-key {
color: var(--text-faint);
font-weight: 500;
white-space: nowrap;
font-family: var(--font-monospace);
font-size: var(--font-ui-smaller);
min-width: 80px;
}
.as-fm-key::after {
content: ":";
}
.as-fm-value {
color: var(--text-normal);
font-size: var(--font-ui-small);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.as-fm-value-long {
color: var(--text-normal);
font-size: var(--font-ui-small);
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Detail — body / preview */
.as-detail-body {
flex: 1;
overflow-y: auto;
min-height: 0;
}
.as-detail-preview {
padding: 12px 20px;
}
.as-detail-preview pre {
background: var(--code-background);
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-s);
padding: 12px 16px;
overflow-x: auto;
position: relative;
}
.as-detail-preview pre code {
font-family: var(--font-monospace);
font-size: var(--code-size);
color: var(--code-normal);
background: none;
padding: 0;
border-radius: 0;
}
.as-detail-preview code {
font-family: var(--font-monospace);
font-size: var(--code-size);
color: var(--code-normal);
background: var(--code-background);
padding: 2px 4px;
border-radius: var(--radius-s);
}
.as-detail-preview .copy-code-button {
position: absolute;
top: 6px;
right: 6px;
padding: 4px 8px;
font-size: var(--font-smallest);
background: var(--background-modifier-hover);
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-s);
color: var(--text-muted);
cursor: pointer;
opacity: 0;
transition: opacity 0.15s;
}
.as-detail-preview pre:hover .copy-code-button {
opacity: 1;
}
/* Detail — editor */
.as-detail-body-editor {
display: flex;
flex-direction: column;
}
.as-editor-textarea {
flex: 1;
width: 100%;
padding: 12px 20px;
background: var(--background-primary);
color: var(--text-normal);
border: none;
resize: none;
font-family: var(--font-monospace);
font-size: var(--font-ui-small);
line-height: 1.6;
outline: none;
tab-size: 2;
min-height: 0;
}
.as-save-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 16px;
border-top: 1px solid var(--background-modifier-border);
background: var(--background-secondary);
flex-shrink: 0;
}
.as-save-btn {
padding: 4px 16px;
border-radius: var(--radius-s);
background: var(--interactive-accent);
color: var(--text-on-accent);
border: none;
cursor: pointer;
font-size: var(--font-ui-small);
font-weight: 500;
}
.as-save-btn:hover {
opacity: 0.9;
}
.as-save-hint {
font-size: var(--font-smallest);
color: var(--text-faint);
}

17
tsconfig.json Normal file
View file

@ -0,0 +1,17 @@
{
"compilerOptions": {
"baseUrl": ".",
"inlineSourceMap": true,
"inlineSources": true,
"module": "ESNext",
"target": "ES2022",
"allowJs": true,
"noImplicitAny": true,
"moduleResolution": "node",
"importHelpers": true,
"isolatedModules": true,
"strictNullChecks": true,
"lib": ["DOM", "ES2022"]
},
"include": ["src/**/*.ts"]
}