Skip to content

Commit 511ce5a

Browse files
Copilotalexec
andauthored
Add named parameter support to slash commands (#128)
* Initial plan * Add named parameter support to slash commands Extended slash command parsing to support named parameters in key="value" format alongside existing positional parameters. Named parameters can be mixed with positional arguments and do not count toward positional numbering. Examples: - /fix-bug issue="PROJ-123" -> ${issue} = "PROJ-123" - /task arg1 key="value" arg2 -> ${1}="arg1", ${2}="arg2", ${key}="value" Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> * Fix review comments: clarify parseNamedParam documentation Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> * Fix: Named parameters now count toward positional numbering Updated slash command parsing so that named parameters (key="value") are also included as positional arguments in their original form. This addresses the feedback that `/task arg1 issue="PROJ-123" arg2` should result in: - ${1} = arg1 - ${2} = issue="PROJ-123" - ${3} = arg2 - ${issue} = PROJ-123 Also added protections: - Reserved key ARGUMENTS cannot be used as a named parameter key - Numeric keys (1, 2, etc.) cannot be used as named parameter keys - Duplicate named parameter keys: last value wins (documented behavior) Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> * Make double quotes mandatory for named parameters Per @alexec's feedback, named parameters now require mandatory double quotes. - Unquoted values like `key=value` are treated as positional arguments - Single-quoted values like `key='value'` are also treated as positional arguments - Only `key="value"` format is recognized as a named parameter Updated parseNamedParamWithQuotes to validate double quote requirement. Added tests for unquoted and single-quoted edge cases. Updated documentation to clarify mandatory double quotes. Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> * Simplify numeric key check using strconv.Atoi Replaced manual character-by-character numeric check with strconv.Atoi for cleaner, more idiomatic Go code. Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> * Simplify parseBashArgsWithNamed to return map[string]string Changed return type from ([]string, map[string]string, error) to (map[string]string, error). The map now contains both positional keys ("1", "2", "3", etc.) and named parameter keys, eliminating the need for a separate allArgs slice. Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> * Remove protection for reserved keys (ARGUMENTS, numeric keys) Per @alexec's feedback, removed the validation that prevented named parameters from using ARGUMENTS or numeric keys. Users would have to misuse the system greatly for this to cause problems. 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 2911b09 commit 511ce5a

File tree

3 files changed

+346
-15
lines changed

3 files changed

+346
-15
lines changed

docs/reference/cli.md

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -395,7 +395,31 @@ This enables dynamic task execution with inline arguments.
395395
/task-name arg1 "arg with spaces" arg3
396396
```
397397

398-
### Example
398+
### Positional Parameters
399+
400+
Positional arguments are automatically numbered starting from 1:
401+
- `/fix-bug 123``$1` = `123`
402+
- `/task arg1 arg2 arg3``$1` = `arg1`, `$2` = `arg2`, `$3` = `arg3`
403+
404+
Quoted arguments preserve spaces:
405+
- `/code-review "PR #42"``$1` = `PR #42`
406+
407+
### Named Parameters
408+
409+
Named parameters use the format `key="value"` with **mandatory double quotes**:
410+
- `/fix-bug issue="PROJ-123"``$1` = `issue="PROJ-123"`, `$issue` = `PROJ-123`
411+
- `/deploy env="production" version="1.2.3"``$1` = `env="production"`, `$2` = `version="1.2.3"`, `$env` = `production`, `$version` = `1.2.3`
412+
413+
Named parameters are counted as positional arguments (retaining their original form) while also being available by their key name:
414+
- `/task arg1 key="value" arg2``$1` = `arg1`, `$2` = `key="value"`, `$3` = `arg2`, `$key` = `value`
415+
416+
Named parameter values can contain spaces and special characters:
417+
- `/run message="Hello, World!"``$1` = `message="Hello, World!"`, `$message` = `Hello, World!`
418+
- `/config query="x=y+z"``$1` = `query="x=y+z"`, `$query` = `x=y+z`
419+
420+
**Note:** Unquoted values (e.g., `key=value`) or single-quoted values (e.g., `key='value'`) are treated as regular positional arguments, not named parameters.
421+
422+
### Example with Positional Parameters
399423

400424
Create a task file (`implement-feature.md`):
401425
```yaml
@@ -429,6 +453,43 @@ This is equivalent to manually running:
429453
coding-context -p 1=login -p 2="Add OAuth support" /implement-feature
430454
```
431455

456+
### Example with Named Parameters
457+
458+
Create a wrapper task (`fix-issue-wrapper.md`):
459+
```yaml
460+
---
461+
task_name: fix-issue-wrapper
462+
---
463+
/fix-bug issue="PROJ-456" priority="high"
464+
```
465+
466+
The target task (`fix-bug.md`):
467+
```yaml
468+
---
469+
task_name: fix-bug
470+
---
471+
# Fix Bug: ${issue}
472+
473+
Priority: ${priority}
474+
```
475+
476+
When you run:
477+
```bash
478+
coding-context fix-issue-wrapper
479+
```
480+
481+
The output will be:
482+
```
483+
# Fix Bug: PROJ-456
484+
485+
Priority: high
486+
```
487+
488+
This is equivalent to manually running:
489+
```bash
490+
coding-context -p issue=PROJ-456 -p priority=high fix-bug
491+
```
492+
432493
## See Also
433494

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

pkg/codingcontext/slashcommand.go

Lines changed: 105 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,33 +2,41 @@ package codingcontext
22

33
import (
44
"fmt"
5+
"strconv"
56
"strings"
67
)
78

89
// parseSlashCommand parses a slash command string and extracts the task name and parameters.
910
// It searches for a slash command anywhere in the input string, not just at the beginning.
10-
// The expected format is: /task-name arg1 "arg 2" arg3
11+
// The expected format is: /task-name arg1 "arg 2" arg3 key="value"
1112
//
1213
// The function will find the slash command even if it's embedded in other text. For example:
1314
// - "Please /fix-bug 123 today" -> taskName: "fix-bug", params: {"ARGUMENTS": "123 today", "1": "123", "2": "today"}, found: true
1415
// - "Some text /code-review" -> taskName: "code-review", params: {}, found: true
1516
//
1617
// Arguments are parsed like Bash:
1718
// - Quoted arguments can contain spaces
18-
// - Both single and double quotes are supported
19+
// - Both single and double quotes are supported for positional arguments
1920
// - Quotes are removed from the parsed arguments
2021
// - Arguments are extracted until end of line
2122
//
23+
// Named parameters:
24+
// - Named parameters use key="value" format with mandatory double quotes
25+
// - Named parameters are also counted as positional arguments (retaining their original form)
26+
//
2227
// Examples:
2328
// - "/fix-bug 123" -> taskName: "fix-bug", params: {"ARGUMENTS": "123", "1": "123"}, found: true
2429
// - "/code-review \"PR #42\" high" -> taskName: "code-review", params: {"ARGUMENTS": "\"PR #42\" high", "1": "PR #42", "2": "high"}, found: true
30+
// - "/fix-bug issue=\"PROJ-123\"" -> taskName: "fix-bug", params: {"ARGUMENTS": "issue=\"PROJ-123\"", "1": "issue=\"PROJ-123\"", "issue": "PROJ-123"}, found: true
31+
// - "/task arg1 key=\"val\" arg2" -> taskName: "task", params: {"ARGUMENTS": "arg1 key=\"val\" arg2", "1": "arg1", "2": "key=\"val\"", "3": "arg2", "key": "val"}, found: true
2532
// - "no command here" -> taskName: "", params: nil, found: false
2633
//
2734
// Returns:
2835
// - taskName: the task name (without the leading slash)
2936
// - params: a map containing:
3037
// - "ARGUMENTS": the full argument string (with quotes preserved)
31-
// - "1", "2", "3", etc.: positional arguments (with quotes removed)
38+
// - "1", "2", "3", etc.: all arguments in order (with quotes removed), including named parameters in their original form
39+
// - "key": named parameter value (with quotes removed)
3240
// - found: true if a slash command was found, false otherwise
3341
// - err: an error if the command format is invalid (e.g., unclosed quotes)
3442
func parseSlashCommand(command string) (taskName string, params map[string]string, found bool, err error) {
@@ -79,24 +87,30 @@ func parseSlashCommand(command string) (taskName string, params map[string]strin
7987
return taskName, params, true, nil
8088
}
8189

82-
// Parse positional arguments using bash-like parsing
83-
args, err := parseBashArgs(argsString)
90+
// Parse arguments using bash-like parsing, handling both positional and named parameters
91+
parsedParams, err := parseBashArgsWithNamed(argsString)
8492
if err != nil {
8593
return "", nil, false, err
8694
}
8795

88-
// Add positional arguments as $1, $2, $3, etc.
89-
for i, arg := range args {
90-
params[fmt.Sprintf("%d", i+1)] = arg
96+
// Merge parsed params into params
97+
for key, value := range parsedParams {
98+
params[key] = value
9199
}
92100

93101
return taskName, params, true, nil
94102
}
95103

96-
// parseBashArgs parses a string into arguments like bash does, respecting quoted values
97-
func parseBashArgs(s string) ([]string, error) {
98-
var args []string
104+
// parseBashArgsWithNamed parses a string into a map of parameters.
105+
// The map contains positional keys ("1", "2", "3", etc.) and named parameter keys.
106+
// Named parameters must use key="value" format with mandatory double quotes.
107+
// Returns the parameters map and any error.
108+
func parseBashArgsWithNamed(s string) (map[string]string, error) {
109+
params := make(map[string]string)
110+
argNum := 1
111+
99112
var current strings.Builder
113+
var rawArg strings.Builder // Tracks the raw argument including quotes
100114
inQuotes := false
101115
quoteChar := byte(0)
102116
escaped := false
@@ -107,13 +121,15 @@ func parseBashArgs(s string) ([]string, error) {
107121

108122
if escaped {
109123
current.WriteByte(ch)
124+
rawArg.WriteByte(ch)
110125
escaped = false
111126
continue
112127
}
113128

114129
if ch == '\\' && inQuotes && quoteChar == '"' {
115130
// Only recognize escape in double quotes
116131
escaped = true
132+
rawArg.WriteByte(ch)
117133
continue
118134
}
119135

@@ -122,34 +138,109 @@ func parseBashArgs(s string) ([]string, error) {
122138
inQuotes = true
123139
quoteChar = ch
124140
justClosedQuotes = false
141+
rawArg.WriteByte(ch)
125142
} else if ch == quoteChar && inQuotes {
126143
// End of quoted string - mark that we just closed quotes
127144
inQuotes = false
128145
quoteChar = 0
129146
justClosedQuotes = true
147+
rawArg.WriteByte(ch)
130148
} else if (ch == ' ' || ch == '\t') && !inQuotes {
131149
// Whitespace outside quotes - end of argument
132150
if current.Len() > 0 || justClosedQuotes {
133-
args = append(args, current.String())
151+
arg := current.String()
152+
rawArgStr := rawArg.String()
153+
154+
// Add as positional argument
155+
params[strconv.Itoa(argNum)] = rawArgStr
156+
argNum++
157+
158+
// Check if this is also a named parameter with mandatory double quotes
159+
if key, value, isNamed := parseNamedParamWithQuotes(rawArgStr); isNamed {
160+
params[key] = value
161+
} else {
162+
// For non-named params, use stripped value as positional
163+
params[strconv.Itoa(argNum-1)] = arg
164+
}
165+
134166
current.Reset()
167+
rawArg.Reset()
135168
justClosedQuotes = false
136169
}
137170
} else {
138171
// Regular character
139172
current.WriteByte(ch)
173+
rawArg.WriteByte(ch)
140174
justClosedQuotes = false
141175
}
142176
}
143177

144178
// Add the last argument
145179
if current.Len() > 0 || justClosedQuotes {
146-
args = append(args, current.String())
180+
arg := current.String()
181+
rawArgStr := rawArg.String()
182+
183+
// Add as positional argument
184+
params[strconv.Itoa(argNum)] = rawArgStr
185+
186+
// Check if this is also a named parameter with mandatory double quotes
187+
if key, value, isNamed := parseNamedParamWithQuotes(rawArgStr); isNamed {
188+
params[key] = value
189+
} else {
190+
// For non-named params, use stripped value as positional
191+
params[strconv.Itoa(argNum)] = arg
192+
}
147193
}
148194

149195
// Check for unclosed quotes
150196
if inQuotes {
151197
return nil, fmt.Errorf("unclosed quote in arguments")
152198
}
153199

154-
return args, nil
200+
return params, nil
201+
}
202+
203+
// parseNamedParamWithQuotes checks if an argument is a named parameter in key="value" format.
204+
// Double quotes are mandatory for the value portion.
205+
// Returns the key, value (with quotes stripped), and whether it was a valid named parameter.
206+
// Key must be non-empty and cannot contain spaces or tabs.
207+
func parseNamedParamWithQuotes(rawArg string) (key string, value string, isNamed bool) {
208+
// Find the equals sign
209+
eqIdx := strings.Index(rawArg, "=")
210+
if eqIdx == -1 {
211+
return "", "", false
212+
}
213+
214+
key = rawArg[:eqIdx]
215+
// Key must be a valid identifier (non-empty, no spaces or tabs)
216+
if key == "" || strings.ContainsAny(key, " \t") {
217+
return "", "", false
218+
}
219+
220+
// The value portion (after '=')
221+
valuePart := rawArg[eqIdx+1:]
222+
223+
// Value must start with double quote (mandatory)
224+
if len(valuePart) < 2 || valuePart[0] != '"' {
225+
return "", "", false
226+
}
227+
228+
// Value must end with double quote
229+
if valuePart[len(valuePart)-1] != '"' {
230+
return "", "", false
231+
}
232+
233+
// Extract the value between quotes and handle escaped quotes
234+
quotedValue := valuePart[1 : len(valuePart)-1]
235+
var unescaped strings.Builder
236+
for i := 0; i < len(quotedValue); i++ {
237+
if quotedValue[i] == '\\' && i+1 < len(quotedValue) && quotedValue[i+1] == '"' {
238+
unescaped.WriteByte('"')
239+
i++ // Skip the escaped quote
240+
} else {
241+
unescaped.WriteByte(quotedValue[i])
242+
}
243+
}
244+
245+
return key, unescaped.String(), true
155246
}

0 commit comments

Comments
 (0)