From 41186c61524eb7c84f6633c1952681aa599dff22 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Dec 2025 02:07:00 +0000 Subject: [PATCH 1/8] Initial plan From baf7dd359f681c6e762a03d3daded0c0bc58144e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Dec 2025 02:14:12 +0000 Subject: [PATCH 2/8] 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> --- docs/reference/cli.md | 61 +++++++++++++- pkg/codingcontext/slashcommand.go | 69 +++++++++++++--- pkg/codingcontext/slashcommand_test.go | 107 +++++++++++++++++++++++++ 3 files changed, 226 insertions(+), 11 deletions(-) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 8333e862..58d7805b 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -376,7 +376,29 @@ This enables wrapper tasks that can dynamically delegate to other tasks with arg /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"` (double quotes required): +- `/fix-bug issue="PROJ-123"` → `$issue` = `PROJ-123` +- `/deploy env="production" version="1.2.3"` → `$env` = `production`, `$version` = `1.2.3` + +Named parameters can be mixed with positional arguments. Named parameters do not count toward positional numbering: +- `/task arg1 key="value" arg2` → `$1` = `arg1`, `$2` = `arg2`, `$key` = `value` + +Named parameter values can contain spaces and special characters: +- `/run message="Hello, World!"` → `$message` = `Hello, World!` +- `/config query="x=y+z"` → `$query` = `x=y+z` + +### Example with Positional Parameters Create a wrapper task (`wrapper.md`): ```yaml @@ -418,6 +440,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 diff --git a/pkg/codingcontext/slashcommand.go b/pkg/codingcontext/slashcommand.go index 4f221d2c..6802f521 100644 --- a/pkg/codingcontext/slashcommand.go +++ b/pkg/codingcontext/slashcommand.go @@ -7,7 +7,7 @@ import ( // 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 @@ -19,9 +19,16 @@ import ( // - Quotes are removed from the parsed arguments // - Arguments are extracted until end of line // +// Named parameters: +// - Named parameters use key="value" format (double quotes required) +// - Named parameters can be mixed with positional arguments +// - Named parameters do not count toward positional numbering +// // 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\"", "issue": "PROJ-123"}, found: true +// - "/task arg1 key=\"val\" arg2" -> taskName: "task", params: {"ARGUMENTS": "arg1 key=\"val\" arg2", "1": "arg1", "2": "arg2", "key": "val"}, found: true // - "no command here" -> taskName: "", params: nil, found: false // // Returns: @@ -29,6 +36,7 @@ import ( // - params: a map containing: // - "ARGUMENTS": the full argument string (with quotes preserved) // - "1", "2", "3", etc.: positional arguments (with quotes removed) +// - "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) { @@ -79,8 +87,8 @@ 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 + args, namedParams, err := parseBashArgsWithNamed(argsString) if err != nil { return "", nil, false, err } @@ -90,12 +98,21 @@ func parseSlashCommand(command string) (taskName string, params map[string]strin params[fmt.Sprintf("%d", i+1)] = arg } + // Add named parameters + for key, value := range namedParams { + 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 positional arguments and named parameters. +// Named parameters have the format key="value" (double quotes required). +// Returns positional arguments, named parameters, and any error. +func parseBashArgsWithNamed(s string) ([]string, map[string]string, error) { + var positionalArgs []string + namedParams := make(map[string]string) + var current strings.Builder inQuotes := false quoteChar := byte(0) @@ -130,7 +147,13 @@ func parseBashArgs(s string) ([]string, error) { } 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() + // Check if this is a named parameter (key="value" format) + if key, value, isNamed := parseNamedParam(arg); isNamed { + namedParams[key] = value + } else { + positionalArgs = append(positionalArgs, arg) + } current.Reset() justClosedQuotes = false } @@ -143,13 +166,39 @@ func parseBashArgs(s string) ([]string, error) { // Add the last argument if current.Len() > 0 || justClosedQuotes { - args = append(args, current.String()) + arg := current.String() + // Check if this is a named parameter (key="value" format) + if key, value, isNamed := parseNamedParam(arg); isNamed { + namedParams[key] = value + } else { + positionalArgs = append(positionalArgs, arg) + } } // Check for unclosed quotes if inQuotes { - return nil, fmt.Errorf("unclosed quote in arguments") + return nil, nil, fmt.Errorf("unclosed quote in arguments") + } + + return positionalArgs, namedParams, nil +} + +// parseNamedParam checks if a string is a named parameter in key=value format. +// The value must have been quoted (quotes are already stripped by the caller). +// Returns the key, value, and whether it was a named parameter. +func parseNamedParam(arg string) (key string, value string, isNamed bool) { + // Find the equals sign + eqIdx := strings.Index(arg, "=") + if eqIdx == -1 { + return "", "", false + } + + key = arg[:eqIdx] + // Key must be a valid identifier (non-empty, no spaces) + if key == "" || strings.ContainsAny(key, " \t") { + return "", "", false } - return args, nil + value = arg[eqIdx+1:] + return key, value, true } diff --git a/pkg/codingcontext/slashcommand_test.go b/pkg/codingcontext/slashcommand_test.go index c9fcb958..31623cf5 100644 --- a/pkg/codingcontext/slashcommand_test.go +++ b/pkg/codingcontext/slashcommand_test.go @@ -150,6 +150,113 @@ func TestParseSlashCommand(t *testing.T) { wantParams: map[string]string{}, wantErr: false, }, + // Named parameter tests + { + name: "command with single named parameter", + command: `/fix-bug issue="PROJ-123"`, + wantFound: true, + wantTask: "fix-bug", + wantParams: map[string]string{ + "ARGUMENTS": `issue="PROJ-123"`, + "issue": "PROJ-123", + }, + wantErr: false, + }, + { + name: "command with multiple named parameters", + command: `/deploy env="production" version="1.2.3"`, + wantFound: true, + wantTask: "deploy", + wantParams: map[string]string{ + "ARGUMENTS": `env="production" version="1.2.3"`, + "env": "production", + "version": "1.2.3", + }, + wantErr: false, + }, + { + name: "command with mixed positional and named parameters", + command: `/task arg1 key="value" arg2`, + wantFound: true, + wantTask: "task", + wantParams: map[string]string{ + "ARGUMENTS": `arg1 key="value" arg2`, + "1": "arg1", + "2": "arg2", + "key": "value", + }, + wantErr: false, + }, + { + name: "named parameter with spaces in value", + command: `/implement feature="Add user authentication"`, + wantFound: true, + wantTask: "implement", + wantParams: map[string]string{ + "ARGUMENTS": `feature="Add user authentication"`, + "feature": "Add user authentication", + }, + wantErr: false, + }, + { + name: "named parameter with escaped quotes in value", + command: `/log message="User said \"hello\""`, + wantFound: true, + wantTask: "log", + wantParams: map[string]string{ + "ARGUMENTS": `message="User said \"hello\""`, + "message": `User said "hello"`, + }, + wantErr: false, + }, + { + name: "positional before and after named parameter", + command: `/task before key="middle" after`, + wantFound: true, + wantTask: "task", + wantParams: map[string]string{ + "ARGUMENTS": `before key="middle" after`, + "1": "before", + "2": "after", + "key": "middle", + }, + wantErr: false, + }, + { + name: "multiple named parameters with different types of values", + command: `/config host="localhost" port="8080" debug="true"`, + wantFound: true, + wantTask: "config", + wantParams: map[string]string{ + "ARGUMENTS": `host="localhost" port="8080" debug="true"`, + "host": "localhost", + "port": "8080", + "debug": "true", + }, + wantErr: false, + }, + { + name: "named parameter with empty value", + command: `/task key=""`, + wantFound: true, + wantTask: "task", + wantParams: map[string]string{ + "ARGUMENTS": `key=""`, + "key": "", + }, + wantErr: false, + }, + { + name: "named parameter with equals sign in value", + command: `/run equation="x=y+z"`, + wantFound: true, + wantTask: "run", + wantParams: map[string]string{ + "ARGUMENTS": `equation="x=y+z"`, + "equation": "x=y+z", + }, + wantErr: false, + }, } for _, tt := range tests { From 9b0f8d7c3cf37e696d29e234fcc027d6b58ae009 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Dec 2025 02:16:18 +0000 Subject: [PATCH 3/8] Fix review comments: clarify parseNamedParam documentation Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> --- pkg/codingcontext/slashcommand.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pkg/codingcontext/slashcommand.go b/pkg/codingcontext/slashcommand.go index 6802f521..1a0ab881 100644 --- a/pkg/codingcontext/slashcommand.go +++ b/pkg/codingcontext/slashcommand.go @@ -184,8 +184,9 @@ func parseBashArgsWithNamed(s string) ([]string, map[string]string, error) { } // parseNamedParam checks if a string is a named parameter in key=value format. -// The value must have been quoted (quotes are already stripped by the caller). +// After quote stripping by the caller, the value portion is what remains after the equals sign. // Returns the key, value, and whether it was a named parameter. +// Key must be non-empty and cannot contain spaces or tabs. func parseNamedParam(arg string) (key string, value string, isNamed bool) { // Find the equals sign eqIdx := strings.Index(arg, "=") @@ -194,7 +195,7 @@ func parseNamedParam(arg string) (key string, value string, isNamed bool) { } key = arg[:eqIdx] - // Key must be a valid identifier (non-empty, no spaces) + // Key must be a valid identifier (non-empty, no spaces or tabs) if key == "" || strings.ContainsAny(key, " \t") { return "", "", false } From ee87d7957d5485373d4627e4383758e0cd466798 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Dec 2025 03:52:51 +0000 Subject: [PATCH 4/8] 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> --- docs/reference/cli.md | 16 +++--- pkg/codingcontext/slashcommand.go | 71 ++++++++++++++++++-------- pkg/codingcontext/slashcommand_test.go | 54 +++++++++++++++++++- 3 files changed, 111 insertions(+), 30 deletions(-) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 546ff38d..cc766acd 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -406,16 +406,18 @@ Quoted arguments preserve spaces: ### Named Parameters -Named parameters use the format `key="value"` (double quotes required): -- `/fix-bug issue="PROJ-123"` → `$issue` = `PROJ-123` -- `/deploy env="production" version="1.2.3"` → `$env` = `production`, `$version` = `1.2.3` +Named parameters use the format `key="value"` (quotes required for values with spaces): +- `/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 can be mixed with positional arguments. Named parameters do not count toward positional numbering: -- `/task arg1 key="value" arg2` → `$1` = `arg1`, `$2` = `arg2`, `$key` = `value` +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!"` → `$message` = `Hello, World!` -- `/config query="x=y+z"` → `$query` = `x=y+z` +- `/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` + +Reserved keys (`ARGUMENTS` and numeric keys like `1`, `2`, etc.) cannot be used as named parameter keys and will be ignored. ### Example with Positional Parameters diff --git a/pkg/codingcontext/slashcommand.go b/pkg/codingcontext/slashcommand.go index 1a0ab881..6a424ea0 100644 --- a/pkg/codingcontext/slashcommand.go +++ b/pkg/codingcontext/slashcommand.go @@ -20,22 +20,22 @@ import ( // - Arguments are extracted until end of line // // Named parameters: -// - Named parameters use key="value" format (double quotes required) -// - Named parameters can be mixed with positional arguments -// - Named parameters do not count toward positional numbering +// - Named parameters use key="value" format (quotes required for values with spaces) +// - Named parameters are also counted as positional arguments (retaining their original form) +// - The named parameter key must not be a reserved key (ARGUMENTS) or a numeric key // // 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\"", "issue": "PROJ-123"}, found: true -// - "/task arg1 key=\"val\" arg2" -> taskName: "task", params: {"ARGUMENTS": "arg1 key=\"val\" arg2", "1": "arg1", "2": "arg2", "key": "val"}, 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) @@ -88,32 +88,50 @@ func parseSlashCommand(command string) (taskName string, params map[string]strin } // Parse arguments using bash-like parsing, handling both positional and named parameters - args, namedParams, err := parseBashArgsWithNamed(argsString) + allArgs, namedParams, err := parseBashArgsWithNamed(argsString) if err != nil { return "", nil, false, err } - // Add positional arguments as $1, $2, $3, etc. - for i, arg := range args { + // Add all arguments as positional parameters $1, $2, $3, etc. + // Named parameters are also included in positional numbering + for i, arg := range allArgs { params[fmt.Sprintf("%d", i+1)] = arg } - // Add named parameters + // Add named parameters (excluding reserved and numeric keys) for key, value := range namedParams { + // Skip reserved key ARGUMENTS + if key == "ARGUMENTS" { + continue + } + // Skip numeric keys to avoid overwriting positional parameters + isNumeric := true + for _, ch := range key { + if ch < '0' || ch > '9' { + isNumeric = false + break + } + } + if isNumeric { + continue + } params[key] = value } return taskName, params, true, nil } -// parseBashArgsWithNamed parses a string into positional arguments and named parameters. -// Named parameters have the format key="value" (double quotes required). -// Returns positional arguments, named parameters, and any error. +// parseBashArgsWithNamed parses a string into all arguments and named parameters. +// All arguments (including named parameters) are returned in allArgs for positional numbering. +// Named parameters (key=value format) also have their values extracted into namedParams. +// Returns all arguments, named parameters map, and any error. func parseBashArgsWithNamed(s string) ([]string, map[string]string, error) { - var positionalArgs []string + var allArgs []string namedParams := make(map[string]string) var current strings.Builder + var rawArg strings.Builder // Tracks the raw argument including quotes inQuotes := false quoteChar := byte(0) escaped := false @@ -124,6 +142,7 @@ func parseBashArgsWithNamed(s string) ([]string, map[string]string, error) { if escaped { current.WriteByte(ch) + rawArg.WriteByte(ch) escaped = false continue } @@ -131,6 +150,7 @@ func parseBashArgsWithNamed(s string) ([]string, map[string]string, error) { if ch == '\\' && inQuotes && quoteChar == '"' { // Only recognize escape in double quotes escaped = true + rawArg.WriteByte(ch) continue } @@ -139,27 +159,34 @@ func parseBashArgsWithNamed(s string) ([]string, map[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 { arg := current.String() - // Check if this is a named parameter (key="value" format) + rawArgStr := rawArg.String() + + // All arguments go into positional list (use raw form for named params) if key, value, isNamed := parseNamedParam(arg); isNamed { + allArgs = append(allArgs, rawArgStr) namedParams[key] = value } else { - positionalArgs = append(positionalArgs, arg) + allArgs = append(allArgs, arg) } current.Reset() + rawArg.Reset() justClosedQuotes = false } } else { // Regular character current.WriteByte(ch) + rawArg.WriteByte(ch) justClosedQuotes = false } } @@ -167,11 +194,14 @@ func parseBashArgsWithNamed(s string) ([]string, map[string]string, error) { // Add the last argument if current.Len() > 0 || justClosedQuotes { arg := current.String() - // Check if this is a named parameter (key="value" format) + rawArgStr := rawArg.String() + + // All arguments go into positional list (use raw form for named params) if key, value, isNamed := parseNamedParam(arg); isNamed { + allArgs = append(allArgs, rawArgStr) namedParams[key] = value } else { - positionalArgs = append(positionalArgs, arg) + allArgs = append(allArgs, arg) } } @@ -180,11 +210,10 @@ func parseBashArgsWithNamed(s string) ([]string, map[string]string, error) { return nil, nil, fmt.Errorf("unclosed quote in arguments") } - return positionalArgs, namedParams, nil + return allArgs, namedParams, nil } -// parseNamedParam checks if a string is a named parameter in key=value format. -// After quote stripping by the caller, the value portion is what remains after the equals sign. +// parseNamedParam checks if an argument (with quotes already stripped) is a named parameter in key=value format. // Returns the key, value, and whether it was a named parameter. // Key must be non-empty and cannot contain spaces or tabs. func parseNamedParam(arg string) (key string, value string, isNamed bool) { diff --git a/pkg/codingcontext/slashcommand_test.go b/pkg/codingcontext/slashcommand_test.go index 31623cf5..e7f79e57 100644 --- a/pkg/codingcontext/slashcommand_test.go +++ b/pkg/codingcontext/slashcommand_test.go @@ -158,6 +158,7 @@ func TestParseSlashCommand(t *testing.T) { wantTask: "fix-bug", wantParams: map[string]string{ "ARGUMENTS": `issue="PROJ-123"`, + "1": `issue="PROJ-123"`, "issue": "PROJ-123", }, wantErr: false, @@ -169,6 +170,8 @@ func TestParseSlashCommand(t *testing.T) { wantTask: "deploy", wantParams: map[string]string{ "ARGUMENTS": `env="production" version="1.2.3"`, + "1": `env="production"`, + "2": `version="1.2.3"`, "env": "production", "version": "1.2.3", }, @@ -182,7 +185,8 @@ func TestParseSlashCommand(t *testing.T) { wantParams: map[string]string{ "ARGUMENTS": `arg1 key="value" arg2`, "1": "arg1", - "2": "arg2", + "2": `key="value"`, + "3": "arg2", "key": "value", }, wantErr: false, @@ -194,6 +198,7 @@ func TestParseSlashCommand(t *testing.T) { wantTask: "implement", wantParams: map[string]string{ "ARGUMENTS": `feature="Add user authentication"`, + "1": `feature="Add user authentication"`, "feature": "Add user authentication", }, wantErr: false, @@ -205,6 +210,7 @@ func TestParseSlashCommand(t *testing.T) { wantTask: "log", wantParams: map[string]string{ "ARGUMENTS": `message="User said \"hello\""`, + "1": `message="User said \"hello\""`, "message": `User said "hello"`, }, wantErr: false, @@ -217,7 +223,8 @@ func TestParseSlashCommand(t *testing.T) { wantParams: map[string]string{ "ARGUMENTS": `before key="middle" after`, "1": "before", - "2": "after", + "2": `key="middle"`, + "3": "after", "key": "middle", }, wantErr: false, @@ -229,6 +236,9 @@ func TestParseSlashCommand(t *testing.T) { wantTask: "config", wantParams: map[string]string{ "ARGUMENTS": `host="localhost" port="8080" debug="true"`, + "1": `host="localhost"`, + "2": `port="8080"`, + "3": `debug="true"`, "host": "localhost", "port": "8080", "debug": "true", @@ -242,6 +252,7 @@ func TestParseSlashCommand(t *testing.T) { wantTask: "task", wantParams: map[string]string{ "ARGUMENTS": `key=""`, + "1": `key=""`, "key": "", }, wantErr: false, @@ -253,10 +264,49 @@ func TestParseSlashCommand(t *testing.T) { wantTask: "run", wantParams: map[string]string{ "ARGUMENTS": `equation="x=y+z"`, + "1": `equation="x=y+z"`, "equation": "x=y+z", }, wantErr: false, }, + // Edge case tests for named parameters + { + name: "numeric key in named parameter is ignored", + command: `/task arg1 1="override"`, + wantFound: true, + wantTask: "task", + wantParams: map[string]string{ + "ARGUMENTS": `arg1 1="override"`, + "1": "arg1", + "2": `1="override"`, + }, + wantErr: false, + }, + { + name: "ARGUMENTS key in named parameter is ignored", + command: `/task arg1 ARGUMENTS="custom"`, + wantFound: true, + wantTask: "task", + wantParams: map[string]string{ + "ARGUMENTS": `arg1 ARGUMENTS="custom"`, + "1": "arg1", + "2": `ARGUMENTS="custom"`, + }, + wantErr: false, + }, + { + name: "duplicate named parameter keys - last value wins", + command: `/task key="first" key="second"`, + wantFound: true, + wantTask: "task", + wantParams: map[string]string{ + "ARGUMENTS": `key="first" key="second"`, + "1": `key="first"`, + "2": `key="second"`, + "key": "second", + }, + wantErr: false, + }, } for _, tt := range tests { From 86b250245139ef33f56069a62a956546f92bd7de Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Dec 2025 04:00:12 +0000 Subject: [PATCH 5/8] 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> --- docs/reference/cli.md | 4 +- pkg/codingcontext/slashcommand.go | 53 +++++++++++++++++++------- pkg/codingcontext/slashcommand_test.go | 22 +++++++++++ 3 files changed, 64 insertions(+), 15 deletions(-) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index cc766acd..e88e6a15 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -406,7 +406,7 @@ Quoted arguments preserve spaces: ### Named Parameters -Named parameters use the format `key="value"` (quotes required for values with spaces): +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` @@ -417,6 +417,8 @@ 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. + Reserved keys (`ARGUMENTS` and numeric keys like `1`, `2`, etc.) cannot be used as named parameter keys and will be ignored. ### Example with Positional Parameters diff --git a/pkg/codingcontext/slashcommand.go b/pkg/codingcontext/slashcommand.go index 6a424ea0..de983769 100644 --- a/pkg/codingcontext/slashcommand.go +++ b/pkg/codingcontext/slashcommand.go @@ -15,12 +15,12 @@ import ( // // 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 (quotes required for values with spaces) +// - Named parameters use key="value" format with mandatory double quotes // - Named parameters are also counted as positional arguments (retaining their original form) // - The named parameter key must not be a reserved key (ARGUMENTS) or a numeric key // @@ -124,7 +124,7 @@ func parseSlashCommand(command string) (taskName string, params map[string]strin // parseBashArgsWithNamed parses a string into all arguments and named parameters. // All arguments (including named parameters) are returned in allArgs for positional numbering. -// Named parameters (key=value format) also have their values extracted into namedParams. +// Named parameters must use key="value" format with mandatory double quotes. // Returns all arguments, named parameters map, and any error. func parseBashArgsWithNamed(s string) ([]string, map[string]string, error) { var allArgs []string @@ -172,8 +172,8 @@ func parseBashArgsWithNamed(s string) ([]string, map[string]string, error) { arg := current.String() rawArgStr := rawArg.String() - // All arguments go into positional list (use raw form for named params) - if key, value, isNamed := parseNamedParam(arg); isNamed { + // Check if this is a named parameter with mandatory double quotes + if key, value, isNamed := parseNamedParamWithQuotes(rawArgStr); isNamed { allArgs = append(allArgs, rawArgStr) namedParams[key] = value } else { @@ -196,8 +196,8 @@ func parseBashArgsWithNamed(s string) ([]string, map[string]string, error) { arg := current.String() rawArgStr := rawArg.String() - // All arguments go into positional list (use raw form for named params) - if key, value, isNamed := parseNamedParam(arg); isNamed { + // Check if this is a named parameter with mandatory double quotes + if key, value, isNamed := parseNamedParamWithQuotes(rawArgStr); isNamed { allArgs = append(allArgs, rawArgStr) namedParams[key] = value } else { @@ -213,22 +213,47 @@ func parseBashArgsWithNamed(s string) ([]string, map[string]string, error) { return allArgs, namedParams, nil } -// parseNamedParam checks if an argument (with quotes already stripped) is a named parameter in key=value format. -// Returns the key, value, and whether it was a named parameter. +// 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 parseNamedParam(arg string) (key string, value string, isNamed bool) { +func parseNamedParamWithQuotes(rawArg string) (key string, value string, isNamed bool) { // Find the equals sign - eqIdx := strings.Index(arg, "=") + eqIdx := strings.Index(rawArg, "=") if eqIdx == -1 { return "", "", false } - key = arg[:eqIdx] + key = rawArg[:eqIdx] // Key must be a valid identifier (non-empty, no spaces or tabs) if key == "" || strings.ContainsAny(key, " \t") { return "", "", false } - value = arg[eqIdx+1:] - return key, value, true + // 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 } diff --git a/pkg/codingcontext/slashcommand_test.go b/pkg/codingcontext/slashcommand_test.go index e7f79e57..09eb25af 100644 --- a/pkg/codingcontext/slashcommand_test.go +++ b/pkg/codingcontext/slashcommand_test.go @@ -307,6 +307,28 @@ func TestParseSlashCommand(t *testing.T) { }, wantErr: false, }, + { + name: "unquoted key=value is treated as positional argument not named parameter", + command: `/task key=value`, + wantFound: true, + wantTask: "task", + wantParams: map[string]string{ + "ARGUMENTS": `key=value`, + "1": "key=value", + }, + wantErr: false, + }, + { + name: "single-quoted key=value is treated as positional argument not named parameter", + command: `/task key='value'`, + wantFound: true, + wantTask: "task", + wantParams: map[string]string{ + "ARGUMENTS": `key='value'`, + "1": "key=value", + }, + wantErr: false, + }, } for _, tt := range tests { From f8eb8ea03bb285e222242b5e63b188f0ccd9e7e4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Dec 2025 04:12:07 +0000 Subject: [PATCH 6/8] 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> --- pkg/codingcontext/slashcommand.go | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/pkg/codingcontext/slashcommand.go b/pkg/codingcontext/slashcommand.go index de983769..6fcf62d3 100644 --- a/pkg/codingcontext/slashcommand.go +++ b/pkg/codingcontext/slashcommand.go @@ -2,6 +2,7 @@ package codingcontext import ( "fmt" + "strconv" "strings" ) @@ -101,19 +102,11 @@ func parseSlashCommand(command string) (taskName string, params map[string]strin // Add named parameters (excluding reserved and numeric keys) for key, value := range namedParams { - // Skip reserved key ARGUMENTS + // Skip reserved key ARGUMENTS and numeric keys if key == "ARGUMENTS" { continue } - // Skip numeric keys to avoid overwriting positional parameters - isNumeric := true - for _, ch := range key { - if ch < '0' || ch > '9' { - isNumeric = false - break - } - } - if isNumeric { + if _, err := strconv.Atoi(key); err == nil { continue } params[key] = value From 2e7e6ad23aa9e64b923c9b0ae4796d916b6d03aa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Dec 2025 04:17:10 +0000 Subject: [PATCH 7/8] 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> --- pkg/codingcontext/slashcommand.go | 69 ++++++++++++++++++------------- 1 file changed, 40 insertions(+), 29 deletions(-) diff --git a/pkg/codingcontext/slashcommand.go b/pkg/codingcontext/slashcommand.go index 6fcf62d3..53a7b264 100644 --- a/pkg/codingcontext/slashcommand.go +++ b/pkg/codingcontext/slashcommand.go @@ -89,39 +89,32 @@ func parseSlashCommand(command string) (taskName string, params map[string]strin } // Parse arguments using bash-like parsing, handling both positional and named parameters - allArgs, namedParams, err := parseBashArgsWithNamed(argsString) + parsedParams, err := parseBashArgsWithNamed(argsString) if err != nil { return "", nil, false, err } - // Add all arguments as positional parameters $1, $2, $3, etc. - // Named parameters are also included in positional numbering - for i, arg := range allArgs { - params[fmt.Sprintf("%d", i+1)] = arg - } - - // Add named parameters (excluding reserved and numeric keys) - for key, value := range namedParams { - // Skip reserved key ARGUMENTS and numeric keys + // Merge parsed params into params (excluding reserved keys) + for key, value := range parsedParams { + // Skip reserved key ARGUMENTS (already set above) if key == "ARGUMENTS" { continue } - if _, err := strconv.Atoi(key); err == nil { - continue - } + // Skip numeric keys used as named parameter keys (they're already set as positional) + // But we do want to include positional keys "1", "2", etc from the parser params[key] = value } return taskName, params, true, nil } -// parseBashArgsWithNamed parses a string into all arguments and named parameters. -// All arguments (including named parameters) are returned in allArgs for positional numbering. +// 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 all arguments, named parameters map, and any error. -func parseBashArgsWithNamed(s string) ([]string, map[string]string, error) { - var allArgs []string - namedParams := make(map[string]string) +// 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 @@ -165,13 +158,23 @@ func parseBashArgsWithNamed(s string) ([]string, map[string]string, error) { arg := current.String() rawArgStr := rawArg.String() - // Check if this is a named parameter with mandatory double quotes + // 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 { - allArgs = append(allArgs, rawArgStr) - namedParams[key] = value + // Only add if not a reserved or numeric key + if key != "ARGUMENTS" { + if _, err := strconv.Atoi(key); err != nil { + params[key] = value + } + } } else { - allArgs = append(allArgs, arg) + // For non-named params, use stripped value as positional + params[strconv.Itoa(argNum-1)] = arg } + current.Reset() rawArg.Reset() justClosedQuotes = false @@ -189,21 +192,29 @@ func parseBashArgsWithNamed(s string) ([]string, map[string]string, error) { arg := current.String() rawArgStr := rawArg.String() - // Check if this is a named parameter with mandatory double quotes + // 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 { - allArgs = append(allArgs, rawArgStr) - namedParams[key] = value + // Only add if not a reserved or numeric key + if key != "ARGUMENTS" { + if _, err := strconv.Atoi(key); err != nil { + params[key] = value + } + } } else { - allArgs = append(allArgs, arg) + // For non-named params, use stripped value as positional + params[strconv.Itoa(argNum)] = arg } } // Check for unclosed quotes if inQuotes { - return nil, nil, fmt.Errorf("unclosed quote in arguments") + return nil, fmt.Errorf("unclosed quote in arguments") } - return allArgs, namedParams, nil + return params, nil } // parseNamedParamWithQuotes checks if an argument is a named parameter in key="value" format. From a07097eada7be9b34c78e5bcef935ae1f6efa971 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Dec 2025 04:35:34 +0000 Subject: [PATCH 8/8] 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> --- docs/reference/cli.md | 2 -- pkg/codingcontext/slashcommand.go | 23 +++-------------------- pkg/codingcontext/slashcommand_test.go | 8 ++++---- 3 files changed, 7 insertions(+), 26 deletions(-) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index e88e6a15..186bb42c 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -419,8 +419,6 @@ Named parameter values can contain spaces and special characters: **Note:** Unquoted values (e.g., `key=value`) or single-quoted values (e.g., `key='value'`) are treated as regular positional arguments, not named parameters. -Reserved keys (`ARGUMENTS` and numeric keys like `1`, `2`, etc.) cannot be used as named parameter keys and will be ignored. - ### Example with Positional Parameters Create a task file (`implement-feature.md`): diff --git a/pkg/codingcontext/slashcommand.go b/pkg/codingcontext/slashcommand.go index 53a7b264..68dbb098 100644 --- a/pkg/codingcontext/slashcommand.go +++ b/pkg/codingcontext/slashcommand.go @@ -23,7 +23,6 @@ import ( // Named parameters: // - Named parameters use key="value" format with mandatory double quotes // - Named parameters are also counted as positional arguments (retaining their original form) -// - The named parameter key must not be a reserved key (ARGUMENTS) or a numeric key // // Examples: // - "/fix-bug 123" -> taskName: "fix-bug", params: {"ARGUMENTS": "123", "1": "123"}, found: true @@ -94,14 +93,8 @@ func parseSlashCommand(command string) (taskName string, params map[string]strin return "", nil, false, err } - // Merge parsed params into params (excluding reserved keys) + // Merge parsed params into params for key, value := range parsedParams { - // Skip reserved key ARGUMENTS (already set above) - if key == "ARGUMENTS" { - continue - } - // Skip numeric keys used as named parameter keys (they're already set as positional) - // But we do want to include positional keys "1", "2", etc from the parser params[key] = value } @@ -164,12 +157,7 @@ func parseBashArgsWithNamed(s string) (map[string]string, error) { // Check if this is also a named parameter with mandatory double quotes if key, value, isNamed := parseNamedParamWithQuotes(rawArgStr); isNamed { - // Only add if not a reserved or numeric key - if key != "ARGUMENTS" { - if _, err := strconv.Atoi(key); err != nil { - params[key] = value - } - } + params[key] = value } else { // For non-named params, use stripped value as positional params[strconv.Itoa(argNum-1)] = arg @@ -197,12 +185,7 @@ func parseBashArgsWithNamed(s string) (map[string]string, error) { // Check if this is also a named parameter with mandatory double quotes if key, value, isNamed := parseNamedParamWithQuotes(rawArgStr); isNamed { - // Only add if not a reserved or numeric key - if key != "ARGUMENTS" { - if _, err := strconv.Atoi(key); err != nil { - params[key] = value - } - } + params[key] = value } else { // For non-named params, use stripped value as positional params[strconv.Itoa(argNum)] = arg diff --git a/pkg/codingcontext/slashcommand_test.go b/pkg/codingcontext/slashcommand_test.go index 09eb25af..3b8ddc81 100644 --- a/pkg/codingcontext/slashcommand_test.go +++ b/pkg/codingcontext/slashcommand_test.go @@ -271,24 +271,24 @@ func TestParseSlashCommand(t *testing.T) { }, // Edge case tests for named parameters { - name: "numeric key in named parameter is ignored", + name: "numeric key in named parameter overwrites positional", command: `/task arg1 1="override"`, wantFound: true, wantTask: "task", wantParams: map[string]string{ "ARGUMENTS": `arg1 1="override"`, - "1": "arg1", + "1": "override", "2": `1="override"`, }, wantErr: false, }, { - name: "ARGUMENTS key in named parameter is ignored", + name: "ARGUMENTS key in named parameter overwrites", command: `/task arg1 ARGUMENTS="custom"`, wantFound: true, wantTask: "task", wantParams: map[string]string{ - "ARGUMENTS": `arg1 ARGUMENTS="custom"`, + "ARGUMENTS": "custom", "1": "arg1", "2": `ARGUMENTS="custom"`, },