From a611f7ae95d995d5fd9d73ebc97e0edf7e2c3332 Mon Sep 17 00:00:00 2001 From: shahinyanm Date: Fri, 5 Jun 2026 16:35:17 +0400 Subject: [PATCH] =?UTF-8?q?v0.42.0:=20`token-pilot=20install-statusline`?= =?UTF-8?q?=20=E2=80=94=20one-command=20badge=20setup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After v0.41.1 removed the intrusive sessionTitle overwrite, the cumulative-savings badge lives in the additive statusline (the caveman-style channel that sits alongside the session name and live-updates every render). But wiring it meant hand-editing ~/.claude/settings.json. The user asked: can't we auto-enable it? We can — but NOT by silently writing the user's config (that's the sessionTitle mistake again: clobbering a statusLine the user may have set for caveman or a custom badge). So this is an explicit, non-destructive opt-in command. `token-pilot install-statusline` decides by current state (reusing ecosystem-check's classifier): - not-configured → write the chain command (merges into existing settings, preserving every other key) - configured-caveman-only / tp-only → upgrade to the chain wrapper so BOTH badges render side by side - configured-chain → no-op - configured-other → LEFT UNTOUCHED; prints how to switch, or --force to replace a custom statusLine - unknown (bad JSON) → not modified; prints manual guidance The chain command is version-agnostic (globs the newest plugin dir), so it survives plugin upgrades. Wiring: src/cli/install-statusline.ts (decideStatuslineAction pure + classifyStatuslineAt path-injectable, both fully tested) · index.js case install-statusline · typo-guard. The `doctor` not-configured nudge now points at this one command instead of a JSON recipe. Verified e2e against the dist: fresh write preserves other settings keys; re-run is a no-op; a custom statusLine is never clobbered. Tests: 1349/1349 pass (+16). Build: clean (25 agents under 0.42.0). --- .claude-plugin/marketplace.json | 4 +- .claude-plugin/plugin.json | 2 +- agents/tp-api-surface-tracker.md | 2 +- agents/tp-audit-scanner.md | 2 +- agents/tp-commit-writer.md | 2 +- agents/tp-context-engineer.md | 2 +- agents/tp-dead-code-finder.md | 2 +- agents/tp-debugger.md | 2 +- agents/tp-dep-health.md | 2 +- agents/tp-doc-writer.md | 2 +- agents/tp-history-explorer.md | 2 +- agents/tp-impact-analyzer.md | 2 +- agents/tp-incident-timeline.md | 2 +- agents/tp-incremental-builder.md | 2 +- agents/tp-migration-scout.md | 2 +- agents/tp-onboard.md | 2 +- agents/tp-performance-profiler.md | 2 +- agents/tp-pr-reviewer.md | 2 +- agents/tp-refactor-planner.md | 2 +- agents/tp-review-impact.md | 2 +- agents/tp-run.md | 2 +- agents/tp-session-restorer.md | 2 +- agents/tp-ship-coordinator.md | 2 +- agents/tp-spec-writer.md | 2 +- agents/tp-test-coverage-gapper.md | 2 +- agents/tp-test-triage.md | 2 +- agents/tp-test-writer.md | 2 +- package.json | 2 +- src/cli/ecosystem-check.ts | 18 +-- src/cli/install-statusline.ts | 187 +++++++++++++++++++++++++++ src/cli/typo-guard.ts | 2 + src/index.ts | 12 ++ tests/cli/ecosystem-check.test.ts | 6 +- tests/cli/install-statusline.test.ts | 143 ++++++++++++++++++++ tests/cli/typo-guard.test.ts | 1 + 35 files changed, 383 insertions(+), 44 deletions(-) create mode 100644 src/cli/install-statusline.ts create mode 100644 tests/cli/install-statusline.test.ts diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index d67587a..0f995e2 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -6,14 +6,14 @@ }, "metadata": { "description": "Token Pilot \u2014 save 60-90% tokens when AI reads code", - "version": "0.41.1" + "version": "0.42.0" }, "plugins": [ { "name": "token-pilot", "source": "./", "description": "Reduces token consumption by 60-90% via AST-aware lazy file reading, structural symbol navigation, and cross-session tool-usage analytics. 22 MCP tools + 19 subagents + budget watchdog hooks.", - "version": "0.41.1", + "version": "0.42.0", "author": { "name": "Digital-Threads" }, diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 6390510..4f69d98 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "token-pilot", - "version": "0.41.1", + "version": "0.42.0", "description": "Saves 60-90% tokens on AI code reading. AST-aware lazy reads, symbol navigation, find_usages, structural git diff/log, edit-safety guard, Task-routing matcher, cross-session telemetry (errors + diagnostics), 25 tp-* subagents tiered to haiku/sonnet/opus with budget watchdog.", "author": { "name": "Digital-Threads", diff --git a/agents/tp-api-surface-tracker.md b/agents/tp-api-surface-tracker.md index 6501543..4022a7d 100644 --- a/agents/tp-api-surface-tracker.md +++ b/agents/tp-api-surface-tracker.md @@ -9,7 +9,7 @@ tools: - mcp__token-pilot__read_symbol - Bash model: haiku -token_pilot_version: "0.41.1" +token_pilot_version: "0.42.0" token_pilot_body_hash: dd184501203fa7f3c73f419c4ffbe33c4be75400cb64a7a51733a3fe23f6e085 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-audit-scanner.md b/agents/tp-audit-scanner.md index c9ae74c..7ee64a9 100644 --- a/agents/tp-audit-scanner.md +++ b/agents/tp-audit-scanner.md @@ -11,7 +11,7 @@ tools: - Grep - Read model: sonnet -token_pilot_version: "0.41.1" +token_pilot_version: "0.42.0" token_pilot_body_hash: d172f600bf32277ea6eb4cbbee4542ddd698a986dcd96997d33930561964569b requiredMcpServers: - "token-pilot" diff --git a/agents/tp-commit-writer.md b/agents/tp-commit-writer.md index f6f4b7d..5ff26ff 100644 --- a/agents/tp-commit-writer.md +++ b/agents/tp-commit-writer.md @@ -8,7 +8,7 @@ tools: - mcp__token-pilot__test_summary - mcp__token-pilot__outline - Bash -token_pilot_version: "0.41.1" +token_pilot_version: "0.42.0" token_pilot_body_hash: de64a406b5176de19f7422619c7de7949b1f28865f225402c9cea9255f377428 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-context-engineer.md b/agents/tp-context-engineer.md index 723e4eb..06076a6 100644 --- a/agents/tp-context-engineer.md +++ b/agents/tp-context-engineer.md @@ -13,7 +13,7 @@ tools: - Edit - Glob model: sonnet -token_pilot_version: "0.41.1" +token_pilot_version: "0.42.0" token_pilot_body_hash: 68b32af2dacd82ebe52c4eec93edb903d452688274c3065218270627c564d8b0 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-dead-code-finder.md b/agents/tp-dead-code-finder.md index b58f033..2e2a8f2 100644 --- a/agents/tp-dead-code-finder.md +++ b/agents/tp-dead-code-finder.md @@ -11,7 +11,7 @@ tools: - Grep - Read model: sonnet -token_pilot_version: "0.41.1" +token_pilot_version: "0.42.0" token_pilot_body_hash: d9b7f5b7ae6f4ae21305c775361bcab097cc774370a6d976c093571d46d55021 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-debugger.md b/agents/tp-debugger.md index 9572f64..b3a501e 100644 --- a/agents/tp-debugger.md +++ b/agents/tp-debugger.md @@ -12,7 +12,7 @@ tools: - Read - Bash model: sonnet -token_pilot_version: "0.41.1" +token_pilot_version: "0.42.0" token_pilot_body_hash: 052413de8d92377edcde6ae5c823f5378db304baccfa29e8866467f42553a500 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-dep-health.md b/agents/tp-dep-health.md index be0ff2a..1d1d42e 100644 --- a/agents/tp-dep-health.md +++ b/agents/tp-dep-health.md @@ -9,7 +9,7 @@ tools: - Bash - Read model: haiku -token_pilot_version: "0.41.1" +token_pilot_version: "0.42.0" token_pilot_body_hash: e14dc57493d816f8c2e017963e2ef5f66bea50fd0b805a80e8a0d97c968427e7 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-doc-writer.md b/agents/tp-doc-writer.md index afa6586..927692a 100644 --- a/agents/tp-doc-writer.md +++ b/agents/tp-doc-writer.md @@ -13,7 +13,7 @@ tools: - Edit - Glob model: haiku -token_pilot_version: "0.41.1" +token_pilot_version: "0.42.0" token_pilot_body_hash: 57d741794ab40e31a7ac49c68ea39a9088f5827cdef866ce81bfca1b7c9180cf requiredMcpServers: - "token-pilot" diff --git a/agents/tp-history-explorer.md b/agents/tp-history-explorer.md index 6b76779..760fc91 100644 --- a/agents/tp-history-explorer.md +++ b/agents/tp-history-explorer.md @@ -10,7 +10,7 @@ tools: - Bash - Read model: haiku -token_pilot_version: "0.41.1" +token_pilot_version: "0.42.0" token_pilot_body_hash: 7b70fa76a60e3c58a1de4f56c32c0f166424137e203a0cf1c8654e7c9235d904 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-impact-analyzer.md b/agents/tp-impact-analyzer.md index a2f155a..5ff4412 100644 --- a/agents/tp-impact-analyzer.md +++ b/agents/tp-impact-analyzer.md @@ -12,7 +12,7 @@ tools: - mcp__token-pilot__read_symbols - Read model: sonnet -token_pilot_version: "0.41.1" +token_pilot_version: "0.42.0" token_pilot_body_hash: 351a987e11eba63852f5431a16d8eb53104f4f689f82fdcc5a2bf4db948ba92f requiredMcpServers: - "token-pilot" diff --git a/agents/tp-incident-timeline.md b/agents/tp-incident-timeline.md index 1b4c89d..719b7c0 100644 --- a/agents/tp-incident-timeline.md +++ b/agents/tp-incident-timeline.md @@ -8,7 +8,7 @@ tools: - mcp__token-pilot__read_symbol - Bash model: inherit -token_pilot_version: "0.41.1" +token_pilot_version: "0.42.0" token_pilot_body_hash: de5722bfea374eaab096c1ae635c37879e7a91370ee3cd0532f4240be03c91eb requiredMcpServers: - "token-pilot" diff --git a/agents/tp-incremental-builder.md b/agents/tp-incremental-builder.md index f2d49eb..35febbf 100644 --- a/agents/tp-incremental-builder.md +++ b/agents/tp-incremental-builder.md @@ -13,7 +13,7 @@ tools: - Edit - Bash model: sonnet -token_pilot_version: "0.41.1" +token_pilot_version: "0.42.0" token_pilot_body_hash: 375a824d0d847bb5453ec594c7a62ad566ee7e4d92717b0473f771f1a0477c60 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-migration-scout.md b/agents/tp-migration-scout.md index beaa716..793cd1a 100644 --- a/agents/tp-migration-scout.md +++ b/agents/tp-migration-scout.md @@ -11,7 +11,7 @@ tools: - Grep - Glob model: sonnet -token_pilot_version: "0.41.1" +token_pilot_version: "0.42.0" token_pilot_body_hash: 0334de1bf99b431b65359637d125cda7c44c6f780eb92c57cc538715b1939536 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-onboard.md b/agents/tp-onboard.md index 6ba2a53..6ec7639 100644 --- a/agents/tp-onboard.md +++ b/agents/tp-onboard.md @@ -10,7 +10,7 @@ tools: - mcp__token-pilot__smart_read - mcp__token-pilot__smart_read_many - mcp__token-pilot__read_section -token_pilot_version: "0.41.1" +token_pilot_version: "0.42.0" token_pilot_body_hash: 832e95633fbc8e9b0c10f3e540a327d4be062fb4b3f17a6cce6be13f414e2927 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-performance-profiler.md b/agents/tp-performance-profiler.md index 7175962..de9a797 100644 --- a/agents/tp-performance-profiler.md +++ b/agents/tp-performance-profiler.md @@ -11,7 +11,7 @@ tools: - Bash - Read model: sonnet -token_pilot_version: "0.41.1" +token_pilot_version: "0.42.0" token_pilot_body_hash: b61f06380d80798fa2e49d37bcba0653495bee04dd6bdbc1feff9a75607b0508 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-pr-reviewer.md b/agents/tp-pr-reviewer.md index 3c55002..487c2a5 100644 --- a/agents/tp-pr-reviewer.md +++ b/agents/tp-pr-reviewer.md @@ -11,7 +11,7 @@ tools: - mcp__token-pilot__read_for_edit - Read model: sonnet -token_pilot_version: "0.41.1" +token_pilot_version: "0.42.0" token_pilot_body_hash: f83f50d05b4f70285ae7afed2b1a406fc436df56e61a0aedbfb31edc7f2b6e66 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-refactor-planner.md b/agents/tp-refactor-planner.md index 39b1f49..b91acb8 100644 --- a/agents/tp-refactor-planner.md +++ b/agents/tp-refactor-planner.md @@ -8,7 +8,7 @@ tools: - mcp__token-pilot__outline - mcp__token-pilot__read_symbol model: sonnet -token_pilot_version: "0.41.1" +token_pilot_version: "0.42.0" token_pilot_body_hash: c5f6fc122c89e16e5cf774045f92169ee3468555320b898171ba13eca5323550 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-review-impact.md b/agents/tp-review-impact.md index ced1930..e6e1335 100644 --- a/agents/tp-review-impact.md +++ b/agents/tp-review-impact.md @@ -9,7 +9,7 @@ tools: - mcp__token-pilot__module_info - Bash model: sonnet -token_pilot_version: "0.41.1" +token_pilot_version: "0.42.0" token_pilot_body_hash: 8ef3c3341cbfed4eb8dd130126a9683edc57e378c92ff0ca764d584fd941c55c requiredMcpServers: - "token-pilot" diff --git a/agents/tp-run.md b/agents/tp-run.md index ea694b6..5ac4065 100644 --- a/agents/tp-run.md +++ b/agents/tp-run.md @@ -16,7 +16,7 @@ tools: - Glob - Bash model: haiku -token_pilot_version: "0.41.1" +token_pilot_version: "0.42.0" token_pilot_body_hash: 2b08618d34a61f00aafccbda9fed6d83243296dedb83440edbd2d5c28bb6dbc4 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-session-restorer.md b/agents/tp-session-restorer.md index 6287fa2..79a140f 100644 --- a/agents/tp-session-restorer.md +++ b/agents/tp-session-restorer.md @@ -9,7 +9,7 @@ tools: - mcp__token-pilot__session_budget - Bash - Read -token_pilot_version: "0.41.1" +token_pilot_version: "0.42.0" token_pilot_body_hash: 529374ed728f5eed5b758b3be3da65624783c0bf0c1a253d7d661a843eb5f767 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-ship-coordinator.md b/agents/tp-ship-coordinator.md index e699ddb..968d19e 100644 --- a/agents/tp-ship-coordinator.md +++ b/agents/tp-ship-coordinator.md @@ -11,7 +11,7 @@ tools: - Read - Grep model: sonnet -token_pilot_version: "0.41.1" +token_pilot_version: "0.42.0" token_pilot_body_hash: a60f6ae110eb3138064bce074e8ba26fa0ce5f4659df1624a9d9d3646803391b requiredMcpServers: - "token-pilot" diff --git a/agents/tp-spec-writer.md b/agents/tp-spec-writer.md index c9b615b..c35b56b 100644 --- a/agents/tp-spec-writer.md +++ b/agents/tp-spec-writer.md @@ -9,7 +9,7 @@ tools: - Read - Write model: sonnet -token_pilot_version: "0.41.1" +token_pilot_version: "0.42.0" token_pilot_body_hash: c7a4e8b39228fd5158528f389c924c5ff2d98c4b9b05ee0106d54a26c5dc1350 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-test-coverage-gapper.md b/agents/tp-test-coverage-gapper.md index 05cea55..c418cbb 100644 --- a/agents/tp-test-coverage-gapper.md +++ b/agents/tp-test-coverage-gapper.md @@ -10,7 +10,7 @@ tools: - mcp__token-pilot__test_summary - Glob - Grep -token_pilot_version: "0.41.1" +token_pilot_version: "0.42.0" token_pilot_body_hash: be81eed53a3720d146cf89e4a14a7a56577633f7c84c234c412ab70d64c05b11 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-test-triage.md b/agents/tp-test-triage.md index 6a446ff..bb23770 100644 --- a/agents/tp-test-triage.md +++ b/agents/tp-test-triage.md @@ -8,7 +8,7 @@ tools: - mcp__token-pilot__find_usages - mcp__token-pilot__read_symbol model: sonnet -token_pilot_version: "0.41.1" +token_pilot_version: "0.42.0" token_pilot_body_hash: 362ecf4cb03b059421ea26933473700900073dc38b3a7fe271208dfb1ae14f90 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-test-writer.md b/agents/tp-test-writer.md index 51e4917..9c79b50 100644 --- a/agents/tp-test-writer.md +++ b/agents/tp-test-writer.md @@ -13,7 +13,7 @@ tools: - Edit - Bash model: sonnet -token_pilot_version: "0.41.1" +token_pilot_version: "0.42.0" token_pilot_body_hash: 269f2fe22ff4517c277d3f56ca67d8a5527b93290ab21079a83ba7af22c1b5a9 requiredMcpServers: - "token-pilot" diff --git a/package.json b/package.json index fa2d185..e609deb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "token-pilot", - "version": "0.41.1", + "version": "0.42.0", "description": "Save up to 80% tokens when AI reads code — MCP server for token-efficient code navigation, AST-aware structural reading instead of dumping full files into context window", "type": "module", "main": "dist/index.js", diff --git a/src/cli/ecosystem-check.ts b/src/cli/ecosystem-check.ts index 821f123..c9f9d75 100644 --- a/src/cli/ecosystem-check.ts +++ b/src/cli/ecosystem-check.ts @@ -299,19 +299,13 @@ export function formatStatuslineHint( case "not-configured": { lines.push( - ` ○ no statusline badge configured — add one to see token-pilot`, + ` ○ no statusline badge configured — see token-pilot's live`, ); - lines.push(` state (enforcement mode + cumulative saved tokens) in`); - lines.push(` Claude Code's status bar.`); - lines.push(""); - const command = pluginRoot - ? `bash "${pluginRoot}/hooks/statusline-chain.sh"` - : `bash "$(ls -t ~/.claude/plugins/cache/token-pilot/token-pilot/*/hooks/statusline-chain.sh 2>/dev/null | head -1)"`; - lines.push(` Add to ${result.configPath}:`); - lines.push(` "statusLine": {`); - lines.push(` "type": "command",`); - lines.push(` "command": "${command.replace(/"/g, '\\"')}"`); - lines.push(` }`); + lines.push( + ` saved-token count + enforcement mode in your status bar.`, + ); + lines.push(` one command (non-destructive — never clobbers an existing one):`); + lines.push(` token-pilot install-statusline`); return lines.join("\n"); } } diff --git a/src/cli/install-statusline.ts b/src/cli/install-statusline.ts new file mode 100644 index 0000000..b753442 --- /dev/null +++ b/src/cli/install-statusline.ts @@ -0,0 +1,187 @@ +/** + * v0.42.0 — `token-pilot install-statusline`. + * + * Convenience installer for the statusline badge. v0.41.1 removed the + * intrusive sessionTitle overwrite and pointed users at the additive + * statusline (hooks/statusline-chain.sh) — the caveman-style channel + * that sits ALONGSIDE the session name and live-updates on every render. + * This command wires it into `~/.claude/settings.json` without making + * the user hand-edit JSON. + * + * Non-destructive by design (the sessionTitle lesson): we NEVER clobber + * a third-party statusLine. Decision per current state: + * + * not-configured → write our chain command + * configured-caveman-only → upgrade to chain (shows BOTH badges) + * configured-tp-only → upgrade to chain (so caveman shows too if present) + * configured-chain → already ideal, no-op + * configured-other → leave alone; print how to switch manually + * unknown → settings.json unreadable; print guidance + * + * `--force` overrides the configured-other guard for users who really + * want to replace a custom statusLine. + */ + +import { readFile, writeFile, mkdir, access } from "node:fs/promises"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import type { StatuslineStatus } from "./ecosystem-check.js"; + +/** The version-agnostic chain command (auto-picks the newest plugin dir). */ +export const CHAIN_COMMAND = + 'bash "$(ls -t ~/.claude/plugins/cache/token-pilot/token-pilot/*/hooks/statusline-chain.sh 2>/dev/null | head -1)"'; + +export interface InstallStatuslineResult { + action: "installed" | "upgraded" | "noop" | "skipped"; + message: string; +} + +/** + * Pure decision: given the current statusline status, what should the + * installer do? Separated from I/O for unit tests. + */ +export function decideStatuslineAction( + status: StatuslineStatus, + force: boolean, +): { write: boolean; result: InstallStatuslineResult } { + switch (status) { + case "not-configured": + return { + write: true, + result: { + action: "installed", + message: + "statusLine configured — the [TP] badge will show in your status bar (restart Claude Code).", + }, + }; + case "configured-caveman-only": + case "configured-tp-only": + return { + write: true, + result: { + action: "upgraded", + message: + "statusLine upgraded to the chain wrapper — both caveman and [TP] badges now render side by side.", + }, + }; + case "configured-chain": + return { + write: false, + result: { + action: "noop", + message: "statusLine already uses the token-pilot chain wrapper. Nothing to do.", + }, + }; + case "configured-other": + if (force) { + return { + write: true, + result: { + action: "installed", + message: + "Replaced your custom statusLine with the token-pilot chain wrapper (--force).", + }, + }; + } + return { + write: false, + result: { + action: "skipped", + message: + "You already have a custom statusLine — left untouched. " + + "To show the [TP] badge too, set statusLine.command to:\n " + + CHAIN_COMMAND + + "\nor re-run with --force to replace it.", + }, + }; + case "unknown": + default: + return { + write: false, + result: { + action: "skipped", + message: + "Could not read ~/.claude/settings.json as JSON — not modifying it. " + + "Add this manually under \"statusLine\":\n " + + CHAIN_COMMAND, + }, + }; + } +} + +/** + * Classify the statusLine state of a settings.json at `settingsPath`. + * Async, path-injectable (so tests point at a tmp file). Mirrors + * ecosystem-check.checkStatusline but works on any path. Never throws. + */ +export async function classifyStatuslineAt( + settingsPath: string, +): Promise { + try { + await access(settingsPath); + } catch { + return "not-configured"; + } + let parsed: unknown; + try { + parsed = JSON.parse(await readFile(settingsPath, "utf-8")); + } catch { + return "unknown"; + } + const cmd = (parsed as { statusLine?: { command?: unknown } } | null) + ?.statusLine?.command; + if (typeof cmd !== "string") return "not-configured"; + if (cmd.includes("statusline-chain.sh")) return "configured-chain"; + if (cmd.includes("tp-statusline.sh")) return "configured-tp-only"; + if (cmd.includes("caveman-statusline.sh")) return "configured-caveman-only"; + return "configured-other"; +} + +/** + * CLI entry. Returns an exit code. + */ +export async function handleInstallStatusline( + argv: string[], + opts?: { settingsPath?: string }, +): Promise { + const force = argv.includes("--force"); + const settingsPath = + opts?.settingsPath ?? join(homedir(), ".claude", "settings.json"); + + const status = await classifyStatuslineAt(settingsPath); + const { write, result } = decideStatuslineAction(status, force); + + if (!write) { + process.stdout.write(`[token-pilot] ${result.message}\n`); + return 0; + } + + // Merge the statusLine field into existing settings (preserve the rest). + let settings: Record = {}; + try { + const raw = await readFile(settingsPath, "utf-8"); + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === "object") { + settings = parsed as Record; + } + } catch { + /* fresh file — start clean (only reached when status was safe) */ + } + + settings.statusLine = { type: "command", command: CHAIN_COMMAND }; + + try { + await mkdir(dirname(settingsPath), { recursive: true }); + await writeFile(settingsPath, JSON.stringify(settings, null, 2) + "\n"); + } catch (err) { + process.stderr.write( + `[token-pilot] failed to write ${settingsPath}: ${ + err instanceof Error ? err.message : String(err) + }\n`, + ); + return 1; + } + + process.stdout.write(`[token-pilot] ${result.message}\n`); + return 0; +} diff --git a/src/cli/typo-guard.ts b/src/cli/typo-guard.ts index 89e5898..7b8193a 100644 --- a/src/cli/typo-guard.ts +++ b/src/cli/typo-guard.ts @@ -39,6 +39,8 @@ export const KNOWN_COMMANDS = [ "hook-bootstrap", // v0.40.0 — canonical subagent-completion capture "hook-subagent-stop", + // v0.42.0 — one-command statusline badge installer + "install-statusline", "install-hook", "uninstall-hook", "install-ast-index", diff --git a/src/index.ts b/src/index.ts index b8f5df6..c663448 100644 --- a/src/index.ts +++ b/src/index.ts @@ -501,6 +501,18 @@ export async function main(cliArgs = process.argv.slice(2)): Promise { process.exit(code); return; } + case "install-statusline": { + // v0.42.0 — wire the additive statusline badge into + // ~/.claude/settings.json so users don't hand-edit JSON. Never + // clobbers a third-party statusLine (the sessionTitle lesson); + // upgrades caveman-only / tp-only to the chain wrapper. + const { handleInstallStatusline } = await import( + "./cli/install-statusline.js" + ); + const code = await handleInstallStatusline(cliArgs.slice(1)); + process.exit(code); + return; + } case "migrate-hooks": { // v0.33.0 — clean stale npx-cache / pinned-version token-pilot // hook entries from user-level + project-level settings.json so diff --git a/tests/cli/ecosystem-check.test.ts b/tests/cli/ecosystem-check.test.ts index 6c3e899..228df37 100644 --- a/tests/cli/ecosystem-check.test.ts +++ b/tests/cli/ecosystem-check.test.ts @@ -277,11 +277,11 @@ describe("checkStatusline + formatStatuslineHint", () => { }, [], ); + // v0.42.0 — the not-configured nudge now points at the one-command + // installer instead of a hand-edit JSON recipe. expect(hint).not.toBeNull(); expect(hint).toContain("statusline badge"); - expect(hint).toContain("/fake/settings.json"); - expect(hint).toContain("statusline-chain.sh"); - expect(hint).toContain('"type": "command"'); + expect(hint).toContain("token-pilot install-statusline"); }); it("formatStatuslineHint: silent when chain wrapper already active", async () => { diff --git a/tests/cli/install-statusline.test.ts b/tests/cli/install-statusline.test.ts new file mode 100644 index 0000000..48e5bc0 --- /dev/null +++ b/tests/cli/install-statusline.test.ts @@ -0,0 +1,143 @@ +/** + * Tests for v0.42.0 `token-pilot install-statusline`. + * + * decideStatuslineAction is pure; handleInstallStatusline is exercised + * against a tmp settings.json so the real ~/.claude is never touched. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtemp, rm, readFile, writeFile, mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { + decideStatuslineAction, + handleInstallStatusline, + classifyStatuslineAt, + CHAIN_COMMAND, +} from "../../src/cli/install-statusline.ts"; + +describe("decideStatuslineAction", () => { + it("installs when not configured", () => { + const d = decideStatuslineAction("not-configured", false); + expect(d.write).toBe(true); + expect(d.result.action).toBe("installed"); + }); + it("upgrades caveman-only / tp-only to the chain", () => { + expect(decideStatuslineAction("configured-caveman-only", false).write).toBe( + true, + ); + expect(decideStatuslineAction("configured-tp-only", false).result.action).toBe( + "upgraded", + ); + }); + it("no-ops when already chain", () => { + const d = decideStatuslineAction("configured-chain", false); + expect(d.write).toBe(false); + expect(d.result.action).toBe("noop"); + }); + it("leaves a third-party statusLine alone without --force", () => { + const d = decideStatuslineAction("configured-other", false); + expect(d.write).toBe(false); + expect(d.result.action).toBe("skipped"); + expect(d.result.message).toContain("--force"); + }); + it("replaces a third-party statusLine with --force", () => { + const d = decideStatuslineAction("configured-other", true); + expect(d.write).toBe(true); + }); + it("does not write on unknown (unparseable settings)", () => { + expect(decideStatuslineAction("unknown", false).write).toBe(false); + }); +}); + +describe("classifyStatuslineAt", () => { + let dir: string; + let p: string; + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "tp-sl-")); + p = join(dir, "settings.json"); + }); + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it("not-configured for missing file", async () => { + expect(await classifyStatuslineAt(p)).toBe("not-configured"); + }); + it("not-configured when no statusLine key", async () => { + await writeFile(p, JSON.stringify({ env: {} })); + expect(await classifyStatuslineAt(p)).toBe("not-configured"); + }); + it("configured-chain for the chain wrapper", async () => { + await writeFile( + p, + JSON.stringify({ statusLine: { command: "bash x/statusline-chain.sh" } }), + ); + expect(await classifyStatuslineAt(p)).toBe("configured-chain"); + }); + it("configured-other for a custom command", async () => { + await writeFile( + p, + JSON.stringify({ statusLine: { command: "my-own-badge.sh" } }), + ); + expect(await classifyStatuslineAt(p)).toBe("configured-other"); + }); + it("unknown for invalid JSON", async () => { + await writeFile(p, "{ not json"); + expect(await classifyStatuslineAt(p)).toBe("unknown"); + }); +}); + +describe("handleInstallStatusline (tmp settings)", () => { + let dir: string; + let p: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let outSpy: any; + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "tp-sl-h-")); + p = join(dir, "settings.json"); + outSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + }); + afterEach(async () => { + outSpy.mockRestore(); + await rm(dir, { recursive: true, force: true }); + }); + + it("writes the chain command into a fresh settings file", async () => { + const code = await handleInstallStatusline([], { settingsPath: p }); + expect(code).toBe(0); + const saved = JSON.parse(await readFile(p, "utf-8")); + expect(saved.statusLine.command).toBe(CHAIN_COMMAND); + expect(saved.statusLine.type).toBe("command"); + }); + + it("preserves existing settings keys when writing", async () => { + await mkdir(dir, { recursive: true }); + await writeFile(p, JSON.stringify({ env: { FOO: "1" }, model: "opus" })); + await handleInstallStatusline([], { settingsPath: p }); + const saved = JSON.parse(await readFile(p, "utf-8")); + expect(saved.env.FOO).toBe("1"); + expect(saved.model).toBe("opus"); + expect(saved.statusLine.command).toBe(CHAIN_COMMAND); + }); + + it("does NOT clobber a third-party statusLine without --force", async () => { + await writeFile( + p, + JSON.stringify({ statusLine: { type: "command", command: "custom.sh" } }), + ); + const code = await handleInstallStatusline([], { settingsPath: p }); + expect(code).toBe(0); + const saved = JSON.parse(await readFile(p, "utf-8")); + expect(saved.statusLine.command).toBe("custom.sh"); // untouched + }); + + it("replaces a third-party statusLine with --force", async () => { + await writeFile( + p, + JSON.stringify({ statusLine: { type: "command", command: "custom.sh" } }), + ); + await handleInstallStatusline(["--force"], { settingsPath: p }); + const saved = JSON.parse(await readFile(p, "utf-8")); + expect(saved.statusLine.command).toBe(CHAIN_COMMAND); + }); +}); diff --git a/tests/cli/typo-guard.test.ts b/tests/cli/typo-guard.test.ts index 9daa075..35eb3d9 100644 --- a/tests/cli/typo-guard.test.ts +++ b/tests/cli/typo-guard.test.ts @@ -115,6 +115,7 @@ describe("checkForTypo", () => { "init", "doctor", "migrate-hooks", + "install-statusline", "errors", "workflow", ])(