@gobi-ai/cli 2.0.30 → 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.30",
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.30",
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.30",
4
+ "version": "2.0.31",
5
5
  "author": {
6
6
  "name": "gobi-ai"
7
7
  },
@@ -7,8 +7,10 @@ import { apiPost } from "./client.js";
7
7
  import { normalizeSyncPattern } from "./commands/sync.js";
8
8
  // Best-effort extension → MIME mapping. Anything we don't recognize falls
9
9
  // back to `application/octet-stream`; the backend caps size per content-type
10
- // tier (5MB photos / 15MB GIFs / 512MB video) so it's the authority on what's
11
- // 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.
12
14
  const POST_MEDIA_MIME_MAP = {
13
15
  ".jpg": "image/jpeg",
14
16
  ".jpeg": "image/jpeg",
@@ -26,20 +28,30 @@ const POST_MEDIA_MIME_MAP = {
26
28
  ".webm": "video/webm",
27
29
  ".m4v": "video/x-m4v",
28
30
  ".pdf": "application/pdf",
31
+ ".md": "text/markdown",
32
+ ".markdown": "text/markdown",
33
+ ".txt": "text/plain",
34
+ ".csv": "text/csv",
29
35
  };
30
- // X-style cap: 4 photos OR 1 GIF OR 1 video. Anything not photo/gif/video
31
- // is treated as "photo" for cap purposes so misclassified files still get
32
- // 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.
33
42
  export function assertPostAttachmentMix(paths) {
34
43
  let photos = 0;
35
44
  let gifs = 0;
36
45
  let videos = 0;
46
+ let files = 0;
37
47
  for (const p of paths) {
38
48
  const ext = extname(p).toLowerCase();
39
49
  if (ext === ".gif")
40
50
  gifs += 1;
41
- else if ([".mp4", ".mov", ".webm", ".m4v"].includes(ext))
51
+ else if (VIDEO_EXTENSIONS.includes(ext))
42
52
  videos += 1;
53
+ else if (FILE_EXTENSIONS.includes(ext))
54
+ files += 1;
43
55
  else
44
56
  photos += 1;
45
57
  }
@@ -49,10 +61,12 @@ export function assertPostAttachmentMix(paths) {
49
61
  throw new Error("Only 1 GIF allowed per post");
50
62
  if (photos > 4)
51
63
  throw new Error("Up to 4 photos allowed per post");
52
- 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)) {
53
67
  throw new Error("A video can't be combined with other media");
54
68
  }
55
- if (gifs > 0 && (videos > 0 || photos > 0)) {
69
+ if (gifs > 0 && (videos > 0 || photos > 0 || files > 0)) {
56
70
  throw new Error("A GIF can't be combined with other media");
57
71
  }
58
72
  }
@@ -90,7 +104,15 @@ export async function uploadPostAttachment(filePath) {
90
104
  if (!putRes.ok) {
91
105
  throw new Error(`Failed to PUT ${filePath} to S3: HTTP ${putRes.status}`);
92
106
  }
93
- 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;
94
116
  }
95
117
  export async function uploadPostAttachments(paths) {
96
118
  const out = [];
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
  }
@@ -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
  }
@@ -28,3 +28,73 @@ export function unwrapResp(resp) {
28
28
  }
29
29
  return resp;
30
30
  }
31
+ // Compact reaction chips for single-line output: `👍2* 🎉1` — the trailing
32
+ // `*` marks emojis the caller has reacted with (remove via `unreact`).
33
+ export function formatReactionChips(m) {
34
+ const reactions = m.reactions || [];
35
+ if (!Array.isArray(reactions) || !reactions.length)
36
+ return "";
37
+ return reactions
38
+ .map((r) => `${r.emoji}${r.count}${r.reactedByMe ? "*" : ""}`)
39
+ .join(" ");
40
+ }
41
+ // Classify a wire attachment the way the clients do: declared mimeType first,
42
+ // then the media-url extension (covers rows that predate the mimeType field).
43
+ function attachmentKind(a) {
44
+ if (a.artifact)
45
+ return "artifact";
46
+ const mime = (a.mimeType || "").toLowerCase();
47
+ const url = (a.mediaUrl || "").toLowerCase().split("?")[0];
48
+ if (mime === "image/gif" || (!mime && url.endsWith(".gif")))
49
+ return "gif";
50
+ if (mime.startsWith("image/"))
51
+ return "photo";
52
+ if (mime.startsWith("video/"))
53
+ return "video";
54
+ if (mime)
55
+ return "file";
56
+ if (/\.(jpe?g|png|webp|heic|heif|avif|svg|bmp|tiff)$/.test(url))
57
+ return "photo";
58
+ if (/\.(mp4|mov|webm|m4v)$/.test(url))
59
+ return "video";
60
+ if (/\.(pdf|md|txt|csv)$/.test(url))
61
+ return "file";
62
+ return "media";
63
+ }
64
+ // Compact attachment marker for single-line output: `📎 2 photos, 1 file`.
65
+ // Counts per kind; full URLs are in `get-post` (or --json).
66
+ export function formatAttachmentSummary(m) {
67
+ const attachments = m.attachments || [];
68
+ if (!Array.isArray(attachments) || !attachments.length)
69
+ return "";
70
+ const counts = new Map();
71
+ for (const a of attachments) {
72
+ const kind = attachmentKind(a);
73
+ counts.set(kind, (counts.get(kind) ?? 0) + 1);
74
+ }
75
+ const parts = [];
76
+ for (const [kind, count] of counts) {
77
+ parts.push(count === 1 ? `1 ${kind}` : `${count} ${kind}s`);
78
+ }
79
+ return `📎 ${parts.join(", ")}`;
80
+ }
81
+ // One line per attachment with the fetchable URL — used by `get-post` so an
82
+ // agent can download/describe the media without re-querying in --json mode.
83
+ export function formatAttachmentLines(m, indent = " ") {
84
+ const attachments = m.attachments || [];
85
+ if (!Array.isArray(attachments) || !attachments.length)
86
+ return [];
87
+ return attachments.map((a) => {
88
+ const kind = attachmentKind(a);
89
+ if (kind === "artifact") {
90
+ const art = a.artifact;
91
+ const title = art.title ? ` "${art.title}"` : "";
92
+ const status = art.isPublished ? "published" : "unpublished";
93
+ return `${indent}- artifact [${art.kind}]${title} (${art.artifactId}, ${status})${art.mediaUrl ? ` — ${art.mediaUrl}` : ""}`;
94
+ }
95
+ const dims = a.width && a.height ? ` ${a.width}×${a.height}` : "";
96
+ const name = a.fileName ? ` "${a.fileName}"` : "";
97
+ const mime = a.mimeType ? ` (${a.mimeType})` : "";
98
+ return `${indent}- ${kind}${name}${dims}${mime} — ${a.mediaUrl}`;
99
+ });
100
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gobi-ai/cli",
3
- "version": "2.0.30",
3
+ "version": "2.0.31",
4
4
  "description": "CLI client for the Gobi collaborative knowledge platform",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -9,12 +9,12 @@ description: >-
9
9
  allowed-tools: Bash(gobi:*)
10
10
  metadata:
11
11
  author: gobi-ai
12
- version: "2.0.30"
12
+ version: "2.0.31"
13
13
  ---
14
14
 
15
15
  # gobi-artifact
16
16
 
17
- Gobi artifact commands for versioned, post-attachable creations (v2.0.30).
17
+ Gobi artifact commands for versioned, post-attachable creations (v2.0.31).
18
18
 
19
19
  Requires gobi-cli installed and authenticated. See gobi-core skill for setup.
20
20
 
@@ -8,12 +8,12 @@ description: >-
8
8
  allowed-tools: Bash(gobi:*)
9
9
  metadata:
10
10
  author: gobi-ai
11
- version: "2.0.30"
11
+ version: "2.0.31"
12
12
  ---
13
13
 
14
14
  # gobi-core
15
15
 
16
- Core CLI commands for the Gobi collaborative knowledge platform (v2.0.30).
16
+ Core CLI commands for the Gobi collaborative knowledge platform (v2.0.31).
17
17
 
18
18
  ## Prerequisites
19
19
 
@@ -6,13 +6,13 @@ Usage: gobi space [options] [command]
6
6
  Space commands (posts, replies). Space and member admin is web-UI only.
7
7
 
8
8
  Options:
9
- --space-slug <spaceSlug> Space slug (overrides .gobi/settings.yaml)
10
- -h, --help display help for command
9
+ --space-slug <spaceSlug> Space slug (overrides .gobi/settings.yaml)
10
+ -h, --help display help for command
11
11
 
12
12
  Commands:
13
- list List spaces you are a member of.
14
- warp [spaceSlug] Select the active space. Pass a slug to warp directly, or omit for interactive selection.
15
- help [command] display help for command
13
+ list List spaces you are a member of.
14
+ warp [spaceSlug] Select the active space. Pass a slug to warp directly, or omit for interactive selection.
15
+ help [command] display help for command
16
16
  ```
17
17
 
18
18
  ## list
@@ -7,7 +7,7 @@ description: >-
7
7
  allowed-tools: Bash(gobi:*)
8
8
  metadata:
9
9
  author: gobi-ai
10
- version: "2.0.30"
10
+ version: "2.0.31"
11
11
  ---
12
12
 
13
13
  # Gobi Homepage Developer Guide
@@ -10,12 +10,12 @@ description: >-
10
10
  allowed-tools: Bash(gobi:*)
11
11
  metadata:
12
12
  author: gobi-ai
13
- version: "2.0.30"
13
+ version: "2.0.31"
14
14
  ---
15
15
 
16
16
  # gobi-media
17
17
 
18
- Gobi media generation commands (v2.0.30).
18
+ Gobi media generation commands (v2.0.31).
19
19
 
20
20
  Requires gobi-cli installed and authenticated. See gobi-core skill for setup.
21
21
 
@@ -7,12 +7,12 @@ description: >-
7
7
  allowed-tools: Bash(gobi:*)
8
8
  metadata:
9
9
  author: gobi-ai
10
- version: "2.0.30"
10
+ version: "2.0.31"
11
11
  ---
12
12
 
13
13
  # gobi-sense
14
14
 
15
- Gobi sense commands for activity and transcription data (v2.0.30).
15
+ Gobi sense commands for activity and transcription data (v2.0.31).
16
16
 
17
17
  Requires gobi-cli installed and authenticated. See the **gobi-core** skill for setup.
18
18
 
@@ -11,12 +11,12 @@ description: >-
11
11
  allowed-tools: Bash(gobi:*)
12
12
  metadata:
13
13
  author: gobi-ai
14
- version: "2.0.30"
14
+ version: "2.0.31"
15
15
  ---
16
16
 
17
17
  # gobi-space
18
18
 
19
- Gobi space, global, and personal-space posts (v2.0.30).
19
+ Gobi space, global, and personal-space posts (v2.0.31).
20
20
 
21
21
  Requires gobi-cli installed and authenticated. See the **gobi-core** skill for setup.
22
22
 
@@ -51,13 +51,15 @@ Posts have no vault attribution. Both `create-post` and `edit-post` across all t
51
51
 
52
52
  > **Wiki-link uploads moved to artifacts.** The `--auto-attachments` flag that used to upload `[[wiki-linked files]]` from a post body now lives on `gobi artifact create` / `gobi artifact revise` (markdown kinds). To publish a markdown creation with resolvable wikilinks, create a markdown artifact with `--vault-slug` + `--auto-attachments` and attach it to the post (`--post-id`). See the **gobi-artifact** skill. Before relying on wikilink resolution, confirm the anchor vault is published: `gobi --json vault status --vault-slug <slug>` should report `isPublished: true`.
53
53
 
54
- ## Post media attachments (`--attach`)
54
+ ## Post media + file attachments (`--attach`)
55
55
 
56
- `create-post` and `create-reply` across all three scopes (`gobi space`, `gobi global`, `gobi personal`) accept `--attach <file>` (repeatable) for inline post mediathe photos/GIF/video that render in-feed alongside the post body. The CLI uploads each file to S3 via `POST /posts/upload-url` and passes the resulting `{ mediaUrl, mediaKey }` array as the post's `attachments`.
56
+ `create-post` and `create-reply` across all three scopes (`gobi space`, `gobi global`, `gobi personal`) accept `--attach <file>` (repeatable) for inline post attachments — photos/GIF/video that render in-feed alongside the post body, and document files (**pdf/md/txt/csv**) that render as Slack-style file cards. The CLI uploads each file to S3 via `POST /posts/upload-url`; document files additionally carry `fileName` + `mimeType` on the row (the S3 key is a UUID, so that's the only place the original name survives).
57
57
 
58
- X-style mix rule (enforced client-side before upload): up to **4 photos** OR **1 GIF** OR **1 video** — they don't combine. Server-side ceilings: 5MB photos, 15MB GIFs, 512MB video.
58
+ Mix rule (enforced client-side before upload): up to **4 photos + 4 document files** together, OR **1 GIF**, OR **1 video** — GIF and video are exclusive with everything. Size ceilings: 10MB photos, 15MB GIFs, 512MB video, 250MB document files.
59
59
 
60
- Use `--attach` for media you want shown in the post itself; use a markdown **artifact** (`gobi artifact create`) for `[[wikilinks]]`-bearing creations attached to the post.
60
+ Use `--attach` for media/files you want shown in the post itself; use a markdown **artifact** (`gobi artifact create`) for `[[wikilinks]]`-bearing creations attached to the post.
61
+
62
+ **Reading attachments back:** feed and list lines show a compact marker like `📎 2 photos, 1 file`; `get-post` prints an `Attachments (N):` block with one line per attachment — kind, original fileName, dimensions/MIME, and the fetchable CDN URL (artifact attachments show kind/title/artifactId instead). In `--json` mode the full `attachments` array is on every post/reply object.
61
63
 
62
64
  ## Public link formats
63
65
 
@@ -106,7 +108,7 @@ gobi --json space list-posts
106
108
  - `gobi space list-topic-posts` — List posts tagged with a topic in a space (cursor-paginated).
107
109
 
108
110
  ### Feed
109
- - `gobi space feed` — List the unified feed (posts and replies, newest first).
111
+ - `gobi space feed` — List the unified feed (posts and replies, newest first). `--channel <channelId>` reads a channel's feed instead of the main feed.
110
112
 
111
113
  ### Space posts
112
114
  - `gobi space list-posts` — List posts in a space (paginated).
@@ -120,6 +122,22 @@ gobi --json space list-posts
120
122
  - `gobi space edit-reply <replyId>` — Edit a reply. You must be the author.
121
123
  - `gobi space delete-reply <replyId>` — Delete a reply. You must be the author.
122
124
 
125
+ ### Reactions
126
+
127
+ Emoji reactions on posts **and** replies, across all three scopes (`gobi space`, `gobi global`, `gobi personal`). The id is the bare number from the `[p:N]`/`[r:N]` ids in feed output. Feed and `get-post` lines render existing reactions as compact chips like `👍2* 🎉1` — the count follows each emoji, and a trailing `*` marks ones you reacted with.
128
+
129
+ - `gobi space react <postId> <emoji>` — Add a reaction (idempotent; re-reacting with the same emoji is a no-op).
130
+ - `gobi space unreact <postId> <emoji>` — Remove your reaction. Pass the emoji literally (`gobi space unreact 123 👍`).
131
+
132
+ ### Channels (space scope only)
133
+
134
+ Channels are private, member-gated sub-feeds inside a space. The **main feed is not a channel** — `feed` / `list-posts` / `create-post` without `--channel` target it, as always. Members see their own channels; space owners/admins see all of them; the space agent sees only channels with agent access enabled. Posting into a channel requires being able to see it. Channel administration (create, rename, delete, add/remove members, leave) is web-UI only — like space and member admin, the CLI deliberately doesn't expose it.
135
+
136
+ - `gobi space list-channels` — List channels visible to you (shows member count, your membership, agent access).
137
+ - `gobi space get-channel <channelId>` — Get one channel's details.
138
+ - `gobi space list-channel-members <channelId>` — List a channel's members.
139
+ - `--channel <channelId>` on `feed`, `list-posts`, and `create-post` reads/writes that channel instead of the main feed.
140
+
123
141
  ### Personal posts (global feed)
124
142
 
125
143
  `gobi global` is the same surface for Personal Posts — posts that live on the author's profile and surface in the public global feed.
@@ -157,8 +175,9 @@ Most posts and replies are publicly visible — in a community space (`gobi spac
157
175
  - `create-post` / `create-reply` — content goes live on submission.
158
176
  - `edit-post` / `edit-reply` — confirm the *new* content; people who already saw the original may re-see it.
159
177
  - `delete-post` / `delete-reply` — irreversible. Flag that explicitly and confirm the target id before running.
178
+ - `react` / `unreact` are lightweight and reversible — when the user asked for the reaction, no extra confirmation needed.
160
179
 
161
- Read-only commands (`list-posts`, `get-post`, `feed`, `list-topics`, `list-topic-posts`, `get`) run without confirmation.
180
+ Read-only commands (`list-posts`, `get-post`, `feed`, `list-topics`, `list-topic-posts`, `get`, `list-channels`, `get-channel`, `list-channel-members`) run without confirmation.
162
181
 
163
182
  ## Reference Documentation
164
183
 
@@ -18,6 +18,8 @@ Commands:
18
18
  create-reply [options] <postId> Create a reply to a post in the global feed.
19
19
  edit-reply [options] <replyId> Edit a reply you authored in the global feed.
20
20
  delete-reply <replyId> Delete a reply you authored in the global feed.
21
+ react <postId> <emoji> 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.
22
+ unreact <postId> <emoji> Remove your emoji reaction from a global-feed post or reply. <postId> is the numeric id of a post OR a reply.
21
23
  help [command] display help for command
22
24
  ```
23
25
 
@@ -75,7 +77,8 @@ Options:
75
77
  --content <content> Post content (markdown supported, use "-" for stdin)
76
78
  --rich-text <richText> Rich-text JSON array (mutually exclusive with --content)
77
79
  --artifact <artifactId> Attach an existing artifact to the post (repeatable). Create artifacts with `gobi artifact create`. (default: [])
78
- --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. (default: [])
80
+ --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 /
81
+ 15MB GIFs / 512MB video / 250MB files. (default: [])
79
82
  --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
80
83
  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.
81
84
  -h, --help display help for command
@@ -92,8 +95,8 @@ Options:
92
95
  --title <title> New title
93
96
  --content <content> New content (markdown supported, use "-" for stdin)
94
97
  --rich-text <richText> Rich-text JSON array (mutually exclusive with --content)
95
- --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
96
- ceilings: 5MB photos / 15MB GIFs / 512MB video. Omit to leave attachments unchanged. (default: [])
98
+ --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)
99
+ OR 1 GIF OR 1 video. Size ceilings: 10MB photos / 15MB GIFs / 512MB video / 250MB files. Omit to leave attachments unchanged. (default: [])
97
100
  --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
98
101
  with `gobi artifact create`. (default: [])
99
102
  -h, --help display help for command
@@ -120,8 +123,8 @@ Create a reply to a post in the global feed.
120
123
  Options:
121
124
  --content <content> Reply content (markdown supported, use "-" for stdin)
122
125
  --rich-text <richText> Rich-text JSON array (mutually exclusive with --content)
123
- --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. (default:
124
- [])
126
+ --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
127
+ photos / 15MB GIFs / 512MB video / 250MB files. (default: [])
125
128
  -h, --help display help for command
126
129
  ```
127
130
 
@@ -148,3 +151,25 @@ Delete a reply you authored in the global feed.
148
151
  Options:
149
152
  -h, --help display help for command
150
153
  ```
154
+
155
+ ## react
156
+
157
+ ```
158
+ Usage: gobi global react [options] <postId> <emoji>
159
+
160
+ 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.
161
+
162
+ Options:
163
+ -h, --help display help for command
164
+ ```
165
+
166
+ ## unreact
167
+
168
+ ```
169
+ Usage: gobi global unreact [options] <postId> <emoji>
170
+
171
+ Remove your emoji reaction from a global-feed post or reply. <postId> is the numeric id of a post OR a reply.
172
+
173
+ Options:
174
+ -h, --help display help for command
175
+ ```
@@ -19,6 +19,8 @@ Commands:
19
19
  create-reply [options] <postId> Reply to a personal-space post. The reply inherits the parent's private scope automatically.
20
20
  edit-reply [options] <replyId> Edit a reply you authored in your personal space.
21
21
  delete-reply <replyId> Delete a reply you authored in your personal space.
22
+ react <postId> <emoji> Add an emoji reaction to a personal-space post or reply (idempotent). <postId> is the numeric id of a post OR a reply.
23
+ unreact <postId> <emoji> Remove your emoji reaction from a personal-space post or reply. <postId> is the numeric id of a post OR a reply.
22
24
  help [command] display help for command
23
25
  ```
24
26
 
@@ -74,7 +76,8 @@ Options:
74
76
  --content <content> Post content (markdown supported, use "-" for stdin)
75
77
  --rich-text <richText> Rich-text JSON array (mutually exclusive with --content)
76
78
  --artifact <artifactId> Attach an existing artifact to the post (repeatable). Create artifacts with `gobi artifact create`. (default: [])
77
- --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. (default: [])
79
+ --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 /
80
+ 15MB GIFs / 512MB video / 250MB files. (default: [])
78
81
  --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
79
82
  post, or a post in a space you're a member of). Reposting someone else's personal-space post returns 404.
80
83
  -h, --help display help for command
@@ -91,8 +94,8 @@ Options:
91
94
  --title <title> New title
92
95
  --content <content> New content (markdown supported, use "-" for stdin)
93
96
  --rich-text <richText> Rich-text JSON array (mutually exclusive with --content)
94
- --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
95
- ceilings: 5MB photos / 15MB GIFs / 512MB video. Omit to leave attachments unchanged. (default: [])
97
+ --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)
98
+ OR 1 GIF OR 1 video. Size ceilings: 10MB photos / 15MB GIFs / 512MB video / 250MB files. Omit to leave attachments unchanged. (default: [])
96
99
  --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
97
100
  with `gobi artifact create`. (default: [])
98
101
  -h, --help display help for command
@@ -119,8 +122,8 @@ Reply to a personal-space post. The reply inherits the parent's private scope au
119
122
  Options:
120
123
  --content <content> Reply content (markdown supported, use "-" for stdin)
121
124
  --rich-text <richText> Rich-text JSON array (mutually exclusive with --content)
122
- --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. (default:
123
- [])
125
+ --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
126
+ photos / 15MB GIFs / 512MB video / 250MB files. (default: [])
124
127
  -h, --help display help for command
125
128
  ```
126
129
 
@@ -147,3 +150,25 @@ Delete a reply you authored in your personal space.
147
150
  Options:
148
151
  -h, --help display help for command
149
152
  ```
153
+
154
+ ## react
155
+
156
+ ```
157
+ Usage: gobi personal react [options] <postId> <emoji>
158
+
159
+ Add an emoji reaction to a personal-space post or reply (idempotent). <postId> is the numeric id of a post OR a reply.
160
+
161
+ Options:
162
+ -h, --help display help for command
163
+ ```
164
+
165
+ ## unreact
166
+
167
+ ```
168
+ Usage: gobi personal unreact [options] <postId> <emoji>
169
+
170
+ Remove your emoji reaction from a personal-space post or reply. <postId> is the numeric id of a post OR a reply.
171
+
172
+ Options:
173
+ -h, --help display help for command
174
+ ```
@@ -6,23 +6,29 @@ Usage: gobi space [options] [command]
6
6
  Space commands (posts, replies). Space and member admin is web-UI only.
7
7
 
8
8
  Options:
9
- --space-slug <spaceSlug> Space slug (overrides .gobi/settings.yaml)
10
- -h, --help display help for command
9
+ --space-slug <spaceSlug> Space slug (overrides .gobi/settings.yaml)
10
+ -h, --help display help for command
11
11
 
12
12
  Commands:
13
- get [options] [spaceSlug] Get details for a space. Pass a slug or omit to use the current space (from .gobi/settings.yaml or --space-slug).
14
- list-topics [options] List topics in a space, ordered by most recent content linkage.
15
- list-topic-posts [options] <topicSlug> List posts tagged with a topic in a space (cursor-paginated).
16
- feed [options] List the unified feed (posts and replies, newest first) in a space.
17
- get-post [options] <postId> Get a post with its ancestors and replies (paginated).
18
- list-posts [options] List posts in a space (paginated).
19
- create-post [options] Create a post in a space.
20
- edit-post [options] <postId> Edit a post you authored in a space.
21
- delete-post [options] <postId> Delete a post you authored in a space.
22
- create-reply [options] <postId> Create a reply to a post in a space.
23
- edit-reply [options] <replyId> Edit a reply you authored in a space.
24
- delete-reply [options] <replyId> Delete a reply you authored in a space.
25
- help [command] display help for command
13
+ get [options] [spaceSlug] Get details for a space. Pass a slug or omit to use the current space (from .gobi/settings.yaml or --space-slug).
14
+ list-topics [options] List topics in a space, ordered by most recent content linkage.
15
+ list-topic-posts [options] <topicSlug> List posts tagged with a topic in a space (cursor-paginated).
16
+ feed [options] List the unified feed (posts and replies, newest first) in a space.
17
+ get-post [options] <postId> Get a post with its ancestors and replies (paginated).
18
+ list-posts [options] List posts in a space (paginated).
19
+ create-post [options] Create a post in a space.
20
+ edit-post [options] <postId> Edit a post you authored in a space.
21
+ delete-post [options] <postId> Delete a post you authored in a space.
22
+ create-reply [options] <postId> Create a reply to a post in a space.
23
+ edit-reply [options] <replyId> Edit a reply you authored in a space.
24
+ delete-reply [options] <replyId> Delete a reply you authored in a space.
25
+ react [options] <postId> <emoji> 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.
26
+ unreact [options] <postId> <emoji> Remove your emoji reaction from a post or reply. <postId> is the numeric id of a post OR a reply.
27
+ list-channels [options] 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
28
+ `feed`.
29
+ get-channel [options] <channelId> Get one channel (channel members, space owner/admin, or the agent on agent-enabled channels).
30
+ list-channel-members [options] <channelId> List the members of a channel.
31
+ help [command] display help for command
26
32
  ```
27
33
 
28
34
  ## get
@@ -74,6 +80,7 @@ List the unified feed (posts and replies, newest first) in a space.
74
80
  Options:
75
81
  --limit <number> Items per page (default: "20")
76
82
  --cursor <string> Pagination cursor from previous response
83
+ --channel <channelId> Channel id to read instead of the main feed (see `list-channels`). Omit for the main feed.
77
84
  --space-slug <spaceSlug> Space slug (overrides .gobi/settings.yaml)
78
85
  -h, --help display help for command
79
86
  ```
@@ -103,6 +110,7 @@ List posts in a space (paginated).
103
110
  Options:
104
111
  --limit <number> Items per page (default: "20")
105
112
  --cursor <string> Pagination cursor from previous response
113
+ --channel <channelId> Channel id to read instead of the main feed (see `list-channels`). Omit for the main feed.
106
114
  --space-slug <spaceSlug> Space slug (overrides .gobi/settings.yaml)
107
115
  -h, --help display help for command
108
116
  ```
@@ -120,9 +128,12 @@ Options:
120
128
  --rich-text <richText> Rich-text JSON array (mutually exclusive with --content)
121
129
  --artifact <artifactId> Attach an existing artifact to the post (repeatable). Create artifacts with `gobi artifact create`. (default: [])
122
130
  --space-slug <spaceSlug> Space slug (overrides .gobi/settings.yaml)
123
- --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. (default: [])
131
+ --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 /
132
+ 15MB GIFs / 512MB video / 250MB files. (default: [])
124
133
  --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
125
134
  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.
135
+ --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
136
+ on an agent-enabled channel).
126
137
  -h, --help display help for command
127
138
  ```
128
139
 
@@ -138,8 +149,8 @@ Options:
138
149
  --content <content> New content for the post (markdown supported, use "-" for stdin)
139
150
  --rich-text <richText> Rich-text JSON array (mutually exclusive with --content)
140
151
  --space-slug <spaceSlug> Space slug (overrides .gobi/settings.yaml)
141
- --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
142
- ceilings: 5MB photos / 15MB GIFs / 512MB video. Omit to leave attachments unchanged. (default: [])
152
+ --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
153
+ (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. (default: [])
143
154
  --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
144
155
  with `gobi artifact create`. (default: [])
145
156
  -h, --help display help for command
@@ -168,8 +179,8 @@ Options:
168
179
  --content <content> Reply content (markdown supported, use "-" for stdin)
169
180
  --rich-text <richText> Rich-text JSON array (mutually exclusive with --content)
170
181
  --space-slug <spaceSlug> Space slug (overrides .gobi/settings.yaml)
171
- --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. (default:
172
- [])
182
+ --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:
183
+ 10MB photos / 15MB GIFs / 512MB video / 250MB files. (default: [])
173
184
  -h, --help display help for command
174
185
  ```
175
186
 
@@ -198,3 +209,63 @@ Options:
198
209
  --space-slug <spaceSlug> Space slug (overrides .gobi/settings.yaml)
199
210
  -h, --help display help for command
200
211
  ```
212
+
213
+ ## react
214
+
215
+ ```
216
+ Usage: gobi space react [options] <postId> <emoji>
217
+
218
+ 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.
219
+
220
+ Options:
221
+ --space-slug <spaceSlug> Space slug (overrides .gobi/settings.yaml)
222
+ -h, --help display help for command
223
+ ```
224
+
225
+ ## unreact
226
+
227
+ ```
228
+ Usage: gobi space unreact [options] <postId> <emoji>
229
+
230
+ Remove your emoji reaction from a post or reply. <postId> is the numeric id of a post OR a reply.
231
+
232
+ Options:
233
+ --space-slug <spaceSlug> Space slug (overrides .gobi/settings.yaml)
234
+ -h, --help display help for command
235
+ ```
236
+
237
+ ## list-channels
238
+
239
+ ```
240
+ Usage: gobi space list-channels [options]
241
+
242
+ 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`.
243
+
244
+ Options:
245
+ --space-slug <spaceSlug> Space slug (overrides .gobi/settings.yaml)
246
+ -h, --help display help for command
247
+ ```
248
+
249
+ ## get-channel
250
+
251
+ ```
252
+ Usage: gobi space get-channel [options] <channelId>
253
+
254
+ Get one channel (channel members, space owner/admin, or the agent on agent-enabled channels).
255
+
256
+ Options:
257
+ --space-slug <spaceSlug> Space slug (overrides .gobi/settings.yaml)
258
+ -h, --help display help for command
259
+ ```
260
+
261
+ ## list-channel-members
262
+
263
+ ```
264
+ Usage: gobi space list-channel-members [options] <channelId>
265
+
266
+ List the members of a channel.
267
+
268
+ Options:
269
+ --space-slug <spaceSlug> Space slug (overrides .gobi/settings.yaml)
270
+ -h, --help display help for command
271
+ ```
@@ -8,12 +8,12 @@ description: >-
8
8
  allowed-tools: Bash(gobi:*)
9
9
  metadata:
10
10
  author: gobi-ai
11
- version: "2.0.30"
11
+ version: "2.0.31"
12
12
  ---
13
13
 
14
14
  # gobi-vault
15
15
 
16
- Gobi vault commands for publishing your vault profile and syncing files (v2.0.30).
16
+ Gobi vault commands for publishing your vault profile and syncing files (v2.0.31).
17
17
 
18
18
  Requires gobi-cli installed and authenticated. See gobi-core skill for setup.
19
19