Full Code of Nutlope/aicommits for AI

develop b67574531282 cached
84 files
178.9 KB
51.4k tokens
75 symbols
1 requests
Download .txt
Repository: Nutlope/aicommits
Branch: develop
Commit: b67574531282
Files: 84
Total size: 178.9 KB

Directory structure:
gitextract_96rq2eky/

├── .editorconfig
├── .gitattributes
├── .github/
│   ├── ISSUE_TEMPLATE/
│   │   ├── BUG_REPORT.yml
│   │   ├── FEATURE_REQUEST.yml
│   │   └── config.yml
│   └── workflows/
│       ├── release-vscode.yml
│       ├── release.yml
│       └── test.yml
├── .gitignore
├── .nvmrc
├── .vscode/
│   └── settings.json
├── AGENTS.md
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── package.json
├── patches/
│   └── @clack__prompts@0.6.1.patch
├── src/
│   ├── cli.ts
│   ├── commands/
│   │   ├── aicommits.ts
│   │   ├── config.ts
│   │   ├── hook.ts
│   │   ├── model.ts
│   │   ├── pr.ts
│   │   ├── prepare-commit-msg-hook.ts
│   │   ├── setup.ts
│   │   └── update.ts
│   ├── feature/
│   │   ├── models.ts
│   │   └── providers/
│   │       ├── base.ts
│   │       ├── groq.ts
│   │       ├── index.ts
│   │       ├── lmstudio.ts
│   │       ├── ollama.ts
│   │       ├── openai.ts
│   │       ├── openaiCustom.ts
│   │       ├── openrouter.ts
│   │       ├── providers-data.ts
│   │       ├── together.ts
│   │       └── xai.ts
│   └── utils/
│       ├── auto-update.ts
│       ├── clipboard.ts
│       ├── commit-helpers.ts
│       ├── config-runtime.ts
│       ├── config-types.ts
│       ├── constants.ts
│       ├── error.ts
│       ├── fs.ts
│       ├── git.ts
│       ├── headless.ts
│       ├── openai.ts
│       └── prompt.ts
├── tests/
│   ├── fixtures/
│   │   ├── README.md
│   │   ├── chore.diff
│   │   ├── code-refactoring.diff
│   │   ├── code-style.diff
│   │   ├── continous-integration.diff
│   │   ├── deprecate-feature.diff
│   │   ├── documentation-changes.diff
│   │   ├── fix-nullpointer-exception.diff
│   │   ├── github-action-build-pipeline.diff
│   │   ├── new-feature.diff
│   │   ├── performance-improvement.diff
│   │   ├── remove-feature.diff
│   │   └── testing-react-application.diff
│   ├── index.ts
│   ├── specs/
│   │   ├── auto-update.ts
│   │   ├── cli/
│   │   │   ├── commits.ts
│   │   │   ├── error-cases.ts
│   │   │   ├── headless.ts
│   │   │   ├── index.ts
│   │   │   └── no-verify.ts
│   │   ├── config.ts
│   │   ├── git-hook.ts
│   │   ├── openai/
│   │   │   └── index.ts
│   │   └── togetherai/
│   │       └── index.ts
│   └── utils.ts
├── tsconfig.json
└── vscode-extension/
    ├── .gitignore
    ├── .vscode/
    │   └── launch.json
    ├── .vscodeignore
    ├── LICENSE
    ├── README.md
    ├── package.json
    ├── src/
    │   └── extension.ts
    └── tsconfig.json

================================================
FILE CONTENTS
================================================

================================================
FILE: .editorconfig
================================================
root = true

[*]
indent_style = tab
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
quote_type = single

[*.yml]
indent_style = space
indent_size = 2


================================================
FILE: .gitattributes
================================================
* text=auto eol=lf


================================================
FILE: .github/ISSUE_TEMPLATE/BUG_REPORT.yml
================================================
name: Bug report
description: File a bug report
labels: [bug, pending triage]
body:
  - type: markdown
    attributes:
      value: |
        Thank you for taking the time to file this bug report.
  - type: textarea
    attributes:
      label: Bug description
      description: A clear and concise description of the bug.
      placeholder: |
        <!--
          What did you do, what did you expect to happen, and what happened instead?
        -->
    validations:
      required: true
  - type: input
    attributes:
      label: aicommits version
      description: |
        Run and paste the output of:
        ```sh
        aicommits --version
        ```
      placeholder: v0.0.0
    validations:
      required: true
  - type: textarea
    attributes:
      label: Environment
      description: |
        Run and paste the output of:
        ```sh
        npx envinfo --system --binaries
        ```
        
        This information is used to for reproduction and debugging.
      placeholder: |
        System:
          OS:
          CPU:
          Shell:
        Binaries:
          Node:
          npm:
      render: shell
    validations:
      required: true
  - type: checkboxes
    attributes:
      label: Can you contribute a fix?
      description: We would love it if you can open a pull request to fix this bug!
      options:
        - label: I’m interested in opening a pull request for this issue.


================================================
FILE: .github/ISSUE_TEMPLATE/FEATURE_REQUEST.yml
================================================
name: Feature request
description: Suggest an idea for this project
labels: [feature, pending triage]
body:
  - type: markdown
    attributes:
      value: |
        Thank you for taking the time to file this feature request.
  - type: textarea
    attributes:
      label: Feature request
      description: A description of the feature you would like.
    validations:
      required: true
  - type: textarea
    attributes:
      label: Why?
      description: |
        Describe the problem you’re tackling with this feature request.
      placeholder: |
        <!--
          What’s the motivation behind this issue?

          eg. “I’m frustrated when...”
        -->
    validations:
      required: true
  - type: textarea
    attributes:
      label: Alternatives
      description: |
        Have you considered alternative solutions? Is there a workaround?
      placeholder: |
        <!--
          Do you have alternative proposals?

          Do you have a workaround?
        -->
  - type: textarea
    attributes:
      label: Additional context
      description: |
        Anything else to share? Screenshots? Links?


================================================
FILE: .github/ISSUE_TEMPLATE/config.yml
================================================
blank_issues_enabled: false


================================================
FILE: .github/workflows/release-vscode.yml
================================================
name: Release VSCode Extension

on:
  push:
    branches: [main]
    paths:
      - 'vscode-extension/**'
  workflow_dispatch:

jobs:
  release:
    name: Publish Extension
    runs-on: ubuntu-latest
    timeout-minutes: 10
    permissions:
      contents: write

    steps:
      - name: Checkout
        uses: actions/checkout@v5

      - name: Setup Node.js
        uses: actions/setup-node@v6
        with:
          node-version-file: '.nvmrc'

      - name: Setup pnpm
        uses: pnpm/action-setup@v4.2.0
        with:
          version: 9

      - name: Install dependencies
        working-directory: vscode-extension
        run: pnpm install

      - name: Build
        working-directory: vscode-extension
        run: pnpm run compile

      - name: Publish to VSCode Marketplace
        working-directory: vscode-extension
        env:
          VSCE_PAT: ${{ secrets.VSCE_PAT }}
        run: pnpm run package && npx @vscode/vsce publish --no-dependencies

      - name: Upload VSIX artifact
        uses: actions/upload-artifact@v4
        with:
          name: vscode-extension
          path: vscode-extension/*.vsix


================================================
FILE: .github/workflows/release.yml
================================================
name: Release

on:
  push:
    branches: [develop]

jobs:
  release:
    name: Release
    runs-on: ubuntu-latest
    timeout-minutes: 10
    permissions:
      contents: write
      issues: write
      id-token: write

    steps:
      - name: Checkout
        uses: actions/checkout@v5

      - name: Setup Node.js
        uses: actions/setup-node@v6
        with:
          node-version-file: '.nvmrc'

      - name: Setup pnpm
        uses: pnpm/action-setup@v4.2.0
        with:
          version: 9
          run_install: true

      - name: Release
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
          NPM_CONFIG_PROVENANCE: false
        run: pnpm dlx semantic-release


================================================
FILE: .github/workflows/test.yml
================================================
name: Test

on:
  push:
    branches: [main, develop]
  pull_request:

jobs:
  test:
    name: Test
    strategy:
      matrix:
        os: [ubuntu-latest, windows-latest]

    runs-on: ${{ matrix.os }}
    timeout-minutes: 10

    steps:
      - name: Checkout
        uses: actions/checkout@v5

      - name: Use Node.js
        uses: actions/setup-node@v6
        with:
          node-version-file: '.nvmrc'

      - name: Setup pnpm
        uses: pnpm/action-setup@v4.2.0
        with:
          version: 9
          run_install: true

      - name: Type check
        run: pnpm type-check

      - name: Build
        run: pnpm build

      - name: Install tinyproxy
        if: matrix.os == 'ubuntu-latest'
        run: |
          sudo apt-get install tinyproxy
          tinyproxy
      - name: Test
        env:
          OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
        run: |
          pnpm test


================================================
FILE: .gitignore
================================================
# macOS
.DS_Store

# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*

# Dependency directories
node_modules/

# Output of 'npm pack'
*.tgz

# dotenv environment variables file
.env
.env.test

# Distribution
dist

# Eslint cache
.eslintcache


================================================
FILE: .nvmrc
================================================
v22.14.0


================================================
FILE: .vscode/settings.json
================================================
{
	"typescript.tsdk": "node_modules/typescript/lib"
}

================================================
FILE: AGENTS.md
================================================
# AGENTS.md

## Commands
- **Build:** `pnpm build` (uses pkgroll with minify)
- **Type check:** `pnpm type-check` (runs tsc)
- **Test all:** `pnpm test` (runs `tsx tests`)
- **Test single file:** `pnpm tsx tests/specs/<file>.ts`

## Architecture
CLI tool that generates git commit messages using AI (OpenAI/Together AI or any OpenAI compatible endpoint).
- `src/cli.ts` - Main entry point using cleye for CLI parsing
- `src/commands/` - CLI subcommands (aicommits, config, hook, model, pr, setup)
- `src/utils/` - Shared utilities (git, openai, config, prompts)
- `src/feature/` - Feature-specific logic
- `tests/specs/` - Test files using manten framework

## Code Style
- **Indentation:** Tabs (spaces for YAML)
- **Quotes:** Single quotes
- **Line endings:** LF
- **Module system:** ESM (`"type": "module"`)
- **TypeScript:** Strict mode, ES2020 target, Node16 module resolution
- **Imports:** Use `.js` extension for local imports (ESM requirement)
- **Final newline:** Required


================================================
FILE: CONTRIBUTING.md
================================================
# Contribution Guide

## Setting up the project

Use [nvm](https://nvm.sh) to use the appropriate Node.js version from `.nvmrc`:

```sh
nvm i
```

Install the dependencies using pnpm:

```sh
pnpm i
```

## Building the project

Run the `build` script:

```sh
pnpm build
```

The package is bundled using [pkgroll](https://github.com/privatenumber/pkgroll) (Rollup). It infers the entry-points from `package.json` so there are no build configurations.

### Development (watch) mode

During development, you can use the watch flag (`--watch, -w`) to automatically rebuild the package on file changes:

```sh
pnpm build -w
```

## Running the package locally

Since pkgroll knows the entry-point is a binary (being in `package.json#bin`), it automatically adds the Node.js hashbang to the top of the file, and chmods it so it's executable.

You can run the distribution file in any directory:

```sh
./dist/cli.mjs
```

Or in non-UNIX environments, you can use Node.js to run the file:

```sh
node ./dist/cli.mjs
```

## Testing

Testing requires passing in `OPENAI_API_KEY` as an environment variable:

```sh
OPENAI_API_KEY=<your OPENAI key> pnpm test
```

You can still run tests that don't require `OPENAI_API_KEY` but will not test the main functionality:

```
pnpm test
```

## Using & testing your changes

Let's say you made some changes in a fork/branch and you want to test it in a project. You can publish the package to a GitHub branch using [`git-publish`](https://github.com/privatenumber/git-publish):

Publish your current branch to a `npm/*` branch on your GitHub repository:

```sh
$ pnpm dlx git-publish

✔ Successfully published branch! Install with command:
  → npm i 'Nutlope/aicommits#npm/develop'
```

> Note: The `Nutlope/aicommits` will be replaced with your fork's URL.

Now, you can run the branch in your project:

```sh
$ pnpm dlx 'Nutlope/aicommits#npm/develop' # same as running `npx aicommits`
```


================================================
FILE: LICENSE
================================================
MIT License

Copyright (c) Hassan El Mghari

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


================================================
FILE: README.md
================================================
<div align="center">
  <div>
    <img src=".github/screenshot.png" alt="AI Commits"/>
    <img src="./aic.png" width="50" alt="AI Commits"/>
    <h1 align="center">AI Commits</h1>
  </div>
  <p>A CLI that writes your git commit messages for you with AI. Never write a commit message again.</p>
  <a href="https://www.npmjs.com/package/aicommits"><img src="https://img.shields.io/npm/v/aicommits" alt="Current version"></a>
  <a href="https://www.npmjs.com/package/aicommits"><img src="https://img.shields.io/npm/dt/aicommits" alt="Downloads"></a>
</div>

---

## Setup

> The minimum supported version of Node.js is v22. Check your Node.js version with `node --version`.

1. Install _aicommits_:

   ```sh
   npm install -g aicommits
   ```

2. Run the setup command to choose your AI provider:

   ```sh
   aicommits setup
   ```

This will guide you through:

- Selecting your AI provider (sets the `provider` config)
- Configuring your API key
- **Automatically fetching and selecting from available models** (when supported)
- **Choosing your preferred commit message format** (plain, conventional, or gitmoji)

  Supported providers include:

  - **TogetherAI** (recommended) - Get your API key from [TogetherAI](https://api.together.ai/)
  - **OpenAI** - Get your API key from [OpenAI API Keys page](https://platform.openai.com/account/api-keys)
  - **Groq** - Get your API key from [Groq Console](https://console.groq.com/keys)
  - **xAI** - Get your API key from [xAI Console](https://console.x.ai/)
  - **OpenRouter** - Get your API key from [OpenRouter](https://openrouter.ai/keys)
  - **Ollama** (local) - Run AI models locally with [Ollama](https://ollama.ai)
  - **LM Studio** (local) - No API key required. Runs on your computer via [LM Studio](https://lmstudio.ai/)
  - **Custom OpenAI-compatible endpoint** - Use any service that implements the OpenAI API

  **For CI/CD environments**, you can also set up configuration via the config file:

  ```bash
  aicommits config set OPENAI_API_KEY="your_api_key_here"
  aicommits config set OPENAI_BASE_URL="your_api_endpoint"  # Optional, for custom endpoints
  aicommits config set OPENAI_MODEL="your_model_choice"     # Optional, defaults to provider default
  ```

  > **Note:** When using environment variables, ensure all related variables (e.g., `OPENAI_API_KEY` and `OPENAI_BASE_URL`) are set consistently to avoid configuration mismatches with the config file.

  This will create a `.aicommits` file in your home directory.

### Upgrading

Check the installed version with:

```sh
aicommits --version
```

To update to the latest version, run:

```sh
aicommits update
```

This will automatically detect your package manager (npm, pnpm, yarn, or bun) and update using the correct command.

Alternatively, you can manually update:

```sh
npm install -g aicommits
```

## Usage

### CLI mode

You can call `aicommits` directly to generate a commit message for your staged changes:

```sh
git add <files...>
aicommits
```

`aicommits` passes down unknown flags to `git commit`, so you can pass in [`commit` flags](https://git-scm.com/docs/git-commit).

For example, you can stage all changes in tracked files with as you commit:

```sh
aicommits --all # or -a
```

> 👉 **Tip:** Use the `aic` alias if `aicommits` is too long for you.

#### CLI Options

- `--all` or `-a`: Automatically stage changes in tracked files for the commit (default: **false**)
- `--clipboard` or `-c`: Copy the selected message to the clipboard instead of committing (default: **false**)
- `--generate` or `-g`: Number of messages to generate (default: **1**)
- `--exclude` or `-x`: Files to exclude from AI analysis
- `--type` or `-t`: Git commit message format (default: **plain**). Supports `plain`, `conventional`, and `gitmoji`
- `--prompt` or `-p`: Custom prompt to guide the LLM behavior (e.g., specific language, style instructions)
- `--no-verify` or `-n`: Bypass pre-commit hooks while committing (default: **false**)
- `--yes` or `-y`: Skip confirmation when committing after message generation (default: **false**)

#### Generate multiple recommendations

Sometimes the recommended commit message isn't the best so you want it to generate a few to pick from. You can generate multiple commit messages at once by passing in the `--generate <i>` flag, where 'i' is the number of generated messages:

```sh
aicommits --generate <i> # or -g <i>
```

> Warning: this uses more tokens, meaning it costs more.

#### Commit Message Formats

You can choose from four different commit message formats:

- **plain** (default): Simple, unstructured commit messages
- **conventional**: [Conventional Commits](https://conventionalcommits.org/) format with type and scope
- **gitmoji**: Emoji-based commit messages
- **subject+body**: Git-style subject line plus a body (description) generated from the diff

Use the `--type` flag to specify the format:

```sh
aicommits --type conventional # or -t conventional
aicommits --type gitmoji       # or -t gitmoji
aicommits --type plain         # or -t plain (default)
aicommits --type subject+body  # or -t subject+body (subject + body)
```

This feature is useful if your project follows a specific commit message standard or if you're using tools that rely on these commit formats.

#### Custom Prompts

You can customize the LLM's behavior with the `--prompt` flag to guide commit message generation:

```sh
# Write commit messages in a specific language
aicommits -p "Write commit messages in Italian"

# Focus on specific aspects of the changes
aicommits -p "Focus on performance implications of changes"

# Use a specific style or tone
aicommits -p "Use technical jargon suitable for senior developers"

# Include specific details in the message
aicommits -p "Always mention the specific function names and file paths changed"
```

### Git hook

You can also integrate _aicommits_ with Git via the [`prepare-commit-msg`](https://git-scm.com/docs/githooks#_prepare_commit_msg) hook. This lets you use Git like you normally would, and edit the commit message before committing.

#### Install

In the Git repository you want to install the hook in:

```sh
aicommits hook install
```

#### Uninstall

In the Git repository you want to uninstall the hook from:

```sh
aicommits hook uninstall
```

#### Usage

1. Stage your files and commit:

   ```sh
   git add <files...>
   git commit # Only generates a message when it's not passed in
   ```

   > If you ever want to write your own message instead of generating one, you can simply pass one in: `git commit -m "My message"`

2. Aicommits will generate the commit message for you and pass it back to Git. Git will open it with the [configured editor](https://docs.github.com/en/get-started/getting-started-with-git/associating-text-editors-with-git) for you to review/edit it.

3. Save and close the editor to commit!

### Environment Variables

You can also configure aicommits using environment variables instead of the config file.

**Example:**

```bash
export OPENAI_API_KEY="sk-..."
export OPENAI_BASE_URL="https://api.example.com"
export OPENAI_MODEL="gpt-4"
aicommits  # Uses environment variables
```

Configuration settings are resolved in the following order of precedence:

1. Command-line arguments
2. Environment variables
3. Configuration file
4. Default values

## Configuration

### Viewing current configuration

To view all current configuration options that differ from defaults, run:

```sh
aicommits config
```

This will display only non-default configuration values with API keys masked for security. If no custom configuration is set, it will show "(using all default values)".

### Changing your model

To interactively select or change your AI model, run:

```sh
aicommits model
```

This will:

- Show your current provider and model
- Fetch available models from your provider's API
- Let you select from available models or enter a custom model name
- Update your configuration automatically

### Updating aicommits

To update to the latest version, run:

```sh
aicommits update
```

This will:

- Check for the latest version on npm
- Detect your package manager (npm, pnpm, yarn, or bun)
- Update using the appropriate command
- Show progress and confirm when complete

### Reading a configuration value

To retrieve a configuration option, use the command:

```sh
aicommits config get <key>
```

For example, to retrieve the API key, you can use:

```sh
aicommits config get OPENAI_API_KEY
```

You can also retrieve multiple configuration options at once by separating them with spaces:

```sh
aicommits config get OPENAI_API_KEY generate
```

### Setting a configuration value

To set a configuration option, use the command:

```sh
aicommits config set <key>=<value>
```

For example, to set the API key, you can use:

```sh
aicommits config set OPENAI_API_KEY=<your-api-key>
```

You can also set multiple configuration options at once by separating them with spaces, like

```sh
aicommits config set OPENAI_API_KEY=<your-api-key> generate=3 locale=en
```

### Config Options

#### OPENAI_API_KEY

Your OpenAI API key or custom provider API Key

#### OPENAI_BASE_URL

Custom OpenAI-compatible API endpoint URL.

#### OPENAI_MODEL

Model to use for OpenAI-compatible providers.

#### provider

The selected AI provider. Set automatically during `aicommits setup`. Valid values: `openai`, `togetherai`, `groq`, `xai`, `openrouter`, `ollama`, `lmstudio`, `custom`.

#### locale

Default: `en`

The locale to use for the generated commit messages. Consult the list of codes in: https://wikipedia.org/wiki/List_of_ISO_639-1_codes.

#### generate

Default: `1`

The number of commit messages to generate to pick from.

Note, this will use more tokens as it generates more results.

#### timeout

The timeout for network requests to the OpenAI API in milliseconds.

Default: `10000` (10 seconds)

```sh
aicommits config set timeout=20000 # 20s
```

#### max-length

The maximum character length of the generated commit message.

Default: `72`

```sh
aicommits config set max-length=100
```

#### type

Default: `plain`

The type of commit message to generate. Available options:

- `plain`: Simple, unstructured commit messages
- `conventional`: Conventional Commits format with type and scope
- `gitmoji`: Emoji-based commit messages

Examples:

```sh
aicommits config set type=conventional
aicommits config set type=gitmoji
aicommits config set type=plain
```

## How it works

This CLI tool runs `git diff` to grab all your latest code changes, sends them to the configured AI provider (TogetherAI by default), then returns the AI generated commit message.

Video coming soon where I rebuild it from scratch to show you how to easily build your own CLI tools powered by AI.

## Maintainers

- **Hassan El Mghari**: [@Nutlope](https://github.com/Nutlope) [<img src="https://img.shields.io/twitter/follow/nutlope?style=flat&label=nutlope&logo=twitter&color=0bf&logoColor=fff" align="center">](https://x.com/nutlope)

- **Riccardo Giorato**: [@riccardogiorato](https://github.com/riccardogiorato) [<img src="https://img.shields.io/twitter/follow/riccardogiorato?style=flat&label=riccardogiorato&logo=twitter&color=0bf&logoColor=fff" align="center">](https://x.com/riccardogiorato)

- **Hiroki Osame**: [@privatenumber](https://github.com/privatenumber) [<img src="https://img.shields.io/twitter/follow/privatenumbr?style=flat&label=privatenumbr&logo=twitter&color=0bf&logoColor=fff" align="center">](https://twitter.com/privatenumbr)

## Contributing

If you want to help fix a bug or implement a feature in [Issues](https://github.com/Nutlope/aicommits/issues), checkout the [Contribution Guide](CONTRIBUTING.md) to learn how to setup and test the project

================================================
FILE: package.json
================================================
{
	"name": "aicommits",
	"version": "0.0.0-semantic-release",
	"description": "Writes your git commit messages for you with AI",
	"keywords": [
		"ai",
		"git",
		"commit",
		"code changes"
	],
	"license": "MIT",
	"repository": {
		"type": "git",
		"url": "git+https://github.com/Nutlope/aicommits.git"
	},
	"author": "Hassan El Mghari (@nutlope)",
	"type": "module",
	"files": [
		"dist"
	],
	"bin": {
		"aicommits": "dist/cli.mjs",
		"aic": "dist/cli.mjs"
	},
	"scripts": {
		"build": "pkgroll --minify",
		"lint": "",
		"type-check": "tsc",
		"test": "tsx tests",
		"prepack": "pnpm build && clean-pkg-json"
	},
	"devDependencies": {
		"@clack/prompts": "^0.11.0",
		"@types/ini": "^1.3.31",
		"@types/node": "^24.10.1",
		"clean-pkg-json": "^1.3.0",
		"cleye": "^2.0.0",
		"execa": "^7.0.0",
		"fs-fixture": "^1.2.0",
		"ini": "^3.0.1",
		"kolorist": "^1.8.0",
		"manten": "^0.7.0",
		"ai": "^6.0.97",
		"@ai-sdk/openai": "^3.0.30",
		"@ai-sdk/openai-compatible": "^2.0.30",
		"pkgroll": "^2.20.1",
		"tsx": "^4.21.0",
		"typescript": "^5.9.3"
	},
	"release": {
		"branches": ["develop"]
	}
}


================================================
FILE: patches/@clack__prompts@0.6.1.patch
================================================
diff --git a/dist/index.d.ts b/dist/index.d.ts
index 693d552f60c8e0dfef11480da22fb844065b18eb..f74db21d7709c9f6693a218cec2e424e4cf43c2d 100644
--- a/dist/index.d.ts
+++ b/dist/index.d.ts
@@ -36,7 +36,7 @@ interface SelectOptions<Options extends Option<Value>[], Value> {
     options: Options;
     initialValue?: Value;
 }
-declare const select: <Options extends Option<Value>[], Value>(opts: SelectOptions<Options, Value>) => Promise<symbol | Value>;
+declare const select: <Options extends Option<Value>[], Value>(opts: SelectOptions<Options, Value>) => Promise<symbol | [...Options][number]['value']>;
 declare const selectKey: <Options extends Option<Value>[], Value extends string>(opts: SelectOptions<Options, Value>) => Promise<symbol | Value>;
 interface MultiSelectOptions<Options extends Option<Value>[], Value> {
     message: string;

================================================
FILE: src/cli.ts
================================================
// Suppress AI SDK warnings (e.g., "temperature is not supported for reasoning models")
globalThis.AI_SDK_LOG_WARNINGS = false;

import { cli } from 'cleye';
import pkg from '../package.json';
const { description, version } = pkg;
import aicommits from './commands/aicommits.js';
import prepareCommitMessageHook from './commands/prepare-commit-msg-hook.js';
import configCommand from './commands/config.js';
import setupCommand from './commands/setup.js';
import modelCommand from './commands/model.js';
import hookCommand, { isCalledFromGitHook } from './commands/hook.js';
import prCommand from './commands/pr.js';
import updateCommand from './commands/update.js';
import { checkAndAutoUpdate } from './utils/auto-update.js';
import { isHeadless } from './utils/headless.js';

// Auto-update check - runs in production to update under the hood
// Skip during git hooks to avoid breaking commit flow
if (!isCalledFromGitHook && !isHeadless() && version !== '0.0.0-semantic-release') {
	const distTag = version.includes('-') ? 'develop' : 'latest';

	// Check for updates and auto-update if available
	checkAndAutoUpdate({
		pkg,
		distTag,
		headless: false,
	});
}

const rawArgv = process.argv.slice(2);

cli(
	{
		name: 'aicommits',

		/**
		 * Since this is a wrapper around `git commit`,
		 * flags should not overlap with it
		 * https://git-scm.com/docs/git-commit
		 */
		flags: {
			generate: {
				type: Number,
				description:
					'Number of messages to generate (Warning: generating multiple costs more) (default: 1)',
				alias: 'g',
			},
			exclude: {
				type: [String],
				description: 'Files to exclude from AI analysis',
				alias: 'x',
			},
			all: {
				type: Boolean,
				description:
					'Automatically stage changes in tracked files for the commit',
				alias: 'a',
				default: false,
			},
			type: {
				type: String,
				description:
					'Git commit message format (default: plain). Supports plain, conventional, gitmoji, and subject+body',
				alias: 't',
			},
			yes: {
				type: Boolean,
				description:
					'Skip confirmation when committing after message generation (default: false)',
				alias: 'y',
				default: false,
			},
			clipboard: {
				type: Boolean,
				description:
					'Copy the selected message to the clipboard instead of committing (default: false)',
				alias: 'c',
				default: false,
			},
			noVerify: {
				type: Boolean,
				description:
					'Bypass pre-commit hooks while committing (default: false)',
				alias: 'n',
				default: false,
			},
			prompt: {
				type: String,
				description:
					'Custom prompt to guide the LLM behavior (e.g., specific language, style instructions)',
				alias: 'p',
			},
		version: {
			type: Boolean,
			description: 'Show version number',
			alias: 'v',
		},
		},

		commands: [configCommand, setupCommand, modelCommand, hookCommand, prCommand, updateCommand],

		help: {
			description,
		},

		ignoreArgv: (type) => type === 'unknown-flag' || type === 'argument',
	},
	(argv) => {
		if (argv.flags.version) {
			console.log(version);
			process.exit(0);
		}

		if (isCalledFromGitHook) {
			prepareCommitMessageHook();
		} else {
			aicommits(
				argv.flags.generate,
				argv.flags.exclude,
				argv.flags.all,
				argv.flags.type,
				argv.flags.yes,
				argv.flags.clipboard,
				argv.flags.noVerify,
				argv.flags.prompt,
				rawArgv
			);
		}
	},
	rawArgv
);


================================================
FILE: src/commands/aicommits.ts
================================================
import { execa } from 'execa';
import { black, dim, green, red, yellow, bgCyan } from 'kolorist';
import { copyToClipboard as copyMessage } from '../utils/clipboard.js';
import {
	intro,
	outro,
	spinner,
	select,
	confirm,
	isCancel,
} from '@clack/prompts';
import {
	assertGitRepo,
	getStagedDiff,
	getStagedDiffForFiles,
	getDetectedMessage,
} from '../utils/git.js';
import { getConfig, setConfigs } from '../utils/config-runtime.js';
import { getProvider } from '../feature/providers/index.js';
import {
	generateCommitMessage,
	generateCommitDescription,
	combineCommitMessages,
} from '../utils/openai.js';
import { KnownError, handleCommandError } from '../utils/error.js';

import { getCommitMessage } from '../utils/commit-helpers.js';
import { isHeadless } from '../utils/headless.js';

export default async (
	generate: number | undefined,
	excludeFiles: string[],
	stageAll: boolean,
	commitType: string | undefined,
	skipConfirm: boolean,
	copyToClipboard: boolean,
	noVerify: boolean,
	customPrompt: string | undefined,
	rawArgv: string[]
) =>
	(async () => {
		const headless = isHeadless();
		
		if (!headless) {
			intro(bgCyan(black(' aicommits ')));
		}

		await assertGitRepo();

		if (stageAll) {
			await execa('git', ['add', '--update']);
		}

		const staged = await getStagedDiff(excludeFiles);

		if (!staged) {
			throw new KnownError(
				'No staged changes found. Stage your changes manually, or automatically stage all changes with the `--all` flag.'
			);
		}

		if (!headless) {
			const detectingFiles = spinner();
			if (staged.files.length <= 10) {
				detectingFiles.start('Detecting staged files');
				detectingFiles.stop(
					`📁 ${getDetectedMessage(staged.files)}:\n${staged.files
						.map((file) => `     ${file}`)
						.join('\n')}`
				);
			} else {
				detectingFiles.start('Detecting staged files');
				detectingFiles.stop(`📁 ${getDetectedMessage(staged.files)}`);
			}
		}

		const { env } = process;
		const config = await getConfig({
			generate: generate?.toString(),
			type: commitType?.toString(),
		});

		const providerInstance = getProvider(config);
		if (!providerInstance) {
			if (!headless) {
				console.log("Welcome to aicommits! Let's set up your AI provider.");
				console.log('Run `aicommits setup` to configure your provider.');
				outro('Setup required. Please run: aicommits setup');
				return;
			} else {
				throw new KnownError(
					'No configuration found. Run `aicommits setup` in an interactive terminal, or set environment variables (OPENAI_API_KEY, etc.)'
				);
			}
		}

		// Use config timeout, or default per provider
		const timeout =
			config.timeout || (providerInstance.name === 'ollama' ? 30_000 : 10_000);

		// Validate provider config
		const validation = providerInstance.validateConfig();
		if (!validation.valid) {
			throw new KnownError(
				`Provider configuration issues: ${validation.errors.join(
					', '
				)}. Run \`aicommits setup\` to reconfigure.`
			);
		}

		// Use the unified model setting or provider default
		config.model = config.OPENAI_MODEL || providerInstance.getDefaultModel();

		// Check if diff is large and needs chunking
		const MAX_FILES = 50;
		const CHUNK_SIZE = 10;
		let isChunking = false;
		if (staged.files.length > MAX_FILES) {
			isChunking = true;
		}

		const s = headless ? null : spinner();
		if (s) {
			s.start(
				`🔍 Analyzing changes in ${staged.files.length} file${
					staged.files.length === 1 ? '' : 's'
				}`
			);
		}
		const startTime = Date.now();
		let messages: string[];
		let usage: any;
		try {
			const baseUrl = providerInstance.getBaseUrl();
			const apiKey = providerInstance.getApiKey() || '';
			const providerHeaders = providerInstance.getHeaders();
			const maxDiffLength = 30000;
			let diffToUse = staged.diff;
			if (diffToUse.length > maxDiffLength) {
				diffToUse =
					diffToUse.substring(0, maxDiffLength) +
					'\n\n[Diff truncated due to size]';
			}

			if (config.type === 'subject+body') {
				const result = await generateCommitMessage({
					baseUrl,
					apiKey,
					model: config.model!,
					locale: config.locale,
					diff: diffToUse,
					completions: 1,
					maxLength: config['max-length'],
					type: 'subject+body',
					timeout,
					customPrompt,
					headers: providerHeaders,
				});
				const title = result.messages[0];
				const { description } = await generateCommitDescription({
					baseUrl,
					apiKey,
					model: config.model!,
					locale: config.locale,
					title,
					diff: diffToUse,
					timeout,
					maxLength: config['max-length'],
					customPrompt,
					headers: providerHeaders,
				});
				messages = [
					description.trim()
						? `${title}\n\n${description.trim()}`
						: title,
				];
				usage = result.usage;
			} else if (isChunking) {
				// Split files into chunks
				const chunks: string[][] = [];
				for (let i = 0; i < staged.files.length; i += CHUNK_SIZE) {
					chunks.push(staged.files.slice(i, i + CHUNK_SIZE));
				}

				const chunkMessages: string[] = [];
				let totalUsage = {
					prompt_tokens: 0,
					completion_tokens: 0,
					total_tokens: 0,
				};

				for (const chunk of chunks) {
					const chunkDiff = await getStagedDiffForFiles(chunk, excludeFiles);
					if (chunkDiff && chunkDiff.diff) {
						// Truncate diff if too large to avoid context limits
						const maxDiffLength = 30000; // Approximate 7.5k tokens
						let diffToUse = chunkDiff.diff;
						if (diffToUse.length > maxDiffLength) {
							diffToUse =
								diffToUse.substring(0, maxDiffLength) +
								'\n\n[Diff truncated due to size]';
						}
						const result = await generateCommitMessage({
							baseUrl,
							apiKey,
							model: config.model!,
							locale: config.locale,
							diff: diffToUse,
							completions: config.generate,
							maxLength: config['max-length'],
							type: config.type,
							timeout,
							customPrompt,
							headers: providerHeaders,
						});
						chunkMessages.push(...result.messages);
						if (result.usage) {
							totalUsage.prompt_tokens +=
								(result.usage as any).prompt_tokens ||
								(result.usage as any).promptTokens ||
								0;
							totalUsage.completion_tokens +=
								(result.usage as any).completion_tokens ||
								(result.usage as any).completionTokens ||
								0;
							totalUsage.total_tokens +=
								(result.usage as any).total_tokens ||
								(result.usage as any).totalTokens ||
								0;
						}
					}
				}

				// Combine the chunk messages
				const combineResult = await combineCommitMessages({
					messages: chunkMessages,
					baseUrl,
					apiKey,
					model: config.model!,
					locale: config.locale,
					maxLength: config['max-length'],
					type: config.type,
					timeout,
					customPrompt,
					headers: providerHeaders,
				});
				messages = combineResult.messages;
				if (combineResult.usage) {
					totalUsage.prompt_tokens +=
						(combineResult.usage as any).prompt_tokens ||
						(combineResult.usage as any).promptTokens ||
						0;
					totalUsage.completion_tokens +=
						(combineResult.usage as any).completion_tokens ||
						(combineResult.usage as any).completionTokens ||
						0;
					totalUsage.total_tokens +=
						(combineResult.usage as any).total_tokens ||
						(combineResult.usage as any).totalTokens ||
						0;
				}
				usage = totalUsage;
			} else {
				const result = await generateCommitMessage({
					baseUrl,
					apiKey,
					model: config.model!,
					locale: config.locale,
					diff: diffToUse,
					completions: config.generate,
					maxLength: config['max-length'],
					type: config.type,
					timeout,
					customPrompt,
					headers: providerHeaders,
				});
				messages = result.messages;
				usage = result.usage;
			}
		} finally {
			if (s) {
				const duration = Date.now() - startTime;
				s.stop(
					`✅ Changes analyzed in ${(duration / 1000).toFixed(1)}s`
				);
			}
		}

		if (messages.length === 0) {
			throw new KnownError('No commit messages were generated. Try again.');
		}

		// Headless mode: output to stdout and exit
		if (headless) {
			const message = messages[0];
			console.log(message);
			return;
		}

		// Interactive mode: handle commit message selection and confirmation
		const message = await getCommitMessage(messages, skipConfirm);
		if (!message) {
			outro('Commit cancelled');
			return;
		}

		// Handle clipboard mode (early return)
		if (copyToClipboard) {
			const success = await copyMessage(message);
			if (success) {
				outro(`${green('✔')} Message copied to clipboard`);
			}
			return;
		}

		// Commit the message with timeout (use multiple -m for multi-line messages)
		try {
			const commitArgs =
				message.includes('\n\n')
					? ['-m', message.split(/\n\n/)[0], '-m', message.slice(message.indexOf('\n\n') + 2)]
					: ['-m', message];
			if (noVerify) {
				commitArgs.push('--no-verify');
			}
			await execa('git', ['commit', ...commitArgs, ...rawArgv], {
				stdio: 'inherit',
				cleanup: true,
				timeout: 10000,
			});
			outro(`${green('✔')} Successfully committed!`);
		} catch (error: any) {
			if (error.timedOut) {
				const success = await copyMessage(message);
				if (success) {
					outro(
						`${yellow('⚠')} Commit timed out after 10 seconds. Message copied to clipboard.`
					);
				} else {
					outro(
						`${yellow('⚠')} Commit timed out after 10 seconds. Could not copy to clipboard.`
					);
				}
				return;
			}
			if (error.exitCode !== undefined) {
				outro(`${red('✘')} Commit failed. This may be due to pre-commit hooks.`);
				console.error(
					`  ${dim('Use')} --no-verify ${dim('to bypass pre-commit hooks')}`
				);
				process.exit(1);
			}
			throw error;
		}
	})().catch(handleCommandError);


================================================
FILE: src/commands/config.ts
================================================
import { command } from 'cleye';
import { red } from 'kolorist';
import { hasOwn } from '../utils/config-types.js';
import { getConfig, setConfigs } from '../utils/config-runtime.js';
import { KnownError, handleCommandError } from '../utils/error.js';

export default command(
	{
		name: 'config',
		description: 'View or modify configuration settings',
		help: {
			description: 'View or modify configuration settings',
		},
		parameters: ['[mode]', '[key=value...]'],
	},
	(argv) => {
		(async () => {
			const [mode, ...keyValues] = argv._;

			// If no mode provided, show all current config (excluding defaults)
			if (!mode) {
				const config = await getConfig({}, {}, true);

				console.log('Provider:', config.provider);
				if (config.OPENAI_API_KEY) {
					console.log('API Key:', `${config.OPENAI_API_KEY.substring(0, 4)}****`);
				}
				if (config.OPENAI_BASE_URL) {
					console.log('Base URL:', config.OPENAI_BASE_URL);
				}
				if (config.OPENAI_MODEL) {
					console.log('Model:', config.OPENAI_MODEL);
				}

				return;
			}

			if (mode === 'get') {
				const config = await getConfig({}, {}, true);
				const sensitiveKeys = ['OPENAI_API_KEY', 'TOGETHER_API_KEY', 'api-key'];
				for (const key of keyValues) {
					if (hasOwn(config, key)) {
						const value = config[key as keyof typeof config];
						const displayValue = sensitiveKeys.includes(key)
							? `${String(value).substring(0, 4)}****`
							: String(value);
						console.log(`${key}=${displayValue}`);
					}
				}
				return;
			}

			if (mode === 'set') {
				await setConfigs(
					keyValues.map((keyValue) => keyValue.split('=') as [string, string])
				);
				return;
			}

			throw new KnownError(`Invalid mode: ${mode}`);
		})().catch(handleCommandError);
	}
);


================================================
FILE: src/commands/hook.ts
================================================
import fs from 'fs/promises';
import path from 'path';
import { fileURLToPath, pathToFileURL } from 'url';
import { green, red } from 'kolorist';
import { command } from 'cleye';
import { assertGitRepo } from '../utils/git.js';
import { fileExists } from '../utils/fs.js';
import { KnownError, handleCliError } from '../utils/error.js';

const hookName = 'prepare-commit-msg';
const symlinkPath = `.git/hooks/${hookName}`;

const hookPath = fileURLToPath(new URL('cli.mjs', import.meta.url));

export const isCalledFromGitHook = process.argv[1]
	.replace(/\\/g, '/') // Replace Windows back slashes with forward slashes
	.endsWith(`/${symlinkPath}`);

const isWindows = process.platform === 'win32';
const windowsHook = `
#!/usr/bin/env node
import(${JSON.stringify(pathToFileURL(hookPath))})
`.trim();

export default command(
	{
		name: 'hook',
		description: 'Install or uninstall the Git hook for automatic commit messages',
		help: {
			description: 'Install or uninstall the Git hook for automatic commit messages',
		},
		parameters: ['<install/uninstall>'],
	},
	(argv) => {
		(async () => {
			const gitRepoPath = await assertGitRepo();
			const { installUninstall: mode } = argv._;

			const absoltueSymlinkPath = path.join(gitRepoPath, symlinkPath);
			const hookExists = await fileExists(absoltueSymlinkPath);
			if (mode === 'install') {
				if (hookExists) {
					// If the symlink is broken, it will throw an error
					// eslint-disable-next-line @typescript-eslint/no-empty-function
					const realpath = await fs
						.realpath(absoltueSymlinkPath)
						.catch(() => {});
					if (realpath === hookPath) {
						console.warn('The hook is already installed');
						return;
					}
					throw new KnownError(
						`A different ${hookName} hook seems to be installed. Please remove it before installing aicommits.`
					);
				}

				await fs.mkdir(path.dirname(absoltueSymlinkPath), { recursive: true });

				if (isWindows) {
					await fs.writeFile(absoltueSymlinkPath, windowsHook);
				} else {
					await fs.symlink(hookPath, absoltueSymlinkPath, 'file');
					await fs.chmod(absoltueSymlinkPath, 0o755);
				}
				console.log(`${green('✔')} Hook installed`);
				return;
			}

			if (mode === 'uninstall') {
				if (!hookExists) {
					console.warn('Hook is not installed');
					return;
				}

				if (isWindows) {
					const scriptContent = await fs.readFile(absoltueSymlinkPath, 'utf8');
					if (scriptContent !== windowsHook) {
						console.warn('Hook is not installed');
						return;
					}
				} else {
					const realpath = await fs.realpath(absoltueSymlinkPath);
					if (realpath !== hookPath) {
						console.warn('Hook is not installed');
						return;
					}
				}

				await fs.rm(absoltueSymlinkPath);
				console.log(`${green('✔')} Hook uninstalled`);
				return;
			}

			throw new KnownError(`Invalid mode: ${mode}`);
		})().catch((error) => {
			console.error(`${red('✖')} ${error.message}`);
			handleCliError(error);
			process.exit(1);
		});
	}
);


================================================
FILE: src/commands/model.ts
================================================
import { command } from 'cleye';
import { outro, log } from '@clack/prompts';
import { getConfig, setConfigs } from '../utils/config-runtime.js';
import { getProvider } from '../feature/providers/index.js';
import { selectModel } from '../feature/models.js';
import { KnownError, handleCommandError } from '../utils/error.js';
import { isInteractive } from '../utils/headless.js';

export default command(
	{
		name: 'model',
		description: 'Select or change your AI model',
		help: {
			description: 'Select or change your AI model',
		},
		alias: ['-m', 'models'],
	},
	() => {
		(async () => {
			if (!isInteractive()) {
				throw new KnownError(
					'Interactive terminal required for model selection.'
				);
			}

			const config = await getConfig();

			if (!config.provider) {
				outro('No provider configured. Run `aicommits setup` first.');
				return;
			}

			const provider = getProvider(config);
			if (!provider) {
				outro(
					'Invalid provider configured. Run `aicommits setup` to reconfigure.'
				);
				return;
			}

			const currentModel = config.OPENAI_MODEL;

			// Validate provider config
			const validation = provider.validateConfig();
			if (!validation.valid) {
				outro(
					`Configuration issues: ${validation.errors.join(
						', '
					)}. Run \`aicommits setup\` to reconfigure.`
				);
				return;
			}

			// Select model using provider
			const selectedModel = await selectModel(
				provider.getBaseUrl(),
				provider.getApiKey() || '',
				currentModel,
				provider.getDefinition(),
				provider.displayName
			);

			if (selectedModel) {
				// Save the selected model
				await setConfigs([['OPENAI_MODEL', selectedModel]]);
				outro(`✅ Model updated to: ${selectedModel}`);
			} else {
				outro('Model selection cancelled');
			}
		})().catch(handleCommandError);
	}
);


================================================
FILE: src/commands/pr.ts
================================================
import { command } from 'cleye';
import { execa } from 'execa';
import { black, green, bgCyan } from 'kolorist';
import { intro, outro, spinner, confirm, isCancel } from '@clack/prompts';
import { assertGitRepo } from '../utils/git.js';
import { getConfig } from '../utils/config-runtime.js';
import { getProvider } from '../feature/providers/index.js';
import { generateText } from 'ai';
import { createOpenAI } from '@ai-sdk/openai';
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
import { KnownError, handleCommandError } from '../utils/error.js';
import { isInteractive } from '../utils/headless.js';

type GitProvider = 'github' | 'gitlab' | 'bitbucket' | 'azure';

interface RepoInfo {
	provider: GitProvider;
	owner: string;
	repo: string;
}

function parseRemoteUrl(remoteUrl: string): RepoInfo {
	const githubMatch = remoteUrl.match(/github\.com[\/:]([^\/]+)\/([^\/\.]+)/);
	if (githubMatch) {
		return { provider: 'github', owner: githubMatch[1], repo: githubMatch[2] };
	}

	const gitlabMatch = remoteUrl.match(/gitlab\.com[\/:]([^\/]+)\/([^\/\.]+)/);
	if (gitlabMatch) {
		return { provider: 'gitlab', owner: gitlabMatch[1], repo: gitlabMatch[2] };
	}

	const bitbucketMatch = remoteUrl.match(/bitbucket\.org[\/:]([^\/]+)\/([^\/\.]+)/);
	if (bitbucketMatch) {
		return { provider: 'bitbucket', owner: bitbucketMatch[1], repo: bitbucketMatch[2] };
	}

	const azureMatch = remoteUrl.match(/dev\.azure\.com[\/:]([^\/]+)\/([^\/\.]+)|vs-internal\.visualstudio\.com[\/:]([^\/]+)\/([^\/\.]+)/);
	if (azureMatch) {
		return { provider: 'azure', owner: azureMatch[1] || azureMatch[3], repo: azureMatch[2] || azureMatch[4] };
	}

	throw new KnownError(
		`Unsupported git provider. Supported: GitHub, GitLab, Bitbucket, Azure DevOps.\nRemote URL: ${remoteUrl}`
	);
}

function getPrUrl(
	provider: GitProvider,
	owner: string,
	repo: string,
	defaultBranch: string,
	currentBranch: string,
	title: string,
	body: string
): string {
	const encodedTitle = encodeURIComponent(title);
	const encodedBody = encodeURIComponent(body);

	switch (provider) {
		case 'github':
			return `https://github.com/${owner}/${repo}/compare/${defaultBranch}...${currentBranch}?expand=1&title=${encodedTitle}&body=${encodedBody}`;
		case 'gitlab':
			return `https://gitlab.com/${owner}/${repo}/-/merge_requests/new?merge_request[source_branch]=${encodeURIComponent(currentBranch)}&merge_request[target_branch]=${encodeURIComponent(defaultBranch)}&merge_request[title]=${encodedTitle}&merge_request[description]=${encodedBody}`;
		case 'bitbucket':
			return `https://bitbucket.org/${owner}/${repo}/pull-requests/new?source=${encodeURIComponent(currentBranch)}&dest=${encodeURIComponent(defaultBranch)}&title=${encodedTitle}&description=${encodedBody}`;
		case 'azure':
			return `https://dev.azure.com/${owner}/${repo}/_git/${repo}/pullrequestcreate?sourceRef=${encodeURIComponent(currentBranch)}&targetRef=${encodeURIComponent(defaultBranch)}&title=${encodedTitle}&description=${encodedBody}`;
	}
}

export default command(
	{
		name: 'pr',
		description:
			'[beta 🚧] Generate and create a PR (GitHub/GitLab/Bitbucket/Azure) based on branch diff',
		help: {
			description:
				'[beta 🚧] Generate and create a PR (GitHub/GitLab/Bitbucket/Azure) based on branch diff',
		},
	},
	() => {
		(async () => {
			if (!isInteractive()) {
				throw new KnownError(
					'Interactive terminal required for PR creation.'
				);
			}

			intro(bgCyan(black(' aicommits pr ')));

			await assertGitRepo();

			// Get current branch
			const { stdout: currentBranch } = await execa('git', [
				'branch',
				'--show-current',
			]);
			if (!currentBranch.trim()) {
				throw new KnownError('Not on a branch');
			}

			// Get repo URL
			const { stdout: remoteUrl } = await execa('git', [
				'remote',
				'get-url',
				'origin',
			]);
			const repoInfo = parseRemoteUrl(remoteUrl);
			const { provider, owner, repo } = repoInfo;

			// Get default branch from git remote
			let defaultBranch = 'main';
			try {
				const { stdout } = await execa('git', [
					'symbolic-ref',
					'refs/remotes/origin/HEAD',
				]);
				defaultBranch = stdout.trim().replace('refs/remotes/origin/', '');
			} catch {
				// Fallback to main if git command fails
			}

			// Check if on default branch
			if (currentBranch.trim() === defaultBranch) {
				throw new KnownError('PR creation requires being on a feature branch, not the default branch. Please switch to a feature branch with changes.');
			}

			// Get diff from default branch to current branch
			let diff;
			try {
				const { stdout } = await execa('git', [
					'diff',
					`origin/${defaultBranch}..HEAD`,
				]);
				diff = stdout;
			} catch {
				throw new KnownError(`Could not get diff from origin/${defaultBranch}`);
			}

			if (!diff) {
				throw new KnownError('No changes to create PR from');
			}

			// Count changed files
			const numFiles = diff
				.split('\n')
				.filter((line) => line.startsWith('diff --git')).length;

			// Limit diff size to avoid token limits
			const maxDiffLength = 30000; // Approximate character limit
			if (diff.length > maxDiffLength) {
				diff =
					diff.substring(0, maxDiffLength) + '\n\n[Diff truncated due to size]';
			}

			const config = await getConfig();
			const configProvider = await getProvider(config);

			if (!configProvider) {
				throw new KnownError('No provider configured');
			}

			let baseUrl = configProvider.getBaseUrl();
			if (!baseUrl || baseUrl === '') {
				throw new KnownError(
					'Base URL not configured. Please run `aicommits setup` to configure your provider.'
				);
			}
			if (!baseUrl.endsWith('/v1')) {
				baseUrl += '/v1';
			}
			const apiKey = configProvider.getApiKey();
			if (!apiKey) {
				throw new KnownError(
					'API key not configured. Please run `aicommits setup` to configure your provider.'
				);
			}
			const aiProvider =
				baseUrl === 'https://api.openai.com/v1'
					? createOpenAI({ apiKey })
					: createOpenAICompatible({
							name: 'custom',
							apiKey,
							baseURL: baseUrl,
					  });

			const generating = spinner();
			generating.start(
				`Generating PR title and description (${numFiles} files changed)`
			);

			const startTime = Date.now();

			// Generate PR title
			const titleResult = await generateText({
				model: aiProvider(config.model) as any,
				system:
					'Generate a concise PR title based on the following git diff. The title should be under 72 characters.',
				prompt: diff,
				maxRetries: 2,
			});

			const title = titleResult.text;

			// Generate PR body
			const bodyResult = await generateText({
				model: aiProvider(config.model) as any,
				system:
					'Generate a concise PR description based on the following git diff. Format using Markdown with headings like ### Summary, ### Changes, ### Review Notes. Provide a high-level summary of the changes, what was implemented or fixed, and any specific details reviewers should consider. Avoid listing individual files.',
				prompt: diff,
				maxRetries: 2,
			});

			const body = bodyResult.text;

			const endTime = Date.now();
			const duration = Math.round((endTime - startTime) / 1000);

			generating.stop(
				`Generated PR content for ${numFiles} files in ${duration}s`
			);

			console.log(`${green('Title:')} ${title.replace(/\n/g, ' ')}`);
			console.log(
				`${green('Body:')} ${
					body.length > 100 ? body.substring(0, 100) + '...' : body
				}`
			);

			const { text } = await import('@clack/prompts');
			const proceed = await text({
				message:
					'Press Enter to push and open PR creation in browser, or Ctrl+C to cancel',
				placeholder: 'Press Enter',
			});

			if (isCancel(proceed)) {
				outro('PR creation cancelled');
				return;
			}

			const pushing = spinner();
			pushing.start(`Pushing branch to ${provider}`);

			try {
				await execa('git', ['push', '-u', 'origin', currentBranch.trim()]);
				pushing.stop(`Branch pushed to ${provider}`);
			} catch (error) {
				pushing.stop('Failed to push branch');
				throw new KnownError(`Failed to push branch: ${error instanceof Error ? error.message : String(error)}`);
			}

			const prUrl = getPrUrl(
				provider,
				owner,
				repo,
				defaultBranch,
				currentBranch.trim(),
				title,
				body
			);

			const creating = spinner();
			creating.start('Opening PR creation page in browser');

			try {
				// Try to open browser
				const openCmd =
					process.platform === 'darwin'
						? 'open'
						: process.platform === 'win32'
						? 'start'
						: 'xdg-open';
				await execa(openCmd, [prUrl]);
				creating.stop('PR creation page opened in browser');
				outro(
					green('PR creation page opened! Please review and submit the PR.')
				);
			} catch (error) {
				creating.stop('Failed to open browser');
				outro(`${green('PR URL:')} ${prUrl}`);
				outro('Please open the URL above in your browser to create the PR.');
			}
		})().catch((error) => {
			handleCommandError(error);
		});
	}
);


================================================
FILE: src/commands/prepare-commit-msg-hook.ts
================================================
import fs from 'fs/promises';
import { intro, outro, spinner } from '@clack/prompts';
import { black, green, red, bgCyan } from 'kolorist';
import { getStagedDiff } from '../utils/git.js';
import { getConfig } from '../utils/config-runtime.js';
import { getProvider } from '../feature/providers/index.js';
import { generateCommitMessage } from '../utils/openai.js';
import { KnownError, handleCommandError } from '../utils/error.js';
import { isHeadless } from '../utils/headless.js';

const [messageFilePath, commitSource] = process.argv.slice(2);

export default () =>
	(async () => {
		if (!messageFilePath) {
			throw new KnownError(
				'Commit message file path is missing. This file should be called from the "prepare-commit-msg" git hook'
			);
		}

		// If a commit message is passed in, ignore
		if (commitSource) {
			return;
		}

		// All staged files can be ignored by our filter
		const staged = await getStagedDiff();
		if (!staged) {
			return;
		}

		const headless = isHeadless();
		if (!headless) {
			intro(bgCyan(black(' aicommits ')));
		}

		const config = await getConfig({});

		const providerInstance = getProvider(config);
		if (!providerInstance) {
			throw new KnownError(
				'Invalid provider configuration. Run `aicommits setup` to reconfigure.'
			);
		}

		// Validate provider config
		const validation = providerInstance.validateConfig();
		if (!validation.valid) {
			throw new KnownError(
				`Provider configuration issues: ${validation.errors.join(
					', '
				)}. Run \`aicommits setup\` to reconfigure.`
			);
		}

		const baseUrl = providerInstance.getBaseUrl();
		const apiKey = providerInstance.getApiKey() || '';
		const providerHeaders = providerInstance.getHeaders();

		// Use config timeout, or default per provider
		const timeout =
			config.timeout || (providerInstance.name === 'ollama' ? 30_000 : 10_000);

		// Use the unified model or provider default
		let model = config.OPENAI_MODEL || providerInstance.getDefaultModel();

		const s = headless ? null : spinner();
		s?.start('The AI is analyzing your changes');
		let messages: string[];
		try {
			const result = await generateCommitMessage({
				baseUrl,
				apiKey,
				model,
				locale: config.locale,
				diff: staged!.diff,
				completions: config.generate,
				maxLength: config['max-length'],
				type: config.type,
				timeout,
				headers: providerHeaders,
			});
			messages = result.messages;
		} finally {
			s?.stop('Changes analyzed');
		}

		/**
		 * When `--no-edit` is passed in, the base commit message is empty,
		 * and even when you use pass in comments via #, they are ignored.
		 *
		 * Note: `--no-edit` cannot be detected in argvs so this is the only way to check
		 */
		const baseMessage = await fs.readFile(messageFilePath, 'utf8');
		const supportsComments = baseMessage !== '';
		const hasMultipleMessages = messages.length > 1;

		let instructions = '';

		if (supportsComments) {
			instructions = `# 🤖 AI generated commit${
				hasMultipleMessages ? 's' : ''
			}\n`;
		}

		if (hasMultipleMessages) {
			if (supportsComments) {
				instructions +=
					'# Select one of the following messages by uncommenting:\n';
			}
			instructions += `\n${messages
				.map((message) => `# ${message}`)
				.join('\n')}`;
		} else {
			if (supportsComments) {
				instructions += '# Edit the message below and commit:\n';
			}
			instructions += `\n${messages[0]}\n`;
		}

		const currentContent = await fs.readFile(messageFilePath, 'utf8');
		const newContent = instructions + '\n' + currentContent;
		await fs.writeFile(messageFilePath, newContent);

		if (!headless) {
			outro(`${green('✔')} Saved commit message!`);
		}
	})().catch(handleCommandError);


================================================
FILE: src/commands/setup.ts
================================================
import { execSync } from 'child_process';
import { command } from 'cleye';
import { select, text, outro, isCancel, confirm } from '@clack/prompts';
import { getConfig, setConfigs } from '../utils/config-runtime.js';
import {
	getProvider,
	getAvailableProviders,
	getProviderBaseUrl,
} from '../feature/providers/index.js';
import { KnownError, handleCommandError } from '../utils/error.js';
import { isInteractive } from '../utils/headless.js';

export default command(
	{
		name: 'setup',
		description: 'Configure your AI provider and settings',
		help: {
			description: 'Configure your AI provider and settings',
		},
	},
	(argv) => {
		(async () => {
			if (!isInteractive()) {
				throw new KnownError(
					'Interactive terminal required for setup. Run `aicommits setup` in a terminal.'
				);
			}

			let config = await getConfig();

			const providerOptions = getAvailableProviders();
			const choice = await select({
				message: 'Choose your AI provider:',
				options: providerOptions,
				initialValue: config.provider,
			});

			if (isCancel(choice)) {
				outro('Setup cancelled');
				return;
			}
			const providerChoice = choice as string;

			// Ask for custom base URL if custom provider
			let customBaseUrl = '';
			if (providerChoice === 'custom') {
				const baseUrlInput = await text({
					message: 'Enter your custom API endpoint:',
					validate: (value: string) => {
						if (!value) return 'Endpoint is required';
						try {
							new URL(value);
						} catch {
							return 'Invalid URL format';
						}
						return;
					},
				});
				if (isCancel(baseUrlInput)) {
					outro('Setup cancelled');
					return;
				}
				customBaseUrl = baseUrlInput as string;
			}

			// Set default base URL for the provider
			let defaultBaseUrl = customBaseUrl || getProviderBaseUrl(providerChoice);

			// Set defaults
			config.OPENAI_BASE_URL = defaultBaseUrl;
			config.OPENAI_API_KEY = '';
			config.OPENAI_MODEL = '';

			// Get provider instance
			let provider = getProvider({ ...config, provider: providerChoice });
			if (!provider) {
				outro('Invalid provider selected');
				return;
			}

			try {
				const apiUpdates = await provider.setup();
				for (const [k, v] of apiUpdates) {
					(config as any)[k] = v;
				}
			} catch (error) {
				if (error instanceof Error && error.message === 'Setup cancelled') {
					outro('Setup cancelled');
					return;
				}
				throw error;
			}

			// Recreate provider with updated config for validation
			provider = getProvider({ ...config, provider: providerChoice });
			if (!provider) {
				outro('Invalid provider selected');
				return;
			}

			// Validate configuration
			const validation = provider.validateConfig();
			if (!validation.valid) {
				outro(`Setup cancelled: ${validation.errors.join(', ')}`);
				return;
			}

			// Select model interactively
			const { selectModel } = await import('../feature/models.js');
			const selectedModel = await selectModel(
				provider.getBaseUrl(),
				provider.getApiKey() || '',
				undefined,
				provider.getDefinition()
			);

			if (selectedModel) {
				config.OPENAI_MODEL = selectedModel;
				console.log(`Model selected: ${selectedModel}`);
			} else {
				outro('Model selection cancelled.');
				return;
			}

			const typeChoice = await select({
				message: 'Choose commit message format:',
				options: [
					{ value: 'plain', label: 'Plain - Simple format without structure' },
					{ value: 'conventional', label: 'Conventional - Standard conventional commits' },
					{ value: 'gitmoji', label: 'Gitmoji - Using emojis for commit types' },
					{ value: 'subject+body', label: 'Subject + body - Git-style subject line and body' },
				],
				initialValue: 'plain',
			});

			if (isCancel(typeChoice)) {
				outro('Setup cancelled');
				return;
			}
			(config as any).type = typeChoice as string;

			// Save all config at once
			const finalUpdates = Object.entries(config).filter(
				([k, v]) =>
					k !== 'provider' &&
					k !== 'model' &&
					v !== undefined &&
					v !== '' &&
					typeof v === 'string'
			) as [string, string][];
			await setConfigs(finalUpdates);

			outro(`✅ Setup complete! You're now using ${provider.displayName}.`);

			// // Offer to create git alias
			// const aliasChoice = await confirm({
			// 	message: 'Would you like to create a git alias "git ac" for "aicommits"?',
			// });

			// if (aliasChoice) {
			// 	try {
			// 		execSync('git config --global alias.ac "!aicommits"', { stdio: 'inherit' });
			// 		console.log('✅ Git alias "git ac" created successfully.');
			// 	} catch (error) {
			// 		console.error(`❌ Failed to create git alias: ${(error as Error).message}`);
			// 	}
			// }
		})().catch(handleCommandError);
	}
);


================================================
FILE: src/commands/update.ts
================================================
import { command } from 'cleye';
import { execSync, exec } from 'child_process';
import { promisify } from 'util';
import { green, red, yellow, cyan } from 'kolorist';
import { outro, spinner } from '@clack/prompts';
import pkg from '../../package.json';
import { handleCommandError, KnownError } from '../utils/error.js';

const execAsync = promisify(exec);

interface PackageManagerInfo {
	name: string;
	updateCommand: string;
}

// Determine the dist tag based on current version
// Versions with prerelease (e.g., 2.0.0-develop.5) use 'develop' tag
// Stable versions use 'latest' tag
function getDistTag(version: string): string {
	// Skip for development/semantic-release versions
	if (version === '0.0.0-semantic-release' || version.includes('semantic-release')) {
		return 'latest';
	}
	// If version has prerelease identifier (contains '-'), use 'develop' tag
	if (version.includes('-')) {
		return 'develop';
	}
	return 'latest';
}

function detectPackageManager(distTag: string): PackageManagerInfo {
	// Check if running from global installation
	try {
		const globalPath = execSync('npm root -g', { encoding: 'utf8' }).trim();
		const { execPath } = process;

		// Check if running from global npm installation
		if (execPath.includes(globalPath) || execPath.includes('/usr/local') || execPath.includes('/usr/bin')) {
			return { name: 'npm', updateCommand: `npm install -g aicommits@${distTag}` };
		}
	} catch {
		// Fall through to other detection methods
	}

	// Check for pnpm
	try {
		execSync('pnpm --version', { stdio: 'ignore' });
		// Check if installed via pnpm global
		const pnpmList = execSync('pnpm list -g aicommits', { encoding: 'utf8' });
		if (pnpmList.includes('aicommits')) {
			return { name: 'pnpm', updateCommand: `pnpm add -g aicommits@${distTag}` };
		}
	} catch {
		// Not pnpm
	}

	// Check for yarn
	try {
		execSync('yarn --version', { stdio: 'ignore' });
		// Check if installed via yarn global
		const yarnList = execSync('yarn global list', { encoding: 'utf8' });
		if (yarnList.includes('aicommits')) {
			return { name: 'yarn', updateCommand: `yarn global add aicommits@${distTag}` };
		}
	} catch {
		// Not yarn
	}

	// Check for bun
	try {
		execSync('bun --version', { stdio: 'ignore' });
		// Check if installed via bun
		const bunList = execSync('bun pm bin -g', { encoding: 'utf8' });
		if (process.execPath.includes('bun') || bunList.includes('aicommits')) {
			return { name: 'bun', updateCommand: `bun add -g aicommits@${distTag}` };
		}
	} catch {
		// Not bun
	}

	// Default to npm
	return { name: 'npm', updateCommand: `npm install -g aicommits@${distTag}` };
}

async function getLatestVersion(distTag: string): Promise<string | null> {
	try {
		const response = await fetch(`https://registry.npmjs.org/aicommits/${distTag}`, {
			headers: { Accept: 'application/json' },
		});
		if (!response.ok) return null;
		const data = await response.json();
		return data.version || null;
	} catch {
		return null;
	}
}

export default command(
	{
		name: 'update',
		description: 'Update aicommits to the latest version',
		help: {
			description: 'Check for updates and install the latest version using your package manager',
		},
	},
	() => {
		(async () => {
			// Determine dist tag based on current version
			const distTag = getDistTag(pkg.version);
			const pm = detectPackageManager(distTag);

			console.log(`${cyan('ℹ')} Current version: ${pkg.version}`);
			console.log(`${cyan('ℹ')} Package manager detected: ${pm.name}`);
			if (distTag !== 'latest') {
				console.log(`${cyan('ℹ')} Using '${distTag}' distribution tag`);
			}

			const s = spinner();
			s.start('Checking for updates...');

			const latestVersion = await getLatestVersion(distTag);

			if (!latestVersion) {
				s.stop('Could not check for updates', 1);
				throw new KnownError('Failed to fetch latest version from npm registry');
			}

			if (latestVersion === pkg.version) {
				s.stop(`${green('✔')} Already on the latest version (${pkg.version})`);
				return;
			}

			s.stop(`${green('✔')} Update available: v${pkg.version} → v${latestVersion}`);

			const updateS = spinner();
			updateS.start(`Updating via ${pm.name}...`);

			try {
				await execAsync(pm.updateCommand, { timeout: 120000 });

				updateS.stop(`${green('✔')} Successfully updated to v${latestVersion}`);
				outro(`${green('✔')} Update complete! Run 'aic --version' to verify.`);
			} catch (error: any) {
				updateS.stop(`${red('✘')} Update failed`, 1);

				if (error.stderr?.includes('permission') || error.message?.includes('permission')) {
					console.error(`${red('✘')} Permission denied. Try running with sudo:`);
					console.error(`   sudo ${pm.updateCommand}`);
				} else if (error.stderr?.includes('EACCES')) {
					console.error(`${red('✘')} Permission denied. Try running with sudo:`);
					console.error(`   sudo ${pm.updateCommand}`);
				} else {
					console.error(`${red('✘')} Error: ${error.message || 'Unknown error'}`);
					console.error(`\n${yellow('You can manually update with:')}`);
					console.error(`   ${pm.updateCommand}`);
				}

				process.exit(1);
			}
		})().catch(handleCommandError);
	}
);


================================================
FILE: src/feature/models.ts
================================================
// Model filtering, fetching, and selection utilities
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
import crypto from 'crypto';
import type { ProviderDef } from './providers/base.js';
import { CURRENT_LABEL_FORMAT } from '../utils/constants.js';
import { isCancel, spinner } from '@clack/prompts';
import { fileExists } from '../utils/fs.js';

interface ModelObject {
	id?: string;
	name?: string;
	type?: string;
}

interface CacheEntry {
	data: { models: ModelObject[]; error?: string };
	timestamp: number;
}

const CACHE_DURATION = 60 * 60 * 1000; // 1 hour in milliseconds

const getCacheDir = (): string => {
	const platform = process.platform;
	const home = os.homedir();

	if (platform === 'darwin') {
		return path.join(home, 'Library', 'Caches', 'aicommits', 'models');
	} else if (platform === 'win32') {
		return path.join(home, 'AppData', 'Local', 'aicommits', 'models');
	} else {
		// Linux/Unix
		const xdgCache = process.env.XDG_CACHE_HOME;
		const baseCache = xdgCache ? xdgCache : path.join(home, '.cache');
		return path.join(baseCache, 'aicommits', 'models');
	}
};

const getCacheKey = (baseUrl: string): string => {
	const hash = crypto.createHash('sha256');
	hash.update(baseUrl);
	return hash.digest('hex');
};

const getCachePath = (key: string): string =>
	path.join(getCacheDir(), `${key}.json`);

const readCache = async (key: string): Promise<CacheEntry | null> => {
	const cachePath = getCachePath(key);
	try {
		if (!(await fileExists(cachePath))) return null;
		const data = await fs.readFile(cachePath, 'utf8');
		return JSON.parse(data);
	} catch {
		return null;
	}
};

const writeCache = async (key: string, entry: CacheEntry): Promise<void> => {
	try {
		const cacheDir = getCacheDir();
		await fs.mkdir(cacheDir, { recursive: true });
		const cachePath = getCachePath(key);
		await fs.writeFile(cachePath, JSON.stringify(entry), 'utf8');
	} catch {
		// Ignore write errors
	}
};

interface FetchModelsOptions {
	baseUrl: string;
	apiKey?: string;
	cacheModels?: boolean;
}

// Fetch models from API
export const fetchModels = async (
	options: FetchModelsOptions,
): Promise<{ models: ModelObject[]; error?: string }> => {
	const { baseUrl, apiKey = '', cacheModels = true } = options;
	const cacheKey = getCacheKey(baseUrl);
	const now = Date.now();

	if (cacheModels) {
		const cached = await readCache(cacheKey);
		if (cached && now - cached.timestamp < CACHE_DURATION) {
			return cached.data;
		}
	}

	try {
		const response = await fetch(`${baseUrl}/models`, {
			headers: {
				Authorization: `Bearer ${apiKey}`,
			},
		});

		if (!response.ok) {
			throw new Error(`HTTP ${response.status}: ${response.statusText}`);
		}

		const data = await response.json();

		// we do this since Together API for openai models has different response than standard needing just data, other apis data.data
		const modelsArray: ModelObject[] = (data.data ? data.data : data) || [];

		const result = { models: modelsArray };
		if (cacheModels && modelsArray.length > 0) {
			await writeCache(cacheKey, { data: result, timestamp: now });
		}
		return result;
	} catch (error: unknown) {
		const errorMessage =
			error instanceof Error ? error.message : 'Request failed';
		const result = { models: [], error: errorMessage };
		return result;
	}
};

// Shared model selection function
const fetchAndFilterModels = async (
	baseUrl: string,
	apiKey: string,
	providerDef?: ProviderDef,
): Promise<string[]> => {
	// Fetch models
	const result = await fetchModels({
		baseUrl,
		apiKey,
		cacheModels: providerDef?.cacheModels,
	});

	if (result.error) {
		console.error(`Failed to fetch models: ${result.error}`);
	}

	// Apply provider-specific filtering
	let models: string[] = [];
	const modelsArray = Array.isArray(result.models) ? result.models : [];
	if (providerDef?.modelsFilter) {
		models = providerDef.modelsFilter(modelsArray);
	} else {
		// Fallback: just use model ids/names
		models = modelsArray
			.map((model) => model.id || model.name)
			.filter(Boolean) as string[];
	}
	return models;
};

const prepareModelOptions = (
	models: string[],
	currentModel?: string,
	providerDef?: ProviderDef,
) => {
	let modelOptions = models.map((model: string) => ({
		label: model,
		value: model,
	}));

	// Move highlighted models to the top
	if (providerDef?.defaultModels && providerDef.defaultModels.length > 0) {
		const highlightedModels = providerDef.defaultModels.filter((model) =>
			modelOptions.some((opt) => opt.value === model),
		);

		// Remove highlighted models from their current positions
		highlightedModels.forEach((model) => {
			const index = modelOptions.findIndex((opt) => opt.value === model);
			if (index >= 0) {
				modelOptions.splice(index, 1);
			}
		});

		// Add highlighted models at the beginning with special labels
		highlightedModels.forEach((model, index) => {
			const isCurrent = model === currentModel;
			const isDefault = index === 0;
			let label: string;
			if (isCurrent) {
				label = `✅ ${model} (current)`;
			} else if (isDefault) {
				label = `👑 ${model} (default)`;
			} else {
				label = `🔥 ${model}`;
			}
			modelOptions.unshift({ label, value: model });
		});
	}

	// Move current model to the top if it exists and isn't already highlighted
	if (currentModel && currentModel !== 'undefined') {
		const isHighlighted = providerDef?.defaultModels?.includes(currentModel);
		const currentIndex = modelOptions.findIndex(
			(opt) => opt.value === currentModel,
		);

		if (currentIndex >= 0 && !isHighlighted) {
			// Mark as current and move to top (after highlighted models)
			modelOptions[currentIndex].label = CURRENT_LABEL_FORMAT(
				modelOptions[currentIndex].value,
			);
			if (currentIndex > 0) {
				const [current] = modelOptions.splice(currentIndex, 1);
				// Find position after highlighted models
				const highlightedCount = providerDef?.defaultModels?.length || 0;
				modelOptions.splice(highlightedCount, 0, current);
			}
		} else if (currentIndex < 0 && !isHighlighted) {
			// Current model not in fetched list, add it after highlighted models
			const highlightedCount = providerDef?.defaultModels?.length || 0;
			modelOptions.splice(highlightedCount, 0, {
				label: CURRENT_LABEL_FORMAT(currentModel),
				value: currentModel,
			});
		}
	}

	return modelOptions;
};

const handleSearch = async (
	models: string[],
	select: any,
	text: any,
	isCancel: any,
): Promise<string | null> => {
	// Search for models
	const searchTerm = await text({
		message: 'Enter search term for models:',
		placeholder: 'e.g., gpt, llama',
	});
	if (isCancel(searchTerm)) {
		return null;
	}

	let filteredModels = models;
	if (searchTerm) {
		filteredModels = models.filter((model: string) =>
			model.toLowerCase().includes((searchTerm as string).toLowerCase()),
		);
	}

	// Prepare filtered options
	let searchOptions = filteredModels.slice(0, 20).map((model: string) => ({
		label: model,
		value: model,
	}));

	const searchChoice = await select({
		message: `Choose your model (filtered by "${searchTerm}"):`,
		options: [
			...searchOptions,
			{ label: 'Custom model name...', value: 'custom' },
		],
	});

	if (isCancel(searchChoice)) return null;

	return searchChoice as string;
};

const handleCustom = async (text: any): Promise<string | null> => {
	const customModel = await text({
		message: 'Enter your custom model name:',
		validate: (value: string) => {
			if (!value) return 'Model name is required';
			return;
		},
	});

	if (isCancel(customModel)) return null;

	return customModel as string;
};

export const selectModel = async (
	baseUrl: string,
	apiKey: string,
	currentModel?: string,
	providerDef?: ProviderDef,
	providerName?: string,
): Promise<string | null> => {
	// Default to provider's default model if none set
	if (!currentModel || currentModel === 'undefined') {
		currentModel = providerDef?.defaultModels?.[0];
	}

	const s = spinner();
	s.start('Fetching available models...');
	const models = await fetchAndFilterModels(baseUrl, apiKey, providerDef);
	s.stop(`${providerName || 'Provider'}: ${models.length} models available`);

	let selectedModel: string | null = null;

	if (models.length > 0) {
		const { select, text, isCancel } = await import('@clack/prompts');

		let modelOptions = prepareModelOptions(models, currentModel, providerDef);

		// Limit to max 10 models to prevent UI breaking in terminals
		const maxModels = 10;
		if (modelOptions.length > maxModels) {
			modelOptions = modelOptions.slice(0, maxModels);
		}

		let modelChoice = await select({
			message: 'Choose your model:',
			options: [
				{ label: '🔍 Search models...', value: 'search' },
				...modelOptions,
				{ label: 'Custom model name...', value: 'custom' },
			],
			initialValue: modelOptions.length > 0 ? modelOptions[0].value : undefined,
		});

		if (isCancel(modelChoice)) return null;

		if (modelChoice === 'search') {
			const searchChoice = await handleSearch(models, select, text, isCancel);
			if (searchChoice === null) return null;
			modelChoice = searchChoice;
		}

		if (modelChoice === 'custom') {
			selectedModel = await handleCustom(text);
			if (selectedModel === null) return null;
		} else {
			selectedModel = modelChoice as string;
		}
	} else {
		// Fallback to manual input
		if (providerDef?.isLocal) {
			console.log(
				`No models found on ${providerName || 'local provider'}. Please download a model first, then run \`aicommits model\` to select it.`,
			);
			return null;
		}
		console.log(
			'Could not fetch available models. Please specify a model name manually.',
		);
		const { text, isCancel } = await import('@clack/prompts');
		try {
			const model = await text({
				message: 'Enter your model name:',
				validate: (value) => {
					if (!value) return 'Model name is required';
					return;
				},
			});
			if (isCancel(model)) return null;
			selectedModel = model as string;
		} catch {
			return null;
		}
	}

	return selectedModel;
};


================================================
FILE: src/feature/providers/base.ts
================================================
import { fetchModels } from '../models.js';
import type { ValidConfig } from '../../utils/config-types.js';

export type ProviderDef = {
	name: string;
	displayName: string;
	baseUrl: string;
	apiKeyFormat?: string;
	modelsFilter?: (models: any[]) => string[];
	defaultModels: string[];
	requiresApiKey: boolean;
	headers?: Record<string, string>;
	cacheModels?: boolean;
	isLocal?: boolean;
};

export class Provider {
	protected config: ValidConfig;
	protected def: ProviderDef;

	constructor(def: ProviderDef, config: ValidConfig) {
		this.def = def;
		this.config = config;
	}

	get name(): string {
		return this.def.name;
	}

	get displayName(): string {
		return this.def.displayName;
	}

	getDefinition(): ProviderDef {
		return this.def;
	}

	async setup(): Promise<[string, string][]> {
		const { text, password, isCancel } = await import('@clack/prompts');
		const updates: [string, string][] = [];

		if (this.def.requiresApiKey) {
			const currentKey = this.getApiKey();
			const apiKey = await password({
				message: currentKey
					? `Enter your API key (leave empty to keep current: ${currentKey.substring(
							0,
							4
					  )}****):`
					: 'Enter your API key:',
				validate: (value) => {
					if (!value && !currentKey) return 'API key is required';
					return;
				},
			});
			if (isCancel(apiKey)) {
				throw new Error('Setup cancelled');
			}
			if (apiKey) {
				updates.push(['OPENAI_API_KEY', apiKey as string]);
			}
		}

		if (this.name === 'ollama') {
			const currentEndpoint = this.getBaseUrl();
			const endpoint = await text({
				message: 'Enter Ollama endpoint (leave empty for default):',
				placeholder: currentEndpoint,
			});
			if (isCancel(endpoint)) {
				throw new Error('Setup cancelled');
			}
			if (endpoint && endpoint !== 'http://localhost:11434/v1') {
				updates.push(['OPENAI_BASE_URL', endpoint as string]);
			}
		}

		return updates;
	}

	async getModels(): Promise<{ models: string[]; error?: string }> {
		const baseUrl = this.getBaseUrl();
		const apiKey = this.getApiKey() || '';
		const result = await fetchModels({
			baseUrl,
			apiKey,
			cacheModels: this.def.cacheModels,
		});
		if (result.error) return { models: [], error: result.error };

		const modelsArray = Array.isArray(result.models) ? result.models : [];
		let models: string[];
		if (this.def.modelsFilter) {
			models = this.def.modelsFilter(modelsArray);
		} else {
			// Fallback: just use model ids/names
			models = modelsArray.map((model) => model.id || model.name).filter(Boolean) as string[];
		}

		return { models };
	}

	getApiKey(): string | undefined {
		return this.def.requiresApiKey ? this.config.OPENAI_API_KEY : undefined;
	}

	getBaseUrl(): string {
		if (this.name === 'custom') {
			return this.config.OPENAI_BASE_URL || '';
		}
		return this.def.baseUrl;
	}

	getDefaultModel(): string {
		return this.def.defaultModels[0] || '';
	}

	getHighlightedModels(): string[] {
		return this.def.defaultModels;
	}

	getHeaders(): Record<string, string> | undefined {
		return this.def.headers;
	}

	validateConfig(): { valid: boolean; errors: string[] } {
		const errors: string[] = [];
		if (this.def.requiresApiKey && !this.getApiKey()) {
			errors.push(`${this.displayName} API key is required`);
		}
		if (this.name === 'custom' && !this.getBaseUrl()) {
			errors.push('Custom endpoint is required');
		}
		return { valid: errors.length === 0, errors };
	}
}


================================================
FILE: src/feature/providers/groq.ts
================================================
import { ProviderDef } from './base.js';

export const GroqProvider: ProviderDef = {
	name: 'groq',
	displayName: 'Groq',
	baseUrl: 'https://api.groq.com/openai/v1',
	apiKeyFormat: 'gsk_',
	modelsFilter: (models) =>
		models
			.filter(
				(m: any) =>
					m.id && (!m.type || m.type === 'chat' || m.type === 'language'),
			)
			.map((m: any) => m.id),
	defaultModels: [
		'openai/gpt-oss-120b',
		'llama-3.1-8b-instant',
		'openai/gpt-oss-20b',
	],
	requiresApiKey: true,
};


================================================
FILE: src/feature/providers/index.ts
================================================
import { Provider, type ProviderDef } from './base.js';
import type { ValidConfig } from '../../utils/config-types.js';
import { providers } from './providers-data.js';

export { Provider } from './base.js';
export type { ProviderDef } from './base.js';
export { providers };

export function getProvider(config: ValidConfig): Provider | null {
	const providerName = config.provider;
	const pDef = providers.find((p) => p.name === providerName);
	return pDef ? new Provider(pDef, config) : null;
}

export function getAvailableProviders(): { value: string; label: string }[] {
	return providers.map((p) => ({
		value: p.name,
		label: p.displayName,
	}));
}

export function getProviderBaseUrl(providerName: string): string {
	const provider = providers.find((p) => p.name === providerName);
	return provider?.baseUrl || '';
}

export function getProviderDef(providerName: string): ProviderDef | undefined {
	return providers.find((p) => p.name === providerName);
}


================================================
FILE: src/feature/providers/lmstudio.ts
================================================
import { ProviderDef } from './base.js';

export const LMStudioProvider: ProviderDef = {
	name: 'lmstudio',
	displayName: 'LM Studio (local)',
	baseUrl: 'http://localhost:1234/v1',
	modelsFilter: (models) =>
		models
			.filter((m: any) => !m.type || m.type === 'chat' || m.type === 'language')
			.map((m: any) => m.id),
	defaultModels: ['qwen/qwen3-4b-2507', 'qwen/qwen3-8b'],
	requiresApiKey: false,
	cacheModels: false,
	isLocal: true,
};


================================================
FILE: src/feature/providers/ollama.ts
================================================
import { ProviderDef } from './base.js';

export const OllamaProvider: ProviderDef = {
	name: 'ollama',
	displayName: 'Ollama (local)',
	baseUrl: 'http://localhost:11434/v1',
	modelsFilter: (models) =>
		models.filter((m: any) => m.id || m.name).map((m: any) => m.id || m.name),
	defaultModels: ['qwen3.5:4b', 'llama3.2:latest'],
	requiresApiKey: false,
	cacheModels: false,
	isLocal: true,
};


================================================
FILE: src/feature/providers/openai.ts
================================================
import { ProviderDef } from './base.js';

export const OpenAiProvider: ProviderDef = {
	name: 'openai',
	displayName: 'OpenAI',
	baseUrl: 'https://api.openai.com/v1',
	apiKeyFormat: 'sk-',
	modelsFilter: (models) =>
		models
			.filter(
				(m: any) =>
					m.id &&
					(m.id.includes('gpt') ||
						m.id.includes('o1') ||
						m.id.includes('o3') ||
						m.id.includes('o4') ||
						m.id.includes('o5') ||
						!m.type ||
						m.type === 'chat')
			)
			.map((m: any) => m.id),
	defaultModels: ['gpt-5-mini', 'gpt-4o-mini', 'gpt-4o', 'gpt-5-nano'],
	requiresApiKey: true,
};


================================================
FILE: src/feature/providers/openaiCustom.ts
================================================
import { ProviderDef } from './base.js';

export const OpenAiCustom: ProviderDef = {
	name: 'custom',
	displayName: 'Custom (OpenAI-compatible)',
	baseUrl: '',
	modelsFilter: (models) =>
		models
			.filter((m: any) => !m.type || m.type === 'chat' || m.type === 'language')
			.map((m: any) => m.id),
	defaultModels: [],
	requiresApiKey: true,
};


================================================
FILE: src/feature/providers/openrouter.ts
================================================
import { ProviderDef } from './base.js';

export const OpenRouterProvider: ProviderDef = {
	name: 'openrouter',
	displayName: 'OpenRouter',
	baseUrl: 'https://openrouter.ai/api/v1',
	apiKeyFormat: 'sk-or-v1-',
	modelsFilter: (models) =>
		models
			.filter((m: any) => m.id && (!m.type || m.type === 'chat'))
			.map((m: any) => m.id),
	defaultModels: ['openai/gpt-oss-20b:free', 'z-ai/glm-4.5-air:free'],
	requiresApiKey: true,
	headers: {
		'HTTP-Referer': 'https://github.com/nutlope/aicommits',
		'X-Title': 'aicommits',
	},
};


================================================
FILE: src/feature/providers/providers-data.ts
================================================
import { TogetherProvider } from './together.js';
import { OpenAiProvider } from './openai.js';
import { OllamaProvider } from './ollama.js';
import { OpenAiCustom } from './openaiCustom.js';
import { OpenRouterProvider } from './openrouter.js';
import { LMStudioProvider } from './lmstudio.js';
import { GroqProvider } from './groq.js';
import { XAiProvider } from './xai.js';

export const providers = [
	TogetherProvider,
	OpenAiProvider,
	GroqProvider,
	XAiProvider,
	OllamaProvider,
	LMStudioProvider,
	OpenRouterProvider,
	OpenAiCustom,
];


================================================
FILE: src/feature/providers/together.ts
================================================
import { ProviderDef } from './base.js';

export const TogetherProvider: ProviderDef = {
	name: 'togetherai',
	displayName: 'Together AI (recommended)',
	baseUrl: 'https://api.together.xyz/v1',
	apiKeyFormat: 'tgp_',
	modelsFilter: (models) =>
		models
			.filter(
				(m: any) =>
					(!m.type || m.type === 'chat' || m.type === 'language') &&
					!m.id.toLowerCase().includes('vision'),
			)
			.map((m: any) => m.id),
	defaultModels: [
		'Qwen/Qwen3-Next-80B-A3B-Instruct',
		'zai-org/GLM-4.5-Air-FP8',
		'meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo',
	],
	requiresApiKey: true,
};


================================================
FILE: src/feature/providers/xai.ts
================================================
import { ProviderDef } from './base.js';

export const XAiProvider: ProviderDef = {
	name: 'xai',
	displayName: 'xAI',
	baseUrl: 'https://api.x.ai/v1',
	apiKeyFormat: 'xai-',
	modelsFilter: (models) =>
		models
			.filter(
				(m: any) =>
					m.id && (!m.type || m.type === 'chat' || m.type === 'language'),
			)
			.map((m: any) => m.id),
	defaultModels: ['grok-4.1-fast', 'grok-4-fast', 'grok-code-fast-1'],
	requiresApiKey: true,
};


================================================
FILE: src/utils/auto-update.ts
================================================
import { exec } from 'child_process';
import { promisify } from 'util';

const execAsync = promisify(exec);

export interface AutoUpdateOptions {
	pkg: { name: string; version: string };
	distTag?: string;
	headless?: boolean;
}

// Parse version string into comparable parts
// Supports: 1.2.3, 1.2.3-alpha, 1.2.3-alpha.1, 1.2.3-develop.14
function parseVersion(version: string): {
	major: number;
	minor: number;
	patch: number;
	prerelease: string | null;
	prereleaseNum: number;
} {
	// Remove 'v' prefix if present
	const cleanVersion = version.replace(/^v/, '');

	// Match: major.minor.patch[-prerelease.number]
	const match = cleanVersion.match(
		/^(\d+)\.(\d+)\.(\d+)(?:-([a-zA-Z]+)(?:\.(\d+))?)?$/
	);

	if (!match) {
		return { major: 0, minor: 0, patch: 0, prerelease: null, prereleaseNum: 0 };
	}

	const [, major, minor, patch, prerelease, prereleaseNum] = match;
	return {
		major: parseInt(major, 10),
		minor: parseInt(minor, 10),
		patch: parseInt(patch, 10),
		prerelease: prerelease || null,
		prereleaseNum: prereleaseNum ? parseInt(prereleaseNum, 10) : 0,
	};
}

// Compare two versions
// Returns: -1 if v1 < v2, 0 if equal, 1 if v1 > v2
function compareVersions(v1: string, v2: string): number {
	const p1 = parseVersion(v1);
	const p2 = parseVersion(v2);

	// Compare major.minor.patch
	if (p1.major !== p2.major) return p1.major > p2.major ? 1 : -1;
	if (p1.minor !== p2.minor) return p1.minor > p2.minor ? 1 : -1;
	if (p1.patch !== p2.patch) return p1.patch > p2.patch ? 1 : -1;

	// Handle prerelease versions
	// Stable > prerelease
	if (!p1.prerelease && p2.prerelease) return 1;
	if (p1.prerelease && !p2.prerelease) return -1;

	// Both are prereleases or both are stable
	if (!p1.prerelease && !p2.prerelease) return 0;

	// Compare prerelease numbers
	if (p1.prereleaseNum !== p2.prereleaseNum) {
		return p1.prereleaseNum > p2.prereleaseNum ? 1 : -1;
	}

	return 0;
}

// Fetch latest version from npm registry
async function fetchLatestVersion(
	packageName: string,
	distTag: string
): Promise<string | null> {
	try {
		const response = await fetch(
			`https://registry.npmjs.org/${packageName}/${distTag}`,
			{
				headers: {
					Accept: 'application/json',
				},
			}
		);

		if (!response.ok) {
			return null;
		}

		const data = await response.json();
		return data.version || null;
	} catch {
		return null;
	}
}

// Check if running as global installation
async function checkIfGlobalInstallation(packageName: string): Promise<boolean> {
	try {
		const { stdout } = await execAsync(`npm list -g ${packageName} --depth=0`);
		return stdout.includes(packageName);
	} catch {
		return false;
	}
}

// Run npm update in background
async function runBackgroundUpdate(
	packageName: string,
	distTag: string
): Promise<void> {
	return new Promise((resolve, reject) => {
		const child = exec(`npm install -g ${packageName}@${distTag}`, {
			timeout: 120000, // 2 minute timeout
			env: { ...process.env, NPM_CONFIG_PROGRESS: 'false' },
		});

		child.on('error', reject);

		child.on('exit', (code) => {
			if (code === 0 || code === null) {
				resolve();
			} else {
				reject(new Error(`npm install exited with code ${code}`));
			}
		});
	});
}

export async function checkAndAutoUpdate(
	options: AutoUpdateOptions
): Promise<void> {
	const { pkg, distTag = 'latest', headless = false } = options;

	if (headless) {
		return;
	}

	// Skip for development/semantic-release versions
	if (
		pkg.version === '0.0.0-semantic-release' ||
		pkg.version.includes('semantic-release')
	) {
		return;
	}

	// Determine correct dist tag based on current version
	const currentDistTag = pkg.version.includes('-') ? 'develop' : distTag;

	// Debug logging
	if (process.env.DEBUG || process.env.AICOMMITS_DEBUG) {
		console.log(`[auto-update] Current version: ${pkg.version}`);
		console.log(`[auto-update] Checking ${currentDistTag} tag...`);
	}

	// Fetch latest version from npm
	const latestVersion = await fetchLatestVersion(pkg.name, currentDistTag);

	if (!latestVersion) {
		if (process.env.DEBUG || process.env.AICOMMITS_DEBUG) {
			console.log('[auto-update] Could not fetch latest version');
		}
		return;
	}

	if (process.env.DEBUG || process.env.AICOMMITS_DEBUG) {
		console.log(`[auto-update] Latest version: ${latestVersion}`);
	}

	// Compare versions
	const comparison = compareVersions(pkg.version, latestVersion);

	if (comparison >= 0) {
		// Local version is same or newer
		if (process.env.DEBUG || process.env.AICOMMITS_DEBUG) {
			console.log('[auto-update] No update needed');
		}
		return;
	}

	// Update needed!
	console.log(`Updating aicommits from v${pkg.version} to v${latestVersion}...`);

	// Check if global installation
	const isGlobal = await checkIfGlobalInstallation(pkg.name);
	if (!isGlobal) {
		console.log(
			'Note: aicommits is installed locally. Auto-update skipped for local installations.'
		);
		return;
	}

	try {
		await runBackgroundUpdate(pkg.name, currentDistTag);
		console.log(`✓ aicommits updated to v${latestVersion}`);
		console.log('Please restart aic to use the new version.');
	} catch (error) {
		console.log('Auto-update failed. You can manually update with:');
		console.log(`  npm install -g aicommits@${currentDistTag}`);
	}
}


================================================
FILE: src/utils/clipboard.ts
================================================
import { execa } from 'execa';

/**
 * Copy text to the system clipboard using native CLI tools.
 * macOS: pbcopy
 * Windows: clip
 * Linux: wl-copy (Wayland), xclip or xsel (X11)
 */
export async function copyToClipboard(message: string): Promise<boolean> {
	try {
		if (process.platform === 'darwin') {
			// macOS - use pbcopy
			await execa('pbcopy', { input: message });
		} else if (process.platform === 'win32') {
			// Windows - use clip
			await execa('clip', { input: message });
		} else {
			/**
			 * Linux:
			 * Ignore stdout/stderr to prevent the CLI from hanging while
			 * Linux clipboard tools fork background processes to serve the content.
			 */
			const options = {
				input: message,
				stdio: ['pipe', 'ignore', 'ignore'] as const,
			};

			try {
				// Try Wayland (wl-copy)
				await execa('wl-copy', options);
			} catch {
				try {
					// Fallback to xclip (X11)
					await execa('xclip', ['-selection', 'clipboard'], options);
				} catch {
					// Fallback to xsel (X11)
					await execa('xsel', ['--clipboard', '--input'], options);
				}
			}
		}
		return true;
	} catch {
		return false;
	}
}


================================================
FILE: src/utils/commit-helpers.ts
================================================
import { KnownError } from './error.js';
import { isInteractive } from './headless.js';

export const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));

export const retry = async <T>(fn: () => Promise<T>, attempts: number = 3, delay: number = 1000): Promise<T> => {
	for (let i = 0; i < attempts; i++) {
		try {
			return await fn();
		} catch (error) {
			if (i === attempts - 1) throw error;
			await sleep(delay);
		}
	}
	throw new Error('Retry failed');
};

export const getCommitMessage = async (
	messages: string[],
	skipConfirm: boolean
): Promise<string | null> => {
	const { select, confirm, isCancel } = await import('@clack/prompts');
	const { dim } = await import('kolorist');

	// Single message case
	if (messages.length === 1) {
		const [message] = messages;

		if (skipConfirm) {
			return message;
		}

		if (!isInteractive()) {
			throw new KnownError('Interactive terminal required for commit message confirmation. Use --yes flag to skip confirmation.');
		}

		console.log(`\n\x1b[1m${message}\x1b[0m\n`);
		const confirmed = await confirm({
			message: 'Use this commit message?',
		});

		return confirmed && !isCancel(confirmed) ? message : null;
	}

	// Multiple messages case
	if (skipConfirm) {
		return messages[0];
	}

	if (!isInteractive()) {
		throw new KnownError('Interactive terminal required for commit message selection. Use --yes flag to skip selection and use the first message.');
	}

	const selected = await select({
		message: `Pick a commit message to use: ${dim('(Ctrl+c to exit)')}`,
		options: messages.map((value) => ({ label: value, value })),
	});

	return isCancel(selected) ? null : (selected as string);
};


================================================
FILE: src/utils/config-runtime.ts
================================================
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
import ini from 'ini';
import { fileExists } from './fs.js';
import { KnownError } from './error.js';
import {
	configParsers,
	hasOwn,
	type ValidConfig,
	type ConfigKeys,
	type RawConfig,
} from './config-types.js';
import { providers } from '../feature/providers/providers-data.js';

const getDefaultBaseUrl = (): string => {
	const openaiProvider = providers.find((p) => p.name === 'openai');
	return openaiProvider?.baseUrl || '';
};

const detectProvider = (
	baseUrl?: string,
	apiKey?: string
): string | undefined => {
	if (baseUrl) {
		const matchingProvider = providers.find(
			(p) =>
				p.baseUrl === baseUrl ||
				(p.name === 'ollama' && baseUrl.startsWith(p.baseUrl.slice(0, -3)))
		);
		if (matchingProvider) {
			return matchingProvider.name;
		} else {
			return 'custom';
		}
	} else if (apiKey) {
		return 'openai';
	}
};

const getConfigPath = () => path.join(os.homedir(), '.aicommits');

const readConfigFile = async (): Promise<RawConfig> => {
	const configExists = await fileExists(getConfigPath());
	if (!configExists) {
		return Object.create(null);
	}

	const configString = await fs.readFile(getConfigPath(), 'utf8');
	return ini.parse(configString);
};

export const getConfig = async (
	cliConfig?: RawConfig,
	envConfig?: RawConfig,
	suppressErrors?: boolean
): Promise<ValidConfig> => {
	const config = await readConfigFile();

	// Check for deprecated config properties
	if (hasOwn(config, 'proxy')) {
		console.warn('The "proxy" config property is deprecated and no longer supported');
	}

	const parsedConfig: Record<string, unknown> = {};
	const effectiveEnvConfig = envConfig ?? {};

	for (const key of Object.keys(configParsers) as ConfigKeys[]) {
		const parser = configParsers[key];
		const value = cliConfig?.[key] ?? effectiveEnvConfig?.[key] ?? config[key];

		if (suppressErrors) {
			try {
				parsedConfig[key] = parser(value);
			} catch {}
		} else {
			parsedConfig[key] = parser(value);
		}
	}

	// Detect provider from OPENAI_BASE_URL or default to OpenAI if only API key is set
	let provider: string | undefined;
	let baseUrl = parsedConfig.OPENAI_BASE_URL as string | undefined;
	const apiKey = parsedConfig.OPENAI_API_KEY as string | undefined;

	// If only API key is provided without base URL, default to OpenAI
	if (!baseUrl && apiKey) {
		baseUrl = getDefaultBaseUrl();
		parsedConfig.OPENAI_BASE_URL = baseUrl;
	}

	provider = detectProvider(baseUrl, apiKey);

	return { ...parsedConfig, model: parsedConfig.OPENAI_MODEL, provider } as ValidConfig;
};

export const setConfigs = async (keyValues: [key: string, value: string][]) => {
	const config = await readConfigFile();

	for (const [key, value] of keyValues) {
		if (!hasOwn(configParsers, key)) {
			throw new KnownError(`Invalid config property: ${key}`);
		}

		if (value === '') {
			delete config[key as ConfigKeys];
		} else {
			const parsed = configParsers[key as ConfigKeys](value);
			config[key as ConfigKeys] = parsed as any;
		}
	}

	await fs.writeFile(getConfigPath(), ini.stringify(config), 'utf8');
};


================================================
FILE: src/utils/config-types.ts
================================================
import { KnownError } from './error.js';

const commitTypes = ['plain', 'conventional', 'gitmoji', 'subject+body'] as const;

export type CommitType = (typeof commitTypes)[number];

const { hasOwnProperty } = Object.prototype;
export const hasOwn = (object: unknown, key: PropertyKey) =>
	hasOwnProperty.call(object, key);

const parseAssert = (name: string, condition: boolean, message: string) => {
	if (!condition) {
		throw new KnownError(`Invalid config property ${name}: ${message}`);
	}
};

const configParsers = {
	OPENAI_API_KEY(key?: string) {
		return key;
	},
	OPENAI_BASE_URL(key?: string) {
		return key;
	},
	OPENAI_MODEL(key?: string) {
		return key || '';
	},
	locale(locale?: string) {
		if (!locale) {
			return 'en';
		}
		parseAssert('locale', !!locale, 'Cannot be empty');
		parseAssert(
			'locale',
			/^[a-z-]+$/i.test(locale),
			'Must be a valid locale (letters and dashes/underscores).'
		);
		return locale;
	},
	generate(count?: string) {
		if (!count) {
			return 1;
		}
		parseAssert('generate', /^\d+$/.test(count), 'Must be an integer');
		const parsed = Number(count);
		parseAssert('generate', parsed > 0, 'Must be greater than 0');
		parseAssert('generate', parsed <= 5, 'Must be less or equal to 5');
		return parsed;
	},
	type(type?: string) {
		if (!type) {
			return 'plain';
		}
		parseAssert(
			'type',
			commitTypes.includes(type as CommitType),
			'Invalid commit type'
		);
		return type as CommitType;
	},
	proxy(url?: string) {
		if (!url || url.length === 0) {
			return undefined;
		}
		throw new KnownError(
			'The "proxy" config property is deprecated and no longer supported.'
		);
	},
	timeout(timeout?: string) {
		if (!timeout) {
			return undefined;
		}

		parseAssert('timeout', /^\d+$/.test(timeout), 'Must be an integer');

		const parsed = Number(timeout);
		parseAssert('timeout', parsed >= 500, 'Must be greater than 500ms');

		return parsed;
	},
	'max-length'(maxLength?: string) {
		if (!maxLength) {
			return 72;
		}
		parseAssert('max-length', /^\d+$/.test(maxLength), 'Must be an integer');
		const parsed = Number(maxLength);
		parseAssert(
			'max-length',
			parsed >= 20,
			'Must be greater than 20 characters'
		);
		return parsed;
	},
} as const;

type ConfigKeys = keyof typeof configParsers;

type RawConfig = {
	[key in ConfigKeys]?: string;
};

export type ValidConfig = {
	[Key in ConfigKeys]: ReturnType<(typeof configParsers)[Key]>;
} & {
	OPENAI_API_KEY: string | undefined;
	OPENAI_BASE_URL: string | undefined;
	OPENAI_MODEL: string;
	model: string;
	provider: string | undefined;
	timeout: number | undefined;
};

export { configParsers, type ConfigKeys, type RawConfig };


================================================
FILE: src/utils/constants.ts
================================================
// Label formatters
export const CURRENT_LABEL_FORMAT = (model: string) => `✅ ${model} (current)`;
export const PREFERRED_LABEL_FORMAT = (model: string) =>
	`[${model} - suggested]`;


================================================
FILE: src/utils/error.ts
================================================
import { dim, red } from 'kolorist';
import pkg from '../../package.json';
const { version } = pkg;

export class KnownError extends Error {}

const indent = '    ';

export const handleCliError = (error: unknown) => {
	if (error instanceof Error && !(error instanceof KnownError)) {
		if (error.stack) {
			console.error(dim(error.stack.split('\n').slice(1).join('\n')));
		}
		console.error(`\n${indent}${dim(`aicommits v${version}`)}`);
		console.error(
			`\n${indent}Please open a Bug report with the information above:`
		);
		console.error(
			`${indent}https://github.com/Nutlope/aicommits/issues/new/choose`
		);
	}
};

export const handleCommandError = (error: unknown) => {
	process.stderr.write(`${red('✖')} ${(error as Error).message}\n`);
	handleCliError(error);
	process.exit(1);
};


================================================
FILE: src/utils/fs.ts
================================================
import fs from 'fs/promises';

// lstat is used because this is also used to check if a symlink file exists
export const fileExists = (filePath: string) =>
	fs.lstat(filePath).then(
		() => true,
		() => false
	);


================================================
FILE: src/utils/git.ts
================================================
import { execa } from 'execa';
import { KnownError } from './error.js';

export const assertGitRepo = async () => {
	const { stdout, failed } = await execa(
		'git',
		['rev-parse', '--show-toplevel'],
		{ reject: false }
	);

	if (failed) {
		throw new KnownError('The current directory must be a Git repository!');
	}

	return stdout;
};

const excludeFromDiff = (path: string) => `:(exclude)${path}`;

const lockFilePatterns = [
	'package-lock.json',
	'pnpm-lock.yaml',
	// yarn.lock, Cargo.lock, Gemfile.lock, Pipfile.lock, etc.
	'*.lock',
];

const isLockFile = (file: string) => {
	return lockFilePatterns.some(pattern => {
		if (pattern.includes('*')) {
			// Simple glob match for *.lock
			return file.endsWith('.lock');
		}
		// Match lock files by basename to handle subdirectories
		return file.endsWith('/' + pattern) || file === pattern;
	});
};

const filesToExclude = lockFilePatterns.map(excludeFromDiff);

export const getStagedDiff = async (excludeFiles?: string[]) => {
	const diffCached = ['diff', '--cached', '--diff-algorithm=minimal'];

	// First, get all staged files without any excludes
	const { stdout: allFilesOutput } = await execa('git', [
		...diffCached,
		'--name-only',
		...(excludeFiles ? excludeFiles.map(excludeFromDiff) : []),
	]);

	if (!allFilesOutput) {
		return;
	}

	const allFiles = allFilesOutput.split('\n').filter(Boolean);

	// Check if all staged files are lock files
	const hasNonLockFiles = allFiles.some(file => !isLockFile(file));

	let excludes: string[] = [];
	if (hasNonLockFiles) {
		// If there are non-lock files, exclude lock files
		excludes = [...filesToExclude];
	}
	// If only lock files are staged, don't exclude them

	excludes = [
		...excludes,
		...(excludeFiles ? excludeFiles.map(excludeFromDiff) : []),
	];

	// Get files after applying excludes
	const { stdout: files } = await execa('git', [
		...diffCached,
		'--name-only',
		...excludes,
	]);

	if (!files) {
		return;
	}

	const { stdout: diff } = await execa('git', [
		...diffCached,
		...excludes,
	]);

	return {
		files: files.split('\n'),
		diff,
	};
};

export const getStagedDiffForFiles = async (files: string[], excludeFiles?: string[]) => {
	const diffCached = ['diff', '--cached', '--diff-algorithm=minimal'];
	const excludes = [
		...filesToExclude,
		...(excludeFiles ? excludeFiles.map(excludeFromDiff) : []),
	];

	const { stdout: diff } = await execa('git', [
		...diffCached,
		'--',
		...files,
		...excludes,
	]);

	return {
		files,
		diff,
	};
};

export const getDetectedMessage = (files: string[]) =>
	`Detected ${files.length.toLocaleString()} staged file${
		files.length > 1 ? 's' : ''
	}`;


================================================
FILE: src/utils/headless.ts
================================================
export const isHeadless = () => !process.stdin.isTTY || !process.stdout.isTTY;

export const isInteractive = () =>
	Boolean(process.stdin.isTTY && process.stdout.isTTY && !process.env.CI);


================================================
FILE: src/utils/openai.ts
================================================
import { generateText } from 'ai';
import { createOpenAI } from '@ai-sdk/openai';
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
import { KnownError } from './error.js';
import type { CommitType } from './config-types.js';
import { generatePrompt, generateDescriptionPrompt } from './prompt.js';
import { isHeadless } from './headless.js';

const shouldLogDebug = () =>
	Boolean(process.env.DEBUG || process.env.AICOMMITS_DEBUG) && !isHeadless();

/**
 * Extracts the actual response from reasoning model outputs.
 * Reasoning models (like DeepSeek R1, QwQ, etc.) include their thought process
 * in <think>...</think> tags. We need to extract the content after these tags.
 */
const extractResponseFromReasoning = (message: string): string => {
	// Pattern to match <think>...</think> tags and everything before the actual response
	// This handles both single-line and multi-line think blocks
	const thinkPattern = /<think>[\s\S]*?<\/think>/gi;

	// Remove all <think>...</think> blocks and any content before the first think block
	let cleaned = message.replace(thinkPattern, '');

	// Remove any leading/trailing whitespace and newlines
	cleaned = cleaned.trim();

	return cleaned;
};

const sanitizeMessage = (message: string) => {
	// First, extract response from reasoning models if present
	let processed = extractResponseFromReasoning(message);

	// Then apply existing sanitization
 	const sanitized = processed
 		.trim()
 		.split('\n')[0] // Take only the first line
 		.replace(/(\w)\.$/, '$1')
 		.replace(/^["'`]|["'`]$/g, '') // Remove surrounding quotes
 		.replace(/^<[^>]*>\s*/, ''); // Remove leading tags

 	return sanitized;
};

/** Sanitize description/body (multi-line): strip reasoning blocks, trim, remove surrounding quotes. */
const sanitizeDescription = (message: string) => {
	let processed = extractResponseFromReasoning(message);
	return processed
		.trim()
		.replace(/^["'`]|["'`]$/g, '')
		.replace(/^<[^>]*>\s*/, '');
};

const deduplicateMessages = (array: string[]) => Array.from(new Set(array));

const shortenCommitMessage = async (
	provider: any,
	model: string,
	message: string,
	maxLength: number,
	timeout: number
) => {
	const abortController = new AbortController();
	const timeoutId = setTimeout(() => abortController.abort(), timeout);

	try {
		const result = await generateText({
			model: provider(model),
			system: `You are a tool that shortens git commit messages. Given a commit message, make it shorter while preserving the key information and format. The shortened message must be ${maxLength} characters or less. Respond with ONLY the shortened commit message.`,
			prompt: message,
			temperature: 0.2,
			maxRetries: 2,
			maxOutputTokens: 500,
			abortSignal: abortController.signal,
		});
		clearTimeout(timeoutId);
		return sanitizeMessage(result.text);
	} catch (error) {
		clearTimeout(timeoutId);
		throw error;
	}
};

export type GenerateCommitMessageOptions = {
	baseUrl: string;
	apiKey: string;
	model: string;
	locale: string;
	diff: string;
	completions: number;
	maxLength: number;
	type: CommitType;
	timeout: number;
	customPrompt?: string;
	headers?: Record<string, string>;
};

export const generateCommitMessage = async ({
	baseUrl,
	apiKey,
	model,
	locale,
	diff,
	completions,
	maxLength,
	type,
	timeout,
	customPrompt,
	headers,
}: GenerateCommitMessageOptions) => {
	if (shouldLogDebug()) {
		console.log('Diff being sent to AI:');
		console.log(diff);
	}

	try {
		const provider =
			baseUrl === 'https://api.openai.com/v1'
				? createOpenAI({ apiKey })
				: createOpenAICompatible({
						name: 'custom',
						apiKey,
						baseURL: baseUrl,
						headers,
				  });

		const abortController = new AbortController();
		const timeoutId = setTimeout(() => abortController.abort(), timeout);

		const promises = Array.from({ length: completions }, () =>
			generateText({
				model: provider(model),
				system: generatePrompt(locale, maxLength, type, customPrompt),
				prompt: diff,
				temperature: 0.4,
				maxRetries: 2,
				maxOutputTokens: 2000,
				abortSignal: abortController.signal,
			})
		);
		const results = await (async () => {
			try {
				return await Promise.all(promises);
			} finally {
				clearTimeout(timeoutId);
			}
		})();
		let texts = results.map((r) => r.text);
		let messages = deduplicateMessages(
			texts.map((text: string) => sanitizeMessage(text))
		);

		// Shorten messages that exceed maxLength
		const MAX_SHORTEN_RETRIES = 3;
		for (let retry = 0; retry < MAX_SHORTEN_RETRIES; retry++) {
			let needsShortening = false;
			const shortenedMessages = await Promise.all(
				messages.map(async (msg) => {
					if (msg.length <= maxLength) {
						return msg;
					}
					needsShortening = true;
					try {
						return await shortenCommitMessage(provider, model, msg, maxLength, timeout);
					} catch (error) {
						// If shortening fails, keep the original and continue
						return msg;
					}
				})
			);
			messages = deduplicateMessages(shortenedMessages);
			if (!needsShortening) break;
		}

		const usage = {
			prompt_tokens: results.reduce(
				(sum, r) => sum + ((r.usage as any).promptTokens || 0),
				0
			),
			completion_tokens: results.reduce(
				(sum, r) => sum + ((r.usage as any).completionTokens || 0),
				0
			),
			total_tokens: results.reduce(
				(sum, r) => sum + ((r.usage as any).totalTokens || 0),
				0
			),
		};
		return { messages, usage };
	} catch (error) {
		const errorAsAny = error as any;

		// Handle AbortController timeout
		if (
			errorAsAny.name === 'AbortError' ||
			errorAsAny.message?.includes('aborted') ||
			errorAsAny.message?.includes('This operation was aborted')
		) {
			throw new KnownError(
				`Request timed out after ${timeout / 1000} seconds. The API took too long to respond. Try again or use a different model.`
			);
		}

		if (errorAsAny.code === 'ENOTFOUND') {
			throw new KnownError(
				`Error connecting to ${errorAsAny.hostname} (${errorAsAny.syscall}). Are you connected to the internet?`
			);
		}

		if (errorAsAny.status === 429) {
			const resetHeader = errorAsAny.headers?.get('x-ratelimit-reset');
			let rateLimitMessage = 'Rate limit exceeded';
			if (resetHeader) {
				const resetTime = parseInt(resetHeader);
				const now = Date.now();
				const waitMs = resetTime - now;
				const waitSec = Math.ceil(waitMs / 1000);
				if (waitSec > 0) {
					let timeStr: string;
					if (waitSec < 60) {
						timeStr = `${waitSec} second${waitSec === 1 ? '' : 's'}`;
					} else if (waitSec < 3600) {
						const minutes = Math.ceil(waitSec / 60);
						timeStr = `${minutes} minute${minutes === 1 ? '' : 's'}`;
					} else {
						const hours = Math.ceil(waitSec / 3600);
						timeStr = `${hours} hour${hours === 1 ? '' : 's'}`;
					}
					rateLimitMessage += `. Retry in ${timeStr}.`;
				}
			}
			throw new KnownError(rateLimitMessage);
		}

		throw errorAsAny;
	}
};

export type GenerateCommitDescriptionOptions = {
	baseUrl: string;
	apiKey: string;
	model: string;
	locale: string;
	title: string;
	diff: string;
	timeout: number;
	maxLength: number;
	customPrompt?: string;
	headers?: Record<string, string>;
};

/**
 * Wrap a single line at maxLength by breaking on spaces.
 * Lines that start with "- " or "* " get continuation lines indented with 2 spaces for alignment.
 */
const wrapLine = (line: string, maxLength: number): string => {
	const bulletMatch = /^([-*]\s)/.exec(line);
	const indent = bulletMatch ? '  ' : '';
	const continuationMax = maxLength - indent.length;

	if (line.length <= maxLength) return line;

	const parts: string[] = [];
	let rest = line;
	let isFirst = true;

	while (rest.length > (isFirst ? maxLength : continuationMax)) {
		const maxThisLine = isFirst ? maxLength : continuationMax;
		const chunk = rest.slice(0, maxThisLine);
		const lastSpace = chunk.lastIndexOf(' ');
		const splitAt = lastSpace > 0 ? lastSpace + 1 : maxThisLine;
		const segment = rest.slice(0, splitAt).trim();
		parts.push(isFirst ? segment : indent + segment);
		rest = rest.slice(splitAt).trim();
		isFirst = false;
	}
	if (rest.length > 0) {
		parts.push(isFirst ? rest : indent + rest);
	}
	return parts.join('\n');
};

export const generateCommitDescription = async ({
	baseUrl,
	apiKey,
	model,
	locale,
	title,
	diff,
	timeout,
	maxLength,
	customPrompt,
	headers,
}: GenerateCommitDescriptionOptions) => {
	if (shouldLogDebug()) {
		console.log('Title and diff for description:');
		console.log({ title, diffLength: diff.length });
	}

	const provider =
		baseUrl === 'https://api.openai.com/v1'
			? createOpenAI({ apiKey })
			: createOpenAICompatible({
					name: 'custom',
					apiKey,
					baseURL: baseUrl,
					headers,
			  });

	const abortController = new AbortController();
	const timeoutId = setTimeout(() => abortController.abort(), timeout);

	try {
		const result = await generateText({
			model: provider(model),
			system: generateDescriptionPrompt(locale, maxLength, customPrompt),
			prompt: `Commit message title:\n${title}\n\nCode diff:\n${diff}`,
			temperature: 0.4,
			maxRetries: 2,
			maxOutputTokens: 2000,
			abortSignal: abortController.signal,
		});
		clearTimeout(timeoutId);
		let description = sanitizeDescription(result.text);
		// Enforce line length: wrap any line exceeding maxLength
		description = description
			.split('\n')
			.map((line) => wrapLine(line, maxLength))
			.join('\n');
		return { description, usage: result.usage };
	} catch (error) {
		clearTimeout(timeoutId);
		const errorAsAny = error as any;
		if (
			errorAsAny.name === 'AbortError' ||
			errorAsAny.message?.includes('aborted') ||
			errorAsAny.message?.includes('This operation was aborted')
		) {
			throw new KnownError(
				`Request timed out after ${timeout / 1000} seconds. The API took too long to respond. Try again or use a different model.`
			);
		}
		if (errorAsAny.code === 'ENOTFOUND') {
			throw new KnownError(
				`Error connecting to ${errorAsAny.hostname} (${errorAsAny.syscall}). Are you connected to the internet?`
			);
		}
		throw errorAsAny;
	}
};

export type CombineCommitMessagesOptions = {
	messages: string[];
	baseUrl: string;
	apiKey: string;
	model: string;
	locale: string;
	maxLength: number;
	type: CommitType;
	timeout: number;
	customPrompt?: string;
	headers?: Record<string, string>;
};

export const combineCommitMessages = async ({
	messages,
	baseUrl,
	apiKey,
	model,
	locale,
	maxLength,
	type,
	timeout,
	customPrompt,
	headers,
}: CombineCommitMessagesOptions) => {
	try {
		const provider =
			baseUrl === 'https://api.openai.com/v1'
				? createOpenAI({ apiKey })
				: createOpenAICompatible({
						name: 'custom',
						apiKey,
						baseURL: baseUrl,
						headers,
				  });

		const abortController = new AbortController();
		const timeoutId = setTimeout(() => abortController.abort(), timeout);

		const system = `You are a tool that generates git commit messages. Your task is to combine multiple commit messages into one.

Input: Several commit messages separated by newlines.
Output: A single commit message starting with type like 'feat:' or 'fix:'.

Do not add thanks, explanations, or any text outside the commit message.`;

		const result = await generateText({
			model: provider(model),
			system,
			prompt: messages.join('\n'),
			temperature: 0.4,
			maxRetries: 2,
			maxOutputTokens: 2000,
			abortSignal: abortController.signal,
		});

		clearTimeout(timeoutId);

		let combinedMessage = sanitizeMessage(result.text);

		// Shorten if too long
		if (combinedMessage.length > maxLength) {
			try {
				combinedMessage = await shortenCommitMessage(provider, model, combinedMessage, maxLength, timeout);
			} catch (error) {
				// If shortening fails, keep the original
			}
		}

		return { messages: [combinedMessage], usage: result.usage };
	} catch (error) {
		const errorAsAny = error as any;

		// Handle AbortController timeout
		if (
			errorAsAny.name === 'AbortError' ||
			errorAsAny.message?.includes('aborted') ||
			errorAsAny.message?.includes('This operation was aborted')
		) {
			throw new KnownError(
				`Request timed out after ${timeout / 1000} seconds. The API took too long to respond. Try again or use a different model.`
			);
		}

		throw errorAsAny;
	}
};


================================================
FILE: src/utils/prompt.ts
================================================
import type { CommitType } from './config-types.js';

export const commitTypeFormats: Record<CommitType, string> = {
	plain: '<commit message>',
	conventional: '<type>[optional (<scope>)]: <commit message>\nThe commit message subject must start with a lowercase letter',
	gitmoji: ':emoji: <commit message>',
	'subject+body': '<commit message subject>',
};
const specifyCommitFormat = (type: CommitType) =>
	`The output response must be in format:\n${commitTypeFormats[type]}`;

const commitTypes: Record<CommitType, string> = {
	plain: '',

	/**
	 * References:
	 * Commitlint:
	 * https://github.com/conventional-changelog/commitlint/blob/18fbed7ea86ac0ec9d5449b4979b762ec4305a92/%40commitlint/config-conventional/index.js#L40-L100
	 *
	 * Conventional Changelog:
	 * https://github.com/conventional-changelog/conventional-changelog/blob/d0e5d5926c8addba74bc962553dd8bcfba90e228/packages/conventional-changelog-conventionalcommits/writer-opts.js#L182-L193
	 */
	conventional: `Choose a type from the type-to-description JSON below that best describes the git diff. IMPORTANT: The type MUST be lowercase (e.g., "feat", not "Feat" or "FEAT"):\n${JSON.stringify(
		{
			docs: 'Documentation only changes',
			style:
				'Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc)',
			refactor: 'A code change that improves code structure without changing functionality (renaming, restructuring classes/methods, extracting functions, etc)',
			perf: 'A code change that improves performance',
			test: 'Adding missing tests or correcting existing tests',
			build: 'Changes that affect the build system or external dependencies',
			ci: 'Changes to our CI configuration files and scripts',
			chore: "Other changes that don't modify src or test files",
			revert: 'Reverts a previous commit',
			feat: 'A new feature',
			fix: 'A bug fix',
		},
		null,
		2
	)}`,

	/**
	 * References:
	 * Gitmoji: https://gitmoji.dev/
	 */
	gitmoji: `Choose an emoji from the emoji-to-description JSON below that best describes the git diff:\n${JSON.stringify(
		{
			'🎨': 'Improve structure / format of the code',
			'⚡': 'Improve performance',
			'🔥': 'Remove code or files',
			'🐛': 'Fix a bug',
			'🚑': 'Critical hotfix',
			'✨': 'Introduce new features',
			'📝': 'Add or update documentation',
			'🚀': 'Deploy stuff',
			'💄': 'Add or update the UI and style files',
			'🎉': 'Begin a project',
			'✅': 'Add, update, or pass tests',
			'🔒': 'Fix security or privacy issues',
			'🔐': 'Add or update secrets',
			'🔖': 'Release / Version tags',
			'🚨': 'Fix compiler / linter warnings',
			'🚧': 'Work in progress',
			'💚': 'Fix CI Build',
			'⬇️': 'Downgrade dependencies',
			'⬆️': 'Upgrade dependencies',
			'📌': 'Pin dependencies to specific versions',
			'👷': 'Add or update CI build system',
			'📈': 'Add or update analytics or track code',
			'♻️': 'Refactor code',
			'➕': 'Add a dependency',
			'➖': 'Remove a dependency',
			'🔧': 'Add or update configuration files',
			'🔨': 'Add or update development scripts',
			'🌐': 'Internationalization and localization',
			'✏️': 'Fix typos',
			'💩': 'Write bad code that needs to be improved',
			'⏪': 'Revert changes',
			'🔀': 'Merge branches',
			'📦': 'Add or update compiled files or packages',
			'👽': 'Update code due to external API changes',
			'🚚': 'Move or rename resources (e.g.: files, paths, routes)',
			'📄': 'Add or update license',
			'💥': 'Introduce breaking changes',
			'🍱': 'Add or update assets',
			'♿': 'Improve accessibility',
			'💡': 'Add or update comments in source code',
			'🍻': 'Write code drunkenly',
			'💬': 'Add or update text and literals',
			'🗃': 'Perform database related changes',
			'🔊': 'Add or update logs',
			'🔇': 'Remove logs',
			'👥': 'Add or update contributor(s)',
			'🚸': 'Improve user experience / usability',
			'🏗': 'Make architectural changes',
			'📱': 'Work on responsive design',
			'🤡': 'Mock things',
			'🥚': 'Add or update an easter egg',
			'🙈': 'Add or update a .gitignore file',
			'📸': 'Add or update snapshots',
			'⚗': 'Perform experiments',
			'🔍': 'Improve SEO',
			'🏷': 'Add or update types',
			'🌱': 'Add or update seed files',
			'🚩': 'Add, update, or remove feature flags',
			'🥅': 'Catch errors',
			'💫': 'Add or update animations and transitions',
			'🗑': 'Deprecate code that needs to be cleaned up',
			'🛂': 'Work on code related to authorization, roles and permissions',
			'🩹': 'Simple fix for a non-critical issue',
			'🧐': 'Data exploration/inspection',
			'⚰': 'Remove dead code',
			'🧪': 'Add a failing test',
			'👔': 'Add or update business logic',
			'🩺': 'Add or update healthcheck',
			'🧱': 'Infrastructure related changes',
			'🧑‍💻': 'Improve developer experience',
			'💸': 'Add sponsorships or money related infrastructure',
			'🧵': 'Add or update code related to multithreading or concurrency',
			'🦺': 'Add or update code related to validation',
		},
		null,
		2
	)}`,
	'subject+body': 'Output only the subject line; the body is generated separately.',
};

export const generatePrompt = (
	locale: string,
	maxLength: number,
	type: CommitType,
	customPrompt?: string
) =>
	[
		'Generate a concise git commit message title in present tense that precisely describes the key changes in the following code diff. Focus on what was changed, not just file names. Provide only the title, no description or body.',
		`Message language: ${locale}`,
		`Commit message must be a maximum of ${maxLength} characters.`,
		'Exclude anything unnecessary such as translation. Your entire response will be passed directly into git commit.',
		`IMPORTANT: Do not include any explanations, introductions, or additional text. Do not wrap the commit message in quotes or any other formatting. The commit message must not exceed ${maxLength} characters. Respond with ONLY the commit message text.`,
		'Be specific: include concrete details (package names, versions, functionality) rather than generic statements.',
		customPrompt,
		commitTypes[type],
		specifyCommitFormat(type),
	]
		.filter(Boolean)
		.join('\n');

/**
 * Prompt for generating a commit message body/description given a title and diff.
 * Used when the user has (or generated) a title and wants a detailed description.
 */
export const generateDescriptionPrompt = (
	locale: string,
	maxLength: number,
	customPrompt?: string
) =>
	[
		'You are generating the short body (description) of a git commit message. You are given the commit title and the code diff.',
		'Output must be brief: use 3–6 bullet points (one short line each), or 2–4 short sentences. No long paragraphs. Focus on what changed and why, in present tense.',
		`Git convention: each line at most ${maxLength} characters. When a bullet line wraps, indent the continuation with 2 spaces so it aligns under the bullet text.`,
		'Do not repeat the title. No meta-commentary (e.g. "This commit..."). Respond with ONLY the commit body.',
		`Message language: ${locale}`,
		customPrompt,
	]
		.filter(Boolean)
		.join('\n');


================================================
FILE: tests/fixtures/README.md
================================================
# Generating diffs

1. Instruct ChatGPT with the following command:
```
I want you to act as a git cli
I will give you the type of content and you will generate a random git diff based on that
```

2. Insert the type of change

ChatGPT will generate a fictional git diff based on the type of change you inserted.


================================================
FILE: tests/fixtures/chore.diff
================================================
diff --git a/package.json b/package.json
index 2a7398e..6b2a3f0 100644
--- a/package.json
+++ b/package.json
@@ -3,7 +3,7 @@
   "version": "1.0.0",
   "description": "A sample project",
   "main": "index.js",
-  "scripts": {
+  "scripts": {
     "start": "node index.js",
     "test": "mocha",
-    "lint": "eslint ."
+    "lint": "eslint .",
+    "clean": "rm -rf node_modules && npm install"
   },
   "dependencies": {
     "express": "^4.17.1",


================================================
FILE: tests/fixtures/code-refactoring.diff
================================================
diff --git a/old_example.ts b/new_example.ts
index 1234567..abcdefg 100644
--- a/old_example.ts
+++ b/new_example.ts

@@ -1,15 +1,16 @@
-import { Component, OnInit } from '@angular/core';
+import { Component } from '@angular/core';

-@Component({
-  selector: 'app-example',
-  templateUrl: './example.component.html',
-  styleUrls: ['./example.component.css']
-})
-export class ExampleComponent implements OnInit {
-  message: string;
+@Component({
+  selector: 'app-improved-example',
+  templateUrl: './improved-example.component.html',
+  styleUrls: ['./improved-example.component.css']
+})
+export class ImprovedExampleComponent {
+  private _message: string;

-  ngOnInit() {
-    this.message = 'Hello, world!';
+  constructor() {
+    this._message = 'Hello, world!';
   }

+  get message(): string {
+    return this._message;
+  }
 }


================================================
FILE: tests/fixtures/code-style.diff
================================================
diff --git a/src/app.js b/src/app.js
index 8741c37..91b2e74 100644
--- a/src/app.js
+++ b/src/app.js
@@ -10,12 +10,12 @@ app.use(express.json());
 // Routes
 app.get('/', (req, res) => {
-  res.send('Welcome to the API!');
+    res.send('Welcome to the API!');
 });

 app.post('/users', (req, res) => {
-  const user = createUser(req.body);
-  res.status(201).send(user);
+    const user = createUser(req.body);
+    res.status(201).send(user);
 });

 app.get('/users/:id', (req, res) => {
@@ -27,7 +27,7 @@ app.get('/users/:id', (req, res) => {
     if (user) {
         res.send(user);
     } else {
-      res.status(404).send('User not found');
+        res.status(404).send('User not found');
     }
 });



================================================
FILE: tests/fixtures/continous-integration.diff
================================================
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..b6e5789
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,16 @@
+name: Continuous Integration
+
+on:
+  push:
+    branches:
+      - main
+  pull_request:
+    branches:
+      - main
+
+jobs:
+  build-and-test:
+    runs-on: ubuntu-latest
+    steps:
+    - name: Checkout repository
+      uses: actions/checkout@v2
+    - name: Set up Node.js
+      uses: actions/setup-node@v2
+      with:
+        node-version: '16'
+    - name: Install dependencies
+      run: npm ci
+    - name: Run tests
+      run: npm test


================================================
FILE: tests/fixtures/deprecate-feature.diff
================================================
diff --git a/old_feature.py b/old_feature.py
index 1234567..abcdefg 100644
--- a/old_feature.py
+++ b/old_feature.py
@@ -1,7 +1,9 @@
 import warnings


 class OldFeature:
+    def __init__(self):
+        warnings.warn("OldFeature is deprecated and will be removed in the next release. Please use NewFeature instead.", DeprecationWarning)

     def do_something(self):
         print("Doing something with the old feature...")
diff --git a/new_feature.py b/new_feature.py
new file mode 100644
index 0000000..1111111
--- /dev/null
+++ b/new_feature.py
@@ -0,0 +1,7 @@
+class NewFeature:
+    def __init__(self):
+        print("Initializing the new feature...")
+
+    def do_something(self):
+        print("Doing something with the new feature...")
+


================================================
FILE: tests/fixtures/documentation-changes.diff
================================================
diff --git a/README.md b/README.md
index a0c3e1b..9d1b6f8 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,11 @@
 # My Awesome Project

+## Overview
+
+My Awesome Project is a web application that allows users to manage their tasks and projects in a simple and intuitive way. The project is built with React and Node.js and uses MongoDB for data storage.
+
 ## Installation

+To install and run My Awesome Project, follow these steps:
+
 1. Clone the repository: `git clone https://github.com/username/my-awesome-project.git`
 2. Install dependencies: `npm install`
 3. Start the development server: `npm start`
@@ -13,6 +18,11 @@ To install and run My Awesome Project, follow these steps:
 ## Usage

 To use My Awesome Project, follow these steps:
+
+1. Open your web browser and navigate to `http://localhost:3000`
+2. Sign up for a new account or log in to an existing one
+3. Create a new task or project and start managing your work!
+
 ## Contributing

 We welcome contributions from anyone and everyone. To contribute to My Awesome Project, follow these steps:


================================================
FILE: tests/fixtures/fix-nullpointer-exception.diff
================================================
diff --git a/src/main/java/com/example/MyClass.java b/src/main/java/com/example/MyClass.java
index e7d8f38..caab7f1 100644
--- a/src/main/java/com/example/MyClass.java
+++ b/src/main/java/com/example/MyClass.java
@@ -23,7 +23,10 @@ public class MyClass {
     public void processItems(List<Item> items) {
         for (Item item : items) {
-            if (item.getValue().equalsIgnoreCase("example")) {
+            // Fixing NullPointerException by adding a null check
+            String itemValue = item.getValue();
+            if (itemValue != null && itemValue.equalsIgnoreCase("example")) {
                 processExampleItem(item);
             }
         }


================================================
FILE: tests/fixtures/github-action-build-pipeline.diff
================================================
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 1d07d31..085eb64 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -10,6 +10,8 @@ jobs:
       - uses: actions/setup-node@v1
         with:
           node-version: 12.x
+      - name: Install dependencies
+        run: npm install
       - name: Build and test
         run: |
           npm run build
@@ -22,3 +24,7 @@ jobs:
         if: always()
         uses: actions/upload-artifact@v1
         with:
+          name: Build artifact
+          path: build
+      - name: Deploy to production
+        uses: some-third-party/deploy-action@v1


================================================
FILE: tests/fixtures/new-feature.diff
================================================
diff --git a/src/features/newFeature.js b/src/features/newFeature.js
new file mode 100644
index 0000000..b6e5789
--- /dev/null
+++ b/src/features/newFeature.js
@@ -0,0 +1,18 @@
+/**
+ * New feature: Calculates the factorial of a given number.
+ * @param {number} n - The input number.
+ * @returns {number} - The factorial of the input number.
+ */
+function factorial(n) {
+  if (n === 0 || n === 1) {
+    return 1;
+  }
+  return n * factorial(n - 1);
+}
+
+module.exports = {
+  factorial,
+};
+
diff --git a/src/app.js b/src/app.js
index 8741c37..91b2e74 100644
--- a/src/app.js
+++ b/src/app.js
@@ -2,6 +2,7 @@
 const express = require('express');
 const bodyParser = require('body-parser');
 const userRoutes = require('./routes/userRoutes');
+const { factorial } = require('./features/newFeature');

 const app = express();
 app.use(bodyParser.json());
@@ -21,6 +22,12 @@
   res.send('Welcome to the API!');
 });

+app.get('/factorial/:number', (req, res) => {
+  const number = parseInt(req.params.number, 10);
+  const result = factorial(number);
+  res.send(`Factorial of ${number} is ${result}`);
+});
+
 // Other routes...

 module.exports = app;


================================================
FILE: tests/fixtures/performance-improvement.diff
================================================
diff --git a/src/loop.js b/src/loop.js
index 1d45a2b..8c52e81 100644
--- a/src/loop.js
+++ b/src/loop.js
@@ -5,14 +5,14 @@ const items = generateItems(100000);
 function processData(items) {
   let sum = 0;

-  for (let i = 0; i < items.length; i++) {
-    const item = items[i];
-    if (item.isValid()) {
-      sum += item.value;
-    }
+  for (const item of items) {
+    if (item.isValid()) sum += item.value;
   }

   return sum;
 }

 const startTime = Date.now();
-const result = processData(items);
+const result = processData(items); // Improved loop iteration
 const endTime = Date.now();

 console.log(`Result: ${result}, Time: ${endTime - startTime} ms`);


================================================
FILE: tests/fixtures/remove-feature.diff
================================================
diff --git a/Controllers/FeatureController.cs b/Controllers/FeatureController.cs
index 8a3b7c1..3e29f9a 100644
--- a/Controllers/FeatureController.cs
+++ b/Controllers/FeatureController.cs
@@ -1,16 +1,7 @@
 using Microsoft.AspNetCore.Mvc;
 using System.Collections.Generic;

 namespace MyWebApi.Controllers
 {
     [Route("api/[controller]")]
     [ApiController]
     public class FeatureController : ControllerBase
     {
-        [HttpGet("old-feature")]
-        public ActionResult<string> GetOldFeature()
-        {
-            return "This is the removed old feature.";
-        }
-
         [HttpGet("new-feature")]
         public ActionResult<string> GetNewFeature()
         {
             return "This is the new feature.";
         }
     }
 }


================================================
FILE: tests/fixtures/testing-react-application.diff
================================================
diff --git a/src/components/MyComponent.test.js b/src/components/MyComponent.test.js
index 37eabf2..976c6bf 100644
--- a/src/components/MyComponent.test.js
+++ b/src/components/MyComponent.test.js
@@ -10,6 +10,7 @@ describe("MyComponent", () => {
     });

     it("renders the component correctly", () => {
+        const props = { name: "John Doe", age: 25 };
         const tree = renderer.create(<MyComponent {...props} />).toJSON();
         expect(tree).toMatchSnapshot();
     });
@@ -25,6 +26,11 @@ describe("MyComponent", () => {
         expect(wrapper.find("h1").text()).toEqual("Hello, John Doe!");
     });

+    it("displays the correct age", () => {
+        const props = { name: "Jane Doe", age: 30 };
+        const wrapper = shallow(<MyComponent {...props} />);
+        expect(wrapper.find("p").text()).toEqual("Age: 30");
+    });
 });


================================================
FILE: tests/index.ts
================================================
import { describe } from 'manten';

describe('aicommits', ({ runTestSuite }) => {
	runTestSuite(import('./specs/cli/index.js'));
	runTestSuite(import('./specs/auto-update.js'));
	runTestSuite(import('./specs/openai/index.js'));
	runTestSuite(import('./specs/togetherai/index.js'));
	runTestSuite(import('./specs/config.js'));
	runTestSuite(import('./specs/git-hook.js'));
});


================================================
FILE: tests/specs/auto-update.ts
================================================
import { testSuite, expect } from 'manten';
import { checkAndAutoUpdate } from '../../src/utils/auto-update.js';

export default testSuite(({ describe }) => {
	describe('Auto update', ({ test }) => {
		test('skips update checks entirely in headless mode', async () => {
			const originalFetch = globalThis.fetch;
			const originalConsoleLog = console.log;
			const fetchCalls: string[] = [];
			const consoleCalls: string[] = [];

			globalThis.fetch = (async (input: string | URL | Request) => {
				fetchCalls.push(String(input));
				throw new Error('fetch should not be called in headless mode');
			}) as typeof fetch;

			console.log = (...args: unknown[]) => {
				consoleCalls.push(args.join(' '));
			};

			try {
				await checkAndAutoUpdate({
					pkg: {
						name: 'aicommits',
						version: '1.0.0',
					},
					headless: true,
				});
			} finally {
				globalThis.fetch = originalFetch;
				console.log = originalConsoleLog;
			}

			expect(fetchCalls).toEqual([]);
			expect(consoleCalls).toEqual([]);
		});
	});
});


================================================
FILE: tests/specs/cli/commits.ts
================================================
import { testSuite, expect } from 'manten';
import {
	createFixture,
	createGit,
	files,
} from '../../utils.js';

export default testSuite(({ describe }) => {
	if (process.platform === 'win32') {
		// https://github.com/nodejs/node/issues/31409
		console.warn(
			'Skipping tests on Windows because Node.js spawn cant open TTYs'
		);
		return;
	}

	if (!process.env.OPENAI_API_KEY) {
		console.warn(
			'⚠️  process.env.OPENAI_API_KEY is necessary to run these tests. Skipping...'
		);
		return;
	}

	describe('Commits', async ({ test, describe }) => {
		test('Excludes files', async () => {
			const { fixture, aicommits } = await createFixture(files);
			const git = await createGit(fixture.path);

			await git('add', ['data.json']);
			const statusBefore = await git('status', [
				'--porcelain',
				'--untracked-files=no',
			]);
			expect(statusBefore.stdout).toBe('A  data.json');

			const { stdout, exitCode } = await aicommits(['--exclude', 'data.json'], {
				reject: false,
			});
			expect(exitCode).toBe(1);
			expect(stdout).toMatch('No staged changes found.');
			await fixture.rm();
		});

		test('Generates commit message', async () => {
			const { fixture, aicommits } = await createFixture(files);
			const git = await createGit(fixture.path);

			await git('add', ['data.json']);

			const committing = aicommits();
			committing.stdout!.on('data', (buffer: Buffer) => {
				const stdout = buffer.toString();
				if (stdout.match('└')) {
					committing.stdin!.write('y');
					committing.stdin!.end();
				}
			});

			await committing;

			const statusAfter = await git('status', [
				'--porcelain',
				'--untracked-files=no',
			]);
			expect(statusAfter.stdout).toBe('');

			const { stdout: commitMessage } = await git('log', [
				'--pretty=format:%s',
			]);
			console.log({
				commitMessage,
				length: commitMessage.length,
			});
			expect(commitMessage.length).toBeLessThanOrEqual(50);

			await fixture.rm();
		});

		test('Generated commit message must be under 20 characters', async () => {
			const { fixture, aicommits } = await createFixture({
				...files,
				'.aicommits': `${files['.aicommits']}\nmax-length=20`,
			});

			const git = await createGit(fixture.path);

			await git('add', ['data.json']);

			const committing = aicommits();
			committing.stdout!.on('data', (buffer: Buffer) => {
				const stdout = buffer.toString();
				if (stdout.match('└')) {
					committing.stdin!.write('y');
					committing.stdin!.end();
				}
			});

			await committing;

			const { stdout: commitMessage } = await git('log', [
				'--pretty=format:%s',
			]);
			console.log({
				commitMessage,
				length: commitMessage.length,
			});
			expect(commitMessage.length).toBeLessThanOrEqual(20);

			await fixture.rm();
		});

		test('Accepts --all flag, staging all changes before commit', async () => {
			const { fixture, aicommits } = await createFixture(files);
			const git = await createGit(fixture.path);

			await git('add', ['data.json']);
			await git('commit', ['-m', 'wip']);

			// Change tracked file
			await fixture.writeFile('data.json', 'Test');

			const statusBefore = await git('status', ['--short']);
			expect(statusBefore.stdout).toBe(' M data.json\n?? .aicommits');

			const committing = aicommits(['--all']);
			committing.stdout!.on('data', (buffer: Buffer) => {
				const stdout = buffer.toString();
				if (stdout.match('└')) {
					committing.stdin!.write('y');
					committing.stdin!.end();
				}
			});

			await committing;

			const statusAfter = await git('status', ['--short']);
			expect(statusAfter.stdout).toBe('?? .aicommits');

			const { stdout: commitMessage } = await git('log', [
				'-n1',
				'--pretty=format:%s',
			]);
			console.log({
				commitMessage,
				length: commitMessage.length,
			});
			expect(commitMessage.length).toBeLessThanOrEqual(50);

			await fixture.rm();
		});

		test('Accepts --generate flag, overriding config', async ({
			onTestFail,
		}) => {
			const { fixture, aicommits } = await createFixture({
				...files,
				'.aicommits': `${files['.aicommits']}\ngenerate=4`,
			});
			const git = await createGit(fixture.path);

			await git('add', ['data.json']);

			// Generate flag should override generate config
			const committing = aicommits(['--generate', '2']);

			// Hit enter to accept the commit message
			committing.stdout!.on('data', function onPrompt(buffer: Buffer) {
				const stdout = buffer.toString();
				if (stdout.match('└')) {
					committing.stdin!.write('\r');
					committing.stdin!.end();
					committing.stdout?.off('data', onPrompt);
				}
			});

			const { stdout } = await committing;
			const countChoices = stdout.match(/ {2}[●○]/g)?.length ?? 0;

			onTestFail(() => console.log({ stdout }));
			expect(countChoices).toBe(2);

			const statusAfter = await git('status', [
				'--porcelain',
				'--untracked-files=no',
			]);
			expect(statusAfter.stdout).toBe('');

			const { stdout: commitMessage } = await git('log', [
				'--pretty=format:%s',
			]);
			console.log({
				commitMessage,
				length: commitMessage.length,
			});
			expect(commitMessage.length).toBeLessThanOrEqual(50);

			await fixture.rm();
		});

		test('Generates Japanese commit message via locale config', async () => {
			// https://stackoverflow.com/a/15034560/911407
			const japanesePattern =
				/[\u3000-\u303F\u3040-\u309F\u30A0-\u30FF\uFF00-\uFF9F\u4E00-\u9FAF\u3400-\u4DBF]/;

			const { fixture, aicommits } = await createFixture({
				...files,
				'.aicommits': `${files['.aicommits']}\nlocale=ja`,
			});
			const git = await createGit(fixture.path);

			await git('add', ['data.json']);

			const committing = aicommits();

			committing.stdout!.on('data', (buffer: Buffer) => {
				const stdout = buffer.toString();
				if (stdout.match('└')) {
					committing.stdin!.write('y');
					committing.stdin!.end();
				}
			});

			await committing;

			const statusAfter = await git('status', [
				'--porcelain',
				'--untracked-files=no',
			]);
			expect(statusAfter.stdout).toBe('');

			const { stdout: commitMessage } = await git('log', [
				'--pretty=format:%s',
			]);
			console.log({
				commitMessage,
				length: commitMessage.length,
			});
			expect(commitMessage).toMatch(japanesePattern);
			expect(commitMessage.length).toBeLessThanOrEqual(50);

			await fixture.rm();
		});

		describe('commit types', ({ test }) => {
			test('Should not use conventional commits by default', async () => {
				const conventionalCommitPattern =
					/(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test):\s/;
				const { fixture, aicommits } = await createFixture({
					...files,
				});
				const git = await createGit(fixture.path);

				await git('add', ['data.json']);

				const committing = aicommits();

				committing.stdout!.on('data', (buffer: Buffer) => {
					const stdout = buffer.toString();
					if (stdout.match('└')) {
						committing.stdin!.write('y');
						committing.stdin!.end();
					}
				});

				await committing;

				const statusAfter = await git('status', [
					'--porcelain',
					'--untracked-files=no',
				]);
				expect(statusAfter.stdout).toBe('');

				const { stdout: commitMessage } = await git('log', ['--oneline']);
				console.log('Committed with:', commitMessage);
				expect(commitMessage).not.toMatch(conventionalCommitPattern);

				await fixture.rm();
			});

			test('Conventional commits', async () => {
				const conventionalCommitPattern =
					/(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test):\s/;
				const { fixture, aicommits } = await createFixture({
					...files,
					'.aicommits': `${files['.aicommits']}\ntype=conventional`,
				});
				const git = await createGit(fixture.path);

				await git('add', ['data.json']);

				const committing = aicommits();

				committing.stdout!.on('data', (buffer: Buffer) => {
					const stdout = buffer.toString();
					if (stdout.match('└')) {
						committing.stdin!.write('y');
						committing.stdin!.end();
					}
				});

				await committing;

				const statusAfter = await git('status', [
					'--porcelain',
					'--untracked-files=no',
				]);
				expect(statusAfter.stdout).toBe('');

				const { stdout: commitMessage } = await git('log', ['--oneline']);
				console.log('Committed with:', commitMessage);
				expect(commitMessage).toMatch(conventionalCommitPattern);

				await fixture.rm();
			});

			test('Accepts --type flag, overriding config', async () => {
				const conventionalCommitPattern =
					/(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test):\s/;
				const { fixture, aicommits } = await createFixture({
					...files,
					'.aicommits': `${files['.aicommits']}\ntype=other`,
				});
				const git = await createGit(fixture.path);

				await git('add', ['data.json']);

				// Generate flag should override generate config
				const committing = aicommits(['--type', 'conventional']);

				committing.stdout!.on('data', (buffer: Buffer) => {
					const stdout = buffer.toString();
					if (stdout.match('└')) {
						committing.stdin!.write('y');
						committing.stdin!.end();
					}
				});

				await committing;

				const statusAfter = await git('status', [
					'--porcelain',
					'--untracked-files=no',
				]);
				expect(statusAfter.stdout).toBe('');

				const { stdout: commitMessage } = await git('log', ['--oneline']);
				console.log('Committed with:', commitMessage);
				expect(commitMessage).toMatch(conventionalCommitPattern);

				await fixture.rm();
			});

			test('Accepts plain --type flag', async () => {
				const conventionalCommitPattern =
					/(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test):\s/;
				const { fixture, aicommits } = await createFixture({
					...files,
					'.aicommits': `${files['.aicommits']}\ntype=conventional`,
				});
				const git = await createGit(fixture.path);

				await git('add', ['data.json']);

				const committing = aicommits(['--type', 'plain']);

				committing.stdout!.on('data', (buffer: Buffer) => {
					const stdout = buffer.toString();
					if (stdout.match('└')) {
						committing.stdin!.write('y');
						committing.stdin!.end();
					}
				});

				await committing;

				const statusAfter = await git('status', [
					'--porcelain',
					'--untracked-files=no',
				]);
				expect(statusAfter.stdout).toBe('');

				const { stdout: commitMessage } = await git('log', ['--oneline']);
				console.log('Committed with:', commitMessage);
				expect(commitMessage).not.toMatch(conventionalCommitPattern);

				await fixture.rm();
			});

			test('subject+body generates commit with subject and body', async () => {
				const { fixture, aicommits } = await createFixture({
					...files,
					'.aicommits': `${files['.aicommits']}\ntype=subject+body`,
				});
				const git = await createGit(fixture.path);

				await git('add', ['data.json']);

				const committing = aicommits();

				committing.stdout!.on('data', (buffer: Buffer) => {
					const stdout = buffer.toString();
					if (stdout.match('└')) {
						committing.stdin!.write('y');
						committing.stdin!.end();
					}
				});

				await committing;

				const statusAfter = await git('status', [
					'--porcelain',
					'--untracked-files=no',
				]);
				expect(statusAfter.stdout).toBe('');

				const { stdout: fullMessage } = await git('log', [
					'-n1',
					'--pretty=format:%B',
				]);
				expect(fullMessage).toContain('\n');
				expect(fullMessage.trim().split('\n').length).toBeGreaterThanOrEqual(2);

				await fixture.rm();
			});
		});

		describe('proxy', ({ test }) => {
			test('Fails on deprecated proxy config', async () => {
				const { fixture, aicommits } = await createFixture({
					...files,
					'.aicommits': `${files['.aicommits']}\nproxy=http://localhost:1234`,
				});
				const git = await createGit(fixture.path);

				await git('add', ['data.json']);

				const committing = aicommits([], {
					reject: false,
				});

				const { stdout, exitCode } = await committing;

				expect(exitCode).toBe(1);
				expect(stdout).toMatch('The "proxy" config property is deprecated and no longer supported');

				await fixture.rm();
			});

			test('Connects with env variable', async () => {
				const { fixture, aicommits } = await createFixture(files);
				const git = await createGit(fixture.path);

				await git('add', ['data.json']);

				const committing = aicommits([], {
					env: {
						HTTP_PROXY: 'http://localhost:8888',
					},
				});

				committing.stdout!.on('data', (buffer: Buffer) => {
					const stdout = buffer.toString();
					if (stdout.match('└')) {
						committing.stdin!.write('y');
						committing.stdin!.end();
					}
				});

				await committing;

				const statusAfter = await git('status', [
					'--porcelain',
					'--untracked-files=no',
				]);
				expect(statusAfter.stdout).toBe('');

				const { stdout: commitMessage } = await git('log', [
					'--pretty=format:%s',
				]);
				console.log({
					commitMessage,
					length: commitMessage.length,
				});
				expect(commitMessage.length).toBeLessThanOrEqual(50);

				await fixture.rm();
			});
		});


	});
});


================================================
FILE: tests/specs/cli/error-cases.ts
================================================
import { testSuite, expect } from 'manten';
import { createFixture, createGit } from '../../utils.js';

export default testSuite(({ describe }) => {
	describe('Error cases', async ({ test }) => {
		test('Fails on non-Git project', async () => {
			const { fixture, aicommits } = await createFixture({
				'.aicommits': 'OPENAI_API_KEY=sk-test-key\nprovider=openai'
			});
			const { stderr, exitCode } = await aicommits([], { reject: false });
			expect(exitCode).toBe(1);
			expect(stderr).toMatch('The current directory must be a Git repository!');
			await fixture.rm();
		});

		test('Fails on no staged files', async () => {
			const { fixture, aicommits } = await createFixture({
				'.aicommits': 'OPENAI_API_KEY=sk-test-key\nprovider=openai'
			});
			await createGit(fixture.path);

			const { stderr, exitCode } = await aicommits([], { reject: false });
			expect(exitCode).toBe(1);
			expect(stderr).toMatch(
				'No staged changes found. Stage your changes manually, or automatically stage all changes with the `--all` flag.'
			);
			await fixture.rm();
		});
	});
});


================================================
FILE: tests/specs/cli/headless.ts
================================================
import { testSuite, expect } from 'manten';
import { createFixture, createGit } from '../../utils.js';

export default testSuite(({ describe }) => {
	describe('Headless mode', ({ test }) => {
		test('setup requires an interactive terminal', async () => {
			const { fixture, aicommits } = await createFixture();

			const { stdout, stderr, exitCode } = await aicommits(['setup'], {
				reject: false,
				env: {
					CI: '1',
				},
			});

			expect(exitCode).toBe(1);
			expect(stdout).toBe('');
			expect(stderr).toMatch('Interactive terminal required for setup');

			await fixture.rm();
		});

		test('model requires an interactive terminal', async () => {
			const { fixture, aicommits } = await createFixture();

			const { stdout, stderr, exitCode } = await aicommits(['model'], {
				reject: false,
				env: {
					CI: '1',
				},
			});

			expect(exitCode).toBe(1);
			expect(stdout).toBe('');
			expect(stderr).toMatch('Interactive terminal required for model selection');

			await fixture.rm();
		});

		test('pr requires an interactive terminal', async () => {
			const { fixture, aicommits } = await createFixture();
			await createGit(fixture.path);

			const { stdout, stderr, exitCode } = await aicommits(['pr'], {
				reject: false,
				env: {
					CI: '1',
				},
			});

			expect(exitCode).toBe(1);
			expect(stdout).toBe('');
			expect(stderr).toMatch('Interactive terminal required for PR creation');

			await fixture.rm();
		});
	});
});


================================================
FILE: tests/specs/cli/index.ts
================================================
import { testSuite } from 'manten';

export default testSuite(({ describe }) => {
	describe('CLI', ({ runTestSuite }) => {
		runTestSuite(import('./error-cases.js'));
		runTestSuite(import('./headless.js'));
		runTestSuite(import('./commits.js'));
		runTestSuite(import('./no-verify.js'));
	});
});


================================================
FILE: tests/specs/cli/no-verify.ts
================================================
import { testSuite, expect } from 'manten';
import { execSync } from 'child_process';
import { existsSync } from 'fs';
import path from 'path';

export default testSuite(({ describe }) => {
  describe('No Verify', ({ test }) => {
    test('Exposes --no-verify flag', () => {
      const cliPath = path.resolve(process.cwd(), 'dist', 'cli.mjs');
      if (!existsSync(cliPath)) {
        require('child_process').execSync('npm run build', { stdio: 'inherit' });
      }
      const output = execSync(`node ${cliPath} --help`, { encoding: 'utf8' });
      expect(output).toContain('-n, --no-verify');
    });
  });
});


================================================
FILE: tests/specs/config.ts
================================================
import fs from 'fs/promises';
import path from 'path';
import { testSuite, expect } from 'manten';
import { createFixture } from '../utils.js';

export default testSuite(({ describe }) => {
	describe('config', async ({ test, describe }) => {
		const { fixture, aicommits } = await createFixture();
		const configPath = path.join(fixture.path, '.aicommits');
		const openAiToken = 'OPENAI_API_KEY=abc';

		test('set unknown config file', async () => {
			const { stderr } = await aicommits(['config', 'set', 'UNKNOWN=1'], {
				reject: false,
			});

			expect(stderr).toMatch('Invalid config property: UNKNOWN');
		});

		test('set OPENAI_API_KEY', async () => {
			const { stderr } = await aicommits(
				['config', 'set', 'OPENAI_API_KEY=abc'],
				{
					reject: false,
				}
			);

			expect(stderr).toBe('');
		});

		await test('set config file', async () => {
			await aicommits(['config', 'set', openAiToken]);

			const configFile = await fs.readFile(configPath, 'utf8');
			expect(configFile).toMatch(openAiToken);
		});

		await test('get config file', async () => {
			const { stdout } = await aicommits(['config', 'get', 'OPENAI_API_KEY']);
			expect(stdout).toBe('OPENAI_API_KEY=abc****');
		});

		await test('reading unknown config', async () => {
			await fs.appendFile(configPath, 'UNKNOWN=1');

			const { stdout, stderr } = await aicommits(['config', 'get', 'UNKNOWN'], {
				reject: false,
			});

			expect(stdout).toBe('');
			expect(stderr).toBe('');
		});

		await describe('timeout', ({ test }) => {
			test('setting invalid timeout config', async () => {
				const { stderr } = await aicommits(['config', 'set', 'timeout=abc'], {
					reject: false,
				});

				expect(stderr).toMatch('Must be an integer');
			});

			test('setting valid timeout config', async () => {
				const timeout = 'timeout=20000';
				await aicommits(['config', 'set', timeout]);

				const configFile = await fs.readFile(configPath, 'utf8');
				expect(configFile).toMatch(timeout);

				const get = await aicommits(['config', 'get', 'timeout']);
				expect(get.stdout).toBe(timeout);
			});
		});

		await test('accepts type=subject+body', async () => {
			await aicommits(['config', 'set', 'OPENAI_API_KEY=abc']);
			await aicommits(['config', 'set', 'type=subject+body']);
			const { stdout } = await aicommits(['config', 'get', 'type']);
			expect(stdout).toBe('type=subject+body');
		});

		await describe('max-length', ({ test }) => {
			test('must be an integer', async () => {
				const { stderr } = await aicommits(
					['config', 'set', 'max-length=abc'],
					{
						reject: false,
					}
				);

				expect(stderr).toMatch('Must be an integer');
			});

			test('must be at least 20 characters', async () => {
				const { stderr } = await aicommits(['config', 'set', 'max-length=10'], {
					reject: false,
				});

				expect(stderr).toMatch(/must be greater than 20 characters/i);
			});

			test('updates config', async () => {
				const defaultConfig = await aicommits(['config', 'get', 'max-length']);
				expect(defaultConfig.stdout).toBe('max-length=72');

				const maxLength = 'max-length=60';
				await aicommits(['config', 'set', maxLength]);

				const configFile = await fs.readFile(configPath, 'utf8');
				expect(configFile).toMatch(maxLength);

				const get = await aicommits(['config', 'get', 'max-length']);
				expect(get.stdout).toBe(maxLength);
			});
		});

		await fixture.rm();
	});
});


================================================
FILE: tests/specs/git-hook.ts
================================================
import path from 'path';
import { testSuite, expect } from 'manten';
import {
	createFixture,
	createGit,
	files,
} from '../utils.js';

export default testSuite(({ describe }) => {
	describe('Git hook', ({ test }) => {
		if (!process.env.OPENAI_API_KEY) {
			console.warn(
				'⚠️  process.env.OPENAI_API_KEY is necessary to run these tests. Skipping...'
			);
			return;
		}

		test('errors when not in Git repo', async () => {
			const { fixture, aicommits } = await createFixture(files);
			const { exitCode, stderr } = await aicommits(['hook', 'install'], {
				reject: false,
			});

			expect(exitCode).toBe(1);
			expect(stderr).toMatch('The current directory must be a Git repository');

			await fixture.rm();
		});

		test('installs from Git repo subdirectory', async () => {
			const { fixture, aicommits } = await createFixture({
				...files,
				'some-dir': {
					'file.txt': '',
				},
			});
			await createGit(fixture.path);

			const { stdout } = await aicommits(['hook', 'install'], {
				cwd: path.join(fixture.path, 'some-dir'),
			});
			expect(stdout).toMatch('Hook installed');

			expect(await fixture.exists('.git/hooks/prepare-commit-msg')).toBe(true);

			await fixture.rm();
		});

		test('Commits', async () => {
			const { fixture, aicommits } = await createFixture(files);
			const git = await createGit(fixture.path);

			const { stdout } = await aicommits(['hook', 'install']);
			expect(stdout).toMatch('Hook installed');

			await git('add', ['data.json']);
			await git('commit', ['--no-edit'], {
				env: {
					HOME: fixture.path,
					USERPROFILE: fixture.path,
				},
			});

			const { stdout: commitMessage } = await git('log', ['--pretty=%B']);
			console.log('Committed with:', commitMessage);
			expect(commitMessage.startsWith('# ')).not.toBe(true);

			await fixture.rm();
		});
	});
});


================================================
FILE: tests/specs/openai/index.ts
================================================
import { expect, testSuite } from 'manten';
import {
	generateCommitMessage,
	generateCommitDescription,
} from '../../../src/utils/openai.js';
import type { ValidConfig } from '../../../src/utils/config-types.js';
import { getDiff } from '../../utils.js';

const { OPENAI_API_KEY } = process.env;

export default testSuite(({ describe }) => {
	if (!OPENAI_API_KEY) {
		console.warn(
			'⚠️  process.env.OPENAI_API_KEY is necessary to run these tests. Skipping...'
		);
		return;
	}

	describe('Conventional Commits', async ({ test }) => {
		await test('Should not translate conventional commit type to Japanase when locale config is set to japanese', async () => {
			const japaneseConventionalCommitPattern =
				/(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\(.*\))?: [\u3000-\u303F\u3040-\u309F\u30A0-\u30FF\uFF00-\uFF9F\u4E00-\u9FAF\u3400-\u4DBF]/;

			const gitDiff = await getDiff('new-feature.diff');

			const commitMessage = await runGenerateCommitMessage(gitDiff, {
				locale: 'ja',
			});

			expect(commitMessage).toMatch(japaneseConventionalCommitPattern);
			console.log('Generated message:', commitMessage);
		});

		await test('Should use "feat:" conventional commit when change relate to adding a new feature', async () => {
			const gitDiff = await getDiff('new-feature.diff');

			const commitMessage = await runGenerateCommitMessage(gitDiff);

			// should match "feat:" or "feat(<scope>):"
			expect(commitMessage).toMatch(/(feat(\(.*\))?):/);
			console.log('Generated message:', commitMessage);
		});

		await test('Should use "refactor:" conventional commit when change relate to code refactoring', async () => {
			const gitDiff = await getDiff('code-refactoring.diff');

			const commitMessage = await runGenerateCommitMessage(gitDiff);

			// should match "refactor:" or "refactor(<scope>):"
			expect(commitMessage).toMatch(/(refactor(\(.*\))?):/);
			console.log('Generated message:', commitMessage);
		});

		await test('Should use "test:" conventional commit when change relate to testing a React application', async () => {
			const gitDiff = await getDiff('testing-react-application.diff');

			const commitMessage = await runGenerateCommitMessage(gitDiff);

			// should match "test:" or "test(<scope>):"
			expect(commitMessage).toMatch(/(test(\(.*\))?):/);
			console.log('Generated message:', commitMessage);
		});

		await test('Should use "build:" conventional commit when change relate to github action build pipeline', async () => {
			const gitDiff = await getDiff('github-action-build-pipeline.diff');

			const commitMessage = await runGenerateCommitMessage(gitDiff);

			// should match "build:" or "build(<scope>):"
			expect(commitMessage).toMatch(/((build|ci)(\(.*\))?):/);
			console.log('Generated message:', commitMessage);
		});

		await test('Should use "(ci|build):" conventional commit when change relate to continious integration', async () => {
			const gitDiff = await getDiff('continous-integration.diff');

			const commitMessage = await runGenerateCommitMessage(gitDiff);

			// should match "ci:" or "ci(<scope>):
			// It also sometimes generates build and feat
			expect(commitMessage).toMatch(/((ci|build|feat)(\(.*\))?):/);
			console.log('Generated message:', commitMessage);
		});

		await test('Should use "docs:" conventional commit when change relate to documentation changes', async () => {
			const gitDiff = await getDiff('documentation-changes.diff');
			const commitMessage = await runGenerateCommitMessage(gitDiff);

			// should match "docs:" or "docs(<scope>):"
			expect(commitMessage).toMatch(/(docs(\(.*\))?):/);
			console.log('Generated message:', commitMessage);
		});

		await test('Should use "fix:" conventional commit when change relate to fixing code', async () => {
			const gitDiff = await getDiff('fix-nullpointer-exception.diff');
			const commitMessage = await runGenerateCommitMessage(gitDiff);

			// should match "fix:" or "fix(<scope>):"
			// Sometimes it generates refactor
			expect(commitMessage).toMatch(/((fix|refactor)(\(.*\))?):/);
			console.log('Generated message:', commitMessage);
		});

		await test('Should use "style:" conventional commit when change relate to code style improvements', async () => {
			const gitDiff = await getDiff('code-style.diff');
			const commitMessage = await runGenerateCommitMessage(gitDiff);

			// should match "style:" or "style(<style>):"
			expect(commitMessage).toMatch(/(style|refactor|fix)(\(.*\))?:/);
			console.log('Generated message:', commitMessage);
		});

		await test('Should use "chore:" conventional commit when change relate to a chore or maintenance', async () => {
			const gitDiff = await getDiff('chore.diff');
			const commitMessage = await runGenerateCommitMessage(gitDiff);

			// should match "chore:" or "chore(<style>):"
			// Sometimes it generates build|feat
			expect(commitMessage).toMatch(/((chore|build|feat)(\(.*\))?):/);
			console.log('Generated message:', commitMessage);
		});

		await test('Should use "perf:" conventional commit when change relate to a performance improvement', async () => {
			const gitDiff = await getDiff('performance-improvement.diff');
			const commitMessage = await runGenerateCommitMessage(gitDiff);

			// should match "perf:" or "perf(<style>):"
			// It also sometimes generates refactor:
			expect(commitMessage).toMatch(/((perf|refactor)(\(.*\))?):/);
			console.log('Generated message:', commitMessage);
		});

		async function runGenerateCommitMessage(
			gitDiff: string,
			configOverrides: Partial<ValidConfig> = {}
		): Promise<string> {
			const config = {
				locale: 'en',
				type: 'conventional',
				generate: 1,
				'max-length': 50,
				...configOverrides,
			} as ValidConfig;
			const { messages: commitMessages } = await generateCommitMessage({
				baseUrl: 'https://api.openai.com/v1',
				apiKey: OPENAI_API_KEY!,
				model: 'gpt-3.5-turbo',
				locale: config.locale,
				diff: gitDiff,
				completions: config.generate,
				maxLength: config['max-length'],
				type: config.type,
				timeout: 7000,
			});

			return commitMessages[0];
		}
	});

	describe('subject+body / generateCommitDescription', async ({ test }) => {
		await test('generates a non-empty body from title and diff', async () => {
			const gitDiff = await getDiff('new-feature.diff');
			const title = 'feat: add new feature';

			const { description } = await generateCommitDescription({
				baseUrl: 'https://api.openai.com/v1',
				apiKey: OPENAI_API_KEY!,
				model: 'gpt-3.5-turbo',
				locale: 'en',
				title,
				diff: gitDiff,
				timeout: 7000,
				maxLength: 72,
			});

			expect(typeof description).toBe('string');
			expect(description.length).toBeGreaterThan(0);
		});
	});
});


================================================
FILE: tests/specs/togetherai/index.ts
================================================
import { expect, testSuite } from 'manten';
import { generateCommitMessage } from '../../../src/utils/openai.js';
import type { ValidConfig } from '../../../src/utils/config-types.js';
import { getDiff } from '../../utils.js';

const { TOGETHER_API_KEY } = process.env;

export default testSuite(({ describe }) => {
	if (!TOGETHER_API_KEY) {
		console.warn(
			'⚠️  process.env.TOGETHER_API_KEY is necessary to run these tests. Skipping...',
		);
		return;
	}

	describe('Conventional Commits', async ({ test }) => {
		await test('Should generate conventional commit format', async () => {
			const gitDiff = await getDiff('new-feature.diff');

			const commitMessage = await runGenerateCommitMessage(gitDiff, {
				locale: 'en',
			});

			// Should start with conventional commit type
			expect(commitMessage).toMatch(
				/^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)/,
			);
			console.log('Generated message:', commitMessage);
		});

		await test('Should generate conventional commit for new feature', async () => {
			const gitDiff = await getDiff('new-feature.diff');

			const commitMessage = await runGenerateCommitMessage(gitDiff);

			// Should be in conventional commit format
			expect(commitMessage).toMatch(
				/^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)/,
			);
			console.log('Generated message:', commitMessage);
		});

		await test('Should generate conventional commit for refactoring', async () => {
			const gitDiff = await getDiff('code-refactoring.diff');

			const commitMessage = await runGenerateCommitMessage(gitDiff);

			// Should be in conventional commit format
			expect(commitMessage).toMatch(
				/^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)/,
			);
			console.log('Generated message:', commitMessage);
		});

		async function runGenerateCommitMessage(
			gitDiff: string,
			configOverrides: Partial<ValidConfig> = {},
		): Promise<string> {
			const config = {
				locale: 'en',
				type: 'conventional',
				generate: 1,
				'max-length': 72,
				...configOverrides,
			} as ValidConfig;
			const { messages: commitMessages } = await generateCommitMessage({
				baseUrl: 'https://api.together.xyz',
				apiKey: TOGETHER_API_KEY!,
				model: 'Qwen/Qwen3-Next-80B-A3B-Instruct',
				locale: config.locale,
				diff: gitDiff,
				completions: config.generate,
				maxLength: config['max-length'],
				type: config.type,
				timeout: 7000,
			});

			return commitMessages[0];
		}
	});
});


================================================
FILE: tests/utils.ts
================================================
import path from 'path';
import fs from 'fs/promises';
import { execa, execaNode, type Options } from 'execa';
import {
	createFixture as createFixtureBase,
	type FileTree,
	type FsFixture,
} from 'fs-fixture';

const aicommitsPath = path.resolve('./dist/cli.mjs');

const createAicommits = (fixture: FsFixture) => {
	const homeEnv = {
		HOME: fixture.path, // Linux
		USERPROFILE: fixture.path, // Windows
	};

	return (args?: string[], options?: Options) =>
		execaNode(aicommitsPath, args, {
			cwd: fixture.path,
			...options,
			extendEnv: false,
			env: {
				...homeEnv,
				...options?.env,
			},

			// Block tsx nodeOptions
			nodeOptions: [],
		});
};

export const createGit = async (cwd: string) => {
	const git = (command: string, args?: string[], options?: Options) =>
		execa('git', [command, ...(args || [])], {
			cwd,
			...options,
		});

	await git('init', [
		// In case of different default branch name
		'--initial-branch=master',
	]);

	await git('config', ['user.name', 'name']);
	await git('config', ['user.email', 'email']);

	return git;
};

export const createFixture = async (source?: string | FileTree) => {
	const fixture = await createFixtureBase(source);
	const aicommits = createAicommits(fixture);

	return {
		fixture,
		aicommits,
	};
};

export const files = Object.freeze({
	'.aicommits': `OPENAI_API_KEY=${process.env.OPENAI_API_KEY}`,
	'data.json': Array.from(
		{ length: 10 },
		(_, i) => `${i}. Lorem ipsum dolor sit amet`
	).join('\n'),
});



// See ./diffs/README.md in order to generate diff files
export const getDiff = async (diffName: string): Promise<string> =>
	fs.readFile(new URL(`fixtures/${diffName}`, import.meta.url), 'utf8');


================================================
FILE: tsconfig.json
================================================
{
	"compilerOptions": {
		"noEmit": true,
		"target": "ES2020",
		"module": "Node16",
		"strict": true,
		"resolveJsonModule": true,
		"isolatedModules": true,
		"esModuleInterop": true,
		"skipLibCheck": true
	},
	"exclude": ["vscode-extension"]
}


================================================
FILE: vscode-extension/.gitignore
================================================
node_modules
dist
*.vsix


================================================
FILE: vscode-extension/.vscode/launch.json
================================================
{
	"version": "0.2.0",
	"configurations": [
		{
			"name": "Run Extension",
			"type": "extension",
			"request": "launch",
			"runtimeExecutable": "/Applications/Cursor.app/Contents/MacOS/Cursor",
			"args": [
				"--extensionDevelopmentPath=${workspaceFolder}"
			]
		}
	]
}


================================================
FILE: vscode-extension/.vscodeignore
================================================
.vscode/**
.vscode-test/**
src/**
.gitignore
**/*.map
**/node_modules/**


================================================
FILE: vscode-extension/LICENSE
================================================
MIT License

Copyright (c) Hassan El Mghari

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


================================================
FILE: vscode-extension/README.md
================================================
# AI Commits VSCode Extension

Generate git commit messages using AI directly from VSCode's Git interface.

## Features

- Generate commit messages with a single click from the Source Control panel
- Support for plain, conventional, and gitmoji commit formats
- Integrates seamlessly with VSCode's built-in Git extension
- Preview messages before committing or auto-commit option

## Requirements

- [aicommits CLI](https://github.com/anthropics/aicommits) must be installed and configured
- Run `aicommits setup` first to configure your AI provider

## Usage

1. Open the Source Control panel (Ctrl/Cmd + Shift + G)
2. Stage your changes
3. Click the sparkle icon in the toolbar or use the command palette:
   - `AI Commits: Generate Commit Message` - Plain format
   - `AI Commits: Generate Conventional Commit` - Conventional commits format
   - `AI Commits: Generate Gitmoji Commit` - Gitmoji format
4. The generated message appears in the commit input box
5. Review and commit!

## Configuration

| Setting | Default | Description |
|---------|---------|-------------|
| `aicommits.path` | `aicommits` | Path to the aicommits CLI binary |
| `aicommits.defaultType` | `plain` | Default commit message format (plain, conventional, gitmoji) |
| `aicommits.autoCommit` | `false` | Auto-commit after generating (skips preview) |

## Commands

- `aicommits.generate` - Generate plain commit message
- `aicommits.generateConventional` - Generate conventional commit
- `aicommits.generateGitmoji` - Generate gitmoji commit
- `aicommits.setup` - Setup AI provider (opens terminal)
- `aicommits.selectModel` - Select AI model (opens terminal)

## Installation

### From Source

```bash
cd vscode-extension
pnpm install
pnpm run compile
```

Then in VSCode:
1. Open the Extensions panel
2. Click "..." menu → "Install from VSIX..."
3. Select the `.vsix` file (run `pnpm run package` first)

## License

MIT


================================================
FILE: vscode-extension/package.json
================================================
{
	"name": "aicommits",
	"displayName": "AI Commits",
	"description": "Generate git commit messages using AI directly from VSCode's Git interface",
	"version": "1.0.0",
	"publisher": "aicommits",
	"license": "MIT",
	"icon": "media/icon.png",
	"repository": {
		"type": "git",
		"url": "https://github.com/nutlope/aicommits"
	},
	"engines": {
		"vscode": "^1.85.0"
	},
	"categories": [
		"Source Control",
		"AI"
	],
	"keywords": [
		"aicommits",
		"git",
		"commit",
		"ai",
		"openai",
		"gpt",
		"llm"
	],
	"main": "./dist/extension.js",
	"activationEvents": [
		"onStartupFinished"
	],
	"contributes": {
		"commands": [
			{
				"command": "aicommits.generate",
				"title": "AI Commits: Generate Commit Message",
				"icon": {
					"light": "media/icon.png",
					"dark": "media/icon.png"
				}
			},
			{
				"command": "aicommits.generateConventional",
				"title": "AI Commits: Generate Conventional Commit",
				"icon": "$(checklist)"
			},
			{
				"command": "aicommits.generateGitmoji",
				"title": "AI Commits: Generate Gitmoji Commit",
				"icon": "$(symbol-enum)"
			},
			{
				"command": "aicommits.generateSubjectBody",
				"title": "AI Commits: Generate Subject+Body Commit",
				"icon": "$(list-unordered)"
			},
			{
				"command": "aicommits.setup",
				"title": "AI Commits: Setup Provider"
			},
			{
				"command": "aicommits.selectModel",
				"title": "AI Commits: Select Model"
			}
		],
		"menus": {
			"scm/title": [
				{
					"command": "aicommits.generate",
					"group": "navigation@1",
					"when": "scmProvider == git"
				}
			],
			"scm/resourceGroup/context": [
				{
					"command": "aicommits.generate",
					"group": "1_modification@1",
					"when": "scmProvider == git"
				}
			]
		},
		"configuration": {
			"title": "AI Commits",
			"properties": {
				"aicommits.path": {
					"type": "string",
					"default": "aicommits",
					"description": "Path to the aicommits CLI binary (use 'aic' for short)"
				},
				"aicommits.defaultType": {
					"type": "string",
					"default": "plain",
					"enum": [
						"plain",
						"conventional",
						"gitmoji",
						"subject+body"
					],
					"description": "Default commit message format"
				},
				"aicommits.autoCommit": {
					"type": "boolean",
					"default": false,
					"description": "Automatically commit after generating the message (skips preview)"
				}
			}
		}
	},
	"scripts": {
		"vscode:prepublish": "pnpm run compile",
		"compile": "tsc -p ./",
		"watch": "tsc -watch -p ./",
		"lint": "eslint src --ext ts",
		"package": "vsce package --no-dependencies",
		"cursor": "pnpm run package && cursor --install-extension aicommits-1.0.0.vsix"
	},
	"devDependencies": {
		"@types/node": "^20.10.0",
		"@types/vscode": "^1.85.0",
		"@vscode/vsce": "^3.7.1",
		"typescript": "^5.3.0"
	}
}


================================================
FILE: vscode-extension/src/extension.ts
================================================
import * as vscode from 'vscode';
import { spawn } from 'child_process';
import { promisify } from 'util';
import { exec } from 'child_process';

const execAsync = promisify(exec);

let outputChannel: vscode.OutputChannel;
const TIMEOUT_MS = 15000;
let cliInstalled = false;
const PACKAGE_NAME = 'aicommits';

export function activate(context: vscode.ExtensionContext) {
	outputChannel = vscode.window.createOutputChannel('AI Commits');
	outputChannel.appendLine('[Extension] Activating AI Commits extension...');

	const generateCommand = vscode.commands.registerCommand(
		'aicommits.generate',
		() => {
			const config = vscode.workspace.getConfiguration('aicommits');
			const defaultType = config.get<
				'plain' | 'conventional' | 'gitmoji' | 'subject+body'
			>('defaultType', 'plain');
			return generateCommitMessage(defaultType);
		},
	);

	const generateConventionalCommand = vscode.commands.registerCommand(
		'aicommits.generateConventional',
		() => generateCommitMessage('conventional'),
	);

	const generateGitmojiCommand = vscode.commands.registerCommand(
		'aicommits.generateGitmoji',
		() => generateCommitMessage('gitmoji'),
	);

	const generateSubjectBodyCommand = vscode.commands.registerCommand(
		'aicommits.generateSubjectBody',
		() => generateCommitMessage('subject+body'),
	);

	const setupCommand = vscode.commands.registerCommand('aicommits.setup', () =>
		openSetupTerminal(),
	);

	const selectModelCommand = vscode.commands.registerCommand(
		'aicommits.selectModel',
		() => openTerminal('aicommits model'),
	);

	context.subscriptions.push(
		generateCommand,
		generateConventionalCommand,
		generateGitmojiCommand,
		generateSubjectBodyCommand,
		setupCommand,
		selectModelCommand,
		outputChannel,
	);

	checkCliOnActivation();
}

async function checkCliOnActivation() {
	outputChannel.appendLine('[Activation] Checking CLI installation...');
	cliInstalled = await isCliInstalled();
	outputChannel.appendLine(`[Activation] CLI installed: ${cliInstalled}`);

	if (!cliInstalled) {
		const action = await vscode.window.showInformationMessage(
			'AI Commits requires aicommits CLI. Install it now?',
			'Install',
			'Later',
		);

		if (action === 'Install') {
			await installCli();
		}
	} else {
		checkForCliUpdate();
	}
}

async function getCliVersion(): Promise<string | null> {
	try {
		const { stdout } = await execAsync('aicommits --version');
		const version = stdout.trim().replace(/^v/, '');
		outputChannel.appendLine(`[CLI] Detected version: ${version}`);
		return version;
	} catch (error) {
		outputChannel.appendLine(`[CLI] Failed to get version: ${error}`);
		return null;
	}
}

async function fetchLatestVersion(distTag: string): Promise<string | null> {
	const url = `https://registry.npmjs.org/${PACKAGE_NAME}/${distTag}`;
	outputChannel.appendLine(`[NPM] Fetching: ${url}`);
	try {
		const response = await fetch(url, {
			headers: { Accept: 'application/json' },
		});
		outputChannel.appendLine(`[NPM] Response status: ${response.status}`);
		if (!response.ok) return null;
		const data = (await response.json()) as { version?: string };
		outputChannel.appendLine(`[NPM] Got version: ${data.version}`);
		return data.version || null;
	} catch (error) {
		outputChannel.appendLine(`[NPM] Fetch failed: ${error}`);
		return null;
	}
}

function parseVersion(version: string) {
	const match = version.match(
		/^(\d+)\.(\d+)\.(\d+)(?:-([a-zA-Z]+)(?:\.(\d+))?)/,
	);
	if (!match)
		return {
			major: 0,
			minor: 0,
			patch: 0,
			prerelease: null as string | null,
			prereleaseNum: 0,
		};
	return {
		major: parseInt(match[1], 10),
		minor: parseInt(match[2], 10),
		patch: parseInt(match[3], 10),
		prerelease: match[4] || null,
		prereleaseNum: match[5] ? parseInt(match[5], 10) : 0,
	};
}

function compareVersions(v1: string, v2: string): number {
	const p1 = parseVersion(v1);
	const p2 = parseVersion(v2);
	if (p1.major !== p2.major) return p1.major > p2.major ? 1 : -1;
	if (p1.minor !== p2.minor) return p1.minor > p2.minor ? 1 : -1;
	if (p1.patch !== p2.patch) return p1.patch > p2.patch ? 1 : -1;
	if (!p1.prerelease && p2.prerelease) return 1;
	if (p1.prerelease && !p2.prerelease) return -1;
	if (!p1.prerelease && !p2.prerelease) return 0;
	if (p1.prereleaseNum !== p2.prereleaseNum)
		return p1.prereleaseNum > p2.prereleaseNum ? 1 : -1;
	return 0;
}

async function checkForCliUpdate(): Promise<void> {
	outputChannel.appendLine('[Update Check] Starting...');

	const currentVersion = await getCliVersion();
	outputChannel.appendLine(
		`[Update Check] Current version: ${currentVersion || 'not found'}`,
	);
	if (!currentVersion) return;

	const distTag = currentVersion.includes('-') ? 'develop' : 'latest';
	outputChannel.appendLine(`[Update Check] Using dist-tag: ${distTag}`);

	const latestVersion = await fetchLatestVersion(distTag);
	outputChannel.appendLine(
		`[Update Check] Latest version: ${latestVersion || 'not found'}`,
	);
	if (!latestVersion) return;

	const comparison = compareVersions(currentVersion, latestVersion);
	outputChannel.appendLine(
		`[Update Check] Version comparison result: ${comparison}`,
	);

	if (comparison >= 0) {
		outputChannel.appendLine('[Update Check] No update needed');
		return;
	}

	outputChannel.appendLine(
		`[Update Check] Update available! Showing notification...`,
	);

	const action = await vscode.window.showInformationMessage(
		`A new version of aicommits CLI is available (v${latestVersion}). Update now?`,
		'Update',
		'Later',
	);

	outputChannel.appendLine(
		`[Update Check] User action: ${action || 'dismissed'}`,
	);

	if (action === 'Update') {
		const terminal = vscode.window.createTerminal({
			name: 'AI Commits Update',
		});
		terminal.show();
		terminal.sendText(`npm install -g ${PACKAGE_NAME}@${distTag}`);
		vscode.window.showInformationMessage('Updating aicommits CLI...');
	}
}

async function isCliInstalled(): Promise<boolean> {
	return new Promise((resolve) => {
		const proc = spawn('which', ['aicommits'], { shell: true });
		let output = '';
		proc.stdout.on('data', (data) => {
			output += data.toString();
		});
		proc.on('close', (code) => {
			outputChannel.appendLine(
				`[CLI Check] which aicommits exit code: ${code}, output: ${output.trim()}`,
			);
			resolve(code === 0);
		});
		proc.on('error', (err) => {
			outputChannel.appendLine(`[CLI Check] Error: ${err}`);
			resolve(false);
		});
	});
}

async function installCli(): Promise<boolean> {
	return new Promise((resolve) => {
		const terminal = vscode.window.createTerminal({ name: 'AI Commits Setup' });
		terminal.show();
		terminal.sendText('npm install -g aicommits && aicommits setup');

		vscode.window.showInformationMessage(
			'Installing aicommits... Complete the setup in the terminal, then try again.',
			'OK',
		);

		resolve(false);
	});
}

async function ensureCliInstalled(): Promise<boolean> {
	if (cliInstalled) {
		return true;
	}

	cliInstalled = await isCliInstalled();
	if (cliInstalled) {
		return true;
	}

	const action = await vscode.window.showErrorMessage(
		'aicommits CLI is not installed. Install it now?',
		'Install',
		'Cancel',
	);

	if (action === 'Install') {
		await installCli();
	}
	return false;
}

async function generateCommitMessage(
	type: 'plain' | 'conventional' | 'gitmoji' | 'subject+body',
) {
	if (!(await ensureCliInstalled())) {
		return;
	}

	const config = vscode.workspace.getConfiguration('aicommits');
	const cliPath = config.get<string>('path', 'aicommits');
	const autoCommit = config.get<boolean>('autoCommit', false);

	const workspaceFolders = vscode.workspace.workspaceFolders;
	if (!workspaceFolders || workspaceFolders.length === 0) {
		vscode.window.showErrorMessage('No workspace folder open');
		return;
	}

	const cwd = workspaceFolders[0].uri.fsPath;

	const gitExtension = vscode.extensions.getExtension('vscode.git')?.exports;
	const git = gitExtension?.getAPI(1);
	const repo = git?.repositories[0];

	if (!repo) {
		vscode.window.showErrorMessage('No Git repository found');
		return;
	}

	const originalMessage = repo.inputBox.value;
	repo.inputBox.value = '⏳ Generating commit message...';

	await vscode.window.withProgress(
		{
			location: vscode.ProgressLocation.SourceControl,
			title: 'Generating commit message...',
			cancellable: false,
		},
		async () => {
			try {
				const args = [];
				if (type !== 'plain') {
					args.push('--type', type);
				}

				const message = await runCli(cliPath, args, cwd, TIMEOUT_MS);

				if (!message) {
					repo.inputBox.value = originalMessage;
					vscode.window.showWarningMessage('No message generated');
					return;
				}

				if (autoCommit) {
					await commitWithMessage(repo, message);
				} else {
					repo.inputBox.value = message;
					vscode.window.showInformationMessage('✨ Commit message generated!');
				}
			} catch (error) {
				repo.inputBox.value = originalMessage;
				const errorMessage =
					error instanceof Error ? error.message : String(error);

				if (errorMessage.includes('Timeout')) {
					vscode.window
						.showWarningMessage(
							'⏱️ AI is taking too long. Try again or check your API key.',
							'Setup',
							'Cancel',
						)
						.then((action) => {
							if (action === 'Setup') {
								openSetupTerminal();
							}
						});
				} else {
					outputChannel.appendLine(`Error: ${errorMessage}`);
					vscode.window.showErrorMessage(`AI Commits error: ${errorMessage}`);
				}
			}
		},
	);
}

function openSetupTerminal() {
	const workspaceFolders = vscode.workspace.workspaceFolders;
	const cwd = workspaceFolders?.[0]?.uri.fsPath;

	const terminal = vscode.window.createTerminal({
		name: 'AI Commits Setup',
		cwd,
	});

	terminal.show();
	terminal.sendText('aicommits setup');
}

function openTerminal(command: string) {
	const workspaceFolders = vscode.workspace.workspaceFolders;
	const cwd = workspaceFolders?.[0]?.uri.fsPath;

	const terminal = vscode.window.createTerminal({
		name: 'AI Commits',
		cwd,
	});

	terminal.show();
	terminal.sendText(command);
}

function runCli(
	cliPath: string,
	args: string[],
	cwd: string,
	timeout: number,
): Promise<string> {
	return new Promise((resolve, reject) => {
		outputChannel.appendLine(`Running: ${cliPath} ${args.join(' ')}`);

		const proc = spawn(cliPath, args, {
			cwd,
			shell: true,
		});

		let stdout = '';
		let stderr = '';

		proc.stdout.on('data', (data) => {
			stdout += data.toString();
		});

		proc.stderr.on('data', (data) => {
			stderr += data.toString();
			outputChannel.append(data.toString());
		});

		const timer = setTimeout(() => {
			proc.kill();
			reject(new Error('Timeout'));
		}, timeout);

		proc.on('close', (code) => {
			clearTimeout(timer);

			if (code !== 0) {
				reject(new Error(stderr || `Process exited with code ${code}`));
				return;
			}

			resolve(stdout.trim());
		});

		proc.on('error', (err) => {
			clearTimeout(timer);
			reject(err);
		});
	});
}

async function commitWithMessage(repo: any, message: string) {
	try {
		await repo.commit(message);
		vscode.window.showInformationMessage('✅ Committed successfully!');
	} catch (error) {
		vscode.window.showErrorMessage(
			`Failed to commit: ${error instanceof Error ? error.message : String(error)}`,
		);
	}
}

export function deactivate() {}


================================================
FILE: vscode-extension/tsconfig.json
================================================
{
  "compilerOptions": {
    "module": "commonjs",
    "target": "ES2020",
    "lib": ["ES2020"],
    "sourceMap": true,
    "rootDir": "src",
    "outDir": "dist",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}
Download .txt
gitextract_96rq2eky/

├── .editorconfig
├── .gitattributes
├── .github/
│   ├── ISSUE_TEMPLATE/
│   │   ├── BUG_REPORT.yml
│   │   ├── FEATURE_REQUEST.yml
│   │   └── config.yml
│   └── workflows/
│       ├── release-vscode.yml
│       ├── release.yml
│       └── test.yml
├── .gitignore
├── .nvmrc
├── .vscode/
│   └── settings.json
├── AGENTS.md
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── package.json
├── patches/
│   └── @clack__prompts@0.6.1.patch
├── src/
│   ├── cli.ts
│   ├── commands/
│   │   ├── aicommits.ts
│   │   ├── config.ts
│   │   ├── hook.ts
│   │   ├── model.ts
│   │   ├── pr.ts
│   │   ├── prepare-commit-msg-hook.ts
│   │   ├── setup.ts
│   │   └── update.ts
│   ├── feature/
│   │   ├── models.ts
│   │   └── providers/
│   │       ├── base.ts
│   │       ├── groq.ts
│   │       ├── index.ts
│   │       ├── lmstudio.ts
│   │       ├── ollama.ts
│   │       ├── openai.ts
│   │       ├── openaiCustom.ts
│   │       ├── openrouter.ts
│   │       ├── providers-data.ts
│   │       ├── together.ts
│   │       └── xai.ts
│   └── utils/
│       ├── auto-update.ts
│       ├── clipboard.ts
│       ├── commit-helpers.ts
│       ├── config-runtime.ts
│       ├── config-types.ts
│       ├── constants.ts
│       ├── error.ts
│       ├── fs.ts
│       ├── git.ts
│       ├── headless.ts
│       ├── openai.ts
│       └── prompt.ts
├── tests/
│   ├── fixtures/
│   │   ├── README.md
│   │   ├── chore.diff
│   │   ├── code-refactoring.diff
│   │   ├── code-style.diff
│   │   ├── continous-integration.diff
│   │   ├── deprecate-feature.diff
│   │   ├── documentation-changes.diff
│   │   ├── fix-nullpointer-exception.diff
│   │   ├── github-action-build-pipeline.diff
│   │   ├── new-feature.diff
│   │   ├── performance-improvement.diff
│   │   ├── remove-feature.diff
│   │   └── testing-react-application.diff
│   ├── index.ts
│   ├── specs/
│   │   ├── auto-update.ts
│   │   ├── cli/
│   │   │   ├── commits.ts
│   │   │   ├── error-cases.ts
│   │   │   ├── headless.ts
│   │   │   ├── index.ts
│   │   │   └── no-verify.ts
│   │   ├── config.ts
│   │   ├── git-hook.ts
│   │   ├── openai/
│   │   │   └── index.ts
│   │   └── togetherai/
│   │       └── index.ts
│   └── utils.ts
├── tsconfig.json
└── vscode-extension/
    ├── .gitignore
    ├── .vscode/
    │   └── launch.json
    ├── .vscodeignore
    ├── LICENSE
    ├── README.md
    ├── package.json
    ├── src/
    │   └── extension.ts
    └── tsconfig.json
Download .txt
SYMBOL INDEX (75 symbols across 13 files)

FILE: src/commands/pr.ts
  type GitProvider (line 14) | type GitProvider = 'github' | 'gitlab' | 'bitbucket' | 'azure';
  type RepoInfo (line 16) | interface RepoInfo {
  function parseRemoteUrl (line 22) | function parseRemoteUrl(remoteUrl: string): RepoInfo {
  function getPrUrl (line 48) | function getPrUrl(

FILE: src/commands/update.ts
  type PackageManagerInfo (line 11) | interface PackageManagerInfo {
  function getDistTag (line 19) | function getDistTag(version: string): string {
  function detectPackageManager (line 31) | function detectPackageManager(distTag: string): PackageManagerInfo {
  function getLatestVersion (line 85) | async function getLatestVersion(distTag: string): Promise<string | null> {

FILE: src/feature/models.ts
  type ModelObject (line 11) | interface ModelObject {
  type CacheEntry (line 17) | interface CacheEntry {
  constant CACHE_DURATION (line 22) | const CACHE_DURATION = 60 * 60 * 1000;
  type FetchModelsOptions (line 71) | interface FetchModelsOptions {

FILE: src/feature/providers/base.ts
  type ProviderDef (line 4) | type ProviderDef = {
  class Provider (line 17) | class Provider {
    method constructor (line 21) | constructor(def: ProviderDef, config: ValidConfig) {
    method name (line 26) | get name(): string {
    method displayName (line 30) | get displayName(): string {
    method getDefinition (line 34) | getDefinition(): ProviderDef {
    method setup (line 38) | async setup(): Promise<[string, string][]> {
    method getModels (line 81) | async getModels(): Promise<{ models: string[]; error?: string }> {
    method getApiKey (line 103) | getApiKey(): string | undefined {
    method getBaseUrl (line 107) | getBaseUrl(): string {
    method getDefaultModel (line 114) | getDefaultModel(): string {
    method getHighlightedModels (line 118) | getHighlightedModels(): string[] {
    method getHeaders (line 122) | getHeaders(): Record<string, string> | undefined {
    method validateConfig (line 126) | validateConfig(): { valid: boolean; errors: string[] } {

FILE: src/feature/providers/index.ts
  function getProvider (line 9) | function getProvider(config: ValidConfig): Provider | null {
  function getAvailableProviders (line 15) | function getAvailableProviders(): { value: string; label: string }[] {
  function getProviderBaseUrl (line 22) | function getProviderBaseUrl(providerName: string): string {
  function getProviderDef (line 27) | function getProviderDef(providerName: string): ProviderDef | undefined {

FILE: src/utils/auto-update.ts
  type AutoUpdateOptions (line 6) | interface AutoUpdateOptions {
  function parseVersion (line 14) | function parseVersion(version: string): {
  function compareVersions (line 45) | function compareVersions(v1: string, v2: string): number {
  function fetchLatestVersion (line 71) | async function fetchLatestVersion(
  function checkIfGlobalInstallation (line 97) | async function checkIfGlobalInstallation(packageName: string): Promise<b...
  function runBackgroundUpdate (line 107) | async function runBackgroundUpdate(
  function checkAndAutoUpdate (line 129) | async function checkAndAutoUpdate(

FILE: src/utils/clipboard.ts
  function copyToClipboard (line 9) | async function copyToClipboard(message: string): Promise<boolean> {

FILE: src/utils/config-types.ts
  type CommitType (line 5) | type CommitType = (typeof commitTypes)[number];
  method OPENAI_API_KEY (line 18) | OPENAI_API_KEY(key?: string) {
  method OPENAI_BASE_URL (line 21) | OPENAI_BASE_URL(key?: string) {
  method OPENAI_MODEL (line 24) | OPENAI_MODEL(key?: string) {
  method locale (line 27) | locale(locale?: string) {
  method generate (line 39) | generate(count?: string) {
  method type (line 49) | type(type?: string) {
  method proxy (line 60) | proxy(url?: string) {
  method timeout (line 68) | timeout(timeout?: string) {
  method 'max-length' (line 80) | 'max-length'(maxLength?: string) {
  type ConfigKeys (line 95) | type ConfigKeys = keyof typeof configParsers;
  type RawConfig (line 97) | type RawConfig = {
  type ValidConfig (line 101) | type ValidConfig = {

FILE: src/utils/error.ts
  class KnownError (line 5) | class KnownError extends Error {}

FILE: src/utils/openai.ts
  type GenerateCommitMessageOptions (line 85) | type GenerateCommitMessageOptions = {
  type GenerateCommitDescriptionOptions (line 240) | type GenerateCommitDescriptionOptions = {
  type CombineCommitMessagesOptions (line 353) | type CombineCommitMessagesOptions = {

FILE: tests/specs/openai/index.ts
  function runGenerateCommitMessage (line 133) | async function runGenerateCommitMessage(

FILE: tests/specs/togetherai/index.ts
  function runGenerateCommitMessage (line 55) | async function runGenerateCommitMessage(

FILE: vscode-extension/src/extension.ts
  constant TIMEOUT_MS (line 9) | const TIMEOUT_MS = 15000;
  constant PACKAGE_NAME (line 11) | const PACKAGE_NAME = 'aicommits';
  function activate (line 13) | function activate(context: vscode.ExtensionContext) {
  function checkCliOnActivation (line 65) | async function checkCliOnActivation() {
  function getCliVersion (line 85) | async function getCliVersion(): Promise<string | null> {
  function fetchLatestVersion (line 97) | async function fetchLatestVersion(distTag: string): Promise<string | nul...
  function parseVersion (line 115) | function parseVersion(version: string) {
  function compareVersions (line 136) | function compareVersions(v1: string, v2: string): number {
  function checkForCliUpdate (line 150) | async function checkForCliUpdate(): Promise<void> {
  function isCliInstalled (line 202) | async function isCliInstalled(): Promise<boolean> {
  function installCli (line 222) | async function installCli(): Promise<boolean> {
  function ensureCliInstalled (line 237) | async function ensureCliInstalled(): Promise<boolean> {
  function generateCommitMessage (line 259) | async function generateCommitMessage(
  function openSetupTerminal (line 343) | function openSetupTerminal() {
  function openTerminal (line 356) | function openTerminal(command: string) {
  function runCli (line 369) | function runCli(
  function commitWithMessage (line 418) | async function commitWithMessage(repo: any, message: string) {
  function deactivate (line 429) | function deactivate() {}
Condensed preview — 84 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (206K chars).
[
  {
    "path": ".editorconfig",
    "chars": 195,
    "preview": "root = true\n\n[*]\nindent_style = tab\nend_of_line = lf\ncharset = utf-8\ntrim_trailing_whitespace = true\ninsert_final_newlin"
  },
  {
    "path": ".gitattributes",
    "chars": 19,
    "preview": "* text=auto eol=lf\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/BUG_REPORT.yml",
    "chars": 1432,
    "preview": "name: Bug report\ndescription: File a bug report\nlabels: [bug, pending triage]\nbody:\n  - type: markdown\n    attributes:\n "
  },
  {
    "path": ".github/ISSUE_TEMPLATE/FEATURE_REQUEST.yml",
    "chars": 1137,
    "preview": "name: Feature request\ndescription: Suggest an idea for this project\nlabels: [feature, pending triage]\nbody:\n  - type: ma"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yml",
    "chars": 28,
    "preview": "blank_issues_enabled: false\n"
  },
  {
    "path": ".github/workflows/release-vscode.yml",
    "chars": 1136,
    "preview": "name: Release VSCode Extension\n\non:\n  push:\n    branches: [main]\n    paths:\n      - 'vscode-extension/**'\n  workflow_dis"
  },
  {
    "path": ".github/workflows/release.yml",
    "chars": 745,
    "preview": "name: Release\n\non:\n  push:\n    branches: [develop]\n\njobs:\n  release:\n    name: Release\n    runs-on: ubuntu-latest\n    ti"
  },
  {
    "path": ".github/workflows/test.yml",
    "chars": 904,
    "preview": "name: Test\n\non:\n  push:\n    branches: [main, develop]\n  pull_request:\n\njobs:\n  test:\n    name: Test\n    strategy:\n      "
  },
  {
    "path": ".gitignore",
    "chars": 273,
    "preview": "# macOS\n.DS_Store\n\n# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\nlerna-debug.log*\n\n# Dependency direc"
  },
  {
    "path": ".nvmrc",
    "chars": 9,
    "preview": "v22.14.0\n"
  },
  {
    "path": ".vscode/settings.json",
    "chars": 53,
    "preview": "{\n\t\"typescript.tsdk\": \"node_modules/typescript/lib\"\n}"
  },
  {
    "path": "AGENTS.md",
    "chars": 983,
    "preview": "# AGENTS.md\n\n## Commands\n- **Build:** `pnpm build` (uses pkgroll with minify)\n- **Type check:** `pnpm type-check` (runs "
  },
  {
    "path": "CONTRIBUTING.md",
    "chars": 1927,
    "preview": "# Contribution Guide\n\n## Setting up the project\n\nUse [nvm](https://nvm.sh) to use the appropriate Node.js version from `"
  },
  {
    "path": "LICENSE",
    "chars": 1068,
    "preview": "MIT License\n\nCopyright (c) Hassan El Mghari\n\nPermission is hereby granted, free of charge, to any person obtaining a cop"
  },
  {
    "path": "README.md",
    "chars": 11726,
    "preview": "<div align=\"center\">\n  <div>\n    <img src=\".github/screenshot.png\" alt=\"AI Commits\"/>\n    <img src=\"./aic.png\" width=\"50"
  },
  {
    "path": "package.json",
    "chars": 1097,
    "preview": "{\n\t\"name\": \"aicommits\",\n\t\"version\": \"0.0.0-semantic-release\",\n\t\"description\": \"Writes your git commit messages for you w"
  },
  {
    "path": "patches/@clack__prompts@0.6.1.patch",
    "chars": 846,
    "preview": "diff --git a/dist/index.d.ts b/dist/index.d.ts\nindex 693d552f60c8e0dfef11480da22fb844065b18eb..f74db21d7709c9f6693a218ce"
  },
  {
    "path": "src/cli.ts",
    "chars": 3376,
    "preview": "// Suppress AI SDK warnings (e.g., \"temperature is not supported for reasoning models\")\nglobalThis.AI_SDK_LOG_WARNINGS ="
  },
  {
    "path": "src/commands/aicommits.ts",
    "chars": 9706,
    "preview": "import { execa } from 'execa';\nimport { black, dim, green, red, yellow, bgCyan } from 'kolorist';\nimport { copyToClipboa"
  },
  {
    "path": "src/commands/config.ts",
    "chars": 1762,
    "preview": "import { command } from 'cleye';\nimport { red } from 'kolorist';\nimport { hasOwn } from '../utils/config-types.js';\nimpo"
  },
  {
    "path": "src/commands/hook.ts",
    "chars": 2997,
    "preview": "import fs from 'fs/promises';\nimport path from 'path';\nimport { fileURLToPath, pathToFileURL } from 'url';\nimport { gree"
  },
  {
    "path": "src/commands/model.ts",
    "chars": 1824,
    "preview": "import { command } from 'cleye';\nimport { outro, log } from '@clack/prompts';\nimport { getConfig, setConfigs } from '../"
  },
  {
    "path": "src/commands/pr.ts",
    "chars": 8976,
    "preview": "import { command } from 'cleye';\nimport { execa } from 'execa';\nimport { black, green, bgCyan } from 'kolorist';\nimport "
  },
  {
    "path": "src/commands/prepare-commit-msg-hook.ts",
    "chars": 3693,
    "preview": "import fs from 'fs/promises';\nimport { intro, outro, spinner } from '@clack/prompts';\nimport { black, green, red, bgCyan"
  },
  {
    "path": "src/commands/setup.ts",
    "chars": 4738,
    "preview": "import { execSync } from 'child_process';\nimport { command } from 'cleye';\nimport { select, text, outro, isCancel, confi"
  },
  {
    "path": "src/commands/update.ts",
    "chars": 5145,
    "preview": "import { command } from 'cleye';\nimport { execSync, exec } from 'child_process';\nimport { promisify } from 'util';\nimpor"
  },
  {
    "path": "src/feature/models.ts",
    "chars": 9956,
    "preview": "// Model filtering, fetching, and selection utilities\nimport fs from 'fs/promises';\nimport path from 'path';\nimport os f"
  },
  {
    "path": "src/feature/providers/base.ts",
    "chars": 3417,
    "preview": "import { fetchModels } from '../models.js';\nimport type { ValidConfig } from '../../utils/config-types.js';\n\nexport type"
  },
  {
    "path": "src/feature/providers/groq.ts",
    "chars": 478,
    "preview": "import { ProviderDef } from './base.js';\n\nexport const GroqProvider: ProviderDef = {\n\tname: 'groq',\n\tdisplayName: 'Groq'"
  },
  {
    "path": "src/feature/providers/index.ts",
    "chars": 966,
    "preview": "import { Provider, type ProviderDef } from './base.js';\nimport type { ValidConfig } from '../../utils/config-types.js';\n"
  },
  {
    "path": "src/feature/providers/lmstudio.ts",
    "chars": 443,
    "preview": "import { ProviderDef } from './base.js';\n\nexport const LMStudioProvider: ProviderDef = {\n\tname: 'lmstudio',\n\tdisplayName"
  },
  {
    "path": "src/feature/providers/ollama.ts",
    "chars": 394,
    "preview": "import { ProviderDef } from './base.js';\n\nexport const OllamaProvider: ProviderDef = {\n\tname: 'ollama',\n\tdisplayName: 'O"
  },
  {
    "path": "src/feature/providers/openai.ts",
    "chars": 583,
    "preview": "import { ProviderDef } from './base.js';\n\nexport const OpenAiProvider: ProviderDef = {\n\tname: 'openai',\n\tdisplayName: 'O"
  },
  {
    "path": "src/feature/providers/openaiCustom.ts",
    "chars": 347,
    "preview": "import { ProviderDef } from './base.js';\n\nexport const OpenAiCustom: ProviderDef = {\n\tname: 'custom',\n\tdisplayName: 'Cus"
  },
  {
    "path": "src/feature/providers/openrouter.ts",
    "chars": 532,
    "preview": "import { ProviderDef } from './base.js';\n\nexport const OpenRouterProvider: ProviderDef = {\n\tname: 'openrouter',\n\tdisplay"
  },
  {
    "path": "src/feature/providers/providers-data.ts",
    "chars": 546,
    "preview": "import { TogetherProvider } from './together.js';\nimport { OpenAiProvider } from './openai.js';\nimport { OllamaProvider "
  },
  {
    "path": "src/feature/providers/together.ts",
    "chars": 586,
    "preview": "import { ProviderDef } from './base.js';\n\nexport const TogetherProvider: ProviderDef = {\n\tname: 'togetherai',\n\tdisplayNa"
  },
  {
    "path": "src/feature/providers/xai.ts",
    "chars": 437,
    "preview": "import { ProviderDef } from './base.js';\n\nexport const XAiProvider: ProviderDef = {\n\tname: 'xai',\n\tdisplayName: 'xAI',\n\t"
  },
  {
    "path": "src/utils/auto-update.ts",
    "chars": 5226,
    "preview": "import { exec } from 'child_process';\nimport { promisify } from 'util';\n\nconst execAsync = promisify(exec);\n\nexport inte"
  },
  {
    "path": "src/utils/clipboard.ts",
    "chars": 1132,
    "preview": "import { execa } from 'execa';\n\n/**\n * Copy text to the system clipboard using native CLI tools.\n * macOS: pbcopy\n * Win"
  },
  {
    "path": "src/utils/commit-helpers.ts",
    "chars": 1682,
    "preview": "import { KnownError } from './error.js';\nimport { isInteractive } from './headless.js';\n\nexport const sleep = (ms: numbe"
  },
  {
    "path": "src/utils/config-runtime.ts",
    "chars": 3111,
    "preview": "import fs from 'fs/promises';\nimport path from 'path';\nimport os from 'os';\nimport ini from 'ini';\nimport { fileExists }"
  },
  {
    "path": "src/utils/config-types.ts",
    "chars": 2664,
    "preview": "import { KnownError } from './error.js';\n\nconst commitTypes = ['plain', 'conventional', 'gitmoji', 'subject+body'] as co"
  },
  {
    "path": "src/utils/constants.ts",
    "chars": 183,
    "preview": "// Label formatters\nexport const CURRENT_LABEL_FORMAT = (model: string) => `✅ ${model} (current)`;\nexport const PREFERRE"
  },
  {
    "path": "src/utils/error.ts",
    "chars": 798,
    "preview": "import { dim, red } from 'kolorist';\nimport pkg from '../../package.json';\nconst { version } = pkg;\n\nexport class KnownE"
  },
  {
    "path": "src/utils/fs.ts",
    "chars": 214,
    "preview": "import fs from 'fs/promises';\n\n// lstat is used because this is also used to check if a symlink file exists\nexport const"
  },
  {
    "path": "src/utils/git.ts",
    "chars": 2648,
    "preview": "import { execa } from 'execa';\nimport { KnownError } from './error.js';\n\nexport const assertGitRepo = async () => {\n\tcon"
  },
  {
    "path": "src/utils/headless.ts",
    "chars": 189,
    "preview": "export const isHeadless = () => !process.stdin.isTTY || !process.stdout.isTTY;\n\nexport const isInteractive = () =>\n\tBool"
  },
  {
    "path": "src/utils/openai.ts",
    "chars": 12225,
    "preview": "import { generateText } from 'ai';\nimport { createOpenAI } from '@ai-sdk/openai';\nimport { createOpenAICompatible } from"
  },
  {
    "path": "src/utils/prompt.ts",
    "chars": 6996,
    "preview": "import type { CommitType } from './config-types.js';\n\nexport const commitTypeFormats: Record<CommitType, string> = {\n\tpl"
  },
  {
    "path": "tests/fixtures/README.md",
    "chars": 313,
    "preview": "# Generating diffs\n\n1. Instruct ChatGPT with the following command:\n```\nI want you to act as a git cli\nI will give you t"
  },
  {
    "path": "tests/fixtures/chore.diff",
    "chars": 448,
    "preview": "diff --git a/package.json b/package.json\nindex 2a7398e..6b2a3f0 100644\n--- a/package.json\n+++ b/package.json\n@@ -3,7 +3,"
  },
  {
    "path": "tests/fixtures/code-refactoring.diff",
    "chars": 844,
    "preview": "diff --git a/old_example.ts b/new_example.ts\nindex 1234567..abcdefg 100644\n--- a/old_example.ts\n+++ b/new_example.ts\n\n@@"
  },
  {
    "path": "tests/fixtures/code-style.diff",
    "chars": 711,
    "preview": "diff --git a/src/app.js b/src/app.js\nindex 8741c37..91b2e74 100644\n--- a/src/app.js\n+++ b/src/app.js\n@@ -10,12 +10,12 @@"
  },
  {
    "path": "tests/fixtures/continous-integration.diff",
    "chars": 627,
    "preview": "diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml\nnew file mode 100644\nindex 0000000..b6e5789\n--- /dev/nu"
  },
  {
    "path": "tests/fixtures/deprecate-feature.diff",
    "chars": 752,
    "preview": "diff --git a/old_feature.py b/old_feature.py\nindex 1234567..abcdefg 100644\n--- a/old_feature.py\n+++ b/old_feature.py\n@@ "
  },
  {
    "path": "tests/fixtures/documentation-changes.diff",
    "chars": 1069,
    "preview": "diff --git a/README.md b/README.md\nindex a0c3e1b..9d1b6f8 100644\n--- a/README.md\n+++ b/README.md\n@@ -1,6 +1,11 @@\n # My "
  },
  {
    "path": "tests/fixtures/fix-nullpointer-exception.diff",
    "chars": 668,
    "preview": "diff --git a/src/main/java/com/example/MyClass.java b/src/main/java/com/example/MyClass.java\nindex e7d8f38..caab7f1 1006"
  },
  {
    "path": "tests/fixtures/github-action-build-pipeline.diff",
    "chars": 651,
    "preview": "diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml\nindex 1d07d31..085eb64 100644\n--- a/.github/workf"
  },
  {
    "path": "tests/fixtures/new-feature.diff",
    "chars": 1160,
    "preview": "diff --git a/src/features/newFeature.js b/src/features/newFeature.js\nnew file mode 100644\nindex 0000000..b6e5789\n--- /de"
  },
  {
    "path": "tests/fixtures/performance-improvement.diff",
    "chars": 668,
    "preview": "diff --git a/src/loop.js b/src/loop.js\nindex 1d45a2b..8c52e81 100644\n--- a/src/loop.js\n+++ b/src/loop.js\n@@ -5,14 +5,14 "
  },
  {
    "path": "tests/fixtures/remove-feature.diff",
    "chars": 758,
    "preview": "diff --git a/Controllers/FeatureController.cs b/Controllers/FeatureController.cs\nindex 8a3b7c1..3e29f9a 100644\n--- a/Con"
  },
  {
    "path": "tests/fixtures/testing-react-application.diff",
    "chars": 857,
    "preview": "diff --git a/src/components/MyComponent.test.js b/src/components/MyComponent.test.js\nindex 37eabf2..976c6bf 100644\n--- a"
  },
  {
    "path": "tests/index.ts",
    "chars": 376,
    "preview": "import { describe } from 'manten';\n\ndescribe('aicommits', ({ runTestSuite }) => {\n\trunTestSuite(import('./specs/cli/inde"
  },
  {
    "path": "tests/specs/auto-update.ts",
    "chars": 1036,
    "preview": "import { testSuite, expect } from 'manten';\nimport { checkAndAutoUpdate } from '../../src/utils/auto-update.js';\n\nexport"
  },
  {
    "path": "tests/specs/cli/commits.ts",
    "chars": 13096,
    "preview": "import { testSuite, expect } from 'manten';\nimport {\n\tcreateFixture,\n\tcreateGit,\n\tfiles,\n} from '../../utils.js';\n\nexpor"
  },
  {
    "path": "tests/specs/cli/error-cases.ts",
    "chars": 1082,
    "preview": "import { testSuite, expect } from 'manten';\nimport { createFixture, createGit } from '../../utils.js';\n\nexport default t"
  },
  {
    "path": "tests/specs/cli/headless.ts",
    "chars": 1465,
    "preview": "import { testSuite, expect } from 'manten';\nimport { createFixture, createGit } from '../../utils.js';\n\nexport default t"
  },
  {
    "path": "tests/specs/cli/index.ts",
    "chars": 299,
    "preview": "import { testSuite } from 'manten';\n\nexport default testSuite(({ describe }) => {\n\tdescribe('CLI', ({ runTestSuite }) =>"
  },
  {
    "path": "tests/specs/cli/no-verify.ts",
    "chars": 617,
    "preview": "import { testSuite, expect } from 'manten';\nimport { execSync } from 'child_process';\nimport { existsSync } from 'fs';\ni"
  },
  {
    "path": "tests/specs/config.ts",
    "chars": 3431,
    "preview": "import fs from 'fs/promises';\nimport path from 'path';\nimport { testSuite, expect } from 'manten';\nimport { createFixtur"
  },
  {
    "path": "tests/specs/git-hook.ts",
    "chars": 1838,
    "preview": "import path from 'path';\nimport { testSuite, expect } from 'manten';\nimport {\n\tcreateFixture,\n\tcreateGit,\n\tfiles,\n} from"
  },
  {
    "path": "tests/specs/openai/index.ts",
    "chars": 6715,
    "preview": "import { expect, testSuite } from 'manten';\nimport {\n\tgenerateCommitMessage,\n\tgenerateCommitDescription,\n} from '../../."
  },
  {
    "path": "tests/specs/togetherai/index.ts",
    "chars": 2484,
    "preview": "import { expect, testSuite } from 'manten';\nimport { generateCommitMessage } from '../../../src/utils/openai.js';\nimport"
  },
  {
    "path": "tests/utils.ts",
    "chars": 1689,
    "preview": "import path from 'path';\nimport fs from 'fs/promises';\nimport { execa, execaNode, type Options } from 'execa';\nimport {\n"
  },
  {
    "path": "tsconfig.json",
    "chars": 249,
    "preview": "{\n\t\"compilerOptions\": {\n\t\t\"noEmit\": true,\n\t\t\"target\": \"ES2020\",\n\t\t\"module\": \"Node16\",\n\t\t\"strict\": true,\n\t\t\"resolveJsonMo"
  },
  {
    "path": "vscode-extension/.gitignore",
    "chars": 25,
    "preview": "node_modules\ndist\n*.vsix\n"
  },
  {
    "path": "vscode-extension/.vscode/launch.json",
    "chars": 277,
    "preview": "{\n\t\"version\": \"0.2.0\",\n\t\"configurations\": [\n\t\t{\n\t\t\t\"name\": \"Run Extension\",\n\t\t\t\"type\": \"extension\",\n\t\t\t\"request\": \"launc"
  },
  {
    "path": "vscode-extension/.vscodeignore",
    "chars": 73,
    "preview": ".vscode/**\n.vscode-test/**\nsrc/**\n.gitignore\n**/*.map\n**/node_modules/**\n"
  },
  {
    "path": "vscode-extension/LICENSE",
    "chars": 1068,
    "preview": "MIT License\n\nCopyright (c) Hassan El Mghari\n\nPermission is hereby granted, free of charge, to any person obtaining a cop"
  },
  {
    "path": "vscode-extension/README.md",
    "chars": 1901,
    "preview": "# AI Commits VSCode Extension\n\nGenerate git commit messages using AI directly from VSCode's Git interface.\n\n## Features\n"
  },
  {
    "path": "vscode-extension/package.json",
    "chars": 2793,
    "preview": "{\n\t\"name\": \"aicommits\",\n\t\"displayName\": \"AI Commits\",\n\t\"description\": \"Generate git commit messages using AI directly fr"
  },
  {
    "path": "vscode-extension/src/extension.ts",
    "chars": 11236,
    "preview": "import * as vscode from 'vscode';\nimport { spawn } from 'child_process';\nimport { promisify } from 'util';\nimport { exec"
  },
  {
    "path": "vscode-extension/tsconfig.json",
    "chars": 388,
    "preview": "{\n  \"compilerOptions\": {\n    \"module\": \"commonjs\",\n    \"target\": \"ES2020\",\n    \"lib\": [\"ES2020\"],\n    \"sourceMap\": true,"
  }
]

About this extraction

This page contains the full source code of the Nutlope/aicommits GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 84 files (178.9 KB), approximately 51.4k tokens, and a symbol index with 75 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!