gitbasher 5.1.0 → 5.2.1

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 (4) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +16 -40
  3. package/dist/gitb +481 -177
  4. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,31 @@
2
2
 
3
3
  All notable changes to gitbasher are generated automatically by [semantic-release](https://github.com/semantic-release/semantic-release) from [Conventional Commits](https://www.conventionalcommits.org/) on `main`. The project follows [Semantic Versioning](https://semver.org/).
4
4
 
5
+ ## [5.2.1](https://github.com/maxbolgarin/gitbasher/compare/v5.2.0...v5.2.1) (2026-07-11)
6
+
7
+ ### 🐛 Bug Fixes
8
+
9
+ * **commit:** gate AI split by scope count and stop discarding near-complete groupings ([90becec](https://github.com/maxbolgarin/gitbasher/commit/90becec0f49a7a3e57e57e11a71b6909884ab337))
10
+
11
+ ## [5.2.0](https://github.com/maxbolgarin/gitbasher/compare/v5.1.0...v5.2.0) (2026-07-07)
12
+
13
+ ### 🚀 Features
14
+
15
+ * **ai:** one model per provider with live pickers, reasoning control, and setup safety checks ([a9c6935](https://github.com/maxbolgarin/gitbasher/commit/a9c6935297e84b777c8428203dae7003ef93aa38))
16
+
17
+ ### 🐛 Bug Fixes
18
+
19
+ * **ai:** extract bare scope from full type(scope) prefixes ([18da7e4](https://github.com/maxbolgarin/gitbasher/commit/18da7e4648a0dda2aec2453305bcdf95110feea7))
20
+ * **config:** move values to global scope instead of shadowing them locally ([648ef37](https://github.com/maxbolgarin/gitbasher/commit/648ef3735dfd6c1aa248369d51bdb57ad42c3353))
21
+
22
+ ### 📚 Documentation
23
+
24
+ * describe per-provider AI models, live model menus, and reasoning behavior ([dab3cef](https://github.com/maxbolgarin/gitbasher/commit/dab3cef479d619e59e69b6d4f3a75514e7b6d00d))
25
+
26
+ ### 🚨 Tests
27
+
28
+ * keep [@test](https://github.com/test) descriptions ASCII for bats under bash 3.2 ([1e3fb50](https://github.com/maxbolgarin/gitbasher/commit/1e3fb50071d927b8475178ffdae68afcf7ebc19a))
29
+
5
30
  ## [5.1.0](https://github.com/maxbolgarin/gitbasher/compare/v5.0.1...v5.1.0) (2026-07-03)
6
31
 
7
32
  ### 🚀 Features
package/README.md CHANGED
@@ -111,7 +111,7 @@ Every command has a short alias (`gitb c`, `gitb p`, `gitb pu`, `gitb b`, `gitb
111
111
  - **AI commit messages** — `gitb c ai` writes the message from your diff. OpenRouter (Gemini/Claude/GPT/…), OpenAI direct, or **fully local via Ollama** — no key, no network, no data leaves your machine.
112
112
  - **Safer git** — push/pull detect conflicts up front, `undo` rolls back commit/amend/merge/rebase/stash, `reset` is interactive with a preview.
113
113
  - **Whole workflows, not just commands** — `sync`, `wip`, `branch newd`, `merge to-main`, `squash` chain the steps you'd otherwise do by hand.
114
- - **One file, no deps** — pure bash for every git operation; only the AI features need `jq` and `curl`. Drop the binary anywhere on `PATH` and go. **900+ BATS tests** cover sanitization, git ops, and branch logic.
114
+ - **One file, no deps** — pure bash for every git operation; only the AI features need `jq` and `curl`. Drop the binary anywhere on `PATH` and go. **940+ BATS tests** cover sanitization, git ops, and branch logic.
115
115
 
116
116
  <p align="center">
117
117
  <img src=".github/push.gif" width="640" alt="gitb push demo" />
@@ -253,50 +253,26 @@ export GITB_AI_API_KEY='sk-...'
253
253
  <details>
254
254
  <summary><b>Default models per provider</b> (click to expand)</summary>
255
255
 
256
- Each task uses a model tuned for speed/cost/quality, picked per provider. Defaults (May 2026):
256
+ One model per provider, used for every task (message generation, subjects, feature grouping). Defaults (July 2026), picked speed-first:
257
257
 
258
- **OpenRouter** (default provider)
258
+ | Provider | Default model | Why |
259
+ |----------|---------------|-----|
260
+ | `openrouter` | `google/gemini-3.5-flash` | Current flash generation, ~0.8s median on a commit-sized prompt, good prose |
261
+ | `openai` | `gpt-5.4-mini` | As fast as nano (~0.8s) with strictly better quality, ~$0.75 / $4.50 per M |
262
+ | `ollama` | `qwen3:8b` | Best small instruction-follower; most stable structured output; ~5 GB on disk |
263
+ | `claude` | `haiku` | CLI startup dominates latency, so the fastest model wins; alias tracks the current generation |
259
264
 
260
- | Task | Default model | Why |
261
- |------|---------------|-----|
262
- | `simple` (one-line message) | `google/gemini-3.1-flash-lite` | Fastest model measured (~0.6s), cheapest |
263
- | `subject` (after manual type/scope) | `google/gemini-3.1-flash-lite` | Short structured output, instant |
264
- | `full` (header + body) | `google/gemini-3.5-flash` | Same speed class, better prose |
265
- | `grouping` (atomic-split mapping) | `google/gemini-3.5-flash` | Fast + strict structured output |
265
+ Other good picks: `gpt-5.4-nano` (budget, high volume), `llama3.3:8b` / `qwen2.5-coder:7b` (local), `sonnet` (better prose on the claude provider, noticeably slower).
266
266
 
267
- **OpenAI** — GPT-5.4 family (released March 2026)
267
+ The cloud defaults are hybrid reasoning models that think at "medium" effort out of the box — pure latency for a one-line commit message. gitbasher controls this per request: message generation runs at the provider's reasoning floor (`minimal` on OpenRouter, `none` on OpenAI — measured ~2× faster with identical output), while feature grouping and squash planning run at `low` effort, where a bit of real thinking helps multi-file structure decisions. No configuration needed.
268
268
 
269
- | Task | Default model | Why |
270
- |------|---------------|-----|
271
- | All tasks | `gpt-5.4-mini` | Measured as fast as nano (~0.8s) with strictly better quality, ~$0.75 / $4.50 per M |
269
+ `gitb cfg model` shows a **live menu**: the week's most-used and newest text models on OpenRouter (public API, server-side ranking), or the newest chat models visible to your OpenAI key. A typed id is validated against the live catalog (typos die at config time, with an override for brand-new models), and the chosen model is test-driven with one live request before the step completes — a model that can't complete a request never reaches your commits.
272
270
 
273
- `gpt-5.4-nano` (~$0.20 / $1.25 per M) remains a good budget override for high-volume use.
271
+ The model is remembered **per provider** — switching providers never carries a model across (an OpenRouter slug means nothing to the claude CLI), and switching back restores your previous choice, same as API keys:
274
272
 
275
- **Ollama** — fully local
276
-
277
- | Task | Default model | Why |
278
- |------|---------------|-----|
279
- | All tasks | `qwen3:8b` | Best small instruction-follower among 7/8B models; most stable structured output (rarely drops fields in TSV); ~5 GB on disk, ~25 tok/s on a consumer laptop with GPU |
280
-
281
- Other strong local picks: `llama3.3:8b` (general-purpose), `qwen2.5-coder:7b` (code-heavy diffs).
282
-
283
- **Claude Code CLI** — local `claude -p`
284
-
285
- | Task | Default model | Why |
286
- |------|---------------|-----|
287
- | `simple` / `subject` | `haiku` | Fastest and cheapest for one-line output |
288
- | `full` / `grouping` | `sonnet` | Better prose and strict structured output |
289
-
290
- The alias slugs (`haiku`, `sonnet`, `opus`) always resolve to the current model generation; full ids like `claude-haiku-4-5` work too.
291
-
292
- Override per task or globally:
293
273
  ```bash
294
- gitb cfg model # interactive
295
- git config gitbasher.ai-model <model_id> # global
296
- git config gitbasher.ai-model-simple <model_id> # per-task
297
- git config gitbasher.ai-model-subject <model_id>
298
- git config gitbasher.ai-model-full <model_id>
299
- git config gitbasher.ai-model-grouping <model_id>
274
+ gitb cfg model # interactive, stores for the active provider
275
+ git config gitbasher.ai-model-<provider> <model_id> # direct, e.g. gitbasher.ai-model-claude sonnet
300
276
  ```
301
277
 
302
278
  </details>
@@ -905,7 +881,7 @@ Overview-first diffs built for the gitbasher workflow — no flag memorization.
905
881
  | `gitbasher.ai-api-key-<provider>` | `gitb cfg ai` | Per-provider AI API key (or `GITB_AI_API_KEY_<PROVIDER>` env); legacy `gitbasher.ai-api-key` is read as a fallback |
906
882
  | `gitbasher.ai-provider` | `gitb cfg provider` | `openrouter` (default), `openai`, `ollama`, or `claude` |
907
883
  | `gitbasher.ai-base-url` | `git config` | Custom OpenAI-compatible endpoint (LiteLLM, vLLM, remote Ollama) |
908
- | `gitbasher.ai-model[-task]` | `gitb cfg model` | AI model overrides (per provider) |
884
+ | `gitbasher.ai-model-<provider>` | `gitb cfg model` | Model for that provider (one model for all tasks); legacy `gitbasher.ai-model` is read as a fallback |
909
885
  | `gitbasher.ai-proxy` | `gitb cfg proxy` | HTTP proxy for AI calls |
910
886
  | `gitbasher.ai-ollama-host` | `git config gitbasher.ai-ollama-host <url>` | Ollama server URL (default `http://localhost:11434`) |
911
887
  | `gitbasher.ai-timeout` | `git config gitbasher.ai-timeout <seconds>` | AI request timeout in seconds (default `60`, `300` for ollama/claude) |
@@ -1095,7 +1071,7 @@ PRs welcome. See [CONTRIBUTING.md](./CONTRIBUTING.md) for the dev setup, BATS te
1095
1071
 
1096
1072
  ```bash
1097
1073
  make build # rebuild dist/gitb
1098
- make test # run all 900+ tests
1074
+ make test # run all 940+ tests
1099
1075
  make test-file FILE=test_sanitization.bats
1100
1076
  ```
1101
1077
 
package/dist/gitb CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bash
2
2
  set -o pipefail
3
- GITBASHER_VERSION="5.1.0"
3
+ GITBASHER_VERSION="5.2.1"
4
4
  if [ "$1" == "init" ] || [ "$1" == "i" ]; then
5
5
  git init
6
6
  fi
@@ -875,11 +875,22 @@ function print_ai_configuration {
875
875
  fi
876
876
  local ai_model=$(get_ai_model)
877
877
  if [ -n "$ai_model" ]; then
878
- echo -e "\tAI model:\t${GREEN}$ai_model${ENDCOLOR} ${GRAY}(all tasks)${ENDCOLOR} $(config_source_tag gitbasher.ai-model)"
878
+ local model_key="gitbasher.ai-model-${ai_provider}"
879
+ if [ "$(config_source "$model_key")" = "default" ]; then
880
+ model_key="gitbasher.ai-model"
881
+ fi
882
+ echo -e "\tAI model:\t${GREEN}$ai_model${ENDCOLOR} $(config_source_tag "$model_key")"
879
883
  else
880
- echo -e "\tAI models:\t${GREEN}$(get_ai_model_for simple)${ENDCOLOR} ${GRAY}(simple/subject)${ENDCOLOR} $(config_source_tag gitbasher.ai-model-simple)"
881
- echo -e "\t\t\t${GREEN}$(get_ai_model_for full)${ENDCOLOR} ${GRAY}(full)${ENDCOLOR} $(config_source_tag gitbasher.ai-model-full)"
882
- echo -e "\t\t\t${GREEN}$(get_ai_model_for grouping)${ENDCOLOR} ${GRAY}(grouping)${ENDCOLOR} $(config_source_tag gitbasher.ai-model-grouping)"
884
+ echo -e "\tAI model:\t${GREEN}$(get_ai_default_model)${ENDCOLOR} ${GRAY}(default)${ENDCOLOR}"
885
+ fi
886
+ local other_models=""
887
+ while IFS= read -r prov; do
888
+ [ -z "$prov" ] && continue
889
+ [ "$prov" = "$ai_provider" ] && continue
890
+ other_models="${other_models:+${other_models}, }${prov}"
891
+ done < <(list_providers_with_model)
892
+ if [ -n "$other_models" ]; then
893
+ echo -e "\t\t\t${GRAY}also set for: ${other_models}${ENDCOLOR}"
883
894
  fi
884
895
  }
885
896
  function print_configuration {
@@ -1551,6 +1562,9 @@ function set_config_value {
1551
1562
  git config --local "$1" "$2"
1552
1563
  else
1553
1564
  git config --global "$1" "$2"
1565
+ if [ "$GITBASHER_NO_REPO" != "true" ]; then
1566
+ git config --local --unset "$1" 2>/dev/null || true
1567
+ fi
1554
1568
  fi
1555
1569
  printf '%s\n' "$2"
1556
1570
  }
@@ -1696,9 +1710,9 @@ readonly OPENAI_API_URL="https://api.openai.com/v1/chat/completions"
1696
1710
  readonly AI_DEFAULT_OLLAMA_HOST="http://localhost:11434"
1697
1711
  readonly AI_DEFAULT_PROVIDER="openrouter"
1698
1712
  readonly AI_TEMPERATURE="0.3"
1699
- readonly AI_MAX_TOKENS_SUBJECT=256
1700
- readonly AI_MAX_TOKENS_SIMPLE=512
1701
- readonly AI_MAX_TOKENS_FULL=1024
1713
+ readonly AI_MAX_TOKENS_SUBJECT=512
1714
+ readonly AI_MAX_TOKENS_SIMPLE=1024
1715
+ readonly AI_MAX_TOKENS_FULL=2048
1702
1716
  function get_ai_api_key {
1703
1717
  local provider=$(get_ai_provider)
1704
1718
  local provider_upper
@@ -1834,82 +1848,69 @@ function ai_provider_requires_api_key {
1834
1848
  esac
1835
1849
  }
1836
1850
  function get_ai_model {
1851
+ local provider=$(get_ai_provider)
1852
+ local provider_model
1853
+ provider_model=$(get_config_value "gitbasher.ai-model-${provider}" "")
1854
+ if [ -n "$provider_model" ]; then
1855
+ echo "$provider_model"
1856
+ return
1857
+ fi
1837
1858
  get_config_value gitbasher.ai-model ""
1838
1859
  }
1839
1860
  function set_ai_model {
1840
- set_config_value gitbasher.ai-model "$1"
1841
- }
1842
- readonly AI_DEFAULT_MODEL_SIMPLE="google/gemini-3.1-flash-lite"
1843
- readonly AI_DEFAULT_MODEL_SUBJECT="google/gemini-3.1-flash-lite"
1844
- readonly AI_DEFAULT_MODEL_FULL="google/gemini-3.5-flash"
1845
- readonly AI_DEFAULT_MODEL_GROUPING="google/gemini-3.5-flash"
1846
- readonly AI_DEFAULT_MODEL_SIMPLE_OPENAI="gpt-5.4-mini"
1847
- readonly AI_DEFAULT_MODEL_SUBJECT_OPENAI="gpt-5.4-mini"
1848
- readonly AI_DEFAULT_MODEL_FULL_OPENAI="gpt-5.4-mini"
1849
- readonly AI_DEFAULT_MODEL_GROUPING_OPENAI="gpt-5.4-mini"
1850
- readonly AI_DEFAULT_MODEL_SIMPLE_OLLAMA="qwen3:8b"
1851
- readonly AI_DEFAULT_MODEL_SUBJECT_OLLAMA="qwen3:8b"
1852
- readonly AI_DEFAULT_MODEL_FULL_OLLAMA="qwen3:8b"
1853
- readonly AI_DEFAULT_MODEL_GROUPING_OLLAMA="qwen3:8b"
1854
- readonly AI_DEFAULT_MODEL_SIMPLE_CLAUDE="haiku"
1855
- readonly AI_DEFAULT_MODEL_SUBJECT_CLAUDE="haiku"
1856
- readonly AI_DEFAULT_MODEL_FULL_CLAUDE="sonnet"
1857
- readonly AI_DEFAULT_MODEL_GROUPING_CLAUDE="sonnet"
1858
- function get_ai_model_for {
1859
- local task="$1"
1860
- local task_model
1861
- task_model=$(get_config_value "gitbasher.ai-model-$task" "")
1862
- if [ -n "$task_model" ]; then
1863
- echo "$task_model"
1864
- return
1861
+ local provider=$(get_ai_provider)
1862
+ set_config_value "gitbasher.ai-model-${provider}" "$1" "$2"
1863
+ }
1864
+ function migrate_legacy_ai_model_to {
1865
+ local target_provider="$1"
1866
+ local migrated=""
1867
+ local local_legacy=$(git config --local --get gitbasher.ai-model 2>/dev/null)
1868
+ if [ -n "$local_legacy" ]; then
1869
+ if ! git config --local --get "gitbasher.ai-model-${target_provider}" >/dev/null 2>&1; then
1870
+ git config --local "gitbasher.ai-model-${target_provider}" "$local_legacy"
1871
+ migrated="local"
1872
+ fi
1873
+ git config --local --unset gitbasher.ai-model 2>/dev/null || true
1865
1874
  fi
1866
- local global_model
1867
- global_model=$(get_ai_model)
1868
- if [ -n "$global_model" ]; then
1869
- echo "$global_model"
1870
- return
1875
+ local global_legacy=$(git config --global --get gitbasher.ai-model 2>/dev/null)
1876
+ if [ -n "$global_legacy" ]; then
1877
+ if ! git config --global --get "gitbasher.ai-model-${target_provider}" >/dev/null 2>&1; then
1878
+ git config --global "gitbasher.ai-model-${target_provider}" "$global_legacy"
1879
+ migrated="${migrated:+$migrated, }global"
1880
+ fi
1881
+ git config --global --unset gitbasher.ai-model 2>/dev/null || true
1871
1882
  fi
1883
+ if [ -n "$migrated" ]; then
1884
+ echo -e "${GRAY}↻ Kept model for provider '${target_provider}' (${migrated})${ENDCOLOR}" >&2
1885
+ fi
1886
+ }
1887
+ function list_providers_with_model {
1888
+ {
1889
+ git config --local --get-regexp '^gitbasher\.ai-model-' 2>/dev/null
1890
+ git config --global --get-regexp '^gitbasher\.ai-model-' 2>/dev/null
1891
+ } | awk '{print $1}' | sed 's/^gitbasher\.ai-model-//' \
1892
+ | grep -E '^(openrouter|openai|ollama|claude)$' | sort -u
1893
+ }
1894
+ readonly AI_DEFAULT_MODEL_OPENROUTER="google/gemini-3.5-flash"
1895
+ readonly AI_DEFAULT_MODEL_OPENAI="gpt-5.4-mini"
1896
+ readonly AI_DEFAULT_MODEL_OLLAMA="qwen3:8b"
1897
+ readonly AI_DEFAULT_MODEL_CLAUDE="haiku"
1898
+ function get_ai_default_model {
1872
1899
  case "$(get_ai_provider)" in
1873
- openai)
1874
- case "$task" in
1875
- simple) echo "$AI_DEFAULT_MODEL_SIMPLE_OPENAI" ;;
1876
- subject) echo "$AI_DEFAULT_MODEL_SUBJECT_OPENAI" ;;
1877
- full) echo "$AI_DEFAULT_MODEL_FULL_OPENAI" ;;
1878
- grouping) echo "$AI_DEFAULT_MODEL_GROUPING_OPENAI" ;;
1879
- *) echo "$AI_DEFAULT_MODEL_SIMPLE_OPENAI" ;;
1880
- esac
1881
- ;;
1882
- ollama)
1883
- case "$task" in
1884
- simple) echo "$AI_DEFAULT_MODEL_SIMPLE_OLLAMA" ;;
1885
- subject) echo "$AI_DEFAULT_MODEL_SUBJECT_OLLAMA" ;;
1886
- full) echo "$AI_DEFAULT_MODEL_FULL_OLLAMA" ;;
1887
- grouping) echo "$AI_DEFAULT_MODEL_GROUPING_OLLAMA" ;;
1888
- *) echo "$AI_DEFAULT_MODEL_SIMPLE_OLLAMA" ;;
1889
- esac
1890
- ;;
1891
- claude)
1892
- case "$task" in
1893
- simple) echo "$AI_DEFAULT_MODEL_SIMPLE_CLAUDE" ;;
1894
- subject) echo "$AI_DEFAULT_MODEL_SUBJECT_CLAUDE" ;;
1895
- full) echo "$AI_DEFAULT_MODEL_FULL_CLAUDE" ;;
1896
- grouping) echo "$AI_DEFAULT_MODEL_GROUPING_CLAUDE" ;;
1897
- *) echo "$AI_DEFAULT_MODEL_SIMPLE_CLAUDE" ;;
1898
- esac
1899
- ;;
1900
- *)
1901
- case "$task" in
1902
- simple) echo "$AI_DEFAULT_MODEL_SIMPLE" ;;
1903
- subject) echo "$AI_DEFAULT_MODEL_SUBJECT" ;;
1904
- full) echo "$AI_DEFAULT_MODEL_FULL" ;;
1905
- grouping) echo "$AI_DEFAULT_MODEL_GROUPING" ;;
1906
- *) echo "$AI_DEFAULT_MODEL_SIMPLE" ;;
1907
- esac
1908
- ;;
1900
+ openai) echo "$AI_DEFAULT_MODEL_OPENAI" ;;
1901
+ ollama) echo "$AI_DEFAULT_MODEL_OLLAMA" ;;
1902
+ claude) echo "$AI_DEFAULT_MODEL_CLAUDE" ;;
1903
+ *) echo "$AI_DEFAULT_MODEL_OPENROUTER" ;;
1909
1904
  esac
1910
1905
  }
1911
- function set_ai_model_for {
1912
- set_config_value "gitbasher.ai-model-$1" "$2"
1906
+ function resolve_ai_model {
1907
+ local model
1908
+ model=$(get_ai_model)
1909
+ if [ -n "$model" ]; then
1910
+ echo "$model"
1911
+ return
1912
+ fi
1913
+ get_ai_default_model
1913
1914
  }
1914
1915
  function get_ai_proxy {
1915
1916
  get_config_value gitbasher.ai-proxy ""
@@ -2164,11 +2165,89 @@ function ollama_list_models {
2164
2165
  local base resp names
2165
2166
  base=$(ollama_api_base)
2166
2167
  resp=$(curl -s --max-time 5 "${base}/api/tags" 2>/dev/null)
2167
- [ -z "$resp" ] && return 1
2168
2168
  names=$(printf '%s' "$resp" | _parse_ollama_model_names)
2169
+ if [ -z "$names" ] && command -v ollama >/dev/null 2>&1; then
2170
+ names=$(OLLAMA_HOST="$base" ollama list 2>/dev/null \
2171
+ | LC_ALL=C awk 'NR>1 && $1 != "" {print $1}')
2172
+ fi
2169
2173
  [ -z "$names" ] && return 1
2170
2174
  printf '%s\n' "$names"
2171
2175
  }
2176
+ function secure_curl_get {
2177
+ local proxy_url="$1"
2178
+ local api_key="$2"
2179
+ local url="$3"
2180
+ (
2181
+ { set +x; } 2>/dev/null
2182
+ unset HISTFILE
2183
+ local curl_cmd=(
2184
+ curl -s
2185
+ --connect-timeout 10
2186
+ --max-time 20
2187
+ )
2188
+ [ -n "$proxy_url" ] && curl_cmd+=(--proxy "$proxy_url")
2189
+ curl_cmd+=("$url")
2190
+ if [ -n "$api_key" ]; then
2191
+ local escaped_key="${api_key//\\/\\\\}"
2192
+ escaped_key="${escaped_key//\"/\\\"}"
2193
+ "${curl_cmd[@]}" --config - <<EOF 2>/dev/null
2194
+ header = "Authorization: Bearer ${escaped_key}"
2195
+ EOF
2196
+ else
2197
+ "${curl_cmd[@]}" 2>/dev/null
2198
+ fi
2199
+ )
2200
+ }
2201
+ function _parse_model_ids_json {
2202
+ if command -v jq &>/dev/null; then
2203
+ jq -r '.data[]?.id // empty' 2>/dev/null
2204
+ else
2205
+ LC_ALL=C grep -o '"id"[[:space:]]*:[[:space:]]*"[^"]*"' \
2206
+ | LC_ALL=C sed 's/.*"\([^"]*\)"$/\1/'
2207
+ fi
2208
+ }
2209
+ function openrouter_list_models {
2210
+ local sort_key="${1:-top-weekly}"
2211
+ local models_url resp ids
2212
+ models_url="${OPENROUTER_API_URL/chat\/completions/models}?sort=${sort_key}"
2213
+ resp=$(secure_curl_get "$(get_ai_proxy)" "" "$models_url")
2214
+ [ -z "$resp" ] && return 1
2215
+ ids=$(printf '%s' "$resp" | _parse_model_ids_json)
2216
+ [ -z "$ids" ] && return 1
2217
+ printf '%s\n' "$ids"
2218
+ }
2219
+ function openai_list_models {
2220
+ local api_key resp ids
2221
+ api_key=$(get_ai_api_key)
2222
+ [ -z "$api_key" ] && return 1
2223
+ local models_url="${OPENAI_API_URL/chat\/completions/models}"
2224
+ resp=$(secure_curl_get "$(get_ai_proxy)" "$api_key" "$models_url")
2225
+ [ -z "$resp" ] && return 1
2226
+ if command -v jq &>/dev/null; then
2227
+ ids=$(printf '%s' "$resp" | jq -r '.data // [] | sort_by(-(.created // 0)) | .[].id // empty' 2>/dev/null)
2228
+ else
2229
+ ids=$(printf '%s' "$resp" | _parse_model_ids_json)
2230
+ fi
2231
+ ids=$(printf '%s\n' "$ids" \
2232
+ | LC_ALL=C grep -E '^(gpt-|o[0-9]|chatgpt-)' \
2233
+ | LC_ALL=C grep -vE 'embedding|tts|whisper|audio|realtime|dall-e|image|moderation|transcribe|search|instruct')
2234
+ [ -z "$ids" ] && return 1
2235
+ printf '%s\n' "$ids"
2236
+ }
2237
+ function ai_model_smoke_check {
2238
+ local model out rc
2239
+ model=$(resolve_ai_model)
2240
+ echo -e "${YELLOW}Testing model '${model}' with a live request...${ENDCOLOR}"
2241
+ out=$(call_ai_api "Reply with exactly: OK" "ping" 64 "$model" 2>&1)
2242
+ rc=$?
2243
+ if [ "$rc" -eq 0 ] && [ -n "$out" ]; then
2244
+ echo -e "${GREEN}✓ Model '${model}' responded${ENDCOLOR}"
2245
+ return 0
2246
+ fi
2247
+ echo -e "${RED}✗ Model '${model}' did not respond${ENDCOLOR}" >&2
2248
+ [ -n "$out" ] && printf '%s\n' "$out" | head -4 >&2
2249
+ return 1
2250
+ }
2172
2251
  function ai_smoke_check {
2173
2252
  local provider
2174
2253
  provider=$(get_ai_provider)
@@ -2268,14 +2347,17 @@ function _call_claude_cli {
2268
2347
  local model="$3"
2269
2348
  local timeout_s output rc
2270
2349
  timeout_s=$(get_ai_timeout)
2350
+ local -a claude_args=(-p --model "$model" --output-format text
2351
+ --strict-mcp-config --mcp-config '{"mcpServers":{}}'
2352
+ --setting-sources "")
2271
2353
  if command -v perl >/dev/null 2>&1; then
2272
2354
  output=$(printf '%s\n\n%s' "$system_prompt" "$user_prompt" \
2273
- | perl -e 'alarm shift; exec @ARGV' "$timeout_s" \
2274
- claude -p --model "$model" --output-format text 2>/dev/null)
2355
+ | MAX_THINKING_TOKENS=0 perl -e 'alarm shift; exec @ARGV' "$timeout_s" \
2356
+ claude "${claude_args[@]}" 2>/dev/null)
2275
2357
  rc=$?
2276
2358
  else
2277
2359
  output=$(printf '%s\n\n%s' "$system_prompt" "$user_prompt" \
2278
- | claude -p --model "$model" --output-format text 2>/dev/null)
2360
+ | MAX_THINKING_TOKENS=0 claude "${claude_args[@]}" 2>/dev/null)
2279
2361
  rc=$?
2280
2362
  fi
2281
2363
  if [ "$rc" -eq 142 ]; then
@@ -2294,8 +2376,9 @@ function call_ai_api {
2294
2376
  local system_prompt="$1"
2295
2377
  local user_prompt="$2"
2296
2378
  local max_tokens="${3:-$AI_MAX_TOKENS_FULL}"
2297
- local model="${4:-$(get_ai_model_for simple)}"
2379
+ local model="${4:-$(resolve_ai_model)}"
2298
2380
  local response_format="${5:-}"
2381
+ local reasoning_effort="${6:-}"
2299
2382
  local provider=$(get_ai_provider)
2300
2383
  local api_url=$(get_ai_api_url)
2301
2384
  local api_key=$(get_ai_api_key)
@@ -2328,6 +2411,21 @@ function call_ai_api {
2328
2411
  gpt-5*|o[0-9]*) include_temperature="" ;;
2329
2412
  esac
2330
2413
  fi
2414
+ case "$reasoning_effort" in
2415
+ low|medium|high|"") : ;;
2416
+ *) reasoning_effort="" ;; # unknown value → provider floor
2417
+ esac
2418
+ local or_reasoning="" oa_reasoning=""
2419
+ if [ -n "$(get_ai_base_url)" ]; then
2420
+ : # custom endpoint — send nothing
2421
+ elif [ "$provider" = "openrouter" ]; then
2422
+ or_reasoning="${reasoning_effort:-minimal}"
2423
+ elif [ "$provider" = "openai" ]; then
2424
+ case "$model" in
2425
+ gpt-5*chat*|chatgpt-*) : ;;
2426
+ gpt-5*|o[0-9]*) oa_reasoning="${reasoning_effort:-none}" ;;
2427
+ esac
2428
+ fi
2331
2429
  local json_payload
2332
2430
  if command -v jq &>/dev/null; then
2333
2431
  json_payload=$(jq -nc \
@@ -2349,6 +2447,17 @@ function call_ai_api {
2349
2447
  --argjson rf "$response_format" \
2350
2448
  '$base + {response_format: $rf}')
2351
2449
  fi
2450
+ if [ -n "$or_reasoning" ]; then
2451
+ json_payload=$(jq -nc \
2452
+ --argjson base "$json_payload" \
2453
+ --arg effort "$or_reasoning" \
2454
+ '$base + {reasoning: {effort: $effort}}')
2455
+ elif [ -n "$oa_reasoning" ]; then
2456
+ json_payload=$(jq -nc \
2457
+ --argjson base "$json_payload" \
2458
+ --arg effort "$oa_reasoning" \
2459
+ '$base + {reasoning_effort: $effort}')
2460
+ fi
2352
2461
  else
2353
2462
  local system_escaped=$(_json_escape_for_payload "$system_prompt")
2354
2463
  local user_escaped=$(_json_escape_for_payload "$user_prompt")
@@ -2361,7 +2470,13 @@ function call_ai_api {
2361
2470
  if [ -n "$include_temperature" ]; then
2362
2471
  temp_field=",\"temperature\":$AI_TEMPERATURE"
2363
2472
  fi
2364
- json_payload="{\"model\":\"$model_escaped\",\"messages\":[{\"role\":\"system\",\"content\":\"$system_escaped\"},{\"role\":\"user\",\"content\":\"$user_escaped\"}]${temp_field},\"${tokens_field}\":$max_tokens${rf_field}}"
2473
+ local reasoning_field=""
2474
+ if [ -n "$or_reasoning" ]; then
2475
+ reasoning_field=",\"reasoning\":{\"effort\":\"$or_reasoning\"}"
2476
+ elif [ -n "$oa_reasoning" ]; then
2477
+ reasoning_field=",\"reasoning_effort\":\"$oa_reasoning\""
2478
+ fi
2479
+ json_payload="{\"model\":\"$model_escaped\",\"messages\":[{\"role\":\"system\",\"content\":\"$system_escaped\"},{\"role\":\"user\",\"content\":\"$user_escaped\"}]${temp_field},\"${tokens_field}\":$max_tokens${rf_field}${reasoning_field}}"
2365
2480
  fi
2366
2481
  local proxy_url=$(get_ai_proxy)
2367
2482
  local safe_proxy_url=""
@@ -2557,6 +2672,20 @@ function call_ai_api {
2557
2672
  clear_sensitive_vars
2558
2673
  return 1
2559
2674
  fi
2675
+ local finish_reason=""
2676
+ if command -v jq &>/dev/null; then
2677
+ finish_reason=$(echo "$response" | jq -r '.choices[0].finish_reason // empty' 2>/dev/null)
2678
+ else
2679
+ case "$response" in
2680
+ *'"finish_reason":"length"'*|*'"finish_reason": "length"'*) finish_reason="length" ;;
2681
+ esac
2682
+ fi
2683
+ if [ "$finish_reason" = "length" ]; then
2684
+ echo -e "${RED}✗ AI response was cut off by the output-token limit.${ENDCOLOR}" >&2
2685
+ echo -e "${CYAN}💡 The model likely spent the budget on internal reasoning — pick a faster model with ${GREEN}gitb cfg model${ENDCOLOR}${CYAN}, or retry.${ENDCOLOR}" >&2
2686
+ clear_sensitive_vars
2687
+ return 1
2688
+ fi
2560
2689
  clear_sensitive_vars
2561
2690
  echo "$ai_response"
2562
2691
  }
@@ -2766,13 +2895,13 @@ function generate_ai_commit_message {
2766
2895
  local system_prompt user_prompt
2767
2896
  system_prompt=$(build_ai_commit_system_prompt "$mode" "$commit_prefix")
2768
2897
  user_prompt=$(build_ai_commit_user_prompt "$mode" "$detected_scopes" "$provided_scopes" "$rejected_messages")
2769
- local max_tokens model
2898
+ local max_tokens
2770
2899
  case "$mode" in
2771
- subject) max_tokens="$AI_MAX_TOKENS_SUBJECT"; model=$(get_ai_model_for subject) ;;
2772
- full) max_tokens="$AI_MAX_TOKENS_FULL"; model=$(get_ai_model_for full) ;;
2773
- *) max_tokens="$AI_MAX_TOKENS_SIMPLE"; model=$(get_ai_model_for simple) ;;
2900
+ subject) max_tokens="$AI_MAX_TOKENS_SUBJECT" ;;
2901
+ full) max_tokens="$AI_MAX_TOKENS_FULL" ;;
2902
+ *) max_tokens="$AI_MAX_TOKENS_SIMPLE" ;;
2774
2903
  esac
2775
- call_ai_api "$system_prompt" "$user_prompt" "$max_tokens" "$model"
2904
+ call_ai_api "$system_prompt" "$user_prompt" "$max_tokens"
2776
2905
  }
2777
2906
  function clear_sensitive_vars {
2778
2907
  api_key=""
@@ -3053,10 +3182,10 @@ function configure_ai_provider {
3053
3182
  fi
3054
3183
  echo
3055
3184
  echo -e "Options:"
3056
- echo -e " 1. ${BLUE}openrouter${ENDCOLOR} Aggregator with hundreds of models — needs an API key"
3057
- echo -e " 2. ${BLUE}openai${ENDCOLOR} OpenAI direct (GPT-5 family) — needs an API key"
3058
- echo -e " 3. ${BLUE}ollama${ENDCOLOR} Local or remote models (you'll set the host) — no API key required"
3059
- echo -e " 4. ${BLUE}claude${ENDCOLOR} Local Claude Code CLI (claude -p) — uses your Claude account, no API key"
3185
+ echo -e " 1. ${BOLD}openrouter${ENDCOLOR} Aggregator with hundreds of models — needs an API key"
3186
+ echo -e " 2. ${BOLD}openai${ENDCOLOR} OpenAI direct (GPT-5 family) — needs an API key"
3187
+ echo -e " 3. ${BOLD}ollama${ENDCOLOR} Local or remote models (you'll set the host) — no API key required"
3188
+ echo -e " 4. ${BOLD}claude${ENDCOLOR} Local Claude Code CLI (claude -p) — uses your Claude account, no API key"
3060
3189
  echo
3061
3190
  echo -e "Press Enter to keep the current provider, or enter 0 to reset to default (${AI_DEFAULT_PROVIDER})"
3062
3191
  if ! read_editable_input choice "Choice (1-4): "; then
@@ -3089,8 +3218,12 @@ function configure_ai_provider {
3089
3218
  ;;
3090
3219
  esac
3091
3220
  migrate_legacy_ai_api_key_to "$current_provider"
3221
+ migrate_legacy_ai_model_to "$current_provider"
3222
+ local prev_local_provider
3223
+ prev_local_provider=$(git config --local --get gitbasher.ai-provider 2>/dev/null)
3092
3224
  set_ai_provider "$new_provider"
3093
3225
  echo -e "${GREEN}✓ Set AI provider to '${new_provider}' for '${project_name}'${ENDCOLOR}"
3226
+ local local_smoke_failed=""
3094
3227
  case "$new_provider" in
3095
3228
  ollama)
3096
3229
  local current_host
@@ -3115,19 +3248,24 @@ function configure_ai_provider {
3115
3248
  fi
3116
3249
  echo
3117
3250
  echo -e "${CYAN}💡 Make sure the Ollama daemon is running (${BLUE}ollama serve${CYAN}) and a model is pulled (${BLUE}ollama pull qwen3:8b${CYAN}).${ENDCOLOR}"
3118
- echo -e "${CYAN}💡 Pick a model with ${GREEN}gitb cfg model${CYAN}.${ENDCOLOR}"
3251
+ if [ -z "$_chained" ]; then
3252
+ echo -e "${CYAN}💡 Pick a model with ${GREEN}gitb cfg model${CYAN}.${ENDCOLOR}"
3253
+ fi
3119
3254
  echo
3120
- ai_smoke_check || true
3255
+ ai_smoke_check || local_smoke_failed="true"
3121
3256
  ;;
3122
3257
  claude)
3123
3258
  echo
3124
3259
  if command -v claude >/dev/null 2>&1; then
3125
- ai_smoke_check || true
3260
+ ai_smoke_check || local_smoke_failed="true"
3126
3261
  else
3127
3262
  echo -e "${RED}✗ claude CLI not found on PATH.${ENDCOLOR}"
3128
3263
  echo -e "${CYAN}💡 Install it with ${BLUE}npm install -g @anthropic-ai/claude-code${CYAN}, then sign in once.${ENDCOLOR}"
3264
+ local_smoke_failed="true"
3265
+ fi
3266
+ if [ -z "$_chained" ]; then
3267
+ echo -e "${CYAN}💡 Pick a model with ${GREEN}gitb cfg model${CYAN} (haiku, sonnet, opus).${ENDCOLOR}"
3129
3268
  fi
3130
- echo -e "${CYAN}💡 Pick a model with ${GREEN}gitb cfg model${CYAN} (haiku, sonnet, opus).${ENDCOLOR}"
3131
3269
  ;;
3132
3270
  *)
3133
3271
  if [ -n "$(get_ai_api_key)" ]; then
@@ -3137,6 +3275,25 @@ function configure_ai_provider {
3137
3275
  ;;
3138
3276
  esac
3139
3277
  echo
3278
+ if [ -n "$local_smoke_failed" ]; then
3279
+ echo -e "Keep '${new_provider}' as the AI provider anyway (y/N)?"
3280
+ local _keep_rc=0
3281
+ confirm_destructive || _keep_rc=$?
3282
+ if [ "$_keep_rc" -ne 0 ]; then
3283
+ if [ -n "$prev_local_provider" ]; then
3284
+ git config --local gitbasher.ai-provider "$prev_local_provider"
3285
+ else
3286
+ git config --local --unset gitbasher.ai-provider 2>/dev/null || true
3287
+ fi
3288
+ echo -e "${YELLOW}Provider unchanged — still '$(get_ai_provider)'.${ENDCOLOR}"
3289
+ if [ -n "$_chained" ]; then
3290
+ if [ "$_keep_rc" -eq 2 ]; then return 130; fi
3291
+ return 3
3292
+ fi
3293
+ exit 1
3294
+ fi
3295
+ echo
3296
+ fi
3140
3297
  if [ "$GITBASHER_NO_REPO" != "true" ]; then
3141
3298
  echo -e "Do you want to set this provider ${YELLOW}globally${ENDCOLOR} for all projects (Y/n)?"
3142
3299
  local _prov_key=""
@@ -3164,6 +3321,15 @@ function configure_ai_provider {
3164
3321
  fi
3165
3322
  fi
3166
3323
  }
3324
+ function _print_model_menu_entry {
3325
+ local _desc=""
3326
+ [ -n "$4" ] && _desc=" ${GRAY}- ${4}${ENDCOLOR}"
3327
+ if [ "$1" = "$3" ]; then
3328
+ echo -e " ${GREEN}${2}. ${1}${ENDCOLOR} (current)${_desc}"
3329
+ else
3330
+ echo -e " ${2}. ${BOLD}${1}${ENDCOLOR}${_desc}"
3331
+ fi
3332
+ }
3167
3333
  function configure_ai_model {
3168
3334
  local _chained=""
3169
3335
  [ "$1" = "--chained" ] && _chained="true"
@@ -3171,72 +3337,113 @@ function configure_ai_model {
3171
3337
  echo
3172
3338
  current_model=$(get_ai_model)
3173
3339
  if [ -n "$current_model" ]; then
3174
- echo -e "Current global override: ${GREEN}$current_model${ENDCOLOR}"
3340
+ echo -e "Current model for provider '${GREEN}$(get_ai_provider)${ENDCOLOR}': ${GREEN}$current_model${ENDCOLOR}"
3175
3341
  else
3176
- echo -e "No global override set — each task uses its per-task default:"
3177
- echo -e " • simple → ${GREEN}$(get_ai_model_for simple)${ENDCOLOR}"
3178
- echo -e " • subject → ${GREEN}$(get_ai_model_for subject)${ENDCOLOR}"
3179
- echo -e " • full → ${GREEN}$(get_ai_model_for full)${ENDCOLOR}"
3180
- echo -e " • grouping → ${GREEN}$(get_ai_model_for grouping)${ENDCOLOR}"
3342
+ echo -e "Provider '${GREEN}$(get_ai_provider)${ENDCOLOR}' uses its default model: ${GREEN}$(get_ai_default_model)${ENDCOLOR}"
3181
3343
  fi
3182
3344
  echo
3183
- echo -e "Set a single model for all tasks (a global override), or leave empty to keep using the per-task defaults."
3184
- echo -e "Per-task overrides can be set directly with git config:"
3185
- echo -e " ${BLUE}git config gitbasher.ai-model-simple <model_id>${ENDCOLOR} (and -subject / -full / -grouping)"
3186
- echo
3187
- echo -e "Popular models for provider '${GREEN}$(get_ai_provider)${ENDCOLOR}':"
3345
+ local -a model_menu=()
3346
+ local model_catalog=""
3347
+ local _m _i
3348
+ local _resolved_model
3349
+ _resolved_model=$(resolve_ai_model)
3188
3350
  case "$(get_ai_provider)" in
3189
- openai)
3190
- echo -e " • ${BLUE}gpt-5.4-mini${ENDCOLOR} - Default: as fast as nano with better quality (~\$0.75/\$4.50 per M)"
3191
- echo -e " • ${BLUE}gpt-5.4-nano${ENDCOLOR} - Budget option for high volume (~\$0.20/\$1.25 per M)"
3192
- echo -e " • ${BLUE}gpt-5.5${ENDCOLOR} - Flagship, only worth it for the hardest grouping cases"
3193
- ;;
3194
3351
  ollama)
3195
- echo -e " • ${BLUE}qwen3:8b${ENDCOLOR} - Best small instruction-follower; most stable structured output"
3196
- echo -e " • ${BLUE}llama3.3:8b${ENDCOLOR} - Solid general-purpose alternative"
3197
- echo -e " • ${BLUE}qwen2.5-coder:7b${ENDCOLOR} - Code-focused; good when most diffs are code"
3198
- echo -e " Run ${BLUE}ollama list${ENDCOLOR} to see what you have pulled locally."
3352
+ while IFS= read -r _m; do
3353
+ [ -n "$_m" ] && model_menu+=("$_m")
3354
+ done < <(ollama_list_models)
3355
+ if [ ${#model_menu[@]} -gt 0 ]; then
3356
+ echo -e "Installed models on ${BLUE}$(ollama_api_base)${ENDCOLOR}:"
3357
+ _i=1
3358
+ for _m in "${model_menu[@]}"; do
3359
+ _print_model_menu_entry "$_m" "$_i" "$_resolved_model"
3360
+ _i=$((_i + 1))
3361
+ done
3362
+ else
3363
+ echo -e "${YELLOW}No models found on $(ollama_api_base) — is the daemon running?${ENDCOLOR}"
3364
+ fi
3199
3365
  ;;
3200
- claude)
3201
- echo -e " • ${BLUE}haiku${ENDCOLOR} - Fastest and cheapest; ideal for one-line messages"
3202
- echo -e " • ${BLUE}sonnet${ENDCOLOR} - Best balance for full-body prose and grouping"
3203
- echo -e " • ${BLUE}opus${ENDCOLOR} - Strongest, for the hardest cases"
3204
- echo -e " Aliases resolve to the current model generation; full ids (${BLUE}claude-haiku-4-5${ENDCOLOR}) also work."
3366
+ openrouter)
3367
+ model_catalog=$(openrouter_list_models top-weekly 2>/dev/null)
3368
+ if [ -n "$model_catalog" ]; then
3369
+ _i=1
3370
+ echo -e "Most used on OpenRouter this week:"
3371
+ while IFS= read -r _m; do
3372
+ model_menu+=("$_m")
3373
+ _print_model_menu_entry "$_m" "$_i" "$_resolved_model"
3374
+ _i=$((_i + 1))
3375
+ done < <(printf '%s\n' "$model_catalog" | head -8)
3376
+ local _newest
3377
+ _newest=$(openrouter_list_models newest 2>/dev/null | head -5)
3378
+ if [ -n "$_newest" ]; then
3379
+ echo -e "Newest:"
3380
+ while IFS= read -r _m; do
3381
+ [ -z "$_m" ] && continue
3382
+ case " ${model_menu[*]} " in *" $_m "*) continue ;; esac
3383
+ model_menu+=("$_m")
3384
+ _print_model_menu_entry "$_m" "$_i" "$_resolved_model"
3385
+ _i=$((_i + 1))
3386
+ done <<< "$_newest"
3387
+ fi
3388
+ fi
3205
3389
  ;;
3206
- *)
3207
- echo -e " • ${BLUE}openrouter/auto${ENDCOLOR} - Auto-select best available model"
3208
- echo -e " • ${BLUE}google/gemini-3.1-flash-lite${ENDCOLOR} - Fastest, cheapest (~\$0.25/\$1.50 per M)"
3209
- echo -e " • ${BLUE}google/gemini-3.5-flash${ENDCOLOR} - Current flash generation, better body prose"
3210
- echo -e " • ${BLUE}anthropic/claude-haiku-4.5${ENDCOLOR} - Strict instruction following"
3211
- echo -e " • ${BLUE}anthropic/claude-sonnet-5${ENDCOLOR} - Premium quality for the hardest cases"
3390
+ openai)
3391
+ model_catalog=$(openai_list_models 2>/dev/null)
3392
+ if [ -n "$model_catalog" ]; then
3393
+ echo -e "Newest chat models on your OpenAI account:"
3394
+ _i=1
3395
+ while IFS= read -r _m; do
3396
+ model_menu+=("$_m")
3397
+ _print_model_menu_entry "$_m" "$_i" "$_resolved_model"
3398
+ _i=$((_i + 1))
3399
+ done < <(printf '%s\n' "$model_catalog" \
3400
+ | LC_ALL=C grep -vE -- '-[0-9]{4}-[0-9]{2}-[0-9]{2}$' | head -12)
3401
+ fi
3212
3402
  ;;
3213
3403
  esac
3214
- echo
3215
- local -a ollama_model_list=()
3216
- if [ "$(get_ai_provider)" = "ollama" ]; then
3217
- local _m _i
3218
- while IFS= read -r _m; do
3219
- [ -n "$_m" ] && ollama_model_list+=("$_m")
3220
- done < <(ollama_list_models)
3221
- if [ ${#ollama_model_list[@]} -gt 0 ]; then
3222
- echo -e "Installed models on ${BLUE}$(ollama_api_base)${ENDCOLOR}:"
3223
- _i=1
3224
- for _m in "${ollama_model_list[@]}"; do
3225
- if [ "$_m" = "$current_model" ]; then
3226
- echo -e " ${GREEN}${_i}. ${_m}${ENDCOLOR} (current)"
3227
- else
3228
- echo -e " ${BLUE}${_i}.${ENDCOLOR} ${_m}"
3229
- fi
3230
- _i=$((_i + 1))
3231
- done
3232
- echo -e "Enter a number to select, or type a model id."
3233
- else
3234
- echo -e "${YELLOW}No models found on $(ollama_api_base) — is the daemon running?${ENDCOLOR}"
3235
- echo -e "Pull one with ${BLUE}ollama pull qwen3:8b${ENDCOLOR}, or type a model id below."
3236
- fi
3237
- echo
3404
+ if [ ${#model_menu[@]} -eq 0 ]; then
3405
+ local -a _menu_desc=()
3406
+ case "$(get_ai_provider)" in
3407
+ openai)
3408
+ echo -e "Popular OpenAI models:"
3409
+ model_menu=("gpt-5.4-mini" "gpt-5.4-nano" "gpt-5.5")
3410
+ _menu_desc=("Default: as fast as nano with better quality (~\$0.75/\$4.50 per M)"
3411
+ "Budget option for high volume (~\$0.20/\$1.25 per M)"
3412
+ "Flagship, only worth it for the hardest grouping cases")
3413
+ ;;
3414
+ ollama)
3415
+ echo -e "Suggested models (pull first: ${BLUE}ollama pull <id>${ENDCOLOR}):"
3416
+ model_menu=("qwen3:8b" "llama3.3:8b" "qwen2.5-coder:7b")
3417
+ _menu_desc=("Default: best small instruction-follower; most stable structured output"
3418
+ "Solid general-purpose alternative"
3419
+ "Code-focused; good when most diffs are code")
3420
+ ;;
3421
+ claude)
3422
+ echo -e "Model aliases (resolve to the current generation; full ids like ${BLUE}claude-haiku-4-5${ENDCOLOR} also work):"
3423
+ model_menu=("haiku" "sonnet" "opus")
3424
+ _menu_desc=("Default: fastest and cheapest"
3425
+ "Better prose, noticeably slower"
3426
+ "Strongest, for the hardest cases")
3427
+ ;;
3428
+ *)
3429
+ echo -e "Popular OpenRouter models:"
3430
+ model_menu=("google/gemini-3.5-flash" "google/gemini-3.1-flash-lite" "openrouter/auto" "anthropic/claude-haiku-4.5" "anthropic/claude-sonnet-5")
3431
+ _menu_desc=("Default: current flash generation, fast with good prose"
3432
+ "Fastest, cheapest (~\$0.25/\$1.50 per M)"
3433
+ "Auto-select best available model"
3434
+ "Strict instruction following"
3435
+ "Premium quality for the hardest cases")
3436
+ ;;
3437
+ esac
3438
+ _i=0
3439
+ for _m in "${model_menu[@]}"; do
3440
+ _print_model_menu_entry "$_m" "$((_i + 1))" "$_resolved_model" "${_menu_desc[$_i]}"
3441
+ _i=$((_i + 1))
3442
+ done
3238
3443
  fi
3239
- echo -e "Press Enter to exit without changes, or 0 to clear the override (use per-task defaults)"
3444
+ echo -e "Enter a number to select, or type a model id."
3445
+ echo
3446
+ echo -e "Press Enter to keep the current model, or 0 to reset to the default (${GREEN}$(get_ai_default_model)${ENDCOLOR})"
3240
3447
  if ! read_editable_input model_input "Model ID: "; then
3241
3448
  if [ -n "$_chained" ]; then return 130; fi
3242
3449
  exit
@@ -3246,17 +3453,21 @@ function configure_ai_model {
3246
3453
  exit
3247
3454
  fi
3248
3455
  if [ "$model_input" == "0" ]; then
3456
+ git config --unset "gitbasher.ai-model-$(get_ai_provider)" 2>/dev/null
3457
+ git config --global --unset "gitbasher.ai-model-$(get_ai_provider)" 2>/dev/null
3249
3458
  git config --unset gitbasher.ai-model 2>/dev/null
3250
3459
  git config --global --unset gitbasher.ai-model 2>/dev/null
3251
3460
  echo
3252
- echo -e "${GREEN}✓ Cleared global model override — each task now uses its per-task default${ENDCOLOR}"
3461
+ echo -e "${GREEN}✓ Cleared model override — provider '$(get_ai_provider)' now uses its default ($(get_ai_default_model))${ENDCOLOR}"
3253
3462
  if [ -n "$_chained" ]; then return 0; fi
3254
3463
  exit
3255
3464
  fi
3256
3465
  echo
3257
- if [ ${#ollama_model_list[@]} -gt 0 ] && [[ "$model_input" =~ ^[0-9]+$ ]]; then
3258
- if [ "$model_input" -ge 1 ] && [ "$model_input" -le ${#ollama_model_list[@]} ]; then
3259
- model_input="${ollama_model_list[$((model_input - 1))]}"
3466
+ local picked_from_menu=""
3467
+ if [ ${#model_menu[@]} -gt 0 ] && [[ "$model_input" =~ ^[0-9]+$ ]]; then
3468
+ if [ "$model_input" -ge 1 ] && [ "$model_input" -le ${#model_menu[@]} ]; then
3469
+ model_input="${model_menu[$((model_input - 1))]}"
3470
+ picked_from_menu="true"
3260
3471
  else
3261
3472
  echo -e "${RED}✗ Invalid selection: ${model_input}${ENDCOLOR}" >&2
3262
3473
  if [ -n "$_chained" ]; then return 1; fi
@@ -3269,9 +3480,41 @@ function configure_ai_model {
3269
3480
  if [ -n "$_chained" ]; then return 1; fi
3270
3481
  exit 1
3271
3482
  fi
3483
+ if [ -z "$picked_from_menu" ] && [ -n "$model_catalog" ]; then
3484
+ if ! printf '%s\n' "$model_catalog" | LC_ALL=C grep -qxF "$model_input"; then
3485
+ echo -e "${YELLOW}⚠ '$model_input' is not in $(get_ai_provider)'s current model list.${ENDCOLOR}"
3486
+ echo -e "Save it anyway (y/N)?"
3487
+ if ! confirm_destructive; then
3488
+ echo -e "${YELLOW}Not saved. Pick a number from the list or re-check the id.${ENDCOLOR}"
3489
+ if [ -n "$_chained" ]; then return 1; fi
3490
+ exit 1
3491
+ fi
3492
+ fi
3493
+ fi
3494
+ local prev_model
3495
+ prev_model=$(git config --local --get "gitbasher.ai-model-$(get_ai_provider)" 2>/dev/null)
3272
3496
  set_ai_model "$model_input"
3273
- echo -e "${GREEN}✓ Set AI model to '$model_input' for '${project_name}'${ENDCOLOR}"
3497
+ echo -e "${GREEN}✓ Set AI model to '$model_input' for provider '$(get_ai_provider)' in '${project_name}'${ENDCOLOR}"
3274
3498
  echo
3499
+ case "$(get_ai_provider)" in
3500
+ openrouter|openai)
3501
+ if ! ai_model_smoke_check; then
3502
+ echo -e "Keep '$model_input' anyway (y/N)?"
3503
+ if ! confirm_destructive; then
3504
+ if [ -n "$prev_model" ]; then
3505
+ git config "gitbasher.ai-model-$(get_ai_provider)" "$prev_model"
3506
+ echo -e "${YELLOW}Restored previous model '${prev_model}'.${ENDCOLOR}"
3507
+ else
3508
+ git config --unset "gitbasher.ai-model-$(get_ai_provider)" 2>/dev/null
3509
+ echo -e "${YELLOW}Removed it — provider '$(get_ai_provider)' uses its default ($(get_ai_default_model)).${ENDCOLOR}"
3510
+ fi
3511
+ if [ -n "$_chained" ]; then return 1; fi
3512
+ exit 1
3513
+ fi
3514
+ fi
3515
+ echo
3516
+ ;;
3517
+ esac
3275
3518
  if [ "$GITBASHER_NO_REPO" = "true" ]; then
3276
3519
  if [ -n "$_chained" ]; then return 0; fi
3277
3520
  exit
@@ -3281,7 +3524,7 @@ function configure_ai_model {
3281
3524
  read_key _model_global || { if [ -n "$_chained" ]; then return 130; fi; _model_global="n"; }
3282
3525
  if is_yes "$_model_global"; then
3283
3526
  echo -e "${YELLOW}Set AI model globally${ENDCOLOR}"
3284
- set_config_value gitbasher.ai-model "$model_input" "true"
3527
+ set_ai_model "$model_input" "true"
3285
3528
  fi
3286
3529
  return 0
3287
3530
  }
@@ -3292,6 +3535,11 @@ function configure_ai_wizard {
3292
3535
  configure_ai_provider --chained
3293
3536
  _rc=$?
3294
3537
  if [ "$_rc" -eq 130 ]; then prompt_aborted; fi
3538
+ if [ "$_rc" -eq 3 ]; then
3539
+ echo
3540
+ echo -e "${YELLOW}AI setup stopped — nothing changed.${ENDCOLOR}"
3541
+ return 0
3542
+ fi
3295
3543
  echo
3296
3544
  echo -e "${YELLOW}Step 2/3 — API key${ENDCOLOR}"
3297
3545
  echo
@@ -3325,9 +3573,9 @@ function configure_ai_proxy {
3325
3573
  echo -e "Enter proxy URL to route AI requests through (useful for bypassing geo-restrictions)"
3326
3574
  echo -e ""
3327
3575
  echo -e "${BLUE}HTTP proxy formats:${ENDCOLOR}"
3328
- echo -e " • ${BLUE}http://proxy.example.com:8080${ENDCOLOR}"
3329
- echo -e " • ${BLUE}http://username:password@proxy.example.com:8080${ENDCOLOR}"
3330
- echo -e " • ${BLUE}http://[2001:db8::1]:8080${ENDCOLOR} (IPv6)"
3576
+ echo -e " • ${BOLD}http://proxy.example.com:8080${ENDCOLOR}"
3577
+ echo -e " • ${BOLD}http://username:password@proxy.example.com:8080${ENDCOLOR}"
3578
+ echo -e " • ${BOLD}http://[2001:db8::1]:8080${ENDCOLOR} (IPv6)"
3331
3579
  echo -e ""
3332
3580
  echo -e "Press Enter to exit without changes, or 0 to remove the existing proxy"
3333
3581
  read_editable_input ai_proxy_input "Proxy URL: "
@@ -3582,11 +3830,11 @@ function delete_global {
3582
3830
  _delete_global_add "gitbasher.ai-ollama-host" "Ollama host" "$global_ollama_host" "$GREEN"
3583
3831
  local global_base_url=$(git config --global --get gitbasher.ai-base-url)
3584
3832
  _delete_global_add "gitbasher.ai-base-url" "AI base URL" "$global_base_url" "$GREEN"
3585
- local _task_models
3586
- _task_models=$(git config --global --get-regexp '^gitbasher\.ai-model-' 2>/dev/null | awk '{print $1}' | tr '\n' '+' | sed 's/+$//')
3587
- if [ -n "$_task_models" ]; then
3588
- labels+=("AI per-task models: ${GREEN}configured${ENDCOLOR}")
3589
- actions+=("${_task_models}|AI per-task models")
3833
+ local _provider_models
3834
+ _provider_models=$(git config --global --get-regexp '^gitbasher\.ai-model-' 2>/dev/null | awk '{print $1}' | tr '\n' '+' | sed 's/+$//')
3835
+ if [ -n "$_provider_models" ]; then
3836
+ labels+=("AI models (per provider): ${GREEN}configured${ENDCOLOR}")
3837
+ actions+=("${_provider_models}|AI models (per provider)")
3590
3838
  fi
3591
3839
  while IFS=$'\t' read -r key_name; do
3592
3840
  [ -z "$key_name" ] && continue
@@ -5348,7 +5596,7 @@ Re-do the grouping. Every input hash MUST appear in EXACTLY ONE \"commits\" arra
5348
5596
  local max_out=$(( commit_count * 120 ))
5349
5597
  [ "$max_out" -lt 2048 ] && max_out=2048
5350
5598
  [ "$max_out" -gt 16384 ] && max_out=16384
5351
- call_ai_api "$system_prompt" "$user_prompt" "$max_out" "$(get_ai_model_for grouping)" '{"type":"json_object"}'
5599
+ call_ai_api "$system_prompt" "$user_prompt" "$max_out" "" '{"type":"json_object"}' "low"
5352
5600
  }
5353
5601
  function squash_parse_ai_plan {
5354
5602
  local response="$1"
@@ -6724,6 +6972,18 @@ function get_max_split_groups {
6724
6972
  [ "$v" -gt 20 ] && v=20
6725
6973
  printf '%s' "$v"
6726
6974
  }
6975
+ function should_attempt_ai_grouping {
6976
+ local scope_count="$1" mode="$2"
6977
+ case "$mode" in
6978
+ always) return 0 ;;
6979
+ never) return 1 ;;
6980
+ esac
6981
+ case "$scope_count" in
6982
+ ''|*[!0-9]*) return 1 ;;
6983
+ esac
6984
+ [ "$scope_count" -ge 3 ] && return 0
6985
+ return 1
6986
+ }
6727
6987
  #
6728
6988
  #
6729
6989
  #
@@ -6870,7 +7130,7 @@ A feature is defined by WHAT the change accomplishes, read from the actual code
6870
7130
 
6871
7131
  Input arrives in XML tags: a list of staged files, the actual diff, a diff summary, recent commit messages (for scope-naming conventions), and folder-derived candidate scope tokens.
6872
7132
 
6873
- Task: assign EVERY staged file to exactly one feature group. Use 1 group only if all changes truly form a single feature; otherwise use 2 to ${max_groups} groups. Never exceed ${max_groups} groups — prefer a broader feature over several tiny ones. Name each group with a short lowercase conventional-commit scope describing the FEATURE, matching the style of <recent_commits>. <heuristic_candidates> are folder-derived hints — use one only if it happens to match a feature's purpose. Use \"misc\" only for unrelated incidental files with no feature (e.g., README, top-level config).
7133
+ Task: assign EVERY staged file to exactly one feature group. Use 1 group only if all changes truly form a single feature; otherwise use 2 to ${max_groups} groups. Never exceed ${max_groups} groups — prefer a broader feature over several tiny ones. Name each group with a short lowercase conventional-commit scope describing the FEATURE — the bare scope token only (e.g. \"auth\", never a full prefix like \"feat(auth)\" or a type like \"docs:\"), matching the scope-naming style of <recent_commits>. <heuristic_candidates> are folder-derived hints — use one only if it happens to match a feature's purpose. Use \"misc\" only for unrelated incidental files with no feature (e.g., README, top-level config).
6874
7134
 
6875
7135
  Output format: TSV only. One line per staged file in the form <scope><TAB><file_path>. No header, no prose, no markdown fences, no surrounding quotes. Every file from <staged_files> MUST appear exactly once. The file paths must be byte-identical to those in <staged_files>."
6876
7136
 
@@ -6896,8 +7156,9 @@ ${heuristic_hint}
6896
7156
 
6897
7157
  Read <diff> to decide which files implement the same feature. Output TSV (scope<TAB>file) for every staged file. No prose."
6898
7158
  local ai_response
6899
- ai_response=$(call_ai_api "$system_prompt" "$user_prompt" "$AI_MAX_TOKENS_FULL" "$(get_ai_model_for grouping)" 2>/dev/null)
7159
+ ai_response=$(call_ai_api "$system_prompt" "$user_prompt" "$AI_MAX_TOKENS_FULL" "" "" "low" 2>/dev/null)
6900
7160
  if [ $? -ne 0 ] || [ -z "$ai_response" ]; then
7161
+ ai_grouping_fail_reason="API error"
6901
7162
  return 1
6902
7163
  fi
6903
7164
  ai_response=$(printf '%s' "$ai_response" | LC_ALL=C sed -e 's/^```[a-zA-Z]*$//' -e 's/^```$//')
@@ -6905,6 +7166,14 @@ Read <diff> to decide which files implement the same feature. Output TSV (scope<
6905
7166
  while IFS= read -r f; do
6906
7167
  [ -n "$f" ] && gset_add staged_set "$f"
6907
7168
  done <<< "$staged_files"
7169
+ gmap_clear folder_scope_of
7170
+ local _fk _ff
7171
+ for _fk in "${split_group_keys[@]}"; do
7172
+ while IFS= read -r _ff; do
7173
+ [ -n "$_ff" ] && gmap_set folder_scope_of "$_ff" "$_fk"
7174
+ done <<< "$(gmap_get split_groups "$_fk")"
7175
+ done
7176
+ gmap_clear assigned_set
6908
7177
  gmap_clear new_groups
6909
7178
  local -a new_keys=()
6910
7179
  local line scope file tab_count
@@ -6916,11 +7185,18 @@ Read <diff> to decide which files implement the same feature. Output TSV (scope<
6916
7185
  [ "$tab_count" -ne 1 ] && continue
6917
7186
  scope="${line%% *}"
6918
7187
  file="${line#* }"
7188
+ scope="${scope%:}"
7189
+ local type_scope_re='^[a-zA-Z0-9._/-]+\(([a-zA-Z0-9._/-]+)\)$'
7190
+ if [[ "$scope" =~ $type_scope_re ]]; then
7191
+ scope="${BASH_REMATCH[1]}"
7192
+ fi
6919
7193
  if ! [[ "$scope" =~ ^[a-zA-Z0-9._/-]+$ ]]; then
6920
7194
  continue
6921
7195
  fi
6922
7196
  scope=$(printf '%s' "$scope" | LC_ALL=C tr '[:upper:]' '[:lower:]')
6923
7197
  gset_has staged_set "$file" || continue
7198
+ gset_has assigned_set "$file" && continue
7199
+ gset_add assigned_set "$file"
6924
7200
  if ! gmap_has new_groups "$scope"; then
6925
7201
  new_keys+=("$scope")
6926
7202
  gmap_set new_groups "$scope" "$file"
@@ -6928,15 +7204,44 @@ Read <diff> to decide which files implement the same feature. Output TSV (scope<
6928
7204
  gmap_set new_groups "$scope" "$(gmap_get new_groups "$scope")"$'\n'"$file"
6929
7205
  fi
6930
7206
  done <<< "$ai_response"
6931
- local total_assigned=0 total_staged
7207
+ local total_assigned=0 total_staged unmatched
6932
7208
  local key
6933
7209
  for key in "${new_keys[@]}"; do
6934
7210
  total_assigned=$((total_assigned + $(printf '%s\n' "$(gmap_get new_groups "$key")" | grep -c .)))
6935
7211
  done
6936
7212
  total_staged=$(printf '%s\n' "$staged_files" | grep -c .)
6937
- if [ "$total_assigned" -ne "$total_staged" ] || [ ${#new_keys[@]} -lt 1 ]; then
7213
+ if [ ${#new_keys[@]} -lt 1 ] || [ "$total_assigned" -eq 0 ]; then
7214
+ ai_grouping_fail_reason="no usable grouping returned"
7215
+ return 1
7216
+ fi
7217
+ unmatched=$((total_staged - total_assigned))
7218
+ [ "$unmatched" -lt 0 ] && unmatched=0 # dedup should prevent this; be safe
7219
+ if [ "$unmatched" -gt 0 ] && [ $((unmatched * 5)) -gt "$total_staged" ]; then
7220
+ ai_grouping_fail_reason="${unmatched} of ${total_staged} files unassigned"
6938
7221
  return 1
6939
7222
  fi
7223
+ if [ "$unmatched" -gt 0 ]; then
7224
+ local single_group=""
7225
+ [ ${#new_keys[@]} -eq 1 ] && single_group="${new_keys[0]}"
7226
+ local sf leftover_scope
7227
+ while IFS= read -r sf; do
7228
+ [ -z "$sf" ] && continue
7229
+ gset_has assigned_set "$sf" && continue
7230
+ if [ -n "$single_group" ]; then
7231
+ leftover_scope="$single_group"
7232
+ else
7233
+ leftover_scope=$(gmap_get folder_scope_of "$sf")
7234
+ [ -z "$leftover_scope" ] && leftover_scope="misc"
7235
+ fi
7236
+ if ! gmap_has new_groups "$leftover_scope"; then
7237
+ new_keys+=("$leftover_scope")
7238
+ gmap_set new_groups "$leftover_scope" "$sf"
7239
+ else
7240
+ gmap_set new_groups "$leftover_scope" "$(gmap_get new_groups "$leftover_scope")"$'\n'"$sf"
7241
+ fi
7242
+ gset_add assigned_set "$sf"
7243
+ done <<< "$staged_files"
7244
+ fi
6940
7245
  gmap_clear split_groups
6941
7246
  split_group_keys=()
6942
7247
  for key in "${new_keys[@]}"; do
@@ -7353,18 +7658,17 @@ function try_offer_commit_split {
7353
7658
  local ai_grouping
7354
7659
  ai_grouping=$(get_config_value gitbasher.commit-ai-grouping "auto")
7355
7660
  local should_use_ai="false"
7356
- case "$ai_grouping" in
7357
- never) should_use_ai="false" ;;
7358
- *) should_use_ai="true" ;; # auto + always: attempt AI
7359
- esac
7360
- if [ -z "$llm" ]; then
7361
- should_use_ai="false"
7661
+ if [ -n "$llm" ] && should_attempt_ai_grouping "${#split_group_keys[@]}" "$ai_grouping"; then
7662
+ should_use_ai="true"
7362
7663
  fi
7363
7664
  if [ "$should_use_ai" = "true" ] && check_ai_available 2>/dev/null; then
7364
7665
  echo
7365
7666
  echo -e "${YELLOW}Grouping changes by feature with AI...${ENDCOLOR}"
7667
+ local ai_grouping_fail_reason=""
7366
7668
  if ! group_files_by_feature_with_ai; then
7367
- echo -e "${YELLOW}AI feature grouping failed, falling back to folder-based grouping.${ENDCOLOR}"
7669
+ local reason_suffix=""
7670
+ [ -n "$ai_grouping_fail_reason" ] && reason_suffix=" (${ai_grouping_fail_reason})"
7671
+ echo -e "${YELLOW}AI feature grouping failed${reason_suffix}, using folder-based grouping.${ENDCOLOR}"
7368
7672
  fi
7369
7673
  consolidate_split_groups "$(get_max_split_groups)"
7370
7674
  fi
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitbasher",
3
- "version": "5.1.0",
3
+ "version": "5.2.1",
4
4
  "description": "Simple bash utility that makes git easy to use",
5
5
  "keywords": [
6
6
  "git",