diff --git a/packages/commands/src/commands/config/inventory.ts b/packages/commands/src/commands/config/inventory.ts index 261523d..3c771d2 100644 --- a/packages/commands/src/commands/config/inventory.ts +++ b/packages/commands/src/commands/config/inventory.ts @@ -19,7 +19,7 @@ import { import { inflateRawSync } from "node:zlib"; import yaml from "yaml"; import { parse as parseToml } from "smol-toml"; -import { qwenworkMcpPath } from "../mcp/agent-config.ts"; +import { qwenworkMcpPath, workbuddyMcpPaths } from "../mcp/agent-config.ts"; /** * Where an item comes from. Everything discovered on disk today is `local`; @@ -398,7 +398,9 @@ function transportOf(entry: Record): { const url = typeof entry.url === "string" ? entry.url : undefined; if (url) { const type = typeof entry.type === "string" ? entry.type.toLowerCase() : ""; - return { transport: type === "sse" ? "sse" : "http", detail: url }; + const transportField = typeof entry.transport === "string" ? entry.transport.toLowerCase() : ""; + if (type === "sse" || transportField === "sse") return { transport: "sse", detail: url }; + return { transport: "http", detail: url }; } return { transport: "unknown", detail: "" }; } @@ -475,7 +477,44 @@ export function listMcpServers(home: string = homedir()): McpServerInfo[] { if (gemini) collectMcpMap(gemini.mcpServers, "gemini", "global", out, true); const openclaw = readJsonSafe(join(home, ".openclaw", "openclaw.json")); - if (openclaw) collectMcpMap(openclaw.mcpServers, "openclaw", "global", out, true); + if (openclaw) { + const mcp = asRecord(openclaw.mcp); + collectMcpMap(mcp?.servers ?? openclaw.mcpServers, "openclaw", "global", out, true); + } + + const zcode = readJsonSafe(join(home, ".zcode", "cli", "config.json")); + if (zcode) collectMcpMap(asRecord(zcode.mcp)?.servers, "zcode", "global", out, true); + + for (const file of workbuddyMcpPaths(home)) { + const workbuddy = readJsonSafe(file); + if (workbuddy) collectMcpMap(workbuddy.mcpServers, "workbuddy", file, out, true); + } + + const dshPatch = readText(join(home, ".dsh", "cordis.patch.yml")); + if (dshPatch) { + try { + const parsed = yaml.parse(dshPatch) as unknown; + const items = Array.isArray(parsed) ? parsed : []; + const dshServers: Record = {}; + for (const item of items) { + const entries = asRecord(item)?.insert; + const list = Array.isArray(entries) ? entries : [item]; + for (const entry of list) { + const record = asRecord(entry); + const config = record ? asRecord(record.config) : undefined; + if ( + record?.name === "@deepseek-ai/dsh-mcp-client" && + typeof config?.serverName === "string" + ) { + dshServers[config.serverName] = config; + } + } + } + collectMcpMap(dshServers, "deepseek-harness", "global", out, false); + } catch { + /* ignore malformed yaml */ + } + } const claudeDesktop = readJsonSafe(claudeDesktopConfigPath(home)); if (claudeDesktop) collectMcpMap(claudeDesktop.mcpServers, "claude-desktop", "global", out, true); @@ -514,6 +553,8 @@ function claudeDesktopConfigPath(home: string): string { interface McpWriteTarget { file: string; mapKey: string; + /** When set, the server map lives at parentKey.mapKey (e.g. mcp.servers). */ + parentKey?: string; /** Claude stores project-scoped servers under projects[scope][mapKey]. */ projectScoped: boolean; } @@ -581,6 +622,20 @@ function mcpWriteTarget(source: string, scope: string, home: string): McpWriteTa if (source === "openclaw") return { file: join(home, ".openclaw", "openclaw.json"), + mapKey: "servers", + parentKey: "mcp", + projectScoped: false, + }; + if (source === "zcode") + return { + file: join(home, ".zcode", "cli", "config.json"), + mapKey: "servers", + parentKey: "mcp", + projectScoped: false, + }; + if (source === "workbuddy") + return { + file: workbuddyMcpPaths(home)[0] ?? join(home, ".workbuddy-ai", "mcp.json"), mapKey: "mcpServers", projectScoped: false, }; @@ -623,6 +678,45 @@ function unmaskMcpConfig(submitted: unknown, stored: unknown): unknown { return submitted; } +function mcpMapContainer( + root: Record, + target: McpWriteTarget, + scope: string, +): Record | undefined { + let container: Record | undefined = root; + if (target.projectScoped) { + const projects = asRecord(root.projects); + container = projects ? asRecord(projects[scope]) : undefined; + } + if (!container) return undefined; + if (target.parentKey) { + const parent = asRecord(container[target.parentKey]); + return parent; + } + return container; +} + +function ensureMcpMapContainer( + root: Record, + target: McpWriteTarget, + scope: string, +): Record { + let container: Record = root; + if (target.projectScoped) { + const projects = asRecord(root.projects) ?? {}; + root.projects = projects; + const proj = asRecord(projects[scope]) ?? {}; + projects[scope] = proj; + container = proj; + } + if (target.parentKey) { + const parent = asRecord(container[target.parentKey]) ?? {}; + container[target.parentKey] = parent; + return parent; + } + return container; +} + /** Create or update one MCP server entry, writing back to its source file. */ export function writeMcpServer( source: string, @@ -639,14 +733,7 @@ export function writeMcpServer( if (!cfg) throw new Error("Config must be a JSON object."); const root = readJsonSafe(target.file) ?? {}; - let container: Record = root; - if (target.projectScoped) { - const projects = asRecord(root.projects) ?? {}; - root.projects = projects; - const proj = asRecord(projects[scope]) ?? {}; - projects[scope] = proj; - container = proj; - } + const container = ensureMcpMapContainer(root, target, scope); const map = asRecord(container[target.mapKey]) ?? {}; container[target.mapKey] = map; @@ -667,11 +754,7 @@ export function deleteMcpServer( if (!target) throw new Error("This MCP source is read-only and cannot be edited here."); const root = readJsonSafe(target.file); if (!root) throw new Error("Config file not found."); - let container: Record | undefined = root; - if (target.projectScoped) { - const projects = asRecord(root.projects); - container = projects ? asRecord(projects[scope]) : undefined; - } + const container = mcpMapContainer(root, target, scope); const map = container ? asRecord(container[target.mapKey]) : undefined; if (!map || !(name in map)) throw new Error("Server not found: " + name); delete map[name]; diff --git a/packages/commands/src/commands/config/ui-html.ts b/packages/commands/src/commands/config/ui-html.ts index 7d23af4..154040e 100644 --- a/packages/commands/src/commands/config/ui-html.ts +++ b/packages/commands/src/commands/config/ui-html.ts @@ -1639,7 +1639,10 @@ const PAGE_HTML = ` { id: 'gemini', label: 'Gemini' }, { id: 'opencode', label: 'OpenCode' }, { id: 'openclaw', label: 'OpenClaw' }, - { id: 'qoderwork', label: 'QoderWork' } + { id: 'qoderwork', label: 'QoderWork' }, + { id: 'zcode', label: 'ZCode' }, + { id: 'workbuddy', label: 'WorkBuddy' }, + { id: 'deepseek-harness', label: 'DeepSeek Harness' } ]; function copyButton(getText) { var b = uiEl('button', 'copy-btn', 'Copy'); b.type = 'button'; diff --git a/packages/commands/src/commands/mcp/agent-config.ts b/packages/commands/src/commands/mcp/agent-config.ts index 4ec2b9d..4b0affd 100644 --- a/packages/commands/src/commands/mcp/agent-config.ts +++ b/packages/commands/src/commands/mcp/agent-config.ts @@ -3,6 +3,7 @@ import { existsSync, readFileSync, unlinkSync } from "node:fs"; import { join } from "node:path"; import { BailianError, ExitCode } from "bailian-cli-core"; import { parse as parseToml, stringify as stringifyToml } from "smol-toml"; +import yaml from "yaml"; import { backup, stripJsonc, @@ -19,8 +20,19 @@ export const MCP_AGENT_IDS = [ "qwenwork", "qwen-code", "gemini", + "opencode", + "openclaw", + "deepseek-harness", + "zcode", + "workbuddy", ] as const; +const DSH_MCP_PLUGIN = "@deepseek-ai/dsh-mcp-client"; +const DSH_OTHER_PATCHES = "__dshOtherPatches"; +const DSH_SERVERS = "mcpServers"; +const WORKBUDDY_DIRS = [".workbuddy-ai", ".workbuddy", ".codebuddy"] as const; +const OPENCLAW_DIRS = [".openclaw", ".clawdbot", ".moltbot"] as const; + export type NativeMcpAgent = (typeof MCP_AGENT_IDS)[number]; export type McpTransport = "streamable-http" | "sse"; @@ -57,6 +69,7 @@ interface RegistrationManifest { interface AgentAdapter { path(home: string): string; + paths?(home: string): string[]; installed(home: string): boolean; supports(transport: McpTransport): boolean; parse(path: string): Record; @@ -130,6 +143,43 @@ function serverMap(config: Record, key: string): Record, keys: string[]): Record { + let current = config; + for (const key of keys) { + current = serverMap(current, key); + } + return current; +} + +function adapterWritePaths(adapter: AgentAdapter, home: string): string[] { + const listed = adapter.paths?.(home); + if (listed && listed.length > 0) return listed; + return [adapter.path(home)]; +} + +function envOrHomePath(envValue: string | undefined, home: string, fallbackDir: string): string { + const trimmed = envValue?.trim(); + return trimmed ? trimmed : join(home, fallbackDir); +} + +function mergeConnectStatus( + current: McpAgentResult["status"] | undefined, + next: "added" | "updated" | "unchanged", +): McpAgentResult["status"] { + if (current === undefined || current === "unchanged") return next; + if (next === "updated" || current === "updated") return "updated"; + return current; +} + +function unsupportedSseError(agent: NativeMcpAgent): BailianError { + const label = + agent === "codex" ? "Codex" : agent === "deepseek-harness" ? "DeepSeek Harness" : agent; + return new BailianError( + `${label} does not support SSE MCP servers; use --transport streamable-http.`, + ExitCode.USAGE, + ); +} + function qwenworkUserDataDirs(home: string): string[] { if (process.platform === "darwin") { const support = join(home, "Library", "Application Support"); @@ -155,26 +205,148 @@ export function qwenworkMcpPath(home: string): string { return join(dirs[0], "mcp.json"); } +export function opencodeMcpPath(home: string): string { + return join(home, ".config", "opencode", "opencode.json"); +} + +export function openclawMcpPath(home: string): string { + const fromEnv = process.env.OPENCLAW_CONFIG_PATH?.trim(); + if (fromEnv) return fromEnv; + const existing = OPENCLAW_DIRS.map((dir) => join(home, dir)).find((dir) => existsSync(dir)); + return join(existing ?? join(home, OPENCLAW_DIRS[0]), "openclaw.json"); +} + +export function dshHomeDir(home: string): string { + return envOrHomePath(process.env.DSH_HOME, home, ".dsh"); +} + +export function dshMcpPath(home: string): string { + return join(dshHomeDir(home), "cordis.patch.yml"); +} + +export function zcodeMcpPath(home: string): string { + return join(envOrHomePath(process.env.ZCODE_HOME, home, ".zcode"), "cli", "config.json"); +} + +function workbuddyProductDirs(home: string): string[] { + return WORKBUDDY_DIRS.map((dir) => join(home, dir)); +} + +function workbuddyFileInDir(dir: string): string { + const recommended = join(dir, ".mcp.json"); + if (existsSync(recommended)) return recommended; + return join(dir, "mcp.json"); +} + +export function workbuddyMcpPaths(home: string): string[] { + const existing = workbuddyProductDirs(home).filter((dir) => existsSync(dir)); + const dirs = existing.length > 0 ? existing : [join(home, WORKBUDDY_DIRS[0])]; + return dirs.map((dir) => workbuddyFileInDir(dir)); +} + +function isDshMcpEntry(value: unknown): value is Record { + return isObject(value) && value.name === DSH_MCP_PLUGIN && isObject(value.config); +} + +function dshServerName(entry: Record): string | undefined { + const config = entry.config; + if (!isObject(config) || typeof config.serverName !== "string" || config.serverName === "") { + return undefined; + } + return config.serverName; +} + +function parseDshPatch(path: string): Record { + if (!existsSync(path)) return { [DSH_OTHER_PATCHES]: [], [DSH_SERVERS]: {} }; + let parsed: unknown; + try { + parsed = yaml.parse(readFileSync(path, "utf8")); + } catch (error) { + throw new BailianError( + `Cannot update MCP configuration because ${path} is invalid.`, + ExitCode.GENERAL, + "Fix the existing configuration file and retry; it was not changed.", + { cause: error }, + ); + } + if (parsed === null || parsed === undefined) { + return { [DSH_OTHER_PATCHES]: [], [DSH_SERVERS]: {} }; + } + if (!Array.isArray(parsed)) { + throw new BailianError( + `Cannot update MCP configuration because ${path} is invalid.`, + ExitCode.GENERAL, + "Fix the existing configuration file and retry; it was not changed.", + ); + } + + const otherPatches: unknown[] = []; + const servers: Record = {}; + for (const item of parsed) { + if (isObject(item) && Array.isArray(item.insert)) { + const otherEntries: unknown[] = []; + for (const entry of item.insert) { + if (isDshMcpEntry(entry)) { + const name = dshServerName(entry); + if (name) { + servers[name] = entry; + continue; + } + } + otherEntries.push(entry); + } + if (otherEntries.length > 0) otherPatches.push({ ...item, insert: otherEntries }); + continue; + } + if (isDshMcpEntry(item)) { + const name = dshServerName(item); + if (name) { + servers[name] = item; + continue; + } + } + otherPatches.push(item); + } + return { [DSH_OTHER_PATCHES]: otherPatches, [DSH_SERVERS]: servers }; +} + +function serializeDshPatch(config: Record): string { + const otherPatches = Array.isArray(config[DSH_OTHER_PATCHES]) ? config[DSH_OTHER_PATCHES] : []; + const servers = isObject(config[DSH_SERVERS]) ? config[DSH_SERVERS] : {}; + const patches = [...otherPatches]; + const mcpEntries = Object.values(servers); + if (mcpEntries.length > 0) patches.push({ insert: mcpEntries }); + return yaml.stringify(patches); +} + function jsonMcpAdapter(options: { path: (home: string) => string; + paths?: (home: string) => string[]; installed: (home: string) => boolean; + supports?: (transport: McpTransport) => boolean; typed?: boolean; + serverKeys?: string[]; + buildEntry?: (spec: McpConnectionSpec) => Record; }): AgentAdapter { + const serverKeys = options.serverKeys ?? ["mcpServers"]; return { path: options.path, + paths: options.paths, installed: options.installed, - supports: () => true, + supports: options.supports ?? (() => true), parse: parseJson, serialize: (config) => `${JSON.stringify(config, null, 2)}\n`, - getServers: (config) => serverMap(config, "mcpServers"), - buildEntry: (spec) => - options.typed - ? { - type: spec.transport === "sse" ? "sse" : "http", - url: spec.endpoint, - headers: spec.headers, - } - : { url: spec.endpoint, headers: spec.headers }, + getServers: (config) => nestedServerMap(config, serverKeys), + buildEntry: + options.buildEntry ?? + ((spec) => + options.typed + ? { + type: spec.transport === "sse" ? "sse" : "http", + url: spec.endpoint, + headers: spec.headers, + } + : { url: spec.endpoint, headers: spec.headers }), }; } @@ -248,6 +420,67 @@ const adapters: Record = { ? { url: spec.endpoint, headers: spec.headers } : { httpUrl: spec.endpoint, headers: spec.headers }, }, + opencode: jsonMcpAdapter({ + path: opencodeMcpPath, + installed: (home) => + existsSync(join(home, ".config", "opencode")) || existsSync(opencodeMcpPath(home)), + serverKeys: ["mcp"], + buildEntry: (spec) => ({ + type: "remote", + url: spec.endpoint, + enabled: true, + oauth: false, + headers: spec.headers, + }), + }), + openclaw: jsonMcpAdapter({ + path: openclawMcpPath, + installed: (home) => + Boolean(process.env.OPENCLAW_CONFIG_PATH?.trim()) || + OPENCLAW_DIRS.some((dir) => existsSync(join(home, dir))) || + existsSync(openclawMcpPath(home)), + serverKeys: ["mcp", "servers"], + buildEntry: (spec) => ({ + url: spec.endpoint, + transport: spec.transport === "sse" ? "sse" : "streamable-http", + headers: spec.headers, + }), + }), + "deepseek-harness": { + path: dshMcpPath, + installed: (home) => existsSync(dshHomeDir(home)), + supports: (transport) => transport === "streamable-http", + parse: parseDshPatch, + serialize: serializeDshPatch, + getServers: (config) => serverMap(config, DSH_SERVERS), + buildEntry: (spec) => ({ + id: `mcp-bailian-${spec.name}`, + name: DSH_MCP_PLUGIN, + config: { + serverName: spec.name, + transport: "streamable-http", + url: spec.endpoint, + headers: spec.headers, + }, + }), + }, + zcode: jsonMcpAdapter({ + path: zcodeMcpPath, + installed: (home) => existsSync(envOrHomePath(process.env.ZCODE_HOME, home, ".zcode")), + serverKeys: ["mcp", "servers"], + buildEntry: (spec) => ({ + type: spec.transport === "sse" ? "sse" : "http", + url: spec.endpoint, + enabled: true, + headers: spec.headers, + }), + }), + workbuddy: jsonMcpAdapter({ + path: (home) => workbuddyMcpPaths(home)[0] ?? join(home, WORKBUDDY_DIRS[0], "mcp.json"), + paths: workbuddyMcpPaths, + installed: (home) => workbuddyProductDirs(home).some((dir) => existsSync(dir)), + typed: true, + }), }; function stableJson(value: unknown): string { @@ -362,44 +595,49 @@ export function connectMcpAgents(options: ConnectOptions): McpAgentResult[] { for (const agent of options.agents) { const adapter = adapters[agent]; if (!adapter.supports(options.spec.transport)) { - throw new BailianError( - `Codex does not support SSE MCP servers; use --transport streamable-http.`, - ExitCode.USAGE, - ); + throw unsupportedSseError(agent); } - const path = adapter.path(options.home); - const config = adapter.parse(path); - const servers = adapter.getServers(config); + const paths = adapterWritePaths(adapter, options.home); + const primaryPath = adapter.path(options.home); const key = registrationKey(agent, options.spec.name); const managed = manifest.registrations[key]; - const existing = servers[options.spec.name]; - assertManagedEntry(existing, managed, agent, options.spec.name); - const desired = adapter.buildEntry(options.spec); const desiredFingerprint = fingerprint(desired); - const status = - existing === undefined - ? "added" - : fingerprint(existing) === desiredFingerprint - ? "unchanged" - : "updated"; - results.push({ agent, path, status }); + let status: McpAgentResult["status"] | undefined; - if (status !== "unchanged") { - servers[options.spec.name] = desired; - writes.push({ - path, - original: existsSync(path) ? readFileSync(path, "utf8") : undefined, - content: adapter.serialize(config), - }); + for (const path of paths) { + const config = adapter.parse(path); + const servers = adapter.getServers(config); + const existing = servers[options.spec.name]; + assertManagedEntry(existing, managed, agent, options.spec.name); + const pathStatus = + existing === undefined + ? "added" + : fingerprint(existing) === desiredFingerprint + ? "unchanged" + : "updated"; + status = mergeConnectStatus(status, pathStatus); + if (pathStatus !== "unchanged") { + servers[options.spec.name] = desired; + writes.push({ + path, + original: existsSync(path) ? readFileSync(path, "utf8") : undefined, + content: adapter.serialize(config), + }); + } + } + + const resolvedStatus = status ?? "unchanged"; + results.push({ agent, path: primaryPath, status: resolvedStatus }); + if (resolvedStatus !== "unchanged") { manifest.registrations[key] = { agent, name: options.spec.name, serverCode: options.spec.serverCode, transport: options.spec.transport, endpoint: options.spec.endpoint, - path, + path: primaryPath, fingerprint: desiredFingerprint, cliVersion: options.cliVersion, updatedAt: new Date().toISOString(), @@ -419,34 +657,39 @@ export function disconnectMcpAgents(options: DisconnectOptions): McpAgentResult[ for (const agent of options.agents) { const adapter = adapters[agent]; - const path = adapter.path(options.home); + const primaryPath = adapter.path(options.home); const key = registrationKey(agent, options.name); const managed = manifest.registrations[key]; if (!managed) { - results.push({ agent, path, status: "absent" }); + results.push({ agent, path: primaryPath, status: "absent" }); continue; } - const config = adapter.parse(path); - const servers = adapter.getServers(config); - const existing = servers[options.name]; - if (existing === undefined) { - delete manifest.registrations[key]; - manifestChanged = true; - results.push({ agent, path, status: "absent" }); - continue; + let removed = false; + let sawExisting = false; + for (const path of adapterWritePaths(adapter, options.home)) { + const config = adapter.parse(path); + const servers = adapter.getServers(config); + const existing = servers[options.name]; + if (existing === undefined) continue; + sawExisting = true; + assertManagedEntry(existing, managed, agent, options.name); + delete servers[options.name]; + removed = true; + writes.push({ + path, + original: readFileSync(path, "utf8"), + content: adapter.serialize(config), + }); } - assertManagedEntry(existing, managed, agent, options.name); - delete servers[options.name]; - const result: McpAgentResult = { agent, path, status: "removed" }; - results.push(result); - writes.push({ - path, - original: readFileSync(path, "utf8"), - content: adapter.serialize(config), - }); + delete manifest.registrations[key]; manifestChanged = true; + results.push({ + agent, + path: primaryPath, + status: sawExisting && removed ? "removed" : "absent", + }); } if (writes.length > 0 || manifestChanged) { diff --git a/packages/commands/src/commands/mcp/connect.ts b/packages/commands/src/commands/mcp/connect.ts index e624233..a303948 100644 --- a/packages/commands/src/commands/mcp/connect.ts +++ b/packages/commands/src/commands/mcp/connect.ts @@ -80,6 +80,12 @@ export default defineCommand({ "zh-CN": "qoder、qoderwork、qwenwork 是彼此独立的产品。qwenwork 是千问办公(QwenWork);qoderwork 是 Qoder Work。", }, + { + "en-US": + "workbuddy covers CodeBuddy / WorkBuddy. deepseek-harness is DeepSeek Harness and only accepts streamable-http.", + "zh-CN": + "workbuddy 覆盖 CodeBuddy / WorkBuddy。deepseek-harness 是 DeepSeek Harness,仅支持 streamable-http。", + }, ], exampleArgs: [ "--server TextGenerateImage --transport streamable-http --agent cursor", diff --git a/packages/commands/tests/e2e/mcp.e2e.test.ts b/packages/commands/tests/e2e/mcp.e2e.test.ts index 7cdc74c..f704dc1 100644 --- a/packages/commands/tests/e2e/mcp.e2e.test.ts +++ b/packages/commands/tests/e2e/mcp.e2e.test.ts @@ -47,6 +47,11 @@ describe("e2e: mcp", () => { expect(connect.stderr).toMatch(/qoder/); expect(connect.stderr).toMatch(/qoderwork/); expect(connect.stderr).toMatch(/qwenwork/); + expect(connect.stderr).toMatch(/opencode/); + expect(connect.stderr).toMatch(/openclaw/); + expect(connect.stderr).toMatch(/deepseek-harness/); + expect(connect.stderr).toMatch(/zcode/); + expect(connect.stderr).toMatch(/workbuddy/); const disconnect = await runCommandHelp(MCP_ROUTES, ["mcp", "disconnect", "--help"]); expect(disconnect.exitCode, disconnect.stderr).toBe(0); diff --git a/packages/commands/tests/mcp-agent-config.test.ts b/packages/commands/tests/mcp-agent-config.test.ts index 8e40220..312d2c5 100644 --- a/packages/commands/tests/mcp-agent-config.test.ts +++ b/packages/commands/tests/mcp-agent-config.test.ts @@ -6,8 +6,13 @@ import { afterEach, beforeEach, describe, expect, test } from "vite-plus/test"; import { connectMcpAgents, disconnectMcpAgents, + dshMcpPath, + opencodeMcpPath, + openclawMcpPath, qwenworkMcpPath, resolveMcpAgentTargets, + workbuddyMcpPaths, + zcodeMcpPath, type McpConnectionSpec, } from "../src/commands/mcp/agent-config.ts"; @@ -334,4 +339,174 @@ describe("MCP Agent registration", () => { expect(result.status).toBe("absent"); expect(existsSync(join(configDir, "mcp-registrations.json"))).toBe(false); }); + + test("opencode writes remote MCP entries under mcp", () => { + mkdirSync(join(home, ".config", "opencode"), { recursive: true }); + const [result] = connectMcpAgents({ + agents: ["opencode"], + spec: spec(), + cliVersion: "1.18.2", + home, + configDir, + }); + expect(result.status).toBe("added"); + expect(result.path).toBe(opencodeMcpPath(home)); + expect(readJson(result.path)).toMatchObject({ + mcp: { + ImageGenerate: { + type: "remote", + url: spec().endpoint, + enabled: true, + oauth: false, + headers: spec().headers, + }, + }, + }); + }); + + test("openclaw writes mcp.servers with native transport names", () => { + mkdirSync(join(home, ".openclaw"), { recursive: true }); + connectMcpAgents({ + agents: ["openclaw"], + spec: spec(), + cliVersion: "1.18.2", + home, + configDir, + }); + expect(readJson(openclawMcpPath(home))).toMatchObject({ + mcp: { + servers: { + ImageGenerate: { + url: spec().endpoint, + transport: "streamable-http", + headers: spec().headers, + }, + }, + }, + }); + connectMcpAgents({ + agents: ["openclaw"], + spec: spec("sse"), + cliVersion: "1.18.2", + home, + configDir, + }); + expect(readJson(openclawMcpPath(home))).toMatchObject({ + mcp: { + servers: { + ImageGenerate: { + url: spec("sse").endpoint, + transport: "sse", + headers: spec("sse").headers, + }, + }, + }, + }); + }); + + test("zcode writes mcp.servers with http and sse types", () => { + mkdirSync(join(home, ".zcode", "cli"), { recursive: true }); + const [result] = connectMcpAgents({ + agents: ["zcode"], + spec: spec(), + cliVersion: "1.18.2", + home, + configDir, + }); + expect(result.path).toBe(zcodeMcpPath(home)); + expect(readJson(result.path)).toMatchObject({ + mcp: { + servers: { + ImageGenerate: { + type: "http", + url: spec().endpoint, + enabled: true, + headers: spec().headers, + }, + }, + }, + }); + }); + + test("workbuddy writes mcp.json into each installed product directory", () => { + mkdirSync(join(home, ".workbuddy"), { recursive: true }); + mkdirSync(join(home, ".codebuddy"), { recursive: true }); + const [result] = connectMcpAgents({ + agents: ["workbuddy"], + spec: spec(), + cliVersion: "1.18.2", + home, + configDir, + }); + expect(result.status).toBe("added"); + const paths = workbuddyMcpPaths(home); + expect(paths).toEqual([ + join(home, ".workbuddy", "mcp.json"), + join(home, ".codebuddy", "mcp.json"), + ]); + for (const path of paths) { + expect(readJson(path)).toMatchObject({ + mcpServers: { + ImageGenerate: { type: "http", url: spec().endpoint, headers: spec().headers }, + }, + }); + } + expect(existsSync(join(home, ".workbuddy-ai"))).toBe(false); + + const [removed] = disconnectMcpAgents({ + agents: ["workbuddy"], + name: "ImageGenerate", + home, + configDir, + }); + expect(removed.status).toBe("removed"); + for (const path of paths) { + expect(readJson(path).mcpServers).toEqual({}); + } + }); + + test("deepseek-harness injects a streamable-http MCP client patch and preserves other inserts", () => { + mkdirSync(join(home, ".dsh"), { recursive: true }); + writeFileSync( + dshMcpPath(home), + ["- insert:", " - id: tool-other", " name: other-plugin", ""].join("\n"), + ); + const [result] = connectMcpAgents({ + agents: ["deepseek-harness"], + spec: spec(), + cliVersion: "1.18.2", + home, + configDir, + }); + expect(result.status).toBe("added"); + const content = readFileSync(dshMcpPath(home), "utf8"); + expect(content).toContain("tool-other"); + expect(content).toContain("@deepseek-ai/dsh-mcp-client"); + expect(content).toContain("streamable-http"); + expect(content).toContain(spec().endpoint); + expect(() => + connectMcpAgents({ + agents: ["deepseek-harness"], + spec: spec("sse"), + cliVersion: "1.18.2", + home, + configDir, + }), + ).toThrow(/DeepSeek Harness.*SSE|SSE.*DeepSeek Harness/); + }); + + test("all targets include newly supported agents when installed", () => { + mkdirSync(join(home, ".config", "opencode"), { recursive: true }); + mkdirSync(join(home, ".openclaw"), { recursive: true }); + mkdirSync(join(home, ".dsh"), { recursive: true }); + mkdirSync(join(home, ".zcode"), { recursive: true }); + mkdirSync(join(home, ".workbuddy-ai"), { recursive: true }); + expect(resolveMcpAgentTargets("all", home)).toEqual([ + "opencode", + "openclaw", + "deepseek-harness", + "zcode", + "workbuddy", + ]); + }); }); diff --git a/skills/bailian-cli/SKILL.md b/skills/bailian-cli/SKILL.md index 464ce98..0db0587 100644 --- a/skills/bailian-cli/SKILL.md +++ b/skills/bailian-cli/SKILL.md @@ -58,32 +58,32 @@ Do not guess flags — use the reference files or `--help`. Use this table only after the decision table in [`bailian-protocol`](../bailian-protocol/SKILL.md#provider-selection-and-consent) has routed the request to `bl` (class 4, or class 2 after the user picks Bailian). Hub-owned intents only — for media / fine-tune / agents.yaml / Sandbox, soft hand-off to the domain skill. -| User intent | Command | Notes | -| ------------------------------------------------ | --------------------------------------------- | ----------------------------------------------------------------------------------------- | -| Explicit Bailian model chat / text execution | `bl text chat` | Default `qwen3.8-max` | -| Bailian agent / workflow | `bl app call` | Needs `--app-id` | -| Find app by name | `bl app list` then `bl app call` | Console auth | -| Bailian app memory CRUD (not host-agent memory) | `bl memory *` | [`reference/memory.md`](reference/memory.md) | -| Bailian knowledge base RAG | `bl knowledge search` / `chat` | API key + agent/workspace IDs | -| Upload a file as a step of a Bailian workflow | `bl file upload` | When you need `oss://` URL explicitly; not for generic hosting | -| Bailian model selection / recommendation | `bl advisor recommend` | Intent → candidate recall → LLM ranking | -| Bailian model catalog / pricing / params | `bl model list` | Console auth; `--model ` for detail, `--enrich` for input params | -| Install / list / update / remove registry skills | `bl skill add` / `list` / `update` / `remove` | Bailian skill registry; see [`reference/skill.md`](reference/skill.md) | -| Bailian MCP marketplace discovery / call | `bl mcp list` / `tools` / `call` | — | -| Register or remove a Bailian MCP in an Agent | `bl mcp connect` / `disconnect` | Codex, Claude Code, Cursor, Qoder, Qoder Work, QwenWork (千问办公), Qwen Code, Gemini CLI | -| Bailian pipeline workflow (a step in a bl flow) | `bl pipeline run` / `validate` | JSON/YAML workflow definitions | -| Bailian rate limits / quota | `bl quota list` / `check` / `request` | Console auth; class 2 — ask which product first if unnamed | -| Bailian free tier / usage stats | `bl usage free` / `stats` / `freetier` | Console auth; class 2 — ask which product first if unnamed | -| Bailian Token Plan quota usage | `bl usage token-plan` | Console auth; class 2 — ask which product first if unnamed | -| Bailian Coding Plan quota usage | `bl usage coding-plan` | Console auth; class 2 — ask which product first if unnamed | -| Console API (advanced) | `bl console call` | Console auth | -| Bailian workspace listing | `bl workspace list` | Console auth | -| Switch CLI Help / Quick Start language | `bl config set --key language --value zh-CN` | Use `en-US` to switch back; follows the active config profile | -| Image / video / speech / omni / vision | → skill `bailian-gen` | Fallback: `bl image\|video\|speech\|omni\|vision --help` | -| Dataset / fine-tune / deploy | → skill `bailian-finetune` | Fallback: `bl dataset\|finetune\|deploy --help` | -| agents.yaml IaC / managed-agent sessions | → skill `bailian-managed-agent` | Fallback: `bl managed-agent --help`; `apply`/`destroy` also require `plan` | -| Bailian Sandbox instance / template lifecycle | → skill `bailian-sandbox` | Fallback: `bl sandbox --help` | -| Web search (model-aware routing) | → skill `bailian-web-search` | Token Plan vs MCP path + fallback; fallback: `bl search web --help` | +| User intent | Command | Notes | +| ------------------------------------------------ | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| Explicit Bailian model chat / text execution | `bl text chat` | Default `qwen3.8-max` | +| Bailian agent / workflow | `bl app call` | Needs `--app-id` | +| Find app by name | `bl app list` then `bl app call` | Console auth | +| Bailian app memory CRUD (not host-agent memory) | `bl memory *` | [`reference/memory.md`](reference/memory.md) | +| Bailian knowledge base RAG | `bl knowledge search` / `chat` | API key + agent/workspace IDs | +| Upload a file as a step of a Bailian workflow | `bl file upload` | When you need `oss://` URL explicitly; not for generic hosting | +| Bailian model selection / recommendation | `bl advisor recommend` | Intent → candidate recall → LLM ranking | +| Bailian model catalog / pricing / params | `bl model list` | Console auth; `--model ` for detail, `--enrich` for input params | +| Install / list / update / remove registry skills | `bl skill add` / `list` / `update` / `remove` | Bailian skill registry; see [`reference/skill.md`](reference/skill.md) | +| Bailian MCP marketplace discovery / call | `bl mcp list` / `tools` / `call` | — | +| Register or remove a Bailian MCP in an Agent | `bl mcp connect` / `disconnect` | Codex, Claude Code, Cursor, Qoder, Qoder Work, QwenWork, Qwen Code, Gemini, OpenCode, OpenClaw, DeepSeek Harness, ZCode, WorkBuddy | +| Bailian pipeline workflow (a step in a bl flow) | `bl pipeline run` / `validate` | JSON/YAML workflow definitions | +| Bailian rate limits / quota | `bl quota list` / `check` / `request` | Console auth; class 2 — ask which product first if unnamed | +| Bailian free tier / usage stats | `bl usage free` / `stats` / `freetier` | Console auth; class 2 — ask which product first if unnamed | +| Bailian Token Plan quota usage | `bl usage token-plan` | Console auth; class 2 — ask which product first if unnamed | +| Bailian Coding Plan quota usage | `bl usage coding-plan` | Console auth; class 2 — ask which product first if unnamed | +| Console API (advanced) | `bl console call` | Console auth | +| Bailian workspace listing | `bl workspace list` | Console auth | +| Switch CLI Help / Quick Start language | `bl config set --key language --value zh-CN` | Use `en-US` to switch back; follows the active config profile | +| Image / video / speech / omni / vision | → skill `bailian-gen` | Fallback: `bl image\|video\|speech\|omni\|vision --help` | +| Dataset / fine-tune / deploy | → skill `bailian-finetune` | Fallback: `bl dataset\|finetune\|deploy --help` | +| agents.yaml IaC / managed-agent sessions | → skill `bailian-managed-agent` | Fallback: `bl managed-agent --help`; `apply`/`destroy` also require `plan` | +| Bailian Sandbox instance / template lifecycle | → skill `bailian-sandbox` | Fallback: `bl sandbox --help` | +| Web search (model-aware routing) | → skill `bailian-web-search` | Token Plan vs MCP path + fallback; fallback: `bl search web --help` | Flags, usage, and examples: see hub [`reference/`](reference/index.md) or `bl --help` — do not guess flags. Domain command details live in the owning skill's `reference/`. diff --git a/skills/bailian-cli/reference/mcp.md b/skills/bailian-cli/reference/mcp.md index 698aa2f..29004f9 100644 --- a/skills/bailian-cli/reference/mcp.md +++ b/skills/bailian-cli/reference/mcp.md @@ -63,19 +63,20 @@ bl mcp call --target market-cmapi00073529.SmartFundSelection --arg riskLevel=R3 #### Flags -| Flag | Type | Required | Description | -| ------------------------------------------------------------------------------------------ | ------ | -------- | -------------------------------------------------------------------------------------------- | -| `--server ` | string | yes | Bailian MCP Server Code, such as TextGenerateImage | -| `--transport ` | string | yes | MCP transport exposed by the server: streamable-http or sse | -| `--agent ` | string | yes | Target Agent: codex, claude-code, cursor, qoder, qoderwork, qwenwork, qwen-code, gemini, all | -| `--api-key ` | string | no | API key | -| `--base-url ` | string | no | API base URL | +| Flag | Type | Required | Description | +| -------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--server ` | string | yes | Bailian MCP Server Code, such as TextGenerateImage | +| `--transport ` | string | yes | MCP transport exposed by the server: streamable-http or sse | +| `--agent ` | string | yes | Target Agent: codex, claude-code, cursor, qoder, qoderwork, qwenwork, qwen-code, gemini, opencode, openclaw, deepseek-harness, zcode, workbuddy, all | +| `--api-key ` | string | no | API key | +| `--base-url ` | string | no | API base URL | #### Notes - This release registers the official China-site MCP endpoint; the model API --base-url does not change the MCP endpoint. - The resolved API key is written to the selected Agent's private local configuration. Existing unmanaged entries are never overwritten. - qoder, qoderwork, and qwenwork are independent products. qwenwork is QwenWork (千问办公); qoderwork is Qoder Work. +- workbuddy covers CodeBuddy / WorkBuddy. deepseek-harness is DeepSeek Harness and only accepts streamable-http. #### Examples @@ -102,10 +103,10 @@ bl mcp connect --server TextGenerateImage --transport streamable-http --agent al #### Flags -| Flag | Type | Required | Description | -| ------------------------------------------------------------------------------------------ | ------ | -------- | -------------------------------------------------------------------------------------------- | -| `--server ` | string | yes | Bailian MCP Server Code used during connect | -| `--agent ` | string | yes | Target Agent: codex, claude-code, cursor, qoder, qoderwork, qwenwork, qwen-code, gemini, all | +| Flag | Type | Required | Description | +| -------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--server ` | string | yes | Bailian MCP Server Code used during connect | +| `--agent ` | string | yes | Target Agent: codex, claude-code, cursor, qoder, qoderwork, qwenwork, qwen-code, gemini, opencode, openclaw, deepseek-harness, zcode, workbuddy, all | #### Notes