diff --git a/.gitignore b/.gitignore index 5d93552..1046a9b 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,9 @@ go.work.sum # GoReleaser output dist/ +# Local build of the CLI +/requesty + # Editor/IDE .idea/ # .vscode/ diff --git a/README.md b/README.md index 0689a9b..56bec23 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,8 @@ replaced by default. - [Quick start](#quick-start) - [First run](#first-run) +- [Launching a harness](#launching-a-harness) +- [Profiles](#profiles) - [Supported harnesses](#supported-harnesses) - [Usage](#usage) - [Merge or overwrite](#merge-or-overwrite) @@ -41,27 +43,140 @@ a new terminal, or run the `source` command it prints, then run `requesty`. ## First run -**Paste an API key** +**Sign in with your browser** -On first run the CLI asks for a key. Create one on the -[API keys page](https://app.requesty.ai/api-keys). The key is checked against the gateway before -it is saved, so a typo is rejected here instead of failing later. It is stored in -`~/.requesty/config.json` and reused on every later run, so this happens once. +The first time you run `requesty` or a harness command such as `requesty claude` on a machine without a profile, +the CLI opens your browser to sign in to Requesty straight away; once you approve, it creates an +API key in your account, named after this machine (`Requesty CLI (my-laptop)`), and saves it as a +[profile](#profiles) in `~/.requesty/config.json`. The app then carries on to its dashboard, or +the harness you asked for starts. Every later run reuses that profile. The key shows up on the +[API keys page](https://app.requesty.ai/api-keys) like any other, and can be revoked there. ```text ╭──────────────────────────────────────────────────────────────────╮ │ Welcome to Requesty │ +│ │ │ One gateway for every model, in every tool you use, or app you │ │ build. │ │ │ -│ Paste an API key from https://app.requesty.ai/api-keys to begin. │ +│ Waiting for you to finish signing in with your browser… │ +│ │ +│ If it did not open, visit this address: │ +│ https://app.requesty.ai/oauth/authorize?... │ │ │ -│ ❯ rqsty-... │ +│ Claude Code starts as soon as the key is saved. │ │ │ -│ enter continue · ctrl+c quit dev │ +│ Working over SSH, or already have a key? Quit and run `requesty │ +│ login --api-key ` instead. │ +│ │ +│ esc quit │ ╰──────────────────────────────────────────────────────────────────╯ ``` +`esc` quits; run the command again to retry. The key is created in your group. If you belong to several groups a dialog +asks which one; if you belong to none it is a personal key. Organizations that require keys to live +in a group will say so, in which case ask an admin to add you to one. The profile is named +`default`; pass `--profile ` to choose another name. + +**`requesty login`** + +`requesty login` runs the same sign-in from the command line. Use it to set up a machine before +launching anything, to pick a group without being asked (`--group Engineering`), to name the +profile yourself (`--profile eu`), to point it at another router (`--router-url`), or to replace a +key that was revoked or has expired. Existing profiles are left alone unless you pass `--force`. + +**Over SSH, or with a key you already have** + +The browser hands the sign-in back to the CLI on `127.0.0.1`, which does not work over SSH. Create +a key on the [API keys page](https://app.requesty.ai/api-keys) and run +`requesty login --api-key ` instead. The key is checked against the gateway before it is +saved, so a typo is rejected here instead of failing later. It is saved as the profile `default` +unless you pass `--profile`. + +## Launching a harness + +`requesty claude`, `requesty codex`, `requesty opencode`, `requesty pi` and `requesty hermes` +start the harness with its traffic routed through Requesty for that run only; the harness's own +configuration is not changed. The current profile is used unless you select another. + +The first time you launch a harness from a profile, the CLI settles which model it should use and +remembers the answer in the profile. When the key can route to the harness's recommended default +(`claude-sonnet-5` for Claude Code, `gpt-6-sol` for Codex, and for OpenCode, Pi and Hermes whichever +of the two the key can route to first), that is used and printed, without asking. Otherwise a +picker asks: the Policies tab lists Requesty's managed policies, which name a model once and +route it across providers, and `tab` switches to every model the key can route to. `--model` +overrides the remembered model for one run without changing it; `--choose-model` opens the picker +to change it. In a script or CI with no terminal to ask in, the default is still used when it can +be; otherwise pass `--model`, or pick once interactively first. + +Claude Code hands background work (session titles, summaries, subagents marked `haiku`) to a +smaller model, which it would otherwise ask for by an Anthropic model id the gateway does not +know. The CLI settles this the same way: `claude-haiku-4-5` when the key can route to it, else the +main model itself, so an access list without Haiku still works. `--choose-fast-model` opens the +picker to change it and `--fast-model` overrides it for one run. + +```sh +requesty claude # current profile, remembered model (settled the first time) +requesty claude --profile eu # a named profile, with its own remembered model +requesty claude --model anthropic/claude-opus-4-1 # this model, just for this run +requesty claude --choose-model # pick again and remember the new answer +requesty claude --choose-fast-model # pick what background work runs on +requesty codex --full-auto # everything else goes to the harness untouched +requesty claude --effort high # so its own flags work as usual +requesty opencode --model gpt-6-sol run "explain this repo" +requesty pi --thinking off +requesty hermes chat -q "hello" +``` + +`--profile ` picks the profile for a run; without it, the current profile set with +`requesty profiles use` is used. For a harness command, put `--profile` after the harness name and +before the first argument that is passed through to the harness. + +Each harness is pointed at Requesty in the way it allows without touching its files: + +- **Claude Code** gets `ANTHROPIC_BASE_URL` and the key through the environment plus a + `--settings` document for this run. +- **Codex** gets a `requesty` model provider through `-c` overrides. +- **OpenCode** knows Requesty as a built-in provider that switches on when `REQUESTY_API_KEY` is + set; an `OPENCODE_CONFIG_CONTENT` document, merged into any you already export, pins it to this + profile, and the model is passed as `-m requesty/`. A Requesty key stored earlier with + `opencode auth login` can take precedence over the profile's; the CLI warns when the two differ. +- **Pi** has no flag for a custom provider, so the CLI writes a small extension and a catalog of + the models your key can route to under `~/.requesty/pi/` and starts Pi with `--extension`. The + catalog is refreshed from your account on each launch and kept from last time when that fails. + Other vendors' API key variables are dropped from Pi's environment so every model it offers + goes through Requesty. +- **Hermes** is started as `--provider custom` with `CUSTOM_BASE_URL` pointing at the router. + Hermes loads `~/.hermes/.env` over the environment, so the CLI warns when a value there would + replace one of its own. + +Anything the harness itself can be told, such as reasoning effort, is left to the harness's own +flags: `claude --effort high`, `codex -c model_reasoning_effort=high`, `opencode --variant high`, +`pi --thinking high`, `hermes --reasoning high`. A model or provider flag you pass through takes +precedence over the CLI's. + +## Profiles + +A profile is one sign-in: an API key and the router it is sent to. Most people have one. You want +more when your organization gives you keys in +different groups with different model policies (an EU group and a US group, say), or when you use +Requesty for work and personally on the same machine. Keys with manage permissions for the +`api-keys`, `groups` and `access-lists` subcommands fit here too: save one with +`requesty login --api-key --profile manage` and pass `--profile manage` to those commands. + +```sh +requesty profiles list # what is saved, current marked with * +requesty profiles use eu # make this profile current +requesty profiles remove personal # forget a profile (the key is not revoked) +``` + +`requesty login` creates the `default` profile unless you pass `--profile`. The first profile +becomes current. Every command accepts `--profile `. + +Harnesses configured from the dashboard stay pinned to the profile used during configuration. +Claude Code stores that profile's key in its settings. Codex fetches the key through +`requesty auth token --profile `. Configure the harness again to move it to another profile. + **Pick a harness** Harnesses found on your `PATH` are listed first. The ones that are not installed stay dimmed and @@ -124,9 +239,9 @@ a later run of the CLI keeps those entries and only adds the model you selected ## Usage -Above the harness list, the CLI shows spend, requests and tokens for the last 30 days for the key -you onboarded with, and refreshes them on demand. Full breakdowns by model, user and tool live in -the [Requesty dashboard](https://app.requesty.ai/analytics). +Above the harness list, the CLI shows spend, requests and tokens for the last 30 days for the +profile it is running as, and refreshes them on demand. Full breakdowns by model, user and tool +live in the [Requesty dashboard](https://app.requesty.ai/analytics). ## Merge or overwrite @@ -154,13 +269,20 @@ mv ~/.claude/settings.json.requesty.bak ~/.claude/settings.json ## Keys -Your Requesty API key is written into `~/.requesty/config.json`. Codex retrieves it when needed -through `requesty auth token`; other harnesses may also store it in their own config because that -is how they authenticate. All files containing the key are written so that only your user can -read them. +Your Requesty API keys are written into `~/.requesty/config.json`, one per profile. Codex +retrieves the current profile's key when needed through `requesty auth token`; other harnesses +may also store it in their own config because that is how they authenticate. All files +containing a key are written so that only your user can read them. + +A key created by signing in can make completions and read its own usage, nothing more. The +management subcommands (`requesty api-keys`, `requesty groups`, `requesty access-lists`) need a +key with manage permissions from the [API keys page](https://app.requesty.ai/api-keys), saved as +its own profile with `requesty login --api-key --profile `. The short-lived token from +the browser sign-in is used once to create the key and is never stored. Treat those files as secrets and do not commit them. Keys can be rotated or revoked at any time -on the [API keys page](https://app.requesty.ai/api-keys). +on the [API keys page](https://app.requesty.ai/api-keys); run `requesty login` afterwards to get +a new one. Removing a profile with `requesty profiles remove` forgets the key locally only. ## Configuration @@ -168,18 +290,27 @@ on the [API keys page](https://app.requesty.ai/api-keys). ```json { - "api_key": "rqsty-...", - "router_base_url": "https://router.requesty.ai" + "current": "engineering", + "profiles": { + "engineering": { + "api_key": "rqsty-..." + }, + "eu": { + "api_key": "rqsty-...", + "router_base_url": "https://router.eu.requesty.ai" + } + } } ``` -| Field | Meaning | -| --- | --- | -| `api_key` | The key written into harness configs and used for API calls | -| `router_base_url` | Inference endpoint the harnesses are pointed at | +- `current` names the profile used when none is selected explicitly. It is required whenever any + profiles are saved. +- `profiles..api_key` authenticates API and harness requests. +- `profiles..router_base_url` is that profile's inference endpoint. When omitted, it defaults + to `https://router.requesty.ai`. -Change `router_base_url` to route through a different region or a self-hosted deployment, for -example `https://router.eu.requesty.ai`. Delete the file to start over from onboarding. +Set a profile's `router_base_url` to use a different region or self-hosted deployment. The config +has no legacy single-key format; delete the file to start over from onboarding. ## Advanced installation options @@ -247,13 +378,35 @@ gofmt -l . # CI fails if this prints anything **A harness is listed but cannot be configured.** Its executable was not found on your `PATH`. Install the harness, then refresh the list. -**`That key was not recognised`.** The gateway does not accept the key you pasted. Copy it again -from the [API keys page](https://app.requesty.ai/api-keys). +**`that API key was not recognised`.** The gateway does not accept the key you gave +`requesty login --api-key`. Copy it again from the [API keys page](https://app.requesty.ai/api-keys). + +**The browser never comes back to the terminal.** The sign-in finishes by redirecting your browser +to `127.0.0.1` on the machine running the CLI, so it cannot complete over SSH or in a container. +Run `requesty login --api-key ` with a key from the +[API keys page](https://app.requesty.ai/api-keys) instead, or forward the port shown in the +sign-in URL. + +**`no Requesty profile configured; run requesty login`.** A command that cannot onboard +automatically ran without a saved profile. Run `requesty login` or +`requesty login --api-key `. + +**`group_id is required for organizations in group budget mode`.** Your organization tracks spend +per group, and you are not in one yet. Ask an organization admin to add you to a group, then sign +in again. + +**A harness gets a `401`.** The key was revoked or has expired. Replace that profile +with `requesty login --profile --force`. With OpenCode, also check for a Requesty key stored +by `opencode auth login`, which wins over the profile's; `opencode auth logout` removes it. With +Hermes, check `~/.hermes/.env` for a `REQUESTY_API_KEY` or `CUSTOM_BASE_URL` entry, which Hermes +loads over the environment. + +**`Requesty needs pi 0.84.0 or newer`.** `requesty pi` registers Requesty through Pi's provider +extension API, which arrived in Pi 0.84.0. Upgrade Pi and try again. -**`Could not load usage`.** The usage panel calls the management API with your key. A `401` means -the key was revoked or has expired: create a new one on the -[API keys page](https://app.requesty.ai/api-keys) and delete `~/.requesty/config.json` to -re-onboard. Routing itself is unaffected by this panel. +**`Could not load usage`.** The usage panel asks the management API about your own key, which any +key may do. A `401` means the key was revoked or has expired: run `requesty login` to get a new +one. Routing itself is unaffected by this panel. **The harness still uses its old provider.** Restart it. Configuration is read at startup. diff --git a/cmd/access_lists.go b/cmd/access_lists.go index 255be0f..f0999cb 100644 --- a/cmd/access_lists.go +++ b/cmd/access_lists.go @@ -17,7 +17,7 @@ const ( accessListYesFlag = "yes" ) -func newAccessListsCommand(env environment) *cobra.Command { +func newAccessListsCommand(env *environment) *cobra.Command { cmd := &cobra.Command{ Use: "access-lists", Aliases: []string{"access-list"}, @@ -27,6 +27,7 @@ func newAccessListsCommand(env environment) *cobra.Command { "the list are allowed, whatever their kind, so a list with only chat models\n" + "blocks embeddings, images and audio too. Models are grouped by modality:\n" + "chat, embedding, image, transcription or speech.", + PersistentPreRunE: env.requireProfile, } cmd.PersistentFlags().Bool(jsonFlag, false, "print JSON instead of a table") @@ -42,13 +43,13 @@ func newAccessListsCommand(env environment) *cobra.Command { return cmd } -func newAccessListsListCommand(env environment) *cobra.Command { +func newAccessListsListCommand(env *environment) *cobra.Command { return &cobra.Command{ Use: "list", Short: "List the access lists in your organization", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - lists, err := env.apiv2Client.AccessLists(cmd.Context()) + lists, err := env.session.client.AccessLists(cmd.Context()) if err != nil { return err } @@ -85,7 +86,7 @@ func newAccessListsListCommand(env environment) *cobra.Command { } } -func newAccessListsShowCommand(env environment) *cobra.Command { +func newAccessListsShowCommand(env *environment) *cobra.Command { return &cobra.Command{ Use: "show ", Short: "Show one access list and the models it allows", @@ -96,7 +97,7 @@ func newAccessListsShowCommand(env environment) *cobra.Command { return err } - list, err := env.apiv2Client.AccessList(cmd.Context(), id) + list, err := env.session.client.AccessList(cmd.Context(), id) if err != nil { return err } @@ -136,7 +137,7 @@ func newAccessListsShowCommand(env environment) *cobra.Command { } } -func newAccessListsCreateCommand(env environment) *cobra.Command { +func newAccessListsCreateCommand(env *environment) *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Create an access list", @@ -180,7 +181,7 @@ func newAccessListsCreateCommand(env environment) *cobra.Command { input.Transcription = models[client.ModalityTranscription] input.Speech = models[client.ModalitySpeech] - created, err := env.apiv2Client.CreateAccessList(cmd.Context(), input) + created, err := env.session.client.CreateAccessList(cmd.Context(), input) if err != nil { return err } @@ -226,7 +227,7 @@ func exampleModel(modality client.Modality) string { } } -func newAccessListsSetCommand(env environment) *cobra.Command { +func newAccessListsSetCommand(env *environment) *cobra.Command { cmd := &cobra.Command{ Use: "set", Short: "Set a property on an access list", @@ -240,7 +241,7 @@ func newAccessListsSetCommand(env environment) *cobra.Command { return cmd } -func newAccessListsSetNameCommand(env environment) *cobra.Command { +func newAccessListsSetNameCommand(env *environment) *cobra.Command { return &cobra.Command{ Use: "name ", Short: "Rename an access list", @@ -256,7 +257,7 @@ func newAccessListsSetNameCommand(env environment) *cobra.Command { return errors.New("missing name") } - if err := env.apiv2Client.UpdateAccessListName(cmd.Context(), id, name); err != nil { + if err := env.session.client.UpdateAccessListName(cmd.Context(), id, name); err != nil { return err } @@ -265,7 +266,7 @@ func newAccessListsSetNameCommand(env environment) *cobra.Command { } } -func newAccessListsSetAutoApproveCommand(env environment) *cobra.Command { +func newAccessListsSetAutoApproveCommand(env *environment) *cobra.Command { return &cobra.Command{ Use: "auto-approve ", Short: "Approve new models matching an access list automatically", @@ -278,7 +279,7 @@ func newAccessListsSetAutoApproveCommand(env environment) *cobra.Command { return err } - if err := env.apiv2Client.UpdateAccessListAutoApprove(cmd.Context(), id, true); err != nil { + if err := env.session.client.UpdateAccessListAutoApprove(cmd.Context(), id, true); err != nil { return err } @@ -288,7 +289,7 @@ func newAccessListsSetAutoApproveCommand(env environment) *cobra.Command { } } -func newAccessListsSetModelsCommand(env environment) *cobra.Command { +func newAccessListsSetModelsCommand(env *environment) *cobra.Command { return &cobra.Command{ Use: "models ...", Short: "Set the models of one modality on an access list", @@ -326,7 +327,7 @@ func newAccessListsSetModelsCommand(env environment) *cobra.Command { return err } - err = env.apiv2Client.UpdateAccessListModels(cmd.Context(), id, map[client.Modality][]string{modality: models}) + err = env.session.client.UpdateAccessListModels(cmd.Context(), id, map[client.Modality][]string{modality: models}) if err != nil { return err } @@ -338,7 +339,7 @@ func newAccessListsSetModelsCommand(env environment) *cobra.Command { } } -func newAccessListsClearCommand(env environment) *cobra.Command { +func newAccessListsClearCommand(env *environment) *cobra.Command { cmd := &cobra.Command{ Use: "clear", Short: "Clear a property on an access list", @@ -351,7 +352,7 @@ func newAccessListsClearCommand(env environment) *cobra.Command { return cmd } -func newAccessListsClearAutoApproveCommand(env environment) *cobra.Command { +func newAccessListsClearAutoApproveCommand(env *environment) *cobra.Command { return &cobra.Command{ Use: "auto-approve ", Short: "Stop approving new models on an access list automatically", @@ -362,7 +363,7 @@ func newAccessListsClearAutoApproveCommand(env environment) *cobra.Command { return err } - if err := env.apiv2Client.UpdateAccessListAutoApprove(cmd.Context(), id, false); err != nil { + if err := env.session.client.UpdateAccessListAutoApprove(cmd.Context(), id, false); err != nil { return err } @@ -372,7 +373,7 @@ func newAccessListsClearAutoApproveCommand(env environment) *cobra.Command { } } -func newAccessListsClearModelsCommand(env environment) *cobra.Command { +func newAccessListsClearModelsCommand(env *environment) *cobra.Command { return &cobra.Command{ Use: "models ...", Short: "Remove every model of one or more modalities from an access list", @@ -409,7 +410,7 @@ func newAccessListsClearModelsCommand(env environment) *cobra.Command { names = append(names, string(modality)) } - if err := env.apiv2Client.UpdateAccessListModels(cmd.Context(), id, cleared); err != nil { + if err := env.session.client.UpdateAccessListModels(cmd.Context(), id, cleared); err != nil { return err } @@ -419,7 +420,7 @@ func newAccessListsClearModelsCommand(env environment) *cobra.Command { } } -func newAccessListsDeleteCommand(env environment) *cobra.Command { +func newAccessListsDeleteCommand(env *environment) *cobra.Command { cmd := &cobra.Command{ Use: "delete ", Short: "Delete an access list", @@ -453,7 +454,7 @@ func newAccessListsDeleteCommand(env environment) *cobra.Command { } } - if err := env.apiv2Client.DeleteAccessList(cmd.Context(), id); err != nil { + if err := env.session.client.DeleteAccessList(cmd.Context(), id); err != nil { return err } diff --git a/cmd/api_keys.go b/cmd/api_keys.go index e71e36f..0c638f4 100644 --- a/cmd/api_keys.go +++ b/cmd/api_keys.go @@ -24,7 +24,7 @@ const ( apiKeyYesFlag = "yes" ) -func newAPIKeysCommand(env environment) *cobra.Command { +func newAPIKeysCommand(env *environment) *cobra.Command { cmd := &cobra.Command{ Use: "api-keys", Aliases: []string{"api-key", "keys"}, @@ -32,6 +32,7 @@ func newAPIKeysCommand(env environment) *cobra.Command { Long: "Manage the API keys in your organization.\n\n" + "The show command also takes " + client.SelfAPIKeyID + " as the id, meaning the key this CLI is configured with.", + PersistentPreRunE: env.requireProfile, } cmd.PersistentFlags().Bool(jsonFlag, false, "print JSON instead of a table") @@ -47,13 +48,13 @@ func newAPIKeysCommand(env environment) *cobra.Command { return cmd } -func newAPIKeysListCommand(env environment) *cobra.Command { +func newAPIKeysListCommand(env *environment) *cobra.Command { return &cobra.Command{ Use: "list", Short: "List the API keys in your organization", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - keys, err := env.apiv2Client.APIKeys(cmd.Context()) + keys, err := env.session.client.APIKeys(cmd.Context()) if err != nil { return err } @@ -84,7 +85,7 @@ func newAPIKeysListCommand(env environment) *cobra.Command { } } -func newAPIKeysShowCommand(env environment) *cobra.Command { +func newAPIKeysShowCommand(env *environment) *cobra.Command { return &cobra.Command{ Use: "show ", Short: "Show one API key", @@ -98,7 +99,7 @@ func newAPIKeysShowCommand(env environment) *cobra.Command { return err } - key, err := env.apiv2Client.APIKey(cmd.Context(), id) + key, err := env.session.client.APIKey(cmd.Context(), id) if err != nil { return err } @@ -122,7 +123,7 @@ func newAPIKeysShowCommand(env environment) *cobra.Command { } } -func newAPIKeysCreateCommand(env environment) *cobra.Command { +func newAPIKeysCreateCommand(env *environment) *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Create an API key", @@ -165,7 +166,7 @@ func newAPIKeysCreateCommand(env environment) *cobra.Command { } input.Permissions = permissions - created, err := env.apiv2Client.CreateAPIKey(cmd.Context(), input) + created, err := env.session.client.CreateAPIKey(cmd.Context(), input) if err != nil { return err } @@ -199,7 +200,7 @@ func newAPIKeysCreateCommand(env environment) *cobra.Command { return cmd } -func newAPIKeysSetCommand(env environment) *cobra.Command { +func newAPIKeysSetCommand(env *environment) *cobra.Command { cmd := &cobra.Command{ Use: "set", Short: "Set a property on an API key", @@ -213,7 +214,7 @@ func newAPIKeysSetCommand(env environment) *cobra.Command { return cmd } -func newAPIKeysSetLimitCommand(env environment) *cobra.Command { +func newAPIKeysSetLimitCommand(env *environment) *cobra.Command { return &cobra.Command{ Use: "limit ", Short: "Set the monthly spending cap of an API key", @@ -231,7 +232,7 @@ func newAPIKeysSetLimitCommand(env environment) *cobra.Command { return err } - if err := env.apiv2Client.UpdateAPIKeyLimit(cmd.Context(), id, limit); err != nil { + if err := env.session.client.UpdateAPIKeyLimit(cmd.Context(), id, limit); err != nil { return err } @@ -241,7 +242,7 @@ func newAPIKeysSetLimitCommand(env environment) *cobra.Command { } } -func newAPIKeysSetLabelsCommand(env environment) *cobra.Command { +func newAPIKeysSetLabelsCommand(env *environment) *cobra.Command { return &cobra.Command{ Use: "labels ...", Short: "Set the labels on an API key", @@ -271,7 +272,7 @@ func newAPIKeysSetLabelsCommand(env environment) *cobra.Command { return err } - if err := env.apiv2Client.UpdateAPIKeyLabels(cmd.Context(), id, labels); err != nil { + if err := env.session.client.UpdateAPIKeyLabels(cmd.Context(), id, labels); err != nil { return err } @@ -281,7 +282,7 @@ func newAPIKeysSetLabelsCommand(env environment) *cobra.Command { } } -func newAPIKeysClearCommand(env environment) *cobra.Command { +func newAPIKeysClearCommand(env *environment) *cobra.Command { cmd := &cobra.Command{ Use: "clear", Short: "Clear a property on an API key", @@ -291,7 +292,7 @@ func newAPIKeysClearCommand(env environment) *cobra.Command { return cmd } -func newAPIKeysClearLabelsCommand(env environment) *cobra.Command { +func newAPIKeysClearLabelsCommand(env *environment) *cobra.Command { return &cobra.Command{ Use: "labels ", Short: "Remove every label from an API key", @@ -302,7 +303,7 @@ func newAPIKeysClearLabelsCommand(env environment) *cobra.Command { return err } - if err := env.apiv2Client.UpdateAPIKeyLabels(cmd.Context(), id, nil); err != nil { + if err := env.session.client.UpdateAPIKeyLabels(cmd.Context(), id, nil); err != nil { return err } @@ -311,7 +312,7 @@ func newAPIKeysClearLabelsCommand(env environment) *cobra.Command { } } -func newAPIKeysSetExpiryCommand(env environment) *cobra.Command { +func newAPIKeysSetExpiryCommand(env *environment) *cobra.Command { return &cobra.Command{ Use: "expiry ", Short: "Set when an API key stops working", @@ -334,7 +335,7 @@ func newAPIKeysSetExpiryCommand(env environment) *cobra.Command { expiresAt = &parsed } - if err := env.apiv2Client.UpdateAPIKeyExpiry(cmd.Context(), id, expiresAt); err != nil { + if err := env.session.client.UpdateAPIKeyExpiry(cmd.Context(), id, expiresAt); err != nil { return err } @@ -348,7 +349,7 @@ func newAPIKeysSetExpiryCommand(env environment) *cobra.Command { } } -func newAPIKeysDeleteCommand(env environment) *cobra.Command { +func newAPIKeysDeleteCommand(env *environment) *cobra.Command { cmd := &cobra.Command{ Use: "delete ", Short: "Delete an API key", @@ -381,7 +382,7 @@ func newAPIKeysDeleteCommand(env environment) *cobra.Command { } } - if err := env.apiv2Client.DeleteAPIKey(cmd.Context(), id); err != nil { + if err := env.session.client.DeleteAPIKey(cmd.Context(), id); err != nil { return err } diff --git a/cmd/auth.go b/cmd/auth.go index a19a480..0f4ab32 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -6,24 +6,23 @@ import ( "github.com/spf13/cobra" ) -func newAuthCommand(env environment) *cobra.Command { +func newAuthCommand(env *environment) *cobra.Command { auth := &cobra.Command{ - Use: "auth", - Short: "Credential helpers for integrations", - Args: cobra.NoArgs, - Hidden: true, + Use: "auth", + Short: "Credential helpers for integrations", + Args: cobra.NoArgs, + Hidden: true, + PersistentPreRunE: env.requireProfile, } auth.AddCommand(&cobra.Command{ Use: "token", - Short: "Print the configured Requesty API key", - Args: cobra.NoArgs, + Short: "Print the Requesty API key of the profile in use", + Long: "Print the API key of the profile named with --" + profileFlag + ", else the current one.\n" + + "Harnesses that fetch their key on demand, such as Codex, call this.", + Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - if env.config.APIKey == "" { - return fmt.Errorf("no Requesty API key configured") - } - - if _, err := fmt.Fprintln(cmd.OutOrStdout(), env.config.APIKey); err != nil { + if _, err := fmt.Fprintln(cmd.OutOrStdout(), env.session.config.APIKey); err != nil { return fmt.Errorf("failed to print API key: %w", err) } return nil diff --git a/cmd/environment.go b/cmd/environment.go new file mode 100644 index 0000000..87a08ab --- /dev/null +++ b/cmd/environment.go @@ -0,0 +1,213 @@ +package cmd + +import ( + "errors" + "fmt" + "slices" + + "github.com/requestyai/cli/internal/client" + "github.com/requestyai/cli/internal/config" + "github.com/requestyai/cli/internal/modelpicker" + "github.com/requestyai/cli/internal/onboarding" + "github.com/spf13/cobra" +) + +const ( + // profileFlag names the saved profile to run with. It lives on the root + // so every subcommand inherits it; harness commands parse it themselves. + profileFlag = "profile" + // defaultProfileName is what onboarding calls the first profile. + defaultProfileName = "default" +) + +// environment is the profile store and, once a profile has been picked, the +// session the commands share. +type environment struct { + store config.Store + session *session +} + +// session is the resolved profile and the client built from it, set once the +// profile for a command has been picked. +type session struct { + config config.Config + client *client.Client +} + +func newSession(cfg config.Config) *session { + return &session{config: cfg, client: client.New(cfg)} +} + +func newEnvironment() (*environment, error) { + store, err := config.Load() + if err != nil { + return nil, fmt.Errorf("failed to load config: %w", err) + } + + return &environment{store: store}, nil +} + +// requireProfile is the PersistentPreRunE of the command groups that cannot +// run without a key. It resolves the profile once and hands the commands a +// client for it. +func (env *environment) requireProfile(cmd *cobra.Command, _ []string) error { + cfg, err := env.resolveProfile(cmd, "") + if err != nil { + return err + } + env.session = newSession(cfg) + + return nil +} + +// resolveProfile returns the profile selected for this command. +func (env *environment) resolveProfile(cmd *cobra.Command, name string) (config.Config, error) { + if name == "" { + name, _ = cmd.Flags().GetString(profileFlag) + } + + return env.store.Resolve(name) +} + +// ensureProfile returns the selected profile, running onboarding when none +// has been saved yet. +func (env *environment) ensureProfile(cmd *cobra.Command, name, harness string) (config.Config, error) { + cfg, err := env.resolveProfile(cmd, name) + if !errors.Is(err, config.ErrNoProfiles) { + return cfg, err + } + + cfg, err = onboarding.Run(cmd.Context(), onboarding.Options{ + Config: config.Config{RouterBaseURL: config.DefaultRouterBaseURL}, + Harness: harness, + }) + if err != nil { + return config.Config{}, err + } + if err := env.save(defaultProfileName, cfg); err != nil { + return config.Config{}, err + } + cfg.Name = defaultProfileName + + return cfg, nil +} + +// ensureModels returns the model the harness launches with and, for +// harnesses that have one, the model its background work goes to. +func (env *environment) ensureModels(cmd *cobra.Command, cfg config.Config, spec harnessSpec, parsed harnessArgs) (model, fast string, err error) { + model, pickedModel, err := env.ensureModel(cmd, cfg, modelRequest{ + displayName: spec.displayName, + override: parsed.launch.Model, + overrideFlag: harnessModelFlag, + ask: parsed.chooseModel, + askFlag: harnessChooseModelFlag, + saved: cfg.HarnessModels[spec.binary], + defaults: spec.defaultModels, + }) + if err != nil { + return "", "", err + } + if pickedModel { + cfg.SetHarnessModel(spec.binary, model) + // A new main model may sit in another region, so the fast model is + // forgotten and picked again below to go with it. + delete(cfg.HarnessFastModels, spec.binary) + } + + pickedFast := false + if spec.hasFastModel() { + // The main model is the last resort: it is known to be permitted, + // so an access list without any smaller model still works. + fast, pickedFast, err = env.ensureModel(cmd, cfg, modelRequest{ + displayName: spec.displayName + " background work", + override: parsed.launch.FastModel, + overrideFlag: harnessFastModelFlag, + ask: parsed.chooseFastModel, + askFlag: harnessChooseFastModelFlag, + saved: cfg.HarnessFastModels[spec.binary], + defaults: slices.Concat(spec.defaultFastModels, []string{model}), + }) + if err != nil { + return "", "", err + } + if pickedFast { + cfg.SetHarnessFastModel(spec.binary, fast) + } + } + + if pickedModel || pickedFast { + if err := env.save(cfg.Name, cfg); err != nil { + return "", "", err + } + } + + return model, fast, nil +} + +// modelRequest is how one of a harness's models was asked for on this run. +type modelRequest struct { + // displayName is shown in the picker title and in messages: "Claude Code", + // "Claude Code background work". + displayName string + // override is the model for this run only, from overrideFlag. + override string + // overrideFlag is "--model" or "--fast-model", for messages. + overrideFlag string + // ask says askFlag was passed: open the picker even if saved. + ask bool + // askFlag is "--choose-model" or "--choose-fast-model", for messages. + askFlag string + // saved is what the profile remembers, if anything. + saved string + // defaults are tried in order when nothing is saved; the first one the + // profile can route to is used without asking. + defaults []string +} + +// ensureModel returns the model for one request: the override for this run +// only, else the saved one, else whatever the picker decides, which it +// reports through picked so the caller knows to save it. +func (env *environment) ensureModel(cmd *cobra.Command, cfg config.Config, req modelRequest) (model string, picked bool, err error) { + switch { + case req.override != "": + return req.override, false, nil + case req.saved != "" && !req.ask: + return req.saved, false, nil + } + + preferred := req.defaults + if req.saved != "" { + preferred = slices.Concat([]string{req.saved}, req.defaults) + } + model, asked, err := modelpicker.Run(cmd.Context(), modelpicker.Options{ + Client: client.New(cfg), + Harness: req.displayName, + Preferred: preferred, + Confirm: req.ask, + }) + if errors.Is(err, modelpicker.ErrCancelled) { + return "", false, err + } + if err != nil { + // Most often there is no terminal to ask in, such as a script or CI. + return "", false, fmt.Errorf("no model picked for %s in profile %q; pass %s or run in a terminal once (%w)", req.displayName, cfg.Name, req.overrideFlag, err) + } + if !asked { + if _, err := fmt.Fprintf(cmd.ErrOrStderr(), "Using %s for %s; change with %s.\n", model, req.displayName, req.askFlag); err != nil { + return "", false, err + } + } + + return model, true, nil +} + +// save stores cfg as the named profile and writes the file. The first profile +// saved becomes current. +func (env *environment) save(name string, cfg config.Config) error { + env.store.Set(name, cfg) + if err := config.Save(env.store); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + return nil +} diff --git a/cmd/groups.go b/cmd/groups.go index 82c3d39..171310c 100644 --- a/cmd/groups.go +++ b/cmd/groups.go @@ -17,7 +17,7 @@ const ( groupYesFlag = "yes" ) -func newGroupsCommand(env environment) *cobra.Command { +func newGroupsCommand(env *environment) *cobra.Command { cmd := &cobra.Command{ Use: "groups", Aliases: []string{"group"}, @@ -25,6 +25,7 @@ func newGroupsCommand(env environment) *cobra.Command { Long: "Manage the groups in your organization.\n\n" + "A group gathers users under a shared budget. Members are existing users of\n" + "your organization, referred to by their user id.", + PersistentPreRunE: env.requireProfile, } cmd.PersistentFlags().Bool(jsonFlag, false, "print JSON instead of a table") @@ -39,13 +40,13 @@ func newGroupsCommand(env environment) *cobra.Command { return cmd } -func newGroupsListCommand(env environment) *cobra.Command { +func newGroupsListCommand(env *environment) *cobra.Command { return &cobra.Command{ Use: "list", Short: "List the groups in your organization", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - groups, err := env.apiv2Client.Groups(cmd.Context()) + groups, err := env.session.client.Groups(cmd.Context()) if err != nil { return err } @@ -77,7 +78,7 @@ func newGroupsListCommand(env environment) *cobra.Command { } } -func newGroupsShowCommand(env environment) *cobra.Command { +func newGroupsShowCommand(env *environment) *cobra.Command { return &cobra.Command{ Use: "show ", Short: "Show one group and its members", @@ -88,7 +89,7 @@ func newGroupsShowCommand(env environment) *cobra.Command { return err } - group, err := env.apiv2Client.Group(cmd.Context(), id) + group, err := env.session.client.Group(cmd.Context(), id) if err != nil { return err } @@ -158,7 +159,7 @@ func groupBudgetFields(group client.GroupDetails) [][2]string { return fields } -func newGroupsCreateCommand(env environment) *cobra.Command { +func newGroupsCreateCommand(env *environment) *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Create a group", @@ -189,7 +190,7 @@ func newGroupsCreateCommand(env environment) *cobra.Command { input.MonthlyLimit = &limit } - created, err := env.apiv2Client.CreateGroup(cmd.Context(), input) + created, err := env.session.client.CreateGroup(cmd.Context(), input) if err != nil { return err } @@ -214,7 +215,7 @@ func newGroupsCreateCommand(env environment) *cobra.Command { return cmd } -func newGroupsDeleteCommand(env environment) *cobra.Command { +func newGroupsDeleteCommand(env *environment) *cobra.Command { cmd := &cobra.Command{ Use: "delete ", Short: "Delete a group", @@ -248,7 +249,7 @@ func newGroupsDeleteCommand(env environment) *cobra.Command { } } - if err := env.apiv2Client.DeleteGroup(cmd.Context(), id); err != nil { + if err := env.session.client.DeleteGroup(cmd.Context(), id); err != nil { return err } @@ -267,7 +268,7 @@ func newGroupsDeleteCommand(env environment) *cobra.Command { return cmd } -func newGroupsMembersCommand(env environment) *cobra.Command { +func newGroupsMembersCommand(env *environment) *cobra.Command { cmd := &cobra.Command{ Use: "members", Aliases: []string{"member"}, @@ -285,7 +286,7 @@ func newGroupsMembersCommand(env environment) *cobra.Command { return cmd } -func newGroupsMembersAddCommand(env environment) *cobra.Command { +func newGroupsMembersAddCommand(env *environment) *cobra.Command { cmd := &cobra.Command{ Use: "add ", Short: "Add a member to a group", @@ -305,7 +306,7 @@ func newGroupsMembersAddCommand(env environment) *cobra.Command { return err } - if err := env.apiv2Client.AddGroupMember(cmd.Context(), groupID, userID, role); err != nil { + if err := env.session.client.AddGroupMember(cmd.Context(), groupID, userID, role); err != nil { return err } @@ -329,7 +330,7 @@ func newGroupsMembersAddCommand(env environment) *cobra.Command { return cmd } -func newGroupsMembersUpdateCommand(env environment) *cobra.Command { +func newGroupsMembersUpdateCommand(env *environment) *cobra.Command { cmd := &cobra.Command{ Use: "update --role ", Short: "Update the role of a group member", @@ -349,7 +350,7 @@ func newGroupsMembersUpdateCommand(env environment) *cobra.Command { return err } - if err := env.apiv2Client.UpdateGroupMemberRole(cmd.Context(), groupID, userID, role); err != nil { + if err := env.session.client.UpdateGroupMemberRole(cmd.Context(), groupID, userID, role); err != nil { return err } @@ -366,7 +367,7 @@ func newGroupsMembersUpdateCommand(env environment) *cobra.Command { return cmd } -func newGroupsMembersRemoveCommand(env environment) *cobra.Command { +func newGroupsMembersRemoveCommand(env *environment) *cobra.Command { return &cobra.Command{ Use: "remove ", Aliases: []string{"rm"}, @@ -380,7 +381,7 @@ func newGroupsMembersRemoveCommand(env environment) *cobra.Command { return err } - if err := env.apiv2Client.RemoveGroupMember(cmd.Context(), groupID, userID); err != nil { + if err := env.session.client.RemoveGroupMember(cmd.Context(), groupID, userID); err != nil { return err } diff --git a/cmd/harness.go b/cmd/harness.go new file mode 100644 index 0000000..9e16e4e --- /dev/null +++ b/cmd/harness.go @@ -0,0 +1,319 @@ +package cmd + +import ( + "errors" + "fmt" + "slices" + "strings" + + "github.com/requestyai/cli/internal/config" + "github.com/requestyai/cli/internal/harnesses" + "github.com/spf13/cobra" +) + +const ( + harnessProfileFlag = "--" + profileFlag + harnessModelFlag = "--model" + harnessChooseModelFlag = "--choose-model" + harnessFastModelFlag = "--fast-model" + harnessChooseFastModelFlag = "--choose-fast-model" +) + +// harnessValueFlags are our flags that take a value, as `--flag v` or +// `--flag=v`. +var harnessValueFlags = []string{harnessProfileFlag, harnessModelFlag, harnessFastModelFlag} + +// errHarnessHelp signals that the leading flags asked for help. +var errHarnessHelp = errors.New("help requested") + +// harnessSpec is one `requesty ` command. +type harnessSpec struct { + // binary is the command name and the executable it launches. + binary string + // displayName is how the harness is referred to in messages. + displayName string + // defaultModels are the model ids to launch with when nothing has been + // picked yet, most preferred first; the first one the profile can + // route to is used without asking. + defaultModels []string + // defaultFastModels is the same for the model background work goes + // to. Empty for harnesses without such a slot. + defaultFastModels []string + newHarness func(config.Config) (harnesses.Harness, error) +} + +// hasFastModel reports whether the harness hands background work to a +// second, smaller model. +func (s harnessSpec) hasFastModel() bool { + return len(s.defaultFastModels) > 0 +} + +// newHarnessCommands returns the `requesty ` commands, each of which +// starts a harness with Requesty injected for that run. +func newHarnessCommands(env *environment) []*cobra.Command { + return []*cobra.Command{ + newHarnessCommand(env, harnessSpec{ + binary: "claude", + displayName: "Claude Code", + defaultModels: []string{"claude-sonnet-5"}, + defaultFastModels: []string{"claude-haiku-4-5"}, + newHarness: func(cfg config.Config) (harnesses.Harness, error) { + dir, err := harnesses.DefaultConfigDirClaudeCode() + if err != nil { + return nil, err + } + return harnesses.NewClaudeHarness(cfg, dir), nil + }, + }), + newHarnessCommand(env, harnessSpec{ + binary: "codex", + displayName: "Codex", + defaultModels: []string{"gpt-6-sol"}, + newHarness: func(cfg config.Config) (harnesses.Harness, error) { + dir, err := harnesses.DefaultConfigDirCodex() + if err != nil { + return nil, err + } + return harnesses.NewCodexHarness(cfg, dir), nil + }, + }), + newHarnessCommand(env, harnessSpec{ + binary: "opencode", + displayName: "OpenCode", + defaultModels: []string{"claude-sonnet-5", "gpt-6-sol"}, + newHarness: func(cfg config.Config) (harnesses.Harness, error) { + dir, err := harnesses.DefaultConfigDirOpenCode() + if err != nil { + return nil, err + } + return harnesses.NewOpenCodeHarness(cfg, dir), nil + }, + }), + newHarnessCommand(env, harnessSpec{ + binary: "pi", + displayName: "Pi", + defaultModels: []string{"claude-sonnet-5", "gpt-6-sol"}, + newHarness: func(cfg config.Config) (harnesses.Harness, error) { + dir, err := harnesses.DefaultConfigDirPi() + if err != nil { + return nil, err + } + return harnesses.NewPiHarness(cfg, dir), nil + }, + }), + newHarnessCommand(env, harnessSpec{ + binary: "hermes", + displayName: "Hermes", + defaultModels: []string{"claude-sonnet-5", "gpt-6-sol"}, + newHarness: func(cfg config.Config) (harnesses.Harness, error) { + dir, err := harnesses.DefaultConfigDirHermes() + if err != nil { + return nil, err + } + return harnesses.NewHermesHarness(cfg, dir), nil + }, + }), + } +} + +// newHarnessCommand builds `requesty `. The harness is constructed +// lazily, from the profile as it stands after any onboarding. +func newHarnessCommand(env *environment, spec harnessSpec) *cobra.Command { + cmd := &cobra.Command{ + Use: harnessUse(spec), + Short: fmt.Sprintf("Launch %s through Requesty", spec.displayName), + Long: harnessLong(spec), + // We take over parsing so harness flags are never interpreted as ours. + DisableFlagParsing: true, + DisableFlagsInUseLine: true, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + parsed, err := parseHarnessArgs(args, spec) + if errors.Is(err, errHarnessHelp) { + return cmd.Help() + } + if err != nil { + return err + } + + cfg, err := env.ensureProfile(cmd, parsed.profile, spec.displayName) + if err != nil { + return err + } + + harness, err := spec.newHarness(cfg) + if err != nil { + return fmt.Errorf("failed to set up %s: %w", spec.displayName, err) + } + + parsed.launch.Model, parsed.launch.FastModel, err = env.ensureModels(cmd, cfg, spec, parsed) + if err != nil { + return err + } + + return harness.Launch(parsed.launch) + }, + } + + return cmd +} + +// harnessUse is the one-line synopsis of `requesty `. +func harnessUse(spec harnessSpec) string { + use := fmt.Sprintf("%s [%s ] [%s | %s]", spec.binary, harnessProfileFlag, harnessModelFlag, harnessChooseModelFlag) + if spec.hasFastModel() { + use += fmt.Sprintf(" [%s | %s]", harnessFastModelFlag, harnessChooseFastModelFlag) + } + + return use + fmt.Sprintf(" [-- ] [%s args...]", spec.binary) +} + +// harnessLong is the help page of `requesty `. +func harnessLong(spec harnessSpec) string { + flags := [][2]string{ + {harnessProfileFlag + " ", "Saved profile to run as"}, + {harnessModelFlag + " ", "Model for this run only (a managed policy or any Requesty model id)"}, + {harnessChooseModelFlag, "Pick the model again and remember it"}, + } + if spec.hasFastModel() { + flags = append(flags, + [2]string{harnessFastModelFlag + " ", "Model for background work, this run only"}, + [2]string{harnessChooseFastModelFlag, "Pick the background model again and remember it"}, + ) + } + flags = append(flags, [2]string{"-h, --help", "Show this help"}) + + var b strings.Builder + fmt.Fprintf(&b, "Launch %s through Requesty for this run; its own configuration is not changed.\n\n", spec.displayName) + b.WriteString("Flags:\n") + for _, flag := range flags { + fmt.Fprintf(&b, " %-28s %s\n", flag[0], flag[1]) + } + fmt.Fprintf(&b, "\nAnything else, or everything after `--`, is passed to `%s` untouched.\n\n", spec.binary) + fmt.Fprintf(&b, "The first launch settles the model: %s,\n", describeDefaults(spec.defaultModels)) + fmt.Fprintf(&b, "else a picker asks. The answer is remembered in the profile.") + if spec.hasFastModel() { + fmt.Fprintf(&b, " Background work is settled\nthe same way, preferring %s.", strings.Join(spec.defaultFastModels, ", then ")) + } + b.WriteString("\n") + + return b.String() +} + +// describeDefaults says in help text how the defaults are settled: the one +// model is used when it can be routed to, or the first of several that can. +func describeDefaults(models []string) string { + if len(models) == 1 { + return models[0] + " is used when the profile can route to it" + } + + return fmt.Sprintf("the first of %s the profile can route to is used", strings.Join(models, " or ")) +} + +// harnessArgs is a harness command line split into our profile choice and +// what the harness is launched with. +type harnessArgs struct { + profile string + chooseModel bool + chooseFastModel bool + launch harnesses.LaunchOptions +} + +// parseHarnessArgs splits our leading flags from the harness's own arguments. +// A bare `--` ends our flags explicitly; otherwise the first token we do not +// recognise does. That keeps `requesty claude --model x -p "hi"` and +// `requesty claude -p "hi"` both working, and lets a harness flag that happens +// to share a name with ours (`codex --model`) still reach the harness when it +// appears later. spec says which of our flags apply to this harness. +func parseHarnessArgs(args []string, spec harnessSpec) (harnessArgs, error) { + var parsed harnessArgs + +flags: + for len(args) > 0 { + arg := args[0] + + switch { + case arg == "--": + parsed.launch.Args = args[1:] + break flags + + case arg == "-h" || arg == "--help": + return parsed, errHarnessHelp + + case arg == harnessChooseModelFlag: + parsed.chooseModel = true + args = args[1:] + + case arg == harnessChooseFastModelFlag: + parsed.chooseFastModel = true + args = args[1:] + + case slices.Contains(harnessValueFlags, arg): + if len(args) < 2 { + return parsed, fmt.Errorf("%s requires a value", arg) + } + if err := parsed.set(arg, args[1]); err != nil { + return parsed, err + } + args = args[2:] + + case isValueFlagWithEquals(arg): + flag, value, _ := strings.Cut(arg, "=") + if err := parsed.set(flag, value); err != nil { + return parsed, err + } + args = args[1:] + + default: + parsed.launch.Args = args + break flags + } + } + + if parsed.chooseModel && parsed.launch.Model != "" { + return parsed, fmt.Errorf("%s and %s cannot be combined", harnessModelFlag, harnessChooseModelFlag) + } + if parsed.chooseFastModel && parsed.launch.FastModel != "" { + return parsed, fmt.Errorf("%s and %s cannot be combined", harnessFastModelFlag, harnessChooseFastModelFlag) + } + if !spec.hasFastModel() { + if parsed.launch.FastModel != "" { + return parsed, fmt.Errorf("%s has no separate model for background work; %s does not apply", spec.displayName, harnessFastModelFlag) + } + if parsed.chooseFastModel { + return parsed, fmt.Errorf("%s has no separate model for background work; %s does not apply", spec.displayName, harnessChooseFastModelFlag) + } + } + + return parsed, nil +} + +// isValueFlagWithEquals reports whether arg is one of ours in `--flag=value` +// form. +func isValueFlagWithEquals(arg string) bool { + for _, flag := range harnessValueFlags { + if strings.HasPrefix(arg, flag+"=") { + return true + } + } + + return false +} + +func (p *harnessArgs) set(flag, value string) error { + value = strings.TrimSpace(value) + if value == "" { + return fmt.Errorf("%s requires a value", flag) + } + + switch flag { + case harnessProfileFlag: + p.profile = value + case harnessModelFlag: + p.launch.Model = value + case harnessFastModelFlag: + p.launch.FastModel = value + } + + return nil +} diff --git a/cmd/harness_test.go b/cmd/harness_test.go new file mode 100644 index 0000000..1f57fd6 --- /dev/null +++ b/cmd/harness_test.go @@ -0,0 +1,396 @@ +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/requestyai/cli/internal/config" + "github.com/requestyai/cli/internal/harnesses" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHarnessCommandsAreRegistered(t *testing.T) { + command := newRootCommand(&environment{}) + + for _, name := range []string{"claude", "codex", "opencode", "pi", "hermes"} { + sub, _, err := command.Find([]string{name}) + require.NoError(t, err) + assert.Equal(t, name, sub.Name()) + assert.True(t, sub.DisableFlagParsing, "%s must not interpret harness flags", name) + } +} + +func TestParseHarnessArgs(t *testing.T) { + tests := []struct { + name string + args []string + want harnessArgs + }{ + { + name: "no arguments", + args: nil, + want: harnessArgs{}, + }, + { + name: "leading flags then passthrough", + args: []string{"--profile", "eu", "--model", "anthropic/claude-fable-5", "--effort", "high", "--dangerously-skip-permissions", "-p", "hi"}, + want: harnessArgs{ + profile: "eu", + launch: harnesses.LaunchOptions{ + Model: "anthropic/claude-fable-5", + Args: []string{"--effort", "high", "--dangerously-skip-permissions", "-p", "hi"}, + }, + }, + }, + { + name: "equals form for every flag", + args: []string{"--profile=eu", "--model=openai/gpt-5"}, + want: harnessArgs{profile: "eu", launch: harnesses.LaunchOptions{Model: "openai/gpt-5"}}, + }, + { + // The second --model belongs to the harness because a foreign flag came first. + name: "stops at first foreign argument", + args: []string{"--full-auto", "--model", "theirs"}, + want: harnessArgs{launch: harnesses.LaunchOptions{Args: []string{"--full-auto", "--model", "theirs"}}}, + }, + { + name: "double dash ends our flags", + args: []string{"--model", "openai/gpt-5", "--", "--model", "theirs"}, + want: harnessArgs{launch: harnesses.LaunchOptions{Model: "openai/gpt-5", Args: []string{"--model", "theirs"}}}, + }, + { + name: "bare double dash passes nothing", + args: []string{"--"}, + want: harnessArgs{launch: harnesses.LaunchOptions{Args: []string{}}}, + }, + { + name: "choose model takes no value", + args: []string{"--choose-model", "--profile", "eu", "-p", "hi"}, + want: harnessArgs{profile: "eu", chooseModel: true, launch: harnesses.LaunchOptions{Args: []string{"-p", "hi"}}}, + }, + { + name: "fast model in both forms", + args: []string{"--fast-model", "claude-haiku-4-5", "--model=claude-opus-4-8"}, + want: harnessArgs{launch: harnesses.LaunchOptions{Model: "claude-opus-4-8", FastModel: "claude-haiku-4-5"}}, + }, + { + name: "choose fast model alongside a one-run model", + args: []string{"--model", "claude-opus-4-8", "--choose-fast-model", "-p", "hi"}, + want: harnessArgs{chooseFastModel: true, launch: harnesses.LaunchOptions{Model: "claude-opus-4-8", Args: []string{"-p", "hi"}}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseHarnessArgs(tt.args, claudeSpec) + + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestParseHarnessArgsErrors(t *testing.T) { + tests := []struct { + name string + args []string + spec harnessSpec + want string + }{ + { + name: "model without value", + args: []string{"--model"}, + want: "--model requires a value", + }, + { + name: "profile without value", + args: []string{"--profile"}, + want: "--profile requires a value", + }, + { + name: "empty equals value", + args: []string{"--model="}, + want: "--model requires a value", + }, + { + name: "blank value", + args: []string{"--model", " "}, + want: "--model requires a value", + }, + { + name: "model and choose model together", + args: []string{"--model", "x", "--choose-model", "--", "-p", "hi"}, + want: "--model and --choose-model cannot be combined", + }, + { + name: "fast model without value", + args: []string{"--fast-model="}, + want: "--fast-model requires a value", + }, + { + name: "fast model and choose fast model together", + args: []string{"--choose-fast-model", "--fast-model", "x"}, + want: "--fast-model and --choose-fast-model cannot be combined", + }, + { + name: "fast model for a harness without one", + args: []string{"--fast-model", "x"}, + spec: codexSpec, + want: "Codex has no separate model for background work; --fast-model does not apply", + }, + { + name: "choose fast model for a harness without one", + args: []string{"--choose-fast-model"}, + spec: codexSpec, + want: "Codex has no separate model for background work; --choose-fast-model does not apply", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + spec := tt.spec + if spec.binary == "" { + spec = claudeSpec + } + + _, err := parseHarnessArgs(tt.args, spec) + + require.EqualError(t, err, tt.want) + }) + } +} + +func TestParseHarnessArgsHelp(t *testing.T) { + for _, args := range [][]string{{"-h"}, {"--help"}, {"--model", "x", "--help"}} { + _, err := parseHarnessArgs(args, claudeSpec) + assert.ErrorIs(t, err, errHarnessHelp, "%v", args) + } + + // After the harness's own flags begin, -h belongs to the harness. + got, err := parseHarnessArgs([]string{"-p", "hi", "--help"}, claudeSpec) + require.NoError(t, err) + assert.Equal(t, []string{"-p", "hi", "--help"}, got.launch.Args) +} + +func TestHarnessCommandHelpShowsOurFlags(t *testing.T) { + command := newRootCommand(&environment{store: oneProfile}) + var output bytes.Buffer + command.SetOut(&output) + command.SetArgs([]string{"claude", "--help"}) + + require.NoError(t, command.Execute()) + + assert.Contains(t, output.String(), "--profile") + assert.Contains(t, output.String(), "--choose-model") + assert.Contains(t, output.String(), "--fast-model") + assert.Contains(t, output.String(), "--choose-fast-model") + assert.NotContains(t, output.String(), "reasoning-effort", "effort belongs to the harness's own flags") + assert.Contains(t, output.String(), "passed to `claude`") + assert.Contains(t, output.String(), "claude-sonnet-5 is used when the profile can route to it") + assert.Contains(t, output.String(), "claude-haiku-4-5") +} + +func TestHarnessWithoutFastModelHidesItsFlags(t *testing.T) { + command := newRootCommand(&environment{store: oneProfile}) + var output bytes.Buffer + command.SetOut(&output) + command.SetArgs([]string{"codex", "--help"}) + + require.NoError(t, command.Execute()) + + assert.Contains(t, output.String(), "--choose-model") + assert.NotContains(t, output.String(), "fast-model") +} + +func TestHarnessHelpListsEveryDefault(t *testing.T) { + command := newRootCommand(&environment{store: oneProfile}) + var output bytes.Buffer + command.SetOut(&output) + command.SetArgs([]string{"opencode", "--help"}) + + require.NoError(t, command.Execute()) + + assert.Contains(t, output.String(), "the first of claude-sonnet-5 or gpt-6-sol the profile can route to is used") +} + +func TestHarnessWithoutFastModelRejectsItsFlags(t *testing.T) { + for _, args := range [][]string{{"--fast-model", "x"}, {"--choose-fast-model"}} { + command := newRootCommand(&environment{store: oneProfile}) + command.SetArgs(append([]string{"codex"}, args...)) + + err := command.Execute() + + require.ErrorContains(t, err, "Codex has no separate model for background work", "%v", args) + require.ErrorContains(t, err, args[0]+" does not apply", "%v", args) + } +} + +var ( + claudeSpec = harnessSpec{binary: "claude", displayName: "Claude Code", defaultModels: []string{"claude-sonnet-4-6"}, defaultFastModels: []string{"claude-haiku-4-5"}} + codexSpec = harnessSpec{binary: "codex", displayName: "Codex", defaultModels: []string{"gpt-5.5"}} +) + +// routerServing is a router whose two model lists both hold ids. With none, +// hitting it fails the test: the caller expects no lookup. +func routerServing(t *testing.T, ids ...string) string { + t.Helper() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if len(ids) == 0 { + t.Errorf("unexpected request to %s", r.URL.Path) + w.WriteHeader(http.StatusInternalServerError) + return + } + data := make([]map[string]string, 0, len(ids)) + for _, id := range ids { + data = append(data, map[string]string{"id": id}) + } + require.NoError(t, json.NewEncoder(w).Encode(map[string]any{"data": data})) + })) + t.Cleanup(server.Close) + + return server.URL +} + +// modelsEnvironment is an environment with one profile on router, saving to +// a scratch home, plus a command to run against it and its stderr. +func modelsEnvironment(t *testing.T, router string, profile config.Config) (*environment, config.Config, *cobra.Command, *bytes.Buffer) { + t.Helper() + t.Setenv("HOME", t.TempDir()) + + profile.APIKey, profile.RouterBaseURL = "k", router + env := &environment{store: config.Store{Current: "work", Profiles: map[string]config.Config{"work": profile}}} + cfg, err := env.store.Resolve("") + require.NoError(t, err) + + var stderr bytes.Buffer + cmd := &cobra.Command{} + cmd.SetContext(context.Background()) + cmd.SetErr(&stderr) + + return env, cfg, cmd, &stderr +} + +func TestEnsureModelsUsesRoutableDefaultsAndRemembersThem(t *testing.T) { + env, cfg, cmd, stderr := modelsEnvironment(t, routerServing(t, "claude-sonnet-4-6", "claude-haiku-4-5"), config.Config{}) + + model, fast, err := env.ensureModels(cmd, cfg, claudeSpec, harnessArgs{}) + + require.NoError(t, err) + assert.Equal(t, "claude-sonnet-4-6", model) + assert.Equal(t, "claude-haiku-4-5", fast) + assert.Contains(t, stderr.String(), "Using claude-sonnet-4-6 for Claude Code; change with --choose-model.") + assert.Contains(t, stderr.String(), "Using claude-haiku-4-5 for Claude Code background work; change with --choose-fast-model.") + saved, err := config.Load() + require.NoError(t, err) + assert.Equal(t, "claude-sonnet-4-6", saved.Profiles["work"].HarnessModels["claude"]) + assert.Equal(t, "claude-haiku-4-5", saved.Profiles["work"].HarnessFastModels["claude"]) +} + +func TestEnsureModelsTriesDefaultsInOrder(t *testing.T) { + env, cfg, cmd, _ := modelsEnvironment(t, routerServing(t, "claude-sonnet-4-5", "claude-haiku-3-5"), config.Config{}) + spec := claudeSpec + spec.defaultModels = []string{"claude-sonnet-4-6", "claude-sonnet-4-5"} + spec.defaultFastModels = []string{"claude-haiku-4-5", "claude-haiku-3-5"} + + model, fast, err := env.ensureModels(cmd, cfg, spec, harnessArgs{}) + + require.NoError(t, err) + assert.Equal(t, "claude-sonnet-4-5", model) + assert.Equal(t, "claude-haiku-3-5", fast) +} + +func TestEnsureModelsFallsBackToTheMainModelForBackgroundWork(t *testing.T) { + env, cfg, cmd, stderr := modelsEnvironment(t, routerServing(t, "claude-sonnet-4-6"), config.Config{}) + + model, fast, err := env.ensureModels(cmd, cfg, claudeSpec, harnessArgs{}) + + require.NoError(t, err) + assert.Equal(t, "claude-sonnet-4-6", model) + assert.Equal(t, "claude-sonnet-4-6", fast) + assert.Contains(t, stderr.String(), "Using claude-sonnet-4-6 for Claude Code background work") +} + +func TestEnsureModelsForgetsFastModelWhenMainModelIsNewlyPicked(t *testing.T) { + env, cfg, cmd, _ := modelsEnvironment(t, routerServing(t, "claude-sonnet-4-6", "claude-haiku-4-5"), config.Config{ + HarnessFastModels: map[string]string{"claude": "claude-haiku-4-5@eu"}, + }) + + model, fast, err := env.ensureModels(cmd, cfg, claudeSpec, harnessArgs{}) + + require.NoError(t, err) + assert.Equal(t, "claude-sonnet-4-6", model) + assert.Equal(t, "claude-haiku-4-5", fast, "the remembered fast model is settled again next to the new main model") + assert.Equal(t, "claude-haiku-4-5", env.store.Profiles["work"].HarnessFastModels["claude"]) +} + +func TestEnsureModelsUsesWhatIsRememberedWithoutLookingUp(t *testing.T) { + env, cfg, cmd, stderr := modelsEnvironment(t, routerServing(t), config.Config{ + HarnessModels: map[string]string{"claude": "claude-fable-5"}, + HarnessFastModels: map[string]string{"claude": "claude-haiku-4-5@eu"}, + }) + + model, fast, err := env.ensureModels(cmd, cfg, claudeSpec, harnessArgs{}) + + require.NoError(t, err) + assert.Equal(t, "claude-fable-5", model) + assert.Equal(t, "claude-haiku-4-5@eu", fast) + assert.Empty(t, stderr.String()) + assert.NoFileExists(t, config.DisplayPath(), "nothing changed, so nothing is written") +} + +func TestEnsureModelsFlagsAreForThisRunOnly(t *testing.T) { + env, cfg, cmd, _ := modelsEnvironment(t, routerServing(t), config.Config{ + HarnessModels: map[string]string{"claude": "claude-fable-5"}, + HarnessFastModels: map[string]string{"claude": "claude-haiku-4-5"}, + }) + parsed := harnessArgs{launch: harnesses.LaunchOptions{Model: "anthropic/claude-opus-4-1", FastModel: "anthropic/claude-haiku-4-5"}} + + model, fast, err := env.ensureModels(cmd, cfg, claudeSpec, parsed) + + require.NoError(t, err) + assert.Equal(t, "anthropic/claude-opus-4-1", model) + assert.Equal(t, "anthropic/claude-haiku-4-5", fast) + assert.Equal(t, "claude-fable-5", env.store.Profiles["work"].HarnessModels["claude"]) + assert.NoFileExists(t, config.DisplayPath()) +} + +func TestEnsureModelsSettlesBackgroundWorkNextToAOneRunModel(t *testing.T) { + env, cfg, cmd, _ := modelsEnvironment(t, routerServing(t, "claude-haiku-4-5"), config.Config{}) + parsed := harnessArgs{launch: harnesses.LaunchOptions{Model: "anthropic/claude-opus-4-1"}} + + model, fast, err := env.ensureModels(cmd, cfg, claudeSpec, parsed) + + require.NoError(t, err) + assert.Equal(t, "anthropic/claude-opus-4-1", model) + assert.Equal(t, "claude-haiku-4-5", fast) + assert.Empty(t, env.store.Profiles["work"].HarnessModels, "a one-run model is not remembered") + assert.Equal(t, "claude-haiku-4-5", env.store.Profiles["work"].HarnessFastModels["claude"]) +} + +func TestEnsureModelsWithoutAFastModel(t *testing.T) { + env, cfg, cmd, stderr := modelsEnvironment(t, routerServing(t, "gpt-5.5"), config.Config{}) + + model, fast, err := env.ensureModels(cmd, cfg, codexSpec, harnessArgs{}) + + require.NoError(t, err) + assert.Equal(t, "gpt-5.5", model) + assert.Empty(t, fast) + assert.NotContains(t, stderr.String(), "background work") + assert.Empty(t, env.store.Profiles["work"].HarnessFastModels) +} + +func TestHarnessProfileFlag(t *testing.T) { + command := newRootCommand(&environment{store: twoProfiles}) + command.SetArgs([]string{"claude", "--profile", "sales"}) + + err := command.Execute() + + require.ErrorContains(t, err, `profile "sales" not found`) +} diff --git a/cmd/login.go b/cmd/login.go new file mode 100644 index 0000000..f6e10f7 --- /dev/null +++ b/cmd/login.go @@ -0,0 +1,96 @@ +package cmd + +import ( + "errors" + "fmt" + + "github.com/requestyai/cli/internal/client" + "github.com/requestyai/cli/internal/config" + "github.com/requestyai/cli/internal/onboarding" + "github.com/spf13/cobra" +) + +const ( + loginGroupFlag = "group" + loginForceFlag = "force" + loginAPIKeyFlag = "api-key" + loginRouterURLFlag = "router-url" +) + +var errUnrecognisedAPIKey = errors.New("that API key was not recognised; copy it again from " + onboarding.APIKeysURL) + +func newLoginCommand(env *environment) *cobra.Command { + cmd := &cobra.Command{ + Use: "login [--group ] [--profile ] [--api-key ] [--router-url ] [--force]", + Short: "Sign in to Requesty and save a profile on this machine", + Long: "Sign in to Requesty in your browser and save a profile on this machine.\n\n" + + "The CLI creates an API key in your account, named after this machine, and saves\n" + + "it as the `" + defaultProfileName + "` profile in " + config.DisplayPath() + ". Harness commands and\n" + + "the terminal app run with it.\n\n" + + "The browser hands the sign-in back on 127.0.0.1, which does not work over SSH;\n" + + "there, pass --api-key with a key from " + onboarding.APIKeysURL + " instead.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + groupID, _ := cmd.Flags().GetString(loginGroupFlag) + apiKey, _ := cmd.Flags().GetString(loginAPIKeyFlag) + routerURL, _ := cmd.Flags().GetString(loginRouterURLFlag) + force, _ := cmd.Flags().GetBool(loginForceFlag) + + name, _ := cmd.Flags().GetString(profileFlag) + if name == "" { + name = defaultProfileName + } + if existing, ok := env.store.Profiles[name]; ok { + if !force { + return fmt.Errorf("profile %q already exists; pass --force to replace it", name) + } + if routerURL == "" { + routerURL = existing.RouterBaseURL + } + } + if routerURL == "" { + routerURL = config.DefaultRouterBaseURL + } + cfg := config.Config{APIKey: apiKey, RouterBaseURL: routerURL} + + if apiKey == "" { + var err error + cfg, err = onboarding.RunHeadless(cmd.Context(), onboarding.Options{Config: cfg, GroupID: groupID}) + if err != nil { + return err + } + } else if err := checkAPIKey(cmd, cfg); err != nil { + return err + } + + if err := env.save(name, cfg); err != nil { + return err + } + _, err := fmt.Fprintf(cmd.OutOrStdout(), "Saved profile %q to %s.\n", name, config.DisplayPath()) + + return err + }, + } + + cmd.Flags().String(loginGroupFlag, "", "group to create the API key in, by name or id") + cmd.Flags().String(loginAPIKeyFlag, "", "save an existing API key instead of signing in") + cmd.Flags().String(loginRouterURLFlag, "", "router for the profile (default: the one it has, else "+config.DefaultRouterBaseURL+")") + cmd.Flags().Bool(loginForceFlag, false, "replace the profile's key when it already exists") + + return cmd +} + +// checkAPIKey asks the gateway whether a key the user supplied is live before +// it is saved. This is the route for machines where the browser cannot hand +// the sign-in back, such as over SSH. +func checkAPIKey(cmd *cobra.Command, cfg config.Config) error { + err := client.New(cfg).CheckAPIKey(cmd.Context()) + if errors.Is(err, client.ErrInvalidAPIKey) { + return errUnrecognisedAPIKey + } + if err != nil { + return fmt.Errorf("failed to check the api key: %w", err) + } + + return nil +} diff --git a/cmd/login_test.go b/cmd/login_test.go new file mode 100644 index 0000000..70808f9 --- /dev/null +++ b/cmd/login_test.go @@ -0,0 +1,108 @@ +package cmd + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" + + "github.com/requestyai/cli/internal/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// checkServer answers /v1/auth/check with status, standing in for the gateway +// when a login has to decide whether a supplied key works. +func checkServer(t *testing.T, status int) *httptest.Server { + t.Helper() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/v1/auth/check", r.URL.Path) + w.WriteHeader(status) + })) + t.Cleanup(server.Close) + + return server +} + +func TestLoginSavesValidatedKeyAsCurrentDefaultProfile(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + server := checkServer(t, http.StatusOK) + var output bytes.Buffer + command := newRootCommand(&environment{}) + command.SetOut(&output) + command.SetArgs([]string{"login", "--api-key", "rqsty-existing", "--router-url", server.URL}) + + require.NoError(t, command.Execute()) + + saved, err := config.Load() + require.NoError(t, err) + assert.Equal(t, defaultProfileName, saved.Current) + assert.Equal(t, config.Config{APIKey: "rqsty-existing", RouterBaseURL: server.URL}, saved.Profiles[defaultProfileName]) + assert.Contains(t, output.String(), `Saved profile "default"`) + assert.NotContains(t, output.String(), "rqsty-existing") +} + +func TestLoginSavesNamedProfileWithoutChangingCurrent(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + server := checkServer(t, http.StatusOK) + command := newRootCommand(&environment{store: clone(oneProfile)}) + command.SetOut(&bytes.Buffer{}) + command.SetArgs([]string{"login", "--api-key", "rqsty-manage", "--profile", "manage", "--router-url", server.URL}) + + require.NoError(t, command.Execute()) + + saved, err := config.Load() + require.NoError(t, err) + assert.Equal(t, "work", saved.Current) + assert.Equal(t, "rqsty-manage", saved.Profiles["manage"].APIKey) +} + +func TestLoginRequiresForceToReplaceProfile(t *testing.T) { + server := checkServer(t, http.StatusOK) + store := config.Store{Current: "default", Profiles: map[string]config.Config{"default": {APIKey: "rqsty-old", RouterBaseURL: server.URL}}} + command := newRootCommand(&environment{store: store}) + command.SetArgs([]string{"login", "--api-key", "rqsty-new"}) + + err := command.Execute() + + require.ErrorContains(t, err, "pass --force") +} + +func TestLoginForceReplacesKeyAndKeepsRouter(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + server := checkServer(t, http.StatusOK) + store := config.Store{Current: "default", Profiles: map[string]config.Config{"default": {APIKey: "rqsty-old", RouterBaseURL: server.URL}}} + command := newRootCommand(&environment{store: store}) + command.SetOut(&bytes.Buffer{}) + command.SetArgs([]string{"login", "--api-key", "rqsty-new", "--force"}) + + require.NoError(t, command.Execute()) + + saved, err := config.Load() + require.NoError(t, err) + assert.Equal(t, config.Config{APIKey: "rqsty-new", RouterBaseURL: server.URL}, saved.Profiles["default"]) +} + +func TestLoginRejectsUnrecognisedKeyWithoutSaving(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + server := checkServer(t, http.StatusUnauthorized) + command := newRootCommand(&environment{}) + command.SetArgs([]string{"login", "--api-key", "rqsty-typo", "--router-url", server.URL}) + + require.ErrorIs(t, command.Execute(), errUnrecognisedAPIKey) + + saved, err := config.Load() + require.NoError(t, err) + assert.Empty(t, saved.Profiles) +} + +func TestLoginFlagsAreRegistered(t *testing.T) { + command := newRootCommand(&environment{}) + login, _, err := command.Find([]string{"login"}) + require.NoError(t, err) + for _, name := range []string{loginGroupFlag, loginAPIKeyFlag, loginForceFlag, loginRouterURLFlag} { + assert.NotNil(t, login.Flags().Lookup(name), "missing --%s", name) + } + assert.NotNil(t, login.InheritedFlags().Lookup(profileFlag)) +} diff --git a/cmd/profiles.go b/cmd/profiles.go new file mode 100644 index 0000000..d5936bd --- /dev/null +++ b/cmd/profiles.go @@ -0,0 +1,122 @@ +package cmd + +import ( + "fmt" + + "github.com/requestyai/cli/internal/config" + "github.com/requestyai/cli/internal/onboarding" + "github.com/spf13/cobra" +) + +func newProfilesCommand(env *environment) *cobra.Command { + cmd := &cobra.Command{ + Use: "profiles", + Aliases: []string{"profile"}, + Short: "Manage the profiles saved on this machine", + Long: "Manage the profiles saved in " + config.DisplayPath() + ".\n\n" + + "A profile is one sign-in: an API key and the router it goes to. `requesty login`\n" + + "creates them; pick one for a run with --" + profileFlag + ", or make it current with\n" + + "`requesty profiles use`.", + } + + cmd.PersistentFlags().Bool(jsonFlag, false, "print JSON instead of a table") + cmd.AddCommand( + newProfilesListCommand(env), + newProfilesUseCommand(env), + newProfilesRemoveCommand(env), + ) + + return cmd +} + +// profileListing is one profile as `profiles list --json` prints it; the key +// itself stays out. +type profileListing struct { + Name string `json:"name"` + Current bool `json:"current"` + RouterBaseURL string `json:"router_base_url"` +} + +func newProfilesListCommand(env *environment) *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List the saved profiles", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + out := cmd.OutOrStdout() + + listings := make([]profileListing, 0, len(env.store.Profiles)) + for _, name := range env.store.Names() { + listings = append(listings, profileListing{ + Name: name, + Current: name == env.store.Current, + RouterBaseURL: env.store.Profiles[name].RouterBaseURL, + }) + } + + if jsonOutput(cmd) { + return printJSON(out, listings) + } + if len(listings) == 0 { + _, err := fmt.Fprintln(out, "No profiles yet. Run `requesty login` to sign in.") + return err + } + + rows := make([][]string, 0, len(listings)) + for _, listing := range listings { + marker := "" + if listing.Current { + marker = "*" + } + rows = append(rows, []string{marker, listing.Name, listing.RouterBaseURL}) + } + + return writeTable(out, []string{"", "NAME", "ROUTER"}, rows) + }, + } +} + +func newProfilesUseCommand(env *environment) *cobra.Command { + return &cobra.Command{ + Use: "use ", + Short: "Make a profile the current one", + Long: "Make a profile the one used when none is named with --" + profileFlag + ".", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + name := args[0] + if err := env.store.Use(name); err != nil { + return err + } + if err := config.Save(env.store); err != nil { + return err + } + + _, err := fmt.Fprintf(cmd.OutOrStdout(), "Profile %q is now current.\n", name) + return err + }, + } +} + +func newProfilesRemoveCommand(env *environment) *cobra.Command { + return &cobra.Command{ + Use: "remove ", + Aliases: []string{"rm", "delete"}, + Short: "Forget a profile on this machine", + Long: "Forget a profile on this machine. The API key itself is not revoked; do that on\n" + + onboarding.APIKeysURL + " if it should stop working.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + name := args[0] + if err := env.store.Remove(name); err != nil { + return err + } + if err := config.Save(env.store); err != nil { + return err + } + + _, err := fmt.Fprintf(cmd.OutOrStdout(), "Removed profile %q. Its API key still exists; revoke it at %s if needed.\n", + name, onboarding.APIKeysURL) + return err + }, + } +} diff --git a/cmd/profiles_test.go b/cmd/profiles_test.go new file mode 100644 index 0000000..12cc69b --- /dev/null +++ b/cmd/profiles_test.go @@ -0,0 +1,91 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/requestyai/cli/internal/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// runProfiles runs `requesty profiles ` against a copy of store. +func runProfiles(t *testing.T, store config.Store, args ...string) (string, error) { + t.Helper() + + var output bytes.Buffer + command := newRootCommand(&environment{store: clone(store)}) + command.SetOut(&output) + command.SetArgs(append([]string{"profiles"}, args...)) + err := command.Execute() + + return output.String(), err +} + +func TestProfilesListTable(t *testing.T) { + out, err := runProfiles(t, twoProfiles, "list") + + require.NoError(t, err) + assert.Contains(t, out, "NAME") + assert.Contains(t, out, "* engineering") + assert.Contains(t, out, "personal") + assert.Contains(t, out, config.DefaultRouterBaseURL) + assert.NotContains(t, out, "rqsty-", "keys stay out of listings") +} + +func TestProfilesListJSON(t *testing.T) { + out, err := runProfiles(t, twoProfiles, "list", "--json") + + require.NoError(t, err) + var listings []profileListing + require.NoError(t, json.Unmarshal([]byte(out), &listings)) + assert.Equal(t, []profileListing{ + {Name: "engineering", Current: true, RouterBaseURL: config.DefaultRouterBaseURL}, + {Name: "personal", RouterBaseURL: config.DefaultRouterBaseURL}, + }, listings) +} + +func TestProfilesListEmpty(t *testing.T) { + out, err := runProfiles(t, config.Store{}, "list") + + require.NoError(t, err) + assert.Contains(t, out, "No profiles yet") +} + +func TestProfilesUseSetsCurrent(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + out, err := runProfiles(t, twoProfiles, "use", "personal") + + require.NoError(t, err) + assert.Contains(t, out, `"personal" is now current`) + saved, err := config.Load() + require.NoError(t, err) + assert.Equal(t, "personal", saved.Current) +} + +func TestProfilesUseUnknown(t *testing.T) { + _, err := runProfiles(t, twoProfiles, "use", "sales") + + require.ErrorContains(t, err, `profile "sales" not found; saved profiles: engineering, personal`) +} + +func TestProfilesRemoveClearsCurrent(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + out, err := runProfiles(t, twoProfiles, "remove", "engineering") + + require.NoError(t, err) + assert.Contains(t, out, `Removed profile "engineering"`) + saved, err := config.Load() + require.NoError(t, err) + assert.Equal(t, []string{"personal"}, saved.Names()) + assert.Equal(t, "personal", saved.Current) +} + +func TestProfilesRemoveUnknown(t *testing.T) { + _, err := runProfiles(t, twoProfiles, "remove", "sales") + + require.ErrorContains(t, err, `profile "sales" not found`) +} diff --git a/cmd/requesty.go b/cmd/requesty.go index 997dbb4..ad47fca 100644 --- a/cmd/requesty.go +++ b/cmd/requesty.go @@ -4,8 +4,6 @@ import ( "fmt" tea "charm.land/bubbletea/v2" - "github.com/requestyai/cli/internal/client" - "github.com/requestyai/cli/internal/config" "github.com/requestyai/cli/internal/tui" "github.com/spf13/cobra" ) @@ -20,50 +18,42 @@ func Run() error { return newRootCommand(env).Execute() } -// environment is everything the commands need from the outside world, kept -// behind function fields so tests can stand in for the gateway and the UI. -type environment struct { - config config.Config - apiv2Client *client.Client -} - -func newEnvironment() (environment, error) { - cfg, err := config.Load() - if err != nil { - return environment{}, fmt.Errorf("failed to load config: %w", err) - } - - return environment{ - config: cfg, - apiv2Client: client.New(cfg), - }, nil -} - -func newRootCommand(env environment) *cobra.Command { +func newRootCommand(env *environment) *cobra.Command { root := &cobra.Command{ Use: "requesty", Short: "Point your AI coding harnesses at Requesty", Long: "Requesty routes every AI coding harness on your machine through one gateway.\n\n" + "Run with no arguments for the terminal app that configures harnesses and shows\n" + - "what you are spending. The subcommands manage your organization instead.", + "what you are spending. The subcommands manage your organization instead.\n\n" + + "Every command runs as one saved profile: an API key and its router. Name one\n" + + "with --" + profileFlag + "; otherwise the current profile is used.", Args: cobra.NoArgs, SilenceUsage: true, SilenceErrors: true, - RunE: func(_ *cobra.Command, _ []string) error { - if _, err := tea.NewProgram(tui.NewRoot(env.config)).Run(); err != nil { + RunE: func(cmd *cobra.Command, _ []string) error { + cfg, err := env.ensureProfile(cmd, "", "") + if err != nil { + return err + } + + if _, err := tea.NewProgram(tui.NewRoot(cfg)).Run(); err != nil { return fmt.Errorf("failed to run program: %w", err) } return nil }, } + root.PersistentFlags().String(profileFlag, "", "saved profile to run as") root.AddCommand( + newLoginCommand(env), newAuthCommand(env), + newProfilesCommand(env), newAPIKeysCommand(env), newGroupsCommand(env), newAccessListsCommand(env), ) + root.AddCommand(newHarnessCommands(env)...) return root } diff --git a/cmd/requesty_test.go b/cmd/requesty_test.go index 6803a00..1305d46 100644 --- a/cmd/requesty_test.go +++ b/cmd/requesty_test.go @@ -2,6 +2,9 @@ package cmd import ( "bytes" + "maps" + "net/http" + "net/http/httptest" "testing" "github.com/requestyai/cli/internal/config" @@ -9,11 +12,32 @@ import ( "github.com/stretchr/testify/require" ) -func TestAuthTokenPrintsAPIKey(t *testing.T) { +var ( + oneProfile = config.Store{ + Current: "work", + Profiles: map[string]config.Config{ + "work": {APIKey: "my-api-key", RouterBaseURL: config.DefaultRouterBaseURL}, + }, + } + twoProfiles = config.Store{ + Current: "engineering", + Profiles: map[string]config.Config{ + "engineering": {APIKey: "rqsty-eng", RouterBaseURL: config.DefaultRouterBaseURL}, + "personal": {APIKey: "rqsty-me", RouterBaseURL: config.DefaultRouterBaseURL}, + }, + } +) + +// clone copies a fixture so a command that writes profiles does not change +// it for other tests. +func clone(store config.Store) config.Store { + store.Profiles = maps.Clone(store.Profiles) + return store +} + +func TestAuthTokenPrintsCurrentProfileKey(t *testing.T) { var output bytes.Buffer - command := newRootCommand(environment{ - config: config.Config{APIKey: "my-api-key"}, - }) + command := newRootCommand(&environment{store: oneProfile}) command.SetOut(&output) command.SetArgs([]string{"auth", "token"}) @@ -21,17 +45,49 @@ func TestAuthTokenPrintsAPIKey(t *testing.T) { assert.Equal(t, "my-api-key\n", output.String()) } -func TestAuthTokenRejectsMissingAPIKey(t *testing.T) { - command := newRootCommand(environment{}) +func TestAuthTokenPicksExplicitProfile(t *testing.T) { + var output bytes.Buffer + command := newRootCommand(&environment{store: twoProfiles}) + command.SetOut(&output) + command.SetArgs([]string{"auth", "token", "--profile", "personal"}) + + require.NoError(t, command.Execute()) + assert.Equal(t, "rqsty-me\n", output.String()) +} + +func TestAuthTokenRejectsMissingProfile(t *testing.T) { + command := newRootCommand(&environment{}) command.SetArgs([]string{"auth", "token"}) err := command.Execute() - require.EqualError(t, err, "no Requesty API key configured") + require.ErrorIs(t, err, config.ErrNoProfiles) +} + +func TestManagementCommandUsesSelectedProfile(t *testing.T) { + var authorization string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authorization = r.Header.Get("Authorization") + _, _ = w.Write([]byte(`{"keys":[]}`)) + })) + t.Cleanup(server.Close) + + store := config.Store{ + Current: "work", + Profiles: map[string]config.Config{ + "work": {APIKey: "rqsty-work", RouterBaseURL: server.URL}, + "personal": {APIKey: "rqsty-me", RouterBaseURL: server.URL}, + }, + } + command := newRootCommand(&environment{store: store}) + command.SetArgs([]string{"api-keys", "list", "--profile", "personal"}) + + require.NoError(t, command.Execute()) + assert.Equal(t, "Bearer rqsty-me", authorization) } func TestAuthCommandIsHidden(t *testing.T) { - command := newRootCommand(environment{}) + command := newRootCommand(&environment{}) auth, _, err := command.Find([]string{"auth"}) diff --git a/internal/client/api_keys.go b/internal/client/api_keys.go index 89c6d2e..cee4cd7 100644 --- a/internal/client/api_keys.go +++ b/internal/client/api_keys.go @@ -71,6 +71,7 @@ type CreateAPIKeyInput struct { Name string MonthlyLimit *decimal.Decimal Permissions *APIKeyPermissions + GroupID string } // CreatedAPIKey carries the secret the API returns once and never again. @@ -122,10 +123,12 @@ func (c *Client) CreateAPIKey(ctx context.Context, input CreateAPIKeyInput) (Cre Name string `json:"name"` MonthlyLimit *decimal.Decimal `json:"monthly_limit,omitempty"` Permissions *APIKeyPermissions `json:"permissions,omitempty"` + GroupID string `json:"group_id,omitempty"` }{ Name: input.Name, MonthlyLimit: input.MonthlyLimit, Permissions: input.Permissions, + GroupID: input.GroupID, } var created CreatedAPIKey diff --git a/internal/client/api_keys_test.go b/internal/client/api_keys_test.go index 0e6e7d2..25e64d9 100644 --- a/internal/client/api_keys_test.go +++ b/internal/client/api_keys_test.go @@ -48,7 +48,7 @@ func newTestClient(t *testing.T, status int, reply string) (*Client, *recorder) })) t.Cleanup(server.Close) - return New(config.Config{APIBaseURL: server.URL, APIKey: "test-key"}), seen + return New(config.Config{RouterBaseURL: server.URL, APIKey: "test-key"}), seen } func mustParseTime(t *testing.T, value string) *time.Time { @@ -135,6 +135,15 @@ func TestClientCreateAPIKey(t *testing.T) { assert.Equal(t, CreatedAPIKey{ID: "key-1", Secret: "rqsty-secret"}, created) } +func TestClientCreateAPIKeyInGroup(t *testing.T) { + client, seen := newTestClient(t, http.StatusOK, `{"api_key_id":"key-1","api_key":"rqsty-secret"}`) + + _, err := client.CreateAPIKey(context.Background(), CreateAPIKeyInput{Name: "cli", GroupID: "group-1"}) + + require.NoError(t, err) + assert.JSONEq(t, `{"name":"cli","group_id":"group-1"}`, seen.body) +} + func TestClientCreateAPIKeyOmitsUnsetFields(t *testing.T) { client, seen := newTestClient(t, http.StatusOK, `{"api_key_id":"key-1","api_key":"rqsty-secret"}`) @@ -245,7 +254,7 @@ func TestClientAPIKeysUnauthenticated(t *testing.T) { })) defer server.Close() - client := New(config.Config{APIBaseURL: server.URL}) + client := New(config.Config{RouterBaseURL: server.URL}) keys, err := client.APIKeys(context.Background()) require.NoError(t, err) @@ -261,7 +270,7 @@ func TestClientCreateAPIKeySendsJSONContentType(t *testing.T) { })) defer server.Close() - client := New(config.Config{APIBaseURL: server.URL, APIKey: "test-key"}) + client := New(config.Config{RouterBaseURL: server.URL, APIKey: "test-key"}) _, err := client.CreateAPIKey(context.Background(), CreateAPIKeyInput{Name: "production"}) require.NoError(t, err) diff --git a/internal/client/auth.go b/internal/client/auth.go index 1f57483..f00cfa7 100644 --- a/internal/client/auth.go +++ b/internal/client/auth.go @@ -13,12 +13,7 @@ var ErrInvalidAPIKey = errors.New("invalid api key") // CheckAPIKey asks the gateway whether the configured key is a live one of its // own, rejecting a key that was never issued, was deleted, or has expired. func (c *Client) CheckAPIKey(ctx context.Context) error { - apiBaseURL, err := c.apiBaseURL() - if err != nil { - return fmt.Errorf("failed to get api base url: %w", err) - } - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiBaseURL+"/v1/auth/check", nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.config.APIBaseURL()+"/v1/auth/check", nil) if err != nil { return fmt.Errorf("failed to create request: %w", err) } diff --git a/internal/client/auth_test.go b/internal/client/auth_test.go index 55ee948..14d95ee 100644 --- a/internal/client/auth_test.go +++ b/internal/client/auth_test.go @@ -31,7 +31,7 @@ func TestClientCheckAPIKey(t *testing.T) { })) defer server.Close() - client := New(config.Config{APIBaseURL: server.URL, APIKey: "test-key"}) + client := New(config.Config{RouterBaseURL: server.URL, APIKey: "test-key"}) err := client.CheckAPIKey(context.Background()) if tc.wantErr == nil { @@ -49,7 +49,7 @@ func TestClientCheckAPIKeyUnexpectedStatus(t *testing.T) { })) defer server.Close() - client := New(config.Config{APIBaseURL: server.URL, APIKey: "test-key"}) + client := New(config.Config{RouterBaseURL: server.URL, APIKey: "test-key"}) err := client.CheckAPIKey(context.Background()) require.Error(t, err) diff --git a/internal/client/client.go b/internal/client/client.go index 6bb8edb..2ae0508 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -30,14 +30,6 @@ func New(cfg config.Config) *Client { } } -func (c *Client) apiBaseURL() (string, error) { - if c.config.APIBaseURL != "" { - return c.config.APIBaseURL, nil - } - - return strings.Replace(c.config.RouterBaseURL, "router", "api-v2", 1), nil -} - func (c *Client) authorize(req *http.Request) { if c.config.APIKey != "" { req.Header.Set("Authorization", "Bearer "+c.config.APIKey) @@ -47,17 +39,12 @@ func (c *Client) authorize(req *http.Request) { // manageURL builds a management API address. Each element is escaped, so an // identifier that came from a user cannot reach into another path. func (c *Client) manageURL(elements ...string) (string, error) { - apiBaseURL, err := c.apiBaseURL() - if err != nil { - return "", fmt.Errorf("failed to get api base url: %w", err) - } - escaped := make([]string, 0, len(elements)) for _, element := range elements { escaped = append(escaped, url.PathEscape(element)) } - endpoint := fmt.Sprintf("%s/v1/manage/%s", apiBaseURL, strings.Join(escaped, "/")) + endpoint := fmt.Sprintf("%s/v1/manage/%s", c.config.APIBaseURL(), strings.Join(escaped, "/")) return endpoint, nil } diff --git a/internal/client/models.go b/internal/client/models.go index d5b3991..64fa24b 100644 --- a/internal/client/models.go +++ b/internal/client/models.go @@ -7,6 +7,8 @@ import ( "net/http" ) +// Model is one entry of a model list. Managed policies are listed in the +// same shape, so they are Models too. type Model struct { ID string `json:"id"` @@ -23,7 +25,15 @@ type Model struct { } func (c *Client) Models(ctx context.Context) ([]Model, error) { - endpoint := fmt.Sprintf("%s/v1/models", c.config.RouterBaseURL) + return c.listModels(ctx, "/v1/models") +} + +func (c *Client) ManagedPolicies(ctx context.Context) ([]Model, error) { + return c.listModels(ctx, "/v1/models/managed") +} + +func (c *Client) listModels(ctx context.Context, path string) ([]Model, error) { + endpoint := c.config.RouterBaseURL + path req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) diff --git a/internal/client/models_test.go b/internal/client/models_test.go index f7a62ce..231bafc 100644 --- a/internal/client/models_test.go +++ b/internal/client/models_test.go @@ -2,6 +2,9 @@ package client import ( "context" + "fmt" + "net/http" + "net/http/httptest" "testing" "github.com/requestyai/cli/internal/config" @@ -16,3 +19,40 @@ func TestClient_Models(t *testing.T) { require.NoError(t, err) assert.GreaterOrEqual(t, len(models), 100) } + +func TestClient_ManagedPolicies(t *testing.T) { + var seen struct{ path, auth string } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seen.path = r.URL.Path + seen.auth = r.Header.Get("Authorization") + _, err := fmt.Fprint(w, `{"object":"list","data":[ + {"id":"claude-sonnet-4-6","context_window":1000000,"max_output_tokens":128000,"input_price":0.000003,"output_price":0.000015}, + {"id":"gpt-5.5","context_window":400000,"input_price":0.0000055,"output_price":0.000033} + ]}`) + require.NoError(t, err) + })) + defer server.Close() + + client := New(config.Config{RouterBaseURL: server.URL, APIKey: "test-key"}) + + policies, err := client.ManagedPolicies(context.Background()) + + require.NoError(t, err) + assert.Equal(t, "/v1/models/managed", seen.path) + assert.Equal(t, "Bearer test-key", seen.auth) + assert.Equal(t, []Model{ + {ID: "claude-sonnet-4-6", ContextWindow: 1_000_000, MaxOutputTokens: 128_000, InputPrice: 0.000003, OutputPrice: 0.000015}, + {ID: "gpt-5.5", ContextWindow: 400_000, InputPrice: 0.0000055, OutputPrice: 0.000033}, + }, policies) +} + +func TestClient_ManagedPoliciesRejectsErrorStatus(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer server.Close() + + _, err := New(config.Config{RouterBaseURL: server.URL, APIKey: "bad"}).ManagedPolicies(context.Background()) + + require.EqualError(t, err, "status code not ok: 401") +} diff --git a/internal/client/usage.go b/internal/client/usage.go index 8342f4f..1cc9f70 100644 --- a/internal/client/usage.go +++ b/internal/client/usage.go @@ -49,12 +49,7 @@ type UsageInput struct { // Usage returns the organization's usage for the requested period. // If End or Resolution is unset, the API's defaults are used. func (c *Client) Usage(ctx context.Context, input UsageInput) (map[string]UsageEntry, error) { - apiBaseURL, err := c.apiBaseURL() - if err != nil { - return nil, fmt.Errorf("failed to get api base url: %w", err) - } - - endpoint, err := url.Parse(fmt.Sprintf("%s/v1/manage/apikey/self/usage", apiBaseURL)) + endpoint, err := url.Parse(fmt.Sprintf("%s/v1/manage/apikey/self/usage", c.config.APIBaseURL())) if err != nil { return nil, fmt.Errorf("failed to parse url: %w", err) } diff --git a/internal/client/usage_test.go b/internal/client/usage_test.go index f6e8d9e..a2caf0c 100644 --- a/internal/client/usage_test.go +++ b/internal/client/usage_test.go @@ -30,7 +30,7 @@ func TestClientUsage(t *testing.T) { })) defer server.Close() - client := New(config.Config{APIBaseURL: server.URL, APIKey: "test-key"}) + client := New(config.Config{RouterBaseURL: server.URL, APIKey: "test-key"}) usage, err := client.Usage(context.Background(), UsageInput{ Start: start, End: end, diff --git a/internal/config/config.go b/internal/config/config.go index bf4c04d..8481c44 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -5,8 +5,12 @@ import ( "errors" "fmt" "io/fs" + "net" + "net/url" "os" "path/filepath" + "sort" + "strings" ) const ( @@ -17,39 +21,188 @@ const ( DefaultAPIBaseURL = "https://api-v2.requesty.ai" ) -// Config is the settings file. +// ErrNoProfiles means nobody has signed in on this machine yet. +var ErrNoProfiles = errors.New("no Requesty profile configured; run `requesty login`") + +// Config is what a client or harness runs with: an API key and the router it +// is sent to. The settings file holds one per profile. type Config struct { + Name string `json:"-"` APIKey string `json:"api_key"` RouterBaseURL string `json:"router_base_url"` - APIBaseURL string `json:"api_base_url,omitempty"` + // HarnessModels is the model each harness was picked to launch with. + HarnessModels map[string]string `json:"harness_models,omitempty"` + // HarnessFastModels is the model each harness hands background work to, + // for harnesses that have such a slot. + HarnessFastModels map[string]string `json:"harness_fast_models,omitempty"` +} + +// SetHarnessModel remembers model as the one to launch harness with. +func (c *Config) SetHarnessModel(harness, model string) { + if c.HarnessModels == nil { + c.HarnessModels = make(map[string]string) + } + c.HarnessModels[harness] = model +} + +// SetHarnessFastModel remembers model as the one harness hands background +// work to. +func (c *Config) SetHarnessFastModel(harness, model string) { + if c.HarnessFastModels == nil { + c.HarnessFastModels = make(map[string]string) + } + c.HarnessFastModels[harness] = model +} + +// APIBaseURL is the management API that corresponds to the router: the host's +// "router" becomes "api-v2" and the local stack's port 40000 becomes 40003. +// The path is left alone, and an address that cannot be parsed comes back as +// it was given. +func (c Config) APIBaseURL() string { + parsed, err := url.Parse(c.RouterBaseURL) + if err != nil || parsed.Host == "" { + return c.RouterBaseURL + } + + host, port := parsed.Hostname(), parsed.Port() + rewritten := strings.Replace(host, "router", "api-v2", 1) + if port == "40000" { + port = "40003" + } + if rewritten == host && port == parsed.Port() { + return c.RouterBaseURL + } + + if port != "" { + parsed.Host = net.JoinHostPort(rewritten, port) + } else { + parsed.Host = rewritten + } + + return parsed.String() +} + +// Store is the saved profiles and which one is current. +type Store struct { + Current string `json:"current"` + Profiles map[string]Config `json:"profiles"` +} + +// Names lists the saved profiles in a stable order. +func (s Store) Names() []string { + names := make([]string, 0, len(s.Profiles)) + for name := range s.Profiles { + names = append(names, name) + } + sort.Strings(names) + + return names +} + +// Resolve returns the named profile, or the current profile when name is +// empty. +func (s Store) Resolve(name string) (Config, error) { + if len(s.Profiles) == 0 { + return Config{}, ErrNoProfiles + } + if name == "" { + name = s.Current + } + + cfg, ok := s.Profiles[name] + if !ok { + return Config{}, fmt.Errorf("profile %q not found; saved profiles: %s", name, strings.Join(s.Names(), ", ")) + } + cfg.Name = name + + return cfg, nil +} + +// Set stores cfg as the named profile, replacing any existing one. +func (s *Store) Set(name string, cfg Config) { + if s.Profiles == nil { + s.Profiles = make(map[string]Config) + } + cfg.Name = "" + s.Profiles[name] = cfg + if s.Current == "" { + s.Current = name + } +} + +// Use makes name the current profile. +func (s *Store) Use(name string) error { + if _, ok := s.Profiles[name]; !ok { + return fmt.Errorf("profile %q not found; saved profiles: %s", name, strings.Join(s.Names(), ", ")) + } + s.Current = name + return nil +} + +// Remove deletes name and keeps Current valid. If the current profile is +// removed, the first remaining profile in name order becomes current. +func (s *Store) Remove(name string) error { + if _, ok := s.Profiles[name]; !ok { + return fmt.Errorf("profile %q not found; saved profiles: %s", name, strings.Join(s.Names(), ", ")) + } + delete(s.Profiles, name) + if s.Current == name { + s.Current = "" + if names := s.Names(); len(names) > 0 { + s.Current = names[0] + } + } + return nil } -// Load reads the settings. A missing file is not an error: it means the user -// has not onboarded yet, so the zero Config comes back. -func Load() (Config, error) { +// DisplayPath is where the settings file lives, for messages: the real +// location when it can be worked out, else the conventional one. +func DisplayPath() string { path, err := configPath() if err != nil { - return Config{}, err + return "~/" + dirName + "/" + fileName } - data, err := os.ReadFile(path) - if errors.Is(err, fs.ErrNotExist) { - return Config{}, nil - } + return path +} + +// Load reads the settings and fills in the production router for profiles +// that leave it blank. A missing file is not an error: it means the user has +// not onboarded yet, so an empty Store comes back. +func Load() (Store, error) { + path, err := configPath() if err != nil { - return Config{}, fmt.Errorf("failed to read config: %w", err) + return Store{}, err + } + + var store Store + data, err := os.ReadFile(path) + switch { + case errors.Is(err, fs.ErrNotExist): + // Not onboarded yet. + case err != nil: + return Store{}, fmt.Errorf("failed to read config: %w", err) + default: + if err := json.Unmarshal(data, &store); err != nil { + return Store{}, fmt.Errorf("failed to parse config: %w", err) + } + if err := validate(store); err != nil { + return Store{}, err + } } - var config Config - if err := json.Unmarshal(data, &config); err != nil { - return Config{}, fmt.Errorf("failed to parse config: %w", err) + for name, cfg := range store.Profiles { + if cfg.RouterBaseURL == "" { + cfg.RouterBaseURL = DefaultRouterBaseURL + store.Profiles[name] = cfg + } } - return config, nil + return store, nil } // Save writes the settings, creating ~/.requesty if it is missing. -func Save(config Config) error { +func Save(store Store) error { path, err := configPath() if err != nil { return err @@ -59,7 +212,7 @@ func Save(config Config) error { return fmt.Errorf("failed to create config directory: %w", err) } - data, err := json.MarshalIndent(config, "", " ") + data, err := json.MarshalIndent(store, "", " ") if err != nil { return fmt.Errorf("failed to encode config: %w", err) } @@ -71,6 +224,21 @@ func Save(config Config) error { return nil } +// validate reports a store that names no current profile, or names one that +// is not saved. An empty store is the not-onboarded state and is fine. +func validate(store Store) error { + if len(store.Profiles) > 0 && store.Current == "" { + return errors.New("current profile is required") + } + if store.Current != "" { + if _, ok := store.Profiles[store.Current]; !ok { + return fmt.Errorf("current profile %q is not saved", store.Current) + } + } + + return nil +} + func configPath() (string, error) { home, err := os.UserHomeDir() if err != nil { diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..e985496 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,196 @@ +package config + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAPIBaseURL(t *testing.T) { + tests := []struct { + name string + router string + want string + }{ + {name: "production", router: DefaultRouterBaseURL, want: DefaultAPIBaseURL}, + {name: "staging", router: "https://router.staging.requesty.ai", want: "https://api-v2.staging.requesty.ai"}, + {name: "local stack moves port", router: "http://localhost:40000", want: "http://localhost:40003"}, + {name: "local stack by ip", router: "http://127.0.0.1:40000", want: "http://127.0.0.1:40003"}, + {name: "hostname and port both rewritten", router: "http://router.local:40000", want: "http://api-v2.local:40003"}, + {name: "other port is left alone", router: "http://127.0.0.1:55123", want: "http://127.0.0.1:55123"}, + {name: "unrelated host is left alone", router: "https://gateway.example.test", want: "https://gateway.example.test"}, + {name: "path and trailing slash survive", router: "https://router.requesty.ai/prefix/", want: "https://api-v2.requesty.ai/prefix/"}, + {name: "router in path is not touched", router: "https://gateway.example.test/router", want: "https://gateway.example.test/router"}, + {name: "unparseable address comes back as is", router: "http://[::1", want: "http://[::1"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, Config{RouterBaseURL: tc.router}.APIBaseURL()) + }) + } +} + +func TestLoad(t *testing.T) { + tests := []struct { + name string + file string // empty means no file on disk + want Store + }{ + { + name: "missing file means no profiles", + want: Store{}, + }, + { + name: "blank router is filled in, key kept", + file: `{"current": "work", "profiles": {"work": {"api_key": "rqsty-old"}}}`, + want: Store{Current: "work", Profiles: map[string]Config{"work": {APIKey: "rqsty-old", RouterBaseURL: DefaultRouterBaseURL}}}, + }, + { + name: "explicit router is returned verbatim", + file: `{"current": "local", "profiles": {"local": {"api_key": "rqsty-old", "router_base_url": "http://localhost:40000"}}}`, + want: Store{Current: "local", Profiles: map[string]Config{"local": {APIKey: "rqsty-old", RouterBaseURL: "http://localhost:40000"}}}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + if tc.file != "" { + dir := filepath.Join(home, dirName) + require.NoError(t, os.MkdirAll(dir, 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(dir, fileName), []byte(tc.file), 0o600)) + } + + got, err := Load() + + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestLoadRejectsMalformedFile(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + dir := filepath.Join(home, dirName) + require.NoError(t, os.MkdirAll(dir, 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(dir, fileName), []byte("{not json"), 0o600)) + + _, err := Load() + + require.ErrorContains(t, err, "failed to parse config") +} + +func TestLoadRejectsInvalidStore(t *testing.T) { + tests := []struct { + name string + file string + want string + }{ + { + name: "profiles without current", + file: `{"profiles":{"work":{"api_key":"k"}}}`, + want: "current profile is required", + }, + { + name: "unknown current", + file: `{"current":"gone","profiles":{"work":{"api_key":"k"}}}`, + want: `current profile "gone" is not saved`, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + dir := filepath.Join(home, dirName) + require.NoError(t, os.MkdirAll(dir, 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(dir, fileName), []byte(tc.file), 0o600)) + + _, err := Load() + + require.ErrorContains(t, err, tc.want) + }) + } +} + +func TestSaveRoundTrips(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + var store Store + work := Config{APIKey: "rqsty-work", RouterBaseURL: "https://router.eu.example"} + work.SetHarnessModel("claude", "claude-sonnet-4-6") + work.SetHarnessFastModel("claude", "claude-haiku-4-5") + store.Set("work", work) + + require.NoError(t, Save(store)) + got, err := Load() + + require.NoError(t, err) + assert.Equal(t, store, got) + assert.Equal(t, "claude-sonnet-4-6", got.Profiles["work"].HarnessModels["claude"]) + assert.Equal(t, "claude-haiku-4-5", got.Profiles["work"].HarnessFastModels["claude"]) +} + +func TestHarnessModelsAreOmittedUntilSet(t *testing.T) { + data, err := json.Marshal(Config{APIKey: "k", RouterBaseURL: DefaultRouterBaseURL}) + + require.NoError(t, err) + assert.NotContains(t, string(data), "harness_models") + assert.NotContains(t, string(data), "harness_fast_models") +} + +func TestResolve(t *testing.T) { + work := Config{APIKey: "rqsty-work", RouterBaseURL: DefaultRouterBaseURL} + personal := Config{APIKey: "rqsty-me", RouterBaseURL: DefaultRouterBaseURL} + store := Store{Current: "work", Profiles: map[string]Config{"work": work, "personal": personal}} + + tests := []struct { + name string + store Store + ask string + wantName string + wantErr string + }{ + {name: "explicit name", store: store, ask: "personal", wantName: "personal"}, + {name: "current when unnamed", store: store, wantName: "work"}, + {name: "nothing saved", store: Store{}, wantErr: ErrNoProfiles.Error()}, + {name: "unknown name", store: store, ask: "sales", wantErr: `profile "sales" not found; saved profiles: personal, work`}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cfg, err := tc.store.Resolve(tc.ask) + if tc.wantErr != "" { + require.ErrorContains(t, err, tc.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tc.wantName, cfg.Name) + assert.Equal(t, tc.store.Profiles[tc.wantName].APIKey, cfg.APIKey) + assert.Equal(t, tc.store.Profiles[tc.wantName].RouterBaseURL, cfg.RouterBaseURL) + }) + } +} + +func TestNamesAreSorted(t *testing.T) { + store := Store{Profiles: map[string]Config{"z": {}, "a": {}}} + assert.Equal(t, []string{"a", "z"}, store.Names()) +} + +func TestRemoveCurrentSelectsNextProfile(t *testing.T) { + store := Store{ + Current: "work", + Profiles: map[string]Config{"work": {}, "personal": {}, "admin": {}}, + } + + require.NoError(t, store.Remove("work")) + + assert.Equal(t, "admin", store.Current) + assert.NotContains(t, store.Profiles, "work") +} diff --git a/internal/harnesses/claude.go b/internal/harnesses/claude.go index 2235f83..3149366 100644 --- a/internal/harnesses/claude.go +++ b/internal/harnesses/claude.go @@ -7,6 +7,8 @@ import ( "os" "os/exec" "path/filepath" + "runtime" + "strings" "github.com/requestyai/cli/internal/config" ) @@ -50,8 +52,9 @@ func (c *ClaudeHarness) Description() []string { func (c *ClaudeHarness) Status() (Status, error) { status := Status{} - if _, err := exec.LookPath("claude"); err == nil { + if path, err := exec.LookPath("claude"); err == nil { status.Executable = true + status.ExecutablePath = path } else if !errors.Is(err, exec.ErrNotFound) { return status, fmt.Errorf("failed to find executable: %w", err) } @@ -135,3 +138,116 @@ func (c *ClaudeHarness) configureOverwrite(opts ConfigureOptions) error { func (c *ClaudeHarness) settingsPath() string { return filepath.Join(c.configDir, "settings.json") } + +// claudeOverrideSettings are passed inline with --settings so they outrank +// the user's own settings.json for this run only. Every alternative-provider +// switch is blanked so a Bedrock or Vertex setup on the machine cannot route +// around Requesty; the credential itself stays out of argv and travels in +// the environment. +var claudeOverrideSettings = map[string]string{ + "ANTHROPIC_AUTH_TOKEN": "", + "ANTHROPIC_AWS_BASE_URL": "", + "ANTHROPIC_BEDROCK_BASE_URL": "", + "ANTHROPIC_BEDROCK_MANTLE_BASE_URL": "", + "ANTHROPIC_FOUNDRY_BASE_URL": "", + "ANTHROPIC_GOOGLE_CLOUD_BASE_URL": "", + "ANTHROPIC_UNIX_SOCKET": "", + "ANTHROPIC_VERTEX_BASE_URL": "", + "CLAUDE_CODE_OAUTH_TOKEN": "", + "CLAUDE_CODE_USE_ANTHROPIC_AWS": "", + "CLAUDE_CODE_USE_ANTHROPIC_GOOGLE_CLOUD": "", + "CLAUDE_CODE_USE_BEDROCK": "", + "CLAUDE_CODE_USE_FOUNDRY": "", + "CLAUDE_CODE_USE_GATEWAY": "", + "CLAUDE_CODE_USE_MANTLE": "", + "CLAUDE_CODE_USE_VERTEX": "", +} + +// claudeSettingsFlags are Claude Code flags that mean the user is managing +// settings themselves, in which case we do not add our own --settings. +var claudeSettingsFlags = []string{"--settings", "--setting-sources"} + +// Launch replaces this process with Claude Code pointed at Requesty. The base +// URL and key go in the environment; an inline --settings document pins the +// base URL above the user's settings.json and disables every other provider +// path. Nothing is written to disk. +func (c *ClaudeHarness) Launch(opts LaunchOptions) error { + status, err := c.Status() + if err != nil { + return fmt.Errorf("failed to check %s: %w", c.Name(), err) + } + if !status.Executable { + return fmt.Errorf("`claude` is not on PATH; install Claude Code (https://code.claude.com/docs/en/setup) and try again") + } + + env := parseEnvironmentVariables(opts.Env) + env["ANTHROPIC_BASE_URL"] = c.config.RouterBaseURL + env["ANTHROPIC_API_KEY"] = c.config.APIKey + env["REQUESTY_API_KEY"] = c.config.APIKey + delete(env, "ANTHROPIC_AUTH_TOKEN") + // The fast-mode check calls an Anthropic-only org endpoint that a gateway + // cannot answer; skipping it avoids a startup warning. + env["CLAUDE_CODE_SKIP_FAST_MODE_ORG_CHECK"] = "1" + if opts.Model != "" { + env["ANTHROPIC_MODEL"] = opts.Model + } + if opts.FastModel != "" { + // The haiku alias covers background work (titles, summaries, + // subagents declared as haiku). ANTHROPIC_SMALL_FAST_MODEL is the + // name versions before 2.1.2 read; both get the same value. + env["ANTHROPIC_DEFAULT_HAIKU_MODEL"] = opts.FastModel + env["ANTHROPIC_SMALL_FAST_MODEL"] = opts.FastModel + } + + argv := []string{"claude"} + if !hasAnyFlag(opts.Args, claudeSettingsFlags) { + settings, err := c.inlineSettings() + if err != nil { + return err + } + argv = append(argv, "--settings", settings) + } + argv = append(argv, opts.Args...) + + if err := execProcess(status.ExecutablePath, argv, formatEnvironmentVariables(env)); err != nil { + return fmt.Errorf("failed to launch claude: %w", err) + } + + return nil +} + +func (c *ClaudeHarness) inlineSettings() (string, error) { + settingsEnv := make(map[string]string, len(claudeOverrideSettings)+1) + for key, value := range claudeOverrideSettings { + settingsEnv[key] = value + } + settingsEnv["ANTHROPIC_BASE_URL"] = c.config.RouterBaseURL + + settings := map[string]any{"env": settingsEnv} + if runtime.GOOS != "windows" { + // Claude Code runs the helper through a shell, which Windows lacks a + // reliable one for; there the ANTHROPIC_API_KEY variable carries it. + settings["apiKeyHelper"] = `printf %s "$REQUESTY_API_KEY"` + } + + data, err := json.Marshal(settings) + if err != nil { + return "", fmt.Errorf("failed to encode inline settings: %w", err) + } + + return string(data), nil +} + +// hasAnyFlag reports whether args names one of flags, as `--flag` or +// `--flag=value`. +func hasAnyFlag(args []string, flags []string) bool { + for _, arg := range args { + for _, flag := range flags { + if arg == flag || strings.HasPrefix(arg, flag+"=") { + return true + } + } + } + + return false +} diff --git a/internal/harnesses/codex.go b/internal/harnesses/codex.go index 06fb148..2dee932 100644 --- a/internal/harnesses/codex.go +++ b/internal/harnesses/codex.go @@ -6,6 +6,8 @@ import ( "os" "os/exec" "path/filepath" + "strconv" + "strings" "github.com/pelletier/go-toml/v2" "github.com/requestyai/cli/internal/config" @@ -69,8 +71,9 @@ func (c *CodexHarness) Description() []string { func (c *CodexHarness) Status() (Status, error) { status := Status{} - if _, err := exec.LookPath("codex"); err == nil { + if path, err := exec.LookPath("codex"); err == nil { status.Executable = true + status.ExecutablePath = path } else if !errors.Is(err, exec.ErrNotFound) { return status, fmt.Errorf("failed to find executable: %w", err) } @@ -137,7 +140,7 @@ func (c *CodexHarness) configureMerge(opts ConfigureOptions) error { }, "auth": map[string]any{ "command": "requesty", - "args": []string{"auth", "token"}, + "args": c.authArgs(), }, }, }, @@ -166,7 +169,7 @@ func (c *CodexHarness) configureOverwrite(opts ConfigureOptions) error { }, Auth: codexProviderAuth{ Command: "requesty", - Args: []string{"auth", "token"}, + Args: c.authArgs(), }, }, }, @@ -186,3 +189,59 @@ func (c *CodexHarness) configureOverwrite(opts ConfigureOptions) error { func (c *CodexHarness) configPath() string { return filepath.Join(c.configDir, "config.toml") } + +func (c *CodexHarness) authArgs() []string { + return []string{"auth", "token", "--profile", c.config.Name} +} + +// Launch replaces this process with Codex pointed at Requesty using +// `-c key=value` overrides, which outrank ~/.codex/config.toml for this run +// only. Nothing is written to disk; the key is fetched on demand through +// `requesty auth token`, so it appears in neither argv nor the environment. +func (c *CodexHarness) Launch(opts LaunchOptions) error { + status, err := c.Status() + if err != nil { + return fmt.Errorf("failed to check %s: %w", c.Name(), err) + } + if !status.Executable { + return fmt.Errorf("`codex` is not on PATH; install Codex (https://developers.openai.com/codex/cli) and try again") + } + + provider := "model_providers." + codexModelProvider + argv := []string{ + "codex", + "-c", "model_provider=" + tomlString(codexModelProvider), + "-c", provider + ".name=" + tomlString("Requesty"), + "-c", provider + ".base_url=" + tomlString(c.config.RouterBaseURL+"/v1"), + "-c", provider + ".http_headers.X-Title=" + tomlString("OpenAI Codex"), + "-c", provider + ".auth.command=" + tomlString(requestyExecutable()), + "-c", provider + ".auth.args=[" + tomlString("auth") + "," + tomlString("token") + "," + tomlString("--profile") + "," + tomlString(c.config.Name) + "]", + "-c", "model_supports_reasoning_summaries=false", + } + if opts.Model != "" { + argv = append(argv, "-m", opts.Model) + } + argv = append(argv, opts.Args...) + + env := opts.Env + if env == nil { + env = os.Environ() + } + + if err := execProcess(status.ExecutablePath, argv, env); err != nil { + return fmt.Errorf("failed to launch codex: %w", err) + } + + return nil +} + +// tomlString quotes s as a TOML string for a Codex -c override. Codex parses +// the value side as TOML, so unquoted text with spaces or slashes would be +// rejected. Literal strings are preferred because they need no escaping. +func tomlString(s string) string { + if !strings.ContainsAny(s, "'\n\r") { + return "'" + s + "'" + } + + return strconv.Quote(s) +} diff --git a/internal/harnesses/codex_test.go b/internal/harnesses/codex_test.go index 45f7036..750527a 100644 --- a/internal/harnesses/codex_test.go +++ b/internal/harnesses/codex_test.go @@ -3,6 +3,7 @@ package harnesses import ( "os" "path/filepath" + "strings" "testing" "github.com/pelletier/go-toml/v2" @@ -12,7 +13,8 @@ import ( ) func TestCodexIntegrationRoundTrip(t *testing.T) { - config := config.Config{ + cfg := config.Config{ + Name: "work", RouterBaseURL: "https://router.requesty.ai", APIKey: "my-api-key", } @@ -22,7 +24,7 @@ func TestCodexIntegrationRoundTrip(t *testing.T) { configPath := filepath.Join(configDir, "config.toml") require.NoError(t, os.WriteFile(configPath, []byte("model = \"gpt-5.5\"\n"), 0o600)) - harness := NewCodexHarness(config, configDir) + harness := NewCodexHarness(cfg, configDir) status, err := harness.Status() require.NoError(t, err) @@ -42,13 +44,14 @@ func TestCodexIntegrationRoundTrip(t *testing.T) { } func TestCodexHarnessConfigureCreatesMissingConfig(t *testing.T) { - config := config.Config{ + cfg := config.Config{ + Name: "work", RouterBaseURL: "https://router.requesty.ai", APIKey: "my-api-key", } configDir := t.TempDir() configPath := filepath.Join(configDir, "config.toml") - harness := NewCodexHarness(config, configDir) + harness := NewCodexHarness(cfg, configDir) require.NoError(t, harness.Configure(ConfigureOptions{ Model: "openai-responses/gpt-5.5", @@ -63,7 +66,7 @@ func TestCodexHarnessConfigureCreatesMissingConfig(t *testing.T) { assert.Equal(t, "https://router.requesty.ai/v1", parsedConfig.ModelProviders[codexModelProvider].BaseURL) assert.Equal(t, codexProviderAuth{ Command: "requesty", - Args: []string{"auth", "token"}, + Args: []string{"auth", "token", "--profile", "work"}, }, parsedConfig.ModelProviders[codexModelProvider].Auth) _, err = os.Stat(filepath.Join(configDir, "auth.json")) assert.ErrorIs(t, err, os.ErrNotExist) @@ -71,6 +74,7 @@ func TestCodexHarnessConfigureCreatesMissingConfig(t *testing.T) { func TestCodexHarnessConfigureOverwriteUsesProviderAuth(t *testing.T) { cfg := config.Config{ + Name: "work", RouterBaseURL: "https://router.requesty.ai", APIKey: "my-api-key", } @@ -88,12 +92,50 @@ func TestCodexHarnessConfigureOverwriteUsesProviderAuth(t *testing.T) { require.NoError(t, toml.Unmarshal(configBytes, &parsedConfig)) assert.Equal(t, codexProviderAuth{ Command: "requesty", - Args: []string{"auth", "token"}, + Args: []string{"auth", "token", "--profile", "work"}, }, parsedConfig.ModelProviders[codexModelProvider].Auth) _, err = os.Stat(filepath.Join(configDir, "auth.json")) assert.ErrorIs(t, err, os.ErrNotExist) } +// codexOverrides collects the `-c key=value` pairs from a launch and checks +// each value parses the way Codex parses it: as the right-hand side of a TOML +// assignment. +func codexOverrides(t *testing.T, args []string) map[string]any { + t.Helper() + + overrides := map[string]any{} + for i := 0; i+1 < len(args); i++ { + if args[i] != "-c" { + continue + } + key, value, found := strings.Cut(args[i+1], "=") + require.True(t, found, "override %q has no '='", args[i+1]) + + var parsed struct { + Value any `toml:"value"` + } + require.NoError(t, toml.Unmarshal([]byte("value = "+value), &parsed), "override %q is not valid TOML", args[i+1]) + overrides[key] = parsed.Value + } + + return overrides +} + +func TestTomlStringQuotesForCodexOverrides(t *testing.T) { + assert.Equal(t, `'plain'`, tomlString("plain")) + assert.Equal(t, `'/Users/me/My Tools/requesty'`, tomlString("/Users/me/My Tools/requesty")) + assert.Equal(t, `"it's"`, tomlString("it's")) + + for _, value := range []string{"plain", "/Users/me/My Tools/requesty", "it's", `C:\Program Files\requesty.exe`} { + var parsed struct { + Value string `toml:"value"` + } + require.NoError(t, toml.Unmarshal([]byte("value = "+tomlString(value)), &parsed)) + assert.Equal(t, value, parsed.Value) + } +} + func TestCodexHarnessDefaultConfigDir(t *testing.T) { homePath, err := os.UserHomeDir() require.NoError(t, err) diff --git a/internal/harnesses/deepseek.go b/internal/harnesses/deepseek.go index 0519bc5..cf2e323 100644 --- a/internal/harnesses/deepseek.go +++ b/internal/harnesses/deepseek.go @@ -154,6 +154,10 @@ func (d *DeepSeekHarness) Status() (Status, error) { return status, nil } +func (d *DeepSeekHarness) Launch(LaunchOptions) error { + return launchNotImplemented(d.Name()) +} + func (d *DeepSeekHarness) Configure(opts ConfigureOptions) error { if opts.Overwrite { return d.configureOverwrite(opts) diff --git a/internal/harnesses/exec_unix.go b/internal/harnesses/exec_unix.go new file mode 100644 index 0000000..e66858e --- /dev/null +++ b/internal/harnesses/exec_unix.go @@ -0,0 +1,12 @@ +//go:build !windows + +package harnesses + +import "syscall" + +// execProcess replaces the current process with the harness, so the harness +// owns the terminal directly and requesty leaves no wrapper behind. It is a +// variable so tests can capture the launch instead of performing it. +var execProcess = func(path string, argv []string, env []string) error { + return syscall.Exec(path, argv, env) +} diff --git a/internal/harnesses/exec_windows.go b/internal/harnesses/exec_windows.go new file mode 100644 index 0000000..ee19135 --- /dev/null +++ b/internal/harnesses/exec_windows.go @@ -0,0 +1,32 @@ +//go:build windows + +package harnesses + +import ( + "errors" + "os" + "os/exec" +) + +// execProcess runs the harness as a child with our terminal and exits with +// its status once it finishes. Windows has no exec(2), so this is the closest +// equivalent. It is a variable so tests can capture the launch instead. +var execProcess = func(path string, argv []string, env []string) error { + cmd := exec.Command(path, argv[1:]...) + cmd.Env = env + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + err := cmd.Run() + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + os.Exit(exitErr.ExitCode()) + } + if err != nil { + return err + } + + os.Exit(0) + return nil +} diff --git a/internal/harnesses/harnesses.go b/internal/harnesses/harnesses.go index 0b0ac63..d3c85e5 100644 --- a/internal/harnesses/harnesses.go +++ b/internal/harnesses/harnesses.go @@ -9,7 +9,9 @@ import ( type Status struct { Files []string Executable bool - Configured bool + + ExecutablePath string + Configured bool } type ConfigureOptions struct { @@ -17,11 +19,25 @@ type ConfigureOptions struct { Overwrite bool } +type LaunchOptions struct { + // Model is what the harness is told to use: a managed policy or any + // Requesty model id. + Model string + // FastModel is what the harness hands background work to, such as + // Claude Code's haiku alias. + FastModel string + // Args is passed to the harness binary untouched, after our own flags. + Args []string + // Env is the environment to start from. nil means os.Environ(). + Env []string +} + type Harness interface { Name() string Description() []string Status() (Status, error) Configure(ConfigureOptions) error + Launch(LaunchOptions) error } func Harnesses(config config.Config) ([]Harness, error) { diff --git a/internal/harnesses/hermes.go b/internal/harnesses/hermes.go index 6e1adfb..0eaaafb 100644 --- a/internal/harnesses/hermes.go +++ b/internal/harnesses/hermes.go @@ -6,6 +6,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "github.com/requestyai/cli/internal/config" "gopkg.in/yaml.v3" @@ -42,8 +43,13 @@ type HermesHarness struct { configDir string } -// DefaultConfigDirHermes is where Hermes keeps its configuration. +// DefaultConfigDirHermes is where Hermes keeps its configuration. Hermes +// reads HERMES_HOME first, so an explicit home wins over the default one. func DefaultConfigDirHermes() (string, error) { + if home := os.Getenv("HERMES_HOME"); home != "" { + return home, nil + } + return configDirInHome(".hermes") } @@ -68,8 +74,9 @@ func (h *HermesHarness) Description() []string { func (h *HermesHarness) Status() (Status, error) { status := Status{} - if _, err := exec.LookPath("hermes"); err == nil { + if path, err := exec.LookPath("hermes"); err == nil { status.Executable = true + status.ExecutablePath = path } else if !errors.Is(err, exec.ErrNotFound) { return status, fmt.Errorf("failed to find executable: %w", err) } @@ -112,6 +119,110 @@ func (h *HermesHarness) Status() (Status, error) { return status, nil } +// hermesProviderFlags and hermesModelFlags are the Hermes flags that, when +// the user passes them, mean we leave the corresponding choice alone. +var ( + hermesProviderFlags = []string{"--provider"} + hermesModelFlags = []string{"-m", "--model"} +) + +// hermesLaunchEnv are the variables the launch sets; a ~/.hermes/.env that +// redefines one of them wins over us, so they are what we check for. +var hermesLaunchEnv = []string{"CUSTOM_BASE_URL", "REQUESTY_API_KEY"} + +// Launch replaces this process with Hermes pointed at Requesty. Hermes's +// `custom` provider takes its endpoint from CUSTOM_BASE_URL and derives the +// key variable from the endpoint's host, which for router.requesty.ai is +// REQUESTY_API_KEY. The custom provider speaks OpenAI chat completions, so +// the base URL keeps its /v1 suffix. Nothing is written to disk. +func (h *HermesHarness) Launch(opts LaunchOptions) error { + status, err := h.Status() + if err != nil { + return fmt.Errorf("failed to check %s: %w", h.Name(), err) + } + if !status.Executable { + return fmt.Errorf("`hermes` is not on PATH; install Hermes (https://hermes-agent.nousresearch.com/docs/getting-started/quickstart) and try again") + } + + env := parseEnvironmentVariables(opts.Env) + env["CUSTOM_BASE_URL"] = h.config.RouterBaseURL + "/v1" + env["REQUESTY_API_KEY"] = h.config.APIKey + + if warning, conflict := h.credentialConflict(env); conflict { + _, _ = fmt.Fprintln(os.Stderr, warning) + } + + argv := []string{"hermes"} + if !hasAnyFlag(opts.Args, hermesProviderFlags) { + argv = append(argv, "--provider", "custom") + } + if opts.Model != "" && !hasAnyFlag(opts.Args, hermesModelFlags) { + argv = append(argv, "-m", opts.Model) + } + argv = append(argv, opts.Args...) + + if err := execProcess(status.ExecutablePath, argv, formatEnvironmentVariables(env)); err != nil { + return fmt.Errorf("failed to launch hermes: %w", err) + } + + return nil +} + +// credentialConflict reports a value in Hermes's .env file that would +// replace one of ours: Hermes loads that file over the process environment, +// so a stale entry there sends requests elsewhere or with another key. +func (h *HermesHarness) credentialConflict(env map[string]string) (string, bool) { + envPath := h.envPath() + + data, err := os.ReadFile(envPath) + if err != nil { + return "", false + } + + stored := parseDotEnv(string(data)) + conflicting := make([]string, 0, len(hermesLaunchEnv)) + for _, key := range hermesLaunchEnv { + if value, ok := stored[key]; ok && value != env[key] { + conflicting = append(conflicting, key) + } + } + if len(conflicting) == 0 { + return "", false + } + + return fmt.Sprintf("Warning: %s in %s takes precedence over the values Requesty sets for this run. Remove or update it there if Hermes does not reach Requesty.", strings.Join(conflicting, " and "), envPath), true +} + +// parseDotEnv reads KEY=value lines the way Hermes's dotenv loader does for +// the cases that matter here: comments and blanks are skipped, `export` is +// allowed, and matching quotes around the value are removed. +func parseDotEnv(content string) map[string]string { + values := make(map[string]string) + for _, line := range strings.Split(content, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + line = strings.TrimPrefix(line, "export ") + + key, value, found := strings.Cut(line, "=") + if !found { + continue + } + key, value = strings.TrimSpace(key), strings.TrimSpace(value) + if len(value) >= 2 && (value[0] == '"' || value[0] == '\'') && value[len(value)-1] == value[0] { + value = value[1 : len(value)-1] + } + values[key] = value + } + + return values +} + +func (h *HermesHarness) envPath() string { + return filepath.Join(h.configDir, ".env") +} + func (h *HermesHarness) Configure(opts ConfigureOptions) error { if opts.Overwrite { return h.configureOverwrite(opts) diff --git a/internal/harnesses/home.go b/internal/harnesses/home.go index 5703b11..a026332 100644 --- a/internal/harnesses/home.go +++ b/internal/harnesses/home.go @@ -15,3 +15,10 @@ func configDirInHome(elements ...string) (string, error) { return filepath.Join(append([]string{homePath}, elements...)...), nil } + +// requestyDirInHome joins elements onto our own ~/.requesty directory, where +// a harness may keep files it needs at launch without touching the +// harness's own configuration. +func requestyDirInHome(elements ...string) (string, error) { + return configDirInHome(append([]string{".requesty"}, elements...)...) +} diff --git a/internal/harnesses/launch.go b/internal/harnesses/launch.go new file mode 100644 index 0000000..7b59d02 --- /dev/null +++ b/internal/harnesses/launch.go @@ -0,0 +1,55 @@ +package harnesses + +import ( + "fmt" + "os" + "slices" + "strings" +) + +// launchNotImplemented is what harnesses without a launcher say instead of +// doing anything. +func launchNotImplemented(name string) error { + _, _ = fmt.Fprintf(os.Stderr, "Launching %s through Requesty is not implemented yet.\n", name) + return nil +} + +// requestyExecutable is the command a harness should shell out to for +// credentials. It prefers our own absolute path so the launch does not depend +// on `requesty` being on the harness's PATH. +func requestyExecutable() string { + if path, err := os.Executable(); err == nil && path != "" { + return path + } + + return "requesty" +} + +// parseEnvironmentVariables turns the "KEY=value" strings os.Environ produces into a map, +// so a harness can set or delete variables by name. nil means the current +// process environment. +func parseEnvironmentVariables(env []string) map[string]string { + if env == nil { + env = os.Environ() + } + + parsed := make(map[string]string, len(env)) + for _, entry := range env { + key, value, _ := strings.Cut(entry, "=") + parsed[key] = value + } + + return parsed +} + +// formatEnvironmentVariables is the inverse of parseEnvironmentVariables: it returns the "KEY=value" +// strings exec expects, sorted so the result is deterministic. +func formatEnvironmentVariables(env map[string]string) []string { + formatted := make([]string, 0, len(env)) + for key, value := range env { + formatted = append(formatted, key+"="+value) + } + slices.Sort(formatted) + + return formatted +} diff --git a/internal/harnesses/opencode.go b/internal/harnesses/opencode.go index fd456bd..39c142e 100644 --- a/internal/harnesses/opencode.go +++ b/internal/harnesses/opencode.go @@ -1,12 +1,14 @@ package harnesses import ( + "bytes" "encoding/json" "errors" "fmt" "os" "os/exec" "path/filepath" + "strings" "github.com/requestyai/cli/internal/config" ) @@ -63,8 +65,9 @@ func (o *OpenCodeHarness) Description() []string { func (o *OpenCodeHarness) Status() (Status, error) { status := Status{} - if _, err := exec.LookPath("opencode"); err == nil { + if path, err := exec.LookPath("opencode"); err == nil { status.Executable = true + status.ExecutablePath = path } else if !errors.Is(err, exec.ErrNotFound) { return status, fmt.Errorf("failed to find executable: %w", err) } @@ -97,6 +100,136 @@ func (o *OpenCodeHarness) Status() (Status, error) { return status, nil } +// openCodeModelFlags are the OpenCode flags that, when the user passes them, +// mean we leave the model alone. +var openCodeModelFlags = []string{"-m", "--model"} + +// Launch replaces this process with OpenCode pointed at Requesty. OpenCode +// knows Requesty as a built-in provider that switches on when +// REQUESTY_API_KEY is set; an inline OPENCODE_CONFIG_CONTENT document, which +// outranks opencode.json for this run only, pins the base URL and key to +// this profile. Nothing is written to disk. +func (o *OpenCodeHarness) Launch(opts LaunchOptions) error { + status, err := o.Status() + if err != nil { + return fmt.Errorf("failed to check %s: %w", o.Name(), err) + } + if !status.Executable { + return fmt.Errorf("`opencode` is not on PATH; install OpenCode (https://opencode.ai/docs) and try again") + } + + env := parseEnvironmentVariables(opts.Env) + env["REQUESTY_API_KEY"] = o.config.APIKey + content, err := o.inlineConfig(env["OPENCODE_CONFIG_CONTENT"], opts.Model) + if err != nil { + return err + } + env["OPENCODE_CONFIG_CONTENT"] = content + + if warning, conflict := o.credentialConflict(env); conflict { + _, _ = fmt.Fprintln(os.Stderr, warning) + } + + argv := []string{"opencode"} + if opts.Model != "" && !hasAnyFlag(opts.Args, openCodeModelFlags) { + argv = append(argv, "-m", o.modelID(opts.Model)) + } + argv = append(argv, opts.Args...) + + if err := execProcess(status.ExecutablePath, argv, formatEnvironmentVariables(env)); err != nil { + return fmt.Errorf("failed to launch opencode: %w", err) + } + + return nil +} + +// inlineConfig is the OPENCODE_CONFIG_CONTENT document for this run: the +// user's own, if they set one, with our provider merged in. The key is +// referenced by environment variable so it stays out of the document itself. +// The launched model is declared under the provider so ids OpenCode's +// catalog does not list, such as managed policies, still resolve. +func (o *OpenCodeHarness) inlineConfig(existing, model string) (string, error) { + settings := make(map[string]any) + if strings.TrimSpace(existing) != "" { + decoder := json.NewDecoder(bytes.NewReader([]byte(existing))) + decoder.UseNumber() + if err := decoder.Decode(&settings); err != nil { + return "", fmt.Errorf("OPENCODE_CONFIG_CONTENT is not valid JSON, so Requesty cannot add its provider to it; fix or unset it and try again: %w", err) + } + } + + provider := map[string]any{ + "options": map[string]any{ + "baseURL": o.baseURL(), + "apiKey": "{env:REQUESTY_API_KEY}", + "headers": map[string]any{ + "X-Title": "OpenCode", + }, + }, + } + if model != "" { + provider["models"] = map[string]any{model: map[string]any{}} + } + + if err := mergePatch(settings, map[string]any{"provider": map[string]any{openCodeProvider: provider}}, ""); err != nil { + return "", fmt.Errorf("OPENCODE_CONFIG_CONTENT cannot take a Requesty provider: %w", err) + } + + data, err := json.Marshal(settings) + if err != nil { + return "", fmt.Errorf("failed to encode inline config: %w", err) + } + + return string(data), nil +} + +// credentialConflict reports a Requesty key stored by `opencode auth login` +// that differs from this profile's, since a stored credential can win over +// the one we inject and then fail authentication. +func (o *OpenCodeHarness) credentialConflict(env map[string]string) (string, bool) { + authPath := openCodeAuthPath(env) + if authPath == "" { + return "", false + } + + data, err := os.ReadFile(authPath) + if err != nil { + return "", false + } + + var auth map[string]struct { + Key string `json:"key"` + } + if err := json.Unmarshal(data, &auth); err != nil { + return "", false + } + + stored, ok := auth[openCodeProvider] + if !ok || stored.Key == "" || stored.Key == o.config.APIKey { + return "", false + } + + return fmt.Sprintf("Warning: OpenCode has a Requesty credential stored at %s that differs from this profile's key. If requests fail to authenticate, remove it with `opencode auth logout` and try again.", authPath), true +} + +// openCodeAuthPath is where `opencode auth login` keeps credentials: +// $XDG_DATA_HOME/opencode/auth.json, defaulting to ~/.local/share. +func openCodeAuthPath(env map[string]string) string { + dataHome := strings.TrimSpace(env["XDG_DATA_HOME"]) + if dataHome == "" { + home := env["HOME"] + if home == "" { + var err error + if home, err = os.UserHomeDir(); err != nil { + return "" + } + } + dataHome = filepath.Join(home, ".local", "share") + } + + return filepath.Join(dataHome, "opencode", "auth.json") +} + func (o *OpenCodeHarness) Configure(opts ConfigureOptions) error { if opts.Overwrite { return o.configureOverwrite(opts) diff --git a/internal/harnesses/pi.go b/internal/harnesses/pi.go index 964caf9..e5cc384 100644 --- a/internal/harnesses/pi.go +++ b/internal/harnesses/pi.go @@ -1,13 +1,19 @@ package harnesses import ( + "context" + _ "embed" "encoding/json" "errors" "fmt" "os" "os/exec" "path/filepath" + "slices" + "strings" + "time" + "github.com/requestyai/cli/internal/client" "github.com/requestyai/cli/internal/config" ) @@ -74,8 +80,9 @@ func (p *PiHarness) Description() []string { func (p *PiHarness) Status() (Status, error) { status := Status{} - if _, err := exec.LookPath("pi"); err == nil { + if path, err := exec.LookPath("pi"); err == nil { status.Executable = true + status.ExecutablePath = path } else if !errors.Is(err, exec.ErrNotFound) { return status, fmt.Errorf("failed to find executable: %w", err) } @@ -125,6 +132,254 @@ func (p *PiHarness) Status() (Status, error) { return status, nil } +// piExtension registers Requesty as a Pi provider for one run. It is written +// out before every launch so an upgraded CLI always ships its current copy. +// +//go:embed pi/requesty.ts +var piExtension []byte + +const ( + piExtensionFile = "requesty.ts" + piCatalogFile = "catalog.json" + + // piCatalogTimeout bounds the model list refresh so a slow network + // cannot hold up the launch for long. + piCatalogTimeout = 5 * time.Second + + // piCatalogContextWindow and piCatalogMaxTokens stand in when the model + // list does not say. + piCatalogContextWindow = 200_000 + piCatalogMaxTokens = 8_192 +) + +// piCatalog is what the extension reads: the models the profile can route +// to, in the shape of Pi's ProviderModelConfig. +type piCatalog struct { + Models []piCatalogModel `json:"models"` +} + +type piCatalogModel struct { + ID string `json:"id"` + Name string `json:"name"` + Reasoning bool `json:"reasoning"` + Input []string `json:"input"` + Cost piCatalogCost `json:"cost"` + ContextWindow int `json:"contextWindow"` + MaxTokens int `json:"maxTokens"` +} + +// piCatalogCost is in dollars per million tokens, as Pi expects. +type piCatalogCost struct { + Input float64 `json:"input"` + Output float64 `json:"output"` + CacheRead float64 `json:"cacheRead"` + CacheWrite float64 `json:"cacheWrite"` +} + +// piProviderFlags and piModelFlags are the Pi flags that, when the user +// passes them, mean we leave the model choice alone. +var ( + piProviderFlags = []string{"--provider"} + piModelFlags = []string{"--model"} +) + +// piNeutralizedEnv are the credentials Pi reads for its built-in providers. +// They are dropped from the launched process so every model Pi offers goes +// through Requesty rather than straight to a vendor. +var piNeutralizedEnv = []string{ + "AI_GATEWAY_API_KEY", + "ANT_LING_API_KEY", + "ANTHROPIC_API_KEY", + "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_OAUTH_TOKEN", + "AWS_BEARER_TOKEN_BEDROCK", + "AZURE_OPENAI_API_KEY", + "BASETEN_API_KEY", + "CEREBRAS_API_KEY", + "CLOUDFLARE_API_KEY", + "DEEPSEEK_API_KEY", + "FIREWORKS_API_KEY", + "GEMINI_API_KEY", + "GROQ_API_KEY", + "KIMI_API_KEY", + "MINIMAX_API_KEY", + "MISTRAL_API_KEY", + "MOONSHOT_API_KEY", + "NVIDIA_API_KEY", + "OPENAI_API_KEY", + "OPENCODE_API_KEY", + "OPENROUTER_API_KEY", + "QWEN_TOKEN_PLAN_API_KEY", + "QWEN_TOKEN_PLAN_CN_API_KEY", + "TOGETHER_API_KEY", + "XAI_API_KEY", + "XIAOMI_API_KEY", + "XIAOMI_TOKEN_PLAN_AMS_API_KEY", + "XIAOMI_TOKEN_PLAN_CN_API_KEY", + "XIAOMI_TOKEN_PLAN_SGP_API_KEY", + "ZAI_API_KEY", + "ZAI_CODING_CN_API_KEY", +} + +// DefaultLaunchDirPi is where `requesty pi` keeps the extension and model +// catalog it hands to Pi: under our own directory, not Pi's. +func DefaultLaunchDirPi() (string, error) { + return requestyDirInHome("pi") +} + +// Launch replaces this process with Pi pointed at Requesty. Pi has no flag +// or variable for a custom provider, so a small extension of ours, loaded +// with --extension, registers Requesty with the models the profile can use. +// The extension and its model catalog live under ~/.requesty/pi; Pi's own +// configuration is not touched. +func (p *PiHarness) Launch(opts LaunchOptions) error { + status, err := p.Status() + if err != nil { + return fmt.Errorf("failed to check %s: %w", p.Name(), err) + } + if !status.Executable { + return fmt.Errorf("`pi` is not on PATH; install Pi (https://pi.dev) and try again") + } + + launchDir, err := DefaultLaunchDirPi() + if err != nil { + return fmt.Errorf("failed to find launch directory: %w", err) + } + extensionPath := filepath.Join(launchDir, piExtensionFile) + catalogPath := filepath.Join(launchDir, piCatalogFile) + + if err := writeFile(extensionPath, piExtension, 0o600); err != nil { + return fmt.Errorf("failed to write Pi extension: %w", err) + } + if err := p.writeCatalog(catalogPath, opts.Model); err != nil { + return fmt.Errorf("failed to write Pi model catalog: %w", err) + } + + env := parseEnvironmentVariables(opts.Env) + for _, key := range piNeutralizedEnv { + delete(env, key) + } + env["REQUESTY_API_KEY"] = p.config.APIKey + env["REQUESTY_BASE_URL"] = p.config.RouterBaseURL + env["REQUESTY_PI_CATALOG"] = catalogPath + + argv := []string{"pi", "--extension", extensionPath} + switch { + case hasAnyFlag(opts.Args, piModelFlags), hasAnyFlag(opts.Args, piProviderFlags): + // The user has chosen for themselves. + case opts.Model != "": + argv = append(argv, "--model", piProvider+"/"+opts.Model) + default: + argv = append(argv, "--provider", piProvider) + } + argv = append(argv, opts.Args...) + + if err := execProcess(status.ExecutablePath, argv, formatEnvironmentVariables(env)); err != nil { + return fmt.Errorf("failed to launch pi: %w", err) + } + + return nil +} + +// writeCatalog refreshes the model catalog from the profile's account. When +// that fails, such as offline, the previous catalog is kept. Either way the +// launched model is listed, since Pi only accepts models it knows about. +func (p *PiHarness) writeCatalog(path, model string) error { + ctx, cancel := context.WithTimeout(context.Background(), piCatalogTimeout) + defer cancel() + + catalog, err := p.fetchCatalog(ctx) + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "Warning: could not refresh the Requesty model list for Pi (%v); using the previous list.\n", err) + catalog = readPiCatalog(path) + } + + if model != "" && !slices.ContainsFunc(catalog.Models, func(m piCatalogModel) bool { return m.ID == model }) { + catalog.Models = append(catalog.Models, piCatalogModel{ + ID: model, + Name: model, + Reasoning: true, + Input: []string{"text", "image"}, + ContextWindow: piCatalogContextWindow, + MaxTokens: piCatalogMaxTokens, + }) + } + + data, err := json.MarshalIndent(catalog, "", " ") + if err != nil { + return fmt.Errorf("failed to encode catalog: %w", err) + } + + return writeFile(path, data, 0o600) +} + +// fetchCatalog lists the profile's models and managed policies. Policies +// are optional: an account without any still gets its models. +func (p *PiHarness) fetchCatalog(ctx context.Context) (piCatalog, error) { + apiClient := client.New(p.config) + + models, err := apiClient.Models(ctx) + if err != nil { + return piCatalog{}, err + } + policies, _ := apiClient.ManagedPolicies(ctx) + + listed := slices.Concat(policies, models) + slices.SortFunc(listed, func(a, b client.Model) int { return strings.Compare(a.ID, b.ID) }) + + catalog := piCatalog{Models: make([]piCatalogModel, 0, len(listed))} + for _, model := range listed { + if model.ID == "" || slices.ContainsFunc(catalog.Models, func(m piCatalogModel) bool { return m.ID == model.ID }) { + continue + } + catalog.Models = append(catalog.Models, piCatalogModelFrom(model)) + } + + return catalog, nil +} + +// piCatalogModelFrom describes a Requesty model to Pi. Prices arrive per +// token and leave per million; capacities the list omits get defaults. +func piCatalogModelFrom(model client.Model) piCatalogModel { + entry := piCatalogModel{ + ID: model.ID, + Name: model.ID, + Reasoning: true, + Input: []string{"text", "image"}, + Cost: piCatalogCost{ + Input: model.InputPrice * 1_000_000, + Output: model.OutputPrice * 1_000_000, + CacheRead: model.CacheReadPrice * 1_000_000, + CacheWrite: model.CacheWritePrice * 1_000_000, + }, + ContextWindow: model.ContextWindow, + MaxTokens: model.MaxOutputTokens, + } + if entry.ContextWindow <= 0 { + entry.ContextWindow = piCatalogContextWindow + } + if entry.MaxTokens <= 0 { + entry.MaxTokens = piCatalogMaxTokens + } + + return entry +} + +// readPiCatalog returns the catalog at path, or an empty one when there is +// none or it cannot be read. +func readPiCatalog(path string) piCatalog { + var catalog piCatalog + data, err := os.ReadFile(path) + if err != nil { + return catalog + } + if err := json.Unmarshal(data, &catalog); err != nil { + return piCatalog{} + } + + return catalog +} + func (p *PiHarness) Configure(opts ConfigureOptions) error { if opts.Overwrite { return p.configureOverwrite(opts) diff --git a/internal/harnesses/pi/requesty.ts b/internal/harnesses/pi/requesty.ts new file mode 100644 index 0000000..984003a --- /dev/null +++ b/internal/harnesses/pi/requesty.ts @@ -0,0 +1,53 @@ +// Requesty provider for Pi. Written by `requesty pi` before every launch and +// loaded with `--extension`, so nothing in Pi's own configuration changes. +// +// The launch passes everything through the environment: +// REQUESTY_API_KEY the key for this profile +// REQUESTY_BASE_URL the router, without a /v1 suffix +// REQUESTY_PI_CATALOG a JSON file listing the models the profile can use +import { readFileSync } from "node:fs"; +import type { ExtensionAPI, ProviderModelConfig } from "@earendil-works/pi-coding-agent"; + +const PROVIDER = "requesty"; +const DEFAULT_BASE_URL = "https://router.requesty.ai"; + +// The native Anthropic Messages format lets Requesty apply automatic prompt +// caching; it takes the router's base URL without /v1. +const API = "anthropic-messages"; + +const trimmed = (value: string | undefined): string | undefined => { + const text = value?.trim(); + return text === undefined || text === "" ? undefined : text; +}; + +const loadCatalog = (path: string | undefined): ProviderModelConfig[] => { + if (path === undefined) { + return []; + } + const parsed: unknown = JSON.parse(readFileSync(path, "utf8")); + const models = (parsed as { models?: unknown }).models; + return Array.isArray(models) ? (models as ProviderModelConfig[]) : []; +}; + +export default function (pi: ExtensionAPI): void { + if (typeof pi.registerProvider !== "function") { + throw new Error("Requesty needs pi 0.84.0 or newer; upgrade pi and try again."); + } + + const apiKey = trimmed(process.env.REQUESTY_API_KEY); + if (apiKey === undefined) { + throw new Error("REQUESTY_API_KEY is not set; start Pi with `requesty pi`."); + } + + pi.registerProvider(PROVIDER, { + name: "Requesty", + baseUrl: trimmed(process.env.REQUESTY_BASE_URL) ?? DEFAULT_BASE_URL, + api: API, + apiKey, + headers: { + "HTTP-Referer": "https://pi.dev", + "X-Title": "Pi", + }, + models: loadCatalog(trimmed(process.env.REQUESTY_PI_CATALOG)), + }); +} diff --git a/internal/modelpicker/picker.go b/internal/modelpicker/picker.go new file mode 100644 index 0000000..4123fa6 --- /dev/null +++ b/internal/modelpicker/picker.go @@ -0,0 +1,365 @@ +// Package modelpicker is the full-screen dialog that asks which model a +// harness should launch with. It lists the managed policies the profile can +// route to on one tab and every model on another. +package modelpicker + +import ( + "context" + "errors" + "fmt" + "os" + "slices" + "strings" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/requestyai/cli/internal/client" + "github.com/requestyai/cli/internal/tui/theme" + "github.com/requestyai/cli/internal/tui/ui/table" + "github.com/requestyai/cli/internal/tui/ui/text" + "github.com/requestyai/cli/internal/util" +) + +// ErrCancelled reports that the dialog was closed without choosing. +var ErrCancelled = errors.New("no model chosen") + +// Options says what to pick for and what to prefer. +type Options struct { + // Client fetches the two lists the picker shows, as the profile the + // harness will run with. + Client *client.Client + // Harness is the display name shown in the title. + Harness string + // Preferred lists models in order of preference: what was picked last + // time, or the harness's defaults. The first one the profile can route + // to is returned without asking, or with Confirm set, is where the + // cursor starts. + Preferred []string + // Confirm always shows the dialog, for when the user asked to choose. + Confirm bool +} + +// Run returns the model to launch with. Unless Confirm is set, the first of +// Preferred that the profile can route to is returned without asking, and +// asked reports false. Otherwise the dialog is shown, drawn on stderr so the +// harness's own stdout is untouched. +func Run(ctx context.Context, opts Options) (chosen string, asked bool, err error) { + if !opts.Confirm { + if preferred := routablePreferred(ctx, opts); preferred != "" { + return preferred, false, nil + } + } + + final, err := tea.NewProgram(newModel(ctx, opts), tea.WithContext(ctx), tea.WithOutput(os.Stderr)).Run() + if err != nil { + return "", true, fmt.Errorf("failed to run model picker: %w", err) + } + + finished, ok := final.(model) + if !ok || finished.chosen == "" { + return "", true, ErrCancelled + } + + return finished.chosen, true, nil +} + +// routablePreferred is the first of Preferred that the profile can route to, +// or empty. Both lists come back filtered by the key's access list, so +// anything found is known to be permitted. A list that cannot be fetched +// counts as empty here; the dialog will show the error. +func routablePreferred(ctx context.Context, opts Options) string { + if len(opts.Preferred) == 0 { + return "" + } + + policies, _ := opts.Client.ManagedPolicies(ctx) + models, _ := opts.Client.Models(ctx) + listed := slices.Concat(policies, models) + for _, id := range opts.Preferred { + if slices.ContainsFunc(listed, func(m client.Model) bool { return m.ID == id }) { + return id + } + } + + return "" +} + +const ( + minWidth = 40 + maxWidth = 96 + contextWidth = 9 + priceWidth = 9 + minRows = 3 + maxRows = 15 + // chromeRows is how many lines the dialog spends around the table. + chromeRows = 12 + + framePadX = 2 + framePadY = 1 + defaultWidth = 80 - 2*framePadX + defaultHeight = 24 - 2*framePadY +) + +var frame = lipgloss.NewStyle().Padding(framePadY, framePadX) + +type tab uint8 + +const ( + tabPolicies tab = iota + tabModels + tabCount +) + +func (t tab) title() string { + if t == tabPolicies { + return "Policies" + } + + return "Models" +} + +func (t tab) description() string { + if t == tabPolicies { + return "Managed policies: one name per model, routed across providers." + } + + return "Every model and organization policy this profile can route to." +} + +// list is one tab's contents. loaded distinguishes an empty list from one +// still on its way. +type list struct { + models []client.Model + loaded bool + err error +} + +type loadedMsg struct { + tab tab + models []client.Model + err error +} + +type model struct { + ctx context.Context + opts Options + lists [tabCount]list + tab tab + cursor int + search textinput.Model + width int + height int + chosen string +} + +func newModel(ctx context.Context, opts Options) model { + search := textinput.New() + search.Prompt = "Search: " + search.Placeholder = "type to filter" + search.CharLimit = 200 + // The blink command Focus returns is issued from Init, so it is safe to + // discard here; what matters is that the box is ready to type into. + search.Focus() + + return model{ + ctx: ctx, + opts: opts, + search: search, + width: defaultWidth, + height: defaultHeight, + } +} + +func (m model) Init() tea.Cmd { + return tea.Batch(m.load(tabPolicies), m.load(tabModels), textinput.Blink) +} + +func (m model) load(t tab) tea.Cmd { + ctx, apiClient := m.ctx, m.opts.Client + return func() tea.Msg { + var models []client.Model + var err error + if t == tabPolicies { + models, err = apiClient.ManagedPolicies(ctx) + } else { + models, err = apiClient.Models(ctx) + } + slices.SortFunc(models, func(a, b client.Model) int { + return strings.Compare(a.ID, b.ID) + }) + return loadedMsg{tab: t, models: models, err: err} + } +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch typedMsg := msg.(type) { + case tea.WindowSizeMsg: + m.width = max(typedMsg.Width-2*framePadX, 0) + m.height = max(typedMsg.Height-2*framePadY, 0) + + case loadedMsg: + m.lists[typedMsg.tab] = list{models: typedMsg.models, loaded: true, err: typedMsg.err} + // Someone with no managed policies, or whose list failed to load, + // should not land on an empty tab. + if typedMsg.tab == tabPolicies && m.tab == tabPolicies && len(typedMsg.models) == 0 { + m.tab = tabModels + } + if typedMsg.tab == m.tab { + m.cursor = m.preselectedCursor() + } + + case tea.KeyPressMsg: + return m.updateKey(typedMsg) + } + + return m, nil +} + +func (m model) updateKey(msg tea.KeyPressMsg) (model, tea.Cmd) { + switch msg.String() { + case "ctrl+c", "esc": + return m, tea.Quit + case "tab", "shift+tab": + m.tab = (m.tab + 1) % tabCount + m.cursor = m.preselectedCursor() + case "up": + if m.cursor > 0 { + m.cursor-- + } + case "down": + if m.cursor < len(m.filtered())-1 { + m.cursor++ + } + case "enter": + if filtered := m.filtered(); len(filtered) > 0 { + m.chosen = filtered[m.cursor].ID + return m, tea.Quit + } + default: + previous := m.search.Value() + var cmd tea.Cmd + m.search, cmd = m.search.Update(msg) + if m.search.Value() != previous { + m.cursor = 0 + } + return m, cmd + } + + return m, nil +} + +// filtered is the current tab's list narrowed by the search box. +func (m model) filtered() []client.Model { + models := m.lists[m.tab].models + query := strings.ToLower(strings.TrimSpace(m.search.Value())) + if query == "" { + return models + } + + matches := make([]client.Model, 0, len(models)) + for _, model := range models { + if strings.Contains(strings.ToLower(model.ID), query) { + matches = append(matches, model) + } + } + + return matches +} + +// preselectedCursor is where the cursor goes on a fresh list: on the first of +// Preferred that is there, else the top. +func (m model) preselectedCursor() int { + filtered := m.filtered() + for _, id := range m.opts.Preferred { + if i := slices.IndexFunc(filtered, func(m client.Model) bool { return m.ID == id }); i >= 0 { + return i + } + } + + return 0 +} + +func (m model) View() tea.View { + inner := max(min(m.width-8, maxWidth), minWidth) + m.search.SetWidth(inner - lipgloss.Width(m.search.Prompt)) + + lines := []string{ + text.RenderSplitHeaderSection("Choose a model", m.opts.Harness, inner), + text.LineSeparator, + m.tabs(), + lipgloss.NewStyle().Width(inner).Render(theme.Muted.Render(m.tab.description())), + text.LineSeparator, + m.search.View(), + text.LineSeparator, + m.body(inner), + text.LineSeparator, + text.RenderFooterHintList(inner, + [2]string{"tab", "switch list"}, + [2]string{"↑/↓", "move"}, + [2]string{"enter", "launch"}, + [2]string{"esc", "cancel"}, + ), + } + + panel := theme.Panel.Render(lipgloss.JoinVertical(lipgloss.Left, lines...)) + view := tea.NewView(frame.Render(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, panel))) + view.AltScreen = true + + return view +} + +func (m model) tabs() string { + pills := make([]string, 0, tabCount) + for t := tabPolicies; t < tabCount; t++ { + label := t.title() + if l := m.lists[t]; l.loaded && l.err == nil { + label = fmt.Sprintf("%s (%d)", label, len(l.models)) + } + style := theme.PillOff + if t == m.tab { + style = theme.Pill + } + pills = append(pills, style.Render(label)) + } + + return lipgloss.JoinHorizontal(lipgloss.Top, pills...) +} + +func (m model) body(inner int) string { + current := m.lists[m.tab] + filtered := m.filtered() + switch { + case current.err != nil: + return theme.Bad.Render(fmt.Sprintf("Could not load %s: %s", strings.ToLower(m.tab.title()), current.err)) + case !current.loaded: + return theme.Muted.Render(fmt.Sprintf("Loading %s…", strings.ToLower(m.tab.title()))) + case len(current.models) == 0: + return theme.Muted.Render(fmt.Sprintf("No %s available to this profile", strings.ToLower(m.tab.title()))) + case len(filtered) == 0: + return theme.Muted.Render(fmt.Sprintf("No %s match your search", strings.ToLower(m.tab.title()))) + } + + rows := make([][]string, 0, len(filtered)) + for _, model := range filtered { + rows = append(rows, []string{ + model.ID, + util.FormatTokens(model.ContextWindow), + util.FormatPrice(model.InputPrice), + util.FormatPrice(model.OutputPrice), + }) + } + + return table.Table{ + Cols: []table.Column{ + {Title: "MODEL", Width: inner - 2 - contextWidth - 2*priceWidth, Align: table.Left}, + {Title: "CONTEXT", Width: contextWidth, Align: table.Right}, + {Title: "IN /1M", Width: priceWidth, Align: table.Right}, + {Title: "OUT /1M", Width: priceWidth, Align: table.Right}, + }, + Rows: rows, + Cursor: m.cursor, + Height: max(min(m.height-chromeRows, maxRows), minRows), + Style: table.CellStyle(m.cursor), + }.Render() +} diff --git a/internal/modelpicker/picker_test.go b/internal/modelpicker/picker_test.go new file mode 100644 index 0000000..4b28336 --- /dev/null +++ b/internal/modelpicker/picker_test.go @@ -0,0 +1,250 @@ +package modelpicker + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" + "github.com/requestyai/cli/internal/client" + "github.com/requestyai/cli/internal/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// lists is what a fake router serves for the two endpoints the picker reads. +// A nil list answers with an error status rather than an empty list. +type lists struct { + policies []client.Model + models []client.Model +} + +// newClient returns a client pointed at a router that serves l. +func newClient(t *testing.T, l lists) *client.Client { + t.Helper() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var models []client.Model + switch r.URL.Path { + case "/v1/models/managed": + models = l.policies + case "/v1/models": + models = l.models + } + if models == nil { + w.WriteHeader(http.StatusUnauthorized) + return + } + require.NoError(t, json.NewEncoder(w).Encode(map[string]any{"object": "list", "data": models})) + })) + t.Cleanup(server.Close) + + return client.New(config.Config{RouterBaseURL: server.URL, APIKey: "test-key"}) +} + +var ( + policies = []client.Model{ + {ID: "gpt-5.5", ContextWindow: 400_000, InputPrice: 0.0000055, OutputPrice: 0.000033}, + {ID: "claude-sonnet-4-6", ContextWindow: 1_000_000, InputPrice: 0.000003, OutputPrice: 0.000015}, + {ID: "claude-fable-5", ContextWindow: 1_000_000, InputPrice: 0.00001, OutputPrice: 0.00005}, + } + models = []client.Model{ + {ID: "anthropic/claude-sonnet-4-6", ContextWindow: 1_000_000}, + {ID: "openai/gpt-5.5", ContextWindow: 400_000}, + } +) + +func key(name string) tea.KeyPressMsg { + switch name { + case "enter": + return tea.KeyPressMsg{Code: tea.KeyEnter} + case "esc": + return tea.KeyPressMsg{Code: tea.KeyEscape} + case "tab": + return tea.KeyPressMsg{Code: tea.KeyTab} + case "up": + return tea.KeyPressMsg{Code: tea.KeyUp} + case "down": + return tea.KeyPressMsg{Code: tea.KeyDown} + default: + return tea.KeyPressMsg{Code: rune(name[0]), Text: name} + } +} + +func update(m model, msg tea.Msg) (model, tea.Cmd) { + next, cmd := m.Update(msg) + return next.(model), cmd +} + +func view(m model) string { + return ansi.Strip(m.View().Content) +} + +// loaded runs the picker's own load commands so both tabs are filled in. +func loaded(t *testing.T, l lists, preferred ...string) model { + t.Helper() + + m := newModel(context.Background(), Options{Client: newClient(t, l), Harness: "Claude Code", Preferred: preferred}) + for _, tab := range []tab{tabPolicies, tabModels} { + msg := m.load(tab)() + require.IsType(t, loadedMsg{}, msg) + m, _ = update(m, msg) + } + + return m +} + +func TestRunReturnsTheFirstRoutablePreferredWithoutAsking(t *testing.T) { + opts := Options{Client: newClient(t, lists{policies: policies, models: models}), Harness: "Claude Code"} + + tests := []struct { + name string + preferred []string + want string + }{ + {name: "a managed policy", preferred: []string{"claude-sonnet-4-6"}, want: "claude-sonnet-4-6"}, + {name: "a model", preferred: []string{"openai/gpt-5.5"}, want: "openai/gpt-5.5"}, + {name: "the first that is listed", preferred: []string{"claude-haiku-4-5", "claude-fable-5", "gpt-5.5"}, want: "claude-fable-5"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opts.Preferred = tt.preferred + + got, asked, err := Run(context.Background(), opts) + + require.NoError(t, err) + assert.Equal(t, tt.want, got) + assert.False(t, asked) + }) + } +} + +func TestRoutablePreferredIsEmptyWhenNothingMatches(t *testing.T) { + tests := []struct { + name string + l lists + }{ + {name: "not listed", l: lists{policies: policies, models: models}}, + {name: "lists cannot be fetched", l: lists{}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opts := Options{Client: newClient(t, tt.l), Preferred: []string{"claude-haiku-4-5"}} + + assert.Empty(t, routablePreferred(context.Background(), opts)) + }) + } +} + +func TestShowsPoliciesFirstSortedWithPreferredUnderCursor(t *testing.T) { + m := loaded(t, lists{policies: policies, models: models}, "claude-haiku-4-5", "claude-sonnet-4-6") + + assert.Equal(t, tabPolicies, m.tab) + assert.Equal(t, 1, m.cursor, "sorted: claude-fable-5, claude-sonnet-4-6, gpt-5.5") + out := view(m) + assert.Contains(t, out, "Choose a model") + assert.Contains(t, out, "Claude Code") + assert.Contains(t, out, "Policies (3)") + assert.Contains(t, out, "Models (2)") + assert.Contains(t, out, "❯ claude-sonnet-4-6") + assert.Contains(t, out, "1000K") + assert.Contains(t, out, "$3.00") + assert.Contains(t, out, "$15.00") +} + +func TestEnterChoosesTheModelUnderTheCursor(t *testing.T) { + m := loaded(t, lists{policies: policies, models: models}) + + m, _ = update(m, key("down")) + m, cmd := update(m, key("enter")) + + assert.Equal(t, "claude-sonnet-4-6", m.chosen) + assert.NotNil(t, cmd, "enter quits the program") +} + +func TestTabSwitchesToModelsAndBack(t *testing.T) { + m := loaded(t, lists{policies: policies, models: models}, "openai/gpt-5.5") + + m, _ = update(m, key("tab")) + + assert.Equal(t, tabModels, m.tab) + assert.Equal(t, 1, m.cursor, "preselect applies on whichever tab lists it") + assert.Contains(t, view(m), "❯ openai/gpt-5.5") + + m, _ = update(m, key("tab")) + + assert.Equal(t, tabPolicies, m.tab) + assert.Equal(t, 0, m.cursor) +} + +func TestSearchFiltersTheCurrentTabAndResetsCursor(t *testing.T) { + m := loaded(t, lists{policies: policies, models: models}, "gpt-5.5") + require.Equal(t, 2, m.cursor) + + m, _ = update(m, key("s")) + m, _ = update(m, key("o")) + + assert.Equal(t, 0, m.cursor) + out := view(m) + assert.Contains(t, out, "❯ claude-sonnet-4-6") + assert.NotContains(t, out, "gpt-5.5") + + m, _ = update(m, key("x")) + + assert.Contains(t, view(m), "No policies match your search") +} + +func TestNoPoliciesLandsOnModelsTab(t *testing.T) { + m := loaded(t, lists{policies: []client.Model{}, models: models}) + + assert.Equal(t, tabModels, m.tab) + assert.Contains(t, view(m), "❯ anthropic/claude-sonnet-4-6") +} + +func TestLoadErrorMovesToModelsAndIsShownOnItsTab(t *testing.T) { + m := loaded(t, lists{models: models}) + + assert.Equal(t, tabModels, m.tab) + + m, _ = update(m, key("tab")) + + assert.Equal(t, tabPolicies, m.tab) + assert.Contains(t, view(m), "Could not load policies: status code not ok: 401") +} + +func TestLoadingStateBeforeListsArrive(t *testing.T) { + m := newModel(context.Background(), Options{Client: newClient(t, lists{}), Harness: "Codex"}) + + assert.Contains(t, view(m), "Loading policies…") + + _, cmd := update(m, key("enter")) + assert.Nil(t, cmd, "enter on an empty list does nothing") +} + +func TestEscapeLeavesNothingChosen(t *testing.T) { + m := loaded(t, lists{policies: policies, models: models}) + + m, cmd := update(m, key("esc")) + + assert.Empty(t, m.chosen) + assert.NotNil(t, cmd) +} + +func TestCursorStaysWithinTheList(t *testing.T) { + m := loaded(t, lists{policies: policies, models: models}) + + for range 10 { + m, _ = update(m, key("down")) + } + assert.Equal(t, 2, m.cursor) + + for range 10 { + m, _ = update(m, key("up")) + } + assert.Equal(t, 0, m.cursor) +} diff --git a/internal/oauth/browser.go b/internal/oauth/browser.go new file mode 100644 index 0000000..7e5e9a6 --- /dev/null +++ b/internal/oauth/browser.go @@ -0,0 +1,31 @@ +package oauth + +import ( + "fmt" + "os/exec" + "runtime" +) + +// openBrowser asks the desktop to open url in the default browser. It returns +// once the request has been handed off, without waiting for the browser to +// exit, because some launchers block until the browser window closes. +func openBrowser(url string) error { + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", url) + case "windows": + cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) + default: + cmd = exec.Command("xdg-open", url) + } + + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start browser: %w", err) + } + + // Reap the launcher in the background so it does not linger as a zombie. + go func() { _ = cmd.Wait() }() + + return nil +} diff --git a/internal/oauth/callback.go b/internal/oauth/callback.go new file mode 100644 index 0000000..b42b878 --- /dev/null +++ b/internal/oauth/callback.go @@ -0,0 +1,165 @@ +package oauth + +import ( + "context" + "crypto/subtle" + "errors" + "fmt" + "net" + "net/http" + "net/url" + "sync" + "time" +) + +const ( + callbackPath = "/callback" + + // loginTimeout bounds how long the CLI waits for the user to finish in the + // browser before giving up. + loginTimeout = 5 * time.Minute + + // shutdownTimeout bounds how long Close waits for the browser's connection + // to drain before dropping it. + shutdownTimeout = 2 * time.Second +) + +// Error is a failure the authorization server reported, either on the redirect +// back to the CLI or from the token endpoint. +type Error struct { + Code string + Description string +} + +func (e *Error) Error() string { + if e.Description == "" { + return e.Code + } + + return fmt.Sprintf("%s: %s", e.Code, e.Description) +} + +type callbackResult struct { + code string + err error +} + +// callbackServer is the loopback HTTP server the browser lands on once the +// user has approved or denied the request. It accepts exactly one outcome. +type callbackServer struct { + listener net.Listener + server *http.Server + state string + results chan callbackResult + once sync.Once +} + +// listenCallback starts serving on a free port of the IPv4 loopback interface. +// The redirect URI must use the 127.0.0.1 literal, not localhost, so that is +// what we bind to. +func listenCallback(state string) (*callbackServer, error) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return nil, fmt.Errorf("failed to listen for the browser callback: %w", err) + } + + server := &callbackServer{ + listener: listener, + state: state, + results: make(chan callbackResult, 1), + } + + mux := http.NewServeMux() + mux.HandleFunc("GET "+callbackPath, server.handle) + server.server = &http.Server{ + Handler: mux, + ReadHeaderTimeout: 10 * time.Second, + } + + go func() { + // Serve always returns a non-nil error; after Shutdown it is + // ErrServerClosed, which is the expected way to stop. + _ = server.server.Serve(listener) + }() + + return server, nil +} + +// RedirectURI is the address the authorization server sends the browser back +// to. The exact same string must be presented at the token endpoint. +func (s *callbackServer) RedirectURI() string { + return "http://" + s.listener.Addr().String() + callbackPath +} + +// Wait blocks until the browser delivers an outcome, the context ends, or the +// login timeout passes. +func (s *callbackServer) Wait(ctx context.Context) (string, error) { + ctx, cancel := context.WithTimeout(ctx, loginTimeout) + defer cancel() + + select { + case result := <-s.results: + return result.code, result.err + case <-ctx.Done(): + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + return "", fmt.Errorf("timed out waiting for the browser sign-in: %w", ctx.Err()) + } + + return "", ctx.Err() + } +} + +// Close stops the server, giving the browser a moment to read its response. +func (s *callbackServer) Close() { + ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + defer cancel() + + if err := s.server.Shutdown(ctx); err != nil { + _ = s.server.Close() + } +} + +func (s *callbackServer) handle(w http.ResponseWriter, r *http.Request) { + result := s.resolve(r.URL.Query()) + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + + if result.err != nil { + w.WriteHeader(http.StatusBadRequest) + writePage(w, callbackPage{ + Title: "Sign-in failed", + Message: "Could not sign in to Requesty CLI: " + result.err.Error() + ".", + Hint: "You can close this tab and return to the terminal.", + }) + } else { + writePage(w, callbackPage{ + Success: true, + Title: "Signed in", + Message: "You're signed in to Requesty CLI. You can close this tab.", + Hint: "Return to the terminal to continue.", + }) + } + + s.once.Do(func() { s.results <- result }) +} + +// resolve turns the authorization response into a code or an error. The state +// check comes first so that a response meant for another attempt, or forged +// by a page that guessed the port, is never acted on. +func (s *callbackServer) resolve(query url.Values) callbackResult { + if subtle.ConstantTimeCompare([]byte(query.Get("state")), []byte(s.state)) != 1 { + return callbackResult{err: errors.New("state mismatch: the response did not belong to this sign-in attempt")} + } + + if code := query.Get("error"); code != "" { + return callbackResult{err: &Error{Code: code, Description: query.Get("error_description")}} + } + + code := query.Get("code") + if code == "" { + return callbackResult{err: errors.New("authorization response has no code")} + } + + return callbackResult{code: code} +} diff --git a/internal/oauth/callback_test.go b/internal/oauth/callback_test.go new file mode 100644 index 0000000..8ae3c61 --- /dev/null +++ b/internal/oauth/callback_test.go @@ -0,0 +1,165 @@ +package oauth + +import ( + "context" + "io" + "net/http" + "net/url" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// visit plays the browser: it follows the redirect the authorization server +// would have issued and returns what the callback page said. +func visit(t *testing.T, server *callbackServer, query url.Values) (int, string) { + t.Helper() + + resp, err := http.Get(server.RedirectURI() + "?" + query.Encode()) + require.NoError(t, err) + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + return resp.StatusCode, string(body) +} + +func TestCallbackRedirectURIUsesLoopbackIPLiteral(t *testing.T) { + server, err := listenCallback("state") + require.NoError(t, err) + defer server.Close() + + redirect, err := url.Parse(server.RedirectURI()) + require.NoError(t, err) + + assert.Equal(t, "http", redirect.Scheme) + assert.Equal(t, "127.0.0.1", redirect.Hostname()) + assert.NotEmpty(t, redirect.Port()) + assert.Equal(t, "/callback", redirect.Path) +} + +func TestCallbackDeliversCode(t *testing.T) { + server, err := listenCallback("expected-state") + require.NoError(t, err) + defer server.Close() + + status, body := visit(t, server, url.Values{ + "code": {"the-code"}, + "state": {"expected-state"}, + "iss": {"https://api-v2.requesty.ai"}, + }) + assert.Equal(t, http.StatusOK, status) + assert.Contains(t, body, "You're signed in to Requesty CLI. You can close this tab.") + + code, err := server.Wait(context.Background()) + require.NoError(t, err) + assert.Equal(t, "the-code", code) +} + +func TestCallbackRejectsStateMismatch(t *testing.T) { + server, err := listenCallback("expected-state") + require.NoError(t, err) + defer server.Close() + + status, body := visit(t, server, url.Values{ + "code": {"the-code"}, + "state": {"forged-state"}, + }) + assert.Equal(t, http.StatusBadRequest, status) + assert.Contains(t, body, "state mismatch") + + code, err := server.Wait(context.Background()) + require.ErrorContains(t, err, "state mismatch") + assert.Empty(t, code) +} + +func TestCallbackSurfacesErrorParameters(t *testing.T) { + server, err := listenCallback("expected-state") + require.NoError(t, err) + defer server.Close() + + status, body := visit(t, server, url.Values{ + "error": {"access_denied"}, + "error_description": {"the user denied the request"}, + "state": {"expected-state"}, + }) + assert.Equal(t, http.StatusBadRequest, status) + assert.Contains(t, body, "access_denied: the user denied the request") + + _, err = server.Wait(context.Background()) + var oauthErr *Error + require.ErrorAs(t, err, &oauthErr) + assert.Equal(t, "access_denied", oauthErr.Code) + assert.Equal(t, "the user denied the request", oauthErr.Description) +} + +func TestCallbackRejectsResponseWithoutCode(t *testing.T) { + server, err := listenCallback("expected-state") + require.NoError(t, err) + defer server.Close() + + visit(t, server, url.Values{"state": {"expected-state"}}) + + _, err = server.Wait(context.Background()) + require.ErrorContains(t, err, "no code") +} + +func TestCallbackEscapesHTMLInErrors(t *testing.T) { + server, err := listenCallback("expected-state") + require.NoError(t, err) + defer server.Close() + + _, body := visit(t, server, url.Values{ + "error": {"access_denied"}, + "error_description": {""}, + "state": {"expected-state"}, + }) + + assert.False(t, strings.Contains(body, "