diff --git a/README.md b/README.md index abb0fda..fb22ce8 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,21 @@ go install github.com/jacobcxdev/cq/cmd/cq@latest ## Usage +Start with help if you are new to cq: + +```bash +cq --help # Show every command family +cq check --help # Provider quota checks +cq claude --help # Claude account commands +cq codex --help # Codex account commands +cq proxy --help # Local proxy commands +cq models --help # Model registry commands +cq agent --help # Background refresh agent commands +cq refresh --help # One-shot token refresh +``` + +Every command path has its own help text, including leaves such as `cq proxy pin --help`, `cq models overlay add --help`, and `cq claude login --help`. + ```bash cq # Check all providers cq check claude codex # Check specific providers @@ -26,6 +41,19 @@ cq --version # Print version `check` accepts `claude`, `codex`, and `gemini` provider names. +Common command families: + +| Command | Purpose | +|---------|---------| +| `cq` / `cq check` | Fetch quota usage for all or selected providers. | +| `cq claude ...` | Add, list, switch, or remove Claude accounts. | +| `cq codex ...` | Add, list, switch, or remove Codex accounts. | +| `cq gemini accounts` | Show Gemini credential discovery state. | +| `cq refresh` | Refresh stored OAuth tokens before they expire. | +| `cq agent ...` | Install or uninstall background quota refresh. | +| `cq proxy ...` | Run, inspect, install, or configure the local proxy. | +| `cq models ...` | Refresh, list, or override local model registry entries. | + ### JSON availability `cq --json` includes a provider-level `availability` object for agents and other automated consumers. Use `availability.state` and `availability.guidance` to decide whether to send new work to a provider: diff --git a/cmd/cq/help.go b/cmd/cq/help.go new file mode 100644 index 0000000..e7abc99 --- /dev/null +++ b/cmd/cq/help.go @@ -0,0 +1,168 @@ +package main + +import ( + "fmt" + "io" + "strings" +) + +var manualHelpByPath = map[string]string{ + "refresh": `Usage: cq refresh + +Refresh stored OAuth tokens for Claude and Codex before they expire. + +Use this before long sessions or from the background agent. Expired Claude +accounts may require interactive reauth from a terminal. +`, + "agent": `Usage: cq agent + +Manage the background quota refresh LaunchAgent. + +Commands: + agent install Install background refresh agent + agent uninstall Uninstall background refresh agent +`, + "agent install": `Usage: cq agent install + +Install the background quota refresh LaunchAgent. + +The agent periodically runs token refresh work so provider checks and proxy +routing are less likely to start from expired credentials. +`, + "agent uninstall": `Usage: cq agent uninstall + +Uninstall the background quota refresh LaunchAgent. +`, + "proxy": `Usage: cq proxy + +Run and configure the local Claude and Codex API proxy. + +Commands: + proxy start Start local Claude and Codex proxy + proxy status Show proxy health + proxy install Install proxy launch agent + proxy uninstall Uninstall proxy launch agent + proxy restart Restart proxy launch agent + proxy pin Pin Claude proxy routing +`, + "proxy start": `Usage: cq proxy start [--port PORT] + +Start local Claude and Codex proxy. + +Options: + --port PORT Override configured listen port for this run +`, + "proxy status": `Usage: cq proxy status [--port PORT] + +Show proxy health as JSON. + +Options: + --port PORT Override configured health-check port +`, + "proxy install": `Usage: cq proxy install + +Install the proxy launch agent for the current user. +`, + "proxy uninstall": `Usage: cq proxy uninstall + +Uninstall the proxy launch agent for the current user. +`, + "proxy restart": `Usage: cq proxy restart + +Restart the proxy launch agent for the current user. +`, + "proxy pin": `Usage: cq proxy pin [--clear | ] + +Pin Claude proxy routing to a specific account, show the current pin, or clear it. + +Examples: + cq proxy pin + cq proxy pin user@example.com + cq proxy pin 550e8400-e29b-41d4-a716-446655440000 + cq proxy pin --clear +`, + "models": `Usage: cq models + +Manage the local model registry used by the proxy, Claude Code model caches, +and Codex model cache integration. + +Commands: + models list List active registry models + models refresh Refresh registry data and publish caches + models overlay Manage user model overlays +`, + "models list": `Usage: cq models list [--json] [--provider PROVIDER] + +List active registry models. + +Options: + --json Output JSON + --provider PROVIDER Filter by provider: anthropic or codex +`, + "models refresh": `Usage: cq models refresh + +Refresh registry data and publish provider-specific caches. + +This updates Codex model cache, Claude Code model capabilities, and Claude Code +picker options where those files are available. +`, + "models overlay": `Usage: cq models overlay + +Manage user model overlays. + +Overlays let cq expose model IDs before providers publish them natively. + +Commands: + overlay add Add or update user model overlay + overlay remove Remove user model overlay + overlay prune Remove overlays now supplied natively +`, + "models overlay add": `Usage: cq models overlay add --provider PROVIDER --id MODEL [--clone-from MODEL] + +Add or update user model overlay. + +Options: + --provider PROVIDER Provider to publish under: anthropic or codex + --id MODEL Model ID to add + --clone-from MODEL Native model ID to copy metadata from +`, + "models overlay remove": `Usage: cq models overlay remove --provider PROVIDER --id MODEL + +Remove user model overlay. + +Options: + --provider PROVIDER Provider to remove from: anthropic or codex + --id MODEL Model ID to remove +`, + "models overlay prune": `Usage: cq models overlay prune + +Remove overlays now supplied natively by refreshed provider registries. +`, +} + +func manualHelp(path []string) (string, bool) { + help, ok := manualHelpByPath[strings.Join(path, " ")] + return help, ok +} + +func writeManualHelp(w io.Writer, path []string) error { + help, ok := manualHelp(path) + if !ok { + return fmt.Errorf("no help for command path: %s", strings.Join(path, " ")) + } + _, err := fmt.Fprint(w, help) + return err +} + +func helpRequested(args []string) bool { + for _, arg := range args { + if isHelpToken(arg) { + return true + } + } + return false +} + +func isHelpToken(arg string) bool { + return arg == "--help" || arg == "-h" || arg == "help" +} diff --git a/cmd/cq/help_test.go b/cmd/cq/help_test.go new file mode 100644 index 0000000..d1a3529 --- /dev/null +++ b/cmd/cq/help_test.go @@ -0,0 +1,130 @@ +package main + +import ( + "bytes" + "io" + "strings" + "testing" + + "github.com/alecthomas/kong" +) + +func TestRootHelpShowsFullCLISurface(t *testing.T) { + out := &bytes.Buffer{} + var cli CLI + kctx, err := kong.New(&cli, + append(cliKongOptions(), kong.Writers(out, io.Discard), kong.Exit(func(int) {}))..., + ) + if err != nil { + t.Fatalf("kong.New: %v", err) + } + + _, _ = kctx.Parse([]string{"--help"}) + help := out.String() + for _, want := range []string{ + "check [ ...]", + "claude login", + "codex login", + "gemini accounts", + "refresh", + "agent install", + "agent uninstall", + "proxy start", + "proxy install", + "proxy uninstall", + "proxy restart", + "proxy status", + "proxy pin", + "models list", + "models refresh", + "models overlay add", + "models overlay remove", + "models overlay prune", + } { + if !strings.Contains(help, want) { + t.Fatalf("root help missing %q:\n%s", want, help) + } + } +} + +func TestManualHelpTextDocumentsEachCommandPath(t *testing.T) { + for _, tt := range []struct { + name string + path []string + want []string + }{ + { + name: "refresh", + path: []string{"refresh"}, + want: []string{"Usage: cq refresh", "Refresh stored OAuth tokens"}, + }, + { + name: "agent", + path: []string{"agent"}, + want: []string{"Usage: cq agent ", "agent install", "agent uninstall"}, + }, + { + name: "proxy start", + path: []string{"proxy", "start"}, + want: []string{"Usage: cq proxy start [--port PORT]", "Start local Claude and Codex proxy"}, + }, + { + name: "proxy pin", + path: []string{"proxy", "pin"}, + want: []string{"Usage: cq proxy pin [--clear | ]", "Pin Claude proxy routing"}, + }, + { + name: "models list", + path: []string{"models", "list"}, + want: []string{"Usage: cq models list [--json] [--provider PROVIDER]", "List active registry models"}, + }, + { + name: "models overlay add", + path: []string{"models", "overlay", "add"}, + want: []string{"Usage: cq models overlay add --provider PROVIDER --id MODEL [--clone-from MODEL]", "Add or update user model overlay"}, + }, + { + name: "models overlay remove", + path: []string{"models", "overlay", "remove"}, + want: []string{"Usage: cq models overlay remove --provider PROVIDER --id MODEL", "Remove user model overlay"}, + }, + } { + t.Run(tt.name, func(t *testing.T) { + help, ok := manualHelp(tt.path) + if !ok { + t.Fatalf("manualHelp(%v) missing entry", tt.path) + } + for _, want := range tt.want { + if !strings.Contains(help, want) { + t.Fatalf("manualHelp(%v) missing %q:\n%s", tt.path, want, help) + } + } + }) + } +} + +func TestRunModelsHelpDoesNotRefresh(t *testing.T) { + _, stdout, _, deps := testModelsDeps() + refreshCalls := 0 + deps.Refresh = func() error { + refreshCalls++ + return nil + } + + for _, args := range [][]string{ + {"--help"}, + {"list", "--help"}, + {"overlay", "add", "--help"}, + } { + stdout.Reset() + if err := runModels(args, deps); err != nil { + t.Fatalf("runModels(%v): %v", args, err) + } + if refreshCalls != 0 { + t.Fatalf("runModels(%v) called Refresh %d time(s), want 0", args, refreshCalls) + } + if !strings.Contains(stdout.String(), "Usage: cq models") { + t.Fatalf("runModels(%v) did not print models help:\n%s", args, stdout.String()) + } + } +} diff --git a/cmd/cq/main.go b/cmd/cq/main.go index cdf31f0..9ae5056 100644 --- a/cmd/cq/main.go +++ b/cmd/cq/main.go @@ -27,10 +27,14 @@ type CLI struct { Refresh bool `help:"Bypass cache" short:"r"` Version kong.VersionFlag `help:"Print version" short:"v"` - Check CheckCmd `cmd:"" default:"withargs" help:"Check quota usage"` - Claude ClaudeCmd `cmd:"" help:"Claude account management"` - Codex CodexCmd `cmd:"" help:"Codex account management"` - Gemini GeminiCmd `cmd:"" help:"Gemini account management"` + Check CheckCmd `cmd:"" default:"withargs" help:"Check provider quota usage"` + Claude ClaudeCmd `cmd:"" help:"Manage Claude accounts"` + Codex CodexCmd `cmd:"" help:"Manage Codex accounts"` + Gemini GeminiCmd `cmd:"" help:"Show Gemini account configuration"` + RefreshFn RefreshCmd `cmd:"" name:"refresh" help:"Refresh stored OAuth tokens"` + Agent AgentCmd `cmd:"" help:"Manage background quota refresh agent"` + Proxy ProxyCmd `cmd:"" help:"Run and configure local API proxy"` + Models ModelsCmd `cmd:"" help:"Manage local model registry"` } // CheckCmd is the default command that checks provider quota usage. @@ -59,6 +63,77 @@ type GeminiCmd struct { Accounts AccountsCmd `cmd:"" help:"Show Gemini account"` } +// RefreshCmd refreshes stored provider tokens. +type RefreshCmd struct{} + +// AgentCmd groups background refresh agent commands. +type AgentCmd struct { + Install AgentInstallCmd `cmd:"" help:"Install background refresh agent"` + Uninstall AgentUninstallCmd `cmd:"" help:"Uninstall background refresh agent"` +} + +type AgentInstallCmd struct{} +type AgentUninstallCmd struct{} + +// ProxyCmd groups local proxy commands. +type ProxyCmd struct { + Start ProxyStartCmd `cmd:"" help:"Start local Claude and Codex proxy"` + Install ProxyInstallCmd `cmd:"" help:"Install proxy launch agent"` + Uninstall ProxyUninstallCmd `cmd:"" help:"Uninstall proxy launch agent"` + Restart ProxyRestartCmd `cmd:"" help:"Restart proxy launch agent"` + Status ProxyStatusCmd `cmd:"" help:"Show proxy health"` + Pin ProxyPinCmd `cmd:"" help:"Pin Claude proxy routing"` +} + +type ProxyStartCmd struct { + Port int `help:"Override listen port" placeholder:"PORT"` +} + +type ProxyInstallCmd struct{} +type ProxyUninstallCmd struct{} +type ProxyRestartCmd struct{} + +type ProxyStatusCmd struct { + Port int `help:"Override health-check port" placeholder:"PORT"` +} + +type ProxyPinCmd struct { + Clear bool `help:"Clear active Claude account pin"` + Value string `arg:"" optional:"" name:"email-or-account-uuid" help:"Claude account email or UUID to pin"` +} + +// ModelsCmd groups local model registry commands. +type ModelsCmd struct { + List ModelsListCmd `cmd:"" help:"List active registry models"` + Refresh ModelsRefreshCmd `cmd:"" help:"Refresh registry data and publish caches"` + Overlay ModelsOverlayCmd `cmd:"" help:"Manage user model overlays"` +} + +type ModelsListCmd struct { + Provider string `help:"Filter by provider: anthropic or codex" placeholder:"PROVIDER"` +} + +type ModelsRefreshCmd struct{} + +type ModelsOverlayCmd struct { + Add ModelsOverlayAddCmd `cmd:"" help:"Add or update user model overlay"` + Remove ModelsOverlayRemoveCmd `cmd:"" help:"Remove user model overlay"` + Prune ModelsOverlayPruneCmd `cmd:"" help:"Remove overlays now supplied natively"` +} + +type ModelsOverlayAddCmd struct { + Provider string `help:"Provider: anthropic or codex" placeholder:"PROVIDER"` + ID string `help:"Model ID to add" placeholder:"MODEL"` + CloneFrom string `help:"Native model ID to copy metadata from" placeholder:"MODEL"` +} + +type ModelsOverlayRemoveCmd struct { + Provider string `help:"Provider: anthropic or codex" placeholder:"PROVIDER"` + ID string `help:"Model ID to remove" placeholder:"MODEL"` +} + +type ModelsOverlayPruneCmd struct{} + // LoginCmd adds a new account via OAuth. type LoginCmd struct { Activate bool `help:"Set as active account after login"` @@ -80,6 +155,15 @@ type RemoveCmd struct { // version is set at build time via -ldflags. Falls back to "dev". var version = "dev" +func cliKongOptions() []kong.Option { + return []kong.Option{ + kong.Name("cq"), + kong.Description("Check AI provider usage limits"), + kong.UsageOnError(), + kong.Vars{"version": version}, + } +} + func main() { // Handle commands that conflict with kong's default:"withargs" on CheckCmd. // Kong validates the enum constraint on providers before trying command @@ -87,7 +171,7 @@ func main() { if len(os.Args) > 1 { switch os.Args[1] { case "refresh": - if err := runRefresh(); err != nil { + if err := runRefreshCommand(os.Args[2:]); err != nil { fmt.Fprintf(os.Stderr, "cq: %v\n", err) os.Exit(1) } @@ -114,12 +198,7 @@ func main() { } var cli CLI - ctx := kong.Parse(&cli, - kong.Name("cq"), - kong.Description("Check AI provider usage limits"), - kong.UsageOnError(), - kong.Vars{"version": version}, - ) + ctx := kong.Parse(&cli, cliKongOptions()...) if err := dispatch(ctx, &cli); err != nil { fmt.Fprintf(os.Stderr, "cq: %v\n", err) os.Exit(1) @@ -128,14 +207,27 @@ func main() { } func runAgent(args []string) error { + if len(args) > 0 && isHelpToken(args[0]) { + path := []string{"agent"} + if len(args) > 1 && args[0] == "help" { + path = append(path, args[1:]...) + } + return writeManualHelp(os.Stdout, path) + } if len(args) == 0 { - fmt.Fprintf(os.Stderr, "Usage: cq agent \n") + _ = writeManualHelp(os.Stderr, []string{"agent"}) return fmt.Errorf("missing subcommand") } switch args[0] { case "install": + if helpRequested(args[1:]) { + return writeManualHelp(os.Stdout, []string{"agent", "install"}) + } return installAgent(1800) case "uninstall": + if helpRequested(args[1:]) { + return writeManualHelp(os.Stdout, []string{"agent", "uninstall"}) + } return uninstallAgent() default: return fmt.Errorf("unknown agent command: %s", args[0]) diff --git a/cmd/cq/models.go b/cmd/cq/models.go index 518e90b..cce234b 100644 --- a/cmd/cq/models.go +++ b/cmd/cq/models.go @@ -212,14 +212,28 @@ func writeRegistrySourceDiagnostics(w io.Writer, diag modelregistry.RefreshDiagn func runModels(args []string, deps modelsDeps) error { deps = normaliseModelsDeps(deps) + if len(args) > 0 && isHelpToken(args[0]) { + path := []string{"models"} + if len(args) > 1 && args[0] == "help" { + path = append(path, args[1:]...) + } + return writeManualHelp(deps.Stdout, path) + } if len(args) == 0 { - return fmt.Errorf("usage: cq models ") + _ = writeManualHelp(deps.Stderr, []string{"models"}) + return fmt.Errorf("models: missing subcommand") } switch args[0] { case "list": + if helpRequested(args[1:]) { + return writeManualHelp(deps.Stdout, []string{"models", "list"}) + } return runModelsList(args[1:], deps) case "refresh": + if helpRequested(args[1:]) { + return writeManualHelp(deps.Stdout, []string{"models", "refresh"}) + } if err := runModelsRefreshWithDeps(deps); err != nil { return fmt.Errorf("models refresh: %w", err) } @@ -302,11 +316,22 @@ func runModelsList(args []string, deps modelsDeps) error { } func runModelsOverlay(args []string, deps modelsDeps) error { + if len(args) > 0 && isHelpToken(args[0]) { + path := []string{"models", "overlay"} + if len(args) > 1 && args[0] == "help" { + path = append(path, args[1:]...) + } + return writeManualHelp(deps.Stdout, path) + } if len(args) == 0 { - return fmt.Errorf("usage: cq models overlay ") + _ = writeManualHelp(deps.Stderr, []string{"models", "overlay"}) + return fmt.Errorf("models overlay: missing subcommand") } switch args[0] { case "add": + if helpRequested(args[1:]) { + return writeManualHelp(deps.Stdout, []string{"models", "overlay", "add"}) + } provider, id, cloneFrom, err := parseOverlayModelFlags(args[1:], true) if err != nil { return err @@ -316,6 +341,9 @@ func runModelsOverlay(args []string, deps modelsDeps) error { } return deps.Refresh() case "remove": + if helpRequested(args[1:]) { + return writeManualHelp(deps.Stdout, []string{"models", "overlay", "remove"}) + } provider, id, _, err := parseOverlayModelFlags(args[1:], false) if err != nil { return err @@ -325,6 +353,9 @@ func runModelsOverlay(args []string, deps modelsDeps) error { } return deps.Refresh() case "prune": + if helpRequested(args[1:]) { + return writeManualHelp(deps.Stdout, []string{"models", "overlay", "prune"}) + } if err := deps.Refresh(); err != nil { return err } diff --git a/cmd/cq/proxy.go b/cmd/cq/proxy.go index 3798121..c5656c1 100644 --- a/cmd/cq/proxy.go +++ b/cmd/cq/proxy.go @@ -21,25 +21,47 @@ import ( ) func runProxy(args []string) error { + if len(args) > 0 && isHelpToken(args[0]) { + path := []string{"proxy"} + if len(args) > 1 && args[0] == "help" { + path = append(path, args[1:]...) + } + return writeManualHelp(os.Stdout, path) + } if len(args) == 0 { - fmt.Fprintf(os.Stderr, "Usage: cq proxy \n") + _ = writeManualHelp(os.Stderr, []string{"proxy"}) return fmt.Errorf("missing subcommand") } switch args[0] { case "start": + if helpRequested(args[1:]) { + return writeManualHelp(os.Stdout, []string{"proxy", "start"}) + } opts, err := parseProxyCommandOptions(args[1:]) if err != nil { return err } return runProxyStart(opts) case "install": + if helpRequested(args[1:]) { + return writeManualHelp(os.Stdout, []string{"proxy", "install"}) + } return installProxyAgent() case "uninstall": + if helpRequested(args[1:]) { + return writeManualHelp(os.Stdout, []string{"proxy", "uninstall"}) + } return uninstallProxyAgent() case "restart": + if helpRequested(args[1:]) { + return writeManualHelp(os.Stdout, []string{"proxy", "restart"}) + } return restartProxyAgent() case "status": - opts, err := parseProxyCommandOptions(args[1:]) + if helpRequested(args[1:]) { + return writeManualHelp(os.Stdout, []string{"proxy", "status"}) + } + opts, err := parseProxyCommandOptionsFor("proxy status", args[1:]) if err != nil { return err } @@ -52,6 +74,9 @@ func runProxy(args []string) error { } func runProxyPin(args []string) error { + if helpRequested(args) { + return writeManualHelp(os.Stdout, []string{"proxy", "pin"}) + } cfg, err := proxy.LoadConfig() if err != nil { return fmt.Errorf("load config: %w", err) @@ -113,21 +138,25 @@ type proxyCommandOptions struct { } func parseProxyCommandOptions(args []string) (proxyCommandOptions, error) { + return parseProxyCommandOptionsFor("proxy start", args) +} + +func parseProxyCommandOptionsFor(command string, args []string) (proxyCommandOptions, error) { var opts proxyCommandOptions for i := 0; i < len(args); i++ { switch args[i] { case "--port": if i+1 >= len(args) { - return opts, fmt.Errorf("proxy start: --port requires a value") + return opts, fmt.Errorf("%s: --port requires a value", command) } port, err := strconv.Atoi(args[i+1]) if err != nil || port <= 0 || port > 65535 { - return opts, fmt.Errorf("proxy start: invalid port %q", args[i+1]) + return opts, fmt.Errorf("%s: invalid port %q", command, args[i+1]) } opts.Port = port i++ default: - return opts, fmt.Errorf("proxy start: unknown argument %s", args[i]) + return opts, fmt.Errorf("%s: unknown argument %s", command, args[i]) } } return opts, nil diff --git a/cmd/cq/proxy_pin_test.go b/cmd/cq/proxy_pin_test.go index d24618e..eaf5120 100644 --- a/cmd/cq/proxy_pin_test.go +++ b/cmd/cq/proxy_pin_test.go @@ -121,11 +121,11 @@ func TestProxyPin(t *testing.T) { } }) - t.Run("unknown flag returns error and leaves pin unchanged", func(t *testing.T) { + t.Run("--help returns help and leaves pin unchanged", func(t *testing.T) { setupPinTest(t, "user@example.com") err := runProxyPin([]string{"--help"}) - if err == nil { - t.Fatal("runProxyPin(--help) expected error, got nil") + if err != nil { + t.Fatalf("runProxyPin(--help) returned error: %v", err) } if got := loadPin(t); got != "user@example.com" { t.Errorf("pin changed to %q, want %q", got, "user@example.com") diff --git a/cmd/cq/refresh.go b/cmd/cq/refresh.go index 37a0a7a..108ce9a 100644 --- a/cmd/cq/refresh.go +++ b/cmd/cq/refresh.go @@ -24,7 +24,7 @@ import ( const refreshMarginMs = 30 * 60 * 1000 var ( - discoverClaudeAccountsFn = keyring.DiscoverClaudeAccounts + discoverClaudeAccountsFn = keyring.DiscoverClaudeAccounts newHTTPClientFn = func(timeout time.Duration, version string) httputil.Doer { return httputil.NewClient(timeout, version) } refreshCodexAccountsFn = refreshCodexAccounts invalidateProviderCacheFn = invalidateProviderCache @@ -35,6 +35,16 @@ var ( isStdinTerminalFn = isStdinTerminal ) +func runRefreshCommand(args []string) error { + if helpRequested(args) { + return writeManualHelp(os.Stdout, []string{"refresh"}) + } + if len(args) > 0 { + return fmt.Errorf("refresh: unexpected arguments") + } + return runRefresh() +} + func runRefresh() error { accounts := discoverClaudeAccountsFn() httpClient := newHTTPClientFn(10*time.Second, version) @@ -286,7 +296,6 @@ func syncAnonymousToIdentifiedWithChange(ctx context.Context, client httputil.Do return result, true } - // resolveProfileEmail calls the Claude profile API to determine the email // associated with an access token. func resolveProfileEmail(ctx context.Context, client httputil.Doer, token string) string {