@gobi-ai/cli 1.3.8 → 2.0.1

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.
Files changed (38) hide show
  1. package/.claude-plugin/marketplace.json +5 -6
  2. package/.claude-plugin/plugin.json +3 -4
  3. package/README.md +174 -82
  4. package/commands/space-explore.md +10 -10
  5. package/commands/space-share.md +13 -7
  6. package/dist/attachments.js +2 -1
  7. package/dist/commands/draft.js +2 -3
  8. package/dist/commands/global.js +212 -72
  9. package/dist/commands/init.js +5 -5
  10. package/dist/commands/{notes.js → saved.js} +115 -23
  11. package/dist/commands/space.js +121 -111
  12. package/dist/commands/sync.js +2 -56
  13. package/dist/commands/update.js +14 -8
  14. package/dist/commands/utils.js +6 -0
  15. package/dist/commands/vault.js +113 -0
  16. package/dist/main.js +4 -8
  17. package/package.json +2 -2
  18. package/skills/gobi-core/SKILL.md +19 -15
  19. package/skills/gobi-core/references/space.md +18 -19
  20. package/skills/gobi-draft/SKILL.md +3 -3
  21. package/skills/gobi-homepage/SKILL.md +21 -19
  22. package/skills/gobi-media/SKILL.md +2 -2
  23. package/skills/gobi-saved/SKILL.md +59 -0
  24. package/skills/gobi-saved/references/saved.md +52 -0
  25. package/skills/gobi-sense/SKILL.md +8 -4
  26. package/skills/gobi-space/SKILL.md +55 -38
  27. package/skills/gobi-space/references/global.md +87 -26
  28. package/skills/gobi-space/references/space.md +49 -61
  29. package/skills/gobi-vault/SKILL.md +92 -0
  30. package/skills/{gobi-core/references/sync.md → gobi-vault/references/vault.md} +41 -2
  31. package/dist/commands/brain.js +0 -141
  32. package/dist/commands/feed.js +0 -148
  33. package/skills/gobi-brain/SKILL.md +0 -100
  34. package/skills/gobi-brain/references/brain.md +0 -66
  35. package/skills/gobi-feed/SKILL.md +0 -43
  36. package/skills/gobi-feed/references/feed.md +0 -80
  37. package/skills/gobi-notes/SKILL.md +0 -52
  38. package/skills/gobi-notes/references/notes.md +0 -82
@@ -1,18 +1,17 @@
1
- import { readFileSync } from "fs";
2
1
  import { apiGet, apiPost, apiPatch, apiDelete } from "../client.js";
3
2
  import { selectSpace, writeSpaceSetting } from "./init.js";
4
- import { isJsonMode, jsonOut, resolveSpaceSlug, resolveVaultSlug, unwrapResp } from "./utils.js";
3
+ import { isJsonMode, jsonOut, readStdin, resolveSpaceSlug, resolveVaultSlug, unwrapResp, } from "./utils.js";
5
4
  import { extractWikiLinks, uploadAttachments } from "../attachments.js";
6
5
  import { getValidToken } from "../auth/manager.js";
7
6
  function readContent(value) {
8
7
  if (value === "-")
9
- return readFileSync("/dev/stdin", "utf8");
8
+ return readStdin();
10
9
  return value;
11
10
  }
12
- function formatMessageLine(m) {
13
- const isReply = m.parentThreadId != null;
14
- const id = `[${isReply ? "r" : "t"}:${m.id}]`;
15
- const kind = isReply ? "reply " : "thread";
11
+ function formatFeedLine(m) {
12
+ const isReply = m.parentPostId != null;
13
+ const id = `[${isReply ? "r" : "p"}:${m.id}]`;
14
+ const kind = isReply ? "reply" : "post ";
16
15
  const author = m.author?.name ||
17
16
  `User ${m.authorId ?? "?"}`;
18
17
  let label;
@@ -29,7 +28,7 @@ function formatMessageLine(m) {
29
28
  export function registerSpaceCommand(program) {
30
29
  const space = program
31
30
  .command("space")
32
- .description("Space commands (threads, replies). Space and member admin is web-UI only.")
31
+ .description("Space commands (posts, replies). Space and member admin is web-UI only.")
33
32
  .option("--space-slug <slug>", "Space slug (overrides .gobi/settings.yaml)");
34
33
  // ── List spaces ──
35
34
  space
@@ -124,8 +123,8 @@ export function registerSpaceCommand(program) {
124
123
  console.log(`Topics (${items.length}):\n` + lines.join("\n"));
125
124
  });
126
125
  space
127
- .command("list-topic-threads <topicSlug>")
128
- .description("List threads tagged with a topic in a space (cursor-paginated).")
126
+ .command("list-topic-posts <topicSlug>")
127
+ .description("List posts tagged with a topic in a space (cursor-paginated).")
129
128
  .option("--limit <number>", "Items per page", "20")
130
129
  .option("--cursor <string>", "Pagination cursor from previous response")
131
130
  .action(async (topicSlug, opts) => {
@@ -135,7 +134,7 @@ export function registerSpaceCommand(program) {
135
134
  };
136
135
  if (opts.cursor)
137
136
  params.cursor = opts.cursor;
138
- const resp = (await apiGet(`/spaces/${spaceSlug}/topics/${topicSlug}/threads`, params));
137
+ const resp = (await apiGet(`/spaces/${spaceSlug}/topics/${topicSlug}/posts`, params));
139
138
  const data = unwrapResp(resp);
140
139
  const pagination = (resp.pagination || {});
141
140
  if (isJsonMode(space)) {
@@ -143,27 +142,27 @@ export function registerSpaceCommand(program) {
143
142
  return;
144
143
  }
145
144
  const topic = (data.topic || {});
146
- const threads = (data.threads || []);
147
- if (!threads.length) {
148
- console.log(`No threads found for topic "${topic.name || topicSlug}".`);
145
+ const posts = (data.posts || []);
146
+ if (!posts.length) {
147
+ console.log(`No posts found for topic "${topic.name || topicSlug}".`);
149
148
  return;
150
149
  }
151
150
  const lines = [];
152
- for (const t of threads) {
151
+ for (const t of posts) {
153
152
  const author = t.author?.name || "Unknown";
154
153
  const spaceName = t.space?.name || "";
155
154
  lines.push(`- [${t.id}] "${t.title}" by ${author} in ${spaceName} (${t.replyCount} replies, ${t.createdAt})`);
156
155
  }
157
156
  const footer = pagination.hasMore ? `\n Next cursor: ${pagination.nextCursor}` : "";
158
157
  console.log(`Topic: ${topic.name || topicSlug}\n` +
159
- `Threads (${threads.length} items):\n` +
158
+ `Posts (${posts.length} items):\n` +
160
159
  lines.join("\n") +
161
160
  footer);
162
161
  });
163
- // ── Messages (unified feed) ──
162
+ // ── Feed (unified) ──
164
163
  space
165
- .command("messages")
166
- .description("List the unified message feed (threads and replies, newest first) in a space.")
164
+ .command("feed")
165
+ .description("List the unified feed (posts and replies, newest first) in a space.")
167
166
  .option("--limit <number>", "Items per page", "20")
168
167
  .option("--cursor <string>", "Pagination cursor from previous response")
169
168
  .action(async (opts) => {
@@ -173,7 +172,7 @@ export function registerSpaceCommand(program) {
173
172
  };
174
173
  if (opts.cursor)
175
174
  params.cursor = opts.cursor;
176
- const resp = (await apiGet(`/spaces/${spaceSlug}/messages`, params));
175
+ const resp = (await apiGet(`/spaces/${spaceSlug}/feed`, params));
177
176
  if (isJsonMode(space)) {
178
177
  jsonOut({
179
178
  items: resp.data || [],
@@ -184,74 +183,69 @@ export function registerSpaceCommand(program) {
184
183
  const items = (resp.data || []);
185
184
  const pagination = (resp.pagination || {});
186
185
  if (!items.length) {
187
- console.log("No messages found.");
186
+ console.log("No items found.");
188
187
  return;
189
188
  }
190
- const lines = items.map(formatMessageLine);
189
+ const lines = items.map(formatFeedLine);
191
190
  const footer = pagination.hasMore ? `\n Next cursor: ${pagination.nextCursor}` : "";
192
- console.log(`Messages (${items.length} items, newest first):\n` + lines.join("\n") + footer);
191
+ console.log(`Feed (${items.length} items, newest first):\n` + lines.join("\n") + footer);
193
192
  });
194
- // ── Ancestors ──
193
+ // ── Posts (get, list, create, edit, delete) ──
195
194
  space
196
- .command("ancestors <threadId>")
197
- .description("Show the ancestor lineage of a thread or reply (root → immediate parent).")
198
- .action(async (threadId) => {
199
- const spaceSlug = resolveSpaceSlug(space);
200
- const resp = (await apiGet(`/spaces/${spaceSlug}/threads/${threadId}/ancestors`));
201
- const data = unwrapResp(resp);
202
- const ancestors = (data.ancestors || []);
203
- if (isJsonMode(space)) {
204
- jsonOut({ ancestors });
205
- return;
206
- }
207
- if (!ancestors.length) {
208
- console.log("No ancestors (this is a root thread).");
209
- return;
210
- }
211
- const lines = [];
212
- ancestors.forEach((a, i) => {
213
- lines.push(`${i + 1}. ${formatMessageLine(a)}`);
214
- });
215
- console.log(`Ancestors (${ancestors.length} items, root first):\n` + lines.join("\n"));
216
- });
217
- // ── Threads (get, list, create, edit, delete) ──
218
- space
219
- .command("get-thread <threadId>")
220
- .description("Get a thread and its replies (paginated).")
195
+ .command("get-post <postId>")
196
+ .description("Get a post with its ancestors and replies (paginated).")
221
197
  .option("--limit <number>", "Replies per page", "20")
222
198
  .option("--cursor <string>", "Pagination cursor from previous response")
223
- .action(async (threadId, opts) => {
199
+ .action(async (postId, opts) => {
224
200
  const spaceSlug = resolveSpaceSlug(space);
225
201
  const params = {
226
202
  limit: parseInt(opts.limit, 10),
227
203
  };
228
204
  if (opts.cursor)
229
205
  params.cursor = opts.cursor;
230
- const resp = (await apiGet(`/spaces/${spaceSlug}/threads/${threadId}`, params));
231
- const data = unwrapResp(resp);
232
- const pagination = (resp.pagination || {});
233
- const mentions = (resp.mentions || {});
206
+ const [postResp, ancestorsResp] = await Promise.all([
207
+ apiGet(`/spaces/${spaceSlug}/posts/${postId}`, params),
208
+ apiGet(`/spaces/${spaceSlug}/posts/${postId}/ancestors`),
209
+ ]);
210
+ const data = unwrapResp(postResp);
211
+ const pagination = (postResp.pagination || {});
212
+ const mentions = (postResp.mentions || {});
213
+ const ancestorsData = unwrapResp(ancestorsResp);
214
+ const ancestors = (ancestorsData.ancestors || []);
234
215
  if (isJsonMode(space)) {
235
- jsonOut({ ...data, pagination, mentions });
216
+ jsonOut({ ...data, ancestors, pagination, mentions });
236
217
  return;
237
218
  }
238
- const thread = (data.thread || data);
219
+ const post = (data.thread || data);
239
220
  const replies = (data.items || []);
240
- const author = thread.author?.name ||
241
- `User ${thread.authorId}`;
221
+ const author = post.author?.name ||
222
+ `User ${post.authorId}`;
223
+ const ancestorLines = [];
224
+ if (ancestors.length) {
225
+ ancestors.forEach((a, i) => {
226
+ ancestorLines.push(` ${i + 1}. ${formatFeedLine(a)}`);
227
+ });
228
+ }
242
229
  const replyLines = [];
243
230
  for (const r of replies) {
244
231
  const rAuthor = r.author?.name ||
245
232
  `User ${r.authorId}`;
246
233
  const text = r.content;
247
- const truncated = text.length > 200 ? text.slice(0, 200) + "\u2026" : text;
234
+ const truncated = text && text.length > 200 ? text.slice(0, 200) + "" : text;
248
235
  replyLines.push(` - ${rAuthor}: ${truncated} (${r.createdAt})`);
249
236
  }
237
+ const isReplyPost = post.parentPostId != null;
238
+ const heading = isReplyPost
239
+ ? `Reply [r:${post.id}]`
240
+ : `Post: ${post.title || "(no title)"}`;
250
241
  const output = [
251
- `Thread: ${thread.title}`,
252
- `By: ${author} on ${thread.createdAt}`,
242
+ heading,
243
+ `By: ${author} on ${post.createdAt}`,
244
+ ...(ancestorLines.length
245
+ ? ["", `Ancestors (${ancestors.length} items, root first):`, ...ancestorLines]
246
+ : []),
253
247
  "",
254
- thread.content,
248
+ post.content || "",
255
249
  "",
256
250
  `Replies (${replies.length} items):`,
257
251
  ...replyLines,
@@ -260,8 +254,8 @@ export function registerSpaceCommand(program) {
260
254
  console.log(output);
261
255
  });
262
256
  space
263
- .command("list-threads")
264
- .description("List threads in a space (paginated).")
257
+ .command("list-posts")
258
+ .description("List posts in a space (paginated).")
265
259
  .option("--limit <number>", "Items per page", "20")
266
260
  .option("--cursor <string>", "Pagination cursor from previous response")
267
261
  .action(async (opts) => {
@@ -271,7 +265,7 @@ export function registerSpaceCommand(program) {
271
265
  };
272
266
  if (opts.cursor)
273
267
  params.cursor = opts.cursor;
274
- const resp = (await apiGet(`/spaces/${spaceSlug}/threads`, params));
268
+ const resp = (await apiGet(`/spaces/${spaceSlug}/posts`, params));
275
269
  if (isJsonMode(space)) {
276
270
  jsonOut({
277
271
  items: resp.data || [],
@@ -283,7 +277,7 @@ export function registerSpaceCommand(program) {
283
277
  const items = (resp.data || []);
284
278
  const pagination = (resp.pagination || {});
285
279
  if (!items.length) {
286
- console.log("No threads found.");
280
+ console.log("No posts found.");
287
281
  return;
288
282
  }
289
283
  const lines = [];
@@ -293,94 +287,110 @@ export function registerSpaceCommand(program) {
293
287
  lines.push(`- [${t.id}] "${t.title}" by ${author} (${t.replyCount} replies, ${t.createdAt})`);
294
288
  }
295
289
  const footer = pagination.hasMore ? `\n Next cursor: ${pagination.nextCursor}` : "";
296
- console.log(`Threads (${items.length} items):\n` + lines.join("\n") + footer);
290
+ console.log(`Posts (${items.length} items):\n` + lines.join("\n") + footer);
297
291
  });
298
292
  space
299
- .command("create-thread")
300
- .description("Create a thread in a space.")
301
- .requiredOption("--title <title>", "Title of the thread")
302
- .requiredOption("--content <content>", "Thread content (markdown supported)")
303
- .option("--auto-attachments", "Upload wiki-linked [[files]] to webdrive before posting")
304
- .option("--vault-slug <vaultSlug>", "Vault slug for attachment uploads (overrides .gobi/settings.yaml)")
293
+ .command("create-post")
294
+ .description("Create a post in a space.")
295
+ .requiredOption("--title <title>", "Title of the post")
296
+ .requiredOption("--content <content>", "Post content (markdown supported)")
297
+ .option("--auto-attachments", "Upload wiki-linked [[files]] to webdrive before posting (also attributes the post to that vault)")
298
+ .option("--vault-slug <vaultSlug>", "Attribute the post to this vault (sets authorVaultId). Also used as upload destination for --auto-attachments.")
305
299
  .action(async (opts) => {
306
300
  const content = readContent(opts.content);
301
+ let authorVaultSlug;
302
+ if (opts.vaultSlug || opts.autoAttachments) {
303
+ authorVaultSlug = resolveVaultSlug(opts);
304
+ }
307
305
  if (opts.autoAttachments) {
308
- const vaultSlug = resolveVaultSlug(opts);
309
306
  const token = await getValidToken();
310
307
  const links = extractWikiLinks(content);
311
- await uploadAttachments(vaultSlug, links, token, { addToSyncfiles: true });
308
+ await uploadAttachments(authorVaultSlug, links, token, { addToSyncfiles: true });
312
309
  }
313
310
  const spaceSlug = resolveSpaceSlug(space);
314
- const resp = (await apiPost(`/spaces/${spaceSlug}/threads`, {
311
+ const body = {
315
312
  title: opts.title,
316
313
  content,
317
- }));
318
- const thread = unwrapResp(resp);
314
+ };
315
+ if (authorVaultSlug)
316
+ body.authorVaultSlug = authorVaultSlug;
317
+ const resp = (await apiPost(`/spaces/${spaceSlug}/posts`, body));
318
+ const post = unwrapResp(resp);
319
319
  if (isJsonMode(space)) {
320
- jsonOut(thread);
320
+ jsonOut(post);
321
321
  return;
322
322
  }
323
- console.log(`Thread created!\n` +
324
- ` ID: ${thread.id}\n` +
325
- ` Title: ${thread.title}\n` +
326
- ` Created: ${thread.createdAt}`);
323
+ console.log(`Post created!\n` +
324
+ ` ID: ${post.id}\n` +
325
+ ` Title: ${post.title}\n` +
326
+ ` Created: ${post.createdAt}`);
327
327
  });
328
328
  space
329
- .command("edit-thread <threadId>")
330
- .description("Edit a thread. You must be the author.")
331
- .option("--title <title>", "New title for the thread")
332
- .option("--content <content>", "New content for the thread (markdown supported)")
333
- .option("--auto-attachments", "Upload wiki-linked [[files]] to webdrive before editing")
334
- .option("--vault-slug <vaultSlug>", "Vault slug for attachment uploads (overrides .gobi/settings.yaml)")
335
- .action(async (threadId, opts) => {
336
- if (!opts.title && !opts.content) {
337
- throw new Error("Provide at least --title or --content to update.");
329
+ .command("edit-post <postId>")
330
+ .description("Edit a post. You must be the author.")
331
+ .option("--title <title>", "New title for the post")
332
+ .option("--content <content>", "New content for the post (markdown supported)")
333
+ .option("--auto-attachments", "Upload wiki-linked [[files]] to webdrive before editing (also attributes the post to that vault)")
334
+ .option("--vault-slug <vaultSlug>", "Attribute the post to this vault (sets authorVaultId). Also used as upload destination for --auto-attachments. Pass an empty string to detach.")
335
+ .action(async (postId, opts) => {
336
+ const wantsVaultChange = opts.vaultSlug !== undefined || opts.autoAttachments;
337
+ if (!opts.title && !opts.content && !wantsVaultChange) {
338
+ throw new Error("Provide at least --title, --content, or --vault-slug to update.");
338
339
  }
339
340
  const spaceSlug = resolveSpaceSlug(space);
341
+ let authorVaultSlug;
342
+ if (opts.vaultSlug !== undefined) {
343
+ // Empty string detaches; non-empty resolves through settings fallback.
344
+ authorVaultSlug = opts.vaultSlug === "" ? "" : resolveVaultSlug(opts);
345
+ }
346
+ else if (opts.autoAttachments) {
347
+ authorVaultSlug = resolveVaultSlug(opts);
348
+ }
340
349
  const body = {};
341
350
  if (opts.title != null)
342
351
  body.title = opts.title;
343
352
  if (opts.content != null) {
344
353
  const content = readContent(opts.content);
345
354
  if (opts.autoAttachments) {
346
- const vaultSlug = resolveVaultSlug(opts);
347
355
  const token = await getValidToken();
348
356
  const links = extractWikiLinks(content);
349
- await uploadAttachments(vaultSlug, links, token, { addToSyncfiles: true });
357
+ await uploadAttachments(authorVaultSlug, links, token, { addToSyncfiles: true });
350
358
  }
351
359
  body.content = content;
352
360
  }
353
- const resp = (await apiPatch(`/spaces/${spaceSlug}/threads/${threadId}`, body));
354
- const thread = unwrapResp(resp);
361
+ if (authorVaultSlug !== undefined)
362
+ body.authorVaultSlug = authorVaultSlug;
363
+ const resp = (await apiPatch(`/spaces/${spaceSlug}/posts/${postId}`, body));
364
+ const post = unwrapResp(resp);
355
365
  if (isJsonMode(space)) {
356
- jsonOut(thread);
366
+ jsonOut(post);
357
367
  return;
358
368
  }
359
- console.log(`Thread edited!\n` +
360
- ` ID: ${thread.id}\n` +
361
- ` Title: ${thread.title}\n` +
362
- ` Edited: ${thread.editedAt}`);
369
+ console.log(`Post edited!\n` +
370
+ ` ID: ${post.id}\n` +
371
+ ` Title: ${post.title}\n` +
372
+ ` Edited: ${post.editedAt}`);
363
373
  });
364
374
  space
365
- .command("delete-thread <threadId>")
366
- .description("Delete a thread. You must be the author.")
367
- .action(async (threadId) => {
375
+ .command("delete-post <postId>")
376
+ .description("Delete a post. You must be the author.")
377
+ .action(async (postId) => {
368
378
  const spaceSlug = resolveSpaceSlug(space);
369
- await apiDelete(`/spaces/${spaceSlug}/threads/${threadId}`);
379
+ await apiDelete(`/spaces/${spaceSlug}/posts/${postId}`);
370
380
  if (isJsonMode(space)) {
371
- jsonOut({ id: threadId });
381
+ jsonOut({ id: postId });
372
382
  return;
373
383
  }
374
- console.log(`Thread ${threadId} deleted.`);
384
+ console.log(`Post ${postId} deleted.`);
375
385
  });
376
386
  // ── Replies (create, edit, delete) ──
377
387
  space
378
- .command("create-reply <threadId>")
379
- .description("Create a reply to a thread in a space.")
388
+ .command("create-reply <postId>")
389
+ .description("Create a reply to a post in a space.")
380
390
  .requiredOption("--content <content>", "Reply content (markdown supported)")
381
- .action(async (threadId, opts) => {
391
+ .action(async (postId, opts) => {
382
392
  const spaceSlug = resolveSpaceSlug(space);
383
- const resp = (await apiPost(`/spaces/${spaceSlug}/threads/${threadId}/replies`, { content: readContent(opts.content) }));
393
+ const resp = (await apiPost(`/spaces/${spaceSlug}/posts/${postId}/replies`, { content: readContent(opts.content) }));
384
394
  const msg = unwrapResp(resp);
385
395
  const mentions = (resp.mentions || {});
386
396
  if (isJsonMode(space)) {
@@ -1,7 +1,7 @@
1
1
  import { createHash } from "crypto";
2
2
  import { existsSync, readFileSync, rmSync, mkdirSync, readdirSync, statSync } from "fs";
3
3
  import { writeFile } from "fs/promises";
4
- import { join, dirname, extname, resolve as pathResolve } from "path";
4
+ import { join, dirname, extname } from "path";
5
5
  import Database from "better-sqlite3";
6
6
  import inquirer from "inquirer";
7
7
  import ignore from "ignore";
@@ -9,8 +9,7 @@ import trash from "trash";
9
9
  import { WEBDRIVE_BASE_URL } from "../constants.js";
10
10
  import { getValidToken } from "../auth/manager.js";
11
11
  import { GobiError } from "../errors.js";
12
- import { getVaultSlug } from "./init.js";
13
- import { isJsonMode, jsonOut } from "./utils.js";
12
+ import { jsonOut } from "./utils.js";
14
13
  // ─── Constants ────────────────────────────────────────────────────────────────
15
14
  const SYNC_IGNORE_NAMES = new Set([
16
15
  ".gobi",
@@ -880,56 +879,3 @@ async function executeSyncPlan(opts, baseUrl, token, gobiDir) {
880
879
  }
881
880
  return result;
882
881
  }
883
- // ─── Commander Registration ───────────────────────────────────────────────────
884
- export function registerSyncCommand(program) {
885
- program
886
- .command("sync")
887
- .description("Sync local vault files with Gobi Webdrive.")
888
- .option("--upload-only", "Only upload local changes to server")
889
- .option("--download-only", "Only download server changes to local")
890
- .option("--conflict <strategy>", "Conflict resolution strategy: ask|server|client|skip", "ask")
891
- .option("--dir <path>", "Local vault directory (default: current directory)")
892
- .option("--dry-run", "Preview changes without making them")
893
- .option("--full", "Full sync: ignore cursor and hash cache, re-check every file")
894
- .option("--path <path>", "Restrict sync to a specific file or folder (repeatable)", (v, prev) => prev.concat(v), [])
895
- .option("--plan-file <path>", "Write dry-run plan to file (use with --dry-run) or read plan to execute (use with --execute)")
896
- .option("--execute", "Execute a previously written plan file (requires --plan-file)")
897
- .option("--conflict-choices <json>", "Per-file conflict resolutions as JSON object, e.g. '{\"file.md\":\"server\"}' (use with --execute)")
898
- .action(async function (opts) {
899
- if (opts.uploadOnly && opts.downloadOnly) {
900
- throw new GobiError("--upload-only and --download-only are mutually exclusive.", "INVALID_OPTION");
901
- }
902
- if (opts.execute && !opts.planFile) {
903
- throw new GobiError("--execute requires --plan-file", "INVALID_OPTION");
904
- }
905
- const validStrategies = ["ask", "server", "client", "skip"];
906
- if (!validStrategies.includes(opts.conflict)) {
907
- throw new GobiError(`Invalid --conflict value "${opts.conflict}". Use: ask|server|client|skip`, "INVALID_OPTION");
908
- }
909
- let conflictChoices;
910
- if (opts.conflictChoices) {
911
- try {
912
- conflictChoices = JSON.parse(opts.conflictChoices);
913
- }
914
- catch {
915
- throw new GobiError("--conflict-choices must be valid JSON", "INVALID_OPTION");
916
- }
917
- }
918
- const vaultSlug = getVaultSlug();
919
- const dir = opts.dir ? pathResolve(opts.dir) : process.cwd();
920
- await runSync({
921
- vaultSlug,
922
- dir,
923
- uploadOnly: !!opts.uploadOnly,
924
- downloadOnly: !!opts.downloadOnly,
925
- conflict: opts.conflict,
926
- dryRun: !!opts.dryRun,
927
- full: !!opts.full,
928
- paths: opts.path ?? [],
929
- planFile: opts.planFile,
930
- execute: !!opts.execute,
931
- conflictChoices,
932
- jsonMode: isJsonMode(this),
933
- });
934
- });
935
- }
@@ -10,20 +10,26 @@ async function fetchLatestVersion() {
10
10
  const data = (await res.json());
11
11
  return data.version;
12
12
  }
13
- function detectInstallMethod() {
13
+ // `which` is Unix-only; Windows uses `where`. `where` may print multiple
14
+ // matches on separate lines — take the first one.
15
+ function locateGobi() {
16
+ const cmd = process.platform === "win32" ? "where gobi" : "which gobi";
14
17
  try {
15
- const gobiBin = execSync("which gobi", { encoding: "utf-8" }).trim();
16
- if (gobiBin.includes("/Cellar/") || gobiBin.includes("/homebrew/")) {
17
- return "brew";
18
- }
18
+ const out = execSync(cmd, { encoding: "utf-8" }).trim();
19
+ return out.split(/\r?\n/)[0] || null;
19
20
  }
20
21
  catch {
21
- // ignore
22
+ return null;
23
+ }
24
+ }
25
+ function detectInstallMethod() {
26
+ const gobiBin = locateGobi();
27
+ if (gobiBin && (gobiBin.includes("/Cellar/") || gobiBin.includes("/homebrew/"))) {
28
+ return "brew";
22
29
  }
23
30
  try {
24
31
  const npmGlobalDir = execSync("npm root -g", { encoding: "utf-8" }).trim();
25
- const gobiBin = execSync("which gobi", { encoding: "utf-8" }).trim();
26
- if (gobiBin.includes(npmGlobalDir.replace("/lib/node_modules", ""))) {
32
+ if (gobiBin && gobiBin.includes(npmGlobalDir.replace("/lib/node_modules", ""))) {
27
33
  return "npm";
28
34
  }
29
35
  }
@@ -1,4 +1,10 @@
1
+ import { readFileSync } from "fs";
1
2
  import { getSpaceSlug, getVaultSlug } from "./init.js";
3
+ // Reads all of stdin synchronously. Uses fd 0 (cross-platform) instead of
4
+ // "/dev/stdin", which doesn't exist on Windows.
5
+ export function readStdin() {
6
+ return readFileSync(0, "utf8");
7
+ }
2
8
  export function isJsonMode(cmd) {
3
9
  return !!cmd.parent?.opts().json;
4
10
  }
@@ -0,0 +1,113 @@
1
+ import { existsSync, readFileSync } from "fs";
2
+ import { join, resolve as pathResolve } from "path";
3
+ import { WEBDRIVE_BASE_URL } from "../constants.js";
4
+ import { getValidToken } from "../auth/manager.js";
5
+ import { GobiError } from "../errors.js";
6
+ import { getVaultSlug } from "./init.js";
7
+ import { isJsonMode, jsonOut } from "./utils.js";
8
+ import { runSync } from "./sync.js";
9
+ export const PUBLISH_FILENAME = "PUBLISH.md";
10
+ export function registerVaultCommand(program) {
11
+ const vault = program
12
+ .command("vault")
13
+ .description("Vault commands (publish/unpublish profile, sync files).");
14
+ vault
15
+ .command("publish")
16
+ .description(`Upload ${PUBLISH_FILENAME} to the vault root on webdrive. Triggers post-processing (vault sync, metadata update, Discord notification).`)
17
+ .action(async () => {
18
+ const vaultId = getVaultSlug();
19
+ const filePath = join(process.cwd(), PUBLISH_FILENAME);
20
+ if (!existsSync(filePath)) {
21
+ throw new Error(`${PUBLISH_FILENAME} not found in ${process.cwd()}`);
22
+ }
23
+ const content = readFileSync(filePath, "utf-8");
24
+ const token = await getValidToken();
25
+ const url = `${WEBDRIVE_BASE_URL}/api/v1/vaults/${vaultId}/file/${PUBLISH_FILENAME}`;
26
+ const res = await fetch(url, {
27
+ method: "PUT",
28
+ headers: {
29
+ Authorization: `Bearer ${token}`,
30
+ "Content-Type": "text/markdown",
31
+ },
32
+ body: content,
33
+ });
34
+ if (!res.ok) {
35
+ throw new Error(`Upload failed: HTTP ${res.status}: ${(await res.text()) || "(no body)"}`);
36
+ }
37
+ if (isJsonMode(vault)) {
38
+ jsonOut({ vaultId });
39
+ return;
40
+ }
41
+ console.log(`Published ${PUBLISH_FILENAME} to vault "${vaultId}"`);
42
+ });
43
+ vault
44
+ .command("unpublish")
45
+ .description(`Delete ${PUBLISH_FILENAME} from the vault on webdrive.`)
46
+ .action(async () => {
47
+ const vaultId = getVaultSlug();
48
+ const token = await getValidToken();
49
+ const url = `${WEBDRIVE_BASE_URL}/api/v1/vaults/${vaultId}/file/${PUBLISH_FILENAME}`;
50
+ const res = await fetch(url, {
51
+ method: "DELETE",
52
+ headers: { Authorization: `Bearer ${token}` },
53
+ });
54
+ if (!res.ok) {
55
+ throw new Error(`Delete failed: HTTP ${res.status}: ${(await res.text()) || "(no body)"}`);
56
+ }
57
+ if (isJsonMode(vault)) {
58
+ jsonOut({ vaultId });
59
+ return;
60
+ }
61
+ console.log(`Deleted ${PUBLISH_FILENAME} from vault "${vaultId}"`);
62
+ });
63
+ vault
64
+ .command("sync")
65
+ .description("Sync local vault files with Gobi Webdrive.")
66
+ .option("--upload-only", "Only upload local changes to server")
67
+ .option("--download-only", "Only download server changes to local")
68
+ .option("--conflict <strategy>", "Conflict resolution strategy: ask|server|client|skip", "ask")
69
+ .option("--dir <path>", "Local vault directory (default: current directory)")
70
+ .option("--dry-run", "Preview changes without making them")
71
+ .option("--full", "Full sync: ignore cursor and hash cache, re-check every file")
72
+ .option("--path <path>", "Restrict sync to a specific file or folder (repeatable)", (v, prev) => prev.concat(v), [])
73
+ .option("--plan-file <path>", "Write dry-run plan to file (use with --dry-run) or read plan to execute (use with --execute)")
74
+ .option("--execute", "Execute a previously written plan file (requires --plan-file)")
75
+ .option("--conflict-choices <json>", "Per-file conflict resolutions as JSON object, e.g. '{\"file.md\":\"server\"}' (use with --execute)")
76
+ .action(async function (opts) {
77
+ if (opts.uploadOnly && opts.downloadOnly) {
78
+ throw new GobiError("--upload-only and --download-only are mutually exclusive.", "INVALID_OPTION");
79
+ }
80
+ if (opts.execute && !opts.planFile) {
81
+ throw new GobiError("--execute requires --plan-file", "INVALID_OPTION");
82
+ }
83
+ const validStrategies = ["ask", "server", "client", "skip"];
84
+ if (!validStrategies.includes(opts.conflict)) {
85
+ throw new GobiError(`Invalid --conflict value "${opts.conflict}". Use: ask|server|client|skip`, "INVALID_OPTION");
86
+ }
87
+ let conflictChoices;
88
+ if (opts.conflictChoices) {
89
+ try {
90
+ conflictChoices = JSON.parse(opts.conflictChoices);
91
+ }
92
+ catch {
93
+ throw new GobiError("--conflict-choices must be valid JSON", "INVALID_OPTION");
94
+ }
95
+ }
96
+ const vaultSlug = getVaultSlug();
97
+ const dir = opts.dir ? pathResolve(opts.dir) : process.cwd();
98
+ await runSync({
99
+ vaultSlug,
100
+ dir,
101
+ uploadOnly: !!opts.uploadOnly,
102
+ downloadOnly: !!opts.downloadOnly,
103
+ conflict: opts.conflict,
104
+ dryRun: !!opts.dryRun,
105
+ full: !!opts.full,
106
+ paths: opts.path ?? [],
107
+ planFile: opts.planFile,
108
+ execute: !!opts.execute,
109
+ conflictChoices,
110
+ jsonMode: isJsonMode(this),
111
+ });
112
+ });
113
+ }