From 0b9c5e6a6b8bd4661a42d5ded5e6cf18cb7fec28 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 21:27:39 +0000 Subject: [PATCH 01/19] feat: add CLI package with stats, usage, environmental, and fluency commands Co-authored-by: rajbos <6085745+rajbos@users.noreply.github.com> --- cli/esbuild.js | 82 ++++ cli/package-lock.json | 635 ++++++++++++++++++++++++++++++ cli/package.json | 43 ++ cli/src/cli.ts | 28 ++ cli/src/commands/environmental.ts | 100 +++++ cli/src/commands/fluency.ts | 125 ++++++ cli/src/commands/stats.ts | 149 +++++++ cli/src/commands/usage.ts | 105 +++++ cli/src/helpers.ts | 425 ++++++++++++++++++++ cli/src/vscode-stub.ts | 56 +++ cli/tsconfig.json | 19 + package.json | 9 +- tsconfig.json | 3 +- 13 files changed, 1777 insertions(+), 2 deletions(-) create mode 100644 cli/esbuild.js create mode 100644 cli/package-lock.json create mode 100644 cli/package.json create mode 100644 cli/src/cli.ts create mode 100644 cli/src/commands/environmental.ts create mode 100644 cli/src/commands/fluency.ts create mode 100644 cli/src/commands/stats.ts create mode 100644 cli/src/commands/usage.ts create mode 100644 cli/src/helpers.ts create mode 100644 cli/src/vscode-stub.ts create mode 100644 cli/tsconfig.json diff --git a/cli/esbuild.js b/cli/esbuild.js new file mode 100644 index 0000000..6dd4f9f --- /dev/null +++ b/cli/esbuild.js @@ -0,0 +1,82 @@ +const esbuild = require("esbuild"); +const fs = require("fs"); +const path = require("path"); + +const production = process.argv.includes("--production"); + +async function main() { + // Copy JSON data files from src/ to a temp location for bundling + const dataFiles = [ + "tokenEstimators.json", + "modelPricing.json", + "toolNames.json", + ]; + + for (const file of dataFiles) { + const srcPath = path.join(__dirname, "..", "src", file); + const destPath = path.join(__dirname, "src", file); + if (fs.existsSync(srcPath) && !fs.existsSync(destPath)) { + fs.copyFileSync(srcPath, destPath); + } + } + + // Copy sql-wasm.wasm to dist/ + const distDir = path.join(__dirname, "dist"); + if (!fs.existsSync(distDir)) { + fs.mkdirSync(distDir, { recursive: true }); + } + + const wasmSrc = path.join( + __dirname, + "node_modules", + "sql.js", + "dist", + "sql-wasm.wasm" + ); + const wasmDest = path.join(distDir, "sql-wasm.wasm"); + if (fs.existsSync(wasmSrc)) { + fs.copyFileSync(wasmSrc, wasmDest); + console.log("Copied sql-wasm.wasm to dist/"); + } + + const buildOptions = { + entryPoints: ["src/cli.ts"], + bundle: true, + outfile: "dist/cli.js", + format: "cjs", + platform: "node", + target: "node18", + sourcemap: !production, + minify: production, + banner: { + js: "#!/usr/bin/env node", + }, + external: ["vscode"], + // Resolve the parent src/ directory modules + alias: { + vscode: path.join(__dirname, "src", "vscode-stub.ts"), + }, + loader: { + ".json": "json", + }, + logLevel: "info", + }; + + await esbuild.build(buildOptions); + console.log( + `CLI built successfully (${production ? "production" : "development"})` + ); + + // Clean up copied JSON files + for (const file of dataFiles) { + const destPath = path.join(__dirname, "src", file); + if (fs.existsSync(destPath)) { + fs.unlinkSync(destPath); + } + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/cli/package-lock.json b/cli/package-lock.json new file mode 100644 index 0000000..f156533 --- /dev/null +++ b/cli/package-lock.json @@ -0,0 +1,635 @@ +{ + "name": "copilot-token-tracker-cli", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "copilot-token-tracker-cli", + "version": "0.0.1", + "license": "MIT", + "bin": { + "copilot-token-tracker": "dist/cli.js" + }, + "devDependencies": { + "@types/node": "^22.12.0", + "chalk": "^4.1.2", + "commander": "^13.1.0", + "esbuild": "^0.25.0", + "sql.js": "^1.12.0", + "typescript": "^5.7.3" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "22.19.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", + "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/sql.js": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.14.1.tgz", + "integrity": "sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==", + "dev": true, + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/cli/package.json b/cli/package.json new file mode 100644 index 0000000..56288e9 --- /dev/null +++ b/cli/package.json @@ -0,0 +1,43 @@ +{ + "name": "copilot-token-tracker-cli", + "version": "0.0.1", + "description": "CLI tool to analyze GitHub Copilot token usage from local session files", + "license": "MIT", + "author": "RobBos", + "repository": { + "type": "git", + "url": "https://github.com/rajbos/github-copilot-token-usage", + "directory": "cli" + }, + "keywords": [ + "github-copilot", + "token-usage", + "copilot", + "cli", + "token-tracker" + ], + "bin": { + "copilot-token-tracker": "./dist/cli.js" + }, + "files": [ + "dist/**/*" + ], + "scripts": { + "build": "node esbuild.js", + "build:production": "node esbuild.js --production", + "lint": "eslint src", + "check-types": "tsc --noEmit", + "test": "node --test dist/cli.js --help || true" + }, + "devDependencies": { + "commander": "^13.1.0", + "chalk": "^4.1.2", + "esbuild": "^0.25.0", + "typescript": "^5.7.3", + "@types/node": "^22.12.0", + "sql.js": "^1.12.0" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/cli/src/cli.ts b/cli/src/cli.ts new file mode 100644 index 0000000..99e1932 --- /dev/null +++ b/cli/src/cli.ts @@ -0,0 +1,28 @@ +/** + * Copilot Token Tracker CLI + * + * Command-line interface for analyzing GitHub Copilot token usage + * from local session files. Can be run via `npx copilot-token-tracker-cli`. + */ +import { Command } from 'commander'; +import { statsCommand } from './commands/stats'; +import { usageCommand } from './commands/usage'; +import { environmentalCommand } from './commands/environmental'; +import { fluencyCommand } from './commands/fluency'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const packageJson = require('../package.json'); + +const program = new Command(); + +program + .name('copilot-token-tracker') + .description('Analyze GitHub Copilot token usage from local session files') + .version(packageJson.version); + +program.addCommand(statsCommand); +program.addCommand(usageCommand); +program.addCommand(environmentalCommand); +program.addCommand(fluencyCommand); + +program.parse(); diff --git a/cli/src/commands/environmental.ts b/cli/src/commands/environmental.ts new file mode 100644 index 0000000..77e282a --- /dev/null +++ b/cli/src/commands/environmental.ts @@ -0,0 +1,100 @@ +/** + * `environmental` command - Show environmental impact of Copilot usage. + */ +import { Command } from 'commander'; +import chalk from 'chalk'; +import { discoverSessionFiles, calculateDetailedStats, fmt, formatTokens, ENVIRONMENTAL } from '../helpers'; +import type { PeriodStats } from '../../../src/types'; + +export const environmentalCommand = new Command('environmental') + .alias('env') + .description('Show environmental impact of your Copilot usage (CO2, water, trees)') + .action(async () => { + console.log(chalk.bold.cyan('\n🌍 Copilot Token Tracker - Environmental Impact\n')); + + process.stdout.write(chalk.dim('Scanning for session files...')); + const files = await discoverSessionFiles(); + process.stdout.write('\r' + ' '.repeat(50) + '\r'); + + if (files.length === 0) { + console.log(chalk.yellow('⚠️ No session files found.')); + return; + } + + process.stdout.write(chalk.dim('Calculating usage...')); + const stats = await calculateDetailedStats(files, (completed, total) => { + process.stdout.write(`\r${chalk.dim(`Processing: ${completed}/${total} files`)}`); + }); + process.stdout.write('\r' + ' '.repeat(50) + '\r'); + + const periods: { label: string; emoji: string; stats: PeriodStats }[] = [ + { label: 'Today', emoji: '📅', stats: stats.today }, + { label: 'This Month', emoji: '📆', stats: stats.month }, + { label: 'Last Month', emoji: '🗓️', stats: stats.lastMonth }, + { label: 'Last 30 Days', emoji: '📈', stats: stats.last30Days }, + ]; + + // Environmental impact methodology + console.log(chalk.dim('Methodology: Estimates based on industry averages for AI inference')); + console.log(chalk.dim(` CO₂: ${ENVIRONMENTAL.CO2_PER_1K_TOKENS} gCO₂e per 1K tokens`)); + console.log(chalk.dim(` Water: ${ENVIRONMENTAL.WATER_USAGE_PER_1K_TOKENS} L per 1K tokens`)); + console.log(chalk.dim(` Tree absorption: ${fmt(ENVIRONMENTAL.CO2_ABSORPTION_PER_TREE_PER_YEAR)} g CO₂/year\n`)); + + for (const period of periods) { + printEnvironmentalStats(period.label, period.emoji, period.stats); + } + + // Context comparisons for last 30 days + const last30 = stats.last30Days; + if (last30.tokens > 0) { + console.log(chalk.bold('🔄 Context Comparisons (Last 30 Days)')); + console.log(chalk.dim('─'.repeat(55))); + + const co2 = last30.co2; + const water = last30.waterUsage; + + // Driving comparison: ~120g CO2 per km for average car + const drivingKm = co2 / 120; + console.log(` 🚗 Equivalent to driving: ${drivingKm.toFixed(3)} km`); + + // Smartphone charges: ~8.22g CO2 per full charge + const phoneCharges = co2 / 8.22; + console.log(` 📱 Smartphone charges: ${phoneCharges.toFixed(1)}`); + + // Cups of coffee water: ~140 liters per cup + const coffeeCups = water / 140; + console.log(` ☕ Cups of coffee (water): ${coffeeCups.toFixed(4)}`); + + // LED bulb hours: ~20g CO2 per hour for 10W LED + const ledHours = co2 / 20; + console.log(` 💡 LED bulb hours: ${ledHours.toFixed(2)}`); + + console.log(); + } + + console.log(chalk.dim(`Last updated: ${stats.lastUpdated.toLocaleString()}\n`)); + }); + +function printEnvironmentalStats(label: string, emoji: string, stats: PeriodStats): void { + console.log(chalk.bold(`${emoji} ${label}`)); + console.log(chalk.dim('─'.repeat(55))); + + if (stats.sessions === 0) { + console.log(chalk.dim(' No activity in this period')); + console.log(); + return; + } + + console.log(` Tokens used: ${chalk.bold.yellow(formatTokens(stats.tokens))}`); + console.log(` CO₂ emissions: ${chalk.bold(stats.co2.toFixed(3))} gCO₂e`); + console.log(` Water usage: ${chalk.bold(stats.waterUsage.toFixed(3))} liters`); + + if (stats.treesEquivalent > 0) { + const treeStr = stats.treesEquivalent < 0.001 + ? stats.treesEquivalent.toExponential(2) + : stats.treesEquivalent.toFixed(6); + console.log(` Trees to offset: ${chalk.green(treeStr)} trees/year`); + } + + console.log(); +} diff --git a/cli/src/commands/fluency.ts b/cli/src/commands/fluency.ts new file mode 100644 index 0000000..fea78c3 --- /dev/null +++ b/cli/src/commands/fluency.ts @@ -0,0 +1,125 @@ +/** + * `fluency` command - Show Copilot Fluency Score based on usage patterns. + */ +import { Command } from 'commander'; +import chalk from 'chalk'; +import { discoverSessionFiles, calculateUsageAnalysisStats, fmt } from '../helpers'; +import { calculateMaturityScores } from '../../../src/maturityScoring'; + +export const fluencyCommand = new Command('fluency') + .description('Show your Copilot Fluency Score and improvement tips') + .option('-t, --tips', 'Show improvement tips for each category') + .action(async (options) => { + console.log(chalk.bold.cyan('\n🎯 Copilot Token Tracker - Fluency Score\n')); + + process.stdout.write(chalk.dim('Scanning for session files...')); + const files = await discoverSessionFiles(); + process.stdout.write('\r' + ' '.repeat(50) + '\r'); + + if (files.length === 0) { + console.log(chalk.yellow('⚠️ No session files found.')); + return; + } + + process.stdout.write(chalk.dim('Analyzing usage patterns...')); + + // Calculate usage analysis stats + const usageStats = await calculateUsageAnalysisStats(files); + process.stdout.write('\r' + ' '.repeat(50) + '\r'); + + // Calculate maturity scores + const scores = await calculateMaturityScores( + undefined, + async () => usageStats, + false + ); + + // Overall score + const stageColors: Record = { + 1: chalk.red, + 2: chalk.yellow, + 3: chalk.blue, + 4: chalk.green, + }; + + const stageBar = (stage: number): string => { + const filled = '█'.repeat(stage); + const empty = '░'.repeat(4 - stage); + return filled + empty; + }; + + console.log(chalk.bold('Overall Fluency Score')); + console.log(chalk.dim('─'.repeat(55))); + + const colorFn = stageColors[scores.overallStage] || chalk.white; + console.log(` ${colorFn(stageBar(scores.overallStage))} ${chalk.bold(scores.overallLabel)}`); + console.log(); + + // Category breakdown + console.log(chalk.bold('Category Breakdown')); + console.log(chalk.dim('─'.repeat(55))); + + for (const cat of scores.categories) { + const catColor = stageColors[cat.stage] || chalk.white; + console.log(` ${cat.icon} ${chalk.bold(cat.category)}`); + console.log(` ${catColor(stageBar(cat.stage))} Stage ${cat.stage}/4`); + + // Evidence + if (cat.evidence.length > 0) { + const evidenceToShow = cat.evidence.slice(0, 3); + for (const ev of evidenceToShow) { + console.log(chalk.dim(` ✓ ${ev}`)); + } + if (cat.evidence.length > 3) { + console.log(chalk.dim(` ... and ${cat.evidence.length - 3} more`)); + } + } + + // Tips (only when requested) + if (options.tips && cat.tips.length > 0 && cat.stage < 4) { + console.log(chalk.yellow(` 💡 Tips:`)); + for (const tip of cat.tips.slice(0, 2)) { + // Strip markdown links for cleaner CLI output + const cleanTip = tip.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1').replace(/\[▶ [^\]]+\]\([^)]+\)/g, '').trim(); + console.log(chalk.yellow(` → ${cleanTip}`)); + } + } + + console.log(); + } + + // Summary stats + const p = scores.period; + console.log(chalk.bold('📊 Analysis Period (Last 30 Days)')); + console.log(chalk.dim('─'.repeat(55))); + console.log(` Sessions analyzed: ${chalk.bold(fmt(p.sessions))}`); + + const totalInteractions = p.modeUsage.ask + p.modeUsage.edit + p.modeUsage.agent; + console.log(` Total interactions: ${chalk.bold(fmt(totalInteractions))}`); + + if (p.modeUsage.ask > 0) { + console.log(` Ask mode: ${fmt(p.modeUsage.ask)}`); + } + if (p.modeUsage.edit > 0) { + console.log(` Edit mode: ${fmt(p.modeUsage.edit)}`); + } + if (p.modeUsage.agent > 0) { + console.log(` Agent mode: ${fmt(p.modeUsage.agent)}`); + } + if (p.toolCalls.total > 0) { + console.log(` Tool calls: ${fmt(p.toolCalls.total)}`); + } + if (p.mcpTools.total > 0) { + console.log(` MCP tool calls: ${fmt(p.mcpTools.total)}`); + } + + const totalContextRefs = p.contextReferences.file + p.contextReferences.selection + + p.contextReferences.codebase + p.contextReferences.workspace + + p.contextReferences.terminal + p.contextReferences.vscode; + if (totalContextRefs > 0) { + console.log(` Context references: ${fmt(totalContextRefs)}`); + } + + console.log(); + console.log(chalk.dim(`Last updated: ${scores.lastUpdated}\n`)); + }); diff --git a/cli/src/commands/stats.ts b/cli/src/commands/stats.ts new file mode 100644 index 0000000..cde6984 --- /dev/null +++ b/cli/src/commands/stats.ts @@ -0,0 +1,149 @@ +/** + * `stats` command - Show overview of discovered session files, sessions, and token counts. + */ +import { Command } from 'commander'; +import chalk from 'chalk'; +import { discoverSessionFiles, processSessionFile, getDiagnosticPaths, fmt, formatTokens } from '../helpers'; + +export const statsCommand = new Command('stats') + .description('Show overview of discovered session files, sessions, chat turns, and tokens') + .option('-v, --verbose', 'Show detailed per-folder breakdown') + .action(async (options) => { + console.log(chalk.bold.cyan('\n🔍 Copilot Token Tracker - Session Statistics\n')); + + // Show search paths if verbose + if (options.verbose) { + const paths = getDiagnosticPaths(); + console.log(chalk.dim('Search paths:')); + for (const p of paths) { + const status = p.exists ? chalk.green('✅') : chalk.dim('❌'); + console.log(` ${status} ${chalk.dim(p.source)}: ${p.path}`); + } + console.log(); + } + + // Discover session files + process.stdout.write(chalk.dim('Scanning for session files...')); + const files = await discoverSessionFiles(); + process.stdout.write('\r' + ' '.repeat(50) + '\r'); // Clear line + + if (files.length === 0) { + console.log(chalk.yellow('⚠️ No session files found.')); + console.log(chalk.dim('Have you used GitHub Copilot Chat in VS Code yet?')); + return; + } + + console.log(chalk.green(`📂 Found ${chalk.bold(fmt(files.length))} session file(s)\n`)); + + // Process files and gather stats + let totalTokens = 0; + let totalThinkingTokens = 0; + let totalInteractions = 0; + let processedCount = 0; + let emptyCount = 0; + const editorCounts: { [editor: string]: { files: number; tokens: number; interactions: number } } = {}; + const folderCounts: { [folder: string]: { files: number; tokens: number } } = {}; + + for (let i = 0; i < files.length; i++) { + const data = await processSessionFile(files[i]); + + if (!data || data.tokens === 0) { + emptyCount++; + continue; + } + + processedCount++; + totalTokens += data.tokens; + totalThinkingTokens += data.thinkingTokens; + totalInteractions += data.interactions; + + // Track by editor + if (!editorCounts[data.editorSource]) { + editorCounts[data.editorSource] = { files: 0, tokens: 0, interactions: 0 }; + } + editorCounts[data.editorSource].files++; + editorCounts[data.editorSource].tokens += data.tokens; + editorCounts[data.editorSource].interactions += data.interactions; + + // Track by parent folder + if (options.verbose) { + const folder = getDisplayFolder(files[i]); + if (!folderCounts[folder]) { + folderCounts[folder] = { files: 0, tokens: 0 }; + } + folderCounts[folder].files++; + folderCounts[folder].tokens += data.tokens; + } + + // Progress indicator + if ((i + 1) % 50 === 0 || i === files.length - 1) { + process.stdout.write(`\r${chalk.dim(`Processing: ${i + 1}/${files.length}`)}`); + } + } + process.stdout.write('\r' + ' '.repeat(50) + '\r'); // Clear progress line + + // Summary table + console.log(chalk.bold('📊 Summary')); + console.log(chalk.dim('─'.repeat(50))); + console.log(` Session files (with data): ${chalk.bold(fmt(processedCount))}`); + if (emptyCount > 0) { + console.log(` Empty/skipped files: ${chalk.dim(fmt(emptyCount))}`); + } + console.log(` Total chat turns: ${chalk.bold(fmt(totalInteractions))}`); + console.log(` Total estimated tokens: ${chalk.bold.yellow(formatTokens(totalTokens))}`); + if (totalThinkingTokens > 0) { + console.log(` Thinking tokens (included): ${chalk.dim(formatTokens(totalThinkingTokens))}`); + } + console.log(); + + // Editor breakdown + const editors = Object.entries(editorCounts).sort((a, b) => b[1].tokens - a[1].tokens); + if (editors.length > 0) { + console.log(chalk.bold('🖥️ By Editor')); + console.log(chalk.dim('─'.repeat(50))); + for (const [editor, counts] of editors) { + const editorName = getEditorDisplayName(editor); + console.log(` ${editorName.padEnd(25)} ${fmt(counts.files).padStart(5)} files ${formatTokens(counts.tokens).padStart(8)} tokens ${fmt(counts.interactions).padStart(6)} turns`); + } + console.log(); + } + + // Verbose: per-folder breakdown + if (options.verbose && Object.keys(folderCounts).length > 0) { + const folders = Object.entries(folderCounts).sort((a, b) => b[1].tokens - a[1].tokens); + console.log(chalk.bold('📁 By Folder')); + console.log(chalk.dim('─'.repeat(70))); + for (const [folder, counts] of folders.slice(0, 20)) { + console.log(` ${folder.substring(0, 50).padEnd(50)} ${fmt(counts.files).padStart(5)} files ${formatTokens(counts.tokens).padStart(8)} tokens`); + } + if (folders.length > 20) { + console.log(chalk.dim(` ... and ${folders.length - 20} more folders`)); + } + console.log(); + } + }); + +function getEditorDisplayName(source: string): string { + const names: Record = { + 'vscode': 'VS Code', + 'vscode-insiders': 'VS Code Insiders', + 'vscode-exploration': 'VS Code Exploration', + 'vscode-remote': 'VS Code Remote', + 'vscodium': 'VSCodium', + 'cursor': 'Cursor', + 'copilot-cli': 'Copilot CLI', + 'opencode': 'OpenCode', + }; + return names[source] || source; +} + +function getDisplayFolder(filePath: string): string { + const parts = filePath.split(/[/\\]/); + // Find the meaningful folder (e.g., workspaceStorage/ or chatSessions) + const chatIdx = parts.indexOf('chatSessions'); + if (chatIdx >= 2) { + return parts.slice(chatIdx - 1, chatIdx + 1).join('/'); + } + // Fall back to parent directory + return parts.slice(-3, -1).join('/'); +} diff --git a/cli/src/commands/usage.ts b/cli/src/commands/usage.ts new file mode 100644 index 0000000..726ce1d --- /dev/null +++ b/cli/src/commands/usage.ts @@ -0,0 +1,105 @@ +/** + * `usage` command - Show token usage for today, current month, and last 30 days. + */ +import { Command } from 'commander'; +import chalk from 'chalk'; +import { discoverSessionFiles, calculateDetailedStats, fmt, formatTokens, modelPricing } from '../helpers'; +import type { PeriodStats, ModelUsage } from '../../../src/types'; +import { getModelTier } from '../../../src/tokenEstimation'; + +export const usageCommand = new Command('usage') + .description('Show token usage for today, current month, last month, and last 30 days') + .option('-m, --models', 'Show per-model token breakdown') + .option('-c, --cost', 'Show estimated cost breakdown') + .action(async (options) => { + console.log(chalk.bold.cyan('\n📊 Copilot Token Tracker - Usage Report\n')); + + process.stdout.write(chalk.dim('Scanning for session files...')); + const files = await discoverSessionFiles(); + process.stdout.write('\r' + ' '.repeat(50) + '\r'); + + if (files.length === 0) { + console.log(chalk.yellow('⚠️ No session files found.')); + return; + } + + process.stdout.write(chalk.dim('Calculating token usage...')); + const stats = await calculateDetailedStats(files, (completed, total) => { + process.stdout.write(`\r${chalk.dim(`Processing: ${completed}/${total} files`)}`); + }); + process.stdout.write('\r' + ' '.repeat(50) + '\r'); + + // Display each period + const periods: { label: string; emoji: string; stats: PeriodStats }[] = [ + { label: 'Today', emoji: '📅', stats: stats.today }, + { label: 'This Month', emoji: '📆', stats: stats.month }, + { label: 'Last Month', emoji: '🗓️', stats: stats.lastMonth }, + { label: 'Last 30 Days', emoji: '📈', stats: stats.last30Days }, + ]; + + for (const period of periods) { + printPeriodStats(period.label, period.emoji, period.stats, options); + } + + console.log(chalk.dim(`Last updated: ${stats.lastUpdated.toLocaleString()}\n`)); + }); + +function printPeriodStats( + label: string, + emoji: string, + stats: PeriodStats, + options: { models?: boolean; cost?: boolean } +): void { + console.log(chalk.bold(`${emoji} ${label}`)); + console.log(chalk.dim('─'.repeat(55))); + + if (stats.sessions === 0) { + console.log(chalk.dim(' No activity in this period')); + console.log(); + return; + } + + console.log(` Sessions: ${chalk.bold(fmt(stats.sessions))}`); + console.log(` Avg interactions/sess: ${chalk.bold(stats.avgInteractionsPerSession.toFixed(1))}`); + console.log(` Total tokens: ${chalk.bold.yellow(formatTokens(stats.tokens))}`); + if (stats.thinkingTokens > 0) { + console.log(` Thinking tokens: ${chalk.dim(formatTokens(stats.thinkingTokens))} (included in total)`); + } + console.log(` Avg tokens/session: ${chalk.bold(formatTokens(stats.avgTokensPerSession))}`); + + if (options.cost && stats.estimatedCost > 0) { + console.log(` Estimated cost: ${chalk.green('$' + stats.estimatedCost.toFixed(4))}`); + } + + // Model breakdown + if (options.models && Object.keys(stats.modelUsage).length > 0) { + console.log(); + console.log(chalk.dim(' Model Breakdown:')); + const models = Object.entries(stats.modelUsage) + .sort((a, b) => (b[1].inputTokens + b[1].outputTokens) - (a[1].inputTokens + a[1].outputTokens)); + + for (const [model, usage] of models) { + const total = usage.inputTokens + usage.outputTokens; + const tier = getModelTier(model, modelPricing); + const tierBadge = tier === 'premium' + ? chalk.yellow(' ⭐') + : tier === 'standard' + ? chalk.dim(' ○') + : ''; + console.log(` ${(model + tierBadge).padEnd(35)} ${formatTokens(usage.inputTokens).padStart(8)} in ${formatTokens(usage.outputTokens).padStart(8)} out ${formatTokens(total).padStart(8)} total`); + } + } + + // Editor breakdown + if (Object.keys(stats.editorUsage).length > 1) { + console.log(); + console.log(chalk.dim(' Editor Breakdown:')); + const editors = Object.entries(stats.editorUsage) + .sort((a, b) => b[1].tokens - a[1].tokens); + for (const [editor, usage] of editors) { + console.log(` ${editor.padEnd(25)} ${fmt(usage.sessions).padStart(5)} sessions ${formatTokens(usage.tokens).padStart(8)} tokens`); + } + } + + console.log(); +} diff --git a/cli/src/helpers.ts b/cli/src/helpers.ts new file mode 100644 index 0000000..3227d8c --- /dev/null +++ b/cli/src/helpers.ts @@ -0,0 +1,425 @@ +/** + * Shared helper functions for CLI commands. + * Handles session file discovery, parsing, and stats aggregation. + */ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import chalk from 'chalk'; +import { SessionDiscovery } from '../../src/sessionDiscovery'; +import { OpenCodeDataAccess } from '../../src/opencode'; +import { parseSessionFileContent } from '../../src/sessionParser'; +import { estimateTokensFromText, getModelFromRequest, isJsonlContent, estimateTokensFromJsonlSession, calculateEstimatedCost, getModelTier } from '../../src/tokenEstimation'; +import type { DetailedStats, PeriodStats, ModelUsage, EditorUsage, SessionFileCache, UsageAnalysisStats, UsageAnalysisPeriod } from '../../src/types'; +import { analyzeSessionUsage, mergeUsageAnalysis, calculateModelSwitching, trackEnhancedMetrics } from '../../src/usageAnalysis'; +import { createEmptyContextRefs } from '../../src/tokenEstimation'; +import * as vscodeStub from './vscode-stub'; + +// Import JSON data files +import tokenEstimatorsData from '../../src/tokenEstimators.json'; +import modelPricingData from '../../src/modelPricing.json'; +import toolNamesData from '../../src/toolNames.json'; + +// Environmental impact constants (from extension.ts) +const CO2_PER_1K_TOKENS = 0.2; // gCO2e per 1000 tokens +const CO2_ABSORPTION_PER_TREE_PER_YEAR = 21000; // grams CO2 per tree/year +const WATER_USAGE_PER_1K_TOKENS = 0.3; // liters per 1000 tokens + +const tokenEstimators: { [key: string]: number } = tokenEstimatorsData.estimators; +const modelPricing = modelPricingData.pricing as { [key: string]: any }; +const toolNameMap = toolNamesData as { [key: string]: string }; + +/** Logging functions for the CLI context */ +const log = (msg: string) => { /* quiet by default */ }; +const warn = (msg: string) => { /* quiet by default */ }; +const error = (msg: string, err?: any) => console.error(chalk.red(msg), err || ''); + +/** Create OpenCode data access instance for CLI */ +function createOpenCode(): OpenCodeDataAccess { + const fakeUri = vscodeStub.Uri.file(__dirname); + return new OpenCodeDataAccess(fakeUri as any); +} + +/** Create session discovery instance for CLI */ +function createSessionDiscovery(): SessionDiscovery { + const openCode = createOpenCode(); + return new SessionDiscovery({ log, warn, error, openCode }); +} + +/** Discover all session files on this machine */ +export async function discoverSessionFiles(): Promise { + const discovery = createSessionDiscovery(); + return discovery.getCopilotSessionFiles(); +} + +/** Get diagnostic candidate paths info */ +export function getDiagnosticPaths(): { path: string; exists: boolean; source: string }[] { + const discovery = createSessionDiscovery(); + return discovery.getDiagnosticCandidatePaths(); +} + +/** + * Token estimation wrapper that uses the shared tokenEstimators data. + */ +function estimateTokens(text: string, model?: string): number { + return estimateTokensFromText(text, model || 'gpt-4', tokenEstimators); +} + +/** + * Model resolver wrapper. + */ +function resolveModel(request: any): string { + return getModelFromRequest(request, modelPricing); +} + +/** Determine editor source from file path */ +function getEditorSourceFromPath(filePath: string): string { + const normalized = filePath.toLowerCase().replace(/\\/g, '/'); + if (normalized.includes('/cursor/')) { return 'cursor'; } + if (normalized.includes('/code - insiders/')) { return 'vscode-insiders'; } + if (normalized.includes('/code - exploration/')) { return 'vscode-exploration'; } + if (normalized.includes('/vscodium/')) { return 'vscodium'; } + if (normalized.includes('/.copilot/')) { return 'copilot-cli'; } + if (normalized.includes('/opencode/')) { return 'opencode'; } + if (normalized.includes('.vscode-server')) { return 'vscode-remote'; } + return 'vscode'; +} + +export interface SessionData { + file: string; + tokens: number; + thinkingTokens: number; + interactions: number; + modelUsage: ModelUsage; + lastModified: Date; + editorSource: string; +} + +/** + * Process a single session file and extract its data. + */ +export async function processSessionFile(filePath: string): Promise { + try { + const stats = await fs.promises.stat(filePath); + const content = await fs.promises.readFile(filePath, 'utf-8'); + + if (!content.trim()) { + return null; + } + + const isJsonl = filePath.endsWith('.jsonl') || isJsonlContent(content); + + let tokens = 0; + let thinkingTokens = 0; + let interactions = 0; + let fileModelUsage: ModelUsage = {}; + + if (isJsonl) { + const result = estimateTokensFromJsonlSession(content); + tokens = result.tokens; + thinkingTokens = result.thinkingTokens; + + // Count interactions from JSONL + const lines = content.trim().split('\n'); + for (const line of lines) { + try { + const event = JSON.parse(line); + if (event.type === 'user.message' || (event.kind === 2 && event.k?.[0] === 'requests')) { + interactions++; + } + } catch { + // skip + } + } + } else { + const result = parseSessionFileContent( + filePath, + content, + estimateTokens, + resolveModel + ); + tokens = result.tokens; + thinkingTokens = result.thinkingTokens; + interactions = result.interactions; + fileModelUsage = result.modelUsage as ModelUsage; + } + + return { + file: filePath, + tokens, + thinkingTokens, + interactions, + modelUsage: fileModelUsage, + lastModified: stats.mtime, + editorSource: getEditorSourceFromPath(filePath), + }; + } catch { + return null; + } +} + +/** + * Calculate detailed statistics across all time periods. + */ +export async function calculateDetailedStats( + sessionFiles: string[], + progressCallback?: (completed: number, total: number) => void +): Promise { + const now = new Date(); + const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const monthStart = new Date(now.getFullYear(), now.getMonth(), 1); + const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1); + const lastMonthEnd = new Date(now.getFullYear(), now.getMonth(), 0, 23, 59, 59); + const last30DaysStart = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); + + const periods: { + today: PeriodStats; + month: PeriodStats; + lastMonth: PeriodStats; + last30Days: PeriodStats; + } = { + today: createEmptyPeriodStats(), + month: createEmptyPeriodStats(), + lastMonth: createEmptyPeriodStats(), + last30Days: createEmptyPeriodStats(), + }; + + let processed = 0; + for (const file of sessionFiles) { + const data = await processSessionFile(file); + processed++; + if (progressCallback) { + progressCallback(processed, sessionFiles.length); + } + + if (!data || data.tokens === 0) { + continue; + } + + const modified = data.lastModified; + + // Skip files older than the last month's start + if (modified < lastMonthStart) { + continue; + } + + // Aggregate into appropriate periods + if (modified >= todayStart) { + aggregateIntoPeriod(periods.today, data); + } + if (modified >= monthStart) { + aggregateIntoPeriod(periods.month, data); + } + if (modified >= lastMonthStart && modified <= lastMonthEnd) { + aggregateIntoPeriod(periods.lastMonth, data); + } + if (modified >= last30DaysStart) { + aggregateIntoPeriod(periods.last30Days, data); + } + } + + // Compute derived stats + for (const period of Object.values(periods)) { + if (period.sessions > 0) { + period.avgTokensPerSession = Math.round(period.tokens / period.sessions); + } + period.co2 = (period.tokens / 1000) * CO2_PER_1K_TOKENS; + period.treesEquivalent = period.co2 / CO2_ABSORPTION_PER_TREE_PER_YEAR; + period.waterUsage = (period.tokens / 1000) * WATER_USAGE_PER_1K_TOKENS; + period.estimatedCost = calculateEstimatedCost(period.modelUsage, modelPricing); + } + + return { + ...periods, + lastUpdated: now, + }; +} + +function createEmptyPeriodStats(): PeriodStats { + return { + tokens: 0, + thinkingTokens: 0, + estimatedTokens: 0, + actualTokens: 0, + sessions: 0, + avgInteractionsPerSession: 0, + avgTokensPerSession: 0, + modelUsage: {}, + editorUsage: {}, + co2: 0, + treesEquivalent: 0, + waterUsage: 0, + estimatedCost: 0, + }; +} + +function aggregateIntoPeriod(period: PeriodStats, data: SessionData): void { + period.tokens += data.tokens; + period.thinkingTokens += data.thinkingTokens; + period.estimatedTokens += data.tokens; + period.sessions++; + + // Merge model usage + for (const [model, usage] of Object.entries(data.modelUsage)) { + if (!period.modelUsage[model]) { + period.modelUsage[model] = { inputTokens: 0, outputTokens: 0 }; + } + period.modelUsage[model].inputTokens += usage.inputTokens; + period.modelUsage[model].outputTokens += usage.outputTokens; + } + + // Track interactions + const totalInteractions = period.avgInteractionsPerSession * (period.sessions - 1) + data.interactions; + period.avgInteractionsPerSession = period.sessions > 0 ? totalInteractions / period.sessions : 0; + + // Editor usage + if (!period.editorUsage[data.editorSource]) { + period.editorUsage[data.editorSource] = { tokens: 0, sessions: 0 }; + } + period.editorUsage[data.editorSource].tokens += data.tokens; + period.editorUsage[data.editorSource].sessions++; +} + +/** + * Calculate usage analysis stats for fluency scoring. + * This is a simplified version that uses the shared usageAnalysis module. + */ +export async function calculateUsageAnalysisStats(sessionFiles: string[]): Promise { + const openCode = createOpenCode(); + + const deps = { + warn, + openCode, + tokenEstimators, + modelPricing, + toolNameMap, + }; + + const now = new Date(); + const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const last30DaysStart = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); + const monthStart = new Date(now.getFullYear(), now.getMonth(), 1); + + const todayPeriod = createEmptyUsageAnalysisPeriod(); + const last30DaysPeriod = createEmptyUsageAnalysisPeriod(); + const monthPeriod = createEmptyUsageAnalysisPeriod(); + + for (const file of sessionFiles) { + try { + const stats = await fs.promises.stat(file); + const modified = stats.mtime; + + if (modified < last30DaysStart) { + continue; + } + + const analysis = await analyzeSessionUsage(deps, file); + + if (modified >= last30DaysStart) { + mergeUsageAnalysis(last30DaysPeriod, analysis); + last30DaysPeriod.sessions++; + } + if (modified >= monthStart) { + mergeUsageAnalysis(monthPeriod, analysis); + monthPeriod.sessions++; + } + if (modified >= todayStart) { + mergeUsageAnalysis(todayPeriod, analysis); + todayPeriod.sessions++; + } + } catch { + // Skip files that can't be processed + } + } + + return { + today: todayPeriod, + last30Days: last30DaysPeriod, + month: monthPeriod, + lastUpdated: now, + }; +} + +function createEmptyUsageAnalysisPeriod(): UsageAnalysisPeriod { + return { + sessions: 0, + toolCalls: { total: 0, byTool: {} }, + modeUsage: { ask: 0, edit: 0, agent: 0, plan: 0, customAgent: 0 }, + contextReferences: createEmptyContextRefs(), + mcpTools: { total: 0, byServer: {}, byTool: {} }, + modelSwitching: { + modelsPerSession: [], + totalSessions: 0, + averageModelsPerSession: 0, + maxModelsPerSession: 0, + minModelsPerSession: 0, + switchingFrequency: 0, + standardModels: [], + premiumModels: [], + unknownModels: [], + mixedTierSessions: 0, + standardRequests: 0, + premiumRequests: 0, + unknownRequests: 0, + totalRequests: 0, + }, + repositories: [], + repositoriesWithCustomization: [], + editScope: { + singleFileEdits: 0, + multiFileEdits: 0, + totalEditedFiles: 0, + avgFilesPerSession: 0, + }, + applyUsage: { + totalApplies: 0, + totalCodeBlocks: 0, + applyRate: 0, + }, + sessionDuration: { + totalDurationMs: 0, + avgDurationMs: 0, + avgFirstProgressMs: 0, + avgTotalElapsedMs: 0, + avgWaitTimeMs: 0, + }, + conversationPatterns: { + multiTurnSessions: 0, + singleTurnSessions: 0, + avgTurnsPerSession: 0, + maxTurnsInSession: 0, + }, + agentTypes: { + editsAgent: 0, + defaultAgent: 0, + workspaceAgent: 0, + other: 0, + }, + }; +} + +/** Format a number with thousand separators */ +export function fmt(n: number): string { + return n.toLocaleString('en-US'); +} + +/** Format token counts for display */ +export function formatTokens(tokens: number): string { + if (tokens >= 1_000_000) { + return `${(tokens / 1_000_000).toFixed(1)}M`; + } + if (tokens >= 1_000) { + return `${(tokens / 1_000).toFixed(1)}K`; + } + return tokens.toString(); +} + +/** Environmental impact constants export for use in commands */ +export const ENVIRONMENTAL = { + CO2_PER_1K_TOKENS, + CO2_ABSORPTION_PER_TREE_PER_YEAR, + WATER_USAGE_PER_1K_TOKENS, +}; + +/** Model pricing data export */ +export { modelPricing, tokenEstimators, toolNameMap }; diff --git a/cli/src/vscode-stub.ts b/cli/src/vscode-stub.ts new file mode 100644 index 0000000..dbc197e --- /dev/null +++ b/cli/src/vscode-stub.ts @@ -0,0 +1,56 @@ +/** + * Minimal VS Code API stub for CLI usage. + * Only provides the bare minimum needed by sessionDiscovery.ts and opencode.ts + * when running outside VS Code. + */ + +export const workspace = { + getConfiguration: () => ({ + get: () => undefined, + }), +}; + +export const extensions = { + getExtension: () => undefined, +}; + +export class Uri { + readonly fsPath: string; + readonly scheme: string; + readonly path: string; + + private constructor(fsPath: string) { + this.fsPath = fsPath; + this.scheme = 'file'; + this.path = fsPath; + } + + static file(path: string): Uri { + return new Uri(path); + } + + static joinPath(base: Uri, ...pathSegments: string[]): Uri { + const joined = [base.fsPath, ...pathSegments].join('/'); + return new Uri(joined); + } + + toString(): string { + return this.fsPath; + } +} + +export const window = { + createOutputChannel: () => ({ + appendLine: () => { /* noop */ }, + show: () => { /* noop */ }, + clear: () => { /* noop */ }, + dispose: () => { /* noop */ }, + }), +}; + +export default { + workspace, + extensions, + Uri, + window, +}; diff --git a/cli/tsconfig.json b/cli/tsconfig.json new file mode 100644 index 0000000..33774d1 --- /dev/null +++ b/cli/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "module": "Node16", + "target": "ES2022", + "lib": ["ES2022"], + "sourceMap": true, + "rootDir": "src", + "outDir": "dist", + "strict": true, + "resolveJsonModule": true, + "esModuleInterop": true, + "declaration": false, + "paths": { + "../src/*": ["../src/*"] + } + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/package.json b/package.json index b7bb500..61ec822 100644 --- a/package.json +++ b/package.json @@ -288,7 +288,14 @@ "pre-release": "node scripts/pre-release.js", "capture-screenshots": "pwsh -File scripts/capture-screenshots.ps1", "sync-changelog": "node scripts/sync-changelog.js", - "sync-changelog:test": "node scripts/sync-changelog.js --test" + "sync-changelog:test": "node scripts/sync-changelog.js --test", + "cli:build": "cd cli && npm install && npm run build", + "cli:build:production": "cd cli && npm install && npm run build:production", + "cli:stats": "cd cli && node dist/cli.js stats", + "cli:usage": "cd cli && node dist/cli.js usage", + "cli:environmental": "cd cli && node dist/cli.js environmental", + "cli:fluency": "cd cli && node dist/cli.js fluency", + "cli": "cd cli && node dist/cli.js" }, "devDependencies": { "@github/copilot": "^1.0.5", diff --git a/tsconfig.json b/tsconfig.json index 00e90c9..9a8cee5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,6 +17,7 @@ // "noUnusedParameters": true, /* Report errors on unused parameters. */ }, "exclude": [ - "test" + "test", + "cli" ] } From 204fc6128324eaef73cbd73985ff0a1662547d6c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 21:29:32 +0000 Subject: [PATCH 02/19] feat: add CLI workflows, README, and npmignore Co-authored-by: rajbos <6085745+rajbos@users.noreply.github.com> --- .github/workflows/cli-build.yml | 96 ++++++++++++++++++++++++++++++ .github/workflows/cli-publish.yml | 98 +++++++++++++++++++++++++++++++ cli/.npmignore | 6 ++ cli/README.md | 84 ++++++++++++++++++++++++++ 4 files changed, 284 insertions(+) create mode 100644 .github/workflows/cli-build.yml create mode 100644 .github/workflows/cli-publish.yml create mode 100644 cli/.npmignore create mode 100644 cli/README.md diff --git a/.github/workflows/cli-build.yml b/.github/workflows/cli-build.yml new file mode 100644 index 0000000..4fbe752 --- /dev/null +++ b/.github/workflows/cli-build.yml @@ -0,0 +1,96 @@ +name: CLI - Build & Validate + +on: + push: + branches: [main] + paths: + - 'cli/**' + - 'src/sessionDiscovery.ts' + - 'src/sessionParser.ts' + - 'src/tokenEstimation.ts' + - 'src/maturityScoring.ts' + - 'src/usageAnalysis.ts' + - 'src/opencode.ts' + - 'src/types.ts' + - 'src/tokenEstimators.json' + - 'src/modelPricing.json' + - 'src/toolNames.json' + pull_request: + branches: [main] + paths: + - 'cli/**' + - 'src/sessionDiscovery.ts' + - 'src/sessionParser.ts' + - 'src/tokenEstimation.ts' + - 'src/maturityScoring.ts' + - 'src/usageAnalysis.ts' + - 'src/opencode.ts' + - 'src/types.ts' + - 'src/tokenEstimators.json' + - 'src/modelPricing.json' + - 'src/toolNames.json' + +permissions: + contents: read + +jobs: + build-and-validate: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [18, 20, 22] + steps: + - name: Harden Runner + uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 + with: + egress-policy: audit + + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Setup Node.js ${{ matrix.node-version }} + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: ${{ matrix.node-version }} + + - name: Install extension dependencies + run: npm ci + + - name: Install CLI dependencies + working-directory: cli + run: npm ci + + - name: Build CLI + working-directory: cli + run: npm run build + + - name: Validate CLI --help + working-directory: cli + run: node dist/cli.js --help + + - name: Validate CLI --version + working-directory: cli + run: node dist/cli.js --version + + - name: Validate stats command + working-directory: cli + run: node dist/cli.js stats --verbose + + - name: Validate usage command + working-directory: cli + run: node dist/cli.js usage + + - name: Validate environmental command + working-directory: cli + run: node dist/cli.js environmental + + - name: Validate fluency command + working-directory: cli + run: node dist/cli.js fluency --tips + + - name: Build production bundle + working-directory: cli + run: npm run build:production + + - name: Verify production bundle runs + working-directory: cli + run: node dist/cli.js --help diff --git a/.github/workflows/cli-publish.yml b/.github/workflows/cli-publish.yml new file mode 100644 index 0000000..d349d95 --- /dev/null +++ b/.github/workflows/cli-publish.yml @@ -0,0 +1,98 @@ +name: CLI - Publish to npm + +on: + workflow_dispatch: + inputs: + version_bump: + description: 'Version bump type' + required: true + default: 'patch' + type: choice + options: + - patch + - minor + - major + dry_run: + description: 'Dry run (do not actually publish)' + required: false + default: false + type: boolean + +permissions: + contents: write + +jobs: + publish: + runs-on: ubuntu-latest + defaults: + run: + working-directory: cli + steps: + - name: Harden Runner + uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 + with: + egress-policy: audit + + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 20 + registry-url: https://registry.npmjs.org + + - name: Install extension dependencies + run: npm ci + working-directory: . + + - name: Install CLI dependencies + run: npm ci + + - name: Build production bundle + run: npm run build:production + + - name: Validate CLI works + run: node dist/cli.js --help + + - name: Bump version + run: npm version ${{ inputs.version_bump }} --no-git-tag-version + + - name: Get new version + id: version + run: echo "version=$(node -p 'require("./package.json").version')" >> "$GITHUB_OUTPUT" + + - name: Publish to npm + if: ${{ !inputs.dry_run }} + run: npm publish --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Dry run publish + if: ${{ inputs.dry_run }} + run: npm publish --access public --dry-run + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Commit version bump + if: ${{ !inputs.dry_run }} + run: | + cd .. + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add cli/package.json cli/package-lock.json + git commit -m "chore(cli): bump version to v${{ steps.version.outputs.version }}" + git push + + - name: Summary + run: | + echo "## CLI Package Published 📦" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "- **Version:** v${{ steps.version.outputs.version }}" >> "$GITHUB_STEP_SUMMARY" + echo "- **Bump:** ${{ inputs.version_bump }}" >> "$GITHUB_STEP_SUMMARY" + echo "- **Dry run:** ${{ inputs.dry_run }}" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + if [ "${{ inputs.dry_run }}" = "false" ]; then + echo "Install with: \`npx copilot-token-tracker-cli\`" >> "$GITHUB_STEP_SUMMARY" + fi diff --git a/cli/.npmignore b/cli/.npmignore new file mode 100644 index 0000000..58429cf --- /dev/null +++ b/cli/.npmignore @@ -0,0 +1,6 @@ +src/ +tsconfig.json +esbuild.js +node_modules/ +*.ts +*.map diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 0000000..127ab71 --- /dev/null +++ b/cli/README.md @@ -0,0 +1,84 @@ +# Copilot Token Tracker CLI + +Command-line interface for analyzing GitHub Copilot token usage from local session files. Works anywhere Copilot Chat stores its session data. + +## Quick Start + +```bash +# Run directly with npx (no install required) +npx copilot-token-tracker-cli stats + +# Or install globally +npm install -g copilot-token-tracker-cli +copilot-token-tracker stats +``` + +## Commands + +### `stats` - Session Overview + +Show discovered session files, sessions, chat turns, and token counts. + +```bash +copilot-token-tracker stats +copilot-token-tracker stats --verbose # Show per-folder breakdown +``` + +### `usage` - Token Usage Report + +Show token usage broken down by time period. + +```bash +copilot-token-tracker usage +copilot-token-tracker usage --models # Show per-model breakdown +copilot-token-tracker usage --cost # Show estimated cost +``` + +### `environmental` - Environmental Impact + +Show environmental impact of your Copilot usage (CO₂ emissions, water usage, tree equivalents). + +```bash +copilot-token-tracker environmental +copilot-token-tracker env # Short alias +``` + +### `fluency` - Fluency Score + +Show your Copilot Fluency Score across multiple categories (Prompt Engineering, Context Engineering, Agentic, Tool Usage, Customization, Team Collaboration). + +```bash +copilot-token-tracker fluency +copilot-token-tracker fluency --tips # Show improvement tips +``` + +## Data Sources + +The CLI scans the same session files that the [Copilot Token Tracker VS Code extension](https://marketplace.visualstudio.com/items?itemName=RobBos.copilot-token-tracker) uses: + +- **VS Code** (Stable, Insiders, Exploration) workspace and global storage +- **VSCodium** and **Cursor** editor sessions +- **VS Code Remote** / Codespaces sessions +- **Copilot CLI** agent mode sessions +- **OpenCode** sessions (JSON and SQLite) + +## Development + +```bash +# From the repository root +npm run cli:build # Build the CLI +npm run cli:stats # Run stats command +npm run cli:usage # Run usage command +npm run cli:environmental # Run environmental command +npm run cli:fluency # Run fluency command +npm run cli -- --help # Run any CLI command +``` + +## Requirements + +- Node.js 18 or later +- GitHub Copilot Chat session files on the local machine + +## License + +MIT - See [LICENSE](../LICENSE) for details. From 327c73c8bb0a25c9f54775c7914cd18996d40f6b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 21:31:57 +0000 Subject: [PATCH 03/19] fix: address code review - OpenCode virtual paths, extracted constants, test validation Co-authored-by: rajbos <6085745+rajbos@users.noreply.github.com> --- cli/package.json | 2 +- cli/src/commands/environmental.ts | 16 ++++++++-------- cli/src/helpers.ts | 32 ++++++++++++++++++++++++++++--- 3 files changed, 38 insertions(+), 12 deletions(-) diff --git a/cli/package.json b/cli/package.json index 56288e9..09a6d41 100644 --- a/cli/package.json +++ b/cli/package.json @@ -27,7 +27,7 @@ "build:production": "node esbuild.js --production", "lint": "eslint src", "check-types": "tsc --noEmit", - "test": "node --test dist/cli.js --help || true" + "test": "node dist/cli.js --help | grep -q copilot-token-tracker && node dist/cli.js --version" }, "devDependencies": { "commander": "^13.1.0", diff --git a/cli/src/commands/environmental.ts b/cli/src/commands/environmental.ts index 77e282a..c2c3340 100644 --- a/cli/src/commands/environmental.ts +++ b/cli/src/commands/environmental.ts @@ -53,20 +53,20 @@ export const environmentalCommand = new Command('environmental') const co2 = last30.co2; const water = last30.waterUsage; - // Driving comparison: ~120g CO2 per km for average car - const drivingKm = co2 / 120; + // Driving comparison + const drivingKm = co2 / ENVIRONMENTAL.CO2_PER_KM_DRIVING; console.log(` 🚗 Equivalent to driving: ${drivingKm.toFixed(3)} km`); - // Smartphone charges: ~8.22g CO2 per full charge - const phoneCharges = co2 / 8.22; + // Smartphone charges + const phoneCharges = co2 / ENVIRONMENTAL.CO2_PER_PHONE_CHARGE; console.log(` 📱 Smartphone charges: ${phoneCharges.toFixed(1)}`); - // Cups of coffee water: ~140 liters per cup - const coffeeCups = water / 140; + // Cups of coffee water + const coffeeCups = water / ENVIRONMENTAL.WATER_PER_COFFEE_CUP; console.log(` ☕ Cups of coffee (water): ${coffeeCups.toFixed(4)}`); - // LED bulb hours: ~20g CO2 per hour for 10W LED - const ledHours = co2 / 20; + // LED bulb hours + const ledHours = co2 / ENVIRONMENTAL.CO2_PER_LED_HOUR; console.log(` 💡 LED bulb hours: ${ledHours.toFixed(2)}`); console.log(); diff --git a/cli/src/helpers.ts b/cli/src/helpers.ts index 3227d8c..11900a8 100644 --- a/cli/src/helpers.ts +++ b/cli/src/helpers.ts @@ -72,6 +72,25 @@ function resolveModel(request: any): string { return getModelFromRequest(request, modelPricing); } +/** + * Check if a session file path is an OpenCode DB virtual path. + */ +function isOpenCodeDbSession(filePath: string): boolean { + return filePath.includes('opencode.db#ses_'); +} + +/** + * Stat a session file, handling OpenCode DB virtual paths. + * Virtual DB paths (opencode.db#ses_) are resolved to the actual DB file. + */ +async function statSessionFile(filePath: string): Promise { + if (isOpenCodeDbSession(filePath)) { + const dbPath = filePath.split('#')[0]; + return fs.promises.stat(dbPath); + } + return fs.promises.stat(filePath); +} + /** Determine editor source from file path */ function getEditorSourceFromPath(filePath: string): string { const normalized = filePath.toLowerCase().replace(/\\/g, '/'); @@ -100,8 +119,10 @@ export interface SessionData { */ export async function processSessionFile(filePath: string): Promise { try { - const stats = await fs.promises.stat(filePath); - const content = await fs.promises.readFile(filePath, 'utf-8'); + const stats = await statSessionFile(filePath); + const content = isOpenCodeDbSession(filePath) + ? '' // OpenCode DB sessions are handled by the opencode module + : await fs.promises.readFile(filePath, 'utf-8'); if (!content.trim()) { return null; @@ -306,7 +327,7 @@ export async function calculateUsageAnalysisStats(sessionFiles: string[]): Promi for (const file of sessionFiles) { try { - const stats = await fs.promises.stat(file); + const stats = await statSessionFile(file); const modified = stats.mtime; if (modified < last30DaysStart) { @@ -419,6 +440,11 @@ export const ENVIRONMENTAL = { CO2_PER_1K_TOKENS, CO2_ABSORPTION_PER_TREE_PER_YEAR, WATER_USAGE_PER_1K_TOKENS, + // Context comparison constants + CO2_PER_KM_DRIVING: 120, // grams CO2 per km for average car + CO2_PER_PHONE_CHARGE: 8.22, // grams CO2 per smartphone full charge + WATER_PER_COFFEE_CUP: 140, // liters of water per cup of coffee + CO2_PER_LED_HOUR: 20, // grams CO2 per hour for 10W LED bulb }; /** Model pricing data export */ From f548fc4d2aae606e552fb5b3965d805cae987af7 Mon Sep 17 00:00:00 2001 From: Rob Bos Date: Thu, 19 Mar 2026 21:19:52 +0100 Subject: [PATCH 04/19] remove unknown toolconfig --- .github/agents/refactor.agent.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/agents/refactor.agent.md b/.github/agents/refactor.agent.md index efb1eab..33175ea 100644 --- a/.github/agents/refactor.agent.md +++ b/.github/agents/refactor.agent.md @@ -1,7 +1,7 @@ --- description: "Improve code quality, apply security best practices, and enhance design whilst maintaining green tests." name: "Code Refactor - Improve Quality & Security" -tools: ["execute/runTests", "execute/getTerminalOutput", "execute/runInTerminal", "read/terminalLastCommand", "read/terminalSelection", "search/codebase", "read/problems", "execute/testFailure"] +tools: ["execute/getTerminalOutput", "execute/runInTerminal", "read/terminalLastCommand", "read/terminalSelection", "search/codebase", "read/problems", "execute/testFailure"] --- # Code Refactor - Improve Quality & Security From 8c3f2dda3fd8fe9a7f6d89f56ba8affdf3e447e9 Mon Sep 17 00:00:00 2001 From: Rob Bos Date: Thu, 19 Mar 2026 23:11:01 +0100 Subject: [PATCH 05/19] Fix cli test script --- cli/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/package.json b/cli/package.json index 09a6d41..db50c42 100644 --- a/cli/package.json +++ b/cli/package.json @@ -27,7 +27,7 @@ "build:production": "node esbuild.js --production", "lint": "eslint src", "check-types": "tsc --noEmit", - "test": "node dist/cli.js --help | grep -q copilot-token-tracker && node dist/cli.js --version" + "test": "node dist/cli.js --help && node dist/cli.js --version" }, "devDependencies": { "commander": "^13.1.0", From 19c1c0d113ec4b8a1b3458c797cfafb904dac889 Mon Sep 17 00:00:00 2001 From: Rob Bos Date: Thu, 19 Mar 2026 23:13:09 +0100 Subject: [PATCH 06/19] fix: simplify tips display logic in fluency command --- cli/src/commands/fluency.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/src/commands/fluency.ts b/cli/src/commands/fluency.ts index fea78c3..02ea871 100644 --- a/cli/src/commands/fluency.ts +++ b/cli/src/commands/fluency.ts @@ -75,8 +75,8 @@ export const fluencyCommand = new Command('fluency') } } - // Tips (only when requested) - if (options.tips && cat.tips.length > 0 && cat.stage < 4) { + // Tips + if (cat.tips.length > 0 && cat.stage < 4) { console.log(chalk.yellow(` 💡 Tips:`)); for (const tip of cat.tips.slice(0, 2)) { // Strip markdown links for cleaner CLI output From 0729374412a5f8d13e6789b95efca46209caa495 Mon Sep 17 00:00:00 2001 From: Rob Bos Date: Fri, 20 Mar 2026 08:55:01 +0100 Subject: [PATCH 07/19] Docs updated --- README.md | 18 ++- cli/README.md | 17 +- cli/src/cli.ts | 2 + cli/src/commands/diagnostics.ts | 215 ++++++++++++++++++++++++++ docs/images/Terminal Diagnostitcs.png | Bin 0 -> 20172 bytes docs/images/Terminal Statistics.png | Bin 0 -> 15713 bytes docs/images/Terminal Usage.png | Bin 0 -> 12671 bytes 7 files changed, 248 insertions(+), 4 deletions(-) create mode 100644 cli/src/commands/diagnostics.ts create mode 100644 docs/images/Terminal Diagnostitcs.png create mode 100644 docs/images/Terminal Statistics.png create mode 100644 docs/images/Terminal Usage.png diff --git a/README.md b/README.md index dd1e071..d40435c 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,22 @@ # GitHub Copilot Token Tracker -A VS Code extension that shows your daily and monthly GitHub Copilot estimated token usage in the status bar. It reads GitHub Copilot Chat session logs and computes local aggregates. +A VS Code extension that shows your daily and monthly GitHub Copilot estimated token usage and AI Fluency. It reads the local session logs and computes local aggregates. -Optionally, you can enable an **opt-in Azure Storage backend** to sync aggregates from all your VS Code instances (across machines, profiles, and windows) into **your own Azure Storage account** for cross-device reporting. +## Supported AI engineering tools: -You can also use a **shared Azure Storage account** (a “shared storage server” for the team) so that multiple developers sync into the same dataset and a team lead can view aggregated usage across the team (with explicit per-user consent). +- VS Code + GitHub Copilot +- VS Code Insiders + GitHub Copilot +- GitHub Copilot CLI +- OpenCode + GitHub Copilot (not tested with other AI tooling) + +### CLI + +We also added a CLI that you can run as an npx package: +```bash +npx copilot-token-tracker usage +``` + +For screenshots and examples of the CLI output, see the [CLI README](cli/README.md). ## Features diff --git a/cli/README.md b/cli/README.md index 127ab71..67a34f8 100644 --- a/cli/README.md +++ b/cli/README.md @@ -24,6 +24,8 @@ copilot-token-tracker stats copilot-token-tracker stats --verbose # Show per-folder breakdown ``` +![Terminal Statistics](../docs/images/Terminal%20Statistics.png) + ### `usage` - Token Usage Report Show token usage broken down by time period. @@ -34,6 +36,8 @@ copilot-token-tracker usage --models # Show per-model breakdown copilot-token-tracker usage --cost # Show estimated cost ``` +![Terminal Usage](../docs/images/Terminal%20Usage.png) + ### `environmental` - Environmental Impact Show environmental impact of your Copilot usage (CO₂ emissions, water usage, tree equivalents). @@ -49,9 +53,19 @@ Show your Copilot Fluency Score across multiple categories (Prompt Engineering, ```bash copilot-token-tracker fluency -copilot-token-tracker fluency --tips # Show improvement tips +copilot-token-tracker fluency --tips # Show improvement tips, if there are any +``` + +### `diagnostics` - Search Locations & Stats + +Show all locations searched for session files, whether each path exists, and per-location stats (files, sessions, chat turns, tokens). + +```bash +copilot-token-tracker diagnostics ``` +![Terminal Diagnostics](../docs/images/Terminal%20Diagnostitcs.png) + ## Data Sources The CLI scans the same session files that the [Copilot Token Tracker VS Code extension](https://marketplace.visualstudio.com/items?itemName=RobBos.copilot-token-tracker) uses: @@ -71,6 +85,7 @@ npm run cli:stats # Run stats command npm run cli:usage # Run usage command npm run cli:environmental # Run environmental command npm run cli:fluency # Run fluency command +npm run cli:diagnostics # Run diagnostics command npm run cli -- --help # Run any CLI command ``` diff --git a/cli/src/cli.ts b/cli/src/cli.ts index 99e1932..a947dbc 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -9,6 +9,7 @@ import { statsCommand } from './commands/stats'; import { usageCommand } from './commands/usage'; import { environmentalCommand } from './commands/environmental'; import { fluencyCommand } from './commands/fluency'; +import { diagnosticsCommand } from './commands/diagnostics'; // eslint-disable-next-line @typescript-eslint/no-require-imports const packageJson = require('../package.json'); @@ -24,5 +25,6 @@ program.addCommand(statsCommand); program.addCommand(usageCommand); program.addCommand(environmentalCommand); program.addCommand(fluencyCommand); +program.addCommand(diagnosticsCommand); program.parse(); diff --git a/cli/src/commands/diagnostics.ts b/cli/src/commands/diagnostics.ts new file mode 100644 index 0000000..98ff964 --- /dev/null +++ b/cli/src/commands/diagnostics.ts @@ -0,0 +1,215 @@ +/** + * `diagnostics` command - Show where session files are searched, and stats per location. + */ +import { Command } from 'commander'; +import chalk from 'chalk'; +import * as path from 'path'; +import { discoverSessionFiles, getDiagnosticPaths, processSessionFile, fmt, formatTokens } from '../helpers'; + +interface LocationStats { + label: string; + source: string; + files: number; + sessions: number; + interactions: number; + tokens: number; +} + +/** + * Map a session file path to the candidate search path it lives under. + * Returns the matching candidate path, or 'unknown' if none match. + */ +function matchToCandidatePath( + filePath: string, + candidates: { path: string; exists: boolean; source: string }[] +): { path: string; source: string } | null { + const normalized = filePath.replace(/\\/g, '/'); + // For OpenCode DB virtual paths, resolve to the db file path + const effectivePath = normalized.includes('opencode.db#') ? normalized.split('#')[0] : normalized; + + // Try to find the deepest matching candidate + let best: { path: string; source: string } | null = null; + let bestLen = 0; + for (const cand of candidates) { + const candNorm = cand.path.replace(/\\/g, '/'); + if (effectivePath.startsWith(candNorm) && candNorm.length > bestLen) { + best = { path: cand.path, source: cand.source }; + bestLen = candNorm.length; + } + } + return best; +} + +/** Truncate a path for display, keeping the last N segments */ +function truncatePath(p: string, maxLen = 55): string { + if (p.length <= maxLen) { return p; } + const parts = p.replace(/\\/g, '/').split('/'); + let result = p; + for (let keep = parts.length - 1; keep > 1; keep--) { + result = '…/' + parts.slice(-keep).join('/'); + if (result.length <= maxLen) { break; } + } + return result; +} + +/** Print a simple aligned table to stdout */ +function printTable(headers: string[], rows: string[][], colWidths: number[]): void { + const sep = chalk.dim('─'.repeat(colWidths.reduce((a, b) => a + b + 3, -1))); + + // Header + const headerLine = headers.map((h, i) => chalk.bold(h.padEnd(colWidths[i]))).join(' │ '); + console.log(' ' + headerLine); + console.log(' ' + sep); + + // Rows + for (const row of rows) { + const line = row.map((cell, i) => { + const padded = cell.padEnd(colWidths[i]); + return i === 0 ? chalk.dim(padded) : padded; + }).join(chalk.dim(' │ ')); + console.log(' ' + line); + } + console.log(' ' + sep); +} + +export const diagnosticsCommand = new Command('diagnostics') + .description('Show session file search locations and per-location usage stats') + .action(async () => { + console.log(chalk.bold.cyan('\n🔬 Copilot Token Tracker - Diagnostics\n')); + + // --- Search path candidates --- + const candidates = getDiagnosticPaths(); + const existing = candidates.filter(c => c.exists); + const missing = candidates.filter(c => !c.exists); + + console.log(chalk.bold(`📂 Search Locations (${existing.length} found / ${candidates.length} total)`)); + console.log(chalk.dim('─'.repeat(65))); + + const pathHeaders = ['Source', 'Exists', 'Path']; + const pathColWidths = [18, 6, 55]; + const pathRows = candidates.map(c => [ + c.source, + c.exists ? chalk.green('yes') : chalk.dim('no'), + truncatePath(c.path, 55), + ]); + printTable(pathHeaders, pathRows, pathColWidths); + console.log(); + + if (existing.length === 0) { + console.log(chalk.yellow('⚠️ No search paths exist on this machine.')); + console.log(chalk.dim('Have you used GitHub Copilot Chat in VS Code yet?')); + return; + } + + // --- Discover and process files --- + process.stdout.write(chalk.dim('Scanning for session files...')); + const files = await discoverSessionFiles(); + process.stdout.write('\r' + ' '.repeat(60) + '\r'); + + if (files.length === 0) { + console.log(chalk.yellow('⚠️ No session files found in any search path.')); + return; + } + + // Accumulate stats per candidate path + const locationMap = new Map(); + + // Seed all existing candidates so they appear even if no files matched + for (const cand of existing) { + locationMap.set(cand.path, { + label: truncatePath(cand.path, 45), + source: cand.source, + files: 0, + sessions: 0, + interactions: 0, + tokens: 0, + }); + } + + let totalFiles = 0; + let totalSessions = 0; + let totalInteractions = 0; + let totalTokens = 0; + + for (let i = 0; i < files.length; i++) { + // Progress + if ((i + 1) % 25 === 0 || i === files.length - 1) { + process.stdout.write(`\r${chalk.dim(`Processing: ${i + 1}/${files.length} files...`)}`); + } + + const match = matchToCandidatePath(files[i], candidates); + const key = match ? match.path : '__unknown__'; + + if (!locationMap.has(key)) { + locationMap.set(key, { + label: match ? truncatePath(match.path, 45) : '(unknown)', + source: match ? match.source : 'unknown', + files: 0, + sessions: 0, + interactions: 0, + tokens: 0, + }); + } + + const loc = locationMap.get(key)!; + loc.files++; + totalFiles++; + + const data = await processSessionFile(files[i]); + if (data && data.tokens > 0) { + loc.sessions++; + loc.interactions += data.interactions; + loc.tokens += data.tokens; + totalSessions++; + totalInteractions += data.interactions; + totalTokens += data.tokens; + } + } + + process.stdout.write('\r' + ' '.repeat(60) + '\r'); + + // --- Per-location table --- + console.log(chalk.bold(`📊 Stats per Search Location`)); + console.log(chalk.dim('─'.repeat(65))); + + const statsHeaders = ['Source', 'Files', 'Sessions', 'Turns', 'Tokens']; + const statsColWidths = [18, 6, 9, 8, 14]; + const statsRows: string[][] = []; + + // Sort by tokens desc, then by source name + const sorted = [...locationMap.values()] + .filter(l => l.files > 0) + .sort((a, b) => b.tokens - a.tokens || a.source.localeCompare(b.source)); + + for (const loc of sorted) { + statsRows.push([ + loc.source, + fmt(loc.files), + fmt(loc.sessions), + fmt(loc.interactions), + formatTokens(loc.tokens), + ]); + } + + if (statsRows.length === 0) { + console.log(chalk.dim(' No session data found in any location.')); + } else { + printTable(statsHeaders, statsRows, statsColWidths); + } + + // --- Totals --- + console.log(); + console.log(chalk.bold('📈 Totals (all time)')); + console.log(chalk.dim('─'.repeat(65))); + console.log(` Total files found: ${chalk.bold(fmt(totalFiles))}`); + console.log(` Files with data: ${chalk.bold(fmt(totalSessions))}`); + console.log(` Total chat turns: ${chalk.bold(fmt(totalInteractions))}`); + console.log(` Total tokens (est.): ${chalk.bold.yellow(formatTokens(totalTokens))}`); + + if (missing.length > 0) { + console.log(); + console.log(chalk.dim(`ℹ️ ${missing.length} search path(s) not present on this machine — this is normal if you don't use all VS Code variants.`)); + } + + console.log(); + }); diff --git a/docs/images/Terminal Diagnostitcs.png b/docs/images/Terminal Diagnostitcs.png new file mode 100644 index 0000000000000000000000000000000000000000..f11a8d873219e6c9cb78fd990eed511fe3eb73e8 GIT binary patch literal 20172 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
bRwFX0XQ*{kT*57my$(2L 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 literal 0 HcmV?d00001 diff --git a/docs/images/Terminal Usage.png b/docs/images/Terminal Usage.png new file mode 100644 index 0000000000000000000000000000000000000000..68be140105fba0006f9c8d80bb5c711102ff17b4 GIT binary patch literal 12671 zcmb7r2Q*w!yY@(v5J3ph6TOQborLIw=)HGFZ=*)^AbRu=ozWSiP4rImPIN|#GGx?y z<=H_ z0j{J=lPm!T3>Q`DH=wd1@@?Sco~4AM1PD|agMDdyA2@&HD5LEH0zGNJ-7&hsg=WA- z5?3iL*AHL|R}W)nbI^NZYX?_$B`LKRyzHFpoSYF4iH3km_2yc#7P7L;pohT8eGn2iUDc@BO`Q2b6C&BVfP%{eK<*=TkwT zyCBdPOwc``&MPkNR~X;^VfXeo_rLwaRuWLY-R=OE?){{72AfJ4ld3%xoFi@vZfXz?+IpFN=-kf#2PsxLN4I z%mTy)4G82o{&oZCvuW3`P7rA5E$ai2Bc%x^DB$?iZUgK=lD}Ivg%#OcYl86cnJ}U+GslVNrJvWF$W`u2tH-QuP6X37S^rgM0>zmf}TXfYL*? z9)o@fcbI@cn=gs(f?nfRJ_r5q`k#;cTNRcB`S#%kd1i1ST`p3ynSF6~P(H6c0|QgB zAUqBh7aD;E{Za9@8eUq8yOF)5N}C#Q75NDQrPg+=VBgT4b!cWzS6dEWU7R8G;LZM7 zX^BhC9%uUtLJ%si-E3*fr=OaSgY%X|QNt(}YJNNrXiBG>k&=-`)YHh|P0ePj{yDC{ z*J?&0!=St;2^W+O1Zo^4z39yB&hq)A(%^BkNNl+1XNM>=^w}I*l%~8`wT0avProD3 z9idBT;-K?q(3Dju9gfr}ce}Af-#caHRO0PQn?5RMV^+>8EB55b@7>Zz4Q|Ld%gr2& zJ&pw=2((v~+UWsP1(S}OV`7BWSzPYzq6TV#)$r! z6~y(S;bo`cWz5YVWnZ0X-*a2hy{w`mLDu}3?gu%QfmU`kFR+)F!*fZ)ie8gO;e38( z{?gTgo-S3hG~W64_6vqb%ggY?m&Ycc0G!$+txM0)O{e8Y#!IXFzL{0ya--$ zA@p8Smvg-7_0)P{ZA_~yiH`LOm)Y92#rvGnJEk4Z_%o4KNhp3*ZjP|G+68V$|E!Xa z67;Q-zCCZ`uD3X3Uu^pL7skll27iy?phgJgJY*||{l<5Bg9dSid$tGh9L^NMrSv*6 zK8_IXJuExRsl=Gn&l9)v31f`Q41ch;!pIVnDYMBW=4|ppP~%IuOkdRzfBwK+m5lrS zu(i>)oUP-hy%SGaM98r5Vt+kK-O)`f=2H14)N8%D7AHx24?-A+<6xCJE<*6m)#{AbLxaFjS z*#F|t(D(9p$`Iy01Q?AizB?O(!(um=)yiTJ$1LupK?Mc#9E>>UyCc)$JPg+B=-5x^ zt7Nfw(dD8%1Ibw&y^?Jbe2xR>J(6b6tE?Ee@WYs`S76*?RWD-kb}5k9ShQ@6?f7I9 zo}g*o$)}NF#2{2!W1#|^XhKO z#~~u@R0I1mr!XTNrOIw<3qL>MSsGL5YQGa(BWdTzPi~25Yo?qG9(oXGY1$BrX&_Xv z7%%fsy8Vu<{KlcJ=_&@u4r=b_^!r5F|BMVX)7$Rg>cRtf&pmgR4_*iJjV=d6eb?QT zbJd}z`;axun`7t+0-fpCx#r22j|+2lI{72>o+ZIqJEE(pGjMhNksG;qNq)#+_Bl0^ zH$3_06x@r9fTOO`d4VT+;5Z?44@ziKXeb*NdqR#SP)2l_!;YW{iU zy4=C%UqHRqv0R$k*z*e`=!Zq0 z*-%=f`xhd>3aI8$7b8Fp{-x&s3;zFBZL%kh45fFTfu|%`u>-!OYc@H?#EP02r_f&p z=))*4yDI>LNWc8W(haE!H~^+K+^p*l7Qc=rg%xdc8wbkVLhn=jzc3sa7UsAQ@|-1% zq}fhcHnP42VuL$E9X}<@V1n+{8Jg7PdheBCU;7@1-lOIvlD>s*=4eW)-fF3b-5{bp zlJ9sIA3y5pR6JOb@un9_?jKsOa#?+w-Znpz>zn;GwXU=zTBpLlZ(530ggb+%_UPSQ zDY&&lQEOtnXe-<`GmH@!neZczVCfcS0juMj$zfuWs;zdQ-fSpa2d68_NndWkTlF;R1 zEiW%3wd`)O@2>Bu&_^6RVMaUv@bFcR>{!J|+hk<*8PA($-_)rQ!!X20GPM*Uw^u7i z?gWPdXv`W3mwdZ`!Bp~@nLVGF_GO`a%pLs(gW?EXtGLgh_pJ>`9%(CNvGfJ9*GcwO z8qBTu$d(~c=(%*kXHnasLfOw^UBLli8No6Q{Nm1sANPaUw~SXi-V(rf^$wy&(j_Gg zs2XWO;v@N53OfAmMx`=;ebCb@1FVjk!+R*ptjnJ8(%gg+l{CX1hNYhkMp3mDidIAy zDoX61GcwJVgzv!}hh7os75AaanUJrX%x47s?pKu5auTr|(TFkjtf2@hsa&Sj4_;oO1_FzB=Z2>&Xm>%{5XbS)vI^JPYwLy=up^3Cf^T53{n5!Np?KUTOvR}g|05=`;T+L_e(oGaAlBxw!fgPzwg0CY;6h0pkwYiR`j-!yB^z0$LBEe>o7c;EU^ zGNS@(OID%}DRrIS)$MhD%+^pa0ZvFq-JxhCQSQT$G`%Yjen(D6P1gk3 z7$U@k;a+~a;88Fi=F`f5r7xKLTswDd2F^GEj)&e@dy*2w>OKDumylMdywACy<{LM} z<{2_Wlxuh5DqE6YxhMs7cqx#%R}y^{pj4$kZYeP%Od#U{hUpcSt25lr_m$c)PpXP4 zMWxk2Wm>L9ty2Hg2Q!mvO#>6Qr0(M#ipwU<-1FL`LMqXgrWoe>r)@ahUqpu5o2sY< zGNZgAXH+dq{a`#l*eB}zDyNAFbI<6R5PB*{gv#u)gj*Vj$OusmS^0R$-b1SZRBl&< zv&g|KOpat>27BR>q(m7F=K&UYI6P%{=G;;k+pa%wF+qklf5kO=y`F^S{Z?bC#nG{^2lL zcH7@rqocIgt$o~X`Rs&i;>3{>u`Ms2SrmkYG~A_SQ;a?0`BG~}vRM$tUp;KbK`gEP zSihbc8#4&tTw^u~BXzxTKbXR)bn7?;4rqKOBm^`_T@){pt;?(p>nGRFSw-@lBRX4* zzAs}cO?m|^lqyzgQSs(4#(y#bOFnYn9ppH@zuo6!AZu6zIdjg6gW=n8%>W-ORr8LW zA8l(xXam)^ah0%d(prWOPEeao%3bYd4-L{&`8eV~jO9D((<{dinPEw80w5lb$x^jf zvSE>dIXyT_5ZT%CO+2vhDZUAYs<$N?9Y?;U6gNF5D|b(iRa3_6>`AEAAjNS0Z|)i0 z$BK_bWhmU=hmw-!t2lHuxGXX>-a*Agz2il5#B0VI)>HPi>j8XsRh|}4y-zL(C*J0d z_9la;zH5Mfj!5Ne&-Z|b(0TowrLXT*CxBL`=OO1GpRHpzenn!Bn}!~}KZ|;Q3nrV$ zZt-@7(+6_I_1AB2fQnBf1wNDy2(9S&4_&C|R~7MU`lrm}CPpcjWs%1`kYOKSzDr>O zE)bt3_=eWroJc_#%-30%jC&}jBD`l_?}*M|*0X;A zV@DXvO*$z*u8P8K@J%b32CRr>3N}_lV>)_%z6eE+>-KnoeXKtp2K+8is7bBJ;>@C< z9NQ(Pj(d>_GzT7M4(y{y3NVb+M$wSCuG;ZIp-&OMP&|5Uji1JB>p}!9bu;2<!zaKCV4x!F#{fJ@}Q|pKrOjJ%3+p*Y&N<-8_*4@AuTr#a$ToK zTdq35SES7sSB67=Mg}HrZ}u<|U&6!uHrqYGs@=zT@J9yw8mU~HPA*O^;~`>ye&u`d z2IyOGn{qN_HRubv`|+J^Oxwj*do0MkB@n1PImuNNJk>aEIdBv^XFWmLGWeOXJIHmn z9Q-+Zs)9afxh%d3PtJs+BMtFJ3~pe9&oJC$6hC)1`5@hcdYbI^ciQI~8%ksDoA6HI zGEfMWOvx>t@#dQ@dHHEw$Ax8j$-baIKP4F6>7rr3*>ySRJ>aIAzqHr)4Op-yFm+-< zk9*C`PYqfeym?LdLjMUQ&2?9gEGyb~6PZR5coONDc+{}{<`6SHs=4Sl-5o}z$5li`-; z4zZ+PdVX8YL6D6-KPKi`lZu<*o?sw zPL?1<^BO*OLb~P)wm`XC{iL-DTGL+DHn{LIoO5}UsvNN(IatfzLSgr$=ci=*z-mPx zfP-G}wL!PKI{HOP@@K(tt_}%r$j@rH!8DY-n#EI~|?CVy^6{{Y#B}pK_T5N;k z(^-z6*PQ5mWGh7PRQIOxVVWV|w3f`rVA)S*Y7O+$fy+!^xiOQHy>8in%?N6S@;;(a zq;0A_ih$3D{E0wlK$-o8*WIC|U*_Z`B}EL*`fPN=fiR%ZQE70o+ivH$MT9hqN+1?m zlT<@AnGZc%xTVjnaY>O}ahW8itZR&Ys#&r#TideKoV-volXz#FxLz6W4cZLd-;Z}} zkuW}<*vJRB%sV+%Utd!}xk?%r-)3qjx znNU~{axr*WMMa!4Ophh z*bL8ZPi}1&8Lz)@oV}vHP$G-AvG5Dj=)mt*ue8%2@2r&((*`eWl)4>`*TdrL_04-O z7B7*2=NJFWYD}fznV+X07j31vY_HfH!iI>wI+fQwh@xWa=+);J@q(Mwm`I7V=o;@h zTO0T{q>3FR2n!LTh`JI=A?}AG(ff8?RdKZyei3k9xQ`- z2Ai?yHt`d5r%X%DZ#t-BEx~oel+j70UZ15d2iMBK$1U2LdO5yVaMXA%T0Xo-pJQIM za!P4WO(=b7zzbfoVZ}^YU*pDc;89dSO$_IUctoIibxo~(#yqDO4jOWJMGB28^{fxC z?m*{uwVISRnhw|))y=V7aq#iOeI>F{Q7@id&3=l0u&}PZztw(Z(z%!2N0%dC`|fU+ z#IVk2K9@wyPdjq321|kG@w8qRIaI7LTg{%{ti8dm8W?;Ri@BZ7!3kz*?UROqsR3#y zwoi||d)Ad{9}oaz`#o9=A3Dw~nUE#oud@BDab7uPN z}OYE7P^@hUfo#Wku!K&+{$ z-Ra#z!!eY+CSEgx3pVEfJXI*CVc8chHRk+$(^AX_EO+zHpl%J}oc$$>+y zd7Ti?cR-`tkbp$x#uIR5JO4gUow zZ^apA^Ls1yL2f$A`MP8&A5nnV#tA;eeAuxEvpw$wSZS#P;jlZqh@F0tkm$F;5PE0* zgXN4WCU+G;#GS5kAAEiv^n)nmpJBmXkJkJt;eq1DC;d=pq7^x47MfA{b>Xxn8zb*Z zqT-QBF~_%c;~A)D40c5`OfeZGQPd@uK!ycao`KKvY?QP*O#XK;pmdD$buJy?Zb+Pb z`|5RqD3%!PFq}dG8Cjhw0s8ZM4^8h2;H3eW&CvZF>RSrRiDUX8df^WNcv2UT3q^w?Df0sU$~K-|^`yGM1FwiA_2gDu zlj*B`ybo&Bm@}#L#)bI7b8_iZ^y0?Xm?UY>n@SmKn^l`4ixvZFzSVxwjc=rJP;_|7 zF;c(KzsBQNU1`K@$Z6(1)gLn;Px~o!(?WIo@HUu!BT}NxMfudK+1ek&J;1%wq!RYQ z$WVtNnKlsTgMvO|kK6;}h?Dz8uY_tBRGXyFKYkq5iGtHxO3Q&4^u}6#c3hhuks-p1 zwtz4yP)5g!C!GuZj+F)fQMO}%47G_4m)?OG+8IRON-8KEw2%mF~*Pq z!>Pktb&k0c!bM51&$v8UGlO4{ulO=+u-|p|=ZuUurN}{m5vB8-19yvhLtF;kmeu1y;jB$5Y6tLJD^ihv|;^T;xibe<% z(WZRW6#qDJDdSQ-OeNy%A0&DS|4f!({*8Kja&NT&0&p(P7}Up{#@~r@)17igaZ+R` z#7K}&@Ot@?wG7IZbUEU#cYIB!7XY3+c-4LBBdZb0^p1#m*{VKg$bVLpX>o_A^sS&x8jO;&a?>7;y1ul0u^)vIu;1f z!i-qN|Ct~8|IKau8#eJTSr_uiPQdfm7mA$L6o_$t4U_(?UWdV1=KqYmEBO)V$5fdX zIg(0&7I`y?7sDqQfjZfljo&)p4=HgvAiz z)u2TzNJ^>wgz5&A`oLU=3@joE*!JD?+564uy_tIPu%y)Pi;UMJN__~(sN2dEG0i+K zwpE^}r3_b7$4Wj1TX1bIv6ov-Xvr28?`RtWZ7>nu(va_4o9ZIb^E(W?Ae{@_bmR^i z5-}K-lT!Wo&CShMsDJ+j8S`Pd^fpCanjt;H=`y!KtabKOV`Kwp!i}tb=lUsQL`?x%@LosAqod@wll zOf(KjrvI|y@hp;ohy}9BtWF}g^s6+*$<4Vb%qdmP-^uP|%57oWlkb-d#zc=xwPrBJ4iK<;1px=gM-D+lW(A~kI0 z2TRq;m~M0p(xLv}H{W+h4r?~$OhWmfN}{kdVTnCWQ8&FnpV)exLaH}T=qt+NvnqS- zzc06UsuGi2%uDxc(x$}z0mwaurGSE_+@ynuFDoQO~J=W#4%-)`(?5diyv=8rj#V;lplyDqH92Ru0 zU#8_1GxTgoyBN}!AMV~b@T=#pE894LVRL!WI@4R*-FCtO6v*Yfn1Tc5WXs!@jhWua z_Z}X^7sMxO9EJ$ziFJs;A7>AbsceYm(sfO{a({C9($m$T@|JP8!d2th8#50NP1wyy zUI`V!2y`b|gu~}}BDrC_Okb zkR6VfgOHZ{T^+@3c1|Vemqa1y3ZI#S>yv^51BBZF3y0|0c(XExVT<3+Pi*}%GI>og zYHH-`lgpP=IPT)p^3yk+1;{K=g-eaC5*Y}@117k|mmd%QD)0X*s(?4USAc{=ybZna zm)_e19Dn@N!F5HOnsc4BdqT3`S?*1;#bwS|98_gcMH(kBchzTy>$$ThXY`vh!~qk? zCiSX_;}dF`(u;;OafIR&uIKJR--%UQpF%!7v_8c5`lV1UOOTeGB~p!t&(Vh#%nlyC zjUZ$*Gy2`sv8%G*E0{a0^mYEtFYb=#s^@qZB zn>Uy>!%%V#%f&8)H$CNmz&fQ%Ar$*0Gh?SgB1i@z(btvs(G(4%w~3HW*}*Ahb+$*knuJRm4?Vd-xhvv6__Vx@4VT)F`n zWu>l1!d;MP3<$5uw@kX(M~QRWN@o=xx{@sM0b0H|dpfs|4*jaJM5?AY|1H%O_$dVt z#24m+3yazN-peN55>G$+Mm?zW%q71lHAv^^{dknvH>;GXkw!}_z4Vnj=d$gY_Uppk zq7;h2@U62ryi?XnesvA3j&Tgdm%^p@tlD*xyM5O7l+)q)zS)n?~7$x83!F30ptvoV2&tJ*aU}oIzczmJ~(LClQkc(p3-bl$swb;aoiyas#blG z)IT(Vi3*|&WbRMmNIZ&)gNI39LpKxIXt+u!4_u*jkS5rb^N#Pa7Mp+7>WO z!M`t*T{FEF&^61lFAdE@#gKk79g2ksI00&Au#ciaFPptf$y16s7zxdrh z3`9`_Ef>`ed{oZ{-tiSJNB^)xipW0fP7F%_9D1+!51W>PFF&h{e4W@DvQXL&MbIrG zJ^$v}nVf-syS>BxT}XjbPTE%_PERMc?baW>$|<|&Ms}WC3HxleKadR3w((D@#*P^Y zfvk{Kzzw8k8D?>XTa@lasH14#5S%~;JuOCjb+P2qA~`Fwt4p|59T>CbOl;0STLwPU zneEm0Ek@HkHLKwz zL)uB2xr0_uKcp;wd7t$wH$g37Mj}^{d{qB;h~}WFNcQF1f}!t5sG0DMta_;97nsa# z=rmE+b{jf5{2RtLhpzN2un-EiFAKLrRJixh;Z}6gcGvpQoTne6c8}(Oe8~NA+>E%k z;N+K?Hze&Py?lk4sWRSOer`Dw^Nnv2Tz+CAHttVMQmq%)TKfykYl3J>Tfv_({UdlRH74+9Sn8ETk@H9)deGhad-%Km}|m@|5oRW||_( z-`V?3{NO6g{QG%5%}MpV@ZdRtChBu!ebOiYJ;XIq(@!*aJ&V>Eu zn?ST{n*Uf7_4u%abLz2VXb|0MW25Y8=o9#w?y4hT6-_kOt$kooI@`})Q1n573D2DP z?G!|y*WD)`!ZfQV|IZ0ojRU|xe39%su;!rbDJ36IK>W+aklOr&e)TqnG20!g8FE43 zMPsYsYsSPQC~taYTSGB<(Q>`@mgHwi@*}4@D-&zP&NYs7{LO!MdRWZp@mT8@}Mw!R)u}XpOzkdPfx~oaP~trF2n(rlY$x8tpse znU4w8JE}1?IV4i(Z@!;aBY)`JTdnneINYH{*N)q6nB`OQV*~g>S@0@}Y}R9STHZA1 z6X#wbXwl=t%Y{zCp1C85Amrs7& z61*x4q#TV^xtd8{BsaCE4&fVdKri;}4Z<&Bzql~n7 z4w|J6<*N|DsQWKlB9+z*b__P<42BLTpM1ToQV=VdC|e-gN9jy;A;KPy}!~# zGkeQGkFvRS7zy84I%~VV1niY=KR$f$mS7~H6!NeK={aX+D!ws3?$hMn#`k?aF3Vf_ zX`7R|8Yi&aU{sah?#CXw@(S7@m{?|)-VsWG4VW}a)No*Fn6<71yVvHX3QzLO#ngMV z>68XgyMeodSCtdLFKST*>Z0;OQQPgBGeWQ19D@ttYiVNJUD%(+%s+Z?{$|(lGycBA zT_rV|PUaZbI!C1p^oruqZn`yPXKr0a*D+D)#menq1{1WqJpxt4zZSh+QF6QJPxl8O z>33kkVdYPb|6XhOzp~zcvrh2eAoIV((*LyDMDZ;0?NyMIQxH|&Y_z>I)J1wnj{=lF zvEuUWtu(=3o!P}}?9EkBs4aR1(3V@@X}PVIXjJI5Gp|K<#Qf2hlvHEOKxnB&bpH9K zkqY;eh27EZ^kq9HDzSxF;0=T`SSZ{F`w3iP=&`&!=_{S0zOzyOOMVVEJw#_) zS?z|jW-{SK5Jw2&FxS|=9XWWAp&RcL`v-Q%LvGXms5%eWN~k8zMju~Cuav{fn8{qx zhEGH>-g2T>yMR< z5@}#M4s~^yaEksx%IhyI=r6Pe&5n8-!-i{5THmfzf}77*;xD$Bqu<`%1OEKx&T^kq z`4}UP>JG!ZTTpE+KszE2O+y{_ApNJe3m@jr`Tca|s!K2E4m&cIMOfj?1oqQ#C?Ou|- zGT0@E85P>(P63n{G#7fNFi%-{15h}O>5nyHtpsi?=v`XW@)Dg5nD~8_4Kw=v*Btlu zZtLFaGY7^Loc7Xr#j3-zw2jR>njDQmrHAwOW&IxYT3ceH0B zdCjfcQza!|mP?n2n;B%-PS{VPJVjV|&qgFoUd29FppBkYt8QHy$&;H{7jx#3lx*o? z@zbm|@n7_f)x1>mv#e|_sr1-h5qvicO0UJJ^c1*BB6^tH7tz0`e*N217s9}w4QLj7 zC0a#sbBZlJ#>%y3;oEhJTswzIjbX!*hNjTHI$24{WcIov{HI7VyP zZUz1iIYuV0oH}&9beFdIsD|Ievic5m#5DE%RM4JV2P4nYG-0OIfB=W2dEBm z%(h%jn&w%st4#BS7wD)i6#`!yrGP(j9Nr!Mr|7ay(D+yUEEaV9#;0#jr>OEKv?(*O zul79kl$ZBBv&G9TC+XCo46au~(l`KJ-tQq6*wto2RLe-J`LKK_Zn)H+|3i73Zz=a@ zZ@3KaFhW!ot6BT-X;uzRV9Z)xRelW{H%Q_*!YUx}F3NAb7Au0*H-(}i_ZVLW;s~Xp zPB2AS5^ZbrN{ZgT6g~XfV%TpV=XKB@nfT0nUR%?uQ86d;n!aVT7(7}_e`wdIq zneSl*skkrxu~xbMU_+*Zea}RLHw$EQ+|0Cbp%449+lBr_#8a&q&8V)oM0`Tj&hRYN z;!jPK#r_S?qYKWLZ$-E)CUxt)^xo(GQarf){BDBCf!p~59p;$CEJ$>=a982?uhwC2 zyxO--iA+YuE3l1OdmSsY)fAPptgCjC)!4b@l@6gNQq|a+^@T36nuXE~H~7M#RtrLw z)lK$Ki(E9xc+$oN&+oXEmDJHd4@D2B@%86;CR5Mf*>IbA0Z@H5VSA)BkE{-26XG)9 zYe+#vMHe{GH}2P#KNk}iC?P8obTd4?Z4_IViFEj(#93{v5^OfY`yB8XwlPn3^qS29 zvG$uTl`Mu)`wODC1X|sbKlAPfM18tVx-{((0xK)ri=$Gc0oS+lZ`No1i_f|i8`B Date: Fri, 20 Mar 2026 09:04:20 +0100 Subject: [PATCH 08/19] update package ownership --- cli/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/package.json b/cli/package.json index db50c42..cb63e86 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,7 +1,7 @@ { - "name": "copilot-token-tracker-cli", + "name": "@rajbos/ai-engineering-fluency", "version": "0.0.1", - "description": "CLI tool to analyze GitHub Copilot token usage from local session files", + "description": "AI Engineering Fluency - CLI tool to analyze GitHub Copilot token usage from local session files", "license": "MIT", "author": "RobBos", "repository": { From 8a3980a893c683cddf8664a6216d093a66c14731 Mon Sep 17 00:00:00 2001 From: Rob Bos Date: Fri, 20 Mar 2026 09:38:26 +0100 Subject: [PATCH 09/19] update package publishing --- .github/workflows/cli-publish.yml | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/.github/workflows/cli-publish.yml b/.github/workflows/cli-publish.yml index d349d95..4168d4f 100644 --- a/.github/workflows/cli-publish.yml +++ b/.github/workflows/cli-publish.yml @@ -20,6 +20,7 @@ on: permissions: contents: write + pull-requests: write jobs: publish: @@ -42,6 +43,7 @@ jobs: with: node-version: 20 registry-url: https://registry.npmjs.org + scope: '@rajbos' - name: Install extension dependencies run: npm ci @@ -63,27 +65,31 @@ jobs: id: version run: echo "version=$(node -p 'require("./package.json").version')" >> "$GITHUB_OUTPUT" - - name: Publish to npm + - name: Publish to npm (OIDC) if: ${{ !inputs.dry_run }} run: npm publish --access public - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - - name: Dry run publish + - name: Dry run publish (OIDC) if: ${{ inputs.dry_run }} run: npm publish --access public --dry-run - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - - name: Commit version bump + - name: Commit version bump and create PR if: ${{ !inputs.dry_run }} run: | cd .. git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git checkout -b cli/bump-version-v${{ steps.version.outputs.version }} git add cli/package.json cli/package-lock.json git commit -m "chore(cli): bump version to v${{ steps.version.outputs.version }}" - git push + git push origin cli/bump-version-v${{ steps.version.outputs.version }} + gh pr create \ + --title "chore(cli): bump version to v${{ steps.version.outputs.version }}" \ + --body "Automated version bump after publishing \`@rajbos/ai-engineering-fluency@${{ steps.version.outputs.version }}\` to npm." \ + --base main \ + --head cli/bump-version-v${{ steps.version.outputs.version }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Summary run: | @@ -94,5 +100,7 @@ jobs: echo "- **Dry run:** ${{ inputs.dry_run }}" >> "$GITHUB_STEP_SUMMARY" echo "" >> "$GITHUB_STEP_SUMMARY" if [ "${{ inputs.dry_run }}" = "false" ]; then - echo "Install with: \`npx copilot-token-tracker-cli\`" >> "$GITHUB_STEP_SUMMARY" + echo "Install with: \`npx @rajbos/ai-engineering-fluency\`" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "A PR has been opened to merge the version bump back to main." >> "$GITHUB_STEP_SUMMARY" fi From c0dab0308d2001c101ad028f22aed9b84ef658ea Mon Sep 17 00:00:00 2001 From: Rob Bos Date: Fri, 20 Mar 2026 09:47:25 +0100 Subject: [PATCH 10/19] update package publishing --- .github/workflows/cli-publish.yml | 8 +++++--- cli/package.json | 4 ++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/cli-publish.yml b/.github/workflows/cli-publish.yml index 4168d4f..ff09474 100644 --- a/.github/workflows/cli-publish.yml +++ b/.github/workflows/cli-publish.yml @@ -21,9 +21,11 @@ on: permissions: contents: write pull-requests: write + id-token: write jobs: publish: + name: Publish CLI to npm runs-on: ubuntu-latest defaults: run: @@ -65,11 +67,11 @@ jobs: id: version run: echo "version=$(node -p 'require("./package.json").version')" >> "$GITHUB_OUTPUT" - - name: Publish to npm (OIDC) + - name: Publish to npm if: ${{ !inputs.dry_run }} - run: npm publish --access public + run: npm publish --access public --provenance - - name: Dry run publish (OIDC) + - name: Dry run publish if: ${{ inputs.dry_run }} run: npm publish --access public --dry-run diff --git a/cli/package.json b/cli/package.json index cb63e86..e07eca7 100644 --- a/cli/package.json +++ b/cli/package.json @@ -6,7 +6,7 @@ "author": "RobBos", "repository": { "type": "git", - "url": "https://github.com/rajbos/github-copilot-token-usage", + "url": "git+https://github.com/rajbos/github-copilot-token-usage.git", "directory": "cli" }, "keywords": [ @@ -17,7 +17,7 @@ "token-tracker" ], "bin": { - "copilot-token-tracker": "./dist/cli.js" + "ai-engineering-fluency": "./dist/cli.js" }, "files": [ "dist/**/*" From 905dba76524cd3a04668f1f787088ed5b14934a5 Mon Sep 17 00:00:00 2001 From: Rob Bos Date: Fri, 20 Mar 2026 09:59:59 +0100 Subject: [PATCH 11/19] update package publishing --- .github/workflows/cli-publish.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/cli-publish.yml b/.github/workflows/cli-publish.yml index ff09474..cc0b7c6 100644 --- a/.github/workflows/cli-publish.yml +++ b/.github/workflows/cli-publish.yml @@ -44,8 +44,6 @@ jobs: uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 20 - registry-url: https://registry.npmjs.org - scope: '@rajbos' - name: Install extension dependencies run: npm ci @@ -69,7 +67,10 @@ jobs: - name: Publish to npm if: ${{ !inputs.dry_run }} - run: npm publish --access public --provenance + uses: npm/publish@v2 + with: + access: public + provenance: true - name: Dry run publish if: ${{ inputs.dry_run }} From 9dab34c8957953f7530e068aea30fe4e70db68ba Mon Sep 17 00:00:00 2001 From: Rob Bos Date: Fri, 20 Mar 2026 11:41:26 +0100 Subject: [PATCH 12/19] update package publishing --- .github/workflows/cli-publish.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/cli-publish.yml b/.github/workflows/cli-publish.yml index cc0b7c6..b261434 100644 --- a/.github/workflows/cli-publish.yml +++ b/.github/workflows/cli-publish.yml @@ -67,10 +67,7 @@ jobs: - name: Publish to npm if: ${{ !inputs.dry_run }} - uses: npm/publish@v2 - with: - access: public - provenance: true + run: npm publish --access public - name: Dry run publish if: ${{ inputs.dry_run }} From 5ec7971a096b684a64176c09cd6d7bcb6cda4e2a Mon Sep 17 00:00:00 2001 From: Rob Bos Date: Fri, 20 Mar 2026 11:44:56 +0100 Subject: [PATCH 13/19] Fix publish error --- cli/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/package.json b/cli/package.json index e07eca7..f73ccf9 100644 --- a/cli/package.json +++ b/cli/package.json @@ -17,7 +17,7 @@ "token-tracker" ], "bin": { - "ai-engineering-fluency": "./dist/cli.js" + "ai-engineering-fluency": "dist/cli.js" }, "files": [ "dist/**/*" From 8d2bcfe7951e6a40f2eb66b494c9f18545f2f9dd Mon Sep 17 00:00:00 2001 From: Rob Bos Date: Fri, 20 Mar 2026 11:46:11 +0100 Subject: [PATCH 14/19] update package publishing --- .github/workflows/cli-publish.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cli-publish.yml b/.github/workflows/cli-publish.yml index b261434..d18b108 100644 --- a/.github/workflows/cli-publish.yml +++ b/.github/workflows/cli-publish.yml @@ -21,7 +21,7 @@ on: permissions: contents: write pull-requests: write - id-token: write + id-token: write # Required for OIDC to npm registry jobs: publish: @@ -44,6 +44,7 @@ jobs: uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 20 + registry-url: 'https://registry.npmjs.org' - name: Install extension dependencies run: npm ci @@ -67,7 +68,7 @@ jobs: - name: Publish to npm if: ${{ !inputs.dry_run }} - run: npm publish --access public + run: npm publish - name: Dry run publish if: ${{ inputs.dry_run }} From d6dd3b2b074fbc09e953621384fe162717856ebc Mon Sep 17 00:00:00 2001 From: Rob Bos Date: Fri, 20 Mar 2026 12:09:29 +0100 Subject: [PATCH 15/19] update package publishing --- .github/workflows/cli-build.yml | 13 ++++++++----- .github/workflows/cli-publish.yml | 2 +- cli/package.json | 2 +- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/.github/workflows/cli-build.yml b/.github/workflows/cli-build.yml index 4fbe752..85f751e 100644 --- a/.github/workflows/cli-build.yml +++ b/.github/workflows/cli-build.yml @@ -36,9 +36,8 @@ permissions: jobs: build-and-validate: runs-on: ubuntu-latest - strategy: - matrix: - node-version: [18, 20, 22] + env: + node-version: 22 steps: - name: Harden Runner uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 @@ -47,10 +46,10 @@ jobs: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - name: Setup Node.js ${{ matrix.node-version }} + - name: Setup Node.js ${{ env.node-version }} uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: - node-version: ${{ matrix.node-version }} + node-version: ${{ env.node-version }} - name: Install extension dependencies run: npm ci @@ -87,6 +86,10 @@ jobs: working-directory: cli run: node dist/cli.js fluency --tips + - name: Validate diagnostics command + working-directory: cli + run: node dist/cli.js diagnostics + - name: Build production bundle working-directory: cli run: npm run build:production diff --git a/.github/workflows/cli-publish.yml b/.github/workflows/cli-publish.yml index d18b108..43b9682 100644 --- a/.github/workflows/cli-publish.yml +++ b/.github/workflows/cli-publish.yml @@ -43,7 +43,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: - node-version: 20 + node-version: 22 registry-url: 'https://registry.npmjs.org' - name: Install extension dependencies diff --git a/cli/package.json b/cli/package.json index f73ccf9..1e88ece 100644 --- a/cli/package.json +++ b/cli/package.json @@ -38,6 +38,6 @@ "sql.js": "^1.12.0" }, "engines": { - "node": ">=18.0.0" + "node": ">=22.14.0" } } From 82802778c09c677b7a37a85b71cb74a8b870d2f4 Mon Sep 17 00:00:00 2001 From: Rob Bos Date: Fri, 20 Mar 2026 12:15:21 +0100 Subject: [PATCH 16/19] test trigger --- .github/workflows/cli-publish.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/cli-publish.yml b/.github/workflows/cli-publish.yml index 43b9682..5ae65a4 100644 --- a/.github/workflows/cli-publish.yml +++ b/.github/workflows/cli-publish.yml @@ -18,6 +18,13 @@ on: default: false type: boolean + push: + #branches: + # - main + paths: + - 'cli/package.json' + - 'cli/package-lock.json' + permissions: contents: write pull-requests: write From c75a67cad6e28b6901810c0336c0cb8cac532101 Mon Sep 17 00:00:00 2001 From: Rob Bos Date: Fri, 20 Mar 2026 12:15:41 +0100 Subject: [PATCH 17/19] test trigger --- .github/workflows/cli-publish.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/cli-publish.yml b/.github/workflows/cli-publish.yml index 5ae65a4..68950f9 100644 --- a/.github/workflows/cli-publish.yml +++ b/.github/workflows/cli-publish.yml @@ -22,6 +22,7 @@ on: #branches: # - main paths: + - '.github/workflows/cli-publish.yml' - 'cli/package.json' - 'cli/package-lock.json' From c5d2444b790d4a1ff5f71b2e6ebfcf060319401c Mon Sep 17 00:00:00 2001 From: Rob Bos Date: Fri, 20 Mar 2026 13:30:21 +0100 Subject: [PATCH 18/19] fix publish? --- .github/workflows/cli-publish.yml | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/.github/workflows/cli-publish.yml b/.github/workflows/cli-publish.yml index 68950f9..ddca581 100644 --- a/.github/workflows/cli-publish.yml +++ b/.github/workflows/cli-publish.yml @@ -44,14 +44,12 @@ jobs: with: egress-policy: audit - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - token: ${{ secrets.GITHUB_TOKEN }} + - uses: actions/checkout@v6 #v6 - name: Setup Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + uses: actions/setup-node@v6 #v6 with: - node-version: 22 + node-version: 24 registry-url: 'https://registry.npmjs.org' - name: Install extension dependencies @@ -76,11 +74,11 @@ jobs: - name: Publish to npm if: ${{ !inputs.dry_run }} - run: npm publish + run: NODE_AUTH_TOKEN="" npm publish - name: Dry run publish if: ${{ inputs.dry_run }} - run: npm publish --access public --dry-run + run: NODE_AUTH_TOKEN="" npm publish public --dry-run - name: Commit version bump and create PR if: ${{ !inputs.dry_run }} From 1a0695e5ee2bb93635842a4fb5cf0d9ab819dce1 Mon Sep 17 00:00:00 2001 From: Rob Bos Date: Fri, 20 Mar 2026 13:31:59 +0100 Subject: [PATCH 19/19] version bump --- cli/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/package.json b/cli/package.json index 1e88ece..849b5fb 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@rajbos/ai-engineering-fluency", - "version": "0.0.1", + "version": "0.0.2", "description": "AI Engineering Fluency - CLI tool to analyze GitHub Copilot token usage from local session files", "license": "MIT", "author": "RobBos",