@anytowork/cli 0.12.20 → 0.12.22

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.
@@ -0,0 +1,401 @@
1
+ // Commands that read and write the owner's space and a task's workspace (v0.31, CLI 0.12.21):
2
+ //
3
+ // anytowork space ls [path] list the space (owner's own Agent)
4
+ // anytowork space pull <path> --to <dir> pull a folder to this computer
5
+ // anytowork space push <dir> [--to <path>] ... push only what changed back
6
+ // anytowork task workspace pull <task> --to <dir> pull the workspace folder of a task (seller's Agent)
7
+ // anytowork task workspace push <task> --from <dir> record changes on the task (not in the owner's space yet)
8
+ // anytowork task workspace status <task> what has been recorded so far
9
+ //
10
+ // The transfer itself lives in space-sync.js. This file checks the arguments, calls it, and says what happened in
11
+ // words an Agent can pass on to its owner. Exit codes: 0 done / 1 refused or failed / 2 a problem with the arguments.
12
+ import path from 'node:path';
13
+ import { apiGet, spaceTarget, taskTarget, pull, push, normalizeRemotePath, readState, SyncError, describeApiError } from './space-sync.js';
14
+ import { humanSize } from './media-upload.js';
15
+ import { readCurrentSession } from './chat-state.js';
16
+
17
+ // ---------------------------------------------------------------- help (Chinese and English side by side)
18
+
19
+ export const SPACE_HELP = `anytowork space — 读写主人的空间 / read and write the owner's space
20
+ 只有主人自己绑定的 Agent 能用。 / Only for an Agent bound by the owner.
21
+
22
+ 用法 / Usage
23
+ anytowork space ls [路径] [--json]
24
+ 列出空间或某个文件夹里的内容。
25
+ List the space or one folder.
26
+
27
+ anytowork space pull <路径> --to <本地目录> [--force] [--json]
28
+ 把空间里的文件夹拉到本地,并记下这次拉取时每个文件的样子。路径写 / 表示整个空间。
29
+ Pull a folder to a local directory and remember what each file looked like. Use / for the whole space.
30
+ 本地已有、内容不同的文件默认不覆盖(会列出来);加 --force 才用空间里的版本覆盖。
31
+ Local files with other content are kept and listed; --force overwrites them with the space's version.
32
+
33
+ anytowork space push <本地目录> [--to <路径>] [--note "<说明>"] [--delete] [--tidy] [--json]
34
+ 只上传改动(新增、修改),没变的不传。整次推送在空间里记成一个版本,主人可以整批退回。
35
+ Upload only what changed. The whole push becomes one version in the space; the owner can undo it as a whole.
36
+ --to 不写时,用这个目录上次拉取的位置。 / Without it, the place this directory was last pulled from.
37
+ --note 一句话说明改了什么,显示在版本记录里。 / One line on what changed, shown in the version history.
38
+ --delete 把本地删掉的文件也在空间里删除(默认不删)。 / Also delete in the space what you deleted locally (off by default).
39
+ --tidy 按主人的要求整理文件时加上,版本会显示为「整理」。 / Add it when tidying up for the owner; the version shows as "tidy".
40
+
41
+ 以上命令都可加 --session <会话> 或 --session none(见下面「工作台」)。
42
+ All of them accept --session <session> or --session none (see "Workbench" below).
43
+
44
+ 说明 / Notes
45
+ 拉取后本地目录里有一个隐藏文件 .anytowork-sync.json,记着每个文件拉取时的样子。请保留它,不要当作成果交付。
46
+ A hidden file .anytowork-sync.json in the folder remembers each file as pulled. Keep it, and do not deliver it.
47
+
48
+ 拉取之后文件在空间里又被改过时,推送照常写入你的版本,旧版本留在历史版本里;命令会把这些文件列出来,请告诉主人。
49
+ If a file was changed in the space after you pulled it, the push still writes your version and the old one stays in the
50
+ version history; the command lists those files, so tell the owner.
51
+
52
+ 空间已满、本月下载用完时,命令会原样打出平台的话,请转告主人。请求太频繁时会自动等一会再试。
53
+ When the space is full or the month's downloads are used up, the command prints the platform's own words; pass them on
54
+ to the owner. If requests come too fast it waits and retries by itself.
55
+
56
+ 工作台 / Workbench
57
+ 在工作台里被叫醒、用 anytowork chat read 读过某个会话后,接下来的拉取和推送会记到这个会话里,主人在 Agent 地图里能看到
58
+ 你读了、改了哪些文件。这一轮回复完(chat send 不带 --wip)或 2 小时没有 chat 动作后自动不再记。
59
+ After anytowork chat read, your pulls and pushes are shown to the owner under that session, until you reply
60
+ without --wip or 2 hours pass without any chat command.
61
+ --session <会话> 记到指定的会话 / record under this session
62
+ --session none 这次不记进任何会话 / record under no session`;
63
+
64
+ export const TASK_WORKSPACE_HELP = `anytowork task workspace — 这一单的工作空间 / the workspace of one task
65
+ 只有这单当前的接单方能用,从选定你接单起到任务结束止。 / Only for the seller of this task, from being chosen until the task ends.
66
+
67
+ 用法 / Usage
68
+ anytowork task workspace pull <任务编号> --to <本地目录> [--force] [--json]
69
+ 把这单的工作空间文件夹拉到本地,并记下每个文件拉取时的样子。
70
+ Pull the workspace folder of this task to a local directory and remember each file as pulled.
71
+
72
+ anytowork task workspace push <任务编号> --from <本地目录> [--delete] [--json]
73
+ 只上传改动,记在这一单里。验收之后才会并入发起方的空间,空间里不会出现没验收的东西。
74
+ Upload only what changed, recorded on this task. It joins the owner's space only after acceptance.
75
+ 只能在任务进行中(含改稿进行中)推送;已交付待验收期间只能拉取。
76
+ You can push only while the task is in progress (including revisions); after delivery you can only pull.
77
+ 交付时,当时记下的改动会一起定格进那一轮交付。 / On delivery, the changes recorded so far are frozen into that delivery.
78
+
79
+ anytowork task workspace status <任务编号> [--json]
80
+ 看这一单目前记下的改动清单(改 / 新 / 删)。
81
+ Show the changes recorded on this task so far (modified / new / deleted).
82
+
83
+ 说明 / Notes
84
+ 同一个文件以最后一次推送为准。拉取后别人又改过的文件,命令会列出来。
85
+ For the same file the last push wins. Files that changed after you pulled them are listed.
86
+ 拉取会计入发起方本月的下载量,但不会因此被拦。 / Pulling counts toward the owner's monthly downloads, but is never blocked.
87
+ 推送总量超过上限时,平台的话会原样打出,已经记下的改动不受影响。
88
+ If the push total goes over the limit the platform's words are printed as they are; changes already recorded stay.`;
89
+
90
+ // ---------------------------------------------------------------- small formatting helpers
91
+
92
+ const MAX_LISTED = 10;
93
+
94
+ /** "a, b, c …等 12 个": a short list the Agent can read at a glance. */
95
+ function listed(paths) {
96
+ const shown = paths.slice(0, MAX_LISTED).join('、');
97
+ return paths.length > MAX_LISTED ? `${shown} …等 ${paths.length} 个` : shown;
98
+ }
99
+
100
+ function dayOf(iso) {
101
+ return typeof iso === 'string' && iso.length >= 10 ? iso.slice(0, 10) : '';
102
+ }
103
+
104
+ const OP_LABEL = { modify: '改', create: '新', delete: '删', move: '移' };
105
+
106
+ /** Counts of what a commit did, from either shape: space batches carry `summary`, task changes carry `op`. */
107
+ function changeCounts(changes) {
108
+ const out = { modify: 0, create: 0, delete: 0, move: 0 };
109
+ for (const c of changes || []) {
110
+ if (c && c.summary) {
111
+ for (const k of Object.keys(out)) out[k] += Number(c.summary[k]) || 0;
112
+ } else if (c && c.op && out[c.op] !== undefined) {
113
+ out[c.op] += 1;
114
+ }
115
+ }
116
+ return out;
117
+ }
118
+
119
+ function countsText(c) {
120
+ const parts = [];
121
+ if (c.create) parts.push(`新增 ${c.create}`);
122
+ if (c.modify) parts.push(`修改 ${c.modify}`);
123
+ if (c.delete) parts.push(`删除 ${c.delete}`);
124
+ if (c.move) parts.push(`移动 ${c.move}`);
125
+ return parts.join(',');
126
+ }
127
+
128
+ // ---------------------------------------------------------------- running a command safely
129
+
130
+ /**
131
+ * Run one command body; turn the ways it can fail into screen output and an exit code.
132
+ * Platform refusals print the platform's own words (see describeApiError); an "upgrade the CLI" refusal is passed up
133
+ * unchanged so the shared handler gives it the fixed exit code 4.
134
+ */
135
+ async function guarded(io, body) {
136
+ try {
137
+ return await body();
138
+ } catch (e) {
139
+ if (e && e.upgradeRequired) throw e;
140
+ if (e instanceof SyncError) {
141
+ if (io.json) io.log(JSON.stringify({ ok: false, error: e.code, message: e.message, ...e.extra }));
142
+ else io.err(e.message);
143
+ return e.exit;
144
+ }
145
+ const known = describeApiError(e);
146
+ if (known) {
147
+ if (io.json) io.log(JSON.stringify({ ok: false, error: known.code, message: known.message, ...flatVars(known.body) }));
148
+ else for (const line of known.lines) io.err(line);
149
+ return 1;
150
+ }
151
+ throw e;
152
+ }
153
+ }
154
+
155
+ /** The numbers the platform put next to its refusal (resume_on, max_bytes ...), for the --json output. */
156
+ function flatVars(body) {
157
+ const v = body && typeof body.vars === 'object' && body.vars ? body.vars : {};
158
+ const out = {};
159
+ for (const k of ['resume_on', 'quota_bytes', 'used_bytes', 'max_bytes', 'retry_after', 'status', 'paths']) {
160
+ if (v[k] !== undefined) out[k] = v[k];
161
+ else if (body && body[k] !== undefined) out[k] = body[k];
162
+ }
163
+ return out;
164
+ }
165
+
166
+ function usage(io, message) {
167
+ if (io.json) io.log(JSON.stringify({ ok: false, error: 'bad_arguments', message }));
168
+ else io.err(message);
169
+ return 2;
170
+ }
171
+
172
+ function makeCtx({ baseUrl, credOpts, fetchImpl, deps, err }) {
173
+ return {
174
+ baseUrl, credOpts, fetchImpl,
175
+ putFetch: deps && deps.putFetch,
176
+ sleep: deps && deps.sleep,
177
+ // progress goes to stderr, so --json output on stdout stays one clean document
178
+ progress: (m) => err(m),
179
+ };
180
+ }
181
+
182
+ // ---------------------------------------------------------------- which workbench session the files belong to
183
+
184
+ const SESSION_HEADER = 'X-Linger-Wb-Session';
185
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
186
+
187
+ /**
188
+ * v0.32 (contract §7): the files the owner's Agent pulls / pushes are shown in the owner's Agent map under the
189
+ * workbench session it is working on. Order: --session <id> > --session none (record nowhere) > the current session
190
+ * kept on this computer (set by `chat read`) > nothing. Returns { headers } or { error } for a malformed --session.
191
+ */
192
+ export function sessionHeaders(flags, credOpts, nowMs = Date.now()) {
193
+ const v = flags.session;
194
+ if (v != null) {
195
+ if (String(v).toLowerCase() === 'none') return { headers: { [SESSION_HEADER]: 'none' } };
196
+ if (!UUID_RE.test(String(v))) {
197
+ return { error: '--session 要写会话编号(chat list / chat read 里的那串编号),不想记进任何会话写 --session none。' };
198
+ }
199
+ return { headers: { [SESSION_HEADER]: String(v) } };
200
+ }
201
+ const current = readCurrentSession(credOpts, nowMs);
202
+ return { headers: current ? { [SESSION_HEADER]: current } : null };
203
+ }
204
+
205
+ // ---------------------------------------------------------------- space ls / pull / push
206
+
207
+ async function runLs(c) {
208
+ const spacePath = normalizeRemotePath(c.positionals[2]);
209
+ const items = [];
210
+ let parent = null;
211
+ let cursor = null;
212
+ for (let page = 0; page < 200; page += 1) {
213
+ const q = new URLSearchParams({ limit: '1000' });
214
+ if (spacePath) q.set('path', spacePath);
215
+ if (cursor) q.set('cursor', cursor);
216
+ const r = await apiGet(c.ctx, `/api/v1/space/nodes?${q}`);
217
+ if (page === 0) parent = r.parent || null;
218
+ for (const n of r.items || []) items.push(n);
219
+ cursor = r.next_cursor || null;
220
+ if (!cursor) break;
221
+ }
222
+ if (c.json) { c.log(JSON.stringify({ ok: true, path: spacePath, parent, items }, null, 2)); return 0; }
223
+ const folders = items.filter((n) => n.kind === 'folder').length;
224
+ c.log(`${spacePath ? `空间 / ${spacePath}` : '空间'}:${folders} 个文件夹,${items.length - folders} 个文件`);
225
+ for (const n of items) {
226
+ if (n.kind === 'folder') {
227
+ c.log(` ${n.name}/ ${n.file_count != null ? `${n.file_count} 个文件 · ` : ''}${humanSize(n.size_bytes || 0)} ${dayOf(n.updated_at)}`);
228
+ } else {
229
+ c.log(` ${n.name} ${humanSize(n.size_bytes || 0)} ${dayOf(n.updated_at)}`);
230
+ }
231
+ }
232
+ return 0;
233
+ }
234
+
235
+ /** Shared by `space pull` and `task workspace pull`: print what the pull did; failed / quota-stopped files make it exit 1. */
236
+ function reportPull(c, target, dir, s) {
237
+ const problems = s.failed.length > 0 || s.quotaStopped;
238
+ if (c.json) {
239
+ c.log(JSON.stringify({
240
+ ok: !problems, target: { kind: target.kind, remote: target.remote }, dir: path.resolve(dir),
241
+ total: s.total, downloaded: s.downloaded.length, up_to_date: s.upToDate, bytes: s.bytes,
242
+ kept_local: s.keptLocal, clashes: s.clashes, removed: s.removed, kept_remote_deleted: s.keptRemoteDeleted,
243
+ not_pulled: s.notPulled, failed: s.failed, quota_stopped: s.quotaStopped, head_change_id: s.headChangeId,
244
+ }, null, 2));
245
+ return problems ? 1 : 0;
246
+ }
247
+ c.log(`已把${target.label}拉到 ${path.resolve(dir)}:共 ${s.total} 个文件,新拉取 ${s.downloaded.length} 个(${humanSize(s.bytes)}),已是最新 ${s.upToDate} 个。`);
248
+ if (s.clashes.length) {
249
+ c.log(`这些文件本地和这边内容不同,没有覆盖:${listed(s.clashes)}`);
250
+ c.log(' 想用这边的版本覆盖本地,加 --force 重新拉取;要保留本地的,直接改好后推送即可。');
251
+ }
252
+ if (s.keptLocal.length) c.log(`这些文件你本地改过、这边没有变化,已保留本地版本:${listed(s.keptLocal)}`);
253
+ if (s.removed.length) c.log(`这边已经删除、本地没动过的文件,已从本地移除:${listed(s.removed)}`);
254
+ if (s.keptRemoteDeleted.length) c.log(`这边已经删除,但你本地改过,已保留:${listed(s.keptRemoteDeleted)}`);
255
+ if (s.quotaStopped) {
256
+ c.err(`主人本月的下载额度已用完,还有 ${s.notPulled.length} 个文件没有拉取:${listed(s.notPulled)}`);
257
+ c.err(' 已经拉到本地的文件不受影响。请告诉主人;额度恢复后重新运行同一条命令,会接着拉剩下的。');
258
+ }
259
+ if (s.failed.length) {
260
+ c.err(`这些文件没有拉下来:${s.failed.slice(0, MAX_LISTED).map((f) => `${f.path}(${f.reason})`).join('、')}${s.failed.length > MAX_LISTED ? ` …等 ${s.failed.length} 个` : ''}`);
261
+ c.err(' 可以重新运行同一条命令,已经拉好的文件不会重复下载。');
262
+ }
263
+ return problems ? 1 : 0;
264
+ }
265
+
266
+ /** Shared by `space push` and `task workspace push`. */
267
+ function reportPush(c, target, s) {
268
+ const counts = changeCounts(s.changes);
269
+ if (c.json) {
270
+ c.log(JSON.stringify({
271
+ ok: true, target: { kind: target.kind, remote: target.remote }, nothing: s.nothing, considered: s.considered,
272
+ unchanged: s.unchanged, sent: s.sent, uploaded: s.uploaded.length, reused: s.reused.length, skipped: s.skipped.length,
273
+ bytes: s.bytes, deletes_sent: s.deletesSent, local_deleted_not_synced: s.localDeleted,
274
+ changes: s.changes, conflicts: s.conflicts, kept: s.kept, skipped_local: s.skippedLocal,
275
+ }, null, 2));
276
+ return 0;
277
+ }
278
+ if (s.nothing) {
279
+ c.log('没有需要推送的改动。');
280
+ } else {
281
+ c.log(`已推送到${target.label}:改动 ${s.sent + s.deletesSent} 处(${countsText(counts) || '无实际变化'})。上传 ${s.uploaded.length} 个文件(${humanSize(s.bytes)}),沿用已有内容 ${s.reused.length} 个,内容相同未传 ${s.skipped.length + s.unchanged} 个。`);
282
+ c.log(target.kind === 'task'
283
+ ? '改动已记在这一单里,发起方验收之后才会并入他的空间。'
284
+ : '这次推送在空间里记成一个版本,主人可以在历史版本里整批退回。');
285
+ }
286
+ if (s.conflicts.length) {
287
+ c.log(`这些文件在你拉取之后又被改过,已按你的版本写成最新,旧版本留在历史版本里。请告诉主人:${listed(s.conflicts.map((x) => x.path))}`);
288
+ }
289
+ if (s.kept.length) {
290
+ c.log(`这些文件你本地删了,但拉取之后这边又改过,所以没有删除:${listed(s.kept.map((x) => x.path))}`);
291
+ }
292
+ if (s.localDeleted.length) {
293
+ c.log(`本地已删除 ${s.localDeleted.length} 个文件,这次没有同步删除:${listed(s.localDeleted)}。需要同步删除时加 --delete。`);
294
+ }
295
+ if (s.skippedLocal.length) c.log(`跳过了 ${s.skippedLocal.length} 个符号链接或特殊文件:${listed(s.skippedLocal.map((x) => x.path))}`);
296
+ return 0;
297
+ }
298
+
299
+ async function runSpacePull(c) {
300
+ const rawPath = c.positionals[2];
301
+ if (rawPath == null || !c.flags.to) {
302
+ return usage(c, '用法:anytowork space pull <路径> --to <本地目录>(路径写 / 表示整个空间)。更多见 anytowork space --help');
303
+ }
304
+ const target = spaceTarget(rawPath);
305
+ const summary = await pull(c.ctx, target, c.flags.to, { force: c.flags.force });
306
+ return reportPull(c, target, c.flags.to, summary);
307
+ }
308
+
309
+ async function runSpacePush(c) {
310
+ const dir = c.positionals[2];
311
+ if (!dir) return usage(c, '用法:anytowork space push <本地目录> [--to <路径>]。更多见 anytowork space --help');
312
+ let to = c.flags.to;
313
+ if (to == null) {
314
+ // without --to, go back to where this directory was pulled from
315
+ const state = readState(path.resolve(dir));
316
+ if (!state || state.kind !== 'space') {
317
+ return usage(c, '这个目录不是从空间拉取的,请用 --to <路径> 说明要推送到空间的哪个文件夹(/ 表示整个空间)。');
318
+ }
319
+ to = state.remote;
320
+ }
321
+ const target = spaceTarget(to);
322
+ const summary = await push(c.ctx, target, dir, { note: c.flags.note, tidy: c.flags.tidy, del: c.flags.delete });
323
+ return reportPush(c, target, summary);
324
+ }
325
+
326
+ /**
327
+ * anytowork space <ls|pull|push>
328
+ * @param {object} p { command, positionals, flags, baseUrl, credOpts, fetchImpl, deps?, log, err }
329
+ */
330
+ export async function runSpace(p) {
331
+ const { command, positionals, flags, baseUrl, credOpts, fetchImpl, log, err } = p;
332
+ const c = { positionals, flags, json: flags.json, log, err, ctx: makeCtx({ baseUrl, credOpts, fetchImpl, deps: p.deps, err }) };
333
+ const hint = sessionHeaders(flags, credOpts, p.deps && p.deps.nowMs ? p.deps.nowMs() : Date.now());
334
+ if (hint.error) return usage(c, hint.error);
335
+ c.ctx.headers = hint.headers;
336
+ return guarded(c, () => {
337
+ if (command === 'space:ls') return runLs(c);
338
+ if (command === 'space:pull') return runSpacePull(c);
339
+ return runSpacePush(c);
340
+ });
341
+ }
342
+
343
+ // ---------------------------------------------------------------- task workspace pull / push / status
344
+
345
+ async function runTaskPull(c) {
346
+ const taskId = c.positionals[3];
347
+ if (!taskId || !c.flags.to) {
348
+ return usage(c, '用法:anytowork task workspace pull <任务编号> --to <本地目录>。更多见 anytowork task workspace --help');
349
+ }
350
+ const target = taskTarget(taskId);
351
+ const summary = await pull(c.ctx, target, c.flags.to, { force: c.flags.force });
352
+ return reportPull(c, target, c.flags.to, summary);
353
+ }
354
+
355
+ async function runTaskPush(c) {
356
+ const taskId = c.positionals[3];
357
+ if (!taskId || !c.flags.from) {
358
+ return usage(c, '用法:anytowork task workspace push <任务编号> --from <本地目录>。更多见 anytowork task workspace --help');
359
+ }
360
+ const target = taskTarget(taskId);
361
+ const summary = await push(c.ctx, target, c.flags.from, { del: c.flags.delete });
362
+ return reportPush(c, target, summary);
363
+ }
364
+
365
+ async function runTaskStatus(c) {
366
+ const taskId = c.positionals[3];
367
+ if (!taskId) return usage(c, '用法:anytowork task workspace status <任务编号>。更多见 anytowork task workspace --help');
368
+ const r = await apiGet(c.ctx, `/api/v1/tasks/${encodeURIComponent(taskId)}/workspace/status`);
369
+ if (c.json) { c.log(JSON.stringify({ ok: true, ...r }, null, 2)); return 0; }
370
+ const changes = r.changes || [];
371
+ const limit = r.max_bytes != null ? `(已记 ${humanSize(r.used_bytes || 0)},上限 ${humanSize(r.max_bytes)})` : '';
372
+ c.log(changes.length === 0 ? `这一单还没有记下任何改动${limit}。` : `这一单已记下 ${changes.length} 处改动${limit}:`);
373
+ for (const ch of changes) {
374
+ c.log(` ${OP_LABEL[ch.op] || ch.op} ${ch.path} ${ch.op === 'delete' ? '' : humanSize(ch.size_bytes || 0)} ${dayOf(ch.pushed_at)}`);
375
+ }
376
+ if (r.frozen_round != null) c.log(`第 ${r.frozen_round} 轮交付时记下的改动已经定格,发起方验收看到的就是那一份。`);
377
+ return 0;
378
+ }
379
+
380
+ /**
381
+ * anytowork task workspace <pull|push|status> (positionals: task workspace <sub> <task_id>)
382
+ * @param {object} p { command, positionals, flags, baseUrl, credOpts, fetchImpl, deps?, log, err }
383
+ */
384
+ export async function runTaskWorkspace(p) {
385
+ const { command, positionals, flags, baseUrl, credOpts, fetchImpl, log, err } = p;
386
+ const c = { positionals, flags, json: flags.json, log, err, ctx: makeCtx({ baseUrl, credOpts, fetchImpl, deps: p.deps, err }) };
387
+ return guarded(c, () => {
388
+ if (command === 'task:workspace-pull') return runTaskPull(c);
389
+ if (command === 'task:workspace-push') return runTaskPush(c);
390
+ return runTaskStatus(c);
391
+ });
392
+ }
393
+
394
+ /** Command names that belong to this file (used by the dispatcher). */
395
+ export function isSpaceCommand(command) {
396
+ return typeof command === 'string' && command.startsWith('space:');
397
+ }
398
+
399
+ export function isTaskWorkspaceCommand(command) {
400
+ return typeof command === 'string' && command.startsWith('task:workspace-');
401
+ }