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
17 changes: 17 additions & 0 deletions agents/agents.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ const maxFrontmatterSize = 64 * 1024

var validSegment = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_\-\.]*$`)

// ValidateModelID validates a model ID string format.
// Returns an error if the model ID is empty, exceeds 256 characters, or has invalid segments.
func ValidateModelID(modelID string) error {
if modelID == "" {
return fmt.Errorf("model ID cannot be empty")
Expand Down Expand Up @@ -58,6 +60,8 @@ func isPathWithinDir(path, dir string) (bool, error) {
return strings.HasPrefix(absPath, absDir+string(filepath.Separator)), nil
}

// LoadAllAgents loads agents from all available sources.
// Returns a deduplicated list with project configurations taking precedence over global ones.
func LoadAllAgents() ([]models.Agent, error) {
agentMap := make(map[string]models.Agent)

Expand Down Expand Up @@ -114,10 +118,15 @@ func getProjectAgentsDir() string {
return filepath.Join(".", ".opencode", "agents")
}

// LoadAgents loads agents from a directory of markdown files.
// Deprecated: Use LoadAgentsFromDir for more control over source metadata.
func LoadAgents(agentsDir string) ([]models.Agent, error) {
return LoadAgentsFromDir(agentsDir, models.SourceGlobal, models.FormatMarkdown)
}

// LoadAgentsFromDir loads agents from markdown files in a directory.
// Parameters location and format specify the source metadata for each agent.
// Returns a list of agents with valid model fields.
func LoadAgentsFromDir(agentsDir, location, format string) ([]models.Agent, error) {
absAgentsDir, err := filepath.Abs(agentsDir)
if err != nil {
Expand Down Expand Up @@ -185,6 +194,8 @@ func LoadAgentsFromDir(agentsDir, location, format string) ([]models.Agent, erro
return agentList, nil
}

// ParseFrontmatter extracts YAML frontmatter from markdown content.
// Returns a map of frontmatter fields or an error if parsing fails.
func ParseFrontmatter(content string) (map[string]interface{}, error) {
if !strings.HasPrefix(content, "---") {
return nil, fmt.Errorf("no frontmatter found")
Expand All @@ -208,6 +219,9 @@ func ParseFrontmatter(content string) (map[string]interface{}, error) {
return frontmatter, nil
}

// UpdateAgentModel updates the model field for an agent.
// Supports both markdown files and JSON configuration files.
// Returns an error if the model ID is invalid or the file cannot be updated.
func UpdateAgentModel(agentPath, agentName, newModel string) error {
if err := ValidateModelID(newModel); err != nil {
return fmt.Errorf("invalid model ID: %w", err)
Expand All @@ -231,6 +245,9 @@ func UpdateAgentModel(agentPath, agentName, newModel string) error {
return updateAgentFieldInMarkdown(agentPath, "model", newModel)
}

// UpdateAgentMode updates the mode field for an agent.
// Supports both markdown files and JSON configuration files.
// Returns an error if the mode is invalid (must be 'primary', 'subagent', or 'all').
func UpdateAgentMode(agentPath, agentName, newMode string) error {
if !isValidMode(newMode) {
return fmt.Errorf("invalid mode: must be 'primary', 'subagent', or 'all'")
Expand Down
56 changes: 50 additions & 6 deletions cli/prompt.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"github.com/mario-gc/opencode-agent-switcher/models"
)

// UI choice constants for user selections.
const (
ExitChoice = "__EXIT__"
ContinueChoice = "__CONTINUE__"
Expand All @@ -28,6 +29,8 @@ const (
TemplateInspect = "inspect"
)

// PromptAgentSelection prompts the user to select an agent from the list.
// Returns the selected agent name, a special choice (SortChoice, TemplatesChoice, ExitChoice), or an error.
func PromptAgentSelection(agents []models.Agent, currentSort string, caseSensitive bool) (string, error) {
var selectedName string

Expand Down Expand Up @@ -79,6 +82,8 @@ func formatSourceTag(source models.AgentSource) string {
return fmt.Sprintf("%s/%s", loc, fmtChar)
}

// PromptActionSelection prompts the user to choose an action for a selected agent.
// Returns the selected action (ActionModel, ActionMode, or BackChoice) or an error.
func PromptActionSelection(currentModel, currentMode string) (string, error) {
var action string

Expand Down Expand Up @@ -107,6 +112,8 @@ func PromptActionSelection(currentModel, currentMode string) (string, error) {
return action, nil
}

// PromptModeSelection prompts the user to select a mode for an agent.
// Returns the selected mode (primary, subagent, or all) or an error.
func PromptModeSelection(currentMode string) (string, error) {
var mode string

Expand Down Expand Up @@ -136,6 +143,8 @@ func PromptModeSelection(currentMode string) (string, error) {
return mode, nil
}

// PromptAddModeField prompts the user whether to add an explicit mode field.
// Returns true if the user wants to add the field, false otherwise.
func PromptAddModeField() (bool, error) {
var choice string

Expand All @@ -158,6 +167,8 @@ func PromptAddModeField() (bool, error) {
return choice == "yes", nil
}

// PromptModelSelection prompts the user to select a model from the list.
// Returns the selected model ID, CustomModelChoice, or an error.
func PromptModelSelection(modelOptions []models.ModelOption) (string, error) {
var selectedID string
options := make([]huh.Option[string], len(modelOptions)+1)
Expand All @@ -184,6 +195,8 @@ func PromptModelSelection(modelOptions []models.ModelOption) (string, error) {
return selectedID, nil
}

// PromptCustomModelInput prompts the user to enter a custom model ID.
// Returns the entered model ID or an error.
func PromptCustomModelInput() (string, error) {
var modelID string

Expand All @@ -203,6 +216,8 @@ func PromptCustomModelInput() (string, error) {
return modelID, nil
}

// PromptConfirm prompts the user with a yes/no confirmation.
// Returns true if the user confirms, false otherwise.
func PromptConfirm(message string) (bool, error) {
var confirmed bool

Expand All @@ -223,6 +238,8 @@ func PromptConfirm(message string) (bool, error) {
return confirmed, nil
}

// PromptContinueOrExit prompts the user to continue or exit the application.
// Returns true to continue, false to exit.
func PromptContinueOrExit() (bool, error) {
var choice string

Expand All @@ -245,6 +262,8 @@ func PromptContinueOrExit() (bool, error) {
return choice == ContinueChoice, nil
}

// PromptUndo prompts the user whether to undo recent changes.
// Returns true if the user wants to undo, false to keep changes.
func PromptUndo(message string) (bool, error) {
var choice string

Expand Down Expand Up @@ -288,6 +307,8 @@ func getSortDisplay(sortBy string, caseSensitive bool) string {
return sortType
}

// PromptSortSelection prompts the user to select a sorting method.
// Returns the selected sort option, updated case-sensitivity flag, or an error.
func PromptSortSelection(currentSort string, caseSensitive bool) (string, bool, error) {
var selected string

Expand Down Expand Up @@ -327,6 +348,8 @@ func PromptSortSelection(currentSort string, caseSensitive bool) (string, bool,
return selected, caseSensitive, nil
}

// SortAgents sorts a list of agents by the specified criteria.
// Returns a new sorted slice without modifying the original.
func SortAgents(agents []models.Agent, sortBy string, caseSensitive bool) []models.Agent {
result := make([]models.Agent, len(agents))
copy(result, agents)
Expand Down Expand Up @@ -364,6 +387,8 @@ func SortAgents(agents []models.Agent, sortBy string, caseSensitive bool) []mode
return result
}

// SortModels sorts a list of model options by the specified criteria.
// Returns a new sorted slice without modifying the original.
func SortModels(modelOptions []models.ModelOption, sortBy string, caseSensitive bool) []models.ModelOption {
result := make([]models.ModelOption, len(modelOptions))
copy(result, modelOptions)
Expand Down Expand Up @@ -393,6 +418,8 @@ func SortModels(modelOptions []models.ModelOption, sortBy string, caseSensitive
return result
}

// PromptTemplateMenu prompts the user to choose a template operation.
// Returns the selected action (TemplateSave, TemplateShow, or BackChoice) or an error.
func PromptTemplateMenu() (string, error) {
var choice string

Expand All @@ -416,6 +443,8 @@ func PromptTemplateMenu() (string, error) {
return choice, nil
}

// PromptTemplateName prompts the user to enter a name for a new template.
// Returns the entered name or an error.
func PromptTemplateName() (string, error) {
var name string

Expand All @@ -435,6 +464,8 @@ func PromptTemplateName() (string, error) {
return name, nil
}

// PromptTemplateSelection prompts the user to select a template from the list.
// Returns the selected template name, BackChoice, or an error.
func PromptTemplateSelection(templates []models.Template) (string, error) {
if len(templates) == 0 {
return "", nil
Expand Down Expand Up @@ -477,6 +508,8 @@ func formatDate(timestamp string) string {
return t.Format("2006-01-02")
}

// PromptTemplateAction prompts the user to choose an action for a selected template.
// Returns the selected action (TemplateInspect, TemplateLoad, TemplateDelete, or BackChoice) or an error.
func PromptTemplateAction(templateName string) (string, error) {
var action string

Expand All @@ -501,6 +534,8 @@ func PromptTemplateAction(templateName string) (string, error) {
return action, nil
}

// PromptTemplateOverwrite prompts the user whether to overwrite an existing template.
// Returns true if the user confirms overwrite, false otherwise.
func PromptTemplateOverwrite(templateName string) (bool, error) {
var choice string

Expand All @@ -523,6 +558,9 @@ func PromptTemplateOverwrite(templateName string) (bool, error) {
return choice == "yes", nil
}

// PromptTemplateLoadConfirm prompts the user to confirm loading a template.
// Shows the number of matched and unmatched agents.
// Returns true if the user confirms, false otherwise.
func PromptTemplateLoadConfirm(matchedCount, unmatchedCount int) (bool, error) {
var choice string

Expand All @@ -547,6 +585,8 @@ func PromptTemplateLoadConfirm(matchedCount, unmatchedCount int) (bool, error) {
return choice == "yes", nil
}

// PromptTemplateDeleteConfirm prompts the user to confirm deleting a template.
// Returns true if the user confirms, false otherwise.
func PromptTemplateDeleteConfirm(templateName string) (bool, error) {
var choice string

Expand All @@ -569,6 +609,8 @@ func PromptTemplateDeleteConfirm(templateName string) (bool, error) {
return choice == "yes", nil
}

// PromptTemplateContinueOrExit prompts the user to continue or exit after template operations.
// Returns true to continue, false to exit.
func PromptTemplateContinueOrExit() (bool, error) {
var choice string

Expand All @@ -591,12 +633,14 @@ func PromptTemplateContinueOrExit() (bool, error) {
return choice == ContinueChoice, nil
}

// FormatTemplateInspect formats a template for inspection display.
// Returns a formatted string showing template name, creation date, and all agent assignments.
func FormatTemplateInspect(template models.Template) string {
var builder strings.Builder

builder.WriteString(fmt.Sprintf("Template: %s\n", template.Name))
builder.WriteString(fmt.Sprintf("Created: %s\n", formatDate(template.CreatedAt)))
builder.WriteString(fmt.Sprintf("Agents: %d\n\n", len(template.Agents)))
fmt.Fprintf(&builder, "Template: %s\n", template.Name)
fmt.Fprintf(&builder, "Created: %s\n", formatDate(template.CreatedAt))
fmt.Fprintf(&builder, "Agents: %d\n\n", len(template.Agents))

agentNames := make([]string, 0, len(template.Agents))
for name := range template.Agents {
Expand All @@ -611,9 +655,9 @@ func FormatTemplateInspect(template models.Template) string {
if modeDisplay == "" {
modeDisplay = "all (default)"
}
builder.WriteString(fmt.Sprintf(" %s [%s]\n", name, sourceTag))
builder.WriteString(fmt.Sprintf(" Model: %s\n", assignment.Model))
builder.WriteString(fmt.Sprintf(" Mode: %s\n", modeDisplay))
fmt.Fprintf(&builder, " %s [%s]\n", name, sourceTag)
fmt.Fprintf(&builder, " Model: %s\n", assignment.Model)
fmt.Fprintf(&builder, " Mode: %s\n", modeDisplay)
}

return builder.String()
Expand Down
16 changes: 16 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ func isValidModelID(modelID string) bool {
return true
}

// LoadGlobalConfig loads the global opencode configuration file.
// Returns the configuration or an error if the file cannot be read or parsed.
func LoadGlobalConfig() (*models.OpencodeConfig, error) {
home, err := os.UserHomeDir()
if err != nil {
Expand All @@ -43,6 +45,8 @@ func LoadGlobalConfig() (*models.OpencodeConfig, error) {
return loadConfigFile(configPath)
}

// LoadProjectConfig loads the project-level opencode configuration file.
// Returns nil if the file does not exist, or an error if it cannot be parsed.
func LoadProjectConfig() (*models.OpencodeConfig, error) {
configPath := filepath.Join(".", "opencode.json")
if _, err := os.Stat(configPath); os.IsNotExist(err) {
Expand All @@ -65,6 +69,9 @@ func loadConfigFile(configPath string) (*models.OpencodeConfig, error) {
return &cfg, nil
}

// GetAgentsFromConfig extracts agents from an opencode configuration.
// Parameters location and format specify the source metadata for each agent.
// Returns a list of agents defined in the configuration.
func GetAgentsFromConfig(cfg *models.OpencodeConfig, location, format string) []models.Agent {
if cfg == nil || cfg.Agent == nil {
return nil
Expand All @@ -87,6 +94,8 @@ func GetAgentsFromConfig(cfg *models.OpencodeConfig, location, format string) []
return agentList
}

// GetAvailableModels extracts available models from an opencode configuration.
// Returns a list of model options with provider, ID, and display name.
func GetAvailableModels(cfg *models.OpencodeConfig) []models.ModelOption {
var options []models.ModelOption

Expand Down Expand Up @@ -116,6 +125,8 @@ func GetAvailableModels(cfg *models.OpencodeConfig) []models.ModelOption {
return options
}

// GetModelsFromCLI retrieves available models by calling the opencode CLI.
// Returns a list of model options or an error if the CLI command fails.
func GetModelsFromCLI() ([]models.ModelOption, error) {
cmd := exec.Command("opencode", "models")
output, err := cmd.Output()
Expand Down Expand Up @@ -145,6 +156,8 @@ func GetModelsFromCLI() ([]models.ModelOption, error) {
return options, nil
}

// GetGlobalConfigPath returns the path to the global opencode configuration file.
// Returns an error if the user home directory cannot be determined.
func GetGlobalConfigPath() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
Expand All @@ -153,10 +166,13 @@ func GetGlobalConfigPath() (string, error) {
return filepath.Join(home, ".config", "opencode", "opencode.json"), nil
}

// GetProjectConfigPath returns the path to the project-level opencode configuration file.
func GetProjectConfigPath() string {
return filepath.Join(".", "opencode.json")
}

// UpdateAgentInJSON updates a field for an agent in a JSON configuration file.
// Returns an error if the agent does not exist or the file cannot be updated.
func UpdateAgentInJSON(configPath, agentName, field, value string) error {
data, err := os.ReadFile(configPath)
if err != nil {
Expand Down
Loading
Loading