@love-moon/conductor-cli 0.15.2 → 0.17.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.
@@ -11,11 +11,30 @@
11
11
  * set-default <id|name>
12
12
  * hide <id|name>
13
13
  * unhide <id|name>
14
+ * update <id|name> [--name <n>] [--merge-opt-out true|false]
15
+ * [--workspace-path <p> [--bind-daemon-host <h>]] [--json-body '{...}']
16
+ * refresh <id|name> (re-validate binding with the daemon)
17
+ * delete <id|name> --yes (merged: every daemon unless --daemon-host picks one)
18
+ * reorder <id|name>... (listed first, the rest keep their order)
19
+ * agents [<id|name>] (agents registered in .conductor/settings.yaml)
20
+ * collab invite [<id|name>]
21
+ * collab join <token|invite-url> (--into <id|name> | --create-project <name>)
22
+ * collab leave <id|name>
23
+ * collab show-invite <token|invite-url>
24
+ * labels list [<id|name>]
25
+ * labels add <name> (target project via --project)
26
+ * labels rename <label> <new-name>
27
+ * labels remove <label>
28
+ *
29
+ * Positional <id|name> commands accept --daemon-host to disambiguate
30
+ * same-name projects across daemons.
14
31
  *
15
32
  * Global flags supported on every write subcommand:
16
33
  * --json, --dry-run, --project, --config-file
17
34
  */
18
35
 
36
+ import { randomUUID } from "node:crypto";
37
+ import os from "node:os";
19
38
  import path from "node:path";
20
39
  import process from "node:process";
21
40
  import { fileURLToPath } from "node:url";
@@ -36,6 +55,7 @@ import {
36
55
  reportError,
37
56
  resolveProject,
38
57
  } from "../src/entity-helpers.js";
58
+ import { apiPath, argsError, buildHttp, parseJsonOption, sendOrPreview } from "../src/backend-http.js";
39
59
 
40
60
  const isMainModule = (() => {
41
61
  const currentFile = fileURLToPath(import.meta.url);
@@ -191,7 +211,9 @@ async function handleCreate(argv, deps) {
191
211
  const defaultName = argv.name && String(argv.name).trim()
192
212
  ? String(argv.name).trim()
193
213
  : (path.basename(workspacePath) || undefined);
194
- const daemonHost = argv.daemonHost ? String(argv.daemonHost) : undefined;
214
+ // workspacePath is a path on this machine, so bind to this machine's daemon
215
+ // unless told otherwise (the server needs daemonHost + workspacePath).
216
+ const daemonHost = argv.daemonHost ? String(argv.daemonHost) : localDaemonName(config, env);
195
217
  const isDefault = Boolean(argv.default);
196
218
  const metadata = buildAuditMetadata(env);
197
219
  if (argv.clientRequestId) {
@@ -218,23 +240,14 @@ async function handleCreate(argv, deps) {
218
240
  return EXIT.OK;
219
241
  }
220
242
 
221
- if (!isDefault) {
222
- if (!daemonHost && !config?.daemonName) {
223
- // Service-side will error when daemon is not reachable; we surface a
224
- // clearer hint here per "Decisions on leftover RFC questions" §1.
225
- // We don't proactively probe — but we *do* improve the wording when
226
- // the server returns "daemon not reachable".
227
- }
228
- }
229
-
230
243
  let created;
231
244
  try {
232
245
  created = await apis.projects.createProject(body);
233
246
  } catch (err) {
234
247
  if (isDaemonUnreachableError(err)) {
235
- const host = daemonHost || "(local daemon)";
248
+ const serverMessage = String(err.details?.error || err.message || "").trim();
236
249
  const friendly = new Error(
237
- `Daemon at ${host} not reachable. Start one with \`conductor daemon\` or pass --daemon-host <h> to use an existing one.`,
250
+ `${serverMessage.replace(/\.?$/, ".")} Start it with \`conductor daemon\`, or pass --daemon-host <h> to use another online daemon.`,
238
251
  );
239
252
  friendly.statusCode = err.statusCode;
240
253
  throw friendly;
@@ -250,8 +263,21 @@ async function handleCreate(argv, deps) {
250
263
  return EXIT.OK;
251
264
  }
252
265
 
266
+ /** The daemon name `conductor daemon` registers with on this machine. */
267
+ function localDaemonName(config, env) {
268
+ const name =
269
+ (typeof config?.daemonName === "string" && config.daemonName.trim()) ||
270
+ (typeof env?.CONDUCTOR_DAEMON_NAME === "string" && env.CONDUCTOR_DAEMON_NAME.trim()) ||
271
+ os.hostname().trim();
272
+ return name || undefined;
273
+ }
274
+
275
+ // The server answers 409 with `code` daemon_offline / daemon_unreachable
276
+ // (web/src/lib/projects/daemon-binding.ts).
253
277
  function isDaemonUnreachableError(err) {
254
278
  if (!err) return false;
279
+ const code = err.details?.code;
280
+ if (code === "daemon_offline" || code === "daemon_unreachable") return true;
255
281
  const message = err.message || "";
256
282
  if (/daemon/i.test(message) && /(not reachable|unreachable|cannot reach|connection refused|ECONN)/i.test(message)) {
257
283
  return true;
@@ -301,27 +327,662 @@ async function handleSetHidden(argv, deps, hidden) {
301
327
  cwd: deps.cwd,
302
328
  daemonHost: argv.daemonHost,
303
329
  });
304
- const url = `${buildBaseUrl(apis.config)}/api/projects?projectId=${encodeURIComponent(project.id)}`;
305
- const metadata = buildAuditMetadata(deps.env);
306
- const body = { hidden, metadata };
330
+ // Like the web project list, a cross-daemon merged project is hidden or
331
+ // restored on every daemon at once.
332
+ const members = displayedGroup(await apis.projects.listProjects({ includeHidden: true }), project);
333
+ // No `metadata` here: the project PATCH route replaces the whole metadata
334
+ // blob with whatever it is sent, so an audit-only object would erase the
335
+ // project's task labels, memos and binding data. The web UI sends `hidden` alone.
336
+ const body = { hidden };
307
337
  if (argv.dryRun) {
308
- emitDryRun(deps.stdout, argv.json, makeDryRunPayload("PATCH", url, body));
338
+ for (const member of members) {
339
+ const url = `${buildBaseUrl(apis.config)}/api/projects?projectId=${encodeURIComponent(member.id)}`;
340
+ emitDryRun(deps.stdout, argv.json, makeDryRunPayload("PATCH", url, body));
341
+ }
309
342
  return EXIT.OK;
310
343
  }
311
- // SDK signature: `setProjectHidden(idOrName, hidden, options?)`. Earlier we
312
- // were passing the whole body as the third arg, which the SDK silently
313
- // dropped — audit metadata never reached the server (review H2a).
314
- const updated = await apis.projects.setProjectHidden(project.id, hidden, { metadata });
344
+ const updated = [];
345
+ for (const [index, member] of members.entries()) {
346
+ try {
347
+ updated.push(await apis.projects.setProjectHidden(member.id, hidden));
348
+ } catch (err) {
349
+ // Like delete: say which copies changed before the failure.
350
+ if (members.length > 1) {
351
+ const ids = (list) => list.map((entry) => entry.id).join(", ") || "none";
352
+ const verb = hidden ? "hidden" : "unhidden";
353
+ err.message = `${verb[0].toUpperCase()}${verb.slice(1)}: ${ids(members.slice(0, index))}; ` +
354
+ `not ${verb}: ${ids(members.slice(index))}. Failed on ${member.id} (${err.message})`;
355
+ }
356
+ throw err;
357
+ }
358
+ }
315
359
  if (argv.json) {
316
- printJson(deps.stdout, projectAsObject(updated));
360
+ // The target's object, as before merged groups; `ids` lists every member changed.
361
+ const target = projectAsObject(updated[members.findIndex((member) => member.id === project.id)] ?? updated[0]);
362
+ printJson(deps.stdout, members.length > 1 ? { ...target, ids: members.map((member) => member.id) } : target);
317
363
  return EXIT.OK;
318
364
  }
365
+ const what = members.length > 1
366
+ ? `merged project ${project.name ?? project.id} on ${members.length} daemons`
367
+ : `project ${project.name ?? project.id}`;
368
+ printPretty(deps.stdout, hidden ? `Hid ${what}` : `Unhid ${what}`);
369
+ return EXIT.OK;
370
+ }
371
+
372
+ // ---------------------------------------------------------------------------
373
+ // HTTP-backed subcommands (routes the SDK does not wrap). See
374
+ // web/src/app/api/projects/** and web/src/features/projects/store.ts.
375
+ // ---------------------------------------------------------------------------
376
+
377
+ async function resolveTarget(argv, deps, selector = argv.idOrName ?? argv.project) {
378
+ const apis = await buildApis(deps);
379
+ return resolveProjectSelector(apis, selector, {
380
+ env: deps.env,
381
+ cwd: deps.cwd,
382
+ project: argv.project,
383
+ daemonHost: argv.daemonHost,
384
+ });
385
+ }
386
+
387
+ async function listRawProjects(http) {
388
+ const raw = await http.get("/api/projects");
389
+ if (Array.isArray(raw)) return raw;
390
+ if (raw && Array.isArray(raw.projects)) return raw.projects;
391
+ return [];
392
+ }
393
+
394
+ async function fetchRawProject(http, projectId) {
395
+ const list = await listRawProjects(http);
396
+ const found = list.find((entry) => entry && entry.id === projectId);
397
+ if (!found) {
398
+ const err = new Error(`Project not found: ${projectId}`);
399
+ err.statusCode = 404;
400
+ throw err;
401
+ }
402
+ return found;
403
+ }
404
+
405
+ function projectQuery(projectId) {
406
+ return { projectId };
407
+ }
408
+
409
+ function displayName(project) {
410
+ return `${project?.name ?? "(unnamed)"} (${project?.id})`;
411
+ }
412
+
413
+ function parseBooleanFlag(value, flag) {
414
+ if (value === undefined) return undefined;
415
+ if (typeof value === "boolean") return value;
416
+ const text = String(value).trim().toLowerCase();
417
+ if (text === "true") return true;
418
+ if (text === "false") return false;
419
+ throw argsError(`${flag} must be true or false`);
420
+ }
421
+
422
+ async function handleUpdate(argv, deps) {
423
+ const project = await resolveTarget(argv, deps);
424
+ const http = await buildHttp(deps);
425
+ const body = {};
426
+ if (argv.name !== undefined) {
427
+ const name = String(argv.name).trim();
428
+ if (!name) throw argsError("--name cannot be empty");
429
+ body.name = name;
430
+ }
431
+ const mergeOptOut = parseBooleanFlag(argv.mergeOptOut, "--merge-opt-out");
432
+ if (mergeOptOut !== undefined) body.mergeOptOut = mergeOptOut;
433
+ if (argv.workspacePath !== undefined) {
434
+ // Binding fields: the server requires daemonHost + workspacePath together,
435
+ // plus bindingConfirmed, and refuses to change an existing binding (409).
436
+ const daemonHost = argv.bindDaemonHost || project.daemonHost || argv.daemonHost;
437
+ if (!daemonHost) {
438
+ throw argsError("--workspace-path needs a daemon: pass --bind-daemon-host <host>");
439
+ }
440
+ // The path lives on `daemonHost`, which may not be this machine, so a
441
+ // relative path cannot be resolved here.
442
+ const workspacePath = String(argv.workspacePath).trim();
443
+ if (!path.isAbsolute(workspacePath)) {
444
+ throw argsError(`--workspace-path must be an absolute path on daemon ${daemonHost}`);
445
+ }
446
+ body.daemonHost = String(daemonHost);
447
+ body.workspacePath = path.normalize(workspacePath);
448
+ body.bindingConfirmed = true;
449
+ }
450
+ const extra = parseJsonOption(argv.jsonBody, "--json-body");
451
+ if (extra !== undefined) {
452
+ if (!extra || typeof extra !== "object" || Array.isArray(extra)) {
453
+ throw argsError("--json-body must be a JSON object");
454
+ }
455
+ Object.assign(body, extra);
456
+ }
457
+ if (Object.keys(body).length === 0) {
458
+ throw argsError("Nothing to update: pass --name, --merge-opt-out, --workspace-path or --json-body");
459
+ }
460
+ // Same as the web Split/Merge toggle: splitting opts out every member of the
461
+ // merged group; merging clears the opt-out on every same-name project on
462
+ // another daemon, whichever side had opted out.
463
+ let peers = [];
464
+ if (mergeOptOut !== undefined) {
465
+ const apis = await buildApis(deps);
466
+ const all = await apis.projects.listProjects({ includeHidden: true });
467
+ peers = mergeOptOut
468
+ ? displayedGroup(all, project).filter((entry) => entry.id !== project.id)
469
+ : all.filter((entry) =>
470
+ entry.id !== project.id &&
471
+ entry.name === project.name &&
472
+ String(project.daemonHost ?? "").trim() &&
473
+ String(entry.daemonHost ?? "").trim() &&
474
+ String(entry.daemonHost).trim() !== String(project.daemonHost).trim());
475
+ }
476
+ const result = await sendOrPreview(http, argv, deps, "PATCH", "/api/projects", body, {
477
+ query: projectQuery(project.id),
478
+ });
479
+ for (const peer of peers) {
480
+ await sendOrPreview(http, argv, deps, "PATCH", "/api/projects", { mergeOptOut }, {
481
+ query: projectQuery(peer.id),
482
+ });
483
+ }
484
+ if (result.dryRun) return EXIT.OK;
485
+ if (argv.json) {
486
+ printJson(
487
+ deps.stdout,
488
+ peers.length > 0 ? { ...result.data, ids: [project.id, ...peers.map((peer) => peer.id)] } : result.data,
489
+ );
490
+ return EXIT.OK;
491
+ }
492
+ printPretty(deps.stdout, `Updated project ${displayName(result.data ?? project)}`);
493
+ if (peers.length > 0) {
494
+ printPretty(deps.stdout, `${mergeOptOut ? "Also split" : "Also re-merged"} ${peers.length} same-name project(s):`);
495
+ for (const peer of peers) printPretty(deps.stdout, ` ${displayName(peer)} on ${peer.daemonHost}`);
496
+ }
497
+ return EXIT.OK;
498
+ }
499
+
500
+ async function handleRefresh(argv, deps) {
501
+ const project = await resolveTarget(argv, deps);
502
+ const http = await buildHttp(deps);
503
+ const result = await sendOrPreview(http, argv, deps, "PATCH", "/api/projects", { refresh: true }, {
504
+ query: projectQuery(project.id),
505
+ });
506
+ if (result.dryRun) return EXIT.OK;
507
+ if (argv.json) {
508
+ printJson(deps.stdout, result.data);
509
+ return EXIT.OK;
510
+ }
511
+ const updated = result.data ?? project;
512
+ const branch = updated.worktreeBranch ? ` branch=${updated.worktreeBranch}` : "";
513
+ const commit = updated.lastCommit ? ` commit=${String(updated.lastCommit).slice(0, 12)}` : "";
514
+ printPretty(deps.stdout, `Refreshed project ${displayName(updated)}${branch}${commit}`);
515
+ return EXIT.OK;
516
+ }
517
+
518
+ async function handleDelete(argv, deps) {
519
+ if (!argv.yes && !argv.dryRun) {
520
+ throw argsError(
521
+ "Refusing to delete without --yes: this stops the project's running tasks and deletes its tasks and messages, " +
522
+ "including tasks filed under other projects; a cross-daemon merged project is deleted on every daemon " +
523
+ "unless --daemon-host picks one",
524
+ );
525
+ }
526
+ const { project, members: group } = await resolveTargetGroup(argv, deps);
527
+ // --daemon-host targets that one copy; otherwise the whole group, like the web.
528
+ const members = argv.daemonHost ? [project] : group;
529
+ const http = await buildHttp(deps);
530
+ // Sequential like the web: each daemon may clean up worktrees first. There is
531
+ // no rollback, so stop at the first failure and say what is left.
532
+ for (const [index, member] of members.entries()) {
533
+ try {
534
+ await sendOrPreview(http, argv, deps, "DELETE", "/api/projects", undefined, {
535
+ query: projectQuery(member.id),
536
+ });
537
+ } catch (err) {
538
+ if (members.length > 1) {
539
+ const ids = (list) => list.map((entry) => entry.id).join(", ") || "none";
540
+ err.message = `Deleted: ${ids(members.slice(0, index))}; not deleted: ${ids(members.slice(index))}. ` +
541
+ `Failed on ${member.id} (${err.message})`;
542
+ }
543
+ throw err;
544
+ }
545
+ }
546
+ if (argv.dryRun) return EXIT.OK;
547
+ if (argv.json) {
548
+ printJson(
549
+ deps.stdout,
550
+ members.length > 1
551
+ ? { deleted: true, id: project.id, ids: members.map((member) => member.id) }
552
+ : { deleted: true, id: project.id },
553
+ );
554
+ return EXIT.OK;
555
+ }
556
+ printPretty(deps.stdout, `Deleted ${describeGroup(project, members)}`);
557
+ return EXIT.OK;
558
+ }
559
+
560
+ async function handleReorder(argv, deps) {
561
+ const selectors = (argv.idOrNames || []).map(String).filter(Boolean);
562
+ if (selectors.length === 0) throw argsError("Pass at least one project id or name");
563
+ const apis = await buildApis(deps);
564
+ const ordered = [];
565
+ for (const selector of selectors) {
566
+ const project = await resolveProjectSelector(apis, selector, {
567
+ env: deps.env,
568
+ cwd: deps.cwd,
569
+ daemonHost: argv.daemonHost,
570
+ });
571
+ if (ordered.includes(project.id)) throw argsError(`Project listed twice: ${selector}`);
572
+ ordered.push(project.id);
573
+ }
574
+ // The server requires every project exactly once; keep the unlisted ones in
575
+ // their current display order after the listed ones.
576
+ const all = await apis.projects.listProjects({ includeHidden: true });
577
+ const projectIds = [...ordered, ...all.map((entry) => entry.id).filter((id) => !ordered.includes(id))];
578
+ const http = await buildHttp(deps);
579
+ const result = await sendOrPreview(http, argv, deps, "POST", "/api/projects/reorder", { projectIds });
580
+ if (result.dryRun) return EXIT.OK;
581
+ if (argv.json) {
582
+ printJson(deps.stdout, result.data);
583
+ return EXIT.OK;
584
+ }
585
+ printPretty(deps.stdout, `Reordered ${projectIds.length} projects`);
586
+ return EXIT.OK;
587
+ }
588
+
589
+ async function handleAgents(argv, deps) {
590
+ const project = await resolveTarget(argv, deps);
591
+ const http = await buildHttp(deps);
592
+ const data = await http.get(apiPath("projects", project.id, "agents"));
593
+ if (argv.json) {
594
+ printJson(deps.stdout, data);
595
+ return EXIT.OK;
596
+ }
597
+ const agents = Array.isArray(data?.agents) ? data.agents : [];
598
+ if (agents.length === 0) {
599
+ printPretty(deps.stdout, "(no agents registered)");
600
+ return EXIT.OK;
601
+ }
602
+ printPretty(deps.stdout, `${pad("NAME", 20)} ${pad("BACKEND", 12)} DESCRIPTION`);
603
+ for (const agent of agents) {
604
+ printPretty(deps.stdout, `${pad(agent.name, 20)} ${pad(agent.backend ?? "", 12)} ${agent.description ?? ""}`);
605
+ }
606
+ return EXIT.OK;
607
+ }
608
+
609
+ async function handleCollabInvite(argv, deps) {
610
+ const project = await resolveTarget(argv, deps);
611
+ const http = await buildHttp(deps);
612
+ const result = await sendOrPreview(http, argv, deps, "POST", apiPath("projects", project.id, "collaboration"), undefined);
613
+ if (result.dryRun) return EXIT.OK;
614
+ if (argv.json) {
615
+ printJson(deps.stdout, result.data);
616
+ return EXIT.OK;
617
+ }
618
+ const data = result.data || {};
619
+ const inviteUrl = data.inviteUrl ?? data.invite_url ?? data.collaboration?.inviteUrl;
620
+ const inviteToken = data.inviteToken ?? data.invite_token;
621
+ const collaborationId = data.collaboration?.id;
319
622
  printPretty(
320
623
  deps.stdout,
321
- hidden
322
- ? `Hid project ${project.name ?? project.id}`
323
- : `Unhid project ${project.name ?? project.id}`,
624
+ `Collaboration ${collaborationId ? `${collaborationId} ` : ""}for project ${displayName(project)}`,
324
625
  );
626
+ if (inviteUrl) printPretty(deps.stdout, `Invite URL: ${inviteUrl}`);
627
+ if (inviteToken) printPretty(deps.stdout, `Invite token: ${inviteToken}`);
628
+ return EXIT.OK;
629
+ }
630
+
631
+ /** Accept a bare invite token or an invite URL ending in /app/invite/<token>. */
632
+ /** Preview an invite (what the web /app/invite/<token> page shows) before joining. */
633
+ async function handleCollabShowInvite(argv, deps) {
634
+ const inviteToken = parseInviteToken(argv.token);
635
+ const http = await buildHttp(deps);
636
+ const data = await http.get(apiPath("invitations", inviteToken));
637
+ if (argv.json) {
638
+ printJson(deps.stdout, data);
639
+ return EXIT.OK;
640
+ }
641
+ const collaboration = data?.collaboration ?? {};
642
+ const members = Array.isArray(collaboration.members) ? collaboration.members : [];
643
+ printPretty(deps.stdout, `Collaboration ${collaboration.id ?? ""} (${members.length} member${members.length === 1 ? "" : "s"})`);
644
+ if (data?.alreadyJoined) printPretty(deps.stdout, "You have already joined this collaboration.");
645
+ if (data?.isFull) printPretty(deps.stdout, "This collaboration is full.");
646
+ if (data?.suggestedProjectName) {
647
+ const note = data.suggestedProjectNameHidden ? " (a hidden project already has this name; unhide it to join with it)" : "";
648
+ printPretty(deps.stdout, `Suggested project name: ${data.suggestedProjectName}${note}`);
649
+ }
650
+ const candidates = (Array.isArray(data?.candidateProjects) ? data.candidateProjects : []).filter((p) => p.canJoin);
651
+ if (candidates.length) {
652
+ printPretty(deps.stdout, "Projects you can join with (--into):");
653
+ for (const project of candidates) {
654
+ printPretty(deps.stdout, ` ${project.id} ${project.name}${project.daemonHost ? `@${project.daemonHost}` : ""}`);
655
+ }
656
+ }
657
+ printPretty(deps.stdout, `Join with: conductor project collab join ${inviteToken} (--into <project> | --create-project <name>)`);
658
+ return EXIT.OK;
659
+ }
660
+
661
+ export function parseInviteToken(input) {
662
+ const text = String(input ?? "").trim();
663
+ if (!text) return "";
664
+ if (/^https?:\/\//i.test(text)) {
665
+ try {
666
+ const url = new URL(text);
667
+ const match = url.pathname.match(/\/invite\/([^/]+)\/?$/);
668
+ if (match) return decodeURIComponent(match[1]);
669
+ const fromQuery = url.searchParams.get("token") || url.searchParams.get("inviteToken");
670
+ if (fromQuery) return fromQuery;
671
+ } catch {
672
+ // fall through to treating it as a token
673
+ }
674
+ throw argsError(`Could not find an invite token in URL: ${text}`);
675
+ }
676
+ return text;
677
+ }
678
+
679
+ async function handleCollabJoin(argv, deps) {
680
+ const inviteToken = parseInviteToken(argv.token);
681
+ if (!inviteToken) throw argsError("Invite token is required");
682
+ const hasInto = argv.into !== undefined && String(argv.into).trim() !== "";
683
+ const hasCreate = argv.createProject !== undefined && String(argv.createProject).trim() !== "";
684
+ if (hasInto === hasCreate) {
685
+ throw argsError("Pass exactly one of --into <id|name> (join with an existing project) or --create-project <name>");
686
+ }
687
+ const body = { inviteToken };
688
+ if (hasInto) {
689
+ const project = await resolveTarget(argv, deps, String(argv.into));
690
+ body.projectId = project.id;
691
+ } else {
692
+ body.createProjectName = String(argv.createProject).trim();
693
+ }
694
+ const http = await buildHttp(deps);
695
+ const result = await sendOrPreview(http, argv, deps, "POST", "/api/collaboration/join", body);
696
+ if (result.dryRun) return EXIT.OK;
697
+ if (argv.json) {
698
+ printJson(deps.stdout, result.data);
699
+ return EXIT.OK;
700
+ }
701
+ const data = result.data || {};
702
+ printPretty(
703
+ deps.stdout,
704
+ `Joined collaboration ${data.collaborationId ?? data.collaboration_id ?? data.collaboration?.id ?? ""} with project ${data.projectId ?? data.project_id ?? body.projectId ?? ""}`,
705
+ );
706
+ return EXIT.OK;
707
+ }
708
+
709
+ async function handleCollabLeave(argv, deps) {
710
+ const project = await resolveTarget(argv, deps);
711
+ const http = await buildHttp(deps);
712
+ const raw = await fetchRawProject(http, project.id);
713
+ const collaborationId = raw.collaborationId ?? raw.collaboration_id ?? raw.collaboration?.id ?? null;
714
+ if (!collaborationId) {
715
+ throw argsError(`Project ${displayName(project)} is not in a collaboration`);
716
+ }
717
+ const result = await sendOrPreview(
718
+ http, argv, deps, "DELETE", apiPath("collaboration", collaborationId, "members", "me"), undefined,
719
+ );
720
+ if (result.dryRun) return EXIT.OK;
721
+ if (argv.json) {
722
+ printJson(deps.stdout, { left: true, collaborationId, projectId: project.id });
723
+ return EXIT.OK;
724
+ }
725
+ printPretty(deps.stdout, `Left collaboration ${collaborationId} (project ${displayName(project)})`);
726
+ return EXIT.OK;
727
+ }
728
+
729
+ // ---- task labels (project.metadata.taskLabels, see web/src/lib/projects/task-labels.ts)
730
+
731
+ const TASK_LABELS_KEY = "taskLabels";
732
+ const MAX_TASK_LABEL_NAME_CHARS = 32;
733
+ const MAX_TASK_LABELS_PER_PROJECT = 50;
734
+
735
+ function normalizeLabelName(value) {
736
+ if (typeof value !== "string") return "";
737
+ return value.trim().replace(/\s+/g, " ").slice(0, MAX_TASK_LABEL_NAME_CHARS);
738
+ }
739
+
740
+ function labelKey(name) {
741
+ return normalizeLabelName(name).toLowerCase();
742
+ }
743
+
744
+ function metadataRecord(project) {
745
+ const metadata = project?.metadata;
746
+ return metadata && typeof metadata === "object" && !Array.isArray(metadata) ? metadata : {};
747
+ }
748
+
749
+ function readLabels(project) {
750
+ const raw = metadataRecord(project)[TASK_LABELS_KEY];
751
+ if (!Array.isArray(raw)) return [];
752
+ const labels = [];
753
+ const seen = new Set();
754
+ for (const entry of raw) {
755
+ if (!entry || typeof entry !== "object" || typeof entry.id !== "string" || !entry.id) continue;
756
+ const name = normalizeLabelName(entry.name);
757
+ if (!name || seen.has(entry.id)) continue;
758
+ seen.add(entry.id);
759
+ labels.push({ id: entry.id, name });
760
+ }
761
+ return labels;
762
+ }
763
+
764
+ function canonicalRemote(value) {
765
+ const normalized = String(value ?? "").trim().toLowerCase();
766
+ if (!normalized) return "";
767
+ const slash = normalized.indexOf("/");
768
+ if (slash < 0) return normalized;
769
+ const host = normalized.slice(0, slash);
770
+ const canonicalHost = host === "github.com" || host.startsWith("github-") || host.startsWith("github.com-")
771
+ ? "github.com"
772
+ : host;
773
+ return `${canonicalHost}${normalized.slice(slash)}`;
774
+ }
775
+
776
+ /** Mirror of `canMergeProjectsByFields` in web/src/lib/projects/grouping.ts. */
777
+ function canMerge(a, b) {
778
+ if (a.name !== b.name) return false;
779
+ // SDK `Project`s keep these fields under `raw`; raw API rows have them inline.
780
+ if (groupField(a, "mergeOptOut") === true || groupField(b, "mergeOptOut") === true) return false;
781
+ const aHost = String(a.daemonHost ?? "").trim();
782
+ const bHost = String(b.daemonHost ?? "").trim();
783
+ if (!aHost || !bHost || aHost === bHost) return false;
784
+ const aUrl = canonicalRemote(groupField(a, "gitRemoteUrl"));
785
+ const bUrl = canonicalRemote(groupField(b, "gitRemoteUrl"));
786
+ return !(aUrl && bUrl && aUrl !== bUrl);
787
+ }
788
+
789
+ function groupField(project, key) {
790
+ return project[key] ?? project.raw?.[key];
791
+ }
792
+
793
+ function isHiddenProject(project) {
794
+ return typeof project.hidden === "boolean" ? project.hidden : Boolean(project.hiddenAt ?? project.hidden_at);
795
+ }
796
+
797
+ /**
798
+ * The merged group the web project list shows `target` in: mirror of
799
+ * `computeProjectGroups` (web/src/features/projects/utils/project-groups.ts)
800
+ * over the visible projects, or over all of them when `target` is hidden
801
+ * (only listed once hidden projects are revealed).
802
+ */
803
+ function displayedGroup(all, target) {
804
+ const hidden = isHiddenProject(target);
805
+ const list = all.filter((entry) => hidden || !isHiddenProject(entry) || entry.id === target.id);
806
+ const grouped = new Set();
807
+ for (let i = 0; i < list.length; i += 1) {
808
+ const anchor = list[i];
809
+ if (grouped.has(anchor.id)) continue;
810
+ const members = [anchor];
811
+ for (const candidate of list.slice(i + 1)) {
812
+ if (!grouped.has(candidate.id) && canMerge(anchor, candidate)) members.push(candidate);
813
+ }
814
+ for (const member of members) grouped.add(member.id);
815
+ if (members.some((member) => member.id === target.id)) return members;
816
+ }
817
+ return [target];
818
+ }
819
+
820
+ /** Mirror of `expandMergedProjectGroup`: every project merging with any seed, hidden included. */
821
+ function expandGroup(seeds, all) {
822
+ const expanded = all.filter((candidate) =>
823
+ seeds.some((seed) => seed.id === candidate.id || canMerge(seed, candidate)),
824
+ );
825
+ const ids = new Set(expanded.map((entry) => entry.id));
826
+ return [...expanded, ...seeds.filter((seed) => !ids.has(seed.id))];
827
+ }
828
+
829
+ async function resolveTargetGroup(argv, deps) {
830
+ const project = await resolveTarget(argv, deps);
831
+ const apis = await buildApis(deps);
832
+ const all = await apis.projects.listProjects({ includeHidden: true });
833
+ return { project, all, members: displayedGroup(all, project) };
834
+ }
835
+
836
+ function describeGroup(project, members) {
837
+ return members.length > 1
838
+ ? `merged project ${project.name ?? project.id} on ${members.length} daemons`
839
+ : `project ${displayName(project)}`;
840
+ }
841
+
842
+ /**
843
+ * Labels are shared across a cross-daemon merged group: reads union every
844
+ * member and writes fan out to every member (including hidden ones), same as
845
+ * the web settings page.
846
+ */
847
+ async function loadLabelGroup(argv, deps) {
848
+ const project = await resolveTarget(argv, deps, argv.project);
849
+ const http = await buildHttp(deps);
850
+ const all = await listRawProjects(http);
851
+ const seed = all.find((entry) => entry.id === project.id);
852
+ if (!seed) {
853
+ const err = new Error(`Project not found: ${project.id}`);
854
+ err.statusCode = 404;
855
+ throw err;
856
+ }
857
+ const expanded = expandGroup(displayedGroup(all, seed), all);
858
+ const members = [seed, ...expanded.filter((entry) => entry.id !== seed.id)];
859
+ const labels = [];
860
+ const seen = new Set();
861
+ for (const member of members) {
862
+ for (const label of readLabels(member)) {
863
+ if (seen.has(label.id)) continue;
864
+ seen.add(label.id);
865
+ labels.push(label);
866
+ }
867
+ }
868
+ return { http, seed, members, labels };
869
+ }
870
+
871
+ async function writeLabels(argv, deps, group, next) {
872
+ const bodies = group.members.map((member) => ({
873
+ member,
874
+ body: {
875
+ metadata: {
876
+ ...metadataRecord(member),
877
+ [TASK_LABELS_KEY]: next.map((label) => ({ id: label.id, name: label.name })),
878
+ },
879
+ },
880
+ }));
881
+ if (argv.dryRun) {
882
+ for (const { member, body } of bodies) {
883
+ await sendOrPreview(group.http, argv, deps, "PATCH", "/api/projects", body, { query: projectQuery(member.id) });
884
+ }
885
+ return false;
886
+ }
887
+ const failures = [];
888
+ for (const { member, body } of bodies) {
889
+ try {
890
+ await group.http.patch("/api/projects", body, { query: projectQuery(member.id) });
891
+ } catch (error) {
892
+ failures.push(error);
893
+ }
894
+ }
895
+ if (failures.length > 0) {
896
+ if (failures.length === bodies.length) throw failures[0];
897
+ const err = new Error(
898
+ `Saved on ${bodies.length - failures.length} of ${bodies.length} merged projects: ${failures[0].message}`,
899
+ );
900
+ err.statusCode = failures[0].statusCode;
901
+ err.details = failures[0].details;
902
+ throw err;
903
+ }
904
+ return true;
905
+ }
906
+
907
+ function findLabel(labels, selector) {
908
+ const text = String(selector ?? "");
909
+ const byId = labels.find((label) => label.id === text);
910
+ if (byId) return byId;
911
+ const byName = labels.find((label) => labelKey(label.name) === labelKey(text));
912
+ if (byName) return byName;
913
+ const err = new Error(`No label matching '${text}'`);
914
+ err.statusCode = 404;
915
+ throw err;
916
+ }
917
+
918
+ function printLabels(argv, deps, labels) {
919
+ if (argv.json) {
920
+ printJson(deps.stdout, labels);
921
+ return;
922
+ }
923
+ if (labels.length === 0) {
924
+ printPretty(deps.stdout, "(no labels)");
925
+ return;
926
+ }
927
+ printPretty(deps.stdout, `${pad("ID", 38)} NAME`);
928
+ for (const label of labels) printPretty(deps.stdout, `${pad(label.id, 38)} ${label.name}`);
929
+ }
930
+
931
+ async function handleLabelsList(argv, deps) {
932
+ const group = await loadLabelGroup({ ...argv, project: argv.idOrName ?? argv.project }, deps);
933
+ printLabels(argv, deps, group.labels);
934
+ return EXIT.OK;
935
+ }
936
+
937
+ async function handleLabelsAdd(argv, deps) {
938
+ const name = normalizeLabelName(String(argv.name ?? ""));
939
+ if (!name) throw argsError("Label name cannot be empty");
940
+ const group = await loadLabelGroup(argv, deps);
941
+ if (group.labels.length >= MAX_TASK_LABELS_PER_PROJECT) {
942
+ throw argsError(`A project can have at most ${MAX_TASK_LABELS_PER_PROJECT} labels`);
943
+ }
944
+ if (group.labels.some((label) => labelKey(label.name) === labelKey(name))) {
945
+ throw argsError(`Label "${name}" already exists for this project`);
946
+ }
947
+ const label = { id: randomUUID(), name };
948
+ const next = [...group.labels, label];
949
+ if (!(await writeLabels(argv, deps, group, next))) return EXIT.OK;
950
+ if (argv.json) {
951
+ printJson(deps.stdout, label);
952
+ return EXIT.OK;
953
+ }
954
+ printPretty(deps.stdout, `Added label ${name} (${label.id})`);
955
+ return EXIT.OK;
956
+ }
957
+
958
+ async function handleLabelsRename(argv, deps) {
959
+ const name = normalizeLabelName(String(argv.newName ?? ""));
960
+ if (!name) throw argsError("Label name cannot be empty");
961
+ const group = await loadLabelGroup(argv, deps);
962
+ const current = findLabel(group.labels, argv.label);
963
+ if (group.labels.some((label) => label.id !== current.id && labelKey(label.name) === labelKey(name))) {
964
+ throw argsError(`Label "${name}" already exists for this project`);
965
+ }
966
+ const next = group.labels.map((label) => (label.id === current.id ? { ...label, name } : label));
967
+ if (!(await writeLabels(argv, deps, group, next))) return EXIT.OK;
968
+ if (argv.json) {
969
+ printJson(deps.stdout, { id: current.id, name });
970
+ return EXIT.OK;
971
+ }
972
+ printPretty(deps.stdout, `Renamed label ${current.name} -> ${name}`);
973
+ return EXIT.OK;
974
+ }
975
+
976
+ async function handleLabelsRemove(argv, deps) {
977
+ const group = await loadLabelGroup(argv, deps);
978
+ const current = findLabel(group.labels, argv.label);
979
+ const next = group.labels.filter((label) => label.id !== current.id);
980
+ if (!(await writeLabels(argv, deps, group, next))) return EXIT.OK;
981
+ if (argv.json) {
982
+ printJson(deps.stdout, { removed: true, ...current });
983
+ return EXIT.OK;
984
+ }
985
+ printPretty(deps.stdout, `Removed label ${current.name} (${current.id})`);
325
986
  return EXIT.OK;
326
987
  }
327
988
 
@@ -375,7 +1036,7 @@ export async function main(argvInput = hideBin(process.argv), deps = {}) {
375
1036
  (cmd) => cmd
376
1037
  .option("name", { type: "string", describe: "Project name (defaults to basename of workspace path)" })
377
1038
  .option("workspace-path", { type: "string", describe: "Workspace path (defaults to cwd)" })
378
- .option("daemon-host", { type: "string", describe: "Daemon hostname for binding" })
1039
+ .option("daemon-host", { type: "string", describe: "Daemon hostname for binding (default: this machine's daemon name)" })
379
1040
  .option("create-workspace", { type: "boolean", default: false, describe: "Create the workspace path on the daemon if it does not exist" })
380
1041
  .option("default", { type: "boolean", default: false, describe: "Create the user's default project" })
381
1042
  .option("client-request-id", { type: "string", describe: "Idempotency key" }),
@@ -395,7 +1056,7 @@ export async function main(argvInput = hideBin(process.argv), deps = {}) {
395
1056
  )
396
1057
  .command(
397
1058
  "hide <idOrName>",
398
- "Hide a project from default listings",
1059
+ "Hide a project from default listings (on every daemon of a merged project)",
399
1060
  (cmd) => cmd
400
1061
  .positional("idOrName", { type: "string", demandOption: true })
401
1062
  .option("daemon-host", { type: "string", describe: "Disambiguate same-name projects across daemons" }),
@@ -405,7 +1066,7 @@ export async function main(argvInput = hideBin(process.argv), deps = {}) {
405
1066
  )
406
1067
  .command(
407
1068
  "unhide <idOrName>",
408
- "Unhide a previously hidden project",
1069
+ "Unhide a previously hidden project (on every daemon of a merged project)",
409
1070
  (cmd) => cmd
410
1071
  .positional("idOrName", { type: "string", demandOption: true })
411
1072
  .option("daemon-host", { type: "string", describe: "Disambiguate same-name projects across daemons" }),
@@ -413,6 +1074,158 @@ export async function main(argvInput = hideBin(process.argv), deps = {}) {
413
1074
  exitCode = await handleSetHidden(argv, { ...handlerDeps, configFile: argv.configFile }, false);
414
1075
  },
415
1076
  )
1077
+ .command(
1078
+ "update <idOrName>",
1079
+ "Update a project's name, merge opt-out or (unbound projects only) workspace binding",
1080
+ (cmd) => cmd
1081
+ .positional("idOrName", { type: "string", demandOption: true })
1082
+ .option("daemon-host", { type: "string", describe: "Disambiguate same-name projects across daemons" })
1083
+ .option("name", { type: "string", describe: "New project name" })
1084
+ .option("merge-opt-out", {
1085
+ type: "string",
1086
+ describe: "true splits its cross-daemon merged group (every member), false merges it with same-name projects on other daemons",
1087
+ })
1088
+ .option("workspace-path", { type: "string", describe: "Bind an unbound project to this workspace path" })
1089
+ .option("bind-daemon-host", { type: "string", describe: "Daemon for --workspace-path (defaults to the project's daemon)" })
1090
+ .option("json-body", { type: "string", describe: "Extra raw PATCH fields as a JSON object (merged last)" }),
1091
+ async (argv) => {
1092
+ exitCode = await handleUpdate(argv, { ...handlerDeps, configFile: argv.configFile });
1093
+ },
1094
+ )
1095
+ .command(
1096
+ "refresh <idOrName>",
1097
+ "Re-validate the project's binding with its daemon and refresh git snapshot fields",
1098
+ (cmd) => cmd
1099
+ .positional("idOrName", { type: "string", demandOption: true })
1100
+ .option("daemon-host", { type: "string", describe: "Disambiguate same-name projects across daemons" }),
1101
+ async (argv) => {
1102
+ exitCode = await handleRefresh(argv, { ...handlerDeps, configFile: argv.configFile });
1103
+ },
1104
+ )
1105
+ .command(
1106
+ "delete <idOrName>",
1107
+ "Delete a project, on every daemon if merged (stops its running tasks and deletes its tasks); requires --yes",
1108
+ (cmd) => cmd
1109
+ .positional("idOrName", { type: "string", demandOption: true })
1110
+ .option("daemon-host", { type: "string", describe: "Delete only the copy on this daemon, not the whole merged group" })
1111
+ .option("yes", { type: "boolean", default: false, describe: "Confirm the deletion" }),
1112
+ async (argv) => {
1113
+ exitCode = await handleDelete(argv, { ...handlerDeps, configFile: argv.configFile });
1114
+ },
1115
+ )
1116
+ .command(
1117
+ "reorder <idOrNames..>",
1118
+ "Reorder projects: the listed projects go first, the rest keep their current order",
1119
+ (cmd) => cmd
1120
+ .positional("idOrNames", { type: "string", array: true })
1121
+ .option("daemon-host", { type: "string", describe: "Disambiguate same-name projects across daemons" }),
1122
+ async (argv) => {
1123
+ exitCode = await handleReorder(argv, { ...handlerDeps, configFile: argv.configFile });
1124
+ },
1125
+ )
1126
+ .command(
1127
+ "agents [idOrName]",
1128
+ "List the agents a project registers (usable when creating multi-agent tasks)",
1129
+ (cmd) => cmd
1130
+ .positional("idOrName", { type: "string" })
1131
+ .option("daemon-host", { type: "string", describe: "Disambiguate same-name projects across daemons" }),
1132
+ async (argv) => {
1133
+ exitCode = await handleAgents(argv, { ...handlerDeps, configFile: argv.configFile });
1134
+ },
1135
+ )
1136
+ .command(
1137
+ "collab",
1138
+ "Project collaboration: invite, join, leave",
1139
+ (cmd) => cmd
1140
+ .command(
1141
+ "invite [idOrName]",
1142
+ "Start (or reuse) a collaboration for a project and print its invite link",
1143
+ (sub) => sub
1144
+ .positional("idOrName", { type: "string" })
1145
+ .option("daemon-host", { type: "string", describe: "Disambiguate same-name projects across daemons" }),
1146
+ async (argv) => {
1147
+ exitCode = await handleCollabInvite(argv, { ...handlerDeps, configFile: argv.configFile });
1148
+ },
1149
+ )
1150
+ .command(
1151
+ "join <token>",
1152
+ "Join a collaboration from an invite token or invite URL",
1153
+ (sub) => sub
1154
+ .positional("token", { type: "string", describe: "Invite token or invite URL" })
1155
+ .option("into", { type: "string", describe: "Existing project (id or name) to join with" })
1156
+ .option("create-project", { type: "string", describe: "Create a new project with this name to join with" })
1157
+ .option("daemon-host", { type: "string", describe: "Disambiguate --into across daemons" }),
1158
+ async (argv) => {
1159
+ exitCode = await handleCollabJoin(argv, { ...handlerDeps, configFile: argv.configFile });
1160
+ },
1161
+ )
1162
+ .command(
1163
+ "show-invite <token>",
1164
+ "Preview a collaboration invite before joining",
1165
+ (sub) => sub.positional("token", { type: "string", describe: "Invite token or invite URL" }),
1166
+ async (argv) => {
1167
+ exitCode = await handleCollabShowInvite(argv, { ...handlerDeps, configFile: argv.configFile });
1168
+ },
1169
+ )
1170
+ .command(
1171
+ "leave <idOrName>",
1172
+ "Leave the collaboration a project belongs to",
1173
+ (sub) => sub
1174
+ .positional("idOrName", { type: "string", demandOption: true })
1175
+ .option("daemon-host", { type: "string", describe: "Disambiguate same-name projects across daemons" }),
1176
+ async (argv) => {
1177
+ exitCode = await handleCollabLeave(argv, { ...handlerDeps, configFile: argv.configFile });
1178
+ },
1179
+ )
1180
+ .demandCommand(1),
1181
+ )
1182
+ .command(
1183
+ "labels",
1184
+ "Task label definitions (shared across a cross-daemon merged project group)",
1185
+ (cmd) => cmd
1186
+ .command(
1187
+ "list [idOrName]",
1188
+ "List task labels",
1189
+ (sub) => sub
1190
+ .positional("idOrName", { type: "string" })
1191
+ .option("daemon-host", { type: "string", describe: "Disambiguate same-name projects across daemons" }),
1192
+ async (argv) => {
1193
+ exitCode = await handleLabelsList(argv, { ...handlerDeps, configFile: argv.configFile });
1194
+ },
1195
+ )
1196
+ .command(
1197
+ "add <name>",
1198
+ "Add a task label (target project via --project)",
1199
+ (sub) => sub
1200
+ .positional("name", { type: "string" })
1201
+ .option("daemon-host", { type: "string", describe: "Disambiguate --project across daemons" }),
1202
+ async (argv) => {
1203
+ exitCode = await handleLabelsAdd(argv, { ...handlerDeps, configFile: argv.configFile });
1204
+ },
1205
+ )
1206
+ .command(
1207
+ "rename <label> <newName>",
1208
+ "Rename a task label (label id or name)",
1209
+ (sub) => sub
1210
+ .positional("label", { type: "string" })
1211
+ .positional("newName", { type: "string" })
1212
+ .option("daemon-host", { type: "string", describe: "Disambiguate --project across daemons" }),
1213
+ async (argv) => {
1214
+ exitCode = await handleLabelsRename(argv, { ...handlerDeps, configFile: argv.configFile });
1215
+ },
1216
+ )
1217
+ .command(
1218
+ "remove <label>",
1219
+ "Remove a task label (label id or name)",
1220
+ (sub) => sub
1221
+ .positional("label", { type: "string" })
1222
+ .option("daemon-host", { type: "string", describe: "Disambiguate --project across daemons" }),
1223
+ async (argv) => {
1224
+ exitCode = await handleLabelsRemove(argv, { ...handlerDeps, configFile: argv.configFile });
1225
+ },
1226
+ )
1227
+ .demandCommand(1),
1228
+ )
416
1229
  .demandCommand(1)
417
1230
  .fail((msg, err) => {
418
1231
  if (err) {