@gobi-ai/cli 1.3.8 → 2.0.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.
@@ -1,15 +1,18 @@
1
1
  import { readFileSync } from "fs";
2
- import { apiGet, apiPost } from "../client.js";
3
- import { isJsonMode, jsonOut, unwrapResp } from "./utils.js";
2
+ import { apiGet, apiPost, apiPatch, apiDelete } from "../client.js";
3
+ import { isJsonMode, jsonOut, resolveVaultSlug, unwrapResp } from "./utils.js";
4
+ import { extractWikiLinks, uploadAttachments } from "../attachments.js";
5
+ import { getValidToken } from "../auth/manager.js";
4
6
  function readContent(value) {
5
7
  if (value === "-")
6
8
  return readFileSync("/dev/stdin", "utf8");
7
9
  return value;
8
10
  }
9
- function formatMessageLine(m) {
10
- const isReply = m.parentThreadId != null;
11
- const id = `[${isReply ? "r" : "t"}:${m.id}]`;
12
- const kind = isReply ? "reply " : "thread";
11
+ function formatFeedLine(m) {
12
+ const isReply = m.parentPostId != null ||
13
+ m.type === "post-reply";
14
+ const id = `[${isReply ? "r" : "p"}:${m.id}]`;
15
+ const kind = isReply ? "reply" : "post ";
13
16
  const author = m.author?.name ||
14
17
  `User ${m.authorId ?? "?"}`;
15
18
  let label;
@@ -26,20 +29,23 @@ function formatMessageLine(m) {
26
29
  export function registerGlobalCommand(program) {
27
30
  const global = program
28
31
  .command("global")
29
- .description("Global thread space commands (no slug; visible across all spaces).");
30
- // ── Messages (unified feed) ──
32
+ .description("Global commands (posts and replies in the public feed across all vaults).");
33
+ // ── Feed (unified) ──
31
34
  global
32
- .command("messages")
33
- .description("List the global unified message feed (threads and replies, newest first).")
35
+ .command("feed")
36
+ .description("List the global public feed (posts and replies, newest first).")
34
37
  .option("--limit <number>", "Items per page", "20")
35
38
  .option("--cursor <string>", "Pagination cursor from previous response")
39
+ .option("--following", "Only include posts from authors you follow")
36
40
  .action(async (opts) => {
37
41
  const params = {
38
42
  limit: parseInt(opts.limit, 10),
39
43
  };
40
44
  if (opts.cursor)
41
45
  params.cursor = opts.cursor;
42
- const resp = (await apiGet(`/global/messages`, params));
46
+ if (opts.following)
47
+ params.following = "true";
48
+ const resp = (await apiGet(`/feed`, params));
43
49
  if (isJsonMode(global)) {
44
50
  jsonOut({
45
51
  items: resp.data || [],
@@ -50,92 +56,185 @@ export function registerGlobalCommand(program) {
50
56
  const items = (resp.data || []);
51
57
  const pagination = (resp.pagination || {});
52
58
  if (!items.length) {
53
- console.log("No messages found.");
59
+ console.log("No items found.");
54
60
  return;
55
61
  }
56
- const lines = items.map(formatMessageLine);
62
+ const lines = items.map(formatFeedLine);
57
63
  const footer = pagination.hasMore ? `\n Next cursor: ${pagination.nextCursor}` : "";
58
- console.log(`Global messages (${items.length} items, newest first):\n` + lines.join("\n") + footer);
64
+ console.log(`Global feed (${items.length} items, newest first):\n` + lines.join("\n") + footer);
59
65
  });
60
- // ── Get thread ──
66
+ // ── List posts ──
61
67
  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")
68
+ .command("list-posts")
69
+ .description("List posts in the global feed (paginated). Pass --mine to limit to your own posts.")
70
+ .option("--limit <number>", "Items per page", "20")
65
71
  .option("--cursor <string>", "Pagination cursor from previous response")
66
- .action(async (threadId, opts) => {
72
+ .option("--mine", "Only include posts authored by you")
73
+ .option("--vault-slug <vaultSlug>", "Filter by author vault slug")
74
+ .action(async (opts) => {
67
75
  const params = {
68
76
  limit: parseInt(opts.limit, 10),
69
77
  };
70
78
  if (opts.cursor)
71
79
  params.cursor = opts.cursor;
72
- const resp = (await apiGet(`/global/threads/${threadId}`, params));
73
- const data = unwrapResp(resp);
80
+ if (opts.mine)
81
+ params.mine = "true";
82
+ if (opts.vaultSlug)
83
+ params.vaultSlug = opts.vaultSlug;
84
+ const resp = (await apiGet(`/posts`, params));
85
+ if (isJsonMode(global)) {
86
+ jsonOut({
87
+ items: resp.data || [],
88
+ pagination: resp.pagination || {},
89
+ });
90
+ return;
91
+ }
92
+ const items = (resp.data || []);
74
93
  const pagination = (resp.pagination || {});
94
+ if (!items.length) {
95
+ console.log("No posts found.");
96
+ return;
97
+ }
98
+ const lines = [];
99
+ for (const t of items) {
100
+ const author = t.author?.name ||
101
+ `User ${t.authorId}`;
102
+ const vaultSlug = t.vault?.vaultSlug ||
103
+ t.authorVault?.vaultSlug ||
104
+ "?";
105
+ lines.push(`- [${t.id}] "${t.title}" by ${author} (vault: ${vaultSlug}, ${t.replyCount ?? 0} replies, ${t.createdAt})`);
106
+ }
107
+ const footer = pagination.hasMore ? `\n Next cursor: ${pagination.nextCursor}` : "";
108
+ console.log(`Posts (${items.length} items):\n` + lines.join("\n") + footer);
109
+ });
110
+ // ── Get post (with ancestors and replies) ──
111
+ global
112
+ .command("get-post <postId>")
113
+ .description("Get a global post with its ancestors and replies (paginated).")
114
+ .option("--limit <number>", "Replies per page", "20")
115
+ .option("--cursor <string>", "Pagination cursor from previous response")
116
+ .option("--full", "Show full reply content without truncation")
117
+ .action(async (postId, opts) => {
118
+ const params = {
119
+ limit: parseInt(opts.limit, 10),
120
+ };
121
+ if (opts.cursor)
122
+ params.cursor = opts.cursor;
123
+ const [postResp, ancestorsResp] = await Promise.all([
124
+ apiGet(`/feed/${postId}`, params),
125
+ apiGet(`/feed/${postId}/ancestors`),
126
+ ]);
127
+ const data = unwrapResp(postResp);
128
+ const pagination = (postResp.pagination || {});
129
+ const mentions = (postResp.mentions || {});
130
+ const ancestorsData = unwrapResp(ancestorsResp);
131
+ const ancestors = (ancestorsData.ancestors || []);
75
132
  if (isJsonMode(global)) {
76
- jsonOut({ ...data, pagination });
133
+ jsonOut({ ...data, ancestors, pagination, mentions });
77
134
  return;
78
135
  }
79
- const thread = (data.thread || data);
80
- const replies = (data.items || []);
81
- const author = thread.author?.name ||
82
- `User ${thread.authorId}`;
136
+ const post = (data.update || data.post || data);
137
+ const replies = (data.replies || []);
138
+ const author = post.author?.name ||
139
+ `User ${post.authorId}`;
140
+ const vault = post.vault?.vaultSlug ||
141
+ post.authorVault?.vaultSlug ||
142
+ "?";
143
+ const ancestorLines = [];
144
+ if (ancestors.length) {
145
+ ancestors.forEach((a, i) => {
146
+ ancestorLines.push(` ${i + 1}. ${formatFeedLine(a)}`);
147
+ });
148
+ }
83
149
  const replyLines = [];
84
150
  for (const r of replies) {
85
151
  const rAuthor = r.author?.name ||
86
152
  `User ${r.authorId}`;
87
153
  const text = r.content || "";
88
- const truncated = text.length > 200 ? text.slice(0, 200) + "…" : text;
154
+ const truncated = opts.full || text.length <= 200 ? text : text.slice(0, 200) + "…";
89
155
  replyLines.push(` - ${rAuthor}: ${truncated} (${r.createdAt})`);
90
156
  }
91
- const isReply = thread.parentThreadId != null;
92
- const heading = isReply
93
- ? `Reply [r:${thread.id}]`
94
- : `Thread: ${thread.title || "(no title)"}`;
157
+ const isReplyPost = post.parentPostId != null;
158
+ const heading = isReplyPost
159
+ ? `Reply [r:${post.id}]`
160
+ : `Post: ${post.title || "(no title)"}`;
95
161
  const output = [
96
162
  heading,
97
- `By: ${author} on ${thread.createdAt}`,
163
+ `By: ${author} (vault: ${vault}) on ${post.createdAt}`,
164
+ ...(ancestorLines.length
165
+ ? ["", `Ancestors (${ancestors.length} items, root first):`, ...ancestorLines]
166
+ : []),
98
167
  "",
99
- thread.content,
168
+ post.content || "",
100
169
  "",
101
170
  `Replies (${replies.length} items):`,
102
171
  ...replyLines,
103
- ...(pagination.hasMore ? [` Next cursor: ${pagination.nextCursor}`] : []),
172
+ ...(pagination.hasMore
173
+ ? [` Next cursor: ${pagination.nextCursor}`]
174
+ : []),
104
175
  ].join("\n");
105
176
  console.log(output);
106
177
  });
107
- // ── Ancestors ──
178
+ // ── Create post ──
108
179
  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;
180
+ .command("create-post")
181
+ .description("Create a post in the global feed (publishes from your vault).")
182
+ .option("--title <title>", "Title of the post")
183
+ .option("--content <content>", "Post content (markdown supported, use \"-\" for stdin)")
184
+ .option("--rich-text <richText>", "Rich-text JSON array (mutually exclusive with --content)")
185
+ .option("--vault-slug <vaultSlug>", "Author vault slug (overrides .gobi/settings.yaml)")
186
+ .option("--auto-attachments", "Upload wiki-linked [[files]] to webdrive before posting")
187
+ .action(async (opts) => {
188
+ if (!opts.content && !opts.richText) {
189
+ throw new Error("Provide either --content or --rich-text.");
190
+ }
191
+ if (opts.content && opts.richText) {
192
+ throw new Error("--content and --rich-text are mutually exclusive.");
118
193
  }
119
- if (!ancestors.length) {
120
- console.log("No ancestors (this is a root thread).");
194
+ const vaultSlug = resolveVaultSlug(opts);
195
+ const body = {};
196
+ if (opts.title != null)
197
+ body.title = opts.title;
198
+ if (opts.content != null) {
199
+ const content = readContent(opts.content);
200
+ if (opts.autoAttachments) {
201
+ const token = await getValidToken();
202
+ const links = extractWikiLinks(content);
203
+ await uploadAttachments(vaultSlug, links, token, { addToSyncfiles: true });
204
+ }
205
+ body.content = content;
206
+ }
207
+ if (opts.richText != null) {
208
+ let parsed;
209
+ try {
210
+ parsed = JSON.parse(opts.richText);
211
+ }
212
+ catch {
213
+ throw new Error("Invalid --rich-text JSON.");
214
+ }
215
+ body.richText = parsed;
216
+ }
217
+ const resp = (await apiPost(`/posts/vault/${vaultSlug}`, body));
218
+ const post = unwrapResp(resp);
219
+ if (isJsonMode(global)) {
220
+ jsonOut(post);
121
221
  return;
122
222
  }
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"));
223
+ console.log(`Post created!\n` +
224
+ ` ID: ${post.id}\n` +
225
+ (post.title ? ` Title: ${post.title}\n` : "") +
226
+ ` Created: ${post.createdAt}`);
128
227
  });
129
- // ── Create thread ──
228
+ // ── Edit post ──
130
229
  global
131
- .command("create-thread")
132
- .description("Create a thread in the global space.")
133
- .option("--title <title>", "Title of the thread")
134
- .option("--content <content>", "Thread content (markdown supported, use \"-\" for stdin)")
230
+ .command("edit-post <postId>")
231
+ .description("Edit a post you authored in the global feed.")
232
+ .option("--title <title>", "New title")
233
+ .option("--content <content>", "New content (markdown supported, use \"-\" for stdin)")
135
234
  .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.");
235
+ .action(async (postId, opts) => {
236
+ if (opts.title == null && opts.content == null && opts.richText == null) {
237
+ throw new Error("Provide at least --title, --content, or --rich-text to update.");
139
238
  }
140
239
  if (opts.content && opts.richText) {
141
240
  throw new Error("--content and --rich-text are mutually exclusive.");
@@ -155,24 +254,33 @@ export function registerGlobalCommand(program) {
155
254
  }
156
255
  body.richText = parsed;
157
256
  }
158
- const resp = (await apiPost(`/global/threads`, body));
159
- const thread = unwrapResp(resp);
257
+ const resp = (await apiPatch(`/posts/${postId}`, body));
258
+ const post = unwrapResp(resp);
160
259
  if (isJsonMode(global)) {
161
- jsonOut(thread);
260
+ jsonOut(post);
162
261
  return;
163
262
  }
164
- console.log(`Global thread created!\n` +
165
- ` ID: ${thread.id}\n` +
166
- (thread.title ? ` Title: ${thread.title}\n` : "") +
167
- ` Created: ${thread.createdAt}`);
263
+ console.log(`Post edited!\n ID: ${post.id}\n Edited: ${post.editedAt ?? post.updatedAt}`);
264
+ });
265
+ // ── Delete post ──
266
+ global
267
+ .command("delete-post <postId>")
268
+ .description("Delete a post you authored in the global feed.")
269
+ .action(async (postId) => {
270
+ await apiDelete(`/posts/${postId}`);
271
+ if (isJsonMode(global)) {
272
+ jsonOut({ id: postId });
273
+ return;
274
+ }
275
+ console.log(`Post ${postId} deleted.`);
168
276
  });
169
277
  // ── Reply ──
170
278
  global
171
- .command("reply <threadId>")
172
- .description("Reply to a thread in the global space.")
279
+ .command("create-reply <postId>")
280
+ .description("Reply to a post in the global feed.")
173
281
  .option("--content <content>", "Reply content (markdown supported, use \"-\" for stdin)")
174
282
  .option("--rich-text <richText>", "Rich-text JSON array (mutually exclusive with --content)")
175
- .action(async (threadId, opts) => {
283
+ .action(async (postId, opts) => {
176
284
  if (!opts.content && !opts.richText) {
177
285
  throw new Error("Provide either --content or --rich-text.");
178
286
  }
@@ -192,7 +300,7 @@ export function registerGlobalCommand(program) {
192
300
  }
193
301
  body.richText = parsed;
194
302
  }
195
- const resp = (await apiPost(`/global/threads/${threadId}/replies`, body));
303
+ const resp = (await apiPost(`/posts/${postId}/replies`, body));
196
304
  const reply = unwrapResp(resp);
197
305
  if (isJsonMode(global)) {
198
306
  jsonOut(reply);
@@ -200,4 +308,31 @@ export function registerGlobalCommand(program) {
200
308
  }
201
309
  console.log(`Reply created!\n ID: ${reply.id}\n Created: ${reply.createdAt}`);
202
310
  });
311
+ global
312
+ .command("edit-reply <replyId>")
313
+ .description("Edit a reply you authored in the global feed.")
314
+ .requiredOption("--content <content>", "New reply content (markdown supported, use \"-\" for stdin)")
315
+ .action(async (replyId, opts) => {
316
+ const content = readContent(opts.content);
317
+ const resp = (await apiPatch(`/posts/replies/${replyId}`, {
318
+ content,
319
+ }));
320
+ const reply = unwrapResp(resp);
321
+ if (isJsonMode(global)) {
322
+ jsonOut(reply);
323
+ return;
324
+ }
325
+ console.log(`Reply edited!\n ID: ${reply.id}\n Edited: ${reply.editedAt ?? reply.updatedAt}`);
326
+ });
327
+ global
328
+ .command("delete-reply <replyId>")
329
+ .description("Delete a reply you authored in the global feed.")
330
+ .action(async (replyId) => {
331
+ await apiDelete(`/posts/replies/${replyId}`);
332
+ if (isJsonMode(global)) {
333
+ jsonOut({ replyId });
334
+ return;
335
+ }
336
+ console.log(`Reply ${replyId} deleted.`);
337
+ });
203
338
  }
@@ -205,11 +205,11 @@ export async function runInitFlow() {
205
205
  writeVaultSetting(vaultId);
206
206
  console.log(`Vault set to "${vaultName}" (${vaultId})`);
207
207
  console.log(`Updated ${SETTINGS_DIR}/${SETTINGS_FILE}`);
208
- // Create default BRAIN.md if it doesn't exist
209
- const brainPath = join(process.cwd(), "BRAIN.md");
210
- if (!existsSync(brainPath)) {
211
- writeFileSync(brainPath, `---\ntitle: ${vaultName}\ntags: []\ndescription:\nthumbnail:\nprompt:\n---\n`, "utf-8");
212
- console.log("Created BRAIN.md");
208
+ // Create default PUBLISH.md if it doesn't exist
209
+ const publishPath = join(process.cwd(), "PUBLISH.md");
210
+ if (!existsSync(publishPath)) {
211
+ writeFileSync(publishPath, `---\ntitle: ${vaultName}\ntags: []\ndescription:\nthumbnail:\nprompt:\n---\n`, "utf-8");
212
+ console.log("Created PUBLISH.md");
213
213
  }
214
214
  }
215
215
  export function registerInitCommand(program) {
@@ -18,12 +18,18 @@ function formatNoteLine(note) {
18
18
  const agent = note.agentId != null ? `, agent: ${note.agentId}` : "";
19
19
  return `- [${note.id}] "${snippet.replace(/\n/g, " ")}" (${note.eventDate}${agent}${attachStr}, updated ${note.updatedAt})`;
20
20
  }
21
- export function registerNotesCommand(program) {
22
- const notes = program
23
- .command("notes")
24
- .description("Personal notes (create, list, get, edit, delete).");
25
- // ── List ──
26
- notes
21
+ function formatSavedPostLine(item) {
22
+ const author = item.author?.name;
23
+ const title = item.title || item.content || "(no title)";
24
+ const snippet = title.length > 80 ? title.slice(0, 80) + "…" : title;
25
+ const space = item.spaceSlug ? `, space: ${item.spaceSlug}` : "";
26
+ return `- [${item.postId}] "${snippet.replace(/\n/g, " ")}" by ${author ?? "?"}${space} (saved ${item.savedAt})`;
27
+ }
28
+ function registerNoteCommands(saved) {
29
+ const note = saved
30
+ .command("note")
31
+ .description("Personal saved notes (create, list, get, edit, delete).");
32
+ note
27
33
  .command("list")
28
34
  .description("List your notes. Without --date, returns recent notes via cursor pagination. With --date, returns all notes for that day.")
29
35
  .option("--date <date>", "Filter to a single day (YYYY-MM-DD)")
@@ -42,7 +48,7 @@ export function registerNotesCommand(program) {
42
48
  const resp = (await apiGet(`/app/notes`, params));
43
49
  const items = (resp.data || []);
44
50
  const pagination = (resp.pagination || {});
45
- if (isJsonMode(notes)) {
51
+ if (isJsonMode(saved)) {
46
52
  jsonOut({ items, pagination });
47
53
  return;
48
54
  }
@@ -56,14 +62,13 @@ export function registerNotesCommand(program) {
56
62
  : "";
57
63
  console.log(`Notes (${items.length} items):\n` + lines.join("\n") + footer);
58
64
  });
59
- // ── Get ──
60
- notes
65
+ note
61
66
  .command("get <noteId>")
62
67
  .description("Get a single note by id.")
63
68
  .action(async (noteId) => {
64
69
  const resp = (await apiGet(`/app/notes/${noteId}`));
65
70
  const note = unwrapResp(resp);
66
- if (isJsonMode(notes)) {
71
+ if (isJsonMode(saved)) {
67
72
  jsonOut(note);
68
73
  return;
69
74
  }
@@ -86,8 +91,7 @@ export function registerNotesCommand(program) {
86
91
  .trimEnd();
87
92
  console.log(output);
88
93
  });
89
- // ── Create ──
90
- notes
94
+ note
91
95
  .command("create")
92
96
  .description("Create a note. Provide --content (use '-' for stdin) and/or attachments.")
93
97
  .option("--content <content>", 'Note content (markdown supported, use "-" for stdin)')
@@ -106,14 +110,13 @@ export function registerNotesCommand(program) {
106
110
  body.agentId = parseInt(opts.agentId, 10);
107
111
  const resp = (await apiPost(`/app/notes`, body));
108
112
  const note = unwrapResp(resp);
109
- if (isJsonMode(notes)) {
113
+ if (isJsonMode(saved)) {
110
114
  jsonOut(note);
111
115
  return;
112
116
  }
113
117
  console.log(`Note created!\n ID: ${note.id}\n Date: ${note.eventDate}\n Created: ${note.createdAt}`);
114
118
  });
115
- // ── Edit ──
116
- notes
119
+ note
117
120
  .command("edit <noteId>")
118
121
  .description("Edit a note. Provide --content and/or --agent-id.")
119
122
  .option("--content <content>", 'New note content (markdown supported, use "-" for stdin)')
@@ -132,22 +135,112 @@ export function registerNotesCommand(program) {
132
135
  }
133
136
  const resp = (await apiPatch(`/app/notes/${noteId}`, body));
134
137
  const note = unwrapResp(resp);
135
- if (isJsonMode(notes)) {
138
+ if (isJsonMode(saved)) {
136
139
  jsonOut(note);
137
140
  return;
138
141
  }
139
142
  console.log(`Note edited!\n ID: ${note.id}\n Updated: ${note.updatedAt}`);
140
143
  });
141
- // ── Delete ──
142
- notes
144
+ note
143
145
  .command("delete <noteId>")
144
146
  .description("Delete a note you authored.")
145
147
  .action(async (noteId) => {
146
148
  await apiDelete(`/app/notes/${noteId}`);
147
- if (isJsonMode(notes)) {
149
+ if (isJsonMode(saved)) {
148
150
  jsonOut({ id: noteId });
149
151
  return;
150
152
  }
151
153
  console.log(`Note ${noteId} deleted.`);
152
154
  });
153
155
  }
156
+ function registerPostCommands(saved) {
157
+ const post = saved
158
+ .command("post")
159
+ .description("Saved posts (snapshots of posts and replies you bookmark).");
160
+ post
161
+ .command("list")
162
+ .description("List posts you have saved.")
163
+ .option("--type <type>", "Filter by type: all|article|space-post", "all")
164
+ .option("--limit <number>", "Items per page (1-50)", "20")
165
+ .option("--cursor <string>", "Pagination cursor from previous response")
166
+ .action(async (opts) => {
167
+ const params = {
168
+ type: opts.type,
169
+ limit: parseInt(opts.limit, 10),
170
+ };
171
+ if (opts.cursor)
172
+ params.cursor = opts.cursor;
173
+ const resp = (await apiGet(`/reactions/me/saved`, params));
174
+ const items = (resp.data || []);
175
+ const pagination = (resp.pagination || {});
176
+ if (isJsonMode(saved)) {
177
+ jsonOut({ items, pagination });
178
+ return;
179
+ }
180
+ if (!items.length) {
181
+ console.log("No saved posts found.");
182
+ return;
183
+ }
184
+ const lines = items.map(formatSavedPostLine);
185
+ const footer = pagination.hasMore ? `\n Next cursor: ${pagination.nextCursor}` : "";
186
+ console.log(`Saved posts (${items.length} items):\n` + lines.join("\n") + footer);
187
+ });
188
+ post
189
+ .command("get <postId>")
190
+ .description("Get a saved post snapshot by post id.")
191
+ .action(async (postId) => {
192
+ const resp = (await apiGet(`/feed/${postId}`));
193
+ const data = unwrapResp(resp);
194
+ if (isJsonMode(saved)) {
195
+ jsonOut(data);
196
+ return;
197
+ }
198
+ const post = (data.update || data.post || data);
199
+ const author = post.author?.name ||
200
+ `User ${post.authorId}`;
201
+ const title = post.title || "(no title)";
202
+ console.log([
203
+ `Saved post [${post.id}]: ${title}`,
204
+ `By: ${author} on ${post.createdAt}`,
205
+ "",
206
+ post.content || "",
207
+ ].join("\n"));
208
+ });
209
+ post
210
+ .command("create")
211
+ .description("Save a post or reply. Records a snapshot in your saved-posts collection.")
212
+ .requiredOption("--source <id>", "Source post or reply id to save (numeric)")
213
+ .action(async (opts) => {
214
+ const sourceId = parseInt(opts.source, 10);
215
+ if (!Number.isFinite(sourceId)) {
216
+ throw new Error("--source must be a numeric post or reply id.");
217
+ }
218
+ const resp = (await apiPost(`/reactions/posts/${sourceId}/save`, {
219
+ vaultIds: [],
220
+ }));
221
+ const data = unwrapResp(resp);
222
+ if (isJsonMode(saved)) {
223
+ jsonOut({ postId: sourceId, ...data });
224
+ return;
225
+ }
226
+ console.log(`Saved post ${sourceId}.`);
227
+ });
228
+ post
229
+ .command("delete <postId>")
230
+ .description("Remove a post from your saved-posts collection.")
231
+ .action(async (postId) => {
232
+ await apiDelete(`/reactions/posts/${postId}/save`);
233
+ if (isJsonMode(saved)) {
234
+ jsonOut({ postId });
235
+ return;
236
+ }
237
+ console.log(`Removed post ${postId} from saved.`);
238
+ });
239
+ }
240
+ export function registerSavedCommand(program) {
241
+ const saved = program
242
+ .command("saved")
243
+ .description("Saved-knowledge commands (notes and posts).");
244
+ registerNoteCommands(saved);
245
+ registerPostCommands(saved);
246
+ }