@kyubiware/commit-mint 0.5.5 → 0.6.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/cli.mjs CHANGED
@@ -28,7 +28,7 @@ var __exportAll = (all, no_symbols) => {
28
28
  //#region package.json
29
29
  var package_default = {
30
30
  name: "@kyubiware/commit-mint",
31
- version: "0.5.5",
31
+ version: "0.6.0",
32
32
  description: "🌿 A commit tool that actually handles hook failures",
33
33
  type: "module",
34
34
  bin: { "cmint": "./dist/cli.mjs" },
@@ -47,7 +47,8 @@ var package_default = {
47
47
  "release:patch": "bash scripts/release.sh patch",
48
48
  "release:minor": "bash scripts/release.sh minor",
49
49
  "release:major": "bash scripts/release.sh major",
50
- "prepublishOnly": "npm run build"
50
+ "prepublishOnly": "npm run build",
51
+ "publish:cmint": "cd packages/cmint && npm publish"
51
52
  },
52
53
  keywords: [
53
54
  "git",
@@ -1294,6 +1295,224 @@ function validateGroups(groups, allFiles) {
1294
1295
  return validated;
1295
1296
  }
1296
1297
  //#endregion
1298
+ //#region src/services/clipboard.ts
1299
+ /** Milliseconds to wait after stdin closes for quick exit failures. */
1300
+ const GRACE_PERIOD_MS = 150;
1301
+ async function copyToClipboard(content) {
1302
+ for (const [cmd, args] of [
1303
+ ["wl-copy", []],
1304
+ ["xclip", ["-selection", "clipboard"]],
1305
+ ["xsel", ["--clipboard", "--input"]],
1306
+ ["pbcopy", []]
1307
+ ]) try {
1308
+ if (await tryCopy(cmd, args, content)) return true;
1309
+ } catch {}
1310
+ return false;
1311
+ }
1312
+ /**
1313
+ * Try to copy content using a single clipboard tool.
1314
+ *
1315
+ * Waits a short grace period after stdin closes to detect quick failures
1316
+ * (e.g. wl-copy on non-Wayland, missing display). If the tool survives
1317
+ * the grace period, assumes success — clipboard tools like xclip and
1318
+ * wl-copy hold the selection open indefinitely, so we can't wait for exit.
1319
+ */
1320
+ /**
1321
+ * Evaluate clipboard tool status after the grace period.
1322
+ * If the child already exited, report based on exit code.
1323
+ * If still alive, assume success (clipboard tools hold selection open).
1324
+ */
1325
+ function handleGracePeriod(settled, exitCode, stderrChunks, child, done) {
1326
+ if (settled) return;
1327
+ if (exitCode !== null) {
1328
+ if (exitCode === 0) done(true, "exited 0");
1329
+ else {
1330
+ const stderr = Buffer.concat(stderrChunks).toString().trim();
1331
+ done(false, `exit ${exitCode}${stderr ? `: ${stderr}` : ""}`);
1332
+ }
1333
+ return;
1334
+ }
1335
+ child.unref();
1336
+ done(true);
1337
+ }
1338
+ function tryCopy(cmd, args, content) {
1339
+ return new Promise((resolve) => {
1340
+ debug("clipboard: trying %s", cmd);
1341
+ const child = spawn(cmd, args, { stdio: [
1342
+ "pipe",
1343
+ "ignore",
1344
+ "pipe"
1345
+ ] });
1346
+ let settled = false;
1347
+ const stderrChunks = [];
1348
+ child.stderr?.on("data", (chunk) => {
1349
+ stderrChunks.push(chunk);
1350
+ });
1351
+ const done = (result, reason) => {
1352
+ if (settled) return;
1353
+ settled = true;
1354
+ debug("clipboard: %s %s%s", cmd, result ? "ok" : "failed", reason ? ` (${reason})` : "");
1355
+ resolve(result);
1356
+ };
1357
+ child.on("error", (err) => {
1358
+ done(false, err.message);
1359
+ });
1360
+ let exitCode = null;
1361
+ child.on("exit", (code) => {
1362
+ exitCode = code;
1363
+ });
1364
+ child.stdin.write(content, (err) => {
1365
+ if (err) {
1366
+ done(false, "stdin write error");
1367
+ return;
1368
+ }
1369
+ child.stdin.end(() => {
1370
+ setTimeout(() => handleGracePeriod(settled, exitCode, stderrChunks, child, done), GRACE_PERIOD_MS);
1371
+ });
1372
+ });
1373
+ });
1374
+ }
1375
+ //#endregion
1376
+ //#region src/ui/check-failure-menu.ts
1377
+ const MAX_TSC_DIAGNOSTICS = 3;
1378
+ const MAX_ESLINT_DIAGNOSTICS = 3;
1379
+ const MAX_SUMMARY_LINE_LENGTH = 120;
1380
+ const TSC_DIAGNOSTIC = /^(.+?\.(?:ts|tsx|mts|cts|js|jsx|mjs|cjs))\((\d+),(\d+)\):\s+error\s+(TS\d+):\s+(.+)$/;
1381
+ const ESLINT_ERROR_LINE = /^\s*(\d+):(\d+)\s+(error|warning)\s+(.+)\s{2,}(\S+)\s*$/;
1382
+ function formatCheckFailureSummary(errors) {
1383
+ if (errors.length === 0) return "No check error details were parsed. View full output for details.";
1384
+ return errors.map((error) => formatCheckErrorSummary(error)).join("\n");
1385
+ }
1386
+ function formatCheckErrorSummary(error) {
1387
+ if (error.tool === "tsc") {
1388
+ const diagnostics = extractTscDiagnostics(error.raw || error.message);
1389
+ if (diagnostics.length > 0) return formatTscSummary(diagnostics);
1390
+ }
1391
+ if (error.tool === "eslint") {
1392
+ const diagnostics = extractEslintDiagnostics(error.raw || error.message);
1393
+ if (diagnostics.length > 0) return formatEslintSummary(diagnostics);
1394
+ }
1395
+ const message = firstMeaningfulLine(error.message || error.raw);
1396
+ return ` ${red("•")} [${error.tool}] ${truncate(message, MAX_SUMMARY_LINE_LENGTH)}`;
1397
+ }
1398
+ function extractTscDiagnostics(raw) {
1399
+ return raw.split("\n").map((line) => line.trim()).map((line) => {
1400
+ const match = TSC_DIAGNOSTIC.exec(line);
1401
+ if (!match) return null;
1402
+ return {
1403
+ file: match[1] ?? "",
1404
+ line: match[2] ?? "",
1405
+ column: match[3] ?? "",
1406
+ code: match[4] ?? "",
1407
+ message: match[5] ?? ""
1408
+ };
1409
+ }).filter((diagnostic) => diagnostic !== null);
1410
+ }
1411
+ function formatTscSummary(diagnostics) {
1412
+ const visible = diagnostics.slice(0, MAX_TSC_DIAGNOSTICS);
1413
+ const hidden = diagnostics.length - visible.length;
1414
+ const lines = [` ${red("•")} [tsc] ${diagnostics.length} TypeScript error${diagnostics.length !== 1 ? "s" : ""}`, ...visible.map((diagnostic) => `${diagnostic.file}:${diagnostic.line}:${diagnostic.column} — error ${diagnostic.code}: ${truncate(diagnostic.message, MAX_SUMMARY_LINE_LENGTH)}`)];
1415
+ if (hidden > 0) lines.push(dim(` +${hidden} more TypeScript error${hidden !== 1 ? "s" : ""}. View full output for details.`));
1416
+ return lines.join("\n");
1417
+ }
1418
+ function extractEslintDiagnostics(raw) {
1419
+ const diagnostics = [];
1420
+ const lines = raw.split("\n");
1421
+ let currentFile = "";
1422
+ for (const line of lines) {
1423
+ if (!/^\s/.test(line) && line.includes("/") && !ESLINT_ERROR_LINE.test(line)) {
1424
+ currentFile = line.trim();
1425
+ continue;
1426
+ }
1427
+ const match = ESLINT_ERROR_LINE.exec(line);
1428
+ if (match) diagnostics.push({
1429
+ file: currentFile || "unknown",
1430
+ line: match[1] ?? "",
1431
+ column: match[2] ?? "",
1432
+ severity: match[3] ?? "",
1433
+ message: (match[4] ?? "").trim(),
1434
+ rule: match[5] ?? ""
1435
+ });
1436
+ }
1437
+ return diagnostics;
1438
+ }
1439
+ function formatEslintSummary(diagnostics) {
1440
+ const visible = diagnostics.slice(0, MAX_ESLINT_DIAGNOSTICS);
1441
+ const hidden = diagnostics.length - visible.length;
1442
+ const count = diagnostics.length;
1443
+ const noun = count === 1 ? "problem" : "problems";
1444
+ const lines = [` ${red("•")} [eslint] ${count} ESLint ${noun}`, ...visible.map((diagnostic) => `${diagnostic.file}:${diagnostic.line}:${diagnostic.column} ${diagnostic.severity} ${diagnostic.rule} — ${truncate(diagnostic.message, MAX_SUMMARY_LINE_LENGTH)}`)];
1445
+ if (hidden > 0) lines.push(dim(` +${hidden} more ESLint ${hidden === 1 ? "problem" : "problems"}. View full output for details.`));
1446
+ return lines.join("\n");
1447
+ }
1448
+ function firstMeaningfulLine(message) {
1449
+ return message.split("\n").map((l) => l.trim()).find((l) => l.length > 0 && !l.startsWith(">") && !l.startsWith("ELIFECYCLE")) ?? message;
1450
+ }
1451
+ function truncate(message, maxLength) {
1452
+ const collapsed = message.replace(/\s+/g, " ").trim();
1453
+ if (collapsed.length <= maxLength) return collapsed;
1454
+ return `${collapsed.slice(0, Math.max(0, maxLength - 1))}…`;
1455
+ }
1456
+ async function showCheckFailureMenu(errors, rawStderr, onRetry) {
1457
+ debug("showCheckFailureMenu: %d errors", errors.length);
1458
+ let clipboardCopied = false;
1459
+ p.note(formatCheckFailureSummary(errors), red("Pre-commit check failed"));
1460
+ while (true) {
1461
+ const choice = await p.select({
1462
+ message: "What do you want to do?",
1463
+ options: [
1464
+ {
1465
+ label: clipboardCopied ? `${green("✓")} Copy error report to clipboard` : "Copy error report to clipboard",
1466
+ value: "copy"
1467
+ },
1468
+ {
1469
+ label: "View full error output",
1470
+ value: "view",
1471
+ hint: "Show the raw stderr from checks"
1472
+ },
1473
+ {
1474
+ label: "Retry checks",
1475
+ value: "retry",
1476
+ hint: "Re-run checks after fixing errors"
1477
+ },
1478
+ {
1479
+ label: "Skip checks and commit",
1480
+ value: "skip"
1481
+ },
1482
+ {
1483
+ label: "Cancel",
1484
+ value: "cancel"
1485
+ }
1486
+ ]
1487
+ });
1488
+ if (p.isCancel(choice)) {
1489
+ debug("showCheckFailureMenu: user cancelled");
1490
+ return "cancelled";
1491
+ }
1492
+ debug("showCheckFailureMenu: user chose %s", choice);
1493
+ switch (choice) {
1494
+ case "copy":
1495
+ if (await copyToClipboard(rawStderr)) {
1496
+ clipboardCopied = true;
1497
+ p.log.step(green("Copied to clipboard."));
1498
+ } else p.log.warn(red("No clipboard tool found. Install xclip, wl-copy, or xsel."));
1499
+ continue;
1500
+ case "view":
1501
+ p.note(rawStderr.trim() || "(no raw output)", "Full error output");
1502
+ continue;
1503
+ case "retry":
1504
+ if (onRetry) return "retried";
1505
+ return "retried";
1506
+ case "skip":
1507
+ p.log.info("Skipping checks and proceeding with commit...");
1508
+ return "skipped";
1509
+ case "cancel":
1510
+ p.outro(dim("Cancelled."));
1511
+ return "cancelled";
1512
+ }
1513
+ }
1514
+ }
1515
+ //#endregion
1297
1516
  //#region src/ui/grouping.ts
1298
1517
  async function showGroupingConfirmation(groups, excluded) {
1299
1518
  debug("showGroupingConfirmation: %d groups, %d excluded", groups.length, excluded.length);
@@ -1356,126 +1575,7 @@ function showGroupedFiles(groups, changedFiles) {
1356
1575
  p.note(lines.join("\n"), "Commit groups");
1357
1576
  }
1358
1577
  //#endregion
1359
- //#region src/services/clipboard.ts
1360
- async function copyToClipboard(content) {
1361
- for (const [cmd, args] of [
1362
- ["wl-copy", []],
1363
- ["xclip", ["-selection", "clipboard"]],
1364
- ["xsel", ["--clipboard", "--input"]],
1365
- ["pbcopy", []]
1366
- ]) try {
1367
- if (await new Promise((resolve) => {
1368
- const child = spawn(cmd, args, { stdio: [
1369
- "pipe",
1370
- "ignore",
1371
- "ignore"
1372
- ] });
1373
- let settled = false;
1374
- const done = (result) => {
1375
- if (settled) return;
1376
- settled = true;
1377
- resolve(result);
1378
- };
1379
- child.on("error", () => done(false));
1380
- child.on("exit", (code) => {
1381
- if (code !== 0) done(false);
1382
- });
1383
- child.stdin.write(content, (err) => {
1384
- if (err) {
1385
- done(false);
1386
- return;
1387
- }
1388
- child.stdin.end(() => {
1389
- child.unref();
1390
- done(true);
1391
- });
1392
- });
1393
- })) return true;
1394
- } catch {}
1395
- return false;
1396
- }
1397
- //#endregion
1398
- //#region src/ui/menu.ts
1399
- async function showStagingMenu(files, hasChecks) {
1400
- debug("showStagingMenu: %d files", files.length);
1401
- const statusLabel = (status) => {
1402
- switch (status) {
1403
- case "M": return yellow("M");
1404
- case "A": return green("A");
1405
- case "D": return red("D");
1406
- case "?":
1407
- case "??": return cyan("?");
1408
- default: return dim(status);
1409
- }
1410
- };
1411
- const sorted = [...files].sort((a, b) => {
1412
- if (a.staged !== b.staged) return a.staged ? -1 : 1;
1413
- return a.path.localeCompare(b.path);
1414
- });
1415
- const stagedFiles = sorted.filter((f) => f.staged);
1416
- const unstagedFiles = sorted.filter((f) => !f.staged);
1417
- const lines = [];
1418
- if (stagedFiles.length > 0) lines.push(green(bold("Staged:")), ...stagedFiles.map((f) => ` ${statusLabel(f.status)} ${f.path}`));
1419
- if (unstagedFiles.length > 0) {
1420
- if (lines.length > 0) lines.push("");
1421
- lines.push(yellow(bold("Changed:")), ...unstagedFiles.map((f) => ` ${statusLabel(f.status)} ${f.path}`));
1422
- }
1423
- p.note(lines.join("\n"), `${files.length} file${files.length !== 1 ? "s" : ""}`);
1424
- const choice = await p.select({
1425
- message: "Stage files for commit:",
1426
- options: [
1427
- {
1428
- label: "Auto-group into commits",
1429
- value: "autogroup",
1430
- hint: "LLM groups files into logical commits"
1431
- },
1432
- ...stagedFiles.length > 0 ? [{
1433
- label: "Commit staged files only",
1434
- value: "staged",
1435
- hint: `${stagedFiles.length} file${stagedFiles.length !== 1 ? "s" : ""} already staged`
1436
- }] : [],
1437
- {
1438
- label: "Stage all files",
1439
- value: "all",
1440
- hint: `${files.length} file${files.length !== 1 ? "s" : ""}`
1441
- },
1442
- ...hasChecks ? [{
1443
- label: "Run checks",
1444
- value: "checks",
1445
- hint: "Pre-flight checks from cmint config"
1446
- }] : [],
1447
- {
1448
- label: "Select files...",
1449
- value: "select"
1450
- },
1451
- {
1452
- label: "Cancel",
1453
- value: "cancel"
1454
- }
1455
- ]
1456
- });
1457
- if (p.isCancel(choice) || choice === "cancel") return null;
1458
- if (choice === "autogroup") return "autogroup";
1459
- if (choice === "checks") return "checks";
1460
- if (choice === "staged") return "staged";
1461
- if (choice === "all") return {
1462
- files: files.map((f) => f.path),
1463
- all: true
1464
- };
1465
- const selected = await p.multiselect({
1466
- message: "Select files to stage:",
1467
- options: sorted.map((f) => ({
1468
- label: `${statusLabel(f.status)} ${f.path}`,
1469
- value: f.path
1470
- })),
1471
- required: true
1472
- });
1473
- if (p.isCancel(selected)) return null;
1474
- return {
1475
- files: selected,
1476
- all: false
1477
- };
1478
- }
1578
+ //#region src/ui/recovery-menu.ts
1479
1579
  async function showRecoveryMenu(errors, onRetry, onSkipHooks, onRestage, message, rawStderr) {
1480
1580
  debug("showRecoveryMenu: %d errors", errors.length);
1481
1581
  let clipboardCopied = false;
@@ -1577,65 +1677,6 @@ async function showRecoveryMenu(errors, onRetry, onSkipHooks, onRestage, message
1577
1677
  }
1578
1678
  }
1579
1679
  }
1580
- async function showCheckFailureMenu(errors, rawStderr, onRetry) {
1581
- debug("showCheckFailureMenu: %d errors", errors.length);
1582
- let clipboardCopied = false;
1583
- p.note(errors.map((e) => ` ${red("•")} [${e.tool}] ${e.message}`).join("\n"), red("Pre-commit check failed"));
1584
- while (true) {
1585
- const choice = await p.select({
1586
- message: "What do you want to do?",
1587
- options: [
1588
- {
1589
- label: clipboardCopied ? `${green("✓")} Copy error report to clipboard` : "Copy error report to clipboard",
1590
- value: "copy"
1591
- },
1592
- {
1593
- label: "View full error output",
1594
- value: "view",
1595
- hint: "Show the raw stderr from checks"
1596
- },
1597
- {
1598
- label: "Retry checks",
1599
- value: "retry",
1600
- hint: "Re-run checks after fixing errors"
1601
- },
1602
- {
1603
- label: "Skip checks and commit",
1604
- value: "skip"
1605
- },
1606
- {
1607
- label: "Cancel",
1608
- value: "cancel"
1609
- }
1610
- ]
1611
- });
1612
- if (p.isCancel(choice)) {
1613
- debug("showCheckFailureMenu: user cancelled");
1614
- return "cancelled";
1615
- }
1616
- debug("showCheckFailureMenu: user chose %s", choice);
1617
- switch (choice) {
1618
- case "copy":
1619
- if (await copyToClipboard(rawStderr)) {
1620
- clipboardCopied = true;
1621
- p.log.step(green("Copied to clipboard."));
1622
- } else p.log.warn(red("No clipboard tool found. Install xclip, wl-copy, or xsel."));
1623
- continue;
1624
- case "view":
1625
- p.note(rawStderr.trim() || "(no raw output)", "Full error output");
1626
- continue;
1627
- case "retry":
1628
- if (onRetry) return "retried";
1629
- return "retried";
1630
- case "skip":
1631
- p.log.info("Skipping checks and proceeding with commit...");
1632
- return "skipped";
1633
- case "cancel":
1634
- p.outro(dim("Cancelled."));
1635
- return "cancelled";
1636
- }
1637
- }
1638
- }
1639
1680
  //#endregion
1640
1681
  //#region src/commands/auto-group.ts
1641
1682
  async function runAutoGroupFlow(changedFiles, flags) {
@@ -1869,6 +1910,281 @@ async function handleRetry() {
1869
1910
  else process.exit(1);
1870
1911
  }
1871
1912
  //#endregion
1913
+ //#region src/commands/setup.ts
1914
+ /** Marker files for each tool. First match wins per tool. */
1915
+ const TOOL_MARKERS = {
1916
+ biome: ["biome.json", "biome.jsonc"],
1917
+ eslint: [
1918
+ "eslint.config.js",
1919
+ "eslint.config.mjs",
1920
+ "eslint.config.ts",
1921
+ "eslint.config.cjs",
1922
+ ".eslintrc.js",
1923
+ ".eslintrc.cjs",
1924
+ ".eslintrc.json",
1925
+ ".eslintrc.yml",
1926
+ ".eslintrc.yaml",
1927
+ ".eslintrc"
1928
+ ],
1929
+ typescript: ["tsconfig.json"],
1930
+ vitest: [
1931
+ "vitest.config.js",
1932
+ "vitest.config.mts",
1933
+ "vitest.config.ts",
1934
+ "vitest.config.mjs"
1935
+ ]
1936
+ };
1937
+ /** Indent for generated config — matches biome.json `indentStyle: "tab"`. */
1938
+ const TAB = " ";
1939
+ async function exists(path) {
1940
+ try {
1941
+ await access(path, constants.R_OK);
1942
+ return true;
1943
+ } catch {
1944
+ return false;
1945
+ }
1946
+ }
1947
+ /**
1948
+ * Scan a directory for marker files that indicate which tools the project uses.
1949
+ * Returns a map of tool name to detected status. Order within each tool's list
1950
+ * is priority order (first match wins).
1951
+ */
1952
+ async function detectTools(cwd) {
1953
+ const result = {
1954
+ biome: false,
1955
+ eslint: false,
1956
+ typescript: false,
1957
+ vitest: false
1958
+ };
1959
+ for (const [tool, files] of Object.entries(TOOL_MARKERS)) for (const file of files) if (await exists(join(cwd, file))) {
1960
+ result[tool] = true;
1961
+ debug("setup: detected %s via %s", tool, file);
1962
+ break;
1963
+ }
1964
+ debug("setup: detection result %o", result);
1965
+ return result;
1966
+ }
1967
+ /**
1968
+ * Build the string content of a .cmintrc file from a detection result.
1969
+ * Returns tabs-indented TS/JS object literal with trailing commas. Biome is
1970
+ * preferred when both biome and eslint are present — overlapping globs would
1971
+ * cause both tools to run on the same files, which is wasteful and noisy.
1972
+ */
1973
+ function buildCmintrcContent(tools) {
1974
+ const entries = [];
1975
+ if (tools.biome || tools.eslint) {
1976
+ const cmd = tools.biome ? "biome check --write --no-errors-on-unmatched --error-on-warnings" : "eslint --fix";
1977
+ const ext = tools.biome ? "{js,ts,json}" : "{js,ts}";
1978
+ entries.push(`${TAB}"*.${ext}": "${cmd}",`);
1979
+ }
1980
+ const tsChecks = [];
1981
+ if (tools.typescript) tsChecks.push("tsc --noEmit");
1982
+ if (tools.vitest) tsChecks.push("vitest run --passWithNoTests");
1983
+ if (tsChecks.length > 0) {
1984
+ const body = tsChecks.map((c) => `"${c}"`).join(", ");
1985
+ const fn = tsChecks.length === 1 ? `() => ${body}` : `() => [${body}]`;
1986
+ entries.push(`${TAB}"*.ts": ${fn},`);
1987
+ }
1988
+ if (entries.length === 0) return `export default {\n};\n`;
1989
+ return `export default {\n${entries.join("\n")}\n};\n`;
1990
+ }
1991
+ /** Choose the file extension based on whether the project uses TypeScript. */
1992
+ function pickFileName(tools) {
1993
+ return tools.typescript ? ".cmintrc.ts" : ".cmintrc";
1994
+ }
1995
+ function formatDetection(tools) {
1996
+ return Object.entries(tools).map(([tool, found]) => ` ${found ? green("✓") : dim("✗")} ${tool}`).join("\n");
1997
+ }
1998
+ /**
1999
+ * Interactive setup for `.cmintrc`. Detects biome/eslint/typescript/vitest in
2000
+ * the given directory, previews the generated config, and writes the file
2001
+ * after confirmation. Refuses to overwrite without explicit consent. Defaults
2002
+ * to `process.cwd()` when called from the `cmint config` menu; the preflight
2003
+ * caller passes the repo root explicitly.
2004
+ */
2005
+ async function setupCmintrcCommand(cwd = process.cwd()) {
2006
+ debug("setupCmintrcCommand: starting in %s", cwd);
2007
+ const tools = await detectTools(cwd);
2008
+ p.log.info(`Detected tools in ${bold(cwd)}:`);
2009
+ p.log.message(formatDetection(tools));
2010
+ if (!Object.values(tools).some(Boolean)) p.log.warn("No recognized tools found. Writing an empty config to fill in manually.");
2011
+ else if (tools.biome && tools.eslint) p.log.warn(yellow("Both biome and eslint detected — using biome (remove this line to switch)."));
2012
+ const fileName = pickFileName(tools);
2013
+ const filePath = join(cwd, fileName);
2014
+ if (await exists(filePath)) {
2015
+ const overwrite = await p.confirm({ message: `${fileName} already exists. Overwrite?` });
2016
+ if (p.isCancel(overwrite) || !overwrite) {
2017
+ p.log.info(dim("Cancelled — existing file left untouched."));
2018
+ return;
2019
+ }
2020
+ }
2021
+ const content = buildCmintrcContent(tools);
2022
+ p.log.info(dim(`\nPreview of ${fileName}:`));
2023
+ p.log.message(dim(content));
2024
+ const confirm = await p.confirm({ message: `Write ${fileName}?` });
2025
+ if (p.isCancel(confirm) || !confirm) {
2026
+ p.log.info(dim("Cancelled."));
2027
+ return;
2028
+ }
2029
+ await writeFile(filePath, content, "utf-8");
2030
+ debug("setupCmintrcCommand: wrote %s", filePath);
2031
+ p.log.success(green(`Wrote ${fileName}`));
2032
+ }
2033
+ /** Project-local marker file that suppresses the preflight prompt forever. */
2034
+ const SKIP_SETUP_MARKER = ".cmint-skip-setup";
2035
+ /** True if at least one of biome/eslint/typescript/vitest is present. */
2036
+ function isAutoConfigurable(tools) {
2037
+ return Object.values(tools).some(Boolean);
2038
+ }
2039
+ /** True if the skip-setup marker exists in `cwd`. */
2040
+ async function hasSkipSetupMarker(cwd) {
2041
+ return exists(join(cwd, SKIP_SETUP_MARKER));
2042
+ }
2043
+ /** Write the skip-setup marker to `cwd`. The file is empty by design. */
2044
+ async function writeSkipSetupMarker(cwd) {
2045
+ const filePath = join(cwd, SKIP_SETUP_MARKER);
2046
+ await writeFile(filePath, "", "utf-8");
2047
+ debug("preflight: wrote skip-setup marker to %s", filePath);
2048
+ }
2049
+ /**
2050
+ * One-shot prompt run at the start of `cmint`. Skips silently if the user
2051
+ * already has a `.cmintrc` or has previously opted out (`.cmint-skip-setup`).
2052
+ * If the project is auto-configurable, asks the user whether to run setup
2053
+ * now. Choices: `yes` runs the standard setup flow; `no` proceeds without
2054
+ * setup and re-prompts next time; `never` writes a marker to suppress the
2055
+ * prompt for this project forever.
2056
+ */
2057
+ async function runPreflightSetupPrompt(cwd) {
2058
+ debug("preflight: checking %s", cwd);
2059
+ if (await hasSkipSetupMarker(cwd)) {
2060
+ debug("preflight: skip-setup marker present, skipping prompt");
2061
+ return;
2062
+ }
2063
+ const existingConfig = await detectConfig(cwd);
2064
+ if (existingConfig) {
2065
+ debug("preflight: .cmintrc present at %s, skipping prompt", existingConfig);
2066
+ return;
2067
+ }
2068
+ if (!isAutoConfigurable(await detectTools(cwd))) {
2069
+ debug("preflight: project not auto-configurable, skipping prompt");
2070
+ return;
2071
+ }
2072
+ const choice = await p.select({
2073
+ message: "No .cmintrc found. Run setup to create one from detected tools?",
2074
+ options: [
2075
+ {
2076
+ label: "Yes, set up .cmintrc",
2077
+ value: "yes"
2078
+ },
2079
+ {
2080
+ label: "No, skip for now",
2081
+ value: "no"
2082
+ },
2083
+ {
2084
+ label: "No, don't ask again",
2085
+ value: "never"
2086
+ }
2087
+ ]
2088
+ });
2089
+ if (p.isCancel(choice)) {
2090
+ debug("preflight: user cancelled prompt");
2091
+ return;
2092
+ }
2093
+ if (choice === "never") {
2094
+ await writeSkipSetupMarker(cwd);
2095
+ p.log.info(dim(`Won't ask again. Delete ${SKIP_SETUP_MARKER} to re-enable.`));
2096
+ return;
2097
+ }
2098
+ if (choice === "no") {
2099
+ p.log.info(dim("Skipping .cmintrc setup."));
2100
+ return;
2101
+ }
2102
+ debug("preflight: user chose yes, running setup");
2103
+ await setupCmintrcCommand(cwd);
2104
+ }
2105
+ //#endregion
2106
+ //#region src/ui/staging-menu.ts
2107
+ async function showStagingMenu(files, hasChecks) {
2108
+ debug("showStagingMenu: %d files", files.length);
2109
+ const statusLabel = (status) => {
2110
+ switch (status) {
2111
+ case "M": return yellow("M");
2112
+ case "A": return green("A");
2113
+ case "D": return red("D");
2114
+ case "?":
2115
+ case "??": return cyan("?");
2116
+ default: return dim(status);
2117
+ }
2118
+ };
2119
+ const sorted = [...files].sort((a, b) => {
2120
+ if (a.staged !== b.staged) return a.staged ? -1 : 1;
2121
+ return a.path.localeCompare(b.path);
2122
+ });
2123
+ const stagedFiles = sorted.filter((f) => f.staged);
2124
+ const unstagedFiles = sorted.filter((f) => !f.staged);
2125
+ const lines = [];
2126
+ if (stagedFiles.length > 0) lines.push(green(bold("Staged:")), ...stagedFiles.map((f) => ` ${statusLabel(f.status)} ${f.path}`));
2127
+ if (unstagedFiles.length > 0) {
2128
+ if (lines.length > 0) lines.push("");
2129
+ lines.push(yellow(bold("Changed:")), ...unstagedFiles.map((f) => ` ${statusLabel(f.status)} ${f.path}`));
2130
+ }
2131
+ p.note(lines.join("\n"), `${files.length} file${files.length !== 1 ? "s" : ""}`);
2132
+ const choice = await p.select({
2133
+ message: "Stage files for commit:",
2134
+ options: [
2135
+ {
2136
+ label: "Auto-group into commits",
2137
+ value: "autogroup",
2138
+ hint: "LLM groups files into logical commits"
2139
+ },
2140
+ ...stagedFiles.length > 0 ? [{
2141
+ label: "Commit staged files only",
2142
+ value: "staged",
2143
+ hint: `${stagedFiles.length} file${stagedFiles.length !== 1 ? "s" : ""} already staged`
2144
+ }] : [],
2145
+ {
2146
+ label: "Stage all files",
2147
+ value: "all",
2148
+ hint: `${files.length} file${files.length !== 1 ? "s" : ""}`
2149
+ },
2150
+ ...hasChecks ? [{
2151
+ label: "Run checks",
2152
+ value: "checks",
2153
+ hint: "Pre-flight checks from cmint config"
2154
+ }] : [],
2155
+ {
2156
+ label: "Select files...",
2157
+ value: "select"
2158
+ },
2159
+ {
2160
+ label: "Cancel",
2161
+ value: "cancel"
2162
+ }
2163
+ ]
2164
+ });
2165
+ if (p.isCancel(choice) || choice === "cancel") return null;
2166
+ if (choice === "autogroup") return "autogroup";
2167
+ if (choice === "checks") return "checks";
2168
+ if (choice === "staged") return "staged";
2169
+ if (choice === "all") return {
2170
+ files: files.map((f) => f.path),
2171
+ all: true
2172
+ };
2173
+ const selected = await p.multiselect({
2174
+ message: "Select files to stage:",
2175
+ options: sorted.map((f) => ({
2176
+ label: `${statusLabel(f.status)} ${f.path}`,
2177
+ value: f.path
2178
+ })),
2179
+ required: true
2180
+ });
2181
+ if (p.isCancel(selected)) return null;
2182
+ return {
2183
+ files: selected,
2184
+ all: false
2185
+ };
2186
+ }
2187
+ //#endregion
1872
2188
  //#region src/commands/staging.ts
1873
2189
  /** Interactive staging loop for multiple changed files */
1874
2190
  async function handleStaging(changedFiles, flags) {
@@ -1972,6 +2288,8 @@ async function commitCommand(flags) {
1972
2288
  debug("commitCommand called", { flags });
1973
2289
  await assertGitRepo();
1974
2290
  if (flags.retry) return handleRetry();
2291
+ const repoRoot = await getRepoRoot();
2292
+ await runPreflightSetupPrompt(repoRoot);
1975
2293
  intro("🌿 commit-mint");
1976
2294
  const status = await getStatusShort();
1977
2295
  debug("Git status:", status || "(empty)");
@@ -2018,7 +2336,7 @@ async function commitCommand(flags) {
2018
2336
  debug("All staged files are excluded:", diffResult.excludedFiles);
2019
2337
  const message = buildExcludedFilesMessage(diffResult.excludedFiles);
2020
2338
  log.info(diffResult.excludedFiles.map((f) => ` ${f}`).join("\n"));
2021
- await saveCachedCommit(await getRepoRoot(), message);
2339
+ await saveCachedCommit(repoRoot, message);
2022
2340
  s.start("Running pre-commit hooks...");
2023
2341
  const result = await commitWithRecovery(message, s, await getHead());
2024
2342
  if (result === "committed") {
@@ -2077,7 +2395,6 @@ async function commitCommand(flags) {
2077
2395
  return;
2078
2396
  }
2079
2397
  message = reviewed;
2080
- const repoRoot = await getRepoRoot();
2081
2398
  await saveCachedCommit(repoRoot, message);
2082
2399
  debug("Message cached for repo:", repoRoot);
2083
2400
  s.start("Running pre-commit hooks...");
@@ -2261,13 +2578,20 @@ async function configCommand() {
2261
2578
  p.note(buildConfigDisplay(config), "commit-mint config");
2262
2579
  const action = await p.select({
2263
2580
  message: "What would you like to do?",
2264
- options: [{
2265
- label: "Edit settings",
2266
- value: "edit"
2267
- }, {
2268
- label: "Done",
2269
- value: "done"
2270
- }]
2581
+ options: [
2582
+ {
2583
+ label: "Edit settings",
2584
+ value: "edit"
2585
+ },
2586
+ {
2587
+ label: "Setup .cmintrc",
2588
+ value: "setup"
2589
+ },
2590
+ {
2591
+ label: "Done",
2592
+ value: "done"
2593
+ }
2594
+ ]
2271
2595
  });
2272
2596
  if (p.isCancel(action)) {
2273
2597
  debug("configCommand: cancelled at main menu");
@@ -2279,6 +2603,11 @@ async function configCommand() {
2279
2603
  p.outro("Config saved.");
2280
2604
  return;
2281
2605
  }
2606
+ if (action === "setup") {
2607
+ debug("configCommand: starting .cmintrc setup");
2608
+ await setupCmintrcCommand();
2609
+ continue;
2610
+ }
2282
2611
  await editSettingsLoop(config);
2283
2612
  }
2284
2613
  }