Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(&params, "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
Expand Down
64 changes: 56 additions & 8 deletions pkg/codingcontext/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
158 changes: 158 additions & 0 deletions pkg/codingcontext/context_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
Loading
Loading