@neta-art/cohub-cli 2.0.0 → 2.2.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.
@@ -14,7 +14,7 @@ function shellJoin(values) {
14
14
  }
15
15
  function topLevelRunIndex(argv) {
16
16
  for (let index = 0; index < argv.length; index += 1) {
17
- const token = argv[index];
17
+ const token = argv[index] ?? "";
18
18
  if (token === "--")
19
19
  return -1;
20
20
  if (token === "-s" || token === "--space") {
@@ -31,7 +31,7 @@ function topLevelRunIndex(argv) {
31
31
  }
32
32
  function parseSpaceId(tokens) {
33
33
  for (let index = 0; index < tokens.length; index += 1) {
34
- const token = tokens[index];
34
+ const token = tokens[index] ?? "";
35
35
  if (token === "-s" || token === "--space") {
36
36
  const value = tokens[index + 1];
37
37
  if (!value)
@@ -55,7 +55,7 @@ function parseRunCliOptions(argv) {
55
55
  let commandOption = null;
56
56
  let positionalTokens = [];
57
57
  for (let index = 0; index < afterRun.length; index += 1) {
58
- const token = afterRun[index];
58
+ const token = afterRun[index] ?? "";
59
59
  if (token === "-h" || token === "--help") {
60
60
  printRunHelp();
61
61
  process.exit(0);
@@ -0,0 +1,2 @@
1
+ import type { Command } from "commander";
2
+ export declare function registerSpaceCommerce(spacesCmd: Command): void;
@@ -0,0 +1,500 @@
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 PRODUCT_STATUSES = ["draft", "active"];
5
+ const PRODUCT_UPDATE_STATUSES = ["draft", "active", "archived"];
6
+ const PRODUCT_VISIBILITIES = ["public", "private"];
7
+ const BENEFIT_STATUSES = ["active", "archived"];
8
+ function requireText(value, label, flag) {
9
+ const text = value?.trim();
10
+ if (text)
11
+ return text;
12
+ return error(`Missing ${label}`, `Pass ${flag}.`);
13
+ }
14
+ function parseChoice(value, label, choices) {
15
+ if (value === undefined)
16
+ return undefined;
17
+ if (choices.includes(value))
18
+ return value;
19
+ return error(`Invalid ${label}`, `Use one of: ${choices.join(", ")}.`);
20
+ }
21
+ function parseInteger(value, label, options) {
22
+ if (value === undefined)
23
+ return options.fallback;
24
+ if (!/^-?\d+$/.test(value.trim()))
25
+ return error(`Invalid ${label}`, `${label} must be an integer.`);
26
+ const parsed = Number.parseInt(value, 10);
27
+ if (!Number.isSafeInteger(parsed))
28
+ return error(`Invalid ${label}`, `${label} must be a safe integer.`);
29
+ if (options.min !== undefined && parsed < options.min)
30
+ return error(`Invalid ${label}`, `${label} must be at least ${options.min}.`);
31
+ if (options.max !== undefined && parsed > options.max)
32
+ return error(`Invalid ${label}`, `${label} must be at most ${options.max}.`);
33
+ return parsed;
34
+ }
35
+ function parseAmountUsd(value) {
36
+ const text = requireText(value, "amount", "--amount-usd <amount>");
37
+ if (!/^(?:\d+|\d+\.\d{1,2}|\.\d{1,2})$/.test(text)) {
38
+ return error("Invalid amount", "--amount-usd must be a non-negative USD amount with at most 2 decimals.");
39
+ }
40
+ const amount = Number(text);
41
+ if (!Number.isFinite(amount))
42
+ return error("Invalid amount", "--amount-usd must be a finite USD amount.");
43
+ return amount;
44
+ }
45
+ function parseMetadataJson(value) {
46
+ if (value === undefined)
47
+ return undefined;
48
+ let parsed;
49
+ try {
50
+ parsed = JSON.parse(value);
51
+ }
52
+ catch {
53
+ return error("Invalid metadata", "--metadata-json must be a JSON object.");
54
+ }
55
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
56
+ return error("Invalid metadata", "--metadata-json must be a JSON object.");
57
+ }
58
+ const metadata = {};
59
+ for (const [key, item] of Object.entries(parsed)) {
60
+ if (typeof item !== "string" && typeof item !== "number" && typeof item !== "boolean") {
61
+ return error("Invalid metadata", `Metadata value for "${key}" must be string, number, or boolean.`);
62
+ }
63
+ if (typeof item === "number" && !Number.isFinite(item)) {
64
+ return error("Invalid metadata", `Metadata value for "${key}" must be a finite number.`);
65
+ }
66
+ metadata[key] = item;
67
+ }
68
+ return metadata;
69
+ }
70
+ function compact(input) {
71
+ return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined));
72
+ }
73
+ function formatUsd(amount) {
74
+ return `$${amount.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
75
+ }
76
+ function formatMinorUsd(amountMinor) {
77
+ return formatUsd(amountMinor / 100);
78
+ }
79
+ function printProducts(products) {
80
+ table(products.map((product) => ({
81
+ key: product.key,
82
+ name: product.name,
83
+ status: product.status,
84
+ visibility: product.visibility,
85
+ price: formatUsd(product.pricing.amountUsd),
86
+ credits: product.display.creditsAmount ?? "",
87
+ })), [
88
+ { key: "key", label: "Key" },
89
+ { key: "name", label: "Name" },
90
+ { key: "status", label: "Status" },
91
+ { key: "visibility", label: "Visibility" },
92
+ { key: "price", label: "Price" },
93
+ { key: "credits", label: "Credits" },
94
+ ]);
95
+ }
96
+ function printBenefits(benefits) {
97
+ table(benefits.map((benefit) => {
98
+ let detail;
99
+ if (benefit.type === "credits") {
100
+ const config = benefit.config;
101
+ detail = `${config.amount} credits${config.expiresInDays != null ? ` · ${config.expiresInDays}d` : ""}`;
102
+ }
103
+ else {
104
+ detail = JSON.stringify(benefit.config.metadata);
105
+ }
106
+ return {
107
+ key: benefit.key,
108
+ name: benefit.name,
109
+ type: benefit.type,
110
+ status: benefit.status,
111
+ detail,
112
+ };
113
+ }), [
114
+ { key: "key", label: "Key" },
115
+ { key: "name", label: "Name" },
116
+ { key: "type", label: "Type" },
117
+ { key: "status", label: "Status" },
118
+ { key: "detail", label: "Detail" },
119
+ ]);
120
+ }
121
+ function printOrders(orders) {
122
+ table(orders.map((order) => ({
123
+ id: order.id,
124
+ product: order.productKeySnapshot,
125
+ status: order.status,
126
+ amount: formatMinorUsd(order.amountSnapshot),
127
+ created: order.createdAt,
128
+ })), [
129
+ { key: "id", label: "ID" },
130
+ { key: "product", label: "Product" },
131
+ { key: "status", label: "Status" },
132
+ { key: "amount", label: "Amount" },
133
+ { key: "created", label: "Created" },
134
+ ]);
135
+ }
136
+ async function confirmDanger(opts, detail) {
137
+ if (opts.yes)
138
+ return;
139
+ if (!process.stdin.isTTY || !process.stdout.isTTY)
140
+ return error("Confirmation required", `Pass --yes to ${detail}.`);
141
+ process.stdout.write(`${detail[0]?.toUpperCase() ?? "C"}${detail.slice(1)}? [y/N] `);
142
+ const chunks = [];
143
+ for await (const chunk of process.stdin) {
144
+ chunks.push(chunk);
145
+ break;
146
+ }
147
+ const answer = Buffer.concat(chunks).toString().trim().toLowerCase();
148
+ if (answer !== "y" && answer !== "yes")
149
+ return error("Cancelled");
150
+ }
151
+ function commerceClient(spacesCmd) {
152
+ const spaceId = resolveSpace(spacesCmd);
153
+ return { spaceId, commerce: createClient().space(spaceId).commerce };
154
+ }
155
+ export function registerSpaceCommerce(spacesCmd) {
156
+ const commerceCmd = spacesCmd
157
+ .command("commerce")
158
+ .description("Manage space commerce")
159
+ .addHelpText("after", `
160
+ Examples:
161
+ cohub -s <space-id> spaces commerce setup
162
+ cohub -s <space-id> spaces commerce products list
163
+ COHUB_SPACE_ID=<space-id> cohub spaces commerce orders list --limit 10
164
+ `);
165
+ commerceCmd
166
+ .command("setup")
167
+ .description("Initialize commerce for the target space")
168
+ .option("--json", "Output as JSON")
169
+ .action(async (opts) => {
170
+ const { commerce } = commerceClient(spacesCmd);
171
+ try {
172
+ const result = await commerce.setup();
173
+ if (jsonRequested(opts))
174
+ return outJson(result);
175
+ ok(`Commerce initialized: ${result.businessKey}`);
176
+ }
177
+ catch (e) {
178
+ handleHttp(e);
179
+ }
180
+ });
181
+ const productsCmd = commerceCmd
182
+ .command("products")
183
+ .description("Manage products")
184
+ .addHelpText("after", `
185
+ Examples:
186
+ cohub -s <space-id> spaces commerce products list
187
+ cohub -s <space-id> spaces commerce products create --name "Pro Pack" --amount-usd 9.99
188
+ `);
189
+ productsCmd
190
+ .command("list")
191
+ .description("List products")
192
+ .option("--json", "Output as JSON")
193
+ .action(async (opts) => {
194
+ const { commerce } = commerceClient(spacesCmd);
195
+ try {
196
+ const result = await commerce.listProducts();
197
+ if (jsonRequested(opts))
198
+ return outJson(result);
199
+ printProducts(result.products);
200
+ }
201
+ catch (e) {
202
+ handleHttp(e);
203
+ }
204
+ });
205
+ productsCmd
206
+ .command("create")
207
+ .description("Create a product")
208
+ .option("--product-key <key>", "Product key override")
209
+ .option("--name <name>", "Product name")
210
+ .option("--amount-usd <amount>", "One-time price in USD")
211
+ .option("--description <text>", "Product description")
212
+ .option("--status <status>", "Product status: draft, active")
213
+ .option("--visibility <visibility>", "Product visibility: public, private")
214
+ .option("--json", "Output as JSON")
215
+ .action(async (opts) => {
216
+ const input = {
217
+ key: opts.productKey?.trim() || undefined,
218
+ name: requireText(opts.name, "name", "--name <name>"),
219
+ description: opts.description,
220
+ amountUsd: parseAmountUsd(opts.amountUsd),
221
+ status: parseChoice(opts.status, "status", PRODUCT_STATUSES),
222
+ visibility: parseChoice(opts.visibility, "visibility", PRODUCT_VISIBILITIES),
223
+ };
224
+ const { commerce } = commerceClient(spacesCmd);
225
+ try {
226
+ const result = await commerce.createProduct(input);
227
+ if (jsonRequested(opts))
228
+ return outJson(result);
229
+ ok(`Product created: ${result.product.key}`);
230
+ printProducts([result.product]);
231
+ }
232
+ catch (e) {
233
+ handleHttp(e);
234
+ }
235
+ });
236
+ productsCmd
237
+ .command("update")
238
+ .description("Update a product")
239
+ .option("--product-key <key>", "Product key")
240
+ .option("--name <name>", "Product name")
241
+ .option("--description <text>", "Product description")
242
+ .option("--clear-description", "Clear the product description")
243
+ .option("--status <status>", "Product status: draft, active, archived")
244
+ .option("--visibility <visibility>", "Product visibility: public, private")
245
+ .option("--json", "Output as JSON")
246
+ .action(async (opts) => {
247
+ if (opts.description !== undefined && opts.clearDescription)
248
+ return error("Conflicting description", "Use either --description or --clear-description.");
249
+ const productKey = requireText(opts.productKey, "product key", "--product-key <key>");
250
+ const input = compact({
251
+ name: opts.name,
252
+ description: opts.clearDescription ? null : opts.description,
253
+ status: parseChoice(opts.status, "status", PRODUCT_UPDATE_STATUSES),
254
+ visibility: parseChoice(opts.visibility, "visibility", PRODUCT_VISIBILITIES),
255
+ });
256
+ if (Object.keys(input).length === 0)
257
+ return error("Nothing to update", "Pass --name, --description, --clear-description, --status, or --visibility.");
258
+ const { commerce } = commerceClient(spacesCmd);
259
+ try {
260
+ const result = await commerce.updateProduct(productKey, input);
261
+ if (jsonRequested(opts))
262
+ return outJson(result);
263
+ ok(`Product updated: ${result.product.key}`);
264
+ printProducts([result.product]);
265
+ }
266
+ catch (e) {
267
+ handleHttp(e);
268
+ }
269
+ });
270
+ productsCmd
271
+ .command("archive")
272
+ .description("Archive a product")
273
+ .option("--product-key <key>", "Product key")
274
+ .option("-y, --yes", "Confirm archive")
275
+ .option("--json", "Output as JSON")
276
+ .action(async (opts) => {
277
+ const productKey = requireText(opts.productKey, "product key", "--product-key <key>");
278
+ await confirmDanger(opts, `archive product "${productKey}"`);
279
+ const { commerce } = commerceClient(spacesCmd);
280
+ try {
281
+ const result = await commerce.updateProduct(productKey, { status: "archived" });
282
+ if (jsonRequested(opts))
283
+ return outJson(result);
284
+ ok(`Product archived: ${result.product.key}`);
285
+ }
286
+ catch (e) {
287
+ handleHttp(e);
288
+ }
289
+ });
290
+ const benefitsCmd = commerceCmd
291
+ .command("benefits")
292
+ .description("Manage benefits")
293
+ .addHelpText("after", `
294
+ Examples:
295
+ cohub -s <space-id> spaces commerce benefits list
296
+ cohub -s <space-id> spaces commerce benefits create --name "Premium Export" --metadata-json '{"limit":100}'
297
+ `);
298
+ benefitsCmd
299
+ .command("list")
300
+ .description("List benefits")
301
+ .option("--json", "Output as JSON")
302
+ .action(async (opts) => {
303
+ const { commerce } = commerceClient(spacesCmd);
304
+ try {
305
+ const result = await commerce.listBenefits();
306
+ if (jsonRequested(opts))
307
+ return outJson(result);
308
+ printBenefits(result.benefits);
309
+ }
310
+ catch (e) {
311
+ handleHttp(e);
312
+ }
313
+ });
314
+ benefitsCmd
315
+ .command("create")
316
+ .description("Create a benefit")
317
+ .option("--benefit-key <key>", "Benefit key override")
318
+ .option("--name <name>", "Benefit name")
319
+ .option("--description <text>", "Benefit description")
320
+ .option("--type <type>", "Benefit type: feature, credits (default: feature)")
321
+ .option("--amount <n>", "Credit amount (credits type only)")
322
+ .option("--expires-in-days <n>", "Credit expiry in days (credits type only)")
323
+ .option("--metadata-json <json>", "Benefit metadata JSON object (feature type only)")
324
+ .option("--json", "Output as JSON")
325
+ .action(async (opts) => {
326
+ const name = requireText(opts.name, "name", "--name <name>");
327
+ const type = parseChoice(opts.type, "type", ["feature", "credits"]) ?? "feature";
328
+ if (type === "credits") {
329
+ const amount = parseInteger(opts.amount, "amount", { fallback: 0, min: 1 });
330
+ if (amount < 1)
331
+ return error("Missing amount", "Pass --amount <n> with a positive integer for credits benefits.");
332
+ const input = {
333
+ key: opts.benefitKey?.trim() || undefined,
334
+ name,
335
+ description: opts.description,
336
+ type: "credits",
337
+ amount,
338
+ expiresInDays: opts.expiresInDays !== undefined
339
+ ? parseInteger(opts.expiresInDays, "expires-in-days", { fallback: 0, min: 1 })
340
+ : undefined,
341
+ };
342
+ const { commerce } = commerceClient(spacesCmd);
343
+ try {
344
+ const result = await commerce.createBenefit(input);
345
+ if (jsonRequested(opts))
346
+ return outJson(result);
347
+ ok(`Benefit created: ${result.benefit.key}`);
348
+ printBenefits([result.benefit]);
349
+ }
350
+ catch (e) {
351
+ handleHttp(e);
352
+ }
353
+ return;
354
+ }
355
+ const input = {
356
+ key: opts.benefitKey?.trim() || undefined,
357
+ name,
358
+ description: opts.description,
359
+ type: "feature",
360
+ metadata: parseMetadataJson(opts.metadataJson),
361
+ };
362
+ const { commerce } = commerceClient(spacesCmd);
363
+ try {
364
+ const result = await commerce.createBenefit(input);
365
+ if (jsonRequested(opts))
366
+ return outJson(result);
367
+ ok(`Benefit created: ${result.benefit.key}`);
368
+ printBenefits([result.benefit]);
369
+ }
370
+ catch (e) {
371
+ handleHttp(e);
372
+ }
373
+ });
374
+ benefitsCmd
375
+ .command("update")
376
+ .description("Update a benefit")
377
+ .option("--benefit-key <key>", "Benefit key")
378
+ .option("--name <name>", "Benefit name")
379
+ .option("--description <text>", "Benefit description")
380
+ .option("--clear-description", "Clear the benefit description")
381
+ .option("--status <status>", "Benefit status: active, archived")
382
+ .option("--metadata-json <json>", "Replace metadata with a JSON object")
383
+ .option("--json", "Output as JSON")
384
+ .action(async (opts) => {
385
+ if (opts.description !== undefined && opts.clearDescription)
386
+ return error("Conflicting description", "Use either --description or --clear-description.");
387
+ const benefitKey = requireText(opts.benefitKey, "benefit key", "--benefit-key <key>");
388
+ const input = compact({
389
+ name: opts.name,
390
+ description: opts.clearDescription ? null : opts.description,
391
+ status: parseChoice(opts.status, "status", BENEFIT_STATUSES),
392
+ metadata: parseMetadataJson(opts.metadataJson),
393
+ });
394
+ if (Object.keys(input).length === 0)
395
+ return error("Nothing to update", "Pass --name, --description, --clear-description, --status, or --metadata-json.");
396
+ const { commerce } = commerceClient(spacesCmd);
397
+ try {
398
+ const result = await commerce.updateBenefit(benefitKey, input);
399
+ if (jsonRequested(opts))
400
+ return outJson(result);
401
+ ok(`Benefit updated: ${result.benefit.key}`);
402
+ printBenefits([result.benefit]);
403
+ }
404
+ catch (e) {
405
+ handleHttp(e);
406
+ }
407
+ });
408
+ benefitsCmd
409
+ .command("archive")
410
+ .description("Archive a benefit")
411
+ .option("--benefit-key <key>", "Benefit key")
412
+ .option("-y, --yes", "Confirm archive")
413
+ .option("--json", "Output as JSON")
414
+ .action(async (opts) => {
415
+ const benefitKey = requireText(opts.benefitKey, "benefit key", "--benefit-key <key>");
416
+ await confirmDanger(opts, `archive benefit "${benefitKey}"`);
417
+ const { commerce } = commerceClient(spacesCmd);
418
+ try {
419
+ const result = await commerce.updateBenefit(benefitKey, { status: "archived" });
420
+ if (jsonRequested(opts))
421
+ return outJson(result);
422
+ ok(`Benefit archived: ${result.benefit.key}`);
423
+ }
424
+ catch (e) {
425
+ handleHttp(e);
426
+ }
427
+ });
428
+ commerceCmd
429
+ .command("bind")
430
+ .description("Bind a benefit to a product")
431
+ .option("--product-key <key>", "Product key")
432
+ .option("--benefit-key <key>", "Benefit key")
433
+ .option("--json", "Output as JSON")
434
+ .action(async (opts) => {
435
+ const input = {
436
+ productKey: requireText(opts.productKey, "product key", "--product-key <key>"),
437
+ benefitKey: requireText(opts.benefitKey, "benefit key", "--benefit-key <key>"),
438
+ };
439
+ const { commerce } = commerceClient(spacesCmd);
440
+ try {
441
+ const result = await commerce.bindProductBenefit(input);
442
+ if (jsonRequested(opts))
443
+ return outJson(result);
444
+ ok(`Benefit bound: ${input.benefitKey} -> ${input.productKey}`);
445
+ }
446
+ catch (e) {
447
+ handleHttp(e);
448
+ }
449
+ });
450
+ commerceCmd
451
+ .command("unbind")
452
+ .description("Unbind a benefit from a product")
453
+ .option("--product-key <key>", "Product key")
454
+ .option("--benefit-key <key>", "Benefit key")
455
+ .option("-y, --yes", "Confirm unbind")
456
+ .option("--json", "Output as JSON")
457
+ .action(async (opts) => {
458
+ const input = {
459
+ productKey: requireText(opts.productKey, "product key", "--product-key <key>"),
460
+ benefitKey: requireText(opts.benefitKey, "benefit key", "--benefit-key <key>"),
461
+ };
462
+ await confirmDanger(opts, `unbind benefit "${input.benefitKey}" from product "${input.productKey}"`);
463
+ const { commerce } = commerceClient(spacesCmd);
464
+ try {
465
+ const result = await commerce.unbindProductBenefit(input);
466
+ if (jsonRequested(opts))
467
+ return outJson(result);
468
+ ok(`Benefit unbound: ${input.benefitKey} -> ${input.productKey}`);
469
+ }
470
+ catch (e) {
471
+ handleHttp(e);
472
+ }
473
+ });
474
+ const ordersCmd = commerceCmd
475
+ .command("orders")
476
+ .description("Manage orders");
477
+ ordersCmd
478
+ .command("list")
479
+ .description("List recent orders")
480
+ .option("--page <page>", "Page number")
481
+ .option("--limit <limit>", "Page size, max 50")
482
+ .option("--json", "Output as JSON")
483
+ .action(async (opts) => {
484
+ const { commerce } = commerceClient(spacesCmd);
485
+ try {
486
+ const result = await commerce.listOrders({
487
+ page: parseInteger(opts.page, "page", { fallback: 1, min: 1 }),
488
+ limit: parseInteger(opts.limit, "limit", { fallback: 20, min: 1, max: 50 }),
489
+ });
490
+ if (jsonRequested(opts))
491
+ return outJson(result);
492
+ printOrders(result.orders);
493
+ if (result.pagination.hasMore)
494
+ console.log(`\nNext page: ${result.pagination.nextPage}`);
495
+ }
496
+ catch (e) {
497
+ handleHttp(e);
498
+ }
499
+ });
500
+ }
@@ -7,6 +7,7 @@ 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";
10
+ import { registerSpaceCommerce } from "./space-commerce.js";
10
11
  const cliEnv = resolveCohubEnvironment();
11
12
  const defaultIdleTtlSeconds = cliEnv === "prod" ? 12 * 60 * 60 : 10 * 60;
12
13
  const SPACE_ROLES = ["host", "builder", "guest"];
@@ -478,6 +479,8 @@ export function registerSpaces(program) {
478
479
  registerMods(spacesCmd);
479
480
  // ── spaces labels ──
480
481
  registerLabels(spacesCmd);
482
+ // ── spaces commerce ──
483
+ registerSpaceCommerce(spacesCmd);
481
484
  // ── spaces usage ──
482
485
  spacesCmd
483
486
  .command("usage [days]")
@@ -0,0 +1,2 @@
1
+ import type { Command } from "commander";
2
+ export declare function registerWorkCommerce(worksCmd: Command): void;
@@ -0,0 +1,226 @@
1
+ import { createClient } from "../client.js";
2
+ import { error, handleHttp, json as outJson, jsonRequested, ok, table } from "../output.js";
3
+ function collectOption(value, previous = []) {
4
+ return [...previous, value];
5
+ }
6
+ function requireText(value, label, flag) {
7
+ const text = value?.trim();
8
+ if (text)
9
+ return text;
10
+ return error(`Missing ${label}`, `Pass ${flag}.`);
11
+ }
12
+ function requireList(values, label, flag) {
13
+ const items = [...new Set((values ?? []).map((value) => value.trim()).filter(Boolean))];
14
+ if (items.length > 0)
15
+ return items;
16
+ return error(`Missing ${label}`, `Pass ${flag}.`);
17
+ }
18
+ function formatUsd(amount) {
19
+ return `$${amount.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
20
+ }
21
+ function formatMinorUsd(amountMinor) {
22
+ return formatUsd(amountMinor / 100);
23
+ }
24
+ function printProducts(products) {
25
+ table(products.map((product) => ({
26
+ key: product.key,
27
+ name: product.name,
28
+ status: product.status,
29
+ visibility: product.visibility,
30
+ price: formatUsd(product.pricing.amountUsd),
31
+ credits: product.display.creditsAmount ?? "",
32
+ })), [
33
+ { key: "key", label: "Key" },
34
+ { key: "name", label: "Name" },
35
+ { key: "status", label: "Status" },
36
+ { key: "visibility", label: "Visibility" },
37
+ { key: "price", label: "Price" },
38
+ { key: "credits", label: "Credits" },
39
+ ]);
40
+ }
41
+ function printEntitlements(result) {
42
+ table(result.entitlements.map((item) => ({
43
+ benefitKey: item.benefitKey,
44
+ enabled: item.enabled ? "yes" : "no",
45
+ metadata: item.metadata ? JSON.stringify(item.metadata) : "",
46
+ })), [
47
+ { key: "benefitKey", label: "Benefit" },
48
+ { key: "enabled", label: "Enabled" },
49
+ { key: "metadata", label: "Metadata" },
50
+ ]);
51
+ console.log(`\nCredits available: ${result.credits.available} (net: ${result.credits.net})`);
52
+ }
53
+ function printOrder(order) {
54
+ table([{
55
+ id: order.id,
56
+ product: order.productKeySnapshot,
57
+ status: order.status,
58
+ amount: formatMinorUsd(order.amountSnapshot),
59
+ paid: formatMinorUsd(order.paidAmountSnapshot),
60
+ created: order.createdAt,
61
+ paidAt: order.paidAt ?? "",
62
+ }], [
63
+ { key: "id", label: "ID" },
64
+ { key: "product", label: "Product" },
65
+ { key: "status", label: "Status" },
66
+ { key: "amount", label: "Amount" },
67
+ { key: "paid", label: "Paid" },
68
+ { key: "created", label: "Created" },
69
+ { key: "paidAt", label: "Paid At" },
70
+ ]);
71
+ }
72
+ export function registerWorkCommerce(worksCmd) {
73
+ const commerceCmd = worksCmd
74
+ .command("commerce")
75
+ .description("Work commerce operations")
76
+ .addHelpText("after", `
77
+ Examples:
78
+ cohub works commerce products resolve --work-id <work-id> --product-key pro_pack
79
+ cohub works commerce entitlements --work-id <work-id>
80
+ cohub works commerce credits consume --work-id <work-id> --amount 100
81
+ `);
82
+ const productsCmd = commerceCmd
83
+ .command("products")
84
+ .description("Resolve commerce products");
85
+ productsCmd
86
+ .command("resolve")
87
+ .description("Resolve public products for a work")
88
+ .option("--work-id <id>", "Work ID")
89
+ .option("--product-key <key>", "Product key", collectOption)
90
+ .option("--json", "Output as JSON")
91
+ .action(async (opts) => {
92
+ const workId = requireText(opts.workId, "work ID", "--work-id <id>");
93
+ const productKeys = requireList(opts.productKey, "product key", "--product-key <key>");
94
+ const client = createClient();
95
+ try {
96
+ const result = await client.workCommerce.resolveProducts(workId, { productKeys });
97
+ if (jsonRequested(opts))
98
+ return outJson(result);
99
+ printProducts(result.products);
100
+ }
101
+ catch (e) {
102
+ handleHttp(e);
103
+ }
104
+ });
105
+ commerceCmd
106
+ .command("entitlements")
107
+ .description("Show viewer entitlements and credit balance for a work")
108
+ .option("--work-id <id>", "Work ID")
109
+ .option("--json", "Output as JSON")
110
+ .action(async (opts) => {
111
+ const workId = requireText(opts.workId, "work ID", "--work-id <id>");
112
+ const client = createClient();
113
+ try {
114
+ const result = await client.workCommerce.getEntitlements(workId);
115
+ if (jsonRequested(opts))
116
+ return outJson(result);
117
+ printEntitlements(result);
118
+ }
119
+ catch (e) {
120
+ handleHttp(e);
121
+ }
122
+ });
123
+ const creditsCmd = commerceCmd
124
+ .command("credits")
125
+ .description("Consume credits for a work");
126
+ creditsCmd
127
+ .command("consume")
128
+ .description("Consume credits for a work (self by default)")
129
+ .option("--work-id <id>", "Work ID")
130
+ .option("--amount <n>", "Positive integer credit amount")
131
+ .option("--operation-id <id>", "Idempotency key (generated when omitted)")
132
+ .option("--reason <text>", "Reason for the consumption")
133
+ .option("--json", "Output as JSON")
134
+ .action(async (opts) => {
135
+ const workId = requireText(opts.workId, "work ID", "--work-id <id>");
136
+ const amountText = requireText(opts.amount, "amount", "--amount <n>");
137
+ const amount = Number.parseInt(amountText, 10);
138
+ if (!Number.isSafeInteger(amount) || amount <= 0) {
139
+ return error("Invalid amount", "--amount must be a positive integer.");
140
+ }
141
+ const operationId = opts.operationId?.trim() || crypto.randomUUID();
142
+ const client = createClient();
143
+ try {
144
+ const result = await client.workCommerce.consumeCredits(workId, {
145
+ amount,
146
+ operationId,
147
+ reason: opts.reason,
148
+ });
149
+ if (jsonRequested(opts))
150
+ return outJson({ ...result, operationId });
151
+ ok(`Consumed ${result.amount} credits (operation: ${operationId})`);
152
+ table([{
153
+ status: result.status,
154
+ amount: result.amount,
155
+ remaining: result.remaining,
156
+ shortfall: result.shortfall ?? "",
157
+ }], [
158
+ { key: "status", label: "Status" },
159
+ { key: "amount", label: "Amount" },
160
+ { key: "remaining", label: "Remaining" },
161
+ { key: "shortfall", label: "Shortfall" },
162
+ ]);
163
+ }
164
+ catch (e) {
165
+ handleHttp(e);
166
+ }
167
+ });
168
+ commerceCmd
169
+ .command("purchase")
170
+ .description("Create a work purchase checkout")
171
+ .option("--work-id <id>", "Work ID")
172
+ .option("--product-key <key>", "Product key")
173
+ .option("--json", "Output as JSON")
174
+ .action(async (opts) => {
175
+ const workId = requireText(opts.workId, "work ID", "--work-id <id>");
176
+ const productKey = requireText(opts.productKey, "product key", "--product-key <key>");
177
+ const client = createClient();
178
+ try {
179
+ const result = await client.workCommerce.purchase(workId, { productKey });
180
+ if (jsonRequested(opts))
181
+ return outJson(result);
182
+ ok(`Checkout created: ${result.checkout.orderId}`);
183
+ table([{
184
+ orderId: result.checkout.orderId,
185
+ productKey: result.checkout.productKey,
186
+ usable: result.checkout.checkoutUsable ? "yes" : "no",
187
+ status: result.checkout.status ?? "",
188
+ checkoutUrl: result.checkout.checkoutUrl ?? "",
189
+ message: result.checkout.message ?? "",
190
+ }], [
191
+ { key: "orderId", label: "Order" },
192
+ { key: "productKey", label: "Product" },
193
+ { key: "usable", label: "Usable" },
194
+ { key: "status", label: "Status" },
195
+ { key: "checkoutUrl", label: "Checkout URL" },
196
+ { key: "message", label: "Message" },
197
+ ]);
198
+ }
199
+ catch (e) {
200
+ handleHttp(e);
201
+ }
202
+ });
203
+ const ordersCmd = commerceCmd
204
+ .command("orders")
205
+ .description("Look up commerce orders");
206
+ ordersCmd
207
+ .command("get")
208
+ .description("Show a work commerce order")
209
+ .option("--work-id <id>", "Work ID")
210
+ .option("--order-id <id>", "Order ID")
211
+ .option("--json", "Output as JSON")
212
+ .action(async (opts) => {
213
+ const workId = requireText(opts.workId, "work ID", "--work-id <id>");
214
+ const orderId = requireText(opts.orderId, "order ID", "--order-id <id>");
215
+ const client = createClient();
216
+ try {
217
+ const result = await client.workCommerce.getOrder(workId, orderId);
218
+ if (jsonRequested(opts))
219
+ return outJson(result);
220
+ printOrder(result.order);
221
+ }
222
+ catch (e) {
223
+ handleHttp(e);
224
+ }
225
+ });
226
+ }
@@ -2,6 +2,7 @@ import { HttpError } from "@neta-art/cohub";
2
2
  import { createClient } from "../client.js";
3
3
  import { error, handleHttp, json as outJson, jsonRequested, ok, table } from "../output.js";
4
4
  import { resolveSpace } from "../space.js";
5
+ import { registerWorkCommerce } from "./work-commerce.js";
5
6
  const WORK_STATUSES = ["published", "disabled"];
6
7
  const WORK_VISIBILITIES = ["public", "space"];
7
8
  const collectOption = (value, previous = []) => [...previous, value];
@@ -363,6 +364,7 @@ export function registerWorks(program) {
363
364
  handleHttp(e);
364
365
  }
365
366
  });
367
+ registerWorkCommerce(worksCmd);
366
368
  worksCmd
367
369
  .command("rm <id>")
368
370
  .alias("delete")
package/dist/index.js CHANGED
@@ -44,6 +44,7 @@ Common commands:
44
44
  cohub -s <space-id> spaces sessions turns ls <session-id>
45
45
  cohub -s <space-id> spaces files ls
46
46
  cohub -s <space-id> works publish demo --file dist/index.html
47
+ cohub -s <space-id> spaces commerce products list
47
48
  cohub models ls
48
49
  cohub models ls --model-type multimodal
49
50
  cohub generate "A calm lake at sunrise" --model <model> --output lake.png
package/dist/output.js CHANGED
@@ -85,15 +85,28 @@ function fetchFailureDetail(e) {
85
85
  ? `Network request failed (${parts.join(" · ")}). Check DNS/proxy/firewall settings and try again.`
86
86
  : "Network request failed. Check DNS/proxy/firewall settings and try again.";
87
87
  }
88
+ function errorPresentationFromHttpError(e) {
89
+ const httpError = e;
90
+ if (httpError.code === "space_commerce_not_initialized") {
91
+ return {
92
+ message: "space commerce is not initialized",
93
+ detail: "run `cohub -s <space-id> spaces commerce setup` first",
94
+ };
95
+ }
96
+ return null;
97
+ }
88
98
  export function handleHttp(e) {
89
99
  if (e instanceof Error && e.name === "AuthRequiredError") {
90
100
  return error("not authenticated", "run `cohub auth login`");
91
101
  }
92
102
  const status = e.status;
93
103
  const body = e.body;
94
- const message = errorMessageFromBody(body) ?? (e instanceof Error ? e.message : String(e));
104
+ const presentation = errorPresentationFromHttpError(e);
105
+ const message = presentation?.message ?? errorMessageFromBody(body) ?? (e instanceof Error ? e.message : String(e));
95
106
  const fetchDetail = fetchFailureDetail(e);
96
107
  const detailParts = [];
108
+ if (presentation?.detail)
109
+ detailParts.push(presentation.detail);
97
110
  if (process.env.COHUB_DEBUG_ERRORS) {
98
111
  if (status)
99
112
  detailParts.push(`HTTP ${status}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neta-art/cohub-cli",
3
- "version": "2.0.0",
3
+ "version": "2.2.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.10",
17
17
  "commander": "^14.0.3",
18
18
  "sharp": "^0.34.5",
19
- "@neta-art/cohub": "2.0.0"
19
+ "@neta-art/cohub": "2.2.0"
20
20
  },
21
21
  "publishConfig": {
22
22
  "access": "public"