Skip to content

Commit cef8e47

Browse files
devartifexCopilot
andcommitted
Release 0.2.0
Fix Windows first-install statusline setup by generating a .cmd shim and using a PowerShell OTel profile block when appropriate. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent ec43592 commit cef8e47

5 files changed

Lines changed: 71 additions & 25 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.2.0] - 2026-05-14
9+
10+
### Fixed
11+
12+
- Fixed first-time Windows installs by generating a `copilot-cost.cmd` statusline shim and configuring Copilot CLI to use it.
13+
- Added PowerShell profile OpenTelemetry setup on Windows when no POSIX shell is active.
14+
- Preserved the existing POSIX shell shim behavior for macOS and Linux installs.
15+
816
## [0.1.0] - 2026-05-14
917

1018
Initial public release.

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "copilot-cost",
3-
"version": "0.1.0",
3+
"version": "0.2.0",
44
"description": "Local-only GitHub Copilot CLI statusline + dashboard for real-time token cost tracking. Reads OpenTelemetry traces, never sends data anywhere.",
55
"keywords": [
66
"github",

src/install.ts

Lines changed: 41 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export interface InstallPaths {
2020
shimPath: string;
2121
settingsPath: string;
2222
profilePath: string;
23+
profileKind: "posix" | "powershell";
2324
}
2425

2526
type JsonObject = Record<string, unknown>;
@@ -28,6 +29,10 @@ function currentHome(): string {
2829
return process.env.HOME || homedir();
2930
}
3031

32+
function isWindows(): boolean {
33+
return process.platform === "win32";
34+
}
35+
3136
function pricingCachePath(home = currentHome()): string {
3237
return path.join(home, ".copilot", "cost-cache", "pricing.yaml");
3338
}
@@ -44,14 +49,23 @@ export function resolveInstallPaths(env: NodeJS.ProcessEnv = process.env): Insta
4449
const home = env.HOME || homedir();
4550
const copilotDir = path.join(home, ".copilot");
4651
const shell = path.basename(env.SHELL || "");
52+
const profileKind = !shell && isWindows() ? "powershell" : "posix";
53+
const powerShellProfileDir =
54+
env.PSModulePath?.split(path.delimiter)
55+
.map((entry) => path.normalize(entry))
56+
.find((entry) => path.basename(entry).toLowerCase() === "modules" && path.basename(path.dirname(entry)).toLowerCase() === "powershell");
4757
const profileName = shell.includes("zsh") ? ".zshrc" : shell.includes("bash") ? ".bashrc" : ".profile";
4858
return {
4959
home,
5060
copilotDir,
5161
binDir: path.join(copilotDir, "bin"),
52-
shimPath: path.join(copilotDir, "bin", "copilot-cost"),
62+
shimPath: path.join(copilotDir, "bin", isWindows() ? "copilot-cost.cmd" : "copilot-cost"),
5363
settingsPath: path.join(copilotDir, "settings.json"),
54-
profilePath: path.join(home, profileName),
64+
profilePath:
65+
profileKind === "powershell"
66+
? path.join(powerShellProfileDir ? path.dirname(powerShellProfileDir) : path.join(home, "Documents", "PowerShell"), "Microsoft.PowerShell_profile.ps1")
67+
: path.join(home, profileName),
68+
profileKind,
5569
};
5670
}
5771

@@ -63,14 +77,24 @@ function shellQuote(value: string): string {
6377
return `'${value.replaceAll("'", `'\\''`)}'`;
6478
}
6579

66-
export function otelBlock(): string {
67-
return [
68-
OTEL_BEGIN,
69-
"export COPILOT_OTEL_ENABLED=true",
70-
"export COPILOT_OTEL_EXPORTER_TYPE=file",
71-
'export COPILOT_OTEL_FILE_EXPORTER_PATH="$HOME/.copilot/otel/copilot-otel.jsonl"',
72-
OTEL_END,
73-
].join("\n");
80+
export function otelBlock(profileKind: "posix" | "powershell" = resolveInstallPaths().profileKind): string {
81+
const lines =
82+
profileKind === "powershell"
83+
? [
84+
OTEL_BEGIN,
85+
"$env:COPILOT_OTEL_ENABLED = 'true'",
86+
"$env:COPILOT_OTEL_EXPORTER_TYPE = 'file'",
87+
"$env:COPILOT_OTEL_FILE_EXPORTER_PATH = Join-Path $HOME '.copilot/otel/copilot-otel.jsonl'",
88+
OTEL_END,
89+
]
90+
: [
91+
OTEL_BEGIN,
92+
"export COPILOT_OTEL_ENABLED=true",
93+
"export COPILOT_OTEL_EXPORTER_TYPE=file",
94+
'export COPILOT_OTEL_FILE_EXPORTER_PATH="$HOME/.copilot/otel/copilot-otel.jsonl"',
95+
OTEL_END,
96+
];
97+
return lines.join("\n");
7498
}
7599

76100
function readJsonObject(filePath: string): JsonObject {
@@ -99,12 +123,12 @@ export function hasOtelBlock(profilePath: string): boolean {
99123
return existsSync(profilePath) && readFileSync(profilePath, "utf-8").includes(OTEL_BEGIN);
100124
}
101125

102-
export function appendOtelExporterBlock(profilePath = resolveInstallPaths().profilePath): "appended" | "already-present" {
126+
export function appendOtelExporterBlock(profilePath = resolveInstallPaths().profilePath, profileKind = resolveInstallPaths().profileKind): "appended" | "already-present" {
103127
mkdirSync(path.dirname(profilePath), { recursive: true });
104128
const existing = existsSync(profilePath) ? readFileSync(profilePath, "utf-8") : "";
105129
if (existing.includes(OTEL_BEGIN)) return "already-present";
106130
const prefix = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
107-
writeFileSync(profilePath, `${existing}${prefix}${otelBlock()}\n`, "utf-8");
131+
writeFileSync(profilePath, `${existing}${prefix}${otelBlock(profileKind)}\n`, "utf-8");
108132
return "appended";
109133
}
110134

@@ -120,9 +144,9 @@ export function removeOtelExporterBlock(profilePath = resolveInstallPaths().prof
120144
function writeShim(shimPath: string): void {
121145
mkdirSync(path.dirname(shimPath), { recursive: true });
122146
const target = cliPathFromInstallModule();
123-
const body = `#!/bin/sh\nexec node ${shellQuote(target)} render "$@"\n`;
147+
const body = isWindows() ? `@echo off\r\nnode "${target}" render %*\r\n` : `#!/bin/sh\nexec node ${shellQuote(target)} render "$@"\n`;
124148
writeFileSync(shimPath, body, "utf-8");
125-
chmodSync(shimPath, 0o755);
149+
if (!isWindows()) chmodSync(shimPath, 0o755);
126150
}
127151

128152
function installSettings(settingsPath: string, shimPath: string): "updated" | "already-configured" {
@@ -148,9 +172,9 @@ export async function cmdInstall(opts: { yes?: boolean; otelProfile?: boolean }
148172
let otelAction: "appended" | "already-present" | "skipped" = "skipped";
149173
const shouldOfferProfileEdit = opts.otelProfile !== false;
150174
if (!shouldOfferProfileEdit) {
151-
console.log(`OTel profile edit skipped. Add this block to your shell profile to enable capture:\n${otelBlock()}`);
175+
console.log(`OTel profile edit skipped. Add this block to your shell profile to enable capture:\n${otelBlock(paths.profileKind)}`);
152176
} else {
153-
otelAction = appendOtelExporterBlock(paths.profilePath);
177+
otelAction = appendOtelExporterBlock(paths.profilePath, paths.profileKind);
154178
}
155179

156180
await refreshPricing({ force: false, dest: pricingCachePath(paths.home) });
@@ -250,7 +274,7 @@ export async function cmdDoctor(): Promise<number> {
250274
okLine("sample render", false, error instanceof Error ? error.message : String(error));
251275
}
252276

253-
const executable = existsSync(paths.shimPath) && (statSync(paths.shimPath).mode & 0o111) !== 0;
277+
const executable = existsSync(paths.shimPath) && (isWindows() || (statSync(paths.shimPath).mode & 0o111) !== 0);
254278
if (!okLine("shim executable", executable, paths.shimPath)) failed = true;
255279
const dashboardDir = path.resolve(packageRoot(import.meta.url), "dashboard-ui", "dist");
256280
const missingDashboardFiles = ["index.html", "app.js", "styles.css"].filter((file) => !existsSync(path.join(dashboardDir, file)));

tests-ts/install.test.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,13 @@ import path from "node:path";
44

55
const root = path.resolve(".test-home", "install-tests");
66
const savedEnv = { ...process.env };
7+
const shimName = process.platform === "win32" ? "copilot-cost.cmd" : "copilot-cost";
78

8-
function resetHome(name: string): string {
9+
function resetHome(name: string, overrides: NodeJS.ProcessEnv = { SHELL: "/bin/zsh" }): string {
910
vi.resetModules();
1011
const home = path.join(root, name);
1112
rmSync(home, { recursive: true, force: true });
12-
process.env = { ...savedEnv, HOME: home, SHELL: "/bin/zsh", COPILOT_COST_REFRESH_DAYS: "999999" };
13+
process.env = { ...savedEnv, HOME: home, COPILOT_COST_REFRESH_DAYS: "999999", ...overrides };
1314
return home;
1415
}
1516

@@ -29,13 +30,13 @@ describe("install commands", () => {
2930
const { cmdInstall } = await import("../src/install.js");
3031
await expect(cmdInstall({ yes: true })).resolves.toBe(0);
3132

32-
const shim = path.join(home, ".copilot", "bin", "copilot-cost");
33+
const shim = path.join(home, ".copilot", "bin", shimName);
3334
const otelDir = path.join(home, ".copilot", "otel");
3435
const settingsPath = path.join(home, ".copilot", "settings.json");
3536
const profilePath = path.join(home, ".zshrc");
3637
expect(existsSync(shim)).toBe(true);
3738
expect(existsSync(otelDir)).toBe(true);
38-
expect(statSync(shim).mode & 0o111).not.toBe(0);
39+
if (process.platform !== "win32") expect(statSync(shim).mode & 0o111).not.toBe(0);
3940
const settings = JSON.parse(readFileSync(settingsPath, "utf-8")) as { statusLine: { command: string } };
4041
expect(settings.statusLine.command).toBe(shim);
4142
expect(readFileSync(profilePath, "utf-8")).toContain("copilot-cost OTel exporter");
@@ -62,13 +63,26 @@ describe("install commands", () => {
6263
expect(output).not.toContain("Append OTel exporter settings");
6364
});
6465

66+
it("uses a PowerShell profile block on Windows when no POSIX shell is active", async () => {
67+
if (process.platform !== "win32") return;
68+
const home = resetHome("powershell-profile", {
69+
SHELL: "",
70+
PSModulePath: path.join(root, "powershell-profile", "Documents", "PowerShell", "Modules"),
71+
});
72+
const { cmdInstall } = await import("../src/install.js");
73+
await expect(cmdInstall({ yes: true })).resolves.toBe(0);
74+
75+
const profilePath = path.join(home, "Documents", "PowerShell", "Microsoft.PowerShell_profile.ps1");
76+
expect(readFileSync(profilePath, "utf-8")).toContain("$env:COPILOT_OTEL_ENABLED = 'true'");
77+
});
78+
6579
it("uninstall reverses install", async () => {
6680
const home = resetHome("uninstall");
6781
const { cmdInstall, cmdUninstall } = await import("../src/install.js");
6882
await cmdInstall({ yes: true });
6983
await expect(cmdUninstall({ yes: true })).resolves.toBe(0);
7084

71-
const shim = path.join(home, ".copilot", "bin", "copilot-cost");
85+
const shim = path.join(home, ".copilot", "bin", shimName);
7286
const settings = JSON.parse(readFileSync(path.join(home, ".copilot", "settings.json"), "utf-8")) as Record<string, unknown>;
7387
expect(existsSync(shim)).toBe(false);
7488
expect(settings.statusLine).toBeUndefined();

0 commit comments

Comments
 (0)