code-agent-auto-commit 1.3.0 → 1.3.2

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
@@ -45,9 +45,9 @@ cac --help
45
45
  cac init
46
46
 
47
47
  # 2. Configure AI API key for commit messages
48
- # Edit .code-agent-auto-commit.json — set your provider, model, and API key env var.
49
- # Or export the key in your shell:
50
- export MINIMAX_API_KEY='your-api-key' # or OPENAI_API_KEY, etc.
48
+ # Edit .cac/.code-agent-auto-commit.json — set your model and defaultProvider.
49
+ # Fill keys in .cac/.env and load them:
50
+ source .cac/.env
51
51
 
52
52
  # 3. Install hooks
53
53
  cac install --tool all --scope project
@@ -79,22 +79,31 @@ cac uninstall [--tool all|opencode|codex|claude] [--scope project|global] [--wor
79
79
  cac status [--scope project|global] [--worktree <path>] [--config <path>]
80
80
  cac run [--tool opencode|codex|claude|manual] [--worktree <path>] [--config <path>] [--event-json <json>] [--event-stdin]
81
81
  cac set-worktree <path> [--config <path>]
82
+ cac ai <message> [--config <path>]
83
+ cac ai set-key <provider|ENV_VAR> <api-key> [--config <path>]
84
+ cac ai get-key <provider|ENV_VAR> [--config <path>]
82
85
  ```
83
86
 
84
87
  ### Command Details
85
88
 
86
- - `cac init`: creates a config file for a worktree. It resolves the target path from `--config` or defaults to `<worktree>/.code-agent-auto-commit.json`.
89
+ - `cac init`: creates `.cac/.code-agent-auto-commit.json` under the worktree (unless `--config` is provided), and also writes `.cac/.env.example` and `.cac/.env` with default provider API key env vars.
87
90
  - `cac install`: installs adapters/hooks for selected tools (`opencode`, `codex`, `claude`) in `project` or `global` scope. If no config exists at the resolved path, it creates one first.
88
91
  - `cac uninstall`: removes previously installed adapters/hooks for selected tools and scope.
89
92
  - `cac status`: prints resolved config path, worktree, commit mode, AI/push toggles, and install status of each adapter.
90
- - `cac run`: executes one auto-commit pass (manual or hook-triggered). It reads config, filters changed files, stages/commits by configured mode, and optionally pushes.
93
+ - `cac run`: executes one auto-commit pass (manual or hook-triggered). It reads config, filters changed files, stages/commits by configured mode, and optionally pushes. Hook-triggered runs also write logs to `.cac/run-<timestamp>.log`.
91
94
  - `cac set-worktree`: updates only the `worktree` field in the resolved config file.
95
+ - `cac ai`: tests AI request (`cac ai "hi"`) or manages global keys (`set-key` / `get-key`).
92
96
 
93
97
  ## Config File
94
98
 
95
99
  Default project config file:
96
100
 
97
- `.code-agent-auto-commit.json`
101
+ `.cac/.code-agent-auto-commit.json`
102
+
103
+ Generated env templates:
104
+
105
+ - `.cac/.env.example`
106
+ - `.cac/.env`
98
107
 
99
108
  You can copy from:
100
109
 
package/dist/cli.js CHANGED
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  };
6
6
  Object.defineProperty(exports, "__esModule", { value: true });
7
7
  const node_fs_1 = __importDefault(require("node:fs"));
8
+ const node_os_1 = __importDefault(require("node:os"));
8
9
  const node_path_1 = __importDefault(require("node:path"));
9
10
  const claude_1 = require("./adapters/claude");
10
11
  const codex_1 = require("./adapters/codex");
@@ -73,6 +74,87 @@ function parseTools(value) {
73
74
  }
74
75
  return tools;
75
76
  }
77
+ const ENV_NAME_REGEX = /^[A-Z_][A-Z0-9_]*$/;
78
+ function resolveAiEnvName(target, aiConfig) {
79
+ if (ENV_NAME_REGEX.test(target)) {
80
+ return target;
81
+ }
82
+ const providerConfig = aiConfig.providers[target];
83
+ const envName = providerConfig?.apiKeyEnv?.trim();
84
+ if (envName && ENV_NAME_REGEX.test(envName)) {
85
+ return envName;
86
+ }
87
+ throw new Error(`Unknown provider or env var: ${target}`);
88
+ }
89
+ function shellQuoteSingle(value) {
90
+ return `'${value.replace(/'/g, `'"'"'`)}'`;
91
+ }
92
+ function parseExportValue(raw) {
93
+ const trimmed = raw.trim();
94
+ if (trimmed.startsWith("'") && trimmed.endsWith("'")) {
95
+ return trimmed.slice(1, -1).replace(/'"'"'/g, "'");
96
+ }
97
+ if (trimmed.startsWith("\"") && trimmed.endsWith("\"")) {
98
+ return trimmed.slice(1, -1);
99
+ }
100
+ return trimmed;
101
+ }
102
+ function readKeyFromEnvFile(filePath, envName) {
103
+ if (!node_fs_1.default.existsSync(filePath)) {
104
+ return undefined;
105
+ }
106
+ const lines = node_fs_1.default.readFileSync(filePath, "utf8").split(/\r?\n/);
107
+ for (const line of lines) {
108
+ const match = line.match(/^\s*export\s+([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
109
+ if (!match) {
110
+ continue;
111
+ }
112
+ if (match[1] !== envName) {
113
+ continue;
114
+ }
115
+ return parseExportValue(match[2]);
116
+ }
117
+ return undefined;
118
+ }
119
+ function detectShellRcPath() {
120
+ const shell = node_path_1.default.basename(process.env.SHELL ?? "");
121
+ const home = node_os_1.default.homedir();
122
+ if (shell === "zsh") {
123
+ return node_path_1.default.join(home, ".zshrc");
124
+ }
125
+ if (shell === "bash") {
126
+ return node_path_1.default.join(home, ".bashrc");
127
+ }
128
+ if (shell === "fish") {
129
+ return node_path_1.default.join(home, ".config", "fish", "config.fish");
130
+ }
131
+ return node_path_1.default.join(home, ".profile");
132
+ }
133
+ function ensureGlobalKeysSource(shellRcPath, keysPath) {
134
+ const isFish = node_path_1.default.basename(shellRcPath) === "config.fish";
135
+ const sourceLine = isFish
136
+ ? `if test -f ${JSON.stringify(keysPath)}; source ${JSON.stringify(keysPath)}; end`
137
+ : `[ -f ${JSON.stringify(keysPath)} ] && source ${JSON.stringify(keysPath)}`;
138
+ const existing = node_fs_1.default.existsSync(shellRcPath) ? node_fs_1.default.readFileSync(shellRcPath, "utf8") : "";
139
+ if (existing.includes(keysPath)) {
140
+ return false;
141
+ }
142
+ const next = existing.trimEnd().length > 0
143
+ ? `${existing.trimEnd()}\n\n# code-agent-auto-commit global AI keys\n${sourceLine}\n`
144
+ : `# code-agent-auto-commit global AI keys\n${sourceLine}\n`;
145
+ (0, fs_1.writeTextFile)(shellRcPath, next);
146
+ return true;
147
+ }
148
+ function writeRunLog(worktree, lines) {
149
+ const logPath = (0, fs_1.getProjectRunLogPath)(worktree);
150
+ const content = [
151
+ `time=${new Date().toISOString()}`,
152
+ ...lines,
153
+ "",
154
+ ].join("\n");
155
+ (0, fs_1.writeTextFile)(logPath, content);
156
+ return logPath;
157
+ }
76
158
  async function readStdinText() {
77
159
  const chunks = [];
78
160
  for await (const chunk of process.stdin) {
@@ -91,6 +173,8 @@ Usage:
91
173
  cac run [--tool opencode|codex|claude|manual] [--worktree <path>] [--config <path>] [--event-json <json>] [--event-stdin]
92
174
  cac set-worktree <path> [--config <path>]
93
175
  cac ai <message> [--config <path>]
176
+ cac ai set-key <provider|ENV_VAR> <api-key> [--config <path>]
177
+ cac ai get-key <provider|ENV_VAR> [--config <path>]
94
178
  cac version
95
179
  `);
96
180
  }
@@ -100,6 +184,8 @@ async function commandInit(flags) {
100
184
  const configPath = explicit ? node_path_1.default.resolve(explicit) : (0, fs_1.getProjectConfigPath)(worktree);
101
185
  (0, config_1.initConfigFile)(configPath, worktree);
102
186
  console.log(`Initialized config: ${configPath}`);
187
+ console.log(`Generated env template: ${(0, fs_1.getProjectEnvExamplePath)(worktree)}`);
188
+ console.log(`Generated local env: ${(0, fs_1.getProjectEnvPath)(worktree)}`);
103
189
  }
104
190
  async function commandInstall(flags) {
105
191
  const worktree = node_path_1.default.resolve(getStringFlag(flags, "worktree") ?? process.cwd());
@@ -198,6 +284,23 @@ async function commandRun(flags, positionals) {
198
284
  const tool = (getStringFlag(flags, "tool") ?? "manual");
199
285
  const worktree = getStringFlag(flags, "worktree");
200
286
  const configPath = getStringFlag(flags, "config");
287
+ const shouldLogRun = tool !== "manual";
288
+ const runLogLines = [];
289
+ const logInfo = (message) => {
290
+ console.log(message);
291
+ runLogLines.push(message);
292
+ };
293
+ const logWarn = (message) => {
294
+ console.warn(message);
295
+ runLogLines.push(message);
296
+ };
297
+ const flushRunLog = (resolvedWorktree) => {
298
+ if (!shouldLogRun) {
299
+ return;
300
+ }
301
+ const logPath = writeRunLog(resolvedWorktree, runLogLines);
302
+ logInfo(`Run log: ${logPath}`);
303
+ };
201
304
  let event;
202
305
  const eventJson = getStringFlag(flags, "event-json");
203
306
  if (eventJson) {
@@ -220,37 +323,119 @@ async function commandRun(flags, positionals) {
220
323
  if (tool === "codex" && event && typeof event === "object") {
221
324
  const eventType = event.type;
222
325
  if (eventType && eventType !== "agent-turn-complete") {
223
- console.log(`Skipped: codex event ${eventType}`);
326
+ logInfo(`Skipped: codex event ${eventType}`);
224
327
  return;
225
328
  }
226
329
  }
227
- const result = await (0, run_1.runAutoCommit)({
228
- tool,
229
- worktree,
230
- event,
231
- sessionID: getStringFlag(flags, "session-id"),
232
- }, {
233
- explicitPath: configPath,
234
- worktree,
235
- });
330
+ let result;
331
+ try {
332
+ result = await (0, run_1.runAutoCommit)({
333
+ tool,
334
+ worktree,
335
+ event,
336
+ sessionID: getStringFlag(flags, "session-id"),
337
+ }, {
338
+ explicitPath: configPath,
339
+ worktree,
340
+ });
341
+ }
342
+ catch (error) {
343
+ const message = error instanceof Error ? error.message : String(error);
344
+ logWarn(`Error: ${message}`);
345
+ flushRunLog(node_path_1.default.resolve(worktree ?? process.cwd()));
346
+ throw error;
347
+ }
236
348
  if (result.skipped) {
237
- console.log(`Skipped: ${result.reason ?? "unknown"}`);
349
+ logInfo(`Skipped: ${result.reason ?? "unknown"}`);
350
+ flushRunLog(result.worktree);
238
351
  return;
239
352
  }
240
- console.log(`Committed: ${result.committed.length}`);
353
+ logInfo(`Committed: ${result.committed.length}`);
241
354
  for (const item of result.committed) {
242
- console.log(`- ${item.hash.slice(0, 12)} ${item.message}`);
355
+ logInfo(`- ${item.hash.slice(0, 12)} ${item.message}`);
243
356
  }
244
- console.log(`Pushed: ${result.pushed ? "yes" : "no"}`);
357
+ logInfo(`Pushed: ${result.pushed ? "yes" : "no"}`);
245
358
  if (result.tokenUsage) {
246
- console.log(`AI tokens: ${result.tokenUsage.totalTokens} (prompt: ${result.tokenUsage.promptTokens}, completion: ${result.tokenUsage.completionTokens})`);
359
+ logInfo(`AI tokens: ${result.tokenUsage.totalTokens} (prompt: ${result.tokenUsage.promptTokens}, completion: ${result.tokenUsage.completionTokens})`);
247
360
  }
248
361
  if (result.aiWarning) {
249
- console.warn(`\nWarning: AI commit message failed — ${result.aiWarning}`);
250
- console.warn(`Using fallback prefix instead. Run "cac ai hello" to test your AI config.`);
362
+ logWarn("");
363
+ logWarn(`Warning: AI commit message failed ${result.aiWarning}`);
364
+ logWarn(`Using fallback prefix instead. Run "cac ai hello" to test your AI config.`);
251
365
  }
366
+ flushRunLog(result.worktree);
252
367
  }
253
368
  async function commandAI(flags, positionals) {
369
+ const worktree = node_path_1.default.resolve(getStringFlag(flags, "worktree") ?? process.cwd());
370
+ const explicitConfig = getStringFlag(flags, "config");
371
+ const loaded = (0, config_1.loadConfig)({ explicitPath: explicitConfig, worktree });
372
+ const subcommand = positionals[0];
373
+ if (subcommand === "set-key") {
374
+ const target = positionals[1];
375
+ const key = positionals.slice(2).join(" ").trim();
376
+ if (!target || !key) {
377
+ console.error("Usage: cac ai set-key <provider|ENV_VAR> <api-key>");
378
+ process.exitCode = 1;
379
+ return;
380
+ }
381
+ const envName = resolveAiEnvName(target, loaded.config.ai);
382
+ const keysPath = (0, fs_1.getGlobalKeysEnvPath)();
383
+ const line = `export ${envName}=${shellQuoteSingle(key)}`;
384
+ const existing = node_fs_1.default.existsSync(keysPath) ? node_fs_1.default.readFileSync(keysPath, "utf8") : "";
385
+ const lines = existing.split(/\r?\n/);
386
+ let found = false;
387
+ const updated = lines.map((entry) => {
388
+ const match = entry.match(/^\s*export\s+([A-Za-z_][A-Za-z0-9_]*)=/);
389
+ if (match && match[1] === envName) {
390
+ found = true;
391
+ return line;
392
+ }
393
+ return entry;
394
+ });
395
+ if (!found) {
396
+ if (updated.length === 1 && updated[0] === "") {
397
+ updated.splice(0, 1);
398
+ }
399
+ if (updated.length === 0) {
400
+ updated.push("# code-agent-auto-commit global AI keys");
401
+ }
402
+ updated.push(line);
403
+ }
404
+ (0, fs_1.writeTextFile)(keysPath, `${updated.filter((entry, idx, arr) => !(idx === arr.length - 1 && entry === "")).join("\n")}\n`);
405
+ process.env[envName] = key;
406
+ const shellRcPath = detectShellRcPath();
407
+ const inserted = ensureGlobalKeysSource(shellRcPath, keysPath);
408
+ console.log(`Set ${envName} in ${keysPath}`);
409
+ if (inserted) {
410
+ console.log(`Added source line to ${shellRcPath}`);
411
+ }
412
+ console.log(`Global key configured. Open a new shell or run: source ${shellRcPath}`);
413
+ return;
414
+ }
415
+ if (subcommand === "get-key") {
416
+ const target = positionals[1];
417
+ if (!target) {
418
+ console.error("Usage: cac ai get-key <provider|ENV_VAR>");
419
+ process.exitCode = 1;
420
+ return;
421
+ }
422
+ const envName = resolveAiEnvName(target, loaded.config.ai);
423
+ const fromProcess = process.env[envName]?.trim();
424
+ const fromFile = readKeyFromEnvFile((0, fs_1.getGlobalKeysEnvPath)(), envName);
425
+ const value = fromProcess || fromFile;
426
+ if (!value) {
427
+ console.log(`${envName} is not set`);
428
+ process.exitCode = 1;
429
+ return;
430
+ }
431
+ const masked = value.length <= 8
432
+ ? `${value.slice(0, 2)}***`
433
+ : `${value.slice(0, 4)}***${value.slice(-4)}`;
434
+ console.log(`Env: ${envName}`);
435
+ console.log(`Value: ${masked}`);
436
+ console.log(`Source: ${fromProcess ? "process env" : (0, fs_1.getGlobalKeysEnvPath)()}`);
437
+ return;
438
+ }
254
439
  const message = positionals.join(" ").trim();
255
440
  if (!message) {
256
441
  console.error(`Usage: cac ai <message>`);
@@ -258,9 +443,6 @@ async function commandAI(flags, positionals) {
258
443
  process.exitCode = 1;
259
444
  return;
260
445
  }
261
- const worktree = node_path_1.default.resolve(getStringFlag(flags, "worktree") ?? process.cwd());
262
- const explicitConfig = getStringFlag(flags, "config");
263
- const loaded = (0, config_1.loadConfig)({ explicitPath: explicitConfig, worktree });
264
446
  console.log(`Provider: ${loaded.config.ai.defaultProvider}`);
265
447
  console.log(`Model: ${loaded.config.ai.model}`);
266
448
  console.log(`Sending: "${message}"`);
package/dist/core/ai.js CHANGED
@@ -13,6 +13,32 @@ const TYPE_ALIASES = {
13
13
  refactoring: "refactor",
14
14
  refector: "refactor",
15
15
  };
16
+ const MINIMAX_MODEL_ALIASES = {
17
+ "minimax-m2.5": "MiniMax-M2.5",
18
+ "minimax-m2.5-highspeed": "MiniMax-M2.5-highspeed",
19
+ "minimax-m2.1": "MiniMax-M2.1",
20
+ "minimax-m2.1-highspeed": "MiniMax-M2.1-highspeed",
21
+ "minimax-m2": "MiniMax-M2",
22
+ "minimax-text-01": "MiniMax-Text-01",
23
+ "text-01": "MiniMax-Text-01",
24
+ };
25
+ function normalizeProviderModel(provider, model) {
26
+ const trimmed = model.trim();
27
+ const raw = trimmed.includes("/") ? trimmed.slice(trimmed.lastIndexOf("/") + 1) : trimmed;
28
+ if (provider !== "minimax") {
29
+ return raw;
30
+ }
31
+ return MINIMAX_MODEL_ALIASES[raw.toLowerCase()] ?? raw;
32
+ }
33
+ function minimaxFallbackModel(model) {
34
+ return model === "MiniMax-Text-01" ? undefined : "MiniMax-Text-01";
35
+ }
36
+ function isUnknownModelError(status, body) {
37
+ if (status < 400 || status >= 500) {
38
+ return false;
39
+ }
40
+ return /unknown\s+model|invalid\s+model|model.*not\s+found|does\s+not\s+exist|not\s+supported/i.test(body);
41
+ }
16
42
  function normalizeCommitType(raw) {
17
43
  const value = raw.trim().toLowerCase();
18
44
  if (VALID_TYPES.has(value)) {
@@ -134,7 +160,7 @@ function validateAIConfig(ai) {
134
160
  }
135
161
  return undefined;
136
162
  }
137
- async function generateOpenAiStyleMessage(provider, model, summary, maxLength, signal) {
163
+ async function generateOpenAiStyleMessage(providerName, provider, model, summary, maxLength, signal) {
138
164
  const apiKey = getApiKey(provider);
139
165
  const headers = {
140
166
  "Content-Type": "application/json",
@@ -143,38 +169,59 @@ async function generateOpenAiStyleMessage(provider, model, summary, maxLength, s
143
169
  if (apiKey) {
144
170
  headers.Authorization = `Bearer ${apiKey}`;
145
171
  }
146
- const response = await fetch(`${provider.baseUrl.replace(/\/$/, "")}/chat/completions`, {
147
- method: "POST",
148
- headers,
149
- body: JSON.stringify({
150
- model,
151
- temperature: 0.2,
152
- messages: [
153
- {
154
- role: "system",
155
- content: "You generate exactly one conventional commit message. Format: '<type>(<scope>): <description>'. Scope is optional. Allowed types: feat, fix, refactor, docs, style, test, chore, perf, ci, build. Description must be imperative, lowercase, no period. Describe the actual change, not just 'update <file>'. No quotes. No code block.",
156
- },
157
- {
158
- role: "user",
159
- content: buildUserPrompt(summary, maxLength),
160
- },
161
- ],
162
- }),
163
- signal,
164
- });
165
- if (!response.ok) {
166
- const body = await response.text().catch(() => "");
167
- return { content: undefined, usage: undefined, error: `HTTP ${response.status}: ${body.slice(0, 200)}` };
172
+ async function requestModel(modelName) {
173
+ const response = await fetch(`${provider.baseUrl.replace(/\/$/, "")}/chat/completions`, {
174
+ method: "POST",
175
+ headers,
176
+ body: JSON.stringify({
177
+ model: modelName,
178
+ temperature: 0.2,
179
+ messages: [
180
+ {
181
+ role: "system",
182
+ content: "You generate exactly one conventional commit message. Format: '<type>(<scope>): <description>'. Scope is optional. Allowed types: feat, fix, refactor, docs, style, test, chore, perf, ci, build. Description must be imperative, lowercase, no period. Describe the actual change, not just 'update <file>'. No quotes. No code block.",
183
+ },
184
+ {
185
+ role: "user",
186
+ content: buildUserPrompt(summary, maxLength),
187
+ },
188
+ ],
189
+ }),
190
+ signal,
191
+ });
192
+ if (!response.ok) {
193
+ const body = await response.text().catch(() => "");
194
+ return { ok: false, status: response.status, body };
195
+ }
196
+ const payload = (await response.json());
197
+ const usage = payload.usage
198
+ ? {
199
+ promptTokens: payload.usage.prompt_tokens ?? 0,
200
+ completionTokens: payload.usage.completion_tokens ?? 0,
201
+ totalTokens: payload.usage.total_tokens ?? 0,
202
+ }
203
+ : undefined;
204
+ return { ok: true, content: payload.choices?.[0]?.message?.content, usage };
168
205
  }
169
- const payload = (await response.json());
170
- const usage = payload.usage
171
- ? {
172
- promptTokens: payload.usage.prompt_tokens ?? 0,
173
- completionTokens: payload.usage.completion_tokens ?? 0,
174
- totalTokens: payload.usage.total_tokens ?? 0,
206
+ const first = await requestModel(model);
207
+ if (first.ok) {
208
+ return { content: first.content, usage: first.usage };
209
+ }
210
+ if (providerName === "minimax" && isUnknownModelError(first.status, first.body)) {
211
+ const fallback = minimaxFallbackModel(model);
212
+ if (fallback) {
213
+ const retry = await requestModel(fallback);
214
+ if (retry.ok) {
215
+ return { content: retry.content, usage: retry.usage };
216
+ }
217
+ return {
218
+ content: undefined,
219
+ usage: undefined,
220
+ error: `HTTP ${first.status}: ${first.body.slice(0, 200)} | retry(${fallback}) HTTP ${retry.status}: ${retry.body.slice(0, 120)}`,
221
+ };
175
222
  }
176
- : undefined;
177
- return { content: payload.choices?.[0]?.message?.content, usage };
223
+ }
224
+ return { content: undefined, usage: undefined, error: `HTTP ${first.status}: ${first.body.slice(0, 200)}` };
178
225
  }
179
226
  async function generateAnthropicStyleMessage(provider, model, summary, maxLength, signal) {
180
227
  const apiKey = getApiKey(provider);
@@ -220,21 +267,25 @@ async function generateAnthropicStyleMessage(provider, model, summary, maxLength
220
267
  return { content: firstText, usage };
221
268
  }
222
269
  async function generateCommitMessage(ai, summary, maxLength) {
270
+ if (!ai.enabled) {
271
+ return { message: undefined, usage: undefined };
272
+ }
223
273
  const configError = validateAIConfig(ai);
224
274
  if (configError) {
225
275
  return { message: undefined, usage: undefined, warning: configError };
226
276
  }
227
277
  const { provider, model } = splitModelRef(ai.model, ai.defaultProvider);
278
+ const resolvedModel = normalizeProviderModel(provider, model);
228
279
  const providerConfig = ai.providers[provider];
229
280
  const controller = new AbortController();
230
281
  const timeout = setTimeout(() => controller.abort(), ai.timeoutMs);
231
282
  try {
232
283
  let result;
233
284
  if (providerConfig.api === "openai-completions") {
234
- result = await generateOpenAiStyleMessage(providerConfig, model, summary, maxLength, controller.signal);
285
+ result = await generateOpenAiStyleMessage(provider, providerConfig, resolvedModel, summary, maxLength, controller.signal);
235
286
  }
236
287
  else {
237
- result = await generateAnthropicStyleMessage(providerConfig, model, summary, maxLength, controller.signal);
288
+ result = await generateAnthropicStyleMessage(providerConfig, resolvedModel, summary, maxLength, controller.signal);
238
289
  }
239
290
  if (result.error) {
240
291
  return { message: undefined, usage: result.usage, warning: result.error };
@@ -258,6 +309,7 @@ async function testAI(ai, userMessage) {
258
309
  return { ok: false, error: configError };
259
310
  }
260
311
  const { provider, model } = splitModelRef(ai.model, ai.defaultProvider);
312
+ const resolvedModel = normalizeProviderModel(provider, model);
261
313
  const providerConfig = ai.providers[provider];
262
314
  const apiKey = getApiKey(providerConfig);
263
315
  const controller = new AbortController();
@@ -269,26 +321,49 @@ async function testAI(ai, userMessage) {
269
321
  Authorization: `Bearer ${apiKey}`,
270
322
  ...(providerConfig.headers ?? {}),
271
323
  };
272
- const response = await fetch(`${providerConfig.baseUrl.replace(/\/$/, "")}/chat/completions`, {
273
- method: "POST",
274
- headers,
275
- body: JSON.stringify({
276
- model,
277
- temperature: 0.2,
278
- messages: [{ role: "user", content: userMessage }],
279
- }),
280
- signal: controller.signal,
281
- });
282
- if (!response.ok) {
283
- const body = await response.text().catch(() => "");
284
- return { ok: false, error: `HTTP ${response.status}: ${body.slice(0, 300)}` };
324
+ async function requestModel(modelName) {
325
+ const response = await fetch(`${providerConfig.baseUrl.replace(/\/$/, "")}/chat/completions`, {
326
+ method: "POST",
327
+ headers,
328
+ body: JSON.stringify({
329
+ model: modelName,
330
+ temperature: 0.2,
331
+ messages: [{ role: "user", content: userMessage }],
332
+ }),
333
+ signal: controller.signal,
334
+ });
335
+ if (!response.ok) {
336
+ const body = await response.text().catch(() => "");
337
+ return { ok: false, status: response.status, body };
338
+ }
339
+ const payload = (await response.json());
340
+ const usage = payload.usage
341
+ ? {
342
+ promptTokens: payload.usage.prompt_tokens ?? 0,
343
+ completionTokens: payload.usage.completion_tokens ?? 0,
344
+ totalTokens: payload.usage.total_tokens ?? 0,
345
+ }
346
+ : undefined;
347
+ return { ok: true, reply: payload.choices?.[0]?.message?.content ?? "", usage };
285
348
  }
286
- const payload = (await response.json());
287
- const reply = payload.choices?.[0]?.message?.content ?? "";
288
- const usage = payload.usage
289
- ? { promptTokens: payload.usage.prompt_tokens ?? 0, completionTokens: payload.usage.completion_tokens ?? 0, totalTokens: payload.usage.total_tokens ?? 0 }
290
- : undefined;
291
- return { ok: true, reply, usage };
349
+ const first = await requestModel(resolvedModel);
350
+ if (first.ok) {
351
+ return { ok: true, reply: first.reply, usage: first.usage };
352
+ }
353
+ if (provider === "minimax" && isUnknownModelError(first.status, first.body)) {
354
+ const fallback = minimaxFallbackModel(resolvedModel);
355
+ if (fallback) {
356
+ const retry = await requestModel(fallback);
357
+ if (retry.ok) {
358
+ return { ok: true, reply: retry.reply, usage: retry.usage };
359
+ }
360
+ return {
361
+ ok: false,
362
+ error: `HTTP ${first.status}: ${first.body.slice(0, 300)} | retry(${fallback}) HTTP ${retry.status}: ${retry.body.slice(0, 200)}`,
363
+ };
364
+ }
365
+ }
366
+ return { ok: false, error: `HTTP ${first.status}: ${first.body.slice(0, 300)}` };
292
367
  }
293
368
  else {
294
369
  const headers = {
@@ -301,7 +376,7 @@ async function testAI(ai, userMessage) {
301
376
  method: "POST",
302
377
  headers,
303
378
  body: JSON.stringify({
304
- model,
379
+ model: resolvedModel,
305
380
  max_tokens: 256,
306
381
  messages: [{ role: "user", content: userMessage }],
307
382
  }),
@@ -141,6 +141,21 @@ function normalizeConfig(config) {
141
141
  }
142
142
  return config;
143
143
  }
144
+ function buildProjectEnvContent(config) {
145
+ const envNames = Array.from(new Set(Object.values(config.ai.providers)
146
+ .map((provider) => provider.apiKeyEnv?.trim())
147
+ .filter((name) => Boolean(name)))).sort();
148
+ const lines = [
149
+ "# code-agent-auto-commit local AI keys",
150
+ "# Fill values and run: source .cac/.env",
151
+ "",
152
+ ];
153
+ for (const envName of envNames) {
154
+ lines.push(`${envName}=`);
155
+ }
156
+ lines.push("");
157
+ return lines.join("\n");
158
+ }
144
159
  function resolveConfigPath(options) {
145
160
  if (options.explicitPath) {
146
161
  return node_path_1.default.resolve(options.explicitPath);
@@ -150,6 +165,10 @@ function resolveConfigPath(options) {
150
165
  if (node_fs_1.default.existsSync(projectPath)) {
151
166
  return projectPath;
152
167
  }
168
+ const legacyProjectPath = (0, fs_1.getLegacyProjectConfigPath)(cwd);
169
+ if (node_fs_1.default.existsSync(legacyProjectPath)) {
170
+ return legacyProjectPath;
171
+ }
153
172
  return (0, fs_1.getGlobalConfigPath)();
154
173
  }
155
174
  function loadConfig(options) {
@@ -166,8 +185,15 @@ function loadConfig(options) {
166
185
  };
167
186
  }
168
187
  function initConfigFile(targetPath, worktree) {
169
- const config = DEFAULT_CONFIG(node_path_1.default.resolve(worktree));
188
+ const resolvedWorktree = node_path_1.default.resolve(worktree);
189
+ const config = DEFAULT_CONFIG(resolvedWorktree);
170
190
  (0, fs_1.writeJsonFile)(targetPath, config);
191
+ const envExamplePath = (0, fs_1.getProjectEnvExamplePath)(resolvedWorktree);
192
+ (0, fs_1.writeTextFile)(envExamplePath, buildProjectEnvContent(config));
193
+ const envPath = (0, fs_1.getProjectEnvPath)(resolvedWorktree);
194
+ if (!node_fs_1.default.existsSync(envPath)) {
195
+ (0, fs_1.writeTextFile)(envPath, buildProjectEnvContent(config));
196
+ }
171
197
  return config;
172
198
  }
173
199
  function updateConfigWorktree(configPath, worktree) {
package/dist/core/fs.d.ts CHANGED
@@ -1,6 +1,11 @@
1
1
  export declare function getUserConfigHome(): string;
2
2
  export declare function getProjectConfigPath(worktree: string): string;
3
+ export declare function getLegacyProjectConfigPath(worktree: string): string;
4
+ export declare function getProjectEnvExamplePath(worktree: string): string;
5
+ export declare function getProjectEnvPath(worktree: string): string;
6
+ export declare function getProjectRunLogPath(worktree: string, date?: Date): string;
3
7
  export declare function getGlobalConfigPath(): string;
8
+ export declare function getGlobalKeysEnvPath(): string;
4
9
  export declare function ensureDirForFile(filePath: string): void;
5
10
  export declare function readJsonFile<T>(filePath: string): T | undefined;
6
11
  export declare function writeJsonFile(filePath: string, value: unknown): void;
package/dist/core/fs.js CHANGED
@@ -5,7 +5,12 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.getUserConfigHome = getUserConfigHome;
7
7
  exports.getProjectConfigPath = getProjectConfigPath;
8
+ exports.getLegacyProjectConfigPath = getLegacyProjectConfigPath;
9
+ exports.getProjectEnvExamplePath = getProjectEnvExamplePath;
10
+ exports.getProjectEnvPath = getProjectEnvPath;
11
+ exports.getProjectRunLogPath = getProjectRunLogPath;
8
12
  exports.getGlobalConfigPath = getGlobalConfigPath;
13
+ exports.getGlobalKeysEnvPath = getGlobalKeysEnvPath;
9
14
  exports.ensureDirForFile = ensureDirForFile;
10
15
  exports.readJsonFile = readJsonFile;
11
16
  exports.writeJsonFile = writeJsonFile;
@@ -13,6 +18,20 @@ exports.writeTextFile = writeTextFile;
13
18
  const node_fs_1 = __importDefault(require("node:fs"));
14
19
  const node_os_1 = __importDefault(require("node:os"));
15
20
  const node_path_1 = __importDefault(require("node:path"));
21
+ function formatTimestamp(date) {
22
+ const pad = (value, size = 2) => String(value).padStart(size, "0");
23
+ return [
24
+ date.getFullYear(),
25
+ pad(date.getMonth() + 1),
26
+ pad(date.getDate()),
27
+ "-",
28
+ pad(date.getHours()),
29
+ pad(date.getMinutes()),
30
+ pad(date.getSeconds()),
31
+ "-",
32
+ pad(date.getMilliseconds(), 3),
33
+ ].join("");
34
+ }
16
35
  function getUserConfigHome() {
17
36
  const xdg = process.env.XDG_CONFIG_HOME;
18
37
  if (xdg && xdg.trim().length > 0) {
@@ -21,11 +40,26 @@ function getUserConfigHome() {
21
40
  return node_path_1.default.join(node_os_1.default.homedir(), ".config");
22
41
  }
23
42
  function getProjectConfigPath(worktree) {
43
+ return node_path_1.default.join(worktree, ".cac", ".code-agent-auto-commit.json");
44
+ }
45
+ function getLegacyProjectConfigPath(worktree) {
24
46
  return node_path_1.default.join(worktree, ".code-agent-auto-commit.json");
25
47
  }
48
+ function getProjectEnvExamplePath(worktree) {
49
+ return node_path_1.default.join(worktree, ".cac", ".env.example");
50
+ }
51
+ function getProjectEnvPath(worktree) {
52
+ return node_path_1.default.join(worktree, ".cac", ".env");
53
+ }
54
+ function getProjectRunLogPath(worktree, date = new Date()) {
55
+ return node_path_1.default.join(worktree, ".cac", `run-${formatTimestamp(date)}.log`);
56
+ }
26
57
  function getGlobalConfigPath() {
27
58
  return node_path_1.default.join(getUserConfigHome(), "code-agent-auto-commit", "config.json");
28
59
  }
60
+ function getGlobalKeysEnvPath() {
61
+ return node_path_1.default.join(getUserConfigHome(), "code-agent-auto-commit", "keys.env");
62
+ }
29
63
  function ensureDirForFile(filePath) {
30
64
  node_fs_1.default.mkdirSync(node_path_1.default.dirname(filePath), { recursive: true });
31
65
  }
@@ -9,15 +9,30 @@ const node_path_1 = __importDefault(require("node:path"));
9
9
  const node_test_1 = __importDefault(require("node:test"));
10
10
  const strict_1 = __importDefault(require("node:assert/strict"));
11
11
  const config_1 = require("../core/config");
12
+ const fs_1 = require("../core/fs");
12
13
  (0, node_test_1.default)("init and update config file", () => {
13
14
  const tempDir = node_fs_1.default.mkdtempSync(node_path_1.default.join(node_os_1.default.tmpdir(), "code-agent-auto-commit-"));
14
- const configPath = node_path_1.default.join(tempDir, ".code-agent-auto-commit.json");
15
+ const configPath = (0, fs_1.getProjectConfigPath)(tempDir);
15
16
  const created = (0, config_1.initConfigFile)(configPath, tempDir);
16
17
  strict_1.default.equal(created.version, 1);
17
18
  strict_1.default.equal(created.worktree, tempDir);
19
+ strict_1.default.equal(node_fs_1.default.existsSync(node_path_1.default.join(tempDir, ".cac", ".env.example")), true);
20
+ strict_1.default.equal(node_fs_1.default.existsSync(node_path_1.default.join(tempDir, ".cac", ".env")), true);
18
21
  const loaded = (0, config_1.loadConfig)({ explicitPath: configPath, worktree: tempDir });
19
22
  strict_1.default.equal(loaded.config.worktree, tempDir);
20
23
  const nextDir = node_fs_1.default.mkdtempSync(node_path_1.default.join(node_os_1.default.tmpdir(), "code-agent-auto-commit-next-"));
21
24
  const updated = (0, config_1.updateConfigWorktree)(configPath, nextDir);
22
25
  strict_1.default.equal(updated.worktree, nextDir);
23
26
  });
27
+ (0, node_test_1.default)("loadConfig resolves new and legacy project paths", () => {
28
+ const tempDir = node_fs_1.default.mkdtempSync(node_path_1.default.join(node_os_1.default.tmpdir(), "code-agent-auto-commit-resolve-"));
29
+ const newConfigPath = (0, fs_1.getProjectConfigPath)(tempDir);
30
+ (0, config_1.initConfigFile)(newConfigPath, tempDir);
31
+ const loadedNew = (0, config_1.loadConfig)({ worktree: tempDir });
32
+ strict_1.default.equal(loadedNew.path, newConfigPath);
33
+ node_fs_1.default.rmSync(node_path_1.default.join(tempDir, ".cac"), { recursive: true, force: true });
34
+ const legacyPath = node_path_1.default.join(tempDir, ".code-agent-auto-commit.json");
35
+ node_fs_1.default.writeFileSync(legacyPath, JSON.stringify({ version: 1, enabled: true }, null, 2), "utf8");
36
+ const loadedLegacy = (0, config_1.loadConfig)({ worktree: tempDir });
37
+ strict_1.default.equal(loadedLegacy.path, legacyPath);
38
+ });
package/docs/CONFIG.md CHANGED
@@ -3,8 +3,9 @@
3
3
  `cac` reads JSON config from:
4
4
 
5
5
  1. `--config <path>` if provided
6
- 2. `<worktree>/.code-agent-auto-commit.json` if it exists
7
- 3. `~/.config/code-agent-auto-commit/config.json`
6
+ 2. `<worktree>/.cac/.code-agent-auto-commit.json` if it exists
7
+ 3. `<worktree>/.code-agent-auto-commit.json` if it exists (legacy path)
8
+ 4. `~/.config/code-agent-auto-commit/config.json`
8
9
 
9
10
  ## Schema
10
11
 
@@ -70,3 +71,5 @@
70
71
  - `gitlab`: remote URL must contain `gitlab`
71
72
  - `generic`: no provider URL validation
72
73
  - Keep API keys in environment variables when possible.
74
+ - `cac init` also creates `.cac/.env.example` and `.cac/.env` with provider key variables.
75
+ - Hook-triggered `cac run` writes output logs to `.cac/run-<timestamp>.log`.
package/docs/zh-CN.md CHANGED
@@ -15,9 +15,9 @@ It runs commits automatically when a chat/agent turn ends.
15
15
  cac init
16
16
 
17
17
  # 2. 配置 AI API Key(必须,否则无法生成 AI commit message)
18
- # 编辑 .code-agent-auto-commit.json,设置 provider、model 和 apiKeyEnv
19
- # 或者在 shell 中导出对应的环境变量:
20
- export MINIMAX_API_KEY='your-api-key' # 或 OPENAI_API_KEY 等
18
+ # 编辑 .cac/.code-agent-auto-commit.json,设置 model 和 defaultProvider
19
+ # .cac/.env 中填入 API Key 后加载:
20
+ source .cac/.env
21
21
 
22
22
  # 3. 安装钩子
23
23
  cac install --tool all --scope project
@@ -37,7 +37,8 @@ cac status --scope project
37
37
 
38
38
  - `cac init [--worktree <path>] [--config <path>]`
39
39
  - Initializes a config file.
40
- - Writes to `<worktree>/.code-agent-auto-commit.json` by default; use `--config` for a custom path.
40
+ - 默认写入 `<worktree>/.cac/.code-agent-auto-commit.json`;也会生成 `.cac/.env.example` `.cac/.env`。
41
+ - 可通过 `--config` 指定自定义路径。
41
42
 
42
43
  - `cac install [--tool all|opencode|codex|claude] [--scope project|global] [--worktree <path>] [--config <path>]`
43
44
  - Installs auto-commit adapters for selected tools (OpenCode/Codex/Claude).
@@ -54,6 +55,16 @@ cac status --scope project
54
55
  - `cac run [--tool opencode|codex|claude|manual] [--worktree <path>] [--config <path>] [--event-json <json>] [--event-stdin]`
55
56
  - Executes one auto-commit pass (manual trigger or hook trigger).
56
57
  - Runs the configured pipeline: filter files -> stage -> commit -> optional push.
58
+ - 由聊天结束自动触发时,会把本次输出写到 `.cac/run-<timestamp>.log`。
59
+
60
+ - `cac ai <message> [--config <path>]`
61
+ - 发送一条测试消息到当前 AI 配置并打印回复。
62
+
63
+ - `cac ai set-key <provider|ENV_VAR> <api-key> [--config <path>]`
64
+ - 全局设置 API Key(写入 `~/.config/code-agent-auto-commit/keys.env`),并自动尝试把 source 语句加入 shell rc。
65
+
66
+ - `cac ai get-key <provider|ENV_VAR> [--config <path>]`
67
+ - 查看当前 key 是否已配置(以脱敏形式显示)。
57
68
 
58
69
  - `cac set-worktree <path> [--config <path>]`
59
70
  - Updates only the `worktree` field in config and leaves other settings unchanged.
@@ -68,7 +79,7 @@ cac status --scope project
68
79
 
69
80
  ## Config File
70
81
 
71
- Default location in repository root: `.code-agent-auto-commit.json`
82
+ Default location in repository root: `.cac/.code-agent-auto-commit.json`
72
83
 
73
84
  For full field details, see `docs/CONFIG.md`.
74
85
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "code-agent-auto-commit",
3
- "version": "1.3.0",
3
+ "version": "1.3.2",
4
4
  "description": "CAC provides configurable AI auto-commit(using your git account) for OpenCode, Claude Code, Codex, and other AI code agents",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",