diff --git a/.gitignore b/.gitignore index 40460e6b..75d29569 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,7 @@ .kit/ aidocs/ *.log -kit +/kit .idea test/ build/ diff --git a/.goreleaser.yaml b/.goreleaser.yaml index c3c9db7b..a7dba872 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -3,7 +3,9 @@ before: - go mod tidy builds: - - env: + - id: kit + main: ./cmd/kit + env: - CGO_ENABLED=0 goos: - linux diff --git a/AGENTS.md b/AGENTS.md index 20975144..d0082b41 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,7 @@ Keep this managed block so 'openspec update' can refresh the instructions. # KIT Agent Guidelines ## Build/Test Commands -- **Build**: `go build -o output/kit` +- **Build**: `go build -o output/kit ./cmd/kit` - **Test all**: `go test -race ./...` - **Test single**: `go test -race ./cmd -run TestScriptExecution` - **Lint**: `go vet ./...` diff --git a/README.md b/README.md index 245eb431..2537872f 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,7 @@ kit --provider-url https://192.168.1.100:443 --tls-skip-verify ## Installation 📦 ```bash -go install github.com/mark3labs/kit@latest +go install github.com/mark3labs/kit/cmd/kit@latest ``` ## SDK Usage 🛠️ @@ -143,14 +143,14 @@ package main import ( "context" "fmt" - "github.com/mark3labs/kit/sdk" + kit "github.com/mark3labs/kit/pkg/kit" ) func main() { ctx := context.Background() // Create Kit instance with default configuration - host, err := sdk.New(ctx, nil) + host, err := kit.New(ctx, nil) if err != nil { panic(err) } @@ -175,7 +175,7 @@ func main() { - ✅ Streaming support - ✅ Full compatibility with all providers and MCP servers -For detailed SDK documentation, examples, and API reference, see the [SDK README](sdk/README.md). +For detailed SDK documentation, examples, and API reference, see the [SDK README](pkg/kit/README.md). ## Configuration ⚙️ diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 00000000..6561a1d9 --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,145 @@ +version: "3" + +vars: + BINARY: kit + OUTPUT_DIR: output + BUILD_FLAGS: -o {{.OUTPUT_DIR}}/{{.BINARY}} + LDFLAGS: -s -w -X main.version=dev + +tasks: + default: + desc: Show available tasks + cmds: + - task --list-all + + # ----------------------------------------------------------------------- + # Build + # ----------------------------------------------------------------------- + + build: + desc: Build the kit binary + cmds: + - go build {{.BUILD_FLAGS}} -ldflags "{{.LDFLAGS}}" ./cmd/kit + sources: + - "**/*.go" + - go.mod + - go.sum + generates: + - "{{.OUTPUT_DIR}}/{{.BINARY}}" + + build-all: + desc: Build for all platforms (linux, darwin, windows) + cmds: + - GOOS=linux GOARCH=amd64 go build -o {{.OUTPUT_DIR}}/{{.BINARY}}-linux-amd64 -ldflags "{{.LDFLAGS}}" ./cmd/kit + - GOOS=linux GOARCH=arm64 go build -o {{.OUTPUT_DIR}}/{{.BINARY}}-linux-arm64 -ldflags "{{.LDFLAGS}}" ./cmd/kit + - GOOS=darwin GOARCH=amd64 go build -o {{.OUTPUT_DIR}}/{{.BINARY}}-darwin-amd64 -ldflags "{{.LDFLAGS}}" ./cmd/kit + - GOOS=darwin GOARCH=arm64 go build -o {{.OUTPUT_DIR}}/{{.BINARY}}-darwin-arm64 -ldflags "{{.LDFLAGS}}" ./cmd/kit + - GOOS=windows GOARCH=amd64 go build -o {{.OUTPUT_DIR}}/{{.BINARY}}-windows-amd64.exe -ldflags "{{.LDFLAGS}}" ./cmd/kit + + install: + desc: Install kit to $GOPATH/bin + cmds: + - go install -ldflags "{{.LDFLAGS}}" ./cmd/kit + + # ----------------------------------------------------------------------- + # Test + # ----------------------------------------------------------------------- + + test: + desc: Run all tests with race detector + cmds: + - go test -race ./... + + test-v: + desc: Run all tests (verbose) + cmds: + - go test -race -v ./... + + test-short: + desc: Run tests in short mode (skip long-running tests) + cmds: + - go test -race -short ./... + + test-pkg: + desc: "Run tests for a specific package (usage: task test-pkg -- ./internal/config)" + cmds: + - go test -race -v {{.CLI_ARGS}} + + test-run: + desc: "Run a single test by name (usage: task test-run -- TestScriptExecution)" + cmds: + - go test -race -v ./... -run {{.CLI_ARGS}} + + test-cover: + desc: Run tests with coverage report + cmds: + - mkdir -p {{.OUTPUT_DIR}} + - go test -race -coverprofile={{.OUTPUT_DIR}}/coverage.out ./... + - go tool cover -html={{.OUTPUT_DIR}}/coverage.out -o {{.OUTPUT_DIR}}/coverage.html + - echo "Coverage report written to {{.OUTPUT_DIR}}/coverage.html" + + # ----------------------------------------------------------------------- + # Code quality + # ----------------------------------------------------------------------- + + lint: + desc: Run golangci-lint and go vet + cmds: + - golangci-lint run ./... + - go vet ./... + + fmt: + desc: Format all Go files + cmds: + - go fmt ./... + + fmt-check: + desc: Check formatting (fails if files need formatting) + cmds: + - test -z "$(gofmt -l .)" || (echo "Files need formatting:" && gofmt -l . && exit 1) + + tidy: + desc: Tidy go.mod and go.sum + cmds: + - go mod tidy + + check: + desc: Run all quality checks (fmt, vet, test) + cmds: + - task: fmt-check + - task: lint + - task: test + + # ----------------------------------------------------------------------- + # Development + # ----------------------------------------------------------------------- + + dev: + desc: Build and run kit with optional args + cmds: + - task: build + - "{{.OUTPUT_DIR}}/{{.BINARY}} {{.CLI_ARGS}}" + + watch: + desc: Watch for changes and rebuild (requires watchexec) + cmds: + - watchexec -e go -r -- task build + + clean: + desc: Remove build artifacts + cmds: + - rm -rf {{.OUTPUT_DIR}} + + # ----------------------------------------------------------------------- + # Release + # ----------------------------------------------------------------------- + + release-snapshot: + desc: Build a release snapshot (no publish) + cmds: + - goreleaser release --snapshot --clean + + release-check: + desc: Validate goreleaser config + cmds: + - goreleaser check diff --git a/main.go b/cmd/kit/main.go similarity index 100% rename from main.go rename to cmd/kit/main.go diff --git a/cmd/root.go b/cmd/root.go index f17a1972..a130b9c4 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -17,6 +17,7 @@ import ( "github.com/mark3labs/kit/internal/extensions" "github.com/mark3labs/kit/internal/session" "github.com/mark3labs/kit/internal/ui" + kit "github.com/mark3labs/kit/pkg/kit" "github.com/spf13/cobra" "github.com/spf13/viper" "golang.org/x/term" @@ -110,106 +111,20 @@ func GetRootCommand(v string) *cobra.Command { } // InitConfig initializes the configuration for KIT by loading config files, -// environment variables, and hooks configuration. It follows this priority order: -// 1. Command-line specified config file (--config flag) -// 2. Current directory config file (.kit) -// 3. Home directory config file (~/.kit) -// 4. Environment variables (KIT_* prefix) +// environment variables, and hooks configuration. It delegates to the SDK's +// InitConfig, injecting the CLI-specific configFile flag and debug mode. // This function is automatically called by cobra before command execution. func InitConfig() { - if configFile != "" { - // Use config file from the flag - if err := LoadConfigWithEnvSubstitution(configFile); err != nil { - fmt.Fprintf(os.Stderr, "Error reading config file '%s': %v\n", configFile, err) - os.Exit(1) - } - } else { - // Ensure a config file exists (create default if none found) - if err := config.EnsureConfigExists(); err != nil { - // If we can't create config, continue silently (non-fatal) - fmt.Fprintf(os.Stderr, "Warning: Could not create default config file: %v\n", err) - } - - // Find home directory - home, err := os.UserHomeDir() - if err != nil { - fmt.Fprintf(os.Stderr, "Error finding home directory: %v\n", err) - os.Exit(1) - } - - // Set up viper config search paths and names - // Current directory has higher priority than home directory - viper.AddConfigPath(".") // Current directory (searched first) - viper.AddConfigPath(home) // Home directory (searched second) - - // Try to find and load config file using viper's search mechanism - configLoaded := false - configNames := []string{".kit"} - - for _, name := range configNames { - viper.SetConfigName(name) - - // Try to read the config file - if err := viper.ReadInConfig(); err == nil { - // Config file found, now reload it with env substitution - configPath := viper.ConfigFileUsed() - if err := LoadConfigWithEnvSubstitution(configPath); err != nil { - // Only exit on environment variable substitution errors - if strings.Contains(err.Error(), "environment variable substitution failed") { - fmt.Fprintf(os.Stderr, "Error reading config file '%s': %v\n", configPath, err) - os.Exit(1) - } - // For other errors, continue trying other config files - continue - } - configLoaded = true - break - } - } - - // If no config file was loaded, continue without error (optional config) - if !configLoaded && debugMode { - fmt.Fprintf(os.Stderr, "No config file found in current directory or home directory\n") - } + if err := kit.InitConfig(configFile, debugMode); err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + os.Exit(1) } - - // Set environment variable prefix - viper.SetEnvPrefix("KIT") - viper.AutomaticEnv() - } -// LoadConfigWithEnvSubstitution loads a config file with environment variable substitution. -// It reads the config file, replaces any ${ENV_VAR} patterns with their corresponding -// environment variable values, and then parses the resulting configuration using viper. -// The function automatically detects JSON or YAML format based on file extension. -// Returns an error if the file cannot be read, environment variable substitution fails, -// or the configuration cannot be parsed. +// LoadConfigWithEnvSubstitution loads a config file with environment variable +// substitution. Delegates to the SDK implementation. func LoadConfigWithEnvSubstitution(configPath string) error { - // Read raw config file content - rawContent, err := os.ReadFile(configPath) - if err != nil { - return fmt.Errorf("failed to read config file: %v", err) - } - - // Apply environment variable substitution - substituter := &config.EnvSubstituter{} - processedContent, err := substituter.SubstituteEnvVars(string(rawContent)) - if err != nil { - return fmt.Errorf("config env substitution failed: %v", err) - } - - // Determine config type from file extension - configType := "yaml" - if strings.HasSuffix(configPath, ".json") { - configType = "json" - } - - config.SetConfigPath(configPath) - - // Use viper to parse the processed content - viper.SetConfigType(configType) - return viper.ReadConfig(strings.NewReader(processedContent)) + return kit.LoadConfigWithEnvSubstitution(configPath) } func configToUiTheme(theme config.Theme) ui.Theme { diff --git a/cmd/setup.go b/cmd/setup.go index c5b43109..cf0ae999 100644 --- a/cmd/setup.go +++ b/cmd/setup.go @@ -2,186 +2,37 @@ package cmd import ( "context" - "fmt" "strings" - "charm.land/fantasy" - "github.com/mark3labs/kit/internal/agent" "github.com/mark3labs/kit/internal/app" "github.com/mark3labs/kit/internal/config" "github.com/mark3labs/kit/internal/extensions" - "github.com/mark3labs/kit/internal/hooks" "github.com/mark3labs/kit/internal/models" - "github.com/mark3labs/kit/internal/tools" "github.com/mark3labs/kit/internal/ui" + kit "github.com/mark3labs/kit/pkg/kit" "github.com/spf13/viper" ) -// BuildProviderConfig creates a *models.ProviderConfig from the current viper -// state. All three entry points (root, script, SDK) converge through this -// function, eliminating the previously triplicated ModelConfig assembly. -// -// The caller is responsible for ensuring viper holds the correct values before -// calling this function (e.g. script mode merges frontmatter into viper in its -// PreRun hook, the SDK sets overrides explicitly). +// AgentSetupOptions is the CLI-facing alias for kit.AgentSetupOptions. +// The CLI adds the Quiet field from the package-level quietFlag. +type AgentSetupOptions = kit.AgentSetupOptions + +// AgentSetupResult is the CLI-facing alias for kit.AgentSetupResult. +type AgentSetupResult = kit.AgentSetupResult + +// BuildProviderConfig delegates to the SDK to build a ProviderConfig from +// the current viper state. func BuildProviderConfig() (*models.ProviderConfig, string, error) { - systemPrompt, err := config.LoadSystemPrompt(viper.GetString("system-prompt")) - if err != nil { - return nil, "", fmt.Errorf("failed to load system prompt: %w", err) - } - - temperature := float32(viper.GetFloat64("temperature")) - topP := float32(viper.GetFloat64("top-p")) - topK := int32(viper.GetInt("top-k")) - numGPU := int32(viper.GetInt("num-gpu-layers")) - mainGPU := int32(viper.GetInt("main-gpu")) - - cfg := &models.ProviderConfig{ - ModelString: viper.GetString("model"), - SystemPrompt: systemPrompt, - ProviderAPIKey: viper.GetString("provider-api-key"), - ProviderURL: viper.GetString("provider-url"), - MaxTokens: viper.GetInt("max-tokens"), - Temperature: &temperature, - TopP: &topP, - TopK: &topK, - StopSequences: viper.GetStringSlice("stop-sequences"), - NumGPU: &numGPU, - MainGPU: &mainGPU, - TLSSkipVerify: viper.GetBool("tls-skip-verify"), - } - - return cfg, systemPrompt, nil + return kit.BuildProviderConfig() } -// AgentSetupOptions controls agent creation behaviour that varies between -// entry points (e.g. spinners are only used by the interactive CLI). -type AgentSetupOptions struct { - // MCPConfig is the MCP server configuration. Required. - MCPConfig *config.Config - // ShowSpinner shows a loading spinner for Ollama models. - ShowSpinner bool - // SpinnerFunc provides the spinner implementation (nil = no spinner). - SpinnerFunc agent.SpinnerFunc - // UseBufferedLogger captures debug messages for later display (root - // non-interactive path). When false a simple logger is used instead. - UseBufferedLogger bool -} - -// AgentSetupResult bundles the created agent and any debug logger so the caller -// can flush buffered messages when appropriate. -type AgentSetupResult struct { - Agent *agent.Agent - BufferedLogger *tools.BufferedDebugLogger - // ExtRunner is the extension runner (nil when --no-extensions or no - // extensions were discovered). - ExtRunner *extensions.Runner -} - -// SetupAgent creates an agent from the current viper state + the provided -// options. It wraps BuildProviderConfig and agent.CreateAgent, eliminating the -// triplicated agent-creation boilerplate from root.go, script.go and the SDK. +// SetupAgent creates an agent from the current viper state. It delegates to +// the SDK's SetupAgent, injecting the CLI-specific quietFlag. func SetupAgent(ctx context.Context, opts AgentSetupOptions) (*AgentSetupResult, error) { - modelConfig, systemPrompt, err := BuildProviderConfig() - if err != nil { - return nil, err - } - - // Create the appropriate debug logger. - var debugLogger tools.DebugLogger - var bufferedLogger *tools.BufferedDebugLogger - if viper.GetBool("debug") { - if opts.UseBufferedLogger { - bufferedLogger = tools.NewBufferedDebugLogger(true) - debugLogger = bufferedLogger - } else { - debugLogger = tools.NewSimpleDebugLogger(true) - } - } - - // Load extensions unless --no-extensions is set. Extensions must be loaded - // BEFORE agent creation so their tool wrapper and custom tools are included - // in the Fantasy agent's tool list. - var extRunner *extensions.Runner - var extCreationOpts extensionCreationOpts - if !viper.GetBool("no-extensions") { - var extErr error - extRunner, extCreationOpts, extErr = setupExtensions() - if extErr != nil { - // Extension loading failures are non-fatal. - fmt.Printf("Warning: Failed to load extensions: %v\n", extErr) - } - } - - a, err := agent.CreateAgent(ctx, &agent.AgentCreationOptions{ - ModelConfig: modelConfig, - MCPConfig: opts.MCPConfig, - SystemPrompt: systemPrompt, - MaxSteps: viper.GetInt("max-steps"), - StreamingEnabled: viper.GetBool("stream"), - ShowSpinner: opts.ShowSpinner, - Quiet: quietFlag, - SpinnerFunc: opts.SpinnerFunc, - DebugLogger: debugLogger, - ToolWrapper: extCreationOpts.toolWrapper, - ExtraTools: extCreationOpts.extraTools, - }) - if err != nil { - return nil, fmt.Errorf("failed to create agent: %w", err) - } - - return &AgentSetupResult{ - Agent: a, - ExtRunner: extRunner, - BufferedLogger: bufferedLogger, - }, nil -} - -// extensionCreationOpts holds the tool wrapper and extra tools that need to be -// passed into agent creation, extracted from loaded extensions. -type extensionCreationOpts struct { - toolWrapper func([]fantasy.AgentTool) []fantasy.AgentTool - extraTools []fantasy.AgentTool -} - -// setupExtensions discovers and loads Yaegi extensions plus legacy hooks.yml, -// builds the runner, and returns the tool wrapper/extra tools needed by the -// agent factory. -func setupExtensions() (*extensions.Runner, extensionCreationOpts, error) { - extraPaths := viper.GetStringSlice("extension") - loaded, err := extensions.LoadExtensions(extraPaths) - if err != nil { - return nil, extensionCreationOpts{}, err - } - - // Also load legacy hooks.yml as a compat extension. - hooksCfg, _ := hooks.LoadHooksConfig() - if hooksCfg != nil && len(hooksCfg.Hooks) > 0 { - compat := extensions.HooksAsExtension(hooksCfg) - if compat != nil { - loaded = append([]extensions.LoadedExtension{*compat}, loaded...) - } - } - - if len(loaded) == 0 { - return nil, extensionCreationOpts{}, nil - } - - runner := extensions.NewRunner(loaded) - - // Build the tool wrapper that intercepts tool calls through the runner. - wrapper := func(tools []fantasy.AgentTool) []fantasy.AgentTool { - return extensions.WrapToolsWithExtensions(tools, runner) - } - - // Collect custom tools registered by extensions. - extTools := extensions.ExtensionToolsAsFantasy(runner.RegisteredTools()) - - return runner, extensionCreationOpts{ - toolWrapper: wrapper, - extraTools: extTools, - }, nil + // Inject CLI-specific quiet flag into the SDK options. + opts.Quiet = quietFlag + return kit.SetupAgent(ctx, opts) } // CollectAgentMetadata extracts model display info and tool/server name lists diff --git a/contribute/build.sh b/contribute/build.sh index 8dcbe432..6ab4deae 100755 --- a/contribute/build.sh +++ b/contribute/build.sh @@ -3,4 +3,4 @@ RUN_NAME="kit" mkdir -p output -go build -o output/${RUN_NAME} \ No newline at end of file +go build -o output/${RUN_NAME} ./cmd/kit \ No newline at end of file diff --git a/sdk/README.md b/pkg/kit/README.md similarity index 96% rename from sdk/README.md rename to pkg/kit/README.md index 25fc49e7..da7d8f63 100644 --- a/sdk/README.md +++ b/pkg/kit/README.md @@ -18,14 +18,14 @@ import ( "fmt" "log" - "github.com/mark3labs/kit/sdk" + kit "github.com/mark3labs/kit/pkg/kit" ) func main() { ctx := context.Background() // Create Kit instance with default configuration - host, err := sdk.New(ctx, nil) + host, err := kit.New(ctx, nil) if err != nil { log.Fatal(err) } @@ -54,7 +54,7 @@ The SDK behaves identically to the CLI: You can override specific settings: ```go -host, err := sdk.New(ctx, &sdk.Options{ +host, err := kit.New(ctx, &kit.Options{ Model: "ollama/llama3", // Override model SystemPrompt: "You are a helpful bot", // Override system prompt ConfigFile: "/path/to/config.yml", // Use specific config file diff --git a/pkg/kit/config.go b/pkg/kit/config.go new file mode 100644 index 00000000..31f1e622 --- /dev/null +++ b/pkg/kit/config.go @@ -0,0 +1,101 @@ +package kit + +import ( + "fmt" + "os" + "strings" + + "github.com/mark3labs/kit/internal/config" + "github.com/spf13/viper" +) + +// setSDKDefaults registers the same viper defaults that the CLI sets via +// cobra flag bindings. This ensures the SDK behaves identically to the CLI +// even when cobra is not used. +func setSDKDefaults() { + viper.SetDefault("model", "anthropic/claude-sonnet-4-5-20250929") + viper.SetDefault("max-tokens", 4096) + viper.SetDefault("temperature", 0.7) + viper.SetDefault("top-p", 0.95) + viper.SetDefault("top-k", 40) + viper.SetDefault("stream", true) + viper.SetDefault("num-gpu-layers", -1) + viper.SetDefault("main-gpu", 0) +} + +// InitConfig initializes the viper configuration system. +// It searches for config files in standard locations and loads them with +// environment variable substitution. +// +// configFile: explicit config file path (empty = search defaults). +// debug: if true, print warnings about missing configs to stderr. +func InitConfig(configFile string, debug bool) error { + if configFile != "" { + return LoadConfigWithEnvSubstitution(configFile) + } + + // Ensure a config file exists (create default if none found). + if err := config.EnsureConfigExists(); err != nil { + if debug { + fmt.Fprintf(os.Stderr, "Warning: Could not create default config file: %v\n", err) + } + } + + home, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("error finding home directory: %w", err) + } + + // Current directory has higher priority than home directory. + viper.AddConfigPath(".") + viper.AddConfigPath(home) + + configLoaded := false + configNames := []string{".kit"} + + for _, name := range configNames { + viper.SetConfigName(name) + if err := viper.ReadInConfig(); err == nil { + configPath := viper.ConfigFileUsed() + if err := LoadConfigWithEnvSubstitution(configPath); err != nil { + if strings.Contains(err.Error(), "environment variable substitution failed") { + return fmt.Errorf("error reading config file '%s': %w", configPath, err) + } + continue + } + configLoaded = true + break + } + } + + if !configLoaded && debug { + fmt.Fprintf(os.Stderr, "No config file found in current directory or home directory\n") + } + + viper.SetEnvPrefix("KIT") + viper.AutomaticEnv() + return nil +} + +// LoadConfigWithEnvSubstitution loads a config file with ${ENV_VAR} expansion. +func LoadConfigWithEnvSubstitution(configPath string) error { + rawContent, err := os.ReadFile(configPath) + if err != nil { + return fmt.Errorf("failed to read config file: %w", err) + } + + substituter := &config.EnvSubstituter{} + processedContent, err := substituter.SubstituteEnvVars(string(rawContent)) + if err != nil { + return fmt.Errorf("config env substitution failed: %w", err) + } + + configType := "yaml" + if strings.HasSuffix(configPath, ".json") { + configType = "json" + } + + config.SetConfigPath(configPath) + viper.SetConfigType(configType) + return viper.ReadConfig(strings.NewReader(processedContent)) +} diff --git a/sdk/examples/basic/main.go b/pkg/kit/examples/basic/main.go similarity index 85% rename from sdk/examples/basic/main.go rename to pkg/kit/examples/basic/main.go index 794ad4e3..c3803c20 100644 --- a/sdk/examples/basic/main.go +++ b/pkg/kit/examples/basic/main.go @@ -5,7 +5,7 @@ import ( "fmt" "log" - "github.com/mark3labs/kit/sdk" + kit "github.com/mark3labs/kit/pkg/kit" ) func main() { @@ -13,7 +13,7 @@ func main() { // Example 1: Use all defaults (loads ~/.kit.yml) fmt.Println("=== Example 1: Default configuration ===") - host, err := sdk.New(ctx, nil) + host, err := kit.New(ctx, nil) if err != nil { log.Fatal(err) } @@ -27,7 +27,7 @@ func main() { // Example 2: Override model fmt.Println("=== Example 2: Custom model ===") - host2, err := sdk.New(ctx, &sdk.Options{ + host2, err := kit.New(ctx, &kit.Options{ Model: "ollama/qwen3:8b", }) if err != nil { @@ -43,7 +43,7 @@ func main() { // Example 3: With callbacks fmt.Println("=== Example 3: With tool callbacks ===") - host3, err := sdk.New(ctx, nil) + host3, err := kit.New(ctx, nil) if err != nil { log.Fatal(err) } @@ -53,13 +53,13 @@ func main() { ctx, "List files in the current directory", func(name, args string) { - fmt.Printf("🔧 Calling tool: %s\n", name) + fmt.Printf("Calling tool: %s\n", name) }, func(name, args, result string, isError bool) { if isError { - fmt.Printf("❌ Tool %s failed\n", name) + fmt.Printf("Tool %s failed\n", name) } else { - fmt.Printf("✅ Tool %s completed\n", name) + fmt.Printf("Tool %s completed\n", name) } }, func(chunk string) { @@ -73,7 +73,7 @@ func main() { // Example 4: Session management fmt.Println("\n=== Example 4: Session management ===") - host4, err := sdk.New(ctx, nil) + host4, err := kit.New(ctx, nil) if err != nil { log.Fatal(err) } diff --git a/sdk/examples/scripting/main.go b/pkg/kit/examples/scripting/main.go similarity index 89% rename from sdk/examples/scripting/main.go rename to pkg/kit/examples/scripting/main.go index 0d94e721..685f72b9 100644 --- a/sdk/examples/scripting/main.go +++ b/pkg/kit/examples/scripting/main.go @@ -6,7 +6,7 @@ import ( "log" "os" - "github.com/mark3labs/kit/sdk" + kit "github.com/mark3labs/kit/pkg/kit" ) func main() { @@ -14,7 +14,7 @@ func main() { // Create Kit with environment variable for API key // Expects ANTHROPIC_API_KEY or appropriate provider key to be set - host, err := sdk.New(ctx, &sdk.Options{ + host, err := kit.New(ctx, &kit.Options{ Quiet: true, // Suppress debug output for scripting }) if err != nil { diff --git a/sdk/kit.go b/pkg/kit/kit.go similarity index 81% rename from sdk/kit.go rename to pkg/kit/kit.go index 757cc629..0933eed2 100644 --- a/sdk/kit.go +++ b/pkg/kit/kit.go @@ -1,11 +1,11 @@ -package sdk +package kit import ( "context" "fmt" "charm.land/fantasy" - "github.com/mark3labs/kit/cmd" + "github.com/mark3labs/kit/internal/agent" "github.com/mark3labs/kit/internal/config" "github.com/mark3labs/kit/internal/session" @@ -41,18 +41,16 @@ func New(ctx context.Context, opts *Options) (*Kit, error) { opts = &Options{} } - // Initialize config exactly like CLI does - cmd.InitConfig() + // Set CLI-equivalent defaults for viper. When used as an SDK (without + // cobra), these defaults are not registered via flag bindings. + setSDKDefaults() - // Apply overrides after initialization - if opts.ConfigFile != "" { - // Load specific config file - if err := cmd.LoadConfigWithEnvSubstitution(opts.ConfigFile); err != nil { - return nil, fmt.Errorf("failed to load config file: %v", err) - } + // Initialize config (loads config files and env vars). + if err := InitConfig(opts.ConfigFile, false); err != nil { + return nil, fmt.Errorf("failed to initialize config: %w", err) } - // Override viper settings with options + // Override viper settings with options. if opts.Model != "" { viper.Set("model", opts.Model) } @@ -62,30 +60,26 @@ func New(ctx context.Context, opts *Options) (*Kit, error) { if opts.MaxSteps > 0 { viper.Set("max-steps", opts.MaxSteps) } - // Only override streaming if explicitly set viper.Set("stream", opts.Streaming) - // Load MCP configuration using existing function + // Load MCP configuration. mcpConfig, err := config.LoadAndValidateConfig() if err != nil { - return nil, fmt.Errorf("failed to load MCP config: %v", err) + return nil, fmt.Errorf("failed to load MCP config: %w", err) } - // Create agent using shared setup (builds ProviderConfig from viper internally). - agentResult, err := cmd.SetupAgent(ctx, cmd.AgentSetupOptions{ + // Create agent using shared setup. + agentResult, err := SetupAgent(ctx, AgentSetupOptions{ MCPConfig: mcpConfig, + Quiet: opts.Quiet, }) if err != nil { return nil, err } - a := agentResult.Agent - - // Create session manager - sessionMgr := session.NewManager("") return &Kit{ - agent: a, - sessionMgr: sessionMgr, + agent: agentResult.Agent, + sessionMgr: session.NewManager(""), modelString: viper.GetString("model"), }, nil } @@ -94,14 +88,10 @@ func New(ctx context.Context, opts *Options) (*Kit, error) { // use tools as needed to generate the response. The conversation history is // automatically maintained in the session. Returns an error if generation fails. func (m *Kit) Prompt(ctx context.Context, message string) (string, error) { - // Get messages from session messages := m.sessionMgr.GetMessages() - - // Add new user message userMsg := fantasy.NewUserMessage(message) messages = append(messages, userMsg) - // Call agent result, err := m.agent.GenerateWithLoop(ctx, messages, nil, // onToolCall nil, // onToolExecution @@ -113,9 +103,8 @@ func (m *Kit) Prompt(ctx context.Context, message string) (string, error) { return "", err } - // Update session with all messages from the conversation if err := m.sessionMgr.ReplaceAllMessages(result.ConversationMessages); err != nil { - return "", fmt.Errorf("failed to update session: %v", err) + return "", fmt.Errorf("failed to update session: %w", err) } return result.FinalResponse.Content.Text(), nil @@ -131,14 +120,10 @@ func (m *Kit) PromptWithCallbacks( onToolResult func(name, args, result string, isError bool), onStreaming func(chunk string), ) (string, error) { - // Get messages from session messages := m.sessionMgr.GetMessages() - - // Add new user message userMsg := fantasy.NewUserMessage(message) messages = append(messages, userMsg) - // Call agent with callbacks result, err := m.agent.GenerateWithLoopAndStreaming(ctx, messages, onToolCall, nil, // onToolExecution @@ -151,9 +136,8 @@ func (m *Kit) PromptWithCallbacks( return "", err } - // Update session if err := m.sessionMgr.ReplaceAllMessages(result.ConversationMessages); err != nil { - return "", fmt.Errorf("failed to update session: %v", err) + return "", fmt.Errorf("failed to update session: %w", err) } return result.FinalResponse.Content.Text(), nil diff --git a/sdk/kit_test.go b/pkg/kit/kit_test.go similarity index 90% rename from sdk/kit_test.go rename to pkg/kit/kit_test.go index 60b783c8..43532f51 100644 --- a/sdk/kit_test.go +++ b/pkg/kit/kit_test.go @@ -1,11 +1,11 @@ -package sdk_test +package kit_test import ( "context" "os" "testing" - "github.com/mark3labs/kit/sdk" + kit "github.com/mark3labs/kit/pkg/kit" ) func TestNew(t *testing.T) { @@ -16,7 +16,7 @@ func TestNew(t *testing.T) { ctx := context.Background() // Test default initialization - host, err := sdk.New(ctx, nil) + host, err := kit.New(ctx, nil) if err != nil { t.Fatalf("Failed to create Kit with defaults: %v", err) } @@ -34,13 +34,13 @@ func TestNewWithOptions(t *testing.T) { ctx := context.Background() - opts := &sdk.Options{ + opts := &kit.Options{ Model: "anthropic/claude-sonnet-4-5-20250929", MaxSteps: 5, Quiet: true, } - host, err := sdk.New(ctx, opts) + host, err := kit.New(ctx, opts) if err != nil { t.Fatalf("Failed to create Kit with options: %v", err) } @@ -58,7 +58,7 @@ func TestSessionManagement(t *testing.T) { ctx := context.Background() - host, err := sdk.New(ctx, &sdk.Options{Quiet: true}) + host, err := kit.New(ctx, &kit.Options{Quiet: true}) if err != nil { t.Fatalf("Failed to create Kit: %v", err) } diff --git a/pkg/kit/setup.go b/pkg/kit/setup.go new file mode 100644 index 00000000..5cee4161 --- /dev/null +++ b/pkg/kit/setup.go @@ -0,0 +1,171 @@ +package kit + +import ( + "context" + "fmt" + + "charm.land/fantasy" + + "github.com/mark3labs/kit/internal/agent" + "github.com/mark3labs/kit/internal/config" + "github.com/mark3labs/kit/internal/extensions" + "github.com/mark3labs/kit/internal/hooks" + "github.com/mark3labs/kit/internal/models" + "github.com/mark3labs/kit/internal/tools" + "github.com/spf13/viper" +) + +// AgentSetupOptions configures agent creation. +type AgentSetupOptions struct { + // MCPConfig is the MCP server configuration. Required. + MCPConfig *config.Config + // ShowSpinner shows a loading spinner for Ollama models. + ShowSpinner bool + // SpinnerFunc provides the spinner implementation (nil = no spinner). + SpinnerFunc agent.SpinnerFunc + // UseBufferedLogger captures debug messages for later display (root + // non-interactive path). When false a simple logger is used instead. + UseBufferedLogger bool + // Quiet suppresses output. Replaces the cmd package's quietFlag variable. + Quiet bool +} + +// AgentSetupResult bundles the created agent and any debug logger so the caller +// can flush buffered messages when appropriate. +type AgentSetupResult struct { + Agent *agent.Agent + BufferedLogger *tools.BufferedDebugLogger + // ExtRunner is the extension runner (nil when --no-extensions or no + // extensions were discovered). + ExtRunner *extensions.Runner +} + +// BuildProviderConfig creates a *models.ProviderConfig from the current viper +// state. All entry points (root, script, SDK) converge through this function. +func BuildProviderConfig() (*models.ProviderConfig, string, error) { + systemPrompt, err := config.LoadSystemPrompt(viper.GetString("system-prompt")) + if err != nil { + return nil, "", fmt.Errorf("failed to load system prompt: %w", err) + } + + temperature := float32(viper.GetFloat64("temperature")) + topP := float32(viper.GetFloat64("top-p")) + topK := int32(viper.GetInt("top-k")) + numGPU := int32(viper.GetInt("num-gpu-layers")) + mainGPU := int32(viper.GetInt("main-gpu")) + + cfg := &models.ProviderConfig{ + ModelString: viper.GetString("model"), + SystemPrompt: systemPrompt, + ProviderAPIKey: viper.GetString("provider-api-key"), + ProviderURL: viper.GetString("provider-url"), + MaxTokens: viper.GetInt("max-tokens"), + Temperature: &temperature, + TopP: &topP, + TopK: &topK, + StopSequences: viper.GetStringSlice("stop-sequences"), + NumGPU: &numGPU, + MainGPU: &mainGPU, + TLSSkipVerify: viper.GetBool("tls-skip-verify"), + } + + return cfg, systemPrompt, nil +} + +// SetupAgent creates an agent from the current viper state + the provided +// options. It wraps BuildProviderConfig and agent.CreateAgent. +func SetupAgent(ctx context.Context, opts AgentSetupOptions) (*AgentSetupResult, error) { + modelConfig, systemPrompt, err := BuildProviderConfig() + if err != nil { + return nil, err + } + + // Create the appropriate debug logger. + var debugLogger tools.DebugLogger + var bufferedLogger *tools.BufferedDebugLogger + if viper.GetBool("debug") { + if opts.UseBufferedLogger { + bufferedLogger = tools.NewBufferedDebugLogger(true) + debugLogger = bufferedLogger + } else { + debugLogger = tools.NewSimpleDebugLogger(true) + } + } + + // Load extensions unless --no-extensions is set. + var extRunner *extensions.Runner + var extCreationOpts extensionCreationOpts + if !viper.GetBool("no-extensions") { + var extErr error + extRunner, extCreationOpts, extErr = loadExtensions() + if extErr != nil { + fmt.Printf("Warning: Failed to load extensions: %v\n", extErr) + } + } + + a, err := agent.CreateAgent(ctx, &agent.AgentCreationOptions{ + ModelConfig: modelConfig, + MCPConfig: opts.MCPConfig, + SystemPrompt: systemPrompt, + MaxSteps: viper.GetInt("max-steps"), + StreamingEnabled: viper.GetBool("stream"), + ShowSpinner: opts.ShowSpinner, + Quiet: opts.Quiet, + SpinnerFunc: opts.SpinnerFunc, + DebugLogger: debugLogger, + ToolWrapper: extCreationOpts.toolWrapper, + ExtraTools: extCreationOpts.extraTools, + }) + if err != nil { + return nil, fmt.Errorf("failed to create agent: %w", err) + } + + return &AgentSetupResult{ + Agent: a, + ExtRunner: extRunner, + BufferedLogger: bufferedLogger, + }, nil +} + +// extensionCreationOpts holds the tool wrapper and extra tools extracted from +// loaded extensions for passing into agent creation. +type extensionCreationOpts struct { + toolWrapper func([]fantasy.AgentTool) []fantasy.AgentTool + extraTools []fantasy.AgentTool +} + +// loadExtensions discovers and loads Yaegi extensions plus legacy hooks.yml, +// builds the runner, and returns the tool wrapper/extra tools. +func loadExtensions() (*extensions.Runner, extensionCreationOpts, error) { + extraPaths := viper.GetStringSlice("extension") + loaded, err := extensions.LoadExtensions(extraPaths) + if err != nil { + return nil, extensionCreationOpts{}, err + } + + // Also load legacy hooks.yml as a compat extension. + hooksCfg, _ := hooks.LoadHooksConfig() + if hooksCfg != nil && len(hooksCfg.Hooks) > 0 { + compat := extensions.HooksAsExtension(hooksCfg) + if compat != nil { + loaded = append([]extensions.LoadedExtension{*compat}, loaded...) + } + } + + if len(loaded) == 0 { + return nil, extensionCreationOpts{}, nil + } + + runner := extensions.NewRunner(loaded) + + wrapper := func(tools []fantasy.AgentTool) []fantasy.AgentTool { + return extensions.WrapToolsWithExtensions(tools, runner) + } + + extTools := extensions.ExtensionToolsAsFantasy(runner.RegisteredTools()) + + return runner, extensionCreationOpts{ + toolWrapper: wrapper, + extraTools: extTools, + }, nil +} diff --git a/sdk/types.go b/pkg/kit/types.go similarity index 98% rename from sdk/types.go rename to pkg/kit/types.go index ef6824ce..325ae781 100644 --- a/sdk/types.go +++ b/pkg/kit/types.go @@ -1,7 +1,8 @@ -package sdk +package kit import ( "charm.land/fantasy" + "github.com/mark3labs/kit/internal/message" "github.com/mark3labs/kit/internal/session" ) diff --git a/plans/00-move-sdk-to-top-level.md b/plans/00-move-sdk-to-top-level.md new file mode 100644 index 00000000..235b2b2a --- /dev/null +++ b/plans/00-move-sdk-to-top-level.md @@ -0,0 +1,503 @@ +# Plan 00: Create `pkg/kit` SDK Package & Extract Init from `cmd` + +**Priority**: P0 +**Effort**: Medium-High +**Goal**: Create `pkg/kit` as the canonical SDK package; extract shared logic from `cmd/` so both the CLI and external users consume the same API + +## Background + +Currently the SDK lives in `sdk/` and imports `cmd/` to access `InitConfig`, `SetupAgent`, etc. This creates a circular dependency problem: if the CLI app wants to consume the SDK, `cmd` would import `sdk` which imports `cmd`. + +The fix is two-fold: +1. Move the SDK to `pkg/kit/` (idiomatic Go for public library packages) +2. Extract configuration/agent-setup logic from `cmd/` into `pkg/kit/` so both the CLI and SDK share the same code path without circular deps + +### Architecture Before + +``` +main.go → cmd/ → internal/agent, internal/session, internal/config, ... + +sdk/kit.go → cmd.InitConfig() ← SDK depends on cmd (problem!) + → cmd.SetupAgent() + → internal/session +``` + +### Architecture After + +``` +cmd/kit/main.go → cmd/ → pkg/kit/ → internal/agent, internal/session, ... + ← CLI consumes SDK + +pkg/kit/ → internal/agent, internal/session, internal/config, ... + ← External users consume SDK + +internal/app/ → pkg/kit/ ← App consumes SDK (gradual migration) + → internal/ui/ ← App owns UI only +``` + +## Prerequisites + +- None. This is the foundation for all other plans. + +## Step-by-Step + +### Step 1: Create `pkg/kit/` directory + +```bash +mkdir -p pkg/kit +``` + +### Step 2: Extract config-loading logic from `cmd/root.go` into `pkg/kit/config.go` + +The two functions `InitConfig()` and `LoadConfigWithEnvSubstitution()` currently live in `cmd/root.go` and depend on package-level variables (`configFile`, `debugMode`). Extract them as pure functions that accept parameters. + +**File**: Create `pkg/kit/config.go` + +```go +package kit + +import ( + "fmt" + "os" + "strings" + + "github.com/mark3labs/kit/internal/config" + "github.com/spf13/viper" +) + +// InitConfig initializes the viper configuration system. +// It searches for config files in standard locations and loads them with +// environment variable substitution. +// +// configFile: explicit config file path (empty = search defaults) +// debug: if true, print warnings about missing configs +func InitConfig(configFile string, debug bool) error { + if configFile != "" { + return LoadConfigWithEnvSubstitution(configFile) + } + + // Ensure a config file exists (create default if none found) + if err := config.EnsureConfigExists(); err != nil { + if debug { + fmt.Fprintf(os.Stderr, "Warning: Could not create default config file: %v\n", err) + } + } + + home, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("error finding home directory: %w", err) + } + + viper.AddConfigPath(".") + viper.AddConfigPath(home) + + configNames := []string{".kit"} + configLoaded := false + + for _, name := range configNames { + viper.SetConfigName(name) + if err := viper.ReadInConfig(); err == nil { + configPath := viper.ConfigFileUsed() + if err := LoadConfigWithEnvSubstitution(configPath); err != nil { + if strings.Contains(err.Error(), "environment variable substitution failed") { + return fmt.Errorf("error reading config file '%s': %w", configPath, err) + } + continue + } + configLoaded = true + break + } + } + + if !configLoaded && debug { + fmt.Fprintf(os.Stderr, "No config file found in current directory or home directory\n") + } + + viper.SetEnvPrefix("KIT") + viper.AutomaticEnv() + return nil +} + +// LoadConfigWithEnvSubstitution loads a config file with ${ENV_VAR} expansion. +func LoadConfigWithEnvSubstitution(configPath string) error { + rawContent, err := os.ReadFile(configPath) + if err != nil { + return fmt.Errorf("failed to read config file: %w", err) + } + + substituter := &config.EnvSubstituter{} + processedContent, err := substituter.SubstituteEnvVars(string(rawContent)) + if err != nil { + return fmt.Errorf("config env substitution failed: %w", err) + } + + configType := "yaml" + if strings.HasSuffix(configPath, ".json") { + configType = "json" + } + + config.SetConfigPath(configPath) + viper.SetConfigType(configType) + return viper.ReadConfig(strings.NewReader(processedContent)) +} +``` + +**Source**: Extracted from `cmd/root.go:119-213` + +### Step 3: Extract agent setup logic from `cmd/setup.go` into `pkg/kit/setup.go` + +Move `BuildProviderConfig`, `AgentSetupOptions`, `AgentSetupResult`, `SetupAgent`, and `setupExtensions` to the SDK. The key change: replace the `quietFlag` package-level variable dependency with an explicit `Quiet` field on `AgentSetupOptions`. + +**File**: Create `pkg/kit/setup.go` + +```go +package kit + +import ( + "context" + "fmt" + + "charm.land/fantasy" + "github.com/mark3labs/kit/internal/agent" + "github.com/mark3labs/kit/internal/config" + "github.com/mark3labs/kit/internal/extensions" + "github.com/mark3labs/kit/internal/hooks" + "github.com/mark3labs/kit/internal/models" + "github.com/mark3labs/kit/internal/tools" + "github.com/spf13/viper" +) + +// AgentSetupOptions configures agent creation. +type AgentSetupOptions struct { + MCPConfig *config.Config + ShowSpinner bool + SpinnerFunc agent.SpinnerFunc + UseBufferedLogger bool + Quiet bool // Replaces cmd's quietFlag package var +} + +// AgentSetupResult contains the created agent and related components. +type AgentSetupResult struct { + Agent *agent.Agent + BufferedLogger *tools.BufferedDebugLogger + ExtRunner *extensions.Runner +} + +// BuildProviderConfig creates a ProviderConfig from the current viper state. +func BuildProviderConfig() (*models.ProviderConfig, string, error) { + systemPrompt, err := config.LoadSystemPrompt(viper.GetString("system-prompt")) + if err != nil { + return nil, "", fmt.Errorf("failed to load system prompt: %w", err) + } + + temperature := float32(viper.GetFloat64("temperature")) + topP := float32(viper.GetFloat64("top-p")) + topK := int32(viper.GetInt("top-k")) + numGPU := int32(viper.GetInt("num-gpu-layers")) + mainGPU := int32(viper.GetInt("main-gpu")) + + cfg := &models.ProviderConfig{ + ModelString: viper.GetString("model"), + SystemPrompt: systemPrompt, + ProviderAPIKey: viper.GetString("provider-api-key"), + ProviderURL: viper.GetString("provider-url"), + MaxTokens: viper.GetInt("max-tokens"), + Temperature: &temperature, + TopP: &topP, + TopK: &topK, + StopSequences: viper.GetStringSlice("stop-sequences"), + NumGPU: &numGPU, + MainGPU: &mainGPU, + TLSSkipVerify: viper.GetBool("tls-skip-verify"), + } + + return cfg, systemPrompt, nil +} + +// SetupAgent creates an agent from the current configuration state. +func SetupAgent(ctx context.Context, opts AgentSetupOptions) (*AgentSetupResult, error) { + modelConfig, systemPrompt, err := BuildProviderConfig() + if err != nil { + return nil, err + } + + var debugLogger tools.DebugLogger + var bufferedLogger *tools.BufferedDebugLogger + if viper.GetBool("debug") { + if opts.UseBufferedLogger { + bufferedLogger = tools.NewBufferedDebugLogger(true) + debugLogger = bufferedLogger + } else { + debugLogger = tools.NewSimpleDebugLogger(true) + } + } + + var extRunner *extensions.Runner + var extOpts extensionCreationOpts + if !viper.GetBool("no-extensions") { + var extErr error + extRunner, extOpts, extErr = loadExtensions() + if extErr != nil { + fmt.Printf("Warning: Failed to load extensions: %v\n", extErr) + } + } + + a, err := agent.CreateAgent(ctx, &agent.AgentCreationOptions{ + ModelConfig: modelConfig, + MCPConfig: opts.MCPConfig, + SystemPrompt: systemPrompt, + MaxSteps: viper.GetInt("max-steps"), + StreamingEnabled: viper.GetBool("stream"), + ShowSpinner: opts.ShowSpinner, + Quiet: opts.Quiet, + SpinnerFunc: opts.SpinnerFunc, + DebugLogger: debugLogger, + ToolWrapper: extOpts.toolWrapper, + ExtraTools: extOpts.extraTools, + }) + if err != nil { + return nil, fmt.Errorf("failed to create agent: %w", err) + } + + return &AgentSetupResult{ + Agent: a, + ExtRunner: extRunner, + BufferedLogger: bufferedLogger, + }, nil +} + +// unexported helpers + +type extensionCreationOpts struct { + toolWrapper func([]fantasy.AgentTool) []fantasy.AgentTool + extraTools []fantasy.AgentTool +} + +func loadExtensions() (*extensions.Runner, extensionCreationOpts, error) { + extraPaths := viper.GetStringSlice("extension") + loaded, err := extensions.LoadExtensions(extraPaths) + if err != nil { + return nil, extensionCreationOpts{}, err + } + + hooksCfg, _ := hooks.LoadHooksConfig() + if hooksCfg != nil && len(hooksCfg.Hooks) > 0 { + compat := extensions.HooksAsExtension(hooksCfg) + if compat != nil { + loaded = append([]extensions.LoadedExtension{*compat}, loaded...) + } + } + + if len(loaded) == 0 { + return nil, extensionCreationOpts{}, nil + } + + runner := extensions.NewRunner(loaded) + wrapper := func(tools []fantasy.AgentTool) []fantasy.AgentTool { + return extensions.WrapToolsWithExtensions(tools, runner) + } + extTools := extensions.ExtensionToolsAsFantasy(runner.RegisteredTools()) + + return runner, extensionCreationOpts{ + toolWrapper: wrapper, + extraTools: extTools, + }, nil +} +``` + +**Source**: Extracted from `cmd/setup.go:28-185` + +### Step 4: Move SDK core (`sdk/kit.go`, `sdk/types.go`) into `pkg/kit/` + +Move the files and update them to import from the local package (no more `cmd` import): + +**File**: Move `sdk/kit.go` to `pkg/kit/kit.go` + +Key changes: +- `package sdk` → `package kit` +- Remove `import "github.com/mark3labs/kit/cmd"` entirely +- Replace `cmd.InitConfig()` → `InitConfig(...)` (same package) +- Replace `cmd.LoadConfigWithEnvSubstitution(...)` → `LoadConfigWithEnvSubstitution(...)` (same package) +- Replace `cmd.SetupAgent(...)` → `SetupAgent(...)` (same package) +- Replace `cmd.AgentSetupOptions{...}` → `AgentSetupOptions{...}` (same package) + +**File**: Move `sdk/types.go` to `pkg/kit/types.go` + +Key change: `package sdk` → `package kit` + +### Step 5: Move `main.go` to `cmd/kit/main.go` + +**File**: Create `cmd/kit/main.go` with the current `main.go` contents + +```go +package main + +import ( + "context" + "fmt" + "os" + + "github.com/charmbracelet/fang" + "github.com/mark3labs/kit/cmd" +) + +var version = "dev" + +func main() { + if len(os.Args) > 1 && (os.Args[1] == "--version" || os.Args[1] == "-v") { + fmt.Println(version) + os.Exit(0) + } + ctx := context.Background() + rootCmd := cmd.GetRootCommand(version) + if err := fang.Execute(ctx, rootCmd); err != nil { + os.Exit(1) + } +} +``` + +Delete root `main.go`. + +### Step 6: Update `cmd/root.go` to delegate to `pkg/kit` + +**File**: `cmd/root.go` + +Replace the `InitConfig` function body with a call to the SDK: + +```go +import kit "github.com/mark3labs/kit/pkg/kit" + +func InitConfig() { + if err := kit.InitConfig(configFile, debugMode); err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + os.Exit(1) + } +} +``` + +Keep `LoadConfigWithEnvSubstitution` as a thin wrapper or remove it entirely (callers use `kit.LoadConfigWithEnvSubstitution` directly). + +### Step 7: Update `cmd/setup.go` to delegate to `pkg/kit` + +**File**: `cmd/setup.go` + +Replace `BuildProviderConfig`, `SetupAgent`, etc. with thin wrappers that inject CLI-specific state: + +```go +import kit "github.com/mark3labs/kit/pkg/kit" + +// BuildProviderConfig delegates to the SDK. +func BuildProviderConfig() (*models.ProviderConfig, string, error) { + return kit.BuildProviderConfig() +} + +// SetupAgent delegates to the SDK, injecting CLI-specific quiet flag. +func SetupAgent(ctx context.Context, opts AgentSetupOptions) (*AgentSetupResult, error) { + result, err := kit.SetupAgent(ctx, kit.AgentSetupOptions{ + MCPConfig: opts.MCPConfig, + ShowSpinner: opts.ShowSpinner, + SpinnerFunc: opts.SpinnerFunc, + UseBufferedLogger: opts.UseBufferedLogger, + Quiet: quietFlag, // Inject CLI package-level state + }) + if err != nil { + return nil, err + } + // Map SDK result back to cmd types (or make cmd use SDK types directly) + return &AgentSetupResult{ + Agent: result.Agent, + BufferedLogger: result.BufferedLogger, + ExtRunner: result.ExtRunner, + }, nil +} +``` + +**Alternative (cleaner)**: Remove `cmd` wrapper types entirely and have all callers in `cmd/` use `kit.AgentSetupOptions` and `kit.AgentSetupResult` directly. This is the app-as-consumer pattern. + +### Step 8: Update `.goreleaser.yaml` + +Add `main: ./cmd/kit`: + +```yaml +builds: + - id: kit + main: ./cmd/kit + binary: kit + ldflags: + - -s -w -X main.version={{.Version}} +``` + +### Step 9: Update examples and tests + +**Move**: `sdk/examples/` → `pkg/kit/examples/` + +Update all imports: +- `"github.com/mark3labs/kit/sdk"` → `kit "github.com/mark3labs/kit/pkg/kit"` +- All `sdk.` → `kit.` + +**Move**: `sdk/kit_test.go` → `pkg/kit/kit_test.go` +- `package sdk_test` → `package kit_test` +- Update import path + +### Step 10: Clean up old `sdk/` directory + +Remove `sdk/` entirely after all files are moved. + +### Step 11: Update documentation + +- `README.md`: Update import paths to `"github.com/mark3labs/kit/pkg/kit"` +- Move `sdk/README.md` → `pkg/kit/README.md` with updated paths + +### Step 12: Verify + +```bash +go build -o output/kit ./cmd/kit +go test -race ./... +go vet ./... +``` + +Confirm no remaining imports of `"github.com/mark3labs/kit/sdk"` or `"github.com/mark3labs/kit/cmd"` from `pkg/kit/`. + +## Files Changed Summary + +| Action | File | Change | +|--------|------|--------| +| CREATE | `pkg/kit/config.go` | Extracted InitConfig, LoadConfigWithEnvSubstitution | +| CREATE | `pkg/kit/setup.go` | Extracted BuildProviderConfig, SetupAgent, AgentSetupOptions/Result | +| MOVE | `sdk/kit.go` → `pkg/kit/kit.go` | Change package, remove cmd import | +| MOVE | `sdk/types.go` → `pkg/kit/types.go` | Change package | +| MOVE | `sdk/kit_test.go` → `pkg/kit/kit_test.go` | Change package and imports | +| MOVE | `sdk/examples/` → `pkg/kit/examples/` | Update imports | +| CREATE | `cmd/kit/main.go` | New CLI entrypoint | +| DELETE | `main.go` | Moved to cmd/kit/ | +| EDIT | `cmd/root.go` | Delegate InitConfig to pkg/kit | +| EDIT | `cmd/setup.go` | Delegate SetupAgent to pkg/kit (or use SDK types directly) | +| EDIT | `.goreleaser.yaml` | Add `main: ./cmd/kit` | +| DELETE | `sdk/` | Entire directory after moves | + +## Dependency Graph After + +``` +cmd/kit/main.go → cmd/ +cmd/ → pkg/kit/ (CLI uses SDK) + → internal/app/ (CLI uses app for TUI) + → internal/ui/ (CLI uses UI) +pkg/kit/ → internal/agent, internal/session, internal/config, ... + (SDK uses internals, never cmd) +internal/app/ → pkg/kit/ (App uses SDK — gradual migration) + → internal/ui/ (App owns TUI) +``` + +**No circular dependencies.** + +## Verification Checklist + +- [ ] `go build -o output/kit ./cmd/kit` succeeds +- [ ] `go test -race ./...` passes +- [ ] `go vet ./...` clean +- [ ] No `pkg/kit/` file imports `cmd/` +- [ ] `cmd/` files import `pkg/kit/` for shared logic +- [ ] No remaining references to `"github.com/mark3labs/kit/sdk"` +- [ ] Examples compile with new import path +- [ ] `.goreleaser.yaml` builds from `./cmd/kit` +- [ ] CI passes (`go test ./...`) diff --git a/plans/01-export-tools-and-factories.md b/plans/01-export-tools-and-factories.md new file mode 100644 index 00000000..21538d53 --- /dev/null +++ b/plans/01-export-tools-and-factories.md @@ -0,0 +1,253 @@ +# Plan 01: Export Tools and Tool Factories + +**Priority**: P0 +**Effort**: Medium +**Goal**: Expose built-in tools as public APIs with pre-built instances and factory functions. The Kit CLI app should also consume these exports instead of reaching into `internal/core` directly. + +## Background + +Pi SDK exports individual tools and tool factories: +- Pre-built: `readTool`, `bashTool`, `editTool`, etc. +- Factories: `createReadTool(cwd)`, `createBashTool(cwd)`, etc. +- Bundles: `allTools`, `codingTools`, `readOnlyTools` + +Kit currently keeps all tools internal (`internal/core/`). The agent setup in `internal/agent/agent.go:97` calls `core.AllTools()` directly. After this plan, both SDK users AND the agent use the same public tool constructors. + +## Prerequisites + +- Plan 00 (Create `pkg/kit/` package) + +## Architecture + +``` +pkg/kit/ +├── kit.go # Kit struct, New(), Prompt(), etc. +├── types.go # Type aliases +├── tools.go # NEW: Public tool exports, factories, bundles +├── config.go # Extracted from cmd +├── setup.go # Extracted from cmd +internal/core/ +├── tools.go # MODIFY: Add WithWorkDir option +├── read.go # MODIFY: Accept workdir param +├── write.go # MODIFY: Accept workdir param +├── bash.go # MODIFY: Accept workdir param + cmd.Dir +├── edit.go # MODIFY: Accept workdir param +├── grep.go # MODIFY: Accept workdir param + cmd.Dir +├── find.go # MODIFY: Accept workdir param + cmd.Dir +├── ls.go # MODIFY: Accept workdir param +├── truncate.go # Unchanged +internal/agent/ +├── agent.go # MODIFY: Use public constructors via core package +``` + +## Step-by-Step + +### Step 1: Add ToolOption pattern to `internal/core/tools.go` + +**File**: `internal/core/tools.go` + +Add a functional options pattern for tool creation: + +```go +// ToolOption configures tool behavior. +type ToolOption func(*toolConfig) + +type toolConfig struct { + workDir string +} + +// WithWorkDir sets the working directory for file-based tools. +// If empty, os.Getwd() is used at execution time. +func WithWorkDir(dir string) ToolOption { + return func(c *toolConfig) { + c.workDir = dir + } +} + +func applyOptions(opts []ToolOption) toolConfig { + var cfg toolConfig + for _, o := range opts { + o(&cfg) + } + return cfg +} +``` + +Update all collection functions to accept variadic options: + +```go +func CodingTools(opts ...ToolOption) []fantasy.AgentTool { ... } +func ReadOnlyTools(opts ...ToolOption) []fantasy.AgentTool { ... } +func AllTools(opts ...ToolOption) []fantasy.AgentTool { ... } +``` + +### Step 2: Update path resolution to accept workDir + +**File**: `internal/core/read.go` + +Replace `resolvePath()` at line 134-144 with configurable version: + +```go +func resolvePathWithWorkDir(path, workDir string) (string, error) { + if filepath.IsAbs(path) { + return filepath.Clean(path), nil + } + baseDir := workDir + if baseDir == "" { + var err error + baseDir, err = os.Getwd() + if err != nil { + return "", fmt.Errorf("failed to get working directory: %w", err) + } + } + return filepath.Clean(filepath.Join(baseDir, path)), nil +} + +// Backward-compat wrapper +func resolvePath(path string) (string, error) { + return resolvePathWithWorkDir(path, "") +} +``` + +### Steps 3-9: Update each tool constructor + +For each tool (`read.go`, `write.go`, `edit.go`, `bash.go`, `grep.go`, `find.go`, `ls.go`): +- Change `NewXxxTool()` to `NewXxxTool(opts ...ToolOption)` +- Apply `cfg := applyOptions(opts)` in the constructor +- Pass `cfg.workDir` to path resolution or `cmd.Dir` +- For bash/grep/find (subprocess tools): set `cmd.Dir = cfg.workDir` on `exec.CommandContext` +- Existing callers pass no args, so they get default behavior (backward compatible) + +### Step 10: Create `pkg/kit/tools.go` + +**File**: `pkg/kit/tools.go` + +```go +package kit + +import ( + "charm.land/fantasy" + "github.com/mark3labs/kit/internal/core" +) + +// Tool is the interface that all Kit tools implement. +type Tool = fantasy.AgentTool + +// ToolOption configures tool behavior. +type ToolOption = core.ToolOption + +// WithWorkDir sets the working directory for file-based tools. +var WithWorkDir = core.WithWorkDir + +// Individual tool constructors +func NewReadTool(opts ...ToolOption) Tool { return core.NewReadTool(opts...) } +func NewWriteTool(opts ...ToolOption) Tool { return core.NewWriteTool(opts...) } +func NewEditTool(opts ...ToolOption) Tool { return core.NewEditTool(opts...) } +func NewBashTool(opts ...ToolOption) Tool { return core.NewBashTool(opts...) } +func NewGrepTool(opts ...ToolOption) Tool { return core.NewGrepTool(opts...) } +func NewFindTool(opts ...ToolOption) Tool { return core.NewFindTool(opts...) } +func NewLsTool(opts ...ToolOption) Tool { return core.NewLsTool(opts...) } + +// Tool bundles +func AllTools(opts ...ToolOption) []Tool { return core.AllTools(opts...) } +func CodingTools(opts ...ToolOption) []Tool { return core.CodingTools(opts...) } +func ReadOnlyTools(opts ...ToolOption) []Tool { return core.ReadOnlyTools(opts...) } +``` + +### Step 11: Add GetTools() to Kit struct + +**File**: `pkg/kit/kit.go` + +```go +// GetTools returns all tools available to the agent (core + MCP + extensions). +func (m *Kit) GetTools() []Tool { + return m.agent.GetTools() +} +``` + +### Step 12: App-as-Consumer — Agent uses SDK tool constructors + +This is the key "dog-fooding" step. Currently `internal/agent/agent.go:97` calls `core.AllTools()` directly. After this change, the agent setup should get its tool list from the caller (via `AgentConfig.Tools`) rather than hardcoding `core.AllTools()`. + +**File**: `internal/agent/agent.go` + +Change the `AgentConfig` struct to accept tools explicitly: + +```go +type AgentConfig struct { + // ... existing fields ... + CoreTools []fantasy.AgentTool // NEW: if empty, defaults to core.AllTools() +} +``` + +In `NewAgent()` at line 96-97, change: +```go +// Before: +coreTools := core.AllTools() + +// After: +coreTools := agentConfig.CoreTools +if len(coreTools) == 0 { + coreTools = core.AllTools() // Default fallback +} +``` + +Then in `pkg/kit/setup.go`, the `SetupAgent` function passes tools from the SDK: + +```go +a, err := agent.CreateAgent(ctx, &agent.AgentCreationOptions{ + // ... existing fields ... + CoreTools: core.AllTools(), // Explicit — could be customized via Options +}) +``` + +And in `pkg/kit/kit.go`, the `Options` struct gets a `Tools` field: + +```go +type Options struct { + // ... existing fields ... + Tools []Tool // Custom tool set. If empty, AllTools() is used. +} +``` + +This allows SDK users to pass custom tools: + +```go +k, _ := kit.New(ctx, &kit.Options{ + Tools: kit.CodingTools(kit.WithWorkDir("/my/project")), +}) +``` + +### Step 13: Write tests and verify + +```bash +go build -o output/kit ./cmd/kit +go test -race ./... +go vet ./... +``` + +## Files Changed Summary + +| Action | File | Change | +|--------|------|--------| +| EDIT | `internal/core/tools.go` | Add ToolOption, WithWorkDir, update collection funcs | +| EDIT | `internal/core/read.go` | resolvePathWithWorkDir, accept opts | +| EDIT | `internal/core/write.go` | Accept opts | +| EDIT | `internal/core/edit.go` | Accept opts | +| EDIT | `internal/core/bash.go` | Accept opts, set cmd.Dir | +| EDIT | `internal/core/grep.go` | Accept opts, set cmd.Dir | +| EDIT | `internal/core/find.go` | Accept opts, set cmd.Dir | +| EDIT | `internal/core/ls.go` | Accept opts | +| CREATE | `pkg/kit/tools.go` | Public tool exports and factories | +| EDIT | `pkg/kit/kit.go` | Add GetTools(), Tools option | +| EDIT | `internal/agent/agent.go` | Accept CoreTools in config instead of hardcoding | +| EDIT | `pkg/kit/setup.go` | Pass tools through to agent creation | + +## Verification Checklist + +- [ ] `go build -o output/kit ./cmd/kit` succeeds +- [ ] `go test -race ./...` passes (agent still gets default tools) +- [ ] Tools with `WithWorkDir("/tmp")` resolve paths relative to `/tmp` +- [ ] Tools with no options use `os.Getwd()` (backward compatible) +- [ ] SDK users can pass custom tool sets via `kit.Options{Tools: ...}` +- [ ] Agent accepts injected tools instead of hardcoding `core.AllTools()` diff --git a/plans/02-richer-type-exports.md b/plans/02-richer-type-exports.md new file mode 100644 index 00000000..f26ca759 --- /dev/null +++ b/plans/02-richer-type-exports.md @@ -0,0 +1,196 @@ +# Plan 02: Richer Type Exports + +**Priority**: P0 +**Effort**: Low +**Goal**: Export 40+ internal types so SDK users and the CLI app share the same type surface + +## Background + +Currently only 3 type aliases are exported: `Message`, `ToolCall`, `ToolResult`. Pi exports 50+ types. SDK users and the CLI app both need access to messages, sessions, config, agents, models, and callback types. By exporting from `pkg/kit`, both external consumers and the CLI share the same types — no parallel definitions. + +## Prerequisites + +- Plan 00 (Create `pkg/kit/`) + +## Key Principle: Shared Types + +After this plan, `cmd/` should progressively adopt types from `pkg/kit/` instead of importing from `internal/` directly. For example: +- `cmd/setup.go` should reference `kit.ProviderConfig` rather than `models.ProviderConfig` +- `cmd/root.go` session setup should use `kit.SessionInfo` rather than `session.SessionInfo` + +This is a gradual migration — the type aliases make this zero-cost since `kit.ProviderConfig = models.ProviderConfig` (same underlying type). + +## Step-by-Step + +### Step 1: Expand `pkg/kit/types.go` with all type groups + +**File**: `pkg/kit/types.go` + +```go +package kit + +import ( + "charm.land/fantasy" + "github.com/mark3labs/kit/internal/agent" + "github.com/mark3labs/kit/internal/config" + "github.com/mark3labs/kit/internal/message" + "github.com/mark3labs/kit/internal/models" + "github.com/mark3labs/kit/internal/session" +) + +// ==== Message Types (internal/message/content.go) ==== + +type Message = message.Message +type MessageRole = message.MessageRole + +const ( + RoleUser = message.RoleUser + RoleAssistant = message.RoleAssistant + RoleTool = message.RoleTool + RoleSystem = message.RoleSystem +) + +type ContentPart = message.ContentPart +type TextContent = message.TextContent +type ReasoningContent = message.ReasoningContent +type ToolCall = message.ToolCall +type ToolResult = message.ToolResult +type Finish = message.Finish + +// ==== Session Types (internal/session/) ==== + +type Session = session.Session +type SessionMetadata = session.Metadata +type SessionManager = session.Manager +type SessionInfo = session.SessionInfo +type TreeManager = session.TreeManager +type SessionHeader = session.SessionHeader +type MessageEntry = session.MessageEntry + +// ==== Config Types (internal/config/) ==== + +type Config = config.Config +type MCPServerConfig = config.MCPServerConfig + +// ==== Agent Types (internal/agent/) ==== + +type AgentConfig = agent.AgentConfig +type GenerateResult = agent.GenerateWithLoopResult + +type ( + ToolCallHandler = agent.ToolCallHandler + ToolExecutionHandler = agent.ToolExecutionHandler + ToolResultHandler = agent.ToolResultHandler + ResponseHandler = agent.ResponseHandler + StreamingResponseHandler = agent.StreamingResponseHandler + ToolCallContentHandler = agent.ToolCallContentHandler +) + +// ==== Provider & Model Types (internal/models/) ==== + +type ProviderConfig = models.ProviderConfig +type ProviderResult = models.ProviderResult +type ModelInfo = models.ModelInfo +type ModelCost = models.Cost +type ModelLimit = models.Limit +type ProviderInfo = models.ProviderInfo +type ModelsRegistry = models.ModelsRegistry + +// ==== Fantasy Types (re-exported) ==== + +type FantasyMessage = fantasy.Message +type FantasyUsage = fantasy.Usage +type FantasyResponse = fantasy.Response + +// ==== Constructor & Helper Functions ==== + +var ( + NewSession = session.NewSession + NewSessionManager = session.NewManager + ListSessions = session.ListSessions + ListAllSessions = session.ListAllSessions + ParseModelString = models.ParseModelString + CreateProvider = models.CreateProvider + GetGlobalRegistry = models.GetGlobalRegistry + LoadSystemPrompt = config.LoadSystemPrompt +) + +// ==== Conversion Helpers ==== + +func ConvertToFantasyMessages(msg *Message) []fantasy.Message { + return msg.ToFantasyMessages() +} + +func ConvertFromFantasyMessage(msg fantasy.Message) Message { + return message.FromFantasyMessage(msg) +} +``` + +### Step 2: App-as-Consumer — Migrate `cmd/` to use SDK types + +After this plan, start migrating `cmd/` callers to use `kit.*` types. Since these are aliases, this is purely cosmetic and zero-cost, but it establishes the pattern: + +**Example in `cmd/setup.go`**: +```go +// Before: +import "github.com/mark3labs/kit/internal/models" +cfg := &models.ProviderConfig{...} + +// After (preferred, gradual migration): +import kit "github.com/mark3labs/kit/pkg/kit" +cfg := &kit.ProviderConfig{...} +``` + +This is not blocking — both work simultaneously due to Go type aliases. + +### Step 3: Write a compilation test + +**File**: `pkg/kit/types_test.go` + +```go +package kit_test + +import ( + "testing" + kit "github.com/mark3labs/kit/pkg/kit" +) + +func TestTypeExports(t *testing.T) { + if kit.RoleUser != "user" { t.Error("RoleUser") } + if kit.RoleAssistant != "assistant" { t.Error("RoleAssistant") } + + msg := kit.Message{ + Role: kit.RoleUser, + Parts: []kit.ContentPart{ + kit.TextContent{Text: "hello"}, + }, + } + if msg.Content() != "hello" { t.Error("message content") } + + s := kit.NewSession() + if s == nil { t.Error("NewSession") } +} +``` + +### Step 4: Verify + +```bash +go build -o output/kit ./cmd/kit +go test -race ./... +go vet ./... +``` + +## Files Changed Summary + +| Action | File | Change | +|--------|------|--------| +| EDIT | `pkg/kit/types.go` | Add ~40 type aliases, constants, constructors | +| CREATE | `pkg/kit/types_test.go` | Compilation test | + +## Verification Checklist + +- [ ] `go build -o output/kit ./cmd/kit` succeeds +- [ ] `go test -race ./...` passes +- [ ] No circular import errors +- [ ] Type aliases are interchangeable with internal types +- [ ] `cmd/` can import and use `kit.*` types alongside internal types diff --git a/plans/03-event-subscriber-system.md b/plans/03-event-subscriber-system.md new file mode 100644 index 00000000..f03485cf --- /dev/null +++ b/plans/03-event-subscriber-system.md @@ -0,0 +1,308 @@ +# Plan 03: Event/Subscriber System + +**Priority**: P1 +**Effort**: High +**Goal**: Create a unified event system in the SDK that replaces the three parallel event systems currently in the codebase + +## Background + +Kit currently has **three separate event systems** that overlap: + +1. **SDK callbacks** (`sdk/kit.go`) — 3 function pointers on `PromptWithCallbacks` +2. **Extension events** (`internal/extensions/events.go`) — 13 typed events dispatched via `Runner.Emit()` +3. **App/TUI events** (`internal/app/events.go`) — 13 `tea.Msg` structs for BubbleTea UI updates + +Pi uses a single `session.subscribe(listener)` pattern. This plan creates a unified event system in `pkg/kit/` that: +- Replaces SDK callbacks +- Becomes the canonical event layer that extensions and the app emit through +- The TUI adapts SDK events into `tea.Msg` for rendering (TUI-specific concern stays in `internal/ui/`) + +## Prerequisites + +- Plan 00 (Create `pkg/kit/`) +- Plan 02 (Richer type exports) + +## Design Decisions + +1. **Single source of truth** — events are defined in `pkg/kit/`, not scattered across packages +2. **Multiple subscribers** supported with unsubscribe +3. **Thread-safe** emission +4. **App subscribes to SDK events** — the TUI layer adapts them to `tea.Msg` +5. **Extensions emit through SDK** — the extension runner emits SDK events, not its own types + +## Step-by-Step + +### Step 1: Define public event types + +**File**: `pkg/kit/events.go` (new) + +```go +package kit + +import "sync" + +// EventType identifies the kind of event. +type EventType string + +const ( + EventTurnStart EventType = "turn_start" + EventTurnEnd EventType = "turn_end" + EventMessageStart EventType = "message_start" + EventMessageUpdate EventType = "message_update" + EventMessageEnd EventType = "message_end" + EventToolCall EventType = "tool_call" + EventToolExecutionStart EventType = "tool_execution_start" + EventToolExecutionEnd EventType = "tool_execution_end" + EventToolResult EventType = "tool_result" + EventToolCallContent EventType = "tool_call_content" + EventResponse EventType = "response" + EventSessionStart EventType = "session_start" + EventSessionShutdown EventType = "session_shutdown" +) + +// Event is the interface for all event types. +type Event interface { + EventType() EventType +} +``` + +### Step 2: Define concrete event structs + +These cover the union of all three current event systems: + +```go +type TurnStartEvent struct{ Prompt string } +func (e TurnStartEvent) EventType() EventType { return EventTurnStart } + +type TurnEndEvent struct{ Response string; Error error } +func (e TurnEndEvent) EventType() EventType { return EventTurnEnd } + +type MessageStartEvent struct{} +func (e MessageStartEvent) EventType() EventType { return EventMessageStart } + +type MessageUpdateEvent struct{ Chunk string } +func (e MessageUpdateEvent) EventType() EventType { return EventMessageUpdate } + +type MessageEndEvent struct{ Content string } +func (e MessageEndEvent) EventType() EventType { return EventMessageEnd } + +type ToolCallEvent struct{ ToolName string; ToolArgs string } +func (e ToolCallEvent) EventType() EventType { return EventToolCall } + +type ToolExecutionStartEvent struct{ ToolName string } +func (e ToolExecutionStartEvent) EventType() EventType { return EventToolExecutionStart } + +type ToolExecutionEndEvent struct{ ToolName string } +func (e ToolExecutionEndEvent) EventType() EventType { return EventToolExecutionEnd } + +type ToolResultEvent struct{ ToolName, ToolArgs, Result string; IsError bool } +func (e ToolResultEvent) EventType() EventType { return EventToolResult } + +type ToolCallContentEvent struct{ Content string } +func (e ToolCallContentEvent) EventType() EventType { return EventToolCallContent } + +type ResponseEvent struct{ Content string } +func (e ResponseEvent) EventType() EventType { return EventResponse } +``` + +### Step 3: Implement EventBus + +```go +type EventListener func(event Event) + +type eventBus struct { + mu sync.RWMutex + listeners map[int]EventListener + nextID int +} + +func newEventBus() *eventBus { + return &eventBus{listeners: make(map[int]EventListener)} +} + +func (eb *eventBus) subscribe(listener EventListener) func() { + eb.mu.Lock() + id := eb.nextID + eb.nextID++ + eb.listeners[id] = listener + eb.mu.Unlock() + return func() { + eb.mu.Lock() + delete(eb.listeners, id) + eb.mu.Unlock() + } +} + +func (eb *eventBus) emit(event Event) { + eb.mu.RLock() + snapshot := make([]EventListener, 0, len(eb.listeners)) + for _, l := range eb.listeners { + snapshot = append(snapshot, l) + } + eb.mu.RUnlock() + for _, l := range snapshot { + l(event) + } +} +``` + +### Step 4: Wire EventBus into Kit struct + +**File**: `pkg/kit/kit.go` + +```go +type Kit struct { + agent *agent.Agent + sessionMgr *session.Manager + modelString string + events *eventBus +} + +func (m *Kit) Subscribe(listener EventListener) func() { + return m.events.subscribe(listener) +} +``` + +### Step 5: Wire all agent callbacks to emit events + +Update `Prompt()` and `PromptWithCallbacks()` to emit events at every stage of the agent generation flow. Events fire at these points (matching the lifecycle in `internal/app/app.go:364-520`): + +1. Before generation: `TurnStartEvent`, `MessageStartEvent` +2. During streaming: `MessageUpdateEvent` per chunk +3. On tool call: `ToolCallEvent`, `ToolExecutionStartEvent` +4. On tool result: `ToolExecutionEndEvent`, `ToolResultEvent` +5. On response: `ResponseEvent` +6. After generation: `MessageEndEvent`, `TurnEndEvent` + +Extract shared callback helpers to avoid duplication: + +```go +func (m *Kit) makeToolCallHandler() agent.ToolCallHandler { + return func(name, args string) { + m.events.emit(ToolCallEvent{ToolName: name, ToolArgs: args}) + } +} +// ... similar for all callback types +``` + +### Step 6: App-as-Consumer — TUI subscribes to SDK events + +This is the critical refactor. Currently `internal/app/app.go:executeStep()` emits TUI events directly via `sendFn(StreamChunkEvent{...})`. After this change: + +1. The SDK's `Prompt()` emits SDK events +2. The app subscribes to SDK events and converts them to `tea.Msg` + +**File**: `internal/app/app.go` (migration pattern) + +```go +// In App initialization, subscribe to SDK events and bridge to TUI +func (a *App) setupEventBridge() { + a.kit.Subscribe(func(e kit.Event) { + switch ev := e.(type) { + case kit.MessageUpdateEvent: + a.sendToTUI(StreamChunkEvent{Content: ev.Chunk}) + case kit.ToolCallEvent: + a.sendToTUI(ToolCallStartedEvent{ToolName: ev.ToolName, ToolArgs: ev.ToolArgs}) + case kit.ToolResultEvent: + a.sendToTUI(ToolResultEvent{ + ToolName: ev.ToolName, ToolArgs: ev.ToolArgs, + Result: ev.Result, IsError: ev.IsError, + }) + case kit.ResponseEvent: + a.sendToTUI(ResponseCompleteEvent{Content: ev.Content}) + // ... etc + } + }) +} +``` + +**Migration steps**: +1. First: app subscribes to SDK events AND keeps its own emission (dual-emit phase) +2. Then: remove direct emission from `executeStep()`, rely solely on SDK events +3. Finally: remove `internal/app/events.go` types that are now redundant + +### Step 7: Extension events bridge to SDK events + +The extension `Runner` should emit through the SDK event bus rather than its own parallel system. This can be bridged: + +```go +// In Kit initialization, bridge extension events to SDK events +func (m *Kit) bridgeExtensionEvents(runner *extensions.Runner) { + // When extensions emit events, forward them as SDK events + // This is done by having the Runner call back into the SDK + runner.SetEventForwarder(func(event extensions.Event) { + switch e := event.(type) { + case extensions.ToolCallEvent: + m.events.emit(ToolCallEvent{ToolName: e.ToolName, ToolArgs: e.Input}) + // ... etc + } + }) +} +``` + +**Note**: This is a gradual migration. The extension Runner keeps its typed events for Yaegi compatibility, but forwards them to the SDK bus. Eventually the extension system could be refactored to emit SDK events natively. + +### Step 8: Typed convenience subscribers + +```go +func (m *Kit) OnToolCall(handler func(ToolCallEvent)) func() { + return m.Subscribe(func(e Event) { + if tc, ok := e.(ToolCallEvent); ok { handler(tc) } + }) +} + +func (m *Kit) OnToolResult(handler func(ToolResultEvent)) func() { + return m.Subscribe(func(e Event) { + if tr, ok := e.(ToolResultEvent); ok { handler(tr) } + }) +} + +func (m *Kit) OnStreaming(handler func(MessageUpdateEvent)) func() { + return m.Subscribe(func(e Event) { + if mu, ok := e.(MessageUpdateEvent); ok { handler(mu) } + }) +} +``` + +### Step 9: Write tests and verify + +```bash +go build -o output/kit ./cmd/kit +go test -race ./... +go vet ./... +``` + +## Files Changed Summary + +| Action | File | Change | +|--------|------|--------| +| CREATE | `pkg/kit/events.go` | Event types, EventBus, Subscribe() | +| EDIT | `pkg/kit/kit.go` | Add eventBus field, Subscribe(), callback helpers | +| EDIT | `internal/app/app.go` | Subscribe to SDK events (gradual migration) | +| EDIT | `internal/extensions/runner.go` | Optional: event forwarding to SDK bus | + +## Event Flow After This Plan + +``` +Agent.GenerateWithLoopAndStreaming() + ↓ fantasy callbacks +pkg/kit/kit.go (SDK Prompt method) + ↓ emits SDK events +EventBus + ↓ dispatches to all subscribers + ├── External SDK user's listener + ├── App TUI bridge → tea.Msg → BubbleTea Update() + └── Extension bridge → Runner.Emit() → Yaegi handlers +``` + +**Single source of truth**: The SDK EventBus is the only event dispatcher. + +## Verification Checklist + +- [ ] `go build -o output/kit ./cmd/kit` succeeds +- [ ] `go test -race ./...` passes +- [ ] Events fire in correct order: TurnStart → MessageStart → updates → ToolCall → ToolResult → MessageEnd → TurnEnd +- [ ] Multiple subscribers receive all events +- [ ] Unsubscribe removes listener +- [ ] App TUI still renders correctly via event bridge +- [ ] Thread-safe under concurrent calls diff --git a/plans/04-enhanced-session-management.md b/plans/04-enhanced-session-management.md new file mode 100644 index 00000000..e17cb7b7 --- /dev/null +++ b/plans/04-enhanced-session-management.md @@ -0,0 +1,298 @@ +# Plan 04: Enhanced Session Management + +**Priority**: P1 +**Effort**: High +**Goal**: Expose session management in the SDK; CLI session flags map to SDK options + +## Background + +Kit has rich session infrastructure internally (`store.go`, `tree_manager.go`) but none of it is in the SDK. The CLI handles sessions in `cmd/root.go:479-557` with flags like `--continue`, `--resume`, `--session`, `--no-session`. After this plan, both the CLI and external users configure sessions through `kit.Options`. + +## Prerequisites + +- Plan 00 (Create `pkg/kit/`) +- Plan 02 (Richer type exports) + +## Key Principle + +The CLI should NOT have its own session setup logic. Instead: +1. CLI parses `--continue`, `--session`, etc. into `kit.Options` fields +2. `kit.New()` handles all session initialization +3. The CLI gets back a `*Kit` with the session already configured + +## Step-by-Step + +### Step 1: Add session options to Kit Options + +**File**: `pkg/kit/kit.go` + +```go +type Options struct { + // ... existing fields (Model, SystemPrompt, ConfigFile, etc.) ... + + // Session configuration + SessionDir string // Base directory for session discovery (default: cwd) + SessionPath string // Open a specific session file + Continue bool // Continue most recent session for SessionDir + NoSession bool // Ephemeral mode — no persistence +} +``` + +### Step 2: Add tree session to Kit struct + +```go +type Kit struct { + agent *agent.Agent + sessionMgr *session.Manager + treeSession *session.TreeManager + modelString string + events *eventBus +} +``` + +### Step 3: Initialize tree session in New() + +```go +func New(ctx context.Context, opts *Options) (*Kit, error) { + // ... existing config + agent setup ... + + cwd, _ := os.Getwd() + sessionDir := cwd + if opts != nil && opts.SessionDir != "" { + sessionDir = opts.SessionDir + } + + var treeSession *session.TreeManager + if opts != nil && opts.NoSession { + treeSession = session.InMemoryTreeSession(sessionDir) + } else if opts != nil && opts.Continue { + ts, err := session.ContinueRecent(sessionDir) + if err != nil { + ts, err = session.CreateTreeSession(sessionDir) + if err != nil { + return nil, fmt.Errorf("failed to create session: %w", err) + } + } + treeSession = ts + } else if opts != nil && opts.SessionPath != "" { + ts, err := session.OpenTreeSession(opts.SessionPath) + if err != nil { + return nil, fmt.Errorf("failed to open session: %w", err) + } + treeSession = ts + } else { + ts, err := session.CreateTreeSession(sessionDir) + if err != nil { + return nil, fmt.Errorf("failed to create session: %w", err) + } + treeSession = ts + } + + return &Kit{ + agent: setupResult.Agent, + sessionMgr: sessionMgr, + treeSession: treeSession, + modelString: modelString, + events: newEventBus(), + }, nil +} +``` + +### Step 4: Wire Prompt() to use tree session + +```go +func (m *Kit) Prompt(ctx context.Context, message string) (string, error) { + var messages []fantasy.Message + if m.treeSession != nil { + msgs, _, _ := m.treeSession.BuildContext() + messages = msgs + } else { + messages = m.sessionMgr.GetMessages() + } + + // ... generation ... + + // Persist to tree session + if m.treeSession != nil { + m.treeSession.AppendFantasyMessage(userMsg) + for _, msg := range result.Messages { + m.treeSession.AppendMessage(msg) + } + } + + // Keep legacy manager in sync + _ = m.sessionMgr.ReplaceAllMessages(result.ConversationMessages) + return response, nil +} +``` + +### Step 5: Add session management methods + +**File**: `pkg/kit/sessions.go` (new) + +```go +package kit + +import ( + "fmt" + "os" + "github.com/mark3labs/kit/internal/session" +) + +// Package-level session operations (don't require a Kit instance) + +func ListSessions(dir string) ([]SessionInfo, error) { + if dir == "" { + var err error + dir, err = os.Getwd() + if err != nil { return nil, err } + } + return session.ListSessions(dir) +} + +func ListAllSessions() ([]SessionInfo, error) { + return session.ListAllSessions() +} + +func DeleteSession(path string) error { + return session.DeleteSession(path) +} + +// Instance methods + +func (m *Kit) GetTreeSession() *TreeManager { return m.treeSession } + +func (m *Kit) GetSessionPath() string { + if m.treeSession != nil { return m.treeSession.GetFilePath() } + return "" +} + +func (m *Kit) GetSessionID() string { + if m.treeSession != nil { return m.treeSession.GetSessionID() } + return "" +} + +func (m *Kit) Branch(entryID string) error { + if m.treeSession == nil { + return fmt.Errorf("branching requires tree session") + } + m.treeSession.Branch(entryID) + msgs, _, _ := m.treeSession.BuildContext() + return m.sessionMgr.ReplaceAllMessages(msgs) +} + +func (m *Kit) SetSessionName(name string) error { + if m.treeSession == nil { + return fmt.Errorf("session naming requires tree session") + } + m.treeSession.AppendSessionInfo(name) + return nil +} + +func (m *Kit) ClearSession() { + m.sessionMgr = session.NewManager("") + if m.treeSession != nil { + m.treeSession.ResetLeaf() + } +} +``` + +### Step 6: App-as-Consumer — CLI delegates session setup to SDK + +This is the critical step. Currently `cmd/root.go:479-557` has its own session setup logic with if/else chains for each flag. Replace it with `kit.Options`: + +**File**: `cmd/root.go` (migration) + +```go +// Before (cmd/root.go:479-557): +// Complex if/else chain checking noSessionFlag, continueFlag, resumeFlag, sessionPath + +// After: +import kit "github.com/mark3labs/kit/pkg/kit" + +func buildKitOptions() *kit.Options { + opts := &kit.Options{ + Model: modelFlag, + ConfigFile: configFile, + Quiet: quietFlag, + } + + // Map CLI flags to SDK options + if noSessionFlag { + opts.NoSession = true + } else if continueFlag { + opts.Continue = true + } else if sessionPath != "" { + opts.SessionPath = sessionPath + } + // resumeFlag: handled by listing sessions then picking one + // (call kit.ListSessions first, then set opts.SessionPath) + + return opts +} + +// The Kit instance handles all session init internally: +k, err := kit.New(ctx, buildKitOptions()) +``` + +**For --resume** (currently half-implemented with a TODO for TUI picker): +```go +if resumeFlag { + sessions, err := kit.ListSessions("") + if err != nil || len(sessions) == 0 { + // Fall back to new session + } else { + // TODO: Show TUI picker. For now, pick most recent. + opts.SessionPath = sessions[0].Path + } +} +``` + +### Step 7: App uses Kit's session instead of creating its own TreeManager + +Currently `internal/app/app.go` receives a `TreeSession` via its `Options`. After migration, the app receives a `*Kit` instance and uses its tree session: + +```go +// Before: +type Options struct { + TreeSession *session.TreeManager + // ... +} + +// After (gradual): +type Options struct { + Kit *kit.Kit // The SDK instance + // ... +} + +// App gets messages: +msgs := a.opts.Kit.GetTreeSession().GetFantasyMessages() +``` + +### Step 8: Verify + +```bash +go build -o output/kit ./cmd/kit +go test -race ./... +go vet ./... +``` + +## Files Changed Summary + +| Action | File | Change | +|--------|------|--------| +| EDIT | `pkg/kit/kit.go` | Add treeSession, session Options fields, wire Prompt | +| CREATE | `pkg/kit/sessions.go` | ListSessions, Branch, SetSessionName, etc. | +| EDIT | `cmd/root.go` | Replace session setup logic with kit.Options mapping | +| EDIT | `internal/app/app.go` | Accept Kit instance for session access (gradual) | + +## Verification Checklist + +- [ ] `go build -o output/kit ./cmd/kit` succeeds +- [ ] `go test -race ./...` passes +- [ ] `kit.New(ctx, &kit.Options{Continue: true})` resumes recent session +- [ ] `kit.New(ctx, &kit.Options{NoSession: true})` creates ephemeral session +- [ ] `kit.ListSessions("")` returns sessions +- [ ] CLI `--continue` flag maps to `kit.Options{Continue: true}` +- [ ] CLI `--no-session` flag maps to `kit.Options{NoSession: true}` +- [ ] CLI no longer has its own session initialization logic diff --git a/plans/05-additional-prompt-modes.md b/plans/05-additional-prompt-modes.md new file mode 100644 index 00000000..9b9d25bc --- /dev/null +++ b/plans/05-additional-prompt-modes.md @@ -0,0 +1,276 @@ +# Plan 05: Additional Prompt Modes + +**Priority**: P1 +**Effort**: Medium +**Goal**: Add `Steer()`, `FollowUp()`, `PromptWithOptions()` methods; app's `executeStep()` should call SDK methods + +## Background + +Pi has `session.prompt()`, `session.steer()`, `session.followUp()`, `session.compact()`. Kit only has `Prompt()` and `PromptWithCallbacks()`. The Kit CLI app implements its own agent loop in `internal/app/app.go:executeStep()` which duplicates SDK logic. After this plan, both the app and SDK users call the same methods. + +## Prerequisites + +- Plan 00 (Create `pkg/kit/`) +- Plan 03 (Event subscriber system) + +## Step-by-Step + +### Step 1: Extract shared callback helpers + +To avoid duplicating callback wiring across `Prompt`, `Steer`, `FollowUp`, etc., extract internal helpers: + +**File**: `pkg/kit/kit.go` + +```go +func (m *Kit) makeToolCallHandler() agent.ToolCallHandler { + return func(name, args string) { + m.events.emit(ToolCallEvent{ToolName: name, ToolArgs: args}) + } +} + +func (m *Kit) makeToolExecutionHandler() agent.ToolExecutionHandler { + return func(name string, isStarting bool) { + if isStarting { + m.events.emit(ToolExecutionStartEvent{ToolName: name}) + } else { + m.events.emit(ToolExecutionEndEvent{ToolName: name}) + } + } +} + +func (m *Kit) makeToolResultHandler() agent.ToolResultHandler { + return func(name, args, result string, isError bool) { + m.events.emit(ToolResultEvent{ToolName: name, ToolArgs: args, Result: result, IsError: isError}) + } +} + +func (m *Kit) makeResponseHandler() agent.ResponseHandler { + return func(content string) { m.events.emit(ResponseEvent{Content: content}) } +} + +func (m *Kit) makeStreamingHandler() agent.StreamingResponseHandler { + return func(chunk string) { m.events.emit(MessageUpdateEvent{Chunk: chunk}) } +} + +// getMessages retrieves conversation history from the best available source. +func (m *Kit) getMessages() []fantasy.Message { + if m.treeSession != nil { + msgs, _, _ := m.treeSession.BuildContext() + return msgs + } + return m.sessionMgr.GetMessages() +} + +// updateSession persists generation results. +func (m *Kit) updateSession(userMsg fantasy.Message, result *agent.GenerateWithLoopResult) { + if m.treeSession != nil { + m.treeSession.AppendFantasyMessage(userMsg) + for _, msg := range result.Messages { + m.treeSession.AppendMessage(msg) + } + } + _ = m.sessionMgr.ReplaceAllMessages(result.ConversationMessages) +} + +// generate is the shared generation path for all prompt modes. +func (m *Kit) generate(ctx context.Context, messages []fantasy.Message) (*agent.GenerateWithLoopResult, error) { + return m.agent.GenerateWithLoopAndStreaming( + ctx, messages, + m.makeToolCallHandler(), + m.makeToolExecutionHandler(), + m.makeToolResultHandler(), + m.makeResponseHandler(), + nil, // onToolCallContent + m.makeStreamingHandler(), + ) +} +``` + +### Step 2: Refactor Prompt() to use shared helpers + +```go +func (m *Kit) Prompt(ctx context.Context, msg string) (string, error) { + messages := m.getMessages() + userMsg := fantasy.NewUserMessage(msg) + messages = append(messages, userMsg) + + m.events.emit(TurnStartEvent{Prompt: msg}) + m.events.emit(MessageStartEvent{}) + + result, err := m.generate(ctx, messages) + if err != nil { + m.events.emit(TurnEndEvent{Error: err}) + return "", fmt.Errorf("generation failed: %w", err) + } + + m.updateSession(userMsg, result) + response := result.FinalResponse.Content.Text() + m.events.emit(MessageEndEvent{Content: response}) + m.events.emit(TurnEndEvent{Response: response}) + return response, nil +} +``` + +### Step 3: Add Steer() + +```go +// Steer injects a system message and triggers a new agent turn. +// Use for dynamically adjusting behavior without a visible user message. +func (m *Kit) Steer(ctx context.Context, instruction string) (string, error) { + messages := m.getMessages() + sysMsg := fantasy.NewSystemMessage(instruction) + messages = append(messages, sysMsg) + userMsg := fantasy.NewUserMessage("Please acknowledge and follow the above instruction.") + messages = append(messages, userMsg) + + m.events.emit(TurnStartEvent{Prompt: "[steer] " + instruction}) + m.events.emit(MessageStartEvent{}) + + result, err := m.generate(ctx, messages) + if err != nil { + m.events.emit(TurnEndEvent{Error: err}) + return "", fmt.Errorf("steer failed: %w", err) + } + + m.updateSession(userMsg, result) + response := result.FinalResponse.Content.Text() + m.events.emit(MessageEndEvent{Content: response}) + m.events.emit(TurnEndEvent{Response: response}) + return response, nil +} +``` + +### Step 4: Add FollowUp() + +```go +// FollowUp continues the conversation without new user input. +func (m *Kit) FollowUp(ctx context.Context) (string, error) { + messages := m.getMessages() + if len(messages) == 0 { + return "", fmt.Errorf("cannot follow up: no previous messages") + } + userMsg := fantasy.NewUserMessage("Continue.") + messages = append(messages, userMsg) + + m.events.emit(TurnStartEvent{Prompt: "[follow-up]"}) + m.events.emit(MessageStartEvent{}) + + result, err := m.generate(ctx, messages) + if err != nil { + m.events.emit(TurnEndEvent{Error: err}) + return "", fmt.Errorf("follow-up failed: %w", err) + } + + m.updateSession(userMsg, result) + response := result.FinalResponse.Content.Text() + m.events.emit(MessageEndEvent{Content: response}) + m.events.emit(TurnEndEvent{Response: response}) + return response, nil +} +``` + +### Step 5: Add PromptWithOptions() + +```go +type PromptOptions struct { + SystemMessage string // Injected before the prompt + MaxSteps int // Override max steps for this call (0 = default) +} + +func (m *Kit) PromptWithOptions(ctx context.Context, msg string, opts PromptOptions) (string, error) { + messages := m.getMessages() + if opts.SystemMessage != "" { + messages = append(messages, fantasy.NewSystemMessage(opts.SystemMessage)) + } + userMsg := fantasy.NewUserMessage(msg) + messages = append(messages, userMsg) + + m.events.emit(TurnStartEvent{Prompt: msg}) + m.events.emit(MessageStartEvent{}) + + result, err := m.generate(ctx, messages) + if err != nil { + m.events.emit(TurnEndEvent{Error: err}) + return "", fmt.Errorf("generation failed: %w", err) + } + + m.updateSession(userMsg, result) + response := result.FinalResponse.Content.Text() + m.events.emit(MessageEndEvent{Content: response}) + m.events.emit(TurnEndEvent{Response: response}) + return response, nil +} +``` + +### Step 6: App-as-Consumer — Refactor `executeStep()` to use SDK + +Currently `internal/app/app.go:executeStep()` (lines 364-520) contains a full agent loop with extension events, message building, and session persistence. It should be replaced by SDK method calls. + +**File**: `internal/app/app.go` (migration) + +```go +// Before: 150+ lines of agent loop logic in executeStep() + +// After: executeStep delegates to the Kit SDK +func (a *App) executeStep(ctx context.Context, prompt string, sendFn func(tea.Msg)) (*agent.GenerateWithLoopResult, error) { + // Extension Input hook (stays in app — it's a pre-SDK concern) + if a.opts.Extensions != nil && a.opts.Extensions.HasHandlers(extensions.Input) { + result, _ := a.opts.Extensions.Emit(extensions.InputEvent{Text: prompt}) + if r, ok := result.(extensions.InputResult); ok && r.Action == "handled" { + return nil, nil + } + if r, ok := result.(extensions.InputResult); ok && r.Text != "" { + prompt = r.Text + } + } + + sendFn(SpinnerEvent{Show: true}) + + // Use SDK prompt — events handled by subscriber bridge (Plan 03) + response, err := a.kit.Prompt(ctx, prompt) + if err != nil { + sendFn(StepErrorEvent{Err: err}) + return nil, err + } + + sendFn(SpinnerEvent{Show: false}) + sendFn(StepCompleteEvent{}) + _ = response + + return nil, nil // Result comes through events +} +``` + +**Note**: This is a simplification. The real migration needs to handle: +- Extension `BeforeAgentStart` events (map to Plan 09 hooks) +- Spinner show/hide +- The fact that `executeStep` returns `*GenerateWithLoopResult` for further processing + +The migration is gradual: +1. **Phase 1**: App calls `kit.Prompt()` for simple cases +2. **Phase 2**: Extension events bridge through SDK hooks (Plan 09) +3. **Phase 3**: `executeStep()` becomes a thin adapter + +### Step 7: Verify + +```bash +go build -o output/kit ./cmd/kit +go test -race ./... +go vet ./... +``` + +## Files Changed Summary + +| Action | File | Change | +|--------|------|--------| +| EDIT | `pkg/kit/kit.go` | Steer(), FollowUp(), PromptWithOptions(), shared helpers | +| EDIT | `internal/app/app.go` | Gradual migration of executeStep to use SDK | + +## Verification Checklist + +- [ ] `Steer()` injects system message and triggers response +- [ ] `FollowUp()` continues without user message +- [ ] `PromptWithOptions()` accepts per-call system message +- [ ] All methods emit events via EventBus +- [ ] Shared helpers eliminate callback duplication +- [ ] App's `executeStep()` uses SDK (at least for simple paths) diff --git a/plans/06-auth-model-management.md b/plans/06-auth-model-management.md new file mode 100644 index 00000000..bf296511 --- /dev/null +++ b/plans/06-auth-model-management.md @@ -0,0 +1,192 @@ +# Plan 06: Auth & Model Management APIs + +**Priority**: P2 +**Effort**: Medium +**Goal**: Expose provider management, model validation, and API key handling in the SDK; CLI auth commands consume SDK APIs + +## Background + +Pi exports `AuthStorage`, `ModelRegistry`, `SettingsManager` for programmatic auth/model management. Kit has this internally (`internal/models/registry.go`, `internal/auth/credentials.go`, `internal/models/providers.go`) but none is exposed through the SDK. + +## Prerequisites + +- Plan 00 (Create `pkg/kit/`) +- Plan 02 (Richer type exports) + +## Step-by-Step + +### Step 1: Export model registry functions + +**File**: `pkg/kit/models.go` (new) + +```go +package kit + +import ( + "fmt" + "github.com/mark3labs/kit/internal/models" +) + +// LookupModel returns information about a model, or nil if unknown. +func LookupModel(provider, modelID string) *ModelInfo { + return models.GetGlobalRegistry().LookupModel(provider, modelID) +} + +// GetSupportedProviders returns all known provider names. +func GetSupportedProviders() []string { + return models.GetGlobalRegistry().GetSupportedProviders() +} + +// GetModelsForProvider returns all known models for a provider. +func GetModelsForProvider(provider string) (map[string]ModelInfo, error) { + return models.GetGlobalRegistry().GetModelsForProvider(provider) +} + +// GetProviderInfo returns information about a provider (env vars, API URL, etc.). +func GetProviderInfo(provider string) *ProviderInfo { + return models.GetGlobalRegistry().GetProviderInfo(provider) +} + +// ValidateEnvironment checks if required API keys are set for a provider. +func ValidateEnvironment(provider string, apiKey string) error { + return models.GetGlobalRegistry().ValidateEnvironment(provider, apiKey) +} + +// SuggestModels returns model names similar to an invalid model string. +func SuggestModels(provider, invalidModel string) []string { + return models.GetGlobalRegistry().SuggestModels(provider, invalidModel) +} + +// RefreshModelRegistry reloads the model database from models.dev. +func RefreshModelRegistry() { + models.ReloadGlobalRegistry() +} + +// ParseModelString splits a "provider/model" string into components. +func ParseModelString(modelString string) (provider, model string, err error) { + return models.ParseModelString(modelString) +} + +// CheckProviderReady validates that a provider is properly configured. +func CheckProviderReady(provider string) error { + info := models.GetGlobalRegistry().GetProviderInfo(provider) + if info == nil { + return fmt.Errorf("unknown provider: %s", provider) + } + return models.GetGlobalRegistry().ValidateEnvironment(provider, "") +} +``` + +### Step 2: Add model info to Kit instance + +**File**: `pkg/kit/kit.go` + +```go +// GetModel returns the current model string (e.g., "anthropic/claude-sonnet-4-5-20250929"). +func (m *Kit) GetModel() string { + return m.modelString +} + +// GetModelInfo returns detailed information about the current model. +// Returns nil if the model is not in the registry. +func (m *Kit) GetModelInfo() *ModelInfo { + provider, modelID, err := models.ParseModelString(m.modelString) + if err != nil { + return nil + } + return models.GetGlobalRegistry().LookupModel(provider, modelID) +} +``` + +### Step 3: Export auth credential management + +**File**: `pkg/kit/auth.go` (new) + +```go +package kit + +import "github.com/mark3labs/kit/internal/auth" + +// CredentialManager manages API keys and OAuth credentials. +type CredentialManager = auth.CredentialManager + +// NewCredentialManager creates a credential manager. +func NewCredentialManager() (*CredentialManager, error) { + return auth.NewCredentialManager() +} + +// HasAnthropicCredentials checks if Anthropic credentials are stored. +func HasAnthropicCredentials() bool { + cm, err := auth.NewCredentialManager() + if err != nil { + return false + } + return cm.GetAnthropicCredentials() != nil +} + +// GetAnthropicAPIKey resolves the Anthropic API key using the standard +// resolution order: stored credentials -> ANTHROPIC_API_KEY env var. +func GetAnthropicAPIKey() string { + key, err := auth.GetAnthropicAPIKey("") + if err != nil { + return "" + } + return key +} +``` + +### Step 4: App-as-Consumer — CLI commands use SDK APIs + +Currently CLI commands like `kit models`, `kit update-models`, and provider validation logic directly import `internal/models` and `internal/auth`. They should use `pkg/kit` functions instead. + +**File**: `cmd/root.go` or wherever model validation happens + +```go +// Before: +import "github.com/mark3labs/kit/internal/models" +registry := models.GetGlobalRegistry() +info := registry.LookupModel(provider, model) + +// After: +import kit "github.com/mark3labs/kit/pkg/kit" +info := kit.LookupModel(provider, model) +``` + +**File**: `cmd/` auth-related commands + +```go +// Before: +import "github.com/mark3labs/kit/internal/auth" +cm, _ := auth.NewCredentialManager() + +// After: +import kit "github.com/mark3labs/kit/pkg/kit" +cm, _ := kit.NewCredentialManager() +``` + +Since these are type aliases, existing code continues to work during gradual migration. + +### Step 5: Write tests and verify + +```bash +go build -o output/kit ./cmd/kit +go test -race ./... +go vet ./... +``` + +## Files Changed Summary + +| Action | File | Change | +|--------|------|--------| +| CREATE | `pkg/kit/models.go` | Model registry, parsing, validation, suggestions | +| CREATE | `pkg/kit/auth.go` | Credential management exports | +| EDIT | `pkg/kit/kit.go` | Add GetModel(), GetModelInfo() | +| EDIT | `cmd/` | Migrate to use pkg/kit functions (gradual) | + +## Verification Checklist + +- [ ] `ParseModelString` handles "provider/model" format +- [ ] `GetSupportedProviders` returns provider list +- [ ] `LookupModel` returns info for known models +- [ ] `CheckProviderReady` gives clear error messages +- [ ] CLI commands use SDK functions instead of internal imports diff --git a/plans/07-compaction-apis.md b/plans/07-compaction-apis.md new file mode 100644 index 00000000..b358857f --- /dev/null +++ b/plans/07-compaction-apis.md @@ -0,0 +1,166 @@ +# Plan 07: Compaction APIs + +**Priority**: P2 +**Effort**: Medium +**Goal**: Add context window management with token estimation, compaction triggers, and summarization. CLI `--compact` flag should use the SDK. + +## Background + +Pi exports `compact()`, `generateBranchSummary()`, `shouldCompact()`, `calculateContextTokens()`. Kit has no compaction — only `len(text)/4` estimation in `ui/usage_tracker.go:69` for display. This plan adds compaction from scratch, designed SDK-first so the CLI consumes it. + +## Prerequisites + +- Plan 00 (Create `pkg/kit/`) +- Plan 03 (Event subscriber system) +- Plan 04 (Enhanced session management — tree sessions for branch summaries) + +## Step-by-Step + +### Step 1: Create `internal/compaction/` package + +**File**: `internal/compaction/compaction.go` (new) + +```go +package compaction + +// EstimateTokens provides a rough token count (~4 chars per token). +func EstimateTokens(text string) int { + return len(text) / 4 +} + +// EstimateMessageTokens estimates tokens for a slice of fantasy messages. +func EstimateMessageTokens(messages []fantasy.Message) int { ... } + +// ShouldCompact checks if conversation exceeds threshold percentage of limit. +func ShouldCompact(messages []fantasy.Message, contextLimit int, thresholdPct float64) bool { ... } + +// CompactionResult contains statistics from a compaction. +type CompactionResult struct { + Summary string + OriginalTokens int + CompactedTokens int + MessagesRemoved int +} + +// CompactionOptions configures compaction behavior. +type CompactionOptions struct { + ContextLimit int // Model's context window (tokens) + ThresholdPct float64 // Trigger threshold (0.0-1.0), default 0.8 + PreserveRecent int // Recent messages to keep, default 10 + SummaryPrompt string // Custom summary prompt (empty = default) +} + +// FindCutPoint determines where to cut for compaction. +func FindCutPoint(messages []fantasy.Message, preserveRecent int) int { ... } + +// Compact summarizes older messages using the LLM. +func Compact(ctx context.Context, model fantasy.LanguageModel, messages []fantasy.Message, opts CompactionOptions) (*CompactionResult, []fantasy.Message, error) { ... } +``` + +Full implementations as described in the original plan (summarize messages before cut point using LLM, return summary + preserved recent messages). + +### Step 2: Export compaction in SDK + +**File**: `pkg/kit/types.go` — add type aliases: + +```go +type CompactionResult = compaction.CompactionResult +type CompactionOptions = compaction.CompactionOptions +``` + +### Step 3: Add Compact() and context methods to Kit + +**File**: `pkg/kit/kit.go` + +```go +// Compact summarizes older messages to reduce context usage. +func (m *Kit) Compact(ctx context.Context, opts *CompactionOptions) (*CompactionResult, error) { ... } + +// EstimateContextTokens returns estimated token count of current conversation. +func (m *Kit) EstimateContextTokens() int { ... } + +// ShouldCompact checks if conversation is near the context limit. +func (m *Kit) ShouldCompact() bool { ... } + +// ContextStats returns current context usage statistics. +type ContextStats struct { + EstimatedTokens int + ContextLimit int + UsagePercent float64 + MessageCount int +} + +func (m *Kit) GetContextStats() ContextStats { ... } +``` + +### Step 4: Add auto-compaction option + +```go +type Options struct { + // ... existing fields ... + AutoCompact bool // Auto-compact when near limit + CompactionOptions *CompactionOptions // Config for auto-compact +} +``` + +In `Prompt()`, check before generation: +```go +if m.autoCompact && m.ShouldCompact() { + m.Compact(ctx, m.compactionOpts) // best-effort +} +``` + +### Step 5: App-as-Consumer — CLI `--compact` flag uses SDK + +Currently `cmd/root.go` has a `compactMode` flag (line 37) but compaction is not implemented. After this plan: + +**File**: `cmd/root.go` + +```go +// Map --compact flag to SDK option +if compactMode { + kitOpts.AutoCompact = true +} +``` + +The CLI could also expose a `/compact` slash command in interactive mode that calls `kit.Compact()`: + +```go +// In interactive command handler: +case "/compact": + result, err := k.Compact(ctx, nil) + if err != nil { + fmt.Printf("Compaction failed: %v\n", err) + } else { + fmt.Printf("Compacted: %d messages removed, %d -> %d tokens\n", + result.MessagesRemoved, result.OriginalTokens, result.CompactedTokens) + } +``` + +The usage tracker in `internal/ui/usage_tracker.go` should also use `kit.EstimateContextTokens()` instead of its own `len(text)/4` heuristic — single source of truth. + +### Step 6: Write tests and verify + +```bash +go build -o output/kit ./cmd/kit +go test -race ./... +``` + +## Files Changed Summary + +| Action | File | Change | +|--------|------|--------| +| CREATE | `internal/compaction/compaction.go` | Core compaction logic | +| EDIT | `pkg/kit/types.go` | Export CompactionResult, CompactionOptions | +| EDIT | `pkg/kit/kit.go` | Compact(), ShouldCompact(), GetContextStats(), auto-compact | +| EDIT | `cmd/root.go` | Map --compact to SDK option | +| EDIT | `internal/ui/usage_tracker.go` | Use SDK token estimation | + +## Verification Checklist + +- [ ] Token estimation is reasonable +- [ ] `ShouldCompact()` triggers near context limit +- [ ] `Compact()` reduces message count and tokens +- [ ] Auto-compaction triggers before prompts +- [ ] CLI `--compact` flag maps to `kit.Options{AutoCompact: true}` +- [ ] Usage tracker uses SDK estimation diff --git a/plans/08-skills-prompts-system.md b/plans/08-skills-prompts-system.md new file mode 100644 index 00000000..b6eedd68 --- /dev/null +++ b/plans/08-skills-prompts-system.md @@ -0,0 +1,133 @@ +# Plan 08: Skills & Prompts System + +**Priority**: P2 +**Effort**: Medium +**Goal**: Expose skills loading, prompt templates, and dynamic system prompt management. CLI and SDK share the same skills infrastructure. + +## Background + +Pi exports `loadSkills()`, `formatSkillsForPrompt()`, `PromptTemplate`, `expandPromptTemplate()`. Kit has an extension system but no "skills" concept (markdown-based instruction files) or prompt template system. This plan introduces a skills layer designed SDK-first. + +## Prerequisites + +- Plan 00 (Create `pkg/kit/`) +- Plan 02 (Richer type exports) + +## Step-by-Step + +### Step 1: Create `internal/skills/` package + +**File**: `internal/skills/skills.go` — Skill loading and parsing + +```go +type Skill struct { + Name string + Description string + Content string + Path string + Tags []string + When string // "always", "on-demand", "file:*.go" +} + +func LoadSkill(path string) (*Skill, error) { ... } // Markdown with YAML frontmatter +func LoadSkillsFromDir(dir string) ([]*Skill, error) { ... } // .md/.txt files + SKILL.md subdirs +func LoadSkills(cwd string) ([]*Skill, error) { ... } // Auto-discover .kit/skills/ + ~/.config/kit/skills/ +func FormatForPrompt(skills []*Skill) string { ... } // Format for system prompt +``` + +**File**: `internal/skills/templates.go` — Prompt templates + +```go +type PromptTemplate struct { + Name string + Content string + Variables []string +} + +func NewPromptTemplate(name, content string) *PromptTemplate { ... } +func LoadPromptTemplate(path string) (*PromptTemplate, error) { ... } +func (t *PromptTemplate) Expand(values map[string]string) string { ... } +func (t *PromptTemplate) ExpandStrict(values map[string]string) (string, error) { ... } +``` + +**File**: `internal/skills/prompt_builder.go` — System prompt composition + +```go +type PromptBuilder struct { ... } + +func NewPromptBuilder(basePrompt string) *PromptBuilder { ... } +func (pb *PromptBuilder) WithSkills(skills []*Skill) *PromptBuilder { ... } +func (pb *PromptBuilder) WithSection(name, content string) *PromptBuilder { ... } +func (pb *PromptBuilder) Build() string { ... } +``` + +### Step 2: Export in SDK + +**File**: `pkg/kit/skills.go` (new) + +```go +package kit + +import "github.com/mark3labs/kit/internal/skills" + +type Skill = skills.Skill +type PromptTemplate = skills.PromptTemplate +type PromptBuilder = skills.PromptBuilder + +func LoadSkill(path string) (*Skill, error) { return skills.LoadSkill(path) } +func LoadSkillsFromDir(dir string) ([]*Skill, error) { return skills.LoadSkillsFromDir(dir) } +func LoadSkills(cwd string) ([]*Skill, error) { return skills.LoadSkills(cwd) } +func FormatSkillsForPrompt(s []*Skill) string { return skills.FormatForPrompt(s) } +func NewPromptTemplate(name, content string) *PromptTemplate { return skills.NewPromptTemplate(name, content) } +func LoadPromptTemplate(path string) (*PromptTemplate, error) { return skills.LoadPromptTemplate(path) } +func NewPromptBuilder(basePrompt string) *PromptBuilder { return skills.NewPromptBuilder(basePrompt) } +``` + +### Step 3: Integrate skills into Kit Options + +```go +type Options struct { + // ... existing fields ... + Skills []string // Skill files/dirs to load (empty = auto-discover) + SkillsDir string // Override default skills directory +} +``` + +In `New()`, load skills and compose system prompt via `PromptBuilder`. + +### Step 4: App-as-Consumer — CLI uses SDK for skills + +Currently Kit's extension loader (`internal/extensions/loader.go`) discovers extensions from `.kit/extensions/` and `~/.config/kit/extensions/`. The skills system follows the same pattern but for instruction files. + +The CLI should: +1. Use `kit.LoadSkills(cwd)` to discover skills +2. Pass them via `kit.Options{Skills: ...}` or let auto-discovery handle it +3. A `/skills` slash command in interactive mode could list loaded skills + +The existing `.agents/skills/` directory in the repo (used by btca) aligns with this convention. The SDK auto-discovers from `.kit/skills/` to avoid conflict with the `.agents/` convention used by other tools. + +### Step 5: Write tests and verify + +```bash +go build -o output/kit ./cmd/kit +go test -race ./... +``` + +## Files Changed Summary + +| Action | File | Change | +|--------|------|--------| +| CREATE | `internal/skills/skills.go` | Skill loading/parsing | +| CREATE | `internal/skills/templates.go` | PromptTemplate | +| CREATE | `internal/skills/prompt_builder.go` | System prompt composition | +| CREATE | `pkg/kit/skills.go` | Public SDK exports | +| EDIT | `pkg/kit/kit.go` | Skills option, auto-loading | + +## Verification Checklist + +- [ ] Skills with YAML frontmatter parse correctly +- [ ] Skills without frontmatter load (name from filename) +- [ ] PromptTemplate expansion works +- [ ] PromptBuilder composes multi-section prompts +- [ ] Auto-discovery finds skills in standard directories +- [ ] CLI uses SDK for skill loading diff --git a/plans/09-extension-hook-system.md b/plans/09-extension-hook-system.md new file mode 100644 index 00000000..6e693e64 --- /dev/null +++ b/plans/09-extension-hook-system.md @@ -0,0 +1,275 @@ +# Plan 09: Extension Hook System + +**Priority**: P3 +**Effort**: High +**Goal**: Expose Go-native interception hooks in the SDK. The Kit CLI app registers its own extension handlers as SDK hooks, proving the API is complete. + +## Background + +Pi has 20+ lifecycle hooks. Kit already has an internal extension system (`internal/extensions/`) with 13 event types, a `Runner` for dispatch, and tool wrapping. But none of this is accessible through the SDK. + +This plan exposes hooks in the SDK and migrates the app's extension dispatch to use them — making the CLI the proof that the hook API is production-ready. + +## Prerequisites + +- Plan 00 (Create `pkg/kit/`) +- Plan 01 (Export tools — for custom tool registration) +- Plan 02 (Richer type exports) +- Plan 03 (Event subscriber system — observation layer) + +## Design: Events vs Hooks + +| | Events (Plan 03) | Hooks (This Plan) | +|--|------------------|-------------------| +| Purpose | **Observe** | **Intercept** | +| Can block? | No | Yes (BeforeToolCall) | +| Can modify? | No | Yes (AfterToolResult) | +| Pattern | `Subscribe(func(Event))` | `OnBeforeToolCall(func(Hook) *Result)` | +| Priority | N/A | High/Normal/Low ordering | + +Both coexist — events fire regardless; hooks run before/after and can alter execution. + +## Step-by-Step + +### Step 1: Define hook input/result types + +**File**: `pkg/kit/hooks.go` (new) + +```go +package kit + +type HookPriority int + +const ( + HookPriorityHigh HookPriority = 0 + HookPriorityNormal HookPriority = 50 + HookPriorityLow HookPriority = 100 +) + +// BeforeToolCall — can block tool execution +type BeforeToolCallHook struct { + ToolName string + ToolArgs string +} +type BeforeToolCallResult struct { + Block bool + Reason string +} + +// AfterToolResult — can modify tool output +type AfterToolResultHook struct { + ToolName string + ToolArgs string + Result string + IsError bool +} +type AfterToolResultResult struct { + Result *string // non-nil overrides + IsError *bool // non-nil overrides +} + +// BeforeTurn — can modify prompt, inject context +type BeforeTurnHook struct { + Prompt string +} +type BeforeTurnResult struct { + Prompt *string // override prompt + SystemPrompt *string // prepend system message + InjectText *string // prepend user message (context) +} + +// AfterTurn — observe completion +type AfterTurnHook struct { + Response string + Error error +} +``` + +### Step 2: Implement generic hook registry with priority ordering + +```go +type hookRegistry[In any, Out any] struct { + mu sync.RWMutex + hooks []hookEntry[In, Out] + next int +} + +type hookEntry[In any, Out any] struct { + id int + priority HookPriority + handler func(In) *Out +} + +func (hr *hookRegistry[In, Out]) register(p HookPriority, h func(In) *Out) func() { ... } +func (hr *hookRegistry[In, Out]) run(input In) *Out { ... } // first non-nil result wins +``` + +### Step 3: Add registries to Kit struct and expose registration methods + +```go +type Kit struct { + // ... existing fields ... + beforeToolCall *hookRegistry[BeforeToolCallHook, BeforeToolCallResult] + afterToolResult *hookRegistry[AfterToolResultHook, AfterToolResultResult] + beforeTurn *hookRegistry[BeforeTurnHook, BeforeTurnResult] + afterTurn *hookRegistry[AfterTurnHook, struct{}] +} + +func (m *Kit) OnBeforeToolCall(p HookPriority, h func(BeforeToolCallHook) *BeforeToolCallResult) func() { ... } +func (m *Kit) OnAfterToolResult(p HookPriority, h func(AfterToolResultHook) *AfterToolResultResult) func() { ... } +func (m *Kit) OnBeforeTurn(p HookPriority, h func(BeforeTurnHook) *BeforeTurnResult) func() { ... } +func (m *Kit) OnAfterTurn(p HookPriority, h func(AfterTurnHook)) func() { ... } +``` + +### Step 4: Wire hooks into Prompt flow + +In `Prompt()`: +1. Run `beforeTurn` hooks — can modify prompt, inject system/context messages +2. Wrap tools with `hookedTool` that runs `beforeToolCall` (can block) and `afterToolResult` (can modify) +3. Run `afterTurn` hooks after generation + +### Step 5: Tool wrapping via hooks + +```go +type hookedTool struct { + inner fantasy.AgentTool + kit *Kit +} + +func (h *hookedTool) Run(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) { + // 1. BeforeToolCall hook — can block + result := h.kit.beforeToolCall.run(BeforeToolCallHook{...}) + if result != nil && result.Block { return error } + + // 2. Execute actual tool + resp, err := h.inner.Run(ctx, call) + + // 3. AfterToolResult hook — can modify + after := h.kit.afterToolResult.run(AfterToolResultHook{...}) + if after != nil { /* apply overrides */ } + + return resp, err +} +``` + +The hook wrapper composes with the existing extension wrapper: +```go +// Extension wrapper runs first (inner), SDK hooks run outside (outer) +tools = extensionWrapper(tools) // extensions wrap +tools = m.wrapToolsWithHooks(tools) // SDK hooks wrap on top +``` + +### Step 6: App-as-Consumer — Extension system registers as SDK hooks + +This is the payoff step. The app's extension `Runner` currently dispatches events directly in `internal/app/app.go:executeStep()`. After this plan, extensions register as SDK hooks during initialization: + +**File**: `pkg/kit/setup.go` or a new `pkg/kit/extensions_bridge.go` + +```go +// bridgeExtensions registers extension handlers as SDK hooks. +// This makes the extension system a consumer of the SDK hook API. +func (m *Kit) bridgeExtensions(runner *extensions.Runner) { + // Extension BeforeAgentStart → SDK BeforeTurn hook + if runner.HasHandlers(extensions.BeforeAgentStart) { + m.OnBeforeTurn(HookPriorityNormal, func(h BeforeTurnHook) *BeforeTurnResult { + result, _ := runner.Emit(extensions.BeforeAgentStartEvent{Prompt: h.Prompt}) + if r, ok := result.(extensions.BeforeAgentStartResult); ok { + return &BeforeTurnResult{ + SystemPrompt: r.SystemPrompt, + InjectText: r.InjectText, + } + } + return nil + }) + } + + // Extension Input → SDK BeforeTurn hook (higher priority, runs first) + if runner.HasHandlers(extensions.Input) { + m.OnBeforeTurn(HookPriorityHigh, func(h BeforeTurnHook) *BeforeTurnResult { + result, _ := runner.Emit(extensions.InputEvent{Text: h.Prompt}) + if r, ok := result.(extensions.InputResult); ok { + if r.Action == "transform" { + return &BeforeTurnResult{Prompt: &r.Text} + } + } + return nil + }) + } + + // Extension ToolCall → SDK BeforeToolCall hook + // (Already handled by extensions.WrapToolsWithExtensions, but could also + // be bridged here for SDK-only consumers) +} +``` + +Called during `Kit.New()`: +```go +if setupResult.ExtRunner != nil { + k.bridgeExtensions(setupResult.ExtRunner) +} +``` + +**Migration path**: +1. **Phase 1** (this plan): Bridge existing extensions as SDK hooks +2. **Phase 2** (future): `executeStep()` in app.go uses only SDK hooks, removes direct runner calls +3. **Phase 3** (future): Extension runner emits SDK events/hooks natively instead of its own types + +### Step 7: Custom tool registration via Options + +```go +type Options struct { + // ... existing fields ... + ExtraTools []Tool // Additional tools for the agent +} +``` + +### Step 8: Write tests and verify + +```bash +go build -o output/kit ./cmd/kit +go test -race ./... +``` + +## Files Changed Summary + +| Action | File | Change | +|--------|------|--------| +| CREATE | `pkg/kit/hooks.go` | Hook types, registry, registration methods | +| EDIT | `pkg/kit/kit.go` | Hook registries, tool wrapper, Prompt hook invocation | +| CREATE | `pkg/kit/extensions_bridge.go` | Bridge extension events to SDK hooks | +| EDIT | `internal/app/app.go` | Gradual migration to use SDK hooks | + +## API Surface After This Plan + +```go +// Block dangerous tool calls +k.OnBeforeToolCall(kit.HookPriorityHigh, func(h kit.BeforeToolCallHook) *kit.BeforeToolCallResult { + if h.ToolName == "bash" && isDangerous(h.ToolArgs) { + return &kit.BeforeToolCallResult{Block: true, Reason: "dangerous"} + } + return nil +}) + +// Modify tool results +k.OnAfterToolResult(kit.HookPriorityNormal, func(h kit.AfterToolResultHook) *kit.AfterToolResultResult { + sanitized := redact(h.Result) + return &kit.AfterToolResultResult{Result: &sanitized} +}) + +// Inject context before each turn +k.OnBeforeTurn(kit.HookPriorityNormal, func(h kit.BeforeTurnHook) *kit.BeforeTurnResult { + ctx := loadProjectContext() + return &kit.BeforeTurnResult{InjectText: &ctx} +}) +``` + +## Verification Checklist + +- [ ] BeforeToolCall hooks can block tool calls +- [ ] AfterToolResult hooks can modify results +- [ ] BeforeTurn hooks can modify prompts and inject context +- [ ] Priority ordering works correctly +- [ ] Unregister removes hooks +- [ ] Extension system bridges to SDK hooks +- [ ] Hooks compose with existing extension wrapper +- [ ] Thread-safe under concurrent access diff --git a/plans/README.md b/plans/README.md new file mode 100644 index 00000000..427856dc --- /dev/null +++ b/plans/README.md @@ -0,0 +1,100 @@ +# SDK Revamp Plans + +## Core Architectural Principle + +**The Kit CLI app is the primary consumer of the SDK.** + +The SDK is not a thin wrapper for external users. The CLI is built on top of it: + +1. `pkg/kit/` defines the canonical API for agents, sessions, events, and hooks +2. `cmd/` parses CLI flags, maps them to `kit.Options`, and calls `kit.New()` +3. `internal/app/` subscribes to SDK events for TUI rendering and uses SDK prompt methods +4. If the app needs a capability, it is added to the SDK first, then consumed by the app +5. External users get the exact same API the CLI uses + +### Architecture + +``` +cmd/kit/main.go + | + v +cmd/ Parses flags, maps to kit.Options + | + v +pkg/kit/ Canonical SDK: New(), Prompt(), Subscribe(), hooks + | + +---> internal/agent/ Agent creation, generation loop + +---> internal/session/ Session persistence, tree manager + +---> internal/config/ Config loading, MCP server config + +---> internal/core/ Built-in tools (read, write, bash, etc.) + +---> internal/models/ Provider registry, model validation + +---> internal/auth/ Credential management, OAuth + +---> internal/compaction/ Context summarization (Plan 07) + +---> internal/skills/ Skill loading, templates (Plan 08) + +---> internal/extensions/ Yaegi extension runtime + +internal/app/ TUI/interactive mode — subscribes to SDK events + | + +---> pkg/kit/ Uses SDK for prompts, sessions, tools + +---> internal/ui/ Owns BubbleTea rendering only +``` + +**No circular dependencies.** `pkg/kit/` never imports `cmd/`. `cmd/` imports `pkg/kit/`. + +### Before vs After + +| Concern | Before (Parallel) | After (SDK-First) | +|---------|-------------------|-------------------| +| Config init | `cmd.InitConfig()` called by both CLI and SDK | `kit.InitConfig()` in `pkg/kit/`, `cmd/` delegates | +| Agent creation | `cmd.SetupAgent()` called by both | `kit.SetupAgent()` in `pkg/kit/`, `cmd/` delegates | +| Session setup | `cmd/root.go` has 80-line if/else chain | `kit.Options{Continue: true}`, SDK handles it | +| Events | 3 parallel systems (SDK callbacks, extension events, TUI msgs) | Single SDK EventBus, TUI bridges via `Subscribe()` | +| Tool exposure | Internal only | `kit.AllTools()`, `kit.NewReadTool(kit.WithWorkDir(...))` | +| Hooks | Only via Yaegi extensions | `kit.OnBeforeToolCall()` — extensions bridge to SDK hooks | + +## Plan Execution Order + +| Plan | Priority | Description | Depends On | +|------|----------|-------------|------------| +| **00** | P0 | Create `pkg/kit/`, extract init from `cmd/` | None | +| **01** | P0 | Export tools and tool factories | 00 | +| **02** | P0 | Richer type exports (40+ types) | 00 | +| **03** | P1 | Unified event/subscriber system | 00, 02 | +| **04** | P1 | Enhanced session management | 00, 02 | +| **05** | P1 | Additional prompt modes (Steer, FollowUp) | 00, 03 | +| **06** | P2 | Auth & model management APIs | 00, 02 | +| **07** | P2 | Compaction APIs | 00, 03, 04 | +| **08** | P2 | Skills & prompts system | 00, 02 | +| **09** | P3 | Extension hook system | 00, 01, 02, 03 | + +### Recommended Batches + +**Batch 1 — Foundation** (Plans 00, 01, 02): +Restructure package, expose tools and types. SDK is usable for basic programmatic access. CLI starts delegating to SDK. + +**Batch 2 — Rich Interaction** (Plans 03, 04, 05): +Unified events, sessions, prompt modes. App migrates to SDK for event handling and session setup. + +**Batch 3 — Management** (Plans 06, 07, 08): +Auth, compaction, skills. CLI commands use SDK functions. + +**Batch 4 — Extensibility** (Plan 09): +Hook system with extension bridge. App's extension dispatch routes through SDK hooks. + +## Parity with Pi SDK + +After all plans: + +| Capability | Pi | Kit (After) | +|-----------|-----|-------------| +| Top-level package imports | Yes | `pkg/kit/` | +| Tool exports + factories | Yes | Plan 01 | +| Rich type surface (50+) | Yes | Plan 02 | +| Event subscriber system | Yes | Plan 03 | +| Session management (list/continue/branch) | Yes | Plan 04 | +| Multiple prompt modes | Yes | Plan 05 | +| Auth/model management | Yes | Plan 06 | +| Compaction APIs | Yes | Plan 07 | +| Skills/prompts system | Yes | Plan 08 | +| Extension hooks (20+ events) | Yes | Plan 09 | +| App built on SDK | Yes | Gradual across all plans |