From d157ba30123a1e920eed09092232e45168d5f134 Mon Sep 17 00:00:00 2001 From: poe-code-bot Date: Fri, 20 Feb 2026 21:58:34 +0000 Subject: [PATCH] feat(providers): add openclaw provider --- packages/agent-defs/src/agents.test.ts | 5 +- packages/agent-defs/src/agents/index.ts | 1 + packages/agent-defs/src/agents/openclaw.ts | 16 ++ packages/agent-defs/src/index.ts | 3 +- packages/agent-defs/src/registry.ts | 6 +- src/cli/isolated-services.test.ts | 2 + src/providers/openclaw.test.ts | 221 +++++++++++++++++++++ src/providers/openclaw.ts | 128 ++++++++++++ 8 files changed, 378 insertions(+), 4 deletions(-) create mode 100644 packages/agent-defs/src/agents/openclaw.ts create mode 100644 src/providers/openclaw.test.ts create mode 100644 src/providers/openclaw.ts diff --git a/packages/agent-defs/src/agents.test.ts b/packages/agent-defs/src/agents.test.ts index 47732211..a64fe319 100644 --- a/packages/agent-defs/src/agents.test.ts +++ b/packages/agent-defs/src/agents.test.ts @@ -5,6 +5,7 @@ import { codexAgent, openCodeAgent, kimiAgent, + openClawAgent, allAgents, resolveAgentId, type AgentDefinition @@ -15,7 +16,8 @@ const expectedAgents: AgentDefinition[] = [ claudeDesktopAgent, codexAgent, openCodeAgent, - kimiAgent + kimiAgent, + openClawAgent ]; const normalizeKey = (value: string): string => value.toLowerCase(); @@ -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) => { diff --git a/packages/agent-defs/src/agents/index.ts b/packages/agent-defs/src/agents/index.ts index 7f116f60..d4faa923 100644 --- a/packages/agent-defs/src/agents/index.ts +++ b/packages/agent-defs/src/agents/index.ts @@ -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"; diff --git a/packages/agent-defs/src/agents/openclaw.ts b/packages/agent-defs/src/agents/openclaw.ts new file mode 100644 index 00000000..5968b401 --- /dev/null +++ b/packages/agent-defs/src/agents/openclaw.ts @@ -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" + } + } +}; diff --git a/packages/agent-defs/src/index.ts b/packages/agent-defs/src/index.ts index d8e78c3b..e6599914 100644 --- a/packages/agent-defs/src/index.ts +++ b/packages/agent-defs/src/index.ts @@ -4,6 +4,7 @@ export { claudeDesktopAgent, codexAgent, openCodeAgent, - kimiAgent + kimiAgent, + openClawAgent } from "./agents/index.js"; export { allAgents, resolveAgentId } from "./registry.js"; diff --git a/packages/agent-defs/src/registry.ts b/packages/agent-defs/src/registry.ts index 5b635600..d4b36f4b 100644 --- a/packages/agent-defs/src/registry.ts +++ b/packages/agent-defs/src/registry.ts @@ -4,7 +4,8 @@ import { claudeDesktopAgent, codexAgent, openCodeAgent, - kimiAgent + kimiAgent, + openClawAgent } from "./agents/index.js"; export const allAgents: AgentDefinition[] = [ @@ -12,7 +13,8 @@ export const allAgents: AgentDefinition[] = [ claudeDesktopAgent, codexAgent, openCodeAgent, - kimiAgent + kimiAgent, + openClawAgent ]; const lookup = new Map(); diff --git a/src/cli/isolated-services.test.ts b/src/cli/isolated-services.test.ts index 99965498..14e0ceb4 100644 --- a/src/cli/isolated-services.test.ts +++ b/src/cli/isolated-services.test.ts @@ -27,6 +27,7 @@ describe("listIsolatedServiceIds", () => { "claude-code", "codex", "kimi", + "openclaw", "opencode" ]); }); @@ -55,6 +56,7 @@ describe("listIsolatedServiceIds", () => { "claude-code", "codex", "kimi", + "openclaw", "opencode", "custom-isolated" ]); diff --git a/src/providers/openclaw.test.ts b/src/providers/openclaw.test.ts new file mode 100644 index 00000000..035680f4 --- /dev/null +++ b/src/providers/openclaw.test.ts @@ -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 => ({ + env, + apiKey: "sk-test", + model: DEFAULT_FRONTIER_MODEL, + ...overrides + }); + + const buildUnconfigureOptions = ( + overrides: Partial = {} + ): UnconfigureOptions => ({ + env, + ...overrides + }); + + async function configureOpenClaw( + overrides: Partial = {} + ): Promise { + await openClawModule.openClawService.configure({ + fs, + env, + command: createTestCommandContext(fs), + options: buildConfigureOptions(overrides) + }); + } + + async function unconfigureOpenClaw( + overrides: Partial = {} + ): Promise { + 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")); + }); +}); diff --git a/src/providers/openclaw.ts b/src/providers/openclaw.ts new file mode 100644 index 00000000..95ed2eb4 --- /dev/null +++ b/src/providers/openclaw.ts @@ -0,0 +1,128 @@ +import { + DEFAULT_FRONTIER_MODEL, + FRONTIER_MODELS, + PROVIDER_NAME, + stripModelNamespace +} from "../cli/constants.js"; +import { createBinaryExistsCheck } from "../utils/command-checks.js"; +import { type ServiceInstallDefinition } from "../services/service-install.js"; +import { + configMutation, + fileMutation +} from "@poe-code/config-mutations"; +import { createProvider } from "./create-provider.js"; +import type { ModelConfigureOptions } from "./spawn-options.js"; +import type { CliEnvironment } from "../cli/environment.js"; +import { openClawAgent } from "@poe-code/agent-defs"; + +type OpenClawConfigureContext = ModelConfigureOptions & { + env: CliEnvironment; + apiKey: string; +}; + +type OpenClawUnconfigureContext = { + env: CliEnvironment; +}; + +export const OPEN_CLAW_INSTALL_DEFINITION: ServiceInstallDefinition = { + id: "openclaw", + summary: "OpenClaw CLI", + check: createBinaryExistsCheck( + "openclaw", + "openclaw-cli-binary", + "OpenClaw CLI binary must exist" + ), + steps: [ + { + id: "install-openclaw-npm", + command: "npm", + args: ["install", "-g", "openclaw@latest"] + } + ], + successMessage: "Installed OpenClaw CLI via npm." +}; + +export const openClawService = createProvider< + OpenClawConfigureContext, + OpenClawUnconfigureContext +>({ + ...openClawAgent, + configurePrompts: { + model: { + label: "OpenClaw default model", + defaultValue: DEFAULT_FRONTIER_MODEL, + choices: FRONTIER_MODELS.map((id) => ({ + title: id, + value: id + })) + } + }, + isolatedEnv: { + agentBinary: openClawAgent.binaryName!, + configProbe: { + kind: "isolatedFile", + relativePath: ".openclaw/openclaw.json" + }, + env: { + HOME: { kind: "isolatedDir" } + } + }, + manifest: { + configure: [ + fileMutation.ensureDirectory({ path: "~/.openclaw" }), + configMutation.merge({ + target: "~/.openclaw/openclaw.json", + value: (ctx) => { + const { model, apiKey, env } = (ctx ?? {}) as { + model?: string; + apiKey?: string; + env: CliEnvironment; + }; + return { + agents: { + defaults: { + model: { + primary: `${PROVIDER_NAME}/${stripModelNamespace(model ?? DEFAULT_FRONTIER_MODEL)}` + } + } + }, + models: { + providers: { + [PROVIDER_NAME]: { + baseUrl: env.poeApiBaseUrl, + apiKey: apiKey ?? "", + api: "openai-completions", + models: FRONTIER_MODELS.map((id) => ({ + id: stripModelNamespace(id) + })) + } + } + } + }; + } + }) + ], + unconfigure: [ + configMutation.prune({ + target: "~/.openclaw/openclaw.json", + shape: { + agents: { + defaults: { + model: { + primary: true + } + } + }, + models: { + providers: { + [PROVIDER_NAME]: true + } + } + } + }) + ] + }, + install: OPEN_CLAW_INSTALL_DEFINITION +}); + +export const provider = openClawService;