# @fridzema/cmt

AI-powered conventional commits from unstaged git changes.

[![CI](https://github.com/fridzema/cmt/actions/workflows/ci.yml/badge.svg)](https://github.com/fridzema/cmt/actions/workflows/ci.yml)
[![npm version](https://badge.fury.io/js/%40fridzema%2Fcmt.svg)](https://www.npmjs.com/package/@fridzema/cmt)

## Install

```sh
npm install -g @fridzema/cmt
```

## First Run

On first run with no config, `cmt` opens a setup wizard:

```
Welcome to @fridzema/cmt!

Get your OpenRouter API key at: https://openrouter.ai/keys

? OpenRouter API key: ••••••••••••••••••

Config saved. Ready to commit!
```

Config is saved (owner-readable only, mode 600) to your platform's config directory:

- **macOS**: `~/Library/Preferences/@fridzema/cmt/config.json`
- **Linux**: `~/.config/@fridzema/cmt/config.json`
- **Windows**: `%APPDATA%\@fridzema\cmt\Config\config.json`

After setup, your original commit task runs immediately — no second invocation needed.

Set `OPENROUTER_API_KEY` to skip the config file entirely — useful in CI and
containers, where the wizard would otherwise prompt and hang. The environment
key wins over the stored one; the stored model still applies.

## Usage

```sh
# Group unstaged changes into logical commits and auto-commit (default)
cmt

# Single combined commit for all changes
cmt --all

# Open interactive TUI to review, edit, or skip groups before committing
cmt --review

# Use a specific OpenRouter model for this run
cmt --model openai/gpt-4o

# Show the planned commits without touching the repository
cmt --dry-run

# Write one commit for what you already staged (git add -p and friends)
cmt --staged

# Push the branch to origin once every commit has landed
cmt --push

# Inspect or change the stored settings
cmt config show
cmt config set model openai/gpt-4o
cmt config set key

# Skip commit hooks for this run
cmt --no-verify
```

## How It Works

1. Reads `git diff` (unstaged) and `git status`
2. Sends diff + context to OpenRouter AI
3. AI groups related changes into logical conventional commits
4. All groups are committed automatically by default
5. With `--review`: opens an interactive TUI where you can navigate groups, edit subjects and bodies, skip individual commits, ask for a regrouping, then confirm

Lockfiles, minified bundles, source maps and build output are listed as changed
files but their content is left out of the diff, so the byte budget goes to the
code the model actually has to reason about.

### Review keys

| Key             | Action                                                 |
| --------------- | ------------------------------------------------------ |
| `↑` `↓` `j` `k` | Move between groups                                    |
| `space`         | Skip or un-skip the selected group                     |
| `e`             | Edit the subject (`←` `→` move the cursor)             |
| `b`             | Edit the body; clear it to drop the body entirely      |
| `r`             | Describe what to change and regroup with that feedback |
| `enter` / `a`   | Commit every group that is not skipped                 |
| `q` / `esc`     | Quit without committing                                |

## Flags

| Flag                   | Description                                       |
| ---------------------- | ------------------------------------------------- |
| `--all`                | Single combined commit instead of topic groups    |
| `-i`, `--review`       | Open interactive TUI to review/edit before commit |
| `-n`, `--dry-run`      | Print the planned commits without committing      |
| `--staged`             | Commit the staged index as one commit             |
| `--push`               | Push the branch to origin after committing        |
| `--no-verify`          | Skip git commit hooks                             |
| `--model <slug>`       | OpenRouter model slug for this invocation         |
| `--max-diff-bytes <n>` | Byte budget for the diff sent to the AI           |
| `--version`            | Print version                                     |
| `--help`               | Show help                                         |

`--dry-run` wins over `--review`: a dry run prints the plan and never opens the
TUI, so the two together are the same as `--dry-run` alone.

## Subcommands

```sh
# Generate an AI PR description from commits since the base branch
cmt pr [--base <branch>] [--file <path>] [--create]

# Generate an AI changelog entry from commits since the last tag
cmt changelog [--since <tag>] [--write] [--release-version <version>]

# Generate AI release notes between two tags
cmt release <from> <to> [--file <path>]

# Show or change the stored configuration
cmt config [show | path | set model <slug> | set key]

# Show recent commits created by cmt
cmt history
```

| Command     | Description                                                                            |
| ----------- | -------------------------------------------------------------------------------------- |
| `pr`        | PR description from commits since base branch (prefers `origin/main`, then local refs) |
| `changelog` | Keep-a-Changelog entry since last tag; `--write` updates `CHANGELOG.md` at the root    |
| `release`   | GitHub release notes between two tags; `--file` writes to a file instead of stdout     |
| `config`    | Inspect or change the stored API key and model                                         |
| `history`   | Last 20 commits generated by cmt (stored locally, capped at 50 entries per repository) |

All three prose commands send commit subjects, commit bodies and a diffstat for
the range, so the output reflects why changes were made and not just their
subject lines.

`cmt pr --create` opens the pull request through the
[GitHub CLI](https://cli.github.com), titling it with the oldest commit in the
range. `cmt changelog --write` replaces an existing `## [Unreleased]` section
rather than stacking a second one on top of it; pass `--release-version 2.0.0`
to write a version heading instead.

## Per-Repository Settings

Commit a `.cmt` file at the repo root to set conventions for everyone working
in it:

```json
{
  "model": "openai/gpt-4o",
  "types": ["feat", "fix", "deps", "chore"],
  "scopes": ["api", "tui", "config"],
  "instructions": "Reference the Jira ticket from the branch name in the body."
}
```

| Key            | Effect                                                  |
| -------------- | ------------------------------------------------------- |
| `model`        | Model for this repo; `--model` still overrides it       |
| `baseUrl`      | API endpoint for this repo (see Providers below)        |
| `types`        | Replaces the default conventional-commit type list      |
| `scopes`       | Scopes the AI should prefer when one fits               |
| `instructions` | Free-form guidance appended to the prompt               |
| `maxDiffBytes` | Diff byte budget; `--max-diff-bytes` still overrides it |

Every key is optional. A malformed `.cmt` is an error rather than a silent
fallback — a typo would otherwise change generated commits with no visible
cause.

A `.cmt` in your home directory holds personal defaults for every repository.
The repo's own file wins key by key, except `instructions`, which are
concatenated so your personal notes survive alongside the project's.

Model precedence: `--model` > repo `.cmt` > `~/.cmt` > stored config > default.

## Default Model

`anthropic/claude-sonnet-4.6` via [OpenRouter](https://openrouter.ai). Override per-run with `--model`.

To change the default permanently: `cmt config set model <slug>`.

## Providers

OpenRouter is the default, not a lock. Point `baseUrl` at any endpoint that
speaks the OpenAI chat-completions API — a local Ollama or LM Studio server, a
gateway, or a provider's own endpoint:

```sh
OPENROUTER_BASE_URL=http://localhost:11434/v1 cmt --model qwen2.5-coder
```

The same value can live in `.cmt` as `baseUrl`, or in the stored config. A
repo's `baseUrl` beats the environment variable, which beats the stored one.

## Dependencies

| Package             | Purpose                                                          |
| ------------------- | ---------------------------------------------------------------- |
| `commander`         | CLI argument parsing and help generation                         |
| `openai`            | HTTP client pointed at OpenRouter (same API, different base URL) |
| `simple-git`        | Git operations: diff, status, stage, commit                      |
| `@inquirer/prompts` | Interactive prompts for wizard and confirmation                  |
| `env-paths`         | Platform-appropriate config directory (macOS/Linux/Windows)      |
| `zod`               | Runtime validation of AI JSON responses                          |
| `picocolors`        | Terminal color output                                            |
| `ink` + `react`     | Renders the interactive review TUI                               |
| `cli-highlight`     | Syntax highlighting in the diff preview                          |

## FAQ

**The AI returned invalid JSON.**
`cmt` retries once, replaying the rejected answer with the reason attached so
the model can correct it. JSON wrapped in a markdown fence is recovered without
spending a retry, and models that reject `response_format` are retried without
it. If it still fails, it exits with an error — try again or use `--all`.

**My diff is too large.**
The diff sent to the AI is capped at 50 KB by default. The budget is split
fairly across changed files, so every file is represented and only oversized
ones are truncated — the last files in a large diff are no longer dropped
entirely. Generated files contribute their path but not their content. Raise the
cap with `--max-diff-bytes` or `maxDiffBytes` in `.cmt`. The request timeout
grows with the size of the prompt, up to three minutes.

**My commit hooks reject the generated message.**
Pass `--no-verify` to skip them for that run, or set the repo's own types in
`.cmt` so the generated subjects satisfy commitlint in the first place.

**I already staged some changes.**
Pass `--staged` to write a commit for exactly what is in the index — useful
after `git add -p`. That mode commits the index as it stands and never stages
anything itself, so it always produces a single commit. Otherwise run
`git reset HEAD` to unstage, then run `cmt`.

**A commit failed halfway through.**
`cmt` unstages what it staged, and the error reports how many commits already
landed. Commits that succeeded are left in place.

**Can I use a different AI provider?**
Yes. Set `OPENROUTER_BASE_URL`, or `baseUrl` in `.cmt`, to any OpenAI-compatible
endpoint. Within OpenRouter, `--model` switches models across its catalog:
[openrouter.ai/models](https://openrouter.ai/models).
