@moxt-ai/cli 0.4.0-beta.0 → 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,204 +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
- var DEFAULT_USER_HOST = "api.moxt.ai";
28
- var SANDBOX_ENV_KEYS = [
29
- "BASE_API_HOST",
30
- "SANDBOX_TOOL_API_TOKEN",
31
- "MOXT_REPO_PAYLOAD",
32
- "MOXT_WORKSPACE_ID"
33
- ];
34
- var RuntimeContextError = class extends Error {
35
- constructor(message) {
36
- super(message);
37
- this.name = "RuntimeContextError";
38
- }
39
- };
40
- function resolveRuntimeContext(env = process.env) {
41
- if (hasAnySandboxEnv(env)) {
42
- return resolveSandboxRuntimeContext(env);
43
- }
44
- const apiKey = readNonEmptyEnv(env, "MOXT_API_KEY");
45
- if (apiKey) {
46
- return {
47
- mode: "user",
48
- host: readNonEmptyEnv(env, "MOXT_HOST") ?? DEFAULT_USER_HOST,
49
- apiKey
50
- };
51
- }
52
- throw new RuntimeContextError("Missing auth. Set sandbox environment variables or MOXT_API_KEY.");
53
- }
54
- function resolveSandboxRuntimeContext(env = process.env) {
55
- const missingKeys = SANDBOX_ENV_KEYS.filter((key) => !readNonEmptyEnv(env, key));
56
- if (missingKeys.length > 0) {
57
- throw new RuntimeContextError(`Incomplete sandbox environment. Missing: ${missingKeys.join(", ")}.`);
58
- }
59
- return {
60
- mode: "sandbox",
61
- baseApiHost: normalizeBaseApiHost(readRequiredEnv(env, "BASE_API_HOST")),
62
- sandboxToolApiToken: readRequiredEnv(env, "SANDBOX_TOOL_API_TOKEN"),
63
- workspaceId: readRequiredEnv(env, "MOXT_WORKSPACE_ID"),
64
- repoPayload: parseRepoPayload(readRequiredEnv(env, "MOXT_REPO_PAYLOAD"))
65
- };
66
- }
67
- function parseRepoPayload(raw) {
68
- let parsed;
69
- try {
70
- parsed = JSON.parse(raw);
71
- } catch (error) {
72
- throw new RuntimeContextError(`MOXT_REPO_PAYLOAD must be valid JSON: ${error instanceof Error ? error.message : String(error)}`);
73
- }
74
- if (!isRecord(parsed)) {
75
- throw new RuntimeContextError("MOXT_REPO_PAYLOAD must be a JSON object.");
76
- }
77
- const personal = parseRepoEntry(parsed.personal, "personal");
78
- const teamsValue = parsed.teams;
79
- if (!Array.isArray(teamsValue)) {
80
- throw new RuntimeContextError("MOXT_REPO_PAYLOAD.teams must be an array.");
81
- }
82
- const teams = teamsValue.map((entry, index) => parseRepoEntry(entry, `teams[${index}]`));
83
- const workspace = parsed.workspace == null ? null : parseRepoEntry(parsed.workspace, "workspace");
84
- return { personal, teams, workspace };
85
- }
86
- function normalizeBaseApiHost(value) {
87
- const trimmed = value.trim();
88
- if (!trimmed) {
89
- throw new RuntimeContextError("BASE_API_HOST must not be empty.");
90
- }
91
- return trimmed.replace(/\/+$/, "");
92
- }
93
- function hasAnySandboxEnv(env) {
94
- return SANDBOX_ENV_KEYS.some((key) => readNonEmptyEnv(env, key) !== void 0);
95
- }
96
- function readRequiredEnv(env, key) {
97
- const value = readNonEmptyEnv(env, key);
98
- if (!value) {
99
- throw new RuntimeContextError(`${key} is required.`);
100
- }
101
- return value;
102
- }
103
- function readNonEmptyEnv(env, key) {
104
- const value = env[key];
105
- if (typeof value !== "string") return void 0;
106
- const trimmed = value.trim();
107
- return trimmed.length > 0 ? trimmed : void 0;
108
- }
109
- function parseRepoEntry(value, path2) {
110
- if (!isRecord(value)) {
111
- throw new RuntimeContextError(`MOXT_REPO_PAYLOAD.${path2} must be an object.`);
112
- }
113
- const repoId = readStringField(value, "repoId", path2);
114
- const name = readStringField(value, "name", path2);
115
- return { repoId, name };
116
- }
117
- function readStringField(record2, field, path2) {
118
- const value = record2[field];
119
- if (typeof value !== "string" || value.trim().length === 0) {
120
- throw new RuntimeContextError(`MOXT_REPO_PAYLOAD.${path2}.${field} must be a non-empty string.`);
121
- }
122
- return value;
123
- }
124
- function isRecord(value) {
125
- return typeof value === "object" && value !== null && !Array.isArray(value);
126
- }
127
-
128
- // src/utils/sandbox-path.ts
129
- var WorkspacePathError = class extends Error {
130
- constructor(message) {
131
- super(message);
132
- this.name = "WorkspacePathError";
133
- }
134
- };
135
- function resolveWorkspacePath(repoPayload, workspacePath) {
136
- const normalizedPath = normalizeWorkspacePath(workspacePath);
137
- const [repoName, ...repoPathParts] = normalizedPath.split("/");
138
- const repoEntries = listRepoEntries(repoPayload);
139
- const repo = repoEntries.find((entry) => entry.name === repoName);
140
- if (!repo) {
141
- throw new WorkspacePathError(`Workspace path must start with one of: ${repoEntries.map((entry) => entry.name).join(", ")}.`);
142
- }
143
- return {
144
- repoId: repo.repoId,
145
- repoName: repo.name,
146
- repoPath: repoPathParts.join("/")
147
- };
148
- }
149
- function listRepoEntries(repoPayload) {
150
- return [
151
- repoPayload.personal,
152
- ...repoPayload.teams,
153
- ...repoPayload.workspace ? [repoPayload.workspace] : []
154
- ];
155
- }
156
- function normalizeWorkspacePath(workspacePath) {
157
- const trimmed = workspacePath.trim();
158
- if (!trimmed) {
159
- throw new WorkspacePathError("Workspace path must not be empty.");
160
- }
161
- if (trimmed.startsWith("/")) {
162
- throw new WorkspacePathError("Workspace path must be relative.");
163
- }
164
- const parts = trimmed.split("/").filter((part) => part.length > 0 && part !== ".");
165
- if (parts.length === 0) {
166
- throw new WorkspacePathError("Workspace path must include a repo name.");
167
- }
168
- if (parts.some((part) => part === "..")) {
169
- throw new WorkspacePathError('Workspace path must not contain "..".');
170
- }
171
- return parts.join("/");
172
- }
173
-
174
- // src/commands/auth.ts
175
- function registerAuthCommand(program2) {
176
- const auth = program2.command("auth").description("Authentication utilities");
177
- auth.command("status").description("Show CLI authentication mode").action(() => {
178
- try {
179
- const context = resolveRuntimeContext();
180
- if (context.mode === "sandbox") {
181
- print(`${colors.bold}Mode${colors.reset}: sandbox`);
182
- print(`${colors.bold}Workspace${colors.reset}: ${context.workspaceId}`);
183
- print(`${colors.bold}Base API${colors.reset}: ${context.baseApiHost}`);
184
- print(`${colors.bold}Repos${colors.reset}:`);
185
- for (const repo of listRepoEntries(context.repoPayload)) {
186
- print(` ${repo.name} ${repo.repoId}`);
187
- }
188
- return;
189
- }
190
- print(`${colors.bold}Mode${colors.reset}: user`);
191
- print(`${colors.bold}Host${colors.reset}: ${context.host}`);
192
- } catch (error) {
193
- if (error instanceof RuntimeContextError) {
194
- printError(error.message);
195
- process.exit(1);
196
- }
197
- throw error;
198
- }
199
- });
200
- }
7
+ import { Command } from "commander";
201
8
 
202
9
  // src/commands/file.ts
203
10
  import * as fs2 from "fs";
204
11
 
12
+ // src/commands/file-comment.ts
13
+ import * as fs from "fs";
14
+ import { TextDecoder } from "util";
15
+
205
16
  // src/utils/file-selector.ts
206
17
  var SpaceOptionsError = class extends Error {
207
18
  constructor(message) {
@@ -342,7 +153,7 @@ function getApiKeyOrUndefined() {
342
153
 
343
154
  // src/utils/http.ts
344
155
  function getUserAgent() {
345
- return `moxt-cli/${"0.4.0-beta.0"} (${platform()}/${arch()}) node/${process.versions.node}`;
156
+ return `moxt-cli/${"0.4.0"} (${platform()}/${arch()}) node/${process.versions.node}`;
346
157
  }
347
158
  async function fetchApi(method, path2, body) {
348
159
  const apiKey = getApiKey();
@@ -382,6 +193,9 @@ async function httpPut(path2, body) {
382
193
  async function httpPost(path2, body) {
383
194
  return request("POST", path2, body);
384
195
  }
196
+ async function httpPatch(path2, body) {
197
+ return request("PATCH", path2, body);
198
+ }
385
199
  async function httpDelete(path2, body) {
386
200
  return request("DELETE", path2, body);
387
201
  }
@@ -395,6 +209,265 @@ async function httpRaw(method, path2, body) {
395
209
  };
396
210
  }
397
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
+
398
471
  // src/utils/search-options.ts
399
472
  var SearchOptionsError = class extends Error {
400
473
  constructor(message) {
@@ -436,7 +509,7 @@ function parseTeammateId(raw) {
436
509
  }
437
510
 
438
511
  // src/utils/run-or-exit.ts
439
- function runOrExit(fn) {
512
+ function runOrExit2(fn) {
440
513
  try {
441
514
  return fn();
442
515
  } catch (err) {
@@ -448,279 +521,38 @@ function runOrExit(fn) {
448
521
  }
449
522
  }
450
523
 
451
- // src/utils/sandbox-client.ts
452
- var DEFAULT_TIMEOUT_MS = 3e4;
453
- var SandboxApiError = class extends Error {
454
- status;
455
- body;
456
- constructor(status, body) {
457
- super(`Sandbox API request failed with ${status}: ${body}`);
458
- this.name = "SandboxApiError";
459
- this.status = status;
460
- this.body = body;
461
- }
462
- };
463
- var SandboxMoxtClient = class {
464
- constructor(context) {
465
- this.context = context;
466
- }
467
- context;
468
- async request(method, path2, body, options = {}) {
469
- const signal = AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
470
- const response = await fetch(`${this.context.baseApiHost}${path2}`, {
471
- method,
472
- headers: {
473
- "Content-Type": "application/json",
474
- Authorization: `Bearer ${this.context.sandboxToolApiToken}`
475
- },
476
- body: body === void 0 ? void 0 : JSON.stringify(body),
477
- signal
478
- });
479
- const contentType = response.headers.get("content-type");
480
- 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
+ );
481
547
  if (!response.ok) {
482
- 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);
483
551
  }
484
- return {
485
- status: response.status,
486
- data
487
- };
488
- }
489
- };
490
-
491
- // src/utils/sandbox-file.ts
492
- import { createHash } from "crypto";
493
- var MFS_FILE_TYPE_REGULAR = 1;
494
- var MFS_FILE_SIZE_LIMIT_BYTES = 10 * 1024 * 1024;
495
- var MFS_FILE_SIZE_LIMIT_LABEL = `${MFS_FILE_SIZE_LIMIT_BYTES / (1024 * 1024)}MB`;
496
- var SandboxFileError = class extends Error {
497
- constructor(message, code) {
498
- super(message);
499
- this.code = code;
500
- this.name = "SandboxFileError";
501
- }
502
- code;
503
- };
504
- async function readSandboxFile(context, workspacePath) {
505
- const resolved = resolveSandboxFilePath(context, workspacePath);
506
- const tree = await fetchRepoTree(context, resolved);
507
- const entry = findTreeEntry(tree, resolved);
508
- if (!entry) {
509
- throw new SandboxFileError(`${workspacePath} does not exist`, "not-found");
510
- }
511
- if (entry.type === "tree") {
512
- throw new SandboxFileError(`${workspacePath} is a directory`, "directory");
513
- }
514
- if (!entry.sha) {
515
- throw new SandboxFileError(`${workspacePath} does not have downloadable content`, "remote");
516
- }
517
- if (entry.size !== null && entry.size > MFS_FILE_SIZE_LIMIT_BYTES) {
518
- throw new SandboxFileError(`File too large (max ${MFS_FILE_SIZE_LIMIT_LABEL}): ${workspacePath}`, "too-large");
519
- }
520
- const client = new SandboxMoxtClient(context);
521
- const presign = await client.request(
522
- "POST",
523
- `/agent/api/pipeline-sandbox/mfs/${encodeURIComponent(resolved.repoId)}/presign-download`,
524
- { hashes: [entry.sha] }
525
- );
526
- const url = presign.data.urls[entry.sha];
527
- if (!url) {
528
- throw new SandboxFileError(`No download URL returned for ${workspacePath}`, "remote");
529
- }
530
- const response = await fetch(url);
531
- if (!response.ok) {
532
- throw new SandboxFileError(`Download failed [${response.status}]`, "remote");
533
- }
534
- const content = await response.text();
535
- const data = Buffer.from(content, "utf8");
536
- if (data.length > MFS_FILE_SIZE_LIMIT_BYTES) {
537
- throw new SandboxFileError(`File too large (max ${MFS_FILE_SIZE_LIMIT_LABEL}): ${workspacePath}`, "too-large");
538
- }
539
- const actualSha = createHash("sha256").update(data).digest("hex");
540
- if (actualSha !== entry.sha) {
541
- throw new SandboxFileError(`Downloaded content hash mismatch: ${workspacePath}`, "remote");
542
- }
543
- return {
544
- path: workspacePath,
545
- content,
546
- sha: entry.sha,
547
- size: data.length,
548
- fileId: entry.fileId,
549
- fileType: entry.fileType
550
- };
551
- }
552
- async function writeSandboxFile(context, workspacePath, content, policy) {
553
- const data = Buffer.from(content, "utf8");
554
- if (data.length > MFS_FILE_SIZE_LIMIT_BYTES) {
555
- throw new SandboxFileError(`File too large (max ${MFS_FILE_SIZE_LIMIT_LABEL}): ${workspacePath}`, "too-large");
556
- }
557
- const resolved = resolveSandboxFilePath(context, workspacePath);
558
- const tree = await fetchRepoTree(context, resolved);
559
- const existing = findTreeEntry(tree, resolved);
560
- if (existing?.type === "tree") {
561
- throw new SandboxFileError(`${workspacePath} is a directory`, "directory");
562
- }
563
- if (existing && !existing.sha) {
564
- throw new SandboxFileError(`${workspacePath} does not have base content hash`, "remote");
565
- }
566
- const baseContentHash = resolveBaseContentHash(workspacePath, existing, policy);
567
- const contentHash = createHash("sha256").update(data).digest("hex");
568
- const client = new SandboxMoxtClient(context);
569
- const presign = await client.request(
570
- "POST",
571
- `/agent/api/pipeline-sandbox/mfs/${encodeURIComponent(resolved.repoId)}/presign-upload`,
572
- { hashes: [contentHash] }
573
- );
574
- const uploadUrl = presign.data.urls[contentHash];
575
- if (!uploadUrl) {
576
- throw new SandboxFileError(`No upload URL returned for ${workspacePath}`, "remote");
577
- }
578
- const upload = await fetch(uploadUrl, {
579
- method: "PUT",
580
- headers: { "Content-Type": "application/octet-stream" },
581
- body: new Uint8Array(data)
582
- });
583
- if (!upload.ok) {
584
- throw new SandboxFileError(`Upload failed [${upload.status}]`, "remote");
585
- }
586
- const action = existing ? "update" : "create";
587
- await client.request(
588
- "POST",
589
- "/agent/api/pipeline-sandbox/mfs-workspace/unified-push",
590
- {
591
- message: `moxt file write ${workspacePath}`,
592
- repoPushes: [
593
- {
594
- repoId: resolved.repoId,
595
- changes: [
596
- {
597
- action,
598
- ...existing?.fileId ? { fileId: existing.fileId } : {},
599
- path: resolved.repoPath,
600
- contentHash,
601
- ...baseContentHash ? { baseContentHash } : {},
602
- size: data.length,
603
- fileType: MFS_FILE_TYPE_REGULAR
604
- }
605
- ]
606
- }
607
- ],
608
- crossRepoMoves: []
609
- }
610
- );
611
- return { path: workspacePath, created: !existing };
612
- }
613
- function resolveBaseContentHash(workspacePath, existing, policy) {
614
- if (policy.type === "force") return void 0;
615
- if (policy.type === "use-current-sha") return existing?.sha ?? void 0;
616
- if (policy.type === "create-only") {
617
- if (!existing) return void 0;
618
- throw new SandboxFileError(
619
- `Existing file requires --base-sha or --force: ${workspacePath} (actual ${existing.sha})`,
620
- "conflict"
621
- );
622
- }
623
- if (existing?.sha === policy.sha) return policy.sha;
624
- throw new SandboxFileError(
625
- `File version conflict: ${workspacePath} (expected ${policy.sha}, actual ${existing?.sha ?? "missing"})`,
626
- "conflict"
627
- );
628
- }
629
- function resolveSandboxFilePath(context, workspacePath) {
630
- let resolved;
631
- try {
632
- resolved = resolveWorkspacePath(context.repoPayload, workspacePath);
633
- } catch (error) {
634
- throw new SandboxFileError(error instanceof Error ? error.message : String(error), "invalid-path");
635
- }
636
- if (!resolved.repoPath) {
637
- throw new SandboxFileError(`${workspacePath} is a directory`, "directory");
638
- }
639
- return resolved;
640
- }
641
- async function fetchRepoTree(context, resolved) {
642
- const client = new SandboxMoxtClient(context);
643
- const params = new URLSearchParams({ entryFilter: "paths", path: resolved.repoPath });
644
- const response = await client.request(
645
- "GET",
646
- `/agent/api/pipeline-sandbox/mfs/${encodeURIComponent(resolved.repoId)}/tree?${params.toString()}`
647
- );
648
- return response.data;
649
- }
650
- function findTreeEntry(tree, resolved) {
651
- return tree.entries.find((entry) => entry.path === resolved.repoPath);
652
- }
653
-
654
- // src/utils/spinner.ts
655
- var frames = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
656
- var intervalId = null;
657
- var frameIndex = 0;
658
- function startSpinner(message) {
659
- frameIndex = 0;
660
- process.stdout.write(`${frames[frameIndex]} ${message}`);
661
- intervalId = setInterval(() => {
662
- frameIndex = (frameIndex + 1) % frames.length;
663
- process.stdout.write(`\r${frames[frameIndex]} ${message}`);
664
- }, 80);
665
- }
666
- function stopSpinner(success, message) {
667
- if (intervalId) {
668
- clearInterval(intervalId);
669
- intervalId = null;
670
- }
671
- const symbol = success ? "\x1B[32m\u2713\x1B[0m" : "\x1B[31m\u2717\x1B[0m";
672
- process.stdout.write(`\x1B[2K\r${symbol} ${message ?? ""}
673
- `);
674
- }
675
-
676
- // src/utils/write-content.ts
677
- import * as fs from "fs";
678
- function readWriteCommandContentOrExit(options, readContentFile, readStdin = () => fs.readFileSync(0, "utf8")) {
679
- const inputModes = [options.content !== void 0, options.contentFile !== void 0, options.stdin === true].filter(Boolean);
680
- if (inputModes.length > 1) {
681
- printError("Only one of --content, --content-file, or --stdin can be used");
682
- process.exit(1);
683
- }
684
- if (options.content !== void 0) {
685
- return options.content;
686
- }
687
- if (options.contentFile !== void 0) {
688
- return readContentFile(options.contentFile);
689
- }
690
- return readStdin();
691
- }
692
-
693
- // src/commands/file.ts
694
- function addSpaceOptions(cmd) {
695
- 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)");
696
- }
697
- function registerFileCommand(program2) {
698
- const file = program2.command("file").description("File operations");
699
- addSpaceOptions(
700
- 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")
701
- ).action(async (query, options) => {
702
- const spaceParams = runOrExit(() => resolveSpaceSelector(options));
703
- const limit = runOrExit(() => parseSearchLimit(options.limit, 30, 80));
704
- const mode = runOrExit(() => parseSearchMode(options.mode));
705
- startSpinner("Searching files...");
706
- const response = await httpPost(
707
- `/workspaces/${options.workspace}/files/search`,
708
- {
709
- query,
710
- limit,
711
- mode,
712
- ...spaceParams
713
- }
714
- );
715
- if (!response.ok) {
716
- stopSpinner(false, "Failed");
717
- printError(`Error [${response.status}]: ${JSON.stringify(response.data)}`);
718
- process.exit(1);
719
- }
720
- const { items } = response.data;
721
- if (items.length === 0) {
722
- stopSpinner(true, "No files found.");
723
- return;
552
+ const { items } = response.data;
553
+ if (items.length === 0) {
554
+ stopSpinner(true, "No files found.");
555
+ return;
724
556
  }
725
557
  stopSpinner(true, `Found ${items.length} file(s)`);
726
558
  for (const item of items) {
@@ -736,7 +568,7 @@ function registerFileCommand(program2) {
736
568
  addSpaceOptions(
737
569
  file.command("get-url").description("Resolve the shareable browser URL for a file").requiredOption("-p, --path <path>", "File path")
738
570
  ).action(async (options) => {
739
- const spaceParams = runOrExit(() => resolveSpaceSelector(options));
571
+ const spaceParams = runOrExit2(() => resolveSpaceSelector(options));
740
572
  const qs = buildFileQueryString(options.path, spaceParams);
741
573
  startSpinner("Resolving URL...");
742
574
  const response = await httpGet(
@@ -759,7 +591,7 @@ function registerFileCommand(program2) {
759
591
  addSpaceOptions(
760
592
  file.command("list").description("List directory contents").option("-p, --path <path>", "Directory path", "/")
761
593
  ).action(async (options) => {
762
- const spaceParams = runOrExit(() => resolveSpaceSelector(options));
594
+ const spaceParams = runOrExit2(() => resolveSpaceSelector(options));
763
595
  const qs = buildFileQueryString(options.path, spaceParams);
764
596
  startSpinner("Listing files...");
765
597
  const response = await httpGet(
@@ -788,40 +620,8 @@ function registerFileCommand(program2) {
788
620
  }
789
621
  }
790
622
  });
791
- 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) => {
792
- const sandboxRuntime = resolveSandboxRuntimeContextOrExit();
793
- if (sandboxRuntime) {
794
- if (options.url) {
795
- printError("--url cannot be used in sandbox mode");
796
- process.exit(1);
797
- }
798
- const path2 = workspacePath ?? options.path;
799
- if (!path2) {
800
- printError("Workspace path is required in sandbox mode");
801
- process.exit(1);
802
- }
803
- if (!options.json) startSpinner("Reading file...");
804
- try {
805
- const result = await readSandboxFile(sandboxRuntime, path2);
806
- if (options.json) {
807
- print(JSON.stringify(result, null, 2));
808
- } else {
809
- stopSpinner(true, "Done");
810
- print(result.content);
811
- }
812
- } catch (error) {
813
- handleSandboxFileError(error, path2);
814
- }
815
- return;
816
- }
817
- if (options.json) {
818
- printError("--json is only available for sandbox workspace paths");
819
- process.exit(1);
820
- }
821
- if (workspacePath && !options.path) {
822
- options.path = workspacePath;
823
- }
824
- 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));
825
625
  startSpinner("Reading file...");
826
626
  let response;
827
627
  let displayPath;
@@ -832,7 +632,7 @@ function registerFileCommand(program2) {
832
632
  );
833
633
  } else {
834
634
  displayPath = mode.path;
835
- const spaceParams = runOrExit(() => resolveSpaceSelector(options));
635
+ const spaceParams = runOrExit2(() => resolveSpaceSelector(options));
836
636
  const qs = buildFileQueryString(mode.path, spaceParams);
837
637
  response = await httpGet(
838
638
  `/workspaces/${mode.workspace}/files/read${qs}`
@@ -856,37 +656,29 @@ function registerFileCommand(program2) {
856
656
  stopSpinner(true, "Done");
857
657
  print(content ?? "");
858
658
  });
859
- 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(
860
- async (workspacePath, options) => {
861
- const sandboxRuntime = resolveSandboxRuntimeContextOrExit();
862
- if (sandboxRuntime) {
863
- if (options.url) {
864
- printError("--url cannot be used in sandbox mode");
865
- process.exit(1);
866
- }
867
- const path2 = workspacePath ?? options.path;
868
- if (!path2) {
869
- printError("Workspace path is required in sandbox mode");
870
- process.exit(1);
871
- }
872
- const { content: content2 } = readLocalTextFileOrExit(options.localPath);
873
- startSpinner("Uploading...");
874
- try {
875
- const response2 = await writeSandboxFile(sandboxRuntime, path2, content2, {
876
- type: "use-current-sha"
877
- });
878
- const action2 = response2.created ? "Created" : "Updated";
879
- stopSpinner(true, `${action2}: ${response2.path}`);
880
- } catch (error) {
881
- handleSandboxFileError(error, path2);
882
- }
883
- 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);
884
665
  }
885
- if (workspacePath && !options.path) {
886
- 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);
887
680
  }
888
- const mode = runOrExit(() => resolveWriteMode(options));
889
- const { content } = readLocalTextFileOrExit(options.localPath);
681
+ const content = buffer2.toString("utf8");
890
682
  startSpinner("Uploading...");
891
683
  let response;
892
684
  let displayPath;
@@ -898,7 +690,7 @@ function registerFileCommand(program2) {
898
690
  });
899
691
  } else {
900
692
  displayPath = mode.path;
901
- const spaceParams = runOrExit(() => resolveSpaceSelector(options));
693
+ const spaceParams = runOrExit2(() => resolveSpaceSelector(options));
902
694
  response = await httpPut(
903
695
  `/workspaces/${mode.workspace}/files/write`,
904
696
  {
@@ -928,28 +720,11 @@ function registerFileCommand(program2) {
928
720
  stopSpinner(true, `${action}: ${response.data.path}`);
929
721
  }
930
722
  );
931
- 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) => {
932
- const runtime = resolveSandboxRuntimeContextOrExit();
933
- if (!runtime) {
934
- printError("file write is only available in sandbox mode");
935
- process.exit(1);
936
- }
937
- const policy = resolveSandboxFileWritePolicyOrExit(options);
938
- const content = readWriteCommandContentOrExit(options, (path2) => readLocalTextFileOrExit(path2).content);
939
- startSpinner("Writing file...");
940
- try {
941
- const response = await writeSandboxFile(runtime, workspacePath, content, policy);
942
- const action = response.created ? "Created" : "Updated";
943
- stopSpinner(true, `${action}: ${response.path}`);
944
- } catch (error) {
945
- handleSandboxFileError(error, workspacePath);
946
- }
947
- });
948
723
  addSpaceOptions(
949
724
  file.command("mkdir").description("Create a directory").requiredOption("-p, --path <path>", "Directory path").option("-r, --recursive", "Create parent directories if needed")
950
725
  ).action(
951
726
  async (options) => {
952
- const spaceParams = runOrExit(() => resolveSpaceSelector(options));
727
+ const spaceParams = runOrExit2(() => resolveSpaceSelector(options));
953
728
  startSpinner("Creating directory...");
954
729
  const response = await httpPost(
955
730
  `/workspaces/${options.workspace}/files/mkdir`,
@@ -978,7 +753,7 @@ function registerFileCommand(program2) {
978
753
  file.command("del").description("Delete a file or empty directory").requiredOption("-p, --path <path>", "File or directory path")
979
754
  ).action(
980
755
  async (options) => {
981
- const spaceParams = runOrExit(() => resolveSpaceSelector(options));
756
+ const spaceParams = runOrExit2(() => resolveSpaceSelector(options));
982
757
  startSpinner("Deleting...");
983
758
  const response = await httpDelete(
984
759
  `/workspaces/${options.workspace}/files/delete`,
@@ -1002,90 +777,14 @@ function registerFileCommand(program2) {
1002
777
  }
1003
778
  );
1004
779
  }
1005
- function resolveSandboxFileWritePolicyOrExit(options) {
1006
- if (options.baseSha && options.force) {
1007
- printError("Only one of --base-sha or --force can be used");
1008
- process.exit(1);
1009
- }
1010
- if (options.baseSha) {
1011
- if (!/^[a-f0-9]{64}$/.test(options.baseSha)) {
1012
- printError("--base-sha must be a lowercase SHA-256 hash");
1013
- process.exit(1);
1014
- }
1015
- return { type: "base-sha", sha: options.baseSha };
1016
- }
1017
- return options.force ? { type: "force" } : { type: "create-only" };
1018
- }
1019
- function resolveRuntimeContextOrExit() {
1020
- try {
1021
- return resolveRuntimeContext();
1022
- } catch (error) {
1023
- if (error instanceof RuntimeContextError) {
1024
- printError(error.message);
1025
- process.exit(1);
1026
- }
1027
- throw error;
1028
- }
1029
- }
1030
- function resolveSandboxRuntimeContextOrExit() {
1031
- if (!SANDBOX_ENV_KEYS.some((key) => (process.env[key] ?? "").trim().length > 0)) {
1032
- return void 0;
1033
- }
1034
- const context = resolveRuntimeContextOrExit();
1035
- return context.mode === "sandbox" ? context : void 0;
1036
- }
1037
- function readLocalTextFileOrExit(localPath) {
1038
- if (!fs2.existsSync(localPath)) {
1039
- print(`${colors.red}\u2620 Local file not found: ${localPath}${colors.reset}`);
1040
- process.exit(1);
1041
- }
1042
- const stats = fs2.statSync(localPath);
1043
- if (!stats.isFile()) {
1044
- print(`${colors.red}\u2620 Not a file: ${localPath}${colors.reset}`);
1045
- process.exit(1);
1046
- }
1047
- if (stats.size > MFS_FILE_SIZE_LIMIT_BYTES) {
1048
- print(`${colors.red}\u2620 File too large (max ${MFS_FILE_SIZE_LIMIT_LABEL}): ${localPath}${colors.reset}`);
1049
- process.exit(1);
1050
- }
1051
- const buffer2 = fs2.readFileSync(localPath);
1052
- if (isBinaryContent(buffer2)) {
1053
- print(`${colors.red}\u2620 Binary files are not allowed. Only text files can be uploaded.${colors.reset}`);
1054
- process.exit(1);
1055
- }
1056
- return { content: buffer2.toString("utf8") };
1057
- }
1058
- function handleSandboxFileError(error, displayPath) {
1059
- stopSpinner(false, "Failed");
1060
- if (error instanceof SandboxFileError) {
1061
- if (error.code === "not-found") {
1062
- print(`${colors.red}\u2620 ${displayPath} does not exist${colors.reset}`);
1063
- } else if (error.code === "directory") {
1064
- print(`${colors.red}\u2620 ${displayPath} is a directory${colors.reset}`);
1065
- } else {
1066
- printError(error.message);
1067
- }
1068
- process.exit(1);
1069
- }
1070
- if (error instanceof SandboxApiError) {
1071
- printError(`Sandbox API error [${error.status}]: ${error.body}`);
1072
- process.exit(1);
1073
- }
1074
- if (error instanceof Error) {
1075
- printError(`Sandbox file operation failed: ${error.message}`);
1076
- process.exit(1);
1077
- }
1078
- printError(`Sandbox file operation failed: ${String(error)}`);
1079
- process.exit(1);
1080
- }
1081
780
 
1082
781
  // src/commands/memory.ts
1083
782
  function registerMemoryCommand(program2) {
1084
783
  const memory = program2.command("memory").description("Memory operations");
1085
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(
1086
785
  async (query, options) => {
1087
- const limit = runOrExit(() => parseSearchLimit(options.limit, 5, 10));
1088
- const teammateId = runOrExit(() => parseTeammateId(options.teammateId));
786
+ const limit = runOrExit2(() => parseSearchLimit(options.limit, 5, 10));
787
+ const teammateId = runOrExit2(() => parseTeammateId(options.teammateId));
1089
788
  startSpinner("Searching memory...");
1090
789
  const response = await httpPost(
1091
790
  `/workspaces/${options.workspace}/memory/search`,
@@ -1191,7 +890,7 @@ function isAllowedMiniappDbUserEmail(email) {
1191
890
  }
1192
891
 
1193
892
  // src/commands/miniapp.ts
1194
- function runOrExit2(fn) {
893
+ function runOrExit3(fn) {
1195
894
  try {
1196
895
  return fn();
1197
896
  } catch (err) {
@@ -1251,14 +950,14 @@ function registerMiniappCommand(program2) {
1251
950
  addMiniappDbTargetOptions(
1252
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")
1253
952
  ).action(async (postgrestPath, options) => {
1254
- const body = runOrExit2(() => parseJsonBody(options.body ?? ""));
953
+ const body = runOrExit3(() => parseJsonBody(options.body ?? ""));
1255
954
  await requestDataApi("POST", postgrestPath, options, body);
1256
955
  });
1257
956
  addMiniappDbTargetOptions(
1258
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")
1259
958
  ).action(async (postgrestPath, options) => {
1260
- runOrExit2(() => assertPostgrestFilter(postgrestPath, "PATCH"));
1261
- const body = runOrExit2(() => parseJsonBody(options.body ?? ""));
959
+ runOrExit3(() => assertPostgrestFilter(postgrestPath, "PATCH"));
960
+ const body = runOrExit3(() => parseJsonBody(options.body ?? ""));
1262
961
  await requestDataApi("PATCH", postgrestPath, options, body);
1263
962
  });
1264
963
  addMiniappDbTargetOptions(
@@ -1268,1558 +967,1167 @@ function registerMiniappCommand(program2) {
1268
967
  printError("Error: delete requires --yes.");
1269
968
  process.exit(1);
1270
969
  }
1271
- runOrExit2(() => assertPostgrestFilter(postgrestPath, "DELETE"));
970
+ runOrExit3(() => assertPostgrestFilter(postgrestPath, "DELETE"));
1272
971
  await requestDataApi("DELETE", postgrestPath, options);
1273
972
  });
1274
973
  }
1275
974
 
1276
- // src/commands/pull.ts
1277
- import { isAbsolute as isAbsolute3 } from "path";
1278
- import { Option } from "commander";
1279
-
1280
- // src/utils/sandbox-pull.ts
1281
- import { createHash as createHash2 } from "crypto";
1282
- import { lstat, mkdir as mkdir2, readFile as readFile2, rm as rm2, writeFile as writeFile2 } from "fs/promises";
1283
- import { dirname as dirname2, isAbsolute as isAbsolute2, relative, resolve } from "path";
1284
-
1285
- // src/utils/checkout-state.ts
1286
- import { randomUUID } from "crypto";
1287
- import { homedir } from "os";
1288
- import { dirname, isAbsolute, join, posix } from "path";
1289
- import { mkdir, readFile, rename, rm, writeFile } from "fs/promises";
1290
- var CheckoutStateError = class extends Error {
1291
- constructor(message) {
1292
- super(message);
1293
- this.name = "CheckoutStateError";
1294
- }
1295
- };
1296
- function getCheckoutStatePath() {
1297
- 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");
1298
981
  }
1299
- async function loadCheckoutState(statePath = getCheckoutStatePath()) {
1300
- let raw;
1301
- try {
1302
- raw = await readFile(statePath, "utf8");
1303
- } catch (error) {
1304
- if (isNodeError(error) && error.code === "ENOENT") return void 0;
1305
- throw new CheckoutStateError(`Failed to read checkout state: ${errorMessage(error)}`);
1306
- }
1307
- let value;
982
+ function readTelemetryConfig() {
1308
983
  try {
1309
- value = JSON.parse(raw);
984
+ const raw = fs3.readFileSync(getConfigPath(), "utf8");
985
+ const parsed = JSON.parse(raw);
986
+ return parsed.telemetry ?? {};
1310
987
  } catch {
1311
- throw new CheckoutStateError(`Checkout state is not valid JSON: ${statePath}`);
988
+ return {};
1312
989
  }
1313
- return validateCheckoutState(value);
1314
990
  }
1315
- async function saveCheckoutState(state, statePath = getCheckoutStatePath()) {
1316
- const validState = validateCheckoutState(state);
1317
- const stateDir = dirname(statePath);
1318
- const temporaryPath = `${statePath}.${process.pid}.${randomUUID()}.tmp`;
1319
- await mkdir(stateDir, { recursive: true });
991
+ function writeTelemetryConfig(update) {
992
+ const configPath = getConfigPath();
993
+ let existing = {};
1320
994
  try {
1321
- await writeFile(temporaryPath, `${JSON.stringify(validState, null, 2)}
1322
- `, { mode: 384 });
1323
- await rename(temporaryPath, statePath);
1324
- } catch (error) {
1325
- await rm(temporaryPath, { force: true });
1326
- throw new CheckoutStateError(`Failed to save checkout state: ${errorMessage(error)}`);
1327
- }
1328
- }
1329
- function isCheckoutPathIncluded(state, workspacePath) {
1330
- if (state.mode === "full") return true;
1331
- return state.checkedOutScopes.some(
1332
- (scope) => workspacePath === scope || workspacePath.startsWith(`${scope}/`)
1333
- );
1334
- }
1335
- function doesCheckoutPathIntersect(state, workspacePath) {
1336
- if (state.mode === "full") return true;
1337
- return state.checkedOutScopes.some(
1338
- (scope) => workspacePath === scope || workspacePath.startsWith(`${scope}/`) || scope.startsWith(`${workspacePath}/`)
1339
- );
1340
- }
1341
- function validateCheckoutState(value) {
1342
- const state = requireRecord(value, "Checkout state");
1343
- if (state.version !== 1) {
1344
- throw new CheckoutStateError(`Unsupported checkout state version: ${String(state.version)}`);
1345
- }
1346
- const workspaceId = requireNonEmptyString(state.workspaceId, "workspaceId");
1347
- const checkoutRoot = requireNonEmptyString(state.checkoutRoot, "checkoutRoot");
1348
- if (!isAbsolute(checkoutRoot)) throw new CheckoutStateError("checkoutRoot must be an absolute path");
1349
- if (state.mode !== "partial" && state.mode !== "full") {
1350
- throw new CheckoutStateError("mode must be partial or full");
1351
- }
1352
- if (!Array.isArray(state.checkedOutScopes)) {
1353
- throw new CheckoutStateError("checkedOutScopes must be an array");
1354
- }
1355
- const checkedOutScopes = state.checkedOutScopes.map(
1356
- (scope, index) => requireWorkspacePath(scope, `checkedOutScopes[${index}]`)
1357
- );
1358
- if (new Set(checkedOutScopes).size !== checkedOutScopes.length) {
1359
- throw new CheckoutStateError("checkedOutScopes must not contain duplicates");
1360
- }
1361
- const reposValue = requireRecord(state.repos, "repos");
1362
- const repos = {};
1363
- for (const [repoId, rawRepo] of Object.entries(reposValue)) {
1364
- requireNonEmptyString(repoId, "repo id");
1365
- const repo = requireRecord(rawRepo, `repos.${repoId}`);
1366
- const name = requireRepoName(repo.name, `repos.${repoId}.name`);
1367
- repos[repoId] = {
1368
- name,
1369
- treeSha: requireNonEmptyString(repo.treeSha, `repos.${repoId}.treeSha`)
1370
- };
1371
- }
1372
- const filesValue = requireRecord(state.files, "files");
1373
- const files = {};
1374
- for (const [workspacePath, rawFile] of Object.entries(filesValue)) {
1375
- requireWorkspacePath(workspacePath, `files.${workspacePath}`);
1376
- const file = requireRecord(rawFile, `files.${workspacePath}`);
1377
- const repoId = requireNonEmptyString(file.repoId, `files.${workspacePath}.repoId`);
1378
- const repo = repos[repoId];
1379
- if (!repo) throw new CheckoutStateError(`File references unknown repo: ${workspacePath}`);
1380
- const repoPath = requireWorkspacePath(file.repoPath, `files.${workspacePath}.repoPath`);
1381
- if (workspacePath !== `${repo.name}/${repoPath}`) {
1382
- throw new CheckoutStateError(`File path does not match its repo: ${workspacePath}`);
1383
- }
1384
- const fileId = file.fileId === null ? null : requireNonEmptyString(file.fileId, `files.${workspacePath}.fileId`);
1385
- const sha = requireNonEmptyString(file.sha, `files.${workspacePath}.sha`);
1386
- if (!/^[a-f0-9]{64}$/.test(sha)) throw new CheckoutStateError(`Invalid file SHA: ${workspacePath}`);
1387
- files[workspacePath] = {
1388
- repoId,
1389
- repoPath,
1390
- fileId,
1391
- sha,
1392
- size: requireNonNegativeInteger(file.size, `files.${workspacePath}.size`),
1393
- fileType: requireNonNegativeInteger(file.fileType, `files.${workspacePath}.fileType`)
1394
- };
1395
- }
1396
- return {
1397
- version: 1,
1398
- workspaceId,
1399
- checkoutRoot,
1400
- mode: state.mode,
1401
- checkedOutScopes,
1402
- repos,
1403
- files
1404
- };
1405
- }
1406
- function requireRecord(value, field) {
1407
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
1408
- throw new CheckoutStateError(`${field} must be an object`);
995
+ existing = JSON.parse(fs3.readFileSync(configPath, "utf8"));
996
+ } catch {
997
+ existing = {};
1409
998
  }
1410
- 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));
1411
1003
  }
1412
- function requireNonEmptyString(value, field) {
1413
- if (typeof value !== "string" || value.length === 0) {
1414
- throw new CheckoutStateError(`${field} must be a non-empty string`);
1415
- }
1416
- return value;
1004
+
1005
+ // src/telemetry/opt-out.ts
1006
+ function isCiEnvironment() {
1007
+ return process.env.CI === "true" || process.env.CI === "1";
1417
1008
  }
1418
- function requireWorkspacePath(value, field) {
1419
- const path2 = requireNonEmptyString(value, field);
1420
- if (path2.includes("\\") || path2.includes("\0") || posix.isAbsolute(path2) || posix.normalize(path2) !== path2) {
1421
- 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;
1422
1012
  }
1423
- return path2;
1424
- }
1425
- function requireRepoName(value, field) {
1426
- const name = requireWorkspacePath(value, field);
1427
- if (name.includes("/")) throw new CheckoutStateError(`${field} must be a single path segment`);
1428
- return name;
1429
- }
1430
- function requireNonNegativeInteger(value, field) {
1431
- if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
1432
- 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;
1433
1015
  }
1434
- return value;
1435
- }
1436
- function isNodeError(error) {
1437
- return error instanceof Error && "code" in error;
1438
- }
1439
- function errorMessage(error) {
1440
- return error instanceof Error ? error.message : String(error);
1016
+ return readTelemetryConfig().disabled === true;
1441
1017
  }
1442
1018
 
1443
- // src/utils/sandbox-pull.ts
1444
- var SandboxPullError = class extends Error {
1445
- constructor(message, code) {
1446
- super(message);
1447
- this.code = code;
1448
- this.name = "SandboxPullError";
1449
- }
1450
- code;
1451
- };
1452
- async function pullSandboxWorkspace(context, targetDir, options = {}) {
1453
- const resolvedTargetDir = resolve(targetDir);
1454
- const repos = listRepoEntries(context.repoPayload);
1455
- const previousState = await loadCheckoutState();
1456
- const compatibleState = resolveCompatibleState(previousState, context, resolvedTargetDir, repos);
1457
- const previousFiles = compatibleState?.files ?? {};
1458
- const selection = resolvePullSelection(context, options.scopes);
1459
- const repoById = new Map(selection.repos.map((repo) => [repo.repoId, repo]));
1460
- const tree = await fetchWorkspaceTree(context, selection);
1461
- const stateRepos = resolveStateRepos(tree, selection.repos);
1462
- const entries = [...tree.entries].sort((a, b) => a.prefixedPath.localeCompare(b.prefixedPath));
1463
- const directoryPaths = selection.repos.map((repo) => ({
1464
- workspacePath: repo.name,
1465
- localPath: resolveSafeLocalPath(resolvedTargetDir, repo.name, "")
1466
- }));
1467
- const blobEntries = [];
1468
- const remoteBlobPaths = /* @__PURE__ */ new Set();
1469
- const remotePaths = /* @__PURE__ */ new Set();
1470
- const matchedScopes = /* @__PURE__ */ new Set();
1471
- for (const entry of entries) {
1472
- const repo = repoById.get(entry.repoId);
1473
- if (!repo) {
1474
- throw new SandboxPullError(`Remote tree returned unknown repo: ${entry.repoId}`, "remote");
1475
- }
1476
- const expectedWorkspacePath = entry.path ? `${repo.name}/${entry.path}` : repo.name;
1477
- if (entry.prefixedPath !== expectedWorkspacePath) {
1478
- throw new SandboxPullError(`Remote tree returned inconsistent path: ${entry.prefixedPath}`, "remote");
1479
- }
1480
- if (remotePaths.has(expectedWorkspacePath)) {
1481
- throw new SandboxPullError(`Remote tree returned duplicate path: ${expectedWorkspacePath}`, "remote");
1482
- }
1483
- remotePaths.add(expectedWorkspacePath);
1484
- const localPath = resolveSafeLocalPath(resolvedTargetDir, repo.name, entry.path);
1485
- for (const scope of selection.scopes) {
1486
- if (pathIsIncluded(scope, expectedWorkspacePath)) matchedScopes.add(scope);
1487
- }
1488
- if (!selection.scopes.some((scope) => pathsIntersect(scope, expectedWorkspacePath))) continue;
1489
- if (entry.type === "tree") {
1490
- directoryPaths.push({ workspacePath: entry.prefixedPath, localPath });
1491
- continue;
1492
- }
1493
- if (!entry.sha) {
1494
- throw new SandboxPullError(`${entry.prefixedPath} does not have downloadable content`, "remote");
1495
- }
1496
- if (entry.fileId !== null && (typeof entry.fileId !== "string" || entry.fileId.length === 0)) {
1497
- throw new SandboxPullError(`${entry.prefixedPath} has invalid file metadata`, "remote");
1498
- }
1499
- if (entry.fileType !== null && (!Number.isInteger(entry.fileType) || entry.fileType < 0)) {
1500
- throw new SandboxPullError(`${entry.prefixedPath} has invalid file metadata`, "remote");
1501
- }
1502
- if (entry.size !== null && (!Number.isInteger(entry.size) || entry.size < 0)) {
1503
- throw new SandboxPullError(`${entry.prefixedPath} has invalid file metadata`, "remote");
1504
- }
1505
- if (entry.size !== null && entry.size > MFS_FILE_SIZE_LIMIT_BYTES) {
1506
- throw new SandboxPullError(`File too large (max ${MFS_FILE_SIZE_LIMIT_LABEL}): ${entry.prefixedPath}`, "too-large");
1507
- }
1508
- remoteBlobPaths.add(entry.prefixedPath);
1509
- blobEntries.push({ entry, localPath });
1510
- }
1511
- if (selection.mode === "partial") {
1512
- for (const scope of selection.scopes) {
1513
- const repoRoot = selection.repos.some((repo) => repo.name === scope);
1514
- const previouslyTracked = Object.keys(previousFiles).some((workspacePath) => pathIsIncluded(scope, workspacePath));
1515
- if (!repoRoot && !matchedScopes.has(scope) && !previouslyTracked) {
1516
- throw new SandboxPullError(`Workspace path does not exist: ${scope}`, "invalid-path");
1517
- }
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)");
1518
1031
  }
1519
- }
1520
- const plannedBlobs = [];
1521
- for (const { entry, localPath } of blobEntries) {
1522
- plannedBlobs.push({
1523
- entry,
1524
- localPath,
1525
- shouldWrite: await shouldWritePulledFile(
1526
- localPath,
1527
- requireBlobSha(entry),
1528
- entry.prefixedPath,
1529
- previousFiles[entry.prefixedPath],
1530
- Boolean(options.force)
1531
- )
1532
- });
1533
- }
1534
- const plannedDeletions = await planRemoteDeletions(
1535
- compatibleState,
1536
- selection,
1537
- resolvedTargetDir,
1538
- remoteBlobPaths,
1539
- Boolean(options.force)
1540
- );
1541
- for (const directory of directoryPaths) {
1542
- await assertCanCreateDirectory(directory.localPath, directory.workspacePath);
1543
- }
1544
- const blobsToDownload = plannedBlobs.filter((planned) => planned.shouldWrite);
1545
- const downloadUrls = await presignDownloads(context, blobsToDownload.map(({ entry }) => entry));
1546
- const downloadedEntries = [];
1547
- for (const planned of blobsToDownload) {
1548
- const { entry, localPath } = planned;
1549
- const remoteContent = await downloadBlob(entry, downloadUrls);
1550
- downloadedEntries.push({ ...planned, content: remoteContent });
1551
- }
1552
- for (const deletion of plannedDeletions) {
1553
- await rm2(deletion.localPath);
1554
- }
1555
- for (const directory of directoryPaths) {
1556
- await mkdir2(directory.localPath, { recursive: true });
1557
- }
1558
- for (const downloaded of downloadedEntries) {
1559
- await writePulledFile(downloaded.localPath, downloaded.content);
1560
- }
1561
- const downloadedContentByPath = new Map(
1562
- downloadedEntries.map(({ entry, content }) => [entry.prefixedPath, content])
1563
- );
1564
- const pulledFiles = Object.fromEntries(await Promise.all(plannedBlobs.map(async ({ entry, localPath }) => {
1565
- const content = downloadedContentByPath.get(entry.prefixedPath);
1566
- return [
1567
- entry.prefixedPath,
1568
- {
1569
- repoId: entry.repoId,
1570
- repoPath: entry.path,
1571
- fileId: entry.fileId,
1572
- sha: requireBlobSha(entry),
1573
- size: content?.length ?? entry.size ?? previousFiles[entry.prefixedPath]?.size ?? await localFileSize(localPath, entry.prefixedPath),
1574
- fileType: entry.fileType ?? 1
1575
- }
1576
- ];
1577
- })));
1578
- await saveCheckoutState(buildNextCheckoutState({
1579
- context,
1580
- checkoutRoot: resolvedTargetDir,
1581
- allRepos: repos,
1582
- selection,
1583
- previousState: compatibleState,
1584
- pulledRepos: stateRepos,
1585
- pulledFiles
1586
- }));
1587
- return {
1588
- fileCount: blobEntries.length,
1589
- targetDir: resolvedTargetDir
1590
- };
1591
- }
1592
- function resolveCompatibleState(state, context, checkoutRoot, repos) {
1593
- if (!state || state.workspaceId !== context.workspaceId || state.checkoutRoot !== checkoutRoot) return void 0;
1594
- const currentRepos = new Map(repos.map((repo) => [repo.repoId, repo.name]));
1595
- const stateMatchesRepos = Object.entries(state.repos).every(
1596
- ([repoId, repo]) => currentRepos.get(repoId) === repo.name
1597
- );
1598
- return stateMatchesRepos ? state : void 0;
1599
- }
1600
- function resolvePullSelection(context, requestedScopes) {
1601
- const repos = listRepoEntries(context.repoPayload);
1602
- if (!requestedScopes || requestedScopes.length === 0) {
1603
- return { mode: "full", scopes: repos.map((repo) => repo.name), repos };
1604
- }
1605
- const resolvedScopes = requestedScopes.map((scope) => {
1606
- const resolved = resolveWorkspacePath(context.repoPayload, scope);
1607
- return {
1608
- path: resolved.repoPath ? `${resolved.repoName}/${resolved.repoPath}` : resolved.repoName,
1609
- repoId: resolved.repoId
1610
- };
1611
1032
  });
1612
- const scopes = collapseScopes(resolvedScopes.map((scope) => scope.path));
1613
- const repoIds = new Set(resolvedScopes.map((scope) => scope.repoId));
1614
- return {
1615
- mode: "partial",
1616
- scopes,
1617
- repos: repos.filter((repo) => repoIds.has(repo.repoId))
1618
- };
1619
- }
1620
- function buildNextCheckoutState(input) {
1621
- const { context, checkoutRoot, allRepos, selection, previousState, pulledRepos, pulledFiles } = input;
1622
- if (selection.mode === "full") {
1623
- return {
1624
- version: 1,
1625
- workspaceId: context.workspaceId,
1626
- checkoutRoot,
1627
- mode: "full",
1628
- checkedOutScopes: selection.scopes,
1629
- repos: pulledRepos,
1630
- files: pulledFiles
1631
- };
1632
- }
1633
- const previousFiles = previousState?.files ?? {};
1634
- const preservedFiles = Object.fromEntries(
1635
- Object.entries(previousFiles).filter(
1636
- ([workspacePath]) => !selection.scopes.some((scope) => pathIsIncluded(scope, workspacePath))
1637
- )
1638
- );
1639
- const checkedOutScopes = collapseScopes([...previousState?.checkedOutScopes ?? [], ...selection.scopes]);
1640
- const mode = allRepos.every((repo) => checkedOutScopes.includes(repo.name)) ? "full" : "partial";
1641
- return {
1642
- version: 1,
1643
- workspaceId: context.workspaceId,
1644
- checkoutRoot,
1645
- mode,
1646
- checkedOutScopes,
1647
- repos: { ...previousState?.repos ?? {}, ...pulledRepos },
1648
- files: { ...preservedFiles, ...pulledFiles }
1649
- };
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
+ });
1650
1041
  }
1651
- function collapseScopes(scopes) {
1652
- const result = [];
1653
- for (const scope of scopes) {
1654
- if (result.some((existing) => pathIsIncluded(existing, scope))) continue;
1655
- for (let index = result.length - 1; index >= 0; index--) {
1656
- if (pathIsIncluded(scope, result[index])) result.splice(index, 1);
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);
1657
1056
  }
1658
- result.push(scope);
1659
- }
1660
- return result;
1661
- }
1662
- function pathsIntersect(left, right) {
1663
- return pathIsIncluded(left, right) || pathIsIncluded(right, left);
1664
- }
1665
- function pathIsIncluded(scope, workspacePath) {
1666
- return workspacePath === scope || workspacePath.startsWith(`${scope}/`);
1057
+ });
1667
1058
  }
1668
- function resolveStateRepos(tree, repos) {
1669
- const knownRepoIds = new Set(repos.map((repo) => repo.repoId));
1670
- for (const repoId of Object.keys(tree.repos)) {
1671
- if (!knownRepoIds.has(repoId)) {
1672
- throw new SandboxPullError(`Remote tree returned unknown repo: ${repoId}`, "remote");
1673
- }
1674
- }
1675
- return Object.fromEntries(repos.map((repo) => {
1676
- const remoteRepo = tree.repos[repo.repoId];
1677
- if (!remoteRepo) {
1678
- throw new SandboxPullError(`Remote tree did not return repo: ${repo.repoId}`, "remote");
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);
1064
+ }
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);
1084
+ }
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);
1679
1095
  }
1680
- if (remoteRepo.prefix !== repo.name) {
1681
- throw new SandboxPullError(`Remote tree returned unexpected prefix for repo ${repo.repoId}`, "remote");
1096
+ const { members: members2 } = response.data;
1097
+ if (members2.length === 0) {
1098
+ stopSpinner(true, "No members found.");
1099
+ return;
1682
1100
  }
1683
- if (typeof remoteRepo.sha !== "string" || remoteRepo.sha.length === 0) {
1684
- throw new SandboxPullError(`Remote tree did not return a version 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}`);
1685
1105
  }
1686
- return [repo.repoId, { name: repo.name, treeSha: remoteRepo.sha }];
1687
- }));
1688
- }
1689
- async function fetchWorkspaceTree(context, selection) {
1690
- const client = new SandboxMoxtClient(context);
1691
- const results = await Promise.all(selection.repos.map(async (repo) => {
1692
- const filter = resolveRepoTreeFilter(repo, selection);
1693
- const params = new URLSearchParams({ entryFilter: filter.type });
1694
- if (filter.type === "paths") {
1695
- for (const path2 of filter.paths) params.append("path", path2);
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);
1696
1113
  }
1697
- const response = await client.request(
1698
- "GET",
1699
- `/agent/api/pipeline-sandbox/mfs/${encodeURIComponent(repo.repoId)}/tree?${params.toString()}`
1700
- );
1701
- return { repo, tree: response.data };
1702
- }));
1703
- return {
1704
- repos: Object.fromEntries(
1705
- results.map(({ repo, tree }) => [repo.repoId, { sha: tree.sha, prefix: repo.name }])
1706
- ),
1707
- entries: results.flatMap(
1708
- ({ repo, tree }) => tree.entries.map((entry) => ({
1709
- ...entry,
1710
- repoId: repo.repoId,
1711
- prefixedPath: entry.path ? `${repo.name}/${entry.path}` : repo.name
1712
- }))
1713
- )
1714
- };
1715
- }
1716
- function resolveRepoTreeFilter(repo, selection) {
1717
- if (selection.mode === "full") return { type: "all" };
1718
- const repoPaths = [];
1719
- for (const scope of selection.scopes) {
1720
- if (scope === repo.name) return { type: "all" };
1721
- if (scope.startsWith(`${repo.name}/`)) repoPaths.push(scope.slice(repo.name.length + 1));
1722
- }
1723
- if (repoPaths.length === 0) {
1724
- throw new SandboxPullError(`No pull scope resolved for repo: ${repo.name}`, "invalid-path");
1725
- }
1726
- return { type: "paths", paths: repoPaths };
1727
- }
1728
- async function presignDownloads(context, entries) {
1729
- const client = new SandboxMoxtClient(context);
1730
- const entriesByRepo = /* @__PURE__ */ new Map();
1731
- for (const entry of entries) {
1732
- const existing = entriesByRepo.get(entry.repoId) ?? [];
1733
- existing.push(entry);
1734
- entriesByRepo.set(entry.repoId, existing);
1735
- }
1736
- const urls = /* @__PURE__ */ new Map();
1737
- for (const [repoId, repoEntries] of entriesByRepo) {
1738
- const hashes = [...new Set(repoEntries.map((entry) => entry.sha).filter((sha) => Boolean(sha)))];
1739
- if (hashes.length === 0) continue;
1740
- const response = await client.request(
1741
- "POST",
1742
- `/agent/api/pipeline-sandbox/mfs/${encodeURIComponent(repoId)}/presign-download`,
1743
- { hashes }
1744
- );
1745
- for (const hash of hashes) {
1746
- const url = response.data.urls[hash];
1747
- if (!url) {
1748
- throw new SandboxPullError(`No download URL returned for ${hash}`, "remote");
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);
1120
+ }
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)}`);
1749
1142
  }
1750
- urls.set(hash, url);
1751
1143
  }
1752
- }
1753
- return urls;
1754
- }
1755
- async function downloadBlob(entry, urls) {
1756
- const hash = entry.sha;
1757
- if (!hash) {
1758
- throw new SandboxPullError(`${entry.prefixedPath} does not have downloadable content`, "remote");
1759
- }
1760
- const url = urls.get(hash);
1761
- if (!url) {
1762
- throw new SandboxPullError(`No download URL returned for ${entry.prefixedPath}`, "remote");
1763
- }
1764
- const response = await fetch(url);
1765
- if (!response.ok) {
1766
- throw new SandboxPullError(`Download failed [${response.status}]: ${entry.prefixedPath}`, "remote");
1767
- }
1768
- const content = Buffer.from(await response.arrayBuffer());
1769
- if (content.length > MFS_FILE_SIZE_LIMIT_BYTES) {
1770
- throw new SandboxPullError(`File too large (max ${MFS_FILE_SIZE_LIMIT_LABEL}): ${entry.prefixedPath}`, "too-large");
1771
- }
1772
- const contentHash = createHash2("sha256").update(content).digest("hex");
1773
- if (contentHash !== hash) {
1774
- throw new SandboxPullError(`Downloaded content hash mismatch: ${entry.prefixedPath}`, "remote");
1775
- }
1776
- return content;
1777
- }
1778
- async function shouldWritePulledFile(localPath, remoteContentHash, workspacePath, previousFile, force) {
1779
- const existing = await lstat(localPath).catch((error) => {
1780
- if (isNodeError2(error) && error.code === "ENOENT") return void 0;
1781
- throw error;
1782
1144
  });
1783
- if (existing && !existing.isFile()) {
1784
- throw new SandboxPullError(`Refusing to overwrite local path: ${workspacePath}`, "directory-conflict");
1785
- }
1786
- if (!existing) {
1787
- if (force || !previousFile) return true;
1788
- if (remoteContentHash === previousFile.sha) return false;
1789
- throw new SandboxPullError(
1790
- `Local file was deleted but remote file changed: ${workspacePath}`,
1791
- "file-conflict"
1792
- );
1793
- }
1794
- if (force) return true;
1795
- const localContentHash = await hashLocalFile(localPath);
1796
- if (localContentHash === remoteContentHash) return false;
1797
- if (!previousFile) {
1798
- throw new SandboxPullError(
1799
- `Refusing to overwrite modified local file: ${workspacePath}. Use --force to overwrite.`,
1800
- "file-conflict"
1801
- );
1802
- }
1803
- const localChanged = localContentHash !== previousFile.sha;
1804
- const remoteChanged = remoteContentHash !== previousFile.sha;
1805
- if (!localChanged && remoteChanged) return true;
1806
- if (localChanged && !remoteChanged) return false;
1807
- if (localChanged && remoteChanged) {
1808
- throw new SandboxPullError(
1809
- `Local and remote file both changed since checkout: ${workspacePath}`,
1810
- "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}`
1811
1163
  );
1812
- }
1813
- 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
+ });
1814
1170
  }
1815
- async function planRemoteDeletions(previousState, selection, checkoutRoot, remoteBlobPaths, force) {
1816
- if (!previousState) return [];
1817
- const deletions = [];
1818
- for (const [workspacePath, previousFile] of Object.entries(previousState.files)) {
1819
- if (!selection.scopes.some((scope) => pathIsIncluded(scope, workspacePath))) continue;
1820
- if (remoteBlobPaths.has(workspacePath)) continue;
1821
- const repo = previousState.repos[previousFile.repoId];
1822
- if (!repo) {
1823
- throw new SandboxPullError(`Checkout baseline references unknown repo: ${workspacePath}`, "remote");
1824
- }
1825
- const localPath = resolveSafeLocalPath(checkoutRoot, repo.name, previousFile.repoPath);
1826
- const existing = await lstat(localPath).catch((error) => {
1827
- if (isNodeError2(error) && error.code === "ENOENT") return void 0;
1828
- throw error;
1829
- });
1830
- if (!existing) continue;
1831
- if (!existing.isFile()) {
1832
- throw new SandboxPullError(`Refusing to overwrite local path: ${workspacePath}`, "directory-conflict");
1833
- }
1834
- if (!force && await hashLocalFile(localPath) !== previousFile.sha) {
1835
- throw new SandboxPullError(
1836
- `Remote file was deleted but local file changed: ${workspacePath}`,
1837
- "file-conflict"
1838
- );
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);
1839
1191
  }
1840
- deletions.push({ localPath });
1192
+ throw err;
1841
1193
  }
1842
- return deletions;
1843
1194
  }
1844
- async function hashLocalFile(localPath) {
1845
- return createHash2("sha256").update(await readFile2(localPath)).digest("hex");
1195
+ function addWorkspaceOption(command) {
1196
+ return command.requiredOption("-w, --workspace <workspaceId>", "Workspace ID");
1846
1197
  }
1847
- async function localFileSize(localPath, workspacePath) {
1848
- const stats = await lstat(localPath);
1849
- if (!stats.isFile()) {
1850
- throw new SandboxPullError(`Cannot determine local file size: ${workspacePath}`, "directory-conflict");
1851
- }
1852
- return stats.size;
1853
- }
1854
- function requireBlobSha(entry) {
1855
- if (!entry.sha) {
1856
- throw new SandboxPullError(`${entry.prefixedPath} does not have downloadable content`, "remote");
1857
- }
1858
- 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)");
1859
1200
  }
1860
- async function assertCanCreateDirectory(localPath, workspacePath) {
1861
- const existing = await lstat(localPath).catch((error) => {
1862
- if (isNodeError2(error) && error.code === "ENOENT") return void 0;
1863
- throw error;
1864
- });
1865
- if (existing && !existing.isDirectory()) {
1866
- throw new SandboxPullError(`Refusing to overwrite local path: ${workspacePath}`, "directory-conflict");
1867
- }
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");
1868
1203
  }
1869
- async function writePulledFile(localPath, remoteContent) {
1870
- await mkdir2(dirname2(localPath), { recursive: true });
1871
- 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");
1872
1206
  }
1873
- function resolveSafeLocalPath(targetDir, repoName, repoPath) {
1874
- const pathParts = [repoName, ...repoPath.split("/").filter((part) => part.length > 0)];
1875
- if (pathParts.some((part) => part === "." || part === ".." || part.includes("/") || part.includes("\\") || part.includes("\0"))) {
1876
- throw new SandboxPullError(`Remote path is unsafe: ${[repoName, repoPath].filter(Boolean).join("/")}`, "invalid-path");
1877
- }
1878
- const resolved = resolve(targetDir, ...pathParts);
1879
- const relativePath = relative(targetDir, resolved);
1880
- if (relativePath.startsWith("..") || isAbsolute2(relativePath)) {
1881
- 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}".`);
1882
1211
  }
1883
- return resolved;
1212
+ return parsed;
1884
1213
  }
1885
- function isNodeError2(error) {
1886
- 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);
1887
1217
  }
1888
-
1889
- // src/commands/pull.ts
1890
- function registerPullCommand(program2) {
1891
- 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) => {
1892
- const context = resolveSandboxRuntimeContextOrExit2();
1893
- try {
1894
- const invocation = await resolvePullInvocation(context, workspacePath, options);
1895
- const result = await pullSandboxWorkspace(context, invocation.targetDir, {
1896
- force: Boolean(options.force),
1897
- scopes: invocation.scopes
1898
- });
1899
- print(`Pulled ${result.fileCount} ${result.fileCount === 1 ? "file" : "files"} to ${result.targetDir}`);
1900
- } catch (error) {
1901
- handlePullError(error);
1902
- }
1903
- });
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).");
1904
1223
  }
1905
- async function resolvePullInvocation(context, argument, options) {
1906
- const legacyTargetDir = argument && isLegacyTargetDirectory(argument) ? argument : void 0;
1907
- const workspacePath = legacyTargetDir ? void 0 : argument;
1908
- if (workspacePath && options.all) {
1909
- throw new Error("Specify either a workspace path or --all, not both");
1910
- }
1911
- if (options.scope.length > 0 && (workspacePath || options.all)) {
1912
- 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.");
1913
1228
  }
1914
- if (legacyTargetDir && options.dir) {
1915
- throw new Error("Specify the local checkout directory only once");
1916
- }
1917
- const state = await loadCheckoutState();
1918
- if (state) validateCheckoutStateContext(state, context);
1919
- const targetDir = options.dir ?? legacyTargetDir ?? state?.checkoutRoot ?? ".";
1920
- if (options.all || legacyTargetDir && options.scope.length === 0) {
1921
- return { targetDir };
1922
- }
1923
- if (workspacePath) return { targetDir, scopes: [workspacePath] };
1924
- if (options.scope.length > 0) return { targetDir, scopes: options.scope };
1925
- if (!state || state.checkedOutScopes.length === 0) {
1926
- throw new Error("Checkout is not initialized. Run moxt pull <workspace-path> or moxt pull --all first.");
1927
- }
1928
- return {
1929
- targetDir: state.checkoutRoot,
1930
- scopes: state.mode === "full" ? void 0 : state.checkedOutScopes
1931
- };
1932
- }
1933
- function isLegacyTargetDirectory(value) {
1934
- return value === "." || value.startsWith("./") || value.startsWith("../") || isAbsolute3(value);
1229
+ return value;
1935
1230
  }
1936
- function validateCheckoutStateContext(state, context) {
1937
- if (state.workspaceId !== context.workspaceId) {
1938
- throw new Error(`Checkout state belongs to workspace ${state.workspaceId}, not ${context.workspaceId}`);
1939
- }
1940
- const currentRepos = new Map(listRepoEntries(context.repoPayload).map((repo) => [repo.repoId, repo.name]));
1941
- for (const [repoId, repo] of Object.entries(state.repos)) {
1942
- const currentName = currentRepos.get(repoId);
1943
- if (!currentName) throw new Error(`Checkout state contains unknown repo: ${repoId}`);
1944
- if (currentName !== repo.name) {
1945
- throw new Error(`Checkout state repo name does not match current workspace: ${repo.name}`);
1946
- }
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;
1947
1251
  }
1252
+ throw new WorkflowOptionsError(
1253
+ 'Error: --scope must be "all", "assigned_to_me", "subscribed_by_me", or "owner_is_me".'
1254
+ );
1948
1255
  }
1949
- function collectScope(value, previous) {
1950
- return [...previous, value];
1951
- }
1952
- function resolveSandboxRuntimeContextOrExit2() {
1953
- let context;
1954
- try {
1955
- context = resolveRuntimeContext();
1956
- } catch (error) {
1957
- if (error instanceof RuntimeContextError) {
1958
- printError(error.message);
1959
- process.exit(1);
1960
- }
1961
- 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.`);
1962
1261
  }
1963
- if (context.mode !== "sandbox") {
1964
- printError("moxt pull is only available in sandbox mode");
1965
- process.exit(1);
1966
- }
1967
- return context;
1262
+ return ids;
1968
1263
  }
1969
- function handlePullError(error) {
1970
- if (error instanceof Error) {
1971
- printError(error.message);
1972
- 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}`);
1973
1268
  }
1974
- throw error;
1975
- }
1976
-
1977
- // src/utils/sandbox-push.ts
1978
- import { createHash as createHash3 } from "crypto";
1979
- import { lstat as lstat2, readFile as readFile3, readdir } from "fs/promises";
1980
- import { isAbsolute as isAbsolute4, join as join2, relative as relative2, resolve as resolve2 } from "path";
1981
-
1982
- // src/utils/concurrency.ts
1983
- async function runWithConcurrency(items, concurrency, action) {
1984
- if (!Number.isInteger(concurrency) || concurrency < 1) {
1985
- 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}`);
1986
1272
  }
1987
- let nextIndex = 0;
1988
- async function worker() {
1989
- while (nextIndex < items.length) {
1990
- const item = items[nextIndex];
1991
- nextIndex += 1;
1992
- await action(item);
1993
- }
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.");
1994
1276
  }
1995
- const workerCount = Math.min(concurrency, items.length);
1996
- 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;
1997
1282
  }
1998
-
1999
- // src/utils/sandbox-push.ts
2000
- var MFS_FILE_TYPE_REGULAR2 = 1;
2001
- var MAX_CONCURRENT_UPLOADS = 8;
2002
- var SandboxPushError = class extends Error {
2003
- constructor(message, code) {
2004
- super(message);
2005
- this.code = code;
2006
- this.name = "SandboxPushError";
1283
+ function readCommentContentFile2(path2) {
1284
+ if (!fs4.existsSync(path2)) {
1285
+ throw new WorkflowOptionsError(`Error: comment content file not found: ${path2}`);
2007
1286
  }
2008
- code;
2009
- };
2010
- async function pushSandboxWorkspace(context, sourceDir, options) {
2011
- const resolvedSourceDir = resolve2(sourceDir);
2012
- await assertWorkspaceRoot(resolvedSourceDir);
2013
- const repos = listRepoEntries(context.repoPayload);
2014
- for (const repo of repos) {
2015
- assertSafeRepoName(resolvedSourceDir, repo.name);
2016
- }
2017
- const state = await resolveCheckoutState(context, resolvedSourceDir, repos);
2018
- const checkoutRepos = repos.filter(
2019
- (repo) => state.repos[repo.repoId] && doesCheckoutPathIntersect(state, repo.name)
2020
- );
2021
- if (checkoutRepos.length === 0) {
2022
- throw new SandboxPushError("Checkout state does not contain a pushable scope", "invalid-path");
2023
- }
2024
- const tree = await fetchWorkspaceTree2(context, checkoutRepos);
2025
- const remoteEntries = indexRemoteEntries(tree, checkoutRepos);
2026
- const localFiles = await collectLocalFiles(resolvedSourceDir, checkoutRepos, remoteEntries, state);
2027
- const { changes, reconciledFiles, removedWorkspacePaths } = planChanges(
2028
- localFiles,
2029
- remoteEntries,
2030
- state,
2031
- checkoutRepos
2032
- );
2033
- let pushResponse;
2034
- if (changes.length > 0) {
2035
- await uploadChangedFiles(context, changes);
2036
- 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}`);
2037
1290
  }
2038
- if (changes.length > 0 || reconciledFiles.length > 0 || removedWorkspacePaths.length > 0) {
2039
- 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
+ );
2040
1295
  }
2041
- return {
2042
- changes: changes.map((change) => ({ action: change.action, workspacePath: change.workspacePath })),
2043
- sourceDir: resolvedSourceDir
2044
- };
2045
- }
2046
- async function resolveCheckoutState(context, sourceDir, repos) {
2047
- const state = await loadCheckoutState();
2048
- if (!state) {
2049
- 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.");
2050
1299
  }
2051
- if (state.workspaceId !== context.workspaceId) {
2052
- throw new SandboxPushError(
2053
- `Checkout state belongs to workspace ${state.workspaceId}, not ${context.workspaceId}`,
2054
- "invalid-path"
2055
- );
1300
+ if (isBinaryContent(buffer2)) {
1301
+ throw new WorkflowOptionsError("Error: comment content file must be UTF-8 text, not binary content.");
2056
1302
  }
2057
- if (state.checkoutRoot !== sourceDir) {
2058
- throw new SandboxPushError(
2059
- `Checkout root is ${state.checkoutRoot}, not ${sourceDir}`,
2060
- "invalid-path"
2061
- );
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.");
2062
1308
  }
2063
- const currentRepos = new Map(repos.map((repo) => [repo.repoId, repo.name]));
2064
- for (const [repoId, repo] of Object.entries(state.repos)) {
2065
- if (currentRepos.get(repoId) !== repo.name) {
2066
- throw new SandboxPushError(`Checkout state contains unknown repo: ${repoId}`, "invalid-path");
2067
- }
1309
+ if (content.length === 0) {
1310
+ throw new WorkflowOptionsError("Error: comment content file must not be empty.");
2068
1311
  }
2069
- for (const scope of state.checkedOutScopes) {
2070
- const hasRepo = Object.values(state.repos).some(
2071
- (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.`
2072
1315
  );
2073
- if (!hasRepo) throw new SandboxPushError(`Checkout state contains invalid scope: ${scope}`, "invalid-path");
2074
1316
  }
2075
- return state;
1317
+ return content;
2076
1318
  }
2077
- async function assertWorkspaceRoot(sourceDir) {
2078
- const stats = await lstat2(sourceDir).catch((error) => {
2079
- if (isNodeError3(error) && error.code === "ENOENT") {
2080
- throw new SandboxPushError(`Local workspace directory does not exist: ${sourceDir}`, "invalid-path");
2081
- }
2082
- throw error;
2083
- });
2084
- if (!stats.isDirectory()) {
2085
- throw new SandboxPushError(`Local workspace path is not a directory: ${sourceDir}`, "invalid-path");
2086
- }
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 {};
2087
1339
  }
2088
- async function fetchWorkspaceTree2(context, repos) {
2089
- const client = new SandboxMoxtClient(context);
2090
- const response = await client.request(
2091
- "POST",
2092
- "/agent/api/pipeline-sandbox/mfs-workspace/batch-trees",
2093
- {
2094
- repos: repos.map((repo) => ({ repoId: repo.repoId, prefix: repo.name }))
2095
- }
2096
- );
2097
- return response.data;
2098
- }
2099
- function indexRemoteEntries(tree, repos) {
2100
- const knownRepoIds = new Set(repos.map((repo) => repo.repoId));
2101
- const entries = /* @__PURE__ */ new Map();
2102
- for (const entry of tree.entries) {
2103
- if (!knownRepoIds.has(entry.repoId)) {
2104
- throw new SandboxPushError(`Remote tree returned unknown repo: ${entry.repoId}`, "remote");
2105
- }
2106
- const key = remoteEntryKey(entry.repoId, entry.path);
2107
- if (entries.has(key)) {
2108
- throw new SandboxPushError(`Remote tree returned duplicate path: ${entry.prefixedPath}`, "remote");
2109
- }
2110
- entries.set(key, entry);
2111
- }
2112
- return entries;
2113
- }
2114
- async function collectLocalFiles(sourceDir, repos, remoteEntries, state) {
2115
- const files = [];
2116
- for (const repo of repos) {
2117
- const repoRoot = resolveSafeRepoRoot(sourceDir, repo.name);
2118
- const stats = await lstat2(repoRoot).catch((error) => {
2119
- if (isNodeError3(error) && error.code === "ENOENT") return void 0;
2120
- throw error;
2121
- });
2122
- if (!stats) continue;
2123
- if (!stats.isDirectory()) {
2124
- throw new SandboxPushError(`Local repo path is not a directory: ${repo.name}`, "invalid-path");
2125
- }
2126
- await collectRepoFiles(repoRoot, repo, "", remoteEntries, state, files);
2127
- }
2128
- return files;
2129
- }
2130
- async function collectRepoFiles(repoRoot, repo, relativeDir, remoteEntries, state, files) {
2131
- const localDir = relativeDir ? join2(repoRoot, ...relativeDir.split("/")) : repoRoot;
2132
- const entries = await readdir(localDir, { withFileTypes: true });
2133
- entries.sort((a, b) => a.name.localeCompare(b.name));
2134
- for (const entry of entries) {
2135
- if (entry.name === ".git") continue;
2136
- const repoPath = relativeDir ? `${relativeDir}/${entry.name}` : entry.name;
2137
- const workspacePath = `${repo.name}/${repoPath}`;
2138
- assertSafePathPart(entry.name, workspacePath);
2139
- if (!doesCheckoutPathIntersect(state, workspacePath)) continue;
2140
- if (entry.isSymbolicLink()) {
2141
- throw new SandboxPushError(`Refusing to push symbolic link: ${workspacePath}`, "invalid-file");
2142
- }
2143
- if (entry.isDirectory()) {
2144
- const remote = remoteEntries.get(remoteEntryKey(repo.repoId, repoPath));
2145
- if (remote?.type === "blob") {
2146
- throw new SandboxPushError(`Local directory conflicts with remote file: ${workspacePath}`, "invalid-file");
2147
- }
2148
- await collectRepoFiles(repoRoot, repo, repoPath, remoteEntries, state, files);
2149
- continue;
2150
- }
2151
- if (!entry.isFile()) {
2152
- throw new SandboxPushError(`Refusing to push unsupported file: ${workspacePath}`, "invalid-file");
2153
- }
2154
- const localPath = join2(repoRoot, ...repoPath.split("/"));
2155
- const stats = await lstat2(localPath);
2156
- if (!stats.isFile()) {
2157
- const kind = stats.isSymbolicLink() ? "symbolic link" : "unsupported file";
2158
- throw new SandboxPushError(`Refusing to push ${kind}: ${workspacePath}`, "invalid-file");
2159
- }
2160
- if (stats.size > MFS_FILE_SIZE_LIMIT_BYTES) {
2161
- throw new SandboxPushError(
2162
- `File too large (max ${MFS_FILE_SIZE_LIMIT_LABEL}): ${workspacePath}`,
2163
- "too-large"
2164
- );
2165
- }
2166
- const content = await readFile3(localPath);
2167
- if (content.length > MFS_FILE_SIZE_LIMIT_BYTES) {
2168
- throw new SandboxPushError(
2169
- `File too large (max ${MFS_FILE_SIZE_LIMIT_LABEL}): ${workspacePath}`,
2170
- "too-large"
2171
- );
2172
- }
2173
- if (!isCheckoutPathIncluded(state, workspacePath)) continue;
2174
- files.push({
2175
- repo,
2176
- repoPath,
2177
- workspacePath,
2178
- localPath,
2179
- contentHash: createHash3("sha256").update(content).digest("hex"),
2180
- size: content.length
2181
- });
2182
- }
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 {};
2183
1357
  }
2184
- function planChanges(localFiles, remoteEntries, state, repos) {
2185
- const changes = [];
2186
- const reconciledFiles = [];
2187
- const removedWorkspacePaths = [];
2188
- const localWorkspacePaths = new Set(localFiles.map((file) => file.workspacePath));
2189
- const reposById = new Map(repos.map((repo) => [repo.repoId, repo]));
2190
- for (const file of localFiles) {
2191
- const baseline = state.files[file.workspacePath];
2192
- if (baseline?.sha === file.contentHash) continue;
2193
- const existing = remoteEntries.get(remoteEntryKey(file.repo.repoId, file.repoPath));
2194
- if (existing?.type === "tree") {
2195
- throw new SandboxPushError(`Local file conflicts with remote directory: ${file.workspacePath}`, "invalid-file");
2196
- }
2197
- if (existing?.sha === file.contentHash) {
2198
- reconciledFiles.push({ ...file, remote: existing });
2199
- continue;
2200
- }
2201
- if (existing && !existing.sha) {
2202
- throw new SandboxPushError(`${file.workspacePath} does not have a base content hash`, "remote");
2203
- }
2204
- if (baseline && existing?.sha !== baseline.sha) {
2205
- throw new SandboxPushError(`Remote file changed since checkout: ${file.workspacePath}`, "conflict");
2206
- }
2207
- if (!baseline && existing) {
2208
- throw new SandboxPushError(`Remote file changed since checkout: ${file.workspacePath}`, "conflict");
2209
- }
2210
- changes.push({
2211
- ...file,
2212
- action: existing ? "update" : "create",
2213
- ...existing ? { existing } : {}
2214
- });
2215
- }
2216
- for (const [workspacePath, baseline] of Object.entries(state.files)) {
2217
- if (localWorkspacePaths.has(workspacePath) || !isCheckoutPathIncluded(state, workspacePath)) continue;
2218
- const repo = reposById.get(baseline.repoId);
2219
- if (!repo) continue;
2220
- const existing = remoteEntries.get(remoteEntryKey(baseline.repoId, baseline.repoPath));
2221
- if (!existing) {
2222
- removedWorkspacePaths.push(workspacePath);
2223
- continue;
2224
- }
2225
- if (existing.type !== "blob" || existing.sha !== baseline.sha) {
2226
- throw new SandboxPushError(`Remote file changed since checkout: ${workspacePath}`, "conflict");
2227
- }
2228
- changes.push({
2229
- action: "delete",
2230
- repo,
2231
- repoPath: baseline.repoPath,
2232
- workspacePath,
2233
- fileId: validFileId(existing.fileId) ?? baseline.fileId
2234
- });
1358
+ function assertNonEmptyBody(body) {
1359
+ if (Object.keys(body).length === 0) {
1360
+ throw new WorkflowOptionsError("Error: at least one update option must be provided.");
2235
1361
  }
2236
- return { changes, reconciledFiles, removedWorkspacePaths };
2237
- }
2238
- async function uploadChangedFiles(context, changes) {
2239
- const writeChanges = changes.filter(isWriteChange);
2240
- if (writeChanges.length === 0) return;
2241
- const client = new SandboxMoxtClient(context);
2242
- const changesByRepo = groupChangesByRepo(writeChanges);
2243
- const uploadUrlsByRepo = await Promise.all(
2244
- [...changesByRepo].map(async ([repoId, repoChanges]) => {
2245
- const hashes = [...new Set(repoChanges.map((change) => change.contentHash))];
2246
- const response = await client.request(
2247
- "POST",
2248
- `/agent/api/pipeline-sandbox/mfs/${encodeURIComponent(repoId)}/presign-upload`,
2249
- { hashes }
2250
- );
2251
- return [repoId, response.data.urls];
2252
- })
2253
- );
2254
- const uploadUrls = new Map(uploadUrlsByRepo);
2255
- await runWithConcurrency(writeChanges, MAX_CONCURRENT_UPLOADS, async (change) => {
2256
- const uploadUrl = uploadUrls.get(change.repo.repoId)?.[change.contentHash];
2257
- if (!uploadUrl) {
2258
- throw new SandboxPushError(`No upload URL returned for ${change.workspacePath}`, "remote");
2259
- }
2260
- const content = await readFile3(change.localPath);
2261
- const currentHash = createHash3("sha256").update(content).digest("hex");
2262
- if (content.length !== change.size || currentHash !== change.contentHash) {
2263
- throw new SandboxPushError(`Local file changed while pushing: ${change.workspacePath}`, "invalid-file");
2264
- }
2265
- const upload = await fetch(uploadUrl, {
2266
- method: "PUT",
2267
- headers: { "Content-Type": "application/octet-stream" },
2268
- body: new Uint8Array(content)
2269
- });
2270
- if (!upload.ok) {
2271
- throw new SandboxPushError(`Upload failed [${upload.status}]: ${change.workspacePath}`, "remote");
2272
- }
2273
- });
2274
1362
  }
2275
- async function pushChanges(context, repos, changes, message) {
2276
- const client = new SandboxMoxtClient(context);
2277
- const changesByRepo = groupChangesByRepo(changes);
2278
- const response = await client.request(
2279
- "POST",
2280
- "/agent/api/pipeline-sandbox/mfs-workspace/unified-push",
2281
- {
2282
- message,
2283
- repoPushes: repos.flatMap((repo) => {
2284
- const repoChanges = changesByRepo.get(repo.repoId);
2285
- if (!repoChanges) return [];
2286
- return [{
2287
- repoId: repo.repoId,
2288
- changes: repoChanges.map((change) => {
2289
- if (change.action === "delete") {
2290
- return {
2291
- action: change.action,
2292
- ...change.fileId ? { fileId: change.fileId } : {},
2293
- path: change.repoPath
2294
- };
2295
- }
2296
- return {
2297
- action: change.action,
2298
- ...change.existing?.fileId ? { fileId: change.existing.fileId } : {},
2299
- path: change.repoPath,
2300
- contentHash: change.contentHash,
2301
- ...change.existing?.sha ? { baseContentHash: change.existing.sha } : {},
2302
- size: change.size,
2303
- fileType: MFS_FILE_TYPE_REGULAR2
2304
- };
2305
- })
2306
- }];
2307
- }),
2308
- crossRepoMoves: []
2309
- }
2310
- );
2311
- return response.data;
2312
- }
2313
- async function updateCheckoutState(state, changes, reconciledFiles, removedWorkspacePaths, response) {
2314
- const files = { ...state.files };
2315
- for (const workspacePath of removedWorkspacePaths) delete files[workspacePath];
2316
- for (const file of reconciledFiles) {
2317
- files[file.workspacePath] = checkoutStateFile(
2318
- file,
2319
- validFileId(file.remote.fileId),
2320
- validFileType(file.remote.fileType)
2321
- );
2322
- }
2323
- for (const change of changes) {
2324
- if (change.action === "delete") {
2325
- delete files[change.workspacePath];
2326
- 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));
2327
1368
  }
2328
- const returnedFileId = validFileId(response?.fileMap?.[change.repo.repoId]?.[change.repoPath]);
2329
- const fileId = returnedFileId ?? change.existing?.fileId ?? state.files[change.workspacePath]?.fileId ?? null;
2330
- files[change.workspacePath] = checkoutStateFile(change, validFileId(fileId), validFileType(change.existing?.fileType));
2331
1369
  }
2332
- await saveCheckoutState({ ...state, files });
2333
- }
2334
- function isWriteChange(change) {
2335
- return change.action !== "delete";
2336
- }
2337
- function checkoutStateFile(file, fileId, fileType) {
2338
- return {
2339
- repoId: file.repo.repoId,
2340
- repoPath: file.repoPath,
2341
- fileId,
2342
- sha: file.contentHash,
2343
- size: file.size,
2344
- fileType: fileType ?? MFS_FILE_TYPE_REGULAR2
2345
- };
1370
+ const text = query.toString();
1371
+ return text ? `?${text}` : "";
2346
1372
  }
2347
- function validFileId(fileId) {
2348
- 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);
2349
1376
  }
2350
- function validFileType(fileType) {
2351
- 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
+ );
2352
1425
  }
2353
- function groupChangesByRepo(changes) {
2354
- const result = /* @__PURE__ */ new Map();
2355
- for (const change of changes) {
2356
- const repoChanges = result.get(change.repo.repoId) ?? [];
2357
- repoChanges.push(change);
2358
- 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;
2359
1433
  }
2360
- return result;
1434
+ return String(value);
2361
1435
  }
2362
- function remoteEntryKey(repoId, repoPath) {
2363
- return `${repoId}\0${repoPath}`;
1436
+ function printDetailField2(name, value, indent = 0) {
1437
+ print(`${indentation2(indent)}${name}: ${formatDetailScalar2(value)}`);
2364
1438
  }
2365
- function assertSafePathPart(part, workspacePath) {
2366
- if (part === "." || part === ".." || part.includes("/") || part.includes("\\") || part.includes("\0")) {
2367
- 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;
2368
1443
  }
2369
- }
2370
- function assertSafeRepoName(sourceDir, repoName) {
2371
- resolveSafeRepoRoot(sourceDir, repoName);
2372
- }
2373
- function resolveSafeRepoRoot(sourceDir, repoName) {
2374
- assertSafePathPart(repoName, repoName);
2375
- const repoRoot = resolve2(sourceDir, repoName);
2376
- const relativePath = relative2(sourceDir, repoRoot);
2377
- if (relativePath.startsWith("..") || isAbsolute4(relativePath)) {
2378
- 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}`);
2379
1448
  }
2380
- return repoRoot;
2381
1449
  }
2382
- function isNodeError3(error) {
2383
- 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
+ );
2384
1520
  }
2385
-
2386
- // src/commands/push.ts
2387
- function registerPushCommand(program2) {
2388
- 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) => {
2389
- const context = resolveSandboxRuntimeContextOrExit3();
2390
- try {
2391
- const result = await pushSandboxWorkspace(context, sourceDir, { message: options.message });
2392
- for (const change of result.changes) {
2393
- print(`${change.action} ${change.workspacePath}`);
2394
- }
2395
- if (result.changes.length === 0) {
2396
- print("No changes to push");
2397
- return;
2398
- }
2399
- const fileLabel = result.changes.length === 1 ? "file" : "files";
2400
- print(`Pushed ${result.changes.length} ${fileLabel} from ${result.sourceDir}`);
2401
- } catch (error) {
2402
- 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);
2403
1573
  }
2404
- });
2405
- }
2406
- function resolveSandboxRuntimeContextOrExit3() {
2407
- let context;
2408
- try {
2409
- context = resolveRuntimeContext();
2410
- } catch (error) {
2411
- if (error instanceof RuntimeContextError) {
2412
- printError(error.message);
2413
- process.exit(1);
1574
+ if (response.data.workflows.length === 0) {
1575
+ stopSpinner(true, "No workflows found.");
1576
+ return;
2414
1577
  }
2415
- throw error;
2416
- }
2417
- if (context.mode !== "sandbox") {
2418
- printError("moxt push is only available in sandbox mode");
2419
- process.exit(1);
2420
- }
2421
- return context;
2422
- }
2423
- function handlePushError(error) {
2424
- if (error instanceof Error) {
2425
- printError(error.message);
2426
- process.exit(1);
2427
- }
2428
- throw error;
2429
- }
2430
-
2431
- // src/utils/checkout-status.ts
2432
- import { createHash as createHash4 } from "crypto";
2433
- import { createReadStream } from "fs";
2434
- import { lstat as lstat3, readdir as readdir2 } from "fs/promises";
2435
- import { join as join3 } from "path";
2436
- var CheckoutStatusError = class extends Error {
2437
- constructor(message) {
2438
- super(message);
2439
- this.name = "CheckoutStatusError";
2440
- }
2441
- };
2442
- async function inspectCheckoutStatus(state) {
2443
- await requireCheckoutDirectory(state.checkoutRoot);
2444
- const localFiles = /* @__PURE__ */ new Map();
2445
- for (const repo of Object.values(state.repos)) {
2446
- await scanDirectory(state, repo.name, join3(state.checkoutRoot, repo.name), localFiles);
2447
- }
2448
- const changes = [];
2449
- for (const [path2, sha] of localFiles) {
2450
- const baseline = state.files[path2];
2451
- if (!baseline) changes.push({ action: "added", path: path2 });
2452
- else if (baseline.sha !== sha) changes.push({ action: "modified", path: path2 });
2453
- }
2454
- for (const path2 of Object.keys(state.files)) {
2455
- if (isCheckoutPathIncluded(state, path2) && !localFiles.has(path2)) changes.push({ action: "deleted", path: path2 });
2456
- }
2457
- return changes.sort((left, right) => left.path.localeCompare(right.path));
2458
- }
2459
- async function requireCheckoutDirectory(checkoutRoot) {
2460
- let stats;
2461
- try {
2462
- stats = await lstat3(checkoutRoot);
2463
- } catch (error) {
2464
- throw new CheckoutStatusError(`Checkout root is not available: ${errorMessage2(error)}`);
2465
- }
2466
- if (stats.isSymbolicLink()) throw new CheckoutStatusError(`Refusing to inspect symbolic link: ${checkoutRoot}`);
2467
- if (!stats.isDirectory()) throw new CheckoutStatusError(`Checkout root is not a directory: ${checkoutRoot}`);
2468
- }
2469
- async function scanDirectory(state, workspacePath, localPath, files) {
2470
- let entries;
2471
- try {
2472
- entries = await readdir2(localPath, { withFileTypes: true });
2473
- } catch (error) {
2474
- if (isNodeError4(error) && error.code === "ENOENT") return;
2475
- throw new CheckoutStatusError(`Failed to inspect ${workspacePath}: ${errorMessage2(error)}`);
2476
- }
2477
- for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
2478
- if (entry.name === ".git") continue;
2479
- const childWorkspacePath = `${workspacePath}/${entry.name}`;
2480
- if (!doesCheckoutPathIntersect(state, childWorkspacePath)) continue;
2481
- const childLocalPath = join3(localPath, entry.name);
2482
- const stats = await lstat3(childLocalPath);
2483
- if (stats.isSymbolicLink()) {
2484
- throw new CheckoutStatusError(`Refusing to inspect symbolic link: ${childWorkspacePath}`);
1578
+ stopSpinner(true, `Found ${response.data.workflows.length} workflow(s)`);
1579
+ for (const item of response.data.workflows) printWorkflow(item);
1580
+ });
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);
2485
1600
  }
2486
- if (stats.isDirectory()) {
2487
- await scanDirectory(state, childWorkspacePath, childLocalPath, files);
2488
- } else if (stats.isFile() && isCheckoutPathIncluded(state, childWorkspacePath)) {
2489
- files.set(childWorkspacePath, await hashFile(childLocalPath));
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);
2490
1616
  }
2491
- }
2492
- }
2493
- async function hashFile(path2) {
2494
- const hash = createHash4("sha256");
2495
- const stream = createReadStream(path2);
2496
- for await (const chunk of stream) hash.update(chunk);
2497
- return hash.digest("hex");
2498
- }
2499
- function isNodeError4(error) {
2500
- return error instanceof Error && "code" in error;
2501
- }
2502
- function errorMessage2(error) {
2503
- return error instanceof Error ? error.message : String(error);
2504
- }
2505
-
2506
- // src/commands/status.ts
2507
- function registerStatusCommand(program2) {
2508
- program2.command("status").description("Show Moxt workspace checkout status").option("--json", "Print machine-readable status").action(async (options) => {
2509
- const context = resolveRuntimeContextOrExit2();
2510
- if (context.mode !== "sandbox") {
2511
- printError("moxt status is only available in sandbox mode");
2512
- process.exit(1);
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);
2513
1635
  }
2514
- const status = await buildSandboxStatusOrExit(context);
2515
- if (options.json) {
2516
- print(JSON.stringify(status, null, 2));
2517
- return;
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);
2518
1650
  }
2519
- print(`${colors.bold}Workspace${colors.reset}: ${status.workspaceId}`);
2520
- print(`${colors.bold}Mode${colors.reset}: sandbox`);
2521
- print(`${colors.bold}Checkout${colors.reset}: ${formatCheckoutMode(status.checkout.mode)}`);
2522
- if (status.checkout.mode !== "not_initialized") {
2523
- print(`${colors.bold}Root${colors.reset}: ${status.checkout.root}`);
2524
- }
2525
- print("");
2526
- print(`${colors.bold}Repos${colors.reset}:`);
2527
- const repoNameWidth = status.repos.reduce((width, repo) => Math.max(width, repo.name.length), 0);
2528
- for (const repo of status.repos) {
2529
- print(` ${repo.name.padEnd(repoNameWidth)} ${repo.repoId}`);
2530
- }
2531
- print("");
2532
- if (status.checkout.mode === "not_initialized") {
2533
- print(`${colors.bold}Checked out scopes${colors.reset}: none`);
2534
- print(`${colors.bold}Local changes${colors.reset}: not tracked`);
2535
- 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);
2536
1666
  }
2537
- print(`${colors.bold}Checked out scopes${colors.reset}:`);
2538
- for (const scope of status.checkout.checkedOutScopes) print(` ${scope}`);
2539
- print(`${colors.bold}Local changes${colors.reset}:${status.checkout.changes.length === 0 ? " none" : ""}`);
2540
- for (const change of status.checkout.changes) {
2541
- print(` ${change.action.padEnd(8)} ${change.path}`);
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);
2542
1703
  }
2543
- });
2544
- }
2545
- async function buildSandboxStatusOrExit(context) {
2546
- const repos = listRepoEntries(context.repoPayload).map((repo) => ({
2547
- name: repo.name,
2548
- repoId: repo.repoId
2549
- }));
2550
- try {
2551
- const state = await loadCheckoutState();
2552
- if (!state) return buildUninitializedStatus(context.workspaceId, repos);
2553
- validateStateContext(state, context);
2554
- return {
2555
- mode: "sandbox",
2556
- workspaceId: context.workspaceId,
2557
- checkout: {
2558
- mode: state.mode,
2559
- root: state.checkoutRoot,
2560
- checkedOutScopes: state.checkedOutScopes,
2561
- localChangesTracked: true,
2562
- changes: await inspectCheckoutStatus(state)
2563
- },
2564
- repos
2565
- };
2566
- } catch (error) {
2567
- printError(error instanceof Error ? error.message : String(error));
2568
- process.exit(1);
2569
- }
2570
- }
2571
- function buildUninitializedStatus(workspaceId, repos) {
2572
- return {
2573
- mode: "sandbox",
2574
- workspaceId,
2575
- checkout: {
2576
- mode: "not_initialized",
2577
- checkedOutScopes: [],
2578
- localChangesTracked: false
2579
- },
2580
- repos
2581
- };
2582
- }
2583
- function validateStateContext(state, context) {
2584
- if (state.workspaceId !== context.workspaceId) {
2585
- throw new Error(`Checkout state belongs to workspace ${state.workspaceId}, not ${context.workspaceId}`);
2586
- }
2587
- const currentRepos = new Map(listRepoEntries(context.repoPayload).map((repo) => [repo.repoId, repo.name]));
2588
- for (const [repoId, repo] of Object.entries(state.repos)) {
2589
- const currentName = currentRepos.get(repoId);
2590
- if (!currentName) throw new Error(`Checkout state contains unknown repo: ${repoId}`);
2591
- if (currentName !== repo.name) {
2592
- throw new Error(`Checkout state repo name does not match current workspace: ${repo.name}`);
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);
2593
1738
  }
2594
- }
2595
- }
2596
- function resolveRuntimeContextOrExit2() {
2597
- try {
2598
- return resolveRuntimeContext();
2599
- } catch (error) {
2600
- if (error instanceof RuntimeContextError) {
2601
- printError(error.message);
2602
- process.exit(1);
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");
2603
1766
  }
2604
- throw error;
2605
- }
2606
- }
2607
- function formatCheckoutMode(mode) {
2608
- return mode === "not_initialized" ? "not initialized" : `${mode} checkout`;
2609
- }
2610
-
2611
- // src/telemetry/config.ts
2612
- import * as fs3 from "fs";
2613
- import * as os from "os";
2614
- import * as path from "path";
2615
- function getConfigPath() {
2616
- return path.join(os.homedir(), ".moxt", "config.json");
2617
- }
2618
- function readTelemetryConfig() {
2619
- try {
2620
- const raw = fs3.readFileSync(getConfigPath(), "utf8");
2621
- const parsed = JSON.parse(raw);
2622
- return parsed.telemetry ?? {};
2623
- } catch {
2624
- return {};
2625
- }
2626
- }
2627
- function writeTelemetryConfig(update) {
2628
- const configPath = getConfigPath();
2629
- let existing = {};
2630
- try {
2631
- existing = JSON.parse(fs3.readFileSync(configPath, "utf8"));
2632
- } catch {
2633
- existing = {};
2634
- }
2635
- const currentTelemetry = existing.telemetry ?? {};
2636
- const next = { ...existing, telemetry: { ...currentTelemetry, ...update } };
2637
- fs3.mkdirSync(path.dirname(configPath), { recursive: true });
2638
- fs3.writeFileSync(configPath, JSON.stringify(next, null, 2));
2639
- }
2640
-
2641
- // src/telemetry/opt-out.ts
2642
- function isCiEnvironment() {
2643
- return process.env.CI === "true" || process.env.CI === "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);
1779
+ }
1780
+ stopSpinner(true, `Deleted: ${response.data.scopedId}`);
1781
+ });
2644
1782
  }
2645
- function isTelemetryDisabled() {
2646
- if (process.env.MOXT_TELEMETRY_DISABLED === "1" || process.env.MOXT_TELEMETRY_DISABLED === "true") {
2647
- return true;
2648
- }
2649
- if (process.env.DO_NOT_TRACK === "1" || process.env.DO_NOT_TRACK === "true") {
2650
- 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
+ });
2651
1808
  }
2652
- return readTelemetryConfig().disabled === true;
2653
1809
  }
2654
-
2655
- // src/commands/telemetry.ts
2656
- function registerTelemetryCommand(program2) {
2657
- const telemetry = program2.command("telemetry").description("Manage Moxt CLI telemetry");
2658
- telemetry.command("status").description("Show telemetry status").action(() => {
2659
- const cfg = readTelemetryConfig();
2660
- 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";
2661
- const state = isTelemetryDisabled() ? "disabled" : "enabled";
2662
- print(`telemetry: ${state}`);
2663
- if (envOverride) {
2664
- print("(disabled via environment variable)");
2665
- } else if (cfg.disabled) {
2666
- 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}`);
2667
1835
  }
2668
- });
2669
- telemetry.command("enable").description("Enable telemetry").action(() => {
2670
- writeTelemetryConfig({ disabled: false });
2671
- print("telemetry: enabled");
2672
- });
2673
- telemetry.command("disable").description("Disable telemetry").action(() => {
2674
- writeTelemetryConfig({ disabled: true });
2675
- print("telemetry: disabled");
2676
- });
2677
- }
2678
-
2679
- // src/commands/whoami.ts
2680
- function registerWhoamiCommand(program2) {
2681
- program2.command("whoami").description("Show current user information").action(async () => {
2682
- startSpinner("Fetching user info...");
2683
- const response = await httpGet("/users/whoami");
2684
- if (response.ok) {
2685
- stopSpinner(true, "Done");
2686
- print(`${colors.bold}Nickname${colors.reset}: ${response.data.nickname}`);
2687
- print(`${colors.bold}Email${colors.reset}: ${response.data.email}`);
2688
- } 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) {
2689
1846
  stopSpinner(false, "Failed");
2690
- printError(`Error [${response.status}]: ${JSON.stringify(response.data)}`);
2691
- process.exit(1);
1847
+ failResponse(response);
2692
1848
  }
1849
+ stopSpinner(true, "Task fetched");
1850
+ printTaskDetail(response.data);
2693
1851
  });
2694
- }
2695
-
2696
- // src/commands/workspace.ts
2697
- var emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
2698
- function isValidEmail(email) {
2699
- return emailRegex.test(email);
2700
- }
2701
- function registerWorkspaceCommand(program2) {
2702
- const workspace = program2.command("workspace").description("Workspace management");
2703
- workspace.command("list").description("List all workspaces you belong to").action(async () => {
2704
- startSpinner("Fetching workspaces...");
2705
- const response = await httpGet("/workspaces");
2706
- if (response.ok) {
2707
- const { workspaces } = response.data;
2708
- if (workspaces.length === 0) {
2709
- stopSpinner(true, "No workspaces found.");
2710
- 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);
2711
1879
  }
2712
- stopSpinner(true, `Found ${workspaces.length} workspace(s)`);
2713
- for (const ws of workspaces) {
2714
- 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);
2715
1913
  }
2716
- } 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) {
2717
1994
  stopSpinner(false, "Failed");
2718
- printError(`Error [${response.status}]: ${JSON.stringify(response.data)}`);
2719
- process.exit(1);
1995
+ failResponse(response);
2720
1996
  }
1997
+ stopSpinner(true, `Deleted: ${response.data.scopedId}`);
2721
1998
  });
2722
- const members = program2.command("members").description("Manage workspace members");
2723
- members.command("list").description("List all members in a workspace").requiredOption("-w, --workspace <workspaceId>", "Workspace ID").action(async (options) => {
2724
- const { workspace: workspaceId } = options;
2725
- startSpinner("Fetching workspace members...");
2726
- 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
+ );
2727
2031
  if (!response.ok) {
2728
- stopSpinner(false, "Failed to fetch members");
2729
- printError(`Error [${response.status}]: ${JSON.stringify(response.data)}`);
2730
- process.exit(1);
2032
+ stopSpinner(false, "Failed");
2033
+ failResponse(response);
2731
2034
  }
2732
- const { members: members2 } = response.data;
2733
- if (members2.length === 0) {
2734
- stopSpinner(true, "No members found.");
2035
+ if (response.data.items.length === 0) {
2036
+ stopSpinner(true, "No comments found.");
2735
2037
  return;
2736
2038
  }
2737
- stopSpinner(true, `Found ${members2.length} member(s)`);
2738
- for (const member of members2) {
2739
- const name = member.displayName || "-";
2740
- print(`${colors.bold}${member.email}${colors.reset} ${name} ${member.role}`);
2741
- }
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
+ });
2742
2044
  });
2743
- 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) => {
2744
- const { workspace: workspaceId } = options;
2745
- const emails = emailsArg.split(",").map((e) => e.trim()).filter(Boolean);
2746
- if (emails.length === 0) {
2747
- printError("No valid emails provided");
2748
- process.exit(1);
2749
- }
2750
- const invalidEmails = emails.filter((e) => !isValidEmail(e));
2751
- if (invalidEmails.length > 0) {
2752
- for (const email of invalidEmails) {
2753
- 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);
2754
2060
  }
2755
- process.exit(1);
2061
+ stopSpinner(true, `Deleted: ${response.data.id}`);
2756
2062
  }
2757
- startSpinner("Fetching workspace members...");
2758
- const membersResponse = await httpGet(`/workspaces/${workspaceId}/members`);
2759
- if (!membersResponse.ok) {
2760
- stopSpinner(false, "Failed to fetch members");
2761
- printError(`Error [${membersResponse.status}]: ${JSON.stringify(membersResponse.data)}`);
2762
- 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);
2763
2081
  }
2764
- stopSpinner(true, "Members fetched");
2765
- const { members: members2 } = membersResponse.data;
2766
- const emailToMember = new Map(members2.map((m) => [m.email.toLowerCase(), m]));
2767
- for (const email of emails) {
2768
- const member = emailToMember.get(email.toLowerCase());
2769
- if (!member) {
2770
- print(`${colors.red}\u2717${colors.reset} ${email} - not found in workspace`);
2771
- continue;
2772
- }
2773
- const deleteResponse = await httpDelete(`/workspaces/${workspaceId}/members/${member.userId}`);
2774
- if (deleteResponse.ok) {
2775
- print(`${colors.green}\u2713${colors.reset} ${email} - removed`);
2776
- } else {
2777
- print(`${colors.red}\u2717${colors.reset} ${email} - failed: ${JSON.stringify(deleteResponse.data)}`);
2778
- }
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}`);
2779
2085
  }
2780
2086
  });
2781
- const space = program2.command("space").description("Manage spaces");
2782
- space.command("list").description("List accessible spaces in a workspace").requiredOption("-w, --workspace <workspaceId>", "Workspace ID").action(async (options) => {
2783
- const { workspace: workspaceId } = options;
2784
- startSpinner("Fetching spaces...");
2785
- 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
+ );
2786
2095
  if (!response.ok) {
2787
- stopSpinner(false, "Failed to fetch spaces");
2788
- printError(`Error [${response.status}]: ${JSON.stringify(response.data)}`);
2789
- process.exit(1);
2790
- }
2791
- const { spaces } = response.data;
2792
- if (spaces.length === 0) {
2793
- stopSpinner(true, "No spaces found.");
2794
- return;
2096
+ stopSpinner(false, "Failed");
2097
+ failResponse(response);
2795
2098
  }
2796
- stopSpinner(true, `Found ${spaces.length} space(s)`);
2797
- print(
2798
- `${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
+ {}
2799
2110
  );
2800
- for (const s of spaces) {
2801
- const teamSpaceId = s.teamSpaceId ?? "";
2802
- const teammateId = s.agentId != null ? String(s.agentId) : "";
2803
- print(`${s.type} ${teamSpaceId} ${teammateId} ${s.name}`);
2111
+ if (!response.ok) {
2112
+ stopSpinner(false, "Failed");
2113
+ failResponse(response);
2804
2114
  }
2115
+ stopSpinner(true, `Abort accepted: ${id}`);
2805
2116
  });
2806
2117
  }
2807
2118
 
2808
2119
  // src/program.ts
2809
2120
  function createProgram(version) {
2810
- const program2 = new Command2();
2121
+ const program2 = new Command();
2811
2122
  program2.name("moxt").description("Moxt CLI - AI Workspace").version(version).action(() => {
2812
2123
  program2.help();
2813
2124
  });
2814
- registerAuthCommand(program2);
2815
2125
  registerWhoamiCommand(program2);
2816
2126
  registerWorkspaceCommand(program2);
2817
2127
  registerFileCommand(program2);
2818
2128
  registerMemoryCommand(program2);
2129
+ registerWorkflowCommand(program2);
2819
2130
  registerMiniappCommand(program2);
2820
- registerPullCommand(program2);
2821
- registerPushCommand(program2);
2822
- registerStatusCommand(program2);
2823
2131
  registerTelemetryCommand(program2);
2824
2132
  return program2;
2825
2133
  }
@@ -2831,7 +2139,7 @@ var FLUSH_TIMEOUT_MS = 3e3;
2831
2139
  var MAX_BATCH_SIZE = 100;
2832
2140
  function commonLabels() {
2833
2141
  return {
2834
- cli_version: "0.4.0-beta.0",
2142
+ cli_version: "0.4.0",
2835
2143
  node_version: process.versions.node,
2836
2144
  os: platform2(),
2837
2145
  arch: arch2(),
@@ -2877,7 +2185,7 @@ async function flush() {
2877
2185
  }
2878
2186
  const batch = buffer.splice(0, MAX_BATCH_SIZE);
2879
2187
  const payload = {
2880
- release_id: "0.4.0-beta.0",
2188
+ release_id: "0.4.0",
2881
2189
  client_type: "cli",
2882
2190
  metric_data_list: batch.map((m) => ({
2883
2191
  profile: "cli",
@@ -3018,9 +2326,9 @@ function installExitHandler() {
3018
2326
 
3019
2327
  // src/index.ts
3020
2328
  updateNotifier({
3021
- pkg: { name: "@moxt-ai/cli", version: "0.4.0-beta.0" }
2329
+ pkg: { name: "@moxt-ai/cli", version: "0.4.0" }
3022
2330
  }).notify();
3023
- var program = createProgram("0.4.0-beta.0");
2331
+ var program = createProgram("0.4.0");
3024
2332
  instrumentProgram(program);
3025
2333
  installExitHandler();
3026
2334
  program.parseAsync(process.argv).then(async () => {