@neta-art/cohub-cli 1.17.4 → 1.18.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
@@ -173,6 +173,29 @@ cohub -s <spaceId> spaces files rm <path>
173
173
 
174
174
  Confirm before deleting files or directories.
175
175
 
176
+ ## Works
177
+
178
+ Publish and manage Work entries from a Space workspace.
179
+
180
+ ```bash
181
+ cohub -s <spaceId> works ls --json
182
+ cohub works get <workId> --json
183
+ cohub -s <spaceId> works publish demo --file dist/index.html
184
+ cohub -s <spaceId> works publish site --dir dist
185
+ cohub -s <spaceId> works publish app --port 3000
186
+ cohub works update <workId> --publish-version
187
+ cohub works versions <workId> --json
188
+ cohub works rm <workId> --yes
189
+ ```
190
+
191
+ Resolve a published Work by public identity:
192
+
193
+ ```bash
194
+ cohub works resolve <workSlug> --owner <username> --space-slug <spaceSlug>
195
+ ```
196
+
197
+ Use `--json` for machine-readable output. The resolve command requires both `--owner` and `--space-slug` so missing public profile data fails with a clear message.
198
+
176
199
  ## Saves
177
200
 
178
201
  ```bash
@@ -210,7 +233,7 @@ Confirm before enabling, disabling, or deleting recurring scheduled prompts.
210
233
 
211
234
  Confirm before:
212
235
 
213
- - deleting files or directories
236
+ - deleting files, directories, or Works
214
237
  - creating scheduled or recurring prompts with side effects
215
238
  - enabling, disabling, or deleting recurring scheduled prompts
216
239
  - changing access policies, member roles, or membership
package/dist/avatar.d.ts CHANGED
@@ -13,3 +13,18 @@ export declare function uploadAvatarAsset(input: {
13
13
  uploadUrl: string;
14
14
  uploadFields: Record<string, string>;
15
15
  }>;
16
+ export declare function normalizeChatImageFile(path: string): Promise<Buffer>;
17
+ export declare function uploadChatImageAsset(input: {
18
+ client: CohubHttpClient;
19
+ spaceId: string;
20
+ sessionId: string;
21
+ path: string;
22
+ }): Promise<{
23
+ purpose: PublicAssetPurpose;
24
+ objectKey: string;
25
+ publicUrl: string;
26
+ uploadMethod: "POST";
27
+ uploadUrl: string;
28
+ uploadFields: Record<string, string>;
29
+ size: number;
30
+ }>;
package/dist/avatar.js CHANGED
@@ -10,26 +10,31 @@ export async function normalizeAvatarFile(path) {
10
10
  }
11
11
  export async function uploadAvatarAsset(input) {
12
12
  const body = await normalizeAvatarFile(input.path);
13
- const plan = await input.client.publicAssets.createUpload({
13
+ return input.client.publicAssets.upload({
14
14
  purpose: input.purpose,
15
15
  spaceId: input.spaceId,
16
- file: {
17
- size: body.byteLength,
18
- mimeType: "image/webp",
19
- },
16
+ file: new Blob([new Uint8Array(body)], { type: "image/webp" }),
17
+ mimeType: "image/webp",
18
+ filename: "avatar.webp",
20
19
  });
21
- const formData = new FormData();
22
- for (const [key, value] of Object.entries(plan.asset.uploadFields)) {
23
- formData.append(key, value);
24
- }
25
- formData.append("file", new Blob([new Uint8Array(body)], { type: "image/webp" }), "avatar.webp");
26
- const response = await fetch(plan.asset.uploadUrl, {
27
- method: plan.asset.uploadMethod,
28
- body: formData,
20
+ }
21
+ const CHAT_IMAGE_MAX_EDGE = 2160;
22
+ const CHAT_IMAGE_QUALITY = 86;
23
+ export async function normalizeChatImageFile(path) {
24
+ return sharp(path)
25
+ .rotate()
26
+ .resize(CHAT_IMAGE_MAX_EDGE, CHAT_IMAGE_MAX_EDGE, { fit: "inside", withoutEnlargement: true })
27
+ .webp({ quality: CHAT_IMAGE_QUALITY })
28
+ .toBuffer();
29
+ }
30
+ export async function uploadChatImageAsset(input) {
31
+ const body = await normalizeChatImageFile(input.path);
32
+ const asset = await input.client.publicAssets.uploadChatImageAttachment({
33
+ spaceId: input.spaceId,
34
+ sessionId: input.sessionId,
35
+ file: new Blob([new Uint8Array(body)], { type: "image/webp" }),
36
+ mimeType: "image/webp",
37
+ filename: "image.webp",
29
38
  });
30
- if (!response.ok) {
31
- const detail = await response.text().catch(() => "");
32
- throw new Error(`Avatar upload failed: HTTP ${response.status}${detail ? ` — ${detail}` : ""}`);
33
- }
34
- return plan.asset;
39
+ return { ...asset, size: body.byteLength };
35
40
  }
@@ -3,7 +3,7 @@ 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
5
  import { resolveCohubEnvironment } from "@neta-art/cohub";
6
- import { uploadAvatarAsset } from "../avatar.js";
6
+ import { uploadAvatarAsset, uploadChatImageAsset } from "../avatar.js";
7
7
  import { createClient } from "../client.js";
8
8
  import { table, json as outJson, jsonRequested, ok, error, handleHttp } from "../output.js";
9
9
  import { resolveSpace } from "../space.js";
@@ -26,6 +26,21 @@ function parseInteger(value, name, options = {}) {
26
26
  function collectOption(value, previous = []) {
27
27
  return [...previous, value];
28
28
  }
29
+ function parseEnvOptions(values) {
30
+ if (!values?.length)
31
+ return undefined;
32
+ const env = {};
33
+ for (const value of values) {
34
+ const index = value.indexOf("=");
35
+ if (index <= 0)
36
+ return error("Invalid env", "Use --env KEY=value");
37
+ const name = value.slice(0, index).trim();
38
+ if (!name)
39
+ return error("Invalid env", "Env name is required");
40
+ env[name] = value.slice(index + 1);
41
+ }
42
+ return env;
43
+ }
29
44
  function parseChoice(value, name, choices) {
30
45
  if (choices.includes(value))
31
46
  return value;
@@ -156,7 +171,7 @@ async function confirmRestart(opts) {
156
171
  if (answer !== "y" && answer !== "yes")
157
172
  return error("Cancelled");
158
173
  }
159
- async function readPromptContent(words) {
174
+ async function readPromptContent(words, options = {}) {
160
175
  let content = words.join(" ");
161
176
  if (!content && !process.stdin.isTTY) {
162
177
  const chunks = [];
@@ -164,12 +179,12 @@ async function readPromptContent(words) {
164
179
  chunks.push(chunk);
165
180
  content = Buffer.concat(chunks).toString().trim();
166
181
  }
167
- if (!content)
182
+ if (!content && !options.allowEmpty)
168
183
  return error("No content", "Pass as argument or pipe via stdin");
169
184
  return content;
170
185
  }
171
186
  async function sendPrompt(command, words, opts) {
172
- const content = await readPromptContent(words);
187
+ const content = await readPromptContent(words, { allowEmpty: Boolean(opts.image?.length) });
173
188
  const scheduleFlags = [opts.delayMs, opts.at, opts.cron].filter((value) => value !== undefined);
174
189
  if (scheduleFlags.length > 1)
175
190
  return error("Conflicting schedule", "Use only one of --delay-ms, --at, or --cron");
@@ -185,15 +200,40 @@ async function sendPrompt(command, words, opts) {
185
200
  : opts.cron
186
201
  ? { mode: "repeat", cronExpression: opts.cron, timezone: opts.timezone }
187
202
  : undefined;
203
+ const sessionId = opts.session;
204
+ const imagePaths = opts.image ?? [];
205
+ const imageSessionId = imagePaths.length
206
+ ? sessionId ?? error("Missing session", "Pass --session when attaching images.")
207
+ : "";
208
+ const imageBlocks = imagePaths.length
209
+ ? await Promise.all(imagePaths.map(async (path) => {
210
+ const asset = await uploadChatImageAsset({ client, spaceId, sessionId: imageSessionId, path });
211
+ return {
212
+ type: "image",
213
+ source: { type: "url", url: asset.publicUrl },
214
+ _meta: {
215
+ filename: basename(path),
216
+ mediaType: "image/webp",
217
+ size: asset.size,
218
+ objectKey: asset.objectKey,
219
+ },
220
+ };
221
+ }))
222
+ : [];
223
+ const promptContent = [
224
+ ...(content ? [{ type: "text", text: content }] : []),
225
+ ...imageBlocks,
226
+ ];
188
227
  const result = await client.space(spaceId).prompt({
189
- sessionId: opts.session,
190
- title: opts.title,
228
+ sessionId,
229
+ title: sessionId === opts.session ? opts.title : undefined,
191
230
  source: opts.source?.trim() || "cli",
192
- content: [{ type: "text", text: content }],
231
+ content: promptContent,
193
232
  model: opts.model,
194
233
  provider: opts.provider,
195
234
  accessMode: opts.readOnly ? "read_only" : "full_access",
196
235
  intent: opts.steer ? "steer" : undefined,
236
+ env: parseEnvOptions(opts.env),
197
237
  schedule,
198
238
  labelRefs: opts.label?.length ? opts.label : undefined,
199
239
  });
@@ -225,6 +265,8 @@ export function registerPrompt(program) {
225
265
  .option("--cron <expression>", "Repeat using a 5-field cron expression")
226
266
  .option("--timezone <tz>", "IANA timezone for --cron, e.g. Asia/Shanghai")
227
267
  .option("--label <ref>", "Attach a label, e.g. Bug or Area/Frontend", collectOption, [])
268
+ .option("--env <key=value>", "Set an environment variable for this turn", collectOption, [])
269
+ .option("--image <path>", "Attach an image", collectOption, [])
228
270
  .option("--json", "Output as JSON")
229
271
  .action((words, opts) => sendPrompt(program, words, opts));
230
272
  }
@@ -385,6 +427,8 @@ export function registerSpaces(program) {
385
427
  .option("--cron <expression>", "Repeat using a 5-field cron expression")
386
428
  .option("--timezone <tz>", "IANA timezone for --cron, e.g. Asia/Shanghai")
387
429
  .option("--label <ref>", "Attach a label, e.g. Bug or Area/Frontend", collectOption, [])
430
+ .option("--env <key=value>", "Set an environment variable for this turn", collectOption, [])
431
+ .option("--image <path>", "Attach an image", collectOption, [])
388
432
  .option("--json", "Output as JSON")
389
433
  .action((words, opts) => sendPrompt(spacesCmd, words, opts));
390
434
  // ── spaces files ──
@@ -0,0 +1,2 @@
1
+ import type { Command } from "commander";
2
+ export declare function registerWorks(program: Command): void;
@@ -0,0 +1,266 @@
1
+ import { createClient } from "../client.js";
2
+ import { error, handleHttp, json as outJson, jsonRequested, ok, table } from "../output.js";
3
+ import { resolveSpace } from "../space.js";
4
+ const WORK_STATUSES = ["draft", "published", "disabled"];
5
+ const collectOption = (value, previous = []) => [...previous, value];
6
+ function parseChoice(value, name, choices) {
7
+ if (choices.includes(value))
8
+ return value;
9
+ return error(`Invalid ${name}`, `Use one of: ${choices.join(", ")}`);
10
+ }
11
+ function parseJsonObject(value, name) {
12
+ if (!value)
13
+ return undefined;
14
+ try {
15
+ const parsed = JSON.parse(value);
16
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
17
+ return parsed;
18
+ }
19
+ catch {
20
+ // handled below
21
+ }
22
+ return error(`Invalid ${name}`, `${name} must be a JSON object`);
23
+ }
24
+ function compactObject(input) {
25
+ return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined));
26
+ }
27
+ function resolveTarget(opts) {
28
+ const targets = [
29
+ opts.file ? { targetType: "file", targetRef: opts.file } : null,
30
+ opts.dir ? { targetType: "directory", targetRef: opts.dir } : null,
31
+ opts.port ? { targetType: "port", targetRef: opts.port } : null,
32
+ ].filter((target) => Boolean(target));
33
+ if (targets.length === 0)
34
+ return null;
35
+ if (targets.length > 1)
36
+ return error("Conflicting target", "Use only one of --file, --dir, or --port");
37
+ return targets[0] ?? null;
38
+ }
39
+ function resolveStatus(opts) {
40
+ const values = [opts.status, opts.draft ? "draft" : undefined, opts.disabled ? "disabled" : undefined].filter(Boolean);
41
+ if (values.length > 1)
42
+ return error("Conflicting status", "Use only one of --status, --draft, or --disabled");
43
+ return values[0] ? parseChoice(values[0], "status", WORK_STATUSES) : "published";
44
+ }
45
+ function printWork(work) {
46
+ table([work], [
47
+ { key: "id", label: "ID" },
48
+ { key: "slug", label: "Slug" },
49
+ { key: "status", label: "Status" },
50
+ { key: "targetType", label: "Target" },
51
+ { key: "targetRef", label: "Ref" },
52
+ { key: "latestVersion", label: "Version" },
53
+ { key: "publishedAt", label: "Published" },
54
+ ]);
55
+ }
56
+ async function confirmDelete(opts) {
57
+ if (opts.yes)
58
+ return;
59
+ if (!process.stdin.isTTY || !process.stdout.isTTY)
60
+ return error("Confirmation required", "Pass --yes to delete the work.");
61
+ process.stdout.write("Deleting a work also removes its versions and viewer grants. Continue? [y/N] ");
62
+ const chunks = [];
63
+ for await (const chunk of process.stdin) {
64
+ chunks.push(chunk);
65
+ break;
66
+ }
67
+ const answer = Buffer.concat(chunks).toString().trim().toLowerCase();
68
+ if (answer !== "y" && answer !== "yes")
69
+ return error("Cancelled");
70
+ }
71
+ export function registerWorks(program) {
72
+ const worksCmd = program.command("works").description("Work management");
73
+ worksCmd
74
+ .command("ls")
75
+ .alias("list")
76
+ .description("List works in the target space")
77
+ .option("--json", "Output as JSON")
78
+ .action(async (opts) => {
79
+ const spaceId = resolveSpace(worksCmd);
80
+ const client = createClient();
81
+ try {
82
+ const result = await client.works.listBySpace(spaceId);
83
+ if (jsonRequested(opts))
84
+ return outJson(result);
85
+ table(result.works, [
86
+ { key: "id", label: "ID" },
87
+ { key: "slug", label: "Slug" },
88
+ { key: "status", label: "Status" },
89
+ { key: "targetType", label: "Target" },
90
+ { key: "targetRef", label: "Ref" },
91
+ { key: "latestVersion", label: "Version" },
92
+ { key: "publishedAt", label: "Published" },
93
+ ]);
94
+ }
95
+ catch (e) {
96
+ handleHttp(e);
97
+ }
98
+ });
99
+ worksCmd
100
+ .command("get <id>")
101
+ .description("Show work details")
102
+ .option("--json", "Output as JSON")
103
+ .action(async (id, opts) => {
104
+ const client = createClient();
105
+ try {
106
+ const result = await client.works.get(id);
107
+ if (jsonRequested(opts))
108
+ return outJson(result);
109
+ printWork(result.work);
110
+ }
111
+ catch (e) {
112
+ handleHttp(e);
113
+ }
114
+ });
115
+ worksCmd
116
+ .command("resolve <workSlug>")
117
+ .description("Resolve a published work by owner and space slug")
118
+ .option("--owner <username>", "Owner username")
119
+ .option("--space-slug <slug>", "Space slug")
120
+ .option("--json", "Output as JSON")
121
+ .action(async (workSlug, opts) => {
122
+ if (!opts.owner?.trim())
123
+ return error("Missing owner username", "Pass --owner <username>.");
124
+ if (!opts.spaceSlug?.trim())
125
+ return error("Missing space slug", "Pass --space-slug <slug>.");
126
+ const client = createClient();
127
+ try {
128
+ const result = await client.works.getBySlug(opts.owner.trim(), opts.spaceSlug.trim(), workSlug);
129
+ if (jsonRequested(opts))
130
+ return outJson(result);
131
+ printWork(result.work);
132
+ if (result.content?.url)
133
+ console.log(`\nURL: ${result.content.url}`);
134
+ }
135
+ catch (e) {
136
+ handleHttp(e);
137
+ }
138
+ });
139
+ worksCmd
140
+ .command("publish <slug>")
141
+ .description("Create or publish a work in the target space")
142
+ .option("--file <path>", "Publish a HTML file")
143
+ .option("--dir <path>", "Publish a directory site")
144
+ .option("--port <port>", "Publish a public sandbox port")
145
+ .option("--draft", "Create as draft")
146
+ .option("--disabled", "Create as disabled")
147
+ .option("--status <status>", "Work status: draft, published, disabled")
148
+ .option("--work-scope <scope>", "Scope granted to the work runtime", collectOption, [])
149
+ .option("--viewer-scope <scope>", "Scope viewers may request", collectOption, [])
150
+ .option("--meta <json>", "Work metadata as a JSON object")
151
+ .option("--json", "Output as JSON")
152
+ .action(async (slug, opts) => {
153
+ const target = resolveTarget(opts);
154
+ if (!target)
155
+ return error("Missing target", "Use one of --file, --dir, or --port.");
156
+ const spaceId = resolveSpace(worksCmd);
157
+ const client = createClient();
158
+ const status = resolveStatus(opts);
159
+ const input = {
160
+ spaceId,
161
+ slug,
162
+ status,
163
+ targetType: target.targetType,
164
+ targetRef: target.targetRef,
165
+ workScopes: opts.workScope,
166
+ allowedViewerScopes: opts.viewerScope,
167
+ meta: parseJsonObject(opts.meta, "meta"),
168
+ };
169
+ try {
170
+ const result = await client.works.create(input);
171
+ if (jsonRequested(opts))
172
+ return outJson(result);
173
+ ok(`Work published: ${result.work.id}`);
174
+ printWork(result.work);
175
+ }
176
+ catch (e) {
177
+ handleHttp(e);
178
+ }
179
+ });
180
+ worksCmd
181
+ .command("update <id>")
182
+ .description("Update work settings or publish a new version")
183
+ .option("--slug <slug>", "New work slug")
184
+ .option("--file <path>", "Use a HTML file target")
185
+ .option("--dir <path>", "Use a directory site target")
186
+ .option("--port <port>", "Use a public sandbox port target")
187
+ .option("--draft", "Set status to draft")
188
+ .option("--disabled", "Set status to disabled")
189
+ .option("--status <status>", "Work status: draft, published, disabled")
190
+ .option("--publish-version", "Force publishing a new version")
191
+ .option("--work-scope <scope>", "Scope granted to the work runtime", collectOption, [])
192
+ .option("--viewer-scope <scope>", "Scope viewers may request", collectOption, [])
193
+ .option("--clear-work-scopes", "Clear work runtime scopes")
194
+ .option("--clear-viewer-scopes", "Clear viewer-requestable scopes")
195
+ .option("--meta <json>", "Work metadata as a JSON object")
196
+ .option("--json", "Output as JSON")
197
+ .action(async (id, opts) => {
198
+ const target = resolveTarget(opts);
199
+ if (opts.clearWorkScopes && opts.workScope?.length)
200
+ return error("Conflicting work scopes", "Use either --work-scope or --clear-work-scopes.");
201
+ if (opts.clearViewerScopes && opts.viewerScope?.length)
202
+ return error("Conflicting viewer scopes", "Use either --viewer-scope or --clear-viewer-scopes.");
203
+ const input = compactObject({
204
+ slug: opts.slug,
205
+ status: opts.status || opts.draft || opts.disabled ? resolveStatus(opts) : undefined,
206
+ targetType: target?.targetType,
207
+ targetRef: target?.targetRef,
208
+ publishVersion: opts.publishVersion || undefined,
209
+ workScopes: opts.clearWorkScopes ? [] : opts.workScope?.length ? opts.workScope : undefined,
210
+ allowedViewerScopes: opts.clearViewerScopes ? [] : opts.viewerScope?.length ? opts.viewerScope : undefined,
211
+ meta: opts.meta !== undefined ? parseJsonObject(opts.meta, "meta") ?? null : undefined,
212
+ });
213
+ if (Object.keys(input).length === 0)
214
+ return error("Nothing to update", "Pass --slug, --file, --dir, --port, --status, --publish-version, --work-scope, --viewer-scope, --clear-work-scopes, --clear-viewer-scopes, or --meta.");
215
+ const client = createClient();
216
+ try {
217
+ const result = await client.works.update(id, input);
218
+ if (jsonRequested(opts))
219
+ return outJson(result);
220
+ ok("Work updated");
221
+ printWork(result.work);
222
+ }
223
+ catch (e) {
224
+ handleHttp(e);
225
+ }
226
+ });
227
+ worksCmd
228
+ .command("versions <id>")
229
+ .description("List work versions")
230
+ .option("--json", "Output as JSON")
231
+ .action(async (id, opts) => {
232
+ const client = createClient();
233
+ try {
234
+ const result = await client.works.listVersions(id);
235
+ if (jsonRequested(opts))
236
+ return outJson(result);
237
+ table(result.versions, [
238
+ { key: "version", label: "Version" },
239
+ { key: "id", label: "ID" },
240
+ { key: "status", label: "Status" },
241
+ { key: "targetType", label: "Target" },
242
+ { key: "targetRef", label: "Ref" },
243
+ { key: "publishedAt", label: "Published" },
244
+ ]);
245
+ }
246
+ catch (e) {
247
+ handleHttp(e);
248
+ }
249
+ });
250
+ worksCmd
251
+ .command("rm <id>")
252
+ .alias("delete")
253
+ .description("Delete a work")
254
+ .option("-y, --yes", "Confirm deletion")
255
+ .action(async (id, opts) => {
256
+ await confirmDelete(opts);
257
+ const client = createClient();
258
+ try {
259
+ await client.works.delete(id);
260
+ ok("Work deleted");
261
+ }
262
+ catch (e) {
263
+ handleHttp(e);
264
+ }
265
+ });
266
+ }
package/dist/index.js CHANGED
@@ -10,6 +10,7 @@ import { registerProfile } from "./commands/profile.js";
10
10
  import { registerSearch } from "./commands/search.js";
11
11
  import { registerPrompt, registerSpaces } from "./commands/spaces.js";
12
12
  import { registerTasks } from "./commands/tasks.js";
13
+ import { registerWorks } from "./commands/works.js";
13
14
  import { ensureCliSelfUpdated } from "./self-update.js";
14
15
  const VERSION = (() => {
15
16
  try {
@@ -39,6 +40,7 @@ Common commands:
39
40
  cohub search "release notes"
40
41
  cohub -s <space-id> spaces sessions turns ls <session-id>
41
42
  cohub -s <space-id> spaces files ls
43
+ cohub -s <space-id> works publish demo --file dist/index.html
42
44
  cohub models ls
43
45
  cohub models ls --model-type multimodal
44
46
  cohub generate "A calm lake at sunrise" --model <model> --output lake.png
@@ -57,6 +59,7 @@ registerModels(program);
57
59
  registerSearch(program);
58
60
  registerTasks(program);
59
61
  registerCronJobs(program);
62
+ registerWorks(program);
60
63
  const isVersionRequest = (argv) => argv.some((arg) => arg === "-v" || arg === "--version");
61
64
  try {
62
65
  if (!isVersionRequest(process.argv.slice(2))) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neta-art/cohub-cli",
3
- "version": "1.17.4",
3
+ "version": "1.18.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.5",
17
17
  "commander": "^14.0.3",
18
18
  "sharp": "^0.34.5",
19
- "@neta-art/cohub": "1.28.1"
19
+ "@neta-art/cohub": "1.29.0"
20
20
  },
21
21
  "publishConfig": {
22
22
  "access": "public"