@neta-art/cohub-cli 1.7.1 → 1.8.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
@@ -129,6 +129,8 @@ cohub search "query" --limit 20 --json
129
129
  ```bash
130
130
  cohub models ls --json
131
131
  cohub models ls --model-type multimodal --json
132
+ cohub models show <model>
133
+ cohub models show <model> --json
132
134
 
133
135
  cohub generate "a calm lake at sunrise" \
134
136
  --model <model> \
package/dist/auth.d.ts CHANGED
@@ -30,7 +30,9 @@ export declare class AuthRequiredError extends Error {
30
30
  export declare const readAuthSession: () => AuthSession | null;
31
31
  export declare const clearAuthSession: () => void;
32
32
  export declare const authSource: () => AuthSource;
33
- export declare function resolveAccessToken(): Promise<string | null>;
33
+ export declare function resolveAccessToken(options?: {
34
+ forceRefresh?: boolean;
35
+ }): Promise<string | null>;
34
36
  export declare function requireAccessToken(): Promise<string>;
35
37
  export declare function refreshAccessToken(session?: AuthSession | null): Promise<string | null>;
36
38
  export declare function requestDeviceCode(): Promise<DeviceCode>;
package/dist/auth.js CHANGED
@@ -125,14 +125,14 @@ export const authSource = () => {
125
125
  return "logto";
126
126
  return null;
127
127
  };
128
- export async function resolveAccessToken() {
128
+ export async function resolveAccessToken(options) {
129
129
  const executionToken = process.env.COHUB_EXECUTION_TOKEN?.trim();
130
130
  if (executionToken)
131
131
  return executionToken;
132
132
  const session = readAuthSession();
133
133
  if (!session)
134
134
  throw new AuthRequiredError();
135
- if (session.accessTokenExpiresAt - Date.now() > EXPIRY_SKEW_MS)
135
+ if (!options?.forceRefresh && session.accessTokenExpiresAt - Date.now() > EXPIRY_SKEW_MS)
136
136
  return session.accessToken;
137
137
  return refreshAccessToken(session);
138
138
  }
@@ -1,6 +1,6 @@
1
1
  import { authSource, loginWithDeviceFlow, readAuthSession, refreshAccessToken, requestDeviceCode, revokeAndClearAuthSession, verifyDeviceCode } from "../auth.js";
2
2
  import { createClient } from "../client.js";
3
- import { table, json as outJson, ok, error, spinner, handleHttp } from "../output.js";
3
+ import { table, json as outJson, jsonRequested, ok, error, spinner, handleHttp } from "../output.js";
4
4
  export function registerAuth(program) {
5
5
  const auth = program.command("auth").description("Authentication management");
6
6
  auth
@@ -9,8 +9,8 @@ export function registerAuth(program) {
9
9
  .option("--request-code", "Request a device code without polling")
10
10
  .option("--verify-code", "Exchange a previously requested device code")
11
11
  .option("--json", "Output as JSON")
12
- .action(async (opts, command) => {
13
- const asJson = Boolean(opts.json || command.parent?.optsWithGlobals().json);
12
+ .action(async (opts) => {
13
+ const asJson = jsonRequested(opts);
14
14
  if (opts.requestCode && opts.verifyCode) {
15
15
  return error("Conflicting options", "Use only one of --request-code or --verify-code");
16
16
  }
@@ -43,8 +43,8 @@ export function registerAuth(program) {
43
43
  .command("whoami")
44
44
  .description("Show current user info")
45
45
  .option("--json", "Output as JSON")
46
- .action(async (opts, command) => {
47
- const asJson = Boolean(opts.json || command.parent?.optsWithGlobals().json);
46
+ .action(async (opts) => {
47
+ const asJson = jsonRequested(opts);
48
48
  return showSignedIn(asJson);
49
49
  });
50
50
  auth
@@ -80,11 +80,11 @@ async function showSignedIn(asJson) {
80
80
  if (!source)
81
81
  return error("Not authenticated", "Run `cohub auth login`.");
82
82
  const client = createClient();
83
- const sp = spinner();
84
- sp.start("Fetching user info");
83
+ const sp = asJson ? null : spinner();
84
+ sp?.start("Fetching user info");
85
85
  try {
86
86
  const user = await client.user.getMe();
87
- sp.stop("Done");
87
+ sp?.stop("Done");
88
88
  const session = readAuthSession();
89
89
  const payload = {
90
90
  source,
@@ -93,22 +93,77 @@ async function showSignedIn(asJson) {
93
93
  };
94
94
  if (asJson)
95
95
  return outJson(payload);
96
- const u = user;
96
+ const u = flattenMeForTable(user);
97
97
  console.log(` Auth source: ${source}`);
98
98
  console.log(` Token: ${payload.refreshable ? "refreshable" : "ephemeral"}\n`);
99
99
  table([u], [
100
- { key: "id", label: "ID" },
100
+ { key: "uuid", label: "UUID" },
101
101
  { key: "username", label: "Username" },
102
- { key: "name", label: "Name" },
102
+ { key: "displayName", label: "Name" },
103
103
  { key: "email", label: "Email" },
104
- { key: "created_at", label: "Created" },
105
104
  ]);
106
105
  }
107
106
  catch (e) {
108
- sp.stop("Failed");
107
+ if (source === "execution-token") {
108
+ sp?.stop("Using local execution token");
109
+ return showExecutionTokenFallback(asJson);
110
+ }
111
+ sp?.stop("Failed");
109
112
  handleHttp(e);
110
113
  }
111
114
  }
115
+ function flattenMeForTable(user) {
116
+ const profile = user.profile && typeof user.profile === "object" ? user.profile : {};
117
+ return {
118
+ uuid: user.uuid,
119
+ username: profile.username,
120
+ displayName: profile.displayName,
121
+ email: user.email,
122
+ };
123
+ }
124
+ function decodeExecutionTokenPayload() {
125
+ const token = process.env.COHUB_EXECUTION_TOKEN?.trim();
126
+ const payload = token?.split(".")[1];
127
+ if (!payload)
128
+ return null;
129
+ try {
130
+ const decoded = Buffer.from(payload, "base64url").toString("utf-8");
131
+ const parsed = JSON.parse(decoded);
132
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
133
+ }
134
+ catch {
135
+ return null;
136
+ }
137
+ }
138
+ function showExecutionTokenFallback(asJson) {
139
+ const payload = decodeExecutionTokenPayload();
140
+ const execution = {
141
+ actorUserId: typeof payload?.actorUserId === "string" ? payload.actorUserId : null,
142
+ spaceId: typeof payload?.spaceId === "string" ? payload.spaceId : null,
143
+ sessionId: typeof payload?.sessionId === "string" ? payload.sessionId : null,
144
+ source: typeof payload?.source === "string" ? payload.source : null,
145
+ expiresAt: typeof payload?.exp === "number" ? new Date(payload.exp * 1000).toISOString() : null,
146
+ };
147
+ const result = {
148
+ source: "execution-token",
149
+ refreshable: false,
150
+ user: execution.actorUserId ? { uuid: execution.actorUserId } : null,
151
+ execution,
152
+ };
153
+ if (asJson) {
154
+ outJson(result);
155
+ return;
156
+ }
157
+ console.log(" Auth source: execution-token");
158
+ console.log(" Token: ephemeral\n");
159
+ table([execution], [
160
+ { key: "actorUserId", label: "Actor" },
161
+ { key: "spaceId", label: "Space" },
162
+ { key: "sessionId", label: "Session" },
163
+ { key: "source", label: "Source" },
164
+ { key: "expiresAt", label: "Expires" },
165
+ ]);
166
+ }
112
167
  function printDeviceCode(code) {
113
168
  console.log("\nOpen this URL to sign in:\n");
114
169
  console.log(` ${code.verificationUriComplete}\n`);
@@ -1,5 +1,5 @@
1
1
  import { createClient } from "../client.js";
2
- import { table, json as outJson, ok, error, handleHttp } from "../output.js";
2
+ import { table, json as outJson, jsonRequested, ok, error, handleHttp } from "../output.js";
3
3
  export function registerChannels(program) {
4
4
  const cmd = program.command("channels", { hidden: true }).description("Channel integrations");
5
5
  cmd
@@ -11,7 +11,7 @@ export function registerChannels(program) {
11
11
  const client = createClient();
12
12
  try {
13
13
  const items = await client.channels.list();
14
- if (opts.json)
14
+ if (jsonRequested(opts))
15
15
  return outJson(items);
16
16
  if (items.length === 0)
17
17
  return console.log(" (empty)");
@@ -50,7 +50,7 @@ export function registerChannels(program) {
50
50
  name: opts.name,
51
51
  credentials,
52
52
  });
53
- if (opts.json)
53
+ if (jsonRequested(opts))
54
54
  return outJson(result);
55
55
  ok("Channel created");
56
56
  }
@@ -1,5 +1,5 @@
1
1
  import { createClient } from "../client.js";
2
- import { table, json as outJson, ok, handleHttp } from "../output.js";
2
+ import { table, json as outJson, jsonRequested, ok, error, handleHttp } from "../output.js";
3
3
  export function registerCronJobs(program) {
4
4
  const cmd = program.command("cron-jobs", { hidden: true }).description("Scheduled prompt jobs");
5
5
  cmd
@@ -11,7 +11,7 @@ export function registerCronJobs(program) {
11
11
  const client = createClient();
12
12
  try {
13
13
  const result = await client.cronJobs.list(spaceId);
14
- if (opts.json)
14
+ if (jsonRequested(opts))
15
15
  return outJson(result);
16
16
  if (result.jobs.length === 0)
17
17
  return console.log(" (empty)");
@@ -44,6 +44,8 @@ export function registerCronJobs(program) {
44
44
  .command("toggle <id> <on|off>")
45
45
  .description("Enable or disable a cron job")
46
46
  .action(async (id, state) => {
47
+ if (state !== "on" && state !== "off")
48
+ return error("Invalid state", "Use on or off");
47
49
  const enabled = state === "on";
48
50
  const client = createClient();
49
51
  try {
@@ -62,7 +64,7 @@ export function registerCronJobs(program) {
62
64
  const client = createClient();
63
65
  try {
64
66
  const result = await client.cronJobs.runs(id);
65
- if (opts.json)
67
+ if (jsonRequested(opts))
66
68
  return outJson(result);
67
69
  if (result.runs.length === 0)
68
70
  return console.log(" (empty)");
@@ -1,7 +1,8 @@
1
1
  import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
2
2
  import { basename, dirname, extname, join } from "node:path";
3
3
  import { createClient } from "../client.js";
4
- import { json as outJson, ok, handleHttp } from "../output.js";
4
+ import { resolveSpace } from "../space.js";
5
+ import { json as outJson, jsonRequested, ok, error, handleHttp, spinner } from "../output.js";
5
6
  const mimeByExt = {
6
7
  ".png": "image/png",
7
8
  ".jpg": "image/jpeg",
@@ -108,43 +109,72 @@ function printGeneration(output) {
108
109
  console.log(`${block.type}: ${block.source.space_id}:${block.source.path}`);
109
110
  }
110
111
  }
112
+ function parseTimeoutMs(value) {
113
+ if (!value)
114
+ return undefined;
115
+ if (!/^\d+$/.test(value.trim()))
116
+ return error("Invalid timeout", "--timeout-ms must be a positive integer");
117
+ const timeoutMs = Number.parseInt(value, 10);
118
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0)
119
+ return error("Invalid timeout", "--timeout-ms must be a positive integer");
120
+ return timeoutMs;
121
+ }
111
122
  export function registerGenerations(program) {
112
123
  program
113
124
  .command("generate")
114
125
  .description("Generate multimodal outputs")
115
126
  .argument("<prompt>", "Prompt text")
116
- .requiredOption("--model <model>", "Multimodal model ID from `cohub models ls --model-type multimodal`")
127
+ .requiredOption("-m, --model <model>", "Multimodal model ID from `cohub models ls --model-type multimodal`")
117
128
  .option("--image <path-or-url>", "Image input file path or URL; repeatable", collect, [])
118
129
  .option("--video <path-or-url>", "Video input file path or URL; repeatable", collect, [])
119
130
  .option("--audio <path-or-url>", "Audio input file path or URL; repeatable", collect, [])
120
131
  .option("--param <key=value>", "Generation parameter; repeatable, values may be JSON/number/boolean", collect, [])
121
132
  .option("--parameters <json>", "Generation parameters as a JSON object")
122
133
  .option("--metadata <json>", "Metadata as a JSON object")
123
- .option("--output <path>", "Save generated output to a file or directory")
134
+ .option("-o, --output <path>", "Save generated output to a file or directory")
135
+ .option("--async", "Queue the generation task and return immediately")
136
+ .option("--timeout-ms <ms>", "Maximum time to wait in synchronous mode")
124
137
  .option("--json", "Output as JSON")
125
138
  .addHelpText("after", `
126
139
 
127
140
  Examples:
128
141
  cohub models ls --model-type multimodal
129
- cohub generate "A calm lake at sunrise" --model <model> --output lake.png
130
- cohub generate "Restyle this image" --model <model> --image input.png --param size=1024x1024
142
+ cohub -s <space-id> generate "A calm lake at sunrise" -m <model> -o lake.png
143
+ COHUB_SPACE_ID=<space-id> cohub generate "Restyle this image" -m <model> --image input.png
144
+ cohub -s <space-id> generate "A calm lake" -m <model> --async
131
145
  `)
132
146
  .action(async (prompt, opts) => {
133
147
  try {
148
+ const spaceId = resolveSpace(program);
134
149
  const content = [{ type: "text", text: prompt }];
135
150
  content.push(...await Promise.all(opts.image.map((value) => contentFromPathOrUrl("image", value))));
136
151
  content.push(...await Promise.all(opts.video.map((value) => contentFromPathOrUrl("video", value))));
137
152
  content.push(...await Promise.all(opts.audio.map((value) => contentFromPathOrUrl("audio", value))));
138
- const generation = await createClient().generations.create({
153
+ const client = createClient();
154
+ const created = await client.generations.create({
155
+ spaceId,
139
156
  model: opts.model,
140
157
  content,
141
158
  parameters: parseParams(opts.param, opts.parameters),
142
159
  metadata: opts.metadata ? JSON.parse(opts.metadata) : undefined,
143
160
  });
144
- const savedPaths = opts.output && generation.output ? await saveOutputs(generation.output, opts.output) : [];
145
- if (opts.json)
146
- return outJson(savedPaths.length > 0 ? { ...generation, savedPaths } : generation);
147
- printGeneration(generation.output ?? []);
161
+ if (opts.async) {
162
+ if (jsonRequested(opts))
163
+ return outJson(created);
164
+ return ok(`Generation queued — taskRunId: ${created.taskRunId}`);
165
+ }
166
+ const spin = spinner();
167
+ if (!jsonRequested(opts))
168
+ spin.start("Generating");
169
+ const result = await client.generations.wait(created.taskRunId, {
170
+ timeoutMs: parseTimeoutMs(opts.timeoutMs),
171
+ });
172
+ if (!jsonRequested(opts))
173
+ spin.stop("Generation completed");
174
+ const savedPaths = opts.output ? await saveOutputs(result.output, opts.output) : [];
175
+ if (jsonRequested(opts))
176
+ return outJson(savedPaths.length > 0 ? { ...result, taskRunId: created.taskRunId, savedPaths } : { ...result, taskRunId: created.taskRunId });
177
+ printGeneration(result.output);
148
178
  if (savedPaths.length > 0)
149
179
  ok(`Saved to ${savedPaths.join(", ")}`);
150
180
  }
@@ -1,5 +1,69 @@
1
1
  import { createClient } from "../client.js";
2
- import { table, json as outJson, error, handleHttp } from "../output.js";
2
+ import { table, json as outJson, jsonRequested, error, handleHttp } from "../output.js";
3
+ function toMultimodalModelSummary(model) {
4
+ return {
5
+ model: model.model,
6
+ ...(model.title ? { title: model.title } : {}),
7
+ ...(model.description ? { description: model.description } : {}),
8
+ };
9
+ }
10
+ function printSection(title, lines) {
11
+ if (lines.length === 0)
12
+ return;
13
+ console.log(`\n${title}`);
14
+ for (const line of lines)
15
+ console.log(` ${line}`);
16
+ }
17
+ function formatContentSpec(spec) {
18
+ const details = [];
19
+ details.push(spec.required === false ? "optional" : "required");
20
+ if (typeof spec.min === "number")
21
+ details.push(`min ${spec.min}`);
22
+ if (typeof spec.max === "number")
23
+ details.push(`max ${spec.max}`);
24
+ if (spec.sources?.length)
25
+ details.push(`sources: ${spec.sources.join(", ")}`);
26
+ if (spec.merge)
27
+ details.push(`merge: ${spec.merge}`);
28
+ if (spec.description)
29
+ details.push(spec.description);
30
+ return `${spec.type}${details.length > 0 ? ` — ${details.join("; ")}` : ""}`;
31
+ }
32
+ function formatParameter(name, spec) {
33
+ const lines = [`${name}`];
34
+ const details = [`type: ${spec.type}`];
35
+ if (spec.optional)
36
+ details.push("optional");
37
+ if ("default" in spec && spec.default !== undefined)
38
+ details.push(`default: ${String(spec.default)}`);
39
+ if ("min" in spec && typeof spec.min === "number")
40
+ details.push(`min: ${spec.min}`);
41
+ if ("max" in spec && typeof spec.max === "number")
42
+ details.push(`max: ${spec.max}`);
43
+ if ("enum" in spec && spec.enum?.length)
44
+ details.push(`values: ${spec.enum.join(", ")}`);
45
+ lines.push(` ${details.join("; ")}`);
46
+ if (spec.description)
47
+ lines.push(` ${spec.description}`);
48
+ if ("examples" in spec && spec.examples?.length)
49
+ lines.push(` examples: ${spec.examples.map(String).join(", ")}`);
50
+ return lines;
51
+ }
52
+ function printMultimodalModel(model) {
53
+ console.log(model.title ?? model.model);
54
+ printSection("Model", [model.model]);
55
+ if (model.description)
56
+ printSection("Description", [model.description]);
57
+ printSection("Input", model.content.input.map(formatContentSpec));
58
+ const parameterLines = Object.entries(model.parameters ?? {}).flatMap(([name, spec]) => formatParameter(name, spec));
59
+ printSection("Parameters", parameterLines);
60
+ const examples = model.examples ?? [];
61
+ printSection("Examples", examples.map((example, index) => {
62
+ const title = example.title ? `${example.title}: ` : "";
63
+ const prompt = example.request.content.find((block) => block.type === "text")?.text;
64
+ return `${index + 1}. ${title}${prompt ? `"${prompt}"` : example.request.model}`;
65
+ }));
66
+ }
3
67
  export function registerModels(program) {
4
68
  const cmd = program
5
69
  .command("models")
@@ -10,6 +74,8 @@ Examples:
10
74
  cohub models ls
11
75
  cohub models ls --model-type multimodal
12
76
  cohub models ls --model-type multimodal --json
77
+ cohub models show <model>
78
+ cohub models show <model> --json
13
79
  `);
14
80
  cmd
15
81
  .command("ls")
@@ -22,9 +88,10 @@ Examples:
22
88
  try {
23
89
  if (opts.modelType === "multimodal") {
24
90
  const response = await client.models.listMultimodal();
25
- if (opts.json)
26
- return outJson(response);
27
- table(response.models, [
91
+ const models = response.models.map(toMultimodalModelSummary);
92
+ if (jsonRequested(opts))
93
+ return outJson({ models });
94
+ table(models, [
28
95
  { key: "model", label: "Model" },
29
96
  { key: "title", label: "Title" },
30
97
  { key: "description", label: "Description" },
@@ -35,7 +102,7 @@ Examples:
35
102
  return error("Invalid model type", "Use --model-type llm or --model-type multimodal");
36
103
  }
37
104
  const catalog = await client.models.list();
38
- if (opts.json)
105
+ if (jsonRequested(opts))
39
106
  return outJson(catalog);
40
107
  // catalog is Record<provider, ModelCatalogEntry[]>
41
108
  for (const [provider, entries] of Object.entries(catalog)) {
@@ -51,4 +118,25 @@ Examples:
51
118
  handleHttp(e);
52
119
  }
53
120
  });
121
+ cmd
122
+ .command("show")
123
+ .description("Show full multimodal model details")
124
+ .argument("<model>", "Multimodal model ID")
125
+ .option("--json", "Output as JSON")
126
+ .action(async (modelId, opts) => {
127
+ const client = createClient();
128
+ try {
129
+ const response = await client.models.listMultimodal();
130
+ const model = response.models.find((item) => item.model === modelId);
131
+ if (!model) {
132
+ return error("Model not found", `No multimodal model named ${modelId}`);
133
+ }
134
+ if (jsonRequested(opts))
135
+ return outJson(model);
136
+ printMultimodalModel(model);
137
+ }
138
+ catch (e) {
139
+ handleHttp(e);
140
+ }
141
+ });
54
142
  }
@@ -1,6 +1,6 @@
1
1
  import { uploadAvatarAsset } from "../avatar.js";
2
2
  import { createClient } from "../client.js";
3
- import { json as outJson, ok, handleHttp } from "../output.js";
3
+ import { json as outJson, jsonRequested, ok, handleHttp } from "../output.js";
4
4
  export function registerProfile(program) {
5
5
  const profileCmd = program.command("profile").description("Manage your profile");
6
6
  profileCmd
@@ -12,7 +12,7 @@ export function registerProfile(program) {
12
12
  try {
13
13
  const asset = await uploadAvatarAsset({ client, purpose: "user_avatar", path });
14
14
  const result = await client.user.updateProfile({ avatarUrl: asset.publicUrl });
15
- if (opts.json)
15
+ if (jsonRequested(opts))
16
16
  return outJson({ ...result, asset });
17
17
  ok("Avatar updated");
18
18
  }
@@ -1,5 +1,5 @@
1
1
  import { createClient } from "../client.js";
2
- import { table, json as outJson, handleHttp } from "../output.js";
2
+ import { table, json as outJson, jsonRequested, handleHttp } from "../output.js";
3
3
  export function registerPrompts(program) {
4
4
  const cmd = program.command("prompts").description("Prompt template management");
5
5
  cmd
@@ -12,7 +12,7 @@ export function registerPrompts(program) {
12
12
  const client = createClient();
13
13
  try {
14
14
  const result = await client.prompts.list({ spaceId: opts.space });
15
- if (opts.json)
15
+ if (jsonRequested(opts))
16
16
  return outJson(result);
17
17
  if (result.prompts.length === 0)
18
18
  return console.log(" (empty)");
@@ -1,5 +1,5 @@
1
1
  import { createClient } from "../client.js";
2
- import { table, json as outJson, error, handleHttp } from "../output.js";
2
+ import { table, json as outJson, jsonRequested, error, handleHttp } from "../output.js";
3
3
  const DEFAULT_LIMIT = 20;
4
4
  const MAX_TITLE_LENGTH = 72;
5
5
  const MAX_CONTEXT_LENGTH = 42;
@@ -81,7 +81,7 @@ Examples:
81
81
  types: input.types,
82
82
  spaceId: input.spaceId,
83
83
  });
84
- if (opts.json)
84
+ if (jsonRequested(opts))
85
85
  return outJson(result);
86
86
  if (result.degraded) {
87
87
  process.stderr.write(" Search is temporarily degraded; results may be incomplete.\n");
@@ -1,5 +1,13 @@
1
1
  import { createClient } from "../client.js";
2
- import { table, json as outJson, handleHttp } from "../output.js";
2
+ import { table, json as outJson, jsonRequested, error, handleHttp } from "../output.js";
3
+ const SPACE_ROLES = ["host", "builder", "guest"];
4
+ function parseAnonymousRole(value) {
5
+ if (value === undefined || value === "null")
6
+ return null;
7
+ if (SPACE_ROLES.includes(value))
8
+ return value;
9
+ return error("Invalid anonymous role", "Use one of: host, builder, guest, null");
10
+ }
3
11
  export function registerSessionAccess(program) {
4
12
  const cmd = program
5
13
  .command("session-access")
@@ -12,7 +20,7 @@ export function registerSessionAccess(program) {
12
20
  const client = createClient();
13
21
  try {
14
22
  const policy = await client.sessionAccess.get(id);
15
- if (opts.json)
23
+ if (jsonRequested(opts))
16
24
  return outJson(policy);
17
25
  table([policy], [
18
26
  { key: "signed_in_user", label: "Signed-in" },
@@ -32,9 +40,9 @@ export function registerSessionAccess(program) {
32
40
  const client = createClient();
33
41
  try {
34
42
  const policy = await client.sessionAccess.set(id, {
35
- anonymous_user: (opts.anonymous ?? null),
43
+ anonymous_user: parseAnonymousRole(opts.anonymous),
36
44
  });
37
- if (opts.json)
45
+ if (jsonRequested(opts))
38
46
  return outJson(policy);
39
47
  console.log("Session access updated");
40
48
  table([policy], [
@@ -2,21 +2,36 @@ import { randomUUID } from "node:crypto";
2
2
  import { createReadStream } from "node:fs";
3
3
  import { readdir, stat } from "node:fs/promises";
4
4
  import { basename, dirname, relative, resolve, sep } from "node:path";
5
- import { createClient } from "../client.js";
5
+ import { resolveCohubEnvironment } from "@neta-art/cohub";
6
6
  import { uploadAvatarAsset } from "../avatar.js";
7
- import { table, json as outJson, ok, error, handleHttp } from "../output.js";
8
- function requireSpace(program) {
9
- let current = program;
10
- while (current) {
11
- const opts = current.opts();
12
- if (opts.space)
13
- return String(opts.space);
14
- current = current.parent ?? null;
15
- }
16
- return error("Missing required option", "Add -s, --space <id> to target a space");
17
- }
18
- const cliEnv = (process.env.ENV === "prod" ? "prod" : "dev");
7
+ import { createClient } from "../client.js";
8
+ import { table, json as outJson, jsonRequested, ok, error, handleHttp } from "../output.js";
9
+ import { resolveSpace } from "../space.js";
10
+ const cliEnv = resolveCohubEnvironment();
19
11
  const defaultIdleTtlSeconds = cliEnv === "prod" ? 12 * 60 * 60 : 10 * 60;
12
+ const SPACE_ROLES = ["host", "builder", "guest"];
13
+ function parseInteger(value, name, options = {}) {
14
+ if (!/^-?\d+$/.test(value.trim()))
15
+ return error(`Invalid ${name}`, `${name} must be an integer`);
16
+ const parsed = Number.parseInt(value, 10);
17
+ if (!Number.isSafeInteger(parsed))
18
+ return error(`Invalid ${name}`, `${name} must be a safe integer`);
19
+ if (options.min !== undefined && parsed < options.min)
20
+ return error(`Invalid ${name}`, `${name} must be at least ${options.min}`);
21
+ if (options.max !== undefined && parsed > options.max)
22
+ return error(`Invalid ${name}`, `${name} must be at most ${options.max}`);
23
+ return parsed;
24
+ }
25
+ function parseChoice(value, name, choices) {
26
+ if (choices.includes(value))
27
+ return value;
28
+ return error(`Invalid ${name}`, `Use one of: ${choices.join(", ")}`);
29
+ }
30
+ function parseNullableRole(value, name) {
31
+ if (value === undefined || value === "null")
32
+ return null;
33
+ return parseChoice(value, name, SPACE_ROLES);
34
+ }
20
35
  const parseAutoDestroy = (opts) => {
21
36
  const mode = opts.autoDestroy ?? (opts.idleTtl ? "idle" : undefined);
22
37
  if (!mode)
@@ -25,10 +40,7 @@ const parseAutoDestroy = (opts) => {
25
40
  return { mode: "never" };
26
41
  if (mode !== "idle")
27
42
  return error("Invalid auto destroy mode", "Use --auto-destroy idle or --auto-destroy never");
28
- const ttlSeconds = Number.parseInt(opts.idleTtl ?? String(defaultIdleTtlSeconds), 10);
29
- if (!Number.isSafeInteger(ttlSeconds) || ttlSeconds < 60 || ttlSeconds > 30 * 24 * 60 * 60) {
30
- return error("Invalid idle TTL", "--idle-ttl must be an integer between 60 and 2592000 seconds");
31
- }
43
+ const ttlSeconds = parseInteger(opts.idleTtl ?? String(defaultIdleTtlSeconds), "idle TTL", { min: 60, max: 30 * 24 * 60 * 60 });
32
44
  return { mode: "idle", ttlSeconds };
33
45
  };
34
46
  const formatAutoDestroy = (policy) => {
@@ -93,7 +105,7 @@ async function putUploadEntry(entry, uploadUrl, headers) {
93
105
  }
94
106
  }
95
107
  async function uploadFiles(command, paths, opts) {
96
- const spaceId = requireSpace(command);
108
+ const spaceId = resolveSpace(command);
97
109
  const client = createClient();
98
110
  try {
99
111
  const files = await collectUploadFiles(paths);
@@ -117,7 +129,7 @@ async function uploadFiles(command, paths, opts) {
117
129
  const result = await client.space(spaceId).files.completeUpload(plan.uploadId, {
118
130
  entries: plan.entries.map((entry) => ({ id: entry.id })),
119
131
  });
120
- if (opts.json)
132
+ if (jsonRequested(opts))
121
133
  return outJson({ ...result, uploadId: plan.uploadId, files: files.length });
122
134
  ok(`Uploaded ${files.length} file${files.length === 1 ? "" : "s"}`);
123
135
  }
@@ -159,11 +171,11 @@ async function sendPrompt(command, words, opts) {
159
171
  return error("Conflicting schedule", "Use only one of --delay-ms, --at, or --cron");
160
172
  if (opts.cron && !opts.timezone)
161
173
  return error("Missing timezone", "--timezone is required with --cron");
162
- const spaceId = requireSpace(command);
174
+ const spaceId = resolveSpace(command);
163
175
  const client = createClient();
164
176
  try {
165
177
  const schedule = opts.delayMs
166
- ? { mode: "delay", delayMs: Number.parseInt(opts.delayMs, 10) }
178
+ ? { mode: "delay", delayMs: parseInteger(opts.delayMs, "delay", { min: 1 }) }
167
179
  : opts.at
168
180
  ? { mode: "at", sendAt: opts.at }
169
181
  : opts.cron
@@ -177,7 +189,7 @@ async function sendPrompt(command, words, opts) {
177
189
  provider: opts.provider,
178
190
  schedule,
179
191
  });
180
- if (opts.json)
192
+ if (jsonRequested(opts))
181
193
  return outJson(result);
182
194
  if (result.mode === "immediate")
183
195
  return ok(`Prompt sent — sessionId: ${result.sessionId}, turnId: ${result.turnId}`);
@@ -216,7 +228,7 @@ export function registerSpaces(program) {
216
228
  const client = createClient();
217
229
  try {
218
230
  const items = await client.spaces.list();
219
- if (opts.json)
231
+ if (jsonRequested(opts))
220
232
  return outJson(items);
221
233
  table(items, [
222
234
  { key: "id", label: "ID" },
@@ -237,7 +249,7 @@ export function registerSpaces(program) {
237
249
  const client = createClient();
238
250
  try {
239
251
  const space = await client.spaces.get(id);
240
- if (opts.json)
252
+ if (jsonRequested(opts))
241
253
  return outJson(space);
242
254
  table([space], [
243
255
  { key: "id", label: "ID" },
@@ -269,7 +281,7 @@ export function registerSpaces(program) {
269
281
  description: opts.description,
270
282
  ...(autoDestroy ? { config: { sandbox: { autoDestroy } } } : {}),
271
283
  });
272
- if (opts.json)
284
+ if (jsonRequested(opts))
273
285
  return outJson(result);
274
286
  ok(`Space created: ${result.space.id}`);
275
287
  table([result.space], [
@@ -302,12 +314,12 @@ export function registerSpaces(program) {
302
314
  .description("Upload the space avatar")
303
315
  .option("--json", "Output as JSON")
304
316
  .action(async (path, opts) => {
305
- const spaceId = requireSpace(spacesCmd);
317
+ const spaceId = resolveSpace(spacesCmd);
306
318
  const client = createClient();
307
319
  try {
308
320
  const asset = await uploadAvatarAsset({ client, purpose: "space_avatar", spaceId, path });
309
321
  const result = await client.space(spaceId).profile({ avatarUrl: asset.publicUrl });
310
- if (opts.json)
322
+ if (jsonRequested(opts))
311
323
  return outJson({ ...result, asset });
312
324
  ok("Space avatar updated");
313
325
  }
@@ -328,13 +340,13 @@ export function registerSpaces(program) {
328
340
  const autoDestroy = parseAutoDestroy(opts);
329
341
  if (autoDestroy) {
330
342
  const result = await client.space(id).updateConfig({ sandbox: { autoDestroy } });
331
- if (opts.json)
343
+ if (jsonRequested(opts))
332
344
  return outJson(result);
333
345
  ok(`Space config updated — sandbox auto destroy: ${formatAutoDestroy(autoDestroy)}`);
334
346
  return;
335
347
  }
336
348
  const result = await client.space(id).getConfig();
337
- if (opts.json)
349
+ if (jsonRequested(opts))
338
350
  return outJson(result);
339
351
  table([{ key: "sandbox.autoDestroy", value: formatAutoDestroy(result.config.sandbox.autoDestroy) }], [
340
352
  { key: "key", label: "Key" },
@@ -378,11 +390,11 @@ export function registerSpaces(program) {
378
390
  .description("Space usage statistics (default: 30 days)")
379
391
  .option("--json", "Output as JSON")
380
392
  .action(async (days, opts) => {
381
- const spaceId = requireSpace(spacesCmd);
393
+ const spaceId = resolveSpace(spacesCmd);
382
394
  const client = createClient();
383
395
  try {
384
- const usage = await client.space(spaceId).usage.get(Number.parseInt(days ?? "30", 10));
385
- if (opts.json)
396
+ const usage = await client.space(spaceId).usage.get(parseInteger(days ?? "30", "days", { min: 1 }));
397
+ if (jsonRequested(opts))
386
398
  return outJson(usage);
387
399
  console.log("\n Summary:");
388
400
  table([usage.summary], [
@@ -402,18 +414,18 @@ function registerMods(spacesCmd) {
402
414
  const modsCmd = spacesCmd
403
415
  .command("mods")
404
416
  .description("Manage space mods")
405
- .hook("preAction", () => { requireSpace(spacesCmd); });
417
+ .hook("preAction", () => { resolveSpace(spacesCmd); });
406
418
  modsCmd
407
419
  .command("ls")
408
420
  .alias("list")
409
421
  .description("List mods")
410
422
  .option("--json", "Output as JSON")
411
423
  .action(async (opts) => {
412
- const spaceId = requireSpace(spacesCmd);
424
+ const spaceId = resolveSpace(spacesCmd);
413
425
  const client = createClient();
414
426
  try {
415
427
  const result = await client.space(spaceId).mods.list();
416
- if (opts.json)
428
+ if (jsonRequested(opts))
417
429
  return outJson(result.items);
418
430
  table(result.items, [
419
431
  { key: "id", label: "ID" },
@@ -435,11 +447,11 @@ function registerMods(spacesCmd) {
435
447
  .option("--json", "Output as JSON")
436
448
  .action(async (modSpaceId, opts) => {
437
449
  await confirmRestart(opts);
438
- const spaceId = requireSpace(spacesCmd);
450
+ const spaceId = resolveSpace(spacesCmd);
439
451
  const client = createClient();
440
452
  try {
441
453
  const result = await client.space(spaceId).mods.create({ modSpaceId, name: opts.name, mountSlug: opts.slug });
442
- if (opts.json)
454
+ if (jsonRequested(opts))
443
455
  return outJson(result);
444
456
  ok(`Mod added — ${result.item.mountPath}; sandbox restarting`);
445
457
  }
@@ -454,11 +466,11 @@ function registerMods(spacesCmd) {
454
466
  .option("--json", "Output as JSON")
455
467
  .action(async (modId, opts) => {
456
468
  await confirmRestart(opts);
457
- const spaceId = requireSpace(spacesCmd);
469
+ const spaceId = resolveSpace(spacesCmd);
458
470
  const client = createClient();
459
471
  try {
460
472
  const result = await client.space(spaceId).mods.update(modId, { enabled: true });
461
- if (opts.json)
473
+ if (jsonRequested(opts))
462
474
  return outJson(result);
463
475
  ok("Mod enabled; sandbox restarting");
464
476
  }
@@ -473,11 +485,11 @@ function registerMods(spacesCmd) {
473
485
  .option("--json", "Output as JSON")
474
486
  .action(async (modId, opts) => {
475
487
  await confirmRestart(opts);
476
- const spaceId = requireSpace(spacesCmd);
488
+ const spaceId = resolveSpace(spacesCmd);
477
489
  const client = createClient();
478
490
  try {
479
491
  const result = await client.space(spaceId).mods.update(modId, { enabled: false });
480
- if (opts.json)
492
+ if (jsonRequested(opts))
481
493
  return outJson(result);
482
494
  ok("Mod disabled; sandbox restarting");
483
495
  }
@@ -493,11 +505,11 @@ function registerMods(spacesCmd) {
493
505
  .option("--json", "Output as JSON")
494
506
  .action(async (modId, opts) => {
495
507
  await confirmRestart(opts);
496
- const spaceId = requireSpace(spacesCmd);
508
+ const spaceId = resolveSpace(spacesCmd);
497
509
  const client = createClient();
498
510
  try {
499
511
  const result = await client.space(spaceId).mods.remove(modId);
500
- if (opts.json)
512
+ if (jsonRequested(opts))
501
513
  return outJson(result);
502
514
  ok("Mod removed; sandbox restarting");
503
515
  }
@@ -511,18 +523,18 @@ function registerFiles(spacesCmd) {
511
523
  const filesCmd = spacesCmd
512
524
  .command("files")
513
525
  .description("File operations")
514
- .hook("preAction", () => { requireSpace(spacesCmd); });
526
+ .hook("preAction", () => { resolveSpace(spacesCmd); });
515
527
  filesCmd
516
528
  .command("ls [path]")
517
529
  .alias("list")
518
530
  .description("List directory tree")
519
531
  .option("--json", "Output as JSON")
520
532
  .action(async (path, opts) => {
521
- const spaceId = requireSpace(spacesCmd);
533
+ const spaceId = resolveSpace(spacesCmd);
522
534
  const client = createClient();
523
535
  try {
524
536
  const tree = await client.space(spaceId).files.list(path ?? "");
525
- if (opts.json)
537
+ if (jsonRequested(opts))
526
538
  return outJson(tree);
527
539
  if (tree.entries.length === 0) {
528
540
  console.log(" (empty)");
@@ -543,7 +555,7 @@ function registerFiles(spacesCmd) {
543
555
  .command("cat <path>")
544
556
  .description("Read file content")
545
557
  .action(async (path) => {
546
- const spaceId = requireSpace(spacesCmd);
558
+ const spaceId = resolveSpace(spacesCmd);
547
559
  const client = createClient();
548
560
  try {
549
561
  const file = await client.space(spaceId).files.read(path);
@@ -573,7 +585,7 @@ function registerFiles(spacesCmd) {
573
585
  }
574
586
  if (!content)
575
587
  return error("No content provided", "Use -c or pipe via stdin");
576
- const spaceId = requireSpace(spacesCmd);
588
+ const spaceId = resolveSpace(spacesCmd);
577
589
  const client = createClient();
578
590
  try {
579
591
  const result = await client.space(spaceId).files.write({
@@ -597,7 +609,7 @@ function registerFiles(spacesCmd) {
597
609
  .command("mkdir <path>")
598
610
  .description("Create a directory")
599
611
  .action(async (path) => {
600
- const spaceId = requireSpace(spacesCmd);
612
+ const spaceId = resolveSpace(spacesCmd);
601
613
  const client = createClient();
602
614
  try {
603
615
  await client.space(spaceId).files.createDir(path);
@@ -612,7 +624,7 @@ function registerFiles(spacesCmd) {
612
624
  .description("Delete a file or directory")
613
625
  .option("-r, --recursive", "Delete recursively")
614
626
  .action(async (path, opts) => {
615
- const spaceId = requireSpace(spacesCmd);
627
+ const spaceId = resolveSpace(spacesCmd);
616
628
  const client = createClient();
617
629
  try {
618
630
  await client.space(spaceId).files.delete(path, opts.recursive ?? false);
@@ -626,7 +638,7 @@ function registerFiles(spacesCmd) {
626
638
  .command("mv <from> <to>")
627
639
  .description("Move or rename")
628
640
  .action(async (from, to) => {
629
- const spaceId = requireSpace(spacesCmd);
641
+ const spaceId = resolveSpace(spacesCmd);
630
642
  const client = createClient();
631
643
  try {
632
644
  await client.space(spaceId).files.move({ fromPath: from, toPath: to });
@@ -642,18 +654,18 @@ function registerSessions(spacesCmd) {
642
654
  const sessionsCmd = spacesCmd
643
655
  .command("sessions")
644
656
  .description("Browse sessions and turns")
645
- .hook("preAction", () => { requireSpace(spacesCmd); });
657
+ .hook("preAction", () => { resolveSpace(spacesCmd); });
646
658
  sessionsCmd
647
659
  .command("ls")
648
660
  .alias("list")
649
661
  .description("List sessions")
650
662
  .option("--json", "Output as JSON")
651
663
  .action(async (opts) => {
652
- const spaceId = requireSpace(spacesCmd);
664
+ const spaceId = resolveSpace(spacesCmd);
653
665
  const client = createClient();
654
666
  try {
655
667
  const result = await client.space(spaceId).sessions.list();
656
- if (opts.json)
668
+ if (jsonRequested(opts))
657
669
  return outJson(result);
658
670
  if (result.sessions.length === 0) {
659
671
  console.log(" (empty)");
@@ -675,11 +687,11 @@ function registerSessions(spacesCmd) {
675
687
  .description("Create a session")
676
688
  .option("--json", "Output as JSON")
677
689
  .action(async (title, opts) => {
678
- const spaceId = requireSpace(spacesCmd);
690
+ const spaceId = resolveSpace(spacesCmd);
679
691
  const client = createClient();
680
692
  try {
681
693
  const result = await client.space(spaceId).sessions.create({ title });
682
- if (opts.json)
694
+ if (jsonRequested(opts))
683
695
  return outJson(result);
684
696
  ok(`Session created: ${result.session.id}`);
685
697
  table([result.session], [
@@ -696,11 +708,11 @@ function registerSessions(spacesCmd) {
696
708
  .description("Session details")
697
709
  .option("--json", "Output as JSON")
698
710
  .action(async (id, opts) => {
699
- const spaceId = requireSpace(spacesCmd);
711
+ const spaceId = resolveSpace(spacesCmd);
700
712
  const client = createClient();
701
713
  try {
702
714
  const result = await client.space(spaceId).session(id).get();
703
- if (opts.json)
715
+ if (jsonRequested(opts))
704
716
  return outJson(result);
705
717
  table([result.session], [
706
718
  { key: "id", label: "ID" },
@@ -718,7 +730,7 @@ function registerSessions(spacesCmd) {
718
730
  .command("rename <id> <name>")
719
731
  .description("Rename a session")
720
732
  .action(async (id, name) => {
721
- const spaceId = requireSpace(spacesCmd);
733
+ const spaceId = resolveSpace(spacesCmd);
722
734
  const client = createClient();
723
735
  try {
724
736
  await client.space(spaceId).session(id).rename(name);
@@ -734,13 +746,13 @@ function registerSessions(spacesCmd) {
734
746
  .description("Stream realtime session events")
735
747
  .option("--json", "Output as JSON")
736
748
  .action(async (id, opts) => {
737
- const spaceId = requireSpace(spacesCmd);
749
+ const spaceId = resolveSpace(spacesCmd);
738
750
  const client = createClient();
739
751
  const session = client.space(spaceId).session(id);
740
752
  process.stdout.write(" Listening for events...\n\n");
741
753
  let lastAppendPath = null;
742
754
  session.on("turn.patch", (e) => {
743
- if (opts.json) {
755
+ if (jsonRequested(opts)) {
744
756
  console.log(JSON.stringify(e));
745
757
  }
746
758
  else {
@@ -767,7 +779,7 @@ function registerSessions(spacesCmd) {
767
779
  });
768
780
  session.on("turn.error", (e) => {
769
781
  process.stderr.write(`\n ✗ Error\n`);
770
- if (opts.json)
782
+ if (jsonRequested(opts))
771
783
  process.stderr.write(`${JSON.stringify(e)}\n`);
772
784
  process.exit(1);
773
785
  });
@@ -789,15 +801,15 @@ function registerTurns(sessionsCmd) {
789
801
  .option("--limit <n>", "Page size", "30")
790
802
  .option("--json", "Output as JSON")
791
803
  .action(async (sessionId, opts) => {
792
- const spaceId = requireSpace(sessionsCmd);
804
+ const spaceId = resolveSpace(sessionsCmd);
793
805
  const client = createClient();
794
806
  try {
795
807
  const result = await client.space(spaceId).session(sessionId).turns.listPaginated({
796
- cursor: opts.cursor === undefined ? undefined : Number.parseInt(opts.cursor, 10),
797
- direction: opts.direction,
798
- limit: Number.parseInt(opts.limit ?? "30", 10),
808
+ cursor: opts.cursor === undefined ? undefined : parseInteger(opts.cursor, "cursor", { min: 0 }),
809
+ direction: parseChoice(opts.direction ?? "older", "direction", ["older", "newer"]),
810
+ limit: parseInteger(opts.limit ?? "30", "limit", { min: 1, max: 100 }),
799
811
  });
800
- if (opts.json)
812
+ if (jsonRequested(opts))
801
813
  return outJson(result);
802
814
  if (result.turns.length === 0)
803
815
  return console.log(" No turns found");
@@ -821,11 +833,11 @@ function registerTurns(sessionsCmd) {
821
833
  .description("Show turn details")
822
834
  .option("--json", "Output as JSON")
823
835
  .action(async (sessionId, turnId, opts) => {
824
- const spaceId = requireSpace(sessionsCmd);
836
+ const spaceId = resolveSpace(sessionsCmd);
825
837
  const client = createClient();
826
838
  try {
827
839
  const result = await client.space(spaceId).session(sessionId).turns.get(turnId);
828
- if (opts.json)
840
+ if (jsonRequested(opts))
829
841
  return outJson(result);
830
842
  table([result.turn], [
831
843
  { key: "sequence", label: "Seq" },
@@ -852,14 +864,14 @@ function registerTurns(sessionsCmd) {
852
864
  .option("--limit <n>", "Page size", "100")
853
865
  .option("--json", "Output as JSON")
854
866
  .action(async (sessionId, opts) => {
855
- const spaceId = requireSpace(sessionsCmd);
867
+ const spaceId = resolveSpace(sessionsCmd);
856
868
  const client = createClient();
857
869
  try {
858
870
  const result = await client.space(spaceId).session(sessionId).turns.index({
859
- cursor: opts.cursor === undefined ? undefined : Number.parseInt(opts.cursor, 10),
860
- limit: Number.parseInt(opts.limit ?? "100", 10),
871
+ cursor: opts.cursor === undefined ? undefined : parseInteger(opts.cursor, "cursor", { min: 0 }),
872
+ limit: parseInteger(opts.limit ?? "100", "limit", { min: 1, max: 500 }),
861
873
  });
862
- if (opts.json)
874
+ if (jsonRequested(opts))
863
875
  return outJson(result);
864
876
  if (result.turns.length === 0)
865
877
  return console.log(" No turns found");
@@ -886,18 +898,18 @@ function registerTurns(sessionsCmd) {
886
898
  .option("--after <n>", "Turns after anchor", "20")
887
899
  .option("--json", "Output as JSON")
888
900
  .action(async (sessionId, opts) => {
889
- const spaceId = requireSpace(sessionsCmd);
901
+ const spaceId = resolveSpace(sessionsCmd);
890
902
  if (!opts.sequence && !opts.turn)
891
903
  return error("Missing anchor", "Use --sequence <n> or --turn <id>");
892
904
  const client = createClient();
893
905
  try {
894
906
  const result = await client.space(spaceId).session(sessionId).turns.window({
895
- sequence: opts.sequence === undefined ? undefined : Number.parseInt(opts.sequence, 10),
907
+ sequence: opts.sequence === undefined ? undefined : parseInteger(opts.sequence, "sequence", { min: 0 }),
896
908
  turnId: opts.turn,
897
- before: Number.parseInt(opts.before ?? "10", 10),
898
- after: Number.parseInt(opts.after ?? "20", 10),
909
+ before: parseInteger(opts.before ?? "10", "before", { min: 0, max: 200 }),
910
+ after: parseInteger(opts.after ?? "20", "after", { min: 0, max: 200 }),
899
911
  });
900
- if (opts.json)
912
+ if (jsonRequested(opts))
901
913
  return outJson(result);
902
914
  if (result.turns.length === 0)
903
915
  return console.log(" No turns found");
@@ -926,7 +938,7 @@ function registerSessionAccess(sessionsCmd) {
926
938
  const client = createClient();
927
939
  try {
928
940
  const policy = await client.sessionAccess.get(id);
929
- if (opts.json)
941
+ if (jsonRequested(opts))
930
942
  return outJson(policy);
931
943
  table([policy], [
932
944
  { key: "signed_in_user", label: "Signed-in" },
@@ -946,9 +958,9 @@ function registerSessionAccess(sessionsCmd) {
946
958
  const client = createClient();
947
959
  try {
948
960
  const policy = await client.sessionAccess.set(id, {
949
- anonymous_user: (opts.anonymous ?? null),
961
+ anonymous_user: parseNullableRole(opts.anonymous, "anonymous role"),
950
962
  });
951
- if (opts.json)
963
+ if (jsonRequested(opts))
952
964
  return outJson(policy);
953
965
  ok("Session access updated");
954
966
  table([policy], [
@@ -979,18 +991,18 @@ function registerMembers(spacesCmd) {
979
991
  const memCmd = spacesCmd
980
992
  .command("members")
981
993
  .description("Member management")
982
- .hook("preAction", () => { requireSpace(spacesCmd); });
994
+ .hook("preAction", () => { resolveSpace(spacesCmd); });
983
995
  memCmd
984
996
  .command("ls")
985
997
  .alias("list")
986
998
  .description("List space members")
987
999
  .option("--json", "Output as JSON")
988
1000
  .action(async (opts) => {
989
- const spaceId = requireSpace(spacesCmd);
1001
+ const spaceId = resolveSpace(spacesCmd);
990
1002
  const client = createClient();
991
1003
  try {
992
1004
  const result = await client.space(spaceId).members.list();
993
- if (opts.json)
1005
+ if (jsonRequested(opts))
994
1006
  return outJson(result);
995
1007
  if (result.items.length === 0) {
996
1008
  console.log(" (empty)");
@@ -1010,10 +1022,10 @@ function registerMembers(spacesCmd) {
1010
1022
  .command("update <userId> <role>")
1011
1023
  .description("Change member role (host | builder | guest)")
1012
1024
  .action(async (userId, role) => {
1013
- const spaceId = requireSpace(spacesCmd);
1025
+ const spaceId = resolveSpace(spacesCmd);
1014
1026
  const client = createClient();
1015
1027
  try {
1016
- await client.space(spaceId).members.update(userId, role);
1028
+ await client.space(spaceId).members.update(userId, parseChoice(role, "role", SPACE_ROLES));
1017
1029
  ok(`${userId} → ${role}`);
1018
1030
  }
1019
1031
  catch (e) {
@@ -1024,7 +1036,7 @@ function registerMembers(spacesCmd) {
1024
1036
  .command("remove <userId>")
1025
1037
  .description("Remove a member")
1026
1038
  .action(async (userId) => {
1027
- const spaceId = requireSpace(spacesCmd);
1039
+ const spaceId = resolveSpace(spacesCmd);
1028
1040
  const client = createClient();
1029
1041
  try {
1030
1042
  await client.space(spaceId).members.remove(userId);
@@ -1040,17 +1052,17 @@ function registerAccess(spacesCmd) {
1040
1052
  const accCmd = spacesCmd
1041
1053
  .command("access")
1042
1054
  .description("Access control")
1043
- .hook("preAction", () => { requireSpace(spacesCmd); });
1055
+ .hook("preAction", () => { resolveSpace(spacesCmd); });
1044
1056
  accCmd
1045
1057
  .command("get")
1046
1058
  .description("Get access policy")
1047
1059
  .option("--json", "Output as JSON")
1048
1060
  .action(async (opts) => {
1049
- const spaceId = requireSpace(spacesCmd);
1061
+ const spaceId = resolveSpace(spacesCmd);
1050
1062
  const client = createClient();
1051
1063
  try {
1052
1064
  const policy = await client.space(spaceId).access.get();
1053
- if (opts.json)
1065
+ if (jsonRequested(opts))
1054
1066
  return outJson(policy);
1055
1067
  table([policy], [
1056
1068
  { key: "signed_in_user", label: "Signed-in" },
@@ -1068,14 +1080,14 @@ function registerAccess(spacesCmd) {
1068
1080
  .option("--anonymous <role>", "Role for anonymous users (host|builder|guest|null)")
1069
1081
  .option("--json", "Output as JSON")
1070
1082
  .action(async (opts) => {
1071
- const spaceId = requireSpace(spacesCmd);
1083
+ const spaceId = resolveSpace(spacesCmd);
1072
1084
  const client = createClient();
1073
1085
  try {
1074
1086
  const policy = await client.space(spaceId).access.set({
1075
- signed_in_user: (opts.signedIn ?? null),
1076
- anonymous_user: (opts.anonymous ?? null),
1087
+ signed_in_user: parseNullableRole(opts.signedIn, "signed-in role"),
1088
+ anonymous_user: parseNullableRole(opts.anonymous, "anonymous role"),
1077
1089
  });
1078
- if (opts.json)
1090
+ if (jsonRequested(opts))
1079
1091
  return outJson(policy);
1080
1092
  ok("Access policy updated");
1081
1093
  table([policy], [
@@ -1093,18 +1105,18 @@ function registerCheckpoints(spacesCmd) {
1093
1105
  const cpCmd = spacesCmd
1094
1106
  .command("checkpoints")
1095
1107
  .description("Checkpoint management")
1096
- .hook("preAction", () => { requireSpace(spacesCmd); });
1108
+ .hook("preAction", () => { resolveSpace(spacesCmd); });
1097
1109
  cpCmd
1098
1110
  .command("ls")
1099
1111
  .alias("list")
1100
1112
  .description("List checkpoints")
1101
1113
  .option("--json", "Output as JSON")
1102
1114
  .action(async (opts) => {
1103
- const spaceId = requireSpace(spacesCmd);
1115
+ const spaceId = resolveSpace(spacesCmd);
1104
1116
  const client = createClient();
1105
1117
  try {
1106
1118
  const result = await client.space(spaceId).checkpoints.list();
1107
- if (opts.json)
1119
+ if (jsonRequested(opts))
1108
1120
  return outJson(result);
1109
1121
  if (result.checkpoints.length === 0) {
1110
1122
  console.log(" (empty)");
@@ -1126,11 +1138,11 @@ function registerCheckpoints(spacesCmd) {
1126
1138
  .description("Checkpoint details")
1127
1139
  .option("--json", "Output as JSON")
1128
1140
  .action(async (id, opts) => {
1129
- const spaceId = requireSpace(spacesCmd);
1141
+ const spaceId = resolveSpace(spacesCmd);
1130
1142
  const client = createClient();
1131
1143
  try {
1132
1144
  const result = await client.space(spaceId).checkpoints.get(id);
1133
- if (opts.json)
1145
+ if (jsonRequested(opts))
1134
1146
  return outJson(result);
1135
1147
  table([result.checkpoint], [
1136
1148
  { key: "id", label: "ID" },
@@ -1149,11 +1161,11 @@ function registerCheckpoints(spacesCmd) {
1149
1161
  .description("Create a checkpoint")
1150
1162
  .option("--json", "Output as JSON")
1151
1163
  .action(async (description, opts) => {
1152
- const spaceId = requireSpace(spacesCmd);
1164
+ const spaceId = resolveSpace(spacesCmd);
1153
1165
  const client = createClient();
1154
1166
  try {
1155
1167
  const result = await client.space(spaceId).checkpoints.create(description ?? null);
1156
- if (opts.json)
1168
+ if (jsonRequested(opts))
1157
1169
  return outJson(result);
1158
1170
  ok(`Checkpoint created — taskRunId: ${result.taskRunId}`);
1159
1171
  }
@@ -1,5 +1,5 @@
1
1
  import { createClient } from "../client.js";
2
- import { table, json as outJson, handleHttp } from "../output.js";
2
+ import { table, json as outJson, jsonRequested, handleHttp } from "../output.js";
3
3
  export function registerTasks(program) {
4
4
  const cmd = program.command("tasks", { hidden: true }).description("Task runs");
5
5
  cmd
@@ -18,7 +18,7 @@ export function registerTasks(program) {
18
18
  if (opts.space)
19
19
  filters.spaceId = opts.space;
20
20
  const result = await client.tasks.list(filters);
21
- if (opts.json)
21
+ if (jsonRequested(opts))
22
22
  return outJson(result);
23
23
  if (result.runs.length === 0)
24
24
  return console.log(" (empty)");
@@ -41,7 +41,7 @@ export function registerTasks(program) {
41
41
  const client = createClient();
42
42
  try {
43
43
  const result = await client.tasks.get(id);
44
- if (opts.json)
44
+ if (jsonRequested(opts))
45
45
  return outJson(result);
46
46
  table([result.run], [
47
47
  { key: "id", label: "ID" },
package/dist/output.d.ts CHANGED
@@ -4,6 +4,9 @@ export declare function table(rows: Row[], columns: {
4
4
  label: string;
5
5
  }[]): void;
6
6
  export declare function json(data: unknown): void;
7
+ export declare function jsonRequested(opts?: {
8
+ json?: boolean;
9
+ }): boolean;
7
10
  export declare function ok(msg: string): void;
8
11
  export declare function error(msg: string, detail?: string): never;
9
12
  export declare function handleHttp(e: unknown): never;
package/dist/output.js CHANGED
@@ -35,6 +35,9 @@ export function table(rows, columns) {
35
35
  export function json(data) {
36
36
  console.log(JSON.stringify(data, null, 2));
37
37
  }
38
+ export function jsonRequested(opts) {
39
+ return Boolean(opts?.json || process.argv.includes("--json"));
40
+ }
38
41
  export function ok(msg) {
39
42
  console.log(`\n ✓ ${msg}\n`);
40
43
  }
@@ -0,0 +1,2 @@
1
+ import type { Command } from "commander";
2
+ export declare function resolveSpace(program: Command): string;
package/dist/space.js ADDED
@@ -0,0 +1,14 @@
1
+ import { error } from "./output.js";
2
+ export function resolveSpace(program) {
3
+ let current = program;
4
+ while (current) {
5
+ const opts = current.opts();
6
+ if (typeof opts.space === "string" && opts.space.trim())
7
+ return opts.space.trim();
8
+ current = current.parent ?? null;
9
+ }
10
+ const envSpace = process.env.COHUB_SPACE_ID?.trim();
11
+ if (envSpace)
12
+ return envSpace;
13
+ return error("Missing required space", "Add -s, --space <id> or set COHUB_SPACE_ID.");
14
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neta-art/cohub-cli",
3
- "version": "1.7.1",
3
+ "version": "1.8.0",
4
4
  "description": "CLI for Cohub — spaces, sessions, and agent collaboration.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -15,7 +15,7 @@
15
15
  "dependencies": {
16
16
  "commander": "^13.1.0",
17
17
  "sharp": "^0.34.5",
18
- "@neta-art/cohub": "1.15.1"
18
+ "@neta-art/cohub": "1.16.0"
19
19
  },
20
20
  "publishConfig": {
21
21
  "access": "public"