@neta-art/cohub-cli 1.11.1 → 1.12.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.
@@ -57,23 +57,22 @@ async function saveOutputs(output, outputPath) {
57
57
  if (outputs.length === 0)
58
58
  return [];
59
59
  const info = await stat(outputPath).catch(() => null);
60
- const isDir = info?.isDirectory() ?? (!extname(outputPath) && outputs.length > 1);
61
- if (outputs.length > 1 && !isDir)
62
- throw new Error("--output must be a directory when generation returns multiple outputs");
63
- if (isDir)
64
- await mkdir(outputPath, { recursive: true });
60
+ const isSingleFile = outputs.length === 1 && !(info?.isDirectory() ?? false);
61
+ const targetPath = isSingleFile ? outputPath : await resolveOutputDirectory(outputPath, info);
62
+ if (isSingleFile)
63
+ await mkdir(dirname(targetPath), { recursive: true });
65
64
  else
66
- await mkdir(dirname(outputPath), { recursive: true });
65
+ await mkdir(targetPath, { recursive: true });
67
66
  const savedPaths = [];
68
67
  for (const [i, block] of outputs.entries()) {
69
68
  if (block.type === "text") {
70
- const target = isDir ? join(outputPath, `generation-${i + 1}.txt`) : outputPath;
69
+ const target = isSingleFile ? targetPath : join(targetPath, `generation-${i + 1}.txt`);
71
70
  await writeFile(target, block.text, "utf-8");
72
71
  savedPaths.push(target);
73
72
  continue;
74
73
  }
75
74
  const source = block.source;
76
- const target = isDir ? join(outputPath, outputName(block, source.type === "url" ? source.url : undefined, i)) : outputPath;
75
+ const target = isSingleFile ? targetPath : join(targetPath, outputName(block, source.type === "url" ? source.url : undefined, i));
77
76
  if (source.type === "url") {
78
77
  const response = await fetch(source.url);
79
78
  if (!response.ok)
@@ -88,6 +87,20 @@ async function saveOutputs(output, outputPath) {
88
87
  }
89
88
  return savedPaths;
90
89
  }
90
+ async function resolveOutputDirectory(outputPath, info) {
91
+ if (info?.isDirectory() || (!info && !extname(outputPath)))
92
+ return outputPath;
93
+ const ext = extname(outputPath);
94
+ const stem = ext ? basename(outputPath, ext) : basename(outputPath);
95
+ const parent = dirname(outputPath);
96
+ const base = join(parent, `${stem}-outputs`);
97
+ for (let i = 0;; i += 1) {
98
+ const candidate = i === 0 ? base : `${base}-${i + 1}`;
99
+ const candidateInfo = await stat(candidate).catch(() => null);
100
+ if (!candidateInfo || candidateInfo.isDirectory())
101
+ return candidate;
102
+ }
103
+ }
91
104
  function outputName(block, url, index) {
92
105
  const fromUrl = url ? basename(new URL(url).pathname) : "";
93
106
  const label = slugOutputLabel(block);
@@ -10,6 +10,7 @@ import { resolveSpace } from "../space.js";
10
10
  const cliEnv = resolveCohubEnvironment();
11
11
  const defaultIdleTtlSeconds = cliEnv === "prod" ? 12 * 60 * 60 : 10 * 60;
12
12
  const SPACE_ROLES = ["host", "builder", "guest"];
13
+ const LABEL_RESOURCE_TYPES = ["session", "checkpoint", "file"];
13
14
  function parseInteger(value, name, options = {}) {
14
15
  if (!/^-?\d+$/.test(value.trim()))
15
16
  return error(`Invalid ${name}`, `${name} must be an integer`);
@@ -22,6 +23,9 @@ function parseInteger(value, name, options = {}) {
22
23
  return error(`Invalid ${name}`, `${name} must be at most ${options.max}`);
23
24
  return parsed;
24
25
  }
26
+ function collectOption(value, previous = []) {
27
+ return [...previous, value];
28
+ }
25
29
  function parseChoice(value, name, choices) {
26
30
  if (choices.includes(value))
27
31
  return value;
@@ -189,6 +193,7 @@ async function sendPrompt(command, words, opts) {
189
193
  provider: opts.provider,
190
194
  accessMode: opts.readOnly ? "read_only" : "full_access",
191
195
  schedule,
196
+ labelRefs: opts.label?.length ? opts.label : undefined,
192
197
  });
193
198
  if (jsonRequested(opts))
194
199
  return outJson(result);
@@ -215,6 +220,7 @@ export function registerPrompt(program) {
215
220
  .option("--at <iso>", "Send once at an ISO 8601 time with timezone")
216
221
  .option("--cron <expression>", "Repeat using a 5-field cron expression")
217
222
  .option("--timezone <tz>", "IANA timezone for --cron, e.g. Asia/Shanghai")
223
+ .option("--label <ref>", "Attach a label, e.g. Bug or Area/Frontend", collectOption, [])
218
224
  .option("--json", "Output as JSON")
219
225
  .action((words, opts) => sendPrompt(program, words, opts));
220
226
  }
@@ -373,6 +379,7 @@ export function registerSpaces(program) {
373
379
  .option("--at <iso>", "Send once at an ISO 8601 time with timezone")
374
380
  .option("--cron <expression>", "Repeat using a 5-field cron expression")
375
381
  .option("--timezone <tz>", "IANA timezone for --cron, e.g. Asia/Shanghai")
382
+ .option("--label <ref>", "Attach a label, e.g. Bug or Area/Frontend", collectOption, [])
376
383
  .option("--json", "Output as JSON")
377
384
  .action((words, opts) => sendPrompt(spacesCmd, words, opts));
378
385
  // ── spaces files ──
@@ -387,6 +394,8 @@ export function registerSpaces(program) {
387
394
  registerCheckpoints(spacesCmd);
388
395
  // ── spaces mods ──
389
396
  registerMods(spacesCmd);
397
+ // ── spaces labels ──
398
+ registerLabels(spacesCmd);
390
399
  // ── spaces usage ──
391
400
  spacesCmd
392
401
  .command("usage [days]")
@@ -413,6 +422,197 @@ export function registerSpaces(program) {
413
422
  }
414
423
  });
415
424
  }
425
+ function flattenLabels(items, prefix = "") {
426
+ return items.flatMap((label) => {
427
+ const path = prefix ? `${prefix}/${label.name}` : label.name;
428
+ return [{ ...label, path }, ...flattenLabels(label.children ?? [], path)];
429
+ });
430
+ }
431
+ function parseLabelResourceType(value) {
432
+ return parseChoice(value, "resource type", LABEL_RESOURCE_TYPES);
433
+ }
434
+ function parseLabelRefs(value) {
435
+ if (!value?.trim())
436
+ return [];
437
+ return value.split(",").map((item) => item.trim()).filter(Boolean);
438
+ }
439
+ function registerLabels(spacesCmd) {
440
+ const labelsCmd = spacesCmd
441
+ .command("labels")
442
+ .description("Manage labels")
443
+ .hook("preAction", () => { resolveSpace(spacesCmd); });
444
+ labelsCmd
445
+ .command("ls")
446
+ .alias("list")
447
+ .description("List labels")
448
+ .option("--json", "Output as JSON")
449
+ .action(async (opts) => {
450
+ const spaceId = resolveSpace(spacesCmd);
451
+ const client = createClient();
452
+ try {
453
+ const result = await client.space(spaceId).labels.list();
454
+ if (jsonRequested(opts))
455
+ return outJson(result);
456
+ table(flattenLabels(result.labels), [
457
+ { key: "path", label: "Label" },
458
+ { key: "rank", label: "Rank" },
459
+ ]);
460
+ }
461
+ catch (e) {
462
+ handleHttp(e);
463
+ }
464
+ });
465
+ labelsCmd
466
+ .command("create <labelRef>")
467
+ .description("Create a label")
468
+ .option("--json", "Output as JSON")
469
+ .action(async (labelRef, opts) => {
470
+ const spaceId = resolveSpace(spacesCmd);
471
+ const client = createClient();
472
+ try {
473
+ const result = await client.space(spaceId).labels.create(labelRef);
474
+ if (jsonRequested(opts))
475
+ return outJson(result);
476
+ ok("Label created");
477
+ }
478
+ catch (e) {
479
+ handleHttp(e);
480
+ }
481
+ });
482
+ labelsCmd
483
+ .command("update <labelRef>")
484
+ .description("Update a label")
485
+ .option("--name <name>", "Label name")
486
+ .option("--parent <ref>", "Parent label; use null for root")
487
+ .option("--rank <n>", "Sort rank")
488
+ .option("--json", "Output as JSON")
489
+ .action(async (labelRef, opts) => {
490
+ const spaceId = resolveSpace(spacesCmd);
491
+ const client = createClient();
492
+ try {
493
+ const result = await client.space(spaceId).labels.update(labelRef, {
494
+ name: opts.name,
495
+ parentRef: opts.parent === undefined ? undefined : opts.parent === "null" ? null : opts.parent,
496
+ rank: opts.rank === undefined ? undefined : parseInteger(opts.rank, "rank", { min: -1_000_000, max: 1_000_000 }),
497
+ });
498
+ if (jsonRequested(opts))
499
+ return outJson(result);
500
+ ok("Label updated");
501
+ }
502
+ catch (e) {
503
+ handleHttp(e);
504
+ }
505
+ });
506
+ labelsCmd
507
+ .command("rm <labelRef>")
508
+ .alias("delete")
509
+ .description("Delete a label")
510
+ .action(async (labelRef) => {
511
+ const spaceId = resolveSpace(spacesCmd);
512
+ const client = createClient();
513
+ try {
514
+ await client.space(spaceId).labels.delete(labelRef);
515
+ ok("Label deleted");
516
+ }
517
+ catch (e) {
518
+ handleHttp(e);
519
+ }
520
+ });
521
+ labelsCmd
522
+ .command("reorder <labelRefs...>")
523
+ .description("Reorder labels")
524
+ .option("--json", "Output as JSON")
525
+ .action(async (labelRefs, opts) => {
526
+ const spaceId = resolveSpace(spacesCmd);
527
+ const client = createClient();
528
+ try {
529
+ const result = await client.space(spaceId).labels.reorder(labelRefs);
530
+ if (jsonRequested(opts))
531
+ return outJson(result);
532
+ ok("Labels reordered");
533
+ }
534
+ catch (e) {
535
+ handleHttp(e);
536
+ }
537
+ });
538
+ labelsCmd
539
+ .command("items <labelRef>")
540
+ .description("List label items")
541
+ .option("--limit <n>", "Page size")
542
+ .option("--cursor <cursor>", "Page cursor")
543
+ .option("--json", "Output as JSON")
544
+ .action(async (labelRef, opts) => {
545
+ const spaceId = resolveSpace(spacesCmd);
546
+ const client = createClient();
547
+ try {
548
+ const result = await client.space(spaceId).labels.listItems(labelRef, {
549
+ limit: opts.limit ? parseInteger(opts.limit, "limit", { min: 1 }) : undefined,
550
+ cursor: opts.cursor,
551
+ });
552
+ if (jsonRequested(opts))
553
+ return outJson(result);
554
+ table(result.items, [
555
+ { key: "id", label: "ID" },
556
+ { key: "resourceType", label: "Type" },
557
+ { key: "resourceRef", label: "Resource" },
558
+ { key: "rank", label: "Rank" },
559
+ ]);
560
+ }
561
+ catch (e) {
562
+ handleHttp(e);
563
+ }
564
+ });
565
+ labelsCmd
566
+ .command("attach <labelRef> <resourceType> <resourceRef>")
567
+ .description("Attach a label")
568
+ .option("--json", "Output as JSON")
569
+ .action(async (labelRef, resourceType, resourceRef, opts) => {
570
+ const spaceId = resolveSpace(spacesCmd);
571
+ const client = createClient();
572
+ try {
573
+ const result = await client.space(spaceId).labels.attach(labelRef, { resourceType: parseLabelResourceType(resourceType), resourceRef });
574
+ if (jsonRequested(opts))
575
+ return outJson(result);
576
+ ok("Label attached");
577
+ }
578
+ catch (e) {
579
+ handleHttp(e);
580
+ }
581
+ });
582
+ labelsCmd
583
+ .command("detach <labelRef> <resourceType> <resourceRef>")
584
+ .description("Detach a label")
585
+ .action(async (labelRef, resourceType, resourceRef) => {
586
+ const spaceId = resolveSpace(spacesCmd);
587
+ const client = createClient();
588
+ try {
589
+ await client.space(spaceId).labels.detach(labelRef, { resourceType: parseLabelResourceType(resourceType), resourceRef });
590
+ ok("Label detached");
591
+ }
592
+ catch (e) {
593
+ handleHttp(e);
594
+ }
595
+ });
596
+ labelsCmd
597
+ .command("set <resourceType> <resourceRef> [labelRefs...]")
598
+ .description("Set resource labels")
599
+ .option("--labels <refs>", "Comma-separated label refs")
600
+ .option("--json", "Output as JSON")
601
+ .action(async (resourceType, resourceRef, labelRefs, opts) => {
602
+ const spaceId = resolveSpace(spacesCmd);
603
+ const client = createClient();
604
+ try {
605
+ const refs = [...parseLabelRefs(opts.labels), ...labelRefs];
606
+ const result = await client.space(spaceId).labels.setResourceLabels(parseLabelResourceType(resourceType), resourceRef, refs);
607
+ if (jsonRequested(opts))
608
+ return outJson(result);
609
+ ok("Resource labels updated");
610
+ }
611
+ catch (e) {
612
+ handleHttp(e);
613
+ }
614
+ });
615
+ }
416
616
  function registerMods(spacesCmd) {
417
617
  const modsCmd = spacesCmd
418
618
  .command("mods")
@@ -652,7 +852,6 @@ function registerFiles(spacesCmd) {
652
852
  }
653
853
  });
654
854
  }
655
- // ── Session operations ──
656
855
  function registerSessions(spacesCmd) {
657
856
  const sessionsCmd = spacesCmd
658
857
  .command("sessions")
@@ -688,12 +887,17 @@ function registerSessions(spacesCmd) {
688
887
  sessionsCmd
689
888
  .command("create [title]")
690
889
  .description("Create a session")
890
+ .option("--label <ref>", "Attach a label, e.g. Bug or Area/Frontend", collectOption, [])
691
891
  .option("--json", "Output as JSON")
692
892
  .action(async (title, opts) => {
693
893
  const spaceId = resolveSpace(spacesCmd);
694
894
  const client = createClient();
695
895
  try {
696
- const result = await client.space(spaceId).sessions.create({ title });
896
+ const result = await client.space(spaceId).sessions.create({
897
+ title,
898
+ source: "cli",
899
+ labelRefs: opts.label?.length ? opts.label : undefined,
900
+ });
697
901
  if (jsonRequested(opts))
698
902
  return outJson(result);
699
903
  ok(`Session created: ${result.session.id}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neta-art/cohub-cli",
3
- "version": "1.11.1",
3
+ "version": "1.12.0",
4
4
  "description": "CLI for Cohub — spaces, sessions, and agent collaboration.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -16,7 +16,7 @@
16
16
  "@neta-art/generation": "^0.1.2",
17
17
  "commander": "^14.0.3",
18
18
  "sharp": "^0.34.5",
19
- "@neta-art/cohub": "1.20.0"
19
+ "@neta-art/cohub": "1.21.0"
20
20
  },
21
21
  "publishConfig": {
22
22
  "access": "public"