From 594ecadfddfb5a93c8b0a6f40c323cbefb67b0f5 Mon Sep 17 00:00:00 2001 From: apale7 <1092377056@qq.com> Date: Fri, 17 Apr 2026 19:27:53 +0800 Subject: [PATCH] fix(cli): align help with README --- .../task.json | 53 +++++++++++++ README.md | 2 + README_EN.md | 4 + internal/cli/alias.go | 68 +++++++++++++++++ internal/cli/cli_test.go | 69 +++++++++++++++++ internal/cli/doctor.go | 12 +++ internal/cli/opencode.go | 27 ++++++- internal/cli/provider.go | 74 +++++++++++++++++++ internal/cli/root.go | 29 +++++++- internal/cli/serve.go | 11 +++ 10 files changed, 345 insertions(+), 4 deletions(-) create mode 100644 .trellis/tasks/archive/2026-04/04-17-04-17-align-readme-cli-help/task.json diff --git a/.trellis/tasks/archive/2026-04/04-17-04-17-align-readme-cli-help/task.json b/.trellis/tasks/archive/2026-04/04-17-04-17-align-readme-cli-help/task.json new file mode 100644 index 0000000..10c2c5c --- /dev/null +++ b/.trellis/tasks/archive/2026-04/04-17-04-17-align-readme-cli-help/task.json @@ -0,0 +1,53 @@ +{ + "id": "04-17-align-readme-cli-help", + "name": "04-17-align-readme-cli-help", + "title": "Align README and CLI help", + "description": "", + "status": "completed", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "Apale", + "assignee": "Apale", + "createdAt": "2026-04-17", + "completedAt": "2026-04-17", + "branch": null, + "base_branch": "master", + "worktree_path": null, + "current_phase": 0, + "next_action": [ + { + "phase": 1, + "action": "brainstorm" + }, + { + "phase": 2, + "action": "research" + }, + { + "phase": 3, + "action": "implement" + }, + { + "phase": 4, + "action": "check" + }, + { + "phase": 5, + "action": "update-spec" + }, + { + "phase": 6, + "action": "record-session" + } + ], + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} diff --git a/README.md b/README.md index c087cde..56aedf1 100644 --- a/README.md +++ b/README.md @@ -357,6 +357,8 @@ olpx opencode sync --help olpx --config /path/to/config.json doctor ``` +命令级行为、默认值、写入范围与副作用,以对应命令的 `--help` 为准;README 主要保留快速上手与背景说明。 + 一个最小配置示例: ```json diff --git a/README_EN.md b/README_EN.md index 74fca93..76fce05 100644 --- a/README_EN.md +++ b/README_EN.md @@ -91,6 +91,10 @@ interactions when the same provider is shared across multiple aliases. ## CLI reference +For exact command behavior, defaults, write scope, and side effects, prefer the +matching `--help` page. This README is the quick-start narrative, while CLI help +is the authoritative local execution contract. + - `olpx serve` — run the proxy - `olpx doctor` — validate config - `olpx provider {add,list,enable,disable,remove,import-opencode}` diff --git a/internal/cli/alias.go b/internal/cli/alias.go index c60b9e4..f5ce428 100644 --- a/internal/cli/alias.go +++ b/internal/cli/alias.go @@ -14,6 +14,19 @@ 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/. + +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 @@ -25,6 +38,18 @@ func newAliasAddCmd() *cobra.Command { 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") @@ -63,6 +88,16 @@ 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 { @@ -107,6 +142,18 @@ func newAliasBindCmd() *cobra.Command { 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") @@ -144,6 +191,17 @@ func newAliasUnbindCmd() *cobra.Command { 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") @@ -173,6 +231,16 @@ func newAliasRemoveCmd() *cobra.Command { Use: "remove ", 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 { diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 0008cd5..9a76bc6 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -4,8 +4,11 @@ import ( "bytes" "os" "path/filepath" + "strings" "testing" + "github.com/spf13/cobra" + "github.com/anomalyco/opencode-provider-switch/internal/config" ) @@ -220,3 +223,69 @@ func TestOpencodeSyncDoesNotPanicOnSliceModelMetadata(t *testing.T) { t.Fatalf("sync rewrote unchanged config:\n%s", string(data)) } } + +func TestHelpTextIncludesOperationalGuidance(t *testing.T) { + tests := []struct { + name string + cmd *cobra.Command + wantLong []string + wantExample []string + }{ + { + name: "root", + cmd: NewRootCmd("test"), + wantLong: []string{"Typical workflow:", "prefer command-local --help over README summaries"}, + wantExample: []string{"olpx provider add", "olpx serve"}, + }, + { + name: "provider add", + cmd: newProviderAddCmd(), + wantLong: []string{"--base-url must point at an", "omitted mutable fields keep their current", "values: name, api key, headers"}, + wantExample: []string{"olpx provider add --id relay", "--header X-Workspace=my-team"}, + }, + { + name: "alias bind", + cmd: newAliasBindCmd(), + wantLong: []string{"auto-creates an enabled alias", "Order matters:"}, + wantExample: []string{"--provider codex --model GPT-5.4", "--disabled"}, + }, + { + name: "opencode sync", + cmd: newOpencodeSyncCmd(), + wantLong: []string{"does not follow", "writes alias", "exposure into provider.olpx.models"}, + wantExample: []string{"--dry-run", "--set-small-model olpx/gpt-5.4-mini"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.cmd.Long == "" { + t.Fatal("Long help is empty") + } + if tt.cmd.Example == "" { + t.Fatal("Example help is empty") + } + for _, want := range tt.wantLong { + if !strings.Contains(tt.cmd.Long, want) { + t.Fatalf("Long help missing %q\n%s", want, tt.cmd.Long) + } + } + for _, want := range tt.wantExample { + if !strings.Contains(tt.cmd.Example, want) { + t.Fatalf("Example help missing %q\n%s", want, tt.cmd.Example) + } + } + }) + } + + root := NewRootCmd("test") + flag := root.PersistentFlags().Lookup("config") + if flag == nil { + t.Fatal("--config flag not found") + } + for _, want := range []string{"$OLPX_CONFIG", "$XDG_CONFIG_HOME/olpx/config.json", "~/.config/olpx/config.json"} { + if !strings.Contains(flag.Usage, want) { + t.Fatalf("config flag usage missing %q: %s", want, flag.Usage) + } + } +} diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go index 75735e1..7f3adc0 100644 --- a/internal/cli/doctor.go +++ b/internal/cli/doctor.go @@ -12,6 +12,18 @@ func newDoctorCmd() *cobra.Command { return &cobra.Command{ Use: "doctor", Short: "Validate olpx config (static checks, no upstream requests)", + Long: `doctor performs static validation for local olpx config and the expected +OpenCode sync result. + +It loads local olpx config, checks alias/provider consistency, resolves the +default OpenCode sync target, and validates what provider.olpx would look like +there. It does not send any real requests to upstream providers and does not +consume model quota. + +Run doctor before opencode sync or serve whenever you changed providers, +aliases, or local server settings.`, + Example: ` olpx doctor + olpx --config /path/to/config.json doctor`, RunE: func(cmd *cobra.Command, args []string) error { cfg, err := loadCfg() if err != nil { diff --git a/internal/cli/opencode.go b/internal/cli/opencode.go index c282dfb..fded874 100644 --- a/internal/cli/opencode.go +++ b/internal/cli/opencode.go @@ -12,6 +12,17 @@ func newOpencodeCmd() *cobra.Command { c := &cobra.Command{ Use: "opencode", Short: "OpenCode integration commands", + Long: `OpenCode commands manage the narrow integration boundary between olpx and +OpenCode config. + +These commands do not attempt full OpenCode config takeover. They are limited to +the provider.olpx sync path and optional top-level model fields when you opt in +explicitly. + +Common workflow: validate with doctor first, inspect sync help, then run +opencode sync.`, + Example: ` olpx opencode sync --dry-run + olpx opencode sync --set-model olpx/gpt-5.4`, } c.AddCommand(newOpencodeSyncCmd()) return c @@ -30,7 +41,21 @@ func newOpencodeSyncCmd() *cobra.Command { By default it targets the global user config (~/.config/opencode), picking the existing file in precedence order opencode.jsonc > opencode.json > config.json, or creating opencode.jsonc if none exists. It does NOT touch the top-level -"model" or "small_model" unless --set-model / --set-small-model are given.`, +"model" or "small_model" unless --set-model / --set-small-model are given. + +The default target scope is only the global user config path; it does not follow +OPENCODE_CONFIG_DIR unless you pass --target yourself. The command writes alias +exposure into provider.olpx.models using only aliases that are currently +routable. + +Use --dry-run to preview the resolved target file without writing it. Typical +workflow: run olpx doctor first, then sync, then start or restart olpx serve if +needed.`, + Example: ` olpx opencode sync + olpx opencode sync --dry-run + olpx opencode sync --set-model olpx/gpt-5.4 + olpx opencode sync --set-model olpx/gpt-5.4 --set-small-model olpx/gpt-5.4-mini + olpx opencode sync --target /path/to/opencode.jsonc`, RunE: func(cmd *cobra.Command, args []string) error { cfg, err := loadCfg() if err != nil { diff --git a/internal/cli/provider.go b/internal/cli/provider.go index 2d5932a..9679e9c 100644 --- a/internal/cli/provider.go +++ b/internal/cli/provider.go @@ -15,6 +15,18 @@ func newProviderCmd() *cobra.Command { c := &cobra.Command{ Use: "provider", Short: "Manage upstream providers", + Long: `Provider commands manage upstream OpenAI-compatible endpoints stored in the +local olpx config file. + +Providers are separate from aliases: a provider defines connection details such +as base URL, API key, and extra headers, while aliases decide failover order by +binding one or more provider/model targets. + +Common workflow: add or import providers first, inspect them with provider list, +then bind them to aliases with olpx alias bind.`, + Example: ` olpx provider add --id su8 --base-url https://cn2.su8.codes/v1 --api-key sk-example + olpx provider import-opencode + olpx provider list`, } c.AddCommand( newProviderAddCmd(), @@ -34,6 +46,23 @@ func newProviderAddCmd() *cobra.Command { cmd := &cobra.Command{ Use: "add", Short: "Add or update an upstream provider", + Long: `provider add creates or updates one upstream provider entry in local olpx +config. + +It writes only the olpx config file. --base-url must point at an +OpenAI-compatible /v1 root. The command validates that shape, but it does not +contact the upstream or test credentials. + +When updating an existing provider, omitted mutable fields keep their current +values: name, api key, headers, and disabled state are preserved unless you +explicitly pass new values. Repeated --header KEY=VALUE entries replace the +stored header map for this command invocation. + +Typical next step: run olpx provider list or bind the provider to an alias.`, + Example: ` olpx provider add --id su8 --base-url https://cn2.su8.codes/v1 + olpx provider add --id su8 --base-url https://cn2.su8.codes/v1 --api-key sk-example + olpx provider add --id relay --base-url https://example.com/v1 --api-key sk-example --header X-Token=abc --header X-Workspace=my-team + olpx provider add --id su8 --base-url https://new.example.com/v1`, RunE: func(cmd *cobra.Command, args []string) error { if id == "" || baseURL == "" { return fmt.Errorf("--id and --base-url are required") @@ -111,6 +140,15 @@ func newProviderListCmd() *cobra.Command { return &cobra.Command{ Use: "list", Short: "List configured providers", + Long: `provider list prints the providers currently stored in local olpx config. + +Output is inspection-oriented: provider ids, enabled state, base URLs, and +redacted API keys are shown so you can confirm what was saved or imported before +binding aliases. + +This command does not modify config and does not contact upstream providers.`, + Example: ` olpx provider list + olpx --config /path/to/config.json provider list`, RunE: func(cmd *cobra.Command, args []string) error { cfg, err := loadCfg() if err != nil { @@ -155,6 +193,16 @@ func newProviderStateCmd(use string, disabled bool) *cobra.Command { Use: use + " ", Args: cobra.ExactArgs(1), Short: strings.Title(action[:len(action)-1]) + " a provider without changing alias target state", + Long: fmt.Sprintf(`provider %s flips one provider's disabled state in local olpx config. + +It changes routing eligibility for every alias target that references this +provider, but it does not rewrite alias target enabled flags. This matters when +the same provider is shared across multiple aliases. + +This command writes only the olpx config file and does not test upstream +reachability. Typical next step: run olpx doctor to confirm routable aliases.`, use), + Example: fmt.Sprintf(` olpx provider %s + olpx doctor`, use), RunE: func(cmd *cobra.Command, args []string) error { cfg, err := loadCfg() if err != nil { @@ -181,6 +229,16 @@ func newProviderRemoveCmd() *cobra.Command { Use: "remove ", Args: cobra.ExactArgs(1), Short: "Remove a provider (targets referencing it must be removed first or will fail doctor)", + Long: `provider remove deletes one provider from local olpx config. + +It does not automatically clean alias target references that still point at the +removed provider. If aliases still reference it, doctor will report invalid +config and those aliases will not be routable. + +Typical follow-up: inspect aliases, unbind stale targets, then run olpx doctor.`, + Example: ` olpx provider remove su8 + olpx alias list + olpx doctor`, RunE: func(cmd *cobra.Command, args []string) error { cfg, err := loadCfg() if err != nil { @@ -204,6 +262,22 @@ func newProviderImportCmd() *cobra.Command { cmd := &cobra.Command{ Use: "import-opencode", Short: "Import @ai-sdk/openai custom providers from an OpenCode config file", + Long: `provider import-opencode reads an OpenCode config file and copies supported +custom providers into local olpx config. + +By default it reads the global user OpenCode config resolved in precedence order +opencode.jsonc > opencode.json > config.json under ~/.config/opencode (XDG +aware). It does not follow OPENCODE_CONFIG_DIR for this default source; use +--from when you want a different file. + +Only config-defined @ai-sdk/openai custom providers with both baseURL and apiKey +are imported. Unsupported provider shapes are skipped by design. Existing olpx +providers are skipped unless --overwrite is given. + +Typical next step: run olpx provider list, then create aliases and bindings.`, + Example: ` olpx provider import-opencode + olpx provider import-opencode --from /path/to/opencode.jsonc + olpx provider import-opencode --overwrite`, RunE: func(cmd *cobra.Command, args []string) error { if srcPath == "" { p, existed := opencode.ResolveGlobalConfigPath() diff --git a/internal/cli/root.go b/internal/cli/root.go index b748c4c..a090149 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -21,13 +21,36 @@ func loadCfg() (*config.Config, error) { // NewRootCmd builds the root olpx command. func NewRootCmd(version string) *cobra.Command { root := &cobra.Command{ - Use: "olpx", - Short: "OpenCode LocalProxy CLI: local alias + failover proxy for OpenCode", + Use: "olpx", + Short: "OpenCode LocalProxy CLI: local alias + failover proxy for OpenCode", + Long: `olpx is a local OpenCode proxy that exposes stable aliases as olpx/ +while routing each alias to one or more upstream provider/model targets. + +Typical workflow: +1. add or import upstream providers into local olpx config +2. create aliases and bind ordered targets +3. run doctor for static validation +4. run opencode sync to write provider.olpx into OpenCode config +5. run serve to accept local /v1/responses traffic + +olpx writes only its own local config unless a command explicitly says it also +writes OpenCode config. For exact command behavior, defaults, and side effects, +prefer command-local --help over README summaries.`, + Example: ` olpx provider add --id su8 --base-url https://cn2.su8.codes/v1 --api-key sk-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 doctor + olpx opencode sync + olpx serve + + olpx provider import-opencode + olpx provider list + olpx opencode sync --dry-run`, SilenceUsage: true, SilenceErrors: false, Version: version, } - root.PersistentFlags().StringVar(&configPath, "config", "", "path to olpx config.json (default: $XDG_CONFIG_HOME/olpx/config.json)") + root.PersistentFlags().StringVar(&configPath, "config", "", "path to olpx config.json (default: $OLPX_CONFIG, else $XDG_CONFIG_HOME/olpx/config.json, else ~/.config/olpx/config.json)") root.AddCommand(newServeCmd()) root.AddCommand(newDoctorCmd()) diff --git a/internal/cli/serve.go b/internal/cli/serve.go index 2dfba1e..1529754 100644 --- a/internal/cli/serve.go +++ b/internal/cli/serve.go @@ -13,6 +13,17 @@ func newServeCmd() *cobra.Command { return &cobra.Command{ Use: "serve", Short: "Run the local olpx proxy (alias -> failover upstream)", + Long: `serve starts the long-running local olpx proxy using the current local config. + +It reads local alias/provider configuration, validates that config, and then +accepts OpenAI Responses traffic at the configured local base URL. With default +settings the proxy listens on http://127.0.0.1:9982/v1 and expects the local API +key olpx-local. + +serve does not rewrite config files. Run doctor and opencode sync first so +OpenCode can see the same aliases that the proxy can route.`, + Example: ` olpx serve + olpx --config /path/to/config.json serve`, RunE: func(cmd *cobra.Command, args []string) error { cfg, err := loadCfg() if err != nil {