move SDK to pkg/kit, extract shared logic from cmd, relocate main to cmd/kit

Restructure the codebase so the CLI app consumes the SDK rather than
the SDK wrapping CLI internals. This eliminates the circular dependency
(sdk -> cmd -> sdk) and establishes pkg/kit as the canonical API.

Key changes:
- Create pkg/kit/ with InitConfig, SetupAgent, BuildProviderConfig
  extracted from cmd/root.go and cmd/setup.go as parameterized functions
- Move sdk/kit.go -> pkg/kit/kit.go (remove cmd import, use local calls)
- Move sdk/types.go -> pkg/kit/types.go
- Move main.go -> cmd/kit/main.go (standard Go project layout)
- cmd/root.go and cmd/setup.go now delegate to pkg/kit, injecting
  CLI-specific state (quietFlag) via the Quiet field on AgentSetupOptions
- Add setSDKDefaults() for cobra-free SDK usage (viper defaults)
- Fix .gitignore: kit -> /kit (was blocking cmd/kit/ and pkg/kit/)
- Update .goreleaser.yaml, Taskfile.yml, AGENTS.md, contribute/build.sh,
  README.md for new cmd/kit entrypoint and pkg/kit import paths
- Add plans/ with 10 detailed SDK revamp plans and Taskfile.yml
- Delete sdk/ directory entirely
This commit is contained in:
Ed Zynda
2026-02-27 10:42:27 +03:00
parent fb3608326f
commit 626f1105c9
28 changed files with 3191 additions and 321 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
.kit/
aidocs/
*.log
kit
/kit
.idea
test/
build/
+3 -1
View File
@@ -3,7 +3,9 @@ before:
- go mod tidy
builds:
- env:
- id: kit
main: ./cmd/kit
env:
- CGO_ENABLED=0
goos:
- linux
+1 -1
View File
@@ -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 ./...`
+4 -4
View File
@@ -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 ⚙️
+145
View File
@@ -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
View File
+9 -94
View File
@@ -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 {
+16 -165
View File
@@ -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
+1 -1
View File
@@ -3,4 +3,4 @@
RUN_NAME="kit"
mkdir -p output
go build -o output/${RUN_NAME}
go build -o output/${RUN_NAME} ./cmd/kit
+3 -3
View File
@@ -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
+101
View File
@@ -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))
}
@@ -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)
}
@@ -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 {
+18 -34
View File
@@ -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
+6 -6
View File
@@ -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)
}
+171
View File
@@ -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
}
+2 -1
View File
@@ -1,7 +1,8 @@
package sdk
package kit
import (
"charm.land/fantasy"
"github.com/mark3labs/kit/internal/message"
"github.com/mark3labs/kit/internal/session"
)
+503
View File
@@ -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 ./...`)
+253
View File
@@ -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()`
+196
View File
@@ -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
+308
View File
@@ -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
+298
View File
@@ -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
+276
View File
@@ -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)
+192
View File
@@ -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
+166
View File
@@ -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
+133
View File
@@ -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
+275
View File
@@ -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
+100
View File
@@ -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 |