@gobi-ai/cli 0.7.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,19 +4,20 @@
4
4
  "name": "gobi-ai"
5
5
  },
6
6
  "description": "Claude Code plugin for the Gobi collaborative knowledge platform CLI",
7
- "version": "0.7.1",
7
+ "version": "0.7.3",
8
8
  "plugins": [
9
9
  {
10
10
  "name": "gobi",
11
11
  "description": "Manage the Gobi collaborative knowledge platform from the command line. Search and ask brains, publish brain documents, create threads, manage sessions.",
12
- "version": "0.7.1",
12
+ "version": "0.7.3",
13
13
  "author": {
14
14
  "name": "gobi-ai"
15
15
  },
16
16
  "homepage": "https://github.com/gobi-ai/gobi-cli",
17
17
  "source": "./",
18
18
  "skills": [
19
- "./skills/gobi"
19
+ "./skills/gobi-cli",
20
+ "./skills/gobi-homepage"
20
21
  ],
21
22
  "commands": "./commands"
22
23
  }
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "gobi",
3
3
  "description": "Manage the Gobi collaborative knowledge platform from the command line",
4
- "version": "0.7.1",
4
+ "version": "0.7.3",
5
5
  "author": {
6
6
  "name": "gobi-ai"
7
7
  },
8
8
  "homepage": "https://github.com/gobi-ai/gobi-cli",
9
9
  "skills": [
10
- "./skills/gobi"
10
+ "./skills/gobi-cli",
11
+ "./skills/gobi-homepage"
11
12
  ],
12
13
  "commands": "./commands"
13
14
  }
@@ -0,0 +1,379 @@
1
+ import { apiGet, apiPost } from "../client.js";
2
+ import { BASE_URL, POLL_MAX_DURATION_MS } from "../constants.js";
3
+ import { getValidToken } from "../auth/manager.js";
4
+ import { ApiError } from "../errors.js";
5
+ import { isJsonMode, jsonOut, unwrapResp } from "./utils.js";
6
+ // ── Polling helper ──
7
+ async function pollStatus(path, terminalStates, intervalMs = 3000) {
8
+ const start = Date.now();
9
+ while (Date.now() - start < POLL_MAX_DURATION_MS) {
10
+ const resp = (await apiGet(path));
11
+ const data = unwrapResp(resp);
12
+ const status = data.status || "";
13
+ if (terminalStates.includes(status))
14
+ return data;
15
+ await new Promise((r) => setTimeout(r, intervalMs));
16
+ }
17
+ throw new Error(`Polling timed out after ${POLL_MAX_DURATION_MS / 1000}s`);
18
+ }
19
+ export function registerMediaCommand(program) {
20
+ const media = program
21
+ .command("media")
22
+ .description("Media generation commands (videos, images).");
23
+ // ════════════════════════════════════════════════════════════════════
24
+ // Upload
25
+ // ════════════════════════════════════════════════════════════════════
26
+ media
27
+ .command("upload-init")
28
+ .description("Get a presigned upload URL for a media file.")
29
+ .requiredOption("--file-name <fileName>", "Name of the file to upload")
30
+ .requiredOption("--content-type <contentType>", "MIME type (e.g. image/png, video/mp4)")
31
+ .option("--file-size <fileSize>", "File size in bytes")
32
+ .action(async (opts) => {
33
+ const body = {
34
+ fileName: opts.fileName,
35
+ contentType: opts.contentType,
36
+ };
37
+ if (opts.fileSize)
38
+ body.fileSize = parseInt(opts.fileSize, 10);
39
+ const resp = (await apiPost("/media-gen/media/initialize", body));
40
+ const data = unwrapResp(resp);
41
+ if (isJsonMode(media)) {
42
+ jsonOut(data);
43
+ return;
44
+ }
45
+ console.log(`Upload initialized!\n` +
46
+ ` Media ID: ${data.mediaId}\n` +
47
+ ` Upload URL: ${data.uploadUrl}\n\n` +
48
+ `PUT your file to the upload URL, then run:\n` +
49
+ ` gobi media upload-finalize --media-id ${data.mediaId}`);
50
+ });
51
+ media
52
+ .command("upload-finalize")
53
+ .description("Confirm that a media upload is complete.")
54
+ .requiredOption("--media-id <mediaId>", "Media ID from upload-init")
55
+ .action(async (opts) => {
56
+ const resp = (await apiPost("/media-gen/media/finalize", {
57
+ mediaId: opts.mediaId,
58
+ }));
59
+ const data = unwrapResp(resp);
60
+ if (isJsonMode(media)) {
61
+ jsonOut(data);
62
+ return;
63
+ }
64
+ console.log(`Upload finalized for media ${opts.mediaId}.`);
65
+ });
66
+ // ════════════════════════════════════════════════════════════════════
67
+ // Avatars & Voices
68
+ // ════════════════════════════════════════════════════════════════════
69
+ media
70
+ .command("avatars")
71
+ .description("List available avatars.")
72
+ .action(async () => {
73
+ const resp = (await apiGet("/media-gen/avatars"));
74
+ const data = unwrapResp(resp);
75
+ if (isJsonMode(media)) {
76
+ jsonOut(data);
77
+ return;
78
+ }
79
+ if (!Array.isArray(data) || data.length === 0) {
80
+ console.log("No avatars available.");
81
+ return;
82
+ }
83
+ console.log("Available avatars:");
84
+ for (const a of data) {
85
+ console.log(` - ${a.id || a.avatarId}: ${a.name || "(unnamed)"}`);
86
+ }
87
+ });
88
+ media
89
+ .command("voices")
90
+ .description("List available voices.")
91
+ .action(async () => {
92
+ const resp = (await apiGet("/media-gen/voices"));
93
+ const data = unwrapResp(resp);
94
+ if (isJsonMode(media)) {
95
+ jsonOut(data);
96
+ return;
97
+ }
98
+ if (!Array.isArray(data) || data.length === 0) {
99
+ console.log("No voices available.");
100
+ return;
101
+ }
102
+ console.log("Available voices:");
103
+ for (const v of data) {
104
+ console.log(` - ${v.id || v.voiceId}: ${v.name || "(unnamed)"}`);
105
+ }
106
+ });
107
+ // ════════════════════════════════════════════════════════════════════
108
+ // Videos
109
+ // ════════════════════════════════════════════════════════════════════
110
+ media
111
+ .command("video-create")
112
+ .description("Create an avatar video generation job.")
113
+ .requiredOption("--name <name>", "Name for the video")
114
+ .requiredOption("--avatar-id <avatarId>", "Avatar to use")
115
+ .requiredOption("--voice-id <voiceId>", "Voice to use")
116
+ .requiredOption("--script <script>", "Script for the avatar to read")
117
+ .option("--background-media-id <backgroundMediaId>", "Background media ID (from upload)")
118
+ .option("--wait", "Poll until generation completes")
119
+ .action(async (opts) => {
120
+ const body = {
121
+ name: opts.name,
122
+ avatarId: opts.avatarId,
123
+ voiceId: opts.voiceId,
124
+ script: opts.script,
125
+ };
126
+ if (opts.backgroundMediaId)
127
+ body.backgroundMediaId = opts.backgroundMediaId;
128
+ const resp = (await apiPost("/media-gen/videos", body));
129
+ let data = unwrapResp(resp);
130
+ const videoId = data.id || data.videoId;
131
+ if (opts.wait && videoId) {
132
+ console.log(`Video ${videoId} queued — polling for completion…`);
133
+ data = await pollStatus(`/media-gen/videos/${videoId}/status`, ["inference_complete", "inference_failed"]);
134
+ }
135
+ if (isJsonMode(media)) {
136
+ jsonOut(data);
137
+ return;
138
+ }
139
+ const status = data.status || "queued";
140
+ console.log(`Video created!\n` +
141
+ ` ID: ${videoId}\n` +
142
+ ` Status: ${status}`);
143
+ if (status === "inference_complete") {
144
+ console.log(` Download: gobi media video-download ${videoId}`);
145
+ }
146
+ });
147
+ media
148
+ .command("video-list")
149
+ .description("List all videos.")
150
+ .action(async () => {
151
+ const resp = (await apiGet("/media-gen/videos"));
152
+ const data = unwrapResp(resp);
153
+ if (isJsonMode(media)) {
154
+ jsonOut(data);
155
+ return;
156
+ }
157
+ if (!Array.isArray(data) || data.length === 0) {
158
+ console.log("No videos found.");
159
+ return;
160
+ }
161
+ console.log("Videos:");
162
+ for (const v of data) {
163
+ console.log(` - [${v.id}] status: ${v.status || "unknown"}, created: ${v.createdAt || "?"}`);
164
+ }
165
+ });
166
+ media
167
+ .command("video-get <id>")
168
+ .description("Get video metadata.")
169
+ .action(async (id) => {
170
+ const resp = (await apiGet(`/media-gen/videos/${id}`));
171
+ const data = unwrapResp(resp);
172
+ if (isJsonMode(media)) {
173
+ jsonOut(data);
174
+ return;
175
+ }
176
+ console.log(`Video ${id}:`);
177
+ for (const [k, v] of Object.entries(data)) {
178
+ console.log(` ${k}: ${typeof v === "object" ? JSON.stringify(v) : v}`);
179
+ }
180
+ });
181
+ media
182
+ .command("video-status <id>")
183
+ .description("Poll video generation status.")
184
+ .option("--wait", "Poll until a terminal state is reached")
185
+ .action(async (id, opts) => {
186
+ if (opts.wait) {
187
+ const data = await pollStatus(`/media-gen/videos/${id}/status`, ["inference_complete", "inference_failed"]);
188
+ if (isJsonMode(media)) {
189
+ jsonOut(data);
190
+ return;
191
+ }
192
+ console.log(`Video ${id} — status: ${data.status}`);
193
+ return;
194
+ }
195
+ const resp = (await apiGet(`/media-gen/videos/${id}/status`));
196
+ const data = unwrapResp(resp);
197
+ if (isJsonMode(media)) {
198
+ jsonOut(data);
199
+ return;
200
+ }
201
+ console.log(`Video ${id} — status: ${data.status || "unknown"}`);
202
+ });
203
+ media
204
+ .command("video-download <id>")
205
+ .description("Get the download URL for a completed video.")
206
+ .action(async (id) => {
207
+ const token = await getValidToken();
208
+ const url = `${BASE_URL}/media-gen/videos/${id}/download`;
209
+ const res = await fetch(url, {
210
+ headers: { Authorization: `Bearer ${token}` },
211
+ redirect: "manual",
212
+ });
213
+ // If the server redirects, extract the Location header
214
+ if (res.status >= 300 && res.status < 400) {
215
+ const location = res.headers.get("location") || "";
216
+ if (isJsonMode(media)) {
217
+ jsonOut({ downloadUrl: location });
218
+ return;
219
+ }
220
+ console.log(`Download URL for video ${id}:\n ${location}`);
221
+ return;
222
+ }
223
+ if (!res.ok) {
224
+ const text = (await res.text()) || "(no body)";
225
+ throw new ApiError(res.status, `/media-gen/videos/${id}/download`, text);
226
+ }
227
+ // If it returns JSON instead of a redirect
228
+ const resp = (await res.json());
229
+ const data = unwrapResp(resp);
230
+ if (isJsonMode(media)) {
231
+ jsonOut(data);
232
+ return;
233
+ }
234
+ console.log(`Download URL for video ${id}:\n ${data.url || data.downloadUrl || JSON.stringify(data)}`);
235
+ });
236
+ // ════════════════════════════════════════════════════════════════════
237
+ // Images
238
+ // ════════════════════════════════════════════════════════════════════
239
+ media
240
+ .command("image-generate")
241
+ .description("Generate an image from a text prompt. Types: image (default), thumbnail (YouTube-optimized), asset (logo/product). Aspect ratios: 1:1, 16:9, 9:16, 4:3, 3:4")
242
+ .requiredOption("--prompt <prompt>", "Text prompt for image generation")
243
+ .requiredOption("--name <name>", "Name for the generated image")
244
+ .option("--type <type>", "Generation type: image (default), thumbnail (YouTube-optimized), asset (logo/product)")
245
+ .option("--aspect-ratio <aspectRatio>", "Aspect ratio (1:1, 16:9, 9:16, 4:3, 3:4)")
246
+ .option("--negative-prompt <negativePrompt>", "Negative prompt")
247
+ .option("--seed <seed>", "Random seed for reproducibility")
248
+ .option("--reference-media-id <referenceMediaId>", "Reference image media ID")
249
+ .option("--wait", "Poll until generation completes")
250
+ .action(async (opts) => {
251
+ const body = {
252
+ prompt: opts.prompt,
253
+ name: opts.name,
254
+ };
255
+ if (opts.type)
256
+ body.type = opts.type;
257
+ if (opts.aspectRatio)
258
+ body.aspectRatio = opts.aspectRatio;
259
+ if (opts.negativePrompt)
260
+ body.negativePrompt = opts.negativePrompt;
261
+ if (opts.seed)
262
+ body.seed = parseInt(opts.seed, 10);
263
+ if (opts.referenceMediaId)
264
+ body.referenceMediaId = opts.referenceMediaId;
265
+ const resp = (await apiPost("/media-gen/images/generate", body));
266
+ let data = unwrapResp(resp);
267
+ const jobId = data.jobId || data.id;
268
+ if (opts.wait && jobId) {
269
+ console.log(`Image job ${jobId} queued — polling for completion…`);
270
+ data = await pollStatus(`/media-gen/images/${jobId}`, [
271
+ "completed",
272
+ "failed",
273
+ "inference_complete",
274
+ "inference_failed",
275
+ ]);
276
+ }
277
+ if (isJsonMode(media)) {
278
+ jsonOut(data);
279
+ return;
280
+ }
281
+ console.log(`Image generation started!\n` +
282
+ ` Job ID: ${jobId}\n` +
283
+ ` Status: ${data.status || "queued"}\n` +
284
+ ` Check: gobi media image-status ${jobId}`);
285
+ });
286
+ media
287
+ .command("image-edit")
288
+ .description("Edit an existing image with a prompt (image-to-image).")
289
+ .requiredOption("--media-id <mediaId>", "Source image media ID")
290
+ .requiredOption("--prompt <prompt>", "Edit instruction")
291
+ .requiredOption("--name <name>", "Name for the edited image")
292
+ .option("--wait", "Poll until generation completes")
293
+ .action(async (opts) => {
294
+ const resp = (await apiPost("/media-gen/images/edit", {
295
+ mediaId: opts.mediaId,
296
+ prompt: opts.prompt,
297
+ name: opts.name,
298
+ }));
299
+ let data = unwrapResp(resp);
300
+ const jobId = data.jobId || data.id;
301
+ if (opts.wait && jobId) {
302
+ console.log(`Image edit job ${jobId} — polling for completion…`);
303
+ data = await pollStatus(`/media-gen/images/${jobId}`, [
304
+ "completed",
305
+ "failed",
306
+ "inference_complete",
307
+ "inference_failed",
308
+ ]);
309
+ }
310
+ if (isJsonMode(media)) {
311
+ jsonOut(data);
312
+ return;
313
+ }
314
+ console.log(`Image edit started!\n` +
315
+ ` Job ID: ${jobId}\n` +
316
+ ` Status: ${data.status || "queued"}`);
317
+ });
318
+ media
319
+ .command("image-inpaint")
320
+ .description("Inpaint an image region using a mask.")
321
+ .requiredOption("--media-id <mediaId>", "Source image media ID")
322
+ .requiredOption("--mask-media-id <maskMediaId>", "Mask image media ID")
323
+ .requiredOption("--prompt <prompt>", "Inpainting prompt")
324
+ .requiredOption("--name <name>", "Name for the inpainted image")
325
+ .option("--wait", "Poll until generation completes")
326
+ .action(async (opts) => {
327
+ const resp = (await apiPost("/media-gen/images/inpaint", {
328
+ mediaId: opts.mediaId,
329
+ maskMediaId: opts.maskMediaId,
330
+ prompt: opts.prompt,
331
+ name: opts.name,
332
+ }));
333
+ let data = unwrapResp(resp);
334
+ const jobId = data.jobId || data.id;
335
+ if (opts.wait && jobId) {
336
+ console.log(`Inpaint job ${jobId} — polling for completion…`);
337
+ data = await pollStatus(`/media-gen/images/${jobId}`, [
338
+ "completed",
339
+ "failed",
340
+ "inference_complete",
341
+ "inference_failed",
342
+ ]);
343
+ }
344
+ if (isJsonMode(media)) {
345
+ jsonOut(data);
346
+ return;
347
+ }
348
+ console.log(`Inpainting started!\n` +
349
+ ` Job ID: ${jobId}\n` +
350
+ ` Status: ${data.status || "queued"}`);
351
+ });
352
+ media
353
+ .command("image-status <jobId>")
354
+ .description("Check image generation job status.")
355
+ .option("--wait", "Poll until a terminal state is reached")
356
+ .action(async (jobId, opts) => {
357
+ if (opts.wait) {
358
+ const data = await pollStatus(`/media-gen/images/${jobId}`, [
359
+ "completed",
360
+ "failed",
361
+ "inference_complete",
362
+ "inference_failed",
363
+ ]);
364
+ if (isJsonMode(media)) {
365
+ jsonOut(data);
366
+ return;
367
+ }
368
+ console.log(`Image job ${jobId} — status: ${data.status}`);
369
+ return;
370
+ }
371
+ const resp = (await apiGet(`/media-gen/images/${jobId}`));
372
+ const data = unwrapResp(resp);
373
+ if (isJsonMode(media)) {
374
+ jsonOut(data);
375
+ return;
376
+ }
377
+ console.log(`Image job ${jobId} — status: ${data.status || "unknown"}`);
378
+ });
379
+ }
package/dist/main.js CHANGED
@@ -10,6 +10,7 @@ import { registerSessionsCommand } from "./commands/sessions.js";
10
10
  import { registerSenseCommand } from "./commands/sense.js";
11
11
  import { registerSyncCommand } from "./commands/sync.js";
12
12
  import { registerUpdateCommand } from "./commands/update.js";
13
+ import { registerMediaCommand } from "./commands/media.js";
13
14
  const require = createRequire(import.meta.url);
14
15
  const { version } = require("../package.json");
15
16
  const SKIP_BANNER_COMMANDS = new Set(["auth", "init", "update"]);
@@ -36,6 +37,7 @@ export async function cli() {
36
37
  registerSenseCommand(program);
37
38
  registerSyncCommand(program);
38
39
  registerUpdateCommand(program);
40
+ registerMediaCommand(program);
39
41
  // Propagate helpWidth to all subcommands
40
42
  const helpWidth = process.stdout.columns || 200;
41
43
  for (const cmd of program.commands) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gobi-ai/cli",
3
- "version": "0.7.2",
3
+ "version": "0.8.0",
4
4
  "description": "CLI client for the Gobi collaborative knowledge platform",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -6,16 +6,16 @@ description: >-
6
6
  the outside world — checking what's happening, reading and writing threads,
7
7
  and collaborating with others.
8
8
  Use when the user wants to interact with Gobi spaces, vaults, brains, threads,
9
- sessions, or brain updates.
9
+ sessions, brain updates, or media generation (images, videos, thumbnails).
10
10
  allowed-tools: Bash(gobi:*)
11
11
  metadata:
12
12
  author: gobi-ai
13
- version: "0.7.1"
13
+ version: "0.7.3"
14
14
  ---
15
15
 
16
16
  # gobi-cli
17
17
 
18
- A CLI client for the Gobi collaborative knowledge platform (v0.7.1).
18
+ A CLI client for the Gobi collaborative knowledge platform (v0.7.3).
19
19
 
20
20
  ## Prerequisites
21
21
 
@@ -100,6 +100,14 @@ gobi auth status
100
100
 
101
101
  `gobi brain` commands manage your vault's brain: search across all spaces, ask brains questions, and publish/unpublish your BRAIN.md. Public brains are accessible at `https://gobispace.com/@{vaultSlug}`.
102
102
 
103
+ ## Gobi Media — Image & Video Generation
104
+
105
+ `gobi media` commands generate images, thumbnails, assets, and avatar videos using AI. All generation is async: create a job, poll for status (or use `--wait`), then retrieve the result.
106
+
107
+ - **Images**: Generate from text prompts, edit existing images, or inpaint with masks. Supports types: `image` (default), `thumbnail` (YouTube-optimized), `asset` (logo/product).
108
+ - **Videos**: Create avatar videos with script narration. Choose an avatar and voice, provide a script, and generate a talking-head video.
109
+ - **Upload**: Upload custom media files (backgrounds, references, masks) via presigned S3 URLs for use in generation.
110
+
103
111
  ## Gobi Session — Conversations
104
112
 
105
113
  `gobi session` commands manage your conversations: list, read, and reply to sessions.
@@ -169,6 +177,20 @@ Note: `--space-slug` is not available on other `brain` subcommands or on `sessio
169
177
  - `gobi session get` — Get a session and its messages (paginated).
170
178
  - `gobi session list` — List all sessions you are part of, sorted by most recent activity.
171
179
  - `gobi session reply` — Send a human reply to a session you are a member of.
180
+ - `gobi media` — Media generation commands (images, videos).
181
+ - `gobi media upload-init` — Get a presigned upload URL for a media file. Requires `--file-name`, `--content-type`, `--file-size`.
182
+ - `gobi media upload-finalize` — Confirm that a media upload is complete.
183
+ - `gobi media avatars` — List available avatars.
184
+ - `gobi media voices` — List available voices.
185
+ - `gobi media video-create` — Create an avatar video generation job. Requires `--name`, `--avatar-id`, `--voice-id`, `--script`.
186
+ - `gobi media video-list` — List all videos.
187
+ - `gobi media video-get <id>` — Get video metadata.
188
+ - `gobi media video-status <id>` — Poll video generation status. Use `--wait` to block until complete.
189
+ - `gobi media video-download <id>` — Get the download URL for a completed video.
190
+ - `gobi media image-generate` — Generate an image from a text prompt. Requires `--prompt`, `--name`. Optional: `--type` (image|thumbnail|asset), `--aspect-ratio`, `--negative-prompt`, `--seed`, `--reference-media-id`. Use `--wait` to block until complete.
191
+ - `gobi media image-edit` — Edit an existing image with a prompt. Requires `--media-id`, `--prompt`, `--name`.
192
+ - `gobi media image-inpaint` — Inpaint an image region using a mask. Requires `--media-id`, `--mask-media-id`, `--prompt`, `--name`.
193
+ - `gobi media image-status <jobId>` — Check image generation job status. Use `--wait` to block until complete.
172
194
  - `gobi sense` — Sense commands (activities, transcriptions).
173
195
  - `gobi sense activities` — Fetch activity records within a time range.
174
196
  - `gobi sense transcriptions` — Fetch transcription records within a time range.
@@ -182,6 +204,7 @@ Note: `--space-slug` is not available on other `brain` subcommands or on `sessio
182
204
  - [gobi space](references/space.md)
183
205
  - [gobi brain](references/brain.md)
184
206
  - [gobi session](references/session.md)
207
+ - [gobi media](references/media.md)
185
208
  - [gobi sense](references/sense.md)
186
209
  - [gobi sync](references/sync.md)
187
210
  - [gobi update](references/update.md)
@@ -196,6 +219,7 @@ gobi auth --help
196
219
  gobi space --help
197
220
  gobi brain --help
198
221
  gobi session --help
222
+ gobi media --help
199
223
  gobi sense --help
200
224
  gobi sync --help
201
225
  ```
@@ -0,0 +1,215 @@
1
+ # gobi media
2
+
3
+ ```
4
+ Usage: gobi media [options] [command]
5
+
6
+ Media generation commands (videos, images).
7
+
8
+ Options:
9
+ -h, --help display help for command
10
+
11
+ Commands:
12
+ upload-init [options] Get a presigned upload URL for a media file.
13
+ upload-finalize [options] Confirm that a media upload is complete.
14
+ avatars List available avatars.
15
+ voices List available voices.
16
+ video-create [options] Create an avatar video generation job.
17
+ video-list List all videos.
18
+ video-get <id> Get video metadata.
19
+ video-status [options] <id> Poll video generation status.
20
+ video-download <id> Get the download URL for a completed video.
21
+ image-generate [options] Generate an image from a text prompt. Types: image (default), thumbnail (YouTube-optimized), asset (logo/product). Aspect ratios: 1:1, 16:9, 9:16, 4:3, 3:4
22
+ image-edit [options] Edit an existing image with a prompt (image-to-image).
23
+ image-inpaint [options] Inpaint an image region using a mask.
24
+ image-status [options] <jobId> Check image generation job status.
25
+ help [command] display help for command
26
+ ```
27
+
28
+ ## Async Workflow
29
+
30
+ Video and image generation are async:
31
+ 1. Call the create/generate endpoint — receive a job ID
32
+ 2. Poll with `--wait` or manually check status until terminal state
33
+ 3. Retrieve or download the result
34
+
35
+ ## Media Upload
36
+
37
+ Upload media files (images, videos) for use as backgrounds, references, or masks:
38
+
39
+ 1. `gobi media upload-init` — get a presigned S3 upload URL
40
+ 2. PUT your file to the returned `uploadUrl`
41
+ 3. `gobi media upload-finalize` — confirm the upload
42
+
43
+ The returned `mediaId` can then be used with `--background-media-id`, `--reference-media-id`, `--media-id`, or `--mask-media-id`.
44
+
45
+ ## upload-init
46
+
47
+ ```
48
+ Usage: gobi media upload-init [options]
49
+
50
+ Get a presigned upload URL for a media file.
51
+
52
+ Options:
53
+ --file-name <fileName> Name of the file to upload
54
+ --content-type <contentType> MIME type (e.g. image/png, video/mp4)
55
+ --file-size <fileSize> File size in bytes
56
+ -h, --help display help for command
57
+ ```
58
+
59
+ ## upload-finalize
60
+
61
+ ```
62
+ Usage: gobi media upload-finalize [options]
63
+
64
+ Confirm that a media upload is complete.
65
+
66
+ Options:
67
+ --media-id <mediaId> Media ID from upload-init
68
+ -h, --help display help for command
69
+ ```
70
+
71
+ ## avatars
72
+
73
+ ```
74
+ Usage: gobi media avatars [options]
75
+
76
+ List available avatars.
77
+
78
+ Options:
79
+ -h, --help display help for command
80
+ ```
81
+
82
+ ## voices
83
+
84
+ ```
85
+ Usage: gobi media voices [options]
86
+
87
+ List available voices.
88
+
89
+ Options:
90
+ -h, --help display help for command
91
+ ```
92
+
93
+ ## video-create
94
+
95
+ ```
96
+ Usage: gobi media video-create [options]
97
+
98
+ Create an avatar video generation job.
99
+
100
+ Options:
101
+ --name <name> Name for the video
102
+ --avatar-id <avatarId> Avatar to use
103
+ --voice-id <voiceId> Voice to use
104
+ --script <script> Script for the avatar to read
105
+ --background-media-id <backgroundMediaId> Background media ID (from upload)
106
+ --wait Poll until generation completes
107
+ -h, --help display help for command
108
+ ```
109
+
110
+ ## video-list
111
+
112
+ ```
113
+ Usage: gobi media video-list [options]
114
+
115
+ List all videos.
116
+
117
+ Options:
118
+ -h, --help display help for command
119
+ ```
120
+
121
+ ## video-get
122
+
123
+ ```
124
+ Usage: gobi media video-get [options] <id>
125
+
126
+ Get video metadata.
127
+
128
+ Options:
129
+ -h, --help display help for command
130
+ ```
131
+
132
+ ## video-status
133
+
134
+ ```
135
+ Usage: gobi media video-status [options] <id>
136
+
137
+ Poll video generation status.
138
+
139
+ Options:
140
+ --wait Poll until a terminal state is reached
141
+ -h, --help display help for command
142
+ ```
143
+
144
+ ## video-download
145
+
146
+ ```
147
+ Usage: gobi media video-download [options] <id>
148
+
149
+ Get the download URL for a completed video.
150
+
151
+ Options:
152
+ -h, --help display help for command
153
+ ```
154
+
155
+ ## image-generate
156
+
157
+ ```
158
+ Usage: gobi media image-generate [options]
159
+
160
+ Generate an image from a text prompt. Types: image (default), thumbnail (YouTube-optimized), asset (logo/product). Aspect ratios: 1:1, 16:9, 9:16, 4:3, 3:4
161
+
162
+ Options:
163
+ --prompt <prompt> Text prompt for image generation
164
+ --name <name> Name for the generated image
165
+ --type <type> Generation type: image (default), thumbnail (YouTube-optimized), asset (logo/product)
166
+ --aspect-ratio <aspectRatio> Aspect ratio (1:1, 16:9, 9:16, 4:3, 3:4)
167
+ --negative-prompt <negativePrompt> Negative prompt
168
+ --seed <seed> Random seed for reproducibility
169
+ --reference-media-id <referenceMediaId> Reference image media ID
170
+ --wait Poll until generation completes
171
+ -h, --help display help for command
172
+ ```
173
+
174
+ ## image-edit
175
+
176
+ ```
177
+ Usage: gobi media image-edit [options]
178
+
179
+ Edit an existing image with a prompt (image-to-image).
180
+
181
+ Options:
182
+ --media-id <mediaId> Source image media ID
183
+ --prompt <prompt> Edit instruction
184
+ --name <name> Name for the edited image
185
+ --wait Poll until generation completes
186
+ -h, --help display help for command
187
+ ```
188
+
189
+ ## image-inpaint
190
+
191
+ ```
192
+ Usage: gobi media image-inpaint [options]
193
+
194
+ Inpaint an image region using a mask.
195
+
196
+ Options:
197
+ --media-id <mediaId> Source image media ID
198
+ --mask-media-id <maskMediaId> Mask image media ID
199
+ --prompt <prompt> Inpainting prompt
200
+ --name <name> Name for the inpainted image
201
+ --wait Poll until generation completes
202
+ -h, --help display help for command
203
+ ```
204
+
205
+ ## image-status
206
+
207
+ ```
208
+ Usage: gobi media image-status [options] <jobId>
209
+
210
+ Check image generation job status.
211
+
212
+ Options:
213
+ --wait Poll until a terminal state is reached
214
+ -h, --help display help for command
215
+ ```
@@ -0,0 +1,316 @@
1
+ ---
2
+ name: gobi-homepage
3
+ description: >-
4
+ Developer reference for building Gobi Homepages — custom HTML pages hosted on
5
+ webdrive and served as a vault's public homepage at gobispace.com/@{vaultSlug}.
6
+ Use when a developer wants to build or modify a vault homepage.
7
+ ---
8
+
9
+ # Gobi Homepage Developer Guide
10
+
11
+ A **Gobi Homepage** is a custom HTML page hosted on a vault's webdrive and served as its public homepage at `https://gobispace.com/@{vaultSlug}`. Gobi injects a `window.gobi` bridge before any scripts run, giving the homepage access to vault data, files, brain updates, and chat.
12
+
13
+ > **Sandbox:** The homepage runs in a sandboxed iframe with `origin: null`. Direct `fetch()` / `XMLHttpRequest` calls are blocked by CORS. All data access must go through `window.gobi.*`.
14
+
15
+ ---
16
+
17
+ ## Setup
18
+
19
+ 1. Create an HTML file in the vault (e.g. `app/home.html`) and upload:
20
+ ```bash
21
+ gobi sync
22
+ ```
23
+ 2. Set `homepage` in BRAIN.md (homepage property):
24
+ - `homepage: "[[app/home.html]]"` — Gobi sidebars visible alongside the homepage
25
+ - `homepage: "[[app/home.html?nav=false]]"` — full-screen, no Gobi chrome
26
+
27
+ ---
28
+
29
+ ## window.gobi Reference
30
+
31
+ `gobi.vault` is **synchronous** — available at the top of any `<script>`, no `DOMContentLoaded` needed. All other methods return `Promise`.
32
+
33
+ ### gobi.vault
34
+
35
+ ```js
36
+ const { vaultId, title, description, thumbnailPath, tags,
37
+ ownerName, ownerProfilePictureUrl, webdriveUrl } = gobi.vault;
38
+
39
+ // Profile picture: https://d16t3dioqz0xo9.cloudfront.net/{thumbnailPath}@{W}x{H}.webp
40
+ ```
41
+
42
+ ### Error Handling
43
+
44
+ All async `gobi.*` methods throw on failure. Wrap calls in `try/catch` to handle errors gracefully.
45
+
46
+ ```js
47
+ try {
48
+ const text = await gobi.readFile('data/config.json');
49
+ } catch (err) {
50
+ console.error('gobi API error:', err.message);
51
+ // err.message contains a human-readable description of what went wrong
52
+ }
53
+ ```
54
+
55
+ ### Files
56
+
57
+ ```js
58
+ const text = await gobi.readFile('data/config.json'); // → string (throws if not found)
59
+ const items = await gobi.listFiles('images'); // → [{ name, type: 'file'|'folder' }]
60
+ const exists = await gobi.fileExists('README.md'); // → boolean
61
+
62
+ // Direct URL for any vault file (public, no auth required).
63
+ // Encode each segment separately — encodeURIComponent(path) would encode slashes and break the URL.
64
+ function getFileUrl(path) {
65
+ const enc = path.split('/').map(encodeURIComponent).join('/');
66
+ return `${gobi.vault.webdriveUrl}/api/v1/file/raw/${gobi.vault.vaultId}/${enc}`;
67
+ }
68
+ ```
69
+
70
+ ### Brain Updates
71
+
72
+ ```js
73
+ const { data: updates, pagination } = await gobi.listBrainUpdates({ limit: 10, cursor: null });
74
+ // updates[i] → {
75
+ // id: 42,
76
+ // title: 'New insights',
77
+ // content: '## ...', // markdown — MAY contain ![[file|width]] wiki image embeds
78
+ // topics: [{ id: 3, name: 'AI', slug: 'ai' }],
79
+ // createdAt: '2025-03-01T12:00:00Z'
80
+ // }
81
+ // pagination → { hasMore: true, nextCursor: 'abc...' }
82
+
83
+ // ⚠️ Always call resolveWikiImages() on content before rendering — see Rendering Markdown below.
84
+ for (const u of updates) {
85
+ el.innerHTML += marked.parse(resolveWikiImages(u.content));
86
+ }
87
+
88
+ // Pagination — load the next page using the cursor
89
+ if (pagination.hasMore) {
90
+ const { data: moreUpdates, pagination: nextPage } =
91
+ await gobi.listBrainUpdates({ limit: 10, cursor: pagination.nextCursor });
92
+ }
93
+ ```
94
+
95
+ ### Chat (login required)
96
+
97
+ `getSessions`, `loadMessages`, and `sendMessage` require the visitor to be logged in. `getSessions` returns an empty array when not logged in — **but also when logged in with no prior sessions**. Don't use it as a definitive auth check.
98
+
99
+ ```js
100
+ // Redirect to login, returning here after. Use window.top — the applet is inside an iframe.
101
+ function redirectToLogin() {
102
+ window.top.location.href =
103
+ `https://gobispace.com/login?redirect_uri=${encodeURIComponent(window.location.href)}`;
104
+ }
105
+
106
+ // Session list — newest first
107
+ const { data: sessions, pagination } = await gobi.getSessions({ limit: 20, cursor: null });
108
+ // sessions[i] → { sessionId: 'sess_abc', messageCount: 12, lastMessageAt: '2025-03-30T...' }
109
+
110
+ // Message history
111
+ const { messages, hasMore, nextCursor } = await gobi.loadMessages('sess_abc', { limit: 20, cursor: null });
112
+ // messages[i] → { id, role: 'human'|'assistant', content, createdAt }
113
+
114
+ // Send a message and stream the response.
115
+ // New session: pass crypto.randomUUID() — the backend creates it lazily on first message sent.
116
+ //
117
+ // Signatures:
118
+ // sendMessage(sessionId, text, onDelta) → Promise<{ content }>
119
+ // sendMessage(sessionId, text, options, onDelta) → Promise<{ content }>
120
+ //
121
+ // options.context tells the AI what the user is looking at:
122
+ // { brainUpdateId?: number, brainUpdateTitle?: string, filePath?: string }
123
+
124
+ let reply = '';
125
+ await gobi.sendMessage(sessionId, 'Hello', (delta) => {
126
+ reply += delta;
127
+ renderReply(reply);
128
+ });
129
+
130
+ // With context
131
+ await gobi.sendMessage(sessionId, 'Tell me more', {
132
+ context: { brainUpdateId: 42, brainUpdateTitle: 'New insights' }
133
+ }, (delta) => { reply += delta; renderReply(reply); });
134
+
135
+ await gobi.sendMessage(sessionId, 'Explain this', {
136
+ context: { filePath: 'notes/research.md' }
137
+ }, (delta) => { reply += delta; renderReply(reply); });
138
+
139
+ // Start a fresh session
140
+ sessionId = crypto.randomUUID();
141
+ ```
142
+
143
+ ---
144
+
145
+ ## Rendering Markdown
146
+
147
+ Brain update `content` and any markdown read via `readFile` may contain Obsidian-style wiki embeds (`![[path|width]]`). Resolve them before passing to a renderer.
148
+
149
+ The examples below use [marked](https://cdn.jsdelivr.net/npm/marked/marked.min.js) — include it in your `<head>`:
150
+
151
+ ```html
152
+ <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
153
+ ```
154
+
155
+ ```js
156
+ // Define once, reuses getFileUrl from the Files section.
157
+ function resolveWikiImages(md) {
158
+ return md.replace(/!\[\[([^\]|]+)(?:\|(\d+))?\]\]/g, (_, p, w) => {
159
+ const url = getFileUrl(p.trim());
160
+ return w ? `<img src="${url}" width="${w}" style="max-width:100%">`
161
+ : `<img src="${url}" style="max-width:100%">`;
162
+ });
163
+ }
164
+
165
+ const html = marked.parse(resolveWikiImages(update.content));
166
+ ```
167
+
168
+ ---
169
+
170
+ ## Complete Example
171
+
172
+ ```html
173
+ <!DOCTYPE html>
174
+ <html>
175
+ <head>
176
+ <meta charset="UTF-8">
177
+ <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
178
+ <style>
179
+ * { box-sizing: border-box; margin: 0; padding: 0; }
180
+ body { font-family: system-ui, sans-serif; max-width: 720px; margin: 0 auto; padding: 24px; }
181
+ .update { margin-bottom: 32px; }
182
+ .update h2 { margin-bottom: 8px; }
183
+ .update img { border-radius: 8px; }
184
+ #chat { margin-top: 40px; border-top: 1px solid #ddd; padding-top: 24px; }
185
+ .message { margin-bottom: 12px; padding: 8px 12px; border-radius: 8px; }
186
+ .message[data-role="human"] { background: #e8f0fe; }
187
+ .message[data-role="assistant"] { background: #f1f3f4; }
188
+ #chat-input { display: flex; gap: 8px; margin-top: 16px; }
189
+ #chat-input input { flex: 1; padding: 8px; border: 1px solid #ccc; border-radius: 6px; }
190
+ #chat-input button { padding: 8px 16px; border: none; border-radius: 6px; background: #1a73e8; color: #fff; cursor: pointer; }
191
+ .login-prompt { text-align: center; color: #666; }
192
+ .login-prompt a { color: #1a73e8; cursor: pointer; text-decoration: underline; }
193
+ </style>
194
+ </head>
195
+ <body>
196
+ <div id="updates"></div>
197
+ <div id="chat">
198
+ <div id="messages"></div>
199
+ <div id="chat-input">
200
+ <input id="input" type="text" placeholder="Ask a question…" />
201
+ <button onclick="onSend()">Send</button>
202
+ </div>
203
+ </div>
204
+
205
+ <script>
206
+ document.title = gobi.vault.title || 'Brain';
207
+
208
+ // ── Helpers ──────────────────────────────────────
209
+
210
+ function getFileUrl(path) {
211
+ const enc = path.split('/').map(encodeURIComponent).join('/');
212
+ return `${gobi.vault.webdriveUrl}/api/v1/file/raw/${gobi.vault.vaultId}/${enc}`;
213
+ }
214
+
215
+ function resolveWikiImages(md) {
216
+ return md.replace(/!\[\[([^\]|]+)(?:\|(\d+))?\]\]/g, (_, p, w) => {
217
+ const url = getFileUrl(p.trim());
218
+ return w ? `<img src="${url}" width="${w}" style="max-width:100%">`
219
+ : `<img src="${url}" style="max-width:100%">`;
220
+ });
221
+ }
222
+
223
+ function escapeHtml(s) {
224
+ const el = document.createElement('div');
225
+ el.textContent = s;
226
+ return el.innerHTML;
227
+ }
228
+
229
+ function redirectToLogin() {
230
+ window.top.location.href =
231
+ `https://gobispace.com/login?redirect_uri=${encodeURIComponent(window.location.href)}`;
232
+ }
233
+
234
+ // ── Brain updates ────────────────────────────────
235
+
236
+ async function loadUpdates() {
237
+ try {
238
+ const { data: updates } = await gobi.listBrainUpdates({ limit: 5 });
239
+ const el = document.getElementById('updates');
240
+ for (const u of updates) {
241
+ const div = document.createElement('div');
242
+ div.className = 'update';
243
+ div.innerHTML = `<h2>${escapeHtml(u.title)}</h2>${marked.parse(resolveWikiImages(u.content))}`;
244
+ el.appendChild(div);
245
+ }
246
+ } catch (err) {
247
+ console.error('Failed to load brain updates:', err);
248
+ }
249
+ }
250
+
251
+ // ── Chat ─────────────────────────────────────────
252
+
253
+ let sessionId = null;
254
+ const messagesEl = document.getElementById('messages');
255
+
256
+ function renderMessage(role, html) {
257
+ const div = document.createElement('div');
258
+ div.className = 'message';
259
+ div.dataset.role = role;
260
+ div.innerHTML = html;
261
+ messagesEl.appendChild(div);
262
+ return div;
263
+ }
264
+
265
+ async function initChat() {
266
+ try {
267
+ const { data: sessions } = await gobi.getSessions({ limit: 1 });
268
+ if (!sessions.length) {
269
+ messagesEl.innerHTML =
270
+ '<p class="login-prompt">No chat sessions yet. <a onclick="redirectToLogin()">Log in</a> to start chatting.</p>';
271
+ return;
272
+ }
273
+ sessionId = sessions[0].sessionId;
274
+ const { messages } = await gobi.loadMessages(sessionId, { limit: 20 });
275
+ for (const m of messages) {
276
+ renderMessage(m.role, m.role === 'assistant' ? marked.parse(m.content) : escapeHtml(m.content));
277
+ }
278
+ } catch (err) {
279
+ console.error('Failed to init chat:', err);
280
+ }
281
+ }
282
+
283
+ async function onSend() {
284
+ const input = document.getElementById('input');
285
+ const text = input.value.trim();
286
+ if (!text) return;
287
+ input.value = '';
288
+
289
+ if (!sessionId) sessionId = crypto.randomUUID();
290
+ renderMessage('human', escapeHtml(text));
291
+
292
+ const replyEl = renderMessage('assistant', '…');
293
+ let reply = '';
294
+ try {
295
+ await gobi.sendMessage(sessionId, text, (delta) => {
296
+ reply += delta;
297
+ replyEl.innerHTML = marked.parse(reply);
298
+ });
299
+ } catch (err) {
300
+ replyEl.textContent = 'Error: ' + err.message;
301
+ }
302
+ }
303
+
304
+ // Submit on Enter
305
+ document.getElementById('input').addEventListener('keydown', (e) => {
306
+ if (e.key === 'Enter') onSend();
307
+ });
308
+
309
+ // ─────────────────────────────────────────────────
310
+
311
+ loadUpdates();
312
+ initChat();
313
+ </script>
314
+ </body>
315
+ </html>
316
+ ```
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes