commit 7306bddb0a1ba158803fec671ff4e2ced00f5e0a Author: Railly Date: Sat Mar 28 08:16:54 2026 -0500 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0a0d141 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +data.json diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..16db320 --- /dev/null +++ b/bun.lock @@ -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=="], + } +} diff --git a/esbuild.config.mjs b/esbuild.config.mjs new file mode 100644 index 0000000..f5f0b43 --- /dev/null +++ b/esbuild.config.mjs @@ -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(); +} diff --git a/main.js b/main.js new file mode 100644 index 0000000..025a0e5 --- /dev/null +++ b/main.js @@ -0,0 +1 @@ +var W=Object.defineProperty;var et=Object.getOwnPropertyDescriptor;var it=Object.getOwnPropertyNames;var st=Object.prototype.hasOwnProperty;var nt=(n,t)=>{for(var e in t)W(n,e,{get:t[e],enumerable:!0})},rt=(n,t,e,i)=>{if(t&&typeof t=="object"||typeof t=="function")for(let s of it(t))!st.call(n,s)&&s!==e&&W(n,s,{get:()=>t[s],enumerable:!(i=et(t,s))||i.enumerable});return n};var ot=n=>rt(W({},"__esModule",{value:!0}),n);var vt={};nt(vt,{default:()=>_});module.exports=ot(vt);var J=require("obsidian");var R=require("obsidian");var L=require("obsidian");var G=require("os"),m=require("fs"),a=require("path"),l=(0,G.homedir)(),b=process.env.XDG_CONFIG_HOME||(0,a.join)(l,".config");function C(n){return(0,m.existsSync)(`/Applications/${n}.app`)||(0,m.existsSync)((0,a.join)(l,"Applications",`${n}.app`))}function v(n){let t=[`/usr/local/bin/${n}`,`/opt/homebrew/bin/${n}`,(0,a.join)(l,".local","bin",n)];for(let i of t)if((0,m.existsSync)(i))return!0;let e=(0,a.join)(l,".nvm","versions","node");try{let{readdirSync:i}=require("fs");for(let s of i(e))if((0,m.existsSync)((0,a.join)(e,s,"bin",n)))return!0}catch{}return!1}var g=[{id:"claude-code",name:"Claude Code",color:"#f97316",icon:"brain",paths:[{baseDir:(0,a.join)(l,".claude","skills"),type:"skill",pattern:"directory-with-skillmd"},{baseDir:(0,a.join)(l,".claude","commands"),type:"command",pattern:"flat-md"}],agentPaths:[{baseDir:(0,a.join)(l,".claude","agents"),type:"agent",pattern:"flat-md"}],isInstalled:()=>(0,m.existsSync)((0,a.join)(l,".claude","settings.json"))||(0,m.existsSync)((0,a.join)(l,".claude","CLAUDE.md"))||v("claude")},{id:"cursor",name:"Cursor",color:"#3b82f6",icon:"mouse-pointer",paths:[{baseDir:(0,a.join)(l,".cursor","skills"),type:"skill",pattern:"directory-with-skillmd"},{baseDir:(0,a.join)(l,".cursor","rules"),type:"rule",pattern:"flat-md"}],agentPaths:[{baseDir:(0,a.join)(l,".cursor","agents"),type:"agent",pattern:"flat-md"}],isInstalled:()=>C("Cursor")||(0,m.existsSync)((0,a.join)(l,".cursor","argv.json"))},{id:"windsurf",name:"Windsurf",color:"#14b8a6",icon:"wind",paths:[{baseDir:(0,a.join)(l,".codeium","windsurf","memories"),type:"memory",pattern:"flat-md"},{baseDir:(0,a.join)(l,".windsurf","rules"),type:"rule",pattern:"flat-md"}],agentPaths:[],isInstalled:()=>C("Windsurf")||(0,m.existsSync)((0,a.join)(l,".codeium","windsurf","argv.json"))},{id:"codex",name:"Codex",color:"#22c55e",icon:"book",paths:[{baseDir:(0,a.join)(l,".codex","skills"),type:"skill",pattern:"directory-with-skillmd"}],agentPaths:[{baseDir:(0,a.join)(l,".codex","agents"),type:"agent",pattern:"flat-md"}],isInstalled:()=>(0,m.existsSync)((0,a.join)(l,".codex","config.toml"))||(0,m.existsSync)((0,a.join)(l,".codex","auth.json"))||v("codex")},{id:"copilot",name:"Copilot",color:"#a855f7",icon:"plane",paths:[{baseDir:(0,a.join)(l,".copilot","skills"),type:"skill",pattern:"directory-with-skillmd"}],agentPaths:[],isInstalled:()=>(0,m.existsSync)((0,a.join)(l,".copilot"))||v("copilot")},{id:"amp",name:"Amp",color:"#ec4899",icon:"zap",paths:[{baseDir:(0,a.join)(b,"amp","skills"),type:"skill",pattern:"directory-with-skillmd"}],agentPaths:[],isInstalled:()=>(0,m.existsSync)((0,a.join)(b,"amp","config.json"))||(0,m.existsSync)((0,a.join)(b,"amp","settings.json"))||v("amp")},{id:"opencode",name:"OpenCode",color:"#ef4444",icon:"terminal",paths:[{baseDir:(0,a.join)(b,"opencode","skills"),type:"skill",pattern:"directory-with-skillmd"}],agentPaths:[],isInstalled:()=>C("OpenCode")||(0,m.existsSync)((0,a.join)(b,"opencode","opencode.json"))||(0,m.existsSync)((0,a.join)(b,"opencode","opencode.jsonc"))||v("opencode")},{id:"pi",name:"Pi",color:"#06b6d4",icon:"sparkles",paths:[{baseDir:(0,a.join)(l,".pi","agent","skills"),type:"skill",pattern:"directory-with-skillmd"}],agentPaths:[],isInstalled:()=>v("pi")},{id:"antigravity",name:"Antigravity",color:"#ef4444",icon:"arrow-up-circle",paths:[{baseDir:(0,a.join)(l,".gemini","antigravity","skills"),type:"skill",pattern:"directory-with-skillmd"}],agentPaths:[],isInstalled:()=>C("Antigravity")||(0,m.existsSync)((0,a.join)(l,".gemini","antigravity","skills"))||v("antigravity")},{id:"claude-desktop",name:"Claude Desktop",color:"#f97316",icon:"monitor",paths:[],agentPaths:[],isInstalled:()=>C("Claude")},{id:"global-agents",name:"Global",color:"#a3e635",icon:"globe",paths:[{baseDir:(0,a.join)(l,".agents","skills"),type:"skill",pattern:"directory-with-skillmd"}],agentPaths:[],isInstalled:()=>(0,m.existsSync)((0,a.join)(l,".agents","skills"))},{id:"aider",name:"Aider",color:"#eab308",icon:"wrench",paths:[],agentPaths:[],isInstalled:()=>v("aider")}];var S={"claude-code":{viewBox:"0 0 24 24",paths:''},cursor:{viewBox:"0 0 466.73 532.09",paths:''},windsurf:{viewBox:"0 0 24 24",paths:''},codex:{viewBox:"0 0 24 24",paths:''},copilot:{viewBox:"0 0 24 24",paths:''},opencode:{viewBox:"0 0 24 36",paths:''},"claude-desktop":{viewBox:"0 0 24 24",paths:''},"global-agents":{viewBox:"0 0 24 24",paths:''}};function k(n,t,e=16){let i=S[t];if(!i)return;let s=document.createElementNS("http://www.w3.org/2000/svg","svg");s.setAttribute("viewBox",i.viewBox),s.setAttribute("width",String(e)),s.setAttribute("height",String(e)),s.setAttribute("fill","none"),s.classList.add("as-tool-svg"),s.innerHTML=i.paths,n.appendChild(s)}var P=class{containerEl;store;constructor(t,e){this.containerEl=t,this.store=e}render(){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()}renderSection(t,e){let i=this.containerEl.createDiv("as-sidebar-section");i.createDiv({cls:"as-sidebar-title",text:t});for(let s of e){let o=i.createDiv("as-sidebar-item");this.isActive(s.filter)&&o.addClass("is-active");let r=o.createSpan("as-sidebar-icon");(0,L.setIcon)(r,s.icon),o.createSpan({cls:"as-sidebar-label",text:s.label}),s.count!==void 0&&o.createSpan({cls:"as-sidebar-count",text:String(s.count)}),o.addEventListener("click",()=>{this.store.setFilter(s.filter)})}}renderTypeSection(){let t=this.store.getTypeCounts(),i=[{label:"Skills",icon:"sparkles",type:"skill"},{label:"Commands",icon:"terminal",type:"command"},{label:"Agents",icon:"bot",type:"agent"},{label:"Rules",icon:"scroll",type:"rule"}].filter(s=>t.has(s.type)).map(s=>({label:s.label,icon:s.icon,filter:{kind:"type",type:s.type},count:t.get(s.type)||0}));i.length>0&&this.renderSection("Types",i)}renderToolSection(){let t=this.store.getToolCounts(),e=g.filter(s=>s.isInstalled()&&t.has(s.id));if(e.length===0)return;let i=this.containerEl.createDiv("as-sidebar-section");i.createDiv({cls:"as-sidebar-title",text:"Tools"});for(let s of e){let o={kind:"tool",toolId:s.id},r=i.createDiv("as-sidebar-item");this.isActive(o)&&r.addClass("is-active");let d=r.createSpan("as-sidebar-icon");S[s.id]?k(d,s.id,14):(0,L.setIcon)(d,s.icon),r.createSpan({cls:"as-sidebar-label",text:s.name}),r.createSpan({cls:"as-sidebar-count",text:String(t.get(s.id)||0)}),r.addEventListener("click",()=>this.store.setFilter(o))}}renderCollectionSection(){let t=this.containerEl.createDiv("as-sidebar-section");t.createDiv({cls:"as-sidebar-title",text:"Collections"});let e=new Set;for(let i of this.store.allItems)for(let s of i.collections)e.add(s);if(e.size===0){t.createDiv({cls:"as-sidebar-empty",text:"No collections yet"});return}for(let i of e){let s={kind:"collection",name:i},o=t.createDiv("as-sidebar-item");this.isActive(s)&&o.addClass("is-active");let r=o.createSpan("as-sidebar-icon");(0,L.setIcon)(r,"folder"),o.createSpan({cls:"as-sidebar-label",text:i}),o.addEventListener("click",()=>this.store.setFilter(s))}}isActive(t){let e=this.store.filter;return e.kind!==t.kind?!1:e.kind==="tool"&&t.kind==="tool"?e.toolId===t.toolId:e.kind==="type"&&t.kind==="type"?e.type===t.type:e.kind==="collection"&&t.kind==="collection"?e.name===t.name:!0}};var z=require("obsidian");var F=class{containerEl;store;onSelect;selectedId=null;constructor(t,e,i){this.containerEl=t,this.store=e,this.onSelect=i}setSelected(t){this.selectedId=t}render(){this.containerEl.empty(),this.containerEl.addClass("as-list");let e=this.containerEl.createDiv("as-search").createEl("input",{type:"text",placeholder:"Search skills...",cls:"as-search-input"});e.value=this.store.searchQuery,e.addEventListener("input",()=>{this.store.setSearch(e.value)});let i=this.containerEl.createDiv("as-list-items"),s=this.store.filteredItems;if(s.length===0){i.createDiv({cls:"as-list-empty",text:"No skills found"});return}for(let o of s)this.renderCard(i,o)}renderCard(t,e){let i=t.createDiv("as-skill-card");e.id===this.selectedId&&i.addClass("is-selected");let s=i.createDiv("as-skill-header"),o=s.createSpan({cls:"as-skill-name",text:e.name});if(e.isFavorite){let p=s.createSpan("as-skill-star");(0,z.setIcon)(p,"star")}e.description&&i.createDiv({cls:"as-skill-desc",text:e.description.length>80?e.description.slice(0,80)+"...":e.description});let r=i.createDiv("as-skill-meta"),d=r.createSpan({cls:`as-type-tag as-type-${e.type}`,text:e.type});for(let p of e.tools){let c=g.find(N=>N.id===p);if(!c)continue;let h=r.createSpan("as-tool-badge");h.title=c.name,h.setAttribute("aria-label",c.name),S[p]?(h.style.color=c.color,k(h,p,12)):(h.style.backgroundColor=c.color,h.addClass("as-tool-badge-dot"))}i.addEventListener("click",()=>{this.selectedId=e.id,this.onSelect(e)})}};var y=require("obsidian"),j=require("fs");function at(n){return Math.ceil(n.length/4)}function lt(n){return n<1024?`${n} B`:n<1024*1024?`${(n/1024).toFixed(1)} KB`:`${(n/(1024*1024)).toFixed(1)} MB`}function $(n){return n>=1e3?`${(n/1e3).toFixed(1)}k`:String(n)}function ct(n){return new Date(n).toLocaleDateString(void 0,{month:"short",day:"numeric",year:"numeric"})}var M=class{containerEl;store;settings;saveSettings;currentItem=null;isEditing=!1;component;constructor(t,e,i,s,o){this.containerEl=t,this.store=e,this.settings=i,this.saveSettings=s,this.component=o}show(t){this.currentItem=t,this.isEditing=!1,this.render()}clear(){this.currentItem=null,this.containerEl.empty(),this.containerEl.addClass("as-detail");let t=this.containerEl.createDiv("as-detail-empty");(0,y.setIcon)(t.createDiv("as-detail-empty-icon"),"file-text"),t.createDiv({text:"Select a skill to view"})}render(){this.containerEl.empty(),this.containerEl.addClass("as-detail");let t=this.currentItem;if(!t)return this.clear();this.renderToolbar(t),this.isEditing?this.renderEditor(t):this.renderPreview(t)}renderToolbar(t){let e=this.containerEl.createDiv("as-detail-toolbar"),i=e.createDiv("as-toolbar-top"),s=i.createDiv("as-toolbar-left");s.createSpan({cls:"as-detail-title",text:t.name});for(let E of t.tools){let T=g.find(tt=>tt.id===E);if(!T)continue;let D=s.createSpan("as-tool-name-badge");D.style.borderColor=T.color,D.style.color=T.color,S[E]&&k(D,E,12),D.createSpan({text:T.name})}let o=i.createDiv("as-toolbar-right"),r=o.createEl("button",{cls:"as-toolbar-btn",attr:{"aria-label":"Toggle favorite"}});(0,y.setIcon)(r,t.isFavorite?"star":"star-off"),r.addEventListener("click",()=>{this.store.toggleFavorite(t.id,this.settings),this.saveSettings(),this.render()});let d=o.createEl("button",{cls:"as-toolbar-btn",attr:{"aria-label":this.isEditing?"Preview":"Edit"}});(0,y.setIcon)(d,this.isEditing?"eye":"pencil"),d.addEventListener("click",()=>{this.isEditing=!this.isEditing,this.render()});let p=o.createEl("button",{cls:"as-toolbar-btn",attr:{"aria-label":"Open in Finder"}});(0,y.setIcon)(p,"folder-open"),p.addEventListener("click",()=>{let{shell:E}=require("electron");E.showItemInFolder(t.filePath)});let c=e.createDiv("as-detail-meta-bar"),h=at(t.content),N=t.content.length;c.createSpan({cls:"as-meta-item",text:lt(t.fileSize)}),c.createSpan({cls:"as-meta-item",text:`${$(N)} chars`}),c.createSpan({cls:"as-meta-item",text:`~${$(h)} tokens`}),c.createSpan({cls:"as-meta-item",text:ct(t.lastModified)}),c.createSpan({cls:"as-meta-item as-meta-type",text:t.type})}renderFrontmatter(t,e){let i=Object.keys(e.frontmatter);if(i.length===0)return;let s=t.createDiv("as-frontmatter");for(let o of i){let r=e.frontmatter[o];if(r==null)continue;let d=s.createDiv("as-fm-prop");d.createSpan({cls:"as-fm-key",text:o});let p=typeof r=="object"?JSON.stringify(r):String(r);p.length>200?d.createDiv({cls:"as-fm-value-long"}).setText(p):d.createSpan({cls:"as-fm-value"}).setText(p)}}renderPreview(t){let e=this.containerEl.createDiv("as-detail-body");this.renderFrontmatter(e,t);let i=e.createDiv("as-detail-preview markdown-rendered");y.MarkdownRenderer.render(this.component.app||globalThis.app,t.content,i,t.filePath,this.component)}renderEditor(t){let e=this.containerEl.createDiv("as-detail-body as-detail-body-editor"),i=e.createEl("textarea",{cls:"as-editor-textarea"});i.value=t.content,i.spellcheck=!1,i.addEventListener("keydown",r=>{if((r.metaKey||r.ctrlKey)&&r.key==="s"&&(r.preventDefault(),this.saveFile(t,i.value)),r.key==="Tab"){r.preventDefault();let d=i.selectionStart,p=i.selectionEnd;i.value=i.value.substring(0,d)+" "+i.value.substring(p),i.selectionStart=i.selectionEnd=d+1}});let s=e.createDiv("as-save-bar");s.createEl("button",{cls:"as-save-btn",text:"Save"}).addEventListener("click",()=>{this.saveFile(t,i.value)}),s.createSpan({cls:"as-save-hint",text:"Cmd+S to save"})}saveFile(t,e){try{(0,j.writeFileSync)(t.filePath,e,"utf-8"),t.content=e;let{Notice:i}=require("obsidian");new i(`Saved ${t.name}`)}catch(i){let{Notice:s}=require("obsidian");new s(`Failed to save: ${i instanceof Error?i.message:String(i)}`)}}};var I="agent-skills-view",O=class extends R.ItemView{store;settings;saveSettings;sidebarPanel;listPanel;detailPanel;sidebarEl;listEl;detailEl;constructor(t,e,i,s){super(t),this.store=e,this.settings=i,this.saveSettings=s}getViewType(){return I}getDisplayText(){return"Agent Skills"}getIcon(){return"cpu"}async onOpen(){let t=this.contentEl;t.empty(),t.addClass("as-container"),this.sidebarEl=t.createDiv("as-panel as-panel-sidebar"),this.listEl=t.createDiv("as-panel as-panel-list"),this.detailEl=t.createDiv("as-panel as-panel-detail"),this.sidebarPanel=new P(this.sidebarEl,this.store),this.listPanel=new F(this.listEl,this.store,e=>this.onSelectItem(e)),this.detailPanel=new M(this.detailEl,this.store,this.settings,this.saveSettings,this),this.store.on("updated",()=>this.renderAll()),this.renderAll()}renderAll(){this.sidebarPanel.render(),this.listPanel.render(),this.store.filteredItems.length||this.detailPanel.clear()}onSelectItem(t){this.listPanel.setSelected(t.id),this.listPanel.render(),this.detailPanel.show(t)}async onClose(){this.store.offref(this.store.on("updated",()=>{}))}};var Y=require("obsidian");var u=require("fs"),f=require("path"),K=require("obsidian"),Q=require("crypto");var Z=new Set(["readme.md","license","license.md","changelog.md",".ds_store","thumbs.db"]);function dt(n){return(0,Q.createHash)("sha256").update(n).digest("hex").slice(0,12)}function pt(n){let t=n.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);if(!t)return{frontmatter:{},content:n};try{let e=(0,K.parseYaml)(t[1]);return{frontmatter:typeof e=="object"&&e?e:{},content:t[2]}}catch{return{frontmatter:{},content:n}}}function ht(n,t,e){if(typeof n.name=="string"&&n.name)return n.name;let i=t.match(/^#\s+(.+)$/m);if(i)return i[1].trim();let s=(0,f.basename)(e,(0,f.extname)(e));return s==="SKILL"?(0,f.basename)((0,f.join)(e,"..")):s}function mt(n,t,e){if(!(0,u.existsSync)(n))return[];let i=[];for(let s of(0,u.readdirSync)(n,{withFileTypes:!0})){if(!s.isDirectory())continue;let o=(0,f.join)(n,s.name,"SKILL.md");if(!(0,u.existsSync)(o))continue;let r=x(o,t,e);r&&i.push(r)}return i}function ut(n,t,e){if(!(0,u.existsSync)(n))return[];let i=[];for(let s of(0,u.readdirSync)(n,{withFileTypes:!0})){if(s.isDirectory()){let d=(0,f.join)(n,s.name,"SKILL.md");if((0,u.existsSync)(d)){let h=x(d,t,e);h&&i.push(h);continue}let p=(0,u.readdirSync)((0,f.join)(n,s.name)).filter(h=>h.endsWith(".md")&&!Z.has(h.toLowerCase())),c=p.find(h=>h.toLowerCase()===`${s.name.toLowerCase()}.md`)||p[0];if(c){let h=x((0,f.join)(n,s.name,c),t,e);h&&i.push(h)}continue}let o=s.name.toLowerCase();if(!o.endsWith(".md")||Z.has(o))continue;let r=x((0,f.join)(n,s.name),t,e);r&&i.push(r)}return i}function ft(n,t,e){if(!(0,u.existsSync)(n))return[];let i=[];for(let s of(0,u.readdirSync)(n,{withFileTypes:!0})){if(!s.name.endsWith(".mdc")&&!s.name.endsWith(".md")||s.isDirectory())continue;let o=x((0,f.join)(n,s.name),t,e);o&&i.push(o)}return i}function x(n,t,e){try{let i=(0,u.readFileSync)(n,"utf-8"),s=(0,u.statSync)(n),{frontmatter:o,content:r}=pt(i),d=ht(o,r,n),p=typeof o.description=="string"?o.description:"",c;try{c=(0,u.realpathSync)(n)}catch{c=n}return{id:dt(c),name:d,description:p,type:t,tools:[e],filePath:n,realPath:c,dirPath:(0,f.join)(n,".."),content:i,frontmatter:o,lastModified:s.mtimeMs,fileSize:s.size,isFavorite:!1,collections:[]}}catch{return null}}function gt(n,t){switch(n.pattern){case"directory-with-skillmd":return mt(n.baseDir,n.type,t);case"flat-md":return ut(n.baseDir,n.type,t);case"mdc":return ft(n.baseDir,n.type,t)}}function q(n){let t=new Map;for(let e of g){if(!e.isInstalled())continue;let i=n.tools[e.id];if(i&&!i.enabled)continue;let s=[...e.paths,...e.agentPaths];for(let o of s)for(let r of gt(o,e.id)){let d=t.get(r.id);if(d)d.tools.includes(e.id)||d.tools.push(e.id);else{r.isFavorite=n.favorites.includes(r.id);for(let[p,c]of Object.entries(n.collections))c.includes(r.id)&&r.collections.push(p);t.set(r.id,r)}}}return t}function U(){let n=[];for(let t of g)if(t.isInstalled())for(let e of[...t.paths,...t.agentPaths])(0,u.existsSync)(e.baseDir)&&n.push(e.baseDir);return n}var A=class extends Y.Events{items=new Map;_filter={kind:"all"};_searchQuery="";get filter(){return this._filter}get searchQuery(){return this._searchQuery}get allItems(){return Array.from(this.items.values())}get filteredItems(){let t=this.allItems;switch(this._filter.kind){case"favorites":t=t.filter(e=>e.isFavorite);break;case"tool":t=t.filter(e=>e.tools.includes(this._filter.toolId));break;case"type":t=t.filter(e=>e.type===this._filter.type);break;case"collection":t=t.filter(e=>e.collections.includes(this._filter.name));break}if(this._searchQuery){let e=this._searchQuery.toLowerCase();t=t.filter(i=>i.name.toLowerCase().includes(e)||i.description.toLowerCase().includes(e)||i.content.toLowerCase().includes(e))}return t.sort((e,i)=>e.name.localeCompare(i.name))}getItem(t){return this.items.get(t)}refresh(t){this.items=q(t),this.trigger("updated")}setFilter(t){this._filter=t,this.trigger("updated")}setSearch(t){this._searchQuery=t,this.trigger("updated")}toggleFavorite(t,e){let i=this.items.get(t);i&&(i.isFavorite=!i.isFavorite,i.isFavorite?e.favorites.includes(t)||e.favorites.push(t):e.favorites=e.favorites.filter(s=>s!==t),this.trigger("updated"))}getToolCounts(){let t=new Map;for(let e of this.items.values())for(let i of e.tools)t.set(i,(t.get(i)||0)+1);return t}getTypeCounts(){let t=new Map;for(let e of this.items.values())t.set(e.type,(t.get(e.type)||0)+1);return t}};var X=require("fs"),H=class{watchers=[];debounceTimer=null;debounceMs;onChange;constructor(t,e){this.debounceMs=t,this.onChange=e}watchPaths(t){this.close();for(let e of t)try{let i=(0,X.watch)(e,{recursive:!0},()=>this.scheduleUpdate());this.watchers.push(i)}catch{}}scheduleUpdate(){this.debounceTimer&&clearTimeout(this.debounceTimer),this.debounceTimer=setTimeout(()=>{this.debounceTimer=null,this.onChange()},this.debounceMs)}close(){this.debounceTimer&&(clearTimeout(this.debounceTimer),this.debounceTimer=null);for(let t of this.watchers)try{t.close()}catch{}this.watchers=[]}};var w=require("obsidian");var V=class extends w.PluginSettingTab{plugin;constructor(t,e){super(t,e),this.plugin=e}display(){let{containerEl:t}=this;t.empty(),t.createEl("h2",{text:"Agent Skills"}),new w.Setting(t).setName("File watching").setDesc("Automatically detect changes to skill files").addToggle(e=>e.setValue(this.plugin.settings.watchEnabled).onChange(async i=>{this.plugin.settings.watchEnabled=i,await this.plugin.saveSettings(),this.plugin.restartWatcher()})),new w.Setting(t).setName("Watch debounce (ms)").setDesc("Delay before re-scanning after file changes").addText(e=>e.setValue(String(this.plugin.settings.watchDebounceMs)).onChange(async i=>{let s=parseInt(i);!isNaN(s)&&s>=100&&(this.plugin.settings.watchDebounceMs=s,await this.plugin.saveSettings())})),t.createEl("h3",{text:"Tools"});for(let e of g){let i=e.isInstalled(),s=this.plugin.settings.tools[e.id]||{enabled:!0,customPaths:[]};new w.Setting(t).setName(e.name).setDesc(i?"Installed":"Not detected").addToggle(o=>o.setValue(i&&s.enabled).setDisabled(!i).onChange(async r=>{this.plugin.settings.tools[e.id]={...s,enabled:r},await this.plugin.saveSettings(),this.plugin.refreshStore()}))}}};var B={tools:{},watchEnabled:!0,watchDebounceMs:500,favorites:[],collections:{},customScanPaths:[]};var _=class extends J.Plugin{settings=B;store=new A;watcher=null;async onload(){await this.loadSettings(),this.registerView(I,t=>new O(t,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 V(this.app,this)),this.refreshStore(),this.startWatcher()}onunload(){this.stopWatcher()}refreshStore(){this.store.refresh(this.settings)}startWatcher(){this.settings.watchEnabled&&(this.watcher=new H(this.settings.watchDebounceMs,()=>this.refreshStore()),this.watcher.watchPaths(U()))}stopWatcher(){this.watcher&&(this.watcher.close(),this.watcher=null)}restartWatcher(){this.stopWatcher(),this.startWatcher()}async activateView(){let t=this.app.workspace.getLeavesOfType(I);if(t.length>0){this.app.workspace.revealLeaf(t[0]);return}let e=this.app.workspace.getLeaf("tab");await e.setViewState({type:I,active:!0}),this.app.workspace.revealLeaf(e)}async loadSettings(){this.settings=Object.assign({},B,await this.loadData())}async saveSettings(){await this.saveData(this.settings)}}; diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..760c2aa --- /dev/null +++ b/manifest.json @@ -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 +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..dabff2a --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..7759421 --- /dev/null +++ b/src/main.ts @@ -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 { + 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 { + 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 { + this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); + } + + async saveSettings(): Promise { + await this.saveData(this.settings); + } +} diff --git a/src/scanner.ts b/src/scanner.ts new file mode 100644 index 0000000..b4a67f0 --- /dev/null +++ b/src/scanner.ts @@ -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; + 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, + 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 { + const items = new Map(); + + 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; +} diff --git a/src/settings.ts b/src/settings.ts new file mode 100644 index 0000000..f68a501 --- /dev/null +++ b/src/settings.ts @@ -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(); + }) + ); + } + } +} diff --git a/src/store.ts b/src/store.ts new file mode 100644 index 0000000..d16feec --- /dev/null +++ b/src/store.ts @@ -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 = 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 { + const counts = new Map(); + 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 { + const counts = new Map(); + for (const item of this.items.values()) { + counts.set(item.type, (counts.get(item.type) || 0) + 1); + } + return counts; + } +} diff --git a/src/tool-configs.ts b/src/tool-configs.ts new file mode 100644 index 0000000..8bf060e --- /dev/null +++ b/src/tool-configs.ts @@ -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"), + }, +]; diff --git a/src/tool-icons.ts b/src/tool-icons.ts new file mode 100644 index 0000000..6e401d5 --- /dev/null +++ b/src/tool-icons.ts @@ -0,0 +1,52 @@ +export const TOOL_SVGS: Record = { + "claude-code": { + viewBox: "0 0 24 24", + paths: ``, + }, + cursor: { + viewBox: "0 0 466.73 532.09", + paths: ``, + }, + windsurf: { + viewBox: "0 0 24 24", + paths: ``, + }, + codex: { + viewBox: "0 0 24 24", + paths: ``, + }, + copilot: { + viewBox: "0 0 24 24", + paths: ``, + }, + opencode: { + viewBox: "0 0 24 36", + paths: ``, + }, + "claude-desktop": { + viewBox: "0 0 24 24", + paths: ``, + }, + "global-agents": { + viewBox: "0 0 24 24", + paths: ``, + }, +}; + +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); +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..84a781b --- /dev/null +++ b/src/types.ts @@ -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; + 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; + watchEnabled: boolean; + watchDebounceMs: number; + favorites: string[]; + collections: Record; + customScanPaths: string[]; +} + +export const DEFAULT_SETTINGS: ChopsSettings = { + tools: {}, + watchEnabled: true, + watchDebounceMs: 500, + favorites: [], + collections: {}, + customScanPaths: [], +}; diff --git a/src/views/detail.ts b/src/views/detail.ts new file mode 100644 index 0000000..d71b350 --- /dev/null +++ b/src/views/detail.ts @@ -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; + private currentItem: SkillItem | null = null; + private isEditing = false; + private component: Component; + + constructor( + containerEl: HTMLElement, + store: SkillStore, + settings: ChopsSettings, + saveSettings: () => Promise, + 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)}`); + } + } +} diff --git a/src/views/list.ts b/src/views/list.ts new file mode 100644 index 0000000..8ec6c34 --- /dev/null +++ b/src/views/list.ts @@ -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); + }); + } +} diff --git a/src/views/main-view.ts b/src/views/main-view.ts new file mode 100644 index 0000000..ca3153b --- /dev/null +++ b/src/views/main-view.ts @@ -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; + + 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 + ) { + 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 { + 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 { + this.store.offref(this.store.on("updated", () => {})); + } +} diff --git a/src/views/sidebar.ts b/src/views/sidebar.ts new file mode 100644 index 0000000..5bd7075 --- /dev/null +++ b/src/views/sidebar.ts @@ -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(); + 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; + } +} diff --git a/src/watcher.ts b/src/watcher.ts new file mode 100644 index 0000000..04a00ca --- /dev/null +++ b/src/watcher.ts @@ -0,0 +1,44 @@ +import { watch, type FSWatcher } from "fs"; + +export class SkillWatcher { + private watchers: FSWatcher[] = []; + private debounceTimer: ReturnType | 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 = []; + } +} diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..9b883d0 --- /dev/null +++ b/styles.css @@ -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); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..d0ccec7 --- /dev/null +++ b/tsconfig.json @@ -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"] +}