Merge branch 'main' into feat/command-pack

This commit is contained in:
若麒
2026-07-13 15:38:45 +08:00
139 changed files with 5393 additions and 2564 deletions
+1
View File
@@ -59,6 +59,7 @@
"ajv": "catalog:",
"boxen": "catalog:",
"chalk": "catalog:",
"e2e": "workspace:*",
"typescript": "^6.0.2",
"undici": "catalog:",
"vite-plus": "0.1.22",
+23
View File
@@ -0,0 +1,23 @@
import type { AnyCommand } from "bailian-cli-core";
import {
configShow,
configSet,
update,
knowledgeRetrieve,
knowledgeSearch,
knowledgeChat,
} from "bailian-cli-commands";
// kscli (Knowledge Studio CLI): lightweight RAG product. Ships config/update
// plus the knowledge commands, remapped to flat paths. Routing is driven
// entirely by these keys, and usage/examples/errors render the path from the
// key — so the same shared command shows `kscli search` here and
// `bl knowledge search` in bl.
export const commands: Record<string, AnyCommand> = {
"config show": configShow,
"config set": configSet,
update,
retrieve: knowledgeRetrieve,
search: knowledgeSearch,
chat: knowledgeChat,
};
+1 -23
View File
@@ -1,29 +1,7 @@
import { createCli } from "bailian-cli-runtime";
import type { AnyCommand } from "bailian-cli-core";
import {
configShow,
configSet,
update,
knowledgeRetrieve,
knowledgeSearch,
knowledgeChat,
} from "bailian-cli-commands";
import { commands } from "./commands.ts";
import pkg from "../package.json" with { type: "json" };
// kscli (Knowledge Studio CLI): lightweight RAG product. Ships config/update
// plus the knowledge commands, remapped to flat paths. Routing is driven
// entirely by these keys, and usage/examples/errors render the path from the
// key — so the same shared command shows `kscli search` here and
// `bl knowledge search` in bl.
const commands: Record<string, AnyCommand> = {
"config show": configShow,
"config set": configSet,
update,
retrieve: knowledgeRetrieve,
search: knowledgeSearch,
chat: knowledgeChat,
};
void createCli(commands, {
binName: "kscli",
version: pkg.version,
-131
View File
@@ -1,131 +0,0 @@
import { describe, expect, test } from "vite-plus/test";
import { isChatE2EReady, parseStdoutJson, runKscli } from "./helpers.ts";
// ---- Types ----
interface ChatJsonResult {
answer: string;
request_id: string;
}
// ---- Real API call tests (gated by BAILIAN_E2E + credentials) ----
describe.skipIf(!isChatE2EReady())("e2e: kscli chat (live)", () => {
const agentId = process.env.BAILIAN_E2E_CHAT_AGENT_ID!;
const workspaceId = process.env.BAILIAN_WORKSPACE_ID!;
test("chat (JSON mode) returns answer", async () => {
const { stdout, stderr, exitCode } = await runKscli([
"chat",
"--message",
"什么是大模型?",
"--agent-id",
agentId,
"--workspace-id",
workspaceId,
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<ChatJsonResult>(stdout);
expect(data.answer).toBeTruthy();
expect(data.answer.length).toBeGreaterThan(0);
expect(data.request_id).toBeTruthy();
});
test("chat (text mode) returns plain text", async () => {
const { stdout, stderr, exitCode } = await runKscli([
"chat",
"--message",
"什么是RAG?",
"--agent-id",
agentId,
"--workspace-id",
workspaceId,
"--output",
"text",
]);
expect(exitCode, stderr).toBe(0);
expect(stdout.trim().length).toBeGreaterThan(0);
});
test("chat (stream, JSON mode) collects and returns answer", async () => {
const { stdout, stderr, exitCode } = await runKscli([
"chat",
"--message",
"什么是检索增强生成?",
"--agent-id",
agentId,
"--workspace-id",
workspaceId,
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<ChatJsonResult>(stdout);
expect(data.answer).toBeTruthy();
expect(data.answer.length).toBeGreaterThan(0);
expect(data.request_id).toBeTruthy();
});
test("chat (stream, text mode) outputs streaming text", async () => {
const { stdout, stderr, exitCode } = await runKscli([
"chat",
"--message",
"什么是向量检索?",
"--agent-id",
agentId,
"--workspace-id",
workspaceId,
"--output",
"text",
]);
expect(exitCode, stderr).toBe(0);
// Streaming text mode: output should contain some text content
expect(stdout.trim().length).toBeGreaterThan(0);
});
test("chat with multi-turn messages returns context-aware answer", async () => {
const { stdout, stderr, exitCode } = await runKscli([
"chat",
"--message",
"user:什么是大模型",
"--message",
"assistant:大模型是大规模语言模型,具有强大的理解和生成能力",
"--message",
"它有哪些应用场景?",
"--agent-id",
agentId,
"--workspace-id",
workspaceId,
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<ChatJsonResult>(stdout);
expect(data.answer).toBeTruthy();
expect(data.answer.length).toBeGreaterThan(0);
});
test("chat with invalid agent_id fails gracefully", async () => {
const { stderr, exitCode } = await runKscli([
"chat",
"--message",
"test",
"--agent-id",
"aid-invalid-not-exist",
"--workspace-id",
workspaceId,
"--output",
"json",
]);
expect(exitCode).not.toBe(0);
expect(stderr).toBeTruthy();
});
});
-9
View File
@@ -1,9 +0,0 @@
import { loadRootEnv } from "./helpers.ts";
/**
* Vitest globalSetup: load monorepo root `.env` into `process.env` before tests run.
*/
export default function vitestGlobalSetup(): () => void {
loadRootEnv();
return () => {};
}
+28 -124
View File
@@ -1,138 +1,42 @@
import { execFile } from "child_process";
import { existsSync, mkdtempSync, readFileSync } from "fs";
import { mkdtempSync } from "fs";
import { tmpdir } from "os";
import { promisify } from "util";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
import { parseEnv } from "util";
import { parseStdoutJson } from "e2e/output";
import { runNodeMain, type RunCliResult } from "e2e/runner";
import {
isBailianE2EEnabled,
isChatE2EReady,
isDashScopeE2EReady,
isSearchE2EReady,
} from "e2e/gating";
import { monorepoRoot } from "e2e/monorepo-root";
const execFileAsync = promisify(execFile);
export {
isBailianE2EEnabled,
isChatE2EReady,
isDashScopeE2EReady,
isSearchE2EReady,
monorepoRoot,
parseStdoutJson,
};
export type { RunCliResult };
/** `packages/kscli` 根目录(含 `src/main.ts`) */
/** `packages/kscli` 根目录 */
export const kscliPackageRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
const mainTs = join(kscliPackageRoot, "src", "main.ts");
/** Monorepo 根(含根 `package.json` 和 `.env`) */
export function monorepoRoot(): string {
return join(kscliPackageRoot, "..", "..");
}
function localBin(name: string): string {
return join(
monorepoRoot(),
"node_modules",
".bin",
process.platform === "win32" ? `${name}.cmd` : name,
);
}
// ---- E2E gating helpers ----
// ---- .env loader (cached) ----
let _rootEnvCache: Record<string, string | undefined> | null = null;
/** 读取 monorepo 根目录 `.env` 并缓存(.env 值优先于 shell 环境变量) */
function getRootEnv(): Record<string, string | undefined> {
if (_rootEnvCache !== null) return _rootEnvCache;
const rootEnvPath = join(monorepoRoot(), ".env");
_rootEnvCache = existsSync(rootEnvPath) ? parseEnv(readFileSync(rootEnvPath, "utf8")) : {};
return _rootEnvCache;
}
/** 从 .env 或 process.env 获取值(.env 优先) */
function envVar(key: string): string | undefined {
return getRootEnv()[key] ?? process.env[key];
}
// ---- E2E gating helpers ----
/** 显式开启后才跑真实网络 E2E */
export function isBailianE2EEnabled(): boolean {
return envVar("BAILIAN_E2E") === "1";
}
/** 是否有 DashScope API Key 可用 */
export function isDashScopeE2EReady(): boolean {
if (!isBailianE2EEnabled()) return false;
return !!envVar("DASHSCOPE_API_KEY")?.trim();
}
/** 知识检索 E2E 就绪:E2E 开启 + API Key + search agent ID + workspace ID */
export function isSearchE2EReady(): boolean {
if (!isDashScopeE2EReady()) return false;
return (
!!envVar("BAILIAN_E2E_SEARCH_AGENT_ID")?.trim() && !!envVar("BAILIAN_WORKSPACE_ID")?.trim()
);
}
/** 知识问答 E2E 就绪:E2E 开启 + API Key + chat agent ID + workspace ID */
export function isChatE2EReady(): boolean {
if (!isDashScopeE2EReady()) return false;
return !!envVar("BAILIAN_E2E_CHAT_AGENT_ID")?.trim() && !!envVar("BAILIAN_WORKSPACE_ID")?.trim();
}
// ---- CLI runner ----
export interface RunCliResult {
stdout: string;
stderr: string;
exitCode: number;
}
/**
* 子进程执行 kscli(等价于 `tsx packages/kscli/src/main.ts ...`)。
*/
/** 子进程执行 kscli */
export async function runKscli(
args: string[],
envOverrides: NodeJS.ProcessEnv = {},
): Promise<RunCliResult> {
try {
const { stdout, stderr } = await execFileAsync(localBin("tsx"), [mainTs, ...args], {
cwd: kscliPackageRoot,
encoding: "utf8",
maxBuffer: 32 * 1024 * 1024,
env: {
...process.env,
// .env values override shell env vars (ensures correct API key is used)
...getRootEnv(),
// Unique clean config dir per run — prevents stale config.json from previous tests
BAILIAN_CONFIG_DIR: mkdtempSync(join(tmpdir(), "kscli-test-")),
NODE_NO_WARNINGS: "1",
DO_NOT_TRACK: "1",
...envOverrides,
},
});
return { stdout: stdout ?? "", stderr: stderr ?? "", exitCode: 0 };
} catch (err: unknown) {
const e = err as {
stdout?: string;
stderr?: string;
code?: number;
};
return {
stdout: e.stdout ?? "",
stderr: e.stderr ?? "",
exitCode: typeof e.code === "number" ? e.code : 1,
};
}
}
export function parseStdoutJson<T = unknown>(stdout: string): T {
const t = stdout.trim();
return JSON.parse(t) as T;
}
// ---- Global setup: load root .env ----
/**
* Vitest globalSetup:加载 monorepo 根目录 `.env` 合并到 `process.env`。
*/
export function loadRootEnv(): void {
const rootEnv = join(monorepoRoot(), ".env");
if (existsSync(rootEnv)) {
const parsed = parseEnv(readFileSync(rootEnv, "utf8"));
Object.assign(process.env, parsed);
}
return runNodeMain(mainTs, args, {
cwd: kscliPackageRoot,
env: {
BAILIAN_CONFIG_DIR: mkdtempSync(join(tmpdir(), "kscli-test-")),
...envOverrides,
},
});
}
@@ -0,0 +1,50 @@
import { describe, expect, test } from "vite-plus/test";
import { deriveGroupPaths } from "e2e/registry-smoke";
import pkg from "../../package.json" with { type: "json" };
import { commands } from "../../src/commands.ts";
import { runKscli } from "./helpers.ts";
const commandPaths = Object.keys(commands).sort();
const groupPaths = deriveGroupPaths(commandPaths);
describe("e2e: kscli registry smoke", () => {
test("根帮助展示 kscli 与全局 flag", async () => {
const { stderr, exitCode } = await runKscli(["--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/\bkscli\b/i);
expect(stderr).toMatch(/--base-url/);
expect(stderr).not.toMatch(/^\s*--region\s/m);
});
test("--version 输出产品名与版本", async () => {
const { stdout, exitCode } = await runKscli(["--version"]);
expect(exitCode).toBe(0);
expect(stdout.trim()).toBe(`kscli ${pkg.version}`);
});
test("search --help 展示 kscli 路径与 knowledge 必填 flag", async () => {
const { stderr, exitCode } = await runKscli(["search", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/kscli search/i);
expect(stderr).toMatch(/--query/i);
expect(stderr).toMatch(/--agent-id/i);
expect(stderr).toMatch(/--workspace-id/i);
expect(stderr).not.toMatch(/bl knowledge search/i);
});
test("search 缺少 --query 时报用法错误 (2)", async () => {
const { stderr, exitCode } = await runKscli(["search", "--agent-id", "aid_test"]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--query|Missing required/i);
});
test.each(commandPaths)("已注册命令 %s --help 成功", async (path) => {
const { stderr, exitCode } = await runKscli([...path.split(" "), "--help"]);
expect(exitCode, stderr).toBe(0);
});
test.each(groupPaths)("命令分组 %s --help 成功", async (path) => {
const { stderr, exitCode } = await runKscli([...path.split(" "), "--help"]);
expect(exitCode, stderr).toBe(0);
});
});
-122
View File
@@ -1,122 +0,0 @@
import { describe, expect, test } from "vite-plus/test";
import { isSearchE2EReady, parseStdoutJson, runKscli } from "./helpers.ts";
// ---- Types ----
interface SearchResponse {
code: string;
status_code: number;
request_id: string;
data: {
total: number;
cost_time: number;
nodes: Array<{
score: number;
text: string;
metadata: {
content?: string;
title?: string;
doc_id?: string;
doc_name?: string;
doc_url?: string;
pipeline_id?: string;
workspace_id?: string;
page_number?: number;
image_url?: string;
_knowledge_type?: string;
_citation_index?: number;
_score?: number;
};
}>;
};
}
// ---- Real API call tests (gated by BAILIAN_E2E + credentials) ----
describe.skipIf(!isSearchE2EReady())("e2e: kscli search (live)", () => {
const agentId = process.env.BAILIAN_E2E_SEARCH_AGENT_ID!;
const workspaceId = process.env.BAILIAN_WORKSPACE_ID!;
test("search returns results in JSON mode", async () => {
const { stdout, stderr, exitCode } = await runKscli([
"search",
"--query",
"什么是大模型",
"--agent-id",
agentId,
"--workspace-id",
workspaceId,
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<SearchResponse>(stdout);
expect(data.code).toBe("Success");
expect(data.request_id).toBeTruthy();
expect(data.data.total).toBeGreaterThan(0);
expect(data.data.nodes.length).toBeGreaterThan(0);
const firstNode = data.data.nodes[0]!;
expect(typeof firstNode.score).toBe("number");
expect(firstNode.score).toBeGreaterThanOrEqual(0);
expect(typeof firstNode.text).toBe("string");
expect(firstNode.text.length).toBeGreaterThan(0);
});
test("search returns results in text mode", async () => {
const { stdout, stderr, exitCode } = await runKscli([
"search",
"--query",
"RAG",
"--agent-id",
agentId,
"--workspace-id",
workspaceId,
"--output",
"text",
]);
expect(exitCode, stderr).toBe(0);
// Text mode: [1] (score: 0.xxxx) followed by text content
expect(stdout).toMatch(/\[1\].*score/);
});
test("search with --query-history returns results", async () => {
const { stdout, stderr, exitCode } = await runKscli([
"search",
"--query",
"它怎么工作",
"--agent-id",
agentId,
"--workspace-id",
workspaceId,
"--query-history",
'[{"role":"user","content":"什么是大模型"},{"role":"assistant","content":"大模型是大规模语言模型"}]',
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<SearchResponse>(stdout);
expect(data.code).toBe("Success");
expect(data.data.nodes.length).toBeGreaterThan(0);
});
test("search with invalid agent_id fails gracefully", async () => {
const { stderr, exitCode } = await runKscli([
"search",
"--query",
"test",
"--agent-id",
"aid-invalid-not-exist",
"--workspace-id",
workspaceId,
"--output",
"json",
]);
expect(exitCode).not.toBe(0);
expect(stderr).toBeTruthy();
});
});
+1 -1
View File
@@ -2,7 +2,7 @@ import { defineConfig } from "vite-plus";
export default defineConfig({
test: {
globalSetup: "./tests/e2e/global-setup.ts",
globalSetup: "../e2e/src/global-setup.ts",
testTimeout: 60_000,
hookTimeout: 60_000,
},