Skip to content
63 changes: 62 additions & 1 deletion docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -395,7 +395,31 @@ This enables dynamic task execution with inline arguments.
/task-name arg1 "arg with spaces" arg3
```

### Example
### Positional Parameters

Positional arguments are automatically numbered starting from 1:
- `/fix-bug 123` → `$1` = `123`
- `/task arg1 arg2 arg3` → `$1` = `arg1`, `$2` = `arg2`, `$3` = `arg3`

Quoted arguments preserve spaces:
- `/code-review "PR #42"` → `$1` = `PR #42`

### Named Parameters

Named parameters use the format `key="value"` with **mandatory double quotes**:
- `/fix-bug issue="PROJ-123"` → `$1` = `issue="PROJ-123"`, `$issue` = `PROJ-123`
- `/deploy env="production" version="1.2.3"` → `$1` = `env="production"`, `$2` = `version="1.2.3"`, `$env` = `production`, `$version` = `1.2.3`

Named parameters are counted as positional arguments (retaining their original form) while also being available by their key name:
- `/task arg1 key="value" arg2` → `$1` = `arg1`, `$2` = `key="value"`, `$3` = `arg2`, `$key` = `value`

Named parameter values can contain spaces and special characters:
- `/run message="Hello, World!"` → `$1` = `message="Hello, World!"`, `$message` = `Hello, World!`
- `/config query="x=y+z"` → `$1` = `query="x=y+z"`, `$query` = `x=y+z`

**Note:** Unquoted values (e.g., `key=value`) or single-quoted values (e.g., `key='value'`) are treated as regular positional arguments, not named parameters.

### Example with Positional Parameters

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

### Example with Named Parameters

Create a wrapper task (`fix-issue-wrapper.md`):
```yaml
---
task_name: fix-issue-wrapper
---
/fix-bug issue="PROJ-456" priority="high"
```

The target task (`fix-bug.md`):
```yaml
---
task_name: fix-bug
---
# Fix Bug: ${issue}

Priority: ${priority}
```

When you run:
```bash
coding-context fix-issue-wrapper
```

The output will be:
```
# Fix Bug: PROJ-456

Priority: high
```

This is equivalent to manually running:
```bash
coding-context -p issue=PROJ-456 -p priority=high fix-bug
```

## See Also

- [File Formats Reference](./file-formats) - Task and rule file specifications
Expand Down
119 changes: 105 additions & 14 deletions pkg/codingcontext/slashcommand.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,33 +2,41 @@ package codingcontext

import (
"fmt"
"strconv"
"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 expected format is: /task-name arg1 "arg 2" arg3 key="value"
//
// 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
// - Both single and double quotes are supported for positional arguments
// - Quotes are removed from the parsed arguments
// - Arguments are extracted until end of line
//
// Named parameters:
// - Named parameters use key="value" format with mandatory double quotes
// - Named parameters are also counted as positional arguments (retaining their original form)
//
// 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
// - "/fix-bug issue=\"PROJ-123\"" -> taskName: "fix-bug", params: {"ARGUMENTS": "issue=\"PROJ-123\"", "1": "issue=\"PROJ-123\"", "issue": "PROJ-123"}, found: true
// - "/task arg1 key=\"val\" arg2" -> taskName: "task", params: {"ARGUMENTS": "arg1 key=\"val\" arg2", "1": "arg1", "2": "key=\"val\"", "3": "arg2", "key": "val"}, 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)
// - "1", "2", "3", etc.: all arguments in order (with quotes removed), including named parameters in their original form
// - "key": named parameter value (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) {
Expand Down Expand Up @@ -79,24 +87,30 @@ func parseSlashCommand(command string) (taskName string, params map[string]strin
return taskName, params, true, nil
}

// Parse positional arguments using bash-like parsing
args, err := parseBashArgs(argsString)
// Parse arguments using bash-like parsing, handling both positional and named parameters
parsedParams, err := parseBashArgsWithNamed(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
// Merge parsed params into params
for key, value := range parsedParams {
params[key] = value
}

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
// parseBashArgsWithNamed parses a string into a map of parameters.
// The map contains positional keys ("1", "2", "3", etc.) and named parameter keys.
// Named parameters must use key="value" format with mandatory double quotes.
// Returns the parameters map and any error.
func parseBashArgsWithNamed(s string) (map[string]string, error) {
params := make(map[string]string)
argNum := 1

var current strings.Builder
var rawArg strings.Builder // Tracks the raw argument including quotes
inQuotes := false
quoteChar := byte(0)
escaped := false
Expand All @@ -107,13 +121,15 @@ func parseBashArgs(s string) ([]string, error) {

if escaped {
current.WriteByte(ch)
rawArg.WriteByte(ch)
escaped = false
continue
}

if ch == '\\' && inQuotes && quoteChar == '"' {
// Only recognize escape in double quotes
escaped = true
rawArg.WriteByte(ch)
continue
}

Expand All @@ -122,34 +138,109 @@ func parseBashArgs(s string) ([]string, error) {
inQuotes = true
quoteChar = ch
justClosedQuotes = false
rawArg.WriteByte(ch)
} else if ch == quoteChar && inQuotes {
// End of quoted string - mark that we just closed quotes
inQuotes = false
quoteChar = 0
justClosedQuotes = true
rawArg.WriteByte(ch)
} else if (ch == ' ' || ch == '\t') && !inQuotes {
// Whitespace outside quotes - end of argument
if current.Len() > 0 || justClosedQuotes {
args = append(args, current.String())
arg := current.String()
rawArgStr := rawArg.String()

// Add as positional argument
params[strconv.Itoa(argNum)] = rawArgStr
argNum++

// Check if this is also a named parameter with mandatory double quotes
if key, value, isNamed := parseNamedParamWithQuotes(rawArgStr); isNamed {
params[key] = value
} else {
// For non-named params, use stripped value as positional
params[strconv.Itoa(argNum-1)] = arg
}

current.Reset()
rawArg.Reset()
justClosedQuotes = false
}
} else {
// Regular character
current.WriteByte(ch)
rawArg.WriteByte(ch)
justClosedQuotes = false
}
}

// Add the last argument
if current.Len() > 0 || justClosedQuotes {
args = append(args, current.String())
arg := current.String()
rawArgStr := rawArg.String()

// Add as positional argument
params[strconv.Itoa(argNum)] = rawArgStr

// Check if this is also a named parameter with mandatory double quotes
if key, value, isNamed := parseNamedParamWithQuotes(rawArgStr); isNamed {
params[key] = value
} else {
// For non-named params, use stripped value as positional
params[strconv.Itoa(argNum)] = arg
}
}

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

return args, nil
return params, nil
}

// parseNamedParamWithQuotes checks if an argument is a named parameter in key="value" format.
// Double quotes are mandatory for the value portion.
// Returns the key, value (with quotes stripped), and whether it was a valid named parameter.
// Key must be non-empty and cannot contain spaces or tabs.
func parseNamedParamWithQuotes(rawArg string) (key string, value string, isNamed bool) {
// Find the equals sign
eqIdx := strings.Index(rawArg, "=")
if eqIdx == -1 {
return "", "", false
}

key = rawArg[:eqIdx]
// Key must be a valid identifier (non-empty, no spaces or tabs)
if key == "" || strings.ContainsAny(key, " \t") {
return "", "", false
}

// The value portion (after '=')
valuePart := rawArg[eqIdx+1:]

// Value must start with double quote (mandatory)
if len(valuePart) < 2 || valuePart[0] != '"' {
return "", "", false
}

// Value must end with double quote
if valuePart[len(valuePart)-1] != '"' {
return "", "", false
}

// Extract the value between quotes and handle escaped quotes
quotedValue := valuePart[1 : len(valuePart)-1]
var unescaped strings.Builder
for i := 0; i < len(quotedValue); i++ {
if quotedValue[i] == '\\' && i+1 < len(quotedValue) && quotedValue[i+1] == '"' {
unescaped.WriteByte('"')
i++ // Skip the escaped quote
} else {
unescaped.WriteByte(quotedValue[i])
}
}

return key, unescaped.String(), true
}
Loading