From bb89b9c71f9822d8c94c52db505fa58e0bca32a2 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 10 Jun 2026 19:16:19 +0200 Subject: [PATCH] added gemini cli for endpoint /runGemini --- AGENTS.md | 35 ++++++++++++++ go.mod | 3 ++ main.go | 137 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 173 insertions(+), 2 deletions(-) create mode 100644 AGENTS.md create mode 100644 go.mod diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..be9db53 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,35 @@ +# Repository Guidelines + +## Project Structure & Module Organization + +This repository contains a small Go HTTP wrapper for running AI CLI prompts on a Debian host. The active service entry point is `main.go`; it exposes `POST /run`, `POST /runCodex`, and `POST /runGemini`. `/run` is a backward-compatible alias for `/runCodex`. `daiapi.service` is the systemd unit, and `go.mod` defines the local module. `old-python/` contains the previous Python implementation for reference only. + +## Build, Test, and Development Commands + +- `gofmt -w main.go`: format Go source before committing. +- `go test ./...`: run all Go tests; currently reports no test files. +- `go build ./...`: verify the module builds. +- `go build -o daiapi .`: build the service binary used by `daiapi.service`. +- `PORT=8000 ./daiapi`: run locally on port 8000. +- `curl -X POST localhost:8000/runCodex -H 'Content-Type: application/json' -d '{"prompt":"Say hello"}'`: smoke-test Codex. +- `curl -X POST localhost:8000/runGemini -H 'Content-Type: application/json' -d '{"prompt":"Say hello"}'`: smoke-test Gemini. + +## API Behavior + +Codex endpoints accept `{"prompt":"..."}` and return the original response shape: `success`, `answer`, `usage`, and `stderr`. Keep `/run` and `/runCodex` behavior identical. Gemini uses the same request body and returns `success`, `answer`, `usage`, `stdout`, `stderr`, `exitCode`, and optional `error`. Use `exec.Command` or `exec.CommandContext` with argument slices; never pass prompts through shell strings. + +## Coding Style & Naming Conventions + +Use standard Go formatting and idioms. Keep imports, indentation, and struct tags managed by `gofmt`. Use short lower-camel-case names for handlers and locals, such as `runCodexHandler`, `runGeminiHandler`, and `finalText`. Keep helpers unexported unless there is a concrete external need. + +## Testing Guidelines + +Add Go tests beside the code as `*_test.go` files, using `testing` and `net/http/httptest`. Prefer handler-level tests for request validation, status codes, JSON response shape, and CLI error handling. Isolate external command invocation so tests do not require real Codex or Gemini credentials. + +## Commit & Pull Request Guidelines + +The current history uses short initial-commit messages only, so use concise imperative subjects, for example `Add Gemini endpoint tests`. Pull requests should explain API behavior changes, list manual verification commands, and call out deployment impact such as changes to `daiapi.service`, environment variables, PATH requirements, ports, or CLI arguments. + +## Security & Configuration Notes + +The service executes prompts through local CLI tools. Codex runs with `--full-auto` and access to `/tmp`; Gemini must be available in the service `PATH`. Keep `PORT` and secrets in `/etc/daiapi/daiapi.env`, and do not commit host-specific credentials or expanded environment files. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ceb3542 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module daiapi + +go 1.24 diff --git a/main.go b/main.go index f2553eb..e45ade9 100644 --- a/main.go +++ b/main.go @@ -3,11 +3,15 @@ package main import ( "bufio" "bytes" + "context" "encoding/json" + "errors" "log" "net/http" "os" "os/exec" + "strings" + "time" ) type runRequest struct { @@ -21,15 +25,37 @@ type runResponse struct { Stderr string `json:"stderr"` } +type geminiResponse struct { + Success bool `json:"success"` + Answer *string `json:"answer"` + Usage interface{} `json:"usage"` + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + ExitCode int `json:"exitCode"` + Error string `json:"error,omitempty"` +} + type event struct { Type string `json:"type"` Item map[string]interface{} `json:"item"` Usage interface{} `json:"usage"` } +type commandResult struct { + Stdout string + Stderr string + ExitCode int + Err error + TimedOut bool +} + +const geminiTimeout = 5 * time.Minute + func main() { mux := http.NewServeMux() - mux.HandleFunc("/run", runHandler) + mux.HandleFunc("/run", runCodexHandler) + mux.HandleFunc("/runCodex", runCodexHandler) + mux.HandleFunc("/runGemini", runGeminiHandler) addr := ":8000" if port := os.Getenv("PORT"); port != "" { @@ -42,7 +68,7 @@ func main() { } } -func runHandler(w http.ResponseWriter, r *http.Request) { +func runCodexHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return @@ -112,3 +138,110 @@ func runHandler(w http.ResponseWriter, r *http.Request) { http.Error(w, encodeErr.Error(), http.StatusInternalServerError) } } + +func runGeminiHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + if r.Method != http.MethodPost { + writeGeminiError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + var req runRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeGeminiError(w, http.StatusBadRequest, "invalid json body") + return + } + + prompt := strings.TrimSpace(req.Prompt) + if prompt == "" { + writeGeminiError(w, http.StatusBadRequest, "prompt must not be empty") + return + } + + result := runCommand(r.Context(), geminiTimeout, "gemini", "-p", req.Prompt) + answer := result.Stdout + resp := geminiResponse{ + Success: result.Err == nil, + Answer: &answer, + Usage: nil, + Stdout: result.Stdout, + Stderr: result.Stderr, + ExitCode: result.ExitCode, + Error: commandError(result), + } + + if result.Err != nil { + w.WriteHeader(http.StatusInternalServerError) + } + + if encodeErr := json.NewEncoder(w).Encode(resp); encodeErr != nil { + http.Error(w, encodeErr.Error(), http.StatusInternalServerError) + } +} + +func runCommand(parent context.Context, timeout time.Duration, name string, args ...string) commandResult { + ctx, cancel := context.WithTimeout(parent, timeout) + defer cancel() + + cmd := exec.CommandContext(ctx, name, args...) + + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + result := commandResult{ + Stdout: stdout.String(), + Stderr: stderr.String(), + ExitCode: 0, + Err: err, + TimedOut: ctx.Err() == context.DeadlineExceeded, + } + + if err != nil { + result.ExitCode = -1 + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + result.ExitCode = exitErr.ExitCode() + } + } + + return result +} + +func commandError(result commandResult) string { + if result.TimedOut { + return "process timed out" + } + if result.Err == nil { + return "" + } + if errors.Is(result.Err, exec.ErrNotFound) { + return "gemini CLI not found in PATH" + } + + var execErr *exec.Error + if errors.As(result.Err, &execErr) { + return "gemini CLI not found in PATH" + } + + return result.Err.Error() +} + +func writeGeminiError(w http.ResponseWriter, status int, message string) { + w.WriteHeader(status) + resp := geminiResponse{ + Success: false, + Answer: nil, + Usage: nil, + Stdout: "", + Stderr: "", + ExitCode: -1, + Error: message, + } + if err := json.NewEncoder(w).Encode(resp); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +}