feat(nadezhda): AI setup wizard (nadezhda ai) + credential store + Ollama port fix

Add a re-runnable interactive wizard that configures an AI provider for the ideation layer: Claude, OpenAI, Gemini, or a local Ollama. Keys persist to ~/.config/nadezhda/credentials (0600, atomic temp+rename) and auto-load on every run; a shell-exported key always wins over the file. A key-name allowlist (NADEZHDA_* / *_API_KEY) blocks env-name injection into spawned subprocesses.

The Ollama path detects a running instance and auto-configures it, else writes an embedded compose file plus one docker command, else prints an install hint. Ollama's host port is remapped to a non-default 39847 (OLLAMA_HOST_PORT) to avoid colliding with an existing Ollama on 11434; the container-internal port stays 11434.

Thread a signal-cancellable root context through cobra (ExecuteContext). Default the Anthropic model to claude-sonnet-4-6.
This commit is contained in:
CarterPerez-dev 2026-07-06 21:26:10 -04:00
parent 41ffd49699
commit 0a86bb8593
15 changed files with 788 additions and 4 deletions

View File

@ -0,0 +1,22 @@
// ©AngelaMos | 2026
// ai.go
package main
import (
"github.com/spf13/cobra"
"github.com/CarterPerez-dev/nadezhda/internal/setup"
)
var aiCmd = &cobra.Command{
Use: "ai",
Short: "Set up an AI provider for ideation (interactive, re-runnable)",
RunE: func(cmd *cobra.Command, args []string) error {
return setup.Run(cmd.InOrStdin(), cmd.OutOrStdout())
},
}
func init() {
rootCmd.AddCommand(aiCmd)
}

View File

@ -3,10 +3,16 @@
package main
import "os"
import (
"context"
"os"
"os/signal"
)
func main() {
if err := rootCmd.Execute(); err != nil {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
if err := rootCmd.ExecuteContext(ctx); err != nil {
os.Exit(1)
}
}

View File

@ -4,9 +4,12 @@
package main
import (
"os"
"github.com/spf13/cobra"
"github.com/CarterPerez-dev/nadezhda/internal/config"
"github.com/CarterPerez-dev/nadezhda/internal/setup"
)
var (
@ -27,6 +30,9 @@ func init() {
}
func loadConfig() (config.Config, error) {
if err := setup.Load(); err != nil {
return config.Config{}, err
}
cfg, err := config.Load(flagConfig)
if err != nil {
return config.Config{}, err
@ -34,5 +40,21 @@ func loadConfig() (config.Config, error) {
if flagDB != "" {
cfg.DBPath = flagDB
}
if p := os.Getenv(setup.EnvProvider); p != "" {
cfg.AI.Enabled = true
cfg.AI.Provider = p
}
if u := os.Getenv(setup.EnvQwenURL); u != "" {
cfg.AI.Qwen.BaseURL = u
}
return cfg, nil
}
func isInteractive(cmd *cobra.Command) bool {
f, ok := cmd.InOrStdin().(*os.File)
if !ok {
return false
}
fi, err := f.Stat()
return err == nil && fi.Mode()&os.ModeCharDevice != 0
}

View File

@ -11,6 +11,7 @@ require (
github.com/spf13/cobra v1.10.2
github.com/temoto/robotstxt v1.1.2
golang.org/x/sync v0.21.0
golang.org/x/term v0.41.0
golang.org/x/time v0.15.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.53.0

View File

@ -149,6 +149,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=

View File

@ -42,14 +42,14 @@ const (
defaultWeightEPSS = 0.08
defaultAIProvider = "qwen"
defaultQwenBaseURL = "http://localhost:11434/v1"
defaultQwenBaseURL = "http://localhost:39847/v1"
defaultQwenModel = "qwen2.5:7b"
defaultOpenAIURL = "https://api.openai.com/v1"
defaultOpenAIModel = "gpt-4o-mini"
defaultGeminiURL = "https://generativelanguage.googleapis.com/v1beta/openai/"
defaultGeminiModel = "gemini-2.5-flash"
defaultAnthropicURL = "https://api.anthropic.com/v1"
defaultClaudeModel = "claude-opus-4-8"
defaultClaudeModel = "claude-sonnet-4-6"
)
var defaultTrackingParams = []string{

View File

@ -0,0 +1,145 @@
// ©AngelaMos | 2026
// credentials.go
package setup
import (
"bufio"
"fmt"
"os"
"sort"
"strings"
)
const (
EnvProvider = "NADEZHDA_AI_PROVIDER"
EnvQwenURL = "NADEZHDA_QWEN_BASE_URL"
commentMark = "#"
kvSeparator = "="
credentialKeyPrefix = "NADEZHDA_"
credentialKeySuffix = "_API_KEY"
tmpSuffix = ".tmp"
credentialsHeader = "# ©AngelaMos | 2026\n# nadezhda credentials — auto-managed by `nadezhda ai`; keep private, do not commit\n\n"
)
func Load() error {
path, err := CredentialsPath()
if err != nil {
return err
}
kv, err := readCredentials(path)
if err != nil {
return err
}
for k, v := range kv {
if !allowedKey(k) {
continue
}
if _, set := os.LookupEnv(k); set {
continue
}
if err := os.Setenv(k, v); err != nil {
return fmt.Errorf("setup: set %s: %w", k, err)
}
}
return nil
}
func allowedKey(k string) bool {
return strings.HasPrefix(k, credentialKeyPrefix) || strings.HasSuffix(k, credentialKeySuffix)
}
func NonSecretEnviron() []string {
env := os.Environ()
out := make([]string, 0, len(env))
for _, e := range env {
if k, _, ok := strings.Cut(e, kvSeparator); ok && allowedKey(k) {
continue
}
out = append(out, e)
}
return out
}
func Save(updates map[string]string) error {
path, err := CredentialsPath()
if err != nil {
return err
}
dir, err := ConfigDir()
if err != nil {
return err
}
if err := os.MkdirAll(dir, dirPerm); err != nil {
return fmt.Errorf("setup: create config dir: %w", err)
}
kv, err := readCredentials(path)
if err != nil {
return err
}
for k, v := range updates {
kv[k] = v
}
return writeCredentials(path, kv)
}
func readCredentials(path string) (map[string]string, error) {
f, err := os.Open(path)
if err != nil {
if os.IsNotExist(err) {
return map[string]string{}, nil
}
return nil, fmt.Errorf("setup: read credentials: %w", err)
}
defer f.Close()
kv := map[string]string{}
sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, commentMark) {
continue
}
k, v, ok := strings.Cut(line, kvSeparator)
if !ok {
continue
}
key := strings.TrimSpace(k)
if key == "" {
continue
}
kv[key] = strings.TrimSpace(v)
}
return kv, sc.Err()
}
func writeCredentials(path string, kv map[string]string) error {
keys := make([]string, 0, len(kv))
for k := range kv {
keys = append(keys, k)
}
sort.Strings(keys)
var b strings.Builder
b.WriteString(credentialsHeader)
for _, k := range keys {
fmt.Fprintf(&b, "%s=%s\n", k, kv[k])
}
tmp := path + tmpSuffix
if err := os.WriteFile(tmp, []byte(b.String()), filePerm); err != nil {
return fmt.Errorf("setup: write credentials: %w", err)
}
if err := os.Chmod(tmp, filePerm); err != nil {
os.Remove(tmp)
return fmt.Errorf("setup: secure credentials: %w", err)
}
if err := os.Rename(tmp, path); err != nil {
os.Remove(tmp)
return fmt.Errorf("setup: replace credentials: %w", err)
}
return nil
}

View File

@ -0,0 +1,144 @@
// ©AngelaMos | 2026
// credentials_test.go
package setup
import (
"os"
"testing"
)
func TestCredentialsRoundTripAndPerms(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
if err := Save(map[string]string{"OPENAI_API_KEY": "sk-abc", EnvProvider: "openai"}); err != nil {
t.Fatalf("Save: %v", err)
}
if err := Save(map[string]string{"NVD_API_KEY": "nvd-1"}); err != nil {
t.Fatalf("Save merge: %v", err)
}
path, _ := CredentialsPath()
kv, err := readCredentials(path)
if err != nil {
t.Fatalf("readCredentials: %v", err)
}
if kv["OPENAI_API_KEY"] != "sk-abc" || kv[EnvProvider] != "openai" || kv["NVD_API_KEY"] != "nvd-1" {
t.Errorf("merge lost data: %v", kv)
}
fi, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if perm := fi.Mode().Perm(); perm != 0o600 {
t.Errorf("credentials perm = %o, want 600", perm)
}
}
func TestLoadEnvWins(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
if err := Save(map[string]string{"OPENAI_API_KEY": "fromfile", "NADEZHDA_TEST_ONLY": "x"}); err != nil {
t.Fatal(err)
}
t.Setenv("OPENAI_API_KEY", "fromenv")
defer os.Unsetenv("NADEZHDA_TEST_ONLY")
if err := Load(); err != nil {
t.Fatalf("Load: %v", err)
}
if got := os.Getenv("OPENAI_API_KEY"); got != "fromenv" {
t.Errorf("OPENAI_API_KEY = %q, want fromenv (env must win over file)", got)
}
if got := os.Getenv("NADEZHDA_TEST_ONLY"); got != "x" {
t.Errorf("NADEZHDA_TEST_ONLY = %q, want x (unset var loads from file)", got)
}
}
func TestReadCredentialsSkipsCommentsAndBlanks(t *testing.T) {
path := t.TempDir() + "/creds"
content := "# header\n\nOPENAI_API_KEY=sk-1\n # indented comment\nbadline_no_sep\nNVD_API_KEY = nvd-2 \n"
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
kv, err := readCredentials(path)
if err != nil {
t.Fatal(err)
}
if kv["OPENAI_API_KEY"] != "sk-1" || kv["NVD_API_KEY"] != "nvd-2" || len(kv) != 2 {
t.Errorf("parsed = %v, want exactly 2 clean entries", kv)
}
}
func TestSaveCreatesDir0700AndFile0600(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
if err := Save(map[string]string{"OPENAI_API_KEY": "k"}); err != nil {
t.Fatal(err)
}
dir, _ := ConfigDir()
if di, _ := os.Stat(dir); di.Mode().Perm() != 0o700 {
t.Errorf("config dir perms = %o, want 700", di.Mode().Perm())
}
path, _ := CredentialsPath()
if fi, _ := os.Stat(path); fi.Mode().Perm() != 0o600 {
t.Errorf("credentials perms = %o, want 600", fi.Mode().Perm())
}
}
func TestSaveTightensExistingLooseFile(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
dir, _ := ConfigDir()
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
}
path, _ := CredentialsPath()
if err := os.WriteFile(path, []byte("OPENAI_API_KEY=old\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := Save(map[string]string{"OPENAI_API_KEY": "new"}); err != nil {
t.Fatal(err)
}
if fi, _ := os.Stat(path); fi.Mode().Perm() != 0o600 {
t.Errorf("Save left perms %o on a pre-existing 0644 file, want 600", fi.Mode().Perm())
}
}
func TestLoadRespectsSetEmptyEnv(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
if err := Save(map[string]string{"OPENAI_API_KEY": "fromfile"}); err != nil {
t.Fatal(err)
}
t.Setenv("OPENAI_API_KEY", "")
if err := Load(); err != nil {
t.Fatal(err)
}
if got := os.Getenv("OPENAI_API_KEY"); got != "" {
t.Errorf("an explicitly-cleared env var was overridden from file: %q", got)
}
}
func TestLoadBlocksDisallowedAndEmptyKeys(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
dir, _ := ConfigDir()
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
}
path, _ := CredentialsPath()
if err := os.WriteFile(path, []byte("=orphan\nLD_PRELOAD=/tmp/evil.so\nOPENAI_API_KEY=ok\n"), 0o600); err != nil {
t.Fatal(err)
}
os.Unsetenv("LD_PRELOAD")
os.Unsetenv("OPENAI_API_KEY")
defer os.Unsetenv("LD_PRELOAD")
defer os.Unsetenv("OPENAI_API_KEY")
if err := Load(); err != nil {
t.Fatalf("Load must tolerate an empty-key line, got: %v", err)
}
if _, set := os.LookupEnv("LD_PRELOAD"); set {
t.Error("LD_PRELOAD injected from credentials file — allowlist bypass (RCE vector)")
}
if os.Getenv("OPENAI_API_KEY") != "ok" {
t.Error("allowlisted OPENAI_API_KEY not loaded")
}
}

View File

@ -0,0 +1,30 @@
# ©AngelaMos | 2026
# ollama.compose.yml
name: nadezhda-ollama
services:
ollama:
image: ollama/ollama:latest
container_name: nadezhda-ollama
ports:
- "127.0.0.1:${OLLAMA_HOST_PORT:-39847}:11434"
volumes:
- nadezhda-ollama-models:/root/.ollama
entrypoint: ["/bin/sh", "-c"]
command:
- |
ollama serve &
until ollama list >/dev/null 2>&1; do sleep 1; done
ollama pull qwen2.5:7b
wait
healthcheck:
test: ["CMD-SHELL", "ollama list | grep -q qwen2.5"]
interval: 10s
timeout: 5s
retries: 60
start_period: 30s
restart: unless-stopped
volumes:
nadezhda-ollama-models:

View File

@ -0,0 +1,67 @@
// ©AngelaMos | 2026
// ollama.go
package setup
import (
_ "embed"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
)
//go:embed ollama.compose.yml
var ComposeYAML []byte
const (
composeFileName = "ollama.compose.yml"
composePerm = 0o644
versionPath = "/api/version"
apiV1Suffix = "/v1"
)
var ollamaCandidates = []string{
"http://localhost:39847",
"http://localhost:11434",
}
func DetectOllama(client *http.Client) string {
for _, base := range ollamaCandidates {
resp, err := client.Get(base + versionPath)
if err != nil {
continue
}
resp.Body.Close()
if resp.StatusCode == http.StatusOK {
return base
}
}
return ""
}
func WriteCompose(dir string) (string, error) {
path := filepath.Join(dir, composeFileName)
if err := os.WriteFile(path, ComposeYAML, composePerm); err != nil {
return "", fmt.Errorf("setup: write compose: %w", err)
}
return path, nil
}
func HasBinary(name string) bool {
_, err := exec.LookPath(name)
return err == nil
}
func OllamaReachable(baseURL string) bool {
base := strings.TrimSuffix(baseURL, apiV1Suffix)
client := &http.Client{Timeout: detectTimeout}
resp, err := client.Get(base + versionPath)
if err != nil {
return false
}
resp.Body.Close()
return resp.StatusCode == http.StatusOK
}

View File

@ -0,0 +1,62 @@
// ©AngelaMos | 2026
// ollama_test.go
package setup
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestDetectOllama(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == versionPath {
w.WriteHeader(http.StatusOK)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
old := ollamaCandidates
defer func() { ollamaCandidates = old }()
client := &http.Client{Timeout: 2 * time.Second}
ollamaCandidates = []string{srv.URL}
if got := DetectOllama(client); got != srv.URL {
t.Errorf("DetectOllama = %q, want %q", got, srv.URL)
}
ollamaCandidates = []string{"http://127.0.0.1:1"}
if got := DetectOllama(client); got != "" {
t.Errorf("DetectOllama with no server = %q, want empty", got)
}
}
func TestWriteComposeMatchesEmbed(t *testing.T) {
yaml := string(ComposeYAML)
if len(yaml) == 0 || !strings.Contains(yaml, "nadezhda-ollama") || !strings.Contains(yaml, "qwen2.5:7b") {
t.Fatal("embedded compose is empty or missing expected content")
}
dir := t.TempDir()
path, err := WriteCompose(dir)
if err != nil {
t.Fatalf("WriteCompose: %v", err)
}
if path != filepath.Join(dir, composeFileName) {
t.Errorf("path = %q", path)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(got) != yaml {
t.Error("written compose differs from embedded content")
}
}

View File

@ -0,0 +1,33 @@
// ©AngelaMos | 2026
// paths.go
package setup
import (
"fmt"
"os"
"path/filepath"
)
const (
appDir = "nadezhda"
credentialsFile = "credentials"
dirPerm = 0o700
filePerm = 0o600
)
func ConfigDir() (string, error) {
base, err := os.UserConfigDir()
if err != nil {
return "", fmt.Errorf("setup: locate config dir: %w", err)
}
return filepath.Join(base, appDir), nil
}
func CredentialsPath() (string, error) {
dir, err := ConfigDir()
if err != nil {
return "", err
}
return filepath.Join(dir, credentialsFile), nil
}

View File

@ -0,0 +1,158 @@
// ©AngelaMos | 2026
// wizard.go
package setup
import (
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"golang.org/x/term"
)
const (
ProviderClaude = "anthropic"
ProviderOpenAI = "openai"
ProviderGemini = "gemini"
ProviderQwen = "qwen"
envOpenAIKey = "OPENAI_API_KEY"
envGeminiKey = "GEMINI_API_KEY"
envAnthropicKey = "ANTHROPIC_API_KEY"
envNVDKey = "NVD_API_KEY"
composeQwenURL = "http://localhost:39847/v1"
nativeQwenURL = "http://localhost:11434/v1"
detectTimeout = 3 * time.Second
ollamaInstall = "https://ollama.com/download"
)
type apiChoice struct {
provider string
envKey string
label string
}
var apiProviders = map[string]apiChoice{
"1": {ProviderClaude, envAnthropicKey, "Claude (Anthropic)"},
"2": {ProviderOpenAI, envOpenAIKey, "OpenAI"},
"3": {ProviderGemini, envGeminiKey, "Gemini (Google)"},
}
func Run(in io.Reader, out io.Writer) error {
fmt.Fprintln(out, "nadezhda AI setup")
fmt.Fprintln(out, " 1) Claude (Anthropic) 2) OpenAI 3) Gemini 4) Ollama (local qwen, free, no key)")
choice := prompt(in, out, "choose a provider [1-4]: ")
switch choice {
case "1", "2", "3":
return setupAPIProvider(in, out, apiProviders[choice])
case "4":
return setupOllama(out)
default:
return fmt.Errorf("setup: invalid choice %q (want 1-4)", choice)
}
}
func setupAPIProvider(in io.Reader, out io.Writer, c apiChoice) error {
fmt.Fprintf(out, "\nConfiguring %s.\n", c.label)
key := strings.TrimSpace(readSecret(in, out, fmt.Sprintf("paste your %s API key: ", c.label)))
if key == "" {
return fmt.Errorf("setup: no API key entered")
}
updates := map[string]string{
EnvProvider: c.provider,
c.envKey: key,
}
if nvd := strings.TrimSpace(readSecret(in, out, "optional NVD token for the CVE booster (enter to skip): ")); nvd != "" {
updates[envNVDKey] = nvd
}
if err := Save(updates); err != nil {
return err
}
path, _ := CredentialsPath()
fmt.Fprintf(out, "\nSaved to %s (0600). %s is ready — run: nadezhda ideate\n", path, c.label)
return nil
}
func setupOllama(out io.Writer) error {
fmt.Fprintln(out, "\nConfiguring Ollama (local qwen2.5, no API key).")
client := &http.Client{Timeout: detectTimeout}
if base := DetectOllama(client); base != "" {
fmt.Fprintf(out, "Found a running Ollama at %s — using it.\n", base)
return saveOllama(out, base+"/v1")
}
fmt.Fprintln(out, "No running Ollama found.")
switch {
case HasBinary("docker"):
dir, _ := os.Getwd()
path, err := WriteCompose(dir)
if err != nil {
return err
}
fmt.Fprintf(out, "Wrote %s. Start it with:\n docker compose -f %s up -d\n", path, path)
fmt.Fprintln(out, "(first run pulls qwen2.5:7b, a few minutes), then re-run `nadezhda ai` and pick 4.")
return saveOllama(out, composeQwenURL)
case HasBinary("ollama"):
fmt.Fprintln(out, "Ollama is installed but not running. Start it and pull the model:")
fmt.Fprintln(out, " ollama serve & then: ollama pull qwen2.5:7b")
return saveOllama(out, nativeQwenURL)
default:
fmt.Fprintf(out, "Install Ollama (%s) or Docker, then re-run `nadezhda ai`.\n", ollamaInstall)
return saveOllama(out, composeQwenURL)
}
}
func saveOllama(out io.Writer, baseURL string) error {
if err := Save(map[string]string{EnvProvider: ProviderQwen, EnvQwenURL: baseURL}); err != nil {
return err
}
path, _ := CredentialsPath()
fmt.Fprintf(out, "Saved qwen (%s) to %s. Run: nadezhda ideate\n", baseURL, path)
return nil
}
func prompt(in io.Reader, out io.Writer, label string) string {
fmt.Fprint(out, label)
return readLine(in)
}
func readSecret(in io.Reader, out io.Writer, label string) string {
fmt.Fprint(out, label)
if f, ok := in.(*os.File); ok && term.IsTerminal(int(f.Fd())) {
b, err := term.ReadPassword(int(f.Fd()))
fmt.Fprintln(out)
if err != nil {
return ""
}
return string(b)
}
return readLine(in)
}
func readLine(in io.Reader) string {
var b strings.Builder
buf := make([]byte, 1)
for {
n, err := in.Read(buf)
if n > 0 {
if buf[0] == '\n' {
break
}
if buf[0] != '\r' {
b.WriteByte(buf[0])
}
}
if err != nil {
break
}
}
return strings.TrimSpace(b.String())
}

View File

@ -0,0 +1,82 @@
// ©AngelaMos | 2026
// wizard_test.go
package setup
import (
"bytes"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestWizardAPIProvider(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
var out bytes.Buffer
in := strings.NewReader("2\nsk-test-key\n\n")
if err := Run(in, &out); err != nil {
t.Fatalf("Run: %v", err)
}
path, _ := CredentialsPath()
kv, _ := readCredentials(path)
if kv[EnvProvider] != ProviderOpenAI || kv["OPENAI_API_KEY"] != "sk-test-key" {
t.Errorf("saved = %v, want openai + key", kv)
}
if _, ok := kv["NVD_API_KEY"]; ok {
t.Error("NVD key should be absent when skipped")
}
if !strings.Contains(out.String(), "OpenAI is ready") {
t.Errorf("output missing confirmation:\n%s", out.String())
}
if strings.Contains(out.String(), "sk-test-key") {
t.Error("wizard echoed the API key to its output")
}
}
func TestWizardOllamaDetected(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
old := ollamaCandidates
ollamaCandidates = []string{srv.URL}
defer func() { ollamaCandidates = old }()
var out bytes.Buffer
if err := Run(strings.NewReader("4\n"), &out); err != nil {
t.Fatalf("Run: %v", err)
}
path, _ := CredentialsPath()
kv, _ := readCredentials(path)
if kv[EnvProvider] != ProviderQwen || kv[EnvQwenURL] != srv.URL+"/v1" {
t.Errorf("saved = %v, want qwen + %s/v1", kv, srv.URL)
}
if !strings.Contains(out.String(), "Found a running Ollama") {
t.Errorf("output missing detect line:\n%s", out.String())
}
}
func TestWizardInvalidChoice(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
if err := Run(strings.NewReader("9\n"), &bytes.Buffer{}); err == nil {
t.Error("invalid choice should error")
}
}
func TestReadLine(t *testing.T) {
cases := map[string]string{
"hello\n": "hello",
"trim \r\n": "trim",
"noeol": "noeol",
"\n": "",
}
for in, want := range cases {
if got := readLine(strings.NewReader(in)); got != want {
t.Errorf("readLine(%q) = %q, want %q", in, got, want)
}
}
}

View File

@ -29,5 +29,15 @@ lint: vet
tidy:
go mod tidy
ollama-up:
docker compose -f internal/setup/ollama.compose.yml up -d
@echo "ollama starting on 127.0.0.1:39847 (override with OLLAMA_HOST_PORT) — the first run pulls qwen2.5:7b (a few minutes). watch progress with: just ollama-logs"
ollama-down:
docker compose -f internal/setup/ollama.compose.yml down
ollama-logs:
docker compose -f internal/setup/ollama.compose.yml logs -f
clean:
rm -f {{binary}} *.db *.db-wal *.db-shm