From 8bb13e1c6a03432f28ec1e437f5e0aaf4fb9acff Mon Sep 17 00:00:00 2001 From: stevejkang Date: Mon, 29 Jun 2026 09:46:09 +0900 Subject: [PATCH 1/2] Fix client filter flag for tokscale v4 compatibility tokscale v4.0.0 removed deprecated per-client boolean flags (--opencode, --claude, etc.) in favor of the unified -c/--client flag (PR #465). Since this plugin defaults showOpenCodeOnly to true, every CLI call included --opencode, which v4+ rejects as an unexpected argument. All three period stats (today, week, month) failed silently and rendered "err" in the sidebar. Replace --opencode with -c opencode and update the corresponding test assertion. --- src/__tests__/tokscale.test.ts | 2 +- src/tokscale.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/__tests__/tokscale.test.ts b/src/__tests__/tokscale.test.ts index 2ae506b..20e4f81 100644 --- a/src/__tests__/tokscale.test.ts +++ b/src/__tests__/tokscale.test.ts @@ -88,7 +88,7 @@ describe("fetchPeriodStats", () => { await fetchPeriodStats("today") expect(mockExecFile).toHaveBeenCalledWith( "tokscale", - ["models", "--json", "--today", "--no-spinner", "--opencode"], + ["models", "--json", "--today", "--no-spinner", "-c", "opencode"], expect.objectContaining({ timeout: 15000, maxBuffer: 1024 * 1024 }), expect.any(Function), ) diff --git a/src/tokscale.ts b/src/tokscale.ts index 4f3b47a..e730219 100644 --- a/src/tokscale.ts +++ b/src/tokscale.ts @@ -24,7 +24,7 @@ export function fetchPeriodStats( options?: { openCodeOnly?: boolean }, ): Promise { const args = ["models", "--json", PERIOD_FLAGS[period], "--no-spinner"] - if (options?.openCodeOnly !== false) args.push("--opencode") + if (options?.openCodeOnly !== false) args.push("-c", "opencode") return new Promise((resolve, reject) => { execFile( From 0ab4bfd75825f0bb6d19498b6d85c8dac3daa6bf Mon Sep 17 00:00:00 2001 From: stevejkang Date: Mon, 29 Jun 2026 09:53:48 +0900 Subject: [PATCH 2/2] Add version detection for backward-compatible client filtering tokscale v4.0.0 replaced per-client boolean flags (--opencode) with the unified -c/--client flag. Since opencode auto-updates this plugin faster than users update the tokscale binary, both flag styles need to work. detectTokscale() now runs tokscale --version after the install check and caches the parsed semver. fetchPeriodStats() uses -c opencode on v4.0.0+ and falls back to --opencode on older versions or when the version is unknown. parseVersion() and versionAtLeast() are exported as general utilities for any future version-gated behavior. --- src/__tests__/tokscale.test.ts | 154 +++++++++++++++++++++++++++++++-- src/tokscale.ts | 43 ++++++++- 2 files changed, 188 insertions(+), 9 deletions(-) diff --git a/src/__tests__/tokscale.test.ts b/src/__tests__/tokscale.test.ts index 20e4f81..d039d70 100644 --- a/src/__tests__/tokscale.test.ts +++ b/src/__tests__/tokscale.test.ts @@ -20,7 +20,16 @@ vi.mock("child_process", () => ({ })) import { execFile } from "child_process" -import { detectTokscale, fetchPeriodStats, parseModelReport, reportToStats, resetDetectionCache } from "../tokscale" +import { + detectTokscale, + fetchPeriodStats, + getVersion, + parseModelReport, + parseVersion, + versionAtLeast, + reportToStats, + resetDetectionCache, +} from "../tokscale" const mockExecFile = vi.mocked(execFile) @@ -40,22 +49,104 @@ function mockExecFileError(error: Error & { code?: number }) { }) } +/** + * Mock detectTokscale's two-step sequence: + * 1st call: `which tokscale` → success + * 2nd call: `tokscale --version` → returns versionStdout + */ +function mockDetectSequence(versionStdout: string) { + let callCount = 0 + mockExecFile.mockImplementation((_cmd, _args, _opts, cb) => { + callCount++ + const callback = typeof _opts === "function" ? _opts : cb + if (callCount === 1) { + // which tokscale → success + ;(callback as Function)(null, "/usr/local/bin/tokscale", "") + } else if (callCount === 2) { + // tokscale --version → return version + ;(callback as Function)(null, versionStdout, "") + } + return {} as ReturnType + }) +} + beforeEach(() => { vi.clearAllMocks() resetDetectionCache() }) +describe("parseVersion", () => { + it("parses 'tokscale 4.0.5' → [4, 0, 5]", () => { + expect(parseVersion("tokscale 4.0.5")).toEqual([4, 0, 5]) + }) + + it("parses '3.1.3' → [3, 1, 3]", () => { + expect(parseVersion("3.1.3")).toEqual([3, 1, 3]) + }) + + it("parses '4.0.0\\n' with trailing newline → [4, 0, 0]", () => { + expect(parseVersion("4.0.0\n")).toEqual([4, 0, 0]) + }) + + it("returns null for empty string", () => { + expect(parseVersion("")).toBeNull() + }) + + it("returns null for garbage", () => { + expect(parseVersion("not a version")).toBeNull() + }) +}) + +describe("versionAtLeast", () => { + it("returns true when equal", () => { + expect(versionAtLeast([4, 0, 0], [4, 0, 0])).toBe(true) + }) + + it("returns true when major is greater", () => { + expect(versionAtLeast([5, 0, 0], [4, 0, 0])).toBe(true) + }) + + it("returns true when minor is greater", () => { + expect(versionAtLeast([4, 1, 0], [4, 0, 0])).toBe(true) + }) + + it("returns true when patch is greater", () => { + expect(versionAtLeast([4, 0, 5], [4, 0, 0])).toBe(true) + }) + + it("returns false when below", () => { + expect(versionAtLeast([3, 1, 3], [4, 0, 0])).toBe(false) + }) +}) + describe("detectTokscale", () => { - it("returns true when which tokscale succeeds", async () => { - mockExecFileSuccess("/usr/local/bin/tokscale") + it("returns true and caches major version when tokscale is found", async () => { + mockDetectSequence("tokscale 4.0.5") const result = await detectTokscale() expect(result).toBe(true) - expect(mockExecFile).toHaveBeenCalledWith( + expect(getVersion()).toEqual([4, 0, 5]) + expect(mockExecFile).toHaveBeenCalledTimes(2) + expect(mockExecFile).toHaveBeenNthCalledWith( + 1, "which", ["tokscale"], expect.objectContaining({ timeout: 5000 }), expect.any(Function), ) + expect(mockExecFile).toHaveBeenNthCalledWith( + 2, + "tokscale", + ["--version"], + expect.objectContaining({ timeout: 5000 }), + expect.any(Function), + ) + }) + + it("returns true with v3 version cached", async () => { + mockDetectSequence("tokscale 3.1.3") + const result = await detectTokscale() + expect(result).toBe(true) + expect(getVersion()).toEqual([3, 1, 3]) }) it("returns false when which tokscale fails with exit code 1", async () => { @@ -63,6 +154,7 @@ describe("detectTokscale", () => { mockExecFileError(error) const result = await detectTokscale() expect(result).toBe(false) + expect(getVersion()).toBeNull() }) it("returns false when which tokscale times out", async () => { @@ -71,6 +163,23 @@ describe("detectTokscale", () => { const result = await 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 + }) + const result = await detectTokscale() + expect(result).toBe(true) + expect(getVersion()).toBeNull() + }) }) describe("fetchPeriodStats", () => { @@ -83,7 +192,12 @@ describe("fetchPeriodStats", () => { expect(stats.fetchedAt).toBeTypeOf("number") }) - it("passes correct CLI args for today with openCodeOnly=true (default)", async () => { + it("uses -c opencode on v4+", async () => { + // Prime the version cache with v4 + mockDetectSequence("tokscale 4.0.5") + await detectTokscale() + vi.clearAllMocks() + mockExecFileSuccess(validReportJson) await fetchPeriodStats("today") expect(mockExecFile).toHaveBeenCalledWith( @@ -94,7 +208,35 @@ describe("fetchPeriodStats", () => { ) }) - it("passes CLI args without --opencode when openCodeOnly=false", async () => { + it("uses --opencode on v3", async () => { + // Prime the version cache with v3 + mockDetectSequence("tokscale 3.1.3") + await detectTokscale() + vi.clearAllMocks() + + mockExecFileSuccess(validReportJson) + await fetchPeriodStats("today") + expect(mockExecFile).toHaveBeenCalledWith( + "tokscale", + ["models", "--json", "--today", "--no-spinner", "--opencode"], + expect.objectContaining({ timeout: 15000, maxBuffer: 1024 * 1024 }), + expect.any(Function), + ) + }) + + it("falls back to --opencode when version is unknown", async () => { + // No detectTokscale() called → version is null + mockExecFileSuccess(validReportJson) + await fetchPeriodStats("today") + expect(mockExecFile).toHaveBeenCalledWith( + "tokscale", + ["models", "--json", "--today", "--no-spinner", "--opencode"], + expect.objectContaining({ timeout: 15000 }), + expect.any(Function), + ) + }) + + it("passes CLI args without client filter when openCodeOnly=false", async () => { mockExecFileSuccess(validReportJson) await fetchPeriodStats("today", { openCodeOnly: false }) expect(mockExecFile).toHaveBeenCalledWith( diff --git a/src/tokscale.ts b/src/tokscale.ts index e730219..36d606e 100644 --- a/src/tokscale.ts +++ b/src/tokscale.ts @@ -2,10 +2,32 @@ import { execFile } from "child_process" import type { ModelReportJson, TimePeriod, PeriodStats } from "./types" import { PERIOD_FLAGS } from "./types" +type SemVer = readonly [number, number, number] + let cachedDetection: boolean | null = null +let cachedVersion: SemVer | null = null export function resetDetectionCache(): void { cachedDetection = null + cachedVersion = null +} + +export function getVersion(): SemVer | null { + return cachedVersion +} + +export function parseVersion(versionOutput: string): SemVer | null { + const match = versionOutput.trim().match(/(\d+)\.(\d+)\.(\d+)/) + if (!match) return null + return [parseInt(match[1], 10), parseInt(match[2], 10), parseInt(match[3], 10)] +} + +export function versionAtLeast(version: SemVer, target: SemVer): boolean { + for (let i = 0; i < 3; i++) { + if (version[i] > target[i]) return true + if (version[i] < target[i]) return false + } + return true } export function detectTokscale(): Promise { @@ -13,8 +35,16 @@ export function detectTokscale(): Promise { return new Promise((resolve) => { execFile("which", ["tokscale"], { timeout: 5000 }, (error) => { - cachedDetection = !error - resolve(cachedDetection) + if (error) { + cachedDetection = false + resolve(false) + return + } + cachedDetection = true + execFile("tokscale", ["--version"], { timeout: 5000 }, (_err, stdout) => { + cachedVersion = parseVersion(String(stdout ?? "")) + resolve(true) + }) }) }) } @@ -24,7 +54,14 @@ export function fetchPeriodStats( options?: { openCodeOnly?: boolean }, ): Promise { const args = ["models", "--json", PERIOD_FLAGS[period], "--no-spinner"] - if (options?.openCodeOnly !== false) args.push("-c", "opencode") + if (options?.openCodeOnly !== false) { + const version = getVersion() + if (version && versionAtLeast(version, [4, 0, 0])) { + args.push("-c", "opencode") + } else { + args.push("--opencode") + } + } return new Promise((resolve, reject) => { execFile(