fix(cli): align help with README

This commit is contained in:
apale7 2026-04-17 19:27:53 +08:00
parent b5776e4e1f
commit 594ecadfdd
10 changed files with 345 additions and 4 deletions

View File

@ -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": {}
}

View File

@ -357,6 +357,8 @@ olpx opencode sync --help
olpx --config /path/to/config.json doctor olpx --config /path/to/config.json doctor
``` ```
命令级行为、默认值、写入范围与副作用,以对应命令的 `--help` 为准README 主要保留快速上手与背景说明。
一个最小配置示例: 一个最小配置示例:
```json ```json

View File

@ -91,6 +91,10 @@ interactions when the same provider is shared across multiple aliases.
## CLI reference ## 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 serve` — run the proxy
- `olpx doctor` — validate config - `olpx doctor` — validate config
- `olpx provider {add,list,enable,disable,remove,import-opencode}` - `olpx provider {add,list,enable,disable,remove,import-opencode}`

View File

@ -14,6 +14,19 @@ func newAliasCmd() *cobra.Command {
c := &cobra.Command{ c := &cobra.Command{
Use: "alias", Use: "alias",
Short: "Manage logical aliases routed by olpx", 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()) c.AddCommand(newAliasAddCmd(), newAliasListCmd(), newAliasBindCmd(), newAliasUnbindCmd(), newAliasRemoveCmd())
return c return c
@ -25,6 +38,18 @@ func newAliasAddCmd() *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "add", Use: "add",
Short: "Create or update an alias (without targets)", 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 { RunE: func(cmd *cobra.Command, args []string) error {
if name == "" { if name == "" {
return fmt.Errorf("--name is required") return fmt.Errorf("--name is required")
@ -63,6 +88,16 @@ func newAliasListCmd() *cobra.Command {
return &cobra.Command{ return &cobra.Command{
Use: "list", Use: "list",
Short: "List aliases and their target chains", 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 { RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadCfg() cfg, err := loadCfg()
if err != nil { if err != nil {
@ -107,6 +142,18 @@ func newAliasBindCmd() *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "bind", Use: "bind",
Short: "Append a target (provider/model) to an alias in failover order", 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 { RunE: func(cmd *cobra.Command, args []string) error {
if alias == "" || provider == "" || model == "" { if alias == "" || provider == "" || model == "" {
return fmt.Errorf("--alias, --provider and --model are required") return fmt.Errorf("--alias, --provider and --model are required")
@ -144,6 +191,17 @@ func newAliasUnbindCmd() *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "unbind", Use: "unbind",
Short: "Remove a target from an alias", 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 { RunE: func(cmd *cobra.Command, args []string) error {
if alias == "" || provider == "" || model == "" { if alias == "" || provider == "" || model == "" {
return fmt.Errorf("--alias, --provider and --model are required") return fmt.Errorf("--alias, --provider and --model are required")
@ -173,6 +231,16 @@ func newAliasRemoveCmd() *cobra.Command {
Use: "remove <alias>", Use: "remove <alias>",
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
Short: "Delete an alias entirely", 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 { RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadCfg() cfg, err := loadCfg()
if err != nil { if err != nil {

View File

@ -4,8 +4,11 @@ import (
"bytes" "bytes"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"github.com/spf13/cobra"
"github.com/anomalyco/opencode-provider-switch/internal/config" "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)) 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)
}
}
}

View File

@ -12,6 +12,18 @@ func newDoctorCmd() *cobra.Command {
return &cobra.Command{ return &cobra.Command{
Use: "doctor", Use: "doctor",
Short: "Validate olpx config (static checks, no upstream requests)", 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 { RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadCfg() cfg, err := loadCfg()
if err != nil { if err != nil {

View File

@ -12,6 +12,17 @@ func newOpencodeCmd() *cobra.Command {
c := &cobra.Command{ c := &cobra.Command{
Use: "opencode", Use: "opencode",
Short: "OpenCode integration commands", 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()) c.AddCommand(newOpencodeSyncCmd())
return c return c
@ -30,7 +41,21 @@ func newOpencodeSyncCmd() *cobra.Command {
By default it targets the global user config (~/.config/opencode), picking the By default it targets the global user config (~/.config/opencode), picking the
existing file in precedence order opencode.jsonc > opencode.json > config.json, 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 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 { RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadCfg() cfg, err := loadCfg()
if err != nil { if err != nil {

View File

@ -15,6 +15,18 @@ func newProviderCmd() *cobra.Command {
c := &cobra.Command{ c := &cobra.Command{
Use: "provider", Use: "provider",
Short: "Manage upstream providers", 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( c.AddCommand(
newProviderAddCmd(), newProviderAddCmd(),
@ -34,6 +46,23 @@ func newProviderAddCmd() *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "add", Use: "add",
Short: "Add or update an upstream provider", 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 { RunE: func(cmd *cobra.Command, args []string) error {
if id == "" || baseURL == "" { if id == "" || baseURL == "" {
return fmt.Errorf("--id and --base-url are required") return fmt.Errorf("--id and --base-url are required")
@ -111,6 +140,15 @@ func newProviderListCmd() *cobra.Command {
return &cobra.Command{ return &cobra.Command{
Use: "list", Use: "list",
Short: "List configured providers", 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 { RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadCfg() cfg, err := loadCfg()
if err != nil { if err != nil {
@ -155,6 +193,16 @@ func newProviderStateCmd(use string, disabled bool) *cobra.Command {
Use: use + " <id>", Use: use + " <id>",
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
Short: strings.Title(action[:len(action)-1]) + " a provider without changing alias target state", 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 <id>
olpx doctor`, use),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadCfg() cfg, err := loadCfg()
if err != nil { if err != nil {
@ -181,6 +229,16 @@ func newProviderRemoveCmd() *cobra.Command {
Use: "remove <id>", Use: "remove <id>",
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
Short: "Remove a provider (targets referencing it must be removed first or will fail doctor)", 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 { RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadCfg() cfg, err := loadCfg()
if err != nil { if err != nil {
@ -204,6 +262,22 @@ func newProviderImportCmd() *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "import-opencode", Use: "import-opencode",
Short: "Import @ai-sdk/openai custom providers from an OpenCode config file", 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 { RunE: func(cmd *cobra.Command, args []string) error {
if srcPath == "" { if srcPath == "" {
p, existed := opencode.ResolveGlobalConfigPath() p, existed := opencode.ResolveGlobalConfigPath()

View File

@ -21,13 +21,36 @@ func loadCfg() (*config.Config, error) {
// NewRootCmd builds the root olpx command. // NewRootCmd builds the root olpx command.
func NewRootCmd(version string) *cobra.Command { func NewRootCmd(version string) *cobra.Command {
root := &cobra.Command{ root := &cobra.Command{
Use: "olpx", Use: "olpx",
Short: "OpenCode LocalProxy CLI: local alias + failover proxy for OpenCode", Short: "OpenCode LocalProxy CLI: local alias + failover proxy for OpenCode",
Long: `olpx is a local OpenCode proxy that exposes stable aliases as olpx/<alias>
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, SilenceUsage: true,
SilenceErrors: false, SilenceErrors: false,
Version: version, 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(newServeCmd())
root.AddCommand(newDoctorCmd()) root.AddCommand(newDoctorCmd())

View File

@ -13,6 +13,17 @@ func newServeCmd() *cobra.Command {
return &cobra.Command{ return &cobra.Command{
Use: "serve", Use: "serve",
Short: "Run the local olpx proxy (alias -> failover upstream)", 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 { RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadCfg() cfg, err := loadCfg()
if err != nil { if err != nil {