diff --git a/bin/package-lock.json b/bin/package-lock.json
index 2e6ef4b..a84f592 100644
--- a/bin/package-lock.json
+++ b/bin/package-lock.json
@@ -5,7 +5,425 @@
"packages": {
"": {
"dependencies": {
- "gray-matter": "^4.0.3"
+ "gray-matter": "^4.0.3",
+ "tsx": "^4.19.2",
+ "yaml": "^2.7.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
+ "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
+ "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
+ "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
+ "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
+ "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
+ "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
+ "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
+ "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
+ "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
+ "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
+ "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
+ "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
+ "cpu": [
+ "loong64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
+ "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
+ "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
+ "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
+ "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz",
+ "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
+ "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
+ "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
+ "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
+ "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
+ "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
+ "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
+ "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
+ "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
+ "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
}
},
"node_modules/argparse": {
@@ -17,6 +435,47 @@
"sprintf-js": "~1.0.2"
}
},
+ "node_modules/esbuild": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
+ "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.27.3",
+ "@esbuild/android-arm": "0.27.3",
+ "@esbuild/android-arm64": "0.27.3",
+ "@esbuild/android-x64": "0.27.3",
+ "@esbuild/darwin-arm64": "0.27.3",
+ "@esbuild/darwin-x64": "0.27.3",
+ "@esbuild/freebsd-arm64": "0.27.3",
+ "@esbuild/freebsd-x64": "0.27.3",
+ "@esbuild/linux-arm": "0.27.3",
+ "@esbuild/linux-arm64": "0.27.3",
+ "@esbuild/linux-ia32": "0.27.3",
+ "@esbuild/linux-loong64": "0.27.3",
+ "@esbuild/linux-mips64el": "0.27.3",
+ "@esbuild/linux-ppc64": "0.27.3",
+ "@esbuild/linux-riscv64": "0.27.3",
+ "@esbuild/linux-s390x": "0.27.3",
+ "@esbuild/linux-x64": "0.27.3",
+ "@esbuild/netbsd-arm64": "0.27.3",
+ "@esbuild/netbsd-x64": "0.27.3",
+ "@esbuild/openbsd-arm64": "0.27.3",
+ "@esbuild/openbsd-x64": "0.27.3",
+ "@esbuild/openharmony-arm64": "0.27.3",
+ "@esbuild/sunos-x64": "0.27.3",
+ "@esbuild/win32-arm64": "0.27.3",
+ "@esbuild/win32-ia32": "0.27.3",
+ "@esbuild/win32-x64": "0.27.3"
+ }
+ },
"node_modules/esprima": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
@@ -42,6 +501,32 @@
"node": ">=0.10.0"
}
},
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/get-tsconfig": {
+ "version": "4.13.6",
+ "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
+ "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==",
+ "license": "MIT",
+ "dependencies": {
+ "resolve-pkg-maps": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
+ }
+ },
"node_modules/gray-matter": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz",
@@ -88,6 +573,15 @@
"node": ">=0.10.0"
}
},
+ "node_modules/resolve-pkg-maps": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
+ "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
+ }
+ },
"node_modules/section-matter": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz",
@@ -115,6 +609,40 @@
"engines": {
"node": ">=0.10.0"
}
+ },
+ "node_modules/tsx": {
+ "version": "4.21.0",
+ "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
+ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "~0.27.0",
+ "get-tsconfig": "^4.7.5"
+ },
+ "bin": {
+ "tsx": "dist/cli.mjs"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ }
+ },
+ "node_modules/yaml": {
+ "version": "2.8.2",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
+ "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
+ "license": "ISC",
+ "bin": {
+ "yaml": "bin.mjs"
+ },
+ "engines": {
+ "node": ">= 14.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/eemeli"
+ }
}
}
}
diff --git a/skills/agent-md-refactor/README.md b/skills/agent-md-refactor/README.md
new file mode 100644
index 0000000..9de3b3d
--- /dev/null
+++ b/skills/agent-md-refactor/README.md
@@ -0,0 +1,227 @@
+# Agent MD Refactor
+
+A Claude Code skill that transforms bloated agent instruction files into clean, organized documentation using progressive disclosure principles.
+
+Based on https://x.com/mattpocockuk/status/2012906065856270504 (Matt Pocock's Prompt Idea)
+
+## Purpose
+
+Over time, agent instruction files like `CLAUDE.md`, `AGENTS.md`, or `COPILOT.md` tend to grow into unwieldy documents containing hundreds of lines of mixed instructions. This creates several problems:
+
+- **Context waste**: Every task loads the entire file, even when most instructions are irrelevant
+- **Maintenance burden**: Finding and updating specific instructions becomes difficult
+- **Contradictions**: Conflicting guidelines accumulate without being noticed
+- **Signal-to-noise ratio**: Important rules get buried among obvious or vague statements
+
+This skill solves these problems by applying **progressive disclosure** - keeping only essential, universal instructions in the root file while organizing everything else into focused, linked documentation files.
+
+## When to Use
+
+Use this skill when you need to clean up agent instruction files. Common trigger phrases include:
+
+- "refactor my AGENTS.md" / "refactor my CLAUDE.md"
+- "split my agent instructions"
+- "organize my CLAUDE.md file"
+- "my AGENTS.md is too long"
+- "progressive disclosure for my instructions"
+- "clean up my agent config"
+
+**Good candidates for refactoring:**
+
+- Root agent files exceeding 50-100 lines
+- Files mixing multiple unrelated topics (testing, code style, architecture, etc.)
+- Documents that have grown organically without structure
+- Files containing contradictory or redundant instructions
+
+## How It Works
+
+The skill follows a systematic 5-phase process:
+
+### Phase 1: Find Contradictions
+
+Before restructuring, the skill identifies conflicting instructions that need resolution. Examples include contradictory style guidelines ("use semicolons" vs "no semicolons") or incompatible workflow instructions. Each contradiction is surfaced with a question for the user to resolve.
+
+### Phase 2: Identify the Essentials
+
+Extracts only what truly belongs in the root file - information that applies to every single task:
+
+| Keep in Root | Move Out |
+|-------------|----------|
+| One-sentence project description | Language-specific conventions |
+| Non-standard package manager | Testing guidelines |
+| Custom build/test commands | Code style details |
+| Critical overrides | Framework patterns |
+| Universal rules (100% of tasks) | Documentation standards |
+
+### Phase 3: Group the Rest
+
+Organizes remaining instructions into logical categories like:
+
+- `typescript.md` - Type patterns, strict mode rules
+- `testing.md` - Test frameworks, coverage, mocking
+- `code-style.md` - Formatting, naming, structure
+- `git-workflow.md` - Commits, branches, PRs
+- `architecture.md` - Patterns, folder structure
+
+### Phase 4: Create the File Structure
+
+Generates the new file hierarchy with properly linked documentation:
+
+```
+project-root/
+├── CLAUDE.md # Minimal root with links
+└── .claude/ # Categorized instructions
+ ├── typescript.md
+ ├── testing.md
+ ├── code-style.md
+ └── architecture.md
+```
+
+### Phase 5: Flag for Deletion
+
+Identifies instructions that should be removed entirely:
+
+- **Redundant**: "Use TypeScript" in a TypeScript project
+- **Too vague**: "Write clean code" without specifics
+- **Overly obvious**: "Don't introduce bugs"
+- **Default behavior**: "Use descriptive variable names"
+- **Outdated**: References to deprecated APIs
+
+## Key Features
+
+- **Contradiction detection**: Surfaces conflicting instructions before restructuring
+- **Intelligent categorization**: Groups related instructions into logical files
+- **Root file minimization**: Targets under 50 lines for the main file
+- **Deletion recommendations**: Identifies instructions wasting context tokens
+- **Template-driven output**: Consistent structure across all generated files
+- **Link verification**: Ensures all references between files are valid
+
+## Usage Examples
+
+### Basic Refactoring
+
+```
+User: refactor my CLAUDE.md
+
+Claude: I'll analyze your CLAUDE.md file and refactor it using progressive
+disclosure principles...
+```
+
+### Specific File
+
+```
+User: my AGENTS.md is too long, can you split it up?
+
+Claude: I'll review your AGENTS.md and organize it into focused, linked files...
+```
+
+### After a Project Grows
+
+```
+User: organize my agent config - it's gotten out of control
+
+Claude: I'll apply the 5-phase refactoring process to clean up your
+agent instructions...
+```
+
+## Output
+
+After running the skill, you get:
+
+**Minimal root file (~50 lines or less):**
+```markdown
+# Project Name
+
+One-sentence description of the project.
+
+## Quick Reference
+
+- **Package Manager:** pnpm
+- **Build:** `pnpm build`
+- **Test:** `pnpm test`
+
+## Detailed Instructions
+
+- [TypeScript Conventions](.claude/typescript.md)
+- [Testing Guidelines](.claude/testing.md)
+- [Code Style](.claude/code-style.md)
+```
+
+**Organized linked files with consistent structure:**
+```markdown
+# Testing Guidelines
+
+## Overview
+Brief context for when these guidelines apply.
+
+## Rules
+
+### Unit Tests
+- Specific, actionable instruction
+- Another specific instruction
+
+## Examples
+
+### Good
+[code example]
+
+### Avoid
+[code example]
+```
+
+**Deletion report:**
+```markdown
+## Flagged for Deletion
+
+| Instruction | Reason |
+|-------------|--------|
+| "Write clean, maintainable code" | Too vague to be actionable |
+| "Use TypeScript" | Redundant - project is already TS |
+```
+
+## Best Practices
+
+### Before Refactoring
+
+1. **Commit current state** - Have a clean git state so you can review changes
+2. **Identify your goals** - Know what problems you want to solve
+3. **Gather all instruction files** - Some projects have instructions scattered across multiple locations
+
+### During Refactoring
+
+1. **Resolve contradictions first** - Do not proceed until conflicts are addressed
+2. **Be aggressive about root minimization** - When in doubt, move it out
+3. **Aim for 3-8 linked files** - Not too granular, not too broad
+4. **Delete liberally** - Vague instructions waste tokens without providing value
+
+### After Refactoring
+
+1. **Verify all links work** - Test that referenced files exist
+2. **Check for lost instructions** - Ensure nothing important was dropped
+3. **Test with real tasks** - Run a few typical tasks to verify the agent can find needed instructions
+
+## Anti-Patterns to Avoid
+
+| Avoid | Why | Instead |
+|-------|-----|---------|
+| Keeping everything in root | Bloated, hard to maintain | Split into linked files |
+| Too many categories | Fragmentation, navigation overhead | Consolidate related topics |
+| Vague instructions | Wastes tokens, no value | Be specific or delete |
+| Duplicating defaults | Agent already knows | Only override when needed |
+| Deep nesting | Hard to navigate | Flat structure with links |
+
+## Verification Checklist
+
+After refactoring, verify:
+
+- [ ] Root file is under 50 lines
+- [ ] Root contains ONLY universal information
+- [ ] All links to sub-files work correctly
+- [ ] No contradictions remain between files
+- [ ] Every instruction is specific and actionable
+- [ ] No instructions were lost (unless intentionally deleted)
+- [ ] Each linked file is self-contained for its topic
+
+## License
+
+MIT
diff --git a/skills/agent-md-refactor/SKILL.md b/skills/agent-md-refactor/SKILL.md
new file mode 100644
index 0000000..a05ec0c
--- /dev/null
+++ b/skills/agent-md-refactor/SKILL.md
@@ -0,0 +1,295 @@
+---
+name: agent-md-refactor
+description: >-
+ Refactor bloated AGENTS.md, CLAUDE.md, or similar agent instruction files to
+ follow progressive disclosure principles. Splits monolithic files into
+ organized, linked documentation.
+license: MIT
+metadata:
+ category: development
+ source:
+ repository: 'https://github.com/softaworks/agent-toolkit'
+ path: skills/agent-md-refactor
+---
+
+# Agent MD Refactor
+
+Refactor bloated agent instruction files (AGENTS.md, CLAUDE.md, COPILOT.md, etc.) to follow **progressive disclosure principles** - keeping essentials at root and organizing the rest into linked, categorized files.
+
+---
+
+## Triggers
+
+Use this skill when:
+- "refactor my AGENTS.md" / "refactor my CLAUDE.md"
+- "split my agent instructions"
+- "organize my CLAUDE.md file"
+- "my AGENTS.md is too long"
+- "progressive disclosure for my instructions"
+- "clean up my agent config"
+
+---
+
+## Quick Reference
+
+| Phase | Action | Output |
+|-------|--------|--------|
+| 1. Analyze | Find contradictions | List of conflicts to resolve |
+| 2. Extract | Identify essentials | Core instructions for root file |
+| 3. Categorize | Group remaining instructions | Logical categories |
+| 4. Structure | Create file hierarchy | Root + linked files |
+| 5. Prune | Flag for deletion | Redundant/vague instructions |
+
+---
+
+## Process
+
+### Phase 1: Find Contradictions
+
+Identify any instructions that conflict with each other.
+
+**Look for:**
+- Contradictory style guidelines (e.g., "use semicolons" vs "no semicolons")
+- Conflicting workflow instructions
+- Incompatible tool preferences
+- Mutually exclusive patterns
+
+**For each contradiction found:**
+```markdown
+## Contradiction Found
+
+**Instruction A:** [quote]
+**Instruction B:** [quote]
+
+**Question:** Which should take precedence, or should both be conditional?
+```
+
+Ask the user to resolve before proceeding.
+
+---
+
+### Phase 2: Identify the Essentials
+
+Extract ONLY what belongs in the root agent file. The root should be minimal - information that applies to **every single task**.
+
+**Essential content (keep in root):**
+| Category | Example |
+|----------|---------|
+| Project description | One sentence: "A React dashboard for analytics" |
+| Package manager | Only if not npm (e.g., "Uses pnpm") |
+| Non-standard commands | Custom build/test/typecheck commands |
+| Critical overrides | Things that MUST override defaults |
+| Universal rules | Applies to 100% of tasks |
+
+**NOT essential (move to linked files):**
+- Language-specific conventions
+- Testing guidelines
+- Code style details
+- Framework patterns
+- Documentation standards
+- Git workflow details
+
+---
+
+### Phase 3: Group the Rest
+
+Organize remaining instructions into logical categories.
+
+**Common categories:**
+| Category | Contents |
+|----------|----------|
+| `typescript.md` | TS conventions, type patterns, strict mode rules |
+| `testing.md` | Test frameworks, coverage, mocking patterns |
+| `code-style.md` | Formatting, naming, comments, structure |
+| `git-workflow.md` | Commits, branches, PRs, reviews |
+| `architecture.md` | Patterns, folder structure, dependencies |
+| `api-design.md` | REST/GraphQL conventions, error handling |
+| `security.md` | Auth patterns, input validation, secrets |
+| `performance.md` | Optimization rules, caching, lazy loading |
+
+**Grouping rules:**
+1. Each file should be self-contained for its topic
+2. Aim for 3-8 files (not too granular, not too broad)
+3. Name files clearly: `{topic}.md`
+4. Include only actionable instructions
+
+---
+
+### Phase 4: Create the File Structure
+
+**Output structure:**
+```
+project-root/
+├── CLAUDE.md (or AGENTS.md) # Minimal root with links
+└── .claude/ # Or docs/agent-instructions/
+ ├── typescript.md
+ ├── testing.md
+ ├── code-style.md
+ ├── git-workflow.md
+ └── architecture.md
+```
+
+**Root file template:**
+```markdown
+# Project Name
+
+One-sentence description of the project.
+
+## Quick Reference
+
+- **Package Manager:** pnpm
+- **Build:** `pnpm build`
+- **Test:** `pnpm test`
+- **Typecheck:** `pnpm typecheck`
+
+## Detailed Instructions
+
+For specific guidelines, see:
+- [TypeScript Conventions](.claude/typescript.md)
+- [Testing Guidelines](.claude/testing.md)
+- [Code Style](.claude/code-style.md)
+- [Git Workflow](.claude/git-workflow.md)
+- [Architecture Patterns](.claude/architecture.md)
+```
+
+**Each linked file template:**
+```markdown
+# {Topic} Guidelines
+
+## Overview
+Brief context for when these guidelines apply.
+
+## Rules
+
+### Rule Category 1
+- Specific, actionable instruction
+- Another specific instruction
+
+### Rule Category 2
+- Specific, actionable instruction
+
+## Examples
+
+### Good
+\`\`\`typescript
+// Example of correct pattern
+\`\`\`
+
+### Avoid
+\`\`\`typescript
+// Example of what not to do
+\`\`\`
+```
+
+---
+
+### Phase 5: Flag for Deletion
+
+Identify instructions that should be removed entirely.
+
+**Delete if:**
+| Criterion | Example | Why Delete |
+|-----------|---------|------------|
+| Redundant | "Use TypeScript" (in a .ts project) | Agent already knows |
+| Too vague | "Write clean code" | Not actionable |
+| Overly obvious | "Don't introduce bugs" | Wastes context |
+| Default behavior | "Use descriptive variable names" | Standard practice |
+| Outdated | References deprecated APIs | No longer applies |
+
+**Output format:**
+```markdown
+## Flagged for Deletion
+
+| Instruction | Reason |
+|-------------|--------|
+| "Write clean, maintainable code" | Too vague to be actionable |
+| "Use TypeScript" | Redundant - project is already TS |
+| "Don't commit secrets" | Agent already knows this |
+| "Follow best practices" | Meaningless without specifics |
+```
+
+---
+
+## Execution Checklist
+
+```
+[ ] Phase 1: All contradictions identified and resolved
+[ ] Phase 2: Root file contains ONLY essentials
+[ ] Phase 3: All remaining instructions categorized
+[ ] Phase 4: File structure created with proper links
+[ ] Phase 5: Redundant/vague instructions removed
+[ ] Verify: Each linked file is self-contained
+[ ] Verify: Root file is under 50 lines
+[ ] Verify: All links work correctly
+```
+
+---
+
+## Anti-Patterns
+
+| Avoid | Why | Instead |
+|-------|-----|---------|
+| Keeping everything in root | Bloated, hard to maintain | Split into linked files |
+| Too many categories | Fragmentation | Consolidate related topics |
+| Vague instructions | Wastes tokens, no value | Be specific or delete |
+| Duplicating defaults | Agent already knows | Only override when needed |
+| Deep nesting | Hard to navigate | Flat structure with links |
+
+---
+
+## Examples
+
+### Before (Bloated Root)
+```markdown
+# CLAUDE.md
+
+This is a React project.
+
+## Code Style
+- Use 2 spaces
+- Use semicolons
+- Prefer const over let
+- Use arrow functions
+... (200 more lines)
+
+## Testing
+- Use Jest
+- Coverage > 80%
+... (100 more lines)
+
+## TypeScript
+- Enable strict mode
+... (150 more lines)
+```
+
+### After (Progressive Disclosure)
+```markdown
+# CLAUDE.md
+
+React dashboard for real-time analytics visualization.
+
+## Commands
+- `pnpm dev` - Start development server
+- `pnpm test` - Run tests with coverage
+- `pnpm build` - Production build
+
+## Guidelines
+- [Code Style](.claude/code-style.md)
+- [Testing](.claude/testing.md)
+- [TypeScript](.claude/typescript.md)
+```
+
+---
+
+## Verification
+
+After refactoring, verify:
+
+1. **Root file is minimal** - Under 50 lines, only universal info
+2. **Links work** - All referenced files exist
+3. **No contradictions** - Instructions are consistent
+4. **Actionable content** - Every instruction is specific
+5. **Complete coverage** - No instructions were lost (unless flagged for deletion)
+6. **Self-contained files** - Each linked file stands alone
+
+---
diff --git a/skills/angular-component/SKILL.md b/skills/angular-component/SKILL.md
new file mode 100644
index 0000000..7c67419
--- /dev/null
+++ b/skills/angular-component/SKILL.md
@@ -0,0 +1,298 @@
+---
+name: angular-component
+description: >-
+ Create modern Angular standalone components following v20+ best practices. Use
+ for building UI components with signal-based inputs/outputs, OnPush change
+ detection, host bindings, content projection, and lifecycle hooks. Triggers on
+ component creation, refactoring class-based inputs to signals, adding host
+ bindings, or implementing accessible interactive components.
+metadata:
+ category: development
+ source:
+ repository: 'https://github.com/analogjs/angular-skills'
+ path: skills/angular-component
+---
+
+# Angular Component
+
+Create standalone components for Angular v20+. Components are standalone by default—do NOT set `standalone: true`.
+
+## Component Structure
+
+```typescript
+import { Component, ChangeDetectionStrategy, input, output, computed } from '@angular/core';
+
+@Component({
+ selector: 'app-user-card',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ host: {
+ 'class': 'user-card',
+ '[class.active]': 'isActive()',
+ '(click)': 'handleClick()',
+ },
+ template: `
+
+
{{ name() }}
+ @if (showEmail()) {
+ {{ email() }}
+ }
+ `,
+ styles: `
+ :host { display: block; }
+ :host.active { border: 2px solid blue; }
+ `,
+})
+export class UserCard {
+ // Required input
+ name = input.required();
+
+ // Optional input with default
+ email = input('');
+ showEmail = input(false);
+
+ // Input with transform
+ isActive = input(false, { transform: booleanAttribute });
+
+ // Computed from inputs
+ avatarUrl = computed(() => `https://api.example.com/avatar/${this.name()}`);
+
+ // Output
+ selected = output();
+
+ handleClick() {
+ this.selected.emit(this.name());
+ }
+}
+```
+
+## Signal Inputs
+
+```typescript
+// Required - must be provided by parent
+name = input.required();
+
+// Optional with default value
+count = input(0);
+
+// Optional without default (undefined allowed)
+label = input();
+
+// With alias for template binding
+size = input('medium', { alias: 'buttonSize' });
+
+// With transform function
+disabled = input(false, { transform: booleanAttribute });
+value = input(0, { transform: numberAttribute });
+```
+
+## Signal Outputs
+
+```typescript
+import { output, outputFromObservable } from '@angular/core';
+
+// Basic output
+clicked = output();
+selected = output- ();
+
+// With alias
+valueChange = output({ alias: 'change' });
+
+// From Observable (for RxJS interop)
+scroll$ = new Subject();
+scrolled = outputFromObservable(this.scroll$);
+
+// Emit values
+this.clicked.emit();
+this.selected.emit(item);
+```
+
+## Host Bindings
+
+Use the `host` object in `@Component`—do NOT use `@HostBinding` or `@HostListener` decorators.
+
+```typescript
+@Component({
+ selector: 'app-button',
+ host: {
+ // Static attributes
+ 'role': 'button',
+
+ // Dynamic class bindings
+ '[class.primary]': 'variant() === "primary"',
+ '[class.disabled]': 'disabled()',
+
+ // Dynamic style bindings
+ '[style.--btn-color]': 'color()',
+
+ // Attribute bindings
+ '[attr.aria-disabled]': 'disabled()',
+ '[attr.tabindex]': 'disabled() ? -1 : 0',
+
+ // Event listeners
+ '(click)': 'onClick($event)',
+ '(keydown.enter)': 'onClick($event)',
+ '(keydown.space)': 'onClick($event)',
+ },
+ template: ``,
+})
+export class Button {
+ variant = input<'primary' | 'secondary'>('primary');
+ disabled = input(false, { transform: booleanAttribute });
+ color = input('#007bff');
+
+ clicked = output();
+
+ onClick(event: Event) {
+ if (!this.disabled()) {
+ this.clicked.emit();
+ }
+ }
+}
+```
+
+## Content Projection
+
+```typescript
+@Component({
+ selector: 'app-card',
+ template: `
+
+
+
+
+
+ `,
+})
+export class Card {}
+
+// Usage:
+//
+//
Title
+// Main content
+//
+//
+```
+
+## Lifecycle Hooks
+
+```typescript
+import { OnDestroy, OnInit, afterNextRender, afterRender } from '@angular/core';
+
+export class My implements OnInit, OnDestroy {
+ constructor() {
+ // For DOM manipulation after render (SSR-safe)
+ afterNextRender(() => {
+ // Runs once after first render
+ });
+
+ afterRender(() => {
+ // Runs after every render
+ });
+ }
+
+ ngOnInit() { /* Component initialized */ }
+ ngOnDestroy() { /* Cleanup */ }
+}
+```
+
+## Accessibility Requirements
+
+Components MUST:
+- Pass AXE accessibility checks
+- Meet WCAG AA standards
+- Include proper ARIA attributes for interactive elements
+- Support keyboard navigation
+- Maintain visible focus indicators
+
+```typescript
+@Component({
+ selector: 'app-toggle',
+ host: {
+ 'role': 'switch',
+ '[attr.aria-checked]': 'checked()',
+ '[attr.aria-label]': 'label()',
+ 'tabindex': '0',
+ '(click)': 'toggle()',
+ '(keydown.enter)': 'toggle()',
+ '(keydown.space)': 'toggle(); $event.preventDefault()',
+ },
+ template: ``,
+})
+export class Toggle {
+ label = input.required();
+ checked = input(false, { transform: booleanAttribute });
+ checkedChange = output();
+
+ toggle() {
+ this.checkedChange.emit(!this.checked());
+ }
+}
+```
+
+## Template Syntax
+
+Use native control flow—do NOT use `*ngIf`, `*ngFor`, `*ngSwitch`.
+
+```html
+
+@if (isLoading()) {
+
+} @else if (error()) {
+
+} @else {
+
+}
+
+
+@for (item of items(); track item.id) {
+
+} @empty {
+ No items found
+}
+
+
+@switch (status()) {
+ @case ('pending') { Pending }
+ @case ('active') { Active }
+ @default { Unknown }
+}
+```
+
+## Class and Style Bindings
+
+Do NOT use `ngClass` or `ngStyle`. Use direct bindings:
+
+```html
+
+Single class
+Class string
+
+
+Styled text
+With unit
+```
+
+## Images
+
+Use `NgOptimizedImage` for static images:
+
+```typescript
+import { NgOptimizedImage } from '@angular/common';
+
+@Component({
+ imports: [NgOptimizedImage],
+ template: `
+
+
+ `,
+})
+export class Hero {
+ imageUrl = input.required();
+}
+```
+
+For detailed patterns, see [references/component-patterns.md](references/component-patterns.md).
diff --git a/skills/angular-component/references/component-patterns.md b/skills/angular-component/references/component-patterns.md
new file mode 100644
index 0000000..68e4e33
--- /dev/null
+++ b/skills/angular-component/references/component-patterns.md
@@ -0,0 +1,358 @@
+# Angular Component Patterns
+
+## Table of Contents
+- [Model Inputs (Two-Way Binding)](#model-inputs-two-way-binding)
+- [View Queries](#view-queries)
+- [Content Queries](#content-queries)
+- [Dependency Injection in Components](#dependency-injection-in-components)
+- [Component Communication Patterns](#component-communication-patterns)
+- [Dynamic Components](#dynamic-components)
+
+## Model Inputs (Two-Way Binding)
+
+For two-way binding with `[(value)]` syntax:
+
+```typescript
+import { Component, model } from '@angular/core';
+
+@Component({
+ selector: 'app-slider',
+ host: {
+ '(input)': 'onInput($event)',
+ },
+ template: `
+
+ {{ value() }}
+ `,
+})
+export class Slider {
+ // Model creates both input and output
+ value = model(0);
+ min = input(0);
+ max = input(100);
+
+ onInput(event: Event) {
+ const target = event.target as HTMLInputElement;
+ this.value.set(Number(target.value));
+ }
+}
+
+// Usage:
+```
+
+Required model:
+
+```typescript
+value = model.required();
+```
+
+## View Queries
+
+Query elements and components in the template:
+
+```typescript
+import { Component, viewChild, viewChildren, ElementRef } from '@angular/core';
+
+@Component({
+ selector: 'app-gallery',
+ template: `
+
+ @for (image of images(); track image.id) {
+
+ }
+
+ `,
+})
+export class Gallery {
+ images = input.required();
+
+ // Query single element
+ container = viewChild.required>('container');
+
+ // Query single component (optional)
+ firstCard = viewChild(ImageCard);
+
+ // Query all matching components
+ allCards = viewChildren(ImageCard);
+}
+```
+
+## Content Queries
+
+Query projected content:
+
+```typescript
+import { Component, contentChild, contentChildren, effect, signal } from '@angular/core';
+
+@Component({
+ selector: 'app-tabs',
+ template: `
+
+
+
+
+ `,
+})
+export class Tabs {
+ // Query all projected Tab children
+ tabs = contentChildren(Tab);
+
+ // Query single projected element
+ header = contentChild('tabHeader');
+
+ activeTab = signal(undefined);
+
+ constructor() {
+ // Set first tab as active when tabs are available
+ effect(() => {
+ const firstTab = this.tabs()[0];
+ if (firstTab && !this.activeTab()) {
+ this.activeTab.set(firstTab);
+ }
+ });
+ }
+
+ selectTab(tab: Tab) {
+ this.activeTab.set(tab);
+ }
+}
+
+@Component({
+ selector: 'app-tab',
+ template: ``,
+ host: {
+ '[class.active]': 'isActive()',
+ '[style.display]': 'isActive() ? "block" : "none"',
+ },
+})
+export class Tab {
+ label = input.required();
+ isActive = input(false);
+}
+```
+
+## Dependency Injection in Components
+
+Use `inject()` function instead of constructor injection:
+
+```typescript
+import { Component, inject } from '@angular/core';
+import { Router } from '@angular/router';
+
+@Component({
+ selector: 'app-dashboard',
+ template: `...`,
+})
+export class Dashboard {
+ private router = inject(Router);
+ private userService = inject(User);
+ private config = inject(APP_CONFIG);
+
+ // Optional injection
+ private analytics = inject(Analytics, { optional: true });
+
+ // Self-only injection
+ private localService = inject(Local, { self: true });
+
+ navigateToProfile() {
+ this.router.navigate(['/profile']);
+ }
+}
+```
+
+## Component Communication Patterns
+
+### Parent to Child (Inputs)
+
+```typescript
+// Parent
+@Component({
+ template: ``,
+})
+export class Parent {
+ parentData = signal({ name: 'Test' });
+ config = { theme: 'dark' };
+}
+
+// Child
+@Component({ selector: 'app-child' })
+export class Child {
+ data = input.required();
+ config = input();
+}
+```
+
+### Child to Parent (Outputs)
+
+```typescript
+// Child
+@Component({
+ selector: 'app-child',
+ template: ``,
+})
+export class Child {
+ saved = output();
+
+ save() {
+ this.saved.emit({ id: 1, name: 'Item' });
+ }
+}
+
+// Parent
+@Component({
+ template: ``,
+})
+export class Parent {
+ onSaved(data: Data) {
+ console.log('Saved:', data);
+ }
+}
+```
+
+### Shared Service Pattern
+
+```typescript
+// Shared state service
+@Injectable({ providedIn: 'root' })
+export class Cart {
+ private items = signal([]);
+
+ readonly items$ = this.items.asReadonly();
+ readonly total = computed(() =>
+ this.items().reduce((sum, item) => sum + item.price, 0)
+ );
+
+ addItem(item: CartItem) {
+ this.items.update(items => [...items, item]);
+ }
+
+ removeItem(id: string) {
+ this.items.update(items => items.filter(i => i.id !== id));
+ }
+}
+
+// Component A
+@Component({ template: `` })
+export class Product {
+ private cart = inject(Cart);
+ product = input.required();
+
+ add() {
+ this.cart.addItem({ ...this.product(), quantity: 1 });
+ }
+}
+
+// Component B
+@Component({ template: `Total: {{ cart.total() }}` })
+export class CartSummary {
+ cart = inject(Cart);
+}
+```
+
+## Dynamic Components
+
+Using `@defer` for lazy loading:
+
+```typescript
+@Component({
+ template: `
+ @defer (on viewport) {
+
+ } @placeholder {
+ Loading chart...
+ } @loading (minimum 500ms) {
+
+ } @error {
+ Failed to load chart
+ }
+ `,
+})
+export class Dashboard {
+ chartData = input.required();
+}
+```
+
+Defer triggers:
+- `on viewport` - When element enters viewport
+- `on idle` - When browser is idle
+- `on interaction` - On user interaction (click, focus)
+- `on hover` - On mouse hover
+- `on immediate` - Immediately after non-deferred content
+- `on timer(500ms)` - After specified delay
+- `when condition` - When expression becomes true
+
+```typescript
+@Component({
+ template: `
+ @defer (on interaction; prefetch on idle) {
+
+ } @placeholder {
+
+ }
+ `,
+})
+export class Post {
+ postId = input.required();
+}
+```
+
+## Attribute Directives on Components
+
+```typescript
+@Directive({
+ selector: '[appHighlight]',
+ host: {
+ '[style.backgroundColor]': 'color()',
+ },
+})
+export class Highlight {
+ color = input('yellow', { alias: 'appHighlight' });
+}
+
+// Usage on component
+@Component({
+ imports: [Highlight],
+ template: ``,
+})
+export class Page {}
+```
+
+## Error Boundaries
+
+```typescript
+@Component({
+ selector: 'app-error-boundary',
+ template: `
+ @if (hasError()) {
+
+
Something went wrong
+
+
+ } @else {
+
+ }
+ `,
+})
+export class ErrorBoundary {
+ hasError = signal(false);
+ private errorHandler = inject(ErrorHandler);
+
+ retry() {
+ this.hasError.set(false);
+ }
+}
+```
diff --git a/skills/angular-di/SKILL.md b/skills/angular-di/SKILL.md
new file mode 100644
index 0000000..7d73407
--- /dev/null
+++ b/skills/angular-di/SKILL.md
@@ -0,0 +1,397 @@
+---
+name: angular-di
+description: >-
+ Implement dependency injection in Angular v20+ using inject(), injection
+ tokens, and provider configuration. Use for service architecture, providing
+ dependencies at different levels, creating injectable tokens, and managing
+ singleton vs scoped services. Triggers on service creation, configuring
+ providers, using injection tokens, or understanding DI hierarchy.
+metadata:
+ category: development
+ source:
+ repository: 'https://github.com/analogjs/angular-skills'
+ path: skills/angular-di
+---
+
+# Angular Dependency Injection
+
+Configure and use dependency injection in Angular v20+ with `inject()` and providers.
+
+## Basic Injection
+
+### Using inject()
+
+Prefer `inject()` over constructor injection:
+
+```typescript
+import { Component, inject } from '@angular/core';
+import { HttpClient } from '@angular/common/http';
+import { User } from './user.service';
+
+@Component({
+ selector: 'app-user-list',
+ template: `...`,
+})
+export class UserList {
+ // Inject dependencies
+ private http = inject(HttpClient);
+ private userService = inject(User);
+
+ // Can use immediately
+ users = this.userService.getUsers();
+}
+```
+
+### Injectable Services
+
+```typescript
+import { Injectable, inject, signal } from '@angular/core';
+import { HttpClient } from '@angular/common/http';
+
+@Injectable({
+ providedIn: 'root', // Singleton at root level
+})
+export class User {
+ private http = inject(HttpClient);
+
+ private users = signal([]);
+ readonly users$ = this.users.asReadonly();
+
+ async loadUsers() {
+ const users = await firstValueFrom(
+ this.http.get('/api/users')
+ );
+ this.users.set(users);
+ }
+}
+```
+
+## Provider Scopes
+
+### Root Level (Singleton)
+
+```typescript
+// Recommended: providedIn
+@Injectable({
+ providedIn: 'root',
+})
+export class Auth {}
+
+// Alternative: in app.config.ts
+export const appConfig: ApplicationConfig = {
+ providers: [
+ Auth,
+ ],
+};
+```
+
+### Component Level (Instance per Component)
+
+```typescript
+@Component({
+ selector: 'app-editor',
+ providers: [EditorState], // New instance for each component
+ template: `...`,
+})
+export class Editor {
+ private editorState = inject(EditorState);
+}
+```
+
+### Route Level
+
+```typescript
+export const routes: Routes = [
+ {
+ path: 'admin',
+ providers: [Admin], // Shared within this route tree
+ children: [
+ { path: '', component: AdminDashboard },
+ { path: 'users', component: AdminUsers },
+ ],
+ },
+];
+```
+
+## Injection Tokens
+
+### Creating Tokens
+
+```typescript
+import { InjectionToken } from '@angular/core';
+
+// Simple value token
+export const API_URL = new InjectionToken('API_URL');
+
+// Object token
+export interface AppConfig {
+ apiUrl: string;
+ features: {
+ darkMode: boolean;
+ analytics: boolean;
+ };
+}
+
+export const APP_CONFIG = new InjectionToken('APP_CONFIG');
+
+// Token with factory (self-providing)
+export const WINDOW = new InjectionToken('Window', {
+ providedIn: 'root',
+ factory: () => window,
+});
+
+export const LOCAL_STORAGE = new InjectionToken('LocalStorage', {
+ providedIn: 'root',
+ factory: () => localStorage,
+});
+```
+
+### Providing Token Values
+
+```typescript
+// app.config.ts
+export const appConfig: ApplicationConfig = {
+ providers: [
+ { provide: API_URL, useValue: 'https://api.example.com' },
+ {
+ provide: APP_CONFIG,
+ useValue: {
+ apiUrl: 'https://api.example.com',
+ features: { darkMode: true, analytics: true },
+ },
+ },
+ ],
+};
+```
+
+### Injecting Tokens
+
+```typescript
+@Injectable({ providedIn: 'root' })
+export class Api {
+ private apiUrl = inject(API_URL);
+ private config = inject(APP_CONFIG);
+ private window = inject(WINDOW);
+
+ getBaseUrl(): string {
+ return this.apiUrl;
+ }
+}
+```
+
+## Provider Types
+
+### useClass
+
+```typescript
+// Provide implementation
+{ provide: Logger, useClass: ConsoleLogger }
+
+// Conditional implementation
+{
+ provide: Logger,
+ useClass: environment.production
+ ? ProductionLogger
+ : ConsoleLogger,
+}
+```
+
+### useValue
+
+```typescript
+// Static values
+{ provide: API_URL, useValue: 'https://api.example.com' }
+
+// Configuration objects
+{ provide: APP_CONFIG, useValue: { theme: 'dark', language: 'en' } }
+```
+
+### useFactory
+
+```typescript
+// Factory with dependencies
+{
+ provide: User,
+ useFactory: (http: HttpClient, config: AppConfig) => {
+ return new User(http, config.apiUrl);
+ },
+ deps: [HttpClient, APP_CONFIG],
+}
+
+// Async factory (not recommended - use provideAppInitializer)
+{
+ provide: CONFIG,
+ useFactory: () => fetch('/config.json').then(r => r.json()),
+}
+```
+
+### useExisting
+
+```typescript
+// Alias to existing provider
+{ provide: AbstractLogger, useExisting: ConsoleLogger }
+
+// Multiple tokens pointing to same instance
+providers: [
+ ConsoleLogger,
+ { provide: Logger, useExisting: ConsoleLogger },
+ { provide: ErrorLogger, useExisting: ConsoleLogger },
+]
+```
+
+## Injection Options
+
+### Optional Injection
+
+```typescript
+@Component({...})
+export class My {
+ // Returns null if not provided
+ private analytics = inject(Analytics, { optional: true });
+
+ trackEvent(name: string) {
+ this.analytics?.track(name);
+ }
+}
+```
+
+### Self, SkipSelf, Host
+
+```typescript
+@Component({
+ providers: [Local],
+})
+export class Parent {
+ // Only look in this component's injector
+ private local = inject(Local, { self: true });
+}
+
+@Component({...})
+export class Child {
+ // Skip this component, look in parent
+ private parentService = inject(ParentSvc, { skipSelf: true });
+
+ // Only look up to host component
+ private hostService = inject(Host, { host: true });
+}
+```
+
+## Multi Providers
+
+Collect multiple values for same token:
+
+```typescript
+// Token for multiple validators
+export const VALIDATORS = new InjectionToken('Validators');
+
+// Provide multiple values
+providers: [
+ { provide: VALIDATORS, useClass: RequiredValidator, multi: true },
+ { provide: VALIDATORS, useClass: EmailValidator, multi: true },
+ { provide: VALIDATORS, useClass: MinLengthValidator, multi: true },
+]
+
+// Inject as array
+@Injectable()
+export class Validation {
+ private validators = inject(VALIDATORS); // Validator[]
+
+ validate(value: string): ValidationError[] {
+ return this.validators
+ .map(v => v.validate(value))
+ .filter(Boolean);
+ }
+}
+```
+
+### HTTP Interceptors (Multi Provider)
+
+```typescript
+// Interceptors use multi providers internally
+export const appConfig: ApplicationConfig = {
+ providers: [
+ provideHttpClient(
+ withInterceptors([
+ authInterceptor,
+ loggingInterceptor,
+ errorInterceptor,
+ ])
+ ),
+ ],
+};
+```
+
+## App Initializers
+
+Run async code before app starts using `provideAppInitializer`:
+
+```typescript
+import { provideAppInitializer, inject } from '@angular/core';
+
+export const appConfig: ApplicationConfig = {
+ providers: [
+ Config,
+ provideAppInitializer(() => {
+ const configService = inject(Config);
+ return configService.loadConfig();
+ }),
+ ],
+};
+```
+
+### Multiple Initializers
+
+```typescript
+providers: [
+ provideAppInitializer(() => {
+ const config = inject(Config);
+ return config.load();
+ }),
+ provideAppInitializer(() => {
+ const auth = inject(Auth);
+ return auth.checkSession();
+ }),
+]
+```
+
+## Environment Injector
+
+Create injectors programmatically:
+
+```typescript
+import { createEnvironmentInjector, EnvironmentInjector, inject } from '@angular/core';
+
+@Injectable({ providedIn: 'root' })
+export class Plugin {
+ private parentInjector = inject(EnvironmentInjector);
+
+ loadPlugin(providers: Provider[]): EnvironmentInjector {
+ return createEnvironmentInjector(providers, this.parentInjector);
+ }
+}
+```
+
+## runInInjectionContext
+
+Run code with injection context:
+
+```typescript
+import { runInInjectionContext, EnvironmentInjector, inject } from '@angular/core';
+
+@Injectable({ providedIn: 'root' })
+export class Utility {
+ private injector = inject(EnvironmentInjector);
+
+ executeWithDI(fn: () => T): T {
+ return runInInjectionContext(this.injector, fn);
+ }
+}
+
+// Usage
+utilityService.executeWithDI(() => {
+ const http = inject(HttpClient);
+ // Use http...
+});
+```
+
+For advanced patterns, see [references/di-patterns.md](references/di-patterns.md).
diff --git a/skills/angular-di/references/di-patterns.md b/skills/angular-di/references/di-patterns.md
new file mode 100644
index 0000000..03a9397
--- /dev/null
+++ b/skills/angular-di/references/di-patterns.md
@@ -0,0 +1,519 @@
+# Angular Dependency Injection Patterns
+
+## Table of Contents
+- [Service Patterns](#service-patterns)
+- [Abstract Classes as Tokens](#abstract-classes-as-tokens)
+- [Hierarchical Injection](#hierarchical-injection)
+- [Dynamic Providers](#dynamic-providers)
+- [Testing with DI](#testing-with-di)
+- [DestroyRef and Cleanup](#destroyref-and-cleanup)
+
+## Service Patterns
+
+### Facade Service
+
+Combine multiple services into a single API:
+
+```typescript
+@Injectable({ providedIn: 'root' })
+export class ShopFacade {
+ private productService = inject(Product);
+ private cartService = inject(Cart);
+ private orderService = inject(Order);
+
+ // Expose combined state
+ readonly products = this.productService.products;
+ readonly cart = this.cartService.items;
+ readonly cartTotal = this.cartService.total;
+
+ // Unified actions
+ addToCart(productId: string, quantity: number) {
+ const product = this.productService.getById(productId);
+ if (product) {
+ this.cartService.add(product, quantity);
+ }
+ }
+
+ async checkout() {
+ const items = this.cartService.items();
+ const order = await this.orderService.create(items);
+ this.cartService.clear();
+ return order;
+ }
+}
+```
+
+### State Service Pattern
+
+```typescript
+interface UserState {
+ user: User | null;
+ loading: boolean;
+ error: string | null;
+}
+
+@Injectable({ providedIn: 'root' })
+export class UserState {
+ private state = signal({
+ user: null,
+ loading: false,
+ error: null,
+ });
+
+ // Selectors
+ readonly user = computed(() => this.state().user);
+ readonly loading = computed(() => this.state().loading);
+ readonly error = computed(() => this.state().error);
+ readonly isAuthenticated = computed(() => this.state().user !== null);
+
+ // Actions
+ setUser(user: User) {
+ this.state.update(s => ({ ...s, user, loading: false, error: null }));
+ }
+
+ setLoading() {
+ this.state.update(s => ({ ...s, loading: true, error: null }));
+ }
+
+ setError(error: string) {
+ this.state.update(s => ({ ...s, loading: false, error }));
+ }
+
+ clear() {
+ this.state.set({ user: null, loading: false, error: null });
+ }
+}
+```
+
+### Repository Pattern
+
+```typescript
+// Generic repository interface
+export abstract class Repository {
+ abstract getAll(): Promise;
+ abstract getById(id: string): Promise;
+ abstract create(item: Omit): Promise;
+ abstract update(id: string, item: Partial): Promise;
+ abstract delete(id: string): Promise;
+}
+
+// HTTP implementation
+@Injectable()
+export class HttpUserRepo extends Repository {
+ private http = inject(HttpClient);
+ private apiUrl = inject(API_URL);
+
+ async getAll(): Promise {
+ return firstValueFrom(this.http.get(`${this.apiUrl}/users`));
+ }
+
+ async getById(id: string): Promise {
+ return firstValueFrom(
+ this.http.get(`${this.apiUrl}/users/${id}`).pipe(
+ catchError(() => of(null))
+ )
+ );
+ }
+
+ async create(user: Omit): Promise {
+ return firstValueFrom(this.http.post(`${this.apiUrl}/users`, user));
+ }
+
+ async update(id: string, user: Partial): Promise {
+ return firstValueFrom(this.http.patch(`${this.apiUrl}/users/${id}`, user));
+ }
+
+ async delete(id: string): Promise {
+ await firstValueFrom(this.http.delete(`${this.apiUrl}/users/${id}`));
+ }
+}
+
+// Provide implementation
+{ provide: Repository, useClass: HttpUserRepo }
+```
+
+## Abstract Classes as Tokens
+
+Use abstract classes for better type safety:
+
+```typescript
+// Abstract service definition
+export abstract class Logger {
+ abstract log(message: string): void;
+ abstract error(message: string, error?: Error): void;
+ abstract warn(message: string): void;
+}
+
+// Console implementation
+@Injectable()
+export class ConsoleLog extends Logger {
+ log(message: string) {
+ console.log(`[LOG] ${message}`);
+ }
+
+ error(message: string, error?: Error) {
+ console.error(`[ERROR] ${message}`, error);
+ }
+
+ warn(message: string) {
+ console.warn(`[WARN] ${message}`);
+ }
+}
+
+// Remote implementation
+@Injectable()
+export class RemoteLog extends Logger {
+ private http = inject(HttpClient);
+
+ log(message: string) {
+ this.send('log', message);
+ }
+
+ error(message: string, error?: Error) {
+ this.send('error', message, error);
+ }
+
+ warn(message: string) {
+ this.send('warn', message);
+ }
+
+ private send(level: string, message: string, error?: Error) {
+ this.http.post('/api/logs', { level, message, error: error?.message }).subscribe();
+ }
+}
+
+// Provide based on environment
+{
+ provide: Logger,
+ useClass: environment.production ? RemoteLog : ConsoleLog,
+}
+
+// Inject using abstract class
+@Injectable({ providedIn: 'root' })
+export class User {
+ private logger = inject(Logger);
+
+ createUser(user: UserData) {
+ this.logger.log(`Creating user: ${user.email}`);
+ // ...
+ }
+}
+```
+
+## Hierarchical Injection
+
+### Component Tree Injection
+
+```typescript
+// Parent provides service
+@Component({
+ selector: 'app-form-container',
+ providers: [FormState],
+ template: `
+
+
+
+ `,
+})
+export class FormContainer {
+ private formState = inject(FormState);
+}
+
+// Children share same instance
+@Component({
+ selector: 'app-form-body',
+ template: `...`,
+})
+export class FormBody {
+ // Gets same instance as parent
+ private formState = inject(FormState);
+}
+
+// Grandchildren also share
+@Component({
+ selector: 'app-form-field',
+ template: `...`,
+})
+export class FormField {
+ // Gets same instance from ancestor
+ private formState = inject(FormState);
+}
+```
+
+### viewProviders vs providers
+
+```typescript
+@Component({
+ selector: 'app-tabs',
+ // providers: Available to component AND content children
+ providers: [TabsSvc],
+
+ // viewProviders: Available to component AND view children only
+ // NOT available to content children ()
+ viewProviders: [InternalTabs],
+
+ template: `
+
+
+
+ `,
+})
+export class Tabs {}
+```
+
+## Dynamic Providers
+
+### Feature Flags
+
+```typescript
+export const FEATURE_FLAGS = new InjectionToken('FeatureFlags');
+
+interface FeatureFlags {
+ newDashboard: boolean;
+ betaFeatures: boolean;
+ experimentalApi: boolean;
+}
+
+// Load from API
+{
+ provide: FEATURE_FLAGS,
+ useFactory: async () => {
+ const response = await fetch('/api/features');
+ return response.json();
+ },
+}
+
+// Use in components
+@Component({...})
+export class Dashboard {
+ private features = inject(FEATURE_FLAGS);
+
+ showNewDashboard = this.features.newDashboard;
+}
+```
+
+### Platform-Specific Services
+
+```typescript
+export abstract class Storage {
+ abstract get(key: string): string | null;
+ abstract set(key: string, value: string): void;
+ abstract remove(key: string): void;
+}
+
+@Injectable()
+export class BrowserStorage extends Storage {
+ get(key: string) { return localStorage.getItem(key); }
+ set(key: string, value: string) { localStorage.setItem(key, value); }
+ remove(key: string) { localStorage.removeItem(key); }
+}
+
+@Injectable()
+export class ServerStorage extends Storage {
+ private store = new Map();
+
+ get(key: string) { return this.store.get(key) ?? null; }
+ set(key: string, value: string) { this.store.set(key, value); }
+ remove(key: string) { this.store.delete(key); }
+}
+
+// Provide based on platform
+import { PLATFORM_ID, isPlatformBrowser } from '@angular/common';
+
+{
+ provide: Storage,
+ useFactory: (platformId: object) => {
+ return isPlatformBrowser(platformId)
+ ? new BrowserStorage()
+ : new ServerStorage();
+ },
+ deps: [PLATFORM_ID],
+}
+```
+
+## Testing with DI
+
+### Mocking Services
+
+```typescript
+describe('UserCmpt', () => {
+ let userServiceSpy: jasmine.SpyObj;
+
+ beforeEach(async () => {
+ userServiceSpy = jasmine.createSpyObj('User', ['getUser', 'updateUser']);
+ userServiceSpy.getUser.and.returnValue(of({ id: '1', name: 'Test' }));
+
+ await TestBed.configureTestingModule({
+ imports: [UserCmpt],
+ providers: [
+ { provide: User, useValue: userServiceSpy },
+ ],
+ }).compileComponents();
+ });
+
+ it('should load user', () => {
+ const fixture = TestBed.createComponent(UserCmpt);
+ fixture.detectChanges();
+
+ expect(userServiceSpy.getUser).toHaveBeenCalled();
+ });
+});
+```
+
+### Overriding Providers
+
+```typescript
+describe('with different config', () => {
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [App],
+ })
+ .overrideProvider(APP_CONFIG, {
+ useValue: { apiUrl: 'http://test-api.com' },
+ })
+ .compileComponents();
+ });
+});
+```
+
+### Testing Injection Tokens
+
+```typescript
+describe('API_URL token', () => {
+ it('should provide correct URL', () => {
+ TestBed.configureTestingModule({
+ providers: [
+ { provide: API_URL, useValue: 'https://api.test.com' },
+ ],
+ });
+
+ const apiUrl = TestBed.inject(API_URL);
+ expect(apiUrl).toBe('https://api.test.com');
+ });
+});
+```
+
+## DestroyRef and Cleanup
+
+### Automatic Cleanup
+
+```typescript
+import { DestroyRef, inject } from '@angular/core';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
+
+@Component({...})
+export class Data {
+ private destroyRef = inject(DestroyRef);
+ private dataService = inject(DataSvc);
+
+ constructor() {
+ // Auto-unsubscribe when component destroys
+ this.dataService.data$
+ .pipe(takeUntilDestroyed())
+ .subscribe(data => {
+ console.log(data);
+ });
+ }
+
+ // Or use DestroyRef directly
+ ngOnInit() {
+ const subscription = this.dataService.updates$.subscribe();
+
+ this.destroyRef.onDestroy(() => {
+ subscription.unsubscribe();
+ console.log('Cleaned up!');
+ });
+ }
+}
+```
+
+### In Services
+
+```typescript
+@Injectable()
+export class WebSocket {
+ private destroyRef = inject(DestroyRef);
+ private socket: WebSocket | null = null;
+
+ constructor() {
+ this.destroyRef.onDestroy(() => {
+ this.socket?.close();
+ });
+ }
+
+ connect(url: string) {
+ this.socket = new WebSocket(url);
+ }
+}
+```
+
+### takeUntilDestroyed Outside Constructor
+
+```typescript
+@Component({...})
+export class My {
+ private destroyRef = inject(DestroyRef);
+
+ loadData() {
+ // Pass destroyRef when using outside constructor
+ this.http.get('/api/data')
+ .pipe(takeUntilDestroyed(this.destroyRef))
+ .subscribe();
+ }
+}
+```
+
+## Injection Context Utilities
+
+### assertInInjectionContext
+
+```typescript
+import { assertInInjectionContext, inject } from '@angular/core';
+
+export function injectLogger(): Logger {
+ assertInInjectionContext(injectLogger);
+ return inject(Logger);
+}
+
+// Usage - must be called in injection context
+@Component({...})
+export class My2 {
+ private logger = injectLogger(); // OK
+
+ someMethod() {
+ // injectLogger(); // ERROR - not in injection context
+ }
+}
+```
+
+### Custom inject Functions
+
+```typescript
+// Create reusable injection utilities
+export function injectRouteParam(param: string): Signal {
+ assertInInjectionContext(injectRouteParam);
+
+ const route = inject(ActivatedRoute);
+ return toSignal(
+ route.paramMap.pipe(map(params => params.get(param))),
+ { initialValue: null }
+ );
+}
+
+export function injectQueryParam(param: string): Signal {
+ assertInInjectionContext(injectQueryParam);
+
+ const route = inject(ActivatedRoute);
+ return toSignal(
+ route.queryParamMap.pipe(map(params => params.get(param))),
+ { initialValue: null }
+ );
+}
+
+// Usage
+@Component({...})
+export class UserCmpt {
+ userId = injectRouteParam('id');
+ tab = injectQueryParam('tab');
+}
+```
diff --git a/skills/angular-directives/SKILL.md b/skills/angular-directives/SKILL.md
new file mode 100644
index 0000000..5e781d1
--- /dev/null
+++ b/skills/angular-directives/SKILL.md
@@ -0,0 +1,449 @@
+---
+name: angular-directives
+description: >-
+ Create custom directives in Angular v20+ for DOM manipulation and behavior
+ extension. Use for attribute directives that modify element
+ behavior/appearance, structural directives for portals/overlays, and host
+ directives for composition. Triggers on creating reusable DOM behaviors,
+ extending element functionality, or composing behaviors across components.
+ Note - use native @if/@for/@switch for control flow, not custom structural
+ directives.
+metadata:
+ category: development
+ source:
+ repository: 'https://github.com/analogjs/angular-skills'
+ path: skills/angular-directives
+---
+
+# Angular Directives
+
+Create custom directives for reusable DOM manipulation and behavior in Angular v20+.
+
+## Attribute Directives
+
+Modify the appearance or behavior of an element:
+
+```typescript
+import { Directive, input, effect, inject, ElementRef } from '@angular/core';
+
+@Directive({
+ selector: '[appHighlight]',
+})
+export class Highlight {
+ private el = inject(ElementRef);
+
+ // Input with alias matching selector
+ color = input('yellow', { alias: 'appHighlight' });
+
+ constructor() {
+ effect(() => {
+ this.el.nativeElement.style.backgroundColor = this.color();
+ });
+ }
+}
+
+// Usage: Highlighted text
+// Usage: Default yellow highlight
+```
+
+### Using host Property
+
+Prefer `host` over `@HostBinding`/`@HostListener`:
+
+```typescript
+@Directive({
+ selector: '[appTooltip]',
+ host: {
+ '(mouseenter)': 'show()',
+ '(mouseleave)': 'hide()',
+ '[attr.aria-describedby]': 'tooltipId',
+ },
+})
+export class Tooltip {
+ text = input.required({ alias: 'appTooltip' });
+ position = input<'top' | 'bottom' | 'left' | 'right'>('top');
+
+ tooltipId = `tooltip-${crypto.randomUUID()}`;
+ private tooltipEl: HTMLElement | null = null;
+ private el = inject(ElementRef);
+
+ show() {
+ this.tooltipEl = document.createElement('div');
+ this.tooltipEl.id = this.tooltipId;
+ this.tooltipEl.className = `tooltip tooltip-${this.position()}`;
+ this.tooltipEl.textContent = this.text();
+ this.tooltipEl.setAttribute('role', 'tooltip');
+ document.body.appendChild(this.tooltipEl);
+ this.positionTooltip();
+ }
+
+ hide() {
+ this.tooltipEl?.remove();
+ this.tooltipEl = null;
+ }
+
+ private positionTooltip() {
+ // Position logic based on this.position() and this.el
+ }
+}
+
+// Usage:
+```
+
+### Class and Style Manipulation
+
+```typescript
+@Directive({
+ selector: '[appButton]',
+ host: {
+ 'class': 'btn',
+ '[class.btn-primary]': 'variant() === "primary"',
+ '[class.btn-secondary]': 'variant() === "secondary"',
+ '[class.btn-sm]': 'size() === "small"',
+ '[class.btn-lg]': 'size() === "large"',
+ '[class.disabled]': 'disabled()',
+ '[attr.disabled]': 'disabled() || null',
+ },
+})
+export class Button {
+ variant = input<'primary' | 'secondary'>('primary');
+ size = input<'small' | 'medium' | 'large'>('medium');
+ disabled = input(false, { transform: booleanAttribute });
+}
+
+// Usage:
+```
+
+### Event Handling
+
+```typescript
+@Directive({
+ selector: '[appClickOutside]',
+ host: {
+ '(document:click)': 'onDocumentClick($event)',
+ },
+})
+export class ClickOutside {
+ private el = inject(ElementRef);
+
+ clickOutside = output();
+
+ onDocumentClick(event: MouseEvent) {
+ if (!this.el.nativeElement.contains(event.target as Node)) {
+ this.clickOutside.emit();
+ }
+ }
+}
+
+// Usage: ...
+```
+
+### Keyboard Shortcuts
+
+```typescript
+@Directive({
+ selector: '[appShortcut]',
+ host: {
+ '(document:keydown)': 'onKeydown($event)',
+ },
+})
+export class Shortcut {
+ key = input.required({ alias: 'appShortcut' });
+ ctrl = input(false, { transform: booleanAttribute });
+ shift = input(false, { transform: booleanAttribute });
+ alt = input(false, { transform: booleanAttribute });
+
+ triggered = output();
+
+ onKeydown(event: KeyboardEvent) {
+ const keyMatch = event.key.toLowerCase() === this.key().toLowerCase();
+ const ctrlMatch = this.ctrl() ? event.ctrlKey || event.metaKey : !event.ctrlKey && !event.metaKey;
+ const shiftMatch = this.shift() ? event.shiftKey : !event.shiftKey;
+ const altMatch = this.alt() ? event.altKey : !event.altKey;
+
+ if (keyMatch && ctrlMatch && shiftMatch && altMatch) {
+ event.preventDefault();
+ this.triggered.emit(event);
+ }
+ }
+}
+
+// Usage:
+```
+
+## Structural Directives
+
+Use structural directives for DOM manipulation beyond control flow (portals, overlays, dynamic insertion points). For conditionals and loops, use native `@if`, `@for`, `@switch`.
+
+### Portal Directive
+
+Render content in a different DOM location:
+
+```typescript
+import { Directive, inject, TemplateRef, ViewContainerRef, OnInit, OnDestroy, input } from '@angular/core';
+
+@Directive({
+ selector: '[appPortal]',
+})
+export class Portal implements OnInit, OnDestroy {
+ private templateRef = inject(TemplateRef);
+ private viewContainerRef = inject(ViewContainerRef);
+ private viewRef: EmbeddedViewRef | null = null;
+
+ // Target container selector or element
+ target = input('body', { alias: 'appPortal' });
+
+ ngOnInit() {
+ const container = this.getContainer();
+ if (container) {
+ this.viewRef = this.viewContainerRef.createEmbeddedView(this.templateRef);
+ this.viewRef.rootNodes.forEach(node => container.appendChild(node));
+ }
+ }
+
+ ngOnDestroy() {
+ this.viewRef?.destroy();
+ }
+
+ private getContainer(): HTMLElement | null {
+ const target = this.target();
+ if (typeof target === 'string') {
+ return document.querySelector(target);
+ }
+ return target;
+ }
+}
+
+// Usage: Render modal at body level
+//
+```
+
+### Lazy Render Directive
+
+Defer rendering until condition is met (one-time):
+
+```typescript
+@Directive({
+ selector: '[appLazyRender]',
+})
+export class LazyRender {
+ private templateRef = inject(TemplateRef);
+ private viewContainer = inject(ViewContainerRef);
+ private rendered = false;
+
+ condition = input.required({ alias: 'appLazyRender' });
+
+ constructor() {
+ effect(() => {
+ // Only render once when condition becomes true
+ if (this.condition() && !this.rendered) {
+ this.viewContainer.createEmbeddedView(this.templateRef);
+ this.rendered = true;
+ }
+ });
+ }
+}
+
+// Usage: Render heavy component only when tab is first activated
+//
+```
+
+### Template Outlet with Context
+
+```typescript
+interface TemplateContext {
+ $implicit: T;
+ item: T;
+ index: number;
+}
+
+@Directive({
+ selector: '[appTemplateOutlet]',
+})
+export class TemplateOutlet {
+ private viewContainer = inject(ViewContainerRef);
+ private currentView: EmbeddedViewRef> | null = null;
+
+ template = input.required>>({ alias: 'appTemplateOutlet' });
+ context = input.required({ alias: 'appTemplateOutletContext' });
+ index = input(0, { alias: 'appTemplateOutletIndex' });
+
+ constructor() {
+ effect(() => {
+ const template = this.template();
+ const context = this.context();
+ const index = this.index();
+
+ if (this.currentView) {
+ this.currentView.context.$implicit = context;
+ this.currentView.context.item = context;
+ this.currentView.context.index = index;
+ this.currentView.markForCheck();
+ } else {
+ this.currentView = this.viewContainer.createEmbeddedView(template, {
+ $implicit: context,
+ item: context,
+ index,
+ });
+ }
+ });
+ }
+}
+
+// Usage: Custom list with template
+//
+// {{ i }}: {{ item.name }}
+//
+//
+```
+
+## Host Directives
+
+Compose directives on components or other directives:
+
+```typescript
+// Reusable behavior directives
+@Directive({
+ selector: '[focusable]',
+ host: {
+ 'tabindex': '0',
+ '(focus)': 'onFocus()',
+ '(blur)': 'onBlur()',
+ '[class.focused]': 'isFocused()',
+ },
+})
+export class Focusable {
+ isFocused = signal(false);
+
+ onFocus() { this.isFocused.set(true); }
+ onBlur() { this.isFocused.set(false); }
+}
+
+@Directive({
+ selector: '[disableable]',
+ host: {
+ '[class.disabled]': 'disabled()',
+ '[attr.aria-disabled]': 'disabled()',
+ },
+})
+export class Disableable {
+ disabled = input(false, { transform: booleanAttribute });
+}
+
+// Component using host directives
+@Component({
+ selector: 'app-custom-button',
+ hostDirectives: [
+ Focusable,
+ {
+ directive: Disableable,
+ inputs: ['disabled'],
+ },
+ ],
+ host: {
+ 'role': 'button',
+ '(click)': 'onClick($event)',
+ '(keydown.enter)': 'onClick($event)',
+ '(keydown.space)': 'onClick($event)',
+ },
+ template: ``,
+})
+export class CustomButton {
+ private disableable = inject(Disableable);
+
+ clicked = output();
+
+ onClick(event: Event) {
+ if (!this.disableable.disabled()) {
+ this.clicked.emit();
+ }
+ }
+}
+
+// Usage: Click me
+```
+
+### Exposing Host Directive Outputs
+
+```typescript
+@Directive({
+ selector: '[hoverable]',
+ host: {
+ '(mouseenter)': 'onEnter()',
+ '(mouseleave)': 'onLeave()',
+ '[class.hovered]': 'isHovered()',
+ },
+})
+export class Hoverable {
+ isHovered = signal(false);
+
+ hoverChange = output();
+
+ onEnter() {
+ this.isHovered.set(true);
+ this.hoverChange.emit(true);
+ }
+
+ onLeave() {
+ this.isHovered.set(false);
+ this.hoverChange.emit(false);
+ }
+}
+
+@Component({
+ selector: 'app-card',
+ hostDirectives: [
+ {
+ directive: Hoverable,
+ outputs: ['hoverChange'],
+ },
+ ],
+ template: ``,
+})
+export class Card {}
+
+// Usage: ...
+```
+
+## Directive Composition API
+
+Combine multiple behaviors:
+
+```typescript
+// Base directives
+@Directive({ selector: '[withRipple]' })
+export class Ripple {
+ // Ripple effect implementation
+}
+
+@Directive({ selector: '[withElevation]' })
+export class Elevation {
+ elevation = input(2);
+}
+
+// Composed component
+@Component({
+ selector: 'app-material-button',
+ hostDirectives: [
+ Ripple,
+ {
+ directive: Elevation,
+ inputs: ['elevation'],
+ },
+ {
+ directive: Disableable,
+ inputs: ['disabled'],
+ },
+ ],
+ template: ``,
+})
+export class MaterialButton {}
+```
+
+For advanced patterns, see [references/directive-patterns.md](references/directive-patterns.md).
diff --git a/skills/angular-directives/references/directive-patterns.md b/skills/angular-directives/references/directive-patterns.md
new file mode 100644
index 0000000..06c02d3
--- /dev/null
+++ b/skills/angular-directives/references/directive-patterns.md
@@ -0,0 +1,570 @@
+# Angular Directive Patterns
+
+## Table of Contents
+- [DOM Manipulation](#dom-manipulation)
+- [Form Directives](#form-directives)
+- [Intersection Observer](#intersection-observer)
+- [Resize Observer](#resize-observer)
+- [Drag and Drop](#drag-and-drop)
+- [Permission Directive](#permission-directive)
+
+## DOM Manipulation
+
+### Auto-Focus Directive
+
+```typescript
+@Directive({
+ selector: '[appAutoFocus]',
+})
+export class AutoFocus {
+ private el = inject(ElementRef);
+
+ enabled = input(true, { alias: 'appAutoFocus', transform: booleanAttribute });
+ delay = input(0);
+
+ constructor() {
+ afterNextRender(() => {
+ if (this.enabled()) {
+ setTimeout(() => {
+ this.el.nativeElement.focus();
+ }, this.delay());
+ }
+ });
+ }
+}
+
+// Usage:
+// Usage:
+```
+
+### Text Selection Directive
+
+```typescript
+@Directive({
+ selector: '[appSelectAll]',
+ host: {
+ '(focus)': 'onFocus()',
+ '(click)': 'onClick($event)',
+ },
+})
+export class SelectAll {
+ private el = inject(ElementRef);
+
+ onFocus() {
+ // Delay to ensure value is set
+ setTimeout(() => this.el.nativeElement.select(), 0);
+ }
+
+ onClick(event: MouseEvent) {
+ // Select all on first click if not already focused
+ if (document.activeElement !== this.el.nativeElement) {
+ this.el.nativeElement.select();
+ }
+ }
+}
+
+// Usage:
+```
+
+### Copy to Clipboard
+
+```typescript
+@Directive({
+ selector: '[appCopyToClipboard]',
+ host: {
+ '(click)': 'copy()',
+ '[style.cursor]': '"pointer"',
+ },
+})
+export class CopyToClipboard {
+ text = input.required({ alias: 'appCopyToClipboard' });
+
+ copied = output();
+ error = output();
+
+ async copy() {
+ try {
+ await navigator.clipboard.writeText(this.text());
+ this.copied.emit();
+ } catch (err) {
+ this.error.emit(err as Error);
+ }
+ }
+}
+
+// Usage:
+//
+```
+
+## Form Directives
+
+### Trim Input
+
+```typescript
+@Directive({
+ selector: 'input[appTrim], textarea[appTrim]',
+ host: {
+ '(blur)': 'onBlur()',
+ },
+})
+export class Trim {
+ private el = inject(ElementRef);
+ private ngControl = inject(NgControl, { optional: true, self: true });
+
+ onBlur() {
+ const value = this.el.nativeElement.value;
+ const trimmed = value.trim();
+
+ if (value !== trimmed) {
+ this.el.nativeElement.value = trimmed;
+ this.ngControl?.control?.setValue(trimmed);
+ }
+ }
+}
+
+// Usage:
+```
+
+### Input Mask
+
+```typescript
+@Directive({
+ selector: '[appMask]',
+ host: {
+ '(input)': 'onInput($event)',
+ '(keydown)': 'onKeydown($event)',
+ },
+})
+export class Mask {
+ private el = inject(ElementRef);
+
+ // Mask pattern: 9 = digit, A = letter, * = any
+ mask = input.required({ alias: 'appMask' });
+
+ onInput(event: InputEvent) {
+ const input = this.el.nativeElement;
+ const value = input.value;
+ const masked = this.applyMask(value);
+
+ if (value !== masked) {
+ input.value = masked;
+ }
+ }
+
+ onKeydown(event: KeyboardEvent) {
+ // Allow navigation keys
+ if (['Backspace', 'Delete', 'ArrowLeft', 'ArrowRight', 'Tab'].includes(event.key)) {
+ return;
+ }
+
+ const input = this.el.nativeElement;
+ const position = input.selectionStart ?? 0;
+ const maskChar = this.mask()[position];
+
+ if (!maskChar) {
+ event.preventDefault();
+ return;
+ }
+
+ if (!this.isValidChar(event.key, maskChar)) {
+ event.preventDefault();
+ }
+ }
+
+ private applyMask(value: string): string {
+ const mask = this.mask();
+ let result = '';
+ let valueIndex = 0;
+
+ for (let i = 0; i < mask.length && valueIndex < value.length; i++) {
+ const maskChar = mask[i];
+ const inputChar = value[valueIndex];
+
+ if (maskChar === '9' || maskChar === 'A' || maskChar === '*') {
+ if (this.isValidChar(inputChar, maskChar)) {
+ result += inputChar;
+ valueIndex++;
+ } else {
+ valueIndex++;
+ i--;
+ }
+ } else {
+ result += maskChar;
+ if (inputChar === maskChar) {
+ valueIndex++;
+ }
+ }
+ }
+
+ return result;
+ }
+
+ private isValidChar(char: string, maskChar: string): boolean {
+ switch (maskChar) {
+ case '9': return /\d/.test(char);
+ case 'A': return /[a-zA-Z]/.test(char);
+ case '*': return /[a-zA-Z0-9]/.test(char);
+ default: return char === maskChar;
+ }
+ }
+}
+
+// Usage:
+```
+
+### Character Counter
+
+```typescript
+@Directive({
+ selector: '[appCharCount]',
+})
+export class CharCount {
+ private el = inject(ElementRef);
+
+ maxLength = input.required({ alias: 'appCharCount' });
+
+ currentLength = signal(0);
+ remaining = computed(() => this.maxLength() - this.currentLength());
+ isOverLimit = computed(() => this.remaining() < 0);
+
+ constructor() {
+ effect(() => {
+ this.currentLength.set(this.el.nativeElement.value.length);
+ });
+
+ // Listen for input changes
+ afterNextRender(() => {
+ this.el.nativeElement.addEventListener('input', () => {
+ this.currentLength.set(this.el.nativeElement.value.length);
+ });
+ });
+ }
+}
+
+// Usage with template:
+//
+// {{ counter.remaining() }} characters remaining
+```
+
+## Intersection Observer
+
+### Lazy Load Directive
+
+```typescript
+@Directive({
+ selector: '[appLazyLoad]',
+})
+export class LazyLoad implements OnDestroy {
+ private el = inject(ElementRef);
+ private observer: IntersectionObserver | null = null;
+
+ src = input.required({ alias: 'appLazyLoad' });
+ placeholder = input('/assets/placeholder.png');
+
+ loaded = output();
+
+ constructor() {
+ afterNextRender(() => {
+ this.setupObserver();
+ });
+ }
+
+ private setupObserver() {
+ this.observer = new IntersectionObserver(
+ (entries) => {
+ entries.forEach((entry) => {
+ if (entry.isIntersecting) {
+ this.loadImage();
+ this.observer?.disconnect();
+ }
+ });
+ },
+ { rootMargin: '50px' }
+ );
+
+ this.observer.observe(this.el.nativeElement);
+
+ // Set placeholder
+ if (this.el.nativeElement instanceof HTMLImageElement) {
+ this.el.nativeElement.src = this.placeholder();
+ }
+ }
+
+ private loadImage() {
+ const element = this.el.nativeElement;
+
+ if (element instanceof HTMLImageElement) {
+ element.src = this.src();
+ element.onload = () => this.loaded.emit();
+ } else {
+ element.style.backgroundImage = `url(${this.src()})`;
+ this.loaded.emit();
+ }
+ }
+
+ ngOnDestroy() {
+ this.observer?.disconnect();
+ }
+}
+
+// Usage:
+```
+
+### Infinite Scroll
+
+```typescript
+@Directive({
+ selector: '[appInfiniteScroll]',
+})
+export class InfiniteScroll implements OnDestroy {
+ private el = inject(ElementRef);
+ private observer: IntersectionObserver | null = null;
+
+ threshold = input(0.1);
+ disabled = input(false);
+
+ scrolled = output();
+
+ constructor() {
+ afterNextRender(() => {
+ this.setupObserver();
+ });
+
+ effect(() => {
+ if (this.disabled()) {
+ this.observer?.disconnect();
+ } else {
+ this.setupObserver();
+ }
+ });
+ }
+
+ private setupObserver() {
+ this.observer?.disconnect();
+
+ this.observer = new IntersectionObserver(
+ (entries) => {
+ if (entries[0].isIntersecting && !this.disabled()) {
+ this.scrolled.emit();
+ }
+ },
+ { threshold: this.threshold() }
+ );
+
+ this.observer.observe(this.el.nativeElement);
+ }
+
+ ngOnDestroy() {
+ this.observer?.disconnect();
+ }
+}
+
+// Usage:
+//
+// @for (item of items(); track item.id) {
+//
{{ item.name }}
+// }
+//
+// Loading...
+//
+//
+```
+
+## Resize Observer
+
+```typescript
+@Directive({
+ selector: '[appResize]',
+})
+export class Resize implements OnDestroy {
+ private el = inject(ElementRef);
+ private observer: ResizeObserver | null = null;
+
+ width = signal(0);
+ height = signal(0);
+
+ resized = output<{ width: number; height: number }>();
+
+ constructor() {
+ afterNextRender(() => {
+ this.observer = new ResizeObserver((entries) => {
+ const entry = entries[0];
+ const { width, height } = entry.contentRect;
+
+ this.width.set(width);
+ this.height.set(height);
+ this.resized.emit({ width, height });
+ });
+
+ this.observer.observe(this.el.nativeElement);
+ });
+ }
+
+ ngOnDestroy() {
+ this.observer?.disconnect();
+ }
+}
+
+// Usage:
+//
+// Size: {{ resize.width() }}x{{ resize.height() }}
+//
+```
+
+## Drag and Drop
+
+```typescript
+@Directive({
+ selector: '[appDraggable]',
+ host: {
+ 'draggable': 'true',
+ '[class.dragging]': 'isDragging()',
+ '(dragstart)': 'onDragStart($event)',
+ '(dragend)': 'onDragEnd($event)',
+ },
+})
+export class Draggable {
+ data = input(null, { alias: 'appDraggable' });
+ effectAllowed = input('move');
+
+ isDragging = signal(false);
+
+ dragStart = output();
+ dragEnd = output();
+
+ onDragStart(event: DragEvent) {
+ this.isDragging.set(true);
+
+ if (event.dataTransfer) {
+ event.dataTransfer.effectAllowed = this.effectAllowed();
+ event.dataTransfer.setData('application/json', JSON.stringify(this.data()));
+ }
+
+ this.dragStart.emit(event);
+ }
+
+ onDragEnd(event: DragEvent) {
+ this.isDragging.set(false);
+ this.dragEnd.emit(event);
+ }
+}
+
+@Directive({
+ selector: '[appDropZone]',
+ host: {
+ '[class.drag-over]': 'isDragOver()',
+ '(dragover)': 'onDragOver($event)',
+ '(dragleave)': 'onDragLeave($event)',
+ '(drop)': 'onDrop($event)',
+ },
+})
+export class DropZone {
+ isDragOver = signal(false);
+
+ dropped = output();
+
+ onDragOver(event: DragEvent) {
+ event.preventDefault();
+ this.isDragOver.set(true);
+ }
+
+ onDragLeave(event: DragEvent) {
+ this.isDragOver.set(false);
+ }
+
+ onDrop(event: DragEvent) {
+ event.preventDefault();
+ this.isDragOver.set(false);
+
+ const data = event.dataTransfer?.getData('application/json');
+ if (data) {
+ this.dropped.emit(JSON.parse(data));
+ }
+ }
+}
+
+// Usage:
+// Drag me
+// Drop here
+```
+
+## Permission Directive
+
+```typescript
+@Directive({
+ selector: '[appHasPermission]',
+})
+export class HasPermission {
+ private templateRef = inject(TemplateRef);
+ private viewContainer = inject(ViewContainerRef);
+ private authService = inject(Auth);
+ private hasView = false;
+
+ permission = input.required({ alias: 'appHasPermission' });
+ mode = input<'any' | 'all'>('any');
+
+ constructor() {
+ effect(() => {
+ const hasPermission = this.checkPermission();
+
+ if (hasPermission && !this.hasView) {
+ this.viewContainer.createEmbeddedView(this.templateRef);
+ this.hasView = true;
+ } else if (!hasPermission && this.hasView) {
+ this.viewContainer.clear();
+ this.hasView = false;
+ }
+ });
+ }
+
+ private checkPermission(): boolean {
+ const required = this.permission();
+ const permissions = Array.isArray(required) ? required : [required];
+ const userPermissions = this.authService.permissions();
+
+ if (this.mode() === 'all') {
+ return permissions.every(p => userPermissions.includes(p));
+ }
+
+ return permissions.some(p => userPermissions.includes(p));
+ }
+}
+
+// Usage:
+//
+// Edit & Delete
+```
+
+## Export Directive Reference
+
+```typescript
+@Directive({
+ selector: '[appToggle]',
+ exportAs: 'appToggle',
+})
+export class Toggle {
+ isOpen = signal(false);
+
+ toggle() {
+ this.isOpen.update(v => !v);
+ }
+
+ open() {
+ this.isOpen.set(true);
+ }
+
+ close() {
+ this.isOpen.set(false);
+ }
+}
+
+// Usage:
+//
+//
+// @if (toggle.isOpen()) {
+//
Content
+// }
+//
+```
diff --git a/skills/angular-forms/SKILL.md b/skills/angular-forms/SKILL.md
new file mode 100644
index 0000000..f97d54a
--- /dev/null
+++ b/skills/angular-forms/SKILL.md
@@ -0,0 +1,446 @@
+---
+name: angular-forms
+description: >-
+ Build signal-based forms in Angular v21+ using the new Signal Forms API. Use
+ for form creation with automatic two-way binding, schema-based validation,
+ field state management, and dynamic forms. Triggers on form implementation,
+ adding validation, creating multi-step forms, or building forms with
+ conditional fields. Signal Forms are experimental but recommended for new
+ Angular projects. Don't use for template-driven forms without signals or
+ third-party form libraries like Formly or ngx-formly.
+metadata:
+ category: development
+ source:
+ repository: 'https://github.com/analogjs/angular-skills'
+ path: skills/angular-forms
+---
+
+# Angular Signal Forms
+
+Build type-safe, reactive forms using Angular's Signal Forms API. Signal Forms provide automatic two-way binding, schema-based validation, and reactive field state.
+
+**Note:** Signal Forms are experimental in Angular v21. For production apps requiring stability, see [references/form-patterns.md](references/form-patterns.md) for Reactive Forms patterns.
+
+## Basic Setup
+
+```typescript
+import { Component, signal } from '@angular/core';
+import { form, FormField, required, email } from '@angular/forms/signals';
+
+interface LoginData {
+ email: string;
+ password: string;
+}
+
+@Component({
+ selector: 'app-login',
+ imports: [FormField],
+ template: `
+
+ `,
+})
+export class Login {
+ // Form model - a writable signal
+ loginModel = signal({
+ email: '',
+ password: '',
+ });
+
+ // Create form with validation schema
+ loginForm = form(this.loginModel, (schemaPath) => {
+ required(schemaPath.email, { message: 'Email is required' });
+ email(schemaPath.email, { message: 'Enter a valid email address' });
+ required(schemaPath.password, { message: 'Password is required' });
+ });
+
+ onSubmit(event: Event) {
+ event.preventDefault();
+ if (this.loginForm().valid()) {
+ const credentials = this.loginModel();
+ console.log('Submitting:', credentials);
+ }
+ }
+}
+```
+
+## Form Models
+
+Form models are writable signals that serve as the single source of truth:
+
+```typescript
+// Define interface for type safety
+interface UserProfile {
+ name: string;
+ email: string;
+ age: number | null;
+ preferences: {
+ newsletter: boolean;
+ theme: 'light' | 'dark';
+ };
+}
+
+// Create model signal with initial values
+const userModel = signal({
+ name: '',
+ email: '',
+ age: null,
+ preferences: {
+ newsletter: false,
+ theme: 'light',
+ },
+});
+
+// Create form from model
+const userForm = form(userModel);
+
+// Access nested fields via dot notation
+userForm.name // FieldTree
+userForm.preferences.theme // FieldTree<'light' | 'dark'>
+```
+
+### Reading Values
+
+```typescript
+// Read entire model
+const data = this.userModel();
+
+// Read field value via field state
+const name = this.userForm.name().value();
+const theme = this.userForm.preferences.theme().value();
+```
+
+### Updating Values
+
+```typescript
+// Replace entire model
+this.userModel.set({
+ name: 'Alice',
+ email: 'alice@example.com',
+ age: 30,
+ preferences: { newsletter: true, theme: 'dark' },
+});
+
+// Update single field
+this.userForm.name().value.set('Bob');
+this.userForm.age().value.update(age => (age ?? 0) + 1);
+```
+
+## Field State
+
+Each field provides reactive signals for validation, interaction, and availability:
+
+```typescript
+const emailField = this.form.email();
+
+// Validation state
+emailField.valid() // true if passes all validation
+emailField.invalid() // true if has validation errors
+emailField.errors() // array of error objects
+emailField.pending() // true if async validation in progress
+
+// Interaction state
+emailField.touched() // true after focus + blur
+emailField.dirty() // true after user modification
+
+// Availability state
+emailField.disabled() // true if field is disabled
+emailField.hidden() // true if field should be hidden
+emailField.readonly() // true if field is readonly
+
+// Value
+emailField.value() // current field value (signal)
+```
+
+### Form-Level State
+
+The form itself is also a field with aggregated state:
+
+```typescript
+// Form is valid when all interactive fields are valid
+this.form().valid()
+
+// Form is touched when any field is touched
+this.form().touched()
+
+// Form is dirty when any field is modified
+this.form().dirty()
+```
+
+## Validation
+
+### Built-in Validators
+
+```typescript
+import {
+ form, required, email, min, max,
+ minLength, maxLength, pattern
+} from '@angular/forms/signals';
+
+const userForm = form(this.userModel, (schemaPath) => {
+ // Required field
+ required(schemaPath.name, { message: 'Name is required' });
+
+ // Email format
+ email(schemaPath.email, { message: 'Invalid email' });
+
+ // Numeric range
+ min(schemaPath.age, 18, { message: 'Must be 18+' });
+ max(schemaPath.age, 120, { message: 'Invalid age' });
+
+ // String/array length
+ minLength(schemaPath.password, 8, { message: 'Min 8 characters' });
+ maxLength(schemaPath.bio, 500, { message: 'Max 500 characters' });
+
+ // Regex pattern
+ pattern(schemaPath.phone, /^\d{3}-\d{3}-\d{4}$/, {
+ message: 'Format: 555-123-4567',
+ });
+});
+```
+
+### Conditional Validation
+
+```typescript
+const orderForm = form(this.orderModel, (schemaPath) => {
+ required(schemaPath.promoCode, {
+ message: 'Promo code required for discounts',
+ when: ({ valueOf }) => valueOf(schemaPath.applyDiscount),
+ });
+});
+```
+
+### Custom Validators
+
+```typescript
+import { validate } from '@angular/forms/signals';
+
+const signupForm = form(this.signupModel, (schemaPath) => {
+ // Custom validation logic
+ validate(schemaPath.username, ({ value }) => {
+ if (value().includes(' ')) {
+ return { kind: 'noSpaces', message: 'Username cannot contain spaces' };
+ }
+ return null;
+ });
+});
+```
+
+### Cross-Field Validation
+
+```typescript
+const passwordForm = form(this.passwordModel, (schemaPath) => {
+ required(schemaPath.password);
+ required(schemaPath.confirmPassword);
+
+ // Compare fields
+ validate(schemaPath.confirmPassword, ({ value, valueOf }) => {
+ if (value() !== valueOf(schemaPath.password)) {
+ return { kind: 'mismatch', message: 'Passwords do not match' };
+ }
+ return null;
+ });
+});
+```
+
+### Async Validation
+
+```typescript
+import { validateHttp } from '@angular/forms/signals';
+
+const signupForm = form(this.signupModel, (schemaPath) => {
+ validateHttp(schemaPath.username, {
+ request: ({ value }) => `/api/check-username?u=${value()}`,
+ onSuccess: (response: { taken: boolean }) => {
+ if (response.taken) {
+ return { kind: 'taken', message: 'Username already taken' };
+ }
+ return null;
+ },
+ onError: () => ({
+ kind: 'networkError',
+ message: 'Could not verify username',
+ }),
+ });
+});
+```
+
+## Conditional Fields
+
+### Hidden Fields
+
+```typescript
+import { hidden } from '@angular/forms/signals';
+
+const profileForm = form(this.profileModel, (schemaPath) => {
+ hidden(schemaPath.publicUrl, ({ valueOf }) => !valueOf(schemaPath.isPublic));
+});
+```
+
+```html
+@if (!profileForm.publicUrl().hidden()) {
+
+}
+```
+
+### Disabled Fields
+
+```typescript
+import { disabled } from '@angular/forms/signals';
+
+const orderForm = form(this.orderModel, (schemaPath) => {
+ disabled(schemaPath.couponCode, ({ valueOf }) => valueOf(schemaPath.total) < 50);
+});
+```
+
+### Readonly Fields
+
+```typescript
+import { readonly } from '@angular/forms/signals';
+
+const accountForm = form(this.accountModel, (schemaPath) => {
+ readonly(schemaPath.username); // Always readonly
+});
+```
+
+## Form Submission
+
+```typescript
+import { submit } from '@angular/forms/signals';
+
+@Component({
+ template: `
+
+ `,
+})
+export class Login {
+ model = signal({ email: '', password: '' });
+ form = form(this.model, (schemaPath) => {
+ required(schemaPath.email);
+ required(schemaPath.password);
+ });
+
+ onSubmit(event: Event) {
+ event.preventDefault();
+
+ // submit() marks all fields touched and runs callback if valid
+ submit(this.form, async () => {
+ await this.authService.login(this.model());
+ });
+ }
+}
+```
+
+## Arrays and Dynamic Fields
+
+```typescript
+interface Order {
+ items: Array<{ product: string; quantity: number }>;
+}
+
+@Component({
+ template: `
+ @for (item of orderForm.items; track $index; let i = $index) {
+
+
+
+
+
+ }
+
+ `,
+})
+export class Order {
+ orderModel = signal({
+ items: [{ product: '', quantity: 1 }],
+ });
+
+ orderForm = form(this.orderModel, (schemaPath) => {
+ applyEach(schemaPath.items, (item) => {
+ required(item.product, { message: 'Product required' });
+ min(item.quantity, 1, { message: 'Min quantity is 1' });
+ });
+ });
+
+ addItem() {
+ this.orderModel.update(m => ({
+ ...m,
+ items: [...m.items, { product: '', quantity: 1 }],
+ }));
+ }
+
+ removeItem(index: number) {
+ this.orderModel.update(m => ({
+ ...m,
+ items: m.items.filter((_, i) => i !== index),
+ }));
+ }
+}
+```
+
+## Displaying Errors
+
+```html
+
+
+@if (form.email().touched() && form.email().invalid()) {
+
+ @for (error of form.email().errors(); track error) {
+ - {{ error.message }}
+ }
+
+}
+
+@if (form.email().pending()) {
+ Validating...
+}
+```
+
+## Styling Based on State
+
+```html
+
+```
+
+## Reset Form
+
+```typescript
+async onSubmit() {
+ if (!this.form().valid()) return;
+
+ await this.api.submit(this.model());
+
+ // Clear interaction state
+ this.form().reset();
+
+ // Clear values
+ this.model.set({ email: '', password: '' });
+}
+```
+
+For Reactive Forms patterns (production-stable), see [references/form-patterns.md](references/form-patterns.md).
diff --git a/skills/angular-forms/references/form-patterns.md b/skills/angular-forms/references/form-patterns.md
new file mode 100644
index 0000000..522f9b8
--- /dev/null
+++ b/skills/angular-forms/references/form-patterns.md
@@ -0,0 +1,405 @@
+# Angular Form Patterns
+
+## Table of Contents
+- [Reactive Forms (Production-Stable)](#reactive-forms-production-stable)
+- [Typed Reactive Forms](#typed-reactive-forms)
+- [FormBuilder Patterns](#formbuilder-patterns)
+- [Dynamic Forms with FormArray](#dynamic-forms-with-formarray)
+- [Custom Validators](#custom-validators)
+- [Form State Management](#form-state-management)
+
+## Reactive Forms (Production-Stable)
+
+For production applications requiring stability guarantees, use Reactive Forms:
+
+```typescript
+import { Component, inject } from '@angular/core';
+import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
+
+@Component({
+ selector: 'app-login',
+ imports: [ReactiveFormsModule],
+ template: `
+
+ `,
+})
+export class Login {
+ private fb = inject(FormBuilder);
+
+ form = this.fb.group({
+ email: ['', [Validators.required, Validators.email]],
+ password: ['', [Validators.required, Validators.minLength(8)]],
+ });
+
+ onSubmit() {
+ if (this.form.valid) {
+ console.log(this.form.value);
+ }
+ }
+}
+```
+
+## Typed Reactive Forms
+
+### Typed FormControl
+
+```typescript
+import { FormControl } from '@angular/forms';
+
+// Inferred type: FormControl
+const name = new FormControl('');
+
+// Non-nullable (no reset to null)
+const email = new FormControl('', { nonNullable: true });
+// Type: FormControl
+
+// With validators
+const username = new FormControl('', {
+ nonNullable: true,
+ validators: [Validators.required, Validators.minLength(3)],
+});
+```
+
+### Typed FormGroup
+
+```typescript
+import { FormGroup, FormControl } from '@angular/forms';
+
+interface UserForm {
+ name: FormControl;
+ email: FormControl;
+ age: FormControl;
+}
+
+const form = new FormGroup({
+ name: new FormControl('', { nonNullable: true }),
+ email: new FormControl('', { nonNullable: true }),
+ age: new FormControl(null),
+});
+
+// Typed value access
+const name: string = form.controls.name.value;
+```
+
+### NonNullableFormBuilder
+
+```typescript
+import { inject } from '@angular/core';
+import { NonNullableFormBuilder } from '@angular/forms';
+
+@Component({...})
+export class Profile {
+ private fb = inject(NonNullableFormBuilder);
+
+ form = this.fb.group({
+ name: ['', Validators.required], // FormControl
+ email: ['', [Validators.required, Validators.email]],
+ preferences: this.fb.group({
+ newsletter: [false], // FormControl
+ theme: ['light' as 'light' | 'dark'], // FormControl<'light' | 'dark'>
+ }),
+ });
+}
+```
+
+## FormBuilder Patterns
+
+### Nested FormGroups
+
+```typescript
+@Component({
+ imports: [ReactiveFormsModule],
+ template: `
+
+ `,
+})
+export class Profile {
+ private fb = inject(NonNullableFormBuilder);
+
+ form = this.fb.group({
+ name: ['', Validators.required],
+ address: this.fb.group({
+ street: [''],
+ city: ['', Validators.required],
+ zip: ['', [Validators.required, Validators.pattern(/^\d{5}$/)]],
+ }),
+ });
+}
+```
+
+## Dynamic Forms with FormArray
+
+```typescript
+import { FormArray } from '@angular/forms';
+
+@Component({
+ imports: [ReactiveFormsModule],
+ template: `
+
+ `,
+})
+export class Order {
+ private fb = inject(NonNullableFormBuilder);
+
+ form = this.fb.group({
+ items: this.fb.array([this.createItem()]),
+ });
+
+ get items() {
+ return this.form.controls.items;
+ }
+
+ createItem() {
+ return this.fb.group({
+ product: ['', Validators.required],
+ quantity: [1, [Validators.required, Validators.min(1)]],
+ });
+ }
+
+ addItem() {
+ this.items.push(this.createItem());
+ }
+
+ removeItem(index: number) {
+ this.items.removeAt(index);
+ }
+}
+```
+
+## Custom Validators
+
+### Sync Validator
+
+```typescript
+import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
+
+export function forbiddenValue(forbidden: string): ValidatorFn {
+ return (control: AbstractControl): ValidationErrors | null => {
+ return control.value === forbidden
+ ? { forbiddenValue: { value: control.value } }
+ : null;
+ };
+}
+
+// Usage
+name: ['', [Validators.required, forbiddenValue('admin')]],
+```
+
+### Cross-Field Validator
+
+```typescript
+export function passwordMatch(): ValidatorFn {
+ return (group: AbstractControl): ValidationErrors | null => {
+ const password = group.get('password')?.value;
+ const confirm = group.get('confirmPassword')?.value;
+ return password === confirm ? null : { passwordMismatch: true };
+ };
+}
+
+// Usage
+form = this.fb.group({
+ password: ['', [Validators.required, Validators.minLength(8)]],
+ confirmPassword: ['', Validators.required],
+}, { validators: passwordMatch() });
+```
+
+### Async Validator
+
+```typescript
+import { AsyncValidatorFn } from '@angular/forms';
+import { map, catchError, of } from 'rxjs';
+
+export function uniqueEmail(userService: User): AsyncValidatorFn {
+ return (control: AbstractControl) => {
+ return userService.checkEmail(control.value).pipe(
+ map(exists => exists ? { emailTaken: true } : null),
+ catchError(() => of(null))
+ );
+ };
+}
+
+// Usage
+email: ['',
+ [Validators.required, Validators.email], // sync validators
+ [uniqueEmail(this.userService)] // async validators
+],
+```
+
+## Form State Management
+
+### State Properties
+
+```typescript
+// Check states
+form.valid // All validations pass
+form.invalid // Has validation errors
+form.pending // Async validation in progress
+form.dirty // Value changed by user
+form.pristine // Value not changed
+form.touched // Control has been focused
+form.untouched // Control never focused
+
+// Update values
+form.setValue({ name: 'John', email: 'john@example.com' }); // Must include all
+form.patchValue({ name: 'John' }); // Partial update
+
+// Reset
+form.reset();
+form.reset({ name: 'Default' });
+
+// Disable/Enable
+form.disable();
+form.enable();
+form.controls.email.disable();
+
+// Mark states
+form.markAllAsTouched(); // Show all errors
+form.markAsPristine();
+form.markAsDirty();
+```
+
+### Value Changes Observable
+
+```typescript
+// Subscribe to value changes
+form.valueChanges.subscribe(value => {
+ console.log('Form value:', value);
+});
+
+// Single control with debounce
+form.controls.email.valueChanges.pipe(
+ debounceTime(300),
+ distinctUntilChanged()
+).subscribe(email => {
+ this.validateEmail(email);
+});
+
+// Status changes
+form.statusChanges.subscribe(status => {
+ console.log('Form status:', status); // VALID, INVALID, PENDING
+});
+```
+
+### Unified Events (Angular v18+)
+
+```typescript
+import {
+ ValueChangeEvent, StatusChangeEvent,
+ PristineChangeEvent,TouchedChangeEvent,
+ FormSubmittedEvent, FormResetEvent
+} from '@angular/forms';
+
+form.events.subscribe(event => {
+ if (event instanceof ValueChangeEvent) {
+ console.log('Value changed:', event.value);
+ }
+ if (event instanceof StatusChangeEvent) {
+ console.log('Status changed:', event.status);
+ }
+ if (event instanceof PristineChangeEvent) {
+ console.log('Pristine changed:', event.pristine);
+ }
+ if (event instanceof TouchedChangeEvent) {
+ console.log('Touched changed:', event.touched);
+ }
+ if (event instanceof FormSubmittedEvent) {
+ console.log('Form submitted');
+ }
+ if (event instanceof FormResetEvent) {
+ console.log('Form reset');
+ }
+});
+```
+
+## Error Display Pattern
+
+```typescript
+@Component({
+ template: `
+
+
+ @if (form.controls.email.invalid && form.controls.email.touched) {
+
+ @if (form.controls.email.errors?.['required']) {
+ Email is required
+ }
+ @if (form.controls.email.errors?.['email']) {
+ Invalid email format
+ }
+
+ }
+ `,
+})
+export class Form {
+ // Helper for cleaner templates
+ hasError(controlName: string, errorKey: string): boolean {
+ const control = this.form.get(controlName);
+ return control?.hasError(errorKey) && control?.touched || false;
+ }
+}
+```
+
+## Form Submission Pattern
+
+```typescript
+@Component({
+ template: `
+
+ `,
+})
+export class Form {
+ isSubmitting = false;
+
+ async onSubmit() {
+ if (this.form.invalid) {
+ this.form.markAllAsTouched();
+ return;
+ }
+
+ this.isSubmitting = true;
+ try {
+ await this.api.submit(this.form.getRawValue());
+ this.form.reset();
+ } catch (error) {
+ // Handle error
+ } finally {
+ this.isSubmitting = false;
+ }
+ }
+}
+```
diff --git a/skills/angular-forms/references/formvalueControl-patterns.md b/skills/angular-forms/references/formvalueControl-patterns.md
new file mode 100644
index 0000000..ef9f4af
--- /dev/null
+++ b/skills/angular-forms/references/formvalueControl-patterns.md
@@ -0,0 +1,110 @@
+# Angular Signal Forms - ( FormValueControl )
+
+## Table of Contents
+- [Signal Form FormValueControl](#formValueControl)
+
+## Signal Forms FormValueControl
+
+``` typescript
+
+interface Rating {
+ rating : number
+}
+
+import { form, FormField, FormValueControl, ValidationError, WithOptionalField } from '@angular/forms/signals';
+import { MatIconModule } from '@angular/material/icon';
+import { MatError } from '@angular/material/form-field';
+
+
+@Component({
+ selector: 'app-rating',
+ imports : [MatIconModule,MatError],
+ template: `
+
+ @for (star of starArray(); track $index) {
+
+ {{ getStarIcon(star) }}
+
+ }
+ @if (errors().at(0)?.message) {
+
+ {{ errors().at(0)?.message }}
+
+ }
+
+ `,
+ styles: ``,
+})
+export class Rating implements FormValueControl {
+ // Required: The value of the control, exposed as a two-way binding.
+ readonly value = model(0);
+ // Optional: Bindings for other form control states.
+ readonly readonly = input(false);
+ readonly invalid = input(false);
+ readonly errors: InputSignal[]> = input<
+ readonly WithOptionalField[]
+ >([]);
+
+ starArray: Signal = signal(
+ Array(5)
+ .fill(0)
+ .map((_, i) => i + 1),
+ );
+
+ getStarIcon(index: number): string {
+ const floorRating = Math.floor(this.value());
+ if (index <= floorRating) {
+ return 'star'; // Full star
+ } else {
+ return 'star_border'; // Empty star
+ }
+ }
+ rate(index: number): void {
+ if (!this.readonly()) {
+ this.value.set(index);
+ }
+ }
+}
+
+
+import { FormField } from '@angular/forms/signals';
+
+@Component({
+ selector: 'app-signal-forms',
+ imports : [FormField, Rating],
+ template: `
+
+ `,
+ styles: ``,
+})
+export class SignalForms {
+ readonly ratingModel = signal({
+ rating: 0,
+ });
+
+ readonly ratingForm = form(this.ratingModel)
+
+ submit(event: Event): void {
+ event.preventDefault();
+ console.log(this.ratingForm.rating().value());
+ }
+}
+
+
+
+```
+
diff --git a/skills/angular-http/SKILL.md b/skills/angular-http/SKILL.md
new file mode 100644
index 0000000..bd3de3c
--- /dev/null
+++ b/skills/angular-http/SKILL.md
@@ -0,0 +1,376 @@
+---
+name: angular-http
+description: >-
+ Implement HTTP data fetching in Angular v20+ using resource(), httpResource(),
+ and HttpClient. Use for API calls, data loading with signals, request/response
+ handling, and interceptors. Triggers on data fetching, API integration,
+ loading states, error handling, or converting Observable-based HTTP to
+ signal-based patterns.
+metadata:
+ category: development
+ source:
+ repository: 'https://github.com/analogjs/angular-skills'
+ path: skills/angular-http
+---
+
+# Angular HTTP & Data Fetching
+
+Fetch data in Angular using signal-based `resource()`, `httpResource()`, and the traditional `HttpClient`.
+
+## httpResource() - Signal-Based HTTP
+
+`httpResource()` wraps HttpClient with signal-based state management:
+
+```typescript
+import { Component, signal } from '@angular/core';
+import { httpResource } from '@angular/common/http';
+
+interface User {
+ id: number;
+ name: string;
+ email: string;
+}
+
+@Component({
+ selector: 'app-user-profile',
+ template: `
+ @if (userResource.isLoading()) {
+ Loading...
+ } @else if (userResource.error()) {
+ Error: {{ userResource.error()?.message }}
+
+ } @else if (userResource.hasValue()) {
+ {{ userResource.value().name }}
+ {{ userResource.value().email }}
+ }
+ `,
+})
+export class UserProfile {
+ userId = signal('123');
+
+ // Reactive HTTP resource - refetches when userId changes
+ userResource = httpResource(() => `/api/users/${this.userId()}`);
+}
+```
+
+### httpResource Options
+
+```typescript
+// Simple GET request
+userResource = httpResource(() => `/api/users/${this.userId()}`);
+
+// With full request options
+userResource = httpResource(() => ({
+ url: `/api/users/${this.userId()}`,
+ method: 'GET',
+ headers: { 'Authorization': `Bearer ${this.token()}` },
+ params: { include: 'profile' },
+}));
+
+// With default value
+usersResource = httpResource(() => '/api/users', {
+ defaultValue: [],
+});
+
+// Skip request when params undefined
+userResource = httpResource(() => {
+ const id = this.userId();
+ return id ? `/api/users/${id}` : undefined;
+});
+```
+
+### Resource State
+
+```typescript
+// Status signals
+userResource.value() // Current value or undefined
+userResource.hasValue() // Boolean - has resolved value
+userResource.error() // Error or undefined
+userResource.isLoading() // Boolean - currently loading
+userResource.status() // 'idle' | 'loading' | 'reloading' | 'resolved' | 'error' | 'local'
+
+// Actions
+userResource.reload() // Manually trigger reload
+userResource.set(value) // Set local value
+userResource.update(fn) // Update local value
+```
+
+## resource() - Generic Async Data
+
+For non-HTTP async operations or custom fetch logic:
+
+```typescript
+import { resource, signal } from '@angular/core';
+
+@Component({...})
+export class Search {
+ query = signal('');
+
+ searchResource = resource({
+ // Reactive params - triggers reload when changed
+ params: () => ({ q: this.query() }),
+
+ // Async loader function
+ loader: async ({ params, abortSignal }) => {
+ if (!params.q) return [];
+
+ const response = await fetch(`/api/search?q=${params.q}`, {
+ signal: abortSignal,
+ });
+ return response.json() as Promise;
+ },
+ });
+}
+```
+
+### Resource with Default Value
+
+```typescript
+todosResource = resource({
+ defaultValue: [] as Todo[],
+ params: () => ({ filter: this.filter() }),
+ loader: async ({ params }) => {
+ const res = await fetch(`/api/todos?filter=${params.filter}`);
+ return res.json();
+ },
+});
+
+// value() returns Todo[] (never undefined)
+```
+
+### Conditional Loading
+
+```typescript
+const userId = signal(null);
+
+userResource = resource({
+ params: () => {
+ const id = userId();
+ // Return undefined to skip loading
+ return id ? { id } : undefined;
+ },
+ loader: async ({ params }) => {
+ return fetch(`/api/users/${params.id}`).then(r => r.json());
+ },
+});
+// Status is 'idle' when params returns undefined
+```
+
+## HttpClient - Traditional Approach
+
+For complex scenarios or when you need Observable operators:
+
+```typescript
+import { Component, inject } from '@angular/core';
+import { HttpClient } from '@angular/common/http';
+import { toSignal } from '@angular/core/rxjs-interop';
+
+@Component({...})
+export class Users {
+ private http = inject(HttpClient);
+
+ // Convert Observable to Signal
+ users = toSignal(
+ this.http.get('/api/users'),
+ { initialValue: [] }
+ );
+
+ // Or use Observable directly
+ users$ = this.http.get('/api/users');
+}
+```
+
+### HTTP Methods
+
+```typescript
+private http = inject(HttpClient);
+
+// GET
+getUser(id: string) {
+ return this.http.get(`/api/users/${id}`);
+}
+
+// POST
+createUser(user: CreateUserDto) {
+ return this.http.post('/api/users', user);
+}
+
+// PUT
+updateUser(id: string, user: UpdateUserDto) {
+ return this.http.put(`/api/users/${id}`, user);
+}
+
+// PATCH
+patchUser(id: string, changes: Partial) {
+ return this.http.patch(`/api/users/${id}`, changes);
+}
+
+// DELETE
+deleteUser(id: string) {
+ return this.http.delete(`/api/users/${id}`);
+}
+```
+
+### Request Options
+
+```typescript
+this.http.get('/api/users', {
+ headers: {
+ 'Authorization': 'Bearer token',
+ 'Content-Type': 'application/json',
+ },
+ params: {
+ page: '1',
+ limit: '10',
+ sort: 'name',
+ },
+ observe: 'response', // Get full HttpResponse
+ responseType: 'json',
+});
+```
+
+## Interceptors
+
+### Functional Interceptor (Recommended)
+
+```typescript
+// auth.interceptor.ts
+import { HttpInterceptorFn } from '@angular/common/http';
+import { inject } from '@angular/core';
+
+export const authInterceptor: HttpInterceptorFn = (req, next) => {
+ const authService = inject(Auth);
+ const token = authService.token();
+
+ if (token) {
+ req = req.clone({
+ setHeaders: { Authorization: `Bearer ${token}` },
+ });
+ }
+
+ return next(req);
+};
+
+// error.interceptor.ts
+export const errorInterceptor: HttpInterceptorFn = (req, next) => {
+ return next(req).pipe(
+ catchError((error: HttpErrorResponse) => {
+ if (error.status === 401) {
+ inject(Router).navigate(['/login']);
+ }
+ return throwError(() => error);
+ })
+ );
+};
+
+// logging.interceptor.ts
+export const loggingInterceptor: HttpInterceptorFn = (req, next) => {
+ const started = Date.now();
+ return next(req).pipe(
+ tap({
+ next: () => console.log(`${req.method} ${req.url} - ${Date.now() - started}ms`),
+ error: (err) => console.error(`${req.method} ${req.url} failed`, err),
+ })
+ );
+};
+```
+
+### Register Interceptors
+
+```typescript
+// app.config.ts
+import { provideHttpClient, withInterceptors } from '@angular/common/http';
+
+export const appConfig: ApplicationConfig = {
+ providers: [
+ provideHttpClient(
+ withInterceptors([
+ authInterceptor,
+ errorInterceptor,
+ loggingInterceptor,
+ ])
+ ),
+ ],
+};
+```
+
+## Error Handling
+
+### With httpResource
+
+```typescript
+@Component({
+ template: `
+ @if (userResource.error(); as error) {
+
+
{{ getErrorMessage(error) }}
+
+
+ }
+ `,
+})
+export class UserCmpt {
+ userResource = httpResource(() => `/api/users/${this.userId()}`);
+
+ getErrorMessage(error: unknown): string {
+ if (error instanceof HttpErrorResponse) {
+ return error.error?.message || `Error ${error.status}: ${error.statusText}`;
+ }
+ return 'An unexpected error occurred';
+ }
+}
+```
+
+### With HttpClient
+
+```typescript
+import { catchError, retry } from 'rxjs';
+
+getUser(id: string) {
+ return this.http.get(`/api/users/${id}`).pipe(
+ retry(2), // Retry up to 2 times
+ catchError((error: HttpErrorResponse) => {
+ console.error('Error fetching user:', error);
+ return throwError(() => new Error('Failed to load user'));
+ })
+ );
+}
+```
+
+## Loading States Pattern
+
+```typescript
+@Component({
+ template: `
+ @switch (dataResource.status()) {
+ @case ('idle') {
+ Enter a search term
+ }
+ @case ('loading') {
+
+ }
+ @case ('reloading') {
+
+
+ }
+ @case ('resolved') {
+
+ }
+ @case ('error') {
+
+ }
+ }
+ `,
+})
+export class Data {
+ query = signal('');
+ dataResource = httpResource(() =>
+ this.query() ? `/api/search?q=${this.query()}` : undefined
+ );
+}
+```
+
+For advanced patterns, see [references/http-patterns.md](references/http-patterns.md).
diff --git a/skills/angular-http/references/http-patterns.md b/skills/angular-http/references/http-patterns.md
new file mode 100644
index 0000000..1791060
--- /dev/null
+++ b/skills/angular-http/references/http-patterns.md
@@ -0,0 +1,448 @@
+# Angular HTTP Patterns
+
+## Table of Contents
+- [Service Layer Pattern](#service-layer-pattern)
+- [Caching Strategies](#caching-strategies)
+- [Pagination](#pagination)
+- [File Upload](#file-upload)
+- [Request Cancellation](#request-cancellation)
+- [Testing HTTP](#testing-http)
+
+## Service Layer Pattern
+
+Encapsulate HTTP logic in services:
+
+```typescript
+import { Injectable, inject, signal, computed } from '@angular/core';
+import { HttpClient } from '@angular/common/http';
+import { httpResource } from '@angular/common/http';
+
+export interface User {
+ id: string;
+ name: string;
+ email: string;
+}
+
+@Injectable({ providedIn: 'root' })
+export class User {
+ private http = inject(HttpClient);
+ private baseUrl = '/api/users';
+
+ // Current user ID for reactive fetching
+ private currentUserId = signal(null);
+
+ // Reactive resource that updates when currentUserId changes
+ currentUser = httpResource(() => {
+ const id = this.currentUserId();
+ return id ? `${this.baseUrl}/${id}` : undefined;
+ });
+
+ // Set current user to fetch
+ selectUser(id: string) {
+ this.currentUserId.set(id);
+ }
+
+ // CRUD operations
+ getAll() {
+ return this.http.get(this.baseUrl);
+ }
+
+ getById(id: string) {
+ return this.http.get(`${this.baseUrl}/${id}`);
+ }
+
+ create(user: Omit) {
+ return this.http.post(this.baseUrl, user);
+ }
+
+ update(id: string, user: Partial) {
+ return this.http.patch(`${this.baseUrl}/${id}`, user);
+ }
+
+ delete(id: string) {
+ return this.http.delete(`${this.baseUrl}/${id}`);
+ }
+}
+```
+
+## Caching Strategies
+
+### Simple In-Memory Cache
+
+```typescript
+@Injectable({ providedIn: 'root' })
+export class CachedUser {
+ private http = inject(HttpClient);
+ private cache = new Map();
+ private cacheDuration = 5 * 60 * 1000; // 5 minutes
+
+ getUser(id: string): Observable {
+ const cached = this.cache.get(id);
+
+ if (cached && Date.now() - cached.timestamp < this.cacheDuration) {
+ return of(cached.data);
+ }
+
+ return this.http.get(`/api/users/${id}`).pipe(
+ tap(user => {
+ this.cache.set(id, { data: user, timestamp: Date.now() });
+ })
+ );
+ }
+
+ invalidateCache(id?: string) {
+ if (id) {
+ this.cache.delete(id);
+ } else {
+ this.cache.clear();
+ }
+ }
+}
+```
+
+### Signal-Based Cache
+
+```typescript
+@Injectable({ providedIn: 'root' })
+export class UserCache {
+ private http = inject(HttpClient);
+
+ // Cache as signal
+ private usersCache = signal