opencode-provider-switch/internal/cli/alias.go

263 lines
8.7 KiB
Go

package cli
import (
"fmt"
"sort"
"strings"
"github.com/spf13/cobra"
"github.com/anomalyco/opencode-provider-switch/internal/config"
)
func newAliasCmd() *cobra.Command {
c := &cobra.Command{
Use: "alias",
Short: "Manage logical aliases routed by olpx",
Long: `Alias commands manage the user-facing model names that OpenCode sees as
olpx/<alias>.
Each alias contains an ordered target chain of provider/model pairs. Target
order is operational: olpx tries targets in order and only fails over before any
response bytes are sent downstream.
Common workflow: create an alias, bind primary and fallback targets, inspect the
result with alias list, then run doctor and opencode sync.`,
Example: ` olpx alias add --name gpt-5.4 --display-name "GPT 5.4"
olpx alias bind --alias gpt-5.4 --provider su8 --model gpt-5.4
olpx alias bind --alias gpt-5.4 --provider codex --model GPT-5.4
olpx alias list`,
}
c.AddCommand(newAliasAddCmd(), newAliasListCmd(), newAliasBindCmd(), newAliasUnbindCmd(), newAliasRemoveCmd())
return c
}
func newAliasAddCmd() *cobra.Command {
var name, display string
var disabled bool
cmd := &cobra.Command{
Use: "add",
Short: "Create or update an alias (without targets)",
Long: `alias add creates or updates alias metadata in local olpx config.
It writes the alias record itself, but it does not add or validate targets.
Enabled aliases still need at least one routable target before doctor and
opencode sync will treat them as usable.
When updating an existing alias, omitted display-name preserves the current
value and existing targets stay attached. Typical next step: add targets with
olpx alias bind.`,
Example: ` olpx alias add --name gpt-5.4 --display-name "GPT 5.4"
olpx alias add --name gpt-5.4-mini --disabled
olpx alias add --name gpt-5.4 --display-name "GPT 5.4 Reasoning"`,
RunE: func(cmd *cobra.Command, args []string) error {
if name == "" {
return fmt.Errorf("--name is required")
}
cfg, err := loadCfg()
if err != nil {
return err
}
existing := cfg.FindAlias(name)
a := config.Alias{Alias: name, DisplayName: display, Enabled: !disabled}
if existing != nil {
if display == "" {
a.DisplayName = existing.DisplayName
}
a.Enabled = existing.Enabled
if disabled {
a.Enabled = false
}
a.Targets = existing.Targets
}
cfg.UpsertAlias(a)
if err := cfg.Save(); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "saved alias %q (enabled=%v)\n", name, a.Enabled)
return nil
},
}
cmd.Flags().StringVar(&name, "name", "", "alias name exposed as olpx/<name> in OpenCode (required)")
cmd.Flags().StringVar(&display, "display-name", "", "human-friendly display name")
cmd.Flags().BoolVar(&disabled, "disabled", false, "create in disabled state")
return cmd
}
func newAliasListCmd() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List aliases and their target chains",
Long: `alias list prints aliases from local olpx config together with their target
chains.
Output shows alias enabled state, target order, target enabled markers, and a
note when a referenced provider is missing or disabled. This is the easiest way
to verify failover order before running doctor or opencode sync.
This command does not modify config and does not contact upstream providers.`,
Example: ` olpx alias list
olpx --config /path/to/config.json alias list`,
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadCfg()
if err != nil {
return err
}
aliases := append([]config.Alias(nil), cfg.Aliases...)
sort.Slice(aliases, func(i, j int) bool { return aliases[i].Alias < aliases[j].Alias })
if len(aliases) == 0 {
fmt.Fprintln(cmd.OutOrStdout(), "(no aliases)")
return nil
}
for _, a := range aliases {
state := "enabled"
if !a.Enabled {
state = "disabled"
}
fmt.Fprintf(cmd.OutOrStdout(), "%s [%s]\n", a.Alias, state)
for i, t := range a.Targets {
mark := "x"
if !t.Enabled {
mark = " "
}
note := ""
provider := cfg.FindProvider(t.Provider)
switch {
case provider == nil:
note = " (missing provider)"
case !provider.IsEnabled():
note = " (provider disabled)"
}
fmt.Fprintf(cmd.OutOrStdout(), " [%s] %d. %s/%s%s\n", mark, i+1, t.Provider, t.Model, note)
}
}
return nil
},
}
}
func newAliasBindCmd() *cobra.Command {
var alias, provider, model string
var disabled bool
cmd := &cobra.Command{
Use: "bind",
Short: "Append a target (provider/model) to an alias in failover order",
Long: `alias bind appends one provider/model target to an alias's ordered failover
chain in local olpx config.
The provider must already exist. If the alias does not exist yet, this command
auto-creates an enabled alias for convenience. Binding does not test upstream
health or credentials.
Order matters: the first bound target is tried first, the second is fallback,
and so on. Typical next step: inspect with alias list, then run doctor.`,
Example: ` olpx alias bind --alias gpt-5.4 --provider su8 --model gpt-5.4
olpx alias bind --alias gpt-5.4 --provider codex --model GPT-5.4
olpx alias bind --alias gpt-5.4 --provider relay --model gpt-5.4 --disabled`,
RunE: func(cmd *cobra.Command, args []string) error {
if alias == "" || provider == "" || model == "" {
return fmt.Errorf("--alias, --provider and --model are required")
}
cfg, err := loadCfg()
if err != nil {
return err
}
if cfg.FindProvider(provider) == nil {
return fmt.Errorf("provider %q does not exist; add it first", provider)
}
if cfg.FindAlias(alias) == nil {
// auto-create enabled alias for ergonomics
cfg.UpsertAlias(config.Alias{Alias: alias, Enabled: true})
}
if err := cfg.AddTarget(alias, config.Target{Provider: provider, Model: model, Enabled: !disabled}); err != nil {
return err
}
if err := cfg.Save(); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "bound %s → %s/%s\n", alias, provider, model)
return nil
},
}
cmd.Flags().StringVar(&alias, "alias", "", "alias name (required)")
cmd.Flags().StringVar(&provider, "provider", "", "upstream provider id (required)")
cmd.Flags().StringVar(&model, "model", "", "upstream model id (required)")
cmd.Flags().BoolVar(&disabled, "disabled", false, "add target in disabled state")
return cmd
}
func newAliasUnbindCmd() *cobra.Command {
var alias, provider, model string
cmd := &cobra.Command{
Use: "unbind",
Short: "Remove a target from an alias",
Long: `alias unbind removes one concrete provider/model target tuple from an alias in
local olpx config.
It does not delete the alias itself. Removing a target can leave the alias with
no routable targets, which doctor and opencode sync will then treat as invalid
or unavailable.
Typical next step: run alias list or doctor to confirm the remaining target
chain.`,
Example: ` olpx alias unbind --alias gpt-5.4 --provider codex --model GPT-5.4
olpx doctor`,
RunE: func(cmd *cobra.Command, args []string) error {
if alias == "" || provider == "" || model == "" {
return fmt.Errorf("--alias, --provider and --model are required")
}
cfg, err := loadCfg()
if err != nil {
return err
}
if err := cfg.RemoveTarget(alias, provider, model); err != nil {
return err
}
if err := cfg.Save(); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "unbound %s → %s/%s\n", alias, provider, model)
return nil
},
}
cmd.Flags().StringVar(&alias, "alias", "", "alias name (required)")
cmd.Flags().StringVar(&provider, "provider", "", "upstream provider id (required)")
cmd.Flags().StringVar(&model, "model", "", "upstream model id (required)")
return cmd
}
func newAliasRemoveCmd() *cobra.Command {
return &cobra.Command{
Use: "remove <alias>",
Args: cobra.ExactArgs(1),
Short: "Delete an alias entirely",
Long: `alias remove deletes one alias and all of its target bindings from local olpx
config.
Future opencode sync runs will stop exposing that alias in provider.olpx.models.
This command does not directly clear top-level model selections that may still
reference the old alias in OpenCode config.
Typical next step: run olpx opencode sync if OpenCode exposure should be updated.`,
Example: ` olpx alias remove gpt-5.4
olpx opencode sync`,
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadCfg()
if err != nil {
return err
}
if !cfg.RemoveAlias(args[0]) {
return fmt.Errorf("alias %q not found", args[0])
}
if err := cfg.Save(); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "removed alias %q\n", args[0])
return nil
},
}
}
// unused helper kept for clarity if future commands want to normalize lists
var _ = strings.TrimSpace