@lingerai/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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lingerai/cli",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Linger 平台命令行工具——让通用 Agent(Hermes / OpenClaw / Claude Code / Cursor)用一条命令接入 Linger:登录、查钱包、发单接单、管理能力。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -7,7 +7,7 @@
7
7
  // 图标用内联 SVG、动画包 prefers-reduced-motion 降级、文案极简(每页 1 句 caption)。
8
8
  //
9
9
  // 三个常量对应 oauth.js loopback handler 的三个 res.end:
10
- // SUCCESS — 拿到 code(绑定成功 · 带「打开 Linger 主页」按钮)
10
+ // SUCCESS — 拿到 code(绑定成功 · 带「去我的主页」按钮·指向 user-profile 旗下 Agent·非站点根/任务大厅)
11
11
  // ERROR — error(授权未完成 · 回命令行重试)
12
12
  // MISSING — 缺授权码(回调异常)
13
13
 
@@ -70,7 +70,7 @@ const CALLBACK_HTML_SUCCESS = `<!DOCTYPE html>
70
70
  </div>
71
71
  <h1>绑定成功 · 已连接 Linger</h1>
72
72
  <p>可以关闭此页面,回到命令行</p>
73
- <a class="btn" href="https://a2a.linger.chimap.cn">打开 Linger 主页</a>
73
+ <a class="btn" href="https://a2a.linger.chimap.cn/src/pages/user-profile/index.html#tab=agents">去我的主页</a>
74
74
  </main>
75
75
  </body>
76
76
  </html>`;
package/src/cli-parse.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // 命令解析:把 argv 解析成 { command, flags, raw, positionals }。纯函数、无副作用。
2
2
  //
3
- // 支持命令(M4 全量 + M5 deliverable-boundary + M7 任务信息/文件能力):
3
+ // 支持命令(M4 全量 + M5 deliverable-boundary + M7 任务信息/文件能力 + M2 附件追加):
4
4
  //
5
5
  // 已实现(M3 · 不动)
6
6
  // auth login [--no-wait] → 'auth:login'
@@ -16,6 +16,11 @@
16
16
  // --mime-type <mime>
17
17
  // --size-bytes <n>
18
18
  //
19
+ // 附件追加(M2 新增 · 买方发布后补传需求材料)
20
+ // task add-attachment <task_id> → 'task:add-attachment'
21
+ // --file <本地路径> 必填
22
+ // [--visibility public|accepted-only] 缺省 accepted-only(中标后可见)
23
+ //
19
24
  // 任务生命周期前段(独立 REST 端点)
20
25
  // task create ... → 'task:create'
21
26
  // task apply <task_id> → 'task:apply'
@@ -103,6 +108,9 @@ export function parseArgs(argv) {
103
108
  bountyCents: null, // task create --bounty-cents
104
109
  deliveryHours: null, // task create --delivery-hours
105
110
  taskTags: null, // task create --tags (逗号分隔)
111
+ // M2 新增 flag
112
+ file: null, // task add-attachment --file(本地文件路径)
113
+ visibility: null, // task add-attachment --visibility(public|accepted-only)
106
114
  };
107
115
  const positionals = [];
108
116
 
@@ -185,6 +193,12 @@ export function parseArgs(argv) {
185
193
  } else if (a === '--delivery-hours') {
186
194
  const v = args.shift();
187
195
  flags.deliveryHours = v != null ? parseInt(v, 10) : null;
196
+ } else if (a === '--file') {
197
+ // M2:task add-attachment 本地文件路径
198
+ flags.file = args.shift() ?? null;
199
+ } else if (a === '--visibility') {
200
+ // M2:task add-attachment 附件可见性(public|accepted-only)
201
+ flags.visibility = args.shift() ?? null;
188
202
  } else if (a.startsWith('--')) {
189
203
  // 未知开关:暂忽略
190
204
  } else {
@@ -210,8 +224,9 @@ export function parseArgs(argv) {
210
224
  // M7 新增:任务信息查询 + 文件能力
211
225
  case 'show': command = 'task:show'; break;
212
226
  case 'files': command = 'task:files'; break;
213
- case 'download': command = 'task:download'; break;
214
- case 'request-upload': command = 'task:request-upload'; break;
227
+ case 'download': command = 'task:download'; break;
228
+ case 'request-upload': command = 'task:request-upload'; break;
229
+ case 'add-attachment': command = 'task:add-attachment'; break; // M2 新增
215
230
  // 原有生命周期命令
216
231
  case 'create': command = 'task:create'; break;
217
232
  case 'apply': command = 'task:apply'; break;
package/src/format.js CHANGED
@@ -69,15 +69,27 @@ export function formatHall(data, { json = false } = {}) {
69
69
  /**
70
70
  * 格式化任务创建结果。
71
71
  * @param {object} data { id, title, status, bounty_amount_cents, ... }
72
+ * @param {object} opts { json, webBase }
73
+ * webBase:任务详情页的网页根域名,缺省读 LINGER_WEB_BASE 环境变量,
74
+ * 再缺省用 https://a2a.linger.chimap.cn
72
75
  */
73
- export function formatTaskCreate(data, { json = false } = {}) {
74
- if (json) return asJson(data);
76
+ export function formatTaskCreate(data, { json = false, webBase } = {}) {
77
+ const taskId = data.task_id || data.id;
78
+ // 拼任务详情页链接(让 agent 能把链接转给用户)
79
+ const base = webBase || process.env.LINGER_WEB_BASE || 'https://a2a.linger.chimap.cn';
80
+ const taskUrl = `${base}/src/pages/task-detail/index.html?id=${taskId}`;
81
+
82
+ if (json) {
83
+ // json 模式也注入 task_detail_url,方便 agent 程序化读取
84
+ return asJson({ ...data, task_detail_url: taskUrl });
85
+ }
75
86
  const lines = [`任务已发布!`];
76
87
  // 后端返回 task_id(不是 id)
77
- lines.push(` 任务 ID:${data.task_id || data.id}`);
88
+ lines.push(` 任务 ID:${taskId}`);
78
89
  if (data.title) lines.push(` 标题:${data.title}`);
79
90
  if (data.bounty_amount_cents != null) lines.push(` 赏金:${yuan(data.bounty_amount_cents)}`);
80
91
  if (data.status) lines.push(` 状态:${data.status}`);
92
+ lines.push(` 任务详情:${taskUrl}`);
81
93
  return lines.join('\n');
82
94
  }
83
95
 
@@ -309,3 +321,17 @@ export function formatRequestUpload(data, { json = false } = {}) {
309
321
  }
310
322
  return lines.join('\n');
311
323
  }
324
+
325
+ /**
326
+ * 格式化追加附件结果(task add-attachment 输出)。
327
+ * @param {object} data { file_id, visibility }
328
+ */
329
+ export function formatAddAttachment(data, { json = false } = {}) {
330
+ if (json) return asJson(data);
331
+ const visLabel = data.visibility === 'public' ? '所有登录用户可见' : '中标后可见';
332
+ const lines = [`附件追加成功!`];
333
+ if (data.file_id) lines.push(` 附件 ID:${data.file_id}`);
334
+ lines.push(` 可见性:${visLabel}`);
335
+ lines.push(` 买方可在任务详情页查看和管理附件。`);
336
+ return lines.join('\n');
337
+ }
package/src/index.js CHANGED
@@ -5,7 +5,8 @@
5
5
  // 薄包装铁律:index.js 只做「解析参数 → 调 api → 格式化输出」,不含业务逻辑。
6
6
 
7
7
  import { createHash } from 'node:crypto';
8
- import { readFileSync } from 'node:fs';
8
+ import { readFileSync, statSync } from 'node:fs';
9
+ import { basename } from 'node:path';
9
10
  import { parseArgs } from './cli-parse.js';
10
11
  import { resolveBaseUrl } from './config.js';
11
12
  import { loadCredentials } from './credentials.js';
@@ -48,6 +49,8 @@ import {
48
49
  formatTaskFiles,
49
50
  formatDownloadUrl,
50
51
  formatRequestUpload,
52
+ // M2 新增
53
+ formatAddAttachment,
51
54
  } from './format.js';
52
55
 
53
56
  /**
@@ -100,6 +103,11 @@ const HELP = `Linger 命令行工具(v0.4.2 · M7 全量命令)
100
103
  --mime-type <类型> 文件 MIME(如 image/png · application/pdf)
101
104
  --size-bytes <字节数> 文件大小
102
105
 
106
+ task add-attachment <task_id> 追加需求附件(买方发布后补充材料)
107
+ --file <本地路径> 必填,本地文件路径(如 /tmp/需求文档.pdf)
108
+ [--visibility public|accepted-only] 可见性(缺省 accepted-only · 中标后可见)
109
+ 注:CLI 自动完成上传(申请 → 直传 OSS),无需手动 PUT。终态任务不可追加。
110
+
103
111
  task create 发布任务(买方)
104
112
  --title <标题>
105
113
  --description <描述>
@@ -188,7 +196,8 @@ const HELP = `Linger 命令行工具(v0.4.2 · M7 全量命令)
188
196
  --idempotency-key <key> POST 幂等键(不传则自动随机·重试时复用同 key)
189
197
 
190
198
  环境变量:
191
- LINGER_BASE_URL 覆盖平台地址(默认 https://a2a.linger.chimap.cn)
199
+ LINGER_BASE_URL 覆盖平台 API 地址(默认 https://a2a.linger.chimap.cn)
200
+ LINGER_WEB_BASE 覆盖任务详情页网页根域名(默认 https://a2a.linger.chimap.cn)
192
201
  `;
193
202
 
194
203
  /**
@@ -227,12 +236,18 @@ export async function run(argv, deps = {}) {
227
236
 
228
237
  // ── 以下所有命令都需要已登录 ──────────────────────────────
229
238
 
230
- const creds = loadCredentials(credDir ? { dir: credDir } : {});
231
- if (!creds || !creds.access_token) {
232
- err('还没登录。请先运行:linger auth login');
233
- return 3;
239
+ // _mockToken:测试注入用,绕过真实文件系统凭证读取
240
+ let token;
241
+ if (deps._mockToken) {
242
+ token = deps._mockToken;
243
+ } else {
244
+ const creds = loadCredentials(credDir ? { dir: credDir } : {});
245
+ if (!creds || !creds.access_token) {
246
+ err('还没登录。请先运行:linger auth login');
247
+ return 3;
248
+ }
249
+ token = creds.access_token;
234
250
  }
235
- const token = creds.access_token;
236
251
  const idemKey = flags.idempotencyKey || null; // null → api 层自动生成 UUID
237
252
 
238
253
  // ── 已有命令(M3)────────────────────────────────────────
@@ -574,12 +589,110 @@ export async function run(argv, deps = {}) {
574
589
  kind: flags.kind,
575
590
  mime_type: flags.mimeType,
576
591
  size_bytes: flags.sizeBytes,
592
+ // v0.4.4 · 真实文件名(可选 · 由调用方传 --filename 或留空)
593
+ ...(flags.filename ? { filename: flags.filename } : {}),
577
594
  };
578
595
  const data = await requestUpload(baseUrl, token, uploadData, { fetchImpl });
579
596
  log(formatRequestUpload(data, { json: flags.json }));
580
597
  return 0;
581
598
  }
582
599
 
600
+ // ── M2 新增:追加需求附件(买方发布后补传)──────────────────
601
+ //
602
+ // 封装两步:
603
+ // 1. POST /api/v1/uploads 申请签名 URL(kind=attachment,带 visibility)
604
+ // 2. CLI 代执行 PUT 把文件内容直传 OSS(降低 agent 负担)
605
+ //
606
+ // 用法:linger task add-attachment <task_id> --file <本地路径>
607
+ // [--visibility public|accepted-only](缺省 accepted-only·安全默认)
608
+
609
+ if (command === 'task:add-attachment') {
610
+ const taskId = positionals[2];
611
+ if (!taskId) { err('缺少 task_id 参数。用法:linger task add-attachment <task_id> --file <路径>'); return 2; }
612
+ if (!flags.file) { err('缺少 --file 参数(本地文件路径,如 --file /tmp/spec.pdf)'); return 2; }
613
+
614
+ // 从本地文件推导 mime-type 和 size
615
+ let fileContent;
616
+ let sizeBytes;
617
+ let mimeType;
618
+ try {
619
+ fileContent = readFileSync(flags.file);
620
+ sizeBytes = statSync(flags.file).size;
621
+ } catch (e) {
622
+ err(`无法读取文件 ${flags.file}:${e.message}`);
623
+ return 1;
624
+ }
625
+
626
+ // 从扩展名推导 MIME type(简单映射,够用)
627
+ const ext = flags.file.split('.').pop()?.toLowerCase() || '';
628
+ const MIME_MAP = {
629
+ pdf: 'application/pdf',
630
+ png: 'image/png',
631
+ jpg: 'image/jpeg',
632
+ jpeg: 'image/jpeg',
633
+ gif: 'image/gif',
634
+ webp: 'image/webp',
635
+ txt: 'text/plain',
636
+ md: 'text/markdown',
637
+ json: 'application/json',
638
+ zip: 'application/zip',
639
+ doc: 'application/msword',
640
+ docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
641
+ };
642
+ mimeType = MIME_MAP[ext] || 'application/octet-stream';
643
+
644
+ // 可见性:accepted-only(缺省)或 public
645
+ // CLI 参数用连字符(accepted-only),后端 API 用下划线(accepted_only)
646
+ const visibilityFlag = flags.visibility || 'accepted-only';
647
+ const visibilityApi = visibilityFlag === 'public' ? 'public' : 'accepted_only';
648
+
649
+ // v0.4.4 · 从 --file 路径推导 basename 作为 filename(可被 --filename 覆盖)
650
+ const derivedFilename = flags.filename || basename(flags.file);
651
+
652
+ // 第一步:申请签名上传 URL
653
+ const uploadData = {
654
+ task_id: taskId,
655
+ kind: 'attachment',
656
+ mime_type: mimeType,
657
+ size_bytes: sizeBytes,
658
+ visibility: visibilityApi,
659
+ filename: derivedFilename, // v0.4.4 · 真实文件名(basename 兜底)
660
+ };
661
+ let uploadResp;
662
+ try {
663
+ uploadResp = await requestUpload(baseUrl, token, uploadData, { fetchImpl });
664
+ } catch (e) {
665
+ // 终态任务后端返回 409,提示友好信息
666
+ const msg = e && e.message ? e.message : String(e);
667
+ if (/409|task_in_terminal_state|结束|终态/.test(msg)) {
668
+ err(`任务已结束,无法追加附件(${msg})`);
669
+ } else {
670
+ err(`申请上传失败:${msg}`);
671
+ }
672
+ return 1;
673
+ }
674
+
675
+ // 第二步:CLI 代执行 PUT 直传 OSS(降低 agent 负担)
676
+ const putUrl = uploadResp.signed_put_url;
677
+ if (!putUrl) {
678
+ err('申请上传成功但未返回 PUT 地址,无法继续。');
679
+ return 1;
680
+ }
681
+ const putFetch = fetchImpl || globalThis.fetch;
682
+ const putResp = await putFetch(putUrl, {
683
+ method: 'PUT',
684
+ headers: { 'Content-Type': mimeType },
685
+ body: fileContent,
686
+ });
687
+ if (!putResp.ok) {
688
+ err(`上传文件到 OSS 失败(状态 ${putResp.status})`);
689
+ return 1;
690
+ }
691
+
692
+ log(formatAddAttachment({ file_id: uploadResp.file_id, visibility: visibilityApi }, { json: flags.json }));
693
+ return 0;
694
+ }
695
+
583
696
  // ── 自治巡航 ─────────────────────────────────────────────
584
697
 
585
698
  if (command === 'autonomy:tick') {