mirror of
https://github.com/railly/agentfiles.git
synced 2026-07-22 06:12:56 +00:00
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:
commit
7306bddb0a
20 changed files with 2191 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
node_modules/
|
||||
data.json
|
||||
97
bun.lock
Normal file
97
bun.lock
Normal 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
39
esbuild.config.mjs
Normal 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
1
main.js
Normal file
File diff suppressed because one or more lines are too long
10
manifest.json
Normal file
10
manifest.json
Normal 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
16
package.json
Normal 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
86
src/main.ts
Normal 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
243
src/scanner.ts
Normal 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
75
src/settings.ts
Normal 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
105
src/store.ts
Normal 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
258
src/tool-configs.ts
Normal 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
52
src/tool-icons.ts
Normal 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
61
src/types.ts
Normal 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
235
src/views/detail.ts
Normal 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
107
src/views/list.ts
Normal 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
89
src/views/main-view.ts
Normal 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
159
src/views/sidebar.ts
Normal 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
44
src/watcher.ts
Normal 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
495
styles.css
Normal 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
17
tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
Reference in a new issue