diff --git a/compass/compass.go b/compass/compass.go new file mode 100644 index 00000000..2a7634d4 --- /dev/null +++ b/compass/compass.go @@ -0,0 +1,257 @@ +// Package compass provides adaptive instruction template rendering for AI coding agents. +// It is inspired by the Helmsman Rust crate (https://github.com/seuros/helmsman). +package compass + +import ( + "bytes" + "os" + "os/exec" + "runtime" + "strings" + "text/template" + + mcp "github.com/daltoniam/switchboard" +) + +// DefaultModelTiers maps known model IDs to their capability tier. +// "large" = frontier/capable models, "small" = limited/fast models. +var DefaultModelTiers = map[string]string{ + // Anthropic + "claude-opus-4-5-20251101": "large", + "claude-sonnet-4-20250514": "large", + "claude-3-5-sonnet-20241022": "large", + "claude-3-5-haiku-20241022": "small", + "claude-3-haiku-20240307": "small", + + // OpenAI + "gpt-4o": "large", + "gpt-4o-mini": "small", + "gpt-4-turbo": "large", + "gpt-4": "large", + "o1": "large", + "o1-mini": "small", + "o1-preview": "large", + "o3": "large", + "o3-mini": "small", + "gpt-3.5-turbo": "small", + + // Google + "gemini-2.0-flash": "large", + "gemini-2.0-flash-lite": "small", + "gemini-1.5-pro": "large", + "gemini-1.5-flash": "small", + "gemini-2.5-pro": "large", + + // DeepSeek + "deepseek-chat": "large", + "deepseek-reasoner": "large", + + // Meta + "llama-3.3-70b": "large", + "llama-3.1-8b": "small", + + // Mistral + "mistral-large": "large", + "mistral-small": "small", + + // Qwen + "qwen-2.5-72b": "large", +} + +// DefaultTier is used when a model ID is not recognized. +const DefaultTier = "large" + +// GetModelTier returns the capability tier for a model ID. +// customTiers takes precedence over DefaultModelTiers. +func GetModelTier(modelID, defaultTier string, customTiers map[string]string) string { + // Check custom tiers first (from config) + if customTiers != nil { + if tier, ok := customTiers[modelID]; ok { + return tier + } + } + // Check default tiers + if tier, ok := DefaultModelTiers[modelID]; ok { + return tier + } + // Try partial match (model ID prefix) in custom tiers first + lower := strings.ToLower(modelID) + for id, tier := range customTiers { + if strings.HasPrefix(lower, strings.ToLower(id)) { + return tier + } + } + // Try partial match in default tiers + for id, tier := range DefaultModelTiers { + if strings.HasPrefix(lower, strings.ToLower(id)) { + return tier + } + } + if defaultTier != "" { + return defaultTier + } + return DefaultTier +} + +// DetectEnv builds an EnvContext by probing the runtime environment. +func DetectEnv() mcp.EnvContext { + env := mcp.EnvContext{} + + // OS detection + switch runtime.GOOS { + case "darwin": + env.OS = "macos" + case "linux": + env.OS = detectLinuxDistro() + case "windows": + env.OS = "windows" + default: + env.OS = runtime.GOOS + } + + // Shell detection + env.Shell = detectShell() + + // Docker detection + env.InDocker = fileExists("/.dockerenv") + + // SSH detection + env.InSSH = os.Getenv("SSH_CONNECTION") != "" || os.Getenv("SSH_CLIENT") != "" + + // Tool availability + env.HasGit = commandExists("git") + env.HasGh = commandExists("gh") + env.HasMise = commandExists("mise") + env.HasBrew = commandExists("brew") + env.HasApt = commandExists("apt") + + return env +} + +func detectLinuxDistro() string { + // Try to read /etc/os-release + data, err := os.ReadFile("/etc/os-release") + if err != nil { + return "linux" + } + content := string(data) + lower := strings.ToLower(content) + + if strings.Contains(lower, "arch") { + return "arch" + } + if strings.Contains(lower, "debian") { + return "debian" + } + if strings.Contains(lower, "ubuntu") { + return "ubuntu" + } + if strings.Contains(lower, "alpine") { + return "alpine" + } + if strings.Contains(lower, "fedora") { + return "fedora" + } + if strings.Contains(lower, "centos") || strings.Contains(lower, "rhel") { + return "rhel" + } + return "linux" +} + +func detectShell() string { + shell := os.Getenv("SHELL") + if shell == "" { + if runtime.GOOS == "windows" { + return "powershell" + } + return "sh" + } + // Extract shell name from path + parts := strings.Split(shell, "/") + name := parts[len(parts)-1] + switch name { + case "zsh", "bash", "fish", "sh", "dash", "ksh", "csh", "tcsh": + return name + default: + return "sh" + } +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +func commandExists(name string) bool { + _, err := exec.LookPath(name) + return err == nil +} + +// RenderInstruction renders an instruction template with the given context. +func RenderInstruction(inst *mcp.Instruction, ctx mcp.InstructionRenderContext) (string, error) { + // Merge instruction-level variables with context variables + vars := make(map[string]string) + for k, v := range inst.Variables { + vars[k] = v + } + for k, v := range ctx.Vars { + vars[k] = v + } + ctx.Vars = vars + + tmpl, err := template.New(inst.ID).Parse(inst.Template) + if err != nil { + return "", err + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, ctx); err != nil { + return "", err + } + + return buf.String(), nil +} + +// RenderInstructions renders all enabled instructions and concatenates them. +// customTiers allows overriding or extending the default model tier mappings. +func RenderInstructions(instructions []*mcp.Instruction, modelID, defaultTier string, customTiers map[string]string) (string, error) { + ctx := mcp.InstructionRenderContext{ + Model: mcp.ModelContext{ + ID: modelID, + Tier: GetModelTier(modelID, defaultTier, customTiers), + }, + Env: DetectEnv(), + Vars: make(map[string]string), + } + + var results []string + for _, inst := range instructions { + if !inst.Enabled { + continue + } + rendered, err := RenderInstruction(inst, ctx) + if err != nil { + return "", err + } + results = append(results, rendered) + } + + return strings.Join(results, "\n\n"), nil +} + +// BuildRenderContext creates a render context for the given model ID. +// customTiers allows overriding or extending the default model tier mappings. +func BuildRenderContext(modelID, defaultTier string, extraVars map[string]string, customTiers map[string]string) mcp.InstructionRenderContext { + ctx := mcp.InstructionRenderContext{ + Model: mcp.ModelContext{ + ID: modelID, + Tier: GetModelTier(modelID, defaultTier, customTiers), + }, + Env: DetectEnv(), + Vars: extraVars, + } + if ctx.Vars == nil { + ctx.Vars = make(map[string]string) + } + return ctx +} diff --git a/compass/compass_test.go b/compass/compass_test.go new file mode 100644 index 00000000..2379304a --- /dev/null +++ b/compass/compass_test.go @@ -0,0 +1,277 @@ +package compass + +import ( + "testing" + + mcp "github.com/daltoniam/switchboard" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetModelTier(t *testing.T) { + tests := []struct { + name string + modelID string + defaultTier string + customTiers map[string]string + want string + }{ + { + name: "claude opus exact match", + modelID: "claude-opus-4-5-20251101", + want: "large", + }, + { + name: "gpt-4o exact match", + modelID: "gpt-4o", + want: "large", + }, + { + name: "gpt-4o-mini is small", + modelID: "gpt-4o-mini", + want: "small", + }, + { + name: "unknown model uses default tier", + modelID: "unknown-model-123", + want: DefaultTier, + }, + { + name: "unknown model uses custom default", + modelID: "unknown-model-123", + defaultTier: "small", + want: "small", + }, + { + name: "partial match prefix", + modelID: "claude-opus-4-5-20251101-extended", + want: "large", + }, + { + name: "custom tier overrides default", + modelID: "gpt-4o", + customTiers: map[string]string{ + "gpt-4o": "small", + }, + want: "small", + }, + { + name: "custom tier for new model", + modelID: "my-custom-model", + customTiers: map[string]string{ + "my-custom-model": "large", + }, + want: "large", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := GetModelTier(tt.modelID, tt.defaultTier, tt.customTiers) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestDetectEnv(t *testing.T) { + env := DetectEnv() + + assert.NotEmpty(t, env.OS, "OS should be detected") + assert.NotEmpty(t, env.Shell, "Shell should be detected") + // Boolean fields have sensible defaults + assert.IsType(t, false, env.InDocker) + assert.IsType(t, false, env.InSSH) +} + +func TestRenderInstruction(t *testing.T) { + tests := []struct { + name string + inst *mcp.Instruction + ctx mcp.InstructionRenderContext + want string + wantErr bool + }{ + { + name: "simple template no variables", + inst: &mcp.Instruction{ + ID: "test1", + Name: "Test", + Template: "Hello, world!", + Enabled: true, + }, + ctx: mcp.InstructionRenderContext{}, + want: "Hello, world!", + }, + { + name: "template with model context", + inst: &mcp.Instruction{ + ID: "test2", + Name: "Test", + Template: "Model: {{.Model.ID}} (Tier: {{.Model.Tier}})", + Enabled: true, + }, + ctx: mcp.InstructionRenderContext{ + Model: mcp.ModelContext{ + ID: "claude-opus-4-5-20251101", + Tier: "large", + }, + }, + want: "Model: claude-opus-4-5-20251101 (Tier: large)", + }, + { + name: "template with env context", + inst: &mcp.Instruction{ + ID: "test3", + Name: "Test", + Template: "{{if eq .Env.OS \"macos\"}}macOS detected{{else}}Other OS{{end}}", + Enabled: true, + }, + ctx: mcp.InstructionRenderContext{ + Env: mcp.EnvContext{OS: "macos"}, + }, + want: "macOS detected", + }, + { + name: "template with custom variables", + inst: &mcp.Instruction{ + ID: "test4", + Name: "Test", + Template: "Project: {{.Vars.project_name}}", + Enabled: true, + Variables: map[string]string{ + "project_name": "Switchboard", + }, + }, + ctx: mcp.InstructionRenderContext{ + Vars: map[string]string{}, + }, + want: "Project: Switchboard", + }, + { + name: "context vars override instruction vars", + inst: &mcp.Instruction{ + ID: "test5", + Name: "Test", + Template: "Version: {{.Vars.version}}", + Enabled: true, + Variables: map[string]string{ + "version": "1.0", + }, + }, + ctx: mcp.InstructionRenderContext{ + Vars: map[string]string{ + "version": "2.0", + }, + }, + want: "Version: 2.0", + }, + { + name: "conditional based on model tier", + inst: &mcp.Instruction{ + ID: "test6", + Name: "Test", + Template: `{{if eq .Model.Tier "large"}}Full capabilities{{else}}Limited mode{{end}}`, + Enabled: true, + }, + ctx: mcp.InstructionRenderContext{ + Model: mcp.ModelContext{Tier: "large"}, + }, + want: "Full capabilities", + }, + { + name: "tool availability check", + inst: &mcp.Instruction{ + ID: "test7", + Name: "Test", + Template: "{{if .Env.HasGit}}Git available{{else}}No git{{end}}", + Enabled: true, + }, + ctx: mcp.InstructionRenderContext{ + Env: mcp.EnvContext{HasGit: true}, + }, + want: "Git available", + }, + { + name: "invalid template syntax", + inst: &mcp.Instruction{ + ID: "test8", + Name: "Test", + Template: "{{.Invalid", + Enabled: true, + }, + ctx: mcp.InstructionRenderContext{}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := RenderInstruction(tt.inst, tt.ctx) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestRenderInstructions(t *testing.T) { + instructions := []*mcp.Instruction{ + { + ID: "inst1", + Name: "First", + Template: "First instruction for {{.Model.Tier}}", + Enabled: true, + }, + { + ID: "inst2", + Name: "Disabled", + Template: "Should not appear", + Enabled: false, + }, + { + ID: "inst3", + Name: "Third", + Template: "Third instruction", + Enabled: true, + }, + } + + result, err := RenderInstructions(instructions, "gpt-4o", "", nil) + require.NoError(t, err) + + assert.Contains(t, result, "First instruction for large") + assert.Contains(t, result, "Third instruction") + assert.NotContains(t, result, "Should not appear") +} + +func TestBuildRenderContext(t *testing.T) { + ctx := BuildRenderContext("claude-opus-4-5-20251101", "", map[string]string{ + "custom": "value", + }, nil) + + assert.Equal(t, "claude-opus-4-5-20251101", ctx.Model.ID) + assert.Equal(t, "large", ctx.Model.Tier) + assert.NotEmpty(t, ctx.Env.OS) + assert.Equal(t, "value", ctx.Vars["custom"]) +} + +func TestBuildRenderContext_DefaultTier(t *testing.T) { + ctx := BuildRenderContext("unknown-model", "small", nil, nil) + + assert.Equal(t, "unknown-model", ctx.Model.ID) + assert.Equal(t, "small", ctx.Model.Tier) + assert.NotNil(t, ctx.Vars) +} + +func TestBuildRenderContext_CustomTiers(t *testing.T) { + customTiers := map[string]string{ + "my-model": "large", + } + ctx := BuildRenderContext("my-model", "", nil, customTiers) + + assert.Equal(t, "my-model", ctx.Model.ID) + assert.Equal(t, "large", ctx.Model.Tier) +} diff --git a/compass/repo.go b/compass/repo.go new file mode 100644 index 00000000..cfe2267c --- /dev/null +++ b/compass/repo.go @@ -0,0 +1,283 @@ +package compass + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + + mcp "github.com/daltoniam/switchboard" +) + +// RepoInstructionsFile is the filename for repo-local instructions stored in .git/switchboard/ +const RepoInstructionsFile = "instructions.json" + +// ProjectInstructionsFile is the filename for project instructions in repo root +const ProjectInstructionsFile = ".switchboard/instructions.json" + +// RepoInstructionsConfig holds instructions stored in a git repository. +// This is stored in .git/switchboard/instructions.json for worktree-shared, +// uncommitted instructions. +type RepoInstructionsConfig struct { + DefaultTier string `json:"default_tier,omitempty"` + ModelTiers map[string]string `json:"model_tiers,omitempty"` + Instructions []*mcp.Instruction `json:"instructions,omitempty"` +} + +// FindGitDir finds the .git directory for the given path. +// It walks up the directory tree until it finds a .git directory or file (worktree). +// Returns empty string if no git repository is found. +func FindGitDir(startPath string) string { + if startPath == "" { + var err error + startPath, err = os.Getwd() + if err != nil { + return "" + } + } + + path, err := filepath.Abs(startPath) + if err != nil { + return "" + } + + for { + gitPath := filepath.Join(path, ".git") + info, err := os.Stat(gitPath) + if err == nil { + if info.IsDir() { + // Regular git repo + return gitPath + } + // Worktree: .git is a file containing "gitdir: /path/to/actual/.git/worktrees/name" + content, err := os.ReadFile(gitPath) + if err == nil { + return resolveWorktreeGitDir(string(content)) + } + } + + parent := filepath.Dir(path) + if parent == path { + // Reached root + return "" + } + path = parent + } +} + +// resolveWorktreeGitDir parses a worktree .git file and returns the common git dir. +// Worktree .git files contain: "gitdir: /path/to/.git/worktrees/worktree-name" +// We want the parent .git directory, not the worktree-specific one. +func resolveWorktreeGitDir(content string) string { + // Format: "gitdir: /path/to/.git/worktrees/name\n" + if len(content) < 8 || content[:8] != "gitdir: " { + return "" + } + gitDir := strings.TrimSpace(content[8:]) + + // Check if this is a worktree path (.git/worktrees/name) + // If so, return the parent .git directory + dir := gitDir + for { + base := filepath.Base(dir) + parent := filepath.Dir(dir) + if base == "worktrees" { + // parent should be .git + return parent + } + if parent == dir { + // Reached root without finding worktrees + // This might be a direct reference, return as-is + return gitDir + } + dir = parent + } +} + +// LoadRepoInstructions loads instructions from .git/switchboard/instructions.json +// Returns nil if the file doesn't exist or can't be read. +func LoadRepoInstructions(gitDir string) *RepoInstructionsConfig { + if gitDir == "" { + return nil + } + + path := filepath.Join(gitDir, "switchboard", RepoInstructionsFile) + data, err := os.ReadFile(path) + if err != nil { + return nil + } + + var config RepoInstructionsConfig + if err := json.Unmarshal(data, &config); err != nil { + return nil + } + + return &config +} + +// LoadProjectInstructions loads instructions from .switchboard/instructions.json in repo root. +// Returns nil if the file doesn't exist or can't be read. +func LoadProjectInstructions(repoRoot string) *RepoInstructionsConfig { + if repoRoot == "" { + return nil + } + + path := filepath.Join(repoRoot, ProjectInstructionsFile) + data, err := os.ReadFile(path) + if err != nil { + return nil + } + + var config RepoInstructionsConfig + if err := json.Unmarshal(data, &config); err != nil { + return nil + } + + return &config +} + +// SaveRepoInstructions saves instructions to .git/switchboard/instructions.json +func SaveRepoInstructions(gitDir string, config *RepoInstructionsConfig) error { + if gitDir == "" { + return os.ErrNotExist + } + + dir := filepath.Join(gitDir, "switchboard") + if err := os.MkdirAll(dir, 0700); err != nil { + return err + } + + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + return err + } + + path := filepath.Join(dir, RepoInstructionsFile) + return os.WriteFile(path, data, 0600) +} + +// GetRepoRoot returns the repository root directory from a git dir path. +// For regular repos, this is the parent of .git. +// For worktrees, this finds the worktree root. +func GetRepoRoot(gitDir string) string { + if gitDir == "" { + return "" + } + // For regular repos, .git is at repo root + // Parent of .git is the repo root + return filepath.Dir(gitDir) +} + +// MergeInstructions merges instructions from multiple sources. +// Priority (highest to lowest): +// 1. Repo-local (.git/switchboard/instructions.json) - worktree-shared, uncommitted +// 2. Project (.switchboard/instructions.json) - committed, version-controlled +// 3. Global (~/.config/switchboard/config.json) - user-wide defaults +// +// Instructions with the same ID from higher priority sources replace lower priority ones. +// ModelTiers and DefaultTier also merge with the same priority. +func MergeInstructions(global, project, repoLocal *mcp.InstructionsConfig) *mcp.InstructionsConfig { + result := &mcp.InstructionsConfig{ + Instructions: make([]*mcp.Instruction, 0), + ModelTiers: make(map[string]string), + } + + // Start with global + if global != nil { + result.DefaultTier = global.DefaultTier + for k, v := range global.ModelTiers { + result.ModelTiers[k] = v + } + result.Instructions = append(result.Instructions, global.Instructions...) + } + + // Override with project + if project != nil { + if project.DefaultTier != "" { + result.DefaultTier = project.DefaultTier + } + for k, v := range project.ModelTiers { + result.ModelTiers[k] = v + } + result.Instructions = mergeInstructionLists(result.Instructions, project.Instructions) + } + + // Override with repo-local + if repoLocal != nil { + if repoLocal.DefaultTier != "" { + result.DefaultTier = repoLocal.DefaultTier + } + for k, v := range repoLocal.ModelTiers { + result.ModelTiers[k] = v + } + result.Instructions = mergeInstructionLists(result.Instructions, repoLocal.Instructions) + } + + return result +} + +// mergeInstructionLists merges two instruction lists, with newer replacing older by ID. +func mergeInstructionLists(base, overlay []*mcp.Instruction) []*mcp.Instruction { + if len(overlay) == 0 { + return base + } + + // Build a map of existing instructions by ID + byID := make(map[string]int) + for i, inst := range base { + byID[inst.ID] = i + } + + // Merge overlay + result := make([]*mcp.Instruction, len(base)) + copy(result, base) + + for _, inst := range overlay { + if idx, exists := byID[inst.ID]; exists { + // Replace existing + result[idx] = inst + } else { + // Append new + result = append(result, inst) + byID[inst.ID] = len(result) - 1 + } + } + + return result +} + +// RepoInstructionsConfigToMCP converts RepoInstructionsConfig to mcp.InstructionsConfig +func RepoInstructionsConfigToMCP(r *RepoInstructionsConfig) *mcp.InstructionsConfig { + if r == nil { + return nil + } + return &mcp.InstructionsConfig{ + DefaultTier: r.DefaultTier, + ModelTiers: r.ModelTiers, + Instructions: r.Instructions, + } +} + +// LoadAllInstructions loads and merges instructions from all sources for a given working directory. +// Returns the merged instruction config with proper priority handling. +func LoadAllInstructions(workDir string, globalConfig *mcp.InstructionsConfig) *mcp.InstructionsConfig { + gitDir := FindGitDir(workDir) + + var projectConfig, repoLocalConfig *mcp.InstructionsConfig + + if gitDir != "" { + repoRoot := GetRepoRoot(gitDir) + + // Load project instructions (.switchboard/instructions.json) + if proj := LoadProjectInstructions(repoRoot); proj != nil { + projectConfig = RepoInstructionsConfigToMCP(proj) + } + + // Load repo-local instructions (.git/switchboard/instructions.json) + if local := LoadRepoInstructions(gitDir); local != nil { + repoLocalConfig = RepoInstructionsConfigToMCP(local) + } + } + + return MergeInstructions(globalConfig, projectConfig, repoLocalConfig) +} diff --git a/compass/repo_test.go b/compass/repo_test.go new file mode 100644 index 00000000..b8efa866 --- /dev/null +++ b/compass/repo_test.go @@ -0,0 +1,225 @@ +package compass + +import ( + "os" + "path/filepath" + "testing" + + mcp "github.com/daltoniam/switchboard" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFindGitDir(t *testing.T) { + // Test with current directory (should find .git since we're in a git repo) + gitDir := FindGitDir("") + if gitDir != "" { + assert.True(t, filepath.IsAbs(gitDir)) + assert.Contains(t, gitDir, ".git") + } +} + +func TestFindGitDir_NonExistent(t *testing.T) { + // Test with a directory that doesn't exist + gitDir := FindGitDir("/nonexistent/path/that/does/not/exist") + assert.Empty(t, gitDir) +} + +func TestResolveWorktreeGitDir(t *testing.T) { + tests := []struct { + name string + content string + want string + }{ + { + name: "regular worktree path", + content: "gitdir: /home/user/repo/.git/worktrees/feature-branch\n", + want: "/home/user/repo/.git", + }, + { + name: "invalid format", + content: "invalid content", + want: "", + }, + { + name: "direct git reference", + content: "gitdir: /home/user/repo/.git\n", + want: "/home/user/repo/.git", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := resolveWorktreeGitDir(tt.content) + if tt.want != "" { + assert.Equal(t, tt.want, got) + } + }) + } +} + +func TestLoadSaveRepoInstructions(t *testing.T) { + // Create temp directory to simulate .git + tmpDir := t.TempDir() + + config := &RepoInstructionsConfig{ + DefaultTier: "small", + ModelTiers: map[string]string{ + "my-model": "large", + }, + Instructions: []*mcp.Instruction{ + { + ID: "test-inst", + Name: "Test Instruction", + Template: "Hello from repo", + Enabled: true, + }, + }, + } + + // Save + err := SaveRepoInstructions(tmpDir, config) + require.NoError(t, err) + + // Verify file was created + path := filepath.Join(tmpDir, "switchboard", RepoInstructionsFile) + _, err = os.Stat(path) + require.NoError(t, err) + + // Load + loaded := LoadRepoInstructions(tmpDir) + require.NotNil(t, loaded) + + assert.Equal(t, "small", loaded.DefaultTier) + assert.Equal(t, "large", loaded.ModelTiers["my-model"]) + assert.Len(t, loaded.Instructions, 1) + assert.Equal(t, "test-inst", loaded.Instructions[0].ID) +} + +func TestLoadRepoInstructions_NotFound(t *testing.T) { + result := LoadRepoInstructions("/nonexistent") + assert.Nil(t, result) +} + +func TestLoadRepoInstructions_EmptyDir(t *testing.T) { + result := LoadRepoInstructions("") + assert.Nil(t, result) +} + +func TestMergeInstructions(t *testing.T) { + global := &mcp.InstructionsConfig{ + DefaultTier: "large", + ModelTiers: map[string]string{ + "model-a": "large", + "model-b": "small", + }, + Instructions: []*mcp.Instruction{ + {ID: "global-1", Name: "Global 1", Template: "G1", Enabled: true}, + {ID: "shared", Name: "Shared Global", Template: "SG", Enabled: true}, + }, + } + + project := &mcp.InstructionsConfig{ + DefaultTier: "small", + ModelTiers: map[string]string{ + "model-b": "large", // Override + "model-c": "small", // New + }, + Instructions: []*mcp.Instruction{ + {ID: "project-1", Name: "Project 1", Template: "P1", Enabled: true}, + {ID: "shared", Name: "Shared Project", Template: "SP", Enabled: true}, // Override + }, + } + + repoLocal := &mcp.InstructionsConfig{ + ModelTiers: map[string]string{ + "model-d": "large", // New + }, + Instructions: []*mcp.Instruction{ + {ID: "local-1", Name: "Local 1", Template: "L1", Enabled: true}, + {ID: "shared", Name: "Shared Local", Template: "SL", Enabled: true}, // Override + }, + } + + result := MergeInstructions(global, project, repoLocal) + + // DefaultTier: project overrides global, repo-local has empty so project's "small" wins + assert.Equal(t, "small", result.DefaultTier) + + // ModelTiers: all should be merged with proper priority + assert.Equal(t, "large", result.ModelTiers["model-a"]) // From global + assert.Equal(t, "large", result.ModelTiers["model-b"]) // Project overrides global's "small" + assert.Equal(t, "small", result.ModelTiers["model-c"]) // From project + assert.Equal(t, "large", result.ModelTiers["model-d"]) // From repo-local + + // Instructions: should have 4 (global-1, project-1, local-1, shared) + assert.Len(t, result.Instructions, 4) + + // Find the shared instruction - should be from repo-local + var sharedInst *mcp.Instruction + for _, inst := range result.Instructions { + if inst.ID == "shared" { + sharedInst = inst + break + } + } + require.NotNil(t, sharedInst) + assert.Equal(t, "Shared Local", sharedInst.Name) + assert.Equal(t, "SL", sharedInst.Template) +} + +func TestMergeInstructions_NilInputs(t *testing.T) { + result := MergeInstructions(nil, nil, nil) + assert.NotNil(t, result) + assert.Empty(t, result.Instructions) + assert.NotNil(t, result.ModelTiers) +} + +func TestMergeInstructions_GlobalOnly(t *testing.T) { + global := &mcp.InstructionsConfig{ + DefaultTier: "large", + Instructions: []*mcp.Instruction{ + {ID: "g1", Name: "G1"}, + }, + } + + result := MergeInstructions(global, nil, nil) + assert.Equal(t, "large", result.DefaultTier) + assert.Len(t, result.Instructions, 1) +} + +func TestGetRepoRoot(t *testing.T) { + tests := []struct { + gitDir string + want string + }{ + {"/home/user/repo/.git", "/home/user/repo"}, + {"/Users/dev/project/.git", "/Users/dev/project"}, + {"", ""}, + } + + for _, tt := range tests { + got := GetRepoRoot(tt.gitDir) + assert.Equal(t, tt.want, got) + } +} + +func TestRepoInstructionsConfigToMCP(t *testing.T) { + repo := &RepoInstructionsConfig{ + DefaultTier: "small", + ModelTiers: map[string]string{"a": "large"}, + Instructions: []*mcp.Instruction{ + {ID: "test"}, + }, + } + + result := RepoInstructionsConfigToMCP(repo) + assert.Equal(t, "small", result.DefaultTier) + assert.Equal(t, "large", result.ModelTiers["a"]) + assert.Len(t, result.Instructions, 1) +} + +func TestRepoInstructionsConfigToMCP_Nil(t *testing.T) { + result := RepoInstructionsConfigToMCP(nil) + assert.Nil(t, result) +} diff --git a/config/config.go b/config/config.go index 92669ba1..4357a8aa 100644 --- a/config/config.go +++ b/config/config.go @@ -130,6 +130,8 @@ func mergeWithDefaults(file *mcp.Config) *mcp.Config { defIC.Credentials[k] = v } } + // Preserve instructions from file + cfg.Instructions = file.Instructions return cfg } @@ -207,3 +209,70 @@ func (m *manager) DefaultCredentialKeys(name string) []string { } return keys } + +// --- Instructions management --- + +func (m *manager) GetInstructions() *mcp.InstructionsConfig { + m.mu.RLock() + defer m.mu.RUnlock() + if m.cfg.Instructions == nil { + return &mcp.InstructionsConfig{} + } + return m.cfg.Instructions +} + +func (m *manager) SetInstructions(ic *mcp.InstructionsConfig) error { + m.mu.Lock() + defer m.mu.Unlock() + m.cfg.Instructions = ic + return m.saveLocked() +} + +func (m *manager) GetInstruction(id string) (*mcp.Instruction, bool) { + m.mu.RLock() + defer m.mu.RUnlock() + if m.cfg.Instructions == nil { + return nil, false + } + for _, inst := range m.cfg.Instructions.Instructions { + if inst.ID == id { + return inst, true + } + } + return nil, false +} + +func (m *manager) SetInstruction(inst *mcp.Instruction) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.cfg.Instructions == nil { + m.cfg.Instructions = &mcp.InstructionsConfig{} + } + // Update existing or append new + for i, existing := range m.cfg.Instructions.Instructions { + if existing.ID == inst.ID { + m.cfg.Instructions.Instructions[i] = inst + return m.saveLocked() + } + } + m.cfg.Instructions.Instructions = append(m.cfg.Instructions.Instructions, inst) + return m.saveLocked() +} + +func (m *manager) DeleteInstruction(id string) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.cfg.Instructions == nil { + return nil + } + for i, inst := range m.cfg.Instructions.Instructions { + if inst.ID == id { + m.cfg.Instructions.Instructions = append( + m.cfg.Instructions.Instructions[:i], + m.cfg.Instructions.Instructions[i+1:]..., + ) + return m.saveLocked() + } + } + return nil +} diff --git a/config/config_test.go b/config/config_test.go index 0b9c01a2..fc4499f3 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -284,3 +284,152 @@ func TestDefaultCredentialKeys_Unknown(t *testing.T) { keys := m.DefaultCredentialKeys("nonexistent") assert.Nil(t, keys) } + +// --- Instructions tests --- + +func TestGetInstructions_Empty(t *testing.T) { + m, _ := newTestManager(t) + require.NoError(t, m.Load()) + + instConfig := m.GetInstructions() + require.NotNil(t, instConfig) + assert.Empty(t, instConfig.Instructions) +} + +func TestSetInstructions(t *testing.T) { + m, _ := newTestManager(t) + require.NoError(t, m.Load()) + + ic := &mcp.InstructionsConfig{ + DefaultTier: "engineer", + Instructions: []*mcp.Instruction{ + {ID: "test1", Name: "Test", Template: "Hello", Enabled: true}, + }, + } + err := m.SetInstructions(ic) + require.NoError(t, err) + + got := m.GetInstructions() + require.NotNil(t, got) + assert.Equal(t, "engineer", got.DefaultTier) + require.Len(t, got.Instructions, 1) + assert.Equal(t, "test1", got.Instructions[0].ID) +} + +func TestGetInstruction(t *testing.T) { + m, _ := newTestManager(t) + require.NoError(t, m.Load()) + + // Initially not found + _, ok := m.GetInstruction("test1") + assert.False(t, ok) + + // Add instruction + err := m.SetInstruction(&mcp.Instruction{ + ID: "test1", + Name: "Test", + Template: "Hello", + Enabled: true, + }) + require.NoError(t, err) + + // Now found + inst, ok := m.GetInstruction("test1") + assert.True(t, ok) + assert.Equal(t, "Test", inst.Name) + assert.Equal(t, "Hello", inst.Template) +} + +func TestSetInstruction_Update(t *testing.T) { + m, _ := newTestManager(t) + require.NoError(t, m.Load()) + + // Create + err := m.SetInstruction(&mcp.Instruction{ + ID: "test1", + Name: "Test", + Template: "Hello", + Enabled: true, + }) + require.NoError(t, err) + + // Update + err = m.SetInstruction(&mcp.Instruction{ + ID: "test1", + Name: "Updated", + Template: "World", + Enabled: false, + }) + require.NoError(t, err) + + inst, ok := m.GetInstruction("test1") + assert.True(t, ok) + assert.Equal(t, "Updated", inst.Name) + assert.Equal(t, "World", inst.Template) + assert.False(t, inst.Enabled) + + // Should still have only 1 instruction + assert.Len(t, m.GetInstructions().Instructions, 1) +} + +func TestDeleteInstruction(t *testing.T) { + m, _ := newTestManager(t) + require.NoError(t, m.Load()) + + // Add two instructions + err := m.SetInstruction(&mcp.Instruction{ID: "test1", Name: "Test1", Enabled: true}) + require.NoError(t, err) + err = m.SetInstruction(&mcp.Instruction{ID: "test2", Name: "Test2", Enabled: true}) + require.NoError(t, err) + + // Delete one + err = m.DeleteInstruction("test1") + require.NoError(t, err) + + // Verify + _, ok := m.GetInstruction("test1") + assert.False(t, ok) + + inst, ok := m.GetInstruction("test2") + assert.True(t, ok) + assert.Equal(t, "Test2", inst.Name) + + assert.Len(t, m.GetInstructions().Instructions, 1) +} + +func TestDeleteInstruction_NotFound(t *testing.T) { + m, _ := newTestManager(t) + require.NoError(t, m.Load()) + + // Should not error when deleting non-existent + err := m.DeleteInstruction("nonexistent") + assert.NoError(t, err) +} + +func TestInstructions_Persistence(t *testing.T) { + m, path := newTestManager(t) + require.NoError(t, m.Load()) + + // Add instruction + err := m.SetInstruction(&mcp.Instruction{ + ID: "test1", + Name: "Test", + Description: "A test instruction", + Template: "Hello {{.Model.ID}}", + Enabled: true, + Variables: map[string]string{"foo": "bar"}, + }) + require.NoError(t, err) + + // Load fresh manager + m2 := &manager{filePath: path} + require.NoError(t, m2.Load()) + + inst, ok := m2.GetInstruction("test1") + require.True(t, ok) + assert.Equal(t, "Test", inst.Name) + assert.Equal(t, "A test instruction", inst.Description) + assert.Equal(t, "Hello {{.Model.ID}}", inst.Template) + assert.True(t, inst.Enabled) + assert.Equal(t, "bar", inst.Variables["foo"]) +} diff --git a/mcp.go b/mcp.go index bb219099..86533526 100644 --- a/mcp.go +++ b/mcp.go @@ -23,6 +23,7 @@ type IntegrationConfig struct { // Config is the top-level configuration containing all integrations. type Config struct { Integrations map[string]*IntegrationConfig `json:"integrations"` + Instructions *InstructionsConfig `json:"instructions,omitempty"` } // ToolDefinition describes an API operation an integration exposes. @@ -96,6 +97,12 @@ type ConfigService interface { SetIntegration(name string, ic *IntegrationConfig) error EnabledIntegrations() []string DefaultCredentialKeys(name string) []string + // Instructions management + GetInstructions() *InstructionsConfig + SetInstructions(ic *InstructionsConfig) error + GetInstruction(id string) (*Instruction, bool) + SetInstruction(inst *Instruction) error + DeleteInstruction(id string) error } // Registry holds all registered integrations and provides lookup. @@ -111,3 +118,48 @@ type Services struct { Config ConfigService Registry Registry } + +// Instruction represents a system instruction/prompt template that can be +// dynamically rendered with context (model info, environment, custom variables). +// Inspired by the Helmsman Ruby gem's approach to managing AI assistant instructions. +type Instruction struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Template string `json:"template"` + Enabled bool `json:"enabled"` + Variables map[string]string `json:"variables,omitempty"` +} + +// InstructionsConfig holds all instruction templates. +type InstructionsConfig struct { + DefaultTier string `json:"default_tier,omitempty"` + ModelTiers map[string]string `json:"model_tiers,omitempty"` + Instructions []*Instruction `json:"instructions,omitempty"` +} + +// ModelContext provides information about the AI model requesting instructions. +type ModelContext struct { + ID string // Model identifier (e.g., "claude-opus-4-5-20251101") + Tier string // Model tier: "large" or "small" +} + +// EnvContext provides information about the execution environment. +type EnvContext struct { + OS string // Operating system: "macos", "linux", "windows" + Shell string // Shell: "zsh", "bash", "fish", "sh", "powershell" + InDocker bool // Running inside Docker container + InSSH bool // Running in SSH session + HasMise bool // mise (formerly rtx) available + HasBrew bool // Homebrew available + HasApt bool // apt available + HasGh bool // GitHub CLI available + HasGit bool // git available +} + +// InstructionRenderContext is the data passed to instruction templates. +type InstructionRenderContext struct { + Model ModelContext + Env EnvContext + Vars map[string]string // Custom variables from instruction config +} diff --git a/server/server.go b/server/server.go index be1a478c..52b82c41 100644 --- a/server/server.go +++ b/server/server.go @@ -12,6 +12,7 @@ import ( "strings" mcp "github.com/daltoniam/switchboard" + "github.com/daltoniam/switchboard/compass" "github.com/daltoniam/switchboard/script" mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -122,6 +123,21 @@ Use search first to discover available tools and their parameter schemas.`, s.mcpServer.AddTool(searchTool, s.handleSearch) s.mcpServer.AddTool(executeTool, s.handleExecute) + + // Register the instructions prompt (Helmsman-style) + instructionsPrompt := &mcpsdk.Prompt{ + Name: "instructions", + Title: "System Instructions", + Description: "Dynamically rendered system instructions based on model and environment. Pass the model_id as an argument to get context-aware instructions.", + Arguments: []*mcpsdk.PromptArgument{ + { + Name: "model_id", + Description: "The model identifier (e.g., 'claude-opus-4-5-20251101', 'gpt-4o'). Used to determine model tier for conditional instructions.", + Required: false, + }, + }, + } + s.mcpServer.AddPrompt(instructionsPrompt, s.handleInstructionsPrompt) } func (s *Server) configureIntegrations() { @@ -444,3 +460,68 @@ func objectSchema(properties map[string]any, required []string) map[string]any { } return s } + +// handleInstructionsPrompt handles the instructions prompt request. +// It renders all enabled instructions with context-aware templating. +// Instructions are loaded from multiple sources with priority: +// 1. Repo-local (.git/switchboard/instructions.json) - worktree-shared, uncommitted +// 2. Project (.switchboard/instructions.json) - committed, version-controlled +// 3. Global (~/.config/switchboard/config.json) - user-wide defaults +func (s *Server) handleInstructionsPrompt(_ context.Context, req *mcpsdk.GetPromptRequest) (*mcpsdk.GetPromptResult, error) { + // Extract model_id from arguments + modelID := "" + if req.Params.Arguments != nil { + if id, ok := req.Params.Arguments["model_id"]; ok { + modelID = id + } + } + + // Get global instructions config + globalConfig := s.services.Config.GetInstructions() + + // Load and merge instructions from all sources (global + project + repo-local) + // Uses current working directory to find git repo context + instConfig := compass.LoadAllInstructions("", globalConfig) + + if instConfig == nil || len(instConfig.Instructions) == 0 { + return &mcpsdk.GetPromptResult{ + Description: "No instructions configured", + Messages: []*mcpsdk.PromptMessage{ + { + Role: "user", + Content: &mcpsdk.TextContent{ + Text: "No system instructions have been configured. Use the Switchboard web UI to add instruction templates.", + }, + }, + }, + }, nil + } + + // Render instructions + rendered, err := compass.RenderInstructions(instConfig.Instructions, modelID, instConfig.DefaultTier, instConfig.ModelTiers) + if err != nil { + return &mcpsdk.GetPromptResult{ + Description: "Error rendering instructions", + Messages: []*mcpsdk.PromptMessage{ + { + Role: "user", + Content: &mcpsdk.TextContent{ + Text: fmt.Sprintf("Error rendering instructions: %v", err), + }, + }, + }, + }, nil + } + + return &mcpsdk.GetPromptResult{ + Description: "System instructions for AI assistant", + Messages: []*mcpsdk.PromptMessage{ + { + Role: "user", + Content: &mcpsdk.TextContent{ + Text: rendered, + }, + }, + }, + }, nil +} diff --git a/server/server_test.go b/server/server_test.go index 6273c26a..477a17dc 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -45,6 +45,17 @@ func (m *mockConfigService) EnabledIntegrations() []string { } func (m *mockConfigService) DefaultCredentialKeys(_ string) []string { return nil } +// Instructions stubs +func (m *mockConfigService) GetInstructions() *mcp.InstructionsConfig { return nil } +func (m *mockConfigService) SetInstructions(_ *mcp.InstructionsConfig) error { + return nil +} +func (m *mockConfigService) GetInstruction(_ string) (*mcp.Instruction, bool) { + return nil, false +} +func (m *mockConfigService) SetInstruction(_ *mcp.Instruction) error { return nil } +func (m *mockConfigService) DeleteInstruction(_ string) error { return nil } + type mockIntegration struct { name string tools []mcp.ToolDefinition diff --git a/web/templates/layouts/base.templ b/web/templates/layouts/base.templ index b7258f4d..b993631a 100644 --- a/web/templates/layouts/base.templ +++ b/web/templates/layouts/base.templ @@ -19,6 +19,7 @@ func navItems() []NavItem { return []NavItem{ {Path: "/", Label: "Dashboard", Icon: "⚡"}, {Path: "/integrations", Label: "Integrations", Icon: "🔌"}, + {Path: "/instructions", Label: "Instructions", Icon: "📝"}, } } diff --git a/web/templates/layouts/base_templ.go b/web/templates/layouts/base_templ.go index a3248289..639a27d7 100644 --- a/web/templates/layouts/base_templ.go +++ b/web/templates/layouts/base_templ.go @@ -27,6 +27,7 @@ func navItems() []NavItem { return []NavItem{ {Path: "/", Label: "Dashboard", Icon: "⚡"}, {Path: "/integrations", Label: "Integrations", Icon: "🔌"}, + {Path: "/instructions", Label: "Instructions", Icon: "📝"}, } } @@ -65,7 +66,7 @@ func Base(data PageData) templ.Component { var templ_7745c5c3_Var2 string templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(data.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/layouts/base.templ`, Line: 38, Col: 22} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/layouts/base.templ`, Line: 39, Col: 22} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) if templ_7745c5c3_Err != nil { @@ -84,7 +85,7 @@ func Base(data PageData) templ.Component { var templ_7745c5c3_Var3 templ.SafeURL templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(item.Path)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/layouts/base.templ`, Line: 322, Col: 40} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/layouts/base.templ`, Line: 323, Col: 40} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { @@ -97,7 +98,7 @@ func Base(data PageData) templ.Component { var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(item.Icon) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/layouts/base.templ`, Line: 322, Col: 78} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/layouts/base.templ`, Line: 323, Col: 78} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { @@ -110,7 +111,7 @@ func Base(data PageData) templ.Component { var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(item.Label) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/layouts/base.templ`, Line: 322, Col: 93} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/layouts/base.templ`, Line: 323, Col: 93} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { @@ -128,7 +129,7 @@ func Base(data PageData) templ.Component { var templ_7745c5c3_Var6 templ.SafeURL templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(item.Path)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/layouts/base.templ`, Line: 324, Col: 40} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/layouts/base.templ`, Line: 325, Col: 40} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { @@ -141,7 +142,7 @@ func Base(data PageData) templ.Component { var templ_7745c5c3_Var7 string templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(item.Icon) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/layouts/base.templ`, Line: 324, Col: 71} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/layouts/base.templ`, Line: 325, Col: 71} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) if templ_7745c5c3_Err != nil { @@ -154,7 +155,7 @@ func Base(data PageData) templ.Component { var templ_7745c5c3_Var8 string templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(item.Label) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/layouts/base.templ`, Line: 324, Col: 86} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/layouts/base.templ`, Line: 325, Col: 86} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { @@ -178,7 +179,7 @@ func Base(data PageData) templ.Component { var templ_7745c5c3_Var9 string templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(data.FlashSuccess) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/layouts/base.templ`, Line: 330, Col: 57} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/layouts/base.templ`, Line: 331, Col: 57} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) if templ_7745c5c3_Err != nil { @@ -197,7 +198,7 @@ func Base(data PageData) templ.Component { var templ_7745c5c3_Var10 string templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(data.FlashError) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/layouts/base.templ`, Line: 333, Col: 53} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/layouts/base.templ`, Line: 334, Col: 53} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) if templ_7745c5c3_Err != nil { diff --git a/web/templates/pages/instruction_detail.templ b/web/templates/pages/instruction_detail.templ new file mode 100644 index 00000000..fef32b5b --- /dev/null +++ b/web/templates/pages/instruction_detail.templ @@ -0,0 +1,144 @@ +package pages + +import ( + "github.com/daltoniam/switchboard/web/templates/layouts" + "github.com/daltoniam/switchboard/web/templates/components" +) + +type InstructionDetailData struct { + ID string + Name string + Description string + Template string + Enabled bool + Variables map[string]string + IsNew bool + FlashResult string + FlashError string +} + +templ InstructionDetail(page layouts.PageData, data InstructionDetailData) { + @layouts.Base(page) { +
+ Instruction templates are dynamically rendered system prompts for AI assistants.
+ They support Go template syntax with model and environment context.
+ if data.DefaultTier != "" {
+
Default tier: { data.DefaultTier }
+ }
+
+ No instructions configured yet. Click "New Instruction" to create one. +
++ Available variables in templates: +
+{{.Model.ID}} — Model identifier (e.g., "claude-opus-4-5-20251101"){{.Model.Tier}} — Model tier: "agi", "engineer", or "monkey"{{.Env.OS}} — Operating system: "macos", "linux", "windows"{{.Env.Shell}} — Shell: "zsh", "bash", "fish", etc.{{.Env.InDocker}} — true if running in Docker{{.Env.HasGit}}, {{.Env.HasGh}} — Tool availability{{.Vars.key}} — Custom variables defined per instructionInstruction templates are dynamically rendered system prompts for AI assistants. They support Go template syntax with model and environment context. ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if data.DefaultTier != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
Default tier: ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var3 string
+ templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(data.DefaultTier)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/pages/instructions_list.templ`, Line: 30, Col: 58}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
No instructions configured yet. Click \"New Instruction\" to create one.
Available variables in templates:
{{.Model.ID}} — Model identifier (e.g., \"claude-opus-4-5-20251101\"){{.Model.Tier}} — Model tier: \"agi\", \"engineer\", or \"monkey\"{{.Env.OS}} — Operating system: \"macos\", \"linux\", \"windows\"{{.Env.Shell}} — Shell: \"zsh\", \"bash\", \"fish\", etc.{{.Env.InDocker}} — true if running in Docker{{.Env.HasGit}}, {{.Env.HasGh}} — Tool availability{{.Vars.key}} — Custom variables defined per instruction