From c47fd71d36b2a9f269c3067a5f8c7d6dd6edbd33 Mon Sep 17 00:00:00 2001 From: Conner Dunn Date: Fri, 17 Apr 2026 11:19:12 -0700 Subject: [PATCH 1/4] test: WithLenientAgent & WithLenientSearchPaths --- pkg/codingcontext/context_test.go | 272 ++++++++++++++++++++++++++++++ 1 file changed, 272 insertions(+) diff --git a/pkg/codingcontext/context_test.go b/pkg/codingcontext/context_test.go index 5c29458..e8d2400 100644 --- a/pkg/codingcontext/context_test.go +++ b/pkg/codingcontext/context_test.go @@ -2732,3 +2732,275 @@ func TestSkillDiscovery(t *testing.T) { }) } } + +// TestLenientSearchPaths tests that WithLenientSearchPaths makes a best effort +// to recover or skip problematic files instead of returning errors. +func TestLenientSearchPaths(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setup func(t *testing.T, strictDir, lenientDir string) + taskName string + wantErr bool + checkFunc func(t *testing.T, result *Result) + }{ + { + name: "lenient: infer skill name from directory when name is missing", + setup: func(t *testing.T, strictDir, lenientDir string) { + t.Helper() + createTask(t, strictDir, "test-task", "", "Test task content") + + // Skill missing name — should infer "analyze-transcripts" from directory + createSkill(t, lenientDir, filepath.Join(".agents", "skills", "analyze-transcripts"), `--- +description: Analyzes call transcripts +--- + +# Analyze Transcripts +`) + }, + taskName: "test-task", + wantErr: false, + checkFunc: func(t *testing.T, result *Result) { + t.Helper() + if len(result.Skills.Skills) != 1 { + t.Fatalf("expected 1 skill, got %d", len(result.Skills.Skills)) + } + if result.Skills.Skills[0].Name != "analyze-transcripts" { + t.Errorf("expected inferred skill name 'analyze-transcripts', got %q", result.Skills.Skills[0].Name) + } + }, + }, + { + name: "lenient: skip skill when description is missing", + setup: func(t *testing.T, strictDir, lenientDir string) { + t.Helper() + createTask(t, strictDir, "test-task", "", "Test task content") + + // Skill missing description — should be skipped + createSkill(t, lenientDir, filepath.Join(".agents", "skills", "no-desc-skill"), `--- +name: no-desc-skill +--- + +# No Description Skill +`) + }, + taskName: "test-task", + wantErr: false, + checkFunc: func(t *testing.T, result *Result) { + t.Helper() + if len(result.Skills.Skills) != 0 { + t.Errorf("expected 0 skills (skipped due to missing description), got %d", len(result.Skills.Skills)) + } + }, + }, + { + name: "lenient: skip skill when name exceeds max length", + setup: func(t *testing.T, strictDir, lenientDir string) { + t.Helper() + createTask(t, strictDir, "test-task", "", "Test task content") + + createSkill(t, lenientDir, filepath.Join(".agents", "skills", "long-name-skill"), `--- +name: this-is-a-very-long-skill-name-that-exceeds-the-maximum-allowed-length-of-64-characters +description: Valid description +--- + +# Long Name Skill +`) + }, + taskName: "test-task", + wantErr: false, + checkFunc: func(t *testing.T, result *Result) { + t.Helper() + if len(result.Skills.Skills) != 0 { + t.Errorf("expected 0 skills (skipped due to name too long), got %d", len(result.Skills.Skills)) + } + }, + }, + { + name: "lenient: skip skill with bad YAML frontmatter", + setup: func(t *testing.T, strictDir, lenientDir string) { + t.Helper() + createTask(t, strictDir, "test-task", "", "Test task content") + + createSkill(t, lenientDir, filepath.Join(".agents", "skills", "bad-yaml-skill"), `--- +name: [invalid yaml +description: this won't parse +--- + +# Bad YAML Skill +`) + }, + taskName: "test-task", + wantErr: false, + checkFunc: func(t *testing.T, result *Result) { + t.Helper() + if len(result.Skills.Skills) != 0 { + t.Errorf("expected 0 skills (skipped due to bad YAML), got %d", len(result.Skills.Skills)) + } + }, + }, + { + name: "strict path still errors on skill missing name", + setup: func(t *testing.T, strictDir, _ string) { + t.Helper() + createTask(t, strictDir, "test-task", "", "Test task content") + + // Same broken skill on strict path — should still error + createSkill(t, strictDir, filepath.Join(".agents", "skills", "invalid-skill"), `--- +description: Missing name field +--- + +# Invalid Skill +`) + }, + taskName: "test-task", + wantErr: true, + }, + { + name: "lenient: valid skills from lenient path are still discovered", + setup: func(t *testing.T, strictDir, lenientDir string) { + t.Helper() + createTask(t, strictDir, "test-task", "", "Test task content") + + createSkill(t, lenientDir, filepath.Join(".agents", "skills", "good-skill"), `--- +name: good-skill +description: A perfectly valid skill on a lenient path +--- + +# Good Skill +`) + }, + taskName: "test-task", + wantErr: false, + checkFunc: func(t *testing.T, result *Result) { + t.Helper() + if len(result.Skills.Skills) != 1 { + t.Fatalf("expected 1 skill, got %d", len(result.Skills.Skills)) + } + if result.Skills.Skills[0].Name != "good-skill" { + t.Errorf("expected skill name 'good-skill', got %q", result.Skills.Skills[0].Name) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + strictDir := t.TempDir() + lenientDir := t.TempDir() + + tt.setup(t, strictDir, lenientDir) + + opts := []Option{ + WithSearchPaths("file://" + strictDir), + WithLenientSearchPaths("file://" + lenientDir), + } + cc := New(opts...) + + result, err := cc.Run(context.Background(), tt.taskName) + if tt.wantErr { + if err == nil { + t.Fatal("expected error but got none") + } + + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if tt.checkFunc != nil { + tt.checkFunc(t, result) + } + }) + } +} + +// TestLenientAgent tests that WithLenientAgent makes agent paths lenient. +func TestLenientAgent(t *testing.T) { + t.Parallel() + + t.Run("lenient agent skips skill with missing description", func(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + + createTask(t, tmpDir, "test-task", "", "Test task content") + + // Skill missing description in a claude agent path + createSkill(t, tmpDir, filepath.Join(".claude", "skills", "broken-skill"), `--- +name: broken-skill +--- + +# Broken Skill +`) + + cc := New( + WithSearchPaths("file://"+tmpDir), + WithLenientAgent(AgentClaude), + ) + + result, err := cc.Run(context.Background(), "test-task") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(result.Skills.Skills) != 0 { + t.Errorf("expected 0 skills (skipped due to missing description), got %d", len(result.Skills.Skills)) + } + }) + + t.Run("lenient agent infers skill name from directory", func(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + + createTask(t, tmpDir, "test-task", "", "Test task content") + + // Skill missing name in a claude agent path + createSkill(t, tmpDir, filepath.Join(".claude", "skills", "inferred-skill"), `--- +description: Should infer name from directory +--- + +# Inferred Skill +`) + + cc := New( + WithSearchPaths("file://"+tmpDir), + WithLenientAgent(AgentClaude), + ) + + result, err := cc.Run(context.Background(), "test-task") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(result.Skills.Skills) != 1 { + t.Fatalf("expected 1 skill, got %d", len(result.Skills.Skills)) + } + + if result.Skills.Skills[0].Name != "inferred-skill" { + t.Errorf("expected inferred skill name 'inferred-skill', got %q", result.Skills.Skills[0].Name) + } + }) +} + +// TestLenientAgentMutualExclusion tests that -a and -A are mutually exclusive. +func TestLenientAgentMutualExclusion(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + createTask(t, tmpDir, "test-task", "", "Test task content") + + cc := New( + WithSearchPaths("file://"+tmpDir), + WithAgent(AgentClaude), + WithLenientAgent(AgentClaude), + ) + + _, err := cc.Run(context.Background(), "test-task") + if err == nil { + t.Fatal("expected error when both WithAgent and WithLenientAgent are set, but got none") + } +} From e74bd9d2b28a6e1a6db8a5f082d27792e3f39e46 Mon Sep 17 00:00:00 2001 From: Conner Dunn Date: Fri, 17 Apr 2026 11:45:26 -0700 Subject: [PATCH 2/4] feat: support strict & lenient agent & serach paths --- main.go | 67 ++++++++---- pkg/codingcontext/context.go | 163 ++++++++++++++++++++++++------ pkg/codingcontext/context_test.go | 10 +- pkg/codingcontext/enumerate.go | 7 +- pkg/codingcontext/options.go | 33 +++++- 5 files changed, 219 insertions(+), 61 deletions(-) diff --git a/main.go b/main.go index d229120..84ab586 100644 --- a/main.go +++ b/main.go @@ -18,24 +18,27 @@ import ( ) var ( - errInvalidUsage = errors.New("invalid usage: expected one task name argument and optional user-prompt") - errWriteRulesNoAgent = errors.New("-w flag requires an agent to be specified (via task 'agent' field or -a flag)") - errNoUserRulePath = errors.New("no user rule path available for agent") - errRulesPathEscapesHome = errors.New("rules path escapes home directory") + errInvalidUsage = errors.New("invalid usage: expected one task name argument and optional user-prompt") + errWriteRulesNoAgent = errors.New("-w flag requires an agent to be specified (via task 'agent' field or -a flag)") + errNoUserRulePath = errors.New("no user rule path available for agent") + errRulesPathEscapesHome = errors.New("rules path escapes home directory") + errAgentFlagsMutExcl = errors.New("-a and -A flags are mutually exclusive") ) type cliConfig struct { - workDir string - resume bool - skipBootstrap bool - writeRules bool - agent codingcontext.Agent - params taskparser.Params - includes selectors.Selectors - searchPaths []string - manifestURL string - taskName string - userPrompt string + workDir string + resume bool + skipBootstrap bool + writeRules bool + agent codingcontext.Agent + lenientAgent codingcontext.Agent + params taskparser.Params + includes selectors.Selectors + searchPaths []string + lenientSearchPaths []string + manifestURL string + taskName string + userPrompt string } func main() { @@ -66,17 +69,28 @@ func run(ctx context.Context, logger *slog.Logger) error { cfg.searchPaths = append(cfg.searchPaths, "file://"+cfg.workDir) cfg.searchPaths = append(cfg.searchPaths, "file://"+homeDir) - cc := codingcontext.New( + opts := []codingcontext.Option{ codingcontext.WithParams(cfg.params), codingcontext.WithSelectors(cfg.includes), codingcontext.WithSearchPaths(cfg.searchPaths...), codingcontext.WithLogger(logger), codingcontext.WithResume(cfg.resume), codingcontext.WithBootstrap(!cfg.skipBootstrap), - codingcontext.WithAgent(cfg.agent), codingcontext.WithManifestURL(cfg.manifestURL), codingcontext.WithUserPrompt(cfg.userPrompt), - ) + } + + if len(cfg.lenientSearchPaths) > 0 { + opts = append(opts, codingcontext.WithLenientSearchPaths(cfg.lenientSearchPaths...)) + } + + if cfg.lenientAgent.IsSet() { + opts = append(opts, codingcontext.WithLenientAgent(cfg.lenientAgent)) + } else { + opts = append(opts, codingcontext.WithAgent(cfg.agent)) + } + + cc := codingcontext.New(opts...) result, err := cc.Run(ctx, cfg.taskName) if err != nil { @@ -131,13 +145,24 @@ func parseFlags(logger *slog.Logger) (*cliConfig, error) { flag.Var(&cfg.params, "p", "Parameter to substitute in the prompt. Can be specified multiple times as key=value.") flag.Var(&cfg.includes, "s", "Include rules with matching frontmatter. Can be specified multiple times as key=value.") flag.Func("d", - "Directory containing rules and tasks. Can be specified multiple times. "+ + "Directory containing rules and tasks (strict: errors are fatal). Can be specified multiple times. "+ "Supports various protocols via go-getter (http://, https://, git::, s3::, file:// etc.).", func(s string) error { cfg.searchPaths = append(cfg.searchPaths, s) return nil }) + flag.Func("D", + "Directory containing rules and tasks (lenient: errors are warnings). Can be specified multiple times. "+ + "Supports various protocols via go-getter (http://, https://, git::, s3::, file:// etc.).", + func(s string) error { + cfg.lenientSearchPaths = append(cfg.lenientSearchPaths, s) + + return nil + }) + flag.Var(&cfg.lenientAgent, "A", + "Target agent with lenient error handling (errors are warnings, missing skill names inferred from directory). "+ + "Mutually exclusive with -a. Supported agents: cursor, opencode, copilot, claude, gemini, augment, windsurf, codex.") flag.StringVar(&cfg.manifestURL, "m", "", "Go Getter URL to a manifest file containing search paths (one per line). Every line is included as-is.") @@ -165,6 +190,10 @@ func setupUsage(logger *slog.Logger) { } func parseFlagArgs(cfg *cliConfig) (*cliConfig, error) { + if cfg.agent.IsSet() && cfg.lenientAgent.IsSet() { + return nil, errAgentFlagsMutExcl + } + args := flag.Args() const maxArgs = 2 diff --git a/pkg/codingcontext/context.go b/pkg/codingcontext/context.go index 211a343..37e4748 100644 --- a/pkg/codingcontext/context.go +++ b/pkg/codingcontext/context.go @@ -37,6 +37,9 @@ var ( // ErrSkillDescriptionLength is returned when a skill's description exceeds the maximum length. ErrSkillDescriptionLength = errors.New("skill 'description' field must be 1-1024 characters") + // ErrAgentMutualExclusion is returned when both WithAgent and WithLenientAgent are set. + ErrAgentMutualExclusion = errors.New("WithAgent and WithLenientAgent are mutually exclusive") + // ErrInvalidTaskNameNamespace is returned when the task name has an empty namespace. ErrInvalidTaskNameNamespace = errors.New("namespace must not be empty") // ErrInvalidTaskNameBase is returned when the task name has an empty base name. @@ -51,12 +54,20 @@ const ( ) // Context holds the configuration and state for assembling coding context. +// SearchPath represents a search path with an optional lenient flag. +// When Lenient is true, errors encountered while processing files from this path +// are logged as warnings and skipped rather than treated as fatal errors. +type SearchPath struct { + Path string + Lenient bool +} + type Context struct { params taskparser.Params includes selectors.Selectors manifestURL string - searchPaths []string - downloadedPaths []string + searchPaths []SearchPath + downloadedPaths []SearchPath task markdown.Markdown[markdown.TaskFrontMatter] // Parsed task rules []markdown.Markdown[markdown.RuleFrontMatter] // Collected rule files skills skills.AvailableSkills // Discovered skills (metadata only) @@ -67,6 +78,8 @@ type Context struct { doBootstrap bool // Controls whether to discover rules, skills, and run bootstrap scripts includeByDefault bool // Controls whether unmatched rules/skills are included by default agent Agent + strictAgent bool // Set by WithAgent (explicit strict agent) + lenientAgent bool // Set by WithLenientAgent (agent paths treated as lenient) namespace string // Active namespace derived from task name (e.g. "myteam" from "myteam/fix-bug") userPrompt string // User-provided prompt to append to task lintMode bool @@ -134,13 +147,20 @@ type markdownVisitor func(path string, fm *markdown.BaseFrontMatter) error // The taskName is looked up in task search paths and its content is parsed into blocks. // If the taskName cannot be found as a task file, an error is returned. func (cc *Context) Run(ctx context.Context, taskName string) (*Result, error) { + // Validate that WithAgent and WithLenientAgent are not both set + if cc.strictAgent && cc.lenientAgent { + return nil, ErrAgentMutualExclusion + } + // Parse manifest file first to get additional search paths manifestPaths, err := cc.parseManifestFile(ctx) if err != nil { return nil, fmt.Errorf("failed to parse manifest file: %w", err) } - cc.searchPaths = append(cc.searchPaths, manifestPaths...) + for _, p := range manifestPaths { + cc.searchPaths = append(cc.searchPaths, SearchPath{Path: p}) + } // Download all remote directories (including those from manifest) if err := cc.downloadRemoteDirectories(ctx); err != nil { @@ -218,13 +238,27 @@ func (cc *Context) Run(ctx context.Context, taskName string) (*Result, error) { } func (cc *Context) visitMarkdownFiles(searchDirFn func(path string) []string, visitor markdownVisitor) error { - searchDirs := make([]string, 0, len(cc.downloadedPaths)) - for _, path := range cc.downloadedPaths { - searchDirs = append(searchDirs, searchDirFn(path)...) + type searchDir struct { + path string + lenient bool + } + + searchDirs := make([]searchDir, 0, len(cc.downloadedPaths)) + + for _, sp := range cc.downloadedPaths { + for _, dir := range searchDirFn(sp.Path) { + searchDirs = append(searchDirs, searchDir{path: dir, lenient: sp.Lenient}) + } } for _, dir := range searchDirs { - if err := cc.visitMarkdownInDir(dir, visitor); err != nil { + if err := cc.visitMarkdownInDir(dir.path, visitor); err != nil { + if dir.lenient { + cc.logger.Warn("skipping directory", "path", dir.path, "error", err) + + continue + } + return err } } @@ -723,40 +757,46 @@ func (cc *Context) parseManifestFile(ctx context.Context) ([]string, error) { } func (cc *Context) downloadRemoteDirectories(ctx context.Context) error { - for _, path := range cc.searchPaths { + for _, sp := range cc.searchPaths { // If the path is local, use it directly without downloading - if isLocalPath(path) { - localPath := normalizeLocalPath(path) + if isLocalPath(sp.Path) { + localPath := normalizeLocalPath(sp.Path) cc.logger.Info("Using local directory", "path", localPath) - cc.downloadedPaths = append(cc.downloadedPaths, localPath) + cc.downloadedPaths = append(cc.downloadedPaths, SearchPath{Path: localPath, Lenient: sp.Lenient}) continue } // Download remote directories - cc.logger.Info("Downloading remote directory", "path", path) + cc.logger.Info("Downloading remote directory", "path", sp.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) + dst := downloadDir(sp.Path) + if _, err := getter.Get(ctx, dst, sp.Path); err != nil { + if sp.Lenient { + cc.logger.Warn("skipping remote directory", "path", sp.Path, "error", err) + + continue + } + + return fmt.Errorf("failed to download remote directory %s: %w", sp.Path, err) } cc.logger.Info("Downloaded to", "path", dst) - cc.downloadedPaths = append(cc.downloadedPaths, dst) + cc.downloadedPaths = append(cc.downloadedPaths, SearchPath{Path: dst, Lenient: sp.Lenient}) } return nil } func (cc *Context) cleanupDownloadedDirectories() { - for _, path := range cc.searchPaths { + for _, sp := range cc.searchPaths { // Skip cleanup for local paths - they should not be deleted - if isLocalPath(path) { + if isLocalPath(sp.Path) { continue } // Only clean up downloaded remote directories - dst := downloadDir(path) + dst := downloadDir(sp.Path) if err := os.RemoveAll(dst); err != nil { cc.logger.Error("Error cleaning up downloaded directory", "path", dst, "error", err) } @@ -910,14 +950,35 @@ func (cc *Context) discoverSkills() error { return nil } - var skillPaths []string + type skillDir struct { + path string + lenient bool + } + + // Determine the agent's skill path prefix for lenient agent handling. + var agentSkillSuffix string + if cc.lenientAgent && cc.agent.IsSet() { + agentPaths := getAgentsPaths() + if cfg, ok := agentPaths[cc.agent]; ok { + agentSkillSuffix = cfg.skillsPath + } + } + + var skillPaths []skillDir + + for _, sp := range cc.downloadedPaths { + for _, dir := range namespacedSkillSearchPaths(sp.Path, cc.namespace) { + lenient := sp.Lenient + if !lenient && agentSkillSuffix != "" && strings.HasSuffix(dir, agentSkillSuffix) { + lenient = true + } - for _, path := range cc.downloadedPaths { - skillPaths = append(skillPaths, namespacedSkillSearchPaths(path, cc.namespace)...) + skillPaths = append(skillPaths, skillDir{path: dir, lenient: lenient}) + } } for _, dir := range skillPaths { - if err := cc.discoverSkillsInDir(dir); err != nil { + if err := cc.discoverSkillsInDir(dir.path, dir.lenient); err != nil { return err } } @@ -926,15 +987,27 @@ func (cc *Context) discoverSkills() error { } // discoverSkillsInDir discovers skills within a single directory. -func (cc *Context) discoverSkillsInDir(dir string) error { +func (cc *Context) discoverSkillsInDir(dir string, lenient bool) error { if _, err := os.Stat(dir); os.IsNotExist(err) { return nil } else if err != nil { + if lenient { + cc.logger.Warn("skipping skill directory", "path", dir, "error", err) + + return nil + } + return fmt.Errorf("failed to stat skill directory %s: %w", dir, err) } entries, err := os.ReadDir(dir) if err != nil { + if lenient { + cc.logger.Warn("skipping skill directory", "path", dir, "error", err) + + return nil + } + return fmt.Errorf("failed to read skill directory %s: %w", dir, err) } @@ -945,7 +1018,7 @@ func (cc *Context) discoverSkillsInDir(dir string) error { skillFile := filepath.Join(dir, entry.Name(), "SKILL.md") - if err := cc.loadSkillEntry(skillFile); err != nil { + if err := cc.loadSkillEntry(skillFile, lenient); err != nil { return err } } @@ -954,16 +1027,28 @@ func (cc *Context) discoverSkillsInDir(dir string) error { } // loadSkillEntry loads and validates a single skill from its SKILL.md file. -func (cc *Context) loadSkillEntry(skillFile string) error { +func (cc *Context) loadSkillEntry(skillFile string, lenient bool) error { if _, err := os.Stat(skillFile); os.IsNotExist(err) { return nil } else if err != nil { + if lenient { + cc.logger.Warn("skipping skill file", "path", skillFile, "error", err) + + return nil + } + return fmt.Errorf("failed to stat skill file %s: %w", skillFile, err) } var frontmatter markdown.SkillFrontMatter if _, err := markdown.ParseMarkdownFile(skillFile, &frontmatter); err != nil { + if lenient { + cc.logger.Warn("skipping skill file: failed to parse YAML frontmatter", "path", skillFile, "error", err) + + return nil + } + return fmt.Errorf("failed to parse skill file %s: %w", skillFile, err) } @@ -980,25 +1065,33 @@ func (cc *Context) loadSkillEntry(skillFile string) error { return nil } - return cc.validateAndAddSkill(frontmatter, skillFile, reason) + return cc.validateAndAddSkill(frontmatter, skillFile, reason, lenient) } // validateAndAddSkill validates skill metadata and adds it to the skill collection. -func (cc *Context) validateAndAddSkill(frontmatter markdown.SkillFrontMatter, skillFile, reason string) error { +func (cc *Context) validateAndAddSkill(frontmatter markdown.SkillFrontMatter, skillFile, reason string, lenient bool) error { if frontmatter.Name == "" { - if cc.lintMode { + if lenient { + // Infer name from the skill's parent directory + frontmatter.Name = filepath.Base(filepath.Dir(skillFile)) + cc.logger.Warn("using inferred skill name", "name", frontmatter.Name, "path", skillFile) + } else if cc.lintMode { cc.lintCollector.recordError(skillFile, LintErrorKindSkillValidation, fmt.Sprintf("%v: %s", ErrSkillMissingName, skillFile)) return nil + } else { + return fmt.Errorf("%w: %s", ErrSkillMissingName, skillFile) } - - return fmt.Errorf("%w: %s", ErrSkillMissingName, skillFile) } const maxSkillNameLen = 64 if len(frontmatter.Name) > maxSkillNameLen { - if cc.lintMode { + if lenient { + cc.logger.Warn("skipping skill: name exceeds maximum length", "path", skillFile, "length", len(frontmatter.Name)) + + return nil + } else if cc.lintMode { cc.lintCollector.recordError(skillFile, LintErrorKindSkillValidation, fmt.Sprintf("%v: %s (got %d)", ErrSkillNameLength, skillFile, len(frontmatter.Name))) @@ -1009,7 +1102,11 @@ func (cc *Context) validateAndAddSkill(frontmatter markdown.SkillFrontMatter, sk } if frontmatter.Description == "" { - if cc.lintMode { + if lenient { + cc.logger.Warn("skipping skill: missing 'description' field", "path", skillFile) + + return nil + } else if cc.lintMode { cc.lintCollector.recordError(skillFile, LintErrorKindSkillValidation, fmt.Sprintf("%v: %s", ErrSkillMissingDesc, skillFile)) diff --git a/pkg/codingcontext/context_test.go b/pkg/codingcontext/context_test.go index e8d2400..d68ce8a 100644 --- a/pkg/codingcontext/context_test.go +++ b/pkg/codingcontext/context_test.go @@ -274,12 +274,12 @@ func checkNewWithSearchPaths(t *testing.T, c *Context) { t.Errorf("expected 2 search paths, got %d", len(c.searchPaths)) } - if c.searchPaths[0] != "/path/one" { - t.Errorf("expected first path to be /path/one, got %v", c.searchPaths[0]) + if c.searchPaths[0].Path != "/path/one" { + t.Errorf("expected first path to be /path/one, got %v", c.searchPaths[0].Path) } - if c.searchPaths[1] != "/path/two" { - t.Errorf("expected second path to be /path/two, got %v", c.searchPaths[1]) + if c.searchPaths[1].Path != "/path/two" { + t.Errorf("expected second path to be /path/two, got %v", c.searchPaths[1].Path) } } @@ -342,7 +342,7 @@ func checkNewMultipleCombined(t *testing.T, c *Context) { t.Error("selectors not set correctly") } - if len(c.searchPaths) != 1 || c.searchPaths[0] != "/custom/path" { + if len(c.searchPaths) != 1 || c.searchPaths[0].Path != "/custom/path" { t.Error("search paths not set correctly") } diff --git a/pkg/codingcontext/enumerate.go b/pkg/codingcontext/enumerate.go index 53cf0cf..6e5e331 100644 --- a/pkg/codingcontext/enumerate.go +++ b/pkg/codingcontext/enumerate.go @@ -31,7 +31,9 @@ func (cc *Context) ListTasks(ctx context.Context) ([]DiscoveredTask, error) { return nil, fmt.Errorf("failed to parse manifest file: %w", err) } - cc.searchPaths = append(cc.searchPaths, manifestPaths...) + for _, p := range manifestPaths { + cc.searchPaths = append(cc.searchPaths, SearchPath{Path: p}) + } if err := cc.downloadRemoteDirectories(ctx); err != nil { return nil, fmt.Errorf("failed to download remote directories: %w", err) @@ -43,8 +45,9 @@ func (cc *Context) ListTasks(ctx context.Context) ([]DiscoveredTask, error) { seen := make(map[string]bool) - for _, dir := range cc.downloadedPaths { + for _, sp := range cc.downloadedPaths { // Global tasks. + dir := sp.Path for _, taskDir := range taskSearchPaths(dir) { found, err := listTasksInDir(taskDir, "") if err != nil { diff --git a/pkg/codingcontext/options.go b/pkg/codingcontext/options.go index 69b6651..b697778 100644 --- a/pkg/codingcontext/options.go +++ b/pkg/codingcontext/options.go @@ -31,10 +31,25 @@ func WithManifestURL(manifestURL string) Option { } } -// WithSearchPaths adds one or more search paths. +// WithSearchPaths adds one or more strict search paths. +// Errors encountered while processing files from these paths are treated as fatal. func WithSearchPaths(paths ...string) Option { return func(c *Context) { - c.searchPaths = append(c.searchPaths, paths...) + for _, p := range paths { + c.searchPaths = append(c.searchPaths, SearchPath{Path: p}) + } + } +} + +// WithLenientSearchPaths adds one or more lenient search paths. +// Errors encountered while processing files from these paths are logged as warnings +// and the problematic files are skipped rather than causing a fatal error. +// For skills with a missing name, the name is inferred from the directory name. +func WithLenientSearchPaths(paths ...string) Option { + return func(c *Context) { + for _, p := range paths { + c.searchPaths = append(c.searchPaths, SearchPath{Path: p, Lenient: true}) + } } } @@ -62,9 +77,23 @@ func WithBootstrap(doBootstrap bool) Option { } // WithAgent sets the target agent, which excludes that agent's own rules. +// Agent-specific paths are treated as strict (errors are fatal). +// Mutually exclusive with WithLenientAgent. func WithAgent(agent Agent) Option { return func(c *Context) { c.agent = agent + c.strictAgent = agent.IsSet() + } +} + +// WithLenientAgent sets the target agent with lenient error handling. +// Agent-specific paths are treated as lenient: errors are logged as warnings +// and problematic files are skipped rather than causing a fatal error. +// Mutually exclusive with WithAgent. +func WithLenientAgent(agent Agent) Option { + return func(c *Context) { + c.agent = agent + c.lenientAgent = true } } From 8894cf6049f1ed1a4fe6f15fedf735a1d5fe6ecb Mon Sep 17 00:00:00 2001 From: Conner Dunn Date: Fri, 17 Apr 2026 13:28:22 -0700 Subject: [PATCH 3/4] fix: nits --- main.go | 25 ++++++----------- pkg/codingcontext/context.go | 46 +++++++++++++++---------------- pkg/codingcontext/context_test.go | 2 +- pkg/codingcontext/options.go | 12 +++++++- 4 files changed, 43 insertions(+), 42 deletions(-) diff --git a/main.go b/main.go index 84ab586..8c21435 100644 --- a/main.go +++ b/main.go @@ -69,28 +69,19 @@ func run(ctx context.Context, logger *slog.Logger) error { cfg.searchPaths = append(cfg.searchPaths, "file://"+cfg.workDir) cfg.searchPaths = append(cfg.searchPaths, "file://"+homeDir) - opts := []codingcontext.Option{ + cc := codingcontext.New( codingcontext.WithParams(cfg.params), codingcontext.WithSelectors(cfg.includes), codingcontext.WithSearchPaths(cfg.searchPaths...), + codingcontext.WithLenientSearchPaths(cfg.lenientSearchPaths...), codingcontext.WithLogger(logger), codingcontext.WithResume(cfg.resume), codingcontext.WithBootstrap(!cfg.skipBootstrap), codingcontext.WithManifestURL(cfg.manifestURL), codingcontext.WithUserPrompt(cfg.userPrompt), - } - - if len(cfg.lenientSearchPaths) > 0 { - opts = append(opts, codingcontext.WithLenientSearchPaths(cfg.lenientSearchPaths...)) - } - - if cfg.lenientAgent.IsSet() { - opts = append(opts, codingcontext.WithLenientAgent(cfg.lenientAgent)) - } else { - opts = append(opts, codingcontext.WithAgent(cfg.agent)) - } - - cc := codingcontext.New(opts...) + codingcontext.WithAgent(cfg.agent), + codingcontext.WithLenientAgent(cfg.lenientAgent), + ) result, err := cc.Run(ctx, cfg.taskName) if err != nil { @@ -142,6 +133,9 @@ func parseFlags(logger *slog.Logger) (*cliConfig, error) { flag.Var(&cfg.agent, "a", "Target agent to use. Required when using -w to write rules to the agent's user rules path. "+ "Supported agents: cursor, opencode, copilot, claude, gemini, augment, windsurf, codex.") + flag.Var(&cfg.lenientAgent, "A", + "Target agent with lenient error handling (errors are warnings, missing skill names inferred from directory). "+ + "Mutually exclusive with -a. Supported agents: cursor, opencode, copilot, claude, gemini, augment, windsurf, codex.") flag.Var(&cfg.params, "p", "Parameter to substitute in the prompt. Can be specified multiple times as key=value.") flag.Var(&cfg.includes, "s", "Include rules with matching frontmatter. Can be specified multiple times as key=value.") flag.Func("d", @@ -160,9 +154,6 @@ func parseFlags(logger *slog.Logger) (*cliConfig, error) { return nil }) - flag.Var(&cfg.lenientAgent, "A", - "Target agent with lenient error handling (errors are warnings, missing skill names inferred from directory). "+ - "Mutually exclusive with -a. Supported agents: cursor, opencode, copilot, claude, gemini, augment, windsurf, codex.") flag.StringVar(&cfg.manifestURL, "m", "", "Go Getter URL to a manifest file containing search paths (one per line). Every line is included as-is.") diff --git a/pkg/codingcontext/context.go b/pkg/codingcontext/context.go index 37e4748..945e29c 100644 --- a/pkg/codingcontext/context.go +++ b/pkg/codingcontext/context.go @@ -37,8 +37,9 @@ var ( // ErrSkillDescriptionLength is returned when a skill's description exceeds the maximum length. ErrSkillDescriptionLength = errors.New("skill 'description' field must be 1-1024 characters") - // ErrAgentMutualExclusion is returned when both WithAgent and WithLenientAgent are set. - ErrAgentMutualExclusion = errors.New("WithAgent and WithLenientAgent are mutually exclusive") + // ErrMultipleAgents is returned when more than one agent option (WithAgent, WithLenientAgent) is used. + // These options are mutually exclusive; only one agent may be set. + ErrMultipleAgents = errors.New("only one agent option (WithAgent or WithLenientAgent) may be used") // ErrInvalidTaskNameNamespace is returned when the task name has an empty namespace. ErrInvalidTaskNameNamespace = errors.New("namespace must not be empty") @@ -63,27 +64,27 @@ type SearchPath struct { } type Context struct { - params taskparser.Params - includes selectors.Selectors - manifestURL string - searchPaths []SearchPath - downloadedPaths []SearchPath - task markdown.Markdown[markdown.TaskFrontMatter] // Parsed task - rules []markdown.Markdown[markdown.RuleFrontMatter] // Collected rule files - skills skills.AvailableSkills // Discovered skills (metadata only) - totalTokens int - logger *slog.Logger - cmdRunner func(cmd *exec.Cmd) error + params taskparser.Params + includes selectors.Selectors + manifestURL string + searchPaths []SearchPath + downloadedPaths []SearchPath + task markdown.Markdown[markdown.TaskFrontMatter] // Parsed task + rules []markdown.Markdown[markdown.RuleFrontMatter] // Collected rule files + skills skills.AvailableSkills // Discovered skills (metadata only) + totalTokens int + logger *slog.Logger + cmdRunner func(cmd *exec.Cmd) error resume bool doBootstrap bool // Controls whether to discover rules, skills, and run bootstrap scripts includeByDefault bool // Controls whether unmatched rules/skills are included by default - agent Agent - strictAgent bool // Set by WithAgent (explicit strict agent) - lenientAgent bool // Set by WithLenientAgent (agent paths treated as lenient) - namespace string // Active namespace derived from task name (e.g. "myteam" from "myteam/fix-bug") - userPrompt string // User-provided prompt to append to task - lintMode bool - lintCollector *lintCollector + agent Agent + lenientAgent bool // When true, agent-specific paths are treated as lenient + agentSetCount int // Incremented by WithAgent and WithLenientAgent; >1 means conflict + namespace string // Active namespace derived from task name (e.g. "myteam" from "myteam/fix-bug") + userPrompt string // User-provided prompt to append to task + lintMode bool + lintCollector *lintCollector } // parseNamespacedTaskName splits a task name into its optional namespace and base name. @@ -147,9 +148,8 @@ type markdownVisitor func(path string, fm *markdown.BaseFrontMatter) error // The taskName is looked up in task search paths and its content is parsed into blocks. // If the taskName cannot be found as a task file, an error is returned. func (cc *Context) Run(ctx context.Context, taskName string) (*Result, error) { - // Validate that WithAgent and WithLenientAgent are not both set - if cc.strictAgent && cc.lenientAgent { - return nil, ErrAgentMutualExclusion + if cc.agentSetCount > 1 { + return nil, ErrMultipleAgents } // Parse manifest file first to get additional search paths diff --git a/pkg/codingcontext/context_test.go b/pkg/codingcontext/context_test.go index d68ce8a..cd7b365 100644 --- a/pkg/codingcontext/context_test.go +++ b/pkg/codingcontext/context_test.go @@ -2986,7 +2986,7 @@ description: Should infer name from directory }) } -// TestLenientAgentMutualExclusion tests that -a and -A are mutually exclusive. +// TestLenientAgentMutualExclusion tests that WithAgent and WithLenientAgent are mutually exclusive. func TestLenientAgentMutualExclusion(t *testing.T) { t.Parallel() diff --git a/pkg/codingcontext/options.go b/pkg/codingcontext/options.go index b697778..59413cb 100644 --- a/pkg/codingcontext/options.go +++ b/pkg/codingcontext/options.go @@ -81,8 +81,13 @@ func WithBootstrap(doBootstrap bool) Option { // Mutually exclusive with WithLenientAgent. func WithAgent(agent Agent) Option { return func(c *Context) { + if !agent.IsSet() { + return + } + c.agent = agent - c.strictAgent = agent.IsSet() + c.lenientAgent = false + c.agentSetCount++ } } @@ -92,8 +97,13 @@ func WithAgent(agent Agent) Option { // Mutually exclusive with WithAgent. func WithLenientAgent(agent Agent) Option { return func(c *Context) { + if !agent.IsSet() { + return + } + c.agent = agent c.lenientAgent = true + c.agentSetCount++ } } From 82c3272c0a373cef0a91d245abc8783c53a1c0a1 Mon Sep 17 00:00:00 2001 From: Conner Dunn Date: Fri, 17 Apr 2026 13:50:29 -0700 Subject: [PATCH 4/4] docs: updates for lenient search paths and agent options (-D, -A) --- README.md | 8 ++++++-- SPECIFICATION.md | 4 +++- docs/reference/search-paths.md | 4 +++- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 876b2c8..c5dce71 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,9 @@ Options: -C string Change to directory before doing anything. (default ".") -d value - Remote directory containing rules and tasks. Can be specified multiple times. Supports various protocols via go-getter (http://, https://, git::, s3::, etc.). + Directory containing rules and tasks (strict: errors are fatal). Can be specified multiple times. Supports various protocols via go-getter (http://, https://, git::, s3::, file:// etc.). + -D value + Directory containing rules and tasks (lenient: errors are warnings). Can be specified multiple times. Supports various protocols via go-getter (http://, https://, git::, s3::, file:// etc.). -m string Go Getter URL to a manifest file containing search paths (one per line). Every line is included as-is. -p value @@ -164,7 +166,9 @@ Options: Include rules with matching frontmatter. Can be specified multiple times as key=value. Note: Only matches top-level YAML fields in frontmatter. -a string - Target agent to use. Required when using -w to write rules to the agent's user rules path. Supported agents: cursor, opencode, copilot, claude, gemini, augment, windsurf, codex. + Target agent to use (strict: errors are fatal). Required when using -w to write rules to the agent's user rules path. Supported agents: cursor, opencode, copilot, claude, gemini, augment, windsurf, codex. + -A string + Target agent with lenient error handling (errors are warnings, missing skill names inferred from directory). Mutually exclusive with -a. Supported agents: cursor, opencode, copilot, claude, gemini, augment, windsurf, codex. -w Write rules to agent's config file and output only task to stdout. Requires agent (via task or -a flag). --skip-bootstrap Skip discovering rules, skills, and running bootstrap scripts. diff --git a/SPECIFICATION.md b/SPECIFICATION.md index a479c90..df0e63b 100644 --- a/SPECIFICATION.md +++ b/SPECIFICATION.md @@ -1062,10 +1062,12 @@ Writes rules to: `~/.github/agents/AGENTS.md` ### 10.1 Search Path Order -1. Directories specified via `-d` flags (in order) +1. Directories specified via `-d` (strict) or `-D` (lenient) flags (in order) 2. Working directory (auto-added): `.`, parent dirs for some files 3. User home directory (auto-added): `~` +**Lenient search paths** (`-D`): Errors are logged as warnings and problematic files are skipped instead of causing a fatal error. For skills with a missing `name` field, the name is inferred from the directory name. + ### 10.2 Task Discovery **Search locations (in order):** diff --git a/docs/reference/search-paths.md b/docs/reference/search-paths.md index b5f4785..84281ae 100644 --- a/docs/reference/search-paths.md +++ b/docs/reference/search-paths.md @@ -11,7 +11,9 @@ Complete reference for where the CLI searches for task files and rule files. ## Search Paths Overview -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. +The CLI searches for rules and tasks in directories specified via the `-d` (strict) or `-D` (lenient) flags. The working directory (`-C` or current directory) and home directory (`~`) are **automatically added** as strict search paths, so they don't need to be specified explicitly. + +**Lenient search paths** (`-D`): Errors are logged as warnings and problematic files are skipped instead of causing a fatal error. For skills with a missing `name` field, the name is inferred from the directory name. This is useful for third-party or shared directories where you don't control file quality. All directories (local and remote) are processed via go-getter, which downloads remote directories to temporary locations and processes local directories directly.