From 3f8450c91d7b674bacda3878ca33126b0687d406 Mon Sep 17 00:00:00 2001 From: Aman Jaiman Date: Tue, 7 Jul 2026 21:32:32 -0400 Subject: [PATCH 1/3] Improve macOS first-run defaults --- README.md | 9 ++-- cmd/sleeperagent/install.go | 86 +++++++++++++++++++++++++++++++- cmd/sleeperagent/install_test.go | 44 ++++++++++++++++ cmd/sleeperagent/main.go | 26 ++++++++-- cmd/sleeperagent/main_test.go | 18 +++++++ 5 files changed, 173 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index a00110b..9bd9485 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ Put it on your `PATH`: ``` If the install directory is not already on `PATH`, the command prints the exact `setx PATH` or `export PATH` line to run, plus a reminder to open a new shell. +On macOS/Linux, `install` also tries to add that PATH update to your shell profile automatically. **With the Go toolchain:** @@ -60,7 +61,7 @@ SleeperAgent needs a "session backend" — something it can run the agent inside | OS | Default backend | Setup | Handoff | |---|---|---|---| -| **Linux / macOS** | `tmux` | install `tmux` (`apt install tmux` / `brew install tmux`) | **full** — agent survives detach; `tmux attach` to take over | +| **Linux / macOS** | `tmux` when available, otherwise `pty` | optional `tmux` (`apt install tmux` / `brew install tmux`) for full handoff | **full** with tmux; reduced with pty | | **Linux / macOS** | `pty` (`--backend pty`) | none | reduced — agent is bound to the supervisor | | **Windows** | `pty` (ConPTY) | none (Windows 10 1809+) | reduced — agent is bound to the supervisor | @@ -103,7 +104,7 @@ sleeperagent run --agent claude --name mytask | `--name` | Instance / tmux session name (default `sleeperagent-`). | | `--prompt` | Static resume prompt to inject on reset. | | `--reprompt` | Local-LLM reprompt, e.g. `ollama:llama3.1` (falls back to static). | -| `--backend` | `tmux` (default on Unix) or `pty` (default on Windows). | +| `--backend` | `tmux` or `pty`. Unix defaults to tmux when it is available, otherwise pty; Windows defaults to pty. | | `--daemon` | Run in the background; control via `status`/`detach`/`stop`. | | `--watch-only` | Notify at the reset but **do not** auto-inject — you resume by hand. | | `--yolo` | Append the agent's skip-permissions flag (**DANGEROUS** — unattended, no prompts). | @@ -148,7 +149,7 @@ sleeperagent parse --agent claude "5-hour limit reached ∙ resets 2pm" SleeperAgent is built to get out of your way. How handoff works depends on the backend: -**tmux backend (Linux/macOS):** the agent lives in a tmux session that **outlives the supervisor**, so nothing is lost when you take over. +**tmux backend (Linux/macOS):** the agent lives in a tmux session that **outlives the supervisor**, so nothing is lost when you take over. Install tmux (`brew install tmux` on macOS) or pass `--backend tmux` if you specifically need this behavior. - **Hotkeys** (foreground run): `d`/`q` detach, `k` kills the session (with a `y` confirm). - **`sleeperagent detach --name X`** from any other shell. @@ -156,7 +157,7 @@ SleeperAgent is built to get out of your way. How handoff works depends on the b - **Auto-detach:** the moment you `tmux attach`, SleeperAgent notices and steps aside so you don't both type (disable with `--no-auto-detach`). - Reattach anytime with `tmux attach -t `. -**pty / ConPTY backend (default on Windows, optional on Unix):** the agent is a child of the supervisor, so it **can't be handed back interactively**. In the foreground, `detach` gives the terminal back to you until the agent exits; in `--daemon` mode, `detach`/`stop` ends the agent. Use the tmux backend if you need full handoff. +**pty / ConPTY backend (default on Windows, automatic Unix fallback when tmux is missing):** the agent is a child of the supervisor, so it **can't be handed back interactively**. In the foreground, `detach` gives the terminal back to you until the agent exits; in `--daemon` mode, `detach`/`stop` ends the agent. Use the tmux backend if you need full handoff. --- diff --git a/cmd/sleeperagent/install.go b/cmd/sleeperagent/install.go index 62a8629..6783cdf 100644 --- a/cmd/sleeperagent/install.go +++ b/cmd/sleeperagent/install.go @@ -50,7 +50,18 @@ func installCmd(args []string) error { return nil } fmt.Printf("sleeperagent install: %s is not on PATH yet.\n", targetDir) - fmt.Println(pathRemediation(targetDir, runtime.GOOS)) + if profile, updated, err := ensurePathInShellProfile(targetDir, runtime.GOOS); err == nil && profile != "" { + if updated { + fmt.Printf("Added it for future shells in %s.\n", profile) + } else { + fmt.Printf("A PATH update for this directory already exists in %s.\n", profile) + } + } else { + if err != nil { + fmt.Printf("Could not update your shell profile automatically: %v\n", err) + } + fmt.Println(pathRemediation(targetDir, runtime.GOOS)) + } fmt.Println("Open a new shell after updating PATH, then run: sleeperagent version") return nil } @@ -202,5 +213,76 @@ func pathRemediation(dir, goos string) string { if goos == "windows" { return fmt.Sprintf("Add it for future shells with:\n setx PATH \"%%PATH%%;%s\"\nOr add it in Settings > System > About > Advanced system settings > Environment Variables.", dir) } - return fmt.Sprintf("Add it for future shells with:\n export PATH=\"$PATH:%s\"\nPut that line in your shell profile if you want it to persist.", dir) + return fmt.Sprintf("Add it for future shells with:\n %s\nPut that line in your shell profile if you want it to persist.", shellPathLine(dir)) +} + +func ensurePathInShellProfile(dir, goos string) (string, bool, error) { + if goos == "windows" { + return "", false, nil + } + profile, err := defaultShellProfile(goos) + if err != nil { + return "", false, err + } + content, err := os.ReadFile(profile) + if err != nil && !os.IsNotExist(err) { + return profile, false, err + } + if strings.Contains(string(content), dir) { + return profile, false, nil + } + block := "# Added by sleeperagent install.\n" + shellPathLine(dir) + "\n" + f, err := os.OpenFile(profile, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + return profile, false, err + } + defer f.Close() + if len(content) > 0 && content[len(content)-1] != '\n' { + block = "\n" + block + } + if _, err := f.WriteString(block); err != nil { + return profile, false, err + } + return profile, true, nil +} + +func defaultShellProfile(goos string) (string, error) { + if goos != "windows" { + if home := os.Getenv("HOME"); home != "" { + return shellProfileInHome(home, os.Getenv("SHELL"), goos), nil + } + } + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return shellProfileInHome(home, os.Getenv("SHELL"), goos), nil +} + +func shellProfileInHome(home, shell, goos string) string { + switch filepath.Base(shell) { + case "zsh": + if goos == "darwin" { + return filepath.Join(home, ".zprofile") + } + return filepath.Join(home, ".zshrc") + case "bash": + if goos == "darwin" { + return filepath.Join(home, ".bash_profile") + } + return filepath.Join(home, ".profile") + default: + if goos == "darwin" { + return filepath.Join(home, ".zprofile") + } + return filepath.Join(home, ".profile") + } +} + +func shellPathLine(dir string) string { + return `export PATH="$PATH":` + shellSingleQuote(dir) +} + +func shellSingleQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" } diff --git a/cmd/sleeperagent/install_test.go b/cmd/sleeperagent/install_test.go index 30fe4e8..3df99bd 100644 --- a/cmd/sleeperagent/install_test.go +++ b/cmd/sleeperagent/install_test.go @@ -3,6 +3,7 @@ package main import ( "os" "path/filepath" + "strings" "testing" ) @@ -112,3 +113,46 @@ func TestPathContainsDir(t *testing.T) { t.Fatal("expected windows PATH comparison to be case-insensitive") } } + +func TestEnsurePathInShellProfileAddsMacZProfile(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("SHELL", "/bin/zsh") + dir := filepath.Join(home, ".local", "bin") + + profile, updated, err := ensurePathInShellProfile(dir, "darwin") + if err != nil { + t.Fatal(err) + } + if !updated { + t.Fatal("expected profile to be updated") + } + wantProfile := filepath.Join(home, ".zprofile") + if profile != wantProfile { + t.Fatalf("profile = %q, want %q", profile, wantProfile) + } + got, err := os.ReadFile(wantProfile) + if err != nil { + t.Fatal(err) + } + wantLine := shellPathLine(dir) + if !strings.Contains(string(got), wantLine) { + t.Fatalf("profile does not contain %q:\n%s", wantLine, got) + } + + _, updated, err = ensurePathInShellProfile(dir, "darwin") + if err != nil { + t.Fatal(err) + } + if updated { + t.Fatal("expected second profile update to be idempotent") + } +} + +func TestShellPathLineQuotesSpaces(t *testing.T) { + got := shellPathLine("/Users/me/Library/Application Support/sleeperagent/bin") + want := `export PATH="$PATH":'/Users/me/Library/Application Support/sleeperagent/bin'` + if got != want { + t.Fatalf("shellPathLine = %q, want %q", got, want) + } +} diff --git a/cmd/sleeperagent/main.go b/cmd/sleeperagent/main.go index cec8cbd..497f705 100644 --- a/cmd/sleeperagent/main.go +++ b/cmd/sleeperagent/main.go @@ -97,7 +97,7 @@ Run flags: --name string instance / tmux session name (default "sleeperagent-") --prompt string static resume prompt to inject on reset --reprompt string local-LLM reprompt, e.g. "ollama:llama3.1" (falls back to static) - --backend string session backend: "tmux" (default) or "pty" (no-tmux fallback) + --backend string session backend: "tmux" or "pty" (Unix falls back to pty if tmux is missing) --webhook string POST notifications to this URL --config string path to config.toml (default: OS config dir) --yolo append the agent's skip-permissions flag (DANGEROUS, unattended) @@ -134,6 +134,7 @@ func runCmd(args []string) error { if err := fs.Parse(args); err != nil { return err } + backendExplicit := flagWasSet(fs, "backend") cfg, err := config.Load(*cfgPath) if err != nil { @@ -183,7 +184,14 @@ func runCmd(args []string) error { var afterDetach func(ctx context.Context) var attachHint string var restoreLogOutput func() - switch *backend { + selectedBackend := *backend + if selectedBackend == "tmux" && !backendExplicit { + if err := tmux.New(instance, "").Available(); err != nil { + log.Printf("tmux not found on PATH; falling back to --backend pty (install tmux or pass --backend tmux for full handoff)") + selectedBackend = "pty" + } + } + switch selectedBackend { case "tmux": tx := tmux.New(instance, "") if err := tx.Available(); err != nil { @@ -227,7 +235,7 @@ func runCmd(args []string) error { pc.Wait(ctx) } default: - return fmt.Errorf("unknown backend %q (use \"tmux\" or \"pty\")", *backend) + return fmt.Errorf("unknown backend %q (use \"tmux\" or \"pty\")", selectedBackend) } return watchSession(watchParams{ @@ -246,8 +254,18 @@ func runCmd(args []string) error { cwd: cwd, autoAnswerPrompts: *autoAnswerPrompts, notifier: buildNotifier(*noNotify, *webhookURL), - transparentTTY: *backend == "pty", + transparentTTY: selectedBackend == "pty", + }) +} + +func flagWasSet(fs *flag.FlagSet, name string) bool { + seen := false + fs.Visit(func(f *flag.Flag) { + if f.Name == name { + seen = true + } }) + return seen } // watchParams bundles everything watchSession needs so both `run` and diff --git a/cmd/sleeperagent/main_test.go b/cmd/sleeperagent/main_test.go index 885495c..b0bb5bd 100644 --- a/cmd/sleeperagent/main_test.go +++ b/cmd/sleeperagent/main_test.go @@ -2,6 +2,7 @@ package main import ( "context" + "flag" "os" "strings" "testing" @@ -18,6 +19,23 @@ type watchTestPane struct { ended bool } +func TestFlagWasSet(t *testing.T) { + fs := flag.NewFlagSet("test", flag.ContinueOnError) + backend := fs.String("backend", defaultBackend(), "") + if err := fs.Parse([]string{"--backend", "tmux"}); err != nil { + t.Fatal(err) + } + if *backend != "tmux" { + t.Fatalf("backend = %q, want tmux", *backend) + } + if !flagWasSet(fs, "backend") { + t.Fatal("expected backend flag to be reported as set") + } + if flagWasSet(fs, "agent") { + t.Fatal("did not expect missing flag to be reported as set") + } +} + func (p *watchTestPane) Capture(int) (string, error) { return p.screen, nil } func (p *watchTestPane) Inject(string, string) error { return nil } func (p *watchTestPane) AttachHint() string { return "tmux attach -t test" } From 215d7853c1c74c28d04282afc9020ee92dc4ba30 Mon Sep 17 00:00:00 2001 From: Aman Jaiman Date: Tue, 7 Jul 2026 22:11:11 -0400 Subject: [PATCH 2/3] Address review: profile targeting, stale docs - Write to ~/.bashrc for bash on Linux (interactive non-login shells never read ~/.profile, and ~/.bash_profile preempts it for login shells), and skip profile writes for unrecognized shells like fish where an export line would be wrong or unread - Only treat an uncommented profile line mentioning the install dir as existing PATH coverage - Drop README references to the removed --daemon, --watch-only, and --no-auto-detach flags Co-Authored-By: Claude Fable 5 --- README.md | 19 +++------ cmd/sleeperagent/install.go | 34 ++++++++++++++-- cmd/sleeperagent/install_test.go | 67 ++++++++++++++++++++++++++++++++ 3 files changed, 104 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 9bd9485..bed4aa9 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ When a coding agent hits a 5-hour or weekly usage limit it hard-stops until you - **Cross-platform** — native on **Linux, macOS, and Windows** (no WSL required). - **Graceful handoff** — hotkeys, `detach`/`stop`, auto-detach when you attach, Ctrl-C that detaches rather than kills. - **Local-LLM reprompt** *(optional)* — a local Ollama model writes a context-aware continuation instruction from the transcript + git diff, validated before use. Falls back to a static prompt on any doubt. -- **Operable & safe** — background `--daemon` mode, `status`, desktop + webhook notifications, a `parse` command to tune patterns, and unattended tool-calls **off by default**. +- **Operable & safe** — `status`, desktop + webhook notifications, a `parse` command to tune patterns, and unattended tool-calls **off by default**. See [docs/SPEC.md](docs/SPEC.md) for the full design rationale. @@ -39,7 +39,7 @@ Put it on your `PATH`: ``` If the install directory is not already on `PATH`, the command prints the exact `setx PATH` or `export PATH` line to run, plus a reminder to open a new shell. -On macOS/Linux, `install` also tries to add that PATH update to your shell profile automatically. +On macOS/Linux, `install` also tries to add that PATH update to your shell profile automatically (zsh, bash, and sh; other shells get the printed line only). **With the Go toolchain:** @@ -105,12 +105,9 @@ sleeperagent run --agent claude --name mytask | `--prompt` | Static resume prompt to inject on reset. | | `--reprompt` | Local-LLM reprompt, e.g. `ollama:llama3.1` (falls back to static). | | `--backend` | `tmux` or `pty`. Unix defaults to tmux when it is available, otherwise pty; Windows defaults to pty. | -| `--daemon` | Run in the background; control via `status`/`detach`/`stop`. | -| `--watch-only` | Notify at the reset but **do not** auto-inject — you resume by hand. | | `--yolo` | Append the agent's skip-permissions flag (**DANGEROUS** — unattended, no prompts). | | `--auto-answer-prompts` | Answer interactive prompts with the first/default option (**DANGEROUS** — unattended approvals). | | `--webhook` | POST notifications to this URL as JSON. | -| `--no-auto-detach` | Don't auto-detach when you attach to the session. | | `--no-notify` | Disable desktop notifications. | | `--config` | Path to `config.toml` (default: OS config dir). | @@ -123,13 +120,9 @@ sleeperagent run --agent codex --prompt "Continue; run the tests after." # Let a local model write the continuation instruction each reset sleeperagent run --agent claude --reprompt ollama:llama3.1 -# Run in the background and check on it later (works on all platforms) -sleeperagent run --agent claude --name nightly --daemon +# Check on running instances from any other shell (works on all platforms) sleeperagent status -# Just nudge me at the reset — don't auto-resume -sleeperagent run --agent claude --watch-only - # Custom launch command — same Claude adapter, but your own flags / wrapper / binary sleeperagent run --agent claude -- claude --model opus --add-dir ../shared-lib @@ -154,10 +147,10 @@ SleeperAgent is built to get out of your way. How handoff works depends on the b - **Hotkeys** (foreground run): `d`/`q` detach, `k` kills the session (with a `y` confirm). - **`sleeperagent detach --name X`** from any other shell. - **Ctrl-C** detaches — it never kills the session. -- **Auto-detach:** the moment you `tmux attach`, SleeperAgent notices and steps aside so you don't both type (disable with `--no-auto-detach`). +- **Auto-detach:** the moment you `tmux attach`, SleeperAgent notices and steps aside so you don't both type. - Reattach anytime with `tmux attach -t `. -**pty / ConPTY backend (default on Windows, automatic Unix fallback when tmux is missing):** the agent is a child of the supervisor, so it **can't be handed back interactively**. In the foreground, `detach` gives the terminal back to you until the agent exits; in `--daemon` mode, `detach`/`stop` ends the agent. Use the tmux backend if you need full handoff. +**pty / ConPTY backend (default on Windows, automatic Unix fallback when tmux is missing):** the agent is a child of the supervisor, so it **can't be handed back interactively**. `detach` gives the terminal back to you until the agent exits; `stop --kill` ends the agent. Use the tmux backend if you need full handoff. --- @@ -199,7 +192,7 @@ Desktop notifications are on by default (best effort; `--no-notify` to disable) SleeperAgent **waits for legitimate resets**; it does not bypass limits. -Resuming unattended runs tool calls with no human in the loop, so by default the agent keeps its **normal permission prompts** — SleeperAgent does *not* pass `--dangerously-skip-permissions` / full-auto for you. That's an explicit, loud opt-in via `--yolo`; use it only when you understand the risk. `--auto-answer-prompts` is a separate loud opt-in that leaves prompts enabled but answers detected interactive prompts with their first/default option, which may approve tool calls. Prefer to stay in the loop? `--watch-only` notifies you at the reset and lets you resume by hand. LLM-generated prompts are length-capped and denylist-checked before injection. +Resuming unattended runs tool calls with no human in the loop, so by default the agent keeps its **normal permission prompts** — SleeperAgent does *not* pass `--dangerously-skip-permissions` / full-auto for you. That's an explicit, loud opt-in via `--yolo`; use it only when you understand the risk. `--auto-answer-prompts` is a separate loud opt-in that leaves prompts enabled but answers detected interactive prompts with their first/default option, which may approve tool calls. LLM-generated prompts are length-capped and denylist-checked before injection. ## How it works diff --git a/cmd/sleeperagent/install.go b/cmd/sleeperagent/install.go index 6783cdf..e378226 100644 --- a/cmd/sleeperagent/install.go +++ b/cmd/sleeperagent/install.go @@ -224,11 +224,16 @@ func ensurePathInShellProfile(dir, goos string) (string, bool, error) { if err != nil { return "", false, err } + if profile == "" { + // Unrecognized shell (fish, nushell, …) — an `export PATH=…` line would + // be wrong or unread there, so leave it to the printed remediation. + return "", false, nil + } content, err := os.ReadFile(profile) if err != nil && !os.IsNotExist(err) { return profile, false, err } - if strings.Contains(string(content), dir) { + if profileMentionsDir(string(content), dir) { return profile, false, nil } block := "# Added by sleeperagent install.\n" + shellPathLine(dir) + "\n" @@ -259,6 +264,25 @@ func defaultShellProfile(goos string) (string, error) { return shellProfileInHome(home, os.Getenv("SHELL"), goos), nil } +// profileMentionsDir reports whether an uncommented line of the profile +// already references dir, so a commented-out or disabled entry doesn't count +// as coverage. +func profileMentionsDir(content, dir string) bool { + for _, line := range strings.Split(content, "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "#") { + continue + } + if strings.Contains(trimmed, dir) { + return true + } + } + return false +} + +// shellProfileInHome returns the profile file the user's shell actually reads +// on new terminals, or "" for shells where an `export PATH` line wouldn't work +// (fish and friends). func shellProfileInHome(home, shell, goos string) string { switch filepath.Base(shell) { case "zsh": @@ -270,12 +294,16 @@ func shellProfileInHome(home, shell, goos string) string { if goos == "darwin" { return filepath.Join(home, ".bash_profile") } - return filepath.Join(home, ".profile") - default: + // Linux terminal emulators open interactive non-login shells, which + // read .bashrc; .profile is skipped entirely once .bash_profile exists. + return filepath.Join(home, ".bashrc") + case "sh", "", ".": if goos == "darwin" { return filepath.Join(home, ".zprofile") } return filepath.Join(home, ".profile") + default: + return "" } } diff --git a/cmd/sleeperagent/install_test.go b/cmd/sleeperagent/install_test.go index 3df99bd..23a5e65 100644 --- a/cmd/sleeperagent/install_test.go +++ b/cmd/sleeperagent/install_test.go @@ -149,6 +149,73 @@ func TestEnsurePathInShellProfileAddsMacZProfile(t *testing.T) { } } +func TestShellProfileInHome(t *testing.T) { + tests := []struct { + shell, goos, want string + }{ + {"/bin/zsh", "darwin", ".zprofile"}, + {"/usr/bin/zsh", "linux", ".zshrc"}, + {"/bin/bash", "darwin", ".bash_profile"}, + {"/bin/bash", "linux", ".bashrc"}, + {"/bin/sh", "linux", ".profile"}, + {"", "darwin", ".zprofile"}, + {"/usr/bin/fish", "linux", ""}, + {"/usr/bin/fish", "darwin", ""}, + } + for _, tt := range tests { + want := tt.want + if want != "" { + want = filepath.Join("home", want) + } + if got := shellProfileInHome("home", tt.shell, tt.goos); got != want { + t.Errorf("shellProfileInHome(%q, %q) = %q, want %q", tt.shell, tt.goos, got, want) + } + } +} + +func TestEnsurePathInShellProfileSkipsUnknownShell(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("SHELL", "/usr/bin/fish") + + profile, updated, err := ensurePathInShellProfile(filepath.Join(home, "bin"), "linux") + if err != nil { + t.Fatal(err) + } + if profile != "" || updated { + t.Fatalf("expected no profile update for fish, got profile=%q updated=%v", profile, updated) + } +} + +func TestEnsurePathInShellProfileIgnoresCommentedMention(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("SHELL", "/bin/bash") + dir := filepath.Join(home, "bin") + rc := filepath.Join(home, ".bashrc") + if err := os.WriteFile(rc, []byte("# "+shellPathLine(dir)+"\n"), 0o644); err != nil { + t.Fatal(err) + } + + profile, updated, err := ensurePathInShellProfile(dir, "linux") + if err != nil { + t.Fatal(err) + } + if profile != rc { + t.Fatalf("profile = %q, want %q", profile, rc) + } + if !updated { + t.Fatal("expected commented-out mention to still trigger an update") + } + got, err := os.ReadFile(rc) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(got), "\n"+shellPathLine(dir)) { + t.Fatalf("profile missing uncommented PATH line:\n%s", got) + } +} + func TestShellPathLineQuotesSpaces(t *testing.T) { got := shellPathLine("/Users/me/Library/Application Support/sleeperagent/bin") want := `export PATH="$PATH":'/Users/me/Library/Application Support/sleeperagent/bin'` From ea99e82ae4096b3e5d1d6c97bfc5cea6665ae7f3 Mon Sep 17 00:00:00 2001 From: Aman Jaiman Date: Tue, 7 Jul 2026 22:22:09 -0400 Subject: [PATCH 3/3] Add --no-profile to skip automatic shell profile writes Some users may not want install touching dotfiles at all; --no-profile falls back to the printed export/setx line instead. Co-Authored-By: Claude Fable 5 --- README.md | 4 ++-- cmd/sleeperagent/install.go | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index bed4aa9..68d8414 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ Put it on your `PATH`: ``` If the install directory is not already on `PATH`, the command prints the exact `setx PATH` or `export PATH` line to run, plus a reminder to open a new shell. -On macOS/Linux, `install` also tries to add that PATH update to your shell profile automatically (zsh, bash, and sh; other shells get the printed line only). +On macOS/Linux, `install` also tries to add that PATH update to your shell profile automatically (zsh, bash, and sh; other shells get the printed line only). Pass `--no-profile` to skip that and just print the line yourself. **With the Go toolchain:** @@ -93,7 +93,7 @@ sleeperagent run --agent claude --name mytask | `rm --name N [--force]` / `rm --all` | Remove a stale/ended instance record (e.g. after the agent exited). `--all` prunes every record with no running supervisor. | | `agents [--config P]` | List configured adapters and validate that their patterns compile. | | `parse --agent A "text…"` | Test a captured limit string against an agent's patterns and show the resolved reset. | -| `install [--dir DIR] [--force]` | Copy this binary to a PATH directory. | +| `install [--dir DIR] [--force] [--no-profile]` | Copy this binary to a PATH directory. | | `version` | Print the build version. | ### `run` flags diff --git a/cmd/sleeperagent/install.go b/cmd/sleeperagent/install.go index e378226..9970172 100644 --- a/cmd/sleeperagent/install.go +++ b/cmd/sleeperagent/install.go @@ -16,6 +16,7 @@ func installCmd(args []string) error { fs := flag.NewFlagSet("install", flag.ContinueOnError) dir := fs.String("dir", "", "directory to install sleeperagent into") force := fs.Bool("force", false, "overwrite an existing different file") + noProfile := fs.Bool("no-profile", false, "don't modify shell profile files; just print the PATH line") if err := fs.Parse(args); err != nil { return err } @@ -50,7 +51,9 @@ func installCmd(args []string) error { return nil } fmt.Printf("sleeperagent install: %s is not on PATH yet.\n", targetDir) - if profile, updated, err := ensurePathInShellProfile(targetDir, runtime.GOOS); err == nil && profile != "" { + if *noProfile { + fmt.Println(pathRemediation(targetDir, runtime.GOOS)) + } else if profile, updated, err := ensurePathInShellProfile(targetDir, runtime.GOOS); err == nil && profile != "" { if updated { fmt.Printf("Added it for future shells in %s.\n", profile) } else {