diff --git a/.gitignore b/.gitignore index 34f540d..22b4a20 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,11 @@ # Binary output coding-context coding-context-cli + +# Example binaries +examples/*/basic +examples/*/* +!examples/*/*.go +!examples/*/*.md +examples/*/visitor +examples/*/basic diff --git a/README.md b/README.md index 1efd186..49b58d7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Coding Agent Context CLI -A command-line interface for dynamically assembling context for AI coding agents. +A command-line interface and embeddable library for dynamically assembling context for AI coding agents. This tool collects context from predefined rule files and a task-specific prompt, substitutes parameters, and prints a single, combined context to standard output. This is useful for feeding a large amount of relevant information into an AI model like Claude, Gemini, or OpenAI's GPT series. @@ -13,6 +13,7 @@ This tool collects context from predefined rule files and a task-specific prompt - **Bootstrap Scripts**: Run scripts to fetch or generate context dynamically. - **Parameter Substitution**: Inject values into your task prompts. - **Token Estimation**: Get an estimate of the total token count for the generated context. +- **Embeddable Library**: Use as a Go library in your own applications (see [context package documentation](./context/README.md)). ## Supported Coding Agents @@ -32,6 +33,8 @@ The tool automatically discovers and includes rules from these locations in your ## Installation +### CLI Tool + You can install the CLI by downloading the latest release from the [releases page](https://github.com/kitproj/coding-context-cli/releases) or by building from source. ```bash @@ -40,8 +43,20 @@ sudo curl -fsL -o /usr/local/bin/coding-context-cli https://github.com/kitproj/c sudo chmod +x /usr/local/bin/coding-context-cli ``` +### Go Library + +To use this as a library in your Go application: + +```bash +go get github.com/kitproj/coding-context-cli/context +``` + +See the [context package documentation](./context/README.md) for detailed usage examples. + ## Usage +### Command Line + ``` Usage: coding-context-cli [options] @@ -84,6 +99,46 @@ The `` is the name of the task you want the agent to perform. Here ar Each of these would have a corresponding `.md` file in a `tasks` directory (e.g., `triage-bug.md`). +### Library Usage + +You can also use this tool as a library in your Go applications. Here's a simple example: + +```go +package main + +import ( + "context" + "fmt" + "os" + + ctxlib "github.com/kitproj/coding-context-cli/context" +) + +func main() { + // Create parameters for substitution + params := make(ctxlib.ParamMap) + params["component"] = "auth" + params["issue"] = "login bug" + + // Configure the assembler + config := ctxlib.Config{ + WorkDir: ".", + TaskName: "fix-bug", + Params: params, + } + + // Assemble the context + assembler := ctxlib.NewAssembler(config) + ctx := context.Background() + if err := assembler.Assemble(ctx); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} +``` + +For more detailed library usage examples, see the [context package documentation](./context/README.md). + ## How It Works The tool assembles the context in the following order: diff --git a/context/README.md b/context/README.md new file mode 100644 index 0000000..d67413e --- /dev/null +++ b/context/README.md @@ -0,0 +1,274 @@ +# Context Package + +The `context` package provides a library for dynamically assembling context for AI coding agents. This package can be embedded in other Go applications to programmatically collect and assemble context from various rule files and task prompts. + +## Overview + +The context package extracts the core functionality from the `coding-context-cli` tool, making it reusable as a library. It allows you to: + +- Assemble context from rule files and task prompts +- Filter rules based on frontmatter metadata +- Substitute parameters in task prompts +- Run bootstrap scripts before processing rules +- Estimate token counts for the assembled context + +## Installation + +```bash +go get github.com/kitproj/coding-context-cli/context +``` + +## Quick Start + +Here's a simple example of using the context package: + +```go +package main + +import ( + "context" + "fmt" + "os" + + ctxlib "github.com/kitproj/coding-context-cli/context" +) + +func main() { + // Create parameters for substitution + params := make(ctxlib.ParamMap) + params["component"] = "auth" + params["issue"] = "login bug" + + // Create selectors for filtering rules + selectors := make(ctxlib.SelectorMap) + selectors["language"] = "go" + + // Configure the assembler + config := ctxlib.Config{ + WorkDir: ".", + TaskName: "fix-bug", + Params: params, + Selectors: selectors, + } + + // Create the assembler + assembler := ctxlib.NewAssembler(config) + + // Assemble the context + ctx := context.Background() + if err := assembler.Assemble(ctx); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} +``` + +## Core Types + +### Config + +`Config` holds the configuration for context assembly: + +```go +type Config struct { + // WorkDir is the working directory to use + WorkDir string + + // TaskName is the name of the task to execute + TaskName string + + // Params are parameters for substitution in task prompts + Params ParamMap + + // Selectors are frontmatter selectors for filtering rules + Selectors SelectorMap + + // Stdout is where assembled context is written (defaults to os.Stdout) + Stdout io.Writer + + // Stderr is where progress messages are written (defaults to os.Stderr) + Stderr io.Writer + + // Visitor is called for each selected rule (defaults to DefaultRuleVisitor) + Visitor RuleVisitor +} +``` + +### Assembler + +`Assembler` assembles context from rule and task files: + +```go +// Create a new assembler +assembler := context.NewAssembler(config) + +// Assemble the context +err := assembler.Assemble(ctx) +``` + +### ParamMap + +`ParamMap` represents a map of parameters for substitution in task prompts: + +```go +params := make(context.ParamMap) +params["key"] = "value" + +// Or use the Set method (useful for flag parsing) +params.Set("key=value") +``` + +### SelectorMap + +`SelectorMap` is used for filtering rules based on frontmatter metadata: + +```go +selectors := make(context.SelectorMap) +selectors["language"] = "go" +selectors["environment"] = "production" + +// Or use the Set method (useful for flag parsing) +selectors.Set("language=go") +``` + +## Utility Functions + +### ParseMarkdownFile + +Parse a markdown file with YAML frontmatter: + +```go +var frontmatter map[string]string +content, err := context.ParseMarkdownFile("path/to/file.md", &frontmatter) +``` + +### EstimateTokens + +Estimate the number of LLM tokens in text: + +```go +tokens := context.EstimateTokens("This is some text") +``` + +### RuleVisitor (Visitor Pattern) + +The `RuleVisitor` interface allows you to customize how rules are processed as they are selected. This enables advanced use cases like logging, filtering, transformation, or custom output formatting. + +```go +type RuleVisitor interface { + // VisitRule is called for each rule that matches the selection criteria + VisitRule(ctx context.Context, rule *Document) error +} +``` + +The `Document` type represents both rules and tasks (they share the same structure): + +```go +type Frontmatter map[string]string + +type Document struct { + Path string // Absolute path to the file + Content string // Parsed content (without frontmatter) + Frontmatter Frontmatter // YAML frontmatter metadata + Tokens int // Estimated token count +} +``` + +**Example: Custom Visitor** + +```go +// CustomVisitor collects rule metadata +type CustomVisitor struct { + Rules []*context.Document +} + +func (v *CustomVisitor) VisitRule(ctx context.Context, rule *context.Document) error { + // Custom processing logic + v.Rules = append(v.Rules, rule) + fmt.Printf("Processing rule: %s (%d tokens)\n", rule.Path, rule.Tokens) + return nil +} + +// Use the custom visitor +visitor := &CustomVisitor{} +assembler := context.NewAssembler(context.Config{ + WorkDir: ".", + TaskName: "my-task", + Visitor: visitor, +}) +``` + +By default, the `DefaultRuleVisitor` is used, which writes rule content to stdout and logs progress to stderr. + +## Advanced Usage + +### Custom Output Writers + +You can redirect the output to custom writers: + +```go +var stdout, stderr bytes.Buffer + +config := context.Config{ + WorkDir: ".", + TaskName: "my-task", + Params: make(context.ParamMap), + Selectors: make(context.SelectorMap), + Stdout: &stdout, // Assembled context goes here + Stderr: &stderr, // Progress messages go here +} + +assembler := context.NewAssembler(config) +err := assembler.Assemble(ctx) + +// Now you can process the output +contextContent := stdout.String() +progressMessages := stderr.String() +``` + +### Context Cancellation + +The `Assemble` method respects context cancellation: + +```go +ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) +defer cancel() + +assembler := context.NewAssembler(config) +err := assembler.Assemble(ctx) +``` + +## File Search Paths + +The assembler searches for task and rule files in predefined locations: + +**Tasks:** +- `./.agents/tasks/.md` +- `~/.agents/tasks/.md` +- `/etc/agents/tasks/.md` + +**Rules:** +The tool searches for various files and directories, including: +- `CLAUDE.local.md` +- `.agents/rules`, `.cursor/rules`, `.augment/rules`, `.windsurf/rules` +- `.opencode/agent`, `.opencode/command` +- `.github/copilot-instructions.md`, `.gemini/styleguide.md` +- `AGENTS.md`, `CLAUDE.md`, `GEMINI.md` (and in parent directories) +- User-specific rules in `~/.agents/rules`, `~/.claude/CLAUDE.md`, etc. +- System-wide rules in `/etc/agents/rules`, `/etc/opencode/rules` + +## Bootstrap Scripts + +Bootstrap scripts are executed before processing rule files. If a rule file `setup.md` exists, the assembler will look for `setup-bootstrap` and execute it if found. This is useful for environment setup or tool installation. + +## Testing + +The package includes comprehensive tests. Run them with: + +```bash +go test github.com/kitproj/coding-context-cli/context +``` + +## License + +This package is part of the coding-context-cli project and follows the same license. diff --git a/context/assembler.go b/context/assembler.go new file mode 100644 index 0000000..ad14bd4 --- /dev/null +++ b/context/assembler.go @@ -0,0 +1,273 @@ +package context + +import ( + "context" + "fmt" + "io" + "log/slog" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// Frontmatter represents YAML frontmatter metadata +type Frontmatter map[string]string + +// Document represents a markdown file with frontmatter (both rules and tasks) +type Document struct { + // Path is the absolute path to the file + Path string + // Content is the parsed content of the file (without frontmatter) + Content string + // Frontmatter contains the YAML frontmatter metadata + Frontmatter Frontmatter + // Tokens is the estimated token count for this document + Tokens int +} + +// Config holds the configuration for context assembly +type Config struct { + // WorkDir is the working directory to use + WorkDir string + // TaskName is the name of the task to execute + TaskName string + // Params are parameters for substitution in task prompts + Params ParamMap + // Selectors are frontmatter selectors for filtering rules + Selectors SelectorMap + // Stdout is where assembled context is written (defaults to os.Stdout) + Stdout io.Writer + // Stderr is where progress messages are written (defaults to os.Stderr) + Stderr io.Writer + // Visitor is called for each selected rule (defaults to DefaultRuleVisitor) + Visitor RuleVisitor + // Logger is used for logging (defaults to slog.Default()) + Logger *slog.Logger +} + +// Assembler assembles context from rule and task files +type Assembler struct { + config Config +} + +// NewAssembler creates a new context assembler with the given configuration +func NewAssembler(config Config) *Assembler { + if config.Stdout == nil { + config.Stdout = os.Stdout + } + if config.Stderr == nil { + config.Stderr = os.Stderr + } + if config.Params == nil { + config.Params = make(ParamMap) + } + if config.Selectors == nil { + config.Selectors = make(SelectorMap) + } + if config.Logger == nil { + // Create a logger that writes to the configured stderr + handler := slog.NewTextHandler(config.Stderr, nil) + config.Logger = slog.New(handler) + } + if config.Visitor == nil { + config.Visitor = &DefaultRuleVisitor{ + stdout: config.Stdout, + logger: config.Logger, + } + } + return &Assembler{config: config} +} + +// Assemble assembles the context and returns the task document +// Rules are processed via the configured visitor +func (a *Assembler) Assemble(ctx context.Context) (*Document, error) { + // Change to work directory if specified + if a.config.WorkDir != "" { + if err := os.Chdir(a.config.WorkDir); err != nil { + return nil, fmt.Errorf("failed to chdir to %s: %w", a.config.WorkDir, err) + } + } + + // Add task name to selectors so rules can be filtered by task + a.config.Selectors["task_name"] = a.config.TaskName + + homeDir, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("failed to get user home directory: %w", err) + } + + // find the task prompt + var taskPromptPath string + taskPromptPaths := []string{ + filepath.Join(".agents", "tasks", a.config.TaskName+".md"), + filepath.Join(homeDir, ".agents", "tasks", a.config.TaskName+".md"), + filepath.Join("/etc", "agents", "tasks", a.config.TaskName+".md"), + } + for _, path := range taskPromptPaths { + if _, err := os.Stat(path); err == nil { + taskPromptPath = path + break + } + } + + if taskPromptPath == "" { + return nil, fmt.Errorf("prompt file not found for task: %s in %v", a.config.TaskName, taskPromptPaths) + } + + // Track total tokens + var totalTokens int + + for _, rule := range []string{ + "CLAUDE.local.md", + + ".agents/rules", + ".cursor/rules", + ".augment/rules", + ".windsurf/rules", + ".opencode/agent", + ".opencode/command", + + ".github/copilot-instructions.md", + ".gemini/styleguide.md", + ".github/agents", + ".augment/guidelines.md", + + "AGENTS.md", + "CLAUDE.md", + "GEMINI.md", + + ".cursorrules", + ".windsurfrules", + + // ancestors + "../AGENTS.md", + "../CLAUDE.md", + "../GEMINI.md", + + "../../AGENTS.md", + "../../CLAUDE.md", + "../../GEMINI.md", + + // user + filepath.Join(homeDir, ".agents", "rules"), + filepath.Join(homeDir, ".claude", "CLAUDE.md"), + filepath.Join(homeDir, ".codex", "AGENTS.md"), + filepath.Join(homeDir, ".gemini", "GEMINI.md"), + filepath.Join(homeDir, ".opencode", "rules"), + + // system + "/etc/agents/rules", + "/etc/opencode/rules", + } { + + // Skip if the path doesn't exist + if _, err := os.Stat(rule); os.IsNotExist(err) { + continue + } else if err != nil { + return nil, fmt.Errorf("failed to stat rule path %s: %w", rule, err) + } + + err := filepath.Walk(rule, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + + // Only process .md and .mdc files as rule files + ext := filepath.Ext(path) + if ext != ".md" && ext != ".mdc" { + return nil + } + + // Parse frontmatter to check selectors + var frontmatter map[string]string + content, err := ParseMarkdownFile(path, &frontmatter) + if err != nil { + return fmt.Errorf("failed to parse markdown file: %w", err) + } + + // Check if file matches include selectors. + // Note: Files with duplicate basenames will both be included. + if !a.config.Selectors.MatchesIncludes(frontmatter) { + fmt.Fprintf(a.config.Stderr, "⪢ Excluding rule file (does not match include selectors): %s\n", path) + return nil + } + + // Check for a bootstrap file named -bootstrap + // For example, setup.md -> setup-bootstrap, setup.mdc -> setup-bootstrap + baseNameWithoutExt := strings.TrimSuffix(path, ext) + bootstrapFilePath := baseNameWithoutExt + "-bootstrap" + + if _, err := os.Stat(bootstrapFilePath); err == nil { + // Bootstrap file exists, make it executable and run it before printing content + if err := os.Chmod(bootstrapFilePath, 0755); err != nil { + return fmt.Errorf("failed to chmod bootstrap file %s: %w", bootstrapFilePath, err) + } + + fmt.Fprintf(a.config.Stderr, "⪢ Running bootstrap script: %s\n", bootstrapFilePath) + + cmd := exec.CommandContext(ctx, bootstrapFilePath) + cmd.Stdout = a.config.Stderr + cmd.Stderr = a.config.Stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to run bootstrap script: %w", err) + } + } else if !os.IsNotExist(err) { + return fmt.Errorf("failed to stat bootstrap file %s: %w", bootstrapFilePath, err) + } + + // Create Document object and visit it + tokens := EstimateTokens(content) + totalTokens += tokens + + doc := &Document{ + Path: path, + Content: content, + Frontmatter: frontmatter, + Tokens: tokens, + } + + // Visit the rule using the configured visitor + if err := a.config.Visitor.VisitRule(ctx, doc); err != nil { + return fmt.Errorf("visitor error for rule %s: %w", path, err) + } + + return nil + + }) + if err != nil { + return nil, fmt.Errorf("failed to walk rule dir: %w", err) + } + } + + content, err := ParseMarkdownFile(taskPromptPath, &struct{}{}) + if err != nil { + return nil, fmt.Errorf("failed to parse prompt file %s: %w", taskPromptPath, err) + } + + expanded := os.Expand(content, func(key string) string { + if val, ok := a.config.Params[key]; ok { + return val + } + // this might not exist, in that case, return the original text + return fmt.Sprintf("${%s}", key) + }) + + // Estimate tokens for this file + tokens := EstimateTokens(expanded) + totalTokens += tokens + + a.config.Logger.Info("including task file", "path", taskPromptPath, "tokens", tokens) + a.config.Logger.Info("total estimated tokens", "count", totalTokens) + + // Return the task document + return &Document{ + Path: taskPromptPath, + Content: expanded, + Tokens: tokens, + }, nil +} diff --git a/context/assembler_test.go b/context/assembler_test.go new file mode 100644 index 0000000..0e6adf5 --- /dev/null +++ b/context/assembler_test.go @@ -0,0 +1,260 @@ +package context + +import ( + "bytes" + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestAssembler_Assemble(t *testing.T) { + // Create a temporary directory structure + tmpDir := t.TempDir() + rulesDir := filepath.Join(tmpDir, ".agents", "rules") + tasksDir := filepath.Join(tmpDir, ".agents", "tasks") + + if err := os.MkdirAll(rulesDir, 0755); err != nil { + t.Fatalf("failed to create rules dir: %v", err) + } + if err := os.MkdirAll(tasksDir, 0755); err != nil { + t.Fatalf("failed to create tasks dir: %v", err) + } + + // Create a rule file + ruleFile := filepath.Join(rulesDir, "setup.md") + ruleContent := `--- +--- +# Development Setup + +This is a setup guide. +` + if err := os.WriteFile(ruleFile, []byte(ruleContent), 0644); err != nil { + t.Fatalf("failed to write rule file: %v", err) + } + + // Create a task file + taskFile := filepath.Join(tasksDir, "test-task.md") + taskContent := `--- +--- +# Test Task + +Please help with this task. +` + if err := os.WriteFile(taskFile, []byte(taskContent), 0644); err != nil { + t.Fatalf("failed to write task file: %v", err) + } + + // Test assembling context + var stdout, stderr bytes.Buffer + assembler := NewAssembler(Config{ + WorkDir: tmpDir, + TaskName: "test-task", + Params: make(ParamMap), + Selectors: make(SelectorMap), + Stdout: &stdout, + Stderr: &stderr, + }) + + ctx := context.Background() + task, err := assembler.Assemble(ctx) + if err != nil { + t.Fatalf("Assemble() error = %v", err) + } + + // Check that rule content is present in stdout (via visitor) + outputStr := stdout.String() + if !strings.Contains(outputStr, "# Development Setup") { + t.Errorf("rule content not found in stdout") + } + + // Check that task content is in the returned document + if !strings.Contains(task.Content, "# Test Task") { + t.Errorf("task content not found in returned document") + } + + // Check stderr for progress messages (slog format) + stderrStr := stderr.String() + if !strings.Contains(stderrStr, "including rule file") { + t.Errorf("progress messages not found in stderr") + } + if !strings.Contains(stderrStr, "total estimated tokens") { + t.Errorf("total token count not found in stderr") + } +} + +func TestAssembler_AssembleWithParams(t *testing.T) { + // Create a temporary directory structure + tmpDir := t.TempDir() + tasksDir := filepath.Join(tmpDir, ".agents", "tasks") + + if err := os.MkdirAll(tasksDir, 0755); err != nil { + t.Fatalf("failed to create tasks dir: %v", err) + } + + // Create a task file with template variables + taskFile := filepath.Join(tasksDir, "test-task.md") + taskContent := `--- +--- +# Test Task + +Please work on ${component} and fix ${issue}. +` + if err := os.WriteFile(taskFile, []byte(taskContent), 0644); err != nil { + t.Fatalf("failed to write task file: %v", err) + } + + // Test assembling context with parameters + var stdout, stderr bytes.Buffer + params := make(ParamMap) + params["component"] = "auth" + params["issue"] = "login bug" + + assembler := NewAssembler(Config{ + WorkDir: tmpDir, + TaskName: "test-task", + Params: params, + Selectors: make(SelectorMap), + Stdout: &stdout, + Stderr: &stderr, + }) + + ctx := context.Background() + task, err := assembler.Assemble(ctx) + if err != nil { + t.Fatalf("Assemble() error = %v", err) + } + + // Check that template variables were expanded in the returned task + if !strings.Contains(task.Content, "Please work on auth and fix login bug.") { + t.Errorf("template variables were not expanded correctly. Task content:\n%s", task.Content) + } +} + +func TestAssembler_AssembleWithSelectors(t *testing.T) { + // Create a temporary directory structure + tmpDir := t.TempDir() + rulesDir := filepath.Join(tmpDir, ".agents", "rules") + tasksDir := filepath.Join(tmpDir, ".agents", "tasks") + + if err := os.MkdirAll(rulesDir, 0755); err != nil { + t.Fatalf("failed to create rules dir: %v", err) + } + if err := os.MkdirAll(tasksDir, 0755); err != nil { + t.Fatalf("failed to create tasks dir: %v", err) + } + + // Create rule files with different selectors + ruleFile1 := filepath.Join(rulesDir, "python.md") + ruleContent1 := `--- +language: python +--- +# Python Guidelines + +Python specific guidelines. +` + if err := os.WriteFile(ruleFile1, []byte(ruleContent1), 0644); err != nil { + t.Fatalf("failed to write python rule file: %v", err) + } + + ruleFile2 := filepath.Join(rulesDir, "golang.md") + ruleContent2 := `--- +language: go +--- +# Go Guidelines + +Go specific guidelines. +` + if err := os.WriteFile(ruleFile2, []byte(ruleContent2), 0644); err != nil { + t.Fatalf("failed to write go rule file: %v", err) + } + + // Create a task file + taskFile := filepath.Join(tasksDir, "test-task.md") + taskContent := `--- +--- +# Test Task + +Please help with this task. +` + if err := os.WriteFile(taskFile, []byte(taskContent), 0644); err != nil { + t.Fatalf("failed to write task file: %v", err) + } + + // Test assembling context with selector filtering for Python + var stdout, stderr bytes.Buffer + selectors := make(SelectorMap) + selectors["language"] = "python" + + assembler := NewAssembler(Config{ + WorkDir: tmpDir, + TaskName: "test-task", + Params: make(ParamMap), + Selectors: selectors, + Stdout: &stdout, + Stderr: &stderr, + }) + + ctx := context.Background() + if _, err := assembler.Assemble(ctx); err != nil { + t.Fatalf("Assemble() error = %v", err) + } + + // Check that only Python guidelines are included + outputStr := stdout.String() + if !strings.Contains(outputStr, "# Python Guidelines") { + t.Errorf("Python guidelines not found in stdout") + } + if strings.Contains(outputStr, "# Go Guidelines") { + t.Errorf("Go guidelines should not be in stdout when filtering for Python") + } +} + +func TestAssembler_TaskNotFound(t *testing.T) { + // Create a temporary directory without tasks + tmpDir := t.TempDir() + + var stdout, stderr bytes.Buffer + assembler := NewAssembler(Config{ + WorkDir: tmpDir, + TaskName: "nonexistent-task", + Params: make(ParamMap), + Selectors: make(SelectorMap), + Stdout: &stdout, + Stderr: &stderr, + }) + + ctx := context.Background() + _, err := assembler.Assemble(ctx) + if err == nil { + t.Fatalf("expected error for nonexistent task, got nil") + } + + if !strings.Contains(err.Error(), "prompt file not found") { + t.Errorf("expected 'prompt file not found' error, got: %v", err) + } +} + +func TestNewAssembler_DefaultValues(t *testing.T) { + // Test that NewAssembler sets default values correctly + config := Config{ + WorkDir: ".", + TaskName: "test", + } + + assembler := NewAssembler(config) + + if assembler.config.Stdout != os.Stdout { + t.Errorf("expected Stdout to default to os.Stdout") + } + if assembler.config.Stderr != os.Stderr { + t.Errorf("expected Stderr to default to os.Stderr") + } + if assembler.config.Params == nil { + t.Errorf("expected Params to be initialized") + } + if assembler.config.Selectors == nil { + t.Errorf("expected Selectors to be initialized") + } +} diff --git a/context/markdown.go b/context/markdown.go new file mode 100644 index 0000000..f67a095 --- /dev/null +++ b/context/markdown.go @@ -0,0 +1,69 @@ +package context + +import ( + "bufio" + "bytes" + "fmt" + "os" + + yaml "go.yaml.in/yaml/v2" +) + +// ParseMarkdownFile parses the file into frontmatter and content +func ParseMarkdownFile(path string, frontmatter any) (string, error) { + + fh, err := os.Open(path) + if err != nil { + return "", fmt.Errorf("failed to open file: %w", err) + } + defer fh.Close() + + s := bufio.NewScanner(fh) + + var content bytes.Buffer + var frontMatterBytes bytes.Buffer + + // State machine: 0 = unknown, 1 = scanning frontmatter, 2 = scanning content + state := 0 + + for s.Scan() { + line := s.Text() + + switch state { + case 0: // State unknown - first line + if line == "---" { + state = 1 // Start scanning frontmatter + } else { + state = 2 // No frontmatter, start scanning content + if _, err := content.WriteString(line + "\n"); err != nil { + return "", fmt.Errorf("failed to write content: %w", err) + } + } + case 1: // Scanning frontmatter + if line == "---" { + state = 2 // End of frontmatter, start scanning content + } else { + if _, err := frontMatterBytes.WriteString(line + "\n"); err != nil { + return "", fmt.Errorf("failed to write frontmatter: %w", err) + } + } + case 2: // Scanning content + if _, err := content.WriteString(line + "\n"); err != nil { + return "", fmt.Errorf("failed to write content: %w", err) + } + } + } + + if err := s.Err(); err != nil { + return "", fmt.Errorf("failed to scan file: %w", err) + } + + // Parse frontmatter if we collected any + if frontMatterBytes.Len() > 0 { + if err := yaml.Unmarshal(frontMatterBytes.Bytes(), frontmatter); err != nil { + return "", fmt.Errorf("failed to unmarshal frontmatter: %w", err) + } + } + + return content.String(), nil +} diff --git a/context/markdown_test.go b/context/markdown_test.go new file mode 100644 index 0000000..2f9a29c --- /dev/null +++ b/context/markdown_test.go @@ -0,0 +1,104 @@ +package context + +import ( + "os" + "path/filepath" + "testing" +) + +func TestParseMarkdownFile(t *testing.T) { + tests := []struct { + name string + content string + wantContent string + wantFrontmatter map[string]string + wantErr bool + }{ + { + name: "markdown with frontmatter", + content: `--- +title: Test Title +author: Test Author +--- +This is the content +of the markdown file. +`, + wantContent: "This is the content\nof the markdown file.\n", + wantFrontmatter: map[string]string{ + "title": "Test Title", + "author": "Test Author", + }, + wantErr: false, + }, + { + name: "markdown without frontmatter", + content: `This is a simple markdown file +without any frontmatter. +`, + wantContent: "This is a simple markdown file\nwithout any frontmatter.\n", + wantFrontmatter: map[string]string{}, + wantErr: false, + }, + { + name: "markdown with title as first line", + content: `# My Title + +This is the content. +`, + wantContent: "# My Title\n\nThis is the content.\n", + wantFrontmatter: map[string]string{}, + wantErr: false, + }, + { + name: "empty file", + content: "", + wantContent: "", + wantFrontmatter: map[string]string{}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a temporary file + tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "test.md") + if err := os.WriteFile(tmpFile, []byte(tt.content), 0644); err != nil { + t.Fatalf("failed to create temp file: %v", err) + } + + // Parse the file + var frontmatter map[string]string + content, err := ParseMarkdownFile(tmpFile, &frontmatter) + + // Check error + if (err != nil) != tt.wantErr { + t.Errorf("ParseMarkdownFile() error = %v, wantErr %v", err, tt.wantErr) + return + } + + // Check content + if content != tt.wantContent { + t.Errorf("ParseMarkdownFile() content = %q, want %q", content, tt.wantContent) + } + + // Check frontmatter + if len(frontmatter) != len(tt.wantFrontmatter) { + t.Errorf("ParseMarkdownFile() frontmatter length = %d, want %d", len(frontmatter), len(tt.wantFrontmatter)) + } + for k, v := range tt.wantFrontmatter { + if frontmatter[k] != v { + t.Errorf("ParseMarkdownFile() frontmatter[%q] = %q, want %q", k, frontmatter[k], v) + } + } + }) + } +} + +func TestParseMarkdownFile_FileNotFound(t *testing.T) { + var frontmatter map[string]string + _, err := ParseMarkdownFile("/nonexistent/file.md", &frontmatter) + if err == nil { + t.Error("ParseMarkdownFile() expected error for non-existent file, got nil") + } +} diff --git a/context/param_map.go b/context/param_map.go new file mode 100644 index 0000000..982ab8b --- /dev/null +++ b/context/param_map.go @@ -0,0 +1,25 @@ +package context + +import ( + "fmt" + "strings" +) + +// ParamMap represents a map of parameters for substitution in task prompts +type ParamMap map[string]string + +func (p *ParamMap) String() string { + return fmt.Sprint(*p) +} + +func (p *ParamMap) Set(value string) error { + kv := strings.SplitN(value, "=", 2) + if len(kv) != 2 { + return fmt.Errorf("invalid parameter format: %s", value) + } + if *p == nil { + *p = make(map[string]string) + } + (*p)[kv[0]] = kv[1] + return nil +} diff --git a/context/param_map_test.go b/context/param_map_test.go new file mode 100644 index 0000000..ca9c135 --- /dev/null +++ b/context/param_map_test.go @@ -0,0 +1,97 @@ +package context + +import ( + "testing" +) + +func TestParamMap_Set(t *testing.T) { + tests := []struct { + name string + value string + wantKey string + wantVal string + wantErr bool + }{ + { + name: "valid key=value", + value: "key=value", + wantKey: "key", + wantVal: "value", + wantErr: false, + }, + { + name: "key=value with equals in value", + value: "key=value=with=equals", + wantKey: "key", + wantVal: "value=with=equals", + wantErr: false, + }, + { + name: "empty value", + value: "key=", + wantKey: "key", + wantVal: "", + wantErr: false, + }, + { + name: "invalid format - no equals", + value: "keyvalue", + wantErr: true, + }, + { + name: "invalid format - only key", + value: "key", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := ParamMap{} + err := p.Set(tt.value) + + if (err != nil) != tt.wantErr { + t.Errorf("paramMap.Set() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if !tt.wantErr { + if p[tt.wantKey] != tt.wantVal { + t.Errorf("paramMap[%q] = %q, want %q", tt.wantKey, p[tt.wantKey], tt.wantVal) + } + } + }) + } +} + +func TestParamMap_String(t *testing.T) { + p := ParamMap{ + "key1": "value1", + "key2": "value2", + } + s := p.String() + if s == "" { + t.Error("paramMap.String() returned empty string") + } +} + +func TestParamMap_SetMultiple(t *testing.T) { + p := ParamMap{} + + if err := p.Set("key1=value1"); err != nil { + t.Fatalf("paramMap.Set() failed: %v", err) + } + if err := p.Set("key2=value2"); err != nil { + t.Fatalf("paramMap.Set() failed: %v", err) + } + + if len(p) != 2 { + t.Errorf("paramMap length = %d, want 2", len(p)) + } + if p["key1"] != "value1" { + t.Errorf("paramMap[key1] = %q, want %q", p["key1"], "value1") + } + if p["key2"] != "value2" { + t.Errorf("paramMap[key2] = %q, want %q", p["key2"], "value2") + } +} diff --git a/context/selector_map.go b/context/selector_map.go new file mode 100644 index 0000000..8487ed0 --- /dev/null +++ b/context/selector_map.go @@ -0,0 +1,55 @@ +package context + +import ( + "fmt" + "strings" +) + +// SelectorMap represents a specialized map for parsing selector key=value pairs, based on ParamMap +type SelectorMap ParamMap + +func (s *SelectorMap) String() string { + return (*ParamMap)(s).String() +} + +func (s *SelectorMap) Set(value string) error { + // Parse key=value format with trimming + kv := strings.SplitN(value, "=", 2) + if len(kv) != 2 { + return fmt.Errorf("invalid selector format: %s", value) + } + if *s == nil { + *s = make(SelectorMap) + } + // Trim spaces from both key and value for selectors + (*s)[strings.TrimSpace(kv[0])] = strings.TrimSpace(kv[1]) + return nil +} + +// MatchesIncludes returns true if the frontmatter matches all include selectors +// If a key doesn't exist in frontmatter, it's allowed +func (includes *SelectorMap) MatchesIncludes(frontmatter map[string]string) bool { + for key, value := range *includes { + fmValue, exists := frontmatter[key] + // If key exists, it must match the value + if exists && fmValue != value { + return false + } + // If key doesn't exist, allow it + } + return true +} + +// MatchesExcludes returns true if the frontmatter doesn't match any exclude selectors +// If a key doesn't exist in frontmatter, it's allowed +func (excludes *SelectorMap) MatchesExcludes(frontmatter map[string]string) bool { + for key, value := range *excludes { + fmValue, exists := frontmatter[key] + // If key exists and matches the value, exclude it + if exists && fmValue == value { + return false + } + // If key doesn't exist, allow it + } + return true +} diff --git a/context/selector_map_test.go b/context/selector_map_test.go new file mode 100644 index 0000000..7038431 --- /dev/null +++ b/context/selector_map_test.go @@ -0,0 +1,251 @@ +package context + +import ( + "testing" +) + +func TestSelectorMap_Set(t *testing.T) { + tests := []struct { + name string + value string + wantKey string + wantVal string + wantErr bool + }{ + { + name: "valid selector", + value: "env=production", + wantKey: "env", + wantVal: "production", + wantErr: false, + }, + { + name: "selector with spaces", + value: "env = production", + wantKey: "env", + wantVal: "production", + wantErr: false, + }, + { + name: "invalid format - no operator", + value: "env", + wantErr: true, + }, + { + name: "invalid format - empty", + value: "", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := make(SelectorMap) + err := s.Set(tt.value) + + if (err != nil) != tt.wantErr { + t.Errorf("Set() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if !tt.wantErr { + if len(s) != 1 { + t.Errorf("Set() resulted in %d selectors, want 1", len(s)) + return + } + if s[tt.wantKey] != tt.wantVal { + t.Errorf("Set() s[%q] = %q, want %q", tt.wantKey, s[tt.wantKey], tt.wantVal) + } + } + }) + } +} + +func TestSelectorMap_SetMultiple(t *testing.T) { + s := make(SelectorMap) + if err := s.Set("env=production"); err != nil { + t.Fatalf("Set() error = %v", err) + } + if err := s.Set("language=go"); err != nil { + t.Fatalf("Set() error = %v", err) + } + + if len(s) != 2 { + t.Errorf("Set() resulted in %d selectors, want 2", len(s)) + } +} + +func TestSelectorMap_MatchesIncludes(t *testing.T) { + tests := []struct { + name string + selectors []string + frontmatter map[string]string + wantMatch bool + }{ + { + name: "single include - match", + selectors: []string{"env=production"}, + frontmatter: map[string]string{"env": "production"}, + wantMatch: true, + }, + { + name: "single include - no match", + selectors: []string{"env=production"}, + frontmatter: map[string]string{"env": "development"}, + wantMatch: false, + }, + { + name: "single include - key missing (allowed)", + selectors: []string{"env=production"}, + frontmatter: map[string]string{"language": "go"}, + wantMatch: true, + }, + { + name: "multiple includes - all match", + selectors: []string{"env=production", "language=go"}, + frontmatter: map[string]string{"env": "production", "language": "go"}, + wantMatch: true, + }, + { + name: "multiple includes - one doesn't match", + selectors: []string{"env=production", "language=go"}, + frontmatter: map[string]string{"env": "production", "language": "python"}, + wantMatch: false, + }, + { + name: "multiple includes - one key missing (allowed)", + selectors: []string{"env=production", "language=go"}, + frontmatter: map[string]string{"env": "production"}, + wantMatch: true, + }, + { + name: "empty includes - always match", + selectors: []string{}, + frontmatter: map[string]string{"env": "production"}, + wantMatch: true, + }, + { + name: "empty frontmatter - key missing (allowed)", + selectors: []string{"env=production"}, + frontmatter: map[string]string{}, + wantMatch: true, + }, + { + name: "task_name include - match", + selectors: []string{"task_name=deploy"}, + frontmatter: map[string]string{"task_name": "deploy"}, + wantMatch: true, + }, + { + name: "task_name include - no match", + selectors: []string{"task_name=deploy"}, + frontmatter: map[string]string{"task_name": "test"}, + wantMatch: false, + }, + { + name: "task_name include - key missing (allowed)", + selectors: []string{"task_name=deploy"}, + frontmatter: map[string]string{"env": "production"}, + wantMatch: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := make(SelectorMap) + for _, sel := range tt.selectors { + if err := s.Set(sel); err != nil { + t.Fatalf("Set() error = %v", err) + } + } + + if got := s.MatchesIncludes(tt.frontmatter); got != tt.wantMatch { + t.Errorf("matchesIncludes() = %v, want %v", got, tt.wantMatch) + } + }) + } +} + +func TestSelectorMap_MatchesExcludes(t *testing.T) { + tests := []struct { + name string + selectors []string + frontmatter map[string]string + wantMatch bool + }{ + { + name: "single exclude - doesn't match (allowed)", + selectors: []string{"env=production"}, + frontmatter: map[string]string{"env": "development"}, + wantMatch: true, + }, + { + name: "single exclude - matches (excluded)", + selectors: []string{"env=production"}, + frontmatter: map[string]string{"env": "production"}, + wantMatch: false, + }, + { + name: "single exclude - key missing (allowed)", + selectors: []string{"env=production"}, + frontmatter: map[string]string{"language": "go"}, + wantMatch: true, + }, + { + name: "multiple excludes - none match (allowed)", + selectors: []string{"env=production", "language=go"}, + frontmatter: map[string]string{"env": "development", "language": "python"}, + wantMatch: true, + }, + { + name: "multiple excludes - one matches (excluded)", + selectors: []string{"env=production", "language=go"}, + frontmatter: map[string]string{"env": "production", "language": "python"}, + wantMatch: false, + }, + { + name: "multiple excludes - one key missing (allowed)", + selectors: []string{"env=production", "language=go"}, + frontmatter: map[string]string{"env": "development"}, + wantMatch: true, + }, + { + name: "empty excludes - always match", + selectors: []string{}, + frontmatter: map[string]string{"env": "production"}, + wantMatch: true, + }, + { + name: "empty frontmatter - key missing (allowed)", + selectors: []string{"env=production"}, + frontmatter: map[string]string{}, + wantMatch: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := make(SelectorMap) + for _, sel := range tt.selectors { + if err := s.Set(sel); err != nil { + t.Fatalf("Set() error = %v", err) + } + } + + if got := s.MatchesExcludes(tt.frontmatter); got != tt.wantMatch { + t.Errorf("matchesExcludes() = %v, want %v", got, tt.wantMatch) + } + }) + } +} + +func TestSelectorMap_String(t *testing.T) { + s := make(SelectorMap) + s.Set("env=production") + s.Set("language=go") + + str := s.String() + if str == "" { + t.Error("String() returned empty string") + } +} diff --git a/context/token_counter.go b/context/token_counter.go new file mode 100644 index 0000000..b65d0ca --- /dev/null +++ b/context/token_counter.go @@ -0,0 +1,14 @@ +package context + +import ( + "unicode/utf8" +) + +// EstimateTokens estimates the number of LLM tokens in the given text. +// Uses a simple heuristic of approximately 4 characters per token, +// which is a common approximation for English text with GPT-style tokenizers. +func EstimateTokens(text string) int { + charCount := utf8.RuneCountInString(text) + // Approximate: 1 token ≈ 4 characters + return charCount / 4 +} diff --git a/context/token_counter_test.go b/context/token_counter_test.go new file mode 100644 index 0000000..6f61ad8 --- /dev/null +++ b/context/token_counter_test.go @@ -0,0 +1,65 @@ +package context + +import "testing" + +func TestEstimateTokens(t *testing.T) { + tests := []struct { + name string + text string + want int + }{ + { + name: "empty string", + text: "", + want: 0, + }, + { + name: "short text", + text: "Hi", + want: 0, // 2 chars / 4 = 0 + }, + { + name: "simple sentence", + text: "This is a test.", + want: 3, // 15 chars / 4 = 3 + }, + { + name: "paragraph", + text: "This is a longer paragraph with multiple words that should result in more tokens being counted by our estimation algorithm.", + want: 30, // 123 chars / 4 = 30 + }, + { + name: "multiline text", + text: `This is line one. +This is line two. +This is line three.`, + want: 13, // 55 chars / 4 = 13 + }, + { + name: "code snippet", + text: `func main() { + fmt.Println("Hello, World!") +}`, + want: 12, // 49 chars / 4 = 12 + }, + { + name: "markdown with frontmatter", + text: `--- +title: Test +--- +# Heading + +This is content.`, + want: 11, // 47 chars / 4 = 11 + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := EstimateTokens(tt.text) + if got != tt.want { + t.Errorf("EstimateTokens() = %d, want %d", got, tt.want) + } + }) + } +} diff --git a/context/visitor.go b/context/visitor.go new file mode 100644 index 0000000..e79f3e1 --- /dev/null +++ b/context/visitor.go @@ -0,0 +1,32 @@ +package context + +import ( +"context" +"fmt" +"io" +"log/slog" +) + +// RuleVisitor defines the interface for visiting rules as they are selected +type RuleVisitor interface { +// VisitRule is called for each rule that matches the selection criteria +// It receives the context and the rule document +// Returning an error will stop the assembly process +VisitRule(ctx context.Context, rule *Document) error +} + +// DefaultRuleVisitor is the default implementation that writes rules to stdout +type DefaultRuleVisitor struct { +stdout io.Writer +logger *slog.Logger +} + +// VisitRule writes the rule content to stdout and logs progress +func (v *DefaultRuleVisitor) VisitRule(ctx context.Context, rule *Document) error { +if v.logger == nil { +v.logger = slog.Default() +} +v.logger.Info("including rule file", "path", rule.Path, "tokens", rule.Tokens) +fmt.Fprintln(v.stdout, rule.Content) +return nil +} diff --git a/context/visitor_test.go b/context/visitor_test.go new file mode 100644 index 0000000..88ff884 --- /dev/null +++ b/context/visitor_test.go @@ -0,0 +1,202 @@ +package context + +import ( + "bytes" + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +// CustomVisitor is a test visitor that collects rule information +type CustomVisitor struct { + VisitedRules []*Document + stderr *bytes.Buffer +} + +func (v *CustomVisitor) VisitRule(ctx context.Context, rule *Document) error { + v.VisitedRules = append(v.VisitedRules, rule) + return nil +} + +func TestAssembler_WithCustomVisitor(t *testing.T) { + // Create a temporary directory structure + tmpDir := t.TempDir() + rulesDir := filepath.Join(tmpDir, ".agents", "rules") + tasksDir := filepath.Join(tmpDir, ".agents", "tasks") + + if err := os.MkdirAll(rulesDir, 0755); err != nil { + t.Fatalf("failed to create rules dir: %v", err) + } + if err := os.MkdirAll(tasksDir, 0755); err != nil { + t.Fatalf("failed to create tasks dir: %v", err) + } + + // Create rule files + rule1File := filepath.Join(rulesDir, "rule1.md") + rule1Content := `--- +language: go +--- +# Rule 1 + +This is rule 1. +` + if err := os.WriteFile(rule1File, []byte(rule1Content), 0644); err != nil { + t.Fatalf("failed to write rule file 1: %v", err) + } + + rule2File := filepath.Join(rulesDir, "rule2.md") + rule2Content := `--- +language: python +--- +# Rule 2 + +This is rule 2. +` + if err := os.WriteFile(rule2File, []byte(rule2Content), 0644); err != nil { + t.Fatalf("failed to write rule file 2: %v", err) + } + + // Create a task file + taskFile := filepath.Join(tasksDir, "test-task.md") + taskContent := `# Test Task + +Please help with this task. +` + if err := os.WriteFile(taskFile, []byte(taskContent), 0644); err != nil { + t.Fatalf("failed to write task file: %v", err) + } + + // Create custom visitor + var stderr bytes.Buffer + customVisitor := &CustomVisitor{ + VisitedRules: make([]*Document, 0), + stderr: &stderr, + } + + // Test assembling context with custom visitor + var stdout bytes.Buffer + assembler := NewAssembler(Config{ + WorkDir: tmpDir, + TaskName: "test-task", + Params: make(ParamMap), + Selectors: make(SelectorMap), + Stdout: &stdout, + Stderr: &stderr, + Visitor: customVisitor, + }) + + ctx := context.Background() + if _, err := assembler.Assemble(ctx); err != nil { + t.Fatalf("Assemble() error = %v", err) + } + + // Verify that the custom visitor was called + if len(customVisitor.VisitedRules) != 2 { + t.Errorf("expected 2 rules to be visited, got %d", len(customVisitor.VisitedRules)) + } + + // Verify rule information + for _, rule := range customVisitor.VisitedRules { + if rule.Path == "" { + t.Errorf("rule path should not be empty") + } + if rule.Content == "" { + t.Errorf("rule content should not be empty") + } + if rule.Tokens == 0 { + t.Errorf("rule tokens should not be zero") + } + if rule.Frontmatter == nil { + t.Errorf("rule frontmatter should not be nil") + } + } + + // Verify that rules were visited in order + foundRule1 := false + foundRule2 := false + for _, rule := range customVisitor.VisitedRules { + if strings.Contains(rule.Content, "Rule 1") { + foundRule1 = true + } + if strings.Contains(rule.Content, "Rule 2") { + foundRule2 = true + } + } + if !foundRule1 { + t.Errorf("rule1 was not visited") + } + if !foundRule2 { + t.Errorf("rule2 was not visited") + } +} + +func TestAssembler_WithDefaultVisitor(t *testing.T) { + // Create a temporary directory structure + tmpDir := t.TempDir() + rulesDir := filepath.Join(tmpDir, ".agents", "rules") + tasksDir := filepath.Join(tmpDir, ".agents", "tasks") + + if err := os.MkdirAll(rulesDir, 0755); err != nil { + t.Fatalf("failed to create rules dir: %v", err) + } + if err := os.MkdirAll(tasksDir, 0755); err != nil { + t.Fatalf("failed to create tasks dir: %v", err) + } + + // Create a rule file + ruleFile := filepath.Join(rulesDir, "setup.md") + ruleContent := `# Development Setup + +This is a setup guide. +` + if err := os.WriteFile(ruleFile, []byte(ruleContent), 0644); err != nil { + t.Fatalf("failed to write rule file: %v", err) + } + + // Create a task file + taskFile := filepath.Join(tasksDir, "test-task.md") + taskContent := `# Test Task + +Please help with this task. +` + if err := os.WriteFile(taskFile, []byte(taskContent), 0644); err != nil { + t.Fatalf("failed to write task file: %v", err) + } + + // Test with default visitor (should write to stdout) + var stdout, stderr bytes.Buffer + assembler := NewAssembler(Config{ + WorkDir: tmpDir, + TaskName: "test-task", + Params: make(ParamMap), + Selectors: make(SelectorMap), + Stdout: &stdout, + Stderr: &stderr, + // Visitor not specified, should use default + }) + + ctx := context.Background() + task, err := assembler.Assemble(ctx) + if err != nil { + t.Fatalf("Assemble() error = %v", err) + } + + // Check that rule content is present in stdout (default behavior) + outputStr := stdout.String() + if !strings.Contains(outputStr, "# Development Setup") { + t.Errorf("rule content not found in stdout with default visitor") + } + + // Check that task content is in the returned document + if !strings.Contains(task.Content, "# Test Task") { + t.Errorf("task content not found in returned document") + } + + // Check stderr for progress messages (slog format) + stderrStr := stderr.String() + if !strings.Contains(stderrStr, "including rule file") { + t.Errorf("progress messages not found in stderr with default visitor") + } +} diff --git a/examples/basic/README.md b/examples/basic/README.md new file mode 100644 index 0000000..c3cc2b4 --- /dev/null +++ b/examples/basic/README.md @@ -0,0 +1,37 @@ +# Basic Library Usage Example + +This example demonstrates how to use the coding-context-cli as a library. + +## Running the Example + +```bash +go run main.go +``` + +## Prerequisites + +This example expects to find a task file named `fix-bug.md` in one of the standard search paths: +- `./.agents/tasks/fix-bug.md` +- `~/.agents/tasks/fix-bug.md` +- `/etc/agents/tasks/fix-bug.md` + +You can create a simple task file for testing: + +```bash +mkdir -p .agents/tasks +cat > .agents/tasks/fix-bug.md << 'EOF' +# Fix Bug Task + +Please fix the bug in ${component} related to ${issue}. + +Analyze the code and provide a fix. +EOF +``` + +## What This Example Shows + +The example demonstrates how to: +- Create parameters for substitution +- Create selectors for filtering rules +- Configure and create an Assembler +- Execute the context assembly diff --git a/examples/basic/main.go b/examples/basic/main.go new file mode 100644 index 0000000..1853766 --- /dev/null +++ b/examples/basic/main.go @@ -0,0 +1,42 @@ +package main + +import ( + "context" + "fmt" + "os" + + ctxlib "github.com/kitproj/coding-context-cli/context" +) + +func main() { + // Create parameters for substitution + params := make(ctxlib.ParamMap) + params["component"] = "authentication" + params["issue"] = "password reset bug" + + // Create selectors for filtering rules + selectors := make(ctxlib.SelectorMap) + selectors["language"] = "go" + + // Configure the assembler + config := ctxlib.Config{ + WorkDir: ".", + TaskName: "fix-bug", + Params: params, + Selectors: selectors, + } + + // Create the assembler + assembler := ctxlib.NewAssembler(config) + + // Assemble the context + ctx := context.Background() + task, err := assembler.Assemble(ctx) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + // Print the task content + fmt.Println(task.Content) +} diff --git a/examples/visitor/README.md b/examples/visitor/README.md new file mode 100644 index 0000000..993c747 --- /dev/null +++ b/examples/visitor/README.md @@ -0,0 +1,61 @@ +# Visitor Pattern Example + +This example demonstrates how to use the visitor pattern to customize rule processing. + +## What This Example Shows + +The example creates a custom `LoggingVisitor` that: +- Logs detailed information about each rule as it's processed +- Tracks the total number of rules processed +- Maintains the default behavior of writing content to stdout + +## Running the Example + +```bash +go run main.go +``` + +## Prerequisites + +This example expects to find a task file named `fix-bug.md` in one of the standard search paths. + +You can create a simple task file for testing: + +```bash +mkdir -p .agents/tasks +cat > .agents/tasks/fix-bug.md << 'TASK_EOF' +# Fix Bug Task + +Please analyze and fix the bug. +TASK_EOF +``` + +## Use Cases for Custom Visitors + +Custom visitors enable many advanced scenarios: + +1. **Logging and Monitoring**: Track which rules are being used +2. **Filtering**: Skip certain rules based on custom logic +3. **Transformation**: Modify rule content before output +4. **Analytics**: Collect statistics about rule usage +5. **Custom Output**: Write to multiple destinations or formats +6. **Caching**: Store processed rules for reuse +7. **Validation**: Verify rule content meets requirements + +## Example Output + +When running with the logging visitor, you'll see output like: + +``` +=== Rule #1 === +Path: /path/to/.agents/rules/setup.md +Tokens: 42 +Frontmatter: + language: go + +# Setup Guide +... + +=== Summary === +Total rules processed: 3 +``` diff --git a/examples/visitor/main.go b/examples/visitor/main.go new file mode 100644 index 0000000..99e53f9 --- /dev/null +++ b/examples/visitor/main.go @@ -0,0 +1,64 @@ +package main + +import ( + "context" + "fmt" + "os" + + ctxlib "github.com/kitproj/coding-context-cli/context" +) + +// LoggingVisitor is a custom visitor that logs rule information +type LoggingVisitor struct { + RuleCount int +} + +func (v *LoggingVisitor) VisitRule(ctx context.Context, rule *ctxlib.Document) error { + v.RuleCount++ + + // Log detailed information about each rule + fmt.Fprintf(os.Stderr, "\n=== Rule #%d ===\n", v.RuleCount) + fmt.Fprintf(os.Stderr, "Path: %s\n", rule.Path) + fmt.Fprintf(os.Stderr, "Tokens: %d\n", rule.Tokens) + + // Log frontmatter + if len(rule.Frontmatter) > 0 { + fmt.Fprintf(os.Stderr, "Frontmatter:\n") + for key, value := range rule.Frontmatter { + fmt.Fprintf(os.Stderr, " %s: %s\n", key, value) + } + } + + // Write the content to stdout (maintaining default behavior) + fmt.Println(rule.Content) + + return nil +} + +func main() { + // Create a custom logging visitor + visitor := &LoggingVisitor{} + + // Configure the assembler with the custom visitor + config := ctxlib.Config{ + WorkDir: ".", + TaskName: "fix-bug", + Visitor: visitor, + } + + // Assemble the context + assembler := ctxlib.NewAssembler(config) + ctx := context.Background() + task, err := assembler.Assemble(ctx) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + // Print the task content + fmt.Println(task.Content) + + // Print summary + fmt.Fprintf(os.Stderr, "\n=== Summary ===\n") + fmt.Fprintf(os.Stderr, "Total rules processed: %d\n", visitor.RuleCount) +} diff --git a/main.go b/main.go index 015bd03..bc708c7 100644 --- a/main.go +++ b/main.go @@ -1,26 +1,25 @@ package main import ( - "context" + ctx "context" _ "embed" "flag" "fmt" "os" - "os/exec" "os/signal" - "path/filepath" - "strings" "syscall" + + "github.com/kitproj/coding-context-cli/context" ) var ( workDir string - params = make(paramMap) - includes = make(selectorMap) + params = make(context.ParamMap) + includes = make(context.SelectorMap) ) func main() { - ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + c, cancel := signal.NotifyContext(ctx.Background(), os.Interrupt, syscall.SIGTERM) defer cancel() flag.StringVar(&workDir, "C", ".", "Change to directory before doing anything.") @@ -38,190 +37,35 @@ func main() { } flag.Parse() - if err := run(ctx, flag.Args()); err != nil { + if err := run(c, flag.Args()); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) flag.Usage() os.Exit(1) } } -func run(ctx context.Context, args []string) error { +func run(c ctx.Context, args []string) error { if len(args) != 1 { return fmt.Errorf("invalid usage") } - if err := os.Chdir(workDir); err != nil { - return fmt.Errorf("failed to chdir to %s: %w", workDir, err) - } - - // Add task name to includes so rules can be filtered by task taskName := args[0] - includes["task_name"] = taskName - - homeDir, err := os.UserHomeDir() - if err != nil { - return fmt.Errorf("failed to get user home directory: %w", err) - } - - // find the task prompt - var taskPromptPath string - taskPromptPaths := []string{ - filepath.Join(".agents", "tasks", taskName+".md"), - filepath.Join(homeDir, ".agents", "tasks", taskName+".md"), - filepath.Join("/etc", "agents", "tasks", taskName+".md"), - } - for _, path := range taskPromptPaths { - if _, err := os.Stat(path); err == nil { - taskPromptPath = path - break - } - } - - if taskPromptPath == "" { - return fmt.Errorf("prompt file not found for task: %s in %v", taskName, taskPromptPaths) - } - - // Track total tokens - var totalTokens int - - for _, rule := range []string{ - "CLAUDE.local.md", - - ".agents/rules", - ".cursor/rules", - ".augment/rules", - ".windsurf/rules", - ".opencode/agent", - ".opencode/command", - - ".github/copilot-instructions.md", - ".gemini/styleguide.md", - ".github/agents", - ".augment/guidelines.md", - - "AGENTS.md", - "CLAUDE.md", - "GEMINI.md", - - ".cursorrules", - ".windsurfrules", - - // ancestors - "../AGENTS.md", - "../CLAUDE.md", - "../GEMINI.md", - - "../../AGENTS.md", - "../../CLAUDE.md", - "../../GEMINI.md", - - // user - filepath.Join(homeDir, ".agents", "rules"), - filepath.Join(homeDir, ".claude", "CLAUDE.md"), - filepath.Join(homeDir, ".codex", "AGENTS.md"), - filepath.Join(homeDir, ".gemini", "GEMINI.md"), - filepath.Join(homeDir, ".opencode", "rules"), - - // system - "/etc/agents/rules", - "/etc/opencode/rules", - } { - // Skip if the path doesn't exist - if _, err := os.Stat(rule); os.IsNotExist(err) { - continue - } else if err != nil { - return fmt.Errorf("failed to stat rule path %s: %w", rule, err) - } - - err := filepath.Walk(rule, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - if info.IsDir() { - return nil - } - - // Only process .md and .mdc files as rule files - ext := filepath.Ext(path) - if ext != ".md" && ext != ".mdc" { - return nil - } - - // Parse frontmatter to check selectors - var frontmatter map[string]string - content, err := parseMarkdownFile(path, &frontmatter) - if err != nil { - return fmt.Errorf("failed to parse markdown file: %w", err) - } - - // Check if file matches include selectors. - // Note: Files with duplicate basenames will both be included. - if !includes.matchesIncludes(frontmatter) { - fmt.Fprintf(os.Stderr, "⪢ Excluding rule file (does not match include selectors): %s\n", path) - return nil - } - - // Check for a bootstrap file named -bootstrap - // For example, setup.md -> setup-bootstrap, setup.mdc -> setup-bootstrap - baseNameWithoutExt := strings.TrimSuffix(path, ext) - bootstrapFilePath := baseNameWithoutExt + "-bootstrap" - - if _, err := os.Stat(bootstrapFilePath); err == nil { - // Bootstrap file exists, make it executable and run it before printing content - if err := os.Chmod(bootstrapFilePath, 0755); err != nil { - return fmt.Errorf("failed to chmod bootstrap file %s: %w", bootstrapFilePath, err) - } - - fmt.Fprintf(os.Stderr, "⪢ Running bootstrap script: %s\n", bootstrapFilePath) - - cmd := exec.CommandContext(ctx, bootstrapFilePath) - cmd.Stdout = os.Stderr - cmd.Stderr = os.Stderr - - if err := cmd.Run(); err != nil { - return fmt.Errorf("failed to run bootstrap script: %w", err) - } - } else if !os.IsNotExist(err) { - return fmt.Errorf("failed to stat bootstrap file %s: %w", bootstrapFilePath, err) - } - - // Estimate tokens for this file - tokens := estimateTokens(content) - totalTokens += tokens - fmt.Fprintf(os.Stderr, "⪢ Including rule file: %s (~%d tokens)\n", path, tokens) - fmt.Println(content) - - return nil - - }) - if err != nil { - return fmt.Errorf("failed to walk rule dir: %w", err) - } - } + // Create assembler with configuration + assembler := context.NewAssembler(context.Config{ + WorkDir: workDir, + TaskName: taskName, + Params: params, + Selectors: includes, + }) - content, err := parseMarkdownFile(taskPromptPath, &struct{}{}) + task, err := assembler.Assemble(c) if err != nil { - return fmt.Errorf("failed to parse prompt file %s: %w", taskPromptPath, err) + return err } - - expanded := os.Expand(content, func(key string) string { - if val, ok := params[key]; ok { - return val - } - // this might not exist, in that case, return the original text - return fmt.Sprintf("${%s}", key) - }) - - // Estimate tokens for this file - tokens := estimateTokens(expanded) - totalTokens += tokens - fmt.Fprintf(os.Stderr, "⪢ Including task file: %s (~%d tokens)\n", taskPromptPath, tokens) - - fmt.Println(expanded) - - // Print total token count - fmt.Fprintf(os.Stderr, "⪢ Total estimated tokens: %d\n", totalTokens) - + + // Print the task content + fmt.Println(task.Content) + return nil } diff --git a/markdown.go b/markdown.go index 978994f..7ffde0a 100644 --- a/markdown.go +++ b/markdown.go @@ -1,69 +1,7 @@ package main import ( - "bufio" - "bytes" - "fmt" - "os" - - yaml "go.yaml.in/yaml/v2" + "github.com/kitproj/coding-context-cli/context" ) -// parseMarkdownFile parses the file into frontmatter and content -func parseMarkdownFile(path string, frontmatter any) (string, error) { - - fh, err := os.Open(path) - if err != nil { - return "", fmt.Errorf("failed to open file: %w", err) - } - defer fh.Close() - - s := bufio.NewScanner(fh) - - var content bytes.Buffer - var frontMatterBytes bytes.Buffer - - // State machine: 0 = unknown, 1 = scanning frontmatter, 2 = scanning content - state := 0 - - for s.Scan() { - line := s.Text() - - switch state { - case 0: // State unknown - first line - if line == "---" { - state = 1 // Start scanning frontmatter - } else { - state = 2 // No frontmatter, start scanning content - if _, err := content.WriteString(line + "\n"); err != nil { - return "", fmt.Errorf("failed to write content: %w", err) - } - } - case 1: // Scanning frontmatter - if line == "---" { - state = 2 // End of frontmatter, start scanning content - } else { - if _, err := frontMatterBytes.WriteString(line + "\n"); err != nil { - return "", fmt.Errorf("failed to write frontmatter: %w", err) - } - } - case 2: // Scanning content - if _, err := content.WriteString(line + "\n"); err != nil { - return "", fmt.Errorf("failed to write content: %w", err) - } - } - } - - if err := s.Err(); err != nil { - return "", fmt.Errorf("failed to scan file: %w", err) - } - - // Parse frontmatter if we collected any - if frontMatterBytes.Len() > 0 { - if err := yaml.Unmarshal(frontMatterBytes.Bytes(), frontmatter); err != nil { - return "", fmt.Errorf("failed to unmarshal frontmatter: %w", err) - } - } - - return content.String(), nil -} +var parseMarkdownFile = context.ParseMarkdownFile diff --git a/param_map.go b/param_map.go index 3b4f836..0017ad8 100644 --- a/param_map.go +++ b/param_map.go @@ -1,24 +1,7 @@ package main import ( - "fmt" - "strings" + "github.com/kitproj/coding-context-cli/context" ) -type paramMap map[string]string - -func (p *paramMap) String() string { - return fmt.Sprint(*p) -} - -func (p *paramMap) Set(value string) error { - kv := strings.SplitN(value, "=", 2) - if len(kv) != 2 { - return fmt.Errorf("invalid parameter format: %s", value) - } - if *p == nil { - *p = make(map[string]string) - } - (*p)[kv[0]] = kv[1] - return nil -} +type paramMap = context.ParamMap diff --git a/selector_map.go b/selector_map.go index 1baf2c8..605d8f8 100644 --- a/selector_map.go +++ b/selector_map.go @@ -1,55 +1,8 @@ package main import ( - "fmt" - "strings" + "github.com/kitproj/coding-context-cli/context" ) -// selectorMap reuses paramMap for parsing key=value pairs -type selectorMap paramMap - -func (s *selectorMap) String() string { - return (*paramMap)(s).String() -} - -func (s *selectorMap) Set(value string) error { - // Parse key=value format with trimming - kv := strings.SplitN(value, "=", 2) - if len(kv) != 2 { - return fmt.Errorf("invalid selector format: %s", value) - } - if *s == nil { - *s = make(selectorMap) - } - // Trim spaces from both key and value for selectors - (*s)[strings.TrimSpace(kv[0])] = strings.TrimSpace(kv[1]) - return nil -} - -// matchesIncludes returns true if the frontmatter matches all include selectors -// If a key doesn't exist in frontmatter, it's allowed -func (includes *selectorMap) matchesIncludes(frontmatter map[string]string) bool { - for key, value := range *includes { - fmValue, exists := frontmatter[key] - // If key exists, it must match the value - if exists && fmValue != value { - return false - } - // If key doesn't exist, allow it - } - return true -} - -// matchesExcludes returns true if the frontmatter doesn't match any exclude selectors -// If a key doesn't exist in frontmatter, it's allowed -func (excludes *selectorMap) matchesExcludes(frontmatter map[string]string) bool { - for key, value := range *excludes { - fmValue, exists := frontmatter[key] - // If key exists and matches the value, exclude it - if exists && fmValue == value { - return false - } - // If key doesn't exist, allow it - } - return true -} +// selectorMap reuses ParamMap for parsing key=value pairs +type selectorMap = context.SelectorMap diff --git a/selector_map_test.go b/selector_map_test.go index 99e5b56..db6eb66 100644 --- a/selector_map_test.go +++ b/selector_map_test.go @@ -2,6 +2,8 @@ package main import ( "testing" + + "github.com/kitproj/coding-context-cli/context" ) func TestSelectorMap_Set(t *testing.T) { @@ -159,7 +161,7 @@ func TestSelectorMap_MatchesIncludes(t *testing.T) { } } - if got := s.matchesIncludes(tt.frontmatter); got != tt.wantMatch { + if got := (*context.SelectorMap)(&s).MatchesIncludes(tt.frontmatter); got != tt.wantMatch { t.Errorf("matchesIncludes() = %v, want %v", got, tt.wantMatch) } }) @@ -232,7 +234,7 @@ func TestSelectorMap_MatchesExcludes(t *testing.T) { } } - if got := s.matchesExcludes(tt.frontmatter); got != tt.wantMatch { + if got := (*context.SelectorMap)(&s).MatchesExcludes(tt.frontmatter); got != tt.wantMatch { t.Errorf("matchesExcludes() = %v, want %v", got, tt.wantMatch) } }) diff --git a/token_counter.go b/token_counter.go index 114ba96..080c3aa 100644 --- a/token_counter.go +++ b/token_counter.go @@ -1,14 +1,7 @@ package main import ( - "unicode/utf8" + "github.com/kitproj/coding-context-cli/context" ) -// estimateTokens estimates the number of LLM tokens in the given text. -// Uses a simple heuristic of approximately 4 characters per token, -// which is a common approximation for English text with GPT-style tokenizers. -func estimateTokens(text string) int { - charCount := utf8.RuneCountInString(text) - // Approximate: 1 token ≈ 4 characters - return charCount / 4 -} +var estimateTokens = context.EstimateTokens