From 8b090df7eabfcfe3cc13678cdd97a715e95b59d0 Mon Sep 17 00:00:00 2001 From: Recoup Agent Date: Sat, 21 Mar 2026 15:04:53 +0000 Subject: [PATCH 1/2] feat: add accounts and keys commands to CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `recoup accounts create --email/--wallet` — creates or retrieves account via POST /api/accounts - Add `recoup accounts upgrade` — prints the Pro upgrade URL - Add `recoup keys list` — lists API keys via GET /api/keys - Add `recoup keys create --name` — creates an API key via POST /api/keys - Add `recoup keys delete --id` — deletes an API key via DELETE /api/keys - Add `del()` to client.ts for DELETE requests with body - 18 new tests, all passing (92 total) Co-Authored-By: Claude Sonnet 4.6 --- __tests__/commands/accounts.test.ts | 105 ++++++++++++++++++ __tests__/commands/keys.test.ts | 161 ++++++++++++++++++++++++++++ src/bin.ts | 4 + src/client.ts | 25 +++++ src/commands/accounts.ts | 43 ++++++++ src/commands/keys.ts | 78 ++++++++++++++ 6 files changed, 416 insertions(+) create mode 100644 __tests__/commands/accounts.test.ts create mode 100644 __tests__/commands/keys.test.ts create mode 100644 src/commands/accounts.ts create mode 100644 src/commands/keys.ts diff --git a/__tests__/commands/accounts.test.ts b/__tests__/commands/accounts.test.ts new file mode 100644 index 0000000..2d2f0ec --- /dev/null +++ b/__tests__/commands/accounts.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +vi.mock("../../src/client.js", () => ({ + get: vi.fn(), + post: vi.fn(), + del: vi.fn(), +})); + +import { accountsCommand } from "../../src/commands/accounts.js"; +import { post } from "../../src/client.js"; + +let logSpy: ReturnType; +let errorSpy: ReturnType; +let exitSpy: ReturnType; + +beforeEach(() => { + logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + exitSpy = vi + .spyOn(process, "exit") + .mockImplementation(() => undefined as never); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("accounts create", () => { + it("creates account with email and prints account_id", async () => { + vi.mocked(post).mockResolvedValue({ + data: { account_id: "acc-123", email: "test@example.com" }, + }); + + await accountsCommand.parseAsync( + ["create", "--email", "test@example.com"], + { from: "user" }, + ); + + expect(post).toHaveBeenCalledWith("/api/accounts", { + email: "test@example.com", + }); + expect(logSpy).toHaveBeenCalledWith("acc-123"); + }); + + it("creates account with wallet and prints account_id", async () => { + vi.mocked(post).mockResolvedValue({ + data: { account_id: "acc-456", wallet: "0xabc123" }, + }); + + await accountsCommand.parseAsync( + ["create", "--wallet", "0xabc123"], + { from: "user" }, + ); + + expect(post).toHaveBeenCalledWith("/api/accounts", { + wallet: "0xabc123", + }); + expect(logSpy).toHaveBeenCalledWith("acc-456"); + }); + + it("prints JSON with --json flag", async () => { + const response = { + data: { account_id: "acc-123", email: "test@example.com" }, + }; + vi.mocked(post).mockResolvedValue(response); + + await accountsCommand.parseAsync( + ["create", "--email", "test@example.com", "--json"], + { from: "user" }, + ); + + expect(logSpy).toHaveBeenCalledWith(JSON.stringify(response, null, 2)); + }); + + it("prints error when no email or wallet provided", async () => { + await accountsCommand.parseAsync(["create"], { from: "user" }); + + expect(errorSpy).toHaveBeenCalledWith( + "Error: at least one of --email or --wallet is required", + ); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("prints error on API failure", async () => { + vi.mocked(post).mockRejectedValue(new Error("Request failed")); + + await accountsCommand.parseAsync( + ["create", "--email", "test@example.com"], + { from: "user" }, + ); + + expect(errorSpy).toHaveBeenCalledWith("Error: Request failed"); + expect(exitSpy).toHaveBeenCalledWith(1); + }); +}); + +describe("accounts upgrade", () => { + it("prints the upgrade URL", async () => { + await accountsCommand.parseAsync(["upgrade"], { from: "user" }); + + expect(logSpy).toHaveBeenCalledWith( + "To upgrade to Pro, visit: https://chat.recoupable.com/settings", + ); + }); +}); diff --git a/__tests__/commands/keys.test.ts b/__tests__/commands/keys.test.ts new file mode 100644 index 0000000..7925fb1 --- /dev/null +++ b/__tests__/commands/keys.test.ts @@ -0,0 +1,161 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +vi.mock("../../src/client.js", () => ({ + get: vi.fn(), + post: vi.fn(), + del: vi.fn(), +})); + +import { keysCommand } from "../../src/commands/keys.js"; +import { get, post, del } from "../../src/client.js"; + +let logSpy: ReturnType; +let errorSpy: ReturnType; +let exitSpy: ReturnType; + +beforeEach(() => { + logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + exitSpy = vi + .spyOn(process, "exit") + .mockImplementation(() => undefined as never); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("keys list", () => { + it("prints API keys table", async () => { + vi.mocked(get).mockResolvedValue({ + keys: [ + { id: "key-1", name: "My Key", created_at: "2024-01-01T00:00:00Z", last_used: null }, + { id: "key-2", name: "CI Key", created_at: "2024-02-01T00:00:00Z", last_used: "2024-03-01T00:00:00Z" }, + ], + }); + + await keysCommand.parseAsync(["list"], { from: "user" }); + + expect(get).toHaveBeenCalledWith("/api/keys"); + expect(logSpy).toHaveBeenCalledTimes(4); // header + separator + 2 rows + }); + + it("prints JSON with --json flag", async () => { + const keys = [ + { id: "key-1", name: "My Key", created_at: "2024-01-01T00:00:00Z", last_used: null }, + ]; + vi.mocked(get).mockResolvedValue({ keys }); + + await keysCommand.parseAsync(["list", "--json"], { from: "user" }); + + expect(logSpy).toHaveBeenCalledWith(JSON.stringify(keys, null, 2)); + }); + + it("handles empty key list", async () => { + vi.mocked(get).mockResolvedValue({ keys: [] }); + + await keysCommand.parseAsync(["list"], { from: "user" }); + + expect(logSpy).toHaveBeenCalledWith("No results."); + }); + + it("prints error on failure", async () => { + vi.mocked(get).mockRejectedValue(new Error("Unauthorized")); + + await keysCommand.parseAsync(["list"], { from: "user" }); + + expect(errorSpy).toHaveBeenCalledWith("Error: Unauthorized"); + expect(exitSpy).toHaveBeenCalledWith(1); + }); +}); + +describe("keys create", () => { + it("creates an API key and prints it", async () => { + vi.mocked(post).mockResolvedValue({ key: "recoup_sk_abc123" }); + + await keysCommand.parseAsync(["create", "--name", "My Key"], { + from: "user", + }); + + expect(post).toHaveBeenCalledWith("/api/keys", { key_name: "My Key" }); + expect(logSpy).toHaveBeenCalledWith("recoup_sk_abc123"); + }); + + it("prints JSON with --json flag", async () => { + const response = { key: "recoup_sk_abc123" }; + vi.mocked(post).mockResolvedValue(response); + + await keysCommand.parseAsync(["create", "--name", "My Key", "--json"], { + from: "user", + }); + + expect(logSpy).toHaveBeenCalledWith(JSON.stringify(response, null, 2)); + }); + + it("prints error when --name is missing", async () => { + await keysCommand.parseAsync(["create"], { from: "user" }); + + expect(errorSpy).toHaveBeenCalledWith( + "Error: --name is required", + ); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("prints error on API failure", async () => { + vi.mocked(post).mockRejectedValue(new Error("Unauthorized")); + + await keysCommand.parseAsync(["create", "--name", "My Key"], { + from: "user", + }); + + expect(errorSpy).toHaveBeenCalledWith("Error: Unauthorized"); + expect(exitSpy).toHaveBeenCalledWith(1); + }); +}); + +describe("keys delete", () => { + it("deletes an API key by id and prints confirmation", async () => { + vi.mocked(del).mockResolvedValue({ + status: "success", + message: "API key deleted successfully", + }); + + await keysCommand.parseAsync(["delete", "--id", "key-123"], { + from: "user", + }); + + expect(del).toHaveBeenCalledWith("/api/keys", { id: "key-123" }); + expect(logSpy).toHaveBeenCalledWith("API key deleted successfully"); + }); + + it("prints JSON with --json flag", async () => { + const response = { status: "success", message: "API key deleted successfully" }; + vi.mocked(del).mockResolvedValue(response); + + await keysCommand.parseAsync(["delete", "--id", "key-123", "--json"], { + from: "user", + }); + + expect(logSpy).toHaveBeenCalledWith(JSON.stringify(response, null, 2)); + }); + + it("prints error when --id is missing", async () => { + await keysCommand.parseAsync(["delete"], { from: "user" }); + + expect(errorSpy).toHaveBeenCalledWith( + "Error: --id is required", + ); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("prints error on API failure", async () => { + vi.mocked(del).mockRejectedValue(new Error("API key not found")); + + await keysCommand.parseAsync(["delete", "--id", "key-123"], { + from: "user", + }); + + expect(errorSpy).toHaveBeenCalledWith("Error: API key not found"); + expect(exitSpy).toHaveBeenCalledWith(1); + }); +}); diff --git a/src/bin.ts b/src/bin.ts index 5608963..06c8033 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -10,6 +10,8 @@ import { notificationsCommand } from "./commands/notifications.js"; import { orgsCommand } from "./commands/orgs.js"; import { contentCommand } from "./commands/content.js"; import { tasksCommand } from "./commands/tasks.js"; +import { accountsCommand } from "./commands/accounts.js"; +import { keysCommand } from "./commands/keys.js"; const pkgPath = join(__dirname, "..", "package.json"); const { version } = JSON.parse(readFileSync(pkgPath, "utf-8")); @@ -30,5 +32,7 @@ program.addCommand(sandboxesCommand); program.addCommand(orgsCommand); program.addCommand(tasksCommand); program.addCommand(contentCommand); +program.addCommand(accountsCommand); +program.addCommand(keysCommand); program.parse(); diff --git a/src/client.ts b/src/client.ts index d2d326a..694d8b5 100644 --- a/src/client.ts +++ b/src/client.ts @@ -62,3 +62,28 @@ export async function post( return data; } + +export async function del( + path: string, + body: Record, +): Promise { + const baseUrl = getBaseUrl(); + const url = new URL(path, baseUrl); + + const response = await fetch(url.toString(), { + method: "DELETE", + headers: { + "x-api-key": getApiKey(), + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + + const data: ApiResponse = await response.json(); + + if (!response.ok || data.status === "error") { + throw new Error(data.error || data.message || `Request failed: ${response.status}`); + } + + return data; +} diff --git a/src/commands/accounts.ts b/src/commands/accounts.ts new file mode 100644 index 0000000..09f8b58 --- /dev/null +++ b/src/commands/accounts.ts @@ -0,0 +1,43 @@ +import { Command } from "commander"; +import { post } from "../client.js"; +import { printJson, printError } from "../output.js"; + +const createCommand = new Command("create") + .description("Create a new account or retrieve an existing one by email or wallet") + .option("--email ", "Email address for the account") + .option("--wallet ", "Wallet address for the account") + .option("--json", "Output as JSON") + .action(async (opts) => { + if (!opts.email && !opts.wallet) { + printError("at least one of --email or --wallet is required"); + return; + } + + try { + const body: Record = {}; + if (opts.email) body.email = opts.email; + if (opts.wallet) body.wallet = opts.wallet; + + const data = await post("/api/accounts", body); + + if (opts.json) { + printJson(data); + } else { + const account = data.data as Record | undefined; + console.log(account?.account_id); + } + } catch (err) { + printError((err as Error).message); + } + }); + +const upgradeCommand = new Command("upgrade") + .description("Upgrade your account to Pro") + .action(() => { + console.log("To upgrade to Pro, visit: https://chat.recoupable.com/settings"); + }); + +export const accountsCommand = new Command("accounts") + .description("Manage your account") + .addCommand(createCommand) + .addCommand(upgradeCommand); diff --git a/src/commands/keys.ts b/src/commands/keys.ts new file mode 100644 index 0000000..3e210b2 --- /dev/null +++ b/src/commands/keys.ts @@ -0,0 +1,78 @@ +import { Command } from "commander"; +import { get, post, del } from "../client.js"; +import { printJson, printTable, printError } from "../output.js"; + +const listCommand = new Command("list") + .description("List API keys for the current account") + .option("--json", "Output as JSON") + .action(async (opts) => { + try { + const data = await get("/api/keys"); + const keys = (data.keys as Record[]) || []; + + if (opts.json) { + printJson(keys); + } else { + printTable(keys, [ + { key: "id", label: "ID" }, + { key: "name", label: "NAME" }, + { key: "created_at", label: "CREATED" }, + { key: "last_used", label: "LAST USED" }, + ]); + } + } catch (err) { + printError((err as Error).message); + } + }); + +const createCommand = new Command("create") + .description("Create a new API key") + .option("--name ", "Name for the API key") + .option("--json", "Output as JSON") + .action(async (opts) => { + if (!opts.name) { + printError("--name is required"); + return; + } + + try { + const data = await post("/api/keys", { key_name: opts.name }); + + if (opts.json) { + printJson(data); + } else { + console.log(data.key); + } + } catch (err) { + printError((err as Error).message); + } + }); + +const deleteCommand = new Command("delete") + .description("Delete an API key by ID") + .option("--id ", "ID of the API key to delete") + .option("--json", "Output as JSON") + .action(async (opts) => { + if (!opts.id) { + printError("--id is required"); + return; + } + + try { + const data = await del("/api/keys", { id: opts.id }); + + if (opts.json) { + printJson(data); + } else { + console.log(data.message); + } + } catch (err) { + printError((err as Error).message); + } + }); + +export const keysCommand = new Command("keys") + .description("Manage API keys") + .addCommand(listCommand) + .addCommand(createCommand) + .addCommand(deleteCommand); From 7581abd38191a5b54a9c63a76dd1e0c8276397b5 Mon Sep 17 00:00:00 2001 From: Recoup Agent Date: Tue, 24 Mar 2026 01:50:26 +0000 Subject: [PATCH 2/2] fix: handle undefined responses in accounts and keys commands - accounts create: print error if account_id missing from API response - keys create: print error if key missing from API response - keys delete: fallback to data.error or JSON.stringify(data) if message is missing - add tests for all three new error cases Co-Authored-By: Claude Sonnet 4.6 --- __tests__/commands/accounts.test.ts | 14 ++++++++++++++ __tests__/commands/keys.test.ts | 11 +++++++++++ src/commands/accounts.ts | 6 +++++- src/commands/keys.ts | 6 +++++- 4 files changed, 35 insertions(+), 2 deletions(-) diff --git a/__tests__/commands/accounts.test.ts b/__tests__/commands/accounts.test.ts index 2d2f0ec..d68da4c 100644 --- a/__tests__/commands/accounts.test.ts +++ b/__tests__/commands/accounts.test.ts @@ -81,6 +81,20 @@ describe("accounts create", () => { expect(exitSpy).toHaveBeenCalledWith(1); }); + it("prints error when account_id is missing from response", async () => { + vi.mocked(post).mockResolvedValue({ data: {} }); + + await accountsCommand.parseAsync( + ["create", "--email", "test@example.com"], + { from: "user" }, + ); + + expect(errorSpy).toHaveBeenCalledWith( + "Error: Account ID not found in API response", + ); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + it("prints error on API failure", async () => { vi.mocked(post).mockRejectedValue(new Error("Request failed")); diff --git a/__tests__/commands/keys.test.ts b/__tests__/commands/keys.test.ts index 7925fb1..857decd 100644 --- a/__tests__/commands/keys.test.ts +++ b/__tests__/commands/keys.test.ts @@ -92,6 +92,17 @@ describe("keys create", () => { expect(logSpy).toHaveBeenCalledWith(JSON.stringify(response, null, 2)); }); + it("prints error when key is missing from response", async () => { + vi.mocked(post).mockResolvedValue({}); + + await keysCommand.parseAsync(["create", "--name", "My Key"], { + from: "user", + }); + + expect(errorSpy).toHaveBeenCalledWith("Error: No key returned from API"); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + it("prints error when --name is missing", async () => { await keysCommand.parseAsync(["create"], { from: "user" }); diff --git a/src/commands/accounts.ts b/src/commands/accounts.ts index 09f8b58..1b01025 100644 --- a/src/commands/accounts.ts +++ b/src/commands/accounts.ts @@ -24,7 +24,11 @@ const createCommand = new Command("create") printJson(data); } else { const account = data.data as Record | undefined; - console.log(account?.account_id); + if (!account || !account.account_id) { + printError("Account ID not found in API response"); + return; + } + console.log(account.account_id); } } catch (err) { printError((err as Error).message); diff --git a/src/commands/keys.ts b/src/commands/keys.ts index 3e210b2..a2b7213 100644 --- a/src/commands/keys.ts +++ b/src/commands/keys.ts @@ -41,6 +41,10 @@ const createCommand = new Command("create") if (opts.json) { printJson(data); } else { + if (!data.key) { + printError("No key returned from API"); + return; + } console.log(data.key); } } catch (err) { @@ -64,7 +68,7 @@ const deleteCommand = new Command("delete") if (opts.json) { printJson(data); } else { - console.log(data.message); + console.log(data.message ?? data.error ?? JSON.stringify(data)); } } catch (err) { printError((err as Error).message);