mirror of
https://github.com/modelstudioai/cli.git
synced 2026-09-14 19:49:23 +08:00
Merge branch 'main' into feat/command-pack
This commit is contained in:
@@ -59,6 +59,7 @@
|
||||
"ajv": "catalog:",
|
||||
"boxen": "catalog:",
|
||||
"chalk": "catalog:",
|
||||
"e2e": "workspace:*",
|
||||
"typescript": "^6.0.2",
|
||||
"undici": "catalog:",
|
||||
"vite-plus": "0.1.22",
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 () => {};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user