Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ Services initialize in `cmd/daemon/main.go`: check enabled flag → create → s
- Local overrides: `$HOME/.shelltime/config.local.yaml` (merged over the base, gitignored)
- Daemon socket: `/tmp/shelltime.sock` (configurable via `socketPath`)
- AICodeOtel gRPC port: configurable via `aiCodeOtel.grpcPort` (default: 54027)
- Outbound proxy: `proxy.url` / `proxy.noProxy` (http, https, socks5, socks5h). Applied once at startup via `model.ConfigureProxy`; every outbound HTTP client must use `model.NewHTTPClient` or `model.HTTPTransport()` so the proxy applies

## Commit Rules

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ ShellTime stores data under `~/.shelltime/`.
- Local overrides: `~/.shelltime/config.local.yaml`
- Also supported: `config.yml`, `config.toml`, `config.local.yml`, `config.local.toml`
- Generated schema: `~/.shelltime/config-schema.json`
- Proxy: set `proxy.url` (`http`, `https`, `socks5`, `socks5h`) to route all outbound traffic through a proxy. See [Network Proxy](docs/CONFIG.md#network-proxy)

Minimal example:

Expand Down
5 changes: 5 additions & 0 deletions cmd/cli/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ func main() {
}

cfg, err := configService.ReadConfigFile(ctx)
if err == nil {
if proxyErr := model.ConfigureProxy(cfg.Proxy); proxyErr != nil {
slog.Warn("invalid proxy config, falling back to environment proxy", slog.Any("err", proxyErr))
}
}
if err != nil ||
cfg.EnableMetrics == nil ||
*cfg.EnableMetrics == false ||
Expand Down
6 changes: 6 additions & 0 deletions cmd/daemon/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ func main() {

slog.DebugContext(ctx, "daemon.config", slog.Any("config", cfg))

if err := model.ConfigureProxy(cfg.Proxy); err != nil {
slog.Warn("invalid proxy config, falling back to environment proxy", slog.Any("err", err))
} else if cfg.Proxy != nil && cfg.Proxy.URL != "" {
slog.Info("proxy enabled", slog.String("url", model.RedactProxyURL(cfg.Proxy.URL)))
}

uptraceOptions := []uptrace.Option{
uptrace.WithDSN(uptraceDsn),
uptrace.WithServiceName("cli-daemon"),
Expand Down
2 changes: 2 additions & 0 deletions commands/config_view.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,8 @@ func flattenConfig(v interface{}, prefix string) []keyValuePair {
} else {
value = "****"
}
} else if fullKey == "proxy.url" {
value = model.RedactProxyURL(value)
}
pairs = append(pairs, keyValuePair{key: fullKey, value: value})
default:
Expand Down
11 changes: 11 additions & 0 deletions commands/config_view_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,17 @@ func TestFlattenConfig_NestedTokenMasking(t *testing.T) {
assert.Contains(t, v, "supersecrettoken")
}

func TestFlattenConfig_ProxyURLPasswordRedacted(t *testing.T) {
cfg := model.ShellTimeConfig{
Proxy: &model.ProxyConfig{URL: "socks5h://alice:hunter2@127.0.0.1:1080"},
}
pairs := flattenConfig(cfg, "")
v, ok := findPair(pairs, "proxy.url")
require.True(t, ok)
assert.NotContains(t, v, "hunter2")
assert.Equal(t, "socks5h://alice:xxxxx@127.0.0.1:1080", v)
}

func TestFlattenConfig_NonStructReturnsEmpty(t *testing.T) {
assert.Empty(t, flattenConfig(42, ""))
assert.Empty(t, flattenConfig("just a string", ""))
Expand Down
4 changes: 3 additions & 1 deletion daemon/anthropic_ratelimit.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import (
"strings"
"sync"
"time"

"github.com/malamtime/cli/model"
)

const anthropicUsageCacheTTL = 10 * time.Minute
Expand Down Expand Up @@ -176,7 +178,7 @@ func fetchAnthropicUsage(ctx context.Context, token, version string) (*Anthropic
req.Header.Set("User-Agent", "claude-code/"+version)
req.Header.Set("Content-Type", "application/json")

client := &http.Client{Timeout: 5 * time.Second}
client := &http.Client{Timeout: 5 * time.Second, Transport: model.HTTPTransport()}
resp, err := client.Do(req)
if err != nil {
return nil, err
Expand Down
4 changes: 3 additions & 1 deletion daemon/codex_ratelimit.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import (
"strings"
"sync"
"time"

"github.com/malamtime/cli/model"
)

const codexUsageCacheTTL = 10 * time.Minute
Expand Down Expand Up @@ -211,7 +213,7 @@ type whamRateLimitWindow struct {

// fetchCodexUsage calls the Codex usage API and returns rate limit data.
func fetchCodexUsage(ctx context.Context, auth *codexAuthData) (*CodexRateLimitData, error) {
client := &http.Client{Timeout: 5 * time.Second}
client := &http.Client{Timeout: 5 * time.Second, Transport: model.HTTPTransport()}
return fetchCodexUsageFromEndpoint(ctx, auth, codexUsageEndpoint, client)
}

Expand Down
47 changes: 47 additions & 0 deletions docs/CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ This guide covers every configuration option in ShellTime CLI. ShellTime runs fi
- [AI Features](#ai-features)
- [Claude Code Integration](#claude-code-integration)
- [Codex Usage Tracking](#codex-usage-tracking)
- [Network Proxy](#network-proxy)
- [Advanced Settings](#advanced-settings)
- [Complete Example](#complete-example)
- [FAQ](#faq)
Expand Down Expand Up @@ -320,6 +321,47 @@ When `apiEndpoint` or `token` is set under `codeTracking`, heartbeats use those

---

## Network Proxy

Send all outbound HTTP(S) traffic from the CLI and the daemon through a proxy. This covers syncing to shelltime.xyz, the AI command suggestions, `shelltime update`, and the Claude Code / Codex quota lookups.

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `proxy.url` | string | - | Proxy URL. Schemes: `http`, `https`, `socks5`, `socks5h` |
| `proxy.noProxy` | string[] | `[]` | Hosts that bypass the proxy (`NO_PROXY` syntax) |

```yaml
proxy:
url: "socks5h://127.0.0.1:7890"
noProxy:
- "localhost"
- ".corp.example.com" # domain and all subdomains
- "10.0.0.0/8" # CIDR ranges
```

**Supported proxy URLs:**

| Scheme | Example | Notes |
|--------|---------|-------|
| `http` | `http://127.0.0.1:8080` | Plain HTTP proxy. HTTPS requests are tunneled with `CONNECT` |
| `https` | `https://proxy.corp.com:443` | TLS connection to the proxy itself |
| `socks5` | `socks5://127.0.0.1:1080` | SOCKS5. Hostnames are resolved by the proxy |
| `socks5h` | `socks5h://127.0.0.1:1080` | Same as `socks5` |
| _(none)_ | `127.0.0.1:7890` | Treated as `http://127.0.0.1:7890` |

Credentials can be embedded in the URL, e.g. `http://user:pass@proxy:8080` or `socks5://user:pass@127.0.0.1:1080`. `shelltime config view` masks the password.

**Notes:**
- When `proxy` is not set, the standard `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` environment variables are used. When it is set, it takes precedence over them.
- Requests to `localhost` and loopback addresses never go through the proxy.
- SOCKS4 is not supported.
- An invalid proxy URL is logged as a warning, and the environment-variable proxy is used instead.
- The daemon reads the proxy on startup, so restart it after changing this setting.
- The optional OTEL metrics exporter (`enableMetrics`) and the `ccusage` subprocess only honor the environment variables.
- Put a machine-specific proxy in `config.local.yaml` to keep it out of a shared config.

---

## Advanced Settings

### Multiple Endpoints
Expand Down Expand Up @@ -437,6 +479,11 @@ logCleanup:
enabled: true
thresholdMB: 100

# --- Network Proxy ---
# proxy:
# url: "socks5h://127.0.0.1:7890" # http, https, socks5, socks5h
# noProxy: ["localhost", ".corp.example.com"]

# --- Advanced ---
socketPath: "/tmp/shelltime.sock"
enableMetrics: false
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ require (
go.opentelemetry.io/otel v1.39.0
go.opentelemetry.io/otel/trace v1.39.0
go.opentelemetry.io/proto/otlp v1.9.0
golang.org/x/net v0.48.0
google.golang.org/grpc v1.77.0
gopkg.in/yaml.v3 v3.0.1
)
Expand Down Expand Up @@ -79,7 +80,6 @@ require (
go.opentelemetry.io/otel/sdk v1.39.0 // indirect
go.opentelemetry.io/otel/sdk/log v0.15.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.39.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/term v0.38.0 // indirect
golang.org/x/text v0.32.0 // indirect
Expand Down
2 changes: 1 addition & 1 deletion model/ai_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ func (s *sseAIService) QueryCommandStream(
req.Header.Set("Accept", "text/event-stream")
req.Header.Set("Authorization", "CLI "+endpoint.Token)

client := &http.Client{Timeout: 2 * time.Minute}
client := &http.Client{Timeout: 2 * time.Minute, Transport: HTTPTransport()}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to send request: %w", err)
Expand Down
7 changes: 1 addition & 6 deletions model/api.base.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,6 @@ import (
"log/slog"
"net/http"
"time"

"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)

// HTTPRequestOptions contains all options for sending an HTTP request
Expand Down Expand Up @@ -42,10 +40,7 @@ func SendHTTPRequestJSON[T any, R any](opts HTTPRequestOptions[T, R]) error {
timeout = opts.Timeout
}

client := &http.Client{
Timeout: timeout,
Transport: otelhttp.NewTransport(http.DefaultTransport),
}
client := NewHTTPClient(timeout)

req, err := http.NewRequestWithContext(ctx, opts.Method, opts.Endpoint.APIEndpoint+opts.Path, bytes.NewBuffer(jsonData))
if err != nil {
Expand Down
3 changes: 3 additions & 0 deletions model/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,9 @@ func mergeConfig(base, local *ShellTimeConfig) {
if local.CodeTracking != nil {
base.CodeTracking = local.CodeTracking
}
if local.Proxy != nil {
base.Proxy = local.Proxy
}
if local.LogCleanup != nil {
base.LogCleanup = local.LogCleanup
}
Expand Down
3 changes: 3 additions & 0 deletions model/config_cov_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ func TestMergeConfig_AllOverrides(t *testing.T) {
LogCleanup: &LogCleanup{Enabled: &truthy, ThresholdMB: 42},
SocketPath: "/tmp/local.sock",
CodeTracking: &CodeTracking{Token: "ct"},
Proxy: &ProxyConfig{URL: "socks5://127.0.0.1:1080"},
}

mergeConfig(base, local)
Expand All @@ -61,6 +62,8 @@ func TestMergeConfig_AllOverrides(t *testing.T) {
assert.EqualValues(t, 42, base.LogCleanup.ThresholdMB)
assert.Equal(t, "/tmp/local.sock", base.SocketPath)
require.NotNil(t, base.CodeTracking)
require.NotNil(t, base.Proxy)
assert.Equal(t, "socks5://127.0.0.1:1080", base.Proxy.URL)
}

// TestMergeConfig_CCOtelMigration covers the deprecated CCOtel -> AICodeOtel
Expand Down
47 changes: 47 additions & 0 deletions model/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -578,3 +578,50 @@ token = 'local-heartbeat-token'`
assert.Equal(t, "https://api.local-heartbeat.com", config.CodeTracking.APIEndpoint)
assert.Equal(t, "local-heartbeat-token", config.CodeTracking.Token)
}

func TestReadConfig_Proxy(t *testing.T) {
t.Run("yaml with local override", func(t *testing.T) {
tmpDir := t.TempDir()
yamlConfig := `token: yaml-token
proxy:
url: http://proxy.base:8080
noProxy:
- localhost
- .corp.example.com`
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "config.yaml"), []byte(yamlConfig), 0644))
localConfig := `proxy:
url: socks5h://user:pass@127.0.0.1:1080`
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "config.local.yaml"), []byte(localConfig), 0644))

config, err := NewConfigService(tmpDir).ReadConfigFile(context.Background())
require.NoError(t, err)
require.NotNil(t, config.Proxy)
assert.Equal(t, "socks5h://user:pass@127.0.0.1:1080", config.Proxy.URL)
assert.Empty(t, config.Proxy.NoProxy, "local proxy block replaces the base block")
})

t.Run("toml", func(t *testing.T) {
tmpDir := t.TempDir()
tomlConfig := `Token = 'toml-token'

[proxy]
url = 'socks5://127.0.0.1:1080'
noProxy = ['localhost', '10.0.0.0/8']`
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "config.toml"), []byte(tomlConfig), 0644))

config, err := NewConfigService(tmpDir).ReadConfigFile(context.Background())
require.NoError(t, err)
require.NotNil(t, config.Proxy)
assert.Equal(t, "socks5://127.0.0.1:1080", config.Proxy.URL)
assert.Equal(t, []string{"localhost", "10.0.0.0/8"}, config.Proxy.NoProxy)
})

t.Run("unset", func(t *testing.T) {
tmpDir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "config.yaml"), []byte("token: t"), 0644))

config, err := NewConfigService(tmpDir).ReadConfigFile(context.Background())
require.NoError(t, err)
assert.Nil(t, config.Proxy)
})
}
7 changes: 1 addition & 6 deletions model/handshake.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,6 @@ import (
"net/http"
"os"
"time"

"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)

type handshakeResponse struct {
Expand All @@ -39,10 +37,7 @@ func NewHandshakeService(config ShellTimeConfig) HandshakeService {
}

func (hs handshakeService) send(ctx context.Context, path string, jsonData []byte) (result handshakeResponse, errResp errorResponse, err error) {
hc := http.Client{
Timeout: time.Second * 30,
Transport: otelhttp.NewTransport(http.DefaultTransport),
}
hc := NewHTTPClient(time.Second * 30)

req, err := http.NewRequestWithContext(ctx, "POST", hs.config.APIEndpoint+"/api/v1/handshake"+path, bytes.NewBuffer(jsonData))
if err != nil {
Expand Down
Loading
Loading