dsh-agentone 1.0.2 → 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/lib/archive.js CHANGED
@@ -28,7 +28,7 @@ import { resolveDshHome, storeDir } from './store.js';
28
28
  export const ARCHIVE_MARKER_BEGIN = '<!-- dsh-agentone:drive-archive begin -->';
29
29
  export const ARCHIVE_MARKER_END = '<!-- dsh-agentone:drive-archive end -->';
30
30
  export const ARCHIVE_SKILL_SLUG = 'agentone-drive';
31
- export const ARCHIVE_SKILL_VERSION = '1.2.0';
31
+ export const ARCHIVE_SKILL_VERSION = '1.3.0';
32
32
  /** 历史版本用过的技能目录名:升级时带归属标记的顺带清理,避免技能列表出现新旧两份。 */
33
33
  export const LEGACY_SKILL_SLUGS = ['agentone-drive-archive'];
34
34
  const PREF_FILE = 'archive.json';
@@ -145,8 +145,9 @@ export function archiveInstructionBlock({ driveCachePath: cachePath }) {
145
145
  '- 云盘(dsh-agentone 已启用):飞书云盘的 AgentOne 文件夹是本机与 AI 共享的工作目录,',
146
146
  ' 你可以读取使用里面的资料,也可以把飞书云文档(文档 / 表格 / 多维表格 / 幻灯片)',
147
147
  ' 存进去,子文件夹按「项目或应用」自动创建。',
148
- '- 需要参考资料、或用户提到云盘里的资料时,可用 agentone-drive 技能查看与读取;',
149
- ' 交付文档或用户要求保存/整理文档时,可用它存进对应的项目文件夹。',
148
+ '- 需要参考资料、或用户提到云盘里的资料时,可用 agentone-drive 技能查看、按关键词',
149
+ ' 搜索或读取;用户提到「新放进云盘的文件」时可用它看最近更新。交付文档或用户',
150
+ ' 要求保存/整理文档时,可用它存进对应的项目文件夹(本地文件也能上传)。',
150
151
  `- 文件夹地址以 \`${cachePath}\` 的 \`agentone_folder\` 为准;读不到就先让用户在`,
151
152
  ' 「设置 → 云盘」点「初始化 / 重新定位」。命令与重复保存的判断见 agentone-drive 技能。',
152
153
  '- 到「设置 → 云盘」停用开关即可取消。',
package/lib/client.js CHANGED
@@ -293,6 +293,12 @@ window.__ModuleLoader__.load({
293
293
  const DRIVE_TAB_ID = 'agentone:feishu-drive';
294
294
  const DRIVE_HOME_API = '/agentone/api/drive/home';
295
295
 
296
+ // better-sidebar 服务引用:页签注册(ctx.inject 回调)与 footer 入口按钮
297
+ // (组件渲染时)两个消费方。组件运行在 React 渲染期,inject 回调早已把
298
+ // 服务存进这里;未装 better-sidebar 时为 null,footer 按钮退化为开设置页。
299
+ let driveSidebarService = null;
300
+ function setDriveSidebarService(value) { driveSidebarService = value || null; }
301
+
296
302
  // 寻址响应 → 组件相位(纯函数,测试直接覆盖)。
297
303
  const DRIVE_GATE_STATES = [
298
304
  'cli_missing', 'cli_unconfigured', 'cli_unauthorized',
@@ -501,18 +507,77 @@ window.__ModuleLoader__.load({
501
507
  order: 80,
502
508
  single: true,
503
509
  // 飞书风格云盘实心图标(用户提供的 云盘.svg,viewBox 1024)
504
- icon: (size) => React.createElement('svg', {
505
- viewBox: '0 0 1024 1024', width: size, height: size,
506
- 'aria-hidden': 'true', focusable: 'false',
507
- }, React.createElement('path', {
508
- d: 'M778.581333 374.101333C766.976 237.226667 651.946667 129.706667 512 129.706667s-254.976 107.52-266.581333 244.394666c-93.184 27.989333-160.768 114.005333-160.768 216.064 0 124.586667 101.034667 225.621333 225.621333 225.621334h404.138667c124.586667 0 225.621333-101.034667 225.621333-225.621334-0.341333-102.058667-68.266667-188.074667-161.450667-216.064z m-128.341333 209.237334c-22.869333 21.845333-62.122667 49.834667-117.418667 58.709333-11.264 1.706667-22.186667 2.730667-32.768 2.730667-72.704 0-126.293333-37.888-142.336-50.858667-18.090667-14.677333-21.162667-41.301333-6.485333-59.392 14.677333-18.090667 41.301333-21.162667 59.392-6.485333 11.605333 9.557333 53.930667 39.253333 108.885333 30.72 35.498667-5.802667 60.416-25.258667 72.021334-36.181334 17.066667-16.042667 43.690667-15.36 59.733333 1.365334 16.725333 16.384 16.042667 43.349333-1.024 59.392z',
509
- fill: '#635DF7',
510
- opacity: '.7',
511
- })),
510
+ icon: driveCloudIcon,
512
511
  component: () => React.createElement(FeishuDriveTab),
513
512
  };
514
513
  }
515
514
 
515
+ // 飞书风格云盘实心图标(用户提供的 云盘.svg,viewBox 1024):侧边栏页签与
516
+ // footer 入口按钮共用。
517
+ function driveCloudIcon(size) {
518
+ return React.createElement('svg', {
519
+ viewBox: '0 0 1024 1024', width: size, height: size,
520
+ 'aria-hidden': 'true', focusable: 'false',
521
+ }, React.createElement('path', {
522
+ d: 'M778.581333 374.101333C766.976 237.226667 651.946667 129.706667 512 129.706667s-254.976 107.52-266.581333 244.394666c-93.184 27.989333-160.768 114.005333-160.768 216.064 0 124.586667 101.034667 225.621333 225.621333 225.621334h404.138667c124.586667 0 225.621333-101.034667 225.621333-225.621334-0.341333-102.058667-68.266667-188.074667-161.450667-216.064z m-128.341333 209.237334c-22.869333 21.845333-62.122667 49.834667-117.418667 58.709333-11.264 1.706667-22.186667 2.730667-32.768 2.730667-72.704 0-126.293333-37.888-142.336-50.858667-18.090667-14.677333-21.162667-41.301333-6.485333-59.392 14.677333-18.090667 41.301333-21.162667 59.392-6.485333 11.605333 9.557333 53.930667 39.253333 108.885333 30.72 35.498667-5.802667 60.416-25.258667 72.021334-36.181334 17.066667-16.042667 43.690667-15.36 59.733333 1.365334 16.725333 16.384 16.042667 43.349333-1.024 59.392z',
523
+ fill: '#635DF7',
524
+ opacity: '.7',
525
+ }));
526
+ }
527
+
528
+ // ── sidebar.footer.action 云盘入口 ─────────────────────────────────────
529
+ // 主界面左栏底部、设置按钮上方的常驻按钮(参照 dsh-ccpg-larkauth 的
530
+ // 「飞书账号」入口,同 slot 同几何口径)。点击打开 better-sidebar 的云盘
531
+ // 页签;未装 better-sidebar 时退化为新窗口开设置页(与页签注册的静默
532
+ // 策略一致,不放死按钮)。宽态带「飞书云盘」文字、收起态只留图标,两态
533
+ // 几何(42px 满行 / 36px 圆钮)逐项对齐官方设置触发器;多个 footer 入口
534
+ // 的纵向堆叠由本插件的 :has() 规则自己兜(不依赖 harness-one 装没装)。
535
+ const FOOTER_STYLE_ID = 'agentone-footer-entry-style';
536
+ function ensureFooterStyle() {
537
+ // 非 DOM 宿主(测试 vm 沙箱/单测加载)直接跳过
538
+ if (typeof document === 'undefined' || !document.getElementById) return;
539
+ if (document.getElementById(FOOTER_STYLE_ID)) return;
540
+ const style = document.createElement('style');
541
+ style.id = FOOTER_STYLE_ID;
542
+ style.textContent = [
543
+ '.aone-drive-entry{box-sizing:border-box;cursor:pointer;width:calc(100% + 4px);height:42px;',
544
+ 'color:var(--dsw-alias-label-primary);background:0 0;border:none;border-radius:12px;',
545
+ 'flex:none;align-items:center;justify-content:flex-start;gap:8px;margin:4px -2px;',
546
+ 'padding:0 10px 0 8px;font-family:inherit;font-size:14px;line-height:22px;',
547
+ 'display:flex;overflow:hidden;white-space:nowrap}',
548
+ '.aone-drive-entry:hover{background:var(--dsw-alias-interactive-bg-hover)}',
549
+ '.aone-drive-entry.aone-drive-rail{border-radius:50%;justify-content:center;gap:0;width:36px;height:36px;margin:8px 0 10px;padding:0}',
550
+ ':has(> [data-slot="sidebar.footer.action"] .aone-drive-entry){display:flex;flex-direction:column}',
551
+ ].join('');
552
+ document.head.appendChild(style);
553
+ }
554
+
555
+ function DriveFooterEntry(props) {
556
+ const wide = Boolean(props && props.wide);
557
+ ensureFooterStyle();
558
+ const openDrive = () => {
559
+ const sidebar = driveSidebarService;
560
+ if (sidebar && typeof sidebar.openTab === 'function') {
561
+ const sessionId = currentDshSessionId();
562
+ try {
563
+ sidebar.openTab({ type: DRIVE_TAB_ID, title: '飞书云盘' },
564
+ sessionId ? { sessionId } : undefined);
565
+ return;
566
+ } catch {
567
+ /* 页签打不开(旧版本服务/无会话)→ 落到设置页兜底 */
568
+ }
569
+ }
570
+ window.open('/agentone/', '_blank', 'noopener');
571
+ };
572
+ const children = [driveCloudIcon(18)];
573
+ if (wide) children.push(React.createElement('span', null, '飞书云盘'));
574
+ return React.createElement('button', {
575
+ onClick: openDrive,
576
+ title: '飞书云盘(AgentOne 文件夹)',
577
+ className: wide ? 'aone-drive-entry' : 'aone-drive-entry aone-drive-rail',
578
+ }, ...children);
579
+ }
580
+
516
581
  // 设置页(/agentone/ 子框架,web 走 iframe、桌面壳走 srcdoc)里点「打开云盘」
517
582
  // 时,经 postMessage 让宿主页这边的我们把页签开出来;开不了就回 ok:false,
518
583
  // 页面自己退回「新窗口打开飞书链接」。srcdoc 子框架 origin 为 'null'。
@@ -547,6 +612,7 @@ window.__ModuleLoader__.load({
547
612
  if (typeof ctx.inject !== 'function') return () => {};
548
613
  return ctx.inject(['betterSidebar'], (scope) => {
549
614
  const sidebar = scope.betterSidebar;
615
+ setDriveSidebarService(sidebar);
550
616
  if (!sidebar || typeof sidebar.registerTab !== 'function') return;
551
617
  const openDriveTab = () => {
552
618
  const sessionId = currentDshSessionId();
@@ -582,6 +648,11 @@ window.__ModuleLoader__.load({
582
648
  scope.effect(() => () => window.removeEventListener('message', onMessage),
583
649
  'dsh-agentone: feishu-drive open bridge');
584
650
  }
651
+ // 插件卸载时清掉共享引用:footer 按钮随后点击走设置页兜底,不持有悬空服务
652
+ if (typeof scope.effect === 'function') {
653
+ scope.effect(() => () => setDriveSidebarService(null),
654
+ 'dsh-agentone: feishu-drive service ref');
655
+ }
585
656
  });
586
657
  }
587
658
  // ==drive-tab-end==
@@ -640,6 +711,18 @@ window.__ModuleLoader__.load({
640
711
  setupNavIcon();
641
712
  // 飞书云盘侧边栏 tab(better-sidebar 缺失时内部静默跳过)
642
713
  registerFeishuDriveTab(ctx);
714
+ // 主界面左栏底部云盘入口(order 10 < 飞书账号 20 → 排在它上方、设置按钮之下)。
715
+ // 不依赖 better-sidebar:点击时服务在就开页签,不在退化为新窗口开设置页。
716
+ ctx.slots.inject('sidebar.footer.action', () =>
717
+ ctx.slots.register(
718
+ {
719
+ name: 'sidebar.footer.action',
720
+ id: 'agentone-drive-entry',
721
+ order: 10,
722
+ },
723
+ DriveFooterEntry,
724
+ ),
725
+ );
643
726
  ctx.slots.inject('settings.section', () =>
644
727
  ctx.slots.register(
645
728
  {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-agentone",
3
- "version": "1.0.2",
4
- "description": "AgentOne 平台集成:平台模型、SkillHub 技能、套餐、CLI、飞书云盘共享文件夹(侧栏页签;「启用云盘」后 AI 可读取使用资料、把文档存进文件夹)",
3
+ "version": "1.0.3",
4
+ "description": "AgentOne 平台集成:平台模型、SkillHub 技能、套餐、CLI、飞书云盘共享文件夹(侧栏页签 + 左栏底部入口;「启用云盘」后 AI 可读取/搜索资料、看最近更新、存文档、传本地文件)",
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "smoke": "node scripts/env-matrix-smoke.mjs"
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agentone-drive
3
- description: 使用飞书云盘的 AgentOne 文件夹:查看和读取里面的资料,或把飞书云文档(文档 / 表格 / 多维表格 / 幻灯片)保存进去并按「项目或应用」建子文件夹。当需要查共享资料、读取云盘文件夹内容,或把新做的/已有的飞书文档统一存进云盘时使用。
4
- version: 1.2.0
3
+ description: 使用飞书云盘的 AgentOne 文件夹:查看和读取里面的资料,或把飞书云文档(文档 / 表格 / 多维表格 / 幻灯片)保存进去并按「项目或应用」建子文件夹。当需要查共享资料、按关键词搜云盘里的文档、看最近有哪些新放进的文件、读取云盘文件夹内容,或把新做的/已有的飞书文档统一存进云盘时使用。
4
+ version: 1.3.0
5
5
  ---
6
6
 
7
7
  # AgentOne 云盘文件夹 / agentone-drive
@@ -18,9 +18,10 @@ version: 1.2.0
18
18
  ## 文件夹规则
19
19
 
20
20
  - 根文件夹:`{{DRIVE_CACHE}}` 里 `agentone_folder` 的 token(脚本自己读,不要手抄 token)。
21
- - 子文件夹:`<项目或应用>`(只有这一层,不按日期切分),保存时自动创建,查看时不创建。
22
- - 只管理云文档(docx / sheet / base / slides);普通附件上传用 lark-cli 的
23
- `drive +upload`,不要用本技能。
21
+ - 子文件夹:`<项目或应用>`(只有这一层,不按日期切分),保存/上传时自动创建,查看时不创建。
22
+ - 云文档(docx / sheet / base / slides)走 archive/创建流程;普通附件(图片/压缩包/
23
+ xlsx 等本地文件)用本技能的 `upload`,不要自己拼 `drive +upload`(拿不到项目
24
+ 文件夹 token,会传错地方)。
24
25
 
25
26
  ## 一:查看/读取云盘里的资料
26
27
 
@@ -37,6 +38,23 @@ node "{{DRIVE_SCRIPT}}" list --project "项目或应用" # 看某个项目文件
37
38
  - 表格 / 多维表格:`lark-cli` 的 sheets / base 命令
38
39
  - 普通文件:`lark-cli drive +download --file-token <token>`
39
40
 
41
+ ## 一·五:按关键词搜文档 / 看最近更新(不用逐个翻文件夹)
42
+
43
+ ```bash
44
+ node "{{DRIVE_SCRIPT}}" search "关键词" # 在全部项目文件夹里按文件名找(不区分大小写)
45
+ node "{{DRIVE_SCRIPT}}" changed # 上次查看之后新放进/有更新的文档
46
+ ```
47
+
48
+ - `search` 输出 `{ok:true, matches:[{name,type,token,url,project}...], total}`,
49
+ `project` 字段标明文档在哪个项目文件夹;找不到时 `total:0`,可以换关键词再试一次,
50
+ 也不要自创层级。
51
+ - `changed` 用于「用户说看看新放的文件 / 我手机上存了几篇」这类诉求:输出
52
+ `{ok:true, changes:[{name,type,token,url,project,modified_time}...], since, total}`,
53
+ 按更新时间新在前。**首次运行没有基准,返回空列表(first_run:true)属正常**;
54
+ 每次运行会自动把基准推进到当前时刻(看过即已读),不用额外操作。
55
+ - 用户提到「云盘里的资料」但不确定在哪时,先 `search` 再读取;拿到 token/url 后
56
+ 按上一节的方式按类型读取。
57
+
40
58
  ## 二:新建文档时直接存进项目文件夹
41
59
 
42
60
  先取子文件夹 token,再在创建时指定父文件夹,文档一步到位、无需二次移动:
@@ -49,6 +67,17 @@ lark-cli docs +create --title "标题" --content @body.xml --parent-token "$FOLD
49
67
  表格 / 多维表格 / 幻灯片:先用 `lark-cli drive +import --file x.xlsx --type sheet`
50
68
  生成云文档,再按下节存进去。
51
69
 
70
+ ## 二·五:本地文件传进项目文件夹
71
+
72
+ ```bash
73
+ node "{{DRIVE_SCRIPT}}" upload --file /path/报告.xlsx --project "项目或应用"
74
+ ```
75
+
76
+ 输出 `{ok:true, file_token, url, path,...}`(链接已带 `?lang=zh-CN`)。用于 AI 生成的
77
+ xlsx / zip / 截图等**普通文件**;在线云文档(docx/sheet/base/slides)不要用它——
78
+ 它们走第二、三节的创建/存入流程,上传会多出一份重复副本。`--name` 可指定云盘里
79
+ 的文件名(默认用本地文件名)。
80
+
52
81
  ## 三:把已经存在的文档存进去
53
82
 
54
83
  ```bash
@@ -13,9 +13,9 @@
13
13
  // 飞书原始英文错误只进 stderr(日志侧),stdout 永远是中文。
14
14
  import { execFile } from 'node:child_process';
15
15
  import { readdirSync } from 'node:fs';
16
- import { readFile } from 'node:fs/promises';
16
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
17
17
  import { homedir } from 'node:os';
18
- import { delimiter, join } from 'node:path';
18
+ import { delimiter, dirname, join } from 'node:path';
19
19
 
20
20
  const DEFAULT_STORE_DIR = join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'agentone');
21
21
  const DEFAULT_CACHE = join(DEFAULT_STORE_DIR, 'drive.json');
@@ -26,11 +26,38 @@ const USAGE = `用法:
26
26
  node drive.mjs list [--project "项目或应用"] [--drive-cache <路径>]
27
27
  node drive.mjs resolve-folder --project "项目或应用" [--token-only] [--drive-cache <路径>]
28
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 "云盘里的文件名"]
29
32
 
30
33
  说明:AgentOne 云盘文件夹的查看与保存。list 只查看不创建;保存进
31
- 「<项目或应用>」子文件夹(自动创建)。
34
+ 「<项目或应用>」子文件夹(自动创建)。search 按关键词在全部项目文件夹里
35
+ 找文档(只读);changed 列出上次查看之后有更新的文档(手机上放进云盘的
36
+ 文件会出现在这里),查看后基准自动推进。upload 把本地文件(xlsx/zip/
37
+ 图片等普通文件)传进项目文件夹,云文档用 archive 而不是它。
32
38
  `;
33
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
+
34
61
  function parseArgs(argv) {
35
62
  const out = { _: [] };
36
63
  for (let i = 0; i < argv.length; i += 1) {
@@ -305,6 +332,133 @@ async function listCommand(args) {
305
332
  };
306
333
  }
307
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
+
308
462
  async function main() {
309
463
  const args = parseArgs(process.argv.slice(2));
310
464
  const command = args._[0];
@@ -315,7 +469,10 @@ async function main() {
315
469
  if (command === 'list') return listCommand(args);
316
470
  if (command === 'resolve-folder') return resolveFolderCommand(args);
317
471
  if (command === 'archive') return archiveCommand(args);
318
- throw new Error(`未知子命令:${command}(可用:list、resolve-folder、archive)`);
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)`);
319
476
  }
320
477
 
321
478
  main().then((result) => {