@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.
- package/README.md +8 -0
- package/dist/index.js +287 -92
- 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
|
|
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/
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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">✓ 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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
86
|
-
const authUrl =
|
|
87
|
-
console.error(
|
|
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
|
-
|
|
90
|
-
|
|
163
|
+
try {
|
|
164
|
+
await onUrl(authUrl);
|
|
165
|
+
} catch (err) {
|
|
166
|
+
finish(() => reject(err));
|
|
167
|
+
}
|
|
91
168
|
});
|
|
92
|
-
const timeout = setTimeout(
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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/
|
|
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
|
|
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
|
|
1139
|
-
|
|
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
|
|
1192
|
-
import { readFileSync as
|
|
1193
|
-
var roadmapsCmd = new
|
|
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 ?
|
|
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 =
|
|
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
|
|
1238
|
-
import { readFileSync as
|
|
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
|
|
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(
|
|
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(
|
|
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 =
|
|
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
|
|
1339
|
-
import { readFileSync as
|
|
1340
|
-
var blogCmd = new
|
|
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(
|
|
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(
|
|
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
|
|
1406
|
-
var apiKeysCmd = new
|
|
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
|
|
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
|
|
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 &&
|
|
1576
|
-
return { status: "satisfied", detail: "
|
|
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
|
|
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", "
|
|
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
|
|
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
|
|
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
|
|
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 = () =>
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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);
|