{"id":51169550,"url":"https://github.com/austenstone/copilot-hooks-ts","last_synced_at":"2026-06-26T23:03:33.139Z","repository":{"id":365073667,"uuid":"1270421264","full_name":"austenstone/copilot-hooks-ts","owner":"austenstone","description":"Type-safe GitHub Copilot hooks. Parse the stdin payload, build the decision, read the transcript. Typed against @github/copilot-sdk.","archived":false,"fork":false,"pushed_at":"2026-06-15T18:04:40.000Z","size":86,"stargazers_count":0,"open_issues_count":1,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-15T20:04:52.427Z","etag":null,"topics":["copilot-cli","github-copilot","hooks","typescript","zod"],"latest_commit_sha":null,"homepage":"","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/austenstone.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null},"funding":{"github":["austenstone"]}},"created_at":"2026-06-15T17:40:17.000Z","updated_at":"2026-06-15T18:02:12.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/austenstone/copilot-hooks-ts","commit_stats":null,"previous_names":["austenstone/copilot-hooks-ts"],"tags_count":null,"template":false,"template_full_name":null,"purl":"pkg:github/austenstone/copilot-hooks-ts","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/austenstone%2Fcopilot-hooks-ts","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/austenstone%2Fcopilot-hooks-ts/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/austenstone%2Fcopilot-hooks-ts/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/austenstone%2Fcopilot-hooks-ts/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/austenstone","download_url":"https://codeload.github.com/austenstone/copilot-hooks-ts/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/austenstone%2Fcopilot-hooks-ts/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34835782,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-26T02:00:06.560Z","response_time":106,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["copilot-cli","github-copilot","hooks","typescript","zod"],"created_at":"2026-06-26T23:03:28.016Z","updated_at":"2026-06-26T23:03:33.130Z","avatar_url":"https://github.com/austenstone.png","language":"TypeScript","funding_links":["https://github.com/sponsors/austenstone"],"categories":[],"sub_categories":[],"readme":"# copilot-hooks-ts\n\n[![CI](https://github.com/austenstone/copilot-hooks-ts/actions/workflows/ci.yml/badge.svg)](https://github.com/austenstone/copilot-hooks-ts/actions/workflows/ci.yml)\n[![npm](https://img.shields.io/npm/v/copilot-hooks-ts.svg)](https://www.npmjs.com/package/copilot-hooks-ts)\n\nType-safe **GitHub Copilot CLI hooks**. Write a handler, return a decision, done. The library handles the messy wire format (stdin parsing, event inference, dialect detection, zod validation) so you don't.\n\n```bash\nnpm i copilot-hooks-ts\n```\n\n## Quick start\n\nWrite a hook. Here's a guardrail that blocks dangerous shell commands before they run, plus context injected once when the session starts:\n\n```ts\n// my-hook.ts\nimport { execSync } from \"node:child_process\";\nimport {\n  runHooks,\n  denyTool,\n  injectContext,\n  continueAgent,\n  loadTranscript,\n} from \"copilot-hooks-ts\";\n\nconst BLOCKED = [/rm\\s+-rf\\s+\\//, /git\\s+push\\s+.*--force/, /\\.env\\b/];\n\nrunHooks({\n  // Guardrail: deny a tool call before it runs. `bash` types `toolInput.command`.\n  preToolUse: {\n    bash({ toolInput }) {\n      const hit = BLOCKED.find((re) =\u003e re.test(toolInput.command ?? \"\"));\n      if (hit) return denyTool(`blocked by guardrail: ${hit}`);\n    },\n  },\n\n  // Context injection: hand the model project context once, at session start.\n  sessionStart() {\n    const branch = execSync(\"git branch --show-current\").toString().trim();\n    return injectContext(`Current branch: ${branch}. House rule: no force pushes.`);\n  },\n\n  // Stop gate: if the user said \"call me X\", don't stop until the agent\n  // actually used the name. Return continueAgent to keep going; nothing = allow.\n  async agentStop(input) {\n    const text = JSON.stringify(await loadTranscript(input.transcriptPath!));\n    const name = text.match(/call me (\\w+)/i)?.[1];\n    if (name \u0026\u0026 text.split(name).length - 1 \u003c 2) {\n      return continueAgent(`The user asked to be called ${name}. Address them by name.`);\n    }\n  },\n});\n```\n\nWire it in `hooks.json` (`command` is a full shell string):\n\n```jsonc\n{\n  \"preToolUse\":   [{ \"command\": \"npx tsx my-hook.ts\" }],\n  \"sessionStart\": [{ \"command\": \"npx tsx my-hook.ts\" }],\n  \"agentStop\":    [{ \"command\": \"npx tsx my-hook.ts\" }]\n}\n```\n\nThat's it. Return nothing to allow, return a builder (`denyTool`, `injectContext`, ...) to act. `preToolUse` and `permissionRequest` are **fail-closed**: if your handler throws, an explicit deny is emitted so a bug can't silently allow a gated action.\n\n\u003e ESM-only, Node 18+. `@github/copilot-sdk` is an optional, type-only peer dep with zero runtime weight.\n\n## Test your hooks\n\n`testHook` runs a handler through the real dispatch path and returns the parsed decision. Works with any test runner (Vitest, Jest, `node:test`).\n\n```ts\nimport { testHook } from \"copilot-hooks-ts\";\nimport { handlers } from \"./my-hook.js\";\n\nconst out = await testHook(handlers, {\n  event: \"preToolUse\",\n  toolInput: { command: \"rm -rf /\" },\n});\n// out -\u003e { permissionDecision: \"deny\", permissionDecisionReason: \"...\" }\n// undefined when the hook allowed / no-op'd\n```\n\nOnly `event` is required. Pass `toolInput` and it's encoded to the right wire field for you.\n\n## Reading the transcript\n\n`agentStop` payloads carry a `transcriptPath` to the session's `events.jsonl`. The typed reader makes it easy to gate on what actually happened:\n\n```ts\nimport { loadTranscript, successfulToolCalls, skillNames } from \"copilot-hooks-ts\";\n\nconst events = await loadTranscript(input.transcriptPath!);\nsuccessfulToolCalls(events); // tool calls that succeeded\nskillNames(events);          // skills invoked this session\n```\n\nSee the [heartbeat example](./examples/heartbeat) for a stateless `agentStop` coverage gate built on this.\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003cb\u003eThe 14 events\u003c/b\u003e\u003c/summary\u003e\n\n| `hooks.json` key | VS Code alias | Fires when | Consumed output |\n| --- | --- | --- | --- |\n| `sessionStart` | `SessionStart` | a session begins | `injectContext` |\n| `sessionEnd` | `SessionEnd` | a session ends | *(ignored)* |\n| `userPromptSubmitted` | `UserPromptSubmit` | the user sends a prompt | `injectContext` / `modifyPrompt` / `blockPrompt` / `respond` |\n| `preToolUse` | `PreToolUse` | before a tool runs (fail-closed) | `allowTool` / `denyTool` / `askTool` / `modifyToolArgs` / `injectContext` |\n| `preMcpToolCall` | *(native only)* | before an MCP tool call | `setMcpMeta` |\n| `postToolUse` | `PostToolUse` | after a tool succeeds | `blockToolResult` / `modifyToolResult` / `injectContext` / `suppressOutput` |\n| `postToolUseFailure` | `PostToolUseFailure` | after a tool errors | `injectContext` |\n| `errorOccurred` | `ErrorOccurred` | an error is raised | *(ignored)* |\n| `agentStop` | `Stop` | the agent is about to stop | `continueAgent` |\n| `subagentStop` | `SubagentStop` | a subagent is about to stop | `continueAgent` |\n| `subagentStart` | *(native only)* | a subagent starts | `injectContext` |\n| `preCompact` | `PreCompact` | before transcript compaction | *(ignored)* |\n| `permissionRequest` | *(native only)* | a permission prompt (fail-closed) | `allowPermission` / `denyPermission` |\n| `notification` | *(native only)* | a user-facing notification | `injectContext` |\n\nEach handler gets a fully-typed, discriminated input. `input.event` narrows the shape, `input.dialect` tells you `\"native\"` vs `\"vscode\"`. Both wire dialects (camelCase native, PascalCase VS Code) auto-detect and normalize to one canonical shape.\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003cb\u003eTool-scoped hooks\u003c/b\u003e\u003c/summary\u003e\n\nThe five tool events (`preToolUse`, `postToolUse`, `postToolUseFailure`, `preMcpToolCall`, `permissionRequest`) accept a map keyed by tool name. The matched key narrows `input.toolInput` to that tool's shape, and `default` catches the rest:\n\n```ts\nrunHooks({\n  preToolUse: {\n    bash({ toolInput }) {\n      if (toolInput.command.includes(\"rm -rf /\")) return denyTool(\"nope\");\n    },\n    view({ toolInput }) {\n      // toolInput: { path: string; view_range?: number[] }\n    },\n    default({ toolName }) {},\n  },\n});\n```\n\nBuilt-in shapes ship for `bash` / `powershell` / `local_shell`, `view`, `create`, `edit`, `str_replace_editor`, `glob`, and `grep`. Use `onTool\u003c\"preToolUse\"\u003e({ ... })` for a standalone handler.\n\nType your own (or MCP) tools via declaration merging:\n\n```ts\ndeclare module \"copilot-hooks-ts\" {\n  interface ToolSchema {\n    \"mcp__deepwiki__ask_question\": {\n      input: { question: string; repoName: string };\n    };\n  }\n}\n```\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003cb\u003eAPI reference\u003c/b\u003e\u003c/summary\u003e\n\n- **Run / input**: `runHooks`, `readHookInput`, `parseHookInput`, `parseToolArgs`, `HookParseError`\n- **Output builders**: `injectContext`, `allowTool`, `denyTool`, `askTool`, `modifyToolArgs`, `setMcpMeta`, `blockPrompt`, `modifyPrompt`, `respond`, `blockToolResult`, `modifyToolResult`, `continueAgent`, `allowPermission`, `denyPermission`, `suppressOutput`, `emit`\n- **Tool-scoped**: `onTool`, the augmentable `ToolSchema`, and types `ToolName`, `ToolInputOf\u003cName\u003e`, `ToolScopedInput\u003cE, Name\u003e`, `ToolHandlerMap\u003cE\u003e`, `ToolEvent`, plus input shapes (`ShellInput`, `ViewInput`, `CreateInput`, `StrReplaceInput`, `InsertInput`, `GlobInput`, `GrepInput`)\n- **Transcript**: `streamTranscript`, `loadTranscript`, `joinToolCalls`, `successfulToolCalls`, `skillNames`\n- **Testing**: `testHook`, `buildHookPayload` (types `HookPayloadSpec`, `TestHookOptions`)\n- **Events / dialect**: `HOOK_EVENTS`, `inferEventName`, `detectDialect`, `PASCAL_TO_EVENT`, `EVENT_TO_PASCAL`, and the `DECISION_EVENTS` / `CONTEXT_ONLY_EVENTS` / `OBSERVE_ONLY_EVENTS` / `FAIL_CLOSED_EVENTS` category sets\n- **Schemas**: `nativeSchemaByEvent`, `compatSchemaByEvent`\n- **Types**: `HookInput`, `HookInputFor\u003cE\u003e`, `HookOutput`, `HookHandlers`, `HookMeta`, `HookDialect`, `ToolCall`, `SessionEvent`\n- **Gating**: pass `shouldRun` to `runHooks` to skip a run before stdin is read, e.g. `{ shouldRun: () =\u003e process.platform === \"darwin\" }`\n\n\u003c/details\u003e\n\n## Examples\n\n[`examples/`](./examples) has runnable hooks: context injection, a deny guardrail, before/after timing, and the heartbeat coverage gate. All typecheck against the library.\n\n## License\n\nMIT\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Faustenstone%2Fcopilot-hooks-ts","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Faustenstone%2Fcopilot-hooks-ts","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Faustenstone%2Fcopilot-hooks-ts/lists"}