@balise.dev/cli 0.1.3 → 0.2.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 +19 -0
  2. package/dist/index.js +608 -176
  3. package/package.json +4 -1
package/README.md CHANGED
@@ -61,6 +61,25 @@ Tokens are written to a plaintext JSON file with restrictive permissions:
61
61
 
62
62
  If you need OS-keychain-grade protection, set `BALISE_CREDENTIALS_FILE` to a path on an encrypted volume, or feed `BALISE_TOKEN` from your existing secret manager.
63
63
 
64
+ ## Build-time policy patterns
65
+
66
+ `balise init` ships with two compile-time pattern lists baked into the bundle via tsup `define`:
67
+
68
+ | Env var (build) | Used for | SSOT contract |
69
+ |---|---|---|
70
+ | `BALISE_BAN_PATTERNS` | Directories the extractor will never read (`.git`, `node_modules`, …). Displayed as 🔒 read-only in the `balise init` ignore tree. | **MUST** match the worker-side env var of the same name (read by `balise_mcp/diff_loc.py`). Changing one without the other breaks the SSOT verrou between credit estimate and what the agent sees. |
71
+ | `BALISE_DEFAULT_PATTERNS` | Globs pre-checked-out in the ignore tree as a sensible default (lockfiles, minified assets, snapshots, …). User remains free to scan them — they just pay the tokens. | CLI-only. |
72
+
73
+ Override at build time:
74
+
75
+ ```bash
76
+ BALISE_BAN_PATTERNS=.git,node_modules,dist \
77
+ BALISE_DEFAULT_PATTERNS='*.lock,*.min.js' \
78
+ npm run build
79
+ ```
80
+
81
+ The defaults baked when these env vars are unset are visible in `cli/tsup.config.ts`.
82
+
64
83
  ## Dev
65
84
 
66
85
  ```bash
package/dist/index.js CHANGED
@@ -548,8 +548,8 @@ var ApiClient = class {
548
548
  }
549
549
  return res.json();
550
550
  }
551
- async getJson(path5) {
552
- const url = joinUrl(this.opts.apiUrl, path5);
551
+ async getJson(path7) {
552
+ const url = joinUrl(this.opts.apiUrl, path7);
553
553
  return this.withAuth(async (headers) => {
554
554
  const { statusCode, body } = await wrapNetwork(() => request2(url, {
555
555
  method: "GET",
@@ -564,8 +564,8 @@ var ApiClient = class {
564
564
  };
565
565
  });
566
566
  }
567
- async postJson(path5, payload) {
568
- const url = joinUrl(this.opts.apiUrl, path5);
567
+ async postJson(path7, payload) {
568
+ const url = joinUrl(this.opts.apiUrl, path7);
569
569
  return this.withAuth(async (headers) => {
570
570
  const { statusCode, body } = await wrapNetwork(() => request2(url, {
571
571
  method: "POST",
@@ -589,10 +589,10 @@ var ApiClient = class {
589
589
  * metadata travels in the query string. Matches the FastAPI contract
590
590
  * `POST /v1/repos/:owner/:slug/sync?commit_sha=...&branch=...`.
591
591
  */
592
- async uploadBundle(path5, opts) {
592
+ async uploadBundle(path7, opts) {
593
593
  await this.ensureFreshToken();
594
594
  const qs = new URLSearchParams(opts.query).toString();
595
- const url = `${joinUrl(this.opts.apiUrl, path5)}${qs ? `?${qs}` : ""}`;
595
+ const url = `${joinUrl(this.opts.apiUrl, path7)}${qs ? `?${qs}` : ""}`;
596
596
  return this.withAuth(async (headers) => {
597
597
  const { statusCode, body } = await wrapNetwork(() => request2(url, {
598
598
  method: "POST",
@@ -622,10 +622,10 @@ var ApiClient = class {
622
622
  * `withAuth` for the 401-then-retry plumbing. Non-401 statuses are
623
623
  * returned to the caller verbatim — `withAuth` only treats 401 specially.
624
624
  */
625
- async uploadBundleRaw(path5, opts) {
625
+ async uploadBundleRaw(path7, opts) {
626
626
  await this.ensureFreshToken();
627
627
  const qs = new URLSearchParams(opts.query).toString();
628
- const url = `${joinUrl(this.opts.apiUrl, path5)}${qs ? `?${qs}` : ""}`;
628
+ const url = `${joinUrl(this.opts.apiUrl, path7)}${qs ? `?${qs}` : ""}`;
629
629
  let tokens = await loadTokens();
630
630
  if (!tokens) throw new NotAuthenticatedError();
631
631
  const exec = async (token) => {
@@ -648,8 +648,8 @@ var ApiClient = class {
648
648
  throw new NotAuthenticatedError();
649
649
  }
650
650
  };
651
- function joinUrl(base, path5) {
652
- return `${base.replace(/\/$/, "")}${path5.startsWith("/") ? path5 : `/${path5}`}`;
651
+ function joinUrl(base, path7) {
652
+ return `${base.replace(/\/$/, "")}${path7.startsWith("/") ? path7 : `/${path7}`}`;
653
653
  }
654
654
 
655
655
  // src/config.ts
@@ -734,8 +734,8 @@ account_id : ${me.account_id}
734
734
  }
735
735
 
736
736
  // src/commands/init.ts
737
- import path3 from "path";
738
- import React2 from "react";
737
+ import path5 from "path";
738
+ import React3 from "react";
739
739
  import { render } from "ink";
740
740
 
741
741
  // src/git.ts
@@ -921,13 +921,14 @@ function normalizeSlug(raw) {
921
921
  }
922
922
  function InitPicker(props) {
923
923
  const { exit } = useApp();
924
- const [field, setField] = useState("slug");
924
+ const currentMatches = props.currentRepoId !== void 0 && props.repos.some((r) => r.id === props.currentRepoId);
925
+ const [field, setField] = useState(currentMatches ? "link" : "slug");
925
926
  const [slug, setSlug] = useState(normalizeSlug(props.defaultSlug));
926
927
  const [ownerId, setOwnerId] = useState(
927
928
  props.ownerships[0]?.id
928
929
  );
929
930
  const [repoId, setRepoId] = useState(
930
- props.repos[0]?.id
931
+ currentMatches ? props.currentRepoId : props.repos[0]?.id
931
932
  );
932
933
  const submit = () => {
933
934
  if (field === "link") {
@@ -964,7 +965,7 @@ function InitPicker(props) {
964
965
  value: o.id
965
966
  }));
966
967
  const repoOptions = props.repos.map((r) => ({
967
- label: `${r.owner_login}/${r.slug}`,
968
+ label: r.id === props.currentRepoId ? `${r.owner_login}/${r.slug} (current)` : `${r.owner_login}/${r.slug}`,
968
969
  value: r.id
969
970
  }));
970
971
  return /* @__PURE__ */ React.createElement(Box, { flexDirection: "column", paddingX: 1 }, /* @__PURE__ */ React.createElement(Text, { bold: true }, "Balise \u2014 link or create a repo"), /* @__PURE__ */ React.createElement(Box, { marginTop: 1, flexDirection: "column" }, /* @__PURE__ */ React.createElement(Text, { color: field === "slug" ? "cyan" : "gray" }, field === "slug" ? "\u25B8 " : " ", "New repo slug"), /* @__PURE__ */ React.createElement(Box, { marginLeft: 2 }, /* @__PURE__ */ React.createElement(
@@ -992,6 +993,550 @@ function InitPicker(props) {
992
993
  ) : /* @__PURE__ */ React.createElement(Text, { dimColor: true }, "tab to browse"))), /* @__PURE__ */ React.createElement(Box, { marginTop: 1 }, /* @__PURE__ */ React.createElement(Text, { dimColor: true }, "Tab: next field \xB7 \u2191/\u2193: navigate \xB7 Enter: confirm \xB7 Esc: cancel")));
993
994
  }
994
995
 
996
+ // src/scan.ts
997
+ import { isUtf8 } from "buffer";
998
+ import { execFile as execFileCb } from "child_process";
999
+ import { promises as fs3 } from "fs";
1000
+ import path3 from "path";
1001
+ import { promisify } from "util";
1002
+ import picomatch from "picomatch";
1003
+
1004
+ // src/policy.ts
1005
+ var BAN_PATTERNS = [
1006
+ ".git",
1007
+ "node_modules",
1008
+ "__pycache__",
1009
+ ".venv",
1010
+ "venv",
1011
+ "dist",
1012
+ "build",
1013
+ ".next",
1014
+ ".turbo",
1015
+ ".cache",
1016
+ "target"
1017
+ ];
1018
+ var DEFAULT_PATTERNS = [
1019
+ "*.lock",
1020
+ "package-lock.json",
1021
+ "yarn.lock",
1022
+ "pnpm-lock.yaml",
1023
+ "*.min.js",
1024
+ "*.min.css",
1025
+ "*.svg",
1026
+ "*.png",
1027
+ "*.jpg",
1028
+ "*.jpeg",
1029
+ "*.gif",
1030
+ "*.ico",
1031
+ "*.woff",
1032
+ "*.woff2",
1033
+ "*.snap",
1034
+ "CHANGELOG.md"
1035
+ ];
1036
+ var LOC_THRESHOLD = 2500;
1037
+ var MAX_FILE_BYTES = 1e6;
1038
+
1039
+ // src/scan.ts
1040
+ var execFile = promisify(execFileCb);
1041
+ var BAN_DIRS = new Set(BAN_PATTERNS);
1042
+ var matchesDefault = DEFAULT_PATTERNS.length > 0 ? picomatch([...DEFAULT_PATTERNS], { dot: true, matchBase: true }) : () => false;
1043
+ function countLines(buf) {
1044
+ let count = 0;
1045
+ for (let i = 0; i < buf.length; i++) {
1046
+ if (buf[i] === 10) count++;
1047
+ }
1048
+ if (buf.length > 0 && buf[buf.length - 1] !== 10) count++;
1049
+ return count;
1050
+ }
1051
+ async function scanFile(absPath) {
1052
+ let stat;
1053
+ try {
1054
+ stat = await fs3.stat(absPath);
1055
+ } catch {
1056
+ return null;
1057
+ }
1058
+ if (stat.size > MAX_FILE_BYTES) return null;
1059
+ const buf = await fs3.readFile(absPath);
1060
+ if (!isUtf8(buf)) return null;
1061
+ return countLines(buf);
1062
+ }
1063
+ async function listTrackedFiles(cwd) {
1064
+ try {
1065
+ const { stdout } = await execFile("git", ["ls-files", "-z"], {
1066
+ cwd,
1067
+ maxBuffer: 50 * 1024 * 1024
1068
+ });
1069
+ return new Set(stdout.split("\0").filter((l) => l.length > 0));
1070
+ } catch {
1071
+ return null;
1072
+ }
1073
+ }
1074
+ async function walk(absDir, relDir, out, tracked) {
1075
+ let dirents;
1076
+ try {
1077
+ dirents = await fs3.readdir(absDir, { withFileTypes: true });
1078
+ } catch {
1079
+ return;
1080
+ }
1081
+ for (const d of dirents) {
1082
+ const relPath = relDir ? `${relDir}/${d.name}` : d.name;
1083
+ const absPath = path3.join(absDir, d.name);
1084
+ if (d.isDirectory()) {
1085
+ if (BAN_DIRS.has(d.name)) {
1086
+ out.push({
1087
+ path: relPath,
1088
+ locCount: 0,
1089
+ isBan: true,
1090
+ isDefault: false,
1091
+ isLarge: false
1092
+ });
1093
+ continue;
1094
+ }
1095
+ await walk(absPath, relPath, out, tracked);
1096
+ } else if (d.isFile()) {
1097
+ if (tracked !== null && !tracked.has(relPath)) continue;
1098
+ const locCount = await scanFile(absPath);
1099
+ if (locCount === null) continue;
1100
+ out.push({
1101
+ path: relPath,
1102
+ locCount,
1103
+ isBan: false,
1104
+ isDefault: matchesDefault(relPath),
1105
+ isLarge: locCount > LOC_THRESHOLD
1106
+ });
1107
+ }
1108
+ }
1109
+ }
1110
+ async function scan(cwd) {
1111
+ const tracked = await listTrackedFiles(cwd);
1112
+ const out = [];
1113
+ await walk(cwd, "", out, tracked);
1114
+ return out;
1115
+ }
1116
+
1117
+ // src/baliseignore.ts
1118
+ import { promises as fs4 } from "fs";
1119
+ import path4 from "path";
1120
+ var FILENAME2 = ".baliseignore";
1121
+ var HEADER = [
1122
+ "# Generated by balise init.",
1123
+ "# balise init will overwrite this file on next run.",
1124
+ ""
1125
+ ].join("\n");
1126
+ async function readBaliseignore(cwd) {
1127
+ const file = path4.join(cwd, FILENAME2);
1128
+ let raw;
1129
+ try {
1130
+ raw = await fs4.readFile(file, "utf-8");
1131
+ } catch (err) {
1132
+ if (err.code === "ENOENT") return /* @__PURE__ */ new Set();
1133
+ throw err;
1134
+ }
1135
+ const out = /* @__PURE__ */ new Set();
1136
+ for (const line of raw.split(/\r?\n/)) {
1137
+ const trimmed = line.trim();
1138
+ if (!trimmed || trimmed.startsWith("#")) continue;
1139
+ out.add(trimmed);
1140
+ }
1141
+ return out;
1142
+ }
1143
+ async function writeBaliseignore(cwd, patterns) {
1144
+ const sorted = [...new Set(patterns)].filter((p) => p.length > 0).sort((a, b) => a.localeCompare(b));
1145
+ const body = sorted.length > 0 ? sorted.join("\n") + "\n" : "";
1146
+ await fs4.writeFile(path4.join(cwd, FILENAME2), HEADER + body, "utf-8");
1147
+ }
1148
+
1149
+ // src/ui/IgnoreTree.tsx
1150
+ import React2, { useMemo, useState as useState2 } from "react";
1151
+ import { Box as Box2, Text as Text2, useApp as useApp2, useInput as useInput2 } from "ink";
1152
+ function buildTree(entries) {
1153
+ const root = {
1154
+ path: "",
1155
+ name: ".",
1156
+ isDir: true,
1157
+ isBan: false,
1158
+ locCount: 0,
1159
+ isDefault: false,
1160
+ isLarge: false,
1161
+ children: [],
1162
+ parent: null
1163
+ };
1164
+ const dirMap = /* @__PURE__ */ new Map();
1165
+ dirMap.set("", root);
1166
+ const ensureDir = (dirPath) => {
1167
+ if (!dirPath) return root;
1168
+ const existing = dirMap.get(dirPath);
1169
+ if (existing) return existing;
1170
+ const parts = dirPath.split("/");
1171
+ const name = parts[parts.length - 1];
1172
+ const parentPath = parts.slice(0, -1).join("/");
1173
+ const parent = ensureDir(parentPath);
1174
+ const node = {
1175
+ path: dirPath,
1176
+ name,
1177
+ isDir: true,
1178
+ isBan: false,
1179
+ locCount: 0,
1180
+ isDefault: false,
1181
+ isLarge: false,
1182
+ children: [],
1183
+ parent
1184
+ };
1185
+ parent.children.push(node);
1186
+ dirMap.set(dirPath, node);
1187
+ return node;
1188
+ };
1189
+ const sorted = [...entries].sort((a, b) => a.path.localeCompare(b.path));
1190
+ for (const e of sorted) {
1191
+ const parts = e.path.split("/");
1192
+ const name = parts[parts.length - 1];
1193
+ const parentPath = parts.slice(0, -1).join("/");
1194
+ const parent = ensureDir(parentPath);
1195
+ const node = {
1196
+ path: e.path,
1197
+ name,
1198
+ isDir: e.isBan,
1199
+ // ban entries from scan() are directories by construction
1200
+ isBan: e.isBan,
1201
+ locCount: e.locCount,
1202
+ isDefault: e.isDefault,
1203
+ isLarge: e.isLarge,
1204
+ children: [],
1205
+ parent
1206
+ };
1207
+ parent.children.push(node);
1208
+ if (e.isBan) dirMap.set(e.path, node);
1209
+ }
1210
+ const sortChildren = (n) => {
1211
+ n.children.sort((a, b) => {
1212
+ if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
1213
+ return a.name.localeCompare(b.name);
1214
+ });
1215
+ for (const c of n.children) sortChildren(c);
1216
+ };
1217
+ sortChildren(root);
1218
+ return root;
1219
+ }
1220
+ function collectDescendantFiles(node) {
1221
+ const out = [];
1222
+ const recurse = (n) => {
1223
+ if (!n.isDir && !n.isBan) out.push(n.path);
1224
+ for (const c of n.children) recurse(c);
1225
+ };
1226
+ recurse(node);
1227
+ return out;
1228
+ }
1229
+ function flattenVisible(root, expanded) {
1230
+ const out = [];
1231
+ const recurse = (node, depth) => {
1232
+ if (node !== root) {
1233
+ out.push({ node, depth });
1234
+ }
1235
+ if (node === root || node.isDir && !node.isBan && expanded.has(node.path)) {
1236
+ for (const c of node.children) recurse(c, depth + 1);
1237
+ }
1238
+ };
1239
+ recurse(root, -1);
1240
+ return out;
1241
+ }
1242
+ function dirAllIncluded(node, excluded) {
1243
+ const files = collectDescendantFiles(node);
1244
+ if (files.length === 0) return true;
1245
+ return files.every((f) => !excluded.has(f));
1246
+ }
1247
+ function computeInitialExcluded(scan2, current) {
1248
+ const out = /* @__PURE__ */ new Set();
1249
+ const scanPaths = /* @__PURE__ */ new Set();
1250
+ for (const e of scan2) {
1251
+ if (e.isBan) continue;
1252
+ scanPaths.add(e.path);
1253
+ if (e.isDefault || e.isLarge) out.add(e.path);
1254
+ }
1255
+ for (const p of current) {
1256
+ if (!scanPaths.has(p)) out.add(p);
1257
+ }
1258
+ return out;
1259
+ }
1260
+ function computeDiff(current, excluded) {
1261
+ const all = /* @__PURE__ */ new Set([...current, ...excluded]);
1262
+ return [...all].sort((a, b) => a.localeCompare(b)).map((pattern) => {
1263
+ const inCurrent = current.has(pattern);
1264
+ const inExcluded = excluded.has(pattern);
1265
+ const kind = inCurrent && inExcluded ? "unchanged" : inExcluded ? "added" : "removed";
1266
+ return { pattern, kind };
1267
+ });
1268
+ }
1269
+ var VIEWPORT_HEIGHT = 18;
1270
+ function IgnoreTree(props) {
1271
+ const { exit } = useApp2();
1272
+ const root = useMemo(() => buildTree(props.scan), [props.scan]);
1273
+ const [excluded, setExcluded] = useState2(
1274
+ () => computeInitialExcluded(props.scan, props.currentBaliseignore)
1275
+ );
1276
+ const [expandedDirs, setExpandedDirs] = useState2(/* @__PURE__ */ new Set());
1277
+ const [focus, setFocus] = useState2("tree");
1278
+ const [treeCursor, setTreeCursor] = useState2("");
1279
+ const [diffCursor, setDiffCursor] = useState2(0);
1280
+ const [modalOpen, setModalOpen] = useState2(false);
1281
+ const visible = useMemo(
1282
+ () => flattenVisible(root, expandedDirs),
1283
+ [root, expandedDirs]
1284
+ );
1285
+ const diff = useMemo(
1286
+ () => computeDiff(props.currentBaliseignore, excluded),
1287
+ [props.currentBaliseignore, excluded]
1288
+ );
1289
+ const treeIdx = useMemo(() => {
1290
+ if (visible.length === 0) return -1;
1291
+ const i = visible.findIndex((v) => v.node.path === treeCursor);
1292
+ return i >= 0 ? i : 0;
1293
+ }, [visible, treeCursor]);
1294
+ const safeDiffIdx = diff.length === 0 ? -1 : Math.max(0, Math.min(diffCursor, diff.length - 1));
1295
+ const stats = useMemo(() => {
1296
+ let added = 0;
1297
+ let removed = 0;
1298
+ let unchanged = 0;
1299
+ for (const p of excluded) {
1300
+ if (props.currentBaliseignore.has(p)) unchanged++;
1301
+ else added++;
1302
+ }
1303
+ for (const p of props.currentBaliseignore) {
1304
+ if (!excluded.has(p)) removed++;
1305
+ }
1306
+ return { added, removed, unchanged };
1307
+ }, [excluded, props.currentBaliseignore]);
1308
+ const toggleFile = (path7) => {
1309
+ setExcluded((prev) => {
1310
+ const next = new Set(prev);
1311
+ if (next.has(path7)) next.delete(path7);
1312
+ else next.add(path7);
1313
+ return next;
1314
+ });
1315
+ };
1316
+ const toggleDir = (dirNode) => {
1317
+ const descendants = collectDescendantFiles(dirNode);
1318
+ if (descendants.length === 0) return;
1319
+ const allIncluded = descendants.every((p) => !excluded.has(p));
1320
+ setExcluded((prev) => {
1321
+ const next = new Set(prev);
1322
+ if (allIncluded) {
1323
+ for (const p of descendants) next.add(p);
1324
+ } else {
1325
+ for (const p of descendants) next.delete(p);
1326
+ }
1327
+ return next;
1328
+ });
1329
+ };
1330
+ useInput2((input, key) => {
1331
+ if (modalOpen) {
1332
+ if (input === "y" || input === "Y") {
1333
+ props.onDone({
1334
+ action: "save",
1335
+ patterns: [...excluded].sort((a, b) => a.localeCompare(b))
1336
+ });
1337
+ exit();
1338
+ return;
1339
+ }
1340
+ if (input === "n" || input === "N") {
1341
+ setModalOpen(false);
1342
+ return;
1343
+ }
1344
+ if (input === "q" || input === "Q" || key.escape) {
1345
+ props.onDone({ action: "cancel", patterns: [] });
1346
+ exit();
1347
+ return;
1348
+ }
1349
+ return;
1350
+ }
1351
+ if (key.tab) {
1352
+ setFocus((f) => f === "tree" ? "diff" : "tree");
1353
+ return;
1354
+ }
1355
+ if (key.return) {
1356
+ setModalOpen(true);
1357
+ return;
1358
+ }
1359
+ if (key.escape || input === "q") {
1360
+ setModalOpen(true);
1361
+ return;
1362
+ }
1363
+ if (focus === "tree") {
1364
+ if (key.downArrow) {
1365
+ const i = Math.min(treeIdx + 1, visible.length - 1);
1366
+ if (i >= 0) setTreeCursor(visible[i].node.path);
1367
+ return;
1368
+ }
1369
+ if (key.upArrow) {
1370
+ const i = Math.max(treeIdx - 1, 0);
1371
+ if (i >= 0) setTreeCursor(visible[i].node.path);
1372
+ return;
1373
+ }
1374
+ if (key.rightArrow) {
1375
+ const cur = treeIdx >= 0 ? visible[treeIdx].node : null;
1376
+ if (!cur || !cur.isDir || cur.isBan) return;
1377
+ if (!expandedDirs.has(cur.path)) {
1378
+ setExpandedDirs((s) => {
1379
+ const next = new Set(s);
1380
+ next.add(cur.path);
1381
+ return next;
1382
+ });
1383
+ } else if (cur.children.length > 0) {
1384
+ setTreeCursor(cur.children[0].path);
1385
+ }
1386
+ return;
1387
+ }
1388
+ if (key.leftArrow) {
1389
+ const cur = treeIdx >= 0 ? visible[treeIdx].node : null;
1390
+ if (!cur) return;
1391
+ if (cur.isDir && !cur.isBan && expandedDirs.has(cur.path)) {
1392
+ setExpandedDirs((s) => {
1393
+ const next = new Set(s);
1394
+ next.delete(cur.path);
1395
+ return next;
1396
+ });
1397
+ } else if (cur.parent && cur.parent.path !== "") {
1398
+ setTreeCursor(cur.parent.path);
1399
+ }
1400
+ return;
1401
+ }
1402
+ if (input === " ") {
1403
+ const cur = treeIdx >= 0 ? visible[treeIdx].node : null;
1404
+ if (!cur || cur.isBan) return;
1405
+ if (cur.isDir) toggleDir(cur);
1406
+ else toggleFile(cur.path);
1407
+ return;
1408
+ }
1409
+ } else {
1410
+ if (key.downArrow) {
1411
+ setDiffCursor((c) => Math.min(c + 1, Math.max(diff.length - 1, 0)));
1412
+ return;
1413
+ }
1414
+ if (key.upArrow) {
1415
+ setDiffCursor((c) => Math.max(c - 1, 0));
1416
+ return;
1417
+ }
1418
+ if (input === " ") {
1419
+ if (safeDiffIdx < 0) return;
1420
+ toggleFile(diff[safeDiffIdx].pattern);
1421
+ return;
1422
+ }
1423
+ }
1424
+ });
1425
+ if (modalOpen) {
1426
+ return /* @__PURE__ */ React2.createElement(ConfirmModal, { stats, excludedSize: excluded.size });
1427
+ }
1428
+ const treeStart = Math.max(
1429
+ 0,
1430
+ Math.min(
1431
+ treeIdx - Math.floor(VIEWPORT_HEIGHT / 2),
1432
+ visible.length - VIEWPORT_HEIGHT
1433
+ )
1434
+ );
1435
+ const treeWindow = visible.slice(
1436
+ Math.max(treeStart, 0),
1437
+ Math.max(treeStart, 0) + VIEWPORT_HEIGHT
1438
+ );
1439
+ const diffStart = Math.max(
1440
+ 0,
1441
+ Math.min(
1442
+ safeDiffIdx - Math.floor(VIEWPORT_HEIGHT / 2),
1443
+ diff.length - VIEWPORT_HEIGHT
1444
+ )
1445
+ );
1446
+ const diffWindow = diff.slice(
1447
+ Math.max(diffStart, 0),
1448
+ Math.max(diffStart, 0) + VIEWPORT_HEIGHT
1449
+ );
1450
+ const treeWindowStart = Math.max(treeStart, 0);
1451
+ const diffWindowStart = Math.max(diffStart, 0);
1452
+ return /* @__PURE__ */ React2.createElement(Box2, { flexDirection: "column", paddingX: 1 }, /* @__PURE__ */ React2.createElement(Text2, { bold: true }, "Balise \u2014 configure .baliseignore"), /* @__PURE__ */ React2.createElement(Text2, { dimColor: true }, "Tab: panel \xB7 \u2191/\u2193: nav \xB7 \u2192/\u2190: open/close \xB7 Space: toggle \xB7 Enter: save \xB7 Esc: cancel"), /* @__PURE__ */ React2.createElement(Box2, { marginTop: 1 }, /* @__PURE__ */ React2.createElement(
1453
+ Box2,
1454
+ {
1455
+ flexDirection: "column",
1456
+ width: "50%",
1457
+ paddingRight: 1,
1458
+ borderStyle: "round",
1459
+ borderColor: focus === "tree" ? "cyan" : "gray"
1460
+ },
1461
+ /* @__PURE__ */ React2.createElement(Text2, { bold: true, color: focus === "tree" ? "cyan" : void 0 }, "Files"),
1462
+ treeWindow.length === 0 ? /* @__PURE__ */ React2.createElement(Text2, { dimColor: true }, "(no files)") : treeWindow.map((v, i) => /* @__PURE__ */ React2.createElement(
1463
+ TreeRow,
1464
+ {
1465
+ key: v.node.path,
1466
+ node: v.node,
1467
+ depth: v.depth,
1468
+ expanded: expandedDirs.has(v.node.path),
1469
+ excluded,
1470
+ isFocused: focus === "tree" && treeWindowStart + i === treeIdx
1471
+ }
1472
+ ))
1473
+ ), /* @__PURE__ */ React2.createElement(
1474
+ Box2,
1475
+ {
1476
+ flexDirection: "column",
1477
+ width: "50%",
1478
+ paddingLeft: 1,
1479
+ borderStyle: "round",
1480
+ borderColor: focus === "diff" ? "cyan" : "gray"
1481
+ },
1482
+ /* @__PURE__ */ React2.createElement(Text2, { bold: true, color: focus === "diff" ? "cyan" : void 0 }, "Diff vs .baliseignore"),
1483
+ diff.length === 0 ? /* @__PURE__ */ React2.createElement(Text2, { dimColor: true }, "(no changes)") : diffWindow.map((d, i) => /* @__PURE__ */ React2.createElement(
1484
+ DiffRow,
1485
+ {
1486
+ key: d.pattern,
1487
+ entry: d,
1488
+ isFocused: focus === "diff" && diffWindowStart + i === safeDiffIdx
1489
+ }
1490
+ ))
1491
+ )), /* @__PURE__ */ React2.createElement(Box2, { marginTop: 1 }, /* @__PURE__ */ React2.createElement(Text2, { dimColor: true }, stats.added > 0 || stats.removed > 0 ? `${stats.added} additions, ${stats.removed} removals` : "(no changes vs current .baliseignore)")));
1492
+ }
1493
+ function TreeRow(props) {
1494
+ const { node, depth, expanded, excluded, isFocused } = props;
1495
+ const indent = " ".repeat(Math.max(depth, 0));
1496
+ let label;
1497
+ let dim = false;
1498
+ if (node.isBan) {
1499
+ label = `\u{1F512} ${node.name} [ban]`;
1500
+ dim = true;
1501
+ } else if (node.isDir) {
1502
+ const allIncluded = dirAllIncluded(node, excluded);
1503
+ const arrow = expanded ? "\u25BE" : "\u25B8";
1504
+ const box = allIncluded ? "\u2611" : "\u2610";
1505
+ label = `${arrow} ${box} ${node.name}/`;
1506
+ } else {
1507
+ const box = excluded.has(node.path) ? "\u2610" : "\u2611";
1508
+ const loc = node.locCount > 0 ? ` (${node.locCount} LoC)` : "";
1509
+ const flags = [];
1510
+ if (node.isLarge) flags.push("large");
1511
+ if (node.isDefault) flags.push("default");
1512
+ const flagStr = flags.length > 0 ? ` [${flags.join(",")}]` : "";
1513
+ label = `${box} ${node.name}${loc}${flagStr}`;
1514
+ }
1515
+ return /* @__PURE__ */ React2.createElement(Text2, { color: isFocused ? "cyan" : void 0, dimColor: dim }, isFocused ? "\u25B8 " : " ", indent, label);
1516
+ }
1517
+ function DiffRow(props) {
1518
+ const { entry, isFocused } = props;
1519
+ const sigil = entry.kind === "added" ? "+" : entry.kind === "removed" ? "-" : " ";
1520
+ const color = isFocused ? "cyan" : entry.kind === "added" ? "green" : entry.kind === "removed" ? "red" : void 0;
1521
+ return /* @__PURE__ */ React2.createElement(Text2, { color }, isFocused ? "\u25B8 " : " ", sigil, " ", entry.pattern);
1522
+ }
1523
+ function ConfirmModal(props) {
1524
+ const { stats, excludedSize } = props;
1525
+ return /* @__PURE__ */ React2.createElement(
1526
+ Box2,
1527
+ {
1528
+ flexDirection: "column",
1529
+ padding: 1,
1530
+ borderStyle: "double",
1531
+ borderColor: "cyan"
1532
+ },
1533
+ /* @__PURE__ */ React2.createElement(Text2, { bold: true }, "Save changes to .baliseignore?"),
1534
+ /* @__PURE__ */ React2.createElement(Box2, { marginTop: 1, flexDirection: "row", gap: 2 }, /* @__PURE__ */ React2.createElement(Text2, { color: "green" }, "+", stats.added, " added"), /* @__PURE__ */ React2.createElement(Text2, { color: "red" }, "-", stats.removed, " removed"), /* @__PURE__ */ React2.createElement(Text2, { dimColor: true }, stats.unchanged, " unchanged")),
1535
+ /* @__PURE__ */ React2.createElement(Box2, { marginTop: 1 }, /* @__PURE__ */ React2.createElement(Text2, { dimColor: true }, excludedSize, " total patterns in resulting file")),
1536
+ /* @__PURE__ */ React2.createElement(Box2, { marginTop: 1 }, /* @__PURE__ */ React2.createElement(Text2, null, "[y] Confirm [n] Back to edit [q/Esc] Cancel without saving"))
1537
+ );
1538
+ }
1539
+
995
1540
  // src/commands/init.ts
996
1541
  async function runInit(opts) {
997
1542
  const cwd = opts.cwd ?? process.cwd();
@@ -1015,6 +1560,13 @@ async function runInit(opts) {
1015
1560
  }
1016
1561
  throw err;
1017
1562
  }
1563
+ const existingConfig = await readConfig(cwd);
1564
+ if (existingConfig) {
1565
+ process.stdout.write(
1566
+ `Re-configuring ${existingConfig.repo.owner_login}/${existingConfig.repo.slug}.
1567
+ `
1568
+ );
1569
+ }
1018
1570
  const apiUrl = DEFAULT_API_URL;
1019
1571
  const client = new ApiClient({
1020
1572
  apiUrl,
@@ -1043,13 +1595,14 @@ async function runInit(opts) {
1043
1595
  const sortedRepos = [...repos].sort(
1044
1596
  (a, b) => `${a.owner_login}/${a.slug}`.localeCompare(`${b.owner_login}/${b.slug}`)
1045
1597
  );
1046
- const defaultSlug = path3.basename(cwd).toLowerCase();
1598
+ const defaultSlug = path5.basename(cwd).toLowerCase();
1047
1599
  const result = await new Promise((resolve) => {
1048
1600
  const app = render(
1049
- React2.createElement(InitPicker, {
1601
+ React3.createElement(InitPicker, {
1050
1602
  defaultSlug,
1051
1603
  ownerships,
1052
1604
  repos: sortedRepos,
1605
+ currentRepoId: existingConfig?.repo.id,
1053
1606
  onDone: (r) => {
1054
1607
  resolve(r);
1055
1608
  app.unmount();
@@ -1089,25 +1642,50 @@ async function runInit(opts) {
1089
1642
  await ensureGitignored(cwd);
1090
1643
  process.stdout.write(
1091
1644
  `Linked ${cfg.repo.owner_login}/${cfg.repo.slug} \u2192 .balise/config
1645
+ `
1646
+ );
1647
+ const [scanResult, currentBaliseignore] = await Promise.all([
1648
+ scan(cwd),
1649
+ readBaliseignore(cwd)
1650
+ ]);
1651
+ const treeResult = await new Promise((resolve) => {
1652
+ const app = render(
1653
+ React3.createElement(IgnoreTree, {
1654
+ scan: scanResult,
1655
+ currentBaliseignore,
1656
+ onDone: (r) => {
1657
+ resolve(r);
1658
+ app.unmount();
1659
+ }
1660
+ })
1661
+ );
1662
+ });
1663
+ if (treeResult.action === "cancel") {
1664
+ process.stderr.write(
1665
+ "Ignore configuration cancelled. .baliseignore unchanged.\n"
1666
+ );
1667
+ process.exit(1);
1668
+ }
1669
+ await writeBaliseignore(cwd, treeResult.patterns);
1670
+ process.stdout.write(
1671
+ `Wrote ${treeResult.patterns.length} patterns to .baliseignore
1092
1672
  `
1093
1673
  );
1094
1674
  }
1095
1675
 
1096
1676
  // src/commands/sync.ts
1097
1677
  import readline from "readline/promises";
1098
- import React4 from "react";
1099
- import { render as render2 } from "ink";
1100
1678
 
1101
1679
  // src/logger.ts
1102
- import { promises as fs3 } from "fs";
1103
- import path4 from "path";
1680
+ import { promises as fs5 } from "fs";
1681
+ import path6 from "path";
1104
1682
  import os2 from "os";
1105
1683
  var APP_DIR2 = ".balise";
1106
- var FILENAME2 = "balise.log";
1684
+ var FILENAME3 = "balise.log";
1107
1685
  function logPath() {
1108
1686
  const override = process.env.BALISE_LOG_FILE;
1109
1687
  if (override && override.length > 0) return override;
1110
- return path4.join(os2.homedir(), APP_DIR2, FILENAME2);
1688
+ return path6.join(os2.homedir(), APP_DIR2, FILENAME3);
1111
1689
  }
1112
1690
  function formatError(err) {
1113
1691
  if (err instanceof Error) {
@@ -1128,154 +1706,17 @@ ${extras.join("\n")}` : stack;
1128
1706
  async function logError(context, err) {
1129
1707
  try {
1130
1708
  const p = logPath();
1131
- await fs3.mkdir(path4.dirname(p), { recursive: true, mode: 448 });
1709
+ await fs5.mkdir(path6.dirname(p), { recursive: true, mode: 448 });
1132
1710
  const ts = (/* @__PURE__ */ new Date()).toISOString();
1133
1711
  const line = `[${ts}] ERROR ${context}
1134
1712
  ${formatError(err)}
1135
1713
 
1136
1714
  `;
1137
- await fs3.appendFile(p, line, { mode: 384 });
1715
+ await fs5.appendFile(p, line, { mode: 384 });
1138
1716
  } catch {
1139
1717
  }
1140
1718
  }
1141
1719
 
1142
- // src/ui/SyncProgress.tsx
1143
- import React3, { useEffect, useState as useState2 } from "react";
1144
- import { Box as Box2, Text as Text2, useApp as useApp2 } from "ink";
1145
- import { Spinner } from "@inkjs/ui";
1146
- var QUEUED_MESSAGES = [
1147
- "Waiting for an agent willing to accept the job\u2026",
1148
- "Your request is in line. An agent will be assigned once one stops pretending to be busy.",
1149
- 'Looking for an available agent. Most are currently "in a meeting."',
1150
- "Queued. An agent will pick this up as soon as they finish rereading the spec.",
1151
- "Your task is waiting for an agent with the right vibes.",
1152
- "Negotiating with an agent. They want a raise.",
1153
- "All agents are currently on strike. Sending in a scab.",
1154
- "Waiting for an agent to finish their coffee \u2615",
1155
- "An agent saw your task and walked away slowly. Finding another one.",
1156
- "Your task is being passed around like a hot potato. Someone will catch it eventually.",
1157
- "Agents are currently arguing about who has to do this one.",
1158
- "Waiting for an agent brave enough to open your repo.",
1159
- "Your task is sitting in the agent break room. Someone will notice it soon.",
1160
- "An agent was assigned, but they ghosted. Recruiting a replacement.",
1161
- "Paging an agent. Please hold while we bribe one.",
1162
- "Queued. Waiting for an agent with enough context window to care.",
1163
- "An agent is spinning up. They're doing their stretches.",
1164
- "Looking for an agent whose system prompt allows this.",
1165
- "Waiting for a worker. Their last task traumatized them.",
1166
- "Your task is in queue. Agents are busy arguing about tabs vs spaces.",
1167
- "Finding an agent\u2026",
1168
- "Found one. They said no.",
1169
- "Finding another agent\u2026",
1170
- "This one looks promising.",
1171
- "Negotiating\u2026"
1172
- ];
1173
- var RUNNING_MESSAGES = [
1174
- "An agent is on it. Try not to watch.",
1175
- "Your task has an owner. They seem focused.",
1176
- "An agent accepted the job. Surprisingly.",
1177
- "Working. The agent asked not to be disturbed.",
1178
- "An agent is handling it. They'll let us know if they need anything.",
1179
- "Progress is happening. Allegedly.",
1180
- "An agent rolled up their sleeves. Mostly for show.",
1181
- "Hard at work. The agent has opened seventeen browser tabs.",
1182
- "Your agent is locked in. Do not make eye contact.",
1183
- "An agent is typing furiously. Some of it might be relevant.",
1184
- "Working. The agent has entered the zone. And also a Wikipedia rabbit hole.",
1185
- "An agent is deep in thought. Or buffering. Hard to tell.",
1186
- "Your task is being handled. The agent is muttering to themselves.",
1187
- "Cooking. \u{1F9D1}\u200D\u{1F373}",
1188
- "The agent is working. They've asked for snacks.",
1189
- "An agent is doing the thing. Please clap.",
1190
- "Agent is crunching tokens.",
1191
- "Working. The agent is reasoning about your reasoning.",
1192
- "An agent is in a tool-use loop. Going well so far.",
1193
- "Thinking hard. The agent just discovered your codebase has opinions.",
1194
- "Working. The agent is negotiating with your linter.",
1195
- "An agent is running. They promise it's not an infinite loop.",
1196
- "The agent is writing, deleting, and rewriting. Classic.",
1197
- "An agent picked it up.",
1198
- "They're reading the task\u2026",
1199
- "Looks like they have a plan.",
1200
- "Executing\u2026",
1201
- "Still going. Confidently.",
1202
- "Almost there. (They always say that.)"
1203
- ];
1204
- function pickInitialIndex(len) {
1205
- return Math.floor(Math.random() * len);
1206
- }
1207
- function SyncProgress(props) {
1208
- const { exit } = useApp2();
1209
- const [status, setStatus] = useState2(null);
1210
- const [error, setError] = useState2(null);
1211
- const [startedAt] = useState2(() => Date.now());
1212
- const [queuedIdx, setQueuedIdx] = useState2(
1213
- () => pickInitialIndex(QUEUED_MESSAGES.length)
1214
- );
1215
- const [runningIdx, setRunningIdx] = useState2(
1216
- () => pickInitialIndex(RUNNING_MESSAGES.length)
1217
- );
1218
- useEffect(() => {
1219
- let cancelled = false;
1220
- const tick = async () => {
1221
- try {
1222
- const s = await props.client.getJson(
1223
- `/v1/syncs/${props.syncId}`
1224
- );
1225
- if (cancelled) return;
1226
- setStatus(s);
1227
- if (s.status === "done") {
1228
- props.onDone(true);
1229
- exit();
1230
- return;
1231
- }
1232
- if (s.status === "failed") {
1233
- props.onDone(false);
1234
- exit();
1235
- return;
1236
- }
1237
- } catch (err) {
1238
- if (cancelled) return;
1239
- setError(err.message);
1240
- props.onDone(false);
1241
- exit();
1242
- return;
1243
- }
1244
- setTimeout(tick, props.pollIntervalMs ?? 1e3);
1245
- };
1246
- void tick();
1247
- return () => {
1248
- cancelled = true;
1249
- };
1250
- }, []);
1251
- useEffect(() => {
1252
- const period = props.messageRotationMs ?? 1e4;
1253
- const h = setInterval(() => {
1254
- setQueuedIdx((i) => (i + 1) % QUEUED_MESSAGES.length);
1255
- setRunningIdx((i) => (i + 1) % RUNNING_MESSAGES.length);
1256
- }, period);
1257
- return () => clearInterval(h);
1258
- }, [props.messageRotationMs]);
1259
- if (error) {
1260
- return /* @__PURE__ */ React3.createElement(Text2, { color: "red" }, "\u2717 Failed");
1261
- }
1262
- if (!status) {
1263
- return /* @__PURE__ */ React3.createElement(Spinner, { label: "Getting things ready\u2026" });
1264
- }
1265
- if (status.status === "done") {
1266
- return /* @__PURE__ */ React3.createElement(Text2, { color: "green" }, "\u2713 Done");
1267
- }
1268
- if (status.status === "failed") {
1269
- return /* @__PURE__ */ React3.createElement(Text2, { color: "red" }, "\u2717 Failed");
1270
- }
1271
- const message = status.status === "queued" ? QUEUED_MESSAGES[queuedIdx] : RUNNING_MESSAGES[runningIdx];
1272
- const { files_processed, files_total, nodes_pushed } = status.progress;
1273
- const isRunning = status.status === "running";
1274
- const showFiles = isRunning && files_total > 0;
1275
- const showNodes = isRunning && nodes_pushed > 0;
1276
- return /* @__PURE__ */ React3.createElement(Box2, { flexDirection: "column" }, /* @__PURE__ */ React3.createElement(Spinner, { label: message }), showFiles || showNodes ? /* @__PURE__ */ React3.createElement(Text2, { dimColor: true }, " ", showFiles ? `${files_processed}/${files_total} files` : null, showFiles && showNodes ? " \xB7 " : null, showNodes ? `${nodes_pushed} concepts` : null) : null);
1277
- }
1278
-
1279
1720
  // src/commands/sync.ts
1280
1721
  function buildSyncQuery(opts) {
1281
1722
  const q = { commit_sha: opts.commitSha };
@@ -1321,6 +1762,9 @@ function formatDryRunSummary(body) {
1321
1762
  const fromDiff = body.would_be_cold_start ? "cold-start (no prior tag)" : `from-commit=${body.from_commit ?? "?"}` + (body.from_commit_distance !== null ? ` distance=${body.from_commit_distance}` : "");
1322
1763
  return `Sync would use: base=${body.base_dolt_ref} (${body.base_kind}) / ${fromDiff}`;
1323
1764
  }
1765
+ function formatAcceptedSummary(body) {
1766
+ return `Sync queued. Track it here: ${body.web_url}`;
1767
+ }
1324
1768
  async function runSync(opts) {
1325
1769
  try {
1326
1770
  await runSyncInner(opts);
@@ -1430,20 +1874,8 @@ async function runSyncInner(opts) {
1430
1874
  if (outcome.kind !== "accepted") {
1431
1875
  process.exit(1);
1432
1876
  }
1433
- const accepted = outcome.body;
1434
- const result = await new Promise((resolve) => {
1435
- const app = render2(
1436
- React4.createElement(SyncProgress, {
1437
- client,
1438
- syncId: accepted.sync_id,
1439
- onDone: (ok) => {
1440
- resolve(ok);
1441
- app.unmount();
1442
- }
1443
- })
1444
- );
1445
- });
1446
- process.exit(result ? 0 : 1);
1877
+ process.stdout.write(formatAcceptedSummary(outcome.body) + "\n");
1878
+ process.exit(0);
1447
1879
  }
1448
1880
  async function uploadOnce(client, owner, slug, stream, query) {
1449
1881
  const raw = await client.uploadBundleRaw(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@balise.dev/cli",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "description": "Balise CLI — push codebase to Balise backend for spec extraction.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,6 +27,7 @@
27
27
  "ini": "^5.0.0",
28
28
  "ink": "^5.0.1",
29
29
  "open": "^10.1.0",
30
+ "picomatch": "^4.0.2",
30
31
  "react": "^18.3.1",
31
32
  "tar": "^7.4.3",
32
33
  "undici": "^6.19.8"
@@ -34,8 +35,10 @@
34
35
  "devDependencies": {
35
36
  "@types/ini": "^4.1.1",
36
37
  "@types/node": "^20.14.0",
38
+ "@types/picomatch": "^3.0.2",
37
39
  "@types/react": "^18.3.3",
38
40
  "@types/tar": "^6.1.13",
41
+ "ink-testing-library": "^4.0.0",
39
42
  "tsup": "^8.2.4",
40
43
  "typescript": "^5.5.4",
41
44
  "vitest": "^2.0.5"