mirror of
https://github.com/modelstudioai/cli.git
synced 2026-09-14 19:49:23 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f4e7fd4b38 |
@@ -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<string, unknown>): {
|
||||
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<string, unknown> = {};
|
||||
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<string, unknown>,
|
||||
target: McpWriteTarget,
|
||||
scope: string,
|
||||
): Record<string, unknown> | undefined {
|
||||
let container: Record<string, unknown> | 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<string, unknown>,
|
||||
target: McpWriteTarget,
|
||||
scope: string,
|
||||
): Record<string, unknown> {
|
||||
let container: Record<string, unknown> = 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<string, unknown> = 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<string, unknown> | 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];
|
||||
|
||||
@@ -1639,7 +1639,10 @@ const PAGE_HTML = `<!doctype 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';
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
@@ -130,6 +143,43 @@ function serverMap(config: Record<string, unknown>, key: string): Record<string,
|
||||
return current;
|
||||
}
|
||||
|
||||
function nestedServerMap(config: Record<string, unknown>, keys: string[]): Record<string, unknown> {
|
||||
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<string, unknown> {
|
||||
return isObject(value) && value.name === DSH_MCP_PLUGIN && isObject(value.config);
|
||||
}
|
||||
|
||||
function dshServerName(entry: Record<string, unknown>): 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<string, unknown> {
|
||||
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<string, unknown> = {};
|
||||
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, unknown>): 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<string, unknown>;
|
||||
}): 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<NativeMcpAgent, AgentAdapter> = {
|
||||
? { 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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
+26
-26
@@ -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 <family>` 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 <family>` 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 <command> --help` — do not guess flags. Domain command details live in the owning skill's `reference/`.
|
||||
|
||||
|
||||
@@ -63,19 +63,20 @@ bl mcp call --target market-cmapi00073529.SmartFundSelection --arg riskLevel=R3
|
||||
|
||||
#### Flags
|
||||
|
||||
| Flag | Type | Required | Description |
|
||||
| ------------------------------------------------------------------------------------------ | ------ | -------- | -------------------------------------------------------------------------------------------- |
|
||||
| `--server <code>` | string | yes | Bailian MCP Server Code, such as TextGenerateImage |
|
||||
| `--transport <streamable-http\|sse>` | string | yes | MCP transport exposed by the server: streamable-http or sse |
|
||||
| `--agent <codex\|claude-code\|cursor\|qoder\|qoderwork\|qwenwork\|qwen-code\|gemini\|all>` | string | yes | Target Agent: codex, claude-code, cursor, qoder, qoderwork, qwenwork, qwen-code, gemini, all |
|
||||
| `--api-key <key>` | string | no | API key |
|
||||
| `--base-url <url>` | string | no | API base URL |
|
||||
| Flag | Type | Required | Description |
|
||||
| -------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `--server <code>` | string | yes | Bailian MCP Server Code, such as TextGenerateImage |
|
||||
| `--transport <streamable-http\|sse>` | string | yes | MCP transport exposed by the server: streamable-http or sse |
|
||||
| `--agent <codex\|claude-code\|cursor\|qoder\|qoderwork\|qwenwork\|qwen-code\|gemini\|opencode\|openclaw\|deepseek-harness\|zcode\|workbuddy\|all>` | string | yes | Target Agent: codex, claude-code, cursor, qoder, qoderwork, qwenwork, qwen-code, gemini, opencode, openclaw, deepseek-harness, zcode, workbuddy, all |
|
||||
| `--api-key <key>` | string | no | API key |
|
||||
| `--base-url <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 <code>` | string | yes | Bailian MCP Server Code used during connect |
|
||||
| `--agent <codex\|claude-code\|cursor\|qoder\|qoderwork\|qwenwork\|qwen-code\|gemini\|all>` | string | yes | Target Agent: codex, claude-code, cursor, qoder, qoderwork, qwenwork, qwen-code, gemini, all |
|
||||
| Flag | Type | Required | Description |
|
||||
| -------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `--server <code>` | string | yes | Bailian MCP Server Code used during connect |
|
||||
| `--agent <codex\|claude-code\|cursor\|qoder\|qoderwork\|qwenwork\|qwen-code\|gemini\|opencode\|openclaw\|deepseek-harness\|zcode\|workbuddy\|all>` | string | yes | Target Agent: codex, claude-code, cursor, qoder, qoderwork, qwenwork, qwen-code, gemini, opencode, openclaw, deepseek-harness, zcode, workbuddy, all |
|
||||
|
||||
#### Notes
|
||||
|
||||
|
||||
Reference in New Issue
Block a user