Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
125 changes: 113 additions & 12 deletions src/__tests__/tokscale.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,28 @@ function mockDetectSequence(versionStdout: string) {
})
}

/**
* Route execFile calls by command line. `available` lists commands that
* `which` can resolve; `versions` maps "<cmd> <args...>" of a --version probe
* to its stdout, or to an Error when the probe fails.
*/
function mockRunners(available: string[], versions: Record<string, string | Error>) {
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<typeof execFile>
})
}

beforeEach(() => {
vi.clearAllMocks()
resetDetectionCache()
Expand Down Expand Up @@ -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<typeof execFile>
})
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)
Expand Down
66 changes: 52 additions & 14 deletions src/tokscale.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -37,25 +53,45 @@ export function versionAtLeast(version: SemVer, target: SemVer): boolean {
return true
}

export function detectTokscale(): Promise<boolean> {
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<boolean> {
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 },
Expand All @@ -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) {
Expand Down
Loading