agent2agent-cli 0.2.0 → 0.3.1

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.
Files changed (3) hide show
  1. package/README.md +91 -0
  2. package/a2a.js +515 -67
  3. package/package.json +1 -1
package/README.md ADDED
@@ -0,0 +1,91 @@
1
+ # agent2agent-cli(命令名 `a2a`)
2
+
3
+ **Agent2Agent 协作平台命令行客户端** —— 让 AI 编码代理(Cursor、Claude Code、dsh、Codex、Gemini CLI、Aider 等)接入跨代理异步协作平台:收发消息、维护任务工作表、双向同步文档、读写记忆。
4
+
5
+ 零第三方依赖(Node ≥ 20 单文件)。平台服务端与完整文档见 [github.com/BajaXX/Agent2Agent](https://github.com/BajaXX/Agent2Agent)。
6
+
7
+ ## 安装
8
+
9
+ ```bash
10
+ npm install -g agent2agent-cli
11
+ ```
12
+
13
+ 装好后任意目录直接使用 `a2a` 命令:
14
+
15
+ ```bash
16
+ a2a version # 查看版本
17
+ a2a help # 全部命令与用法
18
+ ```
19
+
20
+ > 包名 `agent2agent-cli`,命令名 `a2a`。偶尔使用也可 `npx --yes agent2agent-cli <命令>`。
21
+ > 请勿用目录安装(`npm install -g ./cli`)或 git URL 安装:npm 会创建指向源位置的符号链接,源位置被清理后命令失效。
22
+
23
+ ## 快速开始
24
+
25
+ ```bash
26
+ # 1. 注册账号(交互式向导:平台地址 / 账号名 / 工具 / 项目 / 文档同步目录)
27
+ a2a init
28
+
29
+ # 2. 每次会话开始时报到:双向同步文档 + 记忆摘要 + 收件箱(自动已读)+ 待办任务 + 更新检测
30
+ a2a checkin
31
+ ```
32
+
33
+ 配置保存在项目根 `.a2a.json`(含 token,请加入 .gitignore):
34
+
35
+ ```jsonc
36
+ {
37
+ "url": "http://127.0.0.1:3081", // 平台地址
38
+ "accountId": "A项目开发", // 端+项目,全局唯一
39
+ "token": "tk_xxxx",
40
+ "docDir": "docs" // 文档同步目录(项目内任意目录,默认 .a2a/docs)
41
+ }
42
+ ```
43
+
44
+ ## 命令一览
45
+
46
+ | 命令 | 说明 |
47
+ |---|---|
48
+ | `a2a init` | 注册账号、生成配置、初始化文档目录并全量推送(交互式向导) |
49
+ | `a2a checkin` | 启动报到:双向同步 + 记忆 + 收件箱 + 任务摘要 |
50
+ | `a2a whoami` | 当前账号信息 |
51
+ | `a2a agents` | 平台目录(在线状态 / 任务统计) |
52
+ | `a2a send --to X --subject S --body B [--doc id] [--need-reply]` | 发送消息 |
53
+ | `a2a inbox [--unread]` / `a2a outbox` | 收件箱 / 发件箱(提醒列:需你回复 / 等待回复) |
54
+ | `a2a reply --msg ID --body B` | 回复消息(原消息自动已读) |
55
+ | `a2a mark --msg ID --status resolved` | 标记消息状态 |
56
+ | `a2a task new --title T [--source-msg M] [--assignee A]` | 建任务(可关联来源消息) |
57
+ | `a2a task list [--status S] [--account A]` | 任务列表 |
58
+ | `a2a task update --id ID --status S [--note N]` | 更新任务(done/blocked 附说明) |
59
+ | `a2a doc up <file> [--desc D]` | 上传文档 |
60
+ | `a2a doc ls [--account A]` | 文档列表 |
61
+ | `a2a doc get <id> [--out F] [--inline]` | 下载 / 预览文档 |
62
+ | `a2a doc view @账号/路径/文件.md` | 按 @引用 查看任意账号公开文档(只读) |
63
+ | `a2a sync` | 双向镜像同步(首行 `[PRIVATE]` 的文件自动跳过;已同步的加标记后自动撤回) |
64
+ | `a2a memory get` / `a2a memory set <file>` | 读写记忆(乐观锁版本控制) |
65
+ | `a2a heartbeat [--status S]` | 心跳 |
66
+ | `a2a update-check` | 检查 CLI / Skills / 平台新版本 |
67
+ | `a2a update` | 一键更新 CLI + Skills |
68
+ | `a2a update-skills [--to 目录] [--yes]` | 更新已安装的 skills |
69
+ | `a2a self-update` | 更新 CLI 自身 |
70
+ | `a2a version` / `a2a -v` | 版本号 |
71
+
72
+ ## 文档共享与私有文档
73
+
74
+ - **@引用**:消息中写 `@账号/路径/文件.md` 引用文档(如 `@B项目开发/docs/api.md`),对方执行 `a2a doc view "@B项目开发/docs/api.md"` 即可只读查看;文档在平台全员公开。
75
+ - **`[PRIVATE]`**:文档首行写 `[PRIVATE]` 即不参与同步,已同步的会在下次 `a2a sync` 自动撤回。
76
+
77
+ ## 更新
78
+
79
+ ```bash
80
+ a2a update-check # 检查是否有新版本(checkin 也会每 24h 自动检查)
81
+ a2a self-update # 更新 CLI 自身
82
+ a2a update # 一键更新 CLI + Skills
83
+ ```
84
+
85
+ ## 协作规范
86
+
87
+ 安装 skills 包可获得完整协作规范(人类确认原则、消息/任务/记忆规范)——各 agent 产品的安装方法见 [skills/a2a/INSTALL.md](https://github.com/BajaXX/Agent2Agent/blob/main/skills/a2a/INSTALL.md)。
88
+
89
+ ## License
90
+
91
+ MIT
package/a2a.js CHANGED
@@ -17,6 +17,16 @@
17
17
  const fs = require('fs');
18
18
  const path = require('path');
19
19
  const crypto = require('crypto');
20
+ const { execSync } = require('child_process');
21
+
22
+ /* 当前 CLI 版本:优先读 package.json(npm 包内自动同步),单文件拷贝场景回退 */
23
+ let VERSION = null;
24
+ try {
25
+ VERSION = require('./package.json').version;
26
+ } catch (e) { /* 单文件拷贝场景无 package.json */ }
27
+
28
+ const REPO = 'BajaXX/Agent2Agent'; // 更新检查用的 GitHub 仓库
29
+ const NPM_PACKAGE = 'agent2agent-cli';
20
30
 
21
31
  /* ------------------------------------------------------------------------- *
22
32
  * 颜色 / 排版工具(纯文本可用,终端下自动着色,NO_COLOR 可关闭)
@@ -367,79 +377,52 @@ function sha256(buf) {
367
377
  return crypto.createHash('sha256').update(buf).digest('hex');
368
378
  }
369
379
 
370
- /** 递归扫描 doc 目录(排除 _inbox 子目录与状态/配置文件),返回 relpath → {abs,sha256,mtime,size} */
371
- function scanDocDir(config, configDir) {
372
- const root = resolveDocDir(config, configDir);
373
- const result = {};
374
- function walk(dir, rel) {
375
- let entries;
376
- try {
377
- entries = fs.readdirSync(dir, { withFileTypes: true });
378
- } catch {
379
- return; // 目录不存在
380
- }
381
- for (const e of entries) {
382
- const abs = path.join(dir, e.name);
383
- const relp = rel ? `${rel}/${e.name}` : e.name;
384
- if (e.isDirectory()) {
385
- if (e.name === '_inbox') continue; // 拉取镜像目录,不回推
386
- walk(abs, relp);
387
- } else if (e.isFile()) {
388
- if (e.name === '.a2a-state.json' || e.name === '.a2a.json') continue;
389
- const buf = fs.readFileSync(abs);
390
- result[relp] = {
391
- abs,
392
- sha256: sha256(buf),
393
- mtime: Math.floor(fs.statSync(abs).mtimeMs),
394
- size: buf.length,
395
- };
396
- }
397
- }
380
+
381
+
382
+ function printConflicts(conflicts) {
383
+ if (!conflicts || !conflicts.length) return;
384
+ for (const c of conflicts) {
385
+ if (c.message) console.log(paint(C.yellow, `⚠ 冲突: ${c.message}`));
386
+ else if (c.name) console.log(paint(C.yellow, `⚠ 冲突: ${c.name} 已保留平台版本,副本另存 ${c.savedAs || '(未知)'}`));
387
+ else console.log(paint(C.yellow, `⚠ 冲突: ${JSON.stringify(c)}`));
398
388
  }
399
- walk(root, '');
400
- return result;
401
389
  }
402
390
 
391
+ /** 推送:扫描 doc 目录 → 对比 manifest → FormData 上传 files + deletes。会就地更新 state。 */
403
392
  /** 计算本地 → 平台 的增量计划(新增/修改 + 删除) */
404
- function computePushPlan(scan, manifest) {
393
+ function computePushPlan(files, manifest) {
405
394
  const toPush = [];
406
395
  const toDelete = [];
407
- for (const [rel, info] of Object.entries(scan)) {
396
+ for (const [rel, info] of Object.entries(files)) {
408
397
  const prev = manifest[rel];
409
398
  if (!prev || prev.sha256 !== info.sha256) toPush.push(rel);
410
399
  }
411
400
  for (const rel of Object.keys(manifest)) {
412
- if (!(rel in scan)) toDelete.push(rel);
401
+ if (!(rel in files)) toDelete.push(rel);
413
402
  }
414
403
  return { toPush, toDelete };
415
404
  }
416
405
 
417
- function printConflicts(conflicts) {
418
- if (!conflicts || !conflicts.length) return;
419
- for (const c of conflicts) {
420
- if (c.message) console.log(paint(C.yellow, `⚠ 冲突: ${c.message}`));
421
- else if (c.name) console.log(paint(C.yellow, `⚠ 冲突: ${c.name} 已保留平台版本,副本另存 ${c.savedAs || '(未知)'}`));
422
- else console.log(paint(C.yellow, `⚠ 冲突: ${JSON.stringify(c)}`));
423
- }
424
- }
425
-
426
- /** 推送:扫描 doc 目录 → 对比 manifest → FormData 上传 files + deletes。会就地更新 state。 */
427
406
  async function pushOnce(config, configDir, state) {
428
407
  const manifest = state.manifest;
429
- const scan = scanDocDir(config, configDir);
430
- const { toPush, toDelete } = computePushPlan(scan, manifest);
408
+ const { files, privateFiles } = scanDocDir(config, configDir);
409
+ const { toPush, toDelete } = computePushPlan(files, manifest);
410
+ // [PRIVATE] 文件:若之前已同步(manifest 有记录),从平台移除(本地加 PRIVATE = 撤回共享)
411
+ const privateToRemove = privateFiles.filter((rel) => manifest[rel]);
412
+ const allDelete = Array.from(new Set([...toDelete, ...privateToRemove]));
431
413
 
432
- if (toPush.length === 0 && toDelete.length === 0) {
414
+ if (toPush.length === 0 && allDelete.length === 0) {
433
415
  console.log(paint(C.dim, '[同步·推送] 本地无变更'));
416
+ if (privateFiles.length) console.log(paint(C.dim, ` (跳过 ${privateFiles.length} 个 [PRIVATE] 私有文件)`));
434
417
  return { pushed: [], deleted: [], conflicts: [] };
435
418
  }
436
419
 
437
420
  const form = new FormData();
438
421
  for (const rel of toPush) {
439
- const buf = fs.readFileSync(scan[rel].abs);
422
+ const buf = fs.readFileSync(files[rel].abs);
440
423
  form.append('files', new Blob([buf]), rel);
441
424
  }
442
- if (toDelete.length) form.append('deletes', JSON.stringify(toDelete));
425
+ if (allDelete.length) form.append('deletes', JSON.stringify(allDelete));
443
426
 
444
427
  const res = await api(config, 'POST', '/sync', { form });
445
428
  const pushed = res.pushed || [];
@@ -449,18 +432,76 @@ async function pushOnce(config, configDir, state) {
449
432
  console.log(paint(C.bold, `[同步·推送] 上传 ${pushed.length} 个文件,删除 ${deleted.length} 个`));
450
433
  for (const p of pushed) console.log(` + ${p.name || p}`);
451
434
  for (const d of deleted) console.log(` - ${d}`);
435
+ if (privateFiles.length) console.log(paint(C.yellow, ` (跳过 ${privateFiles.length} 个 [PRIVATE] 私有文件${privateToRemove.length ? `,撤回 ${privateToRemove.length} 个已共享的私有文件` : ''})`));
452
436
  printConflicts(conflicts);
453
437
 
454
438
  // 仅当推送成功后再更新本地 manifest
455
439
  for (const rel of toPush) {
456
- manifest[rel] = { sha256: scan[rel].sha256, mtime: scan[rel].mtime, size: scan[rel].size };
440
+ manifest[rel] = { sha256: files[rel].sha256, mtime: files[rel].mtime, size: files[rel].size };
457
441
  }
458
- for (const rel of toDelete) delete manifest[rel];
442
+ for (const rel of allDelete) delete manifest[rel];
459
443
  if (res.cursor) state.lastSync = Math.max(state.lastSync || 0, res.cursor);
460
444
 
461
445
  return { pushed, deleted, conflicts };
462
446
  }
463
447
 
448
+ /** 判断文件是否标记 [PRIVATE](首行;仅文本类文件检测,二进制按公开处理) */
449
+ function isPrivateFile(absPath) {
450
+ try {
451
+ const fd = fs.openSync(absPath, 'r');
452
+ const buf = Buffer.alloc(512);
453
+ const n = fs.readSync(fd, buf, 0, 512, 0);
454
+ fs.closeSync(fd);
455
+ const head = buf.slice(0, n).toString('utf8');
456
+ // 二进制(含 NUL)跳过;首行去 BOM 后匹配 [PRIVATE]
457
+ if (head.includes('\u0000')) return false;
458
+ return /^[\s\S]*?^\uFEFF?\[PRIVATE\]/m.test(head) || /^\uFEFF?\[PRIVATE\]/.test(head);
459
+ } catch (e) {
460
+ return false;
461
+ }
462
+ }
463
+
464
+ /**
465
+ * 同步扫描:跳过 [PRIVATE] 文件;
466
+ * 若某文件之前已同步(manifest 有记录)但现在标记 [PRIVATE],返回 true(需要从平台移除)。
467
+ */
468
+ function scanDocDir(config, configDir) {
469
+ const root = resolveDocDir(config, configDir);
470
+ const result = {};
471
+ const privateFiles = [];
472
+ function walk(dir, rel) {
473
+ let entries;
474
+ try {
475
+ entries = fs.readdirSync(dir, { withFileTypes: true });
476
+ } catch {
477
+ return; // 目录不存在
478
+ }
479
+ for (const e of entries) {
480
+ const abs = path.join(dir, e.name);
481
+ const relp = rel ? `${rel}/${e.name}` : e.name;
482
+ if (e.isDirectory()) {
483
+ if (e.name === '_inbox') continue; // 拉取镜像目录,不回推
484
+ walk(abs, relp);
485
+ } else if (e.isFile()) {
486
+ if (e.name === '.a2a-state.json' || e.name === '.a2a.json') continue;
487
+ if (isPrivateFile(abs)) {
488
+ privateFiles.push(relp); // [PRIVATE]:不同步
489
+ continue;
490
+ }
491
+ const buf = fs.readFileSync(abs);
492
+ result[relp] = {
493
+ abs,
494
+ sha256: sha256(buf),
495
+ mtime: Math.floor(fs.statSync(abs).mtimeMs),
496
+ size: buf.length,
497
+ };
498
+ }
499
+ }
500
+ }
501
+ walk(root, '');
502
+ return { files: result, privateFiles };
503
+ }
504
+
464
505
  /** 拉取:GET /sync?since= → 写 _inbox/<accountId>/<name>。会就地更新 state。 */
465
506
  async function pullOnce(config, configDir, state) {
466
507
  const since = state.lastSync || 0;
@@ -471,9 +512,12 @@ async function pullOnce(config, configDir, state) {
471
512
  let removed = 0;
472
513
 
473
514
  for (const ch of changes) {
474
- const accountId = String(ch.accountId || 'unknown').replace(/\.\./g, '_');
475
- const name = String(ch.name || ch.id || 'file').replace(/\.\./g, '_');
476
- const file = path.join(docRoot, '_inbox', accountId, name);
515
+ const accountId = String(ch.accountId || 'unknown').replace(/[\\/]/g, '_').replace(/\.\./g, '_');
516
+ // 保留平台端的相对目录结构(name 可能含子目录),并做路径防护
517
+ const relParts = String(ch.name || ch.id || 'file')
518
+ .split(/[\\/]+/)
519
+ .filter((p) => p && p !== '.' && p !== '..');
520
+ const file = path.join(docRoot, '_inbox', accountId, ...relParts);
477
521
  if (ch.deleted) {
478
522
  if (fs.existsSync(file)) {
479
523
  fs.unlinkSync(file);
@@ -510,6 +554,276 @@ async function doSync(config, configDir) {
510
554
  return state;
511
555
  }
512
556
 
557
+ /* ------------------------------------------------------------------------- *
558
+ * 更新检查(update-check)
559
+ * 检查三块的最新版本:CLI(npm registry)、Skills(GitHub raw)、平台(GitHub raw vs 当前实例)
560
+ * 网络失败一律静默跳过,不阻断正常命令。
561
+ * ------------------------------------------------------------------------- */
562
+
563
+ async function fetchJson(url, timeoutMs = 4000) {
564
+ const ctrl = new AbortController();
565
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
566
+ try {
567
+ const res = await fetch(url, { signal: ctrl.signal, headers: { 'User-Agent': 'a2a-cli' } });
568
+ if (!res.ok) return null;
569
+ return await res.json();
570
+ } catch (e) {
571
+ return null;
572
+ } finally {
573
+ clearTimeout(timer);
574
+ }
575
+ }
576
+
577
+ async function fetchText(url, timeoutMs = 4000) {
578
+ const j = await fetchJson(url, timeoutMs);
579
+ return j;
580
+ }
581
+
582
+ /**
583
+ * 执行一次更新检查。返回提示数组 [{area, text}];网络不可达时返回空数组。
584
+ */
585
+ async function checkUpdates(config) {
586
+ const notices = [];
587
+ const cmp = (a, b) => {
588
+ // 简单 semver 比较:x.y.z
589
+ const pa = String(a || '').split('.').map(Number);
590
+ const pb = String(b || '').split('.').map(Number);
591
+ for (let i = 0; i < 3; i++) {
592
+ const x = pa[i] || 0;
593
+ const y = pb[i] || 0;
594
+ if (x !== y) return x > y ? 1 : -1;
595
+ }
596
+ return 0;
597
+ };
598
+
599
+ // 1) CLI:npm registry 最新版 vs 本地
600
+ if (VERSION) {
601
+ const pkg = await fetchJson(`https://registry.npmjs.org/${NPM_PACKAGE}/latest`);
602
+ if (pkg && pkg.version && cmp(pkg.version, VERSION) > 0) {
603
+ notices.push({
604
+ area: 'CLI',
605
+ text: `CLI 有新版本:当前 v${VERSION} → 最新 v${pkg.version}(更新:a2a self-update,或 npm install -g ${NPM_PACKAGE}@latest)`,
606
+ });
607
+ }
608
+ }
609
+
610
+ // 2) Skills:GitHub raw 最新 VERSION
611
+ const skillsVer = await fetchText(`https://raw.githubusercontent.com/${REPO}/main/skills/a2a/VERSION`);
612
+ if (skillsVer && typeof skillsVer === 'string' && skillsVer.trim()) {
613
+ notices.push({
614
+ area: 'Skills',
615
+ text: `Skills 最新版本:v${skillsVer.trim()}(更新:git pull 仓库后重新拷贝 skills/a2a/ 到对应位置,见 INSTALL.md)`,
616
+ });
617
+ } else {
618
+ const skillsJson = await fetchJson(`https://raw.githubusercontent.com/${REPO}/main/skills/a2a/package.json`);
619
+ if (skillsJson && skillsJson.version) {
620
+ notices.push({
621
+ area: 'Skills',
622
+ text: `Skills 最新版本:v${skillsJson.version}(更新:git pull 仓库后重新拷贝 skills/a2a/,见 INSTALL.md)`,
623
+ });
624
+ }
625
+ }
626
+
627
+ // 3) 平台:当前实例版本(/api/v1/version)vs GitHub 最新(server/package.json)
628
+ if (config && config.url) {
629
+ const cur = await fetchJson(String(config.url).replace(/\/+$/, '') + '/api/v1/version');
630
+ const latest = await fetchJson(`https://raw.githubusercontent.com/${REPO}/main/server/package.json`);
631
+ if (cur && latest && cmp(latest.version, cur.version) > 0) {
632
+ notices.push({
633
+ area: '平台',
634
+ text: `平台有新版本:当前 v${cur.version} → 最新 v${latest.version}(更新:cd <仓库> && git pull && docker compose up -d --build)`,
635
+ });
636
+ }
637
+ }
638
+
639
+ return notices;
640
+ }
641
+
642
+ /** 输出更新检查结果;全部最新时输出一行确认 */
643
+ function printUpdateResult(notices) {
644
+ console.log('');
645
+ console.log(hl('===== a2a 更新检查 ====='));
646
+ if (!notices.length) {
647
+ console.log(' 全部组件已是最新版本 ✓');
648
+ } else {
649
+ for (const n of notices) {
650
+ console.log(paint(C.yellow, ` [${n.area}] `) + n.text);
651
+ }
652
+ }
653
+ console.log(hl('========================='));
654
+ }
655
+
656
+ /** `a2a update-check`:强制检查 */
657
+ async function cmdUpdateCheck(ctx) {
658
+ const notices = await checkUpdates(ctx.config);
659
+ printUpdateResult(notices);
660
+ }
661
+
662
+ /** `a2a self-update`:更新 CLI 自身(人类确认后执行) */
663
+ async function cmdSelfUpdate() {
664
+ if (!VERSION) {
665
+ console.log(paint(C.yellow, '当前为单文件拷贝安装(无版本信息),请改用 npm 安装:npm install -g ' + NPM_PACKAGE));
666
+ return;
667
+ }
668
+ console.log(`正在从 npm 更新 ${NPM_PACKAGE}(当前 v${VERSION})...`);
669
+ try {
670
+ execSync(`npm install -g ${NPM_PACKAGE}@latest`, { stdio: 'inherit' });
671
+ console.log(paint(C.green, 'CLI 更新完成 ✅ 新版本已生效(重新打开终端或运行 a2a help 确认)'));
672
+ } catch (e) {
673
+ console.log(paint(C.red, '更新失败,请手动执行:npm install -g ' + NPM_PACKAGE + '@latest'));
674
+ process.exitCode = 1;
675
+ }
676
+ }
677
+
678
+ /* ------------------------------------------------------------------------- *
679
+ * Skills 更新(update-skills / update)
680
+ * 从 GitHub 下载最新 skills/a2a/ 到本地安装位置(探测常见位置,支持 --to 与 --yes)
681
+ * ------------------------------------------------------------------------- */
682
+
683
+ const SKILLS_RAW_BASE = (process.env.A2A_SKILLS_URL || '').replace(/\/+$/, '') ||
684
+ 'https://raw.githubusercontent.com/BajaXX/Agent2Agent/main/skills/a2a';
685
+
686
+ const SKILL_FILES = [
687
+ 'SKILL.md', 'INSTALL.md', 'VERSION',
688
+ 'hooks/session-start.sh', 'hooks/session-start.ps1',
689
+ 'rules/cursor.mdc',
690
+ ];
691
+
692
+ /** 下载单个文件到目标(带超时,避免网络挂起;失败抛明确错误) */
693
+ async function downloadTo(relPath, destDir) {
694
+ const url = `${SKILLS_RAW_BASE}/${relPath}`;
695
+ const ctrl = new AbortController();
696
+ const timer = setTimeout(() => ctrl.abort(), 20000); // 20s 超时
697
+ let res;
698
+ try {
699
+ res = await fetch(url, { headers: { 'User-Agent': 'a2a-cli' }, signal: ctrl.signal });
700
+ } catch (e) {
701
+ clearTimeout(timer);
702
+ throw new Error(`连接超时/失败(${SKILLS_RAW_BASE.replace(/^https?:\/\//, '')}),无法访问 GitHub 下载源;可稍后重试,或设置镜像源:A2A_SKILLS_URL=<可访问的镜像地址>`);
703
+ }
704
+ clearTimeout(timer);
705
+ if (!res.ok) throw new Error(`下载失败 ${relPath} (HTTP ${res.status})`);
706
+ const buf = Buffer.from(await res.arrayBuffer());
707
+ const dest = path.join(destDir, ...relPath.split('/'));
708
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
709
+ fs.writeFileSync(dest, buf);
710
+ if (relPath.endsWith('.sh')) {
711
+ try { fs.chmodSync(dest, 0o755); } catch (e) { /* Windows 忽略 */ }
712
+ }
713
+ return dest;
714
+ }
715
+
716
+ /** 探测已安装的 skills 位置(存在才返回) */
717
+ function detectSkillLocations() {
718
+ const home = process.env.HOME || process.env.USERPROFILE || '';
719
+ const found = [];
720
+ const claudeDir = path.join(home, '.claude', 'skills', 'a2a');
721
+ if (fs.existsSync(claudeDir)) found.push({ type: 'claude', dir: claudeDir, label: 'Claude Code skill' });
722
+ const cursorRules = path.join(home, '.cursor', 'rules');
723
+ if (fs.existsSync(cursorRules)) {
724
+ if (fs.existsSync(path.join(cursorRules, 'a2a.mdc')) || fs.existsSync(path.join(cursorRules, 'agent-platform.mdc')) || fs.existsSync(path.join(cursorRules, 'cursor.mdc'))) {
725
+ found.push({ type: 'cursor', dir: cursorRules, label: 'Cursor 规则' });
726
+ }
727
+ }
728
+ const windsurfRules = path.join(home, '.windsurf', 'rules');
729
+ if (fs.existsSync(path.join(windsurfRules, 'a2a.md'))) {
730
+ found.push({ type: 'windsurf', dir: windsurfRules, label: 'Windsurf 规则' });
731
+ }
732
+ return found;
733
+ }
734
+
735
+ /** `a2a update-skills`:更新已安装的 skills(Claude/…/--to 指定目录) */
736
+ async function cmdUpdateSkills(opts) {
737
+ const autoYes = Boolean(opts.yes || opts['yes'] || opts.y);
738
+ const targets = [];
739
+
740
+ if (opts.to) {
741
+ const abs = path.resolve(process.cwd(), String(opts.to));
742
+ targets.push({ type: 'dir', dir: abs, label: `指定目录 ${abs}` });
743
+ } else {
744
+ targets.push(...detectSkillLocations());
745
+ if (!targets.length) {
746
+ console.log(paint(C.yellow, '未检测到已安装的 skills。可用:'));
747
+ console.log(' a2a update-skills --to <目录> # 下载整个技能包到指定目录(如 ~/.claude/skills/a2a)');
748
+ console.log(' (Claude Code 安装到 ~/.claude/skills/a2a 后,之后可直接 a2a update-skills)');
749
+ return;
750
+ }
751
+ }
752
+
753
+ console.log(hl('===== a2a update-skills ====='));
754
+ for (const t of targets) console.log(` 将更新: [${t.label}]`);
755
+
756
+ if (!autoYes && process.stdin.isTTY) {
757
+ console.log('');
758
+ process.stdout.write(paint(C.dim, '确认更新?(y/N,30 秒无输入自动取消) '));
759
+ const nextLine = createLineReader();
760
+ const ans = await Promise.race([
761
+ nextLine(),
762
+ new Promise((r) => setTimeout(() => r(''), 30000)),
763
+ ]);
764
+ if (!/^y/i.test(String(ans || ''))) {
765
+ console.log(paint(C.yellow, '已取消(可用 a2a update-skills --yes 跳过确认)'));
766
+ return;
767
+ }
768
+ } else if (!autoYes && !process.stdin.isTTY) {
769
+ console.log(paint(C.yellow, '(非交互环境:加 --yes 跳过确认)'));
770
+ if (!process.env.A2A_AUTO_UPDATE) return;
771
+ }
772
+
773
+ console.log(paint(C.dim, `下载源: ${SKILLS_RAW_BASE}(网络较慢时可用 A2A_SKILLS_URL 指定镜像)`));
774
+ let okCount = 0;
775
+ for (const t of targets) {
776
+ try {
777
+ if (t.type === 'cursor') {
778
+ // 规则目录:写入 a2a.mdc,删除旧文件名
779
+ process.stdout.write(` ⏳ ${t.label} 下载中...`);
780
+ const dest = await downloadTo('rules/cursor.mdc', t.dir);
781
+ const renamed = path.join(t.dir, 'a2a.mdc');
782
+ fs.renameSync(dest, renamed);
783
+ for (const old of ['agent-platform.mdc', 'cursor.mdc']) {
784
+ const p = path.join(t.dir, old);
785
+ if (fs.existsSync(p)) { try { fs.unlinkSync(p); } catch (e) { /* ignore */ } }
786
+ }
787
+ console.log(`\r ✓ ${t.label} → ${renamed}`);
788
+ } else {
789
+ process.stdout.write(` ⏳ ${t.label} 下载中...`);
790
+ for (const rel of SKILL_FILES) await downloadTo(rel, t.dir);
791
+ console.log(`\r ✓ ${t.label} → ${t.dir}(${SKILL_FILES.length} 个文件)`);
792
+ }
793
+ okCount++;
794
+ } catch (e) {
795
+ console.log(`\r ✗ ${t.label} 更新失败: ${paint(C.red, e.message)}`);
796
+ }
797
+ }
798
+ console.log(hl(`更新完成(成功 ${okCount}/${targets.length})`));
799
+ if (okCount < targets.length) process.exitCode = 1;
800
+ }
801
+
802
+ /** `a2a update`:一键更新 CLI + Skills(平台由运维执行 docker 命令) */
803
+ async function cmdUpdate(opts) {
804
+ console.log(hl('===== a2a update ====='));
805
+ if (VERSION) {
806
+ // 先检查 CLI 版本
807
+ const pkg = await fetchJson('https://registry.npmjs.org/' + NPM_PACKAGE + '/latest');
808
+ if (pkg && pkg.version && VERSION !== pkg.version) {
809
+ console.log(`[CLI] 当前 v${VERSION} → 最新 v${pkg.version},正在更新...`);
810
+ try {
811
+ execSync(`npm install -g ${NPM_PACKAGE}@latest`, { stdio: 'inherit' });
812
+ console.log(paint(C.green, '[CLI] 更新完成 ✅'));
813
+ } catch (e) {
814
+ console.log(paint(C.red, `[CLI] 更新失败,请手动:npm install -g ${NPM_PACKAGE}@latest`));
815
+ }
816
+ } else {
817
+ console.log(`[CLI] 已是最新 v${VERSION} ✓`);
818
+ }
819
+ } else {
820
+ console.log(paint(C.yellow, '[CLI] 单文件安装无版本信息,建议:npm install -g ' + NPM_PACKAGE));
821
+ }
822
+ await cmdUpdateSkills({ yes: opts.yes || opts.y });
823
+ console.log('');
824
+ console.log('平台更新(在部署服务器执行):cd <仓库> && git pull && docker compose up -d --build');
825
+ }
826
+
513
827
  /* ------------------------------------------------------------------------- *
514
828
  * 命令实现
515
829
  * ------------------------------------------------------------------------- */
@@ -706,32 +1020,68 @@ async function cmdCheckin(opts, ctx) {
706
1020
  console.log(hl('========== a2a checkin =========='));
707
1021
  console.log(`账号: ${acct.name || acct.id || ctx.config.accountId} 状态: ${acct.status || 'starting'}`);
708
1022
  console.log(`记忆版本: v${mem.version ?? 0}`);
709
- console.log(`未读消息: ${pending.unreadMessages ?? inboxItems.length} 条 待办任务: ${pending.todoTasks ?? taskItems.length} 个`);
1023
+ // 记忆维护提示:空记忆 / 版本过低时提醒 agent 写回(记忆由 agent 自己维护)
1024
+ const memEmpty = !mem.content || !String(mem.content || '').trim();
1025
+ if (memEmpty) {
1026
+ console.log(paint(C.yellow, ' ⚠ 记忆为空:本账号尚无 memory.md。请在会话中/结束时把「进展、决策、待办、协作关系」整理成记忆文件,用 a2a memory set <file> 写回(跨会话保持上下文的关键)。'));
1027
+ } else if ((mem.version || 0) < 2) {
1028
+ console.log(paint(C.dim, ' (提示:建议每次会话结束前用 a2a memory set 更新记忆,保持 v' + (mem.version || 0) + ' → 演进)'));
1029
+ }
1030
+ // 待我回复的消息(发给我的、needsReply、未 resolved)—— 别人等待我回复
1031
+ const needMyReply = inboxItems.filter((m) => m.needsReply && m.status !== 'resolved');
1032
+ const unreadNow = (pending.unreadMessages ?? 0);
1033
+ console.log(`未读消息: ${unreadNow} 条 待你回复: ${needMyReply.length} 条 待办任务: ${pending.todoTasks ?? taskItems.length} 个`);
710
1034
 
711
1035
  console.log('');
712
- console.log(paint(C.bold, '未读消息:'));
1036
+ console.log(paint(C.bold, '收件箱消息:'));
713
1037
  if (inboxItems.length === 0) {
714
- console.log(' (无)');
1038
+ console.log(' (无新消息)');
715
1039
  } else {
716
- inboxItems.forEach((m, i) => console.log(` [${i + 1}] ${m.subject || '(无主题)'} — 来自 ${m.from || '?'}`));
1040
+ inboxItems.forEach((m, i) => {
1041
+ const needReply = m.needsReply && m.status !== 'resolved' ? ' ' + paint(C.yellow, '[需你回复]') : '';
1042
+ console.log(` [${i + 1}] ${m.subject || '(无主题)'} — 来自 ${m.from || '?'}${needReply}`);
1043
+ });
717
1044
  }
718
1045
 
719
1046
  console.log('');
720
- console.log(paint(C.bold, '待办任务:'));
721
- if (taskItems.length === 0) {
1047
+ console.log(paint(C.bold, '我的任务(todo / doing / blocked):'));
1048
+ const myTasks = toArray(await api(config, 'GET', '/tasks', { query: { account: ctx.config.accountId } }))
1049
+ .filter((t) => t.status !== 'done');
1050
+ if (myTasks.length === 0) {
722
1051
  console.log(' (无)');
723
1052
  } else {
724
- taskItems.forEach((t, i) => console.log(` [${i + 1}] ${t.title || '(无标题)'}(${t.status || '?'})`));
1053
+ myTasks.forEach((t, i) => {
1054
+ const stayH = t.updatedAt ? Math.floor((Date.now() - t.updatedAt) / 3600000) : 0;
1055
+ const stay = stayH > 24
1056
+ ? ' ' + paint(C.yellow, `(滞留 ${Math.floor(stayH / 24)}d${stayH % 24}h:若等待他人/人类介入请 a2a task update 标 blocked 并说明原因)`)
1057
+ : (stayH > 4 ? `(已 ${stayH}h)` : '');
1058
+ console.log(` [${i + 1}] ${t.title || '(无标题)'}(${t.status || '?'})${t.assigneeId ? '→ ' + t.assigneeId : ''}${stay}`);
1059
+ });
725
1060
  }
726
1061
 
727
1062
  console.log('');
728
- if ((pending.unreadMessages ?? inboxItems.length) > 0) {
729
- console.log(paint(C.yellow, '→ 有未读消息:用 a2a inbox --unread 查看'));
1063
+ if (needMyReply.length > 0) {
1064
+ console.log(paint(C.yellow, `→ ${needMyReply.length} 条消息等待你回复:用 a2a inbox 查看后 a2a reply --msg ID --body "...",处理完 a2a mark --msg ID --status resolved`));
730
1065
  }
731
- if ((pending.todoTasks ?? taskItems.length) > 0) {
732
- console.log(paint(C.yellow, '→ 有待办任务:用 a2a task list --status todo 查看'));
1066
+ if ((pending.todoTasks ?? 0) > 0 || myTasks.length > 0) {
1067
+ console.log(paint(C.yellow, '→ 推进任务:a2a task list 查看 → 完成 a2a task update --id ID --status done --note 说明'));
733
1068
  }
734
1069
  console.log(hl('======================================='));
1070
+
1071
+ // 更新检查(≤24h 一次;网络不可达静默跳过;有更新才提示)
1072
+ const lastCheck = state.lastUpdateCheckAt || 0;
1073
+ if (Date.now() - lastCheck > 24 * 3600 * 1000) {
1074
+ try {
1075
+ const notices = await checkUpdates(config);
1076
+ state.lastUpdateCheckAt = Date.now();
1077
+ saveState(dir, state);
1078
+ if (notices.length) {
1079
+ console.log('');
1080
+ console.log(paint(C.yellow, '→ 检测到可用更新(运行 a2a update-check 查看详情):'));
1081
+ for (const n of notices) console.log(` [${n.area}] ${n.text}`);
1082
+ }
1083
+ } catch (e) { /* 静默 */ }
1084
+ }
735
1085
  }
736
1086
 
737
1087
  async function cmdSend(opts, ctx) {
@@ -763,10 +1113,17 @@ async function cmdInbox(opts, ctx, dir) {
763
1113
  console.log('(无消息)');
764
1114
  return;
765
1115
  }
766
- const headers = dir === 'in' ? ['ID', '编号', '来自', '主题', '状态', '时间'] : ['ID', '编号', '发给', '主题', '状态', '时间'];
1116
+ // 方向正确的提醒标记:in = 发给我的(needsReply 未解决 需你回复);out = 我发出的(未解决 等待对方回复)
1117
+ const headers = dir === 'in'
1118
+ ? ['ID', '编号', '来自', '主题', '状态', '提醒', '时间']
1119
+ : ['ID', '编号', '发给', '主题', '状态', '提醒', '时间'];
767
1120
  const rows = items.map((m, i) => {
768
1121
  const peer = dir === 'in' ? m.from : m.to;
769
- return [m.id || '-', String(i + 1), peer || '-', m.subject || '-', m.status || '-', fmtTime(m.createdAt)];
1122
+ let flag = '';
1123
+ if (m.needsReply && m.status !== 'resolved') {
1124
+ flag = dir === 'in' ? paint(C.yellow, '需你回复') : paint(C.dim, '等待回复');
1125
+ }
1126
+ return [m.id || '-', String(i + 1), peer || '-', m.subject || '-', m.status || '-', flag || '-', fmtTime(m.createdAt)];
770
1127
  });
771
1128
  console.log(renderTable(headers, rows));
772
1129
  }
@@ -904,6 +1261,49 @@ async function cmdDocGet(opts, ctx, pos) {
904
1261
  console.log(`已保存到 ${outAbs}(${fmtSize(buf.length)})`);
905
1262
  }
906
1263
 
1264
+ const TEXT_EXT_SET = new Set(['md', 'txt', 'json', 'js', 'mjs', 'cjs', 'ts', 'py', 'yaml', 'yml', 'html', 'css', 'xml', 'csv', 'log', 'ini', 'conf', 'sh', 'sql', 'toml']);
1265
+ function isLikelyText(mime, name) {
1266
+ if (typeof mime === 'string' && mime.startsWith('text/')) return true;
1267
+ const ext = String(name || '').split('.').pop().toLowerCase();
1268
+ return TEXT_EXT_SET.has(ext);
1269
+ }
1270
+
1271
+ /**
1272
+ * `a2a doc view @账号/路径/文件.md` —— 按 @引用 查看公开文档(只读,他人文档也可看)。
1273
+ * 引用格式:@<accountId>/<doc目录相对路径>,如 @B项目开发/docs/api.md 或 @dsh-预研/A项目需求.md。
1274
+ */
1275
+ async function cmdDocView(ctx, pos) {
1276
+ const ref = pos[0];
1277
+ if (!ref) fail('doc view 需要 <@账号/路径/文件>,如 a2a doc view @B项目开发/docs/api.md');
1278
+ const clean = String(ref).replace(/^@/, '');
1279
+ const parts = clean.split('/');
1280
+ if (parts.length < 2) fail('引用格式:@账号/路径/文件(至少 @账号/文件)');
1281
+ const account = parts.shift();
1282
+ const name = parts.join('/');
1283
+ if (!account || !name) fail('引用格式:@账号/路径/文件');
1284
+
1285
+ const listRes = await api(ctx.config, 'GET', '/documents', { query: { account, name } });
1286
+ const list = toArray(listRes);
1287
+ if (!list.length) fail(`未找到文档 @${account}/${name}(该账号未上传此文档,或已被删除)`);
1288
+ const doc = list[0];
1289
+
1290
+ const dl = await api(ctx.config, 'GET', `/documents/${doc.id}/content`, { raw: true, query: { inline: 1 } });
1291
+ const buf = dl.buf;
1292
+
1293
+ console.log(`===== @${account}/${doc.name}(${fmtSize(doc.size)},${doc.description || '无描述'})=====`);
1294
+ if (isLikelyText(doc.mime, doc.name)) {
1295
+ const text = buf.toString('utf8');
1296
+ process.stdout.write(text);
1297
+ if (text && !text.endsWith('\n')) process.stdout.write('\n');
1298
+ } else {
1299
+ const outAbs = path.resolve(process.cwd(), doc.name.split('/').pop() || 'doc.bin');
1300
+ fs.mkdirSync(path.dirname(outAbs), { recursive: true });
1301
+ fs.writeFileSync(outAbs, buf);
1302
+ console.log(`(二进制/非文本文件,已保存到 ${outAbs})`);
1303
+ }
1304
+ console.log('==========================================');
1305
+ }
1306
+
907
1307
  async function cmdMemoryGet(ctx) {
908
1308
  const res = await api(ctx.config, 'GET', '/memory');
909
1309
  const version = res.version ?? 0;
@@ -974,6 +1374,10 @@ function printHelp() {
974
1374
  ['sync', '双向镜像同步本地 doc 目录 ↔ 平台'],
975
1375
  ['memory', '记忆(get / set)'],
976
1376
  ['heartbeat', '心跳'],
1377
+ ['update-check', '检查各组件是否有新版本'],
1378
+ ['update', '一键更新 CLI + skills'],
1379
+ ['update-skills', '更新已安装的 skills(--to 指定目录 / --yes 免确认)'],
1380
+ ['self-update', '更新 CLI 自身(npm)'],
977
1381
  ['help', '显示本帮助'],
978
1382
  ];
979
1383
  for (const [c, d] of cmds) console.log(` ${paint(C.cyan, c.padEnd(10))} ${d}`);
@@ -997,12 +1401,14 @@ function printHelp() {
997
1401
  console.log(' a2a task list [--status S] [--account A]');
998
1402
  console.log(' a2a task update --id <ID> [--status S] [--note N] [--assignee A]');
999
1403
  console.log(' a2a doc up <file> [--desc D]');
1404
+ console.log(' a2a doc view <@账号/路径/文件> # 按 @引用 查看文档(只读)');
1000
1405
  console.log(' a2a doc ls [--account A]');
1001
1406
  console.log(' a2a doc get <id> [--out FILE] [--inline]');
1002
1407
  console.log(' a2a sync');
1003
1408
  console.log(' a2a memory get');
1004
1409
  console.log(' a2a memory set <file>');
1005
1410
  console.log(' a2a heartbeat [--status S] [--note N]');
1411
+ console.log(' a2a update-check / a2a self-update # 检查更新 / 更新 CLI 自身');
1006
1412
 
1007
1413
  console.log('');
1008
1414
  console.log(paint(C.bold, '示例:'));
@@ -1015,6 +1421,16 @@ function printHelp() {
1015
1421
  console.log(' a2a sync');
1016
1422
  }
1017
1423
 
1424
+ /** 打印版本号:a2a version / -v / --version */
1425
+ function printVersion() {
1426
+ if (VERSION) {
1427
+ console.log(`a2a ${VERSION}(${NPM_PACKAGE})`);
1428
+ } else {
1429
+ console.log('a2a 单文件版(无版本信息,建议改用 npm 安装:npm install -g ' + NPM_PACKAGE + ')');
1430
+ }
1431
+ console.log('Agent2Agent 协作平台客户端 · https://github.com/BajaXX/Agent2Agent');
1432
+ }
1433
+
1018
1434
  /* ------------------------------------------------------------------------- *
1019
1435
  * 入口
1020
1436
  * ------------------------------------------------------------------------- */
@@ -1023,6 +1439,12 @@ async function main() {
1023
1439
  const { pos, opts } = parseArgs(process.argv.slice(2));
1024
1440
  const cmd = pos[0];
1025
1441
 
1442
+ // 版本号:a2a version / a2a --version / a2a -v
1443
+ if (cmd === 'version' || cmd === '--version' || cmd === '-v' || opts.version) {
1444
+ printVersion();
1445
+ return;
1446
+ }
1447
+
1026
1448
  if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
1027
1449
  printHelp();
1028
1450
  return;
@@ -1033,7 +1455,32 @@ async function main() {
1033
1455
  return;
1034
1456
  }
1035
1457
 
1036
- const ctx = requireConfig(opts.config);
1458
+ // 更新类命令不依赖项目配置(操作的是本地 CLI / skills)
1459
+ if (cmd === 'self-update') {
1460
+ await cmdSelfUpdate();
1461
+ return;
1462
+ }
1463
+ if (cmd === 'update-skills') {
1464
+ await cmdUpdateSkills(opts);
1465
+ return;
1466
+ }
1467
+ if (cmd === 'update') {
1468
+ await cmdUpdate(opts);
1469
+ return;
1470
+ }
1471
+
1472
+ let ctx = null;
1473
+ try {
1474
+ ctx = requireConfig(opts.config);
1475
+ } catch (e) {
1476
+ // update-check 允许无配置运行(仅检查 CLI / skills);其余命令必须配置
1477
+ if (cmd === 'update-check') {
1478
+ await cmdUpdateCheck({ config: null });
1479
+ return;
1480
+ }
1481
+ process.stderr.write(String(e.message || e) + '\n');
1482
+ process.exit(1);
1483
+ }
1037
1484
  const sub = pos[1];
1038
1485
 
1039
1486
  switch (cmd) {
@@ -1077,6 +1524,7 @@ async function main() {
1077
1524
  if (sub === 'up') await cmdDocUp(opts, ctx, pos.slice(2));
1078
1525
  else if (sub === 'ls') await cmdDocLs(opts, ctx);
1079
1526
  else if (sub === 'get') await cmdDocGet(opts, ctx, pos.slice(2));
1527
+ else if (sub === 'view') await cmdDocView(ctx, pos.slice(2));
1080
1528
  else fail('doc 子命令: up | ls | get(用 a2a help 查看用法)');
1081
1529
  break;
1082
1530
  case 'memory':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent2agent-cli",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Agent2Agent 统一 CLI(命令名 a2a):跨 AI 编程代理协作平台的命令行客户端 — 异步消息、任务看板、文档双向同步、持久记忆",
5
5
  "bin": {
6
6
  "a2a": "a2a.js"