feat: add hooks functionality with automated workflows

- Introduced hooks to enable automated workflows triggered by specific events during GitHub Copilot sessions.
- Added documentation for hooks in AGENTS.md and README.md.
- Created a new directory structure for hooks, including README.md and hooks.json files.
- Implemented two example hooks: Session Auto-Commit and Session Logger.
- Developed scripts for logging session events and auto-committing changes.
- Enhanced validation and parsing for hook metadata.
- Updated build and validation scripts to accommodate new hooks functionality.
This commit is contained in:
Aaron Powell
2026-02-09 16:44:53 +11:00
parent d99ba71986
commit acb5ad4ce8
17 changed files with 783 additions and 66 deletions

View File

@@ -158,6 +158,8 @@ function getDisplayName(filePath, kind) {
return basename.replace(".agent.md", "");
} else if (kind === "instruction") {
return basename.replace(".instructions.md", "");
} else if (kind === "hook") {
return basename.replace(".hook.md", "");
} else if (kind === "skill") {
return path.basename(filePath);
}
@@ -221,6 +223,23 @@ 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;
const event = item.frontmatter?.event || "N/A";
lines.push(`| \`${name}\` | ${description} | ${event} |`);
}
lines.push("");
}
// Skills
const skills = items.filter((item) => item.kind === "skill");
if (skills.length > 0) {

View File

@@ -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,7 @@ 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,
COLLECTIONS_DIR,
COOKBOOK_DIR,
MAX_COLLECTION_ITEMS,
SKILL_NAME_MIN_LENGTH,
SKILL_NAME_MAX_LENGTH,
SKILL_DESCRIPTION_MIN_LENGTH,
SKILL_DESCRIPTION_MAX_LENGTH,
DOCS_DIR,
AGENTS_DIR, AKA_INSTALL_URLS, COLLECTIONS_DIR,
COOKBOOK_DIR, DOCS_DIR, HOOKS_DIR, INSTRUCTIONS_DIR, MAX_COLLECTION_ITEMS, PROMPTS_DIR, repoBaseUrl, ROOT_FOLDER, SKILL_DESCRIPTION_MAX_LENGTH, SKILL_DESCRIPTION_MIN_LENGTH, SKILL_NAME_MAX_LENGTH, SKILL_NAME_MIN_LENGTH, SKILLS_DIR, TEMPLATES, vscodeInsidersInstallImage, vscodeInstallImage
};

View File

@@ -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 {
parseCollectionYaml,
parseFrontmatter,
parseSkillMetadata,
parseYamlFile,
} from "./yaml-parser.mjs";
import { getGitFileDates } from "./utils/git-dates.mjs";
import {
parseCollectionYaml,
parseFrontmatter,
parseSkillMetadata,
parseHookMetadata,
parseYamlFile,
} from "./yaml-parser.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)

View File

@@ -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("<br />")
: "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"),

View File

@@ -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,11 @@ function validateCollectionItems(items) {
i + 1
} kind is "agent" but path doesn't end with .agent.md`;
}
if (item.kind === "hook" && !item.path.endsWith(".hook.md")) {
return `Item ${
i + 1
} kind is "hook" but path doesn't end with .hook.md`;
}
// Validate agent-specific frontmatter
if (item.kind === "agent") {
@@ -216,6 +256,14 @@ function validateCollectionItems(items) {
return agentValidation;
}
}
// Validate hook-specific frontmatter
if (item.kind === "hook") {
const hookValidation = validateHookFile(filePath, i + 1);
if (hookValidation) {
return hookValidation;
}
}
}
return null;
}

View File

@@ -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 {
extractAgentMetadata,
extractMcpServerConfigs,
extractMcpServers,
parseCollectionYaml,
parseFrontmatter,
extractAgentMetadata,
extractMcpServers,
extractMcpServerConfigs,
parseSkillMetadata,
parseHookMetadata,
parseYamlFile,
safeFileOperation,
};