Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ _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
- **Native MCP setup** — Register Bailian MCP servers in Codex, Claude Code, Cursor, Qoder, Qoder Work, QwenWork (千问办公), Qwen Code, Gemini CLI, or Pi 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
Expand Down
2 changes: 1 addition & 1 deletion README.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_
- **模型生成** — 文本、图像、视频、语音全模态生成,支持编辑与参考生成
- **素材理解** — 图像、文档、音频、长视频的解析与问答
- **应用编排** — 调用百炼已发布的 Managed Agent、智能体和工作流,接入知识库、记忆库、联网搜索与 MCP 工具
- **原生 MCP 接入** — 将百炼 MCP 服务注册到 Codex、Claude Code、Cursor、Qoder、Qoder Work、千问办公(QwenWork)、Qwen Code 或 Gemini CLI,并保留渠道归因
- **原生 MCP 接入** — 将百炼 MCP 服务注册到 Codex、Claude Code、Cursor、Qoder、Qoder Work、千问办公(QwenWork)、Qwen Code、Gemini CLI 或 Pi,并保留渠道归因
- **模型训推** — 数据集校验上传、模型精调、专属模型部署上线
- **账号运维** — 授权登录、界面化配置、模型市场、用量与额度、限流提额、团队席位管理
- **套餐接入** — 支持 Token Plan 等订阅计划一键接到 CLI 和常见 Coding Agent
Expand Down
1 change: 1 addition & 0 deletions packages/commands/src/commands/config/agent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ export default defineCommand({
model,
contextWindow,
wireApi,
localize: ctx.localize,
};
const summary = agentDef.write(params);

Expand Down
13 changes: 13 additions & 0 deletions packages/commands/src/commands/config/agent/pi-home.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { homedir } from "node:os";
import { join } from "node:path";

/** Pi's official config directory override, shared by model and MCP writers. */
export function getPiAgentDir(home: string = homedir()): string {
const configured = process.env.PI_CODING_AGENT_DIR;
if (!configured) return join(home, ".pi", "agent");
if (configured === "~") return home;
if (configured.startsWith("~/") || configured.startsWith("~\\")) {
return join(home, configured.slice(2));
}
return configured;
}
2 changes: 2 additions & 0 deletions packages/commands/src/commands/config/agent/writers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import opencode from "./writers/opencode.ts";
import openclaw from "./writers/openclaw.ts";
import hermes from "./writers/hermes.ts";
import codex from "./writers/codex.ts";
import pi from "./writers/pi.ts";

export const AGENTS: Record<string, AgentDef> = {
"claude-code": claudeCode,
Expand All @@ -15,6 +16,7 @@ export const AGENTS: Record<string, AgentDef> = {
openclaw,
hermes,
codex,
pi,
};

export const VALID_AGENT_NAMES = Object.keys(AGENTS) as [string, ...string[]];
117 changes: 117 additions & 0 deletions packages/commands/src/commands/config/agent/writers/pi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { BailianError, ExitCode, type LocalizedText } from "bailian-cli-core";
import { getPiAgentDir } from "../pi-home.ts";
import {
backup,
isAnthropicEndpoint,
stripJsonc,
writeJsonAtomic,
type AgentDef,
} from "./utils.ts";

const PROVIDER_ID = "bailian-cli";

function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

export default {
label: "Pi",
write({
baseUrl,
apiKey,
model,
localize = (text: LocalizedText) => (typeof text === "string" ? text : text["en-US"]),
}) {
const agentDir = getPiAgentDir();
const paths = ["models.json", "auth.json", "settings.json"].map((filename) =>
join(agentDir, filename),
);
const originals = paths.map((path) =>
existsSync(path) ? readFileSync(path, "utf8") : undefined,
);
// Read every file before writing any of them. Malformed user config must
// fail closed rather than silently discarding providers or credentials.
const configs = paths.map((path, index) => {
try {
const original = originals[index];
const content = original?.replace(/^\uFEFF/, "");
const parsed: unknown =
content === undefined ? {} : JSON.parse(index === 0 ? stripJsonc(content) : content);
if (!isObject(parsed)) throw new Error("root value is not an object");
return parsed;
} catch {
throw new BailianError(
localize({
"en-US": `Cannot update Pi because ${path} is invalid; no configuration was changed.`,
"zh-CN": `无法更新 Pi:${path} 格式无效,配置未被修改。`,
}),
ExitCode.GENERAL,
);
}
});
const [modelsConfig, authConfig, settingsConfig] = configs;
if (modelsConfig.providers !== undefined && !isObject(modelsConfig.providers)) {
throw new BailianError(
localize({
"en-US":
"Cannot update Pi because models.json providers is not an object; no configuration was changed.",
"zh-CN": "无法更新 Pi:models.json 中的 providers 必须为对象,配置未被修改。",
}),
ExitCode.GENERAL,
);
}
const providers = (modelsConfig.providers ?? {}) as Record<string, unknown>;
const existing = providers[PROVIDER_ID];
if (
existing !== undefined &&
(!isObject(existing) || (existing.models !== undefined && !Array.isArray(existing.models)))
) {
throw new BailianError(
localize({
"en-US":
"Cannot update Pi because the bailian-cli provider is invalid; no configuration was changed.",
"zh-CN": "无法更新 Pi:bailian-cli provider 格式无效,配置未被修改。",
}),
ExitCode.GENERAL,
);
}
const provider = (existing ?? {}) as Record<string, unknown>;
const models = (provider.models ?? []) as Record<string, unknown>[];
const existingModel = models.find((entry) => entry.id === model);
const updatedModel: Record<string, unknown> = {
...existingModel,
id: model,
name: existingModel?.name ?? model,
};
// Pi gives model-level connection fields precedence over provider defaults.
// Reconfiguration must use the newly selected endpoint and protocol.
delete updatedModel.baseUrl;
delete updatedModel.api;
provider.models = existingModel
? models.map((entry) => (entry.id === model ? updatedModel : entry))
: [...models, updatedModel];
provider.baseUrl = baseUrl;
provider.api = isAnthropicEndpoint(baseUrl) ? "anthropic-messages" : "openai-completions";
// Authentication belongs in Pi's private credential store, not model metadata.
delete provider.apiKey;
providers[PROVIDER_ID] = provider;
modelsConfig.providers = providers;
authConfig[PROVIDER_ID] = { type: "api_key", key: apiKey };
settingsConfig.defaultProvider = PROVIDER_ID;
settingsConfig.defaultModel = model;

for (const [index, path] of paths.entries()) {
backup(path);
writeJsonAtomic(path, configs[index]);
}
return {
paths,
nextStep: localize({
"en-US": "Run `pi` to start using Pi with DashScope.",
"zh-CN": "运行 `pi` 开始使用百炼模型。",
}),
};
},
} satisfies AgentDef;
4 changes: 3 additions & 1 deletion packages/commands/src/commands/config/agent/writers/utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { dirname } from "path";
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, copyFileSync } from "fs";
import { BailianError, ExitCode } from "bailian-cli-core";
import { BailianError, ExitCode, type LocalizedText } from "bailian-cli-core";

/** Parameters shared by every agent writer. */
export interface WriteParams {
Expand All @@ -11,6 +11,8 @@ export interface WriteParams {
contextWindow?: number;
/** Codex provider wire protocol: "responses" or "chat". */
wireApi?: string;
/** Locale selection for new agent-specific messages. */
localize?: (text: LocalizedText) => string;
}

/** What a writer reports back after configuring an agent. */
Expand Down
7 changes: 7 additions & 0 deletions packages/commands/src/commands/mcp/agent-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ 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 { getPiAgentDir } from "../config/agent/pi-home.ts";
import {
backup,
stripJsonc,
Expand All @@ -25,6 +26,7 @@ export const MCP_AGENT_IDS = [
"deepseek-harness",
"zcode",
"workbuddy",
"pi",
] as const;

const DSH_MCP_PLUGIN = "@deepseek-ai/dsh-mcp-client";
Expand Down Expand Up @@ -345,6 +347,11 @@ function jsonMcpAdapter(options: {
}

const adapters: Record<NativeMcpAgent, AgentAdapter> = {
pi: jsonMcpAdapter({
path: (home) => join(getPiAgentDir(home), "mcp.json"),
installed: (home) => existsSync(getPiAgentDir(home)),
supports: (transport) => transport === "streamable-http",
}),
codex: {
path: (home) => join(process.env.CODEX_HOME || join(home, ".codex"), "config.toml"),
installed: (home) =>
Expand Down
143 changes: 143 additions & 0 deletions packages/commands/tests/e2e/pi-agent.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, test } from "vite-plus/test";
import { parseStdoutJson, runCommandE2e, runCommandHelp } from "./helpers.ts";
import { CONFIG_ROUTES, MCP_ROUTES } from "./topic-routes.ts";

const PLACEHOLDER_KEY = "sk-pi-e2e-placeholder-secret";
let probeRoot: string;
let agentDir: string;
let probeEnv: NodeJS.ProcessEnv;

beforeEach(() => {
probeRoot = mkdtempSync(join(tmpdir(), "bl-pi-e2e-"));
agentDir = join(probeRoot, "pi-agent");
probeEnv = { PI_CODING_AGENT_DIR: agentDir, BAILIAN_CONFIG_DIR: join(probeRoot, "bailian") };
});

afterEach(() => rmSync(probeRoot, { recursive: true, force: true }));

const modelArgs = [
"config",
"agent",
"--agent",
"pi",
"--region",
"ap-southeast-1",
"--api-key",
PLACEHOLDER_KEY,
"--model",
"qwen3-coder-plus",
];
const mcpArgs = [
"mcp",
"connect",
"--agent",
"pi",
"--server",
"TextGenerateImage",
"--transport",
"streamable-http",
"--api-key",
PLACEHOLDER_KEY,
"--output",
"json",
];

describe("e2e: Pi Agent integration", () => {
test("config agent and mcp connect/disconnect help expose Pi", async () => {
for (const [routes, args] of [
[CONFIG_ROUTES, ["config", "agent", "--help"]],
[MCP_ROUTES, ["mcp", "connect", "--help"]],
[MCP_ROUTES, ["mcp", "disconnect", "--help"]],
] as const) {
const result = await runCommandHelp(routes, [...args]);
expect(result.exitCode, result.stderr).toBe(0);
expect(result.stderr).toMatch(/\bpi\b/);
}
});

test("model dry-run accepts Pi, redacts its key, and leaves its directory absent", async () => {
const result = await runCommandE2e(
CONFIG_ROUTES,
[...modelArgs, "--dry-run", "--output", "json"],
probeEnv,
);
expect(result.exitCode, result.stderr).toBe(0);
const plan = parseStdoutJson<{
agent: string;
base_url: string;
model: string;
api_key: string;
}>(result.stdout);
expect(plan.agent).toBe("pi");
expect(plan.base_url).toBe(
"https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1",
);
expect(plan.model).toBe("qwen3-coder-plus");
expect(plan.api_key).not.toBe(PLACEHOLDER_KEY);
expect(result.stdout + result.stderr).not.toContain(PLACEHOLDER_KEY);
expect(existsSync(agentDir)).toBe(false);
});

test("config agent writes Pi's native settings and private auth without disclosing credentials", async () => {
const result = await runCommandE2e(CONFIG_ROUTES, modelArgs, probeEnv);
expect(result.exitCode, result.stderr).toBe(0);
expect(result.stdout + result.stderr).not.toContain(PLACEHOLDER_KEY);
const settings = JSON.parse(readFileSync(join(agentDir, "settings.json"), "utf8"));
expect(settings).toMatchObject({
defaultProvider: "bailian-cli",
defaultModel: "qwen3-coder-plus",
});
const auth = JSON.parse(readFileSync(join(agentDir, "auth.json"), "utf8"));
expect(auth["bailian-cli"]).toEqual({ type: "api_key", key: PLACEHOLDER_KEY });
expect(readFileSync(join(agentDir, "models.json"), "utf8")).not.toContain(PLACEHOLDER_KEY);
});

test("MCP dry-run reports only non-secret metadata without writing configuration", async () => {
const result = await runCommandE2e(MCP_ROUTES, [...mcpArgs, "--dry-run"], probeEnv);
expect(result.exitCode, result.stderr).toBe(0);
const plan = parseStdoutJson<{ agents: string[]; endpoint: string; header_names: string[] }>(
result.stdout,
);
expect(plan.agents).toEqual(["pi"]);
expect(plan.endpoint).toBe("https://dashscope.aliyuncs.com/api/v1/mcps/TextGenerateImage/mcp");
expect(plan.header_names).toContain("Authorization");
expect(result.stdout + result.stderr).not.toContain(PLACEHOLDER_KEY);
expect(existsSync(agentDir)).toBe(false);
});

test("MCP connect is idempotent and disconnect removes the managed entry without disclosing credentials", async () => {
const first = await runCommandE2e(MCP_ROUTES, mcpArgs, probeEnv);
expect(first.exitCode, first.stderr).toBe(0);
expect(first.stdout + first.stderr).not.toContain(PLACEHOLDER_KEY);
expect(parseStdoutJson<{ results: { status: string }[] }>(first.stdout).results[0].status).toBe(
"added",
);
const repeated = await runCommandE2e(MCP_ROUTES, mcpArgs, probeEnv);
expect(repeated.exitCode, repeated.stderr).toBe(0);
expect(
parseStdoutJson<{ results: { status: string }[] }>(repeated.stdout).results[0].status,
).toBe("unchanged");
const disconnected = await runCommandE2e(
MCP_ROUTES,
["mcp", "disconnect", "--agent", "pi", "--server", "TextGenerateImage", "--output", "json"],
probeEnv,
);
expect(disconnected.exitCode, disconnected.stderr).toBe(0);
expect(
parseStdoutJson<{ results: { status: string }[] }>(disconnected.stdout).results[0].status,
).toBe("removed");
expect(JSON.parse(readFileSync(join(agentDir, "mcp.json"), "utf8")).mcpServers).toEqual({});
});

test("MCP SSE uses the shared adapter rejection before any Pi file is created", async () => {
const sseArgs = mcpArgs.map((argument) => (argument === "streamable-http" ? "sse" : argument));
const result = await runCommandE2e(MCP_ROUTES, sseArgs, probeEnv);
expect(result.exitCode).toBe(2);
expect(result.stderr).toMatch(/pi.*SSE/i);
expect(result.stdout + result.stderr).not.toContain(PLACEHOLDER_KEY);
expect(existsSync(agentDir)).toBe(false);
});
});
Loading
Loading