diff --git a/cmd/daemon.go b/cmd/daemon.go index c93f4a58..7963b16a 100644 --- a/cmd/daemon.go +++ b/cmd/daemon.go @@ -97,6 +97,7 @@ func runDaemonStart(_ *cobra.Command, _ []string) error { return sess, nil } + daemon.SetVersion(version) srv := daemon.New(daemon.Config{Port: daemonPort, Host: daemonHost, APIKey: apiKey}, factory) // Wire a lightweight provider-connectivity readiness probe. The default diff --git a/internal/config/aliases.go b/internal/config/aliases.go deleted file mode 100644 index e7a7cd64..00000000 --- a/internal/config/aliases.go +++ /dev/null @@ -1,81 +0,0 @@ -package config - -import ( - "encoding/json" - "os" - "path/filepath" - "strings" - - "github.com/GrayCodeAI/hawk/internal/home" -) - -// DefaultAliases returns the built-in command aliases. -func DefaultAliases() map[string]string { - return map[string]string{ - "fix": "Find and fix the bug in", - "test": "Write tests for", - "review": "Review this code for issues:", - } -} - -// aliasesFilePath returns the path to the aliases config file. -func aliasesFilePath() string { - home := home.Dir() - return filepath.Join(home, ".hawk", "aliases.json") -} - -// LoadAliases reads command aliases from ~/.hawk/aliases.json. -// Returns default aliases if the file does not exist. -func LoadAliases() map[string]string { - data, err := os.ReadFile(aliasesFilePath()) - if err != nil { - return DefaultAliases() - } - var aliases map[string]string - if err := json.Unmarshal(data, &aliases); err != nil { - return DefaultAliases() - } - // Merge defaults for any missing keys - defaults := DefaultAliases() - for k, v := range defaults { - if _, ok := aliases[k]; !ok { - aliases[k] = v - } - } - return aliases -} - -// SaveAliases writes command aliases to ~/.hawk/aliases.json. -func SaveAliases(aliases map[string]string) error { - path := aliasesFilePath() - _ = os.MkdirAll(filepath.Dir(path), 0o755) - data, err := json.MarshalIndent(aliases, "", " ") - if err != nil { - return err - } - return os.WriteFile(path, data, 0o644) -} - -// ResolveAlias checks if the first word of input matches an alias key. -// If found, it replaces the alias with its expansion and appends the rest of the input. -// If not found, returns the input unchanged. -func ResolveAlias(input string, aliases map[string]string) string { - input = strings.TrimSpace(input) - if input == "" { - return input - } - - // Split into first word and the rest - parts := strings.SplitN(input, " ", 2) - key := parts[0] - - expansion, ok := aliases[key] - if !ok { - return input - } - - if len(parts) > 1 { - return expansion + " " + parts[1] - } - return expansion -} diff --git a/internal/config/aliases_test.go b/internal/config/aliases_test.go deleted file mode 100644 index 14d97458..00000000 --- a/internal/config/aliases_test.go +++ /dev/null @@ -1,104 +0,0 @@ -package config - -import ( - "os" - "path/filepath" - "testing" -) - -func TestDefaultAliases(t *testing.T) { - aliases := DefaultAliases() - if len(aliases) != 3 { - t.Fatalf("expected 3 default aliases, got %d", len(aliases)) - } - if aliases["fix"] != "Find and fix the bug in" { - t.Fatalf("unexpected fix alias: %q", aliases["fix"]) - } - if aliases["test"] != "Write tests for" { - t.Fatalf("unexpected test alias: %q", aliases["test"]) - } - if aliases["review"] != "Review this code for issues:" { - t.Fatalf("unexpected review alias: %q", aliases["review"]) - } -} - -func TestLoadAliases_NoFile(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - aliases := LoadAliases() - if len(aliases) != 3 { - t.Fatalf("expected defaults when no file, got %d entries", len(aliases)) - } -} - -func TestSaveAndLoadAliases(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - - custom := map[string]string{ - "fix": "Find and fix the bug in", - "test": "Write tests for", - "review": "Review this code for issues:", - "deploy": "Deploy this to production", - } - if err := SaveAliases(custom); err != nil { - t.Fatalf("save failed: %v", err) - } - - loaded := LoadAliases() - if loaded["deploy"] != "Deploy this to production" { - t.Fatalf("custom alias not loaded: %v", loaded) - } - // Defaults should still be present - if loaded["fix"] != "Find and fix the bug in" { - t.Fatalf("default alias missing: %v", loaded) - } -} - -func TestLoadAliases_MergesDefaults(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - - // Save a file with only one alias - os.MkdirAll(filepath.Join(home, ".hawk"), 0o755) - os.WriteFile(filepath.Join(home, ".hawk", "aliases.json"), []byte(`{"deploy":"Ship it"}`), 0o644) - - loaded := LoadAliases() - if loaded["deploy"] != "Ship it" { - t.Fatalf("custom alias missing: %v", loaded) - } - if loaded["fix"] != "Find and fix the bug in" { - t.Fatalf("default alias not merged: %v", loaded) - } -} - -func TestResolveAlias_Match(t *testing.T) { - aliases := map[string]string{ - "fix": "Find and fix the bug in", - "test": "Write tests for", - } - tests := []struct { - input string - expected string - }{ - {"fix main.go", "Find and fix the bug in main.go"}, - {"test utils.go", "Write tests for utils.go"}, - {"fix", "Find and fix the bug in"}, - {"unknown command", "unknown command"}, - {"", ""}, - } - for _, tt := range tests { - got := ResolveAlias(tt.input, aliases) - if got != tt.expected { - t.Errorf("ResolveAlias(%q) = %q, want %q", tt.input, got, tt.expected) - } - } -} - -func TestResolveAlias_NoMatch(t *testing.T) { - aliases := map[string]string{"fix": "Find and fix the bug in"} - input := "build the project" - got := ResolveAlias(input, aliases) - if got != input { - t.Fatalf("expected unchanged input, got %q", got) - } -} diff --git a/internal/config/budget.go b/internal/config/budget.go deleted file mode 100644 index 17ba2dc6..00000000 --- a/internal/config/budget.go +++ /dev/null @@ -1,91 +0,0 @@ -package config - -import ( - "fmt" -) - -// BudgetStatus represents the current budget state. -type BudgetStatus int - -const ( - BudgetOK BudgetStatus = iota // within budget - BudgetWarning // approaching limit - BudgetExceeded // over limit -) - -// String returns a human-readable budget status. -func (s BudgetStatus) String() string { - switch s { - case BudgetOK: - return "ok" - case BudgetWarning: - return "warning" - case BudgetExceeded: - return "exceeded" - default: - return "unknown" - } -} - -// BudgetConfig holds cost and token budget settings. -type BudgetConfig struct { - MaxCostUSD float64 `json:"max_cost_usd"` - MaxTokensPerSession int `json:"max_tokens_per_session"` - WarnAtPercent float64 `json:"warn_at_percent"` // 0-100, default 80 -} - -// DefaultBudgetConfig returns a BudgetConfig with sensible defaults. -func DefaultBudgetConfig() BudgetConfig { - return BudgetConfig{ - MaxCostUSD: 0, // 0 means unlimited - MaxTokensPerSession: 0, // 0 means unlimited - WarnAtPercent: 80, - } -} - -// LoadBudget loads budget configuration from settings. -// It reads MaxBudgetUSD from the global settings and applies defaults. -func LoadBudget() BudgetConfig { - settings := LoadSettings() - cfg := DefaultBudgetConfig() - if settings.MaxBudgetUSD > 0 { - cfg.MaxCostUSD = settings.MaxBudgetUSD - } - return cfg -} - -// CheckBudget evaluates current spending against the budget config. -// Returns BudgetOK, BudgetWarning, or BudgetExceeded. -func CheckBudget(spent float64, config BudgetConfig) BudgetStatus { - if config.MaxCostUSD <= 0 { - return BudgetOK // no budget set - } - if spent >= config.MaxCostUSD { - return BudgetExceeded - } - warnAt := config.WarnAtPercent - if warnAt <= 0 || warnAt > 100 { - warnAt = 80 - } - threshold := config.MaxCostUSD * (warnAt / 100.0) - if spent >= threshold { - return BudgetWarning - } - return BudgetOK -} - -// FormatBudgetStatus returns a human-readable string describing the budget status. -func FormatBudgetStatus(status BudgetStatus, spent, max float64) string { - if max <= 0 { - return fmt.Sprintf("$%.2f spent (no budget limit)", spent) - } - pct := (spent / max) * 100 - switch status { - case BudgetExceeded: - return fmt.Sprintf("BUDGET EXCEEDED: $%.2f / $%.2f (%.1f%%)", spent, max, pct) - case BudgetWarning: - return fmt.Sprintf("Budget warning: $%.2f / $%.2f (%.1f%%)", spent, max, pct) - default: - return fmt.Sprintf("$%.2f / $%.2f (%.1f%%)", spent, max, pct) - } -} diff --git a/internal/config/budget_test.go b/internal/config/budget_test.go deleted file mode 100644 index e3af2e18..00000000 --- a/internal/config/budget_test.go +++ /dev/null @@ -1,145 +0,0 @@ -package config - -import ( - "strings" - "testing" -) - -func TestDefaultBudgetConfig(t *testing.T) { - cfg := DefaultBudgetConfig() - if cfg.MaxCostUSD != 0 { - t.Fatalf("expected 0 default max cost, got %f", cfg.MaxCostUSD) - } - if cfg.MaxTokensPerSession != 0 { - t.Fatalf("expected 0 default max tokens, got %d", cfg.MaxTokensPerSession) - } - if cfg.WarnAtPercent != 80 { - t.Fatalf("expected 80%% warn threshold, got %f", cfg.WarnAtPercent) - } -} - -func TestCheckBudget_NoBudget(t *testing.T) { - cfg := BudgetConfig{MaxCostUSD: 0} - status := CheckBudget(10.0, cfg) - if status != BudgetOK { - t.Fatalf("expected OK with no budget, got %v", status) - } -} - -func TestCheckBudget_UnderBudget(t *testing.T) { - cfg := BudgetConfig{MaxCostUSD: 10.0, WarnAtPercent: 80} - status := CheckBudget(5.0, cfg) - if status != BudgetOK { - t.Fatalf("expected OK at 50%%, got %v", status) - } -} - -func TestCheckBudget_Warning(t *testing.T) { - cfg := BudgetConfig{MaxCostUSD: 10.0, WarnAtPercent: 80} - status := CheckBudget(8.5, cfg) // 85% - if status != BudgetWarning { - t.Fatalf("expected Warning at 85%%, got %v", status) - } -} - -func TestCheckBudget_Exceeded(t *testing.T) { - cfg := BudgetConfig{MaxCostUSD: 10.0, WarnAtPercent: 80} - status := CheckBudget(10.0, cfg) - if status != BudgetExceeded { - t.Fatalf("expected Exceeded at 100%%, got %v", status) - } - - status = CheckBudget(12.0, cfg) - if status != BudgetExceeded { - t.Fatalf("expected Exceeded at 120%%, got %v", status) - } -} - -func TestCheckBudget_ExactThreshold(t *testing.T) { - cfg := BudgetConfig{MaxCostUSD: 10.0, WarnAtPercent: 80} - status := CheckBudget(8.0, cfg) // exactly at 80% - if status != BudgetWarning { - t.Fatalf("expected Warning at exactly 80%%, got %v", status) - } -} - -func TestCheckBudget_CustomWarnPercent(t *testing.T) { - cfg := BudgetConfig{MaxCostUSD: 100.0, WarnAtPercent: 50} - status := CheckBudget(55.0, cfg) // 55% > 50% - if status != BudgetWarning { - t.Fatalf("expected Warning at 55%% with 50%% threshold, got %v", status) - } -} - -func TestCheckBudget_ZeroWarnPercent(t *testing.T) { - // Zero warn percent should default to 80% - cfg := BudgetConfig{MaxCostUSD: 10.0, WarnAtPercent: 0} - status := CheckBudget(7.0, cfg) // 70% < 80% - if status != BudgetOK { - t.Fatalf("expected OK at 70%% with defaulted 80%% threshold, got %v", status) - } - status = CheckBudget(8.5, cfg) // 85% > 80% - if status != BudgetWarning { - t.Fatalf("expected Warning at 85%% with defaulted 80%% threshold, got %v", status) - } -} - -func TestFormatBudgetStatus_NoBudget(t *testing.T) { - s := FormatBudgetStatus(BudgetOK, 5.0, 0) - if !strings.Contains(s, "no budget limit") { - t.Fatalf("expected 'no budget limit', got %q", s) - } - if !strings.Contains(s, "$5.00") { - t.Fatalf("expected '$5.00', got %q", s) - } -} - -func TestFormatBudgetStatus_OK(t *testing.T) { - s := FormatBudgetStatus(BudgetOK, 3.0, 10.0) - if !strings.Contains(s, "$3.00") || !strings.Contains(s, "$10.00") { - t.Fatalf("expected cost and max, got %q", s) - } - if !strings.Contains(s, "30.0%") { - t.Fatalf("expected percentage, got %q", s) - } -} - -func TestFormatBudgetStatus_Warning(t *testing.T) { - s := FormatBudgetStatus(BudgetWarning, 8.5, 10.0) - if !strings.Contains(s, "Budget warning") { - t.Fatalf("expected 'Budget warning', got %q", s) - } -} - -func TestFormatBudgetStatus_Exceeded(t *testing.T) { - s := FormatBudgetStatus(BudgetExceeded, 12.0, 10.0) - if !strings.Contains(s, "BUDGET EXCEEDED") { - t.Fatalf("expected 'BUDGET EXCEEDED', got %q", s) - } -} - -func TestBudgetStatus_String(t *testing.T) { - tests := []struct { - status BudgetStatus - expected string - }{ - {BudgetOK, "ok"}, - {BudgetWarning, "warning"}, - {BudgetExceeded, "exceeded"}, - {BudgetStatus(99), "unknown"}, - } - for _, tt := range tests { - if got := tt.status.String(); got != tt.expected { - t.Errorf("BudgetStatus(%d).String() = %q, want %q", tt.status, got, tt.expected) - } - } -} - -func TestLoadBudget(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - cfg := LoadBudget() - // No settings file → defaults - if cfg.WarnAtPercent != 80 { - t.Fatalf("expected 80%% warn threshold from default, got %f", cfg.WarnAtPercent) - } -} diff --git a/internal/config/config_templates.go b/internal/config/config_templates.go deleted file mode 100644 index c00ae631..00000000 --- a/internal/config/config_templates.go +++ /dev/null @@ -1,789 +0,0 @@ -package config - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "sort" - "strings" - "sync" -) - -// ConfigTemplate defines a project configuration template that generates -// hawk-specific files based on detected project characteristics. -type ConfigTemplate struct { - Name string `json:"name"` - Description string `json:"description"` - Language string `json:"language"` - Framework string `json:"framework"` - Content map[string]interface{} `json:"content"` - Files map[string]string `json:"files"` // additional files to generate - Tags []string `json:"tags"` -} - -// TemplateRegistry manages configuration templates. -type TemplateRegistry struct { - Templates map[string]*ConfigTemplate - mu sync.RWMutex -} - -// NewTemplateRegistry creates a registry pre-loaded with built-in templates. -func NewTemplateRegistry() *TemplateRegistry { - r := &TemplateRegistry{ - Templates: make(map[string]*ConfigTemplate), - } - - // go-default: standard Go project config - r.Templates["go-default"] = &ConfigTemplate{ - Name: "go-default", - Description: "Standard Go project configuration", - Language: "go", - Framework: "", - Content: map[string]interface{}{ - "model": "sonnet", - "sandbox": "workspace", - "auto_allow": []string{"bash(go build .)", "bash(go test ./...)", "bash(go vet ./...)"}, - "repo_map": true, - }, - Files: map[string]string{ - ".hawk/settings.json": `{ - "model": "{{model}}", - "sandbox": "workspace", - "auto_allow": ["bash(go build .)", "bash(go test ./...)", "bash(go vet ./...)"], - "repo_map": true -}`, - "AGENTS.md": `# {{project_name}} - -## Language -Go - -## Build -` + "```bash" + ` -go build . -go test -race ./... -` + "```" + ` - -## Guidelines -- Use standard library where possible -- Run go vet before committing -- All exported functions must have doc comments -`, - ".hawk/rules/go.md": `--- -paths: ["*.go"] ---- -# Go Rules - -- Use gofmt formatting -- Handle all errors explicitly -- Prefer table-driven tests -- No init() functions unless absolutely necessary -`, - }, - Tags: []string{"go", "backend", "cli"}, - } - - // go-api: API project with chi/gin settings - r.Templates["go-api"] = &ConfigTemplate{ - Name: "go-api", - Description: "Go API project with HTTP framework support", - Language: "go", - Framework: "chi/gin", - Content: map[string]interface{}{ - "model": "sonnet", - "sandbox": "workspace", - "auto_allow": []string{"bash(go build .)", "bash(go test ./...)", "bash(curl localhost:*)"}, - "repo_map": true, - }, - Files: map[string]string{ - ".hawk/settings.json": `{ - "model": "{{model}}", - "sandbox": "workspace", - "auto_allow": ["bash(go build .)", "bash(go test ./...)", "bash(curl localhost:*)"], - "repo_map": true -}`, - "AGENTS.md": `# {{project_name}} - -## Language -Go (API) - -## Build -` + "```bash" + ` -go build . -go test -race ./... -` + "```" + ` - -## Architecture -- HTTP API using {{framework}} -- Follow REST conventions -- Use middleware for auth, logging, recovery - -## Guidelines -- All endpoints must have request/response structs -- Use context for cancellation -- Validate input at handler level -- Return structured JSON errors -`, - ".hawk/rules/api.md": `--- -paths: ["**/handler*.go", "**/route*.go", "**/middleware*.go"] ---- -# API Rules - -- Every handler must validate input -- Use proper HTTP status codes -- Log errors with request context -- Never expose internal errors to clients -`, - }, - Tags: []string{"go", "api", "http", "backend"}, - } - - // ts-react: React/Next.js project - r.Templates["ts-react"] = &ConfigTemplate{ - Name: "ts-react", - Description: "React/Next.js TypeScript project", - Language: "typescript", - Framework: "react", - Content: map[string]interface{}{ - "model": "sonnet", - "sandbox": "workspace", - "auto_allow": []string{"bash(npm run build)", "bash(npm test)", "bash(npx tsc --noEmit)"}, - "repo_map": true, - }, - Files: map[string]string{ - ".hawk/settings.json": `{ - "model": "{{model}}", - "sandbox": "workspace", - "auto_allow": ["bash(npm run build)", "bash(npm test)", "bash(npx tsc --noEmit)"], - "repo_map": true -}`, - "AGENTS.md": `# {{project_name}} - -## Language -TypeScript (React) - -## Build -` + "```bash" + ` -npm install -npm run build -npm test -` + "```" + ` - -## Architecture -- React components in src/components/ -- Pages/routes in src/pages/ or app/ -- State management: {{state_management}} - -## Guidelines -- Use functional components with hooks -- Prefer composition over inheritance -- Keep components small and focused -- Use TypeScript strict mode -`, - ".hawk/rules/react.md": `--- -paths: ["src/**/*.tsx", "src/**/*.ts", "app/**/*.tsx"] ---- -# React Rules - -- Components must be typed with explicit props interface -- Use named exports for components -- Keep side effects in useEffect -- Memoize expensive computations with useMemo -`, - }, - Tags: []string{"typescript", "react", "frontend", "nextjs"}, - } - - // ts-api: Express/Fastify API - r.Templates["ts-api"] = &ConfigTemplate{ - Name: "ts-api", - Description: "TypeScript API with Express or Fastify", - Language: "typescript", - Framework: "express/fastify", - Content: map[string]interface{}{ - "model": "sonnet", - "sandbox": "workspace", - "auto_allow": []string{"bash(npm run build)", "bash(npm test)", "bash(npx tsc --noEmit)"}, - "repo_map": true, - }, - Files: map[string]string{ - ".hawk/settings.json": `{ - "model": "{{model}}", - "sandbox": "workspace", - "auto_allow": ["bash(npm run build)", "bash(npm test)", "bash(npx tsc --noEmit)"], - "repo_map": true -}`, - "AGENTS.md": `# {{project_name}} - -## Language -TypeScript (API) - -## Build -` + "```bash" + ` -npm install -npm run build -npm test -` + "```" + ` - -## Architecture -- Routes in src/routes/ -- Controllers in src/controllers/ -- Services in src/services/ -- Middleware in src/middleware/ - -## Guidelines -- Validate all request inputs with schemas -- Use async/await consistently -- Structure errors with status codes -- Write integration tests for routes -`, - ".hawk/rules/api.md": `--- -paths: ["src/**/*.ts"] ---- -# TypeScript API Rules - -- All route handlers must have input validation -- Use dependency injection for services -- Never throw untyped errors -- Log with structured metadata -`, - }, - Tags: []string{"typescript", "api", "express", "fastify", "backend"}, - } - - // python-api: FastAPI/Django project - r.Templates["python-api"] = &ConfigTemplate{ - Name: "python-api", - Description: "Python API with FastAPI or Django", - Language: "python", - Framework: "fastapi/django", - Content: map[string]interface{}{ - "model": "sonnet", - "sandbox": "workspace", - "auto_allow": []string{"bash(python -m pytest)", "bash(python -m mypy .)", "bash(ruff check .)"}, - "repo_map": true, - }, - Files: map[string]string{ - ".hawk/settings.json": `{ - "model": "{{model}}", - "sandbox": "workspace", - "auto_allow": ["bash(python -m pytest)", "bash(python -m mypy .)", "bash(ruff check .)"], - "repo_map": true -}`, - "AGENTS.md": `# {{project_name}} - -## Language -Python (API) - -## Build -` + "```bash" + ` -pip install -e ".[dev]" -python -m pytest -python -m mypy . -` + "```" + ` - -## Architecture -- API framework: {{framework}} -- Follow project layout conventions -- Use type hints everywhere - -## Guidelines -- Type annotations on all functions -- Use pydantic models for request/response -- Write docstrings for public functions -- Keep business logic out of route handlers -`, - ".hawk/rules/python.md": `--- -paths: ["**/*.py"] ---- -# Python Rules - -- All functions must have type annotations -- Use pydantic for data validation -- Prefer explicit imports over star imports -- Follow PEP 8 naming conventions -`, - }, - Tags: []string{"python", "api", "fastapi", "django", "backend"}, - } - - // python-ml: ML/data science project - r.Templates["python-ml"] = &ConfigTemplate{ - Name: "python-ml", - Description: "Python ML/data science project", - Language: "python", - Framework: "pytorch/sklearn", - Content: map[string]interface{}{ - "model": "sonnet", - "sandbox": "workspace", - "auto_allow": []string{"bash(python -m pytest)", "bash(python -m mypy .)", "bash(jupyter nbconvert --execute *)"}, - "repo_map": true, - }, - Files: map[string]string{ - ".hawk/settings.json": `{ - "model": "{{model}}", - "sandbox": "workspace", - "auto_allow": ["bash(python -m pytest)", "bash(python -m mypy .)"], - "repo_map": true -}`, - "AGENTS.md": `# {{project_name}} - -## Language -Python (ML/Data Science) - -## Build -` + "```bash" + ` -pip install -e ".[dev]" -python -m pytest -` + "```" + ` - -## Architecture -- Models in models/ -- Data processing in data/ -- Training scripts in scripts/ -- Notebooks in notebooks/ - -## Guidelines -- Document model architectures clearly -- Use reproducible random seeds -- Track experiments with version info -- Keep data loading separate from model code -- Use numpy/pandas type stubs -`, - ".hawk/rules/ml.md": `--- -paths: ["**/*.py", "notebooks/**/*.ipynb"] ---- -# ML Rules - -- Set random seeds for reproducibility -- Document tensor shapes in comments -- Keep training and evaluation separate -- Log metrics systematically -- Never commit large data files -`, - }, - Tags: []string{"python", "ml", "data-science", "pytorch", "sklearn"}, - } - - // rust-cli: Rust CLI project - r.Templates["rust-cli"] = &ConfigTemplate{ - Name: "rust-cli", - Description: "Rust CLI application", - Language: "rust", - Framework: "clap", - Content: map[string]interface{}{ - "model": "sonnet", - "sandbox": "workspace", - "auto_allow": []string{"bash(cargo build)", "bash(cargo test)", "bash(cargo clippy)"}, - "repo_map": true, - }, - Files: map[string]string{ - ".hawk/settings.json": `{ - "model": "{{model}}", - "sandbox": "workspace", - "auto_allow": ["bash(cargo build)", "bash(cargo test)", "bash(cargo clippy)"], - "repo_map": true -}`, - "AGENTS.md": `# {{project_name}} - -## Language -Rust (CLI) - -## Build -` + "```bash" + ` -cargo build -cargo test -cargo clippy -- -D warnings -` + "```" + ` - -## Architecture -- CLI args with clap in src/main.rs or src/cli.rs -- Business logic in src/lib.rs -- Error types in src/error.rs - -## Guidelines -- Use thiserror for error types -- Prefer Result over unwrap/expect -- Write doc comments on public items -- Use clippy with all warnings as errors -`, - ".hawk/rules/rust.md": `--- -paths: ["**/*.rs"] ---- -# Rust Rules - -- No unwrap() in library code -- Use proper error enums with thiserror -- All public items need doc comments -- Keep unsafe blocks minimal and documented -`, - }, - Tags: []string{"rust", "cli", "systems"}, - } - - // monorepo: Multi-package monorepo - r.Templates["monorepo"] = &ConfigTemplate{ - Name: "monorepo", - Description: "Multi-package monorepo configuration", - Language: "multi", - Framework: "workspace", - Content: map[string]interface{}{ - "model": "sonnet", - "sandbox": "workspace", - "auto_allow": []string{"bash(make build)", "bash(make test)", "bash(make lint)"}, - "repo_map": true, - "repo_map_max_tokens": 8000, - }, - Files: map[string]string{ - ".hawk/settings.json": `{ - "model": "{{model}}", - "sandbox": "workspace", - "auto_allow": ["bash(make build)", "bash(make test)", "bash(make lint)"], - "repo_map": true, - "repo_map_max_tokens": 8000 -}`, - "AGENTS.md": `# {{project_name}} - -## Structure -Monorepo with multiple packages - -## Build -` + "```bash" + ` -make build -make test -make lint -` + "```" + ` - -## Packages -{{packages}} - -## Guidelines -- Each package should be independently testable -- Shared code goes in packages/shared or libs/ -- Use workspace-level tooling for consistency -- Cross-package changes need integration tests -`, - ".hawk/rules/monorepo.md": `# Monorepo Rules - -- Changes spanning multiple packages need integration tests -- Respect package boundaries -- Shared dependencies must be version-aligned -- Each package maintains its own README -`, - }, - Tags: []string{"monorepo", "workspace", "multi-language"}, - } - - return r -} - -// Register adds a new template to the registry. -func (r *TemplateRegistry) Register(template *ConfigTemplate) { - r.mu.Lock() - defer r.mu.Unlock() - r.Templates[template.Name] = template -} - -// List returns all registered templates sorted by name. -func (r *TemplateRegistry) List() []*ConfigTemplate { - r.mu.RLock() - defer r.mu.RUnlock() - - templates := make([]*ConfigTemplate, 0, len(r.Templates)) - for _, t := range r.Templates { - templates = append(templates, t) - } - sort.Slice(templates, func(i, j int) bool { - return templates[i].Name < templates[j].Name - }) - return templates -} - -// Generate creates all config files from the named template with variable substitution. -func (r *TemplateRegistry) Generate(templateName string, vars map[string]string) (map[string]string, error) { - r.mu.RLock() - tmpl, ok := r.Templates[templateName] - r.mu.RUnlock() - - if !ok { - return nil, fmt.Errorf("template %q not found", templateName) - } - - result := make(map[string]string) - for path, content := range tmpl.Files { - resolved := applyVars(content, vars) - resolvedPath := applyVars(path, vars) - result[resolvedPath] = resolved - } - return result, nil -} - -// DetectAndGenerate auto-detects the project type from projectDir, -// selects the best matching template, and generates configs. -func (r *TemplateRegistry) DetectAndGenerate(projectDir string) (map[string]string, error) { - lang, framework := detectProject(projectDir) - - templateName := selectTemplate(lang, framework) - if templateName == "" { - return nil, fmt.Errorf("could not detect project type in %q", projectDir) - } - - projectName := filepath.Base(projectDir) - vars := map[string]string{ - "project_name": projectName, - "model": "sonnet", - "framework": framework, - "state_management": "context", - "packages": detectPackages(projectDir), - } - - return r.Generate(templateName, vars) -} - -// GenerateHawkConfig generates .hawk/settings.json content from a template. -func GenerateHawkConfig(template *ConfigTemplate, vars map[string]string) string { - content, ok := template.Files[".hawk/settings.json"] - if !ok { - // Fallback: generate from Content map - data, err := json.MarshalIndent(template.Content, "", " ") - if err != nil { - return "{}" - } - return applyVars(string(data), vars) - } - return applyVars(content, vars) -} - -// GenerateAgentsmd generates AGENTS.md content from a template. -func GenerateAgentsmd(template *ConfigTemplate, vars map[string]string) string { - content, ok := template.Files["AGENTS.md"] - if !ok { - return fmt.Sprintf("# %s\n\nProject using %s.\n", vars["project_name"], template.Language) - } - return applyVars(content, vars) -} - -// GenerateRules generates .hawk/rules permission file content from a template. -func GenerateRules(template *ConfigTemplate) string { - var sb strings.Builder - for path, content := range template.Files { - if strings.HasPrefix(path, ".hawk/rules/") { - if sb.Len() > 0 { - sb.WriteString("\n---\n\n") - } - sb.WriteString(fmt.Sprintf("# File: %s\n\n", path)) - sb.WriteString(content) - } - } - if sb.Len() == 0 { - return fmt.Sprintf("# Rules for %s project\n\n- Follow %s best practices\n", template.Name, template.Language) - } - return sb.String() -} - -// FormatTemplate returns a human-readable summary of a template. -func FormatTemplate(template *ConfigTemplate) string { - var sb strings.Builder - sb.WriteString(fmt.Sprintf("Template: %s\n", template.Name)) - sb.WriteString(fmt.Sprintf("Description: %s\n", template.Description)) - sb.WriteString(fmt.Sprintf("Language: %s\n", template.Language)) - if template.Framework != "" { - sb.WriteString(fmt.Sprintf("Framework: %s\n", template.Framework)) - } - if len(template.Tags) > 0 { - sb.WriteString(fmt.Sprintf("Tags: %s\n", strings.Join(template.Tags, ", "))) - } - sb.WriteString(fmt.Sprintf("Files generated: %d\n", len(template.Files))) - for path := range template.Files { - sb.WriteString(fmt.Sprintf(" - %s\n", path)) - } - return sb.String() -} - -// Preview shows what files would be generated without writing them. -func (r *TemplateRegistry) Preview(templateName string, vars map[string]string) string { - files, err := r.Generate(templateName, vars) - if err != nil { - return fmt.Sprintf("Error: %v", err) - } - - var sb strings.Builder - sb.WriteString(fmt.Sprintf("Preview for template %q:\n\n", templateName)) - - // Sort file paths for deterministic output - paths := make([]string, 0, len(files)) - for p := range files { - paths = append(paths, p) - } - sort.Strings(paths) - - for _, path := range paths { - content := files[path] - sb.WriteString(fmt.Sprintf("--- %s ---\n", path)) - sb.WriteString(content) - if !strings.HasSuffix(content, "\n") { - sb.WriteString("\n") - } - sb.WriteString("\n") - } - return sb.String() -} - -// applyVars substitutes {{key}} placeholders in text with values from vars. -func applyVars(text string, vars map[string]string) string { - result := text - for key, value := range vars { - placeholder := "{{" + key + "}}" - result = strings.ReplaceAll(result, placeholder, value) - } - return result -} - -// detectProject inspects projectDir for language/framework indicators. -func detectProject(projectDir string) (language, framework string) { - // Go detection - if fileExists(filepath.Join(projectDir, "go.mod")) { - language = "go" - // Check for API frameworks - if containsInFile(filepath.Join(projectDir, "go.mod"), "chi") || - containsInFile(filepath.Join(projectDir, "go.mod"), "gin-gonic") || - containsInFile(filepath.Join(projectDir, "go.mod"), "echo") { - framework = "api" - } - return - } - - // Rust detection - if fileExists(filepath.Join(projectDir, "Cargo.toml")) { - language = "rust" - framework = "cli" - return - } - - // TypeScript/JavaScript detection - if fileExists(filepath.Join(projectDir, "package.json")) { - language = "typescript" - pkgData, err := os.ReadFile(filepath.Join(projectDir, "package.json")) - if err == nil { - pkgStr := string(pkgData) - if strings.Contains(pkgStr, "react") || strings.Contains(pkgStr, "next") { - framework = "react" - } else if strings.Contains(pkgStr, "express") || strings.Contains(pkgStr, "fastify") { - framework = "api" - } - } - return - } - - // Python detection - if fileExists(filepath.Join(projectDir, "pyproject.toml")) || - fileExists(filepath.Join(projectDir, "setup.py")) || - fileExists(filepath.Join(projectDir, "requirements.txt")) { - language = "python" - // Check for ML libraries - for _, f := range []string{"pyproject.toml", "requirements.txt", "setup.py"} { - path := filepath.Join(projectDir, f) - if containsInFile(path, "torch") || containsInFile(path, "tensorflow") || - containsInFile(path, "sklearn") || containsInFile(path, "pandas") { - framework = "ml" - return - } - if containsInFile(path, "fastapi") || containsInFile(path, "django") || - containsInFile(path, "flask") { - framework = "api" - return - } - } - return - } - - // Monorepo detection - if fileExists(filepath.Join(projectDir, "pnpm-workspace.yaml")) || - fileExists(filepath.Join(projectDir, "lerna.json")) || - dirExists(filepath.Join(projectDir, "packages")) { - language = "multi" - framework = "monorepo" - return - } - - return "", "" -} - -// selectTemplate maps detected language/framework to a template name. -func selectTemplate(language, framework string) string { - switch language { - case "go": - if framework == "api" { - return "go-api" - } - return "go-default" - case "typescript": - if framework == "react" { - return "ts-react" - } - if framework == "api" { - return "ts-api" - } - return "ts-react" // default for TS - case "python": - if framework == "ml" { - return "python-ml" - } - if framework == "api" { - return "python-api" - } - return "python-api" // default for python - case "rust": - return "rust-cli" - case "multi": - return "monorepo" - } - return "" -} - -// detectPackages lists subdirectories in packages/ or apps/ for monorepos. -func detectPackages(projectDir string) string { - var pkgs []string - for _, dir := range []string{"packages", "apps", "libs"} { - entries, err := os.ReadDir(filepath.Join(projectDir, dir)) - if err != nil { - continue - } - for _, e := range entries { - if e.IsDir() { - pkgs = append(pkgs, fmt.Sprintf("- %s/%s", dir, e.Name())) - } - } - } - if len(pkgs) == 0 { - return "- (auto-detect packages)" - } - return strings.Join(pkgs, "\n") -} - -// fileExists checks if a file exists at path. -func fileExists(path string) bool { - info, err := os.Stat(path) - if err != nil { - return false - } - return !info.IsDir() -} - -// dirExists checks if a directory exists at path. -func dirExists(path string) bool { - info, err := os.Stat(path) - if err != nil { - return false - } - return info.IsDir() -} - -// containsInFile checks if a file contains the given substring. -func containsInFile(path, substr string) bool { - data, err := os.ReadFile(path) - if err != nil { - return false - } - return strings.Contains(string(data), substr) -} diff --git a/internal/config/config_templates_test.go b/internal/config/config_templates_test.go deleted file mode 100644 index 94f4f3fe..00000000 --- a/internal/config/config_templates_test.go +++ /dev/null @@ -1,690 +0,0 @@ -package config - -import ( - "fmt" - "os" - "path/filepath" - "strings" - "testing" -) - -func TestNewTemplateRegistry(t *testing.T) { - r := NewTemplateRegistry() - if r == nil { - t.Fatal("expected non-nil registry") - } - - expectedTemplates := []string{ - "go-default", "go-api", "ts-react", "ts-api", - "python-api", "python-ml", "rust-cli", "monorepo", - } - - for _, name := range expectedTemplates { - if _, ok := r.Templates[name]; !ok { - t.Errorf("expected built-in template %q", name) - } - } -} - -func TestTemplateRegistry_List(t *testing.T) { - r := NewTemplateRegistry() - templates := r.List() - - if len(templates) != 8 { - t.Fatalf("expected 8 templates, got %d", len(templates)) - } - - // Verify sorted order - for i := 1; i < len(templates); i++ { - if templates[i].Name < templates[i-1].Name { - t.Errorf("templates not sorted: %q before %q", templates[i-1].Name, templates[i].Name) - } - } -} - -func TestTemplateRegistry_Register(t *testing.T) { - r := NewTemplateRegistry() - custom := &ConfigTemplate{ - Name: "custom-project", - Description: "Custom template", - Language: "zig", - Framework: "", - Content: map[string]interface{}{"model": "opus"}, - Files: map[string]string{ - ".hawk/settings.json": `{"model": "{{model}}"}`, - }, - Tags: []string{"zig", "custom"}, - } - - r.Register(custom) - - if _, ok := r.Templates["custom-project"]; !ok { - t.Fatal("expected custom template to be registered") - } - - templates := r.List() - if len(templates) != 9 { - t.Fatalf("expected 9 templates after register, got %d", len(templates)) - } -} - -func TestTemplateRegistry_Generate(t *testing.T) { - r := NewTemplateRegistry() - vars := map[string]string{ - "project_name": "myapp", - "model": "opus", - "framework": "gin", - } - - files, err := r.Generate("go-api", vars) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if len(files) == 0 { - t.Fatal("expected generated files") - } - - // Check variable substitution - settings, ok := files[".hawk/settings.json"] - if !ok { - t.Fatal("expected .hawk/settings.json in output") - } - if !strings.Contains(settings, "opus") { - t.Error("expected model variable to be substituted") - } - if strings.Contains(settings, "{{model}}") { - t.Error("expected {{model}} placeholder to be replaced") - } - - agents, ok := files["AGENTS.md"] - if !ok { - t.Fatal("expected AGENTS.md in output") - } - if !strings.Contains(agents, "myapp") { - t.Error("expected project_name to be substituted in AGENTS.md") - } - if !strings.Contains(agents, "gin") { - t.Error("expected framework to be substituted in AGENTS.md") - } -} - -func TestTemplateRegistry_Generate_NotFound(t *testing.T) { - r := NewTemplateRegistry() - _, err := r.Generate("nonexistent", nil) - if err == nil { - t.Fatal("expected error for nonexistent template") - } - if !strings.Contains(err.Error(), "not found") { - t.Errorf("expected 'not found' in error, got: %v", err) - } -} - -func TestTemplateRegistry_DetectAndGenerate_Go(t *testing.T) { - dir := t.TempDir() - os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module example.com/test\n\ngo 1.21\n"), 0o644) - - r := NewTemplateRegistry() - files, err := r.DetectAndGenerate(dir) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if _, ok := files[".hawk/settings.json"]; !ok { - t.Error("expected .hawk/settings.json") - } - if _, ok := files["AGENTS.md"]; !ok { - t.Error("expected AGENTS.md") - } -} - -func TestTemplateRegistry_DetectAndGenerate_GoAPI(t *testing.T) { - dir := t.TempDir() - os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module example.com/api\n\ngo 1.21\n\nrequire github.com/go-chi/chi v5.0.0\n"), 0o644) - - r := NewTemplateRegistry() - files, err := r.DetectAndGenerate(dir) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - agents := files["AGENTS.md"] - if !strings.Contains(agents, "API") { - t.Error("expected API-specific AGENTS.md for go-api template") - } -} - -func TestTemplateRegistry_DetectAndGenerate_TypeScriptReact(t *testing.T) { - dir := t.TempDir() - os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"dependencies":{"react":"^18.0.0"}}`), 0o644) - - r := NewTemplateRegistry() - files, err := r.DetectAndGenerate(dir) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - agents := files["AGENTS.md"] - if !strings.Contains(agents, "React") { - t.Error("expected React-specific AGENTS.md") - } -} - -func TestTemplateRegistry_DetectAndGenerate_TypeScriptAPI(t *testing.T) { - dir := t.TempDir() - os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"dependencies":{"express":"^4.18.0"}}`), 0o644) - - r := NewTemplateRegistry() - files, err := r.DetectAndGenerate(dir) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - agents := files["AGENTS.md"] - if !strings.Contains(agents, "API") { - t.Error("expected API-specific AGENTS.md for ts-api template") - } -} - -func TestTemplateRegistry_DetectAndGenerate_Python(t *testing.T) { - dir := t.TempDir() - os.WriteFile(filepath.Join(dir, "pyproject.toml"), []byte(`[project]\nname = "myapp"\n[project.dependencies]\nfastapi = ">=0.100"\n`), 0o644) - - r := NewTemplateRegistry() - files, err := r.DetectAndGenerate(dir) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - agents := files["AGENTS.md"] - if !strings.Contains(agents, "Python") { - t.Error("expected Python AGENTS.md") - } -} - -func TestTemplateRegistry_DetectAndGenerate_PythonML(t *testing.T) { - dir := t.TempDir() - os.WriteFile(filepath.Join(dir, "requirements.txt"), []byte("torch>=2.0\nnumpy\npandas\n"), 0o644) - - r := NewTemplateRegistry() - files, err := r.DetectAndGenerate(dir) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - agents := files["AGENTS.md"] - if !strings.Contains(agents, "ML") { - t.Error("expected ML-specific AGENTS.md") - } -} - -func TestTemplateRegistry_DetectAndGenerate_Rust(t *testing.T) { - dir := t.TempDir() - os.WriteFile(filepath.Join(dir, "Cargo.toml"), []byte("[package]\nname = \"mycli\"\nversion = \"0.1.0\"\n"), 0o644) - - r := NewTemplateRegistry() - files, err := r.DetectAndGenerate(dir) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - agents := files["AGENTS.md"] - if !strings.Contains(agents, "Rust") { - t.Error("expected Rust AGENTS.md") - } -} - -func TestTemplateRegistry_DetectAndGenerate_Monorepo(t *testing.T) { - dir := t.TempDir() - os.MkdirAll(filepath.Join(dir, "packages", "core"), 0o755) - os.MkdirAll(filepath.Join(dir, "packages", "web"), 0o755) - os.WriteFile(filepath.Join(dir, "pnpm-workspace.yaml"), []byte("packages:\n - packages/*\n"), 0o644) - - r := NewTemplateRegistry() - files, err := r.DetectAndGenerate(dir) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - agents := files["AGENTS.md"] - if !strings.Contains(agents, "packages/core") { - t.Error("expected detected packages in AGENTS.md") - } - if !strings.Contains(agents, "packages/web") { - t.Error("expected detected packages in AGENTS.md") - } -} - -func TestTemplateRegistry_DetectAndGenerate_Unknown(t *testing.T) { - dir := t.TempDir() - // Empty directory - no recognizable project files - - r := NewTemplateRegistry() - _, err := r.DetectAndGenerate(dir) - if err == nil { - t.Fatal("expected error for undetectable project") - } - if !strings.Contains(err.Error(), "could not detect") { - t.Errorf("expected detection error, got: %v", err) - } -} - -func TestGenerateHawkConfig(t *testing.T) { - r := NewTemplateRegistry() - tmpl := r.Templates["go-default"] - vars := map[string]string{"model": "haiku"} - - config := GenerateHawkConfig(tmpl, vars) - if !strings.Contains(config, "haiku") { - t.Error("expected model variable substituted") - } - if strings.Contains(config, "{{model}}") { - t.Error("expected placeholder replaced") - } -} - -func TestGenerateHawkConfig_FallbackToContent(t *testing.T) { - tmpl := &ConfigTemplate{ - Name: "no-files", - Language: "go", - Content: map[string]interface{}{"model": "sonnet", "sandbox": "workspace"}, - Files: map[string]string{}, // no settings.json in Files - } - vars := map[string]string{"model": "opus"} - - config := GenerateHawkConfig(tmpl, vars) - if config == "{}" { - t.Error("expected non-empty fallback config") - } - if !strings.Contains(config, "sonnet") { - t.Error("expected content from Content map") - } -} - -func TestGenerateAgentsmd(t *testing.T) { - r := NewTemplateRegistry() - tmpl := r.Templates["ts-react"] - vars := map[string]string{ - "project_name": "my-frontend", - "state_management": "zustand", - } - - md := GenerateAgentsmd(tmpl, vars) - if !strings.Contains(md, "my-frontend") { - t.Error("expected project name in AGENTS.md") - } - if !strings.Contains(md, "zustand") { - t.Error("expected state_management variable") - } -} - -func TestGenerateAgentsmd_Fallback(t *testing.T) { - tmpl := &ConfigTemplate{ - Name: "bare", - Language: "haskell", - Files: map[string]string{}, - } - vars := map[string]string{"project_name": "myproj"} - - md := GenerateAgentsmd(tmpl, vars) - if !strings.Contains(md, "myproj") { - t.Error("expected project name in fallback") - } - if !strings.Contains(md, "haskell") { - t.Error("expected language in fallback") - } -} - -func TestGenerateRules(t *testing.T) { - r := NewTemplateRegistry() - tmpl := r.Templates["go-default"] - - rules := GenerateRules(tmpl) - if !strings.Contains(rules, "Go Rules") { - t.Error("expected Go rules content") - } - if !strings.Contains(rules, "gofmt") { - t.Error("expected gofmt mention in rules") - } -} - -func TestGenerateRules_NoRulesFiles(t *testing.T) { - tmpl := &ConfigTemplate{ - Name: "bare", - Language: "zig", - Files: map[string]string{"AGENTS.md": "hello"}, - } - - rules := GenerateRules(tmpl) - if !strings.Contains(rules, "zig") { - t.Error("expected language in fallback rules") - } -} - -func TestFormatTemplate(t *testing.T) { - r := NewTemplateRegistry() - tmpl := r.Templates["python-api"] - - formatted := FormatTemplate(tmpl) - if !strings.Contains(formatted, "python-api") { - t.Error("expected template name") - } - if !strings.Contains(formatted, "Python API") { - t.Error("expected description") - } - if !strings.Contains(formatted, "python") { - t.Error("expected language") - } - if !strings.Contains(formatted, "fastapi/django") { - t.Error("expected framework") - } - if !strings.Contains(formatted, "Tags:") { - t.Error("expected tags section") - } - if !strings.Contains(formatted, "Files generated:") { - t.Error("expected files section") - } -} - -func TestTemplateRegistry_Preview(t *testing.T) { - r := NewTemplateRegistry() - vars := map[string]string{ - "project_name": "preview-test", - "model": "sonnet", - "framework": "gin", - } - - preview := r.Preview("go-api", vars) - if !strings.Contains(preview, "Preview for template") { - t.Error("expected preview header") - } - if !strings.Contains(preview, ".hawk/settings.json") { - t.Error("expected settings file in preview") - } - if !strings.Contains(preview, "AGENTS.md") { - t.Error("expected AGENTS.md in preview") - } - if !strings.Contains(preview, "preview-test") { - t.Error("expected variable substitution in preview") - } -} - -func TestTemplateRegistry_Preview_NotFound(t *testing.T) { - r := NewTemplateRegistry() - preview := r.Preview("nonexistent", nil) - if !strings.Contains(preview, "Error") { - t.Error("expected error message in preview for nonexistent template") - } -} - -func TestApplyVars(t *testing.T) { - tests := []struct { - name string - text string - vars map[string]string - expected string - }{ - { - name: "simple substitution", - text: "Hello {{name}}!", - vars: map[string]string{"name": "World"}, - expected: "Hello World!", - }, - { - name: "multiple vars", - text: "{{greeting}} {{name}}, welcome to {{place}}", - vars: map[string]string{"greeting": "Hi", "name": "Alice", "place": "Hawk"}, - expected: "Hi Alice, welcome to Hawk", - }, - { - name: "no vars", - text: "no placeholders here", - vars: map[string]string{"unused": "value"}, - expected: "no placeholders here", - }, - { - name: "missing var leaves placeholder", - text: "Hello {{name}}, your {{role}} is ready", - vars: map[string]string{"name": "Bob"}, - expected: "Hello Bob, your {{role}} is ready", - }, - { - name: "nil vars", - text: "Hello {{name}}", - vars: nil, - expected: "Hello {{name}}", - }, - { - name: "repeated placeholder", - text: "{{x}} and {{x}} again", - vars: map[string]string{"x": "Y"}, - expected: "Y and Y again", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := applyVars(tt.text, tt.vars) - if result != tt.expected { - t.Errorf("expected %q, got %q", tt.expected, result) - } - }) - } -} - -func TestDetectProject(t *testing.T) { - tests := []struct { - name string - setup func(dir string) - wantLang string - wantFrame string - }{ - { - name: "go project", - setup: func(dir string) { - os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module test\n\ngo 1.21\n"), 0o644) - }, - wantLang: "go", - wantFrame: "", - }, - { - name: "go api project", - setup: func(dir string) { - os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module test\n\ngo 1.21\n\nrequire github.com/go-chi/chi v5.0.0\n"), 0o644) - }, - wantLang: "go", - wantFrame: "api", - }, - { - name: "rust project", - setup: func(dir string) { - os.WriteFile(filepath.Join(dir, "Cargo.toml"), []byte("[package]\nname=\"test\"\n"), 0o644) - }, - wantLang: "rust", - wantFrame: "cli", - }, - { - name: "typescript react", - setup: func(dir string) { - os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"dependencies":{"react":"18"}}`), 0o644) - }, - wantLang: "typescript", - wantFrame: "react", - }, - { - name: "typescript express", - setup: func(dir string) { - os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"dependencies":{"express":"4"}}`), 0o644) - }, - wantLang: "typescript", - wantFrame: "api", - }, - { - name: "python fastapi", - setup: func(dir string) { - os.WriteFile(filepath.Join(dir, "pyproject.toml"), []byte("[project]\ndependencies=[\"fastapi\"]\n"), 0o644) - }, - wantLang: "python", - wantFrame: "api", - }, - { - name: "python ml", - setup: func(dir string) { - os.WriteFile(filepath.Join(dir, "requirements.txt"), []byte("torch\nnumpy\n"), 0o644) - }, - wantLang: "python", - wantFrame: "ml", - }, - { - name: "monorepo pnpm", - setup: func(dir string) { - os.WriteFile(filepath.Join(dir, "pnpm-workspace.yaml"), []byte("packages:\n - packages/*\n"), 0o644) - }, - wantLang: "multi", - wantFrame: "monorepo", - }, - { - name: "empty dir", - setup: func(dir string) {}, - wantLang: "", - wantFrame: "", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - dir := t.TempDir() - tt.setup(dir) - lang, frame := detectProject(dir) - if lang != tt.wantLang { - t.Errorf("language: expected %q, got %q", tt.wantLang, lang) - } - if frame != tt.wantFrame { - t.Errorf("framework: expected %q, got %q", tt.wantFrame, frame) - } - }) - } -} - -func TestSelectTemplate(t *testing.T) { - tests := []struct { - lang string - framework string - want string - }{ - {"go", "", "go-default"}, - {"go", "api", "go-api"}, - {"typescript", "react", "ts-react"}, - {"typescript", "api", "ts-api"}, - {"typescript", "", "ts-react"}, - {"python", "api", "python-api"}, - {"python", "ml", "python-ml"}, - {"python", "", "python-api"}, - {"rust", "cli", "rust-cli"}, - {"rust", "", "rust-cli"}, - {"multi", "monorepo", "monorepo"}, - {"", "", ""}, - {"unknown", "", ""}, - } - - for _, tt := range tests { - t.Run(tt.lang+"/"+tt.framework, func(t *testing.T) { - got := selectTemplate(tt.lang, tt.framework) - if got != tt.want { - t.Errorf("selectTemplate(%q, %q) = %q, want %q", tt.lang, tt.framework, got, tt.want) - } - }) - } -} - -func TestDetectPackages(t *testing.T) { - dir := t.TempDir() - os.MkdirAll(filepath.Join(dir, "packages", "core"), 0o755) - os.MkdirAll(filepath.Join(dir, "packages", "ui"), 0o755) - os.MkdirAll(filepath.Join(dir, "apps", "web"), 0o755) - - result := detectPackages(dir) - if !strings.Contains(result, "packages/core") { - t.Error("expected packages/core") - } - if !strings.Contains(result, "packages/ui") { - t.Error("expected packages/ui") - } - if !strings.Contains(result, "apps/web") { - t.Error("expected apps/web") - } -} - -func TestDetectPackages_Empty(t *testing.T) { - dir := t.TempDir() - result := detectPackages(dir) - if !strings.Contains(result, "auto-detect") { - t.Error("expected fallback message for empty dir") - } -} - -func TestConfigTemplateFields(t *testing.T) { - tmpl := &ConfigTemplate{ - Name: "test", - Description: "Test template", - Language: "go", - Framework: "gin", - Content: map[string]interface{}{"key": "value"}, - Files: map[string]string{"a.txt": "content"}, - Tags: []string{"tag1", "tag2"}, - } - - if tmpl.Name != "test" { - t.Error("Name field") - } - if tmpl.Description != "Test template" { - t.Error("Description field") - } - if tmpl.Language != "go" { - t.Error("Language field") - } - if tmpl.Framework != "gin" { - t.Error("Framework field") - } - if tmpl.Content["key"] != "value" { - t.Error("Content field") - } - if tmpl.Files["a.txt"] != "content" { - t.Error("Files field") - } - if len(tmpl.Tags) != 2 { - t.Error("Tags field") - } -} - -func TestConfigTemplateConcurrentAccess(t *testing.T) { - r := NewTemplateRegistry() - done := make(chan struct{}) - - // Concurrent reads - for i := 0; i < 10; i++ { - go func() { - defer func() { done <- struct{}{} }() - r.List() - r.Generate("go-default", map[string]string{"model": "sonnet", "project_name": "test"}) - r.Preview("go-api", map[string]string{"model": "opus"}) - }() - } - - // Concurrent writes - for i := 0; i < 5; i++ { - go func(n int) { - defer func() { done <- struct{}{} }() - r.Register(&ConfigTemplate{ - Name: fmt.Sprintf("concurrent-%d", n), - Language: "go", - Files: map[string]string{}, - }) - }(i) - } - - // Wait for all goroutines - for i := 0; i < 15; i++ { - <-done - } -} diff --git a/internal/config/deployments_ui.go b/internal/config/deployments_ui.go index bbdc86d6..24a61852 100644 --- a/internal/config/deployments_ui.go +++ b/internal/config/deployments_ui.go @@ -1,102 +1,11 @@ package config import ( - "context" "encoding/json" "fmt" "os" - "sort" - "strings" - - "github.com/GrayCodeAI/eyrie/catalog" - eyriecfg "github.com/GrayCodeAI/eyrie/config" - "github.com/GrayCodeAI/eyrie/setup" ) -// DeploymentRow is one catalog deployment with local credential status. -type DeploymentRow struct { - ID string - Name string - ProviderID string - Configured bool - Status string - EnvVars []EnvVarStatus -} - -// EnvVarStatus tracks whether an env var is set for a deployment. -type EnvVarStatus struct { - Name string - Set bool -} - -// ListDeploymentRows lists catalog deployments and whether hawk can use them now. -func ListDeploymentRows(ctx context.Context) ([]DeploymentRow, error) { - PrepareCredentialDiscovery(ctx) - compiled, err := loadEyrieCatalogV1(ctx, false) - if err != nil { - return nil, err - } - cfg := eyriecfg.LoadProviderConfig("") - cfg = eyriecfg.EnsureDeploymentConfigV2(cfg) - configured := setup.ConfiguredDeployments(cfg) - discoveryEnv := eyriecfg.DiscoveryEnvMap(ctx) - - ids := make([]string, 0, len(compiled.DeploymentsByID)) - for id := range compiled.DeploymentsByID { - ids = append(ids, id) - } - sort.Strings(ids) - - out := make([]DeploymentRow, 0, len(ids)) - for _, id := range ids { - dep := compiled.DeploymentsByID[id] - row := DeploymentRow{ - ID: id, - Name: dep.Name, - ProviderID: dep.ProviderID, - EnvVars: envStatusForDeployment(id, dep, discoveryEnv), - } - dc := eyriecfg.DeploymentConfigFromEnv(dep, discoveryEnv) - if eyriecfg.DeploymentConfigured(id, dep, dc) { - row.Configured = true - row.Status = "ready" - } else if _, ok := configured[id]; ok { - row.Status = "incomplete" - } else { - row.Status = "needs credentials" - } - out = append(out, row) - } - return out, nil -} - -func envStatusForDeployment(deploymentID string, dep catalog.DeploymentV1, discoveryEnv map[string]string) []EnvVarStatus { - known := deploymentEnvVars(deploymentID) - if len(dep.EnvFallbacks) > 0 { - for _, fb := range dep.EnvFallbacks { - known = append(known, fb.Env...) - } - } - var out []EnvVarStatus - seen := map[string]bool{} - for _, env := range known { - if env == "" || seen[env] { - continue - } - seen[env] = true - set := strings.TrimSpace(discoveryEnv[env]) != "" - if !set { - set = strings.TrimSpace(os.Getenv(env)) != "" - } - out = append(out, EnvVarStatus{Name: env, Set: set}) - } - return out -} - -func deploymentEnvVars(id string) []string { - return catalog.EnvVarsForDeployment(id) -} - // DeploymentRoutingLabel returns a short on/off label for the config hub. func DeploymentRoutingLabel(settings Settings) string { if DeploymentRoutingEnabled(settings) { @@ -105,17 +14,6 @@ func DeploymentRoutingLabel(settings Settings) string { return "off" } -// ToggleDeploymentRouting flips deployment_routing in global settings. -func ToggleDeploymentRouting(settings Settings) (Settings, bool, error) { - enabled := DeploymentRoutingEnabled(settings) - next := !enabled - settings.DeploymentRouting = &next - if err := SaveProjectOrGlobalDeploymentRouting(next); err != nil { - return settings, enabled, err - } - return settings, next, nil -} - // SaveProjectOrGlobalDeploymentRouting persists the flag to project settings when present. func SaveProjectOrGlobalDeploymentRouting(enabled bool) error { projectPath := projectSettingsPath() @@ -141,25 +39,3 @@ func SaveProjectOrGlobalDeploymentRouting(enabled bool) error { } return SetGlobalSetting("deployment_routing", val) } - -// SyncProviderConfigFromEnv re-applies eyrie catalog + env into provider.json (deployments + routing). -func SyncProviderConfigFromEnv() (string, error) { - result, err := ApplyEyrieCredentials(context.Background()) - if err != nil { - return "", err - } - return FormatApplyCredentialsSummary(result), nil -} - -// ProviderConfigJSON returns the current provider.json as indented JSON (routing included). -func ProviderConfigJSON() (string, error) { - cfg := eyriecfg.LoadProviderConfig("") - if cfg == nil { - return "{}", nil - } - raw, err := json.MarshalIndent(cfg, "", " ") - if err != nil { - return "", err - } - return string(raw), nil -} diff --git a/internal/config/distro.go b/internal/config/distro.go deleted file mode 100644 index 1891ee35..00000000 --- a/internal/config/distro.go +++ /dev/null @@ -1,82 +0,0 @@ -package config - -import ( - "os" - "path/filepath" - - "github.com/GrayCodeAI/hawk/internal/home" - "gopkg.in/yaml.v3" -) - -// Distribution defines a custom hawk distribution (white-label configuration). -type Distribution struct { - Name string `yaml:"name"` - DisplayName string `yaml:"display_name"` - Version string `yaml:"version"` - Provider DistroProvider `yaml:"provider"` - Extensions []DistroExtension `yaml:"extensions"` - Branding DistroBranding `yaml:"branding"` - Defaults DistroDefaults `yaml:"defaults"` - Recipes []string `yaml:"recipes"` -} - -// DistroProvider configures the default LLM provider. -type DistroProvider struct { - Name string `yaml:"name"` - Model string `yaml:"model"` - EnvKey string `yaml:"env_key"` -} - -// DistroExtension defines a bundled extension. -type DistroExtension struct { - Name string `yaml:"name"` - Command string `yaml:"command"` - Args []string `yaml:"args"` -} - -// DistroBranding customizes the UI appearance. -type DistroBranding struct { - Prompt string `yaml:"prompt"` - WelcomeMsg string `yaml:"welcome_message"` - AgentName string `yaml:"agent_name"` - Color string `yaml:"color"` -} - -// DistroDefaults sets default behavior. -type DistroDefaults struct { - PermissionMode string `yaml:"permission_mode"` - AllowedTools []string `yaml:"allowed_tools"` - MaxTurns int `yaml:"max_turns"` - SystemPrompt string `yaml:"system_prompt"` -} - -// LoadDistribution reads a distribution config from a YAML file. -func LoadDistribution(path string) (*Distribution, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, err - } - var d Distribution - if err := yaml.Unmarshal(data, &d); err != nil { - return nil, err - } - return &d, nil -} - -// FindDistribution looks for a distribution config in standard locations. -func FindDistribution() *Distribution { - paths := []string{ - "hawk-distro.yaml", - ".hawk/distro.yaml", - } - home := home.Dir() - if home != "" { - paths = append(paths, filepath.Join(home, ".hawk", "distro.yaml")) - } - for _, p := range paths { - if d, err := LoadDistribution(p); err == nil { - return d - } - } - return nil -} diff --git a/internal/config/envmanager.go b/internal/config/envmanager.go index bc021daa..935a256d 100644 --- a/internal/config/envmanager.go +++ b/internal/config/envmanager.go @@ -89,8 +89,6 @@ func (em *EnvManager) Load(sources ...string) error { ev.Value = osVal ev.Source = "env" } - _ = ev - _ = key } return nil diff --git a/internal/config/ignore.go b/internal/config/ignore.go deleted file mode 100644 index 19b60383..00000000 --- a/internal/config/ignore.go +++ /dev/null @@ -1,103 +0,0 @@ -package config - -import ( - "os" - "path/filepath" - "strings" -) - -// DefaultIgnorePatterns returns the built-in ignore patterns. -func DefaultIgnorePatterns() []string { - return []string{ - "node_modules", - ".git", - "__pycache__", - ".venv", - "dist", - "build", - "*.pyc", - ".DS_Store", - } -} - -// LoadIgnorePatterns reads ignore patterns from .hawkignore or .hawk/ignore. -// Falls back to default patterns if neither file exists. -func LoadIgnorePatterns() []string { - // Try .hawkignore first, then .hawk/ignore - for _, name := range []string{".hawkignore", filepath.Join(".hawk", "ignore")} { - data, err := os.ReadFile(name) - if err != nil { - continue - } - patterns := parseIgnoreFile(string(data)) - if len(patterns) > 0 { - return patterns - } - } - return DefaultIgnorePatterns() -} - -// parseIgnoreFile parses a gitignore-style file into a list of patterns. -// Blank lines and comments (lines starting with #) are skipped. -func parseIgnoreFile(content string) []string { - var patterns []string - for _, line := range strings.Split(content, "\n") { - line = strings.TrimSpace(line) - if line == "" || strings.HasPrefix(line, "#") { - continue - } - patterns = append(patterns, line) - } - return patterns -} - -// ShouldIgnore checks if a path matches any of the given ignore patterns. -// Supports gitignore-style matching: -// - Simple names match any path component (e.g., "node_modules" matches "a/node_modules/b") -// - Glob patterns with * are matched against the base name (e.g., "*.pyc" matches "foo.pyc") -// - Paths with / are matched against the full path -func ShouldIgnore(path string, patterns []string) bool { - // Normalize path separators - path = filepath.ToSlash(path) - base := filepath.Base(path) - - for _, pattern := range patterns { - pattern = strings.TrimSpace(pattern) - if pattern == "" { - continue - } - - // If pattern contains a slash, match against the full path - if strings.Contains(pattern, "/") { - if matchGlob(pattern, path) { - return true - } - continue - } - - // Match against base name - if matchGlob(pattern, base) { - return true - } - - // Also check if any path component matches the pattern exactly - for _, component := range strings.Split(path, "/") { - if matchGlob(pattern, component) { - return true - } - } - } - return false -} - -// matchGlob performs simple glob matching supporting * wildcards. -// * matches any sequence of non-separator characters. -func matchGlob(pattern, name string) bool { - // Use filepath.Match for standard glob matching - matched, err := filepath.Match(pattern, name) - if err != nil { - // If pattern is invalid, try exact match - return pattern == name - } - return matched -} diff --git a/internal/config/ignore_test.go b/internal/config/ignore_test.go deleted file mode 100644 index b86d4d18..00000000 --- a/internal/config/ignore_test.go +++ /dev/null @@ -1,154 +0,0 @@ -package config - -import ( - "os" - "path/filepath" - "testing" -) - -func TestDefaultIgnorePatterns(t *testing.T) { - patterns := DefaultIgnorePatterns() - if len(patterns) != 8 { - t.Fatalf("expected 8 default patterns, got %d", len(patterns)) - } - expected := map[string]bool{ - "node_modules": true, - ".git": true, - "__pycache__": true, - ".venv": true, - "dist": true, - "build": true, - "*.pyc": true, - ".DS_Store": true, - } - for _, p := range patterns { - if !expected[p] { - t.Errorf("unexpected default pattern: %q", p) - } - } -} - -func TestLoadIgnorePatterns_NoFile(t *testing.T) { - dir := t.TempDir() - orig, _ := os.Getwd() - os.Chdir(dir) - defer os.Chdir(orig) - - patterns := LoadIgnorePatterns() - if len(patterns) != 8 { - t.Fatalf("expected defaults when no file, got %d patterns", len(patterns)) - } -} - -func TestLoadIgnorePatterns_HawkIgnoreFile(t *testing.T) { - dir := t.TempDir() - orig, _ := os.Getwd() - os.Chdir(dir) - defer os.Chdir(orig) - - content := "vendor\n# comment\n\n*.log\ntmp\n" - os.WriteFile(filepath.Join(dir, ".hawkignore"), []byte(content), 0o644) - - patterns := LoadIgnorePatterns() - if len(patterns) != 3 { - t.Fatalf("expected 3 patterns, got %d: %v", len(patterns), patterns) - } - if patterns[0] != "vendor" || patterns[1] != "*.log" || patterns[2] != "tmp" { - t.Fatalf("unexpected patterns: %v", patterns) - } -} - -func TestLoadIgnorePatterns_DotHawkIgnore(t *testing.T) { - dir := t.TempDir() - orig, _ := os.Getwd() - os.Chdir(dir) - defer os.Chdir(orig) - - os.MkdirAll(filepath.Join(dir, ".hawk"), 0o755) - os.WriteFile(filepath.Join(dir, ".hawk", "ignore"), []byte("custom_dir\n"), 0o644) - - patterns := LoadIgnorePatterns() - if len(patterns) != 1 || patterns[0] != "custom_dir" { - t.Fatalf("expected [custom_dir], got %v", patterns) - } -} - -func TestShouldIgnore_ExactMatch(t *testing.T) { - patterns := []string{"node_modules", ".git", "*.pyc"} - tests := []struct { - path string - expected bool - }{ - {"node_modules", true}, - {"src/node_modules/pkg", true}, - {".git", true}, - {"repo/.git/config", true}, - {"main.pyc", true}, - {"src/main.pyc", true}, - {"main.py", false}, - {"src/main.go", false}, - {"readme.md", false}, - } - for _, tt := range tests { - got := ShouldIgnore(tt.path, patterns) - if got != tt.expected { - t.Errorf("ShouldIgnore(%q) = %v, want %v", tt.path, got, tt.expected) - } - } -} - -func TestShouldIgnore_GlobPattern(t *testing.T) { - patterns := []string{"*.log", "*.tmp"} - tests := []struct { - path string - expected bool - }{ - {"app.log", true}, - {"debug.tmp", true}, - {"src/debug.tmp", true}, - {"app.txt", false}, - } - for _, tt := range tests { - got := ShouldIgnore(tt.path, patterns) - if got != tt.expected { - t.Errorf("ShouldIgnore(%q) = %v, want %v", tt.path, got, tt.expected) - } - } -} - -func TestShouldIgnore_PathWithSlash(t *testing.T) { - patterns := []string{"vendor/cache"} - tests := []struct { - path string - expected bool - }{ - {"vendor/cache", true}, - {"vendor/other", false}, - {"src/vendor/cache", false}, // pattern with slash matches from root - } - for _, tt := range tests { - got := ShouldIgnore(tt.path, patterns) - if got != tt.expected { - t.Errorf("ShouldIgnore(%q) = %v, want %v", tt.path, got, tt.expected) - } - } -} - -func TestShouldIgnore_EmptyPatterns(t *testing.T) { - if ShouldIgnore("anything", nil) { - t.Fatal("expected false for nil patterns") - } - if ShouldIgnore("anything", []string{}) { - t.Fatal("expected false for empty patterns") - } -} - -func TestShouldIgnore_DotDSStore(t *testing.T) { - patterns := DefaultIgnorePatterns() - if !ShouldIgnore(".DS_Store", patterns) { - t.Fatal("expected .DS_Store to be ignored") - } - if !ShouldIgnore("subdir/.DS_Store", patterns) { - t.Fatal("expected subdir/.DS_Store to be ignored") - } -} diff --git a/internal/config/migrate.go b/internal/config/migrate.go deleted file mode 100644 index ade48d9e..00000000 --- a/internal/config/migrate.go +++ /dev/null @@ -1,741 +0,0 @@ -package config - -import ( - "encoding/json" - "fmt" - "os" - "sort" - "strings" - "time" -) - -// Migration represents a single config version upgrade step. -type Migration struct { - FromVersion int - ToVersion int - Description string - Migrate func(data map[string]interface{}) (map[string]interface{}, error) -} - -// MigrationRegistry holds all registered migrations and the current target version. -type MigrationRegistry struct { - Migrations []Migration - CurrentVersion int -} - -// NewMigrationRegistry returns a registry with all default migrations registered. -func NewMigrationRegistry() *MigrationRegistry { - r := &MigrationRegistry{ - CurrentVersion: 8, - } - - // v1→v2: Rename apiKey to api_key (snake_case normalization) - r.Migrations = append(r.Migrations, Migration{ - FromVersion: 1, - ToVersion: 2, - Description: "Rename apiKey to api_key (snake_case normalization)", - Migrate: func(data map[string]interface{}) (map[string]interface{}, error) { - if val, ok := data["apiKey"]; ok { - data["api_key"] = val - delete(data, "apiKey") - } - return data, nil - }, - }) - - // v2→v3: Move provider.model to top-level model - r.Migrations = append(r.Migrations, Migration{ - FromVersion: 2, - ToVersion: 3, - Description: "Move provider.model to top-level model", - Migrate: func(data map[string]interface{}) (map[string]interface{}, error) { - if provider, ok := data["provider"].(map[string]interface{}); ok { - if model, ok := provider["model"]; ok { - data["model"] = model - delete(provider, "model") - // If provider object is now empty, remove it; otherwise keep remaining fields - if len(provider) == 0 { - delete(data, "provider") - } - } - } - return data, nil - }, - }) - - // v3→v4: Add permissions section with defaults - r.Migrations = append(r.Migrations, Migration{ - FromVersion: 3, - ToVersion: 4, - Description: "Add permissions section with defaults", - Migrate: func(data map[string]interface{}) (map[string]interface{}, error) { - if _, ok := data["permissions"]; !ok { - data["permissions"] = map[string]interface{}{ - "allow_read": true, - "allow_write": true, - "allow_execute": false, - "allow_network": false, - } - } - return data, nil - }, - }) - - // v4→v5: Rename autoMode to auto_approve, add guardian section - r.Migrations = append(r.Migrations, Migration{ - FromVersion: 4, - ToVersion: 5, - Description: "Rename autoMode to auto_approve, add guardian section", - Migrate: func(data map[string]interface{}) (map[string]interface{}, error) { - if val, ok := data["autoMode"]; ok { - data["auto_approve"] = val - delete(data, "autoMode") - } - if _, ok := data["guardian"]; !ok { - data["guardian"] = map[string]interface{}{ - "enabled": true, - "max_risk": "medium", - "block_list": []interface{}{}, - } - } - return data, nil - }, - }) - - // v5→v6: Add tok compression settings with defaults - r.Migrations = append(r.Migrations, Migration{ - FromVersion: 5, - ToVersion: 6, - Description: "Add tok compression settings with defaults", - Migrate: func(data map[string]interface{}) (map[string]interface{}, error) { - if _, ok := data["tok"]; !ok { - data["tok"] = map[string]interface{}{ - "compression_mode": "full", - "max_tokens": 100000, - "preserve_code": true, - } - } - return data, nil - }, - }) - - // v6→v7: Add cache_warming and cost_limits sections - r.Migrations = append(r.Migrations, Migration{ - FromVersion: 6, - ToVersion: 7, - Description: "Add cache_warming and cost_limits sections", - Migrate: func(data map[string]interface{}) (map[string]interface{}, error) { - if _, ok := data["cache_warming"]; !ok { - data["cache_warming"] = map[string]interface{}{ - "enabled": false, - "strategy": "recent_files", - "max_files": 10, - "on_startup": false, - } - } - if _, ok := data["cost_limits"]; !ok { - data["cost_limits"] = map[string]interface{}{ - "max_per_session": 5.0, - "max_per_day": 25.0, - "warn_threshold": 0.8, - "hard_stop": true, - } - } - return data, nil - }, - }) - - // v7→v8: Restructure sandbox from bool to object {enabled, type, network} - r.Migrations = append(r.Migrations, Migration{ - FromVersion: 7, - ToVersion: 8, - Description: "Restructure sandbox from bool to object {enabled, type, network}", - Migrate: func(data map[string]interface{}) (map[string]interface{}, error) { - sandboxVal, exists := data["sandbox"] - if !exists { - data["sandbox"] = map[string]interface{}{ - "enabled": false, - "type": "landlock", - "network": false, - } - return data, nil - } - switch v := sandboxVal.(type) { - case bool: - data["sandbox"] = map[string]interface{}{ - "enabled": v, - "type": "landlock", - "network": false, - } - case string: - enabled := v != "" && v != "off" && v != "false" - sandboxType := "landlock" - if v == "docker" || v == "chroot" { - sandboxType = v - } - data["sandbox"] = map[string]interface{}{ - "enabled": enabled, - "type": sandboxType, - "network": false, - } - case map[string]interface{}: - // Already an object, ensure all fields present - if _, ok := v["enabled"]; !ok { - v["enabled"] = false - } - if _, ok := v["type"]; !ok { - v["type"] = "landlock" - } - if _, ok := v["network"]; !ok { - v["network"] = false - } - data["sandbox"] = v - default: - data["sandbox"] = map[string]interface{}{ - "enabled": false, - "type": "landlock", - "network": false, - } - } - return data, nil - }, - }) - - return r -} - -// NeedsMigration returns true if the config data is at a version below CurrentVersion. -func (r *MigrationRegistry) NeedsMigration(data map[string]interface{}) bool { - version := r.getVersion(data) - return version < r.CurrentVersion -} - -// Run applies all relevant migrations sequentially from the config's current version -// to the registry's CurrentVersion. Each migration is atomic: if one fails, we return -// the error without applying further migrations. -func (r *MigrationRegistry) Run(data map[string]interface{}) (map[string]interface{}, error) { - version := r.getVersion(data) - if version >= r.CurrentVersion { - return data, nil - } - - // Sort migrations by FromVersion - sorted := make([]Migration, len(r.Migrations)) - copy(sorted, r.Migrations) - sort.Slice(sorted, func(i, j int) bool { - return sorted[i].FromVersion < sorted[j].FromVersion - }) - - for _, m := range sorted { - if m.FromVersion >= version && m.ToVersion <= r.CurrentVersion { - if m.FromVersion != version { - continue - } - // Make a shallow copy for atomicity check - backup := make(map[string]interface{}, len(data)) - for k, v := range data { - backup[k] = v - } - - result, err := m.Migrate(data) - if err != nil { - // Restore from backup on failure (atomic) - for k := range data { - delete(data, k) - } - for k, v := range backup { - data[k] = v - } - return data, fmt.Errorf("migration v%d→v%d failed: %w", m.FromVersion, m.ToVersion, err) - } - data = result - data["config_version"] = m.ToVersion - version = m.ToVersion - } - } - - return data, nil -} - -// RunWithSidecar is like Run but tracks applied migrations in a sidecar file -// adjacent to configPath. Already-applied migrations are skipped. -func (r *MigrationRegistry) RunWithSidecar(data map[string]interface{}, configPath string) (map[string]interface{}, error) { - version := r.getVersion(data) - if version >= r.CurrentVersion { - return data, nil - } - - sorted := make([]Migration, len(r.Migrations)) - copy(sorted, r.Migrations) - sort.Slice(sorted, func(i, j int) bool { - return sorted[i].FromVersion < sorted[j].FromVersion - }) - - applied := AppliedMigrations(configPath) - - for _, m := range sorted { - if m.FromVersion >= version && m.ToVersion <= r.CurrentVersion { - if m.FromVersion != version { - continue - } - - key := MigrationKey(m) - if applied[key] { - // Already applied — update version and skip - data["config_version"] = m.ToVersion - version = m.ToVersion - continue - } - - backup := make(map[string]interface{}, len(data)) - for k, v := range data { - backup[k] = v - } - - result, err := m.Migrate(data) - if err != nil { - for k := range data { - delete(data, k) - } - for k, v := range backup { - data[k] = v - } - return data, fmt.Errorf("migration v%d→v%d failed: %w", m.FromVersion, m.ToVersion, err) - } - data = result - data["config_version"] = m.ToVersion - version = m.ToVersion - - if recordErr := RecordAppliedMigration(configPath, key); recordErr != nil { - // Non-fatal: log but continue - fmt.Fprintf(os.Stderr, "warning: failed to record migration %s: %v\n", key, recordErr) - } - } - } - - return data, nil -} - -// Backup creates a timestamped backup of the config file before migration. -// Returns the path to the backup file. -func (r *MigrationRegistry) Backup(configPath string) (string, error) { - data, err := os.ReadFile(configPath) - if err != nil { - return "", fmt.Errorf("failed to read config for backup: %w", err) - } - - timestamp := time.Now().Format("20060102_150405") - backupPath := configPath + ".bak." + timestamp - - if err := os.WriteFile(backupPath, data, 0o644); err != nil { - return "", fmt.Errorf("failed to write backup: %w", err) - } - - return backupPath, nil -} - -// MigrateFile reads a config JSON file, checks if migration is needed, -// creates a backup, applies migrations, and writes the updated config. -func (r *MigrationRegistry) MigrateFile(path string) error { - rawData, err := os.ReadFile(path) - if err != nil { - return fmt.Errorf("failed to read config file: %w", err) - } - - var data map[string]interface{} - if unmarshalErr := json.Unmarshal(rawData, &data); unmarshalErr != nil { - return fmt.Errorf("failed to parse config JSON: %w", unmarshalErr) - } - - if !r.NeedsMigration(data) { - return nil - } - - // Create backup before migration - _, err = r.Backup(path) - if err != nil { - return fmt.Errorf("failed to create backup: %w", err) - } - - // Apply migrations - result, err := r.Run(data) - if err != nil { - return err - } - - // Write updated config - output, err := json.MarshalIndent(result, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal migrated config: %w", err) - } - - if err := os.WriteFile(path, output, 0o644); err != nil { - return fmt.Errorf("failed to write migrated config: %w", err) - } - - return nil -} - -// ValidateConfig checks that a migrated config has required fields and correct types. -// Returns a list of warnings (empty list means valid). -func ValidateConfig(data map[string]interface{}) []string { - var warnings []string - - // Check for required fields at current version (v8) - requiredSections := []string{"permissions", "guardian", "tok", "cache_warming", "cost_limits", "sandbox"} - for _, section := range requiredSections { - if _, ok := data[section]; !ok { - warnings = append(warnings, fmt.Sprintf("missing required section: %s", section)) - } - } - - // Check for deprecated fields - deprecatedFields := map[string]string{ - "apiKey": "use api_key instead", - "autoMode": "use auto_approve instead", - } - for field, msg := range deprecatedFields { - if _, ok := data[field]; ok { - warnings = append(warnings, fmt.Sprintf("deprecated field %q found: %s", field, msg)) - } - } - - // Type checks for known sections - if sandbox, ok := data["sandbox"]; ok { - if _, isMap := sandbox.(map[string]interface{}); !isMap { - warnings = append(warnings, "sandbox should be an object with {enabled, type, network}") - } - } - - if permissions, ok := data["permissions"]; ok { - if _, isMap := permissions.(map[string]interface{}); !isMap { - warnings = append(warnings, "permissions should be an object") - } - } - - if guardian, ok := data["guardian"]; ok { - if _, isMap := guardian.(map[string]interface{}); !isMap { - warnings = append(warnings, "guardian should be an object") - } - } - - if tok, ok := data["tok"]; ok { - if _, isMap := tok.(map[string]interface{}); !isMap { - warnings = append(warnings, "tok should be an object") - } - } - - if costLimits, ok := data["cost_limits"]; ok { - if clMap, isMap := costLimits.(map[string]interface{}); isMap { - if maxSession, ok := clMap["max_per_session"]; ok { - if _, isNum := maxSession.(float64); !isNum { - warnings = append(warnings, "cost_limits.max_per_session should be a number") - } - } - } else { - warnings = append(warnings, "cost_limits should be an object") - } - } - - if cacheWarming, ok := data["cache_warming"]; ok { - if _, isMap := cacheWarming.(map[string]interface{}); !isMap { - warnings = append(warnings, "cache_warming should be an object") - } - } - - // Check config_version type - if ver, ok := data["config_version"]; ok { - switch ver.(type) { - case float64, int: - // ok - default: - warnings = append(warnings, "config_version should be an integer") - } - } - - sort.Strings(warnings) - return warnings -} - -// DiffConfigs returns a human-readable diff showing what changed during migration. -func DiffConfigs(old, new map[string]interface{}) string { - var lines []string - - oldVersion := getVersionFromMap(old) - newVersion := getVersionFromMap(new) - - lines = append(lines, fmt.Sprintf("Config Migration v%d → v%d:", oldVersion, newVersion)) - - // Find added keys - for key := range new { - if key == "config_version" { - continue - } - if _, exists := old[key]; !exists { - val := formatValue(new[key]) - lines = append(lines, fmt.Sprintf(" + Added: %s = %s", key, val)) - } - } - - // Find removed keys - for key := range old { - if key == "config_version" { - continue - } - if _, exists := new[key]; !exists { - lines = append(lines, fmt.Sprintf(" - Removed: %s", key)) - } - } - - // Find renamed fields (heuristic: same value, old key gone, new key appeared) - renames := detectRenames(old, new) - for _, rename := range renames { - lines = append(lines, fmt.Sprintf(" ~ Renamed: %s → %s", rename[0], rename[1])) - } - - // Find moved fields - moves := detectMoves(old, new) - for _, move := range moves { - lines = append(lines, fmt.Sprintf(" ~ Moved: %s → %s", move[0], move[1])) - } - - // Find changed keys (present in both but different) - for key := range new { - if key == "config_version" { - continue - } - oldVal, existsInOld := old[key] - if !existsInOld { - continue - } - newVal := new[key] - oldJSON, _ := json.Marshal(oldVal) - newJSON, _ := json.Marshal(newVal) - if string(oldJSON) != string(newJSON) { - lines = append(lines, fmt.Sprintf(" ~ Changed: %s", key)) - } - } - - if len(lines) == 1 { - lines = append(lines, " (no changes)") - } - - return strings.Join(lines, "\n") -} - -// RollbackMigration restores a config file from its backup. -func RollbackMigration(configPath, backupPath string) error { - data, err := os.ReadFile(backupPath) - if err != nil { - return fmt.Errorf("failed to read backup file: %w", err) - } - - if err := os.WriteFile(configPath, data, 0o644); err != nil { - return fmt.Errorf("failed to restore config from backup: %w", err) - } - - return nil -} - -// DetectVersion uses heuristics to determine the config version when no -// config_version field is present. -func DetectVersion(data map[string]interface{}) int { - // If config_version is explicitly set, use it - if ver, ok := data["config_version"]; ok { - switch v := ver.(type) { - case float64: - return int(v) - case int: - return v - } - } - - // Heuristic detection in reverse order (highest version first) - - // Has sandbox as object → v8 - if sandbox, ok := data["sandbox"]; ok { - if _, isMap := sandbox.(map[string]interface{}); isMap { - return 8 - } - } - - // Has cache_warming or cost_limits → v7 - if _, ok := data["cache_warming"]; ok { - return 7 - } - if _, ok := data["cost_limits"]; ok { - return 7 - } - - // Has tok section → v6 - if _, ok := data["tok"]; ok { - return 6 - } - - // Has guardian section or auto_approve → v5 - if _, ok := data["guardian"]; ok { - return 5 - } - if _, ok := data["auto_approve"]; ok { - return 5 - } - - // Has permissions section → v4 - if _, ok := data["permissions"]; ok { - return 4 - } - - // Has top-level model (not nested in provider) → v3 - if _, ok := data["model"]; ok { - if provider, ok := data["provider"].(map[string]interface{}); ok { - if _, hasModel := provider["model"]; !hasModel { - return 3 - } - } else { - return 3 - } - } - - // Has provider.model nested → v2 - if provider, ok := data["provider"].(map[string]interface{}); ok { - if _, hasModel := provider["model"]; hasModel { - return 2 - } - } - - // Has api_key (snake_case) → v2 - if _, ok := data["api_key"]; ok { - return 2 - } - - // Has apiKey (camelCase) → v1 - if _, ok := data["apiKey"]; ok { - return 1 - } - - // Default: assume v1 - return 1 -} - -// ───────────────────────────────────────────────────────────── -// Internal helpers -// ───────────────────────────────────────────────────────────── - -func (r *MigrationRegistry) getVersion(data map[string]interface{}) int { - if ver, ok := data["config_version"]; ok { - switch v := ver.(type) { - case float64: - return int(v) - case int: - return v - } - } - return DetectVersion(data) -} - -func getVersionFromMap(data map[string]interface{}) int { - if ver, ok := data["config_version"]; ok { - switch v := ver.(type) { - case float64: - return int(v) - case int: - return v - } - } - return 0 -} - -func formatValue(v interface{}) string { - switch val := v.(type) { - case map[string]interface{}: - // Show first-level keys with their values - var parts []string - keys := make([]string, 0, len(val)) - for k := range val { - keys = append(keys, k) - } - sort.Strings(keys) - for _, k := range keys { - parts = append(parts, fmt.Sprintf("%s: %v", k, formatSimpleValue(val[k]))) - } - return "{" + strings.Join(parts, ", ") + "}" - default: - return fmt.Sprintf("%v", v) - } -} - -func formatSimpleValue(v interface{}) string { - switch val := v.(type) { - case string: - return fmt.Sprintf("%q", val) - case bool: - if val { - return "true" - } - return "false" - case float64: - if val == float64(int(val)) { - return fmt.Sprintf("%d", int(val)) - } - return fmt.Sprintf("%g", val) - case []interface{}: - return fmt.Sprintf("[%d items]", len(val)) - case map[string]interface{}: - return fmt.Sprintf("{%d keys}", len(val)) - default: - return fmt.Sprintf("%v", v) - } -} - -// detectRenames finds keys that were renamed (same value, different key). -func detectRenames(old, new map[string]interface{}) [][2]string { - var renames [][2]string - - // Known rename mappings - knownRenames := map[string]string{ - "apiKey": "api_key", - "autoMode": "auto_approve", - } - - for oldKey, newKey := range knownRenames { - oldVal, hadOld := old[oldKey] - newVal, hasNew := new[newKey] - _, stillHasOld := new[oldKey] - if hadOld && hasNew && !stillHasOld { - oldJSON, _ := json.Marshal(oldVal) - newJSON, _ := json.Marshal(newVal) - if string(oldJSON) == string(newJSON) { - renames = append(renames, [2]string{oldKey, newKey}) - } - } - } - - return renames -} - -// detectMoves finds values that moved from nested to top-level or vice versa. -func detectMoves(old, new map[string]interface{}) [][2]string { - var moves [][2]string - - // Check provider.model → model - if provider, ok := old["provider"].(map[string]interface{}); ok { - if modelVal, ok := provider["model"]; ok { - if newModel, ok := new["model"]; ok { - oldJSON, _ := json.Marshal(modelVal) - newJSON, _ := json.Marshal(newModel) - if string(oldJSON) == string(newJSON) { - // Check that provider.model no longer exists in new - if newProvider, ok := new["provider"].(map[string]interface{}); ok { - if _, still := newProvider["model"]; !still { - moves = append(moves, [2]string{"provider.model", "model"}) - } - } else if _, hasProvider := new["provider"]; !hasProvider { - moves = append(moves, [2]string{"provider.model", "model"}) - } - } - } - } - } - - return moves -} diff --git a/internal/config/migrate_sidecar.go b/internal/config/migrate_sidecar.go deleted file mode 100644 index 1cc563cc..00000000 --- a/internal/config/migrate_sidecar.go +++ /dev/null @@ -1,69 +0,0 @@ -package config - -import ( - "encoding/json" - "fmt" - "os" - "sort" -) - -// sidecarPath returns the path to the migration sidecar file. -// For ~/.hawk/settings.json it returns ~/.hawk/settings.migrations.json. -func sidecarPath(configPath string) string { - ext := ".json" - base := configPath - if idx := len(configPath) - len(ext); idx >= 0 && configPath[idx:] == ext { - base = configPath[:idx] - } - return base + ".migrations.json" -} - -// sidecarData is the JSON structure of the migration sidecar file. -type sidecarData struct { - Applied []string `json:"applied_migrations"` -} - -// AppliedMigrations reads the set of applied migration keys from the sidecar -// file adjacent to the given config path. Returns an empty set if the -// sidecar doesn't exist or can't be parsed. -func AppliedMigrations(configPath string) map[string]bool { - data, err := os.ReadFile(sidecarPath(configPath)) - if err != nil { - return make(map[string]bool) - } - var sd sidecarData - if err := json.Unmarshal(data, &sd); err != nil { - return make(map[string]bool) - } - result := make(map[string]bool, len(sd.Applied)) - for _, k := range sd.Applied { - result[k] = true - } - return result -} - -// RecordAppliedMigration adds a migration key to the sidecar file. -// Idempotent — recording the same key twice is a no-op. -func RecordAppliedMigration(configPath, migrationKey string) error { - applied := AppliedMigrations(configPath) - if applied[migrationKey] { - return nil - } - keys := make([]string, 0, len(applied)+1) - for k := range applied { - keys = append(keys, k) - } - keys = append(keys, migrationKey) - sort.Strings(keys) - - data, err := json.MarshalIndent(sidecarData{Applied: keys}, "", " ") - if err != nil { - return err - } - return os.WriteFile(sidecarPath(configPath), data, 0o644) -} - -// MigrationKey returns a unique key for a migration step. -func MigrationKey(m Migration) string { - return fmt.Sprintf("v%d->v%d:%s", m.FromVersion, m.ToVersion, m.Description) -} diff --git a/internal/config/migrate_sidecar_test.go b/internal/config/migrate_sidecar_test.go deleted file mode 100644 index b9a2623a..00000000 --- a/internal/config/migrate_sidecar_test.go +++ /dev/null @@ -1,138 +0,0 @@ -package config - -import ( - "encoding/json" - "os" - "path/filepath" - "testing" -) - -func TestSidecarPath_GlobalSettings(t *testing.T) { - got := sidecarPath("/home/user/.hawk/settings.json") - want := "/home/user/.hawk/settings.migrations.json" - if got != want { - t.Fatalf("got %q, want %q", got, want) - } -} - -func TestSidecarPath_ProjectSettings(t *testing.T) { - got := sidecarPath(".hawk/settings.json") - want := ".hawk/settings.migrations.json" - if got != want { - t.Fatalf("got %q, want %q", got, want) - } -} - -func TestAppliedMigrations_EmptyFile(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "settings.json") - - applied := AppliedMigrations(path) - if len(applied) != 0 { - t.Fatalf("expected empty map, got %v", applied) - } -} - -func TestRecordAppliedMigration_CreatesSidecar(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "settings.json") - // Write a minimal config so sidecarPath resolves - if err := os.WriteFile(path, []byte("{}"), 0o644); err != nil { - t.Fatal(err) - } - - err := RecordAppliedMigration(path, "v1->v2:test migration") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - sidecar := sidecarPath(path) - data, err := os.ReadFile(sidecar) - if err != nil { - t.Fatalf("failed to read sidecar: %v", err) - } - - var sd sidecarData - if err := json.Unmarshal(data, &sd); err != nil { - t.Fatalf("failed to parse sidecar: %v", err) - } - - if len(sd.Applied) != 1 || sd.Applied[0] != "v1->v2:test migration" { - t.Fatalf("unexpected sidecar content: %v", sd.Applied) - } -} - -func TestRecordAppliedMigration_Idempotent(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "settings.json") - if err := os.WriteFile(path, []byte("{}"), 0o644); err != nil { - t.Fatal(err) - } - - key := "v1->v2:test" - if err := RecordAppliedMigration(path, key); err != nil { - t.Fatal(err) - } - if err := RecordAppliedMigration(path, key); err != nil { - t.Fatal(err) - } - - applied := AppliedMigrations(path) - if len(applied) != 1 { - t.Fatalf("expected 1 entry, got %d", len(applied)) - } -} - -func TestMigrationKey(t *testing.T) { - m := Migration{ - FromVersion: 1, - ToVersion: 2, - Description: "Rename apiKey to api_key", - } - key := MigrationKey(m) - want := "v1->v2:Rename apiKey to api_key" - if key != want { - t.Fatalf("got %q, want %q", key, want) - } -} - -func TestRunWithSidecar_SkipsApplied(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "settings.json") - if err := os.WriteFile(path, []byte("{}"), 0o644); err != nil { - t.Fatal(err) - } - - r := NewMigrationRegistry() - - // Record the first migration as already applied - firstKey := MigrationKey(r.Migrations[0]) - if err := RecordAppliedMigration(path, firstKey); err != nil { - t.Fatal(err) - } - - // Start from v1 — the first migration should be skipped - data := map[string]interface{}{ - "apiKey": "test-key", - } - - result, err := r.RunWithSidecar(data, path) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - // First migration (apiKey -> api_key) was already applied via sidecar, - // so the data should still have apiKey if the sidecar skip worked. - // However, since we start from v1 and skip the v1->v2 migration, - // the apiKey field should NOT be renamed. - if _, ok := result["apiKey"]; !ok { - // If apiKey was removed, the migration ran despite sidecar - t.Log("apiKey was renamed — sidecar skip may not have worked for raw data") - } - - // Verify the sidecar now has more entries - applied := AppliedMigrations(path) - if !applied[firstKey] { - t.Fatal("expected first key to still be in sidecar") - } -} diff --git a/internal/config/migrate_test.go b/internal/config/migrate_test.go deleted file mode 100644 index 3c3020a5..00000000 --- a/internal/config/migrate_test.go +++ /dev/null @@ -1,1013 +0,0 @@ -package config - -import ( - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" -) - -func TestMigrationV1ToV2(t *testing.T) { - r := NewMigrationRegistry() - data := map[string]interface{}{ - "apiKey": "sk-test-123", - "config_version": 1, - } - - result, err := r.Migrations[0].Migrate(data) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if _, ok := result["apiKey"]; ok { - t.Error("apiKey should have been removed") - } - if val, ok := result["api_key"]; !ok || val != "sk-test-123" { - t.Errorf("api_key should be 'sk-test-123', got %v", val) - } -} - -func TestMigrationV2ToV3(t *testing.T) { - r := NewMigrationRegistry() - data := map[string]interface{}{ - "provider": map[string]interface{}{ - "model": "claude-3-opus", - "name": "anthropic", - }, - "config_version": 2, - } - - result, err := r.Migrations[1].Migrate(data) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if val, ok := result["model"]; !ok || val != "claude-3-opus" { - t.Errorf("model should be 'claude-3-opus', got %v", val) - } - - // Provider should still exist but without model - provider, ok := result["provider"].(map[string]interface{}) - if !ok { - t.Fatal("provider should still exist") - } - if _, ok := provider["model"]; ok { - t.Error("provider.model should have been removed") - } - if provider["name"] != "anthropic" { - t.Error("provider.name should still be 'anthropic'") - } -} - -func TestMigrationV2ToV3_EmptyProvider(t *testing.T) { - r := NewMigrationRegistry() - data := map[string]interface{}{ - "provider": map[string]interface{}{ - "model": "gpt-4", - }, - "config_version": 2, - } - - result, err := r.Migrations[1].Migrate(data) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if val, ok := result["model"]; !ok || val != "gpt-4" { - t.Errorf("model should be 'gpt-4', got %v", val) - } - - // Provider should be removed entirely since it's empty - if _, ok := result["provider"]; ok { - t.Error("empty provider object should have been removed") - } -} - -func TestMigrationV3ToV4(t *testing.T) { - r := NewMigrationRegistry() - data := map[string]interface{}{ - "model": "claude-3-opus", - "config_version": 3, - } - - result, err := r.Migrations[2].Migrate(data) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - perms, ok := result["permissions"].(map[string]interface{}) - if !ok { - t.Fatal("permissions section should be added") - } - if perms["allow_read"] != true { - t.Error("permissions.allow_read should default to true") - } - if perms["allow_execute"] != false { - t.Error("permissions.allow_execute should default to false") - } -} - -func TestMigrationV3ToV4_ExistingPermissions(t *testing.T) { - r := NewMigrationRegistry() - data := map[string]interface{}{ - "permissions": map[string]interface{}{ - "allow_read": false, - }, - "config_version": 3, - } - - result, err := r.Migrations[2].Migrate(data) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - perms, ok := result["permissions"].(map[string]interface{}) - if !ok { - t.Fatal("permissions section should exist") - } - // Should preserve existing value - if perms["allow_read"] != false { - t.Error("existing permissions.allow_read should be preserved as false") - } -} - -func TestMigrationV4ToV5(t *testing.T) { - r := NewMigrationRegistry() - data := map[string]interface{}{ - "autoMode": true, - "config_version": 4, - } - - result, err := r.Migrations[3].Migrate(data) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if _, ok := result["autoMode"]; ok { - t.Error("autoMode should have been removed") - } - if val, ok := result["auto_approve"]; !ok || val != true { - t.Error("auto_approve should be true") - } - - guardian, ok := result["guardian"].(map[string]interface{}) - if !ok { - t.Fatal("guardian section should be added") - } - if guardian["enabled"] != true { - t.Error("guardian.enabled should default to true") - } - if guardian["max_risk"] != "medium" { - t.Error("guardian.max_risk should default to 'medium'") - } -} - -func TestMigrationV5ToV6(t *testing.T) { - r := NewMigrationRegistry() - data := map[string]interface{}{ - "config_version": 5, - } - - result, err := r.Migrations[4].Migrate(data) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - tok, ok := result["tok"].(map[string]interface{}) - if !ok { - t.Fatal("tok section should be added") - } - if tok["compression_mode"] != "full" { - t.Error("tok.compression_mode should default to 'full'") - } - if tok["max_tokens"] != 100000 { - t.Error("tok.max_tokens should default to 100000") - } - if tok["preserve_code"] != true { - t.Error("tok.preserve_code should default to true") - } -} - -func TestMigrationV6ToV7(t *testing.T) { - r := NewMigrationRegistry() - data := map[string]interface{}{ - "config_version": 6, - } - - result, err := r.Migrations[5].Migrate(data) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - cw, ok := result["cache_warming"].(map[string]interface{}) - if !ok { - t.Fatal("cache_warming section should be added") - } - if cw["enabled"] != false { - t.Error("cache_warming.enabled should default to false") - } - if cw["strategy"] != "recent_files" { - t.Error("cache_warming.strategy should default to 'recent_files'") - } - - cl, ok := result["cost_limits"].(map[string]interface{}) - if !ok { - t.Fatal("cost_limits section should be added") - } - if cl["max_per_session"] != 5.0 { - t.Error("cost_limits.max_per_session should default to 5.0") - } - if cl["hard_stop"] != true { - t.Error("cost_limits.hard_stop should default to true") - } -} - -func TestMigrationV7ToV8_BoolSandbox(t *testing.T) { - r := NewMigrationRegistry() - data := map[string]interface{}{ - "sandbox": true, - "config_version": 7, - } - - result, err := r.Migrations[6].Migrate(data) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - sandbox, ok := result["sandbox"].(map[string]interface{}) - if !ok { - t.Fatal("sandbox should be converted to an object") - } - if sandbox["enabled"] != true { - t.Error("sandbox.enabled should be true") - } - if sandbox["type"] != "landlock" { - t.Error("sandbox.type should default to 'landlock'") - } - if sandbox["network"] != false { - t.Error("sandbox.network should default to false") - } -} - -func TestMigrationV7ToV8_StringSandbox(t *testing.T) { - r := NewMigrationRegistry() - data := map[string]interface{}{ - "sandbox": "docker", - "config_version": 7, - } - - result, err := r.Migrations[6].Migrate(data) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - sandbox, ok := result["sandbox"].(map[string]interface{}) - if !ok { - t.Fatal("sandbox should be converted to an object") - } - if sandbox["enabled"] != true { - t.Error("sandbox.enabled should be true") - } - if sandbox["type"] != "docker" { - t.Errorf("sandbox.type should be 'docker', got %v", sandbox["type"]) - } -} - -func TestMigrationV7ToV8_NoSandbox(t *testing.T) { - r := NewMigrationRegistry() - data := map[string]interface{}{ - "config_version": 7, - } - - result, err := r.Migrations[6].Migrate(data) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - sandbox, ok := result["sandbox"].(map[string]interface{}) - if !ok { - t.Fatal("sandbox should be created as an object") - } - if sandbox["enabled"] != false { - t.Error("sandbox.enabled should be false when not previously set") - } -} - -func TestMigrationV7ToV8_AlreadyObject(t *testing.T) { - r := NewMigrationRegistry() - data := map[string]interface{}{ - "sandbox": map[string]interface{}{ - "enabled": true, - }, - "config_version": 7, - } - - result, err := r.Migrations[6].Migrate(data) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - sandbox, ok := result["sandbox"].(map[string]interface{}) - if !ok { - t.Fatal("sandbox should remain an object") - } - if sandbox["enabled"] != true { - t.Error("sandbox.enabled should be preserved as true") - } - if sandbox["type"] != "landlock" { - t.Error("sandbox.type should be added as 'landlock'") - } - if sandbox["network"] != false { - t.Error("sandbox.network should be added as false") - } -} - -func TestFullMigrationChainV1ToV8(t *testing.T) { - r := NewMigrationRegistry() - data := map[string]interface{}{ - "apiKey": "sk-test", - "provider": map[string]interface{}{ - "model": "claude-3-opus", - "name": "anthropic", - }, - "autoMode": true, - "sandbox": true, - "config_version": 1, - } - - result, err := r.Run(data) - if err != nil { - t.Fatalf("full migration failed: %v", err) - } - - // Check v1→v2 - if _, ok := result["apiKey"]; ok { - t.Error("apiKey should be renamed to api_key") - } - if result["api_key"] != "sk-test" { - t.Error("api_key should have value 'sk-test'") - } - - // Check v2→v3 - if result["model"] != "claude-3-opus" { - t.Error("model should be moved to top level") - } - - // Check v3→v4 - if _, ok := result["permissions"].(map[string]interface{}); !ok { - t.Error("permissions section should exist") - } - - // Check v4→v5 - if _, ok := result["autoMode"]; ok { - t.Error("autoMode should be renamed to auto_approve") - } - if result["auto_approve"] != true { - t.Error("auto_approve should be true") - } - if _, ok := result["guardian"].(map[string]interface{}); !ok { - t.Error("guardian section should exist") - } - - // Check v5→v6 - if _, ok := result["tok"].(map[string]interface{}); !ok { - t.Error("tok section should exist") - } - - // Check v6→v7 - if _, ok := result["cache_warming"].(map[string]interface{}); !ok { - t.Error("cache_warming section should exist") - } - if _, ok := result["cost_limits"].(map[string]interface{}); !ok { - t.Error("cost_limits section should exist") - } - - // Check v7→v8 - sandbox, ok := result["sandbox"].(map[string]interface{}) - if !ok { - t.Fatal("sandbox should be an object") - } - if sandbox["enabled"] != true { - t.Error("sandbox.enabled should be true") - } - - // Check final version - ver, ok := result["config_version"] - if !ok { - t.Fatal("config_version should be set") - } - if v, ok := ver.(int); ok { - if v != 8 { - t.Errorf("config_version should be 8, got %d", v) - } - } else { - t.Errorf("config_version should be int, got %T", ver) - } -} - -func TestNeedsMigration(t *testing.T) { - r := NewMigrationRegistry() - - tests := []struct { - name string - data map[string]interface{} - expect bool - }{ - { - name: "version 1 needs migration", - data: map[string]interface{}{"config_version": 1}, - expect: true, - }, - { - name: "version 7 needs migration", - data: map[string]interface{}{"config_version": 7}, - expect: true, - }, - { - name: "version 8 does not need migration", - data: map[string]interface{}{"config_version": 8}, - expect: false, - }, - { - name: "no version field (has apiKey) needs migration", - data: map[string]interface{}{"apiKey": "test"}, - expect: true, - }, - { - name: "higher version does not need migration", - data: map[string]interface{}{"config_version": 99}, - expect: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := r.NeedsMigration(tt.data) - if got != tt.expect { - t.Errorf("NeedsMigration() = %v, want %v", got, tt.expect) - } - }) - } -} - -func TestBackupCreation(t *testing.T) { - dir := t.TempDir() - configPath := filepath.Join(dir, "config.json") - original := `{"config_version": 1, "apiKey": "test"}` - if err := os.WriteFile(configPath, []byte(original), 0o644); err != nil { - t.Fatal(err) - } - - r := NewMigrationRegistry() - backupPath, err := r.Backup(configPath) - if err != nil { - t.Fatalf("backup failed: %v", err) - } - - // Verify backup file exists - if _, statErr := os.Stat(backupPath); os.IsNotExist(statErr) { - t.Fatal("backup file should exist") - } - - // Verify backup content matches original - backupData, err := os.ReadFile(backupPath) - if err != nil { - t.Fatal(err) - } - if string(backupData) != original { - t.Error("backup content should match original") - } - - // Verify backup path format - if !strings.HasPrefix(backupPath, configPath+".bak.") { - t.Errorf("backup path should start with %s.bak., got %s", configPath, backupPath) - } -} - -func TestBackupNonexistentFile(t *testing.T) { - r := NewMigrationRegistry() - _, err := r.Backup("/tmp/nonexistent_config_12345.json") - if err == nil { - t.Error("backup of nonexistent file should fail") - } -} - -func TestRollback(t *testing.T) { - dir := t.TempDir() - configPath := filepath.Join(dir, "config.json") - backupPath := filepath.Join(dir, "config.json.bak.20240315_103045") - - original := `{"config_version": 1, "apiKey": "original"}` - migrated := `{"config_version": 8, "api_key": "original"}` - - if err := os.WriteFile(backupPath, []byte(original), 0o644); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(configPath, []byte(migrated), 0o644); err != nil { - t.Fatal(err) - } - - if err := RollbackMigration(configPath, backupPath); err != nil { - t.Fatalf("rollback failed: %v", err) - } - - // Verify config was restored - data, err := os.ReadFile(configPath) - if err != nil { - t.Fatal(err) - } - if string(data) != original { - t.Error("config should be restored to original content") - } -} - -func TestRollbackMissingBackup(t *testing.T) { - dir := t.TempDir() - configPath := filepath.Join(dir, "config.json") - if err := os.WriteFile(configPath, []byte("{}"), 0o644); err != nil { - t.Fatal(err) - } - - err := RollbackMigration(configPath, "/tmp/nonexistent_backup.json") - if err == nil { - t.Error("rollback with missing backup should fail") - } -} - -func TestValidateConfig_Valid(t *testing.T) { - data := map[string]interface{}{ - "config_version": 8, - "permissions": map[string]interface{}{"allow_read": true}, - "guardian": map[string]interface{}{"enabled": true}, - "tok": map[string]interface{}{"compression_mode": "full"}, - "cache_warming": map[string]interface{}{"enabled": false}, - "cost_limits": map[string]interface{}{"max_per_session": 5.0}, - "sandbox": map[string]interface{}{"enabled": true, "type": "landlock"}, - } - - warnings := ValidateConfig(data) - if len(warnings) != 0 { - t.Errorf("expected no warnings, got: %v", warnings) - } -} - -func TestValidateConfig_MissingSections(t *testing.T) { - data := map[string]interface{}{ - "config_version": 8, - } - - warnings := ValidateConfig(data) - if len(warnings) == 0 { - t.Error("expected warnings for missing sections") - } - - // Should warn about all required sections - joined := strings.Join(warnings, " ") - for _, section := range []string{"permissions", "guardian", "tok", "cache_warming", "cost_limits", "sandbox"} { - if !strings.Contains(joined, section) { - t.Errorf("should warn about missing %s", section) - } - } -} - -func TestValidateConfig_DeprecatedFields(t *testing.T) { - data := map[string]interface{}{ - "config_version": 8, - "apiKey": "should-not-be-here", - "autoMode": true, - "permissions": map[string]interface{}{}, - "guardian": map[string]interface{}{}, - "tok": map[string]interface{}{}, - "cache_warming": map[string]interface{}{}, - "cost_limits": map[string]interface{}{}, - "sandbox": map[string]interface{}{}, - } - - warnings := ValidateConfig(data) - joined := strings.Join(warnings, " ") - if !strings.Contains(joined, "apiKey") { - t.Error("should warn about deprecated apiKey") - } - if !strings.Contains(joined, "autoMode") { - t.Error("should warn about deprecated autoMode") - } -} - -func TestValidateConfig_TypeErrors(t *testing.T) { - data := map[string]interface{}{ - "config_version": 8, - "permissions": "should-be-object", - "guardian": map[string]interface{}{}, - "tok": map[string]interface{}{}, - "cache_warming": map[string]interface{}{}, - "cost_limits": map[string]interface{}{}, - "sandbox": "should-be-object", - } - - warnings := ValidateConfig(data) - joined := strings.Join(warnings, " ") - if !strings.Contains(joined, "permissions should be an object") { - t.Error("should warn about permissions type") - } - if !strings.Contains(joined, "sandbox should be an object") { - t.Error("should warn about sandbox type") - } -} - -func TestDiffConfigs(t *testing.T) { - old := map[string]interface{}{ - "apiKey": "test", - "autoMode": true, - "config_version": 1, - "provider": map[string]interface{}{ - "model": "claude-3-opus", - }, - } - new := map[string]interface{}{ - "api_key": "test", - "auto_approve": true, - "model": "claude-3-opus", - "permissions": map[string]interface{}{}, - "guardian": map[string]interface{}{}, - "tok": map[string]interface{}{}, - "cache_warming": map[string]interface{}{}, - "cost_limits": map[string]interface{}{}, - "sandbox": map[string]interface{}{}, - "config_version": 8, - } - - diff := DiffConfigs(old, new) - - if !strings.Contains(diff, "v1 → v8") { - t.Error("diff should show version range") - } - if !strings.Contains(diff, "Added") { - t.Error("diff should show added sections") - } - if !strings.Contains(diff, "Renamed: apiKey → api_key") { - t.Error("diff should show renamed fields") - } - if !strings.Contains(diff, "Moved: provider.model → model") { - t.Error("diff should show moved fields") - } - if !strings.Contains(diff, "Removed: autoMode") { - t.Error("diff should show removed fields") - } -} - -func TestDetectVersion(t *testing.T) { - tests := []struct { - name string - data map[string]interface{} - version int - }{ - { - name: "explicit version", - data: map[string]interface{}{"config_version": float64(5)}, - version: 5, - }, - { - name: "has apiKey (camelCase) → v1", - data: map[string]interface{}{"apiKey": "test"}, - version: 1, - }, - { - name: "has api_key (snake_case) → v2", - data: map[string]interface{}{"api_key": "test"}, - version: 2, - }, - { - name: "has provider.model nested → v2", - data: map[string]interface{}{ - "provider": map[string]interface{}{"model": "gpt-4"}, - }, - version: 2, - }, - { - name: "has top-level model only → v3", - data: map[string]interface{}{"model": "claude-3-opus"}, - version: 3, - }, - { - name: "has permissions → v4", - data: map[string]interface{}{"permissions": map[string]interface{}{}}, - version: 4, - }, - { - name: "has guardian → v5", - data: map[string]interface{}{"guardian": map[string]interface{}{}}, - version: 5, - }, - { - name: "has auto_approve → v5", - data: map[string]interface{}{"auto_approve": true}, - version: 5, - }, - { - name: "has tok → v6", - data: map[string]interface{}{"tok": map[string]interface{}{}}, - version: 6, - }, - { - name: "has cache_warming → v7", - data: map[string]interface{}{"cache_warming": map[string]interface{}{}}, - version: 7, - }, - { - name: "has cost_limits → v7", - data: map[string]interface{}{"cost_limits": map[string]interface{}{}}, - version: 7, - }, - { - name: "has sandbox as object → v8", - data: map[string]interface{}{"sandbox": map[string]interface{}{"enabled": true}}, - version: 8, - }, - { - name: "empty config → v1", - data: map[string]interface{}{}, - version: 1, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := DetectVersion(tt.data) - if got != tt.version { - t.Errorf("DetectVersion() = %d, want %d", got, tt.version) - } - }) - } -} - -func TestAlreadyCurrentConfigIsNoop(t *testing.T) { - r := NewMigrationRegistry() - data := map[string]interface{}{ - "config_version": 8, - "model": "claude-3-opus", - "permissions": map[string]interface{}{"allow_read": true}, - } - - // Make a copy to compare - original, _ := json.Marshal(data) - - result, err := r.Run(data) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - resultJSON, _ := json.Marshal(result) - if string(original) != string(resultJSON) { - t.Error("already-current config should not be modified") - } -} - -func TestInvalidConfigData(t *testing.T) { - r := NewMigrationRegistry() - - // Config with no recognizable fields - defaults to v1, should still migrate - data := map[string]interface{}{ - "random_field": "value", - "config_version": 1, - } - - result, err := r.Run(data) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - // Should have migrated to v8 with all default sections added - if result["config_version"] != 8 { - t.Errorf("should migrate to v8, got %v", result["config_version"]) - } -} - -func TestMigrateFileEndToEnd(t *testing.T) { - dir := t.TempDir() - configPath := filepath.Join(dir, "config.json") - - originalConfig := map[string]interface{}{ - "config_version": 1, - "apiKey": "sk-secret", - "provider": map[string]interface{}{ - "model": "claude-3-opus", - "name": "anthropic", - }, - "autoMode": true, - "sandbox": true, - } - - data, err := json.MarshalIndent(originalConfig, "", " ") - if err != nil { - t.Fatal(err) - } - if writeErr := os.WriteFile(configPath, data, 0o644); writeErr != nil { - t.Fatal(writeErr) - } - - r := NewMigrationRegistry() - if migrateErr := r.MigrateFile(configPath); migrateErr != nil { - t.Fatalf("MigrateFile failed: %v", migrateErr) - } - - // Read back the migrated config - migratedData, err := os.ReadFile(configPath) - if err != nil { - t.Fatal(err) - } - - var result map[string]interface{} - if unmarshalErr := json.Unmarshal(migratedData, &result); unmarshalErr != nil { - t.Fatalf("failed to parse migrated config: %v", unmarshalErr) - } - - // Verify version - if result["config_version"] != float64(8) { - t.Errorf("config_version should be 8, got %v", result["config_version"]) - } - - // Verify migrations applied - if _, ok := result["apiKey"]; ok { - t.Error("apiKey should have been renamed") - } - if result["api_key"] != "sk-secret" { - t.Error("api_key should be 'sk-secret'") - } - if result["model"] != "claude-3-opus" { - t.Error("model should be at top level") - } - - // Verify sandbox is now an object - sandbox, ok := result["sandbox"].(map[string]interface{}) - if !ok { - t.Fatal("sandbox should be an object") - } - if sandbox["enabled"] != true { - t.Error("sandbox.enabled should be true") - } - - // Verify backup was created - entries, err := os.ReadDir(dir) - if err != nil { - t.Fatal(err) - } - foundBackup := false - for _, entry := range entries { - if strings.HasPrefix(entry.Name(), "config.json.bak.") { - foundBackup = true - break - } - } - if !foundBackup { - t.Error("backup file should have been created") - } -} - -func TestMigrateFileAlreadyCurrent(t *testing.T) { - dir := t.TempDir() - configPath := filepath.Join(dir, "config.json") - - currentConfig := map[string]interface{}{ - "config_version": 8, - "model": "claude-3-opus", - } - - data, err := json.MarshalIndent(currentConfig, "", " ") - if err != nil { - t.Fatal(err) - } - if writeErr := os.WriteFile(configPath, data, 0o644); writeErr != nil { - t.Fatal(writeErr) - } - - r := NewMigrationRegistry() - if migrateErr := r.MigrateFile(configPath); migrateErr != nil { - t.Fatalf("MigrateFile should succeed for current config: %v", migrateErr) - } - - // Verify no backup was created (no migration needed) - entries, err := os.ReadDir(dir) - if err != nil { - t.Fatal(err) - } - for _, entry := range entries { - if strings.HasPrefix(entry.Name(), "config.json.bak.") { - t.Error("no backup should be created when no migration is needed") - } - } -} - -func TestMigrateFileInvalidJSON(t *testing.T) { - dir := t.TempDir() - configPath := filepath.Join(dir, "config.json") - - if err := os.WriteFile(configPath, []byte("not valid json {{{"), 0o644); err != nil { - t.Fatal(err) - } - - r := NewMigrationRegistry() - err := r.MigrateFile(configPath) - if err == nil { - t.Error("MigrateFile should fail for invalid JSON") - } - if !strings.Contains(err.Error(), "parse") { - t.Errorf("error should mention parsing, got: %v", err) - } -} - -func TestMigrateFileNonexistent(t *testing.T) { - r := NewMigrationRegistry() - err := r.MigrateFile("/tmp/nonexistent_config_file_xyz.json") - if err == nil { - t.Error("MigrateFile should fail for nonexistent file") - } -} - -func TestRunPartialMigration(t *testing.T) { - r := NewMigrationRegistry() - - // Start at v4, should only apply v4→v5, v5→v6, v6→v7, v7→v8 - data := map[string]interface{}{ - "config_version": 4, - "api_key": "test", - "model": "gpt-4", - "permissions": map[string]interface{}{"allow_read": true}, - "autoMode": false, - } - - result, err := r.Run(data) - if err != nil { - t.Fatalf("partial migration failed: %v", err) - } - - if result["config_version"] != 8 { - t.Errorf("should end at v8, got %v", result["config_version"]) - } - - // v4→v5 should have renamed autoMode - if _, ok := result["autoMode"]; ok { - t.Error("autoMode should be renamed") - } - if result["auto_approve"] != false { - t.Error("auto_approve should be false") - } - - // Original fields from pre-v4 should be untouched - if result["api_key"] != "test" { - t.Error("api_key should be preserved") - } - if result["model"] != "gpt-4" { - t.Error("model should be preserved") - } -} - -func TestDiffConfigsNoChanges(t *testing.T) { - data := map[string]interface{}{ - "config_version": 8, - "model": "test", - } - - diff := DiffConfigs(data, data) - if !strings.Contains(diff, "no changes") { - t.Error("diff of identical configs should show no changes") - } -} - -func TestValidateConfigVersionType(t *testing.T) { - data := map[string]interface{}{ - "config_version": "eight", // wrong type - "permissions": map[string]interface{}{}, - "guardian": map[string]interface{}{}, - "tok": map[string]interface{}{}, - "cache_warming": map[string]interface{}{}, - "cost_limits": map[string]interface{}{}, - "sandbox": map[string]interface{}{}, - } - - warnings := ValidateConfig(data) - joined := strings.Join(warnings, " ") - if !strings.Contains(joined, "config_version should be an integer") { - t.Error("should warn about config_version type") - } -} - -func TestCostLimitsTypeValidation(t *testing.T) { - data := map[string]interface{}{ - "config_version": 8, - "permissions": map[string]interface{}{}, - "guardian": map[string]interface{}{}, - "tok": map[string]interface{}{}, - "cache_warming": map[string]interface{}{}, - "cost_limits": map[string]interface{}{ - "max_per_session": "not-a-number", - }, - "sandbox": map[string]interface{}{}, - } - - warnings := ValidateConfig(data) - joined := strings.Join(warnings, " ") - if !strings.Contains(joined, "max_per_session should be a number") { - t.Error("should warn about cost_limits.max_per_session type") - } -} diff --git a/internal/config/model_pack_catalog.go b/internal/config/model_pack_catalog.go deleted file mode 100644 index 46d04555..00000000 --- a/internal/config/model_pack_catalog.go +++ /dev/null @@ -1,31 +0,0 @@ -package config - -import ( - "github.com/GrayCodeAI/hawk/internal/provider/routing" - - eycatalog "github.com/GrayCodeAI/eyrie/catalog" -) - -const defaultPackProvider = "anthropic" - -func packRole(provider string, tier eycatalog.ModelTier, temperature float64, maxTokens int, purpose string) ModelRole { - return ModelRole{ - Provider: provider, - Model: routing.PreferredModelForTier(provider, tier, ""), - Temperature: temperature, - MaxTokens: maxTokens, - Purpose: purpose, - } -} - -func anthropicPackModels(haikuTier, sonnetTier, opusTier eycatalog.ModelTier) map[string]ModelRole { - p := defaultPackProvider - return map[string]ModelRole{ - "code": packRole(p, sonnetTier, 0.2, 4096, "code generation and editing"), - "chat": packRole(p, sonnetTier, 0.7, 2048, "interactive conversation"), - "summarize": packRole(p, haikuTier, 0.3, 1024, "summarization"), - "review": packRole(p, sonnetTier, 0.1, 4096, "code review"), - "plan": packRole(p, opusTier, 0.4, 8192, "complex planning and architecture"), - "debug": packRole(p, opusTier, 0.2, 4096, "debugging complex issues"), - } -} diff --git a/internal/config/model_packs.go b/internal/config/model_packs.go deleted file mode 100644 index 2e522c26..00000000 --- a/internal/config/model_packs.go +++ /dev/null @@ -1,415 +0,0 @@ -package config - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "sort" - "strings" - "sync" - - eycatalog "github.com/GrayCodeAI/eyrie/catalog" - - "github.com/GrayCodeAI/hawk/internal/provider/routing" -) - -// ModelRole defines a model configuration for a specific role within a pack. -type ModelRole struct { - Provider string `json:"provider"` - Model string `json:"model"` - Temperature float64 `json:"temperature"` - MaxTokens int `json:"max_tokens"` - Purpose string `json:"purpose"` -} - -// ModelPack is a named configuration that bundles model + provider + settings -// for different use cases. -type ModelPack struct { - Name string `json:"name"` - Description string `json:"description"` - Models map[string]ModelRole `json:"models"` - DefaultProvider string `json:"default_provider"` - Settings map[string]interface{} `json:"settings"` - Tags []string `json:"tags"` - Author string `json:"author"` -} - -// ModelPackRegistry holds all registered packs and tracks the active one. -type ModelPackRegistry struct { - Packs map[string]*ModelPack `json:"packs"` - ActivePack string `json:"active_pack"` - mu sync.RWMutex -} - -// NewModelPackRegistry creates a registry pre-loaded with built-in packs. -func NewModelPackRegistry() *ModelPackRegistry { - r := &ModelPackRegistry{ - Packs: make(map[string]*ModelPack), - ActivePack: "default", - } - - r.Packs["default"] = &ModelPack{ - Name: "default", - Description: "Balanced defaults: sonnet for code, haiku for summarize, opus for complex tasks", - Models: anthropicPackModels(eycatalog.TierHaiku, eycatalog.TierSonnet, eycatalog.TierOpus), - DefaultProvider: defaultPackProvider, - Settings: map[string]interface{}{"stream": true}, - Tags: []string{"recommended", "general"}, - Author: "hawk", - } - - r.Packs["budget"] = &ModelPack{ - Name: "budget", - Description: "Cost-optimized: haiku for everything, sonnet only for complex tasks", - Models: anthropicPackModels(eycatalog.TierHaiku, eycatalog.TierHaiku, eycatalog.TierSonnet), - DefaultProvider: defaultPackProvider, - Settings: map[string]interface{}{"stream": true, "max_retries": 2}, - Tags: []string{"cost-effective", "fast"}, - Author: "hawk", - } - - r.Packs["quality"] = &ModelPack{ - Name: "quality", - Description: "Quality-optimized: opus for code, sonnet for everything else", - Models: anthropicPackModels(eycatalog.TierSonnet, eycatalog.TierSonnet, eycatalog.TierOpus), - DefaultProvider: defaultPackProvider, - Settings: map[string]interface{}{"stream": true, "max_retries": 3}, - Tags: []string{"premium", "thorough"}, - Author: "hawk", - } - - r.Packs["speed"] = &ModelPack{ - Name: "speed", - Description: "Speed-optimized: haiku for everything, lowest latency", - Models: anthropicPackModels(eycatalog.TierHaiku, eycatalog.TierHaiku, eycatalog.TierHaiku), - DefaultProvider: defaultPackProvider, - Settings: map[string]interface{}{"stream": true, "timeout_ms": 5000}, - Tags: []string{"fast", "low-latency"}, - Author: "hawk", - } - - r.Packs["local"] = &ModelPack{ - Name: "local", - Description: "Local models via ollama for all roles", - Models: map[string]ModelRole{ - "code": {Provider: "ollama", Model: "codellama:13b", Temperature: 0.2, MaxTokens: 4096, Purpose: "code generation"}, - "chat": {Provider: "ollama", Model: "llama3:8b", Temperature: 0.7, MaxTokens: 2048, Purpose: "interactive conversation"}, - "summarize": {Provider: "ollama", Model: "llama3:8b", Temperature: 0.3, MaxTokens: 1024, Purpose: "summarization"}, - "review": {Provider: "ollama", Model: "codellama:13b", Temperature: 0.1, MaxTokens: 4096, Purpose: "code review"}, - "plan": {Provider: "ollama", Model: "llama3:70b", Temperature: 0.4, MaxTokens: 4096, Purpose: "planning"}, - "debug": {Provider: "ollama", Model: "codellama:13b", Temperature: 0.2, MaxTokens: 4096, Purpose: "debugging"}, - }, - DefaultProvider: "ollama", - Settings: map[string]interface{}{"stream": true, "base_url": "http://localhost:11434"}, - Tags: []string{"local", "private", "offline"}, - Author: "hawk", - } - - r.Packs["balanced"] = &ModelPack{ - Name: "balanced", - Description: "Balanced: sonnet for code/review, haiku for chat/summarize (from eyrie catalog)", - Models: anthropicPackModels(eycatalog.TierHaiku, eycatalog.TierSonnet, eycatalog.TierSonnet), - DefaultProvider: defaultPackProvider, - Settings: map[string]interface{}{"stream": true}, - Tags: []string{"balanced", "general"}, - Author: "hawk", - } - - return r -} - -// GetModel returns the ModelRole for the given role from the active pack. -// If the role is not found, it returns a zero-value ModelRole. -func (r *ModelPackRegistry) GetModel(role string) ModelRole { - r.mu.RLock() - defer r.mu.RUnlock() - - pack, ok := r.Packs[r.ActivePack] - if !ok { - return ModelRole{} - } - mr, ok := pack.Models[role] - if !ok { - return ModelRole{} - } - return mr -} - -// SetActive switches the active pack. Returns an error if the pack does not exist. -func (r *ModelPackRegistry) SetActive(packName string) error { - r.mu.Lock() - defer r.mu.Unlock() - - if _, ok := r.Packs[packName]; !ok { - available := make([]string, 0, len(r.Packs)) - for name := range r.Packs { - available = append(available, name) - } - sort.Strings(available) - return fmt.Errorf("model pack %q not found; available: %s", packName, strings.Join(available, ", ")) - } - r.ActivePack = packName - return nil -} - -// Register adds a new pack to the registry. If a pack with the same name -// already exists it will be overwritten. -func (r *ModelPackRegistry) Register(pack *ModelPack) { - r.mu.Lock() - defer r.mu.Unlock() - - r.Packs[pack.Name] = pack -} - -// List returns all registered packs sorted by name. -func (r *ModelPackRegistry) List() []*ModelPack { - r.mu.RLock() - defer r.mu.RUnlock() - - names := make([]string, 0, len(r.Packs)) - for name := range r.Packs { - names = append(names, name) - } - sort.Strings(names) - - packs := make([]*ModelPack, 0, len(names)) - for _, name := range names { - packs = append(packs, r.Packs[name]) - } - return packs -} - -// FormatPack returns a human-readable formatted string for a model pack. -func FormatPack(pack *ModelPack) string { - if pack == nil { - return "" - } - - var b strings.Builder - b.WriteString(fmt.Sprintf("Model Pack: %q\n", pack.Name)) - b.WriteString(strings.Repeat("─", 20)) - b.WriteString("\n") - - // Define a consistent role order for display. - roleOrder := []string{"code", "review", "chat", "summarize", "plan", "debug"} - - for _, role := range roleOrder { - mr, ok := pack.Models[role] - if !ok { - continue - } - b.WriteString(fmt.Sprintf("%-10s %s (temp: %.1f)\n", role+":", mr.Model, mr.Temperature)) - } - - // Print any extra roles not in the standard order. - extra := make([]string, 0) - for role := range pack.Models { - found := false - for _, r := range roleOrder { - if role == r { - found = true - break - } - } - if !found { - extra = append(extra, role) - } - } - sort.Strings(extra) - for _, role := range extra { - mr := pack.Models[role] - b.WriteString(fmt.Sprintf("%-10s %s (temp: %.1f)\n", role+":", mr.Model, mr.Temperature)) - } - - b.WriteString(fmt.Sprintf("\nProvider: %s\n", pack.DefaultProvider)) - return b.String() -} - -// costPerToken returns approximate cost per 1K tokens from the eyrie catalog. -func costPerToken(model string) float64 { - if info, ok := routing.Find(model); ok { - if info.InputPrice == 0 && info.OutputPrice == 0 { - return 0 - } - if info.InputPrice > 0 || info.OutputPrice > 0 { - avg := (info.InputPrice + info.OutputPrice) / 2 - if avg > 0 { - return avg / 1000 - } - } - } - return 0 -} - -// EstimateCost estimates the cost of a session with the given pack based on -// approximate tokens per session distributed evenly across roles. -func EstimateCost(pack *ModelPack, tokensPerSession int) float64 { - if pack == nil || len(pack.Models) == 0 { - return 0.0 - } - - totalCost := 0.0 - roleCount := len(pack.Models) - tokensPerRole := tokensPerSession / roleCount - - for _, mr := range pack.Models { - cost := costPerToken(mr.Model) * float64(tokensPerRole) / 1000.0 - totalCost += cost - } - return totalCost -} - -// packFilePath returns the path where packs are persisted. -func packFilePath() (string, error) { - home, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("cannot determine home directory: %w", err) - } - dir := filepath.Join(home, ".hawk") - if err := os.MkdirAll(dir, 0o755); err != nil { - return "", fmt.Errorf("cannot create config directory: %w", err) - } - return filepath.Join(dir, "model_packs.json"), nil -} - -// packFileData is the JSON structure persisted to disk. -type packFileData struct { - Packs map[string]*ModelPack `json:"packs"` - ActivePack string `json:"active_pack"` -} - -// Save persists the registry to disk as JSON. -func (r *ModelPackRegistry) Save() error { - r.mu.RLock() - data := packFileData{ - Packs: r.Packs, - ActivePack: r.ActivePack, - } - r.mu.RUnlock() - - fp, err := packFilePath() - if err != nil { - return err - } - - raw, err := json.MarshalIndent(data, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal model packs: %w", err) - } - - if err := os.WriteFile(fp, raw, 0o644); err != nil { - return fmt.Errorf("failed to write model packs file: %w", err) - } - return nil -} - -// Load reads the registry from disk, merging with built-in packs. -func (r *ModelPackRegistry) Load() error { - fp, err := packFilePath() - if err != nil { - return err - } - - raw, err := os.ReadFile(fp) - if err != nil { - if os.IsNotExist(err) { - return nil // No file yet, use defaults. - } - return fmt.Errorf("failed to read model packs file: %w", err) - } - - var data packFileData - if err := json.Unmarshal(raw, &data); err != nil { - return fmt.Errorf("failed to parse model packs file: %w", err) - } - - r.mu.Lock() - defer r.mu.Unlock() - - // Merge loaded packs into existing (built-in packs are preserved unless overridden). - for name, pack := range data.Packs { - r.Packs[name] = pack - } - if data.ActivePack != "" { - if _, ok := r.Packs[data.ActivePack]; ok { - r.ActivePack = data.ActivePack - } - } - return nil -} - -// Compare produces a side-by-side comparison of two packs. -func (r *ModelPackRegistry) Compare(packA, packB string) string { - r.mu.RLock() - defer r.mu.RUnlock() - - a, okA := r.Packs[packA] - b, okB := r.Packs[packB] - - if !okA { - return fmt.Sprintf("pack %q not found", packA) - } - if !okB { - return fmt.Sprintf("pack %q not found", packB) - } - - var sb strings.Builder - headerA := fmt.Sprintf("%-30s", packA) - headerB := fmt.Sprintf("%-30s", packB) - sb.WriteString(fmt.Sprintf("%-12s %s %s\n", "Role", headerA, headerB)) - sb.WriteString(strings.Repeat("─", 72)) - sb.WriteString("\n") - - roleOrder := []string{"code", "review", "chat", "summarize", "plan", "debug"} - - // Collect all roles from both packs. - allRoles := make(map[string]bool) - for role := range a.Models { - allRoles[role] = true - } - for role := range b.Models { - allRoles[role] = true - } - // Add extra roles not in the standard order. - extras := make([]string, 0) - for role := range allRoles { - found := false - for _, r := range roleOrder { - if role == r { - found = true - break - } - } - if !found { - extras = append(extras, role) - } - } - sort.Strings(extras) - displayOrder := append(roleOrder, extras...) - - for _, role := range displayOrder { - if !allRoles[role] { - continue - } - colA := "(none)" - colB := "(none)" - if mr, ok := a.Models[role]; ok { - colA = fmt.Sprintf("%s (%.1f)", mr.Model, mr.Temperature) - } - if mr, ok := b.Models[role]; ok { - colB = fmt.Sprintf("%s (%.1f)", mr.Model, mr.Temperature) - } - sb.WriteString(fmt.Sprintf("%-12s %-30s %-30s\n", role, colA, colB)) - } - - sb.WriteString(strings.Repeat("─", 72)) - sb.WriteString("\n") - sb.WriteString(fmt.Sprintf("%-12s %-30s %-30s\n", "Provider", a.DefaultProvider, b.DefaultProvider)) - - costA := EstimateCost(a, 100000) - costB := EstimateCost(b, 100000) - sb.WriteString(fmt.Sprintf("%-12s %-30s %-30s\n", "Est. Cost", fmt.Sprintf("$%.4f/100K tok", costA), fmt.Sprintf("$%.4f/100K tok", costB))) - - return sb.String() -} diff --git a/internal/config/model_packs_test.go b/internal/config/model_packs_test.go deleted file mode 100644 index 9b460667..00000000 --- a/internal/config/model_packs_test.go +++ /dev/null @@ -1,425 +0,0 @@ -package config - -import ( - "fmt" - "os" - "path/filepath" - "strings" - "sync" - "testing" - - eycatalog "github.com/GrayCodeAI/eyrie/catalog" -) - -func TestNewModelPackRegistry(t *testing.T) { - r := NewModelPackRegistry() - if r == nil { - t.Fatal("expected non-nil registry") - } - if r.ActivePack != "default" { - t.Errorf("expected active pack 'default', got %q", r.ActivePack) - } - - expectedPacks := []string{"default", "budget", "quality", "speed", "local", "balanced"} - for _, name := range expectedPacks { - if _, ok := r.Packs[name]; !ok { - t.Errorf("expected built-in pack %q to exist", name) - } - } -} - -func TestGetModel(t *testing.T) { - r := NewModelPackRegistry() - wantSonnet := testPackModel(t, eycatalog.TierSonnet) - wantHaiku := testPackModel(t, eycatalog.TierHaiku) - wantOpus := testPackModel(t, eycatalog.TierOpus) - - tests := []struct { - role string - wantModel string - }{ - {"code", wantSonnet}, - {"summarize", wantHaiku}, - {"plan", wantOpus}, - {"debug", wantOpus}, - {"chat", wantSonnet}, - {"review", wantSonnet}, - } - - for _, tt := range tests { - mr := r.GetModel(tt.role) - if mr.Model != tt.wantModel { - t.Errorf("GetModel(%q): got model %q, want %q", tt.role, mr.Model, tt.wantModel) - } - } -} - -func TestGetModel_UnknownRole(t *testing.T) { - r := NewModelPackRegistry() - mr := r.GetModel("nonexistent") - if mr.Model != "" { - t.Errorf("expected empty model for unknown role, got %q", mr.Model) - } -} - -func TestGetModel_UnknownPack(t *testing.T) { - r := NewModelPackRegistry() - r.ActivePack = "does-not-exist" - mr := r.GetModel("code") - if mr.Model != "" { - t.Errorf("expected empty model for unknown pack, got %q", mr.Model) - } -} - -func TestSetActive(t *testing.T) { - r := NewModelPackRegistry() - - if err := r.SetActive("budget"); err != nil { - t.Fatalf("unexpected error: %v", err) - } - if r.ActivePack != "budget" { - t.Errorf("expected active pack 'budget', got %q", r.ActivePack) - } - - // Verify GetModel now uses the budget pack. - mr := r.GetModel("code") - wantHaiku := testPackModel(t, eycatalog.TierHaiku) - if mr.Model != wantHaiku { - t.Errorf("expected haiku for code in budget pack, got %q", mr.Model) - } -} - -func TestSetActive_NotFound(t *testing.T) { - r := NewModelPackRegistry() - err := r.SetActive("nonexistent") - if err == nil { - t.Fatal("expected error for nonexistent pack") - } - if !strings.Contains(err.Error(), "not found") { - t.Errorf("error should mention 'not found': %v", err) - } - if !strings.Contains(err.Error(), "nonexistent") { - t.Errorf("error should mention pack name: %v", err) - } -} - -func TestRegister(t *testing.T) { - r := NewModelPackRegistry() - - custom := &ModelPack{ - Name: "custom", - Description: "A custom pack for testing", - Models: map[string]ModelRole{ - "code": {Provider: "openai", Model: "gpt-4o", Temperature: 0.1, MaxTokens: 4096, Purpose: "code"}, - "chat": {Provider: "openai", Model: "gpt-4o-mini", Temperature: 0.8, MaxTokens: 2048, Purpose: "chat"}, - }, - DefaultProvider: "openai", - Settings: map[string]interface{}{"api_version": "2024-01"}, - Tags: []string{"custom", "openai"}, - Author: "tester", - } - - r.Register(custom) - - if _, ok := r.Packs["custom"]; !ok { - t.Fatal("expected custom pack to be registered") - } - - // Switch to it and verify. - if err := r.SetActive("custom"); err != nil { - t.Fatalf("unexpected error: %v", err) - } - mr := r.GetModel("code") - if mr.Model != "gpt-4o" { - t.Errorf("expected gpt-4o, got %q", mr.Model) - } -} - -func TestRegister_Overwrite(t *testing.T) { - r := NewModelPackRegistry() - - override := &ModelPack{ - Name: "budget", - Description: "Overridden budget pack", - Models: map[string]ModelRole{ - "code": {Provider: "anthropic", Model: "claude-sonnet-4-6", Temperature: 0.3, MaxTokens: 4096, Purpose: "code"}, - }, - DefaultProvider: "anthropic", - Tags: []string{"override"}, - Author: "tester", - } - r.Register(override) - - if r.Packs["budget"].Description != "Overridden budget pack" { - t.Error("expected pack to be overwritten") - } -} - -func TestList(t *testing.T) { - r := NewModelPackRegistry() - packs := r.List() - - if len(packs) != 6 { - t.Errorf("expected 6 packs, got %d", len(packs)) - } - - // Verify sorted order. - for i := 1; i < len(packs); i++ { - if packs[i-1].Name >= packs[i].Name { - t.Errorf("packs not sorted: %q >= %q", packs[i-1].Name, packs[i].Name) - } - } -} - -func TestFormatPack(t *testing.T) { - r := NewModelPackRegistry() - pack := r.Packs["balanced"] - - output := FormatPack(pack) - - if !strings.Contains(output, `"balanced"`) { - t.Error("output should contain pack name") - } - // Without a live catalog, models are empty — skip model-specific checks - if pack.Models["code"].Model != "" { - if !strings.Contains(output, "claude-sonnet-4-6") { - t.Error("output should contain sonnet model") - } - if !strings.Contains(output, "claude-haiku-4-5") { - t.Error("output should contain haiku model") - } - } - if !strings.Contains(output, "Provider: anthropic") { - t.Error("output should contain provider") - } - if !strings.Contains(output, "code:") { - t.Error("output should contain 'code:' role") - } - if !strings.Contains(output, "temp:") { - t.Error("output should contain temperature") - } -} - -func TestFormatPack_Nil(t *testing.T) { - output := FormatPack(nil) - if output != "" { - t.Errorf("expected empty string for nil pack, got %q", output) - } -} - -func TestEstimateCost(t *testing.T) { - r := NewModelPackRegistry() - - costBudget := EstimateCost(r.Packs["budget"], 100000) - costQuality := EstimateCost(r.Packs["quality"], 100000) - costLocal := EstimateCost(r.Packs["local"], 100000) - - // Without a live catalog, models are empty — cost is 0 - if costBudget > 0 && costQuality < costBudget { - t.Errorf("quality (%f) should cost at least as much as budget (%f)", costQuality, costBudget) - } - if costLocal != 0.0 { - t.Errorf("local pack should be free, got %f", costLocal) - } -} - -func TestEstimateCost_Nil(t *testing.T) { - cost := EstimateCost(nil, 100000) - if cost != 0.0 { - t.Errorf("expected 0 for nil pack, got %f", cost) - } -} - -func TestEstimateCost_Empty(t *testing.T) { - pack := &ModelPack{ - Name: "empty", - Models: map[string]ModelRole{}, - } - cost := EstimateCost(pack, 100000) - if cost != 0.0 { - t.Errorf("expected 0 for empty pack, got %f", cost) - } -} - -func TestSaveAndLoad(t *testing.T) { - // Use a temp directory for testing. - tmpDir := t.TempDir() - origHome := os.Getenv("HOME") - os.Setenv("HOME", tmpDir) - defer os.Setenv("HOME", origHome) - - // Ensure .hawk directory exists. - os.MkdirAll(filepath.Join(tmpDir, ".hawk"), 0o755) - - r := NewModelPackRegistry() - - // Register a custom pack. - r.Register(&ModelPack{ - Name: "test-pack", - Description: "For testing save/load", - Models: map[string]ModelRole{ - "code": {Provider: "test", Model: "test-model", Temperature: 0.5, MaxTokens: 1000, Purpose: "test"}, - }, - DefaultProvider: "test", - Tags: []string{"test"}, - Author: "tester", - }) - r.ActivePack = "test-pack" - - // Save. - if err := r.Save(); err != nil { - t.Fatalf("Save() error: %v", err) - } - - // Verify file exists. - fp := filepath.Join(tmpDir, ".hawk", "model_packs.json") - if _, err := os.Stat(fp); err != nil { - t.Fatalf("expected file to exist at %s", fp) - } - - // Load into a fresh registry. - r2 := NewModelPackRegistry() - if err := r2.Load(); err != nil { - t.Fatalf("Load() error: %v", err) - } - - if r2.ActivePack != "test-pack" { - t.Errorf("expected active pack 'test-pack' after load, got %q", r2.ActivePack) - } - if _, ok := r2.Packs["test-pack"]; !ok { - t.Error("expected test-pack to be loaded") - } - // Built-in packs should still be present. - if _, ok := r2.Packs["default"]; !ok { - t.Error("expected built-in 'default' pack to persist after load") - } -} - -func TestLoad_NoFile(t *testing.T) { - tmpDir := t.TempDir() - origHome := os.Getenv("HOME") - os.Setenv("HOME", tmpDir) - defer os.Setenv("HOME", origHome) - - r := NewModelPackRegistry() - // Should not error when no file exists. - if err := r.Load(); err != nil { - t.Fatalf("Load() should not error when file missing: %v", err) - } -} - -func TestCompare(t *testing.T) { - r := NewModelPackRegistry() - output := r.Compare("budget", "quality") - - if !strings.Contains(output, "budget") { - t.Error("compare should contain pack A name") - } - if !strings.Contains(output, "quality") { - t.Error("compare should contain pack B name") - } - if !strings.Contains(output, "code") { - t.Error("compare should contain role 'code'") - } - if !strings.Contains(output, "Provider") { - t.Error("compare should contain 'Provider'") - } - if !strings.Contains(output, "Est. Cost") { - t.Error("compare should contain cost estimate") - } -} - -func TestCompare_NotFound(t *testing.T) { - r := NewModelPackRegistry() - - output := r.Compare("nonexistent", "budget") - if !strings.Contains(output, "not found") { - t.Errorf("expected 'not found' in output, got: %s", output) - } - - output = r.Compare("budget", "nonexistent") - if !strings.Contains(output, "not found") { - t.Errorf("expected 'not found' in output, got: %s", output) - } -} - -func TestModelPackConcurrentAccess(t *testing.T) { - r := NewModelPackRegistry() - var wg sync.WaitGroup - - // Concurrent reads. - for i := 0; i < 50; i++ { - wg.Add(1) - go func() { - defer wg.Done() - _ = r.GetModel("code") - _ = r.List() - }() - } - - // Concurrent writes. - for i := 0; i < 10; i++ { - wg.Add(1) - go func(n int) { - defer wg.Done() - packs := []string{"default", "budget", "quality", "speed", "local", "balanced"} - _ = r.SetActive(packs[n%len(packs)]) - }(i) - } - - // Concurrent register. - for i := 0; i < 5; i++ { - wg.Add(1) - go func(n int) { - defer wg.Done() - r.Register(&ModelPack{ - Name: fmt.Sprintf("concurrent-%d", n), - Models: map[string]ModelRole{"code": {Model: "test"}}, - }) - }(i) - } - - wg.Wait() -} - -func TestAllRolesPresent(t *testing.T) { - r := NewModelPackRegistry() - expectedRoles := []string{"code", "chat", "summarize", "review", "plan", "debug"} - - for name, pack := range r.Packs { - for _, role := range expectedRoles { - if _, ok := pack.Models[role]; !ok { - t.Errorf("pack %q missing role %q", name, role) - } - } - } -} - -func TestLocalPackUsesOllama(t *testing.T) { - r := NewModelPackRegistry() - pack := r.Packs["local"] - - if pack.DefaultProvider != "ollama" { - t.Errorf("local pack should use ollama provider, got %q", pack.DefaultProvider) - } - for role, mr := range pack.Models { - if mr.Provider != "ollama" { - t.Errorf("local pack role %q should use ollama provider, got %q", role, mr.Provider) - } - } -} - -func TestSpeedPackUsesHaiku(t *testing.T) { - r := NewModelPackRegistry() - pack := r.Packs["speed"] - - for role, mr := range pack.Models { - if mr.Model == "" { - // Without a live catalog, models are empty — expected - continue - } - if !strings.Contains(mr.Model, "haiku") { - t.Errorf("speed pack role %q should use haiku, got %q", role, mr.Model) - } - } -} diff --git a/internal/config/model_packs_test_helper.go b/internal/config/model_packs_test_helper.go deleted file mode 100644 index 71187e7c..00000000 --- a/internal/config/model_packs_test_helper.go +++ /dev/null @@ -1,19 +0,0 @@ -package config - -import ( - "testing" - - "github.com/GrayCodeAI/hawk/internal/provider/routing" - - eycatalog "github.com/GrayCodeAI/eyrie/catalog" -) - -func testPackModel(t *testing.T, tier eycatalog.ModelTier) string { - t.Helper() - m := routing.PreferredModelForTier(defaultPackProvider, tier, "") - if m == "" { - // Without a live catalog, no models are available (fully dynamic) - t.Skipf("no %s tier model for %s without live catalog", tier, defaultPackProvider) - } - return m -} diff --git a/internal/config/modelfile.go b/internal/config/modelfile.go deleted file mode 100644 index 5f3b3da0..00000000 --- a/internal/config/modelfile.go +++ /dev/null @@ -1,573 +0,0 @@ -package config - -import ( - "fmt" - "os" - "strconv" - "strings" - "sync" -) - -// ModelMessage represents a conversation example in a Modelfile. -type ModelMessage struct { - Role string - Content string -} - -// ModelConfig holds the parsed configuration from a Modelfile. -type ModelConfig struct { - From string - Parameters map[string]interface{} - System string - Template string - Messages []ModelMessage - License string - Adapters []string -} - -// ModelfileParser parses Modelfile DSL content into ModelConfig. -type ModelfileParser struct { - mu sync.Mutex -} - -// NewModelfileParser creates a new ModelfileParser instance. -func NewModelfileParser() *ModelfileParser { - return &ModelfileParser{} -} - -// validParameters defines the set of recognized parameter names. -var validParameters = map[string]bool{ - "temperature": true, - "top_p": true, - "top_k": true, - "max_tokens": true, - "stop": true, - "seed": true, - "num_ctx": true, - "repeat_penalty": true, - "presence_penalty": true, - "frequency_penalty": true, -} - -// validRoles defines the set of valid message roles. -var validRoles = map[string]bool{ - "system": true, - "user": true, - "assistant": true, -} - -// Parse parses Modelfile DSL content and returns a ModelConfig. -func (p *ModelfileParser) Parse(content string) (*ModelConfig, error) { - p.mu.Lock() - defer p.mu.Unlock() - - cfg := &ModelConfig{ - Parameters: make(map[string]interface{}), - } - - lines := strings.Split(content, "\n") - i := 0 - for i < len(lines) { - line := strings.TrimSpace(lines[i]) - - // Skip empty lines and comments. - if line == "" || strings.HasPrefix(line, "#") { - i++ - continue - } - - // Extract directive and argument. - directive, arg := splitDirective(line) - directive = strings.ToUpper(directive) - - switch directive { - case "FROM": - if arg == "" { - return nil, fmt.Errorf("line %d: FROM requires a model name", i+1) - } - cfg.From = arg - - case "PARAMETER": - name, value, err := parseParameter(arg) - if err != nil { - return nil, fmt.Errorf("line %d: %w", i+1, err) - } - cfg.Parameters[name] = value - - case "SYSTEM": - text, advance, err := parseTextValue(arg, lines, i) - if err != nil { - return nil, fmt.Errorf("line %d: %w", i+1, err) - } - cfg.System = text - i += advance - continue - - case "TEMPLATE": - text, advance, err := parseTextValue(arg, lines, i) - if err != nil { - return nil, fmt.Errorf("line %d: %w", i+1, err) - } - cfg.Template = text - i += advance - continue - - case "MESSAGE": - role, msgContent, err := parseMessage(arg) - if err != nil { - return nil, fmt.Errorf("line %d: %w", i+1, err) - } - cfg.Messages = append(cfg.Messages, ModelMessage{Role: role, Content: msgContent}) - - case "LICENSE": - text, advance, err := parseTextValue(arg, lines, i) - if err != nil { - return nil, fmt.Errorf("line %d: %w", i+1, err) - } - cfg.License = text - i += advance - continue - - case "ADAPTER": - if arg == "" { - return nil, fmt.Errorf("line %d: ADAPTER requires a path", i+1) - } - cfg.Adapters = append(cfg.Adapters, arg) - - default: - return nil, fmt.Errorf("line %d: unknown directive %q", i+1, directive) - } - - i++ - } - - return cfg, nil -} - -// ParseFile reads a Modelfile from disk and parses it. -func (p *ModelfileParser) ParseFile(path string) (*ModelConfig, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("reading modelfile: %w", err) - } - return p.Parse(string(data)) -} - -// Validate checks the ModelConfig for errors and returns a list of issues. -func (p *ModelfileParser) Validate(config *ModelConfig) []string { - var issues []string - - if config.From == "" { - issues = append(issues, "FROM is required") - } - - if temp, ok := config.Parameters["temperature"]; ok { - if v, err := toFloat64(temp); err == nil { - if v < 0 || v > 2 { - issues = append(issues, "temperature must be between 0 and 2") - } - } - } - - if topP, ok := config.Parameters["top_p"]; ok { - if v, err := toFloat64(topP); err == nil { - if v < 0 || v > 1 { - issues = append(issues, "top_p must be between 0 and 1") - } - } - } - - for name := range config.Parameters { - if !validParameters[name] { - issues = append(issues, fmt.Sprintf("unknown parameter %q", name)) - } - } - - for _, msg := range config.Messages { - if !validRoles[msg.Role] { - issues = append(issues, fmt.Sprintf("invalid message role %q", msg.Role)) - } - } - - return issues -} - -// Render converts a ModelConfig back into Modelfile format. -func (p *ModelfileParser) Render(config *ModelConfig) string { - var b strings.Builder - - if config.From != "" { - b.WriteString("FROM ") - b.WriteString(config.From) - b.WriteString("\n") - } - - for name, value := range config.Parameters { - b.WriteString("PARAMETER ") - b.WriteString(name) - b.WriteString(" ") - b.WriteString(formatParamValue(value)) - b.WriteString("\n") - } - - if config.System != "" { - b.WriteString("SYSTEM ") - b.WriteString(quoteMultiline(config.System)) - b.WriteString("\n") - } - - if config.Template != "" { - b.WriteString("TEMPLATE ") - b.WriteString(quoteMultiline(config.Template)) - b.WriteString("\n") - } - - for _, msg := range config.Messages { - b.WriteString("MESSAGE ") - b.WriteString(msg.Role) - b.WriteString(" ") - b.WriteString(quoteValue(msg.Content)) - b.WriteString("\n") - } - - if config.License != "" { - b.WriteString("LICENSE ") - b.WriteString(quoteValue(config.License)) - b.WriteString("\n") - } - - for _, adapter := range config.Adapters { - b.WriteString("ADAPTER ") - b.WriteString(adapter) - b.WriteString("\n") - } - - return b.String() -} - -// ToProviderConfig converts a ModelConfig into an eyrie-compatible provider configuration map. -func (p *ModelfileParser) ToProviderConfig(config *ModelConfig) map[string]interface{} { - result := map[string]interface{}{ - "model": config.From, - } - - if config.System != "" { - result["system_prompt"] = config.System - } - - if len(config.Parameters) > 0 { - params := make(map[string]interface{}) - for k, v := range config.Parameters { - params[k] = v - } - result["parameters"] = params - } - - if len(config.Messages) > 0 { - msgs := make([]map[string]string, 0, len(config.Messages)) - for _, m := range config.Messages { - msgs = append(msgs, map[string]string{ - "role": m.Role, - "content": m.Content, - }) - } - result["messages"] = msgs - } - - if config.Template != "" { - result["template"] = config.Template - } - - if len(config.Adapters) > 0 { - result["adapters"] = config.Adapters - } - - return result -} - -// MergeConfigs merges override into base. Override takes precedence for same parameters. -func (p *ModelfileParser) MergeConfigs(base, override *ModelConfig) *ModelConfig { - merged := &ModelConfig{ - From: base.From, - Parameters: make(map[string]interface{}), - System: base.System, - Template: base.Template, - License: base.License, - } - - // Copy base parameters. - for k, v := range base.Parameters { - merged.Parameters[k] = v - } - - // Copy base messages. - merged.Messages = append(merged.Messages, base.Messages...) - - // Copy base adapters. - merged.Adapters = append(merged.Adapters, base.Adapters...) - - // Apply overrides. - if override.From != "" { - merged.From = override.From - } - if override.System != "" { - merged.System = override.System - } - if override.Template != "" { - merged.Template = override.Template - } - if override.License != "" { - merged.License = override.License - } - - for k, v := range override.Parameters { - merged.Parameters[k] = v - } - - if len(override.Messages) > 0 { - merged.Messages = override.Messages - } - - if len(override.Adapters) > 0 { - merged.Adapters = override.Adapters - } - - return merged -} - -// DefaultModelConfigs returns built-in configurations for common use cases. -func DefaultModelConfigs() map[string]*ModelConfig { - return map[string]*ModelConfig{ - "coding": { - From: "claude-sonnet-4-6", - Parameters: map[string]interface{}{ - "temperature": 0.2, - "max_tokens": 4096, - }, - System: "You are a coding assistant. Write clean, efficient, and well-documented code. Follow best practices and established patterns in the codebase.", - }, - "creative": { - From: "claude-sonnet-4-6", - Parameters: map[string]interface{}{ - "temperature": 0.9, - "max_tokens": 4096, - }, - System: "You are a creative writing assistant. Be imaginative, expressive, and original in your responses.", - }, - "precise": { - From: "claude-sonnet-4-6", - Parameters: map[string]interface{}{ - "temperature": 0.0, - "top_p": 0.1, - "max_tokens": 4096, - }, - System: "You are a precise assistant. Provide accurate, factual, and concise responses. Avoid speculation.", - }, - } -} - -// FormatConfig produces a human-readable summary of a ModelConfig. -func FormatConfig(config *ModelConfig) string { - var b strings.Builder - - b.WriteString("Model Configuration:\n") - - if config.From != "" { - b.WriteString(fmt.Sprintf(" Base: %s\n", config.From)) - } - - if len(config.Parameters) > 0 { - parts := make([]string, 0, len(config.Parameters)) - for k, v := range config.Parameters { - parts = append(parts, fmt.Sprintf("%s=%v", k, v)) - } - b.WriteString(fmt.Sprintf(" Parameters: %s\n", strings.Join(parts, ", "))) - } - - if config.System != "" { - sys := config.System - if len(sys) > 50 { - sys = sys[:50] + "..." - } - b.WriteString(fmt.Sprintf(" System: %q\n", sys)) - } - - if len(config.Messages) > 0 { - b.WriteString(fmt.Sprintf(" Messages: %d examples\n", len(config.Messages))) - } - - if len(config.Adapters) > 0 { - b.WriteString(fmt.Sprintf(" Adapters: %d\n", len(config.Adapters))) - } - - if config.License != "" { - b.WriteString(fmt.Sprintf(" License: %s\n", config.License)) - } - - return b.String() -} - -// --- internal helpers --- - -// splitDirective splits a line into its directive keyword and the remaining argument. -func splitDirective(line string) (string, string) { - idx := strings.IndexByte(line, ' ') - if idx < 0 { - return line, "" - } - return line[:idx], strings.TrimSpace(line[idx+1:]) -} - -// parseParameter parses "name value" from a PARAMETER directive argument. -func parseParameter(arg string) (string, interface{}, error) { - if arg == "" { - return "", nil, fmt.Errorf("PARAMETER requires name and value") - } - - parts := strings.SplitN(arg, " ", 2) - if len(parts) < 2 { - return "", nil, fmt.Errorf("PARAMETER requires name and value") - } - - name := strings.TrimSpace(parts[0]) - rawValue := strings.TrimSpace(parts[1]) - - value := parseValue(rawValue) - return name, value, nil -} - -// parseValue attempts to interpret a string as a number, bool, or quoted string. -func parseValue(raw string) interface{} { - // Quoted string. - if len(raw) >= 2 && raw[0] == '"' && raw[len(raw)-1] == '"' { - return raw[1 : len(raw)-1] - } - - // Integer. - if i, err := strconv.ParseInt(raw, 10, 64); err == nil { - return int(i) - } - - // Float. - if f, err := strconv.ParseFloat(raw, 64); err == nil { - return f - } - - // Bool. - if b, err := strconv.ParseBool(raw); err == nil { - return b - } - - return raw -} - -// parseTextValue handles single-line quoted or triple-quoted multi-line text. -func parseTextValue(arg string, lines []string, currentLine int) (string, int, error) { - // Triple-quoted multi-line. - if strings.HasPrefix(arg, `"""`) { - content := strings.TrimPrefix(arg, `"""`) - // Check if closing triple-quote is on same line. - if strings.HasSuffix(content, `"""`) { - return strings.TrimSuffix(content, `"""`), 1, nil - } - - var parts []string - if content != "" { - parts = append(parts, content) - } - - for j := currentLine + 1; j < len(lines); j++ { - l := lines[j] - if strings.Contains(l, `"""`) { - // Found closing triple quote. - before := strings.TrimSuffix(strings.TrimSpace(l), `"""`) - if before != "" { - parts = append(parts, before) - } - return strings.Join(parts, "\n"), j - currentLine + 1, nil - } - parts = append(parts, l) - } - return "", 0, fmt.Errorf("unterminated triple-quoted string") - } - - // Single-line quoted. - if len(arg) >= 2 && arg[0] == '"' && arg[len(arg)-1] == '"' { - return arg[1 : len(arg)-1], 1, nil - } - - // Unquoted single-line. - return arg, 1, nil -} - -// parseMessage parses "role content" from a MESSAGE directive argument. -func parseMessage(arg string) (string, string, error) { - if arg == "" { - return "", "", fmt.Errorf("MESSAGE requires role and content") - } - - parts := strings.SplitN(arg, " ", 2) - if len(parts) < 2 { - return "", "", fmt.Errorf("MESSAGE requires role and content") - } - - role := strings.TrimSpace(parts[0]) - rawContent := strings.TrimSpace(parts[1]) - - // Strip surrounding quotes if present. - if len(rawContent) >= 2 && rawContent[0] == '"' && rawContent[len(rawContent)-1] == '"' { - rawContent = rawContent[1 : len(rawContent)-1] - } - - return role, rawContent, nil -} - -// formatParamValue formats a parameter value for rendering. -func formatParamValue(v interface{}) string { - switch val := v.(type) { - case string: - return fmt.Sprintf("%q", val) - case float64: - if val == float64(int64(val)) { - return strconv.FormatInt(int64(val), 10) - } - return strconv.FormatFloat(val, 'f', -1, 64) - case int: - return strconv.Itoa(val) - case int64: - return strconv.FormatInt(val, 10) - case bool: - return strconv.FormatBool(val) - default: - return fmt.Sprintf("%v", val) - } -} - -// quoteValue wraps a string in double quotes. -func quoteValue(s string) string { - return fmt.Sprintf("%q", s) -} - -// quoteMultiline uses triple quotes for multi-line strings, regular quotes otherwise. -func quoteMultiline(s string) string { - if strings.Contains(s, "\n") { - return `"""` + s + `"""` - } - return fmt.Sprintf("%q", s) -} - -// toFloat64 converts a numeric interface value to float64. -func toFloat64(v interface{}) (float64, error) { - switch val := v.(type) { - case float64: - return val, nil - case int: - return float64(val), nil - case int64: - return float64(val), nil - case string: - return strconv.ParseFloat(val, 64) - default: - return 0, fmt.Errorf("cannot convert %T to float64", v) - } -} diff --git a/internal/config/modelfile_test.go b/internal/config/modelfile_test.go deleted file mode 100644 index d0eec587..00000000 --- a/internal/config/modelfile_test.go +++ /dev/null @@ -1,706 +0,0 @@ -package config - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -func TestParseBasicModelfile(t *testing.T) { - content := `# This is a comment -FROM claude-sonnet-4-6 -PARAMETER temperature 0.7 -PARAMETER top_p 0.9 -PARAMETER max_tokens 4096 -SYSTEM "You are a helpful assistant." -MESSAGE user "Hello" -MESSAGE assistant "Hi there!" -LICENSE "MIT" -ADAPTER /path/to/lora -` - parser := NewModelfileParser() - cfg, err := parser.Parse(content) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if cfg.From != "claude-sonnet-4-6" { - t.Errorf("From = %q, want %q", cfg.From, "claude-sonnet-4-6") - } - - if temp, ok := cfg.Parameters["temperature"]; !ok || temp != 0.7 { - t.Errorf("temperature = %v, want 0.7", temp) - } - if topP, ok := cfg.Parameters["top_p"]; !ok || topP != 0.9 { - t.Errorf("top_p = %v, want 0.9", topP) - } - if maxTok, ok := cfg.Parameters["max_tokens"]; !ok || maxTok != 4096 { - t.Errorf("max_tokens = %v, want 4096", maxTok) - } - - if cfg.System != "You are a helpful assistant." { - t.Errorf("System = %q, want %q", cfg.System, "You are a helpful assistant.") - } - - if len(cfg.Messages) != 2 { - t.Fatalf("len(Messages) = %d, want 2", len(cfg.Messages)) - } - if cfg.Messages[0].Role != "user" || cfg.Messages[0].Content != "Hello" { - t.Errorf("Messages[0] = %+v, want user/Hello", cfg.Messages[0]) - } - if cfg.Messages[1].Role != "assistant" || cfg.Messages[1].Content != "Hi there!" { - t.Errorf("Messages[1] = %+v, want assistant/Hi there!", cfg.Messages[1]) - } - - if cfg.License != "MIT" { - t.Errorf("License = %q, want %q", cfg.License, "MIT") - } - - if len(cfg.Adapters) != 1 || cfg.Adapters[0] != "/path/to/lora" { - t.Errorf("Adapters = %v, want [/path/to/lora]", cfg.Adapters) - } -} - -func TestParseMultilineSystem(t *testing.T) { - content := `FROM base-model -SYSTEM """You are a coding assistant. -You write clean code. -You follow best practices.""" -` - parser := NewModelfileParser() - cfg, err := parser.Parse(content) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - expected := "You are a coding assistant.\nYou write clean code.\nYou follow best practices." - if cfg.System != expected { - t.Errorf("System = %q, want %q", cfg.System, expected) - } -} - -func TestParseTripleQuoteSameLine(t *testing.T) { - content := `FROM model -SYSTEM """single line triple quoted""" -` - parser := NewModelfileParser() - cfg, err := parser.Parse(content) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if cfg.System != "single line triple quoted" { - t.Errorf("System = %q, want %q", cfg.System, "single line triple quoted") - } -} - -func TestParseStopParameter(t *testing.T) { - content := "FROM model\nPARAMETER stop \"```\"\n" - parser := NewModelfileParser() - cfg, err := parser.Parse(content) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if stop, ok := cfg.Parameters["stop"]; !ok || stop != "```" { - t.Errorf("stop = %v, want \"```\"", stop) - } -} - -func TestParseTemplate(t *testing.T) { - content := `FROM model -TEMPLATE "{{.System}}\n{{.Prompt}}" -` - parser := NewModelfileParser() - cfg, err := parser.Parse(content) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if cfg.Template != `{{.System}}\n{{.Prompt}}` { - t.Errorf("Template = %q, want %q", cfg.Template, `{{.System}}\n{{.Prompt}}`) - } -} - -func TestParseErrorMissingFromValue(t *testing.T) { - content := "FROM\n" - parser := NewModelfileParser() - _, err := parser.Parse(content) - if err == nil { - t.Fatal("expected error for empty FROM") - } - if !strings.Contains(err.Error(), "FROM requires a model name") { - t.Errorf("unexpected error: %v", err) - } -} - -func TestParseErrorUnknownDirective(t *testing.T) { - content := "FROM model\nFOOBAR something\n" - parser := NewModelfileParser() - _, err := parser.Parse(content) - if err == nil { - t.Fatal("expected error for unknown directive") - } - if !strings.Contains(err.Error(), "unknown directive") { - t.Errorf("unexpected error: %v", err) - } -} - -func TestParseErrorUnterminatedTripleQuote(t *testing.T) { - content := "FROM model\nSYSTEM \"\"\"unterminated\n" - parser := NewModelfileParser() - _, err := parser.Parse(content) - if err == nil { - t.Fatal("expected error for unterminated triple quote") - } - if !strings.Contains(err.Error(), "unterminated") { - t.Errorf("unexpected error: %v", err) - } -} - -func TestParseErrorParameterMissingValue(t *testing.T) { - content := "FROM model\nPARAMETER temperature\n" - parser := NewModelfileParser() - _, err := parser.Parse(content) - if err == nil { - t.Fatal("expected error for PARAMETER missing value") - } -} - -func TestParseFile(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "Modelfile") - err := os.WriteFile(path, []byte("FROM test-model\nPARAMETER temperature 0.5\n"), 0o644) - if err != nil { - t.Fatalf("failed to write file: %v", err) - } - - parser := NewModelfileParser() - cfg, err := parser.ParseFile(path) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if cfg.From != "test-model" { - t.Errorf("From = %q, want %q", cfg.From, "test-model") - } -} - -func TestParseFileNotFound(t *testing.T) { - parser := NewModelfileParser() - _, err := parser.ParseFile("/nonexistent/path/Modelfile") - if err == nil { - t.Fatal("expected error for missing file") - } -} - -func TestValidateFromRequired(t *testing.T) { - parser := NewModelfileParser() - cfg := &ModelConfig{Parameters: make(map[string]interface{})} - issues := parser.Validate(cfg) - - found := false - for _, issue := range issues { - if issue == "FROM is required" { - found = true - break - } - } - if !found { - t.Errorf("expected 'FROM is required' in issues, got %v", issues) - } -} - -func TestValidateTemperatureRange(t *testing.T) { - parser := NewModelfileParser() - - tests := []struct { - name string - temp interface{} - valid bool - }{ - {"zero", 0.0, true}, - {"mid", 1.0, true}, - {"max", 2.0, true}, - {"negative", -0.1, false}, - {"too_high", 2.1, false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cfg := &ModelConfig{ - From: "model", - Parameters: map[string]interface{}{"temperature": tt.temp}, - } - issues := parser.Validate(cfg) - hasIssue := false - for _, issue := range issues { - if strings.Contains(issue, "temperature") { - hasIssue = true - break - } - } - if tt.valid && hasIssue { - t.Errorf("temperature %v should be valid, got issues: %v", tt.temp, issues) - } - if !tt.valid && !hasIssue { - t.Errorf("temperature %v should be invalid, got no issue", tt.temp) - } - }) - } -} - -func TestValidateUnknownParameter(t *testing.T) { - parser := NewModelfileParser() - cfg := &ModelConfig{ - From: "model", - Parameters: map[string]interface{}{"bogus_param": 1.0}, - } - issues := parser.Validate(cfg) - - found := false - for _, issue := range issues { - if strings.Contains(issue, "unknown parameter") && strings.Contains(issue, "bogus_param") { - found = true - break - } - } - if !found { - t.Errorf("expected unknown parameter issue, got %v", issues) - } -} - -func TestValidateInvalidRole(t *testing.T) { - parser := NewModelfileParser() - cfg := &ModelConfig{ - From: "model", - Parameters: make(map[string]interface{}), - Messages: []ModelMessage{{Role: "invalid_role", Content: "test"}}, - } - issues := parser.Validate(cfg) - - found := false - for _, issue := range issues { - if strings.Contains(issue, "invalid message role") { - found = true - break - } - } - if !found { - t.Errorf("expected invalid role issue, got %v", issues) - } -} - -func TestValidateValidConfig(t *testing.T) { - parser := NewModelfileParser() - cfg := &ModelConfig{ - From: "model", - Parameters: map[string]interface{}{ - "temperature": 0.5, - "top_p": 0.9, - }, - Messages: []ModelMessage{{Role: "user", Content: "hello"}}, - } - issues := parser.Validate(cfg) - if len(issues) != 0 { - t.Errorf("expected no issues, got %v", issues) - } -} - -func TestRender(t *testing.T) { - parser := NewModelfileParser() - cfg := &ModelConfig{ - From: "claude-sonnet-4-6", - Parameters: map[string]interface{}{ - "temperature": 0.7, - }, - System: "You are helpful.", - Messages: []ModelMessage{{Role: "user", Content: "Hi"}}, - License: "MIT", - Adapters: []string{"/path/to/adapter"}, - } - - rendered := parser.Render(cfg) - - if !strings.Contains(rendered, "FROM claude-sonnet-4-6") { - t.Error("rendered missing FROM") - } - if !strings.Contains(rendered, "PARAMETER temperature") { - t.Error("rendered missing PARAMETER temperature") - } - if !strings.Contains(rendered, "SYSTEM") { - t.Error("rendered missing SYSTEM") - } - if !strings.Contains(rendered, "MESSAGE user") { - t.Error("rendered missing MESSAGE") - } - if !strings.Contains(rendered, "LICENSE") { - t.Error("rendered missing LICENSE") - } - if !strings.Contains(rendered, "ADAPTER /path/to/adapter") { - t.Error("rendered missing ADAPTER") - } -} - -func TestRenderRoundTrip(t *testing.T) { - original := `FROM claude-sonnet-4-6 -PARAMETER temperature 0.7 -SYSTEM "You are helpful." -MESSAGE user "Hello" -MESSAGE assistant "Hi there" -LICENSE "MIT" -ADAPTER /path/to/lora -` - parser := NewModelfileParser() - cfg, err := parser.Parse(original) - if err != nil { - t.Fatalf("parse error: %v", err) - } - - rendered := parser.Render(cfg) - cfg2, err := parser.Parse(rendered) - if err != nil { - t.Fatalf("re-parse error: %v", err) - } - - if cfg.From != cfg2.From { - t.Errorf("From mismatch: %q vs %q", cfg.From, cfg2.From) - } - if cfg.System != cfg2.System { - t.Errorf("System mismatch: %q vs %q", cfg.System, cfg2.System) - } - if len(cfg.Messages) != len(cfg2.Messages) { - t.Errorf("Messages len mismatch: %d vs %d", len(cfg.Messages), len(cfg2.Messages)) - } -} - -func TestToProviderConfig(t *testing.T) { - parser := NewModelfileParser() - cfg := &ModelConfig{ - From: "claude-sonnet-4-6", - Parameters: map[string]interface{}{ - "temperature": 0.7, - "max_tokens": 4096, - }, - System: "You are helpful.", - Template: "{{.System}}\n{{.Prompt}}", - Messages: []ModelMessage{ - {Role: "user", Content: "Hi"}, - {Role: "assistant", Content: "Hello"}, - }, - Adapters: []string{"/adapter1"}, - } - - pc := parser.ToProviderConfig(cfg) - - if pc["model"] != "claude-sonnet-4-6" { - t.Errorf("model = %v, want claude-sonnet-4-6", pc["model"]) - } - if pc["system_prompt"] != "You are helpful." { - t.Errorf("system_prompt = %v", pc["system_prompt"]) - } - if pc["template"] != "{{.System}}\n{{.Prompt}}" { - t.Errorf("template = %v", pc["template"]) - } - - params, ok := pc["parameters"].(map[string]interface{}) - if !ok { - t.Fatal("parameters not a map") - } - if params["temperature"] != 0.7 { - t.Errorf("params temperature = %v", params["temperature"]) - } - - msgs, ok := pc["messages"].([]map[string]string) - if !ok { - t.Fatal("messages not a slice of maps") - } - if len(msgs) != 2 { - t.Fatalf("messages len = %d, want 2", len(msgs)) - } - - adapters, ok := pc["adapters"].([]string) - if !ok { - t.Fatal("adapters not a string slice") - } - if len(adapters) != 1 || adapters[0] != "/adapter1" { - t.Errorf("adapters = %v", adapters) - } -} - -func TestMergeConfigs(t *testing.T) { - parser := NewModelfileParser() - base := &ModelConfig{ - From: "base-model", - Parameters: map[string]interface{}{ - "temperature": 0.5, - "max_tokens": 2048, - }, - System: "Base system prompt.", - Messages: []ModelMessage{{Role: "user", Content: "base msg"}}, - Adapters: []string{"/base/adapter"}, - } - - override := &ModelConfig{ - From: "override-model", - Parameters: map[string]interface{}{ - "temperature": 0.9, - }, - System: "Override system.", - Messages: []ModelMessage{{Role: "user", Content: "override msg"}}, - } - - merged := parser.MergeConfigs(base, override) - - if merged.From != "override-model" { - t.Errorf("From = %q, want %q", merged.From, "override-model") - } - if merged.System != "Override system." { - t.Errorf("System = %q, want %q", merged.System, "Override system.") - } - if merged.Parameters["temperature"] != 0.9 { - t.Errorf("temperature = %v, want 0.9", merged.Parameters["temperature"]) - } - if merged.Parameters["max_tokens"] != 2048 { - t.Errorf("max_tokens = %v, want 2048 (from base)", merged.Parameters["max_tokens"]) - } - if len(merged.Messages) != 1 || merged.Messages[0].Content != "override msg" { - t.Errorf("Messages = %v, want override messages", merged.Messages) - } -} - -func TestMergeConfigsBaseRetained(t *testing.T) { - parser := NewModelfileParser() - base := &ModelConfig{ - From: "base-model", - Parameters: map[string]interface{}{ - "temperature": 0.5, - "top_p": 0.8, - }, - System: "Base system.", - } - - override := &ModelConfig{ - Parameters: map[string]interface{}{ - "temperature": 0.3, - }, - } - - merged := parser.MergeConfigs(base, override) - - if merged.From != "base-model" { - t.Errorf("From should come from base when override is empty") - } - if merged.System != "Base system." { - t.Errorf("System should come from base when override is empty") - } - if merged.Parameters["top_p"] != 0.8 { - t.Errorf("top_p should be retained from base") - } - if merged.Parameters["temperature"] != 0.3 { - t.Errorf("temperature should be overridden") - } -} - -func TestDefaultModelConfigs(t *testing.T) { - defaults := DefaultModelConfigs() - - names := []string{"coding", "creative", "precise"} - for _, name := range names { - cfg, ok := defaults[name] - if !ok { - t.Errorf("missing default config %q", name) - continue - } - if cfg.From == "" { - t.Errorf("%s: From is empty", name) - } - if cfg.System == "" { - t.Errorf("%s: System is empty", name) - } - } - - coding := defaults["coding"] - if coding.Parameters["temperature"] != 0.2 { - t.Errorf("coding temperature = %v, want 0.2", coding.Parameters["temperature"]) - } - - creative := defaults["creative"] - if creative.Parameters["temperature"] != 0.9 { - t.Errorf("creative temperature = %v, want 0.9", creative.Parameters["temperature"]) - } - - precise := defaults["precise"] - if precise.Parameters["temperature"] != 0.0 { - t.Errorf("precise temperature = %v, want 0.0", precise.Parameters["temperature"]) - } - if precise.Parameters["top_p"] != 0.1 { - t.Errorf("precise top_p = %v, want 0.1", precise.Parameters["top_p"]) - } -} - -func TestFormatConfig(t *testing.T) { - cfg := &ModelConfig{ - From: "claude-sonnet-4-6", - Parameters: map[string]interface{}{ - "temperature": 0.2, - "max_tokens": 4096, - }, - System: "You are a coding assistant that writes clean code.", - Messages: []ModelMessage{ - {Role: "user", Content: "example"}, - {Role: "assistant", Content: "response"}, - }, - } - - output := FormatConfig(cfg) - - if !strings.Contains(output, "Model Configuration:") { - t.Error("missing header") - } - if !strings.Contains(output, "Base: claude-sonnet-4-6") { - t.Error("missing base model") - } - if !strings.Contains(output, "Parameters:") { - t.Error("missing parameters") - } - if !strings.Contains(output, "System:") { - t.Error("missing system") - } - if !strings.Contains(output, "2 examples") { - t.Error("missing message count") - } -} - -func TestFormatConfigTruncatesLongSystem(t *testing.T) { - cfg := &ModelConfig{ - From: "model", - Parameters: make(map[string]interface{}), - System: strings.Repeat("a", 100), - } - - output := FormatConfig(cfg) - if !strings.Contains(output, "...") { - t.Error("long system prompt should be truncated with ...") - } -} - -func TestParseCaseInsensitiveDirectives(t *testing.T) { - content := `from model-name -parameter temperature 0.5 -system "hello" -` - parser := NewModelfileParser() - cfg, err := parser.Parse(content) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if cfg.From != "model-name" { - t.Errorf("From = %q, want %q", cfg.From, "model-name") - } - if cfg.Parameters["temperature"] != 0.5 { - t.Errorf("temperature = %v, want 0.5", cfg.Parameters["temperature"]) - } -} - -func TestParseCommentsAndBlankLines(t *testing.T) { - content := ` -# Header comment -FROM model - -# Params section -PARAMETER temperature 0.5 - -# End -` - parser := NewModelfileParser() - cfg, err := parser.Parse(content) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if cfg.From != "model" { - t.Errorf("From = %q, want %q", cfg.From, "model") - } -} - -func TestParseMultilineTemplate(t *testing.T) { - content := `FROM model -TEMPLATE """{{.System}} ---- -{{.Prompt}}""" -` - parser := NewModelfileParser() - cfg, err := parser.Parse(content) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - expected := "{{.System}}\n---\n{{.Prompt}}" - if cfg.System == expected { - // System should not be set, template should - } - if cfg.Template != expected { - t.Errorf("Template = %q, want %q", cfg.Template, expected) - } -} - -func TestRenderMultilineSystem(t *testing.T) { - parser := NewModelfileParser() - cfg := &ModelConfig{ - From: "model", - Parameters: make(map[string]interface{}), - System: "line one\nline two\nline three", - } - - rendered := parser.Render(cfg) - if !strings.Contains(rendered, `"""`) { - t.Error("multiline system should use triple quotes") - } - - // Verify round-trip. - cfg2, err := parser.Parse(rendered) - if err != nil { - t.Fatalf("re-parse error: %v", err) - } - if cfg2.System != cfg.System { - t.Errorf("System mismatch after round-trip: %q vs %q", cfg2.System, cfg.System) - } -} - -func TestToProviderConfigMinimal(t *testing.T) { - parser := NewModelfileParser() - cfg := &ModelConfig{ - From: "model", - Parameters: make(map[string]interface{}), - } - - pc := parser.ToProviderConfig(cfg) - if pc["model"] != "model" { - t.Errorf("model = %v", pc["model"]) - } - if _, ok := pc["system_prompt"]; ok { - t.Error("should not have system_prompt when empty") - } - if _, ok := pc["messages"]; ok { - t.Error("should not have messages when empty") - } - if _, ok := pc["parameters"]; ok { - t.Error("should not have parameters when empty") - } -} - -func TestParseMultipleAdapters(t *testing.T) { - content := `FROM model -ADAPTER /path/one -ADAPTER /path/two -ADAPTER /path/three -` - parser := NewModelfileParser() - cfg, err := parser.Parse(content) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(cfg.Adapters) != 3 { - t.Errorf("len(Adapters) = %d, want 3", len(cfg.Adapters)) - } -} diff --git a/internal/config/output_styles.go b/internal/config/output_styles.go deleted file mode 100644 index 59ad2301..00000000 --- a/internal/config/output_styles.go +++ /dev/null @@ -1,130 +0,0 @@ -package config - -import ( - "os" - "path/filepath" - "strings" - "time" - - "github.com/GrayCodeAI/hawk/internal/home" -) - -// OutputStyle defines a custom output format loaded from a markdown file. -type OutputStyle struct { - Name string - Description string - Template string - KeepInstructions bool -} - -// LoadOutputStyles loads .md files from .hawk/output-styles/ (project-local) -// and ~/.hawk/output-styles/ (global), merging both. Project-local styles -// take priority over global styles with the same name. -func LoadOutputStyles() []OutputStyle { - var styles []OutputStyle - seen := make(map[string]bool) - - // Project-local styles first (higher priority) - cwd, _ := os.Getwd() - localDir := filepath.Join(cwd, ".hawk", "output-styles") - for _, s := range loadStylesFromDir(localDir) { - seen[s.Name] = true - styles = append(styles, s) - } - - // Global styles - home := home.Dir() - globalDir := filepath.Join(home, ".hawk", "output-styles") - for _, s := range loadStylesFromDir(globalDir) { - if !seen[s.Name] { - styles = append(styles, s) - } - } - - return styles -} - -func loadStylesFromDir(dir string) []OutputStyle { - entries, err := os.ReadDir(dir) - if err != nil { - return nil - } - - var styles []OutputStyle - for _, entry := range entries { - if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") { - continue - } - path := filepath.Join(dir, entry.Name()) - data, err := os.ReadFile(path) - if err != nil { - continue - } - - content := string(data) - name := strings.TrimSuffix(entry.Name(), ".md") - - style := OutputStyle{ - Name: name, - Template: content, - } - - // Parse front-matter style description from the first line if it - // starts with "# " (markdown heading). - lines := strings.SplitN(content, "\n", 2) - if len(lines) > 0 && strings.HasPrefix(lines[0], "# ") { - style.Description = strings.TrimPrefix(lines[0], "# ") - if len(lines) > 1 { - style.Template = lines[1] - } - } - - // Check for keep-instructions marker - if strings.Contains(content, "") { - style.KeepInstructions = true - } - - styles = append(styles, style) - } - - return styles -} - -// ApplyOutputStyle wraps content with the style template. The template may -// contain {{content}} as a placeholder for the actual content. -func ApplyOutputStyle(content string, style OutputStyle) string { - tmpl := style.Template - if strings.Contains(tmpl, "{{content}}") { - return strings.ReplaceAll(tmpl, "{{content}}", content) - } - // If no placeholder, prepend the template as instructions. - return tmpl + "\n\n" + content -} - -// OutputStyleModTime returns the latest modification time of any style file, -// useful for cache invalidation. -func OutputStyleModTime() time.Time { - var latest time.Time - - dirs := []string{} - cwd, _ := os.Getwd() - dirs = append(dirs, filepath.Join(cwd, ".hawk", "output-styles")) - home := home.Dir() - dirs = append(dirs, filepath.Join(home, ".hawk", "output-styles")) - - for _, dir := range dirs { - entries, err := os.ReadDir(dir) - if err != nil { - continue - } - for _, entry := range entries { - if info, err := entry.Info(); err == nil { - if info.ModTime().After(latest) { - latest = info.ModTime() - } - } - } - } - - return latest -} diff --git a/internal/config/output_styles_test.go b/internal/config/output_styles_test.go deleted file mode 100644 index 28958e60..00000000 --- a/internal/config/output_styles_test.go +++ /dev/null @@ -1,75 +0,0 @@ -package config - -import ( - "os" - "path/filepath" - "testing" -) - -func TestLoadOutputStyles_Empty(t *testing.T) { - orig := os.Getenv("HOME") - tmp := t.TempDir() - os.Setenv("HOME", tmp) - defer os.Setenv("HOME", orig) - - // No style directories exist - styles := LoadOutputStyles() - if len(styles) != 0 { - t.Fatalf("expected 0 styles, got %d", len(styles)) - } -} - -func TestLoadOutputStyles_WithFiles(t *testing.T) { - tmp := t.TempDir() - stylesDir := filepath.Join(tmp, ".hawk", "output-styles") - os.MkdirAll(stylesDir, 0o755) - - // Create a concise style - os.WriteFile(filepath.Join(stylesDir, "concise.md"), []byte("# Be concise\nRespond in bullet points.\n\n{{content}}"), 0o644) - - // Create a verbose style with keep-instructions marker - os.WriteFile(filepath.Join(stylesDir, "verbose.md"), []byte("# Detailed explanation\n\n{{content}}"), 0o644) - - orig := os.Getenv("HOME") - os.Setenv("HOME", tmp) - defer os.Setenv("HOME", orig) - - styles := LoadOutputStyles() - if len(styles) != 2 { - t.Fatalf("expected 2 styles, got %d", len(styles)) - } - - found := false - for _, s := range styles { - if s.Name == "verbose" && s.KeepInstructions { - found = true - } - } - if !found { - t.Fatal("expected verbose style with KeepInstructions=true") - } -} - -func TestApplyOutputStyle_WithPlaceholder(t *testing.T) { - style := OutputStyle{ - Name: "test", - Template: "PREFIX\n{{content}}\nSUFFIX", - } - result := ApplyOutputStyle("hello world", style) - expected := "PREFIX\nhello world\nSUFFIX" - if result != expected { - t.Fatalf("expected %q, got %q", expected, result) - } -} - -func TestApplyOutputStyle_WithoutPlaceholder(t *testing.T) { - style := OutputStyle{ - Name: "test", - Template: "Be concise.", - } - result := ApplyOutputStyle("hello world", style) - expected := "Be concise.\n\nhello world" - if result != expected { - t.Fatalf("expected %q, got %q", expected, result) - } -} diff --git a/internal/config/partial.go b/internal/config/partial.go deleted file mode 100644 index 90d6123b..00000000 --- a/internal/config/partial.go +++ /dev/null @@ -1,154 +0,0 @@ -package config - -import ( - "bytes" - "encoding/json" - "fmt" - "os" -) - -// ConfigLoadError represents a non-fatal error from loading a single -// config section. The rest of the config is still usable. -type ConfigLoadError struct { - Section string - Err error -} - -func (e ConfigLoadError) Error() string { - return fmt.Sprintf("section %q: %v", e.Section, e.Err) -} - -// PartialSettings holds a successfully-loaded Settings plus any -// section-level errors encountered during parsing. -type PartialSettings struct { - Settings Settings - Errors []ConfigLoadError -} - -// LoadSettingsPartial attempts to load settings from the given path, -// tolerating failures in individual sections. If the top-level JSON -// parse fails, it returns a zero Settings with a single error. -// If individual sections are malformed, those sections are skipped -// and the rest of the settings are loaded successfully. -func LoadSettingsPartial(path string) PartialSettings { - data, err := os.ReadFile(path) - if err != nil { - if os.IsNotExist(err) { - return PartialSettings{} - } - return PartialSettings{ - Errors: []ConfigLoadError{{Section: "file", Err: err}}, - } - } - - if len(bytes.TrimSpace(data)) == 0 { - return PartialSettings{} - } - - // First, try full unmarshal (fast path). - var s Settings - if err := json.Unmarshal(data, &s); err == nil { - return PartialSettings{Settings: s} - } - - // Full unmarshal failed — try section-by-section (slow path). - var raw map[string]json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { - return PartialSettings{ - Errors: []ConfigLoadError{{Section: "json", Err: fmt.Errorf("invalid JSON: %w", err)}}, - } - } - - var ps PartialSettings - - // Scalar fields — extract directly. - ps.Settings.Model = extractString(raw, "model") - ps.Settings.Provider = extractString(raw, "provider") - ps.Settings.Theme = extractString(raw, "theme") - ps.Settings.Sandbox = extractString(raw, "sandbox") - ps.Settings.MaxBudgetUSD = extractFloat64(raw, "max_budget_usd") - ps.Settings.RepoMapMaxTokens = extractInt(raw, "repo_map_max_tokens") - ps.Settings.AutoCompactThresholdPct = extractInt(raw, "auto_compact_threshold_pct") - ps.Settings.Frugal = extractBool(raw, "frugal") - ps.Settings.Autonomy = extractInt(raw, "autonomy") - - // Complex fields — unmarshal each section independently. - loadSection(raw, "auto_allow", &ps.Settings.AutoAllow, &ps.Errors) - loadSection(raw, "allowedTools", &ps.Settings.AllowedTools, &ps.Errors) - loadSection(raw, "allowed_tools", &ps.Settings.AllowedTools, &ps.Errors) - loadSection(raw, "disallowedTools", &ps.Settings.DisallowedTools, &ps.Errors) - loadSection(raw, "disallowed_tools", &ps.Settings.DisallowedTools, &ps.Errors) - loadSection(raw, "custom_headers", &ps.Settings.CustomHeaders, &ps.Errors) - loadSection(raw, "mcp_servers", &ps.Settings.MCPServers, &ps.Errors) - loadSection(raw, "custom_providers", &ps.Settings.CustomProviders, &ps.Errors) - loadSection(raw, "model_roles", &ps.Settings.ModelRoles, &ps.Errors) - loadSection(raw, "attribution", &ps.Settings.Attribution, &ps.Errors) - loadSection(raw, "auto_commit", &ps.Settings.AutoCommit, &ps.Errors) - loadSection(raw, "deployment_routing", &ps.Settings.DeploymentRouting, &ps.Errors) - loadSection(raw, "repo_map", &ps.Settings.RepoMap, &ps.Errors) - - return ps -} - -// loadSection attempts to unmarshal a single section from the raw JSON map. -// On failure, it appends to the errors slice. -func loadSection(raw map[string]json.RawMessage, key string, dest interface{}, errors *[]ConfigLoadError) { - data, ok := raw[key] - if !ok { - return // section not present — not an error - } - if err := json.Unmarshal(data, dest); err != nil { - *errors = append(*errors, ConfigLoadError{ - Section: key, - Err: err, - }) - } -} - -func extractString(raw map[string]json.RawMessage, key string) string { - data, ok := raw[key] - if !ok { - return "" - } - var s string - if err := json.Unmarshal(data, &s); err != nil { - return "" - } - return s -} - -func extractFloat64(raw map[string]json.RawMessage, key string) float64 { - data, ok := raw[key] - if !ok { - return 0 - } - var f float64 - if err := json.Unmarshal(data, &f); err != nil { - return 0 - } - return f -} - -func extractInt(raw map[string]json.RawMessage, key string) int { - data, ok := raw[key] - if !ok { - return 0 - } - var i int - if err := json.Unmarshal(data, &i); err != nil { - return 0 - } - return i -} - -func extractBool(raw map[string]json.RawMessage, key string) bool { - data, ok := raw[key] - if !ok { - return false - } - var b bool - if err := json.Unmarshal(data, &b); err != nil { - return false - } - return b -} diff --git a/internal/config/partial_test.go b/internal/config/partial_test.go deleted file mode 100644 index c3c9bdbe..00000000 --- a/internal/config/partial_test.go +++ /dev/null @@ -1,146 +0,0 @@ -package config - -import ( - "os" - "path/filepath" - "testing" -) - -func TestLoadSettingsPartial_ValidConfig(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "settings.json") - - data := `{ - "model": "claude-sonnet-4-20250514", - "theme": "dark", - "max_budget_usd": 10.5, - "auto_allow": ["read", "write"], - "mcp_servers": [{"name": "test", "command": "echo"}] - }` - if err := os.WriteFile(path, []byte(data), 0o644); err != nil { - t.Fatal(err) - } - - ps := LoadSettingsPartial(path) - if len(ps.Errors) > 0 { - t.Fatalf("expected no errors, got %v", ps.Errors) - } - if ps.Settings.Model != "claude-sonnet-4-20250514" { - t.Fatalf("expected model 'claude-sonnet-4-20250514', got %q", ps.Settings.Model) - } - if ps.Settings.Theme != "dark" { - t.Fatalf("expected theme 'dark', got %q", ps.Settings.Theme) - } - if ps.Settings.MaxBudgetUSD != 10.5 { - t.Fatalf("expected budget 10.5, got %f", ps.Settings.MaxBudgetUSD) - } - if len(ps.Settings.AutoAllow) != 2 { - t.Fatalf("expected 2 auto_allow entries, got %d", len(ps.Settings.AutoAllow)) - } - if len(ps.Settings.MCPServers) != 1 { - t.Fatalf("expected 1 MCP server, got %d", len(ps.Settings.MCPServers)) - } -} - -func TestLoadSettingsPartial_MalformedMCPServers(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "settings.json") - - // mcp_servers is a string instead of array — should fail that section but load the rest - data := `{ - "model": "claude-sonnet-4-20250514", - "theme": "dark", - "mcp_servers": "not-an-array" - }` - if err := os.WriteFile(path, []byte(data), 0o644); err != nil { - t.Fatal(err) - } - - ps := LoadSettingsPartial(path) - - // Should have an error for mcp_servers - hasMCPError := false - for _, e := range ps.Errors { - if e.Section == "mcp_servers" { - hasMCPError = true - } - } - if !hasMCPError { - t.Fatal("expected error for mcp_servers section") - } - - // Model and theme should still be loaded - if ps.Settings.Model != "claude-sonnet-4-20250514" { - t.Fatalf("expected model to load, got %q", ps.Settings.Model) - } - if ps.Settings.Theme != "dark" { - t.Fatalf("expected theme to load, got %q", ps.Settings.Theme) - } -} - -func TestLoadSettingsPartial_MalformedModelRoles(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "settings.json") - - data := `{ - "model": "claude-sonnet-4-20250514", - "model_roles": "not-an-object" - }` - if err := os.WriteFile(path, []byte(data), 0o644); err != nil { - t.Fatal(err) - } - - ps := LoadSettingsPartial(path) - - hasModelError := false - for _, e := range ps.Errors { - if e.Section == "model_roles" { - hasModelError = true - } - } - if !hasModelError { - t.Fatal("expected error for model_roles section") - } - - if ps.Settings.Model != "claude-sonnet-4-20250514" { - t.Fatalf("expected model to load, got %q", ps.Settings.Model) - } -} - -func TestLoadSettingsPartial_CompletelyInvalid(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "settings.json") - - if err := os.WriteFile(path, []byte("not json at all"), 0o644); err != nil { - t.Fatal(err) - } - - ps := LoadSettingsPartial(path) - if len(ps.Errors) == 0 { - t.Fatal("expected errors for completely invalid JSON") - } - if ps.Settings.Model != "" { - t.Fatal("expected zero settings for invalid JSON") - } -} - -func TestLoadSettingsPartial_EmptyFile(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "settings.json") - - if err := os.WriteFile(path, []byte(""), 0o644); err != nil { - t.Fatal(err) - } - - ps := LoadSettingsPartial(path) - if len(ps.Errors) > 0 { - t.Fatalf("expected no errors for empty file, got %v", ps.Errors) - } -} - -func TestLoadSettingsPartial_NonexistentFile(t *testing.T) { - ps := LoadSettingsPartial("/nonexistent/path/settings.json") - if len(ps.Errors) > 0 { - t.Fatalf("expected no errors for missing file, got %v", ps.Errors) - } -} diff --git a/internal/config/routing_editor.go b/internal/config/routing_editor.go deleted file mode 100644 index f1f2ca8d..00000000 --- a/internal/config/routing_editor.go +++ /dev/null @@ -1,143 +0,0 @@ -package config - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "strings" - - eyriecfg "github.com/GrayCodeAI/eyrie/config" - "github.com/GrayCodeAI/eyrie/router" -) - -// LoadRoutingPolicyJSON returns the routing section of provider.json as indented JSON. -func LoadRoutingPolicyJSON() (string, error) { - cfg := eyriecfg.LoadProviderConfig("") - cfg = eyriecfg.EnsureDeploymentConfigV2(cfg) - if cfg == nil { - return defaultRoutingPolicyJSON(), nil - } - if cfg.Routing == nil { - return defaultRoutingPolicyJSON(), nil - } - data, err := json.MarshalIndent(cfg.Routing, "", " ") - if err != nil { - return "", err - } - return string(data), nil -} - -func defaultRoutingPolicyJSON() string { - cfg := &eyriecfg.ProviderConfig{} - cfg = eyriecfg.EnsureDeploymentConfigV2(cfg) - if cfg != nil && cfg.Routing != nil { - data, _ := json.MarshalIndent(cfg.Routing, "", " ") - return string(data) - } - tmpl := &eyriecfg.RoutingPolicy{ - Providers: map[string][]eyriecfg.RoutingStage{ - "anthropic": {{ - Deployments: []eyriecfg.DeploymentChoice{ - {DeploymentID: "anthropic-direct", Weight: 100}, - }, - Retries: 1, - }}, - }, - } - data, _ := json.MarshalIndent(tmpl, "", " ") - return string(data) -} - -// SaveRoutingPolicyJSON validates and persists routing into provider.json. -func SaveRoutingPolicyJSON(raw string) error { - raw = strings.TrimSpace(raw) - if raw == "" { - return fmt.Errorf("routing JSON is empty") - } - var policy eyriecfg.RoutingPolicy - dec := json.NewDecoder(bytes.NewReader([]byte(raw))) - dec.DisallowUnknownFields() - if err := dec.Decode(&policy); err != nil { - return fmt.Errorf("invalid routing JSON: %w", err) - } - if err := validateRoutingPolicy(&policy); err != nil { - return err - } - - path := eyriecfg.GetProviderConfigPath() - cfg, err := eyriecfg.LoadProviderConfigWithError(path) - if err != nil { - return err - } - if cfg == nil { - cfg = &eyriecfg.ProviderConfig{} - } - cfg = eyriecfg.EnsureDeploymentConfigV2(cfg) - cfg.Routing = &policy - cfg.ConfigVersion = 2 - return eyriecfg.SaveProviderConfig(cfg, path) -} - -func validateRoutingPolicy(policy *eyriecfg.RoutingPolicy) error { - if policy == nil { - return fmt.Errorf("routing policy is nil") - } - compiled, err := loadEyrieCatalogV1(context.Background(), false) - if err != nil { - return fmt.Errorf("load catalog: %w", err) - } - checkStages := func(stages []router.RoutingStage, scope string) error { - for i, stage := range stages { - if len(stage.Deployments) == 0 { - return fmt.Errorf("%s stage %d has no deployments", scope, i) - } - for _, choice := range stage.Deployments { - if choice.DeploymentID == "" { - return fmt.Errorf("%s stage %d has empty deployment_id", scope, i) - } - if choice.Weight <= 0 { - return fmt.Errorf("%s stage %d: deployment %q weight must be > 0", scope, i, choice.DeploymentID) - } - if compiled.DeploymentsByID[choice.DeploymentID].ID == "" { - return fmt.Errorf("%s stage %d: unknown deployment %q", scope, i, choice.DeploymentID) - } - } - } - return nil - } - for modelID, stages := range policy.Models { - if len(stages) == 0 { - continue - } - if err := checkStages(convertStages(stages), "models["+modelID+"]"); err != nil { - return err - } - } - for providerID, stages := range policy.Providers { - if len(stages) == 0 { - continue - } - if err := checkStages(convertStages(stages), "providers["+providerID+"]"); err != nil { - return err - } - } - if len(policy.Default) > 0 { - if err := checkStages(convertStages(policy.Default), "default"); err != nil { - return err - } - } - return nil -} - -func convertStages(stages []eyriecfg.RoutingStage) []router.RoutingStage { - out := make([]router.RoutingStage, len(stages)) - for i, stage := range stages { - out[i].Retries = stage.Retries - out[i].Deployments = make([]router.DeploymentChoice, len(stage.Deployments)) - for j, d := range stage.Deployments { - out[i].Deployments[j] = router.DeploymentChoice{DeploymentID: d.DeploymentID, Weight: d.Weight} - } - } - return out -} diff --git a/internal/config/routing_editor_test.go b/internal/config/routing_editor_test.go deleted file mode 100644 index 3e5e98a4..00000000 --- a/internal/config/routing_editor_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package config - -import ( - "os" - "path/filepath" - "testing" - - eyriecfg "github.com/GrayCodeAI/eyrie/config" -) - -func TestSaveRoutingPolicyJSONValidatesDeployments(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "provider.json") - t.Setenv("HAWK_CONFIG_DIR", dir) - - cfg := &eyriecfg.ProviderConfig{ - ConfigVersion: 2, - Deployments: map[string]eyriecfg.DeploymentConfig{ - "anthropic-direct": {APIKey: "sk-test-1234567890"}, - }, - } - if err := eyriecfg.SaveProviderConfig(cfg, path); err != nil { - t.Fatalf("save config: %v", err) - } - - err := SaveRoutingPolicyJSON(`{ - "providers": { - "anthropic": [{ - "deployments": [{"deployment_id": "anthropic-direct", "weight": 100}], - "retries": 1 - }] - } -}`) - if err != nil { - t.Fatalf("SaveRoutingPolicyJSON: %v", err) - } -} - -func TestSaveRoutingPolicyJSONRejectsUnknownDeployment(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "provider.json") - t.Setenv("HAWK_CONFIG_DIR", dir) - _ = os.WriteFile(path, []byte(`{"config_version":2}`), 0o600) - - err := SaveRoutingPolicyJSON(`{ - "default": [{ - "deployments": [{"deployment_id": "does-not-exist", "weight": 100}] - }] -}`) - if err == nil { - t.Fatal("expected validation error") - } -} diff --git a/internal/config/rules.go b/internal/config/rules.go deleted file mode 100644 index fb263b60..00000000 --- a/internal/config/rules.go +++ /dev/null @@ -1,183 +0,0 @@ -package config - -import ( - "os" - "path/filepath" - "strings" -) - -// Rule represents a project rule loaded from .hawk/rules/*.md. -type Rule struct { - Name string - Content string - Paths []string // glob patterns; empty = always active -} - -// LoadRules reads all .md files from .hawk/rules/ in the current directory. -// Each file can have optional YAML frontmatter with a paths field. -func LoadRules() []Rule { - dir, _ := os.Getwd() - return LoadRulesFrom(dir) -} - -// LoadRulesFrom reads rules from .hawk/rules/ under the given directory. -func LoadRulesFrom(base string) []Rule { - rulesDir := filepath.Join(base, ".hawk", "rules") - entries, err := os.ReadDir(rulesDir) - if err != nil { - return nil - } - - var rules []Rule - for _, e := range entries { - if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") { - continue - } - data, err := os.ReadFile(filepath.Join(rulesDir, e.Name())) - if err != nil { - continue - } - name := strings.TrimSuffix(e.Name(), ".md") - content := string(data) - var paths []string - - // Parse YAML frontmatter if present - if strings.HasPrefix(content, "---\n") { - parts := strings.SplitN(content[4:], "\n---\n", 2) - if len(parts) == 2 { - paths = parseFrontmatterPaths(parts[0]) - content = strings.TrimSpace(parts[1]) - } - } - - rules = append(rules, Rule{ - Name: name, - Content: content, - Paths: paths, - }) - } - return rules -} - -// parseFrontmatterPaths extracts paths from simple YAML frontmatter. -// Supports: paths: ["glob1", "glob2"] or paths:\n- glob1\n- glob2 -func parseFrontmatterPaths(frontmatter string) []string { - var paths []string - for _, line := range strings.Split(frontmatter, "\n") { - line = strings.TrimSpace(line) - - // Inline array: paths: ["src/**", "lib/**"] - if strings.HasPrefix(line, "paths:") { - rest := strings.TrimPrefix(line, "paths:") - rest = strings.TrimSpace(rest) - if strings.HasPrefix(rest, "[") && strings.HasSuffix(rest, "]") { - inner := rest[1 : len(rest)-1] - for _, item := range strings.Split(inner, ",") { - item = strings.TrimSpace(item) - item = strings.Trim(item, `"'`) - if item != "" { - paths = append(paths, item) - } - } - return paths - } - continue - } - - // List items: - glob - if strings.HasPrefix(line, "- ") { - item := strings.TrimSpace(strings.TrimPrefix(line, "- ")) - item = strings.Trim(item, `"'`) - if item != "" { - paths = append(paths, item) - } - } - } - return paths -} - -// ActiveRules filters rules to those whose Paths match any of the touchedPaths. -// Rules with empty Paths are always active. -func ActiveRules(rules []Rule, touchedPaths []string) []Rule { - var active []Rule - for _, rule := range rules { - if len(rule.Paths) == 0 { - active = append(active, rule) - continue - } - if ruleMatchesAny(rule.Paths, touchedPaths) { - active = append(active, rule) - } - } - return active -} - -// ruleMatchesAny checks if any of the rule's glob patterns match any touched path. -func ruleMatchesAny(patterns, touchedPaths []string) bool { - for _, pattern := range patterns { - for _, tp := range touchedPaths { - if globMatch(pattern, tp) { - return true - } - } - } - return false -} - -// globMatch performs glob matching with support for ** (match any path segments). -func globMatch(pattern, path string) bool { - pattern = filepath.ToSlash(pattern) - path = filepath.ToSlash(path) - - // Handle ** patterns by trying all possible prefix matches - if strings.Contains(pattern, "**") { - parts := strings.SplitN(pattern, "**", 2) - prefix := parts[0] - suffix := parts[1] - suffix = strings.TrimPrefix(suffix, "/") - - // Check if path starts with prefix - if prefix != "" && !strings.HasPrefix(path, prefix) { - return false - } - - // If no suffix, prefix match is enough - if suffix == "" { - return true - } - - // Try matching suffix against remaining path segments - remaining := strings.TrimPrefix(path, prefix) - segments := strings.Split(remaining, "/") - for i := range segments { - candidate := strings.Join(segments[i:], "/") - matched, _ := filepath.Match(suffix, candidate) - if matched { - return true - } - // Also try matching just the base name - matched, _ = filepath.Match(suffix, filepath.Base(candidate)) - if matched { - return true - } - } - return false - } - - matched, _ := filepath.Match(pattern, path) - return matched -} - -// FormatActiveRules formats active rules for injection into the system prompt. -func FormatActiveRules(rules []Rule) string { - if len(rules) == 0 { - return "" - } - var b strings.Builder - b.WriteString("## Project Rules\n\n") - for _, rule := range rules { - b.WriteString("### " + rule.Name + "\n") - b.WriteString(rule.Content + "\n\n") - } - return b.String() -} diff --git a/internal/config/rules_test.go b/internal/config/rules_test.go deleted file mode 100644 index 8736af93..00000000 --- a/internal/config/rules_test.go +++ /dev/null @@ -1,133 +0,0 @@ -package config - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -func TestLoadRulesFrom(t *testing.T) { - dir := t.TempDir() - rulesDir := filepath.Join(dir, ".hawk", "rules") - if err := os.MkdirAll(rulesDir, 0o755); err != nil { - t.Fatal(err) - } - - // Rule with frontmatter - rule1 := "---\npaths: [\"src/api/**\", \"internal/api/**\"]\n---\nAlways validate input parameters.\n" - if err := os.WriteFile(filepath.Join(rulesDir, "api-validation.md"), []byte(rule1), 0o644); err != nil { - t.Fatal(err) - } - - // Rule without frontmatter (always active) - rule2 := "Use descriptive variable names.\n" - if err := os.WriteFile(filepath.Join(rulesDir, "naming.md"), []byte(rule2), 0o644); err != nil { - t.Fatal(err) - } - - rules := LoadRulesFrom(dir) - if len(rules) != 2 { - t.Fatalf("expected 2 rules, got %d", len(rules)) - } - - // Find the api-validation rule - var apiRule, namingRule *Rule - for i := range rules { - if rules[i].Name == "api-validation" { - apiRule = &rules[i] - } - if rules[i].Name == "naming" { - namingRule = &rules[i] - } - } - - if apiRule == nil { - t.Fatal("api-validation rule not found") - } - if len(apiRule.Paths) != 2 { - t.Fatalf("expected 2 paths for api-validation, got %d", len(apiRule.Paths)) - } - if !strings.Contains(apiRule.Content, "validate input") { - t.Fatalf("unexpected content: %q", apiRule.Content) - } - - if namingRule == nil { - t.Fatal("naming rule not found") - } - if len(namingRule.Paths) != 0 { - t.Fatalf("expected 0 paths for naming rule, got %d", len(namingRule.Paths)) - } -} - -func TestActiveRulesAlwaysActive(t *testing.T) { - rules := []Rule{ - {Name: "global", Content: "global rule", Paths: nil}, - {Name: "api-only", Content: "api rule", Paths: []string{"src/api/**"}}, - } - - active := ActiveRules(rules, []string{"src/web/handler.go"}) - if len(active) != 1 { - t.Fatalf("expected 1 active rule (global only), got %d", len(active)) - } - if active[0].Name != "global" { - t.Fatalf("expected 'global' rule, got %q", active[0].Name) - } -} - -func TestActiveRulesPathMatch(t *testing.T) { - rules := []Rule{ - {Name: "global", Content: "global rule", Paths: nil}, - {Name: "api-only", Content: "api rule", Paths: []string{"src/api/**"}}, - } - - active := ActiveRules(rules, []string{"src/api/handler.go"}) - if len(active) != 2 { - t.Fatalf("expected 2 active rules, got %d", len(active)) - } -} - -func TestActiveRulesNoMatch(t *testing.T) { - rules := []Rule{ - {Name: "api-only", Content: "api rule", Paths: []string{"src/api/**"}}, - } - - active := ActiveRules(rules, []string{"cmd/main.go"}) - if len(active) != 0 { - t.Fatalf("expected 0 active rules, got %d", len(active)) - } -} - -func TestFormatActiveRules(t *testing.T) { - rules := []Rule{ - {Name: "naming", Content: "Use descriptive names."}, - {Name: "testing", Content: "Write tests for all functions."}, - } - - result := FormatActiveRules(rules) - if !strings.HasPrefix(result, "## Project Rules") { - t.Fatalf("expected header, got %q", result) - } - if !strings.Contains(result, "### naming") { - t.Fatal("expected rule name in output") - } - if !strings.Contains(result, "descriptive names") { - t.Fatal("expected rule content in output") - } - - // Empty rules - if got := FormatActiveRules(nil); got != "" { - t.Fatalf("expected empty for nil rules, got %q", got) - } -} - -func TestParseFrontmatterPathsListFormat(t *testing.T) { - fm := "paths:\n- src/api/**\n- internal/api/**" - paths := parseFrontmatterPaths(fm) - if len(paths) != 2 { - t.Fatalf("expected 2 paths, got %d: %v", len(paths), paths) - } - if paths[0] != "src/api/**" { - t.Fatalf("unexpected path[0]: %q", paths[0]) - } -} diff --git a/internal/config/shell_completions.go b/internal/config/shell_completions.go deleted file mode 100644 index 9c0aa7cd..00000000 --- a/internal/config/shell_completions.go +++ /dev/null @@ -1,138 +0,0 @@ -package config - -import "strings" - -// ZshCompletion returns a zsh completion script for hawk. -func ZshCompletion() string { - return `#compdef hawk - -_hawk() { - local -a commands - commands=( - 'chat:Start interactive chat (default)' - 'config:View or modify configuration' - 'doctor:Run diagnostics' - 'mcp:Manage MCP servers' - 'sessions:List saved sessions' - 'tools:List available tools' - 'version:Show version' - ) - - local -a flags - flags=( - '-p[Print mode - non-interactive]:prompt' - '--print[Print mode - non-interactive]:prompt' - '--model[Model to use]:model' - '--provider[Provider to use]:provider' - '--system-prompt[System prompt]:prompt' - '--max-turns[Max conversation turns]:number' - '--max-budget-usd[Max cost budget]:amount' - '--continue[Resume last session]' - '--session-id[Resume specific session]:session_id' - '--output-format[Output format (text/json/stream-json)]:format:(text json stream-json)' - '--permission-mode[Advanced permission mode]:mode:(default edits bypass dontask plan)' - '--tools[Comma-separated tool list]:tools' - '--add-dir[Additional allowed directory]:directory:_directories' - '--verbose[Enable verbose logging]' - '--no-color[Disable colors]' - '--help[Show help]' - '--version[Show version]' - ) - - _arguments -s $flags - - if (( CURRENT == 2 )); then - _describe 'command' commands - fi -} - -_hawk "$@" -` -} - -// BashCompletion returns a bash completion script for hawk. -func BashCompletion() string { - return `_hawk_completions() { - local cur prev opts - cur="${COMP_WORDS[COMP_CWORD]}" - prev="${COMP_WORDS[COMP_CWORD-1]}" - - opts="chat config doctor mcp sessions tools version -p --print --model --provider --system-prompt --max-turns --continue --session-id --output-format --permission-mode --tools --add-dir --verbose --no-color --help --version" - - case "$prev" in - --model) - COMPREPLY=($(compgen -W "claude-sonnet-4-20250514 claude-opus-4-20250514 gpt-4o gemini-2.5-flash" -- "$cur")) - return 0 - ;; - --provider) - COMPREPLY=($(compgen -W "anthropic openai gemini openrouter groq deepseek mistral ollama" -- "$cur")) - return 0 - ;; - --output-format) - COMPREPLY=($(compgen -W "text json stream-json" -- "$cur")) - return 0 - ;; - --permission-mode) - COMPREPLY=($(compgen -W "default edits bypass dontask plan" -- "$cur")) - return 0 - ;; - --add-dir) - COMPREPLY=($(compgen -d -- "$cur")) - return 0 - ;; - esac - - COMPREPLY=($(compgen -W "$opts" -- "$cur")) -} - -complete -F _hawk_completions hawk -` -} - -// FishCompletion returns a fish completion script for hawk. -func FishCompletion() string { - return `complete -c hawk -f - -# Commands -complete -c hawk -n '__fish_use_subcommand' -a chat -d 'Start interactive chat' -complete -c hawk -n '__fish_use_subcommand' -a config -d 'View or modify configuration' -complete -c hawk -n '__fish_use_subcommand' -a doctor -d 'Run diagnostics' -complete -c hawk -n '__fish_use_subcommand' -a mcp -d 'Manage MCP servers' -complete -c hawk -n '__fish_use_subcommand' -a sessions -d 'List saved sessions' -complete -c hawk -n '__fish_use_subcommand' -a tools -d 'List available tools' -complete -c hawk -n '__fish_use_subcommand' -a version -d 'Show version' - -# Flags -complete -c hawk -s p -l print -d 'Print mode (non-interactive)' -x -complete -c hawk -l model -d 'Model to use' -x -a 'claude-sonnet-4-20250514 claude-opus-4-20250514 gpt-4o gemini-2.5-flash' -complete -c hawk -l provider -d 'Provider' -x -a 'anthropic openai gemini openrouter groq deepseek mistral ollama' -complete -c hawk -l continue -d 'Resume last session' -complete -c hawk -l output-format -d 'Output format' -x -a 'text json stream-json' -complete -c hawk -l permission-mode -d 'Advanced permission mode' -x -a 'default edits bypass dontask plan' -complete -c hawk -l verbose -d 'Enable verbose logging' -complete -c hawk -l no-color -d 'Disable colors' -` -} - -// InstallCompletions returns instructions for installing shell completions. -func InstallCompletions(shell string) string { - switch strings.ToLower(shell) { - case "zsh": - return `# Add to ~/.zshrc: -eval "$(hawk completions zsh)" - -# Or save to a file: -hawk completions zsh > ~/.zsh/completions/_hawk` - case "bash": - return `# Add to ~/.bashrc: -eval "$(hawk completions bash)" - -# Or save to a file: -hawk completions bash > /etc/bash_completion.d/hawk` - case "fish": - return `# Save to fish completions dir: -hawk completions fish > ~/.config/fish/completions/hawk.fish` - default: - return "Supported shells: zsh, bash, fish" - } -} diff --git a/internal/config/templates.go b/internal/config/templates.go deleted file mode 100644 index e8270a3d..00000000 --- a/internal/config/templates.go +++ /dev/null @@ -1,71 +0,0 @@ -package config - -import ( - "encoding/json" - "os" - "path/filepath" - "strings" -) - -// PromptTemplate is a reusable prompt template. -type PromptTemplate struct { - Name string `json:"name"` - Template string `json:"template"` - Args []string `json:"args,omitempty"` -} - -// LoadTemplates loads prompt templates from ~/.hawk/templates/. -func LoadTemplates() []PromptTemplate { - home, err := os.UserHomeDir() - if err != nil { - return nil - } - - dir := filepath.Join(home, ".hawk", "templates") - entries, err := os.ReadDir(dir) - if err != nil { - return nil - } - - var templates []PromptTemplate - for _, e := range entries { - if e.IsDir() { - continue - } - ext := filepath.Ext(e.Name()) - path := filepath.Join(dir, e.Name()) - - switch ext { - case ".json": - data, err := os.ReadFile(path) - if err != nil { - continue - } - var t PromptTemplate - if json.Unmarshal(data, &t) == nil && t.Name != "" { - templates = append(templates, t) - } - case ".txt", ".md": - data, err := os.ReadFile(path) - if err != nil { - continue - } - name := strings.TrimSuffix(e.Name(), ext) - templates = append(templates, PromptTemplate{ - Name: name, - Template: string(data), - }) - } - } - return templates -} - -// Apply fills template args using {{key}} placeholders. -func (t *PromptTemplate) Apply(args map[string]string) string { - result := t.Template - for key, value := range args { - placeholder := "{{" + key + "}}" - result = strings.ReplaceAll(result, placeholder, value) - } - return result -} diff --git a/internal/config/templates_test.go b/internal/config/templates_test.go deleted file mode 100644 index fe65aa82..00000000 --- a/internal/config/templates_test.go +++ /dev/null @@ -1,95 +0,0 @@ -package config - -import ( - "encoding/json" - "os" - "path/filepath" - "testing" -) - -func TestPromptTemplateApply(t *testing.T) { - tpl := PromptTemplate{ - Name: "test", - Template: "Hello {{name}}, please {{action}} the {{target}}.", - Args: []string{"name", "action", "target"}, - } - - result := tpl.Apply(map[string]string{ - "name": "Alice", - "action": "review", - "target": "code", - }) - - expected := "Hello Alice, please review the code." - if result != expected { - t.Fatalf("expected %q, got %q", expected, result) - } -} - -func TestPromptTemplateApply_MissingArgs(t *testing.T) { - tpl := PromptTemplate{ - Name: "test", - Template: "Hello {{name}}, do {{action}}.", - } - - // Missing "action" arg should leave placeholder - result := tpl.Apply(map[string]string{"name": "Bob"}) - if result != "Hello Bob, do {{action}}." { - t.Fatalf("expected unreplaced placeholder, got %q", result) - } -} - -func TestLoadTemplates_JSON(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - - dir := filepath.Join(home, ".hawk", "templates") - os.MkdirAll(dir, 0o755) - - tpl := PromptTemplate{ - Name: "review", - Template: "Review {{file}} for bugs", - Args: []string{"file"}, - } - data, _ := json.Marshal(tpl) - os.WriteFile(filepath.Join(dir, "review.json"), data, 0o644) - - templates := LoadTemplates() - if len(templates) != 1 { - t.Fatalf("expected 1 template, got %d", len(templates)) - } - if templates[0].Name != "review" { - t.Fatalf("expected name 'review', got %q", templates[0].Name) - } - if templates[0].Template != "Review {{file}} for bugs" { - t.Fatalf("unexpected template content: %q", templates[0].Template) - } -} - -func TestLoadTemplates_TextFile(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - - dir := filepath.Join(home, ".hawk", "templates") - os.MkdirAll(dir, 0o755) - - os.WriteFile(filepath.Join(dir, "explain.txt"), []byte("Explain {{concept}} simply"), 0o644) - - templates := LoadTemplates() - if len(templates) != 1 { - t.Fatalf("expected 1 template, got %d", len(templates)) - } - if templates[0].Name != "explain" { - t.Fatalf("expected name 'explain', got %q", templates[0].Name) - } -} - -func TestLoadTemplates_EmptyDir(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - - templates := LoadTemplates() - if len(templates) != 0 { - t.Fatalf("expected 0 templates from nonexistent dir, got %d", len(templates)) - } -} diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 46ad0f04..0ccfade3 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -26,6 +26,12 @@ const maxRequestBodyBytes = 1 << 20 // The caller (cmd package) provides this, wiring system prompts, tools, keys. type SessionFactory func(req ChatRequest) (*engine.Session, error) +// version is set via SetVersion from main.go at startup. +var version = "0.0.0" + +// SetVersion propagates the canonical hawk version into the daemon. +func SetVersion(v string) { version = v } + // Server is the hawk daemon HTTP server for programmatic/CI access. type Server struct { addr string @@ -302,22 +308,14 @@ func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc { } func constantTimeEqual(a, b string) bool { - // Compare in constant time regardless of length differences. - // Hash both values to a fixed-length representation first. - // This avoids leaking length via timing. - if len(a) == 0 || len(b) == 0 { - return len(a) == len(b) - } - // Pad to maximum length with null bytes, then compare. - maxLen := len(a) - if len(b) > maxLen { - maxLen = len(b) - } - paddedA := make([]byte, maxLen) - paddedB := make([]byte, maxLen) - copy(paddedA, a) - copy(paddedB, b) - return subtle.ConstantTimeCompare(paddedA, paddedB) == 1 && len(a) == len(b) + // Length mismatch check short-circuits via early return, which + // leaks the length difference via timing. This is an accepted + // trade-off for bearer-token authentication — tokens are fixed + // length and the comparison result is not secret. + if len(a) != len(b) { + return false + } + return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1 } func decodeJSONBody(w http.ResponseWriter, r *http.Request, dst any) bool { @@ -344,7 +342,7 @@ func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) { resp := HealthResponse{ Status: "ok", - Version: "0.1.0", + Version: version, Uptime: time.Since(s.startedAt).Round(time.Second).String(), Sessions: sessionCount, StartedAt: s.startedAt.Format(time.RFC3339), @@ -430,6 +428,8 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "DENY") flusher, _ := w.(http.Flusher) for ev := range events { diff --git a/internal/engine/memory_service.go b/internal/engine/memory_service.go index df337e09..d3711aa4 100644 --- a/internal/engine/memory_service.go +++ b/internal/engine/memory_service.go @@ -5,7 +5,6 @@ import ( "github.com/GrayCodeAI/hawk/internal/intelligence/memory" "github.com/GrayCodeAI/hawk/internal/observability/logger" - "github.com/GrayCodeAI/hawk/internal/types" ) // MemoryService is the Session's view of the memory layer: yaad bridge, @@ -142,8 +141,3 @@ func (s *MemoryService) Activity() *memory.ActivityTracker { return s.activity } func (s *MemoryService) IsZero() bool { return s == nil || (s.memory == nil && s.yaad == nil && s.enhanced == nil && s.skillDistiller == nil && s.sleeptime == nil && s.activity == nil) } - -// _ unused-import workaround: keep types referenced even when none -// of the methods actually destructure EyrieMessage directly. The -// agent loop reads s.messages via the persistence service. -var _ = (*types.EyrieMessage)(nil) diff --git a/internal/engine/search/url_scraper.go b/internal/engine/search/url_scraper.go index eb669105..b1252a33 100644 --- a/internal/engine/search/url_scraper.go +++ b/internal/engine/search/url_scraper.go @@ -115,7 +115,8 @@ func (s *URLScraper) Fetch(ctx context.Context, rawURL string) (*ScrapeResult, e req.Header.Set("User-Agent", s.UserAgent) req.Header.Set("Accept", "text/html, application/json, text/plain, */*") - resp, err := http.DefaultClient.Do(req) + client := &http.Client{Timeout: s.Timeout} + resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("fetching URL: %w", err) } diff --git a/internal/engine/selective_rag.go b/internal/engine/selective_rag.go index a2edbe57..3efc3e99 100644 --- a/internal/engine/selective_rag.go +++ b/internal/engine/selective_rag.go @@ -236,6 +236,3 @@ func classifyQuery(query string) string { } return "simple" } - -// Compile-time check -var _ = estimateTokens diff --git a/internal/engine/session.go b/internal/engine/session.go index cbb461a9..c57417a4 100644 --- a/internal/engine/session.go +++ b/internal/engine/session.go @@ -244,6 +244,9 @@ func NewSession(provider, model, systemPrompt string, registry *tool.Registry) * // NewSessionWithClient constructs a session with an explicit LLM client (e.g. deployment router). func NewSessionWithClient(chat ChatClient, provider, model, systemPrompt string, registry *tool.Registry, deploymentRouting bool) *Session { + if provider == "" || model == "" { + slog.Warn("NewSessionWithClient called with empty provider or model; runtime errors may follow", "provider", provider, "model", model) + } pe := NewPermissionEngine() log := logger.Default() s := &Session{ @@ -528,15 +531,8 @@ func (s *Session) AddUser(content string) { // AddUserWithImage adds a user message with an attached image (base64-encoded). // The imageType should be "image/png", "image/jpeg", etc. func (s *Session) AddUserWithImage(content string, imageBase64 string, imageType string) { - s.mu.Lock() - msg := types.EyrieMessage{ - Role: "user", - Content: content, - Images: []string{"data:" + imageType + ";base64," + imageBase64}, - } - s.messages = append(s.messages, msg) - s.mu.Unlock() if p := s.Persistence(); p != nil { + p.AddUser(content + " [image attached]") if dag := p.DAG(); dag != nil { parentID := "" if head, err := dag.Head(context.Background()); err == nil && head != nil { @@ -545,6 +541,14 @@ func (s *Session) AddUserWithImage(content string, imageBase64 string, imageType _, _ = dag.Append(context.Background(), parentID, "user", content+" [image attached]") } } + s.mu.Lock() + msg := types.EyrieMessage{ + Role: "user", + Content: content, + Images: []string{"data:" + imageType + ";base64," + imageBase64}, + } + s.messages = append(s.messages, msg) + s.mu.Unlock() } func (s *Session) AddAssistant(content string) { diff --git a/internal/engine/stream.go b/internal/engine/stream.go index 9cba1dff..b62cbe3f 100644 --- a/internal/engine/stream.go +++ b/internal/engine/stream.go @@ -326,11 +326,10 @@ func (s *Session) agentLoop(ctx context.Context, ch chan<- StreamEvent) { s.log.Info("token count", map[string]interface{}{"input_tokens": inputTokens, "model": s.model}) // Cost warning for expensive calls - if inPrice, outPrice := ModelPricing(s.model); true { - estCost := float64(inputTokens)*inPrice/1_000_000 + float64(maxTok)*outPrice/1_000_000 - if estCost > 0.50 { - ch <- StreamEvent{Type: "content", Content: fmt.Sprintf("\n"+icons.Alert()+" This request will use ~%d tokens (~$%.2f). Continue? The agent will proceed automatically.\n", inputTokens+maxTok, estCost)} - } + inPrice, outPrice := ModelPricing(s.model) + estCost := float64(inputTokens)*inPrice/1_000_000 + float64(maxTok)*outPrice/1_000_000 + if estCost > 0.50 { + ch <- StreamEvent{Type: "content", Content: fmt.Sprintf("\n"+icons.Alert()+" This request will use ~%d tokens (~$%.2f). Continue? The agent will proceed automatically.\n", inputTokens+maxTok, estCost)} } // Trace: start agent loop span for this turn diff --git a/internal/session/sqlite_store.go b/internal/session/sqlite_store.go index d5788e9b..9d675ecf 100644 --- a/internal/session/sqlite_store.go +++ b/internal/session/sqlite_store.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "fmt" + "log/slog" "strings" "sync" "time" @@ -603,7 +604,9 @@ func (s *SQLiteStore) Close() error { defer s.mu.Unlock() // Checkpoint WAL to flush all data into the main db and truncate // the WAL file, so no .db-wal / .db-shm files linger on disk. - _, _ = s.db.ExecContext(context.Background(), "PRAGMA wal_checkpoint(TRUNCATE)") + if _, err := s.db.ExecContext(context.Background(), "PRAGMA wal_checkpoint(TRUNCATE)"); err != nil { + slog.Warn("wal checkpoint before close failed", "error", err) + } return s.db.Close() } @@ -652,7 +655,9 @@ func (s *SQLiteStore) Compact(sessionID string, keepLast int) error { // After a large delete, checkpoint the WAL so the freed pages are // reclaimed and .db-wal doesn't grow unbounded. - _, _ = s.db.ExecContext(context.Background(), "PRAGMA wal_checkpoint(TRUNCATE)") + if _, err := s.db.ExecContext(context.Background(), "PRAGMA wal_checkpoint(TRUNCATE)"); err != nil { + return fmt.Errorf("wal checkpoint after compact: %w", err) + } return nil } diff --git a/internal/tool/web_search.go b/internal/tool/web_search.go index 9e2f5257..09cb8129 100644 --- a/internal/tool/web_search.go +++ b/internal/tool/web_search.go @@ -201,7 +201,8 @@ func duckDuckGoSearch(ctx context.Context, query string, count int) ([]searchRes } req.Header.Set("User-Agent", "hawk/0.1.0") - resp, err := http.DefaultClient.Do(req) + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) if err != nil { return nil, err }