@neta-art/cohub-cli 10.6.0 → 10.7.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/README.md CHANGED
@@ -404,6 +404,8 @@ cohub generate "Lip-sync to this spoken take" \
404
404
  --image reference_image=https://example.com/portrait.png \
405
405
  --audio reference_audio=https://example.com/speech.mp3
406
406
 
407
+ cohub generate -m song2 --text "prompt=upbeat synthwave" --text "lyrics=[Verse] Neon lights" --param seed=42
408
+
407
409
  cohub generate "a calm lake" \
408
410
  --model <model> \
409
411
  --async
@@ -414,6 +416,7 @@ cohub tasks get <taskRunId> --json
414
416
  Supported inputs:
415
417
 
416
418
  ```bash
419
+ --text <role>=<text>
417
420
  --image <path-or-url>
418
421
  --image first_frame=<path-or-url>
419
422
  --image last_frame=<path-or-url>
@@ -424,7 +427,7 @@ Supported inputs:
424
427
  --audio reference_audio=<path-or-url>
425
428
  ```
426
429
 
427
- Role-qualified media values add `meta.role` to that content block. Repeat `--image reference_image=...` for multiple reference images. Seedance role-qualified media should use public URL inputs. Do not mix first/last frame roles with reference roles (`reference_image`, `reference_video`, `reference_audio`) in one request.
430
+ Role-qualified values add `meta.role` to that content block. Text roles come from the model's text inputs (`cohub models show <model>`); repeat `--text <role>=<text>` for each text block. Repeat `--image reference_image=...` for multiple reference images. Seedance role-qualified media should use public URL inputs. Do not mix first/last frame roles with reference roles (`reference_image`, `reference_video`, `reference_audio`) in one request.
428
431
 
429
432
  Pass generation parameters with `--param key=value` or `--parameters '<json>'`.
430
433
 
@@ -27490,9 +27490,14 @@ function fitTextToWidth(text, value, width) {
27490
27490
  }
27491
27491
  //#endregion
27492
27492
  //#region ../sdk/src/board/render/text-resolution.ts
27493
- const MAX_BOARD_RESOLUTION = 2;
27494
- function getBoardResolution() {
27495
- return Math.min(globalThis.devicePixelRatio || 1, MAX_BOARD_RESOLUTION);
27493
+ const MAX_BOARD_RESOLUTION = 3;
27494
+ const MAX_BOARD_PIXELS = 8e6;
27495
+ const MAX_TEXT_RESOLUTION = 6;
27496
+ function getBoardResolution(size, devicePixelRatio = globalThis.devicePixelRatio) {
27497
+ const density = Number.isFinite(devicePixelRatio) && devicePixelRatio > 0 ? devicePixelRatio : 1;
27498
+ const pixels = size ? size.width * size.height : 0;
27499
+ const budget = pixels > 0 ? Math.sqrt(MAX_BOARD_PIXELS / pixels) : MAX_BOARD_RESOLUTION;
27500
+ return Math.min(density, MAX_BOARD_RESOLUTION, budget);
27496
27501
  }
27497
27502
  /**
27498
27503
  * Zoom buckets for text re-rasterisation. Pixi Text is a bitmap: at high zoom it
@@ -27518,12 +27523,12 @@ function boardCardDetailed(zoom) {
27518
27523
  /** Effective text resolution for a given camera zoom. */
27519
27524
  function textResolutionForZoom(zoom) {
27520
27525
  const bucket = textZoomBucket(zoom);
27521
- return Math.min(getBoardResolution() * Math.max(1, bucket), 6);
27526
+ return Math.min(getBoardResolution() * Math.max(1, bucket), MAX_TEXT_RESOLUTION);
27522
27527
  }
27523
27528
  /** Update a Pixi text texture only when zoom crosses a resolution bucket. */
27524
27529
  function syncTextResolution(text, state, zoom) {
27525
27530
  const resolution = textResolutionForZoom(zoom);
27526
- if (resolution === state.resolution) return;
27531
+ if (resolution === state.resolution && resolution === text.resolution) return;
27527
27532
  text.resolution = resolution;
27528
27533
  state.resolution = resolution;
27529
27534
  }
@@ -28056,13 +28061,10 @@ function cardElevation(context, id) {
28056
28061
  }
28057
28062
  //#endregion
28058
28063
  //#region ../sdk/src/board/render/renderers/base-card-renderer.ts
28059
- const TEXT_OPTIONS = {
28060
- resolution: getBoardResolution(),
28061
- roundPixels: true
28062
- };
28063
28064
  function createLabel(text, style) {
28064
28065
  return new Text({
28065
- ...TEXT_OPTIONS,
28066
+ resolution: getBoardResolution(),
28067
+ roundPixels: true,
28066
28068
  text,
28067
28069
  style: {
28068
28070
  fontFamily: BOARD_FONT_STACK,
@@ -29495,6 +29497,7 @@ function sync$5(container, item, context) {
29495
29497
  parts.id = item.id;
29496
29498
  }
29497
29499
  positionShell(parts.root, item);
29500
+ syncTextResolution(parts.label, parts.label, context.zoom);
29498
29501
  const { width, height } = item.frame;
29499
29502
  const resolution = Math.min(4, Math.max(.25, context.zoom * getBoardResolution()));
29500
29503
  const texture = context.scripts?.frame(item, context.time, resolution) ?? null;
@@ -32,6 +32,20 @@ function parseValue(value) {
32
32
  }
33
33
  return value;
34
34
  }
35
+ function parseTextInputs(values) {
36
+ return values.map((value) => {
37
+ const index = value.indexOf("=");
38
+ if (index < 0)
39
+ return error("Invalid --text", "Pass --text <role>=<text>.");
40
+ const role = value.slice(0, index).trim();
41
+ if (!role)
42
+ return error("Invalid --text", "Pass a role: --text <role>=<text>.");
43
+ const text = value.slice(index + 1);
44
+ if (!text)
45
+ return error("Invalid --text", 'Pass text after "=": --text <role>=<text>.');
46
+ return { role, text };
47
+ });
48
+ }
35
49
  function parseParams(param, parameters) {
36
50
  const result = parameters ? JSON.parse(parameters) : {};
37
51
  for (const item of param ?? []) {
@@ -260,8 +274,9 @@ export function registerGenerations(program) {
260
274
  program
261
275
  .command("generate")
262
276
  .description("Generate multimodal outputs")
263
- .argument("<prompt>", "Prompt text")
277
+ .argument("[prompt]", "Prompt text; optional when --text is given")
264
278
  .requiredOption("-m, --model <model>", "Multimodal model ID from `cohub models ls --model-type multimodal`")
279
+ .option("--text <role=text>", "Text input with a role from the model's text inputs (`cohub models show <model>`); repeatable, split at the first =, text sent unchanged", collect, [])
265
280
  .option("--image <path-or-url>", "Image input file path or URL; prefix with first_frame=, last_frame=, or reference_image= when needed; repeatable", collect, [])
266
281
  .option("--video <path-or-url>", "Video input file path or URL; prefix with reference_video= when needed; repeatable", collect, [])
267
282
  .option("--audio <path-or-url>", "Audio input file path or URL; prefix with reference_audio= when needed; repeatable", collect, [])
@@ -285,10 +300,15 @@ Examples:
285
300
  cohub -s <space-id> generate "Smooth transition" -m seedance-2-0-fast --image first_frame=https://example.com/first.png --image last_frame=https://example.com/last.png
286
301
  cohub -s <space-id> generate "Use these references" -m seedance-2-0-fast --image reference_image=https://example.com/a.png --image reference_image=https://example.com/b.png
287
302
  cohub -s <space-id> generate "Lip-sync to the reference take" -m seedance-2-0-fast --image reference_image=https://example.com/portrait.png --audio reference_audio=https://example.com/speech.mp3
303
+ cohub -s <space-id> generate -m song2 --text "prompt=upbeat synthwave" --text "lyrics=[Verse] Neon lights" --param seed=42
288
304
  cohub -s <space-id> generate "A calm lake" -m <model> --async
289
305
  `)
290
306
  .action(async (prompt, opts) => {
291
307
  try {
308
+ const texts = parseTextInputs(opts.text);
309
+ if (prompt === undefined && texts.length === 0) {
310
+ return error("Missing prompt", "Pass a prompt or at least one --text <role>=<text>.");
311
+ }
292
312
  const spaceId = await resolveSpace(program, { home: true });
293
313
  const sessionId = envValue("COHUB_SESSION_ID");
294
314
  const inputs = await Promise.all([
@@ -330,7 +350,8 @@ Examples:
330
350
  return asset.publicUrl;
331
351
  };
332
352
  const content = [
333
- { type: "text", text: prompt },
353
+ ...(prompt === undefined ? [] : [{ type: "text", text: prompt }]),
354
+ ...texts.map(({ role, text }) => ({ type: "text", text, meta: { role } })),
334
355
  ...await Promise.all(inputs.map((input) => mediaContent(input, upload))),
335
356
  ];
336
357
  const created = await client.generations.create({
@@ -1,9 +1,13 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { homedir } from "node:os";
2
3
  import { join } from "node:path";
4
+ import { JsonRpcProcess, record } from "../json-rpc.js";
3
5
  import { copyNativeArchive } from "../native/archive.js";
4
6
  import { projectChainedSession, projectionMeta, projectionToolId, rebindSessionId } from "./chained-projection.js";
7
+ import { array, harnessCommand, text } from "./process.js";
5
8
  import { DEFAULT_MODEL, runStreamJson, streamJsonHelp, streamJsonModel } from "./stream-json.js";
6
9
  const MAX_PROJECT_NAME = 200;
10
+ const INITIALIZE_TIMEOUT_MS = 30_000;
7
11
  const dataRoot = () => process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), ".claude");
8
12
  /**
9
13
  * Claude Code names a project directory by replacing every non-alphanumeric UTF-16 unit; a longer
@@ -47,11 +51,66 @@ function withAssistantIds(projection) {
47
51
  ...entry, record: { ...entry.record, message: { id: `msg_${String(entry.record.uuid).replaceAll("-", "")}`, ...entry.record.message } },
48
52
  }) };
49
53
  }
54
+ /** Claude Code's own `/model` list (what the SDK's `supportedModels()` reads), gateway-discovered models included. */
55
+ async function claudeInitialize(executable, cwd) {
56
+ const args = ["-p", "--input-format", "stream-json", "--output-format", "stream-json", "--verbose", "--no-session-persistence", "--strict-mcp-config"];
57
+ const host = new JsonRpcProcess(...harnessCommand(executable, args, cwd), cwd, "stream");
58
+ const requestId = randomUUID();
59
+ let timer;
60
+ try {
61
+ return await new Promise((resolve, reject) => {
62
+ timer = setTimeout(() => reject(new Error("Claude Code initialize timed out")), INITIALIZE_TIMEOUT_MS);
63
+ host.onFailure(reject);
64
+ host.onEvent((event) => {
65
+ const response = record(event.response);
66
+ if (event.type !== "control_response" || response.request_id !== requestId)
67
+ return;
68
+ if (response.subtype === "success")
69
+ resolve(record(response.response));
70
+ else
71
+ reject(new Error(text(response.error) || "Claude Code initialize failed"));
72
+ });
73
+ host.write({ type: "control_request", request_id: requestId, request: { subtype: "initialize" } });
74
+ });
75
+ }
76
+ finally {
77
+ clearTimeout(timer);
78
+ await host.close();
79
+ }
80
+ }
81
+ /** Gateway models are served by `ANTHROPIC_BASE_URL`, so they carry its host as provider. */
82
+ function claudeModelCatalog(entries, baseUrl) {
83
+ let gateway = "gateway";
84
+ try {
85
+ if (baseUrl)
86
+ gateway = new URL(baseUrl).hostname.slice(0, 100) || gateway;
87
+ }
88
+ catch { /* Keep the generic name. */ }
89
+ const seen = new Set();
90
+ return entries.flatMap((value) => {
91
+ const model = record(value);
92
+ const id = text(model.value);
93
+ if (!id || id.length > 255 || seen.has(id))
94
+ return [];
95
+ seen.add(id);
96
+ const effortLevels = model.supportsEffort === true ? array(model.supportedEffortLevels).filter((level) => typeof level === "string") : [];
97
+ return [streamJsonModel(profile, id, (text(model.displayName) || id).slice(0, 255), {
98
+ reasoning: effortLevels.length > 0, effortLevels, isDefault: id === DEFAULT_MODEL,
99
+ provider: text(model.description).startsWith("From gateway") ? gateway : profile.provider,
100
+ })];
101
+ });
102
+ }
50
103
  export const claudeHarness = {
51
104
  id: "claude",
52
105
  binary: profile.binary,
53
106
  async models(executable, cwd) {
54
107
  await streamJsonHelp(profile, executable, cwd);
108
+ try {
109
+ const models = claudeModelCatalog(array((await claudeInitialize(executable, cwd)).models), process.env.ANTHROPIC_BASE_URL);
110
+ if (models.some((model) => model.isDefault))
111
+ return models;
112
+ }
113
+ catch { /* A CLI without the initialize model list keeps the account aliases. */ }
55
114
  // Aliases follow the signed-in account; Haiku takes no effort level.
56
115
  return [
57
116
  streamJsonModel(profile, DEFAULT_MODEL, "Default", { reasoning: true, isDefault: true }),
@@ -20,6 +20,8 @@ export declare function streamJsonHelp(profile: StreamJsonProfile, executable: s
20
20
  export declare function streamJsonModel(profile: StreamJsonProfile, id: string, name: string, options: {
21
21
  reasoning: boolean;
22
22
  isDefault?: boolean;
23
+ provider?: string;
24
+ effortLevels?: readonly string[];
23
25
  }): HarnessModel;
24
26
  /**
25
27
  * One Turn per process. Unattended execution never grants an approval: workspace edits follow
@@ -29,10 +29,11 @@ export async function streamJsonHelp(profile, executable, cwd) {
29
29
  return stdout;
30
30
  }
31
31
  export function streamJsonModel(profile, id, name, options) {
32
+ const effortLevels = options.effortLevels ?? profile.effortLevels;
32
33
  return {
33
- harness: profile.harness, provider: profile.provider, id, name,
34
+ harness: profile.harness, provider: options.provider ?? profile.provider, id, name,
34
35
  ...(options.isDefault ? { isDefault: true } : {}),
35
- ...(options.reasoning ? { reasoning: true, thinkingLevelMap: Object.fromEntries(THINKING_LEVELS.map((level) => [level, profile.effortLevels.includes(level) ? level : null])) } : {}),
36
+ ...(options.reasoning ? { reasoning: true, thinkingLevelMap: Object.fromEntries(THINKING_LEVELS.map((level) => [level, effortLevels.includes(level) ? level : null])) } : {}),
36
37
  };
37
38
  }
38
39
  /** A session's file is found by id; the project directory naming is only the fast path. */
@@ -208,7 +209,7 @@ export async function runStreamJson(profile, executor, turn) {
208
209
  signal.throwIfAborted();
209
210
  const host = new JsonRpcProcess(...harnessCommand(executor.executables[profile.harness] || profile.binary, args, executor.cwd), executor.cwd, "stream", turnEnvironment(input));
210
211
  watchHost(host, executor.diagnostics, context);
211
- const translator = streamJsonTranslator(turn.emit, { provider: profile.provider, model: input.model && input.model !== DEFAULT_MODEL ? input.model : null });
212
+ const translator = streamJsonTranslator(turn.emit, { provider: input.provider || profile.provider, model: input.model && input.model !== DEFAULT_MODEL ? input.model : null });
212
213
  let message = { ordinal: 0, content: [] };
213
214
  let begun = false;
214
215
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neta-art/cohub-cli",
3
- "version": "10.6.0",
3
+ "version": "10.7.0",
4
4
  "description": "CLI for Cohub — spaces, sessions, and agent collaboration.",
5
5
  "type": "module",
6
6
  "engines": {
@@ -18,7 +18,7 @@
18
18
  "NOTICE"
19
19
  ],
20
20
  "dependencies": {
21
- "@neta-art/cohub": "11.5.0",
21
+ "@neta-art/cohub": "11.5.1",
22
22
  "@neta-art/generation": "^0.1.39",
23
23
  "commander": "^15.0.0",
24
24
  "sharp": "^0.35.4",