@mihairo/cmt 1.0.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/cmt ADDED
@@ -0,0 +1,972 @@
1
+ #!/usr/bin/env bash
2
+ # =============================================================================
3
+ # cmt — Conventional Commits CLI
4
+ # Zero-dependency. Pure bash. Works wherever git works.
5
+ # https://www.conventionalcommits.org/en/v1.0.0/
6
+ # =============================================================================
7
+ set -euo pipefail
8
+
9
+ CC_VERSION="1.0.0"
10
+ CC_CONFIG_FILE=".cmt.json"
11
+ CC_SCHEMA_URL="https://raw.githubusercontent.com/mihai-ro/cmt/main/schema/cmt.schema.json"
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
36
+ info() { printf " ${BLUE}·${RESET} %s\n" "$*"; }
37
+ success() { printf " ${GREEN}✔${RESET} %s\n" "$*"; }
38
+ warn() { printf " ${YELLOW}⚑${RESET} ${YELLOW}%s${RESET}\n" "$*" >&2; }
39
+ error() { printf " ${RED}✖${RESET} %s\n" "$*" >&2; }
40
+ bold() { printf "${BOLD}%s${RESET}" "$*"; }
41
+ die() { error "$*"; exit 1; }
42
+
43
+ # Conventional Commits spec types
44
+ # format: "type|emoji|semver_impact|description"
45
+ BUILTIN_TYPES=(
46
+ "feat|✨|minor|A new feature"
47
+ "fix|🐛|patch|A bug fix"
48
+ "docs|📚|none|Documentation only changes"
49
+ "style|💅|none|Formatting, missing semi-colons, etc — no logic change"
50
+ "refactor|♻️ |none|Code change that neither fixes a bug nor adds a feature"
51
+ "perf|⚡|patch|A code change that improves performance"
52
+ "test|🧪|none|Adding or correcting tests"
53
+ "build|🏗️ |none|Changes to build system or external dependencies"
54
+ "ci|🔧|none|Changes to CI/CD configuration files and scripts"
55
+ "chore|🔩|none|Other changes that don't modify src or test files"
56
+ "revert|⏪|patch|Reverts a previous commit"
57
+ )
58
+
59
+ # config loader
60
+ load_config() {
61
+ local cfg="${CC_CONFIG_FILE}"
62
+
63
+ TYPES=("${BUILTIN_TYPES[@]}")
64
+ CUSTOM_SCOPES=()
65
+
66
+ # rule defaults (overridden by .cmt.json "rules" block)
67
+ RULE_MAX_HEADER=72
68
+ RULE_REQUIRE_SCOPE=0
69
+ RULE_DISALLOW_UPPER=0
70
+ RULE_DISALLOW_PERIOD=0
71
+ RULE_ALLOW_BREAKING=("feat" "fix")
72
+
73
+ [[ -f "$cfg" ]] || return 0
74
+
75
+ # Anchored key patterns in variables — [[ =~ var ]] treats the value as ERE.
76
+ # Must be unquoted on the RHS of =~ to get regex behaviour (not literal match).
77
+ local _p_ct='^[[:space:]]+"customTypes"[[:space:]]*:'
78
+ local _p_sc='^[[:space:]]+"scopes"[[:space:]]*:'
79
+ local _p_ab='^[[:space:]]+"allowBreakingChanges"[[:space:]]*:'
80
+ local _p_ru='^[[:space:]]+"rules"[[:space:]]*:'
81
+ local _p_kv='"([^"]+)"[[:space:]]*:[[:space:]]*"([^"]+)"'
82
+
83
+ # single-pass state machine
84
+ local section="" # current top-level context
85
+ local in_obj=0 # inside a customTypes item object
86
+ local collecting_arr="" # key name of a multi-line array being collected
87
+ local array_buf="" # accumulated multi-line array content
88
+
89
+ local type_val="" emoji_val="⚙️ " semver_val="none" desc_val="Custom type"
90
+
91
+ while IFS= read -r line; do
92
+
93
+ # ── top-level section detection — anchored regex, zero subprocesses ───
94
+ if [[ "$line" =~ $_p_ct ]]; then
95
+ # handle empty inline: "customTypes": []
96
+ [[ "$line" == *"[]"* ]] && continue
97
+ section="customTypes"; continue
98
+ fi
99
+
100
+ if [[ "$line" =~ $_p_sc ]]; then
101
+ if [[ "$line" =~ \[.*\] ]]; then
102
+ # fully inline: "scopes": ["auth","api"]
103
+ local _s="${line#*[}"; _s="${_s%%]*}"
104
+ while [[ "$_s" =~ '"([^"]+)"' ]]; do
105
+ CUSTOM_SCOPES+=("${BASH_REMATCH[1]}")
106
+ _s="${_s#*"${BASH_REMATCH[0]}"}"
107
+ done
108
+ elif [[ "$line" == *"["* ]]; then
109
+ collecting_arr="scopes"; array_buf=""
110
+ fi
111
+ section=""; continue
112
+ fi
113
+
114
+ if [[ "$line" =~ $_p_ab ]]; then
115
+ if [[ "$line" =~ \[.*\] ]]; then
116
+ local _s="${line#*[}"; _s="${_s%%]*}"
117
+ RULE_ALLOW_BREAKING=()
118
+ while [[ "$_s" =~ '"([^"]+)"' ]]; do
119
+ RULE_ALLOW_BREAKING+=("${BASH_REMATCH[1]}")
120
+ _s="${_s#*"${BASH_REMATCH[0]}"}"
121
+ done
122
+ [[ ${#RULE_ALLOW_BREAKING[@]} -eq 0 ]] && RULE_ALLOW_BREAKING=("feat" "fix")
123
+ elif [[ "$line" == *"["* ]]; then
124
+ collecting_arr="allowBreakingChanges"; array_buf=""
125
+ RULE_ALLOW_BREAKING=()
126
+ fi
127
+ continue
128
+ fi
129
+
130
+ if [[ "$line" =~ $_p_ru ]]; then
131
+ section="rules"; continue
132
+ fi
133
+
134
+ # ── multi-line array collector ─────────────────────────────────────────
135
+ if [[ -n "$collecting_arr" ]]; then
136
+ if [[ "$line" == *"]"* ]]; then
137
+ # closing bracket — flush accumulated buffer
138
+ array_buf+="${line%%]*}"
139
+ local _s="$array_buf"
140
+ case "$collecting_arr" in
141
+ scopes)
142
+ while [[ "$_s" =~ '"([^"]+)"' ]]; do
143
+ CUSTOM_SCOPES+=("${BASH_REMATCH[1]}")
144
+ _s="${_s#*"${BASH_REMATCH[0]}"}"
145
+ done
146
+ ;;
147
+ allowBreakingChanges)
148
+ while [[ "$_s" =~ '"([^"]+)"' ]]; do
149
+ RULE_ALLOW_BREAKING+=("${BASH_REMATCH[1]}")
150
+ _s="${_s#*"${BASH_REMATCH[0]}"}"
151
+ done
152
+ [[ ${#RULE_ALLOW_BREAKING[@]} -eq 0 ]] && RULE_ALLOW_BREAKING=("feat" "fix")
153
+ ;;
154
+ esac
155
+ collecting_arr=""; array_buf=""
156
+ else
157
+ array_buf+="$line"
158
+ fi
159
+ continue
160
+ fi
161
+
162
+ # ── customTypes object items ───────────────────────────────────────────
163
+ if [[ "$section" == "customTypes" ]]; then
164
+ if [[ "$line" =~ ^[[:space:]]*\] ]]; then
165
+ section=""; in_obj=0; continue
166
+ fi
167
+ # inline object: { "type": "wip", "emoji": "🚧", ... } on one line
168
+ if [[ "$line" =~ ^[[:space:]]*\{.*\} ]]; then
169
+ local _it="" _ie="⚙️ " _is="none" _id="Custom type" _tmp="$line"
170
+ while [[ "$_tmp" =~ $_p_kv ]]; do
171
+ case "${BASH_REMATCH[1]}" in
172
+ type) _it="${BASH_REMATCH[2]}" ;;
173
+ emoji) _ie="${BASH_REMATCH[2]}" ;;
174
+ semver) _is="${BASH_REMATCH[2]}" ;;
175
+ description) _id="${BASH_REMATCH[2]}" ;;
176
+ esac
177
+ _tmp="${_tmp#*"${BASH_REMATCH[0]}"}"
178
+ done
179
+ [[ -n "$_it" ]] && TYPES+=("${_it}|${_ie}|${_is}|${_id}")
180
+ continue
181
+ fi
182
+ # multi-line object: { opens on its own line
183
+ if [[ "$line" =~ ^[[:space:]]*\{ ]]; then
184
+ in_obj=1
185
+ type_val=""; emoji_val="⚙️ "; semver_val="none"; desc_val="Custom type"
186
+ continue
187
+ fi
188
+ if [[ $in_obj -eq 1 ]]; then
189
+ if [[ "$line" =~ ^[[:space:]]*\} ]]; then
190
+ [[ -n "$type_val" ]] && TYPES+=("${type_val}|${emoji_val}|${semver_val}|${desc_val}")
191
+ in_obj=0; continue
192
+ fi
193
+ if [[ "$line" =~ $_p_kv ]]; then
194
+ case "${BASH_REMATCH[1]}" in
195
+ type) type_val="${BASH_REMATCH[2]}" ;;
196
+ emoji) emoji_val="${BASH_REMATCH[2]}" ;;
197
+ semver) semver_val="${BASH_REMATCH[2]}" ;;
198
+ description) desc_val="${BASH_REMATCH[2]}" ;;
199
+ esac
200
+ fi
201
+ fi
202
+ fi
203
+
204
+ # ── rules scalar fields ────────────────────────────────────────────────
205
+ if [[ "$section" == "rules" ]]; then
206
+ if [[ "$line" =~ ^[[:space:]]*\} ]]; then
207
+ section=""; continue
208
+ fi
209
+ # match: "key": true|false|number — no subprocess needed
210
+ if [[ "$line" =~ '"([^"]+)"'[[:space:]]*:[[:space:]]*(true|false|[0-9]+) ]]; then
211
+ case "${BASH_REMATCH[1]}" in
212
+ maxHeaderLength) RULE_MAX_HEADER="${BASH_REMATCH[2]}" ;;
213
+ requireScope) [[ "${BASH_REMATCH[2]}" == "true" ]] && RULE_REQUIRE_SCOPE=1 ;;
214
+ disallowUpperCaseDescription) [[ "${BASH_REMATCH[2]}" == "true" ]] && RULE_DISALLOW_UPPER=1 ;;
215
+ disallowTrailingPeriod) [[ "${BASH_REMATCH[2]}" == "true" ]] && RULE_DISALLOW_PERIOD=1 ;;
216
+ esac
217
+ fi
218
+ fi
219
+
220
+ done < "$cfg"
221
+ }
222
+
223
+ # type selection UI
224
+ # arrow-key picker
225
+ # Usage: _pick <result_var> <prompt> <item1> <item2> ...
226
+ # Ssts result_var to chosen index (0-based). Renders on /dev/tty.
227
+ _pick() {
228
+ local _var="$1" _prompt="$2"; shift 2
229
+ local -a _items=("$@")
230
+ local _n=${#_items[@]} _cur=0 _top=0 _view=7 _k1 _k2 _k3
231
+
232
+ exec 9<>/dev/tty
233
+
234
+ local _old
235
+ _old=$(stty -g <&9 2>/dev/null) || { exec 9>&-; _pick_fallback "$_var" "$_prompt" "${_items[@]}"; return; }
236
+ stty -echo -icanon min 1 time 0 <&9 2>/dev/null || { exec 9>&-; _pick_fallback "$_var" "$_prompt" "${_items[@]}"; return; }
237
+
238
+ trap "stty '$_old' <&9 2>/dev/null; printf '\033[?25h' >&9 2>/dev/null; exec 9>&-" EXIT INT TERM
239
+
240
+ _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 ))
245
+ [[ $_top -lt 0 ]] && _top=0 || true
246
+ elif [[ $_cur -lt $_top ]]; then
247
+ _top=$(( _cur ))
248
+ fi
249
+ }
250
+
251
+ _pick_move_down() {
252
+ _cur=$(( (_cur + 1) % _n ))
253
+ if [[ $_cur -ge $(( _top + _view )) ]]; then
254
+ _top=$(( _cur - _view + 1 ))
255
+ elif [[ $_cur -eq 0 ]]; then
256
+ # wrapped from bottom to top
257
+ _top=0
258
+ fi
259
+ }
260
+
261
+ # draw the visible window of _view items starting at _top
262
+ _draw() {
263
+ local _i _end
264
+ _end=$(( _top + _view ))
265
+ [[ $_end -gt $_n ]] && _end=$_n
266
+ local _visible=$(( _end - _top ))
267
+
268
+ # scroll hint above
269
+ if [[ $_top -gt 0 ]]; then
270
+ printf " ${MUTED}↑ %d more${RESET}\033[K\n" "$_top" >&9
271
+ else
272
+ printf "\033[K\n" >&9
273
+ fi
274
+
275
+ for (( _i=_top; _i<_end; _i++ )); do
276
+ if (( _i == _cur )); then
277
+ printf " ${ACCENT_BOLD}❯${RESET} %s${RESET}\033[K\n" "${_items[$_i]}" >&9
278
+ else
279
+ printf " ${MUTED}·${RESET} ${MUTED}%s${RESET}\033[K\n" "${_items[$_i]}" >&9
280
+ fi
281
+ done
282
+
283
+ # scroll hint below
284
+ local _remaining=$(( _n - _end ))
285
+ if [[ $_remaining -gt 0 ]]; then
286
+ printf " ${MUTED}↓ %d more${RESET}\033[K\n" "$_remaining" >&9
287
+ else
288
+ printf "\033[K\n" >&9
289
+ fi
290
+ }
291
+
292
+ # total drawn lines = _view items + 2 hint lines — fixed for this pick session
293
+ local _dl=$(( _view < _n ? _view + 2 : _n + 2 ))
294
+
295
+ printf "\033[?25l" >&9
296
+ printf "\n ${ACCENT_BOLD}%s${RESET}\n\n" "$_prompt" >&9
297
+ _draw
298
+
299
+ while true; do
300
+ IFS= read -rsn1 _k1 <&9
301
+ case "$_k1" in
302
+ $'\033')
303
+ IFS= read -rsn1 -t1 _k2 <&9 || true
304
+ IFS= read -rsn1 -t1 _k3 <&9 || true
305
+ if [[ "$_k2" == "[" ]]; then
306
+ case "$_k3" in
307
+ A) _pick_move_up ;;
308
+ B) _pick_move_down ;;
309
+ esac
310
+ fi
311
+ ;;
312
+ $'\r'|$'\n'|'') break ;;
313
+ k) _pick_move_up ;;
314
+ j) _pick_move_down ;;
315
+ q|$'\003') stty "$_old" <&9 2>/dev/null; trap - EXIT INT TERM; exec 9>&-; exit 1 ;;
316
+ esac
317
+ printf "\033[%dA" "$_dl" >&9
318
+ _draw
319
+ done
320
+
321
+ # collapse to just the selected line
322
+ printf "\033[%dA" "$_dl" >&9
323
+ printf "\033[J" >&9
324
+ printf " ${ACCENT_BOLD}❯${RESET} ${BOLD}%s${RESET}\n" "${_items[$_cur]}" >&9
325
+
326
+ stty "$_old" <&9 2>/dev/null
327
+ trap - EXIT INT TERM
328
+ printf "\033[?25h" >&9
329
+ exec 9>&-
330
+
331
+ printf -v "$_var" '%d' "$_cur"
332
+ }
333
+
334
+
335
+ _pick_fallback() {
336
+ local _var="$1" _prompt="$2"; shift 2
337
+ local -a _items=("$@") _i=1 _choice
338
+ printf "\n\033[1m%s\033[0m\n\n" "$_prompt" >/dev/tty
339
+ for _item in "${_items[@]}"; do
340
+ printf " %2d) %s\n" "$_i" "$_item" >/dev/tty
341
+ _i=$(( _i + 1 ))
342
+ done
343
+ printf "\nChoice (1-%d): " "${#_items[@]}" >/dev/tty
344
+ read -r _choice </dev/tty
345
+ [[ "$_choice" =~ ^[0-9]+$ ]] && (( _choice >= 1 && _choice <= ${#_items[@]} )) \
346
+ && printf -v "$_var" '%d' "$(( _choice - 1 ))" \
347
+ || printf -v "$_var" '%d' 0
348
+ }
349
+
350
+
351
+ # type selection
352
+ select_type() {
353
+ load_config
354
+ local -a labels keys
355
+ for entry in "${TYPES[@]}"; do
356
+ local t e s d impact
357
+ IFS='|' read -r t e s d <<< "$entry"
358
+ case "$s" in
359
+ minor) impact="${GREEN}minor${RESET}" ;;
360
+ patch) impact="${YELLOW}patch${RESET}" ;;
361
+ *) impact="" ;;
362
+ esac
363
+ # fixed 9-char visual slot for the badge so descriptions always align
364
+ local _badge
365
+ case "$s" in
366
+ minor) _badge=" ${MUTED}[${RESET}${GREEN}minor${RESET}${MUTED}]${RESET}" ;;
367
+ patch) _badge=" ${MUTED}[${RESET}${YELLOW}patch${RESET}${MUTED}]${RESET}" ;;
368
+ *) _badge=" " ;; # 9 spaces — same visual width as [minor]
369
+ esac
370
+ local _tp; printf -v _tp '%-10s' "$t"
371
+ labels+=("$e ${BOLD}${_tp}${RESET}${_badge} ${MUTED}$d${RESET}")
372
+ keys+=("$t")
373
+ done
374
+ local idx
375
+ _pick idx "Select commit type:" "${labels[@]}"
376
+ SELECTED_TYPE="${keys[$idx]}"
377
+ }
378
+
379
+ # scope selection
380
+ select_scope() {
381
+ if [[ ${#CUSTOM_SCOPES[@]} -gt 0 ]]; then
382
+ local -a labels
383
+ for _sc in "${CUSTOM_SCOPES[@]}"; do
384
+ labels+=("${BOLD}$_sc${RESET}")
385
+ done
386
+ labels+=("${MUTED}─ custom${RESET} ${MUTED}type your own${RESET}")
387
+ labels+=("${MUTED}─ skip${RESET}")
388
+ local idx
389
+ _pick idx "scope" "${labels[@]}"
390
+ local _custom_idx=$(( ${#CUSTOM_SCOPES[@]} ))
391
+ local _skip_idx=$(( ${#CUSTOM_SCOPES[@]} + 1 ))
392
+ if (( idx < _custom_idx )); then
393
+ # predefined scope chosen
394
+ SELECTED_SCOPE="${CUSTOM_SCOPES[$idx]}"
395
+ return 0
396
+ elif (( idx == _custom_idx )); then
397
+ # custom free-text
398
+ printf "\n ${ACCENT_BOLD}scope${RESET} ${MUTED}enter custom scope${RESET} ${ACCENT}›${RESET} " >/dev/tty
399
+ read -r SELECTED_SCOPE </dev/tty
400
+ return 0
401
+ else
402
+ # skip
403
+ SELECTED_SCOPE=""
404
+ return 0
405
+ fi
406
+ fi
407
+ # no configured scopes — free-text input
408
+ printf "\n ${ACCENT_BOLD}scope${RESET} ${MUTED}leave blank to omit${RESET} ${ACCENT}›${RESET} " >/dev/tty
409
+ read -r SELECTED_SCOPE </dev/tty
410
+ }
411
+
412
+ # description input
413
+ input_description() {
414
+ printf "\n ${ACCENT_BOLD}description${RESET} ${MUTED}imperative, present tense${RESET}\n" >/dev/tty
415
+ local desc
416
+ while true; do
417
+ printf " ${ACCENT}›${RESET} " >/dev/tty
418
+ read -r desc </dev/tty
419
+ if [[ -z "$desc" ]]; then
420
+ warn "Description is required"
421
+ elif (( ${#desc} > 100 )); then
422
+ warn "Description is ${#desc} chars — keep it under 72 for readability"
423
+ printf " ${YELLOW}use anyway?${RESET} ${MUTED}[y/N]${RESET} ${ACCENT}›${RESET} " >/dev/tty
424
+ read -r confirm </dev/tty
425
+ [[ "$confirm" =~ ^[yY]$ ]] && { SELECTED_DESC="$desc"; return 0; }
426
+ else
427
+ SELECTED_DESC="$desc"
428
+ return 0
429
+ fi
430
+ done
431
+ }
432
+
433
+ # body input
434
+ input_body() {
435
+ printf "\n ${ACCENT_BOLD}body${RESET} ${MUTED}optional — press Enter to skip${RESET}\n" >/dev/tty
436
+ printf " ${ACCENT}›${RESET} " >/dev/tty
437
+ local line
438
+ read -r line </dev/tty
439
+ SELECTED_BODY="$line"
440
+ }
441
+
442
+ # breaking change
443
+ input_breaking() {
444
+ printf "\n ${ACCENT_BOLD}breaking change?${RESET} ${MUTED}[y/N]${RESET} ${ACCENT}›${RESET} " >/dev/tty
445
+ read -r ans </dev/tty
446
+ if [[ "$ans" =~ ^[yY]$ ]]; then
447
+ IS_BREAKING=1
448
+ printf " ${RED}breaking change${RESET} ${ACCENT}›${RESET} " >/dev/tty
449
+ read -r BREAKING_DESC </dev/tty
450
+ else
451
+ IS_BREAKING=0
452
+ BREAKING_DESC=""
453
+ fi
454
+ }
455
+
456
+ # footer (issue refs)
457
+ input_footer() {
458
+ printf "\n ${ACCENT_BOLD}footer${RESET} ${MUTED}e.g. Closes #42 — leave blank to skip${RESET}\n" >/dev/tty
459
+ printf " ${ACCENT}›${RESET} " >/dev/tty
460
+ read -r SELECTED_FOOTER </dev/tty
461
+ }
462
+
463
+ # assemble commit message
464
+ assemble_message() {
465
+ local header
466
+ if [[ -n "$SELECTED_SCOPE" ]]; then
467
+ header="${SELECTED_TYPE}(${SELECTED_SCOPE})"
468
+ else
469
+ header="${SELECTED_TYPE}"
470
+ fi
471
+
472
+ if [[ $IS_BREAKING -eq 1 ]]; then
473
+ header="${header}!"
474
+ fi
475
+
476
+ header="${header}: ${SELECTED_DESC}"
477
+
478
+ local msg="$header"
479
+
480
+ if [[ -n "$SELECTED_BODY" ]]; then
481
+ msg="${msg}"$'\n\n'"${SELECTED_BODY}"
482
+ fi
483
+
484
+ if [[ $IS_BREAKING -eq 1 && -n "$BREAKING_DESC" ]]; then
485
+ msg="${msg}"$'\n\n'"BREAKING CHANGE: ${BREAKING_DESC}"
486
+ fi
487
+
488
+ if [[ -n "$SELECTED_FOOTER" ]]; then
489
+ msg="${msg}"$'\n\n'"${SELECTED_FOOTER}"
490
+ fi
491
+
492
+ COMMIT_MESSAGE="$msg"
493
+ }
494
+
495
+ # confirm and commit
496
+ confirm_and_commit() {
497
+ printf "\n ${MUTED}╭─ commit message ──────────────────────────────╮${RESET}\n" >/dev/tty
498
+ while IFS= read -r _line; do
499
+ printf " ${MUTED}│${RESET} ${BOLD}%s${RESET}\n" "$_line" >/dev/tty
500
+ done <<< "$COMMIT_MESSAGE"
501
+ printf " ${MUTED}╰───────────────────────────────────────────────╯${RESET}\n" >/dev/tty
502
+
503
+ printf "\n ${ACCENT_BOLD}commit?${RESET} ${MUTED}[Y/n/e]${RESET} ${ACCENT}›${RESET} " >/dev/tty
504
+ read -r ans </dev/tty
505
+
506
+ case "$ans" in
507
+ ""|y|Y|yes|Yes|YES)
508
+ git commit -m "$COMMIT_MESSAGE"
509
+ printf "\n"; success "${BOLD}committed${RESET}"
510
+ ;;
511
+ e|E|edit|Edit|EDIT)
512
+ local tmpfile
513
+ tmpfile=$(mktemp /tmp/cmt-commit-XXXXXX)
514
+ echo "$COMMIT_MESSAGE" > "$tmpfile"
515
+ "${EDITOR:-vi}" "$tmpfile"
516
+ COMMIT_MESSAGE=$(cat "$tmpfile")
517
+ rm -f "$tmpfile"
518
+ git commit -m "$COMMIT_MESSAGE"
519
+ printf "\n"; success "${BOLD}committed${RESET}"
520
+ ;;
521
+ *)
522
+ warn "Commit aborted."
523
+ exit 0
524
+ ;;
525
+ esac
526
+ }
527
+
528
+ # cmd: commit
529
+ cmd_commit() {
530
+ # --write-only: used by prepare-commit-msg hook — run picker, print message to
531
+ # stdout, do NOT call git commit (git will do that after the hook returns).
532
+ local write_only=0
533
+ [[ "${1:-}" == "--write-only" ]] && write_only=1
534
+
535
+ if [[ $write_only -eq 0 ]]; then
536
+ git rev-parse --git-dir > /dev/null 2>&1 || die "Not inside a git repository"
537
+ if git diff --cached --quiet 2>/dev/null; then
538
+ warn "No staged changes detected. Did you forget \`git add\`?"
539
+ printf "${DIM}Continue anyway? [y/N]:${RESET} " >/dev/tty
540
+ read -r cont </dev/tty
541
+ [[ "$cont" =~ ^[yY]$ ]] || exit 0
542
+ fi
543
+ fi
544
+
545
+ printf "\n ${ACCENT_BOLD}cmt${RESET} ${MUTED}conventional commits v%s${RESET}\n" "$CC_VERSION" >/dev/tty
546
+ printf " ${MUTED}─────────────────────────────────────${RESET}\n" >/dev/tty
547
+
548
+ SELECTED_TYPE="" SELECTED_SCOPE="" SELECTED_DESC=""
549
+ SELECTED_BODY="" SELECTED_FOOTER="" IS_BREAKING=0 BREAKING_DESC=""
550
+
551
+ select_type
552
+ select_scope
553
+ input_description
554
+ input_body
555
+ input_breaking
556
+ input_footer
557
+ assemble_message
558
+
559
+ if [[ $write_only -eq 1 ]]; then
560
+ # show preview on /dev/tty (git won't capture it — only stdout goes to the msg file)
561
+ printf "\n ${MUTED}╭─ commit message ──────────────────────────────╮${RESET}\n" >/dev/tty
562
+ while IFS= read -r _line; do
563
+ printf " ${MUTED}│${RESET} ${BOLD}%s${RESET}\n" "$_line" >/dev/tty
564
+ done <<< "$COMMIT_MESSAGE"
565
+ printf " ${MUTED}╰───────────────────────────────────────────────╯${RESET}\n\n" >/dev/tty
566
+ printf '%s' "$COMMIT_MESSAGE"
567
+ return 0
568
+ fi
569
+
570
+ confirm_and_commit
571
+ }
572
+
573
+ # cmd: lint
574
+ cmd_lint() {
575
+ local msg_file="${1:-}"
576
+ local msg
577
+
578
+ if [[ -n "$msg_file" ]]; then
579
+ [[ -f "$msg_file" ]] || die "File not found: $msg_file"
580
+ msg=$(cat "$msg_file")
581
+ else
582
+ printf "${BOLD}Paste commit message to lint:${RESET}\n"
583
+ msg=$(cat)
584
+ fi
585
+
586
+ lint_message "$msg"
587
+ }
588
+
589
+ lint_message() {
590
+ local msg="$1"
591
+ # extract first line — pure bash, no subprocess
592
+ local header="${msg%%$'\n'*}"
593
+
594
+ local errors=() warnings=()
595
+ load_config
596
+
597
+ # rule #1: header format
598
+ local _p_hdr='^[a-z][a-z0-9_-]*(\([^)]*\))?!?:[[:space:]].+'
599
+ if ! [[ "$header" =~ $_p_hdr ]]; then
600
+ errors+=("Header must match: type(scope)?: description")
601
+ else
602
+ # rule #2: valid type — extract via BASH_REMATCH from a fresh match
603
+ local _p_type='^([a-z][a-z0-9_-]*)'
604
+ local commit_type=""
605
+ [[ "$header" =~ $_p_type ]] && commit_type="${BASH_REMATCH[1]}"
606
+ local valid_type=0
607
+ for entry in "${TYPES[@]}"; do
608
+ local t; t="${entry%%|*}"
609
+ [[ "$t" == "$commit_type" ]] && { valid_type=1; break; }
610
+ done
611
+ [[ $valid_type -eq 0 ]] && errors+=("Unknown type '${commit_type}'. Valid: $(IFS=, ; echo "${TYPES[*]%%|*}")")
612
+
613
+ # rule #3: description not empty — strip type(scope)!: prefix via parameter expansion
614
+ local desc="${header#*: }"
615
+ [[ -z "$desc" ]] && errors+=("Description must not be empty")
616
+
617
+ # rule #4: description not capitalized
618
+ if [[ "${desc:0:1}" =~ [A-Z] ]]; then
619
+ if [[ $RULE_DISALLOW_UPPER -eq 1 ]]; then
620
+ errors+=("Description must start with a lowercase letter")
621
+ else
622
+ warnings+=("Description starts with uppercase — prefer lowercase")
623
+ fi
624
+ fi
625
+
626
+ # rule #5: no trailing period
627
+ if [[ "$desc" =~ \.$ ]]; then
628
+ if [[ $RULE_DISALLOW_PERIOD -eq 1 ]]; then
629
+ errors+=("Description must not end with a period")
630
+ else
631
+ warnings+=("Description ends with a period — omit it")
632
+ fi
633
+ fi
634
+
635
+ # rule #6: header length
636
+ (( ${#header} > RULE_MAX_HEADER )) && warnings+=("Header is ${#header} chars — aim for ≤${RULE_MAX_HEADER}")
637
+
638
+ # rule #7: requireScope
639
+ if [[ $RULE_REQUIRE_SCOPE -eq 1 ]]; then
640
+ local _p_scope='^[a-z][a-z0-9_-]*\([^)]+\)'
641
+ [[ "$header" =~ $_p_scope ]] || errors+=("Scope is required — e.g. feat(auth): ...")
642
+ fi
643
+ fi
644
+
645
+ # rule #8: blank line between header and body — pure bash string ops
646
+ if [[ "$msg" == *$'\n'* ]]; then
647
+ local _rest="${msg#*$'\n'}"
648
+ local _second="${_rest%%$'\n'*}"
649
+ [[ -n "$_second" ]] && errors+=("Blank line required between header and body")
650
+ fi
651
+
652
+ # report
653
+ local exit_code=0
654
+ printf "\n ${ACCENT_BOLD}lint${RESET} ${MUTED}%s${RESET}\n\n" "$header"
655
+
656
+ if [[ ${#errors[@]} -gt 0 ]]; then
657
+ for e in "${errors[@]}"; do
658
+ printf " ${RED}✖${RESET} %s\n" "$e"
659
+ done
660
+ exit_code=1
661
+ fi
662
+
663
+ if [[ ${#warnings[@]} -gt 0 ]]; then
664
+ for w in "${warnings[@]}"; do
665
+ printf " ${YELLOW}⚑${RESET} ${YELLOW}%s${RESET}\n" "$w"
666
+ done
667
+ fi
668
+
669
+ if [[ ${#errors[@]} -eq 0 && ${#warnings[@]} -eq 0 ]]; then
670
+ success "valid conventional commit"
671
+ elif [[ ${#errors[@]} -eq 0 ]]; then
672
+ printf "\n"; success "valid — with ${#warnings[@]} suggestion(s)"
673
+ fi
674
+
675
+ printf "\n"
676
+ return $exit_code
677
+ }
678
+
679
+ # hook snippet
680
+ # The only hook cmt installs is prepare-commit-msg.
681
+ # It intercepts plain `git commit`, runs the picker, writes the message.
682
+ # commit-msg is not needed — the message is already conventional when we write it.
683
+ _hook_snippet() {
684
+ local _cmt_path="$1"
685
+ # variables prefixed with \ are meant for the hook at runtime (not expanded now).
686
+ # _cmt_path IS expanded now; it records the install-time absolute path.
687
+ cat << SNIPPET
688
+ # >>> cmt — Conventional Commits CLI
689
+ [ -n "\${2:-}" ] && exit 0
690
+ for _p in "${_cmt_path}" "\$HOME/.local/bin/cmt" "/usr/local/bin/cmt" "/opt/homebrew/bin/cmt"; do
691
+ [ -x "\$_p" ] && { "\$_p" commit --write-only > "\$1"; exit 0; }
692
+ done
693
+ # <<< cmt
694
+ SNIPPET
695
+ }
696
+
697
+ # lint snippet (commit-msg hook)
698
+ _lint_snippet() {
699
+ local _cmt_path="$1"
700
+ cat << SNIPPET
701
+ # >>> cmt-lint — Conventional Commits CLI
702
+ for _p in "${_cmt_path}" "\$HOME/.local/bin/cmt" "/usr/local/bin/cmt" "/opt/homebrew/bin/cmt"; do
703
+ [ -x "\$_p" ] && { "\$_p" lint "\$1"; exit \$?; }
704
+ done
705
+ # <<< cmt-lint
706
+ SNIPPET
707
+ }
708
+
709
+ # install or append cmt block to a hook file
710
+ # usage: _install_hook <path> <needs_shebang> <cmt_path> [snippet_fn]
711
+ _install_hook() {
712
+ local path="$1" shebang="${2:-1}" cc_path="$3" snippet_fn="${4:-_hook_snippet}"
713
+ local marker; [[ "$snippet_fn" == "_lint_snippet" ]] && marker="cmt-lint" || marker="cmt"
714
+
715
+ if [[ -f "$path" ]]; then
716
+ if grep -q ">>> ${marker}" "$path" 2>/dev/null; then
717
+ local tmp; tmp=$(mktemp)
718
+ awk "/# >>> ${marker}/{skip=1} !skip{print} /# <<< ${marker}/{skip=0}" "$path" > "$tmp"
719
+ "$snippet_fn" "$cc_path" >> "$tmp"
720
+ mv "$tmp" "$path"
721
+ success "Updated ${marker} block in $(basename "$(dirname "$path")")/$(basename "$path")"
722
+ else
723
+ "$snippet_fn" "$cc_path" >> "$path"
724
+ success "Appended to $(basename "$(dirname "$path")")/$(basename "$path")"
725
+ fi
726
+ else
727
+ [[ "$shebang" == "1" ]] && printf '#!/usr/bin/env bash\n' > "$path"
728
+ "$snippet_fn" "$cc_path" >> "$path"
729
+ chmod +x "$path"
730
+ success "Created $(basename "$(dirname "$path")")/$(basename "$path")"
731
+ fi
732
+ chmod +x "$path"
733
+ }
734
+
735
+ # cmd: log
736
+ cmd_log() {
737
+ git rev-parse --git-dir > /dev/null 2>&1 || die "Not inside a git repository"
738
+ load_config
739
+
740
+ local limit="${1:-20}"
741
+ printf "\n ${ACCENT_BOLD}log${RESET} ${MUTED}last %s commits${RESET}\n\n" "$limit"
742
+
743
+ local _p_log_type='^([a-z][a-z0-9_-]*)[(:!]'
744
+ while IFS='|' read -r hash short_hash date author subject; do
745
+ local type="" emoji color
746
+ [[ "$subject" =~ $_p_log_type ]] && type="${BASH_REMATCH[1]}"
747
+ emoji="·"; color="$RESET"
748
+
749
+ if [[ -n "$type" ]]; then
750
+ for entry in "${TYPES[@]}"; do
751
+ local t e
752
+ IFS='|' read -r t e _ _ <<< "$entry"
753
+ if [[ "$t" == "$type" ]]; then emoji="$e"; break; fi
754
+ done
755
+ case "$type" in
756
+ feat) color="$GREEN" ;;
757
+ fix|perf) color="$YELLOW" ;;
758
+ revert) color="$RED" ;;
759
+ *) color="$MUTED" ;;
760
+ esac
761
+ fi
762
+
763
+ local breaking=""
764
+ [[ "$subject" == *"!:"* ]] && breaking=" ${RED}breaking${RESET}"
765
+
766
+ printf " ${MUTED}%s${RESET} %s ${color}${BOLD}%s${RESET}%s\n" "$short_hash" "$emoji" "$subject" "$breaking"
767
+ printf " ${MUTED}%s %s${RESET}\n\n" "$date" "$author"
768
+
769
+ done < <(git log -"$limit" --format="%H|%h|%ad|%an|%s" --date=short 2>/dev/null)
770
+ }
771
+
772
+ # cmd: types
773
+ cmd_types() {
774
+ load_config
775
+ printf "\n ${ACCENT_BOLD}commit types${RESET}\n\n"
776
+ for entry in "${TYPES[@]}"; do
777
+ local t e s d badge
778
+ IFS='|' read -r t e s d <<< "$entry"
779
+ badge=""
780
+ case "$s" in
781
+ minor) badge=" ${GREEN}minor${RESET}" ;;
782
+ patch) badge=" ${YELLOW}patch${RESET}" ;;
783
+ esac
784
+ printf " %s ${BOLD}%-12s${RESET}${badge} ${MUTED}%s${RESET}\n" "$e" "$t" "$d"
785
+ done
786
+ printf "\n"
787
+ if [[ -f "$CC_CONFIG_FILE" ]]; then
788
+ printf " ${MUTED}custom types from .cmt.json included above${RESET}\n\n"
789
+ else
790
+ printf " ${MUTED}run cmt init to configure custom types${RESET}\n\n"
791
+ fi
792
+ }
793
+
794
+
795
+ # cmd: init
796
+ cmd_init() {
797
+ git rev-parse --git-dir > /dev/null 2>&1 || die "Not inside a git repository"
798
+
799
+ local git_root cc_path
800
+ git_root=$(git rev-parse --show-toplevel)
801
+ cc_path=$(realpath "$0")
802
+
803
+ # .cmt.json
804
+ if [[ ! -f "${git_root}/${CC_CONFIG_FILE}" ]]; then
805
+ cat > "${git_root}/${CC_CONFIG_FILE}" << JSONEOF
806
+ {
807
+ "\$schema": "${CC_SCHEMA_URL}",
808
+ "version": "1.0.0",
809
+ "customTypes": [
810
+ { "type": "wip", "emoji": "🚧", "semver": "none", "description": "Work in progress" }
811
+ ],
812
+ "scopes": [],
813
+ "rules": {
814
+ "maxHeaderLength": 72,
815
+ "requireScope": false,
816
+ "allowBreakingChanges": ["feat", "fix"],
817
+ "disallowUpperCaseDescription": false,
818
+ "disallowTrailingPeriod": false
819
+ }
820
+ }
821
+ JSONEOF
822
+ success "Created .cmt.json"
823
+ fi
824
+
825
+ local use_husky=0 use_lint=0
826
+ for arg in "$@"; do
827
+ [[ "$arg" == "--husky" ]] && use_husky=1 || true
828
+ [[ "$arg" == "--lint" ]] && use_lint=1 || true
829
+ done
830
+
831
+ if [[ $use_husky -eq 1 ]]; then
832
+ mkdir -p "${git_root}/.husky"
833
+ _install_hook "${git_root}/.husky/prepare-commit-msg" 0 "$cc_path"
834
+ if [[ $use_lint -eq 1 ]]; then
835
+ _install_hook "${git_root}/.husky/commit-msg" 0 "$cc_path" _lint_snippet
836
+ fi
837
+ printf " ${DIM}Commit .husky/ hooks to share with your team.${RESET}\n"
838
+ else
839
+ _install_hook "${git_root}/.git/hooks/prepare-commit-msg" 1 "$cc_path"
840
+ if [[ $use_lint -eq 1 ]]; then
841
+ _install_hook "${git_root}/.git/hooks/commit-msg" 1 "$cc_path" _lint_snippet
842
+ fi
843
+ fi
844
+
845
+ printf "\n"
846
+ success "Done."
847
+ printf " ${DIM}git commit or cmt commit — both work${RESET}\n\n"
848
+ }
849
+
850
+ # cmd: uninstall
851
+ cmd_uninstall() {
852
+ git rev-parse --git-dir > /dev/null 2>&1 || die "Not inside a git repository"
853
+ local git_root; git_root=$(git rev-parse --show-toplevel)
854
+ local removed=0
855
+
856
+ # remove a named cmt block from a hook file.
857
+ # deletes the file entirely if the block was the only content.
858
+ _remove_block() {
859
+ local path="$1" marker="$2"
860
+ [[ -f "$path" ]] || return 0
861
+ grep -q "# >>> ${marker} " "$path" 2>/dev/null || return 0
862
+
863
+ local tmp
864
+ tmp=$(mktemp)
865
+ # match opening line as prefix ("# >>> cmt " catches "cmt" but not "cmt-lint")
866
+ awk "/^# >>> ${marker} /{skip=1} !skip{print} /^# <<< ${marker}\$/{skip=0}" "$path" > "$tmp"
867
+
868
+ local has_content
869
+ has_content=$(grep -v '^#!' "$tmp" | grep -v '^#' | grep -cv '^[[:space:]]*$' || true)
870
+ if [[ "$has_content" -eq 0 ]]; then
871
+ rm "$path"
872
+ success "Removed $(basename "$(dirname "$path")")/$(basename "$path")"
873
+ else
874
+ mv "$tmp" "$path"
875
+ success "Removed ${marker} block from $(basename "$(dirname "$path")")/$(basename "$path")"
876
+ fi
877
+ rm -f "$tmp"
878
+ removed=1
879
+ }
880
+
881
+ for dir in "${git_root}/.git/hooks" "${git_root}/.husky"; do
882
+ _remove_block "${dir}/prepare-commit-msg" "cmt"
883
+ _remove_block "${dir}/commit-msg" "cmt-lint"
884
+ done
885
+
886
+ # clean up legacy commit-msg hooks from older cmt versions
887
+ for path in "${git_root}/.git/hooks/commit-msg" "${git_root}/.husky/commit-msg"; do
888
+ if [[ -f "$path" ]] && grep -q "Installed by cmt" "$path" 2>/dev/null; then
889
+ rm "$path"
890
+ success "Removed legacy $(basename "$path")"
891
+ removed=1
892
+ fi
893
+ done
894
+
895
+ # remove .cmt.json config
896
+ if [[ -f "${git_root}/${CC_CONFIG_FILE}" ]]; then
897
+ rm "${git_root}/${CC_CONFIG_FILE}"
898
+ success "Removed .cmt.json"
899
+ removed=1
900
+ fi
901
+
902
+ [[ $removed -eq 0 ]] && info "Nothing to remove."
903
+ printf "\n"
904
+ }
905
+
906
+
907
+ # cmd: version
908
+ cmd_version() {
909
+ printf "cmt version %s\n" "$CC_VERSION"
910
+ }
911
+
912
+ # cmd: help
913
+ cmd_help() {
914
+ printf "
915
+ ${BOLD}${CYAN}cmt${RESET} v${CC_VERSION} — Conventional Commits CLI
916
+
917
+ ${BOLD}USAGE${RESET}
918
+ cmt <command> [options]
919
+
920
+ ${BOLD}COMMANDS${RESET}
921
+ ${CYAN}init${RESET} [--husky] [--lint] Create .cmt.json + install prepare-commit-msg hook
922
+ --husky write to .husky/ (husky v9, committable)
923
+ --lint also install commit-msg linting hook
924
+ default write to .git/hooks/ (local)
925
+
926
+ ${CYAN}commit${RESET} Interactive commit builder
927
+
928
+ ${CYAN}lint${RESET} [file] Lint a commit message file or stdin
929
+
930
+ ${CYAN}log${RESET} [n] Pretty log — last n commits (default: 20)
931
+
932
+ ${CYAN}types${RESET} List available types
933
+
934
+ ${CYAN}uninstall${RESET} Remove cmt-managed hooks
935
+
936
+ ${BOLD}EXAMPLES${RESET}
937
+ cmt init # set up repo — done in one step
938
+ cmt init --husky # same, but writes to .husky/ for husky v9
939
+ cmt commit # guided commit
940
+ echo 'fix: typo' | cmt lint
941
+ git log --format=%s | cmt lint # lint every commit on a branch
942
+
943
+ ${BOLD}INTEGRATIONS${RESET}
944
+ Husky v9 cmt init --husky → .husky/prepare-commit-msg (commit it, team shares it)
945
+ lint-staged add to .husky/pre-commit: npx lint-staged
946
+ GitHub CI echo \"\$(git log -1 --format=%s)\" | cmt lint
947
+
948
+ ${BOLD}CONFIG${RESET} .cmt.json (JSON Schema → intellisense in VS Code / JetBrains)
949
+ {
950
+ \"customTypes\": [{ \"type\": \"wip\", \"emoji\": \"🚧\", \"semver\": \"none\", \"description\": \"...\" }],
951
+ \"scopes\": [\"auth\", \"api\", \"ui\"],
952
+ \"rules\": { \"maxHeaderLength\": 72, \"requireScope\": false }
953
+ }
954
+
955
+ "
956
+ }
957
+
958
+ # dispatch
959
+ main() {
960
+ case "${1:-help}" in
961
+ commit|c) shift; cmd_commit "$@" ;;
962
+ lint|l) shift; cmd_lint "$@" ;;
963
+ init) shift; cmd_init "$@" ;;
964
+ log) shift; cmd_log "$@" ;;
965
+ types|t) shift; cmd_types "$@" ;;
966
+ uninstall) shift; cmd_uninstall "$@" ;;
967
+ version|-v|--version) cmd_version ;;
968
+ help|-h|--help|*) cmd_help ;;
969
+ esac
970
+ }
971
+
972
+ main "$@"