diff --git a/Makefile b/Makefile index 1d6d2592..321f5888 100644 --- a/Makefile +++ b/Makefile @@ -7,4 +7,5 @@ lint: $$(go env GOPATH)/bin/goimports -w .; \ fi go vet ./... + go run mvdan.cc/unparam@latest go run golang.org/x/tools/cmd/deadcode@latest -test ./... diff --git a/docs/how-to/use-remote-directories.md b/docs/how-to/use-remote-directories.md index 4a8e145e..66cc72b5 100644 --- a/docs/how-to/use-remote-directories.md +++ b/docs/how-to/use-remote-directories.md @@ -121,17 +121,27 @@ Combine remote directories with local project rules: ```bash # Loads from: -# 1. Remote Git repository -# 2. Local .agents/rules/ directory (if exists) -# 3. Local .github/copilot-instructions.md (if exists) -# etc. +# 1. Remote Git repository (via -d) +# 2. Working directory (automatically added) +# 3. Home directory (automatically added) coding-context \ -d git::https://github.com/company/shared-rules.git \ -s language=Go \ fix-bug ``` -Local rules take precedence over remote rules with the same name. +You can also explicitly add local directories: + +```bash +# Explicitly add a local directory +coding-context \ + -d git::https://github.com/company/shared-rules.git \ + -d file:///path/to/local/rules \ + -s language=Go \ + fix-bug +``` + +**Note:** The working directory (`-C` or current directory) and home directory are automatically added to search paths, so you don't need to specify them explicitly. ### With Selectors and Parameters @@ -373,3 +383,4 @@ Ensure the repository has files in standard locations like `.agents/rules/`. - [Search Paths Reference](../reference/search-paths) - Where files are discovered - [File Formats](../reference/file-formats) - Rule and task file specifications - [go-getter Documentation](https://github.com/hashicorp/go-getter) - Supported protocols and features + diff --git a/docs/reference/cli.md b/docs/reference/cli.md index c981e570..8333e862 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -49,13 +49,15 @@ coding-context -C /path/to/project fix-bug **Type:** String (URL or path) **Repeatable:** Yes -Load rules and tasks from a remote directory. The directory is downloaded to a temporary location before processing and cleaned up afterward. +Load rules and tasks from a directory (remote or local). The directory is processed via go-getter, which downloads remote directories to a temporary location before processing and cleans up afterward. + +**Note:** The working directory (`-C` or current directory) and home directory (`~`) are automatically added to the search paths, so you don't need to specify them explicitly. Supports various protocols via [go-getter](https://github.com/hashicorp/go-getter): - `git::` - Git repositories (HTTPS, SSH) - `http://`, `https://` - HTTP/HTTPS URLs (tar.gz, zip, directories) - `s3::` - S3 buckets -- `file://` - Local file paths +- `file://` - Local file paths (or absolute paths without prefix) **Examples:** ```bash @@ -80,8 +82,13 @@ coding-context \ # Mix local and remote coding-context \ -d git::https://github.com/company/org-standards.git \ + -d file:///path/to/local/rules \ -s language=Go \ fix-bug + +# Local directories are automatically included +# (workDir and homeDir are added automatically) +coding-context fix-bug ``` **See also:** [How to Use Remote Directories](../how-to/use-remote-directories) diff --git a/docs/reference/search-paths.md b/docs/reference/search-paths.md index 971113cc..40df86d0 100644 --- a/docs/reference/search-paths.md +++ b/docs/reference/search-paths.md @@ -9,37 +9,19 @@ nav_order: 3 Complete reference for where the CLI searches for task files and rule files. -## Remote Directories +## Search Paths Overview -When using the `-d` flag, the CLI downloads remote directories to a temporary location and includes them in the search paths. +The CLI searches for rules and tasks in directories specified via the `-d` flag. The working directory (`-C` or current directory) and home directory (`~`) are **automatically added** to the search paths, so they don't need to be specified explicitly. -**Example:** -```bash -coding-context -d git::https://github.com/company/shared-rules.git fix-bug -``` - -The downloaded directory is searched for rules and tasks in all standard locations (`.agents/rules/`, `.agents/tasks/`, `AGENTS.md`, etc.) before being automatically cleaned up. - -Multiple remote directories can be specified and are processed in the order given: -```bash -coding-context \ - -d git::https://github.com/company/org-standards.git \ - -d git::https://github.com/team/team-rules.git \ - fix-bug -``` - -See [How to Use Remote Directories](../how-to/use-remote-directories) for complete documentation. - -## Local Search Paths +All directories (local and remote) are processed via go-getter, which downloads remote directories to temporary locations and processes local directories directly. ### Task File Search Paths -Task files are searched in the following directories, in order of precedence: +Within each directory, task files are searched in the following locations: -1. `./.agents/tasks/` -2. `./.cursor/commands/` -3. `./.opencode/command/` -4. `~/.agents/tasks/` +1. `.agents/tasks/` +2. `.cursor/commands/` +3. `.opencode/command/` ### Discovery Rules @@ -48,7 +30,7 @@ Task files are searched in the following directories, in order of precedence: - If `task_name` is absent, the filename (without `.md` extension) is used as the task name - First match wins (unless selectors create ambiguity) - Searches stop when a matching task is found -- Remote directories (via `-d` flag) are searched before local directories +- Directories are searched in the order they appear in `-d` flags, then the automatically-added working directory and home directory ### Example @@ -60,69 +42,63 @@ Project structure: ~/.agents/tasks/code-review.md (task_name: code-review) Commands: -coding-context fix-bug → Uses ./.agents/tasks/fix-bug.md -coding-context review-code → Uses ./.opencode/command/review-code.md -coding-context deploy → Uses ./.opencode/command/deploy.md -coding-context code-review → Uses ~/.agents/tasks/code-review.md -coding-context deploy → Uses ~/.config/opencode/command/deploy.md +coding-context fix-bug → Uses ./.agents/tasks/fix-bug.md (from working directory) +coding-context review-code → Uses ./.opencode/command/review-code.md (from working directory) +coding-context deploy → Uses ./.opencode/command/deploy.md (from working directory) +coding-context code-review → Uses ~/.agents/tasks/code-review.md (from home directory) ``` -## Rule File Search Paths - -Rule files are discovered from multiple locations supporting various AI agent formats. +**Note:** The working directory and home directory are automatically added to search paths, so tasks in those locations are found automatically. +``` -### Remote Directories (Highest Precedence) +## Rule File Search Paths -When using `-d` flag, remote directories are searched first: +Rule files are discovered from directories specified via the `-d` flag (plus automatically-added working directory and home directory). Within each directory, the CLI searches for all standard file patterns listed below. -```bash -coding-context -d git::https://github.com/company/rules.git fix-bug -``` +### Directory Processing Order -The remote directory is searched for all standard file patterns listed below. +1. Directories specified via `-d` flags (in order) +2. Working directory (`-C` flag or current directory) - added automatically +3. Home directory (`~`) - added automatically -### Project-Specific Rules +### Rule File Locations Within Each Directory **Agent-specific directories:** ``` -./.agents/rules/ -./.cursor/rules/ -./.augment/rules/ -./.windsurf/rules/ -./.opencode/agent/ -./.opencode/command/ -./.opencode/rules/ -./.github/agents/ -./.codex/ +.agents/rules/ +.cursor/rules/ +.augment/rules/ +.windsurf/rules/ +.opencode/agent/ +.opencode/command/ +.opencode/rules/ +.github/agents/ +.codex/ ``` **Specific files:** ``` -./CLAUDE.local.md -./.github/copilot-instructions.md -./.gemini/styleguide.md +CLAUDE.local.md +.github/copilot-instructions.md +.gemini/styleguide.md ``` -**Standard files (searched in current and parent directories):** +**Standard files:** ``` -./AGENTS.md -./CLAUDE.md -./GEMINI.md -../ (continues up to root) +AGENTS.md +CLAUDE.md +GEMINI.md +.cursorrules +.windsurfrules ``` -### User-Specific Rules (Medium Precedence) - +**User-specific locations (only in home directory):** ``` -~/.agents/rules/ -~/.claude/CLAUDE.md -~/.cursor/rules/ -~/.augment/rules/ -~/.windsurf/rules/ -~/.opencode/rules/ -~/.github/agents/ -~/.codex/AGENTS.md -~/.gemini/styleguide.md +.agents/rules/ +.claude/CLAUDE.md +.codex/AGENTS.md +.gemini/GEMINI.md +.opencode/rules/ ``` ## Supported AI Agent Formats @@ -151,35 +127,37 @@ The CLI processes: Other file types are ignored. -### Directory Traversal +### Directory Processing -For standard files (like `AGENTS.md`, `CLAUDE.md`): -1. Start in current directory (or `-C` directory) -2. Check for file -3. Move to parent directory -4. Repeat until root or file found +The CLI searches within each directory specified in search paths. It does not traverse parent directories automatically. Each directory is searched independently for the standard file patterns listed above. **Example:** ``` -/home/user/projects/myapp/backend/ - -Searches: -/home/user/projects/myapp/backend/AGENTS.md -/home/user/projects/myapp/AGENTS.md -/home/user/projects/AGENTS.md -/home/user/AGENTS.md -/home/AGENTS.md -/AGENTS.md +Search paths: +1. /home/user/projects/myapp/backend/ (working directory, auto-added) +2. /home/user/ (home directory, auto-added) + +Searches in /home/user/projects/myapp/backend/: +- .agents/rules/ +- .agents/tasks/ +- CLAUDE.md +- AGENTS.md +- etc. + +Searches in /home/user/: +- .agents/rules/ +- .claude/CLAUDE.md +- etc. ``` ### Precedence Order -When multiple rule files exist: -1. Project-specific (`./.agents/rules/`) -2. Parent directories (moving up) -3. User-specific (`~/.agents/rules/`) +When multiple rule files exist across different directories: +1. Directories specified via `-d` flags (in order) +2. Working directory +3. Home directory -All matching files are included (unless filtered by selectors). +Within each directory, all matching files are included (unless filtered by selectors). ## Bootstrap Script Discovery @@ -200,20 +178,17 @@ For each rule file `rule-name.md`, the CLI looks for `rule-name-bootstrap` in th ## Working Directory -The `-C` option changes the working directory before searching: +The `-C` option changes the working directory, which is automatically added to the search paths: ```bash # Search from /path/to/project coding-context -C /path/to/project fix-bug -# Equivalent to: -cd /path/to/project && coding-context fix-bug +# The working directory is automatically included, equivalent to: +coding-context -d file:///path/to/project fix-bug ``` -This affects: -- Where `./.agents/` is located -- Parent directory traversal starting point -- Bootstrap script execution directory +The working directory is automatically included in search paths, so rules and tasks in that directory are discovered automatically. ## Custom Organization @@ -303,14 +278,17 @@ coding-context -s team=backend fix-bug ## Troubleshooting **No rules found:** -- Check that `.agents/rules/` directory exists +- Check that directories are in search paths (working directory and home directory are added automatically) +- Verify that `.agents/rules/` directory exists in one of the search path directories - Verify files have `.md` or `.mdc` extension - Check file permissions (must be readable) +- For remote directories, verify the download succeeded (check stderr logs) **Task not found:** -- Verify `.agents/tasks/` directory exists -- Check `task_name` field in frontmatter +- Verify that `.agents/tasks/`, `.cursor/commands/`, or `.opencode/command/` directory exists in one of the search path directories +- Check `task_name` field in frontmatter matches the task name you're using - Ensure filename has `.md` extension +- Verify the directory containing the task is in search paths (working directory and home directory are added automatically) **Rules not filtered correctly:** - Verify frontmatter YAML is valid diff --git a/main.go b/main.go index 793d6702..387e24e8 100644 --- a/main.go +++ b/main.go @@ -24,17 +24,19 @@ func main() { var agent codingcontext.Agent params := make(codingcontext.Params) includes := make(codingcontext.Selectors) - var remotePaths []string + var searchPaths []string + var manifestURL 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", "Directory containing rules and tasks. Can be specified multiple times. Supports various protocols via go-getter (http://, https://, git::, s3::, file:// etc.).", func(s string) error { + searchPaths = append(searchPaths, s) return nil }) + flag.StringVar(&manifestURL, "m", "", "Go Getter URL to a manifest file containing search paths (one per line). Every line is included as-is.") flag.Usage = func() { logger.Info("Usage:") @@ -52,14 +54,23 @@ func main() { os.Exit(1) } + homeDir, err := os.UserHomeDir() + if err != nil { + logger.Error("Error", "error", fmt.Errorf("failed to get user home directory: %w", err)) + os.Exit(1) + } + + searchPaths = append(searchPaths, "file://"+workDir) + searchPaths = append(searchPaths, "file://"+homeDir) + cc := codingcontext.New( - codingcontext.WithWorkDir(workDir), codingcontext.WithParams(params), codingcontext.WithSelectors(includes), - codingcontext.WithRemotePaths(remotePaths), + codingcontext.WithSearchPaths(searchPaths...), codingcontext.WithLogger(logger), codingcontext.WithResume(resume), codingcontext.WithAgent(agent), + codingcontext.WithManifestURL(manifestURL), ) result, err := cc.Run(ctx, args[0]) 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..bdfbe71d 100644 --- a/pkg/codingcontext/agent_test.go +++ b/pkg/codingcontext/agent_test.go @@ -382,29 +382,3 @@ func TestAgent_IsSet(t *testing.T) { 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..d446706a 100644 --- a/pkg/codingcontext/context.go +++ b/pkg/codingcontext/context.go @@ -1,22 +1,25 @@ package codingcontext import ( + "bufio" "context" + "crypto/sha256" "fmt" "log/slog" "os" "os/exec" "path/filepath" "strings" + + "github.com/hashicorp/go-getter/v2" ) // Context holds the configuration and state for assembling coding context type Context struct { - workDir string params Params includes Selectors - remotePaths []string - downloadedDirs []string + manifestURL string + searchPaths []string matchingTaskFile string task Markdown[TaskFrontMatter] // Parsed task rules []Markdown[RuleFrontMatter] // Collected rule files @@ -30,13 +33,6 @@ type Context struct { // Option is a functional option for configuring a Context type Option func(*Context) -// WithWorkDir sets the working directory -func WithWorkDir(dir string) Option { - return func(c *Context) { - c.workDir = dir - } -} - // WithParams sets the parameters func WithParams(params Params) Option { return func(c *Context) { @@ -51,10 +47,17 @@ func WithSelectors(selectors Selectors) Option { } } -// WithRemotePaths sets the remote paths -func WithRemotePaths(paths []string) Option { +// WithManifestURL sets the manifest URL +func WithManifestURL(manifestURL string) Option { return func(c *Context) { - c.remotePaths = paths + c.manifestURL = manifestURL + } +} + +// WithSearchPaths adds one or more search paths +func WithSearchPaths(paths ...string) Option { + return func(c *Context) { + c.searchPaths = append(c.searchPaths, paths...) } } @@ -82,7 +85,6 @@ func WithAgent(agent Agent) Option { // New creates a new Context with the given options func New(opts ...Option) *Context { c := &Context{ - workDir: ".", params: make(Params), includes: make(Selectors), rules: make([]Markdown[RuleFrontMatter], 0), @@ -128,7 +130,13 @@ func (cc *Context) Run(ctx context.Context, taskName string) (*Result, error) { return nil, fmt.Errorf("failed to get user home directory: %w", err) } - err = cc.findTaskFile(homeDir, taskName) + searchPaths, err := cc.parseManifestFile(ctx) + if err != nil { + return nil, fmt.Errorf("failed to parse manifest file: %w", err) + } + cc.searchPaths = append(cc.searchPaths, searchPaths...) + + err = cc.findTaskFile(taskName) if err != nil { return nil, fmt.Errorf("failed to find task file: %w", err) } @@ -178,7 +186,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) } @@ -213,39 +221,82 @@ func (cc *Context) Run(ctx context.Context, taskName string) (*Result, error) { return result, nil } +func downloadDir(path string) string { + // hash the path and prepend it with a temporary directory + hash := sha256.Sum256([]byte(path)) + tempDir := os.TempDir() + return filepath.Join(tempDir, fmt.Sprintf("%x", hash)) +} + +// parseManifestFile downloads a manifest file from a Go Getter URL and returns +// the list of search paths (one per line). Every line is included as-is without trimming. +func (cc *Context) parseManifestFile(ctx context.Context) ([]string, error) { + if cc.manifestURL == "" { + return nil, nil + } + + manifestFile := downloadDir(cc.manifestURL) + + // Download the manifest file using go-getter + if _, err := getter.Get(ctx, manifestFile, cc.manifestURL); err != nil { + return nil, fmt.Errorf("failed to download manifest file %s: %w", cc.manifestURL, err) + } + defer os.RemoveAll(manifestFile) + + cc.logger.Info("Downloaded manifest file", "path", manifestFile) + + // Read and parse the manifest file + file, err := os.Open(manifestFile) + if err != nil { + return nil, fmt.Errorf("failed to open manifest file: %w", err) + } + defer file.Close() + + var paths []string + scanner := bufio.NewScanner(file) + for scanner.Scan() { + paths = append(paths, scanner.Text()) + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("failed to read manifest file: %w", err) + } + + cc.logger.Info("Parsed manifest file", "url", cc.manifestURL, "paths", len(paths)) + + return paths, 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) - if err != nil { - return fmt.Errorf("failed to download remote directory %s: %w", remotePath, err) + for _, path := range cc.searchPaths { + cc.logger.Info("Downloading remote directory", "path", path) + dst := downloadDir(path) + if _, err := getter.Get(ctx, dst, path); err != nil { + return fmt.Errorf("failed to download remote directory %s: %w", path, err) } - cc.downloadedDirs = append(cc.downloadedDirs, localPath) - cc.logger.Info("Downloaded to", "path", localPath) + cc.logger.Info("Downloaded to", "path", dst) } 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 _, path := range cc.searchPaths { + dst := downloadDir(path) + if err := os.RemoveAll(dst); err != nil { + cc.logger.Error("Error cleaning up downloaded directory", "path", dst, "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 { + var taskSearchDirs []string // Add downloaded remote directories to task search paths - for _, dir := range cc.downloadedDirs { - taskSearchDirs = append(taskSearchDirs, DownloadedTaskSearchPaths(dir)...) + for _, path := range cc.searchPaths { + dst := downloadDir(path) + subPaths := taskSearchPaths(dst) + taskSearchDirs = append(taskSearchDirs, subPaths...) } for _, dir := range taskSearchDirs { @@ -315,15 +366,15 @@ func (cc *Context) findExecuteRuleFiles(ctx context.Context, homeDir string) err return nil } - // Build the list of rule locations (local and remote) - rulePaths := AllRulePaths(cc.workDir, homeDir) - - // Append remote directories to rule paths - for _, dir := range cc.downloadedDirs { - rulePaths = append(rulePaths, DownloadedRulePaths(dir)...) + var ruleSearchDirs []string + for _, path := range cc.searchPaths { + dst := downloadDir(path) + subPaths := rulePaths(dst, path == homeDir) + ruleSearchDirs = append(ruleSearchDirs, subPaths...) } - for _, rule := range rulePaths { + // Build the list of rule locations (local and remote) + for _, rule := range ruleSearchDirs { // Skip if this path should be excluded based on target agent if cc.agent.ShouldExcludePath(rule) { cc.logger.Info("Excluding rule path (target agent filtering)", "path", rule) diff --git a/pkg/codingcontext/context_test.go b/pkg/codingcontext/context_test.go index 38a44313..d09dfe57 100644 --- a/pkg/codingcontext/context_test.go +++ b/pkg/codingcontext/context_test.go @@ -127,11 +127,14 @@ func TestRun(t *testing.T) { 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)), + searchPaths: func() []string { + abs, _ := filepath.Abs(tmpDir) + return []string{"file://" + abs} + }(), + rules: make([]Markdown[RuleFrontMatter], 0), + logger: slog.New(slog.NewTextHandler(&logOut, nil)), cmdRunner: func(cmd *exec.Cmd) error { return nil // Mock command runner }, @@ -176,13 +179,13 @@ func TestRun(t *testing.T) { func TestFindTaskFile(t *testing.T) { tests := []struct { - name string - taskName string - includes Selectors - setupFiles func(t *testing.T, tmpDir string) - downloadedDirs []string // Directories to add to downloadedDirs - wantErr bool - errContains string + name string + taskName string + includes Selectors + setupFiles func(t *testing.T, tmpDir string) + searchPaths []string // Search paths to use (will be downloaded via downloadDir) + wantErr bool + errContains string }{ { name: "task file not found", @@ -272,8 +275,8 @@ func TestFindTaskFile(t *testing.T) { "", "# Downloaded Task") }, - downloadedDirs: []string{"downloaded"}, // Relative path, will be joined with tmpDir - wantErr: false, + searchPaths: []string{"downloaded"}, // Will be resolved relative to tmpDir + wantErr: false, }, { name: "task file found in .cursor/commands directory", @@ -297,8 +300,8 @@ func TestFindTaskFile(t *testing.T) { "", "# Cursor Remote Task") }, - downloadedDirs: []string{"downloaded"}, // Relative path, will be joined with tmpDir - wantErr: false, + searchPaths: []string{"downloaded"}, // Will be resolved relative to tmpDir + wantErr: false, }, { name: "task file found in .opencode/command directory", @@ -322,8 +325,8 @@ func TestFindTaskFile(t *testing.T) { "", "# OpenCode Remote Task") }, - downloadedDirs: []string{"downloaded"}, // Relative path, will be joined with tmpDir - wantErr: false, + searchPaths: []string{"downloaded"}, // Will be resolved relative to tmpDir + wantErr: false, }, } @@ -344,26 +347,52 @@ func TestFindTaskFile(t *testing.T) { cc := &Context{ includes: tt.includes, + logger: slog.New(slog.NewTextHandler(os.Stderr, nil)), } if cc.includes == nil { cc.includes = make(Selectors) } cc.includes.SetValue("task_name", tt.taskName) - // Set downloadedDirs 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) + // Always add tmpDir to searchPaths so local files are found + // Ensure absolute path for go-getter + absTmpDir, err := filepath.Abs(tmpDir) + if err != nil { + t.Fatalf("failed to get absolute path: %v", err) + } + cc.searchPaths = []string{"file://" + absTmpDir} + + // Add additional searchPaths if specified in test case + if len(tt.searchPaths) > 0 { + additionalPaths := make([]string, len(tt.searchPaths)) + for i, path := range tt.searchPaths { + // Convert relative paths to absolute file:// URLs + if strings.HasPrefix(path, "file://") { + // Already a file:// URL, resolve relative to tmpDir + relPath := strings.TrimPrefix(path, "file://") + absPath, err := filepath.Abs(filepath.Join(tmpDir, relPath)) + if err != nil { + t.Fatalf("failed to get absolute path: %v", err) + } + additionalPaths[i] = "file://" + absPath + } else { + absPath, err := filepath.Abs(filepath.Join(tmpDir, path)) + if err != nil { + t.Fatalf("failed to get absolute path: %v", err) + } + additionalPaths[i] = "file://" + absPath + } } + cc.searchPaths = append(cc.searchPaths, additionalPaths...) } - homeDir, err := os.UserHomeDir() - if err != nil { - t.Fatalf("failed to get user home directory: %v", err) + // Download the directories + if err := cc.downloadRemoteDirectories(context.Background()); err != nil { + t.Fatalf("failed to download remote directories: %v", err) } + defer cc.cleanupDownloadedDirectories() - err = cc.findTaskFile(homeDir, tt.taskName) + err = cc.findTaskFile(tt.taskName) if tt.wantErr { if err == nil { @@ -389,7 +418,7 @@ func TestFindExecuteRuleFiles(t *testing.T) { includes Selectors params Params // Parameters for template expansion setupFiles func(t *testing.T, tmpDir string) - downloadedDirs []string // Directories to add to downloadedDirs + searchPaths []string // Search paths to use (will be downloaded via downloadDir) wantTokens int wantMinTokens bool // Check that tokens > 0 expectInOutput string @@ -483,7 +512,7 @@ func TestFindExecuteRuleFiles(t *testing.T) { "", "# Remote Rule") }, - downloadedDirs: []string{"downloaded"}, // Relative path, will be joined with tmpDir + searchPaths: []string{"file://downloaded"}, // Will be resolved relative to tmpDir wantMinTokens: true, expectInOutput: "Downloaded Rule", }, @@ -577,13 +606,42 @@ func TestFindExecuteRuleFiles(t *testing.T) { cc.params = make(Params) } - // Set downloadedDirs 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) + // Always add tmpDir to searchPaths so local files are found + absTmpDir, err := filepath.Abs(tmpDir) + if err != nil { + t.Fatalf("failed to get absolute path: %v", err) + } + cc.searchPaths = []string{"file://" + absTmpDir} + + // Add additional searchPaths if specified in test case + if len(tt.searchPaths) > 0 { + additionalPaths := make([]string, len(tt.searchPaths)) + for i, path := range tt.searchPaths { + // Convert relative paths to absolute file:// URLs + if strings.HasPrefix(path, "file://") { + // Already a file:// URL, resolve relative to tmpDir + relPath := strings.TrimPrefix(path, "file://") + absPath, err := filepath.Abs(filepath.Join(tmpDir, relPath)) + if err != nil { + t.Fatalf("failed to get absolute path: %v", err) + } + additionalPaths[i] = "file://" + absPath + } else { + absPath, err := filepath.Abs(filepath.Join(tmpDir, path)) + if err != nil { + t.Fatalf("failed to get absolute path: %v", err) + } + additionalPaths[i] = "file://" + absPath + } } + cc.searchPaths = append(cc.searchPaths, additionalPaths...) + } + + // Download the directories + if err := cc.downloadRemoteDirectories(context.Background()); err != nil { + t.Fatalf("failed to download remote directories: %v", err) } + defer cc.cleanupDownloadedDirectories() err = cc.findExecuteRuleFiles(context.Background(), tmpDir) if err != nil { @@ -841,7 +899,6 @@ func TestWriteTaskFileContent(t *testing.T) { var logOut bytes.Buffer cc := &Context{ - workDir: tmpDir, matchingTaskFile: taskPath, params: tt.params, rules: make([]Markdown[RuleFrontMatter], 0), @@ -881,11 +938,9 @@ 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 - } + _ = cc.task.FrontMatter.Content["task_name"] // Check if task_name exists (not all tasks have it) } // Note: Token counting is done in Run(), not in these internal methods @@ -1321,7 +1376,6 @@ func TestTaskSelectorsFilterRulesByRuleName(t *testing.T) { var logOut bytes.Buffer cc := &Context{ - workDir: tmpDir, includes: make(Selectors), rules: make([]Markdown[RuleFrontMatter], 0), logger: slog.New(slog.NewTextHandler(&logOut, nil)), @@ -1332,6 +1386,17 @@ func TestTaskSelectorsFilterRulesByRuleName(t *testing.T) { // Set up task name in includes (as done in run()) cc.includes.SetValue("task_name", "test-task") + absTmpDir, err := filepath.Abs(tmpDir) + if err != nil { + t.Fatalf("failed to get absolute path: %v", err) + } + cc.searchPaths = []string{"file://" + absTmpDir} + + // Download the directories + if err := cc.downloadRemoteDirectories(context.Background()); err != nil { + t.Fatalf("failed to download remote directories: %v", err) + } + defer cc.cleanupDownloadedDirectories() // Find and parse task file homeDir, err := os.UserHomeDir() @@ -1339,7 +1404,7 @@ func TestTaskSelectorsFilterRulesByRuleName(t *testing.T) { 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) } @@ -1740,11 +1805,14 @@ 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)), + searchPaths: func() []string { + abs, _ := filepath.Abs(tmpDir) + return []string{"file://" + abs} + }(), + rules: make([]Markdown[RuleFrontMatter], 0), + logger: slog.New(slog.NewTextHandler(&logOut, nil)), cmdRunner: func(cmd *exec.Cmd) error { return nil }, @@ -1852,7 +1920,7 @@ func TestTaskLanguageFieldFilteringRules(t *testing.T) { createMarkdownFile(t, taskPath, tt.taskFrontmatter, "# Test Task Content") cc := New( - WithWorkDir(tmpDir), + WithSearchPaths(func() string { abs, _ := filepath.Abs(tmpDir); return "file://" + abs }()), ) result, err := cc.Run(ctx, "test-task") @@ -1883,7 +1951,7 @@ func TestTaskLanguageFieldFilteringRules(t *testing.T) { // Verify task frontmatter contains the language field if strings.Contains(tt.taskFrontmatter, "language:") { - if _, ok := result.Task.FrontMatter.Content["language"]; !ok { + if _, exists := result.Task.FrontMatter.Content["language"]; !exists { t.Errorf("Expected task frontmatter to contain 'language' field") } } @@ -1914,7 +1982,9 @@ mcp_servers: taskPath := filepath.Join(tmpDir, ".agents", "tasks", "test-task.md") createMarkdownFile(t, taskPath, taskFrontmatter, "# Test Task") - cc := New(WithWorkDir(tmpDir)) + cc := New( + WithSearchPaths(func() string { abs, _ := filepath.Abs(tmpDir); return "file://" + abs }()), + ) result, err := cc.Run(context.Background(), "test-task") if err != nil { t.Fatalf("Run() error = %v", err) @@ -1996,9 +2066,9 @@ func TestWithResume(t *testing.T) { var logOut bytes.Buffer cc := New( - WithWorkDir(tmpDir), WithResume(true), WithLogger(slog.New(slog.NewTextHandler(&logOut, nil))), + WithSearchPaths(func() string { abs, _ := filepath.Abs(tmpDir); return "file://" + abs }()), ) result, err := cc.Run(context.Background(), "resume_task") @@ -2021,9 +2091,9 @@ func TestWithResume(t *testing.T) { // Test that WithResume(false) includes rules cc2 := New( - WithWorkDir(tmpDir), WithResume(false), WithLogger(slog.New(slog.NewTextHandler(&logOut, nil))), + WithSearchPaths(func() string { abs, _ := filepath.Abs(tmpDir); return "file://" + abs }()), ) result2, err := cc2.Run(context.Background(), "resume_task") @@ -2091,9 +2161,9 @@ func TestWithAgent(t *testing.T) { } cc := New( - WithWorkDir(tmpDir), WithAgent(agent), WithLogger(slog.New(slog.NewTextHandler(&logOut, nil))), + WithSearchPaths(func() string { abs, _ := filepath.Abs(tmpDir); return "file://" + abs }()), ) result, err := cc.Run(context.Background(), "test-task") 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/paths.go b/pkg/codingcontext/paths.go index 5ee06b71..a05d961c 100644 --- a/pkg/codingcontext/paths.go +++ b/pkg/codingcontext/paths.go @@ -2,61 +2,18 @@ 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"), - } -} - -// 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"), - - filepath.Join(baseDir, "..", "..", "AGENTS.md"), - filepath.Join(baseDir, "..", "..", "CLAUDE.md"), - filepath.Join(baseDir, "..", "..", "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"), - } -} - // DownloadedRulePaths returns the search paths for rule files in downloaded directories -func DownloadedRulePaths(dir string) []string { +func rulePaths(dir string, home bool) []string { + if home { + return []string{ + // user + filepath.Join(dir, ".agents", "rules"), + filepath.Join(dir, ".claude", "CLAUDE.md"), + filepath.Join(dir, ".codex", "AGENTS.md"), + filepath.Join(dir, ".gemini", "GEMINI.md"), + filepath.Join(dir, ".opencode", "rules"), + } + } return []string{ filepath.Join(dir, ".agents", "rules"), filepath.Join(dir, ".cursor", "rules"), @@ -69,14 +26,15 @@ func DownloadedRulePaths(dir string) []string { filepath.Join(dir, ".augment", "guidelines.md"), filepath.Join(dir, "AGENTS.md"), filepath.Join(dir, "CLAUDE.md"), + filepath.Join(dir, "CLAUDE.local.md"), filepath.Join(dir, "GEMINI.md"), filepath.Join(dir, ".cursorrules"), filepath.Join(dir, ".windsurfrules"), } } -// DownloadedTaskSearchPaths returns the search paths for task files in downloaded directories -func DownloadedTaskSearchPaths(dir string) []string { +// taskSearchPaths returns the search paths for task files in a directory +func taskSearchPaths(dir string) []string { return []string{ filepath.Join(dir, ".agents", "tasks"), filepath.Join(dir, ".cursor", "commands"), diff --git a/pkg/codingcontext/remote.go b/pkg/codingcontext/remote.go deleted file mode 100644 index 398b2908..00000000 --- a/pkg/codingcontext/remote.go +++ /dev/null @@ -1,32 +0,0 @@ -package codingcontext - -import ( - "context" - "fmt" - "os" - "path/filepath" - - 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) { - // Create a temporary directory for the download - tmpBase, err := os.MkdirTemp("", "coding-context-remote-*") - if err != nil { - return "", fmt.Errorf("failed to create temp dir: %w", err) - } - - // go-getter requires the destination to not exist, so create a subdirectory - tmpDir := filepath.Join(tmpBase, "download") - - // Use go-getter to download the directory - _, err = getter.Get(ctx, tmpDir, src) - if err != nil { - os.RemoveAll(tmpBase) - return "", fmt.Errorf("failed to download from %s: %w", src, err) - } - - return tmpDir, nil -} diff --git a/pkg/codingcontext/rule_frontmatter.go b/pkg/codingcontext/rule_frontmatter.go index febe2bfc..20d49684 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 { return err } - + return nil }