mirror of
https://github.com/modelstudioai/cli.git
synced 2026-09-14 19:49:23 +08:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 241345d7f1 | |||
| 46b55b29ac | |||
| f953f428c5 | |||
| e03103364e |
@@ -6,6 +6,13 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
||||
|
||||
[中文版](CHANGELOG.zh.md) · [README](README.md) · [Contributing](CONTRIBUTING.md)
|
||||
|
||||
## [1.25.0] - 2026-09-14
|
||||
|
||||
### Added
|
||||
|
||||
- **Token Plan harness quota** — `bl token-plan harness-quota` shows Token Plan harness entitlement quota usage (Console auth), joining the harness list with issued entitlements to display used/total quota, usage ratio, and reset time; harnesses with a pending entitlement are listed as issuing.
|
||||
- Filter the harness list with `--type official_tool|infrastructure`; render as a quota box or `--output json`.
|
||||
|
||||
## [1.24.0] - 2026-09-11
|
||||
|
||||
### Added
|
||||
|
||||
@@ -6,6 +6,13 @@
|
||||
|
||||
[English](CHANGELOG.md) · [README](README.zh.md) · [参与贡献](CONTRIBUTING.zh.md)
|
||||
|
||||
## [1.25.0] - 2026-09-14
|
||||
|
||||
### 新增
|
||||
|
||||
- **Token Plan harness 权益额度** —— `bl token-plan harness-quota` 查看 Token Plan harness 权益额度用量(Console 认证),联合 harness 列表与已发放权益,展示已用/总额度、使用比例和重置时间;待发放权益的 harness 以「发放中」状态列出。
|
||||
- 通过 `--type official_tool|infrastructure` 筛选 harness 列表;支持额度框输出或 `--output json`。
|
||||
|
||||
## [1.24.0] - 2026-09-11
|
||||
|
||||
### 新增
|
||||
|
||||
@@ -26,12 +26,11 @@ _Built for AI Agents. Every command works as a structured tool call._
|
||||
- **Model generation** — Full-modality generation across text, image, video, and speech, with editing and reference-based generation
|
||||
- **Asset understanding** — Parse and ask questions about images, documents, audio, and long videos
|
||||
- **App orchestration** — Call Managed Agents, agents, and workflows published on Aliyun Model Studio, wired to knowledge bases, memory, web search, and MCP tools
|
||||
- **Native MCP setup** — Register Bailian MCP servers in Codex, Claude Code, Cursor, Qoder, Qoder Work, QwenWork (千问办公), Qwen Code, or Gemini CLI with channel attribution preserved
|
||||
- **Training & deployment** — Validate and upload datasets, fine-tune models, deploy dedicated models as endpoints
|
||||
- **Account operations** — Login, UI-based configuration, model marketplace, usage and quota, rate-limit increases, team seat management
|
||||
- **Plan onboarding** — Connect subscription plans such as Token Plan to the CLI and common coding agents in one step
|
||||
|
||||
> **Note:** App orchestration, native MCP setup, training & deployment, account operations, and plan onboarding are currently available only to China site (aliyun.com) account holders and are not yet supported for international / global site accounts.
|
||||
> **Note:** App orchestration, training & deployment, account operations, and plan onboarding are currently available only to China site (aliyun.com) account holders and are not yet supported for international / global site accounts.
|
||||
|
||||
## Showcase 1: A Cinematic Short Film from One Sentence
|
||||
|
||||
|
||||
+1
-2
@@ -25,12 +25,11 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_
|
||||
- **模型生成** — 文本、图像、视频、语音全模态生成,支持编辑与参考生成
|
||||
- **素材理解** — 图像、文档、音频、长视频的解析与问答
|
||||
- **应用编排** — 调用百炼已发布的 Managed Agent、智能体和工作流,接入知识库、记忆库、联网搜索与 MCP 工具
|
||||
- **原生 MCP 接入** — 将百炼 MCP 服务注册到 Codex、Claude Code、Cursor、Qoder、Qoder Work、千问办公(QwenWork)、Qwen Code 或 Gemini CLI,并保留渠道归因
|
||||
- **模型训推** — 数据集校验上传、模型精调、专属模型部署上线
|
||||
- **账号运维** — 授权登录、界面化配置、模型市场、用量与额度、限流提额、团队席位管理
|
||||
- **套餐接入** — 支持 Token Plan 等订阅计划一键接到 CLI 和常见 Coding Agent
|
||||
|
||||
> **注意:** 应用编排、原生 MCP 接入、模型训推、账号运维和套餐接入目前仅支持中国站(aliyun.com)账号,暂不支持国际站 / 全球站账号。
|
||||
> **注意:** 应用编排、模型训推、账号运维和套餐接入目前仅支持中国站(aliyun.com)账号,暂不支持国际站 / 全球站账号。
|
||||
|
||||
## 示例 1:一句话生成一部电影短片
|
||||
|
||||
|
||||
@@ -26,12 +26,11 @@ _Built for AI Agents. Every command works as a structured tool call._
|
||||
- **Model generation** — Full-modality generation across text, image, video, and speech, with editing and reference-based generation
|
||||
- **Asset understanding** — Parse and ask questions about images, documents, audio, and long videos
|
||||
- **App orchestration** — Call Managed Agents, agents, and workflows published on Aliyun Model Studio, wired to knowledge bases, memory, web search, and MCP tools
|
||||
- **Native MCP setup** — Register Bailian MCP servers in Codex, Claude Code, Cursor, Qoder, Qoder Work, QwenWork (千问办公), Qwen Code, or Gemini CLI with channel attribution preserved
|
||||
- **Training & deployment** — Validate and upload datasets, fine-tune models, deploy dedicated models as endpoints
|
||||
- **Account operations** — Login, UI-based configuration, model marketplace, usage and quota, rate-limit increases, team seat management
|
||||
- **Plan onboarding** — Connect subscription plans such as Token Plan to the CLI and common coding agents in one step
|
||||
|
||||
> **Note:** App orchestration, native MCP setup, training & deployment, account operations, and plan onboarding are currently available only to China site (aliyun.com) account holders and are not yet supported for international / global site accounts.
|
||||
> **Note:** App orchestration, training & deployment, account operations, and plan onboarding are currently available only to China site (aliyun.com) account holders and are not yet supported for international / global site accounts.
|
||||
|
||||
## Showcase 1: A Cinematic Short Film from One Sentence
|
||||
|
||||
|
||||
@@ -25,12 +25,11 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_
|
||||
- **模型生成** — 文本、图像、视频、语音全模态生成,支持编辑与参考生成
|
||||
- **素材理解** — 图像、文档、音频、长视频的解析与问答
|
||||
- **应用编排** — 调用百炼已发布的 Managed Agent、智能体和工作流,接入知识库、记忆库、联网搜索与 MCP 工具
|
||||
- **原生 MCP 接入** — 将百炼 MCP 服务注册到 Codex、Claude Code、Cursor、Qoder、Qoder Work、千问办公(QwenWork)、Qwen Code 或 Gemini CLI,并保留渠道归因
|
||||
- **模型训推** — 数据集校验上传、模型精调、专属模型部署上线
|
||||
- **账号运维** — 授权登录、界面化配置、模型市场、用量与额度、限流提额、团队席位管理
|
||||
- **套餐接入** — 支持 Token Plan 等订阅计划一键接到 CLI 和常见 Coding Agent
|
||||
|
||||
> **注意:** 应用编排、原生 MCP 接入、模型训推、账号运维和套餐接入目前仅支持中国站(aliyun.com)账号,暂不支持国际站 / 全球站账号。
|
||||
> **注意:** 应用编排、模型训推、账号运维和套餐接入目前仅支持中国站(aliyun.com)账号,暂不支持国际站 / 全球站账号。
|
||||
|
||||
## 示例 1:一句话生成一部电影短片
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bailian-cli",
|
||||
"version": "1.24.0",
|
||||
"version": "1.25.0",
|
||||
"description": "CLI for Aliyun Model Studio (DashScope) AI Platform.",
|
||||
"keywords": [
|
||||
"agent",
|
||||
|
||||
@@ -65,8 +65,6 @@ import {
|
||||
knowledgeCollectionGet,
|
||||
knowledgeDocImportOss,
|
||||
mcpCall,
|
||||
mcpConnect,
|
||||
mcpDisconnect,
|
||||
mcpList,
|
||||
mcpTools,
|
||||
searchWeb,
|
||||
@@ -132,6 +130,7 @@ import {
|
||||
tokenPlanCreateKey,
|
||||
tokenPlanAssignSeats,
|
||||
tokenPlanAddMember,
|
||||
tokenPlanHarnessQuota,
|
||||
workspaceInit,
|
||||
pluginInstall,
|
||||
pluginLink,
|
||||
@@ -303,8 +302,6 @@ export const commands: Record<string, AnyCommand> = {
|
||||
"knowledge file get": knowledgeFileGet,
|
||||
"knowledge file delete": knowledgeFileDelete,
|
||||
"mcp call": mcpCall,
|
||||
"mcp connect": mcpConnect,
|
||||
"mcp disconnect": mcpDisconnect,
|
||||
"mcp list": mcpList,
|
||||
"mcp tools": mcpTools,
|
||||
"search web": searchWeb,
|
||||
@@ -370,6 +367,7 @@ export const commands: Record<string, AnyCommand> = {
|
||||
"token-plan create-key": tokenPlanCreateKey,
|
||||
"token-plan assign-seats": tokenPlanAssignSeats,
|
||||
"token-plan add-member": tokenPlanAddMember,
|
||||
"token-plan harness-quota": tokenPlanHarnessQuota,
|
||||
"workspace init": workspaceInit,
|
||||
"plugin install": pluginInstall,
|
||||
"plugin link": pluginLink,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bailian-cli-commands",
|
||||
"version": "1.24.0",
|
||||
"version": "1.25.0",
|
||||
"description": "Command library for bailian-cli products (knowledge, memory, media, …). See https://www.npmjs.com/package/bailian-cli for usage.",
|
||||
"homepage": "https://bailian.console.aliyun.com/cli",
|
||||
"bugs": {
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
import { inflateRawSync } from "node:zlib";
|
||||
import yaml from "yaml";
|
||||
import { parse as parseToml } from "smol-toml";
|
||||
import { qwenworkMcpPath, workbuddyMcpPaths } from "../mcp/agent-config.ts";
|
||||
|
||||
/**
|
||||
* Where an item comes from. Everything discovered on disk today is `local`;
|
||||
@@ -398,9 +397,7 @@ 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() : "";
|
||||
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: type === "sse" ? "sse" : "http", detail: url };
|
||||
}
|
||||
return { transport: "unknown", detail: "" };
|
||||
}
|
||||
@@ -456,65 +453,22 @@ export function listMcpServers(home: string = homedir()): McpServerInfo[] {
|
||||
const opencode = readJsonSafe(join(home, ".config", "opencode", "opencode.json"));
|
||||
if (opencode) collectMcpMap(opencode.mcp, "opencode", "global", out, true);
|
||||
|
||||
// Cursor, Windsurf, Gemini, Qoder, Qoder Work, QwenWork (千问办公), OpenClaw,
|
||||
// Claude Desktop: JSON with a top-level `mcpServers` map.
|
||||
// Cursor, Windsurf, Gemini, QoderWork, OpenClaw, Claude Desktop: all JSON with
|
||||
// a top-level `mcpServers` map (Claude/Cursor convention).
|
||||
const cursor = readJsonSafe(join(home, ".cursor", "mcp.json"));
|
||||
if (cursor) collectMcpMap(cursor.mcpServers, "cursor", "global", out, true);
|
||||
|
||||
const qoder = readJsonSafe(join(home, ".qoder", "mcp.json"));
|
||||
if (qoder) collectMcpMap(qoder.mcpServers, "qoder", "global", out, true);
|
||||
|
||||
const qoderwork = readJsonSafe(join(home, ".qoderwork", "mcp.json"));
|
||||
if (qoderwork) collectMcpMap(qoderwork.mcpServers, "qoderwork", "global", out, true);
|
||||
|
||||
const qwenwork = readJsonSafe(qwenworkMcpPath(home));
|
||||
if (qwenwork) collectMcpMap(qwenwork.mcpServers, "qwenwork", "global", out, true);
|
||||
|
||||
const windsurf = readJsonSafe(join(home, ".codeium", "windsurf", "mcp_config.json"));
|
||||
if (windsurf) collectMcpMap(windsurf.mcpServers, "windsurf", "global", out, true);
|
||||
|
||||
const gemini = readJsonSafe(join(home, ".gemini", "settings.json"));
|
||||
if (gemini) collectMcpMap(gemini.mcpServers, "gemini", "global", out, true);
|
||||
|
||||
const qoder = readJsonSafe(join(home, ".qoderwork", "mcp.json"));
|
||||
if (qoder) collectMcpMap(qoder.mcpServers, "qoderwork", "global", out, true);
|
||||
|
||||
const openclaw = readJsonSafe(join(home, ".openclaw", "openclaw.json"));
|
||||
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 */
|
||||
}
|
||||
}
|
||||
if (openclaw) collectMcpMap(openclaw.mcpServers, "openclaw", "global", out, true);
|
||||
|
||||
const claudeDesktop = readJsonSafe(claudeDesktopConfigPath(home));
|
||||
if (claudeDesktop) collectMcpMap(claudeDesktop.mcpServers, "claude-desktop", "global", out, true);
|
||||
@@ -553,8 +507,6 @@ 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;
|
||||
}
|
||||
@@ -601,41 +553,15 @@ function mcpWriteTarget(source: string, scope: string, home: string): McpWriteTa
|
||||
mapKey: "mcpServers",
|
||||
projectScoped: false,
|
||||
};
|
||||
if (source === "qoder")
|
||||
return {
|
||||
file: join(home, ".qoder", "mcp.json"),
|
||||
mapKey: "mcpServers",
|
||||
projectScoped: false,
|
||||
};
|
||||
if (source === "qoderwork")
|
||||
return {
|
||||
file: join(home, ".qoderwork", "mcp.json"),
|
||||
mapKey: "mcpServers",
|
||||
projectScoped: false,
|
||||
};
|
||||
if (source === "qwenwork")
|
||||
return {
|
||||
file: qwenworkMcpPath(home),
|
||||
mapKey: "mcpServers",
|
||||
projectScoped: false,
|
||||
};
|
||||
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,
|
||||
};
|
||||
@@ -678,45 +604,6 @@ 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,
|
||||
@@ -733,7 +620,14 @@ export function writeMcpServer(
|
||||
if (!cfg) throw new Error("Config must be a JSON object.");
|
||||
|
||||
const root = readJsonSafe(target.file) ?? {};
|
||||
const container = ensureMcpMapContainer(root, target, scope);
|
||||
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 map = asRecord(container[target.mapKey]) ?? {};
|
||||
container[target.mapKey] = map;
|
||||
|
||||
@@ -754,7 +648,11 @@ 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.");
|
||||
const container = mcpMapContainer(root, target, scope);
|
||||
let container: Record<string, unknown> | undefined = root;
|
||||
if (target.projectScoped) {
|
||||
const projects = asRecord(root.projects);
|
||||
container = projects ? asRecord(projects[scope]) : undefined;
|
||||
}
|
||||
const map = container ? asRecord(container[target.mapKey]) : undefined;
|
||||
if (!map || !(name in map)) throw new Error("Server not found: " + name);
|
||||
delete map[name];
|
||||
|
||||
@@ -1639,10 +1639,7 @@ const PAGE_HTML = `<!doctype html>
|
||||
{ id: 'gemini', label: 'Gemini' },
|
||||
{ id: 'opencode', label: 'OpenCode' },
|
||||
{ id: 'openclaw', label: 'OpenClaw' },
|
||||
{ id: 'qoderwork', label: 'QoderWork' },
|
||||
{ id: 'zcode', label: 'ZCode' },
|
||||
{ id: 'workbuddy', label: 'WorkBuddy' },
|
||||
{ id: 'deepseek-harness', label: 'DeepSeek Harness' }
|
||||
{ id: 'qoderwork', label: 'QoderWork' }
|
||||
];
|
||||
function copyButton(getText) {
|
||||
var b = uiEl('button', 'copy-btn', 'Copy'); b.type = 'button';
|
||||
|
||||
@@ -1,699 +0,0 @@
|
||||
import { createHash } from "node:crypto";
|
||||
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,
|
||||
writeJsonAtomic,
|
||||
writeTextAtomic,
|
||||
} from "../config/agent/writers/utils.ts";
|
||||
|
||||
export const MCP_AGENT_IDS = [
|
||||
"codex",
|
||||
"claude-code",
|
||||
"cursor",
|
||||
"qoder",
|
||||
"qoderwork",
|
||||
"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";
|
||||
|
||||
export interface McpConnectionSpec {
|
||||
name: string;
|
||||
serverCode: string;
|
||||
transport: McpTransport;
|
||||
endpoint: string;
|
||||
headers: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface McpAgentResult {
|
||||
agent: NativeMcpAgent;
|
||||
path: string;
|
||||
status: "added" | "updated" | "unchanged" | "removed" | "absent";
|
||||
}
|
||||
|
||||
interface ManagedRegistration {
|
||||
agent: NativeMcpAgent;
|
||||
name: string;
|
||||
serverCode: string;
|
||||
transport: McpTransport;
|
||||
endpoint: string;
|
||||
path: string;
|
||||
fingerprint: string;
|
||||
cliVersion: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface RegistrationManifest {
|
||||
version: 1;
|
||||
registrations: Record<string, ManagedRegistration>;
|
||||
}
|
||||
|
||||
interface AgentAdapter {
|
||||
path(home: string): string;
|
||||
paths?(home: string): string[];
|
||||
installed(home: string): boolean;
|
||||
supports(transport: McpTransport): boolean;
|
||||
parse(path: string): Record<string, unknown>;
|
||||
serialize(config: Record<string, unknown>): string;
|
||||
getServers(config: Record<string, unknown>): Record<string, unknown>;
|
||||
buildEntry(spec: McpConnectionSpec): Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface ConnectOptions {
|
||||
agents: NativeMcpAgent[];
|
||||
spec: McpConnectionSpec;
|
||||
cliVersion: string;
|
||||
home: string;
|
||||
configDir: string;
|
||||
}
|
||||
|
||||
interface DisconnectOptions {
|
||||
agents: NativeMcpAgent[];
|
||||
name: string;
|
||||
home: string;
|
||||
configDir: string;
|
||||
}
|
||||
|
||||
interface PlannedWrite {
|
||||
path: string;
|
||||
original?: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function parseObject(path: string, parser: (content: string) => unknown): Record<string, unknown> {
|
||||
if (!existsSync(path)) return {};
|
||||
try {
|
||||
const parsed = parser(readFileSync(path, "utf8"));
|
||||
if (!isObject(parsed)) throw new Error("root value is not an object");
|
||||
return parsed;
|
||||
} 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function parseJson(path: string): Record<string, unknown> {
|
||||
return parseObject(path, (content) => JSON.parse(stripJsonc(content)));
|
||||
}
|
||||
|
||||
function parseTomlObject(path: string): Record<string, unknown> {
|
||||
return parseObject(path, parseToml);
|
||||
}
|
||||
|
||||
function serverMap(config: Record<string, unknown>, key: string): Record<string, unknown> {
|
||||
const current = config[key];
|
||||
if (current === undefined) {
|
||||
const created: Record<string, unknown> = {};
|
||||
config[key] = created;
|
||||
return created;
|
||||
}
|
||||
if (!isObject(current)) {
|
||||
throw new BailianError(
|
||||
`Cannot update MCP configuration because ${key} is not an object.`,
|
||||
ExitCode.GENERAL,
|
||||
);
|
||||
}
|
||||
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");
|
||||
return [join(support, "QwenWorkCN"), join(support, "QwenWork"), join(support, "QwenWork CN")];
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
const appData = join(home, "AppData", "Roaming");
|
||||
return [join(appData, "QwenWorkCN"), join(appData, "QwenWork")];
|
||||
}
|
||||
return [join(home, ".config", "QwenWorkCN"), join(home, ".config", "QwenWork")];
|
||||
}
|
||||
|
||||
/** QwenWork / 千问办公 stores MCP config in Electron userData (`mcp.json`). */
|
||||
export function qwenworkMcpPath(home: string): string {
|
||||
const dirs = qwenworkUserDataDirs(home);
|
||||
for (const dir of dirs) {
|
||||
const file = join(dir, "mcp.json");
|
||||
if (existsSync(file)) return file;
|
||||
}
|
||||
for (const dir of dirs) {
|
||||
if (existsSync(dir)) return join(dir, "mcp.json");
|
||||
}
|
||||
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: options.supports ?? (() => true),
|
||||
parse: parseJson,
|
||||
serialize: (config) => `${JSON.stringify(config, null, 2)}\n`,
|
||||
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 }),
|
||||
};
|
||||
}
|
||||
|
||||
const adapters: Record<NativeMcpAgent, AgentAdapter> = {
|
||||
codex: {
|
||||
path: (home) => join(process.env.CODEX_HOME || join(home, ".codex"), "config.toml"),
|
||||
installed: (home) =>
|
||||
existsSync(process.env.CODEX_HOME || join(home, ".codex")) ||
|
||||
existsSync(join(process.env.CODEX_HOME || join(home, ".codex"), "config.toml")),
|
||||
supports: (transport) => transport === "streamable-http",
|
||||
parse: parseTomlObject,
|
||||
serialize: (config) => `${stringifyToml(config)}\n`,
|
||||
getServers: (config) => serverMap(config, "mcp_servers"),
|
||||
buildEntry: (spec) => ({ url: spec.endpoint, http_headers: spec.headers }),
|
||||
},
|
||||
"claude-code": {
|
||||
path: (home) => join(home, ".claude.json"),
|
||||
installed: (home) =>
|
||||
existsSync(join(home, ".claude")) || existsSync(join(home, ".claude.json")),
|
||||
supports: () => true,
|
||||
parse: parseJson,
|
||||
serialize: (config) => `${JSON.stringify(config, null, 2)}\n`,
|
||||
getServers: (config) => serverMap(config, "mcpServers"),
|
||||
buildEntry: (spec) => ({
|
||||
type: spec.transport === "sse" ? "sse" : "http",
|
||||
url: spec.endpoint,
|
||||
headers: spec.headers,
|
||||
}),
|
||||
},
|
||||
cursor: jsonMcpAdapter({
|
||||
path: (home) => join(home, ".cursor", "mcp.json"),
|
||||
installed: (home) =>
|
||||
existsSync(join(home, ".cursor")) || existsSync(join(home, ".cursor", "mcp.json")),
|
||||
}),
|
||||
qoder: jsonMcpAdapter({
|
||||
path: (home) => join(home, ".qoder", "mcp.json"),
|
||||
installed: (home) =>
|
||||
existsSync(join(home, ".qoder")) || existsSync(join(home, ".qoder", "mcp.json")),
|
||||
}),
|
||||
qoderwork: jsonMcpAdapter({
|
||||
path: (home) => join(home, ".qoderwork", "mcp.json"),
|
||||
installed: (home) =>
|
||||
existsSync(join(home, ".qoderwork")) || existsSync(join(home, ".qoderwork", "mcp.json")),
|
||||
}),
|
||||
qwenwork: jsonMcpAdapter({
|
||||
path: qwenworkMcpPath,
|
||||
installed: (home) => qwenworkUserDataDirs(home).some((dir) => existsSync(dir)),
|
||||
typed: true,
|
||||
}),
|
||||
"qwen-code": {
|
||||
path: (home) => join(home, ".qwen", "settings.json"),
|
||||
installed: (home) => existsSync(join(home, ".qwen")),
|
||||
supports: () => true,
|
||||
parse: parseJson,
|
||||
serialize: (config) => `${JSON.stringify(config, null, 2)}\n`,
|
||||
getServers: (config) => serverMap(config, "mcpServers"),
|
||||
buildEntry: (spec) =>
|
||||
spec.transport === "sse"
|
||||
? { url: spec.endpoint, headers: spec.headers }
|
||||
: { httpUrl: spec.endpoint, headers: spec.headers },
|
||||
},
|
||||
gemini: {
|
||||
path: (home) => join(home, ".gemini", "settings.json"),
|
||||
installed: (home) => existsSync(join(home, ".gemini")),
|
||||
supports: () => true,
|
||||
parse: parseJson,
|
||||
serialize: (config) => `${JSON.stringify(config, null, 2)}\n`,
|
||||
getServers: (config) => serverMap(config, "mcpServers"),
|
||||
buildEntry: (spec) =>
|
||||
spec.transport === "sse"
|
||||
? { 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 {
|
||||
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
|
||||
if (isObject(value)) {
|
||||
return `{${Object.keys(value)
|
||||
.sort()
|
||||
.map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`)
|
||||
.join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function fingerprint(value: unknown): string {
|
||||
return createHash("sha256").update(stableJson(value)).digest("hex");
|
||||
}
|
||||
|
||||
function registrationKey(agent: NativeMcpAgent, name: string): string {
|
||||
return `${agent}:${name}`;
|
||||
}
|
||||
|
||||
function manifestPath(configDir: string): string {
|
||||
return join(configDir, "mcp-registrations.json");
|
||||
}
|
||||
|
||||
function readManifest(configDir: string): RegistrationManifest {
|
||||
const path = manifestPath(configDir);
|
||||
if (!existsSync(path)) return { version: 1, registrations: {} };
|
||||
const parsed = parseJson(path);
|
||||
if (parsed.version !== 1 || !isObject(parsed.registrations)) {
|
||||
throw new BailianError(
|
||||
`Cannot update MCP registrations because ${path} has an unsupported format.`,
|
||||
ExitCode.GENERAL,
|
||||
);
|
||||
}
|
||||
return parsed as unknown as RegistrationManifest;
|
||||
}
|
||||
|
||||
function assertManagedEntry(
|
||||
existing: unknown,
|
||||
managed: ManagedRegistration | undefined,
|
||||
agent: NativeMcpAgent,
|
||||
name: string,
|
||||
): void {
|
||||
if (existing === undefined) return;
|
||||
if (!managed) {
|
||||
throw new BailianError(
|
||||
`MCP server "${name}" already exists in ${agent} and is not managed by bailian-cli.`,
|
||||
ExitCode.GENERAL,
|
||||
"Choose another server name or remove the existing entry yourself.",
|
||||
);
|
||||
}
|
||||
if (fingerprint(existing) !== managed.fingerprint) {
|
||||
throw new BailianError(
|
||||
`MCP server "${name}" in ${agent} conflicts with the last bailian-cli registration.`,
|
||||
ExitCode.GENERAL,
|
||||
"The entry was changed after registration; resolve it manually before retrying.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function restoreWrites(writes: PlannedWrite[]): void {
|
||||
for (const write of writes.reverse()) {
|
||||
try {
|
||||
if (write.original === undefined) unlinkSync(write.path);
|
||||
else writeTextAtomic(write.path, write.original);
|
||||
} catch {
|
||||
// Preserve the original failure; timestamped backups remain available.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyWrites(
|
||||
writes: PlannedWrite[],
|
||||
manifest: RegistrationManifest,
|
||||
configDir: string,
|
||||
): void {
|
||||
const completed: PlannedWrite[] = [];
|
||||
try {
|
||||
for (const write of writes) {
|
||||
backup(write.path);
|
||||
writeTextAtomic(write.path, write.content);
|
||||
completed.push(write);
|
||||
}
|
||||
writeJsonAtomic(manifestPath(configDir), manifest);
|
||||
} catch (error) {
|
||||
restoreWrites(completed);
|
||||
throw new BailianError(
|
||||
"Failed to update MCP Agent configuration.",
|
||||
ExitCode.GENERAL,
|
||||
undefined,
|
||||
{
|
||||
cause: error,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveMcpAgentTargets(
|
||||
target: NativeMcpAgent | "all",
|
||||
home: string,
|
||||
): NativeMcpAgent[] {
|
||||
if (target !== "all") return [target];
|
||||
return MCP_AGENT_IDS.filter((agent) => adapters[agent].installed(home));
|
||||
}
|
||||
|
||||
export function connectMcpAgents(options: ConnectOptions): McpAgentResult[] {
|
||||
const manifest = readManifest(options.configDir);
|
||||
const writes: PlannedWrite[] = [];
|
||||
const results: McpAgentResult[] = [];
|
||||
|
||||
for (const agent of options.agents) {
|
||||
const adapter = adapters[agent];
|
||||
if (!adapter.supports(options.spec.transport)) {
|
||||
throw unsupportedSseError(agent);
|
||||
}
|
||||
|
||||
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 desired = adapter.buildEntry(options.spec);
|
||||
const desiredFingerprint = fingerprint(desired);
|
||||
let status: McpAgentResult["status"] | undefined;
|
||||
|
||||
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: primaryPath,
|
||||
fingerprint: desiredFingerprint,
|
||||
cliVersion: options.cliVersion,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (writes.length > 0) applyWrites(writes, manifest, options.configDir);
|
||||
return results;
|
||||
}
|
||||
|
||||
export function disconnectMcpAgents(options: DisconnectOptions): McpAgentResult[] {
|
||||
const manifest = readManifest(options.configDir);
|
||||
const writes: PlannedWrite[] = [];
|
||||
const results: McpAgentResult[] = [];
|
||||
let manifestChanged = false;
|
||||
|
||||
for (const agent of options.agents) {
|
||||
const adapter = adapters[agent];
|
||||
const primaryPath = adapter.path(options.home);
|
||||
const key = registrationKey(agent, options.name);
|
||||
const managed = manifest.registrations[key];
|
||||
if (!managed) {
|
||||
results.push({ agent, path: primaryPath, 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),
|
||||
});
|
||||
}
|
||||
|
||||
delete manifest.registrations[key];
|
||||
manifestChanged = true;
|
||||
results.push({
|
||||
agent,
|
||||
path: primaryPath,
|
||||
status: sawExisting && removed ? "removed" : "absent",
|
||||
});
|
||||
}
|
||||
|
||||
if (writes.length > 0 || manifestChanged) {
|
||||
applyWrites(writes, manifest, options.configDir);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
import { homedir } from "node:os";
|
||||
import {
|
||||
BailianError,
|
||||
ExitCode,
|
||||
REGIONS,
|
||||
bailianMcpPath,
|
||||
bailianMcpSsePath,
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
getConfigDir,
|
||||
trackingHeaders,
|
||||
type FlagsDef,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult } from "bailian-cli-runtime";
|
||||
import {
|
||||
MCP_AGENT_IDS,
|
||||
connectMcpAgents,
|
||||
resolveMcpAgentTargets,
|
||||
type McpTransport,
|
||||
type NativeMcpAgent,
|
||||
} from "./agent-config.ts";
|
||||
|
||||
const AGENT_CHOICES = [...MCP_AGENT_IDS, "all"] as const;
|
||||
const TRANSPORT_CHOICES = ["streamable-http", "sse"] as const;
|
||||
|
||||
const FLAGS = {
|
||||
server: {
|
||||
type: "string",
|
||||
valueHint: "<code>",
|
||||
description: {
|
||||
"en-US": "Bailian MCP Server Code, such as TextGenerateImage",
|
||||
"zh-CN": "百炼 MCP Server Code,例如 TextGenerateImage",
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
transport: {
|
||||
type: "string",
|
||||
valueHint: "<transport>",
|
||||
choices: TRANSPORT_CHOICES,
|
||||
description: {
|
||||
"en-US": "MCP transport exposed by the server: streamable-http or sse",
|
||||
"zh-CN": "服务端提供的 MCP 传输协议:streamable-http 或 sse",
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
agent: {
|
||||
type: "string",
|
||||
valueHint: "<agent>",
|
||||
choices: AGENT_CHOICES,
|
||||
description: {
|
||||
"en-US": `Target Agent: ${AGENT_CHOICES.join(", ")}`,
|
||||
"zh-CN": `目标 Agent:${AGENT_CHOICES.join(", ")}`,
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
} satisfies FlagsDef;
|
||||
|
||||
export default defineCommand({
|
||||
description: {
|
||||
"en-US": "Register a Bailian MCP server in an Agent's native configuration",
|
||||
"zh-CN": "将百炼 MCP 服务注册到 Agent 的原生配置中",
|
||||
},
|
||||
auth: "apiKey",
|
||||
usageArgs: "--server <code> --transport <streamable-http|sse> --agent <agent|all>",
|
||||
flags: FLAGS,
|
||||
notes: [
|
||||
{
|
||||
"en-US":
|
||||
"This release registers the official China-site MCP endpoint; the model API --base-url does not change the MCP endpoint.",
|
||||
"zh-CN": "本期固定注册中国站官方 MCP 地址;模型 API 的 --base-url 不会改变 MCP 地址。",
|
||||
},
|
||||
{
|
||||
"en-US":
|
||||
"The resolved API key is written to the selected Agent's private local configuration. Existing unmanaged entries are never overwritten.",
|
||||
"zh-CN": "解析出的 API Key 会写入所选 Agent 的本地私有配置;CLI 不会覆盖非其管理的同名条目。",
|
||||
},
|
||||
{
|
||||
"en-US":
|
||||
"qoder, qoderwork, and qwenwork are independent products. qwenwork is QwenWork (千问办公); qoderwork is Qoder Work.",
|
||||
"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",
|
||||
"--server VideoGenerate --transport sse --agent claude-code",
|
||||
"--server TextGenerateImage --transport streamable-http --agent all",
|
||||
],
|
||||
async run(ctx) {
|
||||
const { flags, settings } = ctx;
|
||||
const transport = flags.transport as McpTransport;
|
||||
const target = flags.agent as NativeMcpAgent | "all";
|
||||
const agents = resolveMcpAgentTargets(target, homedir());
|
||||
if (agents.length === 0) {
|
||||
throw new BailianError(
|
||||
"No supported installed Agent was found for --agent all.",
|
||||
ExitCode.USAGE,
|
||||
);
|
||||
}
|
||||
|
||||
const endpointPath =
|
||||
transport === "sse" ? bailianMcpSsePath(flags.server) : bailianMcpPath(flags.server);
|
||||
const endpoint = `${REGIONS.cn}${endpointPath}`;
|
||||
const channelHeaders = trackingHeaders(ctx.identity);
|
||||
const headerNames = ["Authorization", ...Object.keys(channelHeaders)];
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult(
|
||||
{
|
||||
server: flags.server,
|
||||
transport,
|
||||
endpoint,
|
||||
agents,
|
||||
header_names: headerNames,
|
||||
},
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const results = connectMcpAgents({
|
||||
agents,
|
||||
spec: {
|
||||
name: flags.server,
|
||||
serverCode: flags.server,
|
||||
transport,
|
||||
endpoint,
|
||||
headers: ctx.client.bailianMcpRegistrationHeaders(),
|
||||
},
|
||||
cliVersion: ctx.identity.version,
|
||||
home: homedir(),
|
||||
configDir: getConfigDir(),
|
||||
});
|
||||
|
||||
emitResult({ server: flags.server, transport, endpoint, results }, format);
|
||||
},
|
||||
});
|
||||
@@ -1,83 +0,0 @@
|
||||
import { homedir } from "node:os";
|
||||
import {
|
||||
BailianError,
|
||||
ExitCode,
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
getConfigDir,
|
||||
type FlagsDef,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult } from "bailian-cli-runtime";
|
||||
import {
|
||||
MCP_AGENT_IDS,
|
||||
disconnectMcpAgents,
|
||||
resolveMcpAgentTargets,
|
||||
type NativeMcpAgent,
|
||||
} from "./agent-config.ts";
|
||||
|
||||
const AGENT_CHOICES = [...MCP_AGENT_IDS, "all"] as const;
|
||||
|
||||
const FLAGS = {
|
||||
server: {
|
||||
type: "string",
|
||||
valueHint: "<code>",
|
||||
description: {
|
||||
"en-US": "Bailian MCP Server Code used during connect",
|
||||
"zh-CN": "connect 时使用的百炼 MCP Server Code",
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
agent: {
|
||||
type: "string",
|
||||
valueHint: "<agent>",
|
||||
choices: AGENT_CHOICES,
|
||||
description: {
|
||||
"en-US": `Target Agent: ${AGENT_CHOICES.join(", ")}`,
|
||||
"zh-CN": `目标 Agent:${AGENT_CHOICES.join(", ")}`,
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
} satisfies FlagsDef;
|
||||
|
||||
export default defineCommand({
|
||||
description: {
|
||||
"en-US": "Remove an unchanged MCP registration previously managed by bailian-cli",
|
||||
"zh-CN": "移除此前由 bailian-cli 管理且未被修改的 MCP 注册",
|
||||
},
|
||||
auth: "none",
|
||||
usageArgs: "--server <code> --agent <agent|all>",
|
||||
flags: FLAGS,
|
||||
notes: [
|
||||
{
|
||||
"en-US": "A registration changed after connect is left untouched and reported as a conflict.",
|
||||
"zh-CN": "如果注册项在 connect 后被修改,CLI 会保留该配置并报告冲突。",
|
||||
},
|
||||
],
|
||||
exampleArgs: [
|
||||
"--server TextGenerateImage --agent cursor",
|
||||
"--server TextGenerateImage --agent all",
|
||||
],
|
||||
async run(ctx) {
|
||||
const { flags, settings } = ctx;
|
||||
const target = flags.agent as NativeMcpAgent | "all";
|
||||
const agents = resolveMcpAgentTargets(target, homedir());
|
||||
if (agents.length === 0) {
|
||||
throw new BailianError(
|
||||
"No supported installed Agent was found for --agent all.",
|
||||
ExitCode.USAGE,
|
||||
);
|
||||
}
|
||||
const format = detectOutputFormat(settings.output);
|
||||
if (settings.dryRun) {
|
||||
emitResult({ server: flags.server, agents, action: "disconnect" }, format);
|
||||
return;
|
||||
}
|
||||
const results = disconnectMcpAgents({
|
||||
agents,
|
||||
name: flags.server,
|
||||
home: homedir(),
|
||||
configDir: getConfigDir(),
|
||||
});
|
||||
emitResult({ server: flags.server, results }, format);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { defineCommand, detectOutputFormat, unwrapResponse } from "bailian-cli-core";
|
||||
import { emitResult } from "bailian-cli-runtime";
|
||||
import { printQuotaBox, readNumber, type QuotaSection } from "../usage/quota-box.ts";
|
||||
import { formatNumber } from "../shared/format.ts";
|
||||
import type { HarnessBenefitItem, TokenPlanEquityInfo } from "./types.ts";
|
||||
|
||||
const HARNESS_LIST_API = "zeldaEasy.broadscope-bailian.token-plan.detail";
|
||||
const EQUITY_INFO_API = "zeldaEasy.bailian-commerce.tokenPlan.queryTokenPlanEquityInfo";
|
||||
|
||||
/** One harness that carries an entitlement quota, issued or still pending. */
|
||||
interface HarnessQuotaRow {
|
||||
planCode: string;
|
||||
title: string;
|
||||
type: string;
|
||||
/** `issuing` means the harness carries a quota but no entitlement is issued yet. */
|
||||
status: "issued" | "issuing";
|
||||
quotaUnit: string;
|
||||
instanceId?: string;
|
||||
totalQuota?: number;
|
||||
availableQuota?: number;
|
||||
usedQuota?: number;
|
||||
/** Used ratio in percent with 0.1 precision, matching the console display. */
|
||||
usedPercent?: number;
|
||||
instanceStartTime?: number;
|
||||
instanceEndTime?: number;
|
||||
}
|
||||
|
||||
function readArray<T>(result: unknown, field: string): T[] {
|
||||
const response = unwrapResponse(result as Record<string, unknown>);
|
||||
const value = response[field];
|
||||
return Array.isArray(value) ? (value as T[]) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Join the harness list with the issued entitlements, keeping the server order.
|
||||
* A harness carrying a resource pack but no matching entitlement is still
|
||||
* listed as `issuing` — issuance lags the purchase by a few minutes.
|
||||
*/
|
||||
function buildRows(
|
||||
items: HarnessBenefitItem[],
|
||||
equityInfos: TokenPlanEquityInfo[],
|
||||
): HarnessQuotaRow[] {
|
||||
const rows: HarnessQuotaRow[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
// Only harnesses that carry a resource pack have an entitlement quota at all.
|
||||
if (item.hasResourcePack !== true) continue;
|
||||
|
||||
const planCodes = item.planCodes ?? [];
|
||||
const equity = equityInfos.find(
|
||||
(equityInfo) => equityInfo.equityType && planCodes.includes(equityInfo.equityType),
|
||||
);
|
||||
const planCode = equity?.equityType ?? planCodes[0] ?? "";
|
||||
const row: HarnessQuotaRow = {
|
||||
planCode,
|
||||
title: item.title ?? planCode,
|
||||
type: item.type ?? "",
|
||||
status: equity ? "issued" : "issuing",
|
||||
quotaUnit: item.quotaUnit ?? item.priceInfo?.unit ?? "",
|
||||
};
|
||||
|
||||
if (!equity) {
|
||||
rows.push(row);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (equity.instanceId) row.instanceId = equity.instanceId;
|
||||
const totalQuota = readNumber(equity.totalQuota);
|
||||
if (totalQuota !== undefined) row.totalQuota = totalQuota;
|
||||
const availableQuota = readNumber(equity.availableQuota);
|
||||
if (availableQuota !== undefined) row.availableQuota = availableQuota;
|
||||
if (totalQuota !== undefined && availableQuota !== undefined) {
|
||||
row.usedQuota = Math.max(totalQuota - availableQuota, 0);
|
||||
if (totalQuota > 0) {
|
||||
row.usedPercent = Math.round((row.usedQuota / totalQuota) * 1000) / 10;
|
||||
}
|
||||
}
|
||||
const instanceStartTime = readNumber(equity.instanceStartTime);
|
||||
if (instanceStartTime !== undefined) row.instanceStartTime = instanceStartTime;
|
||||
const instanceEndTime = readNumber(equity.instanceEndTime);
|
||||
if (instanceEndTime !== undefined) row.instanceEndTime = instanceEndTime;
|
||||
|
||||
rows.push(row);
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
function toSection(row: HarnessQuotaRow): QuotaSection {
|
||||
const section: QuotaSection = {
|
||||
label: `${row.title} (${row.planCode})`,
|
||||
emptyMessage:
|
||||
row.status === "issuing"
|
||||
? "Quota is being issued; issuance can take up to 5 minutes."
|
||||
: "No positive quota total reported; check the Bailian Token Plan console.",
|
||||
};
|
||||
if (row.status === "issuing") return section;
|
||||
|
||||
if (row.totalQuota !== undefined && row.usedQuota !== undefined) {
|
||||
const unitSuffix = row.quotaUnit ? ` ${row.quotaUnit}` : "";
|
||||
section.detail = `Used: ${formatNumber(row.usedQuota)} / ${formatNumber(row.totalQuota)}${unitSuffix}`;
|
||||
if (row.totalQuota > 0) section.percentage = row.usedQuota / row.totalQuota;
|
||||
}
|
||||
if (row.instanceEndTime !== undefined) section.resetTime = row.instanceEndTime;
|
||||
|
||||
return section;
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
description: {
|
||||
"en-US": "Show Token Plan harness entitlement quota usage",
|
||||
"zh-CN": "查看 Token Plan harness 权益额度用量",
|
||||
},
|
||||
auth: "console",
|
||||
usageArgs: "[flags]",
|
||||
flags: {
|
||||
type: {
|
||||
type: "string",
|
||||
valueHint: "<type>",
|
||||
choices: ["official_tool", "infrastructure"] as const,
|
||||
description: {
|
||||
"en-US": "Filter harness list by type: official_tool, infrastructure",
|
||||
"zh-CN": "按类型筛选 harness 列表:official_tool、infrastructure",
|
||||
},
|
||||
},
|
||||
},
|
||||
exampleArgs: ["", "--type official_tool", "--output json"],
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const format = detectOutputFormat(settings.output);
|
||||
const benefitsData = flags.type ? { type: flags.type } : {};
|
||||
const equityData = { queryTokenPlanEquityInfoRequest: { requestId: randomUUID() } };
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult(
|
||||
{
|
||||
requests: [
|
||||
{ api: HARNESS_LIST_API, data: benefitsData },
|
||||
{ api: EQUITY_INFO_API, data: equityData },
|
||||
],
|
||||
},
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const [benefitsResult, equityResult] = await Promise.all([
|
||||
ctx.client.console(HARNESS_LIST_API, benefitsData),
|
||||
ctx.client.console(EQUITY_INFO_API, equityData),
|
||||
]);
|
||||
const rows = buildRows(
|
||||
readArray<HarnessBenefitItem>(benefitsResult, "items"),
|
||||
readArray<TokenPlanEquityInfo>(equityResult, "tokenPlanEquityInfos"),
|
||||
);
|
||||
|
||||
if (format === "json") {
|
||||
emitResult({ generatedAt: Date.now(), items: rows }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
process.stdout.write("No harness with entitlement quota found.\n");
|
||||
return;
|
||||
}
|
||||
|
||||
printQuotaBox("Token Plan Harness Quota", rows.map(toSection), Date.now());
|
||||
},
|
||||
});
|
||||
@@ -65,3 +65,25 @@ export interface AddOrganizationMemberResponse {
|
||||
SeatAssigned?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
// Console gateway payloads keep the server's camelCase keys, unlike the
|
||||
// PascalCase OpenAPI shapes above.
|
||||
|
||||
export interface HarnessBenefitItem {
|
||||
type?: string;
|
||||
hasResourcePack?: boolean;
|
||||
planCodes?: string[];
|
||||
title?: string;
|
||||
quotaUnit?: string;
|
||||
priceInfo?: { unit?: string } | null;
|
||||
}
|
||||
|
||||
export interface TokenPlanEquityInfo {
|
||||
instanceId?: string;
|
||||
templateCode?: string;
|
||||
equityType?: string;
|
||||
totalQuota?: number;
|
||||
availableQuota?: number;
|
||||
instanceStartTime?: number;
|
||||
instanceEndTime?: number;
|
||||
}
|
||||
|
||||
@@ -68,8 +68,6 @@ export { default as knowledgeCollectionCreate } from "./commands/knowledge/colle
|
||||
export { default as knowledgeCollectionGet } from "./commands/knowledge/collection-get.ts";
|
||||
export { default as knowledgeDocImportOss } from "./commands/knowledge/doc-import-oss.ts";
|
||||
export { default as mcpCall } from "./commands/mcp/call.ts";
|
||||
export { default as mcpConnect } from "./commands/mcp/connect.ts";
|
||||
export { default as mcpDisconnect } from "./commands/mcp/disconnect.ts";
|
||||
export { default as mcpList } from "./commands/mcp/list.ts";
|
||||
export { default as mcpTools } from "./commands/mcp/tools.ts";
|
||||
export { default as searchWeb } from "./commands/search/web.ts";
|
||||
@@ -139,6 +137,7 @@ export { default as tokenPlanListSeats } from "./commands/token-plan/list-seats.
|
||||
export { default as tokenPlanCreateKey } from "./commands/token-plan/create-key.ts";
|
||||
export { default as tokenPlanAssignSeats } from "./commands/token-plan/assign-seats.ts";
|
||||
export { default as tokenPlanAddMember } from "./commands/token-plan/add-member.ts";
|
||||
export { default as tokenPlanHarnessQuota } from "./commands/token-plan/harness-quota.ts";
|
||||
export { default as managedAgentInit } from "./commands/managed-agent/init.ts";
|
||||
export { default as managedAgentValidate } from "./commands/managed-agent/validate.ts";
|
||||
export { default as managedAgentPlan } from "./commands/managed-agent/plan.ts";
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { parse as parseToml } from "smol-toml";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { isDashScopeE2EReady, parseStdoutJson, runCommandHelp, runCommandE2e } from "./helpers.ts";
|
||||
import { MCP_ROUTES } from "./topic-routes.ts";
|
||||
@@ -39,157 +35,6 @@ describe("e2e: mcp", () => {
|
||||
expect(stderr).toMatch(/call|--target|--arg|--json/i);
|
||||
});
|
||||
|
||||
test("mcp connect/disconnect --help 展示原生 Agent 注册参数", async () => {
|
||||
const connect = await runCommandHelp(MCP_ROUTES, ["mcp", "connect", "--help"]);
|
||||
expect(connect.exitCode, connect.stderr).toBe(0);
|
||||
expect(connect.stderr).toMatch(/--server|--transport|streamable-http|--agent/i);
|
||||
expect(connect.stderr).toMatch(/cursor/);
|
||||
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);
|
||||
expect(disconnect.stderr).toMatch(/--server|--agent/i);
|
||||
});
|
||||
|
||||
test("mcp connect 缺少必填参数时退出为用法错误 (2)", async () => {
|
||||
for (const args of [
|
||||
["mcp", "connect", "--transport", "streamable-http", "--agent", "codex"],
|
||||
["mcp", "connect", "--server", "ImageGenerate", "--agent", "codex"],
|
||||
["mcp", "connect", "--server", "ImageGenerate", "--transport", "streamable-http"],
|
||||
]) {
|
||||
const { exitCode } = await runCommandE2e(MCP_ROUTES, [...args, "--quiet"]);
|
||||
expect(exitCode).toBe(2);
|
||||
}
|
||||
});
|
||||
|
||||
test("mcp connect --dry-run 输出端点和 Header 名但不写配置", async () => {
|
||||
const tempHome = mkdtempSync(join(tmpdir(), "bl-mcp-connect-dry-"));
|
||||
try {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(
|
||||
MCP_ROUTES,
|
||||
[
|
||||
"mcp",
|
||||
"connect",
|
||||
"--server",
|
||||
"ImageGenerate",
|
||||
"--transport",
|
||||
"streamable-http",
|
||||
"--agent",
|
||||
"codex",
|
||||
"--base-url",
|
||||
"https://custom-model-gateway.example.com",
|
||||
"--dry-run",
|
||||
"--output",
|
||||
"json",
|
||||
],
|
||||
{
|
||||
HOME: tempHome,
|
||||
CODEX_HOME: join(tempHome, ".codex"),
|
||||
BAILIAN_CONFIG_DIR: join(tempHome, ".bailian"),
|
||||
},
|
||||
);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{
|
||||
server?: string;
|
||||
transport?: string;
|
||||
endpoint?: string;
|
||||
header_names?: string[];
|
||||
}>(stdout);
|
||||
expect(data.server).toBe("ImageGenerate");
|
||||
expect(data.transport).toBe("streamable-http");
|
||||
expect(data.endpoint).toBe("https://dashscope.aliyuncs.com/api/v1/mcps/ImageGenerate/mcp");
|
||||
expect(data.header_names).toEqual(
|
||||
expect.arrayContaining([
|
||||
"Authorization",
|
||||
"x-dashscope-openapisource",
|
||||
"x-dashscope-source-config",
|
||||
]),
|
||||
);
|
||||
expect(existsSync(join(tempHome, ".codex", "config.toml"))).toBe(false);
|
||||
} finally {
|
||||
rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("mcp connect/disconnect 可在隔离 HOME 内完成 Codex 配置闭环", async () => {
|
||||
const tempHome = mkdtempSync(join(tmpdir(), "bl-mcp-connect-write-"));
|
||||
const codexDir = join(tempHome, ".codex");
|
||||
const configDir = join(tempHome, ".bailian");
|
||||
const configPath = join(codexDir, "config.toml");
|
||||
mkdirSync(codexDir, { recursive: true });
|
||||
writeFileSync(configPath, 'model = "gpt-5"\n');
|
||||
const env = { HOME: tempHome, CODEX_HOME: codexDir, BAILIAN_CONFIG_DIR: configDir };
|
||||
|
||||
try {
|
||||
const connected = await runCommandE2e(
|
||||
MCP_ROUTES,
|
||||
[
|
||||
"mcp",
|
||||
"connect",
|
||||
"--server",
|
||||
"ImageGenerate",
|
||||
"--transport",
|
||||
"streamable-http",
|
||||
"--agent",
|
||||
"codex",
|
||||
"--api-key",
|
||||
"sk-test-secret",
|
||||
"--base-url",
|
||||
"https://dashscope.aliyuncs.com",
|
||||
"--output",
|
||||
"json",
|
||||
],
|
||||
env,
|
||||
);
|
||||
expect(connected.exitCode, connected.stderr).toBe(0);
|
||||
const config = parseToml(readFileSync(configPath, "utf8")) as Record<string, unknown>;
|
||||
expect(config.model).toBe("gpt-5");
|
||||
expect((config.mcp_servers as Record<string, unknown>).ImageGenerate).toBeDefined();
|
||||
expect(readFileSync(join(configDir, "mcp-registrations.json"), "utf8")).not.toContain(
|
||||
"sk-test-secret",
|
||||
);
|
||||
|
||||
const preview = await runCommandE2e(
|
||||
MCP_ROUTES,
|
||||
[
|
||||
"mcp",
|
||||
"disconnect",
|
||||
"--server",
|
||||
"ImageGenerate",
|
||||
"--agent",
|
||||
"codex",
|
||||
"--dry-run",
|
||||
"--output",
|
||||
"json",
|
||||
],
|
||||
env,
|
||||
);
|
||||
expect(preview.exitCode, preview.stderr).toBe(0);
|
||||
expect(
|
||||
(parseToml(readFileSync(configPath, "utf8")).mcp_servers as Record<string, unknown>)
|
||||
.ImageGenerate,
|
||||
).toBeDefined();
|
||||
|
||||
const disconnected = await runCommandE2e(
|
||||
MCP_ROUTES,
|
||||
["mcp", "disconnect", "--server", "ImageGenerate", "--agent", "codex", "--output", "json"],
|
||||
env,
|
||||
);
|
||||
expect(disconnected.exitCode, disconnected.stderr).toBe(0);
|
||||
const after = parseToml(readFileSync(configPath, "utf8")) as Record<string, unknown>;
|
||||
expect((after.mcp_servers as Record<string, unknown>).ImageGenerate).toBeUndefined();
|
||||
} finally {
|
||||
rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("mcp list --help 不暴露 --all 入口(市场全量已下线)", async () => {
|
||||
const { stderr, exitCode } = await runCommandHelp(MCP_ROUTES, ["mcp", "list", "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { makeE2eOutputDir, parseStdoutJson, runCommandHelp, runCommandE2e } from "./helpers.ts";
|
||||
import {
|
||||
isConsoleAuthFailure,
|
||||
isConsoleE2EReady,
|
||||
makeE2eOutputDir,
|
||||
parseStdoutJson,
|
||||
runCommandHelp,
|
||||
runCommandE2e,
|
||||
} from "./helpers.ts";
|
||||
import { TOKEN_PLAN_ROUTES } from "./topic-routes.ts";
|
||||
|
||||
describe("e2e: token-plan", () => {
|
||||
@@ -60,4 +67,92 @@ describe("e2e: token-plan", () => {
|
||||
expect(stderr).toMatch(/ALIBABA_CLOUD_ACCESS_KEY_ID/);
|
||||
expect(stderr).not.toMatch(/auth login --api-key/);
|
||||
});
|
||||
|
||||
test("token-plan harness-quota help 展示 --type 与 console 鉴权域 flags", async () => {
|
||||
const { stderr, exitCode } = await runCommandHelp(TOKEN_PLAN_ROUTES, [
|
||||
"token-plan",
|
||||
"harness-quota",
|
||||
"--help",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/--type <official_tool\|infrastructure>/);
|
||||
expect(stderr).toMatch(/--console-region/);
|
||||
expect(stderr).not.toMatch(/--access-key-id/);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!isConsoleE2EReady())("e2e: token-plan harness-quota(Console)", () => {
|
||||
test("harness-quota --dry-run 输出两个网关请求计划", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(TOKEN_PLAN_ROUTES, [
|
||||
"token-plan",
|
||||
"harness-quota",
|
||||
"--dry-run",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{
|
||||
requests?: Array<{ api?: string; data?: Record<string, unknown> }>;
|
||||
}>(stdout);
|
||||
expect(data.requests?.[0]?.api).toBe("zeldaEasy.broadscope-bailian.token-plan.detail");
|
||||
expect(data.requests?.[1]?.api).toBe(
|
||||
"zeldaEasy.bailian-commerce.tokenPlan.queryTokenPlanEquityInfo",
|
||||
);
|
||||
expect(data.requests?.[0]?.data).toEqual({});
|
||||
});
|
||||
|
||||
test("harness-quota --type 透传给 harness 列表接口", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(TOKEN_PLAN_ROUTES, [
|
||||
"token-plan",
|
||||
"harness-quota",
|
||||
"--type",
|
||||
"official_tool",
|
||||
"--dry-run",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ requests?: Array<{ data?: Record<string, unknown> }> }>(stdout);
|
||||
expect(data.requests?.[0]?.data).toEqual({ type: "official_tool" });
|
||||
});
|
||||
|
||||
test("harness-quota --output json 返回权益额度条目", async () => {
|
||||
const result = await runCommandE2e(TOKEN_PLAN_ROUTES, [
|
||||
"token-plan",
|
||||
"harness-quota",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
if (isConsoleAuthFailure(result)) return;
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
const data = parseStdoutJson<{
|
||||
generatedAt?: number;
|
||||
items?: Array<{
|
||||
planCode?: string;
|
||||
status?: string;
|
||||
totalQuota?: number;
|
||||
availableQuota?: number;
|
||||
usedQuota?: number;
|
||||
usedPercent?: number;
|
||||
}>;
|
||||
}>(result.stdout);
|
||||
expect(Array.isArray(data.items)).toBe(true);
|
||||
for (const item of data.items ?? []) {
|
||||
expect(item.planCode).toBeTypeOf("string");
|
||||
expect(["issued", "issuing"]).toContain(item.status);
|
||||
const numbers = [item.totalQuota, item.availableQuota, item.usedQuota, item.usedPercent];
|
||||
for (const value of numbers) {
|
||||
if (value !== undefined) expect(value).toBeTypeOf("number");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("harness-quota 默认渲染额度框或空态文案", async () => {
|
||||
const result = await runCommandE2e(TOKEN_PLAN_ROUTES, ["token-plan", "harness-quota"]);
|
||||
if (isConsoleAuthFailure(result)) return;
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
expect(result.stdout).toMatch(
|
||||
/Token Plan Harness Quota|No harness with entitlement quota found/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -81,8 +81,6 @@ export const SPEECH_ROUTES: E2eRouteExports = {
|
||||
|
||||
export const MCP_ROUTES: E2eRouteExports = {
|
||||
"mcp call": "mcpCall",
|
||||
"mcp connect": "mcpConnect",
|
||||
"mcp disconnect": "mcpDisconnect",
|
||||
"mcp list": "mcpList",
|
||||
"mcp tools": "mcpTools",
|
||||
};
|
||||
@@ -179,6 +177,7 @@ export const TOKEN_PLAN_ROUTES: E2eRouteExports = {
|
||||
"token-plan create-key": "tokenPlanCreateKey",
|
||||
"token-plan assign-seats": "tokenPlanAssignSeats",
|
||||
"token-plan add-member": "tokenPlanAddMember",
|
||||
"token-plan harness-quota": "tokenPlanHarnessQuota",
|
||||
};
|
||||
|
||||
export const SKILL_ROUTES: E2eRouteExports = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, symlinkSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, relative } from "node:path";
|
||||
import { join } from "node:path";
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import {
|
||||
listSkills,
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
listAgents,
|
||||
getSkillDetail,
|
||||
} from "../src/commands/config/inventory.ts";
|
||||
import { qwenworkMcpPath } from "../src/commands/mcp/agent-config.ts";
|
||||
|
||||
/** Build an isolated fake $HOME and clean it up afterwards. */
|
||||
function withHome(fn: (home: string) => void): void {
|
||||
@@ -146,32 +145,6 @@ test("listMcpServers 无配置时返回空数组", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("listMcpServers 区分 Qoder、Qoder Work 与千问办公 QwenWork", () => {
|
||||
withHome((home) => {
|
||||
write(
|
||||
home,
|
||||
".qoder/mcp.json",
|
||||
JSON.stringify({ mcpServers: { qoder: { url: "https://qoder" } } }),
|
||||
);
|
||||
write(
|
||||
home,
|
||||
".qoderwork/mcp.json",
|
||||
JSON.stringify({ mcpServers: { qoderwork: { url: "https://qoderwork" } } }),
|
||||
);
|
||||
write(
|
||||
home,
|
||||
relative(home, qwenworkMcpPath(home)),
|
||||
JSON.stringify({ mcpServers: { qwenwork: { type: "http", url: "https://qwenwork" } } }),
|
||||
);
|
||||
|
||||
const servers = listMcpServers(home);
|
||||
const byName = Object.fromEntries(servers.map((s) => [s.name, s]));
|
||||
expect(byName.qoder).toMatchObject({ source: "qoder" });
|
||||
expect(byName.qoderwork).toMatchObject({ source: "qoderwork" });
|
||||
expect(byName.qwenwork).toMatchObject({ source: "qwenwork" });
|
||||
});
|
||||
});
|
||||
|
||||
test("listAgents 报告安装与已连接 bailian-cli 的状态", () => {
|
||||
withHome((home) => {
|
||||
// Claude Code: installed + configured (base url present).
|
||||
|
||||
@@ -1,512 +0,0 @@
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { parse as parseToml } from "smol-toml";
|
||||
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";
|
||||
|
||||
let home = "";
|
||||
let configDir = "";
|
||||
|
||||
beforeEach(() => {
|
||||
home = mkdtempSync(join(tmpdir(), "bl-mcp-agent-"));
|
||||
configDir = join(home, ".bailian");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function spec(transport: "streamable-http" | "sse" = "streamable-http"): McpConnectionSpec {
|
||||
return {
|
||||
name: "ImageGenerate",
|
||||
serverCode: "ImageGenerate",
|
||||
transport,
|
||||
endpoint: `https://dashscope.aliyuncs.com/api/v1/mcps/ImageGenerate/${transport === "sse" ? "sse" : "mcp"}`,
|
||||
headers: {
|
||||
Authorization: "Bearer sk-test-secret",
|
||||
"x-dashscope-openapisource": "BailianCLI",
|
||||
"x-dashscope-source-config":
|
||||
'{"channel":"bailian-cli","tags":{"t1":"public","t2":"bl","t3":"1.18.2"}}',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function readJson(path: string): Record<string, unknown> {
|
||||
return JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
describe("MCP Agent registration", () => {
|
||||
test("Codex writes a Streamable HTTP server without a type field", () => {
|
||||
const codexDir = join(home, ".codex");
|
||||
mkdirSync(codexDir, { recursive: true });
|
||||
writeFileSync(join(codexDir, "config.toml"), 'model = "gpt-5"\n\n[features]\nfoo = true\n');
|
||||
|
||||
const [result] = connectMcpAgents({
|
||||
agents: ["codex"],
|
||||
spec: spec(),
|
||||
cliVersion: "1.18.2",
|
||||
home,
|
||||
configDir,
|
||||
});
|
||||
|
||||
expect(result.status).toBe("added");
|
||||
const config = parseToml(readFileSync(join(codexDir, "config.toml"), "utf8")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(config.model).toBe("gpt-5");
|
||||
expect(config.features).toEqual({ foo: true });
|
||||
const entry = (config.mcp_servers as Record<string, Record<string, unknown>>).ImageGenerate;
|
||||
expect(entry).toEqual({
|
||||
url: "https://dashscope.aliyuncs.com/api/v1/mcps/ImageGenerate/mcp",
|
||||
http_headers: spec().headers,
|
||||
});
|
||||
expect(entry.type).toBeUndefined();
|
||||
});
|
||||
|
||||
test("Codex rejects SSE before changing its config", () => {
|
||||
const codexDir = join(home, ".codex");
|
||||
const configPath = join(codexDir, "config.toml");
|
||||
mkdirSync(codexDir, { recursive: true });
|
||||
writeFileSync(configPath, 'model = "gpt-5"\n');
|
||||
|
||||
expect(() =>
|
||||
connectMcpAgents({
|
||||
agents: ["codex"],
|
||||
spec: spec("sse"),
|
||||
cliVersion: "1.18.2",
|
||||
home,
|
||||
configDir,
|
||||
}),
|
||||
).toThrow(/Codex.*SSE|SSE.*Codex/);
|
||||
expect(readFileSync(configPath, "utf8")).toBe('model = "gpt-5"\n');
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
agent: "claude-code" as const,
|
||||
path: [".claude.json"],
|
||||
streamable: { type: "http", url: spec().endpoint, headers: spec().headers },
|
||||
sse: { type: "sse", url: spec("sse").endpoint, headers: spec("sse").headers },
|
||||
},
|
||||
{
|
||||
agent: "qwen-code" as const,
|
||||
path: [".qwen", "settings.json"],
|
||||
streamable: { httpUrl: spec().endpoint, headers: spec().headers },
|
||||
sse: { url: spec("sse").endpoint, headers: spec("sse").headers },
|
||||
},
|
||||
{
|
||||
agent: "gemini" as const,
|
||||
path: [".gemini", "settings.json"],
|
||||
streamable: { httpUrl: spec().endpoint, headers: spec().headers },
|
||||
sse: { url: spec("sse").endpoint, headers: spec("sse").headers },
|
||||
},
|
||||
{
|
||||
agent: "cursor" as const,
|
||||
path: [".cursor", "mcp.json"],
|
||||
streamable: { url: spec().endpoint, headers: spec().headers },
|
||||
sse: { url: spec("sse").endpoint, headers: spec("sse").headers },
|
||||
},
|
||||
{
|
||||
agent: "qoder" as const,
|
||||
path: [".qoder", "mcp.json"],
|
||||
streamable: { url: spec().endpoint, headers: spec().headers },
|
||||
sse: { url: spec("sse").endpoint, headers: spec("sse").headers },
|
||||
},
|
||||
{
|
||||
agent: "qoderwork" as const,
|
||||
path: [".qoderwork", "mcp.json"],
|
||||
streamable: { url: spec().endpoint, headers: spec().headers },
|
||||
sse: { url: spec("sse").endpoint, headers: spec("sse").headers },
|
||||
},
|
||||
{
|
||||
agent: "qwenwork" as const,
|
||||
path:
|
||||
process.platform === "darwin"
|
||||
? ["Library", "Application Support", "QwenWorkCN", "mcp.json"]
|
||||
: process.platform === "win32"
|
||||
? ["AppData", "Roaming", "QwenWorkCN", "mcp.json"]
|
||||
: [".config", "QwenWorkCN", "mcp.json"],
|
||||
streamable: { type: "http", url: spec().endpoint, headers: spec().headers },
|
||||
sse: { type: "sse", url: spec("sse").endpoint, headers: spec("sse").headers },
|
||||
},
|
||||
])(
|
||||
"$agent maps both remote transports to its native JSON format",
|
||||
({ agent, path, streamable, sse }) => {
|
||||
const configPath = join(home, ...path);
|
||||
mkdirSync(join(configPath, ".."), { recursive: true });
|
||||
writeFileSync(configPath, JSON.stringify({ keep: { user: true } }));
|
||||
|
||||
connectMcpAgents({
|
||||
agents: [agent],
|
||||
spec: spec(),
|
||||
cliVersion: "1.18.2",
|
||||
home,
|
||||
configDir,
|
||||
});
|
||||
expect(readJson(configPath)).toMatchObject({
|
||||
keep: { user: true },
|
||||
mcpServers: { ImageGenerate: streamable },
|
||||
});
|
||||
|
||||
connectMcpAgents({
|
||||
agents: [agent],
|
||||
spec: spec("sse"),
|
||||
cliVersion: "1.18.2",
|
||||
home,
|
||||
configDir,
|
||||
});
|
||||
expect(readJson(configPath)).toMatchObject({
|
||||
keep: { user: true },
|
||||
mcpServers: { ImageGenerate: sse },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test("reconnect is idempotent and an unmanaged same-name server is never overwritten", () => {
|
||||
const first = connectMcpAgents({
|
||||
agents: ["claude-code"],
|
||||
spec: spec(),
|
||||
cliVersion: "1.18.2",
|
||||
home,
|
||||
configDir,
|
||||
});
|
||||
const second = connectMcpAgents({
|
||||
agents: ["claude-code"],
|
||||
spec: spec(),
|
||||
cliVersion: "1.18.2",
|
||||
home,
|
||||
configDir,
|
||||
});
|
||||
expect(first[0].status).toBe("added");
|
||||
expect(second[0].status).toBe("unchanged");
|
||||
|
||||
const otherHome = mkdtempSync(join(tmpdir(), "bl-mcp-agent-unmanaged-"));
|
||||
try {
|
||||
writeFileSync(
|
||||
join(otherHome, ".claude.json"),
|
||||
JSON.stringify({ mcpServers: { ImageGenerate: { type: "http", url: "https://user" } } }),
|
||||
);
|
||||
expect(() =>
|
||||
connectMcpAgents({
|
||||
agents: ["claude-code"],
|
||||
spec: spec(),
|
||||
cliVersion: "1.18.2",
|
||||
home: otherHome,
|
||||
configDir: join(otherHome, ".bailian"),
|
||||
}),
|
||||
).toThrow(/not managed|conflict/i);
|
||||
expect(
|
||||
(readJson(join(otherHome, ".claude.json")).mcpServers as Record<string, unknown>)
|
||||
.ImageGenerate,
|
||||
).toEqual({ type: "http", url: "https://user" });
|
||||
} finally {
|
||||
rmSync(otherHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("disconnect removes only an unchanged managed entry and never stores the API key in manifest", () => {
|
||||
const claudePath = join(home, ".claude.json");
|
||||
writeFileSync(
|
||||
claudePath,
|
||||
JSON.stringify({ mcpServers: { userServer: { type: "http", url: "https://user" } } }),
|
||||
);
|
||||
connectMcpAgents({
|
||||
agents: ["claude-code"],
|
||||
spec: spec(),
|
||||
cliVersion: "1.18.2",
|
||||
home,
|
||||
configDir,
|
||||
});
|
||||
|
||||
const manifestPath = join(configDir, "mcp-registrations.json");
|
||||
expect(readFileSync(manifestPath, "utf8")).not.toContain("sk-test-secret");
|
||||
|
||||
const [removed] = disconnectMcpAgents({
|
||||
agents: ["claude-code"],
|
||||
name: "ImageGenerate",
|
||||
home,
|
||||
configDir,
|
||||
});
|
||||
expect(removed.status).toBe("removed");
|
||||
const servers = readJson(claudePath).mcpServers as Record<string, unknown>;
|
||||
expect(servers.ImageGenerate).toBeUndefined();
|
||||
expect(servers.userServer).toEqual({ type: "http", url: "https://user" });
|
||||
});
|
||||
|
||||
test("all targets only installed supported agents", () => {
|
||||
mkdirSync(join(home, ".codex"), { recursive: true });
|
||||
mkdirSync(join(home, ".gemini"), { recursive: true });
|
||||
mkdirSync(join(home, ".cursor"), { recursive: true });
|
||||
mkdirSync(join(home, ".qoderwork"), { recursive: true });
|
||||
|
||||
expect(resolveMcpAgentTargets("all", home)).toEqual(["codex", "cursor", "qoderwork", "gemini"]);
|
||||
expect(resolveMcpAgentTargets("qwen-code", home)).toEqual(["qwen-code"]);
|
||||
expect(existsSync(join(home, ".qwen"))).toBe(false);
|
||||
|
||||
mkdirSync(join(qwenworkMcpPath(home), ".."), { recursive: true });
|
||||
expect(resolveMcpAgentTargets("all", home)).toEqual([
|
||||
"codex",
|
||||
"cursor",
|
||||
"qoderwork",
|
||||
"qwenwork",
|
||||
"gemini",
|
||||
]);
|
||||
});
|
||||
|
||||
test("qoderwork writes ~/.qoderwork/mcp.json", () => {
|
||||
mkdirSync(join(home, ".qoderwork"), { recursive: true });
|
||||
const [result] = connectMcpAgents({
|
||||
agents: ["qoderwork"],
|
||||
spec: spec(),
|
||||
cliVersion: "1.18.2",
|
||||
home,
|
||||
configDir,
|
||||
});
|
||||
expect(result.status).toBe("added");
|
||||
expect(result.path).toBe(join(home, ".qoderwork", "mcp.json"));
|
||||
expect(readJson(result.path)).toMatchObject({
|
||||
mcpServers: { ImageGenerate: { url: spec().endpoint, headers: spec().headers } },
|
||||
});
|
||||
});
|
||||
|
||||
test("qwenwork writes Electron userData mcp.json and never uses Qoder Work", () => {
|
||||
mkdirSync(join(home, ".qoderwork"), { recursive: true });
|
||||
const [result] = connectMcpAgents({
|
||||
agents: ["qwenwork"],
|
||||
spec: spec(),
|
||||
cliVersion: "1.18.2",
|
||||
home,
|
||||
configDir,
|
||||
});
|
||||
expect(result.status).toBe("added");
|
||||
expect(result.path).toBe(qwenworkMcpPath(home));
|
||||
expect(result.path).not.toContain(".qoderwork");
|
||||
expect(existsSync(join(home, ".qoderwork", "mcp.json"))).toBe(false);
|
||||
expect(readJson(result.path)).toMatchObject({
|
||||
mcpServers: {
|
||||
ImageGenerate: { type: "http", url: spec().endpoint, headers: spec().headers },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("qwenwork prefers an existing QwenWork mcp.json over creating QwenWorkCN", () => {
|
||||
const intlPath =
|
||||
process.platform === "darwin"
|
||||
? join(home, "Library", "Application Support", "QwenWork", "mcp.json")
|
||||
: process.platform === "win32"
|
||||
? join(home, "AppData", "Roaming", "QwenWork", "mcp.json")
|
||||
: join(home, ".config", "QwenWork", "mcp.json");
|
||||
mkdirSync(join(intlPath, ".."), { recursive: true });
|
||||
writeFileSync(intlPath, JSON.stringify({ keep: true }));
|
||||
|
||||
const [result] = connectMcpAgents({
|
||||
agents: ["qwenwork"],
|
||||
spec: spec(),
|
||||
cliVersion: "1.18.2",
|
||||
home,
|
||||
configDir,
|
||||
});
|
||||
expect(result.path).toBe(intlPath);
|
||||
expect(readJson(intlPath)).toMatchObject({
|
||||
keep: true,
|
||||
mcpServers: {
|
||||
ImageGenerate: { type: "http", url: spec().endpoint, headers: spec().headers },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("disconnecting an unmanaged absent server does not create a manifest", () => {
|
||||
const [result] = disconnectMcpAgents({
|
||||
agents: ["codex"],
|
||||
name: "NotRegistered",
|
||||
home,
|
||||
configDir,
|
||||
});
|
||||
|
||||
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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,247 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vite-plus/test";
|
||||
import harnessQuota from "../src/commands/token-plan/harness-quota.ts";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function captureStdout(): string[] {
|
||||
const output: string[] = [];
|
||||
vi.spyOn(process.stdout, "write").mockImplementation((chunk) => {
|
||||
output.push(String(chunk));
|
||||
return true;
|
||||
});
|
||||
return output;
|
||||
}
|
||||
|
||||
/** Gateway envelope produced by `callConsoleGateway` (DataV2 wrapper included). */
|
||||
function wrapResponse(payload: Record<string, unknown>): Record<string, unknown> {
|
||||
return { data: { DataV2: { data: { data: payload } } } };
|
||||
}
|
||||
|
||||
const IMAGE_HARNESS = {
|
||||
type: "official_tool",
|
||||
hasResourcePack: true,
|
||||
title: "图像生成 MCP",
|
||||
quotaUnit: "张",
|
||||
planCodes: ["tokenplan_harnesstool_image_generation", "tokenplan_harnesstool_image_monthly"],
|
||||
};
|
||||
|
||||
const SEARCH_HARNESS = {
|
||||
type: "official_tool",
|
||||
hasResourcePack: true,
|
||||
title: "联网搜索 MCP",
|
||||
priceInfo: { unit: "次" },
|
||||
planCodes: ["tokenplan_harnesstool_web_search"],
|
||||
};
|
||||
|
||||
/** Harness without an entitlement quota — never shown, same as the console card. */
|
||||
const NO_QUOTA_HARNESS = {
|
||||
type: "infrastructure",
|
||||
hasResourcePack: false,
|
||||
title: "无额度 Harness",
|
||||
planCodes: ["tokenplan_harnesstool_no_quota"],
|
||||
};
|
||||
|
||||
async function runHarnessQuota(
|
||||
items: Record<string, unknown>[],
|
||||
equityInfos: Record<string, unknown>[],
|
||||
options: { output?: string; type?: string } = {},
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
const calls: Record<string, unknown>[] = [];
|
||||
await harnessQuota.run({
|
||||
client: {
|
||||
console: vi.fn().mockImplementation((api: string, data: Record<string, unknown>) => {
|
||||
calls.push({ api, data });
|
||||
return Promise.resolve(
|
||||
api.endsWith("token-plan.detail")
|
||||
? wrapResponse({ items })
|
||||
: wrapResponse({ userId: "1256099523640572", tokenPlanEquityInfos: equityInfos }),
|
||||
);
|
||||
}),
|
||||
},
|
||||
flags: options.type ? { type: options.type } : {},
|
||||
settings: { dryRun: false, output: options.output },
|
||||
} as never);
|
||||
return calls;
|
||||
}
|
||||
|
||||
describe("token-plan harness-quota view", () => {
|
||||
test("renders one gauge per harness with issued quota", async () => {
|
||||
const output = captureStdout();
|
||||
|
||||
await runHarnessQuota(
|
||||
[IMAGE_HARNESS, NO_QUOTA_HARNESS],
|
||||
[
|
||||
{
|
||||
instanceId: "instance-1",
|
||||
equityType: "tokenplan_harnesstool_image_generation",
|
||||
totalQuota: 100,
|
||||
availableQuota: 60,
|
||||
instanceStartTime: 1_786_000_000_000,
|
||||
instanceEndTime: 1_788_000_000_000,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
const rendered = output.join("");
|
||||
expect(rendered).toContain("Token Plan Harness Quota");
|
||||
expect(rendered).toContain("图像生成 MCP (tokenplan_harnesstool_image_generation)");
|
||||
expect(rendered).toContain("40% used");
|
||||
expect(rendered).toContain("Used: 40 / 100 张");
|
||||
expect(rendered).toMatch(/Resets: \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/);
|
||||
// Harness without a resource pack has no entitlement quota at all.
|
||||
expect(rendered).not.toContain("无额度 Harness");
|
||||
});
|
||||
|
||||
test("marks a harness whose entitlement is not issued yet as issuing", async () => {
|
||||
const output = captureStdout();
|
||||
|
||||
await runHarnessQuota(
|
||||
[IMAGE_HARNESS, SEARCH_HARNESS],
|
||||
[
|
||||
{
|
||||
equityType: "tokenplan_harnesstool_image_generation",
|
||||
totalQuota: 100,
|
||||
availableQuota: 60,
|
||||
instanceEndTime: 1_788_000_000_000,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
const rendered = output.join("");
|
||||
// Pending harness keeps its first plan code and shows no gauge or reset line.
|
||||
expect(rendered).toContain("联网搜索 MCP (tokenplan_harnesstool_web_search)");
|
||||
expect(rendered).toContain("Quota is being issued; issuance can take up to 5 minutes.");
|
||||
expect(rendered).not.toContain("Resets: not applicable");
|
||||
});
|
||||
|
||||
test("falls back to the price unit when quotaUnit is absent", async () => {
|
||||
const output = captureStdout();
|
||||
|
||||
await runHarnessQuota(
|
||||
[SEARCH_HARNESS],
|
||||
[
|
||||
{
|
||||
equityType: "tokenplan_harnesstool_web_search",
|
||||
totalQuota: 2000,
|
||||
availableQuota: 500,
|
||||
instanceEndTime: 1_788_000_000_000,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
expect(output.join("")).toContain("Used: 1,500 / 2,000 次");
|
||||
});
|
||||
|
||||
test("reports a non-positive quota total instead of a gauge", async () => {
|
||||
const output = captureStdout();
|
||||
|
||||
await runHarnessQuota(
|
||||
[IMAGE_HARNESS],
|
||||
[
|
||||
{
|
||||
equityType: "tokenplan_harnesstool_image_monthly",
|
||||
totalQuota: 0,
|
||||
availableQuota: 0,
|
||||
instanceEndTime: 1_788_000_000_000,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
expect(output.join("")).toContain(
|
||||
"No positive quota total reported; check the Bailian Token Plan console.",
|
||||
);
|
||||
});
|
||||
|
||||
test("prints the empty state when no harness carries an entitlement quota", async () => {
|
||||
const output = captureStdout();
|
||||
|
||||
await runHarnessQuota([NO_QUOTA_HARNESS], []);
|
||||
|
||||
expect(output.join("")).toBe("No harness with entitlement quota found.\n");
|
||||
});
|
||||
|
||||
test("passes --type through to the harness list API only", async () => {
|
||||
captureStdout();
|
||||
|
||||
const calls = await runHarnessQuota([], [], { type: "infrastructure" });
|
||||
|
||||
expect(calls[0]).toEqual({
|
||||
api: "zeldaEasy.broadscope-bailian.token-plan.detail",
|
||||
data: { type: "infrastructure" },
|
||||
});
|
||||
expect(calls[1]?.api).toBe("zeldaEasy.bailian-commerce.tokenPlan.queryTokenPlanEquityInfo");
|
||||
});
|
||||
});
|
||||
|
||||
describe("token-plan harness-quota json", () => {
|
||||
test("emits the joined quota fields with --output json", async () => {
|
||||
const output = captureStdout();
|
||||
|
||||
await runHarnessQuota(
|
||||
[IMAGE_HARNESS, SEARCH_HARNESS],
|
||||
[
|
||||
{
|
||||
instanceId: "instance-1",
|
||||
equityType: "tokenplan_harnesstool_image_generation",
|
||||
totalQuota: 100,
|
||||
availableQuota: 60,
|
||||
instanceStartTime: 1_786_000_000_000,
|
||||
instanceEndTime: 1_788_000_000_000,
|
||||
},
|
||||
],
|
||||
{ output: "json" },
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(output.join("")) as { items: Record<string, unknown>[] };
|
||||
expect(parsed.items).toEqual([
|
||||
{
|
||||
planCode: "tokenplan_harnesstool_image_generation",
|
||||
title: "图像生成 MCP",
|
||||
type: "official_tool",
|
||||
status: "issued",
|
||||
quotaUnit: "张",
|
||||
instanceId: "instance-1",
|
||||
totalQuota: 100,
|
||||
availableQuota: 60,
|
||||
usedQuota: 40,
|
||||
usedPercent: 40,
|
||||
instanceStartTime: 1_786_000_000_000,
|
||||
instanceEndTime: 1_788_000_000_000,
|
||||
},
|
||||
{
|
||||
planCode: "tokenplan_harnesstool_web_search",
|
||||
title: "联网搜索 MCP",
|
||||
type: "official_tool",
|
||||
status: "issuing",
|
||||
quotaUnit: "次",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("treats non-numeric quota fields as absent instead of failing", async () => {
|
||||
const output = captureStdout();
|
||||
|
||||
await runHarnessQuota(
|
||||
[IMAGE_HARNESS],
|
||||
[
|
||||
{
|
||||
equityType: "tokenplan_harnesstool_image_generation",
|
||||
totalQuota: "not-a-number",
|
||||
availableQuota: Number.NaN,
|
||||
},
|
||||
],
|
||||
{ output: "json" },
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(output.join("")) as { items: Record<string, unknown>[] };
|
||||
expect(parsed.items[0]).toEqual({
|
||||
planCode: "tokenplan_harnesstool_image_generation",
|
||||
title: "图像生成 MCP",
|
||||
type: "official_tool",
|
||||
status: "issued",
|
||||
quotaUnit: "张",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bailian-cli-core",
|
||||
"version": "1.24.0",
|
||||
"version": "1.25.0",
|
||||
"description": "Core SDK for bailian-cli. See https://www.npmjs.com/package/bailian-cli for usage.",
|
||||
"homepage": "https://bailian.console.aliyun.com/cli",
|
||||
"bugs": {
|
||||
|
||||
@@ -110,18 +110,6 @@ export class Client {
|
||||
return this.deps.apiCred;
|
||||
}
|
||||
|
||||
/**
|
||||
* Headers for registering a Bailian MCP endpoint in an external Agent.
|
||||
* Credential resolution and channel attribution remain owned by the client.
|
||||
*/
|
||||
bailianMcpRegistrationHeaders(): Record<string, string> {
|
||||
const credential = this.requireApi();
|
||||
return {
|
||||
Authorization: `Bearer ${credential.token}`,
|
||||
...trackingHeaders(this.deps.identity),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Full URL for a model-domain path. Services may supply a lazy default origin,
|
||||
* evaluated only when no flag/env/profile base URL was configured.
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { Client } from "../src/client/client.ts";
|
||||
|
||||
describe("Client.bailianMcpRegistrationHeaders", () => {
|
||||
test("returns API authorization and CLI channel attribution headers", () => {
|
||||
const client = new Client({
|
||||
identity: {
|
||||
binName: "bl",
|
||||
version: "1.18.2",
|
||||
npmPackage: "bailian-cli",
|
||||
clientName: "bailian-cli-test",
|
||||
},
|
||||
settings: {
|
||||
output: "json",
|
||||
outputExplicit: false,
|
||||
timeout: 30,
|
||||
watermark: true,
|
||||
verbose: false,
|
||||
quiet: true,
|
||||
dryRun: false,
|
||||
telemetry: false,
|
||||
},
|
||||
baseUrl: "https://dashscope.aliyuncs.com",
|
||||
apiCred: {
|
||||
token: "sk-test",
|
||||
baseUrl: "https://dashscope.aliyuncs.com",
|
||||
source: "flag",
|
||||
},
|
||||
});
|
||||
|
||||
expect(client.bailianMcpRegistrationHeaders()).toEqual({
|
||||
Authorization: "Bearer sk-test",
|
||||
"x-dashscope-openapisource": "BailianCLI",
|
||||
"x-dashscope-source-config":
|
||||
'{"channel":"bailian-cli","tags":{"t1":"public","t2":"bl","t3":"1.18.2"}}',
|
||||
});
|
||||
});
|
||||
|
||||
test("requires a model-domain credential", () => {
|
||||
const client = new Client({
|
||||
identity: {
|
||||
binName: "bl",
|
||||
version: "test",
|
||||
npmPackage: "bailian-cli",
|
||||
clientName: "bailian-cli-test",
|
||||
},
|
||||
settings: {
|
||||
output: "json",
|
||||
outputExplicit: false,
|
||||
timeout: 30,
|
||||
watermark: true,
|
||||
verbose: false,
|
||||
quiet: true,
|
||||
dryRun: false,
|
||||
telemetry: false,
|
||||
},
|
||||
baseUrl: "https://dashscope.aliyuncs.com",
|
||||
});
|
||||
|
||||
expect(() => client.bailianMcpRegistrationHeaders()).toThrow(/model-domain API key/);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "knowledge-studio-cli",
|
||||
"version": "1.24.0",
|
||||
"version": "1.25.0",
|
||||
"description": "Lightweight RAG CLI for Aliyun Model Studio — focused on knowledge-base retrieval.",
|
||||
"keywords": [
|
||||
"alibaba-cloud",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bailian-cli-runtime",
|
||||
"version": "1.24.0",
|
||||
"version": "1.25.0",
|
||||
"description": "Runtime framework for bailian-cli (createCli, registry, args, output, pipeline). See https://www.npmjs.com/package/bailian-cli for usage.",
|
||||
"homepage": "https://bailian.console.aliyun.com/cli",
|
||||
"bugs": {
|
||||
|
||||
+27
-27
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: bailian-cli
|
||||
metadata:
|
||||
version: "1.24.0"
|
||||
version: "1.25.0"
|
||||
requires:
|
||||
bins: ["bl"]
|
||||
description: >-
|
||||
@@ -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, 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` |
|
||||
| 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` | — |
|
||||
| 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 |
|
||||
| Token Plan harness entitlement quota | `bl token-plan harness-quota` | Console auth; issued quota usage + still-issuing harnesses |
|
||||
| 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/`.
|
||||
|
||||
|
||||
@@ -61,8 +61,6 @@ Use this index for the skill-scoped quick index and global flags.
|
||||
| `bl knowledge stats` | API Key | Show knowledge base storage and QPS monitoring data | [knowledge.md](knowledge.md) |
|
||||
| `bl knowledge update` | API Key | Update knowledge base name, description or rerank threshold | [knowledge.md](knowledge.md) |
|
||||
| `bl mcp call` | API Key | Call a tool on an MCP server (tools/call) | [mcp.md](mcp.md) |
|
||||
| `bl mcp connect` | API Key | Register a Bailian MCP server in an Agent's native configuration | [mcp.md](mcp.md) |
|
||||
| `bl mcp disconnect` | No Auth | Remove an unchanged MCP registration previously managed by bailian-cli | [mcp.md](mcp.md) |
|
||||
| `bl mcp list` | Console | List MCP servers activated under your Bailian account | [mcp.md](mcp.md) |
|
||||
| `bl mcp tools` | API Key | List tools exposed by an MCP server (tools/list) | [mcp.md](mcp.md) |
|
||||
| `bl memory add` | API Key | Add memory from messages or custom content | [memory.md](memory.md) |
|
||||
@@ -97,6 +95,7 @@ Use this index for the skill-scoped quick index and global flags.
|
||||
| `bl token-plan add-member` | AK/SK | Add a member to a Token Plan organization | [token-plan.md](token-plan.md) |
|
||||
| `bl token-plan assign-seats` | AK/SK | Batch assign Token Plan seats to members | [token-plan.md](token-plan.md) |
|
||||
| `bl token-plan create-key` | AK/SK | Create a Token Plan API key for a seat | [token-plan.md](token-plan.md) |
|
||||
| `bl token-plan harness-quota` | Console | Show Token Plan harness entitlement quota usage | [token-plan.md](token-plan.md) |
|
||||
| `bl token-plan list-seats` | AK/SK | List Token Plan subscription seat details | [token-plan.md](token-plan.md) |
|
||||
| `bl update` | No Auth | Update the CLI to the latest or a specified version | [update.md](update.md) |
|
||||
| `bl usage coding-plan` | Console | Show Coding Plan quota usage | [usage.md](usage.md) |
|
||||
@@ -119,7 +118,7 @@ Use this index for the skill-scoped quick index and global flags.
|
||||
| `console` | `call` | [console.md](console.md) |
|
||||
| `file` | `upload` | [file.md](file.md) |
|
||||
| `knowledge` | `category add`, `category delete`, `category list`, `chat`, `chunk add`, `chunk delete`, `chunk list`, `chunk update`, `collection create`, `collection get`, `create`, `delete`, `doc delete`, `doc import-oss`, `doc list`, `doc status`, `doc tag`, `doc upload`, `file delete`, `file get`, `file list`, `info`, `list`, `retrieve`, `search`, `service copy`, `service create`, `service delete`, `service deploy`, `service get`, `service list`, `service update`, `stats`, `update` | [knowledge.md](knowledge.md) |
|
||||
| `mcp` | `call`, `connect`, `disconnect`, `list`, `tools` | [mcp.md](mcp.md) |
|
||||
| `mcp` | `call`, `list`, `tools` | [mcp.md](mcp.md) |
|
||||
| `memory` | `add`, `delete`, `list`, `profile create`, `profile get`, `search`, `update` | [memory.md](memory.md) |
|
||||
| `model` | `list` | [model.md](model.md) |
|
||||
| `permission` | `grant`, `list`, `revoke` | [permission.md](permission.md) |
|
||||
@@ -129,7 +128,7 @@ Use this index for the skill-scoped quick index and global flags.
|
||||
| `search` | `web` | [search.md](search.md) |
|
||||
| `skill` | `add`, `init`, `list`, `remove`, `update` | [skill.md](skill.md) |
|
||||
| `text` | `chat` | [text.md](text.md) |
|
||||
| `token-plan` | `add-member`, `assign-seats`, `create-key`, `list-seats` | [token-plan.md](token-plan.md) |
|
||||
| `token-plan` | `add-member`, `assign-seats`, `create-key`, `harness-quota`, `list-seats` | [token-plan.md](token-plan.md) |
|
||||
| `update` | `(root)` | [update.md](update.md) |
|
||||
| `usage` | `coding-plan`, `free`, `freetier`, `stats`, `summary`, `token-plan` | [usage.md](usage.md) |
|
||||
| `workspace` | `init`, `list` | [workspace.md](workspace.md) |
|
||||
|
||||
@@ -7,13 +7,11 @@ Index: [index.md](index.md)
|
||||
|
||||
## Commands in this group
|
||||
|
||||
| Command | Authentication | Description |
|
||||
| ------------------- | -------------- | ---------------------------------------------------------------------- |
|
||||
| `bl mcp call` | API Key | Call a tool on an MCP server (tools/call) |
|
||||
| `bl mcp connect` | API Key | Register a Bailian MCP server in an Agent's native configuration |
|
||||
| `bl mcp disconnect` | No Auth | Remove an unchanged MCP registration previously managed by bailian-cli |
|
||||
| `bl mcp list` | Console | List MCP servers activated under your Bailian account |
|
||||
| `bl mcp tools` | API Key | List tools exposed by an MCP server (tools/list) |
|
||||
| Command | Authentication | Description |
|
||||
| -------------- | -------------- | ----------------------------------------------------- |
|
||||
| `bl mcp call` | API Key | Call a tool on an MCP server (tools/call) |
|
||||
| `bl mcp list` | Console | List MCP servers activated under your Bailian account |
|
||||
| `bl mcp tools` | API Key | List tools exposed by an MCP server (tools/list) |
|
||||
|
||||
## Command details
|
||||
|
||||
@@ -52,76 +50,6 @@ bl mcp call --target market-cmapi00073529.FinQuery --json '{"q":"Guizhou Maotai"
|
||||
bl mcp call --target market-cmapi00073529.SmartFundSelection --arg riskLevel=R3 --arg minScale=10
|
||||
```
|
||||
|
||||
### `bl mcp connect`
|
||||
|
||||
| Field | Value |
|
||||
| ------------------ | ---------------------------------------------------------------------------------------- |
|
||||
| **Name** | `mcp connect` |
|
||||
| **Description** | Register a Bailian MCP server in an Agent's native configuration |
|
||||
| **Authentication** | API Key |
|
||||
| **Usage** | `bl mcp connect --server <code> --transport <streamable-http\|sse> --agent <agent\|all>` |
|
||||
|
||||
#### 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\|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
|
||||
|
||||
```bash
|
||||
bl mcp connect --server TextGenerateImage --transport streamable-http --agent cursor
|
||||
```
|
||||
|
||||
```bash
|
||||
bl mcp connect --server VideoGenerate --transport sse --agent claude-code
|
||||
```
|
||||
|
||||
```bash
|
||||
bl mcp connect --server TextGenerateImage --transport streamable-http --agent all
|
||||
```
|
||||
|
||||
### `bl mcp disconnect`
|
||||
|
||||
| Field | Value |
|
||||
| ------------------ | ---------------------------------------------------------------------- |
|
||||
| **Name** | `mcp disconnect` |
|
||||
| **Description** | Remove an unchanged MCP registration previously managed by bailian-cli |
|
||||
| **Authentication** | No Auth |
|
||||
| **Usage** | `bl mcp disconnect --server <code> --agent <agent\|all>` |
|
||||
|
||||
#### 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\|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
|
||||
|
||||
- A registration changed after connect is left untouched and reported as a conflict.
|
||||
|
||||
#### Examples
|
||||
|
||||
```bash
|
||||
bl mcp disconnect --server TextGenerateImage --agent cursor
|
||||
```
|
||||
|
||||
```bash
|
||||
bl mcp disconnect --server TextGenerateImage --agent all
|
||||
```
|
||||
|
||||
### `bl mcp list`
|
||||
|
||||
| Field | Value |
|
||||
|
||||
@@ -7,12 +7,13 @@ Index: [index.md](index.md)
|
||||
|
||||
## Commands in this group
|
||||
|
||||
| Command | Authentication | Description |
|
||||
| ---------------------------- | -------------- | ----------------------------------------- |
|
||||
| `bl token-plan add-member` | AK/SK | Add a member to a Token Plan organization |
|
||||
| `bl token-plan assign-seats` | AK/SK | Batch assign Token Plan seats to members |
|
||||
| `bl token-plan create-key` | AK/SK | Create a Token Plan API key for a seat |
|
||||
| `bl token-plan list-seats` | AK/SK | List Token Plan subscription seat details |
|
||||
| Command | Authentication | Description |
|
||||
| ----------------------------- | -------------- | ----------------------------------------------- |
|
||||
| `bl token-plan add-member` | AK/SK | Add a member to a Token Plan organization |
|
||||
| `bl token-plan assign-seats` | AK/SK | Batch assign Token Plan seats to members |
|
||||
| `bl token-plan create-key` | AK/SK | Create a Token Plan API key for a seat |
|
||||
| `bl token-plan harness-quota` | Console | Show Token Plan harness entitlement quota usage |
|
||||
| `bl token-plan list-seats` | AK/SK | List Token Plan subscription seat details |
|
||||
|
||||
## Command details
|
||||
|
||||
@@ -118,6 +119,39 @@ bl token-plan create-key --account-id acc_123 --workspace-id ws_456
|
||||
bl token-plan create-key --account-id acc_123 --workspace-id ws_456 --description 'Dev key'
|
||||
```
|
||||
|
||||
### `bl token-plan harness-quota`
|
||||
|
||||
| Field | Value |
|
||||
| ------------------ | ----------------------------------------------- |
|
||||
| **Name** | `token-plan harness-quota` |
|
||||
| **Description** | Show Token Plan harness entitlement quota usage |
|
||||
| **Authentication** | Console |
|
||||
| **Usage** | `bl token-plan harness-quota [flags]` |
|
||||
|
||||
#### Flags
|
||||
|
||||
| Flag | Type | Required | Description |
|
||||
| ---------------------------------------- | ------ | -------- | ---------------------------------------------------------- |
|
||||
| `--type <official_tool\|infrastructure>` | string | no | Filter harness list by type: official_tool, infrastructure |
|
||||
| `--console-region <region>` | string | no | Console gateway region (e.g. cn-beijing, ap-southeast-1) |
|
||||
| `--console-site <site>` | string | no | Console site: domestic, international |
|
||||
| `--console-switch-agent <uid>` | number | no | Switch agent UID for delegated access |
|
||||
| `--workspace-id <id>` | string | no | Workspace ID (env: BAILIAN_WORKSPACE_ID) |
|
||||
|
||||
#### Examples
|
||||
|
||||
```bash
|
||||
bl token-plan harness-quota
|
||||
```
|
||||
|
||||
```bash
|
||||
bl token-plan harness-quota --type official_tool
|
||||
```
|
||||
|
||||
```bash
|
||||
bl token-plan harness-quota --output json
|
||||
```
|
||||
|
||||
### `bl token-plan list-seats`
|
||||
|
||||
| Field | Value |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: bailian-finetune
|
||||
metadata:
|
||||
version: "1.24.0"
|
||||
version: "1.25.0"
|
||||
requires:
|
||||
bins: ["bl"]
|
||||
description: >-
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: bailian-gen
|
||||
metadata:
|
||||
version: "1.24.0"
|
||||
version: "1.25.0"
|
||||
requires:
|
||||
bins: ["bl"]
|
||||
description: >-
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: bailian-managed-agent
|
||||
metadata:
|
||||
version: "1.24.0"
|
||||
version: "1.25.0"
|
||||
requires:
|
||||
bins: ["bl"]
|
||||
description: >-
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: bailian-protocol
|
||||
metadata:
|
||||
version: "1.24.0"
|
||||
version: "1.25.0"
|
||||
requires:
|
||||
bins: ["bl"]
|
||||
description: >-
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: bailian-sandbox
|
||||
metadata:
|
||||
version: "1.24.0"
|
||||
version: "1.25.0"
|
||||
requires:
|
||||
bins: ["bl"]
|
||||
description: >-
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: bailian-web-search
|
||||
metadata:
|
||||
version: "1.24.0"
|
||||
version: "1.25.0"
|
||||
requires:
|
||||
bins: ["bl"]
|
||||
description: >-
|
||||
|
||||
Reference in New Issue
Block a user