diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 1e53bc9..c981e57 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -352,6 +352,65 @@ metadata: coding-context -s metadata.language=Go fix-bug ``` +## Slash Commands + +Slash command parsing is **always enabled** in task files. When a task file contains a slash command (e.g., `/task-name arg1 "arg 2"`), the CLI will automatically: + +1. Extract the task name and arguments from the slash command +2. Load the referenced task instead of the original task +3. Pass the slash command arguments as parameters (`$1`, `$2`, `$ARGUMENTS`, etc.) +4. **Completely replace** any existing parameters with the slash command parameters + +This enables wrapper tasks that can dynamically delegate to other tasks with arguments. The slash command fully replaces both the task name and all parameters. + +### Slash Command Format + +``` +/task-name arg1 "arg with spaces" arg3 +``` + +### Example + +Create a wrapper task (`wrapper.md`): +```yaml +--- +task_name: wrapper +--- +Please execute: /implement-feature login "Add OAuth support" +``` + +The target task (`implement-feature.md`): +```yaml +--- +task_name: implement-feature +--- +# Feature: ${1} + +Description: ${2} +``` + +When you run: +```bash +coding-context wrapper +``` + +It will: +1. Parse the slash command `/implement-feature login "Add OAuth support"` +2. Load the `implement-feature` task +3. Substitute `$1` with `login` and `$2` with `Add OAuth support` + +The output will be: +``` +# Feature: login + +Description: Add OAuth support +``` + +This is equivalent to manually running: +```bash +coding-context -p 1=login -p 2="Add OAuth support" implement-feature +``` + ## See Also - [File Formats Reference](./file-formats) - Task and rule file specifications diff --git a/main.go b/main.go index 34b86f8..dfc027b 100644 --- a/main.go +++ b/main.go @@ -29,11 +29,11 @@ func main() { var remotePaths []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.BoolVar(&emitTaskFrontmatter, "t", false, "Print task frontmatter at the beginning of output.") flag.Var(¶ms, "p", "Parameter to substitute in the prompt. Can be specified multiple times as key=value.") + flag.BoolVar(&resume, "r", false, "Resume mode: skip outputting rules and select task with 'resume: true' in frontmatter.") flag.Var(&includes, "s", "Include rules with matching frontmatter. Can be specified multiple times as key=value.") flag.Var(&targetAgent, "a", "Target agent to use (excludes rules from other agents). Supported agents: cursor, opencode, copilot, claude, gemini, augment, windsurf, codex.") + flag.BoolVar(&emitTaskFrontmatter, "t", false, "Print task frontmatter at the beginning of output.") 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) return nil diff --git a/pkg/codingcontext/context.go b/pkg/codingcontext/context.go index dcc24ce..3810f2a 100644 --- a/pkg/codingcontext/context.go +++ b/pkg/codingcontext/context.go @@ -107,6 +107,17 @@ func New(opts ...Option) *Context { return c } +// expandParams expands parameter placeholders in the given content +func (cc *Context) expandParams(content string) string { + return os.Expand(content, func(key string) string { + if val, ok := cc.params[key]; ok { + return val + } + // this might not exist, in that case, return the original text + return fmt.Sprintf("${%s}", key) + }) +} + // 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 { @@ -137,18 +148,55 @@ func (cc *Context) Run(ctx context.Context, taskName string) (*Result, error) { return nil, fmt.Errorf("failed to parse task file: %w", err) } + // Expand parameters in task content to allow slash commands in parameters + expandedContent := cc.expandParams(cc.taskContent) + + // Check if the task contains a slash command (after parameter expansion) + slashTaskName, slashParams, found, err := parseSlashCommand(expandedContent) + if err != nil { + return nil, fmt.Errorf("failed to parse slash command in task: %w", err) + } + if found { + cc.logger.Info("Found slash command in task", "task", slashTaskName, "params", slashParams) + + // Replace parameters completely with slash command parameters + // The slash command fully replaces both task name and parameters + cc.params = slashParams + + // Always find and parse the slash command task file, even if it's the same task name + // This ensures fresh parsing with the new parameters + if slashTaskName == taskName { + cc.logger.Info("Reloading slash command task", "task", slashTaskName) + } else { + cc.logger.Info("Switching to slash command task", "from", taskName, "to", slashTaskName) + } + + // Reset task-related state + cc.matchingTaskFile = "" + cc.taskFrontmatter = nil + cc.taskContent = "" + + // Update task_name in includes + cc.includes.SetValue("task_name", slashTaskName) + + // Find the new task file + if err := cc.findTaskFile(homeDir, slashTaskName); err != nil { + return nil, fmt.Errorf("failed to find slash command task file: %w", err) + } + + // Parse the new task file + if err := cc.parseTaskFile(); err != nil { + return nil, fmt.Errorf("failed to parse slash command task file: %w", err) + } + } + if err := cc.findExecuteRuleFiles(ctx, homeDir); err != nil { return nil, fmt.Errorf("failed to find and execute rule files: %w", err) } - // Expand parameters in task content - expandedTask := os.Expand(cc.taskContent, func(key string) string { - if val, ok := cc.params[key]; ok { - return val - } - // this might not exist, in that case, return the original text - return fmt.Sprintf("${%s}", key) - }) + // Expand parameters in task content (note: this may be a different task than initially loaded + // if a slash command was found above, which loaded a new task with new parameters) + expandedTask := cc.expandParams(cc.taskContent) // Estimate tokens for task file taskTokens := estimateTokens(expandedTask) diff --git a/pkg/codingcontext/context_test.go b/pkg/codingcontext/context_test.go index 238d425..19e5cdd 100644 --- a/pkg/codingcontext/context_test.go +++ b/pkg/codingcontext/context_test.go @@ -1526,6 +1526,164 @@ func (f *fileInfoMock) ModTime() time.Time { return time.Time{} } func (f *fileInfoMock) IsDir() bool { return f.isDir } func (f *fileInfoMock) Sys() any { return nil } +func TestSlashCommandSubstitution(t *testing.T) { + tests := []struct { + name string + initialTaskName string + taskContent string + params Params + wantTaskName string + wantParams map[string]string + wantErr bool + errContains string + }{ + { + name: "substitution to different task", + initialTaskName: "wrapper-task", + taskContent: "Please /real-task 123", + params: Params{}, + wantTaskName: "real-task", + wantParams: map[string]string{ + "ARGUMENTS": "123", + "1": "123", + }, + wantErr: false, + }, + { + name: "slash command replaces existing parameters completely", + initialTaskName: "wrapper-task", + taskContent: "Please /real-task 456", + params: Params{"foo": "bar", "existing": "old"}, + wantTaskName: "real-task", + wantParams: map[string]string{ + "ARGUMENTS": "456", + "1": "456", + }, + wantErr: false, + }, + { + name: "same task with params - replaces existing params", + initialTaskName: "my-task", + taskContent: "/my-task arg1 arg2", + params: Params{"existing": "value"}, + wantTaskName: "my-task", + wantParams: map[string]string{ + "ARGUMENTS": "arg1 arg2", + "1": "arg1", + "2": "arg2", + }, + wantErr: false, + }, + { + name: "slash command in parameter value (free-text use case)", + initialTaskName: "free-text-task", + taskContent: "${text}", + params: Params{"text": "/real-task PROJ-123"}, + wantTaskName: "real-task", + wantParams: map[string]string{ + "ARGUMENTS": "PROJ-123", + "1": "PROJ-123", + }, + wantErr: false, + }, + { + name: "no slash command in task", + initialTaskName: "simple-task", + taskContent: "Just a simple task with no slash command", + params: Params{}, + wantTaskName: "simple-task", + wantParams: map[string]string{}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := t.TempDir() + + // Create the initial task file + taskDir := filepath.Join(tmpDir, ".agents", "tasks") + createMarkdownFile(t, filepath.Join(taskDir, "wrapper-task.md"), + "task_name: wrapper-task", + tt.taskContent) + + // Create the real-task file if needed + createMarkdownFile(t, filepath.Join(taskDir, "real-task.md"), + "task_name: real-task", + "# Real Task Content for issue ${1}") + + // Create a simple-task file + createMarkdownFile(t, filepath.Join(taskDir, "simple-task.md"), + "task_name: simple-task", + "Just a simple task with no slash command") + + // Create my-task file + createMarkdownFile(t, filepath.Join(taskDir, "my-task.md"), + "task_name: my-task", + "/my-task arg1 arg2") + + // Create free-text-task file + createMarkdownFile(t, filepath.Join(taskDir, "free-text-task.md"), + "task_name: free-text-task", + "${text}") + + var logOut bytes.Buffer + cc := &Context{ + workDir: tmpDir, + params: tt.params, + includes: make(Selectors), + rules: make([]Markdown, 0), + logger: slog.New(slog.NewTextHandler(&logOut, nil)), + cmdRunner: func(cmd *exec.Cmd) error { + return nil + }, + } + + if cc.params == nil { + cc.params = make(Params) + } + + result, err := cc.Run(context.Background(), tt.initialTaskName) + + if tt.wantErr { + if err == nil { + t.Errorf("Run() expected error, got nil") + } else if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) { + t.Errorf("Run() error = %v, should contain %q", err, tt.errContains) + } + return + } + + if err != nil { + t.Errorf("Run() unexpected error: %v\nLog output:\n%s", err, logOut.String()) + return + } + + if result == nil { + t.Errorf("Run() returned nil result") + return + } + + // Verify the task name by checking the task path + expectedTaskPath := filepath.Join(taskDir, tt.wantTaskName+".md") + if result.Task.Path != expectedTaskPath { + t.Errorf("Task path = %v, want %v", result.Task.Path, expectedTaskPath) + } + + // Verify parameters + for k, v := range tt.wantParams { + if cc.params[k] != v { + t.Errorf("Param[%q] = %q, want %q", k, cc.params[k], v) + } + } + + // Verify param count + if len(cc.params) != len(tt.wantParams) { + t.Errorf("Param count = %d, want %d. Params: %v", len(cc.params), len(tt.wantParams), cc.params) + } + }) + } +} func TestTargetAgentIntegration(t *testing.T) { tmpDir := t.TempDir() diff --git a/pkg/codingcontext/slashcommand.go b/pkg/codingcontext/slashcommand.go new file mode 100644 index 0000000..4f221d2 --- /dev/null +++ b/pkg/codingcontext/slashcommand.go @@ -0,0 +1,155 @@ +package codingcontext + +import ( + "fmt" + "strings" +) + +// parseSlashCommand parses a slash command string and extracts the task name and parameters. +// It searches for a slash command anywhere in the input string, not just at the beginning. +// The expected format is: /task-name arg1 "arg 2" arg3 +// +// The function will find the slash command even if it's embedded in other text. For example: +// - "Please /fix-bug 123 today" -> taskName: "fix-bug", params: {"ARGUMENTS": "123 today", "1": "123", "2": "today"}, found: true +// - "Some text /code-review" -> taskName: "code-review", params: {}, found: true +// +// Arguments are parsed like Bash: +// - Quoted arguments can contain spaces +// - Both single and double quotes are supported +// - Quotes are removed from the parsed arguments +// - Arguments are extracted until end of line +// +// Examples: +// - "/fix-bug 123" -> taskName: "fix-bug", params: {"ARGUMENTS": "123", "1": "123"}, found: true +// - "/code-review \"PR #42\" high" -> taskName: "code-review", params: {"ARGUMENTS": "\"PR #42\" high", "1": "PR #42", "2": "high"}, found: true +// - "no command here" -> taskName: "", params: nil, found: false +// +// Returns: +// - taskName: the task name (without the leading slash) +// - params: a map containing: +// - "ARGUMENTS": the full argument string (with quotes preserved) +// - "1", "2", "3", etc.: positional arguments (with quotes removed) +// - found: true if a slash command was found, false otherwise +// - err: an error if the command format is invalid (e.g., unclosed quotes) +func parseSlashCommand(command string) (taskName string, params map[string]string, found bool, err error) { + // Find the slash command anywhere in the string + slashIdx := strings.Index(command, "/") + if slashIdx == -1 { + return "", nil, false, nil + } + + // Extract from the slash onwards + command = command[slashIdx+1:] + + if command == "" { + return "", nil, false, nil + } + + // Find the task name (first word after the slash) + // Task name ends at first whitespace or newline + endIdx := strings.IndexAny(command, " \t\n\r") + if endIdx == -1 { + // No arguments, just the task name (rest of the string) + return command, make(map[string]string), true, nil + } + + taskName = command[:endIdx] + + // Extract arguments until end of line + restOfString := command[endIdx:] + newlineIdx := strings.IndexAny(restOfString, "\n\r") + var argsString string + if newlineIdx == -1 { + // No newline, use everything + argsString = strings.TrimSpace(restOfString) + } else { + // Only use up to the newline + argsString = strings.TrimSpace(restOfString[:newlineIdx]) + } + + params = make(map[string]string) + + // Store the full argument string (with quotes preserved) + if argsString != "" { + params["ARGUMENTS"] = argsString + } + + // If there are no arguments, return early + if argsString == "" { + return taskName, params, true, nil + } + + // Parse positional arguments using bash-like parsing + args, err := parseBashArgs(argsString) + if err != nil { + return "", nil, false, err + } + + // Add positional arguments as $1, $2, $3, etc. + for i, arg := range args { + params[fmt.Sprintf("%d", i+1)] = arg + } + + return taskName, params, true, nil +} + +// parseBashArgs parses a string into arguments like bash does, respecting quoted values +func parseBashArgs(s string) ([]string, error) { + var args []string + var current strings.Builder + inQuotes := false + quoteChar := byte(0) + escaped := false + justClosedQuotes := false + + for i := 0; i < len(s); i++ { + ch := s[i] + + if escaped { + current.WriteByte(ch) + escaped = false + continue + } + + if ch == '\\' && inQuotes && quoteChar == '"' { + // Only recognize escape in double quotes + escaped = true + continue + } + + if (ch == '"' || ch == '\'') && !inQuotes { + // Start of quoted string + inQuotes = true + quoteChar = ch + justClosedQuotes = false + } else if ch == quoteChar && inQuotes { + // End of quoted string - mark that we just closed quotes + inQuotes = false + quoteChar = 0 + justClosedQuotes = true + } else if (ch == ' ' || ch == '\t') && !inQuotes { + // Whitespace outside quotes - end of argument + if current.Len() > 0 || justClosedQuotes { + args = append(args, current.String()) + current.Reset() + justClosedQuotes = false + } + } else { + // Regular character + current.WriteByte(ch) + justClosedQuotes = false + } + } + + // Add the last argument + if current.Len() > 0 || justClosedQuotes { + args = append(args, current.String()) + } + + // Check for unclosed quotes + if inQuotes { + return nil, fmt.Errorf("unclosed quote in arguments") + } + + return args, nil +} diff --git a/pkg/codingcontext/slashcommand_test.go b/pkg/codingcontext/slashcommand_test.go new file mode 100644 index 0000000..c9fcb95 --- /dev/null +++ b/pkg/codingcontext/slashcommand_test.go @@ -0,0 +1,184 @@ +package codingcontext + +import ( + "reflect" + "strings" + "testing" +) + +func TestParseSlashCommand(t *testing.T) { + tests := []struct { + name string + command string + wantFound bool + wantTask string + wantParams map[string]string + wantErr bool + errContains string + }{ + { + name: "simple command without parameters", + command: "/fix-bug", + wantFound: true, + wantTask: "fix-bug", + wantParams: map[string]string{}, + wantErr: false, + }, + { + name: "command with single unquoted argument", + command: "/fix-bug 123", + wantFound: true, + wantTask: "fix-bug", + wantParams: map[string]string{ + "ARGUMENTS": "123", + "1": "123", + }, + wantErr: false, + }, + { + name: "command with multiple unquoted arguments", + command: "/implement-feature login high urgent", + wantFound: true, + wantTask: "implement-feature", + wantParams: map[string]string{ + "ARGUMENTS": "login high urgent", + "1": "login", + "2": "high", + "3": "urgent", + }, + wantErr: false, + }, + { + name: "command with double-quoted argument containing spaces", + command: `/code-review "Fix authentication bug in login flow"`, + wantFound: true, + wantTask: "code-review", + wantParams: map[string]string{ + "ARGUMENTS": `"Fix authentication bug in login flow"`, + "1": "Fix authentication bug in login flow", + }, + wantErr: false, + }, + { + name: "missing leading slash", + command: "fix-bug", + wantFound: false, + wantTask: "", + wantParams: nil, + wantErr: false, + }, + { + name: "empty command", + command: "/", + wantFound: false, + wantTask: "", + wantParams: nil, + wantErr: false, + }, + { + name: "empty string", + command: "", + wantFound: false, + wantTask: "", + wantParams: nil, + wantErr: false, + }, + { + name: "unclosed double quote", + command: `/fix-bug "unclosed`, + wantFound: false, + wantTask: "", + wantParams: nil, + wantErr: true, + errContains: "unclosed quote", + }, + { + name: "command in middle of string", + command: "Please /fix-bug 123 today", + wantFound: true, + wantTask: "fix-bug", + wantParams: map[string]string{ + "ARGUMENTS": "123 today", + "1": "123", + "2": "today", + }, + wantErr: false, + }, + { + name: "command followed by newline", + command: "Text before /fix-bug 123\nText after on next line", + wantFound: true, + wantTask: "fix-bug", + wantParams: map[string]string{ + "ARGUMENTS": "123", + "1": "123", + }, + wantErr: false, + }, + { + name: "command with leading period and spaces", + command: ". /taskname", + wantFound: true, + wantTask: "taskname", + wantParams: map[string]string{}, + wantErr: false, + }, + { + name: "command with leading period and more spaces", + command: ". /taskname", + wantFound: true, + wantTask: "taskname", + wantParams: map[string]string{}, + wantErr: false, + }, + { + name: "command with leading period, spaces and arguments", + command: ". /fix-bug PROJ-123", + wantFound: true, + wantTask: "fix-bug", + wantParams: map[string]string{ + "ARGUMENTS": "PROJ-123", + "1": "PROJ-123", + }, + wantErr: false, + }, + { + name: "command with leading period, spaces, and newline", + command: ". /taskname\n", + wantFound: true, + wantTask: "taskname", + wantParams: map[string]string{}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotTask, gotParams, gotFound, err := parseSlashCommand(tt.command) + + if (err != nil) != tt.wantErr { + t.Errorf("parseSlashCommand() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if tt.wantErr && tt.errContains != "" { + if err == nil || !strings.Contains(err.Error(), tt.errContains) { + t.Errorf("parseSlashCommand() error = %v, want error containing %q", err, tt.errContains) + } + return + } + + if gotFound != tt.wantFound { + t.Errorf("parseSlashCommand() gotFound = %v, want %v", gotFound, tt.wantFound) + } + + if gotTask != tt.wantTask { + t.Errorf("parseSlashCommand() gotTask = %v, want %v", gotTask, tt.wantTask) + } + + if !reflect.DeepEqual(gotParams, tt.wantParams) { + t.Errorf("parseSlashCommand() gotParams = %v, want %v", gotParams, tt.wantParams) + } + }) + } +}