dsh-agentone 1.0.2 → 1.0.4

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'。
@@ -545,8 +610,16 @@ window.__ModuleLoader__.load({
545
610
  // 那个窗口里取到 null 会静默跳过且永不重试(真机事故:侧边栏找不到云盘入口)。
546
611
  function registerFeishuDriveTab(ctx) {
547
612
  if (typeof ctx.inject !== 'function') return () => {};
613
+ // DSH NEXT 桌面壳(dsh-app://,secure 自定义协议)里跨源 https iframe
614
+ // 静默白屏(2026-10-13 真机:官方桌面端 http 宿主正常,DSH NEXT 不行,
615
+ // 宿主 will-attach-webview 又拒绝无 lease 的 webview)——嵌入展示在该
616
+ // 宿主不可用,整块隐藏:不注册页签、不监听消息桥。web/官方桌面端不受
617
+ // 影响;设置页「打开云盘」经消息桥超时自动退化为系统浏览器打开。
618
+ if (typeof window !== 'undefined' && window.location
619
+ && window.location.protocol === 'dsh-app:') return () => {};
548
620
  return ctx.inject(['betterSidebar'], (scope) => {
549
621
  const sidebar = scope.betterSidebar;
622
+ setDriveSidebarService(sidebar);
550
623
  if (!sidebar || typeof sidebar.registerTab !== 'function') return;
551
624
  const openDriveTab = () => {
552
625
  const sessionId = currentDshSessionId();
@@ -582,6 +655,11 @@ window.__ModuleLoader__.load({
582
655
  scope.effect(() => () => window.removeEventListener('message', onMessage),
583
656
  'dsh-agentone: feishu-drive open bridge');
584
657
  }
658
+ // 插件卸载时清掉共享引用:footer 按钮随后点击走设置页兜底,不持有悬空服务
659
+ if (typeof scope.effect === 'function') {
660
+ scope.effect(() => () => setDriveSidebarService(null),
661
+ 'dsh-agentone: feishu-drive service ref');
662
+ }
585
663
  });
586
664
  }
587
665
  // ==drive-tab-end==
@@ -638,8 +716,22 @@ window.__ModuleLoader__.load({
638
716
 
639
717
  function apply(ctx) {
640
718
  setupNavIcon();
641
- // 飞书云盘侧边栏 tab(better-sidebar 缺失时内部静默跳过)
719
+ // 飞书云盘侧边栏 tab(better-sidebar 缺失时内部静默跳过;DSH NEXT 桌面壳
720
+ // 同样跳过——页签与 footer 入口一起隐藏,见 registerFeishuDriveTab 注释)
642
721
  registerFeishuDriveTab(ctx);
722
+ const driveEntryHidden = typeof window !== 'undefined' && window.location
723
+ && window.location.protocol === 'dsh-app:';
724
+ if (!driveEntryHidden)
725
+ ctx.slots.inject('sidebar.footer.action', () =>
726
+ ctx.slots.register(
727
+ {
728
+ name: 'sidebar.footer.action',
729
+ id: 'agentone-drive-entry',
730
+ order: 10,
731
+ },
732
+ DriveFooterEntry,
733
+ ),
734
+ );
643
735
  ctx.slots.inject('settings.section', () =>
644
736
  ctx.slots.register(
645
737
  {
package/lib/page.js CHANGED
@@ -1203,7 +1203,13 @@ const renderDrive = async () => {
1203
1203
  if (seq !== renderDrive._seq) return;
1204
1204
  renderDrive._data = data;
1205
1205
  if (data.state === 'ready') {
1206
- statusEl.textContent = '已就绪:侧边栏「飞书云盘」页签会直接进入这个文件夹。';
1206
+ // DSH NEXT 桌面壳(srcdoc 加载)没有云盘页签可进——文案引导走「打开云盘」
1207
+ // 按钮(宿主消息桥超时后自动退化为系统浏览器打开)。
1208
+ const inDesktopShell = window.__AGENTONE_DESKTOP__ === true;
1209
+ statusEl.innerHTML = '<span class="pill ok">已就绪</span>'
1210
+ + (inDesktopShell
1211
+ ? '点「打开云盘」会在浏览器中进入这个文件夹。'
1212
+ : '侧边栏「飞书云盘」页签会直接进入这个文件夹。');
1207
1213
  if (data.url) {
1208
1214
  linkEl.textContent = data.name || 'AgentOne';
1209
1215
  linkEl.href = data.url;
@@ -1862,8 +1868,8 @@ export function statusPageHtml() {
1862
1868
  <span class="v"><a id="drive-folder-link" target="_blank" rel="noopener"></a></span>
1863
1869
  </div>
1864
1870
  <div class="row" style="margin-top:12px">
1865
- <button id="drive-open-btn" class="btn small">打开云盘</button>
1866
1871
  <button id="drive-resolve-btn" class="btn small primary" data-loading="定位中…">初始化 / 重新定位</button>
1872
+ <button id="drive-open-btn" class="btn small">打开云盘</button>
1867
1873
  </div>
1868
1874
  <div class="hint" id="drive-firstuse-hint">首次使用:云盘需要飞书授权。点「打开云盘」后在侧边栏页签里按提示点「去授权」,用手机飞书扫描弹窗里的二维码确认一次即可(之后不再需要)。</div>
1869
1875
  <div class="hint" id="drive-hint">初始化会在你的飞书「我的空间」根目录找到(没有则创建)名为「AgentOne」的文件夹;之后侧边栏云盘页签直接进入这个文件夹,你看到的飞书云盘界面里做的上传、整理都发生在这里。</div>
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.4",
4
+ "description": "AgentOne 平台集成:平台模型、SkillHub 技能、套餐、CLI、飞书云盘共享文件夹(web/官方桌面端侧栏页签 + 左栏底部入口;「启用云盘」后 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) => {