diff --git a/.gitignore b/.gitignore index 895b2e9..208e49f 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,7 @@ npm/cli-*/bin/ # Local release artifacts *.tar.gz *.zip + +# Agent instructions +AGENTS.md +CLAUDE.md diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 384e177..a8408e6 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -67,7 +67,7 @@ brews: name: homebrew-tap token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}" homepage: "https://shellroute.com" - description: "Residential proxy CLI" + description: "A proxied shell for terminal workflows. Open a session, choose a proxy, and run commands normally." directory: Formula signs: diff --git a/README.md b/README.md index 7d539e9..0fb5929 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE) -Run terminal commands through country-specific residential or datacenter proxies. No VPN, no per-tool configuration. Learn more at [shellroute.com](https://shellroute.com/). +**Every terminal can be somewhere else.** Open a proxied shell or route one command, then run your terminal workflow normally. Learn more at [shellroute.com](https://shellroute.com/). ## Install @@ -29,16 +29,22 @@ Supports macOS and Linux. ## Quick start ```bash -# Log in (creates account if new) +# Log in. A new account is created automatically. shellroute login -# Start a proxy -shellroute proxy --country US +# Shellroute opens after login. Run these inside it: +/connect US +curl https://ipinfo.io/json +``` -# In another terminal -curl -x http://127.0.0.1:41900 https://ipinfo.io/ip +### Or route one command + +```bash +shellroute run DE -- curl https://ipinfo.io/json ``` +[Read the full quickstart](https://shellroute.com/docs/quickstart?utm_source=github&utm_medium=readme&utm_campaign=cli_readme). + ## Commands ### Authentication @@ -58,6 +64,17 @@ curl -x http://127.0.0.1:41900 https://ipinfo.io/ip | `shellroute proxy --country ` | Persistent proxy (blocks until Ctrl+C) | | `shellroute proxy stop` | Stop running proxy sessions | +#### Local proxy mode + +For tools configured with an explicit proxy URL: + +```bash +shellroute proxy --country US + +# In another terminal +curl -x http://127.0.0.1:41900 https://ipinfo.io/ip +``` + ### Info | Command | Description | @@ -86,13 +103,13 @@ Run all checks (lint, tests, audit, cross-compile): `./scripts/run-tests.sh`. Re Your terminal -> shellroute CLI (local proxy) -> shellroute API -> Gateway -> Exit IP -> Internet ``` -The CLI runs a local HTTP proxy on `127.0.0.1` and sets `HTTP_PROXY`/`HTTPS_PROXY` for the child process. Proxy-aware tools such as curl, Python Requests, and HTTPX inherit the route. Some clients need explicit configuration. See the [compatibility matrix](docs/compatibility.md) for tested versions and conditions. +Shellroute implements each active route as a local HTTP proxy and provides standard proxy environment variables to the shell or child process. Clients that use those variables send requests through the selected proxy. Each session remains independent, while shellroute manages credentials, rotation, usage, and cleanup. -Traffic exits through residential or datacenter IPs in 120+ countries. +[See what shellroute proxies.](docs/compatibility.md) ## Important -The shellroute CLI is open source. It connects to the shellroute service, which requires a paid account. See [shellroute.com](https://shellroute.com/) for pricing and [acceptable use policy](https://shellroute.com/acceptable-use). +The shellroute CLI is open source and connects to the shellroute service. The service uses prepaid credits. See [pricing](https://shellroute.com/pricing) and the [acceptable use policy](https://shellroute.com/acceptable-use). ## Privacy diff --git a/internal/api/client.go b/internal/api/client.go index 883a258..e45624a 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -121,9 +121,10 @@ type LocationsResponse struct { } type LoginResponse struct { - APIKey string `json:"api_key,omitempty"` // empty — key is generated locally - Email string `json:"email"` - BalanceUSD float64 `json:"balance_usd"` + APIKey string `json:"api_key,omitempty"` // empty — key is generated locally + Email string `json:"email"` + BalanceUSD float64 `json:"balance_usd"` + StarterCredit string `json:"starter_credit,omitempty"` } type VersionResponse struct { diff --git a/internal/cli/login.go b/internal/cli/login.go index b3f6d6a..fcdf874 100644 --- a/internal/cli/login.go +++ b/internal/cli/login.go @@ -118,7 +118,7 @@ func runLoginVerifyCode() error { keyHash := auth.HashClientKey(rawKey) keyPrefix := auth.KeyPrefix(rawKey) - _, err = client.VerifyLoginCode(loginEmail, loginVerifyCode, keyHash, keyPrefix) + resp, err := client.VerifyLoginCode(loginEmail, loginVerifyCode, keyHash, keyPrefix) if err != nil { if apiErr, ok := err.(*api.APIError); ok { display.Error("%s", apiErr.UserMessage()) @@ -135,6 +135,10 @@ func runLoginVerifyCode() error { } display.Success("Logged in successfully") + if resp != nil && resp.StarterCredit != "" { + fmt.Fprintln(os.Stderr) + display.InfoBold("%s", resp.StarterCredit) + } return nil } @@ -181,7 +185,7 @@ func runLoginInteractive() error { keyHash := auth.HashClientKey(rawKey) keyPrefix := auth.KeyPrefix(rawKey) - _, err = client.VerifyLoginCode(email, code, keyHash, keyPrefix) + resp, err := client.VerifyLoginCode(email, code, keyHash, keyPrefix) if err != nil { if apiErr, ok := err.(*api.APIError); ok { display.Error("%s", apiErr.UserMessage()) @@ -198,6 +202,10 @@ func runLoginInteractive() error { } display.Success("Logged in successfully") + if resp != nil && resp.StarterCredit != "" { + fmt.Fprintln(os.Stderr) + display.InfoBold("%s", resp.StarterCredit) + } // Enter interactive mode tui.Version = Version() diff --git a/internal/display/display.go b/internal/display/display.go index 85b9c24..d0c3d19 100644 --- a/internal/display/display.go +++ b/internal/display/display.go @@ -44,6 +44,10 @@ func Info(msg string, args ...interface{}) { fmt.Fprintf(os.Stderr, " %s\n", fmt.Sprintf(msg, args...)) } +func InfoBold(msg string, args ...interface{}) { + fmt.Fprintf(os.Stderr, " \033[1m%s\033[0m\n", fmt.Sprintf(msg, args...)) +} + func Label(label, value string) { fmt.Fprintf(os.Stderr, " %s %s\n", labelStyle.Render(label+":"), diff --git a/internal/session/session.go b/internal/session/session.go index 7e26071..0bbb231 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -81,12 +81,6 @@ type StartOpts struct { Mode string // "proxy", "run", or "" (interactive) } -// StartWithCredentials starts a local proxy using pre-existing session credentials -// (from a rotate response). Does not call the API to create a session. -func StartWithCredentials(ctx context.Context, client *api.Client, resp *api.SessionCreateResponse, port int) (*Session, error) { - return startWithResponse(ctx, client, resp, port, StartOpts{}) -} - // Start creates a new session via the API and starts the local proxy. func Start(ctx context.Context, client *api.Client, req *api.SessionCreateRequest, port int, opts ...StartOpts) (*Session, error) { // Bind port before API call — fail fast if port unavailable @@ -112,9 +106,9 @@ func Start(ctx context.Context, client *api.Client, req *api.SessionCreateReques return startWithResponse(ctx, client, resp, port, sOpts) } -// startWithResponse is the shared implementation for Start and StartWithCredentials. +// startWithResponse is the shared implementation for session start. func startWithResponse(ctx context.Context, client *api.Client, resp *api.SessionCreateResponse, port int, opts StartOpts) (*Session, error) { - // If port not yet bound (StartWithCredentials path), bind now + // If port not yet bound, bind now if port == 0 { ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 798d0a1..0f1e47a 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -25,11 +25,11 @@ func Run(cfg *config.Config) error { client := api.New(cfg.APIURL, cfg.APIKey) - // Show balance warning on startup + // Show balance warning on startup (threshold from server) if bal, err := client.GetBalance(); err == nil { if bal.BalanceUSD == 0 { display.Warn("Balance $0.00 — top up to use at https://console.shellroute.com") - } else if bal.BalanceUSD < 0.50 { + } else if bal.LowBalance { display.Warn("Balance low (%s) — top up at https://console.shellroute.com", display.FormatBalance(bal.BalanceUSD)) } } @@ -43,35 +43,6 @@ func Run(cfg *config.Config) error { runShell(ctrlPort, "", cfg.DefaultType) - // Disconnect if session is still active - if resp := ctrl.StopAndDisconnect(); resp != nil { - printSessionSummary(resp) - } - - return nil -} - -// RunWithConnect starts the shell and auto-connects to a country. -func RunWithConnect(cfg *config.Config, country string) error { - client := api.New(cfg.APIURL, cfg.APIKey) - - if bal, err := client.GetBalance(); err == nil { - if bal.BalanceUSD == 0 { - display.Warn("Balance $0.00 — top up to use at https://console.shellroute.com") - } else if bal.BalanceUSD < 0.50 { - display.Warn("Balance low (%s) — top up at https://console.shellroute.com", display.FormatBalance(bal.BalanceUSD)) - } - } - - ctrl := session.NewController(client, cfg) - - ctrlPort, err := ctrl.Start() - if err != nil { - return fmt.Errorf("control server: %w", err) - } - - runShell(ctrlPort, country, cfg.DefaultType) - if resp := ctrl.StopAndDisconnect(); resp != nil { printSessionSummary(resp) } @@ -114,9 +85,11 @@ func runShell(ctrlPort int, autoConnect string, defaultType string) { if defaultType == "" { defaultType = "residential" } + cfgDir, _ := config.Dir() env := append(os.Environ(), fmt.Sprintf("SHELLROUTE_CTRL=%d", ctrlPort), fmt.Sprintf("SHELLROUTE_IPTYPE=%s", defaultType), + fmt.Sprintf("SHELLROUTE_CONFIG_DIR=%s", cfgDir), "SHELL_SESSIONS_DISABLE=1", // suppress macOS session restore on exit "BASH_SILENCE_DEPRECATION_WARNING=1", // suppress macOS "default shell is now zsh" nag ) diff --git a/internal/tui/tui_shell.go b/internal/tui/tui_shell.go index 9043e03..68213fc 100644 --- a/internal/tui/tui_shell.go +++ b/internal/tui/tui_shell.go @@ -310,8 +310,8 @@ func writeSettingsCommands(f *os.File) { } /logout() { - # Clear stored credentials - local config_file="${HOME}/.shellroute/config.toml" + # Clear stored credentials (respects local mode via SHELLROUTE_CONFIG_DIR) + local config_file="${SHELLROUTE_CONFIG_DIR:-${HOME}/.shellroute}/config.toml" if [ -f "$config_file" ]; then sed -i.bak 's/^api_key = .*/api_key = ""/' "$config_file" 2>/dev/null || \ sed -i '' 's/^api_key = .*/api_key = ""/' "$config_file" 2>/dev/null diff --git a/npm/cli-darwin-arm64/package.json b/npm/cli-darwin-arm64/package.json index 0aa2c57..f78a251 100644 --- a/npm/cli-darwin-arm64/package.json +++ b/npm/cli-darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@shellroute/cli-darwin-arm64", - "version": "0.1.0", + "version": "0.0.0", "os": [ "darwin" ], diff --git a/npm/cli-darwin-x64/package.json b/npm/cli-darwin-x64/package.json index 391beea..b0f0aa1 100644 --- a/npm/cli-darwin-x64/package.json +++ b/npm/cli-darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@shellroute/cli-darwin-x64", - "version": "0.1.0", + "version": "0.0.0", "os": [ "darwin" ], diff --git a/npm/cli-linux-arm64/package.json b/npm/cli-linux-arm64/package.json index 414a040..322d9c8 100644 --- a/npm/cli-linux-arm64/package.json +++ b/npm/cli-linux-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@shellroute/cli-linux-arm64", - "version": "0.1.0", + "version": "0.0.0", "os": [ "linux" ], diff --git a/npm/cli-linux-x64/package.json b/npm/cli-linux-x64/package.json index f0dd549..c689459 100644 --- a/npm/cli-linux-x64/package.json +++ b/npm/cli-linux-x64/package.json @@ -1,6 +1,6 @@ { "name": "@shellroute/cli-linux-x64", - "version": "0.1.0", + "version": "0.0.0", "os": [ "linux" ], diff --git a/npm/shellroute/README.md b/npm/shellroute/README.md new file mode 100644 index 0000000..64eda35 --- /dev/null +++ b/npm/shellroute/README.md @@ -0,0 +1,26 @@ +# shellroute + +A proxied shell for terminal workflows. Every terminal can be somewhere else. + +## Install + +```bash +npm install -g shellroute +``` + +## First session + +```bash +# Log in. A new account is created automatically. +shellroute login + +# Shellroute opens after login. Run these inside it: +/connect US +curl https://ipinfo.io/json +``` + +[Follow the two-minute quickstart](https://shellroute.com/docs/quickstart?utm_source=npm&utm_medium=package-readme&utm_campaign=cli_readme). + +The CLI is open source and connects to the shellroute service. The service uses prepaid credits. + +[GitHub](https://github.com/shellroute/shellroute-cli) · [Pricing](https://shellroute.com/pricing) · [Compatibility](https://shellroute.com/docs/compatibility) · [Acceptable use](https://shellroute.com/acceptable-use) diff --git a/npm/shellroute/package.json b/npm/shellroute/package.json index f86b307..2a79370 100644 --- a/npm/shellroute/package.json +++ b/npm/shellroute/package.json @@ -1,7 +1,7 @@ { "name": "shellroute", - "version": "0.1.0", - "description": "Residential proxy CLI", + "version": "0.0.0", + "description": "A proxied shell for terminal workflows. Open a session, choose a proxy, and run commands normally.", "license": "Apache-2.0", "repository": { "type": "git", @@ -11,15 +11,28 @@ "files": [ "bin", "LICENSE", - "NOTICE" + "NOTICE", + "README.md" ], "bin": { "shellroute": "bin/shellroute" }, "optionalDependencies": { - "@shellroute/cli-darwin-arm64": "0.1.0", - "@shellroute/cli-darwin-x64": "0.1.0", - "@shellroute/cli-linux-arm64": "0.1.0", - "@shellroute/cli-linux-x64": "0.1.0" - } + "@shellroute/cli-darwin-arm64": "0.0.0", + "@shellroute/cli-darwin-x64": "0.0.0", + "@shellroute/cli-linux-arm64": "0.0.0", + "@shellroute/cli-linux-x64": "0.0.0" + }, + "keywords": [ + "automation", + "cli", + "command-line-tool", + "developer-tools", + "geo-testing", + "http-proxy", + "network-debugging", + "proxy", + "shell", + "terminal" + ] }