Skip to content
2 changes: 1 addition & 1 deletion .node-version
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
24.16.0
24
10 changes: 8 additions & 2 deletions packages/cli/README.zh.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -122,6 +122,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 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&)
Expand DownExpand Up@@ -151,9 +157,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` 与 `token-plan` 命令组需要阿里云 AccessKey。前往 [RAM 控制台](https://ram.console.aliyun.com/manage/ak) 获取。

> 建议:创建 RAM 子账号并授予最小权限,避免使用主账号 AK/SK。

Expand Down
8 changes: 8 additions & 0 deletions packages/cli/src/commands/catalog.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -46,6 +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 "./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<string, Command> = {
Expand DownExpand Up@@ -94,5 +98,9 @@ export const commands: Record<string, Command> = {
"quota request": quotaRequest,
"quota history": quotaHistory,
"quota check": quotaCheck,
"token-plan seats": tokenplanSeats,
"token-plan create-key": tokenplanCreateKey,
"token-plan assign-seats": tokenplanAssignSeats,
"token-plan add-member": tokenplanAddMember,
update: update,
};
116 changes: 116 additions & 0 deletions packages/cli/src/commands/token-plan/add-member.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
import {
defineCommand,
detectOutputFormat,
type Config,
type GlobalFlags,
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_ACTION = "AddOrganizationMember";
const API_PATH = "/tokenplan/organization/member-additions";

const DEFAULT_ORG_ROLE = "ORG_MEMBER";

export default defineCommand({
name: "token-plan add-member",
description: "Add a member to a Token Plan organization",
usage: "bl token-plan add-member --account-name <name> --org-id <id> [flags]",
options: [
{ flag: "--account-name <name>", description: "Member display name", required: true },
{ flag: "--org-id <id>", description: "Organization ID", required: true },
{
flag: "--org-role-code <code>",
description: "Organization role: ORG_ADMIN or ORG_MEMBER (default: ORG_MEMBER)",
},
{
flag: "--spec-type <type>",
description: "Seat tier to assign on creation: standard, pro, or max",
},
...TOKEN_PLAN_COMMON_QUERY_OPTIONS,
...TOKEN_PLAN_AK_OPTIONS,
],
examples: [
"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);
const credentials = resolveTokenPlanCredentials(config, flags);

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);

if (config.dryRun) {
const { endpoint, queryParams: query } = prepareTokenPlanRequest(
config,
API_PATH,
queryParams,
);
emitResult({ endpoint, query }, format);
return;
}

const data = await callTokenPlanApi<AddOrganizationMemberResponse>({
config,
credentials,
action: API_ACTION,
path: API_PATH,
method: "POST",
queryParams,
});

if (config.quiet || format === "text") {
emitTextMember(data);
} else {
emitResult(data, format);
}
},
});

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;
params.OrgRoleCode =
typeof flags.orgRoleCode === "string" && flags.orgRoleCode.length > 0
? flags.orgRoleCode
: DEFAULT_ORG_ROLE;
if (flags.specType) params.SpecType = flags.specType as string;
appendCommonQueryParams(params, flags);

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 ?? "-")}`);
}
103 changes: 103 additions & 0 deletions packages/cli/src/commands/token-plan/ak-sign.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
/**
* ACS3-HMAC-SHA256 signing for ModelStudio Token Plan POP APIs (query-string style).
*
* Extends the core ROA signer with canonical query string support required by
* Token Plan endpoints that pass parameters in the URL query.
*/

import { createHmac, createHash, randomUUID } from "crypto";

export interface TokenPlanAkSignConfig {
accessKeyId: string;
accessKeySecret: string;
action: string;
version: string;
body: string;
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, string | string[] | undefined>): 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<string, string> {
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<string, string> = {
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");
}
112 changes: 112 additions & 0 deletions packages/cli/src/commands/token-plan/assign-seats.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
import {
defineCommand,
detectOutputFormat,
type Config,
type GlobalFlags,
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_ACTION = "BatchAssignSeats";
const API_PATH = "/tokenplan/subscription/seat-assignments";

export default defineCommand({
name: "token-plan assign-seats",
description: "Batch assign Token Plan seats to members",
usage:
"bl token-plan assign-seats --workspace-id <id> --seat-type <type> --account-id <id> [flags]",
options: [
TOKEN_PLAN_WORKSPACE_OPTION,
{
flag: "--seat-type <type>",
description: "Seat tier: standard, pro, or max",
required: true,
},
{
flag: "--account-id <id>",
description: "Target member account ID (repeatable)",
type: "array",
},
...TOKEN_PLAN_COMMON_QUERY_OPTIONS,
{
flag: "--locale <locale>",
description: "Language: zh-CN or en-US",
},
...TOKEN_PLAN_AK_OPTIONS,
],
examples: [
"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);
const credentials = resolveTokenPlanCredentials(config, flags);

const workspaceId = requireWorkspaceId(config, flags);
const seatType = flags.seatType as string | undefined;
if (!seatType) {
throw new BailianError("Missing required argument --seat-type.", ExitCode.USAGE);
}

const accountIds = flags.accountId as string[] | undefined;
if (!accountIds || accountIds.length === 0) {
throw new BailianError("Missing required argument --account-id.", ExitCode.USAGE);
}

const queryParams = buildQueryParams(flags, workspaceId);

if (config.dryRun) {
const { endpoint, queryParams: query } = prepareTokenPlanRequest(
config,
API_PATH,
queryParams,
);
emitResult({ endpoint, query }, format);
return;
}

const data = await callTokenPlanApi<BatchAssignSeatsResponse>({
config,
credentials,
action: API_ACTION,
path: API_PATH,
method: "POST",
queryParams,
});

if (config.quiet || format === "text") {
emitBare("Seats assigned successfully.");
} else {
emitResult(data, format);
}
},
});

function buildQueryParams(flags: GlobalFlags, workspaceId: string): TokenPlanQueryParams {
const params: TokenPlanQueryParams = {};

params.WorkspaceId = workspaceId;
if (flags.seatType) params.SeatType = flags.seatType as string;
appendCommonQueryParams(params, flags);
if (flags.locale) params.Locale = flags.locale as string;

const accountIds = flags.accountId as string[] | undefined;
if (accountIds && accountIds.length > 0) {
params.AccountIds = accountIds;
}

return params;
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
feat(token-plan): add Token Plan organization & seats commands by lishengzxc · Pull Request #75 · modelstudioai/cli · GitHub
Skip to content
2 changes: 1 addition & 1 deletion .node-version
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
24.16.0
24
10 changes: 8 additions & 2 deletions packages/cli/README.zh.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -122,6 +122,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 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&)
Expand DownExpand Up@@ -151,9 +157,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` 与 `token-plan` 命令组需要阿里云 AccessKey。前往 [RAM 控制台](https://ram.console.aliyun.com/manage/ak) 获取。

> 建议:创建 RAM 子账号并授予最小权限,避免使用主账号 AK/SK。

Expand Down
8 changes: 8 additions & 0 deletions packages/cli/src/commands/catalog.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -46,6 +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 "./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<string, Command> = {
Expand DownExpand Up@@ -94,5 +98,9 @@ export const commands: Record<string, Command> = {
"quota request": quotaRequest,
"quota history": quotaHistory,
"quota check": quotaCheck,
"token-plan seats": tokenplanSeats,
"token-plan create-key": tokenplanCreateKey,
"token-plan assign-seats": tokenplanAssignSeats,
"token-plan add-member": tokenplanAddMember,
update: update,
};
116 changes: 116 additions & 0 deletions packages/cli/src/commands/token-plan/add-member.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
import {
defineCommand,
detectOutputFormat,
type Config,
type GlobalFlags,
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_ACTION = "AddOrganizationMember";
const API_PATH = "/tokenplan/organization/member-additions";

const DEFAULT_ORG_ROLE = "ORG_MEMBER";

export default defineCommand({
name: "token-plan add-member",
description: "Add a member to a Token Plan organization",
usage: "bl token-plan add-member --account-name <name> --org-id <id> [flags]",
options: [
{ flag: "--account-name <name>", description: "Member display name", required: true },
{ flag: "--org-id <id>", description: "Organization ID", required: true },
{
flag: "--org-role-code <code>",
description: "Organization role: ORG_ADMIN or ORG_MEMBER (default: ORG_MEMBER)",
},
{
flag: "--spec-type <type>",
description: "Seat tier to assign on creation: standard, pro, or max",
},
...TOKEN_PLAN_COMMON_QUERY_OPTIONS,
...TOKEN_PLAN_AK_OPTIONS,
],
examples: [
"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);
const credentials = resolveTokenPlanCredentials(config, flags);

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);

if (config.dryRun) {
const { endpoint, queryParams: query } = prepareTokenPlanRequest(
config,
API_PATH,
queryParams,
);
emitResult({ endpoint, query }, format);
return;
}

const data = await callTokenPlanApi<AddOrganizationMemberResponse>({
config,
credentials,
action: API_ACTION,
path: API_PATH,
method: "POST",
queryParams,
});

if (config.quiet || format === "text") {
emitTextMember(data);
} else {
emitResult(data, format);
}
},
});

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;
params.OrgRoleCode =
typeof flags.orgRoleCode === "string" && flags.orgRoleCode.length > 0
? flags.orgRoleCode
: DEFAULT_ORG_ROLE;
if (flags.specType) params.SpecType = flags.specType as string;
appendCommonQueryParams(params, flags);

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 ?? "-")}`);
}
103 changes: 103 additions & 0 deletions packages/cli/src/commands/token-plan/ak-sign.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
/**
* ACS3-HMAC-SHA256 signing for ModelStudio Token Plan POP APIs (query-string style).
*
* Extends the core ROA signer with canonical query string support required by
* Token Plan endpoints that pass parameters in the URL query.
*/

import { createHmac, createHash, randomUUID } from "crypto";

export interface TokenPlanAkSignConfig {
accessKeyId: string;
accessKeySecret: string;
action: string;
version: string;
body: string;
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, string | string[] | undefined>): 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<string, string> {
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<string, string> = {
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");
}
112 changes: 112 additions & 0 deletions packages/cli/src/commands/token-plan/assign-seats.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
import {
defineCommand,
detectOutputFormat,
type Config,
type GlobalFlags,
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_ACTION = "BatchAssignSeats";
const API_PATH = "/tokenplan/subscription/seat-assignments";

export default defineCommand({
name: "token-plan assign-seats",
description: "Batch assign Token Plan seats to members",
usage:
"bl token-plan assign-seats --workspace-id <id> --seat-type <type> --account-id <id> [flags]",
options: [
TOKEN_PLAN_WORKSPACE_OPTION,
{
flag: "--seat-type <type>",
description: "Seat tier: standard, pro, or max",
required: true,
},
{
flag: "--account-id <id>",
description: "Target member account ID (repeatable)",
type: "array",
},
...TOKEN_PLAN_COMMON_QUERY_OPTIONS,
{
flag: "--locale <locale>",
description: "Language: zh-CN or en-US",
},
...TOKEN_PLAN_AK_OPTIONS,
],
examples: [
"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);
const credentials = resolveTokenPlanCredentials(config, flags);

const workspaceId = requireWorkspaceId(config, flags);
const seatType = flags.seatType as string | undefined;
if (!seatType) {
throw new BailianError("Missing required argument --seat-type.", ExitCode.USAGE);
}

const accountIds = flags.accountId as string[] | undefined;
if (!accountIds || accountIds.length === 0) {
throw new BailianError("Missing required argument --account-id.", ExitCode.USAGE);
}

const queryParams = buildQueryParams(flags, workspaceId);

if (config.dryRun) {
const { endpoint, queryParams: query } = prepareTokenPlanRequest(
config,
API_PATH,
queryParams,
);
emitResult({ endpoint, query }, format);
return;
}

const data = await callTokenPlanApi<BatchAssignSeatsResponse>({
config,
credentials,
action: API_ACTION,
path: API_PATH,
method: "POST",
queryParams,
});

if (config.quiet || format === "text") {
emitBare("Seats assigned successfully.");
} else {
emitResult(data, format);
}
},
});

function buildQueryParams(flags: GlobalFlags, workspaceId: string): TokenPlanQueryParams {
const params: TokenPlanQueryParams = {};

params.WorkspaceId = workspaceId;
if (flags.seatType) params.SeatType = flags.seatType as string;
appendCommonQueryParams(params, flags);
if (flags.locale) params.Locale = flags.locale as string;

const accountIds = flags.accountId as string[] | undefined;
if (accountIds && accountIds.length > 0) {
params.AccountIds = accountIds;
}

return params;
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(token-plan): add Token Plan organization & seats commands by lishengzxc · Pull Request #75 · modelstudioai/cli · GitHub
Skip to content
2 changes: 1 addition & 1 deletion .node-version
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
24.16.0
24
10 changes: 8 additions & 2 deletions packages/cli/README.zh.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -122,6 +122,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 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&)
Expand DownExpand Up@@ -151,9 +157,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` 与 `token-plan` 命令组需要阿里云 AccessKey。前往 [RAM 控制台](https://ram.console.aliyun.com/manage/ak) 获取。

> 建议:创建 RAM 子账号并授予最小权限,避免使用主账号 AK/SK。

Expand Down
8 changes: 8 additions & 0 deletions packages/cli/src/commands/catalog.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -46,6 +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 "./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<string, Command> = {
Expand DownExpand Up@@ -94,5 +98,9 @@ export const commands: Record<string, Command> = {
"quota request": quotaRequest,
"quota history": quotaHistory,
"quota check": quotaCheck,
"token-plan seats": tokenplanSeats,
"token-plan create-key": tokenplanCreateKey,
"token-plan assign-seats": tokenplanAssignSeats,
"token-plan add-member": tokenplanAddMember,
update: update,
};
116 changes: 116 additions & 0 deletions packages/cli/src/commands/token-plan/add-member.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
import {
defineCommand,
detectOutputFormat,
type Config,
type GlobalFlags,
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_ACTION = "AddOrganizationMember";
const API_PATH = "/tokenplan/organization/member-additions";

const DEFAULT_ORG_ROLE = "ORG_MEMBER";

export default defineCommand({
name: "token-plan add-member",
description: "Add a member to a Token Plan organization",
usage: "bl token-plan add-member --account-name <name> --org-id <id> [flags]",
options: [
{ flag: "--account-name <name>", description: "Member display name", required: true },
{ flag: "--org-id <id>", description: "Organization ID", required: true },
{
flag: "--org-role-code <code>",
description: "Organization role: ORG_ADMIN or ORG_MEMBER (default: ORG_MEMBER)",
},
{
flag: "--spec-type <type>",
description: "Seat tier to assign on creation: standard, pro, or max",
},
...TOKEN_PLAN_COMMON_QUERY_OPTIONS,
...TOKEN_PLAN_AK_OPTIONS,
],
examples: [
"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);
const credentials = resolveTokenPlanCredentials(config, flags);

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);

if (config.dryRun) {
const { endpoint, queryParams: query } = prepareTokenPlanRequest(
config,
API_PATH,
queryParams,
);
emitResult({ endpoint, query }, format);
return;
}

const data = await callTokenPlanApi<AddOrganizationMemberResponse>({
config,
credentials,
action: API_ACTION,
path: API_PATH,
method: "POST",
queryParams,
});

if (config.quiet || format === "text") {
emitTextMember(data);
} else {
emitResult(data, format);
}
},
});

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;
params.OrgRoleCode =
typeof flags.orgRoleCode === "string" && flags.orgRoleCode.length > 0
? flags.orgRoleCode
: DEFAULT_ORG_ROLE;
if (flags.specType) params.SpecType = flags.specType as string;
appendCommonQueryParams(params, flags);

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 ?? "-")}`);
}
103 changes: 103 additions & 0 deletions packages/cli/src/commands/token-plan/ak-sign.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
/**
* ACS3-HMAC-SHA256 signing for ModelStudio Token Plan POP APIs (query-string style).
*
* Extends the core ROA signer with canonical query string support required by
* Token Plan endpoints that pass parameters in the URL query.
*/

import { createHmac, createHash, randomUUID } from "crypto";

export interface TokenPlanAkSignConfig {
accessKeyId: string;
accessKeySecret: string;
action: string;
version: string;
body: string;
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, string | string[] | undefined>): 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<string, string> {
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<string, string> = {
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");
}
112 changes: 112 additions & 0 deletions packages/cli/src/commands/token-plan/assign-seats.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
import {
defineCommand,
detectOutputFormat,
type Config,
type GlobalFlags,
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_ACTION = "BatchAssignSeats";
const API_PATH = "/tokenplan/subscription/seat-assignments";

export default defineCommand({
name: "token-plan assign-seats",
description: "Batch assign Token Plan seats to members",
usage:
"bl token-plan assign-seats --workspace-id <id> --seat-type <type> --account-id <id> [flags]",
options: [
TOKEN_PLAN_WORKSPACE_OPTION,
{
flag: "--seat-type <type>",
description: "Seat tier: standard, pro, or max",
required: true,
},
{
flag: "--account-id <id>",
description: "Target member account ID (repeatable)",
type: "array",
},
...TOKEN_PLAN_COMMON_QUERY_OPTIONS,
{
flag: "--locale <locale>",
description: "Language: zh-CN or en-US",
},
...TOKEN_PLAN_AK_OPTIONS,
],
examples: [
"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);
const credentials = resolveTokenPlanCredentials(config, flags);

const workspaceId = requireWorkspaceId(config, flags);
const seatType = flags.seatType as string | undefined;
if (!seatType) {
throw new BailianError("Missing required argument --seat-type.", ExitCode.USAGE);
}

const accountIds = flags.accountId as string[] | undefined;
if (!accountIds || accountIds.length === 0) {
throw new BailianError("Missing required argument --account-id.", ExitCode.USAGE);
}

const queryParams = buildQueryParams(flags, workspaceId);

if (config.dryRun) {
const { endpoint, queryParams: query } = prepareTokenPlanRequest(
config,
API_PATH,
queryParams,
);
emitResult({ endpoint, query }, format);
return;
}

const data = await callTokenPlanApi<BatchAssignSeatsResponse>({
config,
credentials,
action: API_ACTION,
path: API_PATH,
method: "POST",
queryParams,
});

if (config.quiet || format === "text") {
emitBare("Seats assigned successfully.");
} else {
emitResult(data, format);
}
},
});

function buildQueryParams(flags: GlobalFlags, workspaceId: string): TokenPlanQueryParams {
const params: TokenPlanQueryParams = {};

params.WorkspaceId = workspaceId;
if (flags.seatType) params.SeatType = flags.seatType as string;
appendCommonQueryParams(params, flags);
if (flags.locale) params.Locale = flags.locale as string;

const accountIds = flags.accountId as string[] | undefined;
if (accountIds && accountIds.length > 0) {
params.AccountIds = accountIds;
}

return params;
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(token-plan): add Token Plan organization & seats commands by lishengzxc · Pull Request #75 · modelstudioai/cli · GitHub
Skip to content
2 changes: 1 addition & 1 deletion .node-version
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
24.16.0
24
10 changes: 8 additions & 2 deletions packages/cli/README.zh.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -122,6 +122,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 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&)
Expand DownExpand Up@@ -151,9 +157,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` 与 `token-plan` 命令组需要阿里云 AccessKey。前往 [RAM 控制台](https://ram.console.aliyun.com/manage/ak) 获取。

> 建议:创建 RAM 子账号并授予最小权限,避免使用主账号 AK/SK。

Expand Down
8 changes: 8 additions & 0 deletions packages/cli/src/commands/catalog.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -46,6 +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 "./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<string, Command> = {
Expand DownExpand Up@@ -94,5 +98,9 @@ export const commands: Record<string, Command> = {
"quota request": quotaRequest,
"quota history": quotaHistory,
"quota check": quotaCheck,
"token-plan seats": tokenplanSeats,
"token-plan create-key": tokenplanCreateKey,
"token-plan assign-seats": tokenplanAssignSeats,
"token-plan add-member": tokenplanAddMember,
update: update,
};
116 changes: 116 additions & 0 deletions packages/cli/src/commands/token-plan/add-member.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
import {
defineCommand,
detectOutputFormat,
type Config,
type GlobalFlags,
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_ACTION = "AddOrganizationMember";
const API_PATH = "/tokenplan/organization/member-additions";

const DEFAULT_ORG_ROLE = "ORG_MEMBER";

export default defineCommand({
name: "token-plan add-member",
description: "Add a member to a Token Plan organization",
usage: "bl token-plan add-member --account-name <name> --org-id <id> [flags]",
options: [
{ flag: "--account-name <name>", description: "Member display name", required: true },
{ flag: "--org-id <id>", description: "Organization ID", required: true },
{
flag: "--org-role-code <code>",
description: "Organization role: ORG_ADMIN or ORG_MEMBER (default: ORG_MEMBER)",
},
{
flag: "--spec-type <type>",
description: "Seat tier to assign on creation: standard, pro, or max",
},
...TOKEN_PLAN_COMMON_QUERY_OPTIONS,
...TOKEN_PLAN_AK_OPTIONS,
],
examples: [
"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);
const credentials = resolveTokenPlanCredentials(config, flags);

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);

if (config.dryRun) {
const { endpoint, queryParams: query } = prepareTokenPlanRequest(
config,
API_PATH,
queryParams,
);
emitResult({ endpoint, query }, format);
return;
}

const data = await callTokenPlanApi<AddOrganizationMemberResponse>({
config,
credentials,
action: API_ACTION,
path: API_PATH,
method: "POST",
queryParams,
});

if (config.quiet || format === "text") {
emitTextMember(data);
} else {
emitResult(data, format);
}
},
});

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;
params.OrgRoleCode =
typeof flags.orgRoleCode === "string" && flags.orgRoleCode.length > 0
? flags.orgRoleCode
: DEFAULT_ORG_ROLE;
if (flags.specType) params.SpecType = flags.specType as string;
appendCommonQueryParams(params, flags);

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 ?? "-")}`);
}
103 changes: 103 additions & 0 deletions packages/cli/src/commands/token-plan/ak-sign.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
/**
* ACS3-HMAC-SHA256 signing for ModelStudio Token Plan POP APIs (query-string style).
*
* Extends the core ROA signer with canonical query string support required by
* Token Plan endpoints that pass parameters in the URL query.
*/

import { createHmac, createHash, randomUUID } from "crypto";

export interface TokenPlanAkSignConfig {
accessKeyId: string;
accessKeySecret: string;
action: string;
version: string;
body: string;
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, string | string[] | undefined>): 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<string, string> {
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<string, string> = {
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");
}
112 changes: 112 additions & 0 deletions packages/cli/src/commands/token-plan/assign-seats.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
import {
defineCommand,
detectOutputFormat,
type Config,
type GlobalFlags,
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_ACTION = "BatchAssignSeats";
const API_PATH = "/tokenplan/subscription/seat-assignments";

export default defineCommand({
name: "token-plan assign-seats",
description: "Batch assign Token Plan seats to members",
usage:
"bl token-plan assign-seats --workspace-id <id> --seat-type <type> --account-id <id> [flags]",
options: [
TOKEN_PLAN_WORKSPACE_OPTION,
{
flag: "--seat-type <type>",
description: "Seat tier: standard, pro, or max",
required: true,
},
{
flag: "--account-id <id>",
description: "Target member account ID (repeatable)",
type: "array",
},
...TOKEN_PLAN_COMMON_QUERY_OPTIONS,
{
flag: "--locale <locale>",
description: "Language: zh-CN or en-US",
},
...TOKEN_PLAN_AK_OPTIONS,
],
examples: [
"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);
const credentials = resolveTokenPlanCredentials(config, flags);

const workspaceId = requireWorkspaceId(config, flags);
const seatType = flags.seatType as string | undefined;
if (!seatType) {
throw new BailianError("Missing required argument --seat-type.", ExitCode.USAGE);
}

const accountIds = flags.accountId as string[] | undefined;
if (!accountIds || accountIds.length === 0) {
throw new BailianError("Missing required argument --account-id.", ExitCode.USAGE);
}

const queryParams = buildQueryParams(flags, workspaceId);

if (config.dryRun) {
const { endpoint, queryParams: query } = prepareTokenPlanRequest(
config,
API_PATH,
queryParams,
);
emitResult({ endpoint, query }, format);
return;
}

const data = await callTokenPlanApi<BatchAssignSeatsResponse>({
config,
credentials,
action: API_ACTION,
path: API_PATH,
method: "POST",
queryParams,
});

if (config.quiet || format === "text") {
emitBare("Seats assigned successfully.");
} else {
emitResult(data, format);
}
},
});

function buildQueryParams(flags: GlobalFlags, workspaceId: string): TokenPlanQueryParams {
const params: TokenPlanQueryParams = {};

params.WorkspaceId = workspaceId;
if (flags.seatType) params.SeatType = flags.seatType as string;
appendCommonQueryParams(params, flags);
if (flags.locale) params.Locale = flags.locale as string;

const accountIds = flags.accountId as string[] | undefined;
if (accountIds && accountIds.length > 0) {
params.AccountIds = accountIds;
}

return params;
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' feat(token-plan): add Token Plan organization & seats commands by lishengzxc · Pull Request #75 · modelstudioai/cli · GitHub
Skip to content
2 changes: 1 addition & 1 deletion .node-version
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
24.16.0
24
10 changes: 8 additions & 2 deletions packages/cli/README.zh.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -122,6 +122,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 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&)
Expand DownExpand Up@@ -151,9 +157,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` 与 `token-plan` 命令组需要阿里云 AccessKey。前往 [RAM 控制台](https://ram.console.aliyun.com/manage/ak) 获取。

> 建议:创建 RAM 子账号并授予最小权限,避免使用主账号 AK/SK。

Expand Down
8 changes: 8 additions & 0 deletions packages/cli/src/commands/catalog.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -46,6 +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 "./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<string, Command> = {
Expand DownExpand Up@@ -94,5 +98,9 @@ export const commands: Record<string, Command> = {
"quota request": quotaRequest,
"quota history": quotaHistory,
"quota check": quotaCheck,
"token-plan seats": tokenplanSeats,
"token-plan create-key": tokenplanCreateKey,
"token-plan assign-seats": tokenplanAssignSeats,
"token-plan add-member": tokenplanAddMember,
update: update,
};
116 changes: 116 additions & 0 deletions packages/cli/src/commands/token-plan/add-member.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
import {
defineCommand,
detectOutputFormat,
type Config,
type GlobalFlags,
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_ACTION = "AddOrganizationMember";
const API_PATH = "/tokenplan/organization/member-additions";

const DEFAULT_ORG_ROLE = "ORG_MEMBER";

export default defineCommand({
name: "token-plan add-member",
description: "Add a member to a Token Plan organization",
usage: "bl token-plan add-member --account-name <name> --org-id <id> [flags]",
options: [
{ flag: "--account-name <name>", description: "Member display name", required: true },
{ flag: "--org-id <id>", description: "Organization ID", required: true },
{
flag: "--org-role-code <code>",
description: "Organization role: ORG_ADMIN or ORG_MEMBER (default: ORG_MEMBER)",
},
{
flag: "--spec-type <type>",
description: "Seat tier to assign on creation: standard, pro, or max",
},
...TOKEN_PLAN_COMMON_QUERY_OPTIONS,
...TOKEN_PLAN_AK_OPTIONS,
],
examples: [
"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);
const credentials = resolveTokenPlanCredentials(config, flags);

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);

if (config.dryRun) {
const { endpoint, queryParams: query } = prepareTokenPlanRequest(
config,
API_PATH,
queryParams,
);
emitResult({ endpoint, query }, format);
return;
}

const data = await callTokenPlanApi<AddOrganizationMemberResponse>({
config,
credentials,
action: API_ACTION,
path: API_PATH,
method: "POST",
queryParams,
});

if (config.quiet || format === "text") {
emitTextMember(data);
} else {
emitResult(data, format);
}
},
});

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;
params.OrgRoleCode =
typeof flags.orgRoleCode === "string" && flags.orgRoleCode.length > 0
? flags.orgRoleCode
: DEFAULT_ORG_ROLE;
if (flags.specType) params.SpecType = flags.specType as string;
appendCommonQueryParams(params, flags);

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 ?? "-")}`);
}
103 changes: 103 additions & 0 deletions packages/cli/src/commands/token-plan/ak-sign.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
/**
* ACS3-HMAC-SHA256 signing for ModelStudio Token Plan POP APIs (query-string style).
*
* Extends the core ROA signer with canonical query string support required by
* Token Plan endpoints that pass parameters in the URL query.
*/

import { createHmac, createHash, randomUUID } from "crypto";

export interface TokenPlanAkSignConfig {
accessKeyId: string;
accessKeySecret: string;
action: string;
version: string;
body: string;
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, string | string[] | undefined>): 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<string, string> {
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<string, string> = {
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");
}
112 changes: 112 additions & 0 deletions packages/cli/src/commands/token-plan/assign-seats.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
import {
defineCommand,
detectOutputFormat,
type Config,
type GlobalFlags,
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_ACTION = "BatchAssignSeats";
const API_PATH = "/tokenplan/subscription/seat-assignments";

export default defineCommand({
name: "token-plan assign-seats",
description: "Batch assign Token Plan seats to members",
usage:
"bl token-plan assign-seats --workspace-id <id> --seat-type <type> --account-id <id> [flags]",
options: [
TOKEN_PLAN_WORKSPACE_OPTION,
{
flag: "--seat-type <type>",
description: "Seat tier: standard, pro, or max",
required: true,
},
{
flag: "--account-id <id>",
description: "Target member account ID (repeatable)",
type: "array",
},
...TOKEN_PLAN_COMMON_QUERY_OPTIONS,
{
flag: "--locale <locale>",
description: "Language: zh-CN or en-US",
},
...TOKEN_PLAN_AK_OPTIONS,
],
examples: [
"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);
const credentials = resolveTokenPlanCredentials(config, flags);

const workspaceId = requireWorkspaceId(config, flags);
const seatType = flags.seatType as string | undefined;
if (!seatType) {
throw new BailianError("Missing required argument --seat-type.", ExitCode.USAGE);
}

const accountIds = flags.accountId as string[] | undefined;
if (!accountIds || accountIds.length === 0) {
throw new BailianError("Missing required argument --account-id.", ExitCode.USAGE);
}

const queryParams = buildQueryParams(flags, workspaceId);

if (config.dryRun) {
const { endpoint, queryParams: query } = prepareTokenPlanRequest(
config,
API_PATH,
queryParams,
);
emitResult({ endpoint, query }, format);
return;
}

const data = await callTokenPlanApi<BatchAssignSeatsResponse>({
config,
credentials,
action: API_ACTION,
path: API_PATH,
method: "POST",
queryParams,
});

if (config.quiet || format === "text") {
emitBare("Seats assigned successfully.");
} else {
emitResult(data, format);
}
},
});

function buildQueryParams(flags: GlobalFlags, workspaceId: string): TokenPlanQueryParams {
const params: TokenPlanQueryParams = {};

params.WorkspaceId = workspaceId;
if (flags.seatType) params.SeatType = flags.seatType as string;
appendCommonQueryParams(params, flags);
if (flags.locale) params.Locale = flags.locale as string;

const accountIds = flags.accountId as string[] | undefined;
if (accountIds && accountIds.length > 0) {
params.AccountIds = accountIds;
}

return params;
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(token-plan): add Token Plan organization & seats commands by lishengzxc · Pull Request #75 · modelstudioai/cli · GitHub
Skip to content
2 changes: 1 addition & 1 deletion .node-version
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
24.16.0
24
10 changes: 8 additions & 2 deletions packages/cli/README.zh.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -122,6 +122,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 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&)
Expand DownExpand Up@@ -151,9 +157,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` 与 `token-plan` 命令组需要阿里云 AccessKey。前往 [RAM 控制台](https://ram.console.aliyun.com/manage/ak) 获取。

> 建议:创建 RAM 子账号并授予最小权限,避免使用主账号 AK/SK。

Expand Down
8 changes: 8 additions & 0 deletions packages/cli/src/commands/catalog.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -46,6 +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 "./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<string, Command> = {
Expand DownExpand Up@@ -94,5 +98,9 @@ export const commands: Record<string, Command> = {
"quota request": quotaRequest,
"quota history": quotaHistory,
"quota check": quotaCheck,
"token-plan seats": tokenplanSeats,
"token-plan create-key": tokenplanCreateKey,
"token-plan assign-seats": tokenplanAssignSeats,
"token-plan add-member": tokenplanAddMember,
update: update,
};
116 changes: 116 additions & 0 deletions packages/cli/src/commands/token-plan/add-member.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
import {
defineCommand,
detectOutputFormat,
type Config,
type GlobalFlags,
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_ACTION = "AddOrganizationMember";
const API_PATH = "/tokenplan/organization/member-additions";

const DEFAULT_ORG_ROLE = "ORG_MEMBER";

export default defineCommand({
name: "token-plan add-member",
description: "Add a member to a Token Plan organization",
usage: "bl token-plan add-member --account-name <name> --org-id <id> [flags]",
options: [
{ flag: "--account-name <name>", description: "Member display name", required: true },
{ flag: "--org-id <id>", description: "Organization ID", required: true },
{
flag: "--org-role-code <code>",
description: "Organization role: ORG_ADMIN or ORG_MEMBER (default: ORG_MEMBER)",
},
{
flag: "--spec-type <type>",
description: "Seat tier to assign on creation: standard, pro, or max",
},
...TOKEN_PLAN_COMMON_QUERY_OPTIONS,
...TOKEN_PLAN_AK_OPTIONS,
],
examples: [
"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);
const credentials = resolveTokenPlanCredentials(config, flags);

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);

if (config.dryRun) {
const { endpoint, queryParams: query } = prepareTokenPlanRequest(
config,
API_PATH,
queryParams,
);
emitResult({ endpoint, query }, format);
return;
}

const data = await callTokenPlanApi<AddOrganizationMemberResponse>({
config,
credentials,
action: API_ACTION,
path: API_PATH,
method: "POST",
queryParams,
});

if (config.quiet || format === "text") {
emitTextMember(data);
} else {
emitResult(data, format);
}
},
});

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;
params.OrgRoleCode =
typeof flags.orgRoleCode === "string" && flags.orgRoleCode.length > 0
? flags.orgRoleCode
: DEFAULT_ORG_ROLE;
if (flags.specType) params.SpecType = flags.specType as string;
appendCommonQueryParams(params, flags);

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 ?? "-")}`);
}
103 changes: 103 additions & 0 deletions packages/cli/src/commands/token-plan/ak-sign.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
/**
* ACS3-HMAC-SHA256 signing for ModelStudio Token Plan POP APIs (query-string style).
*
* Extends the core ROA signer with canonical query string support required by
* Token Plan endpoints that pass parameters in the URL query.
*/

import { createHmac, createHash, randomUUID } from "crypto";

export interface TokenPlanAkSignConfig {
accessKeyId: string;
accessKeySecret: string;
action: string;
version: string;
body: string;
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, string | string[] | undefined>): 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<string, string> {
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<string, string> = {
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");
}
112 changes: 112 additions & 0 deletions packages/cli/src/commands/token-plan/assign-seats.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
import {
defineCommand,
detectOutputFormat,
type Config,
type GlobalFlags,
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_ACTION = "BatchAssignSeats";
const API_PATH = "/tokenplan/subscription/seat-assignments";

export default defineCommand({
name: "token-plan assign-seats",
description: "Batch assign Token Plan seats to members",
usage:
"bl token-plan assign-seats --workspace-id <id> --seat-type <type> --account-id <id> [flags]",
options: [
TOKEN_PLAN_WORKSPACE_OPTION,
{
flag: "--seat-type <type>",
description: "Seat tier: standard, pro, or max",
required: true,
},
{
flag: "--account-id <id>",
description: "Target member account ID (repeatable)",
type: "array",
},
...TOKEN_PLAN_COMMON_QUERY_OPTIONS,
{
flag: "--locale <locale>",
description: "Language: zh-CN or en-US",
},
...TOKEN_PLAN_AK_OPTIONS,
],
examples: [
"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);
const credentials = resolveTokenPlanCredentials(config, flags);

const workspaceId = requireWorkspaceId(config, flags);
const seatType = flags.seatType as string | undefined;
if (!seatType) {
throw new BailianError("Missing required argument --seat-type.", ExitCode.USAGE);
}

const accountIds = flags.accountId as string[] | undefined;
if (!accountIds || accountIds.length === 0) {
throw new BailianError("Missing required argument --account-id.", ExitCode.USAGE);
}

const queryParams = buildQueryParams(flags, workspaceId);

if (config.dryRun) {
const { endpoint, queryParams: query } = prepareTokenPlanRequest(
config,
API_PATH,
queryParams,
);
emitResult({ endpoint, query }, format);
return;
}

const data = await callTokenPlanApi<BatchAssignSeatsResponse>({
config,
credentials,
action: API_ACTION,
path: API_PATH,
method: "POST",
queryParams,
});

if (config.quiet || format === "text") {
emitBare("Seats assigned successfully.");
} else {
emitResult(data, format);
}
},
});

function buildQueryParams(flags: GlobalFlags, workspaceId: string): TokenPlanQueryParams {
const params: TokenPlanQueryParams = {};

params.WorkspaceId = workspaceId;
if (flags.seatType) params.SeatType = flags.seatType as string;
appendCommonQueryParams(params, flags);
if (flags.locale) params.Locale = flags.locale as string;

const accountIds = flags.accountId as string[] | undefined;
if (accountIds && accountIds.length > 0) {
params.AccountIds = accountIds;
}

return params;
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); feat(token-plan): add Token Plan organization & seats commands by lishengzxc · Pull Request #75 · modelstudioai/cli · GitHub
Skip to content
2 changes: 1 addition & 1 deletion .node-version
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
24.16.0
24
10 changes: 8 additions & 2 deletions packages/cli/README.zh.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -122,6 +122,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 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&)
Expand DownExpand Up@@ -151,9 +157,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` 与 `token-plan` 命令组需要阿里云 AccessKey。前往 [RAM 控制台](https://ram.console.aliyun.com/manage/ak) 获取。

> 建议:创建 RAM 子账号并授予最小权限,避免使用主账号 AK/SK。

Expand Down
8 changes: 8 additions & 0 deletions packages/cli/src/commands/catalog.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -46,6 +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 "./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<string, Command> = {
Expand DownExpand Up@@ -94,5 +98,9 @@ export const commands: Record<string, Command> = {
"quota request": quotaRequest,
"quota history": quotaHistory,
"quota check": quotaCheck,
"token-plan seats": tokenplanSeats,
"token-plan create-key": tokenplanCreateKey,
"token-plan assign-seats": tokenplanAssignSeats,
"token-plan add-member": tokenplanAddMember,
update: update,
};
116 changes: 116 additions & 0 deletions packages/cli/src/commands/token-plan/add-member.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
import {
defineCommand,
detectOutputFormat,
type Config,
type GlobalFlags,
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_ACTION = "AddOrganizationMember";
const API_PATH = "/tokenplan/organization/member-additions";

const DEFAULT_ORG_ROLE = "ORG_MEMBER";

export default defineCommand({
name: "token-plan add-member",
description: "Add a member to a Token Plan organization",
usage: "bl token-plan add-member --account-name <name> --org-id <id> [flags]",
options: [
{ flag: "--account-name <name>", description: "Member display name", required: true },
{ flag: "--org-id <id>", description: "Organization ID", required: true },
{
flag: "--org-role-code <code>",
description: "Organization role: ORG_ADMIN or ORG_MEMBER (default: ORG_MEMBER)",
},
{
flag: "--spec-type <type>",
description: "Seat tier to assign on creation: standard, pro, or max",
},
...TOKEN_PLAN_COMMON_QUERY_OPTIONS,
...TOKEN_PLAN_AK_OPTIONS,
],
examples: [
"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);
const credentials = resolveTokenPlanCredentials(config, flags);

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);

if (config.dryRun) {
const { endpoint, queryParams: query } = prepareTokenPlanRequest(
config,
API_PATH,
queryParams,
);
emitResult({ endpoint, query }, format);
return;
}

const data = await callTokenPlanApi<AddOrganizationMemberResponse>({
config,
credentials,
action: API_ACTION,
path: API_PATH,
method: "POST",
queryParams,
});

if (config.quiet || format === "text") {
emitTextMember(data);
} else {
emitResult(data, format);
}
},
});

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;
params.OrgRoleCode =
typeof flags.orgRoleCode === "string" && flags.orgRoleCode.length > 0
? flags.orgRoleCode
: DEFAULT_ORG_ROLE;
if (flags.specType) params.SpecType = flags.specType as string;
appendCommonQueryParams(params, flags);

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 ?? "-")}`);
}
103 changes: 103 additions & 0 deletions packages/cli/src/commands/token-plan/ak-sign.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
/**
* ACS3-HMAC-SHA256 signing for ModelStudio Token Plan POP APIs (query-string style).
*
* Extends the core ROA signer with canonical query string support required by
* Token Plan endpoints that pass parameters in the URL query.
*/

import { createHmac, createHash, randomUUID } from "crypto";

export interface TokenPlanAkSignConfig {
accessKeyId: string;
accessKeySecret: string;
action: string;
version: string;
body: string;
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, string | string[] | undefined>): 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<string, string> {
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<string, string> = {
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");
}
112 changes: 112 additions & 0 deletions packages/cli/src/commands/token-plan/assign-seats.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
import {
defineCommand,
detectOutputFormat,
type Config,
type GlobalFlags,
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_ACTION = "BatchAssignSeats";
const API_PATH = "/tokenplan/subscription/seat-assignments";

export default defineCommand({
name: "token-plan assign-seats",
description: "Batch assign Token Plan seats to members",
usage:
"bl token-plan assign-seats --workspace-id <id> --seat-type <type> --account-id <id> [flags]",
options: [
TOKEN_PLAN_WORKSPACE_OPTION,
{
flag: "--seat-type <type>",
description: "Seat tier: standard, pro, or max",
required: true,
},
{
flag: "--account-id <id>",
description: "Target member account ID (repeatable)",
type: "array",
},
...TOKEN_PLAN_COMMON_QUERY_OPTIONS,
{
flag: "--locale <locale>",
description: "Language: zh-CN or en-US",
},
...TOKEN_PLAN_AK_OPTIONS,
],
examples: [
"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);
const credentials = resolveTokenPlanCredentials(config, flags);

const workspaceId = requireWorkspaceId(config, flags);
const seatType = flags.seatType as string | undefined;
if (!seatType) {
throw new BailianError("Missing required argument --seat-type.", ExitCode.USAGE);
}

const accountIds = flags.accountId as string[] | undefined;
if (!accountIds || accountIds.length === 0) {
throw new BailianError("Missing required argument --account-id.", ExitCode.USAGE);
}

const queryParams = buildQueryParams(flags, workspaceId);

if (config.dryRun) {
const { endpoint, queryParams: query } = prepareTokenPlanRequest(
config,
API_PATH,
queryParams,
);
emitResult({ endpoint, query }, format);
return;
}

const data = await callTokenPlanApi<BatchAssignSeatsResponse>({
config,
credentials,
action: API_ACTION,
path: API_PATH,
method: "POST",
queryParams,
});

if (config.quiet || format === "text") {
emitBare("Seats assigned successfully.");
} else {
emitResult(data, format);
}
},
});

function buildQueryParams(flags: GlobalFlags, workspaceId: string): TokenPlanQueryParams {
const params: TokenPlanQueryParams = {};

params.WorkspaceId = workspaceId;
if (flags.seatType) params.SeatType = flags.seatType as string;
appendCommonQueryParams(params, flags);
if (flags.locale) params.Locale = flags.locale as string;

const accountIds = flags.accountId as string[] | undefined;
if (accountIds && accountIds.length > 0) {
params.AccountIds = accountIds;
}

return params;
}
Loading