@gobi-ai/cli 1.1.0 → 1.2.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.
@@ -8,7 +8,7 @@ description: >-
8
8
 
9
9
  # Gobi Homepage Developer Guide
10
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, and chat.
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
12
 
13
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
14
 
@@ -20,7 +20,7 @@ A **Gobi Homepage** is a custom HTML page hosted on a vault's webdrive and serve
20
20
  ```bash
21
21
  gobi sync
22
22
  ```
23
- 2. Set `homepage` in PUBLISH.md (homepage property):
23
+ 2. Set `homepage` in BRAIN.md (homepage property):
24
24
  - `homepage: "[[app/home.html]]"` — Gobi sidebars visible alongside the homepage
25
25
  - `homepage: "[[app/home.html?nav=false]]"` — full-screen, no Gobi chrome
26
26
 
@@ -67,6 +67,31 @@ function getFileUrl(path) {
67
67
  }
68
68
  ```
69
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
+
70
95
  ### Chat (login required)
71
96
 
72
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.
@@ -94,7 +119,7 @@ const { messages, hasMore, nextCursor } = await gobi.loadMessages('sess_abc', {
94
119
  // sendMessage(sessionId, text, options, onDelta) → Promise<{ content }>
95
120
  //
96
121
  // options.context tells the AI what the user is looking at:
97
- // { filePath?: string }
122
+ // { brainUpdateId?: number, brainUpdateTitle?: string, filePath?: string }
98
123
 
99
124
  let reply = '';
100
125
  await gobi.sendMessage(sessionId, 'Hello', (delta) => {
@@ -103,6 +128,10 @@ await gobi.sendMessage(sessionId, 'Hello', (delta) => {
103
128
  });
104
129
 
105
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
+
106
135
  await gobi.sendMessage(sessionId, 'Explain this', {
107
136
  context: { filePath: 'notes/research.md' }
108
137
  }, (delta) => { reply += delta; renderReply(reply); });
@@ -115,7 +144,7 @@ sessionId = crypto.randomUUID();
115
144
 
116
145
  ## Rendering Markdown
117
146
 
118
- Markdown read via `readFile` may contain Obsidian-style wiki embeds (`![[path|width]]`). Resolve them before passing to a renderer.
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.
119
148
 
120
149
  The examples below use [marked](https://cdn.jsdelivr.net/npm/marked/marked.min.js) — include it in your `<head>`:
121
150
 
@@ -133,7 +162,7 @@ function resolveWikiImages(md) {
133
162
  });
134
163
  }
135
164
 
136
- const html = marked.parse(resolveWikiImages(text));
165
+ const html = marked.parse(resolveWikiImages(update.content));
137
166
  ```
138
167
 
139
168
  **Open links in a new tab.** The homepage runs in a sandboxed iframe — clicking a rendered link replaces the iframe with the external page. Override the renderer so every `<a>` opens in a new tab:
@@ -146,7 +175,7 @@ renderer.link = (href, title, text) =>
146
175
  marked.setOptions({ renderer });
147
176
  ```
148
177
 
149
- **Plain-text previews.** For preview cards, render a truncated preview with `escapeHtml(content.substring(0, 200))` — don't run markdown on a random substring, it produces broken HTML. Use `marked.parse(resolveWikiImages(content))` only for the full expanded view. Same for chat: `marked.parse(content)` for assistant messages, `escapeHtml(content)` for human messages.
178
+ **Plain-text previews.** For BU list cards, render a truncated preview with `escapeHtml(content.substring(0, 200))` — don't run markdown on a random substring, it produces broken HTML. Use `marked.parse(resolveWikiImages(content))` only for the full expanded view. Same for chat: `marked.parse(content)` for assistant messages, `escapeHtml(content)` for human messages.
150
179
 
151
180
  ---
152
181
 
@@ -173,9 +202,58 @@ Centralize colors and spacing in CSS custom properties so restyling is a one-lin
173
202
 
174
203
  Pair with Google Fonts (e.g. Space Grotesk for headings, IBM Plex Mono for meta, Inter for body) via CDN `<link>`.
175
204
 
205
+ ### Knowledge Graph from BU topics
206
+
207
+ Brain updates carry a `topics` array. Treat each topic as a node and any two topics co-occurring in the same BU as an edge — you get a force-directed graph of the vault's themes for free. Use [d3](https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js).
208
+
209
+ ```js
210
+ // Separate data-building from rendering so the same graph can be drawn at multiple sizes.
211
+ function buildGraphData(updates) {
212
+ const counts = new Map(); // name → frequency
213
+ const edges = new Map(); // "a|b" → weight
214
+ for (const u of updates) {
215
+ const names = (u.topics || []).map(t => t.name);
216
+ for (const n of names) counts.set(n, (counts.get(n) || 0) + 1);
217
+ for (let i = 0; i < names.length; i++)
218
+ for (let j = i + 1; j < names.length; j++) {
219
+ const key = [names[i], names[j]].sort().join('|');
220
+ edges.set(key, (edges.get(key) || 0) + 1);
221
+ }
222
+ }
223
+ // Top 20 by frequency, then drop orphans (nodes with no surviving edges).
224
+ const top = [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 20).map(([n]) => n);
225
+ const keep = new Set(top);
226
+ const links = [...edges].flatMap(([k, w]) => {
227
+ const [a, b] = k.split('|');
228
+ return keep.has(a) && keep.has(b) ? [{ source: a, target: b, weight: w }] : [];
229
+ });
230
+ const connected = new Set(links.flatMap(l => [l.source, l.target]));
231
+ const nodes = top.filter(n => connected.has(n)).map(n => ({ id: n, count: counts.get(n) }));
232
+ return { nodes, links };
233
+ }
234
+
235
+ function drawGraph(containerId, w, h, data, opts = {}) {
236
+ const { nodeRange = [4, 16], fontSize = '9px', distance = 60, charge = -80 } = opts;
237
+ const max = Math.max(...data.nodes.map(n => n.count), 1);
238
+ const r = d3.scaleSqrt().domain([1, max]).range(nodeRange);
239
+ const svg = d3.select('#' + containerId).append('svg').attr('width', w).attr('height', h);
240
+ // ... standard d3.forceSimulation with link/charge/center, then clamp in tick:
241
+ // node.attr('cx', d => d.x = Math.max(20, Math.min(w - 20, d.x)))
242
+ // node.attr('cy', d => d.y = Math.max(20, Math.min(h - 20, d.y)))
243
+ // Node fill: accent with opacity 0.3 + 0.7 * (count/max).
244
+ // Call d3.drag() on nodes so visitors can rearrange the graph.
245
+ }
246
+ ```
247
+
248
+ Tips:
249
+ - **Enrich the data.** One page of 8 BUs makes a sparse graph. Paginate 3–4 times (cap at ~32 BUs) before building.
250
+ - **Cache the built data** in a module-level variable so the full-screen overlay can reuse it without refetching.
251
+ - **Mini vs full presets.** Pass different `opts` — e.g. mini `{nodeRange:[4,16], fontSize:'9px', distance:60, charge:-80}`, full `{nodeRange:[8,32], fontSize:'12px', distance:120, charge:-200}`.
252
+ - Run a **separate simulation** for the full-scale instance — copy the nodes/links rather than sharing references, otherwise both graphs fight over the same positions.
253
+
176
254
  ### Full-screen overlay
177
255
 
178
- Useful for expanding any small component into a focused view:
256
+ Useful for expanding the K-Graph (or any small component) into a focused view:
179
257
 
180
258
  ```js
181
259
  function openOverlay(renderInto) {
@@ -194,12 +272,27 @@ function openOverlay(renderInto) {
194
272
 
195
273
  Always restore `body.overflow` on close, and always remove the `keydown` listener.
196
274
 
275
+ ### Brain update card — preview/full toggle
276
+
277
+ Show a truncated card that expands in place on click:
278
+
279
+ ```js
280
+ card.onclick = (event) => {
281
+ // Link click guard — don't toggle when the user clicked a link inside the card.
282
+ if (event.target.closest('a')) return;
283
+ card.classList.toggle('expanded');
284
+ card.querySelector('.body').innerHTML = card.classList.contains('expanded')
285
+ ? marked.parse(resolveWikiImages(update.content))
286
+ : escapeHtml(update.content.substring(0, 200));
287
+ };
288
+ ```
289
+
197
290
  ### Chat suggestion chips
198
291
 
199
292
  Empty chat looks dead. Show clickable prompt chips until the first message is sent:
200
293
 
201
294
  ```js
202
- const prompts = ['What is this vault about?', 'Summarize the latest notes', 'What topics come up most?'];
295
+ const prompts = ['What is this brain about?', 'Summarize the latest update', 'What topics come up most?'];
203
296
  chips.innerHTML = prompts.map(p => `<button class="chip">${escapeHtml(p)}</button>`).join('');
204
297
  chips.querySelectorAll('.chip').forEach((btn, i) => {
205
298
  btn.onclick = () => { input.value = prompts[i]; chips.remove(); input.focus(); };
@@ -224,7 +317,7 @@ Single breakpoint at `768px` is enough for most homepages:
224
317
 
225
318
  ```css
226
319
  @media (max-width: 768px) {
227
- .hero-grid { grid-template-columns: 1fr; }
320
+ .hero-grid, .updates-grid { grid-template-columns: 1fr; }
228
321
  .hero-content { flex-direction: column; }
229
322
  .btn { width: 100%; }
230
323
  }
@@ -243,9 +336,9 @@ Single breakpoint at `768px` is enough for most homepages:
243
336
  <style>
244
337
  * { box-sizing: border-box; margin: 0; padding: 0; }
245
338
  body { font-family: system-ui, sans-serif; max-width: 720px; margin: 0 auto; padding: 24px; }
246
- .hero { margin-bottom: 32px; }
247
- .hero h1 { margin-bottom: 8px; }
248
- .hero p { color: #555; }
339
+ .update { margin-bottom: 32px; }
340
+ .update h2 { margin-bottom: 8px; }
341
+ .update img { border-radius: 8px; }
249
342
  #chat { margin-top: 40px; border-top: 1px solid #ddd; padding-top: 24px; }
250
343
  .message { margin-bottom: 12px; padding: 8px 12px; border-radius: 8px; }
251
344
  .message[data-role="human"] { background: #e8f0fe; }
@@ -258,10 +351,7 @@ Single breakpoint at `768px` is enough for most homepages:
258
351
  </style>
259
352
  </head>
260
353
  <body>
261
- <div class="hero">
262
- <h1 id="title"></h1>
263
- <p id="description"></p>
264
- </div>
354
+ <div id="updates"></div>
265
355
  <div id="chat">
266
356
  <div id="messages"></div>
267
357
  <div id="chat-input">
@@ -271,9 +361,7 @@ Single breakpoint at `768px` is enough for most homepages:
271
361
  </div>
272
362
 
273
363
  <script>
274
- document.title = gobi.vault.title || 'Vault';
275
- document.getElementById('title').textContent = gobi.vault.title || '';
276
- document.getElementById('description').textContent = gobi.vault.description || '';
364
+ document.title = gobi.vault.title || 'Brain';
277
365
 
278
366
  // ── Helpers ──────────────────────────────────────
279
367
 
@@ -301,6 +389,23 @@ Single breakpoint at `768px` is enough for most homepages:
301
389
  `https://gobispace.com/login?redirect_uri=${encodeURIComponent(window.location.href)}`;
302
390
  }
303
391
 
392
+ // ── Brain updates ────────────────────────────────
393
+
394
+ async function loadUpdates() {
395
+ try {
396
+ const { data: updates } = await gobi.listBrainUpdates({ limit: 5 });
397
+ const el = document.getElementById('updates');
398
+ for (const u of updates) {
399
+ const div = document.createElement('div');
400
+ div.className = 'update';
401
+ div.innerHTML = `<h2>${escapeHtml(u.title)}</h2>${marked.parse(resolveWikiImages(u.content))}`;
402
+ el.appendChild(div);
403
+ }
404
+ } catch (err) {
405
+ console.error('Failed to load brain updates:', err);
406
+ }
407
+ }
408
+
304
409
  // ── Chat ─────────────────────────────────────────
305
410
 
306
411
  let sessionId = null;
@@ -361,6 +466,7 @@ Single breakpoint at `768px` is enough for most homepages:
361
466
 
362
467
  // ─────────────────────────────────────────────────
363
468
 
469
+ loadUpdates();
364
470
  initChat();
365
471
  </script>
366
472
  </body>
@@ -1,20 +1,19 @@
1
1
  ---
2
2
  name: gobi-space
3
3
  description: >-
4
- Gobi space commands for community interaction: post threads and
5
- replies, browse the unified message feed and topic feeds, walk reply
6
- lineage, and post to the global (slugless) space. Use when the user
7
- wants to read or write threads and replies in their Gobi community
8
- spaces. Space and member administration is web-UI only.
4
+ Gobi space commands for community interaction: list/create/edit/delete
5
+ threads and replies, browse topics and topic threads, explore what's
6
+ happening in a space. Use when the user wants to read, write, or manage
7
+ threads and replies in their Gobi community spaces.
9
8
  allowed-tools: Bash(gobi:*)
10
9
  metadata:
11
10
  author: gobi-ai
12
- version: "0.9.13"
11
+ version: "0.8.0"
13
12
  ---
14
13
 
15
14
  # gobi-space
16
15
 
17
- Gobi space commands for community interaction (v0.9.13).
16
+ Gobi space commands for community interaction (v0.8.0).
18
17
 
19
18
  Requires gobi-cli installed and authenticated. See gobi-core skill for setup.
20
19
 
@@ -41,43 +40,19 @@ For programmatic/agent usage, always pass `--json` as a **global** option (befor
41
40
  gobi --json space list-threads
42
41
  ```
43
42
 
44
- > Space and member administration (creating spaces, inviting/approving members, joining/leaving) is web-UI only and not available in the CLI.
45
-
46
43
  ## Available Commands
47
44
 
48
- ### Space details
49
- - `gobi space get` — Get details for a space.
50
-
51
- ### Topics
52
45
  - `gobi space list-topics` — List topics in a space, ordered by most recent content linkage.
53
46
  - `gobi space list-topic-threads` — List threads tagged with a topic in a space (cursor-paginated).
54
-
55
- ### Feed & lineage
56
- - `gobi space messages` — List the unified message feed (threads and replies, newest first).
57
- - `gobi space ancestors` — Show the ancestor lineage of a thread or reply (root → immediate parent).
58
-
59
- ### Threads
60
47
  - `gobi space get-thread` — Get a thread and its replies (paginated).
61
48
  - `gobi space list-threads` — List threads in a space (paginated).
62
49
  - `gobi space create-thread` — Create a thread in a space.
63
50
  - `gobi space edit-thread` — Edit a thread. You must be the author.
64
51
  - `gobi space delete-thread` — Delete a thread. You must be the author.
65
-
66
- ### Replies
67
52
  - `gobi space create-reply` — Create a reply to a thread in a space.
68
53
  - `gobi space edit-reply` — Edit a reply. You must be the author.
69
54
  - `gobi space delete-reply` — Delete a reply. You must be the author.
70
55
 
71
- ### Global thread space
72
- The global thread space has no slug and is visible across all spaces.
73
-
74
- - `gobi global messages` — List the global unified message feed (newest first).
75
- - `gobi global get-thread` — Get a global thread and its direct replies.
76
- - `gobi global ancestors` — Show the ancestor lineage of a global thread or reply.
77
- - `gobi global create-thread` — Create a thread in the global space.
78
- - `gobi global reply` — Reply to a thread in the global space.
79
-
80
56
  ## Reference Documentation
81
57
 
82
58
  - [gobi space](references/space.md)
83
- - [gobi global](references/global.md)
@@ -3,7 +3,7 @@
3
3
  ```
4
4
  Usage: gobi space [options] [command]
5
5
 
6
- Space commands. A Space is a shared room of members where they post threads and replies, organized by topics.
6
+ Space commands (threads, replies).
7
7
 
8
8
  Options:
9
9
  --space-slug <slug> Space slug (overrides .gobi/settings.yaml)
@@ -11,12 +11,9 @@ Options:
11
11
 
12
12
  Commands:
13
13
  list List spaces you are a member of.
14
- get [spaceSlug] Get details for a space. Pass a slug or omit to use the current space (from .gobi/settings.yaml or --space-slug).
15
14
  warp [spaceSlug] Select the active space. Pass a slug to warp directly, or omit for interactive selection.
16
15
  list-topics [options] List topics in a space, ordered by most recent content linkage.
17
16
  list-topic-threads [options] <topicSlug> List threads tagged with a topic in a space (cursor-paginated).
18
- messages [options] List the unified message feed (threads and replies, newest first) in a space.
19
- ancestors <threadId> Show the ancestor lineage of a thread or reply (root → immediate parent).
20
17
  get-thread [options] <threadId> Get a thread and its replies (paginated).
21
18
  list-threads [options] List threads in a space (paginated).
22
19
  create-thread [options] Create a thread in a space.
@@ -28,17 +25,6 @@ Commands:
28
25
  help [command] display help for command
29
26
  ```
30
27
 
31
- ## get
32
-
33
- ```
34
- Usage: gobi space get [options] [spaceSlug]
35
-
36
- Get details for a space. Pass a slug or omit to use the current space (from .gobi/settings.yaml or --space-slug).
37
-
38
- Options:
39
- -h, --help display help for command
40
- ```
41
-
42
28
  ## list-topics
43
29
 
44
30
  ```
@@ -64,30 +50,6 @@ Options:
64
50
  -h, --help display help for command
65
51
  ```
66
52
 
67
- ## messages
68
-
69
- ```
70
- Usage: gobi space messages [options]
71
-
72
- List the unified message feed (threads and replies, newest first) in a space.
73
-
74
- Options:
75
- --limit <number> Items per page (default: "20")
76
- --cursor <string> Pagination cursor from previous response
77
- -h, --help display help for command
78
- ```
79
-
80
- ## ancestors
81
-
82
- ```
83
- Usage: gobi space ancestors [options] <threadId>
84
-
85
- Show the ancestor lineage of a thread or reply (root → immediate parent).
86
-
87
- Options:
88
- -h, --help display help for command
89
- ```
90
-
91
53
  ## get-thread
92
54
 
93
55
  ```
@@ -1,203 +0,0 @@
1
- import { readFileSync } from "fs";
2
- import { apiGet, apiPost } from "../client.js";
3
- import { isJsonMode, jsonOut, unwrapResp } from "./utils.js";
4
- function readContent(value) {
5
- if (value === "-")
6
- return readFileSync("/dev/stdin", "utf8");
7
- return value;
8
- }
9
- function formatMessageLine(m) {
10
- const isReply = m.parentThreadId != null;
11
- const id = `[${isReply ? "r" : "t"}:${m.id}]`;
12
- const kind = isReply ? "reply " : "thread";
13
- const author = m.author?.name ||
14
- `User ${m.authorId ?? "?"}`;
15
- let label;
16
- if (isReply) {
17
- const text = m.content || "";
18
- label = text.length > 80 ? text.slice(0, 80) + "…" : text;
19
- label = label.replace(/\s+/g, " ").trim();
20
- }
21
- else {
22
- label = m.title || m.content || "";
23
- }
24
- return `${id} ${kind} ${author} "${label}" ${m.createdAt}`;
25
- }
26
- export function registerGlobalCommand(program) {
27
- const global = program
28
- .command("global")
29
- .description("Global thread commands. Global is the platform-wide thread feed visible to everyone on Gobi.");
30
- // ── Messages (unified feed) ──
31
- global
32
- .command("messages")
33
- .description("List the global unified message feed (threads and replies, newest first).")
34
- .option("--limit <number>", "Items per page", "20")
35
- .option("--cursor <string>", "Pagination cursor from previous response")
36
- .action(async (opts) => {
37
- const params = {
38
- limit: parseInt(opts.limit, 10),
39
- };
40
- if (opts.cursor)
41
- params.cursor = opts.cursor;
42
- const resp = (await apiGet(`/global/messages`, params));
43
- if (isJsonMode(global)) {
44
- jsonOut({
45
- items: resp.data || [],
46
- pagination: resp.pagination || {},
47
- });
48
- return;
49
- }
50
- const items = (resp.data || []);
51
- const pagination = (resp.pagination || {});
52
- if (!items.length) {
53
- console.log("No messages found.");
54
- return;
55
- }
56
- const lines = items.map(formatMessageLine);
57
- const footer = pagination.hasMore ? `\n Next cursor: ${pagination.nextCursor}` : "";
58
- console.log(`Global messages (${items.length} items, newest first):\n` + lines.join("\n") + footer);
59
- });
60
- // ── Get thread ──
61
- global
62
- .command("get-thread <threadId>")
63
- .description("Get a global thread and its direct replies (paginated).")
64
- .option("--limit <number>", "Replies per page", "20")
65
- .option("--cursor <string>", "Pagination cursor from previous response")
66
- .action(async (threadId, opts) => {
67
- const params = {
68
- limit: parseInt(opts.limit, 10),
69
- };
70
- if (opts.cursor)
71
- params.cursor = opts.cursor;
72
- const resp = (await apiGet(`/global/threads/${threadId}`, params));
73
- const data = unwrapResp(resp);
74
- const pagination = (resp.pagination || {});
75
- if (isJsonMode(global)) {
76
- jsonOut({ ...data, pagination });
77
- return;
78
- }
79
- const thread = (data.thread || data);
80
- const replies = (data.items || []);
81
- const author = thread.author?.name ||
82
- `User ${thread.authorId}`;
83
- const replyLines = [];
84
- for (const r of replies) {
85
- const rAuthor = r.author?.name ||
86
- `User ${r.authorId}`;
87
- const text = r.content || "";
88
- const truncated = text.length > 200 ? text.slice(0, 200) + "…" : text;
89
- replyLines.push(` - ${rAuthor}: ${truncated} (${r.createdAt})`);
90
- }
91
- const isReply = thread.parentThreadId != null;
92
- const heading = isReply
93
- ? `Reply [r:${thread.id}]`
94
- : `Thread: ${thread.title || "(no title)"}`;
95
- const output = [
96
- heading,
97
- `By: ${author} on ${thread.createdAt}`,
98
- "",
99
- thread.content,
100
- "",
101
- `Replies (${replies.length} items):`,
102
- ...replyLines,
103
- ...(pagination.hasMore ? [` Next cursor: ${pagination.nextCursor}`] : []),
104
- ].join("\n");
105
- console.log(output);
106
- });
107
- // ── Ancestors ──
108
- global
109
- .command("ancestors <threadId>")
110
- .description("Show the ancestor lineage of a global thread or reply (root → immediate parent).")
111
- .action(async (threadId) => {
112
- const resp = (await apiGet(`/global/threads/${threadId}/ancestors`));
113
- const data = unwrapResp(resp);
114
- const ancestors = (data.ancestors || []);
115
- if (isJsonMode(global)) {
116
- jsonOut({ ancestors });
117
- return;
118
- }
119
- if (!ancestors.length) {
120
- console.log("No ancestors (this is a root thread).");
121
- return;
122
- }
123
- const lines = [];
124
- ancestors.forEach((a, i) => {
125
- lines.push(`${i + 1}. ${formatMessageLine(a)}`);
126
- });
127
- console.log(`Ancestors (${ancestors.length} items, root first):\n` + lines.join("\n"));
128
- });
129
- // ── Create thread ──
130
- global
131
- .command("create-thread")
132
- .description("Create a global thread (visible platform-wide).")
133
- .option("--title <title>", "Title of the thread")
134
- .option("--content <content>", "Thread content (markdown supported, use \"-\" for stdin)")
135
- .option("--rich-text <richText>", "Rich-text JSON array (mutually exclusive with --content)")
136
- .action(async (opts) => {
137
- if (!opts.content && !opts.richText) {
138
- throw new Error("Provide either --content or --rich-text.");
139
- }
140
- if (opts.content && opts.richText) {
141
- throw new Error("--content and --rich-text are mutually exclusive.");
142
- }
143
- const body = {};
144
- if (opts.title != null)
145
- body.title = opts.title;
146
- if (opts.content != null)
147
- body.content = readContent(opts.content);
148
- if (opts.richText != null) {
149
- let parsed;
150
- try {
151
- parsed = JSON.parse(opts.richText);
152
- }
153
- catch {
154
- throw new Error("Invalid --rich-text JSON.");
155
- }
156
- body.richText = parsed;
157
- }
158
- const resp = (await apiPost(`/global/threads`, body));
159
- const thread = unwrapResp(resp);
160
- if (isJsonMode(global)) {
161
- jsonOut(thread);
162
- return;
163
- }
164
- console.log(`Global thread created!\n` +
165
- ` ID: ${thread.id}\n` +
166
- (thread.title ? ` Title: ${thread.title}\n` : "") +
167
- ` Created: ${thread.createdAt}`);
168
- });
169
- // ── Reply ──
170
- global
171
- .command("reply <threadId>")
172
- .description("Reply to a global thread.")
173
- .option("--content <content>", "Reply content (markdown supported, use \"-\" for stdin)")
174
- .option("--rich-text <richText>", "Rich-text JSON array (mutually exclusive with --content)")
175
- .action(async (threadId, opts) => {
176
- if (!opts.content && !opts.richText) {
177
- throw new Error("Provide either --content or --rich-text.");
178
- }
179
- if (opts.content && opts.richText) {
180
- throw new Error("--content and --rich-text are mutually exclusive.");
181
- }
182
- const body = {};
183
- if (opts.content != null)
184
- body.content = readContent(opts.content);
185
- if (opts.richText != null) {
186
- let parsed;
187
- try {
188
- parsed = JSON.parse(opts.richText);
189
- }
190
- catch {
191
- throw new Error("Invalid --rich-text JSON.");
192
- }
193
- body.richText = parsed;
194
- }
195
- const resp = (await apiPost(`/global/threads/${threadId}/replies`, body));
196
- const reply = unwrapResp(resp);
197
- if (isJsonMode(global)) {
198
- jsonOut(reply);
199
- return;
200
- }
201
- console.log(`Reply created!\n ID: ${reply.id}\n Created: ${reply.createdAt}`);
202
- });
203
- }