@kyubiware/commit-mint 0.8.4 → 0.9.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/README.md CHANGED
@@ -107,17 +107,27 @@ each with its own message. The flow:
107
107
  — the remaining groups are not committed.
108
108
 
109
109
  `cmint` (no `-a`) shows a staging menu for any number of changed files
110
- (including one). The menu is also the only place the `a` hotkey toggles
111
- auto-accept mode, so it always runs:
110
+ (including one). The menu is also the only place the `a` and `c` hotkeys
111
+ toggle persistent modes, so it always runs:
112
112
 
113
113
  ```
114
- What do you want to stage?
115
- Stage all files
116
- Select files...
117
- Auto-group into commits
118
- Run checks
114
+ Stage files for commit:
115
+ Auto-accept: OFF (press `a` to toggle)
116
+ 🛡 Pre-commit checks: ON (press `c` to toggle)
117
+
118
+ Stage all files
119
+ ○ Auto-group into commits
120
+ ○ Run checks
121
+ ...
119
122
  ```
120
123
 
124
+ - **`a` — Auto-accept** — skip the message review step. Persisted in
125
+ `~/.commit-mint` as `auto-accept`.
126
+ - **`c` — Pre-commit checks** — when OFF, skip the user-defined pre-commit
127
+ checks (the `.cmintrc` phase). Persisted in `~/.commit-mint` as
128
+ `run-checks` (default `true`/ON). Same effect as `cmint -N`, but persists
129
+ across runs. Only shown when a `.cmintrc` file exists.
130
+
121
131
  ## Self-update (`cmint update`)
122
132
 
123
133
  `cmint update` checks the npm registry for a newer version and, if one exists,
@@ -228,6 +238,8 @@ and **eslint**. Unrecognized output falls back to a single raw-stderr entry.
228
238
  | `type` | — | Force commit type prefix |
229
239
  | `timeout` | `10000` | AI request timeout in ms |
230
240
  | `proxy` | — | Proxy URL for API requests |
241
+ | `auto-accept` | `false` | `a` hotkey: skip message review step |
242
+ | `run-checks` | `true` | `c` hotkey: run user-defined pre-commit checks |
231
243
  | `--agent` | `false` | Headless JSON-output mode for AI agents |
232
244
 
233
245
  API key lookup checks the env var first, then the INI file.
package/dist/cli.mjs CHANGED
@@ -33,7 +33,7 @@ var __exportAll = (all, no_symbols) => {
33
33
  //#region package.json
34
34
  var package_default = {
35
35
  name: "@kyubiware/commit-mint",
36
- version: "0.8.4",
36
+ version: "0.9.0",
37
37
  description: "🌿 AI-powered git commit tool — auto-group changed files, generate messages, run pre-commit checks",
38
38
  type: "module",
39
39
  bin: { "cmint": "./dist/cli.mjs" },
@@ -1662,6 +1662,42 @@ async function setAutoAccept(enabled) {
1662
1662
  await writeConfig({ "auto-accept": value });
1663
1663
  }
1664
1664
  //#endregion
1665
+ //#region src/services/run-checks.ts
1666
+ /**
1667
+ * Parse a stored `run-checks` INI value into a boolean.
1668
+ *
1669
+ * Polarity is positive: `true` means "run user-defined pre-commit checks"
1670
+ * (the default behavior), `false` means "skip them".
1671
+ *
1672
+ * Accepts true variants ("true", "1", "yes" — case-insensitive) and boolean
1673
+ * values from ini.parse (which converts unquoted `true`/`false` to actual
1674
+ * booleans). Returns **true** for undefined / empty / unknown values so the
1675
+ * default behavior is to run checks — a fresh install with no INI key must
1676
+ * behave identically to `run-checks = true`.
1677
+ */
1678
+ function parseRunChecksValue(value) {
1679
+ if (typeof value === "boolean") return value;
1680
+ if (typeof value !== "string" || !value) return true;
1681
+ return ![
1682
+ "false",
1683
+ "0",
1684
+ "no"
1685
+ ].includes(value.toLowerCase());
1686
+ }
1687
+ /** Read the persisted run-checks preference from `~/.commit-mint`. Defaults to true. */
1688
+ async function getRunChecks() {
1689
+ const raw = (await readConfig())["run-checks"];
1690
+ const enabled = parseRunChecksValue(raw);
1691
+ debug("getRunChecks: raw=%s enabled=%s", raw, enabled);
1692
+ return enabled;
1693
+ }
1694
+ /** Persist the run-checks preference to `~/.commit-mint`. */
1695
+ async function setRunChecks(enabled) {
1696
+ const value = enabled ? "true" : "false";
1697
+ debug("setRunChecks: %s", value);
1698
+ await writeConfig({ "run-checks": value });
1699
+ }
1700
+ //#endregion
1665
1701
  //#region src/ui/grouping.ts
1666
1702
  async function showGroupingConfirmation(groups, excluded) {
1667
1703
  debug("showGroupingConfirmation: %d groups, %d excluded", groups.length, excluded.length);
@@ -1728,7 +1764,9 @@ function showGroupedFiles(groups, changedFiles) {
1728
1764
  /** Milliseconds to wait after stdin closes for quick exit failures. */
1729
1765
  const GRACE_PERIOD_MS = 150;
1730
1766
  async function copyToClipboard(content) {
1767
+ debug("clipboard: copying %d bytes", content.length);
1731
1768
  for (const [cmd, args] of [
1769
+ ["wl-copy", ["--foreground"]],
1732
1770
  ["wl-copy", []],
1733
1771
  ["xclip", ["-selection", "clipboard"]],
1734
1772
  ["xsel", ["--clipboard", "--input"]],
@@ -2258,7 +2296,7 @@ async function runAutoGroupFlow(changedFiles, flags) {
2258
2296
  else outro(dim("Nothing to commit."));
2259
2297
  return "committed";
2260
2298
  }
2261
- if (!flags.noCheck) {
2299
+ if (!flags.noCheck && await getRunChecks()) {
2262
2300
  const { getRepoRoot } = await Promise.resolve().then(() => git_exports);
2263
2301
  if (await runCheckPhaseInteractive(await getRepoRoot(), await resolveToRepoRoot(included.filter((f) => f.status !== "D").map((f) => f.path)), 6e4) === "cancelled") return "cancelled";
2264
2302
  }
@@ -3793,13 +3831,13 @@ var ut = class extends m {
3793
3831
  }
3794
3832
  };
3795
3833
  //#endregion
3796
- //#region src/ui/auto-accept-select.ts
3834
+ //#region src/ui/toggle-select.ts
3797
3835
  const ON_LABEL = styleText("green", "ON");
3798
3836
  const OFF_LABEL = styleText("dim", "OFF");
3799
- const HOTKEY_HINT = dim("(press `a` to toggle)");
3800
- function renderStatus(autoAccept) {
3801
- const label = autoAccept ? ON_LABEL : OFF_LABEL;
3802
- return `${dim("⚡ Auto-accept:")} ${label} ${HOTKEY_HINT}`;
3837
+ function renderToggleState(t, state) {
3838
+ const label = state ? ON_LABEL : OFF_LABEL;
3839
+ const hint = dim(`(press \`${t.hotkey}\` to toggle)`);
3840
+ return `${dim(`${t.icon} ${t.label}:`)} ${label} ${hint}`;
3803
3841
  }
3804
3842
  /** Render a single select option line. */
3805
3843
  function renderOption(opt, active) {
@@ -3812,68 +3850,91 @@ function renderOption(opt, active) {
3812
3850
  return `${dim(S_RADIO_INACTIVE)} ${dim(text)}`;
3813
3851
  }
3814
3852
  /**
3815
- * Select prompt with an inline `a`-hotkey toggle for auto-accept mode.
3853
+ * Select prompt with inline hotkey toggles.
3816
3854
  *
3817
- * Renders a normal select list plus a status line showing the current
3818
- * auto-accept state. Pressing `a` flips the state in-place (the menu
3819
- * re-renders) and fires `onToggle` so callers can persist the change.
3855
+ * Renders a normal select list plus one status line per toggle. Pressing a
3856
+ * toggle's hotkey flips its state in-place (the prompt re-renders) and fires
3857
+ * its `onToggle` callback so callers can persist the change.
3820
3858
  *
3821
- * Returns `{ value, autoAccept }` on submit, or the clack cancel symbol
3822
- * on cancel.
3859
+ * Returns `{ value, toggles }` on submit, or the clack cancel symbol on
3860
+ * cancel. `toggles` is a map of `ToggleOption.key -> final boolean state`.
3823
3861
  */
3824
- async function selectWithAutoAccept(opts) {
3825
- let autoAccept = opts.initialAutoAccept;
3862
+ async function selectWithToggles(opts) {
3863
+ const state = {};
3864
+ for (const t of opts.toggles) state[t.hotkey] = t.initial;
3826
3865
  const prompt = new ut({
3827
3866
  options: opts.options,
3828
3867
  input: opts.input,
3829
3868
  output: opts.output,
3830
- render() {
3831
- const sym = symbol(this.state);
3832
- const statusLine = renderStatus(autoAccept);
3833
- const header = `${sym} ${opts.message}\n${dim(S_BAR)} ${statusLine}`;
3834
- switch (this.state) {
3835
- case "submit": {
3836
- const selected = this.options[this.cursor];
3837
- const text = selected.label ?? String(selected.value);
3838
- return `${header}\n${dim(S_BAR)} ${dim(text)}`;
3839
- }
3840
- case "cancel": {
3841
- const selected = this.options[this.cursor];
3842
- const text = selected.label ?? String(selected.value);
3843
- return `${header}\n${dim(S_BAR)} ${styleText(["strikethrough", "dim"], text)}\n${dim(S_BAR_END)}`;
3844
- }
3845
- default: return [
3846
- header,
3847
- ...limitOptions({
3848
- cursor: this.cursor,
3849
- options: this.options,
3850
- style: (opt, active) => renderOption(opt, active),
3851
- maxItems: 7,
3852
- output: opts.output ?? process.stdout
3853
- }).map((line) => `${dim(S_BAR)} ${line}`),
3854
- `${dim(S_BAR_END)} ${dim("↑/↓ navigate • Enter confirm • `a` toggle auto-accept")}`
3855
- ].join("\n");
3856
- }
3857
- }
3869
+ render: buildPromptRenderer(opts, state)
3858
3870
  });
3859
3871
  prompt.on("key", async (char) => {
3860
- if (char === "a") {
3861
- autoAccept = !autoAccept;
3862
- debug("auto-accept toggled to %s", autoAccept);
3863
- try {
3864
- await opts.onToggle?.(autoAccept);
3865
- } catch (err) {
3866
- debug("onToggle threw (ignored): %s", err instanceof Error ? err.message : String(err));
3867
- }
3872
+ const toggle = opts.toggles.find((t) => t.hotkey === char);
3873
+ if (!toggle) return;
3874
+ state[toggle.hotkey] = !state[toggle.hotkey];
3875
+ debug("%s toggled to %s", toggle.label, state[toggle.hotkey]);
3876
+ try {
3877
+ await toggle.onToggle?.(state[toggle.hotkey]);
3878
+ } catch (err) {
3879
+ debug("onToggle threw (ignored): %s", err instanceof Error ? err.message : String(err));
3868
3880
  }
3869
3881
  });
3870
3882
  const result = await prompt.prompt();
3871
3883
  if (q(result)) return result;
3872
3884
  return {
3873
3885
  value: result,
3874
- autoAccept
3886
+ toggles: buildTogglesMap(opts, state)
3887
+ };
3888
+ }
3889
+ /**
3890
+ * Build a render callback for the SelectPrompt.
3891
+ *
3892
+ * Closed over `opts` (the toggle-select options) and `state` (the mutable
3893
+ * hotkey → boolean map) so the inline render function can access toggle
3894
+ * state without encoding it in `this`.
3895
+ */
3896
+ function buildPromptRenderer(opts, state) {
3897
+ const optionList = opts.options;
3898
+ const toggleList = opts.toggles;
3899
+ return function() {
3900
+ const sym = symbol(this.state);
3901
+ const statusLines = toggleList.map((t) => renderToggleState(t, state[t.hotkey])).join("\n");
3902
+ const header = `${sym} ${opts.message}\n${dim(S_BAR)} ${statusLines}`;
3903
+ switch (this.state) {
3904
+ case "submit": {
3905
+ const selected = optionList[this.cursor];
3906
+ const text = selected.label ?? String(selected.value);
3907
+ return `${header}\n${dim(S_BAR)} ${dim(text)}`;
3908
+ }
3909
+ case "cancel": {
3910
+ const selected = optionList[this.cursor];
3911
+ const text = selected.label ?? String(selected.value);
3912
+ return `${header}\n${dim(S_BAR)} ${styleText(["strikethrough", "dim"], text)}\n${dim(S_BAR_END)}`;
3913
+ }
3914
+ default: {
3915
+ const lines = limitOptions({
3916
+ cursor: this.cursor,
3917
+ options: optionList,
3918
+ style: (opt, active) => renderOption(opt, active),
3919
+ maxItems: 7,
3920
+ output: opts.output ?? process.stdout
3921
+ }).map((line) => `${dim(S_BAR)} ${line}`);
3922
+ const hotkeysHint = toggleList.map((t) => `\`${t.hotkey}\` toggle ${t.label.toLowerCase()}`).join(" • ");
3923
+ return [
3924
+ header,
3925
+ ...lines,
3926
+ `${dim(S_BAR_END)} ${dim(`↑/↓ navigate • Enter confirm • ${hotkeysHint}`)}`
3927
+ ].join("\n");
3928
+ }
3929
+ }
3875
3930
  };
3876
3931
  }
3932
+ /** Collate the final toggle states keyed by `ToggleOption.key`. */
3933
+ function buildTogglesMap(opts, state) {
3934
+ const togglesByKey = {};
3935
+ for (const t of opts.toggles) togglesByKey[t.key] = state[t.hotkey];
3936
+ return togglesByKey;
3937
+ }
3877
3938
  //#endregion
3878
3939
  //#region src/ui/staging-menu.ts
3879
3940
  async function showStagingMenu(files, hasChecks) {
@@ -3903,12 +3964,25 @@ async function showStagingMenu(files, hasChecks) {
3903
3964
  p$1.note(lines.join("\n"), `${files.length} file${files.length !== 1 ? "s" : ""}`);
3904
3965
  const initialAutoAccept = await getAutoAccept();
3905
3966
  debug("showStagingMenu: initial auto-accept=%s", initialAutoAccept);
3906
- const selectResult = await selectWithAutoAccept({
3967
+ const initialRunChecks = hasChecks ? await getRunChecks() : true;
3968
+ debug("showStagingMenu: initial run-checks=%s", initialRunChecks);
3969
+ const selectResult = await selectWithToggles({
3907
3970
  message: "Stage files for commit:",
3908
- initialAutoAccept,
3909
- onToggle: async (next) => {
3910
- await setAutoAccept(next);
3911
- },
3971
+ toggles: [{
3972
+ key: "autoAccept",
3973
+ hotkey: "a",
3974
+ label: "Auto-accept",
3975
+ icon: "⚡",
3976
+ initial: initialAutoAccept,
3977
+ onToggle: (next) => setAutoAccept(next)
3978
+ }, ...hasChecks ? [{
3979
+ key: "runChecks",
3980
+ hotkey: "c",
3981
+ label: "Pre-commit checks",
3982
+ icon: "🛡",
3983
+ initial: initialRunChecks,
3984
+ onToggle: (next) => setRunChecks(next)
3985
+ }] : []],
3912
3986
  options: [
3913
3987
  ...files.length > 1 ? [{
3914
3988
  label: "Auto-group into commits",
@@ -3945,7 +4019,7 @@ async function showStagingMenu(files, hasChecks) {
3945
4019
  return null;
3946
4020
  }
3947
4021
  const choice = selectResult.value;
3948
- debug("showStagingMenu: choice=%s autoAccept=%s", choice, selectResult.autoAccept);
4022
+ debug("showStagingMenu: choice=%s autoAccept=%s runChecks=%s", choice, selectResult.toggles.autoAccept, selectResult.toggles.runChecks);
3949
4023
  if (p$1.isCancel(choice) || choice === "cancel") return null;
3950
4024
  if (choice === "autogroup") return "autogroup";
3951
4025
  if (choice === "checks") return "checks";
@@ -4073,7 +4147,10 @@ async function commitCommand(flags, version) {
4073
4147
  debug("Changed files:", changedFiles.length);
4074
4148
  const s = spinner();
4075
4149
  try {
4076
- if (flags.auto) {
4150
+ if (flags.single) {
4151
+ debug("Single-commit mode: staging all files");
4152
+ await stageAll();
4153
+ } else if (flags.auto) {
4077
4154
  if (flags.message) {
4078
4155
  outro(red("--message flag is not compatible with auto-group mode."));
4079
4156
  return;
@@ -4093,7 +4170,7 @@ async function commitCommand(flags, version) {
4093
4170
  process.exit(1);
4094
4171
  }
4095
4172
  changedFiles = await getChangedFiles();
4096
- await runPreCommitChecks(changedFiles, flags.noCheck);
4173
+ if (!flags.noCheck && await getRunChecks()) await runPreCommitChecks(changedFiles, false);
4097
4174
  const diffResult = await getStagedDiff();
4098
4175
  if (!diffResult) {
4099
4176
  debug("No staged changes found after staging");
@@ -4157,7 +4234,7 @@ async function commitCommand(flags, version) {
4157
4234
  }
4158
4235
  s.stop("Message generated");
4159
4236
  }
4160
- if (await getAutoAccept()) {
4237
+ if (flags.single || await getAutoAccept()) {
4161
4238
  debug("Auto-accept ON: skipping review step");
4162
4239
  log.info(message);
4163
4240
  } else {
@@ -4629,6 +4706,12 @@ cli({
4629
4706
  type: Boolean,
4630
4707
  description: "AI agent mode: non-interactive auto-group with JSON output",
4631
4708
  default: false
4709
+ },
4710
+ single: {
4711
+ type: Boolean,
4712
+ description: "Stage all files as a single commit with AI message (non-interactive)",
4713
+ alias: "s",
4714
+ default: false
4632
4715
  }
4633
4716
  },
4634
4717
  commands: [