Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions cmd/kilroy/detach_paths.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,6 @@ func resolveDetachedPaths(graphPath, configPath, logsRoot string) (string, strin
if graphPath == "" {
return "", "", "", fmt.Errorf("graph path is required")
}
if configPath == "" {
return "", "", "", fmt.Errorf("config path is required")
}
if logsRoot == "" {
return "", "", "", fmt.Errorf("logs root is required")
}
Expand All @@ -24,9 +21,12 @@ func resolveDetachedPaths(graphPath, configPath, logsRoot string) (string, strin
if err != nil {
return "", "", "", err
}
absConfig, err := filepath.Abs(configPath)
if err != nil {
return "", "", "", err
var absConfig string
if configPath != "" {
absConfig, err = filepath.Abs(configPath)
if err != nil {
return "", "", "", err
}
}
absLogs, err := filepath.Abs(logsRoot)
if err != nil {
Expand Down
36 changes: 31 additions & 5 deletions cmd/kilroy/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ func loadEnvFile(args []string) []string {
func usage() {
fmt.Fprintln(os.Stderr, "usage:")
fmt.Fprintln(os.Stderr, " kilroy --version")
fmt.Fprintln(os.Stderr, " kilroy [--env-file <path>] attractor run [--detach] [--validate|--preflight|--test-run] [--allow-test-shim] [--confirm-stale-build] [--no-cxdb] [--force-model <provider=model>] --graph <file.dot> --config <run.yaml> [--run-id <id>] [--logs-root <dir>]")
fmt.Fprintln(os.Stderr, " kilroy [--env-file <path>] attractor run [--detach] [--validate|--preflight|--test-run] [--allow-test-shim] [--confirm-stale-build] [--no-cxdb] [--force-model <provider=model>] --graph <file.dot> [--config <run.yaml>] [--run-id <id>] [--logs-root <dir>]")
fmt.Fprintln(os.Stderr, " kilroy attractor resume --logs-root <dir>")
fmt.Fprintln(os.Stderr, " kilroy attractor resume --cxdb <http_base_url> --context-id <id>")
fmt.Fprintln(os.Stderr, " kilroy attractor resume --run-branch <attractor/run/...> [--repo <path>]")
Expand Down Expand Up @@ -228,7 +228,7 @@ func attractorRun(args []string) {
}
}

if graphPath == "" || configPath == "" {
if graphPath == "" {
usage()
os.Exit(1)
}
Expand All @@ -247,7 +247,7 @@ func attractorRun(args []string) {
}

if detach {
cfg, err := engine.LoadRunConfigFile(configPath)
cfg, err := loadOrBuildConfig(configPath)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
Expand Down Expand Up @@ -284,7 +284,10 @@ func attractorRun(args []string) {
configPath = absConfigPath
logsRoot = absLogsRoot

childArgs := []string{"attractor", "run", "--graph", graphPath, "--config", configPath}
childArgs := []string{"attractor", "run", "--graph", graphPath}
if configPath != "" {
childArgs = append(childArgs, "--config", configPath)
}
if runID != "" {
childArgs = append(childArgs, "--run-id", runID)
}
Expand Down Expand Up @@ -318,7 +321,7 @@ func attractorRun(args []string) {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
cfg, err := engine.LoadRunConfigFile(configPath)
cfg, err := loadOrBuildConfig(configPath)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
Expand Down Expand Up @@ -461,6 +464,29 @@ func isSupportedForceModelProvider(provider string) bool {
return ok
}

// loadOrBuildConfig loads a config from file, or builds a zero-config default
// when configPath is empty. For zero-config, providers are auto-detected from
// the environment. Graph-level provider validation happens later in bootstrap.
func loadOrBuildConfig(configPath string) (*engine.RunConfigFile, error) {
if configPath != "" {
return engine.LoadRunConfigFile(configPath)
}
cfg, err := engine.DefaultRunConfig()
if err != nil {
return nil, err
}
detected := engine.DetectProviders()
engine.ApplyDetectedProviders(cfg, detected)
if len(detected) > 0 {
for _, dp := range detected {
fmt.Fprintf(os.Stderr, "auto-detected provider %s (backend=%s)\n", dp.Key, dp.Backend)
}
} else {
fmt.Fprintln(os.Stderr, "no providers auto-detected from environment (set API key env vars for your LLM providers)")
}
return cfg, nil
}

func runConfigUsesCLIProviders(cfg *engine.RunConfigFile) bool {
if cfg == nil {
return false
Expand Down
71 changes: 71 additions & 0 deletions internal/attractor/engine/autodetect.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// Auto-detect available LLM providers from the environment.
// Scans for API keys and CLI binaries to populate provider config.

package engine

import (
"os"
"os/exec"
"strings"

"github.com/danshapiro/kilroy/internal/providerspec"
)

// DetectedProvider describes a provider found via environment scanning.
type DetectedProvider struct {
Key string
Backend BackendKind
APIKey string
}

// DetectProviders scans the environment for known API keys and returns
// provider configurations for each detected provider. For providers with
// a CLI spec, the CLI backend is preferred when the binary is on PATH.
func DetectProviders() []DetectedProvider {
return detectProvidersWithLookPath(exec.LookPath)
}

func detectProvidersWithLookPath(lookPath func(string) (string, error)) []DetectedProvider {
var detected []DetectedProvider
for key, spec := range providerspec.Builtins() {
if spec.API == nil || spec.API.DefaultAPIKeyEnv == "" {
continue
}
apiKey := strings.TrimSpace(os.Getenv(spec.API.DefaultAPIKeyEnv))
// Google also accepts GOOGLE_API_KEY as a fallback.
if apiKey == "" && key == "google" {
apiKey = strings.TrimSpace(os.Getenv("GOOGLE_API_KEY"))
}
if apiKey == "" {
continue
}
backend := BackendAPI
if spec.CLI != nil {
if _, err := lookPath(spec.CLI.DefaultExecutable); err == nil {
backend = BackendCLI
}
}
detected = append(detected, DetectedProvider{
Key: key,
Backend: backend,
APIKey: apiKey,
})
}
return detected
}

// ApplyDetectedProviders populates cfg.LLM.Providers from auto-detected
// providers. Only providers not already configured are added.
func ApplyDetectedProviders(cfg *RunConfigFile, detected []DetectedProvider) {
if cfg.LLM.Providers == nil {
cfg.LLM.Providers = map[string]ProviderConfig{}
}
for _, dp := range detected {
if _, exists := cfg.LLM.Providers[dp.Key]; exists {
continue
}
cfg.LLM.Providers[dp.Key] = ProviderConfig{
Backend: dp.Backend,
}
}
}
125 changes: 125 additions & 0 deletions internal/attractor/engine/autodetect_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
// Unit tests for provider auto-detection from environment.

package engine

import (
"fmt"
"sort"
"strings"
"testing"
)

func TestDetectProviders_AnthropicAPIKey(t *testing.T) {
t.Setenv("ANTHROPIC_API_KEY", "sk-test-123")
t.Setenv("OPENAI_API_KEY", "")
t.Setenv("GEMINI_API_KEY", "")
t.Setenv("GOOGLE_API_KEY", "")

// No CLI binary on path — should fall back to API backend.
detected := detectProvidersWithLookPath(func(name string) (string, error) {
return "", fmt.Errorf("not found: %s", name)
})
var found *DetectedProvider
for i := range detected {
if detected[i].Key == "anthropic" {
found = &detected[i]
break
}
}
if found == nil {
t.Fatal("expected anthropic provider to be detected")
}
if found.Backend != BackendAPI {
t.Fatalf("expected api backend, got %q", found.Backend)
}
}

func TestDetectProviders_CLIPreferred(t *testing.T) {
t.Setenv("ANTHROPIC_API_KEY", "sk-test-123")
t.Setenv("OPENAI_API_KEY", "")
t.Setenv("GEMINI_API_KEY", "")
t.Setenv("GOOGLE_API_KEY", "")

// Simulate claude binary on path.
detected := detectProvidersWithLookPath(func(name string) (string, error) {
if name == "claude" {
return "/usr/bin/claude", nil
}
return "", fmt.Errorf("not found: %s", name)
})
var found *DetectedProvider
for i := range detected {
if detected[i].Key == "anthropic" {
found = &detected[i]
break
}
}
if found == nil {
t.Fatal("expected anthropic provider to be detected")
}
if found.Backend != BackendCLI {
t.Fatalf("expected cli backend when binary found, got %q", found.Backend)
}
}

func TestDetectProviders_GoogleFallbackKey(t *testing.T) {
t.Setenv("ANTHROPIC_API_KEY", "")
t.Setenv("OPENAI_API_KEY", "")
t.Setenv("GEMINI_API_KEY", "")
t.Setenv("GOOGLE_API_KEY", "goog-test-123")

detected := detectProvidersWithLookPath(func(name string) (string, error) {
return "", fmt.Errorf("not found: %s", name)
})
var found *DetectedProvider
for i := range detected {
if detected[i].Key == "google" {
found = &detected[i]
break
}
}
if found == nil {
t.Fatal("expected google provider to be detected via GOOGLE_API_KEY")
}
}

func TestDetectProviders_NoKeysNoResults(t *testing.T) {
t.Setenv("ANTHROPIC_API_KEY", "")
t.Setenv("OPENAI_API_KEY", "")
t.Setenv("GEMINI_API_KEY", "")
t.Setenv("GOOGLE_API_KEY", "")
t.Setenv("KIMI_API_KEY", "")
t.Setenv("ZAI_API_KEY", "")
t.Setenv("CEREBRAS_API_KEY", "")
t.Setenv("MINIMAX_API_KEY", "")
t.Setenv("INCEPTION_API_KEY", "")

detected := detectProvidersWithLookPath(func(name string) (string, error) {
return "", fmt.Errorf("not found: %s", name)
})
if len(detected) != 0 {
keys := make([]string, 0, len(detected))
for _, d := range detected {
keys = append(keys, d.Key)
}
sort.Strings(keys)
t.Fatalf("expected no providers, got: %s", strings.Join(keys, ", "))
}
}

func TestApplyDetectedProviders_DoesNotOverwrite(t *testing.T) {
cfg := &RunConfigFile{}
cfg.LLM.Providers = map[string]ProviderConfig{
"anthropic": {Backend: BackendCLI},
}
ApplyDetectedProviders(cfg, []DetectedProvider{
{Key: "anthropic", Backend: BackendAPI},
{Key: "openai", Backend: BackendAPI},
})
if cfg.LLM.Providers["anthropic"].Backend != BackendCLI {
t.Fatal("existing provider config should not be overwritten")
}
if _, ok := cfg.LLM.Providers["openai"]; !ok {
t.Fatal("new provider should be added")
}
}
23 changes: 12 additions & 11 deletions internal/attractor/engine/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -318,17 +318,18 @@ func validateConfig(cfg *RunConfigFile) error {
if cfg.CXDB.Autostart.Enabled && len(cfg.CXDB.Autostart.Command) == 0 {
return fmt.Errorf("cxdb.autostart.command is required when cxdb.autostart.enabled=true")
}
if strings.TrimSpace(cfg.ModelDB.OpenRouterModelInfoPath) == "" {
return fmt.Errorf("modeldb.openrouter_model_info_path is required")
}
switch strings.ToLower(strings.TrimSpace(cfg.ModelDB.OpenRouterModelInfoUpdatePolicy)) {
case "pinned", "on_run_start":
// ok
default:
return fmt.Errorf("invalid modeldb.openrouter_model_info_update_policy: %q (want pinned|on_run_start)", cfg.ModelDB.OpenRouterModelInfoUpdatePolicy)
}
if strings.ToLower(strings.TrimSpace(cfg.ModelDB.OpenRouterModelInfoUpdatePolicy)) == "on_run_start" && strings.TrimSpace(cfg.ModelDB.OpenRouterModelInfoURL) == "" {
return fmt.Errorf("modeldb.openrouter_model_info_url is required when update_policy=on_run_start")
// Model catalog is optional: when no path is configured the engine falls
// back to the embedded catalog at bootstrap time.
if strings.TrimSpace(cfg.ModelDB.OpenRouterModelInfoPath) != "" {
switch strings.ToLower(strings.TrimSpace(cfg.ModelDB.OpenRouterModelInfoUpdatePolicy)) {
case "pinned", "on_run_start":
// ok
default:
return fmt.Errorf("invalid modeldb.openrouter_model_info_update_policy: %q (want pinned|on_run_start)", cfg.ModelDB.OpenRouterModelInfoUpdatePolicy)
}
if strings.ToLower(strings.TrimSpace(cfg.ModelDB.OpenRouterModelInfoUpdatePolicy)) == "on_run_start" && strings.TrimSpace(cfg.ModelDB.OpenRouterModelInfoURL) == "" {
return fmt.Errorf("modeldb.openrouter_model_info_url is required when update_policy=on_run_start")
}
}
switch strings.ToLower(strings.TrimSpace(cfg.LLM.CLIProfile)) {
case "real", "test_shim":
Expand Down
37 changes: 37 additions & 0 deletions internal/attractor/engine/config_defaults.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Construct a RunConfigFile with sensible defaults for zero-config runs.

package engine

import (
"fmt"
"os"

"github.com/danshapiro/kilroy/internal/attractor/gitutil"
)

// DefaultRunConfig builds a RunConfigFile with sensible defaults suitable for
// running without an explicit config file. The repo path defaults to the
// current working directory if it is a git repo, otherwise an error is returned.
// Call applyConfigDefaults and validateConfig on the result before use.
func DefaultRunConfig() (*RunConfigFile, error) {
cwd, err := os.Getwd()
if err != nil {
return nil, fmt.Errorf("cannot determine working directory: %w", err)
}
if !gitutil.IsRepo(cwd) {
return nil, fmt.Errorf("current directory is not a git repo; either run from a git repo or provide --config")
}

cfg := &RunConfigFile{}
cfg.Version = 1
cfg.Repo.Path = cwd
cfg.LLM.CLIProfile = "real"
// ModelDB left empty — bootstrap will use embedded catalog.
// CXDB left empty — already optional.

applyConfigDefaults(cfg)
if err := validateConfig(cfg); err != nil {
return nil, err
}
return cfg, nil
}
Loading
Loading