From c0d30fee3d88205ace406bd1fff647f601d80de1 Mon Sep 17 00:00:00 2001 From: wb-liuxuehuan Date: Tue, 23 Jun 2026 14:05:11 +0800 Subject: [PATCH 1/7] =?UTF-8?q?feat(tokenplan):=20=E6=B7=BB=E5=8A=A0=20tok?= =?UTF-8?q?enplan=20seats=20=E5=91=BD=E4=BB=A4=E4=BB=A5=E5=88=97=E5=87=BA?= =?UTF-8?q?=E8=AE=A2=E9=98=85=E5=BA=A7=E4=BD=8D=E8=AF=A6=E6=83=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 tokenplan seats 命令,支持分页和状态过滤,提供详细的座位信息查询功能。相关文档已更新。 --- packages/cli/src/commands/catalog.ts | 2 + packages/cli/src/commands/tokenplan/seats.ts | 201 +++++++++++++++++++ packages/cli/src/main.ts | 1 + packages/cli/tests/e2e/helpers.ts | 9 + packages/cli/tests/e2e/tokenplan.e2e.test.ts | 96 +++++++++ packages/core/src/client/ak-sign.ts | 31 ++- packages/core/src/client/endpoints.ts | 13 ++ packages/core/src/client/index.ts | 3 +- packages/core/src/types/api.ts | 38 ++++ skills/bailian-cli/reference/index.md | 2 + skills/bailian-cli/reference/tokenplan.md | 52 +++++ 11 files changed, 446 insertions(+), 2 deletions(-) create mode 100644 packages/cli/src/commands/tokenplan/seats.ts create mode 100644 packages/cli/tests/e2e/tokenplan.e2e.test.ts create mode 100644 skills/bailian-cli/reference/tokenplan.md diff --git a/packages/cli/src/commands/catalog.ts b/packages/cli/src/commands/catalog.ts index ae48fcc..948d695 100644 --- a/packages/cli/src/commands/catalog.ts +++ b/packages/cli/src/commands/catalog.ts @@ -46,6 +46,7 @@ import quotaList from "./quota/list.ts"; import quotaRequest from "./quota/request.ts"; import quotaHistory from "./quota/history.ts"; import quotaCheck from "./quota/check.ts"; +import tokenplanSeats from "./tokenplan/seats.ts"; /** Command registry map (no dependency on registry.ts — safe for build-time import). */ export const commands: Record = { @@ -94,5 +95,6 @@ export const commands: Record = { "quota request": quotaRequest, "quota history": quotaHistory, "quota check": quotaCheck, + "tokenplan seats": tokenplanSeats, update: update, }; diff --git a/packages/cli/src/commands/tokenplan/seats.ts b/packages/cli/src/commands/tokenplan/seats.ts new file mode 100644 index 0000000..16ff409 --- /dev/null +++ b/packages/cli/src/commands/tokenplan/seats.ts @@ -0,0 +1,201 @@ +import { + defineCommand, + buildCanonicalQuery, + signRequest, + modelStudioHost, + detectOutputFormat, + maskToken, + trackingHeaders, + type Config, + type GlobalFlags, + type GetSubscriptionSeatDetailsResponse, + type TokenPlanSeatDetail, + 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 = "GetSubscriptionSeatDetails"; +const API_PATH = "/tokenplan/subscription/seat-detail"; + +export default defineCommand({ + name: "tokenplan seats", + description: "List Token Plan subscription seat details", + usage: "bl tokenplan seats [flags]", + options: [ + { flag: "--page-no ", description: "Page number (default: 1)", type: "number" }, + { flag: "--page-size ", description: "Page size (default: 10)", type: "number" }, + { + flag: "--caller-uac-account-id ", + description: "Caller UAC account ID", + }, + { + flag: "--namespace-id ", + description: "Product namespace ID (Token Plan default: namespace-1)", + }, + { + flag: "--status ", + description: + "Seat status filter (repeatable): CREATING, NORMAL, LIMIT, RELEASE, STOP, REFUNDED", + type: "array", + }, + { + flag: "--status-list-str ", + description: "StatusList as JSON string, e.g. '[\"NORMAL\"]'", + }, + { flag: "--seat-id ", description: "Filter by seat ID" }, + { + flag: "--seat-type ", + description: "Seat tier: standard, pro, or max", + }, + { + flag: "--query-assigned ", + description: "Filter by assignment: true=assigned, false=unassigned", + }, + { 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 seats", + "bl tokenplan seats --page-size 20 --status NORMAL", + "bl tokenplan seats --query-assigned true --seat-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 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: "GET", + queryString, + }); + + if (config.verbose) { + process.stderr.write(`> GET ${endpoint}\n`); + process.stderr.write(`> AK: ${maskToken(accessKeyId)}\n`); + } + + const timeoutMs = config.timeout * 1000; + const res = await fetch(endpoint, { + method: "GET", + headers: { ...headers, ...trackingHeaders() }, + signal: AbortSignal.timeout(timeoutMs), + }); + + if (config.verbose) { + process.stderr.write(`< ${res.status} ${res.statusText}\n`); + } + + const data = (await res.json()) as GetSubscriptionSeatDetailsResponse; + + if (!res.ok || data.Success === false) { + throw new BailianError( + `${data.Code || res.status} - ${data.Message || res.statusText}`, + ExitCode.GENERAL, + ); + } + + const items = data.Data?.Items ?? []; + if (config.quiet || format === "text") { + emitTextSeats(items, data.Data?.Total, data.Data?.PageNo, data.Data?.PageSize); + } else { + emitResult(data, format); + } + }, +}); + +function buildQueryParams(flags: GlobalFlags): Record { + const params: Record = {}; + + if (flags.pageNo !== undefined) params.PageNo = String(flags.pageNo as number); + if (flags.pageSize !== undefined) params.PageSize = String(flags.pageSize as number); + if (flags.callerUacAccountId) params.CallerUacAccountId = flags.callerUacAccountId as string; + if (flags.namespaceId) params.NamespaceId = flags.namespaceId as string; + if (flags.statusListStr) params.StatusListStr = flags.statusListStr as string; + + const status = flags.status; + if (Array.isArray(status) && status.length > 0) { + params.StatusList = status as string[]; + } else if (typeof status === "string" && status.length > 0) { + params.StatusList = [status]; + } + + if (flags.seatId) params.SeatId = flags.seatId as string; + if (flags.seatType) params.SeatType = flags.seatType as string; + + if (typeof flags.queryAssigned === "string" && flags.queryAssigned.length > 0) { + params.QueryAssigned = flags.queryAssigned; + } + + return params; +} + +function emitTextSeats( + items: TokenPlanSeatDetail[], + total?: number, + pageNo?: number, + pageSize?: number, +): void { + if (items.length === 0) { + emitBare("No seats found."); + return; + } + + const header = [ + padEnd("SeatId", 18), + padEnd("Type", 10), + padEnd("Status", 10), + padEnd("Assigned", 12), + padEnd("Account", 20), + ].join(" "); + emitBare(header); + emitBare("-".repeat(header.length)); + + for (const item of items) { + const row = [ + padEnd(item.SeatId ?? "-", 18), + padEnd(item.SpecType ?? "-", 10), + padEnd(item.Status ?? "-", 10), + padEnd(item.AssignedStatus ?? "-", 12), + padEnd(item.AccountName ?? item.AccountId ?? "-", 20), + ].join(" "); + emitBare(row); + } + + if (total !== undefined) { + emitBare(""); + emitBare( + `Total: ${total}${pageNo !== undefined ? ` | Page: ${pageNo}` : ""}${pageSize !== undefined ? ` | PageSize: ${pageSize}` : ""}`, + ); + } +} diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index 4448aed..e879377 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -70,6 +70,7 @@ const NO_AUTH_SETUP = [ ["quota", "request"], ["quota", "history"], ["quota", "check"], + ["tokenplan", "seats"], ]; async function main() { diff --git a/packages/cli/tests/e2e/helpers.ts b/packages/cli/tests/e2e/helpers.ts index e35b8a3..63d5b97 100644 --- a/packages/cli/tests/e2e/helpers.ts +++ b/packages/cli/tests/e2e/helpers.ts @@ -136,6 +136,15 @@ export function isKnowledgeAkSkReady(): boolean { ); } +/** Token Plan POP commands (AK/SK only). */ +export function isTokenPlanAkSkReady(): boolean { + return ( + isBailianE2EEnabled() && + !!process.env.ALIBABA_CLOUD_ACCESS_KEY_ID && + !!process.env.ALIBABA_CLOUD_ACCESS_KEY_SECRET + ); +} + export interface RunCliResult { stdout: string; stderr: string; diff --git a/packages/cli/tests/e2e/tokenplan.e2e.test.ts b/packages/cli/tests/e2e/tokenplan.e2e.test.ts new file mode 100644 index 0000000..ff23b13 --- /dev/null +++ b/packages/cli/tests/e2e/tokenplan.e2e.test.ts @@ -0,0 +1,96 @@ +import { tmpdir } from "os"; +import { describe, expect, test } from "vite-plus/test"; +import { isTokenPlanAkSkReady, parseStdoutJson, runCli } from "./helpers.ts"; + +interface DryRunBody { + endpoint?: string; + query?: Record; +} + +describe("e2e: tokenplan seats", () => { + 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); + }); + + test("tokenplan seats --help 正常退出", async () => { + const { stderr, exitCode } = await runCli(["tokenplan", "seats", "--help"]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toMatch(/--page-no/i); + expect(stderr).toMatch(/--page-size/i); + expect(stderr).toMatch(/--seat-id/i); + expect(stderr).toMatch(/--status/i); + expect(stderr).toMatch(/--query-assigned/i); + }); +}); + +describe("e2e: tokenplan seats errors", () => { + test("无任何凭证时提示 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(), + }, + ); + expect(exitCode).not.toBe(0); + expect(stderr).toMatch(/no credentials found/i); + }); +}); + +describe("e2e: tokenplan seats dry-run", () => { + test("--dry-run 输出 endpoint 和 query 参数", async () => { + const { stdout, stderr, exitCode } = await runCli( + [ + "tokenplan", + "seats", + "--dry-run", + "--page-no", + "1", + "--page-size", + "10", + "--status", + "NORMAL", + "--query-assigned", + "true", + "--non-interactive", + "--output", + "json", + ], + { + ALIBABA_CLOUD_ACCESS_KEY_ID: "LTAI-fake", + ALIBABA_CLOUD_ACCESS_KEY_SECRET: "fake-secret", + }, + ); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson(stdout); + expect(data.endpoint).toMatch(/\/tokenplan\/subscription\/seat-detail/); + expect(data.query?.PageNo).toBe("1"); + expect(data.query?.PageSize).toBe("10"); + expect(data.query?.QueryAssigned).toBe("true"); + expect(data.query?.StatusList).toEqual(["NORMAL"]); + }); +}); + +describe.skipIf(!isTokenPlanAkSkReady())("e2e: tokenplan seats(AK/SK)", () => { + test("GetSubscriptionSeatDetails 真实调用", async () => { + const { stdout, stderr, exitCode } = await runCli([ + "tokenplan", + "seats", + "--page-size", + "5", + "--non-interactive", + "--output", + "json", + ]); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ Success?: boolean; Data?: { Items?: unknown[] } }>(stdout); + expect(data.Success).toBe(true); + expect(Array.isArray(data.Data?.Items)).toBe(true); + }); +}); diff --git a/packages/core/src/client/ak-sign.ts b/packages/core/src/client/ak-sign.ts index e9ed7be..d807ad3 100644 --- a/packages/core/src/client/ak-sign.ts +++ b/packages/core/src/client/ak-sign.ts @@ -18,6 +18,33 @@ export interface AkSignConfig { host: string; pathname: string; method?: string; + /** ACS3 canonical query string (sorted, encoded, no leading `?`). Empty for POST body-only APIs. */ + queryString?: string; +} + +/** Build ACS3 canonical query string from POP query parameters. */ +export function buildCanonicalQuery(params: Record): string { + const pairs: Array<[string, string]> = []; + for (const [key, value] of Object.entries(params)) { + if (value === undefined || value === "") continue; + if (Array.isArray(value)) { + const sorted = [...value].sort(); + for (const v of sorted) { + if (v !== "") pairs.push([key, v]); + } + } else { + pairs.push([key, value]); + } + } + pairs.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); + return pairs.map(([k, v]) => `${encodeRFC3986(k)}=${encodeRFC3986(v)}`).join("&"); +} + +function encodeRFC3986(str: string): string { + return encodeURIComponent(str).replace( + /[!'()*]/g, + (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`, + ); } export function signRequest(cfg: AkSignConfig): Record { @@ -47,11 +74,13 @@ export function signRequest(cfg: AkSignConfig): Record { const signedHeadersStr = signedHeaderKeys.join(";"); + const queryString = cfg.queryString ?? ""; + // Build canonical request const canonicalRequest = [ method, cfg.pathname, - "", // query string (empty for POST) + queryString, canonicalHeaders, signedHeadersStr, hashedBody, diff --git a/packages/core/src/client/endpoints.ts b/packages/core/src/client/endpoints.ts index 7cb4ab2..cbcfa20 100644 --- a/packages/core/src/client/endpoints.ts +++ b/packages/core/src/client/endpoints.ts @@ -1,3 +1,16 @@ +import type { Region } from "../config/schema.ts"; + +const MODEL_STUDIO_HOSTS: Record = { + cn: "modelstudio.cn-beijing.aliyuncs.com", + us: "modelstudio.cn-beijing.aliyuncs.com", + intl: "modelstudio.ap-southeast-1.aliyuncs.com", +}; + +/** ModelStudio POP OpenAPI host for the given DashScope region preset. */ +export function modelStudioHost(region: Region): string { + return MODEL_STUDIO_HOSTS[region] ?? MODEL_STUDIO_HOSTS.cn; +} + // ---- Chat (OpenAI Compatible) ---- export function chatEndpoint(baseUrl: string): string { diff --git a/packages/core/src/client/index.ts b/packages/core/src/client/index.ts index 22be23e..5c334c7 100644 --- a/packages/core/src/client/index.ts +++ b/packages/core/src/client/index.ts @@ -1,5 +1,5 @@ export type { AkSignConfig } from "./ak-sign.ts"; -export { signRequest } from "./ak-sign.ts"; +export { buildCanonicalQuery, signRequest } from "./ak-sign.ts"; export { appCompletionEndpoint, chatEndpoint, @@ -10,6 +10,7 @@ export { memoryListEndpoint, memoryNodeEndpoint, memorySearchEndpoint, + modelStudioHost, mcpWebSearchEndpoint, profileSchemaEndpoint, speechRecognizeEndpoint, diff --git a/packages/core/src/types/api.ts b/packages/core/src/types/api.ts index 87f0782..8c1ab9c 100644 --- a/packages/core/src/types/api.ts +++ b/packages/core/src/types/api.ts @@ -417,6 +417,44 @@ export interface DashScopeKnowledgeRetrieveResponse { }; } +// ---- Token Plan / ModelStudio POP (2026-02-10) ---- + +export interface TokenPlanSeatEquity { + EquityType?: string; + CycleInstanceId?: string; + CycleStartTime?: number; + CycleEndTime?: number; + CycleTotalValue?: number; + CycleSurplusValue?: number; + CycleVersion?: number; +} + +export interface TokenPlanSeatDetail { + InstanceCode?: string; + EquityList?: TokenPlanSeatEquity[]; + EndTime?: number; + SeatId?: string; + SpecType?: string; + StartTime?: number; + AssignedStatus?: string; + AccountId?: string; + AccountName?: string; + AccountEmail?: string; + Status?: string; +} + +export interface GetSubscriptionSeatDetailsResponse { + Success?: boolean; + Code?: string; + Message?: string; + Data?: { + Items?: TokenPlanSeatDetail[]; + Total?: number; + PageNo?: number; + PageSize?: number; + }; +} + // ---- Speech Synthesis / TTS (DashScope) ---- export interface DashScopeTTSRequest { diff --git a/skills/bailian-cli/reference/index.md b/skills/bailian-cli/reference/index.md index 0da2213..f78ae52 100644 --- a/skills/bailian-cli/reference/index.md +++ b/skills/bailian-cli/reference/index.md @@ -45,6 +45,7 @@ Use this index for the full quick index and global flags. | `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) | @@ -77,6 +78,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) | | `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 new file mode 100644 index 0000000..91253c6 --- /dev/null +++ b/skills/bailian-cli/reference/tokenplan.md @@ -0,0 +1,52 @@ +# `bl tokenplan` commands + +> Auto-generated from `packages/cli/src/commands/catalog.ts`. Do not edit by hand. +> Regenerate: `pnpm --filter bailian-cli run generate:reference`. + +Index: [index.md](index.md) + +## Commands in this group + +| Command | Description | +| -------------------- | ----------------------------------------- | +| `bl tokenplan seats` | List Token Plan subscription seat details | + +## Command details + +### `bl tokenplan seats` + +| Field | Value | +| --------------- | ----------------------------------------- | +| **Name** | `tokenplan seats` | +| **Description** | List Token Plan subscription seat details | +| **Usage** | `bl tokenplan seats [flags]` | + +#### Options + +| Flag | Type | Required | Description | +| ------------------------------ | ------ | -------- | --------------------------------------------------------------------------------- | +| `--page-no ` | number | no | Page number (default: 1) | +| `--page-size ` | number | no | Page size (default: 10) | +| `--caller-uac-account-id ` | string | no | Caller UAC account ID | +| `--namespace-id ` | string | no | Product namespace ID (Token Plan default: namespace-1) | +| `--status ` | array | no | Seat status filter (repeatable): CREATING, NORMAL, LIMIT, RELEASE, STOP, REFUNDED | +| `--status-list-str ` | string | no | StatusList as JSON string, e.g. '["NORMAL"]' | +| `--seat-id ` | string | no | Filter by seat ID | +| `--seat-type ` | string | no | Seat tier: standard, pro, or max | +| `--query-assigned ` | string | no | Filter by assignment: true=assigned, false=unassigned | +| `--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 seats +``` + +```bash +bl tokenplan seats --page-size 20 --status NORMAL +``` + +```bash +bl tokenplan seats --query-assigned true --seat-type standard +``` From d74686f09ff08f7ff1d8d727ac794c3369937fe4 Mon Sep 17 00:00:00 2001 From: wb-liuxuehuan Date: Tue, 23 Jun 2026 16:51:54 +0800 Subject: [PATCH 2/7] =?UTF-8?q?feat(tokenplan):=20=E6=B7=BB=E5=8A=A0=20Tok?= =?UTF-8?q?en=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 | From 1590e69d67f309bb8d0b1516f8c622b53c60c98a Mon Sep 17 00:00:00 2001 From: wb-liuxuehuan Date: Tue, 23 Jun 2026 17:42:10 +0800 Subject: [PATCH 3/7] =?UTF-8?q?feat(tokenplan):=20=E6=9B=B4=E6=96=B0=20Acc?= =?UTF-8?q?ountIds=20=E5=8F=82=E6=95=B0=E5=A4=84=E7=90=86=E9=80=BB?= =?UTF-8?q?=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修改 `assign-seats` 命令中的 AccountIds 参数处理,将字符串类型的 AccountIds 转换为数组。同时,更新相关的测试用例以验证新逻辑的正确性。 --- .../src/commands/tokenplan/assign-seats.ts | 2 +- packages/cli/tests/e2e/tokenplan.e2e.test.ts | 2 ++ packages/core/src/client/ak-sign.ts | 6 +++--- packages/core/tests/ak-sign.test.ts | 20 +++++++++++++++++++ 4 files changed, 26 insertions(+), 4 deletions(-) create mode 100644 packages/core/tests/ak-sign.test.ts diff --git a/packages/cli/src/commands/tokenplan/assign-seats.ts b/packages/cli/src/commands/tokenplan/assign-seats.ts index d453ae4..5dd1e76 100644 --- a/packages/cli/src/commands/tokenplan/assign-seats.ts +++ b/packages/cli/src/commands/tokenplan/assign-seats.ts @@ -165,7 +165,7 @@ function buildQueryParams( if (Array.isArray(accountIds) && accountIds.length > 0) { params.AccountIds = accountIds; } else if (typeof accountIds === "string" && accountIds.length > 0) { - params.AccountIds = accountIds; + params.AccountIds = [accountIds]; } return params; diff --git a/packages/cli/tests/e2e/tokenplan.e2e.test.ts b/packages/cli/tests/e2e/tokenplan.e2e.test.ts index 1635bbf..e264cbf 100644 --- a/packages/cli/tests/e2e/tokenplan.e2e.test.ts +++ b/packages/cli/tests/e2e/tokenplan.e2e.test.ts @@ -328,6 +328,8 @@ describe("e2e: tokenplan dry-run", () => { expect(data.query?.WorkspaceId).toBe("ws_456"); expect(data.query?.SeatType).toBe("standard"); expect(data.query?.AccountIds).toEqual(["acc_1", "acc_2"]); + expect(data.endpoint).toMatch(/AccountIds\.1=acc_1/); + expect(data.endpoint).toMatch(/AccountIds\.2=acc_2/); }); test("add-member --dry-run 输出 endpoint 和 query 参数", async () => { diff --git a/packages/core/src/client/ak-sign.ts b/packages/core/src/client/ak-sign.ts index d807ad3..1f55c46 100644 --- a/packages/core/src/client/ak-sign.ts +++ b/packages/core/src/client/ak-sign.ts @@ -28,9 +28,9 @@ export function buildCanonicalQuery(params: Record { + expect( + buildCanonicalQuery({ + WorkspaceId: "ws_1", + SeatType: "pro", + AccountIds: ["acc_1", "acc_2"], + }), + ).toBe("AccountIds.1=acc_1&AccountIds.2=acc_2&SeatType=pro&WorkspaceId=ws_1"); +}); + +test("buildCanonicalQuery uses single indexed key for one-element arrays", () => { + expect( + buildCanonicalQuery({ + AccountIds: ["acc_2bd88814c31743d9aa5833dc16b3b8e0"], + }), + ).toBe("AccountIds.1=acc_2bd88814c31743d9aa5833dc16b3b8e0"); +}); From 14105547e84c3eddf6d4a379c8aabd4b0b31cd98 Mon Sep 17 00:00:00 2001 From: wb-liuxuehuan Date: Tue, 23 Jun 2026 18:27:12 +0800 Subject: [PATCH 4/7] =?UTF-8?q?fix(tokenplan):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E5=8F=82=E6=95=B0=E5=A4=84=E7=90=86=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 更新 `assign-seats` 和 `seats` 命令中的参数处理逻辑,简化对 AccountIds 和 StatusList 的检查,确保在缺少必要参数时抛出相应错误。同时,增强对 `--query-assigned` 参数的验证,确保其值为 'true' 或 'false'。此更改提高了代码的可读性和健壮性。 --- .../cli/src/commands/tokenplan/assign-seats.ts | 13 ++++--------- packages/cli/src/commands/tokenplan/seats.ts | 14 ++++++++------ 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/packages/cli/src/commands/tokenplan/assign-seats.ts b/packages/cli/src/commands/tokenplan/assign-seats.ts index 5dd1e76..9d04759 100644 --- a/packages/cli/src/commands/tokenplan/assign-seats.ts +++ b/packages/cli/src/commands/tokenplan/assign-seats.ts @@ -86,11 +86,8 @@ export default defineCommand({ 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) { + const accountIds = flags.accountId as string[] | undefined; + if (!accountIds || accountIds.length === 0) { throw new BailianError("Missing required argument --account-id.", ExitCode.USAGE); } @@ -161,11 +158,9 @@ function buildQueryParams( 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) { + const accountIds = flags.accountId as string[] | undefined; + if (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/seats.ts b/packages/cli/src/commands/tokenplan/seats.ts index 16ff409..879ee01 100644 --- a/packages/cli/src/commands/tokenplan/seats.ts +++ b/packages/cli/src/commands/tokenplan/seats.ts @@ -143,18 +143,20 @@ function buildQueryParams(flags: GlobalFlags): Record 0) { - params.StatusList = status as string[]; - } else if (typeof status === "string" && status.length > 0) { - params.StatusList = [status]; + const status = flags.status as string[] | undefined; + if (status && status.length > 0) { + params.StatusList = status; } if (flags.seatId) params.SeatId = flags.seatId as string; if (flags.seatType) params.SeatType = flags.seatType as string; if (typeof flags.queryAssigned === "string" && flags.queryAssigned.length > 0) { - params.QueryAssigned = flags.queryAssigned; + const val = flags.queryAssigned.toLowerCase(); + if (val !== "true" && val !== "false") { + throw new BailianError("--query-assigned must be 'true' or 'false'.", ExitCode.USAGE); + } + params.QueryAssigned = val; } return params; From ba1661356f29a10f9ac3039334ec39550cf9dfc2 Mon Sep 17 00:00:00 2001 From: wb-liuxuehuan Date: Wed, 24 Jun 2026 11:14:57 +0800 Subject: [PATCH 5/7] =?UTF-8?q?feat(tokenplan):=20=E9=87=8D=E6=9E=84=20Tok?= =?UTF-8?q?en=20Plan=20=E5=91=BD=E4=BB=A4=E4=BB=A5=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E6=96=B0=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对 `tokenplan` 相关命令进行了重构,新增了 `ak-sign` 模块以支持 ACS3-HMAC-SHA256 签名,优化了参数处理逻辑,简化了对凭证的处理。更新了 `add-member`、`assign-seats`、`create-key` 和 `seats` 命令,增强了对参数的验证和处理,确保代码的可读性和健壮性。同时,新增了类型定义和工具函数以支持更好的代码结构。 --- .../cli/src/commands/tokenplan/add-member.ts | 98 ++--- .../cli/src/commands/tokenplan/ak-sign.ts | 103 +++++ .../src/commands/tokenplan/assign-seats.ts | 117 ++---- .../cli/src/commands/tokenplan/create-key.ts | 114 ++---- packages/cli/src/commands/tokenplan/seats.ts | 100 ++--- packages/cli/src/commands/tokenplan/types.ts | 69 ++++ packages/cli/src/commands/tokenplan/utils.ts | 161 ++++++++ packages/cli/tests/e2e/helpers.ts | 9 - packages/cli/tests/e2e/tokenplan.e2e.test.ts | 379 ------------------ packages/core/src/client/ak-sign.ts | 31 +- packages/core/src/client/endpoints.ts | 13 - packages/core/src/client/index.ts | 3 +- packages/core/src/types/api.ts | 70 ---- packages/core/tests/ak-sign.test.ts | 20 - 14 files changed, 451 insertions(+), 836 deletions(-) create mode 100644 packages/cli/src/commands/tokenplan/ak-sign.ts create mode 100644 packages/cli/src/commands/tokenplan/types.ts create mode 100644 packages/cli/src/commands/tokenplan/utils.ts delete mode 100644 packages/cli/tests/e2e/tokenplan.e2e.test.ts delete mode 100644 packages/core/tests/ak-sign.test.ts diff --git a/packages/cli/src/commands/tokenplan/add-member.ts b/packages/cli/src/commands/tokenplan/add-member.ts index f9c950e..4fb7fa5 100644 --- a/packages/cli/src/commands/tokenplan/add-member.ts +++ b/packages/cli/src/commands/tokenplan/add-member.ts @@ -1,21 +1,24 @@ 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"; +import type { AddOrganizationMemberResponse } from "./types.ts"; +import { + TOKEN_PLAN_AK_OPTIONS, + TOKEN_PLAN_COMMON_QUERY_OPTIONS, + appendCommonQueryParams, + callTokenPlanApi, + prepareTokenPlanRequest, + resolveTokenPlanCredentials, + type TokenPlanQueryParams, +} from "./utils.ts"; -const API_VERSION = "2026-02-10"; const API_ACTION = "AddOrganizationMember"; const API_PATH = "/tokenplan/organization/member-additions"; @@ -36,19 +39,8 @@ export default defineCommand({ 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)", - }, + ...TOKEN_PLAN_COMMON_QUERY_OPTIONS, + ...TOKEN_PLAN_AK_OPTIONS, ], examples: [ "bl tokenplan add-member --account-name dev_user --org-id org_123", @@ -57,16 +49,7 @@ export default defineCommand({ ], 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 credentials = resolveTokenPlanCredentials(config, flags); const accountName = flags.accountName as string | undefined; const orgId = flags.orgId as string | undefined; @@ -78,52 +61,26 @@ export default defineCommand({ } 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); + const { endpoint, queryParams: query } = prepareTokenPlanRequest( + config, + API_PATH, + queryParams, + ); + emitResult({ endpoint, query }, format); return; } - const headers = signRequest({ - accessKeyId, - accessKeySecret, + const data = await callTokenPlanApi({ + config, + credentials, action: API_ACTION, - version: API_VERSION, - body: "", - host, - pathname: API_PATH, + path: API_PATH, method: "POST", - queryString, + queryParams, }); - 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 { @@ -132,8 +89,8 @@ export default defineCommand({ }, }); -function buildQueryParams(flags: GlobalFlags): Record { - const params: Record = {}; +function buildQueryParams(flags: GlobalFlags): TokenPlanQueryParams { + const params: TokenPlanQueryParams = {}; if (flags.accountName) params.AccountName = flags.accountName as string; if (flags.orgId) params.OrgId = flags.orgId as string; @@ -142,8 +99,7 @@ function buildQueryParams(flags: GlobalFlags): Record): string { + const pairs: Array<[string, string]> = []; + for (const [key, value] of Object.entries(params)) { + if (value === undefined || value === "") continue; + if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) { + const v = value[i]; + if (v !== "") pairs.push([`${key}.${i + 1}`, v]); + } + } else { + pairs.push([key, value]); + } + } + pairs.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); + return pairs.map(([k, v]) => `${encodeRFC3986(k)}=${encodeRFC3986(v)}`).join("&"); +} + +function encodeRFC3986(str: string): string { + return encodeURIComponent(str).replace( + /[!'()*]/g, + (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`, + ); +} + +export function signTokenPlanRequest(cfg: TokenPlanAkSignConfig): Record { + const method = cfg.method ?? "POST"; + const now = new Date(); + const dateISO = now.toISOString().replace(/\.\d{3}Z$/, "Z"); + const nonce = randomUUID(); + + const hashedBody = sha256Hex(cfg.body); + + const headers: Record = { + host: cfg.host, + "x-acs-action": cfg.action, + "x-acs-version": cfg.version, + "x-acs-date": dateISO, + "x-acs-signature-nonce": nonce, + "x-acs-content-sha256": hashedBody, + "content-type": "application/json", + }; + + const signedHeaderKeys = Object.keys(headers) + .filter((k) => k === "host" || k === "content-type" || k.startsWith("x-acs-")) + .sort(); + + const canonicalHeaders = signedHeaderKeys.map((k) => `${k}:${headers[k]}`).join("\n") + "\n"; + + const signedHeadersStr = signedHeaderKeys.join(";"); + + const queryString = cfg.queryString ?? ""; + + const canonicalRequest = [ + method, + cfg.pathname, + queryString, + canonicalHeaders, + signedHeadersStr, + hashedBody, + ].join("\n"); + + const algorithm = "ACS3-HMAC-SHA256"; + const hashedCanonical = sha256Hex(canonicalRequest); + const stringToSign = `${algorithm}\n${hashedCanonical}`; + + const signature = hmacSHA256Hex(cfg.accessKeySecret, stringToSign); + + headers["authorization"] = + `${algorithm} Credential=${cfg.accessKeyId},SignedHeaders=${signedHeadersStr},Signature=${signature}`; + + return headers; +} + +function sha256Hex(data: string): string { + return createHash("sha256").update(data, "utf8").digest("hex"); +} + +function hmacSHA256Hex(key: string, data: string): string { + return createHmac("sha256", key).update(data, "utf8").digest("hex"); +} diff --git a/packages/cli/src/commands/tokenplan/assign-seats.ts b/packages/cli/src/commands/tokenplan/assign-seats.ts index 9d04759..96da7ad 100644 --- a/packages/cli/src/commands/tokenplan/assign-seats.ts +++ b/packages/cli/src/commands/tokenplan/assign-seats.ts @@ -1,20 +1,25 @@ 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"; +import type { BatchAssignSeatsResponse } from "./types.ts"; +import { + TOKEN_PLAN_AK_OPTIONS, + TOKEN_PLAN_COMMON_QUERY_OPTIONS, + TOKEN_PLAN_WORKSPACE_OPTION, + appendCommonQueryParams, + callTokenPlanApi, + prepareTokenPlanRequest, + requireWorkspaceId, + resolveTokenPlanCredentials, + type TokenPlanQueryParams, +} from "./utils.ts"; -const API_VERSION = "2026-02-10"; const API_ACTION = "BatchAssignSeats"; const API_PATH = "/tokenplan/subscription/seat-assignments"; @@ -24,10 +29,7 @@ export default defineCommand({ 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)", - }, + TOKEN_PLAN_WORKSPACE_OPTION, { flag: "--seat-type ", description: "Seat tier: standard, pro, or max", @@ -38,23 +40,12 @@ export default defineCommand({ 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)", - }, + ...TOKEN_PLAN_COMMON_QUERY_OPTIONS, { 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)", - }, + ...TOKEN_PLAN_AK_OPTIONS, ], examples: [ "bl tokenplan assign-seats --workspace-id ws_456 --seat-type standard --account-id acc_123", @@ -62,26 +53,10 @@ export default defineCommand({ ], 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 credentials = resolveTokenPlanCredentials(config, flags); - const workspaceId = (flags.workspaceId as string) || config.workspaceId; + const workspaceId = requireWorkspaceId(config, flags); 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); } @@ -92,52 +67,26 @@ export default defineCommand({ } 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); + const { endpoint, queryParams: query } = prepareTokenPlanRequest( + config, + API_PATH, + queryParams, + ); + emitResult({ endpoint, query }, format); return; } - const headers = signRequest({ - accessKeyId, - accessKeySecret, + const data = await callTokenPlanApi({ + config, + credentials, 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, { + path: API_PATH, method: "POST", - headers: { ...headers, ...trackingHeaders() }, - signal: AbortSignal.timeout(timeoutMs), + queryParams, }); - 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 { @@ -146,16 +95,12 @@ export default defineCommand({ }, }); -function buildQueryParams( - flags: GlobalFlags, - workspaceId: string, -): Record { - const params: Record = {}; +function buildQueryParams(flags: GlobalFlags, workspaceId: string): TokenPlanQueryParams { + const params: TokenPlanQueryParams = {}; 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; + appendCommonQueryParams(params, flags); if (flags.locale) params.Locale = flags.locale as string; const accountIds = flags.accountId as string[] | undefined; diff --git a/packages/cli/src/commands/tokenplan/create-key.ts b/packages/cli/src/commands/tokenplan/create-key.ts index e2fe431..00e19f9 100644 --- a/packages/cli/src/commands/tokenplan/create-key.ts +++ b/packages/cli/src/commands/tokenplan/create-key.ts @@ -1,21 +1,26 @@ 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"; +import type { CreateTokenPlanKeyResponse } from "./types.ts"; +import { + TOKEN_PLAN_AK_OPTIONS, + TOKEN_PLAN_COMMON_QUERY_OPTIONS, + TOKEN_PLAN_WORKSPACE_OPTION, + appendCommonQueryParams, + callTokenPlanApi, + prepareTokenPlanRequest, + requireWorkspaceId, + resolveTokenPlanCredentials, + type TokenPlanQueryParams, +} from "./utils.ts"; -const API_VERSION = "2026-02-10"; const API_ACTION = "CreateTokenPlanKey"; const API_PATH = "/tokenplan/api-keys"; @@ -25,24 +30,10 @@ export default defineCommand({ 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)", - }, + TOKEN_PLAN_WORKSPACE_OPTION, { 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)", - }, + ...TOKEN_PLAN_COMMON_QUERY_OPTIONS, + ...TOKEN_PLAN_AK_OPTIONS, ], examples: [ "bl tokenplan create-key --account-id acc_123 --workspace-id ws_456", @@ -50,77 +41,35 @@ export default defineCommand({ ], 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 credentials = resolveTokenPlanCredentials(config, flags); const accountId = flags.accountId as string | undefined; - const workspaceId = (flags.workspaceId as string) || config.workspaceId; + const workspaceId = requireWorkspaceId(config, flags); 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); + const { endpoint, queryParams: query } = prepareTokenPlanRequest( + config, + API_PATH, + queryParams, + ); + emitResult({ endpoint, query }, format); return; } - const headers = signRequest({ - accessKeyId, - accessKeySecret, + const data = await callTokenPlanApi({ + config, + credentials, 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, { + path: API_PATH, method: "POST", - headers: { ...headers, ...trackingHeaders() }, - signal: AbortSignal.timeout(timeoutMs), + queryParams, }); - 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 { @@ -132,14 +81,13 @@ export default defineCommand({ function buildQueryParams( flags: GlobalFlags, resolved: { accountId: string; workspaceId: string }, -): Record { - const params: Record = {}; +): TokenPlanQueryParams { + const params: TokenPlanQueryParams = {}; 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; + appendCommonQueryParams(params, flags); return params; } diff --git a/packages/cli/src/commands/tokenplan/seats.ts b/packages/cli/src/commands/tokenplan/seats.ts index 879ee01..ab4a2c6 100644 --- a/packages/cli/src/commands/tokenplan/seats.ts +++ b/packages/cli/src/commands/tokenplan/seats.ts @@ -1,22 +1,24 @@ import { defineCommand, - buildCanonicalQuery, - signRequest, - modelStudioHost, detectOutputFormat, - maskToken, - trackingHeaders, type Config, type GlobalFlags, - type GetSubscriptionSeatDetailsResponse, - type TokenPlanSeatDetail, BailianError, ExitCode, } from "bailian-cli-core"; import { emitResult, emitBare } from "../../output/output.ts"; import { padEnd } from "../../output/cjk-width.ts"; +import type { GetSubscriptionSeatDetailsResponse, TokenPlanSeatDetail } from "./types.ts"; +import { + TOKEN_PLAN_AK_OPTIONS, + TOKEN_PLAN_COMMON_QUERY_OPTIONS, + appendCommonQueryParams, + callTokenPlanApi, + prepareTokenPlanRequest, + resolveTokenPlanCredentials, + type TokenPlanQueryParams, +} from "./utils.ts"; -const API_VERSION = "2026-02-10"; const API_ACTION = "GetSubscriptionSeatDetails"; const API_PATH = "/tokenplan/subscription/seat-detail"; @@ -27,14 +29,7 @@ export default defineCommand({ options: [ { flag: "--page-no ", description: "Page number (default: 1)", type: "number" }, { flag: "--page-size ", description: "Page size (default: 10)", type: "number" }, - { - flag: "--caller-uac-account-id ", - description: "Caller UAC account ID", - }, - { - flag: "--namespace-id ", - description: "Product namespace ID (Token Plan default: namespace-1)", - }, + ...TOKEN_PLAN_COMMON_QUERY_OPTIONS, { flag: "--status ", description: @@ -54,11 +49,7 @@ export default defineCommand({ flag: "--query-assigned ", description: "Filter by assignment: true=assigned, false=unassigned", }, - { flag: "--access-key-id ", description: "Alibaba Cloud Access Key ID (deprecated)" }, - { - flag: "--access-key-secret ", - description: "Alibaba Cloud Access Key Secret (deprecated)", - }, + ...TOKEN_PLAN_AK_OPTIONS, ], examples: [ "bl tokenplan seats", @@ -67,64 +58,28 @@ export default defineCommand({ ], 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 credentials = resolveTokenPlanCredentials(config, flags); 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); + const { endpoint, queryParams: query } = prepareTokenPlanRequest( + config, + API_PATH, + queryParams, + ); + emitResult({ endpoint, query }, format); return; } - const headers = signRequest({ - accessKeyId, - accessKeySecret, + const data = await callTokenPlanApi({ + config, + credentials, action: API_ACTION, - version: API_VERSION, - body: "", - host, - pathname: API_PATH, + path: API_PATH, method: "GET", - queryString, + queryParams, }); - if (config.verbose) { - process.stderr.write(`> GET ${endpoint}\n`); - process.stderr.write(`> AK: ${maskToken(accessKeyId)}\n`); - } - - const timeoutMs = config.timeout * 1000; - const res = await fetch(endpoint, { - method: "GET", - headers: { ...headers, ...trackingHeaders() }, - signal: AbortSignal.timeout(timeoutMs), - }); - - if (config.verbose) { - process.stderr.write(`< ${res.status} ${res.statusText}\n`); - } - - const data = (await res.json()) as GetSubscriptionSeatDetailsResponse; - - if (!res.ok || data.Success === false) { - throw new BailianError( - `${data.Code || res.status} - ${data.Message || res.statusText}`, - ExitCode.GENERAL, - ); - } - const items = data.Data?.Items ?? []; if (config.quiet || format === "text") { emitTextSeats(items, data.Data?.Total, data.Data?.PageNo, data.Data?.PageSize); @@ -134,13 +89,12 @@ export default defineCommand({ }, }); -function buildQueryParams(flags: GlobalFlags): Record { - const params: Record = {}; +function buildQueryParams(flags: GlobalFlags): TokenPlanQueryParams { + const params: TokenPlanQueryParams = {}; if (flags.pageNo !== undefined) params.PageNo = String(flags.pageNo as number); if (flags.pageSize !== undefined) params.PageSize = String(flags.pageSize as number); - if (flags.callerUacAccountId) params.CallerUacAccountId = flags.callerUacAccountId as string; - if (flags.namespaceId) params.NamespaceId = flags.namespaceId as string; + appendCommonQueryParams(params, flags); if (flags.statusListStr) params.StatusListStr = flags.statusListStr as string; const status = flags.status as string[] | undefined; diff --git a/packages/cli/src/commands/tokenplan/types.ts b/packages/cli/src/commands/tokenplan/types.ts new file mode 100644 index 0000000..86fa661 --- /dev/null +++ b/packages/cli/src/commands/tokenplan/types.ts @@ -0,0 +1,69 @@ +// ---- Token Plan / ModelStudio POP (2026-02-10) ---- + +export interface TokenPlanSeatEquity { + EquityType?: string; + CycleInstanceId?: string; + CycleStartTime?: number; + CycleEndTime?: number; + CycleTotalValue?: number; + CycleSurplusValue?: number; + CycleVersion?: number; +} + +export interface TokenPlanSeatDetail { + InstanceCode?: string; + EquityList?: TokenPlanSeatEquity[]; + EndTime?: number; + SeatId?: string; + SpecType?: string; + StartTime?: number; + AssignedStatus?: string; + AccountId?: string; + AccountName?: string; + AccountEmail?: string; + Status?: string; +} + +export interface GetSubscriptionSeatDetailsResponse { + Success?: boolean; + Code?: string; + Message?: string; + Data?: { + Items?: TokenPlanSeatDetail[]; + Total?: number; + PageNo?: number; + PageSize?: number; + }; +} + +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; + }; +} diff --git a/packages/cli/src/commands/tokenplan/utils.ts b/packages/cli/src/commands/tokenplan/utils.ts new file mode 100644 index 0000000..674295a --- /dev/null +++ b/packages/cli/src/commands/tokenplan/utils.ts @@ -0,0 +1,161 @@ +import { + REGIONS, + maskToken, + trackingHeaders, + type Config, + type GlobalFlags, + type OptionDef, + type Region, + BailianError, + ExitCode, +} from "bailian-cli-core"; +import { buildCanonicalQuery, signTokenPlanRequest } from "./ak-sign.ts"; + +export const TOKEN_PLAN_API_VERSION = "2026-02-10"; + +export const TOKEN_PLAN_AK_OPTIONS: OptionDef[] = [ + { flag: "--access-key-id ", description: "Alibaba Cloud Access Key ID (deprecated)" }, + { + flag: "--access-key-secret ", + description: "Alibaba Cloud Access Key Secret (deprecated)", + }, +]; + +export const TOKEN_PLAN_COMMON_QUERY_OPTIONS: OptionDef[] = [ + { + flag: "--caller-uac-account-id ", + description: "Caller UAC account ID", + }, + { + flag: "--namespace-id ", + description: "Product namespace ID (Token Plan default: namespace-1)", + }, +]; + +export const TOKEN_PLAN_WORKSPACE_OPTION: OptionDef = { + flag: "--workspace-id ", + description: "Workspace ID (env: BAILIAN_WORKSPACE_ID, config: workspace_id)", +}; + +const MODEL_STUDIO_HOSTS: Partial> = { + cn: "modelstudio.cn-beijing.aliyuncs.com", + intl: "modelstudio.ap-southeast-1.aliyuncs.com", +}; + +function resolveRegion(baseUrl: string): Region { + for (const [region, url] of Object.entries(REGIONS) as Array<[Region, string]>) { + if (baseUrl === url || baseUrl.startsWith(`${url}/`)) return region; + } + return "cn"; +} + +/** ModelStudio POP OpenAPI host for the given DashScope base URL preset. */ +function modelStudioHost(baseUrl: string): string { + const region = resolveRegion(baseUrl); + return MODEL_STUDIO_HOSTS[region] ?? MODEL_STUDIO_HOSTS.cn!; +} + +export interface TokenPlanApiResponse { + Success?: boolean; + Code?: string; + Message?: string; +} + +export type TokenPlanQueryParams = Record; + +export function resolveTokenPlanCredentials( + config: Config, + flags: GlobalFlags, +): { accessKeyId: string; accessKeySecret: string } { + 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, + ); + } + + return { accessKeyId, accessKeySecret }; +} + +export function requireWorkspaceId(config: Config, flags: GlobalFlags): string { + const workspaceId = (flags.workspaceId as string) || config.workspaceId; + 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, + ); + } + return workspaceId; +} + +export function appendCommonQueryParams(params: TokenPlanQueryParams, flags: GlobalFlags): void { + if (flags.callerUacAccountId) params.CallerUacAccountId = flags.callerUacAccountId as string; + if (flags.namespaceId) params.NamespaceId = flags.namespaceId as string; +} + +export function prepareTokenPlanRequest( + config: Config, + path: string, + queryParams: TokenPlanQueryParams, +): { host: string; endpoint: string; queryString: string; queryParams: TokenPlanQueryParams } { + const queryString = buildCanonicalQuery(queryParams); + const host = modelStudioHost(config.baseUrl); + const endpoint = `https://${host}${path}${queryString ? `?${queryString}` : ""}`; + return { host, endpoint, queryString, queryParams }; +} + +export async function callTokenPlanApi(opts: { + config: Config; + credentials: { accessKeyId: string; accessKeySecret: string }; + action: string; + path: string; + method: "GET" | "POST"; + queryParams: TokenPlanQueryParams; +}): Promise { + const { config, credentials, action, path, method, queryParams } = opts; + const { host, endpoint, queryString } = prepareTokenPlanRequest(config, path, queryParams); + + const headers = signTokenPlanRequest({ + accessKeyId: credentials.accessKeyId, + accessKeySecret: credentials.accessKeySecret, + action, + version: TOKEN_PLAN_API_VERSION, + body: "", + host, + pathname: path, + method, + queryString, + }); + + if (config.verbose) { + process.stderr.write(`> ${method} ${endpoint}\n`); + process.stderr.write(`> AK: ${maskToken(credentials.accessKeyId)}\n`); + } + + const timeoutMs = config.timeout * 1000; + const res = await fetch(endpoint, { + method, + headers: { ...headers, ...trackingHeaders() }, + signal: AbortSignal.timeout(timeoutMs), + }); + + if (config.verbose) { + process.stderr.write(`< ${res.status} ${res.statusText}\n`); + } + + const data = (await res.json()) as T; + + if (!res.ok || data.Success === false) { + throw new BailianError( + `${data.Code || res.status} - ${data.Message || res.statusText}`, + ExitCode.GENERAL, + ); + } + + return data; +} diff --git a/packages/cli/tests/e2e/helpers.ts b/packages/cli/tests/e2e/helpers.ts index 63d5b97..e35b8a3 100644 --- a/packages/cli/tests/e2e/helpers.ts +++ b/packages/cli/tests/e2e/helpers.ts @@ -136,15 +136,6 @@ export function isKnowledgeAkSkReady(): boolean { ); } -/** Token Plan POP commands (AK/SK only). */ -export function isTokenPlanAkSkReady(): boolean { - return ( - isBailianE2EEnabled() && - !!process.env.ALIBABA_CLOUD_ACCESS_KEY_ID && - !!process.env.ALIBABA_CLOUD_ACCESS_KEY_SECRET - ); -} - export interface RunCliResult { stdout: string; stderr: string; diff --git a/packages/cli/tests/e2e/tokenplan.e2e.test.ts b/packages/cli/tests/e2e/tokenplan.e2e.test.ts deleted file mode 100644 index e264cbf..0000000 --- a/packages/cli/tests/e2e/tokenplan.e2e.test.ts +++ /dev/null @@ -1,379 +0,0 @@ -import { tmpdir } from "os"; -import { describe, expect, test } from "vite-plus/test"; -import { isTokenPlanAkSkReady, parseStdoutJson, runCli } from "./helpers.ts"; - -interface DryRunBody { - endpoint?: string; - query?: Record; -} - -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 () => { - const { stderr, exitCode } = await runCli(["tokenplan", "seats", "--help"]); - expect(exitCode, stderr).toBe(0); - expect(stderr).toMatch(/--page-no/i); - expect(stderr).toMatch(/--page-size/i); - expect(stderr).toMatch(/--seat-id/i); - 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 errors", () => { - test("seats 无任何凭证时提示 No credentials found 并非零退出", async () => { - const { stderr, exitCode } = await runCli( - ["tokenplan", "seats", "--non-interactive", "--output", "json"], - 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.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", - "seats", - "--dry-run", - "--page-no", - "1", - "--page-size", - "10", - "--status", - "NORMAL", - "--query-assigned", - "true", - "--non-interactive", - "--output", - "json", - ], - fakeAkEnv, - ); - expect(exitCode, stderr).toBe(0); - const data = parseStdoutJson(stdout); - expect(data.endpoint).toMatch(/\/tokenplan\/subscription\/seat-detail/); - expect(data.query?.PageNo).toBe("1"); - expect(data.query?.PageSize).toBe("10"); - 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"]); - expect(data.endpoint).toMatch(/AccountIds\.1=acc_1/); - expect(data.endpoint).toMatch(/AccountIds\.2=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)", () => { - test("GetSubscriptionSeatDetails 真实调用", async () => { - const { stdout, stderr, exitCode } = await runCli([ - "tokenplan", - "seats", - "--page-size", - "5", - "--non-interactive", - "--output", - "json", - ]); - expect(exitCode, stderr).toBe(0); - const data = parseStdoutJson<{ Success?: boolean; Data?: { Items?: unknown[] } }>(stdout); - expect(data.Success).toBe(true); - expect(Array.isArray(data.Data?.Items)).toBe(true); - }); -}); diff --git a/packages/core/src/client/ak-sign.ts b/packages/core/src/client/ak-sign.ts index 1f55c46..e9ed7be 100644 --- a/packages/core/src/client/ak-sign.ts +++ b/packages/core/src/client/ak-sign.ts @@ -18,33 +18,6 @@ export interface AkSignConfig { host: string; pathname: string; method?: string; - /** ACS3 canonical query string (sorted, encoded, no leading `?`). Empty for POST body-only APIs. */ - queryString?: string; -} - -/** Build ACS3 canonical query string from POP query parameters. */ -export function buildCanonicalQuery(params: Record): string { - const pairs: Array<[string, string]> = []; - for (const [key, value] of Object.entries(params)) { - if (value === undefined || value === "") continue; - if (Array.isArray(value)) { - for (let i = 0; i < value.length; i++) { - const v = value[i]; - if (v !== "") pairs.push([`${key}.${i + 1}`, v]); - } - } else { - pairs.push([key, value]); - } - } - pairs.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); - return pairs.map(([k, v]) => `${encodeRFC3986(k)}=${encodeRFC3986(v)}`).join("&"); -} - -function encodeRFC3986(str: string): string { - return encodeURIComponent(str).replace( - /[!'()*]/g, - (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`, - ); } export function signRequest(cfg: AkSignConfig): Record { @@ -74,13 +47,11 @@ export function signRequest(cfg: AkSignConfig): Record { const signedHeadersStr = signedHeaderKeys.join(";"); - const queryString = cfg.queryString ?? ""; - // Build canonical request const canonicalRequest = [ method, cfg.pathname, - queryString, + "", // query string (empty for POST) canonicalHeaders, signedHeadersStr, hashedBody, diff --git a/packages/core/src/client/endpoints.ts b/packages/core/src/client/endpoints.ts index cbcfa20..7cb4ab2 100644 --- a/packages/core/src/client/endpoints.ts +++ b/packages/core/src/client/endpoints.ts @@ -1,16 +1,3 @@ -import type { Region } from "../config/schema.ts"; - -const MODEL_STUDIO_HOSTS: Record = { - cn: "modelstudio.cn-beijing.aliyuncs.com", - us: "modelstudio.cn-beijing.aliyuncs.com", - intl: "modelstudio.ap-southeast-1.aliyuncs.com", -}; - -/** ModelStudio POP OpenAPI host for the given DashScope region preset. */ -export function modelStudioHost(region: Region): string { - return MODEL_STUDIO_HOSTS[region] ?? MODEL_STUDIO_HOSTS.cn; -} - // ---- Chat (OpenAI Compatible) ---- export function chatEndpoint(baseUrl: string): string { diff --git a/packages/core/src/client/index.ts b/packages/core/src/client/index.ts index 5c334c7..22be23e 100644 --- a/packages/core/src/client/index.ts +++ b/packages/core/src/client/index.ts @@ -1,5 +1,5 @@ export type { AkSignConfig } from "./ak-sign.ts"; -export { buildCanonicalQuery, signRequest } from "./ak-sign.ts"; +export { signRequest } from "./ak-sign.ts"; export { appCompletionEndpoint, chatEndpoint, @@ -10,7 +10,6 @@ export { memoryListEndpoint, memoryNodeEndpoint, memorySearchEndpoint, - modelStudioHost, mcpWebSearchEndpoint, profileSchemaEndpoint, speechRecognizeEndpoint, diff --git a/packages/core/src/types/api.ts b/packages/core/src/types/api.ts index cdf6bf5..87f0782 100644 --- a/packages/core/src/types/api.ts +++ b/packages/core/src/types/api.ts @@ -417,76 +417,6 @@ export interface DashScopeKnowledgeRetrieveResponse { }; } -// ---- Token Plan / ModelStudio POP (2026-02-10) ---- - -export interface TokenPlanSeatEquity { - EquityType?: string; - CycleInstanceId?: string; - CycleStartTime?: number; - CycleEndTime?: number; - CycleTotalValue?: number; - CycleSurplusValue?: number; - CycleVersion?: number; -} - -export interface TokenPlanSeatDetail { - InstanceCode?: string; - EquityList?: TokenPlanSeatEquity[]; - EndTime?: number; - SeatId?: string; - SpecType?: string; - StartTime?: number; - AssignedStatus?: string; - AccountId?: string; - AccountName?: string; - AccountEmail?: string; - Status?: string; -} - -export interface GetSubscriptionSeatDetailsResponse { - Success?: boolean; - Code?: string; - Message?: string; - Data?: { - Items?: TokenPlanSeatDetail[]; - Total?: number; - PageNo?: number; - PageSize?: number; - }; -} - -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/packages/core/tests/ak-sign.test.ts b/packages/core/tests/ak-sign.test.ts deleted file mode 100644 index 6633ee8..0000000 --- a/packages/core/tests/ak-sign.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { expect, test } from "vite-plus/test"; -import { buildCanonicalQuery } from "../src/client/ak-sign.ts"; - -test("buildCanonicalQuery flattens arrays as indexed keys", () => { - expect( - buildCanonicalQuery({ - WorkspaceId: "ws_1", - SeatType: "pro", - AccountIds: ["acc_1", "acc_2"], - }), - ).toBe("AccountIds.1=acc_1&AccountIds.2=acc_2&SeatType=pro&WorkspaceId=ws_1"); -}); - -test("buildCanonicalQuery uses single indexed key for one-element arrays", () => { - expect( - buildCanonicalQuery({ - AccountIds: ["acc_2bd88814c31743d9aa5833dc16b3b8e0"], - }), - ).toBe("AccountIds.1=acc_2bd88814c31743d9aa5833dc16b3b8e0"); -}); From b7fba7679ecaca6d7e1ce8b6d188be22b4f38bb2 Mon Sep 17 00:00:00 2001 From: "lisheng.lisheng" Date: Wed, 24 Jun 2026 12:34:15 +0800 Subject: [PATCH 6/7] chore: update Node.js version to 24 --- .node-version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.node-version b/.node-version index b832e40..a45fd52 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -24.16.0 +24 From 0ba705f19490ecc8b4b5d93b13597873919bdcf0 Mon Sep 17 00:00:00 2001 From: "lisheng.lisheng" Date: Wed, 24 Jun 2026 12:48:19 +0800 Subject: [PATCH 7/7] refactor(token-plan): rename top-level command tokenplan -> token-plan Rename the public command group from `bl tokenplan` to `bl token-plan` for kebab-case consistency. Source directory and reference doc renamed accordingly; remote API paths (/tokenplan/...) and internal TS identifiers are unchanged. Co-Authored-By: Claude Fable 5 --- packages/cli/README.zh.md | 10 +- packages/cli/src/commands/catalog.ts | 16 +- .../{tokenplan => token-plan}/add-member.ts | 10 +- .../{tokenplan => token-plan}/ak-sign.ts | 0 .../{tokenplan => token-plan}/assign-seats.ts | 8 +- .../{tokenplan => token-plan}/create-key.ts | 8 +- .../{tokenplan => token-plan}/seats.ts | 10 +- .../{tokenplan => token-plan}/types.ts | 0 .../{tokenplan => token-plan}/utils.ts | 0 skills/bailian-cli/reference/index.md | 152 +++++++++--------- .../reference/{tokenplan.md => token-plan.md} | 76 ++++----- 11 files changed, 145 insertions(+), 145 deletions(-) rename packages/cli/src/commands/{tokenplan => token-plan}/add-member.ts (89%) rename packages/cli/src/commands/{tokenplan => token-plan}/ak-sign.ts (100%) rename packages/cli/src/commands/{tokenplan => token-plan}/assign-seats.ts (89%) rename packages/cli/src/commands/{tokenplan => token-plan}/create-key.ts (91%) rename packages/cli/src/commands/{tokenplan => token-plan}/seats.ts (95%) rename packages/cli/src/commands/{tokenplan => token-plan}/types.ts (100%) rename packages/cli/src/commands/{tokenplan => token-plan}/utils.ts (100%) rename skills/bailian-cli/reference/{tokenplan.md => token-plan.md} (73%) diff --git a/packages/cli/README.zh.md b/packages/cli/README.zh.md index a593cef..6638ba5 100644 --- a/packages/cli/README.zh.md +++ b/packages/cli/README.zh.md @@ -124,10 +124,10 @@ 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 +bl token-plan seats # 查看订阅席位明细 +bl token-plan add-member --account-name dev --org-id org_xxx +bl token-plan assign-seats --workspace-id ws_xxx --seat-type standard --account-id acc_xxx +bl token-plan create-key --account-id acc_xxx --workspace-id ws_xxx ``` > 更多案例与使用场景:[阿里云百炼 CLI 官方主页](https://bailian.console.aliyun.com/cli?source_channel=cli_github&) @@ -159,7 +159,7 @@ bl auth login --console ### 阿里云 AK/SK(知识库检索与 Token Plan) -`knowledge retrieve` 与 `tokenplan` 命令组需要阿里云 AccessKey。前往 [RAM 控制台](https://ram.console.aliyun.com/manage/ak) 获取。 +`knowledge retrieve` 与 `token-plan` 命令组需要阿里云 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 2134f16..c0eb287 100644 --- a/packages/cli/src/commands/catalog.ts +++ b/packages/cli/src/commands/catalog.ts @@ -46,10 +46,10 @@ import quotaList from "./quota/list.ts"; 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"; +import tokenplanSeats from "./token-plan/seats.ts"; +import tokenplanCreateKey from "./token-plan/create-key.ts"; +import tokenplanAssignSeats from "./token-plan/assign-seats.ts"; +import tokenplanAddMember from "./token-plan/add-member.ts"; /** Command registry map (no dependency on registry.ts — safe for build-time import). */ export const commands: Record = { @@ -98,9 +98,9 @@ export const commands: Record = { "quota request": quotaRequest, "quota history": quotaHistory, "quota check": quotaCheck, - "tokenplan seats": tokenplanSeats, - "tokenplan create-key": tokenplanCreateKey, - "tokenplan assign-seats": tokenplanAssignSeats, - "tokenplan add-member": tokenplanAddMember, + "token-plan seats": tokenplanSeats, + "token-plan create-key": tokenplanCreateKey, + "token-plan assign-seats": tokenplanAssignSeats, + "token-plan add-member": tokenplanAddMember, update: update, }; diff --git a/packages/cli/src/commands/tokenplan/add-member.ts b/packages/cli/src/commands/token-plan/add-member.ts similarity index 89% rename from packages/cli/src/commands/tokenplan/add-member.ts rename to packages/cli/src/commands/token-plan/add-member.ts index 4fb7fa5..6bbe64c 100644 --- a/packages/cli/src/commands/tokenplan/add-member.ts +++ b/packages/cli/src/commands/token-plan/add-member.ts @@ -25,9 +25,9 @@ const API_PATH = "/tokenplan/organization/member-additions"; const DEFAULT_ORG_ROLE = "ORG_MEMBER"; export default defineCommand({ - name: "tokenplan add-member", + name: "token-plan add-member", description: "Add a member to a Token Plan organization", - usage: "bl tokenplan add-member --account-name --org-id [flags]", + usage: "bl token-plan 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 }, @@ -43,9 +43,9 @@ export default defineCommand({ ...TOKEN_PLAN_AK_OPTIONS, ], 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", + "bl token-plan add-member --account-name dev_user --org-id org_123", + "bl token-plan add-member --account-name admin_user --org-id org_123 --org-role-code ORG_ADMIN", + "bl token-plan add-member --account-name member1 --org-id org_123 --spec-type standard", ], async run(config: Config, flags: GlobalFlags) { const format = detectOutputFormat(config.output); diff --git a/packages/cli/src/commands/tokenplan/ak-sign.ts b/packages/cli/src/commands/token-plan/ak-sign.ts similarity index 100% rename from packages/cli/src/commands/tokenplan/ak-sign.ts rename to packages/cli/src/commands/token-plan/ak-sign.ts diff --git a/packages/cli/src/commands/tokenplan/assign-seats.ts b/packages/cli/src/commands/token-plan/assign-seats.ts similarity index 89% rename from packages/cli/src/commands/tokenplan/assign-seats.ts rename to packages/cli/src/commands/token-plan/assign-seats.ts index 96da7ad..ec283d9 100644 --- a/packages/cli/src/commands/tokenplan/assign-seats.ts +++ b/packages/cli/src/commands/token-plan/assign-seats.ts @@ -24,10 +24,10 @@ const API_ACTION = "BatchAssignSeats"; const API_PATH = "/tokenplan/subscription/seat-assignments"; export default defineCommand({ - name: "tokenplan assign-seats", + name: "token-plan assign-seats", description: "Batch assign Token Plan seats to members", usage: - "bl tokenplan assign-seats --workspace-id --seat-type --account-id [flags]", + "bl token-plan assign-seats --workspace-id --seat-type --account-id [flags]", options: [ TOKEN_PLAN_WORKSPACE_OPTION, { @@ -48,8 +48,8 @@ export default defineCommand({ ...TOKEN_PLAN_AK_OPTIONS, ], 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", + "bl token-plan assign-seats --workspace-id ws_456 --seat-type standard --account-id acc_123", + "bl token-plan 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); diff --git a/packages/cli/src/commands/tokenplan/create-key.ts b/packages/cli/src/commands/token-plan/create-key.ts similarity index 91% rename from packages/cli/src/commands/tokenplan/create-key.ts rename to packages/cli/src/commands/token-plan/create-key.ts index 00e19f9..932dacd 100644 --- a/packages/cli/src/commands/tokenplan/create-key.ts +++ b/packages/cli/src/commands/token-plan/create-key.ts @@ -25,9 +25,9 @@ const API_ACTION = "CreateTokenPlanKey"; const API_PATH = "/tokenplan/api-keys"; export default defineCommand({ - name: "tokenplan create-key", + name: "token-plan create-key", description: "Create a Token Plan API key for a seat", - usage: "bl tokenplan create-key --account-id --workspace-id [flags]", + usage: "bl token-plan create-key --account-id --workspace-id [flags]", options: [ { flag: "--account-id ", description: "Target member account ID", required: true }, TOKEN_PLAN_WORKSPACE_OPTION, @@ -36,8 +36,8 @@ export default defineCommand({ ...TOKEN_PLAN_AK_OPTIONS, ], 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'", + "bl token-plan create-key --account-id acc_123 --workspace-id ws_456", + "bl token-plan create-key --account-id acc_123 --workspace-id ws_456 --description 'Dev key'", ], async run(config: Config, flags: GlobalFlags) { const format = detectOutputFormat(config.output); diff --git a/packages/cli/src/commands/tokenplan/seats.ts b/packages/cli/src/commands/token-plan/seats.ts similarity index 95% rename from packages/cli/src/commands/tokenplan/seats.ts rename to packages/cli/src/commands/token-plan/seats.ts index ab4a2c6..7c9a119 100644 --- a/packages/cli/src/commands/tokenplan/seats.ts +++ b/packages/cli/src/commands/token-plan/seats.ts @@ -23,9 +23,9 @@ const API_ACTION = "GetSubscriptionSeatDetails"; const API_PATH = "/tokenplan/subscription/seat-detail"; export default defineCommand({ - name: "tokenplan seats", + name: "token-plan seats", description: "List Token Plan subscription seat details", - usage: "bl tokenplan seats [flags]", + usage: "bl token-plan seats [flags]", options: [ { flag: "--page-no ", description: "Page number (default: 1)", type: "number" }, { flag: "--page-size ", description: "Page size (default: 10)", type: "number" }, @@ -52,9 +52,9 @@ export default defineCommand({ ...TOKEN_PLAN_AK_OPTIONS, ], examples: [ - "bl tokenplan seats", - "bl tokenplan seats --page-size 20 --status NORMAL", - "bl tokenplan seats --query-assigned true --seat-type standard", + "bl token-plan seats", + "bl token-plan seats --page-size 20 --status NORMAL", + "bl token-plan seats --query-assigned true --seat-type standard", ], async run(config: Config, flags: GlobalFlags) { const format = detectOutputFormat(config.output); diff --git a/packages/cli/src/commands/tokenplan/types.ts b/packages/cli/src/commands/token-plan/types.ts similarity index 100% rename from packages/cli/src/commands/tokenplan/types.ts rename to packages/cli/src/commands/token-plan/types.ts diff --git a/packages/cli/src/commands/tokenplan/utils.ts b/packages/cli/src/commands/token-plan/utils.ts similarity index 100% rename from packages/cli/src/commands/tokenplan/utils.ts rename to packages/cli/src/commands/token-plan/utils.ts diff --git a/skills/bailian-cli/reference/index.md b/skills/bailian-cli/reference/index.md index 54b9055..6d524e8 100644 --- a/skills/bailian-cli/reference/index.md +++ b/skills/bailian-cli/reference/index.md @@ -8,85 +8,85 @@ 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 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.1-t2v / happyhorse-1.1-i2v / wan2.6-t2v) | [video.md](video.md) | -| `bl video ref` | Reference-to-video generation (happyhorse-1.1-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 token-plan add-member` | Add a member to a Token Plan organization | [token-plan.md](token-plan.md) | +| `bl token-plan assign-seats` | Batch assign Token Plan seats to members | [token-plan.md](token-plan.md) | +| `bl token-plan create-key` | Create a Token Plan API key for a seat | [token-plan.md](token-plan.md) | +| `bl token-plan seats` | List Token Plan subscription seat details | [token-plan.md](token-plan.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.1-t2v / happyhorse-1.1-i2v / wan2.6-t2v) | [video.md](video.md) | +| `bl video ref` | Reference-to-video generation (happyhorse-1.1-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 -| Group | Commands | Reference | -| ----------- | ---------------------------------------------------------------------------- | ---------------------------- | -| `advisor` | `recommend` | [advisor.md](advisor.md) | -| `app` | `call`, `list` | [app.md](app.md) | -| `auth` | `login`, `logout`, `status` | [auth.md](auth.md) | -| `config` | `export-schema`, `set`, `show` | [config.md](config.md) | -| `console` | `call` | [console.md](console.md) | -| `file` | `upload` | [file.md](file.md) | -| `image` | `edit`, `generate` | [image.md](image.md) | -| `knowledge` | `retrieve` | [knowledge.md](knowledge.md) | -| `mcp` | `call`, `list`, `tools` | [mcp.md](mcp.md) | -| `memory` | `add`, `delete`, `list`, `profile create`, `profile get`, `search`, `update` | [memory.md](memory.md) | -| `omni` | `(root)` | [omni.md](omni.md) | -| `pipeline` | `run`, `validate` | [pipeline.md](pipeline.md) | -| `quota` | `check`, `history`, `list`, `request` | [quota.md](quota.md) | -| `search` | `web` | [search.md](search.md) | -| `speech` | `recognize`, `synthesize` | [speech.md](speech.md) | -| `text` | `chat` | [text.md](text.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) | -| `vision` | `describe` | [vision.md](vision.md) | -| `workspace` | `list` | [workspace.md](workspace.md) | +| Group | Commands | Reference | +| ------------ | ---------------------------------------------------------------------------- | ------------------------------ | +| `advisor` | `recommend` | [advisor.md](advisor.md) | +| `app` | `call`, `list` | [app.md](app.md) | +| `auth` | `login`, `logout`, `status` | [auth.md](auth.md) | +| `config` | `export-schema`, `set`, `show` | [config.md](config.md) | +| `console` | `call` | [console.md](console.md) | +| `file` | `upload` | [file.md](file.md) | +| `image` | `edit`, `generate` | [image.md](image.md) | +| `knowledge` | `retrieve` | [knowledge.md](knowledge.md) | +| `mcp` | `call`, `list`, `tools` | [mcp.md](mcp.md) | +| `memory` | `add`, `delete`, `list`, `profile create`, `profile get`, `search`, `update` | [memory.md](memory.md) | +| `omni` | `(root)` | [omni.md](omni.md) | +| `pipeline` | `run`, `validate` | [pipeline.md](pipeline.md) | +| `quota` | `check`, `history`, `list`, `request` | [quota.md](quota.md) | +| `search` | `web` | [search.md](search.md) | +| `speech` | `recognize`, `synthesize` | [speech.md](speech.md) | +| `text` | `chat` | [text.md](text.md) | +| `token-plan` | `add-member`, `assign-seats`, `create-key`, `seats` | [token-plan.md](token-plan.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) | +| `vision` | `describe` | [vision.md](vision.md) | +| `workspace` | `list` | [workspace.md](workspace.md) | ## Global flags diff --git a/skills/bailian-cli/reference/tokenplan.md b/skills/bailian-cli/reference/token-plan.md similarity index 73% rename from skills/bailian-cli/reference/tokenplan.md rename to skills/bailian-cli/reference/token-plan.md index a0be912..5166ce2 100644 --- a/skills/bailian-cli/reference/tokenplan.md +++ b/skills/bailian-cli/reference/token-plan.md @@ -1,4 +1,4 @@ -# `bl tokenplan` commands +# `bl token-plan` commands > Auto-generated from `packages/cli/src/commands/catalog.ts`. Do not edit by hand. > Regenerate: `pnpm --filter bailian-cli run generate:reference`. @@ -7,22 +7,22 @@ Index: [index.md](index.md) ## Commands in this group -| 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 | Description | +| ---------------------------- | ----------------------------------------- | +| `bl token-plan add-member` | Add a member to a Token Plan organization | +| `bl token-plan assign-seats` | Batch assign Token Plan seats to members | +| `bl token-plan create-key` | Create a Token Plan API key for a seat | +| `bl token-plan seats` | List Token Plan subscription seat details | ## Command details -### `bl tokenplan add-member` +### `bl token-plan 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]` | +| Field | Value | +| --------------- | ---------------------------------------------------------------------- | +| **Name** | `token-plan add-member` | +| **Description** | Add a member to a Token Plan organization | +| **Usage** | `bl token-plan add-member --account-name --org-id [flags]` | #### Options @@ -40,24 +40,24 @@ Index: [index.md](index.md) #### Examples ```bash -bl tokenplan add-member --account-name dev_user --org-id org_123 +bl token-plan 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 +bl token-plan 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 token-plan add-member --account-name member1 --org-id org_123 --spec-type standard ``` -### `bl tokenplan assign-seats` +### `bl token-plan 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]` | +| Field | Value | +| --------------- | --------------------------------------------------------------------------------------------- | +| **Name** | `token-plan assign-seats` | +| **Description** | Batch assign Token Plan seats to members | +| **Usage** | `bl token-plan assign-seats --workspace-id --seat-type --account-id [flags]` | #### Options @@ -75,20 +75,20 @@ bl tokenplan add-member --account-name member1 --org-id org_123 --spec-type stan #### Examples ```bash -bl tokenplan assign-seats --workspace-id ws_456 --seat-type standard --account-id acc_123 +bl token-plan 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 token-plan assign-seats --workspace-id ws_456 --seat-type pro --account-id acc_1 --account-id acc_2 ``` -### `bl tokenplan create-key` +### `bl token-plan 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]` | +| Field | Value | +| --------------- | ------------------------------------------------------------------------ | +| **Name** | `token-plan create-key` | +| **Description** | Create a Token Plan API key for a seat | +| **Usage** | `bl token-plan create-key --account-id --workspace-id [flags]` | #### Options @@ -105,20 +105,20 @@ bl tokenplan assign-seats --workspace-id ws_456 --seat-type pro --account-id acc #### Examples ```bash -bl tokenplan create-key --account-id acc_123 --workspace-id ws_456 +bl token-plan 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 token-plan create-key --account-id acc_123 --workspace-id ws_456 --description 'Dev key' ``` -### `bl tokenplan seats` +### `bl token-plan seats` | Field | Value | | --------------- | ----------------------------------------- | -| **Name** | `tokenplan seats` | +| **Name** | `token-plan seats` | | **Description** | List Token Plan subscription seat details | -| **Usage** | `bl tokenplan seats [flags]` | +| **Usage** | `bl token-plan seats [flags]` | #### Options @@ -139,13 +139,13 @@ bl tokenplan create-key --account-id acc_123 --workspace-id ws_456 --description #### Examples ```bash -bl tokenplan seats +bl token-plan seats ``` ```bash -bl tokenplan seats --page-size 20 --status NORMAL +bl token-plan seats --page-size 20 --status NORMAL ``` ```bash -bl tokenplan seats --query-assigned true --seat-type standard +bl token-plan seats --query-assigned true --seat-type standard ```