-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathcfme
More file actions
970 lines (814 loc) · 30.9 KB
/
cfme
File metadata and controls
970 lines (814 loc) · 30.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
#!/usr/bin/env bash
# This script was generated by bashly 1.3.3 (https://bashly.dev)
# Modifying it manually is not recommended
# :wrapper.bash3_bouncer
if ((BASH_VERSINFO[0] < 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] < 2))); then
printf "bash version 4.2 or higher is required\n" >&2
exit 1
fi
# :command.master_script
# :command.root_command
root_command() {
# src/root_command.sh
instructions_override=${args[--instructions]:-}
prompt_file_override=${args["--prompt-file"]:-}
variables_file_override=${args["--variables-file"]:-}
flag_print_response=${args[--response]:-}
flag_print_message=${args[--message]:-}
flag_print_prompt=${args["--print-parsed-prompt"]:-}
# We check whether there are staged changes to commit, and exit if there are none.
git_diff="$(git diff --cached)"
validate_staged_changes "$git_diff"
# We load the prompt and variable file paths, applying any overrides provided by the user.
read prompt_file_path variable_file_path < <(get_file_paths "$prompt_file_override" "$variables_file_override")
prompt="$(<"$prompt_file_path")"
# We inform the user about the process that is about to take place.
print_if_not_silent "Commit for me will now attempt to generate a commit message based on your staged changes."
print_if_not_silent "Using prompt file at '$prompt_file_path'"
print_if_not_silent "Using prompt variables file at '$variable_file_path'"
# We declare a map to hold the variables from the prompt variables file
declare -A vars_map
load_vars_map vars_map "$variable_file_path" "$instructions_override"
validate_vars_map vars_map "$prompt"
# Substitute variables in the prompt
substituted_prompt=$(render_prompt "$prompt" vars_map)
print_if_not_silent "The prompt substitution was successful."
print_if_not_silent "Now fetching the message candidates from the AI... (this may take a moment)"
if [[ "$flag_print_prompt" == "1" ]]; then
echo "$substituted_prompt"
return 0
fi
response=$(fetch_and_wait_for_ai_response "$substituted_prompt")
if [[ "$flag_print_response" == "1" ]]; then
echo "$response"
return 0
fi
# Now print message (no blank line)
print_if_not_silent "AI response received. Presenting options for selection..."
# Parse AI response and let user select a commit message
mapfile -t headers < <(extract_headers_from_response "$response")
selected_header="$(pick_from_headers headers "$response")"
selected_entry="$(select_entry "$selected_header" "$response")"
# Build the commit message and open it in the user's editor for review/editing
commit_message="$(build_commit_message "$selected_entry")"
# Finally, open it for review, and commit or abort based on user input
edit_and_commit_or_abort "$commit_message" "$flag_print_message"
if [[ $? -ne 0 ]]; then
return 1
fi
}
# :command.version_command
version_command() {
echo "$version"
}
# :command.usage
cfme_usage() {
printf "cfme - Commit For Me - Generate commit messages for staged files using aichat\n\n"
printf "%s\n" "Usage:"
printf " cfme [OPTIONS]\n"
printf " cfme --help | -h\n"
printf " cfme --version\n"
echo
# :command.long_usage
if [[ -n "$long_usage" ]]; then
printf "%s\n" "Options:"
# :command.usage_flags
# :flag.usage
printf " %s\n" "--instructions, -i INSTRUCTIONS"
printf " An optional brief set of instructions to pass the AI, to help the AI\n generate commit message candidates. Replaces template string\n <__INSTRUCTIONS__> in the prompt file.\n"
echo
# :flag.usage
printf " %s\n" "--prompt-file, -p FILE_PATH"
printf " Overrides the default prompt by reading from a specified file.\n"
echo
# :flag.usage
printf " %s\n" "--variables-file, -v FILE_PATH"
printf " Overrides path for the prompt variables file (\$CFME_PROMPT_VARIABLES_FILE)\n by reading from a specified file.\n"
echo
# :flag.usage
printf " %s\n" "--response, -r"
printf " Prints the direct response from the AI, instead of prompting to review\n response and then committing. Used for piping the response into other tools.\n"
printf " %s\n" "Conflicts: --message"
echo
# :flag.usage
printf " %s\n" "--message, -m"
printf " Prints the reviewed commit message instead of committing. Used for piping\n the reviewed message into other tools.\n"
printf " %s\n" "Conflicts: --response"
echo
# :flag.usage
printf " %s\n" "--silent, -s"
printf " Suppresses all non-error output. Also possible through the\n \$CFME_SILENT_MODE environment variable.\n"
echo
# :flag.usage
printf " %s\n" "--print-parsed-prompt"
printf " Prints the parsed prompt after replacing template strings with variable\n values, then exits. Useful for debugging.\n"
echo
# :command.usage_fixed_flags
printf " %s\n" "--help, -h"
printf " Show this help\n"
echo
printf " %s\n" "--version"
printf " Show version number\n"
echo
# :command.usage_environment_variables
printf "%s\n" "Environment Variables:"
# :environment_variable.usage
printf " %s\n" "CFME_CONFIG_DIR"
printf " Directory to store configuration files for Commit For Me\n"
printf " %s\n" "Default: ${XDG_CONFIG_HOME:-$HOME/.config}/cfme"
echo
# :environment_variable.usage
printf " %s\n" "CFME_PROMPT_DIR"
printf " Directory to store prompt files for Commit For Me\n"
printf " %s\n" "Default: ${CFME_CONFIG_DIR}/prompts"
echo
# :environment_variable.usage
printf " %s\n" "CFME_PROMPT_TYPE"
printf " The default subfolder to process prompts from.\n"
printf " %s\n" "Default: conventional-commits"
echo
# :environment_variable.usage
printf " %s\n" "CFME_DEFAULT_PROMPT_FILE"
printf " Path to the default prompt file for generating commit messages (overridden\n by --file flag)\n"
printf " %s\n" "Default: ${CFME_PROMPT_DIR}/${CFME_PROMPT_TYPE}/default.md"
echo
# :environment_variable.usage
printf " %s\n" "CFME_DEFAULT_PROMPT_VARIABLES_FILE"
printf " Path to the file containing prompt variables.\n"
printf " %s\n" "Default: ${CFME_PROMPT_DIR}/${CFME_PROMPT_TYPE}/default-vars.yaml"
echo
# :environment_variable.usage
printf " %s\n" "CFME_SILENT_MODE"
printf " If set to true, suppresses all non-error output.\n"
printf " %s\n" "Default: false"
echo
# :command.usage_examples
printf "%s\n" "Examples:"
printf " Generate, review, and commit using the defaults:\n cfme\n Ditto, overriding the <__INSTRUCTIONS__> template string: (useful for helping\n the AI figure out what msg to generate)\n cfme -i \"Fix issue with user login\"\n Ditto, overriding the default prompt file:\n cfme -p \"./my-custom-prompt.md\"\n Ditto, overriding the default variables file:\n cfme -v \"./my-custom-variables.yaml\"\n Generate and review a commit message, but echo it instead of committing:\n cfme -m\n Generate a response to the prompt, then echo the raw response instead of\n reviewing and committing, suppressing all non-error output:\n cfme -rs\n Generates a raw response to the prompt, replacing the <__INSTRUCTIONS__>\n template string, using a custom prompt file and custom variables file,\n suppressing all non-error output:\n cfme -si \"<Instructions>\" -p \"./my-prompt.md\" -v \"./my-variables.yaml\"\n"
echo
fi
}
# :command.normalize_input
# :command.normalize_input_function
normalize_input() {
local arg passthru flags
passthru=false
while [[ $# -gt 0 ]]; do
arg="$1"
if [[ $passthru == true ]]; then
input+=("$arg")
elif [[ $arg =~ ^(--[a-zA-Z0-9_\-]+)=(.+)$ ]]; then
input+=("${BASH_REMATCH[1]}")
input+=("${BASH_REMATCH[2]}")
elif [[ $arg =~ ^(-[a-zA-Z0-9])=(.+)$ ]]; then
input+=("${BASH_REMATCH[1]}")
input+=("${BASH_REMATCH[2]}")
elif [[ $arg =~ ^-([a-zA-Z0-9][a-zA-Z0-9]+)$ ]]; then
flags="${BASH_REMATCH[1]}"
for ((i = 0; i < ${#flags}; i++)); do
input+=("-${flags:i:1}")
done
elif [[ "$arg" == "--" ]]; then
passthru=true
input+=("$arg")
else
input+=("$arg")
fi
shift
done
}
# :command.inspect_args
inspect_args() {
if ((${#args[@]})); then
readarray -t sorted_keys < <(printf '%s\n' "${!args[@]}" | sort)
echo args:
for k in "${sorted_keys[@]}"; do
echo "- \${args[$k]} = ${args[$k]}"
done
else
echo args: none
fi
if ((${#deps[@]})); then
readarray -t sorted_keys < <(printf '%s\n' "${!deps[@]}" | sort)
echo
echo deps:
for k in "${sorted_keys[@]}"; do
echo "- \${deps[$k]} = ${deps[$k]}"
done
fi
if ((${#env_var_names[@]})); then
readarray -t sorted_names < <(printf '%s\n' "${env_var_names[@]}" | sort)
echo
echo "environment variables:"
for k in "${sorted_names[@]}"; do
echo "- \$$k = ${!k:-}"
done
fi
}
# :command.user_lib
# src/lib/build_commit_message.sh
build_commit_message() {
local selected_entry="$1"
local tmpfile=$(mktemp)
echo "# Below is the generated commit message. Alter as needed." >"$tmpfile"
echo "# Be sure to review the body and footer if present." >>"$tmpfile"
echo "# If you want to commit with this message, save this file and exit your editor." >>"$tmpfile"
echo "# If you want to cancel the commit, exit without saving, or save as an empty file." >>"$tmpfile"
echo "#" >>"$tmpfile"
local git_diff_name_status="$(git diff --name-status --cached)"
echo "# Staged changes:" >>"$tmpfile"
echo "$git_diff_name_status" | while read -r line; do
echo "# $line" >>"$tmpfile"
done
# Extract header, body, footer
header=$(echo "$selected_entry" | yq eval '.header' -)
body=$(echo "$selected_entry" | yq eval '.body // ""' -)
footer=$(echo "$selected_entry" | yq eval '.footer // ""' -)
# Build commit message according to rules
{
echo "$header"
[[ -n "$body" ]] && echo -e "\n$body"
[[ -n "$footer" ]] && echo -e "\n$footer"
} >>"$tmpfile"
cat $tmpfile
rm -f "$tmpfile"
}
# src/lib/edit_and_commit_or_abort.sh
function edit_and_commit_or_abort() {
local message_template="$1"
local flag_print_message="$2"
tmpfile=$(mktemp)
echo "$message_template" >"$tmpfile"
# Record the file's modification time in miliseconds before editing
original_mtime_ms=$(date -r "$tmpfile" +%s%3N)
${EDITOR:-vi} "$tmpfile"
# Quit if editor was exited with non-zero status
if [[ $? -ne 0 ]]; then
echo "Editor exited with non-zero status, aborting." >&2
rm -f "$tmpfile"
return 1
fi
# Check the file's modification time after editing
new_mtime_ms=$(date -r "$tmpfile" +%s%3N)
if [[ "$original_mtime_ms" -eq "$new_mtime_ms" ]]; then
echo "File was not saved, aborting." >&2
rm -f "$tmpfile"
return 1
fi
# Quit if editor leaves the commit message empty
commit_msg="$(<"$tmpfile")"
if [[ -z "$commit_msg" ]]; then
echo "Commit message is empty, aborting." >&2
rm -f "$tmpfile"
return 1
fi
if [[ $flag_print_message == "1" ]]; then
echo "$commit_msg"
rm -f "$tmpfile"
return 0
fi
git commit --cleanup=strip -F "$tmpfile"
rm -f "$tmpfile"
}
# src/lib/extract_headers_from_response.sh
extract_headers_from_response() {
local response="$1"
echo "$response" | yq eval '.commitMessages[] | "\(.score) \(.header)"' -
}
# src/lib/fetch_ai_response.sh
fetch_ai_response() {
local substituted_prompt="$1"
aichat "$(echo "$substituted_prompt")"
if [ $? -ne 0 ]; then
echo "Error: Failed to fetch AI response." >&2
return 1
fi
}
# src/lib/fetch_and_wait_for_ai_response.sh
# src/lib/fetch_and_wait_for_ai_response.sh
spinner() {
local pid=$1
local delay=0.1
local spin_chars=(⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏)
local i=0
while kill -0 "$pid" 2>/dev/null; do
printf "\r%s" "${spin_chars[$i]}" >&2
i=$(((i + 1) % ${#spin_chars[@]}))
sleep "$delay"
done
}
clear_spinner() {
printf "\r\033[K" >&2
}
fetch_and_wait_for_ai_response() {
local prompt="$1"
local response_file
local error_file
if [[ "$SILENT_RUN" == "true" ]]; then
fetch_ai_response "$prompt"
return $?
fi
response_file=$(mktemp)
error_file=$(mktemp)
# Run the actual AI fetch (stderr captured to file)
fetch_ai_response "$prompt" >"$response_file" 2>"$error_file" &
local fetch_pid=$!
spinner "$fetch_pid" &
local spinner_pid=$!
wait "$fetch_pid"
local fetch_status=$?
# Stop spinner cleanly
kill "$spinner_pid" 2>/dev/null
wait "$spinner_pid" 2>/dev/null || true
clear_spinner
if [[ $fetch_status -ne 0 ]]; then
# Print captured error output once
if [[ -s "$error_file" ]]; then
echo >&2
cat "$error_file" >&2
echo >&2
fi
rm -f "$response_file" "$error_file"
return 1
fi
cat "$response_file"
rm -f "$response_file" "$error_file"
}
# src/lib/fetch_custom_default_file_if_not_exist.sh
fetch_custom_default_file_if_not_exist() {
local custom_default_file_path="$1"
local default_file_path_variable_name="$2"
local custom_default_file_fetch_url="$3"
local default_file_fetch_url_variable_name="$4"
local official_default_file_fetch_url="$5"
if [[ -f "$custom_default_file_path" ]]; then
return 0
fi
echo "$default_file_path_variable_name ('$custom_default_file_path') does not exist." >&2
if [[ "$custom_default_file_fetch_url" == "$official_default_file_fetch_url" ]]; then
echo "Since you have \$CFME_PROMPT_TYPE set to '$CFME_PROMPT_TYPE' instead of 'conventional-commits', I cannot fetch a default file for you." >&2
echo "Please create your own default file, or set a custom \$$default_file_fetch_url_variable_name" >&2
return 1
fi
echo "Since you have \$CFME_PROMPT_TYPE set to '$CFME_PROMPT_TYPE' instead of 'conventional-commits', and have set a custom \$$default_file_fetch_url_variable_name ('$custom_default_file_fetch_url'), I can try to fetch the default prompt file from there." >&2
choice="$(get_choice "Are you sure this is what you want to do? (y/n): ")"
case "$choice" in
y | Y)
echo "Okay then, fetching the file..." >&2
fetch_file "$custom_default_file_path" "$custom_default_file_fetch_url"
;;
n | N)
echo "Exiting..." >&2
return 1
;;
*)
echo "Invalid choice. Exiting..." >&2
return 1
;;
esac
}
# src/lib/fetch_default_file.sh
# Function to fetch a default file if it doesn't exist
fetch_default_file() {
local file_path="$1"
local fetch_url="$2"
local prompt_type="$3"
local default_url="$4"
local file_description="$5"
local recommended_name="$6"
if [ ! -f "$file_path" ]; then
print_if_not_silent "Tried to find $file_description at '$file_path', but it does not exist. (This is normal during the very first run.)"
# If prompt type is non-standard
if [ "$prompt_type" != "conventional-commits" ]; then
if [ "$fetch_url" != "$default_url" ]; then
echo "I have noticed that you have a non-standard \$CFME_PROMPT_TYPE ('$prompt_type') and no local $file_description." &>2
echo "You have also set a custom fetch URL:" &>2
echo " $fetch_url" &>2
echo "If you intend to fetch your own custom defaults, that’s fine." &>2
echo "Otherwise, please unset the custom fetch URL environment variable." &>2
read -p "Are you sure this is what you want to do? (y/n): " choice
case "$choice" in
y | Y) echo "Okay then, continuing..." &>2 ;;
n | N)
echo "Exiting..." &>2
exit 1
;;
*)
echo "Invalid choice. Exiting..." &>2
exit 1
;;
esac
else
echo "Oops! You may have made a mistake." &>2
echo "Your \$CFME_PROMPT_TYPE is '$prompt_type'." &>2
echo "The expected $file_description path '$file_path' does not exist." &>2
echo "Since this is a non-standard prompt type (the standard is '$CFME_PROMPT_TYPE'), I am not able to fetch any defaults for you. So, please create your own file at the following recommended path:" &>2
echo " \$CFME_PROMPT_DIR/\$CFME_PROMPT_TYPE/$recommended_name" &>2
echo "(For you, this resolves to '$CFME_PROMPT_DIR/$CFME_PROMPT_TYPE/$recommended_name')." &>2
echo "If you really want to, you can use any path for \$CFME_DEFAULT_PROMPT_FILE or \$CFME_DEFAULT_PROMPT_VARIABLES_FILE, in that case, do make sure that those files exist." &>2
exit 1
fi
fi
print_if_not_silent "Fetching default $file_description from $fetch_url ..."
curl -fsSL -o "$file_path" "$fetch_url"
if [ $? -eq 0 ]; then
print_if_not_silent "Default $file_description fetched successfully."
else
echo "Failed to fetch default $file_description." &>2
exit 1
fi
print_if_not_silent "The default $file_description now lives at $file_path"
print_if_not_silent ""
fi
}
# src/lib/fetch_file.sh
fetch_file() {
local file_path="$1"
local fetch_url="$2"
print_if_not_silent "Fetching file from $fetch_url ..."
curl -fsSL -o "$file_path" "$fetch_url"
if [ $? -eq 0 ]; then
print_if_not_silent "File fetched successfully and saved to '$file_path'."
return 0
fi
echo "Failed to fetch file from '$fetch_url'." >&2
return 1
}
# src/lib/fetch_file_if_not_exist.sh
fetch_file_if_not_exist() {
local file_path="$1"
local fetch_url="$2"
if [ ! -f "$file_path" ]; then
print_if_not_silent "File '$file_path' does not exist."
fetch_file "$file_path" "$fetch_url"
return $?
fi
}
# src/lib/get_choice.sh
get_choice() {
local prompt_message="$1"
local choice
read -p "$prompt_message (y/n): " choice
echo "$choice"
}
# src/lib/get_file_paths.sh
get_file_paths() {
local prompt_file_override="$1"
local variables_file_override="$2"
echo "${prompt_file_override:-$CFME_DEFAULT_PROMPT_FILE} ${variables_file_override:-$CFME_DEFAULT_PROMPT_VARIABLES_FILE}"
}
# src/lib/load_vars_map.sh
# Loads variables from a YAML file into an associative array
# Usage: load_vars_map <array_name> <vars_file>
# Example:
# declare -A my_vars
# load_vars_map my_vars "/path/to/vars.yaml"
load_vars_map() {
local -n referenced_map="$1"
local vars_file="$2"
local instructions_override="$3"
# Get number of vars
local count
count=$(yq eval '.vars | length' "$vars_file")
# Iterate over vars and populate vars_map
for i in $(seq 0 $((count - 1))); do
local key value cmd
# Get template_string
key=$(yq eval ".vars[$i].template_string" "$vars_file")
# Get value or value_from
if yq eval -e ".vars[$i].value_from" "$vars_file" >/dev/null 2>&1; then
# value_from: execute shell command(s) and capture output as value
cmd=$(yq eval ".vars[$i].value_from" "$vars_file" | sed 's/^[[:space:]]*//')
value="$(eval "$cmd")"
else
# value: literal string
value=$(yq eval -o=yaml ".vars[$i].value" "$vars_file")
fi
# Save to the map
referenced_map["$key"]="$value"
done
# Add defaults to the map
referenced_map["<__GIT_DIFF__>"]="$(git diff --cached)"
referenced_map["<__RESPONSE_FORMAT_REQUIREMENTS__>"]="$(
cat <<'EOF'
**Response Format Requirements:**
- Each commit message MUST include a 'header'.
- Optionally include 'body' and 'footer', ONLY if a 'header'
wouldn't suffice to fully describe all changes.
- Attempt to describe everything in the 'header' whenever possible.
- Respond ONLY in YAML format as demonstrated in the following codeblock:
```yaml
commitMessages:
- header: "<required header>"
body: "<optional body>"
footer: "<optional footer>"
score: <integer 0-100 representing confidence that this is the best commit message>
```
- Do NOT include any explanations or additional text outside of the YAML response.
- The response must be valid YAML.
- The response may not contain any markdown formatting (so no codeblocks).
EOF
)"
# if instructions override is not empty, override instructions key.
# else, do not override potential instructions from file with empty string.
if [[ -n "$instructions_override" ]]; then
referenced_map["<__INSTRUCTIONS__>"]="$instructions_override"
else
# if reference_map["<__INSTRUCTIONS__>"] was set through the vars file, keep it.
# else, set it to empty string.
# this is to allow people to provide a default instructions var if they want to.
if [[ -z "${referenced_map["<__INSTRUCTIONS__>"]+_}" ]]; then
referenced_map["<__INSTRUCTIONS__>"]=""
fi
fi
}
# src/lib/pick_from_headers.sh
pick_from_headers() {
local -n headers_ref=$1
local response="$2"
# Quit if no headers are provided
if [[ ${#headers_ref[@]} -eq 0 ]]; then
echo "No headers available to pick from. Maybe the AI response is empty or incorrectly formatted? You can test this by running the command again with the -r flag." >&2
return 1
fi
# Prerender all full messages to a temp file
local temp_file=$(mktemp)
local count=1 # Start at 1 to match nl numbering
while IFS= read -r header; do
local header_only="${header#* }" # Remove score prefix
echo "=== MESSAGE $count ===" >>"$temp_file"
echo "$response" | yq eval ".commitMessages[] | select(.header==\"$header_only\") | .header" - >>"$temp_file"
local body=$(echo "$response" | yq eval ".commitMessages[] | select(.header==\"$header_only\") | .body // \"\"" -)
local footer=$(echo "$response" | yq eval ".commitMessages[] | select(.header==\"$header_only\") | .footer // \"\"" -)
[[ -n "$body" && "$body" != "null" ]] && echo "" >>"$temp_file" && echo "$body" >>"$temp_file"
[[ -n "$footer" && "$footer" != "null" ]] && echo "" >>"$temp_file" && echo "$footer" >>"$temp_file"
echo "" >>"$temp_file"
((count++))
done < <(printf '%s\n' "${headers_ref[@]}" | sort -rn)
# Add a final marker to ensure the last message is captured correctly
echo "=== END ===" >>"$temp_file"
selected_line=$(printf '%s\n' "${headers_ref[@]}" | sort -rn | sed 's/^[0-9]* //' | nl -w1 -s' | ' | fzf --ansi \
--prompt="Select commit message: " \
--preview "num=\$(echo {} | grep -o '^[0-9]*'); sed -n \"/=== MESSAGE \$num ===/,/=== MESSAGE/p\" '$temp_file' | head -n -1 | tail -n +2" \
--preview-window=wrap)
local exit_code=$?
rm -f "$temp_file"
[[ -z "$selected_line" ]] && {
echo "No selection, aborting." >&2
return 1
}
# Remove the rank number and separator
echo "${selected_line}" | sed 's/^[0-9]* | //'
}
# src/lib/print_if_not_silent.sh
print_if_not_silent() {
if [[ "$SILENT_RUN" != "true" ]]; then
echo "$1" >&2
fi
}
# src/lib/render_prompt.sh
render_prompt() {
local prompt="$1"
local -n referenced_map="$2"
local substituted_prompt="$prompt"
for key in "${!referenced_map[@]}"; do
substituted_prompt="${substituted_prompt//$key/${referenced_map[$key]}}"
done
echo "$substituted_prompt"
}
# src/lib/select_entry.sh
select_entry() {
local selected_header="$1"
local response="$2"
echo "$(echo "$response" | yq eval ".commitMessages[] | select(.header==\"$selected_header\")" -)"
}
# src/lib/validate_staged_changes.sh
validate_staged_changes() {
local git_diff="$1"
if [[ -z "$git_diff" ]]; then
echo "Error: No staged changes found. Please stage your changes before committing." >&2
return 1
fi
}
# src/lib/validate_vars_map.sh
function validate_vars_map() {
local -n referenced_map="$1"
local prompt="$2"
# Validate: ensure all placeholders in prompt are in referenced_map
for tmpl in $(grep -oP '<__[^>]+__>' <<<"$prompt" | sort -u); do
if [[ -z "${referenced_map[$tmpl]+_}" ]]; then
echo "Error: custom template string '$tmpl' was found in the prompt file, but is not present in the variables file." >&2
return 1
fi
done
# Validate: ensure all keys in referenced_map exist in prompt
for key in "${!referenced_map[@]}"; do
if [[ "$key" == "<__INSTRUCTIONS__>" ]]; then
# This key is optional
continue
fi
if ! grep -q "$key" <<<"$prompt"; then
if [[ "$key" == "<__RESPONSE_FORMAT_REQUIREMENTS__>" || "$key" == "<__GIT_DIFF__>" ]]; then
# This key is mandatory
echo "Error: key '$key' is essential for cfme to function, but not present in the prompt." >&2
return 1
fi
echo "Error: custom key '$key' was found in variables, but not present in the prompt." >&2
return 1
fi
done
}
# :command.command_functions
# :command.parse_requirements
parse_requirements() {
# :command.fixed_flags_filter
while [[ $# -gt 0 ]]; do
key="$1"
case "$key" in
--version)
version_command
exit
;;
--help | -h)
long_usage=yes
cfme_usage
exit
;;
*)
break
;;
esac
done
# :command.environment_variables_filter
# :command.environment_variables_default
export CFME_CONFIG_DIR="${CFME_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/cfme}"
export CFME_PROMPT_DIR="${CFME_PROMPT_DIR:-${CFME_CONFIG_DIR}/prompts}"
export CFME_PROMPT_TYPE="${CFME_PROMPT_TYPE:-conventional-commits}"
export CFME_DEFAULT_PROMPT_FILE="${CFME_DEFAULT_PROMPT_FILE:-${CFME_PROMPT_DIR}/${CFME_PROMPT_TYPE}/default.md}"
export CFME_DEFAULT_PROMPT_VARIABLES_FILE="${CFME_DEFAULT_PROMPT_VARIABLES_FILE:-${CFME_PROMPT_DIR}/${CFME_PROMPT_TYPE}/default-vars.yaml}"
export CFME_SILENT_MODE="${CFME_SILENT_MODE:-false}"
export CFME_DEFAULT_PROMPT_FILE_FETCH_URL="${CFME_DEFAULT_PROMPT_FILE_FETCH_URL:-https://raw.githubusercontent.com/codevogel/commit-for-me/refs/heads/main/defaults/prompts/conventional-commits/default.md}"
export CFME_DEFAULT_PROMPT_VARIABLES_FILE_FETCH_URL="${CFME_DEFAULT_PROMPT_VARIABLES_FILE_FETCH_URL:-https://raw.githubusercontent.com/codevogel/commit-for-me/refs/heads/main/defaults/prompts/conventional-commits/default-vars.yaml}"
env_var_names+=("CFME_CONFIG_DIR")
env_var_names+=("CFME_PROMPT_DIR")
env_var_names+=("CFME_PROMPT_TYPE")
env_var_names+=("CFME_DEFAULT_PROMPT_FILE")
env_var_names+=("CFME_DEFAULT_PROMPT_VARIABLES_FILE")
env_var_names+=("CFME_SILENT_MODE")
env_var_names+=("CFME_DEFAULT_PROMPT_FILE_FETCH_URL")
env_var_names+=("CFME_DEFAULT_PROMPT_VARIABLES_FILE_FETCH_URL")
# :command.command_filter
action="root"
# :command.parse_requirements_while
while [[ $# -gt 0 ]]; do
key="$1"
case "$key" in
# :flag.case
--instructions | -i)
# :flag.case_arg
if [[ -n ${2+x} ]]; then
args['--instructions']="$2"
shift
shift
else
printf "%s\n" "--instructions requires an argument: --instructions, -i INSTRUCTIONS" >&2
exit 1
fi
;;
# :flag.case
--prompt-file | -p)
# :flag.case_arg
if [[ -n ${2+x} ]]; then
args['--prompt-file']="$2"
shift
shift
else
printf "%s\n" "--prompt-file requires an argument: --prompt-file, -p FILE_PATH" >&2
exit 1
fi
;;
# :flag.case
--variables-file | -v)
# :flag.case_arg
if [[ -n ${2+x} ]]; then
args['--variables-file']="$2"
shift
shift
else
printf "%s\n" "--variables-file requires an argument: --variables-file, -v FILE_PATH" >&2
exit 1
fi
;;
# :flag.case
--response | -r)
# :flag.conflicts
if [[ -n "${args['--message']:-}" ]]; then
printf "conflicting options: %s cannot be used with %s\n" "$key" "--message" >&2
exit 1
fi
# :flag.case_no_arg
args['--response']=1
shift
;;
# :flag.case
--message | -m)
# :flag.conflicts
if [[ -n "${args['--response']:-}" ]]; then
printf "conflicting options: %s cannot be used with %s\n" "$key" "--response" >&2
exit 1
fi
# :flag.case_no_arg
args['--message']=1
shift
;;
# :flag.case
--silent | -s)
# :flag.case_no_arg
args['--silent']=1
shift
;;
# :flag.case
--print-parsed-prompt)
# :flag.case_no_arg
args['--print-parsed-prompt']=1
shift
;;
-?*)
printf "invalid option: %s\n" "$key" >&2
exit 1
;;
*)
# :command.parse_requirements_case
# :command.parse_requirements_case_simple
printf "invalid argument: %s\n" "$key" >&2
exit 1
;;
esac
done
}
# :command.user_hooks
before_hook() {
# src/before.sh
# Generate directories for configuration
mkdir -p $CFME_CONFIG_DIR
mkdir -p $CFME_PROMPT_DIR
mkdir -p $CFME_PROMPT_DIR/$CFME_PROMPT_TYPE
flag_silent=${args[--silent]:-}
if [[ $flag_silent == "1" ]]; then
export SILENT_RUN="true"
elif [[ "$CFME_SILENT_MODE" == "true" ]]; then
export SILENT_RUN="true"
fi
if [[ "$CFME_PROMPT_TYPE" == "conventional-commits" ]]; then
fetch_file_if_not_exist "$CFME_DEFAULT_PROMPT_FILE" "$CFME_DEFAULT_PROMPT_FILE_FETCH_URL"
fetch_file_if_not_exist "$CFME_DEFAULT_PROMPT_VARIABLES_FILE" "$CFME_DEFAULT_PROMPT_VARIABLES_FILE_FETCH_URL"
return 0
fi
fetch_custom_default_file_if_not_exist \
"$CFME_DEFAULT_PROMPT_FILE" \
"\$CFME_DEFAULT_PROMPT_FILE" \
"$CFME_DEFAULT_PROMPT_FILE_FETCH_URL" \
"\$CFME_DEFAULT_PROMPT_FILE_FETCH_URL" \
"https://raw.githubusercontent.com/codevogel/commit-for-me/refs/heads/main/defaults/prompts/conventional-commits/default.md"
fetch_custom_default_file_if_not_exist \
"$CFME_DEFAULT_PROMPT_VARIABLES_FILE" \
"\$CFME_DEFAULT_PROMPT_VARIABLES_FILE" \
"$CFME_DEFAULT_PROMPT_VARIABLES_FILE_FETCH_URL" \
"\$CFME_DEFAULT_PROMPT_VARIABLES_FILE_FETCH_URL" \
"https://raw.githubusercontent.com/codevogel/commit-for-me/refs/heads/main/defaults/prompts/conventional-commits/default-vars.yaml"
}
# :command.initialize
initialize() {
declare -g version="0.1.1"
set -e
# :command.environment_variables_default
export CFME_CONFIG_DIR="${CFME_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/cfme}"
export CFME_PROMPT_DIR="${CFME_PROMPT_DIR:-${CFME_CONFIG_DIR}/prompts}"
export CFME_PROMPT_TYPE="${CFME_PROMPT_TYPE:-conventional-commits}"
export CFME_DEFAULT_PROMPT_FILE="${CFME_DEFAULT_PROMPT_FILE:-${CFME_PROMPT_DIR}/${CFME_PROMPT_TYPE}/default.md}"
export CFME_DEFAULT_PROMPT_VARIABLES_FILE="${CFME_DEFAULT_PROMPT_VARIABLES_FILE:-${CFME_PROMPT_DIR}/${CFME_PROMPT_TYPE}/default-vars.yaml}"
export CFME_SILENT_MODE="${CFME_SILENT_MODE:-false}"
export CFME_DEFAULT_PROMPT_FILE_FETCH_URL="${CFME_DEFAULT_PROMPT_FILE_FETCH_URL:-https://raw.githubusercontent.com/codevogel/commit-for-me/refs/heads/main/defaults/prompts/conventional-commits/default.md}"
export CFME_DEFAULT_PROMPT_VARIABLES_FILE_FETCH_URL="${CFME_DEFAULT_PROMPT_VARIABLES_FILE_FETCH_URL:-https://raw.githubusercontent.com/codevogel/commit-for-me/refs/heads/main/defaults/prompts/conventional-commits/default-vars.yaml}"
}
# :command.run
run() {
# :command.globals
declare -g long_usage=''
declare -g -A args=()
declare -g -A deps=()
declare -g -a env_var_names=()
declare -g -a input=()
normalize_input "$@"
parse_requirements "${input[@]}"
before_hook
case "$action" in
"root") root_command ;;
esac
}
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
# :command.start
command_line_args=("$@")
initialize
run "${command_line_args[@]}"
fi