@crabcloud/cli 0.1.8 → 0.1.9

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
@@ -5869,12 +5869,23 @@ var adminCreditPackInput = object({
5869
5869
  title: string2().trim().min(1).max(100),
5870
5870
  credits: number2().int().positive().max(1e7),
5871
5871
  priceCents: number2().int().positive(),
5872
- currency: string2().trim().length(3).default("CNY"),
5872
+ currency: string2().trim().length(3).default("USD"),
5873
5873
  sort: number2().int().min(0).max(1e4).default(0)
5874
5874
  });
5875
5875
  var adminCreditPackUpdateInput = adminCreditPackInput.partial().extend({
5876
5876
  status: _enum(creditPackStatuses).optional()
5877
5877
  });
5878
+ var adminPricingOverridesInput = object({
5879
+ overrides: record(string2().trim().min(1).max(100), union([number2().int().positive().max(1e8), _null3()]))
5880
+ });
5881
+ var adminPlanTierStatuses = ["active", "hidden"];
5882
+ var adminPlanTierUpdateInput = object({
5883
+ name: string2().trim().min(1).max(50).optional(),
5884
+ priceCents: number2().int().min(0).max(1e8).nullable().optional(),
5885
+ monthlyCredits: number2().int().min(0).max(1e8).optional(),
5886
+ quotaJson: record(string2(), unknown()).optional(),
5887
+ status: _enum(adminPlanTierStatuses).optional()
5888
+ });
5878
5889
  var giftFulfillments = ["stock", "webhook"];
5879
5890
  var giftMerchantStatuses = ["active", "disabled"];
5880
5891
  var giftProductStatuses = ["draft", "live", "delisted"];
@@ -5980,6 +5991,57 @@ var contactQueryInput = object({
5980
5991
  offset: number2().int().min(0).default(0)
5981
5992
  });
5982
5993
 
5994
+ // ../../packages/cloudkit/src/crypto.ts
5995
+ var enc = new TextEncoder;
5996
+ var b64urlEncode = (bytes) => {
5997
+ let s = "";
5998
+ for (const b of bytes)
5999
+ s += String.fromCharCode(b);
6000
+ return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
6001
+ };
6002
+ var b64urlDecode = (s) => Uint8Array.from(atob(s.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0));
6003
+ function randomCode(length = 6) {
6004
+ const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
6005
+ return Array.from(crypto.getRandomValues(new Uint8Array(length)), (byte) => alphabet[byte % alphabet.length]).join("");
6006
+ }
6007
+
6008
+ // ../../packages/cloudkit/src/secretbox.ts
6009
+ var enc2 = new TextEncoder;
6010
+ var utf8 = (s) => enc2.encode(s);
6011
+ var DEFAULT_KEK_ITERATIONS = 210000;
6012
+ async function deriveKekBits(password, salt, iterations = DEFAULT_KEK_ITERATIONS) {
6013
+ const key = await crypto.subtle.importKey("raw", utf8(password), "PBKDF2", false, ["deriveBits"]);
6014
+ return new Uint8Array(await crypto.subtle.deriveBits({ name: "PBKDF2", hash: "SHA-256", salt, iterations }, key, 256));
6015
+ }
6016
+ async function importAesKey(raw, usages = ["encrypt", "decrypt"]) {
6017
+ return crypto.subtle.importKey("raw", raw, "AES-GCM", false, usages);
6018
+ }
6019
+ function randomKeyBytes(length = 32) {
6020
+ return crypto.getRandomValues(new Uint8Array(length));
6021
+ }
6022
+ async function aeadEncrypt(key, plaintext, aad) {
6023
+ const iv = crypto.getRandomValues(new Uint8Array(12));
6024
+ const params = aad ? { name: "AES-GCM", iv, additionalData: aad } : { name: "AES-GCM", iv };
6025
+ const ciphertext = await crypto.subtle.encrypt(params, key, plaintext);
6026
+ return { iv, ciphertext: new Uint8Array(ciphertext) };
6027
+ }
6028
+ async function aeadDecrypt(key, sealed, aad) {
6029
+ const params = aad ? { name: "AES-GCM", iv: sealed.iv, additionalData: aad } : { name: "AES-GCM", iv: sealed.iv };
6030
+ return new Uint8Array(await crypto.subtle.decrypt(params, key, sealed.ciphertext));
6031
+ }
6032
+ async function unwrapKeyBytes(kek, sealed, aad) {
6033
+ return aeadDecrypt(kek, sealed, aad);
6034
+ }
6035
+ function sealToB64url(sealed) {
6036
+ return `${b64urlEncode(sealed.iv)}.${b64urlEncode(sealed.ciphertext)}`;
6037
+ }
6038
+ function sealFromB64url(s) {
6039
+ const [iv, ct] = s.split(".");
6040
+ if (!iv || !ct)
6041
+ throw new Error("invalid sealed payload");
6042
+ return { iv: b64urlDecode(iv), ciphertext: b64urlDecode(ct) };
6043
+ }
6044
+
5983
6045
  // ../../packages/skills/src/install.ts
5984
6046
  import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
5985
6047
  import { homedir } from "node:os";
@@ -6268,9 +6330,14 @@ var usage = `crab — Crab Cloud CLI(Agent-first 入口)
6268
6330
  列出邮件(cursor 分页契约与 Web 工作台一致)
6269
6331
  mail read <id> [--json] 读取邮件正文(自动置已读)
6270
6332
  mail search <query> [--json] 全文搜索(主题/发件人/收件人/正文)
6271
- mail send --to a@x.cc,b@y.cn [--cc c@z.io] --subject "..." --body "..." | --body-file <path>
6272
- [--in-reply-to <message-id>] 回信线程锚点(原信 RFC Message-ID,crab mail read 可见)
6273
- 发送邮件:平台内(@crabcloud.cc)免费,外部出站 2 积分/封
6333
+ mail send --to a@x.cc|联系人名,b@y.cn [--cc c@z.io] --subject "..." --body "..." | --body-file <path>
6334
+ [--attach <云盘id|文件名>,...] [--in-reply-to <message-id>]
6335
+ 回信线程锚点(原信 RFC Message-ID,crab mail read 可见)
6336
+ 发送邮件:不含 @ 的收件人按联系人簿解析(备注名/地址精确
6337
+ 命中采用,多候选报错列出);--attach 引用云盘已有对象
6338
+ (id 或文件名精确唯一命中,未绑信对象,≤10 个,大文件
6339
+ 自动转下载链接);平台内(@crabcloud.cc)免费,外部出站
6340
+ 2 积分/封
6274
6341
  mail archive|trash|delete <id> 移夹 / 废纸篓 / 彻底删除(delete 不可恢复)
6275
6342
  contacts list [query] [--offset N] [--json] 列出联系人(地址前缀/显示名匹配)
6276
6343
  contacts add <address> [--name 名] [--note 备注] [--json]
@@ -6281,7 +6348,7 @@ var usage = `crab — Crab Cloud CLI(Agent-first 入口)
6281
6348
  我邀请的人(直接下级 + 间接计数)
6282
6349
  storage ls [--q 关键字] [--source files|media|mail] [--category <id|none>]
6283
6350
  [--type doc|image|video|archive|code] [--tag a,b] [--trash]
6284
- [--shared] [--recent] [--limit N] [--offset N] [--json]
6351
+ [--shared] [--recent] [--message-id <id>] [--limit N] [--offset N] [--json]
6285
6352
  列出云盘对象(跨来源统一视图,能力与 Web 云盘一致)
6286
6353
  storage upload <file...> [--json] 上传一个或多个文件(直传优先,自动回退中转)
6287
6354
  storage download <id|文件名> [-o 输出路径]
@@ -6296,6 +6363,11 @@ var usage = `crab — Crab Cloud CLI(Agent-first 入口)
6296
6363
  storage rm|restore|purge <id|文件名>
6297
6364
  回收站 / 恢复 / 彻底删除(purge 不可恢复)
6298
6365
  storage quota [--json] 存储水位(统一容量账:文件/素材/邮件附件一本账)
6366
+ storage share <id|文件名> [--expires 7|30|90|forever] [--passcode 提取码]
6367
+ 创建分享链接(加密文件链接自带解密钥匙 #fragment)
6368
+ storage shares <id|文件名> [--json]
6369
+ 该文件的分享链接列表(状态/下载计数)
6370
+ storage unshare <shareId> 撤销分享
6299
6371
  storage categories [--json] 分类列表
6300
6372
  storage mkcat <名称> 新建分类
6301
6373
  storage tags [--json] 标签汇总
@@ -6401,7 +6473,7 @@ async function whoami() {
6401
6473
  if (subscription)
6402
6474
  console.log(` 订阅: ${subscription.tier_name}`);
6403
6475
  if (credits !== undefined)
6404
- console.log(` 积分余额: ${credits}`);
6476
+ console.log(` 积分余额: $${(credits / 100).toFixed(2)} (${credits} credit_cents)`);
6405
6477
  if (apps?.length)
6406
6478
  console.log(` 已启用应用: ${apps.map((a) => a.title).join("、")}`);
6407
6479
  return 0;
@@ -6530,6 +6602,13 @@ async function mailRead(id) {
6530
6602
  console.log(` 回复: ${mail.inReplyTo}`);
6531
6603
  console.log("");
6532
6604
  console.log(mail.bodyText);
6605
+ if (mail.attachments.length > 0) {
6606
+ console.log("");
6607
+ console.log("附件:");
6608
+ for (const att of mail.attachments) {
6609
+ console.log(` ${att.id} ${att.filename} ${att.mimeType} ${formatBytesCli(att.sizeBytes)}`);
6610
+ }
6611
+ }
6533
6612
  if (mail.actors.length > 0) {
6534
6613
  console.log("");
6535
6614
  console.log("操作记录:");
@@ -6565,15 +6644,63 @@ async function mailSearch(query) {
6565
6644
  printMailRow(mail);
6566
6645
  return 0;
6567
6646
  }
6647
+ async function resolveRecipients(credentials, tokens) {
6648
+ const resolved = [];
6649
+ for (const token of tokens) {
6650
+ if (token.includes("@")) {
6651
+ resolved.push(token);
6652
+ continue;
6653
+ }
6654
+ const params = new URLSearchParams({ query: token, limit: "50" });
6655
+ const res = await api2(credentials.apiBase, `/v1/contacts?${params}`, { headers: authHeaders(credentials) });
6656
+ if (res.status !== 200) {
6657
+ console.error(errorMessage(res.body, "查询联系人失败。"));
6658
+ return null;
6659
+ }
6660
+ const items = res.body.data.items;
6661
+ const lowered = token.toLowerCase();
6662
+ const exact = items.filter((c) => c.address.toLowerCase() === lowered || (c.displayName ?? "").toLowerCase() === lowered);
6663
+ if (exact.length === 1) {
6664
+ resolved.push(exact[0].address);
6665
+ continue;
6666
+ }
6667
+ if (exact.length > 1) {
6668
+ console.error(`「${token}」匹配到多个联系人,请改用完整地址:`);
6669
+ for (const c of exact.slice(0, 5))
6670
+ console.error(` ${contactDisplayName(c)} <${c.address}>`);
6671
+ return null;
6672
+ }
6673
+ if (items.length === 1) {
6674
+ console.error(`「${token}」→ ${contactDisplayName(items[0])} <${items[0].address}>`);
6675
+ resolved.push(items[0].address);
6676
+ continue;
6677
+ }
6678
+ console.error(`「${token}」没有唯一命中的联系人。`);
6679
+ if (items.length > 0) {
6680
+ console.error("相近候选:");
6681
+ for (const c of items.slice(0, 5))
6682
+ console.error(` ${contactDisplayName(c)} <${c.address}>`);
6683
+ } else {
6684
+ console.error("联系人簿没有相近条目(可先 crab contacts list 查看)。");
6685
+ }
6686
+ return null;
6687
+ }
6688
+ return resolved;
6689
+ }
6568
6690
  async function mailSend() {
6569
- const to = (readFlag("--to") ?? "").split(",").map((x) => x.trim()).filter(Boolean);
6570
- const cc = (readFlag("--cc") ?? "").split(",").map((x) => x.trim()).filter(Boolean);
6691
+ const toRaw = (readFlag("--to") ?? "").split(",").map((x) => x.trim()).filter(Boolean);
6692
+ const ccRaw = (readFlag("--cc") ?? "").split(",").map((x) => x.trim()).filter(Boolean);
6693
+ const attachRaw = (readFlag("--attach") ?? "").split(",").map((x) => x.trim()).filter(Boolean);
6571
6694
  const subject = readFlag("--subject") ?? "";
6572
6695
  const bodyFlag = readFlag("--body");
6573
6696
  const bodyFile = readFlag("--body-file");
6574
6697
  const inReplyTo = readFlag("--in-reply-to") ?? undefined;
6575
- if (to.length === 0 || !subject || !bodyFlag && !bodyFile) {
6576
- console.error('用法:crab mail send --to a@x.cc[,b@y.cn] [--cc c@z.io] --subject "..." --body "..." | --body-file <path> [--in-reply-to <message-id>]');
6698
+ if (toRaw.length === 0 || !subject || !bodyFlag && !bodyFile) {
6699
+ console.error('用法:crab mail send --to a@x.cc|联系人名[,b@y.cn] [--cc c@z.io] --subject "..." --body "..." | --body-file <path> [--attach <云盘id|文件名>,...] [--in-reply-to <message-id>]');
6700
+ return EXIT_USAGE;
6701
+ }
6702
+ if (attachRaw.length > 10) {
6703
+ console.error("附件最多 10 个。");
6577
6704
  return EXIT_USAGE;
6578
6705
  }
6579
6706
  let body = bodyFlag ?? "";
@@ -6588,10 +6715,29 @@ async function mailSend() {
6588
6715
  const credentials = requireCredentials();
6589
6716
  if (typeof credentials === "number")
6590
6717
  return credentials;
6718
+ const to = await resolveRecipients(credentials, toRaw);
6719
+ if (to === null)
6720
+ return 1;
6721
+ const cc = ccRaw.length > 0 ? await resolveRecipients(credentials, ccRaw) : [];
6722
+ if (cc === null)
6723
+ return 1;
6724
+ const attachmentIds = [];
6725
+ for (const token of attachRaw) {
6726
+ const hit = await resolveStorageObject(credentials, token);
6727
+ if (typeof hit === "number")
6728
+ return hit;
6729
+ if (typeof hit === "object" && hit.isEncrypted) {
6730
+ console.error(`「${token}」是端到端加密文件,暂不能作为邮件附件发送(服务端无法解密)。`);
6731
+ return EXIT_USAGE;
6732
+ }
6733
+ const id = typeof hit === "object" ? hit.id : hit;
6734
+ if (!attachmentIds.includes(id))
6735
+ attachmentIds.push(id);
6736
+ }
6591
6737
  const res = await api2(credentials.apiBase, "/v1/mail/messages", {
6592
6738
  method: "POST",
6593
6739
  headers: authHeaders(credentials),
6594
- body: JSON.stringify({ to, cc, subject, body, ...inReplyTo ? { inReplyTo } : {} })
6740
+ body: JSON.stringify({ to, cc, subject, body, ...inReplyTo ? { inReplyTo } : {}, ...attachmentIds.length > 0 ? { attachmentIds } : {} })
6595
6741
  });
6596
6742
  if (res.status !== 200 && res.status !== 201) {
6597
6743
  console.error(errorMessage(res.body, "发送失败。"));
@@ -6603,8 +6749,10 @@ async function mailSend() {
6603
6749
  return 0;
6604
6750
  }
6605
6751
  console.log(`✓ 已发送(id ${result.message.id})`);
6752
+ if (attachmentIds.length > 0)
6753
+ console.log(` 附件 ${attachmentIds.length} 个(云盘引用,不复制)`);
6606
6754
  if (result.chargedCredits > 0)
6607
- console.log(` 消耗积分:${result.chargedCredits}`);
6755
+ console.log(` 消耗积分:$${(result.chargedCredits / 100).toFixed(2)}(${result.chargedCredits} credit_cents)`);
6608
6756
  if (result.failedExternal > 0)
6609
6757
  console.log(` ⚠ ${result.failedExternal} 个外部收件人发送失败,对应积分已退回`);
6610
6758
  return 0;
@@ -6824,11 +6972,12 @@ function formatBytesCli(n) {
6824
6972
  function printStorageRow(o) {
6825
6973
  const flags = [
6826
6974
  o.source !== "files" ? o.source === "media" ? "素材" : "邮件附件" : null,
6975
+ o.isEncrypted ? "加密" : null,
6827
6976
  o.isPublic ? "公开" : null
6828
6977
  ].filter(Boolean);
6829
6978
  console.log(`${o.id} ${o.filename} ${formatBytesCli(o.sizeBytes)} ${formatTime(o.createdAt)}${flags.length > 0 ? ` [${flags.join(" · ")}]` : ""}`);
6830
6979
  }
6831
- async function resolveStorageId(credentials, token) {
6980
+ async function resolveStorageObject(credentials, token) {
6832
6981
  for (const trash of [false, true]) {
6833
6982
  const params = new URLSearchParams({ limit: "100", q: token });
6834
6983
  if (trash)
@@ -6846,10 +6995,16 @@ async function resolveStorageId(credentials, token) {
6846
6995
  return EXIT_USAGE;
6847
6996
  }
6848
6997
  if (exact.length === 1)
6849
- return exact[0].id;
6998
+ return exact[0];
6850
6999
  }
6851
7000
  return token;
6852
7001
  }
7002
+ async function resolveStorageId(credentials, token) {
7003
+ const hit = await resolveStorageObject(credentials, token);
7004
+ if (typeof hit === "string" || typeof hit === "number")
7005
+ return hit;
7006
+ return hit.id;
7007
+ }
6853
7008
  async function storageList() {
6854
7009
  const credentials = requireCredentials();
6855
7010
  if (typeof credentials === "number")
@@ -6891,6 +7046,9 @@ async function storageList() {
6891
7046
  const q = readFlag("--q");
6892
7047
  if (q)
6893
7048
  params.set("q", q);
7049
+ const messageId = readFlag("--message-id");
7050
+ if (messageId)
7051
+ params.set("messageId", messageId);
6894
7052
  const res = await api2(credentials.apiBase, `/v1/storage?${params}`, { headers: authHeaders(credentials) });
6895
7053
  if (res.status !== 200) {
6896
7054
  console.error(errorMessage(res.body, "获取云盘列表失败。"));
@@ -6907,10 +7065,65 @@ async function storageList() {
6907
7065
  }
6908
7066
  for (const object of page.objects)
6909
7067
  printStorageRow(object);
7068
+ console.log(`共 ${page.total} 条`);
6910
7069
  if (page.nextOffset !== null)
6911
7070
  console.log(`(还有更多:storage ls --offset ${page.nextOffset})`);
6912
7071
  return 0;
6913
7072
  }
7073
+ var utf82 = (s) => new TextEncoder().encode(s);
7074
+ var MASTER_AAD = utf82("drive-master");
7075
+ var DATA_AAD = utf82("drive-data");
7076
+ var KEK_ITERATIONS = 210000;
7077
+ function drivePassphrase() {
7078
+ return readFlag("--passphrase") ?? process.env.CRAB_PASSPHRASE;
7079
+ }
7080
+ async function driveMasterKey(credentials) {
7081
+ const passphrase = drivePassphrase();
7082
+ if (!passphrase) {
7083
+ console.error("需要云盘口令:--passphrase <口令> 或环境变量 CRAB_PASSPHRASE。");
7084
+ return EXIT_USAGE;
7085
+ }
7086
+ const res = await api2(credentials.apiBase, "/v1/storage/keys", { headers: authHeaders(credentials) });
7087
+ if (res.status === 404) {
7088
+ console.error("云盘口令尚未设置——正在初始化(恢复码只显示一次,请立即抄写):");
7089
+ const salt = b64urlEncode(randomKeyBytes(16));
7090
+ const masterRaw = randomKeyBytes(32);
7091
+ const recoveryCode = randomCode(16);
7092
+ const kek = await importAesKey(await deriveKekBits(passphrase, b64urlDecode(salt), KEK_ITERATIONS));
7093
+ const recoveryKek = await importAesKey(await deriveKekBits(recoveryCode, b64urlDecode(salt), KEK_ITERATIONS));
7094
+ const wrapped = await aeadEncrypt(kek, masterRaw, MASTER_AAD);
7095
+ const recoveryWrapped = await aeadEncrypt(recoveryKek, masterRaw, MASTER_AAD);
7096
+ const put = await api2(credentials.apiBase, "/v1/storage/keys", {
7097
+ method: "PUT",
7098
+ headers: authHeaders(credentials),
7099
+ body: JSON.stringify({
7100
+ kdfSalt: salt,
7101
+ kdfIterations: KEK_ITERATIONS,
7102
+ wrappedMasterKey: `u1.${sealToB64url({ iv: wrapped.iv, ciphertext: wrapped.ciphertext })}`,
7103
+ recoveryWrappedMasterKey: `r1.${sealToB64url({ iv: recoveryWrapped.iv, ciphertext: recoveryWrapped.ciphertext })}`
7104
+ })
7105
+ });
7106
+ if (put.status !== 200 && put.status !== 201) {
7107
+ console.error(errorMessage(put.body, "初始化云盘口令失败。"));
7108
+ return exitCodeForApiError(put.body);
7109
+ }
7110
+ console.log(`★ 恢复码:${recoveryCode}`);
7111
+ console.log(" (口令遗忘时的唯一找回方式;平台无后门,丢失即数据不可解)");
7112
+ return importAesKey(masterRaw);
7113
+ }
7114
+ if (res.status !== 200) {
7115
+ console.error(errorMessage(res.body, "获取云盘密钥失败。"));
7116
+ return exitCodeForApiError(res.body);
7117
+ }
7118
+ const keys = res.body.data;
7119
+ try {
7120
+ const kek = await importAesKey(await deriveKekBits(passphrase, b64urlDecode(keys.kdfSalt), keys.kdfIterations));
7121
+ return await importAesKey(await unwrapKeyBytes(kek, sealFromB64url(keys.wrappedMasterKey.slice(3)), MASTER_AAD));
7122
+ } catch {
7123
+ console.error("口令错误。");
7124
+ return 1;
7125
+ }
7126
+ }
6914
7127
  async function storageUploadOne(credentials, filePath) {
6915
7128
  let buf;
6916
7129
  try {
@@ -6921,14 +7134,22 @@ async function storageUploadOne(credentials, filePath) {
6921
7134
  }
6922
7135
  const filename = basename(filePath);
6923
7136
  const mimeType = mimeFor(filename);
7137
+ const master = await driveMasterKey(credentials);
7138
+ if (typeof master === "number")
7139
+ return master;
7140
+ const dataKey = randomKeyBytes(32);
7141
+ const sealed = await aeadEncrypt(await importAesKey(dataKey), new Uint8Array(buf), DATA_AAD);
7142
+ const cipher = new Uint8Array([...sealed.iv, ...sealed.ciphertext]);
7143
+ const wrapped = await aeadEncrypt(master, dataKey, DATA_AAD);
7144
+ const wrappedDataKey = `u1.${sealToB64url({ iv: wrapped.iv, ciphertext: wrapped.ciphertext })}`;
6924
7145
  const presign = await api2(credentials.apiBase, "/v1/storage/presign", {
6925
7146
  method: "POST",
6926
7147
  headers: authHeaders(credentials),
6927
- body: JSON.stringify({ filename, mimeType, sizeBytes: buf.byteLength })
7148
+ body: JSON.stringify({ filename, mimeType, sizeBytes: cipher.byteLength, enc: true })
6928
7149
  });
6929
7150
  if (presign.status === 200) {
6930
7151
  const { fileId, uploadUrl } = presign.body.data;
6931
- const put = await fetch(uploadUrl, { method: "PUT", body: new Uint8Array(buf), headers: { "content-type": mimeType } });
7152
+ const put = await fetch(uploadUrl, { method: "PUT", body: cipher, headers: { "content-type": mimeType } });
6932
7153
  if (!put.ok) {
6933
7154
  console.error(`直传失败(${put.status}):${filename}`);
6934
7155
  return 1;
@@ -6936,7 +7157,7 @@ async function storageUploadOne(credentials, filePath) {
6936
7157
  const complete = await api2(credentials.apiBase, `/v1/storage/${encodeURIComponent(fileId)}/complete`, {
6937
7158
  method: "POST",
6938
7159
  headers: authHeaders(credentials),
6939
- body: "{}"
7160
+ body: JSON.stringify({ isEncrypted: true, wrappedDataKey })
6940
7161
  });
6941
7162
  if (complete.status !== 200 && complete.status !== 201) {
6942
7163
  console.error(errorMessage(complete.body, `记账失败:${filename}`));
@@ -6946,12 +7167,14 @@ async function storageUploadOne(credentials, filePath) {
6946
7167
  if (hasFlag("--json"))
6947
7168
  console.log(JSON.stringify(object, null, 2));
6948
7169
  else
6949
- console.log(`✓ 已上传:${object.filename}(id ${object.id})`);
7170
+ console.log(`✓ 已上传(已加密):${object.filename}(id ${object.id})`);
6950
7171
  return 0;
6951
7172
  }
6952
7173
  if (presign.body?.error?.code === "presign_unavailable") {
6953
7174
  const form = new FormData;
6954
- form.append("file", new Blob([new Uint8Array(buf)], { type: mimeType }), filename);
7175
+ form.append("file", new Blob([cipher], { type: mimeType }), filename);
7176
+ form.append("enc", "1");
7177
+ form.append("wrappedDataKey", wrappedDataKey);
6955
7178
  const res = await fetch(`${credentials.apiBase}/v1/storage`, {
6956
7179
  method: "POST",
6957
7180
  headers: authHeaders(credentials),
@@ -6965,16 +7188,24 @@ async function storageUploadOne(credentials, filePath) {
6965
7188
  if (hasFlag("--json"))
6966
7189
  console.log(JSON.stringify(body.data, null, 2));
6967
7190
  else
6968
- console.log(`✓ 已上传:${body.data?.filename}(id ${body.data?.id})`);
7191
+ console.log(`✓ 已上传(已加密):${body.data?.filename}(id ${body.data?.id})`);
6969
7192
  return 0;
6970
7193
  }
6971
7194
  console.error(errorMessage(presign.body, `上传失败:${filename}`));
6972
7195
  return exitCodeForApiError(presign.body);
6973
7196
  }
6974
7197
  async function storageUpload(paths) {
6975
- const files = paths.filter((p) => !p.startsWith("--"));
7198
+ const files = [];
7199
+ for (let i = 0;i < paths.length; i++) {
7200
+ if (paths[i].startsWith("--")) {
7201
+ if (paths[i] === "--passphrase")
7202
+ i++;
7203
+ continue;
7204
+ }
7205
+ files.push(paths[i]);
7206
+ }
6976
7207
  if (files.length === 0) {
6977
- console.error("用法:crab storage upload <file...> [--json]");
7208
+ console.error("用法:crab storage upload <file...> [--passphrase 口令] [--json]");
6978
7209
  return EXIT_USAGE;
6979
7210
  }
6980
7211
  const credentials = requireCredentials();
@@ -6992,9 +7223,33 @@ async function storageUpload(paths) {
6992
7223
  }
6993
7224
  async function storageDownload(token) {
6994
7225
  if (!token) {
6995
- console.error("用法:crab storage download <id|文件名> [-o 输出路径]");
7226
+ console.error("用法:crab storage download <id|文件名|分享链接> [-o 输出路径] [--passphrase 口令] [--passcode 提取码]");
6996
7227
  return EXIT_USAGE;
6997
7228
  }
7229
+ const urlMatch = token.match(/\/s\/([0-9a-f]{64})(?:#([A-Za-z0-9_-]+))?$/);
7230
+ const shareToken = urlMatch?.[1];
7231
+ const fragment = urlMatch?.[2];
7232
+ if (shareToken) {
7233
+ const apiBase = process.env.CRAB_API ?? readFlag("--api") ?? DEFAULT_API;
7234
+ const passcode = readFlag("--passcode");
7235
+ const url = `${apiBase}/s/${shareToken}${passcode ? `?passcode=${encodeURIComponent(passcode)}` : ""}`;
7236
+ const res = await fetch(url);
7237
+ if (!res.ok) {
7238
+ const body = await res.json().catch(() => ({}));
7239
+ console.error(errorMessage(body, "下载失败。"));
7240
+ return exitCodeForApiError(body);
7241
+ }
7242
+ let bytes = new Uint8Array(await res.arrayBuffer());
7243
+ if (fragment) {
7244
+ bytes = await aeadDecrypt(await importAesKey(b64urlDecode(fragment)), { iv: bytes.slice(0, 12), ciphertext: bytes.slice(12) }, DATA_AAD);
7245
+ }
7246
+ const disposition = res.headers.get("content-disposition") ?? "";
7247
+ const m = disposition.match(/filename\*=UTF-8''([^;]+)/);
7248
+ const out = readFlag("-o") ?? readFlag("--out") ?? (m ? decodeURIComponent(m[1]) : `${shareToken}.bin`);
7249
+ writeFileSync2(out, Buffer.from(bytes));
7250
+ console.log(`✓ 已下载${fragment ? "并解密" : ""}:${out}(${formatBytesCli(bytes.byteLength)})`);
7251
+ return 0;
7252
+ }
6998
7253
  const credentials = requireCredentials();
6999
7254
  if (typeof credentials === "number")
7000
7255
  return credentials;
@@ -7009,12 +7264,26 @@ async function storageDownload(token) {
7009
7264
  console.error(errorMessage(body, "下载失败。"));
7010
7265
  return exitCodeForApiError(body);
7011
7266
  }
7267
+ let bytes = new Uint8Array(await res.arrayBuffer());
7268
+ const wrapped = res.headers.get("x-drive-wrapped-key");
7269
+ if (wrapped?.startsWith("u1.")) {
7270
+ const passphrase = drivePassphrase();
7271
+ if (!passphrase) {
7272
+ console.error("该文件已加密:--passphrase <口令> 或 CRAB_PASSPHRASE 提供云盘口令后重试。");
7273
+ return EXIT_USAGE;
7274
+ }
7275
+ const master = await driveMasterKey(credentials);
7276
+ if (typeof master === "number")
7277
+ return master;
7278
+ const dataKey = await unwrapKeyBytes(master, sealFromB64url(wrapped.slice(3)), DATA_AAD);
7279
+ bytes = await aeadDecrypt(await importAesKey(dataKey), { iv: bytes.slice(0, 12), ciphertext: bytes.slice(12) }, DATA_AAD);
7280
+ }
7012
7281
  const disposition = res.headers.get("content-disposition") ?? "";
7013
7282
  const match = disposition.match(/filename\*=UTF-8''([^;]+)/);
7014
7283
  const fallbackName = match ? decodeURIComponent(match[1]) : `${id}.bin`;
7015
7284
  const out = readFlag("-o") ?? readFlag("--out") ?? fallbackName;
7016
- writeFileSync2(out, Buffer.from(await res.arrayBuffer()));
7017
- console.log(`✓ 已下载:${out}(${formatBytesCli(Number(res.headers.get("content-length") ?? 0))})`);
7285
+ writeFileSync2(out, Buffer.from(bytes));
7286
+ console.log(`✓ 已下载:${out}(${formatBytesCli(bytes.byteLength)})`);
7018
7287
  return 0;
7019
7288
  }
7020
7289
  async function storagePatch(credentials, token, patch) {
@@ -7116,6 +7385,110 @@ async function storageMove(action, token) {
7116
7385
  console.log(`✓ ${action === "rm" ? "已移入回收站(7 天后自动清理)" : action === "restore" ? "已恢复" : "已彻底删除"}(${id})`);
7117
7386
  return 0;
7118
7387
  }
7388
+ async function storageShareCmd(token) {
7389
+ if (!token) {
7390
+ console.error("用法:crab storage share <id|文件名> [--expires 7|30|90|forever] [--passcode 提取码]");
7391
+ return EXIT_USAGE;
7392
+ }
7393
+ const expires = readFlag("--expires") ?? "7";
7394
+ const passcode = readFlag("--passcode");
7395
+ if (!["7", "30", "90", "forever"].includes(expires) || passcode !== undefined && !/^[A-Za-z0-9]{4,8}$/.test(passcode)) {
7396
+ console.error("无效的 --expires / --passcode 参数。");
7397
+ return EXIT_USAGE;
7398
+ }
7399
+ const credentials = requireCredentials();
7400
+ if (typeof credentials === "number")
7401
+ return credentials;
7402
+ const hit = await resolveStorageObject(credentials, token);
7403
+ if (typeof hit === "number")
7404
+ return hit;
7405
+ const id = typeof hit === "object" ? hit.id : hit;
7406
+ const needMaster = typeof hit === "object" && hit.isEncrypted === true && (hit.wrappedDataKey ?? "").startsWith("u1.");
7407
+ const master = needMaster ? await driveMasterKey(credentials) : null;
7408
+ if (needMaster && typeof master === "number")
7409
+ return master;
7410
+ const res = await api2(credentials.apiBase, `/v1/storage/${encodeURIComponent(id)}/share`, {
7411
+ method: "POST",
7412
+ headers: authHeaders(credentials),
7413
+ body: JSON.stringify({ expires, ...passcode ? { passcode } : {} })
7414
+ });
7415
+ if (res.status !== 200 && res.status !== 201) {
7416
+ console.error(errorMessage(res.body, "创建分享失败。"));
7417
+ return exitCodeForApiError(res.body);
7418
+ }
7419
+ const data = res.body.data;
7420
+ let link = data.shareUrl;
7421
+ if (data.isEncrypted && data.wrappedDataKey) {
7422
+ const masterKey = master ?? await driveMasterKey(credentials);
7423
+ if (typeof masterKey === "number") {
7424
+ await api2(credentials.apiBase, `/v1/storage/shares/${encodeURIComponent(data.share.id)}`, {
7425
+ method: "DELETE",
7426
+ headers: authHeaders(credentials)
7427
+ }).catch(() => null);
7428
+ console.error("该文件是端到端加密文件,需要云盘口令解出分享钥匙:--passphrase <口令> 或 CRAB_PASSPHRASE(本次分享已撤销,未留孤儿链接)。");
7429
+ return EXIT_USAGE;
7430
+ }
7431
+ const dataKey = await unwrapKeyBytes(masterKey, sealFromB64url(data.wrappedDataKey.slice(3)), DATA_AAD);
7432
+ link = `${link}#${b64urlEncode(dataKey)}`;
7433
+ }
7434
+ console.log(`✓ 分享链接已创建(${expires === "forever" ? "永久" : `${expires} 天`}有效):`);
7435
+ console.log(` ${link}`);
7436
+ if (data.share.hasPasscode)
7437
+ console.log(" (已设提取码,接收方需携带)");
7438
+ if (data.isEncrypted)
7439
+ console.log(" (链接自带解密钥匙,泄露即泄露文件——请走可信渠道传递)");
7440
+ return 0;
7441
+ }
7442
+ async function storageShares(token) {
7443
+ if (!token) {
7444
+ console.error("用法:crab storage shares <id|文件名>");
7445
+ return EXIT_USAGE;
7446
+ }
7447
+ const credentials = requireCredentials();
7448
+ if (typeof credentials === "number")
7449
+ return credentials;
7450
+ const id = await resolveStorageId(credentials, token);
7451
+ if (typeof id === "number")
7452
+ return id;
7453
+ const res = await api2(credentials.apiBase, `/v1/storage/${encodeURIComponent(id)}/shares`, { headers: authHeaders(credentials) });
7454
+ if (res.status !== 200) {
7455
+ console.error(errorMessage(res.body, "获取分享列表失败。"));
7456
+ return exitCodeForApiError(res.body);
7457
+ }
7458
+ const rows = res.body.data.shares;
7459
+ if (hasFlag("--json")) {
7460
+ console.log(JSON.stringify(rows, null, 2));
7461
+ return 0;
7462
+ }
7463
+ if (rows.length === 0) {
7464
+ console.log("该文件还没有分享链接。");
7465
+ return 0;
7466
+ }
7467
+ for (const s of rows) {
7468
+ const state = s.revokedAt ? "已撤销" : s.expiresAt !== null && s.expiresAt <= Date.now() ? "已过期" : "生效中";
7469
+ console.log(`${s.id} …${s.token.slice(-12)} ${state} 下载 ${s.downloadCount} 次 ${formatTime(s.createdAt)}${s.hasPasscode ? " [提取码]" : ""}`);
7470
+ }
7471
+ return 0;
7472
+ }
7473
+ async function storageUnshare(shareId) {
7474
+ if (!shareId) {
7475
+ console.error("用法:crab storage unshare <shareId>(id 见 crab storage shares)");
7476
+ return EXIT_USAGE;
7477
+ }
7478
+ const credentials = requireCredentials();
7479
+ if (typeof credentials === "number")
7480
+ return credentials;
7481
+ const res = await api2(credentials.apiBase, `/v1/storage/shares/${encodeURIComponent(shareId)}`, {
7482
+ method: "DELETE",
7483
+ headers: authHeaders(credentials)
7484
+ });
7485
+ if (res.status !== 200) {
7486
+ console.error(errorMessage(res.body, "撤销失败。"));
7487
+ return exitCodeForApiError(res.body);
7488
+ }
7489
+ console.log(`✓ 已撤销分享(${shareId})。`);
7490
+ return 0;
7491
+ }
7119
7492
  async function storageQuota() {
7120
7493
  const credentials = requireCredentials();
7121
7494
  if (typeof credentials === "number")
@@ -7323,8 +7696,14 @@ switch (command) {
7323
7696
  process.exit(await storageMkCat(args.slice(2).join(" ") || undefined));
7324
7697
  if (sub === "tags")
7325
7698
  process.exit(await storageTags());
7699
+ if (sub === "share")
7700
+ process.exit(await storageShareCmd(args[2]));
7701
+ if (sub === "shares")
7702
+ process.exit(await storageShares(args[2]));
7703
+ if (sub === "unshare")
7704
+ process.exit(await storageUnshare(args[2]));
7326
7705
  console.error(sub ? `未知子命令:storage ${sub}
7327
- ` : `缺少子命令:storage ls | upload | download | rename | tag | category | publish | unpublish | rm | restore | purge | quota | categories | mkcat | tags
7706
+ ` : `缺少子命令:storage ls | upload | download | rename | tag | category | publish | unpublish | rm | restore | purge | quota | categories | mkcat | tags | share | shares | unshare
7328
7707
  `);
7329
7708
  console.log(usage);
7330
7709
  process.exit(EXIT_USAGE);
@@ -12,7 +12,12 @@ description: Read, search and send the user's Crab Cloud platform email (usernam
12
12
 
13
13
  ## 第一步:确认可用
14
14
 
15
- - `crab --version` 确认已安装;未登录(退出码 3)引导用户 `crab login`。
15
+ - 运行 `crab --version` 确认已安装。命令不存在时让用户运行
16
+ `npx @crabcloud/cli init`(登录 + 安装本 skill 一步完成);已装过但命令缺失
17
+ 时运行 `npx @crabcloud/cli@latest init` 升级。
18
+ - CLI 基于 npx 分发,**本机没有 `crab` 命令时,下文所有 `crab <命令>` 都可以
19
+ `npx @crabcloud/cli <命令>` 等价执行**(例如 `npx @crabcloud/cli mail list`)。
20
+ - 未登录(退出码 3)引导用户 `crab login`。
16
21
  - 邮箱动作需要对应 scope:读 `mail.read`、搜索 `mail.search`、发信 `mail.send`、
17
22
  删除 `mail.delete`。退出码 4 = scope 不足:如实转述缺哪个,引导用户
18
23
  `crab login --scopes mail`(组名 = 四项整组)或单列所需 scope 按需最小化
@@ -28,6 +33,8 @@ description: Read, search and send the user's Crab Cloud platform email (usernam
28
33
  | 读某封邮件 | `crab mail read <id>`(自动置已读;正文 + 操作记录) |
29
34
  | 找邮件 | `crab mail search <关键词>`(主题/发件人/收件人/正文,不含废纸篓) |
30
35
  | 发邮件 | `crab mail send --to a@x.cc --subject "..." --body "..."`(长正文用 `--body-file`) |
36
+ | 发邮件带附件 | `crab mail send … --attach <云盘id\|文件名>[,…]`(引用云盘已有对象,不复制,≤10 个;先 `crab storage ls` 核对对象,文件名需精确唯一命中) |
37
+ | 按名字/备注发邮件 | `--to`/`--cc` 可直接写联系人名(不含 `@`):CLI 查联系人簿解析成地址,唯一命中即用;多候选会报错列出,改用完整地址重试。也可先 `crab contacts list <名字>`(`crabcloud` skill)查地址再发 |
31
38
  | 回信(带线程锚点) | 先 `crab mail read <id>` 取 Message-ID,再 `crab mail send --to … --in-reply-to <message-id> …` |
32
39
  | 整理邮箱 | `crab mail archive <id>` / `crab mail trash <id>` / `crab mail delete <id>` |
33
40
 
@@ -47,15 +54,21 @@ description: Read, search and send the user's Crab Cloud platform email (usernam
47
54
 
48
55
  - `mail list --json` → `{ items: [...], nextCursor }`,条目含
49
56
  `id/folder/fromAddr/to/subject/snippet/isUnread/hasAttachments/createdAt`;
50
- - `mail read --json` → 完整正文 + `actors`(谁读过/谁代发,审计源);
57
+ - `mail read --json` → 完整正文 + `attachments`(id/filename/mimeType/sizeBytes)
58
+ + `actors`(谁读过/谁代发,审计源);
51
59
  - `mail send --json` → `{ message, chargedCredits, failedExternal }`。
52
60
 
53
61
  ## 纪律
54
62
 
55
63
  - **发邮件是外发动作**:发送前向用户复述收件人、主题与计费口径,确认后再执行;
56
- 不确定收件人身份时先搜索历史邮件核对,不要猜测地址。
64
+ 不确定收件人身份时优先查联系人簿(`crab contacts list <名字>`,见 `crabcloud`
65
+ skill),其次搜索历史邮件核对,不要猜测地址。
57
66
  - **`mail delete` 是彻底删除**,不可恢复;用户没明说「彻底删除」时用
58
67
  `mail trash`(可找回,废纸篓 30 天后自动清除)。
59
68
  - 草稿流程:先 `mail list --folder drafts` 查看草稿;本阶段 CLI 不直接改草稿,
60
69
  确认发送请把草稿内容转述给用户后用 `mail send` 发出。
61
- - 附件:本阶段只标记 `hasAttachments`,附件内容尚未开放读取,如实说明。
70
+ - 附件:`mail read` 输出附件清单(id/文件名/类型/体积);附件内容归云盘管
71
+ ——下载用 `crab storage download <id>`(见 `crabcloud-storage` skill,跨来
72
+ 源通用),发送引用云盘对象用 `mail send --attach`(对象须未被其他邮件绑
73
+ 定;若报错说明已被引用,改走正文链接)。邮件详情附件显示「已在云盘删除」
74
+ 表示对象已被软删,如实转述。
@@ -12,7 +12,12 @@ description: Manage the user's Crab Cloud Drive (云盘 — unified storage for
12
12
 
13
13
  ## 第一步:确认可用
14
14
 
15
- - `crab --version` 确认已安装;未登录(退出码 3)引导用户 `crab login`。
15
+ - 运行 `crab --version` 确认已安装。命令不存在时让用户运行
16
+ `npx @crabcloud/cli init`(登录 + 安装本 skill 一步完成);已装过但命令缺失
17
+ 时运行 `npx @crabcloud/cli@latest init` 升级。
18
+ - CLI 基于 npx 分发,**本机没有 `crab` 命令时,下文所有 `crab <命令>` 都可以
19
+ `npx @crabcloud/cli <命令>` 等价执行**(例如 `npx @crabcloud/cli storage ls`)。
20
+ - 未登录(退出码 3)引导用户 `crab login`。
16
21
  - 动作需要对应 scope:列取/下载 `storage.read`,上传/改名/标签/分类/公开
17
22
  `storage.write`,移回收站/恢复/彻底删除 `storage.delete`。退出码 4 = scope
18
23
  不足:如实转述缺哪个,引导 `crab login --scopes storage`(组名 = 三项整组)
@@ -33,6 +38,7 @@ description: Manage the user's Crab Cloud Drive (云盘 — unified storage for
33
38
  | 加/删标签 | `crab storage tag <id\|文件名> --add a,b [--remove c,d]` |
34
39
  | 归类 / 建分类 | `crab storage category <id\|文件名> <categoryId\|none>`;`crab storage mkcat <名称>`;分类 id 见 `crab storage categories` |
35
40
  | 公开分享 | `crab storage publish <id\|文件名>`(打印公开直链;收回用 `unpublish`) |
41
+ | 可控分享(有效期/提取码/撤销) | `crab storage share <id\|文件名> [--expires 7\|30\|90\|forever] [--passcode 提取码]`(打印完整分享链接;加密对象链接自带 `#` 密钥片段);列表 `crab storage shares <id\|文件名>`、撤销 `crab storage unshare <shareId>` |
36
42
  | 删除 | `crab storage rm <id\|文件名>`(进回收站,7 天后自动清理)→ `restore` 恢复 / `purge` 彻底删除 |
37
43
  | 看水位 | `crab storage quota`(三来源分项 + 回收站占用 + 对象计数) |
38
44
 
@@ -44,7 +50,7 @@ restore/purge 直接用文件名即可);命中多个或查不到时按字面
44
50
 
45
51
  程序化消费**始终加 `--json`**:
46
52
 
47
- - `storage ls --json` → `{ objects: [...], nextOffset }`,条目含
53
+ - `storage ls --json` → `{ objects: [...], nextOffset, total }`,条目含
48
54
  `id / source / filename / mimeType / sizeBytes / categoryId / tags / isPublic / createdAt`;
49
55
  - `storage quota --json` → `{ usedBytes, quotaBytes, bySource: { files, media, mail }, trashBytes, counts }`;
50
56
  - `upload` / `categories` / `tags` 的 `--json` 同构(单对象 / 分类数组 / 标签数组)。
@@ -53,6 +59,9 @@ restore/purge 直接用文件名即可);命中多个或查不到时按字面
53
59
 
54
60
  - **publish 是对外公开**:公开后任何持链接者可访问,先向用户复述对象与后果,
55
61
  确认后再执行;收回用 `unpublish`(已缓存浏览器至多 5 分钟内失效)。
62
+ - **share 是可控分享**:默认 7 天有效、可选提取码、随时 `unshare` 撤销;加密
63
+ 对象的分享链接自带解密钥匙(`#` 后缀)——泄露链接即泄露文件,外发前向
64
+ 用户复述有效期与提取码设置,确认后再执行。
56
65
  - **purge 是彻底删除**,不可恢复;用户没明说「彻底删除」时一律用 `rm`
57
66
  (回收站保留 7 天可找回)。
58
67
  - 邮件附件与用户上传同账:水位告急时用 `quota` 分项定位来源再建议清理。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crabcloud/cli",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
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": {
@@ -24,6 +24,7 @@
24
24
  "prepublishOnly": "bun run typecheck && bun run build"
25
25
  },
26
26
  "devDependencies": {
27
+ "@crabcloud/cloudkit": "workspace:*",
27
28
  "@crabcloud/contracts": "workspace:*",
28
29
  "@crabcloud/skills": "workspace:*",
29
30
  "@types/bun": "latest",