@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("
|
|
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
|
|
6272
|
-
[--in-reply-to <message-id>]
|
|
6273
|
-
|
|
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
|
|
6570
|
-
const
|
|
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 (
|
|
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(`
|
|
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
|
|
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]
|
|
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:
|
|
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:
|
|
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(`✓
|
|
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([
|
|
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(`✓
|
|
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 =
|
|
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
|
|
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(
|
|
7017
|
-
console.log(`✓ 已下载:${out}(${formatBytesCli(
|
|
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`
|
|
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` → 完整正文 + `
|
|
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
|
-
-
|
|
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`
|
|
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.
|
|
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",
|