@kyubiware/commit-mint 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -201,11 +201,15 @@ Stored in `~/.commit-mint` (INI format). Run `cmint config` to edit.
201
201
 
202
202
  | Key | Default | Description |
203
203
  |---|---|---|
204
- | `provider` | `groq` | `groq`, `cerebras`, or `mistral` |
204
+ | `provider` | `groq` | `groq`, `cerebras`, `mistral`, `commandcode`, `omniroute`, `openrouter`, or `gemini` |
205
205
  | `model` | `openai/gpt-oss-20b` | Default model; overridable per provider |
206
206
  | `model_groq` | — | Override default when provider is `groq` |
207
207
  | `model_cerebras` | — | Override default when provider is `cerebras` |
208
208
  | `model_mistral` | — | Override default when provider is `mistral` |
209
+ | `model_commandcode` | — | Override default when provider is `commandcode` |
210
+ | `model_omniroute` | — | Override default when provider is `omniroute` |
211
+ | `model_openrouter` | — | Override default when provider is `openrouter` |
212
+ | `model_gemini` | — | Override default when provider is `gemini` |
209
213
  | `locale` | `en` | Locale for generated messages |
210
214
  | `max-length` | `100` | Max commit message length |
211
215
  | `type` | — | Force commit type prefix |
@@ -223,6 +227,10 @@ API key lookup checks the env var first, then the INI file.
223
227
  | `GROQ_API_KEY` | `cmint` (when `provider=groq`) | Yes, unless set in `~/.commit-mint` |
224
228
  | `CEREBRAS_API_KEY` | `cmint` (when `provider=cerebras`) | Yes, unless set in `~/.commit-mint` |
225
229
  | `MISTRAL_API_KEY` | `cmint` (when `provider=mistral`) | Yes, unless set in `~/.commit-mint` |
230
+ | `COMMANDCODE_API_KEY` | `cmint` (when `provider=commandcode`) | Yes, unless set in `~/.commit-mint` |
231
+ | `OMNIROUTE_API_KEY` | `cmint` (when `provider=omniroute`) | Optional — local gateway, no key needed |
232
+ | `OPENROUTER_API_KEY` | `cmint` (when `provider=openrouter`) | Yes, unless set in `~/.commit-mint` |
233
+ | `GEMINI_API_KEY` | `cmint` (when `provider=gemini`) | Yes, unless set in `~/.commit-mint` |
226
234
  | `https_proxy` / `HTTPS_PROXY` | All providers | Optional — overrides `proxy` config key |
227
235
  | `NO_COLOR` | All UI | Optional — disables `kolorist` color output |
228
236
 
@@ -233,11 +241,19 @@ API key lookup checks the env var first, then the INI file.
233
241
  | `groq` | `GROQ_API_KEY` | `openai/gpt-oss-20b` | `groq-sdk` |
234
242
  | `cerebras` | `CEREBRAS_API_KEY` | `gpt-oss-120b` | Built-in fetch |
235
243
  | `mistral` | `MISTRAL_API_KEY` | `mistral-small` | Built-in fetch |
236
-
237
- All three use OpenAI-compatible APIs and have a generous free tier. Per-
238
- provider model overrides: set `model_groq`, `model_cerebras`, or `model_mistral`
244
+ | `commandcode` | `COMMANDCODE_API_KEY` | `deepseek/deepseek-v4-flash` | Built-in fetch |
245
+ | `omniroute` | `OMNIROUTE_API_KEY` | `auto/fast` | Built-in fetch (none local gateway) |
246
+ | `openrouter` | `OPENROUTER_API_KEY` | `openrouter/free` | Built-in fetch |
247
+ | `gemini` | `GEMINI_API_KEY` | `gemini-3.8-flash` | Built-in fetch |
248
+
249
+ All seven use OpenAI-compatible APIs and most have a generous free tier. Per-
250
+ provider model overrides: set `model_groq`, `model_cerebras`, `model_mistral`,
251
+ `model_commandcode`, `model_omniroute`, `model_openrouter`, or `model_gemini`
239
252
  in `~/.commit-mint`. Resolution order is `model_<provider>` → `model` →
240
- provider default.
253
+ provider default. OpenRouter and Gemini expose live model lists in `cmint
254
+ config` — ids tagged free (`:free`/`-free`, plus OpenRouter's `openrouter/free`
255
+ router) sort first and are labeled `(free)`. Gemini ids carry no free marker,
256
+ so its list keeps the provider's server order.
241
257
 
242
258
  ### Pre-flight Check Config
243
259
 
@@ -387,8 +403,9 @@ cmint logs -n 50 # last 50 lines
387
403
 
388
404
  - [x] Node.js 18+
389
405
  - [x] git
390
- - [x] One of: `GROQ_API_KEY`, `CEREBRAS_API_KEY`, or `MISTRAL_API_KEY`
391
- (or run `cmint config` to set one)
406
+ - [x] One of: `GROQ_API_KEY`, `CEREBRAS_API_KEY`, `MISTRAL_API_KEY`, or
407
+ `COMMANDCODE_API_KEY` (or run `cmint config` to set one; `omniroute`
408
+ needs no key)
392
409
  - [x] Optional, for clipboard copy: `wl-copy`, `xclip`, `xsel`, or `pbcopy`
393
410
 
394
411
  ## Support
package/dist/bin.mjs CHANGED
@@ -33,7 +33,7 @@ var __exportAll = (all, no_symbols) => {
33
33
  //#region package.json
34
34
  var package_default = {
35
35
  name: "@kyubiware/commit-mint",
36
- version: "0.12.0",
36
+ version: "0.13.0",
37
37
  description: "🌿 AI-powered git commit tool — auto-group changed files, generate messages, run pre-commit checks",
38
38
  type: "module",
39
39
  bin: { "cmint": "./dist/bin.mjs" },
@@ -43,6 +43,7 @@ var package_default = {
43
43
  "dev": "tsx src/bin.ts",
44
44
  "dev:auto": "tsx src/bin.ts -a",
45
45
  "dev:debug": "tsx src/bin.ts --debug",
46
+ "dev:config": "tsx src/bin.ts config",
46
47
  "lint": "biome check .",
47
48
  "lint:fix": "biome check --write --unsafe .",
48
49
  "typecheck": "tsc --noEmit",
@@ -136,10 +137,30 @@ const PROVIDER_CONFIGS = {
136
137
  baseURL: "https://api.mistral.ai/v1",
137
138
  defaultModel: "mistral-small"
138
139
  },
140
+ commandcode: {
141
+ baseURL: "https://api.commandcode.ai/provider/v1",
142
+ defaultModel: "deepseek/deepseek-v4-flash",
143
+ supportsModelList: true,
144
+ displayName: "Command Code",
145
+ modelListExclude: /^claude/
146
+ },
139
147
  omniroute: {
140
148
  baseURL: "http://localhost:20128/v1",
141
- defaultModel: "auto",
149
+ defaultModel: "auto/fast",
142
150
  requiresApiKey: false
151
+ },
152
+ openrouter: {
153
+ baseURL: "https://openrouter.ai/api/v1",
154
+ defaultModel: "openrouter/free",
155
+ supportsModelList: true,
156
+ displayName: "OpenRouter"
157
+ },
158
+ gemini: {
159
+ baseURL: "https://generativelanguage.googleapis.com/v1beta/openai",
160
+ defaultModel: "gemini-3.8-flash",
161
+ supportsModelList: true,
162
+ displayName: "Gemini",
163
+ modelListExclude: /embedding|image|tts|veo|imagen|nano-banana|audio/i
143
164
  }
144
165
  };
145
166
  const ALLOWED_PROVIDERS = Object.keys(PROVIDER_CONFIGS);
@@ -147,19 +168,33 @@ const PROVIDER_ENV_KEYS = {
147
168
  groq: "GROQ_API_KEY",
148
169
  cerebras: "CEREBRAS_API_KEY",
149
170
  mistral: "MISTRAL_API_KEY",
150
- omniroute: "OMNIROUTE_API_KEY"
171
+ commandcode: "COMMANDCODE_API_KEY",
172
+ omniroute: "OMNIROUTE_API_KEY",
173
+ openrouter: "OPENROUTER_API_KEY",
174
+ gemini: "GEMINI_API_KEY"
151
175
  };
152
176
  function formatProviderName(provider) {
177
+ const displayName = PROVIDER_CONFIGS[provider]?.displayName;
178
+ if (displayName) return displayName;
153
179
  return provider.charAt(0).toUpperCase() + provider.slice(1);
154
180
  }
155
181
  function isValidProvider(name) {
156
182
  return ALLOWED_PROVIDERS.includes(name);
157
183
  }
158
184
  /**
159
- * Generic OpenAI-compatible chat completions client using fetch.
160
- * Used for non-Groq providers where the Groq SDK's hardcoded `/openai/v1/` path
161
- * prefix doesn't match the provider's actual API path.
185
+ * Parse a chat-completions JSON response and normalize gateway quirks:
186
+ * non-2xx thrown Error, DeepSeek-style `reasoning_content` OpenAI-style
187
+ * `reasoning` so downstream fallbacks see one field name.
162
188
  */
189
+ async function readCompletion(response) {
190
+ if (!response.ok) {
191
+ const text = await response.text().catch(() => "");
192
+ throw new Error(`${response.status} ${text}`);
193
+ }
194
+ const data = await response.json();
195
+ for (const choice of data.choices ?? []) if (!choice.message?.reasoning && choice.message?.reasoning_content) choice.message.reasoning = choice.message.reasoning_content;
196
+ return data;
197
+ }
163
198
  function createFetchClient(baseURL, apiKey, timeout) {
164
199
  return { chat: { completions: { async create(params) {
165
200
  const url = `${baseURL}/chat/completions`;
@@ -167,20 +202,21 @@ function createFetchClient(baseURL, apiKey, timeout) {
167
202
  const controller = new AbortController();
168
203
  const timer = setTimeout(() => controller.abort(), timeout);
169
204
  try {
170
- const response = await fetch(url, {
205
+ return await readCompletion(await fetch(url, {
171
206
  method: "POST",
172
207
  headers: {
173
208
  "Content-Type": "application/json",
174
209
  ...apiKey ? { Authorization: `Bearer ${apiKey}` } : {}
175
210
  },
176
- body: JSON.stringify(params),
211
+ body: JSON.stringify({
212
+ ...params,
213
+ stream: false
214
+ }),
177
215
  signal: controller.signal
178
- });
179
- if (!response.ok) {
180
- const text = await response.text().catch(() => "");
181
- throw new Error(`${response.status} ${text}`);
182
- }
183
- return await response.json();
216
+ }));
217
+ } catch (error) {
218
+ if (error instanceof Error && error.name === "AbortError") throw new Error(`Request timed out after ${timeout}ms`);
219
+ throw error;
184
220
  } finally {
185
221
  clearTimeout(timer);
186
222
  }
@@ -204,6 +240,42 @@ function createProvider(options) {
204
240
  model
205
241
  };
206
242
  }
243
+ /**
244
+ * Fetch a provider's live model list via GET {baseURL}/models (standard OpenAI
245
+ * list shape). Sends `Authorization: Bearer <key>` only when a key is
246
+ * provided; throws on non-2xx responses and malformed payloads. Server order
247
+ * is preserved (curated, flagship models first).
248
+ */
249
+ async function fetchProviderModels(provider, apiKey = "", timeoutMs = 15e3) {
250
+ const url = `${PROVIDER_CONFIGS[provider].baseURL}/models`;
251
+ const response = await fetch(url, {
252
+ headers: { ...apiKey ? { Authorization: `Bearer ${apiKey}` } : {} },
253
+ signal: AbortSignal.timeout(timeoutMs)
254
+ });
255
+ if (!response.ok) throw new Error(`Failed to fetch models from ${provider} (HTTP ${response.status})`);
256
+ const json = await response.json();
257
+ if (!Array.isArray(json.data)) throw new Error(`Invalid model list response from ${provider}: expected { data: [...] }`);
258
+ const models = [];
259
+ for (const entry of json.data) {
260
+ if (entry.id === void 0 || entry.id === null) continue;
261
+ models.push({
262
+ id: String(entry.id),
263
+ name: typeof entry.name === "string" ? entry.name : void 0,
264
+ contextLength: typeof entry.context_length === "number" ? entry.context_length : void 0
265
+ });
266
+ }
267
+ return models;
268
+ }
269
+ /**
270
+ * Free models are flagged in the id by the provider's own convention — a
271
+ * `-free` / `:free` suffix (e.g. `poolside/laguna-s-2.1-free`,
272
+ * `meituan/LongCat-2.0:free`), matching the models CommandCode's pricing docs
273
+ * list as free. OpenRouter's `openrouter/free` router id counts as free by
274
+ * exact match.
275
+ */
276
+ function isFreeModelId(id) {
277
+ return id === "openrouter/free" || /[:-]free$/i.test(id);
278
+ }
207
279
  //#endregion
208
280
  //#region src/services/config.ts
209
281
  const CONFIG_PATH = join(os.homedir(), ".commit-mint");
@@ -1268,6 +1340,7 @@ function mapGroqError(error, providerLabel) {
1268
1340
  if (error instanceof Groq.APIConnectionTimeoutError) return /* @__PURE__ */ new Error("Request timed out. Check your network or try a smaller diff.");
1269
1341
  if (error instanceof Groq.APIError) return /* @__PURE__ */ new Error(`${label} API error: ${error.message}`);
1270
1342
  if (error instanceof Error && /^4\d{2}\s/.test(error.message)) return /* @__PURE__ */ new Error(`${label} API error: ${error.message}`);
1343
+ if (error instanceof Error && (error.name === "AbortError" || /timed out/i.test(error.message))) return /* @__PURE__ */ new Error(`Request to ${label} timed out. Check your network, try a smaller diff, or increase 'timeout' in ~/.commit-mint.`);
1271
1344
  return /* @__PURE__ */ new Error(`Unexpected error: ${error instanceof Error ? error.message : String(error)}`);
1272
1345
  }
1273
1346
  const CONVENTIONAL_COMMIT_REGEX = /^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\(.+\))?!?: .+$/;
@@ -4572,6 +4645,21 @@ async function promptProvider() {
4572
4645
  value: "mistral",
4573
4646
  hint: PROVIDER_CONFIGS.mistral.defaultModel
4574
4647
  },
4648
+ {
4649
+ label: "Command Code",
4650
+ value: "commandcode",
4651
+ hint: PROVIDER_CONFIGS.commandcode.defaultModel
4652
+ },
4653
+ {
4654
+ label: "OpenRouter",
4655
+ value: "openrouter",
4656
+ hint: PROVIDER_CONFIGS.openrouter.defaultModel
4657
+ },
4658
+ {
4659
+ label: "Gemini",
4660
+ value: "gemini",
4661
+ hint: PROVIDER_CONFIGS.gemini.defaultModel
4662
+ },
4575
4663
  {
4576
4664
  label: "OmniRoute",
4577
4665
  value: "omniroute",
@@ -4612,6 +4700,55 @@ const requireNumber = (v) => {
4612
4700
  if (!v?.trim()) return "Value cannot be empty";
4613
4701
  return Number.isNaN(Number(v)) ? "Must be a number" : void 0;
4614
4702
  };
4703
+ /**
4704
+ * Build select options for the live model list: free models first (provider
4705
+ * id convention: `-free` / `:free` suffix), then the rest in the server's
4706
+ * curated order. `exclude` hides ids the provider's /chat/completions
4707
+ * endpoint can't serve (registry `modelListExclude` — e.g. CommandCode's
4708
+ * Anthropic-format claude-* models, Gemini's embeddings/TTS/image models).
4709
+ */
4710
+ function buildModelOptions(models, exclude) {
4711
+ const usable = exclude ? models.filter((m) => !exclude.test(m.id)) : models;
4712
+ return [...usable.filter((m) => isFreeModelId(m.id)), ...usable.filter((m) => !isFreeModelId(m.id))].map((m) => {
4713
+ const label = m.name ?? m.id;
4714
+ return {
4715
+ label: isFreeModelId(m.id) ? `${label} (free)` : label,
4716
+ value: m.id,
4717
+ hint: m.contextLength ? `${Math.round(m.contextLength / 1e3)}k ctx` : void 0
4718
+ };
4719
+ });
4720
+ }
4721
+ async function pickModelFromList(provider, currentModel) {
4722
+ let apiKey;
4723
+ try {
4724
+ apiKey = await getProviderApiKey(provider);
4725
+ } catch {
4726
+ return;
4727
+ }
4728
+ let models;
4729
+ try {
4730
+ models = await fetchProviderModels(provider, apiKey);
4731
+ } catch {
4732
+ p$1.log.warn("Could not fetch model list — enter the model ID manually");
4733
+ return;
4734
+ }
4735
+ const options = buildModelOptions(models, PROVIDER_CONFIGS[provider].modelListExclude);
4736
+ if (options.length === 0) {
4737
+ p$1.log.warn("Could not fetch model list — enter the model ID manually");
4738
+ return;
4739
+ }
4740
+ const initial = options.find((o) => o.value === currentModel);
4741
+ const selected = await p$1.select({
4742
+ message: "Select model:",
4743
+ options,
4744
+ ...initial ? { initialValue: initial.value } : {},
4745
+ maxItems: 12
4746
+ });
4747
+ if (p$1.isCancel(selected)) return selected;
4748
+ await writeConfig({ model: selected });
4749
+ debug("config: model set to %s", selected);
4750
+ return selected;
4751
+ }
4615
4752
  function getSettingHandlers(config) {
4616
4753
  const provider = getProvider(config);
4617
4754
  return {
@@ -4633,7 +4770,12 @@ function getSettingHandlers(config) {
4633
4770
  },
4634
4771
  apikey: async () => promptApiKey(provider),
4635
4772
  model: async () => {
4636
- return promptTextSetting("Model ID:", "model", getModelForProvider(config, provider, PROVIDER_CONFIGS[provider].defaultModel));
4773
+ const effectiveModel = getModelForProvider(config, provider, PROVIDER_CONFIGS[provider].defaultModel);
4774
+ if (PROVIDER_CONFIGS[provider].supportsModelList) {
4775
+ const picked = await pickModelFromList(provider, effectiveModel);
4776
+ if (picked !== void 0) return picked;
4777
+ }
4778
+ return promptTextSetting("Model ID:", "model", effectiveModel);
4637
4779
  },
4638
4780
  locale: async () => promptTextSetting("Locale (e.g. en, ja, ko):", "locale", config.locale),
4639
4781
  maxlen: async () => promptTextSetting("Max commit message length:", "max-length", config["max-length"], requireNumber),