From d74686f09ff08f7ff1d8d727ac794c3369937fe4 Mon Sep 17 00:00:00 2001 From: wb-liuxuehuan Date: Tue, 23 Jun 2026 16:51:54 +0800 Subject: [PATCH] =?UTF-8?q?feat(tokenplan):=20=E6=B7=BB=E5=8A=A0=20Token?= =?UTF-8?q?=20Plan=20=E7=9B=B8=E5=85=B3=E5=91=BD=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 `tokenplan add-member`、`tokenplan assign-seats` 和 `tokenplan create-key` 命令,支持管理 Token Plan 组织成员和 API 密钥。相关文档已更新,提供使用示例和参数说明。 --- packages/cli/README.zh.md | 10 +- packages/cli/src/commands/catalog.ts | 6 + .../cli/src/commands/tokenplan/add-member.ts | 160 +++++++++ .../src/commands/tokenplan/assign-seats.ts | 172 ++++++++++ .../cli/src/commands/tokenplan/create-key.ts | 163 +++++++++ packages/cli/src/main.ts | 3 + packages/cli/tests/e2e/tokenplan.e2e.test.ts | 313 +++++++++++++++++- packages/core/src/types/api.ts | 32 ++ skills/bailian-cli/reference/index.md | 103 +++--- skills/bailian-cli/reference/tokenplan.md | 105 +++++- 10 files changed, 996 insertions(+), 71 deletions(-) create mode 100644 packages/cli/src/commands/tokenplan/add-member.ts create mode 100644 packages/cli/src/commands/tokenplan/assign-seats.ts create mode 100644 packages/cli/src/commands/tokenplan/create-key.ts diff --git a/packages/cli/README.zh.md b/packages/cli/README.zh.md index 4d9a101..0d742c4 100644 --- a/packages/cli/README.zh.md +++ b/packages/cli/README.zh.md @@ -119,6 +119,12 @@ bl quota check # 查看当前用量 vs bl quota check --model qwen3.6-plus --period 5 # 查看最近 5 分钟用量 bl quota request --model qwen3.6-plus --tpm 6000000 # 申请临时 TPM 提额 bl quota history # 查看提额历史记录 + +# Token Plan 团队版管理(需 AK/SK,见下方认证说明) +bl tokenplan seats # 查看订阅席位明细 +bl tokenplan add-member --account-name dev --org-id org_xxx +bl tokenplan assign-seats --workspace-id ws_xxx --seat-type standard --account-id acc_xxx +bl tokenplan create-key --account-id acc_xxx --workspace-id ws_xxx ``` > 更多案例与使用场景:[阿里云百炼 CLI 官方主页](https://bailian.console.aliyun.com/cli?source_channel=cli_github&) @@ -148,9 +154,9 @@ bl text chat --api-key sk-xxxxx --message "你好" bl auth login --console ``` -### 阿里云 AK/SK(仅知识库检索) +### 阿里云 AK/SK(知识库检索与 Token Plan) -`knowledge retrieve` 命令需要阿里云 AccessKey。前往 [RAM 控制台](https://ram.console.aliyun.com/manage/ak) 获取。 +`knowledge retrieve` 与 `tokenplan` 命令组需要阿里云 AccessKey。前往 [RAM 控制台](https://ram.console.aliyun.com/manage/ak) 获取。 > 建议:创建 RAM 子账号并授予最小权限,避免使用主账号 AK/SK。 diff --git a/packages/cli/src/commands/catalog.ts b/packages/cli/src/commands/catalog.ts index 948d695..2134f16 100644 --- a/packages/cli/src/commands/catalog.ts +++ b/packages/cli/src/commands/catalog.ts @@ -47,6 +47,9 @@ import quotaRequest from "./quota/request.ts"; import quotaHistory from "./quota/history.ts"; import quotaCheck from "./quota/check.ts"; import tokenplanSeats from "./tokenplan/seats.ts"; +import tokenplanCreateKey from "./tokenplan/create-key.ts"; +import tokenplanAssignSeats from "./tokenplan/assign-seats.ts"; +import tokenplanAddMember from "./tokenplan/add-member.ts"; /** Command registry map (no dependency on registry.ts — safe for build-time import). */ export const commands: Record = { @@ -96,5 +99,8 @@ export const commands: Record = { "quota history": quotaHistory, "quota check": quotaCheck, "tokenplan seats": tokenplanSeats, + "tokenplan create-key": tokenplanCreateKey, + "tokenplan assign-seats": tokenplanAssignSeats, + "tokenplan add-member": tokenplanAddMember, update: update, }; diff --git a/packages/cli/src/commands/tokenplan/add-member.ts b/packages/cli/src/commands/tokenplan/add-member.ts new file mode 100644 index 0000000..f9c950e --- /dev/null +++ b/packages/cli/src/commands/tokenplan/add-member.ts @@ -0,0 +1,160 @@ +import { + defineCommand, + buildCanonicalQuery, + signRequest, + modelStudioHost, + detectOutputFormat, + maskToken, + trackingHeaders, + type Config, + type GlobalFlags, + type AddOrganizationMemberResponse, + BailianError, + ExitCode, +} from "bailian-cli-core"; +import { emitResult, emitBare } from "../../output/output.ts"; +import { padEnd } from "../../output/cjk-width.ts"; + +const API_VERSION = "2026-02-10"; +const API_ACTION = "AddOrganizationMember"; +const API_PATH = "/tokenplan/organization/member-additions"; + +const DEFAULT_ORG_ROLE = "ORG_MEMBER"; + +export default defineCommand({ + name: "tokenplan add-member", + description: "Add a member to a Token Plan organization", + usage: "bl tokenplan add-member --account-name --org-id [flags]", + options: [ + { flag: "--account-name ", description: "Member display name", required: true }, + { flag: "--org-id ", description: "Organization ID", required: true }, + { + flag: "--org-role-code ", + description: "Organization role: ORG_ADMIN or ORG_MEMBER (default: ORG_MEMBER)", + }, + { + flag: "--spec-type ", + description: "Seat tier to assign on creation: standard, pro, or max", + }, + { + flag: "--caller-uac-account-id ", + description: "Caller UAC account ID", + }, + { + flag: "--namespace-id ", + description: "Product namespace ID (Token Plan default: namespace-1)", + }, + { flag: "--access-key-id ", description: "Alibaba Cloud Access Key ID (deprecated)" }, + { + flag: "--access-key-secret ", + description: "Alibaba Cloud Access Key Secret (deprecated)", + }, + ], + examples: [ + "bl tokenplan add-member --account-name dev_user --org-id org_123", + "bl tokenplan add-member --account-name admin_user --org-id org_123 --org-role-code ORG_ADMIN", + "bl tokenplan add-member --account-name member1 --org-id org_123 --spec-type standard", + ], + async run(config: Config, flags: GlobalFlags) { + const format = detectOutputFormat(config.output); + const accessKeyId = (flags.accessKeyId as string) || config.accessKeyId; + const accessKeySecret = (flags.accessKeySecret as string) || config.accessKeySecret; + + if (!accessKeyId || !accessKeySecret) { + throw new BailianError( + "No credentials found.\n" + + "Set ALIBABA_CLOUD_ACCESS_KEY_ID and ALIBABA_CLOUD_ACCESS_KEY_SECRET.", + ExitCode.AUTH, + ); + } + + const accountName = flags.accountName as string | undefined; + const orgId = flags.orgId as string | undefined; + if (!accountName) { + throw new BailianError("Missing required argument --account-name.", ExitCode.USAGE); + } + if (!orgId) { + throw new BailianError("Missing required argument --org-id.", ExitCode.USAGE); + } + + const queryParams = buildQueryParams(flags); + const queryString = buildCanonicalQuery(queryParams); + const host = modelStudioHost(config.region); + const endpoint = `https://${host}${API_PATH}${queryString ? `?${queryString}` : ""}`; + + if (config.dryRun) { + emitResult({ endpoint, query: queryParams }, format); + return; + } + + const headers = signRequest({ + accessKeyId, + accessKeySecret, + action: API_ACTION, + version: API_VERSION, + body: "", + host, + pathname: API_PATH, + method: "POST", + queryString, + }); + + if (config.verbose) { + process.stderr.write(`> POST ${endpoint}\n`); + process.stderr.write(`> AK: ${maskToken(accessKeyId)}\n`); + } + + const timeoutMs = config.timeout * 1000; + const res = await fetch(endpoint, { + method: "POST", + headers: { ...headers, ...trackingHeaders() }, + signal: AbortSignal.timeout(timeoutMs), + }); + + if (config.verbose) { + process.stderr.write(`< ${res.status} ${res.statusText}\n`); + } + + const data = (await res.json()) as AddOrganizationMemberResponse; + + if (!res.ok || data.Success === false) { + throw new BailianError( + `${data.Code || res.status} - ${data.Message || res.statusText}`, + ExitCode.GENERAL, + ); + } + + if (config.quiet || format === "text") { + emitTextMember(data); + } else { + emitResult(data, format); + } + }, +}); + +function buildQueryParams(flags: GlobalFlags): Record { + const params: Record = {}; + + if (flags.accountName) params.AccountName = flags.accountName as string; + if (flags.orgId) params.OrgId = flags.orgId as string; + params.OrgRoleCode = + typeof flags.orgRoleCode === "string" && flags.orgRoleCode.length > 0 + ? flags.orgRoleCode + : DEFAULT_ORG_ROLE; + if (flags.specType) params.SpecType = flags.specType as string; + if (flags.callerUacAccountId) params.CallerUacAccountId = flags.callerUacAccountId as string; + if (flags.namespaceId) params.NamespaceId = flags.namespaceId as string; + + return params; +} + +function emitTextMember(data: AddOrganizationMemberResponse): void { + const item = data.Data; + if (!item) { + emitBare("Member added."); + return; + } + + emitBare(`${padEnd("AccountId", 14)} ${item.AccountId ?? "-"}`); + emitBare(`${padEnd("SeatAssigned", 14)} ${String(item.SeatAssigned ?? "-")}`); +} diff --git a/packages/cli/src/commands/tokenplan/assign-seats.ts b/packages/cli/src/commands/tokenplan/assign-seats.ts new file mode 100644 index 0000000..d453ae4 --- /dev/null +++ b/packages/cli/src/commands/tokenplan/assign-seats.ts @@ -0,0 +1,172 @@ +import { + defineCommand, + buildCanonicalQuery, + signRequest, + modelStudioHost, + detectOutputFormat, + maskToken, + trackingHeaders, + type Config, + type GlobalFlags, + type BatchAssignSeatsResponse, + BailianError, + ExitCode, +} from "bailian-cli-core"; +import { emitResult, emitBare } from "../../output/output.ts"; + +const API_VERSION = "2026-02-10"; +const API_ACTION = "BatchAssignSeats"; +const API_PATH = "/tokenplan/subscription/seat-assignments"; + +export default defineCommand({ + name: "tokenplan assign-seats", + description: "Batch assign Token Plan seats to members", + usage: + "bl tokenplan assign-seats --workspace-id --seat-type --account-id [flags]", + options: [ + { + flag: "--workspace-id ", + description: "Workspace ID (env: BAILIAN_WORKSPACE_ID, config: workspace_id)", + }, + { + flag: "--seat-type ", + description: "Seat tier: standard, pro, or max", + required: true, + }, + { + flag: "--account-id ", + description: "Target member account ID (repeatable)", + type: "array", + }, + { + flag: "--caller-uac-account-id ", + description: "Caller UAC account ID", + }, + { + flag: "--namespace-id ", + description: "Product namespace ID (Token Plan default: namespace-1)", + }, + { + flag: "--locale ", + description: "Language: zh-CN or en-US", + }, + { flag: "--access-key-id ", description: "Alibaba Cloud Access Key ID (deprecated)" }, + { + flag: "--access-key-secret ", + description: "Alibaba Cloud Access Key Secret (deprecated)", + }, + ], + examples: [ + "bl tokenplan assign-seats --workspace-id ws_456 --seat-type standard --account-id acc_123", + "bl tokenplan assign-seats --workspace-id ws_456 --seat-type pro --account-id acc_1 --account-id acc_2", + ], + async run(config: Config, flags: GlobalFlags) { + const format = detectOutputFormat(config.output); + const accessKeyId = (flags.accessKeyId as string) || config.accessKeyId; + const accessKeySecret = (flags.accessKeySecret as string) || config.accessKeySecret; + + if (!accessKeyId || !accessKeySecret) { + throw new BailianError( + "No credentials found.\n" + + "Set ALIBABA_CLOUD_ACCESS_KEY_ID and ALIBABA_CLOUD_ACCESS_KEY_SECRET.", + ExitCode.AUTH, + ); + } + + const workspaceId = (flags.workspaceId as string) || config.workspaceId; + const seatType = flags.seatType as string | undefined; + if (!workspaceId) { + throw new BailianError( + "Missing workspace ID.\n" + + "Set via: --workspace-id flag, env: BAILIAN_WORKSPACE_ID, or config: bl config set workspace_id ", + ExitCode.USAGE, + ); + } + if (!seatType) { + throw new BailianError("Missing required argument --seat-type.", ExitCode.USAGE); + } + + const accountIds = flags.accountId; + const hasAccountIds = + (Array.isArray(accountIds) && accountIds.length > 0) || + (typeof accountIds === "string" && accountIds.length > 0); + if (!hasAccountIds) { + throw new BailianError("Missing required argument --account-id.", ExitCode.USAGE); + } + + const queryParams = buildQueryParams(flags, workspaceId); + const queryString = buildCanonicalQuery(queryParams); + const host = modelStudioHost(config.region); + const endpoint = `https://${host}${API_PATH}${queryString ? `?${queryString}` : ""}`; + + if (config.dryRun) { + emitResult({ endpoint, query: queryParams }, format); + return; + } + + const headers = signRequest({ + accessKeyId, + accessKeySecret, + action: API_ACTION, + version: API_VERSION, + body: "", + host, + pathname: API_PATH, + method: "POST", + queryString, + }); + + if (config.verbose) { + process.stderr.write(`> POST ${endpoint}\n`); + process.stderr.write(`> AK: ${maskToken(accessKeyId)}\n`); + } + + const timeoutMs = config.timeout * 1000; + const res = await fetch(endpoint, { + method: "POST", + headers: { ...headers, ...trackingHeaders() }, + signal: AbortSignal.timeout(timeoutMs), + }); + + if (config.verbose) { + process.stderr.write(`< ${res.status} ${res.statusText}\n`); + } + + const data = (await res.json()) as BatchAssignSeatsResponse; + + if (!res.ok || data.Success === false) { + throw new BailianError( + `${data.Code || res.status} - ${data.Message || res.statusText}`, + ExitCode.GENERAL, + ); + } + + if (config.quiet || format === "text") { + emitBare("Seats assigned successfully."); + } else { + emitResult(data, format); + } + }, +}); + +function buildQueryParams( + flags: GlobalFlags, + workspaceId: string, +): Record { + const params: Record = {}; + + params.WorkspaceId = workspaceId; + if (flags.seatType) params.SeatType = flags.seatType as string; + if (flags.callerUacAccountId) params.CallerUacAccountId = flags.callerUacAccountId as string; + if (flags.namespaceId) params.NamespaceId = flags.namespaceId as string; + if (flags.locale) params.Locale = flags.locale as string; + + const accountIds = flags.accountId as string | string[] | undefined; + if (Array.isArray(accountIds) && accountIds.length > 0) { + params.AccountIds = accountIds; + } else if (typeof accountIds === "string" && accountIds.length > 0) { + params.AccountIds = accountIds; + } + + return params; +} diff --git a/packages/cli/src/commands/tokenplan/create-key.ts b/packages/cli/src/commands/tokenplan/create-key.ts new file mode 100644 index 0000000..e2fe431 --- /dev/null +++ b/packages/cli/src/commands/tokenplan/create-key.ts @@ -0,0 +1,163 @@ +import { + defineCommand, + buildCanonicalQuery, + signRequest, + modelStudioHost, + detectOutputFormat, + maskToken, + trackingHeaders, + type Config, + type GlobalFlags, + type CreateTokenPlanKeyResponse, + BailianError, + ExitCode, +} from "bailian-cli-core"; +import { emitResult, emitBare } from "../../output/output.ts"; +import { padEnd } from "../../output/cjk-width.ts"; + +const API_VERSION = "2026-02-10"; +const API_ACTION = "CreateTokenPlanKey"; +const API_PATH = "/tokenplan/api-keys"; + +export default defineCommand({ + name: "tokenplan create-key", + description: "Create a Token Plan API key for a seat", + usage: "bl tokenplan create-key --account-id --workspace-id [flags]", + options: [ + { flag: "--account-id ", description: "Target member account ID", required: true }, + { + flag: "--workspace-id ", + description: "Workspace ID (env: BAILIAN_WORKSPACE_ID, config: workspace_id)", + }, + { flag: "--description ", description: "API key description" }, + { + flag: "--caller-uac-account-id ", + description: "Caller UAC account ID", + }, + { + flag: "--namespace-id ", + description: "Product namespace ID (Token Plan default: namespace-1)", + }, + { flag: "--access-key-id ", description: "Alibaba Cloud Access Key ID (deprecated)" }, + { + flag: "--access-key-secret ", + description: "Alibaba Cloud Access Key Secret (deprecated)", + }, + ], + examples: [ + "bl tokenplan create-key --account-id acc_123 --workspace-id ws_456", + "bl tokenplan create-key --account-id acc_123 --workspace-id ws_456 --description 'Dev key'", + ], + async run(config: Config, flags: GlobalFlags) { + const format = detectOutputFormat(config.output); + const accessKeyId = (flags.accessKeyId as string) || config.accessKeyId; + const accessKeySecret = (flags.accessKeySecret as string) || config.accessKeySecret; + + if (!accessKeyId || !accessKeySecret) { + throw new BailianError( + "No credentials found.\n" + + "Set ALIBABA_CLOUD_ACCESS_KEY_ID and ALIBABA_CLOUD_ACCESS_KEY_SECRET.", + ExitCode.AUTH, + ); + } + + const accountId = flags.accountId as string | undefined; + const workspaceId = (flags.workspaceId as string) || config.workspaceId; + if (!accountId) { + throw new BailianError("Missing required argument --account-id.", ExitCode.USAGE); + } + if (!workspaceId) { + throw new BailianError( + "Missing workspace ID.\n" + + "Set via: --workspace-id flag, env: BAILIAN_WORKSPACE_ID, or config: bl config set workspace_id ", + ExitCode.USAGE, + ); + } + + const queryParams = buildQueryParams(flags, { accountId, workspaceId }); + const queryString = buildCanonicalQuery(queryParams); + const host = modelStudioHost(config.region); + const endpoint = `https://${host}${API_PATH}${queryString ? `?${queryString}` : ""}`; + + if (config.dryRun) { + emitResult({ endpoint, query: queryParams }, format); + return; + } + + const headers = signRequest({ + accessKeyId, + accessKeySecret, + action: API_ACTION, + version: API_VERSION, + body: "", + host, + pathname: API_PATH, + method: "POST", + queryString, + }); + + if (config.verbose) { + process.stderr.write(`> POST ${endpoint}\n`); + process.stderr.write(`> AK: ${maskToken(accessKeyId)}\n`); + } + + const timeoutMs = config.timeout * 1000; + const res = await fetch(endpoint, { + method: "POST", + headers: { ...headers, ...trackingHeaders() }, + signal: AbortSignal.timeout(timeoutMs), + }); + + if (config.verbose) { + process.stderr.write(`< ${res.status} ${res.statusText}\n`); + } + + const data = (await res.json()) as CreateTokenPlanKeyResponse; + + if (!res.ok || data.Success === false) { + throw new BailianError( + `${data.Code || res.status} - ${data.Message || res.statusText}`, + ExitCode.GENERAL, + ); + } + + if (config.quiet || format === "text") { + emitTextKey(data); + } else { + emitResult(data, format); + } + }, +}); + +function buildQueryParams( + flags: GlobalFlags, + resolved: { accountId: string; workspaceId: string }, +): Record { + const params: Record = {}; + + params.AccountId = resolved.accountId; + params.WorkspaceId = resolved.workspaceId; + if (flags.description) params.Description = flags.description as string; + if (flags.callerUacAccountId) params.CallerUacAccountId = flags.callerUacAccountId as string; + if (flags.namespaceId) params.NamespaceId = flags.namespaceId as string; + + return params; +} + +function emitTextKey(data: CreateTokenPlanKeyResponse): void { + const item = data.Data; + if (!item) { + emitBare("API key created."); + return; + } + + emitBare(`${padEnd("ApiKeyId", 14)} ${item.ApiKeyId ?? "-"}`); + emitBare(`${padEnd("MaskedApiKey", 14)} ${item.MaskedApiKey ?? "-"}`); + if (item.Description) { + emitBare(`${padEnd("Description", 14)} ${item.Description}`); + } + if (item.PlainApiKey) { + emitBare(""); + emitBare(`PlainApiKey (shown once): ${item.PlainApiKey}`); + } +} diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index e879377..86c6af3 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -71,6 +71,9 @@ const NO_AUTH_SETUP = [ ["quota", "history"], ["quota", "check"], ["tokenplan", "seats"], + ["tokenplan", "create-key"], + ["tokenplan", "assign-seats"], + ["tokenplan", "add-member"], ]; async function main() { diff --git a/packages/cli/tests/e2e/tokenplan.e2e.test.ts b/packages/cli/tests/e2e/tokenplan.e2e.test.ts index ff23b13..1635bbf 100644 --- a/packages/cli/tests/e2e/tokenplan.e2e.test.ts +++ b/packages/cli/tests/e2e/tokenplan.e2e.test.ts @@ -7,12 +7,28 @@ interface DryRunBody { query?: Record; } -describe("e2e: tokenplan seats", () => { +const noCredsEnv = { + DASHSCOPE_API_KEY: undefined, + DASHSCOPE_ACCESS_TOKEN: undefined, + ALIBABA_CLOUD_ACCESS_KEY_ID: undefined, + ALIBABA_CLOUD_ACCESS_KEY_SECRET: undefined, + BAILIAN_CONFIG_DIR: tmpdir(), +}; + +const fakeAkEnv = { + ALIBABA_CLOUD_ACCESS_KEY_ID: "LTAI-fake", + ALIBABA_CLOUD_ACCESS_KEY_SECRET: "fake-secret", +}; + +describe("e2e: tokenplan", () => { test("tokenplan 分组展示子命令帮助且成功退出", async () => { const { stdout, stderr, exitCode } = await runCli(["tokenplan"]); expect(exitCode, stderr).toBe(0); const out = `${stdout}\n${stderr}`; expect(out).toMatch(/tokenplan|seats/i); + expect(out).toMatch(/create-key/i); + expect(out).toMatch(/assign-seats/i); + expect(out).toMatch(/add-member/i); }); test("tokenplan seats --help 正常退出", async () => { @@ -24,27 +40,195 @@ describe("e2e: tokenplan seats", () => { expect(stderr).toMatch(/--status/i); expect(stderr).toMatch(/--query-assigned/i); }); + + test("tokenplan create-key --help 正常退出", async () => { + const { stderr, exitCode } = await runCli(["tokenplan", "create-key", "--help"]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toMatch(/--account-id/i); + expect(stderr).toMatch(/--workspace-id/i); + }); + + test("tokenplan assign-seats --help 正常退出", async () => { + const { stderr, exitCode } = await runCli(["tokenplan", "assign-seats", "--help"]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toMatch(/--workspace-id/i); + expect(stderr).toMatch(/--seat-type/i); + expect(stderr).toMatch(/--account-id/i); + }); + + test("tokenplan add-member --help 正常退出", async () => { + const { stderr, exitCode } = await runCli(["tokenplan", "add-member", "--help"]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toMatch(/--account-name/i); + expect(stderr).toMatch(/--org-id/i); + expect(stderr).toMatch(/--org-role-code/i); + }); }); -describe("e2e: tokenplan seats errors", () => { - test("无任何凭证时提示 No credentials found 并非零退出", async () => { +describe("e2e: tokenplan errors", () => { + test("seats 无任何凭证时提示 No credentials found 并非零退出", async () => { const { stderr, exitCode } = await runCli( ["tokenplan", "seats", "--non-interactive", "--output", "json"], - { - DASHSCOPE_API_KEY: undefined, - DASHSCOPE_ACCESS_TOKEN: undefined, - ALIBABA_CLOUD_ACCESS_KEY_ID: undefined, - ALIBABA_CLOUD_ACCESS_KEY_SECRET: undefined, - BAILIAN_CONFIG_DIR: tmpdir(), - }, + noCredsEnv, + ); + expect(exitCode).not.toBe(0); + expect(stderr).toMatch(/no credentials found/i); + }); + + test("create-key 无任何凭证时提示 No credentials found 并非零退出", async () => { + const { stderr, exitCode } = await runCli( + [ + "tokenplan", + "create-key", + "--account-id", + "acc_1", + "--workspace-id", + "ws_1", + "--non-interactive", + "--output", + "json", + ], + noCredsEnv, + ); + expect(exitCode).not.toBe(0); + expect(stderr).toMatch(/no credentials found/i); + }); + + test("assign-seats 无任何凭证时提示 No credentials found 并非零退出", async () => { + const { stderr, exitCode } = await runCli( + [ + "tokenplan", + "assign-seats", + "--workspace-id", + "ws_1", + "--seat-type", + "standard", + "--account-id", + "acc_1", + "--non-interactive", + "--output", + "json", + ], + noCredsEnv, + ); + expect(exitCode).not.toBe(0); + expect(stderr).toMatch(/no credentials found/i); + }); + + test("add-member 无任何凭证时提示 No credentials found 并非零退出", async () => { + const { stderr, exitCode } = await runCli( + [ + "tokenplan", + "add-member", + "--account-name", + "user1", + "--org-id", + "org_1", + "--non-interactive", + "--output", + "json", + ], + noCredsEnv, ); expect(exitCode).not.toBe(0); expect(stderr).toMatch(/no credentials found/i); }); }); -describe("e2e: tokenplan seats dry-run", () => { - test("--dry-run 输出 endpoint 和 query 参数", async () => { +describe.skipIf(!isTokenPlanAkSkReady())("e2e: tokenplan missing args", () => { + test("create-key 缺少 --account-id 时退出为用法错误 (2)", async () => { + const { stderr, exitCode } = await runCli([ + "tokenplan", + "create-key", + "--workspace-id", + "ws_1", + "--non-interactive", + ]); + expect(exitCode).toBe(2); + expect(stderr).toMatch(/--account-id|Missing required argument/i); + }); + + test("create-key 缺少 --workspace-id 时退出为用法错误 (2)", async () => { + const { stderr, exitCode } = await runCli([ + "tokenplan", + "create-key", + "--account-id", + "acc_1", + "--non-interactive", + ]); + expect(exitCode).toBe(2); + expect(stderr).toMatch(/workspace-id|Missing workspace ID/i); + }); + + test("assign-seats 缺少 --workspace-id 时退出为用法错误 (2)", async () => { + const { stderr, exitCode } = await runCli([ + "tokenplan", + "assign-seats", + "--seat-type", + "standard", + "--account-id", + "acc_1", + "--non-interactive", + ]); + expect(exitCode).toBe(2); + expect(stderr).toMatch(/workspace-id|Missing workspace ID/i); + }); + + test("assign-seats 缺少 --seat-type 时退出为用法错误 (2)", async () => { + const { stderr, exitCode } = await runCli([ + "tokenplan", + "assign-seats", + "--workspace-id", + "ws_1", + "--account-id", + "acc_1", + "--non-interactive", + ]); + expect(exitCode).toBe(2); + expect(stderr).toMatch(/--seat-type|Missing required argument/i); + }); + + test("assign-seats 缺少 account id 时退出为用法错误 (2)", async () => { + const { stderr, exitCode } = await runCli([ + "tokenplan", + "assign-seats", + "--workspace-id", + "ws_1", + "--seat-type", + "standard", + "--non-interactive", + ]); + expect(exitCode).toBe(2); + expect(stderr).toMatch(/--account-id|Missing required argument/i); + }); + + test("add-member 缺少 --account-name 时退出为用法错误 (2)", async () => { + const { stderr, exitCode } = await runCli([ + "tokenplan", + "add-member", + "--org-id", + "org_1", + "--non-interactive", + ]); + expect(exitCode).toBe(2); + expect(stderr).toMatch(/--account-name|Missing required argument/i); + }); + + test("add-member 缺少 --org-id 时退出为用法错误 (2)", async () => { + const { stderr, exitCode } = await runCli([ + "tokenplan", + "add-member", + "--account-name", + "user1", + "--non-interactive", + ]); + expect(exitCode).toBe(2); + expect(stderr).toMatch(/--org-id|Missing required argument/i); + }); +}); + +describe("e2e: tokenplan dry-run", () => { + test("seats --dry-run 输出 endpoint 和 query 参数", async () => { const { stdout, stderr, exitCode } = await runCli( [ "tokenplan", @@ -62,10 +246,7 @@ describe("e2e: tokenplan seats dry-run", () => { "--output", "json", ], - { - ALIBABA_CLOUD_ACCESS_KEY_ID: "LTAI-fake", - ALIBABA_CLOUD_ACCESS_KEY_SECRET: "fake-secret", - }, + fakeAkEnv, ); expect(exitCode, stderr).toBe(0); const data = parseStdoutJson(stdout); @@ -75,6 +256,106 @@ describe("e2e: tokenplan seats dry-run", () => { expect(data.query?.QueryAssigned).toBe("true"); expect(data.query?.StatusList).toEqual(["NORMAL"]); }); + + test("create-key --dry-run 从 BAILIAN_WORKSPACE_ID 读取 workspace", async () => { + const { stdout, stderr, exitCode } = await runCli( + [ + "tokenplan", + "create-key", + "--dry-run", + "--account-id", + "acc_123", + "--non-interactive", + "--output", + "json", + ], + { ...fakeAkEnv, BAILIAN_WORKSPACE_ID: "ws-from-env" }, + ); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson(stdout); + expect(data.query?.WorkspaceId).toBe("ws-from-env"); + }); + + test("create-key --dry-run 输出 endpoint 和 query 参数", async () => { + const { stdout, stderr, exitCode } = await runCli( + [ + "tokenplan", + "create-key", + "--dry-run", + "--account-id", + "acc_123", + "--workspace-id", + "ws_456", + "--description", + "test key", + "--non-interactive", + "--output", + "json", + ], + fakeAkEnv, + ); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson(stdout); + expect(data.endpoint).toMatch(/\/tokenplan\/api-keys/); + expect(data.query?.AccountId).toBe("acc_123"); + expect(data.query?.WorkspaceId).toBe("ws_456"); + expect(data.query?.Description).toBe("test key"); + }); + + test("assign-seats --dry-run 输出 endpoint 和 query 参数", async () => { + const { stdout, stderr, exitCode } = await runCli( + [ + "tokenplan", + "assign-seats", + "--dry-run", + "--workspace-id", + "ws_456", + "--seat-type", + "standard", + "--account-id", + "acc_1", + "--account-id", + "acc_2", + "--non-interactive", + "--output", + "json", + ], + fakeAkEnv, + ); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson(stdout); + expect(data.endpoint).toMatch(/\/tokenplan\/subscription\/seat-assignments/); + expect(data.query?.WorkspaceId).toBe("ws_456"); + expect(data.query?.SeatType).toBe("standard"); + expect(data.query?.AccountIds).toEqual(["acc_1", "acc_2"]); + }); + + test("add-member --dry-run 输出 endpoint 和 query 参数", async () => { + const { stdout, stderr, exitCode } = await runCli( + [ + "tokenplan", + "add-member", + "--dry-run", + "--account-name", + "dev_user", + "--org-id", + "org_123", + "--spec-type", + "standard", + "--non-interactive", + "--output", + "json", + ], + fakeAkEnv, + ); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson(stdout); + expect(data.endpoint).toMatch(/\/tokenplan\/organization\/member-additions/); + expect(data.query?.AccountName).toBe("dev_user"); + expect(data.query?.OrgId).toBe("org_123"); + expect(data.query?.OrgRoleCode).toBe("ORG_MEMBER"); + expect(data.query?.SpecType).toBe("standard"); + }); }); describe.skipIf(!isTokenPlanAkSkReady())("e2e: tokenplan seats(AK/SK)", () => { diff --git a/packages/core/src/types/api.ts b/packages/core/src/types/api.ts index 8c1ab9c..cdf6bf5 100644 --- a/packages/core/src/types/api.ts +++ b/packages/core/src/types/api.ts @@ -455,6 +455,38 @@ export interface GetSubscriptionSeatDetailsResponse { }; } +export interface CreateTokenPlanKeyResponse { + Success?: boolean; + Code?: string; + Message?: string; + Data?: { + ApiKeyId?: string; + PlainApiKey?: string; + MaskedApiKey?: string; + Description?: string; + CreatedAt?: string; + SourceId?: string; + }; +} + +export interface BatchAssignSeatsResponse { + Success?: boolean; + Code?: string; + Message?: string; +} + +export interface AddOrganizationMemberResponse { + Success?: boolean; + Code?: string; + Message?: string; + RequestId?: string; + HttpStatusCode?: number; + Data?: { + AccountId?: string; + SeatAssigned?: boolean; + }; +} + // ---- Speech Synthesis / TTS (DashScope) ---- export interface DashScopeTTSRequest { diff --git a/skills/bailian-cli/reference/index.md b/skills/bailian-cli/reference/index.md index f78ae52..113f855 100644 --- a/skills/bailian-cli/reference/index.md +++ b/skills/bailian-cli/reference/index.md @@ -8,55 +8,58 @@ Use this index for the full quick index and global flags. ## Quick index -| Command | Description | Detail | -| -------------------------- | ----------------------------------------------------------------------------------------------------- | ---------------------------- | -| `bl advisor recommend` | Recommend the best models for your use case (intent analysis → candidate recall → LLM ranking) | [advisor.md](advisor.md) | -| `bl app call` | Call a Bailian application (agent or workflow) | [app.md](app.md) | -| `bl app list` | List Bailian applications | [app.md](app.md) | -| `bl auth login` | Authenticate with API key or console browser login (credentials can coexist) | [auth.md](auth.md) | -| `bl auth logout` | Clear stored credentials | [auth.md](auth.md) | -| `bl auth status` | Show current authentication state | [auth.md](auth.md) | -| `bl config export-schema` | Export all (or one) CLI command(s) as Anthropic/OpenAI-compatible JSON tool schemas | [config.md](config.md) | -| `bl config set` | Set a config value | [config.md](config.md) | -| `bl config show` | Display current configuration | [config.md](config.md) | -| `bl console call` | Call a Bailian console API via the CLI gateway | [console.md](console.md) | -| `bl file upload` | Upload a local file to DashScope temporary storage (48h) | [file.md](file.md) | -| `bl image edit` | Edit an existing image with text instructions (Qwen-Image) | [image.md](image.md) | -| `bl image generate` | Generate images (Qwen-Image / wan2.x) | [image.md](image.md) | -| `bl knowledge retrieve` | Retrieve from a Bailian knowledge base | [knowledge.md](knowledge.md) | -| `bl mcp call` | Call a tool on an MCP server (tools/call) | [mcp.md](mcp.md) | -| `bl mcp list` | List MCP servers activated under your Bailian account | [mcp.md](mcp.md) | -| `bl mcp tools` | List tools exposed by an MCP server (tools/list) | [mcp.md](mcp.md) | -| `bl memory add` | Add memory from messages or custom content | [memory.md](memory.md) | -| `bl memory delete` | Delete a memory node | [memory.md](memory.md) | -| `bl memory list` | List memory nodes for a user | [memory.md](memory.md) | -| `bl memory profile create` | Create a user profile schema for memory profiling | [memory.md](memory.md) | -| `bl memory profile get` | Get user profile by schema ID and user ID | [memory.md](memory.md) | -| `bl memory search` | Search memory nodes by query or messages | [memory.md](memory.md) | -| `bl memory update` | Update a memory node content | [memory.md](memory.md) | -| `bl omni` | Multimodal chat with text + audio output (Qwen-Omni) | [omni.md](omni.md) | -| `bl pipeline run` | Run a pipeline workflow definition | [pipeline.md](pipeline.md) | -| `bl pipeline validate` | Validate a pipeline definition without executing | [pipeline.md](pipeline.md) | -| `bl quota check` | Check current usage against rate limits | [quota.md](quota.md) | -| `bl quota history` | View quota change history | [quota.md](quota.md) | -| `bl quota list` | View model RPM/TPM rate limits | [quota.md](quota.md) | -| `bl quota request` | Request a temporary quota increase | [quota.md](quota.md) | -| `bl search web` | Search the web using DashScope MCP WebSearch service | [search.md](search.md) | -| `bl speech recognize` | Recognize speech from audio files (FunAudio-ASR) | [speech.md](speech.md) | -| `bl speech synthesize` | Synthesize speech from text (CosyVoice TTS) | [speech.md](speech.md) | -| `bl text chat` | Send a chat completion (OpenAI compatible, DashScope) | [text.md](text.md) | -| `bl tokenplan seats` | List Token Plan subscription seat details | [tokenplan.md](tokenplan.md) | -| `bl update` | Update bl to the latest version | [update.md](update.md) | -| `bl usage free` | Query free-tier quota for models (all models if --model is omitted) | [usage.md](usage.md) | -| `bl usage freetier` | Enable or disable auto-stop for free-tier models. Enables by default; use --off to disable | [usage.md](usage.md) | -| `bl usage stats` | Query model usage statistics | [usage.md](usage.md) | -| `bl video download` | Download a completed video by task ID | [video.md](video.md) | -| `bl video edit` | Edit a video with happyhorse-1.0-video-edit (style transfer, object replacement, etc.) | [video.md](video.md) | -| `bl video generate` | Generate a video from text or image (happyhorse-1.0-t2v / happyhorse-1.0-i2v / wan2.6-t2v) | [video.md](video.md) | -| `bl video ref` | Reference-to-video generation (happyhorse-1.0-r2v / wan2.6-r2v): multi-subject, multi-shot with voice | [video.md](video.md) | -| `bl video task get` | Query async task status | [video.md](video.md) | -| `bl vision describe` | Describe an image or video using Qwen-VL | [vision.md](vision.md) | -| `bl workspace list` | List all workspaces | [workspace.md](workspace.md) | +| Command | Description | Detail | +| --------------------------- | ----------------------------------------------------------------------------------------------------- | ---------------------------- | +| `bl advisor recommend` | Recommend the best models for your use case (intent analysis → candidate recall → LLM ranking) | [advisor.md](advisor.md) | +| `bl app call` | Call a Bailian application (agent or workflow) | [app.md](app.md) | +| `bl app list` | List Bailian applications | [app.md](app.md) | +| `bl auth login` | Authenticate with API key or console browser login (credentials can coexist) | [auth.md](auth.md) | +| `bl auth logout` | Clear stored credentials | [auth.md](auth.md) | +| `bl auth status` | Show current authentication state | [auth.md](auth.md) | +| `bl config export-schema` | Export all (or one) CLI command(s) as Anthropic/OpenAI-compatible JSON tool schemas | [config.md](config.md) | +| `bl config set` | Set a config value | [config.md](config.md) | +| `bl config show` | Display current configuration | [config.md](config.md) | +| `bl console call` | Call a Bailian console API via the CLI gateway | [console.md](console.md) | +| `bl file upload` | Upload a local file to DashScope temporary storage (48h) | [file.md](file.md) | +| `bl image edit` | Edit an existing image with text instructions (Qwen-Image) | [image.md](image.md) | +| `bl image generate` | Generate images (Qwen-Image / wan2.x) | [image.md](image.md) | +| `bl knowledge retrieve` | Retrieve from a Bailian knowledge base | [knowledge.md](knowledge.md) | +| `bl mcp call` | Call a tool on an MCP server (tools/call) | [mcp.md](mcp.md) | +| `bl mcp list` | List MCP servers activated under your Bailian account | [mcp.md](mcp.md) | +| `bl mcp tools` | List tools exposed by an MCP server (tools/list) | [mcp.md](mcp.md) | +| `bl memory add` | Add memory from messages or custom content | [memory.md](memory.md) | +| `bl memory delete` | Delete a memory node | [memory.md](memory.md) | +| `bl memory list` | List memory nodes for a user | [memory.md](memory.md) | +| `bl memory profile create` | Create a user profile schema for memory profiling | [memory.md](memory.md) | +| `bl memory profile get` | Get user profile by schema ID and user ID | [memory.md](memory.md) | +| `bl memory search` | Search memory nodes by query or messages | [memory.md](memory.md) | +| `bl memory update` | Update a memory node content | [memory.md](memory.md) | +| `bl omni` | Multimodal chat with text + audio output (Qwen-Omni) | [omni.md](omni.md) | +| `bl pipeline run` | Run a pipeline workflow definition | [pipeline.md](pipeline.md) | +| `bl pipeline validate` | Validate a pipeline definition without executing | [pipeline.md](pipeline.md) | +| `bl quota check` | Check current usage against rate limits | [quota.md](quota.md) | +| `bl quota history` | View quota change history | [quota.md](quota.md) | +| `bl quota list` | View model RPM/TPM rate limits | [quota.md](quota.md) | +| `bl quota request` | Request a temporary quota increase | [quota.md](quota.md) | +| `bl search web` | Search the web using DashScope MCP WebSearch service | [search.md](search.md) | +| `bl speech recognize` | Recognize speech from audio files (FunAudio-ASR) | [speech.md](speech.md) | +| `bl speech synthesize` | Synthesize speech from text (CosyVoice TTS) | [speech.md](speech.md) | +| `bl text chat` | Send a chat completion (OpenAI compatible, DashScope) | [text.md](text.md) | +| `bl tokenplan add-member` | Add a member to a Token Plan organization | [tokenplan.md](tokenplan.md) | +| `bl tokenplan assign-seats` | Batch assign Token Plan seats to members | [tokenplan.md](tokenplan.md) | +| `bl tokenplan create-key` | Create a Token Plan API key for a seat | [tokenplan.md](tokenplan.md) | +| `bl tokenplan seats` | List Token Plan subscription seat details | [tokenplan.md](tokenplan.md) | +| `bl update` | Update bl to the latest version | [update.md](update.md) | +| `bl usage free` | Query free-tier quota for models (all models if --model is omitted) | [usage.md](usage.md) | +| `bl usage freetier` | Enable or disable auto-stop for free-tier models. Enables by default; use --off to disable | [usage.md](usage.md) | +| `bl usage stats` | Query model usage statistics | [usage.md](usage.md) | +| `bl video download` | Download a completed video by task ID | [video.md](video.md) | +| `bl video edit` | Edit a video with happyhorse-1.0-video-edit (style transfer, object replacement, etc.) | [video.md](video.md) | +| `bl video generate` | Generate a video from text or image (happyhorse-1.0-t2v / happyhorse-1.0-i2v / wan2.6-t2v) | [video.md](video.md) | +| `bl video ref` | Reference-to-video generation (happyhorse-1.0-r2v / wan2.6-r2v): multi-subject, multi-shot with voice | [video.md](video.md) | +| `bl video task get` | Query async task status | [video.md](video.md) | +| `bl vision describe` | Describe an image or video using Qwen-VL | [vision.md](vision.md) | +| `bl workspace list` | List all workspaces | [workspace.md](workspace.md) | ## By group @@ -78,7 +81,7 @@ Use this index for the full quick index and global flags. | `search` | `web` | [search.md](search.md) | | `speech` | `recognize`, `synthesize` | [speech.md](speech.md) | | `text` | `chat` | [text.md](text.md) | -| `tokenplan` | `seats` | [tokenplan.md](tokenplan.md) | +| `tokenplan` | `add-member`, `assign-seats`, `create-key`, `seats` | [tokenplan.md](tokenplan.md) | | `update` | `(root)` | [update.md](update.md) | | `usage` | `free`, `freetier`, `stats` | [usage.md](usage.md) | | `video` | `download`, `edit`, `generate`, `ref`, `task get` | [video.md](video.md) | diff --git a/skills/bailian-cli/reference/tokenplan.md b/skills/bailian-cli/reference/tokenplan.md index 91253c6..a0be912 100644 --- a/skills/bailian-cli/reference/tokenplan.md +++ b/skills/bailian-cli/reference/tokenplan.md @@ -7,12 +7,111 @@ Index: [index.md](index.md) ## Commands in this group -| Command | Description | -| -------------------- | ----------------------------------------- | -| `bl tokenplan seats` | List Token Plan subscription seat details | +| Command | Description | +| --------------------------- | ----------------------------------------- | +| `bl tokenplan add-member` | Add a member to a Token Plan organization | +| `bl tokenplan assign-seats` | Batch assign Token Plan seats to members | +| `bl tokenplan create-key` | Create a Token Plan API key for a seat | +| `bl tokenplan seats` | List Token Plan subscription seat details | ## Command details +### `bl tokenplan add-member` + +| Field | Value | +| --------------- | --------------------------------------------------------------------- | +| **Name** | `tokenplan add-member` | +| **Description** | Add a member to a Token Plan organization | +| **Usage** | `bl tokenplan add-member --account-name --org-id [flags]` | + +#### Options + +| Flag | Type | Required | Description | +| ------------------------------ | ------ | -------- | ---------------------------------------------------------------- | +| `--account-name ` | string | yes | Member display name | +| `--org-id ` | string | yes | Organization ID | +| `--org-role-code ` | string | no | Organization role: ORG_ADMIN or ORG_MEMBER (default: ORG_MEMBER) | +| `--spec-type ` | string | no | Seat tier to assign on creation: standard, pro, or max | +| `--caller-uac-account-id ` | string | no | Caller UAC account ID | +| `--namespace-id ` | string | no | Product namespace ID (Token Plan default: namespace-1) | +| `--access-key-id ` | string | no | Alibaba Cloud Access Key ID (deprecated) | +| `--access-key-secret ` | string | no | Alibaba Cloud Access Key Secret (deprecated) | + +#### Examples + +```bash +bl tokenplan add-member --account-name dev_user --org-id org_123 +``` + +```bash +bl tokenplan add-member --account-name admin_user --org-id org_123 --org-role-code ORG_ADMIN +``` + +```bash +bl tokenplan add-member --account-name member1 --org-id org_123 --spec-type standard +``` + +### `bl tokenplan assign-seats` + +| Field | Value | +| --------------- | -------------------------------------------------------------------------------------------- | +| **Name** | `tokenplan assign-seats` | +| **Description** | Batch assign Token Plan seats to members | +| **Usage** | `bl tokenplan assign-seats --workspace-id --seat-type --account-id [flags]` | + +#### Options + +| Flag | Type | Required | Description | +| ------------------------------ | ------ | -------- | -------------------------------------------------------------- | +| `--workspace-id ` | string | no | Workspace ID (env: BAILIAN_WORKSPACE_ID, config: workspace_id) | +| `--seat-type ` | string | yes | Seat tier: standard, pro, or max | +| `--account-id ` | array | no | Target member account ID (repeatable) | +| `--caller-uac-account-id ` | string | no | Caller UAC account ID | +| `--namespace-id ` | string | no | Product namespace ID (Token Plan default: namespace-1) | +| `--locale ` | string | no | Language: zh-CN or en-US | +| `--access-key-id ` | string | no | Alibaba Cloud Access Key ID (deprecated) | +| `--access-key-secret ` | string | no | Alibaba Cloud Access Key Secret (deprecated) | + +#### Examples + +```bash +bl tokenplan assign-seats --workspace-id ws_456 --seat-type standard --account-id acc_123 +``` + +```bash +bl tokenplan assign-seats --workspace-id ws_456 --seat-type pro --account-id acc_1 --account-id acc_2 +``` + +### `bl tokenplan create-key` + +| Field | Value | +| --------------- | ----------------------------------------------------------------------- | +| **Name** | `tokenplan create-key` | +| **Description** | Create a Token Plan API key for a seat | +| **Usage** | `bl tokenplan create-key --account-id --workspace-id [flags]` | + +#### Options + +| Flag | Type | Required | Description | +| ------------------------------ | ------ | -------- | -------------------------------------------------------------- | +| `--account-id ` | string | yes | Target member account ID | +| `--workspace-id ` | string | no | Workspace ID (env: BAILIAN_WORKSPACE_ID, config: workspace_id) | +| `--description ` | string | no | API key description | +| `--caller-uac-account-id ` | string | no | Caller UAC account ID | +| `--namespace-id ` | string | no | Product namespace ID (Token Plan default: namespace-1) | +| `--access-key-id ` | string | no | Alibaba Cloud Access Key ID (deprecated) | +| `--access-key-secret ` | string | no | Alibaba Cloud Access Key Secret (deprecated) | + +#### Examples + +```bash +bl tokenplan create-key --account-id acc_123 --workspace-id ws_456 +``` + +```bash +bl tokenplan create-key --account-id acc_123 --workspace-id ws_456 --description 'Dev key' +``` + ### `bl tokenplan seats` | Field | Value |