@pebblehouse/odin-cli 0.16.0 → 0.18.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 +8 -0
  2. package/dist/index.js +287 -92
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -65,6 +65,14 @@ odin search "authentication" # Cross-pebble
65
65
  odin search "RLS policies" --pebble odin # Scoped
66
66
  ```
67
67
 
68
+ ### Tickets
69
+
70
+ ```bash
71
+ odin tickets resolve ODIN-12 # Resolve a ticket key to its bet or task
72
+ odin tasks promote <slug> <task-id> --file ./bet.md # Promote a task to a bet (keeps its ticket ID)
73
+ odin tasks create <slug> "Fix" --follow-up-of ODIN-12 # Allocate ODIN-12A as a follow-up
74
+ ```
75
+
68
76
  ### Auth
69
77
 
70
78
  ```bash
package/dist/index.js CHANGED
@@ -1,12 +1,23 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { Command as Command23 } from "commander";
4
+ import { Command as Command24 } from "commander";
5
5
 
6
6
  // src/auth/login.ts
7
- import { createServer } from "http";
8
7
  import open from "open";
9
8
 
9
+ // src/auth/headless.ts
10
+ function isHeadless(env = process.env, platform = process.platform) {
11
+ if (env.ODIN_FORCE_BROWSER === "1") return false;
12
+ if (env.SSH_CONNECTION || env.SSH_TTY) return true;
13
+ if (platform === "linux" && !env.DISPLAY && !env.WAYLAND_DISPLAY) return true;
14
+ if (env.CI) return true;
15
+ return false;
16
+ }
17
+
18
+ // src/auth/login-loopback.ts
19
+ import { createServer } from "http";
20
+
10
21
  // src/auth/credentials.ts
11
22
  import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync } from "fs";
12
23
  import { join } from "path";
@@ -34,69 +45,228 @@ function clearCredentials() {
34
45
  }
35
46
  }
36
47
 
37
- // src/auth/login.ts
48
+ // src/auth/pkce.ts
49
+ import { randomBytes, createHash } from "crypto";
50
+ function generateVerifier() {
51
+ return randomBytes(64).toString("base64url");
52
+ }
53
+ function challengeFromVerifier(verifier) {
54
+ return createHash("sha256").update(verifier).digest("base64url");
55
+ }
56
+ function generateState() {
57
+ return randomBytes(32).toString("hex");
58
+ }
59
+
60
+ // src/auth/login-loopback.ts
38
61
  var API_URL = process.env.ODIN_API_URL ?? "https://www.odin.mu";
39
- async function login() {
40
- return new Promise((resolve3, 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>
62
+ var CLIENT_ID = "odin-cli";
63
+ var TIMEOUT_MS = 18e4;
64
+ var SUCCESS_HTML = `<!DOCTYPE html>
66
65
  <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
66
  <div>
68
67
  <p style="color:#f2c94c;font-size:1.5rem;margin-bottom:0.5rem">&#10003; Login successful</p>
69
68
  <p style="color:#666;font-size:0.9rem">You can close this tab.</p>
70
69
  </div>
71
- </body></html>`);
72
- server.close();
73
- resolve3();
74
- } else {
70
+ </body></html>`;
71
+ function buildAuthorizeUrl(params) {
72
+ const q = new URLSearchParams({
73
+ response_type: "code",
74
+ client_id: CLIENT_ID,
75
+ redirect_uri: params.redirectUri,
76
+ state: params.state,
77
+ code_challenge: params.challenge,
78
+ code_challenge_method: "S256"
79
+ });
80
+ return `${API_URL}/api/oauth/authorize?${q.toString()}`;
81
+ }
82
+ async function exchangeCode(args) {
83
+ const res = await fetch(`${API_URL}/api/oauth/token`, {
84
+ method: "POST",
85
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
86
+ body: new URLSearchParams({
87
+ grant_type: "authorization_code",
88
+ code: args.code,
89
+ code_verifier: args.verifier,
90
+ redirect_uri: args.redirectUri,
91
+ client_id: CLIENT_ID
92
+ }).toString()
93
+ });
94
+ const json = await res.json().catch(() => ({}));
95
+ if (!res.ok) {
96
+ throw new Error(`Token exchange failed: ${json.error ?? res.status}`);
97
+ }
98
+ return {
99
+ access_token: json.access_token,
100
+ refresh_token: json.refresh_token,
101
+ expires_at: Math.floor(Date.now() / 1e3) + (json.expires_in ?? 3600)
102
+ };
103
+ }
104
+ function loginLoopback(onUrl) {
105
+ return new Promise((resolve3, reject) => {
106
+ const verifier = generateVerifier();
107
+ const challenge = challengeFromVerifier(verifier);
108
+ const state = generateState();
109
+ let redirectUri = "";
110
+ let settled = false;
111
+ const finish = (fn) => {
112
+ if (settled) return;
113
+ settled = true;
114
+ server.close();
115
+ fn();
116
+ };
117
+ const server = createServer(async (req, res) => {
118
+ if (req.method !== "GET" || !req.url?.startsWith("/callback")) {
75
119
  res.writeHead(404);
76
120
  res.end();
121
+ return;
122
+ }
123
+ const url = new URL(req.url, redirectUri || "http://127.0.0.1");
124
+ const code = url.searchParams.get("code");
125
+ const returnedState = url.searchParams.get("state");
126
+ const error = url.searchParams.get("error");
127
+ if (error) {
128
+ res.writeHead(400, { "Content-Type": "text/html" });
129
+ res.end(`<html><body><p>Login failed: ${error}</p></body></html>`);
130
+ finish(() => reject(new Error(`Authorization failed: ${error}`)));
131
+ return;
132
+ }
133
+ if (!code || returnedState !== state) {
134
+ res.writeHead(400, { "Content-Type": "text/html" });
135
+ res.end("<html><body><p>Invalid callback. Login failed.</p></body></html>");
136
+ finish(
137
+ () => reject(new Error("Invalid callback: state mismatch or missing code"))
138
+ );
139
+ return;
140
+ }
141
+ try {
142
+ const creds = await exchangeCode({ code, verifier, redirectUri });
143
+ saveCredentials(creds);
144
+ res.writeHead(200, { "Content-Type": "text/html" });
145
+ res.end(SUCCESS_HTML);
146
+ finish(resolve3);
147
+ } catch (err) {
148
+ res.writeHead(500, { "Content-Type": "text/html" });
149
+ res.end("<html><body><p>Token exchange failed.</p></body></html>");
150
+ finish(() => reject(err));
77
151
  }
78
152
  });
79
- server.listen(0, "127.0.0.1", () => {
153
+ server.listen(0, "127.0.0.1", async () => {
80
154
  const addr = server.address();
81
155
  if (!addr || typeof addr === "string") {
82
- reject(new Error("Failed to start callback server"));
156
+ finish(() => reject(new Error("Failed to start callback server")));
83
157
  return;
84
158
  }
85
- const port = addr.port;
86
- const authUrl = `${API_URL}/auth/cli?port=${port}`;
87
- console.error(`Opening browser for authentication...`);
159
+ redirectUri = `http://127.0.0.1:${addr.port}/callback`;
160
+ const authUrl = buildAuthorizeUrl({ redirectUri, state, challenge });
161
+ console.error("Opening browser for authentication...");
88
162
  console.error(`If the browser doesn't open, visit: ${authUrl}`);
89
- open(authUrl).catch(() => {
90
- });
163
+ try {
164
+ await onUrl(authUrl);
165
+ } catch (err) {
166
+ finish(() => reject(err));
167
+ }
91
168
  });
92
- const timeout = setTimeout(() => {
93
- server.close();
94
- reject(new Error("Login timed out. Try again."));
95
- }, 12e4);
169
+ const timeout = setTimeout(
170
+ () => finish(() => reject(new Error("Login timed out. Try 'odin login --device'."))),
171
+ TIMEOUT_MS
172
+ );
96
173
  server.on("close", () => clearTimeout(timeout));
97
174
  });
98
175
  }
99
176
 
177
+ // src/auth/login-device.ts
178
+ var API_URL2 = process.env.ODIN_API_URL ?? "https://www.odin.mu";
179
+ var CLIENT_ID2 = "odin-cli";
180
+ var DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
181
+ async function requestDeviceCode() {
182
+ const res = await fetch(`${API_URL2}/api/oauth/device/code`, {
183
+ method: "POST",
184
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
185
+ body: new URLSearchParams({ client_id: CLIENT_ID2 }).toString()
186
+ });
187
+ if (!res.ok) {
188
+ throw new Error(`Could not start device login (HTTP ${res.status}).`);
189
+ }
190
+ return await res.json();
191
+ }
192
+ async function pollOnce(deviceCode) {
193
+ const res = await fetch(`${API_URL2}/api/oauth/token`, {
194
+ method: "POST",
195
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
196
+ body: new URLSearchParams({
197
+ grant_type: DEVICE_GRANT,
198
+ device_code: deviceCode,
199
+ client_id: CLIENT_ID2
200
+ }).toString()
201
+ });
202
+ const json = await res.json().catch(() => ({}));
203
+ if (res.ok) {
204
+ return {
205
+ creds: {
206
+ access_token: json.access_token,
207
+ refresh_token: json.refresh_token,
208
+ expires_at: Math.floor(Date.now() / 1e3) + (json.expires_in ?? 3600)
209
+ }
210
+ };
211
+ }
212
+ return { error: json.error ?? `http_${res.status}` };
213
+ }
214
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
215
+ async function loginDevice(deps = {}) {
216
+ const nap = deps.sleepFn ?? sleep;
217
+ const start = await requestDeviceCode();
218
+ console.error("");
219
+ console.error(` Open: ${start.verification_uri}`);
220
+ console.error(` Code: ${start.user_code}`);
221
+ console.error("");
222
+ console.error("Waiting for approval...");
223
+ let interval = Math.max(1, start.interval || 5);
224
+ const deadline = Date.now() + start.expires_in * 1e3;
225
+ while (Date.now() < deadline) {
226
+ await nap(interval * 1e3);
227
+ const { creds, error } = await pollOnce(start.device_code);
228
+ if (creds) {
229
+ saveCredentials(creds);
230
+ return;
231
+ }
232
+ if (error === "authorization_pending") continue;
233
+ if (error === "slow_down") {
234
+ interval += 5;
235
+ continue;
236
+ }
237
+ if (error === "expired_token") {
238
+ throw new Error("The device code expired. Run 'odin login --device' again.");
239
+ }
240
+ if (error === "access_denied") {
241
+ throw new Error("Authorization was denied.");
242
+ }
243
+ throw new Error(`Device login failed: ${error}`);
244
+ }
245
+ throw new Error("The device code expired. Run 'odin login --device' again.");
246
+ }
247
+
248
+ // src/auth/login.ts
249
+ async function login(options = {}) {
250
+ if (options.device || isHeadless()) {
251
+ return loginDevice();
252
+ }
253
+ try {
254
+ return await loginLoopback(async (url) => {
255
+ await open(url);
256
+ });
257
+ } catch (err) {
258
+ if (isOpenFailure(err)) {
259
+ console.error("Could not open a browser \u2014 falling back to device login.");
260
+ return loginDevice();
261
+ }
262
+ throw err;
263
+ }
264
+ }
265
+ function isOpenFailure(err) {
266
+ if (!(err instanceof Error)) return false;
267
+ return /spawn|ENOENT|xdg-open|Exited with code/i.test(err.message);
268
+ }
269
+
100
270
  // src/commands/pebbles.ts
101
271
  import { Command } from "commander";
102
272
 
@@ -237,10 +407,10 @@ pebblesCmd.command("get <slug>").description("Get pebble details").action(async
237
407
  process.stdout.write(JSON.stringify(res) + "\n");
238
408
  if (res.error) process.exit(1);
239
409
  });
240
- 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) => {
410
+ pebblesCmd.command("create <slug>").description("Create a pebble").requiredOption("--name <name>", "Pebble name").option("--status <status>", "Initial status", "idea").option("--summary <summary>", "Summary").option("--ticket-prefix <PREFIX>", "Four uppercase letters for ticket IDs (derived from slug when omitted)").action(async (slug, opts) => {
241
411
  const res = await apiRequest("/pebbles", {
242
412
  method: "POST",
243
- body: { slug, name: opts.name, status: opts.status, summary: opts.summary }
413
+ body: { slug, name: opts.name, status: opts.status, summary: opts.summary, ticket_prefix: opts.ticketPrefix }
244
414
  });
245
415
  process.stdout.write(JSON.stringify(res) + "\n");
246
416
  if (res.error) process.exit(1);
@@ -1072,11 +1242,11 @@ betsCmd.command("get <slug> <id>").description("Get a bet by ID").action(async (
1072
1242
  process.stdout.write(JSON.stringify(res) + "\n");
1073
1243
  if (res.error) process.exit(1);
1074
1244
  });
1075
- betsCmd.command("create <slug> <title>").description("Create a bet").option("--source <source>", "Source agent", "claude-code").option("--content <content>", "Bet content").option("--file <path>", "Read content from file").option("--status <status>", "Bet status (pitch, shaped, betting, active, blocked, building, done, archived)").option("--priority <priority>", "Bet priority").option("--appetite <appetite>", "Bet appetite").action(async (slug, title, opts) => {
1245
+ betsCmd.command("create <slug> <title>").description("Create a bet").option("--source <source>", "Source agent", "claude-code").option("--content <content>", "Bet content").option("--file <path>", "Read content from file").option("--status <status>", "Bet status (pitch, shaped, betting, active, blocked, building, done, archived)").option("--priority <priority>", "Bet priority").option("--appetite <appetite>", "Bet appetite").option("--follow-up-of <key-or-uuid>", "Allocate a suffixed follow-up ticket in this ticket's family (e.g. ODIN-12 \u2192 ODIN-12A)").action(async (slug, title, opts) => {
1076
1246
  const content = opts.file ? readFileSync10(opts.file, "utf-8") : opts.content ?? "";
1077
1247
  const res = await apiRequest(`/pebbles/${slug}/bets`, {
1078
1248
  method: "POST",
1079
- body: { title, source: opts.source, content, status: opts.status, priority: opts.priority, appetite: opts.appetite }
1249
+ body: { title, source: opts.source, content, status: opts.status, priority: opts.priority, appetite: opts.appetite, follow_up_of: opts.followUpOf }
1080
1250
  });
1081
1251
  process.stdout.write(JSON.stringify(res) + "\n");
1082
1252
  if (res.error) process.exit(1);
@@ -1105,10 +1275,24 @@ betsCmd.command("delete <slug> <id>").description("Delete a bet").action(async (
1105
1275
  });
1106
1276
  betsCmd.addCommand(depsCmd);
1107
1277
 
1108
- // src/commands/bet-comments.ts
1278
+ // src/commands/tickets.ts
1109
1279
  import { Command as Command15 } from "commander";
1280
+ var ticketsCmd = new Command15("tickets").description("Resolve human-readable ticket IDs");
1281
+ ticketsCmd.command("resolve <key>").description("Resolve a ticket key (e.g. ODIN-12, RATE-36A) to its bet or task, with historical candidates").action(async (key) => {
1282
+ const res = await apiRequest(`/tickets/${encodeURIComponent(key.trim().toUpperCase())}`);
1283
+ process.stdout.write(JSON.stringify(res) + "\n");
1284
+ if (res.data?.legacy_ambiguous) {
1285
+ const others = res.data.candidates.filter((c) => c.entity_id !== res.data.entity_id);
1286
+ process.stderr.write(`note: ${key.toUpperCase()} was a duplicated legacy label; also matches ${others.map((c) => c.ticket_key ?? c.entity_id).join(", ")}
1287
+ `);
1288
+ }
1289
+ if (res.error) process.exit(1);
1290
+ });
1291
+
1292
+ // src/commands/bet-comments.ts
1293
+ import { Command as Command16 } from "commander";
1110
1294
  import { readFileSync as readFileSync11 } from "fs";
1111
- var betCommentsCmd = new Command15("bet-comments").description("Manage bet comments");
1295
+ var betCommentsCmd = new Command16("bet-comments").description("Manage bet comments");
1112
1296
  betCommentsCmd.command("list <slug> <betId>").description("List comments for a bet (oldest first)").action(async (slug, betId) => {
1113
1297
  const res = await apiRequest(`/pebbles/${slug}/bets/${betId}/comments`);
1114
1298
  process.stdout.write(JSON.stringify(res) + "\n");
@@ -1135,8 +1319,9 @@ betCommentsCmd.command("create <slug> <betId> [body]").description("Post a comme
1135
1319
  });
1136
1320
 
1137
1321
  // src/commands/tasks.ts
1138
- import { Command as Command16 } from "commander";
1139
- var tasksCmd = new Command16("tasks").description("Manage tasks");
1322
+ import { Command as Command17 } from "commander";
1323
+ import { readFileSync as readFileSync12 } from "fs";
1324
+ var tasksCmd = new Command17("tasks").description("Manage tasks");
1140
1325
  withListFlags(
1141
1326
  tasksCmd.command("list <slug>").description("List tasks for a pebble").option("--status <status>", "Filter by status").option("--bet <bet-id>", "Filter by bet ID")
1142
1327
  ).action(async (slug, opts) => {
@@ -1157,10 +1342,10 @@ tasksCmd.command("get <slug> <id>").description("Get a task by ID").action(async
1157
1342
  process.stdout.write(JSON.stringify(res) + "\n");
1158
1343
  if (res.error) process.exit(1);
1159
1344
  });
1160
- tasksCmd.command("create <slug> <title>").description("Create a task").option("--source <source>", "Source agent", "claude-code").option("--description <description>", "Task description").option("--bet <bet-id>", "Parent bet ID").option("--status <status>", "Task status").option("--priority <priority>", "Task priority").action(async (slug, title, opts) => {
1345
+ tasksCmd.command("create <slug> <title>").description("Create a task").option("--source <source>", "Source agent", "claude-code").option("--description <description>", "Task description").option("--bet <bet-id>", "Parent bet ID").option("--status <status>", "Task status").option("--priority <priority>", "Task priority").option("--follow-up-of <key-or-uuid>", "Allocate a suffixed follow-up ticket in this ticket's family (e.g. ODIN-12 \u2192 ODIN-12A)").action(async (slug, title, opts) => {
1161
1346
  const res = await apiRequest(`/pebbles/${slug}/tasks`, {
1162
1347
  method: "POST",
1163
- body: { title, source: opts.source, description: opts.description, bet_id: opts.bet, status: opts.status, priority: opts.priority }
1348
+ body: { title, source: opts.source, description: opts.description, bet_id: opts.bet, status: opts.status, priority: opts.priority, follow_up_of: opts.followUpOf }
1164
1349
  });
1165
1350
  process.stdout.write(JSON.stringify(res) + "\n");
1166
1351
  if (res.error) process.exit(1);
@@ -1179,6 +1364,15 @@ tasksCmd.command("update <slug> <id>").description("Update a task").option("--ti
1179
1364
  process.stdout.write(JSON.stringify(res) + "\n");
1180
1365
  if (res.error) process.exit(1);
1181
1366
  });
1367
+ tasksCmd.command("promote <slug> <id>").description("Promote a task to a bet, keeping its ticket ID and history").option("--content <content>", "Shaped bet markdown").option("--file <path>", "Read bet markdown from file").option("--status <status>", "Bet status (pitch | shaped)", "shaped").option("--priority <priority>", "Bet priority").action(async (slug, id, opts) => {
1368
+ const content = opts.file ? readFileSync12(opts.file, "utf-8") : opts.content;
1369
+ const res = await apiRequest(`/pebbles/${slug}/tasks/${id}/promote`, {
1370
+ method: "POST",
1371
+ body: { content, status: opts.status, priority: opts.priority }
1372
+ });
1373
+ process.stdout.write(JSON.stringify(res) + "\n");
1374
+ if (res.error) process.exit(1);
1375
+ });
1182
1376
  tasksCmd.command("delete <slug> <id>").description("Delete a task").action(async (slug, id) => {
1183
1377
  const res = await apiRequest(`/pebbles/${slug}/tasks/${id}`, {
1184
1378
  method: "DELETE"
@@ -1188,9 +1382,9 @@ tasksCmd.command("delete <slug> <id>").description("Delete a task").action(async
1188
1382
  });
1189
1383
 
1190
1384
  // src/commands/roadmaps.ts
1191
- import { Command as Command17 } from "commander";
1192
- import { readFileSync as readFileSync12 } from "fs";
1193
- var roadmapsCmd = new Command17("roadmaps").description("Manage roadmaps");
1385
+ import { Command as Command18 } from "commander";
1386
+ import { readFileSync as readFileSync13 } from "fs";
1387
+ var roadmapsCmd = new Command18("roadmaps").description("Manage roadmaps");
1194
1388
  roadmapsCmd.command("list <slug>").description("List roadmaps for a pebble").option("--status <status>", "Filter by status").action(async (slug, opts) => {
1195
1389
  const params = {};
1196
1390
  if (opts.status) params.status = opts.status;
@@ -1204,7 +1398,7 @@ roadmapsCmd.command("get <slug> <id>").description("Get a roadmap by ID (include
1204
1398
  if (res.error) process.exit(1);
1205
1399
  });
1206
1400
  roadmapsCmd.command("create <slug> <title>").description("Create a roadmap").option("--source <source>", "Source agent", "claude-code").option("--content <content>", "Roadmap content").option("--file <path>", "Read content from file").option("--status <status>", "Roadmap status").action(async (slug, title, opts) => {
1207
- const content = opts.file ? readFileSync12(opts.file, "utf-8") : opts.content ?? "";
1401
+ const content = opts.file ? readFileSync13(opts.file, "utf-8") : opts.content ?? "";
1208
1402
  const res = await apiRequest(`/pebbles/${slug}/roadmaps`, {
1209
1403
  method: "POST",
1210
1404
  body: { title, source: opts.source, content, status: opts.status }
@@ -1215,7 +1409,7 @@ roadmapsCmd.command("create <slug> <title>").description("Create a roadmap").opt
1215
1409
  roadmapsCmd.command("update <slug> <id>").description("Update a roadmap").option("--title <title>", "New title").option("--content <content>", "New content").option("--file <path>", "Read content from file").option("--status <status>", "New status").action(async (slug, id, opts) => {
1216
1410
  const body = {};
1217
1411
  if (opts.title) body.title = opts.title;
1218
- if (opts.file) body.content = readFileSync12(opts.file, "utf-8");
1412
+ if (opts.file) body.content = readFileSync13(opts.file, "utf-8");
1219
1413
  else if (opts.content) body.content = opts.content;
1220
1414
  if (opts.status) body.status = opts.status;
1221
1415
  const res = await apiRequest(`/pebbles/${slug}/roadmaps/${id}`, {
@@ -1234,11 +1428,11 @@ roadmapsCmd.command("delete <slug> <id>").description("Delete a roadmap").action
1234
1428
  });
1235
1429
 
1236
1430
  // src/commands/persona-runs.ts
1237
- import { Command as Command18 } from "commander";
1238
- import { readFileSync as readFileSync13 } from "fs";
1431
+ import { Command as Command19 } from "commander";
1432
+ import { readFileSync as readFileSync14 } from "fs";
1239
1433
  import { join as join2 } from "path";
1240
1434
  import { homedir as homedir2 } from "os";
1241
- var personaRunsCmd = new Command18("persona-runs").description(
1435
+ var personaRunsCmd = new Command19("persona-runs").description(
1242
1436
  "Manage per-persona per-session token usage rows"
1243
1437
  );
1244
1438
  personaRunsCmd.command("list <slug>").description("List persona runs for a pebble").option("--bet-id <id>", "Filter by bet_id").option("--plan-id <id>", "Filter by plan_id").option("--persona <name>", "Filter by persona name").option("--limit <n>", "Max rows", "50").action(async (slug, opts) => {
@@ -1262,7 +1456,7 @@ personaRunsCmd.command("get <slug> <id>").description("Get a persona run by id")
1262
1456
  if (res.error) process.exit(1);
1263
1457
  });
1264
1458
  personaRunsCmd.command("create <slug>").description("Create/upsert a persona run from a JSON file").requiredOption("--file <path>", "JSON payload matching PersonaRunCreate").action(async (slug, opts) => {
1265
- const body = JSON.parse(readFileSync13(opts.file, "utf-8"));
1459
+ const body = JSON.parse(readFileSync14(opts.file, "utf-8"));
1266
1460
  const res = await apiRequest(`/pebbles/${slug}/persona-runs`, {
1267
1461
  method: "POST",
1268
1462
  body
@@ -1274,7 +1468,7 @@ personaRunsCmd.command("capture").description("Read marker + transcript, compute
1274
1468
  const markerPath = opts.marker ?? join2(homedir2(), ".claude", "odin-context", `${opts.sessionId}.json`);
1275
1469
  let marker = {};
1276
1470
  try {
1277
- marker = JSON.parse(readFileSync13(markerPath, "utf-8"));
1471
+ marker = JSON.parse(readFileSync14(markerPath, "utf-8"));
1278
1472
  } catch {
1279
1473
  process.exit(0);
1280
1474
  }
@@ -1284,7 +1478,7 @@ personaRunsCmd.command("capture").description("Read marker + transcript, compute
1284
1478
  let input = 0, output = 0, cacheRead = 0, cacheCreate = 0;
1285
1479
  let minTs = null, maxTs = null;
1286
1480
  try {
1287
- const raw = readFileSync13(opts.transcript, "utf-8");
1481
+ const raw = readFileSync14(opts.transcript, "utf-8");
1288
1482
  for (const line of raw.split("\n")) {
1289
1483
  if (!line.trim()) continue;
1290
1484
  let obj;
@@ -1335,9 +1529,9 @@ personaRunsCmd.command("capture").description("Read marker + transcript, compute
1335
1529
  });
1336
1530
 
1337
1531
  // src/commands/blog.ts
1338
- import { Command as Command19 } from "commander";
1339
- import { readFileSync as readFileSync14 } from "fs";
1340
- var blogCmd = new Command19("blog").description("Manage blog posts");
1532
+ import { Command as Command20 } from "commander";
1533
+ import { readFileSync as readFileSync15 } from "fs";
1534
+ var blogCmd = new Command20("blog").description("Manage blog posts");
1341
1535
  blogCmd.command("list <slug>").description("List blog posts for a pebble").option("--status <status>", "Filter by status (draft, review, scheduled, published, archived)").option("--tag <tag>", "Filter by tag").option("--page <page>", "Page number", "1").option("--per-page <perPage>", "Results per page", "20").action(async (slug, opts) => {
1342
1536
  const params = {};
1343
1537
  if (opts.status) params.status = opts.status;
@@ -1356,7 +1550,7 @@ blogCmd.command("get <slug> <idOrSlug>").description("Get a blog post by UUID or
1356
1550
  blogCmd.command("create <slug> <title>").description("Create a blog post").requiredOption("--post-slug <postSlug>", "URL slug for the post").option("--blocks <blocks>", "Content blocks as JSON string").option("--file <path>", "Read content blocks from JSON file").option("--status <status>", "Initial status (draft, review, scheduled)", "draft").option("--scheduled-publish-at <iso>", "UTC ISO timestamp for scheduled publishing (required when --status scheduled)").option("--metadata <metadata>", "Post metadata as JSON string").option("--campaign-id <campaignId>", "Link to a marketing campaign").action(async (slug, title, opts) => {
1357
1551
  let blocks = [];
1358
1552
  if (opts.file) {
1359
- blocks = JSON.parse(readFileSync14(opts.file, "utf-8"));
1553
+ blocks = JSON.parse(readFileSync15(opts.file, "utf-8"));
1360
1554
  } else if (opts.blocks) {
1361
1555
  blocks = JSON.parse(opts.blocks);
1362
1556
  }
@@ -1380,7 +1574,7 @@ blogCmd.command("update <slug> <id>").description("Update a blog post").option("
1380
1574
  const body = {};
1381
1575
  if (opts.title) body.title = opts.title;
1382
1576
  if (opts.postSlug) body.slug = opts.postSlug;
1383
- if (opts.file) body.blocks = JSON.parse(readFileSync14(opts.file, "utf-8"));
1577
+ if (opts.file) body.blocks = JSON.parse(readFileSync15(opts.file, "utf-8"));
1384
1578
  else if (opts.blocks) body.blocks = JSON.parse(opts.blocks);
1385
1579
  if (opts.status) body.status = opts.status;
1386
1580
  if (opts.scheduledPublishAt !== void 0)
@@ -1402,8 +1596,8 @@ blogCmd.command("delete <slug> <id>").description("Delete a blog post").action(a
1402
1596
  });
1403
1597
 
1404
1598
  // src/commands/api-keys.ts
1405
- import { Command as Command20 } from "commander";
1406
- var apiKeysCmd = new Command20("api-keys").description("Manage API keys for server-to-server access");
1599
+ import { Command as Command21 } from "commander";
1600
+ var apiKeysCmd = new Command21("api-keys").description("Manage API keys for server-to-server access");
1407
1601
  apiKeysCmd.command("list <slug>").description("List API keys for a pebble").action(async (slug) => {
1408
1602
  const res = await apiRequest(`/pebbles/${slug}/api-keys`);
1409
1603
  process.stdout.write(JSON.stringify(res) + "\n");
@@ -1445,7 +1639,7 @@ apiKeysCmd.command("delete <slug> <id>").description("Permanently delete an API
1445
1639
  });
1446
1640
 
1447
1641
  // src/commands/pebble/index.ts
1448
- import { Command as Command21 } from "commander";
1642
+ import { Command as Command22 } from "commander";
1449
1643
 
1450
1644
  // src/commands/pebble/provisioner.ts
1451
1645
  async function runProvisioner(ctx, steps) {
@@ -1565,15 +1759,15 @@ var githubRepoStep = {
1565
1759
  };
1566
1760
  var githubSecretStep = {
1567
1761
  id: "github-secret",
1568
- label: "GitHub Actions secret DOPPLER_TOKEN_PRD",
1762
+ label: "GitHub Actions secret DOPPLER_TOKEN",
1569
1763
  async check(ctx) {
1570
1764
  const cli = await run("gh", ["--version"]);
1571
1765
  if (!cli.ok) {
1572
1766
  return { status: "blocked", detail: "GitHub CLI not found. Install + run `gh auth login`." };
1573
1767
  }
1574
1768
  const list = await run("gh", ["secret", "list", "-R", `${ORG}/${ctx.slug}`]);
1575
- if (list.ok && /DOPPLER_TOKEN_PRD/.test(list.stdout)) {
1576
- return { status: "satisfied", detail: "DOPPLER_TOKEN_PRD already set" };
1769
+ if (list.ok && /\bDOPPLER_TOKEN\b/.test(list.stdout)) {
1770
+ return { status: "satisfied", detail: "DOPPLER_TOKEN already set" };
1577
1771
  }
1578
1772
  return null;
1579
1773
  },
@@ -1581,7 +1775,7 @@ var githubSecretStep = {
1581
1775
  if (ctx.dryRun) {
1582
1776
  return {
1583
1777
  status: "applied",
1584
- detail: "[dry-run] would mint scoped prd token and set DOPPLER_TOKEN_PRD"
1778
+ detail: "[dry-run] would mint scoped prd token and set DOPPLER_TOKEN"
1585
1779
  };
1586
1780
  }
1587
1781
  const token = await run("doppler", [
@@ -1599,13 +1793,13 @@ var githubSecretStep = {
1599
1793
  return { status: "failed", detail: `doppler token mint failed: ${token.stderr}` };
1600
1794
  }
1601
1795
  const set = await setSecretViaStdin(
1602
- ["secret", "set", "DOPPLER_TOKEN_PRD", "-R", `${ORG}/${ctx.slug}`],
1796
+ ["secret", "set", "DOPPLER_TOKEN", "-R", `${ORG}/${ctx.slug}`],
1603
1797
  token.stdout.trim()
1604
1798
  );
1605
1799
  if (!set.ok) {
1606
1800
  return { status: "failed", detail: `gh secret set failed: ${set.stderr}` };
1607
1801
  }
1608
- return { status: "applied", detail: "Set DOPPLER_TOKEN_PRD (read-only prd token)" };
1802
+ return { status: "applied", detail: "Set DOPPLER_TOKEN (read-only prd token)" };
1609
1803
  }
1610
1804
  };
1611
1805
  function setSecretViaStdin(args, value) {
@@ -1648,7 +1842,7 @@ var vercelStep = {
1648
1842
  };
1649
1843
 
1650
1844
  // src/commands/pebble/steps/r2.ts
1651
- var bucketName = (slug) => `${slug}-tfstate`;
1845
+ var bucketName = (slug) => `${slug}-web-tfstate`;
1652
1846
  var r2Step = {
1653
1847
  id: "r2",
1654
1848
  label: "Cloudflare R2 tfstate bucket",
@@ -1858,7 +2052,7 @@ Hand off: /odin:eng-manager bet:${bet.data.id}
1858
2052
  }
1859
2053
 
1860
2054
  // src/commands/pebble/index.ts
1861
- var pebbleCmd = new Command21("pebble").description(
2055
+ var pebbleCmd = new Command22("pebble").description(
1862
2056
  "Provision and initialize pebbles (eng-infra, run-once)"
1863
2057
  );
1864
2058
  pebbleCmd.command("new <name>").description("Provision a new pebble from pebble-template (day-one)").option("--run", "Hand off to eng-manager on the init bet").option("--dir <path>", "Parent dir to clone into").option("--dry-run", "Print the plan; mutate nothing").action(async (name, opts) => {
@@ -1879,7 +2073,7 @@ pebbleCmd.command("provision <provider> <slug>").description("Run one provider s
1879
2073
  });
1880
2074
 
1881
2075
  // src/commands/create/index.ts
1882
- import { Command as Command22 } from "commander";
2076
+ import { Command as Command23 } from "commander";
1883
2077
 
1884
2078
  // src/commands/create/topology.ts
1885
2079
  var ENVS = ["prd", "stg", "dev"];
@@ -2010,8 +2204,8 @@ var githubProdEnvStep = {
2010
2204
  };
2011
2205
 
2012
2206
  // src/commands/create/steps/supabase.ts
2013
- import { randomBytes } from "crypto";
2014
- var genPassword = () => randomBytes(18).toString("base64url");
2207
+ import { randomBytes as randomBytes2 } from "crypto";
2208
+ var genPassword = () => randomBytes2(18).toString("base64url");
2015
2209
  function supabaseStep2(env) {
2016
2210
  return {
2017
2211
  id: `supabase-${env}`,
@@ -2344,22 +2538,22 @@ function assembleInputs(ctx) {
2344
2538
  }
2345
2539
 
2346
2540
  // src/commands/create/index.ts
2347
- var createCmd = new Command22("create").description(
2541
+ var createCmd = new Command23("create").description(
2348
2542
  "Create and provision a new project end to end"
2349
2543
  );
2350
2544
  createCmd.command("project [name]").description("Guided self-serve project + parallel-env provisioning").option("--dry-run", "Print the plan; mutate nothing").action(
2351
2545
  async (name, opts) => process.exit(await runCreate(name, { dryRun: opts.dryRun }))
2352
2546
  );
2353
- var setupCmd = new Command22("setup").description("Alias for `create project`").arguments("[name]").option("--dry-run", "Print the plan; mutate nothing").action(
2547
+ var setupCmd = new Command23("setup").description("Alias for `create project`").arguments("[name]").option("--dry-run", "Print the plan; mutate nothing").action(
2354
2548
  async (name, opts) => process.exit(await runCreate(name, { dryRun: opts.dryRun }))
2355
2549
  );
2356
2550
 
2357
2551
  // src/index.ts
2358
- import { readFileSync as readFileSync15 } from "fs";
2552
+ import { readFileSync as readFileSync16 } from "fs";
2359
2553
  import { fileURLToPath } from "url";
2360
2554
  import { dirname as dirname2, resolve as resolve2 } from "path";
2361
2555
  var __dirname = dirname2(fileURLToPath(import.meta.url));
2362
- var pkg = JSON.parse(readFileSync15(resolve2(__dirname, "../package.json"), "utf-8"));
2556
+ var pkg = JSON.parse(readFileSync16(resolve2(__dirname, "../package.json"), "utf-8"));
2363
2557
  var GOLD = "\x1B[33m";
2364
2558
  var DIM = "\x1B[2m";
2365
2559
  var RESET = "\x1B[0m";
@@ -2377,12 +2571,12 @@ ${RESET}
2377
2571
  ${DIM}${cwd}${RESET}
2378
2572
  `);
2379
2573
  }
2380
- var program = new Command23();
2574
+ var program = new Command24();
2381
2575
  program.name("odin").description("CLI for Odin \u2014 the knowledge backbone for Pebble House").version(VERSION);
2382
- program.command("login").description("Authenticate with Odin via browser").action(async () => {
2576
+ program.command("login").description("Authenticate with Odin via browser").option("--device", "Use the device-code flow (for headless or remote machines)").action(async (options) => {
2383
2577
  printBanner();
2384
2578
  try {
2385
- await login();
2579
+ await login({ device: options.device });
2386
2580
  console.error(`${GOLD}Logged in successfully.${RESET}`);
2387
2581
  } catch (err) {
2388
2582
  console.error(`Login failed: ${err instanceof Error ? err.message : err}`);
@@ -2406,6 +2600,7 @@ program.addCommand(rulesCmd);
2406
2600
  program.addCommand(briefsCmd);
2407
2601
  program.addCommand(blueprintsCmd);
2408
2602
  program.addCommand(betsCmd);
2603
+ program.addCommand(ticketsCmd);
2409
2604
  program.addCommand(betCommentsCmd);
2410
2605
  program.addCommand(tasksCmd);
2411
2606
  program.addCommand(roadmapsCmd);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pebblehouse/odin-cli",
3
- "version": "0.16.0",
3
+ "version": "0.18.0",
4
4
  "type": "module",
5
5
  "description": "CLI for Odin — the knowledge backbone for Pebble House",
6
6
  "bin": {