Skip to content

Implementation Plan: Phase 1 - Foundation

Based on the RFC (output-1_rfc.md) and PRD (prd.md), this document outlines the step-by-step tasks for Phase 1: Foundation.


Overview

Phase 1 focuses on building the foundational infrastructure before any LLM integration or tool implementation. The key deliverables are:

  1. Project structure and Go module setup
  2. Configuration system with YAML
  3. FileGuard for secure file access
  4. GitAwareness component (CRITICAL) - Respects .gitignore to avoid wasting tokens
  5. Basic CLI structure with Cobra
  6. Structured logging with log/slog

Project Structure

keen-cli/
├── cmd/agent/
│   └── main.go                    # Entry point
├── internal/
│   ├── config/
│   │   ├── config.go              # Config struct and defaults
│   │   └── loader.go              # YAML config loading
│   ├── filesystem/
│   │   ├── guard.go               # FileGuard - path security
│   │   └── gitawareness.go        # GitAwareness - .gitignore handling
│   ├── cli/
│   │   ├── root.go                # Cobra root command
│   │   └── repl.go                # Interactive REPL command (stub)
│   └── logger/
│       └── logger.go              # Structured logging setup
├── configs/
│   └── system_prompts/            # Default system prompts
├── go.mod
├── go.sum
└── README.md

Task 1: Initialize Go Module and Project Structure

Objective: Set up the project foundation and dependencies.

Steps: 1. Run go mod init github.com/user/keen-cli 2. Create directory structure as outlined above 3. Add core dependencies: - github.com/spf13/cobra - CLI framework - gopkg.in/yaml.v3 - YAML marshal/unmarshal - github.com/go-git/go-git/v5 - For .gitignore parsing - github.com/go-git/go-git/v5/plumbing/format/gitignore - Gitignore matcher

Deliverables: - go.mod with all dependencies - Directory structure created - Empty placeholder files to satisfy imports

Testing Strategy: - Verify go build ./... succeeds - Verify all packages compile


Task 2: Implement Config System

Package: internal/config/

Files: - config.go - Config structs, resolution logic, and defaults - loader.go - YAML loading and saving

Two-Level Configuration:

  1. Global Config - Persisted to ~/.config/keen/config.yaml
  2. Set via /provider command in REPL
  3. Contains per-provider settings (model, API key)

  4. Session Config - CLI flag overrides (not persisted)

  5. Set via --provider, --api-key, --model flags
  6. Overrides global config for current session only

Key Components:

// GlobalConfig is persisted to ~/.config/keen/config.yaml
type GlobalConfig struct {
    ActiveProvider string `yaml:"provider" mapstructure:"provider"`

    Anthropic ProviderConfig `yaml:"anthropic"`
    OpenAI    ProviderConfig `yaml:"openai"`
    Gemini    ProviderConfig `yaml:"gemini"`
}

type ProviderConfig struct {
    Model  string `yaml:"model"`
    APIKey string `yaml:"api_key"`
}

// SessionConfig holds CLI flag overrides (not persisted)
type SessionConfig struct {
    Provider string
    APIKey   string
    Model    string
}

// ResolvedConfig is the final merged configuration
type ResolvedConfig struct {
    Provider string
    APIKey   string
    Model    string
}

Resolution Order (Session > Global > Default):

func Resolve(global *GlobalConfig, session *SessionConfig) (*ResolvedConfig, error)
  1. Provider: session.Providerglobal.ActiveProvider → error
  2. API Key: session.APIKeyglobal.GetProviderConfig().APIKey → error
  3. Model: session.Modelglobal.GetProviderConfig().ModeldefaultModel(provider)

Loader:

type Loader struct{}

func NewLoader() *Loader
func (l *Loader) Load() (*GlobalConfig, error)    // Load from ~/.config/keen/config.yaml
func (l *Loader) Save(cfg *GlobalConfig) error    // Save with 0600 permissions
func (l *Loader) Exists() bool                    // Check if config exists

Deliverables: - GlobalConfig, SessionConfig, ResolvedConfig structs - Resolve() function with proper error handling - Loader for YAML persistence - Helper methods: GetProviderConfig(), SetProviderConfig(), ConfigPath() - Unit tests with 80%+ coverage


Task 3: Implement FileGuard

Package: internal/filesystem/

File: guard.go

Purpose: Path security - control file system access with permission-based rules.

Requirements (from PRD):

  1. Working Directory Access:
  2. Read: Allowed by default
  3. Write: Requires explicit user permission

  4. Outside Working Directory:

  5. Read/Write: Requires explicit user permission

  6. Blocked Paths (always denied):

  7. Paths in .gitignore
  8. Sensitive directories: ~/.ssh, /etc, ~/.aws, /usr, etc.
  9. Path traversal attempts (../, ..\)

Interface:

type Permission int

const (
    PermissionDenied Permission = iota
    PermissionGranted
    PermissionPending // Requires user confirmation
)

type Guard struct {
    workingDir   string
    blockedPaths []string
    gitignore    GitAwareness // For checking .gitignore rules
}

func NewGuard(workingDir string, gitignore GitAwareness) *Guard

// CheckPath evaluates if a path is accessible for the given operation
// Returns PermissionGranted, PermissionDenied, or PermissionPending
func (g *Guard) CheckPath(path string, operation string) Permission

// IsBlocked checks if path matches blocked patterns (.gitignore, sensitive dirs)
func (g *Guard) IsBlocked(path string) bool

// ResolvePath returns the absolute, cleaned path
func (g *Guard) ResolvePath(path string) (string, error)

// IsInWorkingDir checks if path is within working directory
func (g *Guard) IsInWorkingDir(path string) bool

Permission Matrix:

Path Location Read Write
Inside working dir Granted Pending
Outside working dir Pending Pending
In .gitignore Denied Denied
Sensitive path Denied Denied

Key Methods:

  1. CheckPath(path, operation) Permission
  2. Check if path is blocked (.gitignore, sensitive dirs)
  3. Check if path is within working directory
  4. Return appropriate permission based on operation and location

  5. IsBlocked(path) bool

  6. Check against .gitignore patterns
  7. Check against sensitive directory list: ~/.ssh, /etc, ~/.aws, /usr
  8. Check for path traversal patterns

  9. IsInWorkingDir(path) bool

  10. Resolve to absolute path
  11. Verify it's within or equals working directory

Testable Design: - Constructor injection of working directory and gitignore checker - No global state - Pure functions for permission logic

Test Cases:

func TestGuard_CheckPath(t *testing.T) {
    tests := []struct {
        name      string
        path      string
        operation string
        want      Permission
    }{
        {"read inside working dir", "main.go", "read", PermissionGranted},
        {"write inside working dir", "main.go", "write", PermissionPending},
        {"read outside working dir", "/tmp/file", "read", PermissionPending},
        {"path traversal", "../etc/passwd", "read", PermissionDenied},
        {"sensitive path", "~/.ssh/id_rsa", "read", PermissionDenied},
    }
    // ... table-driven test
}

Deliverables: - FileGuard implementation with permission-based access control - Integration with GitAwareness for .gitignore checking - Comprehensive unit tests - Default sensitive path blocklist


Task 4: Implement GitAwareness (CRITICAL)

Package: internal/filesystem/

File: gitawareness.go

Purpose: Prevent wasting tokens and confusing the LLM by filtering out files that should be ignored according to .gitignore rules.

Interface:

type GitAwareness interface {
    LoadGitignore(path string) error
    IsIgnored(filePath string) bool
    FilterPaths(paths []string) []string
}

Key Methods:

  1. LoadGitignore(path string) error
  2. Load .gitignore from project root
  3. Recursively load nested .gitignore files from subdirectories
  4. Support global gitignore (~/.gitignore_global)
  5. Cache parsed patterns for performance

  6. IsIgnored(filePath string) bool

  7. Check if path matches any loaded ignore pattern
  8. Respect negation patterns (!important.log)
  9. Handle directory vs file patterns correctly

  10. FilterPaths(paths []string) []string

  11. Filter a list of paths, returning only non-ignored ones
  12. Efficient batch operation

Caching Strategy: - Cache parsed ignore matchers per directory - Cache IsIgnored results for frequently checked paths - Invalidate cache when .gitignore files change

Implementation Notes: - Use github.com/go-git/go-git/v5/plumbing/format/gitignore for pattern matching - Handle edge cases: - Empty .gitignore files - Comments and blank lines - Glob patterns (*.log, node_modules/) - Directory patterns (build/) - Negation patterns (!important.log)

Testable Design: - Interface-based for mocking - Separate parser from matcher logic - In-memory implementation for tests

Test Cases:

func TestGitAwareness(t *testing.T) {
    tests := []struct {
        name     string
        patterns []string
        path     string
        ignored  bool
    }{
        {"node_modules dir", []string{"node_modules/"}, "node_modules/lodash", true},
        {"log files", []string{"*.log"}, "debug.log", true},
        {"nested path", []string{"build/"}, "build/output.js", true},
        {"negation", []string{"*.log", "!important.log"}, "important.log", false},
        {"not ignored", []string{"*.log"}, "main.go", false},
    }
    // ... table-driven test
}

Deliverables: - GitAwareness implementation - Support for nested .gitignore files - Caching for performance - Comprehensive unit tests - Benchmark tests for large path lists


Task 5: Basic CLI Structure

Package: internal/cli/

Files: - root.go - Root command - repl.go - REPL command (initial stub) - setup.go - Interactive config setup flow

Commands to Implement:

  1. Root Command (keen)

    keen                    # Start REPL (interactive setup on first run)
    keen --version          # Show version
    

  2. No Flags - All configuration done via interactive prompts

Interactive Setup Flow (First Run):

When keen is run for the first time (no config exists), user is guided through:

  1. Select Provider (arrow key selection)

    Select a provider:
    > anthropic
      openai
      gemini
    

  2. Enter API Key (password input, hidden)

    Enter API key for anthropic: ****
    

  3. Select Model (arrow key selection, provider-specific)

    Select a model for anthropic:
    > claude-3-sonnet
      claude-3-opus
      claude-3-haiku
    

  4. Save Config to ~/.keen/configs.json

Predefined Provider and Model Lists:

var Providers = []string{"anthropic", "openai", "gemini"}

var ProviderModels = map[string][]string{
    "anthropic": {"claude-3-opus", "claude-3-sonnet", "claude-3-haiku"},
    "openai":    {"gpt-4o", "gpt-4-turbo", "gpt-3.5-turbo"},
    "gemini":    {"gemini-1.5-pro", "gemini-1.5-flash"},
}

Implementation:

Use github.com/charmbracelet/huh for interactive forms:

func RunSetup(loader *config.Loader, global *config.GlobalConfig) error {
    // Step 1: Select provider
    var provider string
    err := huh.NewSelect[string]().
        Title("Select a provider:").
        Options(
            huh.NewOption("anthropic", "anthropic"),
            huh.NewOption("openai", "openai"),
            huh.NewOption("gemini", "gemini"),
        ).
        Value(&provider).
        Run()

    // Step 2: Enter API key
    var apiKey string
    err = huh.NewInput().
        Title("Enter API key for " + provider).
        EchoMode(huh.EchoModePassword).
        Value(&apiKey).
        Run()

    // Step 3: Select model
    var model string
    err = huh.NewSelect[string]().
        Title("Select a model for " + provider).
        Options(getModelOptions(provider)...).
        Value(&model).
        Run()

    // Save config
    global.ActiveProvider = provider
    global.ActiveModel = model
    // ... save provider config with API key and model
    return loader.Save(global)
}

Future /model Command (REPL): - Allows switching provider/model without restarting - Shows existing API key (masked) or prompts if not set - Updates active provider/model in config

Integration: - On startup: Load config - If no provider configured: Run interactive setup - Then: Start REPL with resolved config

Testable Design: - Use Cobra's command testing utilities - Dependency injection for config and prompter - Separate setup logic from command execution

Deliverables: - Working CLI with keen and keen --version - Interactive setup with arrow key selection - Config saved to ~/.keen/configs.json - Basic error handling

Implementation Order

Order Task Depends On Priority
1 Project Structure - Critical
2 Config System - Critical
3 Logger Config High
4 FileGuard Config Critical
5 GitAwareness Config Critical

Rationale: - Config is needed by almost all other components - FileGuard and GitAwareness are independent and can be done in parallel - CLI comes last as it integrates everything


Testing Strategy

Unit Tests

  • Each package should have *_test.go files
  • Target: 80%+ code coverage
  • Use table-driven tests for validation logic
  • Mock interfaces for isolation

Integration Tests

  • Test config loading from multiple sources
  • Test FileGuard with real filesystem (temp dir)
  • Test GitAwareness with sample .gitignore files

Test Structure

internal/
├── config/
│   ├── config.go
│   ├── config_test.go
│   └── loader_test.go
├── filesystem/
│   ├── guard.go
│   ├── guard_test.go
│   ├── gitawareness.go
│   └── gitawareness_test.go

Dependencies to Add

// go.mod requirements:
require (
    github.com/spf13/cobra v1.8.0
    gopkg.in/yaml.v3 v3.0.1
    github.com/go-git/go-git/v5 v5.11.0
)

Standard Library Only: - log/slog - Structured logging - os, os/exec - File operations - path/filepath - Cross-platform paths - testing - Unit tests


Success Criteria for Phase 1

  • [ ] go build ./... succeeds with no errors
  • [ ] All unit tests pass (go test ./...)
  • [ ] CLI shows help and version
  • [ ] Config loads from multiple sources correctly
  • [ ] FileGuard blocks path traversal attempts
  • [ ] GitAwareness correctly filters node_modules, .git, etc.
  • [ ] Logging works at all levels
  • [ ] Code follows Go best practices (gofmt, golint)

Next Steps (Phase 2 Preview)

After Phase 1 foundation is complete, Phase 2 will focus on: - LLM Provider Interface (Anthropic first) - Tool System (read_file, list_dir) - Basic Orchestrator loop