@gobi-ai/cli 2.0.14 → 2.0.16

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.14",
7
+ "version": "2.0.16",
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, manage saved notes and posts, manage sessions, generate images and videos.",
12
- "version": "2.0.14",
12
+ "version": "2.0.16",
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.14",
4
+ "version": "2.0.16",
5
5
  "author": {
6
6
  "name": "gobi-ai"
7
7
  },
@@ -1,8 +1,103 @@
1
- import { existsSync, readFileSync, appendFileSync } from "fs";
1
+ import { existsSync, readFileSync, appendFileSync, statSync } from "fs";
2
2
  import { EOL } from "os";
3
- import { join, extname } from "path";
3
+ import { basename, join, extname, isAbsolute, resolve } from "path";
4
4
  import ignore from "ignore";
5
5
  import { WEBDRIVE_BASE_URL } from "./constants.js";
6
+ import { apiPost } from "./client.js";
7
+ // Best-effort extension → MIME mapping. Anything we don't recognize falls
8
+ // 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.
11
+ const POST_MEDIA_MIME_MAP = {
12
+ ".jpg": "image/jpeg",
13
+ ".jpeg": "image/jpeg",
14
+ ".png": "image/png",
15
+ ".gif": "image/gif",
16
+ ".webp": "image/webp",
17
+ ".heic": "image/heic",
18
+ ".heif": "image/heif",
19
+ ".avif": "image/avif",
20
+ ".svg": "image/svg+xml",
21
+ ".bmp": "image/bmp",
22
+ ".tiff": "image/tiff",
23
+ ".mp4": "video/mp4",
24
+ ".mov": "video/quicktime",
25
+ ".webm": "video/webm",
26
+ ".m4v": "video/x-m4v",
27
+ ".pdf": "application/pdf",
28
+ };
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.
32
+ export function assertPostAttachmentMix(paths) {
33
+ let photos = 0;
34
+ let gifs = 0;
35
+ let videos = 0;
36
+ for (const p of paths) {
37
+ const ext = extname(p).toLowerCase();
38
+ if (ext === ".gif")
39
+ gifs += 1;
40
+ else if ([".mp4", ".mov", ".webm", ".m4v"].includes(ext))
41
+ videos += 1;
42
+ else
43
+ photos += 1;
44
+ }
45
+ if (videos > 1)
46
+ throw new Error("Only 1 video allowed per post");
47
+ if (gifs > 1)
48
+ throw new Error("Only 1 GIF allowed per post");
49
+ if (photos > 4)
50
+ throw new Error("Up to 4 photos allowed per post");
51
+ if (videos > 0 && (gifs > 0 || photos > 0)) {
52
+ throw new Error("A video can't be combined with other media");
53
+ }
54
+ if (gifs > 0 && (videos > 0 || photos > 0)) {
55
+ throw new Error("A GIF can't be combined with other media");
56
+ }
57
+ }
58
+ /**
59
+ * Upload a single local file as a post attachment.
60
+ * init (`POST /posts/upload-url`) → PUT to S3 → return `{ mediaUrl, mediaKey }`
61
+ * suitable for the `attachments` array on create-post.
62
+ */
63
+ export async function uploadPostAttachment(filePath) {
64
+ const abs = isAbsolute(filePath) ? filePath : resolve(process.cwd(), filePath);
65
+ if (!existsSync(abs)) {
66
+ throw new Error(`Attachment not found: ${filePath}`);
67
+ }
68
+ const ext = extname(abs).toLowerCase();
69
+ const contentType = POST_MEDIA_MIME_MAP[ext] || "application/octet-stream";
70
+ const fileSize = statSync(abs).size;
71
+ const initResp = (await apiPost("/posts/upload-url", {
72
+ fileName: basename(abs),
73
+ contentType,
74
+ fileSize,
75
+ }));
76
+ const data = (initResp.data ?? initResp);
77
+ const uploadUrl = data.uploadUrl;
78
+ const mediaUrl = data.mediaUrl;
79
+ const mediaKey = data.mediaKey;
80
+ if (!uploadUrl || !mediaUrl || !mediaKey) {
81
+ throw new Error("Upload init returned an incomplete payload");
82
+ }
83
+ const body = readFileSync(abs);
84
+ const putRes = await fetch(uploadUrl, {
85
+ method: "PUT",
86
+ headers: { "Content-Type": contentType },
87
+ body,
88
+ });
89
+ if (!putRes.ok) {
90
+ throw new Error(`Failed to PUT ${filePath} to S3: HTTP ${putRes.status}`);
91
+ }
92
+ return { mediaUrl, mediaKey };
93
+ }
94
+ export async function uploadPostAttachments(paths) {
95
+ const out = [];
96
+ for (const p of paths) {
97
+ out.push(await uploadPostAttachment(p));
98
+ }
99
+ return out;
100
+ }
6
101
  export function extractWikiLinks(content) {
7
102
  const seen = new Set();
8
103
  const results = [];
@@ -1,6 +1,6 @@
1
1
  import { apiGet, apiPost, apiPatch, apiDelete } from "../client.js";
2
2
  import { fetchDraftSummary, isJsonMode, jsonOut, readStdin, resolveVaultSlug, unwrapResp, } from "./utils.js";
3
- import { extractWikiLinks, uploadAttachments } from "../attachments.js";
3
+ import { extractWikiLinks, uploadAttachments, uploadPostAttachments, assertPostAttachmentMix, } from "../attachments.js";
4
4
  import { getValidToken } from "../auth/manager.js";
5
5
  function readContent(value) {
6
6
  if (value === "-")
@@ -184,6 +184,7 @@ export function registerGlobalCommand(program) {
184
184
  .option("--vault-slug <vaultSlug>", "Attribute the post to this vault (sets authorVaultSlug). Defaults to your primary vault.")
185
185
  .option("--auto-attachments", "Upload wiki-linked [[files]] to webdrive before posting (also sets authorVaultSlug to that vault)")
186
186
  .option("--draft-id <draftId>", "Use this draft as the source of title and content (mutually exclusive with --title/--content/--rich-text). On success, links the post back by recording postId on draft.metadata so the client can render an 'Open post' button. The draft's vaultSlug seeds --vault-slug when not given explicitly.")
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], [])
187
188
  .action(async (opts) => {
188
189
  if (opts.draftId) {
189
190
  if (opts.title || opts.content || opts.richText) {
@@ -230,6 +231,10 @@ export function registerGlobalCommand(program) {
230
231
  }
231
232
  if (authorVaultSlug)
232
233
  body.authorVaultSlug = authorVaultSlug;
234
+ if (opts.attach && opts.attach.length > 0) {
235
+ assertPostAttachmentMix(opts.attach);
236
+ body.attachments = await uploadPostAttachments(opts.attach);
237
+ }
233
238
  const resp = (await apiPost(`/posts`, body));
234
239
  const post = unwrapResp(resp);
235
240
  if (opts.draftId && post.id != null) {
@@ -331,6 +336,7 @@ export function registerGlobalCommand(program) {
331
336
  .option("--rich-text <richText>", "Rich-text JSON array (mutually exclusive with --content)")
332
337
  .option("--vault-slug <vaultSlug>", "Attribute the reply to this vault (sets authorVaultSlug). Also used as upload destination for --auto-attachments.")
333
338
  .option("--auto-attachments", "Upload wiki-linked [[files]] to webdrive before posting (also attributes the reply to that vault)")
339
+ .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], [])
334
340
  .action(async (postId, opts) => {
335
341
  if (!opts.content && !opts.richText) {
336
342
  throw new Error("Provide either --content or --rich-text.");
@@ -364,6 +370,10 @@ export function registerGlobalCommand(program) {
364
370
  }
365
371
  if (authorVaultSlug)
366
372
  body.authorVaultSlug = authorVaultSlug;
373
+ if (opts.attach && opts.attach.length > 0) {
374
+ assertPostAttachmentMix(opts.attach);
375
+ body.attachments = await uploadPostAttachments(opts.attach);
376
+ }
367
377
  const resp = (await apiPost(`/posts/${postId}/replies`, body));
368
378
  const reply = unwrapResp(resp);
369
379
  if (isJsonMode(global)) {
@@ -1,7 +1,7 @@
1
1
  import { apiGet, apiPost, apiPatch, apiDelete } from "../client.js";
2
2
  import { requireSpace, selectSpace, setSpaceRequirement, writeSpaceSetting, } from "./init.js";
3
3
  import { fetchDraftSummary, isJsonMode, jsonOut, readStdin, resolveSpaceSlug, resolveVaultSlug, unwrapResp, } from "./utils.js";
4
- import { extractWikiLinks, uploadAttachments } from "../attachments.js";
4
+ import { extractWikiLinks, uploadAttachments, uploadPostAttachments, assertPostAttachmentMix, } from "../attachments.js";
5
5
  import { getValidToken } from "../auth/manager.js";
6
6
  function readContent(value) {
7
7
  if (value === "-")
@@ -319,6 +319,7 @@ export function registerSpaceCommand(program) {
319
319
  .option("--vault-slug <vaultSlug>", "Attribute the post to this vault (sets authorVaultId). Also used as upload destination for --auto-attachments.")
320
320
  .option("--space-slug <spaceSlug>", "Space slug (overrides .gobi/settings.yaml)")
321
321
  .option("--draft-id <draftId>", "Use this draft as the source of title and content (mutually exclusive with --title/--content/--rich-text). On success, links the post back by recording postId/spaceSlug on draft.metadata so the client can render an 'Open post' button. The draft's vaultSlug seeds --vault-slug when not given explicitly.")
322
+ .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], [])
322
323
  .action(async (opts) => {
323
324
  if (opts.draftId) {
324
325
  if (opts.title || opts.content || opts.richText) {
@@ -365,6 +366,10 @@ export function registerSpaceCommand(program) {
365
366
  }
366
367
  if (authorVaultSlug)
367
368
  body.authorVaultSlug = authorVaultSlug;
369
+ if (opts.attach && opts.attach.length > 0) {
370
+ assertPostAttachmentMix(opts.attach);
371
+ body.attachments = await uploadPostAttachments(opts.attach);
372
+ }
368
373
  const spaceSlug = resolveSpaceSlug(space, opts);
369
374
  const resp = (await apiPost(`/spaces/${spaceSlug}/posts`, body));
370
375
  const post = unwrapResp(resp);
@@ -473,6 +478,7 @@ export function registerSpaceCommand(program) {
473
478
  .option("--auto-attachments", "Upload wiki-linked [[files]] to webdrive before posting (also attributes the reply to that vault)")
474
479
  .option("--vault-slug <vaultSlug>", "Attribute the reply to this vault (sets authorVaultSlug). Also used as upload destination for --auto-attachments.")
475
480
  .option("--space-slug <spaceSlug>", "Space slug (overrides .gobi/settings.yaml)")
481
+ .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], [])
476
482
  .action(async (postId, opts) => {
477
483
  if (!opts.content && !opts.richText) {
478
484
  throw new Error("Provide either --content or --rich-text.");
@@ -506,6 +512,10 @@ export function registerSpaceCommand(program) {
506
512
  }
507
513
  if (authorVaultSlug)
508
514
  body.authorVaultSlug = authorVaultSlug;
515
+ if (opts.attach && opts.attach.length > 0) {
516
+ assertPostAttachmentMix(opts.attach);
517
+ body.attachments = await uploadPostAttachments(opts.attach);
518
+ }
509
519
  const spaceSlug = resolveSpaceSlug(space, opts);
510
520
  const resp = (await apiPost(`/spaces/${spaceSlug}/posts/${postId}/replies`, body));
511
521
  const msg = unwrapResp(resp);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gobi-ai/cli",
3
- "version": "2.0.14",
3
+ "version": "2.0.16",
4
4
  "description": "CLI client for the Gobi collaborative knowledge platform",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -40,18 +40,25 @@ Replace `<RATIO>` with the desired aspect ratio: `1:1`, `16:9`, `9:16`, `4:3`, o
40
40
 
41
41
  The `-o` flag implies `--wait` and downloads the image when done.
42
42
 
43
- **IMPORTANT: After downloading, show the image using Obsidian wiki-link syntax EXACTLY like this:**
43
+ **Where you reference the downloaded file depends on what you're doing with it:**
44
44
 
45
- ```
46
- ![[media/<NAME>.png]]
47
- ```
45
+ - **Chat / vault note (vault is mounted, ![[wikilinks]] resolve):** use Obsidian wiki-link syntax:
46
+ ```
47
+ ![[media/<NAME>.png]]
48
+ ```
49
+ - **Post or reply (any flow that goes through `gobi <scope> create-post` or `gobi <scope> create-reply`):** do NOT embed the image in `--content`. Pass the file as a first-class attachment:
50
+ ```bash
51
+ gobi space create-post --title "<TITLE>" --content "<BODY>" --attach media/<NAME>.png
52
+ gobi space create-reply <postId> --content "<BODY>" --attach media/<NAME>.png
53
+ ```
54
+ This uploads the file to the CDN and renders it as a slider on the post card. The `--attach` flag is repeatable; mix rule is **4 photos OR 1 GIF OR 1 video**.
48
55
 
49
- Do NOT use markdown image syntax `![](...)` or `gobi://` URLs. Always use `![[media/<NAME>.png]]`.
56
+ In particular, when you're answering a post-mention (`@gobi` / `@space:<slug>` on a thread) and need to send media, **call `gobi <scope> create-reply <postId> --attach <file>` yourself** — your text-only assistant body is auto-posted only if you don't call create-reply this turn, so calling it explicitly is the only way to land media on the thread.
50
57
 
51
58
  ### Key rules
52
59
  - Replace `<NAME>` with a descriptive slug — NEVER use example names like `sunset.png` literally.
53
60
  - `--name` is **optional** — auto-derived from prompt if omitted.
54
- - Do NOT use the `downloadUrl` from the response — it is a frontend path, not a direct download link.
61
+ - Do NOT use the `downloadUrl` from the response — it is a Miraflow-internal path, not a public link. Always download with `-o` then either wiki-link (chat/vault) or `--attach` (post/reply).
55
62
  - `download-image` takes a **positional** jobId (NOT `--job-id`): `gobi media download-image <jobId>`
56
63
  - The `jobId` (or `id`) field is what you pass to `download-image` / `get-image-status` — NOT `mediaId`.
57
64
 
@@ -133,7 +140,7 @@ gobi --json media get-avatar-job-status <jobId> --wait
133
140
  ![[media/<NAME>.mp4]]
134
141
  ```
135
142
 
136
- Do NOT use markdown image/link syntax `![](...)` or `gobi://` URLs. Always use `![[media/<NAME>.mp4]]`.
143
+ Do NOT use markdown image/link syntax `![](...)` or `gobi://` URLs in chat. For posts/replies use `--attach media/<NAME>.mp4` instead — see the image workflow above for details.
137
144
 
138
145
  ## Available Commands
139
146
 
@@ -51,6 +51,14 @@ Both `gobi space create-post` / `edit-post` and `gobi global create-post` / `edi
51
51
 
52
52
  > **Before using `--auto-attachments`, check that the target vault is published.** Run `gobi --json vault status` (or `gobi vault status --vault-slug <slug>`) and verify `isPublished: true`. Files uploaded to a non-public vault are stored on webdrive but are not reachable at `gobispace.com/@{vaultSlug}` — readers will see broken `[[wikilinks]]`. If the status reports unpublished, ask the user to run `gobi vault publish` first (it requires `title` and `description` in `PUBLISH.md`). See the **gobi-vault** skill.
53
53
 
54
+ ## Post media attachments (`--attach`)
55
+
56
+ Separate from `--auto-attachments`, `gobi space create-post` and `gobi global create-post` accept `--attach <file>` (repeatable) for inline post media — the 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`.
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.
59
+
60
+ Use `--attach` for media you want shown in the post itself; use `--auto-attachments` for `[[wikilinks]]` to vault files referenced in the body.
61
+
54
62
  ## Public link formats
55
63
 
56
64
  Once a post is created, you can build a shareable URL from the response:
@@ -79,6 +79,7 @@ Options:
79
79
  --auto-attachments Upload wiki-linked [[files]] to webdrive before posting (also sets authorVaultSlug to that vault)
80
80
  --draft-id <draftId> Use this draft as the source of title and content (mutually exclusive with --title/--content/--rich-text). On success, links the post back by recording postId on
81
81
  draft.metadata so the client can render an 'Open post' button. The draft's vaultSlug seeds --vault-slug when not given explicitly.
82
+ --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: [])
82
83
  -h, --help display help for command
83
84
  ```
84
85
 
@@ -121,6 +122,8 @@ Options:
121
122
  --rich-text <richText> Rich-text JSON array (mutually exclusive with --content)
122
123
  --vault-slug <vaultSlug> Attribute the reply to this vault (sets authorVaultSlug). Also used as upload destination for --auto-attachments.
123
124
  --auto-attachments Upload wiki-linked [[files]] to webdrive before posting (also attributes the reply to that vault)
125
+ --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:
126
+ [])
124
127
  -h, --help display help for command
125
128
  ```
126
129
 
@@ -125,6 +125,7 @@ Options:
125
125
  --space-slug <spaceSlug> Space slug (overrides .gobi/settings.yaml)
126
126
  --draft-id <draftId> Use this draft as the source of title and content (mutually exclusive with --title/--content/--rich-text). On success, links the post back by recording postId/spaceSlug on
127
127
  draft.metadata so the client can render an 'Open post' button. The draft's vaultSlug seeds --vault-slug when not given explicitly.
128
+ --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: [])
128
129
  -h, --help display help for command
129
130
  ```
130
131
 
@@ -170,6 +171,8 @@ Options:
170
171
  --auto-attachments Upload wiki-linked [[files]] to webdrive before posting (also attributes the reply to that vault)
171
172
  --vault-slug <vaultSlug> Attribute the reply to this vault (sets authorVaultSlug). Also used as upload destination for --auto-attachments.
172
173
  --space-slug <spaceSlug> Space slug (overrides .gobi/settings.yaml)
174
+ --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:
175
+ [])
173
176
  -h, --help display help for command
174
177
  ```
175
178