@neta-art/cohub-cli 6.1.3 → 6.1.4

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
@@ -144,6 +144,17 @@ cohub -s <spaceId> spaces turns ls --cursor <nextCursor> --json
144
144
  cohub -s <spaceId> spaces turns ls --after <snapshotCursor> --before <snapshotAt> --json
145
145
  ```
146
146
 
147
+ ## Space activity
148
+
149
+ One-shot overview of a Space: usage summary, per-user contributors, top
150
+ models, and most viewed Apps. Cost figures require space-management access.
151
+
152
+ ```bash
153
+ cohub -s <spaceId> spaces activity
154
+ cohub -s <spaceId> spaces activity 7
155
+ cohub -s <spaceId> spaces activity 365 --json
156
+ ```
157
+
147
158
  ## Boards
148
159
 
149
160
  Board commands use the selected Space and support `-h` at every level:
@@ -273,6 +284,11 @@ Confirm before deleting files or directories.
273
284
 
274
285
  Publish and manage Work entries from a Space workspace. Public Work URLs require a username and a Space slug.
275
286
 
287
+ `--file` and `--dir` take paths relative to the Space workspace — the same paths
288
+ `spaces files ls` shows, not your local filesystem. To publish local build
289
+ output, upload it first (`spaces files upload <dir>`), then publish the
290
+ Space-side path.
291
+
276
292
  ```bash
277
293
  cohub profile update --username <username>
278
294
  cohub spaces update <spaceId> --slug <space-slug>
@@ -0,0 +1,26 @@
1
+ import { type CohubHttpClient } from "@neta-art/cohub";
2
+ /**
3
+ * `--file` / `--dir` publish targets are paths inside the target Space's
4
+ * workspace, not local filesystem paths. The publish worker resolves them
5
+ * under `{storageRoot}/{spaceId}/workspace` and snapshots the bytes into an
6
+ * immutable artifact.
7
+ *
8
+ * The worker surfaces a bare "file or directory not found" without context, so
9
+ * the CLI checks the target against the Space files API first and fails with
10
+ * an explicit, self-explanatory error before anything is snapshotted.
11
+ */
12
+ export type AppTargetCheckError = {
13
+ status: number;
14
+ code: string;
15
+ message: string;
16
+ };
17
+ /**
18
+ * Verify a Space-relative publish target exists with the expected node type.
19
+ * Empty targetRef (the Space workspace root, directory publishes only) passes.
20
+ * Returns null when the target is valid or the check itself cannot run —
21
+ * the preflight is advisory and must never block a publish the worker can do.
22
+ */
23
+ export declare function checkAppTarget(client: CohubHttpClient, spaceId: string, target: {
24
+ targetType: "file" | "directory";
25
+ targetRef: string;
26
+ }): Promise<AppTargetCheckError | null>;
@@ -0,0 +1,55 @@
1
+ import { isHttpErrorCode } from "@neta-art/cohub";
2
+ /** Normalize a user-supplied Space path for comparison with fs entries. */
3
+ function normalizeSpacePath(path) {
4
+ return path.replace(/^\.\//, "").replace(/\/+$/, "");
5
+ }
6
+ function posixDirname(path) {
7
+ const index = path.lastIndexOf("/");
8
+ return index <= 0 ? "" : path.slice(0, index);
9
+ }
10
+ /**
11
+ * Verify a Space-relative publish target exists with the expected node type.
12
+ * Empty targetRef (the Space workspace root, directory publishes only) passes.
13
+ * Returns null when the target is valid or the check itself cannot run —
14
+ * the preflight is advisory and must never block a publish the worker can do.
15
+ */
16
+ export async function checkAppTarget(client, spaceId, target) {
17
+ const targetPath = normalizeSpacePath(target.targetRef);
18
+ if (!targetPath)
19
+ return null;
20
+ try {
21
+ const tree = await client.space(spaceId).files.list(posixDirname(targetPath));
22
+ const entry = tree.entries.find((candidate) => candidate.path === targetPath);
23
+ if (!entry) {
24
+ return {
25
+ status: 404,
26
+ code: "path_not_found",
27
+ message: `"${targetPath}" does not exist in the Space workspace`,
28
+ };
29
+ }
30
+ const wanted = target.targetType === "directory" ? "dir" : "file";
31
+ if (entry.type !== wanted) {
32
+ const foundKind = entry.type === "dir" ? "a directory" : entry.type === "symlink" ? "a symlink" : "a file";
33
+ const wantedKind = wanted === "dir" ? "a directory" : "a file";
34
+ return {
35
+ status: 400,
36
+ code: entry.type === "symlink" ? "symlink_not_supported" : wanted === "dir" ? "not_a_directory" : "not_a_file",
37
+ message: `"${targetPath}" is ${foundKind}, but the publish target must be ${wantedKind}`,
38
+ };
39
+ }
40
+ return null;
41
+ }
42
+ catch (e) {
43
+ // The parent directory itself is missing.
44
+ if (isHttpErrorCode(e, "path_not_found")) {
45
+ return {
46
+ status: 404,
47
+ code: "path_not_found",
48
+ message: `"${targetPath}" does not exist in the Space workspace`,
49
+ };
50
+ }
51
+ // Advisory preflight: ignore auth/visibility/network noise here and let the
52
+ // publish request proceed — the worker still reports real failures.
53
+ return null;
54
+ }
55
+ }
@@ -4,6 +4,7 @@ import { error, handleHttp, json as outJson, jsonRequested, ok, table } from "..
4
4
  import { resolveSpace } from "../space.js";
5
5
  import { downloadApp } from "../app-download.js";
6
6
  import { getAppByRef, parseAppRef } from "../app-ref.js";
7
+ import { checkAppTarget } from "../app-target.js";
7
8
  import { registerAppCommerce } from "./app-commerce.js";
8
9
  const APP_STATUSES = ["published", "disabled"];
9
10
  const APP_VISIBILITIES = ["public", "space"];
@@ -64,6 +65,28 @@ function resolveStatus(opts) {
64
65
  return error("Conflicting status", "Use only one of --status or --disabled");
65
66
  return values[0] ? parseChoice(values[0], "status", APP_STATUSES) : "published";
66
67
  }
68
+ /**
69
+ * Fail early when a Space-relative `--file` / `--dir` target cannot be a valid
70
+ * publish source. The publish worker resolves targets inside the target
71
+ * Space's workspace, not on the local filesystem.
72
+ */
73
+ async function guardAppTarget(client, spaceId, target) {
74
+ const failure = await checkAppTarget(client, spaceId, target);
75
+ if (failure) {
76
+ error(failure.status === 404 ? "Publish target not found" : "Publish target is invalid", `${failure.message} (--${target.targetType === "directory" ? "dir" : "file"} takes a Space workspace path, not a local path).`);
77
+ }
78
+ }
79
+ /**
80
+ * Translate the publish worker's bare fs errors (e.g. a target removed between
81
+ * preflight and snapshot) into the same explicit wording as the preflight.
82
+ */
83
+ function translateTargetWorkerError(e) {
84
+ if (!(e instanceof HttpError))
85
+ return;
86
+ if (e.code !== "path_not_found" && e.code !== "not_a_directory" && e.code !== "not_a_file" && e.code !== "symlink_not_supported")
87
+ return;
88
+ error(e.code === "path_not_found" ? "Publish target not found" : "Publish target is invalid", "The publish target is a Space workspace path; the Space workspace no longer contains a valid target at that path.");
89
+ }
67
90
  function resolveVisibility(value) {
68
91
  return value ? parseChoice(value, "visibility", APP_VISIBILITIES) : undefined;
69
92
  }
@@ -143,6 +166,7 @@ async function publishAppVersion(id, opts) {
143
166
  printApp(result.app);
144
167
  }
145
168
  catch (e) {
169
+ translateTargetWorkerError(e);
146
170
  handleHttp(e);
147
171
  }
148
172
  }
@@ -261,8 +285,8 @@ export function registerApps(program) {
261
285
  appsCmd
262
286
  .command("publish <slug>")
263
287
  .description("Create or publish an app in the target space")
264
- .option("--file <path>", "Publish a file (HTML page, board, or any other file)")
265
- .option("--dir <path>", "Publish a directory site")
288
+ .option("--file <path>", "Publish a file (HTML page, board, or any other file) from the Space workspace")
289
+ .option("--dir <path>", "Publish a directory site from the Space workspace")
266
290
  .option("--port <port>", "Publish a public sandbox port")
267
291
  .option("--disabled", "Create as disabled")
268
292
  .option("--status <status>", "App status: published, disabled")
@@ -281,6 +305,9 @@ export function registerApps(program) {
281
305
  return error("Missing target", "Use one of --file, --dir, or --port.");
282
306
  const spaceId = resolveSpace(appsCmd);
283
307
  const client = createClient();
308
+ const { targetType, targetRef } = target;
309
+ if (targetType !== "port")
310
+ await guardAppTarget(client, spaceId, { targetType, targetRef });
284
311
  const status = resolveStatus(opts);
285
312
  const meta = withCohubBarMeta({
286
313
  meta: parseJsonObject(opts.meta, "meta"),
@@ -306,6 +333,7 @@ export function registerApps(program) {
306
333
  printApp(result.app);
307
334
  }
308
335
  catch (e) {
336
+ translateTargetWorkerError(e);
309
337
  if (!(e instanceof HttpError) || e.status !== 409)
310
338
  handleHttp(e);
311
339
  try {
@@ -332,6 +360,7 @@ export function registerApps(program) {
332
360
  printApp(result.app);
333
361
  }
334
362
  catch (fallbackError) {
363
+ translateTargetWorkerError(fallbackError);
335
364
  handleHttp(fallbackError);
336
365
  }
337
366
  }
@@ -340,8 +369,8 @@ export function registerApps(program) {
340
369
  .command("update <id>")
341
370
  .description("Update app settings")
342
371
  .option("--slug <slug>", "New app slug")
343
- .option("--file <path>", "Use a file target (HTML page, board, or any other file)")
344
- .option("--dir <path>", "Use a directory site target")
372
+ .option("--file <path>", "Use a file target (HTML page, board, or any other file) from the Space workspace")
373
+ .option("--dir <path>", "Use a directory site target from the Space workspace")
345
374
  .option("--port <port>", "Use a public sandbox port target")
346
375
  .option("--disabled", "Set status to disabled")
347
376
  .option("--status <status>", "App status: published, disabled")
@@ -364,6 +393,19 @@ export function registerApps(program) {
364
393
  return error("Conflicting viewer scopes", "Use either --viewer-scope or --clear-viewer-scopes.");
365
394
  const hasMetaUpdate = opts.meta !== undefined || opts.hideCohubBar || opts.showCohubBar;
366
395
  const client = createClient();
396
+ if (target) {
397
+ // Resolve the app's home Space so the preflight checks the same
398
+ // workspace the publish worker will read from.
399
+ try {
400
+ const current = await client.apps.get(id);
401
+ const { targetType, targetRef } = target;
402
+ if (targetType !== "port")
403
+ await guardAppTarget(client, current.app.spaceId, { targetType, targetRef });
404
+ }
405
+ catch {
406
+ // The update request below surfaces errors for unknown apps.
407
+ }
408
+ }
367
409
  let meta;
368
410
  if (hasMetaUpdate) {
369
411
  let baseMeta = opts.meta !== undefined ? parseJsonObject(opts.meta, "meta") ?? null : undefined;
@@ -407,6 +449,7 @@ export function registerApps(program) {
407
449
  printApp(result.app);
408
450
  }
409
451
  catch (e) {
452
+ translateTargetWorkerError(e);
410
453
  handleHttp(e);
411
454
  }
412
455
  });
@@ -0,0 +1,26 @@
1
+ import type { SpaceActivityAppRanking, SpaceActivityContributor, SpaceActivityResponse } from "@neta-art/cohub";
2
+ import type { Command } from "commander";
3
+ import { type Row } from "../output.js";
4
+ export type SpaceActivityCliOptions = {
5
+ json?: boolean;
6
+ };
7
+ type SpaceActivityCommandClient = {
8
+ space(spaceId: string): {
9
+ activity: {
10
+ get(days: number): Promise<SpaceActivityResponse>;
11
+ };
12
+ };
13
+ };
14
+ export declare class InvalidSpaceActivityDaysError extends Error {
15
+ readonly detail: string;
16
+ constructor(message: string, detail: string);
17
+ }
18
+ /** Shared with the API: any positive day count up to a full leap year. */
19
+ export declare function parseActivityDays(value: string | undefined): number;
20
+ export declare function toContributorRows(items: SpaceActivityContributor[]): Row[];
21
+ export declare function toAppRankingRows(apps: SpaceActivityAppRanking[]): Row[];
22
+ export declare function printActivityReport(activity: SpaceActivityResponse): void;
23
+ export declare function registerSpaceActivity(spacesCmd: Command, dependencies?: {
24
+ createClient?: () => SpaceActivityCommandClient;
25
+ }): void;
26
+ export {};
@@ -0,0 +1,149 @@
1
+ import { createClient } from "../client.js";
2
+ import { error, handleHttp, json as outJson, jsonRequested, table, } from "../output.js";
3
+ import { resolveSpace } from "../space.js";
4
+ const DEFAULT_DAYS = 30;
5
+ const MAX_DAYS = 365;
6
+ export class InvalidSpaceActivityDaysError extends Error {
7
+ detail;
8
+ constructor(message, detail) {
9
+ super(message);
10
+ this.detail = detail;
11
+ this.name = "InvalidSpaceActivityDaysError";
12
+ }
13
+ }
14
+ /** Shared with the API: any positive day count up to a full leap year. */
15
+ export function parseActivityDays(value) {
16
+ const raw = (value ?? String(DEFAULT_DAYS)).trim();
17
+ if (!/^\d+$/.test(raw)) {
18
+ throw new InvalidSpaceActivityDaysError("Invalid days", "days must be a positive integer");
19
+ }
20
+ const days = Number.parseInt(raw, 10);
21
+ if (days < 1 || days > MAX_DAYS) {
22
+ throw new InvalidSpaceActivityDaysError("Invalid days", `days must be between 1 and ${MAX_DAYS}`);
23
+ }
24
+ return days;
25
+ }
26
+ const formatNumber = (value) => new Intl.NumberFormat("en-US").format(Number(value) || 0);
27
+ const formatCost = (value) => `$${(Number(value) || 0).toFixed(2)}`;
28
+ function relativeTime(timestamp) {
29
+ if (!timestamp)
30
+ return "";
31
+ const at = new Date(timestamp).getTime();
32
+ if (!Number.isFinite(at))
33
+ return "";
34
+ const deltaMs = Date.now() - at;
35
+ const minutes = Math.round(deltaMs / 60_000);
36
+ if (minutes < 1)
37
+ return "just now";
38
+ if (minutes < 60)
39
+ return `${minutes}m ago`;
40
+ const hours = Math.round(minutes / 60);
41
+ if (hours < 24)
42
+ return `${hours}h ago`;
43
+ return `${Math.round(hours / 24)}d ago`;
44
+ }
45
+ export function toContributorRows(items) {
46
+ return items.map((contributor) => ({
47
+ name: contributor.profile?.displayName ||
48
+ contributor.profile?.username ||
49
+ contributor.userUuid,
50
+ role: contributor.role ?? "",
51
+ tokens: formatNumber(contributor.tokens),
52
+ requests: formatNumber(contributor.requests),
53
+ sessions: contributor.sessionCount,
54
+ cost: formatCost(contributor.costTotal),
55
+ lastActive: relativeTime(contributor.lastActiveAt),
56
+ }));
57
+ }
58
+ export function toAppRankingRows(apps) {
59
+ return apps.map((app) => ({
60
+ title: app.title,
61
+ status: app.status,
62
+ views: formatNumber(app.viewCount),
63
+ id: app.appId,
64
+ }));
65
+ }
66
+ function hasCost(activity) {
67
+ return activity.summary.costTotal !== 0;
68
+ }
69
+ export function printActivityReport(activity) {
70
+ const showCost = hasCost(activity);
71
+ console.log(`\n Summary (last ${activity.days} days):`);
72
+ table([activity.summary], [
73
+ { key: "totalTokens", label: "Tokens", format: formatNumber },
74
+ { key: "requestCount", label: "Requests", format: formatNumber },
75
+ ...(showCost ? [{ key: "costTotal", label: "Cost", format: formatCost }] : []),
76
+ { key: "successCount", label: "Success", format: formatNumber },
77
+ { key: "errorCount", label: "Errors", format: formatNumber },
78
+ ]);
79
+ if (activity.contributors.items.length > 0) {
80
+ console.log(`\n Contributors (${activity.contributors.memberCount} members):`);
81
+ table(toContributorRows(activity.contributors.items), [
82
+ { key: "name", label: "Name" },
83
+ { key: "role", label: "Role" },
84
+ { key: "tokens", label: "Tokens" },
85
+ { key: "requests", label: "Requests" },
86
+ { key: "sessions", label: "Sessions" },
87
+ ...(showCost ? [{ key: "cost", label: "Cost" }] : []),
88
+ { key: "lastActive", label: "Last active" },
89
+ ]);
90
+ }
91
+ const { llmModels, generationModels, apps } = activity.rankings;
92
+ if (llmModels.length > 0) {
93
+ console.log("\n Top LLM models:");
94
+ table(llmModels, [
95
+ { key: "model", label: "Model" },
96
+ { key: "provider", label: "Provider" },
97
+ { key: "totalTokens", label: "Tokens", format: formatNumber },
98
+ { key: "requestCount", label: "Requests", format: formatNumber },
99
+ ...(showCost ? [{ key: "costTotal", label: "Cost", format: formatCost }] : []),
100
+ ]);
101
+ }
102
+ if (generationModels.length > 0) {
103
+ console.log("\n Top generation models:");
104
+ table(generationModels, [
105
+ { key: "model", label: "Model" },
106
+ { key: "provider", label: "Provider" },
107
+ { key: "requestCount", label: "Calls", format: formatNumber },
108
+ ...(showCost ? [{ key: "costTotal", label: "Cost", format: formatCost }] : []),
109
+ ]);
110
+ }
111
+ if (apps.length > 0) {
112
+ console.log("\n Most viewed apps:");
113
+ table(toAppRankingRows(apps), [
114
+ { key: "title", label: "App" },
115
+ { key: "views", label: "Views" },
116
+ { key: "status", label: "Status" },
117
+ { key: "id", label: "ID" },
118
+ ]);
119
+ }
120
+ }
121
+ export function registerSpaceActivity(spacesCmd, dependencies = {}) {
122
+ spacesCmd
123
+ .command("activity [days]")
124
+ .description("Space activity overview: usage, contributors, rankings")
125
+ .option("--json", "Output as JSON")
126
+ .action(async (days, opts) => {
127
+ const spaceId = resolveSpace(spacesCmd);
128
+ let parsedDays;
129
+ try {
130
+ parsedDays = parseActivityDays(days);
131
+ }
132
+ catch (cause) {
133
+ if (cause instanceof InvalidSpaceActivityDaysError) {
134
+ return error(cause.message, cause.detail);
135
+ }
136
+ throw cause;
137
+ }
138
+ const client = dependencies.createClient?.() ?? createClient();
139
+ try {
140
+ const activity = await client.space(spaceId).activity.get(parsedDays);
141
+ if (jsonRequested(opts))
142
+ return outJson(activity);
143
+ printActivityReport(activity);
144
+ }
145
+ catch (cause) {
146
+ handleHttp(cause);
147
+ }
148
+ });
149
+ }
@@ -8,6 +8,7 @@ import { createClient } from "../client.js";
8
8
  import { table, json as outJson, jsonRequested, ok, error, handleHttp, formatEpochMs } from "../output.js";
9
9
  import { resolveSpace } from "../space.js";
10
10
  import { registerSpaceCommerce } from "./space-commerce.js";
11
+ import { registerSpaceActivity } from "./space-activity.js";
11
12
  import { registerSpaceInvitations } from "./space-invitations.js";
12
13
  import { registerSpaceTurns } from "./space-turns.js";
13
14
  const cliEnv = resolveCohubEnvironment();
@@ -677,6 +678,8 @@ export function registerSpaces(program) {
677
678
  });
678
679
  // ── spaces commerce ──
679
680
  registerSpaceCommerce(spacesCmd);
681
+ // ── spaces activity ──
682
+ registerSpaceActivity(spacesCmd);
680
683
  // ── spaces usage ──
681
684
  spacesCmd
682
685
  .command("usage [days]")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neta-art/cohub-cli",
3
- "version": "6.1.3",
3
+ "version": "6.1.4",
4
4
  "description": "CLI for Cohub — spaces, sessions, and agent collaboration.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",