@mihairo/cmt 1.1.6 → 1.3.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 +4 -2
  2. package/cmt +414 -103
  3. package/package.json +4 -4
package/README.md CHANGED
@@ -1,6 +1,8 @@
1
- # cmt
1
+ <p align="center">
2
+ <img src="logo.png" alt="cmt" width="720" />
3
+ </p>
2
4
 
3
- > Conventional Commits CLI — zero dependencies, one bash script.
5
+ > Conventional Commits CLI — zero dependencies, 1300 lines of bash.
4
6
 
5
7
  [![npm](https://img.shields.io/npm/v/@mihairo/cmt?label=npm)](https://npmjs.com/package/@mihairo/cmt)
6
8
  [![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-fe5196?logo=conventionalcommits)](https://conventionalcommits.org)
package/cmt CHANGED
@@ -6,33 +6,67 @@
6
6
  # =============================================================================
7
7
  set -euo pipefail
8
8
 
9
- CMT_VERSION="1.1.6" # x-release-please-version
9
+ CMT_VERSION="1.3.0" # x-release-please-version
10
10
  CMT_CONFIG_FILE=".cmt.json"
11
11
  CMT_SCHEMA_URL="https://raw.githubusercontent.com/mihai-ro/cmt/main/schema/cmt.schema.json"
12
12
 
13
- # colours
14
- if [[ -z "${NO_COLOR:-}" ]] && ([[ -t 1 ]] || [[ -c /dev/tty ]]); then
15
- RESET=$'\033[0m'
16
- BOLD=$'\033[1m'
17
- DIM=$'\033[2m'
18
- ITALIC=$'\033[3m'
19
- ACCENT=$'\033[38;2;86;182;194m' # cyan #56b6c2
20
- ACCENT_BG=$'\033[48;2;86;182;194m'
21
- ACCENT_BOLD=$'\033[1;38;2;86;182;194m'
22
- GREEN=$'\033[38;2;152;195;121m' # green #98c379 — success
23
- YELLOW=$'\033[38;2;229;192;123m' # yellow #e5c07b — warning / patch
24
- RED=$'\033[38;2;224;108;117m' # red #e06c75 — error / breaking
25
- BLUE=$'\033[38;2;97;175;239m' # blue #61afef info
26
- MUTED=$'\033[38;2;92;99;112m' # comment #5c6370 secondary text
27
- CYAN="$ACCENT"
28
- else
29
- RESET=''; BOLD=''; DIM=''; ITALIC=''
30
- ACCENT=''; ACCENT_BG=''; ACCENT_BOLD=''
31
- GREEN=''; YELLOW=''; RED=''; BLUE=''; MUTED=''
32
- CYAN=''
33
- fi
34
-
35
- # helpers
13
+ # Detect if we have a functional TTY for colored output
14
+ _has_color_tty() {
15
+ # Check for NO_COLOR
16
+ if [ -n "${NO_COLOR:-}" ]; then
17
+ return 1
18
+ fi
19
+
20
+ # Check if stdout is a terminal
21
+ if [ -t 1 ]; then
22
+ return 0
23
+ fi
24
+
25
+ # Check if we can write to /dev/tty
26
+ if [ -c /dev/tty ] && [ -w /dev/tty ]; then
27
+ return 0
28
+ fi
29
+
30
+ return 1
31
+ }
32
+
33
+ # COLORS AND OUTPUT
34
+ # Initialize colors based on terminal capability
35
+ _init_colors() {
36
+ if ! _has_color_tty; then
37
+ RESET='' BOLD='' DIM=''
38
+ GREEN='' YELLOW='' RED='' BLUE='' MUTED='' CYAN=''
39
+ return
40
+ fi
41
+
42
+ # Check for dumb terminal
43
+ case "${TERM:-dumb}" in
44
+ dumb|unknown)
45
+ # Still try if we have a tty
46
+ if ! _has_color_tty; then
47
+ RESET='' BOLD='' DIM=''
48
+ GREEN='' YELLOW='' RED='' BLUE='' MUTED='' CYAN=''
49
+ return
50
+ fi
51
+ ;;
52
+ esac
53
+
54
+ RESET=$'\033[0m'
55
+ BOLD=$'\033[1m'
56
+ DIM=$'\033[2m'
57
+ ACCENT=$'\033[38;2;86;182;194m' # cyan #56b6c2
58
+ ACCENT_BOLD=$'\033[1;38;2;86;182;194m'
59
+ GREEN=$'\033[38;2;152;195;121m' # green #98c379 — success
60
+ YELLOW=$'\033[38;2;229;192;123m' # yellow #e5c07b — warning / patch
61
+ RED=$'\033[38;2;224;108;117m' # red #e06c75 — error / breaking
62
+ BLUE=$'\033[38;2;97;175;239m' # blue #61afef — info
63
+ MUTED=$'\033[38;2;92;99;112m' # comment #5c6370 — secondary text
64
+ CYAN="$ACCENT"
65
+ }
66
+
67
+ _init_colors
68
+
69
+ # output helpers
36
70
  info() { printf " ${BLUE}·${RESET} %s\n" "$*"; }
37
71
  success() { printf " ${GREEN}✔${RESET} %s\n" "$*"; }
38
72
  warn() { printf " ${YELLOW}⚑${RESET} ${YELLOW}%s${RESET}\n" "$*" >&2; }
@@ -101,9 +135,9 @@ load_config() {
101
135
  if [[ "$line" =~ \[.*\] ]]; then
102
136
  # fully inline: "scopes": ["auth","api"]
103
137
  local _s="${line#*[}"; _s="${_s%%]*}"
104
- while [[ "$_s" =~ '"([^"]+)"' ]]; do
138
+ while [[ "$_s" =~ \"([^\"]+)\" ]]; do
105
139
  CUSTOM_SCOPES+=("${BASH_REMATCH[1]}")
106
- _s="${_s#*"${BASH_REMATCH[0]}"}"
140
+ _s="${_s#*\"[^\"]*\"}"
107
141
  done
108
142
  elif [[ "$line" == *"["* ]]; then
109
143
  collecting_arr="scopes"; array_buf=""
@@ -115,9 +149,9 @@ load_config() {
115
149
  if [[ "$line" =~ \[.*\] ]]; then
116
150
  local _s="${line#*[}"; _s="${_s%%]*}"
117
151
  RULE_ALLOW_BREAKING=()
118
- while [[ "$_s" =~ '"([^"]+)"' ]]; do
152
+ while [[ "$_s" =~ \"([^\"]+)\" ]]; do
119
153
  RULE_ALLOW_BREAKING+=("${BASH_REMATCH[1]}")
120
- _s="${_s#*"${BASH_REMATCH[0]}"}"
154
+ _s="${_s#*\"[^\"]*\"}"
121
155
  done
122
156
  [[ ${#RULE_ALLOW_BREAKING[@]} -eq 0 ]] && RULE_ALLOW_BREAKING=("feat" "fix")
123
157
  elif [[ "$line" == *"["* ]]; then
@@ -131,7 +165,6 @@ load_config() {
131
165
  section="rules"; continue
132
166
  fi
133
167
 
134
- # ── multi-line array collector ─────────────────────────────────────────
135
168
  if [[ -n "$collecting_arr" ]]; then
136
169
  if [[ "$line" == *"]"* ]]; then
137
170
  # closing bracket — flush accumulated buffer
@@ -139,15 +172,15 @@ load_config() {
139
172
  local _s="$array_buf"
140
173
  case "$collecting_arr" in
141
174
  scopes)
142
- while [[ "$_s" =~ '"([^"]+)"' ]]; do
175
+ while [[ "$_s" =~ \"([^\"]+)\" ]]; do
143
176
  CUSTOM_SCOPES+=("${BASH_REMATCH[1]}")
144
- _s="${_s#*"${BASH_REMATCH[0]}"}"
177
+ _s="${_s#*\"[^\"]*\"}"
145
178
  done
146
179
  ;;
147
180
  allowBreakingChanges)
148
- while [[ "$_s" =~ '"([^"]+)"' ]]; do
181
+ while [[ "$_s" =~ \"([^\"]+)\" ]]; do
149
182
  RULE_ALLOW_BREAKING+=("${BASH_REMATCH[1]}")
150
- _s="${_s#*"${BASH_REMATCH[0]}"}"
183
+ _s="${_s#*\"[^\"]*\"}"
151
184
  done
152
185
  [[ ${#RULE_ALLOW_BREAKING[@]} -eq 0 ]] && RULE_ALLOW_BREAKING=("feat" "fix")
153
186
  ;;
@@ -159,7 +192,6 @@ load_config() {
159
192
  continue
160
193
  fi
161
194
 
162
- # ── customTypes object items ───────────────────────────────────────────
163
195
  if [[ "$section" == "customTypes" ]]; then
164
196
  if [[ "$line" =~ ^[[:space:]]*\] ]]; then
165
197
  section=""; in_obj=0; continue
@@ -201,13 +233,12 @@ load_config() {
201
233
  fi
202
234
  fi
203
235
 
204
- # ── rules scalar fields ────────────────────────────────────────────────
205
236
  if [[ "$section" == "rules" ]]; then
206
237
  if [[ "$line" =~ ^[[:space:]]*\} ]]; then
207
238
  section=""; continue
208
239
  fi
209
240
  # match: "key": true|false|number — no subprocess needed
210
- if [[ "$line" =~ '"([^"]+)"'[[:space:]]*:[[:space:]]*(true|false|[0-9]+) ]]; then
241
+ if [[ "$line" =~ \"([^\"]+)\"[[:space:]]*:[[:space:]]*(true|false|[0-9]+) ]]; then
211
242
  case "${BASH_REMATCH[1]}" in
212
243
  maxHeaderLength) RULE_MAX_HEADER="${BASH_REMATCH[2]}" ;;
213
244
  requireScope) [[ "${BASH_REMATCH[2]}" == "true" ]] && RULE_REQUIRE_SCOPE=1 ;;
@@ -221,13 +252,15 @@ load_config() {
221
252
  }
222
253
 
223
254
  # type selection UI
224
- # arrow-key picker
255
+ # arrow-key picker with incremental search
225
256
  # Usage: _pick <result_var> <prompt> <item1> <item2> ...
226
- # Ssts result_var to chosen index (0-based). Renders on /dev/tty.
257
+ # Sets result_var to chosen index (0-based, original item list). Renders on /dev/tty.
227
258
  _pick() {
228
259
  local _var="$1" _prompt="$2"; shift 2
229
260
  local -a _items=("$@")
230
261
  local _n=${#_items[@]} _cur=0 _top=0 _view=7 _k1 _k2 _k3
262
+ local _filter="" _nv=0
263
+ local -a _visible=() _plain=()
231
264
 
232
265
  exec 9<>/dev/tty
233
266
 
@@ -237,11 +270,52 @@ _pick() {
237
270
 
238
271
  trap "trap - EXIT INT TERM; stty '$_old' <&9 2>/dev/null; printf '\033[?25h' >&9 2>/dev/null; exec 9>&- 2>/dev/null" EXIT INT TERM
239
272
 
273
+ # Pre-strip ANSI codes for search matching (bash 3.2 compatible char scan)
274
+ _strip_ansi_plain() {
275
+ local _s="$1" _out="" _i=0 _len _c
276
+ _len=${#_s}
277
+ while (( _i < _len )); do
278
+ _c="${_s:$_i:1}"
279
+ if [[ "$_c" == $'\033' && "${_s:$((_i+1)):1}" == "[" ]]; then
280
+ (( _i += 2 ))
281
+ while (( _i < _len )); do
282
+ _c="${_s:$_i:1}"
283
+ (( _i++ ))
284
+ [[ "$_c" =~ [a-zA-Z] ]] && break
285
+ done
286
+ else
287
+ _out+="$_c"
288
+ (( _i++ ))
289
+ fi
290
+ done
291
+ printf '%s' "$_out"
292
+ }
293
+
294
+ local _pi
295
+ for (( _pi=0; _pi<_n; _pi++ )); do
296
+ _plain[$_pi]=$(_strip_ansi_plain "${_items[$_pi]}")
297
+ done
298
+
299
+ # Rebuild _visible indices based on current _filter
300
+ _rebuild_visible() {
301
+ _visible=()
302
+ local _i
303
+ for (( _i=0; _i<_n; _i++ )); do
304
+ if [[ -z "$_filter" || "${_plain[$_i]}" == *"$_filter"* ]]; then
305
+ _visible+=("$_i")
306
+ fi
307
+ done
308
+ _nv=${#_visible[@]}
309
+ (( _cur >= _nv && _nv > 0 )) && _cur=$(( _nv - 1 ))
310
+ [[ $_nv -eq 0 ]] && _cur=0
311
+ _top=0
312
+ }
313
+
240
314
  _pick_move_up() {
241
- _cur=$(( (_cur - 1 + _n) % _n ))
242
- if [[ $_cur -eq $(( _n - 1 )) ]]; then
243
- # wrapped from first to last show last page
244
- _top=$(( _n - _view ))
315
+ [[ $_nv -eq 0 ]] && return
316
+ _cur=$(( (_cur - 1 + _nv) % _nv ))
317
+ if [[ $_cur -eq $(( _nv - 1 )) ]]; then
318
+ _top=$(( _nv - _view ))
245
319
  [[ $_top -lt 0 ]] && _top=0 || true
246
320
  elif [[ $_cur -lt $_top ]]; then
247
321
  _top=$(( _cur ))
@@ -249,36 +323,49 @@ _pick() {
249
323
  }
250
324
 
251
325
  _pick_move_down() {
252
- _cur=$(( (_cur + 1) % _n ))
326
+ [[ $_nv -eq 0 ]] && return
327
+ _cur=$(( (_cur + 1) % _nv ))
253
328
  if [[ $_cur -ge $(( _top + _view )) ]]; then
254
329
  _top=$(( _cur - _view + 1 ))
255
330
  elif [[ $_cur -eq 0 ]]; then
256
- # wrapped from bottom to top
257
331
  _top=0
258
332
  fi
259
333
  }
260
334
 
261
335
  # draw the visible window of _view items starting at _top
262
336
  _draw() {
263
- local _i _end
337
+ local _i _end _vi
338
+
339
+ # search line
340
+ if [[ -n "$_filter" ]]; then
341
+ printf " ${ACCENT}/ %s${RESET}\033[K\n" "$_filter" >&9
342
+ else
343
+ printf " ${MUTED}/ type to filter${RESET}\033[K\n" >&9
344
+ fi
345
+
264
346
  _end=$(( _top + _view ))
265
- [[ $_end -gt $_n ]] && _end=$_n
347
+ [[ $_end -gt _nv ]] && _end=$_nv
266
348
 
267
- # scroll hint above — only when items are hidden above
349
+ # scroll hint above
268
350
  if [[ $_top -gt 0 ]]; then
269
351
  printf " ${MUTED}↑ %d more${RESET}\033[K\n" "$_top" >&9
270
352
  fi
271
353
 
272
- for (( _i=_top; _i<_end; _i++ )); do
273
- if (( _i == _cur )); then
274
- printf " ${ACCENT_BOLD}❯${RESET} %s${RESET}\033[K\n" "${_items[$_i]}" >&9
275
- else
276
- printf " ${MUTED}·${RESET} ${MUTED}%s${RESET}\033[K\n" "${_items[$_i]}" >&9
277
- fi
278
- done
354
+ if [[ $_nv -eq 0 ]]; then
355
+ printf " ${MUTED}no matches${RESET}\033[K\n" >&9
356
+ else
357
+ for (( _i=_top; _i<_end; _i++ )); do
358
+ _vi=${_visible[$_i]}
359
+ if (( _i == _cur )); then
360
+ printf " ${ACCENT_BOLD}❯${RESET} %s${RESET}\033[K\n" "${_items[$_vi]}" >&9
361
+ else
362
+ printf " ${MUTED}·${RESET} ${MUTED}%s${RESET}\033[K\n" "${_items[$_vi]}" >&9
363
+ fi
364
+ done
365
+ fi
279
366
 
280
367
  # scroll hint below
281
- local _remaining=$(( _n - _end ))
368
+ local _remaining=$(( _nv - _end ))
282
369
  if [[ $_remaining -gt 0 ]]; then
283
370
  printf " ${MUTED}↓ %d more${RESET}\033[K\n" "$_remaining" >&9
284
371
  else
@@ -286,6 +373,8 @@ _pick() {
286
373
  fi
287
374
  }
288
375
 
376
+ _rebuild_visible
377
+
289
378
  printf "\033[?25l" >&9
290
379
  printf "\n ${ACCENT_BOLD}%s${RESET}\n\n" "$_prompt" >&9
291
380
  printf "\033[s" >&9
@@ -302,27 +391,49 @@ _pick() {
302
391
  A) _pick_move_up ;;
303
392
  B) _pick_move_down ;;
304
393
  esac
394
+ elif [[ -z "$_k2" ]]; then
395
+ # lone Escape: clear filter
396
+ _filter=""; _rebuild_visible
305
397
  fi
306
398
  ;;
307
399
  $'\r'|$'\n'|'') break ;;
400
+ $'\177'|$'\010')
401
+ # backspace — remove last filter char
402
+ _filter="${_filter%?}"; _rebuild_visible ;;
308
403
  k) _pick_move_up ;;
309
404
  j) _pick_move_down ;;
310
- q|$'\003') stty "$_old" <&9 2>/dev/null; trap - EXIT INT TERM; exec 9>&-; exit 1 ;;
405
+ $'\003') stty "$_old" <&9 2>/dev/null; trap - EXIT INT TERM; exec 9>&-; exit 1 ;;
406
+ q)
407
+ if [[ -z "$_filter" ]]; then
408
+ stty "$_old" <&9 2>/dev/null; trap - EXIT INT TERM; exec 9>&-; exit 1
409
+ else
410
+ _filter+="q"; _rebuild_visible
411
+ fi
412
+ ;;
413
+ [[:print:]])
414
+ # any other printable char goes into the search filter
415
+ _filter+="$_k1"; _rebuild_visible ;;
311
416
  esac
312
417
  printf "\033[u\033[J" >&9
313
418
  _draw
314
419
  done
315
420
 
421
+ # nothing matched — fall back to first item
422
+ [[ $_nv -eq 0 ]] && { printf -v "$_var" '%d' 0; } && {
423
+ stty "$_old" <&9 2>/dev/null; trap - EXIT INT TERM; printf "\033[?25h" >&9; exec 9>&-; return; }
424
+
425
+ local _selected_orig=${_visible[$_cur]}
426
+
316
427
  # collapse to just the selected line
317
428
  printf "\033[u\033[J" >&9
318
- printf " ${ACCENT_BOLD}❯${RESET} ${BOLD}%s${RESET}\n" "${_items[$_cur]}" >&9
429
+ printf " ${ACCENT_BOLD}❯${RESET} ${BOLD}%s${RESET}\n" "${_items[$_selected_orig]}" >&9
319
430
 
320
431
  stty "$_old" <&9 2>/dev/null
321
432
  trap - EXIT INT TERM
322
433
  printf "\033[?25h" >&9
323
434
  exec 9>&-
324
435
 
325
- printf -v "$_var" '%d' "$_cur"
436
+ printf -v "$_var" '%d' "$_selected_orig"
326
437
  }
327
438
 
328
439
 
@@ -412,8 +523,8 @@ input_description() {
412
523
  read -r desc </dev/tty
413
524
  if [[ -z "$desc" ]]; then
414
525
  warn "Description is required"
415
- elif (( ${#desc} > 100 )); then
416
- warn "Description is ${#desc} chars — keep it under 72 for readability"
526
+ elif (( ${#desc} > RULE_MAX_HEADER )); then
527
+ warn "Description is ${#desc} chars — keep it under ${RULE_MAX_HEADER} for readability"
417
528
  printf " ${YELLOW}use anyway?${RESET} ${MUTED}[y/N]${RESET} ${ACCENT}›${RESET} " >/dev/tty
418
529
  read -r confirm </dev/tty
419
530
  [[ "$confirm" =~ ^[yY]$ ]] && { SELECTED_DESC="$desc"; return 0; }
@@ -424,13 +535,22 @@ input_description() {
424
535
  done
425
536
  }
426
537
 
427
- # body input
538
+ # body input — multi-line; empty line to finish
428
539
  input_body() {
429
- printf "\n ${ACCENT_BOLD}body${RESET} ${MUTED}optional — press Enter to skip${RESET}\n" >/dev/tty
430
- printf " ${ACCENT}›${RESET} " >/dev/tty
431
- local line
432
- read -r line </dev/tty
433
- SELECTED_BODY="$line"
540
+ printf "\n ${ACCENT_BOLD}body${RESET} ${MUTED}optional — empty line to finish${RESET}\n" >/dev/tty
541
+ local -a _lines=() _l
542
+ while true; do
543
+ printf " ${ACCENT}›${RESET} " >/dev/tty
544
+ IFS= read -r _l </dev/tty
545
+ [[ -z "$_l" ]] && break
546
+ _lines+=("$_l")
547
+ done
548
+ if (( ${#_lines[@]} > 0 )); then
549
+ SELECTED_BODY="$(printf '%s\n' "${_lines[@]}")"
550
+ SELECTED_BODY="${SELECTED_BODY%$'\n'}" # trim trailing newline
551
+ else
552
+ SELECTED_BODY=""
553
+ fi
434
554
  }
435
555
 
436
556
  # breaking change
@@ -486,6 +606,25 @@ assemble_message() {
486
606
  COMMIT_MESSAGE="$msg"
487
607
  }
488
608
 
609
+ # word-wrap: prints wrapped lines of $1 at most $2 chars wide.
610
+ _wordwrap() {
611
+ local _text="$1" _width="$2" _line="" _word
612
+ local -a _words
613
+ read -ra _words <<< "$_text"
614
+ if [[ ${#_words[@]} -eq 0 ]]; then printf '\n'; return; fi
615
+ for _word in "${_words[@]}"; do
616
+ if [[ -z "$_line" ]]; then
617
+ _line="$_word"
618
+ elif (( ${#_line} + 1 + ${#_word} <= _width )); then
619
+ _line="$_line $_word"
620
+ else
621
+ printf '%s\n' "$_line"
622
+ _line="$_word"
623
+ fi
624
+ done
625
+ [[ -n "$_line" ]] && printf '%s\n' "$_line"
626
+ }
627
+
489
628
  _print_commit_box() {
490
629
  local msg="$1" _max=0 _line _W _content_w _bar _bottom _max_W
491
630
 
@@ -504,17 +643,10 @@ _print_commit_box() {
504
643
  printf -v _bar '%*s' $(( _W - 19 )) ''; _bar=${_bar// /─}
505
644
  printf "\n ${MUTED}╭─ commit message %s╮${RESET}\n" "$_bar" >/dev/tty
506
645
  while IFS= read -r _line; do
507
- printf " ${MUTED}│${RESET} ${BOLD}%-${_content_w}s${RESET} ${MUTED}│${RESET}\n" "$_line" >/dev/tty
508
- done <<< "$(printf '%s\n' "$msg" | awk -v w="$_content_w" '{
509
- if (length($0) <= w) { print; next }
510
- line = ""
511
- for (i = 1; i <= NF; i++) {
512
- if (line == "") { line = $i }
513
- else if (length(line) + 1 + length($i) <= w) { line = line " " $i }
514
- else { print line; line = $i }
515
- }
516
- if (line != "") print line
517
- }')"
646
+ while IFS= read -r _wrapped; do
647
+ printf " ${MUTED}│${RESET} ${BOLD}%-${_content_w}s${RESET} ${MUTED}│${RESET}\n" "$_wrapped" >/dev/tty
648
+ done < <(_wordwrap "$_line" "$_content_w")
649
+ done <<< "$msg"
518
650
  printf -v _bottom '%*s' $(( _W - 2 )) ''; _bottom=${_bottom// /─}
519
651
  printf " ${MUTED}╰%s╯${RESET}\n" "$_bottom" >/dev/tty
520
652
  }
@@ -552,10 +684,14 @@ confirm_and_commit() {
552
684
  cmd_commit() {
553
685
  # --write-only: used by prepare-commit-msg hook — run picker, print message to
554
686
  # stdout, do NOT call git commit (git will do that after the hook returns).
555
- local write_only=0
556
- [[ "${1:-}" == "--write-only" ]] && write_only=1
687
+ # --dry-run: run the picker, print the assembled message, do NOT commit.
688
+ local write_only=0 dry_run=0
689
+ for _a in "$@"; do
690
+ [[ "$_a" == "--write-only" ]] && write_only=1
691
+ [[ "$_a" == "--dry-run" ]] && dry_run=1
692
+ done
557
693
 
558
- if [[ $write_only -eq 0 ]]; then
694
+ if [[ $write_only -eq 0 && $dry_run -eq 0 ]]; then
559
695
  git rev-parse --git-dir > /dev/null 2>&1 || die "Not inside a git repository"
560
696
  if git diff --cached --quiet 2>/dev/null; then
561
697
  warn "No staged changes detected. Did you forget \`git add\`?"
@@ -585,6 +721,13 @@ cmd_commit() {
585
721
  return 0
586
722
  fi
587
723
 
724
+ if [[ $dry_run -eq 1 ]]; then
725
+ _print_commit_box "$COMMIT_MESSAGE"
726
+ printf "\n"
727
+ printf '%s\n' "$COMMIT_MESSAGE"
728
+ return 0
729
+ fi
730
+
588
731
  confirm_and_commit
589
732
  }
590
733
 
@@ -702,9 +845,12 @@ _hook_snippet() {
702
845
  cat << 'SNIPPET'
703
846
  # >>> cmt — Conventional Commits CLI
704
847
  [ -n "${2:-}" ] && exit 0
848
+ _cmt_bin=""
705
849
  for _p in "./node_modules/.bin/cmt" "$HOME/.local/bin/cmt" "/usr/local/bin/cmt" "/opt/homebrew/bin/cmt"; do
706
- [ -x "$_p" ] && { "$_p" commit --write-only > "$1"; exit 0; }
850
+ [ -x "$_p" ] && { _cmt_bin="$_p"; break; }
707
851
  done
852
+ [ -z "$_cmt_bin" ] && _cmt_bin=$(command -v cmt 2>/dev/null)
853
+ [ -n "$_cmt_bin" ] && { "$_cmt_bin" commit --write-only > "$1"; exit 0; }
708
854
  exit 0
709
855
  # <<< cmt
710
856
  SNIPPET
@@ -714,9 +860,12 @@ SNIPPET
714
860
  _lint_snippet() {
715
861
  cat << 'SNIPPET'
716
862
  # >>> cmt-lint — Conventional Commits CLI
863
+ _cmt_bin=""
717
864
  for _p in "./node_modules/.bin/cmt" "$HOME/.local/bin/cmt" "/usr/local/bin/cmt" "/opt/homebrew/bin/cmt"; do
718
- [ -x "$_p" ] && { "$_p" lint "$1"; exit $?; }
865
+ [ -x "$_p" ] && { _cmt_bin="$_p"; break; }
719
866
  done
867
+ [ -z "$_cmt_bin" ] && _cmt_bin=$(command -v cmt 2>/dev/null)
868
+ [ -n "$_cmt_bin" ] && { "$_cmt_bin" lint "$1"; exit $?; }
720
869
  exit 0
721
870
  # <<< cmt-lint
722
871
  SNIPPET
@@ -753,13 +902,30 @@ cmd_log() {
753
902
  git rev-parse --git-dir > /dev/null 2>&1 || die "Not inside a git repository"
754
903
  load_config
755
904
 
756
- local limit="${1:-20}"
757
- printf "\n ${ACCENT_BOLD}log${RESET} ${MUTED}last %s commits${RESET}\n" "$limit"
905
+ local limit=20 filter_type="" filter_author=""
906
+ while [[ $# -gt 0 ]]; do
907
+ case "$1" in
908
+ --type=*) filter_type="${1#--type=}" ;;
909
+ --author=*) filter_author="${1#--author=}" ;;
910
+ [0-9]*) limit="$1" ;;
911
+ esac
912
+ shift
913
+ done
914
+
915
+ local _label="last ${limit} commits"
916
+ [[ -n "$filter_type" ]] && _label+=" ${MUTED}type=${filter_type}${RESET}"
917
+ [[ -n "$filter_author" ]] && _label+=" ${MUTED}author=${filter_author}${RESET}"
918
+ printf "\n ${ACCENT_BOLD}log${RESET} ${MUTED}%s${RESET}\n" "$_label"
758
919
 
759
920
  local _p_log_type='^([a-z][a-z0-9_-]*)[(:!]'
760
921
  while IFS='|' read -r hash short_hash date author subject; do
761
922
  local type="" emoji color
762
923
  [[ "$subject" =~ $_p_log_type ]] && type="${BASH_REMATCH[1]}"
924
+
925
+ # apply filters
926
+ [[ -n "$filter_type" && "$type" != "$filter_type" ]] && continue
927
+ [[ -n "$filter_author" && "$author" != *"$filter_author"* ]] && continue
928
+
763
929
  emoji="·"; color="$RESET"
764
930
 
765
931
  if [[ -n "$type" ]]; then
@@ -918,6 +1084,129 @@ cmd_uninstall() {
918
1084
  }
919
1085
 
920
1086
 
1087
+ # cmd: amend — interactively rebuild or raw-edit the last commit message
1088
+ cmd_amend() {
1089
+ git rev-parse --git-dir > /dev/null 2>&1 || die "Not inside a git repository"
1090
+ git log -1 > /dev/null 2>&1 || die "No commits yet"
1091
+
1092
+ local last_msg
1093
+ last_msg=$(git log -1 --format=%B)
1094
+ _print_commit_box "$last_msg"
1095
+
1096
+ printf "\n ${ACCENT_BOLD}amend${RESET} ${MUTED}[Y=rebuild / e=edit raw / N=abort]${RESET} ${ACCENT}›${RESET} " >/dev/tty
1097
+ read -r _ans </dev/tty
1098
+
1099
+ case "$_ans" in
1100
+ e|E)
1101
+ local tmpfile; tmpfile=$(mktemp /tmp/cmt-amend-XXXXXX)
1102
+ printf '%s' "$last_msg" > "$tmpfile"
1103
+ "${EDITOR:-vi}" "$tmpfile"
1104
+ COMMIT_MESSAGE=$(< "$tmpfile"); rm -f "$tmpfile"
1105
+ git commit --amend --no-edit -m "$COMMIT_MESSAGE"
1106
+ printf "\n"; success "${BOLD}amended${RESET}"
1107
+ ;;
1108
+ ""|y|Y)
1109
+ load_config
1110
+ SELECTED_TYPE="" SELECTED_SCOPE="" SELECTED_DESC=""
1111
+ SELECTED_BODY="" SELECTED_FOOTER="" IS_BREAKING=0 BREAKING_DESC=""
1112
+ select_type; select_scope; input_description
1113
+ input_body; input_breaking; input_footer
1114
+ assemble_message
1115
+ _print_commit_box "$COMMIT_MESSAGE"
1116
+ printf "\n ${ACCENT_BOLD}commit --amend?${RESET} ${MUTED}[Y/n]${RESET} ${ACCENT}›${RESET} " >/dev/tty
1117
+ read -r _c </dev/tty
1118
+ [[ "$_c" =~ ^[nN]$ ]] && { warn "Amend aborted."; exit 0; }
1119
+ git commit --amend --no-edit -m "$COMMIT_MESSAGE"
1120
+ printf "\n"; success "${BOLD}amended${RESET}"
1121
+ ;;
1122
+ *) warn "Amend aborted."; exit 0 ;;
1123
+ esac
1124
+ }
1125
+
1126
+ # cmd: status — show staged changes before committing
1127
+ cmd_status() {
1128
+ git rev-parse --git-dir > /dev/null 2>&1 || die "Not inside a git repository"
1129
+ printf "\n ${ACCENT_BOLD}staged${RESET}\n\n"
1130
+ local staged=0
1131
+ while IFS= read -r _line; do
1132
+ local _x="${_line:0:1}" _f="${_line:3}"
1133
+ case "$_x" in
1134
+ A) printf " ${GREEN}+${RESET} %-40s ${MUTED}added${RESET}\n" "$_f"; (( staged++ )) ;;
1135
+ M) printf " ${BLUE}~${RESET} %-40s ${MUTED}modified${RESET}\n" "$_f"; (( staged++ )) ;;
1136
+ D) printf " ${RED}-${RESET} %-40s ${MUTED}deleted${RESET}\n" "$_f"; (( staged++ )) ;;
1137
+ R) printf " ${YELLOW}»${RESET} %-40s ${MUTED}renamed${RESET}\n" "$_f"; (( staged++ )) ;;
1138
+ C) printf " ${YELLOW}»${RESET} %-40s ${MUTED}copied${RESET}\n" "$_f"; (( staged++ )) ;;
1139
+ esac
1140
+ done < <(git status --porcelain 2>/dev/null)
1141
+ [[ $staged -eq 0 ]] && warn "Nothing staged. Run: git add <file>"
1142
+ printf "\n"
1143
+ }
1144
+
1145
+ # cmd: completions — print shell completion scripts
1146
+ cmd_completions() {
1147
+ local _shell="${1:-bash}"
1148
+ case "$_shell" in
1149
+ bash) _completions_bash ;;
1150
+ zsh) _completions_zsh ;;
1151
+ fish) _completions_fish ;;
1152
+ *) die "Unknown shell: $_shell (use: bash, zsh, or fish)" ;;
1153
+ esac
1154
+ }
1155
+
1156
+ _completions_bash() {
1157
+ cat << 'EOF'
1158
+ # cmt bash completions — add to ~/.bashrc: eval "$(cmt completions bash)"
1159
+ _cmt_completions() {
1160
+ local cur="${COMP_WORDS[COMP_CWORD]}"
1161
+ local cmds="commit lint init log types status amend uninstall completions version help"
1162
+ COMPREPLY=( $(compgen -W "$cmds" -- "$cur") )
1163
+ }
1164
+ complete -F _cmt_completions cmt
1165
+ EOF
1166
+ }
1167
+
1168
+ _completions_zsh() {
1169
+ cat << 'EOF'
1170
+ # cmt zsh completions — add to ~/.zshrc: eval "$(cmt completions zsh)"
1171
+ _cmt() {
1172
+ local -a cmds
1173
+ cmds=(
1174
+ 'commit:Interactive commit builder'
1175
+ 'lint:Lint a commit message file or stdin'
1176
+ 'init:Set up repo with hook and config'
1177
+ 'log:Pretty log of recent commits'
1178
+ 'types:List available commit types'
1179
+ 'status:Show staged changes'
1180
+ 'amend:Amend the last commit'
1181
+ 'uninstall:Remove cmt-managed hooks'
1182
+ 'completions:Print shell completion scripts'
1183
+ 'version:Print version'
1184
+ 'help:Show help'
1185
+ )
1186
+ _describe 'command' cmds
1187
+ }
1188
+ compdef _cmt cmt
1189
+ EOF
1190
+ }
1191
+
1192
+ _completions_fish() {
1193
+ cat << 'EOF'
1194
+ # cmt fish completions — add to config.fish: cmt completions fish | source
1195
+ complete -c cmt -f
1196
+ complete -c cmt -n '__fish_use_subcommand' -a commit -d 'Interactive commit builder'
1197
+ complete -c cmt -n '__fish_use_subcommand' -a lint -d 'Lint a commit message'
1198
+ complete -c cmt -n '__fish_use_subcommand' -a init -d 'Set up repo with hook and config'
1199
+ complete -c cmt -n '__fish_use_subcommand' -a log -d 'Pretty log of recent commits'
1200
+ complete -c cmt -n '__fish_use_subcommand' -a types -d 'List available commit types'
1201
+ complete -c cmt -n '__fish_use_subcommand' -a status -d 'Show staged changes'
1202
+ complete -c cmt -n '__fish_use_subcommand' -a amend -d 'Amend the last commit'
1203
+ complete -c cmt -n '__fish_use_subcommand' -a uninstall -d 'Remove cmt-managed hooks'
1204
+ complete -c cmt -n '__fish_use_subcommand' -a completions -d 'Print shell completion scripts'
1205
+ complete -c cmt -n '__fish_use_subcommand' -a version -d 'Print version'
1206
+ complete -c cmt -n '__fish_use_subcommand' -a help -d 'Show help'
1207
+ EOF
1208
+ }
1209
+
921
1210
  # cmd: version
922
1211
  cmd_version() {
923
1212
  printf "cmt version %s\n" "$CMT_VERSION"
@@ -932,27 +1221,44 @@ ${BOLD}USAGE${RESET}
932
1221
  cmt <command> [options]
933
1222
 
934
1223
  ${BOLD}COMMANDS${RESET}
935
- ${CYAN}init${RESET} [--husky] [--lint] Create .cmt.json + install prepare-commit-msg hook
936
- --husky write to .husky/ (husky v9, committable)
937
- --lint also install commit-msg linting hook
938
- default write to .git/hooks/ (local)
1224
+ ${CYAN}init${RESET} [--husky] [--lint] Create .cmt.json + install prepare-commit-msg hook
1225
+ --husky write to .husky/ (husky v9, committable)
1226
+ --lint also install commit-msg linting hook
1227
+ default write to .git/hooks/ (local)
1228
+
1229
+ ${CYAN}commit${RESET} [--dry-run] Interactive commit builder
1230
+ --dry-run show assembled message, do not commit
1231
+
1232
+ ${CYAN}amend${RESET} Amend the last commit (rebuild or raw edit)
939
1233
 
940
- ${CYAN}commit${RESET} Interactive commit builder
1234
+ ${CYAN}status${RESET} Show staged changes
941
1235
 
942
- ${CYAN}lint${RESET} [file] Lint a commit message file or stdin
1236
+ ${CYAN}lint${RESET} [file] Lint a commit message file or stdin
943
1237
 
944
- ${CYAN}log${RESET} [n] Pretty log — last n commits (default: 20)
1238
+ ${CYAN}log${RESET} [n] [--type=<t>] Pretty log — last n commits (default: 20)
1239
+ [--author=<a>] filter by type or author substring
945
1240
 
946
- ${CYAN}types${RESET} List available types
1241
+ ${CYAN}types${RESET} List available types
947
1242
 
948
- ${CYAN}uninstall${RESET} Remove cmt-managed hooks
1243
+ ${CYAN}completions${RESET} [bash|zsh|fish] Print shell completion script
1244
+
1245
+ ${CYAN}uninstall${RESET} Remove cmt-managed hooks
949
1246
 
950
1247
  ${BOLD}EXAMPLES${RESET}
951
- cmt init # set up repo — done in one step
952
- cmt init --husky # same, but writes to .husky/ for husky v9
953
- cmt commit # guided commit
1248
+ cmt init # set up repo — done in one step
1249
+ cmt init --husky # same, but writes to .husky/ for husky v9
1250
+ cmt commit # guided commit
1251
+ cmt commit --dry-run # preview message without committing
1252
+ cmt amend # fix last commit
1253
+ cmt status # see what's staged
1254
+ cmt log 10 --type=feat # last 10 feat commits
954
1255
  echo 'fix: typo' | cmt lint
955
- git log --format=%s | cmt lint # lint every commit on a branch
1256
+ git log --format=%s | cmt lint # lint every commit on a branch
1257
+
1258
+ ${BOLD}SHELL COMPLETIONS${RESET}
1259
+ bash Add to ~/.bashrc: eval \"\$(cmt completions bash)\"
1260
+ zsh Add to ~/.zshrc: eval \"\$(cmt completions zsh)\"
1261
+ fish Add to config.fish: cmt completions fish | source
956
1262
 
957
1263
  ${BOLD}INTEGRATIONS${RESET}
958
1264
  Husky v9 cmt init --husky → .husky/prepare-commit-msg (commit it, team shares it)
@@ -974,15 +1280,20 @@ main() {
974
1280
  local cmd="${1:-commit}"
975
1281
  [[ $# -gt 0 ]] && shift
976
1282
  case "$cmd" in
977
- commit|c) cmd_commit "$@" ;;
978
- lint|l) cmd_lint "$@" ;;
979
- init) cmd_init "$@" ;;
980
- log) cmd_log "$@" ;;
981
- types|t) cmd_types "$@" ;;
982
- uninstall) cmd_uninstall "$@" ;;
1283
+ commit|c) cmd_commit "$@" ;;
1284
+ amend|a) cmd_amend "$@" ;;
1285
+ lint|l) cmd_lint "$@" ;;
1286
+ init) cmd_init "$@" ;;
1287
+ log) cmd_log "$@" ;;
1288
+ status|s) cmd_status "$@" ;;
1289
+ types|t) cmd_types "$@" ;;
1290
+ completions) cmd_completions "$@" ;;
1291
+ uninstall) cmd_uninstall "$@" ;;
983
1292
  version|-v|--version) cmd_version ;;
984
1293
  help|-h|--help|*) cmd_help ;;
985
1294
  esac
986
1295
  }
987
1296
 
1297
+ # Allow sourcing for tests without executing main
1298
+ [[ "${CMT_SOURCED:-0}" == "1" ]] && return 0
988
1299
  main "$@"
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mihairo/cmt",
3
- "version": "1.1.6",
4
- "description": "Zero-dependency conventional commits CLI interactive picker, linter, and git hook installer. One bash script, works in any repo.",
3
+ "version": "1.3.0",
4
+ "description": "Zero-dependency commit messages that actually follow the spec. One 1300-line bash script.",
5
5
  "keywords": [
6
6
  "conventional-commits",
7
7
  "commit",
@@ -18,11 +18,11 @@
18
18
  },
19
19
  "repository": {
20
20
  "type": "git",
21
- "url": "https://github.com/mihai-ro/cmt.git"
21
+ "url": "git+https://github.com/mihai-ro/cmt.git"
22
22
  },
23
23
  "license": "MIT",
24
24
  "bin": {
25
- "cmt": "./cmt"
25
+ "cmt": "cmt"
26
26
  },
27
27
  "files": [
28
28
  "cmt",