Skip to content
Open
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
5 changes: 4 additions & 1 deletion packages/agent-defs/src/agents.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
codexAgent,
openCodeAgent,
kimiAgent,
openClawAgent,
allAgents,
resolveAgentId,
type AgentDefinition
Expand All @@ -15,7 +16,8 @@ const expectedAgents: AgentDefinition[] = [
claudeDesktopAgent,
codexAgent,
openCodeAgent,
kimiAgent
kimiAgent,
openClawAgent
];

const normalizeKey = (value: string): string => value.toLowerCase();
Expand All @@ -27,6 +29,7 @@ describe("agent-defs package", () => {
expect(codexAgent).toBeDefined();
expect(openCodeAgent).toBeDefined();
expect(kimiAgent).toBeDefined();
expect(openClawAgent).toBeDefined();
});

it.each(expectedAgents)("$id has all required fields", (agent) => {
Expand Down
1 change: 1 addition & 0 deletions packages/agent-defs/src/agents/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export { claudeDesktopAgent } from "./claude-desktop.js";
export { codexAgent } from "./codex.js";
export { openCodeAgent } from "./opencode.js";
export { kimiAgent } from "./kimi.js";
export { openClawAgent } from "./openclaw.js";
16 changes: 16 additions & 0 deletions packages/agent-defs/src/agents/openclaw.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { AgentDefinition } from "../types.js";

export const openClawAgent: AgentDefinition = {
id: "openclaw",
name: "openclaw",
label: "OpenClaw",
summary: "Configure OpenClaw to use the Poe API.",
binaryName: "openclaw",
configPath: "~/.openclaw/openclaw.json",
branding: {
colors: {
dark: "#E8521C",
light: "#C44316"
}
}
};
3 changes: 2 additions & 1 deletion packages/agent-defs/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export {
claudeDesktopAgent,
codexAgent,
openCodeAgent,
kimiAgent
kimiAgent,
openClawAgent
} from "./agents/index.js";
export { allAgents, resolveAgentId } from "./registry.js";
6 changes: 4 additions & 2 deletions packages/agent-defs/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,17 @@ import {
claudeDesktopAgent,
codexAgent,
openCodeAgent,
kimiAgent
kimiAgent,
openClawAgent
} from "./agents/index.js";

export const allAgents: AgentDefinition[] = [
claudeCodeAgent,
claudeDesktopAgent,
codexAgent,
openCodeAgent,
kimiAgent
kimiAgent,
openClawAgent
];

const lookup = new Map<string, string>();
Expand Down
2 changes: 2 additions & 0 deletions src/cli/isolated-services.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ describe("listIsolatedServiceIds", () => {
"claude-code",
"codex",
"kimi",
"openclaw",
"opencode"
]);
});
Expand Down Expand Up @@ -55,6 +56,7 @@ describe("listIsolatedServiceIds", () => {
"claude-code",
"codex",
"kimi",
"openclaw",
"opencode",
"custom-isolated"
]);
Expand Down
221 changes: 221 additions & 0 deletions src/providers/openclaw.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
import { describe, it, expect, beforeEach } from "vitest";
import path from "node:path";
import type { FileSystem } from "../utils/file-system.js";
import {
DEFAULT_FRONTIER_MODEL,
FRONTIER_MODELS,
PROVIDER_NAME,
stripModelNamespace
} from "../cli/constants.js";
import * as openClawModule from "./openclaw.js";
import { createCliEnvironment } from "../cli/environment.js";
import { createTestCommandContext } from "../../tests/test-command-context.js";
import { createMockFs } from "@poe-code/config-mutations/testing";

const withProviderPrefix = (model: string): string =>
`${PROVIDER_NAME}/${stripModelNamespace(model)}`;

describe("openclaw service", () => {
let fs: FileSystem;
const homeDir = "/home/user";
const configPath = path.join(homeDir, ".openclaw", "openclaw.json");
let env = createCliEnvironment({ cwd: homeDir, homeDir });

beforeEach(() => {
fs = createMockFs({}, homeDir);
env = createCliEnvironment({ cwd: homeDir, homeDir });
});

type ConfigureOptions = Parameters<
typeof openClawModule.openClawService.configure
>[0]["options"];

type UnconfigureOptions = Parameters<
typeof openClawModule.openClawService.unconfigure
>[0]["options"];

const buildConfigureOptions = (
overrides: Partial<ConfigureOptions> = {}
): ConfigureOptions => ({
env,
apiKey: "sk-test",
model: DEFAULT_FRONTIER_MODEL,
...overrides
});

const buildUnconfigureOptions = (
overrides: Partial<UnconfigureOptions> = {}
): UnconfigureOptions => ({
env,
...overrides
});

async function configureOpenClaw(
overrides: Partial<ConfigureOptions> = {}
): Promise<void> {
await openClawModule.openClawService.configure({
fs,
env,
command: createTestCommandContext(fs),
options: buildConfigureOptions(overrides)
});
}

async function unconfigureOpenClaw(
overrides: Partial<UnconfigureOptions> = {}
): Promise<boolean> {
return openClawModule.openClawService.unconfigure({
fs,
env,
command: createTestCommandContext(fs),
options: buildUnconfigureOptions(overrides)
});
}

it("creates the openclaw config with poe provider", async () => {
await configureOpenClaw();

const config = JSON.parse(await fs.readFile(configPath, "utf8"));
expect(config).toEqual({
agents: {
defaults: {
model: {
primary: withProviderPrefix(DEFAULT_FRONTIER_MODEL)
}
}
},
models: {
providers: {
[PROVIDER_NAME]: {
baseUrl: env.poeApiBaseUrl,
apiKey: "sk-test",
api: "openai-completions",
models: FRONTIER_MODELS.map((id) => ({ id: stripModelNamespace(id) }))
}
}
}
});
});

it("writes the selected model as the primary", async () => {
const alternate = FRONTIER_MODELS[FRONTIER_MODELS.length - 1]!;
await configureOpenClaw({ model: alternate });

const config = JSON.parse(await fs.readFile(configPath, "utf8"));
expect(config.agents.defaults.model.primary).toBe(withProviderPrefix(alternate));
});

it("merges with existing config and preserves other settings", async () => {
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(
configPath,
JSON.stringify({ theme: "dark", customSetting: true }, null, 2)
);

await configureOpenClaw();

const config = JSON.parse(await fs.readFile(configPath, "utf8"));
expect(config.theme).toBe("dark");
expect(config.customSetting).toBe(true);
expect(config.models.providers[PROVIDER_NAME]).toBeDefined();
});

it("replaces the poe provider entry while keeping other providers", async () => {
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(
configPath,
JSON.stringify(
{
models: {
providers: {
poe: {
baseUrl: "https://api.poe.com/v1",
apiKey: "old-key",
api: "openai-completions",
models: []
},
openai: {
baseUrl: "https://api.openai.com/v1",
apiKey: "openai-key",
api: "openai-completions",
models: []
}
}
}
},
null,
2
)
);

await configureOpenClaw();

const config = JSON.parse(await fs.readFile(configPath, "utf8"));
expect(config.models.providers[PROVIDER_NAME].apiKey).toBe("sk-test");
expect(config.models.providers.openai).toEqual({
baseUrl: "https://api.openai.com/v1",
apiKey: "openai-key",
api: "openai-completions",
models: []
});
});

it("removes the poe provider and model on unconfigure", async () => {
await configureOpenClaw();

const removed = await unconfigureOpenClaw();
expect(removed).toBe(true);

await expect(fs.readFile(configPath, "utf8")).rejects.toThrow();
});

it("preserves other providers when unconfiguring", async () => {
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(
configPath,
JSON.stringify(
{
agents: {
defaults: {
model: { primary: "poe/claude-sonnet-4.6" }
}
},
models: {
providers: {
poe: {
baseUrl: "https://api.poe.com/v1",
apiKey: "sk-test",
api: "openai-completions",
models: []
},
openai: {
baseUrl: "https://api.openai.com/v1",
apiKey: "openai-key",
api: "openai-completions",
models: []
}
}
}
},
null,
2
)
);

await unconfigureOpenClaw();

const config = JSON.parse(await fs.readFile(configPath, "utf8"));
expect(config.models?.providers?.poe).toBeUndefined();
expect(config.models.providers.openai).toBeDefined();
});

it("returns false when config file is absent on unconfigure", async () => {
const removed = await unconfigureOpenClaw();
expect(removed).toBe(false);
});

it("creates ~/.openclaw directory when configuring", async () => {
await configureOpenClaw();
await fs.stat(path.join(homeDir, ".openclaw"));
});
});
Loading