diff --git a/README.md b/README.md index 4255b94..2f48ff5 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ https://raw.githubusercontent.com/stevejkang/opencode-tokscale/refs/heads/main/d npm i -g @tokscale/cli ``` -If tokscale isn't found, the plugin shows an install prompt instead of stats. +A global install is recommended but not required. If `tokscale` isn't in PATH (or its `--version` fails), the plugin falls back to `bunx tokscale@latest`, then `npx -y tokscale@latest`. If none of them work, it shows an install prompt instead of stats. ### Setup @@ -83,12 +83,12 @@ Three parallel CLI calls per refresh. tokscale processes in ~175ms thanks to its | | What | Why it matters | |:---:|---|---| | ⏱ | **Auto-refresh** | Configurable interval, default 60 seconds | -| 🛡 | **Graceful fallback** | No tokscale? Shows install instructions instead of crashing | +| 🛡 | **Graceful fallback** | No global tokscale? Runs it via `bunx`/`npx`, or shows install instructions if neither works | ## Requirements - [opencode](https://opencode.ai) with plugin support (`@opencode-ai/plugin` >= 1.4.3) -- [tokscale](https://github.com/junhoyeo/tokscale) CLI installed and in PATH +- [tokscale](https://github.com/junhoyeo/tokscale) CLI in PATH, or `bunx`/`npx` available to run it ## Manual Install diff --git a/src/__tests__/tokscale.test.ts b/src/__tests__/tokscale.test.ts index d092bea..4e561e0 100644 --- a/src/__tests__/tokscale.test.ts +++ b/src/__tests__/tokscale.test.ts @@ -71,6 +71,28 @@ function mockDetectSequence(versionStdout: string) { }) } +/** + * Route execFile calls by command line. `available` lists commands that + * `which` can resolve; `versions` maps " " of a --version probe + * to its stdout, or to an Error when the probe fails. + */ +function mockRunners(available: string[], versions: Record) { + mockExecFile.mockImplementation((cmd, args, _opts, cb) => { + const callback = (typeof _opts === "function" ? _opts : cb) as Function + const argv = (args ?? []) as string[] + if (cmd === "which") { + if (available.includes(argv[0])) callback(null, `/usr/bin/${argv[0]}`, "") + else callback(Object.assign(new Error("not found"), { code: 1 }), "", "") + } else { + const result = versions[[cmd, ...argv].join(" ")] + if (result instanceof Error) callback(result, "", "") + else if (result !== undefined) callback(null, result, "") + else callback(null, validReportJson, "") + } + return {} as ReturnType + }) +} + beforeEach(() => { vi.clearAllMocks() resetDetectionCache() @@ -165,24 +187,103 @@ describe("detectTokscale", () => { expect(result).toBe(false) }) - it("returns true with null version when --version fails", async () => { - let callCount = 0 - mockExecFile.mockImplementation((_cmd, _args, _opts, cb) => { - callCount++ - const callback = typeof _opts === "function" ? _opts : cb - if (callCount === 1) { - ;(callback as Function)(null, "/usr/local/bin/tokscale", "") - } else { - ;(callback as Function)(new Error("version failed"), "", "") - } - return {} as ReturnType - }) + it("returns false when global tokscale --version fails and no bunx/npx exists", async () => { + mockRunners(["tokscale"], { "tokscale --version": new Error("version failed") }) + const result = await detectTokscale() + expect(result).toBe(false) + expect(getVersion()).toBeNull() + }) + + it("accepts a runner with null version when --version succeeds with unparseable output", async () => { + mockRunners(["tokscale"], { "tokscale --version": "dev build" }) const result = await detectTokscale() expect(result).toBe(true) expect(getVersion()).toBeNull() }) }) +describe("runner fallback", () => { + it("falls back to bunx tokscale@latest when global tokscale is missing", async () => { + mockRunners(["bunx", "npx"], { "bunx tokscale@latest --version": "tokscale 4.17.0" }) + expect(await detectTokscale()).toBe(true) + expect(getVersion()).toEqual([4, 17, 0]) + + await fetchPeriodStats("today") + expect(mockExecFile).toHaveBeenLastCalledWith( + "bunx", + ["tokscale@latest", "models", "--json", "--today", "--no-spinner", "-c", "opencode"], + expect.objectContaining({ timeout: 15000 }), + expect.any(Function), + ) + }) + + it("falls back to npx -y tokscale@latest when tokscale and bunx are missing", async () => { + mockRunners(["npx"], { "npx -y tokscale@latest --version": "tokscale 4.17.0" }) + expect(await detectTokscale()).toBe(true) + + await fetchPeriodStats("week") + expect(mockExecFile).toHaveBeenLastCalledWith( + "npx", + ["-y", "tokscale@latest", "models", "--json", "--week", "--no-spinner", "-c", "opencode"], + expect.objectContaining({ timeout: 15000 }), + expect.any(Function), + ) + }) + + it("falls back to bunx when global tokscale exists but --version fails", async () => { + mockRunners(["tokscale", "bunx"], { + "tokscale --version": new Error("broken install"), + "bunx tokscale@latest --version": "tokscale 4.17.0", + }) + expect(await detectTokscale()).toBe(true) + await fetchPeriodStats("today") + expect(mockExecFile).toHaveBeenLastCalledWith( + "bunx", + expect.arrayContaining(["tokscale@latest", "models"]), + expect.any(Object), + expect.any(Function), + ) + }) + + it("falls back to npx when bunx --version fails", async () => { + mockRunners(["bunx", "npx"], { + "bunx tokscale@latest --version": new Error("network"), + "npx -y tokscale@latest --version": "tokscale 4.17.0", + }) + expect(await detectTokscale()).toBe(true) + await fetchPeriodStats("today") + expect(mockExecFile).toHaveBeenLastCalledWith( + "npx", + expect.arrayContaining(["-y", "tokscale@latest", "models"]), + expect.any(Object), + expect.any(Function), + ) + }) + + it("gives bunx/npx probes a longer timeout to allow the first download", async () => { + mockRunners(["npx"], { "npx -y tokscale@latest --version": "tokscale 4.17.0" }) + await detectTokscale() + expect(mockExecFile).toHaveBeenCalledWith( + "npx", + ["-y", "tokscale@latest", "--version"], + expect.objectContaining({ timeout: 60000 }), + expect.any(Function), + ) + }) + + it("prefers global tokscale when it works", async () => { + mockRunners(["tokscale", "bunx", "npx"], { "tokscale --version": "tokscale 2.0.22" }) + expect(await detectTokscale()).toBe(true) + expect(getVersion()).toEqual([2, 0, 22]) + expect(mockExecFile).not.toHaveBeenCalledWith("which", ["bunx"], expect.anything(), expect.anything()) + }) + + it("returns false when no runner is available", async () => { + mockRunners([], {}) + expect(await detectTokscale()).toBe(false) + }) +}) + describe("fetchPeriodStats", () => { it("returns parsed PeriodStats on successful CLI call", async () => { mockExecFileSuccess(validReportJson) diff --git a/src/tokscale.ts b/src/tokscale.ts index 64528b1..c6a3a7b 100644 --- a/src/tokscale.ts +++ b/src/tokscale.ts @@ -11,12 +11,28 @@ export class TokscaleNotFoundError extends Error { } } +interface Runner { + command: string + prefixArgs: readonly string[] + probeTimeout: number +} + +// bunx/npx may download tokscale on first use, so their probes get more time. +// npx gets -y so it never waits for an install confirmation. +const RUNNERS: readonly Runner[] = [ + { command: "tokscale", prefixArgs: [], probeTimeout: 5000 }, + { command: "bunx", prefixArgs: ["tokscale@latest"], probeTimeout: 60000 }, + { command: "npx", prefixArgs: ["-y", "tokscale@latest"], probeTimeout: 60000 }, +] + let cachedDetection: boolean | null = null let cachedVersion: SemVer | null = null +let cachedRunner: Runner | null = null export function resetDetectionCache(): void { cachedDetection = null cachedVersion = null + cachedRunner = null } export function getVersion(): SemVer | null { @@ -37,25 +53,45 @@ export function versionAtLeast(version: SemVer, target: SemVer): boolean { return true } -export function detectTokscale(): Promise { - if (cachedDetection !== null) return Promise.resolve(cachedDetection) - +function probeRunner(runner: Runner): Promise<{ version: SemVer | null } | null> { return new Promise((resolve) => { - execFile("which", ["tokscale"], { timeout: 5000 }, (error) => { - if (error) { - cachedDetection = false - resolve(false) + execFile("which", [runner.command], { timeout: 5000 }, (whichError) => { + if (whichError) { + resolve(null) return } - cachedDetection = true - execFile("tokscale", ["--version"], { timeout: 5000 }, (_err, stdout) => { - cachedVersion = parseVersion(String(stdout ?? "")) - resolve(true) - }) + execFile( + runner.command, + [...runner.prefixArgs, "--version"], + { timeout: runner.probeTimeout }, + (versionError, stdout) => { + if (versionError) { + resolve(null) + return + } + resolve({ version: parseVersion(String(stdout ?? "")) }) + }, + ) }) }) } +export async function detectTokscale(): Promise { + if (cachedDetection !== null) return cachedDetection + + for (const runner of RUNNERS) { + const probe = await probeRunner(runner) + if (probe) { + cachedRunner = runner + cachedVersion = probe.version + cachedDetection = true + return true + } + } + cachedDetection = false + return false +} + export function fetchPeriodStats( period: TimePeriod, options?: { openCodeOnly?: boolean }, @@ -70,10 +106,12 @@ export function fetchPeriodStats( } } + const runner = cachedRunner ?? RUNNERS[0] + return new Promise((resolve, reject) => { execFile( - "tokscale", - args, + runner.command, + [...runner.prefixArgs, ...args], { timeout: 15000, maxBuffer: 1024 * 1024 }, (error, stdout) => { if (error) {