dsh-agentone 1.0.0 → 1.0.3
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/README.md +23 -0
- package/lib/archive.js +323 -0
- package/lib/client.js +390 -0
- package/lib/drive.js +289 -0
- package/lib/index.js +159 -0
- package/lib/page.js +248 -2
- package/package.json +3 -2
- package/skills/agentone-drive/SKILL.md +100 -0
- package/skills/agentone-drive/scripts/drive.mjs +493 -0
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// AgentOne 云盘文件夹助手(随 dsh-agentone 的 agentone-drive 技能安装到本机技能目录)。
|
|
3
|
+
//
|
|
4
|
+
// 职责:这个文件夹是用户与 AI 共享的工作目录——
|
|
5
|
+
// list 查看里面有什么(只读,绝不创建);
|
|
6
|
+
// resolve-folder 取「<项目或应用>」子文件夹 token(没有则创建);
|
|
7
|
+
// archive 把一篇飞书云文档存进该子文件夹(已在则不动,重复调用安全)。
|
|
8
|
+
// 目标文件夹来自插件维护的 drive.json(设置 → 云盘),本脚本不接收文件夹 token 参数。
|
|
9
|
+
//
|
|
10
|
+
// 输出契约:成功 → stdout JSON {ok:true,...} 退出码 0;失败 → stdout
|
|
11
|
+
// {"ok":false,"error":"<中文>"} 退出码 1。--token-only 只打印 token,失败时
|
|
12
|
+
// 改为 stderr + 退出码 1(避免把错误 JSON 当成 token 用进命令行)。
|
|
13
|
+
// 飞书原始英文错误只进 stderr(日志侧),stdout 永远是中文。
|
|
14
|
+
import { execFile } from 'node:child_process';
|
|
15
|
+
import { readdirSync } from 'node:fs';
|
|
16
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
17
|
+
import { homedir } from 'node:os';
|
|
18
|
+
import { delimiter, dirname, join } from 'node:path';
|
|
19
|
+
|
|
20
|
+
const DEFAULT_STORE_DIR = join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'agentone');
|
|
21
|
+
const DEFAULT_CACHE = join(DEFAULT_STORE_DIR, 'drive.json');
|
|
22
|
+
const LARK_BIN = process.platform === 'win32' ? 'lark-cli.cmd' : 'lark-cli';
|
|
23
|
+
const LIST_PAGE_SIZE = 200;
|
|
24
|
+
|
|
25
|
+
const USAGE = `用法:
|
|
26
|
+
node drive.mjs list [--project "项目或应用"] [--drive-cache <路径>]
|
|
27
|
+
node drive.mjs resolve-folder --project "项目或应用" [--token-only] [--drive-cache <路径>]
|
|
28
|
+
node drive.mjs archive --token <文档 token 或链接> [--type docx] [--project "项目或应用"]
|
|
29
|
+
node drive.mjs search <关键词> [--drive-cache <路径>]
|
|
30
|
+
node drive.mjs changed [--limit 20] [--drive-cache <路径>]
|
|
31
|
+
node drive.mjs upload --file <本地文件路径> [--project "项目或应用"] [--name "云盘里的文件名"]
|
|
32
|
+
|
|
33
|
+
说明:AgentOne 云盘文件夹的查看与保存。list 只查看不创建;保存进
|
|
34
|
+
「<项目或应用>」子文件夹(自动创建)。search 按关键词在全部项目文件夹里
|
|
35
|
+
找文档(只读);changed 列出上次查看之后有更新的文档(手机上放进云盘的
|
|
36
|
+
文件会出现在这里),查看后基准自动推进。upload 把本地文件(xlsx/zip/
|
|
37
|
+
图片等普通文件)传进项目文件夹,云文档用 archive 而不是它。
|
|
38
|
+
`;
|
|
39
|
+
|
|
40
|
+
/** seen 基准文件(drive.json 同目录):changed 命令的「上次查看」时间戳。 */
|
|
41
|
+
function seenPath(cachePath) {
|
|
42
|
+
return join(dirname(cachePath), 'drive-seen.json');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function readSeenBaseline(cachePath) {
|
|
46
|
+
try {
|
|
47
|
+
const data = JSON.parse(await readFile(seenPath(cachePath), 'utf8'));
|
|
48
|
+
const value = Number(data?.last_seen_ms);
|
|
49
|
+
return Number.isFinite(value) && value > 0 ? value : null;
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function writeSeenBaseline(cachePath, ms) {
|
|
56
|
+
const path = seenPath(cachePath);
|
|
57
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
58
|
+
await writeFile(path, `${JSON.stringify({ schema_version: 1, last_seen_ms: ms, updated_at: new Date(ms).toISOString() }, null, 2)}\n`, { mode: 0o600 });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function parseArgs(argv) {
|
|
62
|
+
const out = { _: [] };
|
|
63
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
64
|
+
const token = argv[i];
|
|
65
|
+
if (!token.startsWith('--')) { out._.push(token); continue; }
|
|
66
|
+
const key = token.slice(2);
|
|
67
|
+
const next = argv[i + 1];
|
|
68
|
+
if (next === undefined || next.startsWith('--')) { out[key] = true; continue; }
|
|
69
|
+
out[key] = next;
|
|
70
|
+
i += 1;
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function compareVersion(a, b) {
|
|
76
|
+
const left = a.split('.').map(Number);
|
|
77
|
+
const right = b.split('.').map(Number);
|
|
78
|
+
for (let i = 0; i < 3; i += 1) {
|
|
79
|
+
const diff = (left[i] || 0) - (right[i] || 0);
|
|
80
|
+
if (diff) return diff;
|
|
81
|
+
}
|
|
82
|
+
return 0;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** GUI 宿主 PATH 贫瘠(launchd 的 /usr/bin:/bin)时的兑底目录,口径同插件 proc.js。 */
|
|
86
|
+
function fallbackDirs() {
|
|
87
|
+
const home = homedir();
|
|
88
|
+
const dirs = [];
|
|
89
|
+
try {
|
|
90
|
+
const versions = readdirSync(join(home, '.nvm/versions/node'))
|
|
91
|
+
.filter((v) => /^v?\d/.test(v))
|
|
92
|
+
.sort((a, b) => compareVersion(a.replace(/^v/, ''), b.replace(/^v/, '')));
|
|
93
|
+
if (versions.length) dirs.push(join(home, '.nvm/versions/node', versions[versions.length - 1], 'bin'));
|
|
94
|
+
} catch {
|
|
95
|
+
// 没有 nvm:走后面的通用目录
|
|
96
|
+
}
|
|
97
|
+
dirs.push(
|
|
98
|
+
join(home, '.local/bin'), '/opt/homebrew/bin', '/usr/local/bin',
|
|
99
|
+
join(home, 'Library', 'pnpm'), join(home, 'Library', 'pnpm', 'bin'), join(home, '.local', 'share', 'pnpm'),
|
|
100
|
+
);
|
|
101
|
+
return dirs;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function childEnv() {
|
|
105
|
+
const path = [...fallbackDirs(), process.env.PATH || ''].filter(Boolean).join(delimiter);
|
|
106
|
+
return { ...process.env, PATH: path };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function runLark(args) {
|
|
110
|
+
return new Promise((resolve, reject) => {
|
|
111
|
+
execFile(LARK_BIN, args, {
|
|
112
|
+
env: childEnv(), timeout: 120_000, maxBuffer: 16 * 1024 * 1024,
|
|
113
|
+
windowsHide: true, shell: process.platform === 'win32',
|
|
114
|
+
}, (error, stdout, stderr) => {
|
|
115
|
+
if (error?.code === 'ENOENT') {
|
|
116
|
+
reject(new Error('未找到 lark-cli,请先在 dsh 设置 → CLI 里安装并完成飞书授权'));
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
resolve({ stdout: String(stdout || ''), stderr: String(stderr || ''), failed: Boolean(error) });
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** 缺权限/授权失效给出可自助的中文动作,其余只给中性提示(原文进 stderr)。 */
|
|
125
|
+
function larkErrorText(error = {}) {
|
|
126
|
+
const missing = Array.isArray(error.missing_scopes) ? error.missing_scopes : [];
|
|
127
|
+
if (error.subtype === 'missing_scope' || error.code === 99991679 || missing.length) {
|
|
128
|
+
return '你的飞书授权里还没有云盘权限,请到 dsh 设置 → 云盘 点「重新授权」后重试';
|
|
129
|
+
}
|
|
130
|
+
if (error.type === 'authorization') {
|
|
131
|
+
return '飞书授权已失效,请到 dsh 设置 → 云盘 重新授权后重试';
|
|
132
|
+
}
|
|
133
|
+
return '飞书接口调用失败,请稍后重试';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** lark-cli 的 {ok, data|error} 壳解析(口径同插件 lib/drive.js)。 */
|
|
137
|
+
function parseLarkJson(text) {
|
|
138
|
+
let data;
|
|
139
|
+
try {
|
|
140
|
+
data = JSON.parse(String(text || '').trim());
|
|
141
|
+
} catch {
|
|
142
|
+
throw new Error('飞书命令返回解析失败,请稍后重试');
|
|
143
|
+
}
|
|
144
|
+
if (data && typeof data === 'object' && !Array.isArray(data) && 'ok' in data) {
|
|
145
|
+
if (data.ok === false) {
|
|
146
|
+
const error = data.error || {};
|
|
147
|
+
process.stderr.write(`[archive] lark error: ${JSON.stringify(error)}\n`);
|
|
148
|
+
throw new Error(larkErrorText(error));
|
|
149
|
+
}
|
|
150
|
+
return data.data ?? {};
|
|
151
|
+
}
|
|
152
|
+
return data ?? {};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function larkJson(args) {
|
|
156
|
+
const { stdout, stderr, failed } = await runLark(args);
|
|
157
|
+
const text = stdout.trim();
|
|
158
|
+
if (!text) {
|
|
159
|
+
if (failed) {
|
|
160
|
+
const reason = stderr.trim().split('\n')[0] || '';
|
|
161
|
+
process.stderr.write(`[archive] lark failed: ${reason}\n`);
|
|
162
|
+
throw new Error('飞书命令执行失败,请稍后重试');
|
|
163
|
+
}
|
|
164
|
+
throw new Error('飞书命令没有返回内容,请稍后重试');
|
|
165
|
+
}
|
|
166
|
+
return parseLarkJson(text);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function readRootFolder(cachePath) {
|
|
170
|
+
let data;
|
|
171
|
+
try {
|
|
172
|
+
data = JSON.parse(await readFile(cachePath, 'utf8'));
|
|
173
|
+
} catch {
|
|
174
|
+
throw new Error('还没有找到云盘文件夹,请先在 dsh 设置 → 云盘 点「初始化 / 重新定位」');
|
|
175
|
+
}
|
|
176
|
+
const folder = data?.agentone_folder;
|
|
177
|
+
if (!folder?.token) {
|
|
178
|
+
throw new Error('云盘文件夹记录里没有可用文件夹,请先在 dsh 设置 → 云盘 重新定位');
|
|
179
|
+
}
|
|
180
|
+
return { token: String(folder.token), url: String(folder.url || ''), name: String(folder.name || 'AgentOne') };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function listFolder(folderToken) {
|
|
184
|
+
const data = await larkJson([
|
|
185
|
+
'api', 'GET', '/open-apis/drive/v1/files',
|
|
186
|
+
'--params', JSON.stringify({ folder_token: folderToken, page_size: LIST_PAGE_SIZE }),
|
|
187
|
+
'--format', 'json',
|
|
188
|
+
]);
|
|
189
|
+
return data;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* 给飞书链接带上中文界面参数。宿主的内嵌浏览器是按账号分区的独立会话
|
|
194
|
+
* (persist:dsh-platform-…),locale cookie 跟云盘页签不共用、可能是英文;
|
|
195
|
+
* `?lang=zh-CN` 会被飞书网关消费并改写该分区的 locale cookie(口径同
|
|
196
|
+
* lib/drive.js 的 withChineseLocale,脚本自包含故复制一份)。
|
|
197
|
+
*/
|
|
198
|
+
function withChineseLocale(raw) {
|
|
199
|
+
const url = String(raw || '').trim();
|
|
200
|
+
if (!url) return url;
|
|
201
|
+
try {
|
|
202
|
+
// 只探测、不重建:URL.toString() 会把路径里的非 ASCII 百分号编码,改变
|
|
203
|
+
// 返回给用户的链接字形;这里保持原样,仅在 hash 前追加参数。
|
|
204
|
+
const parsed = new URL(url);
|
|
205
|
+
if (parsed.searchParams.has('lang')) return url;
|
|
206
|
+
const hash = parsed.hash || '';
|
|
207
|
+
const base = hash ? url.slice(0, url.length - hash.length) : url;
|
|
208
|
+
return `${base}${parsed.search ? '&' : '?'}lang=zh-CN${hash}`;
|
|
209
|
+
} catch {
|
|
210
|
+
return url;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function folderUrl(root, token, raw) {
|
|
215
|
+
if (raw) return withChineseLocale(raw);
|
|
216
|
+
try {
|
|
217
|
+
return withChineseLocale(`${new URL(root.url).origin}/drive/folder/${token}`);
|
|
218
|
+
} catch {
|
|
219
|
+
return '';
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** 只查找不创建(读取路径专用):返回 {token,url,name} 或 null。 */
|
|
224
|
+
async function findFolder(parent, name) {
|
|
225
|
+
const listed = await listFolder(parent.token);
|
|
226
|
+
const hit = (Array.isArray(listed.files) ? listed.files : [])
|
|
227
|
+
.find((item) => item?.type === 'folder' && item?.name === name);
|
|
228
|
+
return hit ? { token: String(hit.token), url: folderUrl(parent, hit.token, hit.url), name } : null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function findOrCreateFolder(root, parent, name) {
|
|
232
|
+
const hit = await findFolder(parent, name);
|
|
233
|
+
if (hit) return hit;
|
|
234
|
+
const created = await larkJson([
|
|
235
|
+
'drive', '+create-folder', '--name', name, '--folder-token', parent.token, '--format', 'json',
|
|
236
|
+
]);
|
|
237
|
+
const token = created.folder_token || created.token;
|
|
238
|
+
if (!token) throw new Error(`创建子文件夹「${name}」失败:飞书未返回文件夹标识,请稍后重试`);
|
|
239
|
+
return { token: String(token), url: folderUrl(root, token, created.url), name };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** 子目录名净化:去掉路径分隔符与控制字符,避免在云盘里造出意外的层级。 */
|
|
243
|
+
function sanitizeName(value) {
|
|
244
|
+
const cleaned = String(value || '')
|
|
245
|
+
.replace(/[\\/:*?"<>|\u0000-\u001f]/g, '-')
|
|
246
|
+
.replace(/\s+/g, ' ')
|
|
247
|
+
.trim()
|
|
248
|
+
.replace(/^[.\-\s]+/, '')
|
|
249
|
+
.slice(0, 40)
|
|
250
|
+
.trim();
|
|
251
|
+
return cleaned || '未分类';
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function resolveTarget(root, project) {
|
|
255
|
+
// 只按「项目/应用」分组一层:同一项目的产物聚在一个目录里,便于按项目找,
|
|
256
|
+
// 也不引入按时间切分的层级(用户明确要求不要日期层)。
|
|
257
|
+
const groupName = sanitizeName(project);
|
|
258
|
+
const group = await findOrCreateFolder(root, root, groupName);
|
|
259
|
+
return { ...group, path: `${root.name}/${groupName}` };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function report(root, target) {
|
|
263
|
+
// 输出的链接一律带 ?lang=zh-CN:AI 会把这些链接直接给用户,在宿主内嵌浏览器
|
|
264
|
+
// (独立会话)里打开时界面语言才不会被该分区的 locale cookie 重置成英文。
|
|
265
|
+
return {
|
|
266
|
+
folder_token: target.token,
|
|
267
|
+
folder_url: withChineseLocale(target.url),
|
|
268
|
+
path: target.path,
|
|
269
|
+
root_url: withChineseLocale(root.url),
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async function resolveFolderCommand(args) {
|
|
274
|
+
const root = await readRootFolder(args['drive-cache'] || DEFAULT_CACHE);
|
|
275
|
+
const target = await resolveTarget(root, args.project);
|
|
276
|
+
if (args['token-only']) return { tokenOnly: target.token };
|
|
277
|
+
return { ok: true, ...report(root, target) };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function archiveCommand(args) {
|
|
281
|
+
const root = await readRootFolder(args['drive-cache'] || DEFAULT_CACHE);
|
|
282
|
+
let fileToken = String(args.token || '').trim();
|
|
283
|
+
let fileType = String(args.type || '').trim().toLowerCase();
|
|
284
|
+
if (!fileToken || fileToken === 'true') throw new Error('缺少 --token(文档 token 或飞书链接)');
|
|
285
|
+
if (/^https?:\/\//i.test(fileToken)) {
|
|
286
|
+
const info = await larkJson(['drive', '+inspect', '--url', fileToken, '--format', 'json']);
|
|
287
|
+
fileToken = String(info.obj_token || info.token || '');
|
|
288
|
+
fileType = fileType || String(info.obj_type || info.type || '').toLowerCase();
|
|
289
|
+
if (!fileToken) throw new Error('无法从链接解析出文档标识,请改用文档 token 重试');
|
|
290
|
+
}
|
|
291
|
+
const target = await resolveTarget(root, args.project);
|
|
292
|
+
// 只翻第一页判断「是否已归档」:命中则不动(幂等);漏判时移动到自己所在的
|
|
293
|
+
// 目录同样无副作用,不为此翻完整个目录。
|
|
294
|
+
const listed = await listFolder(target.token);
|
|
295
|
+
const archived = !(Array.isArray(listed.files) ? listed.files : []).some((item) => item?.token === fileToken);
|
|
296
|
+
if (archived) {
|
|
297
|
+
await larkJson([
|
|
298
|
+
'drive', '+move', '--file-token', fileToken, '--type', fileType || 'file',
|
|
299
|
+
'--folder-token', target.token, '--format', 'json',
|
|
300
|
+
]);
|
|
301
|
+
}
|
|
302
|
+
return { ok: true, archived, ...report(root, target) };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* 列出文件夹里的内容(读取路径,绝不创建)。不带 --project 列根文件夹;
|
|
307
|
+
* 带 --project 只列该子文件夹(不存在返回空列表,不新建)。
|
|
308
|
+
*/
|
|
309
|
+
async function listCommand(args) {
|
|
310
|
+
const root = await readRootFolder(args['drive-cache'] || DEFAULT_CACHE);
|
|
311
|
+
let folder = root;
|
|
312
|
+
let path = root.name;
|
|
313
|
+
if (args.project !== undefined) {
|
|
314
|
+
const groupName = sanitizeName(args.project);
|
|
315
|
+
const found = await findFolder(root, groupName);
|
|
316
|
+
if (!found) {
|
|
317
|
+
return { ok: true, folder_token: '', folder_url: '', path: `${root.name}/${groupName}`, files: [], has_more: false };
|
|
318
|
+
}
|
|
319
|
+
folder = { ...found, url: found.url || root.url };
|
|
320
|
+
path = `${root.name}/${groupName}`;
|
|
321
|
+
}
|
|
322
|
+
const listed = await listFolder(folder.token);
|
|
323
|
+
const files = (Array.isArray(listed.files) ? listed.files : []).map((item) => ({
|
|
324
|
+
name: item?.name || '',
|
|
325
|
+
type: item?.type || '',
|
|
326
|
+
token: item?.token || '',
|
|
327
|
+
url: withChineseLocale(item?.url),
|
|
328
|
+
}));
|
|
329
|
+
return {
|
|
330
|
+
ok: true, folder_token: folder.token, folder_url: withChineseLocale(folder.url), path,
|
|
331
|
+
files, has_more: Boolean(listed.has_more),
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** list 输出的条目形状(含 url 中文化);changed/search 复用。 */
|
|
336
|
+
function fileEntry(item) {
|
|
337
|
+
return {
|
|
338
|
+
name: item?.name || '',
|
|
339
|
+
type: item?.type || '',
|
|
340
|
+
token: item?.token || '',
|
|
341
|
+
url: withChineseLocale(item?.url),
|
|
342
|
+
modified_time: item?.modified_time || '',
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* 根目录下各项目文件夹的一层清单(读取路径,不创建):[{project, files}]。
|
|
348
|
+
* 单层结构是产品约定(不按日期切分),所以遍历固定只有两层。
|
|
349
|
+
*/
|
|
350
|
+
async function listProjectFolders(root) {
|
|
351
|
+
const listed = await listFolder(root.token);
|
|
352
|
+
const folders = (Array.isArray(listed.files) ? listed.files : []).filter((item) => item?.type === 'folder');
|
|
353
|
+
const groups = [];
|
|
354
|
+
for (const folder of folders) {
|
|
355
|
+
const child = await listFolder(String(folder.token));
|
|
356
|
+
groups.push({
|
|
357
|
+
project: folder.name || '',
|
|
358
|
+
files: (Array.isArray(child.files) ? child.files : []).map(fileEntry),
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
return groups;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* 按关键词在全部项目文件夹里找文档(只读)。文件名不区分大小写的包含匹配;
|
|
366
|
+
* 命中条目带 project 字段方便直接定位。一个文件夹翻页上限内(200 条)找不到
|
|
367
|
+
* 就当没有——读取路径不追求全量,翻页交给 lark-cli 原生命令。
|
|
368
|
+
*/
|
|
369
|
+
async function searchCommand(args) {
|
|
370
|
+
const keyword = String(args._[1] || '').trim();
|
|
371
|
+
if (!keyword) throw new Error('缺少搜索关键词,例如:node drive.mjs search 调研');
|
|
372
|
+
const root = await readRootFolder(args['drive-cache'] || DEFAULT_CACHE);
|
|
373
|
+
const needle = keyword.toLowerCase();
|
|
374
|
+
const groups = await listProjectFolders(root);
|
|
375
|
+
const matches = [];
|
|
376
|
+
for (const group of groups) {
|
|
377
|
+
for (const file of group.files) {
|
|
378
|
+
if (file.name.toLowerCase().includes(needle)) {
|
|
379
|
+
matches.push({ ...file, project: group.project });
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
return {
|
|
384
|
+
ok: true, keyword, root_url: withChineseLocale(root.url),
|
|
385
|
+
matches, total: matches.length,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* 增量感知:列出上次查看(基准时间戳)之后有更新的文档。首跑没有基准 →
|
|
391
|
+
* 记录当前时间并返回空列表(first_run),此后手机上放进云盘/有编辑的文档
|
|
392
|
+
* 会按 modified_time 出现在结果里。每次查看默认把基准推进到本次运行时刻
|
|
393
|
+
* (AI 看过即已读),--no-update 可保留基准。
|
|
394
|
+
*/
|
|
395
|
+
async function changedCommand(args) {
|
|
396
|
+
const cachePath = args['drive-cache'] || DEFAULT_CACHE;
|
|
397
|
+
const root = await readRootFolder(cachePath);
|
|
398
|
+
const now = Date.now();
|
|
399
|
+
const baseline = await readSeenBaseline(cachePath);
|
|
400
|
+
if (baseline === null) {
|
|
401
|
+
await writeSeenBaseline(cachePath, now);
|
|
402
|
+
return {
|
|
403
|
+
ok: true, first_run: true, root_url: withChineseLocale(root.url),
|
|
404
|
+
changes: [], total: 0,
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
const groups = await listProjectFolders(root);
|
|
408
|
+
// 根目录下新建/重命名的项目文件夹也算变化(文件夹本身是清单项)
|
|
409
|
+
const rootListed = await listFolder(root.token);
|
|
410
|
+
const changes = [];
|
|
411
|
+
for (const item of Array.isArray(rootListed.files) ? rootListed.files : []) {
|
|
412
|
+
if (item?.type === 'folder' && Number(item?.modified_time || 0) * 1000 > baseline) {
|
|
413
|
+
changes.push({ ...fileEntry(item), project: '' });
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
for (const group of groups) {
|
|
417
|
+
for (const file of group.files) {
|
|
418
|
+
if (Number(file.modified_time || 0) * 1000 > baseline) {
|
|
419
|
+
changes.push({ ...file, project: group.project });
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
// 新的在前,截断交给 --limit
|
|
424
|
+
changes.sort((a, b) => Number(b.modified_time || 0) - Number(a.modified_time || 0));
|
|
425
|
+
const limitRaw = Number(args.limit);
|
|
426
|
+
const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? Math.min(Math.floor(limitRaw), 200) : 20;
|
|
427
|
+
if (args['no-update'] !== true) {
|
|
428
|
+
await writeSeenBaseline(cachePath, now);
|
|
429
|
+
}
|
|
430
|
+
return {
|
|
431
|
+
ok: true, first_run: false,
|
|
432
|
+
since: new Date(baseline).toISOString(),
|
|
433
|
+
root_url: withChineseLocale(root.url),
|
|
434
|
+
changes: changes.slice(0, limit),
|
|
435
|
+
total: changes.length,
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* 本地文件上传:resolve 建好项目文件夹后交给 lark-cli drive +upload 一步到位。
|
|
441
|
+
* 只管普通文件(xlsx/zip/图片等);在线云文档(docx/sheet/base/slides)走
|
|
442
|
+
* archive——它们不是上传而是移动/创建,入口必须分开,避免用户把云文档
|
|
443
|
+
* “上传”出一份重复副本。
|
|
444
|
+
*/
|
|
445
|
+
async function uploadCommand(args) {
|
|
446
|
+
const filePath = String(args.file || '').trim();
|
|
447
|
+
if (!filePath || filePath === 'true') throw new Error('缺少 --file(本地文件路径)');
|
|
448
|
+
const root = await readRootFolder(args['drive-cache'] || DEFAULT_CACHE);
|
|
449
|
+
const target = await resolveTarget(root, args.project);
|
|
450
|
+
const uploadArgs = ['drive', '+upload', '--file', filePath, '--folder-token', target.token, '--format', 'json'];
|
|
451
|
+
const customName = String(args.name || '').trim();
|
|
452
|
+
if (customName && customName !== 'true') uploadArgs.push('--name', customName);
|
|
453
|
+
const data = await larkJson(uploadArgs);
|
|
454
|
+
const token = String(data.file_token || data.token || '');
|
|
455
|
+
return {
|
|
456
|
+
ok: true, file_token: token, name: String(data.name || customName || ''),
|
|
457
|
+
url: withChineseLocale(String(data.url || '')),
|
|
458
|
+
...report(root, target),
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
async function main() {
|
|
463
|
+
const args = parseArgs(process.argv.slice(2));
|
|
464
|
+
const command = args._[0];
|
|
465
|
+
if (!command || args.help) {
|
|
466
|
+
process.stdout.write(USAGE);
|
|
467
|
+
return null;
|
|
468
|
+
}
|
|
469
|
+
if (command === 'list') return listCommand(args);
|
|
470
|
+
if (command === 'resolve-folder') return resolveFolderCommand(args);
|
|
471
|
+
if (command === 'archive') return archiveCommand(args);
|
|
472
|
+
if (command === 'search') return searchCommand(args);
|
|
473
|
+
if (command === 'changed') return changedCommand(args);
|
|
474
|
+
if (command === 'upload') return uploadCommand(args);
|
|
475
|
+
throw new Error(`未知子命令:${command}(可用:list、resolve-folder、archive、search、changed、upload)`);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
main().then((result) => {
|
|
479
|
+
if (!result) return;
|
|
480
|
+
if (result.tokenOnly) {
|
|
481
|
+
process.stdout.write(`${result.tokenOnly}\n`);
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
485
|
+
}).catch((error) => {
|
|
486
|
+
const message = error?.message || '保存失败,请稍后重试';
|
|
487
|
+
if (process.argv.includes('--token-only')) {
|
|
488
|
+
process.stderr.write(`保存失败:${message}\n`);
|
|
489
|
+
} else {
|
|
490
|
+
process.stdout.write(`${JSON.stringify({ ok: false, error: message })}\n`);
|
|
491
|
+
}
|
|
492
|
+
process.exitCode = 1;
|
|
493
|
+
});
|