Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ Or if you already have `curl` you can run the following script to detect OS and
$ curl -fsSL https://commit.jaw.dev/install.sh | bash
```

On the first run, Commit asks only for your OpenRouter API key. It then
On the first run, Commit asks for your OpenRouter API key and preferred model. It
uses `openrouter/free` when you press Enter at the model prompt, then
creates `~/.config/commit/config.json` with private permissions automatically:

```bash
Expand All @@ -46,7 +47,8 @@ The generated configuration looks like this:

```json
{
"api_key": "YOUR_OPENROUTER_API_KEY"
"api_key": "YOUR_OPENROUTER_API_KEY",
"model": "openrouter/free"
}
```

Expand All @@ -57,8 +59,9 @@ again at any time:
$ curl -fsSL https://commit.jaw.dev/ | bash -s -- --setup
```

Set `OPENROUTER_API_KEY` to avoid saving a key locally. Advanced users can set
`COMMIT_MODEL` to override the default model. Browse valid model IDs at
Set `OPENROUTER_API_KEY` to avoid saving a key locally. `--model` overrides
`COMMIT_MODEL`, which overrides the model saved in the configuration. Browse
valid model IDs at
[openrouter.ai/models](https://openrouter.ai/models), or list them from the API:

```bash
Expand All @@ -77,7 +80,7 @@ $ curl -fsSL https://commit.jaw.dev/ | bash
- `--dry-run` Run the script without making any changes
- `-y`, `--yes` Accept the generated message without confirmation
- `-v`, `--verbose` Enable verbose logging
- `--setup` Create or update the saved configuration
- `--setup` Configure the saved API key and model
- `-h`, `--help` Display this help message

### Example Commands
Expand All @@ -96,8 +99,8 @@ The configuration path follows `$XDG_CONFIG_HOME` when set and defaults to
default model is `openrouter/free`, which randomly selects an available free
model. Free models have lower rate limits and may be less consistent. Pass a
model ID exactly as OpenRouter displays it, for example `openrouter/auto`, to
override the default. Diffs larger than 1 MiB are rejected before an API request
is made.
override the default. Model IDs must not contain whitespace. Diffs larger than
1 MiB are rejected before an API request is made.

# Docs

Expand Down
44 changes: 40 additions & 4 deletions assets/sh/commit.sh
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ API_KEY=""
API_URL="https://openrouter.ai/api/v1/chat/completions"
AI_MODEL=""
CONFIG_API_KEY=""
CONFIG_MODEL=""
AUTH_HEADER_FILE=""
MAX_DIFF_BYTES=1048576
CONFIG_DIR_MANAGED=true
Expand Down Expand Up @@ -112,6 +113,11 @@ format_changed_files() {
}'
}

is_valid_model() {
local candidate="$1"
[ -n "$candidate" ] && [[ "$candidate" != *[[:space:]]* ]]
}

show_help() {
local status="${1:-0}"
log_verbose "Displaying help message"
Expand All @@ -122,7 +128,7 @@ show_help() {
printf " ${GREEN}%-22s${NC} %s\n" "-y, --yes" "Accept the generated message without confirmation"
printf " ${GREEN}%-22s${NC} %s\n" "-m, --model" "Override the OpenRouter model"
printf " ${GREEN}%-22s${NC} %s\n" "-v, --verbose" "Enable verbose logging"
printf " ${GREEN}%-22s${NC} %s\n" "--setup" "Create or update the saved configuration"
printf " ${GREEN}%-22s${NC} %s\n" "--setup" "Configure the saved API key and model"
printf " ${GREEN}%-22s${NC} %s\n" "-h, --help" "Display this help message"
printf "\n"
printf "${YELLOW}Configuration:${NC}\n"
Expand Down Expand Up @@ -169,12 +175,20 @@ load_config() {
exit 1
fi

if ! jq -e '(.model == null) or ((.model | type) == "string" and (.model | length) > 0 and (.model | test("\\s") | not))' "$CONFIG_FILE" >/dev/null 2>&1; then
printf "${RED}Invalid model in %s. Use a non-empty model ID without whitespace.${NC}\n" "$CONFIG_FILE"
exit 1
fi

CONFIG_API_KEY=$(jq -r '.api_key // empty' "$CONFIG_FILE")
CONFIG_MODEL=$(jq -r '.model // empty' "$CONFIG_FILE")
}

setup_config() {
local api_key
local existing_api_key="$CONFIG_API_KEY"
local existing_model="${CONFIG_MODEL:-openrouter/free}"
local model
local config_dir
local config_dir_existed=false
local temp_file
Expand Down Expand Up @@ -204,6 +218,22 @@ setup_config() {

CONFIG_API_KEY="$api_key"
API_KEY="$api_key"

while true; do
printf "Model [%s]: " "$existing_model" >> "$TTY_OUTPUT"
if ! read -r model <&3; then
exec 3<&-
return 1
fi
if [ -z "$model" ]; then
model="$existing_model"
fi
if is_valid_model "$model"; then
break
fi
printf "${RED}Enter a non-empty model ID without whitespace.${NC}\n" >> "$TTY_OUTPUT"
done
CONFIG_MODEL="$model"
exec 3<&-

config_dir=$(dirname "$CONFIG_FILE")
Expand All @@ -218,9 +248,11 @@ setup_config() {
temp_file=$(mktemp "$CONFIG_FILE.tmp.XXXXXX") || return 1

if ! jq -n \
--arg api_key "$CONFIG_API_KEY" '
--arg api_key "$CONFIG_API_KEY" \
--arg model "$CONFIG_MODEL" '
{
api_key: $api_key
api_key: $api_key,
model: $model
} | with_entries(select(.value != ""))' > "$temp_file"; then
rm -f "$temp_file"
return 1
Expand All @@ -238,7 +270,11 @@ setup_config() {
}

configure_openrouter() {
AI_MODEL="${AI_MODEL:-${COMMIT_MODEL:-openrouter/free}}"
AI_MODEL="${AI_MODEL:-${COMMIT_MODEL:-${CONFIG_MODEL:-openrouter/free}}}"
if ! is_valid_model "$AI_MODEL"; then
printf "${RED}Invalid OpenRouter model. Use a non-empty model ID without whitespace.${NC}\n"
exit 1
fi
if [ -z "$API_KEY" ]; then
API_KEY="${OPENROUTER_API_KEY:-$CONFIG_API_KEY}"
fi
Expand Down
7 changes: 5 additions & 2 deletions assets/templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ <h1>🤖 Commit</h1>
<article>
<section>
<h2>Configure</h2>
<p>The first run asks only for your OpenRouter API key, then securely saves it.</p>
<p>
The first run asks for your OpenRouter API key and preferred model,
then securely saves both. Press Enter to use <code>openrouter/free</code>.
</p>
<pre><code>$ curl -fsSL {{.Domain}} | bash
$ curl -fsSL {{.Domain}} | bash -s -- --setup</code></pre>
</section>
Expand Down Expand Up @@ -55,7 +58,7 @@ <h2>Options</h2>
<dd>Show command help.</dd>

<dt><code>--setup</code></dt>
<dd>Create or update the saved configuration.</dd>
<dd>Configure the saved OpenRouter API key and model.</dd>
</dl>
</section>

Expand Down
144 changes: 136 additions & 8 deletions cmd/script_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ func TestCommitScriptRunsFirstSetupAndCallsOpenRouter(t *testing.T) {
configPath := filepath.Join(configDir, "config.json")
setupInputPath := filepath.Join(root, "setup-input")
setupOutputPath := filepath.Join(root, "setup-output")
if err := os.WriteFile(setupInputPath, []byte("openrouter-secret\n"), 0o600); err != nil {
if err := os.WriteFile(setupInputPath, []byte("openrouter-secret\n\n"), 0o600); err != nil {
t.Fatal(err)
}

Expand Down Expand Up @@ -260,7 +260,7 @@ printf '{"choices":[{"message":{"content":"feat: test openrouter"}}]}\n200'
if err := json.Unmarshal(configData, &config); err != nil {
t.Fatal(err)
}
if config["api_key"] != "openrouter-secret" || len(config) != 1 {
if config["api_key"] != "openrouter-secret" || config["model"] != "openrouter/free" || len(config) != 2 {
t.Errorf("unexpected generated config: %#v", config)
}
configInfo, err := os.Stat(configPath)
Expand Down Expand Up @@ -338,6 +338,86 @@ printf '{"choices":[{"message":{"content":"feat: test openrouter"}}]}\n200'
}
}

func TestCommitScriptModelPrecedence(t *testing.T) {
script, err := assets.Embeddedfiles.ReadFile("sh/commit.sh")
if err != nil {
t.Fatal(err)
}

root := t.TempDir()
repo := filepath.Join(root, "repo")
configDir := filepath.Join(root, "config", "commit")
binDir := filepath.Join(root, "bin")
for _, dir := range []string{repo, configDir, binDir} {
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
}
}
config := `{"api_key":"test-key","model":"saved/model"}`
if err := os.WriteFile(filepath.Join(configDir, "config.json"), []byte(config), 0o600); err != nil {
t.Fatal(err)
}
requestPath := filepath.Join(root, "request.json")
fakeCurl := `#!/bin/bash
cat > "$CAPTURE_REQUEST"
printf '{"choices":[{"message":{"content":"test: verify model precedence"}}]}\n200'
`
if err := os.WriteFile(filepath.Join(binDir, "curl"), []byte(fakeCurl), 0o755); err != nil {
t.Fatal(err)
}
runGit(t, repo, "init", "-q")
if err := os.WriteFile(filepath.Join(repo, "feature.txt"), []byte("model test\n"), 0o600); err != nil {
t.Fatal(err)
}
runGit(t, repo, "add", "feature.txt")

tests := []struct {
name string
envModel string
args []string
want string
}{
{name: "saved config", want: "saved/model"},
{name: "environment", envModel: "environment/model", want: "environment/model"},
{name: "flag", envModel: "environment/model", args: []string{"--model", "flag/model"}, want: "flag/model"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
args := []string{"-s", "--", "--dry-run"}
args = append(args, tt.args...)
cmd := exec.Command("bash", args...)
cmd.Dir = repo
cmd.Stdin = bytes.NewReader(script)
cmd.Env = append(os.Environ(),
"PATH="+binDir+":"+os.Getenv("PATH"),
"XDG_CONFIG_HOME="+filepath.Join(root, "config"),
"OPENROUTER_API_KEY=",
"COMMIT_MODEL="+tt.envModel,
"TMPDIR="+root,
"CAPTURE_REQUEST="+requestPath,
)
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("commit script failed: %v\n%s", err, output)
}

requestData, err := os.ReadFile(requestPath)
if err != nil {
t.Fatal(err)
}
var request struct {
Model string `json:"model"`
}
if err := json.Unmarshal(requestData, &request); err != nil {
t.Fatal(err)
}
if request.Model != tt.want {
t.Errorf("model = %q, want %q", request.Model, tt.want)
}
})
}
}

func TestCommitScriptRejectsLooseConfigPermissions(t *testing.T) {
script, err := assets.Embeddedfiles.ReadFile("sh/commit.sh")
if err != nil {
Expand Down Expand Up @@ -369,7 +449,48 @@ func TestCommitScriptRejectsLooseConfigPermissions(t *testing.T) {
}
}

func TestCommitScriptSetupKeepsExistingKey(t *testing.T) {
func TestCommitScriptRejectsInvalidSavedModels(t *testing.T) {
script, err := assets.Embeddedfiles.ReadFile("sh/commit.sh")
if err != nil {
t.Fatal(err)
}

tests := []struct {
name string
model string
}{
{name: "empty", model: `""`},
{name: "whitespace", model: `"bad model"`},
{name: "non-string", model: `{}`},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
configRoot := t.TempDir()
configDir := filepath.Join(configRoot, "commit")
if err := os.MkdirAll(configDir, 0o700); err != nil {
t.Fatal(err)
}
config := `{"api_key":"test-key","model":` + tt.model + `}`
if err := os.WriteFile(filepath.Join(configDir, "config.json"), []byte(config), 0o600); err != nil {
t.Fatal(err)
}

cmd := exec.Command("bash", "-s", "--", "--dry-run")
cmd.Stdin = bytes.NewReader(script)
cmd.Env = append(os.Environ(), "XDG_CONFIG_HOME="+configRoot)
output, err := cmd.CombinedOutput()
if err == nil {
t.Fatal("script accepted an invalid saved model")
}
if !strings.Contains(string(output), "Invalid model") {
t.Fatalf("unexpected output:\n%s", output)
}
})
}
}

func TestCommitScriptSetupKeepsExistingKeyAndChangesModel(t *testing.T) {
script, err := assets.Embeddedfiles.ReadFile("sh/commit.sh")
if err != nil {
t.Fatal(err)
Expand All @@ -381,13 +502,13 @@ func TestCommitScriptSetupKeepsExistingKey(t *testing.T) {
t.Fatal(err)
}
configPath := filepath.Join(configDir, "config.json")
initialConfig := `{"api_key":"saved-key"}`
initialConfig := `{"api_key":"saved-key","model":"openrouter/auto"}`
if err := os.WriteFile(configPath, []byte(initialConfig), 0o600); err != nil {
t.Fatal(err)
}
inputPath := filepath.Join(root, "setup-input")
outputPath := filepath.Join(root, "setup-output")
if err := os.WriteFile(inputPath, []byte("\n"), 0o600); err != nil {
if err := os.WriteFile(inputPath, []byte("\nbad model\ncustom/model\n"), 0o600); err != nil {
t.Fatal(err)
}

Expand All @@ -402,6 +523,13 @@ func TestCommitScriptSetupKeepsExistingKey(t *testing.T) {
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("setup failed: %v\n%s", err, output)
}
setupOutput, err := os.ReadFile(outputPath)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(setupOutput), "without whitespace") {
t.Fatalf("setup did not reject the invalid model:\n%s", setupOutput)
}

configData, err := os.ReadFile(configPath)
if err != nil {
Expand All @@ -411,7 +539,7 @@ func TestCommitScriptSetupKeepsExistingKey(t *testing.T) {
if err := json.Unmarshal(configData, &config); err != nil {
t.Fatal(err)
}
if config["api_key"] != "saved-key" || len(config) != 1 {
if config["api_key"] != "saved-key" || config["model"] != "custom/model" || len(config) != 2 {
t.Errorf("unexpected updated config: %#v", config)
}
}
Expand Down Expand Up @@ -472,7 +600,7 @@ func TestCommitScriptDoesNotChangeExistingConfigDirectoryMode(t *testing.T) {
}
inputPath := filepath.Join(root, "setup-input")
outputPath := filepath.Join(root, "setup-output")
if err := os.WriteFile(inputPath, []byte("test-key\n"), 0o600); err != nil {
if err := os.WriteFile(inputPath, []byte("test-key\n\n"), 0o600); err != nil {
t.Fatal(err)
}

Expand Down Expand Up @@ -513,7 +641,7 @@ func TestCommitScriptRemovesConfigTempFileWhenMoveFails(t *testing.T) {
}
inputPath := filepath.Join(root, "setup-input")
outputPath := filepath.Join(root, "setup-output")
if err := os.WriteFile(inputPath, []byte("test-key\n"), 0o600); err != nil {
if err := os.WriteFile(inputPath, []byte("test-key\n\n"), 0o600); err != nil {
t.Fatal(err)
}

Expand Down
Loading