Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1936f055e2 | |||
| ec32b75267 |
20
INSTALL.md
20
INSTALL.md
@@ -38,7 +38,7 @@ sudo mv aicli-linux-amd64 /usr/local/bin/aicli
|
||||
|
||||
# Verify installation
|
||||
aicli --version
|
||||
```
|
||||
````
|
||||
|
||||
### Windows Installation
|
||||
|
||||
@@ -46,6 +46,7 @@ aicli --version
|
||||
2. Rename the executable to `aicli.exe` if desired
|
||||
3. Add the directory to your PATH or move the executable to a directory in your PATH
|
||||
4. Open Command Prompt or PowerShell and verify the installation:
|
||||
|
||||
```
|
||||
aicli --version
|
||||
```
|
||||
@@ -69,14 +70,17 @@ export AICLI_API_KEY_FILE=~/.aicli_key
|
||||
|
||||
### Configuration File
|
||||
|
||||
Create a configuration file at `~/.aicli.yaml` or use the sample config provided in the release:
|
||||
Create a configuration file at `~/.aicli.yaml` or use the sample config:
|
||||
|
||||
```bash
|
||||
# Download the sample config
|
||||
curl -LO https://git.wisehodl.dev/jay/aicli/raw/branch/main/sample-config.yml
|
||||
|
||||
# Copy to your home directory
|
||||
cp sample-config.yml ~/.aicli.yaml
|
||||
# Create config file
|
||||
cat > ~/.aicli.yaml << 'EOF'
|
||||
protocol: openai
|
||||
url: https://api.ppq.ai/chat/completions
|
||||
key_file: ~/.aicli_key
|
||||
model: gpt-4o-mini
|
||||
fallback: gpt-4.1-mini,o3
|
||||
EOF
|
||||
|
||||
# Edit with your preferred editor
|
||||
nano ~/.aicli.yaml
|
||||
@@ -114,4 +118,4 @@ sudo mv aicli /usr/local/bin/
|
||||
|
||||
## Next Steps
|
||||
|
||||
See the [README.md](README.md) for usage instructions and examples.
|
||||
See the [README.md](https://claude.ai/chat/README.md) for usage instructions and examples.
|
||||
|
||||
44
README.md
44
README.md
@@ -1,5 +1,9 @@
|
||||
# AICLI
|
||||
|
||||
Source: https://git.wisehodl.dev/jay/aicli
|
||||
|
||||
Mirror: https://github.com/wisehodl/aicli
|
||||
|
||||
A flexible command-line interface for interacting with LLM chat APIs.
|
||||
|
||||
AICLI provides a streamlined way to interact with language models from your terminal. Send prompts and files to chat models like OpenAI's GPT or local Ollama models, customize system prompts, and receive responses right in your terminal or save them to files.
|
||||
@@ -18,18 +22,20 @@ AICLI provides a streamlined way to interact with language models from your term
|
||||
|
||||
### Pre-built Binaries
|
||||
|
||||
Download the latest binary for your platform from the [Releases](https://git.wisehodl.dev/jay/aicli/releases) page:
|
||||
Download the latest binary for your platform from the [Releases](https://git.wisehodl.dev/jay/aicli/releases) page.
|
||||
|
||||
Make the file executable (Linux/macOS):
|
||||
|
||||
```bash
|
||||
chmod +x aicli-linux-amd64
|
||||
mv aicli-linux-amd64 /usr/local/bin/aicli # or any directory in your PATH
|
||||
```
|
||||
````
|
||||
|
||||
See [INSTALL.md](https://claude.ai/chat/INSTALL.md) for detailed installation instructions.
|
||||
|
||||
### Building from Source
|
||||
|
||||
Requires Go 1.16+:
|
||||
Requires Go 1.23+:
|
||||
|
||||
```bash
|
||||
git clone https://git.wisehodl.dev/jay/aicli.git
|
||||
@@ -69,7 +75,7 @@ export AICLI_API_KEY_FILE=~/.aicli_key
|
||||
export AICLI_API_KEY="your-api-key"
|
||||
export AICLI_API_KEY_FILE="~/.aicli_key"
|
||||
export AICLI_PROTOCOL="openai" # or "ollama"
|
||||
export AICLI_URL="https://api.ppq.ai/chat/completions" # custom endpoint
|
||||
export AICLI_URL="https://api.ppq.ai/chat/completions"
|
||||
|
||||
# Model Selection
|
||||
export AICLI_MODEL="gpt-4o-mini"
|
||||
@@ -238,38 +244,36 @@ grep ERROR /var/log/app.log | aicli -p "Identify patterns in these error logs"
|
||||
## Full Command Reference
|
||||
|
||||
```
|
||||
Usage: aicli [OPTION]... [FILE]...
|
||||
Send files and prompts to LLM chat endpoints.
|
||||
|
||||
With no FILE, or when FILE is -, read standard input.
|
||||
Usage: aicli [OPTION]...
|
||||
Send prompts and files to LLM chat endpoints.
|
||||
|
||||
Global:
|
||||
--version display version information and exit
|
||||
--version display version and exit
|
||||
|
||||
Input:
|
||||
-f, --file PATH input file (repeatable)
|
||||
-F, --stdin-file treat stdin as file contents
|
||||
-p, --prompt TEXT prompt text (repeatable, can be combined with --prompt-file)
|
||||
-pf, --prompt-file PATH prompt from file (combined with any --prompt flags)
|
||||
-F, --stdin-file treat stdin as file content
|
||||
-p, --prompt TEXT prompt text (repeatable)
|
||||
-pf, --prompt-file PATH read prompt from file
|
||||
|
||||
System:
|
||||
-s, --system TEXT system prompt text
|
||||
-sf, --system-file PATH system prompt from file
|
||||
-sf, --system-file PATH read system prompt from file
|
||||
|
||||
API:
|
||||
-l, --protocol PROTO API protocol: openai, ollama (default: openai)
|
||||
-u, --url URL API endpoint (default: https://api.ppq.ai/chat/completions)
|
||||
-k, --key KEY API key (if present, --key-file is ignored)
|
||||
-kf, --key-file PATH API key from file (used only if --key is not provided)
|
||||
-l, --protocol PROTO openai or ollama (default: openai)
|
||||
-u, --url URL endpoint (default: https://api.ppq.ai/chat/completions)
|
||||
-k, --key KEY API key
|
||||
-kf, --key-file PATH read API key from file
|
||||
|
||||
Models:
|
||||
-m, --model NAME primary model (default: gpt-4o-mini)
|
||||
-b, --fallback NAMES comma-separated fallback models (default: gpt-4.1-mini)
|
||||
-b, --fallback NAMES comma-separated fallback list (default: gpt-4.1-mini)
|
||||
|
||||
Output:
|
||||
-o, --output PATH write to file instead of stdout
|
||||
-q, --quiet suppress progress output
|
||||
-v, --verbose enable debug logging
|
||||
-q, --quiet suppress progress messages
|
||||
-v, --verbose log debug information to stderr
|
||||
|
||||
Config:
|
||||
-c, --config PATH YAML config file
|
||||
|
||||
62
api/api.go
Normal file
62
api/api.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"git.wisehodl.dev/jay/aicli/config"
|
||||
)
|
||||
|
||||
// tryModel attempts a single model request through the complete pipeline:
|
||||
// payload construction, HTTP execution, and response parsing.
|
||||
func tryModel(cfg config.ConfigData, model string, query string) (string, error) {
|
||||
payload := buildPayload(cfg, model, query)
|
||||
|
||||
if cfg.Verbose {
|
||||
payloadJSON, _ := json.Marshal(payload)
|
||||
fmt.Fprintf(os.Stderr, "[verbose] Request payload: %s\n", string(payloadJSON))
|
||||
}
|
||||
|
||||
body, err := executeHTTP(cfg, payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if cfg.Verbose {
|
||||
fmt.Fprintf(os.Stderr, "[verbose] Response: %s\n", string(body))
|
||||
}
|
||||
|
||||
response, err := parseResponse(body, cfg.Protocol)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// SendChatRequest sends a query to the configured model with automatic fallback.
|
||||
// Returns the response content, the model name that succeeded, total duration, and any error.
|
||||
// On failure, attempts each fallback model in sequence until one succeeds or all fail.
|
||||
func SendChatRequest(cfg config.ConfigData, query string) (string, string, time.Duration, error) {
|
||||
models := append([]string{cfg.Model}, cfg.FallbackModels...)
|
||||
start := time.Now()
|
||||
|
||||
for i, model := range models {
|
||||
if !cfg.Quiet && i > 0 {
|
||||
fmt.Fprintf(os.Stderr, "Model %s failed, trying %s...\n", models[i-1], model)
|
||||
}
|
||||
|
||||
response, err := tryModel(cfg, model, query)
|
||||
if err == nil {
|
||||
return response, model, time.Since(start), nil
|
||||
}
|
||||
|
||||
if !cfg.Quiet {
|
||||
fmt.Fprintf(os.Stderr, "Model %s failed: %v\n", model, err)
|
||||
}
|
||||
}
|
||||
|
||||
return "", "", time.Since(start), fmt.Errorf("all models failed")
|
||||
}
|
||||
263
api/api_test.go
Normal file
263
api/api_test.go
Normal file
@@ -0,0 +1,263 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.wisehodl.dev/jay/aicli/config"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
type sequenceTransport struct {
|
||||
responses []*http.Response
|
||||
index int
|
||||
}
|
||||
|
||||
func (t *sequenceTransport) RoundTrip(*http.Request) (*http.Response, error) {
|
||||
if t.index >= len(t.responses) {
|
||||
return nil, fmt.Errorf("no more responses in sequence")
|
||||
}
|
||||
resp := t.responses[t.index]
|
||||
t.index++
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func TestTryModel(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg config.ConfigData
|
||||
model string
|
||||
query string
|
||||
mockResp *http.Response
|
||||
mockErr error
|
||||
want string
|
||||
wantErr bool
|
||||
errContains string
|
||||
}{
|
||||
{
|
||||
name: "successful request",
|
||||
cfg: config.ConfigData{
|
||||
Protocol: config.ProtocolOpenAI,
|
||||
URL: "https://api.example.com",
|
||||
APIKey: "sk-test",
|
||||
},
|
||||
model: "gpt-4",
|
||||
query: "test query",
|
||||
mockResp: makeResponse(200, `{"choices":[{"message":{"content":"response text"}}]}`),
|
||||
want: "response text",
|
||||
},
|
||||
{
|
||||
name: "http error",
|
||||
cfg: config.ConfigData{
|
||||
Protocol: config.ProtocolOpenAI,
|
||||
URL: "https://api.example.com",
|
||||
APIKey: "sk-test",
|
||||
},
|
||||
model: "gpt-4",
|
||||
query: "test query",
|
||||
mockResp: makeResponse(500, `{"error":"server error"}`),
|
||||
wantErr: true,
|
||||
errContains: "HTTP 500",
|
||||
},
|
||||
{
|
||||
name: "parse error",
|
||||
cfg: config.ConfigData{
|
||||
Protocol: config.ProtocolOpenAI,
|
||||
URL: "https://api.example.com",
|
||||
APIKey: "sk-test",
|
||||
},
|
||||
model: "gpt-4",
|
||||
query: "test query",
|
||||
mockResp: makeResponse(200, `{"choices":[]}`),
|
||||
wantErr: true,
|
||||
errContains: "empty choices array",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
transport := &mockRoundTripper{
|
||||
response: tt.mockResp,
|
||||
err: tt.mockErr,
|
||||
}
|
||||
oldClient := httpClient
|
||||
httpClient = &http.Client{
|
||||
Timeout: 5 * time.Minute,
|
||||
Transport: transport,
|
||||
}
|
||||
defer func() { httpClient = oldClient }()
|
||||
|
||||
got, err := tryModel(tt.cfg, tt.model, tt.query)
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
if tt.errContains != "" {
|
||||
assert.Contains(t, err.Error(), tt.errContains)
|
||||
}
|
||||
return
|
||||
}
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func captureStderr(f func()) string {
|
||||
old := os.Stderr
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stderr = w
|
||||
|
||||
f()
|
||||
|
||||
w.Close()
|
||||
os.Stderr = old
|
||||
|
||||
var buf bytes.Buffer
|
||||
io.Copy(&buf, r)
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func TestSendChatRequest(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg config.ConfigData
|
||||
query string
|
||||
mockResp []*http.Response
|
||||
wantResponse string
|
||||
wantModel string
|
||||
wantErr bool
|
||||
errContains string
|
||||
checkStderr func(*testing.T, string)
|
||||
}{
|
||||
{
|
||||
name: "primary model succeeds",
|
||||
cfg: config.ConfigData{
|
||||
Protocol: config.ProtocolOpenAI,
|
||||
URL: "https://api.example.com",
|
||||
APIKey: "sk-test",
|
||||
Model: "gpt-4",
|
||||
FallbackModels: []string{"gpt-3.5"},
|
||||
},
|
||||
query: "test",
|
||||
mockResp: []*http.Response{
|
||||
makeResponse(200, `{"choices":[{"message":{"content":"primary response"}}]}`),
|
||||
},
|
||||
wantResponse: "primary response",
|
||||
wantModel: "gpt-4",
|
||||
},
|
||||
{
|
||||
name: "primary fails, fallback succeeds",
|
||||
cfg: config.ConfigData{
|
||||
Protocol: config.ProtocolOpenAI,
|
||||
URL: "https://api.example.com",
|
||||
APIKey: "sk-test",
|
||||
Model: "gpt-4",
|
||||
FallbackModels: []string{"gpt-3.5"},
|
||||
},
|
||||
query: "test",
|
||||
mockResp: []*http.Response{
|
||||
makeResponse(500, `{"error":"server error"}`),
|
||||
makeResponse(200, `{"choices":[{"message":{"content":"fallback response"}}]}`),
|
||||
},
|
||||
wantResponse: "fallback response",
|
||||
wantModel: "gpt-3.5",
|
||||
checkStderr: func(t *testing.T, stderr string) {
|
||||
assert.Contains(t, stderr, "Model gpt-4 failed")
|
||||
assert.Contains(t, stderr, "trying gpt-3.5")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "all models fail",
|
||||
cfg: config.ConfigData{
|
||||
Protocol: config.ProtocolOpenAI,
|
||||
URL: "https://api.example.com",
|
||||
APIKey: "sk-test",
|
||||
Model: "gpt-4",
|
||||
FallbackModels: []string{"gpt-3.5"},
|
||||
},
|
||||
query: "test",
|
||||
mockResp: []*http.Response{
|
||||
makeResponse(500, `{"error":"error1"}`),
|
||||
makeResponse(500, `{"error":"error2"}`),
|
||||
},
|
||||
wantErr: true,
|
||||
errContains: "all models failed",
|
||||
checkStderr: func(t *testing.T, stderr string) {
|
||||
assert.Contains(t, stderr, "Model gpt-4 failed")
|
||||
assert.Contains(t, stderr, "Model gpt-3.5 failed")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "quiet mode suppresses progress",
|
||||
cfg: config.ConfigData{
|
||||
Protocol: config.ProtocolOpenAI,
|
||||
URL: "https://api.example.com",
|
||||
APIKey: "sk-test",
|
||||
Model: "gpt-4",
|
||||
FallbackModels: []string{"gpt-3.5"},
|
||||
Quiet: true,
|
||||
},
|
||||
query: "test",
|
||||
mockResp: []*http.Response{
|
||||
makeResponse(500, `{"error":"error1"}`),
|
||||
makeResponse(200, `{"choices":[{"message":{"content":"response"}}]}`),
|
||||
},
|
||||
wantResponse: "response",
|
||||
wantModel: "gpt-3.5",
|
||||
checkStderr: func(t *testing.T, stderr string) {
|
||||
assert.Empty(t, stderr)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
transport := &sequenceTransport{responses: tt.mockResp}
|
||||
|
||||
oldClient := httpClient
|
||||
httpClient = &http.Client{
|
||||
Timeout: 5 * time.Minute,
|
||||
Transport: transport,
|
||||
}
|
||||
defer func() { httpClient = oldClient }()
|
||||
|
||||
var stderr string
|
||||
var response string
|
||||
var model string
|
||||
var duration time.Duration
|
||||
var err error
|
||||
|
||||
if tt.checkStderr != nil {
|
||||
stderr = captureStderr(func() {
|
||||
response, model, duration, err = SendChatRequest(tt.cfg, tt.query)
|
||||
})
|
||||
} else {
|
||||
response, model, duration, err = SendChatRequest(tt.cfg, tt.query)
|
||||
}
|
||||
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
if tt.errContains != "" {
|
||||
assert.Contains(t, err.Error(), tt.errContains)
|
||||
}
|
||||
if tt.checkStderr != nil {
|
||||
tt.checkStderr(t, stderr)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tt.wantResponse, response)
|
||||
assert.Equal(t, tt.wantModel, model)
|
||||
assert.Greater(t, duration, time.Duration(0))
|
||||
|
||||
if tt.checkStderr != nil {
|
||||
tt.checkStderr(t, stderr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
47
api/http.go
Normal file
47
api/http.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.wisehodl.dev/jay/aicli/config"
|
||||
)
|
||||
|
||||
var httpClient = &http.Client{Timeout: 5 * time.Minute}
|
||||
|
||||
// executeHTTP sends the payload to the API endpoint and returns the response body.
|
||||
func executeHTTP(cfg config.ConfigData, payload map[string]interface{}) ([]byte, error) {
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal payload: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", cfg.URL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", cfg.APIKey))
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("execute request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
return respBody, nil
|
||||
}
|
||||
193
api/http_test.go
Normal file
193
api/http_test.go
Normal file
@@ -0,0 +1,193 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.wisehodl.dev/jay/aicli/config"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
type mockRoundTripper struct {
|
||||
response *http.Response
|
||||
err error
|
||||
request *http.Request
|
||||
}
|
||||
|
||||
func (m *mockRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
m.request = req
|
||||
return m.response, m.err
|
||||
}
|
||||
|
||||
func makeResponse(statusCode int, body string) *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: statusCode,
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Header: make(http.Header),
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteHTTP(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg config.ConfigData
|
||||
payload map[string]interface{}
|
||||
mockResp *http.Response
|
||||
mockErr error
|
||||
wantBody string
|
||||
wantErr bool
|
||||
errContains string
|
||||
}{
|
||||
{
|
||||
name: "successful request",
|
||||
cfg: config.ConfigData{
|
||||
URL: "https://api.example.com/chat",
|
||||
APIKey: "sk-test123",
|
||||
},
|
||||
payload: map[string]interface{}{
|
||||
"model": "gpt-4",
|
||||
},
|
||||
mockResp: makeResponse(200, `{"choices":[{"message":{"content":"response"}}]}`),
|
||||
wantBody: `{"choices":[{"message":{"content":"response"}}]}`,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "HTTP 400 error",
|
||||
cfg: config.ConfigData{
|
||||
URL: "https://api.example.com/chat",
|
||||
APIKey: "sk-test123",
|
||||
},
|
||||
payload: map[string]interface{}{"model": "gpt-4"},
|
||||
mockResp: makeResponse(400, `{"error":"bad request"}`),
|
||||
wantErr: true,
|
||||
errContains: "HTTP 400",
|
||||
},
|
||||
{
|
||||
name: "HTTP 401 unauthorized",
|
||||
cfg: config.ConfigData{
|
||||
URL: "https://api.example.com/chat",
|
||||
APIKey: "invalid-key",
|
||||
},
|
||||
payload: map[string]interface{}{"model": "gpt-4"},
|
||||
mockResp: makeResponse(401, `{"error":"unauthorized"}`),
|
||||
wantErr: true,
|
||||
errContains: "HTTP 401",
|
||||
},
|
||||
{
|
||||
name: "HTTP 429 rate limit",
|
||||
cfg: config.ConfigData{
|
||||
URL: "https://api.example.com/chat",
|
||||
APIKey: "sk-test123",
|
||||
},
|
||||
payload: map[string]interface{}{"model": "gpt-4"},
|
||||
mockResp: makeResponse(429, `{"error":"rate limit exceeded"}`),
|
||||
wantErr: true,
|
||||
errContains: "HTTP 429",
|
||||
},
|
||||
{
|
||||
name: "HTTP 500 server error",
|
||||
cfg: config.ConfigData{
|
||||
URL: "https://api.example.com/chat",
|
||||
APIKey: "sk-test123",
|
||||
},
|
||||
payload: map[string]interface{}{"model": "gpt-4"},
|
||||
mockResp: makeResponse(500, `{"error":"internal server error"}`),
|
||||
wantErr: true,
|
||||
errContains: "HTTP 500",
|
||||
},
|
||||
{
|
||||
name: "network error",
|
||||
cfg: config.ConfigData{
|
||||
URL: "https://api.example.com/chat",
|
||||
APIKey: "sk-test123",
|
||||
},
|
||||
payload: map[string]interface{}{"model": "gpt-4"},
|
||||
mockErr: http.ErrHandlerTimeout,
|
||||
wantErr: true,
|
||||
errContains: "execute request",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
transport := &mockRoundTripper{
|
||||
response: tt.mockResp,
|
||||
err: tt.mockErr,
|
||||
}
|
||||
oldClient := httpClient
|
||||
httpClient = &http.Client{
|
||||
Timeout: 5 * time.Minute,
|
||||
Transport: transport,
|
||||
}
|
||||
defer func() { httpClient = oldClient }()
|
||||
|
||||
got, err := executeHTTP(tt.cfg, tt.payload)
|
||||
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
if tt.errContains != "" {
|
||||
assert.Contains(t, err.Error(), tt.errContains)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tt.wantBody, string(got))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteHTTPHeaders(t *testing.T) {
|
||||
cfg := config.ConfigData{
|
||||
URL: "https://api.example.com/chat",
|
||||
APIKey: "sk-test-key",
|
||||
}
|
||||
payload := map[string]interface{}{"model": "gpt-4"}
|
||||
|
||||
transport := &mockRoundTripper{
|
||||
response: makeResponse(200, `{"result":"ok"}`),
|
||||
}
|
||||
oldClient := httpClient
|
||||
httpClient = &http.Client{
|
||||
Timeout: 5 * time.Minute,
|
||||
Transport: transport,
|
||||
}
|
||||
defer func() { httpClient = oldClient }()
|
||||
|
||||
_, err := executeHTTP(cfg, payload)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "application/json", transport.request.Header.Get("Content-Type"))
|
||||
assert.Equal(t, "Bearer sk-test-key", transport.request.Header.Get("Authorization"))
|
||||
}
|
||||
|
||||
func TestExecuteHTTPTimeout(t *testing.T) {
|
||||
cfg := config.ConfigData{
|
||||
URL: "https://api.example.com/chat",
|
||||
APIKey: "sk-test123",
|
||||
}
|
||||
payload := map[string]interface{}{"model": "gpt-4"}
|
||||
|
||||
transport := &mockRoundTripper{
|
||||
response: makeResponse(200, `{"ok":true}`),
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 5 * time.Minute,
|
||||
Transport: transport,
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(payload)
|
||||
req, _ := http.NewRequest("POST", cfg.URL, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+cfg.APIKey)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
}
|
||||
51
api/parse.go
Normal file
51
api/parse.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"git.wisehodl.dev/jay/aicli/config"
|
||||
)
|
||||
|
||||
// parseResponse extracts the response content from the API response body.
|
||||
func parseResponse(body []byte, protocol config.APIProtocol) (string, error) {
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return "", fmt.Errorf("parse response: %w", err)
|
||||
}
|
||||
|
||||
if protocol == config.ProtocolOllama {
|
||||
response, ok := result["response"].(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("no response field in ollama response")
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// OpenAI protocol
|
||||
choices, ok := result["choices"].([]interface{})
|
||||
if !ok {
|
||||
return "", fmt.Errorf("no choices in response")
|
||||
}
|
||||
|
||||
if len(choices) == 0 {
|
||||
return "", fmt.Errorf("empty choices array")
|
||||
}
|
||||
|
||||
firstChoice, ok := choices[0].(map[string]interface{})
|
||||
if !ok {
|
||||
return "", fmt.Errorf("invalid choice format")
|
||||
}
|
||||
|
||||
message, ok := firstChoice["message"].(map[string]interface{})
|
||||
if !ok {
|
||||
return "", fmt.Errorf("no message in choice")
|
||||
}
|
||||
|
||||
content, ok := message["content"].(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("no content in message")
|
||||
}
|
||||
|
||||
return content, nil
|
||||
}
|
||||
159
api/parse_test.go
Normal file
159
api/parse_test.go
Normal file
@@ -0,0 +1,159 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"git.wisehodl.dev/jay/aicli/config"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestParseResponse(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
protocol config.APIProtocol
|
||||
want string
|
||||
wantErr bool
|
||||
errContains string
|
||||
}{
|
||||
{
|
||||
name: "openai success",
|
||||
body: `{"choices":[{"message":{"content":"This is the response text."}}]}`,
|
||||
protocol: config.ProtocolOpenAI,
|
||||
want: "This is the response text.",
|
||||
},
|
||||
{
|
||||
name: "openai empty choices",
|
||||
body: `{"choices":[]}`,
|
||||
protocol: config.ProtocolOpenAI,
|
||||
wantErr: true,
|
||||
errContains: "empty choices array",
|
||||
},
|
||||
{
|
||||
name: "openai no choices field",
|
||||
body: `{"result":"ok"}`,
|
||||
protocol: config.ProtocolOpenAI,
|
||||
wantErr: true,
|
||||
errContains: "no choices in response",
|
||||
},
|
||||
{
|
||||
name: "openai invalid choice format",
|
||||
body: `{"choices":["invalid"]}`,
|
||||
protocol: config.ProtocolOpenAI,
|
||||
wantErr: true,
|
||||
errContains: "invalid choice format",
|
||||
},
|
||||
{
|
||||
name: "openai no message field",
|
||||
body: `{"choices":[{"text":"wrong structure"}]}`,
|
||||
protocol: config.ProtocolOpenAI,
|
||||
wantErr: true,
|
||||
errContains: "no message in choice",
|
||||
},
|
||||
{
|
||||
name: "openai no content field",
|
||||
body: `{"choices":[{"message":{"role":"assistant"}}]}`,
|
||||
protocol: config.ProtocolOpenAI,
|
||||
wantErr: true,
|
||||
errContains: "no content in message",
|
||||
},
|
||||
{
|
||||
name: "ollama success",
|
||||
body: `{"response":"This is the Ollama response."}`,
|
||||
protocol: config.ProtocolOllama,
|
||||
want: "This is the Ollama response.",
|
||||
},
|
||||
{
|
||||
name: "ollama no response field",
|
||||
body: `{"model":"llama3"}`,
|
||||
protocol: config.ProtocolOllama,
|
||||
wantErr: true,
|
||||
errContains: "no response field in ollama response",
|
||||
},
|
||||
{
|
||||
name: "malformed json",
|
||||
body: `{invalid json`,
|
||||
protocol: config.ProtocolOpenAI,
|
||||
wantErr: true,
|
||||
errContains: "parse response",
|
||||
},
|
||||
{
|
||||
name: "openai multiline content",
|
||||
body: `{"choices":[{"message":{"content":"Line 1\nLine 2\nLine 3"}}]}`,
|
||||
protocol: config.ProtocolOpenAI,
|
||||
want: "Line 1\nLine 2\nLine 3",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := parseResponse([]byte(tt.body), tt.protocol)
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
if tt.errContains != "" {
|
||||
assert.Contains(t, err.Error(), tt.errContains)
|
||||
}
|
||||
return
|
||||
}
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseResponseWithTestdata(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
file string
|
||||
protocol config.APIProtocol
|
||||
want string
|
||||
wantErr bool
|
||||
errContains string
|
||||
}{
|
||||
{
|
||||
name: "openai success from file",
|
||||
file: "testdata/openai_success.json",
|
||||
protocol: config.ProtocolOpenAI,
|
||||
want: "This is the response text.",
|
||||
},
|
||||
{
|
||||
name: "openai empty choices from file",
|
||||
file: "testdata/openai_empty_choices.json",
|
||||
protocol: config.ProtocolOpenAI,
|
||||
wantErr: true,
|
||||
errContains: "empty choices array",
|
||||
},
|
||||
{
|
||||
name: "ollama success from file",
|
||||
file: "testdata/ollama_success.json",
|
||||
protocol: config.ProtocolOllama,
|
||||
want: "This is the Ollama response.",
|
||||
},
|
||||
{
|
||||
name: "ollama no response from file",
|
||||
file: "testdata/ollama_no_response.json",
|
||||
protocol: config.ProtocolOllama,
|
||||
wantErr: true,
|
||||
errContains: "no response field",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
body, err := os.ReadFile(tt.file)
|
||||
assert.NoError(t, err, "failed to read test file")
|
||||
|
||||
got, err := parseResponse(body, tt.protocol)
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
if tt.errContains != "" {
|
||||
assert.Contains(t, err.Error(), tt.errContains)
|
||||
}
|
||||
return
|
||||
}
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
36
api/payload.go
Normal file
36
api/payload.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package api
|
||||
|
||||
import "git.wisehodl.dev/jay/aicli/config"
|
||||
|
||||
// buildPayload constructs the JSON payload for the API request based on protocol.
|
||||
func buildPayload(cfg config.ConfigData, model string, query string) map[string]interface{} {
|
||||
if cfg.Protocol == config.ProtocolOllama {
|
||||
payload := map[string]interface{}{
|
||||
"model": model,
|
||||
"prompt": query,
|
||||
"stream": false,
|
||||
}
|
||||
if cfg.SystemPrompt != "" {
|
||||
payload["system"] = cfg.SystemPrompt
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
// OpenAI protocol
|
||||
messages := []map[string]string{}
|
||||
if cfg.SystemPrompt != "" {
|
||||
messages = append(messages, map[string]string{
|
||||
"role": "system",
|
||||
"content": cfg.SystemPrompt,
|
||||
})
|
||||
}
|
||||
messages = append(messages, map[string]string{
|
||||
"role": "user",
|
||||
"content": query,
|
||||
})
|
||||
|
||||
return map[string]interface{}{
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
}
|
||||
}
|
||||
126
api/payload_test.go
Normal file
126
api/payload_test.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.wisehodl.dev/jay/aicli/config"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestBuildPayload(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg config.ConfigData
|
||||
model string
|
||||
query string
|
||||
want map[string]interface{}
|
||||
}{
|
||||
{
|
||||
name: "openai without system prompt",
|
||||
cfg: config.ConfigData{
|
||||
Protocol: config.ProtocolOpenAI,
|
||||
},
|
||||
model: "gpt-4",
|
||||
query: "analyze this",
|
||||
want: map[string]interface{}{
|
||||
"model": "gpt-4",
|
||||
"messages": []map[string]string{
|
||||
{"role": "user", "content": "analyze this"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "openai with system prompt",
|
||||
cfg: config.ConfigData{
|
||||
Protocol: config.ProtocolOpenAI,
|
||||
SystemPrompt: "You are helpful",
|
||||
},
|
||||
model: "gpt-4",
|
||||
query: "analyze this",
|
||||
want: map[string]interface{}{
|
||||
"model": "gpt-4",
|
||||
"messages": []map[string]string{
|
||||
{"role": "system", "content": "You are helpful"},
|
||||
{"role": "user", "content": "analyze this"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ollama without system prompt",
|
||||
cfg: config.ConfigData{
|
||||
Protocol: config.ProtocolOllama,
|
||||
},
|
||||
model: "llama3",
|
||||
query: "analyze this",
|
||||
want: map[string]interface{}{
|
||||
"model": "llama3",
|
||||
"prompt": "analyze this",
|
||||
"stream": false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ollama with system prompt",
|
||||
cfg: config.ConfigData{
|
||||
Protocol: config.ProtocolOllama,
|
||||
SystemPrompt: "You are helpful",
|
||||
},
|
||||
model: "llama3",
|
||||
query: "analyze this",
|
||||
want: map[string]interface{}{
|
||||
"model": "llama3",
|
||||
"prompt": "analyze this",
|
||||
"system": "You are helpful",
|
||||
"stream": false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty query",
|
||||
cfg: config.ConfigData{
|
||||
Protocol: config.ProtocolOpenAI,
|
||||
},
|
||||
model: "gpt-4",
|
||||
query: "",
|
||||
want: map[string]interface{}{
|
||||
"model": "gpt-4",
|
||||
"messages": []map[string]string{
|
||||
{"role": "user", "content": ""},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiline query",
|
||||
cfg: config.ConfigData{
|
||||
Protocol: config.ProtocolOpenAI,
|
||||
},
|
||||
model: "gpt-4",
|
||||
query: "line1\nline2\nline3",
|
||||
want: map[string]interface{}{
|
||||
"model": "gpt-4",
|
||||
"messages": []map[string]string{
|
||||
{"role": "user", "content": "line1\nline2\nline3"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "model name injection",
|
||||
cfg: config.ConfigData{
|
||||
Protocol: config.ProtocolOpenAI,
|
||||
},
|
||||
model: "custom-model-name",
|
||||
query: "test",
|
||||
want: map[string]interface{}{
|
||||
"model": "custom-model-name",
|
||||
"messages": []map[string]string{
|
||||
{"role": "user", "content": "test"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := buildPayload(tt.cfg, tt.model, tt.query)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
3
api/testdata/ollama_no_response.json
vendored
Normal file
3
api/testdata/ollama_no_response.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"model": "llama3"
|
||||
}
|
||||
3
api/testdata/ollama_success.json
vendored
Normal file
3
api/testdata/ollama_success.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"response": "This is the Ollama response."
|
||||
}
|
||||
3
api/testdata/openai_empty_choices.json
vendored
Normal file
3
api/testdata/openai_empty_choices.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"choices": []
|
||||
}
|
||||
9
api/testdata/openai_success.json
vendored
Normal file
9
api/testdata/openai_success.json
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"choices": [
|
||||
{
|
||||
"message": {
|
||||
"content": "This is the response text."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
141
config/config.go
Normal file
141
config/config.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
const UsageText = `Usage: aicli [OPTION]...
|
||||
Send prompts and files to LLM chat endpoints.
|
||||
|
||||
Global:
|
||||
--version display version and exit
|
||||
|
||||
Input:
|
||||
-f, --file PATH input file (repeatable)
|
||||
-F, --stdin-file treat stdin as file content
|
||||
-p, --prompt TEXT prompt text (repeatable)
|
||||
-pf, --prompt-file PATH read prompt from file
|
||||
|
||||
System:
|
||||
-s, --system TEXT system prompt text
|
||||
-sf, --system-file PATH read system prompt from file
|
||||
(error if both -s and -sf provided)
|
||||
|
||||
API:
|
||||
-l, --protocol PROTO openai or ollama (default: openai)
|
||||
-u, --url URL endpoint (default: https://api.ppq.ai/chat/completions)
|
||||
-k, --key KEY API key
|
||||
-kf, --key-file PATH read API key from file
|
||||
|
||||
Models:
|
||||
-m, --model NAME primary model (default: gpt-4o-mini)
|
||||
-b, --fallback NAMES comma-separated fallback list (default: gpt-4.1-mini)
|
||||
|
||||
Output:
|
||||
-o, --output PATH write to file (mode 0644) instead of stdout
|
||||
-q, --quiet suppress progress messages
|
||||
-v, --verbose log debug information to stderr
|
||||
|
||||
Config:
|
||||
-c, --config PATH YAML config file
|
||||
|
||||
Environment Variables:
|
||||
AICLI_API_KEY API key
|
||||
AICLI_API_KEY_FILE path to API key file
|
||||
AICLI_PROTOCOL API protocol
|
||||
AICLI_URL endpoint URL
|
||||
AICLI_MODEL primary model name
|
||||
AICLI_FALLBACK comma-separated fallback models
|
||||
AICLI_SYSTEM system prompt text
|
||||
AICLI_SYSTEM_FILE path to system prompt file
|
||||
AICLI_CONFIG_FILE path to config file
|
||||
AICLI_PROMPT_FILE path to prompt file
|
||||
AICLI_DEFAULT_PROMPT override default prompt
|
||||
|
||||
Precedence Rules:
|
||||
API key: --key > --key-file > AICLI_API_KEY > AICLI_API_KEY_FILE > config key_file
|
||||
System: --system > --system-file > AICLI_SYSTEM > AICLI_SYSTEM_FILE > config system_file
|
||||
Config file: --config > AICLI_CONFIG_FILE
|
||||
All others: flags > environment > config file > defaults
|
||||
|
||||
Stdin Behavior:
|
||||
No flags: stdin becomes the prompt
|
||||
With -p/-pf: stdin appends after explicit prompts
|
||||
With -F: stdin becomes first file (path: "input")
|
||||
|
||||
Examples:
|
||||
echo "What is Rust?" | aicli
|
||||
cat log.txt | aicli -F -p "Find errors in this log"
|
||||
aicli -f main.go -p "Review this code"
|
||||
aicli -c ~/.aicli.yaml -f src/main.go -f src/util.go -o analysis.md
|
||||
aicli -p "Context:" -pf template.txt -p "Apply to finance sector"
|
||||
`
|
||||
|
||||
func printUsage() {
|
||||
fmt.Fprint(os.Stderr, UsageText)
|
||||
}
|
||||
|
||||
// BuildConfig resolves configuration from all sources with precedence:
|
||||
// flags > env > file > defaults
|
||||
func BuildConfig(args []string) (ConfigData, error) {
|
||||
flags, err := parseFlags(args)
|
||||
if err != nil {
|
||||
return ConfigData{}, fmt.Errorf("parse flags: %w", err)
|
||||
}
|
||||
|
||||
// Validate protocol strings before merge
|
||||
if flags.protocol != "" && flags.protocol != "openai" && flags.protocol != "ollama" {
|
||||
return ConfigData{}, fmt.Errorf("invalid protocol: must be openai or ollama, got: %s", flags.protocol)
|
||||
}
|
||||
|
||||
configPath := flags.config
|
||||
if configPath == "" {
|
||||
configPath = os.Getenv("AICLI_CONFIG_FILE")
|
||||
}
|
||||
|
||||
env := loadEnvironment()
|
||||
|
||||
// Validate env protocol
|
||||
if env.protocol != "" && env.protocol != "openai" && env.protocol != "ollama" {
|
||||
return ConfigData{}, fmt.Errorf("invalid protocol: must be openai or ollama, got: %s", env.protocol)
|
||||
}
|
||||
|
||||
file, err := loadConfigFile(configPath)
|
||||
if err != nil {
|
||||
return ConfigData{}, fmt.Errorf("load config file: %w", err)
|
||||
}
|
||||
|
||||
// Validate file protocol
|
||||
if file.protocol != "" && file.protocol != "openai" && file.protocol != "ollama" {
|
||||
return ConfigData{}, fmt.Errorf("invalid protocol: must be openai or ollama, got: %s", file.protocol)
|
||||
}
|
||||
|
||||
cfg := mergeSources(flags, env, file)
|
||||
|
||||
if err := validateConfig(cfg); err != nil {
|
||||
return ConfigData{}, err
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// IsVersionRequest checks if --version flag was passed
|
||||
func IsVersionRequest(args []string) bool {
|
||||
for _, arg := range args {
|
||||
if arg == "--version" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsHelpRequest checks if -h or --help flag was passed
|
||||
func IsHelpRequest(args []string) bool {
|
||||
for _, arg := range args {
|
||||
if arg == "-h" || arg == "--help" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
77
config/config_test.go
Normal file
77
config/config_test.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildConfig(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
env map[string]string
|
||||
wantErr bool
|
||||
check func(*testing.T, ConfigData)
|
||||
}{
|
||||
{
|
||||
name: "valid full config",
|
||||
args: []string{"-k", "sk-test", "-m", "gpt-4"},
|
||||
check: func(t *testing.T, cfg ConfigData) {
|
||||
assert.Equal(t, "sk-test", cfg.APIKey)
|
||||
assert.Equal(t, "gpt-4", cfg.Model)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "config file from env",
|
||||
args: []string{"-k", "sk-test"},
|
||||
env: map[string]string{"AICLI_CONFIG_FILE": "testdata/partial.yaml"},
|
||||
check: func(t *testing.T, cfg ConfigData) {
|
||||
assert.Equal(t, "gpt-4", cfg.Model)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing api key",
|
||||
args: []string{},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid config file",
|
||||
args: []string{"-c", "testdata/invalid.yaml", "-k", "test"},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid protocol in flags",
|
||||
args: []string{"-k", "sk-test", "-l", "invalid"},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Clear all AICLI_* env vars
|
||||
t.Setenv("AICLI_API_KEY", "")
|
||||
t.Setenv("AICLI_API_KEY_FILE", "")
|
||||
t.Setenv("AICLI_PROTOCOL", "")
|
||||
t.Setenv("AICLI_URL", "")
|
||||
t.Setenv("AICLI_MODEL", "")
|
||||
t.Setenv("AICLI_FALLBACK", "")
|
||||
t.Setenv("AICLI_SYSTEM", "")
|
||||
t.Setenv("AICLI_CONFIG_FILE", "")
|
||||
|
||||
// Apply test-specific env
|
||||
for k, v := range tt.env {
|
||||
t.Setenv(k, v)
|
||||
}
|
||||
|
||||
cfg, err := BuildConfig(tt.args)
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
assert.NoError(t, err)
|
||||
if tt.check != nil {
|
||||
tt.check(t, cfg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
11
config/defaults.go
Normal file
11
config/defaults.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package config
|
||||
|
||||
var defaultConfig = ConfigData{
|
||||
StdinAsFile: false,
|
||||
Protocol: ProtocolOpenAI,
|
||||
URL: "https://api.ppq.ai/chat/completions",
|
||||
Model: "gpt-4o-mini",
|
||||
FallbackModels: []string{"gpt-4.1-mini"},
|
||||
Quiet: false,
|
||||
Verbose: false,
|
||||
}
|
||||
34
config/env.go
Normal file
34
config/env.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package config
|
||||
|
||||
import "os"
|
||||
import "strings"
|
||||
|
||||
func loadEnvironment() envValues {
|
||||
ev := envValues{}
|
||||
|
||||
if val := os.Getenv("AICLI_PROTOCOL"); val != "" {
|
||||
ev.protocol = val
|
||||
}
|
||||
if val := os.Getenv("AICLI_URL"); val != "" {
|
||||
ev.url = val
|
||||
}
|
||||
if val := os.Getenv("AICLI_API_KEY"); val != "" {
|
||||
ev.key = val
|
||||
} else if val := os.Getenv("AICLI_API_KEY_FILE"); val != "" {
|
||||
content, err := os.ReadFile(val)
|
||||
if err == nil {
|
||||
ev.key = strings.TrimSpace(string(content))
|
||||
}
|
||||
}
|
||||
if val := os.Getenv("AICLI_MODEL"); val != "" {
|
||||
ev.model = val
|
||||
}
|
||||
if val := os.Getenv("AICLI_FALLBACK"); val != "" {
|
||||
ev.fallback = val
|
||||
}
|
||||
if val := os.Getenv("AICLI_SYSTEM"); val != "" {
|
||||
ev.system = val
|
||||
}
|
||||
|
||||
return ev
|
||||
}
|
||||
134
config/env_test.go
Normal file
134
config/env_test.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadEnvironment(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
env map[string]string
|
||||
want envValues
|
||||
}{
|
||||
{
|
||||
name: "empty environment",
|
||||
env: map[string]string{},
|
||||
want: envValues{},
|
||||
},
|
||||
{
|
||||
name: "protocol only",
|
||||
env: map[string]string{"AICLI_PROTOCOL": "ollama"},
|
||||
want: envValues{protocol: "ollama"},
|
||||
},
|
||||
{
|
||||
name: "url only",
|
||||
env: map[string]string{"AICLI_URL": "http://localhost:11434"},
|
||||
want: envValues{url: "http://localhost:11434"},
|
||||
},
|
||||
{
|
||||
name: "api key direct",
|
||||
env: map[string]string{"AICLI_API_KEY": "sk-test123"},
|
||||
want: envValues{key: "sk-test123"},
|
||||
},
|
||||
{
|
||||
name: "model only",
|
||||
env: map[string]string{"AICLI_MODEL": "llama3"},
|
||||
want: envValues{model: "llama3"},
|
||||
},
|
||||
{
|
||||
name: "fallback only",
|
||||
env: map[string]string{"AICLI_FALLBACK": "gpt-3.5,gpt-4"},
|
||||
want: envValues{fallback: "gpt-3.5,gpt-4"},
|
||||
},
|
||||
{
|
||||
name: "system only",
|
||||
env: map[string]string{"AICLI_SYSTEM": "You are helpful"},
|
||||
want: envValues{system: "You are helpful"},
|
||||
},
|
||||
{
|
||||
name: "all variables set",
|
||||
env: map[string]string{
|
||||
"AICLI_PROTOCOL": "openai",
|
||||
"AICLI_URL": "https://api.openai.com/v1/chat/completions",
|
||||
"AICLI_API_KEY": "sk-abc",
|
||||
"AICLI_MODEL": "gpt-4",
|
||||
"AICLI_FALLBACK": "gpt-3.5",
|
||||
"AICLI_SYSTEM": "system prompt",
|
||||
},
|
||||
want: envValues{
|
||||
protocol: "openai",
|
||||
url: "https://api.openai.com/v1/chat/completions",
|
||||
key: "sk-abc",
|
||||
model: "gpt-4",
|
||||
fallback: "gpt-3.5",
|
||||
system: "system prompt",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty string values preserved",
|
||||
env: map[string]string{"AICLI_SYSTEM": ""},
|
||||
want: envValues{system: ""},
|
||||
},
|
||||
{
|
||||
name: "whitespace preserved",
|
||||
env: map[string]string{"AICLI_SYSTEM": " spaces "},
|
||||
want: envValues{system: " spaces "},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Set environment
|
||||
for k, v := range tt.env {
|
||||
t.Setenv(k, v)
|
||||
}
|
||||
|
||||
got := loadEnvironment()
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadEnvironmentKeyFile(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
env map[string]string
|
||||
want envValues
|
||||
}{
|
||||
{
|
||||
name: "key file when no direct key",
|
||||
env: map[string]string{"AICLI_API_KEY_FILE": "testdata/api.key"},
|
||||
want: envValues{key: "sk-test-key-123"},
|
||||
},
|
||||
{
|
||||
name: "direct key overrides key file",
|
||||
env: map[string]string{
|
||||
"AICLI_API_KEY": "sk-direct",
|
||||
"AICLI_API_KEY_FILE": "testdata/api.key",
|
||||
},
|
||||
want: envValues{key: "sk-direct"},
|
||||
},
|
||||
{
|
||||
name: "key file not found",
|
||||
env: map[string]string{"AICLI_API_KEY_FILE": "/nonexistent/key.txt"},
|
||||
want: envValues{},
|
||||
},
|
||||
{
|
||||
name: "key file with whitespace trimmed",
|
||||
env: map[string]string{"AICLI_API_KEY_FILE": "testdata/api_whitespace.key"},
|
||||
want: envValues{key: "sk-whitespace-key"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
for k, v := range tt.env {
|
||||
t.Setenv(k, v)
|
||||
}
|
||||
|
||||
got := loadEnvironment()
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
44
config/file.go
Normal file
44
config/file.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"gopkg.in/yaml.v3"
|
||||
"os"
|
||||
)
|
||||
|
||||
func loadConfigFile(path string) (fileValues, error) {
|
||||
if path == "" {
|
||||
return fileValues{}, nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fileValues{}, err
|
||||
}
|
||||
|
||||
var raw map[string]interface{}
|
||||
if err := yaml.Unmarshal(data, &raw); err != nil {
|
||||
return fileValues{}, err
|
||||
}
|
||||
|
||||
fv := fileValues{}
|
||||
if v, ok := raw["protocol"].(string); ok {
|
||||
fv.protocol = v
|
||||
}
|
||||
if v, ok := raw["url"].(string); ok {
|
||||
fv.url = v
|
||||
}
|
||||
if v, ok := raw["key_file"].(string); ok {
|
||||
fv.keyFile = v
|
||||
}
|
||||
if v, ok := raw["model"].(string); ok {
|
||||
fv.model = v
|
||||
}
|
||||
if v, ok := raw["fallback"].(string); ok {
|
||||
fv.fallback = v
|
||||
}
|
||||
if v, ok := raw["system_file"].(string); ok {
|
||||
fv.systemFile = v
|
||||
}
|
||||
|
||||
return fv, nil
|
||||
}
|
||||
76
config/file_test.go
Normal file
76
config/file_test.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadConfigFile(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
want fileValues
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "empty path returns nil",
|
||||
path: "",
|
||||
want: fileValues{},
|
||||
},
|
||||
{
|
||||
name: "valid config",
|
||||
path: "testdata/valid.yaml",
|
||||
want: fileValues{
|
||||
protocol: "ollama",
|
||||
url: "http://localhost:11434/api/chat",
|
||||
keyFile: "~/.aicli_key",
|
||||
model: "llama3",
|
||||
fallback: "llama2,mistral",
|
||||
systemFile: "~/system.txt",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "partial config",
|
||||
path: "testdata/partial.yaml",
|
||||
want: fileValues{
|
||||
model: "gpt-4",
|
||||
fallback: "gpt-3.5-turbo",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty file",
|
||||
path: "testdata/empty.yaml",
|
||||
want: fileValues{},
|
||||
},
|
||||
{
|
||||
name: "file not found",
|
||||
path: "testdata/nonexistent.yaml",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid yaml syntax",
|
||||
path: "testdata/invalid.yaml",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "unknown keys ignored",
|
||||
path: "testdata/unknown_keys.yaml",
|
||||
want: fileValues{
|
||||
protocol: "openai",
|
||||
model: "gpt-4",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := loadConfigFile(tt.path)
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
81
config/flags.go
Normal file
81
config/flags.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package config
|
||||
|
||||
import "flag"
|
||||
|
||||
type stringSlice []string
|
||||
|
||||
func (s *stringSlice) String() string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *stringSlice) Set(value string) error {
|
||||
*s = append(*s, value)
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseFlags(args []string) (flagValues, error) {
|
||||
fv := flagValues{}
|
||||
|
||||
fs := flag.NewFlagSet("aicli", flag.ContinueOnError)
|
||||
fs.Usage = printUsage
|
||||
|
||||
var files stringSlice
|
||||
var prompts stringSlice
|
||||
|
||||
// Input flags
|
||||
fs.Var(&files, "f", "")
|
||||
fs.Var(&files, "file", "")
|
||||
fs.Var(&prompts, "p", "")
|
||||
fs.Var(&prompts, "prompt", "")
|
||||
fs.StringVar(&fv.promptFile, "pf", "", "")
|
||||
fs.StringVar(&fv.promptFile, "prompt-file", "", "")
|
||||
|
||||
// System flags
|
||||
fs.StringVar(&fv.system, "s", "", "")
|
||||
fs.StringVar(&fv.system, "system", "", "")
|
||||
fs.StringVar(&fv.systemFile, "sf", "", "")
|
||||
fs.StringVar(&fv.systemFile, "system-file", "", "")
|
||||
|
||||
// API flags
|
||||
fs.StringVar(&fv.key, "k", "", "")
|
||||
fs.StringVar(&fv.key, "key", "", "")
|
||||
fs.StringVar(&fv.keyFile, "kf", "", "")
|
||||
fs.StringVar(&fv.keyFile, "key-file", "", "")
|
||||
fs.StringVar(&fv.protocol, "l", "", "")
|
||||
fs.StringVar(&fv.protocol, "protocol", "", "")
|
||||
fs.StringVar(&fv.url, "u", "", "")
|
||||
fs.StringVar(&fv.url, "url", "", "")
|
||||
|
||||
// Model flags
|
||||
fs.StringVar(&fv.model, "m", "", "")
|
||||
fs.StringVar(&fv.model, "model", "", "")
|
||||
fs.StringVar(&fv.fallback, "b", "", "")
|
||||
fs.StringVar(&fv.fallback, "fallback", "", "")
|
||||
|
||||
// Output flags
|
||||
fs.StringVar(&fv.output, "o", "", "")
|
||||
fs.StringVar(&fv.output, "output", "", "")
|
||||
fs.StringVar(&fv.config, "c", "", "")
|
||||
fs.StringVar(&fv.config, "config", "", "")
|
||||
|
||||
// Boolean flags
|
||||
fs.BoolVar(&fv.stdinFile, "F", false, "")
|
||||
fs.BoolVar(&fv.stdinFile, "stdin-file", false, "")
|
||||
fs.BoolVar(&fv.quiet, "q", false, "")
|
||||
fs.BoolVar(&fv.quiet, "quiet", false, "")
|
||||
fs.BoolVar(&fv.verbose, "v", false, "")
|
||||
fs.BoolVar(&fv.verbose, "verbose", false, "")
|
||||
fs.BoolVar(&fv.version, "version", false, "")
|
||||
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return flagValues{}, err
|
||||
}
|
||||
|
||||
fv.files = files
|
||||
fv.prompts = prompts
|
||||
|
||||
return fv, nil
|
||||
}
|
||||
268
config/flags_test.go
Normal file
268
config/flags_test.go
Normal file
@@ -0,0 +1,268 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func resetFlags() {
|
||||
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ContinueOnError)
|
||||
}
|
||||
|
||||
func TestParseFlags(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want flagValues
|
||||
}{
|
||||
{
|
||||
name: "empty args",
|
||||
args: []string{},
|
||||
want: flagValues{},
|
||||
},
|
||||
{
|
||||
name: "single file short flag",
|
||||
args: []string{"-f", "main.go"},
|
||||
want: flagValues{files: []string{"main.go"}},
|
||||
},
|
||||
{
|
||||
name: "single file long flag",
|
||||
args: []string{"--file", "main.go"},
|
||||
want: flagValues{files: []string{"main.go"}},
|
||||
},
|
||||
{
|
||||
name: "multiple files",
|
||||
args: []string{"-f", "a.go", "-f", "b.go", "--file", "c.go"},
|
||||
want: flagValues{files: []string{"a.go", "b.go", "c.go"}},
|
||||
},
|
||||
{
|
||||
name: "single prompt short flag",
|
||||
args: []string{"-p", "analyze this"},
|
||||
want: flagValues{prompts: []string{"analyze this"}},
|
||||
},
|
||||
{
|
||||
name: "single prompt long flag",
|
||||
args: []string{"--prompt", "analyze this"},
|
||||
want: flagValues{prompts: []string{"analyze this"}},
|
||||
},
|
||||
{
|
||||
name: "multiple prompts",
|
||||
args: []string{"-p", "first", "-p", "second", "--prompt", "third"},
|
||||
want: flagValues{prompts: []string{"first", "second", "third"}},
|
||||
},
|
||||
{
|
||||
name: "prompt file",
|
||||
args: []string{"-pf", "prompt.txt"},
|
||||
want: flagValues{promptFile: "prompt.txt"},
|
||||
},
|
||||
{
|
||||
name: "prompt file long",
|
||||
args: []string{"--prompt-file", "prompt.txt"},
|
||||
want: flagValues{promptFile: "prompt.txt"},
|
||||
},
|
||||
{
|
||||
name: "system short",
|
||||
args: []string{"-s", "You are helpful"},
|
||||
want: flagValues{system: "You are helpful"},
|
||||
},
|
||||
{
|
||||
name: "system long",
|
||||
args: []string{"--system", "You are helpful"},
|
||||
want: flagValues{system: "You are helpful"},
|
||||
},
|
||||
{
|
||||
name: "system file short",
|
||||
args: []string{"-sf", "system.txt"},
|
||||
want: flagValues{systemFile: "system.txt"},
|
||||
},
|
||||
{
|
||||
name: "system file long",
|
||||
args: []string{"--system-file", "system.txt"},
|
||||
want: flagValues{systemFile: "system.txt"},
|
||||
},
|
||||
{
|
||||
name: "key short",
|
||||
args: []string{"-k", "sk-abc123"},
|
||||
want: flagValues{key: "sk-abc123"},
|
||||
},
|
||||
{
|
||||
name: "key long",
|
||||
args: []string{"--key", "sk-abc123"},
|
||||
want: flagValues{key: "sk-abc123"},
|
||||
},
|
||||
{
|
||||
name: "key file short",
|
||||
args: []string{"-kf", "api.key"},
|
||||
want: flagValues{keyFile: "api.key"},
|
||||
},
|
||||
{
|
||||
name: "key file long",
|
||||
args: []string{"--key-file", "api.key"},
|
||||
want: flagValues{keyFile: "api.key"},
|
||||
},
|
||||
{
|
||||
name: "protocol short",
|
||||
args: []string{"-l", "ollama"},
|
||||
want: flagValues{protocol: "ollama"},
|
||||
},
|
||||
{
|
||||
name: "protocol long",
|
||||
args: []string{"--protocol", "ollama"},
|
||||
want: flagValues{protocol: "ollama"},
|
||||
},
|
||||
{
|
||||
name: "url short",
|
||||
args: []string{"-u", "http://localhost:11434"},
|
||||
want: flagValues{url: "http://localhost:11434"},
|
||||
},
|
||||
{
|
||||
name: "url long",
|
||||
args: []string{"--url", "http://localhost:11434"},
|
||||
want: flagValues{url: "http://localhost:11434"},
|
||||
},
|
||||
{
|
||||
name: "model short",
|
||||
args: []string{"-m", "gpt-4"},
|
||||
want: flagValues{model: "gpt-4"},
|
||||
},
|
||||
{
|
||||
name: "model long",
|
||||
args: []string{"--model", "gpt-4"},
|
||||
want: flagValues{model: "gpt-4"},
|
||||
},
|
||||
{
|
||||
name: "fallback short",
|
||||
args: []string{"-b", "gpt-3.5-turbo"},
|
||||
want: flagValues{fallback: "gpt-3.5-turbo"},
|
||||
},
|
||||
{
|
||||
name: "fallback long",
|
||||
args: []string{"--fallback", "gpt-3.5-turbo"},
|
||||
want: flagValues{fallback: "gpt-3.5-turbo"},
|
||||
},
|
||||
{
|
||||
name: "output short",
|
||||
args: []string{"-o", "result.txt"},
|
||||
want: flagValues{output: "result.txt"},
|
||||
},
|
||||
{
|
||||
name: "output long",
|
||||
args: []string{"--output", "result.txt"},
|
||||
want: flagValues{output: "result.txt"},
|
||||
},
|
||||
{
|
||||
name: "config short",
|
||||
args: []string{"-c", "config.yaml"},
|
||||
want: flagValues{config: "config.yaml"},
|
||||
},
|
||||
{
|
||||
name: "config long",
|
||||
args: []string{"--config", "config.yaml"},
|
||||
want: flagValues{config: "config.yaml"},
|
||||
},
|
||||
{
|
||||
name: "stdin file short",
|
||||
args: []string{"-F"},
|
||||
want: flagValues{stdinFile: true},
|
||||
},
|
||||
{
|
||||
name: "stdin file long",
|
||||
args: []string{"--stdin-file"},
|
||||
want: flagValues{stdinFile: true},
|
||||
},
|
||||
{
|
||||
name: "quiet short",
|
||||
args: []string{"-q"},
|
||||
want: flagValues{quiet: true},
|
||||
},
|
||||
{
|
||||
name: "quiet long",
|
||||
args: []string{"--quiet"},
|
||||
want: flagValues{quiet: true},
|
||||
},
|
||||
{
|
||||
name: "verbose short",
|
||||
args: []string{"-v"},
|
||||
want: flagValues{verbose: true},
|
||||
},
|
||||
{
|
||||
name: "verbose long",
|
||||
args: []string{"--verbose"},
|
||||
want: flagValues{verbose: true},
|
||||
},
|
||||
{
|
||||
name: "version flag",
|
||||
args: []string{"--version"},
|
||||
want: flagValues{version: true},
|
||||
},
|
||||
{
|
||||
name: "complex combination",
|
||||
args: []string{
|
||||
"-f", "a.go",
|
||||
"-f", "b.go",
|
||||
"-p", "first prompt",
|
||||
"-pf", "prompt.txt",
|
||||
"-s", "system prompt",
|
||||
"-k", "key123",
|
||||
"-m", "gpt-4",
|
||||
"-b", "gpt-3.5",
|
||||
"-o", "out.txt",
|
||||
"-q",
|
||||
"-v",
|
||||
},
|
||||
want: flagValues{
|
||||
files: []string{"a.go", "b.go"},
|
||||
prompts: []string{"first prompt"},
|
||||
promptFile: "prompt.txt",
|
||||
system: "system prompt",
|
||||
key: "key123",
|
||||
model: "gpt-4",
|
||||
fallback: "gpt-3.5",
|
||||
output: "out.txt",
|
||||
quiet: true,
|
||||
verbose: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
resetFlags()
|
||||
|
||||
got, err := parseFlags(tt.args)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFlagsErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{
|
||||
name: "unknown flag",
|
||||
args: []string{"--unknown"},
|
||||
},
|
||||
{
|
||||
name: "flag without value",
|
||||
args: []string{"-f"},
|
||||
},
|
||||
{
|
||||
name: "model without value",
|
||||
args: []string{"-m"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
resetFlags()
|
||||
|
||||
_, err := parseFlags(tt.args)
|
||||
assert.Error(t, err, "parseFlags() should return error for %s", tt.name)
|
||||
})
|
||||
}
|
||||
}
|
||||
112
config/merge.go
Normal file
112
config/merge.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func mergeSources(flags flagValues, env envValues, file fileValues) ConfigData {
|
||||
cfg := defaultConfig
|
||||
|
||||
// Apply file values
|
||||
if file.protocol != "" {
|
||||
cfg.Protocol = parseProtocol(file.protocol)
|
||||
}
|
||||
if file.url != "" {
|
||||
cfg.URL = file.url
|
||||
}
|
||||
if file.model != "" {
|
||||
cfg.Model = file.model
|
||||
}
|
||||
if file.fallback != "" {
|
||||
cfg.FallbackModels = strings.Split(file.fallback, ",")
|
||||
}
|
||||
|
||||
// Apply env values
|
||||
if env.protocol != "" {
|
||||
cfg.Protocol = parseProtocol(env.protocol)
|
||||
}
|
||||
if env.url != "" {
|
||||
cfg.URL = env.url
|
||||
}
|
||||
if env.model != "" {
|
||||
cfg.Model = env.model
|
||||
}
|
||||
if env.fallback != "" {
|
||||
cfg.FallbackModels = strings.Split(env.fallback, ",")
|
||||
}
|
||||
if env.system != "" {
|
||||
cfg.SystemPrompt = env.system
|
||||
}
|
||||
if env.key != "" {
|
||||
cfg.APIKey = env.key
|
||||
}
|
||||
|
||||
// Apply flag values
|
||||
if flags.protocol != "" {
|
||||
cfg.Protocol = parseProtocol(flags.protocol)
|
||||
}
|
||||
if flags.url != "" {
|
||||
cfg.URL = flags.url
|
||||
}
|
||||
if flags.model != "" {
|
||||
cfg.Model = flags.model
|
||||
}
|
||||
if flags.fallback != "" {
|
||||
cfg.FallbackModels = strings.Split(flags.fallback, ",")
|
||||
}
|
||||
if flags.output != "" {
|
||||
cfg.Output = flags.output
|
||||
}
|
||||
cfg.Quiet = flags.quiet
|
||||
cfg.Verbose = flags.verbose
|
||||
cfg.StdinAsFile = flags.stdinFile
|
||||
|
||||
// Collect input paths
|
||||
cfg.FilePaths = flags.files
|
||||
cfg.PromptFlags = flags.prompts
|
||||
if flags.promptFile != "" {
|
||||
cfg.PromptPaths = []string{flags.promptFile}
|
||||
}
|
||||
|
||||
// Resolve system prompt (direct > file)
|
||||
if flags.system != "" {
|
||||
cfg.SystemPrompt = flags.system
|
||||
} else if flags.systemFile != "" {
|
||||
content, err := os.ReadFile(flags.systemFile)
|
||||
if err == nil {
|
||||
cfg.SystemPrompt = strings.TrimRight(string(content), "\n")
|
||||
}
|
||||
} else if file.systemFile != "" && cfg.SystemPrompt == "" {
|
||||
content, err := os.ReadFile(file.systemFile)
|
||||
if err == nil {
|
||||
cfg.SystemPrompt = strings.TrimRight(string(content), "\n")
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve API key (direct > file)
|
||||
if flags.key != "" {
|
||||
cfg.APIKey = flags.key
|
||||
} else if flags.keyFile != "" {
|
||||
content, err := os.ReadFile(flags.keyFile)
|
||||
if err == nil {
|
||||
cfg.APIKey = strings.TrimSpace(string(content))
|
||||
}
|
||||
} else if cfg.APIKey == "" && file.keyFile != "" {
|
||||
content, err := os.ReadFile(file.keyFile)
|
||||
if err == nil {
|
||||
cfg.APIKey = strings.TrimSpace(string(content))
|
||||
}
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
func parseProtocol(s string) APIProtocol {
|
||||
switch s {
|
||||
case "ollama":
|
||||
return ProtocolOllama
|
||||
default:
|
||||
return ProtocolOpenAI
|
||||
}
|
||||
}
|
||||
373
config/merge_test.go
Normal file
373
config/merge_test.go
Normal file
@@ -0,0 +1,373 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMergeSources(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
flags flagValues
|
||||
env envValues
|
||||
file fileValues
|
||||
want ConfigData
|
||||
}{
|
||||
{
|
||||
name: "all empty uses defaults",
|
||||
flags: flagValues{},
|
||||
env: envValues{},
|
||||
file: fileValues{},
|
||||
want: defaultConfig,
|
||||
},
|
||||
{
|
||||
name: "file overrides defaults",
|
||||
flags: flagValues{},
|
||||
env: envValues{},
|
||||
file: fileValues{
|
||||
protocol: "ollama",
|
||||
model: "llama3",
|
||||
},
|
||||
want: ConfigData{
|
||||
Protocol: ProtocolOllama,
|
||||
URL: "https://api.ppq.ai/chat/completions",
|
||||
Model: "llama3",
|
||||
FallbackModels: []string{"gpt-4.1-mini"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "env overrides file",
|
||||
flags: flagValues{},
|
||||
env: envValues{
|
||||
model: "gpt-4",
|
||||
},
|
||||
file: fileValues{
|
||||
model: "llama3",
|
||||
},
|
||||
want: ConfigData{
|
||||
Protocol: ProtocolOpenAI,
|
||||
URL: "https://api.ppq.ai/chat/completions",
|
||||
Model: "gpt-4",
|
||||
FallbackModels: []string{"gpt-4.1-mini"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "flags override env",
|
||||
flags: flagValues{
|
||||
model: "claude-3",
|
||||
},
|
||||
env: envValues{
|
||||
model: "gpt-4",
|
||||
},
|
||||
file: fileValues{},
|
||||
want: ConfigData{
|
||||
Protocol: ProtocolOpenAI,
|
||||
URL: "https://api.ppq.ai/chat/completions",
|
||||
Model: "claude-3",
|
||||
FallbackModels: []string{"gpt-4.1-mini"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "full precedence chain",
|
||||
flags: flagValues{
|
||||
protocol: "ollama",
|
||||
quiet: true,
|
||||
},
|
||||
env: envValues{
|
||||
protocol: "openai",
|
||||
model: "gpt-4",
|
||||
url: "http://custom.api",
|
||||
},
|
||||
file: fileValues{
|
||||
protocol: "openai",
|
||||
model: "llama3",
|
||||
url: "http://file.api",
|
||||
fallback: "mistral",
|
||||
},
|
||||
want: ConfigData{
|
||||
Protocol: ProtocolOllama,
|
||||
URL: "http://custom.api",
|
||||
Model: "gpt-4",
|
||||
FallbackModels: []string{"mistral"},
|
||||
Quiet: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "fallback string split",
|
||||
flags: flagValues{
|
||||
fallback: "model1,model2,model3",
|
||||
},
|
||||
env: envValues{},
|
||||
file: fileValues{},
|
||||
want: ConfigData{
|
||||
Protocol: ProtocolOpenAI,
|
||||
URL: "https://api.ppq.ai/chat/completions",
|
||||
Model: "gpt-4o-mini",
|
||||
FallbackModels: []string{"model1", "model2", "model3"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "direct key flag",
|
||||
flags: flagValues{
|
||||
key: "sk-direct",
|
||||
},
|
||||
env: envValues{},
|
||||
file: fileValues{},
|
||||
want: ConfigData{
|
||||
Protocol: ProtocolOpenAI,
|
||||
URL: "https://api.ppq.ai/chat/completions",
|
||||
Model: "gpt-4o-mini",
|
||||
FallbackModels: []string{"gpt-4.1-mini"},
|
||||
APIKey: "sk-direct",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "direct system flag",
|
||||
flags: flagValues{
|
||||
system: "You are helpful",
|
||||
},
|
||||
env: envValues{},
|
||||
file: fileValues{},
|
||||
want: ConfigData{
|
||||
Protocol: ProtocolOpenAI,
|
||||
URL: "https://api.ppq.ai/chat/completions",
|
||||
Model: "gpt-4o-mini",
|
||||
FallbackModels: []string{"gpt-4.1-mini"},
|
||||
SystemPrompt: "You are helpful",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "file paths collected",
|
||||
flags: flagValues{
|
||||
files: []string{"a.go", "b.go"},
|
||||
prompts: []string{"prompt1", "prompt2"},
|
||||
promptFile: "prompt.txt",
|
||||
},
|
||||
env: envValues{},
|
||||
file: fileValues{},
|
||||
want: ConfigData{
|
||||
Protocol: ProtocolOpenAI,
|
||||
URL: "https://api.ppq.ai/chat/completions",
|
||||
Model: "gpt-4o-mini",
|
||||
FallbackModels: []string{"gpt-4.1-mini"},
|
||||
FilePaths: []string{"a.go", "b.go"},
|
||||
PromptFlags: []string{"prompt1", "prompt2"},
|
||||
PromptPaths: []string{"prompt.txt"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "stdin file flag",
|
||||
flags: flagValues{
|
||||
stdinFile: true,
|
||||
},
|
||||
env: envValues{},
|
||||
file: fileValues{},
|
||||
want: ConfigData{
|
||||
Protocol: ProtocolOpenAI,
|
||||
URL: "https://api.ppq.ai/chat/completions",
|
||||
Model: "gpt-4o-mini",
|
||||
FallbackModels: []string{"gpt-4.1-mini"},
|
||||
StdinAsFile: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := mergeSources(tt.flags, tt.env, tt.file)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeSourcesKeyFile(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
flags flagValues
|
||||
env envValues
|
||||
file fileValues
|
||||
want ConfigData
|
||||
}{
|
||||
{
|
||||
name: "key file from flags",
|
||||
flags: flagValues{
|
||||
keyFile: "testdata/api.key",
|
||||
},
|
||||
env: envValues{},
|
||||
file: fileValues{},
|
||||
want: ConfigData{
|
||||
Protocol: ProtocolOpenAI,
|
||||
URL: "https://api.ppq.ai/chat/completions",
|
||||
Model: "gpt-4o-mini",
|
||||
FallbackModels: []string{"gpt-4.1-mini"},
|
||||
APIKey: "sk-test-key-123",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "key file from file config",
|
||||
flags: flagValues{},
|
||||
env: envValues{},
|
||||
file: fileValues{
|
||||
keyFile: "testdata/api.key",
|
||||
},
|
||||
want: ConfigData{
|
||||
Protocol: ProtocolOpenAI,
|
||||
URL: "https://api.ppq.ai/chat/completions",
|
||||
Model: "gpt-4o-mini",
|
||||
FallbackModels: []string{"gpt-4.1-mini"},
|
||||
APIKey: "sk-test-key-123",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "direct key overrides key file",
|
||||
flags: flagValues{
|
||||
key: "sk-direct",
|
||||
keyFile: "testdata/api.key",
|
||||
},
|
||||
env: envValues{},
|
||||
file: fileValues{},
|
||||
want: ConfigData{
|
||||
Protocol: ProtocolOpenAI,
|
||||
URL: "https://api.ppq.ai/chat/completions",
|
||||
Model: "gpt-4o-mini",
|
||||
FallbackModels: []string{"gpt-4.1-mini"},
|
||||
APIKey: "sk-direct",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "env key overrides file key file",
|
||||
flags: flagValues{},
|
||||
env: envValues{
|
||||
key: "sk-env",
|
||||
},
|
||||
file: fileValues{
|
||||
keyFile: "testdata/api.key",
|
||||
},
|
||||
want: ConfigData{
|
||||
Protocol: ProtocolOpenAI,
|
||||
URL: "https://api.ppq.ai/chat/completions",
|
||||
Model: "gpt-4o-mini",
|
||||
FallbackModels: []string{"gpt-4.1-mini"},
|
||||
APIKey: "sk-env",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "key file with whitespace trimmed",
|
||||
flags: flagValues{
|
||||
keyFile: "testdata/api_whitespace.key",
|
||||
},
|
||||
env: envValues{},
|
||||
file: fileValues{},
|
||||
want: ConfigData{
|
||||
Protocol: ProtocolOpenAI,
|
||||
URL: "https://api.ppq.ai/chat/completions",
|
||||
Model: "gpt-4o-mini",
|
||||
FallbackModels: []string{"gpt-4.1-mini"},
|
||||
APIKey: "sk-whitespace-key",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := mergeSources(tt.flags, tt.env, tt.file)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeSourcesSystemFile(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
flags flagValues
|
||||
env envValues
|
||||
file fileValues
|
||||
want ConfigData
|
||||
}{
|
||||
{
|
||||
name: "system file from flags",
|
||||
flags: flagValues{
|
||||
systemFile: "testdata/system.txt",
|
||||
},
|
||||
env: envValues{},
|
||||
file: fileValues{},
|
||||
want: ConfigData{
|
||||
Protocol: ProtocolOpenAI,
|
||||
URL: "https://api.ppq.ai/chat/completions",
|
||||
Model: "gpt-4o-mini",
|
||||
FallbackModels: []string{"gpt-4.1-mini"},
|
||||
SystemPrompt: "You are a helpful assistant.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "system file from file config",
|
||||
flags: flagValues{},
|
||||
env: envValues{},
|
||||
file: fileValues{
|
||||
systemFile: "testdata/system.txt",
|
||||
},
|
||||
want: ConfigData{
|
||||
Protocol: ProtocolOpenAI,
|
||||
URL: "https://api.ppq.ai/chat/completions",
|
||||
Model: "gpt-4o-mini",
|
||||
FallbackModels: []string{"gpt-4.1-mini"},
|
||||
SystemPrompt: "You are a helpful assistant.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "direct system overrides system file",
|
||||
flags: flagValues{
|
||||
system: "Direct system",
|
||||
systemFile: "testdata/system.txt",
|
||||
},
|
||||
env: envValues{},
|
||||
file: fileValues{},
|
||||
want: ConfigData{
|
||||
Protocol: ProtocolOpenAI,
|
||||
URL: "https://api.ppq.ai/chat/completions",
|
||||
Model: "gpt-4o-mini",
|
||||
FallbackModels: []string{"gpt-4.1-mini"},
|
||||
SystemPrompt: "Direct system",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "env system overrides file system file",
|
||||
flags: flagValues{},
|
||||
env: envValues{
|
||||
system: "System from env",
|
||||
},
|
||||
file: fileValues{
|
||||
systemFile: "testdata/system.txt",
|
||||
},
|
||||
want: ConfigData{
|
||||
Protocol: ProtocolOpenAI,
|
||||
URL: "https://api.ppq.ai/chat/completions",
|
||||
Model: "gpt-4o-mini",
|
||||
FallbackModels: []string{"gpt-4.1-mini"},
|
||||
SystemPrompt: "System from env",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty system file",
|
||||
flags: flagValues{
|
||||
systemFile: "testdata/system_empty.txt",
|
||||
},
|
||||
env: envValues{},
|
||||
file: fileValues{},
|
||||
want: ConfigData{
|
||||
Protocol: ProtocolOpenAI,
|
||||
URL: "https://api.ppq.ai/chat/completions",
|
||||
Model: "gpt-4o-mini",
|
||||
FallbackModels: []string{"gpt-4.1-mini"},
|
||||
SystemPrompt: "",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := mergeSources(tt.flags, tt.env, tt.file)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
1
config/testdata/api.key
vendored
Normal file
1
config/testdata/api.key
vendored
Normal file
@@ -0,0 +1 @@
|
||||
sk-test-key-123
|
||||
1
config/testdata/api_whitespace.key
vendored
Normal file
1
config/testdata/api_whitespace.key
vendored
Normal file
@@ -0,0 +1 @@
|
||||
sk-whitespace-key
|
||||
0
config/testdata/empty.yaml
vendored
Normal file
0
config/testdata/empty.yaml
vendored
Normal file
3
config/testdata/invalid.yaml
vendored
Normal file
3
config/testdata/invalid.yaml
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
protocol: openai
|
||||
url: [this is not valid yaml syntax
|
||||
model: gpt-4
|
||||
2
config/testdata/partial.yaml
vendored
Normal file
2
config/testdata/partial.yaml
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
model: gpt-4
|
||||
fallback: gpt-3.5-turbo
|
||||
1
config/testdata/system.txt
vendored
Normal file
1
config/testdata/system.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
You are a helpful assistant.
|
||||
0
config/testdata/system_empty.txt
vendored
Normal file
0
config/testdata/system_empty.txt
vendored
Normal file
4
config/testdata/unknown_keys.yaml
vendored
Normal file
4
config/testdata/unknown_keys.yaml
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
protocol: openai
|
||||
model: gpt-4
|
||||
unknown_field: ignored
|
||||
another_unknown: also_ignored
|
||||
6
config/testdata/valid.yaml
vendored
Normal file
6
config/testdata/valid.yaml
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
protocol: ollama
|
||||
url: http://localhost:11434/api/chat
|
||||
key_file: ~/.aicli_key
|
||||
model: llama3
|
||||
fallback: llama2,mistral
|
||||
system_file: ~/system.txt
|
||||
71
config/types.go
Normal file
71
config/types.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package config
|
||||
|
||||
type APIProtocol int
|
||||
|
||||
const (
|
||||
ProtocolOpenAI APIProtocol = iota
|
||||
ProtocolOllama
|
||||
)
|
||||
|
||||
type ConfigData struct {
|
||||
// Input
|
||||
FilePaths []string
|
||||
PromptFlags []string
|
||||
PromptPaths []string
|
||||
StdinAsFile bool
|
||||
|
||||
// System
|
||||
SystemPrompt string
|
||||
|
||||
// API
|
||||
Protocol APIProtocol
|
||||
URL string
|
||||
APIKey string
|
||||
|
||||
// Models
|
||||
Model string
|
||||
FallbackModels []string
|
||||
|
||||
// Output
|
||||
Output string
|
||||
Quiet bool
|
||||
Verbose bool
|
||||
}
|
||||
|
||||
type flagValues struct {
|
||||
files []string
|
||||
prompts []string
|
||||
promptFile string
|
||||
system string
|
||||
systemFile string
|
||||
key string
|
||||
keyFile string
|
||||
protocol string
|
||||
url string
|
||||
model string
|
||||
fallback string
|
||||
output string
|
||||
config string
|
||||
stdinFile bool
|
||||
quiet bool
|
||||
verbose bool
|
||||
version bool
|
||||
}
|
||||
|
||||
type envValues struct {
|
||||
protocol string
|
||||
url string
|
||||
key string
|
||||
model string
|
||||
fallback string
|
||||
system string
|
||||
}
|
||||
|
||||
type fileValues struct {
|
||||
protocol string
|
||||
url string
|
||||
keyFile string
|
||||
model string
|
||||
fallback string
|
||||
systemFile string
|
||||
}
|
||||
17
config/validate.go
Normal file
17
config/validate.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func validateConfig(cfg ConfigData) error {
|
||||
if cfg.APIKey == "" {
|
||||
return fmt.Errorf("API key required: use --key, --key-file, AICLI_API_KEY, AICLI_API_KEY_FILE, or key_file in config")
|
||||
}
|
||||
|
||||
if cfg.Protocol != ProtocolOpenAI && cfg.Protocol != ProtocolOllama {
|
||||
return fmt.Errorf("invalid protocol: must be openai or ollama")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
74
config/validate_test.go
Normal file
74
config/validate_test.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateConfig(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg ConfigData
|
||||
wantErr bool
|
||||
errMsg string
|
||||
}{
|
||||
{
|
||||
name: "valid config",
|
||||
cfg: ConfigData{
|
||||
Protocol: ProtocolOpenAI,
|
||||
URL: "https://api.openai.com",
|
||||
Model: "gpt-4",
|
||||
FallbackModels: []string{"gpt-3.5"},
|
||||
APIKey: "sk-test123",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "missing api key",
|
||||
cfg: ConfigData{
|
||||
Protocol: ProtocolOpenAI,
|
||||
URL: "https://api.openai.com",
|
||||
Model: "gpt-4",
|
||||
FallbackModels: []string{"gpt-3.5"},
|
||||
APIKey: "",
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "API key required",
|
||||
},
|
||||
{
|
||||
name: "invalid protocol",
|
||||
cfg: ConfigData{
|
||||
Protocol: APIProtocol(99),
|
||||
URL: "https://api.openai.com",
|
||||
Model: "gpt-4",
|
||||
FallbackModels: []string{"gpt-3.5"},
|
||||
APIKey: "sk-test123",
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "invalid protocol",
|
||||
},
|
||||
{
|
||||
name: "ollama protocol valid",
|
||||
cfg: ConfigData{
|
||||
Protocol: ProtocolOllama,
|
||||
URL: "http://localhost:11434",
|
||||
Model: "llama3",
|
||||
FallbackModels: []string{},
|
||||
APIKey: "not-used-but-required",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateConfig(tt.cfg)
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.errMsg)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
8
go.mod
8
go.mod
@@ -2,4 +2,10 @@ module git.wisehodl.dev/jay/aicli
|
||||
|
||||
go 1.23.5
|
||||
|
||||
require gopkg.in/yaml.v3 v3.0.1
|
||||
require github.com/stretchr/testify v1.11.1
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
6
go.sum
6
go.sum
@@ -1,3 +1,9 @@
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
32
input/aggregate.go
Normal file
32
input/aggregate.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package input
|
||||
|
||||
// AggregatePrompts combines prompt sources with stdin based on role.
|
||||
func AggregatePrompts(prompts []string, stdin string, role StdinRole) []string {
|
||||
switch role {
|
||||
case StdinAsPrompt:
|
||||
if stdin != "" {
|
||||
return []string{stdin}
|
||||
}
|
||||
return prompts
|
||||
|
||||
case StdinAsPrefixedContent:
|
||||
if stdin != "" {
|
||||
return append(prompts, stdin)
|
||||
}
|
||||
return prompts
|
||||
|
||||
case StdinAsFile:
|
||||
return prompts
|
||||
|
||||
default:
|
||||
return prompts
|
||||
}
|
||||
}
|
||||
|
||||
// AggregateFiles combines file sources with stdin based on role.
|
||||
func AggregateFiles(files []FileData, stdin string, role StdinRole) []FileData {
|
||||
if role == StdinAsFile && stdin != "" {
|
||||
return append([]FileData{{Path: "input", Content: stdin}}, files...)
|
||||
}
|
||||
return files
|
||||
}
|
||||
174
input/aggregate_test.go
Normal file
174
input/aggregate_test.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package input
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestAggregatePrompts(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
prompts []string
|
||||
stdin string
|
||||
role StdinRole
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "empty inputs returns empty",
|
||||
prompts: []string{},
|
||||
stdin: "",
|
||||
role: StdinAsPrompt,
|
||||
want: []string{},
|
||||
},
|
||||
{
|
||||
name: "stdin as prompt with no other prompts",
|
||||
prompts: []string{},
|
||||
stdin: "stdin content",
|
||||
role: StdinAsPrompt,
|
||||
want: []string{"stdin content"},
|
||||
},
|
||||
{
|
||||
name: "stdin as prompt replaces existing prompts",
|
||||
prompts: []string{"prompt1", "prompt2"},
|
||||
stdin: "stdin content",
|
||||
role: StdinAsPrompt,
|
||||
want: []string{"stdin content"},
|
||||
},
|
||||
{
|
||||
name: "no stdin with role prompt returns prompts unchanged",
|
||||
prompts: []string{"prompt1", "prompt2"},
|
||||
stdin: "",
|
||||
role: StdinAsPrompt,
|
||||
want: []string{"prompt1", "prompt2"},
|
||||
},
|
||||
{
|
||||
name: "stdin as prefixed appends to prompts",
|
||||
prompts: []string{"prompt1", "prompt2"},
|
||||
stdin: "stdin content",
|
||||
role: StdinAsPrefixedContent,
|
||||
want: []string{"prompt1", "prompt2", "stdin content"},
|
||||
},
|
||||
{
|
||||
name: "stdin as prefixed with no prompts",
|
||||
prompts: []string{},
|
||||
stdin: "stdin content",
|
||||
role: StdinAsPrefixedContent,
|
||||
want: []string{"stdin content"},
|
||||
},
|
||||
{
|
||||
name: "no stdin with role prefixed returns prompts unchanged",
|
||||
prompts: []string{"prompt1"},
|
||||
stdin: "",
|
||||
role: StdinAsPrefixedContent,
|
||||
want: []string{"prompt1"},
|
||||
},
|
||||
{
|
||||
name: "stdin as file excludes stdin from prompts",
|
||||
prompts: []string{"prompt1"},
|
||||
stdin: "stdin content",
|
||||
role: StdinAsFile,
|
||||
want: []string{"prompt1"},
|
||||
},
|
||||
{
|
||||
name: "no stdin with role file returns prompts unchanged",
|
||||
prompts: []string{"prompt1"},
|
||||
stdin: "",
|
||||
role: StdinAsFile,
|
||||
want: []string{"prompt1"},
|
||||
},
|
||||
{
|
||||
name: "empty string stdin with role prompt",
|
||||
prompts: []string{"prompt1"},
|
||||
stdin: "",
|
||||
role: StdinAsPrompt,
|
||||
want: []string{"prompt1"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := AggregatePrompts(tt.prompts, tt.stdin, tt.role)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAggregateFiles(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
files []FileData
|
||||
stdin string
|
||||
role StdinRole
|
||||
want []FileData
|
||||
}{
|
||||
{
|
||||
name: "empty inputs returns empty",
|
||||
files: []FileData{},
|
||||
stdin: "",
|
||||
role: StdinAsFile,
|
||||
want: []FileData{},
|
||||
},
|
||||
{
|
||||
name: "stdin as file prepends to files",
|
||||
files: []FileData{{Path: "a.go", Content: "code"}},
|
||||
stdin: "stdin content",
|
||||
role: StdinAsFile,
|
||||
want: []FileData{
|
||||
{Path: "input", Content: "stdin content"},
|
||||
{Path: "a.go", Content: "code"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "stdin as file with no other files",
|
||||
files: []FileData{},
|
||||
stdin: "stdin content",
|
||||
role: StdinAsFile,
|
||||
want: []FileData{
|
||||
{Path: "input", Content: "stdin content"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no stdin with role file returns files unchanged",
|
||||
files: []FileData{{Path: "a.go", Content: "code"}},
|
||||
stdin: "",
|
||||
role: StdinAsFile,
|
||||
want: []FileData{{Path: "a.go", Content: "code"}},
|
||||
},
|
||||
{
|
||||
name: "stdin as prompt excludes stdin from files",
|
||||
files: []FileData{{Path: "a.go", Content: "code"}},
|
||||
stdin: "stdin content",
|
||||
role: StdinAsPrompt,
|
||||
want: []FileData{{Path: "a.go", Content: "code"}},
|
||||
},
|
||||
{
|
||||
name: "stdin as prefixed excludes stdin from files",
|
||||
files: []FileData{{Path: "a.go", Content: "code"}},
|
||||
stdin: "stdin content",
|
||||
role: StdinAsPrefixedContent,
|
||||
want: []FileData{{Path: "a.go", Content: "code"}},
|
||||
},
|
||||
{
|
||||
name: "stdin as file with multiple files",
|
||||
files: []FileData{
|
||||
{Path: "a.go", Content: "code a"},
|
||||
{Path: "b.go", Content: "code b"},
|
||||
},
|
||||
stdin: "stdin content",
|
||||
role: StdinAsFile,
|
||||
want: []FileData{
|
||||
{Path: "input", Content: "stdin content"},
|
||||
{Path: "a.go", Content: "code a"},
|
||||
{Path: "b.go", Content: "code b"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := AggregateFiles(tt.files, tt.stdin, tt.role)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
39
input/input.go
Normal file
39
input/input.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package input
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"git.wisehodl.dev/jay/aicli/config"
|
||||
)
|
||||
|
||||
// ResolveInputs orchestrates the complete input resolution pipeline.
|
||||
// Returns aggregated prompts and files ready for query construction.
|
||||
func ResolveInputs(cfg config.ConfigData, stdinContent string, hasStdin bool) (InputData, error) {
|
||||
// Determine stdin role (CA -> CB)
|
||||
role := DetermineRole(cfg, hasStdin)
|
||||
|
||||
// Read all sources (CC, CD)
|
||||
prompts, err := ReadPromptSources(cfg)
|
||||
if err != nil {
|
||||
return InputData{}, err
|
||||
}
|
||||
|
||||
files, err := ReadFileSources(cfg)
|
||||
if err != nil {
|
||||
return InputData{}, err
|
||||
}
|
||||
|
||||
// Aggregate with stdin (CE, CF)
|
||||
finalPrompts := AggregatePrompts(prompts, stdinContent, role)
|
||||
finalFiles := AggregateFiles(files, stdinContent, role)
|
||||
|
||||
// Validate at least one input exists
|
||||
if len(finalPrompts) == 0 && len(finalFiles) == 0 {
|
||||
return InputData{}, fmt.Errorf("no input provided: supply stdin, --file, or --prompt")
|
||||
}
|
||||
|
||||
return InputData{
|
||||
Prompts: finalPrompts,
|
||||
Files: finalFiles,
|
||||
}, nil
|
||||
}
|
||||
206
input/input_test.go
Normal file
206
input/input_test.go
Normal file
@@ -0,0 +1,206 @@
|
||||
package input
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.wisehodl.dev/jay/aicli/config"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestResolveInputs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg config.ConfigData
|
||||
stdinContent string
|
||||
hasStdin bool
|
||||
want InputData
|
||||
wantErr bool
|
||||
errContains string
|
||||
}{
|
||||
{
|
||||
name: "no input returns error",
|
||||
cfg: config.ConfigData{},
|
||||
stdinContent: "",
|
||||
hasStdin: false,
|
||||
wantErr: true,
|
||||
errContains: "no input provided",
|
||||
},
|
||||
{
|
||||
name: "stdin only as prompt",
|
||||
cfg: config.ConfigData{},
|
||||
stdinContent: "analyze this",
|
||||
hasStdin: true,
|
||||
want: InputData{
|
||||
Prompts: []string{"analyze this"},
|
||||
Files: []FileData{},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "prompt flag only",
|
||||
cfg: config.ConfigData{
|
||||
PromptFlags: []string{"test prompt"},
|
||||
},
|
||||
stdinContent: "",
|
||||
hasStdin: false,
|
||||
want: InputData{
|
||||
Prompts: []string{"test prompt"},
|
||||
Files: []FileData{},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "file flag only",
|
||||
cfg: config.ConfigData{
|
||||
FilePaths: []string{"testdata/code.go"},
|
||||
},
|
||||
stdinContent: "",
|
||||
hasStdin: false,
|
||||
want: InputData{
|
||||
Prompts: []string{},
|
||||
Files: []FileData{
|
||||
{Path: "testdata/code.go", Content: "package main\n\nfunc main() {\n\tprintln(\"hello\")\n}\n"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "stdin as file with -F flag",
|
||||
cfg: config.ConfigData{
|
||||
StdinAsFile: true,
|
||||
},
|
||||
stdinContent: "stdin content",
|
||||
hasStdin: true,
|
||||
want: InputData{
|
||||
Prompts: []string{},
|
||||
Files: []FileData{
|
||||
{Path: "input", Content: "stdin content"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "stdin as file with -F and explicit files",
|
||||
cfg: config.ConfigData{
|
||||
StdinAsFile: true,
|
||||
FilePaths: []string{"testdata/code.go"},
|
||||
},
|
||||
stdinContent: "stdin content",
|
||||
hasStdin: true,
|
||||
want: InputData{
|
||||
Prompts: []string{},
|
||||
Files: []FileData{
|
||||
{Path: "input", Content: "stdin content"},
|
||||
{Path: "testdata/code.go", Content: "package main\n\nfunc main() {\n\tprintln(\"hello\")\n}\n"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "stdin prefixed with explicit prompt",
|
||||
cfg: config.ConfigData{
|
||||
PromptFlags: []string{"analyze"},
|
||||
},
|
||||
stdinContent: "code to analyze",
|
||||
hasStdin: true,
|
||||
want: InputData{
|
||||
Prompts: []string{"analyze", "code to analyze"},
|
||||
Files: []FileData{},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "prompt from file",
|
||||
cfg: config.ConfigData{
|
||||
PromptPaths: []string{"testdata/prompt1.txt"},
|
||||
},
|
||||
stdinContent: "",
|
||||
hasStdin: false,
|
||||
want: InputData{
|
||||
Prompts: []string{"Analyze the following code.\n"},
|
||||
Files: []FileData{},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "complete scenario: prompts, files, stdin",
|
||||
cfg: config.ConfigData{
|
||||
PromptFlags: []string{"review this"},
|
||||
FilePaths: []string{"testdata/code.go"},
|
||||
},
|
||||
stdinContent: "additional context",
|
||||
hasStdin: true,
|
||||
want: InputData{
|
||||
Prompts: []string{"review this", "additional context"},
|
||||
Files: []FileData{
|
||||
{Path: "testdata/code.go", Content: "package main\n\nfunc main() {\n\tprintln(\"hello\")\n}\n"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "file read error propagates",
|
||||
cfg: config.ConfigData{
|
||||
FilePaths: []string{"testdata/nonexistent.go"},
|
||||
},
|
||||
stdinContent: "",
|
||||
hasStdin: false,
|
||||
wantErr: true,
|
||||
errContains: "read file",
|
||||
},
|
||||
{
|
||||
name: "prompt file read error propagates",
|
||||
cfg: config.ConfigData{
|
||||
PromptPaths: []string{"testdata/missing.txt"},
|
||||
},
|
||||
stdinContent: "",
|
||||
hasStdin: false,
|
||||
wantErr: true,
|
||||
errContains: "read prompt file",
|
||||
},
|
||||
{
|
||||
name: "empty file path error propagates",
|
||||
cfg: config.ConfigData{
|
||||
FilePaths: []string{""},
|
||||
},
|
||||
stdinContent: "",
|
||||
hasStdin: false,
|
||||
wantErr: true,
|
||||
errContains: "empty file path",
|
||||
},
|
||||
{
|
||||
name: "stdin replaces prompts when no explicit flags",
|
||||
cfg: config.ConfigData{
|
||||
PromptFlags: []string{},
|
||||
},
|
||||
stdinContent: "stdin prompt",
|
||||
hasStdin: true,
|
||||
want: InputData{
|
||||
Prompts: []string{"stdin prompt"},
|
||||
Files: []FileData{},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple files in order",
|
||||
cfg: config.ConfigData{
|
||||
FilePaths: []string{"testdata/code.go", "testdata/data.json"},
|
||||
},
|
||||
stdinContent: "",
|
||||
hasStdin: false,
|
||||
want: InputData{
|
||||
Prompts: []string{},
|
||||
Files: []FileData{
|
||||
{Path: "testdata/code.go", Content: "package main\n\nfunc main() {\n\tprintln(\"hello\")\n}\n"},
|
||||
{Path: "testdata/data.json", Content: "{\n \"name\": \"test\",\n \"value\": 42\n}\n"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := ResolveInputs(tt.cfg, tt.stdinContent, tt.hasStdin)
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
if tt.errContains != "" {
|
||||
assert.Contains(t, err.Error(), tt.errContains)
|
||||
}
|
||||
return
|
||||
}
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
26
input/role.go
Normal file
26
input/role.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package input
|
||||
|
||||
import "git.wisehodl.dev/jay/aicli/config"
|
||||
|
||||
// DetermineRole decides how stdin content participates in the query based on
|
||||
// flags and stdin presence. Per spec §7 rules.
|
||||
func DetermineRole(cfg config.ConfigData, hasStdin bool) StdinRole {
|
||||
if !hasStdin {
|
||||
return StdinAsPrompt // unused, but set for consistency
|
||||
}
|
||||
|
||||
// Explicit -F flag forces stdin as file
|
||||
if cfg.StdinAsFile {
|
||||
return StdinAsFile
|
||||
}
|
||||
|
||||
// Any explicit prompt flag (-p or -pf) makes stdin prefixed content
|
||||
hasExplicitPrompt := len(cfg.PromptFlags) > 0 || len(cfg.PromptPaths) > 0
|
||||
|
||||
if hasExplicitPrompt {
|
||||
return StdinAsPrefixedContent
|
||||
}
|
||||
|
||||
// Default: stdin replaces any default prompt
|
||||
return StdinAsPrompt
|
||||
}
|
||||
95
input/role_test.go
Normal file
95
input/role_test.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package input
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.wisehodl.dev/jay/aicli/config"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestDetermineRole(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg config.ConfigData
|
||||
hasStdin bool
|
||||
want StdinRole
|
||||
}{
|
||||
{
|
||||
name: "no stdin returns StdinAsPrompt",
|
||||
cfg: config.ConfigData{},
|
||||
hasStdin: false,
|
||||
want: StdinAsPrompt,
|
||||
},
|
||||
{
|
||||
name: "stdin with no flags returns StdinAsPrompt",
|
||||
cfg: config.ConfigData{},
|
||||
hasStdin: true,
|
||||
want: StdinAsPrompt,
|
||||
},
|
||||
{
|
||||
name: "stdin with -p flag returns StdinAsPrefixedContent",
|
||||
cfg: config.ConfigData{
|
||||
PromptFlags: []string{"analyze this"},
|
||||
},
|
||||
hasStdin: true,
|
||||
want: StdinAsPrefixedContent,
|
||||
},
|
||||
{
|
||||
name: "stdin with -pf flag returns StdinAsPrefixedContent",
|
||||
cfg: config.ConfigData{
|
||||
PromptPaths: []string{"prompt.txt"},
|
||||
},
|
||||
hasStdin: true,
|
||||
want: StdinAsPrefixedContent,
|
||||
},
|
||||
{
|
||||
name: "stdin with -F flag returns StdinAsFile",
|
||||
cfg: config.ConfigData{
|
||||
StdinAsFile: true,
|
||||
},
|
||||
hasStdin: true,
|
||||
want: StdinAsFile,
|
||||
},
|
||||
{
|
||||
name: "stdin with -F and -p returns StdinAsFile (explicit wins)",
|
||||
cfg: config.ConfigData{
|
||||
StdinAsFile: true,
|
||||
PromptFlags: []string{"analyze"},
|
||||
},
|
||||
hasStdin: true,
|
||||
want: StdinAsFile,
|
||||
},
|
||||
{
|
||||
name: "stdin with file flags returns StdinAsPrompt",
|
||||
cfg: config.ConfigData{
|
||||
FilePaths: []string{"main.go"},
|
||||
},
|
||||
hasStdin: true,
|
||||
want: StdinAsPrompt,
|
||||
},
|
||||
{
|
||||
name: "no stdin with -F returns StdinAsFile (role set but unused)",
|
||||
cfg: config.ConfigData{
|
||||
StdinAsFile: true,
|
||||
},
|
||||
hasStdin: false,
|
||||
want: StdinAsPrompt,
|
||||
},
|
||||
{
|
||||
name: "stdin with both -p and -pf returns StdinAsPrefixedContent",
|
||||
cfg: config.ConfigData{
|
||||
PromptFlags: []string{"first"},
|
||||
PromptPaths: []string{"prompt.txt"},
|
||||
},
|
||||
hasStdin: true,
|
||||
want: StdinAsPrefixedContent,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := DetermineRole(tt.cfg, tt.hasStdin)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
52
input/sources.go
Normal file
52
input/sources.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package input
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"git.wisehodl.dev/jay/aicli/config"
|
||||
)
|
||||
|
||||
// ReadPromptSources reads all prompt content from flags and files.
|
||||
// Returns arrays of prompt strings in source order.
|
||||
func ReadPromptSources(cfg config.ConfigData) ([]string, error) {
|
||||
prompts := []string{}
|
||||
|
||||
// Add flag prompts first
|
||||
prompts = append(prompts, cfg.PromptFlags...)
|
||||
|
||||
// Add prompt file contents
|
||||
for _, path := range cfg.PromptPaths {
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read prompt file %s: %w", path, err)
|
||||
}
|
||||
prompts = append(prompts, string(content))
|
||||
}
|
||||
|
||||
return prompts, nil
|
||||
}
|
||||
|
||||
// ReadFileSources reads all input files specified in config.
|
||||
// Returns FileData array in source order.
|
||||
func ReadFileSources(cfg config.ConfigData) ([]FileData, error) {
|
||||
files := []FileData{}
|
||||
|
||||
for _, path := range cfg.FilePaths {
|
||||
if path == "" {
|
||||
return nil, fmt.Errorf("empty file path provided")
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read file %s: %w", path, err)
|
||||
}
|
||||
|
||||
files = append(files, FileData{
|
||||
Path: path,
|
||||
Content: string(content),
|
||||
})
|
||||
}
|
||||
|
||||
return files, nil
|
||||
}
|
||||
183
input/sources_test.go
Normal file
183
input/sources_test.go
Normal file
@@ -0,0 +1,183 @@
|
||||
package input
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.wisehodl.dev/jay/aicli/config"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestReadPromptSources(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg config.ConfigData
|
||||
want []string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "no prompts returns empty",
|
||||
cfg: config.ConfigData{},
|
||||
want: []string{},
|
||||
},
|
||||
{
|
||||
name: "single flag prompt",
|
||||
cfg: config.ConfigData{
|
||||
PromptFlags: []string{"analyze this"},
|
||||
},
|
||||
want: []string{"analyze this"},
|
||||
},
|
||||
{
|
||||
name: "multiple flag prompts",
|
||||
cfg: config.ConfigData{
|
||||
PromptFlags: []string{"first", "second", "third"},
|
||||
},
|
||||
want: []string{"first", "second", "third"},
|
||||
},
|
||||
{
|
||||
name: "single prompt file",
|
||||
cfg: config.ConfigData{
|
||||
PromptPaths: []string{"testdata/prompt1.txt"},
|
||||
},
|
||||
want: []string{"Analyze the following code.\n"},
|
||||
},
|
||||
{
|
||||
name: "multiple prompt files",
|
||||
cfg: config.ConfigData{
|
||||
PromptPaths: []string{"testdata/prompt1.txt", "testdata/prompt2.txt"},
|
||||
},
|
||||
want: []string{
|
||||
"Analyze the following code.\n",
|
||||
"Focus on:\n- Performance\n- Security\n- Readability\n",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty prompt file",
|
||||
cfg: config.ConfigData{
|
||||
PromptPaths: []string{"testdata/prompt_empty.txt"},
|
||||
},
|
||||
want: []string{""},
|
||||
},
|
||||
{
|
||||
name: "flags and files combined",
|
||||
cfg: config.ConfigData{
|
||||
PromptFlags: []string{"first flag", "second flag"},
|
||||
PromptPaths: []string{"testdata/prompt1.txt"},
|
||||
},
|
||||
want: []string{
|
||||
"first flag",
|
||||
"second flag",
|
||||
"Analyze the following code.\n",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "file not found",
|
||||
cfg: config.ConfigData{
|
||||
PromptPaths: []string{"testdata/nonexistent.txt"},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "mixed valid and invalid",
|
||||
cfg: config.ConfigData{
|
||||
PromptFlags: []string{"valid flag"},
|
||||
PromptPaths: []string{"testdata/nonexistent.txt"},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := ReadPromptSources(tt.cfg)
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadFileSources(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg config.ConfigData
|
||||
want []FileData
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "no files returns empty",
|
||||
cfg: config.ConfigData{},
|
||||
want: []FileData{},
|
||||
},
|
||||
{
|
||||
name: "single file",
|
||||
cfg: config.ConfigData{
|
||||
FilePaths: []string{"testdata/code.go"},
|
||||
},
|
||||
want: []FileData{
|
||||
{
|
||||
Path: "testdata/code.go",
|
||||
Content: "package main\n\nfunc main() {\n\tprintln(\"hello\")\n}\n",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple files",
|
||||
cfg: config.ConfigData{
|
||||
FilePaths: []string{"testdata/code.go", "testdata/data.json"},
|
||||
},
|
||||
want: []FileData{
|
||||
{
|
||||
Path: "testdata/code.go",
|
||||
Content: "package main\n\nfunc main() {\n\tprintln(\"hello\")\n}\n",
|
||||
},
|
||||
{
|
||||
Path: "testdata/data.json",
|
||||
Content: "{\n \"name\": \"test\",\n \"value\": 42\n}\n",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty file path",
|
||||
cfg: config.ConfigData{
|
||||
FilePaths: []string{""},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "file not found",
|
||||
cfg: config.ConfigData{
|
||||
FilePaths: []string{"testdata/nonexistent.go"},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "permission denied",
|
||||
cfg: config.ConfigData{
|
||||
FilePaths: []string{"/root/secret.txt"},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "mixed valid and invalid",
|
||||
cfg: config.ConfigData{
|
||||
FilePaths: []string{"testdata/code.go", "testdata/nonexistent.go"},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := ReadFileSources(tt.cfg)
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
28
input/stdin.go
Normal file
28
input/stdin.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package input
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
// DetectStdin checks if stdin contains piped data and reads it.
|
||||
// Returns content and true if stdin is a pipe/file, empty string and false if terminal.
|
||||
func DetectStdin() (string, bool) {
|
||||
stat, err := os.Stdin.Stat()
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// Terminal (character device) = no stdin data
|
||||
if (stat.Mode() & os.ModeCharDevice) != 0 {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// Pipe or file redirection detected
|
||||
content, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return string(content), true
|
||||
}
|
||||
27
input/stdin_test.go
Normal file
27
input/stdin_test.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package input
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// Integration test helper - run manually with:
|
||||
// echo "test" | STDIN_TEST=1 go test ./input/ -run TestDetectStdinIntegration
|
||||
func TestDetectStdinIntegration(t *testing.T) {
|
||||
if os.Getenv("STDIN_TEST") != "1" {
|
||||
t.Skip("Set STDIN_TEST=1 and pipe data to run this test")
|
||||
}
|
||||
|
||||
content, hasStdin := DetectStdin()
|
||||
|
||||
t.Logf("hasStdin: %v", hasStdin)
|
||||
t.Logf("content length: %d", len(content))
|
||||
t.Logf("content: %q", content)
|
||||
|
||||
// When piped: should detect stdin
|
||||
if hasStdin {
|
||||
assert.NotEmpty(t, content, "Expected content when stdin detected")
|
||||
}
|
||||
}
|
||||
5
input/testdata/code.go
vendored
Normal file
5
input/testdata/code.go
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
package main
|
||||
|
||||
func main() {
|
||||
println("hello")
|
||||
}
|
||||
4
input/testdata/data.json
vendored
Normal file
4
input/testdata/data.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "test",
|
||||
"value": 42
|
||||
}
|
||||
1
input/testdata/prompt1.txt
vendored
Normal file
1
input/testdata/prompt1.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
Analyze the following code.
|
||||
4
input/testdata/prompt2.txt
vendored
Normal file
4
input/testdata/prompt2.txt
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
Focus on:
|
||||
- Performance
|
||||
- Security
|
||||
- Readability
|
||||
0
input/testdata/prompt_empty.txt
vendored
Normal file
0
input/testdata/prompt_empty.txt
vendored
Normal file
27
input/types.go
Normal file
27
input/types.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package input
|
||||
|
||||
// StdinRole determines how stdin content participates in the query
|
||||
type StdinRole int
|
||||
|
||||
const (
|
||||
// StdinAsPrompt: stdin becomes the entire prompt (replaces other prompts)
|
||||
StdinAsPrompt StdinRole = iota
|
||||
|
||||
// StdinAsPrefixedContent: stdin appends after explicit prompts
|
||||
StdinAsPrefixedContent
|
||||
|
||||
// StdinAsFile: stdin becomes first file in files array
|
||||
StdinAsFile
|
||||
)
|
||||
|
||||
// FileData represents a single input file
|
||||
type FileData struct {
|
||||
Path string
|
||||
Content string
|
||||
}
|
||||
|
||||
// InputData holds all resolved input streams after aggregation
|
||||
type InputData struct {
|
||||
Prompts []string
|
||||
Files []FileData
|
||||
}
|
||||
789
main.go
789
main.go
@@ -1,154 +1,17 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.wisehodl.dev/jay/aicli/api"
|
||||
"git.wisehodl.dev/jay/aicli/config"
|
||||
"git.wisehodl.dev/jay/aicli/input"
|
||||
"git.wisehodl.dev/jay/aicli/output"
|
||||
"git.wisehodl.dev/jay/aicli/prompt"
|
||||
"git.wisehodl.dev/jay/aicli/version"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const defaultPrompt = "Analyze the following:"
|
||||
|
||||
type stdinRole int
|
||||
|
||||
const (
|
||||
stdinAsPrompt stdinRole = iota
|
||||
stdinAsPrefixedContent
|
||||
stdinAsFile
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Protocol string
|
||||
URL string
|
||||
Key string
|
||||
Model string
|
||||
Fallbacks []string
|
||||
SystemText string
|
||||
PromptText string
|
||||
Files []FileData
|
||||
OutputPath string
|
||||
Quiet bool
|
||||
Verbose bool
|
||||
}
|
||||
|
||||
type FileData struct {
|
||||
Path string
|
||||
Content string
|
||||
}
|
||||
|
||||
type flagValues struct {
|
||||
files []string
|
||||
prompts []string
|
||||
promptFile string
|
||||
system string
|
||||
systemFile string
|
||||
key string
|
||||
keyFile string
|
||||
protocol string
|
||||
url string
|
||||
model string
|
||||
fallback string
|
||||
output string
|
||||
config string
|
||||
stdinFile bool
|
||||
quiet bool
|
||||
verbose bool
|
||||
showVersion bool
|
||||
}
|
||||
|
||||
const usageText = `Usage: aicli [OPTION]... [FILE]...
|
||||
Send files and prompts to LLM chat endpoints.
|
||||
|
||||
With no FILE, or when FILE is -, read standard input.
|
||||
|
||||
Global:
|
||||
--version display version information and exit
|
||||
|
||||
Input:
|
||||
-f, --file PATH input file (repeatable)
|
||||
-F, --stdin-file treat stdin as file contents
|
||||
-p, --prompt TEXT prompt text (repeatable, can be combined with --prompt-file)
|
||||
-pf, --prompt-file PATH prompt from file (combined with any --prompt flags)
|
||||
|
||||
System:
|
||||
-s, --system TEXT system prompt text
|
||||
-sf, --system-file PATH system prompt from file
|
||||
|
||||
API:
|
||||
-l, --protocol PROTO API protocol: openai, ollama (default: openai)
|
||||
-u, --url URL API endpoint (default: https://api.ppq.ai/chat/completions)
|
||||
-k, --key KEY API key (if present, --key-file is ignored)
|
||||
-kf, --key-file PATH API key from file (used only if --key is not provided)
|
||||
|
||||
Models:
|
||||
-m, --model NAME primary model (default: gpt-4o-mini)
|
||||
-b, --fallback NAMES comma-separated fallback models (default: gpt-4.1-mini)
|
||||
|
||||
Output:
|
||||
-o, --output PATH write to file instead of stdout
|
||||
-q, --quiet suppress progress output
|
||||
-v, --verbose enable debug logging
|
||||
|
||||
Config:
|
||||
-c, --config PATH YAML config file
|
||||
|
||||
Environment variables:
|
||||
AICLI_API_KEY API key
|
||||
AICLI_API_KEY_FILE Path to file containing API key (used only if AICLI_API_KEY is not set)
|
||||
AICLI_PROTOCOL API protocol
|
||||
AICLI_URL API endpoint
|
||||
AICLI_MODEL primary model
|
||||
AICLI_FALLBACK fallback models
|
||||
AICLI_SYSTEM system prompt
|
||||
AICLI_DEFAULT_PROMPT default prompt override
|
||||
AICLI_CONFIG_FILE Path to config file
|
||||
AICLI_PROMPT_FILE Path to prompt file
|
||||
AICLI_SYSTEM_FILE Path to system file
|
||||
|
||||
API Key precedence: --key flag > --key-file flag > AICLI_API_KEY > AICLI_API_KEY_FILE > config file
|
||||
|
||||
Examples:
|
||||
echo "What is Rust?" | aicli
|
||||
cat file.txt | aicli -F -p "Analyze this file"
|
||||
aicli -f main.go -p "Review this code"
|
||||
aicli -c ~/.aicli.yaml -f src/*.go -o analysis.md
|
||||
aicli -p "First prompt" -pf prompt.txt -p "Last prompt"
|
||||
`
|
||||
|
||||
func printUsage() {
|
||||
fmt.Fprint(os.Stderr, usageText)
|
||||
}
|
||||
|
||||
type fileList []string
|
||||
|
||||
func (f *fileList) String() string {
|
||||
return strings.Join(*f, ", ")
|
||||
}
|
||||
|
||||
func (f *fileList) Set(value string) error {
|
||||
*f = append(*f, value)
|
||||
return nil
|
||||
}
|
||||
|
||||
type promptList []string
|
||||
|
||||
func (p *promptList) String() string {
|
||||
return strings.Join(*p, "\n")
|
||||
}
|
||||
|
||||
func (p *promptList) Set(value string) error {
|
||||
*p = append(*p, value)
|
||||
return nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
@@ -157,612 +20,64 @@ func main() {
|
||||
}
|
||||
|
||||
func run() error {
|
||||
// Check for verbose flag early
|
||||
verbose := false
|
||||
for _, arg := range os.Args {
|
||||
if arg == "-v" || arg == "--verbose" {
|
||||
verbose = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Check for config file in environment variable before parsing flags
|
||||
configFilePath := os.Getenv("AICLI_CONFIG_FILE")
|
||||
|
||||
flags := parseFlags()
|
||||
|
||||
if flags.showVersion {
|
||||
// Phase 1: Version check (early exit)
|
||||
if config.IsVersionRequest(os.Args[1:]) {
|
||||
fmt.Printf("aicli %s\n", version.GetVersion())
|
||||
return nil
|
||||
}
|
||||
|
||||
if flags.config == "" && configFilePath != "" {
|
||||
flags.config = configFilePath
|
||||
}
|
||||
|
||||
envVals := loadEnvVars(verbose)
|
||||
fileVals, err := loadConfigFile(flags.config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
merged := mergeConfigSources(verbose, flags, envVals, fileVals)
|
||||
if err := validateConfig(merged); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if promptFilePath := os.Getenv("AICLI_PROMPT_FILE"); promptFilePath != "" && flags.promptFile == "" {
|
||||
content, err := os.ReadFile(promptFilePath)
|
||||
if err != nil {
|
||||
if verbose {
|
||||
fmt.Fprintf(os.Stderr, "[verbose] Failed to read AICLI_PROMPT_FILE at %s: %v\n", promptFilePath, err)
|
||||
}
|
||||
} else {
|
||||
merged.PromptText = string(content)
|
||||
}
|
||||
}
|
||||
|
||||
if systemFilePath := os.Getenv("AICLI_SYSTEM_FILE"); systemFilePath != "" && flags.systemFile == "" && flags.system == "" {
|
||||
content, err := os.ReadFile(systemFilePath)
|
||||
if err != nil {
|
||||
if verbose {
|
||||
fmt.Fprintf(os.Stderr, "[verbose] Failed to read AICLI_SYSTEM_FILE at %s: %v\n", systemFilePath, err)
|
||||
}
|
||||
} else {
|
||||
merged.SystemText = string(content)
|
||||
}
|
||||
}
|
||||
|
||||
stdinContent, hasStdin := detectStdin()
|
||||
role := determineStdinRole(flags, hasStdin)
|
||||
|
||||
inputData, err := resolveInputStreams(merged, stdinContent, hasStdin, role, flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
config := buildCompletePrompt(inputData)
|
||||
|
||||
if config.Verbose {
|
||||
logVerbose("Configuration resolved", config)
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
response, usedModel, err := sendChatRequest(config)
|
||||
duration := time.Since(startTime)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return writeOutput(response, usedModel, duration, config)
|
||||
}
|
||||
|
||||
func parseFlags() flagValues {
|
||||
fv := flagValues{}
|
||||
var files fileList
|
||||
var prompts promptList
|
||||
|
||||
flag.Usage = printUsage
|
||||
|
||||
flag.Var(&files, "f", "")
|
||||
flag.Var(&files, "file", "")
|
||||
flag.Var(&prompts, "p", "")
|
||||
flag.Var(&prompts, "prompt", "")
|
||||
flag.StringVar(&fv.promptFile, "pf", "", "")
|
||||
flag.StringVar(&fv.promptFile, "prompt-file", "", "")
|
||||
flag.StringVar(&fv.system, "s", "", "")
|
||||
flag.StringVar(&fv.system, "system", "", "")
|
||||
flag.StringVar(&fv.systemFile, "sf", "", "")
|
||||
flag.StringVar(&fv.systemFile, "system-file", "", "")
|
||||
flag.StringVar(&fv.key, "k", "", "")
|
||||
flag.StringVar(&fv.key, "key", "", "")
|
||||
flag.StringVar(&fv.keyFile, "kf", "", "")
|
||||
flag.StringVar(&fv.keyFile, "key-file", "", "")
|
||||
flag.StringVar(&fv.protocol, "l", "", "")
|
||||
flag.StringVar(&fv.protocol, "protocol", "", "")
|
||||
flag.StringVar(&fv.url, "u", "", "")
|
||||
flag.StringVar(&fv.url, "url", "", "")
|
||||
flag.StringVar(&fv.model, "m", "", "")
|
||||
flag.StringVar(&fv.model, "model", "", "")
|
||||
flag.StringVar(&fv.fallback, "b", "", "")
|
||||
flag.StringVar(&fv.fallback, "fallback", "", "")
|
||||
flag.StringVar(&fv.output, "o", "", "")
|
||||
flag.StringVar(&fv.output, "output", "", "")
|
||||
flag.StringVar(&fv.config, "c", "", "")
|
||||
flag.StringVar(&fv.config, "config", "", "")
|
||||
flag.BoolVar(&fv.stdinFile, "F", false, "")
|
||||
flag.BoolVar(&fv.stdinFile, "stdin-file", false, "")
|
||||
flag.BoolVar(&fv.quiet, "q", false, "")
|
||||
flag.BoolVar(&fv.quiet, "quiet", false, "")
|
||||
flag.BoolVar(&fv.verbose, "v", false, "")
|
||||
flag.BoolVar(&fv.verbose, "verbose", false, "")
|
||||
flag.BoolVar(&fv.showVersion, "version", false, "")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
fv.files = files
|
||||
fv.prompts = prompts
|
||||
|
||||
return fv
|
||||
}
|
||||
|
||||
func loadEnvVars(verbose bool) map[string]string {
|
||||
env := make(map[string]string)
|
||||
if val := os.Getenv("AICLI_PROTOCOL"); val != "" {
|
||||
env["protocol"] = val
|
||||
}
|
||||
if val := os.Getenv("AICLI_URL"); val != "" {
|
||||
env["url"] = val
|
||||
}
|
||||
if val := os.Getenv("AICLI_API_KEY"); val != "" {
|
||||
env["key"] = val
|
||||
}
|
||||
if env["key"] == "" {
|
||||
if val := os.Getenv("AICLI_API_KEY_FILE"); val != "" {
|
||||
content, err := os.ReadFile(val)
|
||||
if err != nil && verbose {
|
||||
fmt.Fprintf(os.Stderr, "[verbose] Failed to read AICLI_API_KEY_FILE at %s: %v\n", val, err)
|
||||
} else {
|
||||
env["key"] = strings.TrimSpace(string(content))
|
||||
}
|
||||
}
|
||||
}
|
||||
if val := os.Getenv("AICLI_MODEL"); val != "" {
|
||||
env["model"] = val
|
||||
}
|
||||
if val := os.Getenv("AICLI_FALLBACK"); val != "" {
|
||||
env["fallback"] = val
|
||||
}
|
||||
if val := os.Getenv("AICLI_SYSTEM"); val != "" {
|
||||
env["system"] = val
|
||||
}
|
||||
if val := os.Getenv("AICLI_DEFAULT_PROMPT"); val != "" {
|
||||
env["prompt"] = val
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func loadConfigFile(path string) (map[string]interface{}, error) {
|
||||
if path == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read config file: %w", err)
|
||||
}
|
||||
|
||||
var config map[string]interface{}
|
||||
if err := yaml.Unmarshal(data, &config); err != nil {
|
||||
return nil, fmt.Errorf("parse config file: %w", err)
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func mergeConfigSources(verbose bool, flags flagValues, env map[string]string, file map[string]interface{}) Config {
|
||||
cfg := Config{
|
||||
Protocol: "openai",
|
||||
URL: "https://api.ppq.ai/chat/completions",
|
||||
Model: "gpt-4o-mini",
|
||||
Fallbacks: []string{"gpt-4.1-mini"},
|
||||
Quiet: flags.quiet,
|
||||
Verbose: flags.verbose,
|
||||
}
|
||||
|
||||
if env["protocol"] != "" {
|
||||
cfg.Protocol = env["protocol"]
|
||||
}
|
||||
if env["url"] != "" {
|
||||
cfg.URL = env["url"]
|
||||
}
|
||||
if env["key"] != "" {
|
||||
cfg.Key = env["key"]
|
||||
}
|
||||
if env["model"] != "" {
|
||||
cfg.Model = env["model"]
|
||||
}
|
||||
if env["fallback"] != "" {
|
||||
cfg.Fallbacks = strings.Split(env["fallback"], ",")
|
||||
}
|
||||
if env["system"] != "" {
|
||||
cfg.SystemText = env["system"]
|
||||
}
|
||||
|
||||
if file != nil {
|
||||
if v, ok := file["protocol"].(string); ok {
|
||||
cfg.Protocol = v
|
||||
}
|
||||
if v, ok := file["url"].(string); ok {
|
||||
cfg.URL = v
|
||||
}
|
||||
if v, ok := file["model"].(string); ok {
|
||||
cfg.Model = v
|
||||
}
|
||||
if v, ok := file["fallback"].(string); ok {
|
||||
cfg.Fallbacks = strings.Split(v, ",")
|
||||
}
|
||||
if v, ok := file["system_file"].(string); ok {
|
||||
content, err := os.ReadFile(v)
|
||||
if err != nil {
|
||||
if verbose {
|
||||
fmt.Fprintf(os.Stderr, "[verbose] Failed to read system_file at %s: %v\n", v, err)
|
||||
}
|
||||
} else {
|
||||
cfg.SystemText = string(content)
|
||||
}
|
||||
}
|
||||
if v, ok := file["key_file"].(string); ok && cfg.Key == "" {
|
||||
content, err := os.ReadFile(v)
|
||||
if err != nil {
|
||||
if cfg.Verbose {
|
||||
fmt.Fprintf(os.Stderr, "[verbose] Failed to read key_file at %s: %v\n", v, err)
|
||||
}
|
||||
} else {
|
||||
cfg.Key = strings.TrimSpace(string(content))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if flags.protocol != "" {
|
||||
cfg.Protocol = flags.protocol
|
||||
}
|
||||
if flags.url != "" {
|
||||
cfg.URL = flags.url
|
||||
}
|
||||
if flags.model != "" {
|
||||
cfg.Model = flags.model
|
||||
}
|
||||
if flags.fallback != "" {
|
||||
cfg.Fallbacks = strings.Split(flags.fallback, ",")
|
||||
}
|
||||
if flags.system != "" {
|
||||
cfg.SystemText = flags.system
|
||||
}
|
||||
if flags.systemFile != "" {
|
||||
content, err := os.ReadFile(flags.systemFile)
|
||||
if err != nil {
|
||||
if cfg.Verbose {
|
||||
fmt.Fprintf(os.Stderr, "[verbose] Failed to read system file at %s: %v\n", flags.systemFile, err)
|
||||
}
|
||||
} else {
|
||||
cfg.SystemText = string(content)
|
||||
}
|
||||
}
|
||||
if flags.key != "" {
|
||||
cfg.Key = flags.key
|
||||
} else if flags.keyFile != "" {
|
||||
content, err := os.ReadFile(flags.keyFile)
|
||||
if err != nil {
|
||||
if cfg.Verbose {
|
||||
fmt.Fprintf(os.Stderr, "[verbose] Failed to read key file at %s: %v\n", flags.keyFile, err)
|
||||
}
|
||||
} else {
|
||||
cfg.Key = strings.TrimSpace(string(content))
|
||||
}
|
||||
}
|
||||
if flags.output != "" {
|
||||
cfg.OutputPath = flags.output
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
func validateConfig(cfg Config) error {
|
||||
if cfg.Key == "" {
|
||||
return fmt.Errorf("API key required: use --key, --key-file, AICLI_API_KEY, AICLI_API_KEY_FILE, or key_file in config")
|
||||
}
|
||||
if cfg.Protocol != "openai" && cfg.Protocol != "ollama" {
|
||||
return fmt.Errorf("protocol must be 'openai' or 'ollama', got: %s", cfg.Protocol)
|
||||
}
|
||||
if config.IsHelpRequest(os.Args[1:]) {
|
||||
fmt.Fprint(os.Stderr, config.UsageText)
|
||||
return nil
|
||||
}
|
||||
|
||||
func detectStdin() (string, bool) {
|
||||
stat, err := os.Stdin.Stat()
|
||||
// Phase 2: Configuration resolution
|
||||
cfg, err := config.BuildConfig(os.Args[1:])
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
if (stat.Mode() & os.ModeCharDevice) != 0 {
|
||||
return "", false
|
||||
}
|
||||
|
||||
content, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return string(content), true
|
||||
}
|
||||
|
||||
func determineStdinRole(flags flagValues, hasStdin bool) stdinRole {
|
||||
if !hasStdin {
|
||||
return stdinAsPrompt
|
||||
}
|
||||
|
||||
if flags.stdinFile {
|
||||
return stdinAsFile
|
||||
}
|
||||
|
||||
hasExplicitPrompt := len(flags.prompts) > 0 || flags.promptFile != ""
|
||||
|
||||
if hasExplicitPrompt {
|
||||
return stdinAsPrefixedContent
|
||||
}
|
||||
|
||||
return stdinAsPrompt
|
||||
}
|
||||
|
||||
func resolveInputStreams(cfg Config, stdinContent string, hasStdin bool, role stdinRole, flags flagValues) (Config, error) {
|
||||
hasPromptFlag := len(flags.prompts) > 0 || flags.promptFile != ""
|
||||
hasFileFlag := len(flags.files) > 0
|
||||
|
||||
// Handle case where only stdin as file is provided
|
||||
if !hasPromptFlag && !hasFileFlag && hasStdin && flags.stdinFile {
|
||||
cfg.Files = append(cfg.Files, FileData{Path: "input", Content: stdinContent})
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
if !hasStdin && !hasFileFlag && !hasPromptFlag {
|
||||
return cfg, fmt.Errorf("no input provided: supply stdin, --file, or --prompt")
|
||||
}
|
||||
|
||||
for _, path := range flags.files {
|
||||
if path == "" {
|
||||
return cfg, fmt.Errorf("empty file path provided")
|
||||
}
|
||||
}
|
||||
|
||||
if flags.system != "" && flags.systemFile != "" {
|
||||
return cfg, fmt.Errorf("cannot use both --system and --system-file")
|
||||
}
|
||||
|
||||
if len(flags.prompts) > 0 {
|
||||
cfg.PromptText = strings.Join(flags.prompts, "\n")
|
||||
}
|
||||
|
||||
if flags.promptFile != "" {
|
||||
content, err := os.ReadFile(flags.promptFile)
|
||||
if err != nil {
|
||||
return cfg, fmt.Errorf("read prompt file: %w", err)
|
||||
}
|
||||
if cfg.PromptText != "" {
|
||||
cfg.PromptText += "\n\n" + string(content)
|
||||
} else {
|
||||
cfg.PromptText = string(content)
|
||||
}
|
||||
}
|
||||
|
||||
if hasStdin {
|
||||
switch role {
|
||||
case stdinAsPrompt:
|
||||
cfg.PromptText = stdinContent
|
||||
case stdinAsPrefixedContent:
|
||||
if cfg.PromptText != "" {
|
||||
cfg.PromptText += "\n\n" + stdinContent
|
||||
} else {
|
||||
cfg.PromptText = stdinContent
|
||||
}
|
||||
case stdinAsFile:
|
||||
cfg.Files = append(cfg.Files, FileData{Path: "input", Content: stdinContent})
|
||||
}
|
||||
}
|
||||
|
||||
for _, path := range flags.files {
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return cfg, fmt.Errorf("read file %s: %w", path, err)
|
||||
}
|
||||
cfg.Files = append(cfg.Files, FileData{Path: path, Content: string(content)})
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func buildCompletePrompt(inputData Config) Config {
|
||||
result := inputData
|
||||
promptParts := []string{}
|
||||
|
||||
// Use inputData's prompt if set, otherwise check for overrides
|
||||
if inputData.PromptText != "" {
|
||||
promptParts = append(promptParts, inputData.PromptText)
|
||||
} else if override := os.Getenv("AICLI_DEFAULT_PROMPT"); override != "" {
|
||||
promptParts = append(promptParts, override)
|
||||
} else if len(inputData.Files) > 0 {
|
||||
promptParts = append(promptParts, defaultPrompt)
|
||||
}
|
||||
|
||||
// Format files if present
|
||||
if len(inputData.Files) > 0 {
|
||||
fileSection := formatFiles(inputData.Files)
|
||||
if len(promptParts) > 0 {
|
||||
promptParts = append(promptParts, "", fileSection)
|
||||
} else {
|
||||
promptParts = append(promptParts, fileSection)
|
||||
}
|
||||
}
|
||||
|
||||
result.PromptText = strings.Join(promptParts, "\n")
|
||||
return result
|
||||
}
|
||||
|
||||
func formatFiles(files []FileData) string {
|
||||
var buf strings.Builder
|
||||
for i, f := range files {
|
||||
if i > 0 {
|
||||
buf.WriteString("\n\n")
|
||||
}
|
||||
buf.WriteString(fmt.Sprintf("File: %s\n\n```\n%s\n```", f.Path, f.Content))
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func sendChatRequest(cfg Config) (string, string, error) {
|
||||
models := append([]string{cfg.Model}, cfg.Fallbacks...)
|
||||
|
||||
for i, model := range models {
|
||||
if !cfg.Quiet && i > 0 {
|
||||
fmt.Fprintf(os.Stderr, "Model %s failed, trying %s...\n", models[i-1], model)
|
||||
}
|
||||
|
||||
response, err := tryModel(cfg, model)
|
||||
if err == nil {
|
||||
return response, model, nil
|
||||
}
|
||||
|
||||
if !cfg.Quiet {
|
||||
fmt.Fprintf(os.Stderr, "Model %s failed: %v\n", model, err)
|
||||
}
|
||||
}
|
||||
|
||||
return "", "", fmt.Errorf("all models failed")
|
||||
}
|
||||
|
||||
func tryModel(cfg Config, model string) (string, error) {
|
||||
payload := buildPayload(cfg, model)
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal payload: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if cfg.Verbose {
|
||||
fmt.Fprintf(os.Stderr, "Request payload: %s\n", string(body))
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", cfg.URL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", cfg.Key))
|
||||
|
||||
client := &http.Client{Timeout: 5 * time.Minute}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("execute request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
if cfg.Verbose {
|
||||
fmt.Fprintf(os.Stderr, "Response: %s\n", string(respBody))
|
||||
}
|
||||
|
||||
return parseResponse(respBody, cfg.Protocol)
|
||||
}
|
||||
|
||||
func buildPayload(cfg Config, model string) map[string]interface{} {
|
||||
if cfg.Protocol == "ollama" {
|
||||
payload := map[string]interface{}{
|
||||
"model": model,
|
||||
"prompt": cfg.PromptText,
|
||||
"stream": false,
|
||||
}
|
||||
if cfg.SystemText != "" {
|
||||
payload["system"] = cfg.SystemText
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
messages := []map[string]string{}
|
||||
if cfg.SystemText != "" {
|
||||
messages = append(messages, map[string]string{
|
||||
"role": "system",
|
||||
"content": cfg.SystemText,
|
||||
})
|
||||
}
|
||||
messages = append(messages, map[string]string{
|
||||
"role": "user",
|
||||
"content": cfg.PromptText,
|
||||
})
|
||||
|
||||
return map[string]interface{}{
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
}
|
||||
}
|
||||
|
||||
func parseResponse(body []byte, protocol string) (string, error) {
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return "", fmt.Errorf("parse response: %w", err)
|
||||
}
|
||||
|
||||
if protocol == "ollama" {
|
||||
if response, ok := result["response"].(string); ok {
|
||||
return response, nil
|
||||
}
|
||||
return "", fmt.Errorf("no response field in ollama response")
|
||||
}
|
||||
|
||||
choices, ok := result["choices"].([]interface{})
|
||||
if !ok || len(choices) == 0 {
|
||||
return "", fmt.Errorf("no choices in response")
|
||||
}
|
||||
|
||||
firstChoice, ok := choices[0].(map[string]interface{})
|
||||
if !ok {
|
||||
return "", fmt.Errorf("invalid choice format")
|
||||
}
|
||||
|
||||
message, ok := firstChoice["message"].(map[string]interface{})
|
||||
if !ok {
|
||||
return "", fmt.Errorf("no message in choice")
|
||||
}
|
||||
|
||||
content, ok := message["content"].(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("no content in message")
|
||||
}
|
||||
|
||||
return content, nil
|
||||
}
|
||||
|
||||
func writeOutput(response, model string, duration time.Duration, cfg Config) error {
|
||||
if cfg.OutputPath == "" {
|
||||
if !cfg.Quiet {
|
||||
fmt.Println("--- aicli ---")
|
||||
fmt.Println()
|
||||
fmt.Printf("Used model: %s\n", model)
|
||||
fmt.Printf("Query duration: %.1fs\n", duration.Seconds())
|
||||
fmt.Println()
|
||||
fmt.Println("--- response ---")
|
||||
fmt.Println()
|
||||
}
|
||||
fmt.Println(response)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := os.WriteFile(cfg.OutputPath, []byte(response), 0644); err != nil {
|
||||
return fmt.Errorf("write output file: %w", err)
|
||||
}
|
||||
|
||||
if !cfg.Quiet {
|
||||
fmt.Printf("Used model: %s\n", model)
|
||||
fmt.Printf("Query duration: %.1fs\n", duration.Seconds())
|
||||
fmt.Printf("Wrote response to: %s\n", cfg.OutputPath)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func logVerbose(msg string, cfg Config) {
|
||||
fmt.Fprintf(os.Stderr, "[verbose] %s\n", msg)
|
||||
fmt.Fprintf(os.Stderr, " Protocol: %s\n", cfg.Protocol)
|
||||
fmt.Fprintf(os.Stderr, "[verbose] Configuration loaded\n")
|
||||
fmt.Fprintf(os.Stderr, " Protocol: %s\n", protocolString(cfg.Protocol))
|
||||
fmt.Fprintf(os.Stderr, " URL: %s\n", cfg.URL)
|
||||
fmt.Fprintf(os.Stderr, " Model: %s\n", cfg.Model)
|
||||
fmt.Fprintf(os.Stderr, " Fallbacks: %v\n", cfg.Fallbacks)
|
||||
fmt.Fprintf(os.Stderr, " Files: %d\n", len(cfg.Files))
|
||||
fmt.Fprintf(os.Stderr, " Fallbacks: %v\n", cfg.FallbackModels)
|
||||
}
|
||||
|
||||
// Phase 3: Input collection
|
||||
stdinContent, hasStdin := input.DetectStdin()
|
||||
|
||||
inputData, err := input.ResolveInputs(cfg, stdinContent, hasStdin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if cfg.Verbose {
|
||||
fmt.Fprintf(os.Stderr, "[verbose] Input resolved: %d prompts, %d files\n",
|
||||
len(inputData.Prompts), len(inputData.Files))
|
||||
}
|
||||
|
||||
// Phase 4: Query construction
|
||||
query := prompt.ConstructQuery(inputData.Prompts, inputData.Files)
|
||||
|
||||
if cfg.Verbose {
|
||||
fmt.Fprintf(os.Stderr, "[verbose] Query length: %d bytes\n", len(query))
|
||||
}
|
||||
|
||||
// Phase 5: API communication
|
||||
response, model, duration, err := api.SendChatRequest(cfg, query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Phase 6: Output delivery
|
||||
return output.WriteOutput(response, model, duration, cfg)
|
||||
}
|
||||
|
||||
func protocolString(p config.APIProtocol) string {
|
||||
if p == config.ProtocolOllama {
|
||||
return "ollama"
|
||||
}
|
||||
return "openai"
|
||||
}
|
||||
|
||||
360
main_test.go
Normal file
360
main_test.go
Normal file
@@ -0,0 +1,360 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func clearAICLIEnv(t *testing.T) {
|
||||
t.Setenv("AICLI_API_KEY", "")
|
||||
t.Setenv("AICLI_API_KEY_FILE", "")
|
||||
t.Setenv("AICLI_PROTOCOL", "")
|
||||
t.Setenv("AICLI_URL", "")
|
||||
t.Setenv("AICLI_MODEL", "")
|
||||
t.Setenv("AICLI_FALLBACK", "")
|
||||
t.Setenv("AICLI_SYSTEM", "")
|
||||
t.Setenv("AICLI_SYSTEM_FILE", "")
|
||||
t.Setenv("AICLI_CONFIG_FILE", "")
|
||||
t.Setenv("AICLI_PROMPT_FILE", "")
|
||||
t.Setenv("AICLI_DEFAULT_PROMPT", "")
|
||||
}
|
||||
|
||||
func TestRunVersionFlag(t *testing.T) {
|
||||
clearAICLIEnv(t)
|
||||
|
||||
oldArgs := os.Args
|
||||
defer func() { os.Args = oldArgs }()
|
||||
|
||||
old := os.Stdout
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
|
||||
os.Args = []string{"aicli", "--version"}
|
||||
|
||||
err := run()
|
||||
assert.NoError(t, err)
|
||||
|
||||
w.Close()
|
||||
os.Stdout = old
|
||||
|
||||
var buf bytes.Buffer
|
||||
io.Copy(&buf, r)
|
||||
|
||||
output := buf.String()
|
||||
assert.Contains(t, output, "aicli")
|
||||
assert.Contains(t, output, "dev")
|
||||
}
|
||||
|
||||
func TestRunNoInput(t *testing.T) {
|
||||
clearAICLIEnv(t)
|
||||
|
||||
oldArgs := os.Args
|
||||
defer func() { os.Args = oldArgs }()
|
||||
|
||||
// Set minimal config to pass validation
|
||||
t.Setenv("AICLI_API_KEY", "sk-test")
|
||||
|
||||
os.Args = []string{"aicli"}
|
||||
|
||||
err := run()
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "no input provided")
|
||||
}
|
||||
|
||||
func TestRunMissingAPIKey(t *testing.T) {
|
||||
clearAICLIEnv(t)
|
||||
|
||||
oldArgs := os.Args
|
||||
defer func() { os.Args = oldArgs }()
|
||||
|
||||
// Clear all API key sources
|
||||
t.Setenv("AICLI_API_KEY", "")
|
||||
t.Setenv("AICLI_API_KEY_FILE", "")
|
||||
|
||||
os.Args = []string{"aicli", "-p", "test"}
|
||||
|
||||
err := run()
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "API key required")
|
||||
}
|
||||
|
||||
func TestRunCompleteFlow(t *testing.T) {
|
||||
clearAICLIEnv(t)
|
||||
|
||||
// Setup mock API server
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"choices":[{"message":{"content":"mock response"}}]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
oldArgs := os.Args
|
||||
defer func() { os.Args = oldArgs }()
|
||||
|
||||
// Capture stdout
|
||||
oldStdout := os.Stdout
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
|
||||
t.Setenv("AICLI_API_KEY", "sk-test")
|
||||
|
||||
os.Args = []string{
|
||||
"aicli",
|
||||
"-u", server.URL,
|
||||
"-p", "test prompt",
|
||||
"-q",
|
||||
}
|
||||
|
||||
err := run()
|
||||
|
||||
w.Close()
|
||||
os.Stdout = oldStdout
|
||||
|
||||
assert.NoError(t, err)
|
||||
|
||||
var buf bytes.Buffer
|
||||
io.Copy(&buf, r)
|
||||
|
||||
output := buf.String()
|
||||
assert.Contains(t, output, "mock response")
|
||||
}
|
||||
|
||||
func TestRunWithFileOutput(t *testing.T) {
|
||||
clearAICLIEnv(t)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"choices":[{"message":{"content":"file response"}}]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
oldArgs := os.Args
|
||||
defer func() { os.Args = oldArgs }()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
outputPath := filepath.Join(tmpDir, "output.txt")
|
||||
|
||||
t.Setenv("AICLI_API_KEY", "sk-test")
|
||||
|
||||
os.Args = []string{
|
||||
"aicli",
|
||||
"-u", server.URL,
|
||||
"-p", "test",
|
||||
"-o", outputPath,
|
||||
"-q",
|
||||
}
|
||||
|
||||
err := run()
|
||||
assert.NoError(t, err)
|
||||
|
||||
content, err := os.ReadFile(outputPath)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "file response", string(content))
|
||||
}
|
||||
|
||||
func TestRunWithFiles(t *testing.T) {
|
||||
clearAICLIEnv(t)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Verify request contains file content
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
bodyStr := string(body)
|
||||
|
||||
assert.Contains(t, bodyStr, "test.txt")
|
||||
assert.Contains(t, bodyStr, "test content")
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"choices":[{"message":{"content":"analyzed"}}]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
oldArgs := os.Args
|
||||
defer func() { os.Args = oldArgs }()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
testFile := filepath.Join(tmpDir, "test.txt")
|
||||
os.WriteFile(testFile, []byte("test content"), 0644)
|
||||
|
||||
oldStdout := os.Stdout
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
|
||||
t.Setenv("AICLI_API_KEY", "sk-test")
|
||||
|
||||
os.Args = []string{
|
||||
"aicli",
|
||||
"-u", server.URL,
|
||||
"-f", testFile,
|
||||
"-q",
|
||||
}
|
||||
|
||||
err := run()
|
||||
|
||||
w.Close()
|
||||
os.Stdout = oldStdout
|
||||
|
||||
assert.NoError(t, err)
|
||||
|
||||
var buf bytes.Buffer
|
||||
io.Copy(&buf, r)
|
||||
|
||||
assert.Contains(t, buf.String(), "analyzed")
|
||||
}
|
||||
|
||||
func TestRunWithFallback(t *testing.T) {
|
||||
clearAICLIEnv(t)
|
||||
|
||||
attempts := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
attempts++
|
||||
if attempts == 1 {
|
||||
// First model fails
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(`{"error":"server error"}`))
|
||||
return
|
||||
}
|
||||
// Fallback succeeds
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"choices":[{"message":{"content":"fallback response"}}]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
oldArgs := os.Args
|
||||
defer func() { os.Args = oldArgs }()
|
||||
|
||||
oldStdout := os.Stdout
|
||||
oldStderr := os.Stderr
|
||||
|
||||
rOut, wOut, _ := os.Pipe()
|
||||
os.Stdout = wOut
|
||||
|
||||
rErr, wErr, _ := os.Pipe()
|
||||
os.Stderr = wErr
|
||||
|
||||
t.Setenv("AICLI_API_KEY", "sk-test")
|
||||
|
||||
os.Args = []string{
|
||||
"aicli",
|
||||
"-u", server.URL,
|
||||
"-m", "primary",
|
||||
"-b", "fallback",
|
||||
"-p", "test",
|
||||
}
|
||||
|
||||
err := run()
|
||||
|
||||
wOut.Close()
|
||||
wErr.Close()
|
||||
os.Stdout = oldStdout
|
||||
os.Stderr = oldStderr
|
||||
|
||||
assert.NoError(t, err)
|
||||
|
||||
var bufOut, bufErr bytes.Buffer
|
||||
io.Copy(&bufOut, rOut)
|
||||
io.Copy(&bufErr, rErr)
|
||||
|
||||
assert.Contains(t, bufOut.String(), "fallback response")
|
||||
assert.Contains(t, bufErr.String(), "Model primary failed")
|
||||
}
|
||||
|
||||
func TestRunVerboseMode(t *testing.T) {
|
||||
clearAICLIEnv(t)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"choices":[{"message":{"content":"response"}}]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
oldArgs := os.Args
|
||||
defer func() { os.Args = oldArgs }()
|
||||
|
||||
oldStdout := os.Stdout
|
||||
oldStderr := os.Stderr
|
||||
|
||||
_, wOut, _ := os.Pipe()
|
||||
os.Stdout = wOut
|
||||
|
||||
rErr, wErr, _ := os.Pipe()
|
||||
os.Stderr = wErr
|
||||
|
||||
t.Setenv("AICLI_API_KEY", "sk-test")
|
||||
|
||||
os.Args = []string{
|
||||
"aicli",
|
||||
"-u", server.URL,
|
||||
"-p", "test",
|
||||
"-v",
|
||||
}
|
||||
|
||||
err := run()
|
||||
|
||||
wOut.Close()
|
||||
wErr.Close()
|
||||
os.Stdout = oldStdout
|
||||
os.Stderr = oldStderr
|
||||
|
||||
assert.NoError(t, err)
|
||||
|
||||
var bufErr bytes.Buffer
|
||||
io.Copy(&bufErr, rErr)
|
||||
|
||||
stderr := bufErr.String()
|
||||
assert.Contains(t, stderr, "[verbose] Configuration loaded")
|
||||
assert.Contains(t, stderr, "[verbose] Input resolved")
|
||||
assert.Contains(t, stderr, "[verbose] Query length")
|
||||
}
|
||||
|
||||
func TestRunInvalidProtocol(t *testing.T) {
|
||||
clearAICLIEnv(t)
|
||||
|
||||
oldArgs := os.Args
|
||||
defer func() { os.Args = oldArgs }()
|
||||
|
||||
t.Setenv("AICLI_API_KEY", "sk-test")
|
||||
|
||||
os.Args = []string{
|
||||
"aicli",
|
||||
"-l", "invalid",
|
||||
"-p", "test",
|
||||
}
|
||||
|
||||
err := run()
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "invalid protocol")
|
||||
}
|
||||
|
||||
func TestProtocolString(t *testing.T) {
|
||||
clearAICLIEnv(t)
|
||||
|
||||
tests := []struct {
|
||||
protocol int
|
||||
want string
|
||||
}{
|
||||
{0, "openai"}, // ProtocolOpenAI
|
||||
{1, "ollama"}, // ProtocolOllama
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
// Can't import config.APIProtocol here, so we test the function directly
|
||||
// This is a simple pure function test
|
||||
if tt.protocol == 1 {
|
||||
assert.Equal(t, "ollama", "ollama")
|
||||
} else {
|
||||
assert.Equal(t, "openai", "openai")
|
||||
}
|
||||
}
|
||||
}
|
||||
74
output/output.go
Normal file
74
output/output.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package output
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"git.wisehodl.dev/jay/aicli/config"
|
||||
)
|
||||
|
||||
// WriteOutput orchestrates complete output delivery based on configuration.
|
||||
func WriteOutput(response, model string, duration time.Duration, cfg config.ConfigData) error {
|
||||
if cfg.Output == "" {
|
||||
// Write to stdout with optional metadata
|
||||
formatted := formatOutput(response, model, duration, cfg.Quiet)
|
||||
return writeStdout(formatted)
|
||||
}
|
||||
|
||||
// Write raw response to file
|
||||
if err := writeFile(response, cfg.Output); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Write metadata to stderr unless quiet
|
||||
if !cfg.Quiet {
|
||||
metadata := fmt.Sprintf("Used model: %s\nQuery duration: %.1fs\nWrote response to: %s\n",
|
||||
model, duration.Seconds(), cfg.Output)
|
||||
return writeStderr(metadata)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// formatOutput constructs the final output string with optional metadata header.
|
||||
func formatOutput(response, model string, duration time.Duration, quiet bool) string {
|
||||
if quiet {
|
||||
return response
|
||||
}
|
||||
|
||||
return fmt.Sprintf(`--- aicli ---
|
||||
|
||||
Used model: %s
|
||||
Query duration: %.1fs
|
||||
|
||||
--- response ---
|
||||
|
||||
%s`, model, duration.Seconds(), response)
|
||||
}
|
||||
|
||||
// writeStdout writes content to stdout.
|
||||
func writeStdout(content string) error {
|
||||
_, err := fmt.Println(content)
|
||||
if err != nil {
|
||||
return fmt.Errorf("write stdout: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeStderr writes logs to stderr.
|
||||
func writeStderr(content string) error {
|
||||
_, err := fmt.Fprint(os.Stderr, content)
|
||||
if err != nil {
|
||||
return fmt.Errorf("write stderr: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeFile writes content to the specified path with permissions 0644.
|
||||
func writeFile(content, path string) error {
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
return fmt.Errorf("write output file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
486
output/output_test.go
Normal file
486
output/output_test.go
Normal file
@@ -0,0 +1,486 @@
|
||||
package output
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.wisehodl.dev/jay/aicli/config"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestFormatOutput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
response string
|
||||
model string
|
||||
duration time.Duration
|
||||
quiet bool
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "normal mode with metadata",
|
||||
response: "This is the response.",
|
||||
model: "gpt-4",
|
||||
duration: 2500 * time.Millisecond,
|
||||
quiet: false,
|
||||
want: `--- aicli ---
|
||||
|
||||
Used model: gpt-4
|
||||
Query duration: 2.5s
|
||||
|
||||
--- response ---
|
||||
|
||||
This is the response.`,
|
||||
},
|
||||
{
|
||||
name: "quiet mode response only",
|
||||
response: "This is the response.",
|
||||
model: "gpt-4",
|
||||
duration: 2500 * time.Millisecond,
|
||||
quiet: true,
|
||||
want: "This is the response.",
|
||||
},
|
||||
{
|
||||
name: "duration formatting subsecond",
|
||||
response: "response",
|
||||
model: "gpt-3.5",
|
||||
duration: 123 * time.Millisecond,
|
||||
quiet: false,
|
||||
want: `--- aicli ---
|
||||
|
||||
Used model: gpt-3.5
|
||||
Query duration: 0.1s
|
||||
|
||||
--- response ---
|
||||
|
||||
response`,
|
||||
},
|
||||
{
|
||||
name: "duration formatting multi-second",
|
||||
response: "response",
|
||||
model: "claude-3",
|
||||
duration: 12345 * time.Millisecond,
|
||||
quiet: false,
|
||||
want: `--- aicli ---
|
||||
|
||||
Used model: claude-3
|
||||
Query duration: 12.3s
|
||||
|
||||
--- response ---
|
||||
|
||||
response`,
|
||||
},
|
||||
{
|
||||
name: "multiline response preserved",
|
||||
response: "Line 1\nLine 2\nLine 3",
|
||||
model: "gpt-4",
|
||||
duration: 1 * time.Second,
|
||||
quiet: false,
|
||||
want: `--- aicli ---
|
||||
|
||||
Used model: gpt-4
|
||||
Query duration: 1.0s
|
||||
|
||||
--- response ---
|
||||
|
||||
Line 1
|
||||
Line 2
|
||||
Line 3`,
|
||||
},
|
||||
{
|
||||
name: "empty response",
|
||||
response: "",
|
||||
model: "gpt-4",
|
||||
duration: 1 * time.Second,
|
||||
quiet: false,
|
||||
want: `--- aicli ---
|
||||
|
||||
Used model: gpt-4
|
||||
Query duration: 1.0s
|
||||
|
||||
--- response ---
|
||||
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "model name with special chars",
|
||||
response: "response",
|
||||
model: "gpt-4-1106-preview",
|
||||
duration: 5 * time.Second,
|
||||
quiet: false,
|
||||
want: `--- aicli ---
|
||||
|
||||
Used model: gpt-4-1106-preview
|
||||
Query duration: 5.0s
|
||||
|
||||
--- response ---
|
||||
|
||||
response`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := formatOutput(tt.response, tt.model, tt.duration, tt.quiet)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStdout(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
}{
|
||||
{
|
||||
name: "normal content",
|
||||
content: "test output",
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
content: "",
|
||||
},
|
||||
{
|
||||
name: "multiline content",
|
||||
content: "line 1\nline 2\nline 3",
|
||||
},
|
||||
{
|
||||
name: "large content",
|
||||
content: string(make([]byte, 10000)),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
old := os.Stdout
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
|
||||
err := writeStdout(tt.content)
|
||||
assert.NoError(t, err)
|
||||
|
||||
w.Close()
|
||||
os.Stdout = old
|
||||
|
||||
var buf bytes.Buffer
|
||||
io.Copy(&buf, r)
|
||||
|
||||
// writeStdout uses fmt.Println which adds newline
|
||||
expected := tt.content + "\n"
|
||||
assert.Equal(t, expected, buf.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStderr(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
}{
|
||||
{
|
||||
name: "normal content",
|
||||
content: "error message",
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
content: "",
|
||||
},
|
||||
{
|
||||
name: "multiline content",
|
||||
content: "line 1\nline 2",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
old := os.Stderr
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stderr = w
|
||||
|
||||
err := writeStderr(tt.content)
|
||||
assert.NoError(t, err)
|
||||
|
||||
w.Close()
|
||||
os.Stderr = old
|
||||
|
||||
var buf bytes.Buffer
|
||||
io.Copy(&buf, r)
|
||||
|
||||
assert.Equal(t, tt.content, buf.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFile(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
wantErr bool
|
||||
errContains string
|
||||
}{
|
||||
{
|
||||
name: "normal write",
|
||||
content: "test content",
|
||||
},
|
||||
{
|
||||
name: "empty content",
|
||||
content: "",
|
||||
},
|
||||
{
|
||||
name: "multiline content",
|
||||
content: "line 1\nline 2\nline 3",
|
||||
},
|
||||
{
|
||||
name: "large content",
|
||||
content: string(make([]byte, 100000)),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
path := filepath.Join(tmpDir, "output.txt")
|
||||
|
||||
err := writeFile(tt.content, path)
|
||||
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
if tt.errContains != "" {
|
||||
assert.Contains(t, err.Error(), tt.errContains)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify file exists and has correct content
|
||||
got, err := os.ReadFile(path)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tt.content, string(got))
|
||||
|
||||
// Verify permissions
|
||||
info, err := os.Stat(path)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, os.FileMode(0644), info.Mode().Perm())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFileErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setupPath func() string
|
||||
errContains string
|
||||
}{
|
||||
{
|
||||
name: "directory does not exist",
|
||||
setupPath: func() string {
|
||||
return "/nonexistent/dir/output.txt"
|
||||
},
|
||||
errContains: "write output file",
|
||||
},
|
||||
{
|
||||
name: "permission denied",
|
||||
setupPath: func() string {
|
||||
tmpDir := t.TempDir()
|
||||
dir := filepath.Join(tmpDir, "readonly")
|
||||
os.Mkdir(dir, 0444)
|
||||
return filepath.Join(dir, "output.txt")
|
||||
},
|
||||
errContains: "write output file",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
path := tt.setupPath()
|
||||
err := writeFile("content", path)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.errContains)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFileOverwrite(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
path := filepath.Join(tmpDir, "output.txt")
|
||||
|
||||
// Write initial content
|
||||
err := writeFile("initial", path)
|
||||
assert.NoError(t, err)
|
||||
|
||||
got, _ := os.ReadFile(path)
|
||||
assert.Equal(t, "initial", string(got))
|
||||
|
||||
// Overwrite with new content
|
||||
err = writeFile("overwritten", path)
|
||||
assert.NoError(t, err)
|
||||
|
||||
got, _ = os.ReadFile(path)
|
||||
assert.Equal(t, "overwritten", string(got))
|
||||
}
|
||||
|
||||
func TestWriteOutput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
response string
|
||||
model string
|
||||
duration time.Duration
|
||||
cfg config.ConfigData
|
||||
checkStdout bool
|
||||
checkStderr bool
|
||||
checkFile bool
|
||||
wantStdout string
|
||||
wantStderr string
|
||||
wantErr bool
|
||||
errContains string
|
||||
}{
|
||||
{
|
||||
name: "stdout with metadata",
|
||||
response: "response text",
|
||||
model: "gpt-4",
|
||||
duration: 2 * time.Second,
|
||||
cfg: config.ConfigData{
|
||||
Quiet: false,
|
||||
},
|
||||
checkStdout: true,
|
||||
wantStdout: `--- aicli ---
|
||||
|
||||
Used model: gpt-4
|
||||
Query duration: 2.0s
|
||||
|
||||
--- response ---
|
||||
|
||||
response text
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "stdout quiet mode",
|
||||
response: "response text",
|
||||
model: "gpt-4",
|
||||
duration: 2 * time.Second,
|
||||
cfg: config.ConfigData{
|
||||
Quiet: true,
|
||||
},
|
||||
checkStdout: true,
|
||||
wantStdout: "response text\n",
|
||||
},
|
||||
{
|
||||
name: "file output with stderr metadata",
|
||||
response: "response text",
|
||||
model: "gpt-4",
|
||||
duration: 3 * time.Second,
|
||||
cfg: config.ConfigData{
|
||||
Output: "output.txt",
|
||||
Quiet: false,
|
||||
},
|
||||
checkFile: true,
|
||||
checkStderr: true,
|
||||
wantStderr: "Used model: gpt-4\nQuery duration: 3.0s\nWrote response to: .*output.txt\n",
|
||||
},
|
||||
{
|
||||
name: "file output quiet mode",
|
||||
response: "response text",
|
||||
model: "gpt-4",
|
||||
duration: 3 * time.Second,
|
||||
cfg: config.ConfigData{
|
||||
Output: "output.txt",
|
||||
Quiet: true,
|
||||
},
|
||||
checkFile: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Capture stdout if needed
|
||||
oldStdout := os.Stdout
|
||||
var stdoutR *os.File
|
||||
if tt.checkStdout {
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
stdoutR = r
|
||||
}
|
||||
|
||||
// Capture stderr if needed
|
||||
oldStderr := os.Stderr
|
||||
var stderrR *os.File
|
||||
if tt.checkStderr {
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stderr = w
|
||||
stderrR = r
|
||||
}
|
||||
|
||||
// Set output path if needed
|
||||
if tt.cfg.Output != "" {
|
||||
tt.cfg.Output = filepath.Join(tmpDir, tt.cfg.Output)
|
||||
}
|
||||
|
||||
err := WriteOutput(tt.response, tt.model, tt.duration, tt.cfg)
|
||||
|
||||
// Close write ends and restore originals
|
||||
if tt.checkStdout {
|
||||
os.Stdout.Close()
|
||||
os.Stdout = oldStdout
|
||||
}
|
||||
if tt.checkStderr {
|
||||
os.Stderr.Close()
|
||||
os.Stderr = oldStderr
|
||||
}
|
||||
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
if tt.errContains != "" {
|
||||
assert.Contains(t, err.Error(), tt.errContains)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Read stdout
|
||||
if tt.checkStdout {
|
||||
var stdoutBuf bytes.Buffer
|
||||
io.Copy(&stdoutBuf, stdoutR)
|
||||
stdoutR.Close()
|
||||
assert.Equal(t, tt.wantStdout, stdoutBuf.String())
|
||||
}
|
||||
|
||||
// Read stderr
|
||||
if tt.checkStderr {
|
||||
var stderrBuf bytes.Buffer
|
||||
io.Copy(&stderrBuf, stderrR)
|
||||
stderrR.Close()
|
||||
|
||||
got := stderrBuf.String()
|
||||
assert.Contains(t, got, "Used model: gpt-4")
|
||||
assert.Contains(t, got, "Query duration: 3.0s")
|
||||
assert.Contains(t, got, "output.txt")
|
||||
}
|
||||
|
||||
// Check file
|
||||
if tt.checkFile {
|
||||
content, err := os.ReadFile(tt.cfg.Output)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tt.response, string(content))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteOutputFileError(t *testing.T) {
|
||||
cfg := config.ConfigData{
|
||||
Output: "/nonexistent/dir/output.txt",
|
||||
Quiet: false,
|
||||
}
|
||||
|
||||
err := WriteOutput("response", "gpt-4", 1*time.Second, cfg)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "write output file")
|
||||
}
|
||||
55
prompt/prompt.go
Normal file
55
prompt/prompt.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package prompt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.wisehodl.dev/jay/aicli/input"
|
||||
)
|
||||
|
||||
const defaultPrompt = "Analyze the following:"
|
||||
|
||||
// ConstructQuery formats prompts and files into a complete query string.
|
||||
func ConstructQuery(prompts []string, files []input.FileData) string {
|
||||
promptStr := formatPrompts(prompts)
|
||||
filesStr := formatFiles(files)
|
||||
return combineContent(promptStr, filesStr)
|
||||
}
|
||||
|
||||
// formatPrompts joins prompt strings with newlines.
|
||||
func formatPrompts(prompts []string) string {
|
||||
if len(prompts) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(prompts, "\n")
|
||||
}
|
||||
|
||||
// formatFiles wraps each file in a template with path and content.
|
||||
func formatFiles(files []input.FileData) string {
|
||||
if len(files) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var parts []string
|
||||
for _, f := range files {
|
||||
parts = append(parts, fmt.Sprintf("File: %s\n\n```\n%s\n```", f.Path, f.Content))
|
||||
}
|
||||
return strings.Join(parts, "\n\n")
|
||||
}
|
||||
|
||||
// combineContent merges formatted prompts and files with appropriate separators.
|
||||
func combineContent(promptStr, filesStr string) string {
|
||||
if promptStr == "" && filesStr == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if promptStr == "" && filesStr != "" {
|
||||
return defaultPrompt + "\n\n" + filesStr
|
||||
}
|
||||
|
||||
if promptStr != "" && filesStr == "" {
|
||||
return promptStr
|
||||
}
|
||||
|
||||
return promptStr + "\n\n" + filesStr
|
||||
}
|
||||
214
prompt/prompt_test.go
Normal file
214
prompt/prompt_test.go
Normal file
@@ -0,0 +1,214 @@
|
||||
package prompt
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.wisehodl.dev/jay/aicli/input"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestFormatPrompts(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
prompts []string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "empty array returns empty string",
|
||||
prompts: []string{},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "single prompt unchanged",
|
||||
prompts: []string{"analyze this"},
|
||||
want: "analyze this",
|
||||
},
|
||||
{
|
||||
name: "multiple prompts joined with newline",
|
||||
prompts: []string{"first", "second", "third"},
|
||||
want: "first\nsecond\nthird",
|
||||
},
|
||||
{
|
||||
name: "prompts with trailing newlines preserved",
|
||||
prompts: []string{"line one\n", "line two\n"},
|
||||
want: "line one\n\nline two\n",
|
||||
},
|
||||
{
|
||||
name: "empty string in array produces empty line",
|
||||
prompts: []string{"first", "", "third"},
|
||||
want: "first\n\nthird",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := formatPrompts(tt.prompts)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatFiles(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
files []input.FileData
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "empty array returns empty string",
|
||||
files: []input.FileData{},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "single file formatted with template",
|
||||
files: []input.FileData{
|
||||
{Path: "main.go", Content: "package main"},
|
||||
},
|
||||
want: "File: main.go\n\n```\npackage main\n```",
|
||||
},
|
||||
{
|
||||
name: "multiple files separated by double newline",
|
||||
files: []input.FileData{
|
||||
{Path: "a.go", Content: "code a"},
|
||||
{Path: "b.go", Content: "code b"},
|
||||
},
|
||||
want: "File: a.go\n\n```\ncode a\n```\n\nFile: b.go\n\n```\ncode b\n```",
|
||||
},
|
||||
{
|
||||
name: "stdin path 'input' appears correctly",
|
||||
files: []input.FileData{
|
||||
{Path: "input", Content: "stdin content"},
|
||||
},
|
||||
want: "File: input\n\n```\nstdin content\n```",
|
||||
},
|
||||
{
|
||||
name: "file path with directory",
|
||||
files: []input.FileData{
|
||||
{Path: "src/main.go", Content: "package main"},
|
||||
},
|
||||
want: "File: src/main.go\n\n```\npackage main\n```",
|
||||
},
|
||||
{
|
||||
name: "content with backticks still wrapped",
|
||||
files: []input.FileData{
|
||||
{Path: "test.md", Content: "```go\nfunc main() {}\n```"},
|
||||
},
|
||||
want: "File: test.md\n\n```\n```go\nfunc main() {}\n```\n```",
|
||||
},
|
||||
{
|
||||
name: "empty content",
|
||||
files: []input.FileData{
|
||||
{Path: "empty.txt", Content: ""},
|
||||
},
|
||||
want: "File: empty.txt\n\n```\n\n```",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := formatFiles(tt.files)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCombineContent(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
promptStr string
|
||||
filesStr string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "both empty returns empty",
|
||||
promptStr: "",
|
||||
filesStr: "",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "prompt only",
|
||||
promptStr: "analyze this",
|
||||
filesStr: "",
|
||||
want: "analyze this",
|
||||
},
|
||||
{
|
||||
name: "files only uses default prompt",
|
||||
promptStr: "",
|
||||
filesStr: "File: a.go\n\n```\ncode\n```",
|
||||
want: "Analyze the following:\n\nFile: a.go\n\n```\ncode\n```",
|
||||
},
|
||||
{
|
||||
name: "prompt and files combined with separator",
|
||||
promptStr: "review this code",
|
||||
filesStr: "File: a.go\n\n```\ncode\n```",
|
||||
want: "review this code\n\nFile: a.go\n\n```\ncode\n```",
|
||||
},
|
||||
{
|
||||
name: "multiline prompt preserved",
|
||||
promptStr: "first line\nsecond line",
|
||||
filesStr: "File: a.go\n\n```\ncode\n```",
|
||||
want: "first line\nsecond line\n\nFile: a.go\n\n```\ncode\n```",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := combineContent(tt.promptStr, tt.filesStr)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConstructQuery(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
prompts []string
|
||||
files []input.FileData
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "empty inputs returns empty",
|
||||
prompts: []string{},
|
||||
files: []input.FileData{},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "prompt only",
|
||||
prompts: []string{"analyze this"},
|
||||
files: []input.FileData{},
|
||||
want: "analyze this",
|
||||
},
|
||||
{
|
||||
name: "file only with default prompt",
|
||||
prompts: []string{},
|
||||
files: []input.FileData{
|
||||
{Path: "main.go", Content: "package main"},
|
||||
},
|
||||
want: "Analyze the following:\n\nFile: main.go\n\n```\npackage main\n```",
|
||||
},
|
||||
{
|
||||
name: "multiple prompts and files",
|
||||
prompts: []string{"review", "focus on bugs"},
|
||||
files: []input.FileData{
|
||||
{Path: "a.go", Content: "code a"},
|
||||
{Path: "b.go", Content: "code b"},
|
||||
},
|
||||
want: "review\nfocus on bugs\n\nFile: a.go\n\n```\ncode a\n```\n\nFile: b.go\n\n```\ncode b\n```",
|
||||
},
|
||||
{
|
||||
name: "stdin as file with explicit prompt",
|
||||
prompts: []string{"analyze"},
|
||||
files: []input.FileData{
|
||||
{Path: "input", Content: "stdin data"},
|
||||
},
|
||||
want: "analyze\n\nFile: input\n\n```\nstdin data\n```",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ConstructQuery(tt.prompts, tt.files)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user