@pebblehouse/odin-cli 0.1.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.
Files changed (3) hide show
  1. package/README.md +89 -0
  2. package/dist/index.js +326 -0
  3. package/package.json +42 -0
package/README.md ADDED
@@ -0,0 +1,89 @@
1
+ # @pebblehouse/odin-cli
2
+
3
+ CLI for [Odin](https://www.odin.mu) — the knowledge backbone for Pebble House.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install -g @pebblehouse/odin-cli
9
+ ```
10
+
11
+ ## Setup
12
+
13
+ ```bash
14
+ # Authenticate via browser (Google sign-in)
15
+ odin login
16
+ ```
17
+
18
+ This opens your browser, completes Google OAuth, and stores credentials locally at `~/.odin/credentials.json`. Tokens auto-refresh — you won't need to re-login for ~6 months.
19
+
20
+ ## Usage
21
+
22
+ ### Pebbles
23
+
24
+ ```bash
25
+ odin pebbles list
26
+ odin pebbles get <slug>
27
+ odin pebbles create <slug> --name "My Project"
28
+ odin pebbles update <slug> --status building
29
+ ```
30
+
31
+ ### Documents
32
+
33
+ ```bash
34
+ odin docs list <slug>
35
+ odin docs get <slug> <type>
36
+ odin docs create <slug> architecture --title "Architecture" --file ./ARCHITECTURE.md
37
+ odin docs update <slug> architecture --file ./ARCHITECTURE.md
38
+ ```
39
+
40
+ ### Decisions
41
+
42
+ ```bash
43
+ odin decisions list <slug>
44
+ odin decisions log <slug> "Use Hono for API" --rationale "Lightweight, consistent"
45
+ ```
46
+
47
+ ### Context (Tier 1 Retrieval)
48
+
49
+ ```bash
50
+ odin context <slug> # Last 20 observations
51
+ odin context <slug> --limit 50 # Custom limit
52
+ ```
53
+
54
+ ### Sessions
55
+
56
+ ```bash
57
+ odin sessions start <slug> # Returns session ID
58
+ odin sessions end <session-id> --summary "Completed auth"
59
+ ```
60
+
61
+ ### Search
62
+
63
+ ```bash
64
+ odin search "authentication" # Cross-pebble
65
+ odin search "RLS policies" --pebble odin # Scoped
66
+ ```
67
+
68
+ ### Auth
69
+
70
+ ```bash
71
+ odin login # Browser OAuth
72
+ odin logout # Clear credentials
73
+ ```
74
+
75
+ ## Output
76
+
77
+ All commands output JSON to stdout. Errors go to stderr with non-zero exit codes.
78
+
79
+ ## Configuration
80
+
81
+ | Variable | Default | Description |
82
+ |----------|---------|-------------|
83
+ | `ODIN_API_URL` | `https://www.odin.mu` | API base URL |
84
+ | `ODIN_SUPABASE_URL` | — | Supabase URL (for token refresh) |
85
+ | `ODIN_SUPABASE_ANON_KEY` | — | Supabase anon key (for token refresh) |
86
+
87
+ ## License
88
+
89
+ MIT
package/dist/index.js ADDED
@@ -0,0 +1,326 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { Command as Command7 } from "commander";
5
+
6
+ // src/auth/login.ts
7
+ import { createServer } from "http";
8
+ import open from "open";
9
+
10
+ // src/auth/credentials.ts
11
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync } from "fs";
12
+ import { join } from "path";
13
+ import { homedir } from "os";
14
+ var ODIN_DIR = join(homedir(), ".odin");
15
+ var CREDENTIALS_PATH = join(ODIN_DIR, "credentials.json");
16
+ function getCredentials() {
17
+ if (!existsSync(CREDENTIALS_PATH)) return null;
18
+ try {
19
+ const raw = readFileSync(CREDENTIALS_PATH, "utf-8");
20
+ return JSON.parse(raw);
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
25
+ function saveCredentials(creds) {
26
+ if (!existsSync(ODIN_DIR)) {
27
+ mkdirSync(ODIN_DIR, { recursive: true });
28
+ }
29
+ writeFileSync(CREDENTIALS_PATH, JSON.stringify(creds, null, 2), { mode: 384 });
30
+ }
31
+ function clearCredentials() {
32
+ if (existsSync(CREDENTIALS_PATH)) {
33
+ unlinkSync(CREDENTIALS_PATH);
34
+ }
35
+ }
36
+
37
+ // src/auth/login.ts
38
+ var API_URL = process.env.ODIN_API_URL ?? "https://www.odin.mu";
39
+ async function login() {
40
+ return new Promise((resolve, reject) => {
41
+ const server = createServer(async (req, res) => {
42
+ if (req.method === "POST" && req.url?.startsWith("/callback")) {
43
+ let body = "";
44
+ for await (const chunk of req) {
45
+ body += chunk;
46
+ }
47
+ const params = new URLSearchParams(body);
48
+ const access_token = params.get("access_token");
49
+ const refresh_token = params.get("refresh_token");
50
+ const expires_at = params.get("expires_at");
51
+ if (!access_token || !refresh_token || !expires_at) {
52
+ res.writeHead(400, { "Content-Type": "text/html" });
53
+ res.end("<html><body><p>Missing tokens. Login failed.</p></body></html>");
54
+ server.close();
55
+ reject(new Error("Missing tokens in callback"));
56
+ return;
57
+ }
58
+ const creds = {
59
+ access_token,
60
+ refresh_token,
61
+ expires_at: parseInt(expires_at, 10)
62
+ };
63
+ saveCredentials(creds);
64
+ res.writeHead(200, { "Content-Type": "text/html" });
65
+ res.end(`<!DOCTYPE html>
66
+ <html><body style="margin:0;background:#111;color:#e5e5e5;font-family:monospace;display:flex;align-items:center;justify-content:center;min-height:100vh;text-align:center">
67
+ <div>
68
+ <p style="color:#f2c94c;font-size:1.5rem;margin-bottom:0.5rem">&#10003; Login successful</p>
69
+ <p style="color:#666;font-size:0.9rem">You can close this tab.</p>
70
+ </div>
71
+ </body></html>`);
72
+ server.close();
73
+ resolve();
74
+ } else {
75
+ res.writeHead(404);
76
+ res.end();
77
+ }
78
+ });
79
+ server.listen(0, "127.0.0.1", () => {
80
+ const addr = server.address();
81
+ if (!addr || typeof addr === "string") {
82
+ reject(new Error("Failed to start callback server"));
83
+ return;
84
+ }
85
+ const port = addr.port;
86
+ const authUrl = `${API_URL}/auth/cli?port=${port}`;
87
+ console.error(`Opening browser for authentication...`);
88
+ console.error(`If the browser doesn't open, visit: ${authUrl}`);
89
+ open(authUrl).catch(() => {
90
+ });
91
+ });
92
+ const timeout = setTimeout(() => {
93
+ server.close();
94
+ reject(new Error("Login timed out. Try again."));
95
+ }, 12e4);
96
+ server.on("close", () => clearTimeout(timeout));
97
+ });
98
+ }
99
+
100
+ // src/commands/pebbles.ts
101
+ import { Command } from "commander";
102
+
103
+ // src/auth/refresh.ts
104
+ import { createClient } from "@supabase/supabase-js";
105
+ var SUPABASE_URL = process.env.ODIN_SUPABASE_URL ?? process.env.NEXT_PUBLIC_SUPABASE_URL ?? "";
106
+ var SUPABASE_ANON_KEY = process.env.ODIN_SUPABASE_ANON_KEY ?? process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? "";
107
+ async function getValidToken() {
108
+ const creds = getCredentials();
109
+ if (!creds) {
110
+ throw new Error("Not logged in. Run 'odin login' first.");
111
+ }
112
+ const now = Math.floor(Date.now() / 1e3);
113
+ if (creds.expires_at > now + 60) {
114
+ return creds.access_token;
115
+ }
116
+ const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
117
+ const { data, error } = await supabase.auth.setSession({
118
+ access_token: creds.access_token,
119
+ refresh_token: creds.refresh_token
120
+ });
121
+ if (error || !data.session) {
122
+ throw new Error("Session expired. Run 'odin login' to re-authenticate.");
123
+ }
124
+ const newCreds = {
125
+ access_token: data.session.access_token,
126
+ refresh_token: data.session.refresh_token,
127
+ expires_at: data.session.expires_at ?? now + 3600
128
+ };
129
+ saveCredentials(newCreds);
130
+ return newCreds.access_token;
131
+ }
132
+
133
+ // src/api-client.ts
134
+ var BASE_URL = process.env.ODIN_API_URL ?? "https://www.odin.mu";
135
+ async function apiRequest(path, options = {}) {
136
+ const { method = "GET", body, params } = options;
137
+ const token = await getValidToken();
138
+ let url = `${BASE_URL}/api${path}`;
139
+ if (params) {
140
+ const searchParams = new URLSearchParams(params);
141
+ url += `?${searchParams.toString()}`;
142
+ }
143
+ const headers = {
144
+ Authorization: `Bearer ${token}`,
145
+ "Content-Type": "application/json"
146
+ };
147
+ const res = await fetch(url, {
148
+ method,
149
+ headers,
150
+ body: body ? JSON.stringify(body) : void 0
151
+ });
152
+ const json = await res.json();
153
+ return json;
154
+ }
155
+
156
+ // src/commands/pebbles.ts
157
+ var pebblesCmd = new Command("pebbles").description("Manage pebbles");
158
+ pebblesCmd.command("list").description("List all pebbles").action(async () => {
159
+ const res = await apiRequest("/pebbles");
160
+ process.stdout.write(JSON.stringify(res) + "\n");
161
+ if (res.error) process.exit(1);
162
+ });
163
+ pebblesCmd.command("get <slug>").description("Get pebble details").action(async (slug) => {
164
+ const res = await apiRequest(`/pebbles/${slug}`);
165
+ process.stdout.write(JSON.stringify(res) + "\n");
166
+ if (res.error) process.exit(1);
167
+ });
168
+ pebblesCmd.command("create <slug>").description("Create a pebble").requiredOption("--name <name>", "Pebble name").option("--status <status>", "Initial status", "idea").option("--summary <summary>", "Summary").action(async (slug, opts) => {
169
+ const res = await apiRequest("/pebbles", {
170
+ method: "POST",
171
+ body: { slug, name: opts.name, status: opts.status, summary: opts.summary }
172
+ });
173
+ process.stdout.write(JSON.stringify(res) + "\n");
174
+ if (res.error) process.exit(1);
175
+ });
176
+ pebblesCmd.command("update <slug>").description("Update a pebble").option("--name <name>", "New name").option("--status <status>", "New status").option("--summary <summary>", "New summary").option("--repo-url <url>", "Repository URL").option("--domain <domain>", "Domain").action(async (slug, opts) => {
177
+ const body = {};
178
+ if (opts.name) body.name = opts.name;
179
+ if (opts.status) body.status = opts.status;
180
+ if (opts.summary) body.summary = opts.summary;
181
+ if (opts.repoUrl) body.repo_url = opts.repoUrl;
182
+ if (opts.domain) body.domain = opts.domain;
183
+ const res = await apiRequest(`/pebbles/${slug}`, { method: "PUT", body });
184
+ process.stdout.write(JSON.stringify(res) + "\n");
185
+ if (res.error) process.exit(1);
186
+ });
187
+
188
+ // src/commands/documents.ts
189
+ import { Command as Command2 } from "commander";
190
+ import { readFileSync as readFileSync2 } from "fs";
191
+ var docsCmd = new Command2("docs").description("Manage documents");
192
+ docsCmd.command("list <slug>").description("List documents for a pebble").action(async (slug) => {
193
+ const res = await apiRequest(`/pebbles/${slug}/documents`);
194
+ process.stdout.write(JSON.stringify(res) + "\n");
195
+ if (res.error) process.exit(1);
196
+ });
197
+ docsCmd.command("get <slug> <type>").description("Get document by type").action(async (slug, type) => {
198
+ const res = await apiRequest(`/pebbles/${slug}/documents/${type}`);
199
+ process.stdout.write(JSON.stringify(res) + "\n");
200
+ if (res.error) process.exit(1);
201
+ });
202
+ docsCmd.command("create <slug> <type>").description("Create a document").requiredOption("--title <title>", "Document title").option("--file <path>", "Read content from file").option("--source <source>", "Source", "manual").action(async (slug, type, opts) => {
203
+ const content = opts.file ? readFileSync2(opts.file, "utf-8") : "";
204
+ const res = await apiRequest(`/pebbles/${slug}/documents`, {
205
+ method: "POST",
206
+ body: { doc_type: type, title: opts.title, content, source: opts.source }
207
+ });
208
+ process.stdout.write(JSON.stringify(res) + "\n");
209
+ if (res.error) process.exit(1);
210
+ });
211
+ docsCmd.command("update <slug> <type>").description("Update document content").requiredOption("--file <path>", "Read content from file").option("--source <source>", "Source", "manual").action(async (slug, type, opts) => {
212
+ const content = readFileSync2(opts.file, "utf-8");
213
+ const res = await apiRequest(`/pebbles/${slug}/documents/${type}`, {
214
+ method: "PUT",
215
+ body: { content, source: opts.source }
216
+ });
217
+ process.stdout.write(JSON.stringify(res) + "\n");
218
+ if (res.error) process.exit(1);
219
+ });
220
+
221
+ // src/commands/decisions.ts
222
+ import { Command as Command3 } from "commander";
223
+ var decisionsCmd = new Command3("decisions").description("Manage decisions");
224
+ decisionsCmd.command("list <slug>").description("List decisions for a pebble").action(async (slug) => {
225
+ const res = await apiRequest(`/pebbles/${slug}/decisions`);
226
+ process.stdout.write(JSON.stringify(res) + "\n");
227
+ if (res.error) process.exit(1);
228
+ });
229
+ decisionsCmd.command("log <slug> <title>").description("Log a decision").option("--rationale <rationale>", "Decision rationale").option("--alternatives <alternatives>", "Alternatives considered").option("--session-id <id>", "Link to active session").action(async (slug, title, opts) => {
230
+ const res = await apiRequest(`/pebbles/${slug}/decisions`, {
231
+ method: "POST",
232
+ body: {
233
+ title,
234
+ rationale: opts.rationale,
235
+ alternatives_considered: opts.alternatives,
236
+ session_id: opts.sessionId
237
+ }
238
+ });
239
+ process.stdout.write(JSON.stringify(res) + "\n");
240
+ if (res.error) process.exit(1);
241
+ });
242
+
243
+ // src/commands/context.ts
244
+ import { Command as Command4 } from "commander";
245
+ var contextCmd = new Command4("context").description("Get Tier 1 context for a pebble").argument("<slug>", "Pebble slug").option("--limit <n>", "Number of observations", "20").action(async (slug, opts) => {
246
+ const res = await apiRequest(`/pebbles/${slug}/context`, {
247
+ params: { limit: opts.limit }
248
+ });
249
+ process.stdout.write(JSON.stringify(res) + "\n");
250
+ if (res.error) process.exit(1);
251
+ });
252
+
253
+ // src/commands/sessions.ts
254
+ import { Command as Command5 } from "commander";
255
+ var sessionsCmd = new Command5("sessions").description("Manage sessions");
256
+ sessionsCmd.command("start <slug>").description("Start a session").option("--source <source>", "Session source", "claude-code").action(async (slug, opts) => {
257
+ const res = await apiRequest("/sessions", {
258
+ method: "POST",
259
+ body: { pebble_slug: slug, source: opts.source }
260
+ });
261
+ process.stdout.write(JSON.stringify(res) + "\n");
262
+ if (res.error) process.exit(1);
263
+ });
264
+ sessionsCmd.command("end <id>").description("End a session").option("--summary <summary>", "Session summary").option("--status <status>", "Final status", "completed").action(async (id, opts) => {
265
+ const res = await apiRequest(`/sessions/${id}`, {
266
+ method: "PUT",
267
+ body: { summary: opts.summary, status: opts.status }
268
+ });
269
+ process.stdout.write(JSON.stringify(res) + "\n");
270
+ if (res.error) process.exit(1);
271
+ });
272
+
273
+ // src/commands/search.ts
274
+ import { Command as Command6 } from "commander";
275
+ var searchCmd = new Command6("search").description("Full-text search across Odin").argument("<query>", "Search query").option("--pebble <slug>", "Scope to a pebble").action(async (query, opts) => {
276
+ const params = { q: query };
277
+ if (opts.pebble) params.pebble = opts.pebble;
278
+ const res = await apiRequest("/search", { params });
279
+ process.stdout.write(JSON.stringify(res) + "\n");
280
+ if (res.error) process.exit(1);
281
+ });
282
+
283
+ // src/index.ts
284
+ var GOLD = "\x1B[33m";
285
+ var DIM = "\x1B[2m";
286
+ var RESET = "\x1B[0m";
287
+ var VERSION = "0.1.0";
288
+ function printBanner() {
289
+ const cwd = process.cwd();
290
+ console.error(`${GOLD}
291
+ \u2588\u2588\u2588\u2588\u2588\u2588 \u2588\u2588\u2588\u2588\u2588\u2588 \u2588\u2588 \u2588\u2588\u2588 \u2588\u2588
292
+ \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588\u2588\u2588 \u2588\u2588
293
+ \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588
294
+ \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588
295
+ \u2588\u2588\u2588\u2588\u2588\u2588 \u2588\u2588\u2588\u2588\u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588\u2588\u2588
296
+ ${RESET}
297
+ ${GOLD}ODIN${RESET} v${VERSION}
298
+ ${DIM}${cwd}${RESET}
299
+ `);
300
+ }
301
+ var program = new Command7();
302
+ program.name("odin").description("CLI for Odin \u2014 the knowledge backbone for Pebble House").version(VERSION);
303
+ program.command("login").description("Authenticate with Odin via browser").action(async () => {
304
+ printBanner();
305
+ try {
306
+ await login();
307
+ console.error(`${GOLD}Logged in successfully.${RESET}`);
308
+ } catch (err) {
309
+ console.error(`Login failed: ${err instanceof Error ? err.message : err}`);
310
+ process.exit(1);
311
+ }
312
+ });
313
+ program.command("logout").description("Clear stored credentials").action(() => {
314
+ clearCredentials();
315
+ console.error("Logged out.");
316
+ });
317
+ program.addCommand(pebblesCmd);
318
+ program.addCommand(docsCmd);
319
+ program.addCommand(decisionsCmd);
320
+ program.addCommand(contextCmd);
321
+ program.addCommand(sessionsCmd);
322
+ program.addCommand(searchCmd);
323
+ program.parseAsync(process.argv).catch((err) => {
324
+ console.error(err instanceof Error ? err.message : err);
325
+ process.exit(1);
326
+ });
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@pebblehouse/odin-cli",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "CLI for Odin — the knowledge backbone for Pebble House",
6
+ "bin": {
7
+ "odin": "./dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "scripts": {
13
+ "build": "tsup",
14
+ "dev": "tsup --watch"
15
+ },
16
+ "keywords": [
17
+ "odin",
18
+ "pebblehouse",
19
+ "cli",
20
+ "knowledge",
21
+ "context"
22
+ ],
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "https://github.com/pebblehouse/odin-web.git",
26
+ "directory": "packages/cli"
27
+ },
28
+ "homepage": "https://www.odin.mu",
29
+ "license": "MIT",
30
+ "engines": {
31
+ "node": ">=18"
32
+ },
33
+ "dependencies": {
34
+ "@supabase/supabase-js": "^2.47.10",
35
+ "commander": "^12.0.0",
36
+ "open": "^10.0.0"
37
+ },
38
+ "devDependencies": {
39
+ "tsup": "^8.0.0",
40
+ "typescript": "^5.3.3"
41
+ }
42
+ }