diff --git a/cmd/kilroy/detach_paths.go b/cmd/kilroy/detach_paths.go index 1911c659..cfcf9f8e 100644 --- a/cmd/kilroy/detach_paths.go +++ b/cmd/kilroy/detach_paths.go @@ -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") } @@ -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 { diff --git a/cmd/kilroy/main.go b/cmd/kilroy/main.go index fb7c1ea8..3f0c3ace 100644 --- a/cmd/kilroy/main.go +++ b/cmd/kilroy/main.go @@ -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 ] attractor run [--detach] [--validate|--preflight|--test-run] [--allow-test-shim] [--confirm-stale-build] [--no-cxdb] [--force-model ] --graph --config [--run-id ] [--logs-root ]") + fmt.Fprintln(os.Stderr, " kilroy [--env-file ] attractor run [--detach] [--validate|--preflight|--test-run] [--allow-test-shim] [--confirm-stale-build] [--no-cxdb] [--force-model ] --graph [--config ] [--run-id ] [--logs-root ]") fmt.Fprintln(os.Stderr, " kilroy attractor resume --logs-root ") fmt.Fprintln(os.Stderr, " kilroy attractor resume --cxdb --context-id ") fmt.Fprintln(os.Stderr, " kilroy attractor resume --run-branch [--repo ]") @@ -228,7 +228,7 @@ func attractorRun(args []string) { } } - if graphPath == "" || configPath == "" { + if graphPath == "" { usage() os.Exit(1) } @@ -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) @@ -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) } @@ -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) @@ -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 diff --git a/internal/attractor/engine/autodetect.go b/internal/attractor/engine/autodetect.go new file mode 100644 index 00000000..448b9763 --- /dev/null +++ b/internal/attractor/engine/autodetect.go @@ -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, + } + } +} diff --git a/internal/attractor/engine/autodetect_test.go b/internal/attractor/engine/autodetect_test.go new file mode 100644 index 00000000..a5189a8a --- /dev/null +++ b/internal/attractor/engine/autodetect_test.go @@ -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") + } +} diff --git a/internal/attractor/engine/config.go b/internal/attractor/engine/config.go index 1a67bb38..919e70e9 100644 --- a/internal/attractor/engine/config.go +++ b/internal/attractor/engine/config.go @@ -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": diff --git a/internal/attractor/engine/config_defaults.go b/internal/attractor/engine/config_defaults.go new file mode 100644 index 00000000..7deafc4e --- /dev/null +++ b/internal/attractor/engine/config_defaults.go @@ -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 +} diff --git a/internal/attractor/engine/phase0_tool_graph_test.go b/internal/attractor/engine/phase0_tool_graph_test.go index 72512849..b845aa38 100644 --- a/internal/attractor/engine/phase0_tool_graph_test.go +++ b/internal/attractor/engine/phase0_tool_graph_test.go @@ -264,6 +264,57 @@ func TestToolGraph_WorkspaceLifecycle(t *testing.T) { } } +func TestToolGraph_ZeroConfig(t *testing.T) { + t.Setenv("XDG_STATE_HOME", t.TempDir()) + repo := initTestRepo(t) + logsRoot := t.TempDir() + + dot := []byte(`digraph zero_config { + graph [goal="Test zero-config run"] + start [shape=Mdiamond] + step_a [shape=parallelogram, tool_command="echo hello_zero_config"] + done [shape=Msquare] + start -> step_a -> done +}`) + + // Build a config the same way DefaultRunConfig does, but pointing at + // the test repo instead of CWD. + cfg := &RunConfigFile{} + cfg.Version = 1 + cfg.Repo.Path = repo + cfg.LLM.CLIProfile = "test_shim" + // No ModelDB configured — bootstrap should fall back to embedded catalog. + applyConfigDefaults(cfg) + if err := validateConfig(cfg); err != nil { + t.Fatalf("validateConfig: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + // No DisableCXDB — CXDB config is empty, so bootstrap skips it automatically. + res, err := RunWithConfig(ctx, dot, cfg, RunOptions{ + RunID: "zero-config-test", + LogsRoot: logsRoot, + }) + if err != nil { + t.Fatalf("RunWithConfig: %v", err) + } + if res.FinalStatus != runtime.FinalSuccess { + t.Fatalf("expected success, got %q", res.FinalStatus) + } + + // Verify the tool step executed. + stdout := filepath.Join(logsRoot, "step_a", "stdout.log") + data, err := os.ReadFile(stdout) + if err != nil { + t.Fatalf("read step_a stdout: %v", err) + } + if !strings.Contains(string(data), "hello_zero_config") { + t.Fatalf("step_a stdout: got %q, want to contain %q", string(data), "hello_zero_config") + } +} + // minimalToolGraphConfig returns a RunConfigFile suitable for tool-node-only graphs. func minimalToolGraphConfig(repoPath, pinnedCatalogPath string) *RunConfigFile { cfg := &RunConfigFile{} diff --git a/internal/attractor/engine/run_with_config.go b/internal/attractor/engine/run_with_config.go index ebac12bb..987c8360 100644 --- a/internal/attractor/engine/run_with_config.go +++ b/internal/attractor/engine/run_with_config.go @@ -290,20 +290,41 @@ func bootstrapRunWithConfig(ctx context.Context, dotSource []byte, cfg *RunConfi } // Resolve + snapshot the model catalog for this run (repeatability). - resolved, err := modeldb.ResolveModelCatalog( - ctx, - cfg.ModelDB.OpenRouterModelInfoPath, - opts.LogsRoot, - modeldb.CatalogUpdatePolicy(strings.ToLower(strings.TrimSpace(cfg.ModelDB.OpenRouterModelInfoUpdatePolicy))), - cfg.ModelDB.OpenRouterModelInfoURL, - time.Duration(cfg.ModelDB.OpenRouterModelInfoFetchTimeoutMS)*time.Millisecond, + // When no catalog path is configured, fall back to the embedded catalog. + var ( + catalog *modeldb.Catalog + modelCatalogSource string + modelCatalogPath string + resolvedWarning string ) - if err != nil { - return nil, err - } - catalog, err := loadCatalogForRun(resolved.SnapshotPath) - if err != nil { - return nil, err + if strings.TrimSpace(cfg.ModelDB.OpenRouterModelInfoPath) != "" { + resolved, resolveErr := modeldb.ResolveModelCatalog( + ctx, + cfg.ModelDB.OpenRouterModelInfoPath, + opts.LogsRoot, + modeldb.CatalogUpdatePolicy(strings.ToLower(strings.TrimSpace(cfg.ModelDB.OpenRouterModelInfoUpdatePolicy))), + cfg.ModelDB.OpenRouterModelInfoURL, + time.Duration(cfg.ModelDB.OpenRouterModelInfoFetchTimeoutMS)*time.Millisecond, + ) + if resolveErr != nil { + return nil, resolveErr + } + cat, loadErr := loadCatalogForRun(resolved.SnapshotPath) + if loadErr != nil { + return nil, loadErr + } + catalog = cat + modelCatalogSource = resolved.Source + modelCatalogPath = resolved.SnapshotPath + resolvedWarning = strings.TrimSpace(resolved.Warning) + } else { + cat, loadErr := modeldb.LoadEmbeddedCatalog() + if loadErr != nil { + return nil, fmt.Errorf("no model catalog configured and embedded catalog unavailable: %w", loadErr) + } + catalog = cat + modelCatalogSource = "embedded" + modelCatalogPath = "" } catalogChecks, catalogErr := validateProviderModelPairs(g, runtimes, catalog, opts) if catalogErr != nil { @@ -343,7 +364,6 @@ func bootstrapRunWithConfig(ctx context.Context, dotSource []byte, cfg *RunConfi } } - resolvedWarning := strings.TrimSpace(resolved.Warning) inputInfererInitWarning = strings.TrimSpace(inputInfererInitWarning) combinedWarnings := []string{} if resolvedWarning != "" { @@ -372,8 +392,8 @@ func bootstrapRunWithConfig(ctx context.Context, dotSource []byte, cfg *RunConfi Registry: reg, ResolvedArtifactPolicy: resolvedArtifactPolicy, Catalog: catalog, - ModelCatalogSource: resolved.Source, - ModelCatalogPath: resolved.SnapshotPath, + ModelCatalogSource: modelCatalogSource, + ModelCatalogPath: modelCatalogPath, Runtimes: runtimes, InputInferer: inputInferer, ResolvedWarning: resolvedWarning,