@moxt-ai/cli 0.4.0-beta.1 → 0.4.0

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/dist/index.js CHANGED
@@ -4,236 +4,15 @@
4
4
  import updateNotifier from "update-notifier";
5
5
 
6
6
  // src/program.ts
7
- import { Command as Command2 } from "commander";
8
-
9
- // src/utils/output.ts
10
- var colors = {
11
- bold: "\x1B[1m",
12
- blue: "\x1B[1;34m",
13
- green: "\x1B[32m",
14
- red: "\x1B[31m",
15
- cyan: "\x1B[36m",
16
- dim: "\x1B[2m",
17
- reset: "\x1B[0m"
18
- };
19
- function print(message) {
20
- console.log(message);
21
- }
22
- function printError(message) {
23
- console.error(`${colors.red}${message}${colors.reset}`);
24
- }
25
-
26
- // src/utils/runtime-context.ts
27
- import { readFileSync } from "fs";
28
- var DEFAULT_USER_HOST = "api.moxt.ai";
29
- var SANDBOX_ENV_FILE_KEY = "SANDBOX_ENV_FILE";
30
- var SANDBOX_ENV_KEYS = [
31
- "BASE_API_HOST",
32
- "SANDBOX_TOOL_API_TOKEN",
33
- "MOXT_REPO_PAYLOAD",
34
- "MOXT_WORKSPACE_ID"
35
- ];
36
- var RuntimeContextError = class extends Error {
37
- constructor(message) {
38
- super(message);
39
- this.name = "RuntimeContextError";
40
- }
41
- };
42
- function resolveRuntimeContext(env = process.env) {
43
- if (hasAnySandboxEnv(env)) {
44
- return resolveSandboxRuntimeContext(env);
45
- }
46
- const apiKey = readNonEmptyEnv(env, "MOXT_API_KEY");
47
- if (apiKey) {
48
- return {
49
- mode: "user",
50
- host: readNonEmptyEnv(env, "MOXT_HOST") ?? DEFAULT_USER_HOST,
51
- apiKey
52
- };
53
- }
54
- throw new RuntimeContextError("Missing auth. Set sandbox environment variables or MOXT_API_KEY.");
55
- }
56
- function resolveSandboxRuntimeContext(env = process.env) {
57
- const directRepoPayload = readNonEmptyEnv(env, "MOXT_REPO_PAYLOAD");
58
- const sandboxEnvFile = readNonEmptyEnv(env, SANDBOX_ENV_FILE_KEY);
59
- const missingKeys = SANDBOX_ENV_KEYS.filter((key) => {
60
- if (key === "MOXT_REPO_PAYLOAD") return !directRepoPayload && !sandboxEnvFile;
61
- return !readNonEmptyEnv(env, key);
62
- });
63
- if (missingKeys.length > 0) {
64
- throw new RuntimeContextError(`Incomplete sandbox environment. Missing: ${missingKeys.join(", ")}.`);
65
- }
66
- let repoPayload = directRepoPayload;
67
- if (!repoPayload) {
68
- if (!sandboxEnvFile) {
69
- throw new RuntimeContextError("MOXT_REPO_PAYLOAD is required.");
70
- }
71
- repoPayload = readRepoPayloadFromSandboxEnvFile(sandboxEnvFile);
72
- }
73
- return {
74
- mode: "sandbox",
75
- baseApiHost: normalizeBaseApiHost(readRequiredEnv(env, "BASE_API_HOST")),
76
- sandboxToolApiToken: readRequiredEnv(env, "SANDBOX_TOOL_API_TOKEN"),
77
- workspaceId: readRequiredEnv(env, "MOXT_WORKSPACE_ID"),
78
- repoPayload: parseRepoPayload(repoPayload)
79
- };
80
- }
81
- function readRepoPayloadFromSandboxEnvFile(path2) {
82
- let parsed;
83
- try {
84
- parsed = JSON.parse(readFileSync(path2, "utf8"));
85
- } catch (error) {
86
- throw new RuntimeContextError(
87
- `Failed to read SANDBOX_ENV_FILE: ${error instanceof Error ? error.message : String(error)}`
88
- );
89
- }
90
- if (!isRecord(parsed)) {
91
- throw new RuntimeContextError("SANDBOX_ENV_FILE must contain a JSON object.");
92
- }
93
- const repoPayload = parsed.MOXT_REPO_PAYLOAD;
94
- if (typeof repoPayload !== "string" || repoPayload.trim().length === 0) {
95
- throw new RuntimeContextError("MOXT_REPO_PAYLOAD is missing from SANDBOX_ENV_FILE.");
96
- }
97
- return repoPayload.trim();
98
- }
99
- function parseRepoPayload(raw) {
100
- let parsed;
101
- try {
102
- parsed = JSON.parse(raw);
103
- } catch (error) {
104
- throw new RuntimeContextError(`MOXT_REPO_PAYLOAD must be valid JSON: ${error instanceof Error ? error.message : String(error)}`);
105
- }
106
- if (!isRecord(parsed)) {
107
- throw new RuntimeContextError("MOXT_REPO_PAYLOAD must be a JSON object.");
108
- }
109
- const personal = parseRepoEntry(parsed.personal, "personal");
110
- const teamsValue = parsed.teams;
111
- if (!Array.isArray(teamsValue)) {
112
- throw new RuntimeContextError("MOXT_REPO_PAYLOAD.teams must be an array.");
113
- }
114
- const teams = teamsValue.map((entry, index) => parseRepoEntry(entry, `teams[${index}]`));
115
- const workspace = parsed.workspace == null ? null : parseRepoEntry(parsed.workspace, "workspace");
116
- return { personal, teams, workspace };
117
- }
118
- function normalizeBaseApiHost(value) {
119
- const trimmed = value.trim();
120
- if (!trimmed) {
121
- throw new RuntimeContextError("BASE_API_HOST must not be empty.");
122
- }
123
- return trimmed.replace(/\/+$/, "");
124
- }
125
- function hasAnySandboxEnv(env) {
126
- return SANDBOX_ENV_KEYS.some((key) => readNonEmptyEnv(env, key) !== void 0);
127
- }
128
- function readRequiredEnv(env, key) {
129
- const value = readNonEmptyEnv(env, key);
130
- if (!value) {
131
- throw new RuntimeContextError(`${key} is required.`);
132
- }
133
- return value;
134
- }
135
- function readNonEmptyEnv(env, key) {
136
- const value = env[key];
137
- if (typeof value !== "string") return void 0;
138
- const trimmed = value.trim();
139
- return trimmed.length > 0 ? trimmed : void 0;
140
- }
141
- function parseRepoEntry(value, path2) {
142
- if (!isRecord(value)) {
143
- throw new RuntimeContextError(`MOXT_REPO_PAYLOAD.${path2} must be an object.`);
144
- }
145
- const repoId = readStringField(value, "repoId", path2);
146
- const name = readStringField(value, "name", path2);
147
- return { repoId, name };
148
- }
149
- function readStringField(record2, field, path2) {
150
- const value = record2[field];
151
- if (typeof value !== "string" || value.trim().length === 0) {
152
- throw new RuntimeContextError(`MOXT_REPO_PAYLOAD.${path2}.${field} must be a non-empty string.`);
153
- }
154
- return value;
155
- }
156
- function isRecord(value) {
157
- return typeof value === "object" && value !== null && !Array.isArray(value);
158
- }
159
-
160
- // src/utils/sandbox-path.ts
161
- var WorkspacePathError = class extends Error {
162
- constructor(message) {
163
- super(message);
164
- this.name = "WorkspacePathError";
165
- }
166
- };
167
- function resolveWorkspacePath(repoPayload, workspacePath) {
168
- const normalizedPath = normalizeWorkspacePath(workspacePath);
169
- const [repoName, ...repoPathParts] = normalizedPath.split("/");
170
- const repoEntries = listRepoEntries(repoPayload);
171
- const repo = repoEntries.find((entry) => entry.name === repoName);
172
- if (!repo) {
173
- throw new WorkspacePathError(`Workspace path must start with one of: ${repoEntries.map((entry) => entry.name).join(", ")}.`);
174
- }
175
- return {
176
- repoId: repo.repoId,
177
- repoName: repo.name,
178
- repoPath: repoPathParts.join("/")
179
- };
180
- }
181
- function listRepoEntries(repoPayload) {
182
- return [
183
- repoPayload.personal,
184
- ...repoPayload.teams,
185
- ...repoPayload.workspace ? [repoPayload.workspace] : []
186
- ];
187
- }
188
- function normalizeWorkspacePath(workspacePath) {
189
- const trimmed = workspacePath.trim();
190
- if (!trimmed) {
191
- throw new WorkspacePathError("Workspace path must not be empty.");
192
- }
193
- if (trimmed.startsWith("/")) {
194
- throw new WorkspacePathError("Workspace path must be relative.");
195
- }
196
- const parts = trimmed.split("/").filter((part) => part.length > 0 && part !== ".");
197
- if (parts.length === 0) {
198
- throw new WorkspacePathError("Workspace path must include a repo name.");
199
- }
200
- if (parts.some((part) => part === "..")) {
201
- throw new WorkspacePathError('Workspace path must not contain "..".');
202
- }
203
- return parts.join("/");
204
- }
205
-
206
- // src/commands/auth.ts
207
- function registerAuthCommand(program2) {
208
- const auth = program2.command("auth").description("Authentication utilities");
209
- auth.command("status").description("Show CLI authentication mode").action(() => {
210
- try {
211
- const context = resolveRuntimeContext();
212
- if (context.mode === "sandbox") {
213
- print(`${colors.bold}Mode${colors.reset}: sandbox`);
214
- print(`${colors.bold}Workspace${colors.reset}: ${context.workspaceId}`);
215
- print(`${colors.bold}Base API${colors.reset}: ${context.baseApiHost}`);
216
- print(`${colors.bold}Repos${colors.reset}:`);
217
- for (const repo of listRepoEntries(context.repoPayload)) {
218
- print(` ${repo.name} ${repo.repoId}`);
219
- }
220
- return;
221
- }
222
- print(`${colors.bold}Mode${colors.reset}: user`);
223
- print(`${colors.bold}Host${colors.reset}: ${context.host}`);
224
- } catch (error) {
225
- if (error instanceof RuntimeContextError) {
226
- printError(error.message);
227
- process.exit(1);
228
- }
229
- throw error;
230
- }
231
- });
232
- }
7
+ import { Command } from "commander";
233
8
 
234
9
  // src/commands/file.ts
235
10
  import * as fs2 from "fs";
236
11
 
12
+ // src/commands/file-comment.ts
13
+ import * as fs from "fs";
14
+ import { TextDecoder } from "util";
15
+
237
16
  // src/utils/file-selector.ts
238
17
  var SpaceOptionsError = class extends Error {
239
18
  constructor(message) {
@@ -374,7 +153,7 @@ function getApiKeyOrUndefined() {
374
153
 
375
154
  // src/utils/http.ts
376
155
  function getUserAgent() {
377
- return `moxt-cli/${"0.4.0-beta.1"} (${platform()}/${arch()}) node/${process.versions.node}`;
156
+ return `moxt-cli/${"0.4.0"} (${platform()}/${arch()}) node/${process.versions.node}`;
378
157
  }
379
158
  async function fetchApi(method, path2, body) {
380
159
  const apiKey = getApiKey();
@@ -414,6 +193,9 @@ async function httpPut(path2, body) {
414
193
  async function httpPost(path2, body) {
415
194
  return request("POST", path2, body);
416
195
  }
196
+ async function httpPatch(path2, body) {
197
+ return request("PATCH", path2, body);
198
+ }
417
199
  async function httpDelete(path2, body) {
418
200
  return request("DELETE", path2, body);
419
201
  }
@@ -427,6 +209,265 @@ async function httpRaw(method, path2, body) {
427
209
  };
428
210
  }
429
211
 
212
+ // src/utils/output.ts
213
+ var colors = {
214
+ bold: "\x1B[1m",
215
+ blue: "\x1B[1;34m",
216
+ green: "\x1B[32m",
217
+ red: "\x1B[31m",
218
+ cyan: "\x1B[36m",
219
+ dim: "\x1B[2m",
220
+ reset: "\x1B[0m"
221
+ };
222
+ function print(message) {
223
+ console.log(message);
224
+ }
225
+ function printError(message) {
226
+ console.error(`${colors.red}${message}${colors.reset}`);
227
+ }
228
+
229
+ // src/utils/spinner.ts
230
+ var frames = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
231
+ var intervalId = null;
232
+ var frameIndex = 0;
233
+ function startSpinner(message) {
234
+ frameIndex = 0;
235
+ process.stdout.write(`${frames[frameIndex]} ${message}`);
236
+ intervalId = setInterval(() => {
237
+ frameIndex = (frameIndex + 1) % frames.length;
238
+ process.stdout.write(`\r${frames[frameIndex]} ${message}`);
239
+ }, 80);
240
+ }
241
+ function stopSpinner(success, message) {
242
+ if (intervalId) {
243
+ clearInterval(intervalId);
244
+ intervalId = null;
245
+ }
246
+ const symbol = success ? "\x1B[32m\u2713\x1B[0m" : "\x1B[31m\u2717\x1B[0m";
247
+ process.stdout.write(`\x1B[2K\r${symbol} ${message ?? ""}
248
+ `);
249
+ }
250
+
251
+ // src/commands/file-comment.ts
252
+ var FILE_ID_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
253
+ var URL_FORMAT_HINT = "Error: invalid Moxt URL format. Expected https://moxt.ai/w/{workspaceId}/{fileId} or https://moxt.ai/w/{workspaceId}/c/{agentId}[/{sessionId}]?file={fileId}";
254
+ var FileCommentOptionsError = class extends Error {
255
+ constructor(message) {
256
+ super(message);
257
+ this.name = "FileCommentOptionsError";
258
+ }
259
+ };
260
+ function runOrExit(fn) {
261
+ try {
262
+ return fn();
263
+ } catch (err) {
264
+ if (err instanceof FileCommentOptionsError || err instanceof SpaceOptionsError) {
265
+ printError(err.message);
266
+ process.exit(1);
267
+ }
268
+ throw err;
269
+ }
270
+ }
271
+ function parseMoxtFileUrl(url) {
272
+ let parsed;
273
+ try {
274
+ parsed = new URL(url);
275
+ } catch {
276
+ throw new FileCommentOptionsError(URL_FORMAT_HINT);
277
+ }
278
+ const segments = parsed.pathname.split("/").filter(Boolean);
279
+ if (segments.length < 3 || segments[0] !== "w") {
280
+ throw new FileCommentOptionsError(URL_FORMAT_HINT);
281
+ }
282
+ const workspaceId = segments[1];
283
+ let fileId;
284
+ if (segments[2] === "c") {
285
+ fileId = parsed.searchParams.get("file") ?? void 0;
286
+ } else if (segments.length === 3) {
287
+ fileId = segments[2];
288
+ }
289
+ if (!fileId || !FILE_ID_UUID_RE.test(fileId)) {
290
+ throw new FileCommentOptionsError(URL_FORMAT_HINT);
291
+ }
292
+ return { workspaceId, fileId };
293
+ }
294
+ async function resolveFileTarget(options) {
295
+ const mode = runOrExit(() => resolveReadMode(options));
296
+ if (mode.kind === "by-url") {
297
+ return runOrExit(() => parseMoxtFileUrl(mode.url));
298
+ }
299
+ const spaceParams = runOrExit(() => resolveSpaceSelector(options));
300
+ const qs = buildFileQueryString(mode.path, spaceParams);
301
+ startSpinner("Resolving file...");
302
+ const response = await httpGet(`/workspaces/${mode.workspace}/files/url${qs}`);
303
+ if (!response.ok) {
304
+ stopSpinner(false, "Failed");
305
+ if (response.status === 404) {
306
+ print(`${colors.red}\u2620 ${mode.path} does not exist${colors.reset}`);
307
+ } else if (response.status === 400) {
308
+ printError(`Error: ${JSON.stringify(response.data)}`);
309
+ } else {
310
+ printError(`Error [${response.status}]: ${JSON.stringify(response.data)}`);
311
+ }
312
+ process.exit(1);
313
+ }
314
+ stopSpinner(true, response.data.path);
315
+ return runOrExit(() => parseMoxtFileUrl(response.data.url));
316
+ }
317
+ function readCommentContentFile(path2) {
318
+ if (!fs.existsSync(path2)) {
319
+ throw new FileCommentOptionsError(`Error: comment content file not found: ${path2}`);
320
+ }
321
+ const stat = fs.statSync(path2);
322
+ if (!stat.isFile()) {
323
+ throw new FileCommentOptionsError(`Error: comment content path is not a file: ${path2}`);
324
+ }
325
+ const buffer2 = fs.readFileSync(path2);
326
+ if (buffer2.includes(0)) {
327
+ throw new FileCommentOptionsError("Error: comment content file must not contain NUL bytes.");
328
+ }
329
+ let content;
330
+ try {
331
+ content = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(buffer2);
332
+ } catch {
333
+ throw new FileCommentOptionsError("Error: comment content file must contain valid UTF-8 text.");
334
+ }
335
+ if (content.length === 0) {
336
+ throw new FileCommentOptionsError("Error: comment content file must not be empty.");
337
+ }
338
+ return content;
339
+ }
340
+ function parseResolveState(state) {
341
+ if (state === "resolved") return true;
342
+ if (state === "open") return false;
343
+ throw new FileCommentOptionsError('Error: --state must be "resolved" or "open".');
344
+ }
345
+ function indentation(size) {
346
+ return " ".repeat(size);
347
+ }
348
+ function escapeTerminalControlCharacters(value) {
349
+ return value.replace(/\r\n/g, "\n").replace(
350
+ /[\u0000-\u0008\u000B-\u001F\u007F-\u009F]/g,
351
+ (character) => `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`
352
+ );
353
+ }
354
+ function formatDetailScalar(value) {
355
+ if (value === null) return "null";
356
+ if (typeof value === "string") {
357
+ const escaped = escapeTerminalControlCharacters(value);
358
+ if (escaped === "") return '""';
359
+ if (/\n/.test(escaped)) return JSON.stringify(escaped);
360
+ return escaped;
361
+ }
362
+ return String(value);
363
+ }
364
+ function printDetailField(name, value, indent = 0) {
365
+ print(`${indentation(indent)}${name}: ${formatDetailScalar(value)}`);
366
+ }
367
+ function printDetailTextField(name, value, indent = 0) {
368
+ if (value === null || value === "") {
369
+ printDetailField(name, value, indent);
370
+ return;
371
+ }
372
+ const escaped = escapeTerminalControlCharacters(value);
373
+ print(`${indentation(indent)}${name}:`);
374
+ for (const line of escaped.split("\n")) {
375
+ print(`${indentation(indent + 2)}${line}`);
376
+ }
377
+ }
378
+ function printAuthorFields(author, indent) {
379
+ printDetailField("humanId", author.humanId, indent);
380
+ printDetailField("assistantId", author.assistantId, indent);
381
+ printDetailField("teammateId", author.teammateId, indent);
382
+ printDetailField("authorName", author.authorName, indent);
383
+ printDetailField("authorAvatarUrl", author.authorAvatarUrl, indent);
384
+ }
385
+ function printCommentItem(item, indent = 0) {
386
+ print(`${indentation(indent)}${colors.bold}Comment${colors.reset}`);
387
+ printDetailField("id", item.id, indent + 2);
388
+ printDetailField("threadId", item.threadId, indent + 2);
389
+ printAuthorFields(item, indent + 2);
390
+ printDetailTextField("content", item.content, indent + 2);
391
+ printDetailField("createdAt", item.createdAt, indent + 2);
392
+ printDetailField("editedAt", item.editedAt, indent + 2);
393
+ }
394
+ function printThread(thread) {
395
+ print(`${colors.bold}Thread${colors.reset}`);
396
+ printDetailField("threadId", thread.threadId, 2);
397
+ printDetailField("fileId", thread.fileId, 2);
398
+ printDetailTextField("quotedText", thread.quotedText, 2);
399
+ printDetailField("resolvedAt", thread.resolvedAt, 2);
400
+ print(`${indentation(2)}createdBy:`);
401
+ printAuthorFields(thread.createdBy, 4);
402
+ printDetailField("createdAt", thread.createdAt, 2);
403
+ print(`${indentation(2)}comments:`);
404
+ thread.items.forEach((item) => printCommentItem(item, 4));
405
+ }
406
+ function addFileSelectorOptions(cmd) {
407
+ return cmd.option("-w, --workspace <workspaceId>", "Workspace ID").option("-s, --space <name>", "Space name (personal or teamspace name)").option("--personal", "Use personal space (default)").option("--team-space-id <teamSpaceId>", "Team space ID").option("--teammate-id <teammateId>", "Teammate ID (teammate space)").option("-p, --path <path>", "File path").option("-u, --url <url>", "Moxt file URL (e.g., https://moxt.ai/w/{workspaceId}/{fileId})");
408
+ }
409
+ function registerFileCommentCommands(file) {
410
+ const comment = file.command("comment").description("File comment operations");
411
+ addFileSelectorOptions(
412
+ comment.command("list").description("List comment threads on a file")
413
+ ).action(async (options) => {
414
+ const target = await resolveFileTarget(options);
415
+ startSpinner("Fetching comments...");
416
+ const response = await httpGet(
417
+ `/workspaces/${target.workspaceId}/files/${target.fileId}/comment-threads`
418
+ );
419
+ if (!response.ok) {
420
+ stopSpinner(false, "Failed");
421
+ printError(`Error [${response.status}]: ${JSON.stringify(response.data)}`);
422
+ process.exit(1);
423
+ }
424
+ if (response.data.items.length === 0) {
425
+ stopSpinner(true, "No comments found.");
426
+ return;
427
+ }
428
+ stopSpinner(true, `Found ${response.data.items.length} thread(s)`);
429
+ response.data.items.forEach((thread, index) => {
430
+ if (index > 0) print("");
431
+ printThread(thread);
432
+ });
433
+ });
434
+ addFileSelectorOptions(
435
+ comment.command("update").description("Update a comment you created").argument("<commentId>").requiredOption("--content-path <localPath>", "Local UTF-8 text file for comment content")
436
+ ).action(async (commentId, options) => {
437
+ const content = runOrExit(() => readCommentContentFile(options.contentPath));
438
+ const target = await resolveFileTarget(options);
439
+ startSpinner("Updating comment...");
440
+ const response = await httpPatch(
441
+ `/workspaces/${target.workspaceId}/files/${target.fileId}/comments/${commentId}`,
442
+ { content }
443
+ );
444
+ if (!response.ok) {
445
+ stopSpinner(false, "Failed");
446
+ printError(`Error [${response.status}]: ${JSON.stringify(response.data)}`);
447
+ process.exit(1);
448
+ }
449
+ stopSpinner(true, "Comment updated");
450
+ printCommentItem(response.data);
451
+ });
452
+ addFileSelectorOptions(
453
+ comment.command("resolve").description("Resolve or reopen a comment thread").argument("<threadId>").requiredOption("--state <state>", "Thread state: resolved or open")
454
+ ).action(async (threadId, options) => {
455
+ const resolved = runOrExit(() => parseResolveState(options.state));
456
+ const target = await resolveFileTarget(options);
457
+ startSpinner(resolved ? "Resolving thread..." : "Reopening thread...");
458
+ const response = await httpPatch(
459
+ `/workspaces/${target.workspaceId}/files/${target.fileId}/comment-threads/${threadId}/resolve`,
460
+ { resolved }
461
+ );
462
+ if (!response.ok) {
463
+ stopSpinner(false, "Failed");
464
+ printError(`Error [${response.status}]: ${JSON.stringify(response.data)}`);
465
+ process.exit(1);
466
+ }
467
+ stopSpinner(true, resolved ? `Resolved: ${threadId}` : `Reopened: ${threadId}`);
468
+ });
469
+ }
470
+
430
471
  // src/utils/search-options.ts
431
472
  var SearchOptionsError = class extends Error {
432
473
  constructor(message) {
@@ -468,7 +509,7 @@ function parseTeammateId(raw) {
468
509
  }
469
510
 
470
511
  // src/utils/run-or-exit.ts
471
- function runOrExit(fn) {
512
+ function runOrExit2(fn) {
472
513
  try {
473
514
  return fn();
474
515
  } catch (err) {
@@ -480,279 +521,38 @@ function runOrExit(fn) {
480
521
  }
481
522
  }
482
523
 
483
- // src/utils/sandbox-client.ts
484
- var DEFAULT_TIMEOUT_MS = 3e4;
485
- var SandboxApiError = class extends Error {
486
- status;
487
- body;
488
- constructor(status, body) {
489
- super(`Sandbox API request failed with ${status}: ${body}`);
490
- this.name = "SandboxApiError";
491
- this.status = status;
492
- this.body = body;
493
- }
494
- };
495
- var SandboxMoxtClient = class {
496
- constructor(context) {
497
- this.context = context;
498
- }
499
- context;
500
- async request(method, path2, body, options = {}) {
501
- const signal = AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
502
- const response = await fetch(`${this.context.baseApiHost}${path2}`, {
503
- method,
504
- headers: {
505
- "Content-Type": "application/json",
506
- Authorization: `Bearer ${this.context.sandboxToolApiToken}`
507
- },
508
- body: body === void 0 ? void 0 : JSON.stringify(body),
509
- signal
510
- });
511
- const contentType = response.headers.get("content-type");
512
- const data = contentType?.includes("application/json") ? await response.json() : await response.text();
524
+ // src/commands/file.ts
525
+ function addSpaceOptions(cmd) {
526
+ return cmd.requiredOption("-w, --workspace <workspaceId>", "Workspace ID").option("-s, --space <name>", "Space name (personal or teamspace name)").option("--personal", "Use personal space (default)").option("--team-space-id <teamSpaceId>", "Team space ID").option("--teammate-id <teammateId>", "Teammate ID (teammate space)");
527
+ }
528
+ function registerFileCommand(program2) {
529
+ const file = program2.command("file").description("File operations");
530
+ registerFileCommentCommands(file);
531
+ addSpaceOptions(
532
+ file.command("search").description("Search files in accessible spaces").argument("<query>", "Search query").option("-l, --limit <limit>", "Maximum number of files to return", "30").option("--mode <mode>", "Keyword matching mode: any or all", "any")
533
+ ).action(async (query, options) => {
534
+ const spaceParams = runOrExit2(() => resolveSpaceSelector(options));
535
+ const limit = runOrExit2(() => parseSearchLimit(options.limit, 30, 80));
536
+ const mode = runOrExit2(() => parseSearchMode(options.mode));
537
+ startSpinner("Searching files...");
538
+ const response = await httpPost(
539
+ `/workspaces/${options.workspace}/files/search`,
540
+ {
541
+ query,
542
+ limit,
543
+ mode,
544
+ ...spaceParams
545
+ }
546
+ );
513
547
  if (!response.ok) {
514
- throw new SandboxApiError(response.status, typeof data === "string" ? data : JSON.stringify(data));
548
+ stopSpinner(false, "Failed");
549
+ printError(`Error [${response.status}]: ${JSON.stringify(response.data)}`);
550
+ process.exit(1);
515
551
  }
516
- return {
517
- status: response.status,
518
- data
519
- };
520
- }
521
- };
522
-
523
- // src/utils/sandbox-file.ts
524
- import { createHash } from "crypto";
525
- var MFS_FILE_TYPE_REGULAR = 1;
526
- var MFS_FILE_SIZE_LIMIT_BYTES = 10 * 1024 * 1024;
527
- var MFS_FILE_SIZE_LIMIT_LABEL = `${MFS_FILE_SIZE_LIMIT_BYTES / (1024 * 1024)}MB`;
528
- var SandboxFileError = class extends Error {
529
- constructor(message, code) {
530
- super(message);
531
- this.code = code;
532
- this.name = "SandboxFileError";
533
- }
534
- code;
535
- };
536
- async function readSandboxFile(context, workspacePath) {
537
- const resolved = resolveSandboxFilePath(context, workspacePath);
538
- const tree = await fetchRepoTree(context, resolved);
539
- const entry = findTreeEntry(tree, resolved);
540
- if (!entry) {
541
- throw new SandboxFileError(`${workspacePath} does not exist`, "not-found");
542
- }
543
- if (entry.type === "tree") {
544
- throw new SandboxFileError(`${workspacePath} is a directory`, "directory");
545
- }
546
- if (!entry.sha) {
547
- throw new SandboxFileError(`${workspacePath} does not have downloadable content`, "remote");
548
- }
549
- if (entry.size !== null && entry.size > MFS_FILE_SIZE_LIMIT_BYTES) {
550
- throw new SandboxFileError(`File too large (max ${MFS_FILE_SIZE_LIMIT_LABEL}): ${workspacePath}`, "too-large");
551
- }
552
- const client = new SandboxMoxtClient(context);
553
- const presign = await client.request(
554
- "POST",
555
- `/agent/api/pipeline-sandbox/mfs/${encodeURIComponent(resolved.repoId)}/presign-download`,
556
- { hashes: [entry.sha] }
557
- );
558
- const url = presign.data.urls[entry.sha];
559
- if (!url) {
560
- throw new SandboxFileError(`No download URL returned for ${workspacePath}`, "remote");
561
- }
562
- const response = await fetch(url);
563
- if (!response.ok) {
564
- throw new SandboxFileError(`Download failed [${response.status}]`, "remote");
565
- }
566
- const content = await response.text();
567
- const data = Buffer.from(content, "utf8");
568
- if (data.length > MFS_FILE_SIZE_LIMIT_BYTES) {
569
- throw new SandboxFileError(`File too large (max ${MFS_FILE_SIZE_LIMIT_LABEL}): ${workspacePath}`, "too-large");
570
- }
571
- const actualSha = createHash("sha256").update(data).digest("hex");
572
- if (actualSha !== entry.sha) {
573
- throw new SandboxFileError(`Downloaded content hash mismatch: ${workspacePath}`, "remote");
574
- }
575
- return {
576
- path: workspacePath,
577
- content,
578
- sha: entry.sha,
579
- size: data.length,
580
- fileId: entry.fileId,
581
- fileType: entry.fileType
582
- };
583
- }
584
- async function writeSandboxFile(context, workspacePath, content, policy) {
585
- const data = Buffer.from(content, "utf8");
586
- if (data.length > MFS_FILE_SIZE_LIMIT_BYTES) {
587
- throw new SandboxFileError(`File too large (max ${MFS_FILE_SIZE_LIMIT_LABEL}): ${workspacePath}`, "too-large");
588
- }
589
- const resolved = resolveSandboxFilePath(context, workspacePath);
590
- const tree = await fetchRepoTree(context, resolved);
591
- const existing = findTreeEntry(tree, resolved);
592
- if (existing?.type === "tree") {
593
- throw new SandboxFileError(`${workspacePath} is a directory`, "directory");
594
- }
595
- if (existing && !existing.sha) {
596
- throw new SandboxFileError(`${workspacePath} does not have base content hash`, "remote");
597
- }
598
- const baseContentHash = resolveBaseContentHash(workspacePath, existing, policy);
599
- const contentHash = createHash("sha256").update(data).digest("hex");
600
- const client = new SandboxMoxtClient(context);
601
- const presign = await client.request(
602
- "POST",
603
- `/agent/api/pipeline-sandbox/mfs/${encodeURIComponent(resolved.repoId)}/presign-upload`,
604
- { hashes: [contentHash] }
605
- );
606
- const uploadUrl = presign.data.urls[contentHash];
607
- if (!uploadUrl) {
608
- throw new SandboxFileError(`No upload URL returned for ${workspacePath}`, "remote");
609
- }
610
- const upload = await fetch(uploadUrl, {
611
- method: "PUT",
612
- headers: { "Content-Type": "application/octet-stream" },
613
- body: new Uint8Array(data)
614
- });
615
- if (!upload.ok) {
616
- throw new SandboxFileError(`Upload failed [${upload.status}]`, "remote");
617
- }
618
- const action = existing ? "update" : "create";
619
- await client.request(
620
- "POST",
621
- "/agent/api/pipeline-sandbox/mfs-workspace/unified-push",
622
- {
623
- message: `moxt file write ${workspacePath}`,
624
- repoPushes: [
625
- {
626
- repoId: resolved.repoId,
627
- changes: [
628
- {
629
- action,
630
- ...existing?.fileId ? { fileId: existing.fileId } : {},
631
- path: resolved.repoPath,
632
- contentHash,
633
- ...baseContentHash ? { baseContentHash } : {},
634
- size: data.length,
635
- fileType: MFS_FILE_TYPE_REGULAR
636
- }
637
- ]
638
- }
639
- ],
640
- crossRepoMoves: []
641
- }
642
- );
643
- return { path: workspacePath, created: !existing };
644
- }
645
- function resolveBaseContentHash(workspacePath, existing, policy) {
646
- if (policy.type === "force") return void 0;
647
- if (policy.type === "use-current-sha") return existing?.sha ?? void 0;
648
- if (policy.type === "create-only") {
649
- if (!existing) return void 0;
650
- throw new SandboxFileError(
651
- `Existing file requires --base-sha or --force: ${workspacePath} (actual ${existing.sha})`,
652
- "conflict"
653
- );
654
- }
655
- if (existing?.sha === policy.sha) return policy.sha;
656
- throw new SandboxFileError(
657
- `File version conflict: ${workspacePath} (expected ${policy.sha}, actual ${existing?.sha ?? "missing"})`,
658
- "conflict"
659
- );
660
- }
661
- function resolveSandboxFilePath(context, workspacePath) {
662
- let resolved;
663
- try {
664
- resolved = resolveWorkspacePath(context.repoPayload, workspacePath);
665
- } catch (error) {
666
- throw new SandboxFileError(error instanceof Error ? error.message : String(error), "invalid-path");
667
- }
668
- if (!resolved.repoPath) {
669
- throw new SandboxFileError(`${workspacePath} is a directory`, "directory");
670
- }
671
- return resolved;
672
- }
673
- async function fetchRepoTree(context, resolved) {
674
- const client = new SandboxMoxtClient(context);
675
- const params = new URLSearchParams({ entryFilter: "paths", path: resolved.repoPath });
676
- const response = await client.request(
677
- "GET",
678
- `/agent/api/pipeline-sandbox/mfs/${encodeURIComponent(resolved.repoId)}/tree?${params.toString()}`
679
- );
680
- return response.data;
681
- }
682
- function findTreeEntry(tree, resolved) {
683
- return tree.entries.find((entry) => entry.path === resolved.repoPath);
684
- }
685
-
686
- // src/utils/spinner.ts
687
- var frames = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
688
- var intervalId = null;
689
- var frameIndex = 0;
690
- function startSpinner(message) {
691
- frameIndex = 0;
692
- process.stdout.write(`${frames[frameIndex]} ${message}`);
693
- intervalId = setInterval(() => {
694
- frameIndex = (frameIndex + 1) % frames.length;
695
- process.stdout.write(`\r${frames[frameIndex]} ${message}`);
696
- }, 80);
697
- }
698
- function stopSpinner(success, message) {
699
- if (intervalId) {
700
- clearInterval(intervalId);
701
- intervalId = null;
702
- }
703
- const symbol = success ? "\x1B[32m\u2713\x1B[0m" : "\x1B[31m\u2717\x1B[0m";
704
- process.stdout.write(`\x1B[2K\r${symbol} ${message ?? ""}
705
- `);
706
- }
707
-
708
- // src/utils/write-content.ts
709
- import * as fs from "fs";
710
- function readWriteCommandContentOrExit(options, readContentFile, readStdin = () => fs.readFileSync(0, "utf8")) {
711
- const inputModes = [options.content !== void 0, options.contentFile !== void 0, options.stdin === true].filter(Boolean);
712
- if (inputModes.length > 1) {
713
- printError("Only one of --content, --content-file, or --stdin can be used");
714
- process.exit(1);
715
- }
716
- if (options.content !== void 0) {
717
- return options.content;
718
- }
719
- if (options.contentFile !== void 0) {
720
- return readContentFile(options.contentFile);
721
- }
722
- return readStdin();
723
- }
724
-
725
- // src/commands/file.ts
726
- function addSpaceOptions(cmd) {
727
- return cmd.requiredOption("-w, --workspace <workspaceId>", "Workspace ID").option("-s, --space <name>", "Space name (personal or teamspace name)").option("--personal", "Use personal space (default)").option("--team-space-id <teamSpaceId>", "Team space ID").option("--teammate-id <teammateId>", "Teammate ID (teammate space)");
728
- }
729
- function registerFileCommand(program2) {
730
- const file = program2.command("file").description("File operations");
731
- addSpaceOptions(
732
- file.command("search").description("Search files in accessible spaces").argument("<query>", "Search query").option("-l, --limit <limit>", "Maximum number of files to return", "30").option("--mode <mode>", "Keyword matching mode: any or all", "any")
733
- ).action(async (query, options) => {
734
- const spaceParams = runOrExit(() => resolveSpaceSelector(options));
735
- const limit = runOrExit(() => parseSearchLimit(options.limit, 30, 80));
736
- const mode = runOrExit(() => parseSearchMode(options.mode));
737
- startSpinner("Searching files...");
738
- const response = await httpPost(
739
- `/workspaces/${options.workspace}/files/search`,
740
- {
741
- query,
742
- limit,
743
- mode,
744
- ...spaceParams
745
- }
746
- );
747
- if (!response.ok) {
748
- stopSpinner(false, "Failed");
749
- printError(`Error [${response.status}]: ${JSON.stringify(response.data)}`);
750
- process.exit(1);
751
- }
752
- const { items } = response.data;
753
- if (items.length === 0) {
754
- stopSpinner(true, "No files found.");
755
- return;
552
+ const { items } = response.data;
553
+ if (items.length === 0) {
554
+ stopSpinner(true, "No files found.");
555
+ return;
756
556
  }
757
557
  stopSpinner(true, `Found ${items.length} file(s)`);
758
558
  for (const item of items) {
@@ -768,7 +568,7 @@ function registerFileCommand(program2) {
768
568
  addSpaceOptions(
769
569
  file.command("get-url").description("Resolve the shareable browser URL for a file").requiredOption("-p, --path <path>", "File path")
770
570
  ).action(async (options) => {
771
- const spaceParams = runOrExit(() => resolveSpaceSelector(options));
571
+ const spaceParams = runOrExit2(() => resolveSpaceSelector(options));
772
572
  const qs = buildFileQueryString(options.path, spaceParams);
773
573
  startSpinner("Resolving URL...");
774
574
  const response = await httpGet(
@@ -791,7 +591,7 @@ function registerFileCommand(program2) {
791
591
  addSpaceOptions(
792
592
  file.command("list").description("List directory contents").option("-p, --path <path>", "Directory path", "/")
793
593
  ).action(async (options) => {
794
- const spaceParams = runOrExit(() => resolveSpaceSelector(options));
594
+ const spaceParams = runOrExit2(() => resolveSpaceSelector(options));
795
595
  const qs = buildFileQueryString(options.path, spaceParams);
796
596
  startSpinner("Listing files...");
797
597
  const response = await httpGet(
@@ -820,40 +620,8 @@ function registerFileCommand(program2) {
820
620
  }
821
621
  }
822
622
  });
823
- file.command("read").description("Read file content").argument("[workspacePath]", "Workspace path in sandbox mode, e.g. personal/docs/readme.md").option("-w, --workspace <workspaceId>", "Workspace ID").option("-s, --space <name>", "Space name (personal or teamspace name)").option("--personal", "Use personal space (default)").option("--team-space-id <teamSpaceId>", "Team space ID").option("--teammate-id <teammateId>", "Teammate ID (teammate space)").option("-p, --path <path>", "File path").option("-u, --url <url>", "Moxt file URL (e.g., https://moxt.ai/w/{workspaceId}/{fileId})").option("--json", "Print sandbox file content and metadata as JSON").action(async (workspacePath, options) => {
824
- const sandboxRuntime = resolveSandboxRuntimeContextOrExit();
825
- if (sandboxRuntime) {
826
- if (options.url) {
827
- printError("--url cannot be used in sandbox mode");
828
- process.exit(1);
829
- }
830
- const path2 = workspacePath ?? options.path;
831
- if (!path2) {
832
- printError("Workspace path is required in sandbox mode");
833
- process.exit(1);
834
- }
835
- if (!options.json) startSpinner("Reading file...");
836
- try {
837
- const result = await readSandboxFile(sandboxRuntime, path2);
838
- if (options.json) {
839
- print(JSON.stringify(result, null, 2));
840
- } else {
841
- stopSpinner(true, "Done");
842
- print(result.content);
843
- }
844
- } catch (error) {
845
- handleSandboxFileError(error, path2);
846
- }
847
- return;
848
- }
849
- if (options.json) {
850
- printError("--json is only available for sandbox workspace paths");
851
- process.exit(1);
852
- }
853
- if (workspacePath && !options.path) {
854
- options.path = workspacePath;
855
- }
856
- const mode = runOrExit(() => resolveReadMode(options));
623
+ file.command("read").description("Read file content").option("-w, --workspace <workspaceId>", "Workspace ID").option("-s, --space <name>", "Space name (personal or teamspace name)").option("--personal", "Use personal space (default)").option("--team-space-id <teamSpaceId>", "Team space ID").option("--teammate-id <teammateId>", "Teammate ID (teammate space)").option("-p, --path <path>", "File path").option("-u, --url <url>", "Moxt file URL (e.g., https://moxt.ai/w/{workspaceId}/{fileId})").action(async (options) => {
624
+ const mode = runOrExit2(() => resolveReadMode(options));
857
625
  startSpinner("Reading file...");
858
626
  let response;
859
627
  let displayPath;
@@ -864,7 +632,7 @@ function registerFileCommand(program2) {
864
632
  );
865
633
  } else {
866
634
  displayPath = mode.path;
867
- const spaceParams = runOrExit(() => resolveSpaceSelector(options));
635
+ const spaceParams = runOrExit2(() => resolveSpaceSelector(options));
868
636
  const qs = buildFileQueryString(mode.path, spaceParams);
869
637
  response = await httpGet(
870
638
  `/workspaces/${mode.workspace}/files/read${qs}`
@@ -888,37 +656,29 @@ function registerFileCommand(program2) {
888
656
  stopSpinner(true, "Done");
889
657
  print(content ?? "");
890
658
  });
891
- file.command("put").description("Upload a file").argument("[workspacePath]", "Workspace path in sandbox mode, e.g. personal/docs/readme.md").option("-w, --workspace <workspaceId>", "Workspace ID").option("-s, --space <name>", "Space name (personal or teamspace name)").option("--personal", "Use personal space (default)").option("--team-space-id <teamSpaceId>", "Team space ID").option("--teammate-id <teammateId>", "Teammate ID (teammate space)").option("-p, --path <path>", "Remote file path").option("-u, --url <url>", "Moxt file URL (e.g., https://moxt.ai/w/{workspaceId}/{fileId})").requiredOption("-l, --local-path <localPath>", "Local file path").option("-r, --recursive", "Create parent directories if needed").action(
892
- async (workspacePath, options) => {
893
- const sandboxRuntime = resolveSandboxRuntimeContextOrExit();
894
- if (sandboxRuntime) {
895
- if (options.url) {
896
- printError("--url cannot be used in sandbox mode");
897
- process.exit(1);
898
- }
899
- const path2 = workspacePath ?? options.path;
900
- if (!path2) {
901
- printError("Workspace path is required in sandbox mode");
902
- process.exit(1);
903
- }
904
- const { content: content2 } = readLocalTextFileOrExit(options.localPath);
905
- startSpinner("Uploading...");
906
- try {
907
- const response2 = await writeSandboxFile(sandboxRuntime, path2, content2, {
908
- type: "use-current-sha"
909
- });
910
- const action2 = response2.created ? "Created" : "Updated";
911
- stopSpinner(true, `${action2}: ${response2.path}`);
912
- } catch (error) {
913
- handleSandboxFileError(error, path2);
914
- }
915
- return;
659
+ file.command("put").description("Upload a file").option("-w, --workspace <workspaceId>", "Workspace ID").option("-s, --space <name>", "Space name (personal or teamspace name)").option("--personal", "Use personal space (default)").option("--team-space-id <teamSpaceId>", "Team space ID").option("--teammate-id <teammateId>", "Teammate ID (teammate space)").option("-p, --path <path>", "Remote file path").option("-u, --url <url>", "Moxt file URL (e.g., https://moxt.ai/w/{workspaceId}/{fileId})").requiredOption("-l, --local-path <localPath>", "Local file path").option("-r, --recursive", "Create parent directories if needed").action(
660
+ async (options) => {
661
+ const mode = runOrExit2(() => resolveWriteMode(options));
662
+ if (!fs2.existsSync(options.localPath)) {
663
+ print(`${colors.red}\u2620 Local file not found: ${options.localPath}${colors.reset}`);
664
+ process.exit(1);
916
665
  }
917
- if (workspacePath && !options.path) {
918
- options.path = workspacePath;
666
+ const stats = fs2.statSync(options.localPath);
667
+ if (!stats.isFile()) {
668
+ print(`${colors.red}\u2620 Not a file: ${options.localPath}${colors.reset}`);
669
+ process.exit(1);
670
+ }
671
+ const maxSize = 10 * 1024 * 1024;
672
+ if (stats.size > maxSize) {
673
+ print(`${colors.red}\u2620 File too large (max 10MB): ${options.localPath}${colors.reset}`);
674
+ process.exit(1);
675
+ }
676
+ const buffer2 = fs2.readFileSync(options.localPath);
677
+ if (isBinaryContent(buffer2)) {
678
+ print(`${colors.red}\u2620 Binary files are not allowed. Only text files can be uploaded.${colors.reset}`);
679
+ process.exit(1);
919
680
  }
920
- const mode = runOrExit(() => resolveWriteMode(options));
921
- const { content } = readLocalTextFileOrExit(options.localPath);
681
+ const content = buffer2.toString("utf8");
922
682
  startSpinner("Uploading...");
923
683
  let response;
924
684
  let displayPath;
@@ -930,7 +690,7 @@ function registerFileCommand(program2) {
930
690
  });
931
691
  } else {
932
692
  displayPath = mode.path;
933
- const spaceParams = runOrExit(() => resolveSpaceSelector(options));
693
+ const spaceParams = runOrExit2(() => resolveSpaceSelector(options));
934
694
  response = await httpPut(
935
695
  `/workspaces/${mode.workspace}/files/write`,
936
696
  {
@@ -960,28 +720,11 @@ function registerFileCommand(program2) {
960
720
  stopSpinner(true, `${action}: ${response.data.path}`);
961
721
  }
962
722
  );
963
- file.command("write").description("Write file content in sandbox mode").argument("<workspacePath>", "Workspace path, e.g. personal/docs/readme.md").option("--content <content>", "Text content to write").option("--content-file <contentFile>", "Local text file to write").option("--stdin", "Read text content from stdin (default)").option("--base-sha <sha>", "Write only if the remote file still has this SHA").option("--force", "Overwrite an existing remote file without a version condition").action(async (workspacePath, options) => {
964
- const runtime = resolveSandboxRuntimeContextOrExit();
965
- if (!runtime) {
966
- printError("file write is only available in sandbox mode");
967
- process.exit(1);
968
- }
969
- const policy = resolveSandboxFileWritePolicyOrExit(options);
970
- const content = readWriteCommandContentOrExit(options, (path2) => readLocalTextFileOrExit(path2).content);
971
- startSpinner("Writing file...");
972
- try {
973
- const response = await writeSandboxFile(runtime, workspacePath, content, policy);
974
- const action = response.created ? "Created" : "Updated";
975
- stopSpinner(true, `${action}: ${response.path}`);
976
- } catch (error) {
977
- handleSandboxFileError(error, workspacePath);
978
- }
979
- });
980
723
  addSpaceOptions(
981
724
  file.command("mkdir").description("Create a directory").requiredOption("-p, --path <path>", "Directory path").option("-r, --recursive", "Create parent directories if needed")
982
725
  ).action(
983
726
  async (options) => {
984
- const spaceParams = runOrExit(() => resolveSpaceSelector(options));
727
+ const spaceParams = runOrExit2(() => resolveSpaceSelector(options));
985
728
  startSpinner("Creating directory...");
986
729
  const response = await httpPost(
987
730
  `/workspaces/${options.workspace}/files/mkdir`,
@@ -1010,7 +753,7 @@ function registerFileCommand(program2) {
1010
753
  file.command("del").description("Delete a file or empty directory").requiredOption("-p, --path <path>", "File or directory path")
1011
754
  ).action(
1012
755
  async (options) => {
1013
- const spaceParams = runOrExit(() => resolveSpaceSelector(options));
756
+ const spaceParams = runOrExit2(() => resolveSpaceSelector(options));
1014
757
  startSpinner("Deleting...");
1015
758
  const response = await httpDelete(
1016
759
  `/workspaces/${options.workspace}/files/delete`,
@@ -1034,90 +777,14 @@ function registerFileCommand(program2) {
1034
777
  }
1035
778
  );
1036
779
  }
1037
- function resolveSandboxFileWritePolicyOrExit(options) {
1038
- if (options.baseSha && options.force) {
1039
- printError("Only one of --base-sha or --force can be used");
1040
- process.exit(1);
1041
- }
1042
- if (options.baseSha) {
1043
- if (!/^[a-f0-9]{64}$/.test(options.baseSha)) {
1044
- printError("--base-sha must be a lowercase SHA-256 hash");
1045
- process.exit(1);
1046
- }
1047
- return { type: "base-sha", sha: options.baseSha };
1048
- }
1049
- return options.force ? { type: "force" } : { type: "create-only" };
1050
- }
1051
- function resolveRuntimeContextOrExit() {
1052
- try {
1053
- return resolveRuntimeContext();
1054
- } catch (error) {
1055
- if (error instanceof RuntimeContextError) {
1056
- printError(error.message);
1057
- process.exit(1);
1058
- }
1059
- throw error;
1060
- }
1061
- }
1062
- function resolveSandboxRuntimeContextOrExit() {
1063
- if (!SANDBOX_ENV_KEYS.some((key) => (process.env[key] ?? "").trim().length > 0)) {
1064
- return void 0;
1065
- }
1066
- const context = resolveRuntimeContextOrExit();
1067
- return context.mode === "sandbox" ? context : void 0;
1068
- }
1069
- function readLocalTextFileOrExit(localPath) {
1070
- if (!fs2.existsSync(localPath)) {
1071
- print(`${colors.red}\u2620 Local file not found: ${localPath}${colors.reset}`);
1072
- process.exit(1);
1073
- }
1074
- const stats = fs2.statSync(localPath);
1075
- if (!stats.isFile()) {
1076
- print(`${colors.red}\u2620 Not a file: ${localPath}${colors.reset}`);
1077
- process.exit(1);
1078
- }
1079
- if (stats.size > MFS_FILE_SIZE_LIMIT_BYTES) {
1080
- print(`${colors.red}\u2620 File too large (max ${MFS_FILE_SIZE_LIMIT_LABEL}): ${localPath}${colors.reset}`);
1081
- process.exit(1);
1082
- }
1083
- const buffer2 = fs2.readFileSync(localPath);
1084
- if (isBinaryContent(buffer2)) {
1085
- print(`${colors.red}\u2620 Binary files are not allowed. Only text files can be uploaded.${colors.reset}`);
1086
- process.exit(1);
1087
- }
1088
- return { content: buffer2.toString("utf8") };
1089
- }
1090
- function handleSandboxFileError(error, displayPath) {
1091
- stopSpinner(false, "Failed");
1092
- if (error instanceof SandboxFileError) {
1093
- if (error.code === "not-found") {
1094
- print(`${colors.red}\u2620 ${displayPath} does not exist${colors.reset}`);
1095
- } else if (error.code === "directory") {
1096
- print(`${colors.red}\u2620 ${displayPath} is a directory${colors.reset}`);
1097
- } else {
1098
- printError(error.message);
1099
- }
1100
- process.exit(1);
1101
- }
1102
- if (error instanceof SandboxApiError) {
1103
- printError(`Sandbox API error [${error.status}]: ${error.body}`);
1104
- process.exit(1);
1105
- }
1106
- if (error instanceof Error) {
1107
- printError(`Sandbox file operation failed: ${error.message}`);
1108
- process.exit(1);
1109
- }
1110
- printError(`Sandbox file operation failed: ${String(error)}`);
1111
- process.exit(1);
1112
- }
1113
780
 
1114
781
  // src/commands/memory.ts
1115
782
  function registerMemoryCommand(program2) {
1116
783
  const memory = program2.command("memory").description("Memory operations");
1117
784
  memory.command("search").description("Search archived agent memory").requiredOption("-w, --workspace <workspaceId>", "Workspace ID").option("--teammate-id <teammateId>", "Search a managed AI teammate memory").option("-l, --limit <limit>", "Maximum number of memory chunks to return", "5").argument("<query>", "Search query").action(
1118
785
  async (query, options) => {
1119
- const limit = runOrExit(() => parseSearchLimit(options.limit, 5, 10));
1120
- const teammateId = runOrExit(() => parseTeammateId(options.teammateId));
786
+ const limit = runOrExit2(() => parseSearchLimit(options.limit, 5, 10));
787
+ const teammateId = runOrExit2(() => parseTeammateId(options.teammateId));
1121
788
  startSpinner("Searching memory...");
1122
789
  const response = await httpPost(
1123
790
  `/workspaces/${options.workspace}/memory/search`,
@@ -1223,7 +890,7 @@ function isAllowedMiniappDbUserEmail(email) {
1223
890
  }
1224
891
 
1225
892
  // src/commands/miniapp.ts
1226
- function runOrExit2(fn) {
893
+ function runOrExit3(fn) {
1227
894
  try {
1228
895
  return fn();
1229
896
  } catch (err) {
@@ -1283,14 +950,14 @@ function registerMiniappCommand(program2) {
1283
950
  addMiniappDbTargetOptions(
1284
951
  db.command("post").description("Create miniapp data through PostgREST").argument("<path>", "PostgREST path, for example /todos").requiredOption("--body <json>", "JSON object or array request body").option("--pretty", "Pretty-print JSON responses")
1285
952
  ).action(async (postgrestPath, options) => {
1286
- const body = runOrExit2(() => parseJsonBody(options.body ?? ""));
953
+ const body = runOrExit3(() => parseJsonBody(options.body ?? ""));
1287
954
  await requestDataApi("POST", postgrestPath, options, body);
1288
955
  });
1289
956
  addMiniappDbTargetOptions(
1290
957
  db.command("patch").description("Update miniapp data through PostgREST").argument("<path>", "PostgREST path with a filter, for example /todos?id=eq.1").requiredOption("--body <json>", "JSON object or array request body").option("--pretty", "Pretty-print JSON responses")
1291
958
  ).action(async (postgrestPath, options) => {
1292
- runOrExit2(() => assertPostgrestFilter(postgrestPath, "PATCH"));
1293
- const body = runOrExit2(() => parseJsonBody(options.body ?? ""));
959
+ runOrExit3(() => assertPostgrestFilter(postgrestPath, "PATCH"));
960
+ const body = runOrExit3(() => parseJsonBody(options.body ?? ""));
1294
961
  await requestDataApi("PATCH", postgrestPath, options, body);
1295
962
  });
1296
963
  addMiniappDbTargetOptions(
@@ -1300,1558 +967,1167 @@ function registerMiniappCommand(program2) {
1300
967
  printError("Error: delete requires --yes.");
1301
968
  process.exit(1);
1302
969
  }
1303
- runOrExit2(() => assertPostgrestFilter(postgrestPath, "DELETE"));
970
+ runOrExit3(() => assertPostgrestFilter(postgrestPath, "DELETE"));
1304
971
  await requestDataApi("DELETE", postgrestPath, options);
1305
972
  });
1306
973
  }
1307
974
 
1308
- // src/commands/pull.ts
1309
- import { isAbsolute as isAbsolute3 } from "path";
1310
- import { Option } from "commander";
1311
-
1312
- // src/utils/sandbox-pull.ts
1313
- import { createHash as createHash2 } from "crypto";
1314
- import { lstat, mkdir as mkdir2, readFile as readFile2, rm as rm2, writeFile as writeFile2 } from "fs/promises";
1315
- import { dirname as dirname2, isAbsolute as isAbsolute2, relative, resolve } from "path";
1316
-
1317
- // src/utils/checkout-state.ts
1318
- import { randomUUID } from "crypto";
1319
- import { homedir } from "os";
1320
- import { dirname, isAbsolute, join, posix } from "path";
1321
- import { mkdir, readFile, rename, rm, writeFile } from "fs/promises";
1322
- var CheckoutStateError = class extends Error {
1323
- constructor(message) {
1324
- super(message);
1325
- this.name = "CheckoutStateError";
1326
- }
1327
- };
1328
- function getCheckoutStatePath() {
1329
- return join(homedir(), ".moxt", "checkout-state.json");
975
+ // src/telemetry/config.ts
976
+ import * as fs3 from "fs";
977
+ import * as os from "os";
978
+ import * as path from "path";
979
+ function getConfigPath() {
980
+ return path.join(os.homedir(), ".moxt", "config.json");
1330
981
  }
1331
- async function loadCheckoutState(statePath = getCheckoutStatePath()) {
1332
- let raw;
1333
- try {
1334
- raw = await readFile(statePath, "utf8");
1335
- } catch (error) {
1336
- if (isNodeError(error) && error.code === "ENOENT") return void 0;
1337
- throw new CheckoutStateError(`Failed to read checkout state: ${errorMessage(error)}`);
1338
- }
1339
- let value;
982
+ function readTelemetryConfig() {
1340
983
  try {
1341
- value = JSON.parse(raw);
984
+ const raw = fs3.readFileSync(getConfigPath(), "utf8");
985
+ const parsed = JSON.parse(raw);
986
+ return parsed.telemetry ?? {};
1342
987
  } catch {
1343
- throw new CheckoutStateError(`Checkout state is not valid JSON: ${statePath}`);
988
+ return {};
1344
989
  }
1345
- return validateCheckoutState(value);
1346
990
  }
1347
- async function saveCheckoutState(state, statePath = getCheckoutStatePath()) {
1348
- const validState = validateCheckoutState(state);
1349
- const stateDir = dirname(statePath);
1350
- const temporaryPath = `${statePath}.${process.pid}.${randomUUID()}.tmp`;
1351
- await mkdir(stateDir, { recursive: true });
991
+ function writeTelemetryConfig(update) {
992
+ const configPath = getConfigPath();
993
+ let existing = {};
1352
994
  try {
1353
- await writeFile(temporaryPath, `${JSON.stringify(validState, null, 2)}
1354
- `, { mode: 384 });
1355
- await rename(temporaryPath, statePath);
1356
- } catch (error) {
1357
- await rm(temporaryPath, { force: true });
1358
- throw new CheckoutStateError(`Failed to save checkout state: ${errorMessage(error)}`);
1359
- }
1360
- }
1361
- function isCheckoutPathIncluded(state, workspacePath) {
1362
- if (state.mode === "full") return true;
1363
- return state.checkedOutScopes.some(
1364
- (scope) => workspacePath === scope || workspacePath.startsWith(`${scope}/`)
1365
- );
1366
- }
1367
- function doesCheckoutPathIntersect(state, workspacePath) {
1368
- if (state.mode === "full") return true;
1369
- return state.checkedOutScopes.some(
1370
- (scope) => workspacePath === scope || workspacePath.startsWith(`${scope}/`) || scope.startsWith(`${workspacePath}/`)
1371
- );
1372
- }
1373
- function validateCheckoutState(value) {
1374
- const state = requireRecord(value, "Checkout state");
1375
- if (state.version !== 1) {
1376
- throw new CheckoutStateError(`Unsupported checkout state version: ${String(state.version)}`);
1377
- }
1378
- const workspaceId = requireNonEmptyString(state.workspaceId, "workspaceId");
1379
- const checkoutRoot = requireNonEmptyString(state.checkoutRoot, "checkoutRoot");
1380
- if (!isAbsolute(checkoutRoot)) throw new CheckoutStateError("checkoutRoot must be an absolute path");
1381
- if (state.mode !== "partial" && state.mode !== "full") {
1382
- throw new CheckoutStateError("mode must be partial or full");
1383
- }
1384
- if (!Array.isArray(state.checkedOutScopes)) {
1385
- throw new CheckoutStateError("checkedOutScopes must be an array");
1386
- }
1387
- const checkedOutScopes = state.checkedOutScopes.map(
1388
- (scope, index) => requireWorkspacePath(scope, `checkedOutScopes[${index}]`)
1389
- );
1390
- if (new Set(checkedOutScopes).size !== checkedOutScopes.length) {
1391
- throw new CheckoutStateError("checkedOutScopes must not contain duplicates");
1392
- }
1393
- const reposValue = requireRecord(state.repos, "repos");
1394
- const repos = {};
1395
- for (const [repoId, rawRepo] of Object.entries(reposValue)) {
1396
- requireNonEmptyString(repoId, "repo id");
1397
- const repo = requireRecord(rawRepo, `repos.${repoId}`);
1398
- const name = requireRepoName(repo.name, `repos.${repoId}.name`);
1399
- repos[repoId] = {
1400
- name,
1401
- treeSha: requireNonEmptyString(repo.treeSha, `repos.${repoId}.treeSha`)
1402
- };
1403
- }
1404
- const filesValue = requireRecord(state.files, "files");
1405
- const files = {};
1406
- for (const [workspacePath, rawFile] of Object.entries(filesValue)) {
1407
- requireWorkspacePath(workspacePath, `files.${workspacePath}`);
1408
- const file = requireRecord(rawFile, `files.${workspacePath}`);
1409
- const repoId = requireNonEmptyString(file.repoId, `files.${workspacePath}.repoId`);
1410
- const repo = repos[repoId];
1411
- if (!repo) throw new CheckoutStateError(`File references unknown repo: ${workspacePath}`);
1412
- const repoPath = requireWorkspacePath(file.repoPath, `files.${workspacePath}.repoPath`);
1413
- if (workspacePath !== `${repo.name}/${repoPath}`) {
1414
- throw new CheckoutStateError(`File path does not match its repo: ${workspacePath}`);
1415
- }
1416
- const fileId = file.fileId === null ? null : requireNonEmptyString(file.fileId, `files.${workspacePath}.fileId`);
1417
- const sha = requireNonEmptyString(file.sha, `files.${workspacePath}.sha`);
1418
- if (!/^[a-f0-9]{64}$/.test(sha)) throw new CheckoutStateError(`Invalid file SHA: ${workspacePath}`);
1419
- files[workspacePath] = {
1420
- repoId,
1421
- repoPath,
1422
- fileId,
1423
- sha,
1424
- size: requireNonNegativeInteger(file.size, `files.${workspacePath}.size`),
1425
- fileType: requireNonNegativeInteger(file.fileType, `files.${workspacePath}.fileType`)
1426
- };
1427
- }
1428
- return {
1429
- version: 1,
1430
- workspaceId,
1431
- checkoutRoot,
1432
- mode: state.mode,
1433
- checkedOutScopes,
1434
- repos,
1435
- files
1436
- };
1437
- }
1438
- function requireRecord(value, field) {
1439
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
1440
- throw new CheckoutStateError(`${field} must be an object`);
995
+ existing = JSON.parse(fs3.readFileSync(configPath, "utf8"));
996
+ } catch {
997
+ existing = {};
1441
998
  }
1442
- return value;
999
+ const currentTelemetry = existing.telemetry ?? {};
1000
+ const next = { ...existing, telemetry: { ...currentTelemetry, ...update } };
1001
+ fs3.mkdirSync(path.dirname(configPath), { recursive: true });
1002
+ fs3.writeFileSync(configPath, JSON.stringify(next, null, 2));
1443
1003
  }
1444
- function requireNonEmptyString(value, field) {
1445
- if (typeof value !== "string" || value.length === 0) {
1446
- throw new CheckoutStateError(`${field} must be a non-empty string`);
1447
- }
1448
- return value;
1004
+
1005
+ // src/telemetry/opt-out.ts
1006
+ function isCiEnvironment() {
1007
+ return process.env.CI === "true" || process.env.CI === "1";
1449
1008
  }
1450
- function requireWorkspacePath(value, field) {
1451
- const path2 = requireNonEmptyString(value, field);
1452
- if (path2.includes("\\") || path2.includes("\0") || posix.isAbsolute(path2) || posix.normalize(path2) !== path2) {
1453
- throw new CheckoutStateError(`${field} must be a normalized relative path`);
1009
+ function isTelemetryDisabled() {
1010
+ if (process.env.MOXT_TELEMETRY_DISABLED === "1" || process.env.MOXT_TELEMETRY_DISABLED === "true") {
1011
+ return true;
1454
1012
  }
1455
- return path2;
1456
- }
1457
- function requireRepoName(value, field) {
1458
- const name = requireWorkspacePath(value, field);
1459
- if (name.includes("/")) throw new CheckoutStateError(`${field} must be a single path segment`);
1460
- return name;
1461
- }
1462
- function requireNonNegativeInteger(value, field) {
1463
- if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
1464
- throw new CheckoutStateError(`${field} must be a non-negative integer`);
1013
+ if (process.env.DO_NOT_TRACK === "1" || process.env.DO_NOT_TRACK === "true") {
1014
+ return true;
1465
1015
  }
1466
- return value;
1467
- }
1468
- function isNodeError(error) {
1469
- return error instanceof Error && "code" in error;
1470
- }
1471
- function errorMessage(error) {
1472
- return error instanceof Error ? error.message : String(error);
1016
+ return readTelemetryConfig().disabled === true;
1473
1017
  }
1474
1018
 
1475
- // src/utils/sandbox-pull.ts
1476
- var SandboxPullError = class extends Error {
1477
- constructor(message, code) {
1478
- super(message);
1479
- this.code = code;
1480
- this.name = "SandboxPullError";
1481
- }
1482
- code;
1483
- };
1484
- async function pullSandboxWorkspace(context, targetDir, options = {}) {
1485
- const resolvedTargetDir = resolve(targetDir);
1486
- const repos = listRepoEntries(context.repoPayload);
1487
- const previousState = await loadCheckoutState();
1488
- const compatibleState = resolveCompatibleState(previousState, context, resolvedTargetDir, repos);
1489
- const previousFiles = compatibleState?.files ?? {};
1490
- const selection = resolvePullSelection(context, options.scopes);
1491
- const repoById = new Map(selection.repos.map((repo) => [repo.repoId, repo]));
1492
- const tree = await fetchWorkspaceTree(context, selection);
1493
- const stateRepos = resolveStateRepos(tree, selection.repos);
1494
- const entries = [...tree.entries].sort((a, b) => a.prefixedPath.localeCompare(b.prefixedPath));
1495
- const directoryPaths = selection.repos.map((repo) => ({
1496
- workspacePath: repo.name,
1497
- localPath: resolveSafeLocalPath(resolvedTargetDir, repo.name, "")
1498
- }));
1499
- const blobEntries = [];
1500
- const remoteBlobPaths = /* @__PURE__ */ new Set();
1501
- const remotePaths = /* @__PURE__ */ new Set();
1502
- const matchedScopes = /* @__PURE__ */ new Set();
1503
- for (const entry of entries) {
1504
- const repo = repoById.get(entry.repoId);
1505
- if (!repo) {
1506
- throw new SandboxPullError(`Remote tree returned unknown repo: ${entry.repoId}`, "remote");
1507
- }
1508
- const expectedWorkspacePath = entry.path ? `${repo.name}/${entry.path}` : repo.name;
1509
- if (entry.prefixedPath !== expectedWorkspacePath) {
1510
- throw new SandboxPullError(`Remote tree returned inconsistent path: ${entry.prefixedPath}`, "remote");
1511
- }
1512
- if (remotePaths.has(expectedWorkspacePath)) {
1513
- throw new SandboxPullError(`Remote tree returned duplicate path: ${expectedWorkspacePath}`, "remote");
1514
- }
1515
- remotePaths.add(expectedWorkspacePath);
1516
- const localPath = resolveSafeLocalPath(resolvedTargetDir, repo.name, entry.path);
1517
- for (const scope of selection.scopes) {
1518
- if (pathIsIncluded(scope, expectedWorkspacePath)) matchedScopes.add(scope);
1519
- }
1520
- if (!selection.scopes.some((scope) => pathsIntersect(scope, expectedWorkspacePath))) continue;
1521
- if (entry.type === "tree") {
1522
- directoryPaths.push({ workspacePath: entry.prefixedPath, localPath });
1523
- continue;
1524
- }
1525
- if (!entry.sha) {
1526
- throw new SandboxPullError(`${entry.prefixedPath} does not have downloadable content`, "remote");
1527
- }
1528
- if (entry.fileId !== null && (typeof entry.fileId !== "string" || entry.fileId.length === 0)) {
1529
- throw new SandboxPullError(`${entry.prefixedPath} has invalid file metadata`, "remote");
1530
- }
1531
- if (entry.fileType !== null && (!Number.isInteger(entry.fileType) || entry.fileType < 0)) {
1532
- throw new SandboxPullError(`${entry.prefixedPath} has invalid file metadata`, "remote");
1533
- }
1534
- if (entry.size !== null && (!Number.isInteger(entry.size) || entry.size < 0)) {
1535
- throw new SandboxPullError(`${entry.prefixedPath} has invalid file metadata`, "remote");
1536
- }
1537
- if (entry.size !== null && entry.size > MFS_FILE_SIZE_LIMIT_BYTES) {
1538
- throw new SandboxPullError(`File too large (max ${MFS_FILE_SIZE_LIMIT_LABEL}): ${entry.prefixedPath}`, "too-large");
1539
- }
1540
- remoteBlobPaths.add(entry.prefixedPath);
1541
- blobEntries.push({ entry, localPath });
1542
- }
1543
- if (selection.mode === "partial") {
1544
- for (const scope of selection.scopes) {
1545
- const repoRoot = selection.repos.some((repo) => repo.name === scope);
1546
- const previouslyTracked = Object.keys(previousFiles).some((workspacePath) => pathIsIncluded(scope, workspacePath));
1547
- if (!repoRoot && !matchedScopes.has(scope) && !previouslyTracked) {
1548
- throw new SandboxPullError(`Workspace path does not exist: ${scope}`, "invalid-path");
1549
- }
1019
+ // src/commands/telemetry.ts
1020
+ function registerTelemetryCommand(program2) {
1021
+ const telemetry = program2.command("telemetry").description("Manage Moxt CLI telemetry");
1022
+ telemetry.command("status").description("Show telemetry status").action(() => {
1023
+ const cfg = readTelemetryConfig();
1024
+ const envOverride = process.env.MOXT_TELEMETRY_DISABLED === "1" || process.env.MOXT_TELEMETRY_DISABLED === "true" || process.env.DO_NOT_TRACK === "1" || process.env.DO_NOT_TRACK === "true";
1025
+ const state = isTelemetryDisabled() ? "disabled" : "enabled";
1026
+ print(`telemetry: ${state}`);
1027
+ if (envOverride) {
1028
+ print("(disabled via environment variable)");
1029
+ } else if (cfg.disabled) {
1030
+ print("(disabled via config file)");
1550
1031
  }
1551
- }
1552
- const plannedBlobs = [];
1553
- for (const { entry, localPath } of blobEntries) {
1554
- plannedBlobs.push({
1555
- entry,
1556
- localPath,
1557
- shouldWrite: await shouldWritePulledFile(
1558
- localPath,
1559
- requireBlobSha(entry),
1560
- entry.prefixedPath,
1561
- previousFiles[entry.prefixedPath],
1562
- Boolean(options.force)
1563
- )
1564
- });
1565
- }
1566
- const plannedDeletions = await planRemoteDeletions(
1567
- compatibleState,
1568
- selection,
1569
- resolvedTargetDir,
1570
- remoteBlobPaths,
1571
- Boolean(options.force)
1572
- );
1573
- for (const directory of directoryPaths) {
1574
- await assertCanCreateDirectory(directory.localPath, directory.workspacePath);
1575
- }
1576
- const blobsToDownload = plannedBlobs.filter((planned) => planned.shouldWrite);
1577
- const downloadUrls = await presignDownloads(context, blobsToDownload.map(({ entry }) => entry));
1578
- const downloadedEntries = [];
1579
- for (const planned of blobsToDownload) {
1580
- const { entry, localPath } = planned;
1581
- const remoteContent = await downloadBlob(entry, downloadUrls);
1582
- downloadedEntries.push({ ...planned, content: remoteContent });
1583
- }
1584
- for (const deletion of plannedDeletions) {
1585
- await rm2(deletion.localPath);
1586
- }
1587
- for (const directory of directoryPaths) {
1588
- await mkdir2(directory.localPath, { recursive: true });
1589
- }
1590
- for (const downloaded of downloadedEntries) {
1591
- await writePulledFile(downloaded.localPath, downloaded.content);
1592
- }
1593
- const downloadedContentByPath = new Map(
1594
- downloadedEntries.map(({ entry, content }) => [entry.prefixedPath, content])
1595
- );
1596
- const pulledFiles = Object.fromEntries(await Promise.all(plannedBlobs.map(async ({ entry, localPath }) => {
1597
- const content = downloadedContentByPath.get(entry.prefixedPath);
1598
- return [
1599
- entry.prefixedPath,
1600
- {
1601
- repoId: entry.repoId,
1602
- repoPath: entry.path,
1603
- fileId: entry.fileId,
1604
- sha: requireBlobSha(entry),
1605
- size: content?.length ?? entry.size ?? previousFiles[entry.prefixedPath]?.size ?? await localFileSize(localPath, entry.prefixedPath),
1606
- fileType: entry.fileType ?? 1
1607
- }
1608
- ];
1609
- })));
1610
- await saveCheckoutState(buildNextCheckoutState({
1611
- context,
1612
- checkoutRoot: resolvedTargetDir,
1613
- allRepos: repos,
1614
- selection,
1615
- previousState: compatibleState,
1616
- pulledRepos: stateRepos,
1617
- pulledFiles
1618
- }));
1619
- return {
1620
- fileCount: blobEntries.length,
1621
- targetDir: resolvedTargetDir
1622
- };
1032
+ });
1033
+ telemetry.command("enable").description("Enable telemetry").action(() => {
1034
+ writeTelemetryConfig({ disabled: false });
1035
+ print("telemetry: enabled");
1036
+ });
1037
+ telemetry.command("disable").description("Disable telemetry").action(() => {
1038
+ writeTelemetryConfig({ disabled: true });
1039
+ print("telemetry: disabled");
1040
+ });
1623
1041
  }
1624
- function resolveCompatibleState(state, context, checkoutRoot, repos) {
1625
- if (!state || state.workspaceId !== context.workspaceId || state.checkoutRoot !== checkoutRoot) return void 0;
1626
- const currentRepos = new Map(repos.map((repo) => [repo.repoId, repo.name]));
1627
- const stateMatchesRepos = Object.entries(state.repos).every(
1628
- ([repoId, repo]) => currentRepos.get(repoId) === repo.name
1629
- );
1630
- return stateMatchesRepos ? state : void 0;
1631
- }
1632
- function resolvePullSelection(context, requestedScopes) {
1633
- const repos = listRepoEntries(context.repoPayload);
1634
- if (!requestedScopes || requestedScopes.length === 0) {
1635
- return { mode: "full", scopes: repos.map((repo) => repo.name), repos };
1636
- }
1637
- const resolvedScopes = requestedScopes.map((scope) => {
1638
- const resolved = resolveWorkspacePath(context.repoPayload, scope);
1639
- return {
1640
- path: resolved.repoPath ? `${resolved.repoName}/${resolved.repoPath}` : resolved.repoName,
1641
- repoId: resolved.repoId
1642
- };
1042
+
1043
+ // src/commands/whoami.ts
1044
+ function registerWhoamiCommand(program2) {
1045
+ program2.command("whoami").description("Show current user information").action(async () => {
1046
+ startSpinner("Fetching user info...");
1047
+ const response = await httpGet("/users/whoami");
1048
+ if (response.ok) {
1049
+ stopSpinner(true, "Done");
1050
+ print(`${colors.bold}Nickname${colors.reset}: ${response.data.nickname}`);
1051
+ print(`${colors.bold}Email${colors.reset}: ${response.data.email}`);
1052
+ } else {
1053
+ stopSpinner(false, "Failed");
1054
+ printError(`Error [${response.status}]: ${JSON.stringify(response.data)}`);
1055
+ process.exit(1);
1056
+ }
1643
1057
  });
1644
- const scopes = collapseScopes(resolvedScopes.map((scope) => scope.path));
1645
- const repoIds = new Set(resolvedScopes.map((scope) => scope.repoId));
1646
- return {
1647
- mode: "partial",
1648
- scopes,
1649
- repos: repos.filter((repo) => repoIds.has(repo.repoId))
1650
- };
1651
1058
  }
1652
- function buildNextCheckoutState(input) {
1653
- const { context, checkoutRoot, allRepos, selection, previousState, pulledRepos, pulledFiles } = input;
1654
- if (selection.mode === "full") {
1655
- return {
1656
- version: 1,
1657
- workspaceId: context.workspaceId,
1658
- checkoutRoot,
1659
- mode: "full",
1660
- checkedOutScopes: selection.scopes,
1661
- repos: pulledRepos,
1662
- files: pulledFiles
1663
- };
1664
- }
1665
- const previousFiles = previousState?.files ?? {};
1666
- const preservedFiles = Object.fromEntries(
1667
- Object.entries(previousFiles).filter(
1668
- ([workspacePath]) => !selection.scopes.some((scope) => pathIsIncluded(scope, workspacePath))
1669
- )
1670
- );
1671
- const checkedOutScopes = collapseScopes([...previousState?.checkedOutScopes ?? [], ...selection.scopes]);
1672
- const mode = allRepos.every((repo) => checkedOutScopes.includes(repo.name)) ? "full" : "partial";
1673
- return {
1674
- version: 1,
1675
- workspaceId: context.workspaceId,
1676
- checkoutRoot,
1677
- mode,
1678
- checkedOutScopes,
1679
- repos: { ...previousState?.repos ?? {}, ...pulledRepos },
1680
- files: { ...preservedFiles, ...pulledFiles }
1681
- };
1059
+
1060
+ // src/commands/workspace.ts
1061
+ var emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
1062
+ function isValidEmail(email) {
1063
+ return emailRegex.test(email);
1682
1064
  }
1683
- function collapseScopes(scopes) {
1684
- const result = [];
1685
- for (const scope of scopes) {
1686
- if (result.some((existing) => pathIsIncluded(existing, scope))) continue;
1687
- for (let index = result.length - 1; index >= 0; index--) {
1688
- if (pathIsIncluded(scope, result[index])) result.splice(index, 1);
1065
+ function registerWorkspaceCommand(program2) {
1066
+ const workspace = program2.command("workspace").description("Workspace management");
1067
+ workspace.command("list").description("List all workspaces you belong to").action(async () => {
1068
+ startSpinner("Fetching workspaces...");
1069
+ const response = await httpGet("/workspaces");
1070
+ if (response.ok) {
1071
+ const { workspaces } = response.data;
1072
+ if (workspaces.length === 0) {
1073
+ stopSpinner(true, "No workspaces found.");
1074
+ return;
1075
+ }
1076
+ stopSpinner(true, `Found ${workspaces.length} workspace(s)`);
1077
+ for (const ws of workspaces) {
1078
+ print(`${colors.bold}${ws.workspaceId}${colors.reset} ${ws.name}`);
1079
+ }
1080
+ } else {
1081
+ stopSpinner(false, "Failed");
1082
+ printError(`Error [${response.status}]: ${JSON.stringify(response.data)}`);
1083
+ process.exit(1);
1689
1084
  }
1690
- result.push(scope);
1691
- }
1692
- return result;
1693
- }
1694
- function pathsIntersect(left, right) {
1695
- return pathIsIncluded(left, right) || pathIsIncluded(right, left);
1696
- }
1697
- function pathIsIncluded(scope, workspacePath) {
1698
- return workspacePath === scope || workspacePath.startsWith(`${scope}/`);
1699
- }
1700
- function resolveStateRepos(tree, repos) {
1701
- const knownRepoIds = new Set(repos.map((repo) => repo.repoId));
1702
- for (const repoId of Object.keys(tree.repos)) {
1703
- if (!knownRepoIds.has(repoId)) {
1704
- throw new SandboxPullError(`Remote tree returned unknown repo: ${repoId}`, "remote");
1085
+ });
1086
+ const members = program2.command("members").description("Manage workspace members");
1087
+ members.command("list").description("List all members in a workspace").requiredOption("-w, --workspace <workspaceId>", "Workspace ID").action(async (options) => {
1088
+ const { workspace: workspaceId } = options;
1089
+ startSpinner("Fetching workspace members...");
1090
+ const response = await httpGet(`/workspaces/${workspaceId}/members`);
1091
+ if (!response.ok) {
1092
+ stopSpinner(false, "Failed to fetch members");
1093
+ printError(`Error [${response.status}]: ${JSON.stringify(response.data)}`);
1094
+ process.exit(1);
1705
1095
  }
1706
- }
1707
- return Object.fromEntries(repos.map((repo) => {
1708
- const remoteRepo = tree.repos[repo.repoId];
1709
- if (!remoteRepo) {
1710
- throw new SandboxPullError(`Remote tree did not return repo: ${repo.repoId}`, "remote");
1096
+ const { members: members2 } = response.data;
1097
+ if (members2.length === 0) {
1098
+ stopSpinner(true, "No members found.");
1099
+ return;
1711
1100
  }
1712
- if (remoteRepo.prefix !== repo.name) {
1713
- throw new SandboxPullError(`Remote tree returned unexpected prefix for repo ${repo.repoId}`, "remote");
1101
+ stopSpinner(true, `Found ${members2.length} member(s)`);
1102
+ for (const member of members2) {
1103
+ const name = member.displayName || "-";
1104
+ print(`${colors.bold}${member.email}${colors.reset} ${name} ${member.role}`);
1714
1105
  }
1715
- if (typeof remoteRepo.sha !== "string" || remoteRepo.sha.length === 0) {
1716
- throw new SandboxPullError(`Remote tree did not return a version for repo ${repo.repoId}`, "remote");
1106
+ });
1107
+ members.command("remove").description("Remove members from a workspace").requiredOption("-w, --workspace <workspaceId>", "Workspace ID").argument("<emails>", "Emails to remove (comma-separated)").action(async (emailsArg, options) => {
1108
+ const { workspace: workspaceId } = options;
1109
+ const emails = emailsArg.split(",").map((e) => e.trim()).filter(Boolean);
1110
+ if (emails.length === 0) {
1111
+ printError("No valid emails provided");
1112
+ process.exit(1);
1717
1113
  }
1718
- return [repo.repoId, { name: repo.name, treeSha: remoteRepo.sha }];
1719
- }));
1720
- }
1721
- async function fetchWorkspaceTree(context, selection) {
1722
- const client = new SandboxMoxtClient(context);
1723
- const results = await Promise.all(selection.repos.map(async (repo) => {
1724
- const filter = resolveRepoTreeFilter(repo, selection);
1725
- const params = new URLSearchParams({ entryFilter: filter.type });
1726
- if (filter.type === "paths") {
1727
- for (const path2 of filter.paths) params.append("path", path2);
1114
+ const invalidEmails = emails.filter((e) => !isValidEmail(e));
1115
+ if (invalidEmails.length > 0) {
1116
+ for (const email of invalidEmails) {
1117
+ printError(`Invalid email format: ${email}`);
1118
+ }
1119
+ process.exit(1);
1728
1120
  }
1729
- const response = await client.request(
1730
- "GET",
1731
- `/agent/api/pipeline-sandbox/mfs/${encodeURIComponent(repo.repoId)}/tree?${params.toString()}`
1732
- );
1733
- return { repo, tree: response.data };
1734
- }));
1735
- return {
1736
- repos: Object.fromEntries(
1737
- results.map(({ repo, tree }) => [repo.repoId, { sha: tree.sha, prefix: repo.name }])
1738
- ),
1739
- entries: results.flatMap(
1740
- ({ repo, tree }) => tree.entries.map((entry) => ({
1741
- ...entry,
1742
- repoId: repo.repoId,
1743
- prefixedPath: entry.path ? `${repo.name}/${entry.path}` : repo.name
1744
- }))
1745
- )
1746
- };
1747
- }
1748
- function resolveRepoTreeFilter(repo, selection) {
1749
- if (selection.mode === "full") return { type: "all" };
1750
- const repoPaths = [];
1751
- for (const scope of selection.scopes) {
1752
- if (scope === repo.name) return { type: "all" };
1753
- if (scope.startsWith(`${repo.name}/`)) repoPaths.push(scope.slice(repo.name.length + 1));
1754
- }
1755
- if (repoPaths.length === 0) {
1756
- throw new SandboxPullError(`No pull scope resolved for repo: ${repo.name}`, "invalid-path");
1757
- }
1758
- return { type: "paths", paths: repoPaths };
1759
- }
1760
- async function presignDownloads(context, entries) {
1761
- const client = new SandboxMoxtClient(context);
1762
- const entriesByRepo = /* @__PURE__ */ new Map();
1763
- for (const entry of entries) {
1764
- const existing = entriesByRepo.get(entry.repoId) ?? [];
1765
- existing.push(entry);
1766
- entriesByRepo.set(entry.repoId, existing);
1767
- }
1768
- const urls = /* @__PURE__ */ new Map();
1769
- for (const [repoId, repoEntries] of entriesByRepo) {
1770
- const hashes = [...new Set(repoEntries.map((entry) => entry.sha).filter((sha) => Boolean(sha)))];
1771
- if (hashes.length === 0) continue;
1772
- const response = await client.request(
1773
- "POST",
1774
- `/agent/api/pipeline-sandbox/mfs/${encodeURIComponent(repoId)}/presign-download`,
1775
- { hashes }
1776
- );
1777
- for (const hash of hashes) {
1778
- const url = response.data.urls[hash];
1779
- if (!url) {
1780
- throw new SandboxPullError(`No download URL returned for ${hash}`, "remote");
1121
+ startSpinner("Fetching workspace members...");
1122
+ const membersResponse = await httpGet(`/workspaces/${workspaceId}/members`);
1123
+ if (!membersResponse.ok) {
1124
+ stopSpinner(false, "Failed to fetch members");
1125
+ printError(`Error [${membersResponse.status}]: ${JSON.stringify(membersResponse.data)}`);
1126
+ process.exit(1);
1127
+ }
1128
+ stopSpinner(true, "Members fetched");
1129
+ const { members: members2 } = membersResponse.data;
1130
+ const emailToMember = new Map(members2.map((m) => [m.email.toLowerCase(), m]));
1131
+ for (const email of emails) {
1132
+ const member = emailToMember.get(email.toLowerCase());
1133
+ if (!member) {
1134
+ print(`${colors.red}\u2717${colors.reset} ${email} - not found in workspace`);
1135
+ continue;
1136
+ }
1137
+ const deleteResponse = await httpDelete(`/workspaces/${workspaceId}/members/${member.userId}`);
1138
+ if (deleteResponse.ok) {
1139
+ print(`${colors.green}\u2713${colors.reset} ${email} - removed`);
1140
+ } else {
1141
+ print(`${colors.red}\u2717${colors.reset} ${email} - failed: ${JSON.stringify(deleteResponse.data)}`);
1781
1142
  }
1782
- urls.set(hash, url);
1783
1143
  }
1784
- }
1785
- return urls;
1786
- }
1787
- async function downloadBlob(entry, urls) {
1788
- const hash = entry.sha;
1789
- if (!hash) {
1790
- throw new SandboxPullError(`${entry.prefixedPath} does not have downloadable content`, "remote");
1791
- }
1792
- const url = urls.get(hash);
1793
- if (!url) {
1794
- throw new SandboxPullError(`No download URL returned for ${entry.prefixedPath}`, "remote");
1795
- }
1796
- const response = await fetch(url);
1797
- if (!response.ok) {
1798
- throw new SandboxPullError(`Download failed [${response.status}]: ${entry.prefixedPath}`, "remote");
1799
- }
1800
- const content = Buffer.from(await response.arrayBuffer());
1801
- if (content.length > MFS_FILE_SIZE_LIMIT_BYTES) {
1802
- throw new SandboxPullError(`File too large (max ${MFS_FILE_SIZE_LIMIT_LABEL}): ${entry.prefixedPath}`, "too-large");
1803
- }
1804
- const contentHash = createHash2("sha256").update(content).digest("hex");
1805
- if (contentHash !== hash) {
1806
- throw new SandboxPullError(`Downloaded content hash mismatch: ${entry.prefixedPath}`, "remote");
1807
- }
1808
- return content;
1809
- }
1810
- async function shouldWritePulledFile(localPath, remoteContentHash, workspacePath, previousFile, force) {
1811
- const existing = await lstat(localPath).catch((error) => {
1812
- if (isNodeError2(error) && error.code === "ENOENT") return void 0;
1813
- throw error;
1814
1144
  });
1815
- if (existing && !existing.isFile()) {
1816
- throw new SandboxPullError(`Refusing to overwrite local path: ${workspacePath}`, "directory-conflict");
1817
- }
1818
- if (!existing) {
1819
- if (force || !previousFile) return true;
1820
- if (remoteContentHash === previousFile.sha) return false;
1821
- throw new SandboxPullError(
1822
- `Local file was deleted but remote file changed: ${workspacePath}`,
1823
- "file-conflict"
1824
- );
1825
- }
1826
- if (force) return true;
1827
- const localContentHash = await hashLocalFile(localPath);
1828
- if (localContentHash === remoteContentHash) return false;
1829
- if (!previousFile) {
1830
- throw new SandboxPullError(
1831
- `Refusing to overwrite modified local file: ${workspacePath}. Use --force to overwrite.`,
1832
- "file-conflict"
1833
- );
1834
- }
1835
- const localChanged = localContentHash !== previousFile.sha;
1836
- const remoteChanged = remoteContentHash !== previousFile.sha;
1837
- if (!localChanged && remoteChanged) return true;
1838
- if (localChanged && !remoteChanged) return false;
1839
- if (localChanged && remoteChanged) {
1840
- throw new SandboxPullError(
1841
- `Local and remote file both changed since checkout: ${workspacePath}`,
1842
- "file-conflict"
1145
+ const space = program2.command("space").description("Manage spaces");
1146
+ space.command("list").description("List accessible spaces in a workspace").requiredOption("-w, --workspace <workspaceId>", "Workspace ID").action(async (options) => {
1147
+ const { workspace: workspaceId } = options;
1148
+ startSpinner("Fetching spaces...");
1149
+ const response = await httpGet(`/workspaces/${workspaceId}/spaces`);
1150
+ if (!response.ok) {
1151
+ stopSpinner(false, "Failed to fetch spaces");
1152
+ printError(`Error [${response.status}]: ${JSON.stringify(response.data)}`);
1153
+ process.exit(1);
1154
+ }
1155
+ const { spaces } = response.data;
1156
+ if (spaces.length === 0) {
1157
+ stopSpinner(true, "No spaces found.");
1158
+ return;
1159
+ }
1160
+ stopSpinner(true, `Found ${spaces.length} space(s)`);
1161
+ print(
1162
+ `${colors.bold}TYPE${colors.reset} ${colors.bold}TEAM_SPACE_ID${colors.reset} ${colors.bold}TEAMMATE_ID${colors.reset} ${colors.bold}NAME${colors.reset}`
1843
1163
  );
1844
- }
1845
- return false;
1164
+ for (const s of spaces) {
1165
+ const teamSpaceId = s.teamSpaceId ?? "";
1166
+ const teammateId = s.agentId != null ? String(s.agentId) : "";
1167
+ print(`${s.type} ${teamSpaceId} ${teammateId} ${s.name}`);
1168
+ }
1169
+ });
1846
1170
  }
1847
- async function planRemoteDeletions(previousState, selection, checkoutRoot, remoteBlobPaths, force) {
1848
- if (!previousState) return [];
1849
- const deletions = [];
1850
- for (const [workspacePath, previousFile] of Object.entries(previousState.files)) {
1851
- if (!selection.scopes.some((scope) => pathIsIncluded(scope, workspacePath))) continue;
1852
- if (remoteBlobPaths.has(workspacePath)) continue;
1853
- const repo = previousState.repos[previousFile.repoId];
1854
- if (!repo) {
1855
- throw new SandboxPullError(`Checkout baseline references unknown repo: ${workspacePath}`, "remote");
1856
- }
1857
- const localPath = resolveSafeLocalPath(checkoutRoot, repo.name, previousFile.repoPath);
1858
- const existing = await lstat(localPath).catch((error) => {
1859
- if (isNodeError2(error) && error.code === "ENOENT") return void 0;
1860
- throw error;
1861
- });
1862
- if (!existing) continue;
1863
- if (!existing.isFile()) {
1864
- throw new SandboxPullError(`Refusing to overwrite local path: ${workspacePath}`, "directory-conflict");
1865
- }
1866
- if (!force && await hashLocalFile(localPath) !== previousFile.sha) {
1867
- throw new SandboxPullError(
1868
- `Remote file was deleted but local file changed: ${workspacePath}`,
1869
- "file-conflict"
1870
- );
1171
+
1172
+ // src/commands/workflow.ts
1173
+ import * as fs4 from "fs";
1174
+ import { TextDecoder as TextDecoder2 } from "util";
1175
+ var MAX_DESC_LENGTH = 5e3;
1176
+ var MAX_COMMENT_CONTENT_LENGTH = 5e3;
1177
+ var MAX_COMMENT_CONTENT_BYTES = MAX_COMMENT_CONTENT_LENGTH * 4;
1178
+ var WorkflowOptionsError = class extends Error {
1179
+ constructor(message) {
1180
+ super(message);
1181
+ this.name = "WorkflowOptionsError";
1182
+ }
1183
+ };
1184
+ function runOrExit4(fn) {
1185
+ try {
1186
+ return fn();
1187
+ } catch (err) {
1188
+ if (err instanceof WorkflowOptionsError || err instanceof SpaceOptionsError || err instanceof SearchOptionsError) {
1189
+ printError(err.message);
1190
+ process.exit(1);
1871
1191
  }
1872
- deletions.push({ localPath });
1192
+ throw err;
1873
1193
  }
1874
- return deletions;
1875
1194
  }
1876
- async function hashLocalFile(localPath) {
1877
- return createHash2("sha256").update(await readFile2(localPath)).digest("hex");
1195
+ function addWorkspaceOption(command) {
1196
+ return command.requiredOption("-w, --workspace <workspaceId>", "Workspace ID");
1878
1197
  }
1879
- async function localFileSize(localPath, workspacePath) {
1880
- const stats = await lstat(localPath);
1881
- if (!stats.isFile()) {
1882
- throw new SandboxPullError(`Cannot determine local file size: ${workspacePath}`, "directory-conflict");
1883
- }
1884
- return stats.size;
1885
- }
1886
- function requireBlobSha(entry) {
1887
- if (!entry.sha) {
1888
- throw new SandboxPullError(`${entry.prefixedPath} does not have downloadable content`, "remote");
1889
- }
1890
- return entry.sha;
1198
+ function addSpaceOptions2(command) {
1199
+ return addWorkspaceOption(command).option("-s, --space <name>", "Space name (personal or teamspace name)").option("--personal", "Use personal space (default)").option("--team-space-id <teamSpaceId>", "Team space ID").option("--teammate-id <teammateId>", "Teammate ID (teammate space)");
1891
1200
  }
1892
- async function assertCanCreateDirectory(localPath, workspacePath) {
1893
- const existing = await lstat(localPath).catch((error) => {
1894
- if (isNodeError2(error) && error.code === "ENOENT") return void 0;
1895
- throw error;
1896
- });
1897
- if (existing && !existing.isDirectory()) {
1898
- throw new SandboxPullError(`Refusing to overwrite local path: ${workspacePath}`, "directory-conflict");
1899
- }
1201
+ function addAssigneeOptions(command) {
1202
+ return command.option("--human-id <humanId>", "Human assignee ID").option("--assistant-id <assistantId>", "AI assistant assignee ID").option("--teammate-id <teammateId>", "AI teammate assignee ID");
1900
1203
  }
1901
- async function writePulledFile(localPath, remoteContent) {
1902
- await mkdir2(dirname2(localPath), { recursive: true });
1903
- await writeFile2(localPath, remoteContent);
1204
+ function addPreferredAssigneeOptions(command) {
1205
+ return command.option("--preferred-human-id <humanId>", "Preferred human assignee ID").option("--preferred-assistant-id <assistantId>", "Preferred AI assistant assignee ID").option("--preferred-teammate-id <teammateId>", "Preferred AI teammate assignee ID");
1904
1206
  }
1905
- function resolveSafeLocalPath(targetDir, repoName, repoPath) {
1906
- const pathParts = [repoName, ...repoPath.split("/").filter((part) => part.length > 0)];
1907
- if (pathParts.some((part) => part === "." || part === ".." || part.includes("/") || part.includes("\\") || part.includes("\0"))) {
1908
- throw new SandboxPullError(`Remote path is unsafe: ${[repoName, repoPath].filter(Boolean).join("/")}`, "invalid-path");
1909
- }
1910
- const resolved = resolve(targetDir, ...pathParts);
1911
- const relativePath = relative(targetDir, resolved);
1912
- if (relativePath.startsWith("..") || isAbsolute2(relativePath)) {
1913
- throw new SandboxPullError(`Remote path escapes target directory: ${repoName}/${repoPath}`, "invalid-path");
1207
+ function parsePositiveInteger(raw, label) {
1208
+ const parsed = Number.parseInt(raw, 10);
1209
+ if (!Number.isInteger(parsed) || parsed <= 0 || String(parsed) !== raw) {
1210
+ throw new WorkflowOptionsError(`Error: ${label} must be a positive integer, got "${raw}".`);
1914
1211
  }
1915
- return resolved;
1212
+ return parsed;
1916
1213
  }
1917
- function isNodeError2(error) {
1918
- return error instanceof Error && "code" in error;
1214
+ function parseOptionalPositiveInteger(raw, label) {
1215
+ if (raw == null || raw === "") return void 0;
1216
+ return parsePositiveInteger(raw, label);
1919
1217
  }
1920
-
1921
- // src/commands/pull.ts
1922
- function registerPullCommand(program2) {
1923
- program2.command("pull").description("Pull workspace files into the local checkout").argument("[workspacePath]", "Workspace file or directory to pull").option("--all", "Pull the entire workspace").option("--force", "Overwrite changed local files").addOption(new Option("--dir <dir>").hideHelp()).addOption(new Option("--scope <workspacePath>").argParser(collectScope).default([]).hideHelp()).action(async (workspacePath, options) => {
1924
- const context = resolveSandboxRuntimeContextOrExit2();
1925
- try {
1926
- const invocation = await resolvePullInvocation(context, workspacePath, options);
1927
- const result = await pullSandboxWorkspace(context, invocation.targetDir, {
1928
- force: Boolean(options.force),
1929
- scopes: invocation.scopes
1930
- });
1931
- print(`Pulled ${result.fileCount} ${result.fileCount === 1 ? "file" : "files"} to ${result.targetDir}`);
1932
- } catch (error) {
1933
- handlePullError(error);
1934
- }
1935
- });
1218
+ function parseStatusType(raw) {
1219
+ if (raw == null || raw === "") return void 0;
1220
+ const parsed = Number.parseInt(raw, 10);
1221
+ if ((parsed === 1 || parsed === 2 || parsed === 3) && String(parsed) === raw) return parsed;
1222
+ throw new WorkflowOptionsError("Error: --type must be one of 1 (todo), 2 (in-progress), or 3 (done).");
1936
1223
  }
1937
- async function resolvePullInvocation(context, argument, options) {
1938
- const legacyTargetDir = argument && isLegacyTargetDirectory(argument) ? argument : void 0;
1939
- const workspacePath = legacyTargetDir ? void 0 : argument;
1940
- if (workspacePath && options.all) {
1941
- throw new Error("Specify either a workspace path or --all, not both");
1942
- }
1943
- if (options.scope.length > 0 && (workspacePath || options.all)) {
1944
- throw new Error("Legacy --scope cannot be combined with a workspace path or --all");
1224
+ function parseRequiredStatusType(raw) {
1225
+ const value = parseStatusType(raw);
1226
+ if (value === void 0) {
1227
+ throw new WorkflowOptionsError("Error: --type is required.");
1945
1228
  }
1946
- if (legacyTargetDir && options.dir) {
1947
- throw new Error("Specify the local checkout directory only once");
1948
- }
1949
- const state = await loadCheckoutState();
1950
- if (state) validateCheckoutStateContext(state, context);
1951
- const targetDir = options.dir ?? legacyTargetDir ?? state?.checkoutRoot ?? ".";
1952
- if (options.all || legacyTargetDir && options.scope.length === 0) {
1953
- return { targetDir };
1954
- }
1955
- if (workspacePath) return { targetDir, scopes: [workspacePath] };
1956
- if (options.scope.length > 0) return { targetDir, scopes: options.scope };
1957
- if (!state || state.checkedOutScopes.length === 0) {
1958
- throw new Error("Checkout is not initialized. Run moxt pull <workspace-path> or moxt pull --all first.");
1959
- }
1960
- return {
1961
- targetDir: state.checkoutRoot,
1962
- scopes: state.mode === "full" ? void 0 : state.checkedOutScopes
1963
- };
1964
- }
1965
- function isLegacyTargetDirectory(value) {
1966
- return value === "." || value.startsWith("./") || value.startsWith("../") || isAbsolute3(value);
1229
+ return value;
1967
1230
  }
1968
- function validateCheckoutStateContext(state, context) {
1969
- if (state.workspaceId !== context.workspaceId) {
1970
- throw new Error(`Checkout state belongs to workspace ${state.workspaceId}, not ${context.workspaceId}`);
1971
- }
1972
- const currentRepos = new Map(listRepoEntries(context.repoPayload).map((repo) => [repo.repoId, repo.name]));
1973
- for (const [repoId, repo] of Object.entries(state.repos)) {
1974
- const currentName = currentRepos.get(repoId);
1975
- if (!currentName) throw new Error(`Checkout state contains unknown repo: ${repoId}`);
1976
- if (currentName !== repo.name) {
1977
- throw new Error(`Checkout state repo name does not match current workspace: ${repo.name}`);
1978
- }
1231
+ function parsePriority(raw) {
1232
+ if (raw == null || raw === "") return void 0;
1233
+ if (raw === "urgent") return 0;
1234
+ if (raw === "high") return 1;
1235
+ if (raw === "medium") return 2;
1236
+ if (raw === "low") return 3;
1237
+ throw new WorkflowOptionsError('Error: --priority must be "urgent", "high", "medium", or "low".');
1238
+ }
1239
+ function parseRequiredPriority(raw) {
1240
+ return parsePriority(raw ?? "medium") ?? 2;
1241
+ }
1242
+ function parseWorkflowScope(raw) {
1243
+ if (raw == null || raw === "") return "all";
1244
+ if (raw === "all" || raw === "owner_is_me") return raw;
1245
+ throw new WorkflowOptionsError('Error: --scope must be "all" or "owner_is_me".');
1246
+ }
1247
+ function parseTaskScope(raw) {
1248
+ if (raw == null || raw === "") return "all";
1249
+ if (raw === "all" || raw === "assigned_to_me" || raw === "subscribed_by_me" || raw === "owner_is_me") {
1250
+ return raw;
1979
1251
  }
1252
+ throw new WorkflowOptionsError(
1253
+ 'Error: --scope must be "all", "assigned_to_me", "subscribed_by_me", or "owner_is_me".'
1254
+ );
1980
1255
  }
1981
- function collectScope(value, previous) {
1982
- return [...previous, value];
1983
- }
1984
- function resolveSandboxRuntimeContextOrExit2() {
1985
- let context;
1986
- try {
1987
- context = resolveRuntimeContext();
1988
- } catch (error) {
1989
- if (error instanceof RuntimeContextError) {
1990
- printError(error.message);
1991
- process.exit(1);
1992
- }
1993
- throw error;
1256
+ function parseFileIds(raw, label = "--file-ids") {
1257
+ if (raw == null || raw === "") return void 0;
1258
+ const ids = raw.split(",").map((item) => item.trim()).filter(Boolean);
1259
+ if (ids.length === 0) {
1260
+ throw new WorkflowOptionsError(`Error: ${label} must include at least one file ID.`);
1994
1261
  }
1995
- if (context.mode !== "sandbox") {
1996
- printError("moxt pull is only available in sandbox mode");
1997
- process.exit(1);
1998
- }
1999
- return context;
1262
+ return ids;
2000
1263
  }
2001
- function handlePullError(error) {
2002
- if (error instanceof Error) {
2003
- printError(error.message);
2004
- process.exit(1);
1264
+ function readDescFile(path2) {
1265
+ if (path2 == null || path2 === "") return void 0;
1266
+ if (!fs4.existsSync(path2)) {
1267
+ throw new WorkflowOptionsError(`Error: desc file not found: ${path2}`);
2005
1268
  }
2006
- throw error;
2007
- }
2008
-
2009
- // src/utils/sandbox-push.ts
2010
- import { createHash as createHash3 } from "crypto";
2011
- import { lstat as lstat2, readFile as readFile3, readdir } from "fs/promises";
2012
- import { isAbsolute as isAbsolute4, join as join2, relative as relative2, resolve as resolve2 } from "path";
2013
-
2014
- // src/utils/concurrency.ts
2015
- async function runWithConcurrency(items, concurrency, action) {
2016
- if (!Number.isInteger(concurrency) || concurrency < 1) {
2017
- throw new RangeError("Concurrency must be a positive integer");
1269
+ const stat = fs4.statSync(path2);
1270
+ if (!stat.isFile()) {
1271
+ throw new WorkflowOptionsError(`Error: desc file is not a file: ${path2}`);
2018
1272
  }
2019
- let nextIndex = 0;
2020
- async function worker() {
2021
- while (nextIndex < items.length) {
2022
- const item = items[nextIndex];
2023
- nextIndex += 1;
2024
- await action(item);
2025
- }
1273
+ const buffer2 = fs4.readFileSync(path2);
1274
+ if (isBinaryContent(buffer2)) {
1275
+ throw new WorkflowOptionsError("Error: desc file must be UTF-8 text, not binary content.");
2026
1276
  }
2027
- const workerCount = Math.min(concurrency, items.length);
2028
- await Promise.all(Array.from({ length: workerCount }, () => worker()));
1277
+ const content = buffer2.toString("utf8");
1278
+ if (content.length > MAX_DESC_LENGTH) {
1279
+ throw new WorkflowOptionsError(`Error: desc file must be at most ${MAX_DESC_LENGTH} characters.`);
1280
+ }
1281
+ return content;
2029
1282
  }
2030
-
2031
- // src/utils/sandbox-push.ts
2032
- var MFS_FILE_TYPE_REGULAR2 = 1;
2033
- var MAX_CONCURRENT_UPLOADS = 8;
2034
- var SandboxPushError = class extends Error {
2035
- constructor(message, code) {
2036
- super(message);
2037
- this.code = code;
2038
- this.name = "SandboxPushError";
1283
+ function readCommentContentFile2(path2) {
1284
+ if (!fs4.existsSync(path2)) {
1285
+ throw new WorkflowOptionsError(`Error: comment content file not found: ${path2}`);
2039
1286
  }
2040
- code;
2041
- };
2042
- async function pushSandboxWorkspace(context, sourceDir, options) {
2043
- const resolvedSourceDir = resolve2(sourceDir);
2044
- await assertWorkspaceRoot(resolvedSourceDir);
2045
- const repos = listRepoEntries(context.repoPayload);
2046
- for (const repo of repos) {
2047
- assertSafeRepoName(resolvedSourceDir, repo.name);
2048
- }
2049
- const state = await resolveCheckoutState(context, resolvedSourceDir, repos);
2050
- const checkoutRepos = repos.filter(
2051
- (repo) => state.repos[repo.repoId] && doesCheckoutPathIntersect(state, repo.name)
2052
- );
2053
- if (checkoutRepos.length === 0) {
2054
- throw new SandboxPushError("Checkout state does not contain a pushable scope", "invalid-path");
2055
- }
2056
- const tree = await fetchWorkspaceTree2(context, checkoutRepos);
2057
- const remoteEntries = indexRemoteEntries(tree, checkoutRepos);
2058
- const localFiles = await collectLocalFiles(resolvedSourceDir, checkoutRepos, remoteEntries, state);
2059
- const { changes, reconciledFiles, removedWorkspacePaths } = planChanges(
2060
- localFiles,
2061
- remoteEntries,
2062
- state,
2063
- checkoutRepos
2064
- );
2065
- let pushResponse;
2066
- if (changes.length > 0) {
2067
- await uploadChangedFiles(context, changes);
2068
- pushResponse = await pushChanges(context, checkoutRepos, changes, options.message);
1287
+ const stat = fs4.statSync(path2);
1288
+ if (!stat.isFile()) {
1289
+ throw new WorkflowOptionsError(`Error: comment content path is not a file: ${path2}`);
2069
1290
  }
2070
- if (changes.length > 0 || reconciledFiles.length > 0 || removedWorkspacePaths.length > 0) {
2071
- await updateCheckoutState(state, changes, reconciledFiles, removedWorkspacePaths, pushResponse);
1291
+ if (stat.size > MAX_COMMENT_CONTENT_BYTES) {
1292
+ throw new WorkflowOptionsError(
1293
+ `Error: comment content file must be at most ${MAX_COMMENT_CONTENT_LENGTH} characters.`
1294
+ );
2072
1295
  }
2073
- return {
2074
- changes: changes.map((change) => ({ action: change.action, workspacePath: change.workspacePath })),
2075
- sourceDir: resolvedSourceDir
2076
- };
2077
- }
2078
- async function resolveCheckoutState(context, sourceDir, repos) {
2079
- const state = await loadCheckoutState();
2080
- if (!state) {
2081
- throw new SandboxPushError("Checkout is not initialized. Run moxt pull first.", "invalid-path");
1296
+ const buffer2 = fs4.readFileSync(path2);
1297
+ if (buffer2.includes(0)) {
1298
+ throw new WorkflowOptionsError("Error: comment content file must not contain NUL bytes.");
2082
1299
  }
2083
- if (state.workspaceId !== context.workspaceId) {
2084
- throw new SandboxPushError(
2085
- `Checkout state belongs to workspace ${state.workspaceId}, not ${context.workspaceId}`,
2086
- "invalid-path"
2087
- );
1300
+ if (isBinaryContent(buffer2)) {
1301
+ throw new WorkflowOptionsError("Error: comment content file must be UTF-8 text, not binary content.");
2088
1302
  }
2089
- if (state.checkoutRoot !== sourceDir) {
2090
- throw new SandboxPushError(
2091
- `Checkout root is ${state.checkoutRoot}, not ${sourceDir}`,
2092
- "invalid-path"
2093
- );
1303
+ let content;
1304
+ try {
1305
+ content = new TextDecoder2("utf-8", { fatal: true, ignoreBOM: true }).decode(buffer2);
1306
+ } catch {
1307
+ throw new WorkflowOptionsError("Error: comment content file must contain valid UTF-8 text.");
2094
1308
  }
2095
- const currentRepos = new Map(repos.map((repo) => [repo.repoId, repo.name]));
2096
- for (const [repoId, repo] of Object.entries(state.repos)) {
2097
- if (currentRepos.get(repoId) !== repo.name) {
2098
- throw new SandboxPushError(`Checkout state contains unknown repo: ${repoId}`, "invalid-path");
2099
- }
1309
+ if (content.length === 0) {
1310
+ throw new WorkflowOptionsError("Error: comment content file must not be empty.");
2100
1311
  }
2101
- for (const scope of state.checkedOutScopes) {
2102
- const hasRepo = Object.values(state.repos).some(
2103
- (repo) => scope === repo.name || scope.startsWith(`${repo.name}/`)
1312
+ if (content.length > MAX_COMMENT_CONTENT_LENGTH) {
1313
+ throw new WorkflowOptionsError(
1314
+ `Error: comment content file must be at most ${MAX_COMMENT_CONTENT_LENGTH} characters.`
2104
1315
  );
2105
- if (!hasRepo) throw new SandboxPushError(`Checkout state contains invalid scope: ${scope}`, "invalid-path");
2106
1316
  }
2107
- return state;
1317
+ return content;
2108
1318
  }
2109
- async function assertWorkspaceRoot(sourceDir) {
2110
- const stats = await lstat2(sourceDir).catch((error) => {
2111
- if (isNodeError3(error) && error.code === "ENOENT") {
2112
- throw new SandboxPushError(`Local workspace directory does not exist: ${sourceDir}`, "invalid-path");
2113
- }
2114
- throw error;
2115
- });
2116
- if (!stats.isDirectory()) {
2117
- throw new SandboxPushError(`Local workspace path is not a directory: ${sourceDir}`, "invalid-path");
2118
- }
1319
+ function parseAssigneeBody(options, allowClear) {
1320
+ const hasClear = options.clear === true;
1321
+ const humanId = parseOptionalPositiveInteger(options.humanId, "--human-id");
1322
+ const assistantId = parseOptionalPositiveInteger(options.assistantId, "--assistant-id");
1323
+ const teammateId = parseOptionalPositiveInteger(options.teammateId, "--teammate-id");
1324
+ const count = (humanId !== void 0 ? 1 : 0) + (assistantId !== void 0 ? 1 : 0) + (teammateId !== void 0 ? 1 : 0);
1325
+ if (hasClear && !allowClear) {
1326
+ throw new WorkflowOptionsError("Error: --clear is not supported here.");
1327
+ }
1328
+ if (hasClear && count > 0) {
1329
+ throw new WorkflowOptionsError("Error: --clear cannot be used with assignee ID options.");
1330
+ }
1331
+ if (count > 1) {
1332
+ throw new WorkflowOptionsError("Error: assignee ID options are mutually exclusive.");
1333
+ }
1334
+ if (hasClear) return {};
1335
+ if (humanId !== void 0) return { assigneeHumanId: humanId };
1336
+ if (assistantId !== void 0) return { assigneeAiAssistantId: assistantId };
1337
+ if (teammateId !== void 0) return { assigneeAiTeammateId: teammateId };
1338
+ return {};
2119
1339
  }
2120
- async function fetchWorkspaceTree2(context, repos) {
2121
- const client = new SandboxMoxtClient(context);
2122
- const response = await client.request(
2123
- "POST",
2124
- "/agent/api/pipeline-sandbox/mfs-workspace/batch-trees",
2125
- {
2126
- repos: repos.map((repo) => ({ repoId: repo.repoId, prefix: repo.name }))
2127
- }
2128
- );
2129
- return response.data;
2130
- }
2131
- function indexRemoteEntries(tree, repos) {
2132
- const knownRepoIds = new Set(repos.map((repo) => repo.repoId));
2133
- const entries = /* @__PURE__ */ new Map();
2134
- for (const entry of tree.entries) {
2135
- if (!knownRepoIds.has(entry.repoId)) {
2136
- throw new SandboxPushError(`Remote tree returned unknown repo: ${entry.repoId}`, "remote");
2137
- }
2138
- const key = remoteEntryKey(entry.repoId, entry.path);
2139
- if (entries.has(key)) {
2140
- throw new SandboxPushError(`Remote tree returned duplicate path: ${entry.prefixedPath}`, "remote");
2141
- }
2142
- entries.set(key, entry);
2143
- }
2144
- return entries;
2145
- }
2146
- async function collectLocalFiles(sourceDir, repos, remoteEntries, state) {
2147
- const files = [];
2148
- for (const repo of repos) {
2149
- const repoRoot = resolveSafeRepoRoot(sourceDir, repo.name);
2150
- const stats = await lstat2(repoRoot).catch((error) => {
2151
- if (isNodeError3(error) && error.code === "ENOENT") return void 0;
2152
- throw error;
2153
- });
2154
- if (!stats) continue;
2155
- if (!stats.isDirectory()) {
2156
- throw new SandboxPushError(`Local repo path is not a directory: ${repo.name}`, "invalid-path");
2157
- }
2158
- await collectRepoFiles(repoRoot, repo, "", remoteEntries, state, files);
2159
- }
2160
- return files;
2161
- }
2162
- async function collectRepoFiles(repoRoot, repo, relativeDir, remoteEntries, state, files) {
2163
- const localDir = relativeDir ? join2(repoRoot, ...relativeDir.split("/")) : repoRoot;
2164
- const entries = await readdir(localDir, { withFileTypes: true });
2165
- entries.sort((a, b) => a.name.localeCompare(b.name));
2166
- for (const entry of entries) {
2167
- if (entry.name === ".git") continue;
2168
- const repoPath = relativeDir ? `${relativeDir}/${entry.name}` : entry.name;
2169
- const workspacePath = `${repo.name}/${repoPath}`;
2170
- assertSafePathPart(entry.name, workspacePath);
2171
- if (!doesCheckoutPathIntersect(state, workspacePath)) continue;
2172
- if (entry.isSymbolicLink()) {
2173
- throw new SandboxPushError(`Refusing to push symbolic link: ${workspacePath}`, "invalid-file");
2174
- }
2175
- if (entry.isDirectory()) {
2176
- const remote = remoteEntries.get(remoteEntryKey(repo.repoId, repoPath));
2177
- if (remote?.type === "blob") {
2178
- throw new SandboxPushError(`Local directory conflicts with remote file: ${workspacePath}`, "invalid-file");
2179
- }
2180
- await collectRepoFiles(repoRoot, repo, repoPath, remoteEntries, state, files);
2181
- continue;
2182
- }
2183
- if (!entry.isFile()) {
2184
- throw new SandboxPushError(`Refusing to push unsupported file: ${workspacePath}`, "invalid-file");
2185
- }
2186
- const localPath = join2(repoRoot, ...repoPath.split("/"));
2187
- const stats = await lstat2(localPath);
2188
- if (!stats.isFile()) {
2189
- const kind = stats.isSymbolicLink() ? "symbolic link" : "unsupported file";
2190
- throw new SandboxPushError(`Refusing to push ${kind}: ${workspacePath}`, "invalid-file");
2191
- }
2192
- if (stats.size > MFS_FILE_SIZE_LIMIT_BYTES) {
2193
- throw new SandboxPushError(
2194
- `File too large (max ${MFS_FILE_SIZE_LIMIT_LABEL}): ${workspacePath}`,
2195
- "too-large"
2196
- );
2197
- }
2198
- const content = await readFile3(localPath);
2199
- if (content.length > MFS_FILE_SIZE_LIMIT_BYTES) {
2200
- throw new SandboxPushError(
2201
- `File too large (max ${MFS_FILE_SIZE_LIMIT_LABEL}): ${workspacePath}`,
2202
- "too-large"
2203
- );
2204
- }
2205
- if (!isCheckoutPathIncluded(state, workspacePath)) continue;
2206
- files.push({
2207
- repo,
2208
- repoPath,
2209
- workspacePath,
2210
- localPath,
2211
- contentHash: createHash3("sha256").update(content).digest("hex"),
2212
- size: content.length
2213
- });
2214
- }
1340
+ function parsePreferredAssigneeBody(options) {
1341
+ const hasClear = options.clearPreferredAssignee === true;
1342
+ const humanId = parseOptionalPositiveInteger(options.preferredHumanId, "--preferred-human-id");
1343
+ const assistantId = parseOptionalPositiveInteger(options.preferredAssistantId, "--preferred-assistant-id");
1344
+ const teammateId = parseOptionalPositiveInteger(options.preferredTeammateId, "--preferred-teammate-id");
1345
+ const count = (humanId !== void 0 ? 1 : 0) + (assistantId !== void 0 ? 1 : 0) + (teammateId !== void 0 ? 1 : 0);
1346
+ if (hasClear && count > 0) {
1347
+ throw new WorkflowOptionsError("Error: --clear-preferred-assignee cannot be used with preferred assignee ID options.");
1348
+ }
1349
+ if (count > 1) {
1350
+ throw new WorkflowOptionsError("Error: preferred assignee ID options are mutually exclusive.");
1351
+ }
1352
+ if (hasClear) return { preferredAssigneeHumanId: null };
1353
+ if (humanId !== void 0) return { preferredAssigneeHumanId: humanId };
1354
+ if (assistantId !== void 0) return { preferredAssigneeAiAssistantId: assistantId };
1355
+ if (teammateId !== void 0) return { preferredAssigneeAiTeammateId: teammateId };
1356
+ return {};
2215
1357
  }
2216
- function planChanges(localFiles, remoteEntries, state, repos) {
2217
- const changes = [];
2218
- const reconciledFiles = [];
2219
- const removedWorkspacePaths = [];
2220
- const localWorkspacePaths = new Set(localFiles.map((file) => file.workspacePath));
2221
- const reposById = new Map(repos.map((repo) => [repo.repoId, repo]));
2222
- for (const file of localFiles) {
2223
- const baseline = state.files[file.workspacePath];
2224
- if (baseline?.sha === file.contentHash) continue;
2225
- const existing = remoteEntries.get(remoteEntryKey(file.repo.repoId, file.repoPath));
2226
- if (existing?.type === "tree") {
2227
- throw new SandboxPushError(`Local file conflicts with remote directory: ${file.workspacePath}`, "invalid-file");
2228
- }
2229
- if (existing?.sha === file.contentHash) {
2230
- reconciledFiles.push({ ...file, remote: existing });
2231
- continue;
2232
- }
2233
- if (existing && !existing.sha) {
2234
- throw new SandboxPushError(`${file.workspacePath} does not have a base content hash`, "remote");
2235
- }
2236
- if (baseline && existing?.sha !== baseline.sha) {
2237
- throw new SandboxPushError(`Remote file changed since checkout: ${file.workspacePath}`, "conflict");
2238
- }
2239
- if (!baseline && existing) {
2240
- throw new SandboxPushError(`Remote file changed since checkout: ${file.workspacePath}`, "conflict");
2241
- }
2242
- changes.push({
2243
- ...file,
2244
- action: existing ? "update" : "create",
2245
- ...existing ? { existing } : {}
2246
- });
2247
- }
2248
- for (const [workspacePath, baseline] of Object.entries(state.files)) {
2249
- if (localWorkspacePaths.has(workspacePath) || !isCheckoutPathIncluded(state, workspacePath)) continue;
2250
- const repo = reposById.get(baseline.repoId);
2251
- if (!repo) continue;
2252
- const existing = remoteEntries.get(remoteEntryKey(baseline.repoId, baseline.repoPath));
2253
- if (!existing) {
2254
- removedWorkspacePaths.push(workspacePath);
2255
- continue;
2256
- }
2257
- if (existing.type !== "blob" || existing.sha !== baseline.sha) {
2258
- throw new SandboxPushError(`Remote file changed since checkout: ${workspacePath}`, "conflict");
2259
- }
2260
- changes.push({
2261
- action: "delete",
2262
- repo,
2263
- repoPath: baseline.repoPath,
2264
- workspacePath,
2265
- fileId: validFileId(existing.fileId) ?? baseline.fileId
2266
- });
1358
+ function assertNonEmptyBody(body) {
1359
+ if (Object.keys(body).length === 0) {
1360
+ throw new WorkflowOptionsError("Error: at least one update option must be provided.");
2267
1361
  }
2268
- return { changes, reconciledFiles, removedWorkspacePaths };
2269
- }
2270
- async function uploadChangedFiles(context, changes) {
2271
- const writeChanges = changes.filter(isWriteChange);
2272
- if (writeChanges.length === 0) return;
2273
- const client = new SandboxMoxtClient(context);
2274
- const changesByRepo = groupChangesByRepo(writeChanges);
2275
- const uploadUrlsByRepo = await Promise.all(
2276
- [...changesByRepo].map(async ([repoId, repoChanges]) => {
2277
- const hashes = [...new Set(repoChanges.map((change) => change.contentHash))];
2278
- const response = await client.request(
2279
- "POST",
2280
- `/agent/api/pipeline-sandbox/mfs/${encodeURIComponent(repoId)}/presign-upload`,
2281
- { hashes }
2282
- );
2283
- return [repoId, response.data.urls];
2284
- })
2285
- );
2286
- const uploadUrls = new Map(uploadUrlsByRepo);
2287
- await runWithConcurrency(writeChanges, MAX_CONCURRENT_UPLOADS, async (change) => {
2288
- const uploadUrl = uploadUrls.get(change.repo.repoId)?.[change.contentHash];
2289
- if (!uploadUrl) {
2290
- throw new SandboxPushError(`No upload URL returned for ${change.workspacePath}`, "remote");
2291
- }
2292
- const content = await readFile3(change.localPath);
2293
- const currentHash = createHash3("sha256").update(content).digest("hex");
2294
- if (content.length !== change.size || currentHash !== change.contentHash) {
2295
- throw new SandboxPushError(`Local file changed while pushing: ${change.workspacePath}`, "invalid-file");
2296
- }
2297
- const upload = await fetch(uploadUrl, {
2298
- method: "PUT",
2299
- headers: { "Content-Type": "application/octet-stream" },
2300
- body: new Uint8Array(content)
2301
- });
2302
- if (!upload.ok) {
2303
- throw new SandboxPushError(`Upload failed [${upload.status}]: ${change.workspacePath}`, "remote");
2304
- }
2305
- });
2306
1362
  }
2307
- async function pushChanges(context, repos, changes, message) {
2308
- const client = new SandboxMoxtClient(context);
2309
- const changesByRepo = groupChangesByRepo(changes);
2310
- const response = await client.request(
2311
- "POST",
2312
- "/agent/api/pipeline-sandbox/mfs-workspace/unified-push",
2313
- {
2314
- message,
2315
- repoPushes: repos.flatMap((repo) => {
2316
- const repoChanges = changesByRepo.get(repo.repoId);
2317
- if (!repoChanges) return [];
2318
- return [{
2319
- repoId: repo.repoId,
2320
- changes: repoChanges.map((change) => {
2321
- if (change.action === "delete") {
2322
- return {
2323
- action: change.action,
2324
- ...change.fileId ? { fileId: change.fileId } : {},
2325
- path: change.repoPath
2326
- };
2327
- }
2328
- return {
2329
- action: change.action,
2330
- ...change.existing?.fileId ? { fileId: change.existing.fileId } : {},
2331
- path: change.repoPath,
2332
- contentHash: change.contentHash,
2333
- ...change.existing?.sha ? { baseContentHash: change.existing.sha } : {},
2334
- size: change.size,
2335
- fileType: MFS_FILE_TYPE_REGULAR2
2336
- };
2337
- })
2338
- }];
2339
- }),
2340
- crossRepoMoves: []
2341
- }
2342
- );
2343
- return response.data;
2344
- }
2345
- async function updateCheckoutState(state, changes, reconciledFiles, removedWorkspacePaths, response) {
2346
- const files = { ...state.files };
2347
- for (const workspacePath of removedWorkspacePaths) delete files[workspacePath];
2348
- for (const file of reconciledFiles) {
2349
- files[file.workspacePath] = checkoutStateFile(
2350
- file,
2351
- validFileId(file.remote.fileId),
2352
- validFileType(file.remote.fileType)
2353
- );
2354
- }
2355
- for (const change of changes) {
2356
- if (change.action === "delete") {
2357
- delete files[change.workspacePath];
2358
- continue;
1363
+ function buildQuery(params) {
1364
+ const query = new URLSearchParams();
1365
+ for (const [key, value] of Object.entries(params)) {
1366
+ if (value !== void 0) {
1367
+ query.set(key, String(value));
2359
1368
  }
2360
- const returnedFileId = validFileId(response?.fileMap?.[change.repo.repoId]?.[change.repoPath]);
2361
- const fileId = returnedFileId ?? change.existing?.fileId ?? state.files[change.workspacePath]?.fileId ?? null;
2362
- files[change.workspacePath] = checkoutStateFile(change, validFileId(fileId), validFileType(change.existing?.fileType));
2363
1369
  }
2364
- await saveCheckoutState({ ...state, files });
2365
- }
2366
- function isWriteChange(change) {
2367
- return change.action !== "delete";
2368
- }
2369
- function checkoutStateFile(file, fileId, fileType) {
2370
- return {
2371
- repoId: file.repo.repoId,
2372
- repoPath: file.repoPath,
2373
- fileId,
2374
- sha: file.contentHash,
2375
- size: file.size,
2376
- fileType: fileType ?? MFS_FILE_TYPE_REGULAR2
2377
- };
1370
+ const text = query.toString();
1371
+ return text ? `?${text}` : "";
2378
1372
  }
2379
- function validFileId(fileId) {
2380
- return typeof fileId === "string" && fileId.length > 0 ? fileId : null;
1373
+ function failResponse(response) {
1374
+ printError(`Error [${response.status}]: ${JSON.stringify(response.data)}`);
1375
+ process.exit(1);
2381
1376
  }
2382
- function validFileType(fileType) {
2383
- return typeof fileType === "number" && Number.isInteger(fileType) && fileType >= 0 ? fileType : null;
1377
+ function priorityLabel(value) {
1378
+ switch (value) {
1379
+ case 0:
1380
+ return "urgent";
1381
+ case 1:
1382
+ return "high";
1383
+ case 2:
1384
+ return "medium";
1385
+ case 3:
1386
+ return "low";
1387
+ default: {
1388
+ const unsupportedValue = value;
1389
+ throw new Error(`Unsupported workflow task priority: ${String(unsupportedValue)}`);
1390
+ }
1391
+ }
1392
+ }
1393
+ function statusTypeLabel(value) {
1394
+ switch (value) {
1395
+ case 1:
1396
+ return "todo";
1397
+ case 2:
1398
+ return "in-progress";
1399
+ case 3:
1400
+ return "done";
1401
+ default: {
1402
+ const unsupportedValue = value;
1403
+ throw new Error(`Unsupported workflow status type: ${String(unsupportedValue)}`);
1404
+ }
1405
+ }
1406
+ }
1407
+ function formatAssignee(assignee) {
1408
+ if (!assignee) return "-";
1409
+ if (assignee.type === "human") return assignee.displayName ?? `human:${assignee.humanId}`;
1410
+ if (assignee.type === "assistant") return assignee.displayName ?? `assistant:${assignee.aiAssistantId}`;
1411
+ if (assignee.type === "teammate") return assignee.displayName ?? `teammate:${assignee.aiTeammateId}`;
1412
+ return "unknown";
1413
+ }
1414
+ function printWorkflow(item) {
1415
+ print(`${colors.bold}${item.fileId}${colors.reset} owner=${item.ownerHumanId ?? "-"} desc=${item.desc ?? "-"}`);
1416
+ }
1417
+ function indentation2(size) {
1418
+ return " ".repeat(size);
1419
+ }
1420
+ function escapeTerminalControlCharacters2(value) {
1421
+ return value.replace(/\r\n/g, "\n").replace(
1422
+ /[\u0000-\u0008\u000B-\u001F\u007F-\u009F]/g,
1423
+ (character) => `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`
1424
+ );
2384
1425
  }
2385
- function groupChangesByRepo(changes) {
2386
- const result = /* @__PURE__ */ new Map();
2387
- for (const change of changes) {
2388
- const repoChanges = result.get(change.repo.repoId) ?? [];
2389
- repoChanges.push(change);
2390
- result.set(change.repo.repoId, repoChanges);
1426
+ function formatDetailScalar2(value) {
1427
+ if (value === null) return "null";
1428
+ if (typeof value === "string") {
1429
+ const escaped = escapeTerminalControlCharacters2(value);
1430
+ if (escaped === "") return '""';
1431
+ if (/\n/.test(escaped)) return JSON.stringify(escaped);
1432
+ return escaped;
2391
1433
  }
2392
- return result;
1434
+ return String(value);
2393
1435
  }
2394
- function remoteEntryKey(repoId, repoPath) {
2395
- return `${repoId}\0${repoPath}`;
1436
+ function printDetailField2(name, value, indent = 0) {
1437
+ print(`${indentation2(indent)}${name}: ${formatDetailScalar2(value)}`);
2396
1438
  }
2397
- function assertSafePathPart(part, workspacePath) {
2398
- if (part === "." || part === ".." || part.includes("/") || part.includes("\\") || part.includes("\0")) {
2399
- throw new SandboxPushError(`Local path is unsafe: ${workspacePath}`, "invalid-path");
1439
+ function printDetailTextField2(name, value, indent = 0) {
1440
+ if (value === null || value === "") {
1441
+ printDetailField2(name, value, indent);
1442
+ return;
2400
1443
  }
2401
- }
2402
- function assertSafeRepoName(sourceDir, repoName) {
2403
- resolveSafeRepoRoot(sourceDir, repoName);
2404
- }
2405
- function resolveSafeRepoRoot(sourceDir, repoName) {
2406
- assertSafePathPart(repoName, repoName);
2407
- const repoRoot = resolve2(sourceDir, repoName);
2408
- const relativePath = relative2(sourceDir, repoRoot);
2409
- if (relativePath.startsWith("..") || isAbsolute4(relativePath)) {
2410
- throw new SandboxPushError(`Repo path escapes local workspace: ${repoName}`, "invalid-path");
1444
+ const escaped = escapeTerminalControlCharacters2(value);
1445
+ print(`${indentation2(indent)}${name}:`);
1446
+ for (const line of escaped.split("\n")) {
1447
+ print(`${indentation2(indent + 2)}${line}`);
2411
1448
  }
2412
- return repoRoot;
2413
1449
  }
2414
- function isNodeError3(error) {
2415
- return error instanceof Error && "code" in error;
1450
+ function printAssigneeDetail(name, assignee, indent = 0) {
1451
+ if (assignee === null) {
1452
+ printDetailField2(name, null, indent);
1453
+ return;
1454
+ }
1455
+ print(`${indentation2(indent)}${name}:`);
1456
+ printDetailField2("type", assignee.type, indent + 2);
1457
+ switch (assignee.type) {
1458
+ case "human":
1459
+ printDetailField2("humanId", assignee.humanId, indent + 2);
1460
+ printDetailField2("displayName", assignee.displayName, indent + 2);
1461
+ printDetailField2("avatarUrl", assignee.avatarUrl, indent + 2);
1462
+ return;
1463
+ case "assistant":
1464
+ printDetailField2("aiAssistantId", assignee.aiAssistantId, indent + 2);
1465
+ printDetailField2("displayName", assignee.displayName, indent + 2);
1466
+ printDetailField2("avatarUrl", assignee.avatarUrl, indent + 2);
1467
+ return;
1468
+ case "teammate":
1469
+ printDetailField2("aiTeammateId", assignee.aiTeammateId, indent + 2);
1470
+ printDetailField2("displayName", assignee.displayName, indent + 2);
1471
+ printDetailField2("avatarUrl", assignee.avatarUrl, indent + 2);
1472
+ return;
1473
+ case "unknown":
1474
+ printDetailField2("humanId", assignee.humanId, indent + 2);
1475
+ printDetailField2("aiAssistantId", assignee.aiAssistantId, indent + 2);
1476
+ printDetailField2("aiTeammateId", assignee.aiTeammateId, indent + 2);
1477
+ }
1478
+ }
1479
+ function printStatusRemainingFields(item, indent) {
1480
+ printDetailField2("name", item.name, indent);
1481
+ printDetailTextField2("desc", item.desc, indent);
1482
+ printDetailField2("type", `${item.type} (${statusTypeLabel(item.type)})`, indent);
1483
+ printAssigneeDetail("preferredAssignee", item.preferredAssignee, indent);
1484
+ printDetailField2("order", item.order, indent);
1485
+ }
1486
+ function printStatusDetail(item) {
1487
+ print(`${colors.bold}Status${colors.reset}`);
1488
+ printDetailField2("scopedId", item.scopedId);
1489
+ printStatusRemainingFields(item, 0);
1490
+ }
1491
+ function printWorkflowDetail(item) {
1492
+ print(`${colors.bold}Workflow${colors.reset}`);
1493
+ printDetailField2("fileId", item.fileId);
1494
+ printDetailField2("ownerHumanId", item.ownerHumanId);
1495
+ printDetailTextField2("desc", item.desc);
1496
+ if (item.statusList.length === 0) {
1497
+ print("statusList: []");
1498
+ } else {
1499
+ print("statusList:");
1500
+ for (const status of item.statusList) {
1501
+ print(` - scopedId: ${status.scopedId}`);
1502
+ printStatusRemainingFields(status, 4);
1503
+ }
1504
+ }
1505
+ if (item.transitions.length === 0) {
1506
+ print("transitions: []");
1507
+ } else {
1508
+ print("transitions:");
1509
+ for (const transition of item.transitions) {
1510
+ print(` - statusFromScopedId: ${transition.statusFromScopedId}`);
1511
+ printDetailField2("statusToScopedId", transition.statusToScopedId, 4);
1512
+ }
1513
+ }
1514
+ }
1515
+ function printTaskSummary(item) {
1516
+ const runIds = item.agentRuns.map((run) => run.pipelineTriggerId).join(",") || "-";
1517
+ print(
1518
+ `${colors.bold}#${item.scopedId}${colors.reset} ${item.title} status=${item.statusScopedId} priority=${priorityLabel(item.priority)} assignee=${formatAssignee(item.assignee)} runs=${runIds}`
1519
+ );
2416
1520
  }
2417
-
2418
- // src/commands/push.ts
2419
- function registerPushCommand(program2) {
2420
- program2.command("push").description("Push local workspace files to the sandbox workspace").argument("[dir]", "Local workspace directory", ".").option("-m, --message <message>", "Push message", "moxt push").action(async (sourceDir, options) => {
2421
- const context = resolveSandboxRuntimeContextOrExit3();
2422
- try {
2423
- const result = await pushSandboxWorkspace(context, sourceDir, { message: options.message });
2424
- for (const change of result.changes) {
2425
- print(`${change.action} ${change.workspacePath}`);
2426
- }
2427
- if (result.changes.length === 0) {
2428
- print("No changes to push");
2429
- return;
2430
- }
2431
- const fileLabel = result.changes.length === 1 ? "file" : "files";
2432
- print(`Pushed ${result.changes.length} ${fileLabel} from ${result.sourceDir}`);
2433
- } catch (error) {
2434
- handlePushError(error);
1521
+ function printTaskDetail(item) {
1522
+ print(`${colors.bold}Task${colors.reset}`);
1523
+ printDetailField2("scopedId", item.scopedId);
1524
+ printDetailField2("workflowFileId", item.workflowFileId);
1525
+ printDetailField2("ownerHumanId", item.ownerHumanId);
1526
+ printDetailField2("title", item.title);
1527
+ printDetailTextField2("desc", item.desc);
1528
+ printDetailField2("statusScopedId", item.statusScopedId);
1529
+ printDetailField2("priority", `${item.priority} (${priorityLabel(item.priority)})`);
1530
+ printAssigneeDetail("assignee", item.assignee);
1531
+ if (item.linkedFileIds.length === 0) {
1532
+ print("linkedFileIds: []");
1533
+ } else {
1534
+ print("linkedFileIds:");
1535
+ for (const fileId of item.linkedFileIds) print(` - ${fileId}`);
1536
+ }
1537
+ printDetailField2("subscribed", item.subscribed);
1538
+ if (item.agentRuns.length === 0) {
1539
+ print("agentRuns: []");
1540
+ } else {
1541
+ print("agentRuns:");
1542
+ for (const run of item.agentRuns) {
1543
+ print(` - pipelineTriggerId: ${run.pipelineTriggerId}`);
1544
+ printAssigneeDetail("executor", run.executor, 4);
1545
+ }
1546
+ }
1547
+ printDetailField2("createdAt", item.createdAt);
1548
+ printDetailField2("latestActAt", item.latestActAt);
1549
+ }
1550
+ function printCommentDetail(item) {
1551
+ print(`${colors.bold}Comment${colors.reset}`);
1552
+ printDetailField2("id", item.id);
1553
+ printDetailField2("workflowFileId", item.workflowFileId);
1554
+ printDetailField2("workflowTaskScopedId", item.workflowTaskScopedId);
1555
+ printDetailTextField2("content", item.content);
1556
+ printAssigneeDetail("createdBy", item.createdBy);
1557
+ printDetailField2("createdAt", item.createdAt);
1558
+ printDetailField2("updatedAt", item.updatedAt);
1559
+ }
1560
+ function registerWorkflowCommand(program2) {
1561
+ const workflow = program2.command("workflow").description("Workflow operations");
1562
+ addWorkspaceOption(
1563
+ workflow.command("list").description("List accessible workflows").option("--scope <scope>", "Workflow scope: all or owner_is_me", "all")
1564
+ ).action(async (options) => {
1565
+ const scope = runOrExit4(() => parseWorkflowScope(options.scope));
1566
+ startSpinner("Fetching workflows...");
1567
+ const response = await httpGet(
1568
+ `/workspaces/${options.workspace}/workflows${buildQuery({ scope })}`
1569
+ );
1570
+ if (!response.ok) {
1571
+ stopSpinner(false, "Failed");
1572
+ failResponse(response);
1573
+ }
1574
+ if (response.data.workflows.length === 0) {
1575
+ stopSpinner(true, "No workflows found.");
1576
+ return;
2435
1577
  }
1578
+ stopSpinner(true, `Found ${response.data.workflows.length} workflow(s)`);
1579
+ for (const item of response.data.workflows) printWorkflow(item);
2436
1580
  });
2437
- }
2438
- function resolveSandboxRuntimeContextOrExit3() {
2439
- let context;
2440
- try {
2441
- context = resolveRuntimeContext();
2442
- } catch (error) {
2443
- if (error instanceof RuntimeContextError) {
2444
- printError(error.message);
2445
- process.exit(1);
1581
+ addSpaceOptions2(
1582
+ workflow.command("create").description("Create a blank workflow").requiredOption("-p, --path <path>", "Target .workflow path").option("--desc-file <localPath>", "Local UTF-8 text file for workflow description").option("--owner-human-id <humanId>", "Workflow owner human ID")
1583
+ ).action(async (options) => {
1584
+ const body = runOrExit4(() => {
1585
+ const next = {
1586
+ path: options.path,
1587
+ ...resolveSpaceSelector(options)
1588
+ };
1589
+ const desc = readDescFile(options.descFile);
1590
+ if (desc !== void 0) next.desc = desc;
1591
+ const ownerHumanId = parseOptionalPositiveInteger(options.ownerHumanId, "--owner-human-id");
1592
+ if (ownerHumanId !== void 0) next.ownerHumanId = ownerHumanId;
1593
+ return next;
1594
+ });
1595
+ startSpinner("Creating workflow...");
1596
+ const response = await httpPost(`/workspaces/${options.workspace}/workflows`, body);
1597
+ if (!response.ok) {
1598
+ stopSpinner(false, "Failed");
1599
+ failResponse(response);
2446
1600
  }
2447
- throw error;
2448
- }
2449
- if (context.mode !== "sandbox") {
2450
- printError("moxt push is only available in sandbox mode");
2451
- process.exit(1);
2452
- }
2453
- return context;
2454
- }
2455
- function handlePushError(error) {
2456
- if (error instanceof Error) {
2457
- printError(error.message);
2458
- process.exit(1);
2459
- }
2460
- throw error;
2461
- }
2462
-
2463
- // src/utils/checkout-status.ts
2464
- import { createHash as createHash4 } from "crypto";
2465
- import { createReadStream } from "fs";
2466
- import { lstat as lstat3, readdir as readdir2 } from "fs/promises";
2467
- import { join as join3 } from "path";
2468
- var CheckoutStatusError = class extends Error {
2469
- constructor(message) {
2470
- super(message);
2471
- this.name = "CheckoutStatusError";
2472
- }
2473
- };
2474
- async function inspectCheckoutStatus(state) {
2475
- await requireCheckoutDirectory(state.checkoutRoot);
2476
- const localFiles = /* @__PURE__ */ new Map();
2477
- for (const repo of Object.values(state.repos)) {
2478
- await scanDirectory(state, repo.name, join3(state.checkoutRoot, repo.name), localFiles);
2479
- }
2480
- const changes = [];
2481
- for (const [path2, sha] of localFiles) {
2482
- const baseline = state.files[path2];
2483
- if (!baseline) changes.push({ action: "added", path: path2 });
2484
- else if (baseline.sha !== sha) changes.push({ action: "modified", path: path2 });
2485
- }
2486
- for (const path2 of Object.keys(state.files)) {
2487
- if (isCheckoutPathIncluded(state, path2) && !localFiles.has(path2)) changes.push({ action: "deleted", path: path2 });
2488
- }
2489
- return changes.sort((left, right) => left.path.localeCompare(right.path));
2490
- }
2491
- async function requireCheckoutDirectory(checkoutRoot) {
2492
- let stats;
2493
- try {
2494
- stats = await lstat3(checkoutRoot);
2495
- } catch (error) {
2496
- throw new CheckoutStatusError(`Checkout root is not available: ${errorMessage2(error)}`);
2497
- }
2498
- if (stats.isSymbolicLink()) throw new CheckoutStatusError(`Refusing to inspect symbolic link: ${checkoutRoot}`);
2499
- if (!stats.isDirectory()) throw new CheckoutStatusError(`Checkout root is not a directory: ${checkoutRoot}`);
2500
- }
2501
- async function scanDirectory(state, workspacePath, localPath, files) {
2502
- let entries;
2503
- try {
2504
- entries = await readdir2(localPath, { withFileTypes: true });
2505
- } catch (error) {
2506
- if (isNodeError4(error) && error.code === "ENOENT") return;
2507
- throw new CheckoutStatusError(`Failed to inspect ${workspacePath}: ${errorMessage2(error)}`);
2508
- }
2509
- for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
2510
- if (entry.name === ".git") continue;
2511
- const childWorkspacePath = `${workspacePath}/${entry.name}`;
2512
- if (!doesCheckoutPathIntersect(state, childWorkspacePath)) continue;
2513
- const childLocalPath = join3(localPath, entry.name);
2514
- const stats = await lstat3(childLocalPath);
2515
- if (stats.isSymbolicLink()) {
2516
- throw new CheckoutStatusError(`Refusing to inspect symbolic link: ${childWorkspacePath}`);
1601
+ stopSpinner(true, "Workflow created");
1602
+ printWorkflow(response.data);
1603
+ });
1604
+ addWorkspaceOption(workflow.command("get").description("Get a workflow").argument("<workflowFileId>")).action(
1605
+ async (workflowFileId, options) => {
1606
+ startSpinner("Fetching workflow...");
1607
+ const response = await httpGet(
1608
+ `/workspaces/${options.workspace}/workflows/${workflowFileId}`
1609
+ );
1610
+ if (!response.ok) {
1611
+ stopSpinner(false, "Failed");
1612
+ failResponse(response);
1613
+ }
1614
+ stopSpinner(true, "Workflow fetched");
1615
+ printWorkflowDetail(response.data);
2517
1616
  }
2518
- if (stats.isDirectory()) {
2519
- await scanDirectory(state, childWorkspacePath, childLocalPath, files);
2520
- } else if (stats.isFile() && isCheckoutPathIncluded(state, childWorkspacePath)) {
2521
- files.set(childWorkspacePath, await hashFile(childLocalPath));
1617
+ );
1618
+ addWorkspaceOption(
1619
+ workflow.command("update").description("Update workflow metadata").argument("<workflowFileId>").option("--desc-file <localPath>", "Local UTF-8 text file for workflow description").option("--owner-human-id <humanId>", "Workflow owner human ID")
1620
+ ).action(async (workflowFileId, options) => {
1621
+ const body = runOrExit4(() => {
1622
+ const next = {};
1623
+ const desc = readDescFile(options.descFile);
1624
+ if (desc !== void 0) next.desc = desc;
1625
+ const ownerHumanId = parseOptionalPositiveInteger(options.ownerHumanId, "--owner-human-id");
1626
+ if (ownerHumanId !== void 0) next.ownerHumanId = ownerHumanId;
1627
+ assertNonEmptyBody(next);
1628
+ return next;
1629
+ });
1630
+ startSpinner("Updating workflow...");
1631
+ const response = await httpPatch(`/workspaces/${options.workspace}/workflows/${workflowFileId}`, body);
1632
+ if (!response.ok) {
1633
+ stopSpinner(false, "Failed");
1634
+ failResponse(response);
2522
1635
  }
2523
- }
2524
- }
2525
- async function hashFile(path2) {
2526
- const hash = createHash4("sha256");
2527
- const stream = createReadStream(path2);
2528
- for await (const chunk of stream) hash.update(chunk);
2529
- return hash.digest("hex");
2530
- }
2531
- function isNodeError4(error) {
2532
- return error instanceof Error && "code" in error;
2533
- }
2534
- function errorMessage2(error) {
2535
- return error instanceof Error ? error.message : String(error);
2536
- }
2537
-
2538
- // src/commands/status.ts
2539
- function registerStatusCommand(program2) {
2540
- program2.command("status").description("Show Moxt workspace checkout status").option("--json", "Print machine-readable status").action(async (options) => {
2541
- const context = resolveRuntimeContextOrExit2();
2542
- if (context.mode !== "sandbox") {
2543
- printError("moxt status is only available in sandbox mode");
2544
- process.exit(1);
1636
+ stopSpinner(true, "Workflow updated");
1637
+ printWorkflow(response.data);
1638
+ });
1639
+ addWorkspaceOption(
1640
+ workflow.command("delete").description("Delete a workflow").argument("<workflowFileId>").option("--message <message>", "Commit message")
1641
+ ).action(async (workflowFileId, options) => {
1642
+ startSpinner("Deleting workflow...");
1643
+ const response = await httpDelete(
1644
+ `/workspaces/${options.workspace}/workflows/${workflowFileId}`,
1645
+ options.message ? { message: options.message } : {}
1646
+ );
1647
+ if (!response.ok) {
1648
+ stopSpinner(false, "Failed");
1649
+ failResponse(response);
2545
1650
  }
2546
- const status = await buildSandboxStatusOrExit(context);
2547
- if (options.json) {
2548
- print(JSON.stringify(status, null, 2));
2549
- return;
1651
+ stopSpinner(true, `Deleted: ${response.data.fileId}`);
1652
+ });
1653
+ addWorkspaceOption(workflow.command("duplicate").description("Duplicate a workflow").argument("<workflowFileId>")).action(
1654
+ async (workflowFileId, options) => {
1655
+ startSpinner("Duplicating workflow...");
1656
+ const response = await httpPost(
1657
+ `/workspaces/${options.workspace}/workflows/${workflowFileId}/duplicate`,
1658
+ {}
1659
+ );
1660
+ if (!response.ok) {
1661
+ stopSpinner(false, "Failed");
1662
+ failResponse(response);
1663
+ }
1664
+ stopSpinner(true, "Workflow duplicated");
1665
+ printWorkflow(response.data);
2550
1666
  }
2551
- print(`${colors.bold}Workspace${colors.reset}: ${status.workspaceId}`);
2552
- print(`${colors.bold}Mode${colors.reset}: sandbox`);
2553
- print(`${colors.bold}Checkout${colors.reset}: ${formatCheckoutMode(status.checkout.mode)}`);
2554
- if (status.checkout.mode !== "not_initialized") {
2555
- print(`${colors.bold}Root${colors.reset}: ${status.checkout.root}`);
2556
- }
2557
- print("");
2558
- print(`${colors.bold}Repos${colors.reset}:`);
2559
- const repoNameWidth = status.repos.reduce((width, repo) => Math.max(width, repo.name.length), 0);
2560
- for (const repo of status.repos) {
2561
- print(` ${repo.name.padEnd(repoNameWidth)} ${repo.repoId}`);
2562
- }
2563
- print("");
2564
- if (status.checkout.mode === "not_initialized") {
2565
- print(`${colors.bold}Checked out scopes${colors.reset}: none`);
2566
- print(`${colors.bold}Local changes${colors.reset}: not tracked`);
2567
- return;
1667
+ );
1668
+ registerStatusCommands(workflow);
1669
+ registerTransitionCommands(workflow);
1670
+ registerTaskCommands(workflow);
1671
+ registerCommentCommands(workflow);
1672
+ registerRunCommands(workflow);
1673
+ }
1674
+ function registerStatusCommands(workflow) {
1675
+ const status = workflow.command("status").description("Workflow status operations");
1676
+ addPreferredAssigneeOptions(
1677
+ addWorkspaceOption(
1678
+ status.command("create").description("Create a workflow status").argument("<workflowFileId>").requiredOption("--name <name>", "Status name").requiredOption("--type <type>", "Status type value: 1=todo, 2=in-progress, 3=done").option("--desc-file <localPath>", "Local UTF-8 text file for status description")
1679
+ )
1680
+ ).action(
1681
+ async (workflowFileId, options) => {
1682
+ const body = runOrExit4(() => {
1683
+ const next = {
1684
+ name: options.name,
1685
+ type: parseRequiredStatusType(options.type)
1686
+ };
1687
+ const desc = readDescFile(options.descFile);
1688
+ if (desc !== void 0) next.desc = desc;
1689
+ Object.assign(next, parsePreferredAssigneeBody(options));
1690
+ return next;
1691
+ });
1692
+ startSpinner("Creating status...");
1693
+ const response = await httpPost(
1694
+ `/workspaces/${options.workspace}/workflows/${workflowFileId}/statuses`,
1695
+ body
1696
+ );
1697
+ if (!response.ok) {
1698
+ stopSpinner(false, "Failed");
1699
+ failResponse(response);
1700
+ }
1701
+ stopSpinner(true, "Status created");
1702
+ printStatusDetail(response.data);
2568
1703
  }
2569
- print(`${colors.bold}Checked out scopes${colors.reset}:`);
2570
- for (const scope of status.checkout.checkedOutScopes) print(` ${scope}`);
2571
- print(`${colors.bold}Local changes${colors.reset}:${status.checkout.changes.length === 0 ? " none" : ""}`);
2572
- for (const change of status.checkout.changes) {
2573
- print(` ${change.action.padEnd(8)} ${change.path}`);
1704
+ );
1705
+ addPreferredAssigneeOptions(
1706
+ addWorkspaceOption(
1707
+ status.command("update").description("Update a workflow status").argument("<workflowFileId>").argument("<statusScopedId>").option("--name <name>", "Status name").option("--type <type>", "Status type value: 1=todo, 2=in-progress, 3=done").option("--desc-file <localPath>", "Local UTF-8 text file for status description").option("--clear-desc", "Clear status description").option("--clear-preferred-assignee", "Clear preferred assignee")
1708
+ )
1709
+ ).action(
1710
+ async (workflowFileId, statusScopedId, options) => {
1711
+ const body = runOrExit4(() => {
1712
+ if (options.descFile && options.clearDesc) {
1713
+ throw new WorkflowOptionsError("Error: --desc-file cannot be used with --clear-desc.");
1714
+ }
1715
+ const next = {};
1716
+ if (options.name) next.name = options.name;
1717
+ const type = parseStatusType(options.type);
1718
+ if (type !== void 0) next.type = type;
1719
+ const desc = readDescFile(options.descFile);
1720
+ if (desc !== void 0) next.desc = desc;
1721
+ if (options.clearDesc) next.desc = null;
1722
+ Object.assign(next, parsePreferredAssigneeBody(options));
1723
+ assertNonEmptyBody(next);
1724
+ return next;
1725
+ });
1726
+ const id = runOrExit4(() => parsePositiveInteger(statusScopedId, "statusScopedId"));
1727
+ startSpinner("Updating status...");
1728
+ const response = await httpPatch(
1729
+ `/workspaces/${options.workspace}/workflows/${workflowFileId}/statuses/${id}`,
1730
+ body
1731
+ );
1732
+ if (!response.ok) {
1733
+ stopSpinner(false, "Failed");
1734
+ failResponse(response);
1735
+ }
1736
+ stopSpinner(true, "Status updated");
1737
+ printStatusDetail(response.data);
2574
1738
  }
2575
- });
2576
- }
2577
- async function buildSandboxStatusOrExit(context) {
2578
- const repos = listRepoEntries(context.repoPayload).map((repo) => ({
2579
- name: repo.name,
2580
- repoId: repo.repoId
2581
- }));
2582
- try {
2583
- const state = await loadCheckoutState();
2584
- if (!state) return buildUninitializedStatus(context.workspaceId, repos);
2585
- validateStateContext(state, context);
2586
- return {
2587
- mode: "sandbox",
2588
- workspaceId: context.workspaceId,
2589
- checkout: {
2590
- mode: state.mode,
2591
- root: state.checkoutRoot,
2592
- checkedOutScopes: state.checkedOutScopes,
2593
- localChangesTracked: true,
2594
- changes: await inspectCheckoutStatus(state)
2595
- },
2596
- repos
2597
- };
2598
- } catch (error) {
2599
- printError(error instanceof Error ? error.message : String(error));
2600
- process.exit(1);
2601
- }
2602
- }
2603
- function buildUninitializedStatus(workspaceId, repos) {
2604
- return {
2605
- mode: "sandbox",
2606
- workspaceId,
2607
- checkout: {
2608
- mode: "not_initialized",
2609
- checkedOutScopes: [],
2610
- localChangesTracked: false
2611
- },
2612
- repos
2613
- };
2614
- }
2615
- function validateStateContext(state, context) {
2616
- if (state.workspaceId !== context.workspaceId) {
2617
- throw new Error(`Checkout state belongs to workspace ${state.workspaceId}, not ${context.workspaceId}`);
2618
- }
2619
- const currentRepos = new Map(listRepoEntries(context.repoPayload).map((repo) => [repo.repoId, repo.name]));
2620
- for (const [repoId, repo] of Object.entries(state.repos)) {
2621
- const currentName = currentRepos.get(repoId);
2622
- if (!currentName) throw new Error(`Checkout state contains unknown repo: ${repoId}`);
2623
- if (currentName !== repo.name) {
2624
- throw new Error(`Checkout state repo name does not match current workspace: ${repo.name}`);
1739
+ );
1740
+ addWorkspaceOption(
1741
+ status.command("move-after").description("Move a workflow status after another status").argument("<workflowFileId>").argument("<statusScopedId>").option("--after <statusScopedId>", "Anchor status scoped ID").option("--first", "Move to first position")
1742
+ ).action(
1743
+ async (workflowFileId, statusScopedId, options) => {
1744
+ const { id, anchoredStatusScopedId } = runOrExit4(() => {
1745
+ if (options.after && options.first) {
1746
+ throw new WorkflowOptionsError("Error: --after cannot be used with --first.");
1747
+ }
1748
+ if (!options.after && !options.first) {
1749
+ throw new WorkflowOptionsError("Error: either --after or --first is required.");
1750
+ }
1751
+ return {
1752
+ id: parsePositiveInteger(statusScopedId, "statusScopedId"),
1753
+ anchoredStatusScopedId: options.first ? null : parsePositiveInteger(options.after, "--after")
1754
+ };
1755
+ });
1756
+ startSpinner("Moving status...");
1757
+ const response = await httpPost(
1758
+ `/workspaces/${options.workspace}/workflows/${workflowFileId}/statuses/${id}/move-after`,
1759
+ { anchoredStatusScopedId }
1760
+ );
1761
+ if (!response.ok) {
1762
+ stopSpinner(false, "Failed");
1763
+ failResponse(response);
1764
+ }
1765
+ stopSpinner(true, "Status moved");
2625
1766
  }
2626
- }
2627
- }
2628
- function resolveRuntimeContextOrExit2() {
2629
- try {
2630
- return resolveRuntimeContext();
2631
- } catch (error) {
2632
- if (error instanceof RuntimeContextError) {
2633
- printError(error.message);
2634
- process.exit(1);
1767
+ );
1768
+ addWorkspaceOption(
1769
+ status.command("delete").description("Delete a workflow status").argument("<workflowFileId>").argument("<statusScopedId>")
1770
+ ).action(async (workflowFileId, statusScopedId, options) => {
1771
+ const id = runOrExit4(() => parsePositiveInteger(statusScopedId, "statusScopedId"));
1772
+ startSpinner("Deleting status...");
1773
+ const response = await httpDelete(
1774
+ `/workspaces/${options.workspace}/workflows/${workflowFileId}/statuses/${id}`
1775
+ );
1776
+ if (!response.ok) {
1777
+ stopSpinner(false, "Failed");
1778
+ failResponse(response);
2635
1779
  }
2636
- throw error;
2637
- }
2638
- }
2639
- function formatCheckoutMode(mode) {
2640
- return mode === "not_initialized" ? "not initialized" : `${mode} checkout`;
2641
- }
2642
-
2643
- // src/telemetry/config.ts
2644
- import * as fs3 from "fs";
2645
- import * as os from "os";
2646
- import * as path from "path";
2647
- function getConfigPath() {
2648
- return path.join(os.homedir(), ".moxt", "config.json");
2649
- }
2650
- function readTelemetryConfig() {
2651
- try {
2652
- const raw = fs3.readFileSync(getConfigPath(), "utf8");
2653
- const parsed = JSON.parse(raw);
2654
- return parsed.telemetry ?? {};
2655
- } catch {
2656
- return {};
2657
- }
2658
- }
2659
- function writeTelemetryConfig(update) {
2660
- const configPath = getConfigPath();
2661
- let existing = {};
2662
- try {
2663
- existing = JSON.parse(fs3.readFileSync(configPath, "utf8"));
2664
- } catch {
2665
- existing = {};
2666
- }
2667
- const currentTelemetry = existing.telemetry ?? {};
2668
- const next = { ...existing, telemetry: { ...currentTelemetry, ...update } };
2669
- fs3.mkdirSync(path.dirname(configPath), { recursive: true });
2670
- fs3.writeFileSync(configPath, JSON.stringify(next, null, 2));
2671
- }
2672
-
2673
- // src/telemetry/opt-out.ts
2674
- function isCiEnvironment() {
2675
- return process.env.CI === "true" || process.env.CI === "1";
1780
+ stopSpinner(true, `Deleted: ${response.data.scopedId}`);
1781
+ });
2676
1782
  }
2677
- function isTelemetryDisabled() {
2678
- if (process.env.MOXT_TELEMETRY_DISABLED === "1" || process.env.MOXT_TELEMETRY_DISABLED === "true") {
2679
- return true;
2680
- }
2681
- if (process.env.DO_NOT_TRACK === "1" || process.env.DO_NOT_TRACK === "true") {
2682
- return true;
1783
+ function registerTransitionCommands(workflow) {
1784
+ const transition = workflow.command("transition").description("Workflow transition operations");
1785
+ for (const action of ["create", "delete"]) {
1786
+ addWorkspaceOption(
1787
+ transition.command(action).description(`${action === "create" ? "Create" : "Delete"} a workflow transition`).argument("<workflowFileId>").requiredOption("--from <statusScopedId>", "Source status scoped ID").requiredOption("--to <statusScopedId>", "Target status scoped ID")
1788
+ ).action(async (workflowFileId, options) => {
1789
+ const body = runOrExit4(() => ({
1790
+ statusFromScopedId: parsePositiveInteger(options.from, "--from"),
1791
+ statusToScopedId: parsePositiveInteger(options.to, "--to")
1792
+ }));
1793
+ startSpinner(`${action === "create" ? "Creating" : "Deleting"} transition...`);
1794
+ const response = action === "create" ? await httpPost(
1795
+ `/workspaces/${options.workspace}/workflows/${workflowFileId}/transitions`,
1796
+ body
1797
+ ) : await httpDelete(
1798
+ `/workspaces/${options.workspace}/workflows/${workflowFileId}/transitions`,
1799
+ body
1800
+ );
1801
+ if (!response.ok) {
1802
+ stopSpinner(false, "Failed");
1803
+ failResponse(response);
1804
+ }
1805
+ stopSpinner(true, action === "create" ? "Transition created" : "Transition deleted");
1806
+ print(`${response.data.statusFromScopedId} -> ${response.data.statusToScopedId}`);
1807
+ });
2683
1808
  }
2684
- return readTelemetryConfig().disabled === true;
2685
1809
  }
2686
-
2687
- // src/commands/telemetry.ts
2688
- function registerTelemetryCommand(program2) {
2689
- const telemetry = program2.command("telemetry").description("Manage Moxt CLI telemetry");
2690
- telemetry.command("status").description("Show telemetry status").action(() => {
2691
- const cfg = readTelemetryConfig();
2692
- const envOverride = process.env.MOXT_TELEMETRY_DISABLED === "1" || process.env.MOXT_TELEMETRY_DISABLED === "true" || process.env.DO_NOT_TRACK === "1" || process.env.DO_NOT_TRACK === "true";
2693
- const state = isTelemetryDisabled() ? "disabled" : "enabled";
2694
- print(`telemetry: ${state}`);
2695
- if (envOverride) {
2696
- print("(disabled via environment variable)");
2697
- } else if (cfg.disabled) {
2698
- print("(disabled via config file)");
1810
+ function registerTaskCommands(workflow) {
1811
+ const task = workflow.command("task").description("Workflow task operations");
1812
+ addWorkspaceOption(
1813
+ task.command("list").description("List workflow tasks").option("--workflow-file-id <workflowFileId>", "Filter by workflow file ID").option("--scope <scope>", "Task scope", "all").option("-l, --limit <limit>", "Maximum number of tasks to return", "20").option("--cursor <cursor>", "Pagination cursor")
1814
+ ).action(
1815
+ async (options) => {
1816
+ const query = runOrExit4(() => buildQuery({
1817
+ scope: parseTaskScope(options.scope),
1818
+ limit: parseSearchLimit(options.limit, 20, 100),
1819
+ cursor: options.cursor,
1820
+ workflowFileId: options.workflowFileId
1821
+ }));
1822
+ startSpinner("Fetching workflow tasks...");
1823
+ const response = await httpGet(`/workspaces/${options.workspace}/workflow-tasks${query}`);
1824
+ if (!response.ok) {
1825
+ stopSpinner(false, "Failed");
1826
+ failResponse(response);
1827
+ }
1828
+ if (response.data.tasks.length === 0) {
1829
+ stopSpinner(true, "No workflow tasks found.");
1830
+ return;
1831
+ }
1832
+ stopSpinner(true, `Found ${response.data.tasks.length} task(s)`);
1833
+ for (const item of response.data.tasks) printTaskSummary(item);
1834
+ if (response.data.nextCursor) print(`${colors.dim}nextCursor=${response.data.nextCursor}${colors.reset}`);
2699
1835
  }
2700
- });
2701
- telemetry.command("enable").description("Enable telemetry").action(() => {
2702
- writeTelemetryConfig({ disabled: false });
2703
- print("telemetry: enabled");
2704
- });
2705
- telemetry.command("disable").description("Disable telemetry").action(() => {
2706
- writeTelemetryConfig({ disabled: true });
2707
- print("telemetry: disabled");
2708
- });
2709
- }
2710
-
2711
- // src/commands/whoami.ts
2712
- function registerWhoamiCommand(program2) {
2713
- program2.command("whoami").description("Show current user information").action(async () => {
2714
- startSpinner("Fetching user info...");
2715
- const response = await httpGet("/users/whoami");
2716
- if (response.ok) {
2717
- stopSpinner(true, "Done");
2718
- print(`${colors.bold}Nickname${colors.reset}: ${response.data.nickname}`);
2719
- print(`${colors.bold}Email${colors.reset}: ${response.data.email}`);
2720
- } else {
1836
+ );
1837
+ addWorkspaceOption(
1838
+ task.command("get").description("Get a workflow task").argument("<workflowFileId>").argument("<taskScopedId>")
1839
+ ).action(async (workflowFileId, taskScopedId, options) => {
1840
+ const id = runOrExit4(() => parsePositiveInteger(taskScopedId, "taskScopedId"));
1841
+ startSpinner("Fetching task...");
1842
+ const response = await httpGet(
1843
+ `/workspaces/${options.workspace}/workflows/${workflowFileId}/tasks/${id}`
1844
+ );
1845
+ if (!response.ok) {
2721
1846
  stopSpinner(false, "Failed");
2722
- printError(`Error [${response.status}]: ${JSON.stringify(response.data)}`);
2723
- process.exit(1);
1847
+ failResponse(response);
2724
1848
  }
1849
+ stopSpinner(true, "Task fetched");
1850
+ printTaskDetail(response.data);
2725
1851
  });
2726
- }
2727
-
2728
- // src/commands/workspace.ts
2729
- var emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
2730
- function isValidEmail(email) {
2731
- return emailRegex.test(email);
2732
- }
2733
- function registerWorkspaceCommand(program2) {
2734
- const workspace = program2.command("workspace").description("Workspace management");
2735
- workspace.command("list").description("List all workspaces you belong to").action(async () => {
2736
- startSpinner("Fetching workspaces...");
2737
- const response = await httpGet("/workspaces");
2738
- if (response.ok) {
2739
- const { workspaces } = response.data;
2740
- if (workspaces.length === 0) {
2741
- stopSpinner(true, "No workspaces found.");
2742
- return;
1852
+ addAssigneeOptions(
1853
+ addWorkspaceOption(
1854
+ task.command("create").description("Create a workflow task").argument("<workflowFileId>").requiredOption("--title <title>", "Task title").requiredOption("--status <statusScopedId>", "Status scoped ID").option("--priority <priority>", "Priority: urgent, high, medium, or low", "medium").option("--desc-file <localPath>", "Local UTF-8 text file for task description").option("--owner-human-id <humanId>", "Task owner human ID")
1855
+ )
1856
+ ).action(
1857
+ async (workflowFileId, options) => {
1858
+ const body = runOrExit4(() => {
1859
+ const next = {
1860
+ title: options.title,
1861
+ statusScopedId: parsePositiveInteger(options.status, "--status"),
1862
+ priority: parseRequiredPriority(options.priority)
1863
+ };
1864
+ const desc = readDescFile(options.descFile);
1865
+ if (desc !== void 0) next.desc = desc;
1866
+ const ownerHumanId = parseOptionalPositiveInteger(options.ownerHumanId, "--owner-human-id");
1867
+ if (ownerHumanId !== void 0) next.ownerHumanId = ownerHumanId;
1868
+ Object.assign(next, parseAssigneeBody(options, false));
1869
+ return next;
1870
+ });
1871
+ startSpinner("Creating task...");
1872
+ const response = await httpPost(
1873
+ `/workspaces/${options.workspace}/workflows/${workflowFileId}/tasks`,
1874
+ body
1875
+ );
1876
+ if (!response.ok) {
1877
+ stopSpinner(false, "Failed");
1878
+ failResponse(response);
2743
1879
  }
2744
- stopSpinner(true, `Found ${workspaces.length} workspace(s)`);
2745
- for (const ws of workspaces) {
2746
- print(`${colors.bold}${ws.workspaceId}${colors.reset} ${ws.name}`);
1880
+ stopSpinner(true, "Task created");
1881
+ printTaskDetail(response.data);
1882
+ }
1883
+ );
1884
+ addWorkspaceOption(
1885
+ task.command("update").description("Update workflow task metadata").argument("<workflowFileId>").argument("<taskScopedId>").option("--title <title>", "Task title").option("--priority <priority>", "Priority: urgent, high, medium, or low").option("--desc-file <localPath>", "Local UTF-8 text file for task description").option("--clear-desc", "Clear task description").option("--owner-human-id <humanId>", "Task owner human ID")
1886
+ ).action(
1887
+ async (workflowFileId, taskScopedId, options) => {
1888
+ const body = runOrExit4(() => {
1889
+ if (options.descFile && options.clearDesc) {
1890
+ throw new WorkflowOptionsError("Error: --desc-file cannot be used with --clear-desc.");
1891
+ }
1892
+ const next = {};
1893
+ if (options.title) next.title = options.title;
1894
+ const priority = parsePriority(options.priority);
1895
+ if (priority !== void 0) next.priority = priority;
1896
+ const desc = readDescFile(options.descFile);
1897
+ if (desc !== void 0) next.desc = desc;
1898
+ if (options.clearDesc) next.desc = null;
1899
+ const ownerHumanId = parseOptionalPositiveInteger(options.ownerHumanId, "--owner-human-id");
1900
+ if (ownerHumanId !== void 0) next.ownerHumanId = ownerHumanId;
1901
+ assertNonEmptyBody(next);
1902
+ return next;
1903
+ });
1904
+ const id = runOrExit4(() => parsePositiveInteger(taskScopedId, "taskScopedId"));
1905
+ startSpinner("Updating task...");
1906
+ const response = await httpPatch(
1907
+ `/workspaces/${options.workspace}/workflows/${workflowFileId}/tasks/${id}/metadata`,
1908
+ body
1909
+ );
1910
+ if (!response.ok) {
1911
+ stopSpinner(false, "Failed");
1912
+ failResponse(response);
2747
1913
  }
2748
- } else {
1914
+ stopSpinner(true, "Task updated");
1915
+ printTaskDetail(response.data);
1916
+ }
1917
+ );
1918
+ for (const action of ["add-linked-files", "remove-linked-files"]) {
1919
+ addWorkspaceOption(
1920
+ task.command(action).description(`${action === "add-linked-files" ? "Add" : "Remove"} linked files on a workflow task`).argument("<workflowFileId>").argument("<taskScopedId>").requiredOption("--file-ids <ids>", "Comma-separated file IDs")
1921
+ ).action(
1922
+ async (workflowFileId, taskScopedId, options) => {
1923
+ const { id, fileIds } = runOrExit4(() => ({
1924
+ id: parsePositiveInteger(taskScopedId, "taskScopedId"),
1925
+ fileIds: parseFileIds(options.fileIds, "--file-ids")
1926
+ }));
1927
+ startSpinner(action === "add-linked-files" ? "Adding linked files..." : "Removing linked files...");
1928
+ const path2 = `/workspaces/${options.workspace}/workflows/${workflowFileId}/tasks/${id}/linked-files`;
1929
+ const response = action === "add-linked-files" ? await httpPost(path2, { fileIds }) : await httpDelete(path2, { fileIds });
1930
+ if (!response.ok) {
1931
+ stopSpinner(false, "Failed");
1932
+ failResponse(response);
1933
+ }
1934
+ stopSpinner(true, action === "add-linked-files" ? "Linked files added" : "Linked files removed");
1935
+ printTaskDetail(response.data);
1936
+ }
1937
+ );
1938
+ }
1939
+ addWorkspaceOption(
1940
+ task.command("move").description("Move a workflow task to another status").argument("<workflowFileId>").argument("<taskScopedId>").requiredOption("--status <statusScopedId>", "Target status scoped ID")
1941
+ ).action(
1942
+ async (workflowFileId, taskScopedId, options) => {
1943
+ const { id, statusScopedId } = runOrExit4(() => ({
1944
+ id: parsePositiveInteger(taskScopedId, "taskScopedId"),
1945
+ statusScopedId: parsePositiveInteger(options.status, "--status")
1946
+ }));
1947
+ startSpinner("Moving task...");
1948
+ const response = await httpPatch(
1949
+ `/workspaces/${options.workspace}/workflows/${workflowFileId}/tasks/${id}/status`,
1950
+ { statusScopedId }
1951
+ );
1952
+ if (!response.ok) {
1953
+ stopSpinner(false, "Failed");
1954
+ failResponse(response);
1955
+ }
1956
+ stopSpinner(true, "Task moved");
1957
+ printTaskDetail(response.data);
1958
+ }
1959
+ );
1960
+ addAssigneeOptions(
1961
+ addWorkspaceOption(
1962
+ task.command("assign").description("Update workflow task assignee").argument("<workflowFileId>").argument("<taskScopedId>").option("--clear", "Clear assignee")
1963
+ )
1964
+ ).action(
1965
+ async (workflowFileId, taskScopedId, options) => {
1966
+ const body = runOrExit4(() => {
1967
+ const next = parseAssigneeBody(options, true);
1968
+ if (Object.keys(next).length === 0 && options.clear !== true) assertNonEmptyBody(next);
1969
+ return next;
1970
+ });
1971
+ const id = runOrExit4(() => parsePositiveInteger(taskScopedId, "taskScopedId"));
1972
+ startSpinner("Updating task assignee...");
1973
+ const response = await httpPatch(
1974
+ `/workspaces/${options.workspace}/workflows/${workflowFileId}/tasks/${id}/assignee`,
1975
+ body
1976
+ );
1977
+ if (!response.ok) {
1978
+ stopSpinner(false, "Failed");
1979
+ failResponse(response);
1980
+ }
1981
+ stopSpinner(true, "Task assignee updated");
1982
+ printTaskDetail(response.data);
1983
+ }
1984
+ );
1985
+ addWorkspaceOption(
1986
+ task.command("delete").description("Delete a workflow task").argument("<workflowFileId>").argument("<taskScopedId>")
1987
+ ).action(async (workflowFileId, taskScopedId, options) => {
1988
+ const id = runOrExit4(() => parsePositiveInteger(taskScopedId, "taskScopedId"));
1989
+ startSpinner("Deleting task...");
1990
+ const response = await httpDelete(
1991
+ `/workspaces/${options.workspace}/workflows/${workflowFileId}/tasks/${id}`
1992
+ );
1993
+ if (!response.ok) {
2749
1994
  stopSpinner(false, "Failed");
2750
- printError(`Error [${response.status}]: ${JSON.stringify(response.data)}`);
2751
- process.exit(1);
1995
+ failResponse(response);
2752
1996
  }
1997
+ stopSpinner(true, `Deleted: ${response.data.scopedId}`);
2753
1998
  });
2754
- const members = program2.command("members").description("Manage workspace members");
2755
- members.command("list").description("List all members in a workspace").requiredOption("-w, --workspace <workspaceId>", "Workspace ID").action(async (options) => {
2756
- const { workspace: workspaceId } = options;
2757
- startSpinner("Fetching workspace members...");
2758
- const response = await httpGet(`/workspaces/${workspaceId}/members`);
1999
+ }
2000
+ function registerCommentCommands(workflow) {
2001
+ const comment = workflow.command("comment").description("Workflow task comment operations");
2002
+ addWorkspaceOption(
2003
+ comment.command("create").description("Create a workflow task comment").argument("<workflowFileId>").argument("<taskScopedId>").requiredOption("--content-path <localPath>", "Local UTF-8 text file for comment content")
2004
+ ).action(
2005
+ async (workflowFileId, taskScopedId, options) => {
2006
+ const { id, content } = runOrExit4(() => ({
2007
+ id: parsePositiveInteger(taskScopedId, "taskScopedId"),
2008
+ content: readCommentContentFile2(options.contentPath)
2009
+ }));
2010
+ startSpinner("Creating comment...");
2011
+ const response = await httpPost(
2012
+ `/workspaces/${options.workspace}/workflows/${workflowFileId}/tasks/${id}/comments`,
2013
+ { content }
2014
+ );
2015
+ if (!response.ok) {
2016
+ stopSpinner(false, "Failed");
2017
+ failResponse(response);
2018
+ }
2019
+ stopSpinner(true, "Comment created");
2020
+ printCommentDetail(response.data);
2021
+ }
2022
+ );
2023
+ addWorkspaceOption(
2024
+ comment.command("list").description("List workflow task comments").argument("<workflowFileId>").argument("<taskScopedId>")
2025
+ ).action(async (workflowFileId, taskScopedId, options) => {
2026
+ const id = runOrExit4(() => parsePositiveInteger(taskScopedId, "taskScopedId"));
2027
+ startSpinner("Fetching comments...");
2028
+ const response = await httpGet(
2029
+ `/workspaces/${options.workspace}/workflows/${workflowFileId}/tasks/${id}/comments`
2030
+ );
2759
2031
  if (!response.ok) {
2760
- stopSpinner(false, "Failed to fetch members");
2761
- printError(`Error [${response.status}]: ${JSON.stringify(response.data)}`);
2762
- process.exit(1);
2032
+ stopSpinner(false, "Failed");
2033
+ failResponse(response);
2763
2034
  }
2764
- const { members: members2 } = response.data;
2765
- if (members2.length === 0) {
2766
- stopSpinner(true, "No members found.");
2035
+ if (response.data.items.length === 0) {
2036
+ stopSpinner(true, "No comments found.");
2767
2037
  return;
2768
2038
  }
2769
- stopSpinner(true, `Found ${members2.length} member(s)`);
2770
- for (const member of members2) {
2771
- const name = member.displayName || "-";
2772
- print(`${colors.bold}${member.email}${colors.reset} ${name} ${member.role}`);
2773
- }
2039
+ stopSpinner(true, `Found ${response.data.items.length} comment(s)`);
2040
+ response.data.items.forEach((item, index) => {
2041
+ if (index > 0) print("");
2042
+ printCommentDetail(item);
2043
+ });
2774
2044
  });
2775
- members.command("remove").description("Remove members from a workspace").requiredOption("-w, --workspace <workspaceId>", "Workspace ID").argument("<emails>", "Emails to remove (comma-separated)").action(async (emailsArg, options) => {
2776
- const { workspace: workspaceId } = options;
2777
- const emails = emailsArg.split(",").map((e) => e.trim()).filter(Boolean);
2778
- if (emails.length === 0) {
2779
- printError("No valid emails provided");
2780
- process.exit(1);
2781
- }
2782
- const invalidEmails = emails.filter((e) => !isValidEmail(e));
2783
- if (invalidEmails.length > 0) {
2784
- for (const email of invalidEmails) {
2785
- printError(`Invalid email format: ${email}`);
2045
+ addWorkspaceOption(
2046
+ comment.command("delete").description("Delete a workflow task comment").argument("<workflowFileId>").argument("<taskScopedId>").argument("<commentId>")
2047
+ ).action(
2048
+ async (workflowFileId, taskScopedId, commentId, options) => {
2049
+ const ids = runOrExit4(() => ({
2050
+ task: parsePositiveInteger(taskScopedId, "taskScopedId"),
2051
+ comment: parsePositiveInteger(commentId, "commentId")
2052
+ }));
2053
+ startSpinner("Deleting comment...");
2054
+ const response = await httpDelete(
2055
+ `/workspaces/${options.workspace}/workflows/${workflowFileId}/tasks/${ids.task}/comments/${ids.comment}`
2056
+ );
2057
+ if (!response.ok) {
2058
+ stopSpinner(false, "Failed");
2059
+ failResponse(response);
2786
2060
  }
2787
- process.exit(1);
2061
+ stopSpinner(true, `Deleted: ${response.data.id}`);
2788
2062
  }
2789
- startSpinner("Fetching workspace members...");
2790
- const membersResponse = await httpGet(`/workspaces/${workspaceId}/members`);
2791
- if (!membersResponse.ok) {
2792
- stopSpinner(false, "Failed to fetch members");
2793
- printError(`Error [${membersResponse.status}]: ${JSON.stringify(membersResponse.data)}`);
2794
- process.exit(1);
2063
+ );
2064
+ }
2065
+ function registerRunCommands(workflow) {
2066
+ const run = workflow.command("run").description("Workflow agent run operations");
2067
+ addWorkspaceOption(
2068
+ run.command("status").description("Get workflow agent run statuses").argument("<workflowFileId>").argument("<triggerIds...>")
2069
+ ).action(async (workflowFileId, triggerIds, options) => {
2070
+ const workflowPipelineTriggerIds = runOrExit4(
2071
+ () => triggerIds.map((triggerId) => parsePositiveInteger(triggerId, "workflowPipelineTriggerId"))
2072
+ );
2073
+ startSpinner("Fetching run statuses...");
2074
+ const response = await httpPost(
2075
+ `/workspaces/${options.workspace}/workflows/${workflowFileId}/pipeline/triggers/statuses`,
2076
+ { workflowPipelineTriggerIds }
2077
+ );
2078
+ if (!response.ok) {
2079
+ stopSpinner(false, "Failed");
2080
+ failResponse(response);
2795
2081
  }
2796
- stopSpinner(true, "Members fetched");
2797
- const { members: members2 } = membersResponse.data;
2798
- const emailToMember = new Map(members2.map((m) => [m.email.toLowerCase(), m]));
2799
- for (const email of emails) {
2800
- const member = emailToMember.get(email.toLowerCase());
2801
- if (!member) {
2802
- print(`${colors.red}\u2717${colors.reset} ${email} - not found in workspace`);
2803
- continue;
2804
- }
2805
- const deleteResponse = await httpDelete(`/workspaces/${workspaceId}/members/${member.userId}`);
2806
- if (deleteResponse.ok) {
2807
- print(`${colors.green}\u2713${colors.reset} ${email} - removed`);
2808
- } else {
2809
- print(`${colors.red}\u2717${colors.reset} ${email} - failed: ${JSON.stringify(deleteResponse.data)}`);
2810
- }
2082
+ stopSpinner(true, `Found ${response.data.length} run status item(s)`);
2083
+ for (const item of response.data) {
2084
+ print(`${colors.bold}${item.workflowPipelineTriggerId}${colors.reset} ${item.pipelineStatus}`);
2811
2085
  }
2812
2086
  });
2813
- const space = program2.command("space").description("Manage spaces");
2814
- space.command("list").description("List accessible spaces in a workspace").requiredOption("-w, --workspace <workspaceId>", "Workspace ID").action(async (options) => {
2815
- const { workspace: workspaceId } = options;
2816
- startSpinner("Fetching spaces...");
2817
- const response = await httpGet(`/workspaces/${workspaceId}/spaces`);
2087
+ addWorkspaceOption(
2088
+ run.command("output").description("Get workflow agent run output events").argument("<workflowFileId>").argument("<triggerId>")
2089
+ ).action(async (workflowFileId, triggerId, options) => {
2090
+ const id = runOrExit4(() => parsePositiveInteger(triggerId, "workflowPipelineTriggerId"));
2091
+ startSpinner("Fetching run output...");
2092
+ const response = await httpGet(
2093
+ `/workspaces/${options.workspace}/workflows/${workflowFileId}/pipeline/trigger/${id}/output`
2094
+ );
2818
2095
  if (!response.ok) {
2819
- stopSpinner(false, "Failed to fetch spaces");
2820
- printError(`Error [${response.status}]: ${JSON.stringify(response.data)}`);
2821
- process.exit(1);
2822
- }
2823
- const { spaces } = response.data;
2824
- if (spaces.length === 0) {
2825
- stopSpinner(true, "No spaces found.");
2826
- return;
2096
+ stopSpinner(false, "Failed");
2097
+ failResponse(response);
2827
2098
  }
2828
- stopSpinner(true, `Found ${spaces.length} space(s)`);
2829
- print(
2830
- `${colors.bold}TYPE${colors.reset} ${colors.bold}TEAM_SPACE_ID${colors.reset} ${colors.bold}TEAMMATE_ID${colors.reset} ${colors.bold}NAME${colors.reset}`
2099
+ stopSpinner(true, `Run ${response.data.workflowPipelineTriggerId}`);
2100
+ for (const event of response.data.pipelineEvents) print(event);
2101
+ });
2102
+ addWorkspaceOption(
2103
+ run.command("abort").description("Abort a workflow agent run").argument("<workflowFileId>").argument("<triggerId>")
2104
+ ).action(async (workflowFileId, triggerId, options) => {
2105
+ const id = runOrExit4(() => parsePositiveInteger(triggerId, "workflowPipelineTriggerId"));
2106
+ startSpinner("Aborting run...");
2107
+ const response = await httpPost(
2108
+ `/workspaces/${options.workspace}/workflows/${workflowFileId}/pipeline/trigger/${id}/abort`,
2109
+ {}
2831
2110
  );
2832
- for (const s of spaces) {
2833
- const teamSpaceId = s.teamSpaceId ?? "";
2834
- const teammateId = s.agentId != null ? String(s.agentId) : "";
2835
- print(`${s.type} ${teamSpaceId} ${teammateId} ${s.name}`);
2111
+ if (!response.ok) {
2112
+ stopSpinner(false, "Failed");
2113
+ failResponse(response);
2836
2114
  }
2115
+ stopSpinner(true, `Abort accepted: ${id}`);
2837
2116
  });
2838
2117
  }
2839
2118
 
2840
2119
  // src/program.ts
2841
2120
  function createProgram(version) {
2842
- const program2 = new Command2();
2121
+ const program2 = new Command();
2843
2122
  program2.name("moxt").description("Moxt CLI - AI Workspace").version(version).action(() => {
2844
2123
  program2.help();
2845
2124
  });
2846
- registerAuthCommand(program2);
2847
2125
  registerWhoamiCommand(program2);
2848
2126
  registerWorkspaceCommand(program2);
2849
2127
  registerFileCommand(program2);
2850
2128
  registerMemoryCommand(program2);
2129
+ registerWorkflowCommand(program2);
2851
2130
  registerMiniappCommand(program2);
2852
- registerPullCommand(program2);
2853
- registerPushCommand(program2);
2854
- registerStatusCommand(program2);
2855
2131
  registerTelemetryCommand(program2);
2856
2132
  return program2;
2857
2133
  }
@@ -2863,7 +2139,7 @@ var FLUSH_TIMEOUT_MS = 3e3;
2863
2139
  var MAX_BATCH_SIZE = 100;
2864
2140
  function commonLabels() {
2865
2141
  return {
2866
- cli_version: "0.4.0-beta.1",
2142
+ cli_version: "0.4.0",
2867
2143
  node_version: process.versions.node,
2868
2144
  os: platform2(),
2869
2145
  arch: arch2(),
@@ -2909,7 +2185,7 @@ async function flush() {
2909
2185
  }
2910
2186
  const batch = buffer.splice(0, MAX_BATCH_SIZE);
2911
2187
  const payload = {
2912
- release_id: "0.4.0-beta.1",
2188
+ release_id: "0.4.0",
2913
2189
  client_type: "cli",
2914
2190
  metric_data_list: batch.map((m) => ({
2915
2191
  profile: "cli",
@@ -3050,9 +2326,9 @@ function installExitHandler() {
3050
2326
 
3051
2327
  // src/index.ts
3052
2328
  updateNotifier({
3053
- pkg: { name: "@moxt-ai/cli", version: "0.4.0-beta.1" }
2329
+ pkg: { name: "@moxt-ai/cli", version: "0.4.0" }
3054
2330
  }).notify();
3055
- var program = createProgram("0.4.0-beta.1");
2331
+ var program = createProgram("0.4.0");
3056
2332
  instrumentProgram(program);
3057
2333
  installExitHandler();
3058
2334
  program.parseAsync(process.argv).then(async () => {