[
  {
    "path": ".editorconfig",
    "content": "root = true\n\n[*]\nindent_style = tab\nend_of_line = lf\ncharset = utf-8\ntrim_trailing_whitespace = true\ninsert_final_newline = true\nquote_type = single\n\n[*.yml]\nindent_style = space\nindent_size = 2\n"
  },
  {
    "path": ".gitattributes",
    "content": "* text=auto eol=lf\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/BUG_REPORT.yml",
    "content": "name: Bug report\ndescription: File a bug report\nlabels: [bug, pending triage]\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        Thank you for taking the time to file this bug report.\n  - type: textarea\n    attributes:\n      label: Bug description\n      description: A clear and concise description of the bug.\n      placeholder: |\n        <!--\n          What did you do, what did you expect to happen, and what happened instead?\n        -->\n    validations:\n      required: true\n  - type: input\n    attributes:\n      label: aicommits version\n      description: |\n        Run and paste the output of:\n        ```sh\n        aicommits --version\n        ```\n      placeholder: v0.0.0\n    validations:\n      required: true\n  - type: textarea\n    attributes:\n      label: Environment\n      description: |\n        Run and paste the output of:\n        ```sh\n        npx envinfo --system --binaries\n        ```\n        \n        This information is used to for reproduction and debugging.\n      placeholder: |\n        System:\n          OS:\n          CPU:\n          Shell:\n        Binaries:\n          Node:\n          npm:\n      render: shell\n    validations:\n      required: true\n  - type: checkboxes\n    attributes:\n      label: Can you contribute a fix?\n      description: We would love it if you can open a pull request to fix this bug!\n      options:\n        - label: I’m interested in opening a pull request for this issue.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/FEATURE_REQUEST.yml",
    "content": "name: Feature request\ndescription: Suggest an idea for this project\nlabels: [feature, pending triage]\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        Thank you for taking the time to file this feature request.\n  - type: textarea\n    attributes:\n      label: Feature request\n      description: A description of the feature you would like.\n    validations:\n      required: true\n  - type: textarea\n    attributes:\n      label: Why?\n      description: |\n        Describe the problem you’re tackling with this feature request.\n      placeholder: |\n        <!--\n          What’s the motivation behind this issue?\n\n          eg. “I’m frustrated when...”\n        -->\n    validations:\n      required: true\n  - type: textarea\n    attributes:\n      label: Alternatives\n      description: |\n        Have you considered alternative solutions? Is there a workaround?\n      placeholder: |\n        <!--\n          Do you have alternative proposals?\n\n          Do you have a workaround?\n        -->\n  - type: textarea\n    attributes:\n      label: Additional context\n      description: |\n        Anything else to share? Screenshots? Links?\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yml",
    "content": "blank_issues_enabled: false\n"
  },
  {
    "path": ".github/workflows/release-vscode.yml",
    "content": "name: Release VSCode Extension\n\non:\n  push:\n    branches: [main]\n    paths:\n      - 'vscode-extension/**'\n  workflow_dispatch:\n\njobs:\n  release:\n    name: Publish Extension\n    runs-on: ubuntu-latest\n    timeout-minutes: 10\n    permissions:\n      contents: write\n\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v5\n\n      - name: Setup Node.js\n        uses: actions/setup-node@v6\n        with:\n          node-version-file: '.nvmrc'\n\n      - name: Setup pnpm\n        uses: pnpm/action-setup@v4.2.0\n        with:\n          version: 9\n\n      - name: Install dependencies\n        working-directory: vscode-extension\n        run: pnpm install\n\n      - name: Build\n        working-directory: vscode-extension\n        run: pnpm run compile\n\n      - name: Publish to VSCode Marketplace\n        working-directory: vscode-extension\n        env:\n          VSCE_PAT: ${{ secrets.VSCE_PAT }}\n        run: pnpm run package && npx @vscode/vsce publish --no-dependencies\n\n      - name: Upload VSIX artifact\n        uses: actions/upload-artifact@v4\n        with:\n          name: vscode-extension\n          path: vscode-extension/*.vsix\n"
  },
  {
    "path": ".github/workflows/release.yml",
    "content": "name: Release\n\non:\n  push:\n    branches: [develop]\n\njobs:\n  release:\n    name: Release\n    runs-on: ubuntu-latest\n    timeout-minutes: 10\n    permissions:\n      contents: write\n      issues: write\n      id-token: write\n\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v5\n\n      - name: Setup Node.js\n        uses: actions/setup-node@v6\n        with:\n          node-version-file: '.nvmrc'\n\n      - name: Setup pnpm\n        uses: pnpm/action-setup@v4.2.0\n        with:\n          version: 9\n          run_install: true\n\n      - name: Release\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}\n          NPM_CONFIG_PROVENANCE: false\n        run: pnpm dlx semantic-release\n"
  },
  {
    "path": ".github/workflows/test.yml",
    "content": "name: Test\n\non:\n  push:\n    branches: [main, develop]\n  pull_request:\n\njobs:\n  test:\n    name: Test\n    strategy:\n      matrix:\n        os: [ubuntu-latest, windows-latest]\n\n    runs-on: ${{ matrix.os }}\n    timeout-minutes: 10\n\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v5\n\n      - name: Use Node.js\n        uses: actions/setup-node@v6\n        with:\n          node-version-file: '.nvmrc'\n\n      - name: Setup pnpm\n        uses: pnpm/action-setup@v4.2.0\n        with:\n          version: 9\n          run_install: true\n\n      - name: Type check\n        run: pnpm type-check\n\n      - name: Build\n        run: pnpm build\n\n      - name: Install tinyproxy\n        if: matrix.os == 'ubuntu-latest'\n        run: |\n          sudo apt-get install tinyproxy\n          tinyproxy\n      - name: Test\n        env:\n          OPENAI_KEY: ${{ secrets.OPENAI_KEY }}\n        run: |\n          pnpm test\n"
  },
  {
    "path": ".gitignore",
    "content": "# 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 directories\nnode_modules/\n\n# Output of 'npm pack'\n*.tgz\n\n# dotenv environment variables file\n.env\n.env.test\n\n# Distribution\ndist\n\n# Eslint cache\n.eslintcache\n"
  },
  {
    "path": ".nvmrc",
    "content": "v22.14.0\n"
  },
  {
    "path": ".vscode/settings.json",
    "content": "{\n\t\"typescript.tsdk\": \"node_modules/typescript/lib\"\n}"
  },
  {
    "path": "AGENTS.md",
    "content": "# AGENTS.md\n\n## Commands\n- **Build:** `pnpm build` (uses pkgroll with minify)\n- **Type check:** `pnpm type-check` (runs tsc)\n- **Test all:** `pnpm test` (runs `tsx tests`)\n- **Test single file:** `pnpm tsx tests/specs/<file>.ts`\n\n## Architecture\nCLI tool that generates git commit messages using AI (OpenAI/Together AI or any OpenAI compatible endpoint).\n- `src/cli.ts` - Main entry point using cleye for CLI parsing\n- `src/commands/` - CLI subcommands (aicommits, config, hook, model, pr, setup)\n- `src/utils/` - Shared utilities (git, openai, config, prompts)\n- `src/feature/` - Feature-specific logic\n- `tests/specs/` - Test files using manten framework\n\n## Code Style\n- **Indentation:** Tabs (spaces for YAML)\n- **Quotes:** Single quotes\n- **Line endings:** LF\n- **Module system:** ESM (`\"type\": \"module\"`)\n- **TypeScript:** Strict mode, ES2020 target, Node16 module resolution\n- **Imports:** Use `.js` extension for local imports (ESM requirement)\n- **Final newline:** Required\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contribution Guide\n\n## Setting up the project\n\nUse [nvm](https://nvm.sh) to use the appropriate Node.js version from `.nvmrc`:\n\n```sh\nnvm i\n```\n\nInstall the dependencies using pnpm:\n\n```sh\npnpm i\n```\n\n## Building the project\n\nRun the `build` script:\n\n```sh\npnpm build\n```\n\nThe 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.\n\n### Development (watch) mode\n\nDuring development, you can use the watch flag (`--watch, -w`) to automatically rebuild the package on file changes:\n\n```sh\npnpm build -w\n```\n\n## Running the package locally\n\nSince 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.\n\nYou can run the distribution file in any directory:\n\n```sh\n./dist/cli.mjs\n```\n\nOr in non-UNIX environments, you can use Node.js to run the file:\n\n```sh\nnode ./dist/cli.mjs\n```\n\n## Testing\n\nTesting requires passing in `OPENAI_API_KEY` as an environment variable:\n\n```sh\nOPENAI_API_KEY=<your OPENAI key> pnpm test\n```\n\nYou can still run tests that don't require `OPENAI_API_KEY` but will not test the main functionality:\n\n```\npnpm test\n```\n\n## Using & testing your changes\n\nLet'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):\n\nPublish your current branch to a `npm/*` branch on your GitHub repository:\n\n```sh\n$ pnpm dlx git-publish\n\n✔ Successfully published branch! Install with command:\n  → npm i 'Nutlope/aicommits#npm/develop'\n```\n\n> Note: The `Nutlope/aicommits` will be replaced with your fork's URL.\n\nNow, you can run the branch in your project:\n\n```sh\n$ pnpm dlx 'Nutlope/aicommits#npm/develop' # same as running `npx aicommits`\n```\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) Hassan El Mghari\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "<div align=\"center\">\n  <div>\n    <img src=\".github/screenshot.png\" alt=\"AI Commits\"/>\n    <img src=\"./aic.png\" width=\"50\" alt=\"AI Commits\"/>\n    <h1 align=\"center\">AI Commits</h1>\n  </div>\n  <p>A CLI that writes your git commit messages for you with AI. Never write a commit message again.</p>\n  <a href=\"https://www.npmjs.com/package/aicommits\"><img src=\"https://img.shields.io/npm/v/aicommits\" alt=\"Current version\"></a>\n  <a href=\"https://www.npmjs.com/package/aicommits\"><img src=\"https://img.shields.io/npm/dt/aicommits\" alt=\"Downloads\"></a>\n</div>\n\n---\n\n## Setup\n\n> The minimum supported version of Node.js is v22. Check your Node.js version with `node --version`.\n\n1. Install _aicommits_:\n\n   ```sh\n   npm install -g aicommits\n   ```\n\n2. Run the setup command to choose your AI provider:\n\n   ```sh\n   aicommits setup\n   ```\n\nThis will guide you through:\n\n- Selecting your AI provider (sets the `provider` config)\n- Configuring your API key\n- **Automatically fetching and selecting from available models** (when supported)\n- **Choosing your preferred commit message format** (plain, conventional, or gitmoji)\n\n  Supported providers include:\n\n  - **TogetherAI** (recommended) - Get your API key from [TogetherAI](https://api.together.ai/)\n  - **OpenAI** - Get your API key from [OpenAI API Keys page](https://platform.openai.com/account/api-keys)\n  - **Groq** - Get your API key from [Groq Console](https://console.groq.com/keys)\n  - **xAI** - Get your API key from [xAI Console](https://console.x.ai/)\n  - **OpenRouter** - Get your API key from [OpenRouter](https://openrouter.ai/keys)\n  - **Ollama** (local) - Run AI models locally with [Ollama](https://ollama.ai)\n  - **LM Studio** (local) - No API key required. Runs on your computer via [LM Studio](https://lmstudio.ai/)\n  - **Custom OpenAI-compatible endpoint** - Use any service that implements the OpenAI API\n\n  **For CI/CD environments**, you can also set up configuration via the config file:\n\n  ```bash\n  aicommits config set OPENAI_API_KEY=\"your_api_key_here\"\n  aicommits config set OPENAI_BASE_URL=\"your_api_endpoint\"  # Optional, for custom endpoints\n  aicommits config set OPENAI_MODEL=\"your_model_choice\"     # Optional, defaults to provider default\n  ```\n\n  > **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.\n\n  This will create a `.aicommits` file in your home directory.\n\n### Upgrading\n\nCheck the installed version with:\n\n```sh\naicommits --version\n```\n\nTo update to the latest version, run:\n\n```sh\naicommits update\n```\n\nThis will automatically detect your package manager (npm, pnpm, yarn, or bun) and update using the correct command.\n\nAlternatively, you can manually update:\n\n```sh\nnpm install -g aicommits\n```\n\n## Usage\n\n### CLI mode\n\nYou can call `aicommits` directly to generate a commit message for your staged changes:\n\n```sh\ngit add <files...>\naicommits\n```\n\n`aicommits` passes down unknown flags to `git commit`, so you can pass in [`commit` flags](https://git-scm.com/docs/git-commit).\n\nFor example, you can stage all changes in tracked files with as you commit:\n\n```sh\naicommits --all # or -a\n```\n\n> 👉 **Tip:** Use the `aic` alias if `aicommits` is too long for you.\n\n#### CLI Options\n\n- `--all` or `-a`: Automatically stage changes in tracked files for the commit (default: **false**)\n- `--clipboard` or `-c`: Copy the selected message to the clipboard instead of committing (default: **false**)\n- `--generate` or `-g`: Number of messages to generate (default: **1**)\n- `--exclude` or `-x`: Files to exclude from AI analysis\n- `--type` or `-t`: Git commit message format (default: **plain**). Supports `plain`, `conventional`, and `gitmoji`\n- `--prompt` or `-p`: Custom prompt to guide the LLM behavior (e.g., specific language, style instructions)\n- `--no-verify` or `-n`: Bypass pre-commit hooks while committing (default: **false**)\n- `--yes` or `-y`: Skip confirmation when committing after message generation (default: **false**)\n\n#### Generate multiple recommendations\n\nSometimes 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:\n\n```sh\naicommits --generate <i> # or -g <i>\n```\n\n> Warning: this uses more tokens, meaning it costs more.\n\n#### Commit Message Formats\n\nYou can choose from four different commit message formats:\n\n- **plain** (default): Simple, unstructured commit messages\n- **conventional**: [Conventional Commits](https://conventionalcommits.org/) format with type and scope\n- **gitmoji**: Emoji-based commit messages\n- **subject+body**: Git-style subject line plus a body (description) generated from the diff\n\nUse the `--type` flag to specify the format:\n\n```sh\naicommits --type conventional # or -t conventional\naicommits --type gitmoji       # or -t gitmoji\naicommits --type plain         # or -t plain (default)\naicommits --type subject+body  # or -t subject+body (subject + body)\n```\n\nThis feature is useful if your project follows a specific commit message standard or if you're using tools that rely on these commit formats.\n\n#### Custom Prompts\n\nYou can customize the LLM's behavior with the `--prompt` flag to guide commit message generation:\n\n```sh\n# Write commit messages in a specific language\naicommits -p \"Write commit messages in Italian\"\n\n# Focus on specific aspects of the changes\naicommits -p \"Focus on performance implications of changes\"\n\n# Use a specific style or tone\naicommits -p \"Use technical jargon suitable for senior developers\"\n\n# Include specific details in the message\naicommits -p \"Always mention the specific function names and file paths changed\"\n```\n\n### Git hook\n\nYou 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.\n\n#### Install\n\nIn the Git repository you want to install the hook in:\n\n```sh\naicommits hook install\n```\n\n#### Uninstall\n\nIn the Git repository you want to uninstall the hook from:\n\n```sh\naicommits hook uninstall\n```\n\n#### Usage\n\n1. Stage your files and commit:\n\n   ```sh\n   git add <files...>\n   git commit # Only generates a message when it's not passed in\n   ```\n\n   > If you ever want to write your own message instead of generating one, you can simply pass one in: `git commit -m \"My message\"`\n\n2. 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.\n\n3. Save and close the editor to commit!\n\n### Environment Variables\n\nYou can also configure aicommits using environment variables instead of the config file.\n\n**Example:**\n\n```bash\nexport OPENAI_API_KEY=\"sk-...\"\nexport OPENAI_BASE_URL=\"https://api.example.com\"\nexport OPENAI_MODEL=\"gpt-4\"\naicommits  # Uses environment variables\n```\n\nConfiguration settings are resolved in the following order of precedence:\n\n1. Command-line arguments\n2. Environment variables\n3. Configuration file\n4. Default values\n\n## Configuration\n\n### Viewing current configuration\n\nTo view all current configuration options that differ from defaults, run:\n\n```sh\naicommits config\n```\n\nThis 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)\".\n\n### Changing your model\n\nTo interactively select or change your AI model, run:\n\n```sh\naicommits model\n```\n\nThis will:\n\n- Show your current provider and model\n- Fetch available models from your provider's API\n- Let you select from available models or enter a custom model name\n- Update your configuration automatically\n\n### Updating aicommits\n\nTo update to the latest version, run:\n\n```sh\naicommits update\n```\n\nThis will:\n\n- Check for the latest version on npm\n- Detect your package manager (npm, pnpm, yarn, or bun)\n- Update using the appropriate command\n- Show progress and confirm when complete\n\n### Reading a configuration value\n\nTo retrieve a configuration option, use the command:\n\n```sh\naicommits config get <key>\n```\n\nFor example, to retrieve the API key, you can use:\n\n```sh\naicommits config get OPENAI_API_KEY\n```\n\nYou can also retrieve multiple configuration options at once by separating them with spaces:\n\n```sh\naicommits config get OPENAI_API_KEY generate\n```\n\n### Setting a configuration value\n\nTo set a configuration option, use the command:\n\n```sh\naicommits config set <key>=<value>\n```\n\nFor example, to set the API key, you can use:\n\n```sh\naicommits config set OPENAI_API_KEY=<your-api-key>\n```\n\nYou can also set multiple configuration options at once by separating them with spaces, like\n\n```sh\naicommits config set OPENAI_API_KEY=<your-api-key> generate=3 locale=en\n```\n\n### Config Options\n\n#### OPENAI_API_KEY\n\nYour OpenAI API key or custom provider API Key\n\n#### OPENAI_BASE_URL\n\nCustom OpenAI-compatible API endpoint URL.\n\n#### OPENAI_MODEL\n\nModel to use for OpenAI-compatible providers.\n\n#### provider\n\nThe selected AI provider. Set automatically during `aicommits setup`. Valid values: `openai`, `togetherai`, `groq`, `xai`, `openrouter`, `ollama`, `lmstudio`, `custom`.\n\n#### locale\n\nDefault: `en`\n\nThe locale to use for the generated commit messages. Consult the list of codes in: https://wikipedia.org/wiki/List_of_ISO_639-1_codes.\n\n#### generate\n\nDefault: `1`\n\nThe number of commit messages to generate to pick from.\n\nNote, this will use more tokens as it generates more results.\n\n#### timeout\n\nThe timeout for network requests to the OpenAI API in milliseconds.\n\nDefault: `10000` (10 seconds)\n\n```sh\naicommits config set timeout=20000 # 20s\n```\n\n#### max-length\n\nThe maximum character length of the generated commit message.\n\nDefault: `72`\n\n```sh\naicommits config set max-length=100\n```\n\n#### type\n\nDefault: `plain`\n\nThe type of commit message to generate. Available options:\n\n- `plain`: Simple, unstructured commit messages\n- `conventional`: Conventional Commits format with type and scope\n- `gitmoji`: Emoji-based commit messages\n\nExamples:\n\n```sh\naicommits config set type=conventional\naicommits config set type=gitmoji\naicommits config set type=plain\n```\n\n## How it works\n\nThis 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.\n\nVideo coming soon where I rebuild it from scratch to show you how to easily build your own CLI tools powered by AI.\n\n## Maintainers\n\n- **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)\n\n- **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)\n\n- **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)\n\n## Contributing\n\nIf 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"
  },
  {
    "path": "package.json",
    "content": "{\n\t\"name\": \"aicommits\",\n\t\"version\": \"0.0.0-semantic-release\",\n\t\"description\": \"Writes your git commit messages for you with AI\",\n\t\"keywords\": [\n\t\t\"ai\",\n\t\t\"git\",\n\t\t\"commit\",\n\t\t\"code changes\"\n\t],\n\t\"license\": \"MIT\",\n\t\"repository\": {\n\t\t\"type\": \"git\",\n\t\t\"url\": \"git+https://github.com/Nutlope/aicommits.git\"\n\t},\n\t\"author\": \"Hassan El Mghari (@nutlope)\",\n\t\"type\": \"module\",\n\t\"files\": [\n\t\t\"dist\"\n\t],\n\t\"bin\": {\n\t\t\"aicommits\": \"dist/cli.mjs\",\n\t\t\"aic\": \"dist/cli.mjs\"\n\t},\n\t\"scripts\": {\n\t\t\"build\": \"pkgroll --minify\",\n\t\t\"lint\": \"\",\n\t\t\"type-check\": \"tsc\",\n\t\t\"test\": \"tsx tests\",\n\t\t\"prepack\": \"pnpm build && clean-pkg-json\"\n\t},\n\t\"devDependencies\": {\n\t\t\"@clack/prompts\": \"^0.11.0\",\n\t\t\"@types/ini\": \"^1.3.31\",\n\t\t\"@types/node\": \"^24.10.1\",\n\t\t\"clean-pkg-json\": \"^1.3.0\",\n\t\t\"cleye\": \"^2.0.0\",\n\t\t\"execa\": \"^7.0.0\",\n\t\t\"fs-fixture\": \"^1.2.0\",\n\t\t\"ini\": \"^3.0.1\",\n\t\t\"kolorist\": \"^1.8.0\",\n\t\t\"manten\": \"^0.7.0\",\n\t\t\"ai\": \"^6.0.97\",\n\t\t\"@ai-sdk/openai\": \"^3.0.30\",\n\t\t\"@ai-sdk/openai-compatible\": \"^2.0.30\",\n\t\t\"pkgroll\": \"^2.20.1\",\n\t\t\"tsx\": \"^4.21.0\",\n\t\t\"typescript\": \"^5.9.3\"\n\t},\n\t\"release\": {\n\t\t\"branches\": [\"develop\"]\n\t}\n}\n"
  },
  {
    "path": "patches/@clack__prompts@0.6.1.patch",
    "content": "diff --git a/dist/index.d.ts b/dist/index.d.ts\nindex 693d552f60c8e0dfef11480da22fb844065b18eb..f74db21d7709c9f6693a218cec2e424e4cf43c2d 100644\n--- a/dist/index.d.ts\n+++ b/dist/index.d.ts\n@@ -36,7 +36,7 @@ interface SelectOptions<Options extends Option<Value>[], Value> {\n     options: Options;\n     initialValue?: Value;\n }\n-declare const select: <Options extends Option<Value>[], Value>(opts: SelectOptions<Options, Value>) => Promise<symbol | Value>;\n+declare const select: <Options extends Option<Value>[], Value>(opts: SelectOptions<Options, Value>) => Promise<symbol | [...Options][number]['value']>;\n declare const selectKey: <Options extends Option<Value>[], Value extends string>(opts: SelectOptions<Options, Value>) => Promise<symbol | Value>;\n interface MultiSelectOptions<Options extends Option<Value>[], Value> {\n     message: string;"
  },
  {
    "path": "src/cli.ts",
    "content": "// Suppress AI SDK warnings (e.g., \"temperature is not supported for reasoning models\")\nglobalThis.AI_SDK_LOG_WARNINGS = false;\n\nimport { cli } from 'cleye';\nimport pkg from '../package.json';\nconst { description, version } = pkg;\nimport aicommits from './commands/aicommits.js';\nimport prepareCommitMessageHook from './commands/prepare-commit-msg-hook.js';\nimport configCommand from './commands/config.js';\nimport setupCommand from './commands/setup.js';\nimport modelCommand from './commands/model.js';\nimport hookCommand, { isCalledFromGitHook } from './commands/hook.js';\nimport prCommand from './commands/pr.js';\nimport updateCommand from './commands/update.js';\nimport { checkAndAutoUpdate } from './utils/auto-update.js';\nimport { isHeadless } from './utils/headless.js';\n\n// Auto-update check - runs in production to update under the hood\n// Skip during git hooks to avoid breaking commit flow\nif (!isCalledFromGitHook && !isHeadless() && version !== '0.0.0-semantic-release') {\n\tconst distTag = version.includes('-') ? 'develop' : 'latest';\n\n\t// Check for updates and auto-update if available\n\tcheckAndAutoUpdate({\n\t\tpkg,\n\t\tdistTag,\n\t\theadless: false,\n\t});\n}\n\nconst rawArgv = process.argv.slice(2);\n\ncli(\n\t{\n\t\tname: 'aicommits',\n\n\t\t/**\n\t\t * Since this is a wrapper around `git commit`,\n\t\t * flags should not overlap with it\n\t\t * https://git-scm.com/docs/git-commit\n\t\t */\n\t\tflags: {\n\t\t\tgenerate: {\n\t\t\t\ttype: Number,\n\t\t\t\tdescription:\n\t\t\t\t\t'Number of messages to generate (Warning: generating multiple costs more) (default: 1)',\n\t\t\t\talias: 'g',\n\t\t\t},\n\t\t\texclude: {\n\t\t\t\ttype: [String],\n\t\t\t\tdescription: 'Files to exclude from AI analysis',\n\t\t\t\talias: 'x',\n\t\t\t},\n\t\t\tall: {\n\t\t\t\ttype: Boolean,\n\t\t\t\tdescription:\n\t\t\t\t\t'Automatically stage changes in tracked files for the commit',\n\t\t\t\talias: 'a',\n\t\t\t\tdefault: false,\n\t\t\t},\n\t\t\ttype: {\n\t\t\t\ttype: String,\n\t\t\t\tdescription:\n\t\t\t\t\t'Git commit message format (default: plain). Supports plain, conventional, gitmoji, and subject+body',\n\t\t\t\talias: 't',\n\t\t\t},\n\t\t\tyes: {\n\t\t\t\ttype: Boolean,\n\t\t\t\tdescription:\n\t\t\t\t\t'Skip confirmation when committing after message generation (default: false)',\n\t\t\t\talias: 'y',\n\t\t\t\tdefault: false,\n\t\t\t},\n\t\t\tclipboard: {\n\t\t\t\ttype: Boolean,\n\t\t\t\tdescription:\n\t\t\t\t\t'Copy the selected message to the clipboard instead of committing (default: false)',\n\t\t\t\talias: 'c',\n\t\t\t\tdefault: false,\n\t\t\t},\n\t\t\tnoVerify: {\n\t\t\t\ttype: Boolean,\n\t\t\t\tdescription:\n\t\t\t\t\t'Bypass pre-commit hooks while committing (default: false)',\n\t\t\t\talias: 'n',\n\t\t\t\tdefault: false,\n\t\t\t},\n\t\t\tprompt: {\n\t\t\t\ttype: String,\n\t\t\t\tdescription:\n\t\t\t\t\t'Custom prompt to guide the LLM behavior (e.g., specific language, style instructions)',\n\t\t\t\talias: 'p',\n\t\t\t},\n\t\tversion: {\n\t\t\ttype: Boolean,\n\t\t\tdescription: 'Show version number',\n\t\t\talias: 'v',\n\t\t},\n\t\t},\n\n\t\tcommands: [configCommand, setupCommand, modelCommand, hookCommand, prCommand, updateCommand],\n\n\t\thelp: {\n\t\t\tdescription,\n\t\t},\n\n\t\tignoreArgv: (type) => type === 'unknown-flag' || type === 'argument',\n\t},\n\t(argv) => {\n\t\tif (argv.flags.version) {\n\t\t\tconsole.log(version);\n\t\t\tprocess.exit(0);\n\t\t}\n\n\t\tif (isCalledFromGitHook) {\n\t\t\tprepareCommitMessageHook();\n\t\t} else {\n\t\t\taicommits(\n\t\t\t\targv.flags.generate,\n\t\t\t\targv.flags.exclude,\n\t\t\t\targv.flags.all,\n\t\t\t\targv.flags.type,\n\t\t\t\targv.flags.yes,\n\t\t\t\targv.flags.clipboard,\n\t\t\t\targv.flags.noVerify,\n\t\t\t\targv.flags.prompt,\n\t\t\t\trawArgv\n\t\t\t);\n\t\t}\n\t},\n\trawArgv\n);\n"
  },
  {
    "path": "src/commands/aicommits.ts",
    "content": "import { execa } from 'execa';\nimport { black, dim, green, red, yellow, bgCyan } from 'kolorist';\nimport { copyToClipboard as copyMessage } from '../utils/clipboard.js';\nimport {\n\tintro,\n\toutro,\n\tspinner,\n\tselect,\n\tconfirm,\n\tisCancel,\n} from '@clack/prompts';\nimport {\n\tassertGitRepo,\n\tgetStagedDiff,\n\tgetStagedDiffForFiles,\n\tgetDetectedMessage,\n} from '../utils/git.js';\nimport { getConfig, setConfigs } from '../utils/config-runtime.js';\nimport { getProvider } from '../feature/providers/index.js';\nimport {\n\tgenerateCommitMessage,\n\tgenerateCommitDescription,\n\tcombineCommitMessages,\n} from '../utils/openai.js';\nimport { KnownError, handleCommandError } from '../utils/error.js';\n\nimport { getCommitMessage } from '../utils/commit-helpers.js';\nimport { isHeadless } from '../utils/headless.js';\n\nexport default async (\n\tgenerate: number | undefined,\n\texcludeFiles: string[],\n\tstageAll: boolean,\n\tcommitType: string | undefined,\n\tskipConfirm: boolean,\n\tcopyToClipboard: boolean,\n\tnoVerify: boolean,\n\tcustomPrompt: string | undefined,\n\trawArgv: string[]\n) =>\n\t(async () => {\n\t\tconst headless = isHeadless();\n\t\t\n\t\tif (!headless) {\n\t\t\tintro(bgCyan(black(' aicommits ')));\n\t\t}\n\n\t\tawait assertGitRepo();\n\n\t\tif (stageAll) {\n\t\t\tawait execa('git', ['add', '--update']);\n\t\t}\n\n\t\tconst staged = await getStagedDiff(excludeFiles);\n\n\t\tif (!staged) {\n\t\t\tthrow new KnownError(\n\t\t\t\t'No staged changes found. Stage your changes manually, or automatically stage all changes with the `--all` flag.'\n\t\t\t);\n\t\t}\n\n\t\tif (!headless) {\n\t\t\tconst detectingFiles = spinner();\n\t\t\tif (staged.files.length <= 10) {\n\t\t\t\tdetectingFiles.start('Detecting staged files');\n\t\t\t\tdetectingFiles.stop(\n\t\t\t\t\t`📁 ${getDetectedMessage(staged.files)}:\\n${staged.files\n\t\t\t\t\t\t.map((file) => `     ${file}`)\n\t\t\t\t\t\t.join('\\n')}`\n\t\t\t\t);\n\t\t\t} else {\n\t\t\t\tdetectingFiles.start('Detecting staged files');\n\t\t\t\tdetectingFiles.stop(`📁 ${getDetectedMessage(staged.files)}`);\n\t\t\t}\n\t\t}\n\n\t\tconst { env } = process;\n\t\tconst config = await getConfig({\n\t\t\tgenerate: generate?.toString(),\n\t\t\ttype: commitType?.toString(),\n\t\t});\n\n\t\tconst providerInstance = getProvider(config);\n\t\tif (!providerInstance) {\n\t\t\tif (!headless) {\n\t\t\t\tconsole.log(\"Welcome to aicommits! Let's set up your AI provider.\");\n\t\t\t\tconsole.log('Run `aicommits setup` to configure your provider.');\n\t\t\t\toutro('Setup required. Please run: aicommits setup');\n\t\t\t\treturn;\n\t\t\t} else {\n\t\t\t\tthrow new KnownError(\n\t\t\t\t\t'No configuration found. Run `aicommits setup` in an interactive terminal, or set environment variables (OPENAI_API_KEY, etc.)'\n\t\t\t\t);\n\t\t\t}\n\t\t}\n\n\t\t// Use config timeout, or default per provider\n\t\tconst timeout =\n\t\t\tconfig.timeout || (providerInstance.name === 'ollama' ? 30_000 : 10_000);\n\n\t\t// Validate provider config\n\t\tconst validation = providerInstance.validateConfig();\n\t\tif (!validation.valid) {\n\t\t\tthrow new KnownError(\n\t\t\t\t`Provider configuration issues: ${validation.errors.join(\n\t\t\t\t\t', '\n\t\t\t\t)}. Run \\`aicommits setup\\` to reconfigure.`\n\t\t\t);\n\t\t}\n\n\t\t// Use the unified model setting or provider default\n\t\tconfig.model = config.OPENAI_MODEL || providerInstance.getDefaultModel();\n\n\t\t// Check if diff is large and needs chunking\n\t\tconst MAX_FILES = 50;\n\t\tconst CHUNK_SIZE = 10;\n\t\tlet isChunking = false;\n\t\tif (staged.files.length > MAX_FILES) {\n\t\t\tisChunking = true;\n\t\t}\n\n\t\tconst s = headless ? null : spinner();\n\t\tif (s) {\n\t\t\ts.start(\n\t\t\t\t`🔍 Analyzing changes in ${staged.files.length} file${\n\t\t\t\t\tstaged.files.length === 1 ? '' : 's'\n\t\t\t\t}`\n\t\t\t);\n\t\t}\n\t\tconst startTime = Date.now();\n\t\tlet messages: string[];\n\t\tlet usage: any;\n\t\ttry {\n\t\t\tconst baseUrl = providerInstance.getBaseUrl();\n\t\t\tconst apiKey = providerInstance.getApiKey() || '';\n\t\t\tconst providerHeaders = providerInstance.getHeaders();\n\t\t\tconst maxDiffLength = 30000;\n\t\t\tlet diffToUse = staged.diff;\n\t\t\tif (diffToUse.length > maxDiffLength) {\n\t\t\t\tdiffToUse =\n\t\t\t\t\tdiffToUse.substring(0, maxDiffLength) +\n\t\t\t\t\t'\\n\\n[Diff truncated due to size]';\n\t\t\t}\n\n\t\t\tif (config.type === 'subject+body') {\n\t\t\t\tconst result = await generateCommitMessage({\n\t\t\t\t\tbaseUrl,\n\t\t\t\t\tapiKey,\n\t\t\t\t\tmodel: config.model!,\n\t\t\t\t\tlocale: config.locale,\n\t\t\t\t\tdiff: diffToUse,\n\t\t\t\t\tcompletions: 1,\n\t\t\t\t\tmaxLength: config['max-length'],\n\t\t\t\t\ttype: 'subject+body',\n\t\t\t\t\ttimeout,\n\t\t\t\t\tcustomPrompt,\n\t\t\t\t\theaders: providerHeaders,\n\t\t\t\t});\n\t\t\t\tconst title = result.messages[0];\n\t\t\t\tconst { description } = await generateCommitDescription({\n\t\t\t\t\tbaseUrl,\n\t\t\t\t\tapiKey,\n\t\t\t\t\tmodel: config.model!,\n\t\t\t\t\tlocale: config.locale,\n\t\t\t\t\ttitle,\n\t\t\t\t\tdiff: diffToUse,\n\t\t\t\t\ttimeout,\n\t\t\t\t\tmaxLength: config['max-length'],\n\t\t\t\t\tcustomPrompt,\n\t\t\t\t\theaders: providerHeaders,\n\t\t\t\t});\n\t\t\t\tmessages = [\n\t\t\t\t\tdescription.trim()\n\t\t\t\t\t\t? `${title}\\n\\n${description.trim()}`\n\t\t\t\t\t\t: title,\n\t\t\t\t];\n\t\t\t\tusage = result.usage;\n\t\t\t} else if (isChunking) {\n\t\t\t\t// Split files into chunks\n\t\t\t\tconst chunks: string[][] = [];\n\t\t\t\tfor (let i = 0; i < staged.files.length; i += CHUNK_SIZE) {\n\t\t\t\t\tchunks.push(staged.files.slice(i, i + CHUNK_SIZE));\n\t\t\t\t}\n\n\t\t\t\tconst chunkMessages: string[] = [];\n\t\t\t\tlet totalUsage = {\n\t\t\t\t\tprompt_tokens: 0,\n\t\t\t\t\tcompletion_tokens: 0,\n\t\t\t\t\ttotal_tokens: 0,\n\t\t\t\t};\n\n\t\t\t\tfor (const chunk of chunks) {\n\t\t\t\t\tconst chunkDiff = await getStagedDiffForFiles(chunk, excludeFiles);\n\t\t\t\t\tif (chunkDiff && chunkDiff.diff) {\n\t\t\t\t\t\t// Truncate diff if too large to avoid context limits\n\t\t\t\t\t\tconst maxDiffLength = 30000; // Approximate 7.5k tokens\n\t\t\t\t\t\tlet diffToUse = chunkDiff.diff;\n\t\t\t\t\t\tif (diffToUse.length > maxDiffLength) {\n\t\t\t\t\t\t\tdiffToUse =\n\t\t\t\t\t\t\t\tdiffToUse.substring(0, maxDiffLength) +\n\t\t\t\t\t\t\t\t'\\n\\n[Diff truncated due to size]';\n\t\t\t\t\t\t}\n\t\t\t\t\t\tconst result = await generateCommitMessage({\n\t\t\t\t\t\t\tbaseUrl,\n\t\t\t\t\t\t\tapiKey,\n\t\t\t\t\t\t\tmodel: config.model!,\n\t\t\t\t\t\t\tlocale: config.locale,\n\t\t\t\t\t\t\tdiff: diffToUse,\n\t\t\t\t\t\t\tcompletions: config.generate,\n\t\t\t\t\t\t\tmaxLength: config['max-length'],\n\t\t\t\t\t\t\ttype: config.type,\n\t\t\t\t\t\t\ttimeout,\n\t\t\t\t\t\t\tcustomPrompt,\n\t\t\t\t\t\t\theaders: providerHeaders,\n\t\t\t\t\t\t});\n\t\t\t\t\t\tchunkMessages.push(...result.messages);\n\t\t\t\t\t\tif (result.usage) {\n\t\t\t\t\t\t\ttotalUsage.prompt_tokens +=\n\t\t\t\t\t\t\t\t(result.usage as any).prompt_tokens ||\n\t\t\t\t\t\t\t\t(result.usage as any).promptTokens ||\n\t\t\t\t\t\t\t\t0;\n\t\t\t\t\t\t\ttotalUsage.completion_tokens +=\n\t\t\t\t\t\t\t\t(result.usage as any).completion_tokens ||\n\t\t\t\t\t\t\t\t(result.usage as any).completionTokens ||\n\t\t\t\t\t\t\t\t0;\n\t\t\t\t\t\t\ttotalUsage.total_tokens +=\n\t\t\t\t\t\t\t\t(result.usage as any).total_tokens ||\n\t\t\t\t\t\t\t\t(result.usage as any).totalTokens ||\n\t\t\t\t\t\t\t\t0;\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\t// Combine the chunk messages\n\t\t\t\tconst combineResult = await combineCommitMessages({\n\t\t\t\t\tmessages: chunkMessages,\n\t\t\t\t\tbaseUrl,\n\t\t\t\t\tapiKey,\n\t\t\t\t\tmodel: config.model!,\n\t\t\t\t\tlocale: config.locale,\n\t\t\t\t\tmaxLength: config['max-length'],\n\t\t\t\t\ttype: config.type,\n\t\t\t\t\ttimeout,\n\t\t\t\t\tcustomPrompt,\n\t\t\t\t\theaders: providerHeaders,\n\t\t\t\t});\n\t\t\t\tmessages = combineResult.messages;\n\t\t\t\tif (combineResult.usage) {\n\t\t\t\t\ttotalUsage.prompt_tokens +=\n\t\t\t\t\t\t(combineResult.usage as any).prompt_tokens ||\n\t\t\t\t\t\t(combineResult.usage as any).promptTokens ||\n\t\t\t\t\t\t0;\n\t\t\t\t\ttotalUsage.completion_tokens +=\n\t\t\t\t\t\t(combineResult.usage as any).completion_tokens ||\n\t\t\t\t\t\t(combineResult.usage as any).completionTokens ||\n\t\t\t\t\t\t0;\n\t\t\t\t\ttotalUsage.total_tokens +=\n\t\t\t\t\t\t(combineResult.usage as any).total_tokens ||\n\t\t\t\t\t\t(combineResult.usage as any).totalTokens ||\n\t\t\t\t\t\t0;\n\t\t\t\t}\n\t\t\t\tusage = totalUsage;\n\t\t\t} else {\n\t\t\t\tconst result = await generateCommitMessage({\n\t\t\t\t\tbaseUrl,\n\t\t\t\t\tapiKey,\n\t\t\t\t\tmodel: config.model!,\n\t\t\t\t\tlocale: config.locale,\n\t\t\t\t\tdiff: diffToUse,\n\t\t\t\t\tcompletions: config.generate,\n\t\t\t\t\tmaxLength: config['max-length'],\n\t\t\t\t\ttype: config.type,\n\t\t\t\t\ttimeout,\n\t\t\t\t\tcustomPrompt,\n\t\t\t\t\theaders: providerHeaders,\n\t\t\t\t});\n\t\t\t\tmessages = result.messages;\n\t\t\t\tusage = result.usage;\n\t\t\t}\n\t\t} finally {\n\t\t\tif (s) {\n\t\t\t\tconst duration = Date.now() - startTime;\n\t\t\t\ts.stop(\n\t\t\t\t\t`✅ Changes analyzed in ${(duration / 1000).toFixed(1)}s`\n\t\t\t\t);\n\t\t\t}\n\t\t}\n\n\t\tif (messages.length === 0) {\n\t\t\tthrow new KnownError('No commit messages were generated. Try again.');\n\t\t}\n\n\t\t// Headless mode: output to stdout and exit\n\t\tif (headless) {\n\t\t\tconst message = messages[0];\n\t\t\tconsole.log(message);\n\t\t\treturn;\n\t\t}\n\n\t\t// Interactive mode: handle commit message selection and confirmation\n\t\tconst message = await getCommitMessage(messages, skipConfirm);\n\t\tif (!message) {\n\t\t\toutro('Commit cancelled');\n\t\t\treturn;\n\t\t}\n\n\t\t// Handle clipboard mode (early return)\n\t\tif (copyToClipboard) {\n\t\t\tconst success = await copyMessage(message);\n\t\t\tif (success) {\n\t\t\t\toutro(`${green('✔')} Message copied to clipboard`);\n\t\t\t}\n\t\t\treturn;\n\t\t}\n\n\t\t// Commit the message with timeout (use multiple -m for multi-line messages)\n\t\ttry {\n\t\t\tconst commitArgs =\n\t\t\t\tmessage.includes('\\n\\n')\n\t\t\t\t\t? ['-m', message.split(/\\n\\n/)[0], '-m', message.slice(message.indexOf('\\n\\n') + 2)]\n\t\t\t\t\t: ['-m', message];\n\t\t\tif (noVerify) {\n\t\t\t\tcommitArgs.push('--no-verify');\n\t\t\t}\n\t\t\tawait execa('git', ['commit', ...commitArgs, ...rawArgv], {\n\t\t\t\tstdio: 'inherit',\n\t\t\t\tcleanup: true,\n\t\t\t\ttimeout: 10000,\n\t\t\t});\n\t\t\toutro(`${green('✔')} Successfully committed!`);\n\t\t} catch (error: any) {\n\t\t\tif (error.timedOut) {\n\t\t\t\tconst success = await copyMessage(message);\n\t\t\t\tif (success) {\n\t\t\t\t\toutro(\n\t\t\t\t\t\t`${yellow('⚠')} Commit timed out after 10 seconds. Message copied to clipboard.`\n\t\t\t\t\t);\n\t\t\t\t} else {\n\t\t\t\t\toutro(\n\t\t\t\t\t\t`${yellow('⚠')} Commit timed out after 10 seconds. Could not copy to clipboard.`\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (error.exitCode !== undefined) {\n\t\t\t\toutro(`${red('✘')} Commit failed. This may be due to pre-commit hooks.`);\n\t\t\t\tconsole.error(\n\t\t\t\t\t`  ${dim('Use')} --no-verify ${dim('to bypass pre-commit hooks')}`\n\t\t\t\t);\n\t\t\t\tprocess.exit(1);\n\t\t\t}\n\t\t\tthrow error;\n\t\t}\n\t})().catch(handleCommandError);\n"
  },
  {
    "path": "src/commands/config.ts",
    "content": "import { command } from 'cleye';\nimport { red } from 'kolorist';\nimport { hasOwn } from '../utils/config-types.js';\nimport { getConfig, setConfigs } from '../utils/config-runtime.js';\nimport { KnownError, handleCommandError } from '../utils/error.js';\n\nexport default command(\n\t{\n\t\tname: 'config',\n\t\tdescription: 'View or modify configuration settings',\n\t\thelp: {\n\t\t\tdescription: 'View or modify configuration settings',\n\t\t},\n\t\tparameters: ['[mode]', '[key=value...]'],\n\t},\n\t(argv) => {\n\t\t(async () => {\n\t\t\tconst [mode, ...keyValues] = argv._;\n\n\t\t\t// If no mode provided, show all current config (excluding defaults)\n\t\t\tif (!mode) {\n\t\t\t\tconst config = await getConfig({}, {}, true);\n\n\t\t\t\tconsole.log('Provider:', config.provider);\n\t\t\t\tif (config.OPENAI_API_KEY) {\n\t\t\t\t\tconsole.log('API Key:', `${config.OPENAI_API_KEY.substring(0, 4)}****`);\n\t\t\t\t}\n\t\t\t\tif (config.OPENAI_BASE_URL) {\n\t\t\t\t\tconsole.log('Base URL:', config.OPENAI_BASE_URL);\n\t\t\t\t}\n\t\t\t\tif (config.OPENAI_MODEL) {\n\t\t\t\t\tconsole.log('Model:', config.OPENAI_MODEL);\n\t\t\t\t}\n\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (mode === 'get') {\n\t\t\t\tconst config = await getConfig({}, {}, true);\n\t\t\t\tconst sensitiveKeys = ['OPENAI_API_KEY', 'TOGETHER_API_KEY', 'api-key'];\n\t\t\t\tfor (const key of keyValues) {\n\t\t\t\t\tif (hasOwn(config, key)) {\n\t\t\t\t\t\tconst value = config[key as keyof typeof config];\n\t\t\t\t\t\tconst displayValue = sensitiveKeys.includes(key)\n\t\t\t\t\t\t\t? `${String(value).substring(0, 4)}****`\n\t\t\t\t\t\t\t: String(value);\n\t\t\t\t\t\tconsole.log(`${key}=${displayValue}`);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (mode === 'set') {\n\t\t\t\tawait setConfigs(\n\t\t\t\t\tkeyValues.map((keyValue) => keyValue.split('=') as [string, string])\n\t\t\t\t);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tthrow new KnownError(`Invalid mode: ${mode}`);\n\t\t})().catch(handleCommandError);\n\t}\n);\n"
  },
  {
    "path": "src/commands/hook.ts",
    "content": "import fs from 'fs/promises';\nimport path from 'path';\nimport { fileURLToPath, pathToFileURL } from 'url';\nimport { green, red } from 'kolorist';\nimport { command } from 'cleye';\nimport { assertGitRepo } from '../utils/git.js';\nimport { fileExists } from '../utils/fs.js';\nimport { KnownError, handleCliError } from '../utils/error.js';\n\nconst hookName = 'prepare-commit-msg';\nconst symlinkPath = `.git/hooks/${hookName}`;\n\nconst hookPath = fileURLToPath(new URL('cli.mjs', import.meta.url));\n\nexport const isCalledFromGitHook = process.argv[1]\n\t.replace(/\\\\/g, '/') // Replace Windows back slashes with forward slashes\n\t.endsWith(`/${symlinkPath}`);\n\nconst isWindows = process.platform === 'win32';\nconst windowsHook = `\n#!/usr/bin/env node\nimport(${JSON.stringify(pathToFileURL(hookPath))})\n`.trim();\n\nexport default command(\n\t{\n\t\tname: 'hook',\n\t\tdescription: 'Install or uninstall the Git hook for automatic commit messages',\n\t\thelp: {\n\t\t\tdescription: 'Install or uninstall the Git hook for automatic commit messages',\n\t\t},\n\t\tparameters: ['<install/uninstall>'],\n\t},\n\t(argv) => {\n\t\t(async () => {\n\t\t\tconst gitRepoPath = await assertGitRepo();\n\t\t\tconst { installUninstall: mode } = argv._;\n\n\t\t\tconst absoltueSymlinkPath = path.join(gitRepoPath, symlinkPath);\n\t\t\tconst hookExists = await fileExists(absoltueSymlinkPath);\n\t\t\tif (mode === 'install') {\n\t\t\t\tif (hookExists) {\n\t\t\t\t\t// If the symlink is broken, it will throw an error\n\t\t\t\t\t// eslint-disable-next-line @typescript-eslint/no-empty-function\n\t\t\t\t\tconst realpath = await fs\n\t\t\t\t\t\t.realpath(absoltueSymlinkPath)\n\t\t\t\t\t\t.catch(() => {});\n\t\t\t\t\tif (realpath === hookPath) {\n\t\t\t\t\t\tconsole.warn('The hook is already installed');\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t\tthrow new KnownError(\n\t\t\t\t\t\t`A different ${hookName} hook seems to be installed. Please remove it before installing aicommits.`\n\t\t\t\t\t);\n\t\t\t\t}\n\n\t\t\t\tawait fs.mkdir(path.dirname(absoltueSymlinkPath), { recursive: true });\n\n\t\t\t\tif (isWindows) {\n\t\t\t\t\tawait fs.writeFile(absoltueSymlinkPath, windowsHook);\n\t\t\t\t} else {\n\t\t\t\t\tawait fs.symlink(hookPath, absoltueSymlinkPath, 'file');\n\t\t\t\t\tawait fs.chmod(absoltueSymlinkPath, 0o755);\n\t\t\t\t}\n\t\t\t\tconsole.log(`${green('✔')} Hook installed`);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (mode === 'uninstall') {\n\t\t\t\tif (!hookExists) {\n\t\t\t\t\tconsole.warn('Hook is not installed');\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\tif (isWindows) {\n\t\t\t\t\tconst scriptContent = await fs.readFile(absoltueSymlinkPath, 'utf8');\n\t\t\t\t\tif (scriptContent !== windowsHook) {\n\t\t\t\t\t\tconsole.warn('Hook is not installed');\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tconst realpath = await fs.realpath(absoltueSymlinkPath);\n\t\t\t\t\tif (realpath !== hookPath) {\n\t\t\t\t\t\tconsole.warn('Hook is not installed');\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tawait fs.rm(absoltueSymlinkPath);\n\t\t\t\tconsole.log(`${green('✔')} Hook uninstalled`);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tthrow new KnownError(`Invalid mode: ${mode}`);\n\t\t})().catch((error) => {\n\t\t\tconsole.error(`${red('✖')} ${error.message}`);\n\t\t\thandleCliError(error);\n\t\t\tprocess.exit(1);\n\t\t});\n\t}\n);\n"
  },
  {
    "path": "src/commands/model.ts",
    "content": "import { command } from 'cleye';\nimport { outro, log } from '@clack/prompts';\nimport { getConfig, setConfigs } from '../utils/config-runtime.js';\nimport { getProvider } from '../feature/providers/index.js';\nimport { selectModel } from '../feature/models.js';\nimport { KnownError, handleCommandError } from '../utils/error.js';\nimport { isInteractive } from '../utils/headless.js';\n\nexport default command(\n\t{\n\t\tname: 'model',\n\t\tdescription: 'Select or change your AI model',\n\t\thelp: {\n\t\t\tdescription: 'Select or change your AI model',\n\t\t},\n\t\talias: ['-m', 'models'],\n\t},\n\t() => {\n\t\t(async () => {\n\t\t\tif (!isInteractive()) {\n\t\t\t\tthrow new KnownError(\n\t\t\t\t\t'Interactive terminal required for model selection.'\n\t\t\t\t);\n\t\t\t}\n\n\t\t\tconst config = await getConfig();\n\n\t\t\tif (!config.provider) {\n\t\t\t\toutro('No provider configured. Run `aicommits setup` first.');\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst provider = getProvider(config);\n\t\t\tif (!provider) {\n\t\t\t\toutro(\n\t\t\t\t\t'Invalid provider configured. Run `aicommits setup` to reconfigure.'\n\t\t\t\t);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst currentModel = config.OPENAI_MODEL;\n\n\t\t\t// Validate provider config\n\t\t\tconst validation = provider.validateConfig();\n\t\t\tif (!validation.valid) {\n\t\t\t\toutro(\n\t\t\t\t\t`Configuration issues: ${validation.errors.join(\n\t\t\t\t\t\t', '\n\t\t\t\t\t)}. Run \\`aicommits setup\\` to reconfigure.`\n\t\t\t\t);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Select model using provider\n\t\t\tconst selectedModel = await selectModel(\n\t\t\t\tprovider.getBaseUrl(),\n\t\t\t\tprovider.getApiKey() || '',\n\t\t\t\tcurrentModel,\n\t\t\t\tprovider.getDefinition(),\n\t\t\t\tprovider.displayName\n\t\t\t);\n\n\t\t\tif (selectedModel) {\n\t\t\t\t// Save the selected model\n\t\t\t\tawait setConfigs([['OPENAI_MODEL', selectedModel]]);\n\t\t\t\toutro(`✅ Model updated to: ${selectedModel}`);\n\t\t\t} else {\n\t\t\t\toutro('Model selection cancelled');\n\t\t\t}\n\t\t})().catch(handleCommandError);\n\t}\n);\n"
  },
  {
    "path": "src/commands/pr.ts",
    "content": "import { command } from 'cleye';\nimport { execa } from 'execa';\nimport { black, green, bgCyan } from 'kolorist';\nimport { intro, outro, spinner, confirm, isCancel } from '@clack/prompts';\nimport { assertGitRepo } from '../utils/git.js';\nimport { getConfig } from '../utils/config-runtime.js';\nimport { getProvider } from '../feature/providers/index.js';\nimport { generateText } from 'ai';\nimport { createOpenAI } from '@ai-sdk/openai';\nimport { createOpenAICompatible } from '@ai-sdk/openai-compatible';\nimport { KnownError, handleCommandError } from '../utils/error.js';\nimport { isInteractive } from '../utils/headless.js';\n\ntype GitProvider = 'github' | 'gitlab' | 'bitbucket' | 'azure';\n\ninterface RepoInfo {\n\tprovider: GitProvider;\n\towner: string;\n\trepo: string;\n}\n\nfunction parseRemoteUrl(remoteUrl: string): RepoInfo {\n\tconst githubMatch = remoteUrl.match(/github\\.com[\\/:]([^\\/]+)\\/([^\\/\\.]+)/);\n\tif (githubMatch) {\n\t\treturn { provider: 'github', owner: githubMatch[1], repo: githubMatch[2] };\n\t}\n\n\tconst gitlabMatch = remoteUrl.match(/gitlab\\.com[\\/:]([^\\/]+)\\/([^\\/\\.]+)/);\n\tif (gitlabMatch) {\n\t\treturn { provider: 'gitlab', owner: gitlabMatch[1], repo: gitlabMatch[2] };\n\t}\n\n\tconst bitbucketMatch = remoteUrl.match(/bitbucket\\.org[\\/:]([^\\/]+)\\/([^\\/\\.]+)/);\n\tif (bitbucketMatch) {\n\t\treturn { provider: 'bitbucket', owner: bitbucketMatch[1], repo: bitbucketMatch[2] };\n\t}\n\n\tconst azureMatch = remoteUrl.match(/dev\\.azure\\.com[\\/:]([^\\/]+)\\/([^\\/\\.]+)|vs-internal\\.visualstudio\\.com[\\/:]([^\\/]+)\\/([^\\/\\.]+)/);\n\tif (azureMatch) {\n\t\treturn { provider: 'azure', owner: azureMatch[1] || azureMatch[3], repo: azureMatch[2] || azureMatch[4] };\n\t}\n\n\tthrow new KnownError(\n\t\t`Unsupported git provider. Supported: GitHub, GitLab, Bitbucket, Azure DevOps.\\nRemote URL: ${remoteUrl}`\n\t);\n}\n\nfunction getPrUrl(\n\tprovider: GitProvider,\n\towner: string,\n\trepo: string,\n\tdefaultBranch: string,\n\tcurrentBranch: string,\n\ttitle: string,\n\tbody: string\n): string {\n\tconst encodedTitle = encodeURIComponent(title);\n\tconst encodedBody = encodeURIComponent(body);\n\n\tswitch (provider) {\n\t\tcase 'github':\n\t\t\treturn `https://github.com/${owner}/${repo}/compare/${defaultBranch}...${currentBranch}?expand=1&title=${encodedTitle}&body=${encodedBody}`;\n\t\tcase 'gitlab':\n\t\t\treturn `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}`;\n\t\tcase 'bitbucket':\n\t\t\treturn `https://bitbucket.org/${owner}/${repo}/pull-requests/new?source=${encodeURIComponent(currentBranch)}&dest=${encodeURIComponent(defaultBranch)}&title=${encodedTitle}&description=${encodedBody}`;\n\t\tcase 'azure':\n\t\t\treturn `https://dev.azure.com/${owner}/${repo}/_git/${repo}/pullrequestcreate?sourceRef=${encodeURIComponent(currentBranch)}&targetRef=${encodeURIComponent(defaultBranch)}&title=${encodedTitle}&description=${encodedBody}`;\n\t}\n}\n\nexport default command(\n\t{\n\t\tname: 'pr',\n\t\tdescription:\n\t\t\t'[beta 🚧] Generate and create a PR (GitHub/GitLab/Bitbucket/Azure) based on branch diff',\n\t\thelp: {\n\t\t\tdescription:\n\t\t\t\t'[beta 🚧] Generate and create a PR (GitHub/GitLab/Bitbucket/Azure) based on branch diff',\n\t\t},\n\t},\n\t() => {\n\t\t(async () => {\n\t\t\tif (!isInteractive()) {\n\t\t\t\tthrow new KnownError(\n\t\t\t\t\t'Interactive terminal required for PR creation.'\n\t\t\t\t);\n\t\t\t}\n\n\t\t\tintro(bgCyan(black(' aicommits pr ')));\n\n\t\t\tawait assertGitRepo();\n\n\t\t\t// Get current branch\n\t\t\tconst { stdout: currentBranch } = await execa('git', [\n\t\t\t\t'branch',\n\t\t\t\t'--show-current',\n\t\t\t]);\n\t\t\tif (!currentBranch.trim()) {\n\t\t\t\tthrow new KnownError('Not on a branch');\n\t\t\t}\n\n\t\t\t// Get repo URL\n\t\t\tconst { stdout: remoteUrl } = await execa('git', [\n\t\t\t\t'remote',\n\t\t\t\t'get-url',\n\t\t\t\t'origin',\n\t\t\t]);\n\t\t\tconst repoInfo = parseRemoteUrl(remoteUrl);\n\t\t\tconst { provider, owner, repo } = repoInfo;\n\n\t\t\t// Get default branch from git remote\n\t\t\tlet defaultBranch = 'main';\n\t\t\ttry {\n\t\t\t\tconst { stdout } = await execa('git', [\n\t\t\t\t\t'symbolic-ref',\n\t\t\t\t\t'refs/remotes/origin/HEAD',\n\t\t\t\t]);\n\t\t\t\tdefaultBranch = stdout.trim().replace('refs/remotes/origin/', '');\n\t\t\t} catch {\n\t\t\t\t// Fallback to main if git command fails\n\t\t\t}\n\n\t\t\t// Check if on default branch\n\t\t\tif (currentBranch.trim() === defaultBranch) {\n\t\t\t\tthrow new KnownError('PR creation requires being on a feature branch, not the default branch. Please switch to a feature branch with changes.');\n\t\t\t}\n\n\t\t\t// Get diff from default branch to current branch\n\t\t\tlet diff;\n\t\t\ttry {\n\t\t\t\tconst { stdout } = await execa('git', [\n\t\t\t\t\t'diff',\n\t\t\t\t\t`origin/${defaultBranch}..HEAD`,\n\t\t\t\t]);\n\t\t\t\tdiff = stdout;\n\t\t\t} catch {\n\t\t\t\tthrow new KnownError(`Could not get diff from origin/${defaultBranch}`);\n\t\t\t}\n\n\t\t\tif (!diff) {\n\t\t\t\tthrow new KnownError('No changes to create PR from');\n\t\t\t}\n\n\t\t\t// Count changed files\n\t\t\tconst numFiles = diff\n\t\t\t\t.split('\\n')\n\t\t\t\t.filter((line) => line.startsWith('diff --git')).length;\n\n\t\t\t// Limit diff size to avoid token limits\n\t\t\tconst maxDiffLength = 30000; // Approximate character limit\n\t\t\tif (diff.length > maxDiffLength) {\n\t\t\t\tdiff =\n\t\t\t\t\tdiff.substring(0, maxDiffLength) + '\\n\\n[Diff truncated due to size]';\n\t\t\t}\n\n\t\t\tconst config = await getConfig();\n\t\t\tconst configProvider = await getProvider(config);\n\n\t\t\tif (!configProvider) {\n\t\t\t\tthrow new KnownError('No provider configured');\n\t\t\t}\n\n\t\t\tlet baseUrl = configProvider.getBaseUrl();\n\t\t\tif (!baseUrl || baseUrl === '') {\n\t\t\t\tthrow new KnownError(\n\t\t\t\t\t'Base URL not configured. Please run `aicommits setup` to configure your provider.'\n\t\t\t\t);\n\t\t\t}\n\t\t\tif (!baseUrl.endsWith('/v1')) {\n\t\t\t\tbaseUrl += '/v1';\n\t\t\t}\n\t\t\tconst apiKey = configProvider.getApiKey();\n\t\t\tif (!apiKey) {\n\t\t\t\tthrow new KnownError(\n\t\t\t\t\t'API key not configured. Please run `aicommits setup` to configure your provider.'\n\t\t\t\t);\n\t\t\t}\n\t\t\tconst aiProvider =\n\t\t\t\tbaseUrl === 'https://api.openai.com/v1'\n\t\t\t\t\t? createOpenAI({ apiKey })\n\t\t\t\t\t: createOpenAICompatible({\n\t\t\t\t\t\t\tname: 'custom',\n\t\t\t\t\t\t\tapiKey,\n\t\t\t\t\t\t\tbaseURL: baseUrl,\n\t\t\t\t\t  });\n\n\t\t\tconst generating = spinner();\n\t\t\tgenerating.start(\n\t\t\t\t`Generating PR title and description (${numFiles} files changed)`\n\t\t\t);\n\n\t\t\tconst startTime = Date.now();\n\n\t\t\t// Generate PR title\n\t\t\tconst titleResult = await generateText({\n\t\t\t\tmodel: aiProvider(config.model) as any,\n\t\t\t\tsystem:\n\t\t\t\t\t'Generate a concise PR title based on the following git diff. The title should be under 72 characters.',\n\t\t\t\tprompt: diff,\n\t\t\t\tmaxRetries: 2,\n\t\t\t});\n\n\t\t\tconst title = titleResult.text;\n\n\t\t\t// Generate PR body\n\t\t\tconst bodyResult = await generateText({\n\t\t\t\tmodel: aiProvider(config.model) as any,\n\t\t\t\tsystem:\n\t\t\t\t\t'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.',\n\t\t\t\tprompt: diff,\n\t\t\t\tmaxRetries: 2,\n\t\t\t});\n\n\t\t\tconst body = bodyResult.text;\n\n\t\t\tconst endTime = Date.now();\n\t\t\tconst duration = Math.round((endTime - startTime) / 1000);\n\n\t\t\tgenerating.stop(\n\t\t\t\t`Generated PR content for ${numFiles} files in ${duration}s`\n\t\t\t);\n\n\t\t\tconsole.log(`${green('Title:')} ${title.replace(/\\n/g, ' ')}`);\n\t\t\tconsole.log(\n\t\t\t\t`${green('Body:')} ${\n\t\t\t\t\tbody.length > 100 ? body.substring(0, 100) + '...' : body\n\t\t\t\t}`\n\t\t\t);\n\n\t\t\tconst { text } = await import('@clack/prompts');\n\t\t\tconst proceed = await text({\n\t\t\t\tmessage:\n\t\t\t\t\t'Press Enter to push and open PR creation in browser, or Ctrl+C to cancel',\n\t\t\t\tplaceholder: 'Press Enter',\n\t\t\t});\n\n\t\t\tif (isCancel(proceed)) {\n\t\t\t\toutro('PR creation cancelled');\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst pushing = spinner();\n\t\t\tpushing.start(`Pushing branch to ${provider}`);\n\n\t\t\ttry {\n\t\t\t\tawait execa('git', ['push', '-u', 'origin', currentBranch.trim()]);\n\t\t\t\tpushing.stop(`Branch pushed to ${provider}`);\n\t\t\t} catch (error) {\n\t\t\t\tpushing.stop('Failed to push branch');\n\t\t\t\tthrow new KnownError(`Failed to push branch: ${error instanceof Error ? error.message : String(error)}`);\n\t\t\t}\n\n\t\t\tconst prUrl = getPrUrl(\n\t\t\t\tprovider,\n\t\t\t\towner,\n\t\t\t\trepo,\n\t\t\t\tdefaultBranch,\n\t\t\t\tcurrentBranch.trim(),\n\t\t\t\ttitle,\n\t\t\t\tbody\n\t\t\t);\n\n\t\t\tconst creating = spinner();\n\t\t\tcreating.start('Opening PR creation page in browser');\n\n\t\t\ttry {\n\t\t\t\t// Try to open browser\n\t\t\t\tconst openCmd =\n\t\t\t\t\tprocess.platform === 'darwin'\n\t\t\t\t\t\t? 'open'\n\t\t\t\t\t\t: process.platform === 'win32'\n\t\t\t\t\t\t? 'start'\n\t\t\t\t\t\t: 'xdg-open';\n\t\t\t\tawait execa(openCmd, [prUrl]);\n\t\t\t\tcreating.stop('PR creation page opened in browser');\n\t\t\t\toutro(\n\t\t\t\t\tgreen('PR creation page opened! Please review and submit the PR.')\n\t\t\t\t);\n\t\t\t} catch (error) {\n\t\t\t\tcreating.stop('Failed to open browser');\n\t\t\t\toutro(`${green('PR URL:')} ${prUrl}`);\n\t\t\t\toutro('Please open the URL above in your browser to create the PR.');\n\t\t\t}\n\t\t})().catch((error) => {\n\t\t\thandleCommandError(error);\n\t\t});\n\t}\n);\n"
  },
  {
    "path": "src/commands/prepare-commit-msg-hook.ts",
    "content": "import fs from 'fs/promises';\nimport { intro, outro, spinner } from '@clack/prompts';\nimport { black, green, red, bgCyan } from 'kolorist';\nimport { getStagedDiff } from '../utils/git.js';\nimport { getConfig } from '../utils/config-runtime.js';\nimport { getProvider } from '../feature/providers/index.js';\nimport { generateCommitMessage } from '../utils/openai.js';\nimport { KnownError, handleCommandError } from '../utils/error.js';\nimport { isHeadless } from '../utils/headless.js';\n\nconst [messageFilePath, commitSource] = process.argv.slice(2);\n\nexport default () =>\n\t(async () => {\n\t\tif (!messageFilePath) {\n\t\t\tthrow new KnownError(\n\t\t\t\t'Commit message file path is missing. This file should be called from the \"prepare-commit-msg\" git hook'\n\t\t\t);\n\t\t}\n\n\t\t// If a commit message is passed in, ignore\n\t\tif (commitSource) {\n\t\t\treturn;\n\t\t}\n\n\t\t// All staged files can be ignored by our filter\n\t\tconst staged = await getStagedDiff();\n\t\tif (!staged) {\n\t\t\treturn;\n\t\t}\n\n\t\tconst headless = isHeadless();\n\t\tif (!headless) {\n\t\t\tintro(bgCyan(black(' aicommits ')));\n\t\t}\n\n\t\tconst config = await getConfig({});\n\n\t\tconst providerInstance = getProvider(config);\n\t\tif (!providerInstance) {\n\t\t\tthrow new KnownError(\n\t\t\t\t'Invalid provider configuration. Run `aicommits setup` to reconfigure.'\n\t\t\t);\n\t\t}\n\n\t\t// Validate provider config\n\t\tconst validation = providerInstance.validateConfig();\n\t\tif (!validation.valid) {\n\t\t\tthrow new KnownError(\n\t\t\t\t`Provider configuration issues: ${validation.errors.join(\n\t\t\t\t\t', '\n\t\t\t\t)}. Run \\`aicommits setup\\` to reconfigure.`\n\t\t\t);\n\t\t}\n\n\t\tconst baseUrl = providerInstance.getBaseUrl();\n\t\tconst apiKey = providerInstance.getApiKey() || '';\n\t\tconst providerHeaders = providerInstance.getHeaders();\n\n\t\t// Use config timeout, or default per provider\n\t\tconst timeout =\n\t\t\tconfig.timeout || (providerInstance.name === 'ollama' ? 30_000 : 10_000);\n\n\t\t// Use the unified model or provider default\n\t\tlet model = config.OPENAI_MODEL || providerInstance.getDefaultModel();\n\n\t\tconst s = headless ? null : spinner();\n\t\ts?.start('The AI is analyzing your changes');\n\t\tlet messages: string[];\n\t\ttry {\n\t\t\tconst result = await generateCommitMessage({\n\t\t\t\tbaseUrl,\n\t\t\t\tapiKey,\n\t\t\t\tmodel,\n\t\t\t\tlocale: config.locale,\n\t\t\t\tdiff: staged!.diff,\n\t\t\t\tcompletions: config.generate,\n\t\t\t\tmaxLength: config['max-length'],\n\t\t\t\ttype: config.type,\n\t\t\t\ttimeout,\n\t\t\t\theaders: providerHeaders,\n\t\t\t});\n\t\t\tmessages = result.messages;\n\t\t} finally {\n\t\t\ts?.stop('Changes analyzed');\n\t\t}\n\n\t\t/**\n\t\t * When `--no-edit` is passed in, the base commit message is empty,\n\t\t * and even when you use pass in comments via #, they are ignored.\n\t\t *\n\t\t * Note: `--no-edit` cannot be detected in argvs so this is the only way to check\n\t\t */\n\t\tconst baseMessage = await fs.readFile(messageFilePath, 'utf8');\n\t\tconst supportsComments = baseMessage !== '';\n\t\tconst hasMultipleMessages = messages.length > 1;\n\n\t\tlet instructions = '';\n\n\t\tif (supportsComments) {\n\t\t\tinstructions = `# 🤖 AI generated commit${\n\t\t\t\thasMultipleMessages ? 's' : ''\n\t\t\t}\\n`;\n\t\t}\n\n\t\tif (hasMultipleMessages) {\n\t\t\tif (supportsComments) {\n\t\t\t\tinstructions +=\n\t\t\t\t\t'# Select one of the following messages by uncommenting:\\n';\n\t\t\t}\n\t\t\tinstructions += `\\n${messages\n\t\t\t\t.map((message) => `# ${message}`)\n\t\t\t\t.join('\\n')}`;\n\t\t} else {\n\t\t\tif (supportsComments) {\n\t\t\t\tinstructions += '# Edit the message below and commit:\\n';\n\t\t\t}\n\t\t\tinstructions += `\\n${messages[0]}\\n`;\n\t\t}\n\n\t\tconst currentContent = await fs.readFile(messageFilePath, 'utf8');\n\t\tconst newContent = instructions + '\\n' + currentContent;\n\t\tawait fs.writeFile(messageFilePath, newContent);\n\n\t\tif (!headless) {\n\t\t\toutro(`${green('✔')} Saved commit message!`);\n\t\t}\n\t})().catch(handleCommandError);\n"
  },
  {
    "path": "src/commands/setup.ts",
    "content": "import { execSync } from 'child_process';\nimport { command } from 'cleye';\nimport { select, text, outro, isCancel, confirm } from '@clack/prompts';\nimport { getConfig, setConfigs } from '../utils/config-runtime.js';\nimport {\n\tgetProvider,\n\tgetAvailableProviders,\n\tgetProviderBaseUrl,\n} from '../feature/providers/index.js';\nimport { KnownError, handleCommandError } from '../utils/error.js';\nimport { isInteractive } from '../utils/headless.js';\n\nexport default command(\n\t{\n\t\tname: 'setup',\n\t\tdescription: 'Configure your AI provider and settings',\n\t\thelp: {\n\t\t\tdescription: 'Configure your AI provider and settings',\n\t\t},\n\t},\n\t(argv) => {\n\t\t(async () => {\n\t\t\tif (!isInteractive()) {\n\t\t\t\tthrow new KnownError(\n\t\t\t\t\t'Interactive terminal required for setup. Run `aicommits setup` in a terminal.'\n\t\t\t\t);\n\t\t\t}\n\n\t\t\tlet config = await getConfig();\n\n\t\t\tconst providerOptions = getAvailableProviders();\n\t\t\tconst choice = await select({\n\t\t\t\tmessage: 'Choose your AI provider:',\n\t\t\t\toptions: providerOptions,\n\t\t\t\tinitialValue: config.provider,\n\t\t\t});\n\n\t\t\tif (isCancel(choice)) {\n\t\t\t\toutro('Setup cancelled');\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tconst providerChoice = choice as string;\n\n\t\t\t// Ask for custom base URL if custom provider\n\t\t\tlet customBaseUrl = '';\n\t\t\tif (providerChoice === 'custom') {\n\t\t\t\tconst baseUrlInput = await text({\n\t\t\t\t\tmessage: 'Enter your custom API endpoint:',\n\t\t\t\t\tvalidate: (value: string) => {\n\t\t\t\t\t\tif (!value) return 'Endpoint is required';\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tnew URL(value);\n\t\t\t\t\t\t} catch {\n\t\t\t\t\t\t\treturn 'Invalid URL format';\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn;\n\t\t\t\t\t},\n\t\t\t\t});\n\t\t\t\tif (isCancel(baseUrlInput)) {\n\t\t\t\t\toutro('Setup cancelled');\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tcustomBaseUrl = baseUrlInput as string;\n\t\t\t}\n\n\t\t\t// Set default base URL for the provider\n\t\t\tlet defaultBaseUrl = customBaseUrl || getProviderBaseUrl(providerChoice);\n\n\t\t\t// Set defaults\n\t\t\tconfig.OPENAI_BASE_URL = defaultBaseUrl;\n\t\t\tconfig.OPENAI_API_KEY = '';\n\t\t\tconfig.OPENAI_MODEL = '';\n\n\t\t\t// Get provider instance\n\t\t\tlet provider = getProvider({ ...config, provider: providerChoice });\n\t\t\tif (!provider) {\n\t\t\t\toutro('Invalid provider selected');\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\ttry {\n\t\t\t\tconst apiUpdates = await provider.setup();\n\t\t\t\tfor (const [k, v] of apiUpdates) {\n\t\t\t\t\t(config as any)[k] = v;\n\t\t\t\t}\n\t\t\t} catch (error) {\n\t\t\t\tif (error instanceof Error && error.message === 'Setup cancelled') {\n\t\t\t\t\toutro('Setup cancelled');\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tthrow error;\n\t\t\t}\n\n\t\t\t// Recreate provider with updated config for validation\n\t\t\tprovider = getProvider({ ...config, provider: providerChoice });\n\t\t\tif (!provider) {\n\t\t\t\toutro('Invalid provider selected');\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Validate configuration\n\t\t\tconst validation = provider.validateConfig();\n\t\t\tif (!validation.valid) {\n\t\t\t\toutro(`Setup cancelled: ${validation.errors.join(', ')}`);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Select model interactively\n\t\t\tconst { selectModel } = await import('../feature/models.js');\n\t\t\tconst selectedModel = await selectModel(\n\t\t\t\tprovider.getBaseUrl(),\n\t\t\t\tprovider.getApiKey() || '',\n\t\t\t\tundefined,\n\t\t\t\tprovider.getDefinition()\n\t\t\t);\n\n\t\t\tif (selectedModel) {\n\t\t\t\tconfig.OPENAI_MODEL = selectedModel;\n\t\t\t\tconsole.log(`Model selected: ${selectedModel}`);\n\t\t\t} else {\n\t\t\t\toutro('Model selection cancelled.');\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst typeChoice = await select({\n\t\t\t\tmessage: 'Choose commit message format:',\n\t\t\t\toptions: [\n\t\t\t\t\t{ value: 'plain', label: 'Plain - Simple format without structure' },\n\t\t\t\t\t{ value: 'conventional', label: 'Conventional - Standard conventional commits' },\n\t\t\t\t\t{ value: 'gitmoji', label: 'Gitmoji - Using emojis for commit types' },\n\t\t\t\t\t{ value: 'subject+body', label: 'Subject + body - Git-style subject line and body' },\n\t\t\t\t],\n\t\t\t\tinitialValue: 'plain',\n\t\t\t});\n\n\t\t\tif (isCancel(typeChoice)) {\n\t\t\t\toutro('Setup cancelled');\n\t\t\t\treturn;\n\t\t\t}\n\t\t\t(config as any).type = typeChoice as string;\n\n\t\t\t// Save all config at once\n\t\t\tconst finalUpdates = Object.entries(config).filter(\n\t\t\t\t([k, v]) =>\n\t\t\t\t\tk !== 'provider' &&\n\t\t\t\t\tk !== 'model' &&\n\t\t\t\t\tv !== undefined &&\n\t\t\t\t\tv !== '' &&\n\t\t\t\t\ttypeof v === 'string'\n\t\t\t) as [string, string][];\n\t\t\tawait setConfigs(finalUpdates);\n\n\t\t\toutro(`✅ Setup complete! You're now using ${provider.displayName}.`);\n\n\t\t\t// // Offer to create git alias\n\t\t\t// const aliasChoice = await confirm({\n\t\t\t// \tmessage: 'Would you like to create a git alias \"git ac\" for \"aicommits\"?',\n\t\t\t// });\n\n\t\t\t// if (aliasChoice) {\n\t\t\t// \ttry {\n\t\t\t// \t\texecSync('git config --global alias.ac \"!aicommits\"', { stdio: 'inherit' });\n\t\t\t// \t\tconsole.log('✅ Git alias \"git ac\" created successfully.');\n\t\t\t// \t} catch (error) {\n\t\t\t// \t\tconsole.error(`❌ Failed to create git alias: ${(error as Error).message}`);\n\t\t\t// \t}\n\t\t\t// }\n\t\t})().catch(handleCommandError);\n\t}\n);\n"
  },
  {
    "path": "src/commands/update.ts",
    "content": "import { command } from 'cleye';\nimport { execSync, exec } from 'child_process';\nimport { promisify } from 'util';\nimport { green, red, yellow, cyan } from 'kolorist';\nimport { outro, spinner } from '@clack/prompts';\nimport pkg from '../../package.json';\nimport { handleCommandError, KnownError } from '../utils/error.js';\n\nconst execAsync = promisify(exec);\n\ninterface PackageManagerInfo {\n\tname: string;\n\tupdateCommand: string;\n}\n\n// Determine the dist tag based on current version\n// Versions with prerelease (e.g., 2.0.0-develop.5) use 'develop' tag\n// Stable versions use 'latest' tag\nfunction getDistTag(version: string): string {\n\t// Skip for development/semantic-release versions\n\tif (version === '0.0.0-semantic-release' || version.includes('semantic-release')) {\n\t\treturn 'latest';\n\t}\n\t// If version has prerelease identifier (contains '-'), use 'develop' tag\n\tif (version.includes('-')) {\n\t\treturn 'develop';\n\t}\n\treturn 'latest';\n}\n\nfunction detectPackageManager(distTag: string): PackageManagerInfo {\n\t// Check if running from global installation\n\ttry {\n\t\tconst globalPath = execSync('npm root -g', { encoding: 'utf8' }).trim();\n\t\tconst { execPath } = process;\n\n\t\t// Check if running from global npm installation\n\t\tif (execPath.includes(globalPath) || execPath.includes('/usr/local') || execPath.includes('/usr/bin')) {\n\t\t\treturn { name: 'npm', updateCommand: `npm install -g aicommits@${distTag}` };\n\t\t}\n\t} catch {\n\t\t// Fall through to other detection methods\n\t}\n\n\t// Check for pnpm\n\ttry {\n\t\texecSync('pnpm --version', { stdio: 'ignore' });\n\t\t// Check if installed via pnpm global\n\t\tconst pnpmList = execSync('pnpm list -g aicommits', { encoding: 'utf8' });\n\t\tif (pnpmList.includes('aicommits')) {\n\t\t\treturn { name: 'pnpm', updateCommand: `pnpm add -g aicommits@${distTag}` };\n\t\t}\n\t} catch {\n\t\t// Not pnpm\n\t}\n\n\t// Check for yarn\n\ttry {\n\t\texecSync('yarn --version', { stdio: 'ignore' });\n\t\t// Check if installed via yarn global\n\t\tconst yarnList = execSync('yarn global list', { encoding: 'utf8' });\n\t\tif (yarnList.includes('aicommits')) {\n\t\t\treturn { name: 'yarn', updateCommand: `yarn global add aicommits@${distTag}` };\n\t\t}\n\t} catch {\n\t\t// Not yarn\n\t}\n\n\t// Check for bun\n\ttry {\n\t\texecSync('bun --version', { stdio: 'ignore' });\n\t\t// Check if installed via bun\n\t\tconst bunList = execSync('bun pm bin -g', { encoding: 'utf8' });\n\t\tif (process.execPath.includes('bun') || bunList.includes('aicommits')) {\n\t\t\treturn { name: 'bun', updateCommand: `bun add -g aicommits@${distTag}` };\n\t\t}\n\t} catch {\n\t\t// Not bun\n\t}\n\n\t// Default to npm\n\treturn { name: 'npm', updateCommand: `npm install -g aicommits@${distTag}` };\n}\n\nasync function getLatestVersion(distTag: string): Promise<string | null> {\n\ttry {\n\t\tconst response = await fetch(`https://registry.npmjs.org/aicommits/${distTag}`, {\n\t\t\theaders: { Accept: 'application/json' },\n\t\t});\n\t\tif (!response.ok) return null;\n\t\tconst data = await response.json();\n\t\treturn data.version || null;\n\t} catch {\n\t\treturn null;\n\t}\n}\n\nexport default command(\n\t{\n\t\tname: 'update',\n\t\tdescription: 'Update aicommits to the latest version',\n\t\thelp: {\n\t\t\tdescription: 'Check for updates and install the latest version using your package manager',\n\t\t},\n\t},\n\t() => {\n\t\t(async () => {\n\t\t\t// Determine dist tag based on current version\n\t\t\tconst distTag = getDistTag(pkg.version);\n\t\t\tconst pm = detectPackageManager(distTag);\n\n\t\t\tconsole.log(`${cyan('ℹ')} Current version: ${pkg.version}`);\n\t\t\tconsole.log(`${cyan('ℹ')} Package manager detected: ${pm.name}`);\n\t\t\tif (distTag !== 'latest') {\n\t\t\t\tconsole.log(`${cyan('ℹ')} Using '${distTag}' distribution tag`);\n\t\t\t}\n\n\t\t\tconst s = spinner();\n\t\t\ts.start('Checking for updates...');\n\n\t\t\tconst latestVersion = await getLatestVersion(distTag);\n\n\t\t\tif (!latestVersion) {\n\t\t\t\ts.stop('Could not check for updates', 1);\n\t\t\t\tthrow new KnownError('Failed to fetch latest version from npm registry');\n\t\t\t}\n\n\t\t\tif (latestVersion === pkg.version) {\n\t\t\t\ts.stop(`${green('✔')} Already on the latest version (${pkg.version})`);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\ts.stop(`${green('✔')} Update available: v${pkg.version} → v${latestVersion}`);\n\n\t\t\tconst updateS = spinner();\n\t\t\tupdateS.start(`Updating via ${pm.name}...`);\n\n\t\t\ttry {\n\t\t\t\tawait execAsync(pm.updateCommand, { timeout: 120000 });\n\n\t\t\t\tupdateS.stop(`${green('✔')} Successfully updated to v${latestVersion}`);\n\t\t\t\toutro(`${green('✔')} Update complete! Run 'aic --version' to verify.`);\n\t\t\t} catch (error: any) {\n\t\t\t\tupdateS.stop(`${red('✘')} Update failed`, 1);\n\n\t\t\t\tif (error.stderr?.includes('permission') || error.message?.includes('permission')) {\n\t\t\t\t\tconsole.error(`${red('✘')} Permission denied. Try running with sudo:`);\n\t\t\t\t\tconsole.error(`   sudo ${pm.updateCommand}`);\n\t\t\t\t} else if (error.stderr?.includes('EACCES')) {\n\t\t\t\t\tconsole.error(`${red('✘')} Permission denied. Try running with sudo:`);\n\t\t\t\t\tconsole.error(`   sudo ${pm.updateCommand}`);\n\t\t\t\t} else {\n\t\t\t\t\tconsole.error(`${red('✘')} Error: ${error.message || 'Unknown error'}`);\n\t\t\t\t\tconsole.error(`\\n${yellow('You can manually update with:')}`);\n\t\t\t\t\tconsole.error(`   ${pm.updateCommand}`);\n\t\t\t\t}\n\n\t\t\t\tprocess.exit(1);\n\t\t\t}\n\t\t})().catch(handleCommandError);\n\t}\n);\n"
  },
  {
    "path": "src/feature/models.ts",
    "content": "// Model filtering, fetching, and selection utilities\nimport fs from 'fs/promises';\nimport path from 'path';\nimport os from 'os';\nimport crypto from 'crypto';\nimport type { ProviderDef } from './providers/base.js';\nimport { CURRENT_LABEL_FORMAT } from '../utils/constants.js';\nimport { isCancel, spinner } from '@clack/prompts';\nimport { fileExists } from '../utils/fs.js';\n\ninterface ModelObject {\n\tid?: string;\n\tname?: string;\n\ttype?: string;\n}\n\ninterface CacheEntry {\n\tdata: { models: ModelObject[]; error?: string };\n\ttimestamp: number;\n}\n\nconst CACHE_DURATION = 60 * 60 * 1000; // 1 hour in milliseconds\n\nconst getCacheDir = (): string => {\n\tconst platform = process.platform;\n\tconst home = os.homedir();\n\n\tif (platform === 'darwin') {\n\t\treturn path.join(home, 'Library', 'Caches', 'aicommits', 'models');\n\t} else if (platform === 'win32') {\n\t\treturn path.join(home, 'AppData', 'Local', 'aicommits', 'models');\n\t} else {\n\t\t// Linux/Unix\n\t\tconst xdgCache = process.env.XDG_CACHE_HOME;\n\t\tconst baseCache = xdgCache ? xdgCache : path.join(home, '.cache');\n\t\treturn path.join(baseCache, 'aicommits', 'models');\n\t}\n};\n\nconst getCacheKey = (baseUrl: string): string => {\n\tconst hash = crypto.createHash('sha256');\n\thash.update(baseUrl);\n\treturn hash.digest('hex');\n};\n\nconst getCachePath = (key: string): string =>\n\tpath.join(getCacheDir(), `${key}.json`);\n\nconst readCache = async (key: string): Promise<CacheEntry | null> => {\n\tconst cachePath = getCachePath(key);\n\ttry {\n\t\tif (!(await fileExists(cachePath))) return null;\n\t\tconst data = await fs.readFile(cachePath, 'utf8');\n\t\treturn JSON.parse(data);\n\t} catch {\n\t\treturn null;\n\t}\n};\n\nconst writeCache = async (key: string, entry: CacheEntry): Promise<void> => {\n\ttry {\n\t\tconst cacheDir = getCacheDir();\n\t\tawait fs.mkdir(cacheDir, { recursive: true });\n\t\tconst cachePath = getCachePath(key);\n\t\tawait fs.writeFile(cachePath, JSON.stringify(entry), 'utf8');\n\t} catch {\n\t\t// Ignore write errors\n\t}\n};\n\ninterface FetchModelsOptions {\n\tbaseUrl: string;\n\tapiKey?: string;\n\tcacheModels?: boolean;\n}\n\n// Fetch models from API\nexport const fetchModels = async (\n\toptions: FetchModelsOptions,\n): Promise<{ models: ModelObject[]; error?: string }> => {\n\tconst { baseUrl, apiKey = '', cacheModels = true } = options;\n\tconst cacheKey = getCacheKey(baseUrl);\n\tconst now = Date.now();\n\n\tif (cacheModels) {\n\t\tconst cached = await readCache(cacheKey);\n\t\tif (cached && now - cached.timestamp < CACHE_DURATION) {\n\t\t\treturn cached.data;\n\t\t}\n\t}\n\n\ttry {\n\t\tconst response = await fetch(`${baseUrl}/models`, {\n\t\t\theaders: {\n\t\t\t\tAuthorization: `Bearer ${apiKey}`,\n\t\t\t},\n\t\t});\n\n\t\tif (!response.ok) {\n\t\t\tthrow new Error(`HTTP ${response.status}: ${response.statusText}`);\n\t\t}\n\n\t\tconst data = await response.json();\n\n\t\t// we do this since Together API for openai models has different response than standard needing just data, other apis data.data\n\t\tconst modelsArray: ModelObject[] = (data.data ? data.data : data) || [];\n\n\t\tconst result = { models: modelsArray };\n\t\tif (cacheModels && modelsArray.length > 0) {\n\t\t\tawait writeCache(cacheKey, { data: result, timestamp: now });\n\t\t}\n\t\treturn result;\n\t} catch (error: unknown) {\n\t\tconst errorMessage =\n\t\t\terror instanceof Error ? error.message : 'Request failed';\n\t\tconst result = { models: [], error: errorMessage };\n\t\treturn result;\n\t}\n};\n\n// Shared model selection function\nconst fetchAndFilterModels = async (\n\tbaseUrl: string,\n\tapiKey: string,\n\tproviderDef?: ProviderDef,\n): Promise<string[]> => {\n\t// Fetch models\n\tconst result = await fetchModels({\n\t\tbaseUrl,\n\t\tapiKey,\n\t\tcacheModels: providerDef?.cacheModels,\n\t});\n\n\tif (result.error) {\n\t\tconsole.error(`Failed to fetch models: ${result.error}`);\n\t}\n\n\t// Apply provider-specific filtering\n\tlet models: string[] = [];\n\tconst modelsArray = Array.isArray(result.models) ? result.models : [];\n\tif (providerDef?.modelsFilter) {\n\t\tmodels = providerDef.modelsFilter(modelsArray);\n\t} else {\n\t\t// Fallback: just use model ids/names\n\t\tmodels = modelsArray\n\t\t\t.map((model) => model.id || model.name)\n\t\t\t.filter(Boolean) as string[];\n\t}\n\treturn models;\n};\n\nconst prepareModelOptions = (\n\tmodels: string[],\n\tcurrentModel?: string,\n\tproviderDef?: ProviderDef,\n) => {\n\tlet modelOptions = models.map((model: string) => ({\n\t\tlabel: model,\n\t\tvalue: model,\n\t}));\n\n\t// Move highlighted models to the top\n\tif (providerDef?.defaultModels && providerDef.defaultModels.length > 0) {\n\t\tconst highlightedModels = providerDef.defaultModels.filter((model) =>\n\t\t\tmodelOptions.some((opt) => opt.value === model),\n\t\t);\n\n\t\t// Remove highlighted models from their current positions\n\t\thighlightedModels.forEach((model) => {\n\t\t\tconst index = modelOptions.findIndex((opt) => opt.value === model);\n\t\t\tif (index >= 0) {\n\t\t\t\tmodelOptions.splice(index, 1);\n\t\t\t}\n\t\t});\n\n\t\t// Add highlighted models at the beginning with special labels\n\t\thighlightedModels.forEach((model, index) => {\n\t\t\tconst isCurrent = model === currentModel;\n\t\t\tconst isDefault = index === 0;\n\t\t\tlet label: string;\n\t\t\tif (isCurrent) {\n\t\t\t\tlabel = `✅ ${model} (current)`;\n\t\t\t} else if (isDefault) {\n\t\t\t\tlabel = `👑 ${model} (default)`;\n\t\t\t} else {\n\t\t\t\tlabel = `🔥 ${model}`;\n\t\t\t}\n\t\t\tmodelOptions.unshift({ label, value: model });\n\t\t});\n\t}\n\n\t// Move current model to the top if it exists and isn't already highlighted\n\tif (currentModel && currentModel !== 'undefined') {\n\t\tconst isHighlighted = providerDef?.defaultModels?.includes(currentModel);\n\t\tconst currentIndex = modelOptions.findIndex(\n\t\t\t(opt) => opt.value === currentModel,\n\t\t);\n\n\t\tif (currentIndex >= 0 && !isHighlighted) {\n\t\t\t// Mark as current and move to top (after highlighted models)\n\t\t\tmodelOptions[currentIndex].label = CURRENT_LABEL_FORMAT(\n\t\t\t\tmodelOptions[currentIndex].value,\n\t\t\t);\n\t\t\tif (currentIndex > 0) {\n\t\t\t\tconst [current] = modelOptions.splice(currentIndex, 1);\n\t\t\t\t// Find position after highlighted models\n\t\t\t\tconst highlightedCount = providerDef?.defaultModels?.length || 0;\n\t\t\t\tmodelOptions.splice(highlightedCount, 0, current);\n\t\t\t}\n\t\t} else if (currentIndex < 0 && !isHighlighted) {\n\t\t\t// Current model not in fetched list, add it after highlighted models\n\t\t\tconst highlightedCount = providerDef?.defaultModels?.length || 0;\n\t\t\tmodelOptions.splice(highlightedCount, 0, {\n\t\t\t\tlabel: CURRENT_LABEL_FORMAT(currentModel),\n\t\t\t\tvalue: currentModel,\n\t\t\t});\n\t\t}\n\t}\n\n\treturn modelOptions;\n};\n\nconst handleSearch = async (\n\tmodels: string[],\n\tselect: any,\n\ttext: any,\n\tisCancel: any,\n): Promise<string | null> => {\n\t// Search for models\n\tconst searchTerm = await text({\n\t\tmessage: 'Enter search term for models:',\n\t\tplaceholder: 'e.g., gpt, llama',\n\t});\n\tif (isCancel(searchTerm)) {\n\t\treturn null;\n\t}\n\n\tlet filteredModels = models;\n\tif (searchTerm) {\n\t\tfilteredModels = models.filter((model: string) =>\n\t\t\tmodel.toLowerCase().includes((searchTerm as string).toLowerCase()),\n\t\t);\n\t}\n\n\t// Prepare filtered options\n\tlet searchOptions = filteredModels.slice(0, 20).map((model: string) => ({\n\t\tlabel: model,\n\t\tvalue: model,\n\t}));\n\n\tconst searchChoice = await select({\n\t\tmessage: `Choose your model (filtered by \"${searchTerm}\"):`,\n\t\toptions: [\n\t\t\t...searchOptions,\n\t\t\t{ label: 'Custom model name...', value: 'custom' },\n\t\t],\n\t});\n\n\tif (isCancel(searchChoice)) return null;\n\n\treturn searchChoice as string;\n};\n\nconst handleCustom = async (text: any): Promise<string | null> => {\n\tconst customModel = await text({\n\t\tmessage: 'Enter your custom model name:',\n\t\tvalidate: (value: string) => {\n\t\t\tif (!value) return 'Model name is required';\n\t\t\treturn;\n\t\t},\n\t});\n\n\tif (isCancel(customModel)) return null;\n\n\treturn customModel as string;\n};\n\nexport const selectModel = async (\n\tbaseUrl: string,\n\tapiKey: string,\n\tcurrentModel?: string,\n\tproviderDef?: ProviderDef,\n\tproviderName?: string,\n): Promise<string | null> => {\n\t// Default to provider's default model if none set\n\tif (!currentModel || currentModel === 'undefined') {\n\t\tcurrentModel = providerDef?.defaultModels?.[0];\n\t}\n\n\tconst s = spinner();\n\ts.start('Fetching available models...');\n\tconst models = await fetchAndFilterModels(baseUrl, apiKey, providerDef);\n\ts.stop(`${providerName || 'Provider'}: ${models.length} models available`);\n\n\tlet selectedModel: string | null = null;\n\n\tif (models.length > 0) {\n\t\tconst { select, text, isCancel } = await import('@clack/prompts');\n\n\t\tlet modelOptions = prepareModelOptions(models, currentModel, providerDef);\n\n\t\t// Limit to max 10 models to prevent UI breaking in terminals\n\t\tconst maxModels = 10;\n\t\tif (modelOptions.length > maxModels) {\n\t\t\tmodelOptions = modelOptions.slice(0, maxModels);\n\t\t}\n\n\t\tlet modelChoice = await select({\n\t\t\tmessage: 'Choose your model:',\n\t\t\toptions: [\n\t\t\t\t{ label: '🔍 Search models...', value: 'search' },\n\t\t\t\t...modelOptions,\n\t\t\t\t{ label: 'Custom model name...', value: 'custom' },\n\t\t\t],\n\t\t\tinitialValue: modelOptions.length > 0 ? modelOptions[0].value : undefined,\n\t\t});\n\n\t\tif (isCancel(modelChoice)) return null;\n\n\t\tif (modelChoice === 'search') {\n\t\t\tconst searchChoice = await handleSearch(models, select, text, isCancel);\n\t\t\tif (searchChoice === null) return null;\n\t\t\tmodelChoice = searchChoice;\n\t\t}\n\n\t\tif (modelChoice === 'custom') {\n\t\t\tselectedModel = await handleCustom(text);\n\t\t\tif (selectedModel === null) return null;\n\t\t} else {\n\t\t\tselectedModel = modelChoice as string;\n\t\t}\n\t} else {\n\t\t// Fallback to manual input\n\t\tif (providerDef?.isLocal) {\n\t\t\tconsole.log(\n\t\t\t\t`No models found on ${providerName || 'local provider'}. Please download a model first, then run \\`aicommits model\\` to select it.`,\n\t\t\t);\n\t\t\treturn null;\n\t\t}\n\t\tconsole.log(\n\t\t\t'Could not fetch available models. Please specify a model name manually.',\n\t\t);\n\t\tconst { text, isCancel } = await import('@clack/prompts');\n\t\ttry {\n\t\t\tconst model = await text({\n\t\t\t\tmessage: 'Enter your model name:',\n\t\t\t\tvalidate: (value) => {\n\t\t\t\t\tif (!value) return 'Model name is required';\n\t\t\t\t\treturn;\n\t\t\t\t},\n\t\t\t});\n\t\t\tif (isCancel(model)) return null;\n\t\t\tselectedModel = model as string;\n\t\t} catch {\n\t\t\treturn null;\n\t\t}\n\t}\n\n\treturn selectedModel;\n};\n"
  },
  {
    "path": "src/feature/providers/base.ts",
    "content": "import { fetchModels } from '../models.js';\nimport type { ValidConfig } from '../../utils/config-types.js';\n\nexport type ProviderDef = {\n\tname: string;\n\tdisplayName: string;\n\tbaseUrl: string;\n\tapiKeyFormat?: string;\n\tmodelsFilter?: (models: any[]) => string[];\n\tdefaultModels: string[];\n\trequiresApiKey: boolean;\n\theaders?: Record<string, string>;\n\tcacheModels?: boolean;\n\tisLocal?: boolean;\n};\n\nexport class Provider {\n\tprotected config: ValidConfig;\n\tprotected def: ProviderDef;\n\n\tconstructor(def: ProviderDef, config: ValidConfig) {\n\t\tthis.def = def;\n\t\tthis.config = config;\n\t}\n\n\tget name(): string {\n\t\treturn this.def.name;\n\t}\n\n\tget displayName(): string {\n\t\treturn this.def.displayName;\n\t}\n\n\tgetDefinition(): ProviderDef {\n\t\treturn this.def;\n\t}\n\n\tasync setup(): Promise<[string, string][]> {\n\t\tconst { text, password, isCancel } = await import('@clack/prompts');\n\t\tconst updates: [string, string][] = [];\n\n\t\tif (this.def.requiresApiKey) {\n\t\t\tconst currentKey = this.getApiKey();\n\t\t\tconst apiKey = await password({\n\t\t\t\tmessage: currentKey\n\t\t\t\t\t? `Enter your API key (leave empty to keep current: ${currentKey.substring(\n\t\t\t\t\t\t\t0,\n\t\t\t\t\t\t\t4\n\t\t\t\t\t  )}****):`\n\t\t\t\t\t: 'Enter your API key:',\n\t\t\t\tvalidate: (value) => {\n\t\t\t\t\tif (!value && !currentKey) return 'API key is required';\n\t\t\t\t\treturn;\n\t\t\t\t},\n\t\t\t});\n\t\t\tif (isCancel(apiKey)) {\n\t\t\t\tthrow new Error('Setup cancelled');\n\t\t\t}\n\t\t\tif (apiKey) {\n\t\t\t\tupdates.push(['OPENAI_API_KEY', apiKey as string]);\n\t\t\t}\n\t\t}\n\n\t\tif (this.name === 'ollama') {\n\t\t\tconst currentEndpoint = this.getBaseUrl();\n\t\t\tconst endpoint = await text({\n\t\t\t\tmessage: 'Enter Ollama endpoint (leave empty for default):',\n\t\t\t\tplaceholder: currentEndpoint,\n\t\t\t});\n\t\t\tif (isCancel(endpoint)) {\n\t\t\t\tthrow new Error('Setup cancelled');\n\t\t\t}\n\t\t\tif (endpoint && endpoint !== 'http://localhost:11434/v1') {\n\t\t\t\tupdates.push(['OPENAI_BASE_URL', endpoint as string]);\n\t\t\t}\n\t\t}\n\n\t\treturn updates;\n\t}\n\n\tasync getModels(): Promise<{ models: string[]; error?: string }> {\n\t\tconst baseUrl = this.getBaseUrl();\n\t\tconst apiKey = this.getApiKey() || '';\n\t\tconst result = await fetchModels({\n\t\t\tbaseUrl,\n\t\t\tapiKey,\n\t\t\tcacheModels: this.def.cacheModels,\n\t\t});\n\t\tif (result.error) return { models: [], error: result.error };\n\n\t\tconst modelsArray = Array.isArray(result.models) ? result.models : [];\n\t\tlet models: string[];\n\t\tif (this.def.modelsFilter) {\n\t\t\tmodels = this.def.modelsFilter(modelsArray);\n\t\t} else {\n\t\t\t// Fallback: just use model ids/names\n\t\t\tmodels = modelsArray.map((model) => model.id || model.name).filter(Boolean) as string[];\n\t\t}\n\n\t\treturn { models };\n\t}\n\n\tgetApiKey(): string | undefined {\n\t\treturn this.def.requiresApiKey ? this.config.OPENAI_API_KEY : undefined;\n\t}\n\n\tgetBaseUrl(): string {\n\t\tif (this.name === 'custom') {\n\t\t\treturn this.config.OPENAI_BASE_URL || '';\n\t\t}\n\t\treturn this.def.baseUrl;\n\t}\n\n\tgetDefaultModel(): string {\n\t\treturn this.def.defaultModels[0] || '';\n\t}\n\n\tgetHighlightedModels(): string[] {\n\t\treturn this.def.defaultModels;\n\t}\n\n\tgetHeaders(): Record<string, string> | undefined {\n\t\treturn this.def.headers;\n\t}\n\n\tvalidateConfig(): { valid: boolean; errors: string[] } {\n\t\tconst errors: string[] = [];\n\t\tif (this.def.requiresApiKey && !this.getApiKey()) {\n\t\t\terrors.push(`${this.displayName} API key is required`);\n\t\t}\n\t\tif (this.name === 'custom' && !this.getBaseUrl()) {\n\t\t\terrors.push('Custom endpoint is required');\n\t\t}\n\t\treturn { valid: errors.length === 0, errors };\n\t}\n}\n"
  },
  {
    "path": "src/feature/providers/groq.ts",
    "content": "import { ProviderDef } from './base.js';\n\nexport const GroqProvider: ProviderDef = {\n\tname: 'groq',\n\tdisplayName: 'Groq',\n\tbaseUrl: 'https://api.groq.com/openai/v1',\n\tapiKeyFormat: 'gsk_',\n\tmodelsFilter: (models) =>\n\t\tmodels\n\t\t\t.filter(\n\t\t\t\t(m: any) =>\n\t\t\t\t\tm.id && (!m.type || m.type === 'chat' || m.type === 'language'),\n\t\t\t)\n\t\t\t.map((m: any) => m.id),\n\tdefaultModels: [\n\t\t'openai/gpt-oss-120b',\n\t\t'llama-3.1-8b-instant',\n\t\t'openai/gpt-oss-20b',\n\t],\n\trequiresApiKey: true,\n};\n"
  },
  {
    "path": "src/feature/providers/index.ts",
    "content": "import { Provider, type ProviderDef } from './base.js';\nimport type { ValidConfig } from '../../utils/config-types.js';\nimport { providers } from './providers-data.js';\n\nexport { Provider } from './base.js';\nexport type { ProviderDef } from './base.js';\nexport { providers };\n\nexport function getProvider(config: ValidConfig): Provider | null {\n\tconst providerName = config.provider;\n\tconst pDef = providers.find((p) => p.name === providerName);\n\treturn pDef ? new Provider(pDef, config) : null;\n}\n\nexport function getAvailableProviders(): { value: string; label: string }[] {\n\treturn providers.map((p) => ({\n\t\tvalue: p.name,\n\t\tlabel: p.displayName,\n\t}));\n}\n\nexport function getProviderBaseUrl(providerName: string): string {\n\tconst provider = providers.find((p) => p.name === providerName);\n\treturn provider?.baseUrl || '';\n}\n\nexport function getProviderDef(providerName: string): ProviderDef | undefined {\n\treturn providers.find((p) => p.name === providerName);\n}\n"
  },
  {
    "path": "src/feature/providers/lmstudio.ts",
    "content": "import { ProviderDef } from './base.js';\n\nexport const LMStudioProvider: ProviderDef = {\n\tname: 'lmstudio',\n\tdisplayName: 'LM Studio (local)',\n\tbaseUrl: 'http://localhost:1234/v1',\n\tmodelsFilter: (models) =>\n\t\tmodels\n\t\t\t.filter((m: any) => !m.type || m.type === 'chat' || m.type === 'language')\n\t\t\t.map((m: any) => m.id),\n\tdefaultModels: ['qwen/qwen3-4b-2507', 'qwen/qwen3-8b'],\n\trequiresApiKey: false,\n\tcacheModels: false,\n\tisLocal: true,\n};\n"
  },
  {
    "path": "src/feature/providers/ollama.ts",
    "content": "import { ProviderDef } from './base.js';\n\nexport const OllamaProvider: ProviderDef = {\n\tname: 'ollama',\n\tdisplayName: 'Ollama (local)',\n\tbaseUrl: 'http://localhost:11434/v1',\n\tmodelsFilter: (models) =>\n\t\tmodels.filter((m: any) => m.id || m.name).map((m: any) => m.id || m.name),\n\tdefaultModels: ['qwen3.5:4b', 'llama3.2:latest'],\n\trequiresApiKey: false,\n\tcacheModels: false,\n\tisLocal: true,\n};\n"
  },
  {
    "path": "src/feature/providers/openai.ts",
    "content": "import { ProviderDef } from './base.js';\n\nexport const OpenAiProvider: ProviderDef = {\n\tname: 'openai',\n\tdisplayName: 'OpenAI',\n\tbaseUrl: 'https://api.openai.com/v1',\n\tapiKeyFormat: 'sk-',\n\tmodelsFilter: (models) =>\n\t\tmodels\n\t\t\t.filter(\n\t\t\t\t(m: any) =>\n\t\t\t\t\tm.id &&\n\t\t\t\t\t(m.id.includes('gpt') ||\n\t\t\t\t\t\tm.id.includes('o1') ||\n\t\t\t\t\t\tm.id.includes('o3') ||\n\t\t\t\t\t\tm.id.includes('o4') ||\n\t\t\t\t\t\tm.id.includes('o5') ||\n\t\t\t\t\t\t!m.type ||\n\t\t\t\t\t\tm.type === 'chat')\n\t\t\t)\n\t\t\t.map((m: any) => m.id),\n\tdefaultModels: ['gpt-5-mini', 'gpt-4o-mini', 'gpt-4o', 'gpt-5-nano'],\n\trequiresApiKey: true,\n};\n"
  },
  {
    "path": "src/feature/providers/openaiCustom.ts",
    "content": "import { ProviderDef } from './base.js';\n\nexport const OpenAiCustom: ProviderDef = {\n\tname: 'custom',\n\tdisplayName: 'Custom (OpenAI-compatible)',\n\tbaseUrl: '',\n\tmodelsFilter: (models) =>\n\t\tmodels\n\t\t\t.filter((m: any) => !m.type || m.type === 'chat' || m.type === 'language')\n\t\t\t.map((m: any) => m.id),\n\tdefaultModels: [],\n\trequiresApiKey: true,\n};\n"
  },
  {
    "path": "src/feature/providers/openrouter.ts",
    "content": "import { ProviderDef } from './base.js';\n\nexport const OpenRouterProvider: ProviderDef = {\n\tname: 'openrouter',\n\tdisplayName: 'OpenRouter',\n\tbaseUrl: 'https://openrouter.ai/api/v1',\n\tapiKeyFormat: 'sk-or-v1-',\n\tmodelsFilter: (models) =>\n\t\tmodels\n\t\t\t.filter((m: any) => m.id && (!m.type || m.type === 'chat'))\n\t\t\t.map((m: any) => m.id),\n\tdefaultModels: ['openai/gpt-oss-20b:free', 'z-ai/glm-4.5-air:free'],\n\trequiresApiKey: true,\n\theaders: {\n\t\t'HTTP-Referer': 'https://github.com/nutlope/aicommits',\n\t\t'X-Title': 'aicommits',\n\t},\n};\n"
  },
  {
    "path": "src/feature/providers/providers-data.ts",
    "content": "import { TogetherProvider } from './together.js';\nimport { OpenAiProvider } from './openai.js';\nimport { OllamaProvider } from './ollama.js';\nimport { OpenAiCustom } from './openaiCustom.js';\nimport { OpenRouterProvider } from './openrouter.js';\nimport { LMStudioProvider } from './lmstudio.js';\nimport { GroqProvider } from './groq.js';\nimport { XAiProvider } from './xai.js';\n\nexport const providers = [\n\tTogetherProvider,\n\tOpenAiProvider,\n\tGroqProvider,\n\tXAiProvider,\n\tOllamaProvider,\n\tLMStudioProvider,\n\tOpenRouterProvider,\n\tOpenAiCustom,\n];\n"
  },
  {
    "path": "src/feature/providers/together.ts",
    "content": "import { ProviderDef } from './base.js';\n\nexport const TogetherProvider: ProviderDef = {\n\tname: 'togetherai',\n\tdisplayName: 'Together AI (recommended)',\n\tbaseUrl: 'https://api.together.xyz/v1',\n\tapiKeyFormat: 'tgp_',\n\tmodelsFilter: (models) =>\n\t\tmodels\n\t\t\t.filter(\n\t\t\t\t(m: any) =>\n\t\t\t\t\t(!m.type || m.type === 'chat' || m.type === 'language') &&\n\t\t\t\t\t!m.id.toLowerCase().includes('vision'),\n\t\t\t)\n\t\t\t.map((m: any) => m.id),\n\tdefaultModels: [\n\t\t'Qwen/Qwen3-Next-80B-A3B-Instruct',\n\t\t'zai-org/GLM-4.5-Air-FP8',\n\t\t'meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo',\n\t],\n\trequiresApiKey: true,\n};\n"
  },
  {
    "path": "src/feature/providers/xai.ts",
    "content": "import { ProviderDef } from './base.js';\n\nexport const XAiProvider: ProviderDef = {\n\tname: 'xai',\n\tdisplayName: 'xAI',\n\tbaseUrl: 'https://api.x.ai/v1',\n\tapiKeyFormat: 'xai-',\n\tmodelsFilter: (models) =>\n\t\tmodels\n\t\t\t.filter(\n\t\t\t\t(m: any) =>\n\t\t\t\t\tm.id && (!m.type || m.type === 'chat' || m.type === 'language'),\n\t\t\t)\n\t\t\t.map((m: any) => m.id),\n\tdefaultModels: ['grok-4.1-fast', 'grok-4-fast', 'grok-code-fast-1'],\n\trequiresApiKey: true,\n};\n"
  },
  {
    "path": "src/utils/auto-update.ts",
    "content": "import { exec } from 'child_process';\nimport { promisify } from 'util';\n\nconst execAsync = promisify(exec);\n\nexport interface AutoUpdateOptions {\n\tpkg: { name: string; version: string };\n\tdistTag?: string;\n\theadless?: boolean;\n}\n\n// Parse version string into comparable parts\n// Supports: 1.2.3, 1.2.3-alpha, 1.2.3-alpha.1, 1.2.3-develop.14\nfunction parseVersion(version: string): {\n\tmajor: number;\n\tminor: number;\n\tpatch: number;\n\tprerelease: string | null;\n\tprereleaseNum: number;\n} {\n\t// Remove 'v' prefix if present\n\tconst cleanVersion = version.replace(/^v/, '');\n\n\t// Match: major.minor.patch[-prerelease.number]\n\tconst match = cleanVersion.match(\n\t\t/^(\\d+)\\.(\\d+)\\.(\\d+)(?:-([a-zA-Z]+)(?:\\.(\\d+))?)?$/\n\t);\n\n\tif (!match) {\n\t\treturn { major: 0, minor: 0, patch: 0, prerelease: null, prereleaseNum: 0 };\n\t}\n\n\tconst [, major, minor, patch, prerelease, prereleaseNum] = match;\n\treturn {\n\t\tmajor: parseInt(major, 10),\n\t\tminor: parseInt(minor, 10),\n\t\tpatch: parseInt(patch, 10),\n\t\tprerelease: prerelease || null,\n\t\tprereleaseNum: prereleaseNum ? parseInt(prereleaseNum, 10) : 0,\n\t};\n}\n\n// Compare two versions\n// Returns: -1 if v1 < v2, 0 if equal, 1 if v1 > v2\nfunction compareVersions(v1: string, v2: string): number {\n\tconst p1 = parseVersion(v1);\n\tconst p2 = parseVersion(v2);\n\n\t// Compare major.minor.patch\n\tif (p1.major !== p2.major) return p1.major > p2.major ? 1 : -1;\n\tif (p1.minor !== p2.minor) return p1.minor > p2.minor ? 1 : -1;\n\tif (p1.patch !== p2.patch) return p1.patch > p2.patch ? 1 : -1;\n\n\t// Handle prerelease versions\n\t// Stable > prerelease\n\tif (!p1.prerelease && p2.prerelease) return 1;\n\tif (p1.prerelease && !p2.prerelease) return -1;\n\n\t// Both are prereleases or both are stable\n\tif (!p1.prerelease && !p2.prerelease) return 0;\n\n\t// Compare prerelease numbers\n\tif (p1.prereleaseNum !== p2.prereleaseNum) {\n\t\treturn p1.prereleaseNum > p2.prereleaseNum ? 1 : -1;\n\t}\n\n\treturn 0;\n}\n\n// Fetch latest version from npm registry\nasync function fetchLatestVersion(\n\tpackageName: string,\n\tdistTag: string\n): Promise<string | null> {\n\ttry {\n\t\tconst response = await fetch(\n\t\t\t`https://registry.npmjs.org/${packageName}/${distTag}`,\n\t\t\t{\n\t\t\t\theaders: {\n\t\t\t\t\tAccept: 'application/json',\n\t\t\t\t},\n\t\t\t}\n\t\t);\n\n\t\tif (!response.ok) {\n\t\t\treturn null;\n\t\t}\n\n\t\tconst data = await response.json();\n\t\treturn data.version || null;\n\t} catch {\n\t\treturn null;\n\t}\n}\n\n// Check if running as global installation\nasync function checkIfGlobalInstallation(packageName: string): Promise<boolean> {\n\ttry {\n\t\tconst { stdout } = await execAsync(`npm list -g ${packageName} --depth=0`);\n\t\treturn stdout.includes(packageName);\n\t} catch {\n\t\treturn false;\n\t}\n}\n\n// Run npm update in background\nasync function runBackgroundUpdate(\n\tpackageName: string,\n\tdistTag: string\n): Promise<void> {\n\treturn new Promise((resolve, reject) => {\n\t\tconst child = exec(`npm install -g ${packageName}@${distTag}`, {\n\t\t\ttimeout: 120000, // 2 minute timeout\n\t\t\tenv: { ...process.env, NPM_CONFIG_PROGRESS: 'false' },\n\t\t});\n\n\t\tchild.on('error', reject);\n\n\t\tchild.on('exit', (code) => {\n\t\t\tif (code === 0 || code === null) {\n\t\t\t\tresolve();\n\t\t\t} else {\n\t\t\t\treject(new Error(`npm install exited with code ${code}`));\n\t\t\t}\n\t\t});\n\t});\n}\n\nexport async function checkAndAutoUpdate(\n\toptions: AutoUpdateOptions\n): Promise<void> {\n\tconst { pkg, distTag = 'latest', headless = false } = options;\n\n\tif (headless) {\n\t\treturn;\n\t}\n\n\t// Skip for development/semantic-release versions\n\tif (\n\t\tpkg.version === '0.0.0-semantic-release' ||\n\t\tpkg.version.includes('semantic-release')\n\t) {\n\t\treturn;\n\t}\n\n\t// Determine correct dist tag based on current version\n\tconst currentDistTag = pkg.version.includes('-') ? 'develop' : distTag;\n\n\t// Debug logging\n\tif (process.env.DEBUG || process.env.AICOMMITS_DEBUG) {\n\t\tconsole.log(`[auto-update] Current version: ${pkg.version}`);\n\t\tconsole.log(`[auto-update] Checking ${currentDistTag} tag...`);\n\t}\n\n\t// Fetch latest version from npm\n\tconst latestVersion = await fetchLatestVersion(pkg.name, currentDistTag);\n\n\tif (!latestVersion) {\n\t\tif (process.env.DEBUG || process.env.AICOMMITS_DEBUG) {\n\t\t\tconsole.log('[auto-update] Could not fetch latest version');\n\t\t}\n\t\treturn;\n\t}\n\n\tif (process.env.DEBUG || process.env.AICOMMITS_DEBUG) {\n\t\tconsole.log(`[auto-update] Latest version: ${latestVersion}`);\n\t}\n\n\t// Compare versions\n\tconst comparison = compareVersions(pkg.version, latestVersion);\n\n\tif (comparison >= 0) {\n\t\t// Local version is same or newer\n\t\tif (process.env.DEBUG || process.env.AICOMMITS_DEBUG) {\n\t\t\tconsole.log('[auto-update] No update needed');\n\t\t}\n\t\treturn;\n\t}\n\n\t// Update needed!\n\tconsole.log(`Updating aicommits from v${pkg.version} to v${latestVersion}...`);\n\n\t// Check if global installation\n\tconst isGlobal = await checkIfGlobalInstallation(pkg.name);\n\tif (!isGlobal) {\n\t\tconsole.log(\n\t\t\t'Note: aicommits is installed locally. Auto-update skipped for local installations.'\n\t\t);\n\t\treturn;\n\t}\n\n\ttry {\n\t\tawait runBackgroundUpdate(pkg.name, currentDistTag);\n\t\tconsole.log(`✓ aicommits updated to v${latestVersion}`);\n\t\tconsole.log('Please restart aic to use the new version.');\n\t} catch (error) {\n\t\tconsole.log('Auto-update failed. You can manually update with:');\n\t\tconsole.log(`  npm install -g aicommits@${currentDistTag}`);\n\t}\n}\n"
  },
  {
    "path": "src/utils/clipboard.ts",
    "content": "import { execa } from 'execa';\n\n/**\n * Copy text to the system clipboard using native CLI tools.\n * macOS: pbcopy\n * Windows: clip\n * Linux: wl-copy (Wayland), xclip or xsel (X11)\n */\nexport async function copyToClipboard(message: string): Promise<boolean> {\n\ttry {\n\t\tif (process.platform === 'darwin') {\n\t\t\t// macOS - use pbcopy\n\t\t\tawait execa('pbcopy', { input: message });\n\t\t} else if (process.platform === 'win32') {\n\t\t\t// Windows - use clip\n\t\t\tawait execa('clip', { input: message });\n\t\t} else {\n\t\t\t/**\n\t\t\t * Linux:\n\t\t\t * Ignore stdout/stderr to prevent the CLI from hanging while\n\t\t\t * Linux clipboard tools fork background processes to serve the content.\n\t\t\t */\n\t\t\tconst options = {\n\t\t\t\tinput: message,\n\t\t\t\tstdio: ['pipe', 'ignore', 'ignore'] as const,\n\t\t\t};\n\n\t\t\ttry {\n\t\t\t\t// Try Wayland (wl-copy)\n\t\t\t\tawait execa('wl-copy', options);\n\t\t\t} catch {\n\t\t\t\ttry {\n\t\t\t\t\t// Fallback to xclip (X11)\n\t\t\t\t\tawait execa('xclip', ['-selection', 'clipboard'], options);\n\t\t\t\t} catch {\n\t\t\t\t\t// Fallback to xsel (X11)\n\t\t\t\t\tawait execa('xsel', ['--clipboard', '--input'], options);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn true;\n\t} catch {\n\t\treturn false;\n\t}\n}\n"
  },
  {
    "path": "src/utils/commit-helpers.ts",
    "content": "import { KnownError } from './error.js';\nimport { isInteractive } from './headless.js';\n\nexport const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));\n\nexport const retry = async <T>(fn: () => Promise<T>, attempts: number = 3, delay: number = 1000): Promise<T> => {\n\tfor (let i = 0; i < attempts; i++) {\n\t\ttry {\n\t\t\treturn await fn();\n\t\t} catch (error) {\n\t\t\tif (i === attempts - 1) throw error;\n\t\t\tawait sleep(delay);\n\t\t}\n\t}\n\tthrow new Error('Retry failed');\n};\n\nexport const getCommitMessage = async (\n\tmessages: string[],\n\tskipConfirm: boolean\n): Promise<string | null> => {\n\tconst { select, confirm, isCancel } = await import('@clack/prompts');\n\tconst { dim } = await import('kolorist');\n\n\t// Single message case\n\tif (messages.length === 1) {\n\t\tconst [message] = messages;\n\n\t\tif (skipConfirm) {\n\t\t\treturn message;\n\t\t}\n\n\t\tif (!isInteractive()) {\n\t\t\tthrow new KnownError('Interactive terminal required for commit message confirmation. Use --yes flag to skip confirmation.');\n\t\t}\n\n\t\tconsole.log(`\\n\\x1b[1m${message}\\x1b[0m\\n`);\n\t\tconst confirmed = await confirm({\n\t\t\tmessage: 'Use this commit message?',\n\t\t});\n\n\t\treturn confirmed && !isCancel(confirmed) ? message : null;\n\t}\n\n\t// Multiple messages case\n\tif (skipConfirm) {\n\t\treturn messages[0];\n\t}\n\n\tif (!isInteractive()) {\n\t\tthrow new KnownError('Interactive terminal required for commit message selection. Use --yes flag to skip selection and use the first message.');\n\t}\n\n\tconst selected = await select({\n\t\tmessage: `Pick a commit message to use: ${dim('(Ctrl+c to exit)')}`,\n\t\toptions: messages.map((value) => ({ label: value, value })),\n\t});\n\n\treturn isCancel(selected) ? null : (selected as string);\n};\n"
  },
  {
    "path": "src/utils/config-runtime.ts",
    "content": "import fs from 'fs/promises';\nimport path from 'path';\nimport os from 'os';\nimport ini from 'ini';\nimport { fileExists } from './fs.js';\nimport { KnownError } from './error.js';\nimport {\n\tconfigParsers,\n\thasOwn,\n\ttype ValidConfig,\n\ttype ConfigKeys,\n\ttype RawConfig,\n} from './config-types.js';\nimport { providers } from '../feature/providers/providers-data.js';\n\nconst getDefaultBaseUrl = (): string => {\n\tconst openaiProvider = providers.find((p) => p.name === 'openai');\n\treturn openaiProvider?.baseUrl || '';\n};\n\nconst detectProvider = (\n\tbaseUrl?: string,\n\tapiKey?: string\n): string | undefined => {\n\tif (baseUrl) {\n\t\tconst matchingProvider = providers.find(\n\t\t\t(p) =>\n\t\t\t\tp.baseUrl === baseUrl ||\n\t\t\t\t(p.name === 'ollama' && baseUrl.startsWith(p.baseUrl.slice(0, -3)))\n\t\t);\n\t\tif (matchingProvider) {\n\t\t\treturn matchingProvider.name;\n\t\t} else {\n\t\t\treturn 'custom';\n\t\t}\n\t} else if (apiKey) {\n\t\treturn 'openai';\n\t}\n};\n\nconst getConfigPath = () => path.join(os.homedir(), '.aicommits');\n\nconst readConfigFile = async (): Promise<RawConfig> => {\n\tconst configExists = await fileExists(getConfigPath());\n\tif (!configExists) {\n\t\treturn Object.create(null);\n\t}\n\n\tconst configString = await fs.readFile(getConfigPath(), 'utf8');\n\treturn ini.parse(configString);\n};\n\nexport const getConfig = async (\n\tcliConfig?: RawConfig,\n\tenvConfig?: RawConfig,\n\tsuppressErrors?: boolean\n): Promise<ValidConfig> => {\n\tconst config = await readConfigFile();\n\n\t// Check for deprecated config properties\n\tif (hasOwn(config, 'proxy')) {\n\t\tconsole.warn('The \"proxy\" config property is deprecated and no longer supported');\n\t}\n\n\tconst parsedConfig: Record<string, unknown> = {};\n\tconst effectiveEnvConfig = envConfig ?? {};\n\n\tfor (const key of Object.keys(configParsers) as ConfigKeys[]) {\n\t\tconst parser = configParsers[key];\n\t\tconst value = cliConfig?.[key] ?? effectiveEnvConfig?.[key] ?? config[key];\n\n\t\tif (suppressErrors) {\n\t\t\ttry {\n\t\t\t\tparsedConfig[key] = parser(value);\n\t\t\t} catch {}\n\t\t} else {\n\t\t\tparsedConfig[key] = parser(value);\n\t\t}\n\t}\n\n\t// Detect provider from OPENAI_BASE_URL or default to OpenAI if only API key is set\n\tlet provider: string | undefined;\n\tlet baseUrl = parsedConfig.OPENAI_BASE_URL as string | undefined;\n\tconst apiKey = parsedConfig.OPENAI_API_KEY as string | undefined;\n\n\t// If only API key is provided without base URL, default to OpenAI\n\tif (!baseUrl && apiKey) {\n\t\tbaseUrl = getDefaultBaseUrl();\n\t\tparsedConfig.OPENAI_BASE_URL = baseUrl;\n\t}\n\n\tprovider = detectProvider(baseUrl, apiKey);\n\n\treturn { ...parsedConfig, model: parsedConfig.OPENAI_MODEL, provider } as ValidConfig;\n};\n\nexport const setConfigs = async (keyValues: [key: string, value: string][]) => {\n\tconst config = await readConfigFile();\n\n\tfor (const [key, value] of keyValues) {\n\t\tif (!hasOwn(configParsers, key)) {\n\t\t\tthrow new KnownError(`Invalid config property: ${key}`);\n\t\t}\n\n\t\tif (value === '') {\n\t\t\tdelete config[key as ConfigKeys];\n\t\t} else {\n\t\t\tconst parsed = configParsers[key as ConfigKeys](value);\n\t\t\tconfig[key as ConfigKeys] = parsed as any;\n\t\t}\n\t}\n\n\tawait fs.writeFile(getConfigPath(), ini.stringify(config), 'utf8');\n};\n"
  },
  {
    "path": "src/utils/config-types.ts",
    "content": "import { KnownError } from './error.js';\n\nconst commitTypes = ['plain', 'conventional', 'gitmoji', 'subject+body'] as const;\n\nexport type CommitType = (typeof commitTypes)[number];\n\nconst { hasOwnProperty } = Object.prototype;\nexport const hasOwn = (object: unknown, key: PropertyKey) =>\n\thasOwnProperty.call(object, key);\n\nconst parseAssert = (name: string, condition: boolean, message: string) => {\n\tif (!condition) {\n\t\tthrow new KnownError(`Invalid config property ${name}: ${message}`);\n\t}\n};\n\nconst configParsers = {\n\tOPENAI_API_KEY(key?: string) {\n\t\treturn key;\n\t},\n\tOPENAI_BASE_URL(key?: string) {\n\t\treturn key;\n\t},\n\tOPENAI_MODEL(key?: string) {\n\t\treturn key || '';\n\t},\n\tlocale(locale?: string) {\n\t\tif (!locale) {\n\t\t\treturn 'en';\n\t\t}\n\t\tparseAssert('locale', !!locale, 'Cannot be empty');\n\t\tparseAssert(\n\t\t\t'locale',\n\t\t\t/^[a-z-]+$/i.test(locale),\n\t\t\t'Must be a valid locale (letters and dashes/underscores).'\n\t\t);\n\t\treturn locale;\n\t},\n\tgenerate(count?: string) {\n\t\tif (!count) {\n\t\t\treturn 1;\n\t\t}\n\t\tparseAssert('generate', /^\\d+$/.test(count), 'Must be an integer');\n\t\tconst parsed = Number(count);\n\t\tparseAssert('generate', parsed > 0, 'Must be greater than 0');\n\t\tparseAssert('generate', parsed <= 5, 'Must be less or equal to 5');\n\t\treturn parsed;\n\t},\n\ttype(type?: string) {\n\t\tif (!type) {\n\t\t\treturn 'plain';\n\t\t}\n\t\tparseAssert(\n\t\t\t'type',\n\t\t\tcommitTypes.includes(type as CommitType),\n\t\t\t'Invalid commit type'\n\t\t);\n\t\treturn type as CommitType;\n\t},\n\tproxy(url?: string) {\n\t\tif (!url || url.length === 0) {\n\t\t\treturn undefined;\n\t\t}\n\t\tthrow new KnownError(\n\t\t\t'The \"proxy\" config property is deprecated and no longer supported.'\n\t\t);\n\t},\n\ttimeout(timeout?: string) {\n\t\tif (!timeout) {\n\t\t\treturn undefined;\n\t\t}\n\n\t\tparseAssert('timeout', /^\\d+$/.test(timeout), 'Must be an integer');\n\n\t\tconst parsed = Number(timeout);\n\t\tparseAssert('timeout', parsed >= 500, 'Must be greater than 500ms');\n\n\t\treturn parsed;\n\t},\n\t'max-length'(maxLength?: string) {\n\t\tif (!maxLength) {\n\t\t\treturn 72;\n\t\t}\n\t\tparseAssert('max-length', /^\\d+$/.test(maxLength), 'Must be an integer');\n\t\tconst parsed = Number(maxLength);\n\t\tparseAssert(\n\t\t\t'max-length',\n\t\t\tparsed >= 20,\n\t\t\t'Must be greater than 20 characters'\n\t\t);\n\t\treturn parsed;\n\t},\n} as const;\n\ntype ConfigKeys = keyof typeof configParsers;\n\ntype RawConfig = {\n\t[key in ConfigKeys]?: string;\n};\n\nexport type ValidConfig = {\n\t[Key in ConfigKeys]: ReturnType<(typeof configParsers)[Key]>;\n} & {\n\tOPENAI_API_KEY: string | undefined;\n\tOPENAI_BASE_URL: string | undefined;\n\tOPENAI_MODEL: string;\n\tmodel: string;\n\tprovider: string | undefined;\n\ttimeout: number | undefined;\n};\n\nexport { configParsers, type ConfigKeys, type RawConfig };\n"
  },
  {
    "path": "src/utils/constants.ts",
    "content": "// Label formatters\nexport const CURRENT_LABEL_FORMAT = (model: string) => `✅ ${model} (current)`;\nexport const PREFERRED_LABEL_FORMAT = (model: string) =>\n\t`[${model} - suggested]`;\n"
  },
  {
    "path": "src/utils/error.ts",
    "content": "import { dim, red } from 'kolorist';\nimport pkg from '../../package.json';\nconst { version } = pkg;\n\nexport class KnownError extends Error {}\n\nconst indent = '    ';\n\nexport const handleCliError = (error: unknown) => {\n\tif (error instanceof Error && !(error instanceof KnownError)) {\n\t\tif (error.stack) {\n\t\t\tconsole.error(dim(error.stack.split('\\n').slice(1).join('\\n')));\n\t\t}\n\t\tconsole.error(`\\n${indent}${dim(`aicommits v${version}`)}`);\n\t\tconsole.error(\n\t\t\t`\\n${indent}Please open a Bug report with the information above:`\n\t\t);\n\t\tconsole.error(\n\t\t\t`${indent}https://github.com/Nutlope/aicommits/issues/new/choose`\n\t\t);\n\t}\n};\n\nexport const handleCommandError = (error: unknown) => {\n\tprocess.stderr.write(`${red('✖')} ${(error as Error).message}\\n`);\n\thandleCliError(error);\n\tprocess.exit(1);\n};\n"
  },
  {
    "path": "src/utils/fs.ts",
    "content": "import fs from 'fs/promises';\n\n// lstat is used because this is also used to check if a symlink file exists\nexport const fileExists = (filePath: string) =>\n\tfs.lstat(filePath).then(\n\t\t() => true,\n\t\t() => false\n\t);\n"
  },
  {
    "path": "src/utils/git.ts",
    "content": "import { execa } from 'execa';\nimport { KnownError } from './error.js';\n\nexport const assertGitRepo = async () => {\n\tconst { stdout, failed } = await execa(\n\t\t'git',\n\t\t['rev-parse', '--show-toplevel'],\n\t\t{ reject: false }\n\t);\n\n\tif (failed) {\n\t\tthrow new KnownError('The current directory must be a Git repository!');\n\t}\n\n\treturn stdout;\n};\n\nconst excludeFromDiff = (path: string) => `:(exclude)${path}`;\n\nconst lockFilePatterns = [\n\t'package-lock.json',\n\t'pnpm-lock.yaml',\n\t// yarn.lock, Cargo.lock, Gemfile.lock, Pipfile.lock, etc.\n\t'*.lock',\n];\n\nconst isLockFile = (file: string) => {\n\treturn lockFilePatterns.some(pattern => {\n\t\tif (pattern.includes('*')) {\n\t\t\t// Simple glob match for *.lock\n\t\t\treturn file.endsWith('.lock');\n\t\t}\n\t\t// Match lock files by basename to handle subdirectories\n\t\treturn file.endsWith('/' + pattern) || file === pattern;\n\t});\n};\n\nconst filesToExclude = lockFilePatterns.map(excludeFromDiff);\n\nexport const getStagedDiff = async (excludeFiles?: string[]) => {\n\tconst diffCached = ['diff', '--cached', '--diff-algorithm=minimal'];\n\n\t// First, get all staged files without any excludes\n\tconst { stdout: allFilesOutput } = await execa('git', [\n\t\t...diffCached,\n\t\t'--name-only',\n\t\t...(excludeFiles ? excludeFiles.map(excludeFromDiff) : []),\n\t]);\n\n\tif (!allFilesOutput) {\n\t\treturn;\n\t}\n\n\tconst allFiles = allFilesOutput.split('\\n').filter(Boolean);\n\n\t// Check if all staged files are lock files\n\tconst hasNonLockFiles = allFiles.some(file => !isLockFile(file));\n\n\tlet excludes: string[] = [];\n\tif (hasNonLockFiles) {\n\t\t// If there are non-lock files, exclude lock files\n\t\texcludes = [...filesToExclude];\n\t}\n\t// If only lock files are staged, don't exclude them\n\n\texcludes = [\n\t\t...excludes,\n\t\t...(excludeFiles ? excludeFiles.map(excludeFromDiff) : []),\n\t];\n\n\t// Get files after applying excludes\n\tconst { stdout: files } = await execa('git', [\n\t\t...diffCached,\n\t\t'--name-only',\n\t\t...excludes,\n\t]);\n\n\tif (!files) {\n\t\treturn;\n\t}\n\n\tconst { stdout: diff } = await execa('git', [\n\t\t...diffCached,\n\t\t...excludes,\n\t]);\n\n\treturn {\n\t\tfiles: files.split('\\n'),\n\t\tdiff,\n\t};\n};\n\nexport const getStagedDiffForFiles = async (files: string[], excludeFiles?: string[]) => {\n\tconst diffCached = ['diff', '--cached', '--diff-algorithm=minimal'];\n\tconst excludes = [\n\t\t...filesToExclude,\n\t\t...(excludeFiles ? excludeFiles.map(excludeFromDiff) : []),\n\t];\n\n\tconst { stdout: diff } = await execa('git', [\n\t\t...diffCached,\n\t\t'--',\n\t\t...files,\n\t\t...excludes,\n\t]);\n\n\treturn {\n\t\tfiles,\n\t\tdiff,\n\t};\n};\n\nexport const getDetectedMessage = (files: string[]) =>\n\t`Detected ${files.length.toLocaleString()} staged file${\n\t\tfiles.length > 1 ? 's' : ''\n\t}`;\n"
  },
  {
    "path": "src/utils/headless.ts",
    "content": "export const isHeadless = () => !process.stdin.isTTY || !process.stdout.isTTY;\n\nexport const isInteractive = () =>\n\tBoolean(process.stdin.isTTY && process.stdout.isTTY && !process.env.CI);\n"
  },
  {
    "path": "src/utils/openai.ts",
    "content": "import { generateText } from 'ai';\nimport { createOpenAI } from '@ai-sdk/openai';\nimport { createOpenAICompatible } from '@ai-sdk/openai-compatible';\nimport { KnownError } from './error.js';\nimport type { CommitType } from './config-types.js';\nimport { generatePrompt, generateDescriptionPrompt } from './prompt.js';\nimport { isHeadless } from './headless.js';\n\nconst shouldLogDebug = () =>\n\tBoolean(process.env.DEBUG || process.env.AICOMMITS_DEBUG) && !isHeadless();\n\n/**\n * Extracts the actual response from reasoning model outputs.\n * Reasoning models (like DeepSeek R1, QwQ, etc.) include their thought process\n * in <think>...</think> tags. We need to extract the content after these tags.\n */\nconst extractResponseFromReasoning = (message: string): string => {\n\t// Pattern to match <think>...</think> tags and everything before the actual response\n\t// This handles both single-line and multi-line think blocks\n\tconst thinkPattern = /<think>[\\s\\S]*?<\\/think>/gi;\n\n\t// Remove all <think>...</think> blocks and any content before the first think block\n\tlet cleaned = message.replace(thinkPattern, '');\n\n\t// Remove any leading/trailing whitespace and newlines\n\tcleaned = cleaned.trim();\n\n\treturn cleaned;\n};\n\nconst sanitizeMessage = (message: string) => {\n\t// First, extract response from reasoning models if present\n\tlet processed = extractResponseFromReasoning(message);\n\n\t// Then apply existing sanitization\n \tconst sanitized = processed\n \t\t.trim()\n \t\t.split('\\n')[0] // Take only the first line\n \t\t.replace(/(\\w)\\.$/, '$1')\n \t\t.replace(/^[\"'`]|[\"'`]$/g, '') // Remove surrounding quotes\n \t\t.replace(/^<[^>]*>\\s*/, ''); // Remove leading tags\n\n \treturn sanitized;\n};\n\n/** Sanitize description/body (multi-line): strip reasoning blocks, trim, remove surrounding quotes. */\nconst sanitizeDescription = (message: string) => {\n\tlet processed = extractResponseFromReasoning(message);\n\treturn processed\n\t\t.trim()\n\t\t.replace(/^[\"'`]|[\"'`]$/g, '')\n\t\t.replace(/^<[^>]*>\\s*/, '');\n};\n\nconst deduplicateMessages = (array: string[]) => Array.from(new Set(array));\n\nconst shortenCommitMessage = async (\n\tprovider: any,\n\tmodel: string,\n\tmessage: string,\n\tmaxLength: number,\n\ttimeout: number\n) => {\n\tconst abortController = new AbortController();\n\tconst timeoutId = setTimeout(() => abortController.abort(), timeout);\n\n\ttry {\n\t\tconst result = await generateText({\n\t\t\tmodel: provider(model),\n\t\t\tsystem: `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.`,\n\t\t\tprompt: message,\n\t\t\ttemperature: 0.2,\n\t\t\tmaxRetries: 2,\n\t\t\tmaxOutputTokens: 500,\n\t\t\tabortSignal: abortController.signal,\n\t\t});\n\t\tclearTimeout(timeoutId);\n\t\treturn sanitizeMessage(result.text);\n\t} catch (error) {\n\t\tclearTimeout(timeoutId);\n\t\tthrow error;\n\t}\n};\n\nexport type GenerateCommitMessageOptions = {\n\tbaseUrl: string;\n\tapiKey: string;\n\tmodel: string;\n\tlocale: string;\n\tdiff: string;\n\tcompletions: number;\n\tmaxLength: number;\n\ttype: CommitType;\n\ttimeout: number;\n\tcustomPrompt?: string;\n\theaders?: Record<string, string>;\n};\n\nexport const generateCommitMessage = async ({\n\tbaseUrl,\n\tapiKey,\n\tmodel,\n\tlocale,\n\tdiff,\n\tcompletions,\n\tmaxLength,\n\ttype,\n\ttimeout,\n\tcustomPrompt,\n\theaders,\n}: GenerateCommitMessageOptions) => {\n\tif (shouldLogDebug()) {\n\t\tconsole.log('Diff being sent to AI:');\n\t\tconsole.log(diff);\n\t}\n\n\ttry {\n\t\tconst provider =\n\t\t\tbaseUrl === 'https://api.openai.com/v1'\n\t\t\t\t? createOpenAI({ apiKey })\n\t\t\t\t: createOpenAICompatible({\n\t\t\t\t\t\tname: 'custom',\n\t\t\t\t\t\tapiKey,\n\t\t\t\t\t\tbaseURL: baseUrl,\n\t\t\t\t\t\theaders,\n\t\t\t\t  });\n\n\t\tconst abortController = new AbortController();\n\t\tconst timeoutId = setTimeout(() => abortController.abort(), timeout);\n\n\t\tconst promises = Array.from({ length: completions }, () =>\n\t\t\tgenerateText({\n\t\t\t\tmodel: provider(model),\n\t\t\t\tsystem: generatePrompt(locale, maxLength, type, customPrompt),\n\t\t\t\tprompt: diff,\n\t\t\t\ttemperature: 0.4,\n\t\t\t\tmaxRetries: 2,\n\t\t\t\tmaxOutputTokens: 2000,\n\t\t\t\tabortSignal: abortController.signal,\n\t\t\t})\n\t\t);\n\t\tconst results = await (async () => {\n\t\t\ttry {\n\t\t\t\treturn await Promise.all(promises);\n\t\t\t} finally {\n\t\t\t\tclearTimeout(timeoutId);\n\t\t\t}\n\t\t})();\n\t\tlet texts = results.map((r) => r.text);\n\t\tlet messages = deduplicateMessages(\n\t\t\ttexts.map((text: string) => sanitizeMessage(text))\n\t\t);\n\n\t\t// Shorten messages that exceed maxLength\n\t\tconst MAX_SHORTEN_RETRIES = 3;\n\t\tfor (let retry = 0; retry < MAX_SHORTEN_RETRIES; retry++) {\n\t\t\tlet needsShortening = false;\n\t\t\tconst shortenedMessages = await Promise.all(\n\t\t\t\tmessages.map(async (msg) => {\n\t\t\t\t\tif (msg.length <= maxLength) {\n\t\t\t\t\t\treturn msg;\n\t\t\t\t\t}\n\t\t\t\t\tneedsShortening = true;\n\t\t\t\t\ttry {\n\t\t\t\t\t\treturn await shortenCommitMessage(provider, model, msg, maxLength, timeout);\n\t\t\t\t\t} catch (error) {\n\t\t\t\t\t\t// If shortening fails, keep the original and continue\n\t\t\t\t\t\treturn msg;\n\t\t\t\t\t}\n\t\t\t\t})\n\t\t\t);\n\t\t\tmessages = deduplicateMessages(shortenedMessages);\n\t\t\tif (!needsShortening) break;\n\t\t}\n\n\t\tconst usage = {\n\t\t\tprompt_tokens: results.reduce(\n\t\t\t\t(sum, r) => sum + ((r.usage as any).promptTokens || 0),\n\t\t\t\t0\n\t\t\t),\n\t\t\tcompletion_tokens: results.reduce(\n\t\t\t\t(sum, r) => sum + ((r.usage as any).completionTokens || 0),\n\t\t\t\t0\n\t\t\t),\n\t\t\ttotal_tokens: results.reduce(\n\t\t\t\t(sum, r) => sum + ((r.usage as any).totalTokens || 0),\n\t\t\t\t0\n\t\t\t),\n\t\t};\n\t\treturn { messages, usage };\n\t} catch (error) {\n\t\tconst errorAsAny = error as any;\n\n\t\t// Handle AbortController timeout\n\t\tif (\n\t\t\terrorAsAny.name === 'AbortError' ||\n\t\t\terrorAsAny.message?.includes('aborted') ||\n\t\t\terrorAsAny.message?.includes('This operation was aborted')\n\t\t) {\n\t\t\tthrow new KnownError(\n\t\t\t\t`Request timed out after ${timeout / 1000} seconds. The API took too long to respond. Try again or use a different model.`\n\t\t\t);\n\t\t}\n\n\t\tif (errorAsAny.code === 'ENOTFOUND') {\n\t\t\tthrow new KnownError(\n\t\t\t\t`Error connecting to ${errorAsAny.hostname} (${errorAsAny.syscall}). Are you connected to the internet?`\n\t\t\t);\n\t\t}\n\n\t\tif (errorAsAny.status === 429) {\n\t\t\tconst resetHeader = errorAsAny.headers?.get('x-ratelimit-reset');\n\t\t\tlet rateLimitMessage = 'Rate limit exceeded';\n\t\t\tif (resetHeader) {\n\t\t\t\tconst resetTime = parseInt(resetHeader);\n\t\t\t\tconst now = Date.now();\n\t\t\t\tconst waitMs = resetTime - now;\n\t\t\t\tconst waitSec = Math.ceil(waitMs / 1000);\n\t\t\t\tif (waitSec > 0) {\n\t\t\t\t\tlet timeStr: string;\n\t\t\t\t\tif (waitSec < 60) {\n\t\t\t\t\t\ttimeStr = `${waitSec} second${waitSec === 1 ? '' : 's'}`;\n\t\t\t\t\t} else if (waitSec < 3600) {\n\t\t\t\t\t\tconst minutes = Math.ceil(waitSec / 60);\n\t\t\t\t\t\ttimeStr = `${minutes} minute${minutes === 1 ? '' : 's'}`;\n\t\t\t\t\t} else {\n\t\t\t\t\t\tconst hours = Math.ceil(waitSec / 3600);\n\t\t\t\t\t\ttimeStr = `${hours} hour${hours === 1 ? '' : 's'}`;\n\t\t\t\t\t}\n\t\t\t\t\trateLimitMessage += `. Retry in ${timeStr}.`;\n\t\t\t\t}\n\t\t\t}\n\t\t\tthrow new KnownError(rateLimitMessage);\n\t\t}\n\n\t\tthrow errorAsAny;\n\t}\n};\n\nexport type GenerateCommitDescriptionOptions = {\n\tbaseUrl: string;\n\tapiKey: string;\n\tmodel: string;\n\tlocale: string;\n\ttitle: string;\n\tdiff: string;\n\ttimeout: number;\n\tmaxLength: number;\n\tcustomPrompt?: string;\n\theaders?: Record<string, string>;\n};\n\n/**\n * Wrap a single line at maxLength by breaking on spaces.\n * Lines that start with \"- \" or \"* \" get continuation lines indented with 2 spaces for alignment.\n */\nconst wrapLine = (line: string, maxLength: number): string => {\n\tconst bulletMatch = /^([-*]\\s)/.exec(line);\n\tconst indent = bulletMatch ? '  ' : '';\n\tconst continuationMax = maxLength - indent.length;\n\n\tif (line.length <= maxLength) return line;\n\n\tconst parts: string[] = [];\n\tlet rest = line;\n\tlet isFirst = true;\n\n\twhile (rest.length > (isFirst ? maxLength : continuationMax)) {\n\t\tconst maxThisLine = isFirst ? maxLength : continuationMax;\n\t\tconst chunk = rest.slice(0, maxThisLine);\n\t\tconst lastSpace = chunk.lastIndexOf(' ');\n\t\tconst splitAt = lastSpace > 0 ? lastSpace + 1 : maxThisLine;\n\t\tconst segment = rest.slice(0, splitAt).trim();\n\t\tparts.push(isFirst ? segment : indent + segment);\n\t\trest = rest.slice(splitAt).trim();\n\t\tisFirst = false;\n\t}\n\tif (rest.length > 0) {\n\t\tparts.push(isFirst ? rest : indent + rest);\n\t}\n\treturn parts.join('\\n');\n};\n\nexport const generateCommitDescription = async ({\n\tbaseUrl,\n\tapiKey,\n\tmodel,\n\tlocale,\n\ttitle,\n\tdiff,\n\ttimeout,\n\tmaxLength,\n\tcustomPrompt,\n\theaders,\n}: GenerateCommitDescriptionOptions) => {\n\tif (shouldLogDebug()) {\n\t\tconsole.log('Title and diff for description:');\n\t\tconsole.log({ title, diffLength: diff.length });\n\t}\n\n\tconst provider =\n\t\tbaseUrl === 'https://api.openai.com/v1'\n\t\t\t? createOpenAI({ apiKey })\n\t\t\t: createOpenAICompatible({\n\t\t\t\t\tname: 'custom',\n\t\t\t\t\tapiKey,\n\t\t\t\t\tbaseURL: baseUrl,\n\t\t\t\t\theaders,\n\t\t\t  });\n\n\tconst abortController = new AbortController();\n\tconst timeoutId = setTimeout(() => abortController.abort(), timeout);\n\n\ttry {\n\t\tconst result = await generateText({\n\t\t\tmodel: provider(model),\n\t\t\tsystem: generateDescriptionPrompt(locale, maxLength, customPrompt),\n\t\t\tprompt: `Commit message title:\\n${title}\\n\\nCode diff:\\n${diff}`,\n\t\t\ttemperature: 0.4,\n\t\t\tmaxRetries: 2,\n\t\t\tmaxOutputTokens: 2000,\n\t\t\tabortSignal: abortController.signal,\n\t\t});\n\t\tclearTimeout(timeoutId);\n\t\tlet description = sanitizeDescription(result.text);\n\t\t// Enforce line length: wrap any line exceeding maxLength\n\t\tdescription = description\n\t\t\t.split('\\n')\n\t\t\t.map((line) => wrapLine(line, maxLength))\n\t\t\t.join('\\n');\n\t\treturn { description, usage: result.usage };\n\t} catch (error) {\n\t\tclearTimeout(timeoutId);\n\t\tconst errorAsAny = error as any;\n\t\tif (\n\t\t\terrorAsAny.name === 'AbortError' ||\n\t\t\terrorAsAny.message?.includes('aborted') ||\n\t\t\terrorAsAny.message?.includes('This operation was aborted')\n\t\t) {\n\t\t\tthrow new KnownError(\n\t\t\t\t`Request timed out after ${timeout / 1000} seconds. The API took too long to respond. Try again or use a different model.`\n\t\t\t);\n\t\t}\n\t\tif (errorAsAny.code === 'ENOTFOUND') {\n\t\t\tthrow new KnownError(\n\t\t\t\t`Error connecting to ${errorAsAny.hostname} (${errorAsAny.syscall}). Are you connected to the internet?`\n\t\t\t);\n\t\t}\n\t\tthrow errorAsAny;\n\t}\n};\n\nexport type CombineCommitMessagesOptions = {\n\tmessages: string[];\n\tbaseUrl: string;\n\tapiKey: string;\n\tmodel: string;\n\tlocale: string;\n\tmaxLength: number;\n\ttype: CommitType;\n\ttimeout: number;\n\tcustomPrompt?: string;\n\theaders?: Record<string, string>;\n};\n\nexport const combineCommitMessages = async ({\n\tmessages,\n\tbaseUrl,\n\tapiKey,\n\tmodel,\n\tlocale,\n\tmaxLength,\n\ttype,\n\ttimeout,\n\tcustomPrompt,\n\theaders,\n}: CombineCommitMessagesOptions) => {\n\ttry {\n\t\tconst provider =\n\t\t\tbaseUrl === 'https://api.openai.com/v1'\n\t\t\t\t? createOpenAI({ apiKey })\n\t\t\t\t: createOpenAICompatible({\n\t\t\t\t\t\tname: 'custom',\n\t\t\t\t\t\tapiKey,\n\t\t\t\t\t\tbaseURL: baseUrl,\n\t\t\t\t\t\theaders,\n\t\t\t\t  });\n\n\t\tconst abortController = new AbortController();\n\t\tconst timeoutId = setTimeout(() => abortController.abort(), timeout);\n\n\t\tconst system = `You are a tool that generates git commit messages. Your task is to combine multiple commit messages into one.\n\nInput: Several commit messages separated by newlines.\nOutput: A single commit message starting with type like 'feat:' or 'fix:'.\n\nDo not add thanks, explanations, or any text outside the commit message.`;\n\n\t\tconst result = await generateText({\n\t\t\tmodel: provider(model),\n\t\t\tsystem,\n\t\t\tprompt: messages.join('\\n'),\n\t\t\ttemperature: 0.4,\n\t\t\tmaxRetries: 2,\n\t\t\tmaxOutputTokens: 2000,\n\t\t\tabortSignal: abortController.signal,\n\t\t});\n\n\t\tclearTimeout(timeoutId);\n\n\t\tlet combinedMessage = sanitizeMessage(result.text);\n\n\t\t// Shorten if too long\n\t\tif (combinedMessage.length > maxLength) {\n\t\t\ttry {\n\t\t\t\tcombinedMessage = await shortenCommitMessage(provider, model, combinedMessage, maxLength, timeout);\n\t\t\t} catch (error) {\n\t\t\t\t// If shortening fails, keep the original\n\t\t\t}\n\t\t}\n\n\t\treturn { messages: [combinedMessage], usage: result.usage };\n\t} catch (error) {\n\t\tconst errorAsAny = error as any;\n\n\t\t// Handle AbortController timeout\n\t\tif (\n\t\t\terrorAsAny.name === 'AbortError' ||\n\t\t\terrorAsAny.message?.includes('aborted') ||\n\t\t\terrorAsAny.message?.includes('This operation was aborted')\n\t\t) {\n\t\t\tthrow new KnownError(\n\t\t\t\t`Request timed out after ${timeout / 1000} seconds. The API took too long to respond. Try again or use a different model.`\n\t\t\t);\n\t\t}\n\n\t\tthrow errorAsAny;\n\t}\n};\n"
  },
  {
    "path": "src/utils/prompt.ts",
    "content": "import type { CommitType } from './config-types.js';\n\nexport const commitTypeFormats: Record<CommitType, string> = {\n\tplain: '<commit message>',\n\tconventional: '<type>[optional (<scope>)]: <commit message>\\nThe commit message subject must start with a lowercase letter',\n\tgitmoji: ':emoji: <commit message>',\n\t'subject+body': '<commit message subject>',\n};\nconst specifyCommitFormat = (type: CommitType) =>\n\t`The output response must be in format:\\n${commitTypeFormats[type]}`;\n\nconst commitTypes: Record<CommitType, string> = {\n\tplain: '',\n\n\t/**\n\t * References:\n\t * Commitlint:\n\t * https://github.com/conventional-changelog/commitlint/blob/18fbed7ea86ac0ec9d5449b4979b762ec4305a92/%40commitlint/config-conventional/index.js#L40-L100\n\t *\n\t * Conventional Changelog:\n\t * https://github.com/conventional-changelog/conventional-changelog/blob/d0e5d5926c8addba74bc962553dd8bcfba90e228/packages/conventional-changelog-conventionalcommits/writer-opts.js#L182-L193\n\t */\n\tconventional: `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(\n\t\t{\n\t\t\tdocs: 'Documentation only changes',\n\t\t\tstyle:\n\t\t\t\t'Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc)',\n\t\t\trefactor: 'A code change that improves code structure without changing functionality (renaming, restructuring classes/methods, extracting functions, etc)',\n\t\t\tperf: 'A code change that improves performance',\n\t\t\ttest: 'Adding missing tests or correcting existing tests',\n\t\t\tbuild: 'Changes that affect the build system or external dependencies',\n\t\t\tci: 'Changes to our CI configuration files and scripts',\n\t\t\tchore: \"Other changes that don't modify src or test files\",\n\t\t\trevert: 'Reverts a previous commit',\n\t\t\tfeat: 'A new feature',\n\t\t\tfix: 'A bug fix',\n\t\t},\n\t\tnull,\n\t\t2\n\t)}`,\n\n\t/**\n\t * References:\n\t * Gitmoji: https://gitmoji.dev/\n\t */\n\tgitmoji: `Choose an emoji from the emoji-to-description JSON below that best describes the git diff:\\n${JSON.stringify(\n\t\t{\n\t\t\t'🎨': 'Improve structure / format of the code',\n\t\t\t'⚡': 'Improve performance',\n\t\t\t'🔥': 'Remove code or files',\n\t\t\t'🐛': 'Fix a bug',\n\t\t\t'🚑': 'Critical hotfix',\n\t\t\t'✨': 'Introduce new features',\n\t\t\t'📝': 'Add or update documentation',\n\t\t\t'🚀': 'Deploy stuff',\n\t\t\t'💄': 'Add or update the UI and style files',\n\t\t\t'🎉': 'Begin a project',\n\t\t\t'✅': 'Add, update, or pass tests',\n\t\t\t'🔒': 'Fix security or privacy issues',\n\t\t\t'🔐': 'Add or update secrets',\n\t\t\t'🔖': 'Release / Version tags',\n\t\t\t'🚨': 'Fix compiler / linter warnings',\n\t\t\t'🚧': 'Work in progress',\n\t\t\t'💚': 'Fix CI Build',\n\t\t\t'⬇️': 'Downgrade dependencies',\n\t\t\t'⬆️': 'Upgrade dependencies',\n\t\t\t'📌': 'Pin dependencies to specific versions',\n\t\t\t'👷': 'Add or update CI build system',\n\t\t\t'📈': 'Add or update analytics or track code',\n\t\t\t'♻️': 'Refactor code',\n\t\t\t'➕': 'Add a dependency',\n\t\t\t'➖': 'Remove a dependency',\n\t\t\t'🔧': 'Add or update configuration files',\n\t\t\t'🔨': 'Add or update development scripts',\n\t\t\t'🌐': 'Internationalization and localization',\n\t\t\t'✏️': 'Fix typos',\n\t\t\t'💩': 'Write bad code that needs to be improved',\n\t\t\t'⏪': 'Revert changes',\n\t\t\t'🔀': 'Merge branches',\n\t\t\t'📦': 'Add or update compiled files or packages',\n\t\t\t'👽': 'Update code due to external API changes',\n\t\t\t'🚚': 'Move or rename resources (e.g.: files, paths, routes)',\n\t\t\t'📄': 'Add or update license',\n\t\t\t'💥': 'Introduce breaking changes',\n\t\t\t'🍱': 'Add or update assets',\n\t\t\t'♿': 'Improve accessibility',\n\t\t\t'💡': 'Add or update comments in source code',\n\t\t\t'🍻': 'Write code drunkenly',\n\t\t\t'💬': 'Add or update text and literals',\n\t\t\t'🗃': 'Perform database related changes',\n\t\t\t'🔊': 'Add or update logs',\n\t\t\t'🔇': 'Remove logs',\n\t\t\t'👥': 'Add or update contributor(s)',\n\t\t\t'🚸': 'Improve user experience / usability',\n\t\t\t'🏗': 'Make architectural changes',\n\t\t\t'📱': 'Work on responsive design',\n\t\t\t'🤡': 'Mock things',\n\t\t\t'🥚': 'Add or update an easter egg',\n\t\t\t'🙈': 'Add or update a .gitignore file',\n\t\t\t'📸': 'Add or update snapshots',\n\t\t\t'⚗': 'Perform experiments',\n\t\t\t'🔍': 'Improve SEO',\n\t\t\t'🏷': 'Add or update types',\n\t\t\t'🌱': 'Add or update seed files',\n\t\t\t'🚩': 'Add, update, or remove feature flags',\n\t\t\t'🥅': 'Catch errors',\n\t\t\t'💫': 'Add or update animations and transitions',\n\t\t\t'🗑': 'Deprecate code that needs to be cleaned up',\n\t\t\t'🛂': 'Work on code related to authorization, roles and permissions',\n\t\t\t'🩹': 'Simple fix for a non-critical issue',\n\t\t\t'🧐': 'Data exploration/inspection',\n\t\t\t'⚰': 'Remove dead code',\n\t\t\t'🧪': 'Add a failing test',\n\t\t\t'👔': 'Add or update business logic',\n\t\t\t'🩺': 'Add or update healthcheck',\n\t\t\t'🧱': 'Infrastructure related changes',\n\t\t\t'🧑‍💻': 'Improve developer experience',\n\t\t\t'💸': 'Add sponsorships or money related infrastructure',\n\t\t\t'🧵': 'Add or update code related to multithreading or concurrency',\n\t\t\t'🦺': 'Add or update code related to validation',\n\t\t},\n\t\tnull,\n\t\t2\n\t)}`,\n\t'subject+body': 'Output only the subject line; the body is generated separately.',\n};\n\nexport const generatePrompt = (\n\tlocale: string,\n\tmaxLength: number,\n\ttype: CommitType,\n\tcustomPrompt?: string\n) =>\n\t[\n\t\t'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.',\n\t\t`Message language: ${locale}`,\n\t\t`Commit message must be a maximum of ${maxLength} characters.`,\n\t\t'Exclude anything unnecessary such as translation. Your entire response will be passed directly into git commit.',\n\t\t`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.`,\n\t\t'Be specific: include concrete details (package names, versions, functionality) rather than generic statements.',\n\t\tcustomPrompt,\n\t\tcommitTypes[type],\n\t\tspecifyCommitFormat(type),\n\t]\n\t\t.filter(Boolean)\n\t\t.join('\\n');\n\n/**\n * Prompt for generating a commit message body/description given a title and diff.\n * Used when the user has (or generated) a title and wants a detailed description.\n */\nexport const generateDescriptionPrompt = (\n\tlocale: string,\n\tmaxLength: number,\n\tcustomPrompt?: string\n) =>\n\t[\n\t\t'You are generating the short body (description) of a git commit message. You are given the commit title and the code diff.',\n\t\t'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.',\n\t\t`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.`,\n\t\t'Do not repeat the title. No meta-commentary (e.g. \"This commit...\"). Respond with ONLY the commit body.',\n\t\t`Message language: ${locale}`,\n\t\tcustomPrompt,\n\t]\n\t\t.filter(Boolean)\n\t\t.join('\\n');\n"
  },
  {
    "path": "tests/fixtures/README.md",
    "content": "# Generating diffs\n\n1. Instruct ChatGPT with the following command:\n```\nI want you to act as a git cli\nI will give you the type of content and you will generate a random git diff based on that\n```\n\n2. Insert the type of change\n\nChatGPT will generate a fictional git diff based on the type of change you inserted.\n"
  },
  {
    "path": "tests/fixtures/chore.diff",
    "content": "diff --git a/package.json b/package.json\nindex 2a7398e..6b2a3f0 100644\n--- a/package.json\n+++ b/package.json\n@@ -3,7 +3,7 @@\n   \"version\": \"1.0.0\",\n   \"description\": \"A sample project\",\n   \"main\": \"index.js\",\n-  \"scripts\": {\n+  \"scripts\": {\n     \"start\": \"node index.js\",\n     \"test\": \"mocha\",\n-    \"lint\": \"eslint .\"\n+    \"lint\": \"eslint .\",\n+    \"clean\": \"rm -rf node_modules && npm install\"\n   },\n   \"dependencies\": {\n     \"express\": \"^4.17.1\",\n"
  },
  {
    "path": "tests/fixtures/code-refactoring.diff",
    "content": "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@@ -1,15 +1,16 @@\n-import { Component, OnInit } from '@angular/core';\n+import { Component } from '@angular/core';\n\n-@Component({\n-  selector: 'app-example',\n-  templateUrl: './example.component.html',\n-  styleUrls: ['./example.component.css']\n-})\n-export class ExampleComponent implements OnInit {\n-  message: string;\n+@Component({\n+  selector: 'app-improved-example',\n+  templateUrl: './improved-example.component.html',\n+  styleUrls: ['./improved-example.component.css']\n+})\n+export class ImprovedExampleComponent {\n+  private _message: string;\n\n-  ngOnInit() {\n-    this.message = 'Hello, world!';\n+  constructor() {\n+    this._message = 'Hello, world!';\n   }\n\n+  get message(): string {\n+    return this._message;\n+  }\n }\n"
  },
  {
    "path": "tests/fixtures/code-style.diff",
    "content": "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 @@ app.use(express.json());\n // Routes\n app.get('/', (req, res) => {\n-  res.send('Welcome to the API!');\n+    res.send('Welcome to the API!');\n });\n\n app.post('/users', (req, res) => {\n-  const user = createUser(req.body);\n-  res.status(201).send(user);\n+    const user = createUser(req.body);\n+    res.status(201).send(user);\n });\n\n app.get('/users/:id', (req, res) => {\n@@ -27,7 +27,7 @@ app.get('/users/:id', (req, res) => {\n     if (user) {\n         res.send(user);\n     } else {\n-      res.status(404).send('User not found');\n+        res.status(404).send('User not found');\n     }\n });\n\n"
  },
  {
    "path": "tests/fixtures/continous-integration.diff",
    "content": "diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml\nnew file mode 100644\nindex 0000000..b6e5789\n--- /dev/null\n+++ b/.github/workflows/ci.yml\n@@ -0,0 +1,16 @@\n+name: Continuous Integration\n+\n+on:\n+  push:\n+    branches:\n+      - main\n+  pull_request:\n+    branches:\n+      - main\n+\n+jobs:\n+  build-and-test:\n+    runs-on: ubuntu-latest\n+    steps:\n+    - name: Checkout repository\n+      uses: actions/checkout@v2\n+    - name: Set up Node.js\n+      uses: actions/setup-node@v2\n+      with:\n+        node-version: '16'\n+    - name: Install dependencies\n+      run: npm ci\n+    - name: Run tests\n+      run: npm test\n"
  },
  {
    "path": "tests/fixtures/deprecate-feature.diff",
    "content": "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@@ -1,7 +1,9 @@\n import warnings\n\n\n class OldFeature:\n+    def __init__(self):\n+        warnings.warn(\"OldFeature is deprecated and will be removed in the next release. Please use NewFeature instead.\", DeprecationWarning)\n\n     def do_something(self):\n         print(\"Doing something with the old feature...\")\ndiff --git a/new_feature.py b/new_feature.py\nnew file mode 100644\nindex 0000000..1111111\n--- /dev/null\n+++ b/new_feature.py\n@@ -0,0 +1,7 @@\n+class NewFeature:\n+    def __init__(self):\n+        print(\"Initializing the new feature...\")\n+\n+    def do_something(self):\n+        print(\"Doing something with the new feature...\")\n+\n"
  },
  {
    "path": "tests/fixtures/documentation-changes.diff",
    "content": "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 Awesome Project\n\n+## Overview\n+\n+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.\n+\n ## Installation\n\n+To install and run My Awesome Project, follow these steps:\n+\n 1. Clone the repository: `git clone https://github.com/username/my-awesome-project.git`\n 2. Install dependencies: `npm install`\n 3. Start the development server: `npm start`\n@@ -13,6 +18,11 @@ To install and run My Awesome Project, follow these steps:\n ## Usage\n\n To use My Awesome Project, follow these steps:\n+\n+1. Open your web browser and navigate to `http://localhost:3000`\n+2. Sign up for a new account or log in to an existing one\n+3. Create a new task or project and start managing your work!\n+\n ## Contributing\n\n We welcome contributions from anyone and everyone. To contribute to My Awesome Project, follow these steps:\n"
  },
  {
    "path": "tests/fixtures/fix-nullpointer-exception.diff",
    "content": "diff --git a/src/main/java/com/example/MyClass.java b/src/main/java/com/example/MyClass.java\nindex e7d8f38..caab7f1 100644\n--- a/src/main/java/com/example/MyClass.java\n+++ b/src/main/java/com/example/MyClass.java\n@@ -23,7 +23,10 @@ public class MyClass {\n     public void processItems(List<Item> items) {\n         for (Item item : items) {\n-            if (item.getValue().equalsIgnoreCase(\"example\")) {\n+            // Fixing NullPointerException by adding a null check\n+            String itemValue = item.getValue();\n+            if (itemValue != null && itemValue.equalsIgnoreCase(\"example\")) {\n                 processExampleItem(item);\n             }\n         }\n"
  },
  {
    "path": "tests/fixtures/github-action-build-pipeline.diff",
    "content": "diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml\nindex 1d07d31..085eb64 100644\n--- a/.github/workflows/build.yml\n+++ b/.github/workflows/build.yml\n@@ -10,6 +10,8 @@ jobs:\n       - uses: actions/setup-node@v1\n         with:\n           node-version: 12.x\n+      - name: Install dependencies\n+        run: npm install\n       - name: Build and test\n         run: |\n           npm run build\n@@ -22,3 +24,7 @@ jobs:\n         if: always()\n         uses: actions/upload-artifact@v1\n         with:\n+          name: Build artifact\n+          path: build\n+      - name: Deploy to production\n+        uses: some-third-party/deploy-action@v1\n"
  },
  {
    "path": "tests/fixtures/new-feature.diff",
    "content": "diff --git a/src/features/newFeature.js b/src/features/newFeature.js\nnew file mode 100644\nindex 0000000..b6e5789\n--- /dev/null\n+++ b/src/features/newFeature.js\n@@ -0,0 +1,18 @@\n+/**\n+ * New feature: Calculates the factorial of a given number.\n+ * @param {number} n - The input number.\n+ * @returns {number} - The factorial of the input number.\n+ */\n+function factorial(n) {\n+  if (n === 0 || n === 1) {\n+    return 1;\n+  }\n+  return n * factorial(n - 1);\n+}\n+\n+module.exports = {\n+  factorial,\n+};\n+\ndiff --git a/src/app.js b/src/app.js\nindex 8741c37..91b2e74 100644\n--- a/src/app.js\n+++ b/src/app.js\n@@ -2,6 +2,7 @@\n const express = require('express');\n const bodyParser = require('body-parser');\n const userRoutes = require('./routes/userRoutes');\n+const { factorial } = require('./features/newFeature');\n\n const app = express();\n app.use(bodyParser.json());\n@@ -21,6 +22,12 @@\n   res.send('Welcome to the API!');\n });\n\n+app.get('/factorial/:number', (req, res) => {\n+  const number = parseInt(req.params.number, 10);\n+  const result = factorial(number);\n+  res.send(`Factorial of ${number} is ${result}`);\n+});\n+\n // Other routes...\n\n module.exports = app;\n"
  },
  {
    "path": "tests/fixtures/performance-improvement.diff",
    "content": "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 @@ const items = generateItems(100000);\n function processData(items) {\n   let sum = 0;\n\n-  for (let i = 0; i < items.length; i++) {\n-    const item = items[i];\n-    if (item.isValid()) {\n-      sum += item.value;\n-    }\n+  for (const item of items) {\n+    if (item.isValid()) sum += item.value;\n   }\n\n   return sum;\n }\n\n const startTime = Date.now();\n-const result = processData(items);\n+const result = processData(items); // Improved loop iteration\n const endTime = Date.now();\n\n console.log(`Result: ${result}, Time: ${endTime - startTime} ms`);\n"
  },
  {
    "path": "tests/fixtures/remove-feature.diff",
    "content": "diff --git a/Controllers/FeatureController.cs b/Controllers/FeatureController.cs\nindex 8a3b7c1..3e29f9a 100644\n--- a/Controllers/FeatureController.cs\n+++ b/Controllers/FeatureController.cs\n@@ -1,16 +1,7 @@\n using Microsoft.AspNetCore.Mvc;\n using System.Collections.Generic;\n\n namespace MyWebApi.Controllers\n {\n     [Route(\"api/[controller]\")]\n     [ApiController]\n     public class FeatureController : ControllerBase\n     {\n-        [HttpGet(\"old-feature\")]\n-        public ActionResult<string> GetOldFeature()\n-        {\n-            return \"This is the removed old feature.\";\n-        }\n-\n         [HttpGet(\"new-feature\")]\n         public ActionResult<string> GetNewFeature()\n         {\n             return \"This is the new feature.\";\n         }\n     }\n }\n"
  },
  {
    "path": "tests/fixtures/testing-react-application.diff",
    "content": "diff --git a/src/components/MyComponent.test.js b/src/components/MyComponent.test.js\nindex 37eabf2..976c6bf 100644\n--- a/src/components/MyComponent.test.js\n+++ b/src/components/MyComponent.test.js\n@@ -10,6 +10,7 @@ describe(\"MyComponent\", () => {\n     });\n\n     it(\"renders the component correctly\", () => {\n+        const props = { name: \"John Doe\", age: 25 };\n         const tree = renderer.create(<MyComponent {...props} />).toJSON();\n         expect(tree).toMatchSnapshot();\n     });\n@@ -25,6 +26,11 @@ describe(\"MyComponent\", () => {\n         expect(wrapper.find(\"h1\").text()).toEqual(\"Hello, John Doe!\");\n     });\n\n+    it(\"displays the correct age\", () => {\n+        const props = { name: \"Jane Doe\", age: 30 };\n+        const wrapper = shallow(<MyComponent {...props} />);\n+        expect(wrapper.find(\"p\").text()).toEqual(\"Age: 30\");\n+    });\n });\n"
  },
  {
    "path": "tests/index.ts",
    "content": "import { describe } from 'manten';\n\ndescribe('aicommits', ({ runTestSuite }) => {\n\trunTestSuite(import('./specs/cli/index.js'));\n\trunTestSuite(import('./specs/auto-update.js'));\n\trunTestSuite(import('./specs/openai/index.js'));\n\trunTestSuite(import('./specs/togetherai/index.js'));\n\trunTestSuite(import('./specs/config.js'));\n\trunTestSuite(import('./specs/git-hook.js'));\n});\n"
  },
  {
    "path": "tests/specs/auto-update.ts",
    "content": "import { testSuite, expect } from 'manten';\nimport { checkAndAutoUpdate } from '../../src/utils/auto-update.js';\n\nexport default testSuite(({ describe }) => {\n\tdescribe('Auto update', ({ test }) => {\n\t\ttest('skips update checks entirely in headless mode', async () => {\n\t\t\tconst originalFetch = globalThis.fetch;\n\t\t\tconst originalConsoleLog = console.log;\n\t\t\tconst fetchCalls: string[] = [];\n\t\t\tconst consoleCalls: string[] = [];\n\n\t\t\tglobalThis.fetch = (async (input: string | URL | Request) => {\n\t\t\t\tfetchCalls.push(String(input));\n\t\t\t\tthrow new Error('fetch should not be called in headless mode');\n\t\t\t}) as typeof fetch;\n\n\t\t\tconsole.log = (...args: unknown[]) => {\n\t\t\t\tconsoleCalls.push(args.join(' '));\n\t\t\t};\n\n\t\t\ttry {\n\t\t\t\tawait checkAndAutoUpdate({\n\t\t\t\t\tpkg: {\n\t\t\t\t\t\tname: 'aicommits',\n\t\t\t\t\t\tversion: '1.0.0',\n\t\t\t\t\t},\n\t\t\t\t\theadless: true,\n\t\t\t\t});\n\t\t\t} finally {\n\t\t\t\tglobalThis.fetch = originalFetch;\n\t\t\t\tconsole.log = originalConsoleLog;\n\t\t\t}\n\n\t\t\texpect(fetchCalls).toEqual([]);\n\t\t\texpect(consoleCalls).toEqual([]);\n\t\t});\n\t});\n});\n"
  },
  {
    "path": "tests/specs/cli/commits.ts",
    "content": "import { testSuite, expect } from 'manten';\nimport {\n\tcreateFixture,\n\tcreateGit,\n\tfiles,\n} from '../../utils.js';\n\nexport default testSuite(({ describe }) => {\n\tif (process.platform === 'win32') {\n\t\t// https://github.com/nodejs/node/issues/31409\n\t\tconsole.warn(\n\t\t\t'Skipping tests on Windows because Node.js spawn cant open TTYs'\n\t\t);\n\t\treturn;\n\t}\n\n\tif (!process.env.OPENAI_API_KEY) {\n\t\tconsole.warn(\n\t\t\t'⚠️  process.env.OPENAI_API_KEY is necessary to run these tests. Skipping...'\n\t\t);\n\t\treturn;\n\t}\n\n\tdescribe('Commits', async ({ test, describe }) => {\n\t\ttest('Excludes files', async () => {\n\t\t\tconst { fixture, aicommits } = await createFixture(files);\n\t\t\tconst git = await createGit(fixture.path);\n\n\t\t\tawait git('add', ['data.json']);\n\t\t\tconst statusBefore = await git('status', [\n\t\t\t\t'--porcelain',\n\t\t\t\t'--untracked-files=no',\n\t\t\t]);\n\t\t\texpect(statusBefore.stdout).toBe('A  data.json');\n\n\t\t\tconst { stdout, exitCode } = await aicommits(['--exclude', 'data.json'], {\n\t\t\t\treject: false,\n\t\t\t});\n\t\t\texpect(exitCode).toBe(1);\n\t\t\texpect(stdout).toMatch('No staged changes found.');\n\t\t\tawait fixture.rm();\n\t\t});\n\n\t\ttest('Generates commit message', async () => {\n\t\t\tconst { fixture, aicommits } = await createFixture(files);\n\t\t\tconst git = await createGit(fixture.path);\n\n\t\t\tawait git('add', ['data.json']);\n\n\t\t\tconst committing = aicommits();\n\t\t\tcommitting.stdout!.on('data', (buffer: Buffer) => {\n\t\t\t\tconst stdout = buffer.toString();\n\t\t\t\tif (stdout.match('└')) {\n\t\t\t\t\tcommitting.stdin!.write('y');\n\t\t\t\t\tcommitting.stdin!.end();\n\t\t\t\t}\n\t\t\t});\n\n\t\t\tawait committing;\n\n\t\t\tconst statusAfter = await git('status', [\n\t\t\t\t'--porcelain',\n\t\t\t\t'--untracked-files=no',\n\t\t\t]);\n\t\t\texpect(statusAfter.stdout).toBe('');\n\n\t\t\tconst { stdout: commitMessage } = await git('log', [\n\t\t\t\t'--pretty=format:%s',\n\t\t\t]);\n\t\t\tconsole.log({\n\t\t\t\tcommitMessage,\n\t\t\t\tlength: commitMessage.length,\n\t\t\t});\n\t\t\texpect(commitMessage.length).toBeLessThanOrEqual(50);\n\n\t\t\tawait fixture.rm();\n\t\t});\n\n\t\ttest('Generated commit message must be under 20 characters', async () => {\n\t\t\tconst { fixture, aicommits } = await createFixture({\n\t\t\t\t...files,\n\t\t\t\t'.aicommits': `${files['.aicommits']}\\nmax-length=20`,\n\t\t\t});\n\n\t\t\tconst git = await createGit(fixture.path);\n\n\t\t\tawait git('add', ['data.json']);\n\n\t\t\tconst committing = aicommits();\n\t\t\tcommitting.stdout!.on('data', (buffer: Buffer) => {\n\t\t\t\tconst stdout = buffer.toString();\n\t\t\t\tif (stdout.match('└')) {\n\t\t\t\t\tcommitting.stdin!.write('y');\n\t\t\t\t\tcommitting.stdin!.end();\n\t\t\t\t}\n\t\t\t});\n\n\t\t\tawait committing;\n\n\t\t\tconst { stdout: commitMessage } = await git('log', [\n\t\t\t\t'--pretty=format:%s',\n\t\t\t]);\n\t\t\tconsole.log({\n\t\t\t\tcommitMessage,\n\t\t\t\tlength: commitMessage.length,\n\t\t\t});\n\t\t\texpect(commitMessage.length).toBeLessThanOrEqual(20);\n\n\t\t\tawait fixture.rm();\n\t\t});\n\n\t\ttest('Accepts --all flag, staging all changes before commit', async () => {\n\t\t\tconst { fixture, aicommits } = await createFixture(files);\n\t\t\tconst git = await createGit(fixture.path);\n\n\t\t\tawait git('add', ['data.json']);\n\t\t\tawait git('commit', ['-m', 'wip']);\n\n\t\t\t// Change tracked file\n\t\t\tawait fixture.writeFile('data.json', 'Test');\n\n\t\t\tconst statusBefore = await git('status', ['--short']);\n\t\t\texpect(statusBefore.stdout).toBe(' M data.json\\n?? .aicommits');\n\n\t\t\tconst committing = aicommits(['--all']);\n\t\t\tcommitting.stdout!.on('data', (buffer: Buffer) => {\n\t\t\t\tconst stdout = buffer.toString();\n\t\t\t\tif (stdout.match('└')) {\n\t\t\t\t\tcommitting.stdin!.write('y');\n\t\t\t\t\tcommitting.stdin!.end();\n\t\t\t\t}\n\t\t\t});\n\n\t\t\tawait committing;\n\n\t\t\tconst statusAfter = await git('status', ['--short']);\n\t\t\texpect(statusAfter.stdout).toBe('?? .aicommits');\n\n\t\t\tconst { stdout: commitMessage } = await git('log', [\n\t\t\t\t'-n1',\n\t\t\t\t'--pretty=format:%s',\n\t\t\t]);\n\t\t\tconsole.log({\n\t\t\t\tcommitMessage,\n\t\t\t\tlength: commitMessage.length,\n\t\t\t});\n\t\t\texpect(commitMessage.length).toBeLessThanOrEqual(50);\n\n\t\t\tawait fixture.rm();\n\t\t});\n\n\t\ttest('Accepts --generate flag, overriding config', async ({\n\t\t\tonTestFail,\n\t\t}) => {\n\t\t\tconst { fixture, aicommits } = await createFixture({\n\t\t\t\t...files,\n\t\t\t\t'.aicommits': `${files['.aicommits']}\\ngenerate=4`,\n\t\t\t});\n\t\t\tconst git = await createGit(fixture.path);\n\n\t\t\tawait git('add', ['data.json']);\n\n\t\t\t// Generate flag should override generate config\n\t\t\tconst committing = aicommits(['--generate', '2']);\n\n\t\t\t// Hit enter to accept the commit message\n\t\t\tcommitting.stdout!.on('data', function onPrompt(buffer: Buffer) {\n\t\t\t\tconst stdout = buffer.toString();\n\t\t\t\tif (stdout.match('└')) {\n\t\t\t\t\tcommitting.stdin!.write('\\r');\n\t\t\t\t\tcommitting.stdin!.end();\n\t\t\t\t\tcommitting.stdout?.off('data', onPrompt);\n\t\t\t\t}\n\t\t\t});\n\n\t\t\tconst { stdout } = await committing;\n\t\t\tconst countChoices = stdout.match(/ {2}[●○]/g)?.length ?? 0;\n\n\t\t\tonTestFail(() => console.log({ stdout }));\n\t\t\texpect(countChoices).toBe(2);\n\n\t\t\tconst statusAfter = await git('status', [\n\t\t\t\t'--porcelain',\n\t\t\t\t'--untracked-files=no',\n\t\t\t]);\n\t\t\texpect(statusAfter.stdout).toBe('');\n\n\t\t\tconst { stdout: commitMessage } = await git('log', [\n\t\t\t\t'--pretty=format:%s',\n\t\t\t]);\n\t\t\tconsole.log({\n\t\t\t\tcommitMessage,\n\t\t\t\tlength: commitMessage.length,\n\t\t\t});\n\t\t\texpect(commitMessage.length).toBeLessThanOrEqual(50);\n\n\t\t\tawait fixture.rm();\n\t\t});\n\n\t\ttest('Generates Japanese commit message via locale config', async () => {\n\t\t\t// https://stackoverflow.com/a/15034560/911407\n\t\t\tconst japanesePattern =\n\t\t\t\t/[\\u3000-\\u303F\\u3040-\\u309F\\u30A0-\\u30FF\\uFF00-\\uFF9F\\u4E00-\\u9FAF\\u3400-\\u4DBF]/;\n\n\t\t\tconst { fixture, aicommits } = await createFixture({\n\t\t\t\t...files,\n\t\t\t\t'.aicommits': `${files['.aicommits']}\\nlocale=ja`,\n\t\t\t});\n\t\t\tconst git = await createGit(fixture.path);\n\n\t\t\tawait git('add', ['data.json']);\n\n\t\t\tconst committing = aicommits();\n\n\t\t\tcommitting.stdout!.on('data', (buffer: Buffer) => {\n\t\t\t\tconst stdout = buffer.toString();\n\t\t\t\tif (stdout.match('└')) {\n\t\t\t\t\tcommitting.stdin!.write('y');\n\t\t\t\t\tcommitting.stdin!.end();\n\t\t\t\t}\n\t\t\t});\n\n\t\t\tawait committing;\n\n\t\t\tconst statusAfter = await git('status', [\n\t\t\t\t'--porcelain',\n\t\t\t\t'--untracked-files=no',\n\t\t\t]);\n\t\t\texpect(statusAfter.stdout).toBe('');\n\n\t\t\tconst { stdout: commitMessage } = await git('log', [\n\t\t\t\t'--pretty=format:%s',\n\t\t\t]);\n\t\t\tconsole.log({\n\t\t\t\tcommitMessage,\n\t\t\t\tlength: commitMessage.length,\n\t\t\t});\n\t\t\texpect(commitMessage).toMatch(japanesePattern);\n\t\t\texpect(commitMessage.length).toBeLessThanOrEqual(50);\n\n\t\t\tawait fixture.rm();\n\t\t});\n\n\t\tdescribe('commit types', ({ test }) => {\n\t\t\ttest('Should not use conventional commits by default', async () => {\n\t\t\t\tconst conventionalCommitPattern =\n\t\t\t\t\t/(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test):\\s/;\n\t\t\t\tconst { fixture, aicommits } = await createFixture({\n\t\t\t\t\t...files,\n\t\t\t\t});\n\t\t\t\tconst git = await createGit(fixture.path);\n\n\t\t\t\tawait git('add', ['data.json']);\n\n\t\t\t\tconst committing = aicommits();\n\n\t\t\t\tcommitting.stdout!.on('data', (buffer: Buffer) => {\n\t\t\t\t\tconst stdout = buffer.toString();\n\t\t\t\t\tif (stdout.match('└')) {\n\t\t\t\t\t\tcommitting.stdin!.write('y');\n\t\t\t\t\t\tcommitting.stdin!.end();\n\t\t\t\t\t}\n\t\t\t\t});\n\n\t\t\t\tawait committing;\n\n\t\t\t\tconst statusAfter = await git('status', [\n\t\t\t\t\t'--porcelain',\n\t\t\t\t\t'--untracked-files=no',\n\t\t\t\t]);\n\t\t\t\texpect(statusAfter.stdout).toBe('');\n\n\t\t\t\tconst { stdout: commitMessage } = await git('log', ['--oneline']);\n\t\t\t\tconsole.log('Committed with:', commitMessage);\n\t\t\t\texpect(commitMessage).not.toMatch(conventionalCommitPattern);\n\n\t\t\t\tawait fixture.rm();\n\t\t\t});\n\n\t\t\ttest('Conventional commits', async () => {\n\t\t\t\tconst conventionalCommitPattern =\n\t\t\t\t\t/(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test):\\s/;\n\t\t\t\tconst { fixture, aicommits } = await createFixture({\n\t\t\t\t\t...files,\n\t\t\t\t\t'.aicommits': `${files['.aicommits']}\\ntype=conventional`,\n\t\t\t\t});\n\t\t\t\tconst git = await createGit(fixture.path);\n\n\t\t\t\tawait git('add', ['data.json']);\n\n\t\t\t\tconst committing = aicommits();\n\n\t\t\t\tcommitting.stdout!.on('data', (buffer: Buffer) => {\n\t\t\t\t\tconst stdout = buffer.toString();\n\t\t\t\t\tif (stdout.match('└')) {\n\t\t\t\t\t\tcommitting.stdin!.write('y');\n\t\t\t\t\t\tcommitting.stdin!.end();\n\t\t\t\t\t}\n\t\t\t\t});\n\n\t\t\t\tawait committing;\n\n\t\t\t\tconst statusAfter = await git('status', [\n\t\t\t\t\t'--porcelain',\n\t\t\t\t\t'--untracked-files=no',\n\t\t\t\t]);\n\t\t\t\texpect(statusAfter.stdout).toBe('');\n\n\t\t\t\tconst { stdout: commitMessage } = await git('log', ['--oneline']);\n\t\t\t\tconsole.log('Committed with:', commitMessage);\n\t\t\t\texpect(commitMessage).toMatch(conventionalCommitPattern);\n\n\t\t\t\tawait fixture.rm();\n\t\t\t});\n\n\t\t\ttest('Accepts --type flag, overriding config', async () => {\n\t\t\t\tconst conventionalCommitPattern =\n\t\t\t\t\t/(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test):\\s/;\n\t\t\t\tconst { fixture, aicommits } = await createFixture({\n\t\t\t\t\t...files,\n\t\t\t\t\t'.aicommits': `${files['.aicommits']}\\ntype=other`,\n\t\t\t\t});\n\t\t\t\tconst git = await createGit(fixture.path);\n\n\t\t\t\tawait git('add', ['data.json']);\n\n\t\t\t\t// Generate flag should override generate config\n\t\t\t\tconst committing = aicommits(['--type', 'conventional']);\n\n\t\t\t\tcommitting.stdout!.on('data', (buffer: Buffer) => {\n\t\t\t\t\tconst stdout = buffer.toString();\n\t\t\t\t\tif (stdout.match('└')) {\n\t\t\t\t\t\tcommitting.stdin!.write('y');\n\t\t\t\t\t\tcommitting.stdin!.end();\n\t\t\t\t\t}\n\t\t\t\t});\n\n\t\t\t\tawait committing;\n\n\t\t\t\tconst statusAfter = await git('status', [\n\t\t\t\t\t'--porcelain',\n\t\t\t\t\t'--untracked-files=no',\n\t\t\t\t]);\n\t\t\t\texpect(statusAfter.stdout).toBe('');\n\n\t\t\t\tconst { stdout: commitMessage } = await git('log', ['--oneline']);\n\t\t\t\tconsole.log('Committed with:', commitMessage);\n\t\t\t\texpect(commitMessage).toMatch(conventionalCommitPattern);\n\n\t\t\t\tawait fixture.rm();\n\t\t\t});\n\n\t\t\ttest('Accepts plain --type flag', async () => {\n\t\t\t\tconst conventionalCommitPattern =\n\t\t\t\t\t/(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test):\\s/;\n\t\t\t\tconst { fixture, aicommits } = await createFixture({\n\t\t\t\t\t...files,\n\t\t\t\t\t'.aicommits': `${files['.aicommits']}\\ntype=conventional`,\n\t\t\t\t});\n\t\t\t\tconst git = await createGit(fixture.path);\n\n\t\t\t\tawait git('add', ['data.json']);\n\n\t\t\t\tconst committing = aicommits(['--type', 'plain']);\n\n\t\t\t\tcommitting.stdout!.on('data', (buffer: Buffer) => {\n\t\t\t\t\tconst stdout = buffer.toString();\n\t\t\t\t\tif (stdout.match('└')) {\n\t\t\t\t\t\tcommitting.stdin!.write('y');\n\t\t\t\t\t\tcommitting.stdin!.end();\n\t\t\t\t\t}\n\t\t\t\t});\n\n\t\t\t\tawait committing;\n\n\t\t\t\tconst statusAfter = await git('status', [\n\t\t\t\t\t'--porcelain',\n\t\t\t\t\t'--untracked-files=no',\n\t\t\t\t]);\n\t\t\t\texpect(statusAfter.stdout).toBe('');\n\n\t\t\t\tconst { stdout: commitMessage } = await git('log', ['--oneline']);\n\t\t\t\tconsole.log('Committed with:', commitMessage);\n\t\t\t\texpect(commitMessage).not.toMatch(conventionalCommitPattern);\n\n\t\t\t\tawait fixture.rm();\n\t\t\t});\n\n\t\t\ttest('subject+body generates commit with subject and body', async () => {\n\t\t\t\tconst { fixture, aicommits } = await createFixture({\n\t\t\t\t\t...files,\n\t\t\t\t\t'.aicommits': `${files['.aicommits']}\\ntype=subject+body`,\n\t\t\t\t});\n\t\t\t\tconst git = await createGit(fixture.path);\n\n\t\t\t\tawait git('add', ['data.json']);\n\n\t\t\t\tconst committing = aicommits();\n\n\t\t\t\tcommitting.stdout!.on('data', (buffer: Buffer) => {\n\t\t\t\t\tconst stdout = buffer.toString();\n\t\t\t\t\tif (stdout.match('└')) {\n\t\t\t\t\t\tcommitting.stdin!.write('y');\n\t\t\t\t\t\tcommitting.stdin!.end();\n\t\t\t\t\t}\n\t\t\t\t});\n\n\t\t\t\tawait committing;\n\n\t\t\t\tconst statusAfter = await git('status', [\n\t\t\t\t\t'--porcelain',\n\t\t\t\t\t'--untracked-files=no',\n\t\t\t\t]);\n\t\t\t\texpect(statusAfter.stdout).toBe('');\n\n\t\t\t\tconst { stdout: fullMessage } = await git('log', [\n\t\t\t\t\t'-n1',\n\t\t\t\t\t'--pretty=format:%B',\n\t\t\t\t]);\n\t\t\t\texpect(fullMessage).toContain('\\n');\n\t\t\t\texpect(fullMessage.trim().split('\\n').length).toBeGreaterThanOrEqual(2);\n\n\t\t\t\tawait fixture.rm();\n\t\t\t});\n\t\t});\n\n\t\tdescribe('proxy', ({ test }) => {\n\t\t\ttest('Fails on deprecated proxy config', async () => {\n\t\t\t\tconst { fixture, aicommits } = await createFixture({\n\t\t\t\t\t...files,\n\t\t\t\t\t'.aicommits': `${files['.aicommits']}\\nproxy=http://localhost:1234`,\n\t\t\t\t});\n\t\t\t\tconst git = await createGit(fixture.path);\n\n\t\t\t\tawait git('add', ['data.json']);\n\n\t\t\t\tconst committing = aicommits([], {\n\t\t\t\t\treject: false,\n\t\t\t\t});\n\n\t\t\t\tconst { stdout, exitCode } = await committing;\n\n\t\t\t\texpect(exitCode).toBe(1);\n\t\t\t\texpect(stdout).toMatch('The \"proxy\" config property is deprecated and no longer supported');\n\n\t\t\t\tawait fixture.rm();\n\t\t\t});\n\n\t\t\ttest('Connects with env variable', async () => {\n\t\t\t\tconst { fixture, aicommits } = await createFixture(files);\n\t\t\t\tconst git = await createGit(fixture.path);\n\n\t\t\t\tawait git('add', ['data.json']);\n\n\t\t\t\tconst committing = aicommits([], {\n\t\t\t\t\tenv: {\n\t\t\t\t\t\tHTTP_PROXY: 'http://localhost:8888',\n\t\t\t\t\t},\n\t\t\t\t});\n\n\t\t\t\tcommitting.stdout!.on('data', (buffer: Buffer) => {\n\t\t\t\t\tconst stdout = buffer.toString();\n\t\t\t\t\tif (stdout.match('└')) {\n\t\t\t\t\t\tcommitting.stdin!.write('y');\n\t\t\t\t\t\tcommitting.stdin!.end();\n\t\t\t\t\t}\n\t\t\t\t});\n\n\t\t\t\tawait committing;\n\n\t\t\t\tconst statusAfter = await git('status', [\n\t\t\t\t\t'--porcelain',\n\t\t\t\t\t'--untracked-files=no',\n\t\t\t\t]);\n\t\t\t\texpect(statusAfter.stdout).toBe('');\n\n\t\t\t\tconst { stdout: commitMessage } = await git('log', [\n\t\t\t\t\t'--pretty=format:%s',\n\t\t\t\t]);\n\t\t\t\tconsole.log({\n\t\t\t\t\tcommitMessage,\n\t\t\t\t\tlength: commitMessage.length,\n\t\t\t\t});\n\t\t\t\texpect(commitMessage.length).toBeLessThanOrEqual(50);\n\n\t\t\t\tawait fixture.rm();\n\t\t\t});\n\t\t});\n\n\n\t});\n});\n"
  },
  {
    "path": "tests/specs/cli/error-cases.ts",
    "content": "import { testSuite, expect } from 'manten';\nimport { createFixture, createGit } from '../../utils.js';\n\nexport default testSuite(({ describe }) => {\n\tdescribe('Error cases', async ({ test }) => {\n\t\ttest('Fails on non-Git project', async () => {\n\t\t\tconst { fixture, aicommits } = await createFixture({\n\t\t\t\t'.aicommits': 'OPENAI_API_KEY=sk-test-key\\nprovider=openai'\n\t\t\t});\n\t\t\tconst { stderr, exitCode } = await aicommits([], { reject: false });\n\t\t\texpect(exitCode).toBe(1);\n\t\t\texpect(stderr).toMatch('The current directory must be a Git repository!');\n\t\t\tawait fixture.rm();\n\t\t});\n\n\t\ttest('Fails on no staged files', async () => {\n\t\t\tconst { fixture, aicommits } = await createFixture({\n\t\t\t\t'.aicommits': 'OPENAI_API_KEY=sk-test-key\\nprovider=openai'\n\t\t\t});\n\t\t\tawait createGit(fixture.path);\n\n\t\t\tconst { stderr, exitCode } = await aicommits([], { reject: false });\n\t\t\texpect(exitCode).toBe(1);\n\t\t\texpect(stderr).toMatch(\n\t\t\t\t'No staged changes found. Stage your changes manually, or automatically stage all changes with the `--all` flag.'\n\t\t\t);\n\t\t\tawait fixture.rm();\n\t\t});\n\t});\n});\n"
  },
  {
    "path": "tests/specs/cli/headless.ts",
    "content": "import { testSuite, expect } from 'manten';\nimport { createFixture, createGit } from '../../utils.js';\n\nexport default testSuite(({ describe }) => {\n\tdescribe('Headless mode', ({ test }) => {\n\t\ttest('setup requires an interactive terminal', async () => {\n\t\t\tconst { fixture, aicommits } = await createFixture();\n\n\t\t\tconst { stdout, stderr, exitCode } = await aicommits(['setup'], {\n\t\t\t\treject: false,\n\t\t\t\tenv: {\n\t\t\t\t\tCI: '1',\n\t\t\t\t},\n\t\t\t});\n\n\t\t\texpect(exitCode).toBe(1);\n\t\t\texpect(stdout).toBe('');\n\t\t\texpect(stderr).toMatch('Interactive terminal required for setup');\n\n\t\t\tawait fixture.rm();\n\t\t});\n\n\t\ttest('model requires an interactive terminal', async () => {\n\t\t\tconst { fixture, aicommits } = await createFixture();\n\n\t\t\tconst { stdout, stderr, exitCode } = await aicommits(['model'], {\n\t\t\t\treject: false,\n\t\t\t\tenv: {\n\t\t\t\t\tCI: '1',\n\t\t\t\t},\n\t\t\t});\n\n\t\t\texpect(exitCode).toBe(1);\n\t\t\texpect(stdout).toBe('');\n\t\t\texpect(stderr).toMatch('Interactive terminal required for model selection');\n\n\t\t\tawait fixture.rm();\n\t\t});\n\n\t\ttest('pr requires an interactive terminal', async () => {\n\t\t\tconst { fixture, aicommits } = await createFixture();\n\t\t\tawait createGit(fixture.path);\n\n\t\t\tconst { stdout, stderr, exitCode } = await aicommits(['pr'], {\n\t\t\t\treject: false,\n\t\t\t\tenv: {\n\t\t\t\t\tCI: '1',\n\t\t\t\t},\n\t\t\t});\n\n\t\t\texpect(exitCode).toBe(1);\n\t\t\texpect(stdout).toBe('');\n\t\t\texpect(stderr).toMatch('Interactive terminal required for PR creation');\n\n\t\t\tawait fixture.rm();\n\t\t});\n\t});\n});\n"
  },
  {
    "path": "tests/specs/cli/index.ts",
    "content": "import { testSuite } from 'manten';\n\nexport default testSuite(({ describe }) => {\n\tdescribe('CLI', ({ runTestSuite }) => {\n\t\trunTestSuite(import('./error-cases.js'));\n\t\trunTestSuite(import('./headless.js'));\n\t\trunTestSuite(import('./commits.js'));\n\t\trunTestSuite(import('./no-verify.js'));\n\t});\n});\n"
  },
  {
    "path": "tests/specs/cli/no-verify.ts",
    "content": "import { testSuite, expect } from 'manten';\nimport { execSync } from 'child_process';\nimport { existsSync } from 'fs';\nimport path from 'path';\n\nexport default testSuite(({ describe }) => {\n  describe('No Verify', ({ test }) => {\n    test('Exposes --no-verify flag', () => {\n      const cliPath = path.resolve(process.cwd(), 'dist', 'cli.mjs');\n      if (!existsSync(cliPath)) {\n        require('child_process').execSync('npm run build', { stdio: 'inherit' });\n      }\n      const output = execSync(`node ${cliPath} --help`, { encoding: 'utf8' });\n      expect(output).toContain('-n, --no-verify');\n    });\n  });\n});\n"
  },
  {
    "path": "tests/specs/config.ts",
    "content": "import fs from 'fs/promises';\nimport path from 'path';\nimport { testSuite, expect } from 'manten';\nimport { createFixture } from '../utils.js';\n\nexport default testSuite(({ describe }) => {\n\tdescribe('config', async ({ test, describe }) => {\n\t\tconst { fixture, aicommits } = await createFixture();\n\t\tconst configPath = path.join(fixture.path, '.aicommits');\n\t\tconst openAiToken = 'OPENAI_API_KEY=abc';\n\n\t\ttest('set unknown config file', async () => {\n\t\t\tconst { stderr } = await aicommits(['config', 'set', 'UNKNOWN=1'], {\n\t\t\t\treject: false,\n\t\t\t});\n\n\t\t\texpect(stderr).toMatch('Invalid config property: UNKNOWN');\n\t\t});\n\n\t\ttest('set OPENAI_API_KEY', async () => {\n\t\t\tconst { stderr } = await aicommits(\n\t\t\t\t['config', 'set', 'OPENAI_API_KEY=abc'],\n\t\t\t\t{\n\t\t\t\t\treject: false,\n\t\t\t\t}\n\t\t\t);\n\n\t\t\texpect(stderr).toBe('');\n\t\t});\n\n\t\tawait test('set config file', async () => {\n\t\t\tawait aicommits(['config', 'set', openAiToken]);\n\n\t\t\tconst configFile = await fs.readFile(configPath, 'utf8');\n\t\t\texpect(configFile).toMatch(openAiToken);\n\t\t});\n\n\t\tawait test('get config file', async () => {\n\t\t\tconst { stdout } = await aicommits(['config', 'get', 'OPENAI_API_KEY']);\n\t\t\texpect(stdout).toBe('OPENAI_API_KEY=abc****');\n\t\t});\n\n\t\tawait test('reading unknown config', async () => {\n\t\t\tawait fs.appendFile(configPath, 'UNKNOWN=1');\n\n\t\t\tconst { stdout, stderr } = await aicommits(['config', 'get', 'UNKNOWN'], {\n\t\t\t\treject: false,\n\t\t\t});\n\n\t\t\texpect(stdout).toBe('');\n\t\t\texpect(stderr).toBe('');\n\t\t});\n\n\t\tawait describe('timeout', ({ test }) => {\n\t\t\ttest('setting invalid timeout config', async () => {\n\t\t\t\tconst { stderr } = await aicommits(['config', 'set', 'timeout=abc'], {\n\t\t\t\t\treject: false,\n\t\t\t\t});\n\n\t\t\t\texpect(stderr).toMatch('Must be an integer');\n\t\t\t});\n\n\t\t\ttest('setting valid timeout config', async () => {\n\t\t\t\tconst timeout = 'timeout=20000';\n\t\t\t\tawait aicommits(['config', 'set', timeout]);\n\n\t\t\t\tconst configFile = await fs.readFile(configPath, 'utf8');\n\t\t\t\texpect(configFile).toMatch(timeout);\n\n\t\t\t\tconst get = await aicommits(['config', 'get', 'timeout']);\n\t\t\t\texpect(get.stdout).toBe(timeout);\n\t\t\t});\n\t\t});\n\n\t\tawait test('accepts type=subject+body', async () => {\n\t\t\tawait aicommits(['config', 'set', 'OPENAI_API_KEY=abc']);\n\t\t\tawait aicommits(['config', 'set', 'type=subject+body']);\n\t\t\tconst { stdout } = await aicommits(['config', 'get', 'type']);\n\t\t\texpect(stdout).toBe('type=subject+body');\n\t\t});\n\n\t\tawait describe('max-length', ({ test }) => {\n\t\t\ttest('must be an integer', async () => {\n\t\t\t\tconst { stderr } = await aicommits(\n\t\t\t\t\t['config', 'set', 'max-length=abc'],\n\t\t\t\t\t{\n\t\t\t\t\t\treject: false,\n\t\t\t\t\t}\n\t\t\t\t);\n\n\t\t\t\texpect(stderr).toMatch('Must be an integer');\n\t\t\t});\n\n\t\t\ttest('must be at least 20 characters', async () => {\n\t\t\t\tconst { stderr } = await aicommits(['config', 'set', 'max-length=10'], {\n\t\t\t\t\treject: false,\n\t\t\t\t});\n\n\t\t\t\texpect(stderr).toMatch(/must be greater than 20 characters/i);\n\t\t\t});\n\n\t\t\ttest('updates config', async () => {\n\t\t\t\tconst defaultConfig = await aicommits(['config', 'get', 'max-length']);\n\t\t\t\texpect(defaultConfig.stdout).toBe('max-length=72');\n\n\t\t\t\tconst maxLength = 'max-length=60';\n\t\t\t\tawait aicommits(['config', 'set', maxLength]);\n\n\t\t\t\tconst configFile = await fs.readFile(configPath, 'utf8');\n\t\t\t\texpect(configFile).toMatch(maxLength);\n\n\t\t\t\tconst get = await aicommits(['config', 'get', 'max-length']);\n\t\t\t\texpect(get.stdout).toBe(maxLength);\n\t\t\t});\n\t\t});\n\n\t\tawait fixture.rm();\n\t});\n});\n"
  },
  {
    "path": "tests/specs/git-hook.ts",
    "content": "import path from 'path';\nimport { testSuite, expect } from 'manten';\nimport {\n\tcreateFixture,\n\tcreateGit,\n\tfiles,\n} from '../utils.js';\n\nexport default testSuite(({ describe }) => {\n\tdescribe('Git hook', ({ test }) => {\n\t\tif (!process.env.OPENAI_API_KEY) {\n\t\t\tconsole.warn(\n\t\t\t\t'⚠️  process.env.OPENAI_API_KEY is necessary to run these tests. Skipping...'\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\n\t\ttest('errors when not in Git repo', async () => {\n\t\t\tconst { fixture, aicommits } = await createFixture(files);\n\t\t\tconst { exitCode, stderr } = await aicommits(['hook', 'install'], {\n\t\t\t\treject: false,\n\t\t\t});\n\n\t\t\texpect(exitCode).toBe(1);\n\t\t\texpect(stderr).toMatch('The current directory must be a Git repository');\n\n\t\t\tawait fixture.rm();\n\t\t});\n\n\t\ttest('installs from Git repo subdirectory', async () => {\n\t\t\tconst { fixture, aicommits } = await createFixture({\n\t\t\t\t...files,\n\t\t\t\t'some-dir': {\n\t\t\t\t\t'file.txt': '',\n\t\t\t\t},\n\t\t\t});\n\t\t\tawait createGit(fixture.path);\n\n\t\t\tconst { stdout } = await aicommits(['hook', 'install'], {\n\t\t\t\tcwd: path.join(fixture.path, 'some-dir'),\n\t\t\t});\n\t\t\texpect(stdout).toMatch('Hook installed');\n\n\t\t\texpect(await fixture.exists('.git/hooks/prepare-commit-msg')).toBe(true);\n\n\t\t\tawait fixture.rm();\n\t\t});\n\n\t\ttest('Commits', async () => {\n\t\t\tconst { fixture, aicommits } = await createFixture(files);\n\t\t\tconst git = await createGit(fixture.path);\n\n\t\t\tconst { stdout } = await aicommits(['hook', 'install']);\n\t\t\texpect(stdout).toMatch('Hook installed');\n\n\t\t\tawait git('add', ['data.json']);\n\t\t\tawait git('commit', ['--no-edit'], {\n\t\t\t\tenv: {\n\t\t\t\t\tHOME: fixture.path,\n\t\t\t\t\tUSERPROFILE: fixture.path,\n\t\t\t\t},\n\t\t\t});\n\n\t\t\tconst { stdout: commitMessage } = await git('log', ['--pretty=%B']);\n\t\t\tconsole.log('Committed with:', commitMessage);\n\t\t\texpect(commitMessage.startsWith('# ')).not.toBe(true);\n\n\t\t\tawait fixture.rm();\n\t\t});\n\t});\n});\n"
  },
  {
    "path": "tests/specs/openai/index.ts",
    "content": "import { expect, testSuite } from 'manten';\nimport {\n\tgenerateCommitMessage,\n\tgenerateCommitDescription,\n} from '../../../src/utils/openai.js';\nimport type { ValidConfig } from '../../../src/utils/config-types.js';\nimport { getDiff } from '../../utils.js';\n\nconst { OPENAI_API_KEY } = process.env;\n\nexport default testSuite(({ describe }) => {\n\tif (!OPENAI_API_KEY) {\n\t\tconsole.warn(\n\t\t\t'⚠️  process.env.OPENAI_API_KEY is necessary to run these tests. Skipping...'\n\t\t);\n\t\treturn;\n\t}\n\n\tdescribe('Conventional Commits', async ({ test }) => {\n\t\tawait test('Should not translate conventional commit type to Japanase when locale config is set to japanese', async () => {\n\t\t\tconst japaneseConventionalCommitPattern =\n\t\t\t\t/(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\\(.*\\))?: [\\u3000-\\u303F\\u3040-\\u309F\\u30A0-\\u30FF\\uFF00-\\uFF9F\\u4E00-\\u9FAF\\u3400-\\u4DBF]/;\n\n\t\t\tconst gitDiff = await getDiff('new-feature.diff');\n\n\t\t\tconst commitMessage = await runGenerateCommitMessage(gitDiff, {\n\t\t\t\tlocale: 'ja',\n\t\t\t});\n\n\t\t\texpect(commitMessage).toMatch(japaneseConventionalCommitPattern);\n\t\t\tconsole.log('Generated message:', commitMessage);\n\t\t});\n\n\t\tawait test('Should use \"feat:\" conventional commit when change relate to adding a new feature', async () => {\n\t\t\tconst gitDiff = await getDiff('new-feature.diff');\n\n\t\t\tconst commitMessage = await runGenerateCommitMessage(gitDiff);\n\n\t\t\t// should match \"feat:\" or \"feat(<scope>):\"\n\t\t\texpect(commitMessage).toMatch(/(feat(\\(.*\\))?):/);\n\t\t\tconsole.log('Generated message:', commitMessage);\n\t\t});\n\n\t\tawait test('Should use \"refactor:\" conventional commit when change relate to code refactoring', async () => {\n\t\t\tconst gitDiff = await getDiff('code-refactoring.diff');\n\n\t\t\tconst commitMessage = await runGenerateCommitMessage(gitDiff);\n\n\t\t\t// should match \"refactor:\" or \"refactor(<scope>):\"\n\t\t\texpect(commitMessage).toMatch(/(refactor(\\(.*\\))?):/);\n\t\t\tconsole.log('Generated message:', commitMessage);\n\t\t});\n\n\t\tawait test('Should use \"test:\" conventional commit when change relate to testing a React application', async () => {\n\t\t\tconst gitDiff = await getDiff('testing-react-application.diff');\n\n\t\t\tconst commitMessage = await runGenerateCommitMessage(gitDiff);\n\n\t\t\t// should match \"test:\" or \"test(<scope>):\"\n\t\t\texpect(commitMessage).toMatch(/(test(\\(.*\\))?):/);\n\t\t\tconsole.log('Generated message:', commitMessage);\n\t\t});\n\n\t\tawait test('Should use \"build:\" conventional commit when change relate to github action build pipeline', async () => {\n\t\t\tconst gitDiff = await getDiff('github-action-build-pipeline.diff');\n\n\t\t\tconst commitMessage = await runGenerateCommitMessage(gitDiff);\n\n\t\t\t// should match \"build:\" or \"build(<scope>):\"\n\t\t\texpect(commitMessage).toMatch(/((build|ci)(\\(.*\\))?):/);\n\t\t\tconsole.log('Generated message:', commitMessage);\n\t\t});\n\n\t\tawait test('Should use \"(ci|build):\" conventional commit when change relate to continious integration', async () => {\n\t\t\tconst gitDiff = await getDiff('continous-integration.diff');\n\n\t\t\tconst commitMessage = await runGenerateCommitMessage(gitDiff);\n\n\t\t\t// should match \"ci:\" or \"ci(<scope>):\n\t\t\t// It also sometimes generates build and feat\n\t\t\texpect(commitMessage).toMatch(/((ci|build|feat)(\\(.*\\))?):/);\n\t\t\tconsole.log('Generated message:', commitMessage);\n\t\t});\n\n\t\tawait test('Should use \"docs:\" conventional commit when change relate to documentation changes', async () => {\n\t\t\tconst gitDiff = await getDiff('documentation-changes.diff');\n\t\t\tconst commitMessage = await runGenerateCommitMessage(gitDiff);\n\n\t\t\t// should match \"docs:\" or \"docs(<scope>):\"\n\t\t\texpect(commitMessage).toMatch(/(docs(\\(.*\\))?):/);\n\t\t\tconsole.log('Generated message:', commitMessage);\n\t\t});\n\n\t\tawait test('Should use \"fix:\" conventional commit when change relate to fixing code', async () => {\n\t\t\tconst gitDiff = await getDiff('fix-nullpointer-exception.diff');\n\t\t\tconst commitMessage = await runGenerateCommitMessage(gitDiff);\n\n\t\t\t// should match \"fix:\" or \"fix(<scope>):\"\n\t\t\t// Sometimes it generates refactor\n\t\t\texpect(commitMessage).toMatch(/((fix|refactor)(\\(.*\\))?):/);\n\t\t\tconsole.log('Generated message:', commitMessage);\n\t\t});\n\n\t\tawait test('Should use \"style:\" conventional commit when change relate to code style improvements', async () => {\n\t\t\tconst gitDiff = await getDiff('code-style.diff');\n\t\t\tconst commitMessage = await runGenerateCommitMessage(gitDiff);\n\n\t\t\t// should match \"style:\" or \"style(<style>):\"\n\t\t\texpect(commitMessage).toMatch(/(style|refactor|fix)(\\(.*\\))?:/);\n\t\t\tconsole.log('Generated message:', commitMessage);\n\t\t});\n\n\t\tawait test('Should use \"chore:\" conventional commit when change relate to a chore or maintenance', async () => {\n\t\t\tconst gitDiff = await getDiff('chore.diff');\n\t\t\tconst commitMessage = await runGenerateCommitMessage(gitDiff);\n\n\t\t\t// should match \"chore:\" or \"chore(<style>):\"\n\t\t\t// Sometimes it generates build|feat\n\t\t\texpect(commitMessage).toMatch(/((chore|build|feat)(\\(.*\\))?):/);\n\t\t\tconsole.log('Generated message:', commitMessage);\n\t\t});\n\n\t\tawait test('Should use \"perf:\" conventional commit when change relate to a performance improvement', async () => {\n\t\t\tconst gitDiff = await getDiff('performance-improvement.diff');\n\t\t\tconst commitMessage = await runGenerateCommitMessage(gitDiff);\n\n\t\t\t// should match \"perf:\" or \"perf(<style>):\"\n\t\t\t// It also sometimes generates refactor:\n\t\t\texpect(commitMessage).toMatch(/((perf|refactor)(\\(.*\\))?):/);\n\t\t\tconsole.log('Generated message:', commitMessage);\n\t\t});\n\n\t\tasync function runGenerateCommitMessage(\n\t\t\tgitDiff: string,\n\t\t\tconfigOverrides: Partial<ValidConfig> = {}\n\t\t): Promise<string> {\n\t\t\tconst config = {\n\t\t\t\tlocale: 'en',\n\t\t\t\ttype: 'conventional',\n\t\t\t\tgenerate: 1,\n\t\t\t\t'max-length': 50,\n\t\t\t\t...configOverrides,\n\t\t\t} as ValidConfig;\n\t\t\tconst { messages: commitMessages } = await generateCommitMessage({\n\t\t\t\tbaseUrl: 'https://api.openai.com/v1',\n\t\t\t\tapiKey: OPENAI_API_KEY!,\n\t\t\t\tmodel: 'gpt-3.5-turbo',\n\t\t\t\tlocale: config.locale,\n\t\t\t\tdiff: gitDiff,\n\t\t\t\tcompletions: config.generate,\n\t\t\t\tmaxLength: config['max-length'],\n\t\t\t\ttype: config.type,\n\t\t\t\ttimeout: 7000,\n\t\t\t});\n\n\t\t\treturn commitMessages[0];\n\t\t}\n\t});\n\n\tdescribe('subject+body / generateCommitDescription', async ({ test }) => {\n\t\tawait test('generates a non-empty body from title and diff', async () => {\n\t\t\tconst gitDiff = await getDiff('new-feature.diff');\n\t\t\tconst title = 'feat: add new feature';\n\n\t\t\tconst { description } = await generateCommitDescription({\n\t\t\t\tbaseUrl: 'https://api.openai.com/v1',\n\t\t\t\tapiKey: OPENAI_API_KEY!,\n\t\t\t\tmodel: 'gpt-3.5-turbo',\n\t\t\t\tlocale: 'en',\n\t\t\t\ttitle,\n\t\t\t\tdiff: gitDiff,\n\t\t\t\ttimeout: 7000,\n\t\t\t\tmaxLength: 72,\n\t\t\t});\n\n\t\t\texpect(typeof description).toBe('string');\n\t\t\texpect(description.length).toBeGreaterThan(0);\n\t\t});\n\t});\n});\n"
  },
  {
    "path": "tests/specs/togetherai/index.ts",
    "content": "import { expect, testSuite } from 'manten';\nimport { generateCommitMessage } from '../../../src/utils/openai.js';\nimport type { ValidConfig } from '../../../src/utils/config-types.js';\nimport { getDiff } from '../../utils.js';\n\nconst { TOGETHER_API_KEY } = process.env;\n\nexport default testSuite(({ describe }) => {\n\tif (!TOGETHER_API_KEY) {\n\t\tconsole.warn(\n\t\t\t'⚠️  process.env.TOGETHER_API_KEY is necessary to run these tests. Skipping...',\n\t\t);\n\t\treturn;\n\t}\n\n\tdescribe('Conventional Commits', async ({ test }) => {\n\t\tawait test('Should generate conventional commit format', async () => {\n\t\t\tconst gitDiff = await getDiff('new-feature.diff');\n\n\t\t\tconst commitMessage = await runGenerateCommitMessage(gitDiff, {\n\t\t\t\tlocale: 'en',\n\t\t\t});\n\n\t\t\t// Should start with conventional commit type\n\t\t\texpect(commitMessage).toMatch(\n\t\t\t\t/^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)/,\n\t\t\t);\n\t\t\tconsole.log('Generated message:', commitMessage);\n\t\t});\n\n\t\tawait test('Should generate conventional commit for new feature', async () => {\n\t\t\tconst gitDiff = await getDiff('new-feature.diff');\n\n\t\t\tconst commitMessage = await runGenerateCommitMessage(gitDiff);\n\n\t\t\t// Should be in conventional commit format\n\t\t\texpect(commitMessage).toMatch(\n\t\t\t\t/^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)/,\n\t\t\t);\n\t\t\tconsole.log('Generated message:', commitMessage);\n\t\t});\n\n\t\tawait test('Should generate conventional commit for refactoring', async () => {\n\t\t\tconst gitDiff = await getDiff('code-refactoring.diff');\n\n\t\t\tconst commitMessage = await runGenerateCommitMessage(gitDiff);\n\n\t\t\t// Should be in conventional commit format\n\t\t\texpect(commitMessage).toMatch(\n\t\t\t\t/^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)/,\n\t\t\t);\n\t\t\tconsole.log('Generated message:', commitMessage);\n\t\t});\n\n\t\tasync function runGenerateCommitMessage(\n\t\t\tgitDiff: string,\n\t\t\tconfigOverrides: Partial<ValidConfig> = {},\n\t\t): Promise<string> {\n\t\t\tconst config = {\n\t\t\t\tlocale: 'en',\n\t\t\t\ttype: 'conventional',\n\t\t\t\tgenerate: 1,\n\t\t\t\t'max-length': 72,\n\t\t\t\t...configOverrides,\n\t\t\t} as ValidConfig;\n\t\t\tconst { messages: commitMessages } = await generateCommitMessage({\n\t\t\t\tbaseUrl: 'https://api.together.xyz',\n\t\t\t\tapiKey: TOGETHER_API_KEY!,\n\t\t\t\tmodel: 'Qwen/Qwen3-Next-80B-A3B-Instruct',\n\t\t\t\tlocale: config.locale,\n\t\t\t\tdiff: gitDiff,\n\t\t\t\tcompletions: config.generate,\n\t\t\t\tmaxLength: config['max-length'],\n\t\t\t\ttype: config.type,\n\t\t\t\ttimeout: 7000,\n\t\t\t});\n\n\t\t\treturn commitMessages[0];\n\t\t}\n\t});\n});\n"
  },
  {
    "path": "tests/utils.ts",
    "content": "import path from 'path';\nimport fs from 'fs/promises';\nimport { execa, execaNode, type Options } from 'execa';\nimport {\n\tcreateFixture as createFixtureBase,\n\ttype FileTree,\n\ttype FsFixture,\n} from 'fs-fixture';\n\nconst aicommitsPath = path.resolve('./dist/cli.mjs');\n\nconst createAicommits = (fixture: FsFixture) => {\n\tconst homeEnv = {\n\t\tHOME: fixture.path, // Linux\n\t\tUSERPROFILE: fixture.path, // Windows\n\t};\n\n\treturn (args?: string[], options?: Options) =>\n\t\texecaNode(aicommitsPath, args, {\n\t\t\tcwd: fixture.path,\n\t\t\t...options,\n\t\t\textendEnv: false,\n\t\t\tenv: {\n\t\t\t\t...homeEnv,\n\t\t\t\t...options?.env,\n\t\t\t},\n\n\t\t\t// Block tsx nodeOptions\n\t\t\tnodeOptions: [],\n\t\t});\n};\n\nexport const createGit = async (cwd: string) => {\n\tconst git = (command: string, args?: string[], options?: Options) =>\n\t\texeca('git', [command, ...(args || [])], {\n\t\t\tcwd,\n\t\t\t...options,\n\t\t});\n\n\tawait git('init', [\n\t\t// In case of different default branch name\n\t\t'--initial-branch=master',\n\t]);\n\n\tawait git('config', ['user.name', 'name']);\n\tawait git('config', ['user.email', 'email']);\n\n\treturn git;\n};\n\nexport const createFixture = async (source?: string | FileTree) => {\n\tconst fixture = await createFixtureBase(source);\n\tconst aicommits = createAicommits(fixture);\n\n\treturn {\n\t\tfixture,\n\t\taicommits,\n\t};\n};\n\nexport const files = Object.freeze({\n\t'.aicommits': `OPENAI_API_KEY=${process.env.OPENAI_API_KEY}`,\n\t'data.json': Array.from(\n\t\t{ length: 10 },\n\t\t(_, i) => `${i}. Lorem ipsum dolor sit amet`\n\t).join('\\n'),\n});\n\n\n\n// See ./diffs/README.md in order to generate diff files\nexport const getDiff = async (diffName: string): Promise<string> =>\n\tfs.readFile(new URL(`fixtures/${diffName}`, import.meta.url), 'utf8');\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\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\"resolveJsonModule\": true,\n\t\t\"isolatedModules\": true,\n\t\t\"esModuleInterop\": true,\n\t\t\"skipLibCheck\": true\n\t},\n\t\"exclude\": [\"vscode-extension\"]\n}\n"
  },
  {
    "path": "vscode-extension/.gitignore",
    "content": "node_modules\ndist\n*.vsix\n"
  },
  {
    "path": "vscode-extension/.vscode/launch.json",
    "content": "{\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\": \"launch\",\n\t\t\t\"runtimeExecutable\": \"/Applications/Cursor.app/Contents/MacOS/Cursor\",\n\t\t\t\"args\": [\n\t\t\t\t\"--extensionDevelopmentPath=${workspaceFolder}\"\n\t\t\t]\n\t\t}\n\t]\n}\n"
  },
  {
    "path": "vscode-extension/.vscodeignore",
    "content": ".vscode/**\n.vscode-test/**\nsrc/**\n.gitignore\n**/*.map\n**/node_modules/**\n"
  },
  {
    "path": "vscode-extension/LICENSE",
    "content": "MIT License\n\nCopyright (c) Hassan El Mghari\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "vscode-extension/README.md",
    "content": "# AI Commits VSCode Extension\n\nGenerate git commit messages using AI directly from VSCode's Git interface.\n\n## Features\n\n- Generate commit messages with a single click from the Source Control panel\n- Support for plain, conventional, and gitmoji commit formats\n- Integrates seamlessly with VSCode's built-in Git extension\n- Preview messages before committing or auto-commit option\n\n## Requirements\n\n- [aicommits CLI](https://github.com/anthropics/aicommits) must be installed and configured\n- Run `aicommits setup` first to configure your AI provider\n\n## Usage\n\n1. Open the Source Control panel (Ctrl/Cmd + Shift + G)\n2. Stage your changes\n3. Click the sparkle icon in the toolbar or use the command palette:\n   - `AI Commits: Generate Commit Message` - Plain format\n   - `AI Commits: Generate Conventional Commit` - Conventional commits format\n   - `AI Commits: Generate Gitmoji Commit` - Gitmoji format\n4. The generated message appears in the commit input box\n5. Review and commit!\n\n## Configuration\n\n| Setting | Default | Description |\n|---------|---------|-------------|\n| `aicommits.path` | `aicommits` | Path to the aicommits CLI binary |\n| `aicommits.defaultType` | `plain` | Default commit message format (plain, conventional, gitmoji) |\n| `aicommits.autoCommit` | `false` | Auto-commit after generating (skips preview) |\n\n## Commands\n\n- `aicommits.generate` - Generate plain commit message\n- `aicommits.generateConventional` - Generate conventional commit\n- `aicommits.generateGitmoji` - Generate gitmoji commit\n- `aicommits.setup` - Setup AI provider (opens terminal)\n- `aicommits.selectModel` - Select AI model (opens terminal)\n\n## Installation\n\n### From Source\n\n```bash\ncd vscode-extension\npnpm install\npnpm run compile\n```\n\nThen in VSCode:\n1. Open the Extensions panel\n2. Click \"...\" menu → \"Install from VSIX...\"\n3. Select the `.vsix` file (run `pnpm run package` first)\n\n## License\n\nMIT\n"
  },
  {
    "path": "vscode-extension/package.json",
    "content": "{\n\t\"name\": \"aicommits\",\n\t\"displayName\": \"AI Commits\",\n\t\"description\": \"Generate git commit messages using AI directly from VSCode's Git interface\",\n\t\"version\": \"1.0.0\",\n\t\"publisher\": \"aicommits\",\n\t\"license\": \"MIT\",\n\t\"icon\": \"media/icon.png\",\n\t\"repository\": {\n\t\t\"type\": \"git\",\n\t\t\"url\": \"https://github.com/nutlope/aicommits\"\n\t},\n\t\"engines\": {\n\t\t\"vscode\": \"^1.85.0\"\n\t},\n\t\"categories\": [\n\t\t\"Source Control\",\n\t\t\"AI\"\n\t],\n\t\"keywords\": [\n\t\t\"aicommits\",\n\t\t\"git\",\n\t\t\"commit\",\n\t\t\"ai\",\n\t\t\"openai\",\n\t\t\"gpt\",\n\t\t\"llm\"\n\t],\n\t\"main\": \"./dist/extension.js\",\n\t\"activationEvents\": [\n\t\t\"onStartupFinished\"\n\t],\n\t\"contributes\": {\n\t\t\"commands\": [\n\t\t\t{\n\t\t\t\t\"command\": \"aicommits.generate\",\n\t\t\t\t\"title\": \"AI Commits: Generate Commit Message\",\n\t\t\t\t\"icon\": {\n\t\t\t\t\t\"light\": \"media/icon.png\",\n\t\t\t\t\t\"dark\": \"media/icon.png\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"command\": \"aicommits.generateConventional\",\n\t\t\t\t\"title\": \"AI Commits: Generate Conventional Commit\",\n\t\t\t\t\"icon\": \"$(checklist)\"\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"command\": \"aicommits.generateGitmoji\",\n\t\t\t\t\"title\": \"AI Commits: Generate Gitmoji Commit\",\n\t\t\t\t\"icon\": \"$(symbol-enum)\"\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"command\": \"aicommits.generateSubjectBody\",\n\t\t\t\t\"title\": \"AI Commits: Generate Subject+Body Commit\",\n\t\t\t\t\"icon\": \"$(list-unordered)\"\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"command\": \"aicommits.setup\",\n\t\t\t\t\"title\": \"AI Commits: Setup Provider\"\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"command\": \"aicommits.selectModel\",\n\t\t\t\t\"title\": \"AI Commits: Select Model\"\n\t\t\t}\n\t\t],\n\t\t\"menus\": {\n\t\t\t\"scm/title\": [\n\t\t\t\t{\n\t\t\t\t\t\"command\": \"aicommits.generate\",\n\t\t\t\t\t\"group\": \"navigation@1\",\n\t\t\t\t\t\"when\": \"scmProvider == git\"\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"scm/resourceGroup/context\": [\n\t\t\t\t{\n\t\t\t\t\t\"command\": \"aicommits.generate\",\n\t\t\t\t\t\"group\": \"1_modification@1\",\n\t\t\t\t\t\"when\": \"scmProvider == git\"\n\t\t\t\t}\n\t\t\t]\n\t\t},\n\t\t\"configuration\": {\n\t\t\t\"title\": \"AI Commits\",\n\t\t\t\"properties\": {\n\t\t\t\t\"aicommits.path\": {\n\t\t\t\t\t\"type\": \"string\",\n\t\t\t\t\t\"default\": \"aicommits\",\n\t\t\t\t\t\"description\": \"Path to the aicommits CLI binary (use 'aic' for short)\"\n\t\t\t\t},\n\t\t\t\t\"aicommits.defaultType\": {\n\t\t\t\t\t\"type\": \"string\",\n\t\t\t\t\t\"default\": \"plain\",\n\t\t\t\t\t\"enum\": [\n\t\t\t\t\t\t\"plain\",\n\t\t\t\t\t\t\"conventional\",\n\t\t\t\t\t\t\"gitmoji\",\n\t\t\t\t\t\t\"subject+body\"\n\t\t\t\t\t],\n\t\t\t\t\t\"description\": \"Default commit message format\"\n\t\t\t\t},\n\t\t\t\t\"aicommits.autoCommit\": {\n\t\t\t\t\t\"type\": \"boolean\",\n\t\t\t\t\t\"default\": false,\n\t\t\t\t\t\"description\": \"Automatically commit after generating the message (skips preview)\"\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t},\n\t\"scripts\": {\n\t\t\"vscode:prepublish\": \"pnpm run compile\",\n\t\t\"compile\": \"tsc -p ./\",\n\t\t\"watch\": \"tsc -watch -p ./\",\n\t\t\"lint\": \"eslint src --ext ts\",\n\t\t\"package\": \"vsce package --no-dependencies\",\n\t\t\"cursor\": \"pnpm run package && cursor --install-extension aicommits-1.0.0.vsix\"\n\t},\n\t\"devDependencies\": {\n\t\t\"@types/node\": \"^20.10.0\",\n\t\t\"@types/vscode\": \"^1.85.0\",\n\t\t\"@vscode/vsce\": \"^3.7.1\",\n\t\t\"typescript\": \"^5.3.0\"\n\t}\n}\n"
  },
  {
    "path": "vscode-extension/src/extension.ts",
    "content": "import * as vscode from 'vscode';\nimport { spawn } from 'child_process';\nimport { promisify } from 'util';\nimport { exec } from 'child_process';\n\nconst execAsync = promisify(exec);\n\nlet outputChannel: vscode.OutputChannel;\nconst TIMEOUT_MS = 15000;\nlet cliInstalled = false;\nconst PACKAGE_NAME = 'aicommits';\n\nexport function activate(context: vscode.ExtensionContext) {\n\toutputChannel = vscode.window.createOutputChannel('AI Commits');\n\toutputChannel.appendLine('[Extension] Activating AI Commits extension...');\n\n\tconst generateCommand = vscode.commands.registerCommand(\n\t\t'aicommits.generate',\n\t\t() => {\n\t\t\tconst config = vscode.workspace.getConfiguration('aicommits');\n\t\t\tconst defaultType = config.get<\n\t\t\t\t'plain' | 'conventional' | 'gitmoji' | 'subject+body'\n\t\t\t>('defaultType', 'plain');\n\t\t\treturn generateCommitMessage(defaultType);\n\t\t},\n\t);\n\n\tconst generateConventionalCommand = vscode.commands.registerCommand(\n\t\t'aicommits.generateConventional',\n\t\t() => generateCommitMessage('conventional'),\n\t);\n\n\tconst generateGitmojiCommand = vscode.commands.registerCommand(\n\t\t'aicommits.generateGitmoji',\n\t\t() => generateCommitMessage('gitmoji'),\n\t);\n\n\tconst generateSubjectBodyCommand = vscode.commands.registerCommand(\n\t\t'aicommits.generateSubjectBody',\n\t\t() => generateCommitMessage('subject+body'),\n\t);\n\n\tconst setupCommand = vscode.commands.registerCommand('aicommits.setup', () =>\n\t\topenSetupTerminal(),\n\t);\n\n\tconst selectModelCommand = vscode.commands.registerCommand(\n\t\t'aicommits.selectModel',\n\t\t() => openTerminal('aicommits model'),\n\t);\n\n\tcontext.subscriptions.push(\n\t\tgenerateCommand,\n\t\tgenerateConventionalCommand,\n\t\tgenerateGitmojiCommand,\n\t\tgenerateSubjectBodyCommand,\n\t\tsetupCommand,\n\t\tselectModelCommand,\n\t\toutputChannel,\n\t);\n\n\tcheckCliOnActivation();\n}\n\nasync function checkCliOnActivation() {\n\toutputChannel.appendLine('[Activation] Checking CLI installation...');\n\tcliInstalled = await isCliInstalled();\n\toutputChannel.appendLine(`[Activation] CLI installed: ${cliInstalled}`);\n\n\tif (!cliInstalled) {\n\t\tconst action = await vscode.window.showInformationMessage(\n\t\t\t'AI Commits requires aicommits CLI. Install it now?',\n\t\t\t'Install',\n\t\t\t'Later',\n\t\t);\n\n\t\tif (action === 'Install') {\n\t\t\tawait installCli();\n\t\t}\n\t} else {\n\t\tcheckForCliUpdate();\n\t}\n}\n\nasync function getCliVersion(): Promise<string | null> {\n\ttry {\n\t\tconst { stdout } = await execAsync('aicommits --version');\n\t\tconst version = stdout.trim().replace(/^v/, '');\n\t\toutputChannel.appendLine(`[CLI] Detected version: ${version}`);\n\t\treturn version;\n\t} catch (error) {\n\t\toutputChannel.appendLine(`[CLI] Failed to get version: ${error}`);\n\t\treturn null;\n\t}\n}\n\nasync function fetchLatestVersion(distTag: string): Promise<string | null> {\n\tconst url = `https://registry.npmjs.org/${PACKAGE_NAME}/${distTag}`;\n\toutputChannel.appendLine(`[NPM] Fetching: ${url}`);\n\ttry {\n\t\tconst response = await fetch(url, {\n\t\t\theaders: { Accept: 'application/json' },\n\t\t});\n\t\toutputChannel.appendLine(`[NPM] Response status: ${response.status}`);\n\t\tif (!response.ok) return null;\n\t\tconst data = (await response.json()) as { version?: string };\n\t\toutputChannel.appendLine(`[NPM] Got version: ${data.version}`);\n\t\treturn data.version || null;\n\t} catch (error) {\n\t\toutputChannel.appendLine(`[NPM] Fetch failed: ${error}`);\n\t\treturn null;\n\t}\n}\n\nfunction parseVersion(version: string) {\n\tconst match = version.match(\n\t\t/^(\\d+)\\.(\\d+)\\.(\\d+)(?:-([a-zA-Z]+)(?:\\.(\\d+))?)/,\n\t);\n\tif (!match)\n\t\treturn {\n\t\t\tmajor: 0,\n\t\t\tminor: 0,\n\t\t\tpatch: 0,\n\t\t\tprerelease: null as string | null,\n\t\t\tprereleaseNum: 0,\n\t\t};\n\treturn {\n\t\tmajor: parseInt(match[1], 10),\n\t\tminor: parseInt(match[2], 10),\n\t\tpatch: parseInt(match[3], 10),\n\t\tprerelease: match[4] || null,\n\t\tprereleaseNum: match[5] ? parseInt(match[5], 10) : 0,\n\t};\n}\n\nfunction compareVersions(v1: string, v2: string): number {\n\tconst p1 = parseVersion(v1);\n\tconst p2 = parseVersion(v2);\n\tif (p1.major !== p2.major) return p1.major > p2.major ? 1 : -1;\n\tif (p1.minor !== p2.minor) return p1.minor > p2.minor ? 1 : -1;\n\tif (p1.patch !== p2.patch) return p1.patch > p2.patch ? 1 : -1;\n\tif (!p1.prerelease && p2.prerelease) return 1;\n\tif (p1.prerelease && !p2.prerelease) return -1;\n\tif (!p1.prerelease && !p2.prerelease) return 0;\n\tif (p1.prereleaseNum !== p2.prereleaseNum)\n\t\treturn p1.prereleaseNum > p2.prereleaseNum ? 1 : -1;\n\treturn 0;\n}\n\nasync function checkForCliUpdate(): Promise<void> {\n\toutputChannel.appendLine('[Update Check] Starting...');\n\n\tconst currentVersion = await getCliVersion();\n\toutputChannel.appendLine(\n\t\t`[Update Check] Current version: ${currentVersion || 'not found'}`,\n\t);\n\tif (!currentVersion) return;\n\n\tconst distTag = currentVersion.includes('-') ? 'develop' : 'latest';\n\toutputChannel.appendLine(`[Update Check] Using dist-tag: ${distTag}`);\n\n\tconst latestVersion = await fetchLatestVersion(distTag);\n\toutputChannel.appendLine(\n\t\t`[Update Check] Latest version: ${latestVersion || 'not found'}`,\n\t);\n\tif (!latestVersion) return;\n\n\tconst comparison = compareVersions(currentVersion, latestVersion);\n\toutputChannel.appendLine(\n\t\t`[Update Check] Version comparison result: ${comparison}`,\n\t);\n\n\tif (comparison >= 0) {\n\t\toutputChannel.appendLine('[Update Check] No update needed');\n\t\treturn;\n\t}\n\n\toutputChannel.appendLine(\n\t\t`[Update Check] Update available! Showing notification...`,\n\t);\n\n\tconst action = await vscode.window.showInformationMessage(\n\t\t`A new version of aicommits CLI is available (v${latestVersion}). Update now?`,\n\t\t'Update',\n\t\t'Later',\n\t);\n\n\toutputChannel.appendLine(\n\t\t`[Update Check] User action: ${action || 'dismissed'}`,\n\t);\n\n\tif (action === 'Update') {\n\t\tconst terminal = vscode.window.createTerminal({\n\t\t\tname: 'AI Commits Update',\n\t\t});\n\t\tterminal.show();\n\t\tterminal.sendText(`npm install -g ${PACKAGE_NAME}@${distTag}`);\n\t\tvscode.window.showInformationMessage('Updating aicommits CLI...');\n\t}\n}\n\nasync function isCliInstalled(): Promise<boolean> {\n\treturn new Promise((resolve) => {\n\t\tconst proc = spawn('which', ['aicommits'], { shell: true });\n\t\tlet output = '';\n\t\tproc.stdout.on('data', (data) => {\n\t\t\toutput += data.toString();\n\t\t});\n\t\tproc.on('close', (code) => {\n\t\t\toutputChannel.appendLine(\n\t\t\t\t`[CLI Check] which aicommits exit code: ${code}, output: ${output.trim()}`,\n\t\t\t);\n\t\t\tresolve(code === 0);\n\t\t});\n\t\tproc.on('error', (err) => {\n\t\t\toutputChannel.appendLine(`[CLI Check] Error: ${err}`);\n\t\t\tresolve(false);\n\t\t});\n\t});\n}\n\nasync function installCli(): Promise<boolean> {\n\treturn new Promise((resolve) => {\n\t\tconst terminal = vscode.window.createTerminal({ name: 'AI Commits Setup' });\n\t\tterminal.show();\n\t\tterminal.sendText('npm install -g aicommits && aicommits setup');\n\n\t\tvscode.window.showInformationMessage(\n\t\t\t'Installing aicommits... Complete the setup in the terminal, then try again.',\n\t\t\t'OK',\n\t\t);\n\n\t\tresolve(false);\n\t});\n}\n\nasync function ensureCliInstalled(): Promise<boolean> {\n\tif (cliInstalled) {\n\t\treturn true;\n\t}\n\n\tcliInstalled = await isCliInstalled();\n\tif (cliInstalled) {\n\t\treturn true;\n\t}\n\n\tconst action = await vscode.window.showErrorMessage(\n\t\t'aicommits CLI is not installed. Install it now?',\n\t\t'Install',\n\t\t'Cancel',\n\t);\n\n\tif (action === 'Install') {\n\t\tawait installCli();\n\t}\n\treturn false;\n}\n\nasync function generateCommitMessage(\n\ttype: 'plain' | 'conventional' | 'gitmoji' | 'subject+body',\n) {\n\tif (!(await ensureCliInstalled())) {\n\t\treturn;\n\t}\n\n\tconst config = vscode.workspace.getConfiguration('aicommits');\n\tconst cliPath = config.get<string>('path', 'aicommits');\n\tconst autoCommit = config.get<boolean>('autoCommit', false);\n\n\tconst workspaceFolders = vscode.workspace.workspaceFolders;\n\tif (!workspaceFolders || workspaceFolders.length === 0) {\n\t\tvscode.window.showErrorMessage('No workspace folder open');\n\t\treturn;\n\t}\n\n\tconst cwd = workspaceFolders[0].uri.fsPath;\n\n\tconst gitExtension = vscode.extensions.getExtension('vscode.git')?.exports;\n\tconst git = gitExtension?.getAPI(1);\n\tconst repo = git?.repositories[0];\n\n\tif (!repo) {\n\t\tvscode.window.showErrorMessage('No Git repository found');\n\t\treturn;\n\t}\n\n\tconst originalMessage = repo.inputBox.value;\n\trepo.inputBox.value = '⏳ Generating commit message...';\n\n\tawait vscode.window.withProgress(\n\t\t{\n\t\t\tlocation: vscode.ProgressLocation.SourceControl,\n\t\t\ttitle: 'Generating commit message...',\n\t\t\tcancellable: false,\n\t\t},\n\t\tasync () => {\n\t\t\ttry {\n\t\t\t\tconst args = [];\n\t\t\t\tif (type !== 'plain') {\n\t\t\t\t\targs.push('--type', type);\n\t\t\t\t}\n\n\t\t\t\tconst message = await runCli(cliPath, args, cwd, TIMEOUT_MS);\n\n\t\t\t\tif (!message) {\n\t\t\t\t\trepo.inputBox.value = originalMessage;\n\t\t\t\t\tvscode.window.showWarningMessage('No message generated');\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\tif (autoCommit) {\n\t\t\t\t\tawait commitWithMessage(repo, message);\n\t\t\t\t} else {\n\t\t\t\t\trepo.inputBox.value = message;\n\t\t\t\t\tvscode.window.showInformationMessage('✨ Commit message generated!');\n\t\t\t\t}\n\t\t\t} catch (error) {\n\t\t\t\trepo.inputBox.value = originalMessage;\n\t\t\t\tconst errorMessage =\n\t\t\t\t\terror instanceof Error ? error.message : String(error);\n\n\t\t\t\tif (errorMessage.includes('Timeout')) {\n\t\t\t\t\tvscode.window\n\t\t\t\t\t\t.showWarningMessage(\n\t\t\t\t\t\t\t'⏱️ AI is taking too long. Try again or check your API key.',\n\t\t\t\t\t\t\t'Setup',\n\t\t\t\t\t\t\t'Cancel',\n\t\t\t\t\t\t)\n\t\t\t\t\t\t.then((action) => {\n\t\t\t\t\t\t\tif (action === 'Setup') {\n\t\t\t\t\t\t\t\topenSetupTerminal();\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t});\n\t\t\t\t} else {\n\t\t\t\t\toutputChannel.appendLine(`Error: ${errorMessage}`);\n\t\t\t\t\tvscode.window.showErrorMessage(`AI Commits error: ${errorMessage}`);\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\t);\n}\n\nfunction openSetupTerminal() {\n\tconst workspaceFolders = vscode.workspace.workspaceFolders;\n\tconst cwd = workspaceFolders?.[0]?.uri.fsPath;\n\n\tconst terminal = vscode.window.createTerminal({\n\t\tname: 'AI Commits Setup',\n\t\tcwd,\n\t});\n\n\tterminal.show();\n\tterminal.sendText('aicommits setup');\n}\n\nfunction openTerminal(command: string) {\n\tconst workspaceFolders = vscode.workspace.workspaceFolders;\n\tconst cwd = workspaceFolders?.[0]?.uri.fsPath;\n\n\tconst terminal = vscode.window.createTerminal({\n\t\tname: 'AI Commits',\n\t\tcwd,\n\t});\n\n\tterminal.show();\n\tterminal.sendText(command);\n}\n\nfunction runCli(\n\tcliPath: string,\n\targs: string[],\n\tcwd: string,\n\ttimeout: number,\n): Promise<string> {\n\treturn new Promise((resolve, reject) => {\n\t\toutputChannel.appendLine(`Running: ${cliPath} ${args.join(' ')}`);\n\n\t\tconst proc = spawn(cliPath, args, {\n\t\t\tcwd,\n\t\t\tshell: true,\n\t\t});\n\n\t\tlet stdout = '';\n\t\tlet stderr = '';\n\n\t\tproc.stdout.on('data', (data) => {\n\t\t\tstdout += data.toString();\n\t\t});\n\n\t\tproc.stderr.on('data', (data) => {\n\t\t\tstderr += data.toString();\n\t\t\toutputChannel.append(data.toString());\n\t\t});\n\n\t\tconst timer = setTimeout(() => {\n\t\t\tproc.kill();\n\t\t\treject(new Error('Timeout'));\n\t\t}, timeout);\n\n\t\tproc.on('close', (code) => {\n\t\t\tclearTimeout(timer);\n\n\t\t\tif (code !== 0) {\n\t\t\t\treject(new Error(stderr || `Process exited with code ${code}`));\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tresolve(stdout.trim());\n\t\t});\n\n\t\tproc.on('error', (err) => {\n\t\t\tclearTimeout(timer);\n\t\t\treject(err);\n\t\t});\n\t});\n}\n\nasync function commitWithMessage(repo: any, message: string) {\n\ttry {\n\t\tawait repo.commit(message);\n\t\tvscode.window.showInformationMessage('✅ Committed successfully!');\n\t} catch (error) {\n\t\tvscode.window.showErrorMessage(\n\t\t\t`Failed to commit: ${error instanceof Error ? error.message : String(error)}`,\n\t\t);\n\t}\n}\n\nexport function deactivate() {}\n"
  },
  {
    "path": "vscode-extension/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"module\": \"commonjs\",\n    \"target\": \"ES2020\",\n    \"lib\": [\"ES2020\"],\n    \"sourceMap\": true,\n    \"rootDir\": \"src\",\n    \"outDir\": \"dist\",\n    \"strict\": true,\n    \"esModuleInterop\": true,\n    \"skipLibCheck\": true,\n    \"forceConsistentCasingInFileNames\": true,\n    \"resolveJsonModule\": true\n  },\n  \"include\": [\"src/**/*\"],\n  \"exclude\": [\"node_modules\", \"dist\"]\n}\n"
  }
]