From 1e2e8222032c4227ac33955a06a7006a6f88480d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Dec 2025 23:21:53 +0000 Subject: [PATCH 01/10] Initial plan From 0368c29e5b65aff32ed1a6adf7268a67531d9e6d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Dec 2025 23:30:07 +0000 Subject: [PATCH 02/10] Convert mcp_servers map to single mcp_server string field Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> --- docs/reference/file-formats.md | 46 ++---- .../tasks/example-mcp-arbitrary-fields.md | 109 +++++--------- .../tasks/example-with-standard-fields.md | 10 +- pkg/codingcontext/README.md | 10 +- pkg/codingcontext/result.go | 29 +--- pkg/codingcontext/result_test.go | 140 +++--------------- pkg/codingcontext/rule_frontmatter.go | 5 +- pkg/codingcontext/rule_frontmatter_test.go | 14 +- pkg/codingcontext/task_frontmatter.go | 5 +- pkg/codingcontext/task_frontmatter_test.go | 28 +--- 10 files changed, 96 insertions(+), 300 deletions(-) diff --git a/docs/reference/file-formats.md b/docs/reference/file-formats.md index 807240e..bb9148e 100644 --- a/docs/reference/file-formats.md +++ b/docs/reference/file-formats.md @@ -137,34 +137,27 @@ timeout: 10m - `1h` - 1 hour - `1h30m` - 1 hour 30 minutes -#### `mcp_servers` (optional, standard field) +#### `mcp_server` (optional, standard field) -**Type:** Map (from server name to server configuration) -**Purpose:** Specifies the MCP (Model Context Protocol) servers that the task should use; stored in frontmatter output but does not filter rules +**Type:** String +**Purpose:** Specifies the name of the MCP (Model Context Protocol) server that the task should use; stored in frontmatter output but does not filter rules -The `mcp_servers` field is a **standard frontmatter field** following the industry standard for MCP server definition. It does not act as a selector. The field is a map where keys are server names and values are server configurations. +The `mcp_server` field is a **standard frontmatter field** that specifies the name of the MCP server to use. The name typically matches the task/rule filename. It does not act as a selector. **Example:** ```yaml --- -mcp_servers: - filesystem: - type: stdio - command: npx - args: ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/files"] - git: - type: stdio - command: npx - args: ["-y", "@modelcontextprotocol/server-git"] - database: - type: http - url: https://api.example.com/mcp - headers: - Authorization: Bearer token123 +mcp_server: filesystem --- ``` -**Note:** The format follows the MCP specification for server identification. Each server configuration includes a `type` field (e.g., "stdio", "http", "sse") and other fields specific to that transport type. +**Common server names:** +- `filesystem` - File system access +- `git` - Git repository operations +- `database` - Database access +- Custom server names based on your application + +**Note:** The field simply stores the server name as a string. The actual configuration of the server is handled by your AI agent's configuration. #### `agent` (optional, standard field) @@ -629,22 +622,15 @@ agent: cursor - If task/CLI specifies `agent: cursor`, only rules with `agent: cursor` or no agent field are included - Rules without an agent field are considered generic and always included (unless other selectors exclude them) -#### `mcp_servers` (rule metadata) +#### `mcp_server` (rule metadata) -Specifies MCP servers that need to be running for this rule. Does not filter rules. The field is a map where keys are server names and values are server configurations. +Specifies the name of the MCP server that needs to be running for this rule. Does not filter rules. The field is a simple string specifying the server name. ```yaml --- -mcp_servers: - filesystem: - type: stdio - command: npx - args: ["-y", "@modelcontextprotocol/server-filesystem"] - database: - type: http - url: https://api.example.com/mcp +mcp_server: filesystem --- -# Metadata indicating required MCP servers +# Metadata indicating required MCP server ``` **Note:** This field is informational and does not affect rule selection. diff --git a/examples/agents/tasks/example-mcp-arbitrary-fields.md b/examples/agents/tasks/example-mcp-arbitrary-fields.md index ba3f492..9e3a3bc 100644 --- a/examples/agents/tasks/example-mcp-arbitrary-fields.md +++ b/examples/agents/tasks/example-mcp-arbitrary-fields.md @@ -1,93 +1,50 @@ --- task_name: example-mcp-arbitrary-fields agent: cursor -mcp_servers: - # Example with standard fields only - filesystem: - type: stdio - command: filesystem - - # Example with standard fields plus arbitrary custom fields - custom-database: - type: stdio - command: database-mcp - args: ["--verbose"] - # Arbitrary fields below - cache_enabled: true - max_cache_size: 1000 - connection_pool_size: 10 - - # Example HTTP server with custom metadata - api-server: - type: http - url: https://api.example.com - headers: - Authorization: Bearer token123 - # Arbitrary fields below - api_version: v2 - rate_limit: 100 - timeout_seconds: 30 - retry_policy: exponential - region: us-west-2 - - # Example with nested custom configuration - advanced-server: - type: stdio - command: python - args: ["-m", "server"] - env: - PYTHON_PATH: /usr/bin/python3 - # Arbitrary nested fields below - custom_config: - host: localhost - port: 5432 - ssl: true - pool: - min: 2 - max: 10 - monitoring: - enabled: true - metrics_port: 9090 +mcp_server: filesystem --- -# Example Task with Arbitrary MCP Server Fields +# Example Task with MCP Server Field -This task demonstrates the ability to add arbitrary fields to MCP server configurations, just like we can with FrontMatter. +This task demonstrates the simplified MCP server field. -## Why Arbitrary Fields? +## The `mcp_server` Field -Different MCP servers may need different configuration options beyond the standard fields (`type`, `command`, `args`, `env`, `url`, `headers`). Arbitrary fields allow you to: +Instead of a complex map of server configurations, the `mcp_server` field is now a simple string that specifies the name of the MCP server to use. The name typically matches the filename or task name. -1. **Add custom metadata**: Version info, regions, endpoints, etc. -2. **Configure behavior**: Caching, retry policies, timeouts, rate limits -3. **Include nested config**: Complex configuration objects specific to your server -4. **Future-proof**: Add new fields without changing the schema +**Example:** +```yaml +--- +mcp_server: filesystem +--- +``` -## How It Works +## Why Simplify? -The `MCPServerConfig` struct now includes a `Content` field (similar to `BaseFrontMatter`) that captures all fields from YAML/JSON: +Previously, the `mcp_servers` field was a complex map with detailed configurations: -```go -type MCPServerConfig struct { - // Standard fields - Type TransportType - Command string - Args []string - Env map[string]string - URL string - Headers map[string]string - - // Arbitrary fields via inline map - Content map[string]any `yaml:",inline"` -} +```yaml +mcp_servers: + filesystem: + type: stdio + command: filesystem + git: + type: stdio + command: git +``` + +This was overly complex for most use cases. The new simplified format just specifies the server name: + +```yaml +mcp_server: filesystem ``` +## How It Works + +The `mcp_server` field is a **standard frontmatter field** that provides metadata about which MCP server should be used for the task. It does not act as a selector and does not filter rules. + ## Example Usage -The examples above show: -- **Simple custom fields**: `cache_enabled`, `max_cache_size` -- **API configuration**: `api_version`, `rate_limit`, `timeout_seconds` -- **Nested objects**: `custom_config` with sub-fields like `host`, `port`, `ssl` -- **Multiple custom sections**: `custom_config` and `monitoring` as separate objects +The example above shows a task that uses the `filesystem` MCP server. This is just a name - the actual configuration of the server is handled elsewhere (typically in your AI agent's configuration). -All these fields are preserved when the configuration is parsed and can be accessed via the `Content` map. +All fields are preserved when the configuration is parsed and appear in the task frontmatter output. diff --git a/examples/agents/tasks/example-with-standard-fields.md b/examples/agents/tasks/example-with-standard-fields.md index 0f132d1..e981a30 100644 --- a/examples/agents/tasks/example-with-standard-fields.md +++ b/examples/agents/tasks/example-with-standard-fields.md @@ -5,13 +5,7 @@ language: go model: anthropic.claude-sonnet-4-20250514-v1-0 single_shot: false timeout: 10m -mcp_servers: - filesystem: - type: stdio - command: filesystem - git: - type: stdio - command: git +mcp_server: filesystem selectors: stage: implementation --- @@ -34,7 +28,7 @@ These fields are stored in frontmatter and passed through to output, but do NOT - **model**: `anthropic.claude-sonnet-4-20250514-v1-0` - AI model to use for this task - **single_shot**: `false` - Task can be run multiple times - **timeout**: `10m` - Task timeout as time.Duration (10 minutes) -- **mcp_servers**: `[filesystem, git]` - MCP servers required for this task +- **mcp_server**: `filesystem` - MCP server name to use for this task ## Custom Selectors diff --git a/pkg/codingcontext/README.md b/pkg/codingcontext/README.md index d097626..9af1ef0 100644 --- a/pkg/codingcontext/README.md +++ b/pkg/codingcontext/README.md @@ -111,10 +111,10 @@ func main() { fmt.Printf("Custom field: %v\n", customField) } - // Access MCP server configurations - mcpServers := result.MCPServers() - for name, config := range mcpServers { - fmt.Printf("MCP Server %s: %s\n", name, config.Command) + // Access MCP server name + mcpServer := result.MCPServer() + if mcpServer != "" { + fmt.Printf("MCP Server: %s\n", mcpServer) } } ``` @@ -136,7 +136,7 @@ Result holds the assembled context from running a task: - `Agent Agent` - The agent used (from task frontmatter or option) **Methods:** -- `MCPServers() MCPServerConfigs` - Returns all MCP server configurations from rules and task +- `MCPServer() string` - Returns the MCP server name from the task (or empty string if not specified) #### `Markdown[T]` diff --git a/pkg/codingcontext/result.go b/pkg/codingcontext/result.go index c5f2585..e43e3f4 100644 --- a/pkg/codingcontext/result.go +++ b/pkg/codingcontext/result.go @@ -21,27 +21,14 @@ type Result struct { Agent Agent // The agent used (from task or -a flag) } -// MCPServers returns all MCP servers from both rules and the task. -// Servers from the task take precedence over servers from rules. -// If multiple rules define the same server name, the behavior is non-deterministic. -func (r *Result) MCPServers() MCPServerConfigs { - servers := make(MCPServerConfigs) - - // Add servers from rules first (so task can override) - for _, rule := range r.Rules { - if rule.FrontMatter.MCPServers != nil { - for name, config := range rule.FrontMatter.MCPServers { - servers[name] = config - } - } - } - - // Add servers from task (overriding any from rules) - if r.Task.FrontMatter.MCPServers != nil { - for name, config := range r.Task.FrontMatter.MCPServers { - servers[name] = config - } +// MCPServer returns the MCP server name from the task. +// If the task doesn't specify an MCP server, returns an empty string. +// Rules' MCP servers are ignored in favor of the task's MCP server. +func (r *Result) MCPServer() string { + // Return the MCP server from task + if r.Task.FrontMatter.MCPServer != "" { + return r.Task.FrontMatter.MCPServer } - return servers + return "" } diff --git a/pkg/codingcontext/result_test.go b/pkg/codingcontext/result_test.go index 8cca02d..f816f55 100644 --- a/pkg/codingcontext/result_test.go +++ b/pkg/codingcontext/result_test.go @@ -4,180 +4,76 @@ import ( "testing" ) -func TestResult_MCPServers(t *testing.T) { +func TestResult_MCPServer(t *testing.T) { tests := []struct { name string result Result - want MCPServerConfigs + want string }{ { - name: "no MCP servers", + name: "no MCP server", result: Result{ Rules: []Markdown[RuleFrontMatter]{}, Task: Markdown[TaskFrontMatter]{ FrontMatter: TaskFrontMatter{}, }, }, - want: MCPServerConfigs{}, + want: "", }, { - name: "MCP servers from task only", + name: "MCP server from task", result: Result{ Rules: []Markdown[RuleFrontMatter]{}, Task: Markdown[TaskFrontMatter]{ FrontMatter: TaskFrontMatter{ - MCPServers: MCPServerConfigs{ - "filesystem": {Type: TransportTypeStdio, Command: "filesystem"}, - "git": {Type: TransportTypeStdio, Command: "git"}, - }, - }, - }, - }, - want: MCPServerConfigs{ - "filesystem": {Type: TransportTypeStdio, Command: "filesystem"}, - "git": {Type: TransportTypeStdio, Command: "git"}, - }, - }, - { - name: "MCP servers from rules only", - result: Result{ - Rules: []Markdown[RuleFrontMatter]{ - { - FrontMatter: RuleFrontMatter{ - MCPServers: MCPServerConfigs{ - "jira": {Type: TransportTypeStdio, Command: "jira"}, - }, - }, - }, - { - FrontMatter: RuleFrontMatter{ - MCPServers: MCPServerConfigs{ - "api": {Type: TransportTypeHTTP, URL: "https://api.example.com"}, - }, - }, - }, - }, - Task: Markdown[TaskFrontMatter]{ - FrontMatter: TaskFrontMatter{}, - }, - }, - want: MCPServerConfigs{ - "jira": {Type: TransportTypeStdio, Command: "jira"}, - "api": {Type: TransportTypeHTTP, URL: "https://api.example.com"}, - }, - }, - { - name: "MCP servers from both task and rules", - result: Result{ - Rules: []Markdown[RuleFrontMatter]{ - { - FrontMatter: RuleFrontMatter{ - MCPServers: MCPServerConfigs{ - "jira": {Type: TransportTypeStdio, Command: "jira"}, - }, - }, + MCPServer: "filesystem", }, }, - Task: Markdown[TaskFrontMatter]{ - FrontMatter: TaskFrontMatter{ - MCPServers: MCPServerConfigs{ - "filesystem": {Type: TransportTypeStdio, Command: "filesystem"}, - }, - }, - }, - }, - want: MCPServerConfigs{ - "filesystem": {Type: TransportTypeStdio, Command: "filesystem"}, - "jira": {Type: TransportTypeStdio, Command: "jira"}, }, + want: "filesystem", }, { - name: "multiple rules with MCP servers", + name: "task MCP server takes precedence over rules", result: Result{ Rules: []Markdown[RuleFrontMatter]{ { FrontMatter: RuleFrontMatter{ - MCPServers: MCPServerConfigs{ - "server1": {Type: TransportTypeStdio, Command: "server1"}, - }, - }, - }, - { - FrontMatter: RuleFrontMatter{ - MCPServers: MCPServerConfigs{ - "server2": {Type: TransportTypeStdio, Command: "server2"}, - }, + MCPServer: "git", }, }, - { - FrontMatter: RuleFrontMatter{}, - }, }, Task: Markdown[TaskFrontMatter]{ FrontMatter: TaskFrontMatter{ - MCPServers: MCPServerConfigs{ - "task-server": {Type: TransportTypeStdio, Command: "task-server"}, - }, + MCPServer: "filesystem", }, }, }, - want: MCPServerConfigs{ - "task-server": {Type: TransportTypeStdio, Command: "task-server"}, - "server1": {Type: TransportTypeStdio, Command: "server1"}, - "server2": {Type: TransportTypeStdio, Command: "server2"}, - }, + want: "filesystem", }, { - name: "task overrides rule server with same name", + name: "rules have MCP server but task doesn't", result: Result{ Rules: []Markdown[RuleFrontMatter]{ { FrontMatter: RuleFrontMatter{ - MCPServers: MCPServerConfigs{ - "filesystem": {Type: TransportTypeStdio, Command: "rule-filesystem"}, - }, + MCPServer: "jira", }, }, }, Task: Markdown[TaskFrontMatter]{ - FrontMatter: TaskFrontMatter{ - MCPServers: MCPServerConfigs{ - "filesystem": {Type: TransportTypeStdio, Command: "task-filesystem"}, - }, - }, + FrontMatter: TaskFrontMatter{}, }, }, - want: MCPServerConfigs{ - "filesystem": {Type: TransportTypeStdio, Command: "task-filesystem"}, - }, + want: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := tt.result.MCPServers() - - if len(got) != len(tt.want) { - t.Errorf("MCPServers() returned %d servers, want %d", len(got), len(tt.want)) - return - } - - for name, wantServer := range tt.want { - gotServer, exists := got[name] - if !exists { - t.Errorf("MCPServers() missing server %q", name) - continue - } + got := tt.result.MCPServer() - if gotServer.Type != wantServer.Type { - t.Errorf("MCPServers()[%q].Type = %v, want %v", name, gotServer.Type, wantServer.Type) - } - if gotServer.Command != wantServer.Command { - t.Errorf("MCPServers()[%q].Command = %q, want %q", name, gotServer.Command, wantServer.Command) - } - if gotServer.URL != wantServer.URL { - t.Errorf("MCPServers()[%q].URL = %q, want %q", name, gotServer.URL, wantServer.URL) - } + if got != tt.want { + t.Errorf("MCPServer() = %q, want %q", got, tt.want) } }) } diff --git a/pkg/codingcontext/rule_frontmatter.go b/pkg/codingcontext/rule_frontmatter.go index f0a8583..11de717 100644 --- a/pkg/codingcontext/rule_frontmatter.go +++ b/pkg/codingcontext/rule_frontmatter.go @@ -19,9 +19,10 @@ type RuleFrontMatter struct { // Agent specifies which AI agent this rule is intended for Agent string `yaml:"agent,omitempty" json:"agent,omitempty"` - // MCPServers maps server names to their configurations + // MCPServer specifies the name of the MCP server to use + // The name typically matches the task/rule filename // Metadata only, does not filter - MCPServers MCPServerConfigs `yaml:"mcp_servers,omitempty" json:"mcp_servers,omitempty"` + MCPServer string `yaml:"mcp_server,omitempty" json:"mcp_server,omitempty"` // RuleName is an optional identifier for the rule file RuleName string `yaml:"rule_name,omitempty" json:"rule_name,omitempty"` diff --git a/pkg/codingcontext/rule_frontmatter_test.go b/pkg/codingcontext/rule_frontmatter_test.go index 0dfdfdd..3fba687 100644 --- a/pkg/codingcontext/rule_frontmatter_test.go +++ b/pkg/codingcontext/rule_frontmatter_test.go @@ -50,13 +50,8 @@ agent: cursor TaskNames: []string{"test-task"}, Languages: []string{"go", "python"}, Agent: "copilot", - MCPServers: MCPServerConfigs{ - "database": { - Type: TransportTypeStdio, - Command: "database-server", - }, - }, - RuleName: "test-rule", + MCPServer: "database", + RuleName: "test-rule", }, want: `task_names: - test-task @@ -64,10 +59,7 @@ languages: - go - python agent: copilot -mcp_servers: - database: - type: stdio - command: database-server +mcp_server: database rule_name: test-rule `, }, diff --git a/pkg/codingcontext/task_frontmatter.go b/pkg/codingcontext/task_frontmatter.go index 7d8d5d9..657be01 100644 --- a/pkg/codingcontext/task_frontmatter.go +++ b/pkg/codingcontext/task_frontmatter.go @@ -28,9 +28,10 @@ type TaskFrontMatter struct { // Does not filter rules, metadata only Timeout string `yaml:"timeout,omitempty" json:"timeout,omitempty"` - // MCPServers maps server names to their configurations + // MCPServer specifies the name of the MCP server to use + // The name typically matches the task/rule filename // Does not filter rules, metadata only - MCPServers MCPServerConfigs `yaml:"mcp_servers,omitempty" json:"mcp_servers,omitempty"` + MCPServer string `yaml:"mcp_server,omitempty" json:"mcp_server,omitempty"` // Resume indicates if this task should be resumed Resume bool `yaml:"resume,omitempty" json:"resume,omitempty"` diff --git a/pkg/codingcontext/task_frontmatter_test.go b/pkg/codingcontext/task_frontmatter_test.go index 6eb895d..59203d4 100644 --- a/pkg/codingcontext/task_frontmatter_test.go +++ b/pkg/codingcontext/task_frontmatter_test.go @@ -32,11 +32,8 @@ func TestTaskFrontMatter_Marshal(t *testing.T) { Model: "gpt-4", SingleShot: true, Timeout: "10m", - MCPServers: MCPServerConfigs{ - "filesystem": {Type: TransportTypeStdio, Command: "filesystem-server"}, - "git": {Type: TransportTypeStdio, Command: "git-server"}, - }, - Resume: false, + MCPServer: "filesystem", + Resume: false, Selectors: map[string]any{ "stage": "implementation", }, @@ -48,13 +45,7 @@ languages: model: gpt-4 single_shot: true timeout: 10m -mcp_servers: - filesystem: - type: stdio - command: filesystem-server - git: - type: stdio - command: git-server +mcp_server: filesystem selectors: stage: implementation `, @@ -141,13 +132,7 @@ languages: model: gpt-4 single_shot: true timeout: 10m -mcp_servers: - filesystem: - type: stdio - command: filesystem-server - git: - type: stdio - command: git-server +mcp_server: filesystem selectors: stage: implementation `, @@ -160,10 +145,7 @@ selectors: Model: "gpt-4", SingleShot: true, Timeout: "10m", - MCPServers: MCPServerConfigs{ - "filesystem": {Type: TransportTypeStdio, Command: "filesystem-server"}, - "git": {Type: TransportTypeStdio, Command: "git-server"}, - }, + MCPServer: "filesystem", Selectors: map[string]any{ "stage": "implementation", }, From d62274abebda84fff6eb182059c676a3ae5fd98b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Dec 2025 23:34:01 +0000 Subject: [PATCH 03/10] Clarify MCPServer() comment about rules being ignored Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> --- pkg/codingcontext/result.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/codingcontext/result.go b/pkg/codingcontext/result.go index e43e3f4..7f009f1 100644 --- a/pkg/codingcontext/result.go +++ b/pkg/codingcontext/result.go @@ -23,7 +23,8 @@ type Result struct { // MCPServer returns the MCP server name from the task. // If the task doesn't specify an MCP server, returns an empty string. -// Rules' MCP servers are ignored in favor of the task's MCP server. +// Note: MCP servers specified in rules are intentionally ignored - only the task's +// MCP server is used. This ensures a single, clear source of truth for the MCP server. func (r *Result) MCPServer() string { // Return the MCP server from task if r.Task.FrontMatter.MCPServer != "" { From 64b7225e125a724a27d1b4c6bf1ec205c773efc5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Dec 2025 23:51:45 +0000 Subject: [PATCH 04/10] Change mcp_servers map to single mcp_server config object Convert from map of server configs to single MCPServerConfig object. Each task/rule now specifies one MCP server configuration with standard fields (command, args, env, type, url, headers) plus arbitrary custom fields via the Content map. Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> --- docs/reference/file-formats.md | 44 +++++-- .../tasks/example-mcp-arbitrary-fields.md | 83 ++++++++----- .../tasks/example-with-standard-fields.md | 6 +- pkg/codingcontext/README.md | 10 +- pkg/codingcontext/result.go | 27 +++-- pkg/codingcontext/result_test.go | 112 +++++++++++++++--- pkg/codingcontext/rule_frontmatter.go | 5 +- pkg/codingcontext/rule_frontmatter_test.go | 15 ++- pkg/codingcontext/task_frontmatter.go | 5 +- pkg/codingcontext/task_frontmatter_test.go | 26 +++- 10 files changed, 242 insertions(+), 91 deletions(-) diff --git a/docs/reference/file-formats.md b/docs/reference/file-formats.md index bb9148e..dc4fe2c 100644 --- a/docs/reference/file-formats.md +++ b/docs/reference/file-formats.md @@ -139,25 +139,38 @@ timeout: 10m #### `mcp_server` (optional, standard field) -**Type:** String -**Purpose:** Specifies the name of the MCP (Model Context Protocol) server that the task should use; stored in frontmatter output but does not filter rules +**Type:** Object (MCP server configuration) +**Purpose:** Specifies a single MCP (Model Context Protocol) server configuration for the task; stored in frontmatter output but does not filter rules + +The `mcp_server` field is a **standard frontmatter field** that defines one MCP server configuration. It does not act as a selector. The field is an object with both standard configuration fields and support for arbitrary custom fields. -The `mcp_server` field is a **standard frontmatter field** that specifies the name of the MCP server to use. The name typically matches the task/rule filename. It does not act as a selector. +**Standard configuration fields:** +- `command`: The executable to run (e.g., "npx", "python", "docker") +- `args`: Array of command-line arguments +- `env`: Map of environment variables +- `type`: Connection protocol - "stdio" (default), "http", or "sse" +- `url`: Endpoint URL (required for HTTP/SSE types) +- `headers`: Custom HTTP headers (for HTTP/SSE types) **Example:** ```yaml --- -mcp_server: filesystem +mcp_server: + command: python + args: ["-m", "server"] + env: + PYTHON_PATH: /usr/bin/python3 + custom_config: + host: localhost + port: 5432 + ssl: true --- ``` -**Common server names:** -- `filesystem` - File system access -- `git` - Git repository operations -- `database` - Database access -- Custom server names based on your application +**Additional arbitrary fields:** +You can include any custom fields for your specific server needs (e.g., `custom_config`, `monitoring`, `cache_enabled`, etc.). All fields are preserved in the configuration. -**Note:** The field simply stores the server name as a string. The actual configuration of the server is handled by your AI agent's configuration. +**Note:** Each task or rule can specify one MCP server configuration. The format supports the standard MCP server fields plus arbitrary custom fields for flexibility. #### `agent` (optional, standard field) @@ -624,11 +637,18 @@ agent: cursor #### `mcp_server` (rule metadata) -Specifies the name of the MCP server that needs to be running for this rule. Does not filter rules. The field is a simple string specifying the server name. +Specifies an MCP server configuration that needs to be running for this rule. Does not filter rules. The field is an object with standard and arbitrary custom fields. ```yaml --- -mcp_server: filesystem +mcp_server: + command: python + args: ["-m", "server"] + env: + PYTHON_PATH: /usr/bin/python3 + custom_config: + host: localhost + port: 5432 --- # Metadata indicating required MCP server ``` diff --git a/examples/agents/tasks/example-mcp-arbitrary-fields.md b/examples/agents/tasks/example-mcp-arbitrary-fields.md index 9e3a3bc..646abf8 100644 --- a/examples/agents/tasks/example-mcp-arbitrary-fields.md +++ b/examples/agents/tasks/example-mcp-arbitrary-fields.md @@ -1,50 +1,71 @@ --- task_name: example-mcp-arbitrary-fields agent: cursor -mcp_server: filesystem +mcp_server: + command: python + args: ["-m", "server"] + env: + PYTHON_PATH: /usr/bin/python3 + custom_config: + host: localhost + port: 5432 + ssl: true + pool: + min: 2 + max: 10 + monitoring: + enabled: true + metrics_port: 9090 --- -# Example Task with MCP Server Field +# Example Task with MCP Server Configuration -This task demonstrates the simplified MCP server field. +This task demonstrates the MCP server configuration with arbitrary custom fields. ## The `mcp_server` Field -Instead of a complex map of server configurations, the `mcp_server` field is now a simple string that specifies the name of the MCP server to use. The name typically matches the filename or task name. +The `mcp_server` field specifies a single MCP server configuration with both standard and arbitrary custom fields. Each task or rule can specify one MCP server configuration. -**Example:** -```yaml ---- -mcp_server: filesystem ---- -``` - -## Why Simplify? +**Standard fields:** +- `command`: The executable to run (e.g., "python", "npx", "docker") +- `args`: Array of command-line arguments +- `env`: Environment variables for the server process +- `type`: Connection protocol ("stdio", "http", "sse") - optional, defaults to stdio +- `url`: Endpoint URL for HTTP/SSE types +- `headers`: Custom HTTP headers for HTTP/SSE types -Previously, the `mcp_servers` field was a complex map with detailed configurations: +**Arbitrary custom fields:** +You can add any additional fields for your specific MCP server needs: +- `custom_config`: Nested configuration objects +- `monitoring`: Monitoring settings +- `cache_enabled`, `max_retries`, `timeout_seconds`, etc. -```yaml -mcp_servers: - filesystem: - type: stdio - command: filesystem - git: - type: stdio - command: git -``` +## Why Arbitrary Fields? -This was overly complex for most use cases. The new simplified format just specifies the server name: +Different MCP servers may need different configuration options beyond the standard fields. Arbitrary fields allow you to: -```yaml -mcp_server: filesystem -``` +1. **Add custom metadata**: Version info, regions, endpoints, etc. +2. **Configure behavior**: Caching, retry policies, timeouts, rate limits +3. **Include nested config**: Complex configuration objects specific to your server +4. **Future-proof**: Add new fields without changing the schema ## How It Works -The `mcp_server` field is a **standard frontmatter field** that provides metadata about which MCP server should be used for the task. It does not act as a selector and does not filter rules. +The `MCPServerConfig` struct includes a `Content` field that captures all fields from YAML/JSON: -## Example Usage - -The example above shows a task that uses the `filesystem` MCP server. This is just a name - the actual configuration of the server is handled elsewhere (typically in your AI agent's configuration). +```go +type MCPServerConfig struct { + // Standard fields + Type TransportType + Command string + Args []string + Env map[string]string + URL string + Headers map[string]string + + // Arbitrary fields via inline map + Content map[string]any `yaml:",inline"` +} +``` -All fields are preserved when the configuration is parsed and appear in the task frontmatter output. +All fields (both standard and custom) are preserved when the configuration is parsed and can be accessed via the struct fields or the `Content` map. diff --git a/examples/agents/tasks/example-with-standard-fields.md b/examples/agents/tasks/example-with-standard-fields.md index e981a30..cf49821 100644 --- a/examples/agents/tasks/example-with-standard-fields.md +++ b/examples/agents/tasks/example-with-standard-fields.md @@ -5,7 +5,9 @@ language: go model: anthropic.claude-sonnet-4-20250514-v1-0 single_shot: false timeout: 10m -mcp_server: filesystem +mcp_server: + command: npx + args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] selectors: stage: implementation --- @@ -28,7 +30,7 @@ These fields are stored in frontmatter and passed through to output, but do NOT - **model**: `anthropic.claude-sonnet-4-20250514-v1-0` - AI model to use for this task - **single_shot**: `false` - Task can be run multiple times - **timeout**: `10m` - Task timeout as time.Duration (10 minutes) -- **mcp_server**: `filesystem` - MCP server name to use for this task +- **mcp_server**: Configuration for the MCP server required for this task ## Custom Selectors diff --git a/pkg/codingcontext/README.md b/pkg/codingcontext/README.md index 9af1ef0..a72dc46 100644 --- a/pkg/codingcontext/README.md +++ b/pkg/codingcontext/README.md @@ -111,10 +111,10 @@ func main() { fmt.Printf("Custom field: %v\n", customField) } - // Access MCP server name - mcpServer := result.MCPServer() - if mcpServer != "" { - fmt.Printf("MCP Server: %s\n", mcpServer) + // Access MCP server configurations + mcpServers := result.MCPServers() + for i, config := range mcpServers { + fmt.Printf("MCP Server %d: %s\n", i, config.Command) } } ``` @@ -136,7 +136,7 @@ Result holds the assembled context from running a task: - `Agent Agent` - The agent used (from task frontmatter or option) **Methods:** -- `MCPServer() string` - Returns the MCP server name from the task (or empty string if not specified) +- `MCPServers() []MCPServerConfig` - Returns all MCP server configurations from rules and task as a slice #### `Markdown[T]` diff --git a/pkg/codingcontext/result.go b/pkg/codingcontext/result.go index 7f009f1..da3b7fc 100644 --- a/pkg/codingcontext/result.go +++ b/pkg/codingcontext/result.go @@ -21,15 +21,24 @@ type Result struct { Agent Agent // The agent used (from task or -a flag) } -// MCPServer returns the MCP server name from the task. -// If the task doesn't specify an MCP server, returns an empty string. -// Note: MCP servers specified in rules are intentionally ignored - only the task's -// MCP server is used. This ensures a single, clear source of truth for the MCP server. -func (r *Result) MCPServer() string { - // Return the MCP server from task - if r.Task.FrontMatter.MCPServer != "" { - return r.Task.FrontMatter.MCPServer +// MCPServers returns all MCP server configurations from both rules and the task. +// Each rule and the task can specify one MCP server configuration. +// Returns a slice of all configured MCP servers. +func (r *Result) MCPServers() []MCPServerConfig { + var servers []MCPServerConfig + + // Add server from each rule that has one + for _, rule := range r.Rules { + // Check if the MCPServer is not empty (has at least one field set) + if rule.FrontMatter.MCPServer.Command != "" || rule.FrontMatter.MCPServer.URL != "" { + servers = append(servers, rule.FrontMatter.MCPServer) + } + } + + // Add server from task if it has one + if r.Task.FrontMatter.MCPServer.Command != "" || r.Task.FrontMatter.MCPServer.URL != "" { + servers = append(servers, r.Task.FrontMatter.MCPServer) } - return "" + return servers } diff --git a/pkg/codingcontext/result_test.go b/pkg/codingcontext/result_test.go index f816f55..9fa4532 100644 --- a/pkg/codingcontext/result_test.go +++ b/pkg/codingcontext/result_test.go @@ -4,76 +4,152 @@ import ( "testing" ) -func TestResult_MCPServer(t *testing.T) { +func TestResult_MCPServers(t *testing.T) { tests := []struct { name string result Result - want string + want []MCPServerConfig }{ { - name: "no MCP server", + name: "no MCP servers", result: Result{ Rules: []Markdown[RuleFrontMatter]{}, Task: Markdown[TaskFrontMatter]{ FrontMatter: TaskFrontMatter{}, }, }, - want: "", + want: []MCPServerConfig{}, }, { - name: "MCP server from task", + name: "MCP server from task only", result: Result{ Rules: []Markdown[RuleFrontMatter]{}, Task: Markdown[TaskFrontMatter]{ FrontMatter: TaskFrontMatter{ - MCPServer: "filesystem", + MCPServer: MCPServerConfig{Type: TransportTypeStdio, Command: "filesystem"}, }, }, }, - want: "filesystem", + want: []MCPServerConfig{ + {Type: TransportTypeStdio, Command: "filesystem"}, + }, }, { - name: "task MCP server takes precedence over rules", + name: "MCP servers from rules only", result: Result{ Rules: []Markdown[RuleFrontMatter]{ { FrontMatter: RuleFrontMatter{ - MCPServer: "git", + MCPServer: MCPServerConfig{Type: TransportTypeStdio, Command: "jira"}, + }, + }, + { + FrontMatter: RuleFrontMatter{ + MCPServer: MCPServerConfig{Type: TransportTypeHTTP, URL: "https://api.example.com"}, + }, + }, + }, + Task: Markdown[TaskFrontMatter]{ + FrontMatter: TaskFrontMatter{}, + }, + }, + want: []MCPServerConfig{ + {Type: TransportTypeStdio, Command: "jira"}, + {Type: TransportTypeHTTP, URL: "https://api.example.com"}, + }, + }, + { + name: "MCP servers from both task and rules", + result: Result{ + Rules: []Markdown[RuleFrontMatter]{ + { + FrontMatter: RuleFrontMatter{ + MCPServer: MCPServerConfig{Type: TransportTypeStdio, Command: "jira"}, }, }, }, Task: Markdown[TaskFrontMatter]{ FrontMatter: TaskFrontMatter{ - MCPServer: "filesystem", + MCPServer: MCPServerConfig{Type: TransportTypeStdio, Command: "filesystem"}, }, }, }, - want: "filesystem", + want: []MCPServerConfig{ + {Type: TransportTypeStdio, Command: "jira"}, + {Type: TransportTypeStdio, Command: "filesystem"}, + }, }, { - name: "rules have MCP server but task doesn't", + name: "multiple rules with MCP servers", result: Result{ Rules: []Markdown[RuleFrontMatter]{ { FrontMatter: RuleFrontMatter{ - MCPServer: "jira", + MCPServer: MCPServerConfig{Type: TransportTypeStdio, Command: "server1"}, + }, + }, + { + FrontMatter: RuleFrontMatter{ + MCPServer: MCPServerConfig{Type: TransportTypeStdio, Command: "server2"}, }, }, + { + FrontMatter: RuleFrontMatter{}, + }, }, Task: Markdown[TaskFrontMatter]{ - FrontMatter: TaskFrontMatter{}, + FrontMatter: TaskFrontMatter{ + MCPServer: MCPServerConfig{Type: TransportTypeStdio, Command: "task-server"}, + }, }, }, - want: "", + want: []MCPServerConfig{ + {Type: TransportTypeStdio, Command: "server1"}, + {Type: TransportTypeStdio, Command: "server2"}, + {Type: TransportTypeStdio, Command: "task-server"}, + }, + }, + { + name: "rule without MCP server", + result: Result{ + Rules: []Markdown[RuleFrontMatter]{ + { + FrontMatter: RuleFrontMatter{}, + }, + }, + Task: Markdown[TaskFrontMatter]{ + FrontMatter: TaskFrontMatter{ + MCPServer: MCPServerConfig{Type: TransportTypeStdio, Command: "filesystem"}, + }, + }, + }, + want: []MCPServerConfig{ + {Type: TransportTypeStdio, Command: "filesystem"}, + }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := tt.result.MCPServer() + got := tt.result.MCPServers() + + if len(got) != len(tt.want) { + t.Errorf("MCPServers() returned %d servers, want %d", len(got), len(tt.want)) + return + } + + for i, wantServer := range tt.want { + gotServer := got[i] - if got != tt.want { - t.Errorf("MCPServer() = %q, want %q", got, tt.want) + if gotServer.Type != wantServer.Type { + t.Errorf("MCPServers()[%d].Type = %v, want %v", i, gotServer.Type, wantServer.Type) + } + if gotServer.Command != wantServer.Command { + t.Errorf("MCPServers()[%d].Command = %q, want %q", i, gotServer.Command, wantServer.Command) + } + if gotServer.URL != wantServer.URL { + t.Errorf("MCPServers()[%d].URL = %q, want %q", i, gotServer.URL, wantServer.URL) + } } }) } diff --git a/pkg/codingcontext/rule_frontmatter.go b/pkg/codingcontext/rule_frontmatter.go index 11de717..78c5da8 100644 --- a/pkg/codingcontext/rule_frontmatter.go +++ b/pkg/codingcontext/rule_frontmatter.go @@ -19,10 +19,9 @@ type RuleFrontMatter struct { // Agent specifies which AI agent this rule is intended for Agent string `yaml:"agent,omitempty" json:"agent,omitempty"` - // MCPServer specifies the name of the MCP server to use - // The name typically matches the task/rule filename + // MCPServer specifies a single MCP server configuration // Metadata only, does not filter - MCPServer string `yaml:"mcp_server,omitempty" json:"mcp_server,omitempty"` + MCPServer MCPServerConfig `yaml:"mcp_server,omitempty" json:"mcp_server,omitempty"` // RuleName is an optional identifier for the rule file RuleName string `yaml:"rule_name,omitempty" json:"rule_name,omitempty"` diff --git a/pkg/codingcontext/rule_frontmatter_test.go b/pkg/codingcontext/rule_frontmatter_test.go index 3fba687..55ba4ce 100644 --- a/pkg/codingcontext/rule_frontmatter_test.go +++ b/pkg/codingcontext/rule_frontmatter_test.go @@ -50,8 +50,12 @@ agent: cursor TaskNames: []string{"test-task"}, Languages: []string{"go", "python"}, Agent: "copilot", - MCPServer: "database", - RuleName: "test-rule", + MCPServer: MCPServerConfig{ + Type: TransportTypeStdio, + Command: "database-server", + Args: []string{"--port", "5432"}, + }, + RuleName: "test-rule", }, want: `task_names: - test-task @@ -59,7 +63,12 @@ languages: - go - python agent: copilot -mcp_server: database +mcp_server: + type: stdio + command: database-server + args: + - --port + - "5432" rule_name: test-rule `, }, diff --git a/pkg/codingcontext/task_frontmatter.go b/pkg/codingcontext/task_frontmatter.go index 657be01..16f785d 100644 --- a/pkg/codingcontext/task_frontmatter.go +++ b/pkg/codingcontext/task_frontmatter.go @@ -28,10 +28,9 @@ type TaskFrontMatter struct { // Does not filter rules, metadata only Timeout string `yaml:"timeout,omitempty" json:"timeout,omitempty"` - // MCPServer specifies the name of the MCP server to use - // The name typically matches the task/rule filename + // MCPServer specifies a single MCP server configuration // Does not filter rules, metadata only - MCPServer string `yaml:"mcp_server,omitempty" json:"mcp_server,omitempty"` + MCPServer MCPServerConfig `yaml:"mcp_server,omitempty" json:"mcp_server,omitempty"` // Resume indicates if this task should be resumed Resume bool `yaml:"resume,omitempty" json:"resume,omitempty"` diff --git a/pkg/codingcontext/task_frontmatter_test.go b/pkg/codingcontext/task_frontmatter_test.go index 59203d4..5244cd9 100644 --- a/pkg/codingcontext/task_frontmatter_test.go +++ b/pkg/codingcontext/task_frontmatter_test.go @@ -32,8 +32,12 @@ func TestTaskFrontMatter_Marshal(t *testing.T) { Model: "gpt-4", SingleShot: true, Timeout: "10m", - MCPServer: "filesystem", - Resume: false, + MCPServer: MCPServerConfig{ + Type: TransportTypeStdio, + Command: "filesystem-server", + Args: []string{"--verbose"}, + }, + Resume: false, Selectors: map[string]any{ "stage": "implementation", }, @@ -45,7 +49,11 @@ languages: model: gpt-4 single_shot: true timeout: 10m -mcp_server: filesystem +mcp_server: + type: stdio + command: filesystem-server + args: + - --verbose selectors: stage: implementation `, @@ -132,7 +140,11 @@ languages: model: gpt-4 single_shot: true timeout: 10m -mcp_server: filesystem +mcp_server: + type: stdio + command: filesystem-server + args: + - --verbose selectors: stage: implementation `, @@ -145,7 +157,11 @@ selectors: Model: "gpt-4", SingleShot: true, Timeout: "10m", - MCPServer: "filesystem", + MCPServer: MCPServerConfig{ + Type: TransportTypeStdio, + Command: "filesystem-server", + Args: []string{"--verbose"}, + }, Selectors: map[string]any{ "stage": "implementation", }, From 6ad46e5c3034500f74718828b30e1efa99951f10 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 17 Dec 2025 19:53:36 +0000 Subject: [PATCH 05/10] Remove empty MCP server detection checks Remove the empty server detection logic from MCPServers() method as requested. The agent will handle filtering empty configs. Updated tests to expect empty MCPServerConfig structs in the returned slice. Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> --- pkg/codingcontext/result.go | 13 ++++--------- pkg/codingcontext/result_test.go | 7 ++++++- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/pkg/codingcontext/result.go b/pkg/codingcontext/result.go index da3b7fc..a0d3773 100644 --- a/pkg/codingcontext/result.go +++ b/pkg/codingcontext/result.go @@ -27,18 +27,13 @@ type Result struct { func (r *Result) MCPServers() []MCPServerConfig { var servers []MCPServerConfig - // Add server from each rule that has one + // Add server from each rule for _, rule := range r.Rules { - // Check if the MCPServer is not empty (has at least one field set) - if rule.FrontMatter.MCPServer.Command != "" || rule.FrontMatter.MCPServer.URL != "" { - servers = append(servers, rule.FrontMatter.MCPServer) - } + servers = append(servers, rule.FrontMatter.MCPServer) } - // Add server from task if it has one - if r.Task.FrontMatter.MCPServer.Command != "" || r.Task.FrontMatter.MCPServer.URL != "" { - servers = append(servers, r.Task.FrontMatter.MCPServer) - } + // Add server from task + servers = append(servers, r.Task.FrontMatter.MCPServer) return servers } diff --git a/pkg/codingcontext/result_test.go b/pkg/codingcontext/result_test.go index 9fa4532..1a7c816 100644 --- a/pkg/codingcontext/result_test.go +++ b/pkg/codingcontext/result_test.go @@ -18,7 +18,9 @@ func TestResult_MCPServers(t *testing.T) { FrontMatter: TaskFrontMatter{}, }, }, - want: []MCPServerConfig{}, + want: []MCPServerConfig{ + {}, // Empty task MCP server + }, }, { name: "MCP server from task only", @@ -56,6 +58,7 @@ func TestResult_MCPServers(t *testing.T) { want: []MCPServerConfig{ {Type: TransportTypeStdio, Command: "jira"}, {Type: TransportTypeHTTP, URL: "https://api.example.com"}, + {}, // Empty task MCP server }, }, { @@ -106,6 +109,7 @@ func TestResult_MCPServers(t *testing.T) { want: []MCPServerConfig{ {Type: TransportTypeStdio, Command: "server1"}, {Type: TransportTypeStdio, Command: "server2"}, + {}, // Empty rule MCP server {Type: TransportTypeStdio, Command: "task-server"}, }, }, @@ -124,6 +128,7 @@ func TestResult_MCPServers(t *testing.T) { }, }, want: []MCPServerConfig{ + {}, // Empty rule MCP server {Type: TransportTypeStdio, Command: "filesystem"}, }, }, From c23bcdfa0aade2783d29f72e6b077c31ba904962 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 22 Dec 2025 19:32:33 +0000 Subject: [PATCH 06/10] Remove MCPServer field from TaskFrontMatter Tasks no longer have mcp_server field. MCP servers are now only specified in rules. Result.MCPServers() now only returns configs from rules. Updated tests, examples, and documentation. Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> --- docs/reference/file-formats.md | 54 ++++++------------- .../tasks/example-mcp-arbitrary-fields.md | 36 ++++++++++--- .../tasks/example-with-standard-fields.md | 4 -- pkg/codingcontext/README.md | 2 +- pkg/codingcontext/result.go | 9 ++-- pkg/codingcontext/result_test.go | 50 ++--------------- pkg/codingcontext/task_frontmatter.go | 4 -- pkg/codingcontext/task_frontmatter_test.go | 22 +------- 8 files changed, 54 insertions(+), 127 deletions(-) diff --git a/docs/reference/file-formats.md b/docs/reference/file-formats.md index dc4fe2c..2828f62 100644 --- a/docs/reference/file-formats.md +++ b/docs/reference/file-formats.md @@ -137,41 +137,6 @@ timeout: 10m - `1h` - 1 hour - `1h30m` - 1 hour 30 minutes -#### `mcp_server` (optional, standard field) - -**Type:** Object (MCP server configuration) -**Purpose:** Specifies a single MCP (Model Context Protocol) server configuration for the task; stored in frontmatter output but does not filter rules - -The `mcp_server` field is a **standard frontmatter field** that defines one MCP server configuration. It does not act as a selector. The field is an object with both standard configuration fields and support for arbitrary custom fields. - -**Standard configuration fields:** -- `command`: The executable to run (e.g., "npx", "python", "docker") -- `args`: Array of command-line arguments -- `env`: Map of environment variables -- `type`: Connection protocol - "stdio" (default), "http", or "sse" -- `url`: Endpoint URL (required for HTTP/SSE types) -- `headers`: Custom HTTP headers (for HTTP/SSE types) - -**Example:** -```yaml ---- -mcp_server: - command: python - args: ["-m", "server"] - env: - PYTHON_PATH: /usr/bin/python3 - custom_config: - host: localhost - port: 5432 - ssl: true ---- -``` - -**Additional arbitrary fields:** -You can include any custom fields for your specific server needs (e.g., `custom_config`, `monitoring`, `cache_enabled`, etc.). All fields are preserved in the configuration. - -**Note:** Each task or rule can specify one MCP server configuration. The format supports the standard MCP server fields plus arbitrary custom fields for flexibility. - #### `agent` (optional, standard field) **Type:** String @@ -637,7 +602,9 @@ agent: cursor #### `mcp_server` (rule metadata) -Specifies an MCP server configuration that needs to be running for this rule. Does not filter rules. The field is an object with standard and arbitrary custom fields. +Specifies an MCP server configuration for this rule. Each rule can specify one MCP server configuration with standard and arbitrary custom fields. Does not filter rules. + +**Important:** MCP servers are specified in rules only, not in tasks. Tasks select rules (and thus MCP servers) via selectors. ```yaml --- @@ -650,10 +617,21 @@ mcp_server: host: localhost port: 5432 --- -# Metadata indicating required MCP server +# Rule with MCP server configuration ``` -**Note:** This field is informational and does not affect rule selection. +**Standard configuration fields:** +- `command`: The executable to run (e.g., "npx", "python", "docker") +- `args`: Array of command-line arguments +- `env`: Map of environment variables +- `type`: Connection protocol - "stdio" (default), "http", or "sse" +- `url`: Endpoint URL (required for HTTP/SSE types) +- `headers`: Custom HTTP headers (for HTTP/SSE types) + +**Additional arbitrary fields:** +You can include any custom fields for your specific server needs (e.g., `custom_config`, `monitoring`, `cache_enabled`, etc.). All fields are preserved in the configuration. + +**Note:** This field is metadata and does not affect rule selection. #### `expand` (optional) diff --git a/examples/agents/tasks/example-mcp-arbitrary-fields.md b/examples/agents/tasks/example-mcp-arbitrary-fields.md index 646abf8..d4c291f 100644 --- a/examples/agents/tasks/example-mcp-arbitrary-fields.md +++ b/examples/agents/tasks/example-mcp-arbitrary-fields.md @@ -1,6 +1,33 @@ --- task_name: example-mcp-arbitrary-fields agent: cursor +--- + +# Example Rule with MCP Server Configuration + +This example demonstrates how rules can specify MCP server configuration with arbitrary custom fields. + +Note: MCP servers are specified in rules, not in tasks. Tasks can select which rules (and thus which MCP servers) to use via selectors. + +## The `mcp_server` Field in Rules + +Rules can specify a single MCP server configuration with both standard and arbitrary custom fields. + +The `mcp_server` field specifies a single MCP server configuration with both standard and arbitrary custom fields. Each task or rule can specify one MCP server configuration. + +**Standard fields:** +- `command`: The executable to run (e.g., "python", "npx", "docker") +- `args`: Array of command-line arguments +- `env`: Environment variables for the server process +- `type`: Connection protocol ("stdio", "http", "sse") - optional, defaults to stdio +- `url`: Endpoint URL for HTTP/SSE types +- `headers`: Custom HTTP headers for HTTP/SSE types + +## Example Rule with MCP Server + +```yaml +--- +rule_name: python-mcp-server mcp_server: command: python args: ["-m", "server"] @@ -18,13 +45,10 @@ mcp_server: metrics_port: 9090 --- -# Example Task with MCP Server Configuration - -This task demonstrates the MCP server configuration with arbitrary custom fields. - -## The `mcp_server` Field +# Python MCP Server Rule -The `mcp_server` field specifies a single MCP server configuration with both standard and arbitrary custom fields. Each task or rule can specify one MCP server configuration. +This rule provides the Python MCP server configuration. +``` **Standard fields:** - `command`: The executable to run (e.g., "python", "npx", "docker") diff --git a/examples/agents/tasks/example-with-standard-fields.md b/examples/agents/tasks/example-with-standard-fields.md index cf49821..ec38f96 100644 --- a/examples/agents/tasks/example-with-standard-fields.md +++ b/examples/agents/tasks/example-with-standard-fields.md @@ -5,9 +5,6 @@ language: go model: anthropic.claude-sonnet-4-20250514-v1-0 single_shot: false timeout: 10m -mcp_server: - command: npx - args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] selectors: stage: implementation --- @@ -30,7 +27,6 @@ These fields are stored in frontmatter and passed through to output, but do NOT - **model**: `anthropic.claude-sonnet-4-20250514-v1-0` - AI model to use for this task - **single_shot**: `false` - Task can be run multiple times - **timeout**: `10m` - Task timeout as time.Duration (10 minutes) -- **mcp_server**: Configuration for the MCP server required for this task ## Custom Selectors diff --git a/pkg/codingcontext/README.md b/pkg/codingcontext/README.md index a72dc46..930474c 100644 --- a/pkg/codingcontext/README.md +++ b/pkg/codingcontext/README.md @@ -136,7 +136,7 @@ Result holds the assembled context from running a task: - `Agent Agent` - The agent used (from task frontmatter or option) **Methods:** -- `MCPServers() []MCPServerConfig` - Returns all MCP server configurations from rules and task as a slice +- `MCPServers() []MCPServerConfig` - Returns all MCP server configurations from rules as a slice #### `Markdown[T]` diff --git a/pkg/codingcontext/result.go b/pkg/codingcontext/result.go index a0d3773..948166d 100644 --- a/pkg/codingcontext/result.go +++ b/pkg/codingcontext/result.go @@ -21,9 +21,9 @@ type Result struct { Agent Agent // The agent used (from task or -a flag) } -// MCPServers returns all MCP server configurations from both rules and the task. -// Each rule and the task can specify one MCP server configuration. -// Returns a slice of all configured MCP servers. +// MCPServers returns all MCP server configurations from rules. +// Each rule can specify one MCP server configuration. +// Returns a slice of all configured MCP servers from rules only. func (r *Result) MCPServers() []MCPServerConfig { var servers []MCPServerConfig @@ -32,8 +32,5 @@ func (r *Result) MCPServers() []MCPServerConfig { servers = append(servers, rule.FrontMatter.MCPServer) } - // Add server from task - servers = append(servers, r.Task.FrontMatter.MCPServer) - return servers } diff --git a/pkg/codingcontext/result_test.go b/pkg/codingcontext/result_test.go index 1a7c816..be80cd0 100644 --- a/pkg/codingcontext/result_test.go +++ b/pkg/codingcontext/result_test.go @@ -18,23 +18,7 @@ func TestResult_MCPServers(t *testing.T) { FrontMatter: TaskFrontMatter{}, }, }, - want: []MCPServerConfig{ - {}, // Empty task MCP server - }, - }, - { - name: "MCP server from task only", - result: Result{ - Rules: []Markdown[RuleFrontMatter]{}, - Task: Markdown[TaskFrontMatter]{ - FrontMatter: TaskFrontMatter{ - MCPServer: MCPServerConfig{Type: TransportTypeStdio, Command: "filesystem"}, - }, - }, - }, - want: []MCPServerConfig{ - {Type: TransportTypeStdio, Command: "filesystem"}, - }, + want: []MCPServerConfig{}, }, { name: "MCP servers from rules only", @@ -58,28 +42,6 @@ func TestResult_MCPServers(t *testing.T) { want: []MCPServerConfig{ {Type: TransportTypeStdio, Command: "jira"}, {Type: TransportTypeHTTP, URL: "https://api.example.com"}, - {}, // Empty task MCP server - }, - }, - { - name: "MCP servers from both task and rules", - result: Result{ - Rules: []Markdown[RuleFrontMatter]{ - { - FrontMatter: RuleFrontMatter{ - MCPServer: MCPServerConfig{Type: TransportTypeStdio, Command: "jira"}, - }, - }, - }, - Task: Markdown[TaskFrontMatter]{ - FrontMatter: TaskFrontMatter{ - MCPServer: MCPServerConfig{Type: TransportTypeStdio, Command: "filesystem"}, - }, - }, - }, - want: []MCPServerConfig{ - {Type: TransportTypeStdio, Command: "jira"}, - {Type: TransportTypeStdio, Command: "filesystem"}, }, }, { @@ -101,16 +63,13 @@ func TestResult_MCPServers(t *testing.T) { }, }, Task: Markdown[TaskFrontMatter]{ - FrontMatter: TaskFrontMatter{ - MCPServer: MCPServerConfig{Type: TransportTypeStdio, Command: "task-server"}, - }, + FrontMatter: TaskFrontMatter{}, }, }, want: []MCPServerConfig{ {Type: TransportTypeStdio, Command: "server1"}, {Type: TransportTypeStdio, Command: "server2"}, {}, // Empty rule MCP server - {Type: TransportTypeStdio, Command: "task-server"}, }, }, { @@ -122,14 +81,11 @@ func TestResult_MCPServers(t *testing.T) { }, }, Task: Markdown[TaskFrontMatter]{ - FrontMatter: TaskFrontMatter{ - MCPServer: MCPServerConfig{Type: TransportTypeStdio, Command: "filesystem"}, - }, + FrontMatter: TaskFrontMatter{}, }, }, want: []MCPServerConfig{ {}, // Empty rule MCP server - {Type: TransportTypeStdio, Command: "filesystem"}, }, }, } diff --git a/pkg/codingcontext/task_frontmatter.go b/pkg/codingcontext/task_frontmatter.go index 16f785d..5934842 100644 --- a/pkg/codingcontext/task_frontmatter.go +++ b/pkg/codingcontext/task_frontmatter.go @@ -28,10 +28,6 @@ type TaskFrontMatter struct { // Does not filter rules, metadata only Timeout string `yaml:"timeout,omitempty" json:"timeout,omitempty"` - // MCPServer specifies a single MCP server configuration - // Does not filter rules, metadata only - MCPServer MCPServerConfig `yaml:"mcp_server,omitempty" json:"mcp_server,omitempty"` - // Resume indicates if this task should be resumed Resume bool `yaml:"resume,omitempty" json:"resume,omitempty"` diff --git a/pkg/codingcontext/task_frontmatter_test.go b/pkg/codingcontext/task_frontmatter_test.go index 5244cd9..3be535b 100644 --- a/pkg/codingcontext/task_frontmatter_test.go +++ b/pkg/codingcontext/task_frontmatter_test.go @@ -32,12 +32,7 @@ func TestTaskFrontMatter_Marshal(t *testing.T) { Model: "gpt-4", SingleShot: true, Timeout: "10m", - MCPServer: MCPServerConfig{ - Type: TransportTypeStdio, - Command: "filesystem-server", - Args: []string{"--verbose"}, - }, - Resume: false, + Resume: false, Selectors: map[string]any{ "stage": "implementation", }, @@ -49,11 +44,6 @@ languages: model: gpt-4 single_shot: true timeout: 10m -mcp_server: - type: stdio - command: filesystem-server - args: - - --verbose selectors: stage: implementation `, @@ -140,11 +130,6 @@ languages: model: gpt-4 single_shot: true timeout: 10m -mcp_server: - type: stdio - command: filesystem-server - args: - - --verbose selectors: stage: implementation `, @@ -157,11 +142,6 @@ selectors: Model: "gpt-4", SingleShot: true, Timeout: "10m", - MCPServer: MCPServerConfig{ - Type: TransportTypeStdio, - Command: "filesystem-server", - Args: []string{"--verbose"}, - }, Selectors: map[string]any{ "stage": "implementation", }, From 2c79dc55e8497eff8efb1942b5e6ef2f14164528 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 22 Dec 2025 20:12:49 +0000 Subject: [PATCH 07/10] Merge main branch and resolve conflicts Fetched from main and merged to resolve conflicts. All conflicts resolved: - Removed old task_frontmatter.go and rule_frontmatter.go files - Applied changes to new pkg/codingcontext/markdown/frontmatter.go structure - Removed MCPServer field from TaskFrontMatter - Changed RuleFrontMatter.MCPServers to MCPServer (single config) - Updated Result.MCPServers() to return []mcp.MCPServerConfig from rules only - Updated all tests to match new structure - All tests pass, linter passes Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> --- README.md | 108 ++- docs/how-to/create-tasks.md | 4 +- docs/how-to/github-actions.md | 2 +- docs/index.md | 2 +- docs/reference/cli.md | 119 +-- docs/reference/file-formats.md | 18 +- docs/reference/search-paths.md | 4 +- go.mod | 8 +- go.sum | 14 +- main.go | 10 +- pkg/codingcontext/README.md | 48 +- pkg/codingcontext/command_frontmatter.go | 38 - pkg/codingcontext/context.go | 138 +-- pkg/codingcontext/context_test.go | 93 ++- pkg/codingcontext/free_text_task.go | 7 - pkg/codingcontext/frontmatter.go | 6 - pkg/codingcontext/markdown/frontmatter.go | 151 ++++ .../frontmatter_rule_test.go} | 7 +- .../frontmatter_task_test.go} | 2 +- pkg/codingcontext/{ => markdown}/markdown.go | 18 +- .../{ => markdown}/markdown_test.go | 6 +- .../{mcp_server_config.go => mcp/mcp.go} | 20 +- .../mcp_test.go} | 2 +- pkg/codingcontext/options.go | 67 ++ pkg/codingcontext/params.go | 109 --- pkg/codingcontext/result.go | 28 +- pkg/codingcontext/result_test.go | 65 +- pkg/codingcontext/rule_frontmatter.go | 54 -- .../{ => selectors}/selectors.go | 6 +- .../selectors_test.go} | 44 +- pkg/codingcontext/task_frontmatter.go | 62 -- pkg/codingcontext/task_parser.go | 188 ----- .../{ => taskparser}/expander.go | 23 +- .../{ => taskparser}/expander_test.go | 83 +- pkg/codingcontext/taskparser/grammar.go | 128 +++ .../{ => taskparser}/param_map_test.go | 136 ++- pkg/codingcontext/taskparser/params.go | 479 +++++++++++ pkg/codingcontext/taskparser/params_test.go | 789 ++++++++++++++++++ pkg/codingcontext/taskparser/taskparser.go | 214 +++++ .../taskparser_test.go} | 265 +++++- .../tokencount.go} | 6 +- .../tokencount_test.go} | 10 +- pkg/codingcontext/transport_type.go | 17 - 43 files changed, 2600 insertions(+), 998 deletions(-) delete mode 100644 pkg/codingcontext/command_frontmatter.go delete mode 100644 pkg/codingcontext/free_text_task.go delete mode 100644 pkg/codingcontext/frontmatter.go create mode 100644 pkg/codingcontext/markdown/frontmatter.go rename pkg/codingcontext/{rule_frontmatter_test.go => markdown/frontmatter_rule_test.go} (94%) rename pkg/codingcontext/{task_frontmatter_test.go => markdown/frontmatter_task_test.go} (99%) rename pkg/codingcontext/{ => markdown}/markdown.go (75%) rename pkg/codingcontext/{ => markdown}/markdown_test.go (97%) rename pkg/codingcontext/{mcp_server_config.go => mcp/mcp.go} (77%) rename pkg/codingcontext/{mcp_server_config_test.go => mcp/mcp_test.go} (99%) create mode 100644 pkg/codingcontext/options.go delete mode 100644 pkg/codingcontext/params.go delete mode 100644 pkg/codingcontext/rule_frontmatter.go rename pkg/codingcontext/{ => selectors}/selectors.go (94%) rename pkg/codingcontext/{selector_map_test.go => selectors/selectors_test.go} (75%) delete mode 100644 pkg/codingcontext/task_frontmatter.go delete mode 100644 pkg/codingcontext/task_parser.go rename pkg/codingcontext/{ => taskparser}/expander.go (84%) rename pkg/codingcontext/{ => taskparser}/expander_test.go (81%) create mode 100644 pkg/codingcontext/taskparser/grammar.go rename pkg/codingcontext/{ => taskparser}/param_map_test.go (50%) create mode 100644 pkg/codingcontext/taskparser/params.go create mode 100644 pkg/codingcontext/taskparser/params_test.go create mode 100644 pkg/codingcontext/taskparser/taskparser.go rename pkg/codingcontext/{task_parser_test.go => taskparser/taskparser_test.go} (54%) rename pkg/codingcontext/{token_counter.go => tokencount/tokencount.go} (70%) rename pkg/codingcontext/{token_counter_test.go => tokencount/tokencount_test.go} (87%) delete mode 100644 pkg/codingcontext/transport_type.go diff --git a/README.md b/README.md index 1bf342c..cfac78e 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,17 @@ sudo chmod +x /usr/local/bin/coding-context ``` Usage: - coding-context [options] + coding-context [options] [user-prompt] + +Arguments: + + The name of a task file to look up in task search paths (.agents/tasks). + Task files are matched by filename (without .md extension). + + [user-prompt] (optional) + Optional text to append to the task. It can contain slash commands + (e.g., '/command-name') which will be expanded, and parameter + substitution (${param}). Options: -C string @@ -96,7 +106,7 @@ Options: Include rules with matching frontmatter. Can be specified multiple times as key=value. Note: Only matches top-level YAML fields in frontmatter. -a string - Default agent to use if task doesn't specify one. Excludes that agent's own rule paths (since the agent reads those itself). Supported agents: cursor, opencode, copilot, claude, gemini, augment, windsurf, codex. + Target agent to use (excludes rules from that agent's own paths). Supported agents: cursor, opencode, copilot, claude, gemini, augment, windsurf, codex. -w Write rules to agent's config file and output only task to stdout. Requires agent (via task or -a flag). ``` @@ -179,14 +189,15 @@ Each of these would have a corresponding `.md` file (e.g., `triage-bug.md`, `fix The tool assembles the context in the following order: -1. **Rule Files**: It searches a list of predefined locations for rule files (`.md` or `.mdc`). These locations include the current directory, ancestor directories, user's home directory, and system-wide directories. +1. **Rule Files**: It searches for rule files (`.md` or `.mdc`) in directories specified via `-d` flags and automatically-added working directory and home directory. 2. **Rule Bootstrap Scripts**: For each rule file found (e.g., `my-rule.md`), it looks for an executable script named `my-rule-bootstrap`. If found, it runs the script before processing the rule file. These scripts are meant for bootstrapping the environment (e.g., installing tools) and their output is sent to `stderr`, not into the main context. 3. **Filtering**: If `-s` (include) flag is used, it parses the YAML frontmatter of each rule file to decide whether to include it. Note that selectors can only match top-level YAML fields (e.g., `language: go`), not nested fields. 4. **Task Prompt**: It searches for a task file matching the filename (without `.md` extension). Tasks are matched by filename, not by `task_name` in frontmatter. If selectors are provided with `-s`, they are used to filter between multiple task files with the same filename. 5. **Task Bootstrap Script**: For the task file found (e.g., `fix-bug.md`), it looks for an executable script named `fix-bug-bootstrap`. If found, it runs the script before processing the task file. This allows task-specific environment setup or data preparation. -6. **Parameter Expansion**: It substitutes variables in the task prompt using the `-p` flags. -7. **Output**: It prints the content of all included rule files, followed by the expanded task prompt, to standard output. -8. **Token Count**: A running total of estimated tokens is printed to standard error. +6. **User Prompt Appending**: If a user-prompt argument is provided, it is appended to the task content after a delimiter (`---`). +7. **Parameter Expansion**: It substitutes variables in the task prompt and user-prompt using the `-p` flags. +8. **Output**: It prints the content of all included rule files, followed by the expanded task prompt, to standard output. +9. **Token Count**: A running total of estimated tokens is printed to standard error. ### File Search Paths @@ -194,7 +205,6 @@ The tool looks for task and rule files in the following locations, in order of p **Tasks:** - `./.agents/tasks/*.md` (task name matches filename without `.md` extension) -- `~/.agents/tasks/*.md` **Commands** (reusable content blocks referenced via slash commands like `/command-name` inside task content): - `./.agents/commands/*.md` @@ -204,9 +214,9 @@ The tool looks for task and rule files in the following locations, in order of p **Rules:** The tool searches for a variety of files and directories, including: - `CLAUDE.local.md` -- `.agents/rules`, `.cursor/rules`, `.augment/rules`, `.windsurf/rules`, `.opencode/agent`, `.opencode/rules` -- `.github/copilot-instructions.md`, `.github/agents`, `.gemini/styleguide.md` -- `AGENTS.md`, `CLAUDE.md`, `GEMINI.md`, `.codex/AGENTS.md` +- `.agents/rules`, `.cursor/rules`, `.augment/rules`, `.windsurf/rules`, `.opencode/agent` +- `.github/copilot-instructions.md`, `.github/agents`, `.gemini/styleguide.md`, `.augment/guidelines.md` +- `AGENTS.md`, `CLAUDE.md`, `GEMINI.md`, `.cursorrules`, `.windsurfrules` - User-specific rules in `~/.agents/rules`, `~/.claude/CLAUDE.md`, `~/.codex/AGENTS.md`, `~/.gemini/GEMINI.md`, `~/.opencode/rules`, etc. ### Remote File System Support @@ -300,10 +310,10 @@ Deploy the application to production with all safety checks. You can then select the appropriate task using: ```bash # Deploy to staging -coding-context -s environment=staging /deploy +coding-context -s environment=staging deploy # Deploy to production -coding-context -s environment=production /deploy +coding-context -s environment=production deploy ``` #### Task Frontmatter Selectors @@ -328,11 +338,6 @@ When you run this task, it automatically applies the selectors: coding-context implement-feature ``` -This is equivalent to: -```bash -coding-context -s languages=go -s stage=implementation /implement-feature -``` - **Selectors support OR logic for the same key using arrays:** ```markdown --- @@ -394,13 +399,12 @@ This is particularly useful in agentic workflows where an AI agent has already b - Skipping all rules output **Example usage:** - ```bash # Initial task invocation (includes all rules, uses task with resume: false) -coding-context -s resume=false /fix-bug | ai-agent +coding-context -s resume=false fix-bug | ai-agent # Resume the task (skips rules, uses task with resume: true) -coding-context -r /fix-bug | ai-agent +coding-context -r fix-bug | ai-agent ``` **Example task files for resume mode:** @@ -449,7 +453,7 @@ languages: To include this rule only when working on Go code, you would use `-s languages=go`: ```bash -coding-context -s languages=go /fix-bug +coding-context -s languages=go fix-bug ``` This will include all rules with `languages: [ go ]` in their frontmatter, excluding rules for other languages. @@ -468,10 +472,10 @@ Then select only the relevant rules: ```bash # Work on Python code with Python-specific rules -coding-context -s languages=python /fix-bug +coding-context -s languages=python fix-bug # Work on JavaScript code with JavaScript-specific rules -coding-context -s languages=javascript /enhance-feature +coding-context -s languages=javascript enhance-feature ``` **Language Values** @@ -503,42 +507,34 @@ If you need to filter on nested data, flatten your frontmatter structure to use ### Targeting a Specific Agent -When working with a specific AI coding agent, the agent itself will read its own configuration files. The `-a` flag lets you specify which agent you're using, automatically excluding that agent's specific rule paths while including rules from other agents and generic rules. +The `-a` flag specifies which AI coding agent you're using. This information is currently used for: + +1. **Write Rules Mode**: With the `-w` flag, determines where to write rules (e.g., `~/.github/agents/AGENTS.md` for `copilot`) + +> **Note:** Agent-based rule filtering is not currently implemented. All rules are included regardless of the `-a` value. **Supported agents:** -- `cursor` - Excludes `.cursor/rules`, `.cursorrules`; includes other agents and generic rules -- `opencode` - Excludes `.opencode/agent`, `.opencode/command`; includes other agents and generic rules -- `copilot` - Excludes `.github/copilot-instructions.md`, `.github/agents`; includes other agents and generic rules -- `claude` - Excludes `.claude/`, `CLAUDE.md`, `CLAUDE.local.md`; includes other agents and generic rules -- `gemini` - Excludes `.gemini/`, `GEMINI.md`; includes other agents and generic rules -- `augment` - Excludes `.augment/`; includes other agents and generic rules -- `windsurf` - Excludes `.windsurf/`, `.windsurfrules`; includes other agents and generic rules -- `codex` - Excludes `.codex/`, `AGENTS.md`; includes other agents and generic rules +- `cursor` - Cursor IDE +- `opencode` - OpenCode.ai +- `copilot` - GitHub Copilot +- `claude` - Anthropic Claude +- `gemini` - Google Gemini +- `augment` - Augment +- `windsurf` - Windsurf +- `codex` - Codex -**Example: Using Cursor:** +**Example:** ```bash -# When using Cursor, exclude .cursor/ and .cursorrules (Cursor reads those itself) -# But include rules from other agents and generic rules -coding-context -a cursor /fix-bug +# Use with write rules mode +coding-context -a copilot -w fix-bug ``` **How it works:** -- The `-a` flag sets the target agent -- The target agent's own paths are excluded (e.g., `.cursor/` for cursor) -- Rules from other agents are included (e.g., `.opencode/`, `.github/copilot-instructions.md`) -- Generic rules (from `.agents/rules`) are always included -- The agent name is automatically added as a selector, so generic rules can filter themselves with `agent: cursor` in frontmatter - -**Example generic rule with agent filtering:** - -```markdown ---- -agent: cursor ---- -# This rule only applies when using Cursor -Use Cursor-specific features... -``` +- The `-a` flag sets the target agent value +- The agent value is stored in the context for use by the `-w` flag +- With `-w`, the agent determines where to write rules to the user's home directory +- All rules are currently included regardless of agent value **Agent field in task frontmatter:** @@ -551,15 +547,7 @@ agent: cursor # This task automatically sets the agent to cursor ``` -This is useful for tasks designed for specific agents, ensuring the correct agent context is used regardless of command-line flags. - -**Use cases:** -- **Avoid duplication**: The agent reads its own config, so exclude it from the context -- **Cross-agent rules**: Include rules from other agents that might be relevant -- **Generic rules**: Always include generic rules, with optional agent-specific filtering -- **Task-specific agents**: Tasks can enforce a specific agent context - -The exclusion happens before rule processing, so excluded paths are never loaded or counted toward token estimates. +This is useful for tasks designed for specific agents, ensuring the correct agent is set for determining the write path with `-w`. ### Bootstrap Scripts diff --git a/docs/how-to/create-tasks.md b/docs/how-to/create-tasks.md index de12dbd..b88e8d5 100644 --- a/docs/how-to/create-tasks.md +++ b/docs/how-to/create-tasks.md @@ -22,7 +22,7 @@ Please review the code changes with focus on: - Security implications ``` -Save as `.agents/tasks/code-review.md` (or `.agents/commands/code-review.md`). +Save as `.agents/tasks/code-review.md`. Use with: ```bash @@ -51,7 +51,7 @@ coding-context \ -p feature_name="User Authentication" \ -p requirements="OAuth2 support, secure password storage" \ -p success_criteria="All tests pass, security audit clean" \ - /implement-feature + implement-feature ``` ## Multiple Tasks with Selectors diff --git a/docs/how-to/github-actions.md b/docs/how-to/github-actions.md index 901658a..0aa8fc8 100644 --- a/docs/how-to/github-actions.md +++ b/docs/how-to/github-actions.md @@ -209,7 +209,7 @@ Use the `-C` flag to run from a different directory: ```yaml - name: Assemble Context run: | - coding-context -C ./backend -s languages=go /fix-bug > context.txt + coding-context -C ./backend -s languages=go fix-bug > context.txt ``` ## Best Practices diff --git a/docs/index.md b/docs/index.md index 2fcf929..f6bc387 100644 --- a/docs/index.md +++ b/docs/index.md @@ -19,7 +19,7 @@ sudo curl -fsL -o /usr/local/bin/coding-context \ sudo chmod +x /usr/local/bin/coding-context # Use with an AI agent -coding-context -p issue_key=BUG-123 -s languages=go /fix-bug | llm -m claude-3-5-sonnet-20241022 +coding-context -p issue_key=BUG-123 -s languages=go fix-bug | llm -m claude-3-5-sonnet-20241022 ``` ## Documentation Structure diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 2bf64db..9cf9398 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -12,7 +12,7 @@ Complete reference for the `coding-context` command-line interface. ## Synopsis ``` -coding-context [options] +coding-context [options] [user-prompt] ``` ## Description @@ -27,13 +27,27 @@ The Coding Context CLI assembles context from rule files and task prompts, perfo Task files can contain slash commands (e.g., `/command-name arg`) which reference command files for modular content reuse. +### `[user-prompt]` (optional) + +**Optional.** Additional text to append to the task content. This text is appended after a delimiter (`---`) and can contain: +- Slash commands (e.g., `/command-name arg`) which will be expanded +- Parameter substitution placeholders (e.g., `${param}`) + +The user-prompt is processed the same way as task file content, allowing you to dynamically extend the task at runtime. + **Examples:** ```bash -# Task name (looks up fix-bug.md task file) +# Task name only (looks up fix-bug.md task file) coding-context fix-bug -# With parameters -coding-context -p issue_key=BUG-123 fix-bug +# Task with user-prompt +coding-context fix-bug "Focus on the authentication module" + +# User-prompt with parameters +coding-context -p issue_key=BUG-123 fix-bug "Check the error logs in /var/log" + +# User-prompt with slash commands +coding-context fix-bug "/pre-checks and then analyze the code" # With selectors coding-context -s languages=go fix-bug @@ -135,6 +149,26 @@ file:///path/to/local/rules Define a parameter for substitution in task prompts. Variables in task files using `${key}` syntax will be replaced with the specified value. +**Parameter Parsing Features:** + +The `-p` flag supports flexible parameter parsing with the following features: + +- **Basic key-value pairs**: `key=value` +- **Multiple values per key**: Duplicate keys are collected into a list (e.g., `-p tag=frontend -p tag=backend` results in `tag` having both values) +- **Quoted values**: Use single (`'`) or double (`"`) quotes for values containing spaces or special characters + - `-p description="Application crashes on startup"` + - `-p name='John Doe'` +- **Escape sequences**: Supported in both quoted and unquoted values + - Standard: `\n` (newline), `\t` (tab), `\r` (carriage return), `\\` (backslash) + - Quotes: `\"` (double quote), `\'` (single quote) + - Unicode: `\uXXXX` where XXXX are four hexadecimal digits + - Hex: `\xHH` where HH are two hexadecimal digits + - Octal: `\OOO` where OOO are up to three octal digits +- **Case-insensitive keys**: Keys are automatically converted to lowercase +- **UTF-8 support**: Full Unicode support in keys and values +- **Flexible separators**: Multiple `-p` flags can be used, or a single flag can contain comma or whitespace-separated pairs +- **Empty values**: Unquoted empty values (`key=`) result in empty parameter, quoted empty values (`key=""`) result in empty string + **Examples:** ```bash # Single parameter @@ -153,23 +187,27 @@ coding-context \ **Type:** String **Default:** (empty) -Specify the target agent being used. When set, this excludes that agent's own rule paths (since the agent reads those itself) while including rules from other agents and generic rules. +Specify the target agent being used. This is currently used for: +1. **Write Rules Mode**: With `-w` flag, determines where to write rules (e.g., `~/.github/agents/AGENTS.md` for copilot) + +> **Note:** Agent-based rule filtering is not currently implemented. All rules are included regardless of the `-a` value. **Supported agents:** `cursor`, `opencode`, `copilot`, `claude`, `gemini`, `augment`, `windsurf`, `codex` **How it works:** -- When `-a cursor` is specified, paths like `.cursor/rules` and `.cursorrules` are excluded -- Rules from other agents (e.g., `.opencode/agent`, `.github/copilot-instructions.md`) are included -- Generic rules from `.agents/rules` are always included -- The agent name is automatically added as a selector for rule filtering +- The agent value is stored in the context (can come from `-a` flag or task frontmatter) +- With `-w` flag, the agent determines the user rules path for writing +- All rules are currently included regardless of agent value + +**Agent Precedence:** +- If a task specifies an `agent` field in its frontmatter, that takes precedence over the `-a` flag +- The `-a` flag is used when the task doesn't specify an agent +- Either the task's agent field or `-a` flag can be used to set the agent **Example:** ```bash -# Using Cursor - excludes .cursor/ paths, includes others -coding-context -a cursor fix-bug - -# Using GitHub Copilot - excludes .github/copilot-instructions.md, includes others -coding-context -a copilot implement-feature +# Use with write rules mode +coding-context -a copilot -w implement-feature ``` **Note:** Task files can override this with an `agent` field in their frontmatter. @@ -220,40 +258,6 @@ coding-context -s languages=go -s priority=high fix-bug coding-context -s environment=production deploy ``` -**Note:** When filtering by language, use `-s languages=go` (plural). The selector key is `languages` (plural), matching the frontmatter field name. - -### `-a ` - -**Type:** String (agent name) -**Default:** (empty) - -Specify the default agent to use. This acts as a fallback if the task doesn't specify an agent in its frontmatter. - -**Supported agents:** -- `cursor` - [Cursor](https://cursor.sh/) -- `opencode` - [OpenCode.ai](https://opencode.ai/) -- `copilot` - [GitHub Copilot](https://github.com/features/copilot) -- `claude` - [Anthropic Claude](https://claude.ai/) -- `gemini` - [Google Gemini](https://gemini.google.com/) -- `augment` - [Augment](https://augmentcode.com/) -- `windsurf` - [Windsurf](https://codeium.com/windsurf) -- `codex` - [Codex](https://codex.ai/) - -**Agent Precedence:** -- If the task specifies an `agent` field in its frontmatter, that agent **overrides** the `-a` flag -- The `-a` flag serves as a **default** agent when the task doesn't specify one -- This allows tasks to specify their preferred agent while supporting a command-line default - -**Examples:** -```bash -# Use copilot as the default agent -coding-context -a copilot fix-bug - -# Task with agent field will override -a flag -# If fix-bug.md has "agent: claude", it will use claude instead of copilot -coding-context -a copilot fix-bug -``` - ### `-w` **Type:** Boolean flag @@ -352,15 +356,18 @@ coding-context fix-bug # Bootstrap scripts can use these variables ### Basic Usage ```bash -# Free-text prompt (used directly as task content) -coding-context "Please help me review this code for security issues" - # Task name lookup coding-context code-review +# Task with user-prompt +coding-context code-review "Focus on security vulnerabilities" + # With parameters coding-context -p pr_number=123 code-review +# User-prompt with parameters +coding-context -p issue=BUG-456 fix-bug "Check the database connection logic" + # With selectors coding-context -s languages=python fix-bug @@ -370,6 +377,9 @@ coding-context \ -s stage=implementation \ -p feature_name="Authentication" \ implement-feature + +# User-prompt with slash commands +coding-context implement-feature "/pre-checks and validate the requirements" ``` ### Working Directory @@ -420,6 +430,9 @@ coding-context -r implement-feature | ai-agent # Claude coding-context fix-bug | claude +# With user-prompt +coding-context fix-bug "Focus on edge cases" | claude + # LLM tool coding-context fix-bug | llm -m claude-3-5-sonnet-20241022 @@ -430,8 +443,8 @@ coding-context code-review | openai api completions.create -m gpt-4 coding-context fix-bug > context.txt cat context.txt | your-ai-agent -# Free-text prompt -coding-context "Please help me debug the auth module" | claude +# User-prompt with parameters +coding-context -p issue=123 fix-bug "Check logs in /var/log" | claude ``` ### Token Monitoring diff --git a/docs/reference/file-formats.md b/docs/reference/file-formats.md index 2828f62..ac11736 100644 --- a/docs/reference/file-formats.md +++ b/docs/reference/file-formats.md @@ -413,13 +413,10 @@ This prevents command injection where expanded content could trigger further, un ### File Location -Task files must be in one of these directories: -- `./.agents/tasks/` -- `./.cursor/commands/` -- `./.opencode/command/` -- `~/.agents/tasks/` +Task files must be in these directories within any search path directory: +- `.agents/tasks/` -Tasks are matched by filename (without `.md` extension). The `task_name` field in frontmatter is optional and used only for metadata. For example, a file named `fix-bug.md` is matched by the command `/fix-bug`, regardless of whether it has `task_name` in its frontmatter. +Tasks are matched by filename (without `.md` extension). The `task_name` field in frontmatter is optional and used only for metadata. For example, a file named `fix-bug.md` is matched by the task name `fix-bug`, regardless of whether it has `task_name` in its frontmatter. ## Command Files @@ -494,11 +491,10 @@ Commands can also receive inline parameters: ### File Locations -Command files must be in one of these directories: -- `./.agents/commands/` -- `./.cursor/commands/` -- `./.opencode/command/` -- `~/.agents/commands/` +Command files must be in these directories within any search path directory: +- `.agents/commands/` +- `.cursor/commands/` +- `.opencode/command/` Commands are matched by filename (without `.md` extension). For example, a file named `deploy.md` is matched by the slash command `/deploy`. diff --git a/docs/reference/search-paths.md b/docs/reference/search-paths.md index b354675..0ffd7c6 100644 --- a/docs/reference/search-paths.md +++ b/docs/reference/search-paths.md @@ -81,10 +81,7 @@ Rule files are discovered from directories specified via the `-d` flag (plus aut .augment/rules/ .windsurf/rules/ .opencode/agent/ -.opencode/command/ -.opencode/rules/ .github/agents/ -.codex/ ``` **Specific files:** @@ -92,6 +89,7 @@ Rule files are discovered from directories specified via the `-d` flag (plus aut CLAUDE.local.md .github/copilot-instructions.md .gemini/styleguide.md +.augment/guidelines.md ``` **Standard files:** diff --git a/go.mod b/go.mod index 8ddfced..506f68f 100644 --- a/go.mod +++ b/go.mod @@ -1,22 +1,26 @@ module github.com/kitproj/coding-context-cli -go 1.24.4 +go 1.24.5 require ( github.com/alecthomas/participle/v2 v2.1.4 github.com/goccy/go-yaml v1.18.0 github.com/hashicorp/go-getter/v2 v2.2.3 + github.com/stretchr/testify v1.10.0 ) require ( github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/hashicorp/errwrap v1.0.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.0 // indirect github.com/hashicorp/go-multierror v1.1.0 // indirect github.com/hashicorp/go-safetemp v1.0.0 // indirect github.com/hashicorp/go-version v1.1.0 // indirect - github.com/klauspost/compress v1.11.2 // indirect + github.com/klauspost/compress v1.15.0 // indirect github.com/mitchellh/go-homedir v1.0.0 // indirect github.com/mitchellh/go-testing-interface v1.0.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/ulikunitz/xz v0.5.8 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index ec179d7..40bfc34 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,8 @@ github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d h1:xDfNPAt8lFiC1UJrqV3uuy861HCTo708pDMbjHHdCas= github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d/go.mod h1:6QX/PXZ00z/TKoufEY6K/a0k6AhaJrQKdFe6OfVXsa4= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/google/go-cmp v0.3.0 h1:crn/baboCvb5fXaQ0IJ1SGTsTVrWpDsCWC8EGETZijY= @@ -24,11 +26,19 @@ github.com/hashicorp/go-version v1.1.0 h1:bPIoEKD27tNdebFGGxxYwcL4nepeY4j1QP23PF github.com/hashicorp/go-version v1.1.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= -github.com/klauspost/compress v1.11.2 h1:MiK62aErc3gIiVEtyzKfeOHgW7atJb5g/KNX5m3c2nQ= -github.com/klauspost/compress v1.11.2/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= +github.com/klauspost/compress v1.15.0 h1:xqfchp4whNFxn5A4XFyyYtitiWI8Hy5EW59jEwcyL6U= +github.com/klauspost/compress v1.15.0/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= github.com/mitchellh/go-homedir v1.0.0 h1:vKb8ShqSby24Yrqr/yDYkuFz8d0WUjys40rvnGC8aR0= github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-testing-interface v1.0.0 h1:fzU/JVNcaqHQEcVFAKeR41fkiLdIPrefOvVG1VZ96U0= github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/ulikunitz/xz v0.5.8 h1:ERv8V6GKqVi23rgu5cj9pVfVzJbOqAY2Ntl88O6c2nQ= github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go index 1144bbb..58e4279 100644 --- a/main.go +++ b/main.go @@ -13,6 +13,8 @@ import ( yaml "github.com/goccy/go-yaml" "github.com/kitproj/coding-context-cli/pkg/codingcontext" + "github.com/kitproj/coding-context-cli/pkg/codingcontext/selectors" + "github.com/kitproj/coding-context-cli/pkg/codingcontext/taskparser" ) func main() { @@ -25,15 +27,15 @@ func main() { var resume bool var writeRules bool var agent codingcontext.Agent - params := make(codingcontext.Params) - includes := make(codingcontext.Selectors) + params := make(taskparser.Params) + includes := make(selectors.Selectors) var searchPaths []string var manifestURL 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(&writeRules, "w", false, "Write rules to the agent's user rules path and only print the prompt to stdout. Requires -a flag.") - flag.Var(&agent, "a", "Target agent to use (excludes rules from other agents). Supported agents: cursor, opencode, copilot, claude, gemini, augment, windsurf, codex.") + flag.BoolVar(&writeRules, "w", false, "Write rules to the agent's user rules path and only print the prompt to stdout. Requires agent (via task 'agent' field or -a flag).") + flag.Var(&agent, "a", "Target agent to use. Required when using -w to write rules to the agent's user rules path. Supported agents: cursor, opencode, copilot, claude, gemini, augment, windsurf, codex.") flag.Var(¶ms, "p", "Parameter to substitute in the prompt. Can be specified multiple times as key=value.") flag.Var(&includes, "s", "Include rules with matching frontmatter. Can be specified multiple times as key=value.") flag.Func("d", "Directory containing rules and tasks. Can be specified multiple times. Supports various protocols via go-getter (http://, https://, git::, s3::, file:// etc.).", func(s string) error { diff --git a/pkg/codingcontext/README.md b/pkg/codingcontext/README.md index 930474c..649b0d1 100644 --- a/pkg/codingcontext/README.md +++ b/pkg/codingcontext/README.md @@ -22,15 +22,16 @@ import ( "os" "github.com/kitproj/coding-context-cli/pkg/codingcontext" + "github.com/kitproj/coding-context-cli/pkg/codingcontext/taskparams" ) func main() { // Create a new context with options ctx := codingcontext.New( codingcontext.WithSearchPaths("file://.", "file://"+os.Getenv("HOME")), - codingcontext.WithParams(codingcontext.Params{ - "issue_number": "123", - "feature": "authentication", + codingcontext.WithParams(taskparams.Params{ + "issue_number": []string{"123"}, + "feature": []string{"authentication"}, }), codingcontext.WithLogger(slog.New(slog.NewTextHandler(os.Stderr, nil))), ) @@ -63,13 +64,15 @@ import ( "os" "github.com/kitproj/coding-context-cli/pkg/codingcontext" + "github.com/kitproj/coding-context-cli/pkg/codingcontext/selectors" + "github.com/kitproj/coding-context-cli/pkg/codingcontext/taskparams" ) func main() { // Create selectors for filtering rules - selectors := make(codingcontext.Selectors) - selectors.SetValue("language", "go") - selectors.SetValue("stage", "implementation") + sel := make(selectors.Selectors) + sel.SetValue("language", "go") + sel.SetValue("stage", "implementation") // Create context with all options ctx := codingcontext.New( @@ -77,10 +80,10 @@ func main() { "file://.", "git::https://github.com/org/repo//path/to/rules", ), - codingcontext.WithParams(codingcontext.Params{ - "issue_number": "123", + codingcontext.WithParams(taskparams.Params{ + "issue_number": []string{"123"}, }), - codingcontext.WithSelectors(selectors), + codingcontext.WithSelectors(sel), codingcontext.WithAgent(codingcontext.AgentCursor), codingcontext.WithResume(false), codingcontext.WithUserPrompt("Additional context or instructions"), @@ -232,11 +235,14 @@ Type representing MCP transport protocol (string type): #### `Params` -Map of parameter key-value pairs for template substitution: `map[string]string` +Map of parameter key-value pairs for template substitution: `map[string][]string` **Methods:** - `String() string` - Returns string representation - `Set(value string) error` - Parses and sets key=value pair (implements flag.Value) +- `Value(key string) string` - Returns the first value for the given key +- `Lookup(key string) (string, bool)` - Returns the first value and whether the key exists +- `Values(key string) []string` - Returns all values for the given key #### `Selectors` @@ -262,7 +268,7 @@ Types for parsing task content with slash commands: - `Argument` - Slash command argument (can be positional or named key=value) **Methods:** -- `(*SlashCommand) Params() map[string]string` - Returns parsed parameters as map +- `(*SlashCommand) Params() taskparams.Params` - Returns parsed parameters as map - `(*Text) Content() string` - Returns text content as string - Various `String()` methods for formatting each type @@ -284,8 +290,8 @@ Creates a new Context with the given options. **Options:** - `WithSearchPaths(paths ...string)` - Add search paths (supports go-getter URLs) -- `WithParams(params Params)` - Set parameters for substitution -- `WithSelectors(selectors Selectors)` - Set selectors for filtering rules +- `WithParams(params taskparams.Params)` - Set parameters for substitution (import `taskparams` package) +- `WithSelectors(selectors selectors.Selectors)` - Set selectors for filtering rules (import `selectors` package) - `WithAgent(agent Agent)` - Set target agent (excludes that agent's own rules) - `WithResume(resume bool)` - Enable resume mode (skips rules) - `WithUserPrompt(userPrompt string)` - Set user prompt to append to task @@ -304,23 +310,25 @@ Parses a markdown file into frontmatter and content. Generic function that works Parses task text content into blocks of text and slash commands. -#### `ParseParams(s string) (Params, error)` +#### `taskparams.Parse(s string) (taskparams.Params, error)` Parses a string containing key=value pairs with quoted values. **Examples:** ```go +import "github.com/kitproj/coding-context-cli/pkg/codingcontext/taskparams" + // Parse quoted key-value pairs -params, _ := ParseParams(`key1="value1" key2="value2"`) -// Result: map[string]string{"key1": "value1", "key2": "value2"} +params, _ := taskparams.Parse(`key1="value1" key2="value2"`) +// Result: taskparams.Params{"key1": []string{"value1"}, "key2": []string{"value2"}} // Parse with spaces in values -params, _ := ParseParams(`key1="value with spaces" key2="value2"`) -// Result: map[string]string{"key1": "value with spaces", "key2": "value2"} +params, _ := taskparams.Parse(`key1="value with spaces" key2="value2"`) +// Result: taskparams.Params{"key1": []string{"value with spaces"}, "key2": []string{"value2"}} // Parse with escaped quotes -params, _ := ParseParams(`key1="value with \"escaped\" quotes"`) -// Result: map[string]string{"key1": "value with \"escaped\" quotes"} +params, _ := taskparams.Parse(`key1="value with \"escaped\" quotes"`) +// Result: taskparams.Params{"key1": []string{"value with \"escaped\" quotes"}} ``` #### `ParseAgent(s string) (Agent, error)` diff --git a/pkg/codingcontext/command_frontmatter.go b/pkg/codingcontext/command_frontmatter.go deleted file mode 100644 index 384a076..0000000 --- a/pkg/codingcontext/command_frontmatter.go +++ /dev/null @@ -1,38 +0,0 @@ -package codingcontext - -import ( - "encoding/json" -) - -// CommandFrontMatter represents the frontmatter fields for command files. -// Previously this was an empty placeholder struct, but now supports the expand field -// to control parameter expansion behavior in command content. -type CommandFrontMatter struct { - BaseFrontMatter `yaml:",inline"` - - // ExpandParams controls whether parameter expansion should occur - // Defaults to true if not specified - ExpandParams *bool `yaml:"expand,omitempty" json:"expand,omitempty"` -} - -// UnmarshalJSON custom unmarshaler that populates both typed fields and Content map -func (c *CommandFrontMatter) UnmarshalJSON(data []byte) error { - // First unmarshal into a temporary type to avoid infinite recursion - type Alias CommandFrontMatter - aux := &struct { - *Alias - }{ - Alias: (*Alias)(c), - } - - if err := json.Unmarshal(data, aux); err != nil { - return err - } - - // Also unmarshal into Content map - if err := json.Unmarshal(data, &c.BaseFrontMatter.Content); err != nil { - return err - } - - return nil -} diff --git a/pkg/codingcontext/context.go b/pkg/codingcontext/context.go index 4b2f05a..9ce9ac1 100644 --- a/pkg/codingcontext/context.go +++ b/pkg/codingcontext/context.go @@ -6,23 +6,28 @@ import ( "crypto/sha256" "fmt" "log/slog" + "maps" "os" "os/exec" "path/filepath" "strings" "github.com/hashicorp/go-getter/v2" + "github.com/kitproj/coding-context-cli/pkg/codingcontext/markdown" + "github.com/kitproj/coding-context-cli/pkg/codingcontext/selectors" + "github.com/kitproj/coding-context-cli/pkg/codingcontext/taskparser" + "github.com/kitproj/coding-context-cli/pkg/codingcontext/tokencount" ) // Context holds the configuration and state for assembling coding context type Context struct { - params Params - includes Selectors + params taskparser.Params + includes selectors.Selectors manifestURL string searchPaths []string downloadedPaths []string - task Markdown[TaskFrontMatter] // Parsed task - rules []Markdown[RuleFrontMatter] // Collected rule files + task markdown.Markdown[markdown.TaskFrontMatter] // Parsed task + rules []markdown.Markdown[markdown.RuleFrontMatter] // Collected rule files totalTokens int logger *slog.Logger cmdRunner func(cmd *exec.Cmd) error @@ -31,71 +36,12 @@ type Context struct { userPrompt string // User-provided prompt to append to task } -// Option is a functional option for configuring a Context -type Option func(*Context) - -// WithParams sets the parameters -func WithParams(params Params) Option { - return func(c *Context) { - c.params = params - } -} - -// WithSelectors sets the selectors -func WithSelectors(selectors Selectors) Option { - return func(c *Context) { - c.includes = selectors - } -} - -// WithManifestURL sets the manifest URL -func WithManifestURL(manifestURL string) Option { - return func(c *Context) { - c.manifestURL = manifestURL - } -} - -// WithSearchPaths adds one or more search paths -func WithSearchPaths(paths ...string) Option { - return func(c *Context) { - c.searchPaths = append(c.searchPaths, paths...) - } -} - -// WithLogger sets the logger -func WithLogger(logger *slog.Logger) Option { - return func(c *Context) { - c.logger = logger - } -} - -// WithResume enables resume mode, which skips rule discovery and bootstrap scripts -func WithResume(resume bool) Option { - return func(c *Context) { - c.resume = resume - } -} - -// WithAgent sets the target agent, which excludes that agent's own rules -func WithAgent(agent Agent) Option { - return func(c *Context) { - c.agent = agent - } -} - -// WithUserPrompt sets the user prompt to append to the task -func WithUserPrompt(userPrompt string) Option { - return func(c *Context) { - c.userPrompt = userPrompt - } -} - // New creates a new Context with the given options func New(opts ...Option) *Context { c := &Context{ - params: make(Params), - includes: make(Selectors), - rules: make([]Markdown[RuleFrontMatter], 0), + params: make(taskparser.Params), + includes: make(selectors.Selectors), + rules: make([]markdown.Markdown[markdown.RuleFrontMatter], 0), logger: slog.New(slog.NewTextHandler(os.Stderr, nil)), cmdRunner: func(cmd *exec.Cmd) error { return cmd.Run() @@ -112,7 +58,6 @@ type markdownVisitor func(path string) error // findMarkdownFile searches for a markdown file by name in the given directories. // Returns the path to the file if found, or an error if not found or multiple files match. func (cc *Context) visitMarkdownFiles(searchDirFn func(path string) []string, visitor markdownVisitor) error { - var searchDirs []string for _, path := range cc.downloadedPaths { searchDirs = append(searchDirs, searchDirFn(path)...) @@ -136,8 +81,8 @@ func (cc *Context) visitMarkdownFiles(searchDirFn func(path string) []string, vi // If selectors are provided, check if the file matches // Parse frontmatter to check selectors - var fm BaseFrontMatter - if _, err := ParseMarkdownFile(path, &fm); err != nil { + var fm markdown.BaseFrontMatter + if _, err := markdown.ParseMarkdownFile(path, &fm); err != nil { // Skip files that can't be parsed return nil } @@ -148,7 +93,6 @@ func (cc *Context) visitMarkdownFiles(searchDirFn func(path string) []string, vi } return visitor(path) }) - if err != nil { return err } @@ -159,7 +103,6 @@ func (cc *Context) visitMarkdownFiles(searchDirFn func(path string) []string, vi // findTask searches for a task markdown file and returns it with parameters substituted func (cc *Context) findTask(taskName string) error { - // Add task name to includes so rules can be filtered cc.includes.SetValue("task_name", taskName) @@ -172,8 +115,8 @@ func (cc *Context) findTask(taskName string) error { } taskFound = true - var frontMatter TaskFrontMatter - md, err := ParseMarkdownFile(path, &frontMatter) + var frontMatter markdown.TaskFrontMatter + md, err := markdown.ParseMarkdownFile(path, &frontMatter) if err != nil { return err } @@ -216,7 +159,7 @@ func (cc *Context) findTask(taskName string) error { } // Parse the task content (including user_prompt) to separate text blocks from slash commands - task, err := ParseTask(taskContent) + task, err := taskparser.ParseTask(taskContent) if err != nil { return err } @@ -231,7 +174,10 @@ func (cc *Context) findTask(taskName string) error { textContent := block.Text.Content() // Expand parameters in text blocks only if expand is not explicitly set to false if shouldExpandParams(frontMatter.ExpandParams) { - textContent = cc.expandParams(textContent, nil) + textContent, err = cc.expandParams(textContent, nil) + if err != nil { + return fmt.Errorf("failed to expand parameters: %w", err) + } } finalContent.WriteString(textContent) } else if block.SlashCommand != nil { @@ -243,10 +189,10 @@ func (cc *Context) findTask(taskName string) error { } } - cc.task = Markdown[TaskFrontMatter]{ + cc.task = markdown.Markdown[markdown.TaskFrontMatter]{ FrontMatter: frontMatter, Content: finalContent.String(), - Tokens: estimateTokens(finalContent.String()), + Tokens: tokencount.EstimateTokens(finalContent.String()), } cc.totalTokens += cc.task.Tokens @@ -267,7 +213,7 @@ func (cc *Context) findTask(taskName string) error { // Commands now support optional frontmatter with the expand field. // Parameters are substituted by default (when expand is nil or true). // Substitution is skipped only when expand is explicitly set to false. -func (cc *Context) findCommand(commandName string, params map[string]string) (string, error) { +func (cc *Context) findCommand(commandName string, params taskparser.Params) (string, error) { var content *string err := cc.visitMarkdownFiles(commandSearchPaths, func(path string) error { baseName := filepath.Base(path) @@ -276,8 +222,8 @@ func (cc *Context) findCommand(commandName string, params map[string]string) (st return nil } - var frontMatter CommandFrontMatter - md, err := ParseMarkdownFile(path, &frontMatter) + var frontMatter markdown.CommandFrontMatter + md, err := markdown.ParseMarkdownFile(path, &frontMatter) if err != nil { return err } @@ -285,7 +231,10 @@ func (cc *Context) findCommand(commandName string, params map[string]string) (st // Expand parameters only if expand is not explicitly set to false var processedContent string if shouldExpandParams(frontMatter.ExpandParams) { - processedContent = cc.expandParams(md.Content, params) + processedContent, err = cc.expandParams(md.Content, params) + if err != nil { + return fmt.Errorf("failed to expand parameters: %w", err) + } } else { processedContent = md.Content } @@ -307,18 +256,14 @@ func (cc *Context) findCommand(commandName string, params map[string]string) (st // - Command expansion: !`command` // - Path expansion: @path // If params is provided, it is merged with cc.params (with params taking precedence). -func (cc *Context) expandParams(content string, params map[string]string) string { +func (cc *Context) expandParams(content string, params taskparser.Params) (string, error) { // Merge params with cc.params - mergedParams := make(map[string]string) - for k, v := range cc.params { - mergedParams[k] = v - } - for k, v := range params { - mergedParams[k] = v - } + mergedParams := make(taskparser.Params) + maps.Copy(mergedParams, cc.params) + maps.Copy(mergedParams, params) // Use the expand function to handle all expansion types - return expand(content, mergedParams, cc.logger) + return mergedParams.Expand(content) } // shouldExpandParams returns true if parameter expansion should occur based on the expandParams field. @@ -458,8 +403,8 @@ func (cc *Context) findExecuteRuleFiles(ctx context.Context, homeDir string) err } err := cc.visitMarkdownFiles(func(path string) []string { return rulePaths(path, path == homeDir) }, func(path string) error { - var frontmatter RuleFrontMatter - md, err := ParseMarkdownFile(path, &frontmatter) + var frontmatter markdown.RuleFrontMatter + md, err := markdown.ParseMarkdownFile(path, &frontmatter) if err != nil { return fmt.Errorf("failed to parse markdown file: %w", err) } @@ -467,13 +412,16 @@ func (cc *Context) findExecuteRuleFiles(ctx context.Context, homeDir string) err // Expand parameters only if expand is not explicitly set to false var processedContent string if shouldExpandParams(frontmatter.ExpandParams) { - processedContent = cc.expandParams(md.Content, nil) + processedContent, err = cc.expandParams(md.Content, nil) + if err != nil { + return fmt.Errorf("failed to expand parameters: %w", err) + } } else { processedContent = md.Content } - tokens := estimateTokens(processedContent) + tokens := tokencount.EstimateTokens(processedContent) - cc.rules = append(cc.rules, Markdown[RuleFrontMatter]{ + cc.rules = append(cc.rules, markdown.Markdown[markdown.RuleFrontMatter]{ FrontMatter: frontmatter, Content: processedContent, Tokens: tokens, diff --git a/pkg/codingcontext/context_test.go b/pkg/codingcontext/context_test.go index 09a22c4..a3e07a4 100644 --- a/pkg/codingcontext/context_test.go +++ b/pkg/codingcontext/context_test.go @@ -8,6 +8,9 @@ import ( "path/filepath" "strings" "testing" + + "github.com/kitproj/coding-context-cli/pkg/codingcontext/selectors" + "github.com/kitproj/coding-context-cli/pkg/codingcontext/taskparser" ) // Test helper functions for creating fixtures @@ -16,7 +19,7 @@ import ( func createTask(t *testing.T, dir, name, frontmatter, content string) { t.Helper() taskDir := filepath.Join(dir, ".agents", "tasks") - if err := os.MkdirAll(taskDir, 0755); err != nil { + if err := os.MkdirAll(taskDir, 0o755); err != nil { t.Fatalf("failed to create task directory: %v", err) } @@ -28,7 +31,7 @@ func createTask(t *testing.T, dir, name, frontmatter, content string) { } taskPath := filepath.Join(taskDir, name+".md") - if err := os.WriteFile(taskPath, []byte(fileContent), 0644); err != nil { + if err := os.WriteFile(taskPath, []byte(fileContent), 0o644); err != nil { t.Fatalf("failed to create task file: %v", err) } } @@ -38,7 +41,7 @@ func createRule(t *testing.T, dir, relPath, frontmatter, content string) { t.Helper() rulePath := filepath.Join(dir, relPath) ruleDir := filepath.Dir(rulePath) - if err := os.MkdirAll(ruleDir, 0755); err != nil { + if err := os.MkdirAll(ruleDir, 0o755); err != nil { t.Fatalf("failed to create rule directory: %v", err) } @@ -49,7 +52,7 @@ func createRule(t *testing.T, dir, relPath, frontmatter, content string) { fileContent = content } - if err := os.WriteFile(rulePath, []byte(fileContent), 0644); err != nil { + if err := os.WriteFile(rulePath, []byte(fileContent), 0o644); err != nil { t.Fatalf("failed to create rule file: %v", err) } } @@ -58,7 +61,7 @@ func createRule(t *testing.T, dir, relPath, frontmatter, content string) { func createCommand(t *testing.T, dir, name, frontmatter, content string) { t.Helper() cmdDir := filepath.Join(dir, ".agents", "commands") - if err := os.MkdirAll(cmdDir, 0755); err != nil { + if err := os.MkdirAll(cmdDir, 0o755); err != nil { t.Fatalf("failed to create command directory: %v", err) } @@ -70,7 +73,7 @@ func createCommand(t *testing.T, dir, name, frontmatter, content string) { } cmdPath := filepath.Join(cmdDir, name+".md") - if err := os.WriteFile(cmdPath, []byte(fileContent), 0644); err != nil { + if err := os.WriteFile(cmdPath, []byte(fileContent), 0o644); err != nil { t.Fatalf("failed to create command file: %v", err) } } @@ -82,7 +85,7 @@ func createBootstrapScript(t *testing.T, dir, rulePath, scriptContent string) { baseNameWithoutExt := strings.TrimSuffix(fullRulePath, filepath.Ext(fullRulePath)) bootstrapPath := baseNameWithoutExt + "-bootstrap" - if err := os.WriteFile(bootstrapPath, []byte(scriptContent), 0755); err != nil { + if err := os.WriteFile(bootstrapPath, []byte(scriptContent), 0o755); err != nil { t.Fatalf("failed to create bootstrap script: %v", err) } } @@ -115,21 +118,21 @@ func TestNew(t *testing.T) { { name: "with params", opts: []Option{ - WithParams(Params{"key1": "value1", "key2": "value2"}), + WithParams(taskparser.Params{"key1": []string{"value1"}, "key2": []string{"value2"}}), }, check: func(t *testing.T, c *Context) { - if c.params["key1"] != "value1" { - t.Errorf("expected params[key1]=value1, got %v", c.params["key1"]) + if c.params.Value("key1") != "value1" { + t.Errorf("expected params[key1]=value1, got %v", c.params.Value("key1")) } - if c.params["key2"] != "value2" { - t.Errorf("expected params[key2]=value2, got %v", c.params["key2"]) + if c.params.Value("key2") != "value2" { + t.Errorf("expected params[key2]=value2, got %v", c.params.Value("key2")) } }, }, { name: "with selectors", opts: []Option{ - WithSelectors(Selectors{"env": {"dev": true, "test": true}}), + WithSelectors(selectors.Selectors{"env": {"dev": true, "test": true}}), }, check: func(t *testing.T, c *Context) { if !c.includes.GetValue("env", "dev") { @@ -204,14 +207,14 @@ func TestNew(t *testing.T) { { name: "multiple options combined", opts: []Option{ - WithParams(Params{"env": "production"}), - WithSelectors(Selectors{"lang": {"go": true}}), + WithParams(taskparser.Params{"env": []string{"production"}}), + WithSelectors(selectors.Selectors{"lang": {"go": true}}), WithSearchPaths("/custom/path"), WithResume(false), WithAgent(AgentCopilot), }, check: func(t *testing.T, c *Context) { - if c.params["env"] != "production" { + if c.params.Value("env") != "production" { t.Error("params not set correctly") } if !c.includes.GetValue("lang", "go") { @@ -289,7 +292,7 @@ func TestContext_Run_Basic(t *testing.T) { createTask(t, dir, "params-task", "", "Environment: ${env}\nFeature: ${feature}") }, opts: []Option{ - WithParams(Params{"env": "production", "feature": "auth"}), + WithParams(taskparser.Params{"env": []string{"production"}, "feature": []string{"auth"}}), }, taskName: "params-task", wantErr: false, @@ -341,7 +344,7 @@ func TestContext_Run_Basic(t *testing.T) { createTask(t, dir, "multi-params", "", "User: ${user}, Email: ${email}, Role: ${role}") }, opts: []Option{ - WithParams(Params{"user": "alice", "email": "alice@example.com", "role": "admin"}), + WithParams(taskparser.Params{"user": []string{"alice"}, "email": []string{"alice@example.com"}, "role": []string{"admin"}}), }, taskName: "multi-params", wantErr: false, @@ -449,7 +452,7 @@ func TestContext_Run_Rules(t *testing.T) { createRule(t, dir, ".agents/rules/param-rule.md", "", "Project: ${project}") }, opts: []Option{ - WithParams(Params{"project": "myapp"}), + WithParams(taskparser.Params{"project": []string{"myapp"}}), }, taskName: "param-task", wantErr: false, @@ -578,7 +581,7 @@ func TestContext_Run_Rules(t *testing.T) { createRule(t, dir, ".agents/rules/test-rule.md", "env: test", "Test rule") }, opts: []Option{ - WithSelectors(Selectors{"env": {"development": true}}), // CLI selector for development + WithSelectors(selectors.Selectors{"env": {"development": true}}), // CLI selector for development }, taskName: "or-task", wantErr: false, @@ -624,7 +627,7 @@ func TestContext_Run_Rules(t *testing.T) { createRule(t, dir, ".agents/rules/test-rule.md", "env: test", "Test rule") }, opts: []Option{ - WithSelectors(Selectors{"env": {"development": true}}), // CLI adds development + WithSelectors(selectors.Selectors{"env": {"development": true}}), // CLI adds development }, taskName: "array-or", wantErr: false, @@ -772,7 +775,7 @@ func TestContext_Run_Commands(t *testing.T) { createCommand(t, dir, "deploy", "", "Deploying to ${env}") }, opts: []Option{ - WithParams(Params{"env": "staging"}), + WithParams(taskparser.Params{"env": []string{"staging"}}), }, taskName: "ctx-params", wantErr: false, @@ -817,7 +820,7 @@ func TestContext_Run_Commands(t *testing.T) { createCommand(t, dir, "msg", "", "Value: ${value}") }, opts: []Option{ - WithParams(Params{"value": "general"}), + WithParams(taskparser.Params{"value": []string{"general"}}), }, taskName: "override-param", wantErr: false, @@ -915,7 +918,7 @@ func TestContext_Run_Integration(t *testing.T) { createRule(t, dir, ".agents/rules/dev.md", "env: development", "Dev only rule") }, opts: []Option{ - WithParams(Params{"app": "myservice", "env": "production"}), + WithParams(taskparser.Params{"app": []string{"myservice"}, "env": []string{"production"}}), }, taskName: "fullworkflow", wantErr: false, @@ -1006,7 +1009,7 @@ func TestContext_Run_Integration(t *testing.T) { // Create second directory with additional rule secondDir := filepath.Join(dir, "second") - if err := os.MkdirAll(secondDir, 0755); err != nil { + if err := os.MkdirAll(secondDir, 0o755); err != nil { t.Fatalf("failed to create second dir: %v", err) } createRule(t, secondDir, ".agents/rules/rule2.md", "", "Rule from second path") @@ -1121,7 +1124,7 @@ func TestContext_Run_ExpandParams(t *testing.T) { createTask(t, dir, "no-expand", "expand: false", "Issue: ${issue_number}\nTitle: ${issue_title}") }, opts: []Option{ - WithParams(Params{"issue_number": "123", "issue_title": "Bug fix"}), + WithParams(taskparser.Params{"issue_number": []string{"123"}, "issue_title": []string{"Bug fix"}}), }, taskName: "no-expand", wantErr: false, @@ -1140,7 +1143,7 @@ func TestContext_Run_ExpandParams(t *testing.T) { createTask(t, dir, "expand", "expand: true", "Issue: ${issue_number}\nTitle: ${issue_title}") }, opts: []Option{ - WithParams(Params{"issue_number": "123", "issue_title": "Bug fix"}), + WithParams(taskparser.Params{"issue_number": []string{"123"}, "issue_title": []string{"Bug fix"}}), }, taskName: "expand", wantErr: false, @@ -1159,7 +1162,7 @@ func TestContext_Run_ExpandParams(t *testing.T) { createTask(t, dir, "default", "", "Env: ${env}") }, opts: []Option{ - WithParams(Params{"env": "production"}), + WithParams(taskparser.Params{"env": []string{"production"}}), }, taskName: "default", wantErr: false, @@ -1176,7 +1179,7 @@ func TestContext_Run_ExpandParams(t *testing.T) { createCommand(t, dir, "deploy", "expand: false", "Deploying to ${env}") }, opts: []Option{ - WithParams(Params{"env": "staging"}), + WithParams(taskparser.Params{"env": []string{"staging"}}), }, taskName: "cmd-no-expand", wantErr: false, @@ -1193,7 +1196,7 @@ func TestContext_Run_ExpandParams(t *testing.T) { createCommand(t, dir, "deploy", "expand: true", "Deploying to ${env}") }, opts: []Option{ - WithParams(Params{"env": "staging"}), + WithParams(taskparser.Params{"env": []string{"staging"}}), }, taskName: "cmd-expand", wantErr: false, @@ -1210,7 +1213,7 @@ func TestContext_Run_ExpandParams(t *testing.T) { createCommand(t, dir, "info", "", "Project: ${project}") }, opts: []Option{ - WithParams(Params{"project": "myapp"}), + WithParams(taskparser.Params{"project": []string{"myapp"}}), }, taskName: "cmd-default", wantErr: false, @@ -1227,7 +1230,7 @@ func TestContext_Run_ExpandParams(t *testing.T) { createRule(t, dir, ".agents/rules/rule1.md", "expand: false", "Version: ${version}") }, opts: []Option{ - WithParams(Params{"version": "1.0.0"}), + WithParams(taskparser.Params{"version": []string{"1.0.0"}}), }, taskName: "rule-no-expand", wantErr: false, @@ -1247,7 +1250,7 @@ func TestContext_Run_ExpandParams(t *testing.T) { createRule(t, dir, ".agents/rules/rule1.md", "expand: true", "Version: ${version}") }, opts: []Option{ - WithParams(Params{"version": "1.0.0"}), + WithParams(taskparser.Params{"version": []string{"1.0.0"}}), }, taskName: "rule-expand", wantErr: false, @@ -1267,7 +1270,7 @@ func TestContext_Run_ExpandParams(t *testing.T) { createRule(t, dir, ".agents/rules/rule1.md", "", "App: ${app}") }, opts: []Option{ - WithParams(Params{"app": "service"}), + WithParams(taskparser.Params{"app": []string{"service"}}), }, taskName: "rule-default", wantErr: false, @@ -1287,7 +1290,7 @@ func TestContext_Run_ExpandParams(t *testing.T) { createCommand(t, dir, "cmd", "expand: true", "Command ${cmd_var}") }, opts: []Option{ - WithParams(Params{"task_var": "task_value", "cmd_var": "cmd_value"}), + WithParams(taskparser.Params{"task_var": []string{"task_value"}, "cmd_var": []string{"cmd_value"}}), }, taskName: "mixed1", wantErr: false, @@ -1308,7 +1311,7 @@ func TestContext_Run_ExpandParams(t *testing.T) { createCommand(t, dir, "cmd", "expand: false", "Command ${cmd_var}") }, opts: []Option{ - WithParams(Params{"task_var": "task_value", "cmd_var": "cmd_value"}), + WithParams(taskparser.Params{"task_var": []string{"task_value"}, "cmd_var": []string{"cmd_value"}}), }, taskName: "mixed2", wantErr: false, @@ -1329,7 +1332,7 @@ func TestContext_Run_ExpandParams(t *testing.T) { createCommand(t, dir, "greet", "expand: false", "Hello, ${name}! Your ID: ${id}") }, opts: []Option{ - WithParams(Params{"id": "123"}), + WithParams(taskparser.Params{"id": []string{"123"}}), }, taskName: "inline-no-expand", wantErr: false, @@ -1352,7 +1355,7 @@ func TestContext_Run_ExpandParams(t *testing.T) { createRule(t, dir, ".agents/rules/rule3.md", "", "Rule3: ${var3}") }, opts: []Option{ - WithParams(Params{"var1": "val1", "var2": "val2", "var3": "val3"}), + WithParams(taskparser.Params{"var1": []string{"val1"}, "var2": []string{"val2"}, "var3": []string{"val3"}}), }, taskName: "multi-rules", wantErr: false, @@ -1475,8 +1478,8 @@ func TestUserPrompt(t *testing.T) { }, opts: []Option{ WithUserPrompt("Issue: ${issue_number}"), - WithParams(Params{ - "issue_number": "123", + WithParams(taskparser.Params{ + "issue_number": []string{"123"}, }), }, taskName: "with-params", @@ -1495,9 +1498,9 @@ func TestUserPrompt(t *testing.T) { }, opts: []Option{ WithUserPrompt("Please fix:\n/issue-info\n"), - WithParams(Params{ - "issue_number": "456", - "issue_title": "Fix bug", + WithParams(taskparser.Params{ + "issue_number": []string{"456"}, + "issue_title": []string{"Fix bug"}, }), }, taskName: "complex", @@ -1569,8 +1572,8 @@ func TestUserPrompt(t *testing.T) { }, opts: []Option{ WithUserPrompt("Issue ${issue_number}"), - WithParams(Params{ - "issue_number": "789", + WithParams(taskparser.Params{ + "issue_number": []string{"789"}, }), }, taskName: "no-expand", diff --git a/pkg/codingcontext/free_text_task.go b/pkg/codingcontext/free_text_task.go deleted file mode 100644 index 7532d8a..0000000 --- a/pkg/codingcontext/free_text_task.go +++ /dev/null @@ -1,7 +0,0 @@ -package codingcontext - -// FreeTextTaskName is the task name used for free-text prompts -const FreeTextTaskName = "free-text" - -// FreeTextParamName is the parameter name used for the text content in free-text tasks -const FreeTextParamName = "text" diff --git a/pkg/codingcontext/frontmatter.go b/pkg/codingcontext/frontmatter.go deleted file mode 100644 index 50a30f7..0000000 --- a/pkg/codingcontext/frontmatter.go +++ /dev/null @@ -1,6 +0,0 @@ -package codingcontext - -// BaseFrontMatter represents parsed YAML frontmatter from markdown files -type BaseFrontMatter struct { - Content map[string]any `json:"-" yaml:",inline"` -} diff --git a/pkg/codingcontext/markdown/frontmatter.go b/pkg/codingcontext/markdown/frontmatter.go new file mode 100644 index 0000000..c2d3a18 --- /dev/null +++ b/pkg/codingcontext/markdown/frontmatter.go @@ -0,0 +1,151 @@ +package markdown + +import ( + "encoding/json" + + "github.com/kitproj/coding-context-cli/pkg/codingcontext/mcp" +) + +// BaseFrontMatter represents parsed YAML frontmatter from markdown files +type BaseFrontMatter struct { + Content map[string]any `json:"-" yaml:",inline"` +} + +// TaskFrontMatter represents the standard frontmatter fields for task files +type TaskFrontMatter struct { + BaseFrontMatter `yaml:",inline"` + + // Agent specifies the default agent if not specified via -a flag + // This is not used for selecting tasks or rules, only as a default + Agent string `yaml:"agent,omitempty" json:"agent,omitempty"` + + // Languages specifies the programming language(s) for filtering rules + // Array of languages for OR logic (e.g., ["go", "python"]) + Languages []string `yaml:"languages,omitempty" json:"languages,omitempty"` + + // Model specifies the AI model identifier + // Does not filter rules, metadata only + Model string `yaml:"model,omitempty" json:"model,omitempty"` + + // SingleShot indicates whether the task runs once or multiple times + // Does not filter rules, metadata only + SingleShot bool `yaml:"single_shot,omitempty" json:"single_shot,omitempty"` + + // Timeout specifies the task timeout in time.Duration format (e.g., "10m", "1h") + // Does not filter rules, metadata only + Timeout string `yaml:"timeout,omitempty" json:"timeout,omitempty"` + + // Resume indicates if this task should be resumed + Resume bool `yaml:"resume,omitempty" json:"resume,omitempty"` + + // Selectors contains additional custom selectors for filtering rules + Selectors map[string]any `yaml:"selectors,omitempty" json:"selectors,omitempty"` + + // ExpandParams controls whether parameter expansion should occur + // Defaults to true if not specified + ExpandParams *bool `yaml:"expand,omitempty" json:"expand,omitempty"` +} + +// UnmarshalJSON custom unmarshaler that populates both typed fields and Content map +func (t *TaskFrontMatter) UnmarshalJSON(data []byte) error { + // First unmarshal into a temporary type to avoid infinite recursion + type Alias TaskFrontMatter + aux := &struct { + *Alias + }{ + Alias: (*Alias)(t), + } + + if err := json.Unmarshal(data, aux); err != nil { + return err + } + + // Also unmarshal into Content map + if err := json.Unmarshal(data, &t.BaseFrontMatter.Content); err != nil { + return err + } + + return nil +} + +// CommandFrontMatter represents the frontmatter fields for command files. +// Previously this was an empty placeholder struct, but now supports the expand field +// to control parameter expansion behavior in command content. +type CommandFrontMatter struct { + BaseFrontMatter `yaml:",inline"` + + // ExpandParams controls whether parameter expansion should occur + // Defaults to true if not specified + ExpandParams *bool `yaml:"expand,omitempty" json:"expand,omitempty"` +} + +// UnmarshalJSON custom unmarshaler that populates both typed fields and Content map +func (c *CommandFrontMatter) UnmarshalJSON(data []byte) error { + // First unmarshal into a temporary type to avoid infinite recursion + type Alias CommandFrontMatter + aux := &struct { + *Alias + }{ + Alias: (*Alias)(c), + } + + if err := json.Unmarshal(data, aux); err != nil { + return err + } + + // Also unmarshal into Content map + if err := json.Unmarshal(data, &c.BaseFrontMatter.Content); err != nil { + return err + } + + return nil +} + +// RuleFrontMatter represents the standard frontmatter fields for rule files +type RuleFrontMatter struct { + BaseFrontMatter `yaml:",inline"` + + // TaskNames specifies which task(s) this rule applies to + // Array of task names for OR logic + TaskNames []string `yaml:"task_names,omitempty" json:"task_names,omitempty"` + + // Languages specifies which programming language(s) this rule applies to + // Array of languages for OR logic (e.g., ["go", "python"]) + Languages []string `yaml:"languages,omitempty" json:"languages,omitempty"` + + // Agent specifies which AI agent this rule is intended for + Agent string `yaml:"agent,omitempty" json:"agent,omitempty"` + + // MCPServer specifies a single MCP server configuration + // Metadata only, does not filter + MCPServer mcp.MCPServerConfig `yaml:"mcp_server,omitempty" json:"mcp_server,omitempty"` + + // RuleName is an optional identifier for the rule file + RuleName string `yaml:"rule_name,omitempty" json:"rule_name,omitempty"` + + // ExpandParams controls whether parameter expansion should occur + // Defaults to true if not specified + ExpandParams *bool `yaml:"expand,omitempty" json:"expand,omitempty"` +} + +// UnmarshalJSON custom unmarshaler that populates both typed fields and Content map +func (r *RuleFrontMatter) UnmarshalJSON(data []byte) error { + // First unmarshal into a temporary type to avoid infinite recursion + type Alias RuleFrontMatter + aux := &struct { + *Alias + }{ + Alias: (*Alias)(r), + } + + if err := json.Unmarshal(data, aux); err != nil { + return err + } + + // Also unmarshal into Content map + if err := json.Unmarshal(data, &r.BaseFrontMatter.Content); err != nil { + return err + } + + return nil +} diff --git a/pkg/codingcontext/rule_frontmatter_test.go b/pkg/codingcontext/markdown/frontmatter_rule_test.go similarity index 94% rename from pkg/codingcontext/rule_frontmatter_test.go rename to pkg/codingcontext/markdown/frontmatter_rule_test.go index 55ba4ce..6424383 100644 --- a/pkg/codingcontext/rule_frontmatter_test.go +++ b/pkg/codingcontext/markdown/frontmatter_rule_test.go @@ -1,9 +1,10 @@ -package codingcontext +package markdown import ( "testing" "github.com/goccy/go-yaml" + "github.com/kitproj/coding-context-cli/pkg/codingcontext/mcp" ) func TestRuleFrontMatter_Marshal(t *testing.T) { @@ -50,8 +51,8 @@ agent: cursor TaskNames: []string{"test-task"}, Languages: []string{"go", "python"}, Agent: "copilot", - MCPServer: MCPServerConfig{ - Type: TransportTypeStdio, + MCPServer: mcp.MCPServerConfig{ + Type: mcp.TransportTypeStdio, Command: "database-server", Args: []string{"--port", "5432"}, }, diff --git a/pkg/codingcontext/task_frontmatter_test.go b/pkg/codingcontext/markdown/frontmatter_task_test.go similarity index 99% rename from pkg/codingcontext/task_frontmatter_test.go rename to pkg/codingcontext/markdown/frontmatter_task_test.go index 3be535b..c84d874 100644 --- a/pkg/codingcontext/task_frontmatter_test.go +++ b/pkg/codingcontext/markdown/frontmatter_task_test.go @@ -1,4 +1,4 @@ -package codingcontext +package markdown import ( "testing" diff --git a/pkg/codingcontext/markdown.go b/pkg/codingcontext/markdown/markdown.go similarity index 75% rename from pkg/codingcontext/markdown.go rename to pkg/codingcontext/markdown/markdown.go index 2be3a34..9a36903 100644 --- a/pkg/codingcontext/markdown.go +++ b/pkg/codingcontext/markdown/markdown.go @@ -1,4 +1,4 @@ -package codingcontext +package markdown import ( "bufio" @@ -7,8 +7,22 @@ import ( "os" yaml "github.com/goccy/go-yaml" + "github.com/kitproj/coding-context-cli/pkg/codingcontext/tokencount" ) +// Markdown represents a markdown file with frontmatter and content +type Markdown[T any] struct { + FrontMatter T // Parsed YAML frontmatter + Content string // Expanded content of the markdown + Tokens int // Estimated token count +} + +// TaskMarkdown is a Markdown with TaskFrontMatter +type TaskMarkdown = Markdown[TaskFrontMatter] + +// RuleMarkdown is a Markdown with RuleFrontMatter +type RuleMarkdown = Markdown[RuleFrontMatter] + // ParseMarkdownFile parses a markdown file into frontmatter and content func ParseMarkdownFile[T any](path string, frontMatter *T) (Markdown[T], error) { fh, err := os.Open(path) @@ -67,6 +81,6 @@ func ParseMarkdownFile[T any](path string, frontMatter *T) (Markdown[T], error) return Markdown[T]{ FrontMatter: *frontMatter, Content: content.String(), - Tokens: estimateTokens(content.String()), + Tokens: tokencount.EstimateTokens(content.String()), }, nil } diff --git a/pkg/codingcontext/markdown_test.go b/pkg/codingcontext/markdown/markdown_test.go similarity index 97% rename from pkg/codingcontext/markdown_test.go rename to pkg/codingcontext/markdown/markdown_test.go index 13e95e1..5b3c522 100644 --- a/pkg/codingcontext/markdown_test.go +++ b/pkg/codingcontext/markdown/markdown_test.go @@ -1,4 +1,4 @@ -package codingcontext +package markdown import ( "os" @@ -63,7 +63,7 @@ This is the content. // Create a temporary file tmpDir := t.TempDir() tmpFile := filepath.Join(tmpDir, "test.md") - if err := os.WriteFile(tmpFile, []byte(tt.content), 0644); err != nil { + if err := os.WriteFile(tmpFile, []byte(tt.content), 0o644); err != nil { t.Fatalf("failed to create temp file: %v", err) } @@ -180,7 +180,7 @@ This task has no frontmatter. // Create a temporary file tmpDir := t.TempDir() tmpFile := filepath.Join(tmpDir, "test.md") - if err := os.WriteFile(tmpFile, []byte(tt.content), 0644); err != nil { + if err := os.WriteFile(tmpFile, []byte(tt.content), 0o644); err != nil { t.Fatalf("failed to create temp file: %v", err) } diff --git a/pkg/codingcontext/mcp_server_config.go b/pkg/codingcontext/mcp/mcp.go similarity index 77% rename from pkg/codingcontext/mcp_server_config.go rename to pkg/codingcontext/mcp/mcp.go index 433ee66..b6b0624 100644 --- a/pkg/codingcontext/mcp_server_config.go +++ b/pkg/codingcontext/mcp/mcp.go @@ -1,7 +1,21 @@ -package codingcontext +package mcp -import ( - "encoding/json" +import "encoding/json" + +// TransportType defines the communication protocol used by the server. +// Supported by both Claude and Cursor. +type TransportType string + +const ( + // TransportTypeStdio is for local processes (executables). + TransportTypeStdio TransportType = "stdio" + + // TransportTypeSSE is for Server-Sent Events (Remote). + // Note: Claude Code prefers HTTP over SSE, but supports it. + TransportTypeSSE TransportType = "sse" + + // TransportTypeHTTP is for standard HTTP/POST interactions. + TransportTypeHTTP TransportType = "http" ) // MCPServerConfig defines the common configuration fields supported by both platforms. diff --git a/pkg/codingcontext/mcp_server_config_test.go b/pkg/codingcontext/mcp/mcp_test.go similarity index 99% rename from pkg/codingcontext/mcp_server_config_test.go rename to pkg/codingcontext/mcp/mcp_test.go index 8754a2a..a3420e7 100644 --- a/pkg/codingcontext/mcp_server_config_test.go +++ b/pkg/codingcontext/mcp/mcp_test.go @@ -1,4 +1,4 @@ -package codingcontext +package mcp import ( "encoding/json" diff --git a/pkg/codingcontext/options.go b/pkg/codingcontext/options.go new file mode 100644 index 0000000..d7f2bf8 --- /dev/null +++ b/pkg/codingcontext/options.go @@ -0,0 +1,67 @@ +package codingcontext + +import ( + "log/slog" + + "github.com/kitproj/coding-context-cli/pkg/codingcontext/selectors" + "github.com/kitproj/coding-context-cli/pkg/codingcontext/taskparser" +) + +// Option is a functional option for configuring a Context +type Option func(*Context) + +// WithParams sets the parameters +func WithParams(params taskparser.Params) Option { + return func(c *Context) { + c.params = params + } +} + +// WithSelectors sets the selectors +func WithSelectors(selectors selectors.Selectors) Option { + return func(c *Context) { + c.includes = selectors + } +} + +// WithManifestURL sets the manifest URL +func WithManifestURL(manifestURL string) Option { + return func(c *Context) { + c.manifestURL = manifestURL + } +} + +// WithSearchPaths adds one or more search paths +func WithSearchPaths(paths ...string) Option { + return func(c *Context) { + c.searchPaths = append(c.searchPaths, paths...) + } +} + +// WithLogger sets the logger +func WithLogger(logger *slog.Logger) Option { + return func(c *Context) { + c.logger = logger + } +} + +// WithResume enables resume mode, which skips rule discovery and bootstrap scripts +func WithResume(resume bool) Option { + return func(c *Context) { + c.resume = resume + } +} + +// WithAgent sets the target agent, which excludes that agent's own rules +func WithAgent(agent Agent) Option { + return func(c *Context) { + c.agent = agent + } +} + +// WithUserPrompt sets the user prompt to append to the task +func WithUserPrompt(userPrompt string) Option { + return func(c *Context) { + c.userPrompt = userPrompt + } +} diff --git a/pkg/codingcontext/params.go b/pkg/codingcontext/params.go deleted file mode 100644 index 49022f5..0000000 --- a/pkg/codingcontext/params.go +++ /dev/null @@ -1,109 +0,0 @@ -package codingcontext - -import ( - "fmt" - "strings" -) - -// Params is a map of parameter key-value pairs for template substitution -type Params map[string]string - -// String implements the fmt.Stringer interface for Params -func (p *Params) String() string { - return fmt.Sprint(*p) -} - -// Set implements the flag.Value interface for Params -func (p *Params) Set(value string) error { - kv := strings.SplitN(value, "=", 2) - if len(kv) != 2 { - return fmt.Errorf("invalid parameter format: %s", value) - } - if *p == nil { - *p = make(map[string]string) - } - (*p)[kv[0]] = kv[1] - return nil -} - -// ParseParams parses a string containing key=value pairs separated by spaces. -// Values must be quoted with double quotes, and quotes can be escaped. -// Unquoted values are treated as an error. -// Examples: -// - `key1="value1" key2="value2"` -// - `key1="value with spaces" key2="value2"` -// - `key1="value with \"escaped\" quotes"` -func ParseParams(s string) (Params, error) { - params := make(Params) - if s == "" { - return params, nil - } - - s = strings.TrimSpace(s) - var i int - for i < len(s) { - // Skip whitespace - for i < len(s) && (s[i] == ' ' || s[i] == '\t') { - i++ - } - if i >= len(s) { - break - } - - // Find the key (until '=') - keyStart := i - for i < len(s) && s[i] != '=' { - i++ - } - if i >= len(s) { - break - } - key := strings.TrimSpace(s[keyStart:i]) - if key == "" { - i++ - continue - } - - // Skip '=' - i++ - - // Skip whitespace after '=' - for i < len(s) && (s[i] == ' ' || s[i] == '\t') { - i++ - } - if i >= len(s) { - return nil, fmt.Errorf("missing quoted value for key %q", key) - } - - // Values must be quoted - if s[i] != '"' { - return nil, fmt.Errorf("unquoted value for key %q: values must be double-quoted", key) - } - - // Parse the double-quoted value - var value strings.Builder - i++ // skip opening quote - quoted := false - for i < len(s) { - if s[i] == '\\' && i+1 < len(s) && s[i+1] == '"' { - value.WriteByte('"') - i += 2 - } else if s[i] == '"' { - i++ // skip closing quote - quoted = true - break - } else { - value.WriteByte(s[i]) - i++ - } - } - - if !quoted { - return nil, fmt.Errorf("unclosed quote for key %q", key) - } - - params[key] = value.String() - } - - return params, nil -} diff --git a/pkg/codingcontext/result.go b/pkg/codingcontext/result.go index 948166d..dd7566b 100644 --- a/pkg/codingcontext/result.go +++ b/pkg/codingcontext/result.go @@ -1,31 +1,23 @@ package codingcontext -// Markdown represents a markdown file with frontmatter and content -type Markdown[T any] struct { - FrontMatter T // Parsed YAML frontmatter - Content string // Expanded content of the markdown - Tokens int // Estimated token count -} - -// TaskMarkdown is a Markdown with TaskFrontMatter -type TaskMarkdown = Markdown[TaskFrontMatter] - -// RuleMarkdown is a Markdown with RuleFrontMatter -type RuleMarkdown = Markdown[RuleFrontMatter] +import ( + "github.com/kitproj/coding-context-cli/pkg/codingcontext/markdown" + "github.com/kitproj/coding-context-cli/pkg/codingcontext/mcp" +) // Result holds the assembled context from running a task type Result struct { - Rules []Markdown[RuleFrontMatter] // List of included rule files - Task Markdown[TaskFrontMatter] // Task file with frontmatter and content - Tokens int // Total token count - Agent Agent // The agent used (from task or -a flag) + Rules []markdown.Markdown[markdown.RuleFrontMatter] // List of included rule files + Task markdown.Markdown[markdown.TaskFrontMatter] // Task file with frontmatter and content + Tokens int // Total token count + Agent Agent // The agent used (from task or -a flag) } // MCPServers returns all MCP server configurations from rules. // Each rule can specify one MCP server configuration. // Returns a slice of all configured MCP servers from rules only. -func (r *Result) MCPServers() []MCPServerConfig { - var servers []MCPServerConfig +func (r *Result) MCPServers() []mcp.MCPServerConfig { + var servers []mcp.MCPServerConfig // Add server from each rule for _, rule := range r.Rules { diff --git a/pkg/codingcontext/result_test.go b/pkg/codingcontext/result_test.go index be80cd0..facc250 100644 --- a/pkg/codingcontext/result_test.go +++ b/pkg/codingcontext/result_test.go @@ -2,89 +2,92 @@ package codingcontext import ( "testing" + + "github.com/kitproj/coding-context-cli/pkg/codingcontext/markdown" + "github.com/kitproj/coding-context-cli/pkg/codingcontext/mcp" ) func TestResult_MCPServers(t *testing.T) { tests := []struct { name string result Result - want []MCPServerConfig + want []mcp.MCPServerConfig }{ { name: "no MCP servers", result: Result{ - Rules: []Markdown[RuleFrontMatter]{}, - Task: Markdown[TaskFrontMatter]{ - FrontMatter: TaskFrontMatter{}, + Rules: []markdown.Markdown[markdown.RuleFrontMatter]{}, + Task: markdown.Markdown[markdown.TaskFrontMatter]{ + FrontMatter: markdown.TaskFrontMatter{}, }, }, - want: []MCPServerConfig{}, + want: []mcp.MCPServerConfig{}, }, { name: "MCP servers from rules only", result: Result{ - Rules: []Markdown[RuleFrontMatter]{ + Rules: []markdown.Markdown[markdown.RuleFrontMatter]{ { - FrontMatter: RuleFrontMatter{ - MCPServer: MCPServerConfig{Type: TransportTypeStdio, Command: "jira"}, + FrontMatter: markdown.RuleFrontMatter{ + MCPServer: mcp.MCPServerConfig{Type: mcp.TransportTypeStdio, Command: "jira"}, }, }, { - FrontMatter: RuleFrontMatter{ - MCPServer: MCPServerConfig{Type: TransportTypeHTTP, URL: "https://api.example.com"}, + FrontMatter: markdown.RuleFrontMatter{ + MCPServer: mcp.MCPServerConfig{Type: mcp.TransportTypeHTTP, URL: "https://api.example.com"}, }, }, }, - Task: Markdown[TaskFrontMatter]{ - FrontMatter: TaskFrontMatter{}, + Task: markdown.Markdown[markdown.TaskFrontMatter]{ + FrontMatter: markdown.TaskFrontMatter{}, }, }, - want: []MCPServerConfig{ - {Type: TransportTypeStdio, Command: "jira"}, - {Type: TransportTypeHTTP, URL: "https://api.example.com"}, + want: []mcp.MCPServerConfig{ + {Type: mcp.TransportTypeStdio, Command: "jira"}, + {Type: mcp.TransportTypeHTTP, URL: "https://api.example.com"}, }, }, { name: "multiple rules with MCP servers", result: Result{ - Rules: []Markdown[RuleFrontMatter]{ + Rules: []markdown.Markdown[markdown.RuleFrontMatter]{ { - FrontMatter: RuleFrontMatter{ - MCPServer: MCPServerConfig{Type: TransportTypeStdio, Command: "server1"}, + FrontMatter: markdown.RuleFrontMatter{ + MCPServer: mcp.MCPServerConfig{Type: mcp.TransportTypeStdio, Command: "server1"}, }, }, { - FrontMatter: RuleFrontMatter{ - MCPServer: MCPServerConfig{Type: TransportTypeStdio, Command: "server2"}, + FrontMatter: markdown.RuleFrontMatter{ + MCPServer: mcp.MCPServerConfig{Type: mcp.TransportTypeStdio, Command: "server2"}, }, }, { - FrontMatter: RuleFrontMatter{}, + FrontMatter: markdown.RuleFrontMatter{}, }, }, - Task: Markdown[TaskFrontMatter]{ - FrontMatter: TaskFrontMatter{}, + Task: markdown.Markdown[markdown.TaskFrontMatter]{ + FrontMatter: markdown.TaskFrontMatter{}, }, }, - want: []MCPServerConfig{ - {Type: TransportTypeStdio, Command: "server1"}, - {Type: TransportTypeStdio, Command: "server2"}, + want: []mcp.MCPServerConfig{ + {Type: mcp.TransportTypeStdio, Command: "server1"}, + {Type: mcp.TransportTypeStdio, Command: "server2"}, {}, // Empty rule MCP server }, }, { name: "rule without MCP server", result: Result{ - Rules: []Markdown[RuleFrontMatter]{ + Rules: []markdown.Markdown[markdown.RuleFrontMatter]{ { - FrontMatter: RuleFrontMatter{}, + FrontMatter: markdown.RuleFrontMatter{}, }, }, - Task: Markdown[TaskFrontMatter]{ - FrontMatter: TaskFrontMatter{}, + Task: markdown.Markdown[markdown.TaskFrontMatter]{ + FrontMatter: markdown.TaskFrontMatter{}, }, }, - want: []MCPServerConfig{ + want: []mcp.MCPServerConfig{ {}, // Empty rule MCP server }, }, diff --git a/pkg/codingcontext/rule_frontmatter.go b/pkg/codingcontext/rule_frontmatter.go deleted file mode 100644 index 78c5da8..0000000 --- a/pkg/codingcontext/rule_frontmatter.go +++ /dev/null @@ -1,54 +0,0 @@ -package codingcontext - -import ( - "encoding/json" -) - -// RuleFrontMatter represents the standard frontmatter fields for rule files -type RuleFrontMatter struct { - BaseFrontMatter `yaml:",inline"` - - // TaskNames specifies which task(s) this rule applies to - // Array of task names for OR logic - TaskNames []string `yaml:"task_names,omitempty" json:"task_names,omitempty"` - - // Languages specifies which programming language(s) this rule applies to - // Array of languages for OR logic (e.g., ["go", "python"]) - Languages []string `yaml:"languages,omitempty" json:"languages,omitempty"` - - // Agent specifies which AI agent this rule is intended for - Agent string `yaml:"agent,omitempty" json:"agent,omitempty"` - - // MCPServer specifies a single MCP server configuration - // Metadata only, does not filter - MCPServer MCPServerConfig `yaml:"mcp_server,omitempty" json:"mcp_server,omitempty"` - - // RuleName is an optional identifier for the rule file - RuleName string `yaml:"rule_name,omitempty" json:"rule_name,omitempty"` - - // ExpandParams controls whether parameter expansion should occur - // Defaults to true if not specified - ExpandParams *bool `yaml:"expand,omitempty" json:"expand,omitempty"` -} - -// UnmarshalJSON custom unmarshaler that populates both typed fields and Content map -func (r *RuleFrontMatter) UnmarshalJSON(data []byte) error { - // First unmarshal into a temporary type to avoid infinite recursion - type Alias RuleFrontMatter - aux := &struct { - *Alias - }{ - Alias: (*Alias)(r), - } - - if err := json.Unmarshal(data, aux); err != nil { - return err - } - - // Also unmarshal into Content map - if err := json.Unmarshal(data, &r.BaseFrontMatter.Content); err != nil { - return err - } - - return nil -} diff --git a/pkg/codingcontext/selectors.go b/pkg/codingcontext/selectors/selectors.go similarity index 94% rename from pkg/codingcontext/selectors.go rename to pkg/codingcontext/selectors/selectors.go index cf6dc4a..b4b91a8 100644 --- a/pkg/codingcontext/selectors.go +++ b/pkg/codingcontext/selectors/selectors.go @@ -1,8 +1,10 @@ -package codingcontext +package selectors import ( "fmt" "strings" + + "github.com/kitproj/coding-context-cli/pkg/codingcontext/markdown" ) // Selectors stores selector key-value pairs where values are stored in inner maps @@ -87,7 +89,7 @@ func (s *Selectors) GetValue(key, value string) bool { // Multiple values for the same key use OR logic (matches if frontmatter value is in the inner map). // This enables combining CLI selectors (-s flag) with task frontmatter selectors: // both are added to the same Selectors map, creating an OR condition for rules to match. -func (includes *Selectors) MatchesIncludes(frontmatter BaseFrontMatter) bool { +func (includes *Selectors) MatchesIncludes(frontmatter markdown.BaseFrontMatter) bool { for key, values := range *includes { fmValue, exists := frontmatter.Content[key] if !exists { diff --git a/pkg/codingcontext/selector_map_test.go b/pkg/codingcontext/selectors/selectors_test.go similarity index 75% rename from pkg/codingcontext/selector_map_test.go rename to pkg/codingcontext/selectors/selectors_test.go index cb19f61..c99b6b9 100644 --- a/pkg/codingcontext/selector_map_test.go +++ b/pkg/codingcontext/selectors/selectors_test.go @@ -1,7 +1,9 @@ -package codingcontext +package selectors import ( "testing" + + "github.com/kitproj/coding-context-cli/pkg/codingcontext/markdown" ) func TestSelectorMap_Set(t *testing.T) { @@ -80,61 +82,61 @@ func TestSelectorMap_MatchesIncludes(t *testing.T) { name string selectors []string setupSelectors func(s Selectors) // Optional function to set up array selectors directly - frontmatter BaseFrontMatter + frontmatter markdown.BaseFrontMatter wantMatch bool }{ { name: "single selector - match", selectors: []string{"env=production"}, - frontmatter: BaseFrontMatter{Content: map[string]any{"env": "production"}}, + frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"env": "production"}}, wantMatch: true, }, { name: "single selector - no match", selectors: []string{"env=production"}, - frontmatter: BaseFrontMatter{Content: map[string]any{"env": "development"}}, + frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"env": "development"}}, wantMatch: false, }, { name: "single selector - key missing (allowed)", selectors: []string{"env=production"}, - frontmatter: BaseFrontMatter{Content: map[string]any{"language": "go"}}, + frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"language": "go"}}, wantMatch: true, }, { name: "multiple selectors - all match", selectors: []string{"env=production", "language=go"}, - frontmatter: BaseFrontMatter{Content: map[string]any{"env": "production", "language": "go"}}, + frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"env": "production", "language": "go"}}, wantMatch: true, }, { name: "multiple selectors - one doesn't match", selectors: []string{"env=production", "language=go"}, - frontmatter: BaseFrontMatter{Content: map[string]any{"env": "production", "language": "python"}}, + frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"env": "production", "language": "python"}}, wantMatch: false, }, { name: "multiple selectors - one key missing (allowed)", selectors: []string{"env=production", "language=go"}, - frontmatter: BaseFrontMatter{Content: map[string]any{"env": "production"}}, + frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"env": "production"}}, wantMatch: true, }, { name: "empty selectors - always match", selectors: []string{}, - frontmatter: BaseFrontMatter{Content: map[string]any{"env": "production"}}, + frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"env": "production"}}, wantMatch: true, }, { name: "boolean value conversion - match", selectors: []string{"is_active=true"}, - frontmatter: BaseFrontMatter{Content: map[string]any{"is_active": true}}, + frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"is_active": true}}, wantMatch: true, }, { name: "array selector - match", selectors: []string{}, - frontmatter: BaseFrontMatter{Content: map[string]any{"rule_name": "rule2"}}, + frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"rule_name": "rule2"}}, wantMatch: true, setupSelectors: func(s Selectors) { s.SetValue("rule_name", "rule1") @@ -145,7 +147,7 @@ func TestSelectorMap_MatchesIncludes(t *testing.T) { { name: "array selector - no match", selectors: []string{}, - frontmatter: BaseFrontMatter{Content: map[string]any{"rule_name": "rule4"}}, + frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"rule_name": "rule4"}}, wantMatch: false, setupSelectors: func(s Selectors) { s.SetValue("rule_name", "rule1") @@ -156,7 +158,7 @@ func TestSelectorMap_MatchesIncludes(t *testing.T) { { name: "array selector - key missing (allowed)", selectors: []string{}, - frontmatter: BaseFrontMatter{Content: map[string]any{"env": "prod"}}, + frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"env": "prod"}}, wantMatch: true, setupSelectors: func(s Selectors) { s.SetValue("rule_name", "rule1") @@ -166,7 +168,7 @@ func TestSelectorMap_MatchesIncludes(t *testing.T) { { name: "mixed selectors - array and string both match", selectors: []string{"env=prod"}, - frontmatter: BaseFrontMatter{Content: map[string]any{"env": "prod", "rule_name": "rule1"}}, + frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"env": "prod", "rule_name": "rule1"}}, wantMatch: true, setupSelectors: func(s Selectors) { s.SetValue("rule_name", "rule1") @@ -176,7 +178,7 @@ func TestSelectorMap_MatchesIncludes(t *testing.T) { { name: "mixed selectors - string doesn't match", selectors: []string{"env=dev"}, - frontmatter: BaseFrontMatter{Content: map[string]any{"env": "prod", "rule_name": "rule1"}}, + frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"env": "prod", "rule_name": "rule1"}}, wantMatch: false, setupSelectors: func(s Selectors) { s.SetValue("rule_name", "rule1") @@ -186,7 +188,7 @@ func TestSelectorMap_MatchesIncludes(t *testing.T) { { name: "multiple array selectors - both match", selectors: []string{}, - frontmatter: BaseFrontMatter{Content: map[string]any{"rule_name": "rule1", "language": "go"}}, + frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"rule_name": "rule1", "language": "go"}}, wantMatch: true, setupSelectors: func(s Selectors) { s.SetValue("rule_name", "rule1") @@ -198,7 +200,7 @@ func TestSelectorMap_MatchesIncludes(t *testing.T) { { name: "multiple array selectors - one doesn't match", selectors: []string{}, - frontmatter: BaseFrontMatter{Content: map[string]any{"rule_name": "rule1", "language": "java"}}, + frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"rule_name": "rule1", "language": "java"}}, wantMatch: false, setupSelectors: func(s Selectors) { s.SetValue("rule_name", "rule1") @@ -210,25 +212,25 @@ func TestSelectorMap_MatchesIncludes(t *testing.T) { { name: "OR logic - same key multiple values matches", selectors: []string{"env=prod", "env=dev"}, - frontmatter: BaseFrontMatter{Content: map[string]any{"env": "dev"}}, + frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"env": "dev"}}, wantMatch: true, }, { name: "OR logic - same key multiple values no match", selectors: []string{"env=prod", "env=dev"}, - frontmatter: BaseFrontMatter{Content: map[string]any{"env": "staging"}}, + frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"env": "staging"}}, wantMatch: false, }, { name: "empty value selector - key exists in frontmatter (no match)", selectors: []string{"env="}, - frontmatter: BaseFrontMatter{Content: map[string]any{"env": "production"}}, + frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"env": "production"}}, wantMatch: false, }, { name: "empty value selector - key missing in frontmatter (match)", selectors: []string{"env="}, - frontmatter: BaseFrontMatter{Content: map[string]any{"language": "go"}}, + frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"language": "go"}}, wantMatch: true, }, } diff --git a/pkg/codingcontext/task_frontmatter.go b/pkg/codingcontext/task_frontmatter.go deleted file mode 100644 index 5934842..0000000 --- a/pkg/codingcontext/task_frontmatter.go +++ /dev/null @@ -1,62 +0,0 @@ -package codingcontext - -import ( - "encoding/json" -) - -// TaskFrontMatter represents the standard frontmatter fields for task files -type TaskFrontMatter struct { - BaseFrontMatter `yaml:",inline"` - - // Agent specifies the default agent if not specified via -a flag - // This is not used for selecting tasks or rules, only as a default - Agent string `yaml:"agent,omitempty" json:"agent,omitempty"` - - // Languages specifies the programming language(s) for filtering rules - // Array of languages for OR logic (e.g., ["go", "python"]) - Languages []string `yaml:"languages,omitempty" json:"languages,omitempty"` - - // Model specifies the AI model identifier - // Does not filter rules, metadata only - Model string `yaml:"model,omitempty" json:"model,omitempty"` - - // SingleShot indicates whether the task runs once or multiple times - // Does not filter rules, metadata only - SingleShot bool `yaml:"single_shot,omitempty" json:"single_shot,omitempty"` - - // Timeout specifies the task timeout in time.Duration format (e.g., "10m", "1h") - // Does not filter rules, metadata only - Timeout string `yaml:"timeout,omitempty" json:"timeout,omitempty"` - - // Resume indicates if this task should be resumed - Resume bool `yaml:"resume,omitempty" json:"resume,omitempty"` - - // Selectors contains additional custom selectors for filtering rules - Selectors map[string]any `yaml:"selectors,omitempty" json:"selectors,omitempty"` - - // ExpandParams controls whether parameter expansion should occur - // Defaults to true if not specified - ExpandParams *bool `yaml:"expand,omitempty" json:"expand,omitempty"` -} - -// UnmarshalJSON custom unmarshaler that populates both typed fields and Content map -func (t *TaskFrontMatter) UnmarshalJSON(data []byte) error { - // First unmarshal into a temporary type to avoid infinite recursion - type Alias TaskFrontMatter - aux := &struct { - *Alias - }{ - Alias: (*Alias)(t), - } - - if err := json.Unmarshal(data, aux); err != nil { - return err - } - - // Also unmarshal into Content map - if err := json.Unmarshal(data, &t.BaseFrontMatter.Content); err != nil { - return err - } - - return nil -} diff --git a/pkg/codingcontext/task_parser.go b/pkg/codingcontext/task_parser.go deleted file mode 100644 index 232b8e5..0000000 --- a/pkg/codingcontext/task_parser.go +++ /dev/null @@ -1,188 +0,0 @@ -package codingcontext - -import ( - "strconv" - "strings" - - "github.com/alecthomas/participle/v2" - "github.com/alecthomas/participle/v2/lexer" -) - -// Input is the top-level wrapper for parsing -type Input struct { - Blocks []Block `parser:"@@*"` -} - -// Task represents a parsed task, which is a sequence of blocks -type Task []Block - -// Block represents either a slash command or text content -type Block struct { - SlashCommand *SlashCommand `parser:"@@"` - Text *Text `parser:"| @@"` -} - -// SlashCommand represents a command starting with "/" that ends with a newline or EOF -// The newline is optional to handle EOF, but when present, prevents matching inline slashes -type SlashCommand struct { - Name string `parser:"Slash @Term"` - Arguments []Argument `parser:"(Whitespace @@)* Whitespace? Newline?"` -} - -// Params converts the slash command's arguments into a parameter map -// Returns a map with: -// - "ARGUMENTS": space-separated string of all arguments -// - "1", "2", etc.: positional parameters (1-indexed) -// - named parameters: key-value pairs from key="value" arguments -func (s *SlashCommand) Params() map[string]string { - params := make(map[string]string) - - // Build the ARGUMENTS string from all arguments - if len(s.Arguments) > 0 { - var argStrings []string - for _, arg := range s.Arguments { - if arg.Key != "" { - // Named parameter: key="value" - argStrings = append(argStrings, arg.Key+"="+arg.Value) - } else { - // Positional parameter - argStrings = append(argStrings, arg.Value) - } - } - params["ARGUMENTS"] = strings.Join(argStrings, " ") - } - - // Add positional and named parameters - for i, arg := range s.Arguments { - // Positional parameter (1-indexed) - posKey := strconv.Itoa(i + 1) - if arg.Key != "" { - // This is a named parameter - store as key="value" for positional - params[posKey] = arg.Key + "=" + arg.Value - // Also store the value under the key name (strip quotes if present) - params[arg.Key] = stripQuotes(arg.Value) - } else { - // Pure positional parameter (strip quotes if present) - params[posKey] = stripQuotes(arg.Value) - } - } - - return params -} - -// stripQuotes removes surrounding double quotes from a string if present. -// Single quotes are not supported as the grammar only allows double-quoted strings. -func stripQuotes(s string) string { - if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' { - // Remove quotes and handle escaped quotes inside - unquoted := s[1 : len(s)-1] - return strings.ReplaceAll(unquoted, `\"`, `"`) - } - return s -} - -// Argument represents either a named (key=value) or positional argument -type Argument struct { - Key string `parser:"(@Term Assign)?"` - Value string `parser:"(@String | @Term)"` -} - -// Text represents a block of text -// It can span multiple lines, consuming line content and newlines -// But it will stop before a newline that's followed by a slash (potential command) -type Text struct { - Lines []TextLine `parser:"@@+"` -} - -// TextLine is a single line of text content (not starting with a slash) -// It matches tokens until the end of the line -type TextLine struct { - NonSlashStart []string `parser:"(@Term | @String | @Assign | @Whitespace)"` // First token can't be Slash - RestOfLine []string `parser:"(@Term | @String | @Slash | @Assign | @Whitespace)*"` // Rest can include Slash - NewlineOpt string `parser:"@Newline?"` -} - -// Content returns the text content with all lines concatenated -func (t *Text) Content() string { - var sb strings.Builder - for _, line := range t.Lines { - for _, tok := range line.NonSlashStart { - sb.WriteString(tok) - } - for _, tok := range line.RestOfLine { - sb.WriteString(tok) - } - sb.WriteString(line.NewlineOpt) - } - return sb.String() -} - -// Define the lexer using participle's lexer.MustSimple -var taskLexer = lexer.MustSimple([]lexer.SimpleRule{ - {Name: "Slash", Pattern: `/`}, // Any "/" - {Name: "Assign", Pattern: `=`}, // "=" - {Name: "String", Pattern: `"(?:\\.|[^"])*"`}, // Quoted strings with escapes - {Name: "Whitespace", Pattern: `[ \t]+`}, // Spaces and tabs (horizontal only) - {Name: "Newline", Pattern: `[\n\r]+`}, // Newlines - {Name: "Term", Pattern: `[^ \t\n\r/"=]+`}, // Any char except space, newline, /, ", = -}) - -var parser = participle.MustBuild[Input]( - participle.Lexer(taskLexer), - participle.UseLookahead(4), // Use lookahead to help distinguish Text from SlashCommand patterns -) - -// ParseTask parses a task string into a Task structure -func ParseTask(text string) (Task, error) { - input, err := parser.ParseString("", text) - if err != nil { - return nil, err - } - return Task(input.Blocks), nil -} - -// String returns the original text representation of a task -func (t Task) String() string { - var sb strings.Builder - for _, block := range t { - sb.WriteString(block.String()) - } - return sb.String() -} - -// String returns the original text representation of a block -func (b Block) String() string { - if b.SlashCommand != nil { - return b.SlashCommand.String() - } - if b.Text != nil { - return b.Text.String() - } - return "" -} - -// String returns the original text representation of a slash command -func (s SlashCommand) String() string { - var sb strings.Builder - sb.WriteString("/") - sb.WriteString(s.Name) - for _, arg := range s.Arguments { - sb.WriteString(" ") - sb.WriteString(arg.String()) - } - sb.WriteString("\n") - return sb.String() -} - -// String returns the original text representation of an argument -func (a Argument) String() string { - if a.Key != "" { - return a.Key + "=" + a.Value - } - return a.Value -} - -// String returns the original text representation of text -func (t Text) String() string { - return t.Content() -} diff --git a/pkg/codingcontext/expander.go b/pkg/codingcontext/taskparser/expander.go similarity index 84% rename from pkg/codingcontext/expander.go rename to pkg/codingcontext/taskparser/expander.go index e696e16..4f3baee 100644 --- a/pkg/codingcontext/expander.go +++ b/pkg/codingcontext/taskparser/expander.go @@ -1,8 +1,7 @@ -package codingcontext +package taskparser import ( "fmt" - "log/slog" "os" "os/exec" "strings" @@ -14,7 +13,7 @@ import ( // 3. Path expansion: @path // SECURITY: Processes rune-by-rune to prevent injection attacks where expanded // content contains further expansion sequences (e.g., command output with ${param}). -func expand(content string, params map[string]string, logger *slog.Logger) string { +func (p Params) Expand(content string) (string, error) { var result strings.Builder result.Grow(len(content)) runes := []rune(content) @@ -31,10 +30,9 @@ func expand(content string, params map[string]string, logger *slog.Logger) strin if end < len(runes) { // Extract parameter name paramName := string(runes[i+2 : end]) - if val, ok := params[paramName]; ok { + if val, ok := p.Lookup(paramName); ok { result.WriteString(val) } else { - logger.Warn("parameter not found", "param", paramName) result.WriteString(string(runes[i : end+1])) } i = end + 1 @@ -53,10 +51,7 @@ func expand(content string, params map[string]string, logger *slog.Logger) strin // Extract command command := string(runes[i+2 : end]) cmd := exec.Command("sh", "-c", command) - output, err := cmd.CombinedOutput() - if err != nil { - logger.Warn("command expansion failed", "command", command, "error", err) - } + output, _ := cmd.CombinedOutput() // Write command output (even if command failed, output may contain error info) result.WriteString(string(output)) i = end + 1 @@ -89,8 +84,7 @@ func expand(content string, params map[string]string, logger *slog.Logger) strin path := unescapePath(string(runes[pathStart:pathEnd])) // Validate the path - if err := validatePath(path); err != nil { - logger.Warn("path validation failed", "path", path, "error", err) + if err := ValidatePath(path); err != nil { // Return the original @path if validation fails result.WriteString(string(runes[i:pathEnd])) i = pathEnd @@ -100,7 +94,6 @@ func expand(content string, params map[string]string, logger *slog.Logger) strin // Read the file fileContent, err := os.ReadFile(path) if err != nil { - logger.Warn("path expansion failed", "path", path, "error", err) // Return the original @path if file doesn't exist result.WriteString(string(runes[i:pathEnd])) } else { @@ -118,7 +111,7 @@ func expand(content string, params map[string]string, logger *slog.Logger) strin i++ } - return result.String() + return result.String(), nil } // isWhitespaceRune checks if a rune is whitespace (space, tab, newline, carriage return) @@ -131,11 +124,11 @@ func unescapePath(path string) string { return strings.ReplaceAll(path, "\\ ", " ") } -// validatePath validates a file path for basic safety checks. +// ValidatePath validates a file path for basic safety checks. // Note: This tool is designed to work with user-created markdown files in their // workspace and grants read access to files the user can read. The primary // defense is that users should only use trusted markdown files. -func validatePath(path string) error { +func ValidatePath(path string) error { // Check for null bytes which are never valid in file paths if strings.Contains(path, "\x00") { return fmt.Errorf("path contains null byte") diff --git a/pkg/codingcontext/expander_test.go b/pkg/codingcontext/taskparser/expander_test.go similarity index 81% rename from pkg/codingcontext/expander_test.go rename to pkg/codingcontext/taskparser/expander_test.go index 663e00e..ad5d325 100644 --- a/pkg/codingcontext/expander_test.go +++ b/pkg/codingcontext/taskparser/expander_test.go @@ -1,77 +1,79 @@ -package codingcontext +package taskparser_test import ( - "log/slog" "os" "path/filepath" "strings" "testing" + + "github.com/kitproj/coding-context-cli/pkg/codingcontext/taskparser" + "github.com/stretchr/testify/require" ) func TestExpandParameters(t *testing.T) { tests := []struct { name string - params Params + params taskparser.Params content string expected string }{ { name: "single parameter expansion", - params: Params{"name": "Alice"}, + params: taskparser.Params{"name": []string{"Alice"}}, content: "Hello, ${name}!", expected: "Hello, Alice!", }, { name: "multiple parameter expansions", - params: Params{"first": "John", "last": "Doe"}, + params: taskparser.Params{"first": []string{"John"}, "last": []string{"Doe"}}, content: "${first} ${last}", expected: "John Doe", }, { name: "parameter not found - returns unchanged with warning", - params: Params{}, + params: taskparser.Params{}, content: "Value: ${missing}", expected: "Value: ${missing}", }, { name: "mixed found and not found parameters", - params: Params{"found": "yes"}, + params: taskparser.Params{"found": []string{"yes"}}, content: "${found} and ${notfound}", expected: "yes and ${notfound}", }, { name: "no parameters to expand", - params: Params{"key": "value"}, + params: taskparser.Params{"key": []string{"value"}}, content: "Plain text without parameters", expected: "Plain text without parameters", }, { name: "parameter with special characters", - params: Params{"path": "/tmp/file.txt"}, + params: taskparser.Params{"path": []string{"/tmp/file.txt"}}, content: "File: ${path}", expected: "File: /tmp/file.txt", }, { name: "unclosed parameter - treated as literal", - params: Params{"name": "value"}, + params: taskparser.Params{"name": []string{"value"}}, content: "Text ${name and more", expected: "Text ${name and more", }, { name: "empty parameter name - expands to empty", - params: Params{"": "value"}, + params: taskparser.Params{"": []string{"value"}}, content: "Text ${} more", expected: "Text value more", }, { name: "parameter at end of string", - params: Params{"end": "final"}, + params: taskparser.Params{"end": []string{"final"}}, content: "Start ${end}", expected: "Start final", }, { name: "nested braces - outer takes precedence", - params: Params{"outer": "value"}, + params: taskparser.Params{"outer": []string{"value"}}, content: "${outer{inner}}", expected: "${outer{inner}}", }, @@ -79,7 +81,10 @@ func TestExpandParameters(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := expand(tt.content, tt.params, slog.New(slog.NewTextHandler(os.Stderr, nil))) + result, err := tt.params.Expand(tt.content) + if err != nil { + t.Errorf("expand() = %v, want nil", err) + } if result != tt.expected { t.Errorf("expand() = %q, want %q", result, tt.expected) } @@ -158,7 +163,8 @@ func TestExpandCommands(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := expand(tt.content, Params{}, slog.New(slog.NewTextHandler(os.Stderr, nil))) + result, err := (taskparser.Params{}).Expand(tt.content) + require.NoError(t, err) if tt.contains != "" { if !strings.Contains(result, tt.contains) { t.Errorf("expand() = %q, should contain %q", result, tt.contains) @@ -178,17 +184,17 @@ func TestExpandPaths(t *testing.T) { // Create test files testFile1 := filepath.Join(tmpDir, "test1.txt") - if err := os.WriteFile(testFile1, []byte("content1"), 0644); err != nil { + if err := os.WriteFile(testFile1, []byte("content1"), 0o644); err != nil { t.Fatalf("failed to create test file: %v", err) } testFile2 := filepath.Join(tmpDir, "test2.txt") - if err := os.WriteFile(testFile2, []byte("content2"), 0644); err != nil { + if err := os.WriteFile(testFile2, []byte("content2"), 0o644); err != nil { t.Fatalf("failed to create test file: %v", err) } testFileWithSpace := filepath.Join(tmpDir, "test file.txt") - if err := os.WriteFile(testFileWithSpace, []byte("spaced content"), 0644); err != nil { + if err := os.WriteFile(testFileWithSpace, []byte("spaced content"), 0o644); err != nil { t.Fatalf("failed to create test file with space: %v", err) } @@ -256,7 +262,8 @@ func TestExpandPaths(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := expand(tt.content, Params{}, slog.New(slog.NewTextHandler(os.Stderr, nil))) + result, err := (taskparser.Params{}).Expand(tt.content) + require.NoError(t, err) if result != tt.expected { t.Errorf("expand() = %q, want %q", result, tt.expected) } @@ -269,43 +276,43 @@ func TestExpandCombined(t *testing.T) { tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, "data.txt") - if err := os.WriteFile(testFile, []byte("file-${param}"), 0644); err != nil { + if err := os.WriteFile(testFile, []byte("file-${param}"), 0o644); err != nil { t.Fatalf("failed to create test file: %v", err) } tests := []struct { name string - params Params + params taskparser.Params content string expected string }{ { name: "combined expansions - command, path, parameter", - params: Params{"name": "World"}, + params: taskparser.Params{"name": []string{"World"}}, content: "!`echo Hello` ${name} from @" + testFile, expected: "Hello\n World from file-${param}", }, { name: "file content NOT re-expanded (security fix)", - params: Params{"param": "replaced"}, + params: taskparser.Params{"param": []string{"replaced"}}, content: "@" + testFile, expected: "file-${param}", // Changed: file content is not re-expanded }, { name: "command output NOT re-expanded (security fix)", - params: Params{"dynamic": "value"}, + params: taskparser.Params{"dynamic": []string{"value"}}, content: "!`echo '${dynamic}'`", expected: "${dynamic}\n", // Changed: command output is not re-expanded }, { name: "all expansion types together", - params: Params{"x": "X", "y": "Y"}, + params: taskparser.Params{"x": []string{"X"}, "y": []string{"Y"}}, content: "${x} !`echo middle` ${y}", expected: "X middle\n Y", }, { name: "no expansions needed", - params: Params{}, + params: taskparser.Params{}, content: "plain text", expected: "plain text", }, @@ -313,7 +320,8 @@ func TestExpandCombined(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := expand(tt.content, tt.params, slog.New(slog.NewTextHandler(os.Stderr, nil))) + result, err := tt.params.Expand(tt.content) + require.NoError(t, err) if result != tt.expected { t.Errorf("expand() = %q, want %q", result, tt.expected) } @@ -324,10 +332,10 @@ func TestExpandCombined(t *testing.T) { func TestExpandBasic(t *testing.T) { // Test basic expansion functionality content := "Hello ${name}!" - params := Params{"name": "World"} - logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) + params := taskparser.Params{"name": []string{"World"}} - result := expand(content, params, logger) + result, err := params.Expand(content) + require.NoError(t, err) expected := "Hello World!" if result != expected { t.Errorf("expand() = %q, want %q", result, expected) @@ -374,7 +382,7 @@ func TestValidatePath(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := validatePath(tt.path) + err := taskparser.ValidatePath(tt.path) if (err != nil) != tt.wantErr { t.Errorf("validatePath() error = %v, wantErr %v", err, tt.wantErr) } @@ -385,35 +393,35 @@ func TestValidatePath(t *testing.T) { func TestExpandSecurityNoReExpansion(t *testing.T) { tests := []struct { name string - params Params + params taskparser.Params content string expected string desc string }{ { name: "parameter value with command syntax not expanded", - params: Params{"evil": "!`echo INJECTED`"}, + params: taskparser.Params{"evil": []string{"!`echo INJECTED`"}}, content: "Value: ${evil}", expected: "Value: !`echo INJECTED`", desc: "Parameter containing command syntax should not be executed", }, { name: "parameter value with path syntax not expanded", - params: Params{"path": "@/etc/passwd"}, + params: taskparser.Params{"path": []string{"@/etc/passwd"}}, content: "Path: ${path}", expected: "Path: @/etc/passwd", desc: "Parameter containing path syntax should not be read", }, { name: "command output with parameter syntax not expanded", - params: Params{"secret": "SECRET"}, + params: taskparser.Params{"secret": []string{"SECRET"}}, content: "!`echo '${secret}'`", expected: "${secret}\n", desc: "Command output containing parameter syntax should not be expanded", }, { name: "command output with path syntax not expanded", - params: Params{}, + params: taskparser.Params{}, content: "!`echo '@/etc/passwd'`", expected: "@/etc/passwd\n", desc: "Command output containing path syntax should not be read", @@ -422,7 +430,8 @@ func TestExpandSecurityNoReExpansion(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := expand(tt.content, tt.params, slog.New(slog.NewTextHandler(os.Stderr, nil))) + result, err := tt.params.Expand(tt.content) + require.NoError(t, err) if result != tt.expected { t.Errorf("Security test failed: %s\nexpand() = %q, want %q", tt.desc, result, tt.expected) } diff --git a/pkg/codingcontext/taskparser/grammar.go b/pkg/codingcontext/taskparser/grammar.go new file mode 100644 index 0000000..c378e85 --- /dev/null +++ b/pkg/codingcontext/taskparser/grammar.go @@ -0,0 +1,128 @@ +package taskparser + +import ( + "github.com/alecthomas/participle/v2" + "github.com/alecthomas/participle/v2/lexer" +) + +// Task represents a parsed task, which is a sequence of blocks +type Task []Block + +// Input is the top-level wrapper for parsing +type Input struct { + Blocks []Block `parser:"@@*"` +} + +// Block represents either a slash command or text content +type Block struct { + SlashCommand *SlashCommand `parser:"@@"` + Text *Text `parser:"| @@"` +} + +// SlashCommand represents a command starting with "/" that ends with a newline or EOF +// The newline is optional to handle EOF, but when present, prevents matching inline slashes +// Leading whitespace is optional to allow indented commands +type SlashCommand struct { + LeadingWhitespace string `parser:"Whitespace?"` + Name string `parser:"Slash @Term"` + Arguments []Argument `parser:"(Whitespace @@)* Whitespace? Newline?"` +} + +// Argument represents either a named (key=value) or positional argument +type Argument struct { + Key string `parser:"(@Term Assign)?"` + Value string `parser:"(@String | @Term)"` +} + +// Text represents a block of text +// It can span multiple lines, consuming line content and newlines +// But it will stop before a newline that's followed by a slash (potential command) +type Text struct { + Lines []TextLine `parser:"@@+"` +} + +// TextLine is a single line of text content (not starting with a slash) +// It matches tokens until the end of the line +type TextLine struct { + NonSlashStart []string `parser:"(@Term | @String | @Assign | @Whitespace)"` // First token can't be Slash + RestOfLine []string `parser:"(@Term | @String | @Slash | @Assign | @Whitespace)*"` // Rest can include Slash + NewlineOpt string `parser:"@Newline?"` +} + +// Define the lexer using participle's lexer.MustSimple +var taskLexer = lexer.MustSimple([]lexer.SimpleRule{ + {Name: "Slash", Pattern: `/`}, // Any "/" + {Name: "Assign", Pattern: `=`}, // "=" + {Name: "String", Pattern: `"(?:\\.|[^"])*"`}, // Quoted strings with escapes + {Name: "Whitespace", Pattern: `[ \t]+`}, // Spaces and tabs (horizontal only) + {Name: "Newline", Pattern: `[\n\r]+`}, // Newlines + {Name: "Term", Pattern: `[^ \t\n\r/"=]+`}, // Any char except space, newline, /, ", = +}) + +func parser() *participle.Parser[Input] { + return participle.MustBuild[Input]( + participle.Lexer(taskLexer), + participle.UseLookahead(4), // Use lookahead to help distinguish Text from SlashCommand patterns + ) +} + +// ========== PARAMS GRAMMAR ========== + +// ParamsInput is the top-level structure for parsing parameters +// It now directly parses into named parameters and positional arguments +type ParamsInput struct { + Items []ParamsItem `parser:"@@*"` +} + +// ParamsItem represents either a named parameter or a positional argument +type ParamsItem struct { + Pos lexer.Position + Separator *Separator `parser:"@@"` // Whitespace or comma + Named *NamedParam `parser:"| @@"` // key=value + Positional *Value `parser:"| @@"` // standalone value +} + +// Separator represents whitespace or comma separators +type Separator struct { + Pos lexer.Position + Val string `parser:"(@Whitespace | @Comma)"` +} + +// NamedParam represents a key=value pair +type NamedParam struct { + Pos lexer.Position + Key string `parser:"@Token"` + PreEqualsSpace *string `parser:"Whitespace?"` + Equals string `parser:"@Assign"` + PostEqualsSpace *string `parser:"Whitespace?"` + Value *Value `parser:"@@?"` // Optional to handle empty values like key= +} + +// Value represents a parsed value (quoted or unquoted) +// The Raw field captures the entire token including quotes and escapes +type Value struct { + Pos lexer.Position + Raw string `parser:"(@QuotedDouble | @QuotedSingle | @Token)"` +} + +// paramsLexer defines the lexer for parsing parameters +// Using a simpler approach: capture entire quoted strings and unquoted tokens +var paramsLexer = lexer.MustSimple([]lexer.SimpleRule{ + {Name: "Whitespace", Pattern: `[\s\p{Z}]+`}, // Match ASCII and Unicode whitespace + {Name: "Comma", Pattern: `,`}, + {Name: "Assign", Pattern: `=`}, + // Quoted strings - capture entire string including quotes + // Handles escaped quotes inside + {Name: "QuotedDouble", Pattern: `"(?:\\.|[^"\\])*"`}, + {Name: "QuotedSingle", Pattern: `'(?:\\.|[^'\\])*'`}, + // Unquoted token - matches any sequence of non-delimiter characters + // Can contain escape sequences + {Name: "Token", Pattern: `(?:\\.|[^\s\p{Z},="'\\])+`}, +}) + +func paramsParser() *participle.Parser[ParamsInput] { + return participle.MustBuild[ParamsInput]( + participle.Lexer(paramsLexer), + participle.UseLookahead(3), // Lookahead to distinguish key=value from positional + ) +} diff --git a/pkg/codingcontext/param_map_test.go b/pkg/codingcontext/taskparser/param_map_test.go similarity index 50% rename from pkg/codingcontext/param_map_test.go rename to pkg/codingcontext/taskparser/param_map_test.go index 46a8297..178e629 100644 --- a/pkg/codingcontext/param_map_test.go +++ b/pkg/codingcontext/taskparser/param_map_test.go @@ -1,8 +1,12 @@ -package codingcontext +package taskparser_test import ( "strings" "testing" + + "github.com/kitproj/coding-context-cli/pkg/codingcontext/taskparser" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestParams_Set(t *testing.T) { @@ -21,8 +25,8 @@ func TestParams_Set(t *testing.T) { wantErr: false, }, { - name: "key=value with equals in value", - value: "key=value=with=equals", + name: "key=value with equals in value (requires quotes)", + value: `key="value=with=equals"`, wantKey: "key", wantVal: "value=with=equals", wantErr: false, @@ -35,30 +39,36 @@ func TestParams_Set(t *testing.T) { wantErr: false, }, { - name: "invalid format - no equals", + name: "positional argument - no equals", value: "keyvalue", - wantErr: true, + wantVal: "keyvalue", + wantErr: false, }, { - name: "invalid format - only key", + name: "positional argument - only key", value: "key", - wantErr: true, + wantVal: "key", + wantErr: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - p := Params{} + p := taskparser.Params{} err := p.Set(tt.value) - if (err != nil) != tt.wantErr { - t.Errorf("Params.Set() error = %v, wantErr %v", err, tt.wantErr) - return - } - - if !tt.wantErr { - if p[tt.wantKey] != tt.wantVal { - t.Errorf("Params[%q] = %q, want %q", tt.wantKey, p[tt.wantKey], tt.wantVal) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + if tt.wantKey != "" { + // Named parameter + assert.Equal(t, tt.wantVal, p.Value(tt.wantKey)) + } else { + // Positional argument + args := p.Arguments() + require.NotEmpty(t, args, "expected positional arguments") + assert.Equal(t, tt.wantVal, args[0]) } } }) @@ -66,9 +76,9 @@ func TestParams_Set(t *testing.T) { } func TestParams_String(t *testing.T) { - p := Params{ - "key1": "value1", - "key2": "value2", + p := taskparser.Params{ + "key1": []string{"value1"}, + "key2": []string{"value2"}, } s := p.String() if s == "" { @@ -77,99 +87,86 @@ func TestParams_String(t *testing.T) { } func TestParams_SetMultiple(t *testing.T) { - p := Params{} - - if err := p.Set("key1=value1"); err != nil { - t.Fatalf("Params.Set() failed: %v", err) - } - if err := p.Set("key2=value2"); err != nil { - t.Fatalf("Params.Set() failed: %v", err) - } - - if len(p) != 2 { - t.Errorf("Params length = %d, want 2", len(p)) - } - if p["key1"] != "value1" { - t.Errorf("Params[key1] = %q, want %q", p["key1"], "value1") - } - if p["key2"] != "value2" { - t.Errorf("Params[key2] = %q, want %q", p["key2"], "value2") - } + p, err := taskparser.ParseParams("key1=value1, key2=value2") + require.NoError(t, err) + assert.Len(t, p, 2) + assert.Equal(t, "value1", p.Value("key1")) + assert.Equal(t, "value2", p.Value("key2")) } func TestParseParams(t *testing.T) { tests := []struct { name string input string - expected Params + expected taskparser.Params wantError bool errorMsg string }{ { name: "empty string", input: "", - expected: Params{}, + expected: taskparser.Params{}, wantError: false, }, { name: "single quoted key=value", input: `key="value"`, - expected: Params{"key": "value"}, + expected: taskparser.Params{"key": []string{"value"}}, wantError: false, }, { name: "multiple quoted key=value pairs", input: `key1="value1" key2="value2" key3="value3"`, - expected: Params{"key1": "value1", "key2": "value2", "key3": "value3"}, + expected: taskparser.Params{"key1": []string{"value1"}, "key2": []string{"value2"}, "key3": []string{"value3"}}, wantError: false, }, { name: "double-quoted value with spaces", input: `key1="value with spaces" key2="value2"`, - expected: Params{"key1": "value with spaces", "key2": "value2"}, + expected: taskparser.Params{"key1": []string{"value with spaces"}, "key2": []string{"value2"}}, wantError: false, }, { name: "escaped double quotes", input: `key1="value with \"escaped\" quotes"`, - expected: Params{"key1": `value with "escaped" quotes`}, + expected: taskparser.Params{"key1": []string{`value with "escaped" quotes`}}, wantError: false, }, { name: "value with equals sign in quotes", input: `key1="value=with=equals" key2="normal"`, - expected: Params{"key1": "value=with=equals", "key2": "normal"}, + expected: taskparser.Params{"key1": []string{"value=with=equals"}, "key2": []string{"normal"}}, wantError: false, }, { name: "empty quoted value", input: `key1="" key2="value2"`, - expected: Params{"key1": "", "key2": "value2"}, + expected: taskparser.Params{"key1": []string{""}, "key2": []string{"value2"}}, wantError: false, }, { name: "whitespace around equals", input: `key1 = "value1" key2="value2"`, - expected: Params{"key1": "value1", "key2": "value2"}, + expected: taskparser.Params{"key1": []string{"value1"}, "key2": []string{"value2"}}, wantError: false, }, { name: "quoted value with spaces and equals", input: `key1="value with spaces and = signs"`, - expected: Params{"key1": "value with spaces and = signs"}, + expected: taskparser.Params{"key1": []string{"value with spaces and = signs"}}, wantError: false, }, { name: "unquoted value - error", input: `key1=value1`, - wantError: true, - errorMsg: "unquoted value", + expected: taskparser.Params{"key1": []string{"value1"}}, + wantError: false, }, { - name: "mixed quoted and unquoted - error", + name: "mixed quoted and unquoted", input: `key1="quoted value" key2=unquoted`, - wantError: true, - errorMsg: "unquoted value", + expected: taskparser.Params{"key1": []string{"quoted value"}, "key2": []string{"unquoted"}}, + wantError: false, }, { name: "unclosed quote - error", @@ -178,30 +175,26 @@ func TestParseParams(t *testing.T) { errorMsg: "unclosed quote", }, { - name: "missing value after equals - error", - input: `key1= key2="value2"`, - wantError: true, - errorMsg: "unquoted value", + name: "missing value after equals with comma separator", + input: `key1=, key2="value2"`, + expected: taskparser.Params{"key1": []string{}, "key2": []string{"value2"}}, + wantError: false, }, { - name: "single quote not supported - error", + name: "single quotes", input: `key1='value'`, - wantError: true, - errorMsg: "unquoted value", + expected: taskparser.Params{"key1": []string{"value"}}, + wantError: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result, err := ParseParams(tt.input) - - if (err != nil) != tt.wantError { - t.Errorf("ParseParams() error = %v, wantError %v", err, tt.wantError) - return - } + result, err := taskparser.ParseParams(tt.input) if tt.wantError { - if err != nil && tt.errorMsg != "" { + require.Error(t, err) + if tt.errorMsg != "" { if !strings.Contains(err.Error(), tt.errorMsg) { t.Errorf("ParseParams() error = %v, want error containing %q", err, tt.errorMsg) } @@ -209,15 +202,10 @@ func TestParseParams(t *testing.T) { return } - if len(result) != len(tt.expected) { - t.Errorf("ParseParams() length = %d, want %d", len(result), len(tt.expected)) - return - } - + require.NoError(t, err) + assert.Len(t, result, len(tt.expected)) for k, v := range tt.expected { - if result[k] != v { - t.Errorf("ParseParams()[%q] = %q, want %q", k, result[k], v) - } + assert.Equal(t, v, result.Values(k)) } }) } diff --git a/pkg/codingcontext/taskparser/params.go b/pkg/codingcontext/taskparser/params.go new file mode 100644 index 0000000..8e50712 --- /dev/null +++ b/pkg/codingcontext/taskparser/params.go @@ -0,0 +1,479 @@ +package taskparser + +import ( + "errors" + "fmt" + "maps" + "strconv" + "strings" +) + +const ( + // ArgumentsKey is the key used to store positional arguments in Params + ArgumentsKey = "ARGUMENTS" +) + +var ( + // ErrEmptyKey is returned when a parameter key is empty + ErrEmptyKey = errors.New("empty key in parameter") + // ErrInvalidEscapeSequence is returned when an escape sequence is invalid + ErrInvalidEscapeSequence = errors.New("invalid escape sequence") + // ErrInvalidFormat is returned when the input format is invalid + ErrInvalidFormat = errors.New("invalid parameter format: missing '='") + // ErrMismatchedQuotes is returned when quotes don't match + ErrMismatchedQuotes = errors.New("mismatched quote types") + // ErrUnclosedQuote is returned when a quoted string is not properly closed + ErrUnclosedQuote = errors.New("unclosed quote") +) + +// Params is a map of string keys to string slice values with +// convenience methods for accessing single and multiple values. +type Params map[string][]string + +// ParseParams parses a parameter string into a Params map that supports both +// named parameters (key-value pairs) and positional arguments. +// +// The function provides a flexible, permissive parser that handles various +// quoting styles, escape sequences, and separators. +// +// Named Parameters: +// +// Basic syntax: key=value +// Multiple pairs can be separated by commas, spaces, or both +// Whitespace around the = sign is optional +// Keys are case-insensitive (converted to lowercase) +// The same key can appear multiple times; all values are collected +// +// Examples: +// "key=value" +// "key=value,foo=bar" +// "key = value, foo = bar" +// "key=value1 key=value2 key=value3" // Multiple values for same key +// +// Positional Arguments: +// +// Values without a key are treated as positional arguments and stored +// under the ArgumentsKey constant ("ARGUMENTS"). +// Positional and named parameters can be interleaved. +// +// Examples: +// "value" +// "value1 value2 value3" +// "value1, value2, value3" +// "key=value positional1 positional2" +// "positional1 key=value positional2" +// +// Quoted Values: +// +// Both single and double quotes are supported for values containing +// special characters. Quotes can be escaped within matching quote types. +// Empty quoted values create a value with an empty string. +// +// Examples: +// `key="string value"` +// `key='string value'` +// `key="value=with=equals"` +// `key="value,with,commas"` +// `key="bar\"baz\""` // Escaped quotes +// +// Unquoted Values: +// +// Unquoted values cannot contain spaces (spaces separate arguments). +// Values containing =, ,, or spaces should be quoted. +// Trailing whitespace is trimmed from unquoted values. +// +// Escape Sequences: +// +// Escape sequences work in both quoted and unquoted contexts: +// Standard: \n (newline), \t (tab), \r (carriage return), \\ +// (backslash), \" (double quote), \' (single quote) +// Numeric: \xHH (hex), \uHHHH (Unicode), \OOO (octal, 1-3 digits) +// Other: Any other escape returns the character after backslash +// +// Examples: +// `key="line1\nline2\ttabbed"` +// `key="\x41\x42"` // "AB" +// `key="\u00a0"` // Non-breaking space +// +// Separators: +// +// Multiple separators are supported: commas, spaces, or both. +// Trailing separators are ignored. +// +// Examples: +// "key=value,foo=bar" +// "key=value foo=bar" +// "key=value, foo=bar, baz=qux" +// +// Empty Values: +// +// Unquoted empty: key= creates an empty slice []string{} +// Quoted empty: key="" or key='' creates []string{""} +// +// Unicode Support: +// +// Full Unicode and UTF-8 support for keys and values. +// Unicode whitespace is recognized as separators. +// All unicode whitespace is automatically trimmed from start/end of values. +// +// Examples: +// "ключ=значение" +// "key=こんにちは" +// "emoji=🚀" +// +// Error Conditions: +// +// Returns errors for: +// - Unclosed quotes: key="unclosed +// - Empty keys: =value +// - Invalid escape sequences: incomplete or invalid hex/unicode escapes +// - Mismatched quotes: key='value" or key="value' +// +// Return Value: +// +// The returned Params map has: +// - Named parameters: lowercase keys with string slice values +// - Positional arguments: stored under ArgumentsKey ("ARGUMENTS") +// +// Example: +// params, _ := ParseParams("key=value1 key=value2 arg1 arg2") +// // params["key"] = []string{"value1", "value2"} +// // params[ArgumentsKey] = []string{"arg1", "arg2"} +// +// See the Params type methods (Value, Values, Arguments, Lookup) for +// convenient access to parsed parameters. +func ParseParams(value string) (Params, error) { + params := make(Params) + + // Handle empty input + value = strings.TrimSpace(value) + if value == "" { + return params, nil + } + + // Check for unclosed quotes + if err := validateQuotes(value); err != nil { + return nil, err + } + + // Parse using Participle + input, err := paramsParser().ParseString("", value) + if err != nil { + return nil, err + } + + // Convert parsed structure to Params map + return convertToParams(input) +} + +// convertToParams converts the parsed AST to Params map +func convertToParams(input *ParamsInput) (Params, error) { + params := make(Params) + + for _, item := range input.Items { + // Skip separators + if item.Separator != nil { + continue + } + + // Handle named parameters + if item.Named != nil { + key := strings.ToLower(item.Named.Key) + if key == "" { + return nil, ErrEmptyKey + } + + // Handle empty value (key= vs key="") + if item.Named.Value == nil { + // Empty unquoted value: key= + if params[key] == nil { + params[key] = []string{} + } + } else { + value, wasQuoted, err := extractValue(item.Named.Value) + if err != nil { + return nil, err + } + + // Add value if quoted (even if empty) or non-empty + if wasQuoted || value != "" { + params[key] = append(params[key], value) + } else if params[key] == nil { + // Empty unquoted value + params[key] = []string{} + } + } + continue + } + + // Handle positional arguments + if item.Positional != nil { + value, _, err := extractValue(item.Positional) + if err != nil { + return nil, err + } + if params[ArgumentsKey] == nil { + params[ArgumentsKey] = []string{} + } + params[ArgumentsKey] = append(params[ArgumentsKey], value) + } + } + + return params, nil +} + +// extractValue extracts the string value from a Value node +// Returns the value, whether it was quoted, and any error +func extractValue(val *Value) (string, bool, error) { + raw := val.Raw + + // Check if it's a quoted string + if len(raw) >= 2 { + if (raw[0] == '"' && raw[len(raw)-1] == '"') || (raw[0] == '\'' && raw[len(raw)-1] == '\'') { + // Quoted value - extract content and process escapes + content := raw[1 : len(raw)-1] + processed, err := processEscapes(content) + if err != nil { + return "", true, err + } + return strings.TrimSpace(processed), true, nil + } + } + + // Unquoted value - process escapes + processed, err := processEscapes(raw) + if err != nil { + return "", false, err + } + return strings.TrimSpace(processed), false, nil +} + +// processEscapes processes all escape sequences in a string +func processEscapes(s string) (string, error) { + if !strings.Contains(s, "\\") { + // Fast path: no escapes + return s, nil + } + + var result strings.Builder + result.Grow(len(s)) // Pre-allocate + + for i := 0; i < len(s); i++ { + if s[i] != '\\' { + result.WriteByte(s[i]) + continue + } + + // Handle escape sequence + if i+1 >= len(s) { + // Incomplete escape at end - treat as literal backslash + result.WriteByte('\\') + continue + } + + next := s[i+1] + switch next { + case 'n': + result.WriteByte('\n') + i++ + case 't': + result.WriteByte('\t') + i++ + case 'r': + result.WriteByte('\r') + i++ + case '\\': + result.WriteByte('\\') + i++ + case '"': + result.WriteByte('"') + i++ + case '\'': + result.WriteByte('\'') + i++ + case 'u': + // Unicode escape: \uXXXX + if i+5 < len(s) { + hex := s[i+2 : i+6] + val, err := strconv.ParseUint(hex, 16, 16) + if err != nil { + return "", fmt.Errorf("%w: \\u%s", ErrInvalidEscapeSequence, hex) + } + result.WriteRune(rune(val)) + i += 5 + } else { + return "", fmt.Errorf("%w: incomplete \\u escape", ErrInvalidEscapeSequence) + } + case 'x': + // Hex escape: \xHH + if i+3 < len(s) { + hex := s[i+2 : i+4] + val, err := strconv.ParseUint(hex, 16, 8) + if err != nil { + return "", fmt.Errorf("%w: \\x%s", ErrInvalidEscapeSequence, hex) + } + result.WriteByte(byte(val)) + i += 3 + } else { + return "", fmt.Errorf("%w: incomplete \\x escape", ErrInvalidEscapeSequence) + } + case '0', '1', '2', '3', '4', '5', '6', '7': + // Octal escape: \OOO (1-3 digits) + end := i + 2 + for end < len(s) && end < i+4 && s[end] >= '0' && s[end] <= '7' { + end++ + } + octal := s[i+1 : end] + val, err := strconv.ParseUint(octal, 8, 8) + if err != nil { + return "", fmt.Errorf("%w: \\%s", ErrInvalidEscapeSequence, octal) + } + result.WriteByte(byte(val)) + i = end - 1 + default: + // Any other escape - return the character after backslash + result.WriteByte(next) + i++ + } + } + + return result.String(), nil +} + +// validateQuotes checks if all quoted strings in the input are properly closed +func validateQuotes(input string) error { + inDoubleQuote := false + inSingleQuote := false + escapeNext := false + + for _, r := range input { + if escapeNext { + escapeNext = false + continue + } + + if r == '\\' { + escapeNext = true + continue + } + + if r == '"' && !inSingleQuote { + inDoubleQuote = !inDoubleQuote + } else if r == '\'' && !inDoubleQuote { + inSingleQuote = !inSingleQuote + } + } + + if inDoubleQuote || inSingleQuote { + return ErrUnclosedQuote + } + + return nil +} + +func (p Params) Set(value string) error { + // Auto-quote values that need quoting for better CLI UX + quotedValue := autoQuoteParamValue(value) + + params, err := ParseParams(quotedValue) + if err != nil { + return err + } + + maps.Copy(p, params) + + return nil +} + +// autoQuoteParamValue automatically quotes the value part of a key=value parameter +// if it contains characters that require quoting (spaces, commas, equals, etc.) +// and is not already quoted. +func autoQuoteParamValue(input string) string { + equalsIndex := strings.IndexByte(input, '=') + if equalsIndex == -1 || equalsIndex == len(input)-1 { + return input // No = sign or empty value + } + + value := strings.TrimSpace(input[equalsIndex+1:]) + if len(value) >= 2 && ((value[0] == '"' && value[len(value)-1] == '"') || + (value[0] == '\'' && value[len(value)-1] == '\'')) { + return input // Already quoted + } + + if needsQuoting(value) { + return input[:equalsIndex+1] + strconv.Quote(value) + } + + return input +} + +// needsQuoting checks if a value contains characters that require quoting +func needsQuoting(value string) bool { + if value == "" { + return false + } + + unicodeWhitespace := "\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000" + for _, r := range value { + if r == ' ' || r == '\t' || r == '\n' || r == '\r' || + r == ',' || r == '=' || r == '"' || r == '\'' || + strings.ContainsRune(unicodeWhitespace, r) { + return true + } + } + return false +} + +func (p Params) String() string { + pairs := make([]string, 0, len(p)) + for key, values := range p { + for _, value := range values { + pairs = append(pairs, key+"="+value) + } + } + + return strings.Join(pairs, ",") +} + +// Value returns the first value for the given key, or an empty string if +// the key is not found or has no values. +func (p Params) Value(key string) string { + if p == nil { + return "" + } + values := p[strings.ToLower(key)] + if len(values) == 0 { + return "" + } + return values[0] +} + +func (p Params) Lookup(key string) (string, bool) { + if p == nil { + return "", false + } + values := p[strings.ToLower(key)] + if len(values) == 0 { + return "", false + } + return values[0], true +} + +// Values returns all values for the given key, or an empty slice if +// the key is not found. +func (p Params) Values(key string) []string { + if p == nil { + return nil + } + return p[strings.ToLower(key)] +} + +// Arguments returns all positional arguments (values without keys), or an empty slice +// if there are no positional arguments. This is distinct from named parameters +// accessed via Value() or Values(). +func (p Params) Arguments() []string { + if p == nil { + return nil + } + return p[ArgumentsKey] +} diff --git a/pkg/codingcontext/taskparser/params_test.go b/pkg/codingcontext/taskparser/params_test.go new file mode 100644 index 0000000..bdc1458 --- /dev/null +++ b/pkg/codingcontext/taskparser/params_test.go @@ -0,0 +1,789 @@ +package taskparser_test + +import ( + "testing" + + "github.com/kitproj/coding-context-cli/pkg/codingcontext/taskparser" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseTaskParameters(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + expected taskparser.Params + }{ + { + name: "empty string", + input: "", + expected: taskparser.Params{}, + }, + { + name: "whitespace only", + input: " ", + expected: taskparser.Params{}, + }, + { + name: "single pair", + input: "key=value", + expected: taskparser.Params{ + "key": {"value"}, + }, + }, + { + name: "comma separated pairs", + input: "key=value,foo=bar", + expected: taskparser.Params{ + "key": {"value"}, + "foo": {"bar"}, + }, + }, + { + name: "space separated pairs", + input: "key=value foo=bar", + expected: taskparser.Params{ + "key": {"value"}, + "foo": {"bar"}, + }, + }, + { + name: "wrapped single quotes", + input: "key=\"'value'\"", + expected: taskparser.Params{ + "key": {"'value'"}, + }, + }, + { + name: "wrapped single quotes", + input: "key='\"value\"'", + expected: taskparser.Params{ + "key": {`"value"`}, + }, + }, + { + name: "mixed separators", + input: "key1=value1, key2=value2 key3=value3", + expected: taskparser.Params{ + "key1": {"value1"}, + "key2": {"value2"}, + "key3": {"value3"}, + }, + }, + { + name: "trailing comma", + input: "key=value,", + expected: taskparser.Params{ + "key": {"value"}, + }, + }, + { + name: "multiple spaces", + input: "key=value foo=bar", + expected: taskparser.Params{ + "key": {"value"}, + "foo": {"bar"}, + }, + }, + { + name: "whitespace around equals", + input: "key = value, foo = bar", + expected: taskparser.Params{ + "key": {"value"}, + "foo": {"bar"}, + }, + }, + { + name: "non-breaking spaces trimmed from unquoted values", + input: "key=\u00a0value\u00a0, foo=\u00a0bar\u00a0", + expected: taskparser.Params{ + "key": {"value"}, + "foo": {"bar"}, + }, + }, + { + name: "non-breaking spaces trimmed from quoted values", + input: "key=\"\u00a0value\u00a0\", foo='\u00a0bar\u00a0'", + expected: taskparser.Params{ + "key": {"value"}, + "foo": {"bar"}, + }, + }, + { + name: "unicode escape sequence in quoted value", + input: `key="foo\u00a0", foo='bar\u00a0'`, + expected: taskparser.Params{ + "key": {"foo"}, + "foo": {"bar"}, + }, + }, + { + name: "unicode escape sequence in unquoted value", + input: `key=foo\u00a0, foo=bar\u00a0`, + expected: taskparser.Params{ + "key": {"foo"}, + "foo": {"bar"}, + }, + }, + { + name: "unicode escape sequence with regular characters", + input: `key="test\u00a0value", foo=hello\u0020world`, + expected: taskparser.Params{ + "key": {"test\u00a0value"}, + "foo": {"hello world"}, + }, + }, + { + name: "quoted values with double quotes", + input: `key="string value", foo="bar"`, + expected: taskparser.Params{ + "key": {"string value"}, + "foo": {"bar"}, + }, + }, + { + name: "quoted values with single quotes", + input: `key='string value', foo='bar'`, + expected: taskparser.Params{ + "key": {"string value"}, + "foo": {"bar"}, + }, + }, + { + name: "quotes with spaces around", + input: `key = "value" , foo = "bar"`, + expected: taskparser.Params{ + "key": {"value"}, + "foo": {"bar"}, + }, + }, + { + name: "escaped quotes in double quoted string", + input: `key="bar\"baz\""`, + expected: taskparser.Params{ + "key": {`bar"baz"`}, + }, + }, + { + name: "escaped quotes in single quoted string", + input: `key='bar\'baz\''`, + expected: taskparser.Params{ + "key": {`bar'baz'`}, + }, + }, + { + name: "multiple escape sequences", + input: `text="line1\nline2\ttabbed\rreturned\\backslash"`, + expected: taskparser.Params{ + "text": {"line1\nline2\ttabbed\rreturned\\backslash"}, + }, + }, + { + name: "unquoted values with spaces become positional arguments", + input: "key=value with spaces", + expected: taskparser.Params{ + "key": {"value"}, + taskparser.ArgumentsKey: {"with", "spaces"}, + }, + }, + { + name: "quoted values with spaces", + input: `key="value with spaces"`, + expected: taskparser.Params{ + "key": {"value with spaces"}, + }, + }, + { + name: "value with comma in quotes", + input: `key="value,with,commas"`, + expected: taskparser.Params{ + "key": {"value,with,commas"}, + }, + }, + { + name: "complex example from user", + input: `key="string value", foo="bar\"baz\"", multiline="line1\nline2"`, + expected: taskparser.Params{ + "key": {"string value"}, + "foo": {`bar"baz"`}, + "multiline": {"line1\nline2"}, + }, + }, + { + name: "hex escape sequence", + input: `key="\x41\x42"`, + expected: taskparser.Params{ + "key": {"AB"}, + }, + }, + { + name: "octal escape sequence", + input: `key="\101\102"`, + expected: taskparser.Params{ + "key": {"AB"}, + }, + }, + { + name: "octal escape with different lengths", + input: `key="\7\77\177"`, + expected: taskparser.Params{ + "key": {"\x07?\x7f"}, + }, + }, + { + name: "non-octal digit escape", + input: `key="\8\9\89"`, + expected: taskparser.Params{ + "key": {"8989"}, + }, + }, + { + name: "non-octal escape characters", + input: `key="\a\z\A\!"`, + expected: taskparser.Params{ + "key": {"azA!"}, + }, + }, + { + name: "mixed octal and non-octal escapes", + input: `key="\101\8\102\9"`, + expected: taskparser.Params{ + "key": {"A8B9"}, + }, + }, + { + name: "octal escape boundary", + input: `key="\08\09"`, + expected: taskparser.Params{ + "key": {"\x008\x009"}, + }, + }, + { + name: "duplicate keys", + input: "key=value1 key=value2, key=value3", + expected: taskparser.Params{ + "key": {"value1", "value2", "value3"}, + }, + }, + { + name: "multiple duplicate keys", + input: "key1=value1, key2=value2, key1=value3, key2=value4", + expected: taskparser.Params{ + "key1": {"value1", "value3"}, + "key2": {"value2", "value4"}, + }, + }, + { + name: "case folding", + input: "Key=value, FOO=bar, KeyName=value1, keyName=value2, KEYNAME=value3", + expected: taskparser.Params{ + "key": {"value"}, + "foo": {"bar"}, + "keyname": {"value1", "value2", "value3"}, + }, + }, + { + name: "UTF-8 characters", + input: "ключ=значение, key=こんにちは, 键=值, emoji=🚀", + expected: taskparser.Params{ + "ключ": {"значение"}, + "key": {"こんにちは"}, + "键": {"值"}, + "emoji": {"🚀"}, + }, + }, + { + name: "UTF-8 with Unicode whitespace", + input: "ключ1=значение1\u2003ключ2=значение2", + expected: taskparser.Params{ + "ключ1": {"значение1"}, + "ключ2": {"значение2"}, + }, + }, + { + name: "quoted UTF-8 value with spaces", + input: `ключ="значение с пробелами"`, + expected: taskparser.Params{ + "ключ": {"значение с пробелами"}, + }, + }, + { + name: "mixed UTF-8 and ASCII", + input: "key=value ключ=значение foo=bar", + expected: taskparser.Params{ + "key": {"value"}, + "ключ": {"значение"}, + "foo": {"bar"}, + }, + }, + { + name: "UTF-8 value containing equals sign", + input: `key="значение=со=равно"`, + expected: taskparser.Params{ + "key": {"значение=со=равно"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + result, err := taskparser.ParseParams(tt.input) + require.NoError(t, err) + + // Check positional arguments using Arguments() accessor + expectedArgs, hasArgs := tt.expected[taskparser.ArgumentsKey] + actualArgs := result.Arguments() + if hasArgs { + assert.Equal(t, expectedArgs, actualArgs, "positional arguments mismatch") + } else { + assert.Empty(t, actualArgs, "expected no positional arguments") + } + + // Check named parameters + for key, expectedValues := range tt.expected { + if key != taskparser.ArgumentsKey { + assert.Equal(t, expectedValues, result.Values(key), "values mismatch for key %q", key) + } + } + + // Verify no unexpected keys + for key := range result { + if key != taskparser.ArgumentsKey && tt.expected[key] == nil { + t.Errorf("unexpected key in result: %q", key) + } + } + }) + } +} + +func TestParams_Value(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + key string + expected string + }{ + { + name: "single value", + input: "key=value", + key: "key", + expected: "value", + }, + { + name: "multiple values returns first", + input: "key=value1 key=value2 key=value3", + key: "key", + expected: "value1", + }, + { + name: "non-existent key returns empty", + input: "key=value", + key: "nonexistent", + expected: "", + }, + { + name: "empty params returns empty", + input: "", + key: "key", + expected: "", + }, + { + name: "case insensitive lookup", + input: "Key=value, KeyName=value2", + key: "KEY", + expected: "value", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + params, err := taskparser.ParseParams(tt.input) + require.NoError(t, err) + assert.Equal(t, tt.expected, params.Value(tt.key)) + }) + } +} + +func TestParams_Values(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + key string + expected []string + }{ + { + name: "single value", + input: "key=value", + key: "key", + expected: []string{"value"}, + }, + { + name: "multiple values returns all", + input: "key=value1 key=value2 key=value3", + key: "key", + expected: []string{"value1", "value2", "value3"}, + }, + { + name: "non-existent key returns nil", + input: "key=value", + key: "nonexistent", + expected: nil, + }, + { + name: "empty params returns nil", + input: "", + key: "key", + expected: nil, + }, + { + name: "multiple keys", + input: "key1=value1 key2=value2 key1=value3", + key: "key1", + expected: []string{"value1", "value3"}, + }, + { + name: "case insensitive lookup", + input: "Key=value1 Key=value2, KeyName=value3 keyName=value4", + key: "KEY", + expected: []string{"value1", "value2"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + params, err := taskparser.ParseParams(tt.input) + require.NoError(t, err) + assert.Equal(t, tt.expected, params.Values(tt.key)) + }) + } +} + +func TestParams_NilSafety(t *testing.T) { + t.Parallel() + + var params taskparser.Params + + assert.Equal(t, "", params.Value("key")) + assert.Nil(t, params.Values("key")) +} + +func TestParse_EmptyValues(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + key string + expected []string + }{ + { + name: "empty unquoted value returns empty slice", + input: "key=", + key: "key", + expected: []string{}, + }, + { + name: "empty unquoted value with trailing space returns empty slice", + input: "key= ", + key: "key", + expected: []string{}, + }, + { + name: "explicitly quoted empty value returns slice with empty string", + input: `key=""`, + key: "key", + expected: []string{""}, + }, + { + name: "empty value before comma returns empty slice", + input: "key=,foo=bar", + key: "key", + expected: []string{}, + }, + { + name: "empty value with trailing comma at end returns empty slice", + input: "key=,", + key: "key", + expected: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + params, err := taskparser.ParseParams(tt.input) + require.NoError(t, err) + assert.Equal(t, tt.expected, params.Values(tt.key)) + }) + } +} + +func TestParse_Error(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + }{ + { + name: "empty key", + input: "=value", + }, + { + name: "empty key, second value", + input: "key=value =value", + }, + { + name: "incorrect quotes", + input: "key='value\"", + }, + { + name: "incorrect quotes, double quote first", + input: "key=\"value'", + }, + { + name: "incorrect quotes, multiple", + input: "key=\"value', key2=\"value2'", + }, + { + name: "empty key, second value, comma", + input: "key=value, =value", + }, + { + name: "unclosed double quote", + input: `key="unclosed`, + }, + { + name: "unclosed single quote", + input: `key='unclosed`, + }, + { + name: "incomplete hex escape - missing one digit", + input: `key="\x4"`, + }, + { + name: "incomplete hex escape - missing both digits", + input: `key="\x"`, + }, + { + name: "invalid hex escape", + input: `key="\xGH"`, + }, + { + name: "incomplete hex escape - quote immediately after x with extra char", + input: `key="\x"X"`, + }, + { + name: "incomplete hex escape - invalid char followed by quote", + input: `key="\xG"`, + }, + { + name: "invalid hex escape - valid first digit, invalid second digit", + input: `key="\x4G"`, + }, + { + name: "incomplete unicode escape - missing one digit", + input: `key="\u00a"`, + }, + { + name: "incomplete unicode escape - missing all digits", + input: `key="\u"`, + }, + { + name: "invalid unicode escape", + input: `key="\u00GH"`, + }, + { + name: "incomplete unicode escape in unquoted value", + input: `key=foo\u00a`, + }, + { + name: "invalid unicode escape in unquoted value", + input: `key=foo\u00GH`, + }, + { + name: "wrapped quotes - malformed single quote wrapping", + input: `key='\"'value\"'`, + }, + { + name: "unclosed trailing quote", + input: `key=value=with=equals"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, err := taskparser.ParseParams(tt.input) + require.Error(t, err) + }) + } +} + +func TestParseParams_PositionalArguments(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + expected taskparser.Params + }{ + { + name: "single positional argument", + input: "value", + expected: taskparser.Params{ + taskparser.ArgumentsKey: {"value"}, + }, + }, + { + name: "multiple positional arguments", + input: "value1 value2 value3", + expected: taskparser.Params{ + taskparser.ArgumentsKey: {"value1", "value2", "value3"}, + }, + }, + { + name: "positional arguments with commas", + input: "value1, value2, value3", + expected: taskparser.Params{ + taskparser.ArgumentsKey: {"value1", "value2", "value3"}, + }, + }, + { + name: "positional argument with spaces becomes multiple arguments", + input: "value with spaces", + expected: taskparser.Params{ + taskparser.ArgumentsKey: {"value", "with", "spaces"}, + }, + }, + { + name: "quoted positional argument", + input: `"quoted value"`, + expected: taskparser.Params{ + taskparser.ArgumentsKey: {"quoted value"}, + }, + }, + { + name: "single quoted positional argument", + input: `'quoted value'`, + expected: taskparser.Params{ + taskparser.ArgumentsKey: {"quoted value"}, + }, + }, + { + name: "mixed positional and named arguments", + input: "positional1 key=value positional2", + expected: taskparser.Params{ + taskparser.ArgumentsKey: {"positional1", "positional2"}, + "key": {"value"}, + }, + }, + { + name: "positional before named", + input: "arg1 arg2 key=value", + expected: taskparser.Params{ + taskparser.ArgumentsKey: {"arg1", "arg2"}, + "key": {"value"}, + }, + }, + { + name: "positional after named", + input: "key=value arg1 arg2", + expected: taskparser.Params{ + "key": {"value"}, + taskparser.ArgumentsKey: {"arg1", "arg2"}, + }, + }, + { + name: "positional between named", + input: "key1=value1 arg1 key2=value2", + expected: taskparser.Params{ + "key1": {"value1"}, + taskparser.ArgumentsKey: {"arg1"}, + "key2": {"value2"}, + }, + }, + { + name: "multiple positional with named", + input: "arg1 key1=value1 arg2 arg3 key2=value2 arg4", + expected: taskparser.Params{ + taskparser.ArgumentsKey: {"arg1", "arg2", "arg3", "arg4"}, + "key1": {"value1"}, + "key2": {"value2"}, + }, + }, + { + name: "positional with quoted value containing spaces", + input: `arg1 "quoted arg with spaces" arg3`, + expected: taskparser.Params{ + taskparser.ArgumentsKey: {"arg1", "quoted arg with spaces", "arg3"}, + }, + }, + { + name: "positional with empty quoted value", + input: `arg1 "" arg3`, + expected: taskparser.Params{ + taskparser.ArgumentsKey: {"arg1", "", "arg3"}, + }, + }, + { + name: "positional arguments with UTF-8", + input: "значение1 значение2", + expected: taskparser.Params{ + taskparser.ArgumentsKey: {"значение1", "значение2"}, + }, + }, + { + name: "positional with escape sequences", + input: `arg1 "line1\nline2" arg3`, + expected: taskparser.Params{ + taskparser.ArgumentsKey: {"arg1", "line1\nline2", "arg3"}, + }, + }, + { + name: "positional arguments separated by commas", + input: "arg1,arg2,arg3", + expected: taskparser.Params{ + taskparser.ArgumentsKey: {"arg1", "arg2", "arg3"}, + }, + }, + { + name: "positional with trailing comma", + input: "arg1,arg2,", + expected: taskparser.Params{ + taskparser.ArgumentsKey: {"arg1", "arg2"}, + }, + }, + { + name: "positional with leading comma", + input: ",arg1,arg2", + expected: taskparser.Params{ + taskparser.ArgumentsKey: {"arg1", "arg2"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + result, err := taskparser.ParseParams(tt.input) + require.NoError(t, err) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/pkg/codingcontext/taskparser/taskparser.go b/pkg/codingcontext/taskparser/taskparser.go new file mode 100644 index 0000000..abbbdf9 --- /dev/null +++ b/pkg/codingcontext/taskparser/taskparser.go @@ -0,0 +1,214 @@ +package taskparser + +import ( + "strings" +) + +// ParseTask parses a task string into a structured Task representation. +// A task consists of alternating blocks of text content and slash commands +// (commands starting with /). The parser distinguishes between inline +// slashes (part of text) and command slashes (at the start of a line). +// +// Task Structure: +// +// A Task is a sequence of Block elements, where each block is either: +// - A Text block: regular text content +// - A SlashCommand block: a command starting with / at line start +// (optional whitespace before / is allowed) +// +// Command Detection: +// +// - Command: A / at the start of a line (after newline or at start of input) +// starts a command. Optional whitespace (spaces or tabs) before the / +// is allowed and preserved. +// +// - Text: A / not at the start of a line, or preceded by non-whitespace +// characters, is treated as regular text +// +// - Line boundaries: Newlines separate commands from text +// +// Examples: +// "/fix-bug" // Command +// " /deploy" // Command (whitespace before slash allowed) +// "Some text /fix" // Text (slash not at line start) +// "text/deploy" // Text (non-whitespace before slash prevents command) +// +// Command Arguments: +// +// Commands can have: +// - Positional arguments: values without keys +// - Named arguments: key=value pairs +// - Quoted arguments: both single and double quotes supported +// - Mixed arguments: positional and named can be interleaved +// +// Examples: +// "/fix-bug 123 urgent" // Positional only +// "/deploy env=\"production\"" // Named only +// "/task arg1 key=\"value\" arg2" // Mixed +// "/deploy arg1 env=\"production\" arg2" // Positional before/after named +// +// Note: The argument parsing in ParseTask uses a simpler grammar than +// ParseParams. For more advanced parsing (commas, escape sequences, etc.), +// use the Params() method on SlashCommand. +// +// Text Content: +// +// Text blocks: +// - Preserve whitespace: all whitespace, including indentation +// - Can span multiple lines +// - No special processing: stored as-is +// +// Examples: +// "Line 1\n Indented line 2\nLine 3" +// +// Task Composition: +// +// Tasks can contain: +// - Only text: "This is a task with only text." +// - Only commands: "/command1\n/command2\n" +// - Mixed: "Introduction text\n/command1 arg1\nMiddle text\n/command2\n" +// +// SlashCommand.Params() Method: +// +// The SlashCommand type provides a Params() method that converts command +// arguments to a Params map using ParseParams. This provides: +// - Full ParseParams feature support (commas, escape sequences, etc.) +// - Consistent API with ParseParams +// - More permissive parsing than the initial grammar +// +// Example: +// task, _ := ParseTask("/deploy env=\"production\" region=\"us-east-1\"\n") +// cmd := task[0].SlashCommand +// params := cmd.Params() +// // params["env"] = []string{"production"} +// // params["region"] = []string{"us-east-1"} +// +// Error Conditions: +// +// The function rarely returns errors due to the permissive grammar. +// Potential errors include malformed input that cannot be tokenized. +// +// Examples: +// +// // Simple text task +// task, _ := ParseTask("This is a simple text block.") +// // task[0].Text.Content() == "This is a simple text block." +// +// // Simple command +// task, _ := ParseTask("/fix-bug\n") +// // task[0].SlashCommand.Name == "fix-bug" +// +// // Command with whitespace before slash (allowed) +// task, _ := ParseTask(" /deploy env=\"production\"\n") +// // task[0].SlashCommand.Name == "deploy" +// +// // Command with positional arguments +// task, _ := ParseTask("/fix-bug 123 urgent\n") +// // task[0].SlashCommand.Arguments[0].Value == "123" +// +// // Mixed content +// task, _ := ParseTask("Introduction text\n/fix-bug 123\nSome text after") +// // len(task) == 3 (text, command, text) +func ParseTask(text string) (Task, error) { + input, err := parser().ParseString("", text) + if err != nil { + return nil, err + } + return Task(input.Blocks), nil +} + +// Params converts the slash command's arguments into a parameter map using ParseParams. +// This provides a more permissive parser that supports commas, single quotes, and other features. +// Returns a map with: +// - "ARGUMENTS": positional arguments (values without keys) +// - named parameters: key-value pairs from key="value" or key='value' arguments +func (s *SlashCommand) Params() Params { + // Reconstruct the arguments string from the parsed Arguments + var argStrings []string + for _, arg := range s.Arguments { + if arg.Key != "" { + // Named parameter: key="value" or key='value' + argStrings = append(argStrings, arg.Key+"="+arg.Value) + } else { + // Positional parameter + argStrings = append(argStrings, arg.Value) + } + } + + // Join arguments with spaces (preserving the original format) + argsString := strings.Join(argStrings, " ") + + // Use ParseParams to parse the arguments string + // This is more permissive and handles commas, single quotes, etc. + params, err := ParseParams(argsString) + if err != nil { + // If parsing fails, return empty params + // This should rarely happen since ParseParams handles the same format + // that was parsed by the grammar, but we handle it gracefully + return make(Params) + } + + return params +} + +// Content returns the text content with all lines concatenated +func (t *Text) Content() string { + var sb strings.Builder + for _, line := range t.Lines { + for _, tok := range line.NonSlashStart { + sb.WriteString(tok) + } + for _, tok := range line.RestOfLine { + sb.WriteString(tok) + } + sb.WriteString(line.NewlineOpt) + } + return sb.String() +} + +// String returns the original text representation of a task +func (t Task) String() string { + var sb strings.Builder + for _, block := range t { + sb.WriteString(block.String()) + } + return sb.String() +} + +// String returns the original text representation of a block +func (b Block) String() string { + if b.SlashCommand != nil { + return b.SlashCommand.String() + } + if b.Text != nil { + return b.Text.String() + } + return "" +} + +// String returns the original text representation of a slash command +func (s SlashCommand) String() string { + var sb strings.Builder + sb.WriteString(s.LeadingWhitespace) + sb.WriteString("/") + sb.WriteString(s.Name) + for _, arg := range s.Arguments { + sb.WriteString(" ") + sb.WriteString(arg.String()) + } + sb.WriteString("\n") + return sb.String() +} + +// String returns the original text representation of an argument +func (a Argument) String() string { + if a.Key != "" { + return a.Key + "=" + a.Value + } + return a.Value +} + +// String returns the original text representation of text +func (t Text) String() string { + return t.Content() +} diff --git a/pkg/codingcontext/task_parser_test.go b/pkg/codingcontext/taskparser/taskparser_test.go similarity index 54% rename from pkg/codingcontext/task_parser_test.go rename to pkg/codingcontext/taskparser/taskparser_test.go index 96c0e5a..68fddba 100644 --- a/pkg/codingcontext/task_parser_test.go +++ b/pkg/codingcontext/taskparser/taskparser_test.go @@ -1,4 +1,4 @@ -package codingcontext +package taskparser import ( "strings" @@ -220,6 +220,26 @@ func TestParseTask(t *testing.T) { } }, }, + { + name: "non-whitespace before slash prevents command", + input: "text/deploy env=\"production\"\n", + wantErr: false, + check: func(t *testing.T, task Task) { + if len(task) != 1 { + t.Fatalf("expected 1 block, got %d", len(task)) + } + if task[0].Text == nil { + t.Fatal("expected text block, not command") + } + // The slash should be part of the text, not a command + if task[0].SlashCommand != nil { + t.Fatal("expected no slash command when non-whitespace precedes slash") + } + if !strings.Contains(task[0].Text.Content(), "/deploy") { + t.Errorf("expected text to contain '/deploy', got %q", task[0].Text.Content()) + } + }, + }, { name: "text with equals sign", input: "This is text with key=value pairs.", @@ -326,3 +346,246 @@ func TestTask_String(t *testing.T) { }) } } + +func TestSlashCommand_Params(t *testing.T) { + tests := []struct { + name string + input string + commandIndex int // Which block contains the slash command (0-based) + expectedName string + expectedParams Params + expectedArgs []string // Positional arguments + }{ + { + name: "simple named parameters", + input: "/deploy env=\"production\" region=\"us-east-1\" version=1.2.3\n", + commandIndex: 0, + expectedName: "deploy", + expectedParams: Params{ + "env": {"production"}, + "region": {"us-east-1"}, + "version": {"1.2.3"}, + }, + expectedArgs: nil, + }, + { + name: "whitespace before initial slash", + input: " /deploy env=\"production\" region=\"us-east-1\" version=1.2.3\n", + commandIndex: 0, + expectedName: "deploy", + expectedParams: Params{ + "env": {"production"}, + "region": {"us-east-1"}, + "version": {"1.2.3"}, + }, + expectedArgs: nil, + }, + { + name: "text before slash command", + input: "Some introduction text\n/deploy env=\"production\"\n", + commandIndex: 1, + expectedName: "deploy", + expectedParams: Params{ + "env": {"production"}, + }, + expectedArgs: nil, + }, + { + name: "positional arguments only", + input: "/fix-bug 123 urgent\n", + commandIndex: 0, + expectedName: "fix-bug", + expectedParams: Params{ + ArgumentsKey: {"123", "urgent"}, + }, + expectedArgs: []string{"123", "urgent"}, + }, + { + name: "mixed positional and named arguments", + input: "/task arg1 key=\"value\" arg2 env=\"prod\"\n", + commandIndex: 0, + expectedName: "task", + expectedParams: Params{ + ArgumentsKey: {"arg1", "arg2"}, + "key": {"value"}, + "env": {"prod"}, + }, + expectedArgs: []string{"arg1", "arg2"}, + }, + { + name: "positional before named", + input: "/deploy arg1 arg2 env=\"production\"\n", + commandIndex: 0, + expectedName: "deploy", + expectedParams: Params{ + ArgumentsKey: {"arg1", "arg2"}, + "env": {"production"}, + }, + expectedArgs: []string{"arg1", "arg2"}, + }, + { + name: "positional after named", + input: "/deploy env=\"production\" arg1 arg2\n", + commandIndex: 0, + expectedName: "deploy", + expectedParams: Params{ + ArgumentsKey: {"arg1", "arg2"}, + "env": {"production"}, + }, + expectedArgs: []string{"arg1", "arg2"}, + }, + { + name: "positional between named", + input: "/deploy env=\"production\" arg1 region=\"us-east-1\"\n", + commandIndex: 0, + expectedName: "deploy", + expectedParams: Params{ + ArgumentsKey: {"arg1"}, + "env": {"production"}, + "region": {"us-east-1"}, + }, + expectedArgs: []string{"arg1"}, + }, + { + name: "quoted positional arguments", + input: "/deploy \"quoted arg\" 'single quoted' normal\n", + commandIndex: 0, + expectedName: "deploy", + expectedParams: Params{ + ArgumentsKey: {"quoted arg", "single quoted", "normal"}, + }, + expectedArgs: []string{"quoted arg", "single quoted", "normal"}, + }, + { + name: "multiple named parameters with spaces", + input: "/deploy env=\"production\" region=\"us-east-1\" version=1.2.3\n", + commandIndex: 0, + expectedName: "deploy", + expectedParams: Params{ + "env": {"production"}, + "region": {"us-east-1"}, + "version": {"1.2.3"}, + }, + expectedArgs: nil, + }, + { + name: "text before and after slash command", + input: "Before text\n/deploy env=\"production\" arg1\nAfter text", + commandIndex: 1, + expectedName: "deploy", + expectedParams: Params{ + ArgumentsKey: {"arg1"}, + "env": {"production"}, + }, + expectedArgs: []string{"arg1"}, + }, + { + name: "no arguments", + input: "/deploy\n", + commandIndex: 0, + expectedName: "deploy", + expectedParams: Params{}, + expectedArgs: nil, + }, + { + name: "single quoted named parameter", + input: "/deploy env='production'\n", + commandIndex: 0, + expectedName: "deploy", + expectedParams: Params{ + "env": {"production"}, + }, + expectedArgs: nil, + }, + { + name: "complex mixed arguments", + input: "/deploy arg1 env=\"production\" arg2 region=\"us-east-1\" arg3 version=1.2.3\n", + commandIndex: 0, + expectedName: "deploy", + expectedParams: Params{ + ArgumentsKey: {"arg1", "arg2", "arg3"}, + "env": {"production"}, + "region": {"us-east-1"}, + "version": {"1.2.3"}, + }, + expectedArgs: []string{"arg1", "arg2", "arg3"}, + }, + { + name: "multiple slash commands - test second command", + input: "/command1 arg1\n/deploy env=\"production\" arg1 region=\"us-east-1\"\n/command3 arg3\n", + commandIndex: 1, + expectedName: "deploy", + expectedParams: Params{ + ArgumentsKey: {"arg1"}, + "env": {"production"}, + "region": {"us-east-1"}, + }, + expectedArgs: []string{"arg1"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + task, err := ParseTask(tt.input) + if err != nil { + t.Fatalf("ParseTask() error = %v", err) + } + + // Check for SlashCommand at the expected index + if len(task) <= tt.commandIndex { + t.Fatalf("expected at least %d blocks, got %d", tt.commandIndex+1, len(task)) + } + if task[tt.commandIndex].SlashCommand == nil { + t.Fatalf("expected slash command block at index %d", tt.commandIndex) + } + + cmd := task[tt.commandIndex].SlashCommand + if cmd.Name != tt.expectedName { + t.Errorf("expected command name %q, got %q", tt.expectedName, cmd.Name) + } + + // Use Params() to validate expectations + params := cmd.Params() + + // Validate positional arguments + actualArgs := params.Arguments() + if len(tt.expectedArgs) != len(actualArgs) { + t.Errorf("expected %d positional arguments, got %d: expected=%v, got=%v", + len(tt.expectedArgs), len(actualArgs), tt.expectedArgs, actualArgs) + } else { + for i, expected := range tt.expectedArgs { + if i < len(actualArgs) && actualArgs[i] != expected { + t.Errorf("positional arg[%d]: expected %q, got %q", i, expected, actualArgs[i]) + } + } + } + + // Validate named parameters + for key, expectedValues := range tt.expectedParams { + if key == ArgumentsKey { + continue // Already validated above + } + actualValues := params.Values(key) + if len(expectedValues) != len(actualValues) { + t.Errorf("key %q: expected %d values, got %d: expected=%v, got=%v", + key, len(expectedValues), len(actualValues), expectedValues, actualValues) + } else { + for i, expected := range expectedValues { + if i < len(actualValues) && actualValues[i] != expected { + t.Errorf("key %q[%d]: expected %q, got %q", key, i, expected, actualValues[i]) + } + } + } + } + + // Verify no unexpected keys (except ArgumentsKey which we handle separately) + for key := range params { + if key != ArgumentsKey { + if _, exists := tt.expectedParams[key]; !exists { + t.Errorf("unexpected key in params: %q", key) + } + } + } + }) + } +} diff --git a/pkg/codingcontext/token_counter.go b/pkg/codingcontext/tokencount/tokencount.go similarity index 70% rename from pkg/codingcontext/token_counter.go rename to pkg/codingcontext/tokencount/tokencount.go index fa94212..89d0146 100644 --- a/pkg/codingcontext/token_counter.go +++ b/pkg/codingcontext/tokencount/tokencount.go @@ -1,13 +1,13 @@ -package codingcontext +package tokencount import ( "unicode/utf8" ) -// estimateTokens estimates the number of LLM tokens in the given text. +// EstimateTokens estimates the number of LLM tokens in the given text. // Uses a simple heuristic of approximately 4 characters per token, // which is a common approximation for English text with GPT-style tokenizers. -func estimateTokens(text string) int { +func EstimateTokens(text string) int { charCount := utf8.RuneCountInString(text) // Approximate: 1 token ≈ 4 characters return charCount / 4 diff --git a/pkg/codingcontext/token_counter_test.go b/pkg/codingcontext/tokencount/tokencount_test.go similarity index 87% rename from pkg/codingcontext/token_counter_test.go rename to pkg/codingcontext/tokencount/tokencount_test.go index 786367a..1e9afb5 100644 --- a/pkg/codingcontext/token_counter_test.go +++ b/pkg/codingcontext/tokencount/tokencount_test.go @@ -1,6 +1,10 @@ -package codingcontext +package tokencount_test -import "testing" +import ( + "testing" + + "github.com/kitproj/coding-context-cli/pkg/codingcontext/tokencount" +) func TestEstimateTokens(t *testing.T) { tests := []struct { @@ -56,7 +60,7 @@ This is content.`, for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := estimateTokens(tt.text) + got := tokencount.EstimateTokens(tt.text) if got != tt.want { t.Errorf("estimateTokens() = %d, want %d", got, tt.want) } diff --git a/pkg/codingcontext/transport_type.go b/pkg/codingcontext/transport_type.go deleted file mode 100644 index f58bc07..0000000 --- a/pkg/codingcontext/transport_type.go +++ /dev/null @@ -1,17 +0,0 @@ -package codingcontext - -// TransportType defines the communication protocol used by the server. -// Supported by both Claude and Cursor. -type TransportType string - -const ( - // TransportTypeStdio is for local processes (executables). - TransportTypeStdio TransportType = "stdio" - - // TransportTypeSSE is for Server-Sent Events (Remote). - // Note: Claude Code prefers HTTP over SSE, but supports it. - TransportTypeSSE TransportType = "sse" - - // TransportTypeHTTP is for standard HTTP/POST interactions. - TransportTypeHTTP TransportType = "http" -) From f5a275edc0d43bf0e0f7372857d9412f3d9f731f Mon Sep 17 00:00:00 2001 From: Alex Collins Date: Mon, 22 Dec 2025 17:28:53 -0800 Subject: [PATCH 08/10] Update examples/agents/tasks/example-mcp-arbitrary-fields.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- examples/agents/tasks/example-mcp-arbitrary-fields.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/agents/tasks/example-mcp-arbitrary-fields.md b/examples/agents/tasks/example-mcp-arbitrary-fields.md index d4c291f..5dd8aef 100644 --- a/examples/agents/tasks/example-mcp-arbitrary-fields.md +++ b/examples/agents/tasks/example-mcp-arbitrary-fields.md @@ -13,7 +13,7 @@ Note: MCP servers are specified in rules, not in tasks. Tasks can select which r Rules can specify a single MCP server configuration with both standard and arbitrary custom fields. -The `mcp_server` field specifies a single MCP server configuration with both standard and arbitrary custom fields. Each task or rule can specify one MCP server configuration. +The `mcp_server` field, when present in a rule, specifies that rule's single MCP server configuration with both standard and arbitrary custom fields. Tasks cannot define MCP servers directly. **Standard fields:** - `command`: The executable to run (e.g., "python", "npx", "docker") From fa05d7728bc05b988e0f15a223e266b60ae5549d Mon Sep 17 00:00:00 2001 From: Alex Collins Date: Mon, 22 Dec 2025 17:29:03 -0800 Subject: [PATCH 09/10] Update examples/agents/tasks/example-mcp-arbitrary-fields.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- examples/agents/tasks/example-mcp-arbitrary-fields.md | 8 -------- 1 file changed, 8 deletions(-) diff --git a/examples/agents/tasks/example-mcp-arbitrary-fields.md b/examples/agents/tasks/example-mcp-arbitrary-fields.md index 5dd8aef..d734a85 100644 --- a/examples/agents/tasks/example-mcp-arbitrary-fields.md +++ b/examples/agents/tasks/example-mcp-arbitrary-fields.md @@ -50,14 +50,6 @@ mcp_server: This rule provides the Python MCP server configuration. ``` -**Standard fields:** -- `command`: The executable to run (e.g., "python", "npx", "docker") -- `args`: Array of command-line arguments -- `env`: Environment variables for the server process -- `type`: Connection protocol ("stdio", "http", "sse") - optional, defaults to stdio -- `url`: Endpoint URL for HTTP/SSE types -- `headers`: Custom HTTP headers for HTTP/SSE types - **Arbitrary custom fields:** You can add any additional fields for your specific MCP server needs: - `custom_config`: Nested configuration objects From b1f3abbedfa8faf0038d364beaea179557cfd2f1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Dec 2025 01:32:27 +0000 Subject: [PATCH 10/10] Filter empty MCP configs and fix documentation - Filter out empty MCP server configs in MCPServers() method - Only return configs with Command or URL set - Update tests to expect empty configs to be filtered - Remove duplicate standard fields list from example doc - Add documentation that empty configs are filtered Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> --- examples/agents/tasks/example-mcp-arbitrary-fields.md | 6 ------ pkg/codingcontext/result.go | 9 +++++++-- pkg/codingcontext/result_test.go | 6 +++--- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/examples/agents/tasks/example-mcp-arbitrary-fields.md b/examples/agents/tasks/example-mcp-arbitrary-fields.md index d734a85..bb504be 100644 --- a/examples/agents/tasks/example-mcp-arbitrary-fields.md +++ b/examples/agents/tasks/example-mcp-arbitrary-fields.md @@ -50,12 +50,6 @@ mcp_server: This rule provides the Python MCP server configuration. ``` -**Arbitrary custom fields:** -You can add any additional fields for your specific MCP server needs: -- `custom_config`: Nested configuration objects -- `monitoring`: Monitoring settings -- `cache_enabled`, `max_retries`, `timeout_seconds`, etc. - ## Why Arbitrary Fields? Different MCP servers may need different configuration options beyond the standard fields. Arbitrary fields allow you to: diff --git a/pkg/codingcontext/result.go b/pkg/codingcontext/result.go index dd7566b..af5433b 100644 --- a/pkg/codingcontext/result.go +++ b/pkg/codingcontext/result.go @@ -16,12 +16,17 @@ type Result struct { // MCPServers returns all MCP server configurations from rules. // Each rule can specify one MCP server configuration. // Returns a slice of all configured MCP servers from rules only. +// Empty/zero-value MCP server configurations are filtered out. func (r *Result) MCPServers() []mcp.MCPServerConfig { var servers []mcp.MCPServerConfig - // Add server from each rule + // Add server from each rule, filtering out empty configs for _, rule := range r.Rules { - servers = append(servers, rule.FrontMatter.MCPServer) + server := rule.FrontMatter.MCPServer + // Skip empty MCP server configs (no command and no URL means empty) + if server.Command != "" || server.URL != "" { + servers = append(servers, server) + } } return servers diff --git a/pkg/codingcontext/result_test.go b/pkg/codingcontext/result_test.go index facc250..65a6891 100644 --- a/pkg/codingcontext/result_test.go +++ b/pkg/codingcontext/result_test.go @@ -48,7 +48,7 @@ func TestResult_MCPServers(t *testing.T) { }, }, { - name: "multiple rules with MCP servers", + name: "multiple rules with MCP servers and empty rule", result: Result{ Rules: []markdown.Markdown[markdown.RuleFrontMatter]{ { @@ -72,7 +72,7 @@ func TestResult_MCPServers(t *testing.T) { want: []mcp.MCPServerConfig{ {Type: mcp.TransportTypeStdio, Command: "server1"}, {Type: mcp.TransportTypeStdio, Command: "server2"}, - {}, // Empty rule MCP server + // Empty rule MCP server is filtered out }, }, { @@ -88,7 +88,7 @@ func TestResult_MCPServers(t *testing.T) { }, }, want: []mcp.MCPServerConfig{ - {}, // Empty rule MCP server + // Empty rule MCP server is filtered out }, }, }