Skip to content

Commit 354ab52

Browse files
Copilotalexec
andauthored
Add slash command substitution to codingcontext package (#118)
* Initial plan * Add slash command support to main package - Move slash command parsing from pkg/slashcommand to pkg/codingcontext - Add WithSlashCommand(bool) option to Context - Implement slash command substitution in Run() - Add tests for slash command functionality - Add --slash-command flag to CLI Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> * Add documentation for --slash-command flag Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> * Add logging for parameter overrides in slash commands Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> * Change flag from --slash-command to -l and always reload task file - Changed flag from --slash-command to -l for brevity - Removed conditional check - always reload task file even if same name - Updated documentation to reflect -l flag - Added clarification that slash params override existing params Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> * Improve logging clarity for same vs different task reload - Use "Reloading" when slash command references same task - Use "Switching" when slash command references different task - Makes log messages more accurate and helpful Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> * Remove WithSlashCommand option - slash commands always enabled - Removed slashCommand field from Context struct - Removed WithSlashCommand option function - Removed -l flag from CLI - Slash command parsing now always enabled by default - Updated tests to remove slashCommandEnabled parameter - Updated documentation to reflect always-on behavior Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> * Slash command parameters fully replace existing parameters - Changed from merging to complete replacement of parameters - When slash command is found, both task name and parameters are replaced - Removed parameter override logging (no longer relevant) - Added test case to verify complete parameter replacement - Updated documentation to clarify full replacement behavior Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> * Expand parameters before checking for slash commands - Allows slash commands in parameter values (free-text use case) - Expand task content with parameters first, then parse for slash commands - Added test case for slash command in parameter value - Enables use case: free-text task with text="/fix-bug PROJ-123" Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> * Refactor to eliminate code duplication - Extracted parameter expansion to expandParams helper method - Removed duplicate my-task.md creation in tests - Improved code maintainability Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> * Add clarifying comment for double parameter expansion - Explains why parameters are expanded twice - First for slash command detection, second for final output - Task content may differ between expansions if slash command found Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> * Add test cases for slash commands with leading periods and spaces - Tests confirm slash commands work with patterns like ". /taskname" - Already supported by existing implementation - Added explicit test cases for documentation and regression testing Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> * Merge origin/main into slash command branch - Resolved conflicts in main.go (kept both -a and -t flags) - Resolved conflicts in context_test.go (kept both TestSlashCommandSubstitution and TestTargetAgentIntegration) - All tests passing Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> Co-authored-by: Alex Collins <alexec@users.noreply.github.com>
1 parent 0bd0dc8 commit 354ab52

6 files changed

Lines changed: 614 additions & 10 deletions

File tree

docs/reference/cli.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,65 @@ metadata:
352352
coding-context -s metadata.language=Go fix-bug
353353
```
354354

355+
## Slash Commands
356+
357+
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:
358+
359+
1. Extract the task name and arguments from the slash command
360+
2. Load the referenced task instead of the original task
361+
3. Pass the slash command arguments as parameters (`$1`, `$2`, `$ARGUMENTS`, etc.)
362+
4. **Completely replace** any existing parameters with the slash command parameters
363+
364+
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.
365+
366+
### Slash Command Format
367+
368+
```
369+
/task-name arg1 "arg with spaces" arg3
370+
```
371+
372+
### Example
373+
374+
Create a wrapper task (`wrapper.md`):
375+
```yaml
376+
---
377+
task_name: wrapper
378+
---
379+
Please execute: /implement-feature login "Add OAuth support"
380+
```
381+
382+
The target task (`implement-feature.md`):
383+
```yaml
384+
---
385+
task_name: implement-feature
386+
---
387+
# Feature: ${1}
388+
389+
Description: ${2}
390+
```
391+
392+
When you run:
393+
```bash
394+
coding-context wrapper
395+
```
396+
397+
It will:
398+
1. Parse the slash command `/implement-feature login "Add OAuth support"`
399+
2. Load the `implement-feature` task
400+
3. Substitute `$1` with `login` and `$2` with `Add OAuth support`
401+
402+
The output will be:
403+
```
404+
# Feature: login
405+
406+
Description: Add OAuth support
407+
```
408+
409+
This is equivalent to manually running:
410+
```bash
411+
coding-context -p 1=login -p 2="Add OAuth support" implement-feature
412+
```
413+
355414
## See Also
356415

357416
- [File Formats Reference](./file-formats) - Task and rule file specifications

main.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,11 @@ func main() {
2929
var remotePaths []string
3030

3131
flag.StringVar(&workDir, "C", ".", "Change to directory before doing anything.")
32-
flag.BoolVar(&resume, "r", false, "Resume mode: skip outputting rules and select task with 'resume: true' in frontmatter.")
33-
flag.BoolVar(&emitTaskFrontmatter, "t", false, "Print task frontmatter at the beginning of output.")
3432
flag.Var(&params, "p", "Parameter to substitute in the prompt. Can be specified multiple times as key=value.")
33+
flag.BoolVar(&resume, "r", false, "Resume mode: skip outputting rules and select task with 'resume: true' in frontmatter.")
3534
flag.Var(&includes, "s", "Include rules with matching frontmatter. Can be specified multiple times as key=value.")
3635
flag.Var(&targetAgent, "a", "Target agent to use (excludes rules from other agents). Supported agents: cursor, opencode, copilot, claude, gemini, augment, windsurf, codex.")
36+
flag.BoolVar(&emitTaskFrontmatter, "t", false, "Print task frontmatter at the beginning of output.")
3737
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 {
3838
remotePaths = append(remotePaths, s)
3939
return nil

pkg/codingcontext/context.go

Lines changed: 56 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,17 @@ func New(opts ...Option) *Context {
107107
return c
108108
}
109109

110+
// expandParams expands parameter placeholders in the given content
111+
func (cc *Context) expandParams(content string) string {
112+
return os.Expand(content, func(key string) string {
113+
if val, ok := cc.params[key]; ok {
114+
return val
115+
}
116+
// this might not exist, in that case, return the original text
117+
return fmt.Sprintf("${%s}", key)
118+
})
119+
}
120+
110121
// Run executes the context assembly for the given task name and returns the assembled result
111122
func (cc *Context) Run(ctx context.Context, taskName string) (*Result, error) {
112123
if err := cc.downloadRemoteDirectories(ctx); err != nil {
@@ -137,18 +148,55 @@ func (cc *Context) Run(ctx context.Context, taskName string) (*Result, error) {
137148
return nil, fmt.Errorf("failed to parse task file: %w", err)
138149
}
139150

151+
// Expand parameters in task content to allow slash commands in parameters
152+
expandedContent := cc.expandParams(cc.taskContent)
153+
154+
// Check if the task contains a slash command (after parameter expansion)
155+
slashTaskName, slashParams, found, err := parseSlashCommand(expandedContent)
156+
if err != nil {
157+
return nil, fmt.Errorf("failed to parse slash command in task: %w", err)
158+
}
159+
if found {
160+
cc.logger.Info("Found slash command in task", "task", slashTaskName, "params", slashParams)
161+
162+
// Replace parameters completely with slash command parameters
163+
// The slash command fully replaces both task name and parameters
164+
cc.params = slashParams
165+
166+
// Always find and parse the slash command task file, even if it's the same task name
167+
// This ensures fresh parsing with the new parameters
168+
if slashTaskName == taskName {
169+
cc.logger.Info("Reloading slash command task", "task", slashTaskName)
170+
} else {
171+
cc.logger.Info("Switching to slash command task", "from", taskName, "to", slashTaskName)
172+
}
173+
174+
// Reset task-related state
175+
cc.matchingTaskFile = ""
176+
cc.taskFrontmatter = nil
177+
cc.taskContent = ""
178+
179+
// Update task_name in includes
180+
cc.includes.SetValue("task_name", slashTaskName)
181+
182+
// Find the new task file
183+
if err := cc.findTaskFile(homeDir, slashTaskName); err != nil {
184+
return nil, fmt.Errorf("failed to find slash command task file: %w", err)
185+
}
186+
187+
// Parse the new task file
188+
if err := cc.parseTaskFile(); err != nil {
189+
return nil, fmt.Errorf("failed to parse slash command task file: %w", err)
190+
}
191+
}
192+
140193
if err := cc.findExecuteRuleFiles(ctx, homeDir); err != nil {
141194
return nil, fmt.Errorf("failed to find and execute rule files: %w", err)
142195
}
143196

144-
// Expand parameters in task content
145-
expandedTask := os.Expand(cc.taskContent, func(key string) string {
146-
if val, ok := cc.params[key]; ok {
147-
return val
148-
}
149-
// this might not exist, in that case, return the original text
150-
return fmt.Sprintf("${%s}", key)
151-
})
197+
// Expand parameters in task content (note: this may be a different task than initially loaded
198+
// if a slash command was found above, which loaded a new task with new parameters)
199+
expandedTask := cc.expandParams(cc.taskContent)
152200

153201
// Estimate tokens for task file
154202
taskTokens := estimateTokens(expandedTask)

pkg/codingcontext/context_test.go

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1526,6 +1526,164 @@ func (f *fileInfoMock) ModTime() time.Time { return time.Time{} }
15261526
func (f *fileInfoMock) IsDir() bool { return f.isDir }
15271527
func (f *fileInfoMock) Sys() any { return nil }
15281528

1529+
func TestSlashCommandSubstitution(t *testing.T) {
1530+
tests := []struct {
1531+
name string
1532+
initialTaskName string
1533+
taskContent string
1534+
params Params
1535+
wantTaskName string
1536+
wantParams map[string]string
1537+
wantErr bool
1538+
errContains string
1539+
}{
1540+
{
1541+
name: "substitution to different task",
1542+
initialTaskName: "wrapper-task",
1543+
taskContent: "Please /real-task 123",
1544+
params: Params{},
1545+
wantTaskName: "real-task",
1546+
wantParams: map[string]string{
1547+
"ARGUMENTS": "123",
1548+
"1": "123",
1549+
},
1550+
wantErr: false,
1551+
},
1552+
{
1553+
name: "slash command replaces existing parameters completely",
1554+
initialTaskName: "wrapper-task",
1555+
taskContent: "Please /real-task 456",
1556+
params: Params{"foo": "bar", "existing": "old"},
1557+
wantTaskName: "real-task",
1558+
wantParams: map[string]string{
1559+
"ARGUMENTS": "456",
1560+
"1": "456",
1561+
},
1562+
wantErr: false,
1563+
},
1564+
{
1565+
name: "same task with params - replaces existing params",
1566+
initialTaskName: "my-task",
1567+
taskContent: "/my-task arg1 arg2",
1568+
params: Params{"existing": "value"},
1569+
wantTaskName: "my-task",
1570+
wantParams: map[string]string{
1571+
"ARGUMENTS": "arg1 arg2",
1572+
"1": "arg1",
1573+
"2": "arg2",
1574+
},
1575+
wantErr: false,
1576+
},
1577+
{
1578+
name: "slash command in parameter value (free-text use case)",
1579+
initialTaskName: "free-text-task",
1580+
taskContent: "${text}",
1581+
params: Params{"text": "/real-task PROJ-123"},
1582+
wantTaskName: "real-task",
1583+
wantParams: map[string]string{
1584+
"ARGUMENTS": "PROJ-123",
1585+
"1": "PROJ-123",
1586+
},
1587+
wantErr: false,
1588+
},
1589+
{
1590+
name: "no slash command in task",
1591+
initialTaskName: "simple-task",
1592+
taskContent: "Just a simple task with no slash command",
1593+
params: Params{},
1594+
wantTaskName: "simple-task",
1595+
wantParams: map[string]string{},
1596+
wantErr: false,
1597+
},
1598+
}
1599+
1600+
for _, tt := range tests {
1601+
t.Run(tt.name, func(t *testing.T) {
1602+
tmpDir := t.TempDir()
1603+
1604+
// Create the initial task file
1605+
taskDir := filepath.Join(tmpDir, ".agents", "tasks")
1606+
createMarkdownFile(t, filepath.Join(taskDir, "wrapper-task.md"),
1607+
"task_name: wrapper-task",
1608+
tt.taskContent)
1609+
1610+
// Create the real-task file if needed
1611+
createMarkdownFile(t, filepath.Join(taskDir, "real-task.md"),
1612+
"task_name: real-task",
1613+
"# Real Task Content for issue ${1}")
1614+
1615+
// Create a simple-task file
1616+
createMarkdownFile(t, filepath.Join(taskDir, "simple-task.md"),
1617+
"task_name: simple-task",
1618+
"Just a simple task with no slash command")
1619+
1620+
// Create my-task file
1621+
createMarkdownFile(t, filepath.Join(taskDir, "my-task.md"),
1622+
"task_name: my-task",
1623+
"/my-task arg1 arg2")
1624+
1625+
// Create free-text-task file
1626+
createMarkdownFile(t, filepath.Join(taskDir, "free-text-task.md"),
1627+
"task_name: free-text-task",
1628+
"${text}")
1629+
1630+
var logOut bytes.Buffer
1631+
cc := &Context{
1632+
workDir: tmpDir,
1633+
params: tt.params,
1634+
includes: make(Selectors),
1635+
rules: make([]Markdown, 0),
1636+
logger: slog.New(slog.NewTextHandler(&logOut, nil)),
1637+
cmdRunner: func(cmd *exec.Cmd) error {
1638+
return nil
1639+
},
1640+
}
1641+
1642+
if cc.params == nil {
1643+
cc.params = make(Params)
1644+
}
1645+
1646+
result, err := cc.Run(context.Background(), tt.initialTaskName)
1647+
1648+
if tt.wantErr {
1649+
if err == nil {
1650+
t.Errorf("Run() expected error, got nil")
1651+
} else if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) {
1652+
t.Errorf("Run() error = %v, should contain %q", err, tt.errContains)
1653+
}
1654+
return
1655+
}
1656+
1657+
if err != nil {
1658+
t.Errorf("Run() unexpected error: %v\nLog output:\n%s", err, logOut.String())
1659+
return
1660+
}
1661+
1662+
if result == nil {
1663+
t.Errorf("Run() returned nil result")
1664+
return
1665+
}
1666+
1667+
// Verify the task name by checking the task path
1668+
expectedTaskPath := filepath.Join(taskDir, tt.wantTaskName+".md")
1669+
if result.Task.Path != expectedTaskPath {
1670+
t.Errorf("Task path = %v, want %v", result.Task.Path, expectedTaskPath)
1671+
}
1672+
1673+
// Verify parameters
1674+
for k, v := range tt.wantParams {
1675+
if cc.params[k] != v {
1676+
t.Errorf("Param[%q] = %q, want %q", k, cc.params[k], v)
1677+
}
1678+
}
1679+
1680+
// Verify param count
1681+
if len(cc.params) != len(tt.wantParams) {
1682+
t.Errorf("Param count = %d, want %d. Params: %v", len(cc.params), len(tt.wantParams), cc.params)
1683+
}
1684+
})
1685+
}
1686+
}
15291687
func TestTargetAgentIntegration(t *testing.T) {
15301688
tmpDir := t.TempDir()
15311689

0 commit comments

Comments
 (0)