feat(cli): add balance command

This commit is contained in:
Thomas Brugman
2026-05-15 18:51:10 +02:00
parent a793e59542
commit ac7e46d67a
6 changed files with 118 additions and 2 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": minor
---
Add a `kilo balance` command for checking the active Kilo account or team balance.
+2
View File
@@ -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)
@@ -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))
},
})
@@ -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,
@@ -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,
})
})
})
+4 -2
View File
@@ -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}`)
}
})