@theplato/tiro-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1468 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/bin/tiro.ts
4
+ import { Command as Command10 } from "commander";
5
+
6
+ // src/lib/version.ts
7
+ var VERSION = "0.1.0";
8
+
9
+ // src/lib/error.ts
10
+ var ExitCode = {
11
+ Ok: 0,
12
+ Generic: 1,
13
+ Usage: 2,
14
+ AuthRequired: 4,
15
+ ExUsage: 64,
16
+ ExDataErr: 65,
17
+ ExConfig: 78
18
+ };
19
+ var TiroError = class extends Error {
20
+ code;
21
+ suggestion;
22
+ errorType;
23
+ httpStatus;
24
+ requestId;
25
+ exitCode;
26
+ constructor(payload, exitCode = ExitCode.Generic) {
27
+ super(payload.message);
28
+ this.name = "TiroError";
29
+ this.code = payload.code;
30
+ this.suggestion = payload.suggestion;
31
+ this.errorType = payload.errorType;
32
+ this.httpStatus = payload.httpStatus;
33
+ this.requestId = payload.requestId;
34
+ this.exitCode = exitCode;
35
+ }
36
+ toJSON() {
37
+ return {
38
+ ok: false,
39
+ error: {
40
+ code: this.code,
41
+ message: this.message,
42
+ ...this.suggestion !== void 0 && { suggestion: this.suggestion },
43
+ ...this.errorType !== void 0 && { errorType: this.errorType },
44
+ ...this.httpStatus !== void 0 && { httpStatus: this.httpStatus },
45
+ ...this.requestId !== void 0 && { requestId: this.requestId }
46
+ }
47
+ };
48
+ }
49
+ };
50
+ function authRequired() {
51
+ return new TiroError(
52
+ {
53
+ code: "auth_required",
54
+ message: "Not authenticated. Run `tiro auth login` to sign in, or set TIRO_TOKEN env var.",
55
+ suggestion: "tiro auth login",
56
+ errorType: "auth_required"
57
+ },
58
+ ExitCode.AuthRequired
59
+ );
60
+ }
61
+
62
+ // src/lib/output/tty.ts
63
+ function resolveOutputMode(opts) {
64
+ if (opts.json) return "json";
65
+ if (opts.pretty) return "pretty";
66
+ return process.stdout.isTTY ? "pretty" : "json";
67
+ }
68
+ function colorEnabled(opts) {
69
+ if (opts.noColor) return false;
70
+ if (process.env["NO_COLOR"]) return false;
71
+ if (process.env["FORCE_COLOR"]) return true;
72
+ return process.stdout.isTTY === true;
73
+ }
74
+ var ANSI = {
75
+ reset: "\x1B[0m",
76
+ bold: "\x1B[1m",
77
+ dim: "\x1B[2m",
78
+ red: "\x1B[31m",
79
+ green: "\x1B[32m",
80
+ yellow: "\x1B[33m",
81
+ blue: "\x1B[34m",
82
+ magenta: "\x1B[35m",
83
+ cyan: "\x1B[36m",
84
+ gray: "\x1B[90m"
85
+ };
86
+ function color(text, style, opts = {}) {
87
+ if (!colorEnabled(opts)) return text;
88
+ return `${ANSI[style]}${text}${ANSI.reset}`;
89
+ }
90
+
91
+ // src/lib/output/print.ts
92
+ function printOutput(value, opts = {}) {
93
+ if (opts.quiet) return;
94
+ const mode = resolveOutputMode(opts);
95
+ if (mode === "json") {
96
+ process.stdout.write(`${JSON.stringify(value)}
97
+ `);
98
+ } else {
99
+ process.stdout.write(`${JSON.stringify(value, null, 2)}
100
+ `);
101
+ }
102
+ }
103
+ function printError(value) {
104
+ process.stderr.write(`${JSON.stringify(value)}
105
+ `);
106
+ }
107
+ function printNdjson(item) {
108
+ process.stdout.write(`${JSON.stringify(item)}
109
+ `);
110
+ }
111
+
112
+ // src/commands/auth/index.ts
113
+ import "commander";
114
+
115
+ // src/commands/auth/login.ts
116
+ import "commander";
117
+
118
+ // src/lib/auth/flow.ts
119
+ import { z } from "zod";
120
+
121
+ // src/lib/auth/pkce.ts
122
+ import { createHash, randomBytes } from "crypto";
123
+ function generatePkce() {
124
+ const codeVerifier = base64url(randomBytes(32));
125
+ const codeChallenge = base64url(createHash("sha256").update(codeVerifier).digest());
126
+ return { codeVerifier, codeChallenge, method: "S256" };
127
+ }
128
+ function generateState() {
129
+ return base64url(randomBytes(24));
130
+ }
131
+ function base64url(buf) {
132
+ return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
133
+ }
134
+
135
+ // src/lib/auth/loopback.ts
136
+ import http from "http";
137
+ async function startLoopbackServer() {
138
+ let pendingResolve = null;
139
+ let pendingReject = null;
140
+ const settle = (fn, arg) => {
141
+ pendingResolve = null;
142
+ pendingReject = null;
143
+ fn(arg);
144
+ };
145
+ const server = http.createServer((req, res) => {
146
+ if (!req.url) {
147
+ res.writeHead(400).end();
148
+ return;
149
+ }
150
+ const url = new URL(req.url, `http://127.0.0.1`);
151
+ if (url.pathname !== "/callback") {
152
+ res.writeHead(404, { "Content-Type": "text/plain" }).end("Not found");
153
+ return;
154
+ }
155
+ const code = url.searchParams.get("code");
156
+ const state = url.searchParams.get("state");
157
+ const error = url.searchParams.get("error");
158
+ const errorDesc = url.searchParams.get("error_description") ?? "";
159
+ if (error) {
160
+ const msg = `OAuth error: ${error}${errorDesc ? ` \u2014 ${errorDesc}` : ""}`;
161
+ respondPage(res, 500, "Login failed", msg);
162
+ if (pendingReject) settle(pendingReject, new Error(msg));
163
+ return;
164
+ }
165
+ if (!code || !state) {
166
+ respondPage(res, 400, "Missing parameters", "Missing `code` or `state`.");
167
+ if (pendingReject) settle(pendingReject, new Error("Missing code or state"));
168
+ return;
169
+ }
170
+ respondPage(
171
+ res,
172
+ 200,
173
+ "Login successful",
174
+ "You're signed in. You can close this tab and return to the terminal."
175
+ );
176
+ if (pendingResolve) settle(pendingResolve, { code, state });
177
+ });
178
+ await new Promise((resolve2) => {
179
+ server.listen(0, "127.0.0.1", () => resolve2());
180
+ });
181
+ const address = server.address();
182
+ const port = address.port;
183
+ const redirectUri = `http://127.0.0.1:${port}/callback`;
184
+ return {
185
+ redirectUri,
186
+ port,
187
+ waitForCallback(timeoutMs) {
188
+ return new Promise((resolve2, reject) => {
189
+ const timer = setTimeout(() => {
190
+ pendingResolve = null;
191
+ pendingReject = null;
192
+ reject(new Error(`Timed out waiting for OAuth callback (${timeoutMs}ms)`));
193
+ }, timeoutMs);
194
+ pendingResolve = (r) => {
195
+ clearTimeout(timer);
196
+ resolve2(r);
197
+ };
198
+ pendingReject = (e) => {
199
+ clearTimeout(timer);
200
+ reject(e);
201
+ };
202
+ });
203
+ },
204
+ close() {
205
+ server.close();
206
+ }
207
+ };
208
+ }
209
+ function respondPage(res, status, title, body) {
210
+ const html = `<!doctype html>
211
+ <html lang="en">
212
+ <head>
213
+ <meta charset="utf-8">
214
+ <title>${title}</title>
215
+ <style>
216
+ body { font-family: -apple-system, system-ui, sans-serif; padding: 4rem; max-width: 32rem; margin: 0 auto; color: #1a1a1a; }
217
+ h1 { font-size: 1.5rem; margin-bottom: 1rem; }
218
+ p { color: #555; line-height: 1.5; }
219
+ </style>
220
+ </head>
221
+ <body>
222
+ <h1>${title}</h1>
223
+ <p>${body}</p>
224
+ </body>
225
+ </html>`;
226
+ res.writeHead(status, { "Content-Type": "text/html; charset=utf-8" });
227
+ res.end(html);
228
+ }
229
+
230
+ // src/lib/auth/browser.ts
231
+ import open from "open";
232
+ async function openBrowser(url) {
233
+ try {
234
+ await open(url, { wait: false });
235
+ } catch {
236
+ }
237
+ }
238
+
239
+ // src/lib/auth/keychain.ts
240
+ import { Entry } from "@napi-rs/keyring";
241
+ var SERVICE = "io.tiro.cli";
242
+ var ACCOUNT = "default";
243
+ function saveToken(token) {
244
+ const entry = new Entry(SERVICE, ACCOUNT);
245
+ try {
246
+ entry.setPassword(JSON.stringify(token));
247
+ } catch (err) {
248
+ throw new TiroError(
249
+ {
250
+ code: "keychain_write_failed",
251
+ message: `Failed to write to OS keychain: ${err.message}`,
252
+ errorType: "internal_error",
253
+ suggestion: process.platform === "linux" ? "Linux requires a Secret Service daemon (gnome-keyring or kwallet). Or set TIRO_TOKEN env var." : "Check OS keychain permissions, or set TIRO_TOKEN env var."
254
+ },
255
+ ExitCode.Generic
256
+ );
257
+ }
258
+ }
259
+ function loadToken() {
260
+ const entry = new Entry(SERVICE, ACCOUNT);
261
+ let raw;
262
+ try {
263
+ raw = entry.getPassword();
264
+ } catch {
265
+ return null;
266
+ }
267
+ if (!raw) return null;
268
+ try {
269
+ return JSON.parse(raw);
270
+ } catch {
271
+ return null;
272
+ }
273
+ }
274
+ function deleteToken() {
275
+ const entry = new Entry(SERVICE, ACCOUNT);
276
+ try {
277
+ return entry.deletePassword();
278
+ } catch {
279
+ return false;
280
+ }
281
+ }
282
+
283
+ // src/lib/auth/token.ts
284
+ function resolveToken() {
285
+ const envToken = process.env["TIRO_TOKEN"];
286
+ if (envToken) {
287
+ return { accessToken: envToken, source: "env" };
288
+ }
289
+ const stored = loadToken();
290
+ if (stored) {
291
+ return {
292
+ accessToken: stored.accessToken,
293
+ source: "keychain",
294
+ hostname: stored.hostname,
295
+ expiresAt: stored.expiresAt,
296
+ ...stored.userId !== void 0 && { userId: stored.userId }
297
+ };
298
+ }
299
+ return null;
300
+ }
301
+ function decodeJwtPayload(token) {
302
+ const parts = token.split(".");
303
+ if (parts.length !== 3) return null;
304
+ try {
305
+ const payload = parts[1];
306
+ if (!payload) return null;
307
+ const json = Buffer.from(payload, "base64url").toString("utf8");
308
+ return JSON.parse(json);
309
+ } catch {
310
+ return null;
311
+ }
312
+ }
313
+
314
+ // src/lib/config.ts
315
+ import Conf from "conf";
316
+ var DEFAULT_HOSTNAME = "https://api.tiro.ooo";
317
+ var config = new Conf({
318
+ projectName: "tiro",
319
+ defaults: {
320
+ hostname: DEFAULT_HOSTNAME,
321
+ oauthClientId: null,
322
+ oauthClientIdRegisteredAt: null,
323
+ defaultOutputDir: null
324
+ }
325
+ });
326
+ function getHostname(override) {
327
+ if (override) return stripTrailingSlash(override);
328
+ const env = process.env["TIRO_HOSTNAME"];
329
+ if (env) return stripTrailingSlash(env);
330
+ return stripTrailingSlash(config.get("hostname"));
331
+ }
332
+ function getOauthClientId() {
333
+ const id = config.get("oauthClientId");
334
+ const registeredAt = config.get("oauthClientIdRegisteredAt");
335
+ if (!id || !registeredAt) return null;
336
+ const ttlMs = 29 * 24 * 60 * 60 * 1e3;
337
+ if (Date.now() - registeredAt > ttlMs) return null;
338
+ return id;
339
+ }
340
+ function setOauthClientId(clientId) {
341
+ config.set("oauthClientId", clientId);
342
+ config.set("oauthClientIdRegisteredAt", Date.now());
343
+ }
344
+ function clearOauthClientId() {
345
+ config.set("oauthClientId", null);
346
+ config.set("oauthClientIdRegisteredAt", null);
347
+ }
348
+ function stripTrailingSlash(s) {
349
+ return s.endsWith("/") ? s.slice(0, -1) : s;
350
+ }
351
+
352
+ // src/lib/auth/flow.ts
353
+ var RegisterResponseSchema = z.object({
354
+ client_id: z.string(),
355
+ client_secret: z.string().optional()
356
+ });
357
+ var TokenResponseSchema = z.object({
358
+ access_token: z.string(),
359
+ token_type: z.string(),
360
+ expires_in: z.number().optional(),
361
+ scope: z.string().optional()
362
+ });
363
+ var CALLBACK_TIMEOUT_MS = 5 * 60 * 1e3;
364
+ var DEFAULT_SCOPE = "mcp:notes:read";
365
+ async function performLogin(options = {}) {
366
+ const hostname = getHostname(options.hostname);
367
+ const onPrompt = options.onPrompt ?? ((msg) => process.stderr.write(`${msg}
368
+ `));
369
+ const loopback = await startLoopbackServer();
370
+ try {
371
+ const clientId = await ensureOauthClient(hostname, loopback.redirectUri);
372
+ const { codeVerifier, codeChallenge } = generatePkce();
373
+ const state = generateState();
374
+ const authorizeUrl = buildAuthorizeUrl({
375
+ hostname,
376
+ clientId,
377
+ redirectUri: loopback.redirectUri,
378
+ state,
379
+ codeChallenge,
380
+ scope: options.scope ?? DEFAULT_SCOPE
381
+ });
382
+ if (options.noBrowser) {
383
+ onPrompt(`Open this URL in your browser:
384
+ ${authorizeUrl}`);
385
+ } else {
386
+ onPrompt(`Opening browser for sign-in...`);
387
+ onPrompt(`If the browser does not open, visit:
388
+ ${authorizeUrl}`);
389
+ await openBrowser(authorizeUrl);
390
+ }
391
+ const callback = await loopback.waitForCallback(CALLBACK_TIMEOUT_MS);
392
+ if (callback.state !== state) {
393
+ throw new TiroError(
394
+ {
395
+ code: "auth_state_mismatch",
396
+ message: "OAuth state mismatch \u2014 possible CSRF. Aborting.",
397
+ errorType: "unauthorized"
398
+ },
399
+ ExitCode.Generic
400
+ );
401
+ }
402
+ const tokenRes = await exchangeToken({
403
+ hostname,
404
+ clientId,
405
+ code: callback.code,
406
+ redirectUri: loopback.redirectUri,
407
+ codeVerifier
408
+ });
409
+ const expiresAt = computeExpiry(tokenRes.expires_in);
410
+ const payload = decodeJwtPayload(tokenRes.access_token);
411
+ const userId = typeof payload?.["sub"] === "string" ? payload["sub"] : void 0;
412
+ const stored = {
413
+ accessToken: tokenRes.access_token,
414
+ tokenType: tokenRes.token_type,
415
+ expiresAt,
416
+ hostname,
417
+ ...tokenRes.scope !== void 0 && { scope: tokenRes.scope },
418
+ ...userId !== void 0 && { userId }
419
+ };
420
+ saveToken(stored);
421
+ return { hostname, userId, expiresAt };
422
+ } finally {
423
+ loopback.close();
424
+ }
425
+ }
426
+ async function ensureOauthClient(hostname, redirectUri) {
427
+ const cached = getOauthClientId();
428
+ if (cached) return cached;
429
+ const url = `${hostname}/v1/mcp/oauth/register`;
430
+ let res;
431
+ try {
432
+ res = await fetch(url, {
433
+ method: "POST",
434
+ headers: { "Content-Type": "application/json" },
435
+ body: JSON.stringify({
436
+ client_name: "tiro-cli",
437
+ redirect_uris: [redirectUri],
438
+ grant_types: ["authorization_code"],
439
+ response_types: ["code"],
440
+ token_endpoint_auth_method: "none",
441
+ scope: DEFAULT_SCOPE
442
+ })
443
+ });
444
+ } catch (err) {
445
+ throw new TiroError(
446
+ {
447
+ code: "network_error",
448
+ message: `Failed to reach ${hostname}: ${err.message}`,
449
+ errorType: "network_error",
450
+ suggestion: "Check your network connection or --hostname."
451
+ },
452
+ ExitCode.Generic
453
+ );
454
+ }
455
+ if (!res.ok) {
456
+ const detail = await safeText(res);
457
+ throw new TiroError(
458
+ {
459
+ code: "oauth_register_failed",
460
+ message: `Dynamic Client Registration failed: HTTP ${res.status}`,
461
+ errorType: "internal_error",
462
+ httpStatus: res.status,
463
+ ...detail !== "" && { suggestion: detail.slice(0, 200) }
464
+ },
465
+ ExitCode.Generic
466
+ );
467
+ }
468
+ const json = await res.json();
469
+ const parsed = RegisterResponseSchema.safeParse(json);
470
+ if (!parsed.success) {
471
+ throw new TiroError(
472
+ {
473
+ code: "oauth_register_invalid",
474
+ message: "Registration response did not match expected shape.",
475
+ errorType: "internal_error"
476
+ },
477
+ ExitCode.Generic
478
+ );
479
+ }
480
+ setOauthClientId(parsed.data.client_id);
481
+ return parsed.data.client_id;
482
+ }
483
+ function buildAuthorizeUrl(input) {
484
+ const u = new URL(`${input.hostname}/v1/mcp/oauth/authorize`);
485
+ u.searchParams.set("response_type", "code");
486
+ u.searchParams.set("client_id", input.clientId);
487
+ u.searchParams.set("redirect_uri", input.redirectUri);
488
+ u.searchParams.set("state", input.state);
489
+ u.searchParams.set("code_challenge", input.codeChallenge);
490
+ u.searchParams.set("code_challenge_method", "S256");
491
+ u.searchParams.set("scope", input.scope);
492
+ return u.toString();
493
+ }
494
+ async function exchangeToken(input) {
495
+ const url = `${input.hostname}/v1/mcp/oauth/token`;
496
+ const body = new URLSearchParams({
497
+ grant_type: "authorization_code",
498
+ code: input.code,
499
+ redirect_uri: input.redirectUri,
500
+ client_id: input.clientId,
501
+ code_verifier: input.codeVerifier
502
+ });
503
+ let res;
504
+ try {
505
+ res = await fetch(url, {
506
+ method: "POST",
507
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
508
+ body: body.toString()
509
+ });
510
+ } catch (err) {
511
+ throw new TiroError(
512
+ {
513
+ code: "network_error",
514
+ message: `Failed to reach ${input.hostname}: ${err.message}`,
515
+ errorType: "network_error"
516
+ },
517
+ ExitCode.Generic
518
+ );
519
+ }
520
+ if (!res.ok) {
521
+ if (res.status === 400 || res.status === 401) {
522
+ clearOauthClientId();
523
+ }
524
+ const detail = await safeText(res);
525
+ throw new TiroError(
526
+ {
527
+ code: "oauth_token_failed",
528
+ message: `Token exchange failed: HTTP ${res.status}`,
529
+ errorType: "unauthorized",
530
+ httpStatus: res.status,
531
+ ...detail !== "" && { suggestion: detail.slice(0, 200) }
532
+ },
533
+ ExitCode.AuthRequired
534
+ );
535
+ }
536
+ const json = await res.json();
537
+ const parsed = TokenResponseSchema.safeParse(json);
538
+ if (!parsed.success) {
539
+ throw new TiroError(
540
+ {
541
+ code: "oauth_token_invalid",
542
+ message: "Token response did not match expected shape.",
543
+ errorType: "internal_error"
544
+ },
545
+ ExitCode.Generic
546
+ );
547
+ }
548
+ return parsed.data;
549
+ }
550
+ function computeExpiry(expiresIn) {
551
+ const fallbackSeconds = 180 * 24 * 60 * 60;
552
+ const seconds = expiresIn ?? fallbackSeconds;
553
+ return Date.now() + seconds * 1e3;
554
+ }
555
+ async function safeText(res) {
556
+ try {
557
+ return await res.text();
558
+ } catch {
559
+ return "";
560
+ }
561
+ }
562
+
563
+ // src/commands/auth/login.ts
564
+ function registerAuthLogin(parent) {
565
+ parent.command("login").description("Sign in to Tiro via OAuth (browser-based, PKCE)").option("--hostname <url>", "API base URL (overrides config / TIRO_HOSTNAME)").option("--no-browser", "Print the URL instead of opening a browser").action(async (opts, cmd) => {
566
+ const globalOpts = cmd.optsWithGlobals();
567
+ const result = await performLogin({
568
+ ...opts.hostname !== void 0 && { hostname: opts.hostname },
569
+ noBrowser: opts.noBrowser === true,
570
+ onPrompt: (msg) => {
571
+ if (globalOpts.quiet) return;
572
+ process.stderr.write(`${color(msg, "cyan", globalOpts)}
573
+ `);
574
+ }
575
+ });
576
+ const hostname = getHostname(opts.hostname);
577
+ const expiresIso = new Date(result.expiresAt).toISOString();
578
+ if (globalOpts.json) {
579
+ printOutput(
580
+ {
581
+ ok: true,
582
+ data: {
583
+ signedIn: true,
584
+ hostname,
585
+ userId: result.userId ?? null,
586
+ expiresAt: expiresIso
587
+ }
588
+ },
589
+ globalOpts
590
+ );
591
+ } else if (!globalOpts.quiet) {
592
+ process.stderr.write(`${color("\u2713", "green", globalOpts)} Signed in to ${hostname}
593
+ `);
594
+ if (result.userId) {
595
+ process.stderr.write(` user: ${result.userId}
596
+ `);
597
+ }
598
+ process.stderr.write(` token expires: ${expiresIso}
599
+ `);
600
+ }
601
+ });
602
+ }
603
+
604
+ // src/commands/auth/status.ts
605
+ import "commander";
606
+ function registerAuthStatus(parent) {
607
+ parent.command("status").description("Show current authenticated account and scopes").action(async (_opts, cmd) => {
608
+ const globalOpts = cmd.optsWithGlobals();
609
+ const t = resolveToken();
610
+ if (!t) {
611
+ throw authRequired();
612
+ }
613
+ const payload = decodeJwtPayload(t.accessToken);
614
+ const sub = typeof payload?.["sub"] === "string" ? payload["sub"] : null;
615
+ const exp = typeof payload?.["exp"] === "number" ? payload["exp"] * 1e3 : null;
616
+ const scope = typeof payload?.["scope"] === "string" ? payload["scope"] : null;
617
+ const expMs = exp ?? t.expiresAt ?? null;
618
+ const expired = expMs !== null && Date.now() >= expMs;
619
+ const report = {
620
+ signedIn: true,
621
+ source: t.source,
622
+ hostname: t.hostname ?? null,
623
+ userId: t.userId ?? sub,
624
+ scope,
625
+ expiresAt: expMs ? new Date(expMs).toISOString() : null,
626
+ expired,
627
+ tokenPrefix: `${t.accessToken.slice(0, 4)}...***`
628
+ };
629
+ if (globalOpts.json || !process.stdout.isTTY) {
630
+ printOutput({ ok: true, data: report }, globalOpts);
631
+ } else {
632
+ const headIcon = expired ? color("!", "yellow", globalOpts) : color("\u2713", "green", globalOpts);
633
+ const headText = expired ? "Token expired" : "Signed in";
634
+ process.stdout.write(`${headIcon} ${headText}
635
+ `);
636
+ process.stdout.write(` source: ${report.source}
637
+ `);
638
+ if (report.hostname) process.stdout.write(` hostname: ${report.hostname}
639
+ `);
640
+ if (report.userId) process.stdout.write(` user: ${report.userId}
641
+ `);
642
+ if (report.scope) process.stdout.write(` scope: ${report.scope}
643
+ `);
644
+ if (report.expiresAt) {
645
+ const tag = expired ? color(" (expired)", "red", globalOpts) : "";
646
+ process.stdout.write(` expires at: ${report.expiresAt}${tag}
647
+ `);
648
+ }
649
+ process.stdout.write(` token: ${report.tokenPrefix}
650
+ `);
651
+ if (expired) {
652
+ process.stdout.write(
653
+ `
654
+ ${color("\u2192", "gray", globalOpts)} Run \`tiro auth login\` to refresh.
655
+ `
656
+ );
657
+ }
658
+ }
659
+ });
660
+ }
661
+
662
+ // src/commands/auth/logout.ts
663
+ import "commander";
664
+ function registerAuthLogout(parent) {
665
+ parent.command("logout").description("Sign out and clear the stored token").action(async (_opts, cmd) => {
666
+ const globalOpts = cmd.optsWithGlobals();
667
+ const removed = deleteToken();
668
+ clearOauthClientId();
669
+ if (globalOpts.json) {
670
+ printOutput({ ok: true, data: { signedOut: true, hadToken: removed } }, globalOpts);
671
+ } else if (!globalOpts.quiet) {
672
+ if (removed) {
673
+ process.stderr.write(`${color("\u2713", "green", globalOpts)} Signed out
674
+ `);
675
+ } else {
676
+ process.stderr.write(`${color("\u2022", "gray", globalOpts)} No token was stored
677
+ `);
678
+ }
679
+ }
680
+ });
681
+ }
682
+
683
+ // src/commands/auth/index.ts
684
+ function registerAuth(program) {
685
+ const auth = program.command("auth").description("Manage authentication");
686
+ registerAuthLogin(auth);
687
+ registerAuthStatus(auth);
688
+ registerAuthLogout(auth);
689
+ }
690
+
691
+ // src/commands/notes/index.ts
692
+ import "commander";
693
+
694
+ // src/commands/notes/list.ts
695
+ import "commander";
696
+
697
+ // src/lib/api/client.ts
698
+ import "zod";
699
+
700
+ // src/lib/api/types.ts
701
+ import { z as z2 } from "zod";
702
+ var CollaboratorSchema = z2.object({
703
+ guid: z2.string(),
704
+ name: z2.string(),
705
+ email: z2.string(),
706
+ role: z2.enum(["OWNER", "EDITOR", "VIEWER"])
707
+ });
708
+ var ParticipantSchema = z2.object({
709
+ name: z2.string().nullable().optional(),
710
+ email: z2.string().nullable().optional()
711
+ });
712
+ var NoteSchema = z2.object({
713
+ guid: z2.string(),
714
+ title: z2.string(),
715
+ createdAt: z2.string(),
716
+ updatedAt: z2.string(),
717
+ sourceType: z2.string(),
718
+ recordingDurationSeconds: z2.number(),
719
+ collaborators: z2.array(CollaboratorSchema).optional().default([]),
720
+ participants: z2.array(ParticipantSchema).optional().default([]),
721
+ webUrl: z2.string(),
722
+ recordingStartAt: z2.string().nullable().optional(),
723
+ recordingEndAt: z2.string().nullable().optional()
724
+ }).passthrough();
725
+ var ParagraphSchema = z2.object({
726
+ text: z2.string(),
727
+ startMs: z2.number().nullable().optional(),
728
+ endMs: z2.number().nullable().optional(),
729
+ speaker: z2.object({
730
+ name: z2.string().nullable().optional(),
731
+ email: z2.string().nullable().optional()
732
+ }).nullable().optional()
733
+ }).passthrough();
734
+ var PageCursorResponseSchema = (item) => z2.object({
735
+ content: z2.array(item),
736
+ nextCursor: z2.string().nullable()
737
+ });
738
+ var SimpleListResponseSchema = (item) => z2.object({
739
+ content: z2.array(item)
740
+ });
741
+ var ApiErrorSchema = z2.object({
742
+ error: z2.object({
743
+ code: z2.number(),
744
+ errorType: z2.string(),
745
+ message: z2.string(),
746
+ detail: z2.string().nullable().optional()
747
+ })
748
+ });
749
+
750
+ // src/lib/api/client.ts
751
+ var TiroApiClient = class {
752
+ constructor(hostname, token) {
753
+ this.hostname = hostname;
754
+ this.token = token;
755
+ }
756
+ hostname;
757
+ token;
758
+ async getJson(path, schema, params) {
759
+ const url = this.buildUrl(path, params);
760
+ const res = await this.fetch(url, { method: "GET" });
761
+ return this.parseJson(res, schema, "GET", path);
762
+ }
763
+ async postJson(path, schema, body) {
764
+ const url = this.buildUrl(path);
765
+ const res = await this.fetch(url, {
766
+ method: "POST",
767
+ headers: { "Content-Type": "application/json" },
768
+ body: JSON.stringify(body)
769
+ });
770
+ return this.parseJson(res, schema, "POST", path);
771
+ }
772
+ async putJson(path, schema, body) {
773
+ const url = this.buildUrl(path);
774
+ const res = await this.fetch(url, {
775
+ method: "PUT",
776
+ headers: { "Content-Type": "application/json" },
777
+ body: JSON.stringify(body)
778
+ });
779
+ return this.parseJson(res, schema, "PUT", path);
780
+ }
781
+ async deleteVoid(path) {
782
+ const url = this.buildUrl(path);
783
+ const res = await this.fetch(url, { method: "DELETE" });
784
+ if (!res.ok) throw await mapHttpError(res, "DELETE", path);
785
+ }
786
+ buildUrl(path, params) {
787
+ const base = path.startsWith("http") ? path : `${this.hostname}${path}`;
788
+ const u = new URL(base);
789
+ if (params) {
790
+ for (const [k, v] of Object.entries(params)) {
791
+ if (v !== void 0 && v !== null && v !== "") {
792
+ u.searchParams.set(k, String(v));
793
+ }
794
+ }
795
+ }
796
+ return u.toString();
797
+ }
798
+ async fetch(url, init) {
799
+ const headers = new Headers(init.headers);
800
+ headers.set("Authorization", `Bearer ${this.token}`);
801
+ headers.set("Accept", "application/json");
802
+ try {
803
+ return await fetch(url, { ...init, headers });
804
+ } catch (err) {
805
+ throw new TiroError(
806
+ {
807
+ code: "network_error",
808
+ message: `Network error reaching ${this.hostname}: ${err.message}`,
809
+ errorType: "network_error",
810
+ suggestion: "Check your network or --hostname."
811
+ },
812
+ ExitCode.Generic
813
+ );
814
+ }
815
+ }
816
+ async parseJson(res, schema, method, path) {
817
+ if (!res.ok) throw await mapHttpError(res, method, path);
818
+ let json;
819
+ try {
820
+ json = await res.json();
821
+ } catch (err) {
822
+ throw new TiroError(
823
+ {
824
+ code: "invalid_response",
825
+ message: `Failed to parse JSON from ${method} ${path}: ${err.message}`,
826
+ errorType: "internal_error"
827
+ },
828
+ ExitCode.Generic
829
+ );
830
+ }
831
+ const parsed = schema.safeParse(json);
832
+ if (!parsed.success) {
833
+ throw new TiroError(
834
+ {
835
+ code: "schema_mismatch",
836
+ message: `Response shape did not match expected schema (${method} ${path}).`,
837
+ errorType: "internal_error",
838
+ suggestion: parsed.error.issues.slice(0, 3).map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ")
839
+ },
840
+ ExitCode.Generic
841
+ );
842
+ }
843
+ return parsed.data;
844
+ }
845
+ };
846
+ async function mapHttpError(res, method, path) {
847
+ const requestId = res.headers.get("x-request-id") ?? void 0;
848
+ const exitCode = res.status === 401 ? ExitCode.AuthRequired : ExitCode.Generic;
849
+ const apiError = await tryParseApiError(res);
850
+ if (apiError) {
851
+ return new TiroError(
852
+ {
853
+ code: `${apiError.error.errorType}`,
854
+ message: apiError.error.message,
855
+ errorType: apiError.error.errorType,
856
+ httpStatus: res.status,
857
+ ...requestId !== void 0 && { requestId },
858
+ ...res.status === 401 && {
859
+ suggestion: "Run `tiro auth login` to refresh."
860
+ }
861
+ },
862
+ exitCode
863
+ );
864
+ }
865
+ return new TiroError(
866
+ {
867
+ code: "http_error",
868
+ message: `${method} ${path} failed: HTTP ${res.status} ${res.statusText}`,
869
+ errorType: httpStatusToErrorType(res.status),
870
+ httpStatus: res.status,
871
+ ...requestId !== void 0 && { requestId }
872
+ },
873
+ exitCode
874
+ );
875
+ }
876
+ function httpStatusToErrorType(status) {
877
+ if (status === 400) return "bad_request";
878
+ if (status === 401) return "unauthorized";
879
+ if (status === 403) return "forbidden";
880
+ if (status === 404) return "not_found";
881
+ if (status === 409) return "conflict";
882
+ if (status === 413) return "payload_too_large";
883
+ if (status === 422) return "unprocessable_entity";
884
+ if (status === 429) return "too_many_requests";
885
+ return "internal_error";
886
+ }
887
+ async function tryParseApiError(res) {
888
+ try {
889
+ const json = await res.clone().json();
890
+ const parsed = ApiErrorSchema.safeParse(json);
891
+ return parsed.success ? parsed.data : null;
892
+ } catch {
893
+ return null;
894
+ }
895
+ }
896
+ function createApiClient(opts = {}) {
897
+ if (opts.tokenOverride) {
898
+ return new TiroApiClient(getHostname(opts.hostnameOverride), opts.tokenOverride);
899
+ }
900
+ const t = resolveToken();
901
+ if (!t) throw authRequired();
902
+ const hostname = getHostname(opts.hostnameOverride ?? t.hostname);
903
+ return new TiroApiClient(hostname, t.accessToken);
904
+ }
905
+
906
+ // src/commands/notes/list.ts
907
+ var ListResponseSchema = PageCursorResponseSchema(NoteSchema);
908
+ function registerNotesList(parent) {
909
+ parent.command("list").description("List recent notes").option("--folder <id>", "Filter by folder ID").option("--limit <n>", "Max results per page (default 50, max 500)").option("--cursor <token>", "Cursor for the next page").action(async (opts, cmd) => {
910
+ const globalOpts = cmd.optsWithGlobals();
911
+ const limit = clampLimit(opts.limit);
912
+ const client = createApiClient({
913
+ ...globalOpts.hostname !== void 0 && { hostnameOverride: globalOpts.hostname }
914
+ });
915
+ const params = {};
916
+ if (opts.folder) params["folderId"] = opts.folder;
917
+ if (limit !== void 0) params["size"] = limit;
918
+ if (opts.cursor) params["cursor"] = opts.cursor;
919
+ const res = await client.getJson("/v1/external/notes", ListResponseSchema, params);
920
+ const mode = resolveOutputMode(globalOpts);
921
+ if (mode === "json") {
922
+ for (const note of res.content) printNdjson(note);
923
+ if (res.nextCursor) printNdjson({ _cursor: res.nextCursor });
924
+ } else {
925
+ printPretty(res.content, res.nextCursor, globalOpts);
926
+ }
927
+ });
928
+ }
929
+ function clampLimit(raw) {
930
+ if (!raw) return void 0;
931
+ const n = parseInt(raw, 10);
932
+ if (!Number.isFinite(n) || n <= 0) return 50;
933
+ return Math.min(n, 500);
934
+ }
935
+ function printPretty(notes, nextCursor, opts) {
936
+ if (notes.length === 0) {
937
+ process.stdout.write(`${color("(no notes)", "gray", opts)}
938
+ `);
939
+ return;
940
+ }
941
+ for (const n of notes) {
942
+ const date = n.createdAt.slice(0, 10);
943
+ const dur = formatDuration(n.recordingDurationSeconds);
944
+ process.stdout.write(
945
+ `${color(date, "gray", opts)} ${color(n.guid, "dim", opts)} ${color(dur, "cyan", opts)} ${n.title}
946
+ `
947
+ );
948
+ }
949
+ if (nextCursor) {
950
+ process.stdout.write(
951
+ `${color(`
952
+ next: --cursor ${nextCursor}`, "gray", opts)}
953
+ `
954
+ );
955
+ }
956
+ }
957
+ function formatDuration(sec) {
958
+ if (!sec || sec <= 0) return "\u2014";
959
+ const m = Math.floor(sec / 60);
960
+ const s = Math.floor(sec % 60);
961
+ if (m > 0) return `${m}m${s.toString().padStart(2, "0")}s`;
962
+ return `${s}s`;
963
+ }
964
+
965
+ // src/commands/notes/search.ts
966
+ import "commander";
967
+
968
+ // src/lib/util/parseDate.ts
969
+ var RELATIVE_RE = /^(\d+)([smhdw])$/i;
970
+ var UNIT_MS = {
971
+ s: 1e3,
972
+ m: 6e4,
973
+ h: 36e5,
974
+ d: 864e5,
975
+ w: 6048e5
976
+ };
977
+ function parseDate(input) {
978
+ const trimmed = input.trim();
979
+ const rel = trimmed.match(RELATIVE_RE);
980
+ if (rel) {
981
+ const num = parseInt(rel[1] ?? "", 10);
982
+ const unit = (rel[2] ?? "").toLowerCase();
983
+ const factor = UNIT_MS[unit];
984
+ if (!factor || !Number.isFinite(num)) {
985
+ throw invalidDate(input);
986
+ }
987
+ return new Date(Date.now() - num * factor).toISOString();
988
+ }
989
+ const d = new Date(trimmed);
990
+ if (Number.isNaN(d.getTime())) throw invalidDate(input);
991
+ return d.toISOString();
992
+ }
993
+ function invalidDate(input) {
994
+ return new TiroError(
995
+ {
996
+ code: "invalid_date",
997
+ message: `Invalid date: "${input}". Use ISO-8601 (e.g. 2026-04-01T10:00:00Z) or relative (e.g. 7d, 24h, 30m).`,
998
+ errorType: "bad_request"
999
+ },
1000
+ ExitCode.Usage
1001
+ );
1002
+ }
1003
+
1004
+ // src/commands/notes/search.ts
1005
+ var SearchResponseSchema = PageCursorResponseSchema(NoteSchema);
1006
+ function registerNotesSearch(parent) {
1007
+ parent.command("search [query]").description("Search notes by speaker / date / folder / keyword").option("--speaker <name>", "Filter by speaker name").option("--since <date>", "Notes created after this date (ISO-8601 or 7d, 24h, 30m)").option("--until <date>", "Notes created before this date").option("--folder <id>", "Filter by folder ID").option("--limit <n>", "Max results per page (default 50, max 500)").option("--cursor <token>", "Cursor for the next page").action(async (query, opts, cmd) => {
1008
+ const globalOpts = cmd.optsWithGlobals();
1009
+ const client = createApiClient({
1010
+ ...globalOpts.hostname !== void 0 && { hostnameOverride: globalOpts.hostname }
1011
+ });
1012
+ const body = {};
1013
+ if (query) body["query"] = query;
1014
+ if (opts.speaker) body["speaker"] = opts.speaker;
1015
+ if (opts.since) body["since"] = parseDate(opts.since);
1016
+ if (opts.until) body["until"] = parseDate(opts.until);
1017
+ if (opts.folder) body["folderId"] = opts.folder;
1018
+ const limit = clampLimit2(opts.limit);
1019
+ if (limit !== void 0) body["size"] = limit;
1020
+ if (opts.cursor) body["cursor"] = opts.cursor;
1021
+ const res = await client.postJson(
1022
+ "/v1/external/notes/search",
1023
+ SearchResponseSchema,
1024
+ body
1025
+ );
1026
+ const mode = resolveOutputMode(globalOpts);
1027
+ if (mode === "json") {
1028
+ for (const note of res.content) printNdjson(note);
1029
+ if (res.nextCursor) printNdjson({ _cursor: res.nextCursor });
1030
+ } else {
1031
+ printPretty2(res.content, res.nextCursor, globalOpts);
1032
+ }
1033
+ });
1034
+ }
1035
+ function clampLimit2(raw) {
1036
+ if (!raw) return void 0;
1037
+ const n = parseInt(raw, 10);
1038
+ if (!Number.isFinite(n) || n <= 0) return 50;
1039
+ return Math.min(n, 500);
1040
+ }
1041
+ function printPretty2(notes, nextCursor, opts) {
1042
+ if (notes.length === 0) {
1043
+ process.stdout.write(`${color("(no matches)", "gray", opts)}
1044
+ `);
1045
+ return;
1046
+ }
1047
+ for (const n of notes) {
1048
+ const date = n.createdAt.slice(0, 10);
1049
+ process.stdout.write(
1050
+ `${color(date, "gray", opts)} ${color(n.guid, "dim", opts)} ${n.title}
1051
+ `
1052
+ );
1053
+ }
1054
+ if (nextCursor) {
1055
+ process.stdout.write(
1056
+ `${color(`
1057
+ next: --cursor ${nextCursor}`, "gray", opts)}
1058
+ `
1059
+ );
1060
+ }
1061
+ }
1062
+
1063
+ // src/commands/notes/get.ts
1064
+ import "commander";
1065
+
1066
+ // src/lib/output/file.ts
1067
+ import { mkdir, rename, stat, writeFile, access } from "fs/promises";
1068
+ import { dirname, resolve } from "path";
1069
+ async function writeFileAtomic(filepath, content, opts = {}) {
1070
+ const absPath = resolve(filepath);
1071
+ await mkdir(dirname(absPath), { recursive: true });
1072
+ if (!opts.force) {
1073
+ const exists = await fileExists(absPath);
1074
+ if (exists) {
1075
+ throw new TiroError(
1076
+ {
1077
+ code: "file_exists",
1078
+ message: `File already exists: ${absPath}`,
1079
+ errorType: "conflict",
1080
+ suggestion: "Use --force to overwrite, or pick a different --output."
1081
+ },
1082
+ ExitCode.Generic
1083
+ );
1084
+ }
1085
+ }
1086
+ const tmp = `${absPath}.tmp.${process.pid}.${Date.now()}`;
1087
+ await writeFile(tmp, content, "utf8");
1088
+ await rename(tmp, absPath);
1089
+ const s = await stat(absPath);
1090
+ return { path: absPath, size: s.size };
1091
+ }
1092
+ async function fileExists(p) {
1093
+ try {
1094
+ await access(p);
1095
+ return true;
1096
+ } catch {
1097
+ return false;
1098
+ }
1099
+ }
1100
+
1101
+ // src/lib/output/format.ts
1102
+ function formatNote(note, format, opts = {}) {
1103
+ switch (format) {
1104
+ case "md":
1105
+ return formatMarkdown(note, opts);
1106
+ case "json":
1107
+ return formatJson(note, opts);
1108
+ case "txt":
1109
+ return formatText(note, opts);
1110
+ }
1111
+ }
1112
+ function formatMarkdown(note, opts) {
1113
+ const fm = [
1114
+ "---",
1115
+ `guid: ${escapeYaml(note.guid)}`,
1116
+ `title: ${escapeYaml(note.title)}`,
1117
+ `createdAt: ${note.createdAt}`,
1118
+ `updatedAt: ${note.updatedAt}`,
1119
+ `sourceType: ${note.sourceType}`,
1120
+ `recordingDurationSeconds: ${note.recordingDurationSeconds}`,
1121
+ `webUrl: ${note.webUrl}`
1122
+ ];
1123
+ if (note.recordingStartAt) fm.push(`recordingStartAt: ${note.recordingStartAt}`);
1124
+ if (note.recordingEndAt) fm.push(`recordingEndAt: ${note.recordingEndAt}`);
1125
+ fm.push("---", "");
1126
+ const parts = [fm.join("\n"), `# ${note.title}`, ""];
1127
+ if (note.participants && note.participants.length > 0) {
1128
+ parts.push("## Participants", "");
1129
+ for (const p of note.participants) {
1130
+ const name = p.name ?? "(no name)";
1131
+ const email = p.email ? ` <${p.email}>` : "";
1132
+ parts.push(`- ${name}${email}`);
1133
+ }
1134
+ parts.push("");
1135
+ }
1136
+ if (opts.includeTranscript && opts.paragraphs && opts.paragraphs.length > 0) {
1137
+ parts.push("## Transcript", "");
1138
+ for (const p of opts.paragraphs) {
1139
+ const speaker = p.speaker?.name ?? "Unknown";
1140
+ const ts = formatTimestamp(p.startMs ?? null);
1141
+ parts.push(`**[${speaker}${ts ? `, ${ts}` : ""}]** ${p.text}`);
1142
+ parts.push("");
1143
+ }
1144
+ }
1145
+ return parts.join("\n");
1146
+ }
1147
+ function formatJson(note, opts) {
1148
+ const out = { ...note };
1149
+ if (opts.includeTranscript && opts.paragraphs) {
1150
+ out["transcript"] = { paragraphs: opts.paragraphs };
1151
+ }
1152
+ return `${JSON.stringify(out, null, 2)}
1153
+ `;
1154
+ }
1155
+ function formatText(note, opts) {
1156
+ if (!opts.paragraphs || opts.paragraphs.length === 0) {
1157
+ return `${note.title}
1158
+ ${note.webUrl}
1159
+ `;
1160
+ }
1161
+ return opts.paragraphs.map((p) => `[${p.speaker?.name ?? "Unknown"}] ${p.text}`).join("\n");
1162
+ }
1163
+ function formatTimestamp(ms) {
1164
+ if (ms === null || ms === void 0 || !Number.isFinite(ms)) return "";
1165
+ const totalSec = Math.floor(ms / 1e3);
1166
+ const h = Math.floor(totalSec / 3600);
1167
+ const m = Math.floor(totalSec % 3600 / 60);
1168
+ const s = totalSec % 60;
1169
+ if (h > 0) {
1170
+ return `${pad(h)}:${pad(m)}:${pad(s)}`;
1171
+ }
1172
+ return `${pad(m)}:${pad(s)}`;
1173
+ }
1174
+ function pad(n) {
1175
+ return n.toString().padStart(2, "0");
1176
+ }
1177
+ function escapeYaml(s) {
1178
+ if (/[:#\n"']/.test(s)) {
1179
+ return JSON.stringify(s);
1180
+ }
1181
+ return s;
1182
+ }
1183
+
1184
+ // src/commands/notes/get.ts
1185
+ var ParagraphsListSchema = SimpleListResponseSchema(ParagraphSchema);
1186
+ var ParagraphsCursorSchema = PageCursorResponseSchema(ParagraphSchema);
1187
+ function registerNotesGet(parent) {
1188
+ parent.command("get <guid>").description("Get a single note (stdout or file)").option("--output <path>", "Write to file (stdout becomes metadata only)").option("--format <md|json|txt>", "Output format (default: md for TTY, json for pipe)").option(
1189
+ "--include <items>",
1190
+ "Comma-separated extras: transcript",
1191
+ ""
1192
+ ).option("--force", "Overwrite existing file").action(async (guid, opts, cmd) => {
1193
+ const globalOpts = cmd.optsWithGlobals();
1194
+ const client = createApiClient({
1195
+ ...globalOpts.hostname !== void 0 && { hostnameOverride: globalOpts.hostname }
1196
+ });
1197
+ const includes = parseIncludes(opts.include);
1198
+ const format = pickFormat(opts.format, opts.output);
1199
+ const note = await client.getJson(`/v1/external/notes/${guid}`, NoteSchema);
1200
+ let paragraphs;
1201
+ if (includes.has("transcript") || format === "txt") {
1202
+ paragraphs = await fetchAllParagraphs(client, guid);
1203
+ }
1204
+ const content = formatNote(note, format, {
1205
+ includeTranscript: includes.has("transcript"),
1206
+ ...paragraphs !== void 0 && { paragraphs }
1207
+ });
1208
+ if (opts.output) {
1209
+ const result = await writeFileAtomic(opts.output, content, {
1210
+ ...opts.force === true && { force: true }
1211
+ });
1212
+ printOutput(
1213
+ {
1214
+ ok: true,
1215
+ data: {
1216
+ saved: result.path,
1217
+ size: result.size,
1218
+ format,
1219
+ guid: note.guid,
1220
+ title: note.title
1221
+ }
1222
+ },
1223
+ globalOpts
1224
+ );
1225
+ return;
1226
+ }
1227
+ const mode = resolveOutputMode(globalOpts);
1228
+ if (mode === "json" && format !== "json") {
1229
+ printOutput(
1230
+ {
1231
+ ok: true,
1232
+ data: {
1233
+ ...note,
1234
+ ...paragraphs && { transcript: { paragraphs } }
1235
+ }
1236
+ },
1237
+ globalOpts
1238
+ );
1239
+ } else if (format === "json") {
1240
+ process.stdout.write(content);
1241
+ } else {
1242
+ if (process.stdout.isTTY) {
1243
+ process.stdout.write(`${color(`# ${note.title}`, "bold", globalOpts)}
1244
+ `);
1245
+ }
1246
+ process.stdout.write(content);
1247
+ }
1248
+ });
1249
+ }
1250
+ async function fetchAllParagraphs(client, guid) {
1251
+ const tryCursor = await client.getJson(
1252
+ `/v1/external/notes/${guid}/paragraphs`,
1253
+ ParagraphsCursorSchema.or(ParagraphsListSchema)
1254
+ );
1255
+ const all = [...tryCursor.content];
1256
+ if ("nextCursor" in tryCursor) {
1257
+ let cursor = tryCursor.nextCursor;
1258
+ while (cursor) {
1259
+ const next = await client.getJson(
1260
+ `/v1/external/notes/${guid}/paragraphs`,
1261
+ ParagraphsCursorSchema,
1262
+ { cursor }
1263
+ );
1264
+ all.push(...next.content);
1265
+ cursor = next.nextCursor;
1266
+ }
1267
+ }
1268
+ return all;
1269
+ }
1270
+ function parseIncludes(raw) {
1271
+ if (!raw) return /* @__PURE__ */ new Set();
1272
+ return new Set(
1273
+ raw.split(",").map((s) => s.trim().toLowerCase()).filter((s) => s.length > 0)
1274
+ );
1275
+ }
1276
+ function pickFormat(format, output) {
1277
+ const allowed = ["md", "json", "txt"];
1278
+ if (format) {
1279
+ const f = format.toLowerCase();
1280
+ if (!allowed.includes(f)) {
1281
+ throw new TiroError(
1282
+ {
1283
+ code: "invalid_format",
1284
+ message: `Invalid --format "${format}". Allowed: md, json, txt.`,
1285
+ errorType: "bad_request"
1286
+ },
1287
+ ExitCode.Usage
1288
+ );
1289
+ }
1290
+ return f;
1291
+ }
1292
+ if (output) {
1293
+ if (output.endsWith(".json")) return "json";
1294
+ if (output.endsWith(".txt")) return "txt";
1295
+ return "md";
1296
+ }
1297
+ return process.stdout.isTTY ? "md" : "json";
1298
+ }
1299
+
1300
+ // src/commands/notes/transcript.ts
1301
+ import "commander";
1302
+ var ParagraphsListSchema2 = SimpleListResponseSchema(ParagraphSchema);
1303
+ var ParagraphsCursorSchema2 = PageCursorResponseSchema(ParagraphSchema);
1304
+ function registerNotesTranscript(parent) {
1305
+ parent.command("transcript <guid>").description("Get raw transcript paragraphs of a note").option("--output <path>", "Write to file (stdout = metadata only)").option("--format <md|json|txt>", "Output format (default: txt for transcript)").option("--force", "Overwrite existing file").action(async (guid, opts, cmd) => {
1306
+ const globalOpts = cmd.optsWithGlobals();
1307
+ const client = createApiClient({
1308
+ ...globalOpts.hostname !== void 0 && { hostnameOverride: globalOpts.hostname }
1309
+ });
1310
+ const format = pickFormat2(opts.format, opts.output);
1311
+ const note = await client.getJson(`/v1/external/notes/${guid}`, NoteSchema);
1312
+ const paragraphs = await fetchAllParagraphs2(client, guid);
1313
+ const content = formatNote(note, format, {
1314
+ includeTranscript: true,
1315
+ paragraphs
1316
+ });
1317
+ if (opts.output) {
1318
+ const result = await writeFileAtomic(opts.output, content, {
1319
+ ...opts.force === true && { force: true }
1320
+ });
1321
+ printOutput(
1322
+ {
1323
+ ok: true,
1324
+ data: {
1325
+ saved: result.path,
1326
+ size: result.size,
1327
+ format,
1328
+ guid: note.guid,
1329
+ paragraphCount: paragraphs.length
1330
+ }
1331
+ },
1332
+ globalOpts
1333
+ );
1334
+ return;
1335
+ }
1336
+ const mode = resolveOutputMode(globalOpts);
1337
+ if (mode === "json" && format !== "json") {
1338
+ printOutput(
1339
+ { ok: true, data: { guid: note.guid, paragraphs } },
1340
+ globalOpts
1341
+ );
1342
+ } else {
1343
+ process.stdout.write(content);
1344
+ }
1345
+ });
1346
+ }
1347
+ async function fetchAllParagraphs2(client, guid) {
1348
+ const first = await client.getJson(
1349
+ `/v1/external/notes/${guid}/paragraphs`,
1350
+ ParagraphsCursorSchema2.or(ParagraphsListSchema2)
1351
+ );
1352
+ const all = [...first.content];
1353
+ if ("nextCursor" in first) {
1354
+ let cursor = first.nextCursor;
1355
+ while (cursor) {
1356
+ const next = await client.getJson(
1357
+ `/v1/external/notes/${guid}/paragraphs`,
1358
+ ParagraphsCursorSchema2,
1359
+ { cursor }
1360
+ );
1361
+ all.push(...next.content);
1362
+ cursor = next.nextCursor;
1363
+ }
1364
+ }
1365
+ return all;
1366
+ }
1367
+ function pickFormat2(format, output) {
1368
+ const allowed = ["md", "json", "txt"];
1369
+ if (format) {
1370
+ const f = format.toLowerCase();
1371
+ if (!allowed.includes(f)) {
1372
+ throw new TiroError(
1373
+ {
1374
+ code: "invalid_format",
1375
+ message: `Invalid --format "${format}". Allowed: md, json, txt.`,
1376
+ errorType: "bad_request"
1377
+ },
1378
+ ExitCode.Usage
1379
+ );
1380
+ }
1381
+ return f;
1382
+ }
1383
+ if (output) {
1384
+ if (output.endsWith(".json")) return "json";
1385
+ if (output.endsWith(".md")) return "md";
1386
+ return "txt";
1387
+ }
1388
+ return "txt";
1389
+ }
1390
+
1391
+ // src/commands/notes/index.ts
1392
+ function registerNotes(program) {
1393
+ const notes = program.command("notes").description("List, search, and download notes");
1394
+ registerNotesList(notes);
1395
+ registerNotesSearch(notes);
1396
+ registerNotesGet(notes);
1397
+ registerNotesTranscript(notes);
1398
+ }
1399
+
1400
+ // src/bin/tiro.ts
1401
+ var EXAMPLES = `
1402
+ EXAMPLES
1403
+ $ tiro auth login
1404
+ $ tiro notes search --speaker "\uAE40\uCCA0\uC218" --since 7d --json
1405
+ $ tiro notes get <guid> --output ./meeting.md --include transcript
1406
+ $ tiro notes transcript <guid> --format txt
1407
+
1408
+ ENVIRONMENT
1409
+ TIRO_TOKEN Bearer token (overrides keychain)
1410
+ TIRO_HOSTNAME API base URL (default: https://api.tiro.ooo)
1411
+ NO_COLOR Disable colors
1412
+
1413
+ DOCS
1414
+ https://api.tiro.ooo/cli
1415
+ `;
1416
+ function buildProgram() {
1417
+ const program = new Command10();
1418
+ program.name("tiro").description("Tiro AI notes & transcripts \u2014 agent-first command line").version(VERSION, "-v, --version", "Print version").option("--hostname <url>", "API base URL (default: https://api.tiro.ooo)").option("--json", "Force JSON output").option("--pretty", "Force pretty (human) output").option("--quiet", "Suppress non-error output").option("--verbose", "Verbose logging to stderr").option("--no-color", "Disable ANSI colors").addHelpText("after", EXAMPLES);
1419
+ program.showHelpAfterError("(run `tiro --help` for available commands)");
1420
+ registerAuth(program);
1421
+ registerNotes(program);
1422
+ return program;
1423
+ }
1424
+ async function main() {
1425
+ const program = buildProgram();
1426
+ try {
1427
+ await program.parseAsync(process.argv);
1428
+ } catch (err) {
1429
+ handleError(err, program);
1430
+ }
1431
+ }
1432
+ function handleError(err, program) {
1433
+ const opts = program.opts();
1434
+ if (err instanceof TiroError) {
1435
+ if (opts.json) {
1436
+ printError(err.toJSON());
1437
+ } else if (!opts.quiet) {
1438
+ process.stderr.write(`${color("\u2717", "red", opts)} ${err.message}
1439
+ `);
1440
+ if (err.suggestion) {
1441
+ process.stderr.write(` ${color("\u2192", "gray", opts)} ${err.suggestion}
1442
+ `);
1443
+ }
1444
+ }
1445
+ process.exit(err.exitCode);
1446
+ }
1447
+ if (err instanceof Error) {
1448
+ if (opts.json) {
1449
+ printError({
1450
+ ok: false,
1451
+ error: { code: "internal_error", message: err.message, errorType: "internal_error" }
1452
+ });
1453
+ } else if (!opts.quiet) {
1454
+ process.stderr.write(`${color("\u2717", "red", opts)} ${err.message}
1455
+ `);
1456
+ }
1457
+ process.exit(ExitCode.Generic);
1458
+ }
1459
+ process.stderr.write(`Unknown error: ${String(err)}
1460
+ `);
1461
+ process.exit(ExitCode.Generic);
1462
+ }
1463
+ main().catch((err) => {
1464
+ process.stderr.write(`Fatal: ${String(err)}
1465
+ `);
1466
+ process.exit(ExitCode.Generic);
1467
+ });
1468
+ //# sourceMappingURL=tiro.js.map