diff --git a/website/src/scripts/pages/hooks.ts b/website/src/scripts/pages/hooks.ts
new file mode 100644
index 00000000..2373c80c
--- /dev/null
+++ b/website/src/scripts/pages/hooks.ts
@@ -0,0 +1,348 @@
+/**
+ * Hooks page functionality
+ */
+import { createChoices, getChoicesValues, type Choices } from "../choices";
+import { FuzzySearch, SearchItem } from "../search";
+import {
+ fetchData,
+ debounce,
+ escapeHtml,
+ getGitHubUrl,
+ getRawGitHubUrl,
+ showToast,
+ getLastUpdatedHtml,
+} from "../utils";
+import { setupModal, openFileModal } from "../modal";
+import JSZip from "../jszip";
+
+interface Hook extends SearchItem {
+ id: string;
+ path: string;
+ readmeFile: string;
+ hooks: string[];
+ tags: string[];
+ assets: string[];
+ lastUpdated?: string | null;
+}
+
+interface HooksData {
+ items: Hook[];
+ filters: {
+ hooks: string[];
+ tags: string[];
+ };
+}
+
+type SortOption = "title" | "lastUpdated";
+
+const resourceType = "hook";
+let allItems: Hook[] = [];
+let search = new FuzzySearch
();
+let hookSelect: Choices;
+let tagSelect: Choices;
+let currentFilters = {
+ hooks: [] as string[],
+ tags: [] as string[],
+};
+let currentSort: SortOption = "title";
+
+function sortItems(items: Hook[]): Hook[] {
+ return [...items].sort((a, b) => {
+ if (currentSort === "lastUpdated") {
+ const dateA = a.lastUpdated ? new Date(a.lastUpdated).getTime() : 0;
+ const dateB = b.lastUpdated ? new Date(b.lastUpdated).getTime() : 0;
+ return dateB - dateA;
+ }
+ return a.title.localeCompare(b.title);
+ });
+}
+
+function applyFiltersAndRender(): void {
+ const searchInput = document.getElementById(
+ "search-input"
+ ) as HTMLInputElement;
+ const countEl = document.getElementById("results-count");
+ const query = searchInput?.value || "";
+
+ let results = query ? search.search(query) : [...allItems];
+
+ if (currentFilters.hooks.length > 0) {
+ results = results.filter((item) =>
+ item.hooks.some((h) => currentFilters.hooks.includes(h))
+ );
+ }
+ if (currentFilters.tags.length > 0) {
+ results = results.filter((item) =>
+ item.tags.some((t) => currentFilters.tags.includes(t))
+ );
+ }
+
+ results = sortItems(results);
+
+ renderItems(results, query);
+ const activeFilters: string[] = [];
+ if (currentFilters.hooks.length > 0)
+ activeFilters.push(
+ `${currentFilters.hooks.length} hook event${
+ currentFilters.hooks.length > 1 ? "s" : ""
+ }`
+ );
+ if (currentFilters.tags.length > 0)
+ activeFilters.push(
+ `${currentFilters.tags.length} tag${
+ currentFilters.tags.length > 1 ? "s" : ""
+ }`
+ );
+ let countText = `${results.length} of ${allItems.length} hooks`;
+ if (activeFilters.length > 0) {
+ countText += ` (filtered by ${activeFilters.join(", ")})`;
+ }
+ if (countEl) countEl.textContent = countText;
+}
+
+function renderItems(items: Hook[], query = ""): void {
+ const list = document.getElementById("resource-list");
+ if (!list) return;
+
+ if (items.length === 0) {
+ list.innerHTML =
+ 'No hooks found
Try a different search term or adjust filters
';
+ return;
+ }
+
+ list.innerHTML = items
+ .map(
+ (item) => `
+
+
+
${
+ query ? search.highlight(item.title, query) : escapeHtml(item.title)
+ }
+
${escapeHtml(
+ item.description || "No description"
+ )}
+
+ ${item.hooks
+ .map(
+ (h) =>
+ `${escapeHtml(h)}`
+ )
+ .join("")}
+ ${item.tags
+ .map(
+ (t) =>
+ `${escapeHtml(t)}`
+ )
+ .join("")}
+ ${
+ item.assets.length > 0
+ ? `${
+ item.assets.length
+ } asset${item.assets.length === 1 ? "" : "s"}`
+ : ""
+ }
+ ${getLastUpdatedHtml(item.lastUpdated)}
+
+
+
+
+
GitHub
+
+
+ `
+ )
+ .join("");
+
+ // Add click handlers for opening modal
+ list.querySelectorAll(".resource-item").forEach((el) => {
+ el.addEventListener("click", (e) => {
+ if ((e.target as HTMLElement).closest(".resource-actions")) return;
+ const path = (el as HTMLElement).dataset.path;
+ if (path) openFileModal(path, resourceType);
+ });
+ });
+
+ // Add download handlers
+ list.querySelectorAll(".download-hook-btn").forEach((btn) => {
+ btn.addEventListener("click", (e) => {
+ e.stopPropagation();
+ const hookId = (btn as HTMLElement).dataset.hookId;
+ if (hookId) downloadHook(hookId, btn as HTMLButtonElement);
+ });
+ });
+}
+
+async function downloadHook(
+ hookId: string,
+ btn: HTMLButtonElement
+): Promise {
+ const hook = allItems.find((item) => item.id === hookId);
+ if (!hook) {
+ showToast("Hook not found.", "error");
+ return;
+ }
+
+ // Build file list: README.md + all assets
+ const files = [
+ { name: "README.md", path: hook.readmeFile },
+ ...hook.assets.map((a) => ({
+ name: a,
+ path: `${hook.path}/${a}`,
+ })),
+ ];
+
+ if (files.length === 0) {
+ showToast("No files found for this hook.", "error");
+ return;
+ }
+
+ const originalContent = btn.innerHTML;
+ btn.disabled = true;
+ btn.innerHTML =
+ ' Preparing...';
+
+ try {
+ const zip = new JSZip();
+ const folder = zip.folder(hook.id);
+
+ const fetchPromises = files.map(async (file) => {
+ const url = getRawGitHubUrl(file.path);
+ try {
+ const response = await fetch(url);
+ if (!response.ok) return null;
+ const content = await response.text();
+ return { name: file.name, content };
+ } catch {
+ return null;
+ }
+ });
+
+ const results = await Promise.all(fetchPromises);
+ let addedFiles = 0;
+ for (const result of results) {
+ if (result && folder) {
+ folder.file(result.name, result.content);
+ addedFiles++;
+ }
+ }
+
+ if (addedFiles === 0) throw new Error("Failed to fetch any files");
+
+ const blob = await zip.generateAsync({ type: "blob" });
+ const downloadUrl = URL.createObjectURL(blob);
+ const link = document.createElement("a");
+ link.href = downloadUrl;
+ link.download = `${hook.id}.zip`;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ URL.revokeObjectURL(downloadUrl);
+
+ btn.innerHTML =
+ ' Downloaded!';
+ setTimeout(() => {
+ btn.disabled = false;
+ btn.innerHTML = originalContent;
+ }, 2000);
+ } catch (error) {
+ const message =
+ error instanceof Error ? error.message : "Download failed.";
+ showToast(message, "error");
+ btn.innerHTML =
+ ' Failed';
+ setTimeout(() => {
+ btn.disabled = false;
+ btn.innerHTML = originalContent;
+ }, 2000);
+ }
+}
+
+export async function initHooksPage(): Promise {
+ const list = document.getElementById("resource-list");
+ const searchInput = document.getElementById(
+ "search-input"
+ ) as HTMLInputElement;
+ const clearFiltersBtn = document.getElementById("clear-filters");
+ const sortSelect = document.getElementById(
+ "sort-select"
+ ) as HTMLSelectElement;
+
+ const data = await fetchData("hooks.json");
+ if (!data || !data.items) {
+ if (list)
+ list.innerHTML =
+ 'Failed to load data
';
+ return;
+ }
+
+ allItems = data.items;
+ search.setItems(allItems);
+
+ // Setup hook event filter
+ hookSelect = createChoices("#filter-hook", {
+ placeholderValue: "All Events",
+ });
+ hookSelect.setChoices(
+ data.filters.hooks.map((h) => ({ value: h, label: h })),
+ "value",
+ "label",
+ true
+ );
+ document.getElementById("filter-hook")?.addEventListener("change", () => {
+ currentFilters.hooks = getChoicesValues(hookSelect);
+ applyFiltersAndRender();
+ });
+
+ // Setup tag filter
+ tagSelect = createChoices("#filter-tag", {
+ placeholderValue: "All Tags",
+ });
+ tagSelect.setChoices(
+ data.filters.tags.map((t) => ({ value: t, label: t })),
+ "value",
+ "label",
+ true
+ );
+ document.getElementById("filter-tag")?.addEventListener("change", () => {
+ currentFilters.tags = getChoicesValues(tagSelect);
+ applyFiltersAndRender();
+ });
+
+ sortSelect?.addEventListener("change", () => {
+ currentSort = sortSelect.value as SortOption;
+ applyFiltersAndRender();
+ });
+
+ applyFiltersAndRender();
+ searchInput?.addEventListener(
+ "input",
+ debounce(() => applyFiltersAndRender(), 200)
+ );
+
+ clearFiltersBtn?.addEventListener("click", () => {
+ currentFilters = { hooks: [], tags: [] };
+ currentSort = "title";
+ hookSelect.removeActiveItems();
+ tagSelect.removeActiveItems();
+ if (searchInput) searchInput.value = "";
+ if (sortSelect) sortSelect.value = "title";
+ applyFiltersAndRender();
+ });
+
+ setupModal();
+}
+
+// Auto-initialize when DOM is ready
+document.addEventListener("DOMContentLoaded", initHooksPage);
diff --git a/website/src/scripts/pages/index.ts b/website/src/scripts/pages/index.ts
index 6fa86746..106425d3 100644
--- a/website/src/scripts/pages/index.ts
+++ b/website/src/scripts/pages/index.ts
@@ -11,6 +11,7 @@ interface Manifest {
prompts: number;
instructions: number;
skills: number;
+ hooks: number;
collections: number;
tools: number;
};
@@ -35,7 +36,7 @@ export async function initHomepage(): Promise {
const manifest = await fetchData('manifest.json');
if (manifest && manifest.counts) {
// Populate counts in cards
- const countKeys = ['agents', 'prompts', 'instructions', 'skills', 'collections', 'tools'] as const;
+ const countKeys = ['agents', 'prompts', 'instructions', 'skills', 'hooks', 'collections', 'tools'] as const;
countKeys.forEach(key => {
const countEl = document.querySelector(`.card-count[data-count="${key}"]`);
if (countEl && manifest.counts[key] !== undefined) {
diff --git a/website/src/scripts/utils.ts b/website/src/scripts/utils.ts
index 4e381020..1728cd5e 100644
--- a/website/src/scripts/utils.ts
+++ b/website/src/scripts/utils.ts
@@ -231,6 +231,8 @@ export function getResourceType(filePath: string): string {
if (filePath.endsWith(".instructions.md")) return "instruction";
if (filePath.includes("/skills/") && filePath.endsWith("SKILL.md"))
return "skill";
+ if (filePath.includes("/hooks/") && filePath.endsWith("README.md"))
+ return "hook";
if (filePath.endsWith(".collection.yml")) return "collection";
return "unknown";
}
@@ -244,6 +246,7 @@ export function formatResourceType(type: string): string {
prompt: "🎯 Prompt",
instruction: "📋 Instruction",
skill: "⚡ Skill",
+ hook: "🪝 Hook",
collection: "📦 Collection",
};
return labels[type] || type;
@@ -258,6 +261,7 @@ export function getResourceIcon(type: string): string {
prompt: "🎯",
instruction: "📋",
skill: "⚡",
+ hook: "🪝",
collection: "📦",
};
return icons[type] || "📄";