Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion PRIVACY.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ In workspace directories, sidecar may create:

- `.sidecar/config.json` — per-project configuration (prompts, theme overrides)
- `.sidecar/shells.json` — shell display names and metadata
- `.sidecar-task`, `.sidecar-agent`, `.sidecar-pr`, `.sidecar-base` — workspace state files
- `.sidecar-task`, `.sidecar-agent`, `.sidecar-agent-start`, `.sidecar-pr`, `.sidecar-base` — workspace state files
- `.sidecar-start.sh` — temporary agent launcher script
- `.sidecar-rename-tmp` — temporary file for rename operations
- `.td-root` — links worktrees to a shared td database root
Expand All @@ -81,6 +81,7 @@ Sidecar reads:
- `HOME` — base path for all config and data directories
- `EDITOR`, `VISUAL` — to open files in your editor
- `SIDECAR_PPROF` — profiling server port (development only)
- `SIDECAR_WORKSPACE_DEFAULT_AGENT_TYPE`, `SIDECAR_DEFAULT_AGENT_TYPE` — override workspace default agent type at startup
- `XDG_DATA_HOME`, `XDG_CONFIG_HOME`, `XDG_STATE_HOME` — standard directories for locating agent data on Linux
- `AMP_DATA_HOME` — Amp-specific data directory
- `APPDATA`, `LOCALAPPDATA` — Windows data directories
Expand Down
9 changes: 8 additions & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,13 @@ type WorkspacePluginConfig struct {
// DirPrefix prefixes workspace directory names with the repo name (e.g., 'myrepo-feature-auth')
// This helps associate conversations with the repo after workspace deletion. Default: true.
DirPrefix bool `json:"dirPrefix"`
// DefaultAgentType sets the default agent family selected when creating a workspace.
// Uses workspace.AgentType values (e.g. "claude", "codex", "opencode").
DefaultAgentType string `json:"defaultAgentType,omitempty"`
// AgentStart maps agent family (AgentType string) to default startup command.
// Example: {"claude":"claude", "opencode":"opencode --profile fast"}.
// Per-workspace .sidecar-agent-start still takes precedence when present.
AgentStart map[string]string `json:"agentStart,omitempty"`
// TmuxCaptureMaxBytes caps tmux pane capture size for the preview pane. Default: 2MB.
TmuxCaptureMaxBytes int `json:"tmuxCaptureMaxBytes"`
// InteractiveExitKey is the keybinding to exit interactive mode. Default: "ctrl+\".
Expand Down Expand Up @@ -140,7 +147,7 @@ func Default() *Config {
Overrides: make(map[string]string),
},
UI: UIConfig{
ShowClock: true,
ShowClock: true,
Theme: ThemeConfig{
Name: "default",
Overrides: make(map[string]interface{}),
Expand Down
88 changes: 81 additions & 7 deletions internal/config/loader.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package config

import (
"bytes"
"encoding/json"
"log/slog"
"os"
Expand Down Expand Up @@ -74,16 +75,19 @@ type rawPluginsConfig struct {
GitStatus rawGitStatusConfig `json:"git-status"`
TDMonitor rawTDMonitorConfig `json:"td-monitor"`
Conversations rawConversationsConfig `json:"conversations"`
Workspace rawWorkspaceConfig `json:"workspace"`
Workspace rawWorkspaceConfig `json:"workspace"`
}

type rawWorkspaceConfig struct {
DirPrefix *bool `json:"dirPrefix"`
TmuxCaptureMaxBytes *int `json:"tmuxCaptureMaxBytes"`
InteractiveExitKey string `json:"interactiveExitKey"`
InteractiveAttachKey string `json:"interactiveAttachKey"`
InteractiveCopyKey string `json:"interactiveCopyKey"`
InteractivePasteKey string `json:"interactivePasteKey"`
DirPrefix *bool `json:"dirPrefix"`
DefaultAgentType string `json:"defaultAgentType"`
LegacyDefaultAgent string `json:"defaultAgent"` // Backward compatibility
AgentStart json.RawMessage `json:"agentStart"`
TmuxCaptureMaxBytes *int `json:"tmuxCaptureMaxBytes"`
InteractiveExitKey string `json:"interactiveExitKey"`
InteractiveAttachKey string `json:"interactiveAttachKey"`
InteractiveCopyKey string `json:"interactiveCopyKey"`
InteractivePasteKey string `json:"interactivePasteKey"`
}

type rawGitStatusConfig struct {
Expand All @@ -102,6 +106,11 @@ type rawConversationsConfig struct {
ClaudeDataDir string `json:"claudeDataDir"`
}

const (
envWorkspaceDefaultAgentType = "SIDECAR_WORKSPACE_DEFAULT_AGENT_TYPE"
envDefaultAgentType = "SIDECAR_DEFAULT_AGENT_TYPE"
)

// Load loads configuration from the default location.
func Load() (*Config, error) {
return LoadFrom("")
Expand All @@ -115,6 +124,7 @@ func LoadFrom(path string) (*Config, error) {
if path == "" {
home, err := os.UserHomeDir()
if err != nil {
applyEnvOverrides(cfg)
return cfg, nil // Return defaults on error
}
path = filepath.Join(home, configDir, configFile)
Expand All @@ -123,6 +133,10 @@ func LoadFrom(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
applyEnvOverrides(cfg)
if err := cfg.Validate(); err != nil {
return nil, err
}
return cfg, nil // Return defaults if no config file
}
return nil, err
Expand All @@ -135,6 +149,7 @@ func LoadFrom(path string) (*Config, error) {

// Merge raw config into defaults
mergeConfig(cfg, &raw)
applyEnvOverrides(cfg)

// Expand paths
cfg.Plugins.Conversations.ClaudeDataDir = ExpandPath(cfg.Plugins.Conversations.ClaudeDataDir)
Expand Down Expand Up @@ -209,6 +224,15 @@ func mergeConfig(cfg *Config, raw *rawConfig) {
if raw.Plugins.Workspace.TmuxCaptureMaxBytes != nil {
cfg.Plugins.Workspace.TmuxCaptureMaxBytes = *raw.Plugins.Workspace.TmuxCaptureMaxBytes
}
if raw.Plugins.Workspace.DefaultAgentType != "" {
cfg.Plugins.Workspace.DefaultAgentType = raw.Plugins.Workspace.DefaultAgentType
}
if cfg.Plugins.Workspace.DefaultAgentType == "" && raw.Plugins.Workspace.LegacyDefaultAgent != "" {
cfg.Plugins.Workspace.DefaultAgentType = raw.Plugins.Workspace.LegacyDefaultAgent
}
if agentStart, ok := parseAgentStartOverrides(raw.Plugins.Workspace.AgentStart); ok {
cfg.Plugins.Workspace.AgentStart = agentStart
}
if raw.Plugins.Workspace.InteractiveExitKey != "" {
cfg.Plugins.Workspace.InteractiveExitKey = raw.Plugins.Workspace.InteractiveExitKey
}
Expand Down Expand Up @@ -269,6 +293,56 @@ func mergeConfig(cfg *Config, raw *rawConfig) {
}
}

func applyEnvOverrides(cfg *Config) {
if cfg == nil {
return
}

// SIDECAR_WORKSPACE_DEFAULT_AGENT_TYPE takes precedence over SIDECAR_DEFAULT_AGENT_TYPE,
// but only when it is set to a non-blank value. A blank value means "unset" so we fall
// through to the lower-priority env var rather than silently dropping it.
if v, ok := os.LookupEnv(envWorkspaceDefaultAgentType); ok && strings.TrimSpace(v) != "" {
cfg.Plugins.Workspace.DefaultAgentType = strings.TrimSpace(v)
return
}
if v, ok := os.LookupEnv(envDefaultAgentType); ok {
cfg.Plugins.Workspace.DefaultAgentType = strings.TrimSpace(v)
}
}

func parseAgentStartOverrides(raw json.RawMessage) (map[string]string, bool) {
raw = bytes.TrimSpace(raw)
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
return nil, false
}

var byType map[string]string
if err := json.Unmarshal(raw, &byType); err == nil {
out := make(map[string]string, len(byType))
for k, v := range byType {
key := strings.TrimSpace(k)
val := strings.TrimSpace(v)
if key == "" || val == "" {
continue
}
out[key] = val
}
return out, true
}

// Backward compatibility: previous schema accepted a single string.
var single string
if err := json.Unmarshal(raw, &single); err == nil {
single = strings.TrimSpace(single)
if single == "" {
return map[string]string{}, true
}
return map[string]string{"*": single}, true
}

return nil, false
}

// ExpandPath expands ~ to home directory.
func ExpandPath(path string) string {
if strings.HasPrefix(path, "~/") {
Expand Down
187 changes: 187 additions & 0 deletions internal/config/loader_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,3 +184,190 @@ func TestLoadFrom_EmptyProjectsList(t *testing.T) {
t.Errorf("got %d projects, want 0", len(cfg.Projects.List))
}
}

func TestLoadFrom_WorkspaceAgentSettings(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.json")

content := []byte(`{
"plugins": {
"workspace": {
"defaultAgentType": "opencode",
"agentStart": {
"opencode": "opencode --profile fast"
}
}
}
}`)
if err := os.WriteFile(path, content, 0644); err != nil {
t.Fatal(err)
}

cfg, err := LoadFrom(path)
if err != nil {
t.Fatalf("LoadFrom failed: %v", err)
}

if cfg.Plugins.Workspace.DefaultAgentType != "opencode" {
t.Errorf("DefaultAgentType = %q, want %q", cfg.Plugins.Workspace.DefaultAgentType, "opencode")
}
if got := cfg.Plugins.Workspace.AgentStart["opencode"]; got != "opencode --profile fast" {
t.Errorf("AgentStart[opencode] = %q, want %q", got, "opencode --profile fast")
}
}

func TestLoadFrom_WorkspaceDefaultAgentTypeEnvOverride(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
content := []byte(`{
"plugins": {
"workspace": {
"defaultAgentType": "opencode"
}
}
}`)
if err := os.WriteFile(path, content, 0644); err != nil {
t.Fatal(err)
}

t.Setenv("SIDECAR_WORKSPACE_DEFAULT_AGENT_TYPE", "codex")

cfg, err := LoadFrom(path)
if err != nil {
t.Fatalf("LoadFrom failed: %v", err)
}
if cfg.Plugins.Workspace.DefaultAgentType != "codex" {
t.Errorf("DefaultAgentType = %q, want %q", cfg.Plugins.Workspace.DefaultAgentType, "codex")
}
}

func TestLoadFrom_WorkspaceDefaultAgentTypeEnvOverride_NoConfigFile(t *testing.T) {
t.Setenv("SIDECAR_WORKSPACE_DEFAULT_AGENT_TYPE", "gemini")

cfg, err := LoadFrom("/definitely/missing/config.json")
if err != nil {
t.Fatalf("LoadFrom failed: %v", err)
}
if cfg.Plugins.Workspace.DefaultAgentType != "gemini" {
t.Errorf("DefaultAgentType = %q, want %q", cfg.Plugins.Workspace.DefaultAgentType, "gemini")
}
}

func TestLoadFrom_WorkspaceDefaultAgentTypeEnvOverride_LegacyAlias(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
content := []byte(`{"plugins":{"workspace":{"defaultAgentType":"opencode"}}}`)
if err := os.WriteFile(path, content, 0644); err != nil {
t.Fatal(err)
}

t.Setenv("SIDECAR_DEFAULT_AGENT_TYPE", "cursor")

cfg, err := LoadFrom(path)
if err != nil {
t.Fatalf("LoadFrom failed: %v", err)
}
if cfg.Plugins.Workspace.DefaultAgentType != "cursor" {
t.Errorf("DefaultAgentType = %q, want %q", cfg.Plugins.Workspace.DefaultAgentType, "cursor")
}
}

func TestLoadFrom_WorkspaceDefaultAgentTypeEnvOverride_PrefersPrimaryVar(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
content := []byte(`{"plugins":{"workspace":{"defaultAgentType":"opencode"}}}`)
if err := os.WriteFile(path, content, 0644); err != nil {
t.Fatal(err)
}

t.Setenv("SIDECAR_DEFAULT_AGENT_TYPE", "cursor")
t.Setenv("SIDECAR_WORKSPACE_DEFAULT_AGENT_TYPE", "codex")

cfg, err := LoadFrom(path)
if err != nil {
t.Fatalf("LoadFrom failed: %v", err)
}
if cfg.Plugins.Workspace.DefaultAgentType != "codex" {
t.Errorf("DefaultAgentType = %q, want %q", cfg.Plugins.Workspace.DefaultAgentType, "codex")
}
}

func TestLoadFrom_WorkspaceAgentStartLegacyStringBackwardCompat(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
content := []byte(`{
"plugins": {
"workspace": {
"agentStart": "custom-agent --legacy"
}
}
}`)
if err := os.WriteFile(path, content, 0644); err != nil {
t.Fatal(err)
}

cfg, err := LoadFrom(path)
if err != nil {
t.Fatalf("LoadFrom failed: %v", err)
}
if got := cfg.Plugins.Workspace.AgentStart["*"]; got != "custom-agent --legacy" {
t.Errorf("AgentStart[*] = %q, want %q", got, "custom-agent --legacy")
}
}

func TestApplyEnvOverrides_WorkspaceVarTakesPrecedence(t *testing.T) {
t.Setenv(envWorkspaceDefaultAgentType, "opencode")
t.Setenv(envDefaultAgentType, "gemini")

cfg := Default()
applyEnvOverrides(cfg)

if cfg.Plugins.Workspace.DefaultAgentType != "opencode" {
t.Errorf("DefaultAgentType = %q, want %q", cfg.Plugins.Workspace.DefaultAgentType, "opencode")
}
}

func TestApplyEnvOverrides_FallsThruWhenWorkspaceVarBlank(t *testing.T) {
// When SIDECAR_WORKSPACE_DEFAULT_AGENT_TYPE is set but blank, we should
// NOT short-circuit — SIDECAR_DEFAULT_AGENT_TYPE must still be honoured.
t.Setenv(envWorkspaceDefaultAgentType, " ")
t.Setenv(envDefaultAgentType, "gemini")

cfg := Default()
applyEnvOverrides(cfg)

if cfg.Plugins.Workspace.DefaultAgentType != "gemini" {
t.Errorf("DefaultAgentType = %q, want %q (blank workspace var should fall through)", cfg.Plugins.Workspace.DefaultAgentType, "gemini")
}
}

func TestApplyEnvOverrides_OnlyDefaultVar(t *testing.T) {
t.Setenv(envDefaultAgentType, "codex")

cfg := Default()
applyEnvOverrides(cfg)

if cfg.Plugins.Workspace.DefaultAgentType != "codex" {
t.Errorf("DefaultAgentType = %q, want %q", cfg.Plugins.Workspace.DefaultAgentType, "codex")
}
}

func TestApplyEnvOverrides_NeitherVarSet(t *testing.T) {
cfg := Default()
cfg.Plugins.Workspace.DefaultAgentType = "original"

// Ensure neither env var is set
t.Setenv(envWorkspaceDefaultAgentType, "")
if err := os.Unsetenv(envWorkspaceDefaultAgentType); err != nil {
t.Fatal(err)
}
if err := os.Unsetenv(envDefaultAgentType); err != nil {
t.Fatal(err)
}

applyEnvOverrides(cfg)

if cfg.Plugins.Workspace.DefaultAgentType != "original" {
t.Errorf("DefaultAgentType = %q, want %q (should be unchanged)", cfg.Plugins.Workspace.DefaultAgentType, "original")
}
}
Loading
Loading