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
60 changes: 60 additions & 0 deletions src/agents/index.ts
Original file line number Diff line number Diff line change
@@ -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<CreateAgentResponse> => {
const url = `${this.baseUrl}/api/agents`;

const headers: Record<string, string> = {
"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);
}
};
}
14 changes: 14 additions & 0 deletions src/agents/types.ts
Original file line number Diff line number Diff line change
@@ -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;
}
28 changes: 19 additions & 9 deletions src/client.ts
Original file line number Diff line number Diff line change
@@ -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);
}

/**
Expand All @@ -40,4 +43,11 @@ export class OneCLI {
): Promise<boolean> => {
return this.containerClient.applyContainerConfig(args, options);
};

/**
* Create a new agent.
*/
createAgent = (input: CreateAgentInput): Promise<CreateAgentResponse> => {
return this.agentsClient.createAgent(input);
};
}
7 changes: 6 additions & 1 deletion src/container/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,13 @@ export class ContainerClient {
const url = `${this.baseUrl}/api/container-config`;

try {
const headers: Record<string, string> = {};
if (this.apiKey) {
headers["Authorization"] = `Bearer ${this.apiKey}`;
}

const res = await fetch(url, {
headers: { Authorization: `Bearer ${this.apiKey}` },
headers,
signal: AbortSignal.timeout(this.timeout),
});

Expand Down
5 changes: 5 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
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";
export type {
ContainerConfig,
ApplyContainerConfigOptions,
} from "./container/types.js";
export type {
CreateAgentInput,
CreateAgentResponse,
} from "./agents/types.js";
183 changes: 183 additions & 0 deletions test/agents/client.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.spyOn>;

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");
});
});
});
Loading
Loading