DEV Community

Masumi Kawasaki πŸ’­
Masumi Kawasaki πŸ’­

Posted on

Building a GitHub Copilot CLI Extension - Implementing WakaTime Integration

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
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

Implementation Details

Architecture

The library consists of three main components:

  1. CLI (cli.ts): Entry point
  2. Hook Handler (hook.ts): Event processing and WakaTime integration
  3. 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);
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
  });
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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}`);
}
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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
  });
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 + bin field

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?

Resources

Top comments (0)