From 528ee2b34be21cb544933c1193291d2afbf42873 Mon Sep 17 00:00:00 2001 From: Guy Ben Aharon Date: Tue, 17 Mar 2026 20:22:29 +0200 Subject: [PATCH] fix: make apiKey optional and add createAgent method --- src/agents/index.ts | 60 +++++++++++ src/agents/types.ts | 14 +++ src/client.ts | 28 ++++-- src/container/index.ts | 7 +- src/index.ts | 5 + test/agents/client.test.ts | 183 ++++++++++++++++++++++++++++++++++ test/client.test.ts | 68 +++++++++++-- test/container/client.test.ts | 20 ++++ test/index.test.ts | 6 +- 9 files changed, 374 insertions(+), 17 deletions(-) create mode 100644 src/agents/index.ts create mode 100644 src/agents/types.ts create mode 100644 test/agents/client.test.ts diff --git a/src/agents/index.ts b/src/agents/index.ts new file mode 100644 index 0000000..f29a86d --- /dev/null +++ b/src/agents/index.ts @@ -0,0 +1,60 @@ +import { + OneCLIError, + OneCLIRequestError, + toOneCLIError, +} from "../errors.js"; +import type { CreateAgentInput, CreateAgentResponse } from "./types.js"; + +export class AgentsClient { + private baseUrl: string; + private apiKey: string; + private timeout: number; + + constructor(baseUrl: string, apiKey: string, timeout: number) { + this.baseUrl = baseUrl.replace(/\/+$/, ""); + this.apiKey = apiKey; + this.timeout = timeout; + } + + /** + * Create a new agent. + */ + createAgent = async ( + input: CreateAgentInput, + ): Promise => { + const url = `${this.baseUrl}/api/agents`; + + const headers: Record = { + "Content-Type": "application/json", + }; + if (this.apiKey) { + headers["Authorization"] = `Bearer ${this.apiKey}`; + } + + try { + const res = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify(input), + signal: AbortSignal.timeout(this.timeout), + }); + + if (!res.ok) { + throw new OneCLIRequestError( + `OneCLI returned ${res.status} ${res.statusText}`, + { url, statusCode: res.status }, + ); + } + + return (await res.json()) as CreateAgentResponse; + } catch (error) { + if ( + error instanceof OneCLIError || + error instanceof OneCLIRequestError + ) { + throw error; + } + throw toOneCLIError(error); + } + }; +} diff --git a/src/agents/types.ts b/src/agents/types.ts new file mode 100644 index 0000000..24d86ac --- /dev/null +++ b/src/agents/types.ts @@ -0,0 +1,14 @@ +export interface CreateAgentInput { + /** Display name for the agent. */ + name: string; + + /** Unique identifier (lowercase letters, numbers, hyphens, starts with a letter). */ + identifier: string; +} + +export interface CreateAgentResponse { + id: string; + name: string; + identifier: string; + createdAt: string; +} diff --git a/src/client.ts b/src/client.ts index 62fd16c..8749993 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,26 +1,29 @@ -import { OneCLIError } from "./errors.js"; import { ContainerClient } from "./container/index.js"; +import { AgentsClient } from "./agents/index.js"; import type { OneCLIOptions } from "./types.js"; -import type { ApplyContainerConfigOptions, ContainerConfig } from "./container/types.js"; +import type { + ApplyContainerConfigOptions, + ContainerConfig, +} from "./container/types.js"; +import type { + CreateAgentInput, + CreateAgentResponse, +} from "./agents/types.js"; const DEFAULT_URL = "https://app.onecli.sh"; const DEFAULT_TIMEOUT = 5000; export class OneCLI { private containerClient: ContainerClient; + private agentsClient: AgentsClient; constructor(options: OneCLIOptions = {}) { - const apiKey = options.apiKey ?? process.env.ONECLI_API_KEY; - if (!apiKey) { - throw new OneCLIError( - "apiKey is required. Pass it in options or set the ONECLI_API_KEY environment variable.", - ); - } - + const apiKey = options.apiKey ?? process.env.ONECLI_API_KEY ?? ""; const url = options.url ?? process.env.ONECLI_URL ?? DEFAULT_URL; const timeout = options.timeout ?? DEFAULT_TIMEOUT; this.containerClient = new ContainerClient(url, apiKey, timeout); + this.agentsClient = new AgentsClient(url, apiKey, timeout); } /** @@ -40,4 +43,11 @@ export class OneCLI { ): Promise => { return this.containerClient.applyContainerConfig(args, options); }; + + /** + * Create a new agent. + */ + createAgent = (input: CreateAgentInput): Promise => { + return this.agentsClient.createAgent(input); + }; } diff --git a/src/container/index.ts b/src/container/index.ts index 8d195da..80a6aa0 100644 --- a/src/container/index.ts +++ b/src/container/index.ts @@ -20,8 +20,13 @@ export class ContainerClient { const url = `${this.baseUrl}/api/container-config`; try { + const headers: Record = {}; + if (this.apiKey) { + headers["Authorization"] = `Bearer ${this.apiKey}`; + } + const res = await fetch(url, { - headers: { Authorization: `Bearer ${this.apiKey}` }, + headers, signal: AbortSignal.timeout(this.timeout), }); diff --git a/src/index.ts b/src/index.ts index 2ff0376..01219ca 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ export { OneCLI } from "./client.js"; export { ContainerClient } from "./container/index.js"; +export { AgentsClient } from "./agents/index.js"; export { OneCLIError, OneCLIRequestError } from "./errors.js"; export type { OneCLIOptions } from "./types.js"; @@ -7,3 +8,7 @@ export type { ContainerConfig, ApplyContainerConfigOptions, } from "./container/types.js"; +export type { + CreateAgentInput, + CreateAgentResponse, +} from "./agents/types.js"; diff --git a/test/agents/client.test.ts b/test/agents/client.test.ts new file mode 100644 index 0000000..e949055 --- /dev/null +++ b/test/agents/client.test.ts @@ -0,0 +1,183 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { AgentsClient } from "../../src/agents/index.js"; +import { OneCLIError, OneCLIRequestError } from "../../src/errors.js"; + +const MOCK_AGENT = { + id: "clxyz123abc", + name: "My Agent", + identifier: "my-agent", + createdAt: "2025-01-01T00:00:00.000Z", +}; + +describe("AgentsClient", () => { + let fetchSpy: ReturnType; + + afterEach(() => { + fetchSpy?.mockRestore(); + }); + + describe("constructor", () => { + it("strips trailing slashes from baseUrl", () => { + fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify(MOCK_AGENT), { status: 201 }), + ); + + const client = new AgentsClient( + "http://localhost:3000///", + "oc_test", + 5000, + ); + client.createAgent({ name: "Test", identifier: "test" }); + + expect(fetchSpy).toHaveBeenCalledWith( + "http://localhost:3000/api/agents", + expect.any(Object), + ); + }); + }); + + describe("createAgent", () => { + it("sends POST with correct URL, auth header, and body", async () => { + fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify(MOCK_AGENT), { status: 201 }), + ); + + const client = new AgentsClient( + "http://localhost:3000", + "oc_mykey", + 5000, + ); + await client.createAgent({ name: "My Agent", identifier: "my-agent" }); + + expect(fetchSpy).toHaveBeenCalledWith( + "http://localhost:3000/api/agents", + expect.objectContaining({ + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer oc_mykey", + }, + body: JSON.stringify({ name: "My Agent", identifier: "my-agent" }), + }), + ); + }); + + it("omits auth header when apiKey is empty", async () => { + fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify(MOCK_AGENT), { status: 201 }), + ); + + const client = new AgentsClient("http://localhost:3000", "", 5000); + await client.createAgent({ name: "Test", identifier: "test" }); + + expect(fetchSpy).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: { "Content-Type": "application/json" }, + }), + ); + }); + + it("returns parsed response on success", async () => { + fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify(MOCK_AGENT), { status: 201 }), + ); + + const client = new AgentsClient( + "http://localhost:3000", + "oc_test", + 5000, + ); + const agent = await client.createAgent({ + name: "My Agent", + identifier: "my-agent", + }); + + expect(agent).toEqual(MOCK_AGENT); + }); + + it("throws OneCLIRequestError on 401", async () => { + fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + statusText: "Unauthorized", + }), + ); + + const client = new AgentsClient( + "http://localhost:3000", + "oc_bad", + 5000, + ); + + await expect( + client.createAgent({ name: "Test", identifier: "test" }), + ).rejects.toThrow(OneCLIRequestError); + + await expect( + client.createAgent({ name: "Test", identifier: "test" }), + ).rejects.toMatchObject({ + statusCode: 401, + url: "http://localhost:3000/api/agents", + }); + }); + + it("throws OneCLIRequestError on 409 conflict", async () => { + fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response( + JSON.stringify({ error: "identifier already exists" }), + { status: 409, statusText: "Conflict" }, + ), + ); + + const client = new AgentsClient( + "http://localhost:3000", + "oc_test", + 5000, + ); + + const err = await client + .createAgent({ name: "Test", identifier: "test" }) + .catch((e: unknown) => e); + expect(err).toBeInstanceOf(OneCLIRequestError); + expect((err as OneCLIRequestError).statusCode).toBe(409); + }); + + it("wraps network errors into OneCLIError", async () => { + fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockRejectedValue(new TypeError("fetch failed")); + + const client = new AgentsClient( + "http://localhost:3000", + "oc_test", + 5000, + ); + + await expect( + client.createAgent({ name: "Test", identifier: "test" }), + ).rejects.toThrow(OneCLIError); + await expect( + client.createAgent({ name: "Test", identifier: "test" }), + ).rejects.toThrow("fetch failed"); + }); + + it("re-throws OneCLIRequestError without wrapping", async () => { + fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response("", { status: 500, statusText: "Internal Server Error" }), + ); + + const client = new AgentsClient( + "http://localhost:3000", + "oc_test", + 5000, + ); + + const err = await client + .createAgent({ name: "Test", identifier: "test" }) + .catch((e: unknown) => e); + expect(err).toBeInstanceOf(OneCLIRequestError); + expect((err as OneCLIRequestError).name).toBe("OneCLIRequestError"); + }); + }); +}); diff --git a/test/client.test.ts b/test/client.test.ts index efdfe2c..a4714bd 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { OneCLI } from "../src/client.js"; -import { OneCLIError } from "../src/errors.js"; +import { OneCLIRequestError } from "../src/errors.js"; describe("OneCLI", () => { const originalUrl = process.env.ONECLI_URL; @@ -25,13 +25,14 @@ describe("OneCLI", () => { }); describe("constructor", () => { - it("throws OneCLIError when no apiKey is provided and env var is not set", () => { - expect(() => new OneCLI()).toThrow(OneCLIError); - expect(() => new OneCLI()).toThrow("apiKey is required"); + it("creates instance without apiKey (local mode)", () => { + const oc = new OneCLI(); + expect(oc).toBeInstanceOf(OneCLI); }); - it("throws OneCLIError when apiKey is empty string and env var is not set", () => { - expect(() => new OneCLI({ apiKey: "" })).toThrow(OneCLIError); + it("creates instance with empty apiKey", () => { + const oc = new OneCLI({ apiKey: "" }); + expect(oc).toBeInstanceOf(OneCLI); }); it("accepts apiKey from options", () => { @@ -68,6 +69,24 @@ describe("OneCLI", () => { fetchSpy.mockRestore(); }); + it("omits auth header when no apiKey is provided", () => { + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ env: {}, caCertificate: "", caCertificateContainerPath: "" })), + ); + + const oc = new OneCLI({ url: "http://localhost:3000" }); + oc.getContainerConfig(); + + expect(fetchSpy).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: {}, + }), + ); + + fetchSpy.mockRestore(); + }); + it("uses url from options when provided", () => { const oc = new OneCLI({ apiKey: "oc_test", @@ -137,4 +156,41 @@ describe("OneCLI", () => { fetchSpy.mockRestore(); }); }); + + describe("createAgent", () => { + it("delegates to AgentsClient", async () => { + const mockResponse = { + id: "clxyz123", + name: "My Agent", + identifier: "my-agent", + createdAt: "2025-01-01T00:00:00.000Z", + }; + + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify(mockResponse), { status: 201 }), + ); + + const oc = new OneCLI({ apiKey: "oc_test", url: "http://localhost:3000" }); + const agent = await oc.createAgent({ name: "My Agent", identifier: "my-agent" }); + + expect(agent).toEqual(mockResponse); + fetchSpy.mockRestore(); + }); + + it("throws OneCLIRequestError on 409 conflict", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ error: "identifier already exists" }), { + status: 409, + statusText: "Conflict", + }), + ); + + const oc = new OneCLI({ apiKey: "oc_test", url: "http://localhost:3000" }); + + await expect(oc.createAgent({ name: "Test", identifier: "test" })).rejects.toThrow( + OneCLIRequestError, + ); + fetchSpy.mockRestore(); + }); + }); }); diff --git a/test/container/client.test.ts b/test/container/client.test.ts index b7db4fe..bfe8db9 100644 --- a/test/container/client.test.ts +++ b/test/container/client.test.ts @@ -64,6 +64,26 @@ describe("ContainerClient", () => { ); }); + it("omits auth header when apiKey is empty", async () => { + fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify(MOCK_CONFIG)), + ); + + const client = new ContainerClient( + "http://localhost:3000", + "", + 5000, + ); + await client.getContainerConfig(); + + expect(fetchSpy).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: {}, + }), + ); + }); + it("returns parsed ContainerConfig on success", async () => { fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( new Response(JSON.stringify(MOCK_CONFIG)), diff --git a/test/index.test.ts b/test/index.test.ts index 5970cc1..d354057 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { OneCLI, OneCLIError, ContainerClient } from "../src/index.js"; +import { OneCLI, OneCLIError, ContainerClient, AgentsClient } from "../src/index.js"; describe("package exports", () => { it("exports OneCLI class", () => { @@ -13,4 +13,8 @@ describe("package exports", () => { it("exports ContainerClient", () => { expect(ContainerClient).toBeDefined(); }); + + it("exports AgentsClient", () => { + expect(AgentsClient).toBeDefined(); + }); });