diff --git a/AGENTS.md b/AGENTS.md index 1a1c4c959..4af726ccc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,7 @@ The Awesome GitHub Copilot repository is a community-driven collection of custom - **Prompts** - Task-specific prompts for code generation and problem-solving - **Instructions** - Coding standards and best practices applied to specific file patterns - **Skills** - Self-contained folders with instructions and bundled resources for specialized tasks +- **Hooks** - Automated workflows triggered by specific events during development - **Collections** - Curated collections organized around specific themes and workflows ## Repository Structure @@ -18,6 +19,7 @@ The Awesome GitHub Copilot repository is a community-driven collection of custom ├── prompts/ # Task-specific prompts (.prompt.md files) ├── instructions/ # Coding standards and guidelines (.instructions.md files) ├── skills/ # Agent Skills folders (each with SKILL.md and optional bundled assets) +├── hooks/ # Automated workflow hooks (folders with README.md + hooks.json) ├── collections/ # Curated collections of resources (.md files) ├── docs/ # Documentation for different resource types ├── eng/ # Build and automation scripts @@ -48,9 +50,9 @@ npm run skill:create -- --name ## Development Workflow -### Working with Agents, Prompts, Instructions, and Skills +### Working with Agents, Prompts, Instructions, Skills, and Hooks -All agent files (`*.agent.md`), prompt files (`*.prompt.md`), and instruction files (`*.instructions.md`) must include proper markdown front matter. Agent Skills are folders containing a `SKILL.md` file with frontmatter and optional bundled assets: +All agent files (`*.agent.md`), prompt files (`*.prompt.md`), and instruction files (`*.instructions.md`) must include proper markdown front matter. Agent Skills are folders containing a `SKILL.md` file with frontmatter and optional bundled assets. Hooks are folders containing a `README.md` with frontmatter and a `hooks.json` configuration file: #### Agent Files (*.agent.md) - Must have `description` field (wrapped in single quotes) @@ -80,9 +82,20 @@ All agent files (`*.agent.md`), prompt files (`*.prompt.md`), and instruction fi - Asset files should be reasonably sized (under 5MB per file) - Skills follow the [Agent Skills specification](https://agentskills.io/specification) +#### Hook Folders (hooks/*/README.md) +- Each hook is a folder containing a `README.md` file with frontmatter +- README.md must have `name` field (human-readable name) +- README.md must have `description` field (wrapped in single quotes, not empty) +- Must include a `hooks.json` file with hook configuration (hook events extracted from this file) +- Folder names should be lower case with words separated by hyphens +- Can include bundled assets (scripts, utilities, configuration files) +- Bundled scripts should be referenced in the README.md and hooks.json +- Follow the [GitHub Copilot hooks specification](https://docs.github.com/en/copilot/how-tos/use-copilot-agents/coding-agent/use-hooks) +- Optionally includes `tags` field for categorization + ### Adding New Resources -When adding a new agent, prompt, instruction, or skill: +When adding a new agent, prompt, instruction, skill, or hook: **For Agents, Prompts, and Instructions:** 1. Create the file with proper front matter @@ -90,6 +103,16 @@ When adding a new agent, prompt, instruction, or skill: 3. Update the README.md by running: `npm run build` 4. Verify the resource appears in the generated README +**For Hooks:** +1. Create a new folder in `hooks/` with a descriptive name +2. Create `README.md` with proper frontmatter (name, description, hooks, tags) +3. Create `hooks.json` with hook configuration following GitHub Copilot hooks spec +4. Add any bundled scripts or assets to the folder +5. Make scripts executable: `chmod +x script.sh` +6. Update the README.md by running: `npm run build` +7. Verify the hook appears in the generated README + + **For Skills:** 1. Run `npm run skill:create` to scaffold a new skill folder 2. Edit the generated SKILL.md file with your instructions @@ -186,6 +209,16 @@ For skills (skills/*/): - [ ] Any bundled assets are referenced in SKILL.md - [ ] Bundled assets are under 5MB per file +For hook folders (hooks/*/): +- [ ] Folder contains a README.md file with markdown front matter +- [ ] Has `name` field with human-readable name +- [ ] Has non-empty `description` field wrapped in single quotes +- [ ] Has `hooks.json` file with valid hook configuration (hook events extracted from this file) +- [ ] Folder name is lower case with hyphens +- [ ] Any bundled scripts are executable and referenced in README.md +- [ ] Follows [GitHub Copilot hooks specification](https://docs.github.com/en/copilot/how-tos/use-copilot-agents/coding-agent/use-hooks) +- [ ] Optionally includes `tags` array field for categorization + ## Contributing This is a community-driven project. Contributions are welcome! Please see: diff --git a/README.md b/README.md index 87b11ca75..e0e6f6f5d 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ This repository provides a comprehensive toolkit for enhancing GitHub Copilot wi - **👉 [Awesome Agents](docs/README.agents.md)** - Specialized GitHub Copilot agents that integrate with MCP servers to provide enhanced capabilities for specific workflows and tools - **👉 [Awesome Prompts](docs/README.prompts.md)** - Focused, task-specific prompts for generating code, documentation, and solving specific problems - **👉 [Awesome Instructions](docs/README.instructions.md)** - Comprehensive coding standards and best practices that apply to specific file patterns or entire projects +- **👉 [Awesome Hooks](docs/README.hooks.md)** - Automated workflows triggered by specific events during development, testing, and deployment - **👉 [Awesome Skills](docs/README.skills.md)** - Self-contained folders with instructions and bundled resources that enhance AI capabilities for specialized tasks - **👉 [Awesome Collections](docs/README.collections.md)** - Curated collections of related prompts, instructions, agents, and skills organized around specific themes and workflows - **👉 [Awesome Cookbook Recipes](cookbook/README.md)** - Practical, copy-paste-ready code snippets and real-world examples for working with GitHub Copilot tools and features @@ -96,6 +97,10 @@ Use the `/` command in GitHub Copilot Chat to access prompts: Instructions automatically apply to files based on their patterns and provide contextual guidance for coding standards, frameworks, and best practices. +### 🪝 Hooks + +Hooks enable automated workflows triggered by specific events during GitHub Copilot coding agent sessions (like sessionStart, sessionEnd, userPromptSubmitted). They can automate tasks like logging, auto-committing changes, or integrating with external services. + ## 🎯 Why Use Awesome GitHub Copilot? - **Productivity**: Pre-built agents, prompts and instructions save time and provide consistent results. @@ -107,7 +112,7 @@ Instructions automatically apply to files based on their patterns and provide co We welcome contributions! Please see our [Contributing Guidelines](CONTRIBUTING.md) for details on how to: -- Add new prompts, instructions, agents, or skills +- Add new prompts, instructions, hooks, agents, or skills - Improve existing content - Report issues or suggest enhancements @@ -148,7 +153,7 @@ The customizations in this repository are sourced from and created by third-part --- -**Ready to supercharge your coding experience?** Start exploring our [prompts](docs/README.prompts.md), [instructions](docs/README.instructions.md), and [custom agents](docs/README.agents.md)! +**Ready to supercharge your coding experience?** Start exploring our [prompts](docs/README.prompts.md), [instructions](docs/README.instructions.md), [hooks](docs/README.hooks.md), and [custom agents](docs/README.agents.md)! ## Contributors ✨ diff --git a/docs/README.hooks.md b/docs/README.hooks.md new file mode 100644 index 000000000..7fc12b045 --- /dev/null +++ b/docs/README.hooks.md @@ -0,0 +1,31 @@ +# 🪝 Hooks + +Hooks enable automated workflows triggered by specific events during GitHub Copilot coding agent sessions, such as session start, session end, user prompts, and tool usage. +### How to Use Hooks + +**What's Included:** +- Each hook is a folder containing a `README.md` file and a `hooks.json` configuration +- Hooks may include helper scripts, utilities, or other bundled assets +- Hooks follow the [GitHub Copilot hooks specification](https://docs.github.com/en/copilot/how-tos/use-copilot-agents/coding-agent/use-hooks) + +**To Install:** +- Copy the hook folder to your repository's `.github/hooks/` directory +- Ensure any bundled scripts are executable (`chmod +x script.sh`) +- Commit the hook to your repository's default branch + +**To Activate/Use:** +- Hooks automatically execute during Copilot coding agent sessions +- Configure hook events in the `hooks.json` file +- Available events: `sessionStart`, `sessionEnd`, `userPromptSubmitted`, `preToolUse`, `postToolUse`, `errorOccurred` + +**When to Use:** +- Automate session logging and audit trails +- Auto-commit changes at session end +- Track usage analytics +- Integrate with external tools and services +- Custom session workflows + +| Name | Description | Events | Bundled Assets | +| ---- | ----------- | ------ | -------------- | +| [Session Auto-Commit](../hooks/session-auto-commit/README.md) | Automatically commits and pushes changes when a Copilot coding agent session ends | sessionEnd | `auto-commit.sh`
`hooks.json` | +| [Session Logger](../hooks/session-logger/README.md) | Logs all Copilot coding agent session activity for audit and analysis | sessionStart, sessionEnd, userPromptSubmitted | `hooks.json`
`log-prompt.sh`
`log-session-end.sh`
`log-session-start.sh` | diff --git a/eng/collection-to-plugin.mjs b/eng/collection-to-plugin.mjs index 253ad0574..00099e125 100644 --- a/eng/collection-to-plugin.mjs +++ b/eng/collection-to-plugin.mjs @@ -4,7 +4,11 @@ import fs from "fs"; import path from "path"; import readline from "readline"; import { COLLECTIONS_DIR, ROOT_FOLDER } from "./constants.mjs"; -import { parseCollectionYaml, parseFrontmatter } from "./yaml-parser.mjs"; +import { + parseCollectionYaml, + parseFrontmatter, + parseHookMetadata, +} from "./yaml-parser.mjs"; const PLUGINS_DIR = path.join(ROOT_FOLDER, "plugins"); @@ -158,6 +162,12 @@ function getDisplayName(filePath, kind) { return basename.replace(".agent.md", ""); } else if (kind === "instruction") { return basename.replace(".instructions.md", ""); + } else if (kind === "hook") { + // For folder-based hooks like hooks//README.md, use the folder name. + if (basename.toLowerCase() === "readme.md") { + return path.basename(path.dirname(filePath)); + } + return basename.replace(".hook.md", ""); } else if (kind === "skill") { return path.basename(filePath); } @@ -221,6 +231,27 @@ function generateReadme(collection, items) { lines.push(""); } + // Hooks + const hooks = items.filter((item) => item.kind === "hook"); + if (hooks.length > 0) { + lines.push("### Hooks"); + lines.push(""); + lines.push("| Hook | Description | Event |"); + lines.push("|------|-------------|-------|"); + for (const item of hooks) { + const name = getDisplayName(item.path, "hook"); + const description = + item.frontmatter?.description || item.frontmatter?.name || name; + // Extract events from hooks.json rather than frontmatter + const hookFolderPath = path.join(ROOT_FOLDER, path.dirname(item.path)); + const hookMeta = parseHookMetadata(hookFolderPath); + const event = + hookMeta?.hooks?.length > 0 ? hookMeta.hooks.join(", ") : "N/A"; + lines.push(`| \`${name}\` | ${description} | ${event} |`); + } + lines.push(""); + } + // Skills const skills = items.filter((item) => item.kind === "skill"); if (skills.length > 0) { diff --git a/eng/constants.mjs b/eng/constants.mjs index 180980a20..9e7e41da2 100644 --- a/eng/constants.mjs +++ b/eng/constants.mjs @@ -1,6 +1,5 @@ -import path from "path"; +import path, { dirname } from "path"; import { fileURLToPath } from "url"; -import { dirname } from "path"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -100,6 +99,34 @@ Skills differ from other primitives by supporting bundled assets (scripts, code - Browse the skills table below to find relevant capabilities - Copy the skill folder to your local skills directory - Reference skills in your prompts or let the agent discover them automatically`, + + hooksSection: `## 🪝 Hooks + +Hooks enable automated workflows triggered by specific events during GitHub Copilot coding agent sessions, such as session start, session end, user prompts, and tool usage.`, + + hooksUsage: `### How to Use Hooks + +**What's Included:** +- Each hook is a folder containing a \`README.md\` file and a \`hooks.json\` configuration +- Hooks may include helper scripts, utilities, or other bundled assets +- Hooks follow the [GitHub Copilot hooks specification](https://docs.github.com/en/copilot/how-tos/use-copilot-agents/coding-agent/use-hooks) + +**To Install:** +- Copy the hook folder to your repository's \`.github/hooks/\` directory +- Ensure any bundled scripts are executable (\`chmod +x script.sh\`) +- Commit the hook to your repository's default branch + +**To Activate/Use:** +- Hooks automatically execute during Copilot coding agent sessions +- Configure hook events in the \`hooks.json\` file +- Available events: \`sessionStart\`, \`sessionEnd\`, \`userPromptSubmitted\`, \`preToolUse\`, \`postToolUse\`, \`errorOccurred\` + +**When to Use:** +- Automate session logging and audit trails +- Auto-commit changes at session end +- Track usage analytics +- Integrate with external tools and services +- Custom session workflows`, }; const vscodeInstallImage = @@ -115,6 +142,7 @@ const AKA_INSTALL_URLS = { instructions: "https://aka.ms/awesome-copilot/install/instructions", prompt: "https://aka.ms/awesome-copilot/install/prompt", agent: "https://aka.ms/awesome-copilot/install/agent", + hook: "https://aka.ms/awesome-copilot/install/hook", }; const ROOT_FOLDER = path.join(__dirname, ".."); @@ -122,6 +150,7 @@ const INSTRUCTIONS_DIR = path.join(ROOT_FOLDER, "instructions"); const PROMPTS_DIR = path.join(ROOT_FOLDER, "prompts"); const AGENTS_DIR = path.join(ROOT_FOLDER, "agents"); const SKILLS_DIR = path.join(ROOT_FOLDER, "skills"); +const HOOKS_DIR = path.join(ROOT_FOLDER, "hooks"); const COLLECTIONS_DIR = path.join(ROOT_FOLDER, "collections"); const COOKBOOK_DIR = path.join(ROOT_FOLDER, "cookbook"); const MAX_COLLECTION_ITEMS = 50; @@ -135,23 +164,24 @@ const SKILL_DESCRIPTION_MAX_LENGTH = 1024; const DOCS_DIR = path.join(ROOT_FOLDER, "docs"); export { - TEMPLATES, - vscodeInstallImage, - vscodeInsidersInstallImage, - repoBaseUrl, - AKA_INSTALL_URLS, - ROOT_FOLDER, - INSTRUCTIONS_DIR, - PROMPTS_DIR, AGENTS_DIR, - SKILLS_DIR, + AKA_INSTALL_URLS, COLLECTIONS_DIR, COOKBOOK_DIR, + DOCS_DIR, + HOOKS_DIR, + INSTRUCTIONS_DIR, MAX_COLLECTION_ITEMS, - SKILL_NAME_MIN_LENGTH, - SKILL_NAME_MAX_LENGTH, - SKILL_DESCRIPTION_MIN_LENGTH, + PROMPTS_DIR, + repoBaseUrl, + ROOT_FOLDER, SKILL_DESCRIPTION_MAX_LENGTH, - DOCS_DIR, + SKILL_DESCRIPTION_MIN_LENGTH, + SKILL_NAME_MAX_LENGTH, + SKILL_NAME_MIN_LENGTH, + SKILLS_DIR, + TEMPLATES, + vscodeInsidersInstallImage, + vscodeInstallImage }; diff --git a/eng/generate-website-data.mjs b/eng/generate-website-data.mjs index 48bed4b47..9b0830914 100644 --- a/eng/generate-website-data.mjs +++ b/eng/generate-website-data.mjs @@ -7,24 +7,26 @@ */ import fs from "fs"; -import path, { dirname } from "path"; +import path from "path"; import { fileURLToPath } from "url"; import { - AGENTS_DIR, - COLLECTIONS_DIR, - COOKBOOK_DIR, - INSTRUCTIONS_DIR, - PROMPTS_DIR, - ROOT_FOLDER, - SKILLS_DIR, + AGENTS_DIR, + COLLECTIONS_DIR, + COOKBOOK_DIR, + HOOKS_DIR, + INSTRUCTIONS_DIR, + PROMPTS_DIR, + ROOT_FOLDER, + SKILLS_DIR } from "./constants.mjs"; +import { getGitFileDates } from "./utils/git-dates.mjs"; import { - parseCollectionYaml, - parseFrontmatter, - parseSkillMetadata, - parseYamlFile, + parseCollectionYaml, + parseFrontmatter, + parseSkillMetadata, + parseHookMetadata, + parseYamlFile, } from "./yaml-parser.mjs"; -import { getGitFileDates } from "./utils/git-dates.mjs"; const __filename = fileURLToPath(import.meta.url); @@ -122,6 +124,75 @@ function generateAgentsData(gitDates) { }; } +/** + * Generate hooks metadata + */ +/** + * Generate hooks metadata (similar to skills - folder-based) + */ +function generateHooksData(gitDates) { + const hooks = []; + + // Check if hooks directory exists + if (!fs.existsSync(HOOKS_DIR)) { + return { + items: hooks, + filters: { + hooks: [], + tags: [], + }, + }; + } + + // Get all hook folders (directories) + const hookFolders = fs.readdirSync(HOOKS_DIR).filter((file) => { + const filePath = path.join(HOOKS_DIR, file); + return fs.statSync(filePath).isDirectory(); + }); + + // Track all unique values for filters + const allHookTypes = new Set(); + const allTags = new Set(); + + for (const folder of hookFolders) { + const hookPath = path.join(HOOKS_DIR, folder); + const metadata = parseHookMetadata(hookPath); + if (!metadata) continue; + + const relativePath = path + .relative(ROOT_FOLDER, hookPath) + .replace(/\\/g, "/"); + const readmeRelativePath = `${relativePath}/README.md`; + + // Track unique values + (metadata.hooks || []).forEach((h) => allHookTypes.add(h)); + (metadata.tags || []).forEach((t) => allTags.add(t)); + + hooks.push({ + id: folder, + title: metadata.name, + description: metadata.description, + hooks: metadata.hooks || [], + tags: metadata.tags || [], + assets: metadata.assets || [], + path: relativePath, + readmeFile: readmeRelativePath, + lastUpdated: gitDates.get(readmeRelativePath) || null, + }); + } + + // Sort and return with filter metadata + const sortedHooks = hooks.sort((a, b) => a.title.localeCompare(b.title)); + + return { + items: sortedHooks, + filters: { + hooks: Array.from(allHookTypes).sort(), + tags: Array.from(allTags).sort(), + }, + }; +} + /** * Generate prompts metadata */ @@ -539,6 +610,7 @@ function generateSearchIndex( agents, prompts, instructions, + hooks, skills, collections ) { @@ -584,6 +656,20 @@ function generateSearchIndex( }); } + for (const hook of hooks) { + index.push({ + type: "hook", + id: hook.id, + title: hook.title, + description: hook.description, + path: hook.readmeFile, + lastUpdated: hook.lastUpdated, + searchText: `${hook.title} ${hook.description} ${hook.hooks.join( + " " + )} ${hook.tags.join(" ")}`.toLowerCase(), + }); + } + for (const skill of skills) { index.push({ type: "skill", @@ -720,7 +806,7 @@ async function main() { // Load git dates for all resource files (single efficient git command) console.log("Loading git history for last updated dates..."); const gitDates = getGitFileDates( - ["agents/", "prompts/", "instructions/", "skills/", "collections/"], + ["agents/", "prompts/", "instructions/", "hooks/", "skills/", "collections/"], ROOT_FOLDER ); console.log(`✓ Loaded dates for ${gitDates.size} files\n`); @@ -732,6 +818,12 @@ async function main() { `✓ Generated ${agents.length} agents (${agentsData.filters.models.length} models, ${agentsData.filters.tools.length} tools)` ); + const hooksData = generateHooksData(gitDates); + const hooks = hooksData.items; + console.log( + `✓ Generated ${hooks.length} hooks (${hooksData.filters.hooks.length} hook types, ${hooksData.filters.tags.length} tags)` + ); + const promptsData = generatePromptsData(gitDates); const prompts = promptsData.items; console.log( @@ -771,6 +863,7 @@ async function main() { agents, prompts, instructions, + hooks, skills, collections ); @@ -782,6 +875,11 @@ async function main() { JSON.stringify(agentsData, null, 2) ); + fs.writeFileSync( + path.join(WEBSITE_DATA_DIR, "hooks.json"), + JSON.stringify(hooksData, null, 2) + ); + fs.writeFileSync( path.join(WEBSITE_DATA_DIR, "prompts.json"), JSON.stringify(promptsData, null, 2) @@ -825,6 +923,7 @@ async function main() { prompts: prompts.length, instructions: instructions.length, skills: skills.length, + hooks: hooks.length, collections: collections.length, tools: tools.length, samples: samplesData.totalRecipes, diff --git a/eng/update-readme.mjs b/eng/update-readme.mjs index 99d4268b6..a86f15c1a 100644 --- a/eng/update-readme.mjs +++ b/eng/update-readme.mjs @@ -4,24 +4,26 @@ import fs from "fs"; import path, { dirname } from "path"; import { fileURLToPath } from "url"; import { - AGENTS_DIR, - AKA_INSTALL_URLS, - COLLECTIONS_DIR, - DOCS_DIR, - INSTRUCTIONS_DIR, - PROMPTS_DIR, - repoBaseUrl, - ROOT_FOLDER, - SKILLS_DIR, - TEMPLATES, - vscodeInsidersInstallImage, - vscodeInstallImage, + AGENTS_DIR, + AKA_INSTALL_URLS, + COLLECTIONS_DIR, + DOCS_DIR, + HOOKS_DIR, + INSTRUCTIONS_DIR, + PROMPTS_DIR, + repoBaseUrl, + ROOT_FOLDER, + SKILLS_DIR, + TEMPLATES, + vscodeInsidersInstallImage, + vscodeInstallImage, } from "./constants.mjs"; import { - extractMcpServerConfigs, - parseCollectionYaml, - parseFrontmatter, - parseSkillMetadata, + extractMcpServerConfigs, + parseCollectionYaml, + parseFrontmatter, + parseSkillMetadata, + parseHookMetadata, } from "./yaml-parser.mjs"; const __filename = fileURLToPath(import.meta.url); @@ -515,6 +517,67 @@ function generateAgentsSection(agentsDir, registryNames = []) { }); } +/** + * Generate the hooks section with a table of all hooks + */ +function generateHooksSection(hooksDir) { + if (!fs.existsSync(hooksDir)) { + console.log(`Hooks directory does not exist: ${hooksDir}`); + return ""; + } + + // Get all hook folders (directories) + const hookFolders = fs.readdirSync(hooksDir).filter((file) => { + const filePath = path.join(hooksDir, file); + return fs.statSync(filePath).isDirectory(); + }); + + // Parse each hook folder + const hookEntries = hookFolders + .map((folder) => { + const hookPath = path.join(hooksDir, folder); + const metadata = parseHookMetadata(hookPath); + if (!metadata) return null; + + return { + folder, + name: metadata.name, + description: metadata.description, + hooks: metadata.hooks, + tags: metadata.tags, + assets: metadata.assets, + }; + }) + .filter((entry) => entry !== null) + .sort((a, b) => a.name.localeCompare(b.name)); + + console.log(`Found ${hookEntries.length} hook(s)`); + + if (hookEntries.length === 0) { + return ""; + } + + // Create table header + let content = + "| Name | Description | Events | Bundled Assets |\n| ---- | ----------- | ------ | -------------- |\n"; + + // Generate table rows for each hook + for (const hook of hookEntries) { + const link = `../hooks/${hook.folder}/README.md`; + const events = hook.hooks.length > 0 ? hook.hooks.join(", ") : "N/A"; + const assetsList = + hook.assets.length > 0 + ? hook.assets.map((a) => `\`${a}\``).join("
") + : "None"; + + content += `| [${hook.name}](${link}) | ${formatTableCell( + hook.description + )} | ${events} | ${assetsList} |\n`; + } + + return `${TEMPLATES.hooksSection}\n${TEMPLATES.hooksUsage}\n\n${content}`; +} + /** * Generate the skills section with a table of all skills */ @@ -1002,6 +1065,7 @@ async function main() { ); const promptsHeader = TEMPLATES.promptsSection.replace(/^##\s/m, "# "); const agentsHeader = TEMPLATES.agentsSection.replace(/^##\s/m, "# "); + const hooksHeader = TEMPLATES.hooksSection.replace(/^##\s/m, "# "); const skillsHeader = TEMPLATES.skillsSection.replace(/^##\s/m, "# "); const collectionsHeader = TEMPLATES.collectionsSection.replace( /^##\s/m, @@ -1031,6 +1095,15 @@ async function main() { registryNames ); + // Generate hooks README + const hooksReadme = buildCategoryReadme( + generateHooksSection, + HOOKS_DIR, + hooksHeader, + TEMPLATES.hooksUsage, + registryNames + ); + // Generate skills README const skillsReadme = buildCategoryReadme( generateSkillsSection, @@ -1061,6 +1134,7 @@ async function main() { ); writeFileIfChanged(path.join(DOCS_DIR, "README.prompts.md"), promptsReadme); writeFileIfChanged(path.join(DOCS_DIR, "README.agents.md"), agentsReadme); + writeFileIfChanged(path.join(DOCS_DIR, "README.hooks.md"), hooksReadme); writeFileIfChanged(path.join(DOCS_DIR, "README.skills.md"), skillsReadme); writeFileIfChanged( path.join(DOCS_DIR, "README.collections.md"), diff --git a/eng/validate-collections.mjs b/eng/validate-collections.mjs index e85c1b870..bc20f2339 100644 --- a/eng/validate-collections.mjs +++ b/eng/validate-collections.mjs @@ -3,9 +3,9 @@ import fs from "fs"; import path from "path"; import { - COLLECTIONS_DIR, - MAX_COLLECTION_ITEMS, - ROOT_FOLDER, + COLLECTIONS_DIR, + MAX_COLLECTION_ITEMS, + ROOT_FOLDER, } from "./constants.mjs"; import { parseCollectionYaml, parseFrontmatter } from "./yaml-parser.mjs"; @@ -155,6 +155,41 @@ function validateAgentFile(filePath) { } } +function validateHookFile(filePath) { + try { + const hook = parseFrontmatter(filePath); + + if (!hook) { + return `Item ${filePath} hook file could not be parsed`; + } + + // Validate name field + if (!hook.name || typeof hook.name !== "string") { + return `Item ${filePath} hook must have a 'name' field`; + } + if (hook.name.length < 1 || hook.name.length > 50) { + return `Item ${filePath} hook name must be between 1 and 50 characters`; + } + + // Validate description field + if (!hook.description || typeof hook.description !== "string") { + return `Item ${filePath} hook must have a 'description' field`; + } + if (hook.description.length < 1 || hook.description.length > 500) { + return `Item ${filePath} hook description must be between 1 and 500 characters`; + } + + // Validate event field (optional but recommended) + if (hook.event !== undefined && typeof hook.event !== "string") { + return `Item ${filePath} hook 'event' must be a string`; + } + + return null; // All validations passed + } catch (error) { + return `Item ${filePath} hook file validation failed: ${error.message}`; + } +} + function validateCollectionItems(items) { if (!items || !Array.isArray(items)) { return "Items is required and must be an array"; @@ -177,10 +212,10 @@ function validateCollectionItems(items) { if (!item.kind || typeof item.kind !== "string") { return `Item ${i + 1} must have a kind string`; } - if (!["prompt", "instruction", "agent", "skill"].includes(item.kind)) { + if (!["prompt", "instruction", "agent", "skill", "hook"].includes(item.kind)) { return `Item ${ i + 1 - } kind must be one of: prompt, instruction, agent, skill`; + } kind must be one of: prompt, instruction, agent, skill, hook`; } // Validate file path exists @@ -208,6 +243,15 @@ function validateCollectionItems(items) { i + 1 } kind is "agent" but path doesn't end with .agent.md`; } + if (item.kind === "hook") { + const isValidHookPath = + item.path.startsWith("hooks/") && item.path.endsWith("/README.md"); + if (!isValidHookPath) { + return `Item ${ + i + 1 + } kind is "hook" but path must be hooks//README.md`; + } + } // Validate agent-specific frontmatter if (item.kind === "agent") { @@ -216,6 +260,14 @@ function validateCollectionItems(items) { return agentValidation; } } + + // Validate hook-specific frontmatter + if (item.kind === "hook") { + const hookValidation = validateHookFile(filePath); + if (hookValidation) { + return hookValidation; + } + } } return null; } diff --git a/eng/yaml-parser.mjs b/eng/yaml-parser.mjs index 822a80679..58eb3c455 100644 --- a/eng/yaml-parser.mjs +++ b/eng/yaml-parser.mjs @@ -1,7 +1,7 @@ // YAML parser for collection files and frontmatter parsing using vfile-matter import fs from "fs"; -import path from "path"; import yaml from "js-yaml"; +import path from "path"; import { VFile } from "vfile"; import { matter } from "vfile-matter"; @@ -173,7 +173,7 @@ function parseSkillMetadata(skillPath) { const relativePath = path.relative(skillPath, filePath); if (relativePath !== "SKILL.md") { // Normalize path separators to forward slashes for cross-platform consistency - arrayOfFiles.push(relativePath.replace(/\\/g, '/')); + arrayOfFiles.push(relativePath.replace(/\\/g, "/")); } } }); @@ -195,6 +195,83 @@ function parseSkillMetadata(skillPath) { ); } +/** + * Parse hook metadata from a hook folder (similar to skills) + * @param {string} hookPath - Path to the hook folder + * @returns {object|null} Hook metadata or null on error + */ +function parseHookMetadata(hookPath) { + return safeFileOperation( + () => { + const readmeFile = path.join(hookPath, "README.md"); + if (!fs.existsSync(readmeFile)) { + return null; + } + + const frontmatter = parseFrontmatter(readmeFile); + + // Validate required fields + if (!frontmatter?.name || !frontmatter?.description) { + console.warn( + `Invalid hook at ${hookPath}: missing name or description in frontmatter` + ); + return null; + } + + // Extract hook events from hooks.json if it exists + let hookEvents = []; + const hooksJsonPath = path.join(hookPath, "hooks.json"); + if (fs.existsSync(hooksJsonPath)) { + try { + const hooksJsonContent = fs.readFileSync(hooksJsonPath, "utf8"); + const hooksConfig = JSON.parse(hooksJsonContent); + // Extract all hook event names from the hooks object + if (hooksConfig.hooks && typeof hooksConfig.hooks === "object") { + hookEvents = Object.keys(hooksConfig.hooks); + } + } catch (error) { + console.warn( + `Failed to parse hooks.json at ${hookPath}: ${error.message}` + ); + } + } + + // List bundled assets (all files except README.md), recursing through subdirectories + const getAllFiles = (dirPath, arrayOfFiles = []) => { + const files = fs.readdirSync(dirPath); + + files.forEach((file) => { + const filePath = path.join(dirPath, file); + if (fs.statSync(filePath).isDirectory()) { + arrayOfFiles = getAllFiles(filePath, arrayOfFiles); + } else { + const relativePath = path.relative(hookPath, filePath); + if (relativePath !== "README.md") { + // Normalize path separators to forward slashes for cross-platform consistency + arrayOfFiles.push(relativePath.replace(/\\/g, "/")); + } + } + }); + + return arrayOfFiles; + }; + + const assets = getAllFiles(hookPath).sort(); + + return { + name: frontmatter.name, + description: frontmatter.description, + hooks: hookEvents, + tags: frontmatter.tags || [], + assets, + path: hookPath, + }; + }, + hookPath, + null + ); +} + /** * Parse a generic YAML file (used for tools.yml and other config files) * @param {string} filePath - Path to the YAML file @@ -212,12 +289,13 @@ function parseYamlFile(filePath) { } export { - parseCollectionYaml, - parseFrontmatter, extractAgentMetadata, - extractMcpServers, extractMcpServerConfigs, + extractMcpServers, + parseCollectionYaml, + parseFrontmatter, parseSkillMetadata, + parseHookMetadata, parseYamlFile, safeFileOperation, }; diff --git a/hooks/session-auto-commit/README.md b/hooks/session-auto-commit/README.md new file mode 100644 index 000000000..826f5949c --- /dev/null +++ b/hooks/session-auto-commit/README.md @@ -0,0 +1,90 @@ +--- +name: 'Session Auto-Commit' +description: 'Automatically commits and pushes changes when a Copilot coding agent session ends' +tags: ['automation', 'git', 'productivity'] +--- + +# Session Auto-Commit Hook + +Automatically commits and pushes changes when a GitHub Copilot coding agent session ends, ensuring your work is always saved and backed up. + +## Overview + +This hook runs at the end of each Copilot coding agent session and automatically: +- Detects if there are uncommitted changes +- Stages all changes +- Creates a timestamped commit +- Pushes to the remote repository + +## Features + +- **Automatic Backup**: Never lose work from a Copilot session +- **Timestamped Commits**: Each auto-commit includes the session end time +- **Safe Execution**: Only commits when there are actual changes +- **Error Handling**: Gracefully handles push failures + +## Installation + +1. Copy this hook folder to your repository's `.github/hooks/` directory: + ```bash + cp -r hooks/session-auto-commit .github/hooks/ + ``` + +2. Ensure the script is executable: + ```bash + chmod +x .github/hooks/session-auto-commit/auto-commit.sh + ``` + +3. Commit the hook configuration to your repository's default branch + +## Configuration + +The hook is configured in `hooks.json` to run on the `sessionEnd` event: + +```json +{ + "version": 1, + "hooks": { + "sessionEnd": [ + { + "type": "command", + "bash": ".github/hooks/session-auto-commit/auto-commit.sh", + "timeoutSec": 30 + } + ] + } +} +``` + +## How It Works + +1. When a Copilot coding agent session ends, the hook executes +2. Checks if inside a Git repository +3. Detects uncommitted changes using `git status` +4. Stages all changes with `git add -A` +5. Creates a commit with format: `auto-commit: YYYY-MM-DD HH:MM:SS` +6. Attempts to push to remote +7. Reports success or failure + +## Customization + +You can customize the hook by modifying `auto-commit.sh`: + +- **Commit Message Format**: Change the timestamp format or message prefix +- **Selective Staging**: Use specific git add patterns instead of `-A` +- **Branch Selection**: Push to specific branches only +- **Notifications**: Add desktop notifications or Slack messages + +## Disabling + +To temporarily disable auto-commits: + +1. Remove or comment out the `sessionEnd` hook in `hooks.json` +2. Or set an environment variable: `export SKIP_AUTO_COMMIT=true` + +## Notes + +- The hook uses `--no-verify` to avoid triggering pre-commit hooks +- Failed pushes won't block session termination +- Requires appropriate git credentials configured +- Works with both Copilot coding agent and GitHub Copilot CLI diff --git a/hooks/session-auto-commit/auto-commit.sh b/hooks/session-auto-commit/auto-commit.sh new file mode 100755 index 000000000..a0facc334 --- /dev/null +++ b/hooks/session-auto-commit/auto-commit.sh @@ -0,0 +1,45 @@ +#!/bin/bash + +# Session Auto-Commit Hook +# Automatically commits and pushes changes when a Copilot session ends + +set -euo pipefail + +# Check if SKIP_AUTO_COMMIT is set +if [[ "${SKIP_AUTO_COMMIT:-}" == "true" ]]; then + echo "⏭️ Auto-commit skipped (SKIP_AUTO_COMMIT=true)" + exit 0 +fi + +# Check if we're in a git repository +if ! git rev-parse --is-inside-work-tree &>/dev/null; then + echo "⚠️ Not in a git repository" + exit 0 +fi + +# Check for uncommitted changes +if [[ -z "$(git status --porcelain)" ]]; then + echo "✨ No changes to commit" + exit 0 +fi + +echo "📦 Auto-committing changes from Copilot session..." + +# Stage all changes +git add -A + +# Create timestamped commit +TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S') +git commit -m "auto-commit: $TIMESTAMP" --no-verify 2>/dev/null || { + echo "⚠️ Commit failed" + exit 0 +} + +# Attempt to push +if git push 2>/dev/null; then + echo "✅ Changes committed and pushed successfully" +else + echo "⚠️ Push failed - changes committed locally" +fi + +exit 0 diff --git a/hooks/session-auto-commit/hooks.json b/hooks/session-auto-commit/hooks.json new file mode 100644 index 000000000..bcb18d39e --- /dev/null +++ b/hooks/session-auto-commit/hooks.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "hooks": { + "sessionEnd": [ + { + "type": "command", + "bash": ".github/hooks/session-auto-commit/auto-commit.sh", + "timeoutSec": 30 + } + ] + } +} diff --git a/hooks/session-logger/README.md b/hooks/session-logger/README.md new file mode 100644 index 000000000..3d544341a --- /dev/null +++ b/hooks/session-logger/README.md @@ -0,0 +1,58 @@ +--- +name: 'Session Logger' +description: 'Logs all Copilot coding agent session activity for audit and analysis' +tags: ['logging', 'audit', 'analytics'] +--- + +# Session Logger Hook + +Comprehensive logging for GitHub Copilot coding agent sessions, tracking session starts, ends, and user prompts for audit trails and usage analytics. + +## Overview + +This hook provides detailed logging of Copilot coding agent activity: +- Session start/end times with working directory context +- User prompt submission events +- Configurable log levels + +## Features + +- **Session Tracking**: Log session start and end events +- **Prompt Logging**: Record when user prompts are submitted +- **Structured Logging**: JSON format for easy parsing +- **Privacy Aware**: Configurable to disable logging entirely + +## Installation + +1. Copy this hook folder to your repository's `.github/hooks/` directory: + ```bash + cp -r hooks/session-logger .github/hooks/ + ``` + +2. Create the logs directory: + ```bash + mkdir -p logs/copilot + ``` + +3. Ensure scripts are executable: + ```bash + chmod +x .github/hooks/session-logger/*.sh + ``` + +4. Commit the hook configuration to your repository's default branch + +## Log Format + +Session events are written to `logs/copilot/session.log` and prompt events to `logs/copilot/prompts.log` in JSON format: + +```json +{"timestamp":"2024-01-15T10:30:00Z","event":"sessionStart","cwd":"/workspace/project"} +{"timestamp":"2024-01-15T10:35:00Z","event":"sessionEnd"} +``` + +## Privacy & Security + +- Add `logs/` to `.gitignore` to avoid committing session data +- Use `LOG_LEVEL=ERROR` to only log errors +- Set `SKIP_LOGGING=true` environment variable to disable +- Logs are stored locally only diff --git a/hooks/session-logger/hooks.json b/hooks/session-logger/hooks.json new file mode 100644 index 000000000..c4964d2ad --- /dev/null +++ b/hooks/session-logger/hooks.json @@ -0,0 +1,32 @@ +{ + "version": 1, + "hooks": { + "sessionStart": [ + { + "type": "command", + "bash": ".github/hooks/session-logger/log-session-start.sh", + "cwd": ".", + "timeoutSec": 5 + } + ], + "sessionEnd": [ + { + "type": "command", + "bash": ".github/hooks/session-logger/log-session-end.sh", + "cwd": ".", + "timeoutSec": 5 + } + ], + "userPromptSubmitted": [ + { + "type": "command", + "bash": ".github/hooks/session-logger/log-prompt.sh", + "cwd": ".", + "env": { + "LOG_LEVEL": "INFO" + }, + "timeoutSec": 5 + } + ] + } +} diff --git a/hooks/session-logger/log-prompt.sh b/hooks/session-logger/log-prompt.sh new file mode 100755 index 000000000..a4f499e45 --- /dev/null +++ b/hooks/session-logger/log-prompt.sh @@ -0,0 +1,24 @@ +#!/bin/bash + +# Log user prompt submission + +set -euo pipefail + +# Skip if logging disabled +if [[ "${SKIP_LOGGING:-}" == "true" ]]; then + exit 0 +fi + +# Read input from Copilot (contains prompt info) +INPUT=$(cat) + +# Create logs directory if it doesn't exist +mkdir -p logs/copilot + +# Extract timestamp +TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + +# Log prompt (you can parse INPUT for more details) +echo "{\"timestamp\":\"$TIMESTAMP\",\"event\":\"userPromptSubmitted\",\"level\":\"${LOG_LEVEL:-INFO}\"}" >> logs/copilot/prompts.log + +exit 0 diff --git a/hooks/session-logger/log-session-end.sh b/hooks/session-logger/log-session-end.sh new file mode 100755 index 000000000..d230a77bb --- /dev/null +++ b/hooks/session-logger/log-session-end.sh @@ -0,0 +1,25 @@ +#!/bin/bash + +# Log session end event + +set -euo pipefail + +# Skip if logging disabled +if [[ "${SKIP_LOGGING:-}" == "true" ]]; then + exit 0 +fi + +# Read input from Copilot +INPUT=$(cat) + +# Create logs directory if it doesn't exist +mkdir -p logs/copilot + +# Extract timestamp +TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + +# Log session end +echo "{\"timestamp\":\"$TIMESTAMP\",\"event\":\"sessionEnd\"}" >> logs/copilot/session.log + +echo "📝 Session end logged" +exit 0 diff --git a/hooks/session-logger/log-session-start.sh b/hooks/session-logger/log-session-start.sh new file mode 100755 index 000000000..64dd0deb2 --- /dev/null +++ b/hooks/session-logger/log-session-start.sh @@ -0,0 +1,26 @@ +#!/bin/bash + +# Log session start event + +set -euo pipefail + +# Skip if logging disabled +if [[ "${SKIP_LOGGING:-}" == "true" ]]; then + exit 0 +fi + +# Read input from Copilot +INPUT=$(cat) + +# Create logs directory if it doesn't exist +mkdir -p logs/copilot + +# Extract timestamp and session info +TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ") +CWD=$(pwd) + +# Log session start (use jq for proper JSON encoding) +jq -Rn --arg timestamp "$TIMESTAMP" --arg cwd "$CWD" '{"timestamp":$timestamp,"event":"sessionStart","cwd":$cwd}' >> logs/copilot/session.log + +echo "📝 Session logged" +exit 0 diff --git a/website/src/layouts/BaseLayout.astro b/website/src/layouts/BaseLayout.astro index 74ebf2019..e54c7077e 100644 --- a/website/src/layouts/BaseLayout.astro +++ b/website/src/layouts/BaseLayout.astro @@ -86,6 +86,10 @@ try { href={`${base}skills/`} class:list={[{ active: activeNav === "skills" }]}>Skills + Hooks +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+
+
Loading hooks...
+
+
+
+
+ + + + + diff --git a/website/src/pages/index.astro b/website/src/pages/index.astro index 64f1f0d78..d3d39050e 100644 --- a/website/src/pages/index.astro +++ b/website/src/pages/index.astro @@ -57,6 +57,14 @@ const base = import.meta.env.BASE_URL;
-
+ + +
+

Hooks

+

Automated workflows triggered by agent events

+
+
-
+
diff --git a/website/src/scripts/pages/hooks.ts b/website/src/scripts/pages/hooks.ts new file mode 100644 index 000000000..2373c80cc --- /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) => ` +
+ ` + ) + .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 6fa86746c..106425d3b 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 4e381020c..6be977a03 100644 --- a/website/src/scripts/utils.ts +++ b/website/src/scripts/utils.ts @@ -229,8 +229,10 @@ export function getResourceType(filePath: string): string { if (filePath.endsWith(".agent.md")) return "agent"; if (filePath.endsWith(".prompt.md")) return "prompt"; if (filePath.endsWith(".instructions.md")) return "instruction"; - if (filePath.includes("/skills/") && filePath.endsWith("SKILL.md")) + if (/(^|\/)skills\//.test(filePath) && filePath.endsWith("SKILL.md")) return "skill"; + if (/(^|\/)hooks\//.test(filePath) && 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] || "📄"; @@ -499,5 +503,7 @@ export function getLastUpdatedHtml(isoDate: string | null | undefined): string { return `Updated: Unknown`; } - return `Updated ${relativeTime}`; + return `Updated ${relativeTime}`; }