diff --git a/docs/USAGE.md b/docs/USAGE.md index 54b91f7a1..ef5825d4d 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -114,31 +114,28 @@ agents: commands: # Either mapping or list of singleton maps df: "check how much free space i have on my disk" ls: "list the files in the current directory" + greet: "Say hello to $USER and ask how their day is going" + analyze: "Analyze the project named $PROJECT_NAME in the $ENVIRONMENT environment" ``` -### Running with named commands - -- Example YAML forms supported: - -```yaml -commands: - df: "check how much free space i have on my disk" - ls: "list the files in the current directory" -``` - -```yaml -commands: - - df: "check how much free space i have on my disk" - - ls: "list the files in the current directory" -``` - -Run: +### Running named commands ```bash cagent run ./agent.yaml /df cagent run ./agent.yaml /ls + +export USER=alice +cagent run ./agent.yaml /greet + +export PROJECT_NAME=myproject +export ENVIRONMENT=production +cagent run ./agent.yaml /analyze ``` +- Placeholders are expanded during agent loading using available environment variables +- Undefined variables expand to empty strings (no error is thrown) +- Both `$VAR` and `${VAR}` syntax are supported + ### Model Properties | Property | Type | Description | Required | diff --git a/examples/env_placeholders.yaml b/examples/env_placeholders.yaml new file mode 100644 index 000000000..510376c95 --- /dev/null +++ b/examples/env_placeholders.yaml @@ -0,0 +1,40 @@ +#!/usr/bin/env cagent run +version: "2" + +# Example demonstrating environment variable placeholder expansion in commands +# +# This example shows how to use $VARIABLE_NAME or ${VARIABLE_NAME} placeholders +# in command definitions, which are automatically replaced with environment variable +# values when the agent is loaded. +# +# Usage: +# export USER=alice +# export PROJECT_NAME=myproject +# export ENVIRONMENT=production +# +# cagent run env_placeholders.yaml -c greet +# cagent run env_placeholders.yaml -c analyze +# cagent run env_placeholders.yaml -c status +# +# Note: Undefined environment variables expand to empty strings without causing errors, +# allowing you to only define the variables needed for the commands you actually use. + +agents: + root: + model: anthropic/claude-sonnet-4-0 + description: "Agent demonstrating environment variable placeholder expansion" + instruction: | + You are a helpful assistant that responds to user requests. + Provide clear, concise, and actionable responses. + + commands: + greet: "Say hello to $USER and ask how their day is going" + analyze: "Analyze the project named $PROJECT_NAME in the $ENVIRONMENT environment and provide insights" + status: "Check the status of the service ${SERVICE_NAME} and report any issues" + report: "Generate a comprehensive report for project $PROJECT_NAME owned by $OWNER deployed in $LOCATION" + inspect: "Inspect the contents of the directory at $WORKING_DIR and summarize what you find" + help: "Explain what you can do and how to use environment variable placeholders in commands" + + toolsets: + - type: filesystem + - type: shell diff --git a/pkg/environment/expand.go b/pkg/environment/expand.go index a15591666..02dce9937 100644 --- a/pkg/environment/expand.go +++ b/pkg/environment/expand.go @@ -39,6 +39,14 @@ func Expand(ctx context.Context, value string, env Provider) (string, error) { return expanded, nil } +// ExpandLenient expands environment variables without returning errors for undefined variables. +// Undefined variables expand to empty strings, similar to standard shell behavior. +func ExpandLenient(ctx context.Context, value string, env Provider) string { + return os.Expand(value, func(name string) string { + return env.Get(ctx, name) + }) +} + func ToValues(envMap map[string]string) []string { var values []string for k, v := range envMap { diff --git a/pkg/teamloader/teamloader.go b/pkg/teamloader/teamloader.go index 627f08c16..373369d75 100644 --- a/pkg/teamloader/teamloader.go +++ b/pkg/teamloader/teamloader.go @@ -111,6 +111,19 @@ func createThinkTool(ctx context.Context, toolset latest.Toolset, parentDir stri return builtin.NewThinkTool(), nil } +// expandCommandPlaceholders expands environment variable placeholders in command values. +// Undefined variables are allowed and will expand to empty strings, enabling users to +// only define the environment variables needed for the commands they actually use. +func expandCommandPlaceholders(ctx context.Context, commands map[string]string, envProvider environment.Provider) map[string]string { + expanded := map[string]string{} + + for name, value := range commands { + expanded[name] = environment.ExpandLenient(ctx, value, envProvider) + } + + return expanded +} + func createShellTool(ctx context.Context, toolset latest.Toolset, parentDir string, envProvider environment.Provider, runtimeConfig config.RuntimeConfig) (tools.ToolSet, error) { env, err := environment.ExpandAll(ctx, environment.ToValues(toolset.Env), envProvider) if err != nil { @@ -343,7 +356,7 @@ func Load(ctx context.Context, p string, runtimeConfig config.RuntimeConfig, opt agent.WithAddPromptFiles(agentConfig.AddPromptFiles), agent.WithMaxIterations(agentConfig.MaxIterations), agent.WithNumHistoryItems(agentConfig.NumHistoryItems), - agent.WithCommands(agentConfig.Commands), + agent.WithCommands(expandCommandPlaceholders(ctx, agentConfig.Commands, env)), } models, err := getModelsForAgent(ctx, cfg, &agentConfig, env, runtimeConfig) diff --git a/pkg/teamloader/teamloader_test.go b/pkg/teamloader/teamloader_test.go index f9a089e5c..7882171df 100644 --- a/pkg/teamloader/teamloader_test.go +++ b/pkg/teamloader/teamloader_test.go @@ -143,3 +143,71 @@ func TestToolsetInstructions(t *testing.T) { expected := "Dummy fetch tool instruction" require.Equal(t, expected, instructions) } + +// TestExpandCommandPlaceholders tests that $placeholders in commands are expanded with env var values +func TestExpandCommandPlaceholders(t *testing.T) { + tests := []struct { + name string + commands map[string]string + envVars map[string]string + expected map[string]string + }{ + { + name: "single placeholder", + commands: map[string]string{"greet": "Say hello to $USER"}, + envVars: map[string]string{"USER": "alice"}, + expected: map[string]string{"greet": "Say hello to alice"}, + }, + { + name: "multiple placeholders", + commands: map[string]string{"analyze": "Analyze $PROJECT_NAME in $ENVIRONMENT"}, + envVars: map[string]string{"PROJECT_NAME": "myproject", "ENVIRONMENT": "production"}, + expected: map[string]string{"analyze": "Analyze myproject in production"}, + }, + { + name: "no placeholders", + commands: map[string]string{"simple": "List all files"}, + envVars: map[string]string{}, + expected: map[string]string{"simple": "List all files"}, + }, + { + name: "placeholder with curly braces", + commands: map[string]string{"check": "Check ${SERVICE_NAME} status"}, + envVars: map[string]string{"SERVICE_NAME": "api-server"}, + expected: map[string]string{"check": "Check api-server status"}, + }, + { + name: "missing env var expands to empty string", + commands: map[string]string{"test": "Check $MISSING_VAR status"}, + envVars: map[string]string{}, + expected: map[string]string{"test": "Check status"}, + }, + { + name: "empty commands", + commands: map[string]string{}, + envVars: map[string]string{}, + expected: map[string]string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a test environment provider + env := &testEnvProvider{vars: tt.envVars} + + // Expand the commands + result := expandCommandPlaceholders(t.Context(), tt.commands, env) + + require.Equal(t, tt.expected, result) + }) + } +} + +// testEnvProvider is a simple environment provider for testing +type testEnvProvider struct { + vars map[string]string +} + +func (p *testEnvProvider) Get(_ context.Context, name string) string { + return p.vars[name] +}