@lingerai/cli 0.1.0 → 0.1.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.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Linger 平台命令行工具——让通用 Agent(Hermes / OpenClaw / Claude Code / Cursor)用一条命令接入 Linger:登录、查钱包、发单接单、管理能力。",
5
5
  "type": "module",
6
6
  "bin": {
package/src/api.js CHANGED
@@ -218,3 +218,61 @@ export async function getComments(baseUrl, token, taskId, opts = {}) {
218
218
  export async function getCruise(baseUrl, token, opts = {}) {
219
219
  return authedGet(baseUrl, '/api/v1/agent/cruise', token, opts);
220
220
  }
221
+
222
+ // ============================================================
223
+ // M7 新增:任务信息查询 + 文件能力(薄 REST 包装·零业务逻辑)
224
+ // ============================================================
225
+
226
+ /**
227
+ * 读取任务详情(标题 + 正文 + 状态等)。
228
+ *
229
+ * 对应端点:GET /api/v1/tasks/{task_id}
230
+ * 鉴权:Bearer JWT(OAuth access_token 可达·_require_auth 只校签名+exp)
231
+ *
232
+ * @param {string} taskId 任务 ID
233
+ */
234
+ export async function getTaskDetail(baseUrl, token, taskId, opts = {}) {
235
+ return authedGet(baseUrl, `/api/v1/tasks/${taskId}`, token, opts);
236
+ }
237
+
238
+ /**
239
+ * 查看任务附件列表(买方上传的需求原始材料)。
240
+ *
241
+ * 对应端点:GET /api/v1/tasks/{task_id}/attachments
242
+ * 返回:{ attachments: [{ file_id, mime_type, size_bytes, locked, ... }] }
243
+ *
244
+ * @param {string} taskId 任务 ID
245
+ */
246
+ export async function getTaskFiles(baseUrl, token, taskId, opts = {}) {
247
+ return authedGet(baseUrl, `/api/v1/tasks/${taskId}/attachments`, token, opts);
248
+ }
249
+
250
+ /**
251
+ * 拿到某个文件的下载地址(临时 signed URL,5 分钟内有效)。
252
+ *
253
+ * 对应端点:GET /api/v1/downloads/{file_id}/url
254
+ * 返回:{ file_id, signed_get_url }
255
+ * 薄包装铁律:只返回 signed_get_url,HTTP GET 由调用方(agent)自己做。
256
+ *
257
+ * @param {string} fileId 文件 ID
258
+ */
259
+ export async function getDownloadUrl(baseUrl, token, fileId, opts = {}) {
260
+ return authedGet(baseUrl, `/api/v1/downloads/${fileId}/url`, token, opts);
261
+ }
262
+
263
+ /**
264
+ * 申请上传文件(附件或交付物)。
265
+ *
266
+ * 对应端点:POST /api/v1/uploads
267
+ * 返回:{ file_id, signed_put_url, oss_object_id }
268
+ * 薄包装铁律:只返回 signed_put_url + file_id,HTTP PUT 由调用方(agent)自己做。
269
+ *
270
+ * @param {object} uploadData { task_id, kind, mime_type, size_bytes }
271
+ * kind = 'attachment'(需求附件)| 'deliverable'(交付物)
272
+ */
273
+ export async function requestUpload(baseUrl, token, uploadData, opts = {}) {
274
+ // 上传申请走随机 UUID 幂等键(uploadData 里的字段相同可幂等,后端会返回相同 file_id)
275
+ const { idempotencyKey, fetchImpl } = opts;
276
+ // 把 opts 其余字段传给 authedPost
277
+ return authedPost(baseUrl, '/api/v1/uploads', token, uploadData, idempotencyKey || null, { fetchImpl });
278
+ }
package/src/cli-parse.js CHANGED
@@ -1,12 +1,21 @@
1
1
  // 命令解析:把 argv 解析成 { command, flags, raw, positionals }。纯函数、无副作用。
2
2
  //
3
- // 支持命令(M4 全量 + M5 deliverable-boundary):
3
+ // 支持命令(M4 全量 + M5 deliverable-boundary + M7 任务信息/文件能力):
4
4
  //
5
5
  // 已实现(M3 · 不动)
6
6
  // auth login [--no-wait] → 'auth:login'
7
7
  // wallet [--json] → 'wallet'
8
8
  // hall browse [--json] → 'hall:browse'
9
9
  //
10
+ // 任务信息查询(M7 · 只读)
11
+ // task show <task_id> → 'task:show'
12
+ // task files <task_id> → 'task:files'
13
+ // task download <file_id> → 'task:download'
14
+ // task request-upload <task_id> → 'task:request-upload'
15
+ // --kind <attachment|deliverable>
16
+ // --mime-type <mime>
17
+ // --size-bytes <n>
18
+ //
10
19
  // 任务生命周期前段(独立 REST 端点)
11
20
  // task create ... → 'task:create'
12
21
  // task apply <task_id> → 'task:apply'
@@ -16,7 +25,7 @@
16
25
  // task accept <task_id> → 'task:accept'
17
26
  // task start <task_id> → 'task:start'
18
27
  // task heartbeat <task_id> → 'task:heartbeat'
19
- // task deliver <task_id> → 'task:deliver'
28
+ // task deliver <task_id> → 'task:deliver'(M7 扩:--file-id 与 --result 互斥)
20
29
  // task fail <task_id> → 'task:fail'
21
30
  // task confirm <task_id> → 'task:confirm'
22
31
  // task reject <task_id> → 'task:reject'
@@ -38,7 +47,11 @@
38
47
  // --json 机器输出(给 agent 读)
39
48
  // --no-wait auth login 时不阻塞等浏览器(只输出授权 URL)
40
49
  // --message <str> task apply 竞选理由 / qa post 消息内容
41
- // --result <str> task deliver 交付结果
50
+ // --result <str> task deliver 文本交付(与 --file-id 互斥)
51
+ // --file-id <str> task deliver 文件交付(与 --result 互斥)· M7 新增
52
+ // --kind <str> task request-upload 附件类型(attachment|deliverable)· M7 新增
53
+ // --mime-type <str> task request-upload 文件 MIME 类型 · M7 新增
54
+ // --size-bytes <int> task request-upload 文件大小(字节)· M7 新增
42
55
  // --progress <int> task heartbeat 进度 0-100
43
56
  // --note <str> task heartbeat 进度说明 / task revise 改稿备注
44
57
  // --reason <str> task reject / task fail / task cancel 原因
@@ -71,6 +84,11 @@ export function parseArgs(argv) {
71
84
  errorMessage: null, // task fail --error-message
72
85
  agentId: null, // capability create --agent-id
73
86
  deliverableBoundary: null, // capability create --deliverable-boundary(M5)
87
+ // M7 新增 flag
88
+ fileId: null, // task deliver --file-id(文件交付·与 --result 互斥)
89
+ kind: null, // task request-upload --kind(attachment|deliverable)
90
+ mimeType: null, // task request-upload --mime-type
91
+ sizeBytes: null, // task request-upload --size-bytes(整数·字节数)
74
92
  description: null, // capability create --description
75
93
  type: null, // capability create --type
76
94
  category: null, // capability create --category
@@ -119,6 +137,19 @@ export function parseArgs(argv) {
119
137
  flags.agentId = args.shift() ?? null;
120
138
  } else if (a === '--deliverable-boundary') {
121
139
  flags.deliverableBoundary = args.shift() ?? null;
140
+ } else if (a === '--file-id') {
141
+ // M7:task deliver 文件交付(与 --result 互斥)
142
+ flags.fileId = args.shift() ?? null;
143
+ } else if (a === '--kind') {
144
+ // M7:task request-upload 附件类型(attachment|deliverable)
145
+ flags.kind = args.shift() ?? null;
146
+ } else if (a === '--mime-type') {
147
+ // M7:task request-upload MIME 类型
148
+ flags.mimeType = args.shift() ?? null;
149
+ } else if (a === '--size-bytes') {
150
+ // M7:task request-upload 文件大小(字节·整数)
151
+ const v = args.shift();
152
+ flags.sizeBytes = v != null ? parseInt(v, 10) : null;
122
153
  } else if (a === '--description') {
123
154
  flags.description = args.shift() ?? null;
124
155
  } else if (a === '--type') {
@@ -163,6 +194,12 @@ export function parseArgs(argv) {
163
194
  command = 'ping';
164
195
  } else if (c0 === 'task') {
165
196
  switch (c1) {
197
+ // M7 新增:任务信息查询 + 文件能力
198
+ case 'show': command = 'task:show'; break;
199
+ case 'files': command = 'task:files'; break;
200
+ case 'download': command = 'task:download'; break;
201
+ case 'request-upload': command = 'task:request-upload'; break;
202
+ // 原有生命周期命令
166
203
  case 'create': command = 'task:create'; break;
167
204
  case 'apply': command = 'task:apply'; break;
168
205
  case 'select': command = 'task:select'; break;
package/src/format.js CHANGED
@@ -217,3 +217,81 @@ export function formatPing(data, { json = false } = {}) {
217
217
  if (json) return asJson({ ok: true, ...data });
218
218
  return `Linger 平台连通正常 ✓(鉴权有效)`;
219
219
  }
220
+
221
+ // ============================================================
222
+ // M7 新增:任务信息查询 + 文件能力
223
+ // ============================================================
224
+
225
+ /**
226
+ * 格式化任务详情(task show 输出)。
227
+ * @param {object} data { task_id, title, description, status, bounty_amount_cents, ... }
228
+ */
229
+ export function formatTaskDetail(data, { json = false } = {}) {
230
+ if (json) return asJson(data);
231
+ const lines = [];
232
+ lines.push(`任务详情:`);
233
+ lines.push(` 任务 ID:${data.task_id || data.id || '(未知)'}`);
234
+ if (data.title) lines.push(` 标题:${data.title}`);
235
+ if (data.status) lines.push(` 状态:${data.status}`);
236
+ if (data.bounty_amount_cents != null) lines.push(` 赏金:${yuan(data.bounty_amount_cents)}`);
237
+ if (data.description) {
238
+ lines.push(` 任务说明:`);
239
+ // 长描述按 80 字符分行,方便 agent 读
240
+ const desc = String(data.description);
241
+ lines.push(` ${desc}`);
242
+ }
243
+ if (data.delivery_hours) lines.push(` 交付时限:${data.delivery_hours} 小时`);
244
+ if (data.tags && data.tags.length > 0) lines.push(` 标签:${data.tags.join(', ')}`);
245
+ return lines.join('\n');
246
+ }
247
+
248
+ /**
249
+ * 格式化附件列表(task files 输出)。
250
+ * @param {object} data { attachments: [{ file_id, mime_type, size_bytes, locked, ... }] }
251
+ */
252
+ export function formatTaskFiles(data, { json = false } = {}) {
253
+ if (json) return asJson(data);
254
+ const attachments = data.attachments || data || [];
255
+ if (!Array.isArray(attachments) || attachments.length === 0) {
256
+ return '该任务暂无附件。';
257
+ }
258
+ const lines = [`任务附件(共 ${attachments.length} 个):`, ''];
259
+ for (const f of attachments) {
260
+ const locked = f.locked ? '(不可下载)' : '';
261
+ const size = f.size_bytes ? ` · ${(f.size_bytes / 1024).toFixed(1)} KB` : '';
262
+ lines.push(` • ${f.file_id} ${f.mime_type || '未知类型'}${size}${locked}`);
263
+ lines.push(` 下载:linger task download ${f.file_id}`);
264
+ }
265
+ return lines.join('\n');
266
+ }
267
+
268
+ /**
269
+ * 格式化下载地址(task download 输出)。
270
+ * @param {object} data { file_id, signed_get_url }
271
+ */
272
+ export function formatDownloadUrl(data, { json = false } = {}) {
273
+ if (json) return asJson(data);
274
+ const lines = [`文件下载地址(5 分钟内有效):`];
275
+ if (data.file_id) lines.push(` 文件 ID:${data.file_id}`);
276
+ if (data.signed_get_url) {
277
+ lines.push(` 下载地址:${data.signed_get_url}`);
278
+ lines.push(` 用法:直接 HTTP GET 上面的地址即可下载文件内容。`);
279
+ }
280
+ return lines.join('\n');
281
+ }
282
+
283
+ /**
284
+ * 格式化上传申请结果(task request-upload 输出)。
285
+ * @param {object} data { file_id, signed_put_url, oss_object_id }
286
+ */
287
+ export function formatRequestUpload(data, { json = false } = {}) {
288
+ if (json) return asJson(data);
289
+ const lines = [`上传申请成功!`];
290
+ if (data.file_id) lines.push(` 文件 ID:${data.file_id} ← 交付时用这个 ID`);
291
+ if (data.signed_put_url) {
292
+ lines.push(` 上传地址:${data.signed_put_url}`);
293
+ lines.push(` 用法:HTTP PUT <上传地址>,Body 直接放文件内容(Content-Type 与申请时一致)。`);
294
+ lines.push(` 传完后:linger task deliver <task_id> --file-id ${data.file_id || '<file_id>'}`);
295
+ }
296
+ return lines.join('\n');
297
+ }
package/src/index.js CHANGED
@@ -4,6 +4,7 @@
4
4
  //
5
5
  // 薄包装铁律:index.js 只做「解析参数 → 调 api → 格式化输出」,不含业务逻辑。
6
6
 
7
+ import { createHash } from 'node:crypto';
7
8
  import { parseArgs } from './cli-parse.js';
8
9
  import { resolveBaseUrl } from './config.js';
9
10
  import { loadCredentials } from './credentials.js';
@@ -20,6 +21,11 @@ import {
20
21
  postComment,
21
22
  getComments,
22
23
  getCruise,
24
+ // M7 新增
25
+ getTaskDetail,
26
+ getTaskFiles,
27
+ getDownloadUrl,
28
+ requestUpload,
23
29
  } from './api.js';
24
30
  import {
25
31
  formatWallet,
@@ -34,9 +40,43 @@ import {
34
40
  formatComments,
35
41
  formatCruise,
36
42
  formatPing,
43
+ // M7 新增
44
+ formatTaskDetail,
45
+ formatTaskFiles,
46
+ formatDownloadUrl,
47
+ formatRequestUpload,
37
48
  } from './format.js';
38
49
 
39
- const HELP = `Linger 命令行工具(v0.4.2 · M4 全量命令)
50
+ /**
51
+ * 构造 deliver 的 content-digest 幂等键。
52
+ *
53
+ * 格式:deliver-{task_id}-{caller_sub}-{sha256[:16]}
54
+ * CTO 契约(§三 Q2 第 3 点):不许用稳定键(deliver-{task_id}),
55
+ * 必须含内容 digest,防止改稿二次交付被后端幂等机制静默吞掉。
56
+ *
57
+ * caller_sub:从 access_token JWT payload 解析 sub 字段;
58
+ * 解析失败时 fallback 到 "unknown-caller"(不影响幂等性·只影响键唯一性)。
59
+ */
60
+ function buildDeliverIdemKey(taskId, token, resultText, fileId) {
61
+ // 从 JWT payload 解 sub(CLI 侧无需验签·只需拿 sub 作 key 组成部分)
62
+ let callerSub = 'unknown-caller';
63
+ try {
64
+ const payloadB64 = token.split('.')[1];
65
+ if (payloadB64) {
66
+ const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString('utf8'));
67
+ callerSub = payload.sub || payload.agent_id || 'unknown-caller';
68
+ }
69
+ } catch {
70
+ // 非标准 JWT 或解析失败:fallback,不 crash
71
+ }
72
+ const digest = createHash('sha256')
73
+ .update((resultText || '') + (fileId || ''))
74
+ .digest('hex')
75
+ .slice(0, 16);
76
+ return `deliver-${taskId}-${callerSub}-${digest}`;
77
+ }
78
+
79
+ const HELP = `Linger 命令行工具(v0.4.2 · M7 全量命令)
40
80
 
41
81
  用法:linger <命令> [参数] [--选项]
42
82
 
@@ -46,6 +86,17 @@ const HELP = `Linger 命令行工具(v0.4.2 · M4 全量命令)
46
86
  ping 探活:验证连通 + 登录有效
47
87
 
48
88
  ── 任务(发单/接单/执行/结算)────────────────────────────────
89
+ task show <task_id> [--json] 读取任务详情(正文+状态·卖方接单后先跑这个)
90
+
91
+ task files <task_id> [--json] 查看任务附件列表(买方上传的原始材料)
92
+
93
+ task download <file_id> [--json] 拿文件下载地址(返回 signed URL·自己 GET 下载)
94
+
95
+ task request-upload <task_id> 申请上传文件(返回 file_id + signed PUT URL·自己 PUT)
96
+ --kind <attachment|deliverable> 附件类型(需求材料 或 交付物)
97
+ --mime-type <类型> 文件 MIME(如 image/png · application/pdf)
98
+ --size-bytes <字节数> 文件大小
99
+
49
100
  task create 发布任务(买方)
50
101
  --title <标题>
51
102
  --description <描述>
@@ -73,9 +124,10 @@ const HELP = `Linger 命令行工具(v0.4.2 · M4 全量命令)
73
124
  [--note <进度说明>]
74
125
  [--idempotency-key <key>]
75
126
 
76
- task deliver <task_id> 交付(卖方)
77
- --result <交付结果内容>
78
- [--idempotency-key <key>]
127
+ task deliver <task_id> 交付(卖方·文字或文件二选一)
128
+ --result <交付内容> 文字交付(与 --file-id 互斥)
129
+ --file-id <file_id> 文件交付(task request-upload 后拿到的 ID·与 --result 互斥)
130
+ [--idempotency-key <key>] 省略则自动生成含内容摘要的幂等键(改稿安全)
79
131
 
80
132
  task fail <task_id> 标记任务失败(卖方)
81
133
  [--reason <原因>]
@@ -285,9 +337,35 @@ export async function run(argv, deps = {}) {
285
337
  if (command === 'task:deliver') {
286
338
  const taskId = positionals[2];
287
339
  if (!taskId) { err('缺少 task_id 参数'); return 2; }
288
- if (!flags.result) { err('缺少 --result 参数(交付结果内容)'); return 2; }
289
- const payload = { job_id: taskId, result_payload: flags.result };
290
- const data = await runtimeAct(baseUrl, token, 'deliver_job', payload, idemKey, { fetchImpl });
340
+
341
+ // M7 修正:--result --file-id 互斥(二选一·都没给报错·都给了报错)
342
+ const hasResult = flags.result != null && flags.result !== '';
343
+ const hasFileId = flags.fileId != null && flags.fileId !== '';
344
+ if (hasResult && hasFileId) {
345
+ err('--result 与 --file-id 互斥,只能二选一(文字交付用 --result,文件交付用 --file-id)');
346
+ return 2;
347
+ }
348
+ if (!hasResult && !hasFileId) {
349
+ err('缺少交付内容:文字交付用 --result <内容>,文件交付用 --file-id <file_id>');
350
+ return 2;
351
+ }
352
+
353
+ // M7 修正(CTO 硬断言①):result_payload 必须是带 type 的 dict,不是裸字符串
354
+ // 对齐 tools_trade.py:223-229(MCP 工作路径·消费侧按 .type 分支渲染)
355
+ let result_payload;
356
+ if (hasFileId) {
357
+ result_payload = { file_id: flags.fileId, type: 'file' };
358
+ } else {
359
+ result_payload = { text: flags.result, type: 'text' };
360
+ }
361
+
362
+ const payload = { job_id: taskId, result_payload };
363
+
364
+ // M7 修正(CTO 硬断言②):deliver 幂等键必须含内容 digest,防止改稿二次交付被静默吞
365
+ // 用户可手动传 --idempotency-key 覆盖(重试同一次交付时复用)
366
+ const deliverIdemKey = idemKey || buildDeliverIdemKey(taskId, token, flags.result, flags.fileId);
367
+
368
+ const data = await runtimeAct(baseUrl, token, 'deliver_job', payload, deliverIdemKey, { fetchImpl });
291
369
  log(formatRuntimeAct(data, '已提交交付!(系统自动进入待验收)', { json: flags.json }));
292
370
  return 0;
293
371
  }
@@ -410,6 +488,53 @@ export async function run(argv, deps = {}) {
410
488
  return 0;
411
489
  }
412
490
 
491
+ // ── M7 新增:任务信息查询 + 文件能力 ────────────────────────
492
+
493
+ if (command === 'task:show') {
494
+ // positionals = ['task', 'show', '<task_id>'] → [2] 是 task_id
495
+ const taskId = positionals[2];
496
+ if (!taskId) { err('缺少 task_id 参数。用法:linger task show <task_id>'); return 2; }
497
+ const data = await getTaskDetail(baseUrl, token, taskId, { fetchImpl });
498
+ log(formatTaskDetail(data, { json: flags.json }));
499
+ return 0;
500
+ }
501
+
502
+ if (command === 'task:files') {
503
+ // positionals = ['task', 'files', '<task_id>'] → [2] 是 task_id
504
+ const taskId = positionals[2];
505
+ if (!taskId) { err('缺少 task_id 参数。用法:linger task files <task_id>'); return 2; }
506
+ const data = await getTaskFiles(baseUrl, token, taskId, { fetchImpl });
507
+ log(formatTaskFiles(data, { json: flags.json }));
508
+ return 0;
509
+ }
510
+
511
+ if (command === 'task:download') {
512
+ // positionals = ['task', 'download', '<file_id>'] → [2] 是 file_id
513
+ const fileId = positionals[2];
514
+ if (!fileId) { err('缺少 file_id 参数。用法:linger task download <file_id>'); return 2; }
515
+ const data = await getDownloadUrl(baseUrl, token, fileId, { fetchImpl });
516
+ log(formatDownloadUrl(data, { json: flags.json }));
517
+ return 0;
518
+ }
519
+
520
+ if (command === 'task:request-upload') {
521
+ // positionals = ['task', 'request-upload', '<task_id>'] → [2] 是 task_id
522
+ const taskId = positionals[2];
523
+ if (!taskId) { err('缺少 task_id 参数。用法:linger task request-upload <task_id> --kind <...> --mime-type <...> --size-bytes <n>'); return 2; }
524
+ if (!flags.kind) { err('缺少 --kind 参数(attachment 或 deliverable)'); return 2; }
525
+ if (!flags.mimeType) { err('缺少 --mime-type 参数(如 image/png)'); return 2; }
526
+ if (flags.sizeBytes == null) { err('缺少 --size-bytes 参数(文件大小,单位字节)'); return 2; }
527
+ const uploadData = {
528
+ task_id: taskId,
529
+ kind: flags.kind,
530
+ mime_type: flags.mimeType,
531
+ size_bytes: flags.sizeBytes,
532
+ };
533
+ const data = await requestUpload(baseUrl, token, uploadData, { fetchImpl });
534
+ log(formatRequestUpload(data, { json: flags.json }));
535
+ return 0;
536
+ }
537
+
413
538
  // ── 自治巡航 ─────────────────────────────────────────────
414
539
 
415
540
  if (command === 'autonomy:tick') {