ai_git 0.2.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/LICENSE +28 -0
- data/README.md +8 -0
- data/bin/ai_git +20 -1
- data/doc/RELEASE.md +77 -0
- data/doc/USAGE.md +107 -0
- data/lib/ai_git/ai_client.rb +165 -0
- data/lib/ai_git/commands/config.rb +55 -0
- data/lib/ai_git/commands/default.rb +256 -0
- data/lib/ai_git/config.rb +130 -26
- data/lib/ai_git/git.rb +77 -8
- data/lib/ai_git/options.rb +57 -0
- data/lib/ai_git/prompt.rb +67 -0
- data/lib/ai_git/secrets.rb +66 -0
- data/lib/ai_git/ui.rb +79 -0
- data/lib/ai_git/version.rb +7 -1
- data/lib/ai_git.rb +64 -7
- metadata +20 -7
- data/lib/ai_git/default.rb +0 -177
- data/lib/ai_git/review.rb +0 -173
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2fb023f0a7a794ab912a9502ce5155883ecad2171832690124534e4ded1ce551
|
|
4
|
+
data.tar.gz: bb16265d754dffabf9718b410d38d8f71e00cfc249e2a97ed41cc82cfefcca2c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: beb8cf739a17dc85da3e07bcd75dca5108e30f1aaef7249ac663435322440b0b4ada1768607e97faf15aab35cc629a01b58bacaddd491a3e71171838258ce1a0
|
|
7
|
+
data.tar.gz: 8a9e339b686103ecc350e3844f594f1b0bf0e7297e8305da1754399cfa71955c56eca197742781847213f71beb9b74e333276ca32c39b2edf81a1d5247d24155
|
data/LICENSE
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, Kaíque Kandy Koga
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without
|
|
6
|
+
modification, are permitted provided that the following conditions are met:
|
|
7
|
+
|
|
8
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
9
|
+
list of conditions and the following disclaimer.
|
|
10
|
+
|
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
|
|
15
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
16
|
+
contributors may be used to endorse or promote products derived from
|
|
17
|
+
this software without specific prior written permission.
|
|
18
|
+
|
|
19
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
20
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
21
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
22
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
23
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
24
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
25
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
26
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
27
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
28
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
data/README.md
ADDED
data/bin/ai_git
CHANGED
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
2
|
# frozen_string_literal: true
|
|
3
|
+
# bin/ai_git
|
|
4
|
+
#
|
|
5
|
+
# @purpose Executable entry point for the ai_git CLI: load the library and
|
|
6
|
+
# hand ARGV to AIGit.start.
|
|
7
|
+
# @exports ai_git: the `config` subcommand and the -n/--dry-run, --no-push,
|
|
8
|
+
# -y/--yes, -f/--force, -h/--help, -v/--version flags.
|
|
9
|
+
# @dependencies lib/ai_git: supplies AIGit.start and AIGit::UI.error.
|
|
10
|
+
# @sideEffects Runs the whole CLI; exits 130 on interrupt and 1 on any other
|
|
11
|
+
# error, after printing the message to stderr.
|
|
12
|
+
# @notes Catches StandardError only, so the string messages the library
|
|
13
|
+
# raises surface as one clean line instead of a backtrace.
|
|
3
14
|
|
|
4
15
|
require_relative "../lib/ai_git"
|
|
5
16
|
|
|
6
|
-
|
|
17
|
+
begin
|
|
18
|
+
AIGit.start(ARGV)
|
|
19
|
+
rescue Interrupt
|
|
20
|
+
warn "\nAborted."
|
|
21
|
+
exit 130
|
|
22
|
+
rescue StandardError => e
|
|
23
|
+
AIGit::UI.error("ai_git: #{e.message}")
|
|
24
|
+
exit 1
|
|
25
|
+
end
|
data/doc/RELEASE.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Release
|
|
2
|
+
|
|
3
|
+
`ai_git` is published to [RubyGems](https://rubygems.org/gems/ai_git) by
|
|
4
|
+
`.github/workflows/release.yml`, which runs on every push to `master` that
|
|
5
|
+
touches `lib/ai_git/version.rb`. The workflow authenticates with
|
|
6
|
+
[trusted publishing](https://guides.rubygems.org/trusted-publishing/): GitHub
|
|
7
|
+
mints a short-lived OIDC token, RubyGems exchanges it for a single-use API key
|
|
8
|
+
scoped to this gem. No API key is stored in the repository, and the push
|
|
9
|
+
satisfies the `rubygems_mfa_required` flag set in `ai_git.gemspec` without an
|
|
10
|
+
interactive MFA prompt.
|
|
11
|
+
|
|
12
|
+
## One-time setup
|
|
13
|
+
|
|
14
|
+
1. **RubyGems** — profile → the `ai_git` gem → *Trusted publishers* → *Create*:
|
|
15
|
+
|
|
16
|
+
| Field | Value |
|
|
17
|
+
|-------|-------|
|
|
18
|
+
| Repository owner | `kaiquekandykoga` |
|
|
19
|
+
| Repository name | `ai_git` |
|
|
20
|
+
| Workflow filename | `release.yml` |
|
|
21
|
+
| Environment | `release` |
|
|
22
|
+
|
|
23
|
+
2. **GitHub** — Settings → Environments → `release`. It is created on the first
|
|
24
|
+
workflow run; add required reviewers there to gate each publish behind a
|
|
25
|
+
manual approval.
|
|
26
|
+
|
|
27
|
+
The values must match the workflow exactly. Renaming the workflow file or the
|
|
28
|
+
environment invalidates the publisher and the push is rejected.
|
|
29
|
+
|
|
30
|
+
## Cutting a release
|
|
31
|
+
|
|
32
|
+
1. Bump `AIGit::VERSION` in `lib/ai_git/version.rb`.
|
|
33
|
+
2. Commit the bump and push it to `master`.
|
|
34
|
+
|
|
35
|
+
That is the whole procedure. The push starts the workflow, which tags and
|
|
36
|
+
publishes on its own — there is no tag to create by hand.
|
|
37
|
+
|
|
38
|
+
## What the workflow does
|
|
39
|
+
|
|
40
|
+
1. Checks out `master` with its full history and tags, on Ruby 4.0.
|
|
41
|
+
2. Reads `AIGit::VERSION` and looks for the matching `v<version>` tag. If the
|
|
42
|
+
tag already exists the version was not bumped — the change to `version.rb`
|
|
43
|
+
was a comment or a header edit — and every later step is skipped, so the
|
|
44
|
+
run is a no-op rather than a failure.
|
|
45
|
+
3. Installs the bundle, then runs `rake test` and `rubocop`. A failure here
|
|
46
|
+
stops the run before anything is tagged or published.
|
|
47
|
+
4. Creates the annotated `v<version>` tag and pushes it.
|
|
48
|
+
5. Runs `rubygems/release-gem@v1`, which configures the OIDC credentials, runs
|
|
49
|
+
`bundle exec rake release` (build, `guard_clean`, `gem push`), attaches a
|
|
50
|
+
sigstore attestation, and waits for the version to appear on RubyGems. The
|
|
51
|
+
tag from step 4 already exists, so the release task skips tagging and goes
|
|
52
|
+
straight to the gem push.
|
|
53
|
+
|
|
54
|
+
A failure before step 5 publishes nothing. If the run fails after the tag is
|
|
55
|
+
pushed, delete the tag (`git push origin :refs/tags/v1.0.0`) and re-run the
|
|
56
|
+
workflow from the Actions tab — `workflow_dispatch` is enabled for exactly
|
|
57
|
+
that case.
|
|
58
|
+
|
|
59
|
+
## Verifying
|
|
60
|
+
|
|
61
|
+
The gem is live when the version shows up on
|
|
62
|
+
[rubygems.org/gems/ai_git/versions](https://rubygems.org/gems/ai_git/versions),
|
|
63
|
+
which the last step of the workflow waits for. Locally:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
gem list -r ai_git --all
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Publishing by hand
|
|
70
|
+
|
|
71
|
+
Only needed if the workflow is unavailable. This is the path that prompts for
|
|
72
|
+
MFA:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
gem build ai_git.gemspec
|
|
76
|
+
gem push ai_git-1.0.0.gem
|
|
77
|
+
```
|
data/doc/USAGE.md
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# Usage
|
|
2
|
+
|
|
3
|
+
## Requirements
|
|
4
|
+
|
|
5
|
+
- [llama.cpp](https://github.com/ggml-org/llama.cpp) running locally (e.g. `./llama-server --port 8080`)
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
gem install ai_git
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Configuration
|
|
14
|
+
|
|
15
|
+
Settings live in a YAML file at `~/.ai_git/config.yml` (`config.yaml` is read
|
|
16
|
+
too). The file is optional — without it every setting falls back to its
|
|
17
|
+
default.
|
|
18
|
+
|
|
19
|
+
```yaml
|
|
20
|
+
# ~/.ai_git/config.yml
|
|
21
|
+
model_name: ggml-org/gemma-4-E4B-it-GGUF:Q8_0
|
|
22
|
+
base_url: http://127.0.0.1:8080
|
|
23
|
+
no_color: false
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
| Setting | Description | Default |
|
|
27
|
+
|---------|-------------|---------|
|
|
28
|
+
| `model_name` | Model name | `ggml-org/gemma-4-E4B-it-GGUF:Q8_0` |
|
|
29
|
+
| `base_url` | Base URL of the llama.cpp server | `http://127.0.0.1:8080` |
|
|
30
|
+
| `no_color` | Disable colored terminal output | `false` |
|
|
31
|
+
|
|
32
|
+
An unknown key or a malformed value fails the run with an error naming the
|
|
33
|
+
file, so a typo never silently leaves the default in place.
|
|
34
|
+
|
|
35
|
+
Run `ai_git config` to see exactly which model, URL and endpoint are resolved,
|
|
36
|
+
and which file they came from.
|
|
37
|
+
|
|
38
|
+
ai_git talks to a local [llama.cpp](https://github.com/ggml-org/llama.cpp) server over its OpenAI-compatible
|
|
39
|
+
`/v1/chat/completions` endpoint. No API key is needed.
|
|
40
|
+
|
|
41
|
+
### Example
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
# Start llama.cpp's server, then run ai_git with defaults
|
|
45
|
+
./llama-server --port 8080
|
|
46
|
+
ai_git
|
|
47
|
+
|
|
48
|
+
# Or point at a custom model/port
|
|
49
|
+
mkdir -p ~/.ai_git
|
|
50
|
+
cat > ~/.ai_git/config.yml <<'YAML'
|
|
51
|
+
model_name: my-model
|
|
52
|
+
base_url: http://127.0.0.1:8081
|
|
53
|
+
YAML
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Run
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
git add <files>
|
|
60
|
+
ai_git
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`ai_git` generates a commit message from your staged changes, then asks what to
|
|
64
|
+
do with it:
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
Commit this message? [A]ccept / [e]dit / [r]egenerate / [q]uit:
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Accepting commits and pushes to `origin`. The prompt only appears on a
|
|
71
|
+
terminal — piped or scripted runs stay unattended, as does `--yes`.
|
|
72
|
+
|
|
73
|
+
## Flags
|
|
74
|
+
|
|
75
|
+
| Flag | Description |
|
|
76
|
+
|------|-------------|
|
|
77
|
+
| `-n`, `--dry-run` | Print the generated message and change nothing |
|
|
78
|
+
| `--no-push` | Commit locally without pushing |
|
|
79
|
+
| `-y`, `--yes` | Skip the confirmation prompt (unattended) |
|
|
80
|
+
| `-f`, `--force` | Proceed despite secret or remote-server warnings |
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
ai_git --dry-run # see what it would write, commit nothing
|
|
84
|
+
ai_git --no-push # commit locally, publish later yourself
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Privacy
|
|
88
|
+
|
|
89
|
+
The **full staged diff is sent to the configured `base_url`** as part of the
|
|
90
|
+
prompt. The default is your own machine (`http://127.0.0.1:8080`), and nothing
|
|
91
|
+
leaves it. Point `base_url` at another host and ai_git warns before every run;
|
|
92
|
+
plain `http://` to a non-loopback host is refused outright unless you pass
|
|
93
|
+
`--force`.
|
|
94
|
+
|
|
95
|
+
Before generating, ai_git also checks the staged change for credentials —
|
|
96
|
+
`.env` files, private keys, AWS/GitHub/Slack-shaped tokens — and refuses to
|
|
97
|
+
send them without `--force`. It is a guard, not a guarantee: review what you
|
|
98
|
+
stage.
|
|
99
|
+
|
|
100
|
+
## Subcommands
|
|
101
|
+
|
|
102
|
+
| Subcommand | Description |
|
|
103
|
+
|------------|-------------|
|
|
104
|
+
| `ai_git` | Generate a commit message, commit, and push staged files |
|
|
105
|
+
| `ai_git config` | Show the resolved provider configuration |
|
|
106
|
+
| `ai_git --help` | Show usage |
|
|
107
|
+
| `ai_git --version` | Print version |
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# lib/ai_git/ai_client.rb
|
|
3
|
+
#
|
|
4
|
+
# @purpose Talk to the OpenAI-compatible chat endpoint: post the prompt,
|
|
5
|
+
# retry transient failures, and strip the model's wrapping from
|
|
6
|
+
# the reply.
|
|
7
|
+
# @exports AIGit::AIClient: READ_TIMEOUT_SECONDS, OPEN_TIMEOUT_SECONDS,
|
|
8
|
+
# MAX_ATTEMPTS, RETRY_BASE_DELAY, TRANSIENT_STATUSES,
|
|
9
|
+
# RETRYABLE_ERRORS, .complete, .sanitize.
|
|
10
|
+
# @dependencies ai_git/config: supplies the base URL, endpoint, and provider
|
|
11
|
+
# name used in requests and error messages;
|
|
12
|
+
# json: encodes the request body and parses the response;
|
|
13
|
+
# net/http, uri: perform the HTTP POST.
|
|
14
|
+
# @sideEffects Makes network requests to the configured base URL; sleeps
|
|
15
|
+
# between retries; raises a string message on failure.
|
|
16
|
+
# @notes Retries with exponential backoff on the listed connection
|
|
17
|
+
# errors and status codes only; any other status raises at once.
|
|
18
|
+
# Sanitizing also unescapes a reply whose only newlines are
|
|
19
|
+
# literal backslash-n, which some models emit.
|
|
20
|
+
|
|
21
|
+
require "json"
|
|
22
|
+
require "net/http"
|
|
23
|
+
require "uri"
|
|
24
|
+
|
|
25
|
+
require_relative "config"
|
|
26
|
+
|
|
27
|
+
module AIGit
|
|
28
|
+
module AIClient
|
|
29
|
+
module_function
|
|
30
|
+
|
|
31
|
+
READ_TIMEOUT_SECONDS = 120
|
|
32
|
+
OPEN_TIMEOUT_SECONDS = 10
|
|
33
|
+
|
|
34
|
+
MAX_ATTEMPTS = 3
|
|
35
|
+
RETRY_BASE_DELAY = 0.5
|
|
36
|
+
TRANSIENT_STATUSES = [408, 425, 429, 500, 502, 503, 504].freeze
|
|
37
|
+
RETRYABLE_ERRORS = [
|
|
38
|
+
Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EPIPE,
|
|
39
|
+
Net::OpenTimeout, Net::ReadTimeout, SocketError, EOFError
|
|
40
|
+
].freeze
|
|
41
|
+
|
|
42
|
+
PREAMBLE_PREFIXES = /\A(here|output|generated|based\son|the\schanges|
|
|
43
|
+
the\s(commit\smessage|review)\sis|json|markdown)\b/ix.freeze
|
|
44
|
+
CODE_FENCE = /\A`{3,}/.freeze
|
|
45
|
+
ESCAPED_MESSAGE = /\A[^\n]*\\n\\n[^\n]*\z/.freeze
|
|
46
|
+
|
|
47
|
+
def complete(prompt:, model_name:, temperature:)
|
|
48
|
+
sanitize(openai_complete(prompt, model_name, temperature))
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def openai_complete(prompt, model_name, temperature)
|
|
52
|
+
body = {
|
|
53
|
+
model: model_name,
|
|
54
|
+
messages: [{ role: "user", content: prompt }],
|
|
55
|
+
stream: false,
|
|
56
|
+
temperature: temperature
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
data = post_json(body)
|
|
60
|
+
data.dig("choices", 0, "message", "content").to_s
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def transient_status?(code)
|
|
64
|
+
TRANSIENT_STATUSES.include?(code.to_i)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def retry_delay(attempt)
|
|
68
|
+
RETRY_BASE_DELAY * (2**(attempt - 1))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def post_json(body)
|
|
72
|
+
uri = URI("#{AIGit::Config.base_url}#{AIGit::Config.endpoint}")
|
|
73
|
+
attempt = 0
|
|
74
|
+
|
|
75
|
+
loop do
|
|
76
|
+
attempt += 1
|
|
77
|
+
|
|
78
|
+
begin
|
|
79
|
+
response = perform_request(uri, body)
|
|
80
|
+
rescue *RETRYABLE_ERRORS => e
|
|
81
|
+
raise connection_error_message(e) if attempt >= MAX_ATTEMPTS
|
|
82
|
+
|
|
83
|
+
sleep retry_delay(attempt)
|
|
84
|
+
next
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
return JSON.parse(response.body) if response.is_a?(Net::HTTPSuccess)
|
|
88
|
+
|
|
89
|
+
if transient_status?(response.code) && attempt < MAX_ATTEMPTS
|
|
90
|
+
sleep retry_delay(attempt)
|
|
91
|
+
next
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
raise http_error_message(uri, response)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def perform_request(uri, body)
|
|
99
|
+
request = Net::HTTP::Post.new(uri)
|
|
100
|
+
request["Content-Type"] = "application/json"
|
|
101
|
+
request.body = body.to_json
|
|
102
|
+
|
|
103
|
+
Net::HTTP.start(
|
|
104
|
+
uri.host,
|
|
105
|
+
uri.port,
|
|
106
|
+
use_ssl: uri.scheme == "https",
|
|
107
|
+
open_timeout: OPEN_TIMEOUT_SECONDS,
|
|
108
|
+
read_timeout: READ_TIMEOUT_SECONDS
|
|
109
|
+
) { |http| http.request(request) }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def http_error_message(uri, response)
|
|
113
|
+
provider = AIGit::Config.provider
|
|
114
|
+
body = response.body.to_s.strip
|
|
115
|
+
body = "#{body[0, 500]}…" if body.length > 500
|
|
116
|
+
|
|
117
|
+
hint = response.code.to_i == 404 ? " Check the model name and base URL (see `ai_git config`)." : ""
|
|
118
|
+
|
|
119
|
+
"#{provider} returned HTTP #{response.code} at #{uri}.#{hint}" \
|
|
120
|
+
"#{body.empty? ? '' : "\n#{body}"}"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def connection_error_message(error)
|
|
124
|
+
provider = AIGit::Config.provider
|
|
125
|
+
base_url = AIGit::Config.base_url
|
|
126
|
+
hint = "Is the local server running? See `ai_git config`."
|
|
127
|
+
|
|
128
|
+
"Cannot reach #{provider} at #{base_url} after #{MAX_ATTEMPTS} attempts: #{error.message}. #{hint}"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def sanitize(text)
|
|
132
|
+
lines = unescape_newlines(text.to_s)
|
|
133
|
+
.lines
|
|
134
|
+
.map { |line| line.rstrip.sub(/\A>\s*/, "") }
|
|
135
|
+
|
|
136
|
+
strip_preamble(strip_code_fences(lines)).join("\n").strip
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def unescape_newlines(text)
|
|
140
|
+
return text unless text.match?(ESCAPED_MESSAGE)
|
|
141
|
+
|
|
142
|
+
text.gsub(/\\n/, "\n")
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def strip_code_fences(lines)
|
|
146
|
+
lines = lines.drop_while { |line| line.strip.empty? }
|
|
147
|
+
|
|
148
|
+
if lines.first&.match?(CODE_FENCE)
|
|
149
|
+
unfenced = lines.first.sub(CODE_FENCE, "").strip
|
|
150
|
+
unfenced.empty? ? lines.shift : lines[0] = unfenced
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
lines.pop while lines.last && (lines.last.strip.empty? || lines.last.strip.match?(CODE_FENCE))
|
|
154
|
+
lines
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def strip_preamble(lines)
|
|
158
|
+
lines.drop_while { |line| line.strip.empty? || preamble?(line) }
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def preamble?(line)
|
|
162
|
+
line.strip.match?(PREAMBLE_PREFIXES)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# lib/ai_git/commands/config.rb
|
|
3
|
+
#
|
|
4
|
+
# @purpose Implement the `config` subcommand: print the resolved provider
|
|
5
|
+
# settings and the file they come from, so the user can see what
|
|
6
|
+
# the tool will talk to.
|
|
7
|
+
# @exports AIGit::Commands::Config: .call, .resolved_rows, .config_file.
|
|
8
|
+
# @dependencies ai_git/config: supplies every value printed and the path of
|
|
9
|
+
# the config file;
|
|
10
|
+
# ai_git/ai_client: supplies the read timeout shown;
|
|
11
|
+
# ai_git/ui: formats the heading and the key/value lines.
|
|
12
|
+
# @sideEffects Writes the resolved configuration to stdout; raises a string
|
|
13
|
+
# when the config file cannot be resolved.
|
|
14
|
+
# @notes Every value is resolved before the first line is printed, so a
|
|
15
|
+
# broken config file reports its error instead of a half-printed
|
|
16
|
+
# listing.
|
|
17
|
+
|
|
18
|
+
require_relative "../ai_client"
|
|
19
|
+
require_relative "../config"
|
|
20
|
+
require_relative "../ui"
|
|
21
|
+
|
|
22
|
+
module AIGit
|
|
23
|
+
module Commands
|
|
24
|
+
module Config
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
def call(_argv = [])
|
|
28
|
+
rows = resolved_rows(AIGit::Config)
|
|
29
|
+
|
|
30
|
+
AIGit::UI.heading("ai_git configuration")
|
|
31
|
+
rows.each { |key, value| AIGit::UI.kv(key, value) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def resolved_rows(cfg)
|
|
35
|
+
[
|
|
36
|
+
["Provider", cfg.provider],
|
|
37
|
+
["Model", cfg.model_name],
|
|
38
|
+
["Base URL", cfg.base_url],
|
|
39
|
+
["Endpoint", cfg.endpoint],
|
|
40
|
+
["Read timeout", "#{AIGit::AIClient::READ_TIMEOUT_SECONDS}s"],
|
|
41
|
+
["Config file", config_file(cfg)]
|
|
42
|
+
]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def config_file(cfg)
|
|
46
|
+
return cfg.config_path if cfg.config_path
|
|
47
|
+
|
|
48
|
+
dir = cfg.config_dir
|
|
49
|
+
return "(no home directory)" if dir.nil?
|
|
50
|
+
|
|
51
|
+
"#{File.join(dir, AIGit::Config::CONFIG_FILENAMES.first)} (not found)"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|