From 199297ce7ee71d2e0b3748fd7f8588a13a6c93ca Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Fri, 27 Feb 2026 14:11:27 +0300 Subject: [PATCH] remove script command, hook examples, and related dead code Delete the entire scripting feature (cmd/script.go, tests, examples/scripts/, examples/hooks/) and clean up all supporting code: ArgsSubstituter, MergeConfigs, Config.Prompt/NoExit fields, scriptMCPConfig, and HasScriptArgs. Env substitution (EnvSubstituter, HasEnvVars) is retained as it's used by config loading and hooks. -2171 lines across 21 files. --- README.md | 158 +----- cmd/root.go | 24 +- cmd/script.go | 562 -------------------- cmd/script_deepseek_test.go | 162 ------ cmd/script_integration_test.go | 257 --------- cmd/script_test.go | 432 --------------- cmd/setup.go | 8 +- examples/hooks/bash-validator.py | 57 -- examples/hooks/mcp-monitor.py | 76 --- examples/hooks/prompt-logger.sh | 23 - examples/scripts/README.md | 83 --- examples/scripts/default-values-demo.sh | 38 -- examples/scripts/env-substitution-script.sh | 42 -- examples/scripts/example-script.sh | 9 - examples/scripts/simple-script.sh | 4 - examples/scripts/tls-test-script.sh | 9 - internal/app/app.go | 2 +- internal/config/config.go | 2 - internal/config/merger.go | 15 - internal/config/substitution.go | 55 +- internal/config/substitution_test.go | 168 ------ 21 files changed, 15 insertions(+), 2171 deletions(-) delete mode 100644 cmd/script.go delete mode 100644 cmd/script_deepseek_test.go delete mode 100644 cmd/script_integration_test.go delete mode 100644 cmd/script_test.go delete mode 100755 examples/hooks/bash-validator.py delete mode 100755 examples/hooks/mcp-monitor.py delete mode 100755 examples/hooks/prompt-logger.sh delete mode 100644 examples/scripts/README.md delete mode 100755 examples/scripts/default-values-demo.sh delete mode 100755 examples/scripts/env-substitution-script.sh delete mode 100755 examples/scripts/example-script.sh delete mode 100755 examples/scripts/simple-script.sh delete mode 100755 examples/scripts/tls-test-script.sh diff --git a/README.md b/README.md index 2537872f..402869aa 100644 --- a/README.md +++ b/README.md @@ -500,154 +500,6 @@ Start an interactive conversation session: kit ``` -### Script Mode - -Run executable YAML-based automation scripts with variable substitution support: - -```bash -# Using the script subcommand -kit script myscript.sh - -# With variables -kit script myscript.sh --args:directory /tmp --args:name "John" - -# Direct execution (if executable and has shebang) -./myscript.sh -``` - -#### Script Format - -Scripts combine YAML configuration with prompts in a single executable file. The configuration must be wrapped in frontmatter delimiters (`---`). You can either include the prompt in the YAML configuration or place it after the closing frontmatter delimiter: - -```yaml -#!/usr/bin/env -S kit script ---- -# This script uses the container-use MCP server from https://github.com/dagger/container-use -mcpServers: - container-use: - type: "local" - command: ["cu", "stdio"] -prompt: | - Create 2 variations of a simple hello world app using Flask and FastAPI. - Each in their own environment. Give me the URL of each app ---- -``` - -Or alternatively, omit the `prompt:` field and place the prompt after the frontmatter: - -```yaml -#!/usr/bin/env -S kit script ---- -# This script uses the container-use MCP server from https://github.com/dagger/container-use -mcpServers: - container-use: - type: "local" - command: ["cu", "stdio"] ---- -Create 2 variations of a simple hello world app using Flask and FastAPI. -Each in their own environment. Give me the URL of each app -``` - -#### Variable Substitution - -Scripts support both environment variable substitution and script argument substitution: - -1. **Environment Variables**: `${env://VAR}` and `${env://VAR:-default}` - Processed first -2. **Script Arguments**: `${variable}` and `${variable:-default}` - Processed after environment variables - -Variables can be provided via command line arguments: - -```bash -# Script with variables -kit script myscript.sh --args:directory /tmp --args:name "John" -``` - -##### Variable Syntax - -KIT supports these variable syntaxes: - -1. **Required Environment Variables**: `${env://VAR}` - Must be set in environment -2. **Optional Environment Variables**: `${env://VAR:-default}` - Uses default if not set -3. **Required Script Arguments**: `${variable}` - Must be provided via `--args:variable value` -4. **Optional Script Arguments**: `${variable:-default}` - Uses default if not provided - -Example script with mixed environment variables and script arguments: -```yaml -#!/usr/bin/env -S kit script ---- -mcpServers: - github: - type: "local" - command: ["gh", "api"] - environment: - GITHUB_TOKEN: "${env://GITHUB_TOKEN}" - DEBUG: "${env://DEBUG:-false}" - - filesystem: - type: "local" - command: ["npx", "-y", "@modelcontextprotocol/server-filesystem", "${env://WORK_DIR:-/tmp}"] - -model: "${env://MODEL:-anthropic/claude-sonnet-4-5-20250929}" ---- -Hello ${name:-World}! Please list ${repo_type:-public} repositories for user ${username}. -Working directory is ${env://WORK_DIR:-/tmp}. -Use the ${command:-gh} command to fetch ${count:-10} repositories. -``` - -##### Usage Examples - -```bash -# Set environment variables first -export GITHUB_TOKEN="ghp_your_token_here" -export DEBUG="true" -export WORK_DIR="/home/user/projects" - -# Uses env vars and defaults: name="World", repo_type="public", command="gh", count="10" -kit script myscript.sh - -# Override specific script arguments -kit script myscript.sh --args:name "John" --args:username "alice" - -# Override multiple script arguments -kit script myscript.sh --args:name "John" --args:username "alice" --args:repo_type "private" - -# Mix of env vars, provided args, and default values -kit script myscript.sh --args:name "Alice" --args:command "gh api" --args:count "5" -``` - -##### Default Value Features - -- **Empty defaults**: `${var:-}` - Uses empty string if not provided -- **Complex defaults**: `${path:-/tmp/default/path}` - Supports paths, URLs, etc. -- **Spaces in defaults**: `${msg:-Hello World}` - Supports spaces in default values -- **Backward compatibility**: Existing `${variable}` syntax continues to work unchanged - -**Important**: -- Environment variables without defaults (e.g., `${env://GITHUB_TOKEN}`) are required and must be set in the environment -- Script arguments without defaults (e.g., `${username}`) are required and must be provided via `--args:variable value` syntax -- Variables with defaults are optional and will use their default value if not provided -- Environment variables are processed first, then script arguments - -#### Script Features - -- **Executable**: Use shebang line for direct execution (`#!/usr/bin/env -S kit script`) -- **YAML Configuration**: Define MCP servers directly in the script -- **Embedded Prompts**: Include the prompt in the YAML -- **Variable Substitution**: Use `${variable}` and `${variable:-default}` syntax with `--args:variable value` -- **Variable Validation**: Missing required variables cause script to exit with helpful error -- **Interactive Mode**: If prompt is empty, drops into interactive mode (handy for setup scripts) -- **Config Fallback**: If no `mcpServers` defined, uses default config -- **Tool Filtering**: Supports `allowedTools`/`excludedTools` per server -- **Clean Exit**: Automatically exits after completion - -**Note**: The shebang line requires `env -S` to handle the multi-word command `kit script`. This is supported on most modern Unix-like systems. - -#### Script Examples - -See `examples/scripts/` for sample scripts: -- `example-script.sh` - Script with custom MCP servers -- `simple-script.sh` - Script using default config fallback - ### Hooks System KIT supports a powerful hooks system that allows you to execute custom commands at specific points during execution. This enables security policies, logging, custom integrations, and automated workflows. @@ -937,15 +789,12 @@ curl -X POST http://localhost:8080/process \ -d "$(kit -p 'Generate a UUID' --quiet)" ``` -### Tips for Scripting +### Tips - Use `--quiet` flag to get clean output suitable for parsing (only AI response, no UI) - Use `--compact` flag for simplified output without fancy styling (when you want to see UI elements) - Note: `--compact` and `--quiet` are mutually exclusive - `--compact` has no effect with `--quiet` - **Use environment variables for sensitive data** like API keys instead of hardcoding them -- **Use `${env://VAR}` syntax** in config files and scripts for environment variable substitution -- Combine with standard Unix tools (`grep`, `awk`, `sed`, etc.) -- Set appropriate timeouts for long-running operations -- Handle errors appropriately in your scripts +- **Use `${env://VAR}` syntax** in config files for environment variable substitution - Use environment variables for API keys in production #### Environment Variable Best Practices @@ -961,9 +810,6 @@ mcpServers: environment: GITHUB_TOKEN: "${env://GITHUB_TOKEN}" DEBUG: "${env://DEBUG:-false}" - -# Use in scripts -kit script my-script.sh --args:username alice ``` ## MCP Server Compatibility 🔌 diff --git a/cmd/root.go b/cmd/root.go index 7cc755ff..79efd0c4 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -32,10 +32,9 @@ var ( quietFlag bool noExitFlag bool maxSteps int - streamFlag bool // Enable streaming output - compactMode bool // Enable compact output mode - autoCompactFlag bool // Enable auto-compaction near context limit - scriptMCPConfig *config.Config // Used to override config in script mode + streamFlag bool // Enable streaming output + compactMode bool // Enable compact output mode + autoCompactFlag bool // Enable auto-compaction near context limit // Session management sessionPath string @@ -319,15 +318,9 @@ func runNormalMode(ctx context.Context) error { } // Load MCP configuration. - var mcpConfig *config.Config - var err error - if scriptMCPConfig != nil { - mcpConfig = scriptMCPConfig - } else { - mcpConfig, err = config.LoadAndValidateConfig() - if err != nil { - return fmt.Errorf("failed to load MCP config: %v", err) - } + mcpConfig, err := config.LoadAndValidateConfig() + if err != nil { + return fmt.Errorf("failed to load MCP config: %v", err) } // Create spinner function for agent creation. @@ -460,9 +453,8 @@ func runNormalMode(ctx context.Context) error { // // In quiet mode, RunOnce is used (no intermediate output, final response only). // Otherwise, RunOnceWithDisplay streams tool calls and responses through the -// shared CLIEventHandler — giving --prompt mode the same rich output as script -// mode. This eliminates the previous split where --prompt silently swallowed -// all intermediate events. +// shared CLIEventHandler — giving --prompt mode the same rich output as +// interactive mode. // // When --no-exit is set, after the prompt completes the interactive BubbleTea // TUI is started so the user can continue the conversation. diff --git a/cmd/script.go b/cmd/script.go deleted file mode 100644 index 36f507db..00000000 --- a/cmd/script.go +++ /dev/null @@ -1,562 +0,0 @@ -package cmd - -import ( - "bufio" - "context" - "fmt" - "log" - "os" - "regexp" - "strings" - - "github.com/mark3labs/kit/internal/app" - "github.com/mark3labs/kit/internal/config" - "github.com/mark3labs/kit/internal/ui" - - "github.com/spf13/cobra" - "github.com/spf13/viper" -) - -// scriptCmd represents the script command for executing KIT script files. -// Script files can contain YAML frontmatter configuration followed by a prompt, -// allowing for reproducible AI interactions with custom configurations and -// variable substitution support. -var scriptCmd = &cobra.Command{ - Use: "script ", - Short: "Execute a script file with YAML frontmatter configuration", - Long: `Execute a script file that contains YAML frontmatter with configuration -and a prompt. The script file can contain MCP server configurations, -model settings, and other options. - -Example script file: ---- -model: "anthropic/claude-sonnet-4-5-20250929" -max-steps: 10 -mcpServers: - filesystem: - type: "local" - command: ["npx", "-y", "@modelcontextprotocol/server-filesystem", "${directory:-/tmp}"] ---- -Hello ${name:-World}! List the files in ${directory:-/tmp} and tell me about them. - -The script command supports the same flags as the main command, -which will override any settings in the script file. - -Variable substitution: -Variables in the script can be substituted using ${variable} syntax. -Variables can have default values using ${variable:-default} syntax. -Pass variables using --args:variable value syntax: - - kit script myscript.sh --args:directory /tmp --args:name "John" - -This will replace ${directory} with "/tmp" and ${name} with "John" in the script. -Variables with defaults (${var:-default}) are optional and use the default if not provided.`, - Args: cobra.ExactArgs(1), - FParseErrWhitelist: cobra.FParseErrWhitelist{ - UnknownFlags: true, // Allow unknown flags for variable substitution - }, - PreRun: func(cmd *cobra.Command, args []string) { - // Override config with frontmatter values from the script file - scriptFile := args[0] - variables := parseCustomVariables(cmd) - overrideConfigWithFrontmatter(scriptFile, variables, cmd) - }, - RunE: func(cmd *cobra.Command, args []string) error { - scriptFile := args[0] - - // Parse custom variables from unknown flags - variables := parseCustomVariables(cmd) - - return runScriptCommand(context.Background(), scriptFile, variables, cmd) - }, -} - -func init() { - rootCmd.AddCommand(scriptCmd) -} - -// overrideConfigWithFrontmatter parses the script file and overrides viper config with frontmatter values -// This is the only purpose of this function - to apply frontmatter configuration to viper -func overrideConfigWithFrontmatter(scriptFile string, variables map[string]string, cmd *cobra.Command) { - // Parse the script file to get frontmatter configuration - scriptConfig, err := parseScriptFile(scriptFile, variables) - if err != nil { - // If we can't parse the script file, just continue with existing config - // The error will be handled again in runScriptCommand - return - } - - // Override viper values with frontmatter values (only if flags weren't explicitly set) - // Check both local flags and persistent flags since script inherits from root - flagChanged := func(name string) bool { - return cmd.Flags().Changed(name) || rootCmd.PersistentFlags().Changed(name) - } - - if scriptConfig.Model != "" && !flagChanged("model") { - viper.Set("model", scriptConfig.Model) - } - if scriptConfig.MaxSteps != 0 && !flagChanged("max-steps") { - viper.Set("max-steps", scriptConfig.MaxSteps) - } - if scriptConfig.Debug && !flagChanged("debug") { - viper.Set("debug", scriptConfig.Debug) - } - if scriptConfig.Compact && !flagChanged("compact") { - viper.Set("compact", scriptConfig.Compact) - } - if scriptConfig.SystemPrompt != "" && !flagChanged("system-prompt") { - viper.Set("system-prompt", scriptConfig.SystemPrompt) - } - if scriptConfig.ProviderAPIKey != "" && !flagChanged("provider-api-key") { - viper.Set("provider-api-key", scriptConfig.ProviderAPIKey) - } - if scriptConfig.ProviderURL != "" && !flagChanged("provider-url") { - viper.Set("provider-url", scriptConfig.ProviderURL) - } - if scriptConfig.MaxTokens != 0 && !flagChanged("max-tokens") { - viper.Set("max-tokens", scriptConfig.MaxTokens) - } - if scriptConfig.Temperature != nil && !flagChanged("temperature") { - viper.Set("temperature", *scriptConfig.Temperature) - } - if scriptConfig.TopP != nil && !flagChanged("top-p") { - viper.Set("top-p", *scriptConfig.TopP) - } - if scriptConfig.TopK != nil && !flagChanged("top-k") { - viper.Set("top-k", *scriptConfig.TopK) - } - if len(scriptConfig.StopSequences) > 0 && !flagChanged("stop-sequences") { - viper.Set("stop-sequences", scriptConfig.StopSequences) - } - if scriptConfig.NoExit && !flagChanged("no-exit") { - // Set the global noExitFlag variable if it wasn't explicitly set via command line - noExitFlag = scriptConfig.NoExit - } - if scriptConfig.Stream != nil && !flagChanged("stream") { - viper.Set("stream", *scriptConfig.Stream) - } - if scriptConfig.TLSSkipVerify && !flagChanged("tls-skip-verify") { - viper.Set("tls-skip-verify", scriptConfig.TLSSkipVerify) - } -} - -// parseCustomVariables extracts custom variables from command line arguments -func parseCustomVariables(_ *cobra.Command) map[string]string { - variables := make(map[string]string) - - // Get all arguments passed to the command - args := os.Args[1:] // Skip program name - - // Find the script subcommand position - scriptPos := -1 - for i, arg := range args { - if arg == "script" { - scriptPos = i - break - } - } - - if scriptPos == -1 { - return variables - } - - // Parse arguments after the script file - scriptFileFound := false - - for i := scriptPos + 1; i < len(args); i++ { - arg := args[i] - - // Skip the script file argument (first non-flag after "script") - if !scriptFileFound && !strings.HasPrefix(arg, "-") { - scriptFileFound = true - continue - } - - // Parse custom variables with --args: prefix - if after, ok := strings.CutPrefix(arg, "--args:"); ok { - varName := after - if varName == "" { - continue // Skip malformed --args: without name - } - - // Check if we have a value - if i+1 < len(args) { - varValue := args[i+1] - - // Make sure the next arg isn't a flag - if !strings.HasPrefix(varValue, "-") { - variables[varName] = varValue - i++ // Skip the value - } else { - // No value provided, treat as empty string - variables[varName] = "" - } - } else { - // No value provided, treat as empty string - variables[varName] = "" - } - } - } - - return variables -} - -func runScriptCommand(ctx context.Context, scriptFile string, variables map[string]string, _ *cobra.Command) error { - // Parse the script file to get MCP servers and prompt - scriptConfig, err := parseScriptFile(scriptFile, variables) - if err != nil { - return fmt.Errorf("failed to parse script file: %v", err) - } - - // Get MCP config - use script servers if available, otherwise use global viper config - var mcpConfig *config.Config - if len(scriptConfig.MCPServers) > 0 { - // Load base config and merge with script config - baseConfig, err := config.LoadAndValidateConfig() - if err != nil { - return fmt.Errorf("failed to load base config: %v", err) - } - mcpConfig = config.MergeConfigs(baseConfig, scriptConfig) - } else { - // Use the new config loader - var err error - mcpConfig, err = config.LoadAndValidateConfig() - if err != nil { - return fmt.Errorf("failed to load MCP config: %v", err) - } - } - - // Get final prompt - prioritize command line flag, then script content - finalPrompt := viper.GetString("prompt") - if finalPrompt == "" && scriptConfig.Prompt != "" { - finalPrompt = scriptConfig.Prompt - } - - // Get final no-exit setting - prioritize command line flag, then script config - finalNoExit := noExitFlag || scriptConfig.NoExit - - // Validate that --no-exit is only used when there's a prompt - if finalNoExit && finalPrompt == "" { - return fmt.Errorf("--no-exit flag can only be used when there's a prompt (either from script content or --prompt flag)") - } - - // Run the script using the unified agentic loop - return runScriptMode(ctx, mcpConfig, finalPrompt, finalNoExit) -} - -// mergeScriptConfig and setScriptValuesInViper functions removed -// Configuration override is now handled in overrideConfigWithFrontmatter in the PreRun hook - -// parseScriptFile parses a script file with YAML frontmatter and returns config -func parseScriptFile(filename string, variables map[string]string) (*config.Config, error) { - file, err := os.Open(filename) - if err != nil { - return nil, err - } - defer func() { _ = file.Close() }() - - scanner := bufio.NewScanner(file) - - // Skip shebang line if present - if scanner.Scan() { - line := scanner.Text() - if !strings.HasPrefix(line, "#!") { - // If it's not a shebang, we need to process this line - return parseScriptContent(line+"\n"+readRemainingLines(scanner), variables) - } - } - - // Read the rest of the file - content := readRemainingLines(scanner) - return parseScriptContent(content, variables) -} - -// readRemainingLines reads all remaining lines from a scanner -func readRemainingLines(scanner *bufio.Scanner) string { - var lines []string - for scanner.Scan() { - lines = append(lines, scanner.Text()) - } - return strings.Join(lines, "\n") -} - -// parseScriptContent parses the content to extract YAML frontmatter and prompt -func parseScriptContent(content string, variables map[string]string) (*config.Config, error) { - // STEP 1: Apply environment variable substitution FIRST - envSubstituter := &config.EnvSubstituter{} - processedContent, err := envSubstituter.SubstituteEnvVars(content) - if err != nil { - return nil, fmt.Errorf("script env substitution failed: %v", err) - } - - // STEP 2: Validate that all declared script variables are provided - if err := validateVariables(processedContent, variables); err != nil { - return nil, err - } - - // STEP 3: Apply script args substitution - argsSubstituter := config.NewArgsSubstituter(variables) - content, err = argsSubstituter.SubstituteArgs(processedContent) - if err != nil { - return nil, fmt.Errorf("script args substitution failed: %v", err) - } - - lines := strings.Split(content, "\n") - - // Find YAML frontmatter between --- delimiters - var yamlLines []string - var promptLines []string - var inFrontmatter bool - var foundFrontmatter bool - var frontmatterEnd = -1 - - for i, line := range lines { - trimmed := strings.TrimSpace(line) - - // Skip comment lines (lines starting with #) - if strings.HasPrefix(trimmed, "#") { - continue - } - - // Check for frontmatter start - if trimmed == "---" && !inFrontmatter { - // Start of frontmatter - inFrontmatter = true - foundFrontmatter = true - continue - } - - // Check for frontmatter end - if trimmed == "---" && inFrontmatter { - // End of frontmatter - inFrontmatter = false - frontmatterEnd = i + 1 - continue - } - - // Collect frontmatter lines - if inFrontmatter { - yamlLines = append(yamlLines, line) - } - } - - // Extract prompt (everything after frontmatter) - if foundFrontmatter && frontmatterEnd != -1 && frontmatterEnd < len(lines) { - promptLines = lines[frontmatterEnd:] - } else if !foundFrontmatter { - // If no frontmatter found, treat entire content as prompt - promptLines = lines - yamlLines = []string{} // Empty YAML - } - - // Parse YAML frontmatter using Viper for consistency with config file parsing - var scriptConfig config.Config - if len(yamlLines) > 0 { - yamlContent := strings.Join(yamlLines, "\n") - - // Create temporary viper instance for frontmatter parsing - frontmatterViper := viper.New() - frontmatterViper.SetConfigType("yaml") - - if err := frontmatterViper.ReadConfig(strings.NewReader(yamlContent)); err != nil { - return nil, fmt.Errorf("failed to parse YAML frontmatter: %v\nYAML content:\n%s", err, yamlContent) - } - - if err := frontmatterViper.Unmarshal(&scriptConfig); err != nil { - return nil, fmt.Errorf("failed to unmarshal frontmatter config: %v", err) - } - - // Manually extract hyphenated keys that Viper might not handle correctly during unmarshal - if providerURL := frontmatterViper.GetString("provider-url"); providerURL != "" { - scriptConfig.ProviderURL = providerURL - } - if providerAPIKey := frontmatterViper.GetString("provider-api-key"); providerAPIKey != "" { - scriptConfig.ProviderAPIKey = providerAPIKey - } - if systemPrompt := frontmatterViper.GetString("system-prompt"); systemPrompt != "" { - scriptConfig.SystemPrompt = systemPrompt - } - if maxSteps := frontmatterViper.GetInt("max-steps"); maxSteps != 0 { - scriptConfig.MaxSteps = maxSteps - } - if maxTokens := frontmatterViper.GetInt("max-tokens"); maxTokens != 0 { - scriptConfig.MaxTokens = maxTokens - } - if topP := frontmatterViper.GetFloat64("top-p"); topP != 0 { - topPFloat32 := float32(topP) - scriptConfig.TopP = &topPFloat32 - } - if topK := frontmatterViper.GetInt("top-k"); topK != 0 { - topKInt32 := int32(topK) - scriptConfig.TopK = &topKInt32 - } - if stopSequences := frontmatterViper.GetStringSlice("stop-sequences"); len(stopSequences) > 0 { - scriptConfig.StopSequences = stopSequences - } - if noExit := frontmatterViper.GetBool("no-exit"); noExit { - scriptConfig.NoExit = noExit - } - if tlsSkipVerify := frontmatterViper.GetBool("tls-skip-verify"); tlsSkipVerify { - scriptConfig.TLSSkipVerify = tlsSkipVerify - } - } - - // Set prompt from content after frontmatter - if len(promptLines) > 0 { - prompt := strings.Join(promptLines, "\n") - prompt = strings.TrimSpace(prompt) // Remove leading/trailing whitespace - if prompt != "" { - scriptConfig.Prompt = prompt - } - } - - return &scriptConfig, nil -} - -// Variable represents a script variable with optional default value. -// Variables can be declared in scripts using ${variable} syntax for required variables -// or ${variable:-default} syntax for variables with default values. -type Variable struct { - Name string // The name of the variable as it appears in the script - DefaultValue string // The default value if specified using ${variable:-default} syntax - HasDefault bool // Whether this variable has a default value -} - -// findVariables extracts all unique variable names from ${variable} patterns in content -// Maintains backward compatibility by returning just variable names -func findVariables(content string) []string { - variables := findVariablesWithDefaults(content) - var names []string - for _, v := range variables { - names = append(names, v.Name) - } - return names -} - -// findVariablesWithDefaults extracts all unique variables with their default values -// Supports both ${variable} and ${variable:-default} syntax -func findVariablesWithDefaults(content string) []Variable { - // Pattern matches: - // ${varname} - simple variable - // ${varname:-default} - variable with default value - re := regexp.MustCompile(`\$\{([^}:]+)(?::-([^}]*))?\}`) - matches := re.FindAllStringSubmatch(content, -1) - - seenVars := make(map[string]bool) - var variables []Variable - - for _, match := range matches { - if len(match) >= 2 { - varName := match[1] - if !seenVars[varName] { - seenVars[varName] = true - - // Check if the original match contains the :- pattern - hasDefault := strings.Contains(match[0], ":-") - - variable := Variable{ - Name: varName, - HasDefault: hasDefault, - } - - if hasDefault && len(match) >= 3 { - variable.DefaultValue = match[2] // Can be empty string - } - - variables = append(variables, variable) - } - } - } - - return variables -} - -// validateVariables checks that all declared variables in the content are provided -// Variables with default values are not required -func validateVariables(content string, variables map[string]string) error { - declaredVars := findVariablesWithDefaults(content) - - var missingVars []string - for _, variable := range declaredVars { - if _, exists := variables[variable.Name]; !exists && !variable.HasDefault { - missingVars = append(missingVars, variable.Name) - } - } - - if len(missingVars) > 0 { - return fmt.Errorf("missing required variables: %s\nProvide them using --args:variable value syntax", strings.Join(missingVars, ", ")) - } - - return nil -} - -// substituteVariables replaces ${variable} and ${variable:-default} patterns with their values -// This function is kept for backward compatibility but now uses the shared ArgsSubstituter -func substituteVariables(content string, variables map[string]string) string { - substituter := config.NewArgsSubstituter(variables) - result, err := substituter.SubstituteArgs(content) - if err != nil { - // For backward compatibility, if there's an error, return the original content - // This maintains the existing behavior where missing variables were left as-is - return content - } - return result -} - -// runScriptMode executes the script using the unified agentic loop -func runScriptMode(ctx context.Context, mcpConfig *config.Config, prompt string, noExit bool) error { - // Set up logging - if debugMode || mcpConfig.Debug { - log.SetFlags(log.LstdFlags | log.Lshortfile) - } - - // Create agent using shared setup. Script frontmatter values are already - // merged into viper by overrideConfigWithFrontmatter (PreRun hook), so - // BuildProviderConfig inside SetupAgent reads the correct final values. - agentResult, err := SetupAgent(ctx, AgentSetupOptions{ - MCPConfig: mcpConfig, - }) - if err != nil { - return err - } - mcpAgent := agentResult.Agent - defer func() { _ = mcpAgent.Close() }() - - // Collect model/server/tool metadata. - parsedProvider, modelName, serverNames, toolNames := CollectAgentMetadata(mcpAgent, mcpConfig) - - // Create CLI display layer. - cli, err := SetupCLIForNonInteractive(mcpAgent) - if err != nil { - return fmt.Errorf("failed to setup CLI: %v", err) - } - - DisplayDebugConfig(cli, mcpAgent, mcpConfig, parsedProvider) - - // Build app options. - appOpts := BuildAppOptions(mcpAgent, mcpConfig, modelName, serverNames, toolNames, agentResult.ExtRunner) - if cli != nil { - if tracker := cli.GetUsageTracker(); tracker != nil { - appOpts.UsageTracker = tracker - } - } - - appInstance := app.New(appOpts, nil) - defer appInstance.Close() - - if quietFlag { - // Quiet mode: no intermediate display, just print final response. - return appInstance.RunOnce(ctx, prompt) - } - - // Display user message before running the agent. - if cli != nil { - cli.DisplayUserMessage(prompt) - } - - // Build an event handler that routes app events to the CLI. - eventHandler := ui.NewCLIEventHandler(cli, modelName) - err = appInstance.RunOnceWithDisplay(ctx, prompt, eventHandler.Handle) - eventHandler.Cleanup() - return err -} diff --git a/cmd/script_deepseek_test.go b/cmd/script_deepseek_test.go deleted file mode 100644 index 9e2624b0..00000000 --- a/cmd/script_deepseek_test.go +++ /dev/null @@ -1,162 +0,0 @@ -package cmd - -import ( - "context" - "os" - "path/filepath" - "strings" - "testing" - - kit "github.com/mark3labs/kit/pkg/kit" -) - -// TestDeepSeekChatScriptMode tests the regression where deepseek-chat model -// works in CLI mode but fails in script mode due to provider-url not being -// properly passed to model validation logic. -func TestDeepSeekChatScriptMode(t *testing.T) { - // Create a temporary script file that mimics the issue scenario - tempDir := t.TempDir() - scriptPath := filepath.Join(tempDir, "deepseek-script.sh") - - scriptContent := `#!/usr/bin/env -S kit script ---- -model: "openai/deepseek-chat" -provider-url: "https://api.deepseek.com/v1" -provider-api-key: "${env://DEEPSEEK_API_KEY}" ---- -Calculate 3 times 4 equal to? -` - - err := os.WriteFile(scriptPath, []byte(scriptContent), 0644) - if err != nil { - t.Fatalf("Failed to write test script: %v", err) - } - - // Set up environment variable - _ = os.Setenv("DEEPSEEK_API_KEY", "sk-test-key") - defer func() { _ = os.Unsetenv("DEEPSEEK_API_KEY") }() - - // Parse the script file - variables := map[string]string{} - scriptConfig, err := parseScriptFile(scriptPath, variables) - if err != nil { - t.Fatalf("Failed to parse script: %v", err) - } - - // Verify the script config has the correct values - if scriptConfig.Model != "openai/deepseek-chat" { - t.Errorf("Expected model=openai/deepseek-chat, got %s", scriptConfig.Model) - } - if scriptConfig.ProviderURL != "https://api.deepseek.com/v1" { - t.Errorf("Expected provider-url=https://api.deepseek.com/v1, got %s", scriptConfig.ProviderURL) - } - if scriptConfig.ProviderAPIKey != "sk-test-key" { - t.Errorf("Expected provider-api-key=sk-test-key, got %s", scriptConfig.ProviderAPIKey) - } - - // Now test the actual model creation - this should NOT fail when provider-url is set - providerConfig := &kit.ProviderConfig{ - ModelString: scriptConfig.Model, - ProviderAPIKey: scriptConfig.ProviderAPIKey, - ProviderURL: scriptConfig.ProviderURL, - MaxTokens: scriptConfig.MaxTokens, - Temperature: scriptConfig.Temperature, - TopP: scriptConfig.TopP, - TopK: scriptConfig.TopK, - StopSequences: scriptConfig.StopSequences, - } - - // This should succeed because provider-url is set, which should skip model validation - ctx := context.Background() - _, err = kit.CreateProvider(ctx, providerConfig) - - // We expect this to fail with a connection error (since we're using a fake API key), - // NOT with a "model not found" error. The "model not found" error indicates - // that validation wasn't properly skipped. - if err != nil { - errStr := err.Error() - if strings.Contains(errStr, "model deepseek-chat not found for provider openai") { - t.Errorf("Model validation should be skipped when provider-url is set, but got validation error: %v", err) - } - // Other errors (like connection errors) are expected and acceptable for this test - t.Logf("Expected error (not model validation): %v", err) - } -} - -// TestDeepSeekChatCLIMode tests that the CLI mode works correctly with custom provider URL -func TestDeepSeekChatCLIMode(t *testing.T) { - // Test the CLI mode behavior - this should work - providerConfig := &kit.ProviderConfig{ - ModelString: "openai/deepseek-chat", - ProviderAPIKey: "sk-test-key", - ProviderURL: "https://api.deepseek.com/v1", // This should skip validation - MaxTokens: 0, - } - - ctx := context.Background() - _, err := kit.CreateProvider(ctx, providerConfig) - - // We expect this to fail with a connection error (since we're using a fake API key), - // NOT with a "model not found" error - if err != nil { - errStr := err.Error() - if strings.Contains(errStr, "model deepseek-chat not found for provider openai") { - t.Errorf("CLI mode should skip validation when provider-url is set, but got validation error: %v", err) - } - // Other errors (like connection errors) are expected and acceptable for this test - t.Logf("Expected error (not model validation): %v", err) - } -} - -// TestProviderURLValidationSkip tests that model validation is properly skipped -// when a custom provider URL is provided -func TestProviderURLValidationSkip(t *testing.T) { - // Validation is now advisory — unknown models are passed through to - // the provider API rather than being rejected by the local registry. - // All these cases should NOT produce a "not found for provider" error. - testCases := []struct { - name string - model string - providerURL string - }{ - { - name: "OpenAI with custom URL passes through", - model: "openai/custom-model", - providerURL: "https://api.custom.com/v1", - }, - { - name: "OpenAI without custom URL passes through (advisory validation)", - model: "openai/custom-model", - providerURL: "", - }, - { - name: "Ollama always passes through", - model: "ollama/custom-model", - providerURL: "", - }, - { - name: "Anthropic with custom URL passes through", - model: "anthropic/custom-model", - providerURL: "https://api.custom.com/v1", - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - providerConfig := &kit.ProviderConfig{ - ModelString: tc.model, - ProviderAPIKey: "test-key", - ProviderURL: tc.providerURL, - } - - ctx := context.Background() - _, err := kit.CreateProvider(ctx, providerConfig) - - // Should never get a "not found for provider" error — unknown - // models are passed through to the provider API. - if err != nil && strings.Contains(err.Error(), "not found for provider") { - t.Errorf("Expected unknown model to pass through, but got validation error: %v", err) - } - }) - } -} diff --git a/cmd/script_integration_test.go b/cmd/script_integration_test.go deleted file mode 100644 index 8260f621..00000000 --- a/cmd/script_integration_test.go +++ /dev/null @@ -1,257 +0,0 @@ -package cmd - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -func TestScriptWithEnvAndArgsSubstitution(t *testing.T) { - // Create a temporary script file with both env vars and script args - tempDir := t.TempDir() - scriptPath := filepath.Join(tempDir, "test-script.sh") - - scriptContent := `#!/usr/bin/env -S kit script ---- -mcpServers: - github: - type: local - command: ["gh", "api"] - environment: - GITHUB_TOKEN: "${env://GITHUB_TOKEN}" - DEBUG: "${env://DEBUG:-false}" - -model: "${env://MODEL:-anthropic/claude-sonnet-4-5-20250929}" -debug: ${env://DEBUG:-false} ---- -List ${repo_type:-public} repositories for user ${username}. -Use the GitHub API to fetch ${count:-10} repositories. -Working directory is ${env://WORK_DIR:-/tmp}. -` - - err := os.WriteFile(scriptPath, []byte(scriptContent), 0644) - if err != nil { - t.Fatalf("Failed to write test script: %v", err) - } - - // Set up environment variables - _ = os.Setenv("GITHUB_TOKEN", "ghp_test_token") - _ = os.Setenv("DEBUG", "true") - _ = os.Setenv("WORK_DIR", "/home/user/projects") - defer func() { - _ = os.Unsetenv("GITHUB_TOKEN") - _ = os.Unsetenv("DEBUG") - _ = os.Unsetenv("WORK_DIR") - }() - - // Set up script arguments - variables := map[string]string{ - "username": "alice", - "repo_type": "private", - } - - // Parse the script - scriptConfig, err := parseScriptFile(scriptPath, variables) - if err != nil { - t.Fatalf("Failed to parse script: %v", err) - } - - // Verify environment variable substitution in MCP servers - githubServer, exists := scriptConfig.MCPServers["github"] - if !exists { - t.Fatal("GitHub server not found in script config") - } - - if githubServer.Environment["github_token"] != "ghp_test_token" { - t.Errorf("Expected github_token=ghp_test_token, got %s", githubServer.Environment["github_token"]) - } - if githubServer.Environment["debug"] != "true" { - t.Errorf("Expected debug=true, got %s", githubServer.Environment["debug"]) - } - - // Verify global config values - if scriptConfig.Model != "anthropic/claude-sonnet-4-5-20250929" { - t.Errorf("Expected model=anthropic/claude-sonnet-4-5-20250929, got %s", scriptConfig.Model) - } - if !scriptConfig.Debug { - t.Error("Expected debug=true") - } - - // Verify script args substitution in prompt - expectedPrompt := `List private repositories for user alice. -Use the GitHub API to fetch 10 repositories. -Working directory is /home/user/projects.` - - if strings.TrimSpace(scriptConfig.Prompt) != strings.TrimSpace(expectedPrompt) { - t.Errorf("Expected prompt:\n%s\nGot:\n%s", expectedPrompt, scriptConfig.Prompt) - } -} - -func TestScriptWithMissingRequiredEnvVar(t *testing.T) { - // Create a script with a required environment variable - tempDir := t.TempDir() - scriptPath := filepath.Join(tempDir, "test-script.sh") - - scriptContent := `#!/usr/bin/env -S kit script ---- -mcpServers: - github: - type: local - command: ["gh", "api"] - environment: - GITHUB_TOKEN: "${env://REQUIRED_TOKEN}" ---- -Test script with required env var. -` - - err := os.WriteFile(scriptPath, []byte(scriptContent), 0644) - if err != nil { - t.Fatalf("Failed to write test script: %v", err) - } - - // Make sure the environment variable is not set - _ = os.Unsetenv("REQUIRED_TOKEN") - - // Parse the script - should fail - variables := map[string]string{} - _, err = parseScriptFile(scriptPath, variables) - if err == nil { - t.Fatal("Expected error for missing required environment variable") - } - - if !strings.Contains(err.Error(), "required environment variable REQUIRED_TOKEN not set") { - t.Errorf("Expected error about missing REQUIRED_TOKEN, got: %v", err) - } -} - -func TestScriptWithMissingRequiredArg(t *testing.T) { - // Create a script with a required script argument - tempDir := t.TempDir() - scriptPath := filepath.Join(tempDir, "test-script.sh") - - scriptContent := `#!/usr/bin/env -S kit script ---- -mcpServers: - github: - type: local - command: ["gh", "api"] - environment: - GITHUB_TOKEN: "${env://GITHUB_TOKEN:-default_token}" ---- -List repositories for user ${required_username}. -` - - err := os.WriteFile(scriptPath, []byte(scriptContent), 0644) - if err != nil { - t.Fatalf("Failed to write test script: %v", err) - } - - // Parse the script without providing the required argument - variables := map[string]string{} - _, err = parseScriptFile(scriptPath, variables) - if err == nil { - t.Fatal("Expected error for missing required script argument") - } - - if !strings.Contains(err.Error(), "required_username") { - t.Errorf("Expected error about missing required_username, got: %v", err) - } -} - -func TestScriptProcessingOrder(t *testing.T) { - // Test that env substitution happens before args substitution - tempDir := t.TempDir() - scriptPath := filepath.Join(tempDir, "test-script.sh") - - // This script tests that env vars are processed first, then script args - scriptContent := `#!/usr/bin/env -S kit script ---- -mcpServers: - test: - type: local - command: ["echo", "${env://BASE_PATH:-/tmp}/${path_suffix}"] ---- -Base path is ${env://BASE_PATH:-/tmp} and suffix is ${path_suffix:-default}. -` - - err := os.WriteFile(scriptPath, []byte(scriptContent), 0644) - if err != nil { - t.Fatalf("Failed to write test script: %v", err) - } - - // Set environment variable - _ = os.Setenv("BASE_PATH", "/home/user") - defer func() { _ = os.Unsetenv("BASE_PATH") }() - - // Set script argument - variables := map[string]string{ - "path_suffix": "documents", - } - - // Parse the script - scriptConfig, err := parseScriptFile(scriptPath, variables) - if err != nil { - t.Fatalf("Failed to parse script: %v", err) - } - - // Verify that both substitutions worked correctly - testServer := scriptConfig.MCPServers["test"] - expectedCommand := []string{"echo", "/home/user/documents"} - - if len(testServer.Command) != len(expectedCommand) { - t.Errorf("Expected command length %d, got %d", len(expectedCommand), len(testServer.Command)) - } - - for i, expected := range expectedCommand { - if i < len(testServer.Command) && testServer.Command[i] != expected { - t.Errorf("Expected command[%d] = %s, got %s", i, expected, testServer.Command[i]) - } - } - - // Verify prompt substitution - expectedPrompt := "Base path is /home/user and suffix is documents." - if strings.TrimSpace(scriptConfig.Prompt) != expectedPrompt { - t.Errorf("Expected prompt: %s\nGot: %s", expectedPrompt, strings.TrimSpace(scriptConfig.Prompt)) - } -} - -func TestScriptBackwardCompatibility(t *testing.T) { - // Test that existing scripts without env vars still work - tempDir := t.TempDir() - scriptPath := filepath.Join(tempDir, "test-script.sh") - - scriptContent := `#!/usr/bin/env -S kit script ---- -model: "anthropic/claude-sonnet-4-5-20250929" ---- -List files in ${directory:-/tmp} for user ${username}. -` - - err := os.WriteFile(scriptPath, []byte(scriptContent), 0644) - if err != nil { - t.Fatalf("Failed to write test script: %v", err) - } - - // Set script arguments - variables := map[string]string{ - "username": "bob", - } - - // Parse the script - scriptConfig, err := parseScriptFile(scriptPath, variables) - if err != nil { - t.Fatalf("Failed to parse script: %v", err) - } - - // Verify that script args substitution still works - expectedPrompt := "List files in /tmp for user bob." - if strings.TrimSpace(scriptConfig.Prompt) != expectedPrompt { - t.Errorf("Expected prompt: %s\nGot: %s", expectedPrompt, strings.TrimSpace(scriptConfig.Prompt)) - } - - // Verify that config is unchanged - if scriptConfig.Model != "anthropic/claude-sonnet-4-5-20250929" { - t.Errorf("Expected model unchanged, got %s", scriptConfig.Model) - } -} diff --git a/cmd/script_test.go b/cmd/script_test.go deleted file mode 100644 index d62f1b4c..00000000 --- a/cmd/script_test.go +++ /dev/null @@ -1,432 +0,0 @@ -package cmd - -import ( - "reflect" - "testing" -) - -func TestFindVariablesWithDefaults(t *testing.T) { - tests := []struct { - name string - content string - expected []Variable - }{ - { - name: "simple variable without default", - content: "Hello ${name}!", - expected: []Variable{ - {Name: "name", DefaultValue: "", HasDefault: false}, - }, - }, - { - name: "variable with default value", - content: "Hello ${name:-World}!", - expected: []Variable{ - {Name: "name", DefaultValue: "World", HasDefault: true}, - }, - }, - { - name: "variable with empty default", - content: "Hello ${name:-}!", - expected: []Variable{ - {Name: "name", DefaultValue: "", HasDefault: true}, - }, - }, - { - name: "multiple variables mixed", - content: "Hello ${name:-World}! Your directory is ${directory} and your age is ${age:-25}.", - expected: []Variable{ - {Name: "name", DefaultValue: "World", HasDefault: true}, - {Name: "directory", DefaultValue: "", HasDefault: false}, - {Name: "age", DefaultValue: "25", HasDefault: true}, - }, - }, - { - name: "duplicate variables", - content: "Hello ${name:-World}! Again, hello ${name:-Universe}!", - expected: []Variable{ - {Name: "name", DefaultValue: "World", HasDefault: true}, - }, - }, - { - name: "no variables", - content: "Hello World!", - expected: nil, - }, - { - name: "complex default values", - content: "Path: ${path:-/tmp/default/path} and URL: ${url:-https://example.com/api}", - expected: []Variable{ - {Name: "path", DefaultValue: "/tmp/default/path", HasDefault: true}, - {Name: "url", DefaultValue: "https://example.com/api", HasDefault: true}, - }, - }, - { - name: "default with spaces", - content: "Message: ${msg:-Hello World}", - expected: []Variable{ - {Name: "msg", DefaultValue: "Hello World", HasDefault: true}, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := findVariablesWithDefaults(tt.content) - if !reflect.DeepEqual(result, tt.expected) { - t.Errorf("findVariablesWithDefaults() = %+v, want %+v", result, tt.expected) - } - }) - } -} - -func TestFindVariablesBackwardCompatibility(t *testing.T) { - tests := []struct { - name string - content string - expected []string - }{ - { - name: "simple variables", - content: "Hello ${name} from ${location}!", - expected: []string{"name", "location"}, - }, - { - name: "variables with defaults should still return names", - content: "Hello ${name:-World} from ${location:-Earth}!", - expected: []string{"name", "location"}, - }, - { - name: "mixed variables", - content: "Hello ${name} from ${location:-Earth}!", - expected: []string{"name", "location"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := findVariables(tt.content) - if !reflect.DeepEqual(result, tt.expected) { - t.Errorf("findVariables() = %v, want %v", result, tt.expected) - } - }) - } -} - -func TestValidateVariables(t *testing.T) { - tests := []struct { - name string - content string - variables map[string]string - wantError bool - }{ - { - name: "all required variables provided", - content: "Hello ${name} from ${location}!", - variables: map[string]string{"name": "John", "location": "NYC"}, - wantError: false, - }, - { - name: "missing required variable", - content: "Hello ${name} from ${location}!", - variables: map[string]string{"name": "John"}, - wantError: true, - }, - { - name: "variable with default not provided - should not error", - content: "Hello ${name:-World}!", - variables: map[string]string{}, - wantError: false, - }, - { - name: "mixed required and optional variables", - content: "Hello ${name} from ${location:-Earth}!", - variables: map[string]string{"name": "John"}, - wantError: false, - }, - { - name: "mixed variables with missing required", - content: "Hello ${name} from ${location:-Earth}!", - variables: map[string]string{}, - wantError: true, - }, - { - name: "all variables have defaults", - content: "Hello ${name:-World} from ${location:-Earth}!", - variables: map[string]string{}, - wantError: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := validateVariables(tt.content, tt.variables) - if (err != nil) != tt.wantError { - t.Errorf("validateVariables() error = %v, wantError %v", err, tt.wantError) - } - }) - } -} - -func TestSubstituteVariables(t *testing.T) { - tests := []struct { - name string - content string - variables map[string]string - expected string - }{ - { - name: "simple substitution", - content: "Hello ${name}!", - variables: map[string]string{"name": "John"}, - expected: "Hello John!", - }, - { - name: "substitution with default - value provided", - content: "Hello ${name:-World}!", - variables: map[string]string{"name": "John"}, - expected: "Hello John!", - }, - { - name: "substitution with default - value not provided", - content: "Hello ${name:-World}!", - variables: map[string]string{}, - expected: "Hello World!", - }, - { - name: "multiple variables mixed", - content: "Hello ${name:-World} from ${location}!", - variables: map[string]string{"location": "NYC"}, - expected: "Hello World from NYC!", - }, - { - name: "empty default value", - content: "Hello ${name:-}!", - variables: map[string]string{}, - expected: "Hello !", - }, - { - name: "complex default values", - content: "Path: ${path:-/tmp/default} URL: ${url:-https://example.com}", - variables: map[string]string{}, - expected: "Path: /tmp/default URL: https://example.com", - }, - { - name: "variable not found and no default", - content: "Hello ${name}!", - variables: map[string]string{}, - expected: "Hello ${name}!", - }, - { - name: "default with spaces", - content: "Message: ${msg:-Hello World}", - variables: map[string]string{}, - expected: "Message: Hello World", - }, - { - name: "override default with provided value", - content: "Message: ${msg:-Hello World}", - variables: map[string]string{"msg": "Custom Message"}, - expected: "Message: Custom Message", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := substituteVariables(tt.content, tt.variables) - if result != tt.expected { - t.Errorf("substituteVariables() = %q, want %q", result, tt.expected) - } - }) - } -} - -func TestBackwardCompatibility(t *testing.T) { - // Test that existing scripts without default syntax continue to work - content := `--- -model: "anthropic/claude-sonnet-4-5-20250929" ---- -Hello ${name}! Please analyze ${directory}.` - - variables := map[string]string{ - "name": "John", - "directory": "/tmp", - } - - // Should not error during validation - err := validateVariables(content, variables) - if err != nil { - t.Errorf("validateVariables() should not error for backward compatibility, got: %v", err) - } - - // Should substitute correctly - result := substituteVariables(content, variables) - expected := `--- -model: "anthropic/claude-sonnet-4-5-20250929" ---- -Hello John! Please analyze /tmp.` - - if result != expected { - t.Errorf("substituteVariables() backward compatibility failed.\nGot:\n%s\nWant:\n%s", result, expected) - } -} - -func TestParseScriptContentWithCompactMode(t *testing.T) { - content := `--- -compact: true -mcpServers: - echo: - type: "local" - command: ["echo", "hello"] ---- -Test prompt with compact mode` - - variables := make(map[string]string) - config, err := parseScriptContent(content, variables) - if err != nil { - t.Fatalf("parseScriptContent() failed: %v", err) - } - - if !config.Compact { - t.Errorf("Expected compact mode to be true, got false") - } - - if config.Prompt != "Test prompt with compact mode" { - t.Errorf("Expected prompt 'Test prompt with compact mode', got '%s'", config.Prompt) - } - - if len(config.MCPServers) != 1 { - t.Errorf("Expected 1 MCP server, got %d", len(config.MCPServers)) - } -} - -func TestParseScriptContentMCPServersNewFormat(t *testing.T) { - content := `--- -model: "anthropic/claude-sonnet-4-5-20250929" -mcpServers: - filesystem: - type: "local" - command: ["npx", "-y", "@modelcontextprotocol/server-filesystem", "/tmp"] - environment: - NODE_ENV: "production" - remote-server: - type: "remote" - url: "https://example.com/mcp" ---- -Test prompt with new format MCP servers` - - variables := make(map[string]string) - config, err := parseScriptContent(content, variables) - if err != nil { - t.Fatalf("parseScriptContent() failed: %v", err) - } - - if len(config.MCPServers) != 2 { - t.Errorf("Expected 2 MCP servers, got %d", len(config.MCPServers)) - } - - // Test local server - fs, exists := config.MCPServers["filesystem"] - if !exists { - t.Error("Expected filesystem server to exist") - } - if fs.Type != "local" { - t.Errorf("Expected filesystem server type 'local', got '%s'", fs.Type) - } - expectedCommand := []string{"npx", "-y", "@modelcontextprotocol/server-filesystem", "/tmp"} - if len(fs.Command) != len(expectedCommand) { - t.Errorf("Expected filesystem server command length %d, got %d", len(expectedCommand), len(fs.Command)) - } - for i, expected := range expectedCommand { - if i >= len(fs.Command) || fs.Command[i] != expected { - t.Errorf("Expected filesystem server command %v, got %v", expectedCommand, fs.Command) - break - } - } - if fs.Environment["node_env"] != "production" { - t.Errorf("Expected node_env=production, got %s", fs.Environment["node_env"]) - } - - // Test remote server - remote, exists := config.MCPServers["remote-server"] - if !exists { - t.Error("Expected remote-server to exist") - } - if remote.Type != "remote" { - t.Errorf("Expected remote server type 'remote', got '%s'", remote.Type) - } - if remote.URL != "https://example.com/mcp" { - t.Errorf("Expected remote server URL 'https://example.com/mcp', got '%s'", remote.URL) - } -} - -func TestParseScriptContentMCPServersLegacyFormat(t *testing.T) { - content := `--- -model: "anthropic/claude-sonnet-4-5-20250929" -mcpServers: - legacy-stdio: - transport: "stdio" - command: "npx" - args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] - env: - node_env: "development" - legacy-sse: - transport: "sse" - url: "https://legacy.example.com/mcp" - headers: ["Authorization: Bearer token"] ---- -Test prompt with legacy format MCP servers` - - variables := make(map[string]string) - config, err := parseScriptContent(content, variables) - if err != nil { - t.Fatalf("parseScriptContent() failed: %v", err) - } - - if len(config.MCPServers) != 2 { - t.Errorf("Expected 2 MCP servers, got %d", len(config.MCPServers)) - } - - // Test legacy stdio server - Note: Viper parsing doesn't trigger custom UnmarshalJSON - // so legacy format has limited support in script frontmatter - stdio, exists := config.MCPServers["legacy-stdio"] - if !exists { - t.Error("Expected legacy-stdio server to exist") - } - if stdio.Transport != "stdio" { - t.Errorf("Expected legacy stdio transport 'stdio', got '%s'", stdio.Transport) - } - // Command field only gets the single command value, not combined with args - if stdio.Command[0] != "npx" { - t.Errorf("Expected legacy stdio command to start with 'npx', got %v", stdio.Command) - } - expectedArgs := []string{"-y", "@modelcontextprotocol/server-filesystem", "/tmp"} - if len(stdio.Args) != len(expectedArgs) { - t.Errorf("Expected legacy stdio args length %d, got %d", len(expectedArgs), len(stdio.Args)) - } - for i, expected := range expectedArgs { - if i >= len(stdio.Args) || stdio.Args[i] != expected { - t.Errorf("Expected legacy stdio args %v, got %v", expectedArgs, stdio.Args) - break - } - } - // Env field should contain the environment variables (with lowercase keys due to Viper) - if stdio.Env["node_env"] != "development" { - t.Errorf("Expected legacy stdio env node_env=development, got %v", stdio.Env["node_env"]) - } - - // Test legacy SSE server - sse, exists := config.MCPServers["legacy-sse"] - if !exists { - t.Error("Expected legacy-sse server to exist") - } - if sse.Transport != "sse" { - t.Errorf("Expected legacy sse transport 'sse', got '%s'", sse.Transport) - } - if sse.URL != "https://legacy.example.com/mcp" { - t.Errorf("Expected legacy sse URL 'https://legacy.example.com/mcp', got '%s'", sse.URL) - } - if len(sse.Headers) != 1 || sse.Headers[0] != "Authorization: Bearer token" { - t.Errorf("Expected legacy sse headers [Authorization: Bearer token], got %v", sse.Headers) - } -} diff --git a/cmd/setup.go b/cmd/setup.go index 59a76a15..aa6f3e90 100644 --- a/cmd/setup.go +++ b/cmd/setup.go @@ -35,8 +35,7 @@ func SetupAgent(ctx context.Context, opts AgentSetupOptions) (*AgentSetupResult, } // CollectAgentMetadata extracts model display info and tool/server name lists -// from the agent. This is used by both root.go and script.go to populate -// app.Options and UI setup. +// from the agent, used to populate app.Options and UI setup. func CollectAgentMetadata(mcpAgent *agent.Agent, mcpConfig *config.Config) (provider, modelName string, serverNames, toolNames []string) { modelString := viper.GetString("model") provider, modelName, _ = kit.ParseModelString(modelString) @@ -57,7 +56,6 @@ func CollectAgentMetadata(mcpAgent *agent.Agent, mcpConfig *config.Config) (prov } // BuildAppOptions constructs the app.Options struct from the current state. -// Both root.go and script.go converge here after agent creation. func BuildAppOptions(mcpAgent *agent.Agent, mcpConfig *config.Config, modelName string, serverNames, toolNames []string, extRunner *extensions.Runner) app.Options { return app.Options{ Agent: mcpAgent, @@ -74,7 +72,7 @@ func BuildAppOptions(mcpAgent *agent.Agent, mcpConfig *config.Config, modelName } // DisplayDebugConfig builds and displays the debug configuration map through -// the CLI. Shared by root.go (non-interactive) and script.go. +// the CLI for non-interactive mode. func DisplayDebugConfig(cli *ui.CLI, mcpAgent *agent.Agent, mcpConfig *config.Config, provider string) { if quietFlag || cli == nil || !viper.GetBool("debug") { return @@ -152,7 +150,7 @@ func DisplayDebugConfig(cli *ui.CLI, mcpAgent *agent.Agent, mcpConfig *config.Co } // SetupCLIForNonInteractive creates the CLI display layer for non-interactive -// modes (--prompt and script). Returns nil when quiet mode is active. +// mode (--prompt). Returns nil when quiet mode is active. func SetupCLIForNonInteractive(mcpAgent *agent.Agent) (*ui.CLI, error) { agentAdapter := &agentUIAdapter{agent: mcpAgent} return ui.SetupCLI(&ui.CLISetupOptions{ diff --git a/examples/hooks/bash-validator.py b/examples/hooks/bash-validator.py deleted file mode 100755 index e3120a24..00000000 --- a/examples/hooks/bash-validator.py +++ /dev/null @@ -1,57 +0,0 @@ -#!/usr/bin/env python3 -""" -Validates bash commands before execution. -Blocks dangerous commands and suggests alternatives. -""" -import json -import sys -import re - -# Define validation rules -DANGEROUS_PATTERNS = [ - (r'\brm\s+-rf\s+/', "Dangerous command: rm -rf /"), - (r'\bdd\s+.*\bof=/dev/[sh]d[a-z]', "Direct disk write detected"), - (r'>\s*/dev/null\s+2>&1', "Consider using proper error handling instead of discarding stderr"), -] - -SUGGEST_ALTERNATIVES = { - r'\bgrep\b': "Use 'rg' (ripgrep) for better performance", - r'\bfind\s+.*-name': "Use 'fd' for faster file finding", -} - -def main(): - try: - # Read input - input_data = json.load(sys.stdin) - - # Only process bash commands - if input_data.get('tool_name') != 'bash': - sys.exit(0) - - command = json.loads(input_data.get('tool_input', '{}')).get('command', '') - - # Check dangerous patterns - for pattern, message in DANGEROUS_PATTERNS: - if re.search(pattern, command, re.IGNORECASE): - print(message, file=sys.stderr) - sys.exit(2) # Block execution - - # Suggest alternatives - suggestions = [] - for pattern, suggestion in SUGGEST_ALTERNATIVES.items(): - if re.search(pattern, command): - suggestions.append(suggestion) - - if suggestions: - output = { - "decision": "approve", - "reason": "Command approved. Suggestions: " + "; ".join(suggestions) - } - print(json.dumps(output)) - - except Exception as e: - print(f"Hook error: {e}", file=sys.stderr) - sys.exit(1) - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/examples/hooks/mcp-monitor.py b/examples/hooks/mcp-monitor.py deleted file mode 100755 index f863b036..00000000 --- a/examples/hooks/mcp-monitor.py +++ /dev/null @@ -1,76 +0,0 @@ -#!/usr/bin/env python3 -""" -Monitors MCP tool usage and enforces policies. -""" - -import json -import sys -import re -import os -from datetime import datetime - -# Define MCP tool policies -BLOCKED_MCP_TOOLS = [ - "mcp__github__delete_.*", # Block all GitHub delete operations - "mcp__aws__.*_production", # Block production AWS operations -] - -RATE_LIMITS = { - "mcp__openai__.*": (10, 60), # 10 calls per 60 seconds -} - - -def check_rate_limit(tool_name, limits): - # This is a simplified example - real implementation would need persistent storage - # For now, just log the attempt - return True - - -def main(): - try: - input_data = json.load(sys.stdin) - tool_name = input_data.get("tool_name", "") - - # Check if tool is blocked - for pattern in BLOCKED_MCP_TOOLS: - if re.match(pattern, tool_name): - output = { - "decision": "block", - "reason": f"Tool {tool_name} is blocked by security policy", - } - print(json.dumps(output)) - sys.exit(0) - - # Check rate limits - for pattern, (limit, window) in RATE_LIMITS.items(): - if re.match(pattern, tool_name): - if not check_rate_limit(tool_name, (limit, window)): - output = { - "decision": "block", - "reason": f"Rate limit exceeded: {limit} calls per {window}s", - } - print(json.dumps(output)) - sys.exit(0) - - # Log MCP tool usage - log_entry = { - "timestamp": datetime.now().isoformat(), - "tool": tool_name, - "input": input_data.get("tool_input", {}), - } - - # Use XDG_CONFIG_HOME if set, otherwise default to ~/.config - config_home = os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")) - log_dir = os.path.join(config_home, "kit", "logs") - os.makedirs(log_dir, exist_ok=True) - - with open(os.path.join(log_dir, "mcp-usage.jsonl"), "a") as f: - f.write(json.dumps(log_entry) + "\n") - - except Exception as e: - print(f"Hook error: {e}", file=sys.stderr) - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/examples/hooks/prompt-logger.sh b/examples/hooks/prompt-logger.sh deleted file mode 100755 index acd442b2..00000000 --- a/examples/hooks/prompt-logger.sh +++ /dev/null @@ -1,23 +0,0 @@ -#!/bin/bash -# Logs all user prompts with timestamp - -# Read JSON input -input=$(cat) - -# Extract prompt using jq (ensure jq is installed) -prompt=$(echo "$input" | jq -r '.prompt // empty') - -if [ -n "$prompt" ]; then - # Use XDG_CONFIG_HOME if set, otherwise default to ~/.config - CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}" - LOG_DIR="$CONFIG_DIR/kit/logs" - - # Create log directory if it doesn't exist - mkdir -p "$LOG_DIR" - - # Log with timestamp - echo "[$(date '+%Y-%m-%d %H:%M:%S')] $prompt" >> "$LOG_DIR/prompts.log" -fi - -# Always allow prompt to continue -exit 0 \ No newline at end of file diff --git a/examples/scripts/README.md b/examples/scripts/README.md deleted file mode 100644 index e619b21a..00000000 --- a/examples/scripts/README.md +++ /dev/null @@ -1,83 +0,0 @@ -# KIT Script Examples - -This directory contains example scripts demonstrating various features of KIT's script mode. - -## Scripts - -### `default-values-demo.sh` -Demonstrates the new default values feature for script variables. - -**Features showcased:** -- Optional variables with default values using `${var:-default}` syntax -- Mixed required and optional variables -- Default values in MCP server configuration -- Complex default values (paths, commands, formats) - -**Usage:** -```bash -# Use all defaults -kit script default-values-demo.sh - -# Override specific variables -kit script default-values-demo.sh --args:user_name "John" --args:work_dir "/projects" - -# Override multiple variables -kit script default-values-demo.sh \ - --args:user_name "Alice" \ - --args:editor "vim" \ - --args:format "json" -``` - -### `tls-test-script.sh` -Demonstrates TLS skip verify for connecting to providers with self-signed certificates. - -**Features showcased:** -- `tls-skip-verify` configuration in script frontmatter -- Connecting to HTTPS endpoints with self-signed certificates -- Security considerations for development environments - -**Usage:** -```bash -# Run with TLS skip verify enabled (configured in script) -kit script tls-test-script.sh - -# Override the provider URL -kit script tls-test-script.sh --provider-url https://192.168.1.100:443 - -# Disable TLS skip verify via command line (overrides script config) -kit script tls-test-script.sh --tls-skip-verify=false -``` - -⚠️ **WARNING**: Only use `tls-skip-verify` for development or when connecting to trusted servers with self-signed certificates. - -## Variable Syntax Reference - -KIT scripts support two types of variables: - -### Required Variables -```bash -${variable} -``` -- Must be provided via `--args:variable value` -- Script will fail if not provided - -### Optional Variables with Defaults -```bash -${variable:-default_value} -``` -- Uses `default_value` if not provided -- Can be overridden with `--args:variable value` -- Supports empty defaults: `${var:-}` -- Supports complex defaults: `${path:-/tmp/default/path}` - -## Best Practices - -1. **Use descriptive variable names**: `${user_name}` instead of `${name}` -2. **Provide sensible defaults**: Choose defaults that work in most environments -3. **Document variables**: Include usage examples in script comments -4. **Mix required and optional**: Use required variables for critical inputs, optional for preferences -5. **Test with defaults**: Ensure scripts work with all default values - -## Backward Compatibility - -All existing scripts using `${variable}` syntax continue to work unchanged. The new default syntax is purely additive. diff --git a/examples/scripts/default-values-demo.sh b/examples/scripts/default-values-demo.sh deleted file mode 100755 index 7c68c8b9..00000000 --- a/examples/scripts/default-values-demo.sh +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env -S kit script ---- -# Demo script showcasing default values in KIT scripts -model: "anthropic/claude-sonnet-4-5-20250929" -mcpServers: - filesystem: - type: "builtin" - name: "fs" - options: - allowed_directories: ["${work_dir:-/tmp}", "${home_dir:-/home}"] - bash: - type: "builtin" - name: "bash" - todo: - type: "builtin" - name: "todo" ---- -# Default Values Demo Script - -Hello ${user_name:-Anonymous User}! - -This script demonstrates the new default values feature in KIT scripts. - -## Your Configuration: -- Working directory: ${work_dir:-/tmp} -- Home directory: ${home_dir:-/home} -- Preferred editor: ${editor:-nano} -- Log level: ${log_level:-info} -- Output format: ${format:-text} - -## Tasks to Complete: - -1. **Directory Analysis**: Analyze the contents of ${work_dir:-/tmp} -2. **System Info**: Show system information using ${info_command:-uname -a} -3. **File Operations**: Create a test file named ${test_file:-demo_test.txt} -4. **Report Generation**: Generate a ${format:-text} format report - -Please complete these tasks and provide a summary of what you accomplished. diff --git a/examples/scripts/env-substitution-script.sh b/examples/scripts/env-substitution-script.sh deleted file mode 100755 index 8f3a6e46..00000000 --- a/examples/scripts/env-substitution-script.sh +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env -S kit script ---- -# Example script demonstrating both environment variable and script argument substitution -# Environment variables are processed first, then script arguments - -mcpServers: - github: - type: local - command: ["gh", "api"] - environment: - GITHUB_TOKEN: "${env://GITHUB_TOKEN}" - DEBUG: "${env://DEBUG:-false}" - - filesystem: - type: builtin - name: fs - options: - allowed_directories: ["${env://WORK_DIR:-/tmp}"] - -model: "${env://MODEL:-anthropic/claude-sonnet-4-5-20250929}" -debug: ${env://DEBUG:-false} ---- -List ${repo_type:-public} repositories for user ${username}. -Use the GitHub API to fetch ${count:-10} repositories. -Working directory is ${env://WORK_DIR:-/tmp}. - -# Usage: -# 1. Set environment variables: -# export GITHUB_TOKEN="ghp_your_token_here" -# export DEBUG="true" -# export WORK_DIR="/home/user/projects" -# -# 2. Run with script arguments: -# kit script env-substitution-script.sh --args:username alice --args:repo_type private --args:count 5 -# -# This will: -# - Use GITHUB_TOKEN from environment -# - Set DEBUG=true from environment -# - Use WORK_DIR=/home/user/projects from environment -# - Use username=alice from script args -# - Use repo_type=private from script args -# - Use count=5 from script args \ No newline at end of file diff --git a/examples/scripts/example-script.sh b/examples/scripts/example-script.sh deleted file mode 100755 index 3c7f48df..00000000 --- a/examples/scripts/example-script.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/env -S kit script ---- -# This script uses the container-use MCP server from https://github.com/dagger/container-use -mcpServers: - container-use: - type: "local" - command: ["container-use", "stdio"] ---- -Create 2 variations of a simple hello world app using Flask and FastAPI. each in their own environment. Give me the URL of each app diff --git a/examples/scripts/simple-script.sh b/examples/scripts/simple-script.sh deleted file mode 100755 index 2ad994df..00000000 --- a/examples/scripts/simple-script.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env -S kit script -Hello! This is a simple script that uses the default MCP configuration. -What's 2 + 2? -What tools do you have? diff --git a/examples/scripts/tls-test-script.sh b/examples/scripts/tls-test-script.sh deleted file mode 100755 index be991ed3..00000000 --- a/examples/scripts/tls-test-script.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/env -S kit script ---- -# Example script demonstrating TLS skip verify for self-signed certificates -model: "ollama/llama3.2" -provider-url: "https://localhost:8443" -tls-skip-verify: true -max-tokens: 1000 ---- -Hello! Can you tell me about TLS certificates and why someone might need to skip certificate verification in development environments? \ No newline at end of file diff --git a/internal/app/app.go b/internal/app/app.go index 5e4909c2..35d76a81 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -209,7 +209,7 @@ func (a *App) RunOnce(ctx context.Context, prompt string) error { // RunOnceWithDisplay executes a single agent step synchronously, sending // intermediate display events (spinner, tool calls, streaming chunks, etc.) // to eventFn. This is the non-TUI equivalent of the interactive Run() path — -// used by script mode and non-interactive --prompt mode when output is needed. +// used by non-interactive --prompt mode when output is needed. // // The eventFn receives the same event types as the Bubble Tea TUI // (SpinnerEvent, ToolCallStartedEvent, StreamChunkEvent, StepCompleteEvent, diff --git a/internal/config/config.go b/internal/config/config.go index e218b75d..79b3f8e9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -155,8 +155,6 @@ type Config struct { SystemPrompt string `json:"system-prompt,omitempty" yaml:"system-prompt,omitempty"` ProviderAPIKey string `json:"provider-api-key,omitempty" yaml:"provider-api-key,omitempty"` ProviderURL string `json:"provider-url,omitempty" yaml:"provider-url,omitempty"` - Prompt string `json:"prompt,omitempty" yaml:"prompt,omitempty"` - NoExit bool `json:"no-exit,omitempty" yaml:"no-exit,omitempty"` Stream *bool `json:"stream,omitempty" yaml:"stream,omitempty"` Theme any `json:"theme" yaml:"theme"` MarkdownTheme any `json:"markdown-theme" yaml:"markdown-theme"` diff --git a/internal/config/merger.go b/internal/config/merger.go index 67a1177f..64459ad9 100644 --- a/internal/config/merger.go +++ b/internal/config/merger.go @@ -7,21 +7,6 @@ import ( "github.com/spf13/viper" ) -// MergeConfigs merges script frontmatter config with base config, allowing scripts -// to override MCP server configurations. The script config takes precedence over -// the base config for any fields that are specified. -func MergeConfigs(baseConfig *Config, scriptConfig *Config) *Config { - merged := *baseConfig // Copy base config - - // Override MCP servers if script provides them - if len(scriptConfig.MCPServers) > 0 { - merged.MCPServers = scriptConfig.MCPServers - } - - // Add other merge logic as needed for future config fields - return &merged -} - // LoadAndValidateConfig loads configuration from viper, fixes environment variable // casing issues, and validates the configuration. Returns an error if loading or // validation fails. diff --git a/internal/config/substitution.go b/internal/config/substitution.go index 9dbc7a8f..b4b143cd 100644 --- a/internal/config/substitution.go +++ b/internal/config/substitution.go @@ -8,10 +8,7 @@ import ( ) // Variable substitution patterns -var ( - envVarPattern = regexp.MustCompile(`\$\{env://([A-Za-z_][A-Za-z0-9_]*)(:-([^}]*))?\}`) - scriptArgsPattern = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)(:-([^}]*))?\}`) -) +var envVarPattern = regexp.MustCompile(`\$\{env://([A-Za-z_][A-Za-z0-9_]*)(:-([^}]*))?\}`) // parseVariableWithDefault extracts variable name and default value // Works for both ${var:-default} and ${env://var:-default} patterns @@ -60,58 +57,8 @@ func (e *EnvSubstituter) SubstituteEnvVars(content string) (string, error) { return result, nil } -// ArgsSubstituter handles script argument substitution in configuration strings, -// supporting both ${VAR} and ${VAR:-default} patterns for template variable replacement. -type ArgsSubstituter struct { - args map[string]string -} - -// NewArgsSubstituter creates a new args substituter with the given arguments map. -// The arguments are used to replace template variables in configuration strings. -func NewArgsSubstituter(args map[string]string) *ArgsSubstituter { - return &ArgsSubstituter{args: args} -} - -// SubstituteArgs replaces ${VAR} and ${VAR:-default} patterns with script arguments. -// If an argument is not provided and has a default value, the default is used. -// Returns an error if required arguments (those without defaults) are not provided. -func (a *ArgsSubstituter) SubstituteArgs(content string) (string, error) { - var errors []string - - result := scriptArgsPattern.ReplaceAllStringFunc(content, func(match string) string { - // Extract the variable part from ${VAR:-default} - // Remove ${ prefix and } suffix - varPart := strings.TrimPrefix(strings.TrimSuffix(match, "}"), "${") - - varName, defaultValue, hasDefault := parseVariableWithDefault(varPart) - - if argValue, exists := a.args[varName]; exists { - return argValue - } - - if hasDefault { - return defaultValue - } - - errors = append(errors, fmt.Sprintf("required script argument '%s' not set in %s", varName, match)) - return match // Keep original if error - }) - - if len(errors) > 0 { - return "", fmt.Errorf("script argument substitution failed: %s", strings.Join(errors, ", ")) - } - - return result, nil -} - // HasEnvVars checks if content contains environment variable patterns (${env://...}). // This is useful for determining if substitution is needed before processing. func HasEnvVars(content string) bool { return envVarPattern.MatchString(content) } - -// HasScriptArgs checks if content contains script argument patterns (${...}). -// This is useful for determining if argument substitution is needed before processing. -func HasScriptArgs(content string) bool { - return scriptArgsPattern.MatchString(content) -} diff --git a/internal/config/substitution_test.go b/internal/config/substitution_test.go index 733d1522..10d2eb69 100644 --- a/internal/config/substitution_test.go +++ b/internal/config/substitution_test.go @@ -188,85 +188,6 @@ func TestEnvSubstituter_SubstituteEnvVars(t *testing.T) { } } -func TestArgsSubstituter_SubstituteArgs(t *testing.T) { - tests := []struct { - name string - input string - args map[string]string - expected string - expectError bool - }{ - { - name: "basic args substitution", - input: `{"name": "${username}"}`, - args: map[string]string{"username": "john"}, - expected: `{"name": "john"}`, - }, - { - name: "args with default value used", - input: `{"type": "${repo_type:-public}"}`, - args: map[string]string{}, - expected: `{"type": "public"}`, - }, - { - name: "args with default value overridden", - input: `{"type": "${repo_type:-public}"}`, - args: map[string]string{"repo_type": "private"}, - expected: `{"type": "private"}`, - }, - { - name: "args with empty default", - input: `{"optional": "${optional_arg:-}"}`, - args: map[string]string{}, - expected: `{"optional": ""}`, - }, - { - name: "multiple args in same string", - input: `{"message": "Hello ${name:-World}, you have ${count:-0} messages"}`, - args: map[string]string{"name": "Alice"}, - expected: `{"message": "Hello Alice, you have 0 messages"}`, - }, - { - name: "no args in content", - input: `{"normal": "value", "env": "${env://TOKEN}"}`, - args: map[string]string{}, - expected: `{"normal": "value", "env": "${env://TOKEN}"}`, - }, - { - name: "missing required arg", - input: `{"name": "${required_name}"}`, - args: map[string]string{}, - expectError: true, - }, - { - name: "multiple missing required args", - input: `{"name": "${name}", "id": "${id}"}`, - args: map[string]string{}, - expectError: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - substituter := NewArgsSubstituter(tt.args) - result, err := substituter.SubstituteArgs(tt.input) - - if tt.expectError { - if err == nil { - t.Errorf("Expected error but got none") - } - } else { - if err != nil { - t.Errorf("Unexpected error: %v", err) - } - if result != tt.expected { - t.Errorf("Expected:\n%s\nGot:\n%s", tt.expected, result) - } - } - }) - } -} - func TestHasEnvVars(t *testing.T) { tests := []struct { name string @@ -304,92 +225,3 @@ func TestHasEnvVars(t *testing.T) { }) } } - -func TestHasScriptArgs(t *testing.T) { - tests := []struct { - name string - content string - expected bool - }{ - { - name: "has script args", - content: `{"name": "${username}"}`, - expected: true, - }, - { - name: "has script args with default", - content: `{"type": "${repo_type:-public}"}`, - expected: true, - }, - { - name: "no script args", - content: `{"token": "${env://GITHUB_TOKEN}", "normal": "value"}`, - expected: false, - }, - { - name: "empty content", - content: "", - expected: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := HasScriptArgs(tt.content) - if result != tt.expected { - t.Errorf("Expected %v, got %v", tt.expected, result) - } - }) - } -} - -func TestIntegrationEnvAndArgsSubstitution(t *testing.T) { - // Test that env substitution and args substitution work together correctly - input := `{ - "token": "${env://GITHUB_TOKEN:-default_token}", - "user": "${username}", - "repo": "${repo_name:-my-repo}", - "debug": "${env://DEBUG:-false}" - }` - - // Set up environment - _ = os.Setenv("GITHUB_TOKEN", "ghp_real_token") - defer func() { _ = os.Unsetenv("GITHUB_TOKEN") }() - - // Step 1: Apply env substitution - envSubstituter := &EnvSubstituter{} - afterEnv, err := envSubstituter.SubstituteEnvVars(input) - if err != nil { - t.Fatalf("Env substitution failed: %v", err) - } - - expectedAfterEnv := `{ - "token": "ghp_real_token", - "user": "${username}", - "repo": "${repo_name:-my-repo}", - "debug": "false" - }` - - if afterEnv != expectedAfterEnv { - t.Errorf("After env substitution, expected:\n%s\nGot:\n%s", expectedAfterEnv, afterEnv) - } - - // Step 2: Apply args substitution - args := map[string]string{"username": "alice"} - argsSubstituter := NewArgsSubstituter(args) - final, err := argsSubstituter.SubstituteArgs(afterEnv) - if err != nil { - t.Fatalf("Args substitution failed: %v", err) - } - - expectedFinal := `{ - "token": "ghp_real_token", - "user": "alice", - "repo": "my-repo", - "debug": "false" - }` - - if final != expectedFinal { - t.Errorf("After args substitution, expected:\n%s\nGot:\n%s", expectedFinal, final) - } -}