Skip to content
Closed
27 changes: 21 additions & 6 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(&params, "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
})
Comment thread
alexec marked this conversation as resolved.

Expand All @@ -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 {
Expand Down
29 changes: 20 additions & 9 deletions pkg/codingcontext/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,17 +71,20 @@ 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("."),
codingcontext.WithParams(codingcontext.Params{
"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))),
)

Expand Down Expand Up @@ -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`
Expand All @@ -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)`

Expand All @@ -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

Expand Down
9 changes: 0 additions & 9 deletions pkg/codingcontext/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
30 changes: 3 additions & 27 deletions pkg/codingcontext/agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
79 changes: 38 additions & 41 deletions pkg/codingcontext/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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...)
}
}

Expand Down Expand Up @@ -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()

Expand All @@ -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)
}
Expand Down Expand Up @@ -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)
}

Expand All @@ -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)
}

Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
Loading