From ac7e46d67a7015469bf2edeb573c284308ea05d5 Mon Sep 17 00:00:00 2001 From: Thomas Brugman Date: Fri, 15 May 2026 18:51:10 +0200 Subject: [PATCH] feat(cli): add balance command --- .changeset/fresh-balance-check.md | 5 ++ packages/opencode/src/index.ts | 2 + .../opencode/src/kilocode/cli/cmd/balance.ts | 60 +++++++++++++++++++ packages/opencode/src/kilocode/commands.ts | 2 + .../test/kilocode/cli/balance.test.ts | 45 ++++++++++++++ packages/opencode/test/kilocode/help.test.ts | 6 +- 6 files changed, 118 insertions(+), 2 deletions(-) create mode 100644 .changeset/fresh-balance-check.md create mode 100644 packages/opencode/src/kilocode/cli/cmd/balance.ts create mode 100644 packages/opencode/test/kilocode/cli/balance.test.ts diff --git a/.changeset/fresh-balance-check.md b/.changeset/fresh-balance-check.md new file mode 100644 index 00000000000..e14f0d98473 --- /dev/null +++ b/.changeset/fresh-balance-check.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": minor +--- + +Add a `kilo balance` command for checking the active Kilo account or team balance. diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 116343fb45b..0eac7ffe976 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -36,6 +36,7 @@ import { PrCommand } from "./cli/cmd/pr" import { SessionCommand } from "./cli/cmd/session" import { RemoteCommand } from "./cli/cmd/remote" // kilocode_change import { RollCallCommand } from "./kilocode/cli/cmd/roll-call" // kilocode_change +import { BalanceCommand } from "./kilocode/cli/cmd/balance" // kilocode_change import { DevSetupCommand, DevAliasCommand } from "./kilocode/cli/dev-setup" // kilocode_change // kilocode_change start - Import telemetry, instance disposal, and legacy migration import { Telemetry } from "@kilocode/kilo-telemetry" @@ -231,6 +232,7 @@ let cli = yargs(args) // kilocode_change // .command(WebCommand) // kilocode_change (Disabled unsupported opencode web UI) .command(ModelsCommand) .command(RollCallCommand) // kilocode_change + .command(BalanceCommand) // kilocode_change .command(StatsCommand) .command(ExportCommand) .command(ImportCommand) diff --git a/packages/opencode/src/kilocode/cli/cmd/balance.ts b/packages/opencode/src/kilocode/cli/cmd/balance.ts new file mode 100644 index 00000000000..83085ad5a1a --- /dev/null +++ b/packages/opencode/src/kilocode/cli/cmd/balance.ts @@ -0,0 +1,60 @@ +import type { Argv } from "yargs" +import { cmd } from "../../../cli/cmd/cmd" +import { UI } from "../../../cli/ui" +import { Auth } from "../../../auth" +import { fetchBalance, fetchProfile, type KilocodeBalance, type KilocodeProfile } from "@kilocode/kilo-gateway" + +interface Info { + email: string + team: string + organizationId: string | null + balance: number +} + +export function payload(input: { + profile: KilocodeProfile + balance: KilocodeBalance | null + organizationId?: string | null +}): Info { + const org = input.profile.organizations?.find((item) => item.id === input.organizationId) + return { + email: input.profile.email, + team: org?.name ?? "Personal", + organizationId: input.organizationId ?? null, + balance: input.balance?.balance ?? 0, + } +} + +export function format(info: Info): string { + return [`Account: ${info.email}`, `Team: ${info.team}`, `Balance: $${info.balance.toFixed(2)}`].join("\n") +} + +export const BalanceCommand = cmd({ + command: "balance", + describe: "show Kilo account balance", + builder: (yargs: Argv) => + yargs.option("json", { + describe: "output balance as JSON", + type: "boolean", + default: false, + }), + handler: async (args) => { + const auth = await Auth.get("kilo") + if (!auth || auth.type !== "oauth") { + UI.error("Not authenticated with Kilo Gateway") + process.exitCode = 1 + return + } + + const org = auth.accountId ?? null + const [profile, balance] = await Promise.all([fetchProfile(auth.access), fetchBalance(auth.access, org ?? undefined)]) + const info = payload({ profile, balance, organizationId: org }) + + if (args.json) { + console.log(JSON.stringify(info, null, 2)) + return + } + + UI.println(format(info)) + }, +}) diff --git a/packages/opencode/src/kilocode/commands.ts b/packages/opencode/src/kilocode/commands.ts index d0ca10ad032..05203cd47c1 100644 --- a/packages/opencode/src/kilocode/commands.ts +++ b/packages/opencode/src/kilocode/commands.ts @@ -25,6 +25,7 @@ import { ConfigCommand as ConfigCLICommand } from "../cli/cmd/config" import { PluginCommand } from "../cli/cmd/plug" import { DevSetupCommand, DevAliasCommand } from "./cli/dev-setup" import { RollCallCommand } from "./cli/cmd/roll-call" +import { BalanceCommand } from "./cli/cmd/balance" import { HelpCommand } from "./help-command" import { InstallationBuildKind } from "@opencode-ai/core/installation/version" @@ -57,6 +58,7 @@ export const commands = [ ServeCommand, ModelsCommand, RollCallCommand, + BalanceCommand, StatsCommand, ExportCommand, ImportCommand, diff --git a/packages/opencode/test/kilocode/cli/balance.test.ts b/packages/opencode/test/kilocode/cli/balance.test.ts new file mode 100644 index 00000000000..e0e0655d817 --- /dev/null +++ b/packages/opencode/test/kilocode/cli/balance.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "bun:test" + +import { format, payload } from "../../../src/kilocode/cli/cmd/balance" + +describe("balance CLI formatting", () => { + test("formats personal balance for human output", () => { + expect( + format({ + email: "one@example.com", + team: "Personal", + organizationId: null, + balance: 12.345, + }), + ).toBe("Account: one@example.com\nTeam: Personal\nBalance: $12.35") + }) + + test("formats team balance for human output", () => { + expect( + format({ + email: "one@example.com", + team: "Team One", + organizationId: "org-1", + balance: 7, + }), + ).toBe("Account: one@example.com\nTeam: Team One\nBalance: $7.00") + }) + + test("creates JSON payload", () => { + expect( + payload({ + profile: { + email: "one@example.com", + organizations: [{ id: "org-1", name: "Team One", role: "admin" }], + }, + balance: { balance: 3.5 }, + organizationId: "org-1", + }), + ).toEqual({ + email: "one@example.com", + team: "Team One", + organizationId: "org-1", + balance: 3.5, + }) + }) +}) diff --git a/packages/opencode/test/kilocode/help.test.ts b/packages/opencode/test/kilocode/help.test.ts index 7e8481db936..833735ce3e5 100644 --- a/packages/opencode/test/kilocode/help.test.ts +++ b/packages/opencode/test/kilocode/help.test.ts @@ -23,6 +23,7 @@ import { ConfigCommand as ConfigCLICommand } from "../../src/cli/cmd/config" import { PluginCommand } from "../../src/cli/cmd/plug" import { DbCommand } from "../../src/cli/cmd/db" import { HelpCommand } from "../../src/kilocode/help-command" +import { BalanceCommand } from "../../src/kilocode/cli/cmd/balance" // Stand-in for TuiThreadCommand — the real one imports @opentui/solid which // doesn't resolve in the test environment. Only command/describe matter here. @@ -70,6 +71,7 @@ const commands = [ DbCommand, ConfigCLICommand, PluginCommand, + BalanceCommand, HelpCommand, CompletionStub, ] as any[] @@ -77,7 +79,7 @@ const commands = [ describe("kilo help --all (markdown)", () => { test("contains ## heading for each known top-level command", async () => { const output = await generateHelp({ all: true, format: "md", commands }) - for (const cmd of ["run", "auth", "debug", "mcp", "session", "agent"]) { + for (const cmd of ["run", "auth", "debug", "mcp", "session", "agent", "balance"]) { expect(output).toContain(`## kilo ${cmd}`) } }) @@ -99,7 +101,7 @@ describe("kilo help --all (text)", () => { test("still contains each command name", async () => { const output = await generateHelp({ all: true, format: "text", commands }) - for (const cmd of ["run", "auth", "debug", "mcp", "session", "agent"]) { + for (const cmd of ["run", "auth", "debug", "mcp", "session", "agent", "balance"]) { expect(output).toContain(`kilo ${cmd}`) } })