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:
Ed Zynda
2026-02-27 14:11:27 +03:00
parent 6aa54d0898
commit 199297ce7e
21 changed files with 15 additions and 2171 deletions
+2 -156
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
-162
View File
@@ -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)
}
})
}
}
-257
View File
@@ -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)
}
}
-432
View File
@@ -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
View File
@@ -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{
-57
View File
@@ -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()
-76
View File
@@ -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()
-23
View File
@@ -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
-83
View File
@@ -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.
-38
View File
@@ -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
-9
View File
@@ -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
-4
View File
@@ -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?
-9
View File
@@ -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
View File
@@ -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,
-2
View File
@@ -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"`
-15
View File
@@ -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.
+1 -54
View File
@@ -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)
}
-168
View File
@@ -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)
}
}