Skip to content

Commit e768a6a

Browse files
committed
Enhance installation process and logging for Copilot CLI status line feature
1 parent ed4c3af commit e768a6a

5 files changed

Lines changed: 86 additions & 44 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ copilot-cost install
3232

3333
Then **restart your shell and restart `copilot`**. That's it.
3434

35-
The installer configures the Copilot CLI statusline, appends an idempotent OpenTelemetry block to your shell profile, and enables local JSONL span output under `~/.copilot/otel/`. It does **not** start the dashboard.
35+
The installer configures the Copilot CLI statusline (including the required `"experimental": true` flag in `~/.copilot/settings.json` — the GitHub Copilot CLI gates custom status lines behind that flag), appends an idempotent OpenTelemetry block to your shell profile, and enables local JSONL span output under `~/.copilot/otel/`. It does **not** start the dashboard.
3636

3737
Prefer to edit your shell profile yourself? Run `copilot-cost install --no-otel-profile` — the OpenTelemetry block is printed for manual setup.
3838

@@ -142,7 +142,7 @@ More on the underlying telemetry pipeline: [Copilot OpenTelemetry observability]
142142

143143
## 🩺 Troubleshooting
144144

145-
- **The statusline does not appear.** Run `copilot-cost doctor`, then confirm the Copilot CLI was restarted after install.
145+
- **The statusline does not appear.** Run `copilot-cost doctor`. The custom statusline requires `"experimental": true` in `~/.copilot/settings.json` — without it the GitHub Copilot CLI ignores the `statusLine` block entirely (its logs show `STATUS_LINE: false`). Re-running `copilot-cost install` sets both keys; then restart Copilot CLI.
146146
- **No usage shows up yet.** Make sure you're on the latest Copilot CLI, restart your shell and `copilot`, send a prompt, then check for JSONL files in `~/.copilot/otel/`.
147147
- **I do not want profile edits.** Use `copilot-cost install --no-otel-profile` and paste the printed OpenTelemetry block into the shell profile you choose.
148148
- **The dashboard will not bind.** Use a local host only, e.g. `copilot-cost dashboard --host 127.0.0.1 --port 4567`.

package-lock.json

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

src/install.ts

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,36 @@ function otelDir(home = currentHome()): string {
4545
return path.join(home, ".copilot", "otel");
4646
}
4747

48+
function copilotLogsDir(home = currentHome()): string {
49+
return path.join(home, ".copilot", "logs");
50+
}
51+
52+
export type StatusLineFlag = "enabled" | "disabled" | "unknown";
53+
54+
export function detectStatusLineFeatureFlag(home = currentHome()): { state: StatusLineFlag; logFile?: string } {
55+
const dir = copilotLogsDir(home);
56+
if (!existsSync(dir)) return { state: "unknown" };
57+
const candidates = readdirSync(dir)
58+
.filter((name) => name.startsWith("process-") && name.endsWith(".log"))
59+
.map((name) => ({ name, path: path.join(dir, name), mtimeMs: statSync(path.join(dir, name)).mtimeMs }))
60+
.sort((a, b) => b.mtimeMs - a.mtimeMs);
61+
for (const candidate of candidates.slice(0, 3)) {
62+
let content: string;
63+
try {
64+
content = readFileSync(candidate.path, "utf-8");
65+
} catch {
66+
continue;
67+
}
68+
const matches = content.match(/"STATUS_LINE"\s*:\s*"(true|false)"/g);
69+
if (!matches || matches.length === 0) continue;
70+
const last = matches[matches.length - 1] ?? "";
71+
const value = last.match(/"(true|false)"/)?.[1];
72+
if (value === "true") return { state: "enabled", logFile: candidate.path };
73+
if (value === "false") return { state: "disabled", logFile: candidate.path };
74+
}
75+
return { state: "unknown" };
76+
}
77+
4878
function otelExporterPath(home = currentHome()): string {
4979
return path.join(otelDir(home), "copilot-otel.jsonl");
5080
}
@@ -164,11 +194,14 @@ function installSettings(settingsPath: string, shimPath: string): "updated" | "a
164194
mkdirSync(path.dirname(settingsPath), { recursive: true });
165195
const settings = readJsonObject(settingsPath);
166196
const next = { type: "command", command: shimPath, padding: 1 };
167-
if (JSON.stringify(settings.statusLine ?? null) === JSON.stringify(next)) return "already-configured";
197+
const statusLineMatches = JSON.stringify(settings.statusLine ?? null) === JSON.stringify(next);
198+
const experimentalEnabled = settings.experimental === true;
199+
if (statusLineMatches && experimentalEnabled) return "already-configured";
168200
if (existsSync(settingsPath)) {
169201
copyFileSync(settingsPath, `${settingsPath}.bak.${timestamp()}`);
170202
}
171203
settings.statusLine = next;
204+
settings.experimental = true;
172205
writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, "utf-8");
173206
return "updated";
174207
}
@@ -193,7 +226,7 @@ export async function cmdInstall(opts: { yes?: boolean; otelProfile?: boolean }
193226
console.log([
194227
"copilot-cost installed:",
195228
` shim: ${paths.shimPath}`,
196-
` settings: ${paths.settingsPath} (${settingsAction})`,
229+
` settings: ${paths.settingsPath} (${settingsAction}; statusLine + experimental: true)`,
197230
` shell profile: ${paths.profilePath}`,
198231
" restart: restart Copilot CLI or open a new shell for OTel env vars",
199232
` otel env: ${otelAction}`,
@@ -255,6 +288,8 @@ export async function cmdDoctor(): Promise<number> {
255288
const settings = existsSync(paths.settingsPath) ? readJsonObject(paths.settingsPath) : null;
256289
const statusLine = settings?.statusLine as { command?: unknown } | undefined;
257290
if (!okLine("settings statusLine", Boolean(settings && statusLine?.command === paths.shimPath), `${paths.settingsPath}; expected command ${paths.shimPath}`)) failed = true;
291+
const experimentalEnabled = settings?.experimental === true;
292+
if (!okLine("settings experimental", experimentalEnabled, experimentalEnabled ? `${paths.settingsPath} has \"experimental\": true (required for custom statusLine)` : `${paths.settingsPath} is missing \"experimental\": true; the Copilot CLI ignores statusLine without it. Re-run 'copilot-cost install' or set it manually.`)) failed = true;
258293

259294
try {
260295
const pricing = loadPricing(pricingCachePath(paths.home));
@@ -273,6 +308,22 @@ export async function cmdDoctor(): Promise<number> {
273308
okLine("otel profile block", profileConfigured, profileConfigured ? paths.profilePath : `${paths.profilePath} missing copilot-cost block; run copilot-cost install`, false);
274309
const envEnabled = process.env.COPILOT_OTEL_ENABLED === "true" && process.env.COPILOT_OTEL_EXPORTER_TYPE === "file" && Boolean(process.env.COPILOT_OTEL_FILE_EXPORTER_PATH);
275310
okLine("shell restart", envEnabled, envEnabled ? "current shell has COPILOT_OTEL_* file exporter variables" : "restart your shell and Copilot CLI so COPILOT_OTEL_* variables take effect", false);
311+
const statusLineFlag = detectStatusLineFeatureFlag(paths.home);
312+
if (statusLineFlag.state === "disabled") {
313+
okLine(
314+
"copilot statusline feature",
315+
false,
316+
`Copilot CLI feature flag STATUS_LINE=false in ${statusLineFlag.logFile ?? copilotLogsDir(paths.home)}. The CLI gates the custom statusLine behind \"experimental\": true in ${paths.settingsPath}. Ensure that flag is set, then restart Copilot CLI.`,
317+
false,
318+
);
319+
} else {
320+
okLine(
321+
"copilot statusline feature",
322+
true,
323+
statusLineFlag.state === "enabled" ? "Copilot CLI feature flag STATUS_LINE=true" : "Copilot CLI feature flag STATUS_LINE not observed yet; start Copilot CLI once to populate logs",
324+
false,
325+
);
326+
}
276327
const otelPath = otelDir(paths.home);
277328
const jsonlCount = existsSync(otelPath) ? readdirSync(otelPath).filter((name) => name.endsWith(".jsonl") && name !== "copilot-cost-meta.jsonl").length : 0;
278329
okLine("otel jsonl files", jsonlCount > 0, `${otelPath} (${jsonlCount}); if zero, send a Copilot CLI prompt after shell restart`, false);

tests-ts/doctor.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ describe("doctor", () => {
3838
await cmdInstall({ yes: true });
3939
await expect(cmdDoctor()).resolves.toBe(0);
4040
expect(logs.join("\n")).toContain("OK: settings statusLine");
41+
expect(logs.join("\n")).toContain("OK: settings experimental");
4142
expect(logs.join("\n")).toContain("OK: pricing");
4243
expect(logs.join("\n")).toContain("OK: pricing cache");
4344
expect(logs.join("\n")).toContain("WARN: shell restart");
@@ -52,6 +53,7 @@ describe("doctor", () => {
5253
const { cmdDoctor } = await loadInstall("missing");
5354
await expect(cmdDoctor()).resolves.toBe(1);
5455
expect(logs.join("\n")).toContain("FAIL: settings statusLine");
56+
expect(logs.join("\n")).toContain("FAIL: settings experimental");
5557
expect(logs.join("\n")).toContain("FAIL: shim executable");
5658
expect(logs.join("\n")).toContain("missing copilot-cost block");
5759
});
@@ -67,4 +69,31 @@ describe("doctor", () => {
6769
expect(logs.join("\n")).toContain("WARN: otel jsonl files");
6870
expect(logs.join("\n")).toContain("send a Copilot CLI prompt after shell restart");
6971
});
72+
73+
it("warns when Copilot CLI feature flag STATUS_LINE is false", async () => {
74+
const logs: string[] = [];
75+
vi.spyOn(console, "log").mockImplementation((msg?: unknown) => { logs.push(String(msg)); });
76+
vi.spyOn(console, "error").mockImplementation(() => undefined);
77+
const { cmdInstall, cmdDoctor } = await loadInstall("statusline-flag-off");
78+
await cmdInstall({ yes: true });
79+
const logsDir = path.join(root, "statusline-flag-off", ".copilot", "logs");
80+
mkdirSync(logsDir, { recursive: true });
81+
writeFileSync(path.join(logsDir, "process-1.log"), '{\n "feature_flags": {\n "STATUS_LINE": "false"\n }\n}\n', "utf-8");
82+
await expect(cmdDoctor()).resolves.toBe(0);
83+
expect(logs.join("\n")).toContain("WARN: copilot statusline feature");
84+
expect(logs.join("\n")).toContain("STATUS_LINE=false");
85+
});
86+
87+
it("reports OK when Copilot CLI feature flag STATUS_LINE is true", async () => {
88+
const logs: string[] = [];
89+
vi.spyOn(console, "log").mockImplementation((msg?: unknown) => { logs.push(String(msg)); });
90+
vi.spyOn(console, "error").mockImplementation(() => undefined);
91+
const { cmdInstall, cmdDoctor } = await loadInstall("statusline-flag-on");
92+
await cmdInstall({ yes: true });
93+
const logsDir = path.join(root, "statusline-flag-on", ".copilot", "logs");
94+
mkdirSync(logsDir, { recursive: true });
95+
writeFileSync(path.join(logsDir, "process-1.log"), '{\n "feature_flags": {\n "STATUS_LINE": "true"\n }\n}\n', "utf-8");
96+
await expect(cmdDoctor()).resolves.toBe(0);
97+
expect(logs.join("\n")).toContain("OK: copilot statusline feature");
98+
});
7099
});

tests-ts/install.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,9 @@ describe("install commands", () => {
3939
expect(existsSync(otelDir)).toBe(true);
4040
expect(existsSync(otelExporterPath)).toBe(true);
4141
if (process.platform !== "win32") expect(statSync(shim).mode & 0o111).not.toBe(0);
42-
const settings = JSON.parse(readFileSync(settingsPath, "utf-8")) as { statusLine: { command: string } };
42+
const settings = JSON.parse(readFileSync(settingsPath, "utf-8")) as { statusLine: { command: string }; experimental: boolean };
4343
expect(settings.statusLine.command).toBe(shim);
44+
expect(settings.experimental).toBe(true);
4445
expect(readFileSync(profilePath, "utf-8")).toContain("copilot-cost OTel exporter");
4546

4647
await expect(cmdInstall({ yes: true })).resolves.toBe(0);

0 commit comments

Comments
 (0)