@bigstrider/transcodes-cli 0.1.0 → 0.3.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 +21 -6
  2. package/dist/index.js +1180 -26
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -7,12 +7,12 @@ The plugins and their hooks authenticate to the Transcodes backend with a member
7
7
  ## Install
8
8
 
9
9
  ```bash
10
- # no install needed
11
- npx @bigstrider/transcodes-cli login <token>
10
+ # no install needed — opens the dashboard
11
+ npx @bigstrider/transcodes-cli
12
12
 
13
13
  # or global
14
14
  npm install -g @bigstrider/transcodes-cli
15
- transcodes login <token>
15
+ transcodes
16
16
  ```
17
17
 
18
18
  Works the same on macOS, Linux, and Windows (Node ≥ 20).
@@ -21,10 +21,25 @@ Works the same on macOS, Linux, and Windows (Node ≥ 20).
21
21
 
22
22
  | Command | What it does |
23
23
  |---------|--------------|
24
- | `transcodes login <token>` | Validates the JWT and saves it to `~/.transcodes/config.json` (dir `0700`, file `0600`). |
25
- | `transcodes logout` | Deletes the saved token. |
24
+ | `transcodes` | Opens the dashboard at `http://127.0.0.1:3847/` to paste, save, switch, label, or delete tokens (accepts `--port N` / `--no-open`). |
25
+ | `transcodes set <token> -l <label>` | Validates the JWT and saves it (label required) to `~/.transcodes/config.json` (dir `0700`, file `0600`), making it active. |
26
+ | `transcodes tokens` | Lists all saved tokens; the active one is marked with `*`. |
26
27
  | `transcodes status` | Shows the active token source (env vs file) and its expiry. |
27
- | `transcodes help` | Usage. |
28
+ | `transcodes reset` | Deletes all saved tokens. |
29
+ | `transcodes help` | Shows the full command list and usage. |
30
+
31
+ ### Dashboard
32
+
33
+ ```bash
34
+ npx @bigstrider/transcodes-cli
35
+ ```
36
+
37
+ Starts a small localhost server (127.0.0.1 only), opens your browser, and lets you save, switch, rename, or delete tokens without pasting them on the command line. Multiple tokens are kept in `~/.transcodes/config.json` under `token_list`, each with a label; the active one is stored as `token`.
38
+
39
+ Options:
40
+
41
+ - `--port N` — bind to a specific port (default `3847`; increments if busy)
42
+ - `--no-open` — do not open the browser automatically
28
43
 
29
44
  ## Token precedence
30
45
 
package/dist/index.js CHANGED
@@ -1,5 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ // src/dashboard.ts
4
+ import {
5
+ createServer
6
+ } from "http";
7
+ import { spawn } from "child_process";
8
+ import { createHash } from "crypto";
9
+
3
10
  // ../stepup-core/dist/jwt.js
4
11
  var REQUIRED_AUDIENCE = "transcodes-mcp";
5
12
  function isPlainObject(v) {
@@ -106,7 +113,7 @@ function transcodesConfigDir() {
106
113
  function transcodesConfigFile() {
107
114
  return path.join(transcodesConfigDir(), CONFIG_FILE_NAME);
108
115
  }
109
- function readTokenFromFile() {
116
+ function readRawConfig() {
110
117
  let raw;
111
118
  try {
112
119
  raw = readFileSync(transcodesConfigFile(), "utf8");
@@ -122,29 +129,148 @@ function readTokenFromFile() {
122
129
  if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
123
130
  return null;
124
131
  }
125
- const token = parsed.token;
126
- if (typeof token !== "string")
132
+ return parsed;
133
+ }
134
+ function writeRawConfig(config) {
135
+ const dir = transcodesConfigDir();
136
+ mkdirSync(dir, { recursive: true, mode: 448 });
137
+ writeFileSync(transcodesConfigFile(), JSON.stringify(config), {
138
+ mode: 384
139
+ });
140
+ }
141
+ function normalizeToken(v) {
142
+ if (typeof v !== "string")
127
143
  return null;
128
- const trimmed = token.trim();
144
+ const trimmed = v.trim();
145
+ return trimmed.length > 0 ? trimmed : null;
146
+ }
147
+ function normalizeLabel(v) {
148
+ if (typeof v !== "string")
149
+ return null;
150
+ const trimmed = v.trim();
129
151
  return trimmed.length > 0 ? trimmed : null;
130
152
  }
131
- function writeTokenToFile(token) {
153
+ function normalizeRecord(item) {
154
+ if (typeof item === "string") {
155
+ const token = normalizeToken(item);
156
+ return token ? { token, label: null } : null;
157
+ }
158
+ if (item && typeof item === "object" && !Array.isArray(item)) {
159
+ const obj = item;
160
+ const token = normalizeToken(obj.token);
161
+ if (!token)
162
+ return null;
163
+ return { token, label: normalizeLabel(obj.label) };
164
+ }
165
+ return null;
166
+ }
167
+ function readConfig() {
168
+ const obj = readRawConfig();
169
+ if (!obj) {
170
+ return { token: null, tokenList: [] };
171
+ }
172
+ const list = [];
173
+ const seen = /* @__PURE__ */ new Set();
174
+ const push = (rec) => {
175
+ if (!rec)
176
+ return;
177
+ const existing = list.find((r) => r.token === rec.token);
178
+ if (existing) {
179
+ if (!existing.label && rec.label)
180
+ existing.label = rec.label;
181
+ return;
182
+ }
183
+ seen.add(rec.token);
184
+ list.push(rec);
185
+ };
186
+ if (Array.isArray(obj.token_list)) {
187
+ for (const item of obj.token_list)
188
+ push(normalizeRecord(item));
189
+ }
190
+ const active = normalizeToken(obj.token);
191
+ if (active)
192
+ push({ token: active, label: null });
193
+ return {
194
+ token: active ?? (list.length > 0 ? list[0].token : null),
195
+ tokenList: list
196
+ };
197
+ }
198
+ function writeConfig(config) {
199
+ const token_list = config.tokenList.map((r) => r.label ? { token: r.token, label: r.label } : { token: r.token });
200
+ writeRawConfig({
201
+ ...readRawConfig() ?? {},
202
+ token: config.token,
203
+ token_list
204
+ });
205
+ }
206
+ function readTokenFromFile() {
207
+ return readConfig().token;
208
+ }
209
+ function readTokenList() {
210
+ return readConfig().tokenList.map((r) => r.token);
211
+ }
212
+ function readTokenRecords() {
213
+ return readConfig().tokenList;
214
+ }
215
+ function writeTokenToFile(token, label) {
132
216
  const trimmed = token.trim();
133
217
  if (!trimmed) {
134
218
  throw new Error("token is empty");
135
219
  }
136
- const dir = transcodesConfigDir();
137
- mkdirSync(dir, { recursive: true, mode: 448 });
138
- writeFileSync(transcodesConfigFile(), JSON.stringify({ token: trimmed }), {
139
- mode: 384
140
- });
220
+ const nextLabel = normalizeLabel(label);
221
+ const current = readConfig();
222
+ const existing = current.tokenList.find((r) => r.token === trimmed);
223
+ if (!existing && !nextLabel) {
224
+ throw new Error("label is required");
225
+ }
226
+ const tokenList = existing ? current.tokenList.map((r) => r.token === trimmed ? { token: trimmed, label: nextLabel ?? r.label } : r) : [...current.tokenList, { token: trimmed, label: nextLabel }];
227
+ writeConfig({ token: trimmed, tokenList });
228
+ }
229
+ function setActiveToken(token) {
230
+ writeTokenToFile(token);
231
+ }
232
+ function setTokenLabel(token, label) {
233
+ const trimmed = token.trim();
234
+ const nextLabel = normalizeLabel(label);
235
+ if (!nextLabel) {
236
+ throw new Error("label is required");
237
+ }
238
+ const current = readConfig();
239
+ if (!current.tokenList.some((r) => r.token === trimmed)) {
240
+ throw new Error("token not found");
241
+ }
242
+ const tokenList = current.tokenList.map((r) => r.token === trimmed ? { token: r.token, label: nextLabel } : r);
243
+ writeConfig({ token: current.token, tokenList });
244
+ }
245
+ function removeTokenFromFile(token) {
246
+ const trimmed = token.trim();
247
+ const current = readConfig();
248
+ const tokenList = current.tokenList.filter((r) => r.token !== trimmed);
249
+ if (tokenList.length === 0) {
250
+ clearTokenFile();
251
+ return;
252
+ }
253
+ const active = current.token && tokenList.some((r) => r.token === current.token) ? current.token : tokenList[0].token;
254
+ writeConfig({ token: active, tokenList });
141
255
  }
142
256
  function clearTokenFile() {
257
+ const existing = readRawConfig();
143
258
  try {
259
+ if (existing && existing.enabled !== void 0) {
260
+ const { token: _token, token_list: _list, ...rest } = existing;
261
+ writeRawConfig(rest);
262
+ return;
263
+ }
144
264
  rmSync(transcodesConfigFile(), { force: true });
145
265
  } catch {
146
266
  }
147
267
  }
268
+ function isTrackerEnabled() {
269
+ return readRawConfig()?.enabled !== false;
270
+ }
271
+ function setTrackerEnabled(enabled) {
272
+ writeRawConfig({ ...readRawConfig() ?? {}, enabled });
273
+ }
148
274
  function resolveToken() {
149
275
  const envToken = process.env.TRANSCODES_TOKEN?.trim();
150
276
  if (envToken) {
@@ -157,18 +283,951 @@ function resolveToken() {
157
283
  return { token: null, source: "none" };
158
284
  }
159
285
 
286
+ // src/logo.ts
287
+ var LOGO_DATA_URI = "data:image/png;base64,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";
288
+
289
+ // src/dashboard.ts
290
+ var DEFAULT_PORT = 3847;
291
+ var HOST = "127.0.0.1";
292
+ function fingerprint(token) {
293
+ return createHash("sha256").update(token).digest("hex").slice(0, 12);
294
+ }
295
+ function readJsonBody(req) {
296
+ return new Promise((resolve, reject) => {
297
+ const chunks = [];
298
+ req.on("data", (c) => chunks.push(c));
299
+ req.on("end", () => {
300
+ const raw = Buffer.concat(chunks).toString("utf8");
301
+ if (!raw.trim()) {
302
+ resolve({});
303
+ return;
304
+ }
305
+ try {
306
+ resolve(JSON.parse(raw));
307
+ } catch {
308
+ reject(new Error("invalid JSON body"));
309
+ }
310
+ });
311
+ req.on("error", reject);
312
+ });
313
+ }
314
+ function buildStatus() {
315
+ const configPath = transcodesConfigFile();
316
+ const records = readTokenRecords();
317
+ const active = readTokenFromFile();
318
+ const envOverridesFile = Boolean(
319
+ process.env.TRANSCODES_TOKEN?.trim() && active
320
+ );
321
+ const tokens = records.map(({ token, label }) => {
322
+ const entry = {
323
+ id: fingerprint(token),
324
+ active: token === active
325
+ };
326
+ if (label) entry.label = label;
327
+ try {
328
+ const parsed = parseMemberAccessToken(token);
329
+ entry.projectId = parsed.claims.projectId;
330
+ entry.organizationId = parsed.claims.organizationId;
331
+ entry.expiresAt = new Date(parsed.claims.exp * 1e3).toISOString();
332
+ if (parsed.warnings.length > 0) entry.warnings = [...parsed.warnings];
333
+ } catch (err) {
334
+ entry.warnings = [err instanceof Error ? err.message : String(err)];
335
+ }
336
+ return entry;
337
+ });
338
+ return { configPath, envOverridesFile, tokens };
339
+ }
340
+ function tokenById(id) {
341
+ return readTokenList().find((t) => fingerprint(t) === id);
342
+ }
343
+ function sendJson(res, status, body) {
344
+ res.writeHead(status, {
345
+ "Content-Type": "application/json; charset=utf-8",
346
+ "Cache-Control": "no-store"
347
+ });
348
+ res.end(JSON.stringify(body));
349
+ }
350
+ function dashboardHtml() {
351
+ return `<!DOCTYPE html>
352
+ <html lang="en">
353
+ <head>
354
+ <meta charset="utf-8" />
355
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
356
+ <title>Transcodes \u2014 Token</title>
357
+ <style>
358
+ *, *::before, *::after { box-sizing: border-box; }
359
+ :root {
360
+ --bg: #f4f4f6;
361
+ --card: #ffffff;
362
+ --line: #ececf0;
363
+ --ink: #16161a;
364
+ --muted: #8a8a94;
365
+ --accent: #5b54e6;
366
+ --accent-soft: #eeedfb;
367
+ }
368
+ body {
369
+ margin: 0;
370
+ min-height: 100vh;
371
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
372
+ background: var(--bg);
373
+ color: var(--ink);
374
+ display: flex;
375
+ align-items: center;
376
+ justify-content: center;
377
+ padding: 32px;
378
+ -webkit-font-smoothing: antialiased;
379
+ }
380
+ .card {
381
+ width: 100%;
382
+ max-width: 460px;
383
+ background: var(--card);
384
+ border-radius: 24px;
385
+ padding: 34px;
386
+ box-shadow: 0 1px 2px rgba(16, 16, 26, 0.04), 0 12px 40px rgba(16, 16, 26, 0.06);
387
+ }
388
+ .header {
389
+ display: flex;
390
+ align-items: center;
391
+ gap: 16px;
392
+ padding-bottom: 24px;
393
+ border-bottom: 1px solid var(--line);
394
+ }
395
+ .avatar {
396
+ width: 56px;
397
+ height: 56px;
398
+ border-radius: 14px;
399
+ flex-shrink: 0;
400
+ object-fit: contain;
401
+ background: #f4f4f6;
402
+ padding: 8px;
403
+ }
404
+ .header h1 {
405
+ margin: 0;
406
+ font-size: 21px;
407
+ font-weight: 700;
408
+ letter-spacing: -0.02em;
409
+ }
410
+ .header p {
411
+ margin: 5px 0 0;
412
+ font-size: 14px;
413
+ color: var(--muted);
414
+ }
415
+ .tabs {
416
+ display: flex;
417
+ gap: 4px;
418
+ margin-top: 22px;
419
+ padding: 4px;
420
+ background: #f4f4f6;
421
+ border-radius: 13px;
422
+ }
423
+ .tab {
424
+ flex: 1;
425
+ padding: 9px 12px;
426
+ font-size: 13.5px;
427
+ font-weight: 600;
428
+ color: var(--muted);
429
+ background: transparent;
430
+ border: none;
431
+ border-radius: 9px;
432
+ cursor: pointer;
433
+ transition: background 0.15s, color 0.15s;
434
+ }
435
+ .tab:hover { color: var(--ink); }
436
+ .tab.active {
437
+ background: #fff;
438
+ color: var(--ink);
439
+ box-shadow: 0 1px 2px rgba(16, 16, 26, 0.08);
440
+ }
441
+ .panel { display: none; padding-top: 26px; }
442
+ .panel.active { display: block; }
443
+ .section-title {
444
+ font-size: 17px;
445
+ font-weight: 700;
446
+ margin: 0 0 4px;
447
+ letter-spacing: -0.01em;
448
+ }
449
+ .section-sub {
450
+ font-size: 14px;
451
+ color: var(--muted);
452
+ margin: 0 0 20px;
453
+ }
454
+ textarea {
455
+ width: 100%;
456
+ min-height: 92px;
457
+ padding: 14px 16px;
458
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
459
+ font-size: 12.5px;
460
+ line-height: 1.5;
461
+ color: var(--ink);
462
+ background: #fbfbfc;
463
+ border: 1px solid var(--line);
464
+ border-radius: 14px;
465
+ resize: vertical;
466
+ outline: none;
467
+ transition: border-color 0.15s, box-shadow 0.15s, background 0.15s;
468
+ }
469
+ textarea:focus {
470
+ background: #fff;
471
+ border-color: var(--accent);
472
+ box-shadow: 0 0 0 4px rgba(91, 84, 230, 0.12);
473
+ }
474
+ textarea::placeholder { color: #b9b9c2; }
475
+ .label-input {
476
+ width: 100%;
477
+ margin-top: 12px;
478
+ padding: 12px 16px;
479
+ font-size: 13.5px;
480
+ color: var(--ink);
481
+ background: #fbfbfc;
482
+ border: 1px solid var(--line);
483
+ border-radius: 14px;
484
+ outline: none;
485
+ transition: border-color 0.15s, box-shadow 0.15s, background 0.15s;
486
+ }
487
+ .label-input:focus {
488
+ background: #fff;
489
+ border-color: var(--accent);
490
+ box-shadow: 0 0 0 4px rgba(91, 84, 230, 0.12);
491
+ }
492
+ .label-input::placeholder { color: #b9b9c2; }
493
+ .actions {
494
+ display: flex;
495
+ gap: 12px;
496
+ margin-top: 18px;
497
+ }
498
+ .actions button {
499
+ flex: 1;
500
+ padding: 13px 18px;
501
+ font-size: 15px;
502
+ font-weight: 600;
503
+ border-radius: 14px;
504
+ border: none;
505
+ cursor: pointer;
506
+ transition: background 0.15s, opacity 0.15s, transform 0.05s;
507
+ }
508
+ .actions button:active:not(:disabled) { transform: translateY(1px); }
509
+ .actions button:disabled { opacity: 0.5; cursor: not-allowed; }
510
+ .btn-primary { background: var(--accent); color: #fff; }
511
+ .btn-primary:hover:not(:disabled) { background: #4a43d4; }
512
+ .btn-secondary {
513
+ background: #f4f4f6;
514
+ color: #5a5a64;
515
+ }
516
+ .btn-secondary:hover:not(:disabled) { background: #ececf0; }
517
+ .list-label {
518
+ margin: 26px 0 10px;
519
+ font-size: 13px;
520
+ font-weight: 600;
521
+ color: var(--muted);
522
+ letter-spacing: 0.01em;
523
+ }
524
+ .token-list { display: flex; flex-direction: column; gap: 10px; }
525
+ .token-empty {
526
+ padding: 16px 18px;
527
+ background: #fbfbfc;
528
+ border: 1px dashed var(--line);
529
+ border-radius: 16px;
530
+ font-size: 13.5px;
531
+ color: var(--muted);
532
+ text-align: center;
533
+ }
534
+ .token-row {
535
+ display: flex;
536
+ flex-direction: column;
537
+ gap: 14px;
538
+ padding: 16px;
539
+ background: #fbfbfc;
540
+ border: 1px solid var(--line);
541
+ border-radius: 16px;
542
+ transition: border-color 0.15s, background 0.15s;
543
+ }
544
+ .token-row.active {
545
+ border-color: var(--accent);
546
+ background: var(--accent-soft);
547
+ }
548
+ .token-top { display: flex; align-items: center; gap: 14px; }
549
+ .radio {
550
+ width: 18px;
551
+ height: 18px;
552
+ border-radius: 50%;
553
+ border: 2px solid #d0d0d8;
554
+ flex-shrink: 0;
555
+ position: relative;
556
+ transition: border-color 0.15s;
557
+ }
558
+ .token-row.active .radio { border-color: var(--accent); }
559
+ .token-row.active .radio::after {
560
+ content: "";
561
+ position: absolute;
562
+ inset: 3px;
563
+ border-radius: 50%;
564
+ background: var(--accent);
565
+ }
566
+ .token-info { flex: 1; min-width: 0; line-height: 1.45; }
567
+ .token-info .label {
568
+ font-size: 14.5px;
569
+ font-weight: 700;
570
+ color: var(--ink);
571
+ margin-bottom: 4px;
572
+ }
573
+ .token-info .field { font-size: 13px; color: #4a4a52; }
574
+ .token-info .field .k { color: var(--muted); }
575
+ .token-info .field code {
576
+ font-size: 12px;
577
+ color: var(--ink);
578
+ background: #fff;
579
+ border: 1px solid var(--line);
580
+ padding: 1px 7px;
581
+ border-radius: 6px;
582
+ }
583
+ .token-row.active .token-info .field code { background: #fff; }
584
+ .token-info .warn { font-size: 12px; color: #c0392f; margin-top: 2px; }
585
+ .token-actions {
586
+ display: flex;
587
+ gap: 8px;
588
+ padding-top: 12px;
589
+ border-top: 1px solid var(--line);
590
+ }
591
+ .token-row.active .token-actions { border-top-color: rgba(91, 84, 230, 0.18); }
592
+ .token-actions button {
593
+ flex: 1;
594
+ padding: 9px 12px;
595
+ font-size: 12px;
596
+ font-weight: 600;
597
+ letter-spacing: 0.03em;
598
+ border-radius: 10px;
599
+ border: 1px solid var(--line);
600
+ background: #fff;
601
+ cursor: pointer;
602
+ transition: background 0.15s, color 0.15s, border-color 0.15s, opacity 0.15s;
603
+ }
604
+ .btn-set { color: var(--accent); }
605
+ .btn-set:hover:not(:disabled) { background: var(--accent); color: #fff; border-color: var(--accent); }
606
+ .btn-set:disabled { opacity: 0.45; cursor: default; }
607
+ .btn-del { color: #c0392f; }
608
+ .btn-del:hover { background: #c0392f; color: #fff; border-color: #c0392f; }
609
+ .btn-edit, .btn-cancel { color: #5a5a64; }
610
+ .btn-edit:hover, .btn-cancel:hover { background: #ececf0; color: var(--ink); border-color: #dcdce2; }
611
+ .label-edit {
612
+ width: 100%;
613
+ padding: 9px 12px;
614
+ font-size: 13.5px;
615
+ font-weight: 600;
616
+ color: var(--ink);
617
+ background: #fff;
618
+ border: 1px solid var(--accent);
619
+ border-radius: 9px;
620
+ outline: none;
621
+ box-shadow: 0 0 0 4px rgba(91, 84, 230, 0.12);
622
+ }
623
+ .cmd-list { display: flex; flex-direction: column; gap: 10px; }
624
+ .cmd {
625
+ padding: 14px 16px;
626
+ background: #fbfbfc;
627
+ border: 1px solid var(--line);
628
+ border-radius: 14px;
629
+ }
630
+ .cmd code {
631
+ display: inline-block;
632
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
633
+ font-size: 12.5px;
634
+ color: var(--accent);
635
+ background: var(--accent-soft);
636
+ padding: 3px 9px;
637
+ border-radius: 7px;
638
+ }
639
+ .cmd .cmd-desc {
640
+ display: block;
641
+ margin-top: 8px;
642
+ font-size: 13px;
643
+ color: var(--muted);
644
+ line-height: 1.5;
645
+ }
646
+ .settings-list { display: flex; flex-direction: column; gap: 10px; }
647
+ .setting-row {
648
+ display: flex;
649
+ align-items: flex-start;
650
+ justify-content: space-between;
651
+ gap: 16px;
652
+ padding: 14px 16px;
653
+ background: #fbfbfc;
654
+ border: 1px solid var(--line);
655
+ border-radius: 14px;
656
+ }
657
+ .setting-info { flex: 1; min-width: 0; }
658
+ .setting-label {
659
+ display: block;
660
+ font-size: 14.5px;
661
+ font-weight: 600;
662
+ color: var(--ink);
663
+ letter-spacing: -0.01em;
664
+ }
665
+ .setting-desc {
666
+ margin: 8px 0 0;
667
+ font-size: 13px;
668
+ color: var(--muted);
669
+ line-height: 1.5;
670
+ }
671
+ .toggle {
672
+ position: relative;
673
+ width: 44px;
674
+ height: 26px;
675
+ flex: none;
676
+ flex-shrink: 0;
677
+ margin-top: 2px;
678
+ border: none;
679
+ border-radius: 999px;
680
+ background: #d8d8de;
681
+ cursor: pointer;
682
+ padding: 0;
683
+ transition: background 0.2s;
684
+ }
685
+ .toggle::after {
686
+ content: "";
687
+ position: absolute;
688
+ top: 3px;
689
+ left: 3px;
690
+ width: 20px;
691
+ height: 20px;
692
+ border-radius: 50%;
693
+ background: #fff;
694
+ box-shadow: 0 1px 3px rgba(16, 16, 26, 0.18);
695
+ transition: transform 0.2s;
696
+ }
697
+ .toggle.on { background: var(--accent); }
698
+ .toggle.on::after { transform: translateX(18px); }
699
+ .toggle:disabled { opacity: 0.5; cursor: not-allowed; }
700
+ .toast {
701
+ margin-top: 14px;
702
+ padding: 12px 16px;
703
+ border-radius: 12px;
704
+ font-size: 13.5px;
705
+ font-weight: 500;
706
+ display: none;
707
+ }
708
+ .toast.show { display: block; }
709
+ .toast.success { background: #effaf2; color: #1a7f45; }
710
+ .toast.error { background: #fdf0f0; color: #c0392f; }
711
+ .hint {
712
+ margin: 18px 0 0;
713
+ font-size: 12.5px;
714
+ color: #b9b9c2;
715
+ text-align: center;
716
+ line-height: 1.6;
717
+ }
718
+ .hint code {
719
+ font-size: 11.5px;
720
+ background: #f4f4f6;
721
+ padding: 2px 7px;
722
+ border-radius: 6px;
723
+ color: #8a8a94;
724
+ }
725
+ </style>
726
+ </head>
727
+ <body>
728
+ <div class="card">
729
+ <div class="header">
730
+ <img class="avatar" src="${LOGO_DATA_URI}" alt="Transcodes" />
731
+ <div>
732
+ <h1>Transcodes</h1>
733
+ <p>CLI Dashboard</p>
734
+ </div>
735
+ </div>
736
+ <div class="tabs">
737
+ <button type="button" class="tab active" data-tab="tokens">Tokens</button>
738
+ <button type="button" class="tab" data-tab="settings">Settings</button>
739
+ <button type="button" class="tab" data-tab="cli">CLI Commands</button>
740
+ </div>
741
+
742
+ <div class="panel active" id="panel-tokens">
743
+ <p class="section-title">MCP Agent Token</p>
744
+ <p class="section-sub">Paste the token from your Transcodes console member detail page</p>
745
+ <textarea id="token" placeholder="eyJhbGciOi\u2026" spellcheck="false" autocomplete="off"></textarea>
746
+ <input type="text" id="label" class="label-input" placeholder="Label (required) \u2014 e.g. transcodes-{project_name}-{env}" autocomplete="off" required />
747
+ <div class="actions">
748
+ <button type="button" class="btn-primary" id="save">Save</button>
749
+ <button type="button" class="btn-secondary" id="clear">Clear</button>
750
+ </div>
751
+ <div id="toast" class="toast"></div>
752
+ <p class="list-label">Saved tokens</p>
753
+ <div class="token-list" id="token-list"></div>
754
+ <p class="hint">Saved to <code>{{HOME_DIR}}/.transcodes/config.json</code><br />Press Ctrl+C in the terminal to stop</p>
755
+ </div>
756
+
757
+ <div class="panel" id="panel-cli">
758
+ <p class="section-title">CLI Commands</p>
759
+ <p class="section-sub">Run these from your terminal \u2014 the dashboard wraps the same actions</p>
760
+ <div class="cmd-list">
761
+ <div class="cmd"><code>transcodes</code><span class="cmd-desc">Open this dashboard (default, same as transcodes dashboard)</span></div>
762
+ <div class="cmd"><code>transcodes set &lt;token&gt; -l &lt;label&gt;</code><span class="cmd-desc">Validate and save a token with a label, then make it active</span></div>
763
+ <div class="cmd"><code>transcodes tokens</code><span class="cmd-desc">List all saved tokens (active one marked with *)</span></div>
764
+ <div class="cmd"><code>transcodes reset</code><span class="cmd-desc">Remove all saved tokens</span></div>
765
+ <div class="cmd"><code>transcodes help</code><span class="cmd-desc">Show the full command list and how to use each one</span></div>
766
+ </div>
767
+ </div>
768
+
769
+ <div class="panel" id="panel-settings">
770
+ <p class="section-title">Settings</p>
771
+ <p class="section-sub">Manage your local MCP Agent settings</p>
772
+ <div class="settings-list">
773
+ <div class="setting-row">
774
+ <div class="setting-info">
775
+ <span class="setting-label">Step-up Authentication</span>
776
+ <p class="setting-desc">When off, all features except Transcodes essentials are skipped</p>
777
+ </div>
778
+ <button type="button" class="toggle" id="stepup-toggle" aria-label="Step-up Authentication"></button>
779
+ </div>
780
+ </div>
781
+ <div id="settings-toast" class="toast"></div>
782
+ </div>
783
+ </div>
784
+ <script>
785
+ const tokenEl = document.getElementById("token");
786
+ const labelEl = document.getElementById("label");
787
+ const toastEl = document.getElementById("toast");
788
+ const listEl = document.getElementById("token-list");
789
+ const saveBtn = document.getElementById("save");
790
+ const clearBtn = document.getElementById("clear");
791
+
792
+ document.querySelectorAll(".tab").forEach((tab) => {
793
+ tab.addEventListener("click", () => {
794
+ const name = tab.getAttribute("data-tab");
795
+ document.querySelectorAll(".tab").forEach((t) =>
796
+ t.classList.toggle("active", t === tab));
797
+ document.querySelectorAll(".panel").forEach((p) =>
798
+ p.classList.toggle("active", p.id === "panel-" + name));
799
+ if (name === "settings") loadSettings();
800
+ });
801
+ });
802
+
803
+ function showToast(msg, kind) {
804
+ toastEl.textContent = msg;
805
+ toastEl.className = "toast show " + (kind || "success");
806
+ setTimeout(() => toastEl.classList.remove("show"), 4000);
807
+ }
808
+
809
+ function esc(s) {
810
+ return String(s).replace(/[&<>"]/g, (c) =>
811
+ ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
812
+ }
813
+
814
+ let lastStatus = { tokens: [] };
815
+ let editingId = null;
816
+
817
+ function renderTokens(s) {
818
+ if (!s.tokens || s.tokens.length === 0) {
819
+ listEl.innerHTML = '<div class="token-empty">No tokens saved yet \u2014 paste one above and press Save</div>';
820
+ return;
821
+ }
822
+
823
+ listEl.innerHTML = s.tokens.map((t) => {
824
+ const editing = t.id === editingId;
825
+ const project = t.projectId
826
+ ? '<div class="field"><span class="k">Project Id</span> <code>' + esc(t.projectId) + '</code></div>'
827
+ : '';
828
+ const org = t.organizationId
829
+ ? '<div class="field"><span class="k">Organization Id</span> <code>' + esc(t.organizationId) + '</code></div>'
830
+ : '';
831
+ const warn = t.warnings && t.warnings.length
832
+ ? '<div class="warn">' + esc(t.warnings.join("; ")) + '</div>'
833
+ : '';
834
+ const labelBlock = editing
835
+ ? '<input type="text" class="label-edit" data-edit-input="' + t.id + '" value="' + esc(t.label || "") + '" placeholder="Label" />'
836
+ : (t.label ? '<div class="label">' + esc(t.label) + '</div>' : '');
837
+ const actions = editing
838
+ ? '<button type="button" class="btn-set" data-save-label="' + t.id + '">SAVE</button>' +
839
+ '<button type="button" class="btn-cancel" data-cancel-edit="1">CANCEL</button>'
840
+ : '<button type="button" class="btn-edit" data-edit="' + t.id + '">EDIT</button>' +
841
+ '<button type="button" class="btn-set" data-set="' + t.id + '"' + (t.active ? " disabled" : "") + '>' +
842
+ (t.active ? "DEFAULT" : "SET DEFAULT") +
843
+ '</button>' +
844
+ '<button type="button" class="btn-del" data-del="' + t.id + '">DELETE</button>';
845
+ return (
846
+ '<div class="token-row' + (t.active ? " active" : "") + '" data-id="' + t.id + '">' +
847
+ '<div class="token-top">' +
848
+ '<span class="radio"></span>' +
849
+ '<div class="token-info">' + labelBlock + org + project + warn + '</div>' +
850
+ '</div>' +
851
+ '<div class="token-actions">' + actions + '</div>' +
852
+ '</div>'
853
+ );
854
+ }).join("");
855
+
856
+ if (editingId) {
857
+ const el = listEl.querySelector('[data-edit-input="' + editingId + '"]');
858
+ if (el) { el.focus(); el.select(); }
859
+ }
860
+ }
861
+
862
+ async function refresh() {
863
+ const res = await fetch("/api/status");
864
+ lastStatus = await res.json();
865
+ renderTokens(lastStatus);
866
+ }
867
+
868
+ async function saveLabel(id) {
869
+ const input = listEl.querySelector('[data-edit-input="' + id + '"]');
870
+ const label = input ? input.value.trim() : "";
871
+ if (!label) {
872
+ showToast("Label cannot be empty", "error");
873
+ return;
874
+ }
875
+ try {
876
+ const res = await fetch("/api/label", {
877
+ method: "POST",
878
+ headers: { "Content-Type": "application/json" },
879
+ body: JSON.stringify({ id, label }),
880
+ });
881
+ const data = await res.json();
882
+ if (!res.ok) throw new Error(data.error || "Rename failed");
883
+ editingId = null;
884
+ showToast("Label updated", "success");
885
+ refresh();
886
+ } catch (e) {
887
+ showToast(e.message || "Rename failed", "error");
888
+ }
889
+ }
890
+
891
+ async function setDefault(id) {
892
+ try {
893
+ const res = await fetch("/api/select", {
894
+ method: "POST",
895
+ headers: { "Content-Type": "application/json" },
896
+ body: JSON.stringify({ id }),
897
+ });
898
+ const data = await res.json();
899
+ if (!res.ok) throw new Error(data.error || "Set default failed");
900
+ showToast("Default token updated", "success");
901
+ refresh();
902
+ } catch (e) {
903
+ showToast(e.message || "Set default failed", "error");
904
+ }
905
+ }
906
+
907
+ async function removeToken(id) {
908
+ if (!confirm("Delete this token from the saved list?")) return;
909
+ try {
910
+ const res = await fetch("/api/token", {
911
+ method: "DELETE",
912
+ headers: { "Content-Type": "application/json" },
913
+ body: JSON.stringify({ id }),
914
+ });
915
+ const data = await res.json();
916
+ if (!res.ok) throw new Error(data.error || "Delete failed");
917
+ showToast("Token deleted", "success");
918
+ refresh();
919
+ } catch (e) {
920
+ showToast(e.message || "Delete failed", "error");
921
+ }
922
+ }
923
+
924
+ listEl.addEventListener("click", (e) => {
925
+ const editId = e.target.getAttribute("data-edit");
926
+ if (editId) { editingId = editId; renderTokens(lastStatus); return; }
927
+ const saveId = e.target.getAttribute("data-save-label");
928
+ if (saveId) { saveLabel(saveId); return; }
929
+ if (e.target.getAttribute("data-cancel-edit")) {
930
+ editingId = null; renderTokens(lastStatus); return;
931
+ }
932
+ const setId = e.target.getAttribute("data-set");
933
+ if (setId) { setDefault(setId); return; }
934
+ const delId = e.target.getAttribute("data-del");
935
+ if (delId) { removeToken(delId); return; }
936
+ });
937
+
938
+ listEl.addEventListener("keydown", (e) => {
939
+ const input = e.target.closest(".label-edit");
940
+ if (!input) return;
941
+ if (e.key === "Enter") { e.preventDefault(); saveLabel(editingId); }
942
+ else if (e.key === "Escape") { editingId = null; renderTokens(lastStatus); }
943
+ });
944
+
945
+ saveBtn.addEventListener("click", async () => {
946
+ const token = tokenEl.value.trim();
947
+ const label = labelEl.value.trim();
948
+ if (!token) {
949
+ showToast("Paste a token first", "error");
950
+ return;
951
+ }
952
+ if (!label) {
953
+ showToast("Add a label first", "error");
954
+ labelEl.focus();
955
+ return;
956
+ }
957
+ saveBtn.disabled = true;
958
+ try {
959
+ const res = await fetch("/api/token", {
960
+ method: "POST",
961
+ headers: { "Content-Type": "application/json" },
962
+ body: JSON.stringify({ token, label }),
963
+ });
964
+ const data = await res.json();
965
+ if (!res.ok) throw new Error(data.error || "Save failed");
966
+ tokenEl.value = "";
967
+ labelEl.value = "";
968
+ showToast("Token saved", "success");
969
+ refresh();
970
+ } catch (e) {
971
+ showToast(e.message || "Save failed", "error");
972
+ } finally {
973
+ saveBtn.disabled = false;
974
+ }
975
+ });
976
+
977
+ clearBtn.addEventListener("click", () => {
978
+ tokenEl.value = "";
979
+ labelEl.value = "";
980
+ tokenEl.focus();
981
+ });
982
+
983
+ const stepupToggleEl = document.getElementById("stepup-toggle");
984
+ const settingsToastEl = document.getElementById("settings-toast");
985
+
986
+ function showSettingsToast(msg, kind) {
987
+ settingsToastEl.textContent = msg;
988
+ settingsToastEl.className = "toast show " + (kind || "success");
989
+ setTimeout(() => settingsToastEl.classList.remove("show"), 4000);
990
+ }
991
+
992
+ function renderStepupToggle(enabled) {
993
+ stepupToggleEl.classList.toggle("on", enabled);
994
+ stepupToggleEl.setAttribute("aria-checked", enabled ? "true" : "false");
995
+ }
996
+
997
+ async function loadSettings() {
998
+ const res = await fetch("/api/settings");
999
+ const s = await res.json();
1000
+ renderStepupToggle(s.enabled !== false);
1001
+ }
1002
+
1003
+ stepupToggleEl.addEventListener("click", async () => {
1004
+ const next = !stepupToggleEl.classList.contains("on");
1005
+ stepupToggleEl.disabled = true;
1006
+ try {
1007
+ const res = await fetch("/api/settings", {
1008
+ method: "POST",
1009
+ headers: { "Content-Type": "application/json" },
1010
+ body: JSON.stringify({ enabled: next }),
1011
+ });
1012
+ const data = await res.json();
1013
+ if (!res.ok) throw new Error(data.error || "Save failed");
1014
+ renderStepupToggle(next);
1015
+ showSettingsToast(
1016
+ next ? "Step-up Authentication enabled" : "Step-up Authentication disabled",
1017
+ "success"
1018
+ );
1019
+ } catch (e) {
1020
+ showSettingsToast(e.message || "Save failed", "error");
1021
+ } finally {
1022
+ stepupToggleEl.disabled = false;
1023
+ }
1024
+ });
1025
+
1026
+ refresh();
1027
+ loadSettings();
1028
+ </script>
1029
+ </body>
1030
+ </html>`;
1031
+ }
1032
+ function openBrowser(url) {
1033
+ const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
1034
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
1035
+ try {
1036
+ const child = spawn(opener, args, { stdio: "ignore", detached: true });
1037
+ child.on("error", () => {
1038
+ });
1039
+ child.unref();
1040
+ } catch {
1041
+ }
1042
+ }
1043
+ function listen(port) {
1044
+ return new Promise((resolve, reject) => {
1045
+ const server = createServer(async (req, res) => {
1046
+ const url = req.url ?? "/";
1047
+ const method = req.method ?? "GET";
1048
+ const hostName = (req.headers.host ?? "").split(":")[0];
1049
+ if (hostName !== "127.0.0.1" && hostName !== "localhost") {
1050
+ res.writeHead(403, { "Content-Type": "text/plain" });
1051
+ res.end("forbidden host");
1052
+ return;
1053
+ }
1054
+ try {
1055
+ if (method === "GET" && (url === "/" || url === "/index.html")) {
1056
+ res.writeHead(200, {
1057
+ "Content-Type": "text/html; charset=utf-8",
1058
+ "Cache-Control": "no-store"
1059
+ });
1060
+ res.end(dashboardHtml());
1061
+ return;
1062
+ }
1063
+ if (method === "GET" && url === "/api/status") {
1064
+ sendJson(res, 200, buildStatus());
1065
+ return;
1066
+ }
1067
+ if (method === "POST" && url === "/api/token") {
1068
+ const body = await readJsonBody(req);
1069
+ const token = typeof body.token === "string" ? body.token.trim() : "";
1070
+ const label = typeof body.label === "string" ? body.label.trim() : "";
1071
+ if (!token) {
1072
+ sendJson(res, 400, { error: "token is required" });
1073
+ return;
1074
+ }
1075
+ if (!label) {
1076
+ sendJson(res, 400, { error: "label is required" });
1077
+ return;
1078
+ }
1079
+ try {
1080
+ parseMemberAccessToken(token);
1081
+ } catch (err) {
1082
+ sendJson(res, 400, {
1083
+ error: err instanceof Error ? err.message : String(err)
1084
+ });
1085
+ return;
1086
+ }
1087
+ writeTokenToFile(token, label);
1088
+ sendJson(res, 200, { ok: true, ...buildStatus() });
1089
+ return;
1090
+ }
1091
+ if (method === "POST" && url === "/api/label") {
1092
+ const body = await readJsonBody(req);
1093
+ const id = typeof body.id === "string" ? body.id : "";
1094
+ const label = typeof body.label === "string" ? body.label.trim() : "";
1095
+ const token = id ? tokenById(id) : void 0;
1096
+ if (!token) {
1097
+ sendJson(res, 404, { error: "token not found" });
1098
+ return;
1099
+ }
1100
+ if (!label) {
1101
+ sendJson(res, 400, { error: "label is required" });
1102
+ return;
1103
+ }
1104
+ setTokenLabel(token, label);
1105
+ sendJson(res, 200, { ok: true, ...buildStatus() });
1106
+ return;
1107
+ }
1108
+ if (method === "POST" && url === "/api/select") {
1109
+ const body = await readJsonBody(req);
1110
+ const id = typeof body.id === "string" ? body.id : "";
1111
+ const token = id ? tokenById(id) : void 0;
1112
+ if (!token) {
1113
+ sendJson(res, 404, { error: "token not found" });
1114
+ return;
1115
+ }
1116
+ setActiveToken(token);
1117
+ sendJson(res, 200, { ok: true, ...buildStatus() });
1118
+ return;
1119
+ }
1120
+ if (method === "DELETE" && url === "/api/token") {
1121
+ const body = await readJsonBody(req);
1122
+ const id = typeof body.id === "string" ? body.id : "";
1123
+ const token = id ? tokenById(id) : void 0;
1124
+ if (!token) {
1125
+ sendJson(res, 404, { error: "token not found" });
1126
+ return;
1127
+ }
1128
+ removeTokenFromFile(token);
1129
+ sendJson(res, 200, { ok: true, ...buildStatus() });
1130
+ return;
1131
+ }
1132
+ if (method === "GET" && url === "/api/settings") {
1133
+ sendJson(res, 200, {
1134
+ enabled: isTrackerEnabled()
1135
+ });
1136
+ return;
1137
+ }
1138
+ if (method === "POST" && url === "/api/settings") {
1139
+ const body = await readJsonBody(req);
1140
+ if (typeof body.enabled !== "boolean") {
1141
+ sendJson(res, 400, {
1142
+ error: "enabled must be a boolean"
1143
+ });
1144
+ return;
1145
+ }
1146
+ setTrackerEnabled(body.enabled);
1147
+ sendJson(res, 200, {
1148
+ ok: true,
1149
+ enabled: isTrackerEnabled()
1150
+ });
1151
+ return;
1152
+ }
1153
+ sendJson(res, 404, { error: "not found" });
1154
+ } catch (err) {
1155
+ sendJson(res, 500, {
1156
+ error: err instanceof Error ? err.message : String(err)
1157
+ });
1158
+ }
1159
+ });
1160
+ server.on("error", reject);
1161
+ server.listen(port, HOST, () => resolve(server));
1162
+ });
1163
+ }
1164
+ async function runDashboard(options) {
1165
+ const preferred = options.port ?? DEFAULT_PORT;
1166
+ let server;
1167
+ let port = preferred;
1168
+ for (let attempt = 0; attempt < 10; attempt++) {
1169
+ try {
1170
+ server = await listen(port);
1171
+ break;
1172
+ } catch (err) {
1173
+ const code = err.code;
1174
+ if (code === "EADDRINUSE") {
1175
+ port += 1;
1176
+ continue;
1177
+ }
1178
+ throw err;
1179
+ }
1180
+ }
1181
+ if (!server) {
1182
+ const last = preferred + 9;
1183
+ throw new Error(
1184
+ `could not find a free port in ${preferred}-${last} (all in use).
1185
+ A previous dashboard is probably still running.
1186
+ Tip: if you stopped one with Ctrl+Z it is only suspended (still alive) \u2014 use Ctrl+C to stop it.
1187
+ Free the ports and retry:
1188
+ macOS/Linux: lsof -ti tcp:${preferred}-${last} | xargs kill -9
1189
+ any platform: npx kill-port ${preferred} ${preferred + 1} # repeat per port
1190
+ Or choose another port: transcodes --port <N>`
1191
+ );
1192
+ }
1193
+ const url = `http://${HOST}:${port}/`;
1194
+ process.stdout.write(
1195
+ `Transcodes dashboard running at ${url}
1196
+ Config file: ${transcodesConfigFile()}
1197
+ Press Ctrl+C to stop
1198
+ `
1199
+ );
1200
+ if (options.open !== false) {
1201
+ openBrowser(url);
1202
+ }
1203
+ await new Promise((resolve) => {
1204
+ const onSignal = () => {
1205
+ server.close(() => resolve());
1206
+ };
1207
+ process.on("SIGINT", onSignal);
1208
+ process.on("SIGTERM", onSignal);
1209
+ });
1210
+ }
1211
+
160
1212
  // src/index.ts
161
1213
  var USAGE = `transcodes \u2014 ai-action-tracker token manager
162
1214
 
163
1215
  Usage:
164
- transcodes login <token> Save your Transcodes member token to ${transcodesConfigFile()}
165
- transcodes logout Remove the saved token
166
- transcodes status Show where the active token comes from
167
- transcodes help Show this message
1216
+ transcodes Open the dashboard at http://127.0.0.1:3847/ (add --port N or --no-open)
1217
+ transcodes set <token> -l <label> Save your Transcodes member token (label required) to ${transcodesConfigFile()}
1218
+ transcodes reset Remove all saved tokens
1219
+ transcodes enable Turn the ai-action-tracker step-up gate ON
1220
+ transcodes disable Turn the gate OFF (stops blocking Bash + MCP across all hosts)
1221
+ transcodes status Show the active token source, expiry, and gate state
1222
+ transcodes tokens List all saved tokens (active one marked with *)
1223
+ transcodes help Show this message
168
1224
 
169
1225
  The token is read by the ai-action-tracker plugins/hooks with precedence:
170
1226
  1. TRANSCODES_TOKEN environment variable (overrides everything)
171
1227
  2. ${transcodesConfigFile()}
1228
+
1229
+ enable/disable flips the \`enabled\` flag in the same file; it takes effect on
1230
+ the next hook invocation (no restart needed) and applies to every host.
172
1231
  `;
173
1232
  function fail(message) {
174
1233
  process.stderr.write(`transcodes: ${message}
@@ -185,11 +1244,27 @@ function expiryLine(token) {
185
1244
  return `unable to decode token: ${err instanceof Error ? err.message : String(err)}`;
186
1245
  }
187
1246
  }
188
- function cmdLogin(token) {
1247
+ function cmdSet(args) {
1248
+ let token;
1249
+ let label;
1250
+ for (let i = 0; i < args.length; i++) {
1251
+ const arg = args[i];
1252
+ if (arg === "-l" || arg === "--label") {
1253
+ label = args[++i];
1254
+ } else if (token === void 0) {
1255
+ token = arg;
1256
+ } else {
1257
+ fail(`unexpected argument "${arg}". Usage: transcodes set <token> -l <label>`);
1258
+ }
1259
+ }
189
1260
  if (!token || !token.trim()) {
190
- fail("missing token. Usage: transcodes login <token>");
1261
+ fail("missing token. Usage: transcodes set <token> -l <label>");
1262
+ }
1263
+ if (!label || !label.trim()) {
1264
+ fail("missing label. Usage: transcodes set <token> -l <label>");
191
1265
  }
192
1266
  const trimmed = token.trim();
1267
+ const trimmedLabel = label.trim();
193
1268
  try {
194
1269
  parseMemberAccessToken(trimmed);
195
1270
  } catch (err) {
@@ -198,7 +1273,7 @@ function cmdLogin(token) {
198
1273
  );
199
1274
  }
200
1275
  try {
201
- writeTokenToFile(trimmed);
1276
+ writeTokenToFile(trimmed, trimmedLabel);
202
1277
  } catch (err) {
203
1278
  fail(
204
1279
  `could not write token file: ${err instanceof Error ? err.message : String(err)}`
@@ -206,20 +1281,36 @@ function cmdLogin(token) {
206
1281
  }
207
1282
  process.stdout.write(
208
1283
  `Saved to ${transcodesConfigFile()}
209
- ${expiryLine(trimmed)}
1284
+ label=${trimmedLabel} ${expiryLine(trimmed)}
210
1285
  `
211
1286
  );
212
1287
  }
213
- function cmdLogout() {
1288
+ function cmdReset() {
214
1289
  clearTokenFile();
215
- process.stdout.write(`Removed ${transcodesConfigFile()}
1290
+ process.stdout.write(`Removed all saved tokens (${transcodesConfigFile()})
216
1291
  `);
217
1292
  }
1293
+ function cmdSetEnabled(enabled) {
1294
+ try {
1295
+ setTrackerEnabled(enabled);
1296
+ } catch (err) {
1297
+ fail(
1298
+ `could not update gate state: ${err instanceof Error ? err.message : String(err)}`
1299
+ );
1300
+ }
1301
+ process.stdout.write(
1302
+ enabled ? "ai-action-tracker gate ENABLED \u2014 danger commands require step-up MFA again.\n" : "ai-action-tracker gate DISABLED \u2014 Bash + MCP tool calls pass without step-up until `transcodes enable`.\n"
1303
+ );
1304
+ }
218
1305
  function cmdStatus() {
1306
+ process.stdout.write(
1307
+ `Gate: ${isTrackerEnabled() ? "enabled" : "DISABLED"}
1308
+ `
1309
+ );
219
1310
  const { token, source } = resolveToken();
220
1311
  if (source === "none" || !token) {
221
1312
  process.stdout.write(
222
- "No token configured. Run `transcodes login <token>` to set one.\n"
1313
+ "No token configured. Run `transcodes set <token> -l <label>` or `transcodes` to set one.\n"
223
1314
  );
224
1315
  return;
225
1316
  }
@@ -228,25 +1319,88 @@ function cmdStatus() {
228
1319
  ${expiryLine(token)}
229
1320
  `);
230
1321
  }
1322
+ function cmdTokens() {
1323
+ const records = readTokenRecords();
1324
+ if (records.length === 0) {
1325
+ process.stdout.write(
1326
+ "No tokens saved. Run `transcodes set <token> -l <label>` or `transcodes` to add one.\n"
1327
+ );
1328
+ return;
1329
+ }
1330
+ const active = readTokenFromFile();
1331
+ process.stdout.write(`Saved tokens (${transcodesConfigFile()}):
1332
+ `);
1333
+ for (const { token, label } of records) {
1334
+ const marker = token === active ? "*" : " ";
1335
+ process.stdout.write(` ${marker} ${label ?? "(no label)"}
1336
+ `);
1337
+ process.stdout.write(` ${expiryLine(token)}
1338
+ `);
1339
+ }
1340
+ const envToken = process.env.TRANSCODES_TOKEN?.trim();
1341
+ if (envToken) {
1342
+ process.stdout.write(
1343
+ "\nNote: TRANSCODES_TOKEN is set and overrides the active selection above.\n"
1344
+ );
1345
+ } else {
1346
+ process.stdout.write("\n* = active token used by the plugins/hooks.\n");
1347
+ }
1348
+ }
1349
+ async function cmdDashboard(args) {
1350
+ let port;
1351
+ let open = true;
1352
+ for (let i = 0; i < args.length; i++) {
1353
+ if (args[i] === "--port" && args[i + 1]) {
1354
+ port = Number(args[++i]);
1355
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
1356
+ fail("--port must be an integer between 1 and 65535");
1357
+ }
1358
+ } else if (args[i] === "--no-open") {
1359
+ open = false;
1360
+ } else {
1361
+ fail(`unknown flag "${args[i]}". Usage: transcodes [--port N] [--no-open]`);
1362
+ }
1363
+ }
1364
+ try {
1365
+ await runDashboard({ port, open });
1366
+ } catch (err) {
1367
+ fail(err instanceof Error ? err.message : String(err));
1368
+ }
1369
+ }
231
1370
  function main() {
232
1371
  const [command, ...rest] = process.argv.slice(2);
233
1372
  switch (command) {
234
- case "login":
235
- cmdLogin(rest[0]);
1373
+ case "set":
1374
+ cmdSet(rest);
1375
+ break;
1376
+ case "reset":
1377
+ cmdReset();
1378
+ break;
1379
+ case "enable":
1380
+ cmdSetEnabled(true);
236
1381
  break;
237
- case "logout":
238
- cmdLogout();
1382
+ case "disable":
1383
+ cmdSetEnabled(false);
239
1384
  break;
240
1385
  case "status":
241
1386
  cmdStatus();
242
1387
  break;
1388
+ case "tokens":
1389
+ cmdTokens();
1390
+ break;
243
1391
  case "help":
244
1392
  case "--help":
245
1393
  case "-h":
246
- case void 0:
247
1394
  process.stdout.write(USAGE);
248
1395
  break;
1396
+ case void 0:
1397
+ void cmdDashboard([]);
1398
+ break;
249
1399
  default:
1400
+ if (command.startsWith("-")) {
1401
+ void cmdDashboard([command, ...rest]);
1402
+ break;
1403
+ }
250
1404
  fail(`unknown command "${command}". Run \`transcodes help\`.`);
251
1405
  }
252
1406
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bigstrider/transcodes-cli",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Transcodes CLI — manage the ai-action-tracker member token (login/logout/status).",
5
5
  "type": "module",
6
6
  "bin": {