diff --git a/main.go b/main.go index 793d6702..5d1d9073 100644 --- a/main.go +++ b/main.go @@ -24,15 +24,15 @@ func main() { var agent codingcontext.Agent params := make(codingcontext.Params) includes := make(codingcontext.Selectors) - var remotePaths []string + var pathsToDownload []string flag.StringVar(&workDir, "C", ".", "Change to directory before doing anything.") flag.BoolVar(&resume, "r", false, "Resume mode: skip outputting rules and select task with 'resume: true' in frontmatter.") flag.Var(&agent, "a", "Target agent to use (excludes rules from other agents). Supported agents: cursor, opencode, copilot, claude, gemini, augment, windsurf, codex.") flag.Var(¶ms, "p", "Parameter to substitute in the prompt. Can be specified multiple times as key=value.") flag.Var(&includes, "s", "Include rules with matching frontmatter. Can be specified multiple times as key=value.") - flag.Func("d", "Remote directory containing rules and tasks. Can be specified multiple times. Supports various protocols via go-getter (http://, https://, git::, s3::, etc.).", func(s string) error { - remotePaths = append(remotePaths, s) + flag.Func("d", "Path (local or remote) containing rules and tasks. Can be specified multiple times. Supports various protocols via go-getter (http://, https://, git::, s3::, etc.).", func(s string) error { + pathsToDownload = append(pathsToDownload, s) return nil }) @@ -52,15 +52,30 @@ func main() { os.Exit(1) } - cc := codingcontext.New( + homeDir, err := os.UserHomeDir() + if err != nil { + logger.Error("Error", "error", fmt.Errorf("failed to get user home directory: %w", err)) + os.Exit(1) + } + + opts := []codingcontext.Option{ codingcontext.WithWorkDir(workDir), codingcontext.WithParams(params), codingcontext.WithSelectors(includes), - codingcontext.WithRemotePaths(remotePaths), + codingcontext.WithSearchPaths(codingcontext.DefaultSearchPaths(workDir, homeDir)), codingcontext.WithLogger(logger), codingcontext.WithResume(resume), codingcontext.WithAgent(agent), - ) + } + + // Add SearchPaths for paths to download with default subpaths + for _, path := range pathsToDownload { + opts = append(opts, codingcontext.WithSearchPaths([]codingcontext.SearchPath{ + codingcontext.NewSearchPathWithDefaults(path), + })) + } + + cc := codingcontext.New(opts...) result, err := cc.Run(ctx, args[0]) if err != nil { diff --git a/pkg/codingcontext/README.md b/pkg/codingcontext/README.md index 52b3d81e..cc1657f0 100644 --- a/pkg/codingcontext/README.md +++ b/pkg/codingcontext/README.md @@ -71,6 +71,9 @@ func main() { selectors.SetValue("language", "go") selectors.SetValue("stage", "implementation") + // Get home directory for default search paths + homeDir, _ := os.UserHomeDir() + // Create context with all options ctx := codingcontext.New( codingcontext.WithWorkDir("."), @@ -78,10 +81,10 @@ func main() { "issue_number": "123", }), codingcontext.WithSelectors(selectors), - codingcontext.WithRemotePaths([]string{ - "https://github.com/org/repo//path/to/rules", + codingcontext.WithSearchPaths(codingcontext.DefaultSearchPaths(".", homeDir)), + codingcontext.WithSearchPaths([]codingcontext.SearchPath{ + {BasePath: "https://github.com/org/repo//path/to/rules"}, }), - codingcontext.WithEmitTaskFrontmatter(true), codingcontext.WithLogger(slog.New(slog.NewTextHandler(os.Stderr, nil))), ) @@ -137,6 +140,13 @@ Map structure for filtering rules based on frontmatter metadata. Map representing parsed YAML frontmatter from markdown files. +#### `SearchPath` + +Represents a single search location with its associated subpaths: +- `BasePath string` - Base directory path to search +- `RulesSubPaths []string` - Relative subpaths within BasePath where rule files can be found +- `TaskSubPaths []string` - Relative subpaths within BasePath where task files can be found + ### Functions #### `New(opts ...Option) *Context` @@ -147,9 +157,10 @@ Creates a new Context with the given options. - `WithWorkDir(dir string)` - Set the working directory - `WithParams(params Params)` - Set parameters - `WithSelectors(selectors Selectors)` - Set selectors for filtering -- `WithRemotePaths(paths []string)` - Set remote directories to download -- `WithEmitTaskFrontmatter(emit bool)` - Enable task frontmatter inclusion in result +- `WithSearchPaths(searchPaths []SearchPath)` - Set search paths to use. Typically called with `DefaultSearchPaths(baseDir, homeDir)` to enable default local path searching. Paths to download can be added as SearchPaths with only BasePath set (empty RulesSubPaths and TaskSubPaths) - `WithLogger(logger *slog.Logger)` - Set logger +- `WithResume(resume bool)` - Enable resume mode, which skips rule discovery and bootstrap scripts +- `WithAgent(agent Agent)` - Set the target agent, which excludes that agent's own rules #### `(*Context) Run(ctx context.Context, taskName string) (*Result, error)` @@ -159,13 +170,13 @@ Executes the context assembly for the given task name and returns the assembled Parses a markdown file into frontmatter and content. -#### `AllTaskSearchPaths(baseDir, homeDir string) []string` +#### `DefaultSearchPaths(baseDir, homeDir string) []SearchPath` -Returns the standard search paths for task files. `baseDir` is the working directory to resolve relative paths from. +Returns the search paths for default local paths (baseDir and homeDir). Each `SearchPath` represents one base path with its associated rule and task subpaths. -#### `AllRulePaths(baseDir, homeDir string) []string` +#### `PathSearchPaths(dir string) []SearchPath` -Returns the standard search paths for rule files. `baseDir` is the working directory to resolve relative paths from. +Returns the search paths for a given directory path (used for both local and remote paths after download). Uses the same standard subpaths as downloaded directories. ## See Also diff --git a/pkg/codingcontext/agent.go b/pkg/codingcontext/agent.go index 67d3f8e1..48565567 100644 --- a/pkg/codingcontext/agent.go +++ b/pkg/codingcontext/agent.go @@ -21,15 +21,6 @@ const ( AgentCodex Agent = "codex" ) -// AllAgents returns all supported agents -func AllAgents() []Agent { - agents := make([]Agent, 0, len(agentPathPatterns)) - for agent := range agentPathPatterns { - agents = append(agents, agent) - } - return agents -} - // ParseAgent parses a string into an Agent type func ParseAgent(s string) (Agent, error) { agent := Agent(s) diff --git a/pkg/codingcontext/agent_test.go b/pkg/codingcontext/agent_test.go index fa3b6032..414f0f84 100644 --- a/pkg/codingcontext/agent_test.go +++ b/pkg/codingcontext/agent_test.go @@ -377,34 +377,10 @@ func TestAgent_IsSet(t *testing.T) { t.Errorf("IsSet() on empty agent = true, want false") } - a.Set("cursor") + if err := a.Set("cursor"); err != nil { + t.Fatalf("Set() failed: %v", err) + } if !a.IsSet() { t.Errorf("IsSet() on set agent = false, want true") } } - -func TestAllAgents(t *testing.T) { - agents := AllAgents() - - if len(agents) != 8 { - t.Errorf("AllAgents() returned %d agents, want 8", len(agents)) - } - - // Verify all expected agents are present - expected := map[Agent]bool{ - AgentCursor: true, - AgentOpenCode: true, - AgentCopilot: true, - AgentClaude: true, - AgentGemini: true, - AgentAugment: true, - AgentWindsurf: true, - AgentCodex: true, - } - - for _, agent := range agents { - if !expected[agent] { - t.Errorf("AllAgents() returned unexpected agent: %v", agent) - } - } -} diff --git a/pkg/codingcontext/context.go b/pkg/codingcontext/context.go index 946321de..3968f15e 100644 --- a/pkg/codingcontext/context.go +++ b/pkg/codingcontext/context.go @@ -15,8 +15,7 @@ type Context struct { workDir string params Params includes Selectors - remotePaths []string - downloadedDirs []string + searchPaths []SearchPath matchingTaskFile string task Markdown[TaskFrontMatter] // Parsed task rules []Markdown[RuleFrontMatter] // Collected rule files @@ -51,10 +50,10 @@ func WithSelectors(selectors Selectors) Option { } } -// WithRemotePaths sets the remote paths -func WithRemotePaths(paths []string) Option { +// WithSearchPaths appends search paths to use (can be called multiple times to accumulate search paths) +func WithSearchPaths(searchPaths []SearchPath) Option { return func(c *Context) { - c.remotePaths = paths + c.searchPaths = append(c.searchPaths, searchPaths...) } } @@ -110,8 +109,8 @@ func (cc *Context) expandParams(content string) string { // Run executes the context assembly for the given task name and returns the assembled result func (cc *Context) Run(ctx context.Context, taskName string) (*Result, error) { - if err := cc.downloadRemoteDirectories(ctx); err != nil { - return nil, fmt.Errorf("failed to download remote directories: %w", err) + if err := cc.downloadPaths(ctx); err != nil { + return nil, fmt.Errorf("failed to download paths: %w", err) } defer cc.cleanupDownloadedDirectories() @@ -123,12 +122,7 @@ func (cc *Context) Run(ctx context.Context, taskName string) (*Result, error) { cc.includes.SetValue("resume", "true") } - homeDir, err := os.UserHomeDir() - if err != nil { - return nil, fmt.Errorf("failed to get user home directory: %w", err) - } - - err = cc.findTaskFile(homeDir, taskName) + err := cc.findTaskFile(taskName) if err != nil { return nil, fmt.Errorf("failed to find task file: %w", err) } @@ -178,7 +172,7 @@ func (cc *Context) Run(ctx context.Context, taskName string) (*Result, error) { cc.includes.SetValue("task_name", slashTaskName) // Find the new task file - if err := cc.findTaskFile(homeDir, slashTaskName); err != nil { + if err := cc.findTaskFile(slashTaskName); err != nil { return nil, fmt.Errorf("failed to find slash command task file: %w", err) } @@ -188,7 +182,7 @@ func (cc *Context) Run(ctx context.Context, taskName string) (*Result, error) { } } - if err := cc.findExecuteRuleFiles(ctx, homeDir); err != nil { + if err := cc.findExecuteRuleFiles(ctx); err != nil { return nil, fmt.Errorf("failed to find and execute rule files: %w", err) } @@ -213,39 +207,40 @@ func (cc *Context) Run(ctx context.Context, taskName string) (*Result, error) { return result, nil } -func (cc *Context) downloadRemoteDirectories(ctx context.Context) error { - for _, remotePath := range cc.remotePaths { - cc.logger.Info("Downloading remote directory", "path", remotePath) - localPath, err := downloadRemoteDirectory(ctx, remotePath) +func (cc *Context) downloadPaths(ctx context.Context) error { + // Process each SearchPath - download/copy paths (go-getter handles both local and remote) + for i := range cc.searchPaths { + sp := &cc.searchPaths[i] + // Download/copy the path (go-getter handles both local and remote paths) + cc.logger.Info("Processing path", "path", sp.BasePath) + localPath, err := downloadPath(ctx, sp.BasePath) if err != nil { - return fmt.Errorf("failed to download remote directory %s: %w", remotePath, err) + return fmt.Errorf("failed to process path %s: %w", sp.BasePath, err) } - cc.downloadedDirs = append(cc.downloadedDirs, localPath) - cc.logger.Info("Downloaded to", "path", localPath) + // Set downloadedDir to track the local path, keeping BasePath as original + sp.downloadedDir = localPath + cc.logger.Info("Processed to", "path", localPath) } return nil } func (cc *Context) cleanupDownloadedDirectories() { - for _, dir := range cc.downloadedDirs { - if dir == "" { - continue - } - - if err := os.RemoveAll(dir); err != nil { - cc.logger.Error("Error cleaning up downloaded directory", "path", dir, "error", err) + for _, sp := range cc.searchPaths { + if sp.downloadedDir != "" { + if err := os.RemoveAll(sp.downloadedDir); err != nil { + cc.logger.Error("Error cleaning up downloaded directory", "path", sp.downloadedDir, "error", err) + } } } } -func (cc *Context) findTaskFile(homeDir string, taskName string) error { - // find the task file by matching filename (without .md extension) - taskSearchDirs := AllTaskSearchPaths(cc.workDir, homeDir) +func (cc *Context) findTaskFile(taskName string) error { - // Add downloaded remote directories to task search paths - for _, dir := range cc.downloadedDirs { - taskSearchDirs = append(taskSearchDirs, DownloadedTaskSearchPaths(dir)...) + // Build task search directories from search paths + taskSearchDirs := make([]string, 0) + for _, sp := range cc.searchPaths { + taskSearchDirs = append(taskSearchDirs, sp.TaskSearchDirs()...) } for _, dir := range taskSearchDirs { @@ -308,19 +303,21 @@ func (cc *Context) taskFileWalker(taskName string) func(path string, info os.Fil } } -func (cc *Context) findExecuteRuleFiles(ctx context.Context, homeDir string) error { +func (cc *Context) findExecuteRuleFiles(ctx context.Context) error { // Skip rule file discovery if resume mode is enabled // Check cc.resume directly first, then fall back to selector check for backward compatibility if cc.resume || (cc.includes != nil && cc.includes.GetValue("resume", "true")) { return nil } - // Build the list of rule locations (local and remote) - rulePaths := AllRulePaths(cc.workDir, homeDir) + // Build search paths from all sources + // Downloaded paths are already included in cc.searchPaths with downloadedDir set + searchPaths := cc.searchPaths - // Append remote directories to rule paths - for _, dir := range cc.downloadedDirs { - rulePaths = append(rulePaths, DownloadedRulePaths(dir)...) + // Build rule paths from search paths + rulePaths := make([]string, 0) + for _, sp := range searchPaths { + rulePaths = append(rulePaths, sp.RulesSearchDirs()...) } for _, rule := range rulePaths { diff --git a/pkg/codingcontext/context_test.go b/pkg/codingcontext/context_test.go index 38a44313..85ce0808 100644 --- a/pkg/codingcontext/context_test.go +++ b/pkg/codingcontext/context_test.go @@ -33,6 +33,14 @@ func createMarkdownFile(t *testing.T, path string, frontmatter string, content s } } +// Helper to get default search paths for tests +func getTestSearchPaths(t *testing.T, workDir string) []SearchPath { + t.Helper() + // Use a temporary directory as homeDir to avoid including user's actual home directory rules + testHomeDir := t.TempDir() + return DefaultSearchPaths(workDir, testHomeDir) +} + func TestRun(t *testing.T) { tests := []struct { name string @@ -123,15 +131,20 @@ func TestRun(t *testing.T) { if err != nil { t.Fatalf("failed to get working directory: %v", err) } - defer os.Chdir(oldDir) + defer func() { + if err := os.Chdir(oldDir); err != nil { + t.Errorf("failed to restore working directory: %v", err) + } + }() var logOut bytes.Buffer cc := &Context{ - workDir: tmpDir, - params: tt.params, - includes: tt.includes, - rules: make([]Markdown[RuleFrontMatter], 0), - logger: slog.New(slog.NewTextHandler(&logOut, nil)), + workDir: tmpDir, + params: tt.params, + includes: tt.includes, + searchPaths: getTestSearchPaths(t, tmpDir), + rules: make([]Markdown[RuleFrontMatter], 0), + logger: slog.New(slog.NewTextHandler(&logOut, nil)), cmdRunner: func(cmd *exec.Cmd) error { return nil // Mock command runner }, @@ -337,33 +350,39 @@ func TestFindTaskFile(t *testing.T) { if err != nil { t.Fatalf("failed to get working directory: %v", err) } - defer os.Chdir(oldDir) + defer func() { + if err := os.Chdir(oldDir); err != nil { + t.Errorf("failed to restore working directory: %v", err) + } + }() if err := os.Chdir(tmpDir); err != nil { t.Fatalf("failed to chdir: %v", err) } cc := &Context{ - includes: tt.includes, + workDir: tmpDir, + includes: tt.includes, + searchPaths: getTestSearchPaths(t, tmpDir), } if cc.includes == nil { cc.includes = make(Selectors) } cc.includes.SetValue("task_name", tt.taskName) - // Set downloadedDirs if specified in test case + // Add SearchPaths with downloadedDir set if specified in test case if len(tt.downloadedDirs) > 0 { - cc.downloadedDirs = make([]string, len(tt.downloadedDirs)) - for i, dir := range tt.downloadedDirs { - cc.downloadedDirs[i] = filepath.Join(tmpDir, dir) + for _, dir := range tt.downloadedDirs { + downloadedPath := filepath.Join(tmpDir, dir) + startIdx := len(cc.searchPaths) + cc.searchPaths = append(cc.searchPaths, PathSearchPaths(downloadedPath)...) + // Set downloadedDir on all newly added SearchPaths + for i := startIdx; i < len(cc.searchPaths); i++ { + cc.searchPaths[i].downloadedDir = downloadedPath + } } } - homeDir, err := os.UserHomeDir() - if err != nil { - t.Fatalf("failed to get user home directory: %v", err) - } - - err = cc.findTaskFile(homeDir, tt.taskName) + err = cc.findTaskFile(tt.taskName) if tt.wantErr { if err == nil { @@ -550,7 +569,11 @@ func TestFindExecuteRuleFiles(t *testing.T) { if err != nil { t.Fatalf("failed to get working directory: %v", err) } - defer os.Chdir(oldDir) + defer func() { + if err := os.Chdir(oldDir); err != nil { + t.Errorf("failed to restore working directory: %v", err) + } + }() if err := os.Chdir(tmpDir); err != nil { t.Fatalf("failed to chdir: %v", err) } @@ -558,10 +581,12 @@ func TestFindExecuteRuleFiles(t *testing.T) { var logOut bytes.Buffer bootstrapRan := false cc := &Context{ - includes: tt.includes, - params: tt.params, - rules: make([]Markdown[RuleFrontMatter], 0), - logger: slog.New(slog.NewTextHandler(&logOut, nil)), + workDir: tmpDir, + includes: tt.includes, + params: tt.params, + searchPaths: getTestSearchPaths(t, tmpDir), + rules: make([]Markdown[RuleFrontMatter], 0), + logger: slog.New(slog.NewTextHandler(&logOut, nil)), cmdRunner: func(cmd *exec.Cmd) error { // Track if bootstrap script was executed if cmd.Path != "" { @@ -577,15 +602,20 @@ func TestFindExecuteRuleFiles(t *testing.T) { cc.params = make(Params) } - // Set downloadedDirs if specified in test case + // Add SearchPaths with downloadedDir set if specified in test case if len(tt.downloadedDirs) > 0 { - cc.downloadedDirs = make([]string, len(tt.downloadedDirs)) - for i, dir := range tt.downloadedDirs { - cc.downloadedDirs[i] = filepath.Join(tmpDir, dir) + for _, dir := range tt.downloadedDirs { + downloadedPath := filepath.Join(tmpDir, dir) + startIdx := len(cc.searchPaths) + cc.searchPaths = append(cc.searchPaths, PathSearchPaths(downloadedPath)...) + // Set downloadedDir on all newly added SearchPaths + for i := startIdx; i < len(cc.searchPaths); i++ { + cc.searchPaths[i].downloadedDir = downloadedPath + } } } - err = cc.findExecuteRuleFiles(context.Background(), tmpDir) + err = cc.findExecuteRuleFiles(context.Background()) if err != nil { t.Errorf("findExecuteRuleFiles() unexpected error: %v", err) } @@ -698,7 +728,9 @@ func TestRunBootstrapScript(t *testing.T) { var logOut bytes.Buffer cmdRan := false cc := &Context{ - logger: slog.New(slog.NewTextHandler(&logOut, nil)), + workDir: tmpDir, + searchPaths: getTestSearchPaths(t, tmpDir), + logger: slog.New(slog.NewTextHandler(&logOut, nil)), cmdRunner: func(cmd *exec.Cmd) error { cmdRan = true if tt.mockRunError != nil { @@ -844,6 +876,7 @@ func TestWriteTaskFileContent(t *testing.T) { workDir: tmpDir, matchingTaskFile: taskPath, params: tt.params, + searchPaths: getTestSearchPaths(t, tmpDir), rules: make([]Markdown[RuleFrontMatter], 0), logger: slog.New(slog.NewTextHandler(&logOut, nil)), includes: make(Selectors), @@ -881,11 +914,10 @@ func TestWriteTaskFileContent(t *testing.T) { } // Verify frontmatter is always parsed when present - if cc.task.FrontMatter.Content != nil && len(cc.task.FrontMatter.Content) > 0 { + if len(cc.task.FrontMatter.Content) > 0 { // Just verify frontmatter was parsed - the Context doesn't emit it, main.go does - if _, ok := cc.task.FrontMatter.Content["task_name"]; !ok { - // This is OK - not all tasks have task_name in frontmatter - } + // Not all tasks have task_name in frontmatter, which is OK + _ = cc.task.FrontMatter.Content["task_name"] } // Note: Token counting is done in Run(), not in these internal methods @@ -1314,17 +1346,22 @@ func TestTaskSelectorsFilterRulesByRuleName(t *testing.T) { if err != nil { t.Fatalf("failed to get working directory: %v", err) } - defer os.Chdir(oldDir) + defer func() { + if err := os.Chdir(oldDir); err != nil { + t.Errorf("failed to restore working directory: %v", err) + } + }() if err := os.Chdir(tmpDir); err != nil { t.Fatalf("failed to chdir: %v", err) } var logOut bytes.Buffer cc := &Context{ - workDir: tmpDir, - includes: make(Selectors), - rules: make([]Markdown[RuleFrontMatter], 0), - logger: slog.New(slog.NewTextHandler(&logOut, nil)), + workDir: tmpDir, + includes: make(Selectors), + searchPaths: getTestSearchPaths(t, tmpDir), + rules: make([]Markdown[RuleFrontMatter], 0), + logger: slog.New(slog.NewTextHandler(&logOut, nil)), cmdRunner: func(cmd *exec.Cmd) error { return nil // Mock command runner }, @@ -1334,12 +1371,7 @@ func TestTaskSelectorsFilterRulesByRuleName(t *testing.T) { cc.includes.SetValue("task_name", "test-task") // Find and parse task file - homeDir, err := os.UserHomeDir() - if err != nil { - t.Fatalf("failed to get user home directory: %v", err) - } - - if err := cc.findTaskFile(homeDir, "test-task"); err != nil { + if err := cc.findTaskFile("test-task"); err != nil { if !tt.wantErr { t.Fatalf("findTaskFile() unexpected error: %v", err) } @@ -1355,7 +1387,7 @@ func TestTaskSelectorsFilterRulesByRuleName(t *testing.T) { } // Find and execute rule files - if err := cc.findExecuteRuleFiles(context.Background(), homeDir); err != nil { + if err := cc.findExecuteRuleFiles(context.Background()); err != nil { if !tt.wantErr { t.Fatalf("findExecuteRuleFiles() unexpected error: %v", err) } @@ -1740,11 +1772,12 @@ func TestSlashCommandSubstitution(t *testing.T) { var logOut bytes.Buffer cc := &Context{ - workDir: tmpDir, - params: tt.params, - includes: make(Selectors), - rules: make([]Markdown[RuleFrontMatter], 0), - logger: slog.New(slog.NewTextHandler(&logOut, nil)), + workDir: tmpDir, + params: tt.params, + includes: make(Selectors), + searchPaths: getTestSearchPaths(t, tmpDir), + rules: make([]Markdown[RuleFrontMatter], 0), + logger: slog.New(slog.NewTextHandler(&logOut, nil)), cmdRunner: func(cmd *exec.Cmd) error { return nil }, @@ -1851,8 +1884,13 @@ func TestTaskLanguageFieldFilteringRules(t *testing.T) { taskPath := filepath.Join(tmpDir, ".agents", "tasks", "test-task.md") createMarkdownFile(t, taskPath, tt.taskFrontmatter, "# Test Task Content") + homeDir, err := os.UserHomeDir() + if err != nil { + t.Fatalf("failed to get user home directory: %v", err) + } cc := New( WithWorkDir(tmpDir), + WithSearchPaths(DefaultSearchPaths(tmpDir, homeDir)), ) result, err := cc.Run(ctx, "test-task") @@ -1914,7 +1952,14 @@ mcp_servers: taskPath := filepath.Join(tmpDir, ".agents", "tasks", "test-task.md") createMarkdownFile(t, taskPath, taskFrontmatter, "# Test Task") - cc := New(WithWorkDir(tmpDir)) + homeDir, err := os.UserHomeDir() + if err != nil { + t.Fatalf("failed to get user home directory: %v", err) + } + cc := New( + WithWorkDir(tmpDir), + WithSearchPaths(DefaultSearchPaths(tmpDir, homeDir)), + ) result, err := cc.Run(context.Background(), "test-task") if err != nil { t.Fatalf("Run() error = %v", err) @@ -1989,15 +2034,24 @@ func TestWithResume(t *testing.T) { if err != nil { t.Fatalf("failed to get working directory: %v", err) } - defer os.Chdir(oldDir) + defer func() { + if err := os.Chdir(oldDir); err != nil { + t.Errorf("failed to restore working directory: %v", err) + } + }() if err := os.Chdir(tmpDir); err != nil { t.Fatalf("failed to chdir: %v", err) } var logOut bytes.Buffer + homeDir, err := os.UserHomeDir() + if err != nil { + t.Fatalf("failed to get user home directory: %v", err) + } cc := New( WithWorkDir(tmpDir), WithResume(true), + WithSearchPaths(DefaultSearchPaths(tmpDir, homeDir)), WithLogger(slog.New(slog.NewTextHandler(&logOut, nil))), ) @@ -2023,6 +2077,7 @@ func TestWithResume(t *testing.T) { cc2 := New( WithWorkDir(tmpDir), WithResume(false), + WithSearchPaths(DefaultSearchPaths(tmpDir, homeDir)), WithLogger(slog.New(slog.NewTextHandler(&logOut, nil))), ) @@ -2079,7 +2134,11 @@ func TestWithAgent(t *testing.T) { if err != nil { t.Fatalf("failed to get working directory: %v", err) } - defer os.Chdir(oldDir) + defer func() { + if err := os.Chdir(oldDir); err != nil { + t.Errorf("failed to restore working directory: %v", err) + } + }() if err := os.Chdir(tmpDir); err != nil { t.Fatalf("failed to chdir: %v", err) } @@ -2090,9 +2149,14 @@ func TestWithAgent(t *testing.T) { t.Fatalf("failed to set target agent: %v", err) } + homeDir, err := os.UserHomeDir() + if err != nil { + t.Fatalf("failed to get user home directory: %v", err) + } cc := New( WithWorkDir(tmpDir), WithAgent(agent), + WithSearchPaths(DefaultSearchPaths(tmpDir, homeDir)), WithLogger(slog.New(slog.NewTextHandler(&logOut, nil))), ) diff --git a/pkg/codingcontext/example_test.go b/pkg/codingcontext/example_test.go deleted file mode 100644 index b3fde385..00000000 --- a/pkg/codingcontext/example_test.go +++ /dev/null @@ -1,83 +0,0 @@ -package codingcontext_test - -import ( - "context" - "fmt" - "log" - - "github.com/goccy/go-yaml" - "github.com/kitproj/coding-context-cli/pkg/codingcontext" -) - -// ExampleMarkdown_FrontMatter demonstrates how to access task frontmatter -// when using the coding-context library. -func ExampleMarkdown_FrontMatter() { - // Create a context and run it to get a result - // In a real application, you would configure this properly - cc := codingcontext.New( - codingcontext.WithWorkDir("."), - ) - - // Assuming there's a task file with frontmatter like: - // --- - // task_name: deploy - // priority: high - // environment: production - // --- - result, err := cc.Run(context.Background(), "deploy") - if err != nil { - log.Fatal(err) - } - - // Access the task frontmatter Content map directly - taskName, _ := result.Task.FrontMatter.Content["task_name"].(string) - priority, _ := result.Task.FrontMatter.Content["priority"].(string) - environment, _ := result.Task.FrontMatter.Content["environment"].(string) - - // Now you can use the frontmatter values - fmt.Printf("Task: %s\n", taskName) - fmt.Printf("Priority: %s\n", priority) - fmt.Printf("Environment: %s\n", environment) - - // You can also access rule frontmatter the same way - for _, rule := range result.Rules { - if language, ok := rule.FrontMatter.Content["language"].(string); ok { - if stage, ok := rule.FrontMatter.Content["stage"].(string); ok { - fmt.Printf("Rule: language=%s, stage=%s\n", language, stage) - } - } - } -} - -// ExampleParseMarkdownFile demonstrates how to parse a markdown file -// with frontmatter into a custom struct. -func ExampleParseMarkdownFile() { - // Define your custom struct with yaml tags - type TaskFrontmatter struct { - TaskName string `yaml:"task_name"` - Resume bool `yaml:"resume"` - Priority string `yaml:"priority"` - Tags []string `yaml:"tags"` - } - - // Parse the markdown file into a BaseFrontMatter - var frontmatterMap codingcontext.BaseFrontMatter - md, err := codingcontext.ParseMarkdownFile("path/to/task.md", &frontmatterMap) - if err != nil { - log.Fatal(err) - } - - // Unmarshal the Content into your struct if needed - var frontmatter TaskFrontmatter - yamlBytes, _ := yaml.Marshal(frontmatterMap.Content) - yaml.Unmarshal(yamlBytes, &frontmatter) - - // Access the parsed frontmatter - fmt.Printf("Task: %s\n", frontmatter.TaskName) - fmt.Printf("Resume: %v\n", frontmatter.Resume) - fmt.Printf("Priority: %s\n", frontmatter.Priority) - fmt.Printf("Tags: %v\n", frontmatter.Tags) - - // Access the content - fmt.Printf("Content length: %d\n", len(md.Content)) -} diff --git a/pkg/codingcontext/markdown.go b/pkg/codingcontext/markdown.go index f21e0cf1..a37c2b0a 100644 --- a/pkg/codingcontext/markdown.go +++ b/pkg/codingcontext/markdown.go @@ -15,7 +15,11 @@ func ParseMarkdownFile[T any](path string, frontMatter *T) (Markdown[T], error) if err != nil { return Markdown[T]{}, fmt.Errorf("failed to open file: %w", err) } - defer fh.Close() + defer func() { + if err := fh.Close(); err != nil { + _ = err + } + }() s := bufio.NewScanner(fh) diff --git a/pkg/codingcontext/paths.go b/pkg/codingcontext/paths.go index 5ee06b71..308a2669 100644 --- a/pkg/codingcontext/paths.go +++ b/pkg/codingcontext/paths.go @@ -2,84 +2,127 @@ package codingcontext import "path/filepath" -// AllTaskSearchPaths returns the standard search paths for task files -// baseDir is the working directory to resolve relative paths from -func AllTaskSearchPaths(baseDir, homeDir string) []string { - return []string{ - filepath.Join(baseDir, ".agents", "tasks"), - filepath.Join(baseDir, ".cursor", "commands"), - filepath.Join(baseDir, ".opencode", "command"), - filepath.Join(homeDir, ".agents", "tasks"), - } +// SearchPath represents a single search location with its associated subpaths +type SearchPath struct { + BasePath string + RulesSubPaths []string + TaskSubPaths []string + downloadedDir string // Local path where this directory was downloaded (if applicable) } -// AllRulePaths returns the standard search paths for rule files -// baseDir is the working directory to resolve relative paths from -func AllRulePaths(baseDir, homeDir string) []string { - return []string{ - filepath.Join(baseDir, "CLAUDE.local.md"), - - filepath.Join(baseDir, ".agents", "rules"), - filepath.Join(baseDir, ".cursor", "rules"), - filepath.Join(baseDir, ".augment", "rules"), - filepath.Join(baseDir, ".windsurf", "rules"), - filepath.Join(baseDir, ".opencode", "agent"), - - filepath.Join(baseDir, ".github", "copilot-instructions.md"), - filepath.Join(baseDir, ".gemini", "styleguide.md"), - filepath.Join(baseDir, ".github", "agents"), - filepath.Join(baseDir, ".augment", "guidelines.md"), - - filepath.Join(baseDir, "AGENTS.md"), - filepath.Join(baseDir, "CLAUDE.md"), - filepath.Join(baseDir, "GEMINI.md"), - - filepath.Join(baseDir, ".cursorrules"), - filepath.Join(baseDir, ".windsurfrules"), - - // ancestors - filepath.Join(baseDir, "..", "AGENTS.md"), - filepath.Join(baseDir, "..", "CLAUDE.md"), - filepath.Join(baseDir, "..", "GEMINI.md"), +// TaskSearchDirs returns the full paths for task search directories +// by joining BasePath (or downloadedDir if set) with each TaskSubPath +func (sp SearchPath) TaskSearchDirs() []string { + basePath := sp.BasePath + if sp.downloadedDir != "" { + basePath = sp.downloadedDir + } + dirs := make([]string, 0, len(sp.TaskSubPaths)) + for _, subPath := range sp.TaskSubPaths { + dirs = append(dirs, filepath.Join(basePath, subPath)) + } + return dirs +} - filepath.Join(baseDir, "..", "..", "AGENTS.md"), - filepath.Join(baseDir, "..", "..", "CLAUDE.md"), - filepath.Join(baseDir, "..", "..", "GEMINI.md"), +// RulesSearchDirs returns the full paths for rule search directories +// by joining BasePath (or downloadedDir if set) with each RulesSubPath +func (sp SearchPath) RulesSearchDirs() []string { + basePath := sp.BasePath + if sp.downloadedDir != "" { + basePath = sp.downloadedDir + } + dirs := make([]string, 0, len(sp.RulesSubPaths)) + for _, subPath := range sp.RulesSubPaths { + dirs = append(dirs, filepath.Join(basePath, subPath)) + } + return dirs +} - // 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"), +// DefaultSearchPaths returns the search paths for default local paths (baseDir and homeDir) +func DefaultSearchPaths(baseDir, homeDir string) []SearchPath { + return []SearchPath{ + // baseDir search paths + { + BasePath: baseDir, + RulesSubPaths: []string{ + "CLAUDE.local.md", + ".agents/rules", + ".cursor/rules", + ".augment/rules", + ".windsurf/rules", + ".opencode/agent", + ".github/copilot-instructions.md", + ".gemini/styleguide.md", + ".github/agents", + ".augment/guidelines.md", + "AGENTS.md", + "CLAUDE.md", + "GEMINI.md", + ".cursorrules", + ".windsurfrules", + "../AGENTS.md", + "../CLAUDE.md", + "../GEMINI.md", + "../../AGENTS.md", + "../../CLAUDE.md", + "../../GEMINI.md", + }, + TaskSubPaths: []string{ + ".agents/tasks", + ".cursor/commands", + ".opencode/command", + }, + }, + // homeDir search paths + { + BasePath: homeDir, + RulesSubPaths: []string{ + ".agents/rules", + ".claude/CLAUDE.md", + ".codex/AGENTS.md", + ".gemini/GEMINI.md", + ".opencode/rules", + }, + TaskSubPaths: []string{ + ".agents/tasks", + }, + }, } } -// DownloadedRulePaths returns the search paths for rule files in downloaded directories -func DownloadedRulePaths(dir string) []string { - return []string{ - filepath.Join(dir, ".agents", "rules"), - filepath.Join(dir, ".cursor", "rules"), - filepath.Join(dir, ".augment", "rules"), - filepath.Join(dir, ".windsurf", "rules"), - filepath.Join(dir, ".opencode", "agent"), - filepath.Join(dir, ".github", "copilot-instructions.md"), - filepath.Join(dir, ".gemini", "styleguide.md"), - filepath.Join(dir, ".github", "agents"), - filepath.Join(dir, ".augment", "guidelines.md"), - filepath.Join(dir, "AGENTS.md"), - filepath.Join(dir, "CLAUDE.md"), - filepath.Join(dir, "GEMINI.md"), - filepath.Join(dir, ".cursorrules"), - filepath.Join(dir, ".windsurfrules"), +// NewSearchPathWithDefaults creates a SearchPath with default subpaths for a given base path +// (uses the same default subpaths as PathSearchPaths) +func NewSearchPathWithDefaults(basePath string) SearchPath { + return SearchPath{ + BasePath: basePath, + RulesSubPaths: []string{ + ".agents/rules", + ".cursor/rules", + ".augment/rules", + ".windsurf/rules", + ".opencode/agent", + ".github/copilot-instructions.md", + ".gemini/styleguide.md", + ".github/agents", + ".augment/guidelines.md", + "AGENTS.md", + "CLAUDE.md", + "GEMINI.md", + ".cursorrules", + ".windsurfrules", + }, + TaskSubPaths: []string{ + ".agents/tasks", + ".cursor/commands", + ".opencode/command", + }, } } -// DownloadedTaskSearchPaths returns the search paths for task files in downloaded directories -func DownloadedTaskSearchPaths(dir string) []string { - return []string{ - filepath.Join(dir, ".agents", "tasks"), - filepath.Join(dir, ".cursor", "commands"), - filepath.Join(dir, ".opencode", "command"), +// PathSearchPaths returns the search paths for a given directory path +// (used for both local and remote paths after download) +func PathSearchPaths(dir string) []SearchPath { + return []SearchPath{ + NewSearchPathWithDefaults(dir), } } diff --git a/pkg/codingcontext/remote.go b/pkg/codingcontext/remote.go index 398b2908..80456164 100644 --- a/pkg/codingcontext/remote.go +++ b/pkg/codingcontext/remote.go @@ -9,9 +9,9 @@ import ( getter "github.com/hashicorp/go-getter/v2" ) -// downloadRemoteDirectory downloads a remote directory using go-getter -// and returns the local path where it was downloaded -func downloadRemoteDirectory(ctx context.Context, src string) (string, error) { +// downloadPath downloads or copies a path (local or remote) using go-getter +// and returns the local path where it was downloaded/copied +func downloadPath(ctx context.Context, src string) (string, error) { // Create a temporary directory for the download tmpBase, err := os.MkdirTemp("", "coding-context-remote-*") if err != nil { @@ -24,7 +24,7 @@ func downloadRemoteDirectory(ctx context.Context, src string) (string, error) { // Use go-getter to download the directory _, err = getter.Get(ctx, tmpDir, src) if err != nil { - os.RemoveAll(tmpBase) + _ = os.RemoveAll(tmpBase) return "", fmt.Errorf("failed to download from %s: %w", src, err) } diff --git a/pkg/codingcontext/rule_frontmatter.go b/pkg/codingcontext/rule_frontmatter.go index febe2bfc..ec9db1ca 100644 --- a/pkg/codingcontext/rule_frontmatter.go +++ b/pkg/codingcontext/rule_frontmatter.go @@ -36,15 +36,15 @@ func (r *RuleFrontMatter) UnmarshalJSON(data []byte) error { }{ Alias: (*Alias)(r), } - + if err := json.Unmarshal(data, aux); err != nil { return err } - + // Also unmarshal into Content map - if err := json.Unmarshal(data, &r.BaseFrontMatter.Content); err != nil { + if err := json.Unmarshal(data, &r.Content); err != nil { return err } - + return nil } diff --git a/pkg/codingcontext/selector_map_test.go b/pkg/codingcontext/selector_map_test.go index cb19f61d..41f8f50d 100644 --- a/pkg/codingcontext/selector_map_test.go +++ b/pkg/codingcontext/selector_map_test.go @@ -256,8 +256,12 @@ func TestSelectorMap_MatchesIncludes(t *testing.T) { func TestSelectorMap_String(t *testing.T) { s := make(Selectors) - s.Set("env=production") - s.Set("language=go") + if err := s.Set("env=production"); err != nil { + t.Fatalf("Set() failed: %v", err) + } + if err := s.Set("language=go"); err != nil { + t.Fatalf("Set() failed: %v", err) + } str := s.String() if str == "" { diff --git a/pkg/codingcontext/task_frontmatter.go b/pkg/codingcontext/task_frontmatter.go index 5f832d0e..6424de87 100644 --- a/pkg/codingcontext/task_frontmatter.go +++ b/pkg/codingcontext/task_frontmatter.go @@ -54,7 +54,7 @@ func (t *TaskFrontMatter) UnmarshalJSON(data []byte) error { } // Also unmarshal into Content map - if err := json.Unmarshal(data, &t.BaseFrontMatter.Content); err != nil { + if err := json.Unmarshal(data, &t.Content); err != nil { return err }