@gobi-ai/cli 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Gobi AI
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,152 @@
1
+ # gobi-cli
2
+
3
+ [![CI](https://github.com/gobi-ai/gobi-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/gobi-ai/gobi-cli/actions/workflows/ci.yml)
4
+ [![npm](https://img.shields.io/npm/v/@gobi-ai/cli)](https://www.npmjs.com/package/@gobi-ai/cli)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
6
+
7
+ Command-line interface for the [Gobi](https://joingobi.com) collaborative knowledge platform.
8
+
9
+ ## Installation
10
+
11
+ ### Homebrew
12
+
13
+ ```sh
14
+ brew tap gobi-ai/tap
15
+ brew install gobi
16
+ ```
17
+
18
+ ### npm
19
+
20
+ ```sh
21
+ npm install -g @gobi-ai/cli
22
+ ```
23
+
24
+ ### From source
25
+
26
+ ```sh
27
+ git clone https://github.com/gobi-ai/gobi-cli.git
28
+ cd gobi-cli
29
+ npm install
30
+ npm run build
31
+ npm link
32
+ ```
33
+
34
+ ## Quick start
35
+
36
+ ```sh
37
+ # Authenticate with your Gobi account
38
+ gobi auth login
39
+
40
+ # Set up your space and vault
41
+ gobi init
42
+
43
+ # Search brains in your space
44
+ gobi astra search-brain --query "machine learning"
45
+
46
+ # Ask a brain a question
47
+ gobi astra ask-brain --vault-slug my-vault --question "What is RAG?"
48
+ ```
49
+
50
+ ## Commands
51
+
52
+ ### Authentication
53
+
54
+ | Command | Description |
55
+ |---------|-------------|
56
+ | `gobi auth login` | Sign in via device code flow |
57
+ | `gobi auth status` | Show current auth status |
58
+ | `gobi auth logout` | Sign out and clear credentials |
59
+
60
+ ### Setup
61
+
62
+ | Command | Description |
63
+ |---------|-------------|
64
+ | `gobi init` | Interactive setup — select your vault and space |
65
+
66
+ ### Brains
67
+
68
+ | Command | Description |
69
+ |---------|-------------|
70
+ | `gobi astra search-brain --query <q>` | Search brains in a space |
71
+ | `gobi astra ask-brain --vault-slug <slug> --question <q>` | Ask a brain a question (creates a 1:1 session) |
72
+ | `gobi astra publish-brain` | Upload `BRAIN.md` to your vault |
73
+ | `gobi astra unpublish-brain` | Remove `BRAIN.md` from your vault |
74
+
75
+ ### Posts
76
+
77
+ | Command | Description |
78
+ |---------|-------------|
79
+ | `gobi astra list-posts` | List posts in the current space |
80
+ | `gobi astra get-post <id>` | Get a post and its replies |
81
+ | `gobi astra create-post --title <t> --content <c>` | Create a post |
82
+ | `gobi astra edit-post <id> --title <t>` | Edit a post |
83
+ | `gobi astra delete-post <id>` | Delete a post |
84
+
85
+ ### Replies
86
+
87
+ | Command | Description |
88
+ |---------|-------------|
89
+ | `gobi astra list-replies <postId>` | List replies to a post |
90
+ | `gobi astra create-reply <postId> --content <c>` | Reply to a post |
91
+ | `gobi astra edit-reply <replyId> --content <c>` | Edit a reply |
92
+ | `gobi astra delete-reply <replyId>` | Delete a reply |
93
+
94
+ ### Sessions
95
+
96
+ | Command | Description |
97
+ |---------|-------------|
98
+ | `gobi astra list-sessions` | List your sessions |
99
+ | `gobi astra get-session <id>` | Get a session and its messages |
100
+ | `gobi astra reply-session <id> --content <c>` | Send a message in a session |
101
+
102
+ ### Brain updates
103
+
104
+ | Command | Description |
105
+ |---------|-------------|
106
+ | `gobi astra list-brain-updates` | List brain updates in the space |
107
+ | `gobi astra create-brain-update --title <t> --content <c>` | Create a brain update |
108
+ | `gobi astra edit-brain-update <id> --title <t>` | Edit a brain update |
109
+ | `gobi astra delete-brain-update <id>` | Delete a brain update |
110
+
111
+ ### Global options
112
+
113
+ | Option | Description |
114
+ |--------|-------------|
115
+ | `--json` | Output results as JSON |
116
+ | `--space-slug <slug>` | Override the default space (astra commands) |
117
+
118
+ ## Configuration
119
+
120
+ ### Environment variables
121
+
122
+ | Variable | Default | Description |
123
+ |----------|---------|-------------|
124
+ | `GOBI_BASE_URL` | `https://backend.joingobi.com` | API server URL |
125
+ | `GOBI_WEBDRIVE_BASE_URL` | `https://webdrive.joingobi.com` | File storage URL |
126
+
127
+ ### Files
128
+
129
+ | Path | Description |
130
+ |------|-------------|
131
+ | `~/.gobi/credentials.json` | Stored authentication tokens |
132
+ | `.gobi/settings.yaml` | Per-project vault and space configuration |
133
+
134
+ ## Development
135
+
136
+ ```sh
137
+ git clone https://github.com/gobi-ai/gobi-cli.git
138
+ cd gobi-cli
139
+ npm install
140
+ npm run build
141
+ npm test
142
+ ```
143
+
144
+ Run from source without compiling:
145
+
146
+ ```sh
147
+ npm run dev -- auth status
148
+ ```
149
+
150
+ ## License
151
+
152
+ [MIT](LICENSE)
@@ -0,0 +1,28 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, unlinkSync } from "fs";
2
+ import { join } from "path";
3
+ import { homedir } from "os";
4
+ const CREDENTIALS_DIR = join(homedir(), ".gobi");
5
+ const CREDENTIALS_PATH = join(CREDENTIALS_DIR, "credentials.json");
6
+ export async function loadCredentials() {
7
+ try {
8
+ const raw = readFileSync(CREDENTIALS_PATH, "utf-8");
9
+ return JSON.parse(raw);
10
+ }
11
+ catch {
12
+ return null;
13
+ }
14
+ }
15
+ export async function saveCredentials(creds) {
16
+ mkdirSync(CREDENTIALS_DIR, { recursive: true, mode: 0o700 });
17
+ writeFileSync(CREDENTIALS_PATH, JSON.stringify(creds, null, 2), {
18
+ mode: 0o600,
19
+ });
20
+ }
21
+ export async function clearCredentials() {
22
+ try {
23
+ unlinkSync(CREDENTIALS_PATH);
24
+ }
25
+ catch {
26
+ // ignore if file doesn't exist
27
+ }
28
+ }
@@ -0,0 +1,55 @@
1
+ import { BASE_URL, TOKEN_REFRESH_BUFFER_MS } from "../constants.js";
2
+ import { NotAuthenticatedError, TokenRefreshError } from "../errors.js";
3
+ import { loadCredentials, saveCredentials, clearCredentials, } from "./credentials.js";
4
+ let cachedCredentials = null;
5
+ export async function initCredentials() {
6
+ cachedCredentials = await loadCredentials();
7
+ }
8
+ export function isAuthenticated() {
9
+ return cachedCredentials !== null;
10
+ }
11
+ export function getCurrentUser() {
12
+ if (cachedCredentials === null)
13
+ return null;
14
+ return cachedCredentials.user;
15
+ }
16
+ function isExpiringSoon(creds) {
17
+ return Date.now() >= creds.expiresAt - TOKEN_REFRESH_BUFFER_MS;
18
+ }
19
+ async function performRefresh(creds) {
20
+ const res = await fetch(`${BASE_URL}/auth/refresh`, {
21
+ method: "POST",
22
+ headers: { "Content-Type": "application/json" },
23
+ body: JSON.stringify({ refreshToken: creds.refreshToken }),
24
+ });
25
+ if (!res.ok) {
26
+ const body = (await res.text()) || "(no body)";
27
+ throw new TokenRefreshError(`HTTP ${res.status}: ${body}`);
28
+ }
29
+ const data = (await res.json());
30
+ const updated = {
31
+ ...creds,
32
+ accessToken: data.accessToken,
33
+ refreshToken: data.refreshToken,
34
+ expiresAt: Date.now() + data.expiresIn * 1000,
35
+ };
36
+ await saveCredentials(updated);
37
+ return updated;
38
+ }
39
+ export async function getValidToken() {
40
+ if (cachedCredentials === null) {
41
+ throw new NotAuthenticatedError();
42
+ }
43
+ if (isExpiringSoon(cachedCredentials)) {
44
+ cachedCredentials = await performRefresh(cachedCredentials);
45
+ }
46
+ return cachedCredentials.accessToken;
47
+ }
48
+ export async function storeTokens(creds) {
49
+ await saveCredentials(creds);
50
+ cachedCredentials = creds;
51
+ }
52
+ export async function logout() {
53
+ await clearCredentials();
54
+ cachedCredentials = null;
55
+ }
package/dist/client.js ADDED
@@ -0,0 +1,43 @@
1
+ import { BASE_URL } from "./constants.js";
2
+ import { ApiError } from "./errors.js";
3
+ import { getValidToken } from "./auth/manager.js";
4
+ async function request(method, path, options) {
5
+ const token = await getValidToken();
6
+ let url = `${BASE_URL}${path}`;
7
+ // Filter out null/undefined values from params
8
+ if (options?.params) {
9
+ const filtered = Object.entries(options.params)
10
+ .filter(([, v]) => v != null)
11
+ .map(([k, v]) => [k, String(v)]);
12
+ if (filtered.length > 0) {
13
+ url += "?" + new URLSearchParams(filtered).toString();
14
+ }
15
+ }
16
+ const headers = {
17
+ Authorization: `Bearer ${token}`,
18
+ };
19
+ const body = options?.body != null ? JSON.stringify(options.body) : undefined;
20
+ if (body !== undefined) {
21
+ headers["Content-Type"] = "application/json";
22
+ }
23
+ const res = await fetch(url, { method, headers, body });
24
+ if (!res.ok) {
25
+ const text = (await res.text()) || "(no body)";
26
+ throw new ApiError(res.status, path, text);
27
+ }
28
+ if (res.status === 204)
29
+ return null;
30
+ return res.json();
31
+ }
32
+ export function apiGet(path, params) {
33
+ return request("GET", path, { params });
34
+ }
35
+ export function apiPost(path, body) {
36
+ return request("POST", path, { body });
37
+ }
38
+ export function apiPatch(path, body) {
39
+ return request("PATCH", path, { body });
40
+ }
41
+ export function apiDelete(path) {
42
+ return request("DELETE", path);
43
+ }
@@ -0,0 +1,543 @@
1
+ import { existsSync, readFileSync } from "fs";
2
+ import { join } from "path";
3
+ import { apiGet, apiPost, apiPatch, apiDelete } from "../client.js";
4
+ import { WEBDRIVE_BASE_URL } from "../constants.js";
5
+ import { getValidToken } from "../auth/manager.js";
6
+ import { getSpaceSlug, getVaultSlug } from "./init.js";
7
+ function isJsonMode(cmd) {
8
+ return !!cmd.parent?.opts().json;
9
+ }
10
+ function jsonOut(data) {
11
+ console.log(JSON.stringify({ success: true, data }));
12
+ }
13
+ function resolveSpaceSlug(cmd) {
14
+ return cmd.opts().spaceSlug || getSpaceSlug();
15
+ }
16
+ function resolveVaultSlug(opts) {
17
+ return opts.vaultSlug || getVaultSlug();
18
+ }
19
+ function unwrapResp(resp) {
20
+ if (typeof resp === "object" && resp !== null && "data" in resp) {
21
+ return resp.data;
22
+ }
23
+ return resp;
24
+ }
25
+ export function registerAstraCommand(program) {
26
+ const astra = program
27
+ .command("astra")
28
+ .description("Astra commands (posts, sessions, brains, brain updates).")
29
+ .option("--space-slug <slug>", "Space slug (overrides .gobi/settings.yaml)");
30
+ // ── Brains ──
31
+ astra
32
+ .command("search-brain")
33
+ .description("Search brains (second brains/vaults) in a space using text and semantic search.")
34
+ .requiredOption("--query <query>", "Search query")
35
+ .action(async (opts) => {
36
+ const spaceSlug = resolveSpaceSlug(astra);
37
+ const resp = (await apiGet(`/spaces/${spaceSlug}/brains`, {
38
+ query: opts.query,
39
+ }));
40
+ const results = (Array.isArray(resp) ? resp : resp.data || resp);
41
+ if (isJsonMode(astra)) {
42
+ jsonOut(results || []);
43
+ return;
44
+ }
45
+ if (!results || results.length === 0) {
46
+ console.log(`No brains found matching "${opts.query}".`);
47
+ return;
48
+ }
49
+ const lines = [];
50
+ for (const entry of results) {
51
+ const vault = (entry.vault || entry);
52
+ const owner = (entry.owner || {});
53
+ const ownerName = owner.name ? ` by ${owner.name}` : "";
54
+ const sim = entry.similarity != null
55
+ ? ` [similarity: ${entry.similarity.toFixed(3)}]`
56
+ : "";
57
+ lines.push(`- ${vault.name || vault.title || "N/A"} (ID: ${vault.vaultId || vault.id || "N/A"})${ownerName}${sim}`);
58
+ }
59
+ console.log(`Brains matching "${opts.query}":\n` + lines.join("\n"));
60
+ });
61
+ astra
62
+ .command("ask-brain")
63
+ .description("Ask a brain a question. Creates a targeted session (1:1 conversation).")
64
+ .requiredOption("--vault-slug <vaultSlug>", "Slug of the brain/vault to ask")
65
+ .requiredOption("--question <question>", "The question to ask (markdown supported)")
66
+ .option("--mode <mode>", 'Session mode: "auto" or "manual"', "auto")
67
+ .action(async (opts) => {
68
+ const spaceSlug = resolveSpaceSlug(astra);
69
+ const resp = (await apiPost(`/session/targeted`, {
70
+ vaultSlug: opts.vaultSlug,
71
+ spaceSlug,
72
+ question: opts.question,
73
+ mode: opts.mode,
74
+ }));
75
+ const data = unwrapResp(resp);
76
+ if (isJsonMode(astra)) {
77
+ jsonOut(data);
78
+ return;
79
+ }
80
+ const session = (data.session || {});
81
+ const members = (data.members || []);
82
+ console.log(`Session created!\n` +
83
+ ` Session ID: ${session.id}\n` +
84
+ ` Mode: ${session.mode}\n` +
85
+ ` Members: ${members.length}\n` +
86
+ ` Question sent.`);
87
+ });
88
+ astra
89
+ .command("publish-brain")
90
+ .description("Upload BRAIN.md to the vault root on webdrive. Triggers post-processing (brain sync, metadata update, Discord notification).")
91
+ .action(async () => {
92
+ const vaultId = getVaultSlug();
93
+ const filePath = join(process.cwd(), "BRAIN.md");
94
+ if (!existsSync(filePath)) {
95
+ throw new Error(`BRAIN.md not found in ${process.cwd()}`);
96
+ }
97
+ const content = readFileSync(filePath, "utf-8");
98
+ const token = await getValidToken();
99
+ const url = `${WEBDRIVE_BASE_URL}/api/v1/vaults/${vaultId}/files/BRAIN.md`;
100
+ const res = await fetch(url, {
101
+ method: "PUT",
102
+ headers: {
103
+ Authorization: `Bearer ${token}`,
104
+ "Content-Type": "text/markdown",
105
+ },
106
+ body: content,
107
+ });
108
+ if (!res.ok) {
109
+ throw new Error(`Upload failed: HTTP ${res.status}: ${(await res.text()) || "(no body)"}`);
110
+ }
111
+ if (isJsonMode(astra)) {
112
+ jsonOut({ vaultId });
113
+ return;
114
+ }
115
+ console.log(`Published BRAIN.md to vault "${vaultId}"`);
116
+ });
117
+ astra
118
+ .command("unpublish-brain")
119
+ .description("Delete BRAIN.md from the vault on webdrive.")
120
+ .action(async () => {
121
+ const vaultId = getVaultSlug();
122
+ const token = await getValidToken();
123
+ const url = `${WEBDRIVE_BASE_URL}/api/v1/vaults/${vaultId}/files/BRAIN.md`;
124
+ const res = await fetch(url, {
125
+ method: "DELETE",
126
+ headers: { Authorization: `Bearer ${token}` },
127
+ });
128
+ if (!res.ok) {
129
+ throw new Error(`Delete failed: HTTP ${res.status}: ${(await res.text()) || "(no body)"}`);
130
+ }
131
+ if (isJsonMode(astra)) {
132
+ jsonOut({ vaultId });
133
+ return;
134
+ }
135
+ console.log(`Deleted BRAIN.md from vault "${vaultId}"`);
136
+ });
137
+ // ── Posts (get, list, create, edit, delete) ──
138
+ astra
139
+ .command("get-post <postId>")
140
+ .description("Get a post and its replies (paginated).")
141
+ .option("--limit <number>", "Replies per page", "20")
142
+ .option("--offset <number>", "Offset for reply pagination", "0")
143
+ .action(async (postId, opts) => {
144
+ const spaceSlug = resolveSpaceSlug(astra);
145
+ const resp = (await apiGet(`/spaces/${spaceSlug}/posts/${postId}`, {
146
+ limit: parseInt(opts.limit, 10),
147
+ offset: parseInt(opts.offset, 10),
148
+ }));
149
+ const data = unwrapResp(resp);
150
+ const pagination = (resp.pagination || {});
151
+ if (isJsonMode(astra)) {
152
+ jsonOut({ ...data, pagination });
153
+ return;
154
+ }
155
+ const msg = (data.post || data);
156
+ const replies = (data.replies || []);
157
+ const totalReplies = pagination.total ||
158
+ msg.replyCount ||
159
+ 0;
160
+ const author = msg.author?.name ||
161
+ `User ${msg.authorId}`;
162
+ const replyLines = [];
163
+ for (const r of replies) {
164
+ const rAuthor = r.author?.name ||
165
+ `User ${r.authorId}`;
166
+ const text = r.content;
167
+ const truncated = text.length > 200 ? text.slice(0, 200) + "\u2026" : text;
168
+ replyLines.push(` - ${rAuthor}: ${truncated} (${r.createdAt})`);
169
+ }
170
+ const output = [
171
+ `Post: ${msg.title}`,
172
+ `By: ${author} on ${msg.createdAt}`,
173
+ "",
174
+ msg.content,
175
+ "",
176
+ `Replies (${replies.length} of ${totalReplies}):`,
177
+ ...replyLines,
178
+ ].join("\n");
179
+ console.log(output);
180
+ });
181
+ astra
182
+ .command("list-posts")
183
+ .description("List posts in a space (paginated).")
184
+ .option("--limit <number>", "Items per page", "20")
185
+ .option("--offset <number>", "Offset for pagination", "0")
186
+ .action(async (opts) => {
187
+ const spaceSlug = resolveSpaceSlug(astra);
188
+ const resp = (await apiGet(`/spaces/${spaceSlug}/posts`, {
189
+ limit: parseInt(opts.limit, 10),
190
+ offset: parseInt(opts.offset, 10),
191
+ }));
192
+ if (isJsonMode(astra)) {
193
+ jsonOut({
194
+ items: resp.data || [],
195
+ pagination: resp.pagination || {},
196
+ });
197
+ return;
198
+ }
199
+ const items = (resp.data || []);
200
+ const pagination = (resp.pagination || {});
201
+ if (!items.length) {
202
+ console.log("No posts found.");
203
+ return;
204
+ }
205
+ const lines = [];
206
+ for (const msg of items) {
207
+ const author = msg.author?.name ||
208
+ `User ${msg.authorId}`;
209
+ lines.push(`- [${msg.id}] "${msg.title}" by ${author} (${msg.replyCount} replies, ${msg.createdAt})`);
210
+ }
211
+ const total = pagination.total || items.length;
212
+ console.log(`Posts (${items.length} of ${total}):\n` + lines.join("\n"));
213
+ });
214
+ astra
215
+ .command("create-post")
216
+ .description("Create a post in a space.")
217
+ .requiredOption("--title <title>", "Title of the post")
218
+ .requiredOption("--content <content>", "Post content (markdown supported)")
219
+ .action(async (opts) => {
220
+ const spaceSlug = resolveSpaceSlug(astra);
221
+ const resp = (await apiPost(`/spaces/${spaceSlug}/posts`, {
222
+ title: opts.title,
223
+ content: opts.content,
224
+ }));
225
+ const msg = unwrapResp(resp);
226
+ if (isJsonMode(astra)) {
227
+ jsonOut(msg);
228
+ return;
229
+ }
230
+ console.log(`Post created!\n` +
231
+ ` ID: ${msg.id}\n` +
232
+ ` Title: ${msg.title}\n` +
233
+ ` Created: ${msg.createdAt}`);
234
+ });
235
+ astra
236
+ .command("edit-post <postId>")
237
+ .description("Edit a post. You must be the author.")
238
+ .option("--title <title>", "New title for the post")
239
+ .option("--content <content>", "New content for the post (markdown supported)")
240
+ .action(async (postId, opts) => {
241
+ if (!opts.title && !opts.content) {
242
+ throw new Error("Provide at least --title or --content to update.");
243
+ }
244
+ const spaceSlug = resolveSpaceSlug(astra);
245
+ const body = {};
246
+ if (opts.title != null)
247
+ body.title = opts.title;
248
+ if (opts.content != null)
249
+ body.content = opts.content;
250
+ const resp = (await apiPatch(`/spaces/${spaceSlug}/posts/${postId}`, body));
251
+ const msg = unwrapResp(resp);
252
+ if (isJsonMode(astra)) {
253
+ jsonOut(msg);
254
+ return;
255
+ }
256
+ console.log(`Post edited!\n` +
257
+ ` ID: ${msg.id}\n` +
258
+ ` Title: ${msg.title}\n` +
259
+ ` Edited: ${msg.editedAt}`);
260
+ });
261
+ astra
262
+ .command("delete-post <postId>")
263
+ .description("Delete a post. You must be the author.")
264
+ .action(async (postId) => {
265
+ const spaceSlug = resolveSpaceSlug(astra);
266
+ await apiDelete(`/spaces/${spaceSlug}/posts/${postId}`);
267
+ if (isJsonMode(astra)) {
268
+ jsonOut({ id: postId });
269
+ return;
270
+ }
271
+ console.log(`Post ${postId} deleted.`);
272
+ });
273
+ // ── Replies (list, create, edit, delete) ──
274
+ astra
275
+ .command("list-replies <postId>")
276
+ .description("List replies to a post (paginated).")
277
+ .option("--limit <number>", "Replies per page", "20")
278
+ .option("--offset <number>", "Offset for reply pagination", "0")
279
+ .action(async (postId, opts) => {
280
+ const spaceSlug = resolveSpaceSlug(astra);
281
+ const resp = (await apiGet(`/spaces/${spaceSlug}/posts/${postId}`, {
282
+ limit: parseInt(opts.limit, 10),
283
+ offset: parseInt(opts.offset, 10),
284
+ }));
285
+ const data = unwrapResp(resp);
286
+ const pagination = (resp.pagination || {});
287
+ if (isJsonMode(astra)) {
288
+ jsonOut({
289
+ replies: data.replies || [],
290
+ pagination,
291
+ });
292
+ return;
293
+ }
294
+ const replies = (data.replies || []);
295
+ const totalReplies = pagination.total || replies.length;
296
+ if (!replies.length) {
297
+ console.log("No replies found.");
298
+ return;
299
+ }
300
+ const lines = [];
301
+ for (const r of replies) {
302
+ const author = r.author?.name ||
303
+ `User ${r.authorId}`;
304
+ const text = r.content;
305
+ const truncated = text.length > 200 ? text.slice(0, 200) + "\u2026" : text;
306
+ lines.push(`- [${r.id}] ${author}: ${truncated} (${r.createdAt})`);
307
+ }
308
+ console.log(`Replies (${replies.length} of ${totalReplies}):\n` +
309
+ lines.join("\n"));
310
+ });
311
+ astra
312
+ .command("create-reply <postId>")
313
+ .description("Create a reply to a post in a space.")
314
+ .requiredOption("--content <content>", "Reply content (markdown supported)")
315
+ .action(async (postId, opts) => {
316
+ const spaceSlug = resolveSpaceSlug(astra);
317
+ const resp = (await apiPost(`/spaces/${spaceSlug}/posts/${postId}/replies`, { content: opts.content }));
318
+ const msg = unwrapResp(resp);
319
+ if (isJsonMode(astra)) {
320
+ jsonOut(msg);
321
+ return;
322
+ }
323
+ console.log(`Reply created!\n ID: ${msg.id}\n Created: ${msg.createdAt}`);
324
+ });
325
+ astra
326
+ .command("edit-reply <replyId>")
327
+ .description("Edit a reply. You must be the author.")
328
+ .requiredOption("--content <content>", "New content for the reply (markdown supported)")
329
+ .action(async (replyId, opts) => {
330
+ const spaceSlug = resolveSpaceSlug(astra);
331
+ const resp = (await apiPatch(`/spaces/${spaceSlug}/replies/${replyId}`, { content: opts.content }));
332
+ const msg = unwrapResp(resp);
333
+ if (isJsonMode(astra)) {
334
+ jsonOut(msg);
335
+ return;
336
+ }
337
+ console.log(`Reply edited!\n ID: ${msg.id}\n Edited: ${msg.editedAt}`);
338
+ });
339
+ astra
340
+ .command("delete-reply <replyId>")
341
+ .description("Delete a reply. You must be the author.")
342
+ .action(async (replyId) => {
343
+ const spaceSlug = resolveSpaceSlug(astra);
344
+ await apiDelete(`/spaces/${spaceSlug}/replies/${replyId}`);
345
+ if (isJsonMode(astra)) {
346
+ jsonOut({ replyId });
347
+ return;
348
+ }
349
+ console.log(`Reply ${replyId} deleted.`);
350
+ });
351
+ // ── Sessions (get, list, reply) ──
352
+ astra
353
+ .command("get-session <sessionId>")
354
+ .description("Get a session and its messages (paginated).")
355
+ .option("--limit <number>", "Messages per page", "20")
356
+ .option("--offset <number>", "Offset for message pagination", "0")
357
+ .action(async (sessionId, opts) => {
358
+ const resp = (await apiGet(`/session/${sessionId}`, {
359
+ limit: parseInt(opts.limit, 10),
360
+ offset: parseInt(opts.offset, 10),
361
+ }));
362
+ const data = unwrapResp(resp);
363
+ if (isJsonMode(astra)) {
364
+ jsonOut(data);
365
+ return;
366
+ }
367
+ const session = (data.session || data);
368
+ const messages = (data.messages || []);
369
+ const pagination = (data.pagination || {});
370
+ const totalMessages = pagination.total || messages.length;
371
+ const msgLines = [];
372
+ for (const m of messages) {
373
+ const author = m.author?.name ||
374
+ m.source ||
375
+ `User ${m.authorId}`;
376
+ const text = m.content;
377
+ const truncated = text.length > 200 ? text.slice(0, 200) + "\u2026" : text;
378
+ msgLines.push(` - ${author}: ${truncated} (${m.createdAt})`);
379
+ }
380
+ const output = [
381
+ `Session: ${session.title}`,
382
+ ` ID: ${session.id}`,
383
+ ` Mode: ${session.mode}`,
384
+ ` Last activity: ${session.lastMessageAt}`,
385
+ "",
386
+ `Messages (${messages.length} of ${totalMessages}):`,
387
+ ...msgLines,
388
+ ].join("\n");
389
+ console.log(output);
390
+ });
391
+ astra
392
+ .command("list-sessions")
393
+ .description("List all sessions you are part of, sorted by most recent activity.")
394
+ .option("--limit <number>", "Items per page", "20")
395
+ .option("--offset <number>", "Offset for pagination", "0")
396
+ .action(async (opts) => {
397
+ const spaceSlug = resolveSpaceSlug(astra);
398
+ const limit = parseInt(opts.limit, 10);
399
+ const offset = parseInt(opts.offset, 10);
400
+ const resp = (await apiGet(`/session/my-sessions`, {
401
+ spaceSlug,
402
+ limit,
403
+ offset,
404
+ }));
405
+ const data = unwrapResp(resp);
406
+ const items = (data.sessions || []);
407
+ const total = data.total ?? items.length;
408
+ if (isJsonMode(astra)) {
409
+ jsonOut({
410
+ sessions: items,
411
+ pagination: { total, limit, offset },
412
+ });
413
+ return;
414
+ }
415
+ if (!items.length) {
416
+ console.log("No sessions found.");
417
+ return;
418
+ }
419
+ const lines = [];
420
+ for (const s of items) {
421
+ const title = s.title || "(no title)";
422
+ lines.push(`- [${s.id}] "${title}" (mode: ${s.mode}, last activity: ${s.lastMessageAt})`);
423
+ }
424
+ console.log(`Sessions (${items.length} of ${total}):\n` + lines.join("\n"));
425
+ });
426
+ astra
427
+ .command("reply-session <sessionId>")
428
+ .description("Send a human reply to a session you are a member of.")
429
+ .requiredOption("--content <content>", "Reply content (markdown supported)")
430
+ .action(async (sessionId, opts) => {
431
+ const resp = (await apiPost(`/session/${sessionId}/reply`, {
432
+ content: opts.content,
433
+ }));
434
+ const msg = unwrapResp(resp);
435
+ if (isJsonMode(astra)) {
436
+ jsonOut(msg);
437
+ return;
438
+ }
439
+ console.log(`Reply sent!\n` +
440
+ ` Message ID: ${msg.id}\n` +
441
+ ` Source: ${msg.source}\n` +
442
+ ` Created: ${msg.createdAt}`);
443
+ });
444
+ // ── Brain Updates (list, create, edit, delete) ──
445
+ astra
446
+ .command("list-brain-updates")
447
+ .description("List recent brain updates in a space (paginated).")
448
+ .option("--limit <number>", "Items per page", "20")
449
+ .option("--offset <number>", "Offset for pagination", "0")
450
+ .action(async (opts) => {
451
+ const spaceSlug = resolveSpaceSlug(astra);
452
+ const resp = (await apiGet(`/spaces/${spaceSlug}/brain-updates`, {
453
+ limit: parseInt(opts.limit, 10),
454
+ offset: parseInt(opts.offset, 10),
455
+ }));
456
+ if (isJsonMode(astra)) {
457
+ jsonOut({
458
+ items: resp.data || [],
459
+ pagination: resp.pagination || {},
460
+ });
461
+ return;
462
+ }
463
+ const items = (resp.data || []);
464
+ const pagination = (resp.pagination || {});
465
+ if (!items.length) {
466
+ console.log("No brain updates found.");
467
+ return;
468
+ }
469
+ const lines = [];
470
+ for (const u of items) {
471
+ const author = u.author?.name ||
472
+ `User ${u.authorId}`;
473
+ const vaultSlug = u.vault?.vaultSlug ||
474
+ "?";
475
+ lines.push(`- [${u.id}] "${u.title}" by ${author} (vault: ${vaultSlug}, ${u.createdAt})`);
476
+ }
477
+ const total = pagination.total || items.length;
478
+ console.log(`Brain updates (${items.length} of ${total}):\n` + lines.join("\n"));
479
+ });
480
+ astra
481
+ .command("create-brain-update")
482
+ .description("Create a brain update in a space. Uses the vault from settings.")
483
+ .option("--vault-slug <vaultSlug>", "Vault slug (overrides .gobi/settings.yaml)")
484
+ .requiredOption("--title <title>", "Title of the update")
485
+ .requiredOption("--content <content>", "Update content (markdown supported)")
486
+ .action(async (opts) => {
487
+ const spaceSlug = resolveSpaceSlug(astra);
488
+ const vaultSlug = resolveVaultSlug(opts);
489
+ const resp = (await apiPost(`/spaces/${spaceSlug}/brain-updates`, {
490
+ vaultSlug,
491
+ title: opts.title,
492
+ content: opts.content,
493
+ }));
494
+ const u = unwrapResp(resp);
495
+ if (isJsonMode(astra)) {
496
+ jsonOut(u);
497
+ return;
498
+ }
499
+ console.log(`Brain update created!\n` +
500
+ ` ID: ${u.id}\n` +
501
+ ` Title: ${u.title}\n` +
502
+ ` Vault: ${u.vaultSlug || vaultSlug}\n` +
503
+ ` Created: ${u.createdAt}`);
504
+ });
505
+ astra
506
+ .command("edit-brain-update <updateId>")
507
+ .description("Edit a published brain update. You must be the author.")
508
+ .option("--title <title>", "New title for the update")
509
+ .option("--content <content>", "New content for the update (markdown supported)")
510
+ .action(async (updateId, opts) => {
511
+ if (!opts.title && !opts.content) {
512
+ throw new Error("Provide at least --title or --content to update.");
513
+ }
514
+ const spaceSlug = resolveSpaceSlug(astra);
515
+ const body = {};
516
+ if (opts.title != null)
517
+ body.title = opts.title;
518
+ if (opts.content != null)
519
+ body.content = opts.content;
520
+ const resp = (await apiPatch(`/spaces/${spaceSlug}/brain-updates/${updateId}`, body));
521
+ const u = unwrapResp(resp);
522
+ if (isJsonMode(astra)) {
523
+ jsonOut(u);
524
+ return;
525
+ }
526
+ console.log(`Brain update edited!\n` +
527
+ ` ID: ${u.id}\n` +
528
+ ` Title: ${u.title}\n` +
529
+ ` Updated: ${u.updatedAt}`);
530
+ });
531
+ astra
532
+ .command("delete-brain-update <updateId>")
533
+ .description("Delete a published brain update. You must be the author.")
534
+ .action(async (updateId) => {
535
+ const spaceSlug = resolveSpaceSlug(astra);
536
+ await apiDelete(`/spaces/${spaceSlug}/brain-updates/${updateId}`);
537
+ if (isJsonMode(astra)) {
538
+ jsonOut({ id: updateId });
539
+ return;
540
+ }
541
+ console.log(`Brain update ${updateId} deleted.`);
542
+ });
543
+ }
@@ -0,0 +1,96 @@
1
+ import { BASE_URL, POLL_MAX_DURATION_MS } from "../constants.js";
2
+ import { DeviceCodeError } from "../errors.js";
3
+ import { storeTokens, logout, isAuthenticated, getCurrentUser, } from "../auth/manager.js";
4
+ import { printContext, readSettings, runInitFlow } from "./init.js";
5
+ function sleep(ms) {
6
+ return new Promise((resolve) => setTimeout(resolve, ms));
7
+ }
8
+ export function registerAuthCommand(program) {
9
+ const auth = program
10
+ .command("auth")
11
+ .description("Authentication commands.");
12
+ auth
13
+ .command("login")
14
+ .description("Log in to Gobi. Opens a browser URL for Google OAuth, then polls until authentication is complete.")
15
+ .action(async () => {
16
+ const res = await fetch(`${BASE_URL}/auth/device`, {
17
+ method: "POST",
18
+ headers: { "Content-Type": "application/json" },
19
+ });
20
+ if (!res.ok) {
21
+ const body = (await res.text()) || "(no body)";
22
+ throw new DeviceCodeError(`Failed to initiate login: HTTP ${res.status}: ${body}`);
23
+ }
24
+ const deviceData = (await res.json());
25
+ const intervalS = deviceData.interval || 5;
26
+ const startMs = Date.now();
27
+ console.log(`Open this URL in your browser to log in:\n ${deviceData.verificationUri}`);
28
+ console.log(`Your user code: ${deviceData.userCode}`);
29
+ console.log("Waiting for authentication...");
30
+ while (Date.now() - startMs < POLL_MAX_DURATION_MS) {
31
+ await sleep(intervalS * 1000);
32
+ const tokenRes = await fetch(`${BASE_URL}/auth/device/token`, {
33
+ method: "POST",
34
+ headers: { "Content-Type": "application/json" },
35
+ body: JSON.stringify({ deviceCode: deviceData.deviceCode }),
36
+ });
37
+ if (!tokenRes.ok) {
38
+ const body = (await tokenRes.text()) || "(no body)";
39
+ throw new DeviceCodeError(`Token poll failed: HTTP ${tokenRes.status}: ${body}`);
40
+ }
41
+ const tokenData = (await tokenRes.json());
42
+ if ("accessToken" in tokenData) {
43
+ const user = tokenData.user;
44
+ const creds = {
45
+ accessToken: tokenData.accessToken,
46
+ refreshToken: tokenData.refreshToken,
47
+ expiresAt: Date.now() + tokenData.expiresIn * 1000,
48
+ user: {
49
+ id: user.id,
50
+ email: user.email,
51
+ name: user.name,
52
+ pictureUrl: user.pictureUrl || null,
53
+ },
54
+ };
55
+ await storeTokens(creds);
56
+ console.log(`Successfully logged in as ${user.name} (${user.email}).`);
57
+ const settings = readSettings();
58
+ if (settings && settings.selectedSpaceSlug) {
59
+ printContext();
60
+ console.log("Run 'gobi init' to change.");
61
+ }
62
+ else {
63
+ console.log("");
64
+ await runInitFlow();
65
+ }
66
+ return;
67
+ }
68
+ if (tokenData.status === "expired") {
69
+ throw new DeviceCodeError("Login session expired. Please try 'gobi auth login' again.");
70
+ }
71
+ }
72
+ throw new DeviceCodeError("Login timed out. Please try 'gobi auth login' again.");
73
+ });
74
+ auth
75
+ .command("status")
76
+ .description("Check whether you are currently authenticated with Gobi.")
77
+ .action(() => {
78
+ if (isAuthenticated()) {
79
+ const user = getCurrentUser();
80
+ const name = user?.name || "Unknown";
81
+ const email = user?.email || "Unknown";
82
+ console.log(`Authenticated as ${name} (${email})`);
83
+ printContext();
84
+ }
85
+ else {
86
+ console.log("You are not authenticated. Use 'gobi auth login' to log in.");
87
+ }
88
+ });
89
+ auth
90
+ .command("logout")
91
+ .description("Log out of Gobi and remove stored credentials.")
92
+ .action(async () => {
93
+ await logout();
94
+ console.log("Logged out. Credentials removed.");
95
+ });
96
+ }
@@ -0,0 +1,212 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
2
+ import { join } from "path";
3
+ import inquirer from "inquirer";
4
+ import yaml from "js-yaml";
5
+ import { apiGet, apiPost } from "../client.js";
6
+ import { isAuthenticated } from "../auth/manager.js";
7
+ const SETTINGS_DIR = ".gobi";
8
+ const SETTINGS_FILE = "settings.yaml";
9
+ function settingsPath() {
10
+ return join(process.cwd(), SETTINGS_DIR, SETTINGS_FILE);
11
+ }
12
+ export function readSettings() {
13
+ const path = settingsPath();
14
+ if (!existsSync(path))
15
+ return null;
16
+ const content = readFileSync(path, "utf-8");
17
+ return yaml.load(content);
18
+ }
19
+ export function getSpaceSlug() {
20
+ const settings = readSettings();
21
+ const slug = settings?.selectedSpaceSlug;
22
+ if (!slug) {
23
+ throw new Error("Not initialized. Run 'gobi init' first.");
24
+ }
25
+ return slug;
26
+ }
27
+ export function getVaultSlug() {
28
+ const settings = readSettings();
29
+ const vault = settings?.vaultSlug;
30
+ if (!vault) {
31
+ throw new Error("Not initialized. Run 'gobi init' first.");
32
+ }
33
+ return vault;
34
+ }
35
+ export function printContext() {
36
+ const settings = readSettings();
37
+ const slug = settings?.selectedSpaceSlug;
38
+ if (!slug) {
39
+ console.log("Not initialized. Run 'gobi init' to set up.");
40
+ return;
41
+ }
42
+ const vaultId = settings?.vaultSlug || "?";
43
+ console.log(`Space: ${slug} | Vault: ${vaultId}`);
44
+ }
45
+ function writeSettings(vaultId, spaceSlug) {
46
+ const path = settingsPath();
47
+ const dir = join(process.cwd(), SETTINGS_DIR);
48
+ if (!existsSync(dir)) {
49
+ mkdirSync(dir, { recursive: true });
50
+ }
51
+ const content = yaml.dump({ vaultSlug: vaultId, selectedSpaceSlug: spaceSlug }, { flowLevel: -1 });
52
+ writeFileSync(path, content, "utf-8");
53
+ }
54
+ async function selectSpace() {
55
+ const resp = (await apiGet("/spaces"));
56
+ const spaces = (Array.isArray(resp) ? resp : resp.data || resp);
57
+ if (!spaces || spaces.length === 0) {
58
+ throw new Error("You are not a member of any spaces. Join or create a space first.");
59
+ }
60
+ const choices = spaces.map((s) => ({
61
+ name: `${s.name} (${s.slug})`,
62
+ value: s,
63
+ }));
64
+ if (choices.length > 1) {
65
+ choices.push({ name: "Go back", value: null });
66
+ }
67
+ const { selected } = await inquirer.prompt([
68
+ {
69
+ type: "list",
70
+ name: "selected",
71
+ message: "Select a space:",
72
+ choices,
73
+ },
74
+ ]);
75
+ if (selected === null)
76
+ return null;
77
+ return { slug: selected.slug, name: selected.name };
78
+ }
79
+ async function selectExistingVault() {
80
+ const resp = (await apiGet("/vault"));
81
+ const vaults = (Array.isArray(resp) ? resp : resp.data || resp);
82
+ if (!vaults || vaults.length === 0) {
83
+ console.log("You don't have any vaults yet. Let's create one.");
84
+ return createNewVault();
85
+ }
86
+ const choices = vaults.map((v) => ({
87
+ name: `${v.name} (${v.vaultId})`,
88
+ value: v,
89
+ }));
90
+ choices.push({ name: "Go back", value: null });
91
+ const { selected } = await inquirer.prompt([
92
+ {
93
+ type: "list",
94
+ name: "selected",
95
+ message: "Select a vault:",
96
+ choices,
97
+ },
98
+ ]);
99
+ if (selected === null)
100
+ return null;
101
+ return {
102
+ vaultId: selected.vaultId,
103
+ name: selected.name,
104
+ };
105
+ }
106
+ async function createNewVault() {
107
+ let vaultId;
108
+ while (true) {
109
+ const { id } = await inquirer.prompt([
110
+ {
111
+ type: "input",
112
+ name: "id",
113
+ message: "Enter a unique vault ID:",
114
+ },
115
+ ]);
116
+ if (!id.trim()) {
117
+ console.log("Vault ID cannot be empty.");
118
+ continue;
119
+ }
120
+ vaultId = id.trim();
121
+ const resp = (await apiGet(`/vault/check/${vaultId}`));
122
+ const available = typeof resp === "object" && resp !== null
123
+ ? resp.available
124
+ : false;
125
+ if (available) {
126
+ console.log(`ID "${vaultId}" is available!`);
127
+ break;
128
+ }
129
+ else {
130
+ console.log(`ID "${vaultId}" is already taken. Try another.`);
131
+ }
132
+ }
133
+ const { vaultName } = await inquirer.prompt([
134
+ {
135
+ type: "input",
136
+ name: "vaultName",
137
+ message: "Enter vault name:",
138
+ },
139
+ ]);
140
+ const name = vaultName.trim() || vaultId;
141
+ const resp = (await apiPost("/vault", {
142
+ vaultId,
143
+ name,
144
+ }));
145
+ const vault = (typeof resp === "object" && resp !== null && "data" in resp
146
+ ? resp.data
147
+ : resp);
148
+ console.log(`Created vault "${vault.name}" (${vault.vaultId})`);
149
+ return { vaultId: vault.vaultId, name: vault.name };
150
+ }
151
+ export async function runInitFlow() {
152
+ if (!isAuthenticated()) {
153
+ throw new Error("Not authenticated. Run 'gobi auth login' first.");
154
+ }
155
+ // Step 1: Select or create vault
156
+ let vaultId;
157
+ let vaultName;
158
+ while (true) {
159
+ const { action } = await inquirer.prompt([
160
+ {
161
+ type: "list",
162
+ name: "action",
163
+ message: "How would you like to set up your vault?",
164
+ choices: [
165
+ { name: "Select an existing vault", value: "existing" },
166
+ { name: "Create a new vault", value: "new" },
167
+ ],
168
+ },
169
+ ]);
170
+ if (action === "existing") {
171
+ const result = await selectExistingVault();
172
+ if (result === null)
173
+ continue;
174
+ vaultId = result.vaultId;
175
+ vaultName = result.name;
176
+ }
177
+ else {
178
+ const result = await createNewVault();
179
+ vaultId = result.vaultId;
180
+ vaultName = result.name;
181
+ }
182
+ break;
183
+ }
184
+ // Step 2: Select space
185
+ let spaceSlug;
186
+ let spaceName;
187
+ while (true) {
188
+ const result = await selectSpace();
189
+ if (result === null)
190
+ continue;
191
+ spaceSlug = result.slug;
192
+ spaceName = result.name;
193
+ break;
194
+ }
195
+ writeSettings(vaultId, spaceSlug);
196
+ console.log(`Linked to space "${spaceName}" (${spaceSlug}) with vault "${vaultName}" (${vaultId})`);
197
+ console.log(`Created ${SETTINGS_DIR}/${SETTINGS_FILE}`);
198
+ // Create default BRAIN.md if it doesn't exist
199
+ const brainPath = join(process.cwd(), "BRAIN.md");
200
+ if (!existsSync(brainPath)) {
201
+ writeFileSync(brainPath, `---\ntitle: ${vaultName}\ntags: []\ndescription:\nthumbnail:\nprompt:\n---\n`, "utf-8");
202
+ console.log("Created BRAIN.md");
203
+ }
204
+ }
205
+ export function registerInitCommand(program) {
206
+ program
207
+ .command("init")
208
+ .description("Set up or change the space and vault linked to the current directory.")
209
+ .action(async () => {
210
+ await runInitFlow();
211
+ });
212
+ }
@@ -0,0 +1,6 @@
1
+ export const BASE_URL = process.env.GOBI_BASE_URL || "https://backend.joingobi.com";
2
+ export const WEBDRIVE_BASE_URL = process.env.GOBI_WEBDRIVE_BASE_URL || "https://webdrive.joingobi.com";
3
+ // Refresh access token when less than 5 minutes remain
4
+ export const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000;
5
+ // Max polling duration before giving up (ms) - 10 minutes
6
+ export const POLL_MAX_DURATION_MS = 10 * 60 * 1000;
package/dist/errors.js ADDED
@@ -0,0 +1,45 @@
1
+ export class AstraError extends Error {
2
+ code;
3
+ constructor(message, code) {
4
+ super(message);
5
+ this.code = code;
6
+ this.name = "AstraError";
7
+ }
8
+ }
9
+ export class NotAuthenticatedError extends AstraError {
10
+ constructor() {
11
+ super("Not authenticated. Use 'gobi auth login' to begin the login flow.", "NOT_AUTHENTICATED");
12
+ this.name = "NotAuthenticatedError";
13
+ }
14
+ }
15
+ export class TokenRefreshError extends AstraError {
16
+ constructor(detail) {
17
+ super(`Failed to refresh access token: ${detail}. Please run 'gobi auth login' to re-authenticate.`, "TOKEN_REFRESH_FAILED");
18
+ this.name = "TokenRefreshError";
19
+ }
20
+ }
21
+ export class ApiError extends AstraError {
22
+ status;
23
+ endpoint;
24
+ constructor(status, endpoint, body) {
25
+ let message = body;
26
+ try {
27
+ const parsed = JSON.parse(body);
28
+ if (parsed.message)
29
+ message = parsed.message;
30
+ }
31
+ catch {
32
+ // use raw body as-is
33
+ }
34
+ super(message, "API_ERROR");
35
+ this.status = status;
36
+ this.endpoint = endpoint;
37
+ this.name = "ApiError";
38
+ }
39
+ }
40
+ export class DeviceCodeError extends AstraError {
41
+ constructor(detail) {
42
+ super(`Device code flow error: ${detail}`, "DEVICE_CODE_ERROR");
43
+ this.name = "DeviceCodeError";
44
+ }
45
+ }
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import { cli } from "./main.js";
3
+ cli();
package/dist/main.js ADDED
@@ -0,0 +1,82 @@
1
+ import { createRequire } from "module";
2
+ import { Command } from "commander";
3
+ import { initCredentials } from "./auth/manager.js";
4
+ import { ApiError, AstraError } from "./errors.js";
5
+ import { registerAuthCommand } from "./commands/auth.js";
6
+ import { registerInitCommand, printContext } from "./commands/init.js";
7
+ import { registerAstraCommand } from "./commands/astra.js";
8
+ const require = createRequire(import.meta.url);
9
+ const { version } = require("../package.json");
10
+ const SKIP_BANNER_COMMANDS = new Set(["auth", "init"]);
11
+ function shouldShowBanner() {
12
+ const args = process.argv.slice(2);
13
+ if (args.length === 0)
14
+ return true;
15
+ return !SKIP_BANNER_COMMANDS.has(args[0]);
16
+ }
17
+ export async function cli() {
18
+ const program = new Command();
19
+ program
20
+ .name("gobi")
21
+ .version(version)
22
+ .description("CLI client for the Gobi collaborative knowledge platform")
23
+ .option("--json", "Output results as JSON instead of human-readable text")
24
+ .configureHelp({ helpWidth: process.stdout.columns || 200 });
25
+ // Register all command groups
26
+ registerAuthCommand(program);
27
+ registerInitCommand(program);
28
+ registerAstraCommand(program);
29
+ // Propagate helpWidth to all subcommands
30
+ const helpWidth = process.stdout.columns || 200;
31
+ for (const cmd of program.commands) {
32
+ cmd.configureHelp({ helpWidth });
33
+ for (const sub of cmd.commands) {
34
+ sub.configureHelp({ helpWidth });
35
+ }
36
+ }
37
+ // Hook into the pre-action to init credentials and show banner
38
+ program.hook("preAction", async () => {
39
+ await initCredentials();
40
+ if (!program.opts().json && shouldShowBanner()) {
41
+ printContext();
42
+ console.log("");
43
+ }
44
+ });
45
+ try {
46
+ await program.parseAsync(process.argv);
47
+ }
48
+ catch (err) {
49
+ const isJson = program.opts().json;
50
+ if (err instanceof ApiError) {
51
+ if (isJson) {
52
+ console.log(JSON.stringify({ success: false, error: `API error (HTTP ${err.status}): ${err.message}` }));
53
+ process.exit(1);
54
+ }
55
+ let hint = "";
56
+ if (err.status === 403) {
57
+ hint = " You may not have permission to perform this action.";
58
+ }
59
+ else if (err.status === 404) {
60
+ hint = " The requested resource was not found.";
61
+ }
62
+ console.error(`Error: API error (HTTP ${err.status}): ${err.message}${hint}`);
63
+ process.exit(1);
64
+ }
65
+ else if (err instanceof AstraError) {
66
+ if (isJson) {
67
+ console.log(JSON.stringify({ success: false, error: err.message }));
68
+ process.exit(1);
69
+ }
70
+ console.error(`Error: ${err.message}`);
71
+ process.exit(1);
72
+ }
73
+ const msg = err instanceof Error ? err.message : String(err);
74
+ if (isJson) {
75
+ console.log(JSON.stringify({ success: false, error: msg }));
76
+ }
77
+ else {
78
+ console.error(`Error: ${msg}`);
79
+ }
80
+ process.exit(1);
81
+ }
82
+ }
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@gobi-ai/cli",
3
+ "version": "0.1.1",
4
+ "description": "CLI client for the Gobi collaborative knowledge platform",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/gobi-ai/gobi-cli.git"
10
+ },
11
+ "homepage": "https://github.com/gobi-ai/gobi-cli",
12
+ "bugs": {
13
+ "url": "https://github.com/gobi-ai/gobi-cli/issues"
14
+ },
15
+ "keywords": [
16
+ "gobi",
17
+ "cli",
18
+ "second-brain",
19
+ "knowledge"
20
+ ],
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "engines": {
25
+ "node": ">=18"
26
+ },
27
+ "files": [
28
+ "dist",
29
+ "!dist/**/*.test.js"
30
+ ],
31
+ "bin": {
32
+ "gobi": "./dist/index.js"
33
+ },
34
+ "scripts": {
35
+ "clean": "rm -rf dist",
36
+ "build": "npm run clean && tsc && chmod +x dist/index.js",
37
+ "dev": "tsx src/index.ts",
38
+ "start": "node dist/index.js",
39
+ "test": "node --test dist/*.test.js",
40
+ "prepublishOnly": "npm run build"
41
+ },
42
+ "dependencies": {
43
+ "commander": "^12.1.0",
44
+ "inquirer": "^12.3.0",
45
+ "js-yaml": "^4.1.0"
46
+ },
47
+ "devDependencies": {
48
+ "@types/inquirer": "^9.0.7",
49
+ "@types/js-yaml": "^4.0.9",
50
+ "@types/node": "^22.0.0",
51
+ "tsx": "^4.19.0",
52
+ "typescript": "^5.6.0"
53
+ }
54
+ }