@mauricode/token-derby 1.1.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin.js CHANGED
@@ -250,7 +250,7 @@ function defaultColors() {
250
250
 
251
251
  // src/ui/HorseCreator.tsx
252
252
  import { jsx as jsx2, jsxs } from "react/jsx-runtime";
253
- function HorseCreator({ onSubmit, onCancel, initialColors, initialName, lockName }) {
253
+ function HorseCreator({ onSubmit, onCancel, initialColors, initialName, lockName, initialLevel }) {
254
254
  const [colors, setColors] = useState(initialColors ?? defaultColors());
255
255
  const [slotIdx, setSlotIdx] = useState(0);
256
256
  const [namingMode, setNamingMode] = useState(false);
@@ -296,6 +296,14 @@ function HorseCreator({ onSubmit, onCancel, initialColors, initialName, lockName
296
296
  onSubmit(value.trim(), colors);
297
297
  };
298
298
  return /* @__PURE__ */ jsxs(Box2, { flexDirection: "column", children: [
299
+ lockName && initialName && /* @__PURE__ */ jsxs(Box2, { marginBottom: 1, children: [
300
+ /* @__PURE__ */ jsx2(Text2, { bold: true, children: initialName }),
301
+ typeof initialLevel === "number" && /* @__PURE__ */ jsxs(Text2, { color: "cyan", children: [
302
+ " [Lvl. ",
303
+ initialLevel,
304
+ "]"
305
+ ] })
306
+ ] }),
299
307
  /* @__PURE__ */ jsx2(Box2, { marginBottom: 1, children: /* @__PURE__ */ jsx2(HorseSprite, { sprite: MAIN_SPRITE, colors }) }),
300
308
  /* @__PURE__ */ jsx2(Box2, { flexDirection: "column", children: SLOTS.map((s, i) => /* @__PURE__ */ jsxs(Text2, { children: [
301
309
  i === slotIdx ? "\u25BA" : " ",
@@ -318,9 +326,72 @@ function HorseCreator({ onSubmit, onCancel, initialColors, initialName, lockName
318
326
  ] });
319
327
  }
320
328
 
321
- // src/stable/stable.ts
322
- import * as fs from "fs/promises";
323
- import { randomUUID } from "crypto";
329
+ // src/config.ts
330
+ var DEFAULT_API_BASE = "https://token-derby.mauricode.co.uk/api";
331
+ function apiBase() {
332
+ return process.env.TOKEN_DERBY_API_BASE ?? DEFAULT_API_BASE;
333
+ }
334
+ var HEARTBEAT_INTERVAL_MS = 6e4;
335
+ var HEARTBEAT_RETRY_DELAYS_MS = [1e3, 2e3, 4e3, 8e3, 15e3];
336
+
337
+ // src/version.ts
338
+ import { createRequire } from "module";
339
+ function readVersion() {
340
+ if ("2.0.0".length > 0) {
341
+ return "2.0.0";
342
+ }
343
+ try {
344
+ const req = createRequire(import.meta.url);
345
+ const pkg = req("../package.json");
346
+ if (typeof pkg.version === "string") return pkg.version;
347
+ } catch {
348
+ }
349
+ return "0.0.0-dev";
350
+ }
351
+ var CLI_VERSION = readVersion();
352
+
353
+ // ../shared/dist/constants.js
354
+ var CLI_VERSION_HEADER = "x-cli-version";
355
+ var USER_ID_HEADER = "x-user-id";
356
+ var USER_TOKEN_HEADER = "x-user-token";
357
+ var USER_NAME_MAX_LENGTH = 40;
358
+ var ORG_NAME_MAX_LENGTH = 12;
359
+ var ORG_NAME_PATTERN = /^[A-Za-z0-9]{1,12}$/;
360
+
361
+ // ../shared/dist/levels.js
362
+ var MAX_LEVEL = 30;
363
+ function xpForLevel(n) {
364
+ return 2.5 * n ** 3 + 20 * n ** 2 + 50 * n - 22.5;
365
+ }
366
+ function thresholdForLevel(level) {
367
+ if (level <= 1)
368
+ return 0;
369
+ return Math.round(xpForLevel(level - 1));
370
+ }
371
+ var XP_THRESHOLDS = Array.from({ length: MAX_LEVEL }, (_, i) => thresholdForLevel(i + 1));
372
+ function levelFromXp(xp) {
373
+ const v = Math.max(0, Math.floor(xp));
374
+ let level = 1;
375
+ while (level < MAX_LEVEL && v >= thresholdForLevel(level + 1)) {
376
+ level++;
377
+ }
378
+ return level;
379
+ }
380
+ function levelInfo(xp) {
381
+ const v = Math.max(0, Math.floor(xp));
382
+ const level = levelFromXp(v);
383
+ const level_start_xp = thresholdForLevel(level);
384
+ const isMax = level >= MAX_LEVEL;
385
+ const next_level_xp = isMax ? null : thresholdForLevel(level + 1);
386
+ const xp_into_level = v - level_start_xp;
387
+ const xp_for_level = isMax ? null : next_level_xp - level_start_xp;
388
+ const progress = isMax ? 1 : Math.min(1, xp_into_level / Math.max(1, xp_for_level));
389
+ return { level, xp: v, level_start_xp, next_level_xp, xp_into_level, xp_for_level, progress };
390
+ }
391
+
392
+ // src/identity/identity.ts
393
+ import { promises as fs } from "fs";
394
+ import * as path2 from "path";
324
395
 
325
396
  // src/paths.ts
326
397
  import * as os from "os";
@@ -328,9 +399,6 @@ import * as path from "path";
328
399
  function homeDir() {
329
400
  return process.env.TOKEN_DERBY_HOME ?? path.join(os.homedir(), ".token-derby");
330
401
  }
331
- function stableFile() {
332
- return path.join(homeDir(), "stable.json");
333
- }
334
402
  function identityFile() {
335
403
  return path.join(homeDir(), "identity.json");
336
404
  }
@@ -344,78 +412,185 @@ function claudeProjectsDir() {
344
412
  return process.env.TOKEN_DERBY_CLAUDE_DIR ?? path.join(os.homedir(), ".claude", "projects");
345
413
  }
346
414
 
347
- // src/stable/stable.ts
348
- function newStableHorseId() {
349
- return randomUUID();
350
- }
351
- async function loadStable() {
415
+ // src/identity/identity.ts
416
+ async function loadIdentity() {
352
417
  try {
353
- const raw = await fs.readFile(stableFile(), "utf8");
418
+ const raw = await fs.readFile(identityFile(), "utf8");
354
419
  const parsed = JSON.parse(raw);
355
- if (!parsed || !Array.isArray(parsed.horses)) return { horses: [] };
356
- const stable = parsed;
357
- let mutated = false;
358
- const horses = stable.horses.map((h) => {
359
- if (typeof h.stable_horse_id === "string" && h.stable_horse_id.length > 0) {
360
- return h;
361
- }
362
- mutated = true;
363
- return { ...h, stable_horse_id: newStableHorseId() };
364
- });
365
- const result = { horses };
366
- if (mutated) await saveStable(result);
367
- return result;
420
+ if (typeof parsed.user_id === "string" && typeof parsed.display_name === "string" && typeof parsed.secret_token === "string" && typeof parsed.created_at === "string") {
421
+ return parsed;
422
+ }
423
+ return null;
368
424
  } catch (e) {
369
- if (e?.code === "ENOENT") return { horses: [] };
370
- if (e instanceof SyntaxError) return { horses: [] };
371
- throw e;
425
+ if (e?.code === "ENOENT") return null;
426
+ return null;
372
427
  }
373
428
  }
374
- async function saveStable(stable) {
429
+ async function saveIdentity(identity) {
375
430
  await fs.mkdir(homeDir(), { recursive: true });
376
- await fs.writeFile(stableFile(), JSON.stringify(stable, null, 2) + "\n", "utf8");
431
+ await fs.writeFile(identityFile(), JSON.stringify(identity, null, 2) + "\n", "utf8");
377
432
  }
378
- async function upsertHorse(horse) {
379
- const stable = await loadStable();
380
- const idx = stable.horses.findIndex((h) => h.name === horse.name);
381
- if (idx >= 0) stable.horses[idx] = horse;
382
- else stable.horses.push(horse);
383
- await saveStable(stable);
433
+ async function deleteIdentity() {
434
+ try {
435
+ await fs.unlink(identityFile());
436
+ } catch (e) {
437
+ if (e?.code !== "ENOENT") throw e;
438
+ }
384
439
  }
385
- async function removeHorse(name) {
386
- const stable = await loadStable();
387
- stable.horses = stable.horses.filter((h) => h.name !== name);
388
- await saveStable(stable);
440
+ function validateDisplayName(name) {
441
+ const trimmed = name.trim();
442
+ if (trimmed.length < 1) return { ok: false, error: "Name cannot be empty." };
443
+ if (trimmed.length > USER_NAME_MAX_LENGTH) {
444
+ return { ok: false, error: `Name must be ${USER_NAME_MAX_LENGTH} characters or fewer.` };
445
+ }
446
+ return { ok: true, name: trimmed };
447
+ }
448
+
449
+ // src/api/client.ts
450
+ var ApiError = class extends Error {
451
+ constructor(code, message, status) {
452
+ super(message);
453
+ this.code = code;
454
+ this.status = status;
455
+ this.name = "ApiError";
456
+ }
457
+ code;
458
+ status;
459
+ };
460
+ var identityCache = null;
461
+ function getIdentity() {
462
+ if (!identityCache) identityCache = loadIdentity();
463
+ return identityCache;
389
464
  }
390
- function findHorse(stable, name) {
391
- return stable.horses.find((h) => h.name === name);
465
+ function _resetIdentityCacheForTests() {
466
+ identityCache = null;
467
+ }
468
+ async function request(method, path5, body, horseAuthToken, fetchImpl = fetch) {
469
+ const url = path5.startsWith("http") ? path5 : `${apiBase()}${path5}`;
470
+ const headers = {};
471
+ headers[CLI_VERSION_HEADER] = CLI_VERSION;
472
+ headers["user-agent"] = `token-derby/${CLI_VERSION}`;
473
+ const identity = await getIdentity();
474
+ if (identity) {
475
+ headers[USER_ID_HEADER] = identity.user_id;
476
+ headers[USER_TOKEN_HEADER] = identity.secret_token;
477
+ }
478
+ if (horseAuthToken) headers["authorization"] = `Bearer ${horseAuthToken}`;
479
+ if (body !== void 0) headers["content-type"] = "application/json";
480
+ let res;
481
+ try {
482
+ res = await fetchImpl(url, {
483
+ method,
484
+ headers,
485
+ body: body !== void 0 ? JSON.stringify(body) : void 0
486
+ });
487
+ } catch (e) {
488
+ throw new ApiError("NETWORK_ERROR", e?.message ?? "fetch failed", 0);
489
+ }
490
+ const text = await res.text();
491
+ const contentType = res.headers.get("content-type") ?? "";
492
+ let parsed = null;
493
+ if (contentType.includes("application/json") && text.length > 0) {
494
+ try {
495
+ parsed = JSON.parse(text);
496
+ } catch {
497
+ parsed = null;
498
+ }
499
+ }
500
+ if (!res.ok) {
501
+ if (parsed && typeof parsed.code === "string") {
502
+ throw new ApiError(parsed.code, parsed.message ?? "API error", res.status);
503
+ }
504
+ throw new ApiError("NETWORK_ERROR", `HTTP ${res.status}`, res.status);
505
+ }
506
+ return parsed;
507
+ }
508
+
509
+ // src/api/endpoints.ts
510
+ function createRace(body) {
511
+ return request("POST", "/races", body, void 0);
512
+ }
513
+ function getRace(joinCode) {
514
+ return request("GET", `/races/${encodeURIComponent(joinCode)}`, void 0, void 0);
515
+ }
516
+ function joinRace(joinCode, body) {
517
+ return request("POST", `/races/${encodeURIComponent(joinCode)}/join`, body, void 0);
518
+ }
519
+ function heartbeat(joinCode, horseId, token, body) {
520
+ return request(
521
+ "POST",
522
+ `/races/${encodeURIComponent(joinCode)}/horses/${encodeURIComponent(horseId)}/heartbeat`,
523
+ body,
524
+ token
525
+ );
526
+ }
527
+ function endRace(adminCode) {
528
+ return request("DELETE", `/races/admin/${encodeURIComponent(adminCode)}`, void 0, void 0);
529
+ }
530
+ function createOrganisation(body) {
531
+ return request("POST", "/organisations", body, void 0);
532
+ }
533
+ function joinOrganisation(body) {
534
+ return request("POST", "/organisations/join", body, void 0);
535
+ }
536
+ function listOrganisations() {
537
+ return request("GET", "/organisations", void 0, void 0);
538
+ }
539
+ function getOrganisation(name) {
540
+ return request("GET", `/organisations/${encodeURIComponent(name)}`, void 0, void 0);
541
+ }
542
+ function initJockey(body) {
543
+ return request("POST", "/jockey/init", body, void 0);
544
+ }
545
+ function updateJockey(body) {
546
+ return request("PUT", "/jockey/me", body, void 0);
547
+ }
548
+ function listStable() {
549
+ return request("GET", "/jockey/me/horses", void 0, void 0);
550
+ }
551
+ function createStableHorse(body) {
552
+ return request("POST", "/jockey/me/horses", body, void 0);
553
+ }
554
+ function updateStableHorse(stableHorseId, body) {
555
+ return request(
556
+ "PUT",
557
+ `/jockey/me/horses/${encodeURIComponent(stableHorseId)}`,
558
+ body,
559
+ void 0
560
+ );
561
+ }
562
+ function deleteStableHorse(stableHorseId) {
563
+ return request(
564
+ "DELETE",
565
+ `/jockey/me/horses/${encodeURIComponent(stableHorseId)}`,
566
+ void 0,
567
+ void 0
568
+ );
392
569
  }
393
570
 
394
571
  // src/commands/stable-create.ts
395
- import * as readline from "readline/promises";
396
- import { stdin, stdout } from "process";
397
572
  async function stableCreateCommand() {
398
573
  let exitCode = 0;
399
574
  const app = render(
400
575
  React2.createElement(HorseCreator, {
401
576
  onSubmit: async (name, colors) => {
402
- const stable = await loadStable();
403
- const existing = findHorse(stable, name);
404
- if (existing) {
577
+ try {
578
+ await createStableHorse({ name, colors });
405
579
  app.unmount();
406
- const rl = readline.createInterface({ input: stdin, output: stdout });
407
- const answer = (await rl.question(`Horse "${name}" already exists. Overwrite? [y/N] `)).trim().toLowerCase();
408
- rl.close();
409
- if (answer !== "y" && answer !== "yes") {
410
- console.log("Cancelled.");
580
+ console.log(`\u2713 Saved "${name}" to your stable.`);
581
+ } catch (e) {
582
+ app.unmount();
583
+ if (e instanceof ApiError) {
584
+ if (e.code === "STABLE_HORSE_NAME_TAKEN") {
585
+ console.error(`A horse named "${name}" already exists. Pick a different name or use \`token-derby stable edit ${name}\` to modify it.`);
586
+ } else {
587
+ console.error(`Error: ${e.code} ${e.message}`);
588
+ }
411
589
  exitCode = 1;
412
590
  return;
413
591
  }
592
+ throw e;
414
593
  }
415
- const stable_horse_id = existing?.stable_horse_id ?? newStableHorseId();
416
- await upsertHorse({ stable_horse_id, name, colors, created_at: (/* @__PURE__ */ new Date()).toISOString() });
417
- app.unmount();
418
- console.log(`\u2713 Saved "${name}" to your stable.`);
419
594
  },
420
595
  onCancel: () => {
421
596
  app.unmount();
@@ -433,13 +608,23 @@ import React3 from "react";
433
608
  import { render as render2, Box as Box3, Text as Text3 } from "ink";
434
609
  import { jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
435
610
  async function stableListCommand() {
436
- const stable = await loadStable();
437
- if (stable.horses.length === 0) {
611
+ let horses;
612
+ try {
613
+ const resp = await listStable();
614
+ horses = resp.horses;
615
+ } catch (e) {
616
+ if (e instanceof ApiError) {
617
+ console.error(`Error: ${e.code} ${e.message}`);
618
+ return 1;
619
+ }
620
+ throw e;
621
+ }
622
+ if (horses.length === 0) {
438
623
  console.log("Your stable is empty. Run `token-derby stable create` to add a horse.");
439
624
  return 0;
440
625
  }
441
626
  const app = render2(
442
- React3.createElement(StableList, { horses: stable.horses })
627
+ React3.createElement(StableList, { horses })
443
628
  );
444
629
  await app.waitUntilExit();
445
630
  return 0;
@@ -458,269 +643,127 @@ function StableList({ horses }) {
458
643
  /* @__PURE__ */ jsx3(HorseSprite, { sprite: MINI_SPRITE, colors: h.colors }),
459
644
  /* @__PURE__ */ jsxs2(Text3, { children: [
460
645
  " ",
461
- h.name
646
+ h.name,
647
+ " ",
648
+ /* @__PURE__ */ jsxs2(Text3, { color: "cyan", children: [
649
+ "[Lvl. ",
650
+ levelFromXp(h.xp),
651
+ "]"
652
+ ] })
462
653
  ] })
463
- ] }, h.name))
654
+ ] }, h.stable_horse_id))
464
655
  ] });
465
656
  }
466
657
 
467
658
  // src/commands/stable-delete.ts
468
- import * as readline2 from "readline/promises";
469
- import { stdin as stdin2, stdout as stdout2 } from "process";
470
-
471
- // src/stable/active-race.ts
472
- import * as fs2 from "fs/promises";
473
- import * as path2 from "path";
474
- async function loadActiveRace(joinCode) {
475
- try {
476
- const raw = await fs2.readFile(activeRaceFile(joinCode), "utf8");
477
- return JSON.parse(raw);
478
- } catch (e) {
479
- if (e?.code === "ENOENT") return null;
480
- throw e;
481
- }
482
- }
483
- async function saveActiveRace(active) {
484
- await fs2.mkdir(activeRacesDir(), { recursive: true });
485
- await fs2.writeFile(
486
- activeRaceFile(active.join_code),
487
- JSON.stringify(active, null, 2) + "\n",
488
- "utf8"
489
- );
490
- }
491
- async function listActiveRaces() {
492
- try {
493
- const entries = await fs2.readdir(activeRacesDir());
494
- return entries.filter((f) => f.endsWith(".json")).map((f) => path2.basename(f, ".json"));
495
- } catch (e) {
496
- if (e?.code === "ENOENT") return [];
497
- throw e;
498
- }
499
- }
500
-
501
- // src/commands/stable-delete.ts
659
+ import * as readline from "readline/promises";
660
+ import { stdin, stdout } from "process";
502
661
  async function stableDeleteCommand(name) {
503
662
  if (!name) {
504
- console.error("Usage: token-derby stable delete <name>");
505
- return 2;
506
- }
507
- const stable = await loadStable();
508
- const horse = findHorse(stable, name);
509
- if (!horse) {
510
- console.error(`No horse named "${name}" in your stable.`);
511
- return 1;
512
- }
513
- const codes = await listActiveRaces();
514
- for (const code of codes) {
515
- const active = await loadActiveRace(code);
516
- if (active?.horse_name === name) {
517
- console.error(`"${name}" is currently running in race ${code}. Close that terminal first.`);
518
- return 1;
519
- }
520
- }
521
- const rl = readline2.createInterface({ input: stdin2, output: stdout2 });
522
- const answer = (await rl.question(`Delete "${name}" from your stable? [y/N] `)).trim().toLowerCase();
523
- rl.close();
524
- if (answer !== "y" && answer !== "yes") {
525
- console.log("Cancelled.");
526
- return 1;
527
- }
528
- await removeHorse(name);
529
- console.log(`\u2713 Deleted "${name}".`);
530
- return 0;
531
- }
532
-
533
- // src/commands/stable-edit.ts
534
- import React4 from "react";
535
- import { render as render3 } from "ink";
536
- async function stableEditCommand(name) {
537
- if (!name) {
538
- console.error("Usage: token-derby stable edit <name>");
539
- return 2;
540
- }
541
- const stable = await loadStable();
542
- const existing = findHorse(stable, name);
543
- if (!existing) {
544
- console.error(`No horse named "${name}" in your stable.`);
545
- return 1;
546
- }
547
- let exitCode = 0;
548
- const app = render3(
549
- React4.createElement(HorseCreator, {
550
- initialColors: existing.colors,
551
- initialName: existing.name,
552
- lockName: true,
553
- onSubmit: async (_name, colors) => {
554
- await upsertHorse({
555
- stable_horse_id: existing.stable_horse_id,
556
- name: existing.name,
557
- colors,
558
- created_at: existing.created_at
559
- });
560
- app.unmount();
561
- console.log(`\u2713 Updated "${existing.name}".`);
562
- },
563
- onCancel: () => {
564
- app.unmount();
565
- console.log("Cancelled.");
566
- exitCode = 1;
567
- }
568
- })
569
- );
570
- await app.waitUntilExit();
571
- return exitCode;
572
- }
573
-
574
- // src/commands/create.ts
575
- import * as readline3 from "readline/promises";
576
- import { stdin as stdin3, stdout as stdout3 } from "process";
577
-
578
- // src/config.ts
579
- var DEFAULT_API_BASE = "https://token-derby.mauricode.co.uk/api";
580
- function apiBase() {
581
- return process.env.TOKEN_DERBY_API_BASE ?? DEFAULT_API_BASE;
582
- }
583
- var HEARTBEAT_INTERVAL_MS = 6e4;
584
- var POLL_INTERVAL_MS = 3e3;
585
- var HEARTBEAT_RETRY_DELAYS_MS = [1e3, 2e3, 4e3, 8e3, 15e3];
586
-
587
- // src/version.ts
588
- import { createRequire } from "module";
589
- function readVersion() {
590
- if ("1.1.0".length > 0) {
591
- return "1.1.0";
592
- }
593
- try {
594
- const req = createRequire(import.meta.url);
595
- const pkg = req("../package.json");
596
- if (typeof pkg.version === "string") return pkg.version;
597
- } catch {
598
- }
599
- return "0.0.0-dev";
600
- }
601
- var CLI_VERSION = readVersion();
602
-
603
- // ../shared/dist/constants.js
604
- var CLI_VERSION_HEADER = "x-cli-version";
605
- var USER_ID_HEADER = "x-user-id";
606
- var USER_NAME_HEADER = "x-user-name";
607
- var USER_NAME_MAX_LENGTH = 40;
608
-
609
- // src/identity/identity.ts
610
- import { promises as fs3 } from "fs";
611
- import * as path3 from "path";
612
- import * as crypto from "crypto";
613
- async function loadIdentity() {
614
- try {
615
- const raw = await fs3.readFile(identityFile(), "utf8");
616
- const parsed = JSON.parse(raw);
617
- if (typeof parsed.user_id === "string" && typeof parsed.display_name === "string" && typeof parsed.created_at === "string") {
618
- return parsed;
619
- }
620
- return null;
621
- } catch (e) {
622
- if (e?.code === "ENOENT") return null;
623
- return null;
624
- }
625
- }
626
- async function saveIdentity(identity) {
627
- await fs3.mkdir(homeDir(), { recursive: true });
628
- await fs3.writeFile(identityFile(), JSON.stringify(identity, null, 2) + "\n", "utf8");
629
- }
630
- function generateUserId() {
631
- return crypto.randomUUID();
632
- }
633
- function validateDisplayName(name) {
634
- const trimmed = name.trim();
635
- if (trimmed.length < 1) return { ok: false, error: "Name cannot be empty." };
636
- if (trimmed.length > USER_NAME_MAX_LENGTH) {
637
- return { ok: false, error: `Name must be ${USER_NAME_MAX_LENGTH} characters or fewer.` };
638
- }
639
- return { ok: true, name: trimmed };
640
- }
641
-
642
- // src/api/client.ts
643
- var ApiError = class extends Error {
644
- constructor(code, message, status) {
645
- super(message);
646
- this.code = code;
647
- this.status = status;
648
- this.name = "ApiError";
649
- }
650
- code;
651
- status;
652
- };
653
- var identityCache = null;
654
- function getIdentity() {
655
- if (!identityCache) identityCache = loadIdentity();
656
- return identityCache;
657
- }
658
- async function request(method, path5, body, authToken, fetchImpl = fetch) {
659
- const url = path5.startsWith("http") ? path5 : `${apiBase()}${path5}`;
660
- const headers = {};
661
- headers[CLI_VERSION_HEADER] = CLI_VERSION;
662
- const identity = await getIdentity();
663
- if (identity) {
664
- headers[USER_ID_HEADER] = identity.user_id;
665
- headers[USER_NAME_HEADER] = identity.display_name;
666
- }
667
- if (authToken) headers["authorization"] = `Bearer ${authToken}`;
668
- if (body !== void 0) headers["content-type"] = "application/json";
669
- let res;
670
- try {
671
- res = await fetchImpl(url, {
672
- method,
673
- headers,
674
- body: body !== void 0 ? JSON.stringify(body) : void 0
675
- });
676
- } catch (e) {
677
- throw new ApiError("NETWORK_ERROR", e?.message ?? "fetch failed", 0);
663
+ console.error("Usage: token-derby stable delete <name>");
664
+ return 2;
678
665
  }
679
- const text = await res.text();
680
- const contentType = res.headers.get("content-type") ?? "";
681
- let parsed = null;
682
- if (contentType.includes("application/json") && text.length > 0) {
683
- try {
684
- parsed = JSON.parse(text);
685
- } catch {
686
- parsed = null;
666
+ let horses;
667
+ try {
668
+ horses = (await listStable()).horses;
669
+ } catch (e) {
670
+ if (e instanceof ApiError) {
671
+ console.error(`Error: ${e.code} ${e.message}`);
672
+ return 1;
687
673
  }
674
+ throw e;
688
675
  }
689
- if (!res.ok) {
690
- if (parsed && typeof parsed.code === "string") {
691
- throw new ApiError(parsed.code, parsed.message ?? "API error", res.status);
676
+ const horse = horses.find((h) => h.name === name);
677
+ if (!horse) {
678
+ console.error(`No horse named "${name}" in your stable.`);
679
+ return 1;
680
+ }
681
+ const rl = readline.createInterface({ input: stdin, output: stdout });
682
+ const answer = (await rl.question(`Delete "${name}" from your stable? [y/N] `)).trim().toLowerCase();
683
+ rl.close();
684
+ if (answer !== "y" && answer !== "yes") {
685
+ console.log("Cancelled.");
686
+ return 1;
687
+ }
688
+ try {
689
+ await deleteStableHorse(horse.stable_horse_id);
690
+ console.log(`\u2713 Deleted "${name}".`);
691
+ return 0;
692
+ } catch (e) {
693
+ if (e instanceof ApiError) {
694
+ console.error(`Error: ${e.code} ${e.message}`);
695
+ return 1;
692
696
  }
693
- throw new ApiError("NETWORK_ERROR", `HTTP ${res.status}`, res.status);
697
+ throw e;
694
698
  }
695
- return parsed;
696
699
  }
697
700
 
698
- // src/api/endpoints.ts
699
- function createRace(body) {
700
- return request("POST", "/races", body, void 0);
701
- }
702
- function getRace(joinCode) {
703
- return request("GET", `/races/${encodeURIComponent(joinCode)}`, void 0, void 0);
704
- }
705
- function joinRace(joinCode, body) {
706
- return request("POST", `/races/${encodeURIComponent(joinCode)}/join`, body, void 0);
707
- }
708
- function heartbeat(joinCode, horseId, token, body) {
709
- return request(
710
- "POST",
711
- `/races/${encodeURIComponent(joinCode)}/horses/${encodeURIComponent(horseId)}/heartbeat`,
712
- body,
713
- token
701
+ // src/commands/stable-edit.ts
702
+ import React4 from "react";
703
+ import { render as render3 } from "ink";
704
+ async function stableEditCommand(name) {
705
+ if (!name) {
706
+ console.error("Usage: token-derby stable edit <name>");
707
+ return 2;
708
+ }
709
+ const horses = await fetchStable();
710
+ if (!horses) return 1;
711
+ const existing = horses.find((h) => h.name === name);
712
+ if (!existing) {
713
+ console.error(`No horse named "${name}" in your stable.`);
714
+ return 1;
715
+ }
716
+ let exitCode = 0;
717
+ const app = render3(
718
+ React4.createElement(HorseCreator, {
719
+ initialColors: existing.colors,
720
+ initialName: existing.name,
721
+ lockName: true,
722
+ initialLevel: levelFromXp(existing.xp),
723
+ onSubmit: async (_name, colors) => {
724
+ try {
725
+ await updateStableHorse(existing.stable_horse_id, { colors });
726
+ app.unmount();
727
+ console.log(`\u2713 Updated "${existing.name}".`);
728
+ } catch (e) {
729
+ app.unmount();
730
+ if (e instanceof ApiError) {
731
+ console.error(`Error: ${e.code} ${e.message}`);
732
+ exitCode = 1;
733
+ return;
734
+ }
735
+ throw e;
736
+ }
737
+ },
738
+ onCancel: () => {
739
+ app.unmount();
740
+ console.log("Cancelled.");
741
+ exitCode = 1;
742
+ }
743
+ })
714
744
  );
745
+ await app.waitUntilExit();
746
+ return exitCode;
715
747
  }
716
- function endRace(adminCode) {
717
- return request("DELETE", `/races/admin/${encodeURIComponent(adminCode)}`, void 0, void 0);
748
+ async function fetchStable() {
749
+ try {
750
+ const resp = await listStable();
751
+ return resp.horses;
752
+ } catch (e) {
753
+ if (e instanceof ApiError) {
754
+ console.error(`Error: ${e.code} ${e.message}`);
755
+ return null;
756
+ }
757
+ throw e;
758
+ }
718
759
  }
719
760
 
720
761
  // src/commands/create.ts
762
+ import * as readline2 from "readline/promises";
763
+ import { stdin as stdin2, stdout as stdout2 } from "process";
721
764
  var DEFAULT_TZ = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
722
- async function createRaceCommand() {
723
- const rl = readline3.createInterface({ input: stdin3, output: stdout3 });
765
+ async function createRaceCommand(organisationName) {
766
+ const rl = readline2.createInterface({ input: stdin2, output: stdout2 });
724
767
  try {
725
768
  const name = (await rl.question("Race name: ")).trim();
726
769
  if (!name) {
@@ -747,12 +790,22 @@ async function createRaceCommand() {
747
790
  console.error("Max participants must be a positive number.");
748
791
  return 1;
749
792
  }
793
+ let org = organisationName;
794
+ if (org === void 0) {
795
+ const raw = (await rl.question("Organisation (blank for none): ")).trim();
796
+ if (raw) org = raw;
797
+ }
798
+ if (org !== void 0 && !ORG_NAME_PATTERN.test(org)) {
799
+ console.error("Organisation name must be 1\u201312 alphanumeric characters.");
800
+ return 1;
801
+ }
750
802
  const resp = await createRace({
751
803
  name,
752
804
  start_time: start,
753
805
  end_time: end,
754
806
  tz,
755
- ...max !== void 0 ? { max_participants: max } : {}
807
+ ...max !== void 0 ? { max_participants: max } : {},
808
+ ...org ? { organisation_name: org } : {}
756
809
  });
757
810
  console.log("");
758
811
  console.log(" \u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557");
@@ -762,6 +815,9 @@ async function createRaceCommand() {
762
815
  console.log(` Admin code: ${resp.admin_code}`);
763
816
  console.log(" \u26A0 Save the admin code \u2014 you need it to end the race early.");
764
817
  console.log("");
818
+ if (org) {
819
+ console.log(` Restricted to organisation: ${org}`);
820
+ }
765
821
  console.log(` Share with participants: token-derby join ${resp.join_code}`);
766
822
  return 0;
767
823
  } catch (e) {
@@ -821,17 +877,44 @@ function HorsePicker({ horses, onPick, onCancel }) {
821
877
  /* @__PURE__ */ jsx4(Box4, { flexDirection: "row", children: /* @__PURE__ */ jsxs3(Text4, { children: [
822
878
  i === idx ? "\u25BA" : " ",
823
879
  " ",
824
- h.name
880
+ h.name,
881
+ " ",
882
+ /* @__PURE__ */ jsxs3(Text4, { color: "cyan", children: [
883
+ "[Lvl. ",
884
+ levelFromXp(h.xp),
885
+ "]"
886
+ ] })
825
887
  ] }) }),
826
888
  /* @__PURE__ */ jsxs3(Box4, { flexDirection: "row", children: [
827
889
  /* @__PURE__ */ jsx4(Text4, { children: " " }),
828
890
  /* @__PURE__ */ jsx4(HorseSprite, { sprite: MINI_SPRITE, colors: h.colors })
829
891
  ] })
830
- ] }, h.name)),
892
+ ] }, h.stable_horse_id)),
831
893
  /* @__PURE__ */ jsx4(Box4, { marginTop: 1, children: /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "\u2191/\u2193 choose \xB7 Enter pick \xB7 Esc cancel" }) })
832
894
  ] });
833
895
  }
834
896
 
897
+ // src/stable/active-race.ts
898
+ import * as fs2 from "fs/promises";
899
+ import * as path3 from "path";
900
+ async function loadActiveRace(joinCode) {
901
+ try {
902
+ const raw = await fs2.readFile(activeRaceFile(joinCode), "utf8");
903
+ return JSON.parse(raw);
904
+ } catch (e) {
905
+ if (e?.code === "ENOENT") return null;
906
+ throw e;
907
+ }
908
+ }
909
+ async function saveActiveRace(active) {
910
+ await fs2.mkdir(activeRacesDir(), { recursive: true });
911
+ await fs2.writeFile(
912
+ activeRaceFile(active.join_code),
913
+ JSON.stringify(active, null, 2) + "\n",
914
+ "utf8"
915
+ );
916
+ }
917
+
835
918
  // src/runtime/run-race.tsx
836
919
  import { useEffect, useRef, useState as useState3 } from "react";
837
920
  import { Box as Box6, Text as Text6, useApp } from "ink";
@@ -848,6 +931,7 @@ function StatusScreen(props) {
848
931
  const leader = race.horses[0];
849
932
  const elapsedPct = elapsed(race);
850
933
  const timeLeft = formatDuration(race.time_left_seconds);
934
+ const lvl = levelInfo(own?.xp ?? 0);
851
935
  return /* @__PURE__ */ jsxs4(Box5, { flexDirection: "column", borderStyle: "round", paddingX: 1, children: [
852
936
  /* @__PURE__ */ jsxs4(Text5, { children: [
853
937
  "\u{1F3C7} TOKEN DERBY \u2500\u2500\u2500 ",
@@ -860,7 +944,13 @@ function StatusScreen(props) {
860
944
  /* @__PURE__ */ jsxs4(Box5, { flexDirection: "column", children: [
861
945
  /* @__PURE__ */ jsxs4(Text5, { children: [
862
946
  " ",
863
- ownHorseName
947
+ ownHorseName,
948
+ " ",
949
+ /* @__PURE__ */ jsxs4(Text5, { color: "cyan", children: [
950
+ "[Lvl. ",
951
+ lvl.level,
952
+ "]"
953
+ ] })
864
954
  ] }),
865
955
  /* @__PURE__ */ jsxs4(Text5, { children: [
866
956
  " ",
@@ -897,6 +987,10 @@ function StatusScreen(props) {
897
987
  "Time left: ",
898
988
  timeLeft
899
989
  ] }),
990
+ /* @__PURE__ */ jsxs4(Text5, { children: [
991
+ "XP: ",
992
+ lvl.next_level_xp === null ? `${lvl.xp} (max level) ${bar(1, 20)}` : `${lvl.xp_into_level}/${lvl.xp_for_level} \u2192 Lvl. ${lvl.level + 1} ${bar(lvl.progress, 20)}`
993
+ ] }),
900
994
  /* @__PURE__ */ jsxs4(Text5, { children: [
901
995
  "Last heartbeat: ",
902
996
  lastHeartbeatAgoSec === null ? "\u2014" : `${lastHeartbeatAgoSec}s ago`,
@@ -970,31 +1064,8 @@ function runHeartbeatLoop(opts) {
970
1064
  schedule(0);
971
1065
  }
972
1066
 
973
- // src/runtime/poll-loop.ts
974
- function runPollLoop(opts) {
975
- let timer = null;
976
- let stopped = false;
977
- const stop = () => {
978
- stopped = true;
979
- if (timer) clearTimeout(timer);
980
- timer = null;
981
- };
982
- opts.abortSignal.addEventListener("abort", stop, { once: true });
983
- const tick = async () => {
984
- if (stopped) return;
985
- try {
986
- const race = await opts.fetchRace();
987
- if (!stopped) opts.onSnapshot(race);
988
- } catch (err) {
989
- if (!stopped) opts.onError(err);
990
- }
991
- if (!stopped) timer = setTimeout(tick, opts.intervalMs);
992
- };
993
- timer = setTimeout(tick, 0);
994
- }
995
-
996
1067
  // src/tokens/transcripts.ts
997
- import * as fs4 from "fs/promises";
1068
+ import * as fs3 from "fs/promises";
998
1069
  import * as path4 from "path";
999
1070
  async function sumTokens() {
1000
1071
  const root = claudeProjectsDir();
@@ -1010,12 +1081,18 @@ async function sumTokens() {
1010
1081
  }
1011
1082
  async function sumOutputTokens() {
1012
1083
  const { input, output } = await sumTokens();
1013
- return input + output;
1084
+ return countInputTokens() ? input + output : output;
1085
+ }
1086
+ function countInputTokens() {
1087
+ const v = process.env.TOKEN_DERBY_COUNT_INPUT_TOKENS;
1088
+ if (!v) return false;
1089
+ const s = v.toLowerCase();
1090
+ return s === "1" || s === "true" || s === "yes" || s === "on";
1014
1091
  }
1015
1092
  async function listJsonlFiles(root) {
1016
1093
  let projects;
1017
1094
  try {
1018
- projects = await fs4.readdir(root);
1095
+ projects = await fs3.readdir(root);
1019
1096
  } catch (e) {
1020
1097
  if (e?.code === "ENOENT") return [];
1021
1098
  throw e;
@@ -1025,12 +1102,12 @@ async function listJsonlFiles(root) {
1025
1102
  const projectDir = path4.join(root, project);
1026
1103
  let stat2;
1027
1104
  try {
1028
- stat2 = await fs4.stat(projectDir);
1105
+ stat2 = await fs3.stat(projectDir);
1029
1106
  } catch {
1030
1107
  continue;
1031
1108
  }
1032
1109
  if (!stat2.isDirectory()) continue;
1033
- const entries = await fs4.readdir(projectDir);
1110
+ const entries = await fs3.readdir(projectDir);
1034
1111
  for (const entry of entries) {
1035
1112
  if (entry.endsWith(".jsonl")) out.push(path4.join(projectDir, entry));
1036
1113
  }
@@ -1043,7 +1120,7 @@ function addNum(value) {
1043
1120
  async function sumFile(file) {
1044
1121
  let raw;
1045
1122
  try {
1046
- raw = await fs4.readFile(file, "utf8");
1123
+ raw = await fs3.readFile(file, "utf8");
1047
1124
  } catch {
1048
1125
  return { input: 0, output: 0 };
1049
1126
  }
@@ -1096,14 +1173,6 @@ function RunRace({ active, startingBaseline, pendingMode, ownUserName }) {
1096
1173
  }
1097
1174
  }, [race?.status]);
1098
1175
  useEffect(() => {
1099
- runPollLoop({
1100
- fetchRace: () => getRace(active.join_code),
1101
- intervalMs: POLL_INTERVAL_MS,
1102
- onSnapshot: (r) => setRace(r),
1103
- onError: () => {
1104
- },
1105
- abortSignal: ctrl.current.signal
1106
- });
1107
1176
  runHeartbeatLoop({
1108
1177
  sendHeartbeat: async (currentTokens) => {
1109
1178
  const resp = await heartbeat(
@@ -1129,6 +1198,7 @@ function RunRace({ active, startingBaseline, pendingMode, ownUserName }) {
1129
1198
  onSuccess: (resp) => {
1130
1199
  setLastHbAt(/* @__PURE__ */ new Date());
1131
1200
  setLastHbOk(true);
1201
+ setRace(raceViewFrom(resp));
1132
1202
  if (resp.race_status === "finished") exit();
1133
1203
  },
1134
1204
  onError: (err) => {
@@ -1179,6 +1249,15 @@ function RunRace({ active, startingBaseline, pendingMode, ownUserName }) {
1179
1249
  }
1180
1250
  );
1181
1251
  }
1252
+ function raceViewFrom(resp) {
1253
+ return {
1254
+ ...resp.race,
1255
+ status: resp.race_status,
1256
+ horses: resp.horses,
1257
+ server_time: resp.server_time,
1258
+ time_left_seconds: resp.time_left_seconds
1259
+ };
1260
+ }
1182
1261
  async function buildInitialState(args) {
1183
1262
  const runningTotal = await sumOutputTokens();
1184
1263
  if (args.rejoin) {
@@ -1231,12 +1310,21 @@ async function joinCommand(joinCode) {
1231
1310
  chosenColors = ownHorse.colors;
1232
1311
  isResume = true;
1233
1312
  } else {
1234
- const stable = await loadStable();
1235
- if (stable.horses.length === 0) {
1313
+ let horses;
1314
+ try {
1315
+ horses = (await listStable()).horses;
1316
+ } catch (e) {
1317
+ if (e instanceof ApiError) {
1318
+ console.error(`Error: ${e.code} ${e.message}`);
1319
+ return 1;
1320
+ }
1321
+ throw e;
1322
+ }
1323
+ if (horses.length === 0) {
1236
1324
  console.error("Your stable is empty. Run `token-derby stable create` first.");
1237
1325
  return 1;
1238
1326
  }
1239
- const picked = await pickHorse(stable.horses);
1327
+ const picked = await pickHorse(horses);
1240
1328
  if (!picked) {
1241
1329
  console.log("Cancelled.");
1242
1330
  return 1;
@@ -1248,9 +1336,7 @@ async function joinCommand(joinCode) {
1248
1336
  }
1249
1337
  let joinResp;
1250
1338
  try {
1251
- joinResp = await joinRace(code, {
1252
- horse: { stable_horse_id: chosenStableHorseId, name: chosenName, colors: chosenColors }
1253
- });
1339
+ joinResp = await joinRace(code, { stable_horse_id: chosenStableHorseId });
1254
1340
  } catch (e) {
1255
1341
  if (e instanceof ApiError) {
1256
1342
  if (e.code === "RACE_FULL") console.error("This race is full.");
@@ -1258,7 +1344,9 @@ async function joinCommand(joinCode) {
1258
1344
  else if (e.code === "RACE_NOT_FOUND") console.error(`No race with join code ${code}.`);
1259
1345
  else if (e.code === "VERSION_MISMATCH") console.error(e.message);
1260
1346
  else if (e.code === "DUPLICATE_HORSE") console.error(e.message);
1261
- else if (e.code === "IDENTITY_REQUIRED") console.error(`Error: ${e.message}`);
1347
+ else if (e.code === "STABLE_HORSE_NOT_FOUND") {
1348
+ console.error("That horse no longer exists in your stable. Try again.");
1349
+ } else if (e.code === "NOT_ORG_MEMBER") console.error(e.message);
1262
1350
  else console.error(`Error: ${e.code} ${e.message}`);
1263
1351
  return 1;
1264
1352
  }
@@ -1303,14 +1391,14 @@ async function pickHorse(horses) {
1303
1391
  }
1304
1392
 
1305
1393
  // src/commands/end.ts
1306
- import * as readline4 from "readline/promises";
1307
- import { stdin as stdin4, stdout as stdout4 } from "process";
1394
+ import * as readline3 from "readline/promises";
1395
+ import { stdin as stdin3, stdout as stdout3 } from "process";
1308
1396
  async function endCommand(adminCode) {
1309
1397
  if (!adminCode) {
1310
1398
  console.error("Usage: token-derby end <admin-code>");
1311
1399
  return 2;
1312
1400
  }
1313
- const rl = readline4.createInterface({ input: stdin4, output: stdout4 });
1401
+ const rl = readline3.createInterface({ input: stdin3, output: stdout3 });
1314
1402
  const answer = (await rl.question("End the race now and freeze final tokens? [y/N] ")).trim().toLowerCase();
1315
1403
  rl.close();
1316
1404
  if (answer !== "y" && answer !== "yes") {
@@ -1332,11 +1420,16 @@ async function endCommand(adminCode) {
1332
1420
  }
1333
1421
 
1334
1422
  // src/commands/init.ts
1335
- import * as readline5 from "readline/promises";
1336
- import { stdin as stdin5, stdout as stdout5 } from "process";
1337
- async function initCommand() {
1423
+ import * as readline4 from "readline/promises";
1424
+ import { stdin as stdin4, stdout as stdout4 } from "process";
1425
+ async function initCommand(reset = false) {
1426
+ if (reset) {
1427
+ await deleteIdentity();
1428
+ _resetIdentityCacheForTests();
1429
+ console.log("Removed local identity. Creating a new one\u2026");
1430
+ }
1338
1431
  const existing = await loadIdentity();
1339
- const rl = readline5.createInterface({ input: stdin5, output: stdout5 });
1432
+ const rl = readline4.createInterface({ input: stdin4, output: stdout4 });
1340
1433
  try {
1341
1434
  if (existing) {
1342
1435
  console.log(`Current jockey name: ${existing.display_name}`);
@@ -1350,10 +1443,25 @@ async function initCommand() {
1350
1443
  console.error(v2.error);
1351
1444
  return 1;
1352
1445
  }
1353
- const updated = { ...existing, display_name: v2.name };
1354
- await saveIdentity(updated);
1355
- console.log(`Updated jockey name to: ${updated.display_name}`);
1356
- return 0;
1446
+ try {
1447
+ const resp = await updateJockey({ display_name: v2.name });
1448
+ const updated = { ...existing, display_name: resp.display_name };
1449
+ await saveIdentity(updated);
1450
+ console.log(`Updated jockey name to: ${updated.display_name}`);
1451
+ return 0;
1452
+ } catch (e) {
1453
+ if (e instanceof ApiError) {
1454
+ if (e.code === "UNAUTHENTICATED") {
1455
+ console.error(
1456
+ "Server does not recognise this identity. Your account may have been wiped. Run `token-derby init --reset` to start fresh."
1457
+ );
1458
+ } else {
1459
+ console.error(`Error: ${e.code} ${e.message}`);
1460
+ }
1461
+ return 1;
1462
+ }
1463
+ throw e;
1464
+ }
1357
1465
  }
1358
1466
  const raw = (await rl.question("Jockey Name (use your real name please): ")).trim();
1359
1467
  const v = validateDisplayName(raw);
@@ -1361,26 +1469,152 @@ async function initCommand() {
1361
1469
  console.error(v.error);
1362
1470
  return 1;
1363
1471
  }
1364
- const identity = {
1365
- user_id: generateUserId(),
1366
- display_name: v.name,
1367
- created_at: (/* @__PURE__ */ new Date()).toISOString()
1368
- };
1369
- await saveIdentity(identity);
1472
+ try {
1473
+ const resp = await initJockey({ display_name: v.name });
1474
+ const identity = {
1475
+ user_id: resp.user_id,
1476
+ display_name: resp.display_name,
1477
+ secret_token: resp.secret_token,
1478
+ created_at: (/* @__PURE__ */ new Date()).toISOString()
1479
+ };
1480
+ await saveIdentity(identity);
1481
+ _resetIdentityCacheForTests();
1482
+ console.log("");
1483
+ console.log(`Welcome, ${identity.display_name}!`);
1484
+ console.log("Your identity has been created on the server.");
1485
+ console.log("You can now create a stable and join races.");
1486
+ console.log("");
1487
+ console.log(" \u26A0 Your secret token is stored locally in identity.json.");
1488
+ console.log(" If you lose it, you cannot recover this account \u2014 you would");
1489
+ console.log(" need to run `token-derby init --reset` and rebuild your stable.");
1490
+ return 0;
1491
+ } catch (e) {
1492
+ if (e instanceof ApiError) {
1493
+ console.error(`Error: ${e.code} ${e.message}`);
1494
+ return 1;
1495
+ }
1496
+ throw e;
1497
+ }
1498
+ } finally {
1499
+ rl.close();
1500
+ }
1501
+ }
1502
+
1503
+ // src/commands/org-create.ts
1504
+ import * as readline5 from "readline/promises";
1505
+ import { stdin as stdin5, stdout as stdout5 } from "process";
1506
+ async function orgCreateCommand() {
1507
+ const rl = readline5.createInterface({ input: stdin5, output: stdout5 });
1508
+ try {
1509
+ const name = (await rl.question(`Organisation name (1\u2013${ORG_NAME_MAX_LENGTH} alphanumeric chars): `)).trim();
1510
+ if (!ORG_NAME_PATTERN.test(name)) {
1511
+ console.error(`Name must be 1\u2013${ORG_NAME_MAX_LENGTH} alphanumeric characters (no spaces or symbols).`);
1512
+ return 1;
1513
+ }
1514
+ const resp = await createOrganisation({ name });
1370
1515
  console.log("");
1371
- console.log(`Welcome, ${identity.display_name}!`);
1372
- console.log(`Your identity is saved. You can now create a stable and join races.`);
1516
+ console.log(` Organisation created: ${resp.org_name}`);
1517
+ console.log(" \u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557");
1518
+ console.log(` \u2551 JOIN TOKEN: ${resp.org_join_token.padEnd(43)}\u2551`);
1519
+ console.log(" \u255A\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255D");
1520
+ console.log(" \u26A0 Share this token to invite members. Treat it as a secret.");
1521
+ console.log("");
1522
+ console.log(` Members join with: token-derby organisation join ${resp.org_join_token}`);
1523
+ console.log(` Create org races: token-derby create --organisation ${resp.org_name}`);
1373
1524
  return 0;
1525
+ } catch (e) {
1526
+ if (e instanceof ApiError) {
1527
+ console.error(`Error: ${e.code} ${e.message}`);
1528
+ return 1;
1529
+ }
1530
+ throw e;
1374
1531
  } finally {
1375
1532
  rl.close();
1376
1533
  }
1377
1534
  }
1378
1535
 
1536
+ // src/commands/org-join.ts
1537
+ async function orgJoinCommand(token) {
1538
+ if (!token) {
1539
+ console.error("Usage: token-derby organisation join <join-token>");
1540
+ return 2;
1541
+ }
1542
+ try {
1543
+ const resp = await joinOrganisation({ join_token: token });
1544
+ console.log(`Joined organisation: ${resp.org_name}`);
1545
+ return 0;
1546
+ } catch (e) {
1547
+ if (e instanceof ApiError) {
1548
+ console.error(`Error: ${e.code} ${e.message}`);
1549
+ return 1;
1550
+ }
1551
+ throw e;
1552
+ }
1553
+ }
1554
+
1555
+ // src/commands/org-list.ts
1556
+ async function orgListCommand() {
1557
+ try {
1558
+ const resp = await listOrganisations();
1559
+ if (resp.organisations.length === 0) {
1560
+ console.log("You are not in any organisations.");
1561
+ console.log("Create one with: token-derby organisation create");
1562
+ console.log("Or join one with: token-derby organisation join <token>");
1563
+ return 0;
1564
+ }
1565
+ console.log(`Your organisations (${resp.organisations.length}):`);
1566
+ for (const o of resp.organisations) {
1567
+ console.log(` \u2022 ${o.org_name}`);
1568
+ }
1569
+ return 0;
1570
+ } catch (e) {
1571
+ if (e instanceof ApiError) {
1572
+ console.error(`Error: ${e.code} ${e.message}`);
1573
+ return 1;
1574
+ }
1575
+ throw e;
1576
+ }
1577
+ }
1578
+
1579
+ // src/commands/org-info.ts
1580
+ async function orgInfoCommand(name) {
1581
+ if (!name) {
1582
+ console.error("Usage: token-derby organisation info <name>");
1583
+ return 2;
1584
+ }
1585
+ if (!ORG_NAME_PATTERN.test(name)) {
1586
+ console.error("Organisation name must be 1\u201312 alphanumeric characters.");
1587
+ return 2;
1588
+ }
1589
+ try {
1590
+ const resp = await getOrganisation(name);
1591
+ console.log(`Organisation: ${resp.org_name}`);
1592
+ console.log(`Created: ${resp.created_at} by ${resp.creator_user_name}`);
1593
+ console.log("");
1594
+ console.log(" \u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557");
1595
+ console.log(` \u2551 JOIN TOKEN: ${resp.org_join_token.padEnd(43)}\u2551`);
1596
+ console.log(" \u255A\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255D");
1597
+ console.log(" \u26A0 Treat the token as a secret \u2014 anyone with it can join.");
1598
+ console.log("");
1599
+ console.log(` Members join with: token-derby organisation join ${resp.org_join_token}`);
1600
+ return 0;
1601
+ } catch (e) {
1602
+ if (e instanceof ApiError) {
1603
+ console.error(`Error: ${e.code} ${e.message}`);
1604
+ return 1;
1605
+ }
1606
+ throw e;
1607
+ }
1608
+ }
1609
+
1379
1610
  // src/bin.ts
1380
1611
  var HELP = `token-derby v${CLI_VERSION}
1381
1612
 
1382
1613
  Identity:
1383
1614
  token-derby init Set up your jockey identity (run this first)
1615
+ Re-running renames you on the server.
1616
+ token-derby init --reset Wipe local identity and create a fresh account.
1617
+ Your previous stable is abandoned on the server.
1384
1618
 
1385
1619
  Stable management:
1386
1620
  token-derby stable create Make a new horse (interactive)
@@ -1388,8 +1622,17 @@ Stable management:
1388
1622
  token-derby stable edit <name> Edit an existing horse's colors
1389
1623
  token-derby stable delete <name> Remove a horse from your stable
1390
1624
 
1625
+ Organisations:
1626
+ token-derby organisation create Create a new organisation (interactive)
1627
+ token-derby organisation join <token> Join an organisation with a join token
1628
+ token-derby organisation info <name> Show an org's join token (members only)
1629
+ token-derby organisation list Show organisations you're a member of
1630
+
1391
1631
  Races:
1392
- token-derby create Create a new race (interactive)
1632
+ token-derby create [--organisation <name>]
1633
+ Create a new race (interactive). When
1634
+ --organisation is set, only members of
1635
+ that org can join.
1393
1636
  token-derby join <join-code> Join (or resume) a race
1394
1637
  token-derby end <admin-code> End a race early
1395
1638
 
@@ -1408,7 +1651,10 @@ async function main() {
1408
1651
  console.log(CLI_VERSION);
1409
1652
  return 0;
1410
1653
  }
1411
- if (cmd === "init") return initCommand();
1654
+ if (cmd === "init") {
1655
+ const reset = argv.slice(1).includes("--reset");
1656
+ return initCommand(reset);
1657
+ }
1412
1658
  const identity = await loadIdentity();
1413
1659
  if (!identity) {
1414
1660
  console.error("Run `token-derby init` to set up your identity before using any other command.");
@@ -1424,13 +1670,34 @@ async function main() {
1424
1670
  console.error("Try: stable create | stable list | stable edit <name> | stable delete <name>");
1425
1671
  return 2;
1426
1672
  }
1427
- if (cmd === "create") return createRaceCommand();
1673
+ if (cmd === "organisation" || cmd === "org") {
1674
+ const sub = argv[1];
1675
+ if (sub === "create") return orgCreateCommand();
1676
+ if (sub === "join") return orgJoinCommand(argv[2]);
1677
+ if (sub === "info") return orgInfoCommand(argv[2]);
1678
+ if (sub === "list") return orgListCommand();
1679
+ console.error(`Unknown organisation subcommand: ${sub ?? "(none)"}`);
1680
+ console.error("Try: organisation create | organisation join <token> | organisation info <name> | organisation list");
1681
+ return 2;
1682
+ }
1683
+ if (cmd === "create") {
1684
+ const orgName = parseFlag(argv.slice(1), "--organisation");
1685
+ return createRaceCommand(orgName);
1686
+ }
1428
1687
  if (cmd === "join") return joinCommand(argv[1]);
1429
1688
  if (cmd === "end") return endCommand(argv[1]);
1430
1689
  console.error(`Unknown command: ${cmd}`);
1431
1690
  console.error(HELP);
1432
1691
  return 2;
1433
1692
  }
1693
+ function parseFlag(args, flag) {
1694
+ for (let i = 0; i < args.length; i++) {
1695
+ if (args[i] === flag) return args[i + 1];
1696
+ const eq = `${flag}=`;
1697
+ if (args[i]?.startsWith(eq)) return args[i].slice(eq.length);
1698
+ }
1699
+ return void 0;
1700
+ }
1434
1701
  main().then(
1435
1702
  (code) => process.exit(code),
1436
1703
  (err) => {