@pebblehouse/odin-cli 0.3.1 → 0.4.2

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 (2) hide show
  1. package/dist/index.js +158 -32
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { Command as Command13 } from "commander";
4
+ import { Command as Command14 } from "commander";
5
5
 
6
6
  // src/auth/login.ts
7
7
  import { createServer } from "http";
@@ -102,8 +102,13 @@ import { Command } from "commander";
102
102
 
103
103
  // src/auth/refresh.ts
104
104
  import { createClient } from "@supabase/supabase-js";
105
+ import { existsSync as existsSync2, writeFileSync as writeFileSync2, unlinkSync as unlinkSync2, readFileSync as readFileSync2, mkdirSync as mkdirSync2 } from "fs";
106
+ import { dirname } from "path";
105
107
  var SUPABASE_URL = process.env.ODIN_SUPABASE_URL ?? "https://vdiwtiiksdyhlibqrngw.supabase.co";
106
108
  var SUPABASE_ANON_KEY = process.env.ODIN_SUPABASE_ANON_KEY ?? "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InZkaXd0aWlrc2R5aGxpYnFybmd3Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzM2ODM4NDksImV4cCI6MjA4OTI1OTg0OX0.gAccODqqhBnL_npqG7H42EvaT2CWR1P2pqo5NVjKFas";
109
+ var REFRESH_THRESHOLD_SECONDS = 900;
110
+ var LOCK_PATH = CREDENTIALS_PATH + ".lock";
111
+ var LOCK_STALE_MS = 1e4;
107
112
  function createNodeClient() {
108
113
  return createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
109
114
  auth: {
@@ -112,15 +117,46 @@ function createNodeClient() {
112
117
  }
113
118
  });
114
119
  }
115
- async function getValidToken() {
116
- const creds = getCredentials();
117
- if (!creds) {
118
- throw new Error("Not logged in. Run 'odin login' first.");
120
+ function acquireLock() {
121
+ try {
122
+ if (existsSync2(LOCK_PATH)) {
123
+ try {
124
+ const stat = JSON.parse(readFileSync2(LOCK_PATH, "utf-8"));
125
+ if (Date.now() - stat.pid_time > LOCK_STALE_MS) {
126
+ unlinkSync2(LOCK_PATH);
127
+ } else {
128
+ return false;
129
+ }
130
+ } catch {
131
+ try {
132
+ unlinkSync2(LOCK_PATH);
133
+ } catch {
134
+ }
135
+ }
136
+ }
137
+ const dir = dirname(LOCK_PATH);
138
+ if (!existsSync2(dir)) mkdirSync2(dir, { recursive: true });
139
+ writeFileSync2(LOCK_PATH, JSON.stringify({ pid: process.pid, pid_time: Date.now() }), { flag: "wx" });
140
+ return true;
141
+ } catch {
142
+ return false;
119
143
  }
120
- const now = Math.floor(Date.now() / 1e3);
121
- if (creds.expires_at > now + 60) {
122
- return creds.access_token;
144
+ }
145
+ function releaseLock() {
146
+ try {
147
+ unlinkSync2(LOCK_PATH);
148
+ } catch {
149
+ }
150
+ }
151
+ async function waitForLock(timeoutMs = 5e3) {
152
+ const start = Date.now();
153
+ while (Date.now() - start < timeoutMs) {
154
+ if (acquireLock()) return true;
155
+ await new Promise((r) => setTimeout(r, 50));
123
156
  }
157
+ return false;
158
+ }
159
+ async function refreshTokens(creds) {
124
160
  const supabase = createNodeClient();
125
161
  const { data, error } = await supabase.auth.refreshSession({
126
162
  refresh_token: creds.refresh_token
@@ -128,13 +164,42 @@ async function getValidToken() {
128
164
  if (error || !data.session) {
129
165
  throw new Error("Session expired. Run 'odin login' to re-authenticate.");
130
166
  }
167
+ const now = Math.floor(Date.now() / 1e3);
131
168
  const newCreds = {
132
169
  access_token: data.session.access_token,
133
170
  refresh_token: data.session.refresh_token,
134
171
  expires_at: data.session.expires_at ?? now + 3600
135
172
  };
136
173
  saveCredentials(newCreds);
137
- return newCreds.access_token;
174
+ return newCreds;
175
+ }
176
+ async function getValidToken() {
177
+ const creds = getCredentials();
178
+ if (!creds) {
179
+ throw new Error("Not logged in. Run 'odin login' first.");
180
+ }
181
+ const now = Math.floor(Date.now() / 1e3);
182
+ if (creds.expires_at > now + REFRESH_THRESHOLD_SECONDS) {
183
+ return creds.access_token;
184
+ }
185
+ const locked = await waitForLock();
186
+ try {
187
+ const freshCreds = getCredentials();
188
+ if (freshCreds && freshCreds.expires_at > now + REFRESH_THRESHOLD_SECONDS) {
189
+ return freshCreds.access_token;
190
+ }
191
+ const useForRefresh = freshCreds ?? creds;
192
+ const newCreds = await refreshTokens(useForRefresh);
193
+ return newCreds.access_token;
194
+ } catch (err) {
195
+ const retryCreds = getCredentials();
196
+ if (retryCreds && retryCreds.expires_at > now + REFRESH_THRESHOLD_SECONDS) {
197
+ return retryCreds.access_token;
198
+ }
199
+ throw err;
200
+ } finally {
201
+ if (locked) releaseLock();
202
+ }
138
203
  }
139
204
 
140
205
  // src/api-client.ts
@@ -195,7 +260,7 @@ pebblesCmd.command("update <slug>").description("Update a pebble").option("--slu
195
260
 
196
261
  // src/commands/documents.ts
197
262
  import { Command as Command2 } from "commander";
198
- import { readFileSync as readFileSync2 } from "fs";
263
+ import { readFileSync as readFileSync3 } from "fs";
199
264
  var docsCmd = new Command2("docs").description("Manage documents");
200
265
  docsCmd.command("list <slug>").description("List documents for a pebble").action(async (slug) => {
201
266
  const res = await apiRequest(`/pebbles/${slug}/documents`);
@@ -208,7 +273,7 @@ docsCmd.command("get <slug> <type>").description("Get document by type").action(
208
273
  if (res.error) process.exit(1);
209
274
  });
210
275
  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) => {
211
- const content = opts.file ? readFileSync2(opts.file, "utf-8") : "";
276
+ const content = opts.file ? readFileSync3(opts.file, "utf-8") : "";
212
277
  const res = await apiRequest(`/pebbles/${slug}/documents`, {
213
278
  method: "POST",
214
279
  body: { doc_type: type, title: opts.title, content, source: opts.source }
@@ -224,7 +289,7 @@ docsCmd.command("delete <slug> <type>").description("Delete a document").action(
224
289
  if (res.error) process.exit(1);
225
290
  });
226
291
  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) => {
227
- const content = readFileSync2(opts.file, "utf-8");
292
+ const content = readFileSync3(opts.file, "utf-8");
228
293
  const res = await apiRequest(`/pebbles/${slug}/documents/${type}`, {
229
294
  method: "PUT",
230
295
  body: { content, source: opts.source }
@@ -307,7 +372,7 @@ var searchCmd = new Command6("search").description("Full-text search across Odin
307
372
 
308
373
  // src/commands/plans.ts
309
374
  import { Command as Command7 } from "commander";
310
- import { readFileSync as readFileSync3 } from "fs";
375
+ import { readFileSync as readFileSync4 } from "fs";
311
376
  var plansCmd = new Command7("plans").description("Manage plans");
312
377
  plansCmd.command("list <slug>").description("List plans for a pebble").action(async (slug) => {
313
378
  const res = await apiRequest(`/pebbles/${slug}/plans`);
@@ -328,7 +393,7 @@ plansCmd.command("get <slug> <id>").description("Get plan with phases").action(a
328
393
  process.stdout.write(JSON.stringify(result) + "\n");
329
394
  });
330
395
  plansCmd.command("create <slug> <title>").description("Create a plan").option("--description <description>", "Plan description").option("--file <path>", "Read description from file").option("--source <source>", "Source", "claude-code").action(async (slug, title, opts) => {
331
- const description = opts.file ? readFileSync3(opts.file, "utf-8") : opts.description ?? null;
396
+ const description = opts.file ? readFileSync4(opts.file, "utf-8") : opts.description ?? null;
332
397
  const res = await apiRequest(`/pebbles/${slug}/plans`, {
333
398
  method: "POST",
334
399
  body: { title, description, source: opts.source }
@@ -355,28 +420,24 @@ plansCmd.command("delete <slug> <id>").description("Delete a plan").action(async
355
420
  process.stdout.write(JSON.stringify(res) + "\n");
356
421
  if (res.error) process.exit(1);
357
422
  });
358
- plansCmd.command("add-phase <slug> <planId> <title>").description("Add a phase to a plan").requiredOption("--phase-number <number>", "Phase number", parseInt).option("--description <description>", "Phase description").option("--detail-type <type>", "Detail type (e.g. agent_spec, document)").option("--detail-id <id>", "Detail ID (UUID of linked entity)").action(async (slug, planId, title, opts) => {
423
+ plansCmd.command("add-phase <slug> <planId> <title>").description("Add a phase to a plan").requiredOption("--phase-number <number>", "Phase number", parseInt).option("--description <description>", "Phase description").action(async (slug, planId, title, opts) => {
359
424
  const res = await apiRequest(`/pebbles/${slug}/plans/${planId}/phases`, {
360
425
  method: "POST",
361
426
  body: {
362
427
  phase_number: opts.phaseNumber,
363
428
  title,
364
- description: opts.description ?? null,
365
- detail_type: opts.detailType ?? null,
366
- detail_id: opts.detailId ?? null
429
+ description: opts.description ?? null
367
430
  }
368
431
  });
369
432
  process.stdout.write(JSON.stringify(res) + "\n");
370
433
  if (res.error) process.exit(1);
371
434
  });
372
- plansCmd.command("update-phase <slug> <planId> <phaseId>").description("Update a phase").option("--title <title>", "Phase title").option("--status <status>", "Phase status (pending|in_progress|completed|skipped)").option("--notes <notes>", "Phase notes").option("--description <description>", "Phase description").option("--detail-type <type>", "Detail type (e.g. agent_spec, document)").option("--detail-id <id>", "Detail ID (UUID of linked entity)").action(async (slug, planId, phaseId, opts) => {
435
+ plansCmd.command("update-phase <slug> <planId> <phaseId>").description("Update a phase").option("--title <title>", "Phase title").option("--status <status>", "Phase status (pending|in_progress|completed|skipped)").option("--notes <notes>", "Phase notes").option("--description <description>", "Phase description").action(async (slug, planId, phaseId, opts) => {
373
436
  const body = {};
374
437
  if (opts.title) body.title = opts.title;
375
438
  if (opts.status) body.status = opts.status;
376
439
  if (opts.notes) body.notes = opts.notes;
377
440
  if (opts.description) body.description = opts.description;
378
- if (opts.detailType) body.detail_type = opts.detailType;
379
- if (opts.detailId) body.detail_id = opts.detailId;
380
441
  const res = await apiRequest(`/pebbles/${slug}/plans/${planId}/phases/${phaseId}`, {
381
442
  method: "PUT",
382
443
  body
@@ -391,6 +452,25 @@ plansCmd.command("delete-phase <slug> <planId> <phaseId>").description("Delete a
391
452
  process.stdout.write(JSON.stringify(res) + "\n");
392
453
  if (res.error) process.exit(1);
393
454
  });
455
+ plansCmd.command("add-phase-link <slug> <planId> <phaseId>").description("Add a link from a phase to a spec or document").requiredOption("--link-type <type>", "Link type (spec|plan_doc)").requiredOption("--target-type <type>", "Target type (agent_spec|document)").requiredOption("--target-id <id>", "Target entity UUID").action(async (slug, planId, phaseId, opts) => {
456
+ const res = await apiRequest(`/pebbles/${slug}/plans/${planId}/phases/${phaseId}/links`, {
457
+ method: "POST",
458
+ body: {
459
+ link_type: opts.linkType,
460
+ target_type: opts.targetType,
461
+ target_id: opts.targetId
462
+ }
463
+ });
464
+ process.stdout.write(JSON.stringify(res) + "\n");
465
+ if (res.error) process.exit(1);
466
+ });
467
+ plansCmd.command("remove-phase-link <slug> <planId> <phaseId> <linkId>").description("Remove a link from a phase").action(async (slug, planId, phaseId, linkId) => {
468
+ const res = await apiRequest(`/pebbles/${slug}/plans/${planId}/phases/${phaseId}/links/${linkId}`, {
469
+ method: "DELETE"
470
+ });
471
+ process.stdout.write(JSON.stringify(res) + "\n");
472
+ if (res.error) process.exit(1);
473
+ });
394
474
 
395
475
  // src/commands/conversations.ts
396
476
  import { Command as Command8 } from "commander";
@@ -447,7 +527,7 @@ conversationsCmd.command("delete <slug> <id>").description("Delete a conversatio
447
527
 
448
528
  // src/commands/specs.ts
449
529
  import { Command as Command9 } from "commander";
450
- import { readFileSync as readFileSync4 } from "fs";
530
+ import { readFileSync as readFileSync5 } from "fs";
451
531
  var specsCmd = new Command9("specs").description("Manage agent specs");
452
532
  specsCmd.command("list <slug>").description("List specs for a pebble").option("--source <source>", "Filter by source (claude-code, superpowers, etc.)").action(async (slug, opts) => {
453
533
  const params = {};
@@ -462,7 +542,7 @@ specsCmd.command("get <slug> <id>").description("Get a spec by ID").action(async
462
542
  if (res.error) process.exit(1);
463
543
  });
464
544
  specsCmd.command("create <slug> <title>").description("Create a spec").requiredOption("--spec-type <type>", "Spec type (e.g. brainstorm, plan, research)").option("--source <source>", "Source agent", "claude-code").option("--content <content>", "Spec content").option("--file <path>", "Read content from file").option("--session-id <id>", "Link to active session").action(async (slug, title, opts) => {
465
- const content = opts.file ? readFileSync4(opts.file, "utf-8") : opts.content ?? "";
545
+ const content = opts.file ? readFileSync5(opts.file, "utf-8") : opts.content ?? "";
466
546
  const res = await apiRequest(`/pebbles/${slug}/agent-specs`, {
467
547
  method: "POST",
468
548
  body: {
@@ -481,7 +561,7 @@ specsCmd.command("update <slug> <id>").description("Update a spec").option("--ti
481
561
  if (opts.title) body.title = opts.title;
482
562
  if (opts.specType) body.spec_type = opts.specType;
483
563
  if (opts.source) body.source = opts.source;
484
- if (opts.file) body.content = readFileSync4(opts.file, "utf-8");
564
+ if (opts.file) body.content = readFileSync5(opts.file, "utf-8");
485
565
  else if (opts.content) body.content = opts.content;
486
566
  const res = await apiRequest(`/pebbles/${slug}/agent-specs/${id}`, {
487
567
  method: "PUT",
@@ -553,7 +633,7 @@ versionsCmd.command("get <slug> <docType> <versionId>").description("Get a speci
553
633
 
554
634
  // src/commands/guidelines.ts
555
635
  import { Command as Command12 } from "commander";
556
- import { readFileSync as readFileSync5 } from "fs";
636
+ import { readFileSync as readFileSync6 } from "fs";
557
637
  var guidelinesCmd = new Command12("guidelines").description("Manage global guidelines");
558
638
  guidelinesCmd.command("list").description("List guidelines").option("--category <category>", "Filter by category").option("--status <status>", "Filter by status (default: active)").option("--all", "Include deprecated guidelines").action(async (opts) => {
559
639
  const params = new URLSearchParams();
@@ -571,7 +651,7 @@ guidelinesCmd.command("get <id>").description("Get a guideline by ID").action(as
571
651
  if (res.error) process.exit(1);
572
652
  });
573
653
  guidelinesCmd.command("create <title>").description("Create a guideline").option("--category <category>", "Category", "other").option("--content <content>", "Guideline content").option("--file <path>", "Read content from file").option("--source <source>", "Source", "manual").action(async (title, opts) => {
574
- const content = opts.file ? readFileSync5(opts.file, "utf-8") : opts.content ?? "";
654
+ const content = opts.file ? readFileSync6(opts.file, "utf-8") : opts.content ?? "";
575
655
  const res = await apiRequest("/guidelines", {
576
656
  method: "POST",
577
657
  body: { category: opts.category, title, content, source: opts.source }
@@ -582,7 +662,7 @@ guidelinesCmd.command("create <title>").description("Create a guideline").option
582
662
  guidelinesCmd.command("update <id>").description("Update a guideline").option("--title <title>", "New title").option("--content <content>", "New content").option("--file <path>", "Read content from file").option("--category <category>", "New category").option("--status <status>", "New status (active|deprecated)").option("--source <source>", "Source").action(async (id, opts) => {
583
663
  const body = {};
584
664
  if (opts.title) body.title = opts.title;
585
- if (opts.file) body.content = readFileSync5(opts.file, "utf-8");
665
+ if (opts.file) body.content = readFileSync6(opts.file, "utf-8");
586
666
  else if (opts.content) body.content = opts.content;
587
667
  if (opts.category) body.category = opts.category;
588
668
  if (opts.status) body.status = opts.status;
@@ -615,12 +695,57 @@ guidelinesCmd.command("deprecate <id>").description("Deprecate a guideline").act
615
695
  if (res.error) process.exit(1);
616
696
  });
617
697
 
698
+ // src/commands/observations.ts
699
+ import { Command as Command13 } from "commander";
700
+ var observationsCmd = new Command13("observations").description(
701
+ "View and synthesize observations"
702
+ );
703
+ observationsCmd.command("list <slug>").description("List observations for a pebble").option("--limit <n>", "Max results", "50").option("--offset <n>", "Offset for pagination", "0").action(async (slug, opts) => {
704
+ const res = await apiRequest(
705
+ `/pebbles/${slug}/observations`,
706
+ {
707
+ params: { limit: opts.limit, offset: opts.offset }
708
+ }
709
+ );
710
+ process.stdout.write(JSON.stringify(res) + "\n");
711
+ if (res.error) process.exit(1);
712
+ });
713
+ observationsCmd.command("synthesize [slug]").description("Trigger observation synthesis (requires running worker)").action(async (slug) => {
714
+ try {
715
+ const body = {};
716
+ if (slug) body.slug = slug;
717
+ const res = await fetch("http://127.0.0.1:7433/synthesize", {
718
+ method: "POST",
719
+ headers: { "Content-Type": "application/json" },
720
+ body: JSON.stringify(body)
721
+ });
722
+ if (!res.ok) {
723
+ const text = await res.text();
724
+ console.error(`Worker returned ${res.status}: ${text}`);
725
+ process.exit(1);
726
+ }
727
+ const data = await res.json();
728
+ process.stdout.write(JSON.stringify(data) + "\n");
729
+ } catch (err) {
730
+ if (err instanceof Error && (err.message.includes("ECONNREFUSED") || err.message.includes("fetch failed"))) {
731
+ console.error(
732
+ "Worker not running. Start a Claude Code session to launch the worker."
733
+ );
734
+ } else {
735
+ console.error(
736
+ `Failed: ${err instanceof Error ? err.message : err}`
737
+ );
738
+ }
739
+ process.exit(1);
740
+ }
741
+ });
742
+
618
743
  // src/index.ts
619
- import { readFileSync as readFileSync6 } from "fs";
744
+ import { readFileSync as readFileSync7 } from "fs";
620
745
  import { fileURLToPath } from "url";
621
- import { dirname, resolve } from "path";
622
- var __dirname = dirname(fileURLToPath(import.meta.url));
623
- var pkg = JSON.parse(readFileSync6(resolve(__dirname, "../package.json"), "utf-8"));
746
+ import { dirname as dirname2, resolve } from "path";
747
+ var __dirname = dirname2(fileURLToPath(import.meta.url));
748
+ var pkg = JSON.parse(readFileSync7(resolve(__dirname, "../package.json"), "utf-8"));
624
749
  var GOLD = "\x1B[33m";
625
750
  var DIM = "\x1B[2m";
626
751
  var RESET = "\x1B[0m";
@@ -638,7 +763,7 @@ ${RESET}
638
763
  ${DIM}${cwd}${RESET}
639
764
  `);
640
765
  }
641
- var program = new Command13();
766
+ var program = new Command14();
642
767
  program.name("odin").description("CLI for Odin \u2014 the knowledge backbone for Pebble House").version(VERSION);
643
768
  program.command("login").description("Authenticate with Odin via browser").action(async () => {
644
769
  printBanner();
@@ -666,6 +791,7 @@ program.addCommand(specsCmd);
666
791
  program.addCommand(executionsCmd);
667
792
  program.addCommand(versionsCmd);
668
793
  program.addCommand(guidelinesCmd);
794
+ program.addCommand(observationsCmd);
669
795
  process.on("exit", () => {
670
796
  process.stderr.write("\n\n\n\n\n");
671
797
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pebblehouse/odin-cli",
3
- "version": "0.3.1",
3
+ "version": "0.4.2",
4
4
  "type": "module",
5
5
  "description": "CLI for Odin — the knowledge backbone for Pebble House",
6
6
  "bin": {