Introduction
GitHub Copilot CLI is a powerful tool that enables AI pair programming in your terminal. Did you know that Copilot CLI recently added a custom hooks feature for extensibility?
Using this feature, I created a library called @geeknees/copilot-cli-wakatime that automatically tracks Copilot CLI usage time in WakaTime. In this article, I'll share the implementation details and how to build Copilot CLI extensions.
What I Built
Overview
A tool that automatically sends Copilot CLI session activity to WakaTime, visualizing your AI coding time.
Key Features
- Hooks into Copilot CLI session lifecycle events (start, tool use, end)
- Sends heartbeats with 60-second rate limiting (per project)
- Cross-platform support (macOS, Linux, Windows)
- Zero-config after setup
Copilot CLI Hook System
How Hooks Work
Copilot CLI provides a "hook system" that can execute external commands at specific points in a session. Hook configurations are placed in .github/hooks/<hook-name>.json.
Available events:
-
sessionStart: When a session begins -
postToolUse: After each tool execution -
sessionEnd: When a session ends
Hook Configuration Example
{
"version": 1,
"hooks": {
"sessionStart": [
{
"type": "command",
"bash": "copilot-cli-wakatime hook sessionStart",
"timeoutSec": 10
}
],
"postToolUse": [
{
"type": "command",
"bash": "copilot-cli-wakatime hook postToolUse",
"timeoutSec": 10
}
]
}
}
Receiving Payloads
Copilot CLI passes a JSON payload to hook commands via standard input (stdin).
Payload example:
{
"cwd": "/Users/username/project",
"toolName": "bash",
"sessionId": "abc123"
}
Implementation Details
Architecture
The library consists of three main components:
-
CLI (
cli.ts): Entry point -
Hook Handler (
hook.ts): Event processing and WakaTime integration -
Utilities (
util.ts): Rate limiting, Git detection, etc.
1. CLI Entry Point
A simple command router:
#!/usr/bin/env node
import { runHook } from "./hook.js";
import { runInit } from "./init.js";
const args = process.argv.slice(2);
const cmd = args[0];
async function main() {
if (cmd === "init") {
await runInit({ force: args.includes("--force") });
return;
}
if (cmd === "hook") {
const event = args[1] ?? "unknown";
const debug = args.includes("--debug");
await runHook({ event, debug });
return;
}
console.error(`Usage:
copilot-cli-wakatime init [--force]
copilot-cli-wakatime hook <event> [--debug]`);
process.exit(1);
}
2. Hook Handler
The hook handler performs the following operations:
export async function runHook(opt: Opt) {
// 1. Read payload from stdin
const raw = await readStdin();
let payload: any = {};
try {
payload = raw ? JSON.parse(raw) : {};
} catch {
payload = {};
}
// 2. Prevent self-recursion (skip WakaTime tool calls)
const toolName = payload?.toolName ?? null;
if (typeof toolName === "string" && toolName.startsWith("wakatime-")) {
return;
}
// 3. Detect Git repository root
const cwd = typeof payload?.cwd === "string" && payload.cwd
? payload.cwd
: process.cwd();
const repoRoot = getRepoRoot(cwd) ?? cwd;
const project = path.basename(repoRoot);
// 4. Create virtual entity file
const entity = path.join(repoRoot, ".copilot-cli.ts");
ensureFile(entity);
// 5. Check rate limit (60-second interval)
if (shouldRateLimit(project, 60)) return;
// 6. Send heartbeat to WakaTime CLI
const cfg = homeCfgPath();
const res = spawnSync(
"wakatime-cli",
[
"--config", cfg,
"--entity", entity,
"--entity-type", "file",
"--project", project,
"--plugin", "copilot-cli-wakatime/0.1.0",
],
{ encoding: "utf8" }
);
// 7. Debug output (optional)
if (opt.debug) {
console.error(JSON.stringify({
event: opt.event,
toolName,
cwd,
repoRoot,
project,
entity,
exit: res.status,
stderr: res.stderr?.trim(),
}, null, 2));
}
// Don't break hooks on failure
return;
}
3. Key Implementation Points
a. Reading stdin
Node.js stdin is a stream, so we promisify it to read all data:
export function readStdin(): Promise<string> {
return new Promise((resolve, reject) => {
let data = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (c: string) => (data += c));
process.stdin.on("end", () => resolve(data));
process.stdin.on("error", reject);
});
}
b. Git Repository Detection
Get the Git repository root from the working directory:
export function getRepoRoot(cwd: string): string | null {
const r = spawnSync("git", ["-C", cwd, "rev-parse", "--show-toplevel"], {
encoding: "utf8",
});
if (r.status !== 0) return null;
const out = r.stdout.trim();
return out.length ? out : null;
}
c. Rate Limiting Implementation
Save the last send timestamp to a file per project and prevent duplicate sends within 60 seconds:
export function shouldRateLimit(key: string, seconds: number): boolean {
const dir = path.join(stateDir(), "copilot-wakatime");
fs.mkdirSync(dir, { recursive: true });
const stamp = path.join(dir, key);
const now = Math.floor(Date.now() / 1000);
let last = 0;
try {
last = parseInt(fs.readFileSync(stamp, "utf8"), 10) || 0;
} catch {}
if (now - last < seconds) return true;
fs.writeFileSync(stamp, String(now));
return false;
}
Following the XDG Base Directory specification, state files are stored in ~/.local/state/copilot-wakatime/.
d. Virtual Entity File
WakaTime requires an actual file path, so we create a virtual file .copilot-cli.ts at the repository root. Using the .ts extension ensures the language is properly recognized in the WakaTime dashboard.
const entity = path.join(repoRoot, ".copilot-cli.ts");
ensureFile(entity);
e. Error Handling
To prevent hook execution failures from breaking the entire Copilot CLI session, errors are handled silently:
// Don't break hooks on failure
return;
Detailed output is only shown in debug mode.
4. Init Command
The init command creates .github/hooks/wakatime.json in the project:
export async function runInit(opt: { force: boolean }) {
const hooksPath = path.join(process.cwd(), ".github", "hooks", "wakatime.json");
if (fs.existsSync(hooksPath) && !opt.force) {
console.error(`Already exists: ${hooksPath}`);
console.error("Use --force to overwrite");
process.exit(1);
}
fs.mkdirSync(path.dirname(hooksPath), { recursive: true });
fs.writeFileSync(hooksPath, JSON.stringify(hookConfig, null, 2));
console.log(`Created: ${hooksPath}`);
}
Testing Strategy
Using Node.js 20+'s built-in test runner (node:test) with TypeScript loader (tsx):
{
"scripts": {
"test": "node --test --import tsx test/**/*.test.ts",
"test:watch": "node --test --watch --import tsx test/**/*.test.ts"
}
}
Test Example
import { describe, it } from "node:test";
import assert from "node:assert";
import { shouldRateLimit } from "../src/util.js";
describe("shouldRateLimit", () => {
it("allows first call", () => {
const key = `test-${Date.now()}`;
assert.strictEqual(shouldRateLimit(key, 60), false);
});
it("blocks second call within time window", () => {
const key = `test-${Date.now()}`;
shouldRateLimit(key, 60); // First call
assert.strictEqual(shouldRateLimit(key, 60), true); // Should block
});
});
Gotchas
1. Importance of Shebang
To work as a CLI tool, a shebang is required at the top of cli.ts:
#!/usr/bin/env node
This makes the command specified in package.json's bin field executable.
2. ES Module Configuration
Set "type": "module" in package.json, and use .js extensions in import paths:
import { runHook } from "./hook.js"; // .js, not .ts
TypeScript doesn't transform extensions during transpilation, so specify the output filename (.js).
3. stdin Reading Timing
If you don't start reading stdin immediately when the hook is called, Copilot CLI might hang.
4. WakaTime CLI Dependency
It assumes users have wakatime-cli installed. I documented installation instructions in the README.
Lessons Learned
1. Copilot CLI Hook System is Flexible
You can intervene at any stage of the session lifecycle, supporting various use cases:
- Logging (this implementation)
- Security checks
- Automated deployment triggers
- Custom notifications
2. Best Practices for CLI Development with TypeScript
- Use ES modules
- Zero-dependency testing with built-in test runner
- Improve dev experience with
tsx - Easy CLI tooling with shebang +
binfield
3. Importance of Rate Limiting
The postToolUse hook is called frequently, so without rate limiting, you'd overwhelm the API. Simple file-based rate limiting was sufficient.
Conclusion
Using the Copilot CLI hook system, I implemented WakaTime integration with just ~200 lines of code.
Key points:
- Hook configuration in
.github/hooks/*.json - Receive payload from stdin
- Proper error handling so hooks don't break sessions
- Control API calls with rate limiting
Understanding this mechanism enables you to create various extensions. Why not build your own Copilot CLI extension?
Top comments (0)