diff --git a/README.md b/README.md index 876b2c8e..c5dce710 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 a479c909..df0e63b6 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 b5f4785e..84281aea 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. diff --git a/main.go b/main.go index d229120e..8c214352 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() { @@ -70,12 +73,14 @@ func run(ctx context.Context, logger *slog.Logger) error { 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.WithAgent(cfg.agent), codingcontext.WithManifestURL(cfg.manifestURL), codingcontext.WithUserPrompt(cfg.userPrompt), + codingcontext.WithAgent(cfg.agent), + codingcontext.WithLenientAgent(cfg.lenientAgent), ) result, err := cc.Run(ctx, cfg.taskName) @@ -128,14 +133,25 @@ 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", - "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.StringVar(&cfg.manifestURL, "m", "", @@ -165,6 +181,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 211a3434..945e29c4 100644 --- a/pkg/codingcontext/context.go +++ b/pkg/codingcontext/context.go @@ -37,6 +37,10 @@ var ( // ErrSkillDescriptionLength is returned when a skill's description exceeds the maximum length. ErrSkillDescriptionLength = errors.New("skill 'description' field must be 1-1024 characters") + // 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") // ErrInvalidTaskNameBase is returned when the task name has an empty base name. @@ -51,26 +55,36 @@ 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 - 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 - 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. @@ -134,13 +148,19 @@ 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) { + if cc.agentSetCount > 1 { + return nil, ErrMultipleAgents + } + // 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 5c294581..cd7b3658 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") } @@ -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 WithAgent and WithLenientAgent 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") + } +} diff --git a/pkg/codingcontext/enumerate.go b/pkg/codingcontext/enumerate.go index 53cf0cf1..6e5e331e 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 69b66512..59413cb5 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,33 @@ 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) { + if !agent.IsSet() { + return + } + + c.agent = agent + c.lenientAgent = false + c.agentSetCount++ + } +} + +// 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) { + if !agent.IsSet() { + return + } + c.agent = agent + c.lenientAgent = true + c.agentSetCount++ } }