@gobi-ai/cli 2.0.29 → 2.0.31

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.
@@ -4,12 +4,12 @@
4
4
  "name": "gobi-ai"
5
5
  },
6
6
  "description": "Claude Code plugin for the Gobi collaborative knowledge platform CLI",
7
- "version": "2.0.29",
7
+ "version": "2.0.31",
8
8
  "plugins": [
9
9
  {
10
10
  "name": "gobi",
11
11
  "description": "Manage the Gobi collaborative knowledge platform from the command line. Publish vault profiles, create posts and replies, generate images and videos.",
12
- "version": "2.0.29",
12
+ "version": "2.0.31",
13
13
  "author": {
14
14
  "name": "gobi-ai"
15
15
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "gobi",
3
3
  "description": "Manage the Gobi collaborative knowledge platform from the command line",
4
- "version": "2.0.29",
4
+ "version": "2.0.31",
5
5
  "author": {
6
6
  "name": "gobi-ai"
7
7
  },
@@ -4,10 +4,13 @@ import { basename, join, extname, isAbsolute, resolve } from "path";
4
4
  import ignore from "ignore";
5
5
  import { WEBDRIVE_BASE_URL } from "./constants.js";
6
6
  import { apiPost } from "./client.js";
7
+ import { normalizeSyncPattern } from "./commands/sync.js";
7
8
  // Best-effort extension → MIME mapping. Anything we don't recognize falls
8
9
  // back to `application/octet-stream`; the backend caps size per content-type
9
- // tier (5MB photos / 15MB GIFs / 512MB video) so it's the authority on what's
10
- // allowed. We're just trying to set a usable Content-Type for the S3 PUT.
10
+ // tier (10MB photos / 15MB GIFs / 512MB video / 250MB document files) so
11
+ // it's the authority on what's allowed. We're just trying to set a usable
12
+ // Content-Type for the S3 PUT — and for document files the declared MIME is
13
+ // what routes the post row into the 'file' kind, so it must be accurate.
11
14
  const POST_MEDIA_MIME_MAP = {
12
15
  ".jpg": "image/jpeg",
13
16
  ".jpeg": "image/jpeg",
@@ -25,20 +28,30 @@ const POST_MEDIA_MIME_MAP = {
25
28
  ".webm": "video/webm",
26
29
  ".m4v": "video/x-m4v",
27
30
  ".pdf": "application/pdf",
31
+ ".md": "text/markdown",
32
+ ".markdown": "text/markdown",
33
+ ".txt": "text/plain",
34
+ ".csv": "text/csv",
28
35
  };
29
- // X-style cap: 4 photos OR 1 GIF OR 1 video. Anything not photo/gif/video
30
- // is treated as "photo" for cap purposes so misclassified files still get
31
- // the 4-cap rather than slipping through unlimited.
36
+ const FILE_EXTENSIONS = [".pdf", ".md", ".markdown", ".txt", ".csv"];
37
+ const VIDEO_EXTENSIONS = [".mp4", ".mov", ".webm", ".m4v"];
38
+ // Mirrors the backend mix rule: up to 4 photos + up to 4 document files
39
+ // (pdf/md/txt/csv) together, OR 1 GIF, OR 1 video — GIF and video are
40
+ // exclusive with everything. Unknown extensions count against the photo cap
41
+ // (the backend's 'other' kind) so nothing slips through unlimited.
32
42
  export function assertPostAttachmentMix(paths) {
33
43
  let photos = 0;
34
44
  let gifs = 0;
35
45
  let videos = 0;
46
+ let files = 0;
36
47
  for (const p of paths) {
37
48
  const ext = extname(p).toLowerCase();
38
49
  if (ext === ".gif")
39
50
  gifs += 1;
40
- else if ([".mp4", ".mov", ".webm", ".m4v"].includes(ext))
51
+ else if (VIDEO_EXTENSIONS.includes(ext))
41
52
  videos += 1;
53
+ else if (FILE_EXTENSIONS.includes(ext))
54
+ files += 1;
42
55
  else
43
56
  photos += 1;
44
57
  }
@@ -48,10 +61,12 @@ export function assertPostAttachmentMix(paths) {
48
61
  throw new Error("Only 1 GIF allowed per post");
49
62
  if (photos > 4)
50
63
  throw new Error("Up to 4 photos allowed per post");
51
- if (videos > 0 && (gifs > 0 || photos > 0)) {
64
+ if (files > 4)
65
+ throw new Error("Up to 4 files allowed per post");
66
+ if (videos > 0 && (gifs > 0 || photos > 0 || files > 0)) {
52
67
  throw new Error("A video can't be combined with other media");
53
68
  }
54
- if (gifs > 0 && (videos > 0 || photos > 0)) {
69
+ if (gifs > 0 && (videos > 0 || photos > 0 || files > 0)) {
55
70
  throw new Error("A GIF can't be combined with other media");
56
71
  }
57
72
  }
@@ -89,7 +104,15 @@ export async function uploadPostAttachment(filePath) {
89
104
  if (!putRes.ok) {
90
105
  throw new Error(`Failed to PUT ${filePath} to S3: HTTP ${putRes.status}`);
91
106
  }
92
- return { mediaUrl, mediaKey };
107
+ const attachment = { mediaUrl, mediaKey };
108
+ // Document files carry name + MIME on the row: the declared mimeType is
109
+ // what the backend trusts for kind/preview routing, and the original
110
+ // filename only survives here (the S3 key is a UUID).
111
+ if (FILE_EXTENSIONS.includes(ext)) {
112
+ attachment.fileName = basename(abs);
113
+ attachment.mimeType = contentType;
114
+ }
115
+ return attachment;
93
116
  }
94
117
  export async function uploadPostAttachments(paths) {
95
118
  const out = [];
@@ -129,8 +152,9 @@ function addToLocalSyncfiles(gobiDir, filePath) {
129
152
  if (isPathCovered(filePath, patterns))
130
153
  return;
131
154
  const syncfilesPath = join(gobiDir, "syncfiles");
132
- appendFileSync(syncfilesPath, `${EOL}${filePath}`);
133
- console.log(`Added to syncfiles: ${filePath}`);
155
+ const pattern = normalizeSyncPattern(filePath);
156
+ appendFileSync(syncfilesPath, `${EOL}${pattern}`);
157
+ console.log(`Added to syncfiles: ${pattern}`);
134
158
  }
135
159
  export async function uploadAttachments(vaultSlug, links, token, options) {
136
160
  const addToSyncfiles = options?.addToSyncfiles ?? false;
package/dist/client.js CHANGED
@@ -38,6 +38,9 @@ export function apiPost(path, body) {
38
38
  export function apiPatch(path, body) {
39
39
  return request("PATCH", path, { body });
40
40
  }
41
+ export function apiPut(path, body) {
42
+ return request("PUT", path, { body });
43
+ }
41
44
  export function apiDelete(path) {
42
45
  return request("DELETE", path);
43
46
  }
@@ -1,6 +1,6 @@
1
1
  import { WEB_BASE_URL } from "../constants.js";
2
- import { apiGet, apiPost, apiPatch, apiDelete } from "../client.js";
3
- import { isJsonMode, jsonOut, readStdin, unwrapResp, } from "./utils.js";
2
+ import { apiGet, apiPost, apiPatch, apiPut, apiDelete } from "../client.js";
3
+ import { formatAttachmentLines, formatAttachmentSummary, formatReactionChips, isJsonMode, jsonOut, readStdin, unwrapResp, } from "./utils.js";
4
4
  import { uploadPostAttachments, assertPostAttachmentMix, } from "../attachments.js";
5
5
  function readContent(value) {
6
6
  if (value === "-")
@@ -26,7 +26,11 @@ function formatFeedLine(m) {
26
26
  else {
27
27
  label = m.title || m.content || "";
28
28
  }
29
- return `${id} ${kind} ${author} "${label}" ${m.createdAt}`;
29
+ const chips = formatReactionChips(m);
30
+ const attachSummary = formatAttachmentSummary(m);
31
+ return (`${id} ${kind} ${author} "${label}" ${m.createdAt}` +
32
+ (attachSummary ? ` ${attachSummary}` : "") +
33
+ (chips ? ` ${chips}` : ""));
30
34
  }
31
35
  export function registerGlobalCommand(program) {
32
36
  const global = program
@@ -145,20 +149,28 @@ export function registerGlobalCommand(program) {
145
149
  `User ${r.authorId}`;
146
150
  const text = r.content || "";
147
151
  const truncated = opts.full || text.length <= 200 ? text : text.slice(0, 200) + "…";
148
- replyLines.push(` - ${rAuthor}: ${truncated} (${r.createdAt})`);
152
+ const rChips = formatReactionChips(r);
153
+ const rAttach = formatAttachmentSummary(r);
154
+ replyLines.push(` - [r:${r.id}] ${rAuthor}: ${truncated} (${r.createdAt})${rAttach ? ` ${rAttach}` : ""}${rChips ? ` ${rChips}` : ""}`);
149
155
  }
150
156
  const isReplyPost = post.parentPostId != null;
151
157
  const heading = isReplyPost
152
158
  ? `Reply [r:${post.id}]`
153
159
  : `Post: ${post.title || "(no title)"}`;
160
+ const postChips = formatReactionChips(post);
161
+ const attachmentLines = formatAttachmentLines(post);
154
162
  const output = [
155
163
  heading,
156
164
  `By: ${author} on ${post.createdAt}`,
165
+ ...(postChips ? [`Reactions: ${postChips}`] : []),
157
166
  ...(ancestorLines.length
158
167
  ? ["", `Ancestors (${ancestors.length} items, root first):`, ...ancestorLines]
159
168
  : []),
160
169
  "",
161
170
  post.content || "",
171
+ ...(attachmentLines.length
172
+ ? ["", `Attachments (${attachmentLines.length}):`, ...attachmentLines]
173
+ : []),
162
174
  "",
163
175
  `Replies (${replies.length} items):`,
164
176
  ...replyLines,
@@ -176,7 +188,7 @@ export function registerGlobalCommand(program) {
176
188
  .option("--content <content>", "Post content (markdown supported, use \"-\" for stdin)")
177
189
  .option("--rich-text <richText>", "Rich-text JSON array (mutually exclusive with --content)")
178
190
  .option("--artifact <artifactId>", "Attach an existing artifact to the post (repeatable). Create artifacts with `gobi artifact create`.", (value, prev = []) => [...prev, value], [])
179
- .option("--attach <file>", "Local media file to attach. Repeatable. X-style mix rule: up to 4 photos OR 1 GIF OR 1 video. Size ceilings: 5MB photos / 15MB GIFs / 512MB video.", (value, prev = []) => [...prev, value], [])
191
+ .option("--attach <file>", "Local media or document file to attach. Repeatable. Mix rule: up to 4 photos + up to 4 document files (pdf/md/txt/csv) OR 1 GIF OR 1 video. Size ceilings: 10MB photos / 15MB GIFs / 512MB video / 250MB files.", (value, prev = []) => [...prev, value], [])
180
192
  .option("--repost-post-id <postId>", "Wrap an existing top-level post as the embedded card on this new post. Composes with --content / --rich-text / --attach (the wrapping author's text + media render above the embedded card). Reposts-of-reposts are collapsed to the transitive root server-side. The referenced post must exist, not be deleted, and not itself be a reply.")
181
193
  .action(async (opts) => {
182
194
  if (!opts.content && !opts.richText) {
@@ -234,7 +246,7 @@ export function registerGlobalCommand(program) {
234
246
  .option("--title <title>", "New title")
235
247
  .option("--content <content>", "New content (markdown supported, use \"-\" for stdin)")
236
248
  .option("--rich-text <richText>", "Rich-text JSON array (mutually exclusive with --content)")
237
- .option("--attach <file>", "Replace the post's media attachments with the given files (existing attachments are removed). Repeatable. X-style mix rule: up to 4 photos OR 1 GIF OR 1 video. Size ceilings: 5MB photos / 15MB GIFs / 512MB video. Omit to leave attachments unchanged.", (value, prev = []) => [...prev, value], [])
249
+ .option("--attach <file>", "Replace the post's media attachments with the given files (existing attachments are removed). Repeatable. Mix rule: up to 4 photos + up to 4 document files (pdf/md/txt/csv) OR 1 GIF OR 1 video. Size ceilings: 10MB photos / 15MB GIFs / 512MB video / 250MB files. Omit to leave attachments unchanged.", (value, prev = []) => [...prev, value], [])
238
250
  .option("--artifact <artifactId>", "Replace the post's artifact attachments with the given artifact(s) (existing artifact attachments are removed). Repeatable. Omit to leave them unchanged. Create artifacts with `gobi artifact create`.", (value, prev = []) => [...prev, value], [])
239
251
  .action(async (postId, opts) => {
240
252
  const wantsAttachChange = !!(opts.attach && opts.attach.length > 0);
@@ -297,7 +309,7 @@ export function registerGlobalCommand(program) {
297
309
  .description("Create a reply to a post in the global feed.")
298
310
  .option("--content <content>", "Reply content (markdown supported, use \"-\" for stdin)")
299
311
  .option("--rich-text <richText>", "Rich-text JSON array (mutually exclusive with --content)")
300
- .option("--attach <file>", "Local media file to attach to this reply. Repeatable. X-style mix rule: up to 4 photos OR 1 GIF OR 1 video. Size ceilings: 5MB photos / 15MB GIFs / 512MB video.", (value, prev = []) => [...prev, value], [])
312
+ .option("--attach <file>", "Local media or document file to attach to this reply. Repeatable. Mix rule: up to 4 photos + up to 4 document files (pdf/md/txt/csv) OR 1 GIF OR 1 video. Size ceilings: 10MB photos / 15MB GIFs / 512MB video / 250MB files.", (value, prev = []) => [...prev, value], [])
301
313
  .action(async (postId, opts) => {
302
314
  if (!opts.content && !opts.richText) {
303
315
  throw new Error("Provide either --content or --rich-text.");
@@ -376,4 +388,34 @@ export function registerGlobalCommand(program) {
376
388
  }
377
389
  console.log(`Reply ${replyId} deleted.`);
378
390
  });
391
+ // ── Reactions (react, unreact) ──
392
+ global
393
+ .command("react <postId> <emoji>")
394
+ .description("Add an emoji reaction to a global-feed post or reply (idempotent). <postId> is the numeric id of a post OR a reply — the [p:N]/[r:N] ids shown in feed output.")
395
+ .action(async (postId, emoji) => {
396
+ const resp = (await apiPut(`/posts/${postId}/reactions`, {
397
+ emoji,
398
+ }));
399
+ const data = unwrapResp(resp);
400
+ if (isJsonMode(global)) {
401
+ jsonOut(data);
402
+ return;
403
+ }
404
+ const chips = formatReactionChips(data);
405
+ console.log(`Reacted ${emoji} to ${postId}.` + (chips ? `\n Now: ${chips}` : ""));
406
+ });
407
+ global
408
+ .command("unreact <postId> <emoji>")
409
+ .description("Remove your emoji reaction from a global-feed post or reply. <postId> is the numeric id of a post OR a reply.")
410
+ .action(async (postId, emoji) => {
411
+ const resp = (await apiDelete(`/posts/${postId}/reactions/${encodeURIComponent(emoji)}`));
412
+ const data = unwrapResp(resp);
413
+ if (isJsonMode(global)) {
414
+ jsonOut(data);
415
+ return;
416
+ }
417
+ const chips = formatReactionChips(data);
418
+ console.log(`Removed ${emoji} reaction from ${postId}.` +
419
+ (chips ? `\n Now: ${chips}` : ""));
420
+ });
379
421
  }
@@ -31,6 +31,15 @@ export function getVaultSlug() {
31
31
  }
32
32
  return vault;
33
33
  }
34
+ /**
35
+ * Starter PUBLISH.md frontmatter. Shared by `vault init` (seed) and
36
+ * `vault publish` (scaffold-on-missing) so the two paths can't drift.
37
+ * `description` is intentionally left blank — the vault won't list publicly
38
+ * until the user fills in both `title` and `description`.
39
+ */
40
+ export function defaultPublishMd(title) {
41
+ return `---\ntitle: ${title}\ntags: []\ndescription:\nthumbnail:\nprompt:\n---\n`;
42
+ }
34
43
  // Per-command requirement markers. Tri-state: true / false override / inherit
35
44
  // from parent. The pre-action warning uses these to decide whether to remind
36
45
  // the user to run `gobi vault init` / `gobi space warp`.
@@ -221,7 +230,7 @@ export async function runVaultInitFlow() {
221
230
  // Create default PUBLISH.md if it doesn't exist
222
231
  const publishPath = join(process.cwd(), "PUBLISH.md");
223
232
  if (!existsSync(publishPath)) {
224
- writeFileSync(publishPath, `---\ntitle: ${vaultName}\ntags: []\ndescription:\nthumbnail:\nprompt:\n---\n`, "utf-8");
233
+ writeFileSync(publishPath, defaultPublishMd(vaultName), "utf-8");
225
234
  console.log("Created PUBLISH.md");
226
235
  }
227
236
  }
@@ -1,5 +1,5 @@
1
- import { apiGet, apiPost, apiPatch, apiDelete } from "../client.js";
2
- import { isJsonMode, jsonOut, readStdin, unwrapResp, } from "./utils.js";
1
+ import { apiGet, apiPost, apiPatch, apiPut, apiDelete } from "../client.js";
2
+ import { formatAttachmentLines, formatAttachmentSummary, formatReactionChips, isJsonMode, jsonOut, readStdin, unwrapResp, } from "./utils.js";
3
3
  import { uploadPostAttachments, assertPostAttachmentMix, } from "../attachments.js";
4
4
  function readContent(value) {
5
5
  if (value === "-")
@@ -22,7 +22,11 @@ function formatFeedLine(m) {
22
22
  else {
23
23
  label = m.title || m.content || "";
24
24
  }
25
- return `${id} ${kind} ${author} "${label}" ${m.createdAt}`;
25
+ const chips = formatReactionChips(m);
26
+ const attachSummary = formatAttachmentSummary(m);
27
+ return (`${id} ${kind} ${author} "${label}" ${m.createdAt}` +
28
+ (attachSummary ? ` ${attachSummary}` : "") +
29
+ (chips ? ` ${chips}` : ""));
26
30
  }
27
31
  export function registerPersonalCommand(program) {
28
32
  const personal = program
@@ -147,20 +151,28 @@ export function registerPersonalCommand(program) {
147
151
  `User ${r.authorId}`;
148
152
  const text = r.content || "";
149
153
  const truncated = opts.full || text.length <= 200 ? text : text.slice(0, 200) + "…";
150
- replyLines.push(` - ${rAuthor}: ${truncated} (${r.createdAt})`);
154
+ const rChips = formatReactionChips(r);
155
+ const rAttach = formatAttachmentSummary(r);
156
+ replyLines.push(` - [r:${r.id}] ${rAuthor}: ${truncated} (${r.createdAt})${rAttach ? ` ${rAttach}` : ""}${rChips ? ` ${rChips}` : ""}`);
151
157
  }
152
158
  const isReplyPost = post.parentPostId != null;
153
159
  const heading = isReplyPost
154
160
  ? `Reply [r:${post.id}] (private)`
155
161
  : `Post: ${post.title || "(no title)"} (private)`;
162
+ const postChips = formatReactionChips(post);
163
+ const attachmentLines = formatAttachmentLines(post);
156
164
  const output = [
157
165
  heading,
158
166
  `By: ${author} on ${post.createdAt}`,
167
+ ...(postChips ? [`Reactions: ${postChips}`] : []),
159
168
  ...(ancestorLines.length
160
169
  ? ["", `Ancestors (${ancestors.length} items, root first):`, ...ancestorLines]
161
170
  : []),
162
171
  "",
163
172
  post.content || "",
173
+ ...(attachmentLines.length
174
+ ? ["", `Attachments (${attachmentLines.length}):`, ...attachmentLines]
175
+ : []),
164
176
  "",
165
177
  `Replies (${replies.length} items):`,
166
178
  ...replyLines,
@@ -184,7 +196,7 @@ export function registerPersonalCommand(program) {
184
196
  .option("--content <content>", "Post content (markdown supported, use \"-\" for stdin)")
185
197
  .option("--rich-text <richText>", "Rich-text JSON array (mutually exclusive with --content)")
186
198
  .option("--artifact <artifactId>", "Attach an existing artifact to the post (repeatable). Create artifacts with `gobi artifact create`.", (value, prev = []) => [...prev, value], [])
187
- .option("--attach <file>", "Local media file to attach. Repeatable. X-style mix rule: up to 4 photos OR 1 GIF OR 1 video. Size ceilings: 5MB photos / 15MB GIFs / 512MB video.", (value, prev = []) => [...prev, value], [])
199
+ .option("--attach <file>", "Local media or document file to attach. Repeatable. Mix rule: up to 4 photos + up to 4 document files (pdf/md/txt/csv) OR 1 GIF OR 1 video. Size ceilings: 10MB photos / 15MB GIFs / 512MB video / 250MB files.", (value, prev = []) => [...prev, value], [])
188
200
  .option("--repost-post-id <postId>", "Wrap an existing top-level post as the embedded card on this new private post. The referenced post must be visible to you (your own personal-space post, a global-feed post, or a post in a space you're a member of). Reposting someone else's personal-space post returns 404.")
189
201
  .action(async (opts) => {
190
202
  if (!opts.content && !opts.richText) {
@@ -245,7 +257,7 @@ export function registerPersonalCommand(program) {
245
257
  .option("--title <title>", "New title")
246
258
  .option("--content <content>", "New content (markdown supported, use \"-\" for stdin)")
247
259
  .option("--rich-text <richText>", "Rich-text JSON array (mutually exclusive with --content)")
248
- .option("--attach <file>", "Replace the post's media attachments with the given files (existing attachments are removed). Repeatable. X-style mix rule: up to 4 photos OR 1 GIF OR 1 video. Size ceilings: 5MB photos / 15MB GIFs / 512MB video. Omit to leave attachments unchanged.", (value, prev = []) => [...prev, value], [])
260
+ .option("--attach <file>", "Replace the post's media attachments with the given files (existing attachments are removed). Repeatable. Mix rule: up to 4 photos + up to 4 document files (pdf/md/txt/csv) OR 1 GIF OR 1 video. Size ceilings: 10MB photos / 15MB GIFs / 512MB video / 250MB files. Omit to leave attachments unchanged.", (value, prev = []) => [...prev, value], [])
249
261
  .option("--artifact <artifactId>", "Replace the post's artifact attachments with the given artifact(s) (existing artifact attachments are removed). Repeatable. Omit to leave them unchanged. Create artifacts with `gobi artifact create`.", (value, prev = []) => [...prev, value], [])
250
262
  .action(async (postId, opts) => {
251
263
  const wantsAttachChange = !!(opts.attach && opts.attach.length > 0);
@@ -313,7 +325,7 @@ export function registerPersonalCommand(program) {
313
325
  .description("Reply to a personal-space post. The reply inherits the parent's private scope automatically.")
314
326
  .option("--content <content>", "Reply content (markdown supported, use \"-\" for stdin)")
315
327
  .option("--rich-text <richText>", "Rich-text JSON array (mutually exclusive with --content)")
316
- .option("--attach <file>", "Local media file to attach to this reply. Repeatable. X-style mix rule: up to 4 photos OR 1 GIF OR 1 video. Size ceilings: 5MB photos / 15MB GIFs / 512MB video.", (value, prev = []) => [...prev, value], [])
328
+ .option("--attach <file>", "Local media or document file to attach to this reply. Repeatable. Mix rule: up to 4 photos + up to 4 document files (pdf/md/txt/csv) OR 1 GIF OR 1 video. Size ceilings: 10MB photos / 15MB GIFs / 512MB video / 250MB files.", (value, prev = []) => [...prev, value], [])
317
329
  .action(async (postId, opts) => {
318
330
  if (!opts.content && !opts.richText) {
319
331
  throw new Error("Provide either --content or --rich-text.");
@@ -392,4 +404,34 @@ export function registerPersonalCommand(program) {
392
404
  }
393
405
  console.log(`Reply ${replyId} deleted.`);
394
406
  });
407
+ // ── Reactions (react, unreact) ──
408
+ personal
409
+ .command("react <postId> <emoji>")
410
+ .description("Add an emoji reaction to a personal-space post or reply (idempotent). <postId> is the numeric id of a post OR a reply.")
411
+ .action(async (postId, emoji) => {
412
+ const resp = (await apiPut(`/posts/${postId}/reactions`, {
413
+ emoji,
414
+ }));
415
+ const data = unwrapResp(resp);
416
+ if (isJsonMode(personal)) {
417
+ jsonOut(data);
418
+ return;
419
+ }
420
+ const chips = formatReactionChips(data);
421
+ console.log(`Reacted ${emoji} to ${postId}.` + (chips ? `\n Now: ${chips}` : ""));
422
+ });
423
+ personal
424
+ .command("unreact <postId> <emoji>")
425
+ .description("Remove your emoji reaction from a personal-space post or reply. <postId> is the numeric id of a post OR a reply.")
426
+ .action(async (postId, emoji) => {
427
+ const resp = (await apiDelete(`/posts/${postId}/reactions/${encodeURIComponent(emoji)}`));
428
+ const data = unwrapResp(resp);
429
+ if (isJsonMode(personal)) {
430
+ jsonOut(data);
431
+ return;
432
+ }
433
+ const chips = formatReactionChips(data);
434
+ console.log(`Removed ${emoji} reaction from ${postId}.` +
435
+ (chips ? `\n Now: ${chips}` : ""));
436
+ });
395
437
  }
@@ -1,7 +1,7 @@
1
1
  import { WEB_BASE_URL } from "../constants.js";
2
- import { apiGet, apiPost, apiPatch, apiDelete } from "../client.js";
2
+ import { apiGet, apiPost, apiPatch, apiPut, apiDelete } from "../client.js";
3
3
  import { requireSpace, selectSpace, setSpaceRequirement, writeSpaceSetting, } from "./init.js";
4
- import { isJsonMode, jsonOut, readStdin, resolveSpaceSlug, unwrapResp, } from "./utils.js";
4
+ import { formatAttachmentLines, formatAttachmentSummary, formatReactionChips, isJsonMode, jsonOut, readStdin, resolveSpaceSlug, unwrapResp, } from "./utils.js";
5
5
  import { uploadPostAttachments, assertPostAttachmentMix, } from "../attachments.js";
6
6
  function readContent(value) {
7
7
  if (value === "-")
@@ -23,7 +23,20 @@ function formatFeedLine(m) {
23
23
  else {
24
24
  label = m.title || m.content || "";
25
25
  }
26
- return `${id} ${kind} ${author} "${label}" ${m.createdAt}`;
26
+ const chips = formatReactionChips(m);
27
+ const attachSummary = formatAttachmentSummary(m);
28
+ return (`${id} ${kind} ${author} "${label}" ${m.createdAt}` +
29
+ (attachSummary ? ` ${attachSummary}` : "") +
30
+ (chips ? ` ${chips}` : ""));
31
+ }
32
+ function parseChannelIdOption(value) {
33
+ if (value == null)
34
+ return undefined;
35
+ const n = Number(value);
36
+ if (!Number.isInteger(n) || n <= 0) {
37
+ throw new Error("--channel must be a positive integer channel id.");
38
+ }
39
+ return n;
27
40
  }
28
41
  export function registerSpaceCommand(program) {
29
42
  const space = program
@@ -179,6 +192,7 @@ export function registerSpaceCommand(program) {
179
192
  .description("List the unified feed (posts and replies, newest first) in a space.")
180
193
  .option("--limit <number>", "Items per page", "20")
181
194
  .option("--cursor <string>", "Pagination cursor from previous response")
195
+ .option("--channel <channelId>", "Channel id to read instead of the main feed (see `list-channels`). Omit for the main feed.")
182
196
  .option("--space-slug <spaceSlug>", "Space slug (overrides .gobi/settings.yaml)")
183
197
  .action(async (opts) => {
184
198
  const spaceSlug = resolveSpaceSlug(space, opts);
@@ -187,6 +201,7 @@ export function registerSpaceCommand(program) {
187
201
  };
188
202
  if (opts.cursor)
189
203
  params.cursor = opts.cursor;
204
+ params.channelId = parseChannelIdOption(opts.channel);
190
205
  const resp = (await apiGet(`/spaces/${spaceSlug}/feed`, params));
191
206
  if (isJsonMode(space)) {
192
207
  jsonOut({
@@ -251,20 +266,28 @@ export function registerSpaceCommand(program) {
251
266
  const body = opts.full || !text || text.length <= 200
252
267
  ? text
253
268
  : text.slice(0, 200) + "…";
254
- replyLines.push(` - ${rAuthor}: ${body} (${r.createdAt})`);
269
+ const rChips = formatReactionChips(r);
270
+ const rAttach = formatAttachmentSummary(r);
271
+ replyLines.push(` - [r:${r.id}] ${rAuthor}: ${body} (${r.createdAt})${rAttach ? ` ${rAttach}` : ""}${rChips ? ` ${rChips}` : ""}`);
255
272
  }
256
273
  const isReplyPost = post.parentPostId != null;
257
274
  const heading = isReplyPost
258
275
  ? `Reply [r:${post.id}]`
259
276
  : `Post: ${post.title || "(no title)"}`;
277
+ const postChips = formatReactionChips(post);
278
+ const attachmentLines = formatAttachmentLines(post);
260
279
  const output = [
261
280
  heading,
262
281
  `By: ${author} on ${post.createdAt}`,
282
+ ...(postChips ? [`Reactions: ${postChips}`] : []),
263
283
  ...(ancestorLines.length
264
284
  ? ["", `Ancestors (${ancestors.length} items, root first):`, ...ancestorLines]
265
285
  : []),
266
286
  "",
267
287
  post.content || "",
288
+ ...(attachmentLines.length
289
+ ? ["", `Attachments (${attachmentLines.length}):`, ...attachmentLines]
290
+ : []),
268
291
  "",
269
292
  `Replies (${replies.length} items):`,
270
293
  ...replyLines,
@@ -277,6 +300,7 @@ export function registerSpaceCommand(program) {
277
300
  .description("List posts in a space (paginated).")
278
301
  .option("--limit <number>", "Items per page", "20")
279
302
  .option("--cursor <string>", "Pagination cursor from previous response")
303
+ .option("--channel <channelId>", "Channel id to read instead of the main feed (see `list-channels`). Omit for the main feed.")
280
304
  .option("--space-slug <spaceSlug>", "Space slug (overrides .gobi/settings.yaml)")
281
305
  .action(async (opts) => {
282
306
  const spaceSlug = resolveSpaceSlug(space, opts);
@@ -285,6 +309,7 @@ export function registerSpaceCommand(program) {
285
309
  };
286
310
  if (opts.cursor)
287
311
  params.cursor = opts.cursor;
312
+ params.channelId = parseChannelIdOption(opts.channel);
288
313
  const resp = (await apiGet(`/spaces/${spaceSlug}/posts`, params));
289
314
  if (isJsonMode(space)) {
290
315
  jsonOut({
@@ -304,7 +329,8 @@ export function registerSpaceCommand(program) {
304
329
  for (const t of items) {
305
330
  const author = t.author?.name ||
306
331
  `User ${t.authorId}`;
307
- lines.push(`- [${t.id}] "${t.title}" by ${author} (${t.replyCount} replies, ${t.createdAt})`);
332
+ const attachSummary = formatAttachmentSummary(t);
333
+ lines.push(`- [${t.id}] "${t.title}" by ${author} (${t.replyCount} replies, ${t.createdAt})${attachSummary ? ` ${attachSummary}` : ""}`);
308
334
  }
309
335
  const footer = pagination.hasMore ? `\n Next cursor: ${pagination.nextCursor}` : "";
310
336
  console.log(`Posts (${items.length} items):\n` + lines.join("\n") + footer);
@@ -317,8 +343,9 @@ export function registerSpaceCommand(program) {
317
343
  .option("--rich-text <richText>", "Rich-text JSON array (mutually exclusive with --content)")
318
344
  .option("--artifact <artifactId>", "Attach an existing artifact to the post (repeatable). Create artifacts with `gobi artifact create`.", (value, prev = []) => [...prev, value], [])
319
345
  .option("--space-slug <spaceSlug>", "Space slug (overrides .gobi/settings.yaml)")
320
- .option("--attach <file>", "Local media file to attach. Repeatable. X-style mix rule: up to 4 photos OR 1 GIF OR 1 video. Size ceilings: 5MB photos / 15MB GIFs / 512MB video.", (value, prev = []) => [...prev, value], [])
346
+ .option("--attach <file>", "Local media or document file to attach. Repeatable. Mix rule: up to 4 photos + up to 4 document files (pdf/md/txt/csv) OR 1 GIF OR 1 video. Size ceilings: 10MB photos / 15MB GIFs / 512MB video / 250MB files.", (value, prev = []) => [...prev, value], [])
321
347
  .option("--repost-post-id <postId>", "Wrap an existing top-level post as the embedded card on this new post. Composes with --content / --rich-text / --attach (the wrapping author's text + media render above the embedded card). Reposts-of-reposts are collapsed to the transitive root server-side. The referenced post must exist, not be deleted, and not itself be a reply.")
348
+ .option("--channel <channelId>", "Channel id to post into (see `list-channels`). Omit to post to the space's main feed. You must be able to see the channel (member, space owner/admin, or the space agent on an agent-enabled channel).")
322
349
  .action(async (opts) => {
323
350
  if (!opts.content && !opts.richText) {
324
351
  throw new Error("Provide either --content or --rich-text.");
@@ -355,6 +382,9 @@ export function registerSpaceCommand(program) {
355
382
  }
356
383
  body.repostPostId = n;
357
384
  }
385
+ const channelId = parseChannelIdOption(opts.channel);
386
+ if (channelId != null)
387
+ body.channelId = channelId;
358
388
  const spaceSlug = resolveSpaceSlug(space, opts);
359
389
  const resp = (await apiPost(`/spaces/${spaceSlug}/posts`, body));
360
390
  const post = unwrapResp(resp);
@@ -376,7 +406,7 @@ export function registerSpaceCommand(program) {
376
406
  .option("--content <content>", "New content for the post (markdown supported, use \"-\" for stdin)")
377
407
  .option("--rich-text <richText>", "Rich-text JSON array (mutually exclusive with --content)")
378
408
  .option("--space-slug <spaceSlug>", "Space slug (overrides .gobi/settings.yaml)")
379
- .option("--attach <file>", "Replace the post's media attachments with the given files (existing attachments are removed). Repeatable. X-style mix rule: up to 4 photos OR 1 GIF OR 1 video. Size ceilings: 5MB photos / 15MB GIFs / 512MB video. Omit to leave attachments unchanged.", (value, prev = []) => [...prev, value], [])
409
+ .option("--attach <file>", "Replace the post's media attachments with the given files (existing attachments are removed). Repeatable. Mix rule: up to 4 photos + up to 4 document files (pdf/md/txt/csv) OR 1 GIF OR 1 video. Size ceilings: 10MB photos / 15MB GIFs / 512MB video / 250MB files. Omit to leave attachments unchanged.", (value, prev = []) => [...prev, value], [])
380
410
  .option("--artifact <artifactId>", "Replace the post's artifact attachments with the given artifact(s) (existing artifact attachments are removed). Repeatable. Omit to leave them unchanged. Create artifacts with `gobi artifact create`.", (value, prev = []) => [...prev, value], [])
381
411
  .action(async (postId, opts) => {
382
412
  const wantsAttachChange = !!(opts.attach && opts.attach.length > 0);
@@ -445,7 +475,7 @@ export function registerSpaceCommand(program) {
445
475
  .option("--content <content>", "Reply content (markdown supported, use \"-\" for stdin)")
446
476
  .option("--rich-text <richText>", "Rich-text JSON array (mutually exclusive with --content)")
447
477
  .option("--space-slug <spaceSlug>", "Space slug (overrides .gobi/settings.yaml)")
448
- .option("--attach <file>", "Local media file to attach to this reply. Repeatable. X-style mix rule: up to 4 photos OR 1 GIF OR 1 video. Size ceilings: 5MB photos / 15MB GIFs / 512MB video.", (value, prev = []) => [...prev, value], [])
478
+ .option("--attach <file>", "Local media or document file to attach to this reply. Repeatable. Mix rule: up to 4 photos + up to 4 document files (pdf/md/txt/csv) OR 1 GIF OR 1 video. Size ceilings: 10MB photos / 15MB GIFs / 512MB video / 250MB files.", (value, prev = []) => [...prev, value], [])
449
479
  .action(async (postId, opts) => {
450
480
  if (!opts.content && !opts.richText) {
451
481
  throw new Error("Provide either --content or --rich-text.");
@@ -530,4 +560,113 @@ export function registerSpaceCommand(program) {
530
560
  }
531
561
  console.log(`Reply ${replyId} deleted.`);
532
562
  });
563
+ // ── Reactions (react, unreact) ──
564
+ space
565
+ .command("react <postId> <emoji>")
566
+ .description("Add an emoji reaction to a post or reply (idempotent). <postId> is the numeric id of a post OR a reply — the [p:N]/[r:N] ids shown in feed output.")
567
+ .option("--space-slug <spaceSlug>", "Space slug (overrides .gobi/settings.yaml)")
568
+ .action(async (postId, emoji, opts) => {
569
+ const spaceSlug = resolveSpaceSlug(space, opts);
570
+ const resp = (await apiPut(`/spaces/${spaceSlug}/posts/${postId}/reactions`, { emoji }));
571
+ const data = unwrapResp(resp);
572
+ if (isJsonMode(space)) {
573
+ jsonOut(data);
574
+ return;
575
+ }
576
+ const chips = formatReactionChips(data);
577
+ console.log(`Reacted ${emoji} to ${postId}.` + (chips ? `\n Now: ${chips}` : ""));
578
+ });
579
+ space
580
+ .command("unreact <postId> <emoji>")
581
+ .description("Remove your emoji reaction from a post or reply. <postId> is the numeric id of a post OR a reply.")
582
+ .option("--space-slug <spaceSlug>", "Space slug (overrides .gobi/settings.yaml)")
583
+ .action(async (postId, emoji, opts) => {
584
+ const spaceSlug = resolveSpaceSlug(space, opts);
585
+ const resp = (await apiDelete(`/spaces/${spaceSlug}/posts/${postId}/reactions/${encodeURIComponent(emoji)}`));
586
+ const data = unwrapResp(resp);
587
+ if (isJsonMode(space)) {
588
+ jsonOut(data);
589
+ return;
590
+ }
591
+ const chips = formatReactionChips(data);
592
+ console.log(`Removed ${emoji} reaction from ${postId}.` +
593
+ (chips ? `\n Now: ${chips}` : ""));
594
+ });
595
+ // ── Channels (read-only; channel admin is web-UI only) ──
596
+ //
597
+ // Private member-gated sub-feeds inside a space. The main feed is virtual
598
+ // (no channel row): feed/list-posts/create-post without --channel target
599
+ // it. Members see their channels; space owners/admins see all; the space
600
+ // agent sees agent-enabled channels only. Create/rename/delete and roster
601
+ // management are deliberately not exposed here — like space and member
602
+ // admin, that's web-UI territory.
603
+ space
604
+ .command("list-channels")
605
+ .description("List channels visible to you in a space (members: yours; space owner/admin: all). The main feed is not a channel — read it by omitting --channel on `feed`.")
606
+ .option("--space-slug <spaceSlug>", "Space slug (overrides .gobi/settings.yaml)")
607
+ .action(async (opts) => {
608
+ const spaceSlug = resolveSpaceSlug(space, opts);
609
+ const resp = (await apiGet(`/spaces/${spaceSlug}/channels`));
610
+ const items = (resp.data || []);
611
+ if (isJsonMode(space)) {
612
+ jsonOut(items);
613
+ return;
614
+ }
615
+ if (!items.length) {
616
+ console.log("No channels found.");
617
+ return;
618
+ }
619
+ const lines = [];
620
+ for (const c of items) {
621
+ const flags = [
622
+ `${c.memberCount} member${c.memberCount === 1 ? "" : "s"}`,
623
+ c.isMember ? "member: you" : "member: no",
624
+ c.agentAccess ? "agent: on" : "agent: off",
625
+ ].join(", ");
626
+ lines.push(`- [${c.id}] #${c.name} (${flags})`);
627
+ if (c.description)
628
+ lines.push(` Description: ${c.description}`);
629
+ }
630
+ console.log(`Channels (${items.length}):\n` + lines.join("\n"));
631
+ });
632
+ space
633
+ .command("get-channel <channelId>")
634
+ .description("Get one channel (channel members, space owner/admin, or the agent on agent-enabled channels).")
635
+ .option("--space-slug <spaceSlug>", "Space slug (overrides .gobi/settings.yaml)")
636
+ .action(async (channelId, opts) => {
637
+ const spaceSlug = resolveSpaceSlug(space, opts);
638
+ const resp = (await apiGet(`/spaces/${spaceSlug}/channels/${channelId}`));
639
+ const c = unwrapResp(resp);
640
+ if (isJsonMode(space)) {
641
+ jsonOut(c);
642
+ return;
643
+ }
644
+ const desc = c.description ? `\n Description: ${c.description}` : "";
645
+ console.log(`Channel [${c.id}] #${c.name}${desc}\n` +
646
+ ` Agent access: ${c.agentAccess ? "on" : "off"}\n` +
647
+ ` Created: ${c.createdAt}`);
648
+ });
649
+ space
650
+ .command("list-channel-members <channelId>")
651
+ .description("List the members of a channel.")
652
+ .option("--space-slug <spaceSlug>", "Space slug (overrides .gobi/settings.yaml)")
653
+ .action(async (channelId, opts) => {
654
+ const spaceSlug = resolveSpaceSlug(space, opts);
655
+ const resp = (await apiGet(`/spaces/${spaceSlug}/channels/${channelId}/members`));
656
+ const items = (resp.data || []);
657
+ if (isJsonMode(space)) {
658
+ jsonOut(items);
659
+ return;
660
+ }
661
+ if (!items.length) {
662
+ console.log("No members found.");
663
+ return;
664
+ }
665
+ const lines = [];
666
+ for (const m of items) {
667
+ const user = (m.user || {});
668
+ lines.push(`- [${m.userId}] ${user.name || "Unknown"} (joined ${m.createdAt})`);
669
+ }
670
+ console.log(`Channel members (${items.length}):\n` + lines.join("\n"));
671
+ });
533
672
  }