vibemancer 1.0.37 → 1.0.39

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/cli.js CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  resolveOpponent
7
7
  } from "./chunk-CMTEJ5MU.js";
8
8
  import {
9
+ AUTH_TIMEOUT_MS,
9
10
  MCP_BASE_URL,
10
11
  NotLoggedInError,
11
12
  clearCredentials,
@@ -14,7 +15,7 @@ import {
14
15
  getAccessToken,
15
16
  loadCredentials,
16
17
  registerClient
17
- } from "./chunk-MCU2B4PZ.js";
18
+ } from "./chunk-UKMSXLZR.js";
18
19
 
19
20
  // src/cli-version.ts
20
21
  import fs from "fs";
@@ -42,6 +43,18 @@ function cliVersion() {
42
43
  return "unknown";
43
44
  }
44
45
 
46
+ // src/flag-numbers.ts
47
+ var DECIMAL = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/;
48
+ function parseNumberFlag(flag, raw, kind) {
49
+ const expected = kind === "integer" ? "a whole number" : "a number";
50
+ const refuse = () => ({ ok: false, message: `${flag} must be ${expected}, got "${raw}".` });
51
+ if (!DECIMAL.test(raw)) return refuse();
52
+ const value = Number(raw);
53
+ if (!Number.isFinite(value)) return refuse();
54
+ if (kind === "integer" && !Number.isInteger(value)) return refuse();
55
+ return { ok: true, value };
56
+ }
57
+
45
58
  // src/commands/dev.ts
46
59
  import { execFile } from "child_process";
47
60
 
@@ -328,9 +341,28 @@ function respond(res, status, data) {
328
341
  res.end(JSON.stringify(data));
329
342
  }
330
343
 
344
+ // src/project-root.ts
345
+ import fs3 from "fs";
346
+ import path3 from "path";
347
+ var PROJECT_MARKER = "vibemancer.json";
348
+ function findProjectRoot(startDir) {
349
+ let current = path3.resolve(startDir);
350
+ for (; ; ) {
351
+ const marker = path3.join(current, PROJECT_MARKER);
352
+ try {
353
+ if (fs3.statSync(marker).isFile()) return current;
354
+ } catch {
355
+ }
356
+ const parent = path3.dirname(current);
357
+ if (parent === current) break;
358
+ current = parent;
359
+ }
360
+ return path3.resolve(startDir);
361
+ }
362
+
331
363
  // src/commands/dev.ts
332
364
  async function runDev(options) {
333
- const projectDir = process.cwd();
365
+ const projectDir = findProjectRoot(process.cwd());
334
366
  const bots = await discoverAllBots(projectDir);
335
367
  if (bots.length === 0) {
336
368
  console.log("No bots discovered in src/. Add a .ts file with a PascalCase export, e.g.:");
@@ -373,7 +405,7 @@ async function runTest(_options) {
373
405
  const code = await new Promise((resolve) => {
374
406
  const child = spawn("npx vitest run", {
375
407
  stdio: "inherit",
376
- cwd: process.cwd(),
408
+ cwd: findProjectRoot(process.cwd()),
377
409
  shell: true
378
410
  });
379
411
  child.on("error", () => resolve(1));
@@ -385,8 +417,8 @@ async function runTest(_options) {
385
417
  }
386
418
 
387
419
  // src/commands/fight.ts
388
- import fs3 from "fs";
389
- import path3 from "path";
420
+ import fs4 from "fs";
421
+ import path4 from "path";
390
422
  import { BotBundle, sandboxFight, sandboxSimulate, scoreFight, runBundleFight } from "@vibemancer/core";
391
423
 
392
424
  // src/commands/fight-errors.ts
@@ -551,7 +583,7 @@ async function runFight(options) {
551
583
  return runFullFight(options);
552
584
  }
553
585
  async function runSingleFight(options) {
554
- const projectDir = process.cwd();
586
+ const projectDir = findProjectRoot(process.cwd());
555
587
  const botInfo = await discoverBot(projectDir, options.bot);
556
588
  console.log(`
557
589
  Bot: ${botInfo.exportName}`);
@@ -594,7 +626,7 @@ async function runSingleFight(options) {
594
626
  }
595
627
  }
596
628
  async function runFullFight(options) {
597
- const projectDir = process.cwd();
629
+ const projectDir = findProjectRoot(process.cwd());
598
630
  const botInfo = await discoverBot(projectDir, options.bot);
599
631
  const userBundle = new BotBundle(botInfo.sourcePath, botInfo.exportName);
600
632
  const opponents = getBuiltinBotNames();
@@ -662,13 +694,13 @@ async function runFullFight(options) {
662
694
  console.log("");
663
695
  }
664
696
  function getHistoryPath(projectDir) {
665
- return path3.join(projectDir, ".vibemancer", "history.json");
697
+ return path4.join(projectDir, ".vibemancer", "history.json");
666
698
  }
667
699
  function loadHistory(projectDir) {
668
700
  const historyPath = getHistoryPath(projectDir);
669
- if (!fs3.existsSync(historyPath)) return [];
701
+ if (!fs4.existsSync(historyPath)) return [];
670
702
  try {
671
- const raw = fs3.readFileSync(historyPath, "utf-8");
703
+ const raw = fs4.readFileSync(historyPath, "utf-8");
672
704
  return JSON.parse(raw);
673
705
  } catch {
674
706
  return [];
@@ -676,10 +708,10 @@ function loadHistory(projectDir) {
676
708
  }
677
709
  function saveHistory(projectDir, history, current) {
678
710
  const historyPath = getHistoryPath(projectDir);
679
- const dir = path3.dirname(historyPath);
680
- fs3.mkdirSync(dir, { recursive: true });
711
+ const dir = path4.dirname(historyPath);
712
+ fs4.mkdirSync(dir, { recursive: true });
681
713
  const updated = [...history.slice(-19), current];
682
- fs3.writeFileSync(historyPath, JSON.stringify(updated, null, " ") + "\n");
714
+ fs4.writeFileSync(historyPath, JSON.stringify(updated, null, " ") + "\n");
683
715
  }
684
716
  function showDiff(current, previous) {
685
717
  const prevMap = new Map(previous.map((e) => [e.opponent, e]));
@@ -738,7 +770,7 @@ function describeTickCap(winner, ticks, maxTicks) {
738
770
  return `NOTE: this trace stopped at its ${maxTicks}-tick cap, not because the match was a draw. A real match runs ${FULL_MATCH_TICKS.toLocaleString("en-US")} ticks, so you are seeing the first ${Math.round(maxTicks / FULL_MATCH_TICKS * 100)}% of the fight and the outcome above is NOT the result. Re-run with --ticks N to see further.`;
739
771
  }
740
772
  async function runTrace(options) {
741
- const projectDir = process.cwd();
773
+ const projectDir = findProjectRoot(process.cwd());
742
774
  const botInfo = await discoverBot(projectDir, options.bot);
743
775
  const distance = options.distance ?? 600;
744
776
  let result;
@@ -804,7 +836,7 @@ function summarisePairing(bot1Name, bot2Name, result) {
804
836
  };
805
837
  }
806
838
  async function runTournament(options) {
807
- const projectDir = process.cwd();
839
+ const projectDir = findProjectRoot(process.cwd());
808
840
  const botInfo = await discoverBot(projectDir, options.bot);
809
841
  const userBundle = new BotBundle3(botInfo.sourcePath, botInfo.exportName);
810
842
  const opponentNames = options.opponents && options.opponents.length > 0 ? options.opponents : getBuiltinBotNames();
@@ -975,7 +1007,7 @@ function resolveOpponentNames(opponents) {
975
1007
  return opponents;
976
1008
  }
977
1009
  async function runOptimize(options) {
978
- const projectDir = process.cwd();
1010
+ const projectDir = findProjectRoot(process.cwd());
979
1011
  const botInfo = await discoverBot(projectDir, options.bot);
980
1012
  const source = readFileSync(botInfo.sourcePath, "utf-8");
981
1013
  const params = parseParams(source);
@@ -1060,11 +1092,11 @@ function escapeRegex(s) {
1060
1092
  }
1061
1093
 
1062
1094
  // src/commands/build.ts
1063
- import fs4 from "fs";
1064
- import path4 from "path";
1095
+ import fs5 from "fs";
1096
+ import path5 from "path";
1065
1097
  import { BotBundle as BotBundle5, compileMatchBundle } from "@vibemancer/core";
1066
1098
  async function runBuild(options) {
1067
- const projectDir = process.cwd();
1099
+ const projectDir = findProjectRoot(process.cwd());
1068
1100
  const botInfo = await discoverBot(projectDir, options.bot);
1069
1101
  const coreSourceDir = findCoreSourceDir();
1070
1102
  const userBundle = new BotBundle5(botInfo.sourcePath, botInfo.exportName);
@@ -1079,9 +1111,9 @@ async function runBuild(options) {
1079
1111
  });
1080
1112
  const elapsed = Date.now() - start;
1081
1113
  const outFile = options.output ?? `dist/${botInfo.exportName}-vs-${options.opponent}.js`;
1082
- const outDir = path4.dirname(path4.resolve(outFile));
1083
- fs4.mkdirSync(outDir, { recursive: true });
1084
- fs4.writeFileSync(path4.resolve(outFile), bundle);
1114
+ const outDir = path5.dirname(path5.resolve(outFile));
1115
+ fs5.mkdirSync(outDir, { recursive: true });
1116
+ fs5.writeFileSync(path5.resolve(outFile), bundle);
1085
1117
  const sizeKb = (bundle.length / 1024).toFixed(1);
1086
1118
  console.log(` Output: ${outFile} (${sizeKb} KB)`);
1087
1119
  console.log(` Compiled in ${elapsed}ms
@@ -1091,15 +1123,44 @@ async function runBuild(options) {
1091
1123
  // src/commands/bots.ts
1092
1124
  import { BOT_GROUPS, ALL_BOTS } from "@vibemancer/core";
1093
1125
 
1126
+ // src/auth/gateway-failure.ts
1127
+ var MAX_BODY = 500;
1128
+ function trimBody(body) {
1129
+ const text = body.trim();
1130
+ return text.length > MAX_BODY ? `${text.slice(0, MAX_BODY)}\u2026` : text;
1131
+ }
1132
+ function retryAfterSeconds(retryAfter) {
1133
+ if (retryAfter === null) return null;
1134
+ const seconds = Number(retryAfter.trim());
1135
+ if (!Number.isFinite(seconds) || seconds <= 0) return null;
1136
+ return Math.ceil(seconds);
1137
+ }
1138
+ function describeGatewayFailure(operation, status, retryAfter, body) {
1139
+ if (status === 429) {
1140
+ const seconds = retryAfterSeconds(retryAfter);
1141
+ if (seconds === null) return "Rate limited. Wait a moment and try again.";
1142
+ return `Rate limited. Try again in ${seconds} ${seconds === 1 ? "second" : "seconds"}.`;
1143
+ }
1144
+ if (status === 401 || status === 403) {
1145
+ return `${operation} was refused: your session is missing or expired. Run: vibemancer login`;
1146
+ }
1147
+ if (status >= 500 && status !== 501 && status !== 505) {
1148
+ const detail = trimBody(body);
1149
+ return `${operation} failed: the server had a problem (${status}). Try again in a moment.` + (detail ? `
1150
+ Server said: ${detail}` : "");
1151
+ }
1152
+ return `${operation} failed (${status}): ${trimBody(body)}`;
1153
+ }
1154
+
1094
1155
  // src/auth/env-headers.ts
1095
1156
  import os from "os";
1096
1157
  import { readFileSync as readFileSync2 } from "fs";
1097
- import path5 from "path";
1158
+ import path6 from "path";
1098
1159
  import { fileURLToPath as fileURLToPath2 } from "url";
1099
1160
  function findCliVersionFrom(here) {
1100
1161
  for (const rel of ["../../package.json", "../package.json", "../../../package.json"]) {
1101
1162
  try {
1102
- const parsed = JSON.parse(readFileSync2(path5.resolve(here, rel), "utf8"));
1163
+ const parsed = JSON.parse(readFileSync2(path6.resolve(here, rel), "utf8"));
1103
1164
  if (typeof parsed !== "object" || parsed === null) continue;
1104
1165
  if (!("name" in parsed) || !("version" in parsed)) continue;
1105
1166
  const { name, version } = parsed;
@@ -1111,7 +1172,7 @@ function findCliVersionFrom(here) {
1111
1172
  }
1112
1173
  function readCliVersion() {
1113
1174
  try {
1114
- return findCliVersionFrom(path5.dirname(fileURLToPath2(import.meta.url)));
1175
+ return findCliVersionFrom(path6.dirname(fileURLToPath2(import.meta.url)));
1115
1176
  } catch {
1116
1177
  return "";
1117
1178
  }
@@ -1155,7 +1216,7 @@ async function uploadBot(payload) {
1155
1216
  body: JSON.stringify(payload),
1156
1217
  signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS)
1157
1218
  });
1158
- if (!res.ok) throw new Error(`Upload failed (${res.status}): ${await res.text()}`);
1219
+ if (!res.ok) throw new Error(describeGatewayFailure("Upload", res.status, res.headers.get("retry-after"), await res.text()));
1159
1220
  const data = await res.json();
1160
1221
  const wizardId = typeof data === "object" && data !== null && "wizardId" in data && typeof data.wizardId === "string" ? data.wizardId : "";
1161
1222
  const message = typeof data === "object" && data !== null && "message" in data && typeof data.message === "string" ? data.message : "Uploaded.";
@@ -1186,7 +1247,7 @@ async function listMyBotsPage() {
1186
1247
  headers: { Authorization: `Bearer ${token}`, ...envHeaders() },
1187
1248
  signal: AbortSignal.timeout(READ_TIMEOUT_MS)
1188
1249
  });
1189
- if (!res.ok) throw new Error(`Could not list your bots (${res.status}): ${await res.text()}`);
1250
+ if (!res.ok) throw new Error(describeGatewayFailure("Listing your bots", res.status, res.headers.get("retry-after"), await res.text()));
1190
1251
  const data = await res.json();
1191
1252
  const bots = typeof data === "object" && data !== null && "bots" in data ? data.bots : null;
1192
1253
  if (!Array.isArray(bots)) throw new Error("The server returned an unexpected response while listing your bots. This is not the same as having none - try again, and report it if it persists.");
@@ -1205,7 +1266,7 @@ async function pullBot(name) {
1205
1266
  headers: { Authorization: `Bearer ${token}`, ...envHeaders() },
1206
1267
  signal: AbortSignal.timeout(READ_TIMEOUT_MS)
1207
1268
  });
1208
- if (!res.ok) throw new Error(`Pull failed (${res.status}): ${await res.text()}`);
1269
+ if (!res.ok) throw new Error(describeGatewayFailure("Pull", res.status, res.headers.get("retry-after"), await res.text()));
1209
1270
  const data = await res.json();
1210
1271
  const source = typeof data === "object" && data !== null && "sourceCode" in data && typeof data.sourceCode === "string" ? data.sourceCode : "";
1211
1272
  if (source) {
@@ -1376,55 +1437,105 @@ function getStyleDescription(bot) {
1376
1437
  }
1377
1438
 
1378
1439
  // src/commands/upload.ts
1379
- import fs6 from "fs";
1440
+ import fs7 from "fs";
1380
1441
 
1381
1442
  // src/pull-safety.ts
1382
1443
  import { createHash } from "crypto";
1383
- import fs5 from "fs";
1384
- import path6 from "path";
1444
+ import fs6 from "fs";
1445
+ import path7 from "path";
1385
1446
  function hashContent(content) {
1386
- const normalised = content.replace(/\r\n/g, "\n").trimEnd();
1447
+ const BYTE_ORDER_MARK = String.fromCharCode(65279);
1448
+ const withoutBom = content.startsWith(BYTE_ORDER_MARK) ? content.slice(1) : content;
1449
+ const normalised = withoutBom.replace(/\r\n/g, "\n").trimEnd();
1387
1450
  return createHash("sha256").update(normalised, "utf8").digest("hex");
1388
1451
  }
1389
1452
  function pullDecision(situation) {
1390
1453
  if (!situation.fileExists) return "write";
1391
- if (situation.force) return "write";
1392
1454
  const local = situation.localContent;
1393
1455
  if (hashContent(local) === hashContent(situation.incomingContent)) return "skip-identical";
1456
+ if (situation.force) return "write";
1394
1457
  if (situation.recordedHash === void 0) return "refuse-unknown";
1395
1458
  return hashContent(local) === situation.recordedHash ? "write" : "refuse-modified";
1396
1459
  }
1397
1460
  var STATE_FILE = ".vibemancer-state.json";
1398
1461
  function statePath(projectDir) {
1399
- return path6.join(projectDir, STATE_FILE);
1462
+ return path7.join(projectDir, STATE_FILE);
1400
1463
  }
1401
- function readState(projectDir) {
1464
+ function readRaw(projectDir) {
1402
1465
  try {
1403
- const parsed = JSON.parse(fs5.readFileSync(statePath(projectDir), "utf-8"));
1404
- if (typeof parsed !== "object" || parsed === null) return {};
1466
+ const parsed = JSON.parse(fs6.readFileSync(statePath(projectDir), "utf-8"));
1467
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {};
1405
1468
  const out = {};
1406
- for (const [k, v] of Object.entries(parsed)) if (typeof v === "string") out[k] = v;
1469
+ for (const [key, value] of Object.entries(parsed)) out[key] = value;
1407
1470
  return out;
1408
1471
  } catch {
1409
1472
  return {};
1410
1473
  }
1411
1474
  }
1475
+ var NOTE_PREFIX = "_";
1476
+ var README_NOTE = "Written by the vibemancer CLI: hashes of what it last wrote here, so `vibemancer pull` can tell its own output from your edits. Commit it. Safe to delete - the cost is one --force, never data.";
1477
+ function readState(projectDir) {
1478
+ const out = {};
1479
+ for (const [k, v] of Object.entries(readRaw(projectDir))) {
1480
+ if (!k.startsWith(NOTE_PREFIX) && typeof v === "string") out[k] = v;
1481
+ }
1482
+ return out;
1483
+ }
1484
+ function stateKey(projectDir, filePath) {
1485
+ const canonical = (p) => {
1486
+ try {
1487
+ return fs6.realpathSync.native(p);
1488
+ } catch {
1489
+ return path7.resolve(p);
1490
+ }
1491
+ };
1492
+ return path7.relative(canonical(projectDir), canonical(filePath)).split(path7.sep).join("/");
1493
+ }
1412
1494
  function recordedHashFor(projectDir, filePath) {
1413
- return readState(projectDir)[path6.relative(projectDir, filePath).split(path6.sep).join("/")];
1495
+ const state = readState(projectDir);
1496
+ const key = stateKey(projectDir, filePath);
1497
+ if (key in state) return state[key];
1498
+ return legacyRecordFor(key, state, (candidateKey) => isDistinctFileRecord(projectDir, candidateKey));
1499
+ }
1500
+ function isDistinctFileRecord(projectDir, candidateKey) {
1501
+ const asFile = path7.join(projectDir, candidateKey);
1502
+ return fs6.existsSync(asFile) && stateKey(projectDir, asFile) === candidateKey;
1503
+ }
1504
+ function staleVariantsOf(key, state, isDistinctFile) {
1505
+ const folded = key.toLowerCase();
1506
+ return Object.keys(state).filter((k) => k !== key && k.toLowerCase() === folded && !isDistinctFile(k));
1507
+ }
1508
+ function legacyRecordFor(key, state, isDistinctFile) {
1509
+ const folded = key.toLowerCase();
1510
+ for (const [k, v] of Object.entries(state)) {
1511
+ if (k === key || k.toLowerCase() !== folded) continue;
1512
+ if (isDistinctFile(k)) continue;
1513
+ return v;
1514
+ }
1515
+ return void 0;
1414
1516
  }
1415
1517
  function recordWritten(projectDir, filePath, content) {
1518
+ const tmp = `${statePath(projectDir)}.${process.pid}.tmp`;
1416
1519
  try {
1417
- const state = readState(projectDir);
1418
- state[path6.relative(projectDir, filePath).split(path6.sep).join("/")] = hashContent(content);
1419
- fs5.writeFileSync(statePath(projectDir), JSON.stringify(state, null, 2) + String.fromCharCode(10));
1520
+ const state = readRaw(projectDir);
1521
+ state._readme = README_NOTE;
1522
+ const key = stateKey(projectDir, filePath);
1523
+ for (const k of staleVariantsOf(key, state, (candidate) => isDistinctFileRecord(projectDir, candidate))) delete state[k];
1524
+ state[key] = hashContent(content);
1525
+ fs6.writeFileSync(tmp, JSON.stringify(state, null, 2) + String.fromCharCode(10));
1526
+ fs6.renameSync(tmp, statePath(projectDir));
1420
1527
  } catch {
1528
+ try {
1529
+ fs6.unlinkSync(tmp);
1530
+ } catch {
1531
+ }
1421
1532
  }
1422
1533
  }
1423
1534
 
1424
1535
  // src/commands/upload.ts
1425
1536
  var MAX_BUNDLE_BYTES = 500 * 1024;
1426
1537
  async function runUpload(options) {
1427
- const projectDir = process.cwd();
1538
+ const projectDir = findProjectRoot(process.cwd());
1428
1539
  const botInfo = await discoverBot(projectDir, options.bot);
1429
1540
  console.log(`
1430
1541
  Compiling ${botInfo.exportName}...`);
@@ -1437,7 +1548,7 @@ async function runUpload(options) {
1437
1548
  }
1438
1549
  let sourceCode = "";
1439
1550
  try {
1440
- sourceCode = fs6.readFileSync(botInfo.sourcePath, "utf-8");
1551
+ sourceCode = fs7.readFileSync(botInfo.sourcePath, "utf-8");
1441
1552
  } catch {
1442
1553
  }
1443
1554
  console.log(` Wizard: ${botInfo.exportName}`);
@@ -1450,7 +1561,7 @@ async function runUpload(options) {
1450
1561
  sourceCode,
1451
1562
  active: options.active
1452
1563
  });
1453
- if (sourceCode) recordWritten(process.cwd(), botInfo.sourcePath, sourceCode);
1564
+ if (sourceCode) recordWritten(findProjectRoot(process.cwd()), botInfo.sourcePath, sourceCode);
1454
1565
  console.log(` \u2713 ${result.message}`);
1455
1566
  if (result.wizardId) console.log(` Wizard ID: ${result.wizardId}`);
1456
1567
  console.log(result.active === false ? ` "${botInfo.exportName}" is retired and no longer competing.
@@ -1468,20 +1579,44 @@ async function runUpload(options) {
1468
1579
  }
1469
1580
 
1470
1581
  // src/commands/pull.ts
1471
- import fs7 from "fs";
1472
- import path7 from "path";
1582
+ import fs8 from "fs";
1583
+ import path8 from "path";
1584
+ var LocalProjectError = class extends Error {
1585
+ constructor(message) {
1586
+ super(message);
1587
+ this.name = "LocalProjectError";
1588
+ }
1589
+ };
1590
+ function adviceFor(err, name) {
1591
+ if (err instanceof LocalProjectError) return void 0;
1592
+ return name ? " See your bots with: vibemancer bots --mine" : " Upload first with: vibemancer upload";
1593
+ }
1473
1594
  function pullTargetPath(projectDir, name) {
1474
1595
  const trimmed = name.trim();
1475
1596
  if (!trimmed) throw new Error("A bot name is required.");
1476
1597
  if (/[\u0000-\u001f\u007f]/.test(trimmed)) throw new Error(`Refusing a bot name with a control character: ${JSON.stringify(name)}`);
1477
- if (trimmed !== path7.basename(trimmed) || trimmed === "." || trimmed === ".." || trimmed.includes("/") || trimmed.includes("\\")) {
1598
+ if (trimmed !== path8.basename(trimmed) || trimmed === "." || trimmed === ".." || trimmed.includes("/") || trimmed.includes("\\")) {
1478
1599
  throw new Error(`Refusing a bot name that is not a plain file name: ${JSON.stringify(name)}`);
1479
1600
  }
1480
- return path7.resolve(projectDir, "src", `${trimmed}.ts`);
1601
+ return path8.resolve(projectDir, "src", `${trimmed}.ts`);
1602
+ }
1603
+ function isInsideProject(projectDir, target) {
1604
+ const real = (p) => {
1605
+ try {
1606
+ return fs8.realpathSync.native(p);
1607
+ } catch {
1608
+ return path8.resolve(p);
1609
+ }
1610
+ };
1611
+ const resolved = fs8.existsSync(target) ? real(target) : path8.join(real(path8.dirname(target)), path8.basename(target));
1612
+ const root = real(projectDir);
1613
+ if (resolved === root) return true;
1614
+ const relative = path8.relative(root, resolved);
1615
+ return relative !== "" && !relative.startsWith("..") && !path8.isAbsolute(relative);
1481
1616
  }
1482
1617
  function readProjectConfig(projectDir) {
1483
1618
  try {
1484
- const raw = fs7.readFileSync(path7.join(projectDir, "vibemancer.json"), "utf-8");
1619
+ const raw = fs8.readFileSync(path8.join(projectDir, "vibemancer.json"), "utf-8");
1485
1620
  const parsed = JSON.parse(raw);
1486
1621
  if (typeof parsed !== "object" || parsed === null) return void 0;
1487
1622
  const bot = parsed.bot;
@@ -1500,7 +1635,11 @@ function pullDestination(projectDir, name, config) {
1500
1635
  const configuredExport = config?.export;
1501
1636
  if (!configured) return { path: fallback };
1502
1637
  if (configuredExport && configuredExport.toLowerCase() === name.trim().toLowerCase()) {
1503
- return { path: path7.resolve(projectDir, configured) };
1638
+ const configuredPath = path8.resolve(projectDir, configured);
1639
+ if (!isInsideProject(projectDir, configuredPath)) {
1640
+ throw new LocalProjectError(`Refusing to write outside the project: this project's vibemancer.json points "bot" at ${JSON.stringify(configured)}, which resolves outside ${projectDir}. Fix the path in vibemancer.json, or pass --bot with a path inside the project.`);
1641
+ }
1642
+ return { path: configuredPath };
1504
1643
  }
1505
1644
  return {
1506
1645
  path: fallback,
@@ -1508,10 +1647,10 @@ function pullDestination(projectDir, name, config) {
1508
1647
  };
1509
1648
  }
1510
1649
  function writeGuarded(projectDir, target, sourceCode, force) {
1511
- const isNew = !fs7.existsSync(target);
1650
+ const isNew = !fs8.existsSync(target);
1512
1651
  const decision = pullDecision(isNew ? { fileExists: false, incomingContent: sourceCode, force } : {
1513
1652
  fileExists: true,
1514
- localContent: fs7.readFileSync(target, "utf-8"),
1653
+ localContent: fs8.readFileSync(target, "utf-8"),
1515
1654
  incomingContent: sourceCode,
1516
1655
  recordedHash: recordedHashFor(projectDir, target),
1517
1656
  force
@@ -1519,7 +1658,7 @@ function writeGuarded(projectDir, target, sourceCode, force) {
1519
1658
  if (decision === "skip-identical") {
1520
1659
  console.log(` Already up to date: ${target}`);
1521
1660
  recordWritten(projectDir, target, sourceCode);
1522
- return false;
1661
+ return "identical";
1523
1662
  }
1524
1663
  if (decision !== "write") {
1525
1664
  const why = decision === "refuse-modified" ? "it has changes that were not pulled or uploaded from here" : "this CLI has never written it, so it may be work it did not create";
@@ -1528,14 +1667,14 @@ function writeGuarded(projectDir, target, sourceCode, force) {
1528
1667
  console.error(" Save or commit it first, or re-run with --force to replace it.");
1529
1668
  console.error("");
1530
1669
  process.exitCode = 1;
1531
- return false;
1670
+ return "refused";
1532
1671
  }
1533
- fs7.mkdirSync(path7.dirname(target), { recursive: true });
1534
- fs7.writeFileSync(target, sourceCode);
1672
+ fs8.mkdirSync(path8.dirname(target), { recursive: true });
1673
+ fs8.writeFileSync(target, sourceCode);
1535
1674
  recordWritten(projectDir, target, sourceCode);
1536
1675
  console.log(` \u2713 ${isNew ? "Created" : "Updated"} ${target}`);
1537
1676
  console.log(` ${(sourceCode.length / 1024).toFixed(1)} KB written`);
1538
- return true;
1677
+ return "written";
1539
1678
  }
1540
1679
  async function pullByName(projectDir, name, force) {
1541
1680
  pullTargetPath(projectDir, name);
@@ -1544,8 +1683,8 @@ async function pullByName(projectDir, name, force) {
1544
1683
  try {
1545
1684
  const pulled = await pullBot(name);
1546
1685
  const destination = pullDestination(projectDir, pulled.name, readProjectConfig(projectDir));
1547
- const wrote = writeGuarded(projectDir, destination.path, pulled.sourceCode, force);
1548
- if (wrote && destination.warning) console.log(` ${destination.warning}`);
1686
+ const outcome = writeGuarded(projectDir, destination.path, pulled.sourceCode, force);
1687
+ if (outcome !== "refused" && destination.warning) console.log(` ${destination.warning}`);
1549
1688
  } catch (err) {
1550
1689
  reportPullFailure(err, name);
1551
1690
  }
@@ -1557,13 +1696,14 @@ function reportPullFailure(err, name) {
1557
1696
  console.error("");
1558
1697
  } else {
1559
1698
  console.error(` Pull failed: ${err instanceof Error ? err.message : String(err)}`);
1560
- console.error(name ? " See your bots with: vibemancer bots --mine" : " Upload first with: vibemancer upload");
1699
+ const advice = adviceFor(err, name);
1700
+ if (advice) console.error(advice);
1561
1701
  console.error("");
1562
1702
  }
1563
1703
  process.exitCode = 1;
1564
1704
  }
1565
1705
  async function runPull(options) {
1566
- const projectDir = process.cwd();
1706
+ const projectDir = findProjectRoot(process.cwd());
1567
1707
  if (options.name) {
1568
1708
  await pullByName(projectDir, options.name, options.force === true);
1569
1709
  return;
@@ -1688,7 +1828,8 @@ async function revokeSession(token) {
1688
1828
  const res = await fetch(`${MCP_BASE_URL}/revoke`, {
1689
1829
  method: "POST",
1690
1830
  headers: { "Content-Type": "application/json" },
1691
- body: JSON.stringify({ token })
1831
+ body: JSON.stringify({ token }),
1832
+ signal: AbortSignal.timeout(AUTH_TIMEOUT_MS)
1692
1833
  });
1693
1834
  return res.ok;
1694
1835
  } catch {
@@ -1711,7 +1852,7 @@ async function runLogout() {
1711
1852
 
1712
1853
  // src/commands/feedback.ts
1713
1854
  async function defaultTokenLookup() {
1714
- const { getAccessToken: getAccessToken2 } = await import("./oauth-client-AX3HNK6P.js");
1855
+ const { getAccessToken: getAccessToken2 } = await import("./oauth-client-WUOWASU4.js");
1715
1856
  return await getAccessToken2();
1716
1857
  }
1717
1858
  async function runFeedback(options, tokenLookup = defaultTokenLookup) {
@@ -1765,7 +1906,19 @@ async function runFeedback(options, tokenLookup = defaultTokenLookup) {
1765
1906
  }
1766
1907
 
1767
1908
  // src/commands/missile-calc.ts
1768
- import { calculateMissileCastTime, validateMissileConfig, GCD_DURATION, TICKS_PER_SECOND, calculateMissileRadius } from "@vibemancer/core";
1909
+ import { calculateMissileCastTime, validateMissileConfig, GCD_DURATION, MATCH_DURATION, TICKS_PER_SECOND, calculateMissileRadius } from "@vibemancer/core";
1910
+ function describeAdjustments(raw, used) {
1911
+ const fields = ["damage", "speed", "duration", "turnRate"];
1912
+ const changed = fields.filter((f) => raw[f] !== used[f]);
1913
+ if (changed.length === 0) return null;
1914
+ const lines = changed.map((f) => ` ${f}: you asked for ${raw[f]}, the engine will use ${used[f]}`);
1915
+ return [" Adjusted to the engine's limits:", ...lines].join("\n");
1916
+ }
1917
+ function describeCastability(castFrames) {
1918
+ if (castFrames < MATCH_DURATION) return null;
1919
+ return ` NOTE: this cast takes ${castFrames} frames and a match is only ${MATCH_DURATION} - it never completes.
1920
+ The DPS above is theoretical: the missile is never launched.`;
1921
+ }
1769
1922
  function runMissileCalc(options) {
1770
1923
  const rawConfig = {
1771
1924
  damage: options.damage,
@@ -1781,6 +1934,7 @@ function runMissileCalc(options) {
1781
1934
  const dps = config.damage / totalCycleSec;
1782
1935
  const range = config.speed * config.duration;
1783
1936
  const radius = calculateMissileRadius(config.damage);
1937
+ const notes = [describeAdjustments(rawConfig, config), describeCastability(castFrames)].filter((n) => n !== null).join("\n");
1784
1938
  console.log(`
1785
1939
  Missile Calculator
1786
1940
  \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
@@ -1791,13 +1945,13 @@ function runMissileCalc(options) {
1791
1945
  Eff. DPS: ${dps.toFixed(2)} HP/s
1792
1946
  Range: ${range} units (speed \xD7 duration)
1793
1947
  Hitbox: ${radius.toFixed(2)} radius
1794
- `);
1948
+ ${notes ? notes + "\n" : ""}`);
1795
1949
  }
1796
1950
 
1797
1951
  // src/auth/telemetry.ts
1798
1952
  import os2 from "os";
1799
- import fs8 from "fs";
1800
- import path8 from "path";
1953
+ import fs9 from "fs";
1954
+ import path9 from "path";
1801
1955
  var TIMEOUT_MS = 1e3;
1802
1956
  function isTelemetryEnabled(env) {
1803
1957
  const off = (value) => {
@@ -1817,13 +1971,13 @@ function isTelemetryEnabled(env) {
1817
1971
  }
1818
1972
  var TELEMETRY_NOTICE = " Vibemancer records which commands are run, your OS and version numbers, to decide\n which platforms to support. No code, file paths or personal files are ever sent.\n Opt out any time with VIBEMANCER_TELEMETRY=0 (DO_NOT_TRACK is honoured too).\n";
1819
1973
  function noticePath() {
1820
- return path8.join(os2.homedir(), ".vibemancer", "telemetry-notice-shown");
1974
+ return path9.join(os2.homedir(), ".vibemancer", "telemetry-notice-shown");
1821
1975
  }
1822
1976
  function showNoticeOnce(marker = noticePath()) {
1823
1977
  try {
1824
- if (fs8.existsSync(marker)) return false;
1825
- fs8.mkdirSync(path8.dirname(marker), { recursive: true });
1826
- fs8.writeFileSync(marker, (/* @__PURE__ */ new Date()).toISOString());
1978
+ if (fs9.existsSync(marker)) return false;
1979
+ fs9.mkdirSync(path9.dirname(marker), { recursive: true });
1980
+ fs9.writeFileSync(marker, (/* @__PURE__ */ new Date()).toISOString());
1827
1981
  console.log(TELEMETRY_NOTICE);
1828
1982
  return true;
1829
1983
  } catch {
@@ -1855,18 +2009,24 @@ function parseFlag(flag) {
1855
2009
  }
1856
2010
  return void 0;
1857
2011
  }
1858
- function parseIntFlag(flag, fallback) {
2012
+ function parseNumericFlag(flag, fallback, kind) {
1859
2013
  const str = parseFlag(flag);
1860
2014
  if (str === void 0) return fallback;
1861
- const n = parseInt(str, 10);
1862
- if (Number.isNaN(n)) {
1863
- console.error(`Error: ${flag} must be a number, got "${str}".`);
2015
+ const parsed = parseNumberFlag(flag, str, kind);
2016
+ if (!parsed.ok) {
2017
+ console.error(`Error: ${parsed.message}`);
1864
2018
  process.exit(1);
1865
2019
  }
1866
- return n;
2020
+ return parsed.value;
1867
2021
  }
1868
- function printHelp() {
1869
- console.log(`
2022
+ function parseIntFlag(flag, fallback) {
2023
+ return parseNumericFlag(flag, fallback, "integer");
2024
+ }
2025
+ function parseFloatFlag(flag, fallback) {
2026
+ return parseNumericFlag(flag, fallback, "number");
2027
+ }
2028
+ function helpText() {
2029
+ return `
1870
2030
  Vibemancer CLI - Development tools for wizard bots
1871
2031
 
1872
2032
  Usage:
@@ -1906,6 +2066,27 @@ Trace options:
1906
2066
  --opponent <name> Opponent: a built-in name OR handle/botname (required)
1907
2067
  --seed <n> Random seed (default: 1)
1908
2068
  --distance <n> Spawn distance (default: 600)
2069
+ --ticks <n> How many ticks to trace (default: 3000, a full match is 30000)
2070
+
2071
+ Bots options:
2072
+ --mine List YOUR uploaded bots instead of the built-ins
2073
+ --name <bot> Show one bot by name
2074
+
2075
+ Pull options:
2076
+ --name <bot> Pull by name, with no local file needed
2077
+ --bot <path> Write to this path instead of the project's configured bot
2078
+ --force Overwrite local changes the CLI did not write. Do not reach for
2079
+ this by default: pull refuses precisely when the local file may
2080
+ hold work that was never uploaded.
2081
+
2082
+ Login options:
2083
+ --no-browser Print the URL instead of opening a browser, and paste the code back
2084
+
2085
+ Missile-calc options:
2086
+ --damage <n> Hit point damage (default: 15)
2087
+ --speed <n> Units per frame (default: 7; the engine's minimum is 1.5)
2088
+ --duration <n> Lifetime in frames (default: 200; range = speed x duration)
2089
+ --turnRate <n> Degrees per frame the missile can turn (default: 0 = straight)
1909
2090
 
1910
2091
  Upload options:
1911
2092
  --bot <path> Path to your bot source file
@@ -1945,7 +2126,10 @@ Examples:
1945
2126
  vibemancer build --opponent Battlemage
1946
2127
  vibemancer feedback "missiles go through walls sometimes"
1947
2128
  vibemancer missile-calc --damage 15 --speed 7 --duration 200 --turnRate 3
1948
- `);
2129
+ `;
2130
+ }
2131
+ function printHelp() {
2132
+ console.log(helpText());
1949
2133
  }
1950
2134
  var VALUE_FLAGS = /* @__PURE__ */ new Set([
1951
2135
  "--bot",
@@ -1983,7 +2167,11 @@ var KNOWN_FLAGS = {
1983
2167
  // per-bot concept. The flag was accepted, dropped, and the run reported success for a bot
1984
2168
  // path that did not exist. A flag that cannot work has to be refused, not swallowed.
1985
2169
  test: [],
1986
- fight: ["--bot", "--opponent", "--seed", "--distance"],
2170
+ // --distance is NOT here, and that is the fix rather than an oversight: it is documented
2171
+ // under Trace options, the fight branch never read it, and runFight has no such option. It
2172
+ // was accepted and dropped, so `fight --distance 300` silently ran at the default 600. A
2173
+ // flag that cannot work has to be refused by name, exactly as the `test: []` note says.
2174
+ fight: ["--bot", "--opponent", "--seed"],
1987
2175
  trace: ["--bot", "--opponent", "--seed", "--distance", "--ticks"],
1988
2176
  tournament: ["--bot"],
1989
2177
  optimize: ["--bot", "--steps", "--rounds", "--opponents", "--seed"],
@@ -1996,7 +2184,15 @@ var KNOWN_FLAGS = {
1996
2184
  // flag was accepted and dropped, so `pull --nmae Wiz2` fell through to file discovery and
1997
2185
  // reported "could not find bot source file" about a flag the user had spelled wrong.
1998
2186
  pull: ["--bot", "--name", "--force"],
1999
- "missile-calc": ["--damage", "--speed", "--duration", "--turnRate"]
2187
+ "missile-calc": ["--damage", "--speed", "--duration", "--turnRate"],
2188
+ // login/logout were absent, so a typo'd flag was silently ignored - the same defect the
2189
+ // fight and upload entries above were added for.
2190
+ login: ["--no-browser"],
2191
+ logout: []
2192
+ // feedback is DELIBERATELY absent, and it is the one command that must stay that way: its
2193
+ // argument is a free-text message, joined from every remaining token. Validating flags here
2194
+ // would reject 'missiles go through walls --sometimes' as an unknown flag, turning a bug
2195
+ // report into an error. A command whose argument is prose cannot also police prose.
2000
2196
  };
2001
2197
  var BOOLEAN_FLAGS = ["--mine", "--inactive", "--active", "--no-browser", "--force"];
2002
2198
  function flagsMissingValues(command2, argv) {
@@ -2024,6 +2220,19 @@ function unknownFlags(command2, argv) {
2024
2220
  }
2025
2221
  return found;
2026
2222
  }
2223
+ function duplicateFlags(argv) {
2224
+ const seen = /* @__PURE__ */ new Set();
2225
+ const repeated = /* @__PURE__ */ new Set();
2226
+ for (let i = 0; i < argv.length; i++) {
2227
+ const arg = argv[i];
2228
+ if (!arg.startsWith("--")) continue;
2229
+ const previous = i > 0 ? argv[i - 1] : "";
2230
+ if (VALUE_FLAGS.has(previous)) continue;
2231
+ if (seen.has(arg)) repeated.add(arg);
2232
+ seen.add(arg);
2233
+ }
2234
+ return [...repeated];
2235
+ }
2027
2236
  function editDistance(a, b) {
2028
2237
  const rows = [];
2029
2238
  for (let i = 0; i <= a.length; i++) rows.push([i, ...new Array(b.length).fill(0)]);
@@ -2078,6 +2287,14 @@ async function main() {
2078
2287
  Run "vibemancer --help" to see the flags each command takes.`);
2079
2288
  process.exit(1);
2080
2289
  }
2290
+ const repeated = duplicateFlags(args.slice(1));
2291
+ if (repeated.length > 0) {
2292
+ for (const flag of repeated) {
2293
+ console.error(`Error: ${flag} was given more than once, and only the first would be used.`);
2294
+ }
2295
+ console.error("Remove the extra one so there is no doubt which value you meant.");
2296
+ process.exit(1);
2297
+ }
2081
2298
  const valueless = flagsMissingValues(command, args.slice(1));
2082
2299
  if (valueless.length > 0) {
2083
2300
  for (const flag of valueless) {
@@ -2123,7 +2340,7 @@ ${list}
2123
2340
  }
2124
2341
  const bot = parseFlag("--bot");
2125
2342
  const seed = parseFlag("--seed") !== void 0 ? parseIntFlag("--seed", 0) : void 0;
2126
- const distance = parseFlag("--distance") !== void 0 ? parseIntFlag("--distance", 600) : void 0;
2343
+ const distance = parseFlag("--distance") !== void 0 ? parseFloatFlag("--distance", 600) : void 0;
2127
2344
  const maxTicks = parseFlag("--ticks") !== void 0 ? parseIntFlag("--ticks", 3e3) : void 0;
2128
2345
  await runTrace({ opponent, bot, seed, distance, maxTicks });
2129
2346
  break;
@@ -2203,10 +2420,12 @@ ${list}
2203
2420
  }
2204
2421
  case "missile-calc": {
2205
2422
  runMissileCalc({
2206
- damage: parseIntFlag("--damage", 15),
2207
- speed: parseIntFlag("--speed", 7),
2208
- duration: parseIntFlag("--duration", 200),
2209
- turnRate: parseIntFlag("--turnRate", 0)
2423
+ // Fractional: MISSILE_MIN_SPEED is 1.5, so an integer-only --speed could not express
2424
+ // the engine's own minimum. The engine floors duration itself.
2425
+ damage: parseFloatFlag("--damage", 15),
2426
+ speed: parseFloatFlag("--speed", 7),
2427
+ duration: parseFloatFlag("--duration", 200),
2428
+ turnRate: parseFloatFlag("--turnRate", 0)
2210
2429
  });
2211
2430
  break;
2212
2431
  }
@@ -2229,8 +2448,11 @@ main().catch((error) => {
2229
2448
  process.exit(1);
2230
2449
  });
2231
2450
  export {
2451
+ KNOWN_FLAGS,
2452
+ duplicateFlags,
2232
2453
  flagHint,
2233
2454
  flagsMissingValues,
2455
+ helpText,
2234
2456
  suggestFlag,
2235
2457
  unknownFlags,
2236
2458
  wantsHelp