mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-14 03:30:26 +00:00
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.
This commit is contained in:
@@ -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 🔌
|
||||
|
||||
+8
-16
@@ -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.
|
||||
|
||||
-562
@@ -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 <script-file>",
|
||||
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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+3
-5
@@ -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{
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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?
|
||||
@@ -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?
|
||||
+1
-1
@@ -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,
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user