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
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ go test -run TestHandlerName ./daemon/

Tests use **testify** (assertions + suites). Suite-based tests use `suite.Suite` with `SetupTest`/`TearDownTest` lifecycle hooks (see `daemon/cc_info_handler_test.go` for example). Simple functions use table-driven tests.

Tests must never reach the daemon running on the machine (yours, or a Claude Code cloud session's): it uploads to a real account. `commands/main_test.go` points `track`'s fast-path socket (`trackSocketPath`) at a path that doesn't exist, and every other test uses a temp `socketPath`. The `commands` and `daemon` `TestMain`s clear `CLAUDE_CODE_REMOTE*`, so tests behave the same inside a cloud session as on CI.

### Performance (`shelltime track` latency)
`perf/` is a separate Go module (root `./...` skips it). It benchmarks the real binary the way the shell hooks run it: daemon and direct paths, pre and post, plus a sync. See `perf/README.md`.
```bash
Expand Down Expand Up @@ -113,6 +115,7 @@ Services initialize in `cmd/daemon/main.go`: check enabled flag → create → s
- 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
- Token `proxy-injected` (`model.ProxyInjectedToken`): a Claude Code cloud environment keeps the real token as a network secret, and its agent proxy adds `Authorization: CLI <token>` to requests for `api.shelltime.xyz`. Requests to the API then carry no `Authorization` header of their own (`setCLIAuthorization`); set it through that helper, never by hand. `cloud.bash` in the installation repo writes it
- AI cloud only (`aiCloudOnly`, `model.IsAICloudOnly`): collect only AI coding agent telemetry and nothing about the machine, so it never becomes a host. Unset, it is on where `CLAUDE_CODE_REMOTE=true` (a Claude Code cloud VM); an explicit value wins. The daemon's socket drops `track_pre`/`track_post`/`sync` and heartbeats, and `cmd/daemon/main.go` skips the bolt store, sync circuit breaker and heartbeat resync; OTEL, `cc pr` and session-project pass through. In the CLI, `track` without a daemon stores nothing, and `sync`, `alias import`, `dotfiles push` and `hooks install` print why and return nil (`skippedInAICloudOnly`). `doctor` reports it under Configuration and skips Shell Hooks

## Commit Rules

Expand Down
26 changes: 18 additions & 8 deletions cmd/daemon/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,17 @@ func main() {
daemon.Init(daemonConfigService, version)
model.InjectVar(version)

// In AI cloud only mode (on by default in a Claude Code cloud environment) the socket drops
// commands and heartbeats, so nothing that stores, syncs or resends them is started.
aiCloudOnly := model.IsAICloudOnly(cfg, os.Getenv)
if aiCloudOnly {
slog.Info("AI cloud only mode: only AI coding agent telemetry is collected, no commands, heartbeats or host info",
slog.Bool("fromConfig", cfg.AICloudOnly != nil))
}

// When the bolt storage engine is enabled, the daemon owns the bolt-backed
// command store for its lifetime (bbolt holds an exclusive file lock).
if cfg.Storage != nil && cfg.Storage.Engine == model.StorageEngineBolt {
if !aiCloudOnly && cfg.Storage != nil && cfg.Storage.Engine == model.StorageEngineBolt {
store, err := model.NewCommandStore(cfg)
if err != nil {
slog.Error("Failed to open bolt command store", slog.Any("err", err))
Expand All @@ -109,12 +117,14 @@ func main() {
}

// Start sync circuit breaker service
syncCircuitBreakerService := daemon.NewSyncCircuitBreakerService(pubsub)
if err := syncCircuitBreakerService.Start(ctx); err != nil {
slog.Error("Failed to start sync circuit breaker service", slog.Any("err", err))
} else {
slog.Info("Sync circuit breaker service started")
defer syncCircuitBreakerService.Stop()
if !aiCloudOnly {
syncCircuitBreakerService := daemon.NewSyncCircuitBreakerService(pubsub)
if err := syncCircuitBreakerService.Start(ctx); err != nil {
slog.Error("Failed to start sync circuit breaker service", slog.Any("err", err))
} else {
slog.Info("Sync circuit breaker service started")
defer syncCircuitBreakerService.Stop()
}
}

// Start cleanup timer service if enabled (enabled by default)
Expand Down Expand Up @@ -145,7 +155,7 @@ func main() {
}

// Start heartbeat resync service if codeTracking is enabled
if cfg.CodeTracking != nil && cfg.CodeTracking.Enabled != nil && *cfg.CodeTracking.Enabled {
if !aiCloudOnly && cfg.CodeTracking != nil && cfg.CodeTracking.Enabled != nil && *cfg.CodeTracking.Enabled {
heartbeatResyncService := daemon.NewHeartbeatResyncService(cfg)
if err := heartbeatResyncService.Start(ctx); err != nil {
slog.Error("Failed to start heartbeat resync service", slog.Any("err", err))
Expand Down
32 changes: 32 additions & 0 deletions commands/ai_cloud_only.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package commands

import (
"context"
"os"

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

const aiCloudOnlyReason = "AI cloud only mode collects only AI coding agent usage on this machine " +
"(aiCloudOnly in the config, on by default in a Claude Code cloud environment)"

// skippedInAICloudOnly reports whether AI cloud only mode is on for cfg, after saying which
// action it skipped. Commands that would upload commands or anything about the machine stop then.
func skippedInAICloudOnly(cfg model.ShellTimeConfig, action string) bool {
if !model.IsAICloudOnly(cfg, os.Getenv) {
return false
}
color.Yellow.Printf("%s: %s.\n", action, aiCloudOnlyReason)
return true
}

// configForAICloudOnly reads the config for a command that can run before signing in, so there
// may be none yet; the environment alone decides the mode then.
func configForAICloudOnly(ctx context.Context) model.ShellTimeConfig {
cfg, err := model.NewConfigService(os.ExpandEnv("$HOME/" + model.COMMAND_BASE_STORAGE_FOLDER)).ReadConfigFile(ctx)
if err != nil {
return model.ShellTimeConfig{}
}
return cfg
}
174 changes: 174 additions & 0 deletions commands/ai_cloud_only_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
package commands

import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sync/atomic"
"testing"
"time"

"github.com/malamtime/cli/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"github.com/urfave/cli/v2"
)

// aiCloudOnlyCases turns the mode on through the config, then through the Claude Code cloud
// environment with the key unset.
var aiCloudOnlyCases = []struct {
name string
cfg model.ShellTimeConfig
remote string
}{
{name: "config", cfg: model.ShellTimeConfig{AICloudOnly: new(true)}},
{name: "cloud environment", remote: "true"},
}

// uploadRecorder is an API server that counts the requests it gets.
func uploadRecorder(t *testing.T) (*httptest.Server, *atomic.Int32) {
t.Helper()
var requests atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
w.WriteHeader(http.StatusNoContent)
}))
t.Cleanup(srv.Close)
return srv, &requests
}

// Without a daemon, `track` stores nothing in AI cloud only mode, so a later sync has nothing
// to upload.
func TestCommandTrack_AICloudOnlyStoresNothing(t *testing.T) {
for _, tt := range aiCloudOnlyCases {
t.Run(tt.name, func(t *testing.T) {
_, mc := x3SetupTrack(t)
if tt.remote != "" {
t.Setenv("CLAUDE_CODE_REMOTE", tt.remote)
}
srv, requests := uploadRecorder(t)
cfg := tt.cfg
cfg.Token, cfg.APIEndpoint, cfg.FlushCount = "tok", srv.URL, 3
cfg.SocketPath = x3UnreadySocket(t)
mc.On("ReadConfigFile", mock.Anything).Return(cfg, nil)

app := &cli.App{Name: "t", Commands: []*cli.Command{TrackCommand}}
for _, phase := range []string{"pre", "post"} {
require.NoError(t, app.Run([]string{"t", "track", "--phase", phase, "--shell", "bash", "--command", "go test ./...", "--id", "1"}))
}

_, err := os.Stat(model.GetCommandsStoragePath())
assert.True(t, os.IsNotExist(err), "no command buffer is written")
assert.Zero(t, requests.Load())
})
}
}

// sync, alias import and dotfiles push send commands or describe this machine, which would make
// it a host: in AI cloud only mode they say so and send nothing.
func TestUploadCommands_AICloudOnlySendNothing(t *testing.T) {
runs := map[string]struct {
cmd *cli.Command
args []string
}{
"sync": {cmd: SyncCommand, args: []string{"t", "sync"}},
"alias import": {cmd: AliasCommand, args: []string{"t", "alias", "import"}},
"dotfiles push": {cmd: DotfilesCommand, args: []string{"t", "dotfiles", "push"}},
}
for name, run := range runs {
for _, tt := range aiCloudOnlyCases {
t.Run(name+"/"+tt.name, func(t *testing.T) {
mc := setupAliasTest(t)
home := t.TempDir()
t.Setenv("HOME", home)
if tt.remote != "" {
t.Setenv("CLAUDE_CODE_REMOTE", tt.remote)
}
// Something to upload for each command, if it ran
require.NoError(t, os.WriteFile(filepath.Join(home, ".zshrc"), []byte("alias gs='git status'\n"), 0644))
require.NoError(t, os.WriteFile(filepath.Join(home, ".bashrc"), []byte("export A=1\n"), 0644))
buffered := model.Command{Shell: "bash", SessionID: 1, Command: "go test ./...", Hostname: "vm", Username: "root", Time: time.Now()}
require.NoError(t, buffered.DoSavePre())
buffered.Time = buffered.Time.Add(time.Second)
require.NoError(t, buffered.DoUpdate(0))

srv, requests := uploadRecorder(t)
cfg := tt.cfg
cfg.Token, cfg.APIEndpoint = "tok", srv.URL
cfg.SocketPath = x3UnreadySocket(t)
mc.On("ReadConfigFile", mock.Anything).Return(cfg, nil)

app := &cli.App{Name: "t", Commands: []*cli.Command{run.cmd}}
require.NoError(t, app.Run(run.args))
assert.Zero(t, requests.Load())
})
}
}
}

// hooks install can run before signing in, so it reads the config itself: off in the config wins
// over the cloud environment's default.
func TestCommandHooksInstall_AICloudOnly(t *testing.T) {
tests := []struct {
name string
config string
remote string
installed bool
}{
{name: "cloud environment without a config", remote: "true", installed: false},
{name: "on in the config", config: "aiCloudOnly: true\n", installed: false},
{name: "off in the config in the cloud", config: "aiCloudOnly: false\n", remote: "true", installed: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
SKIP_LOGGER_SETTINGS = true
home := t.TempDir()
t.Setenv("HOME", home)
if tt.remote != "" {
t.Setenv("CLAUDE_CODE_REMOTE", tt.remote)
}
base := filepath.Join(home, model.COMMAND_BASE_STORAGE_FOLDER)
require.NoError(t, os.MkdirAll(filepath.Join(base, "bin"), 0755))
// bash-preexec.sh is pre-seeded so installing the bash hook needs no network
require.NoError(t, os.MkdirAll(filepath.Join(base, "hooks"), 0755))
require.NoError(t, os.WriteFile(filepath.Join(base, "hooks", "bash-preexec.sh"), []byte("# stub\n"), 0644))
if tt.config != "" {
require.NoError(t, os.WriteFile(filepath.Join(base, "config.yaml"), []byte("token: tok\n"+tt.config), 0600))
}

app := &cli.App{Name: "t", Commands: []*cli.Command{HooksInstallCommand}}
require.NoError(t, app.Run([]string{"t", "install"}))

_, err := os.Stat(filepath.Join(home, ".bashrc"))
assert.Equal(t, tt.installed, err == nil, "bash hook installed")
})
}
}

// doctor names the mode and where it comes from, and doesn't ask for shell hooks.
func TestDoctor_AICloudOnly(t *testing.T) {
home, mc := setupDoctorTest(t)
writeDoctorConfigFile(t, home)
t.Setenv("CLAUDE_CODE_REMOTE", "true")
cfg := doctorTestConfig(t)
mc.On("ReadConfigFile", mock.Anything).Return(cfg, nil)

env := newDoctorEnv(context.Background(), doctorOptions{offline: true})
require.True(t, env.aiCloudOnly)
mode := findDoctorResult(t, doctorCheckConfig(env), "config.ai_cloud_only")
assert.Equal(t, doctorInfo, mode.Status)
assert.Contains(t, mode.Message, "Claude Code cloud environment")
hooks := doctorCheckHooks(env)
require.Len(t, hooks, 1)
assert.Equal(t, doctorSkip, hooks[0].Status)

env.cfg.AICloudOnly = new(true)
assert.Contains(t, findDoctorResult(t, doctorCheckConfig(env), "config.ai_cloud_only").Message, "in your config")

env.aiCloudOnly = false
assert.False(t, hasDoctorResult(doctorCheckConfig(env), "config.ai_cloud_only"))
assert.NotEqual(t, doctorSkip, findDoctorResult(t, doctorCheckHooks(env), "hooks.shell").Status)
}
3 changes: 3 additions & 0 deletions commands/alias.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ func importAliases(c *cli.Context) error {
slog.Error("failed to read config file", slog.Any("err", err))
return err
}
if skippedInAICloudOnly(config, "Aliases not imported") {
return nil
}

mainEndpoint := model.Endpoint{
APIEndpoint: config.APIEndpoint,
Expand Down
22 changes: 22 additions & 0 deletions commands/doctor_checks.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ type doctorEnv struct {
cfgErr error
shell string
socketPath string
// aiCloudOnly: shell commands and anything else about the machine aren't collected here.
aiCloudOnly bool

daemonStatus *daemon.StatusResponse

Expand Down Expand Up @@ -137,6 +139,11 @@ func newDoctorEnv(ctx context.Context, opts doctorOptions) *doctorEnv {
if env.cfgErr == nil && env.cfg.SocketPath != "" {
env.socketPath = env.cfg.SocketPath
}
if env.cfgErr == nil {
env.aiCloudOnly = model.IsAICloudOnly(env.cfg, os.Getenv)
} else {
env.aiCloudOnly = model.IsAICloudOnly(model.ShellTimeConfig{}, os.Getenv)
}
if status, _, err := requestDaemonStatus(env.socketPath, doctorDaemonProbeTimeout); err == nil {
env.daemonStatus = status
}
Expand Down Expand Up @@ -354,6 +361,18 @@ func doctorCheckConfig(env *doctorEnv) []doctorResult {
}

cfg := env.cfg
if env.aiCloudOnly {
source := "on by default in a Claude Code cloud environment"
if cfg.AICloudOnly != nil {
source = "aiCloudOnly is on in your config"
}
results = append(results, doctorResult{
ID: "config.ai_cloud_only",
Status: doctorInfo,
Message: fmt.Sprintf("AI cloud only mode (%s): only AI coding agent usage is uploaded, never shell commands, editor activity, aliases, dotfiles or this machine.", source),
})
}

endpointFix := fmt.Sprintf("Set `apiEndpoint: https://api.shelltime.xyz` in %s.", base)
if cfg.APIEndpoint == "" {
results = append(results, doctorResult{
Expand Down Expand Up @@ -647,6 +666,9 @@ var doctorShellFiles = map[string]struct{ rc, hook string }{
}

func doctorCheckHooks(env *doctorEnv) []doctorResult {
if env.aiCloudOnly {
return []doctorResult{{ID: "hooks.shell", Status: doctorSkip, Message: "Skipped: AI cloud only mode collects no shell commands, so no hooks are needed (see Configuration)."}}
}
if env.shell == "" {
return []doctorResult{{
ID: "hooks.shell",
Expand Down
3 changes: 3 additions & 0 deletions commands/dotfiles_push.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ func pushDotfiles(c *cli.Context) error {
slog.Error("failed to read config file", slog.Any("err", err))
return err
}
if skippedInAICloudOnly(config, "Dotfiles not pushed") {
return nil
}

if config.Token == "" {
return fmt.Errorf("no token found, please run 'shelltime auth login' first")
Expand Down
4 changes: 4 additions & 0 deletions commands/hooks.install.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ var HooksInstallCommand = &cli.Command{
}

func commandHooksInstall(c *cli.Context) error {
if skippedInAICloudOnly(configForAICloudOnly(c.Context), "Shell hooks not installed") {
return nil
}

binFolder := os.ExpandEnv(fmt.Sprintf("$HOME/%s/bin", model.COMMAND_BASE_STORAGE_FOLDER))
_, binFolderErr := os.Stat(binFolder)
_, lookPathErr := exec.LookPath("shelltime")
Expand Down
20 changes: 20 additions & 0 deletions commands/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package commands

import (
"fmt"
"os"
"path/filepath"
"testing"
)

// TestMain keeps the suite away from the ShellTime daemon of the machine it runs on: `track`
// hands commands to the daemon on the default socket before reading any config, so a running
// daemon (yours, or a Claude Code cloud session's) would upload the tests' commands to its
// account. It also clears the Claude Code cloud session environment, so the suite gives the same
// results inside a cloud session (where CLAUDE_CODE_REMOTE=true) as on CI.
func TestMain(m *testing.M) {
os.Unsetenv("CLAUDE_CODE_REMOTE")
os.Unsetenv("CLAUDE_CODE_REMOTE_SESSION_ID")
trackSocketPath = filepath.Join(os.TempDir(), fmt.Sprintf("shelltime-commands-test-%d.sock", os.Getpid()))
os.Exit(m.Run())
}
3 changes: 3 additions & 0 deletions commands/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ func commandSync(c *cli.Context) error {
slog.Error("failed to read config file", slog.Any("err", err))
return err
}
if skippedInAICloudOnly(config, "Nothing synced") {
return nil
}

isDryRun := c.Bool("dry-run")
return trySyncLocalToServer(ctx, config, syncOptions{
Expand Down
Loading
Loading