Skip to content

Commit b8a3137

Browse files
authored
fix: make apiKey optional and add createAgent method (#10)
1 parent 3a6dabd commit b8a3137

File tree

9 files changed

+374
-17
lines changed

9 files changed

+374
-17
lines changed

src/agents/index.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import {
2+
OneCLIError,
3+
OneCLIRequestError,
4+
toOneCLIError,
5+
} from "../errors.js";
6+
import type { CreateAgentInput, CreateAgentResponse } from "./types.js";
7+
8+
export class AgentsClient {
9+
private baseUrl: string;
10+
private apiKey: string;
11+
private timeout: number;
12+
13+
constructor(baseUrl: string, apiKey: string, timeout: number) {
14+
this.baseUrl = baseUrl.replace(/\/+$/, "");
15+
this.apiKey = apiKey;
16+
this.timeout = timeout;
17+
}
18+
19+
/**
20+
* Create a new agent.
21+
*/
22+
createAgent = async (
23+
input: CreateAgentInput,
24+
): Promise<CreateAgentResponse> => {
25+
const url = `${this.baseUrl}/api/agents`;
26+
27+
const headers: Record<string, string> = {
28+
"Content-Type": "application/json",
29+
};
30+
if (this.apiKey) {
31+
headers["Authorization"] = `Bearer ${this.apiKey}`;
32+
}
33+
34+
try {
35+
const res = await fetch(url, {
36+
method: "POST",
37+
headers,
38+
body: JSON.stringify(input),
39+
signal: AbortSignal.timeout(this.timeout),
40+
});
41+
42+
if (!res.ok) {
43+
throw new OneCLIRequestError(
44+
`OneCLI returned ${res.status} ${res.statusText}`,
45+
{ url, statusCode: res.status },
46+
);
47+
}
48+
49+
return (await res.json()) as CreateAgentResponse;
50+
} catch (error) {
51+
if (
52+
error instanceof OneCLIError ||
53+
error instanceof OneCLIRequestError
54+
) {
55+
throw error;
56+
}
57+
throw toOneCLIError(error);
58+
}
59+
};
60+
}

src/agents/types.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
export interface CreateAgentInput {
2+
/** Display name for the agent. */
3+
name: string;
4+
5+
/** Unique identifier (lowercase letters, numbers, hyphens, starts with a letter). */
6+
identifier: string;
7+
}
8+
9+
export interface CreateAgentResponse {
10+
id: string;
11+
name: string;
12+
identifier: string;
13+
createdAt: string;
14+
}

src/client.ts

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,29 @@
1-
import { OneCLIError } from "./errors.js";
21
import { ContainerClient } from "./container/index.js";
2+
import { AgentsClient } from "./agents/index.js";
33
import type { OneCLIOptions } from "./types.js";
4-
import type { ApplyContainerConfigOptions, ContainerConfig } from "./container/types.js";
4+
import type {
5+
ApplyContainerConfigOptions,
6+
ContainerConfig,
7+
} from "./container/types.js";
8+
import type {
9+
CreateAgentInput,
10+
CreateAgentResponse,
11+
} from "./agents/types.js";
512

613
const DEFAULT_URL = "https://app.onecli.sh";
714
const DEFAULT_TIMEOUT = 5000;
815

916
export class OneCLI {
1017
private containerClient: ContainerClient;
18+
private agentsClient: AgentsClient;
1119

1220
constructor(options: OneCLIOptions = {}) {
13-
const apiKey = options.apiKey ?? process.env.ONECLI_API_KEY;
14-
if (!apiKey) {
15-
throw new OneCLIError(
16-
"apiKey is required. Pass it in options or set the ONECLI_API_KEY environment variable.",
17-
);
18-
}
19-
21+
const apiKey = options.apiKey ?? process.env.ONECLI_API_KEY ?? "";
2022
const url = options.url ?? process.env.ONECLI_URL ?? DEFAULT_URL;
2123
const timeout = options.timeout ?? DEFAULT_TIMEOUT;
2224

2325
this.containerClient = new ContainerClient(url, apiKey, timeout);
26+
this.agentsClient = new AgentsClient(url, apiKey, timeout);
2427
}
2528

2629
/**
@@ -40,4 +43,11 @@ export class OneCLI {
4043
): Promise<boolean> => {
4144
return this.containerClient.applyContainerConfig(args, options);
4245
};
46+
47+
/**
48+
* Create a new agent.
49+
*/
50+
createAgent = (input: CreateAgentInput): Promise<CreateAgentResponse> => {
51+
return this.agentsClient.createAgent(input);
52+
};
4353
}

src/container/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,13 @@ export class ContainerClient {
2020
const url = `${this.baseUrl}/api/container-config`;
2121

2222
try {
23+
const headers: Record<string, string> = {};
24+
if (this.apiKey) {
25+
headers["Authorization"] = `Bearer ${this.apiKey}`;
26+
}
27+
2328
const res = await fetch(url, {
24-
headers: { Authorization: `Bearer ${this.apiKey}` },
29+
headers,
2530
signal: AbortSignal.timeout(this.timeout),
2631
});
2732

src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
export { OneCLI } from "./client.js";
22
export { ContainerClient } from "./container/index.js";
3+
export { AgentsClient } from "./agents/index.js";
34
export { OneCLIError, OneCLIRequestError } from "./errors.js";
45

56
export type { OneCLIOptions } from "./types.js";
67
export type {
78
ContainerConfig,
89
ApplyContainerConfigOptions,
910
} from "./container/types.js";
11+
export type {
12+
CreateAgentInput,
13+
CreateAgentResponse,
14+
} from "./agents/types.js";

test/agents/client.test.ts

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import { describe, it, expect, vi, afterEach } from "vitest";
2+
import { AgentsClient } from "../../src/agents/index.js";
3+
import { OneCLIError, OneCLIRequestError } from "../../src/errors.js";
4+
5+
const MOCK_AGENT = {
6+
id: "clxyz123abc",
7+
name: "My Agent",
8+
identifier: "my-agent",
9+
createdAt: "2025-01-01T00:00:00.000Z",
10+
};
11+
12+
describe("AgentsClient", () => {
13+
let fetchSpy: ReturnType<typeof vi.spyOn>;
14+
15+
afterEach(() => {
16+
fetchSpy?.mockRestore();
17+
});
18+
19+
describe("constructor", () => {
20+
it("strips trailing slashes from baseUrl", () => {
21+
fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
22+
new Response(JSON.stringify(MOCK_AGENT), { status: 201 }),
23+
);
24+
25+
const client = new AgentsClient(
26+
"http://localhost:3000///",
27+
"oc_test",
28+
5000,
29+
);
30+
client.createAgent({ name: "Test", identifier: "test" });
31+
32+
expect(fetchSpy).toHaveBeenCalledWith(
33+
"http://localhost:3000/api/agents",
34+
expect.any(Object),
35+
);
36+
});
37+
});
38+
39+
describe("createAgent", () => {
40+
it("sends POST with correct URL, auth header, and body", async () => {
41+
fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
42+
new Response(JSON.stringify(MOCK_AGENT), { status: 201 }),
43+
);
44+
45+
const client = new AgentsClient(
46+
"http://localhost:3000",
47+
"oc_mykey",
48+
5000,
49+
);
50+
await client.createAgent({ name: "My Agent", identifier: "my-agent" });
51+
52+
expect(fetchSpy).toHaveBeenCalledWith(
53+
"http://localhost:3000/api/agents",
54+
expect.objectContaining({
55+
method: "POST",
56+
headers: {
57+
"Content-Type": "application/json",
58+
Authorization: "Bearer oc_mykey",
59+
},
60+
body: JSON.stringify({ name: "My Agent", identifier: "my-agent" }),
61+
}),
62+
);
63+
});
64+
65+
it("omits auth header when apiKey is empty", async () => {
66+
fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
67+
new Response(JSON.stringify(MOCK_AGENT), { status: 201 }),
68+
);
69+
70+
const client = new AgentsClient("http://localhost:3000", "", 5000);
71+
await client.createAgent({ name: "Test", identifier: "test" });
72+
73+
expect(fetchSpy).toHaveBeenCalledWith(
74+
expect.any(String),
75+
expect.objectContaining({
76+
headers: { "Content-Type": "application/json" },
77+
}),
78+
);
79+
});
80+
81+
it("returns parsed response on success", async () => {
82+
fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
83+
new Response(JSON.stringify(MOCK_AGENT), { status: 201 }),
84+
);
85+
86+
const client = new AgentsClient(
87+
"http://localhost:3000",
88+
"oc_test",
89+
5000,
90+
);
91+
const agent = await client.createAgent({
92+
name: "My Agent",
93+
identifier: "my-agent",
94+
});
95+
96+
expect(agent).toEqual(MOCK_AGENT);
97+
});
98+
99+
it("throws OneCLIRequestError on 401", async () => {
100+
fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
101+
new Response(JSON.stringify({ error: "Unauthorized" }), {
102+
status: 401,
103+
statusText: "Unauthorized",
104+
}),
105+
);
106+
107+
const client = new AgentsClient(
108+
"http://localhost:3000",
109+
"oc_bad",
110+
5000,
111+
);
112+
113+
await expect(
114+
client.createAgent({ name: "Test", identifier: "test" }),
115+
).rejects.toThrow(OneCLIRequestError);
116+
117+
await expect(
118+
client.createAgent({ name: "Test", identifier: "test" }),
119+
).rejects.toMatchObject({
120+
statusCode: 401,
121+
url: "http://localhost:3000/api/agents",
122+
});
123+
});
124+
125+
it("throws OneCLIRequestError on 409 conflict", async () => {
126+
fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
127+
new Response(
128+
JSON.stringify({ error: "identifier already exists" }),
129+
{ status: 409, statusText: "Conflict" },
130+
),
131+
);
132+
133+
const client = new AgentsClient(
134+
"http://localhost:3000",
135+
"oc_test",
136+
5000,
137+
);
138+
139+
const err = await client
140+
.createAgent({ name: "Test", identifier: "test" })
141+
.catch((e: unknown) => e);
142+
expect(err).toBeInstanceOf(OneCLIRequestError);
143+
expect((err as OneCLIRequestError).statusCode).toBe(409);
144+
});
145+
146+
it("wraps network errors into OneCLIError", async () => {
147+
fetchSpy = vi
148+
.spyOn(globalThis, "fetch")
149+
.mockRejectedValue(new TypeError("fetch failed"));
150+
151+
const client = new AgentsClient(
152+
"http://localhost:3000",
153+
"oc_test",
154+
5000,
155+
);
156+
157+
await expect(
158+
client.createAgent({ name: "Test", identifier: "test" }),
159+
).rejects.toThrow(OneCLIError);
160+
await expect(
161+
client.createAgent({ name: "Test", identifier: "test" }),
162+
).rejects.toThrow("fetch failed");
163+
});
164+
165+
it("re-throws OneCLIRequestError without wrapping", async () => {
166+
fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
167+
new Response("", { status: 500, statusText: "Internal Server Error" }),
168+
);
169+
170+
const client = new AgentsClient(
171+
"http://localhost:3000",
172+
"oc_test",
173+
5000,
174+
);
175+
176+
const err = await client
177+
.createAgent({ name: "Test", identifier: "test" })
178+
.catch((e: unknown) => e);
179+
expect(err).toBeInstanceOf(OneCLIRequestError);
180+
expect((err as OneCLIRequestError).name).toBe("OneCLIRequestError");
181+
});
182+
});
183+
});

0 commit comments

Comments
 (0)