From ea460db5f7bc5e8b833e86bf04ecfdb04ea7c584 Mon Sep 17 00:00:00 2001 From: Ousama Ben Younes Date: Fri, 4 Sep 2026 19:04:57 +0000 Subject: [PATCH] feat(copilot): GitHub Copilot CLI as a provider, and wire the stack into it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps, both closed here. 1. Copilot as a tracked provider. It ships real local telemetry — ~/.copilot/session-store.db -> assistant_usage_events — with a per-call token breakdown AND total_nano_aiu, the AI-credit cost GitHub actually bills. Same class of native source tokenwar already reads for Codex and opencode, so there was no reason to leave it N/A. 2. The stack INSIDE Copilot. Being a tracked provider only gets you the numbers. The tools are published for Claude Code and reach Copilot only if pointed at its own extension points, of which there are three: hooks (~/.copilot/hooks/*.json), skills (~/.copilot/skills//SKILL.md) and MCP (~/.copilot/mcp-config.json). Provider side: lib/providers.sh PROVIDER_IDX_COPILOT=5, COUNT=6, telemetry total + monthly, config dir via Copilot's own COPILOT_HOME. provider_version strips trailing punctuation: Copilot prints "GitHub Copilot CLI 1.0.83." and the literal "1.0.83." can never compare equal to a registry version. pricing Copilot is NOT billed per token — seat + AI credits, no published per-token price. The $ column is an API-equivalent valuation, never an invoice; the unit that IS billed is read from total_nano_aiu into the note. That constraint is written down so nobody later "fixes" it into a fake bill. check-updates no update check: Copilot self-updates (autoUpdate defaults true) and racing it would only report noise. launch/install `copilot` joins the wrapped CLIs; its non-interactive subcommands and --acp/--version are banner-silent. scan `copilot` client on ~/.copilot. New scripts/copilot.sh — `tokenwar copilot [check|wire]`, also reachable as install.sh --with-copilot (folded into --all, delegating so there is one implementation, not two that drift): rtk -> hook rtk init -g --copilot graphify -> skill graphify copilot install caveman -> skill copilot skill add ponytail -> skill copilot skill add claude-mem -> MCP its OWN .mcp.json definition, re-registered context-mode and pxpipe are reported n/a WITH a reason and left alone: context-mode's manifest pins an absolute version-specific interpreter path, and pxpipe proxies the Anthropic-compatible API path that Copilot does not use. Two decisions worth reading: claude-mem is registered from its published .mcp.json, never from a hardcoded path. That definition wraps a locator resolving the current plugin version at runtime, so the registration survives `claude plugin update`; a path to .../claude-mem/13.6.1/... works right up until the next upgrade. The raised timeouts are load-bearing. claude-mem's MCP server calls a local worker and aborts at CLAUDE_MEM_API_TIMEOUT_MS (30s default). The first search after a cold worker path indexes the whole memory DB — measured 2m02s. Reproduced outside Copilot with `env -i` and a raw stdio JSON-RPC probe, so it is claude-mem's cold path, not a Copilot bug. With the defaults the FIRST call of every Copilot session fails and reads as a broken integration. Live verification on real Copilot CLI 1.0.83 (copilot -p sessions): rtk agent executed `rtk git status`, not `git status` graphify skill listed; `graphify god-nodes --top 3` returned hubs caveman skill(caveman) loaded, default intensity `full` ponytail skill(ponytail) loaded, first YAGNI rung correct claude-mem MCP `search` returned 120 results Test verification (RED -> GREEN) RED — upstream main scripts, copilot.sh moved aside, new tests applied: not ok 1-13 tests/copilot.bats, all unrunnable: copilot.sh absent not ok 24-27 Copilot detection, version parse, telemetry, AI credits not ok 59-60 install --with-copilot delegation + absent-CLI warning not ok 69-71 scan client, log-root override, --json contract 22 not ok / 49 ok and under a real pty for the launch filter: not ok 10 copilot --acp -> no banner on a tty not ok 11 copilot management subcommands -> no banner on a tty not ok 12 copilot --version -> no banner on a tty Those three run through `script -qfec` deliberately: without a pty [[ -t 1 ]] is false and the banner is suppressed whatever the filter says, so the first version of these tests passed against a launcher that knew nothing about Copilot. GREEN — with the implementation: 171/171 bats (was 144), and 171/171 again under a CI-like PATH with copilot, graphify, rtk and claude all absent. shellcheck -S warning scripts/*.sh scripts/lib/*.sh install.sh uninstall.sh -> clean CI also gains a provider contract assertion: providers are registry-driven, so one added to lib/providers.sh but forgotten in a note map or a telemetry switch used to surface only as a blank runtime column. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NXot81hzCH5ViVyXJDwX4W --- .github/workflows/ci.yml | 17 +- README.md | 89 ++++++++-- SKILL.md | 69 +++++++- docs/tokenwar-tools.md | 3 +- install.sh | 58 +++++-- scripts/check-updates.sh | 10 +- scripts/copilot.sh | 345 +++++++++++++++++++++++++++++++++++++ scripts/lib/providers.sh | 84 ++++++++- scripts/scan.sh | 6 + scripts/status.sh | 2 + scripts/tokenwar-launch.sh | 20 ++- scripts/tokenwar.sh | 5 +- tests/copilot.bats | 243 ++++++++++++++++++++++++++ tests/install.bats | 30 ++++ tests/launch.bats | 45 +++++ tests/providers.bats | 80 ++++++++- tests/scan.bats | 42 +++++ 17 files changed, 1098 insertions(+), 50 deletions(-) create mode 100755 scripts/copilot.sh create mode 100644 tests/copilot.bats diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1788538..92b5400 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,8 @@ jobs: bash scripts/tokenwar.sh help bash scripts/upgrade.sh p.id); + for (const p of PROVIDERS.filter(x => x !== "claude")) { + if (!gainProviders.includes(p)) throw new Error(`gain --json is missing provider: ${p}`); + } + console.log("JSON contract OK — " + MANAGED.length + " tools in status, " + + GAIN_TOOLS.length + " in gain, " + PROVIDERS.length + " providers"); ' diff --git a/README.md b/README.md index d17f102..ec77ba2 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ [![CI](https://github.com/oratelecom/tokenwar/actions/workflows/ci.yml/badge.svg)](https://github.com/oratelecom/tokenwar/actions/workflows/ci.yml) -**Seven token-saving tools, run as one stack.** Built for Claude Code first — but the stack reaches further: RTK, ponytail, caveman, context-mode, pxpipe, and graphify work across agents (Codex, Gemini, Kimi, opencode, Cursor…), with provider token usage tracked only where native telemetry exists. Each saves a buffer or lane the others can't touch — the model's response, tool stdout, heavy data, cross-session memory, provider-bound prompt payloads, the repo's own shape, and the code itself — so the savings stack instead of competing. None of the seven is the headliner; the point is running all seven at once. **7-in-1.** +**Seven token-saving tools, run as one stack.** Built for Claude Code first — but the stack reaches further: RTK, ponytail, caveman, context-mode, pxpipe, and graphify work across agents (Codex, Gemini, Kimi, opencode, **GitHub Copilot CLI**, Cursor…), with provider token usage tracked only where native telemetry exists. Each saves a buffer or lane the others can't touch — the model's response, tool stdout, heavy data, cross-session memory, provider-bound prompt payloads, the repo's own shape, and the code itself — so the savings stack instead of competing. None of the seven is the headliner; the point is running all seven at once. **7-in-1.** > The stack diagram above still pictures six lanes; graphify joined afterwards and the artwork has not been regenerated yet. @@ -90,6 +90,7 @@ Inside Claude Code (`/tokenwar `) or standalone (`bash ~/.claude/ski | `/tokenwar status` | Health of the 7 tools — installed, enabled, version | | `/tokenwar gain` | Per-tool token savings + per-provider telemetry/status (Codex/Gemini/Kimi/opencode) + **monthly $ value** | | `/tokenwar scan` | Local agent-log scan that estimates which token-saving tools would have helped most | +| `/tokenwar copilot` | Report which tools reach GitHub Copilot CLI; `copilot wire` points the missing ones at Copilot's hook / skills / MCP | | `/tokenwar upgrade` | Bump each tool to latest (asks confirmation) | | `/tokenwar check` | Conflict detector — verifies the 7 tools stack additively | | `/tokenwar test` | End-to-end ping: is each tool actually working? | @@ -122,6 +123,7 @@ Supported clients: | Gemini CLI | `~/.gemini` | Yes, when CLI or logs exist | Scan can recommend tools even when native token telemetry is unavailable. | | Kimi Code CLI | `~/.kimi-code` | Yes, when CLI or logs exist | Local logs are scanned; token totals remain estimates. | | opencode | `~/.local/share/opencode`, `~/.config/opencode` | Yes, when CLI or logs exist | Combines well with native usage telemetry from `tokenwar gain`. | +| GitHub Copilot CLI | `~/.copilot` | Yes, when CLI or logs exist | Session state under `~/.copilot/session-state`; pairs with native token telemetry from `tokenwar gain`. | | Vibe/Ora agents | `~/.ora/tasks`, `~/.ora/contribute`, `~/.claude/contributebg/logs` | Yes, when logs exist | Covers background contribution and vibe-coding agent logs. | | Cursor | `~/.cursor` | Yes, when CLI or logs exist | Reports `none` when the directory exists but no supported logs are found. | @@ -167,14 +169,14 @@ they are installed but disabled. It intentionally does not install new tools, change RTK hooks, remove pxpipe, or choose a code-context alternative for you. Those remain explicit setup decisions. -## Status in every CLI (Claude, Codex, Gemini, Kimi, opencode) +## Status in every CLI (Claude, Codex, Gemini, Kimi, opencode, Copilot) The persistent **bottom status bar** is a Claude Code feature — it ships a -`statusLine` API and tokenwar wires it automatically. **Codex, Gemini, Kimi, and -opencode do not expose a status-bar API** (their footers are hardcoded; their -hooks inject only into the model context, not the screen). So tokenwar surfaces -the stack the best way each CLI allows, with **zero daily effort** — `install.sh` -wires it once: +`statusLine` API and tokenwar wires it automatically. **Codex, Gemini, Kimi, +opencode, and GitHub Copilot CLI do not expose a status-bar API** (their footers +are hardcoded; their hooks inject only into the model context, not the screen). +So tokenwar surfaces the stack the best way each CLI allows, with **zero daily +effort** — `install.sh` wires it once: | CLI | What you get | | ----------- | --------------------------------------------------------------------- | @@ -183,8 +185,9 @@ wires it once: | Gemini CLI | Launch banner + `tokenwar status` reminder + update status hint | | Kimi Code CLI | Launch banner + `tokenwar status` reminder + update status hint | | opencode | Launch banner + `tokenwar status` reminder + update status hint | +| GitHub Copilot CLI | Launch banner + `tokenwar status` reminder + update status hint | -After install you simply type `codex`, `gemini`, `kimi`, or `opencode` as usual — +After install you simply type `codex`, `gemini`, `kimi`, `opencode`, or `copilot` as usual — the banner prints the stack bar. If updates are pending, the bar shows **"⬆ N updates · /tokenwar upgrade"** as an informational hint only; upgrades run only when you call `tokenwar upgrade` yourself. A `tokenwar` command also @@ -194,6 +197,7 @@ works in any shell: tokenwar status # state of the 7 tools + providers tokenwar gain # token savings + monthly $ value tokenwar scan # local log scan + recommendations +tokenwar copilot # which tools reach GitHub Copilot CLI (add `wire` to fix) tokenwar upgrade # bump managed tools (asks confirmation) tokenwar doctor # status → check → gain tokenwar disable context-mode # turn off one plugin without uninstalling it @@ -201,8 +205,59 @@ tokenwar enable context-mode # turn it back on ``` > The banner is silent for non-interactive launches (`codex exec`, -> `gemini -p …`, `kimi -p …`, `opencode run …`, pipes) so it never pollutes -> scripted output. +> `gemini -p …`, `kimi -p …`, `opencode run …`, `copilot -p …`, `copilot --acp`, +> `copilot mcp/skill/plugin …`, pipes) so it never pollutes scripted output. + +## The stack inside GitHub Copilot CLI + +Being a tracked *provider* only gets you the numbers. The **tools** are published +for Claude Code and do not reach Copilot for free — each one has to be pointed at +Copilot's own extension points, of which there are exactly three: hooks +(`~/.copilot/hooks/*.json`), skills (`~/.copilot/skills//SKILL.md`, the +portable Agent-Skills format), and MCP (`~/.copilot/mcp-config.json`). + +`tokenwar copilot` reports that mapping; `tokenwar copilot wire` applies it. + +| Tool | Reaches Copilot via | Wiring | +| ---- | ------------------- | ------ | +| **rtk** | hook | `rtk init -g --copilot` — a `PreToolUse` hook plus user-level instructions | +| **graphify** | skill | `graphify copilot install` — its own native command | +| **caveman** | skill | `copilot skill add` on the plugin's `SKILL.md` | +| **ponytail** | skill | `copilot skill add` on the plugin's `SKILL.md` | +| **claude-mem** | MCP | its own `.mcp.json` definition, re-registered with `copilot mcp add` | +| context-mode | — | not wired: its plugin manifest pins an absolute, version-specific interpreter path, so the registration would break on the next upgrade | +| pxpipe | — | not applicable: it is a proxy on the Anthropic-compatible API path, and Copilot talks to GitHub's endpoint | + +```text +# /tokenwar copilot + + · tool via state note + ───────────────────────────────────────────────────────────────── + ✓ rtk hook wired ~/.copilot/hooks/rtk-rewrite.json + ✓ graphify skill wired ~/.copilot/skills/graphify + ✓ caveman skill wired ~/.copilot/skills/caveman + ✓ ponytail skill wired ~/.copilot/skills/ponytail + ✓ claude-mem MCP wired ~/.copilot/mcp-config.json → claude-mem +``` + +Two details that are easy to get wrong: + +- **claude-mem is registered from its own `.mcp.json`, not from a hardcoded + path.** That file wraps a locator which resolves the current plugin version at + runtime, so the Copilot registration survives `claude plugin update`. Pointing + Copilot straight at `.../claude-mem/13.6.1/scripts/mcp-server.cjs` works right + up until the next upgrade. +- **claude-mem's first search needs a longer timeout than either default + allows.** Its MCP server talks to a local worker over HTTP and aborts at + `CLAUDE_MEM_API_TIMEOUT_MS` (30s by default); the first search after a cold + worker path builds an index over the whole memory DB — measured at **2m02s** + here. With the defaults the very first call in a Copilot session *always* + fails, which reads as "claude-mem is broken under Copilot" when it is not. The + wiring therefore raises both that variable and Copilot's own per-tool timeout. + +`install.sh --with-copilot` (included in `--all`) runs the same wiring at install +time — it delegates to `scripts/copilot.sh`, so there is one implementation, not +two that drift. ## How to activate tokenwar per client @@ -220,9 +275,11 @@ curl -fsSL https://raw.githubusercontent.com/oratelecom/tokenwar/main/install.sh | **Gemini CLI** | Wraps `gemini` the same way | New shell, run `gemini` → banner | | **Kimi Code CLI** | Wraps `kimi` the same way | New shell, run `kimi` → banner | | **opencode** | Wraps `opencode` the same way; reads its real token telemetry from `~/.local/share/opencode/opencode.db` | New shell, run `opencode` → banner; `tokenwar gain` shows opencode session tokens | +| **GitHub Copilot CLI** | Wraps `copilot` the same way; reads its real token + AI-credit telemetry from `~/.copilot/session-store.db`; with `--with-copilot`, also points the tools at Copilot's own hook / skills / MCP | New shell, run `copilot` → banner; `tokenwar copilot` shows every tool `wired` | After install, **reload your shell** (`source ~/.bashrc` or open a new terminal) -so the `codex` / `gemini` / `kimi` / `opencode` / `tokenwar` functions take effect. +so the `codex` / `gemini` / `kimi` / `opencode` / `copilot` / `tokenwar` functions +take effect. That's the whole activation — every subsequent launch of any wrapped CLI is tokenwar-aware with zero extra effort. @@ -281,7 +338,7 @@ tokenwar check # must print COMPLEMENTARY tokenwar gain # real per-tool token savings ``` -Restart Claude Code to load the plugins. `--all` = `--with-plugins --with-rtk --with-pxpipe --with-graphify`; use individual flags if you only want one part. RTK installs from a prebuilt binary (no toolchain, no compiling) on every major platform via rtk's own official installer. pxpipe installs from the pinned npm package `pxpipe-proxy@0.10.0`. graphify installs from PyPI — package `graphifyy`, command `graphify` — preferring an isolated environment (`uv tool`, then `pipx`) over a shared `pip`, because the skill resolves its interpreter at runtime and a shared env is what produces upstream's `ModuleNotFoundError: No module named 'graphify'`; the install then runs `graphify install` to register the skill. +Restart Claude Code to load the plugins. `--all` = `--with-plugins --with-rtk --with-pxpipe --with-graphify --with-copilot`; use individual flags if you only want one part. RTK installs from a prebuilt binary (no toolchain, no compiling) on every major platform via rtk's own official installer. pxpipe installs from the pinned npm package `pxpipe-proxy@0.10.0`. graphify installs from PyPI — package `graphifyy`, command `graphify` — preferring an isolated environment (`uv tool`, then `pipx`) over a shared `pip`, because the skill resolves its interpreter at runtime and a shared env is what produces upstream's `ModuleNotFoundError: No module named 'graphify'`; the install then runs `graphify install` to register the skill. Prefer no surprise mutations? Drop the flags — `… | bash` just wires the statusline + shell functions, then `/tokenwar activate` installs the plugins on confirmation: @@ -349,6 +406,7 @@ tool's own telemetry, nothing invented: Gemini CLI N/A no local token telemetry (server-side sessions) Kimi Code CLI N/A no documented local token telemetry opencode 105.3K 10 opencode sessions (real token cols) + Copilot CLI 13.3K 1 Copilot sessions (real assistant_usage_events) - 0.24 AI credits billed Monthly value — API-equivalent $ saved (Claude Opus 4.8 · input $5.00/M) 2026-07 8.2M $41.00 @@ -404,9 +462,10 @@ bats tests/ CI on every push to `main` and every PR — installs bats + shellcheck, runs the full suite on `ubuntu-latest`, then a contract smoke that asserts every managed -tool is present in `status.sh --json` and `gain.sh --json`. The smoke is what -catches a tool added to the text table but forgotten in the JSON contract that -`tokenwar scan` and downstream consumers read. +tool **and every provider** is present in `status.sh --json` and `gain.sh --json`. +The smoke is what catches a tool or provider added to the text table but +forgotten in the JSON contract that `tokenwar scan` and downstream consumers +read — the two are rendered by separate code paths. ## Credits diff --git a/SKILL.md b/SKILL.md index ebebbd1..f747341 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,6 +1,6 @@ --- name: tokenwar -description: Activate, upgrade, test, and benchmark the 7-tool token-saving stack (context-mode, claude-mem, RTK, pxpipe, graphify, caveman, ponytail). Reports per-tool + per-provider (Codex, Gemini, Kimi, opencode) token savings and detects conflicts that would erase the gains. +description: Activate, upgrade, test, and benchmark the 7-tool token-saving stack (context-mode, claude-mem, RTK, pxpipe, graphify, caveman, ponytail). Reports per-tool + per-provider (Codex, Gemini, Kimi, opencode, GitHub Copilot CLI) token savings, wires the stack into Copilot, and detects conflicts that would erase the gains. trigger: /tokenwar --- @@ -34,17 +34,26 @@ tokenwar now tracks token usage across AI coding agents, each from its own | Gemini CLI | N/A (server-side sessions — no local store) | — | | Kimi Code CLI | N/A (`~/.kimi-code` has no documented token store) | — | | opencode | `~/.local/share/opencode/opencode.db` → `session` token cols | per-session + monthly | +| GitHub Copilot CLI | `~/.copilot/session-store.db` → `assistant_usage_events` | per-call + monthly, plus the AI credits actually billed (`total_nano_aiu`) | Each provider's token counts are valued at their own list prices (input-side). Provider prices are defined in `scripts/lib/providers.sh` — verify against official pricing pages. -## Cross-CLI status (Claude vs Codex/Gemini/Kimi/opencode) +> Copilot is the one provider that is **not** billed per token: it is a seat +> subscription plus AI credits (premium requests on the legacy plan), and GitHub +> publishes no per-token list price. Its `$` column is therefore an +> API-equivalent valuation at a GPT-5-class input rate, never an invoice — while +> the unit that IS billed, AI credits, is read from `total_nano_aiu` and printed +> in the telemetry note. Do not present the `$` figure as a Copilot bill. + +## Cross-CLI status (Claude vs Codex/Gemini/Kimi/opencode/Copilot) The persistent **status bar** is a Claude Code feature (its `statusLine` API). -Codex, Gemini, Kimi, and opencode do **not** expose a status-bar API — their -footers are hardcoded in their TUIs, and their hooks only inject into the *model* -context, never the screen. So tokenwar surfaces the stack differently per CLI: +Codex, Gemini, Kimi, opencode, and GitHub Copilot CLI do **not** expose a +status-bar API — their footers are hardcoded in their TUIs, and their hooks only +inject into the *model* context, never the screen. So tokenwar surfaces the stack +differently per CLI: | CLI | How the stack is surfaced | | ---------- | --------------------------------------------------------------------- | @@ -53,18 +62,20 @@ context, never the screen. So tokenwar surfaces the stack differently per CLI: | Gemini CLI | **Launch banner** + reminder + update status hint (via shell wrapper) | | Kimi Code CLI | **Launch banner** + reminder + update status hint (via shell wrapper) | | opencode | **Launch banner** + reminder + update status hint (via shell wrapper) | +| GitHub Copilot CLI | **Launch banner** + reminder + update status hint (via shell wrapper) | `install.sh` wires the shell functions (one-time, then zero effort): - `tokenwar ` — the dispatcher; `tokenwar status` / `gain` / `check` / `upgrade` work in **any** shell (Codex, Gemini, Kimi, opencode, plain terminal). -- `codex` / `gemini` / `kimi` / `opencode` — wrapped so that launching any of +- `codex` / `gemini` / `kimi` / `opencode` / `copilot` — wrapped so that launching any of them prints the tokenwar banner (`scripts/tokenwar-launch.sh`), reminds the user to run `tokenwar status`, and leaves pending updates as the statusline's informational **"⬆ N updates · /tokenwar upgrade"** hint. It never runs `scripts/upgrade.sh` automatically from provider launch. The banner is silent for non-interactive launches (`codex exec`, `gemini -p …`, `kimi -p …`, - `opencode run …`, pipes) so it never pollutes scripted output. + `opencode run …`, `copilot -p …`, `copilot --acp`, `copilot mcp|skill|plugin …`, + pipes) so it never pollutes scripted output. ## Usage @@ -76,6 +87,7 @@ context, never the screen. So tokenwar surfaces the stack differently per CLI: /tokenwar test # ping each one-by-one, verify it actually responds /tokenwar gain # per-tool + global token-savings report /tokenwar check # conflict detector — verifies the 7 tools are complementary +/tokenwar copilot # which tools reach GitHub Copilot CLI (`copilot wire` to fix) /tokenwar doctor # full pipeline: status → test → check → gain /tokenwar disable X # turn off one plugin (context-mode|claude-mem|caveman|ponytail) /tokenwar enable X # turn a disabled plugin back on @@ -114,8 +126,9 @@ On `Yes`, run for each tool: - `pxpipe` not installed → `npm install -g pxpipe-proxy@0.10.0`. This is the current pinned package for teamchong/pxpipe; do not install a floating version. - `graphify` not installed → `uv tool install graphifyy` (or `pipx install graphifyy`; plain `pip` only as a last resort — the skill resolves its interpreter at runtime and a shared env is what produces upstream's `ModuleNotFoundError: No module named 'graphify'`), then `graphify install` to register the skill. - `graphify` installed-disabled (CLI present, skill missing) → `graphify install`. Do NOT reinstall the package; the CLI is already there, only the skill registration is absent. +- GitHub Copilot CLI present but tools not wired to it → `bash ~/.claude/skills/tokenwar/scripts/copilot.sh wire --yes` (see the `copilot` subcommand below). -**One-shot alternative**: `install.sh --all` (or `curl … | bash -s -- --all`) installs the whole stack at install time — the 4 plugins (marketplace-add + install + enable, with the anti-clobber re-enable), the RTK binary (via rtk's official prebuilt installer — no toolchain), pxpipe (`pxpipe-proxy@0.10.0`), and graphify (`graphifyy` + `graphify install`), then wires RTK's hook with `rtk init -g` (and, when opencode is present, RTK's opencode plugin with `rtk init -g --opencode`). Use `--with-plugins`, `--with-rtk`, `--with-pxpipe`, or `--with-graphify` for just one part. So a fresh machine needs no separate `activate`. +**One-shot alternative**: `install.sh --all` (or `curl … | bash -s -- --all`) installs the whole stack at install time — the 4 plugins (marketplace-add + install + enable, with the anti-clobber re-enable), the RTK binary (via rtk's official prebuilt installer — no toolchain), pxpipe (`pxpipe-proxy@0.10.0`), and graphify (`graphifyy` + `graphify install`), then wires RTK's hook with `rtk init -g` (and, when opencode is present, RTK's opencode plugin with `rtk init -g --opencode`). Use `--with-plugins`, `--with-rtk`, `--with-pxpipe`, `--with-graphify`, or `--with-copilot` for just one part. So a fresh machine needs no separate `activate`. **Gotcha discovered 2026-05-18**: the *first* call to `claude plugin enable` on any plugin creates `enabledPlugins` in `~/.claude/settings.json` and **clobbers** plugins that were enabled implicitly at the marketplace level. Mitigation: after EVERY enable/install, snapshot the full `claude plugin list --json` and re-enable any plugin that flipped from `enabled:true` to `enabled:false`. The `activate` flow must do this snapshot-and-restore. @@ -251,6 +264,46 @@ Verdict: `` must cite an actual path or value (e.g., `~/.claude/settings.json:hooks[0]` or `claude-mem v12.1.4 vs latest 12.1.4`). No vague "looks fine" — show the bytes. +## Subcommand: copilot + +Copilot CLI is a tracked provider, but the TOOLS do not reach it for free — they +are published for Claude Code, and Copilot exposes exactly three extension +points of its own: hooks (`~/.copilot/hooks/*.json`), skills +(`~/.copilot/skills//SKILL.md`), and MCP (`~/.copilot/mcp-config.json`). + +Run `bash ~/.claude/skills/tokenwar/scripts/copilot.sh` (read-only) to report the +mapping, and `… copilot.sh wire --yes` to apply the missing parts. Exit `0` when +every installed tool is wired, `1` otherwise. + +| Tool | Via | Wiring command | +| ---- | --- | -------------- | +| rtk | hook | `rtk init -g --copilot --auto-patch` | +| graphify | skill | `graphify copilot install` | +| caveman | skill | `copilot skill add /skills/caveman/SKILL.md` | +| ponytail | skill | `copilot skill add /skills/ponytail/SKILL.md` | +| claude-mem | MCP | `copilot mcp add claude-mem -- ` | + +Rules for this subcommand: + +- **Never hand-write the claude-mem MCP command.** Read it from the plugin's own + `.mcp.json`: that definition wraps a locator which resolves the current plugin + version at runtime, so the registration survives `claude plugin update`. A + hardcoded `.../claude-mem//scripts/mcp-server.cjs` breaks on the next + upgrade. +- **Never write into `~/.copilot/skills/` directly.** Go through + `copilot skill add `; Copilot owns that directory's layout and derives + the skill name from the SKILL.md frontmatter. +- **The timeouts are not optional.** claude-mem's MCP server calls a local worker + and aborts at `CLAUDE_MEM_API_TIMEOUT_MS` (30s default). The first search after + a cold worker path indexes the whole memory DB — measured 2m02s. With the + defaults, the first Copilot call always fails and looks like a broken + integration. The wiring raises both that variable and Copilot's own per-tool + timeout. +- `context-mode` and `pxpipe` are reported `n/a` with a reason and are NOT + wired. Do not "fix" that: context-mode's manifest pins an absolute, + version-specific interpreter path, and pxpipe proxies the Anthropic-compatible + API path, which Copilot does not use. + ## Subcommand: doctor Run, in order: `status` → `test` → `check` → `gain`. Print each section's header, run the corresponding subcommand, collect the result, and end with a one-line verdict: diff --git a/docs/tokenwar-tools.md b/docs/tokenwar-tools.md index 2d6fc3e..1a8a840 100644 --- a/docs/tokenwar-tools.md +++ b/docs/tokenwar-tools.md @@ -58,6 +58,7 @@ and never calls an AI provider. | `gemini` | Gemini CLI | `~/.gemini` | Full local log scan; recommendations still work even though native token telemetry is unavailable. | | `kimi` | Kimi Code CLI | `~/.kimi-code` | Full local log scan when files exist; totals remain estimates. | | `opencode` | opencode | `~/.local/share/opencode`, `~/.config/opencode` | Full local log scan; pair with `tokenwar gain` for native token telemetry. | +| `copilot` | GitHub Copilot CLI | `~/.copilot` | Full local log scan of `~/.copilot/session-state`; pair with `tokenwar gain` for native token + AI-credit telemetry, and with `tokenwar copilot` for tool wiring. | | `vibe` | Vibe/Ora agents | `~/.ora/tasks`, `~/.ora/contribute`, `~/.claude/contributebg/logs` | Full local log scan for background contribution and vibe-coding sessions. | | `cursor` | Cursor | `~/.cursor` | Detected when installed; reports no opportunity when no supported log files are found. | @@ -135,7 +136,7 @@ for local code navigation TokenWar should test lighter alternatives first: numbers as real savings. - Actual savings: only from native telemetry (`rtk gain`, `pxpipe stats`, - `graphify benchmark`, Codex/opencode token databases, `ctx_stats`). + `graphify benchmark`, Codex/opencode/Copilot token databases, `ctx_stats`). - Estimated opportunity: derived from local logs by matching command, search, scrape, memory, verbosity, and code-generation signals. - Recommendation output must include both: `estimated avoidable tokens` and diff --git a/install.sh b/install.sh index bcb75c3..0abcc30 100755 --- a/install.sh +++ b/install.sh @@ -9,7 +9,7 @@ # 1. git clone https://github.com/oratelecom/tokenwar ~/.claude/skills/tokenwar # 2. chmod +x scripts/*.sh # 3. patch ~/.claude/settings.json to wire the statusLine -# 4. wire the tokenwar/codex/gemini/kimi/opencode shell functions +# 4. wire the tokenwar/codex/gemini/kimi/opencode/copilot shell functions # 5. opt-in installs (none by default — no surprise mutation): # --with-plugins marketplace add + install + enable the 4 Claude Code # plugins (context-mode, claude-mem, caveman, ponytail), @@ -19,8 +19,10 @@ # --with-pxpipe install pxpipe proxy from a pinned npm package. # --with-graphify install the graphify CLI (PyPI `graphifyy`) and register # its assistant skill via `graphify install`. -# --all plugins + RTK + pxpipe + graphify. After plugins/RTK, -# RTK's hook is wired via `rtk init -g`. +# --with-copilot point the installed tools at GitHub Copilot CLI's own +# extension points (hook / skills / MCP) via copilot.sh. +# --all plugins + RTK + pxpipe + graphify + Copilot wiring. After +# plugins/RTK, RTK's hook is wired via `rtk init -g`. # Without any flag, plugin/RTK setup is left to /tokenwar activate. # # Idempotent: re-running pulls the latest tokenwar and only patches settings.json @@ -82,6 +84,11 @@ readonly PIP_BIN="pip" readonly GRAPHIFY_BIN="graphify" readonly GRAPHIFY_PYPI_PACKAGE="graphifyy" +# Copilot wiring (--with-copilot). The tools are published for Claude Code and +# do not reach Copilot for free; copilot.sh points each at Copilot's own +# extension point. It is idempotent and no-ops when the CLI is absent. +readonly COPILOT_BIN="copilot" +readonly COPILOT_WIRE_SCRIPT_REL="scripts/copilot.sh" # Shell-integration block markers — used to idempotently inject/remove the # `tokenwar`, `codex`, `gemini`, `kimi`, and `opencode` wrapper functions in the # user's shell rc. @@ -92,6 +99,7 @@ readonly WRAPPED_PROVIDER_CLIS=( "gemini" "kimi" "opencode" + "copilot" ) color() { printf '\033[%sm%s\033[0m' "$1" "$2"; } @@ -106,22 +114,25 @@ WITH_PLUGINS=false WITH_RTK=false WITH_PXPIPE=false WITH_GRAPHIFY=false +WITH_COPILOT=false for arg in "$@"; do case "$arg" in --with-plugins) WITH_PLUGINS=true ;; --with-rtk) WITH_RTK=true ;; --with-pxpipe) WITH_PXPIPE=true ;; --with-graphify) WITH_GRAPHIFY=true ;; - --all) WITH_PLUGINS=true; WITH_RTK=true; WITH_PXPIPE=true; WITH_GRAPHIFY=true ;; + --with-copilot) WITH_COPILOT=true ;; + --all) WITH_PLUGINS=true; WITH_RTK=true; WITH_PXPIPE=true; WITH_GRAPHIFY=true; WITH_COPILOT=true ;; -h|--help) - printf 'Usage: install.sh [--with-plugins] [--with-rtk] [--with-pxpipe] [--with-graphify] [--all]\n' + printf 'Usage: install.sh [--with-plugins] [--with-rtk] [--with-pxpipe] [--with-graphify] [--with-copilot] [--all]\n' printf ' --with-plugins install+enable the 4 Claude Code plugins (incl. ponytail)\n' printf ' --with-rtk install the RTK binary (official prebuilt installer) + wire its hook\n' printf ' --with-pxpipe install pxpipe proxy (%s)\n' "$PXPIPE_NPM_SPEC" printf ' --with-graphify install the graphify CLI (PyPI %s) + register its skill\n' "$GRAPHIFY_PYPI_PACKAGE" + printf ' --with-copilot wire the installed tools into GitHub Copilot CLI\n' printf ' --all all of the above\n' exit 0 ;; - *) die "unknown argument: $arg (supported: --with-plugins, --with-rtk, --with-pxpipe, --with-graphify, --all)" ;; + *) die "unknown argument: $arg (supported: --with-plugins, --with-rtk, --with-pxpipe, --with-graphify, --with-copilot, --all)" ;; esac done @@ -177,9 +188,10 @@ console.log(` patched (backup at ${path}.bak-${stamp})`); # 4. wire shell integration (tokenwar + provider functions) into shell rc. # -# Claude Code shows the native statusLine; Codex, Gemini, Kimi, and opencode do not expose a -# status-bar API, so we wrap their launch with a banner + reminder + upgrade -# prompt. The `tokenwar` function makes `tokenwar status` work in any shell. +# Claude Code shows the native statusLine; Codex, Gemini, Kimi, opencode, and +# GitHub Copilot CLI do not expose a status-bar API, so we wrap their launch with +# a banner + reminder. The `tokenwar` function makes `tokenwar status` work in +# any shell. # Idempotent: an existing tokenwar block is replaced, never duplicated. wire_shell_rc() { local rc_file="$1" @@ -212,7 +224,7 @@ wire_shell_rc() { if ! mv -f "$tmp" "$rc_file"; then warn "could not write $rc_file"; rm -f "$tmp"; return 1 fi - say "Wired tokenwar/codex/gemini/kimi/opencode shell functions in $rc_file" + say "Wired tokenwar/codex/gemini/kimi/opencode/copilot shell functions in $rc_file" } # Print the ids of every currently-enabled plugin, one per line (from @@ -404,6 +416,23 @@ install_graphify() { fi } +# --with-copilot: point the installed tools at Copilot CLI's extension points. +# Delegates to scripts/copilot.sh so the wiring has ONE implementation, shared +# with `tokenwar copilot wire` — no second copy to drift. +wire_copilot_stack() { + if ! command -v "$COPILOT_BIN" >/dev/null 2>&1; then + warn "GitHub Copilot CLI not found — skipping --with-copilot (npm install -g @github/copilot, then re-run \`tokenwar copilot wire\`)." + return 0 + fi + local wire_script="${INSTALL_DIR}/${COPILOT_WIRE_SCRIPT_REL}" + if [[ ! -f "$wire_script" ]]; then + warn "copilot wiring script not found at $wire_script" + return 0 + fi + say "Wiring the stack into GitHub Copilot CLI" + bash "$wire_script" wire --yes || warn "Copilot wiring reported failures — see \`tokenwar copilot\`" +} + say "Wiring shell integration" wired_any=false for rc in "$HOME/.bashrc" "$HOME/.zshrc"; do @@ -423,6 +452,9 @@ if $WITH_RTK; then install_rtk; fi if $WITH_PXPIPE; then install_pxpipe; fi if $WITH_GRAPHIFY; then install_graphify; fi if $WITH_PLUGINS || $WITH_RTK; then wire_rtk_hook; fi +# Copilot wiring runs LAST: it reflects whatever the steps above installed, so a +# tool added in this same run is picked up rather than reported as missing. +if $WITH_COPILOT; then wire_copilot_stack; fi if $WITH_PLUGINS && $WITH_RTK && $WITH_PXPIPE && $WITH_GRAPHIFY; then next_steps="Plugins + RTK + pxpipe + graphify installed and RTK's hook wired. Restart Claude Code to load the plugins. Start pxpipe when you want proxy-side prompt-to-PNG savings, and run \`graphify .\` inside a repo to build its first graph." @@ -454,9 +486,9 @@ Sanity check now: Statusline appears after restarting Claude Code. Shell integration wired (reload your shell or 'source ~/.bashrc'): - tokenwar status # works in any shell — Codex, Gemini, Kimi, opencode, plain terminal - codex / gemini / kimi / opencode - # now print the tokenwar banner + upgrade prompt on launch + tokenwar status # works in any shell — Codex, Gemini, Kimi, opencode, Copilot, plain terminal + codex / gemini / kimi / opencode / copilot + # now print the tokenwar banner on launch $next_steps diff --git a/scripts/check-updates.sh b/scripts/check-updates.sh index 3db4736..7b17c3b 100755 --- a/scripts/check-updates.sh +++ b/scripts/check-updates.sh @@ -265,6 +265,7 @@ if $force_refresh || ! cache_is_fresh; then gemini_installed=$(provider_version "$PROVIDER_IDX_GEMINI") kimi_installed=$(provider_version "$PROVIDER_IDX_KIMI") opencode_installed=$(provider_version "$PROVIDER_IDX_OPENCODE") + copilot_installed=$(provider_version "$PROVIDER_IDX_COPILOT") # Latest provider versions: we don't have a reliable upstream source yet # (npm view would require knowing the exact package name). For now, # installed == latest unless we can prove otherwise via `codex doctor`. @@ -272,6 +273,10 @@ if $force_refresh || ! cache_is_fresh; then gemini_latest="$gemini_installed" kimi_latest="$kimi_installed" opencode_latest="$opencode_installed" + # Copilot CLI updates itself: `autoUpdate` defaults to true and the binary + # pulls its own release. tokenwar reports the version it finds rather than + # duplicating (and racing) that mechanism. + copilot_latest="$copilot_installed" # Codex self-reports updates via `codex doctor` — parse if available if command -v codex >/dev/null 2>&1 && [[ -n "$codex_installed" ]]; then codex_doctor_latest=$(codex doctor 2>/dev/null | awk '/updates available/ {print $2}' | head -1 || echo "") @@ -294,6 +299,7 @@ if $force_refresh || ! cache_is_fresh; then gemini_state=$(classify "$gemini_installed" "$gemini_latest") kimi_state=$(classify "$kimi_installed" "$kimi_latest") opencode_state=$(classify "$opencode_installed" "$opencode_latest") + copilot_state=$(classify "$copilot_installed" "$copilot_latest") now=$(date +%s) TOKENWAR_CACHE_FILE="$CACHE_FILE" \ @@ -309,6 +315,7 @@ if $force_refresh || ! cache_is_fresh; then GEMINI_I="$gemini_installed" GEMINI_L="$gemini_latest" GEMINI_S="$gemini_state" \ KIMI_I="$kimi_installed" KIMI_L="$kimi_latest" KIMI_S="$kimi_state" \ OPENCODE_I="$opencode_installed" OPENCODE_L="$opencode_latest" OPENCODE_S="$opencode_state" \ + COPILOT_I="$copilot_installed" COPILOT_L="$copilot_latest" COPILOT_S="$copilot_state" \ node --input-type=module -e " import { writeFileSync } from 'node:fs'; const e = process.env; @@ -327,7 +334,8 @@ if $force_refresh || ! cache_is_fresh; then 'codex': { installed: e.CODEX_I, latest: e.CODEX_L, state: e.CODEX_S }, 'gemini': { installed: e.GEMINI_I, latest: e.GEMINI_L, state: e.GEMINI_S }, 'kimi': { installed: e.KIMI_I, latest: e.KIMI_L, state: e.KIMI_S }, - 'opencode': { installed: e.OPENCODE_I, latest: e.OPENCODE_L, state: e.OPENCODE_S } + 'opencode': { installed: e.OPENCODE_I, latest: e.OPENCODE_L, state: e.OPENCODE_S }, + 'copilot': { installed: e.COPILOT_I, latest: e.COPILOT_L, state: e.COPILOT_S } } }; writeFileSync(e.TOKENWAR_CACHE_FILE, JSON.stringify(data, null, 2)); diff --git a/scripts/copilot.sh b/scripts/copilot.sh new file mode 100755 index 0000000..982df78 --- /dev/null +++ b/scripts/copilot.sh @@ -0,0 +1,345 @@ +#!/usr/bin/env bash +# tokenwar copilot — report and wire the token-saving stack into GitHub Copilot CLI. +# +# Copilot CLI is a first-class tokenwar provider (native token telemetry in +# ~/.copilot/session-store.db), but the TOOLS do not reach it for free: each one +# is published for Claude Code and has to be pointed at Copilot's own extension +# points. Copilot offers exactly three: +# +# hooks ~/.copilot/hooks/*.json — PreToolUse command rewriting +# skills ~/.copilot/skills//SKILL.md — the portable Agent-Skills format +# MCP ~/.copilot/mcp-config.json — stdio/HTTP servers +# +# So the stack maps like this: +# +# rtk → hook `rtk init -g --copilot` +# graphify → skill `graphify copilot install` (its own native command) +# caveman → skill copied from the Claude plugin cache +# ponytail → skill copied from the Claude plugin cache +# claude-mem → MCP its own .mcp.json definition, re-registered +# +# context-mode / pxpipe → not wired, on purpose. See NOT_WIRED_REASON_* below. +# +# Usage: +# copilot.sh # check (read-only). Exit 0 if every wireable tool is wired. +# copilot.sh check +# copilot.sh wire # apply the missing wiring (asks confirmation) +# copilot.sh wire --yes # apply without prompting + +set -uo pipefail + +readonly COPILOT_BIN="copilot" +readonly RTK_BIN="rtk" +readonly GRAPHIFY_BIN="graphify" + +# COPILOT_HOME is Copilot CLI's own override for its config + state dir. +readonly COPILOT_HOME="${COPILOT_HOME:-${HOME}/.copilot}" +readonly COPILOT_SKILLS_DIR="${COPILOT_HOME}/skills" +readonly COPILOT_HOOKS_DIR="${COPILOT_HOME}/hooks" +readonly COPILOT_RTK_HOOK="${COPILOT_HOOKS_DIR}/rtk-rewrite.json" +readonly COPILOT_MCP_CONFIG="${COPILOT_HOME}/mcp-config.json" + +readonly CLAUDE_PLUGIN_CACHE="${CLAUDE_CONFIG_DIR:-${HOME}/.claude}/plugins/cache" +readonly CAVEMAN_CACHE_REL="caveman/caveman" +readonly PONYTAIL_CACHE_REL="ponytail/ponytail" +readonly CLAUDE_MEM_CACHE_REL="thedotmack/claude-mem" +readonly CLAUDE_MEM_MCP_MANIFEST=".mcp.json" +# The key claude-mem uses for its server inside its own .mcp.json, and the name +# we register it under in Copilot. They differ on purpose: "mcp-search" is an +# implementation detail, "claude-mem" is what a user recognises in `mcp list`. +readonly CLAUDE_MEM_MCP_SOURCE_KEY="mcp-search" +readonly CLAUDE_MEM_MCP_SERVER_NAME="claude-mem" + +# claude-mem's MCP server talks to a local worker daemon over HTTP, and its own +# client aborts at CLAUDE_MEM_API_TIMEOUT_MS (30s by default). The first search +# after a cold worker path builds an index over the whole memory DB and measured +# 2m02s here, so the default guarantees a timeout on the very first call — which +# reads as "claude-mem is broken under Copilot" when it is not. Both timeouts +# (claude-mem's own, and Copilot's per-tool one) are raised to cover it. +readonly CLAUDE_MEM_API_TIMEOUT_MS=180000 +readonly COPILOT_MCP_TIMEOUT_MS=200000 + +readonly SKILL_MANIFEST="SKILL.md" + +readonly STATE_OK="wired" +readonly STATE_NOT_WIRED="not-wired" +readonly STATE_SOURCE_MISSING="source-missing" +readonly STATE_NA="n/a" + +readonly NOT_WIRED_REASON_CTX="MCP server, but its plugin manifest pins an absolute, version-specific interpreter path — registering it would break on the next context-mode upgrade" +readonly NOT_WIRED_REASON_PXPIPE="proxy on the Anthropic-compatible API path; Copilot CLI talks to GitHub's endpoint, so there is nothing for it to sit in front of" + +readonly EXIT_USAGE=2 + +readonly COL_GREEN=$'\033[32m' +readonly COL_RED=$'\033[31m' +readonly COL_YELLOW=$'\033[33m' +readonly COL_DIM=$'\033[2m' +readonly COL_RESET=$'\033[0m' + +# Source of the interactive confirmation, overridable via TW_TTY so the confirm +# path is testable without a live tty (same contract as upgrade.sh). +readonly TTY_DEVICE="${TW_TTY:-/dev/tty}" + +say() { printf '%s %s\n' "${COL_GREEN}==>${COL_RESET}" "$*"; } +warn() { printf '%s %s\n' "${COL_YELLOW}!!${COL_RESET}" "$*" >&2; } + +usage() { + cat <. +# Plugins version by semver (ponytail: 4.9.0) or by git SHA (caveman), so sort by +# mtime rather than by name — a SHA has no order. +newest_plugin_dir() { + local root="${CLAUDE_PLUGIN_CACHE}/$1" + [[ -d "$root" ]] || { printf ''; return; } + local newest="" + local d + for d in "$root"/*/; do + [[ -d "$d" ]] || continue + if [[ -z "$newest" || "$d" -nt "$newest" ]]; then newest="$d"; fi + done + printf '%s' "${newest%/}" +} + +caveman_skill_source() { local d; d=$(newest_plugin_dir "$CAVEMAN_CACHE_REL"); [[ -n "$d" ]] && printf '%s' "${d}/skills/caveman/${SKILL_MANIFEST}"; } +ponytail_skill_source() { local d; d=$(newest_plugin_dir "$PONYTAIL_CACHE_REL"); [[ -n "$d" ]] && printf '%s' "${d}/skills/ponytail/${SKILL_MANIFEST}"; } +claude_mem_mcp_source() { local d; d=$(newest_plugin_dir "$CLAUDE_MEM_CACHE_REL"); [[ -n "$d" ]] && printf '%s' "${d}/${CLAUDE_MEM_MCP_MANIFEST}"; } + +skill_installed() { [[ -f "${COPILOT_SKILLS_DIR}/$1/${SKILL_MANIFEST}" ]]; } + +# Is already a server in Copilot's user MCP config? Read the file rather +# than shelling out to `copilot mcp list`: the check must work with the CLI +# absent, and the file is the documented user-scope source. +mcp_server_registered() { + [[ -f "$COPILOT_MCP_CONFIG" ]] || return 1 + MCP_CONFIG="$COPILOT_MCP_CONFIG" MCP_NAME="$1" node --input-type=module -e ' + import { readFileSync } from "node:fs"; + let cfg; try { cfg = JSON.parse(readFileSync(process.env.MCP_CONFIG, "utf8")); } catch { process.exit(1); } + const servers = cfg.mcpServers || cfg.servers || {}; + process.exit(process.env.MCP_NAME in servers ? 0 : 1); + ' 2>/dev/null +} + +# ── per-tool state ──────────────────────────────────────────────── + +state_rtk() { + command -v "$RTK_BIN" >/dev/null 2>&1 || { echo "$STATE_SOURCE_MISSING"; return; } + [[ -f "$COPILOT_RTK_HOOK" ]] && echo "$STATE_OK" || echo "$STATE_NOT_WIRED" +} + +state_graphify() { + command -v "$GRAPHIFY_BIN" >/dev/null 2>&1 || { echo "$STATE_SOURCE_MISSING"; return; } + skill_installed graphify && echo "$STATE_OK" || echo "$STATE_NOT_WIRED" +} + +state_caveman() { + [[ -n "$(caveman_skill_source)" && -f "$(caveman_skill_source)" ]] || { echo "$STATE_SOURCE_MISSING"; return; } + skill_installed caveman && echo "$STATE_OK" || echo "$STATE_NOT_WIRED" +} + +state_ponytail() { + [[ -n "$(ponytail_skill_source)" && -f "$(ponytail_skill_source)" ]] || { echo "$STATE_SOURCE_MISSING"; return; } + skill_installed ponytail && echo "$STATE_OK" || echo "$STATE_NOT_WIRED" +} + +state_claude_mem() { + [[ -n "$(claude_mem_mcp_source)" && -f "$(claude_mem_mcp_source)" ]] || { echo "$STATE_SOURCE_MISSING"; return; } + mcp_server_registered "$CLAUDE_MEM_MCP_SERVER_NAME" && echo "$STATE_OK" || echo "$STATE_NOT_WIRED" +} + +# ── per-tool wiring ─────────────────────────────────────────────── + +wire_rtk() { + say "rtk → Copilot hook (rtk init -g --copilot --auto-patch)" + "$RTK_BIN" init -g --copilot --auto-patch >/dev/null 2>&1 \ + || { warn "rtk init -g --copilot failed — run it manually"; return 1; } +} + +wire_graphify() { + say "graphify → Copilot skill (graphify copilot install)" + "$GRAPHIFY_BIN" copilot install >/dev/null 2>&1 \ + || { warn "graphify copilot install failed — run it manually"; return 1; } +} + +# Register a SKILL.md with Copilot. `copilot skill add ` materialises it +# into ~/.copilot/skills// using the frontmatter name — we never write into +# that directory ourselves, so Copilot stays the owner of its own layout. +wire_skill_from_plugin() { + local label="$1" src="$2" + if [[ ! -f "$src" ]]; then + warn "${label}: skill source not found ($src)"; return 1 + fi + say "${label} → Copilot skill (copilot skill add)" + "$COPILOT_BIN" skill add "$src" >/dev/null 2>&1 \ + || { warn "copilot skill add failed for ${label}"; return 1; } +} + +wire_caveman() { wire_skill_from_plugin caveman "$(caveman_skill_source)"; } +wire_ponytail() { wire_skill_from_plugin ponytail "$(ponytail_skill_source)"; } + +# Re-register claude-mem's OWN server definition rather than hand-writing a +# command: the published .mcp.json wraps a locator that finds the current plugin +# version at runtime, so the registration survives claude-mem upgrades. A +# hardcoded path would break on the next `claude plugin update`. +wire_claude_mem() { + local manifest + manifest="$(claude_mem_mcp_source)" + if [[ ! -f "$manifest" ]]; then + warn "claude-mem: .mcp.json not found in the plugin cache"; return 1 + fi + local command_bin + command_bin=$(MANIFEST="$manifest" KEY="$CLAUDE_MEM_MCP_SOURCE_KEY" node --input-type=module -e ' + import { readFileSync } from "node:fs"; + const s = JSON.parse(readFileSync(process.env.MANIFEST, "utf8")).mcpServers[process.env.KEY]; + process.stdout.write(String(s?.command || "")); + ' 2>/dev/null) + if [[ -z "$command_bin" ]]; then + warn "claude-mem: no '${CLAUDE_MEM_MCP_SOURCE_KEY}' server in $manifest"; return 1 + fi + local server_args=() + mapfile -t server_args < <(MANIFEST="$manifest" KEY="$CLAUDE_MEM_MCP_SOURCE_KEY" node --input-type=module -e ' + import { readFileSync } from "node:fs"; + const s = JSON.parse(readFileSync(process.env.MANIFEST, "utf8")).mcpServers[process.env.KEY]; + for (const a of (s?.args || [])) console.log(a); + ' 2>/dev/null) + + say "claude-mem → Copilot MCP server '${CLAUDE_MEM_MCP_SERVER_NAME}'" + "$COPILOT_BIN" mcp add "$CLAUDE_MEM_MCP_SERVER_NAME" \ + --env "CLAUDE_MEM_API_TIMEOUT_MS=${CLAUDE_MEM_API_TIMEOUT_MS}" \ + --timeout "$COPILOT_MCP_TIMEOUT_MS" \ + -- "$command_bin" "${server_args[@]}" >/dev/null 2>&1 \ + || { warn "copilot mcp add ${CLAUDE_MEM_MCP_SERVER_NAME} failed"; return 1; } +} + +# ── report ──────────────────────────────────────────────────────── + +format_row() { + local tool="$1" mechanism="$2" state="$3" note="${4:-}" + local color symbol + case "$state" in + "$STATE_OK") color="$COL_GREEN"; symbol="✓" ;; + "$STATE_NOT_WIRED") color="$COL_RED"; symbol="✗" ;; + "$STATE_SOURCE_MISSING") color="$COL_YELLOW"; symbol="⚠" ;; + *) color="$COL_DIM"; symbol="·" ;; + esac + printf " %s%s%s %-12s %-8s %-16s %s%s%s\n" \ + "$color" "$symbol" "$COL_RESET" "$tool" "$mechanism" "$state" \ + "$COL_DIM" "$note" "$COL_RESET" +} + +action="${1:-check}" +shift || true +assume_yes=false +for arg in "$@"; do + case "$arg" in + --yes) assume_yes=true ;; + *) echo "unknown arg: $arg" >&2; echo "" >&2; usage >&2; exit "$EXIT_USAGE" ;; + esac +done +case "$action" in + check|wire) ;; + help|-h|--help) usage; exit 0 ;; + *) echo "unknown action: $action" >&2; echo "" >&2; usage >&2; exit "$EXIT_USAGE" ;; +esac + +echo "" +echo "# /tokenwar copilot" +echo "" + +if ! command -v "$COPILOT_BIN" >/dev/null 2>&1; then + warn "GitHub Copilot CLI not installed — nothing to wire (npm install -g @github/copilot)" + exit 1 +fi + +rtk_st=$(state_rtk) +graphify_st=$(state_graphify) +caveman_st=$(state_caveman) +ponytail_st=$(state_ponytail) +mem_st=$(state_claude_mem) + +printf " %s %-12s %-8s %-16s %s\n" "·" "tool" "via" "state" "note" +printf " ─────────────────────────────────────────────────────────────────\n" +format_row "rtk" "hook" "$rtk_st" "$COPILOT_RTK_HOOK" +format_row "graphify" "skill" "$graphify_st" "${COPILOT_SKILLS_DIR}/graphify" +format_row "caveman" "skill" "$caveman_st" "${COPILOT_SKILLS_DIR}/caveman" +format_row "ponytail" "skill" "$ponytail_st" "${COPILOT_SKILLS_DIR}/ponytail" +format_row "claude-mem" "MCP" "$mem_st" "${COPILOT_MCP_CONFIG} → ${CLAUDE_MEM_MCP_SERVER_NAME}" +format_row "context-mode" "-" "$STATE_NA" "$NOT_WIRED_REASON_CTX" +format_row "pxpipe" "-" "$STATE_NA" "$NOT_WIRED_REASON_PXPIPE" +echo "" + +pending=() +[[ "$rtk_st" == "$STATE_NOT_WIRED" ]] && pending+=(rtk) +[[ "$graphify_st" == "$STATE_NOT_WIRED" ]] && pending+=(graphify) +[[ "$caveman_st" == "$STATE_NOT_WIRED" ]] && pending+=(caveman) +[[ "$ponytail_st" == "$STATE_NOT_WIRED" ]] && pending+=(ponytail) +[[ "$mem_st" == "$STATE_NOT_WIRED" ]] && pending+=(claude-mem) + +if [[ "$action" == "check" ]]; then + if (( ${#pending[@]} == 0 )); then + say "Every installed tool is wired into Copilot." + exit 0 + fi + echo " ${#pending[@]} tool(s) not wired: ${pending[*]}" + echo " → Run \`tokenwar copilot wire\` to apply." + echo "" + exit 1 +fi + +# ── wire ────────────────────────────────────────────────────────── +if (( ${#pending[@]} == 0 )); then + say "Nothing to wire — every installed tool already reaches Copilot." + exit 0 +fi + +echo " will wire: ${pending[*]}" +echo "" +if ! $assume_yes; then + reply="" + if { : <"$TTY_DEVICE"; } 2>/dev/null; then + printf "Wire these into Copilot? [y/N] " + read -r reply <"$TTY_DEVICE" 2>/dev/null || reply="" + fi + case "$reply" in + y|Y|yes|YES) ;; + *) + if [[ -z "$reply" ]]; then + warn "No interactive terminal — re-run with --yes to apply. Skipped." + else + say "Skipped." + fi + exit 0 + ;; + esac +fi + +rc=0 +for tool in "${pending[@]}"; do + case "$tool" in + rtk) wire_rtk || rc=1 ;; + graphify) wire_graphify || rc=1 ;; + caveman) wire_caveman || rc=1 ;; + ponytail) wire_ponytail || rc=1 ;; + claude-mem) wire_claude_mem || rc=1 ;; + esac +done + +echo "" +if (( rc == 0 )); then + say "Copilot wiring complete. ${COL_DIM}Restart your Copilot CLI session to load it.${COL_RESET}" +else + warn "One or more wiring steps failed — see the messages above." +fi +exit "$rc" diff --git a/scripts/lib/providers.sh b/scripts/lib/providers.sh index 8a40f3a..d6c90f3 100644 --- a/scripts/lib/providers.sh +++ b/scripts/lib/providers.sh @@ -10,6 +10,8 @@ # Gemini — no local token store; CLI detection only, telemetry N/A # Kimi — ~/.kimi-code stores sessions/config, but no documented token store # opencode — ~/.local/share/opencode/opencode.db → session token cols (real) +# Copilot — ~/.copilot/session-store.db → assistant_usage_events (real, with +# per-model rows and the AI-credit cost GitHub actually bills) set -euo pipefail @@ -17,7 +19,7 @@ set -euo pipefail # that source this file (gain.sh, status.sh, check.sh, check-updates.sh, # tokenwar-statusline.sh), not within this file, hence the SC2034 suppressions. # shellcheck disable=SC2034 -readonly PROVIDER_COUNT=5 +readonly PROVIDER_COUNT=6 # shellcheck disable=SC2034 readonly PROVIDER_IDX_CODEX=1 # shellcheck disable=SC2034 @@ -26,11 +28,19 @@ readonly PROVIDER_IDX_GEMINI=2 readonly PROVIDER_IDX_KIMI=3 # shellcheck disable=SC2034 readonly PROVIDER_IDX_OPENCODE=4 +# shellcheck disable=SC2034 +readonly PROVIDER_IDX_COPILOT=5 readonly CODEX_STATE_DB="${HOME}/.codex/state_5.sqlite" readonly KIMI_CODE_HOME="${KIMI_CODE_HOME:-${HOME}/.kimi-code}" readonly OPENCODE_DATA_HOME="${OPENCODE_DATA_HOME:-${HOME}/.local/share/opencode}" readonly OPENCODE_STATE_DB="${OPENCODE_DATA_HOME}/opencode.db" +# COPILOT_HOME is Copilot CLI's own override for its config + state dir. +readonly COPILOT_HOME="${COPILOT_HOME:-${HOME}/.copilot}" +readonly COPILOT_STATE_DB="${COPILOT_HOME}/session-store.db" +# 1 AI credit = 1e9 nano-AIU — the unit `assistant_usage_events.total_nano_aiu` +# stores, and what the CLI prints as "AI Credits" in its exit summary. +readonly COPILOT_NANO_AIU_PER_CREDIT=1000000000 # CHARS_PER_TOKEN is defined in gain.sh (primary consumer) # ── provider metadata ──────────────────────────────────────────────── @@ -42,6 +52,7 @@ provider_id() { 2) echo "gemini" ;; 3) echo "kimi" ;; 4) echo "opencode" ;; + 5) echo "copilot" ;; esac } @@ -52,6 +63,10 @@ provider_name() { 2) echo "Gemini CLI" ;; 3) echo "Kimi Code CLI" ;; 4) echo "opencode" ;; + # Short form on purpose: the provider tables are %-14s columns and the + # official "GitHub Copilot CLI" overflows them. The full product name + # lives in provider_label, which is printed unaligned. + 5) echo "Copilot CLI" ;; esac } @@ -62,6 +77,7 @@ provider_cli() { 2) echo "gemini" ;; 3) echo "kimi" ;; 4) echo "opencode" ;; + 5) echo "copilot" ;; esac } @@ -72,6 +88,13 @@ provider_input_usd_per_mtok() { 2) echo "1.25" ;; # Gemini 2.5 Pro input — VERIFY at ai.google.dev/pricing 3) echo "0.30" ;; # Kimi K2/Kimi Code input — VERIFY at platform.kimi.ai/pricing 4) echo "3.00" ;; # opencode is model-agnostic (BYO provider) — representative input rate, VERIFY per your model + # Copilot is NOT billed per token: it is a seat subscription plus AI + # credits (premium requests on the legacy plan), and GitHub publishes no + # per-token list price. This is a GPT-5-class input rate, so Copilot's $ + # column is an API-equivalent valuation, never an invoice. The unit that + # IS billed — AI credits, read from total_nano_aiu — is surfaced in the + # telemetry note. VERIFY against your plan and model. + 5) echo "1.25" ;; esac } @@ -82,6 +105,7 @@ provider_label() { 2) echo "Gemini 2.5 Pro" ;; 3) echo "Kimi Code" ;; 4) echo "opencode (BYO model)" ;; + 5) echo "GitHub Copilot CLI (seat + AI credits)" ;; esac } @@ -92,6 +116,7 @@ provider_config_dir() { 2) echo "${HOME}/.gemini" ;; 3) echo "$KIMI_CODE_HOME" ;; 4) echo "${HOME}/.config/opencode" ;; + 5) echo "$COPILOT_HOME" ;; esac } @@ -101,11 +126,15 @@ provider_is_installed() { command -v "$cli" >/dev/null 2>&1 } +# Trailing punctuation is stripped because CLIs disagree on how a version line +# ends: Copilot prints "GitHub Copilot CLI 1.0.83." (full stop, then a second +# line about updates), which would otherwise be compared as the literal +# "1.0.83." and never match a registry version. provider_version() { local cli cli=$(provider_cli "$1") if ! command -v "$cli" >/dev/null 2>&1; then echo "-"; return; fi - "$cli" --version 2>/dev/null | head -1 | sed 's/^[^0-9]*//' | awk '{print $1}' + "$cli" --version 2>/dev/null | head -1 | sed 's/^[^0-9]*//' | awk '{print $1}' | sed 's/[.,;:]*$//' } # ── telemetry: total tokens saved per provider ──────────────────────── @@ -123,6 +152,7 @@ provider_telemetry_total() { 2) gemini_telemetry_total ;; 3) kimi_telemetry_total ;; 4) opencode_telemetry_total ;; + 5) copilot_telemetry_total ;; esac } @@ -133,6 +163,7 @@ provider_telemetry_monthly() { 2) gemini_telemetry_monthly ;; 3) kimi_telemetry_monthly ;; 4) opencode_telemetry_monthly ;; + 5) copilot_telemetry_monthly ;; esac } @@ -258,3 +289,52 @@ except Exception: pass " 2>/dev/null || echo "" } + +# ── Copilot CLI native telemetry (SQLite) ───────────────────────────── +# Copilot CLI records one row per assistant call in `assistant_usage_events`, +# with the token breakdown AND `total_nano_aiu` — the AI-credit cost GitHub +# actually bills. Tokens go in the numeric field so the provider table stays +# comparable across CLIs; the credits go in the note, because that is the unit +# on the invoice and reporting only tokens would misstate what Copilot costs. + +copilot_telemetry_total() { + if [[ ! -f "$COPILOT_STATE_DB" ]]; then + echo "N/A|Copilot session store not found ($COPILOT_STATE_DB)|0"; return + fi + if ! command -v python3 >/dev/null 2>&1; then + echo "N/A|python3 required to read the Copilot DB|0"; return + fi + COPILOT_DB="$COPILOT_STATE_DB" NANO_PER_CREDIT="$COPILOT_NANO_AIU_PER_CREDIT" python3 -c " +import sqlite3, os, sys +SQL = 'SELECT SUM(COALESCE(input_tokens,0)+COALESCE(output_tokens,0)+COALESCE(reasoning_tokens,0)), COUNT(DISTINCT session_id), SUM(COALESCE(total_nano_aiu,0)) FROM assistant_usage_events' +try: + db = sqlite3.connect(os.environ['COPILOT_DB']) + row = db.execute(SQL).fetchone() + if not row or row[0] is None: + print('N/A|no Copilot sessions with token usage|0') + sys.exit(0) + tokens = int(row[0]) + sessions = int(row[1]) + credits = float(row[2] or 0) / float(os.environ['NANO_PER_CREDIT']) + human = f'{tokens/1e6:.1f}M' if tokens >= 1e6 else f'{tokens/1e3:.1f}K' if tokens >= 1e3 else str(tokens) + print(f'{human}|{sessions} Copilot sessions (real assistant_usage_events) - {credits:.2f} AI credits billed|{tokens}') +except Exception as e: + print(f'N/A|Copilot DB read failed: {e}|0') +" 2>/dev/null || echo "N/A|Copilot DB query failed|0" +} + +copilot_telemetry_monthly() { + if [[ ! -f "$COPILOT_STATE_DB" ]]; then echo ""; return; fi + if ! command -v python3 >/dev/null 2>&1; then echo ""; return; fi + COPILOT_DB="$COPILOT_STATE_DB" python3 -c " +import sqlite3, os +SQL = \"SELECT strftime('%Y-%m', created_at) AS m, SUM(COALESCE(input_tokens,0)+COALESCE(output_tokens,0)+COALESCE(reasoning_tokens,0)), COUNT(DISTINCT session_id) FROM assistant_usage_events WHERE created_at IS NOT NULL GROUP BY m ORDER BY m\" +try: + db = sqlite3.connect(os.environ['COPILOT_DB']) + for r in db.execute(SQL).fetchall(): + if r[0] and r[1]: + print(f'{r[0]} {r[1]} {r[2]}') +except Exception: + pass +" 2>/dev/null || echo "" +} diff --git a/scripts/scan.sh b/scripts/scan.sh index 5584a9c..1338445 100755 --- a/scripts/scan.sh +++ b/scripts/scan.sh @@ -60,6 +60,12 @@ const CLIENTS = [ cli: "opencode", roots: ["TOKENWAR_OPENCODE_LOG_ROOT", "~/.local/share/opencode", "~/.config/opencode"], }, + { + id: "copilot", + name: "GitHub Copilot CLI", + cli: "copilot", + roots: ["TOKENWAR_COPILOT_LOG_ROOT", "~/.copilot"], + }, { id: "vibe", name: "Vibe/Ora agents", diff --git a/scripts/status.sh b/scripts/status.sh index bb88544..e1a5989 100755 --- a/scripts/status.sh +++ b/scripts/status.sh @@ -214,6 +214,7 @@ if $json_mode; then gemini) pnote="telemetry: N/A (server-side sessions)" ;; kimi) pnote="telemetry: N/A (~/.kimi-code has no token store)" ;; opencode) pnote="telemetry: ~/.local/share/opencode/opencode.db (session tokens)" ;; + copilot) pnote="telemetry: ~/.copilot/session-store.db (assistant_usage_events)" ;; *) pnote="" ;; esac # Build JSON entry via node to ensure proper escaping @@ -313,6 +314,7 @@ for i in $(seq 0 $((PROVIDER_COUNT - 1))); do gemini) pnote="telemetry: N/A (server-side sessions)" ;; kimi) pnote="telemetry: N/A (~/.kimi-code has no token store)" ;; opencode) pnote="telemetry: ~/.local/share/opencode/opencode.db (session tokens)" ;; + copilot) pnote="telemetry: ~/.copilot/session-store.db (assistant_usage_events)" ;; *) pnote="" ;; esac diff --git a/scripts/tokenwar-launch.sh b/scripts/tokenwar-launch.sh index ccd1234..47d486e 100755 --- a/scripts/tokenwar-launch.sh +++ b/scripts/tokenwar-launch.sh @@ -1,16 +1,17 @@ #!/usr/bin/env bash # tokenwar launch banner — shown when a wrapped CLI starts. # -# Codex, Gemini, Kimi, and opencode do NOT expose a persistent status-bar API the way Claude -# Code does (their footers are hardcoded in their TUIs). The closest we can do -# without touching their binaries is a one-time banner at launch: +# Codex, Gemini, Kimi, opencode, and GitHub Copilot CLI do NOT expose a +# persistent status-bar API the way Claude Code does (their footers are +# hardcoded in their TUIs). The closest we can do without touching their +# binaries is a one-time banner at launch: # 1. print the tokenwar stack bar (same renderer as the Claude statusline) # 2. remind the user that `tokenwar status` shows the full state on demand # 3. leave upgrade drift as a statusline hint only # # This is intentionally non-blocking and silent for non-interactive launches -# (`codex exec`, `gemini -p ...`, `kimi -p ...`, `opencode run ...`, pipes) so it never pollutes -# scripted output. +# (`codex exec`, `gemini -p ...`, `kimi -p ...`, `opencode run ...`, +# `copilot -p ...`, pipes) so it never pollutes scripted output. # # Usage: tokenwar-launch.sh [original CLI args...] # is the CLI being launched — used only for the @@ -32,7 +33,10 @@ readonly COL_RESET=$'\033[0m' # Non-interactive subcommands that must NEVER get a banner (scripted/automation # entrypoints whose stdout is consumed by tooling). -readonly NONINTERACTIVE_SUBCMDS=" exec e completion mcp mcp-server app-server apply a review cloud exec-server resume fork run serve " +# Subcommands whose stdout is consumed by tooling or which exit immediately. +# Copilot contributes app / help / init / login / plugin / plugins / skill / +# update / version on top of the shared ones (completion, mcp) it already used. +readonly NONINTERACTIVE_SUBCMDS=" exec e completion mcp mcp-server app-server apply a review cloud exec-server resume fork run serve app help init login plugin plugins skill update version " # Bail silently unless this is a genuine interactive TUI launch. should_banner() { @@ -43,10 +47,12 @@ should_banner() { if [[ -n "$first" && "$NONINTERACTIVE_SUBCMDS" == *" $first "* ]]; then return 1 fi - # Gemini headless flags. + # Headless / immediate-exit flags across the wrapped CLIs. `--acp` starts + # Copilot as an Agent Client Protocol server, which is a machine channel. for a in "$@"; do case "$a" in -p|--prompt|-o|--output-format|-l|--list-extensions|--list-sessions) return 1 ;; + --acp|-v|--version|-h|--help) return 1 ;; esac done return 0 diff --git a/scripts/tokenwar.sh b/scripts/tokenwar.sh index c3a291c..fce87e7 100755 --- a/scripts/tokenwar.sh +++ b/scripts/tokenwar.sh @@ -10,6 +10,7 @@ # tokenwar scan # local agent-log scan + recommendations # tokenwar check # complementarity / conflict detector # tokenwar test # end-to-end ping: is each tool actually working? +# tokenwar copilot # report/wire the stack into GitHub Copilot CLI # tokenwar upgrade # bump managed tools (asks confirmation) # tokenwar updates # show available updates (throttled cache) # tokenwar doctor # full pipeline: status → test → check → gain @@ -31,9 +32,10 @@ tokenwar — token-saving stack manager Usage: tokenwar Commands: - status state of the 7 tools + providers (codex, gemini, kimi, opencode) + status state of the 7 tools + providers (codex, gemini, kimi, opencode, copilot) gain per-tool + per-provider token savings + monthly \$ value scan scan local agent logs and recommend token-saving tools + copilot report which tools reach GitHub Copilot CLI ('copilot wire' to fix) check complementarity / conflict detector test end-to-end ping: is each tool actually working? upgrade bump managed tools to latest (asks confirmation) @@ -53,6 +55,7 @@ case "$cmd" in gain) exec bash "${SCRIPT_DIR}/gain.sh" "$@" ;; scan) exec bash "${SCRIPT_DIR}/scan.sh" "$@" ;; check) exec bash "${SCRIPT_DIR}/check.sh" "$@" ;; + copilot) exec bash "${SCRIPT_DIR}/copilot.sh" "$@" ;; test) bash "${SCRIPT_DIR}/status.sh" --test "$@" rc=$? diff --git a/tests/copilot.bats b/tests/copilot.bats new file mode 100644 index 0000000..003a49e --- /dev/null +++ b/tests/copilot.bats @@ -0,0 +1,243 @@ +#!/usr/bin/env bats +# Tests for copilot.sh — pointing the stack at GitHub Copilot CLI's own +# extension points (hook / skills / MCP). +# +# Copilot is wired from THREE different mechanisms, so the tests assert the +# mechanism actually used, not just "something happened": rtk goes through its +# own installer, skills go through `copilot skill add` (Copilot owns the layout +# of ~/.copilot/skills), and claude-mem is re-registered from its OWN .mcp.json +# so the wiring survives a claude-mem upgrade. + +setup() { + SCRIPT="$BATS_TEST_DIRNAME/../scripts/copilot.sh" + [ -x "$SCRIPT" ] || skip "copilot.sh not executable" + + MOCK_BIN="$(mktemp -d)" + export ORIG_PATH="$PATH" + export PATH="$MOCK_BIN:$PATH" + + export HOME="$(mktemp -d)" + export CLAUDE_CONFIG_DIR="$HOME/.claude" + export COPILOT_HOME="$HOME/.copilot" + mkdir -p "$COPILOT_HOME" + + export CALL_LOG="$HOME/calls.log" + export MCP_ARGS_DUMP="$HOME/mcp-args.txt" +} + +teardown() { + export PATH="$ORIG_PATH" + rm -rf "$MOCK_BIN" "$HOME" +} + +# `copilot skill add ` materialises the skill under ~/.copilot/skills/. +# The mock reproduces that so state detection can be asserted after wiring. +mock_copilot() { + cat > "$MOCK_BIN/copilot" <> "$CALL_LOG" +if [[ "\$1" == "skill" && "\$2" == "add" ]]; then + name=\$(sed -n 's/^name:[[:space:]]*//p' "\$3" | head -1) + mkdir -p "$COPILOT_HOME/skills/\$name" + cp "\$3" "$COPILOT_HOME/skills/\$name/SKILL.md" +fi +if [[ "\$1" == "mcp" && "\$2" == "add" ]]; then + # Record every argv element on its own line so the test can assert the + # exact command+args that were forwarded, whitespace included. + printf '%s\\n' "\$@" > "$MCP_ARGS_DUMP" + mkdir -p "$COPILOT_HOME" + printf '{"mcpServers":{"%s":{}}}' "\$3" > "$COPILOT_HOME/mcp-config.json" +fi +exit 0 +EOF + chmod +x "$MOCK_BIN/copilot" +} + +mock_rtk() { + cat > "$MOCK_BIN/rtk" <> "$CALL_LOG" +if [[ "\$1" == "init" ]]; then + mkdir -p "$COPILOT_HOME/hooks" + echo '{"version":1}' > "$COPILOT_HOME/hooks/rtk-rewrite.json" +fi +exit 0 +EOF + chmod +x "$MOCK_BIN/rtk" +} + +mock_graphify() { + cat > "$MOCK_BIN/graphify" <> "$CALL_LOG" +if [[ "\$1" == "copilot" && "\$2" == "install" ]]; then + mkdir -p "$COPILOT_HOME/skills/graphify" + echo "# graphify" > "$COPILOT_HOME/skills/graphify/SKILL.md" +fi +exit 0 +EOF + chmod +x "$MOCK_BIN/graphify" +} + +# A plugin cache entry, versioned like the real one (semver or git SHA). +seed_plugin_skill() { + local rel="$1" version="$2" skill="$3" + local dir="$CLAUDE_CONFIG_DIR/plugins/cache/$rel/$version/skills/$skill" + mkdir -p "$dir" + printf -- '---\nname: %s\ndescription: test\n---\nbody\n' "$skill" > "$dir/SKILL.md" +} + +seed_claude_mem_mcp() { + local dir="$CLAUDE_CONFIG_DIR/plugins/cache/thedotmack/claude-mem/13.6.1" + mkdir -p "$dir" + cat > "$dir/.mcp.json" <<'EOF' +{"mcpServers":{"mcp-search":{"type":"stdio","command":"node","args":["-e","const locator = 1; // long inline script"]}}} +EOF +} + +seed_all_sources() { + mock_rtk + mock_graphify + seed_plugin_skill "caveman/caveman" "766dce6b1394" "caveman" + seed_plugin_skill "ponytail/ponytail" "4.9.0" "ponytail" + seed_claude_mem_mcp +} + +# ── check ───────────────────────────────────────────────────────── + +@test "no Copilot CLI → refuses with an install hint, exit 1" { + seed_all_sources + ln -sf "$(command -v node)" "$MOCK_BIN/node" + PATH="$MOCK_BIN:/usr/bin:/bin" # excludes ~/.nvm etc, so no real copilot + run bash "$SCRIPT" check + [ "$status" -eq 1 ] + [[ "$output" == *"Copilot CLI not installed"* ]] + [[ "$output" == *"@github/copilot"* ]] +} + +@test "check reports every installed-but-unwired tool and exits 1" { + mock_copilot + seed_all_sources + run bash "$SCRIPT" check + [ "$status" -eq 1 ] + [[ "$output" == *"rtk"*"not-wired"* ]] + [[ "$output" == *"5 tool(s) not wired"* ]] + [[ "$output" == *"tokenwar copilot wire"* ]] +} + +@test "check reports source-missing when the tool itself is not installed" { + mock_copilot + # No rtk/graphify on PATH and no plugin cache: there is nothing to wire, which + # is a different state from "installed but not wired". + ln -sf "$(command -v node)" "$MOCK_BIN/node" + PATH="$MOCK_BIN:/usr/bin:/bin" + run bash "$SCRIPT" check + [[ "$output" == *"caveman"*"source-missing"* ]] + [[ "$output" == *"claude-mem"*"source-missing"* ]] +} + +@test "check reports context-mode and pxpipe as n/a with a reason" { + mock_copilot + seed_all_sources + run bash "$SCRIPT" check + [[ "$output" == *"context-mode"*"n/a"*"version-specific interpreter path"* ]] + [[ "$output" == *"pxpipe"*"n/a"*"GitHub's endpoint"* ]] +} + +@test "check exits 0 once every installed tool is wired" { + mock_copilot + seed_all_sources + run bash "$SCRIPT" wire --yes + [ "$status" -eq 0 ] + run bash "$SCRIPT" check + [ "$status" -eq 0 ] + [[ "$output" == *"Every installed tool is wired"* ]] +} + +# ── wire ────────────────────────────────────────────────────────── + +@test "wire uses each tool's own mechanism" { + mock_copilot + seed_all_sources + run bash "$SCRIPT" wire --yes + [ "$status" -eq 0 ] + grep -qx "rtk init -g --copilot --auto-patch" "$CALL_LOG" + grep -qx "graphify copilot install" "$CALL_LOG" + # Skills go through the CLI, never a hand-rolled copy into ~/.copilot/skills. + grep -q "^copilot skill add .*/skills/caveman/SKILL.md$" "$CALL_LOG" + grep -q "^copilot skill add .*/skills/ponytail/SKILL.md$" "$CALL_LOG" +} + +@test "wire registers claude-mem from its OWN .mcp.json definition" { + # A hardcoded path to the current plugin version would break on the next + # `claude plugin update`; claude-mem's published args carry a runtime locator. + mock_copilot + seed_all_sources + run bash "$SCRIPT" wire --yes + [ "$status" -eq 0 ] + grep -qx "mcp" "$MCP_ARGS_DUMP" + grep -qx "add" "$MCP_ARGS_DUMP" + grep -qx "claude-mem" "$MCP_ARGS_DUMP" + grep -qx "node" "$MCP_ARGS_DUMP" + grep -qx -- "-e" "$MCP_ARGS_DUMP" + grep -qx "const locator = 1; // long inline script" "$MCP_ARGS_DUMP" +} + +@test "wire raises both timeouts around claude-mem's cold first search" { + # claude-mem's first search after a cold worker path builds an index over the + # whole memory DB and measured 2m02s. Its own client aborts at 30s by + # default, so without this the very first MCP call always fails. + mock_copilot + seed_all_sources + run bash "$SCRIPT" wire --yes + grep -qx "CLAUDE_MEM_API_TIMEOUT_MS=180000" "$MCP_ARGS_DUMP" + grep -qx -- "--timeout" "$MCP_ARGS_DUMP" + grep -qx "200000" "$MCP_ARGS_DUMP" +} + +@test "wire is idempotent — a second run does nothing" { + mock_copilot + seed_all_sources + run bash "$SCRIPT" wire --yes + [ "$status" -eq 0 ] + : > "$CALL_LOG" + run bash "$SCRIPT" wire --yes + [ "$status" -eq 0 ] + [[ "$output" == *"Nothing to wire"* ]] + [ ! -s "$CALL_LOG" ] +} + +@test "wire without --yes and without a tty skips instead of applying" { + mock_copilot + seed_all_sources + TW_TTY="$HOME/no-such-tty" run bash "$SCRIPT" wire "$COPILOT_HOME/hooks/rtk-rewrite.json" + run bash "$SCRIPT" wire --yes + [ "$status" -eq 0 ] + ! grep -q "^rtk init" "$CALL_LOG" + grep -qx "graphify copilot install" "$CALL_LOG" +} + +@test "unknown action exits 2 with usage" { + mock_copilot + run bash "$SCRIPT" bogus + [ "$status" -eq 2 ] + [[ "$output" == *"Usage:"* ]] +} + +@test "dispatcher routes the copilot subcommand" { + mock_copilot + seed_all_sources + run bash "$BATS_TEST_DIRNAME/../scripts/tokenwar.sh" copilot check + [[ "$output" == *"# /tokenwar copilot"* ]] +} diff --git a/tests/install.bats b/tests/install.bats index 92c4d87..40d06af 100644 --- a/tests/install.bats +++ b/tests/install.bats @@ -359,6 +359,36 @@ EOF grep -qx "install" "$GRAPHIFY_LOG" } +@test "--with-copilot delegates to scripts/copilot.sh rather than re-implementing it" { + # One implementation of the wiring, shared with `tokenwar copilot wire`. + # If install.sh ever grows its own copy, this test stops seeing the call. + mock_claude_empty + ln -s "$(command -v node)" "$MOCK_BIN/node" + export COPILOT_WIRE_LOG="$HOME/copilot-wire.log" + cat > "$TOKENWAR_DIR/scripts/copilot.sh" <> "$COPILOT_WIRE_LOG" +exit 0 +EOF + chmod +x "$TOKENWAR_DIR/scripts/copilot.sh" + printf '#!/usr/bin/env bash\nexit 0\n' > "$MOCK_BIN/copilot" + chmod +x "$MOCK_BIN/copilot" + PATH="$MOCK_BIN:/usr/bin:/bin" + run bash "$SCRIPT" --with-copilot + [ "$status" -eq 0 ] + grep -qx "wire --yes" "$COPILOT_WIRE_LOG" +} + +@test "--with-copilot warns and skips when the Copilot CLI is absent" { + mock_claude_empty + ln -s "$(command -v node)" "$MOCK_BIN/node" + rm -f "$MOCK_BIN/copilot" + PATH="$MOCK_BIN:/usr/bin:/bin" + run bash "$SCRIPT" --with-copilot + [ "$status" -eq 0 ] + [[ "$output" == *"Copilot CLI not found"* ]] +} + @test "unknown argument exits non-zero" { mock_claude_empty run bash "$SCRIPT" --bogus diff --git a/tests/launch.bats b/tests/launch.bats index 647ffa2..f8ecb0e 100644 --- a/tests/launch.bats +++ b/tests/launch.bats @@ -71,3 +71,48 @@ EOF [[ "$output" == *"/tokenwar upgrade"* ]] [[ "$output" != *"Upgrade now?"* ]] } + +# ── GitHub Copilot CLI ──────────────────────────────────────────── +# +# These run under a real pty (`script -qfec`). Without one, `[[ -t 1 ]]` is false +# and the banner is suppressed no matter what the subcommand/flag filter says — +# the test would pass against a launcher that knows nothing about Copilot. + +copilot_launch() { + command -v script >/dev/null 2>&1 || skip "script command not available" + run script -qfec "env HOME='$HOME' bash '$SCRIPT' copilot $*" /dev/null +} + +@test "copilot -p headless flag → no banner even on a tty" { + copilot_launch -p "summarize" + [ "$status" -eq 0 ] + [[ "$output" != *"tokenwar"* ]] +} + +@test "copilot --acp (Agent Client Protocol server) → no banner on a tty" { + # --acp turns the CLI into a machine channel; a banner on stdout corrupts it. + copilot_launch --acp + [ "$status" -eq 0 ] + [[ "$output" != *"tokenwar"* ]] +} + +@test "copilot management subcommands → no banner on a tty" { + for sub in mcp skill plugin update version login; do + copilot_launch "$sub" list + [ "$status" -eq 0 ] + [[ "$output" != *"tokenwar"* ]] + done +} + +@test "copilot --version → no banner on a tty" { + copilot_launch --version + [ "$status" -eq 0 ] + [[ "$output" != *"tokenwar"* ]] +} + +@test "interactive copilot launch prints the banner naming the provider" { + copilot_launch + [ "$status" -eq 0 ] + [[ "$output" == *"tokenwar"*"copilot"* ]] + [[ "$output" == *"tokenwar status"* ]] +} diff --git a/tests/providers.bats b/tests/providers.bats index bb853ad..9179a45 100644 --- a/tests/providers.bats +++ b/tests/providers.bats @@ -1,5 +1,5 @@ #!/usr/bin/env bats -# Tests for multi-provider support — Codex, Gemini, Kimi, opencode, Claude detection. +# Tests for multi-provider support — Codex, Gemini, Kimi, opencode, Copilot, Claude detection. setup() { GAIN_SCRIPT="$BATS_TEST_DIRNAME/../scripts/gain.sh" @@ -77,6 +77,21 @@ EOF chmod +x "$MOCK_BIN/opencode" } +# Copilot's version line ends with a full stop and is followed by an update +# notice: "GitHub Copilot CLI 1.0.83." + "Run 'copilot update'...". Both halves +# are reproduced so the parser is tested against the real shape. +mock_copilot() { + cat > "$MOCK_BIN/copilot" <<'EOF' +#!/usr/bin/env bash +if [[ "$1" == "--version" || "$1" == "-v" ]]; then + echo "GitHub Copilot CLI 1.0.83." + echo "Run 'copilot update' to check for updates." +fi +exit 0 +EOF + chmod +x "$MOCK_BIN/copilot" +} + @test "status.sh detects Codex CLI when installed" { mock_claude_with_plugins '[ {"id":"context-mode@context-mode","version":"1.0.107","enabled":true}, @@ -193,3 +208,66 @@ db.commit() [[ "$output" == *"opencode"*"30.0K"* ]] [[ "$output" == *"2 opencode sessions (real token cols)"* ]] } + +# ── Copilot CLI ─────────────────────────────────────────────────── + +@test "status.sh detects Copilot CLI and strips its trailing full stop" { + mock_claude_with_plugins '[ + {"id":"context-mode@context-mode","version":"1.0.107","enabled":true} + ]' + mock_rtk_alive + mock_copilot + run bash "$STATUS_SCRIPT" + # "1.0.83." would never compare equal to a registry version — the parser has + # to drop the trailing punctuation. + [[ "$output" == *"Copilot CLI"*"1.0.83"*"OK"* ]] + [[ "$output" != *"1.0.83."* ]] +} + +@test "status.sh names the Copilot telemetry source" { + mock_claude_with_plugins '[]' + mock_rtk_alive + mock_copilot + run bash "$STATUS_SCRIPT" + [[ "$output" == *"session-store.db"* ]] +} + +@test "gain.sh shows Copilot N/A when its session store is absent" { + mock_rtk_alive + mock_copilot + COPILOT_HOME="$BATS_TEST_TMPDIR/no-copilot" run bash "$GAIN_SCRIPT" + [[ "$output" == *"Copilot CLI"*"N/A"* ]] +} + +@test "gain.sh reads REAL Copilot token telemetry and AI credits from session-store.db" { + command -v python3 >/dev/null 2>&1 || skip "python3 required to build/read the Copilot DB" + mock_rtk_alive + mock_copilot + local copilot_home="$BATS_TEST_TMPDIR/copilot-home" + mkdir -p "$copilot_home" + # Mirror the real schema's usage columns: two calls in one session totalling + # 30000 tokens (20000 in + 9000 out + 1000 reasoning) and 1.5 AI credits. + COPILOT_DB="$copilot_home/session-store.db" python3 - <<'PYEOF' +import sqlite3, os +db = sqlite3.connect(os.environ['COPILOT_DB']) +db.execute( + "CREATE TABLE assistant_usage_events (" + "id integer PRIMARY KEY AUTOINCREMENT, session_id text NOT NULL, model text NOT NULL," + "input_tokens integer, output_tokens integer, cache_read_tokens integer," + "cache_write_tokens integer, reasoning_tokens integer, total_nano_aiu integer," + "created_at text)" +) +ins = ("INSERT INTO assistant_usage_events (session_id, model, input_tokens, output_tokens," + " cache_read_tokens, cache_write_tokens, reasoning_tokens, total_nano_aiu, created_at)" + " VALUES (?,?,?,?,?,?,?,?,?)") +db.execute(ins, ('s1', 'm', 12000, 5000, 0, 0, 1000, 1000000000, '2026-09-01T10:00:00.000Z')) +db.execute(ins, ('s1', 'm', 8000, 4000, 0, 0, 0, 500000000, '2026-09-02T10:00:00.000Z')) +db.commit() +PYEOF + COPILOT_HOME="$copilot_home" run bash "$GAIN_SCRIPT" + [ "$status" -eq 0 ] + [[ "$output" == *"Copilot CLI"*"30.0K"* ]] + [[ "$output" == *"1 Copilot sessions (real assistant_usage_events)"* ]] + # AI credits are the unit GitHub actually bills — tokens alone understate it. + [[ "$output" == *"1.50 AI credits billed"* ]] +} diff --git a/tests/scan.bats b/tests/scan.bats index 52fb9c8..c62d941 100644 --- a/tests/scan.bats +++ b/tests/scan.bats @@ -130,3 +130,45 @@ EOF [ "$status" -eq 0 ] [[ "$output" == *"# /tokenwar scan"* ]] } + +@test "scan detects GitHub Copilot CLI logs as a client" { + # Copilot writes session state under ~/.copilot; the scan must pick it up + # like any other local client, without a CLI on PATH. + mkdir -p "$HOME/.copilot/session-state/abc" + cat > "$HOME/.copilot/session-state/abc/events.jsonl" <<'EOF' +{"text":"rg --files then sed -n '1,220p' scripts/tokenwar.sh"} +{"text":"git diff and gh pr view produced a large payload"} +EOF + run bash "$SCRIPT" --client copilot + [ "$status" -eq 0 ] + # The client row is keyed by id and reports how many log files were read — + # asserting the file count proves the root was scanned, not just listed. + [[ "$output" == *"copilot"*" 1 "* ]] + [[ "$output" == *"repo discovery signals"* ]] +} + +@test "scan honours TOKENWAR_COPILOT_LOG_ROOT" { + local root="$HOME/elsewhere" + mkdir -p "$root" + cat > "$root/events.jsonl" <<'EOF' +{"text":"rg --files and grep -rn across the repo"} +EOF + TOKENWAR_COPILOT_LOG_ROOT="$root" run bash "$SCRIPT" --client copilot + [ "$status" -eq 0 ] + [[ "$output" == *"copilot"*" 1 "* ]] +} + +@test "scan --json exposes copilot as a known client" { + mkdir -p "$HOME/.copilot" + printf '{"text":"rg --files"}\n' > "$HOME/.copilot/events.jsonl" + run bash "$SCRIPT" --client copilot --json + [ "$status" -eq 0 ] + echo "$output" | node -e ' + let s = ""; + process.stdin.on("data", d => s += d).on("end", () => { + const j = JSON.parse(s); + const ids = (j.clients || []).map(c => c.id); + if (!ids.includes("copilot")) process.exit(1); + }); + ' +}