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:
parent
41ffd49699
commit
0a86bb8593
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue