@neta-art/cohub-cli 1.17.3 → 1.17.5

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";
@@ -156,7 +156,7 @@ async function confirmRestart(opts) {
156
156
  if (answer !== "y" && answer !== "yes")
157
157
  return error("Cancelled");
158
158
  }
159
- async function readPromptContent(words) {
159
+ async function readPromptContent(words, options = {}) {
160
160
  let content = words.join(" ");
161
161
  if (!content && !process.stdin.isTTY) {
162
162
  const chunks = [];
@@ -164,12 +164,12 @@ async function readPromptContent(words) {
164
164
  chunks.push(chunk);
165
165
  content = Buffer.concat(chunks).toString().trim();
166
166
  }
167
- if (!content)
167
+ if (!content && !options.allowEmpty)
168
168
  return error("No content", "Pass as argument or pipe via stdin");
169
169
  return content;
170
170
  }
171
171
  async function sendPrompt(command, words, opts) {
172
- const content = await readPromptContent(words);
172
+ const content = await readPromptContent(words, { allowEmpty: Boolean(opts.image?.length) });
173
173
  const scheduleFlags = [opts.delayMs, opts.at, opts.cron].filter((value) => value !== undefined);
174
174
  if (scheduleFlags.length > 1)
175
175
  return error("Conflicting schedule", "Use only one of --delay-ms, --at, or --cron");
@@ -185,11 +185,35 @@ async function sendPrompt(command, words, opts) {
185
185
  : opts.cron
186
186
  ? { mode: "repeat", cronExpression: opts.cron, timezone: opts.timezone }
187
187
  : undefined;
188
+ const sessionId = opts.session;
189
+ const imagePaths = opts.image ?? [];
190
+ const imageSessionId = imagePaths.length
191
+ ? sessionId ?? error("Missing session", "Pass --session when attaching images.")
192
+ : "";
193
+ const imageBlocks = imagePaths.length
194
+ ? await Promise.all(imagePaths.map(async (path) => {
195
+ const asset = await uploadChatImageAsset({ client, spaceId, sessionId: imageSessionId, path });
196
+ return {
197
+ type: "image",
198
+ source: { type: "url", url: asset.publicUrl },
199
+ _meta: {
200
+ filename: basename(path),
201
+ mediaType: "image/webp",
202
+ size: asset.size,
203
+ objectKey: asset.objectKey,
204
+ },
205
+ };
206
+ }))
207
+ : [];
208
+ const promptContent = [
209
+ ...(content ? [{ type: "text", text: content }] : []),
210
+ ...imageBlocks,
211
+ ];
188
212
  const result = await client.space(spaceId).prompt({
189
- sessionId: opts.session,
190
- title: opts.title,
213
+ sessionId,
214
+ title: sessionId === opts.session ? opts.title : undefined,
191
215
  source: opts.source?.trim() || "cli",
192
- content: [{ type: "text", text: content }],
216
+ content: promptContent,
193
217
  model: opts.model,
194
218
  provider: opts.provider,
195
219
  accessMode: opts.readOnly ? "read_only" : "full_access",
@@ -225,6 +249,7 @@ export function registerPrompt(program) {
225
249
  .option("--cron <expression>", "Repeat using a 5-field cron expression")
226
250
  .option("--timezone <tz>", "IANA timezone for --cron, e.g. Asia/Shanghai")
227
251
  .option("--label <ref>", "Attach a label, e.g. Bug or Area/Frontend", collectOption, [])
252
+ .option("--image <path>", "Attach an image", collectOption, [])
228
253
  .option("--json", "Output as JSON")
229
254
  .action((words, opts) => sendPrompt(program, words, opts));
230
255
  }
@@ -385,6 +410,7 @@ export function registerSpaces(program) {
385
410
  .option("--cron <expression>", "Repeat using a 5-field cron expression")
386
411
  .option("--timezone <tz>", "IANA timezone for --cron, e.g. Asia/Shanghai")
387
412
  .option("--label <ref>", "Attach a label, e.g. Bug or Area/Frontend", collectOption, [])
413
+ .option("--image <path>", "Attach an image", collectOption, [])
388
414
  .option("--json", "Output as JSON")
389
415
  .action((words, opts) => sendPrompt(spacesCmd, words, opts));
390
416
  // ── spaces files ──
@@ -1356,12 +1382,17 @@ function registerCheckpoints(spacesCmd) {
1356
1382
  .command("ls")
1357
1383
  .alias("list")
1358
1384
  .description("List checkpoints")
1385
+ .option("--limit <n>", "Maximum checkpoints to return", (value) => Number(value))
1386
+ .option("--cursor <cursor>", "Pagination cursor")
1359
1387
  .option("--json", "Output as JSON")
1360
1388
  .action(async (opts) => {
1361
1389
  const spaceId = resolveSpace(spacesCmd);
1362
1390
  const client = createClient();
1363
1391
  try {
1364
- const result = await client.space(spaceId).checkpoints.list();
1392
+ const result = await client.space(spaceId).checkpoints.list({
1393
+ limit: opts.limit,
1394
+ cursor: opts.cursor,
1395
+ });
1365
1396
  if (jsonRequested(opts))
1366
1397
  return outJson(result);
1367
1398
  if (result.checkpoints.length === 0) {
@@ -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.3",
3
+ "version": "1.17.5",
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.0"
19
+ "@neta-art/cohub": "1.28.2"
20
20
  },
21
21
  "publishConfig": {
22
22
  "access": "public"