@crabcloud/cli 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/crab.js CHANGED
@@ -16,6 +16,8 @@ var __export = (target, all) => {
16
16
 
17
17
  // src/index.ts
18
18
  import { hostname, homedir as homedir2 } from "node:os";
19
+ import { createInterface } from "node:readline";
20
+ import { Writable } from "node:stream";
19
21
  import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "node:fs";
20
22
  import { basename, join as join2 } from "node:path";
21
23
 
@@ -5897,8 +5899,8 @@ function date2(params) {
5897
5899
  var accountDomain = "crabcloud.cc";
5898
5900
  var adminDomain = `admin.${accountDomain}`;
5899
5901
  var storageDomain = `storage.${accountDomain}`;
5900
- function storagePublicUrl(objectId) {
5901
- return `https://${storageDomain}/o/${objectId}`;
5902
+ function storagePublicUrl(objectId, originOverride) {
5903
+ return `${originOverride ?? `https://${storageDomain}`}/o/${objectId}`;
5902
5904
  }
5903
5905
  var clientTypes = ["web", "agent", "miniapp", "native"];
5904
5906
  var scopes = [
@@ -6192,7 +6194,7 @@ var collabAllocationInput = object({
6192
6194
  });
6193
6195
  var collabBoostInput = object({ kind: _enum(collabBoostKinds) });
6194
6196
  var collabInviteInput = object({ username: usernameSchema });
6195
- var collabLinkInput = string2().trim().url().max(2000);
6197
+ var collabLinkInput = string2().trim().url().max(2000).regex(/^https?:\/\//i);
6196
6198
  var collabCreateTaskInput = object({
6197
6199
  title: string2().trim().min(1).max(COLLAB_TITLE_MAX),
6198
6200
  body: string2().max(COLLAB_RECORD_BODY_MAX).default(""),
@@ -6451,6 +6453,7 @@ function stripCodexSection(content) {
6451
6453
  var DEFAULT_API = "https://api.crabcloud.cc";
6452
6454
  var DEFAULT_SCOPES = ["account.read"];
6453
6455
  var CONFIG_PATH = process.env.CRAB_CREDENTIALS ?? join2(homedir2(), ".config", "crabcloud", "credentials.json");
6456
+ var CONFIG_DIR = join2(homedir2(), ".config", "crabcloud");
6454
6457
  var EXIT_USAGE = 2;
6455
6458
  var EXIT_AUTH = 3;
6456
6459
  var EXIT_SCOPE = 4;
@@ -6540,7 +6543,10 @@ function formatTime(ms) {
6540
6543
  return new Date(ms).toLocaleString("sv", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
6541
6544
  }
6542
6545
  function fmtCredits(cents) {
6543
- return cents.toLocaleString("en-US");
6546
+ return (cents / 100).toLocaleString("en-US");
6547
+ }
6548
+ function fmtUsd(cents) {
6549
+ return `$${cents / 100}`;
6544
6550
  }
6545
6551
  var usage = `crab — Crab Cloud CLI(Agent-first 入口)
6546
6552
  发布物经 npm 分发:npx @crabcloud/cli <command>(全局安装后短名 crab 等价)
@@ -6586,14 +6592,17 @@ var usage = `crab — Crab Cloud CLI(Agent-first 入口)
6586
6592
  [--type doc|image|video|archive|code] [--tag a,b] [--trash]
6587
6593
  [--shared] [--recent] [--message-id <id>] [--limit N] [--offset N] [--json]
6588
6594
  列出云盘对象(跨来源统一视图,能力与 Web 云盘一致)
6589
- storage upload <file...> [--json] 上传一个或多个文件(直传优先,自动回退中转)
6595
+ storage upload <file...> [--json] 上传一个或多个文件(直传优先,自动回退中转);
6596
+ files 域默认端到端加密,口令 --passphrase /
6597
+ CRAB_PASSPHRASE 提供,终端内运行时交互输入
6590
6598
  storage download <id|文件名> [-o 输出路径]
6591
- 下载对象内容(默认按原文件名存到当前目录)
6599
+ 下载对象内容(默认按原文件名存到当前目录;
6600
+ 加密对象需口令,终端内运行时交互输入)
6592
6601
  storage rename <id|文件名> <新名称> 重命名
6593
6602
  storage tag <id|文件名> --add a,b [--remove c,d]
6594
6603
  增删标签
6595
- storage category <id|文件名> <categoryId|none>
6596
- 设置分类(分类 id 见 storage categories)
6604
+ storage category <id|文件名> <分类id|分类名|none>
6605
+ 设置分类(分类见 storage categories)
6597
6606
  storage publish|unpublish <id|文件名>
6598
6607
  公开 / 收回公开(公开时打印公开直链)
6599
6608
  storage rm|restore|purge <id|文件名>
@@ -6601,11 +6610,17 @@ var usage = `crab — Crab Cloud CLI(Agent-first 入口)
6601
6610
  storage quota [--json] 存储水位(统一容量账:文件/素材/邮件附件一本账)
6602
6611
  storage share <id|文件名> [--expires 7|30|90|forever] [--passcode 提取码]
6603
6612
  创建分享链接(加密文件链接自带解密钥匙 #fragment)
6613
+ storage key [--remember|--forget]
6614
+ 本机记住的云盘钥匙管理:默认查看状态;--remember 解锁一次
6615
+ 并记住(下次免输口令);--forget 清除
6604
6616
  storage shares <id|文件名> [--json]
6605
6617
  该文件的分享链接列表(状态/下载计数)
6606
6618
  storage unshare <shareId> 撤销分享
6607
6619
  storage categories [--json] 分类列表
6608
- storage mkcat <名称> 新建分类
6620
+ storage mkcat <名称> [--color 色] 新建分类(颜色缺省 action)
6621
+ storage rencat <id|分类名> <新名称> [--color 色]
6622
+ 分类重命名 / 改色
6623
+ storage rmcat <id|分类名> 删除分类(其中文件回落未分类)
6609
6624
  storage tags [--json] 标签汇总
6610
6625
  collab spaces [--json] 列出我所在的协作空间(任务/成员计数与限额)
6611
6626
  collab space create <名称> [--allocation-mb N] [--json]
@@ -6821,7 +6836,7 @@ async function creditsHistory() {
6821
6836
  console.log(`${formatTime(entry.createdAt)} ${CREDIT_REASON_LABELS[entry.reason] ?? entry.reason} ${signed}`);
6822
6837
  }
6823
6838
  console.log(`
6824
- 共 ${fmtCredits(data.total)} 条(本页 ${data.ledger.length} 条,--offset 翻页)`);
6839
+ 共 ${data.total.toLocaleString("en-US")} 条(本页 ${data.ledger.length} 条,--offset 翻页)`);
6825
6840
  return 0;
6826
6841
  }
6827
6842
  async function creditsPricing() {
@@ -6841,10 +6856,10 @@ async function creditsPricing() {
6841
6856
  console.log("暂无动作计价项。");
6842
6857
  return 0;
6843
6858
  }
6844
- console.log("动作计价(credit_cents,100 = $1.00):");
6859
+ console.log("动作计价(1 积分 = $1,价目为 $ 口径):");
6845
6860
  for (const item of actions) {
6846
6861
  const label = CREDIT_ACTION_LABELS[item.action] ?? item.action;
6847
- console.log(` ${item.action.padEnd(24)} ${label} ${fmtCredits(item.creditCents)} 积分`);
6862
+ console.log(` ${item.action.padEnd(24)} ${label} ${fmtUsd(item.creditCents)}`);
6848
6863
  }
6849
6864
  return 0;
6850
6865
  }
@@ -7122,7 +7137,7 @@ async function mailSend() {
7122
7137
  if (attachmentIds.length > 0)
7123
7138
  console.log(` 附件 ${attachmentIds.length} 个(云盘引用,不复制)`);
7124
7139
  if (result.chargedCredits > 0)
7125
- console.log(` 消耗积分:${fmtCredits(result.chargedCredits)}`);
7140
+ console.log(` 消耗:${fmtUsd(result.chargedCredits)}`);
7126
7141
  if (result.failedExternal > 0)
7127
7142
  console.log(` ⚠ ${result.failedExternal} 个外部收件人发送失败,对应积分已退回`);
7128
7143
  return 0;
@@ -7444,13 +7459,168 @@ var utf82 = (s) => new TextEncoder().encode(s);
7444
7459
  var MASTER_AAD = utf82("drive-master");
7445
7460
  var DATA_AAD = utf82("drive-data");
7446
7461
  var KEK_ITERATIONS = 210000;
7447
- function drivePassphrase() {
7448
- return readFlag("--passphrase") ?? process.env.CRAB_PASSPHRASE;
7462
+ var passphraseMemo;
7463
+ async function drivePassphrase() {
7464
+ if (passphraseMemo !== undefined)
7465
+ return passphraseMemo;
7466
+ const explicit = readFlag("--passphrase") ?? process.env.CRAB_PASSPHRASE;
7467
+ if (explicit !== undefined && explicit !== "") {
7468
+ passphraseMemo = explicit;
7469
+ return explicit;
7470
+ }
7471
+ if (!process.stdin.isTTY)
7472
+ return;
7473
+ process.stderr.write("请输入云盘口令(输入不回显)Drive passphrase: ");
7474
+ const typed = await askHidden();
7475
+ if (typed.length === 0)
7476
+ return;
7477
+ passphraseMemo = typed;
7478
+ return typed;
7479
+ }
7480
+ function askHidden() {
7481
+ return new Promise((resolve) => {
7482
+ const silent = new Writable({ write(_chunk, _enc, cb) {
7483
+ cb();
7484
+ } });
7485
+ const rl = createInterface({ input: process.stdin, output: silent, terminal: true });
7486
+ rl.question("", (answer) => {
7487
+ rl.close();
7488
+ process.stderr.write(`
7489
+ `);
7490
+ resolve(answer);
7491
+ });
7492
+ });
7449
7493
  }
7450
- async function driveMasterKey(credentials) {
7451
- const passphrase = drivePassphrase();
7494
+ function askVisible(question) {
7495
+ return new Promise((resolve) => {
7496
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
7497
+ rl.question(question, (answer) => {
7498
+ rl.close();
7499
+ resolve(answer);
7500
+ });
7501
+ });
7502
+ }
7503
+ var CANARY_PLAIN = utf82("crab-drive-verify");
7504
+ var CANARY_AAD = utf82("drive-verify");
7505
+ function driveKeyPath(accountId) {
7506
+ return join2(CONFIG_DIR, `drive-key-${accountId}.json`);
7507
+ }
7508
+ function readDriveKeyFile(accountId) {
7509
+ try {
7510
+ return JSON.parse(readFileSync2(driveKeyPath(accountId), "utf8"));
7511
+ } catch {
7512
+ return null;
7513
+ }
7514
+ }
7515
+ async function loadVerifiedDriveKey(credentials, accountId) {
7516
+ const file = readDriveKeyFile(accountId);
7517
+ if (!file || file.apiBase !== credentials.apiBase || typeof file.masterKey !== "string" || typeof file.canary !== "string") {
7518
+ return null;
7519
+ }
7520
+ try {
7521
+ const key = await importAesKey(b64urlDecode(file.masterKey));
7522
+ await aeadDecrypt(key, sealFromB64url(file.canary), CANARY_AAD);
7523
+ return b64urlDecode(file.masterKey);
7524
+ } catch {
7525
+ forgetDriveKey(accountId);
7526
+ console.error("本机记住的云盘钥匙已失效(可能在别处轮换),请改用口令。");
7527
+ return null;
7528
+ }
7529
+ }
7530
+ async function storeDriveKey(accountId, apiBase, masterRaw) {
7531
+ const sealed = await aeadEncrypt(await importAesKey(masterRaw), CANARY_PLAIN, CANARY_AAD);
7532
+ mkdirSync2(CONFIG_DIR, { recursive: true });
7533
+ writeFileSync2(driveKeyPath(accountId), `${JSON.stringify({ apiBase, masterKey: b64urlEncode(masterRaw), canary: sealToB64url({ iv: sealed.iv, ciphertext: sealed.ciphertext }), storedAt: Date.now() }, null, 2)}
7534
+ `, { mode: 384 });
7535
+ }
7536
+ function forgetDriveKey(accountId) {
7537
+ rmSync2(driveKeyPath(accountId), { force: true });
7538
+ }
7539
+ var accountIdMemo;
7540
+ async function driveAccountId(credentials) {
7541
+ if (accountIdMemo !== undefined)
7542
+ return accountIdMemo;
7543
+ const res = await api2(credentials.apiBase, "/v1/auth/me", { headers: authHeaders(credentials) });
7544
+ accountIdMemo = res.status === 200 ? res.body.data.account.id : null;
7545
+ return accountIdMemo;
7546
+ }
7547
+ async function maybeRememberDriveKey(credentials, masterRaw) {
7548
+ if (!process.stdin.isTTY)
7549
+ return;
7550
+ const accountId = await driveAccountId(credentials);
7551
+ const existing = accountId ? readDriveKeyFile(accountId) : null;
7552
+ if (accountId && existing && existing.apiBase === credentials.apiBase && existing.masterKey)
7553
+ return;
7554
+ const answer = await askVisible("记住云盘钥匙到本机?下次免输口令(storage key --forget 清除)[y/N] ");
7555
+ if (answer.trim().toLowerCase() === "y") {
7556
+ await storeDriveKey(accountId, credentials.apiBase, masterRaw);
7557
+ console.log("✓ 已记住云盘钥匙到本机(storage key --forget 可清除)。");
7558
+ }
7559
+ }
7560
+ async function storageKeyCmd() {
7561
+ const credentials = requireCredentials();
7562
+ if (typeof credentials === "number")
7563
+ return credentials;
7564
+ const accountId = await driveAccountId(credentials);
7565
+ if (!accountId) {
7566
+ console.error("获取账号身份失败(凭证无效?)。");
7567
+ return EXIT_AUTH;
7568
+ }
7569
+ if (hasFlag("--forget")) {
7570
+ const had = readDriveKeyFile(accountId)?.masterKey !== undefined;
7571
+ forgetDriveKey(accountId);
7572
+ console.log(had ? "✓ 已清除本机记住的云盘钥匙,下次操作恢复口令输入。" : "本机没有记住的云盘钥匙。");
7573
+ return 0;
7574
+ }
7575
+ if (hasFlag("--remember")) {
7576
+ const existing = readDriveKeyFile(accountId);
7577
+ if (existing && existing.apiBase === credentials.apiBase && existing.masterKey) {
7578
+ console.log(`本机已记住云盘钥匙:${driveKeyPath(accountId)}`);
7579
+ return 0;
7580
+ }
7581
+ const raw = await driveMasterKeyRaw(credentials, { skipRememberPrompt: true });
7582
+ if (typeof raw === "number")
7583
+ return raw;
7584
+ await storeDriveKey(accountId, credentials.apiBase, raw);
7585
+ console.log(`✓ 已记住云盘钥匙到本机:${driveKeyPath(accountId)}(storage key --forget 清除)`);
7586
+ return 0;
7587
+ }
7588
+ const file = readDriveKeyFile(accountId);
7589
+ if (file && file.apiBase === credentials.apiBase && file.masterKey) {
7590
+ const when = typeof file.storedAt === "number" ? new Date(file.storedAt).toLocaleString() : "未知时间";
7591
+ console.log(`✓ 本机已记住云盘钥匙:${driveKeyPath(accountId)}(记住于 ${when})`);
7592
+ console.log(" 之后 storage 命令免输口令;Web 端轮换口令/恢复码后本钥匙自动失效。");
7593
+ console.log(" storage key --forget 清除;storage key --remember 重新解锁并记住。");
7594
+ } else {
7595
+ console.log("本机未记住云盘钥匙(每次操作需输入口令)。");
7596
+ console.log(" storage key --remember:解锁一次并记住;storage key --forget:清除记录。");
7597
+ }
7598
+ return 0;
7599
+ }
7600
+ var masterKeyRawMemo = null;
7601
+ async function driveMasterKeyRaw(credentials, opts = {}) {
7602
+ if (masterKeyRawMemo)
7603
+ return masterKeyRawMemo;
7604
+ const accountId = await driveAccountId(credentials);
7605
+ if (accountId) {
7606
+ const stored = await loadVerifiedDriveKey(credentials, accountId);
7607
+ if (stored) {
7608
+ const res = await api2(credentials.apiBase, "/v1/storage/keys", { headers: authHeaders(credentials) });
7609
+ if (res.status === 200) {
7610
+ masterKeyRawMemo = stored;
7611
+ return stored;
7612
+ }
7613
+ if (res.status === 404) {
7614
+ forgetDriveKey(accountId);
7615
+ } else {
7616
+ console.error(errorMessage(res.body, "获取云盘密钥失败。"));
7617
+ return exitCodeForApiError(res.body);
7618
+ }
7619
+ }
7620
+ }
7621
+ const passphrase = await drivePassphrase();
7452
7622
  if (!passphrase) {
7453
- console.error("需要云盘口令:--passphrase <口令> 或环境变量 CRAB_PASSPHRASE。");
7623
+ console.error("需要云盘口令:--passphrase <口令> 或环境变量 CRAB_PASSPHRASE(交互终端会提示输入)。");
7454
7624
  return EXIT_USAGE;
7455
7625
  }
7456
7626
  const res = await api2(credentials.apiBase, "/v1/storage/keys", { headers: authHeaders(credentials) });
@@ -7479,7 +7649,10 @@ async function driveMasterKey(credentials) {
7479
7649
  }
7480
7650
  console.log(`★ 恢复码:${recoveryCode}`);
7481
7651
  console.log(" (口令遗忘时的唯一找回方式;平台无后门,丢失即数据不可解)");
7482
- return importAesKey(masterRaw);
7652
+ if (!opts.skipRememberPrompt)
7653
+ await maybeRememberDriveKey(credentials, masterRaw);
7654
+ masterKeyRawMemo = masterRaw;
7655
+ return masterRaw;
7483
7656
  }
7484
7657
  if (res.status !== 200) {
7485
7658
  console.error(errorMessage(res.body, "获取云盘密钥失败。"));
@@ -7488,12 +7661,22 @@ async function driveMasterKey(credentials) {
7488
7661
  const keys = res.body.data;
7489
7662
  try {
7490
7663
  const kek = await importAesKey(await deriveKekBits(passphrase, b64urlDecode(keys.kdfSalt), keys.kdfIterations));
7491
- return await importAesKey(await unwrapKeyBytes(kek, sealFromB64url(keys.wrappedMasterKey.slice(3)), MASTER_AAD));
7664
+ const masterRaw = await unwrapKeyBytes(kek, sealFromB64url(keys.wrappedMasterKey.slice(3)), MASTER_AAD);
7665
+ if (!opts.skipRememberPrompt)
7666
+ await maybeRememberDriveKey(credentials, masterRaw);
7667
+ masterKeyRawMemo = masterRaw;
7668
+ return masterRaw;
7492
7669
  } catch {
7493
7670
  console.error("口令错误。");
7494
7671
  return 1;
7495
7672
  }
7496
7673
  }
7674
+ async function driveMasterKey(credentials) {
7675
+ const raw = await driveMasterKeyRaw(credentials);
7676
+ if (typeof raw === "number")
7677
+ return raw;
7678
+ return importAesKey(raw);
7679
+ }
7497
7680
  async function storageUploadOne(credentials, filePath) {
7498
7681
  let buf;
7499
7682
  try {
@@ -7637,11 +7820,6 @@ async function storageDownload(token) {
7637
7820
  let bytes = new Uint8Array(await res.arrayBuffer());
7638
7821
  const wrapped = res.headers.get("x-drive-wrapped-key");
7639
7822
  if (wrapped?.startsWith("u1.")) {
7640
- const passphrase = drivePassphrase();
7641
- if (!passphrase) {
7642
- console.error("该文件已加密:--passphrase <口令> 或 CRAB_PASSPHRASE 提供云盘口令后重试。");
7643
- return EXIT_USAGE;
7644
- }
7645
7823
  const master = await driveMasterKey(credentials);
7646
7824
  if (typeof master === "number")
7647
7825
  return master;
@@ -7708,13 +7886,20 @@ async function storageTag(token) {
7708
7886
  }
7709
7887
  async function storageCategory(token, value) {
7710
7888
  if (!token || !value) {
7711
- console.error("用法:crab storage category <id|文件名> <categoryId|none>(分类 id 见 storage categories)");
7889
+ console.error("用法:crab storage category <id|文件名> <分类id|分类名|none>(分类见 storage categories)");
7712
7890
  return EXIT_USAGE;
7713
7891
  }
7714
7892
  const credentials = requireCredentials();
7715
7893
  if (typeof credentials === "number")
7716
7894
  return credentials;
7717
- const result = await storagePatch(credentials, token, { categoryId: value === "none" ? null : value });
7895
+ let categoryId = null;
7896
+ if (value !== "none") {
7897
+ const resolved = await resolveCategoryId(credentials, value);
7898
+ if (typeof resolved === "number")
7899
+ return resolved;
7900
+ categoryId = resolved;
7901
+ }
7902
+ const result = await storagePatch(credentials, token, { categoryId });
7718
7903
  if (typeof result === "number")
7719
7904
  return result;
7720
7905
  console.log(`✓ 分类已更新:${result.filename}`);
@@ -7732,8 +7917,10 @@ async function storagePublish(token, publish) {
7732
7917
  if (typeof result === "number")
7733
7918
  return result;
7734
7919
  console.log(`✓ ${publish ? "已公开" : "已收回公开"}:${result.filename}`);
7735
- if (publish)
7736
- console.log(` 公开链接:${storagePublicUrl(result.id)}`);
7920
+ if (publish) {
7921
+ const base = credentials.apiBase === DEFAULT_API ? undefined : credentials.apiBase;
7922
+ console.log(` 公开链接:${storagePublicUrl(result.id, base)}`);
7923
+ }
7737
7924
  return 0;
7738
7925
  }
7739
7926
  async function storageMove(action, token) {
@@ -7898,28 +8085,116 @@ async function storageCategories() {
7898
8085
  return 0;
7899
8086
  }
7900
8087
  for (const row of rows)
7901
- console.log(`${row.id} ${row.name} (${row.fileCount})`);
8088
+ console.log(`${row.id} ${row.name} [${row.color ?? "—"}] (${row.fileCount})`);
7902
8089
  return 0;
7903
8090
  }
7904
- async function storageMkCat(name) {
8091
+ async function resolveCategoryId(credentials, idOrName) {
8092
+ const res = await api2(credentials.apiBase, "/v1/storage/categories", { headers: authHeaders(credentials) });
8093
+ if (res.status !== 200) {
8094
+ console.error(errorMessage(res.body, "获取分类失败。"));
8095
+ return exitCodeForApiError(res.body);
8096
+ }
8097
+ const rows = res.body.data.categories;
8098
+ const byId = rows.find((row) => row.id === idOrName);
8099
+ if (byId)
8100
+ return byId.id;
8101
+ const byName = rows.filter((row) => row.name === idOrName);
8102
+ if (byName.length === 1)
8103
+ return byName[0].id;
8104
+ if (byName.length === 0) {
8105
+ console.error(`分类不存在:${idOrName}(crab storage categories 查看列表)`);
8106
+ return EXIT_NOT_FOUND;
8107
+ }
8108
+ console.error(`分类名重复:${idOrName},请用 id 指认。`);
8109
+ return EXIT_USAGE;
8110
+ }
8111
+ function positionalArgs(rest, valuedFlags) {
8112
+ const out = [];
8113
+ for (let i = 0;i < rest.length; i++) {
8114
+ if (valuedFlags.includes(rest[i])) {
8115
+ i++;
8116
+ continue;
8117
+ }
8118
+ if (rest[i].startsWith("--"))
8119
+ continue;
8120
+ out.push(rest[i]);
8121
+ }
8122
+ return out;
8123
+ }
8124
+ async function storageMkCat(rest) {
8125
+ const name = positionalArgs(rest, ["--color"]).join(" ") || undefined;
7905
8126
  if (!name) {
7906
- console.error("用法:crab storage mkcat <名称>");
8127
+ console.error("用法:crab storage mkcat <名称> [--color 色]");
7907
8128
  return EXIT_USAGE;
7908
8129
  }
7909
8130
  const credentials = requireCredentials();
7910
8131
  if (typeof credentials === "number")
7911
8132
  return credentials;
8133
+ const color = readFlag("--color");
7912
8134
  const res = await api2(credentials.apiBase, "/v1/storage/categories", {
7913
8135
  method: "POST",
7914
8136
  headers: authHeaders(credentials),
7915
- body: JSON.stringify({ name })
8137
+ body: JSON.stringify({ name, ...color ? { color } : {} })
7916
8138
  });
7917
8139
  if (res.status !== 200 && res.status !== 201) {
7918
8140
  console.error(errorMessage(res.body, "创建分类失败。"));
7919
8141
  return exitCodeForApiError(res.body);
7920
8142
  }
7921
8143
  const row = res.body.data;
7922
- console.log(`✓ 已创建分类:${row.name}(id ${row.id})`);
8144
+ console.log(`✓ 已创建分类:${row.name}(id ${row.id},颜色 ${row.color ?? "—"})`);
8145
+ return 0;
8146
+ }
8147
+ async function storageRenCat(target, rest) {
8148
+ const name = positionalArgs(rest, ["--color"]).join(" ") || undefined;
8149
+ if (!target || !name && !readFlag("--color")) {
8150
+ console.error("用法:crab storage rencat <id|分类名> <新名称> [--color 色]");
8151
+ return EXIT_USAGE;
8152
+ }
8153
+ const credentials = requireCredentials();
8154
+ if (typeof credentials === "number")
8155
+ return credentials;
8156
+ const id = await resolveCategoryId(credentials, target);
8157
+ if (typeof id === "number")
8158
+ return id;
8159
+ const body = {};
8160
+ if (name)
8161
+ body.name = name;
8162
+ const color = readFlag("--color");
8163
+ if (color)
8164
+ body.color = color;
8165
+ const res = await api2(credentials.apiBase, `/v1/storage/categories/${encodeURIComponent(id)}`, {
8166
+ method: "PATCH",
8167
+ headers: authHeaders(credentials),
8168
+ body: JSON.stringify(body)
8169
+ });
8170
+ if (res.status !== 200) {
8171
+ console.error(errorMessage(res.body, "更新分类失败。"));
8172
+ return exitCodeForApiError(res.body);
8173
+ }
8174
+ const row = res.body.data;
8175
+ console.log(`✓ 分类已更新:${row.name}(颜色 ${row.color ?? "—"})`);
8176
+ return 0;
8177
+ }
8178
+ async function storageRmCat(target) {
8179
+ if (!target) {
8180
+ console.error("用法:crab storage rmcat <id|分类名>(分类下文件回落未分类,不删文件)");
8181
+ return EXIT_USAGE;
8182
+ }
8183
+ const credentials = requireCredentials();
8184
+ if (typeof credentials === "number")
8185
+ return credentials;
8186
+ const id = await resolveCategoryId(credentials, target);
8187
+ if (typeof id === "number")
8188
+ return id;
8189
+ const res = await api2(credentials.apiBase, `/v1/storage/categories/${encodeURIComponent(id)}`, {
8190
+ method: "DELETE",
8191
+ headers: authHeaders(credentials)
8192
+ });
8193
+ if (res.status !== 200) {
8194
+ console.error(errorMessage(res.body, "删除分类失败。"));
8195
+ return exitCodeForApiError(res.body);
8196
+ }
8197
+ console.log("✓ 分类已删除,其中文件回落未分类。");
7923
8198
  return 0;
7924
8199
  }
7925
8200
  async function storageTags() {
@@ -8053,7 +8328,7 @@ async function collabSpaceCreate(name) {
8053
8328
  return 0;
8054
8329
  }
8055
8330
  console.log(`✓ 独立协作空间已创建(id ${data.spaceId})`);
8056
- console.log(` 消耗积分:${fmtCredits(data.chargedCredits)} · --space space:${name} 即可指向它`);
8331
+ console.log(` 消耗:${fmtUsd(data.chargedCredits)} · --space space:${name} 即可指向它`);
8057
8332
  return 0;
8058
8333
  }
8059
8334
  async function collabTasksList() {
@@ -8544,7 +8819,11 @@ switch (command) {
8544
8819
  if (sub === "categories")
8545
8820
  process.exit(await storageCategories());
8546
8821
  if (sub === "mkcat")
8547
- process.exit(await storageMkCat(args.slice(2).join(" ") || undefined));
8822
+ process.exit(await storageMkCat(args.slice(2)));
8823
+ if (sub === "rencat")
8824
+ process.exit(await storageRenCat(args[2], args.slice(3)));
8825
+ if (sub === "rmcat")
8826
+ process.exit(await storageRmCat(args[2]));
8548
8827
  if (sub === "tags")
8549
8828
  process.exit(await storageTags());
8550
8829
  if (sub === "share")
@@ -8553,8 +8832,10 @@ switch (command) {
8553
8832
  process.exit(await storageShares(args[2]));
8554
8833
  if (sub === "unshare")
8555
8834
  process.exit(await storageUnshare(args[2]));
8835
+ if (sub === "key")
8836
+ process.exit(await storageKeyCmd());
8556
8837
  console.error(sub ? `未知子命令:storage ${sub}
8557
- ` : `缺少子命令:storage ls | upload | download | rename | tag | category | publish | unpublish | rm | restore | purge | quota | categories | mkcat | tags | share | shares | unshare
8838
+ ` : `缺少子命令:storage ls | upload | download | rename | tag | category | publish | unpublish | rm | restore | purge | quota | categories | mkcat | rencat | rmcat | tags | share | shares | unshare | key
8558
8839
  `);
8559
8840
  console.log(usage);
8560
8841
  process.exit(EXIT_USAGE);
@@ -1,14 +1,15 @@
1
1
  ---
2
2
  name: crabcloud-storage
3
- description: Manage the user's Crab Cloud Drive (云盘 — unified storage for drive uploads, media assets and mail attachments on one quota) through the `crab` CLI — list and filter objects, upload and download files, rename, tag, categorize, publish/unpublish public links, trash/restore/purge, and check the storage quota. Use this skill when the user asks to upload, find, organize, share or clean up files on Crab Cloud ("上传这个文件", "云盘里有什么", "把这个文件公开分享", "看看存储用了多少", "crab storage"), or wants files moved in or out of their drive — even if they never say "crab" or "Crab Cloud" explicitly.
3
+ description: Manage the user's Crab Cloud Drive (云盘 — unified storage for drive uploads, media assets and mail attachments on one quota, with end-to-end encryption) through the `crab` CLI — list and filter objects, upload and download files (including decrypting share links), rename, tag, categorize, publish/unpublish public links, create expiring share links with passcodes, manage the locally remembered encryption key, trash/restore/purge, and check the storage quota. Use this skill when the user asks to upload, find, organize, share or clean up files on Crab Cloud ("上传这个文件", "云盘里有什么", "把这个文件公开分享", "看看存储用了多少", "crab storage"), or wants files moved in or out of their drive — even if they never say "crab" or "Crab Cloud" explicitly.
4
4
  ---
5
5
 
6
6
  # Crab Cloud 云盘(crab storage)
7
7
 
8
8
  云盘是用户的统一存储:云盘上传 / 素材库 / 邮件附件三来源共用一本 2 GB 容量账;
9
- 属性优先、位置无关——分类(至多一个)+ 标签(AND 检索)代替文件夹。本 skill
10
- 覆盖云盘域的 agent 通道;账号/令牌管理见 `crabcloud` skill,邮箱见
11
- `crabcloud-mail` skill。
9
+ 属性优先、位置无关——分类(至多一个)+ 标签(AND 检索)代替文件夹。files 域
10
+ 上传默认端到端加密(服务器只有包裹态密钥,明文密钥只在本机)。本 skill 覆盖
11
+ 云盘域的 agent 通道;账号/令牌管理见 `crabcloud` skill,邮箱见 `crabcloud-mail`
12
+ skill。
12
13
 
13
14
  ## 第一步:确认可用
14
15
 
@@ -29,22 +30,27 @@ description: Manage the user's Crab Cloud Drive (云盘 — unified storage for
29
30
 
30
31
  | 用户意图 | 命令 |
31
32
  |---|---|
32
- | 云盘里有什么 | `crab storage ls`(按时间倒序,默认 30 条) |
33
- | 找文件 | `crab storage ls --q 关键字`(文件名匹配;可叠 `--source files\|media\|mail`、`--type doc\|image\|video\|archive\|code`、`--tag a,b`、`--shared`、`--recent`) |
33
+ | 云盘里有什么 | `crab storage ls`(按时间倒序,默认 30 条;`--limit` / `--offset` 翻页) |
34
+ | 找文件 | `crab storage ls --q 关键字`(文件名匹配;可叠 `--source files\|media\|mail`、`--type doc\|image\|video\|archive\|code`、`--category 分类id\|none`、`--tag a,b`、`--shared`、`--recent`、`--message-id <id>`) |
34
35
  | 看回收站 | `crab storage ls --trash` |
35
- | 上传文件 | `crab storage upload <路径...>`(多文件串行;直传优先,自动回退中转) |
36
- | 下载文件 | `crab storage download <id\|文件名> [-o 输出路径]`(默认按原文件名存当前目录) |
36
+ | 上传文件 | `crab storage upload <路径...>`(多文件串行;直传优先,自动回退中转;默认端到端加密——口令三来源见下节,首次使用自动初始化并打印一次性恢复码) |
37
+ | 下载文件 | `crab storage download <id\|文件名\|分享链接> [-o 输出路径] [--passcode 提取码]`(默认按原文件名存当前目录;**凭完整分享链接可免登录下载并本地解密**——`#` 后是密钥片段,这是 agent→agent 的主通道;带提取码的分享加 `--passcode`) |
37
38
  | 改名 | `crab storage rename <id\|文件名> <新名称>` |
38
39
  | 加/删标签 | `crab storage tag <id\|文件名> --add a,b [--remove c,d]` |
39
- | 归类 / 建分类 | `crab storage category <id\|文件名> <categoryId\|none>`;`crab storage mkcat <名称>`;分类 id 见 `crab storage categories` |
40
+ | 归类 | `crab storage category <id\|文件名> <分类id\|分类名\|none>`(分类名唯一命中即解析) |
41
+ | 建分类 | `crab storage mkcat <名称> [--color 色]` |
42
+ | 改分类名/颜色 | `crab storage rencat <id\|分类名> <新名称> [--color 色]` |
43
+ | 删分类 | `crab storage rmcat <id\|分类名>`(分类下文件回落未分类,不删文件) |
40
44
  | 公开分享 | `crab storage publish <id\|文件名>`(打印公开直链;收回用 `unpublish`) |
41
45
  | 可控分享(有效期/提取码/撤销) | `crab storage share <id\|文件名> [--expires 7\|30\|90\|forever] [--passcode 提取码]`(打印完整分享链接;加密对象链接自带 `#` 密钥片段);列表 `crab storage shares <id\|文件名>`、撤销 `crab storage unshare <shareId>` |
46
+ | 云盘钥匙管理 | `crab storage key`(查状态)/ `--remember`(解锁一次并记住,之后免口令)/ `--forget`(清除)——见下节 |
42
47
  | 删除 | `crab storage rm <id\|文件名>`(进回收站,7 天后自动清理)→ `restore` 恢复 / `purge` 彻底删除 |
43
48
  | 看水位 | `crab storage quota`(三来源分项 + 回收站占用 + 对象计数) |
44
49
 
45
50
  `<id|文件名>`:文件名全库精确唯一命中时自动解析为 id(回收站内同样可解析,
46
51
  restore/purge 直接用文件名即可);命中多个或查不到时按字面当 id 用——拿不准
47
- 先 `crab storage ls --q <关键字>` 核对再操作。
52
+ 先 `crab storage ls --q <关键字>` 核对再操作。分类指认同款:id 精确命中优先,
53
+ 名称唯一命中其次(`rencat` / `rmcat` / `--category` 都吃 `<id|分类名>`)。
48
54
 
49
55
  ## 机器可读输出
50
56
 
@@ -53,7 +59,26 @@ restore/purge 直接用文件名即可);命中多个或查不到时按字面
53
59
  - `storage ls --json` → `{ objects: [...], nextOffset, total }`,条目含
54
60
  `id / source / filename / mimeType / sizeBytes / categoryId / tags / isPublic / createdAt`;
55
61
  - `storage quota --json` → `{ usedBytes, quotaBytes, bySource: { files, media, mail }, trashBytes, counts }`;
56
- - `upload` / `categories` / `tags` 的 `--json` 同构(单对象 / 分类数组 / 标签数组)。
62
+ - `upload` / `categories` / `tags` / `shares` 的 `--json` 同构(单对象 / 分类数组 /
63
+ 标签数组 / 分享数组)。
64
+
65
+ ## 云盘口令与本机钥匙
66
+
67
+ files 域是端到端加密:服务器只有口令包裹态的主密钥,**明文密钥只存在于用户
68
+ 本机**——CLI 不可能也不允许向服务器索取。涉及加密对象的命令(upload /
69
+ download / share 加密文件)需要云盘口令,来源优先级:
70
+
71
+ 1. `--passphrase <口令>`(flag)
72
+ 2. 环境变量 `CRAB_PASSPHRASE`(**Agent/脚本推荐**——非交互终端不会弹提问,
73
+ 缺失时明确报错)
74
+ 3. 交互终端(TTY)隐藏输入提问(人不经管道跑 CLI 时的默认体验)
75
+
76
+ 本机钥匙(`crab storage key`):口令解锁成功后可把**主密钥**(非口令)记住到
77
+ 本机 `~/.config/crabcloud/drive-key-<账号id>.json`(0600,按账号+环境隔离),
78
+ 之后所有命令免口令——等同 Web 端「记住此设备」。`--remember` 主动记住、
79
+ `--forget` 清除、默认查状态;Web 端轮换口令/恢复码后本机钥匙自动验签失效并
80
+ 回落口令,无需手工干预。Agent 视角:**用户没让你动钥匙设置时不要主动调
81
+ `storage key`**;用户口令遗忘是恢复码流程(Web 端),CLI 侧无法代找回。
57
82
 
58
83
  ## 纪律
59
84
 
@@ -61,7 +86,11 @@ restore/purge 直接用文件名即可);命中多个或查不到时按字面
61
86
  确认后再执行;收回用 `unpublish`(已缓存浏览器至多 5 分钟内失效)。
62
87
  - **share 是可控分享**:默认 7 天有效、可选提取码、随时 `unshare` 撤销;加密
63
88
  对象的分享链接自带解密钥匙(`#` 后缀)——泄露链接即泄露文件,外发前向
64
- 用户复述有效期与提取码设置,确认后再执行。
89
+ 用户复述有效期与提取码设置,确认后再执行。收件人在浏览器打开加密分享链接
90
+ 会看到内联解密落地页(图片/视频/音频/PDF/文本直接预览,其余类型仅下载)。
91
+ - **提取码错误重试有服务端限流**:同一链接+来源 IP 错误次数超限返回 429
92
+ (退出码 1,消息「尝试过于频繁」)——**不要程序化暴力重试**,如实转述用户
93
+ 等待即可;凭链接下载时提取码用 `--passcode` 正确携带。
65
94
  - **purge 是彻底删除**,不可恢复;用户没明说「彻底删除」时一律用 `rm`
66
95
  (回收站保留 7 天可找回)。
67
96
  - 邮件附件与用户上传同账:水位告急时用 `quota` 分项定位来源再建议清理。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crabcloud/cli",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "description": "Crab Cloud CLI (crab) — agent-first entry to your personal cloud, with skills bundled for Claude Code / Codex / ZCode.",
6
6
  "bin": {