diff --git a/.gitignore b/.gitignore index 1b978ed..d26b0c9 100644 --- a/.gitignore +++ b/.gitignore @@ -42,4 +42,6 @@ dev-notes.md _features/ # runtime -*.pid \ No newline at end of file +*.pid +# brainstorming scratch (tooling-specific, not for public repo) +docs/superpowers/ diff --git a/.sloop/skills/.gitkeep b/.sloop/skills/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/CHANGELOG.md b/CHANGELOG.md index 2af9200..253b34d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,31 @@ All notable changes to Sloop are documented here. This project adheres to [Semantic Versioning](https://semver.org/). +## v0.1.1 — 2026-06-28 + +Run more than one agent per repo, peek into a waiting agent without leaving your +screen, and a sharper first-run experience — all on top of v0.1.0. + +### Run multiple agents & accounts +- Named instances: `sloop run claude@review` / `-n/--name` runs a second agent of the same tool in one + repo (session `__tool__instance`); `-N/--new` auto-names the next free slot (`claude·2`…). +- Profiles: save a tool + env once with `sloop profile add|ls|rm` (global `~/.sloop/config.yaml`) and + launch it as `sloop run @` — e.g. a different account via `CLAUDE_CONFIG_DIR`. `--env KEY=VAL` + injects env one-off without a profile (`~`/`$VAR` expanded). The fleet view shows instances as + `tool·instance`. + +### Peek (overlay a waiting agent) +- `sloop peek` floats a waiting agent's live pane over your current screen so you can answer it and + drop back without `switch-client` swapping your whole screen; `sloop peek setup` binds a key. Needs + tmux ≥ 3.2. Every status bar gains a fleet-wide `⏳ N waiting` badge. + +### Sharper onboarding (init / check / doctor) +- `sloop init` is provider-respecting: it asks about the tools you actually use (Claude-first order), + prompts honestly and skips work that is already done, selects tools per workspace, and no longer + offers to scaffold a provider's folders for it. +- `sloop check` gains more AI-readiness criteria, sourced from each tool's adapter manifest. +- `sloop doctor` groups and colors its output and explains the `mode` line. + ## v0.1.0 — first release The initial public release: the local-first control layer for your AI coding CLIs. diff --git a/README.md b/README.md index 46bcf93..fac8f7d 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,15 @@ picker. > only touches `status-left`/`status-right` (not your colors/theme). Keep your own bar fully intact with > `SLOOP_STATUSLINE=0`. +**Don't want to leave your screen?** From inside one agent, **`sloop peek`** floats a *waiting* agent's +live pane over your current work — answer it, close it, and you're exactly where you were (no +`switch-client` that swaps your whole screen). Bind a key with `sloop peek setup`. Needs tmux ≥ 3.2. + +**A second agent, or a second account?** `sloop run claude@review` opens another claude in the same +repo (`claude·review` in the fleet); `sloop run claude --new` auto-names the next one (`claude·2`). For a +different account, save a profile once — `sloop profile add sec --tool claude --env CLAUDE_CONFIG_DIR=~/.claude-sec` +— then `sloop run @sec`. + `sloop ls` is the companion view — your registered **workspaces** (running or not) with their live agents — to launch (`r`), open a shell (`s`), `c` to copy a `cd`, or `Enter` to jump in. @@ -145,7 +154,8 @@ agents — to launch (`r`), open a shell (`s`), `c` to copy a `cd`, or `Enter` t | Command | Alias | Flags (short) | What it does | |---|---|---|---| | `init` | — | `-s/--scan`, `-S/--scaffold` | Scaffold `AGENTS.md` + `.sloop/`, deliver pointers, register the workspace. `--scan` pre-fills `AGENTS.md` from the codebase; `--scaffold` creates each tool's standard folders. | -| `run [tool]` | `r` | `-w/--workspace`, `--split`, `-- ` | Sync context, then launch a tool (in tmux if present). `--split` runs several side by side; `-w` targets a registered workspace from anywhere. | +| `run [tool]` | `r` | `-n/--name`, `-N/--new`, `--env`, `-w/--workspace`, `--split`, `-- ` | Sync context, then launch a tool (in tmux if present). Run a **2nd agent of the same tool** with `tool@instance`, `-n/--name`, or `-N/--new` (auto-named `tool·2`…); a **different account** with a saved `@profile` or one-off `--env KEY=VAL`. `--split` runs several side by side; `-w` targets any registered workspace. | +| `profile add\|ls\|rm` | `prof` | `add --tool`, `add --env` | Save reusable run profiles in `~/.sloop/config.yaml` (e.g. a 2nd account's `CLAUDE_CONFIG_DIR`); launch one with `sloop run @`. | | `sync [tool]` | `s` | `-a/--all`, `-r/--repair`, `-w/--workspace` | (Re)deliver pointer files + skills symlinks without launching. `--repair` safely moves a foreign file aside (never deletes). | | `ps [#]` | — | `-f/--watch`, `-n/--interval`, `--waiting`, `--notify`, `--all` | The cross-repo fleet. Reads what each agent is asking and shows answer keys; `<#>` jumps; `-f` live-monitors + alerts. | | `approve ` | — | `--waiting`, `--all`, `--yes` | Send the Yes/Approve answer to waiting agent(s) — one-command approve. | @@ -154,6 +164,7 @@ agents — to launch (`r`), open a shell (`s`), `c` to copy a `cd`, or `Enter` t | `adopt ` | — | `-w/--workspace`, `--as` | Bring an external tmux session (one you started yourself) into the fleet. | | `restore` | — | `--resume`, `--yes` | Relaunch your recent agents (detached) after a reboot / tmux restart. `--resume` continues each tool's prior conversation. | | `popup` / `popup setup` | `hud` | `--key` | Open the fleet `ps` as a floating tmux popup / HUD; `setup` binds a key. Needs tmux ≥ 3.2. | +| `peek [agent]` / `peek setup` | `pk` | `--key` | Overlay a waiting agent in a floating popup — answer it and drop back **without switching your whole screen**. No arg → the lone waiting agent or a picker; `setup` binds a key. Needs tmux ≥ 3.2. | | `statusline setup` | — | — | Show a session's live status (`⚓ repo tool ◆ waiting`) in its tmux status bar. | | `attach [session]` | `a` | — | Attach to a session by name, or `sloop a` (no name) to pick from the fleet. | | `skills new\|add <…>` | `sk`, `skill` | `new`→`n`, `add`→`import` | Scaffold or import a reusable skill (shared across every tool). | @@ -248,6 +259,8 @@ hooks: # status hooks for `sloop ps` - [docs/reference/CONFIG.md](docs/reference/CONFIG.md) — the three config layers (local / global / built-in) - [docs/reference/ADAPTERS.md](docs/reference/ADAPTERS.md) — the provider-aware adapter contract - [docs/design/run.md](docs/design/run.md) — `sloop run` design: CLI · model · effort resolution +- [docs/design/profiles.md](docs/design/profiles.md) — named instances & profiles: a 2nd agent / 2nd account +- [docs/design/peek.md](docs/design/peek.md) — overlay a waiting agent without leaving your screen - [docs/design/skills.md](docs/design/skills.md) — skills model, lockfile, registry roadmap - [docs/design/hooks.md](docs/design/hooks.md) — status hooks today, workflow-hook design for v0.2.0 - [docs/reference/ARCHITECTURE.md](docs/reference/ARCHITECTURE.md) — packages, data flow, internals diff --git a/docs/design/hooks.md b/docs/design/hooks.md index d0e8a89..463ee4a 100644 --- a/docs/design/hooks.md +++ b/docs/design/hooks.md @@ -1,14 +1,24 @@ # Hooks — status today, portable workflow automation next AI CLIs expose **hooks**: events that fire at points in a session (per-session, per-turn, per -tool-call) where a command can observe, react, or block. Sloop uses hooks two ways — and keeping the -two cleanly separated is the whole design. +tool-call) where a command can observe, react, or block. Sloop uses hooks three ways — and keeping +them cleanly separated is the whole design: -> **Status hooks** ship today. **Workflow hooks** are a v0.2.0 proposal — this doc is the contract to -> review *before* it's built. Command reference for status hooks: [USAGE.md](../guide/USAGE.md); the install -> mechanism: [ADAPTERS.md §Hook install strategies](../reference/ADAPTERS.md). +1. **Status hooks** (shipped) — observe state for `sloop ps`. +2. **Workflow hooks** (v0.2.0 proposal) — run the user's portable automation. +3. **Context-inject hooks** (proposal) — deliver sloop's portable context / memory into a session at + `SessionStart`, without writing into the tool's own context file. -## Two kinds of hook (don't conflate them) +> **Status hooks** ship today. **Workflow hooks** and **context-inject hooks** are proposals — this doc +> is the contract to review *before* they're built. Command reference for status hooks: +> [USAGE.md](../guide/USAGE.md); the install mechanism: +> [ADAPTERS.md §Hook install strategies](../reference/ADAPTERS.md). + +## Status vs. workflow hooks (don't conflate them) + +These two write into the *same* provider config, so the bulk of the design is keeping them apart. +(Context-inject hooks live on a different event and are read-only, so they don't share this hazard — +see their own section below.) | | **Status hooks** (shipped) | **Workflow hooks** (v0.2.0) | |---|---|---| @@ -41,6 +51,28 @@ writes a short-lived marker under `~/.sloop/state` that `sloop ps` prefers over in the adapter manifest. Auto-install today: **claude, gemini, cursor**; **copilot, codex** are `print+paste` pending a matcher-aware model (below). See [ADAPTERS.md](../reference/ADAPTERS.md). +### Privacy: status hooks never read your session + +This is a deliberate boundary and a promise we make to users. Sloop's status hook emits **one state +word** — `waiting` / `working` / `idle` plus a timestamp — into a local marker file. It does **not**: + +- read the prompt or the model's response, +- read file contents or tool inputs, +- make any network call. + +Contrast a typical lifecycle bridge (e.g. the `claude-code-warp` plugin), whose `Stop` hook *reads the +session, extracts the last prompt + response, and ships a summary* to an external notification center. +That is a real content-exfiltration surface; sloop's fleet awareness deliberately stays at the +state-enum level, so "sloop knows an agent is idle" never means "sloop read what it was doing." + +The wiring is auditable end to end: `sloop hooks print` shows the exact line that gets added, the +command is literally `sloop hooks emit `, it installs into `settings.local.json` (not +committed), the merge is idempotent, and any hand-written ("foreign") hooks in the file are preserved. + +The only surface that *does* execute arbitrary code is **workflow hooks** (below) — which is exactly +why they are gated behind a lockfile + checksum + review-before-run, and why the internal status +callback is namespaced as `hooks emit` rather than a bare top-level `hook`. + ## Workflow hooks (v0.2.0 proposal) Turn hooks into a **portable automation library**: pick a hook, sloop installs it into the right @@ -94,6 +126,43 @@ runs on a teammate's machine. So, unlike skills, the library needs: AI tool. - Each definition declares its target mechanism; sloop installs into that and nowhere else. +## Context-inject hooks — the memory/vault bridge (proposal) + +Today sloop delivers portable context by **writing files**: `AGENTS.md` plus per-tool pointers +(`CLAUDE.md`, etc.). That works, but it has two limits: it touches files the provider considers its +own, and it's static — it can't reflect what the workspace has *learned* between sessions. + +A `SessionStart` hook is the third delivery channel, and it's the one that unlocks the **memory / +vault** direction (design to live in `docs/design/memory.md`). The pattern is the +Superpowers one: register a single read-only hook that, at session start, prints sloop's relevant +context to stdout — the provider injects that into the session as additional context. Sloop never +rewrites the user's prompt and never edits the tool's `CLAUDE.md`; it speaks through the provider's +**own** `SessionStart` mechanism. + +```text +SessionStart → `sloop context emit` → prints relevant vault/AGENTS context → provider injects it +``` + +Why this is the right shape for memory: + +- **Dynamic, not static.** The hook runs each session, so it can surface what was learned/saved since + last time — the static `AGENTS.md` can't. +- **Provider-respecting.** It's the *third* way the user asked about ("write into AGENTS.md, or + CLAUDE.md, or through our hooks") — and the only one that doesn't write into the tool's files. +- **Same trust posture as status hooks.** Injection is **read-only and outbound-only**: sloop emits + context it already owns under `.sloop/`; it does not read the session. No new exfiltration surface. +- **No bundled LLM.** Selection of "relevant" context stays plumbing (recency, the active workspace, + simple matching). Any real retrieval is delegated to the agent — sloop is not the model. + +Open design questions specific to this channel (to be settled in `docs/design/memory.md`, not here): + +- What "relevant context" means at session start (whole vault vs. a recency/scoped slice) and the + token budget for an injection. +- How memory gets *written* (a `SessionEnd`/`Stop` capture hook? an explicit `sloop remember`?) — the + write path is a separate decision from this read path. +- Per-provider `SessionStart` support + payload shape in the adapter manifest (which CLIs expose it, + and whether non-supporting tools fall back to the file-pointer delivery). + ## Open questions (feedback wanted before the build) - Library distribution: embedded built-ins in this repo first (review via PR, ship with the binary, diff --git a/docs/design/memory.md b/docs/design/memory.md new file mode 100644 index 0000000..2c3a907 --- /dev/null +++ b/docs/design/memory.md @@ -0,0 +1,114 @@ +# Memory / vault — the 2nd-brain bridge (DRAFT design) + +> **Status: skeleton for discussion.** Nothing here is built or decided. This captures *what we want +> to do* and the constraints it must respect, so we can review and argue the shape before writing +> code. The delivery mechanism (a read-only `SessionStart` context-inject hook) is sketched in +> [hooks.md §Context-inject hooks](hooks.md); this doc owns the memory model itself. + +## The problem + +Portable context today is **static and write-once**: `init`/`sync` write `AGENTS.md` + per-tool +pointers, and that's it. Two gaps follow: + +1. **Nothing is learned.** A decision made or a gotcha hit in session A is gone by session B. The user + re-explains the same things to the same (or a different) tool. +2. **Nothing is shared across the fleet.** sloop's wedge is portable context + cross-repo fleet, but + "context" is currently just a file the user maintains by hand — not something the fleet accrues. + +The goal: a **2nd brain** that the workspace accrues over time and replays into any tool, any session, +any repo — without sloop becoming the model. + +## Goals + +- **Capture** durable facts/decisions/gotchas from real sessions (or explicit user input) into a local + store. +- **Replay** the relevant slice back into a fresh session automatically, via the provider's own + `SessionStart` mechanism (no rewriting prompts, no editing the tool's `CLAUDE.md`). +- **Portable** across the five+ CLIs and across repos — same store, any tool reads it. +- **Provider-respecting and private by construction** — same posture as status hooks (read-only, + outbound-only, local; see hooks.md privacy note). + +## Non-goals (bright lines) + +- **No bundled LLM / RAG / embeddings / vector DB.** sloop is plumbing. "Relevance" is mechanical + (recency, active workspace, simple matching), and any real semantic retrieval is delegated to the + agent — *it* is the LLM. The moment we'd reach for embeddings, we stop and hand the job to the tool. +- **No silent capture of session content.** Writing to memory is an explicit, visible act (see the + write-path question) — never a hook that quietly scrapes prompts/responses. +- **Not a knowledge base / wiki / sync service.** The vault is local notes the fleet replays, not a + hosted product. + +## Shape (two paths, decided separately) + +Memory splits into a **read path** (replay) and a **write path** (capture). They're independent +decisions; we can ship the read path against a hand-written vault long before automating capture. + +### Read path — replay (closest to ready) + +```text +SessionStart → `sloop context emit` → selects relevant vault slice → provider injects it as context +``` + +- Read-only, outbound-only. Emits content sloop already owns under `.sloop/`. No session reading. +- Selection is plumbing: recency + active workspace + simple matching, under a token budget. +- Falls back to file-pointer delivery for tools that don't expose `SessionStart`. + +### Write path — capture (more open) + +How does a fact *get into* the vault? Candidates, not yet chosen: + +- **Explicit** — `sloop remember ""` (and/or a `/remember`-style provider command). Highest + trust, zero magic, user-curated. Likely the v1. +- **Prompted capture** — a `SessionEnd`/`Stop` hook that *asks* the user "save anything from this + session?" rather than auto-scraping. Keeps the human in the loop. +- **Agent-authored** — the tool itself writes a note via a skill/command when it learns something. + Delegates the "what's worth remembering" judgment to the model (consistent with the no-LLM line). + +We deliberately **avoid** an auto-`Stop`-hook that reads and stores session content silently — that +would cross the privacy line we drew for status hooks. + +## Storage model (sketch) + +- Lives under `.sloop/vault/` (already scaffolded by `init`, already in `.sloop/.gitignore` as + personal/not-shared by default). +- Plain files (Markdown, one fact per file?) so it's greppable, diffable, and editable by hand — and + so the agent can read/write it with its normal file tools. Mirrors how *this* assistant's own + `MEMORY.md` index + per-fact files work; that pattern is a candidate to copy outright. +- **Open:** per-fact files + an index vs. a single file; frontmatter schema (type, scope, created); + personal (gitignored) vs. team-shared (committed) tiers; per-workspace vs. a global cross-repo vault + (the cross-repo wedge probably wants *both*: a global brain + a per-workspace overlay). + +## Open questions (the things to argue in review) + +- **Relevance without a model.** What's the actual selection rule for "what to inject at SessionStart"? + Whole vault (simple, but blows the token budget fast) vs. a recency/scope slice vs. tag-matching on + the workspace. Where's the budget cap? +- **Write trigger.** Explicit `sloop remember` first, or invest in prompted capture early? What's the + smallest thing that's genuinely useful? +- **Scope & sharing.** Per-workspace vs. global brain; personal (gitignored) vs. committed/team. How do + the two layers compose at injection time? +- **Cross-repo replay.** The wedge says context should follow you across repos — does a global vault + inject into *every* workspace, or only on demand? How is "this fact is about repo X" expressed? +- **Provider coverage.** Which CLIs expose `SessionStart` (and an injectable payload)? Manifest field + + fallback for those that don't. +- **De-dup / decay.** How do stale or contradicted facts get pruned? (Our own memory rules — "update + the existing file, delete what's wrong" — are a candidate policy.) +- **Interop.** Should sloop's vault read/emit alongside a tool's *native* memory (e.g. a provider's own + memory feature) rather than compete with it? + +## Phasing (proposal) + +1. **Read path on a hand-written vault.** `sloop context emit` + one provider's `SessionStart` wiring; + user maintains `.sloop/vault/` by hand. Proves the injection channel end to end. +2. **Explicit capture.** `sloop remember` writes a fact; emit picks it up next session. +3. **Selection + scope.** Recency/scope slicing, token budget, per-workspace vs. global layering. +4. **Cross-repo brain.** Global vault that replays into any workspace; team-shared tier. + +(Each phase is independently shippable and independently reviewable. We are not committing to past +phase 1 until phase 1 earns it.) + +## Related + +- [hooks.md](hooks.md) — the three hook uses; the `SessionStart` context-inject channel this doc rides + on, and the privacy posture it inherits. +- Product direction: portable context + cross-repo fleet is the wedge; keep LLM out of the core. diff --git a/docs/design/peek.md b/docs/design/peek.md new file mode 100644 index 0000000..8af5bbe --- /dev/null +++ b/docs/design/peek.md @@ -0,0 +1,123 @@ +# Design: `sloop peek` — overlay into a waiting agent without losing your spot + +**Date:** 2026-06-28 +**Status:** Shipped +**Scope:** Phase 1 + Phase 2 (Phase 3 deferred) + +## Problem + +When you orchestrate a cross-repo fleet, you work from one "home base" session +while agents run in other tmux sessions. When an agent hits a hook that needs +your input (it goes `waiting`), the only way to answer it today is +`sloop attach`, which — because `attach` cannot nest inside tmux — falls back to +`switch-client`. That **replaces your whole screen** with the agent's session. +You answer, then switch back. You lose your spot and your context every round +trip. + +What we actually want: from home base, get a quiet signal that an agent needs +you, press one key to **float that agent's live pane over your current screen**, +answer it in place, dismiss it, and be exactly where you were. + +This is the "peek and drop" flow. It is the unique thing tmux overlays enable +that a screen-replacing `switch-client` cannot. + +## Non-goals (deliberately deferred) + +- **Phase 3 — non-modal floating panes (tmux ≥ 3.7 `new-pane`).** A modal + `display-popup` is well-matched to "answer and dismiss," so non-modal is + polish, not foundation. It becomes a progressive enhancement once tmux 3.7 is + widespread. Not in this scope. +- **True auto-pop.** We never auto-open an overlay onto the user mid-keystroke. + Surfacing is always user-triggered. A future opt-in config knob could add + aggressive auto-pop for those who want it. +- **Watching multiple agents floating at once**, move/resize, custom layouts. + +## Design overview: "Signal → Act" + +Two layers ship here: an ambient signal that an agent needs you, and a one-key +action to answer it. + +### Layer A — Signal (ambient, never interrupts) + +The status hooks already write a fresh `waiting | working | idle` marker per +session. We surface a **fleet-wide waiting count** on the home-base session's +status line, e.g. `⏳ 2 waiting`, rendered empty when zero so the bar stays +clean. The status line is drawn on every window/client the user looks at, so the +signal "follows the user" for free, on any tmux version, with zero interruption. + +Rather than a separate command and a new setup step, the badge is **appended to +the per-session status line that every sloop session already renders** — so it +appears everywhere automatically, with no new setup. It counts sessions whose +fresh marker is `waiting`, **excluding the current session** so it reads "others +need you," using markers only (no pane capture) so it is cheap enough to run +every status interval. + +### Layer B — Act (`sloop peek`) + +A command that overlays a target agent's **live session** in a `display-popup`, +using a nested attach so the home-base client is never touched. + +``` +sloop peek [agent] + no arg → resolve target (see below) + agent → peek that session directly +``` + +**Target resolution (the "convenient" part):** + +1. exactly one agent is `waiting` → peek it directly, no prompt. +2. more than one `waiting` → show the fleet picker (already sorted waiting-first) + so you choose. +3. none `waiting` → the same picker over the whole fleet. + +**The overlay mechanism (the one tricky part):** `display-popup -E` runs its +command with `$TMUX` still set, so a plain `tmux attach` inside it would be +refused ("sessions should be nested with care"). Clearing `TMUX` for the inner +command lets tmux allow a **fresh nested client**: + +``` +display-popup -w 90% -h 80% -E 'TMUX= attach -t ' +``` + +Closing the popup ends that nested client → it detaches → **the agent session +keeps running**. The home-base client was never switched. (Confirmed in use: +the nested attach is permitted across tmux 3.2–3.6, and closing the overlay +detaches cleanly without killing the agent. While the popup is open the session +window is clamped to the smallest attached client — acceptable for a peek.) + +**Keybind without double-popup:** pressing the bound key must open exactly one +popup. So the keybind points straight at the popup, and the *inner* command +(`sloop peek --in-popup`, which has a TTY inside the popup) does target +resolution and then the `TMUX=`-cleared nested attach — no second popup: + +``` +bind-key p display-popup -w 90% -h 80% -E " peek --in-popup" +``` + +`sloop peek` from a normal shell wraps the inner command in the popup itself. +`sloop peek setup [--key p]` installs the bind on the live server and prints the +`~/.tmux.conf` line, mirroring `popup setup`. + +## Error & edge handling + +- **Not inside tmux:** peek has nothing to overlay over. Error clearly and point + to `sloop attach`. +- **tmux < 3.2:** no `display-popup`. Error and point to `sloop attach` — peek's + value *is* the overlay, so silently switching the whole screen would defeat the + purpose. +- **No sessions / empty fleet:** the resolver surfaces the same "no running AI + sessions" error the fleet picker already returns. +- **Stale/missing markers:** the fleet state already treats stale markers as + absent, so the resolver and badge fall back to the capture-pane heuristic the + status line already uses — a missing hook never hides a waiting agent. + +## Why this is the simple, convenient core + +- Ships on **today's tmux (≥ 3.2)** — every user, not just 3.7. +- A modal popup fits "answer and dismiss" exactly; no non-modal complexity needed. +- Reuses what exists: status hooks, fleet state, the fleet picker, and the + `popup setup` keybind pattern. +- Keeps orchestration minimal, matching the project's wedge (portable context + + cross-repo fleet, not heavyweight in-repo orchestration). +- The tmux 3.7 feature that prompted this (floating panes) is correctly placed as + an optional later enhancement, not a dependency. diff --git a/docs/design/profiles.md b/docs/design/profiles.md new file mode 100644 index 0000000..ff51ca3 --- /dev/null +++ b/docs/design/profiles.md @@ -0,0 +1,146 @@ +# Design: profiles & named instances — run a second agent (or a second account) of the same provider + +**Date:** 2026-06-28 +**Status:** Shipped +**Scope:** Global profiles, named instances, env injection, `sloop profile` command + +## Problem + +A session is named `__`, and launching re-attaches when that +session already exists. Two consequences: + +1. **You cannot run a second agent of the same provider in one workspace.** + `sloop run claude` a second time just re-attaches the existing `__claude` + — there is no place in the name to distinguish a second instance. +2. **You cannot run a different account of the same provider.** sloop launches + the real binary (e.g. `claude`) directly, *not* through a shell, so a user's + shell alias (`claude-sec`) does not apply, and there is nowhere to inject the + env that selects another account (`CLAUDE_CONFIG_DIR=~/.claude-sec`). + +Both gaps reduce to two missing primitives — a per-instance name suffix, and env +injection at launch — plus declarative sugar so a reused alternate account is one +word. + +## Non-goals (deliberately deferred) + +- **Per-project profiles / project-over-global merge.** Profiles live in the + global config only. An alternate account is a personal thing reused across + every repo; per-project re-declaration is YAGNI. +- **`model`/`effort`/extra-args inside a profile.** v1 profiles carry `tool` + + `env` only. Model/effort still pass through at the call site + (`sloop run @sec -m opus`). The schema leaves room to add them later. +- **Validating env values** (e.g. checking the config dir exists). Values are + expanded (`~`, `$VAR`) and forwarded as-is, matching how `--model` is + forwarded un-validated. +- **Changing the default re-attach behavior.** `sloop run claude` with no + instance still means "jump to my claude." A fresh instance is always explicit + (`@profile`, `tool@instance`, `--name`, or `--new`). + +## Token grammar + +One positional grammar covers both profiles and ad-hoc instances. `@` always +introduces a *name*; what is left of it (if anything) is the tool. + +| Token | Meaning | Session | +|---|---|---| +| `claude` | tool, default instance (unchanged) | `ws__claude` | +| `@sec` | left of `@` empty → **profile** `sec` | `ws__claude__sec` | +| `claude@b` | left is a tool → ad-hoc **instance** `b`, default account | `ws__claude__b` | + +For a token containing `@`, split once into `left@right`: + +- `left` empty → look up **profile** `right` in the global config. The profile + supplies the tool (required) and env. The instance name defaults to the + profile key (`right`), overridable by `--name`. +- `left` is a known tool/alias → ad-hoc **instance** `right` on that tool, no + env — equivalent to `sloop run --name `. +- `left` is neither empty nor a known tool → error (it is neither a profile nor + a known tool). + +Tokens without `@` resolve exactly as today (tool key → binary alias → model +alias). + +## Flags on `sloop run` + +- `-n, --name ` — explicit instance suffix. Sanitized; rejected if it + contains `__` (the parser uses `__` as the separator). Overrides a profile key. +- `--env KEY=VAL` (repeatable) — ad-hoc env injection without a profile. Merged + after profile env, so the call site wins on a key clash. +- `-N, --new` — when no explicit name/instance is given, take the first free + instance slot instead of re-attaching: `ws__tool`, then `ws__tool__2`, `__3`, + … With an explicit name it is a no-op (the name already makes it distinct). + +## Profile schema (global `~/.sloop/config.yaml`) + +```yaml +profiles: + sec: + tool: claude # required: a manifest key (or alias) + env: + CLAUDE_CONFIG_DIR: ~/.claude-sec # ~ and $VAR expanded at launch +``` + +Profiles are optional and unknown to older configs: a config without a +`profiles:` key is unaffected. + +## Session naming + +A named instance appends a suffix: `ws__tool__instance`. An empty instance is +the legacy `ws__tool`, byte-for-byte unchanged. The profile name (or `--name`, +or the auto-picked `2`, `3`, …) becomes the instance suffix, and the session +registry records it. + +## Tool-aware name parsing (the one careful bit) + +The fleet view splits a session name back into workspace and tool. A +three-segment `ws__tool__instance` breaks the old "split on the last `__`" rule +— it would read the instance as the tool. Instead the tool is identified **by +matching known adapter keys**: the tool is the last `__` segment that is a known +tool (legacy `ws__tool` → no instance), else the segment just before a trailing +instance. This is backward compatible, and robust even when a workspace name +itself contains `__`, because the tool is found by key rather than by counting +separators. + +## Env injection + +Launch carries extra env (e.g. `CLAUDE_CONFIG_DIR`). Under tmux the launched +command is prefixed with the `env` binary, so it works on every tmux version +with no dependency on `new-session -e`: + +``` +new-session -d -s -c env CLAUDE_CONFIG_DIR=… +``` + +Without tmux, the env is set on the child process directly. Values are expanded +before launch: a leading `~/` becomes the home dir, and `$VAR`/`${VAR}` expand +against the current environment. Empty env changes nothing (identical to today). + +## Fleet display + +The instance shows next to the tool so two claudes in one repo are +distinguishable — `claude·sec`, `claude·b`, `claude·2` — consistently across +`sloop ps`, the fleet picker, the per-session status line, and the waiting +badge. + +## `sloop profile` command + +A small group that edits the global config (hand-editing the YAML stays valid): + +``` +sloop profile add --tool [--env KEY=VAL …] # write/overwrite +sloop profile ls # name · tool · env keys +sloop profile rm # delete +``` + +`add` validates that the tool resolves to a known adapter before saving; `ls` +with no profiles prints a hint pointing back at `profile add`. + +## Error handling + +User-facing messages stay specific and actionable: + +- Unknown profile → points at `sloop profile ls`. +- A profile whose `tool` is not a known adapter → names the bad tool. +- An instance name containing `__` → rejected (reserved separator). +- An `--env` value that is not `KEY=VAL` → rejected. +- `profile rm` of a name that does not exist → says so. diff --git a/docs/guide/USAGE.md b/docs/guide/USAGE.md index d0d2346..230c241 100644 --- a/docs/guide/USAGE.md +++ b/docs/guide/USAGE.md @@ -124,6 +124,41 @@ sloop run --split # just the default tool (one pane) Great for running two agents on the same code and comparing, or a coder + a reviewer. +### A second agent, or a second account (named instances & profiles) + +`sloop run claude` a second time just re-attaches the existing `__claude` — by design, so it +means "jump back into my claude." To get a **distinct** second agent of the same tool, name it: + +```sh +sloop run claude@review # an ad-hoc instance → session __claude__review +sloop run claude -n review # same thing, with the flag +sloop run claude --new # no name? auto-pick the next free slot: claude·2, claude·3, … +``` + +Named instances show up in `sloop ps` as `claude·review` / `claude·2`, so two claudes in one repo are +never ambiguous. + +For a **different account** of the same provider, sloop launches the real binary (not your shell, so a +`claude-sec` alias won't apply) and injects the env that selects the account. One-off: + +```sh +sloop run claude --name sec --env CLAUDE_CONFIG_DIR=~/.claude-sec +``` + +Reusing it daily? Save a **profile** once (global, works from any repo) and call it with `@`: + +```sh +sloop profile add sec --tool claude --env CLAUDE_CONFIG_DIR=~/.claude-sec +sloop profile ls # name · tool · env keys +sloop run @sec # launches claude under ~/.claude-sec, session __claude__sec +sloop profile rm sec # remove it +``` + +The profile name becomes the instance suffix (override with `--name`). Env values expand `~` and +`$VAR`; `--env` at the call site overrides the profile on a key clash. Profiles live in +`~/.sloop/config.yaml` (see [CONFIG.md](../reference/CONFIG.md)); the full design is in +[profiles.md](../design/profiles.md). + --- ## 3. Manage the fleet (`sloop ps`) @@ -278,6 +313,23 @@ After `setup`, press your tmux **prefix then `g`** from inside any agent to summ control center appears floating, you answer/jump with one key, and the popup vanishes. (Native Windows psmux may not support popups; everything else still works.) +### Peek into a waiting agent (overlay, tmux ≥ 3.2) + +`sloop ps` + `Enter` (or `switch-client`) **replaces your whole screen** with the agent. When you just +need to answer a quick prompt and get back to deep work, **peek** floats that agent's live pane over +your current one instead: + +```sh +sloop peek # overlay the lone waiting agent (or pick when several/none wait) +sloop peek backend__claude # overlay a specific session +sloop peek setup # bind p to peek the waiting agent (prints the ~/.tmux.conf line) +``` + +Answer the agent in the floating pane, close it, and you're exactly where you were — the agent keeps +running (closing the overlay detaches, it never kills the session). Every sloop status bar also shows a +fleet-wide **`⏳ N waiting`** badge so you know when an agent needs you without leaving your seat. Full +design: [peek.md](../design/peek.md). + --- ## 4. Context delivery without launching diff --git a/docs/reference/ADAPTERS.md b/docs/reference/ADAPTERS.md index 49c4657..77bcd57 100644 --- a/docs/reference/ADAPTERS.md +++ b/docs/reference/ADAPTERS.md @@ -51,6 +51,16 @@ run: # `sloop run` model/effort knobs (all optional) heuristics: # OPTIONAL fallback status markers (see below) — usually omit waiting: ["shall I apply"] # tool-specific phrasing the cross-tool defaults miss working: ["indexing repo"] + +readiness: # OPTIONAL extra `sloop check` best practices, sourced from the + docs: https://… # provider's own guidance (cited so it's not sloop's opinion) + checks: # only filesystem-detectable practices; behavioral tips don't belong + - id: subagents + label: "Subagents folder (.claude/agents)" + kind: dir-exists # "file-exists" | "dir-exists" | "git-tracked" + path: .claude/agents # repo-relative + fix: "sloop init --scaffold" + optional: true # missing → advisory info (•), not a counted gap (✗) ``` ## Which features are manifest-driven @@ -64,6 +74,7 @@ heuristics: # OPTIONAL fallback status markers (see below) | skills symlink | `sync` | `skills.target` | | `sloop hooks` + status | `hooks.go` | `hooks.*` | | `sloop ps` status fallback | `tmux.ClassifyStatus` | `heuristics.*` (additive; see below) | +| `sloop check` readiness | `check.go` | `readiness.*` (optional; provider-sourced) | | shell completion | `completion.go` | manifest keys | The runtime view of all of this is **`sloop tools`** (capability matrix: diff --git a/docs/reference/CONFIG.md b/docs/reference/CONFIG.md index 207a1ce..d6301b8 100644 --- a/docs/reference/CONFIG.md +++ b/docs/reference/CONFIG.md @@ -21,8 +21,21 @@ version: 1 mode: ask lang: vi # optional: hint language (en/vi); empty = auto from $SLOOP_LANG/$LANG hints: false # optional: turn the 💡 education tips off (omit/true = on) + +# optional: reusable run profiles — a named tool + env, launched as `sloop run @`. +# Manage with `sloop profile add|ls|rm`, or hand-edit here. +profiles: + sec: # → sloop run @sec (session __claude__sec) + tool: claude # a manifest key (or its binary alias) + env: + CLAUDE_CONFIG_DIR: ~/.claude-sec # ~ and $VAR are expanded at launch ``` +Profiles are **global on purpose**: an alternate account is a personal thing reused across every repo, +not project state. The profile name becomes the session's instance suffix; `sloop run` flags +(`-m`, `-e`, `--env`, …) still apply on top. See the +[profiles design](../design/profiles.md) for the full token grammar (`@profile`, `tool@instance`). + ## 2. Local — `.sloop/` (per repo, committed) | Path | Purpose | diff --git a/internal/adapter/adapter.go b/internal/adapter/adapter.go index 8f37e5b..3c1a03e 100644 --- a/internal/adapter/adapter.go +++ b/internal/adapter/adapter.go @@ -95,6 +95,30 @@ type Manifest struct { // can create (e.g. .claude/skills, .cursor/rules), so users start from the // provider's expected layout instead of an ad-hoc one. Scaffold []string `yaml:"scaffold"` + // Readiness declares extra best-practice checks for `sloop check`, sourced + // from the provider's own docs — so the checklist deepens by editing data. + Readiness ReadinessSpec `yaml:"readiness"` +} + +// ReadinessSpec is a provider's best-practice checklist for `sloop check`. Docs +// is the source these criteria come from (the provider's own guidance), so each +// check is attributable, not sloop's opinion. +type ReadinessSpec struct { + Docs string `yaml:"docs"` + Checks []ReadinessCheck `yaml:"checks"` +} + +// ReadinessCheck is one declarative, filesystem-detectable best practice. Kind is +// "file-exists" | "dir-exists" | "git-tracked"; Path is repo-relative. Optional +// checks surface as advisory info (not counted as a gap) so power-user features +// don't nag a basic setup. +type ReadinessCheck struct { + ID string `yaml:"id"` + Label string `yaml:"label"` + Kind string `yaml:"kind"` + Path string `yaml:"path"` + Fix string `yaml:"fix"` + Optional bool `yaml:"optional"` } func LoadBuiltin() (map[string]Manifest, error) { diff --git a/internal/adapter/builtin/claude.yaml b/internal/adapter/builtin/claude.yaml index d7cc13b..1a70414 100644 --- a/internal/adapter/builtin/claude.yaml +++ b/internal/adapter/builtin/claude.yaml @@ -25,6 +25,19 @@ run: models: [opus, sonnet, haiku, fable] prompt: positional # `claude "task"` seeds an interactive session (claude -p exits) resume_flag: "--continue" # `claude --continue` resumes the repo's most recent conversation +# Extra `sloop check` best practices, sourced from Claude Code's own guidance. +# These are the filesystem-detectable ones; behavioral tips (explore→plan→code, +# /clear, verification loops) are intentionally NOT here — they can't be checked +# mechanically and would only produce false gaps. +readiness: + docs: https://code.claude.com/docs/en/best-practices + checks: + - id: subagents + label: "Subagents folder (.claude/agents) for delegated, isolated tasks" + kind: dir-exists + path: .claude/agents + fix: "sloop init --scaffold" + optional: true # No `heuristics:` block: Claude's prompts use the cross-tool defaults (see # defaultWaitingMarkers/defaultWorkingMarkers in internal/tmux/status.go). # Precise status comes from the hooks above; heuristics are only the fallback. diff --git a/internal/cli/commands/approve.go b/internal/cli/commands/approve.go index a5f3c0a..6b154a1 100644 --- a/internal/cli/commands/approve.go +++ b/internal/cli/commands/approve.go @@ -23,7 +23,7 @@ func RunApprove(w io.Writer, in io.Reader, targets []string, all, waiting, yes b // Enrich first so each row has a Status; selectSessions does its own // filterWaiting for the --waiting case (mirrors kill). Pre-filtering here // would run before Status is set and drop every row. - rows := enrichGlances(fleetRows(tmux.ParseSessions(tmuxList())), manifests) + rows := enrichGlances(fleetRows(tmux.ParseSessions(tmuxList()), manifests), manifests) sel, err := selectSessions(rows, targets, all, waiting) if err != nil { return nil, err diff --git a/internal/cli/commands/check.go b/internal/cli/commands/check.go index d23ee22..512427e 100644 --- a/internal/cli/commands/check.go +++ b/internal/cli/commands/check.go @@ -4,6 +4,8 @@ import ( "fmt" "io" "os" + "os/exec" + "path/filepath" "github.com/spf13/cobra" @@ -15,6 +17,33 @@ import ( "github.com/stroops/sloop/internal/workspace" ) +// git helpers are seams (overridable in tests) so the checklist can verify a +// file is committed without a real repo in the test. +var ( + inGitRepo = func(root string) bool { + return exec.Command("git", "-C", root, "rev-parse", "--is-inside-work-tree").Run() == nil + } + gitTracked = func(root, path string) bool { + return exec.Command("git", "-C", root, "ls-files", "--error-unmatch", "--", path).Run() == nil + } +) + +// evalReadiness evaluates one declarative manifest check against the filesystem. +func evalReadiness(root string, c adapter.ReadinessCheck) bool { + p := filepath.Join(root, c.Path) + switch c.Kind { + case "file-exists": + fi, err := os.Stat(p) + return err == nil && !fi.IsDir() + case "dir-exists": + fi, err := os.Stat(p) + return err == nil && fi.IsDir() + case "git-tracked": + return inGitRepo(root) && gitTracked(root, c.Path) + } + return false +} + // checkItem is one readiness line: whether it passed, a label, and the command // that fixes it when it hasn't. Info items are advisory (not counted as a gap). type checkItem struct { @@ -33,12 +62,26 @@ type checkItem struct { func readinessChecklist(root, sloopDir string, proj *config.Project, manifests map[string]adapter.Manifest) []checkItem { var items []checkItem - // Workspace-level basics. - items = append(items, checkItem{ - OK: syncpkg.AgentsState(root) == "ok", - Label: "AGENTS.md present (canonical context)", - Fix: "sloop init", - }) + // AGENTS.md is sloop's portable-context layer — only relevant when the + // workspace actually uses it (≥2 tools or a native-mode tool). A lone + // pointer-mode tool keeps its own context file, so we don't ask for AGENTS.md. + canonical := needsCanonical(proj.Tools, manifests) + if canonical { + agentsOK := syncpkg.AgentsState(root) == "ok" + items = append(items, checkItem{ + OK: agentsOK, + Label: "AGENTS.md present (canonical context)", + Fix: "sloop init", + }) + // Committed so the team shares it — a best practice every provider echoes. + if agentsOK && inGitRepo(root) { + items = append(items, checkItem{ + OK: gitTracked(root, "AGENTS.md"), + Label: "AGENTS.md committed to git (shared with your team)", + Fix: "git add AGENTS.md && git commit", + }) + } + } _, hasDefault := manifests[proj.DefaultTool] items = append(items, checkItem{ OK: proj.DefaultTool != "" && hasDefault, @@ -54,14 +97,20 @@ func readinessChecklist(root, sloopDir string, proj *config.Project, manifests m continue } - // Context delivery (pointer-mode tools only; native tools need nothing). + // Context: the tool can see project context. We respect a context file the + // tool already owns (e.g. from its own /init) — that counts as ready, never + // a "repair me" nag. Native tools read AGENTS.md directly (only canonical). switch syncpkg.ContextState(root, m) { case "ok": items = append(items, checkItem{OK: true, Label: m.Name + ": context delivered (" + m.Context.File + ")"}) case "foreign": - items = append(items, checkItem{Label: m.Name + ": " + m.Context.File + " is a hand-authored file, not a sloop pointer", Fix: "sloop sync --repair"}) + items = append(items, checkItem{OK: true, Label: m.Name + ": has its own context (" + m.Context.File + ")"}) case "missing": - items = append(items, checkItem{Label: m.Name + ": context pointer not delivered (" + m.Context.File + ")", Fix: "sloop sync"}) + fix := "sloop sync" + if !canonical { + fix = "run `" + t + "` then /init" + } + items = append(items, checkItem{Label: m.Name + ": no context yet (" + m.Context.File + ")", Fix: fix}) } // Skills linked — only relevant once there are skills to deliver. @@ -81,6 +130,19 @@ func readinessChecklist(root, sloopDir string, proj *config.Project, manifests m items = append(items, checkItem{Label: m.Name + ": status hooks not installed", Fix: "sloop hooks install " + t}) } } + + // Extra best practices declared in the manifest (sourced from the provider's + // own docs). Optional ones are advisory so power-user features don't nag. + for _, rc := range m.Readiness.Checks { + switch { + case evalReadiness(root, rc): + items = append(items, checkItem{OK: true, Label: m.Name + ": " + rc.Label}) + case rc.Optional: + items = append(items, checkItem{Info: true, Label: m.Name + ": " + rc.Label + " (optional)"}) + default: + items = append(items, checkItem{Label: m.Name + ": " + rc.Label, Fix: rc.Fix}) + } + } } return items @@ -129,6 +191,17 @@ func RunCheck(startDir string, w io.Writer) error { return err } renderChecklist(w, ws.Name, readinessChecklist(ws.Root, ws.SloopDir(), proj, manifests)) + + // Cite the source of each provider's extra criteria, so the checklist reads as + // "X's own best practices", not sloop's opinion. + seen := map[string]bool{} + for _, t := range proj.Tools { + m := manifests[t] + if m.Readiness.Docs != "" && !seen[m.Readiness.Docs] { + seen[m.Readiness.Docs] = true + _, _ = fmt.Fprintf(w, "%s %s — %s\n", tui.Grey(" learn more:"), m.Name, tui.Grey(m.Readiness.Docs)) + } + } return nil } diff --git a/internal/cli/commands/check_test.go b/internal/cli/commands/check_test.go index 059e8e9..e0ee674 100644 --- a/internal/cli/commands/check_test.go +++ b/internal/cli/commands/check_test.go @@ -27,26 +27,29 @@ func TestReadinessChecklist(t *testing.T) { t.Fatal(err) } + // Two tools (claude pointer + cursor native) → canonical workspace, so the + // AGENTS.md + context checks apply. manifests := map[string]adapter.Manifest{ "claude": { Name: "Claude Code", Context: adapter.ContextSpec{Mode: "pointer", File: "CLAUDE.md"}, Hooks: adapter.HooksSpec{Install: "settings-json", Config: ".claude/settings.local.json", Events: adapter.HookEvents{Idle: "Stop"}}, }, + "cursor": {Name: "Cursor CLI", Context: adapter.ContextSpec{Mode: "native"}}, } - proj := &config.Project{Tools: []string{"claude"}, DefaultTool: "claude"} + proj := &config.Project{Tools: []string{"claude", "cursor"}, DefaultTool: "claude"} // Bare workspace: no AGENTS.md, no pointer, no hooks → those should be gaps. items := readinessChecklist(root, sloopDir, proj, manifests) - if it, ok := findItem(items, "AGENTS.md"); !ok || it.OK { + if it, ok := findItem(items, "AGENTS.md present"); !ok || it.OK { t.Fatalf("AGENTS.md should be a gap: %+v", it) } if it, ok := findItem(items, "Default tool"); !ok || !it.OK { t.Fatalf("default tool should pass: %+v", it) } - if it, ok := findItem(items, "context pointer not delivered"); !ok || it.OK || it.Fix != "sloop sync" { - t.Fatalf("missing pointer should suggest sloop sync: %+v", it) + if it, ok := findItem(items, "no context yet"); !ok || it.OK || it.Fix != "sloop sync" { + t.Fatalf("missing context should suggest sloop sync: %+v", it) } if it, ok := findItem(items, "status hooks not installed"); !ok || it.OK || !strings.Contains(it.Fix, "hooks install") { t.Fatalf("missing hooks should suggest install: %+v", it) @@ -57,7 +60,72 @@ func TestReadinessChecklist(t *testing.T) { t.Fatal(err) } items = readinessChecklist(root, sloopDir, proj, manifests) - if it, ok := findItem(items, "AGENTS.md"); !ok || !it.OK { + if it, ok := findItem(items, "AGENTS.md present"); !ok || !it.OK { t.Fatalf("AGENTS.md should now pass: %+v", it) } } + +func TestEvalReadiness(t *testing.T) { + root := t.TempDir() + _ = os.MkdirAll(filepath.Join(root, "adir"), 0o755) + _ = os.WriteFile(filepath.Join(root, "afile"), []byte("x"), 0o644) + + cases := []struct { + kind, path string + want bool + }{ + {"file-exists", "afile", true}, + {"file-exists", "adir", false}, // a dir is not a file + {"file-exists", "nope", false}, + {"dir-exists", "adir", true}, + {"dir-exists", "afile", false}, + {"weird", "afile", false}, // unknown kind + } + for _, c := range cases { + got := evalReadiness(root, adapter.ReadinessCheck{Kind: c.kind, Path: c.path}) + if got != c.want { + t.Errorf("evalReadiness(%s,%s) = %v, want %v", c.kind, c.path, got, c.want) + } + } +} + +func TestReadinessManifestChecks(t *testing.T) { + root := t.TempDir() + sloopDir := filepath.Join(root, ".sloop") + _ = os.MkdirAll(sloopDir, 0o755) + _ = os.WriteFile(filepath.Join(root, "AGENTS.md"), []byte("# g\n"), 0o644) + + // Pretend we're in a git repo where AGENTS.md is NOT committed. + defer func(a func(string) bool, b func(string, string) bool) { inGitRepo, gitTracked = a, b }(inGitRepo, gitTracked) + inGitRepo = func(string) bool { return true } + gitTracked = func(_, _ string) bool { return false } + + manifests := map[string]adapter.Manifest{ + "claude": { + Name: "Claude Code", + Context: adapter.ContextSpec{Mode: "native"}, + Readiness: adapter.ReadinessSpec{ + Docs: "https://example/best-practices", + Checks: []adapter.ReadinessCheck{ + {ID: "req", Label: "Required dir", Kind: "dir-exists", Path: ".claude/agents", Fix: "make it"}, + {ID: "opt", Label: "Optional dir", Kind: "dir-exists", Path: ".claude/extra", Optional: true}, + }, + }, + }, + } + proj := &config.Project{Tools: []string{"claude"}, DefaultTool: "claude"} + items := readinessChecklist(root, sloopDir, proj, manifests) + + // AGENTS.md exists but not committed → that check is a gap. + if it, ok := findItem(items, "committed to git"); !ok || it.OK { + t.Fatalf("uncommitted AGENTS.md should be a gap: %+v", it) + } + // Required manifest check missing → gap with its fix. + if it, ok := findItem(items, "Required dir"); !ok || it.OK || it.Fix != "make it" { + t.Fatalf("required readiness check should be a gap with fix: %+v", it) + } + // Optional manifest check missing → advisory info, not a gap. + if it, ok := findItem(items, "Optional dir"); !ok || it.OK || !it.Info { + t.Fatalf("optional readiness check should be info, not a gap: %+v", it) + } +} diff --git a/internal/cli/commands/completion.go b/internal/cli/commands/completion.go index 97d418e..3d58141 100644 --- a/internal/cli/commands/completion.go +++ b/internal/cli/commands/completion.go @@ -83,7 +83,7 @@ func completeSessionNames(_ *cobra.Command, args []string, _ string) ([]string, if len(args) > 0 { return nil, cobra.ShellCompDirectiveNoFileComp } - rows := fleetRows(tmux.ParseSessions(tmuxList())) + rows := fleetRows(tmux.ParseSessions(tmuxList()), nil) // only r.Name is used names := make([]string, 0, len(rows)) for _, r := range rows { names = append(names, r.Name) @@ -97,7 +97,8 @@ func completePsIndex(_ *cobra.Command, args []string, _ string) ([]string, cobra if len(args) > 0 { return nil, cobra.ShellCompDirectiveNoFileComp } - rows := fleetRows(tmux.ParseSessions(tmuxList())) + manifests, _ := adapter.Load() + rows := fleetRows(tmux.ParseSessions(tmuxList()), manifests) // same order as `ps` out := make([]string, 0, len(rows)) for i, r := range rows { out = append(out, fmt.Sprintf("%d\t%s", i+1, r.Name)) // valuedescription @@ -111,7 +112,7 @@ func completeSendTargets(_ *cobra.Command, args []string, _ string) ([]string, c if len(args) > 0 { return nil, cobra.ShellCompDirectiveNoFileComp } - rows := fleetRows(tmux.ParseSessions(tmuxList())) + rows := fleetRows(tmux.ParseSessions(tmuxList()), nil) // only r.Name is used out := make([]string, 0, len(rows)) for _, r := range rows { out = append(out, r.Name) diff --git a/internal/cli/commands/doctor.go b/internal/cli/commands/doctor.go index 649a939..19a1e45 100644 --- a/internal/cli/commands/doctor.go +++ b/internal/cli/commands/doctor.go @@ -9,38 +9,47 @@ import ( "github.com/stroops/sloop/internal/adapter" "github.com/stroops/sloop/internal/config" "github.com/stroops/sloop/internal/detect" + "github.com/stroops/sloop/internal/tui" ) func RunDoctor(w io.Writer) error { - _, _ = fmt.Fprintln(w, "Tools:") manifests, err := adapter.Load() if err != nil { return err } + + // AI provider CLIs (the tools sloop launches). A missing one isn't an error — + // you only use some — so it's marked in grey, not alarming red. + _, _ = fmt.Fprintln(w, "AI provider CLIs:") for _, s := range detect.Tools(manifests) { - mark := "✗" - extra := "" if s.Installed { - mark = "✓" + ver := "" if s.Version != "" { - extra = " " + s.Version + ver = " " + tui.Grey(s.Version) } + _, _ = fmt.Fprintf(w, " %s %s%s\n", tui.Green("✓"), s.Key, ver) + } else { + _, _ = fmt.Fprintf(w, " %s %s %s\n", tui.Grey("✗"), s.Key, tui.Grey("(not installed)")) } - _, _ = fmt.Fprintf(w, " %s %s%s\n", mark, s.Key, extra) } - tmux := detect.Tmux() - mark := "✗ (optional — exec fallback in use)" - if tmux.Installed { - mark = "✓ " + tmux.Version + // Multiplexer is its own group: it powers ps/run/attach, but is optional. + _, _ = fmt.Fprintln(w, "\nMultiplexer (powers ps / run / attach):") + if tmux := detect.Tmux(); tmux.Installed { + _, _ = fmt.Fprintf(w, " %s tmux %s\n", tui.Green("✓"), tui.Grey(tmux.Version)) + } else { + _, _ = fmt.Fprintf(w, " %s %s\n", tui.Grey("✗"), tui.Grey("tmux not found — optional; sloop falls back to plain exec")) } - _, _ = fmt.Fprintf(w, "tmux: %s\n", mark) g, err := config.LoadGlobal() if err != nil { return err } - _, _ = fmt.Fprintf(w, "mode: %s\n", g.Mode) + desc := "asks before actions" + if g.Mode == config.ModeAuto { + desc = "runs without prompts (assume yes)" + } + _, _ = fmt.Fprintf(w, "\nMode: %s %s\n", g.Mode, tui.Grey("— "+desc)) return nil } diff --git a/internal/cli/commands/hooks.go b/internal/cli/commands/hooks.go index b494e6f..0e6c1b8 100644 --- a/internal/cli/commands/hooks.go +++ b/internal/cli/commands/hooks.go @@ -366,12 +366,18 @@ var hooksPrintCmd = &cobra.Command{ switch m.Hooks.Install { case "settings-json": doc, _ := mergeSettingsHooks(nil, eventCommands(m.Hooks)) - out, _ := json.MarshalIndent(doc, "", " ") + out, err := json.MarshalIndent(doc, "", " ") + if err != nil { + return err + } cmd.Printf("# %s — add to %s\n%s\n", tool, m.Hooks.Config, string(out)) return nil case "cursor-json": doc, _ := mergeCursorHooks(nil, eventCommands(m.Hooks)) - out, _ := json.MarshalIndent(doc, "", " ") + out, err := json.MarshalIndent(doc, "", " ") + if err != nil { + return err + } cmd.Printf("# %s — add to %s\n%s\n", tool, m.Hooks.Config, string(out)) return nil } diff --git a/internal/cli/commands/init.go b/internal/cli/commands/init.go index 47bb386..20c865c 100644 --- a/internal/cli/commands/init.go +++ b/internal/cli/commands/init.go @@ -26,9 +26,22 @@ cache/ vault/ ` -// RunInit scaffolds the workspace and delivers context for every enabled tool, -// returning a human-readable summary of what it created/linked. -func RunInit(dir string, scan bool) ([]string, error) { +// defaultPrimary is the single tool a bare `init` enables — the user's installed +// default (claude when present), keeping a fresh workspace minimal rather than +// turning on every CLI on the machine. +func defaultPrimary(manifests map[string]adapter.Manifest) string { + detected := detect.InstalledKeys(manifests) + if len(detected) == 0 || contains(detected, fallbackTool) { + return fallbackTool + } + return detected[0] +} + +// RunInit scaffolds the workspace and enables the given tools (nil = a minimal +// single-tool default), delivering context only when the workspace actually +// needs sloop's portable layer (see needsCanonical). Returns a summary of what +// it created/linked. +func RunInit(dir string, tools []string, scan bool) ([]string, error) { sloopDir := filepath.Join(dir, config.SloopDirName) for _, sub := range []string{"skills", "vault"} { if err := os.MkdirAll(filepath.Join(sloopDir, sub), 0o700); err != nil { @@ -36,51 +49,47 @@ func RunInit(dir string, scan bool) ([]string, error) { } } - // Detect installed known tools; always ensure claude is usable. manifests, err := adapter.Load() if err != nil { return nil, err } - enabled := detect.InstalledKeys(manifests) - if len(enabled) == 0 { - enabled = []string{fallbackTool} + if len(tools) == 0 { + tools = []string{defaultPrimary(manifests)} } - defaultTool := fallbackTool - if !contains(enabled, fallbackTool) { - defaultTool = enabled[0] + defaultTool := tools[0] + if contains(tools, fallbackTool) { + defaultTool = fallbackTool } if err := config.SaveProject(sloopDir, &config.Project{ - Tools: enabled, + Tools: tools, DefaultTool: defaultTool, }); err != nil { return nil, err } - if scan { + // The portable-context layer (AGENTS.md + pointers) is only set up when the + // workspace needs it; a lone pointer-mode tool keeps its own context file. + canonical := needsCanonical(tools, manifests) + if canonical && scan { if _, err := syncpkg.EnsureAgentsContent(dir, scanpkg.Scan(dir).AgentsMarkdown()); err != nil { return nil, err } - } else { - if _, err := syncpkg.EnsureAgents(dir); err != nil { - return nil, err - } } if err := os.WriteFile(filepath.Join(sloopDir, ".gitignore"), []byte(sloopGitignore), 0o600); err != nil { return nil, err } - // Deliver native context (pointer files like CLAUDE.md + skills links) for - // every enabled tool, so the workspace is usable right after init — not only - // after the first `sloop sync`/`run`. Best-effort per tool. + // Deliver context (when canonical) + skills links for every enabled tool, so + // the workspace is usable right after init. Best-effort per tool. var summary []string - for _, tool := range enabled { + for _, tool := range tools { m, ok := manifests[tool] if !ok { continue } - log, err := syncOne(dir, sloopDir, m, false) + log, err := syncOne(dir, sloopDir, m, false, canonical) if err != nil { return nil, err } @@ -90,6 +99,9 @@ func RunInit(dir string, scan bool) ([]string, error) { if initScaffold { for _, d := range m.Scaffold { target := filepath.Join(dir, d) + if _, err := os.Stat(target); err == nil { + continue // already there — don't claim to have scaffolded it + } if err := os.MkdirAll(target, 0o755); err != nil { return nil, err } @@ -121,8 +133,86 @@ func RunInit(dir string, scan bool) ([]string, error) { var ( initScan bool initScaffold bool + initTools string ) +// resolveInitTools decides which tools `init` enables: the explicit --tools list +// if given, else an interactive per-tool prompt (every detected tool is asked, the +// primary defaulted on), else a minimal single-tool default. r==nil means +// non-interactive. Keeps a fresh workspace from turning on every CLI installed. +func resolveInitTools(cmd *cobra.Command, manifests map[string]adapter.Manifest, r *bufio.Reader, ix Interaction) []string { + if initTools != "" { + var out []string + for _, t := range strings.Split(initTools, ",") { + t = strings.TrimSpace(t) + if t == "" { + continue + } + if _, has := manifests[t]; !has { + cmd.Printf("%s\n", tui.Grey("ignoring unknown tool: "+t)) + continue + } + if !contains(out, t) { + out = append(out, t) + } + } + if len(out) > 0 { + return out + } + } + + detected := detect.InstalledKeys(manifests) + if len(detected) == 0 { + detected = []string{fallbackTool} + } + primary := defaultPrimary(manifests) + + if r == nil { + return []string{primary} // minimal default + } + + // Ask about every detected tool (including the primary — it isn't always + // Claude), primary first and defaulted on so a quick Enter keeps it simple. + cmd.Printf("Detected tools: %s\n", strings.Join(detected, ", ")) + var selected []string + for _, t := range primaryFirst(detected, primary) { + if ix.Ask("Use "+displayTool(t, manifests)+" in this workspace?", t == primary, r, cmd.OutOrStdout()) { + selected = append(selected, t) + } + } + if len(selected) == 0 { + cmd.Println(tui.Grey("none selected — enabling " + displayTool(primary, manifests))) + selected = []string{primary} + } + return selected +} + +// primaryFirst returns detected tools with the primary moved to the front, so the +// most-likely tool (e.g. Claude) is the first one init asks about. +func primaryFirst(detected []string, primary string) []string { + out := make([]string, 0, len(detected)) + if contains(detected, primary) { + out = append(out, primary) + } + for _, t := range detected { + if t != primary { + out = append(out, t) + } + } + return out +} + +// hooksNeeded reports whether any enabled tool's status hooks aren't installed. +func hooksNeeded(root string, tools []string, manifests map[string]adapter.Manifest) bool { + for _, t := range tools { + m := manifests[t] + if hookInstaller(m.Hooks.Install) != nil && !hooksInstalledFor(root, m) { + return true + } + } + return false +} + var initCmd = &cobra.Command{ Use: "init", Short: "Scaffold a .sloop workspace in the current directory", @@ -131,27 +221,34 @@ var initCmd = &cobra.Command{ if err != nil { return err } + manifests, _ := adapter.Load() - // Interactive onboarding: when on a real terminal and not under - // --auto/--no-input, walk a new user through the choices. Otherwise keep - // the scriptable behavior (flags only). - wantHooks := false + // One reader for the whole interactive flow (two readers on stdin would + // race over buffered input). + var r *bufio.Reader + var ix Interaction if interactiveInit(cmd) { - r := bufio.NewReader(os.Stdin) - ix := initInteraction(cmd) - manifests, _ := adapter.Load() - det := detect.InstalledKeys(manifests) - if len(det) == 0 { - det = []string{fallbackTool} + r = bufio.NewReader(os.Stdin) + ix = initInteraction(cmd) + } + tools := resolveInitTools(cmd, manifests, r, ix) + canonical := needsCanonical(tools, manifests) + + // Only ask about work that isn't already done. + wantHooks := false + if r != nil { + if canonical && syncpkg.AgentsState(cwd) != "ok" { + initScan = ix.Ask("Pre-fill AGENTS.md from your codebase?", true, r, cmd.OutOrStdout()) + } + // Scaffolding provider folders isn't asked: tools create their own + // layout, and sloop already creates the skills dir it needs. It stays + // available as the explicit `--scaffold` opt-in. + if hooksNeeded(cwd, tools, manifests) { + wantHooks = ix.Ask("Install status hooks for precise `sloop ps`?", true, r, cmd.OutOrStdout()) } - cmd.Printf("Detected tools: %s\n", strings.Join(det, ", ")) - cmd.Println(tui.Grey("(edit .sloop/config.yaml later to change which are enabled)")) - initScan = ix.Ask("Pre-fill AGENTS.md from your codebase?", true, r, cmd.OutOrStdout()) - initScaffold = ix.Ask("Create each tool's standard folders?", false, r, cmd.OutOrStdout()) - wantHooks = ix.Ask("Install status hooks for precise `sloop ps`?", true, r, cmd.OutOrStdout()) } - summary, err := RunInit(cwd, initScan) + summary, err := RunInit(cwd, tools, initScan) if err != nil { return err } @@ -164,7 +261,12 @@ var initCmd = &cobra.Command{ cmd.Printf(" %s\n", l) } } - cmd.Println("Next: edit AGENTS.md, then `sloop run`.") + if canonical { + cmd.Println("Next: edit AGENTS.md, then `sloop run`.") + } else { + cmd.Printf("Next: %s keeps its own context (e.g. run it and `/init`). sloop manages the fleet & skills here.\n", + displayTool(tools[0], manifests)) + } hints.Show(cmd.OutOrStdout(), "init") return nil }, @@ -218,7 +320,8 @@ func installHooksForEnabled(root string) []string { func RegisterInit(cmd *cobra.Command) { initCmd.Flags().BoolVarP(&initScan, "scan", "s", false, "scan the existing codebase to pre-fill AGENTS.md") - initCmd.Flags().BoolVarP(&initScaffold, "scaffold", "S", false, "also create each enabled tool's standard folders (.claude/skills, .cursor/rules, …)") + initCmd.Flags().BoolVarP(&initScaffold, "scaffold", "S", false, "opt-in: also create each enabled tool's standard folders (.claude/skills, .cursor/rules, …); off by default since tools create their own") + initCmd.Flags().StringVar(&initTools, "tools", "", "comma-separated tools to enable for this workspace (default: your primary tool)") cmd.AddCommand(initCmd) } diff --git a/internal/cli/commands/initcmd_test.go b/internal/cli/commands/initcmd_test.go index 4496010..fbf7488 100644 --- a/internal/cli/commands/initcmd_test.go +++ b/internal/cli/commands/initcmd_test.go @@ -6,33 +6,70 @@ import ( "strings" "testing" + "github.com/spf13/cobra" + + "github.com/stroops/sloop/internal/adapter" "github.com/stroops/sloop/internal/config" ) +func TestResolveInitToolsFlag(t *testing.T) { + manifests := map[string]adapter.Manifest{ + "claude": {Name: "Claude Code"}, + "cursor": {Name: "Cursor CLI"}, + } + defer func(v string) { initTools = v }(initTools) + initTools = "claude, bogus, cursor, claude" // unknown dropped, dupes deduped + + got := resolveInitTools(&cobra.Command{}, manifests, nil, Interaction{}) + if len(got) != 2 || got[0] != "claude" || got[1] != "cursor" { + t.Fatalf("resolveInitTools = %v, want [claude cursor]", got) + } +} + +func TestPrimaryFirst(t *testing.T) { + got := primaryFirst([]string{"agy", "claude", "cursor"}, "claude") + if len(got) != 3 || got[0] != "claude" { + t.Fatalf("primaryFirst = %v, want claude first", got) + } + // Primary not in the list → order unchanged. + if got := primaryFirst([]string{"cursor", "gemini"}, "claude"); got[0] != "cursor" { + t.Fatalf("primaryFirst absent = %v", got) + } +} + +func TestHooksNeeded(t *testing.T) { + root := t.TempDir() + manifests := map[string]adapter.Manifest{ + "claude": {Hooks: adapter.HooksSpec{Install: "settings-json", Config: ".claude/settings.local.json", Events: adapter.HookEvents{Idle: "Stop"}}}, + "codex": {Hooks: adapter.HooksSpec{Install: ""}}, // manual install → never "needed" + } + if !hooksNeeded(root, []string{"claude"}, manifests) { + t.Fatal("claude hooks not installed → needed") + } + if hooksNeeded(root, []string{"codex"}, manifests) { + t.Fatal("manual-install tool → not auto-needed") + } +} + func TestRunInitScaffolds(t *testing.T) { dir := t.TempDir() t.Setenv("HOME", t.TempDir()) // isolate the global DB - t.Setenv("PATH", t.TempDir()) // empty PATH: no tools detected + t.Setenv("PATH", t.TempDir()) // empty PATH: no tools detected → minimal claude-only - if _, err := RunInit(dir, false); err != nil { + if _, err := RunInit(dir, nil, false); err != nil { t.Fatalf("RunInit: %v", err) } - if _, err := os.Stat(filepath.Join(dir, "AGENTS.md")); err != nil { - t.Fatalf("expected AGENTS.md: %v", err) - } - // init delivers pointers immediately (no separate sync needed). - if _, err := os.Stat(filepath.Join(dir, "CLAUDE.md")); err != nil { - t.Fatalf("expected CLAUDE.md delivered by init: %v", err) + // A lone pointer-mode tool stays minimal: no AGENTS.md / pointer (the tool + // keeps its own context). sloop just sets up .sloop and registers. + if _, err := os.Stat(filepath.Join(dir, "AGENTS.md")); !os.IsNotExist(err) { + t.Fatalf("single-tool init should NOT create AGENTS.md") } - if _, err := os.Stat(filepath.Join(dir, ".sloop", "context")); !os.IsNotExist(err) { - t.Fatalf(".sloop/context should not exist") + if _, err := os.Stat(filepath.Join(dir, "CLAUDE.md")); !os.IsNotExist(err) { + t.Fatalf("single-tool init should NOT create a CLAUDE.md pointer") } - for _, p := range []string{ - ".sloop/config.yaml", - ".sloop/.gitignore", - } { + for _, p := range []string{".sloop/config.yaml", ".sloop/.gitignore"} { if _, err := os.Stat(filepath.Join(dir, p)); err != nil { t.Fatalf("expected %s: %v", p, err) } @@ -48,11 +85,28 @@ func TestRunInitScaffolds(t *testing.T) { } } +func TestRunInitCanonicalDelivers(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", t.TempDir()) + t.Setenv("PATH", t.TempDir()) + + // Two tools → canonical workspace: AGENTS.md + the CLAUDE.md pointer appear. + if _, err := RunInit(dir, []string{"claude", "cursor"}, false); err != nil { + t.Fatalf("RunInit: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "AGENTS.md")); err != nil { + t.Fatalf("expected AGENTS.md in a canonical workspace: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "CLAUDE.md")); err != nil { + t.Fatalf("expected CLAUDE.md pointer delivered: %v", err) + } +} + func TestRunInitFallsBackToClaude(t *testing.T) { dir := t.TempDir() t.Setenv("HOME", t.TempDir()) t.Setenv("PATH", t.TempDir()) // empty PATH: no tools detected - if _, err := RunInit(dir, false); err != nil { + if _, err := RunInit(dir, nil, false); err != nil { t.Fatalf("RunInit: %v", err) } p, err := config.LoadProject(filepath.Join(dir, ".sloop")) @@ -74,7 +128,7 @@ func TestRunInitScaffoldCreatesStandardFolders(t *testing.T) { initScaffold = true defer func() { initScaffold = false }() - if _, err := RunInit(dir, false); err != nil { + if _, err := RunInit(dir, nil, false); err != nil { t.Fatalf("RunInit: %v", err) } for _, d := range []string{".claude/skills", ".claude/agents"} { @@ -91,7 +145,8 @@ func TestRunInitScanPopulatesAgents(t *testing.T) { if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module demo\n\ngo 1.26\n"), 0o600); err != nil { t.Fatal(err) } - if _, err := RunInit(dir, true); err != nil { + // Scan pre-fills AGENTS.md, which only exists in a canonical workspace. + if _, err := RunInit(dir, []string{"claude", "cursor"}, true); err != nil { t.Fatalf("RunInit: %v", err) } b, err := os.ReadFile(filepath.Join(dir, "AGENTS.md")) diff --git a/internal/cli/commands/kill.go b/internal/cli/commands/kill.go index 4919265..bb61e22 100644 --- a/internal/cli/commands/kill.go +++ b/internal/cli/commands/kill.go @@ -68,7 +68,7 @@ func RunKill(w io.Writer, in io.Reader, targets []string, all, waiting, yes bool return nil, fmt.Errorf("tmux is not installed; `sloop kill` needs tmux") } manifests, _ := adapter.Load() - rows := enrichGlances(fleetRows(tmux.ParseSessions(tmuxList())), manifests) + rows := enrichGlances(fleetRows(tmux.ParseSessions(tmuxList()), manifests), manifests) if len(rows) == 0 { rows = enrichGlances(rows, manifests) // need status to find who's waiting } diff --git a/internal/cli/commands/ls.go b/internal/cli/commands/ls.go index 9476c48..f0a877e 100644 --- a/internal/cli/commands/ls.go +++ b/internal/cli/commands/ls.go @@ -37,7 +37,7 @@ func openShellIn(dir string) error { // status dots as `ps` — the bridge between the two views. func enrichedByWorkspace(manifests map[string]adapter.Manifest) map[string][]FleetRow { m := map[string][]FleetRow{} - for _, r := range enrichGlances(fleetRows(tmux.ParseSessions(tmuxList())), manifests) { + for _, r := range enrichGlances(fleetRows(tmux.ParseSessions(tmuxList()), manifests), manifests) { m[r.Workspace] = append(m[r.Workspace], r) } return m @@ -99,7 +99,7 @@ func launchWorkspaceDefault(startDir string) error { if err != nil { return err } - return RunRun(startDir, "", "", "", "", "", nil, selectRunner(ws.Name, plan.toolKey)) + return RunRun(startDir, "", "", "", "", "", nil, nil, "", selectRunner(ws.Name, plan.toolKey)) } func RunLs(w io.Writer) error { diff --git a/internal/cli/commands/peek.go b/internal/cli/commands/peek.go new file mode 100644 index 0000000..8e18ef7 --- /dev/null +++ b/internal/cli/commands/peek.go @@ -0,0 +1,122 @@ +package commands + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/stroops/sloop/internal/adapter" + "github.com/stroops/sloop/internal/tmux" +) + +// soleWaiting returns the session name when exactly one row is waiting, so +// `sloop peek` with no argument can jump straight to the agent that needs you. +func soleWaiting(rows []FleetRow) (string, bool) { + name, n := "", 0 + for _, r := range rows { + if r.Status == tmux.StatusWaiting { + name = r.Name + n++ + } + } + if n == 1 { + return name, true + } + return "", false +} + +// resolvePeekTarget chooses which agent to peek: an explicit arg wins; else a +// lone waiting agent is auto-selected; 0 or many fall back to the fleet picker +// (already sorted waiting-first). +func resolvePeekTarget(args []string) (string, error) { + if len(args) == 1 { + return args[0], nil + } + manifests, _ := adapter.Load() + rows := enrichGlances(fleetRows(tmux.ParseSessions(tmuxList()), manifests), manifests) + if len(rows) == 0 { + return "", fmt.Errorf("no running AI sessions to peek (start one with `sloop run `)") + } + if name, ok := soleWaiting(rows); ok { + return name, nil + } + return pickFleetSession("👀 Peek into which agent?") +} + +// requirePeekEnv guards peek: it only makes sense as an overlay inside tmux ≥ 3.2. +func requirePeekEnv() error { + if os.Getenv("TMUX") == "" { + return fmt.Errorf("`sloop peek` overlays an agent inside tmux; outside tmux use `sloop attach`") + } + if !tmux.PopupSupported() { + return fmt.Errorf("`sloop peek` needs tmux ≥ 3.2 for display-popup; use `sloop attach` instead") + } + return nil +} + +var peekInPopup bool + +var peekCmd = &cobra.Command{ + Use: "peek [agent]", + Aliases: []string{"pk"}, + Short: "Overlay a waiting agent, answer it, and drop back — without losing your spot", + Long: `Float a running agent's live session in a tmux popup over your current +pane: answer the prompt it is blocked on, then close the popup and you are +exactly where you were — unlike ` + "`sloop attach`" + `, which switches your whole +screen to the agent. With no argument, peek jumps straight to the lone waiting +agent, or shows the fleet picker when several (or none) are waiting. Bind it to +a key with ` + "`sloop peek setup`" + `. Needs tmux ≥ 3.2.`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if err := requirePeekEnv(); err != nil { + return err + } + target, err := resolvePeekTarget(args) + if err != nil || target == "" { + return err + } + if peekInPopup { + // Already inside the keybind's popup: attach directly, no second popup. + return tmux.NestedAttach(target) + } + return tmux.Peek(target) + }, +} + +var peekKey string + +var peekSetupCmd = &cobra.Command{ + Use: "setup", + Short: "Bind to peek the waiting agent (and print the .tmux.conf line)", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if !tmux.PopupSupported() { + return fmt.Errorf("`sloop peek setup` needs tmux ≥ 3.2") + } + exe, err := os.Executable() + if err != nil { + exe = "sloop" + } + inner := exe + " peek --in-popup" + bindLine := fmt.Sprintf(`bind-key %s display-popup -w 90%% -h 80%% -E "%s"`, peekKey, inner) + if tmux.Available() { + if err := tmux.BindPeek(peekKey, inner); err == nil { + cmd.Printf("bound for this tmux server: press your prefix then %s to peek the waiting agent\n", peekKey) + } else { + cmd.Printf("warning: failed to bind for this tmux server: %v\n", err) + } + } + cmd.Printf("add this to ~/.tmux.conf to make it permanent:\n %s\n", bindLine) + return nil + }, +} + +func RegisterPeek(cmd *cobra.Command) { + peekCmd.Flags().BoolVar(&peekInPopup, "in-popup", false, "internal: already inside a popup, attach directly") + _ = peekCmd.Flags().MarkHidden("in-popup") + peekSetupCmd.Flags().StringVar(&peekKey, "key", "p", "tmux key to bind (pressed after your prefix)") + peekCmd.AddCommand(peekSetupCmd) + peekCmd.ValidArgsFunction = completeSessionNames + cmd.AddCommand(peekCmd) +} diff --git a/internal/cli/commands/peek_test.go b/internal/cli/commands/peek_test.go new file mode 100644 index 0000000..83f5f7a --- /dev/null +++ b/internal/cli/commands/peek_test.go @@ -0,0 +1,39 @@ +package commands + +import ( + "testing" + + "github.com/stroops/sloop/internal/tmux" +) + +func row(name string, st tmux.AgentStatus) FleetRow { + return FleetRow{Name: name, Status: st} +} + +func TestSoleWaiting(t *testing.T) { + cases := []struct { + name string + rows []FleetRow + wantName string + wantOK bool + }{ + {"none waiting", []FleetRow{row("a__claude", tmux.StatusWorking)}, "", false}, + {"one waiting", []FleetRow{ + row("a__claude", tmux.StatusWorking), + row("b__claude", tmux.StatusWaiting), + }, "b__claude", true}, + {"many waiting", []FleetRow{ + row("a__claude", tmux.StatusWaiting), + row("b__claude", tmux.StatusWaiting), + }, "", false}, + {"empty", nil, "", false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + gotName, gotOK := soleWaiting(c.rows) + if gotName != c.wantName || gotOK != c.wantOK { + t.Fatalf("got (%q,%v) want (%q,%v)", gotName, gotOK, c.wantName, c.wantOK) + } + }) + } +} diff --git a/internal/cli/commands/profile.go b/internal/cli/commands/profile.go new file mode 100644 index 0000000..89112be --- /dev/null +++ b/internal/cli/commands/profile.go @@ -0,0 +1,149 @@ +package commands + +import ( + "fmt" + "sort" + "strings" + + "github.com/spf13/cobra" + + "github.com/stroops/sloop/internal/adapter" + "github.com/stroops/sloop/internal/config" +) + +// profileAdd validates the tool and stores (or overwrites) a profile in g. The +// tool is canonicalized to its manifest key so `sloop run @` always lands +// in the right adapter. +func profileAdd(g *config.Global, name, tool string, envFlags []string, manifests map[string]adapter.Manifest) error { + if name == "" { + return fmt.Errorf("profile name is required") + } + key, ok := toolKeyFor(tool, manifests) + if !ok { + return fmt.Errorf("unknown tool %q (no adapter)", tool) + } + env, err := parseEnvFlags(envFlags) + if err != nil { + return err + } + if g.Profiles == nil { + g.Profiles = map[string]config.Profile{} + } + g.Profiles[name] = config.Profile{Tool: key, Env: env} + return nil +} + +// profileRemove deletes a profile, erroring when it does not exist. +func profileRemove(g *config.Global, name string) error { + if _, ok := g.Profiles[name]; !ok { + return fmt.Errorf("no profile %q to remove", name) + } + delete(g.Profiles, name) + return nil +} + +// renderProfileList formats the configured profiles (sorted), or a hint to add +// one when there are none. +func renderProfileList(profiles map[string]config.Profile) string { + if len(profiles) == 0 { + return "no profiles yet — add one with `sloop profile add --tool --env KEY=VAL`" + } + names := make([]string, 0, len(profiles)) + for n := range profiles { + names = append(names, n) + } + sort.Strings(names) + var b strings.Builder + for _, n := range names { + p := profiles[n] + keys := make([]string, 0, len(p.Env)) + for k := range p.Env { + keys = append(keys, k) + } + sort.Strings(keys) + line := fmt.Sprintf("%-12s %s", n, p.Tool) + if len(keys) > 0 { + line += " env: " + strings.Join(keys, ", ") + } + b.WriteString(line + "\n") + } + return b.String() +} + +var profileTool string +var profileEnv []string + +var profileCmd = &cobra.Command{ + Use: "profile", + Aliases: []string{"prof", "profiles"}, + Short: "Manage reusable run profiles (e.g. a second account) in ~/.sloop/config.yaml", +} + +var profileAddCmd = &cobra.Command{ + Use: "add ", + Short: "Add or overwrite a profile: `sloop run @` launches its tool with its env", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + manifests, err := adapter.Load() + if err != nil { + return err + } + g, err := config.LoadGlobal() + if err != nil { + return err + } + if err := profileAdd(g, args[0], profileTool, profileEnv, manifests); err != nil { + return err + } + if err := config.SaveGlobal(g); err != nil { + return err + } + cmd.Printf("profile %q saved — run it with `sloop run @%s`\n", args[0], args[0]) + return nil + }, +} + +var profileLsCmd = &cobra.Command{ + Use: "ls", + Aliases: []string{"list"}, + Short: "List configured profiles", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + g, err := config.LoadGlobal() + if err != nil { + return err + } + cmd.Print(renderProfileList(g.Profiles)) + return nil + }, +} + +var profileRmCmd = &cobra.Command{ + Use: "rm ", + Aliases: []string{"remove"}, + Short: "Remove a profile", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + g, err := config.LoadGlobal() + if err != nil { + return err + } + if err := profileRemove(g, args[0]); err != nil { + return err + } + if err := config.SaveGlobal(g); err != nil { + return err + } + cmd.Printf("profile %q removed\n", args[0]) + return nil + }, +} + +func RegisterProfile(cmd *cobra.Command) { + profileAddCmd.Flags().StringVar(&profileTool, "tool", "", "the AI tool this profile launches (required)") + profileAddCmd.Flags().StringArrayVar(&profileEnv, "env", nil, "env for the launched tool, KEY=VAL (e.g. CLAUDE_CONFIG_DIR); repeatable") + _ = profileAddCmd.MarkFlagRequired("tool") + _ = profileAddCmd.RegisterFlagCompletionFunc("tool", completeTools) + profileCmd.AddCommand(profileAddCmd, profileLsCmd, profileRmCmd) + cmd.AddCommand(profileCmd) +} diff --git a/internal/cli/commands/profile_test.go b/internal/cli/commands/profile_test.go new file mode 100644 index 0000000..5e5f9a3 --- /dev/null +++ b/internal/cli/commands/profile_test.go @@ -0,0 +1,54 @@ +package commands + +import ( + "strings" + "testing" + + "github.com/stroops/sloop/internal/adapter" + "github.com/stroops/sloop/internal/config" +) + +func TestProfileAdd(t *testing.T) { + m := map[string]adapter.Manifest{"claude": {Name: "Claude"}} + g := &config.Global{} + + if err := profileAdd(g, "sec", "claude", []string{"CLAUDE_CONFIG_DIR=/x"}, m); err != nil { + t.Fatal(err) + } + p, ok := g.Profiles["sec"] + if !ok || p.Tool != "claude" || p.Env["CLAUDE_CONFIG_DIR"] != "/x" { + t.Fatalf("profile not stored: %+v", g.Profiles) + } + + if err := profileAdd(g, "bad", "nope", nil, m); err == nil || !strings.Contains(err.Error(), "unknown tool") { + t.Fatalf("want unknown tool error, got %v", err) + } + if err := profileAdd(g, "", "claude", nil, m); err == nil { + t.Fatal("want error for empty name") + } +} + +func TestProfileRemove(t *testing.T) { + g := &config.Global{Profiles: map[string]config.Profile{"sec": {Tool: "claude"}}} + if err := profileRemove(g, "nope"); err == nil || !strings.Contains(err.Error(), "no profile") { + t.Fatalf("want no-profile error, got %v", err) + } + if err := profileRemove(g, "sec"); err != nil { + t.Fatal(err) + } + if _, ok := g.Profiles["sec"]; ok { + t.Fatal("profile not removed") + } +} + +func TestRenderProfileList(t *testing.T) { + if got := renderProfileList(nil); !strings.Contains(got, "profile add") { + t.Fatalf("empty list should hint, got %q", got) + } + got := renderProfileList(map[string]config.Profile{ + "sec": {Tool: "claude", Env: map[string]string{"CLAUDE_CONFIG_DIR": "/x"}}, + }) + if !strings.Contains(got, "sec") || !strings.Contains(got, "claude") || !strings.Contains(got, "CLAUDE_CONFIG_DIR") { + t.Fatalf("list missing fields: %q", got) + } +} diff --git a/internal/cli/commands/profilerun.go b/internal/cli/commands/profilerun.go new file mode 100644 index 0000000..9e1a2de --- /dev/null +++ b/internal/cli/commands/profilerun.go @@ -0,0 +1,148 @@ +package commands + +import ( + "fmt" + "os" + "strconv" + "strings" + + "github.com/stroops/sloop/internal/adapter" + "github.com/stroops/sloop/internal/config" + "github.com/stroops/sloop/internal/runner" + "github.com/stroops/sloop/internal/tmux" +) + +// instanceResolution is the outcome of interpreting a `sloop run` target for +// profiles / named instances: which tool token to plan, the instance suffix +// (""=default session), and env to inject. +type instanceResolution struct { + target string + instance string + env map[string]string +} + +// resolveInstance interprets the `@profile` / `tool@instance` token grammar and +// folds in --name and --env. `@` always introduces a name: an empty left side +// looks up a profile (supplying tool + env); a tool on the left is an ad-hoc +// instance. --env merges over profile env (call-site wins); --name overrides the +// instance. +func resolveInstance(target, nameFlag string, envFlags []string, profiles map[string]config.Profile, manifests map[string]adapter.Manifest) (instanceResolution, error) { + res := instanceResolution{target: target} + if left, right, ok := strings.Cut(target, "@"); ok { + if right == "" { + return res, fmt.Errorf("missing name after '@' in %q", target) + } + if left == "" { + prof, ok := profiles[right] + if !ok { + return res, fmt.Errorf("unknown profile %q (see: sloop profile ls)", right) + } + if _, ok := toolKeyFor(prof.Tool, manifests); !ok { + return res, fmt.Errorf("profile %q uses unknown tool %q", right, prof.Tool) + } + res.target = prof.Tool + res.instance = right + res.env = expandEnvMap(prof.Env) + } else { + if _, ok := toolKeyFor(left, manifests); !ok { + return res, fmt.Errorf("unknown tool %q in %q (use @ or @)", left, target) + } + res.target = left + res.instance = right + } + } + if len(envFlags) > 0 { + ev, err := parseEnvFlags(envFlags) + if err != nil { + return res, err + } + res.env = mergeEnv(res.env, expandEnvMap(ev)) + } + if nameFlag != "" { + res.instance = nameFlag + } + if strings.Contains(res.instance, "__") { + return res, fmt.Errorf(`instance name %q cannot contain "__"`, res.instance) + } + return res, nil +} + +// parseEnvFlags turns `KEY=VAL` flags into a map; the first `=` splits, so the +// value may itself contain `=`. +func parseEnvFlags(flags []string) (map[string]string, error) { + if len(flags) == 0 { + return nil, nil + } + out := make(map[string]string, len(flags)) + for _, f := range flags { + k, v, ok := strings.Cut(f, "=") + if !ok || k == "" { + return nil, fmt.Errorf("--env must be KEY=VAL, got %q", f) + } + out[k] = v + } + return out, nil +} + +// expandEnvValue expands a leading `~/` to the home dir and any `$VAR`/`${VAR}` +// against the current environment, so profile env values can be written the way +// a user would type them in a shell. +func expandEnvValue(v string) string { + if strings.HasPrefix(v, "~/") { + if home, err := os.UserHomeDir(); err == nil { + v = home + v[1:] + } + } + return os.Expand(v, os.Getenv) +} + +func expandEnvMap(m map[string]string) map[string]string { + if len(m) == 0 { + return nil + } + out := make(map[string]string, len(m)) + for k, v := range m { + out[k] = expandEnvValue(v) + } + return out +} + +// mergeEnv overlays over onto base (over wins on key clash), returning a usable +// map even when either side is nil. +func mergeEnv(base, over map[string]string) map[string]string { + if len(base) == 0 { + return over + } + for k, v := range over { + base[k] = v + } + return base +} + +// nextFreeInstance returns the first instance suffix whose session name is not +// already taken: "" (ws__tool), then "2" (ws__tool__2), "3", … — so `--new` +// spins a fresh agent instead of re-attaching the existing one. +func nextFreeInstance(ws, tool string, sessions []tmux.Session) string { + taken := make(map[string]bool, len(sessions)) + for _, s := range sessions { + taken[s.Name] = true + } + if !taken[tmux.InstanceName(ws, tool, "")] { + return "" + } + for n := 2; ; n++ { + inst := strconv.Itoa(n) + if !taken[tmux.InstanceName(ws, tool, inst)] { + return inst + } + } +} + +// selectRunnerInstance is selectRunner with an instance suffix on the session +// name (tmux when available, else exec). +func selectRunnerInstance(ws, tool, instance string) runner.Runner { + if tmux.Available() { + return tmux.Runner{Session: tmux.InstanceName(ws, tool, instance)} + } + return runner.ExecRunner{} +} diff --git a/internal/cli/commands/profilerun_test.go b/internal/cli/commands/profilerun_test.go new file mode 100644 index 0000000..406ea27 --- /dev/null +++ b/internal/cli/commands/profilerun_test.go @@ -0,0 +1,133 @@ +package commands + +import ( + "strings" + "testing" + + "github.com/stroops/sloop/internal/config" + "github.com/stroops/sloop/internal/tmux" +) + +func TestParseEnvFlags(t *testing.T) { + got, err := parseEnvFlags([]string{"K=V", "X=a=b"}) + if err != nil { + t.Fatal(err) + } + if got["K"] != "V" || got["X"] != "a=b" { + t.Fatalf("got %v", got) + } + if _, err := parseEnvFlags([]string{"noeq"}); err == nil { + t.Fatal("want error for missing =") + } +} + +func TestExpandEnvValue(t *testing.T) { + t.Setenv("HOME", "/home/me") + cases := map[string]string{ + "~/.claude-sec": "/home/me/.claude-sec", + "$HOME/x": "/home/me/x", + "${HOME}/y": "/home/me/y", + "/abs/path": "/abs/path", + "~notme/x": "~notme/x", // only a leading ~/ expands + } + for in, want := range cases { + if got := expandEnvValue(in); got != want { + t.Fatalf("expandEnvValue(%q) = %q, want %q", in, got, want) + } + } +} + +func TestResolveInstance(t *testing.T) { + t.Setenv("HOME", "/home/me") + m := loadManifestsForTest(t) // resets HOME to a temp dir + t.Setenv("HOME", "/home/me") // restore for expansion assertions + profiles := map[string]config.Profile{ + "sec": {Tool: "claude", Env: map[string]string{"CLAUDE_CONFIG_DIR": "~/.claude-sec"}}, + "bogus": {Tool: "nope", Env: nil}, + } + + t.Run("profile", func(t *testing.T) { + r, err := resolveInstance("@sec", "", nil, profiles, m) + if err != nil { + t.Fatal(err) + } + if r.target != "claude" || r.instance != "sec" { + t.Fatalf("got target=%q instance=%q", r.target, r.instance) + } + if r.env["CLAUDE_CONFIG_DIR"] != "/home/me/.claude-sec" { + t.Fatalf("env not expanded: %v", r.env) + } + }) + t.Run("tool@instance", func(t *testing.T) { + r, err := resolveInstance("claude@b", "", nil, profiles, m) + if err != nil || r.target != "claude" || r.instance != "b" || r.env != nil { + t.Fatalf("got %+v err %v", r, err) + } + }) + t.Run("plain tool", func(t *testing.T) { + r, err := resolveInstance("claude", "", nil, profiles, m) + if err != nil || r.target != "claude" || r.instance != "" { + t.Fatalf("got %+v err %v", r, err) + } + }) + t.Run("unknown profile", func(t *testing.T) { + if _, err := resolveInstance("@nope", "", nil, profiles, m); err == nil || !strings.Contains(err.Error(), "unknown profile") { + t.Fatalf("want unknown profile, got %v", err) + } + }) + t.Run("profile bad tool", func(t *testing.T) { + if _, err := resolveInstance("@bogus", "", nil, profiles, m); err == nil || !strings.Contains(err.Error(), "unknown tool") { + t.Fatalf("want unknown tool, got %v", err) + } + }) + t.Run("bad left of @", func(t *testing.T) { + if _, err := resolveInstance("zzz@x", "", nil, profiles, m); err == nil || !strings.Contains(err.Error(), "unknown tool") { + t.Fatalf("want unknown tool, got %v", err) + } + }) + t.Run("name flag overrides instance", func(t *testing.T) { + r, err := resolveInstance("@sec", "override", nil, profiles, m) + if err != nil || r.instance != "override" { + t.Fatalf("got %+v err %v", r, err) + } + }) + t.Run("name flag on plain tool", func(t *testing.T) { + r, err := resolveInstance("claude", "x", nil, profiles, m) + if err != nil || r.instance != "x" { + t.Fatalf("got %+v err %v", r, err) + } + }) + t.Run("env flag merges and overrides", func(t *testing.T) { + r, err := resolveInstance("@sec", "", []string{"CLAUDE_CONFIG_DIR=/flag", "EXTRA=1"}, profiles, m) + if err != nil { + t.Fatal(err) + } + if r.env["CLAUDE_CONFIG_DIR"] != "/flag" || r.env["EXTRA"] != "1" { + t.Fatalf("env flag did not override/merge: %v", r.env) + } + }) + t.Run("instance cannot contain __", func(t *testing.T) { + if _, err := resolveInstance("claude", "a__b", nil, profiles, m); err == nil || !strings.Contains(err.Error(), "__") { + t.Fatalf("want __ error, got %v", err) + } + }) +} + +func TestNextFreeInstance(t *testing.T) { + sess := func(names ...string) []tmux.Session { + out := make([]tmux.Session, len(names)) + for i, n := range names { + out[i] = tmux.Session{Name: n} + } + return out + } + if got := nextFreeInstance("repo", "claude", nil); got != "" { + t.Fatalf("no sessions → empty, got %q", got) + } + if got := nextFreeInstance("repo", "claude", sess("repo__claude")); got != "2" { + t.Fatalf("base taken → 2, got %q", got) + } + if got := nextFreeInstance("repo", "claude", sess("repo__claude", "repo__claude__2")); got != "3" { + t.Fatalf("base+2 taken → 3, got %q", got) + } +} diff --git a/internal/cli/commands/ps.go b/internal/cli/commands/ps.go index af0d3f4..62003b2 100644 --- a/internal/cli/commands/ps.go +++ b/internal/cli/commands/ps.go @@ -38,15 +38,22 @@ type FleetRow struct { Prompt string // the question the agent is blocked on (when waiting) Answers []tmux.Answer // parsed choices the agent offers (answer in one key) Display string // provider display name (e.g. "Google Antigravity" for "agy") + Instance string // named instance suffix (e.g. "sec" / "2"); "" = the default session } // toolName is the row's provider display name, falling back to the tool key when // the row hasn't been enriched (e.g. plain listings built straight from tmux). +// A named instance is appended (`claude·sec`) so two agents of the same provider +// in one workspace are distinguishable. func (r FleetRow) toolName() string { + name := r.Tool if r.Display != "" { - return r.Display + name = r.Display } - return r.Tool + if r.Instance != "" { + return name + "·" + r.Instance + } + return name } // registryPaths maps each registered workspace name to its repo path, so the @@ -79,18 +86,51 @@ func annotatePaths(rows []FleetRow, paths map[string]string) { } } -// fleetRows keeps only sloop-named sessions (`__`), splitting -// on the last `__`, and sorts them by workspace then tool. -func fleetRows(sessions []tmux.Session) []FleetRow { +// splitSession parses a sloop session name into workspace, tool, and instance. +// It is manifest-aware so the tool is found by key rather than by position, +// which keeps `ws__tool__instance` unambiguous: the tool is the last `__` +// segment that is a known tool (legacy `ws__tool` → instance ""), else the +// second-to-last when that is a known tool (→ instance = the last segment). +// With nil/empty manifests it falls back to the legacy last-`__` split, so +// callers that only need the name behave exactly as before. +func splitSession(name string, manifests map[string]adapter.Manifest) (ws, tool, instance string) { + parts := strings.Split(name, "__") + if len(parts) >= 2 { + last := parts[len(parts)-1] + if _, ok := manifests[last]; ok { + return strings.Join(parts[:len(parts)-1], "__"), last, "" + } + if len(parts) >= 3 { + if mid := parts[len(parts)-2]; hasKey(manifests, mid) { + return strings.Join(parts[:len(parts)-2], "__"), mid, last + } + } + } + i := strings.LastIndex(name, "__") + if i < 0 { + return name, "", "" // not a sloop session name + } + return name[:i], name[i+2:], "" +} + +func hasKey(m map[string]adapter.Manifest, k string) bool { + _, ok := m[k] + return ok +} + +// fleetRows keeps only sloop-named sessions (containing `__`), splitting each +// into workspace/tool/instance, and sorts them by workspace, tool, instance. +func fleetRows(sessions []tmux.Session, manifests map[string]adapter.Manifest) []FleetRow { var rows []FleetRow for _, s := range sessions { - i := strings.LastIndex(s.Name, "__") - if i < 0 { + if !strings.Contains(s.Name, "__") { continue // not a sloop session } + ws, tool, instance := splitSession(s.Name, manifests) rows = append(rows, FleetRow{ - Workspace: s.Name[:i], - Tool: s.Name[i+2:], + Workspace: ws, + Tool: tool, + Instance: instance, Name: s.Name, Attached: s.Attached, Windows: s.Windows, @@ -101,7 +141,10 @@ func fleetRows(sessions []tmux.Session) []FleetRow { if rows[i].Workspace != rows[j].Workspace { return rows[i].Workspace < rows[j].Workspace } - return rows[i].Tool < rows[j].Tool + if rows[i].Tool != rows[j].Tool { + return rows[i].Tool < rows[j].Tool + } + return rows[i].Instance < rows[j].Instance }) return rows } @@ -285,7 +328,7 @@ func jumpToFleet(rows []FleetRow, n int) error { // waiting-first order as `ps`, so the picker reads identically to the fleet view. func pickFleetSession(title string) (string, error) { manifests, _ := adapter.Load() - rows := enrichGlances(fleetRows(tmux.ParseSessions(tmuxList())), manifests) + rows := enrichGlances(fleetRows(tmux.ParseSessions(tmuxList()), manifests), manifests) if len(rows) == 0 { return "", fmt.Errorf("no running AI sessions to attach to (start one with `sloop run `)") } @@ -410,8 +453,9 @@ var psCmd = &cobra.Command{ Short: "List running AI sessions (the fleet); `sloop ps <#>` jumps to one", Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + manifests, _ := adapter.Load() parsed := tmux.ParseSessions(tmuxList()) - rows := fleetRows(parsed) + rows := fleetRows(parsed, manifests) if len(args) == 1 { n, err := strconv.Atoi(args[0]) if err != nil { @@ -424,7 +468,6 @@ var psCmd = &cobra.Command{ return runWatch(cmd.OutOrStdout(), psInterval, psWaiting, psNotify) } - manifests, _ := adapter.Load() rows = enrichGlances(rows, manifests) sortNeedsAttention(rows) paths := registryPaths() @@ -464,7 +507,7 @@ var psCmd = &cobra.Command{ detach := tui.Grey(" ⏎ enters an agent — to come back, detach (keeps it running): " + tmux.PrefixRaw() + " d") for { tui.Clear() - rows = enrichGlances(fleetRows(tmux.ParseSessions(tmuxList())), manifests) + rows = enrichGlances(fleetRows(tmux.ParseSessions(tmuxList()), manifests), manifests) sortNeedsAttention(rows) annotatePaths(rows, registryPaths()) if psWaiting { @@ -724,7 +767,7 @@ func runWatch(w io.Writer, interval time.Duration, waitingOnly, notify bool) err var prev []FleetRow for { manifests, _ := adapter.Load() - rows := enrichGlances(fleetRows(tmux.ParseSessions(tmuxList())), manifests) + rows := enrichGlances(fleetRows(tmux.ParseSessions(tmuxList()), manifests), manifests) sortNeedsAttention(rows) shown := rows diff --git a/internal/cli/commands/ps_test.go b/internal/cli/commands/ps_test.go index bd44d94..8add6fc 100644 --- a/internal/cli/commands/ps_test.go +++ b/internal/cli/commands/ps_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + "github.com/stroops/sloop/internal/adapter" "github.com/stroops/sloop/internal/tmux" ) @@ -15,7 +16,7 @@ func TestFleetRowsFiltersAndSplits(t *testing.T) { {Name: "backend__cursor", Windows: 1}, {Name: "random", Windows: 1}, // not a sloop session } - rows := fleetRows(in) + rows := fleetRows(in, nil) if len(rows) != 2 { t.Fatalf("want 2 sloop rows, got %d (%+v)", len(rows), rows) } @@ -28,6 +29,39 @@ func TestFleetRowsFiltersAndSplits(t *testing.T) { } } +func TestSplitSession(t *testing.T) { + m := map[string]adapter.Manifest{"claude": {}, "cursor": {}} + cases := []struct { + name string + in string + manifests map[string]adapter.Manifest + wantWs, wantTool, wInst string + }{ + {"legacy two-segment", "repo__claude", m, "repo", "claude", ""}, + {"named instance", "repo__claude__sec", m, "repo", "claude", "sec"}, + {"numeric instance", "repo__claude__2", m, "repo", "claude", "2"}, + {"workspace with __", "a__b__claude", m, "a__b", "claude", ""}, + {"unknown tool falls back", "repo__weird", nil, "repo", "weird", ""}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + ws, tool, inst := splitSession(c.in, c.manifests) + if ws != c.wantWs || tool != c.wantTool || inst != c.wInst { + t.Fatalf("got (%q,%q,%q) want (%q,%q,%q)", ws, tool, inst, c.wantWs, c.wantTool, c.wInst) + } + }) + } +} + +func TestToolNameWithInstance(t *testing.T) { + if got := (FleetRow{Tool: "claude", Instance: "sec"}).toolName(); got != "claude·sec" { + t.Fatalf("got %q", got) + } + if got := (FleetRow{Tool: "claude"}).toolName(); got != "claude" { + t.Fatalf("got %q", got) + } +} + func TestRunPsEmptyAndPopulated(t *testing.T) { var b bytes.Buffer _ = RunPs(&b, nil) diff --git a/internal/cli/commands/registry.go b/internal/cli/commands/registry.go index 0090741..7144b2b 100644 --- a/internal/cli/commands/registry.go +++ b/internal/cli/commands/registry.go @@ -32,6 +32,8 @@ func init() { add(RegisterAdopt) add(RegisterRestore) add(RegisterPopup) + add(RegisterPeek) + add(RegisterProfile) add(RegisterStatusline) add(RegisterSkill) add(RegisterHooks) diff --git a/internal/cli/commands/run.go b/internal/cli/commands/run.go index ccd79e0..f84303b 100644 --- a/internal/cli/commands/run.go +++ b/internal/cli/commands/run.go @@ -157,8 +157,10 @@ func buildRunArgs(m adapter.Manifest, model, effort, task string, passthrough [] } // RunRun resolves the target + model/effort/task flags, syncs context, then -// launches the CLI with the right flags in a managed session. -func RunRun(startDir, target, provider, model, effort, task string, passthrough []string, r runner.Runner) error { +// launches the CLI with the right flags in a managed session. env is injected +// into the launched process (e.g. a second account's CLAUDE_CONFIG_DIR), and +// instance records which named instance this session is. +func RunRun(startDir, target, provider, model, effort, task string, passthrough []string, env map[string]string, instance string, r runner.Runner) error { ws, err := workspace.Resolve(startDir) if err != nil { return err @@ -187,12 +189,12 @@ func RunRun(startDir, target, provider, model, effort, task string, passthrough } // Record session (best-effort: never block the launch). - sessID, store := recordSessionBestEffort(ws, plan.toolKey, target) + sessID, store := recordSessionBestEffort(ws, plan.toolKey, instance) if store != nil { defer func() { _ = store.Close() }() } - launchErr := r.Launch(runner.Spec{Dir: ws.Root, Command: m.Launch, Args: args}) + launchErr := r.Launch(runner.Spec{Dir: ws.Root, Command: m.Launch, Args: args, Env: env}) if store != nil && sessID > 0 { _ = store.EndSession(sessID, time.Now()) @@ -248,10 +250,7 @@ func resolveStartDir(cwd, workspaceFlag string) (string, error) { // selectRunner returns a tmux-backed runner when tmux is available, else exec. func selectRunner(workspace, tool string) runner.Runner { - if tmux.Available() { - return tmux.Runner{Session: tmux.SessionName(workspace, tool)} - } - return runner.ExecRunner{} + return selectRunnerInstance(workspace, tool, "") } // RunSplit syncs each tool then launches them side-by-side as tmux panes in one @@ -290,12 +289,27 @@ var ( runModel string runEffort string runTask string + runName string + runEnv []string + runNew bool ) var runCmd = &cobra.Command{ - Use: "run [tool|profile ...] [-- ]", + Use: "run [tool | @profile | tool@instance] [-- ]", Aliases: []string{"r"}, Short: "Sync context and launch an AI tool in the workspace (--split for side-by-side panes)", + Long: `Sync context and launch an AI tool in the workspace. + +Run a second agent of the same provider with a named instance, or a different +account with a saved profile: + + sloop run claude # the workspace's claude (re-attaches if running) + sloop run claude --new # a fresh claude instance (claude·2, claude·3, …) + sloop run claude@review # an ad-hoc instance named "review" + sloop run @sec # the "sec" profile (e.g. a second account) + +Profiles live in ~/.sloop/config.yaml; manage them with ` + "`sloop profile`" + `. +Use --env KEY=VAL for a one-off account/env without saving a profile.`, Args: func(cmd *cobra.Command, args []string) error { n := len(args) if d := cmd.ArgsLenAtDash(); d >= 0 { @@ -350,11 +364,27 @@ var runCmd = &cobra.Command{ if err != nil { return err } - plan, err := planLaunch(target, runProvider, runModel, runEffort, proj.DefaultTool, manifests) + // Interpret @profile / tool@instance and fold in --name/--env, then plan + // against the resolved tool token. + glob, err := config.LoadGlobal() if err != nil { return err } - if err := RunRun(startDir, target, runProvider, runModel, runEffort, runTask, passthrough, selectRunner(ws.Name, plan.toolKey)); err != nil { + res, err := resolveInstance(target, runName, runEnv, glob.Profiles, manifests) + if err != nil { + return err + } + plan, err := planLaunch(res.target, runProvider, runModel, runEffort, proj.DefaultTool, manifests) + if err != nil { + return err + } + // --new spins a fresh instance (next free slot) instead of re-attaching; + // it's a no-op once a name/instance already makes the session distinct. + if runNew && res.instance == "" { + res.instance = nextFreeInstance(ws.Name, plan.toolKey, tmux.ParseSessions(tmuxList())) + } + r := selectRunnerInstance(ws.Name, plan.toolKey, res.instance) + if err := RunRun(startDir, res.target, runProvider, runModel, runEffort, runTask, passthrough, res.env, res.instance, r); err != nil { return err } hints.Show(cmd.OutOrStdout(), "run") @@ -369,6 +399,9 @@ func RegisterRun(cmd *cobra.Command) { runCmd.Flags().StringVarP(&runModel, "model", "m", "", "model to pass to the CLI (forwarded as-is, not validated)") runCmd.Flags().StringVarP(&runEffort, "effort", "e", "", "reasoning effort: low|medium|high (if the CLI supports it)") runCmd.Flags().StringVarP(&runTask, "task", "t", "", "hand the agent an initial task — launches an interactive session already working on it") + runCmd.Flags().StringVarP(&runName, "name", "n", "", "name this instance so a second agent of the same tool gets its own session") + runCmd.Flags().StringArrayVar(&runEnv, "env", nil, "extra env for the launched tool, KEY=VAL (e.g. a second account's CLAUDE_CONFIG_DIR); repeatable") + runCmd.Flags().BoolVarP(&runNew, "new", "N", false, "start a fresh instance (next free slot) instead of re-attaching the existing session") runCmd.ValidArgsFunction = completeTools _ = runCmd.RegisterFlagCompletionFunc("workspace", completeWorkspaces) _ = runCmd.RegisterFlagCompletionFunc("provider", completeTools) diff --git a/internal/cli/commands/runcmd_test.go b/internal/cli/commands/runcmd_test.go index c8cbd72..d7e4288 100644 --- a/internal/cli/commands/runcmd_test.go +++ b/internal/cli/commands/runcmd_test.go @@ -15,12 +15,13 @@ func (f *fakeRunner) Launch(s runner.Spec) error { f.got = s; return nil } func TestRunRunSyncsAndLaunches(t *testing.T) { dir := t.TempDir() t.Setenv("HOME", t.TempDir()) - if _, err := RunInit(dir, false); err != nil { + // Two tools → canonical workspace, so the CLAUDE.md pointer is delivered. + if _, err := RunInit(dir, []string{"claude", "cursor"}, false); err != nil { t.Fatalf("RunInit: %v", err) } fr := &fakeRunner{} - if err := RunRun(dir, "claude", "", "", "", "", nil, fr); err != nil { + if err := RunRun(dir, "claude", "", "", "", "", nil, nil, "", fr); err != nil { t.Fatalf("RunRun: %v", err) } @@ -42,15 +43,31 @@ func TestRunRunSyncsAndLaunches(t *testing.T) { func TestRunRunPassesThroughArgs(t *testing.T) { dir := t.TempDir() t.Setenv("HOME", t.TempDir()) - if _, err := RunInit(dir, false); err != nil { + if _, err := RunInit(dir, nil, false); err != nil { t.Fatalf("RunInit: %v", err) } fr := &fakeRunner{} - if err := RunRun(dir, "claude", "", "", "", "", []string{"--foo", "bar"}, fr); err != nil { + if err := RunRun(dir, "claude", "", "", "", "", []string{"--foo", "bar"}, nil, "", fr); err != nil { t.Fatalf("RunRun: %v", err) } if len(fr.got.Args) != 2 || fr.got.Args[0] != "--foo" || fr.got.Args[1] != "bar" { t.Fatalf("want passthrough args [--foo bar], got %v", fr.got.Args) } } + +func TestRunRunInjectsEnv(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", t.TempDir()) + if _, err := RunInit(dir, nil, false); err != nil { + t.Fatalf("RunInit: %v", err) + } + fr := &fakeRunner{} + env := map[string]string{"CLAUDE_CONFIG_DIR": "/x"} + if err := RunRun(dir, "claude", "", "", "", "", nil, env, "sec", fr); err != nil { + t.Fatalf("RunRun: %v", err) + } + if fr.got.Env["CLAUDE_CONFIG_DIR"] != "/x" { + t.Fatalf("env not passed to Spec: %v", fr.got.Env) + } +} diff --git a/internal/cli/commands/runwflag_test.go b/internal/cli/commands/runwflag_test.go index 231f00f..0df5934 100644 --- a/internal/cli/commands/runwflag_test.go +++ b/internal/cli/commands/runwflag_test.go @@ -11,7 +11,7 @@ import ( func TestResolveStartDirFromRegistry(t *testing.T) { dir := t.TempDir() t.Setenv("HOME", t.TempDir()) - if _, err := RunInit(dir, false); err != nil { + if _, err := RunInit(dir, nil, false); err != nil { t.Fatalf("RunInit: %v", err) } // init registered the workspace under base(dir). diff --git a/internal/cli/commands/send.go b/internal/cli/commands/send.go index e01c073..58ed0ff 100644 --- a/internal/cli/commands/send.go +++ b/internal/cli/commands/send.go @@ -59,7 +59,8 @@ func RunSend(target, msg string) error { if !tmux.Available() { return fmt.Errorf("tmux is not installed; `sloop send` needs tmux") } - rows := fleetRows(tmux.ParseSessions(tmuxList())) + manifests, _ := adapter.Load() + rows := fleetRows(tmux.ParseSessions(tmuxList()), manifests) row, err := resolveTarget(rows, target) if err != nil { return err @@ -77,7 +78,7 @@ func RunSendBroadcast(msg string, all, waiting bool) (int, error) { return 0, fmt.Errorf("tmux is not installed; `sloop send` needs tmux") } manifests, _ := adapter.Load() - rows := enrichGlances(fleetRows(tmux.ParseSessions(tmuxList())), manifests) + rows := enrichGlances(fleetRows(tmux.ParseSessions(tmuxList()), manifests), manifests) if waiting { rows = filterWaiting(rows) } diff --git a/internal/cli/commands/skilladd_test.go b/internal/cli/commands/skilladd_test.go index 9503403..f87246a 100644 --- a/internal/cli/commands/skilladd_test.go +++ b/internal/cli/commands/skilladd_test.go @@ -27,7 +27,7 @@ func TestRunSkillAddFetchesAndLinks(t *testing.T) { dir := t.TempDir() t.Setenv("HOME", t.TempDir()) t.Setenv("PATH", t.TempDir()) // claude fallback (has .claude/skills target) - if _, err := RunInit(dir, false); err != nil { + if _, err := RunInit(dir, nil, false); err != nil { t.Fatalf("RunInit: %v", err) } diff --git a/internal/cli/commands/skillcmd_test.go b/internal/cli/commands/skillcmd_test.go index 4a57b4b..10730ce 100644 --- a/internal/cli/commands/skillcmd_test.go +++ b/internal/cli/commands/skillcmd_test.go @@ -10,7 +10,7 @@ func TestRunSkillNewCreatesAndLinks(t *testing.T) { dir := t.TempDir() t.Setenv("HOME", t.TempDir()) t.Setenv("PATH", t.TempDir()) // empty PATH → claude fallback (has .claude/skills target) - if _, err := RunInit(dir, false); err != nil { + if _, err := RunInit(dir, nil, false); err != nil { t.Fatalf("RunInit: %v", err) } path, linked, err := RunSkillNew(dir, "review") diff --git a/internal/cli/commands/skillupdate_test.go b/internal/cli/commands/skillupdate_test.go index e6661fc..3f348bd 100644 --- a/internal/cli/commands/skillupdate_test.go +++ b/internal/cli/commands/skillupdate_test.go @@ -23,7 +23,7 @@ func TestSkillAddRecordsLock(t *testing.T) { dir := t.TempDir() t.Setenv("HOME", t.TempDir()) t.Setenv("PATH", t.TempDir()) - if _, err := RunInit(dir, false); err != nil { + if _, err := RunInit(dir, nil, false); err != nil { t.Fatalf("RunInit: %v", err) } addLockedSkill(t, dir, "# Review\n\nv1\n") @@ -45,7 +45,7 @@ func TestRunSkillUpdateRefetches(t *testing.T) { dir := t.TempDir() t.Setenv("HOME", t.TempDir()) t.Setenv("PATH", t.TempDir()) - if _, err := RunInit(dir, false); err != nil { + if _, err := RunInit(dir, nil, false); err != nil { t.Fatalf("RunInit: %v", err) } addLockedSkill(t, dir, "# Review\n\nv1\n") @@ -81,7 +81,7 @@ func TestRunSkillUpdateUnknownName(t *testing.T) { dir := t.TempDir() t.Setenv("HOME", t.TempDir()) t.Setenv("PATH", t.TempDir()) - if _, err := RunInit(dir, false); err != nil { + if _, err := RunInit(dir, nil, false); err != nil { t.Fatalf("RunInit: %v", err) } if _, err := RunSkillUpdate(dir, []string{"nope"}); err == nil { @@ -93,7 +93,7 @@ func TestRunSkillUpdateEmptyLock(t *testing.T) { dir := t.TempDir() t.Setenv("HOME", t.TempDir()) t.Setenv("PATH", t.TempDir()) - if _, err := RunInit(dir, false); err != nil { + if _, err := RunInit(dir, nil, false); err != nil { t.Fatalf("RunInit: %v", err) } res, err := RunSkillUpdate(dir, nil) diff --git a/internal/cli/commands/status.go b/internal/cli/commands/status.go index ce316bf..187d941 100644 --- a/internal/cli/commands/status.go +++ b/internal/cli/commands/status.go @@ -74,8 +74,9 @@ func RunStatus(startDir string, w io.Writer) error { // running sessions (best-effort; needs tmux). if tmux.Available() { + manifests, _ := adapter.Load() n := 0 - for _, r := range fleetRows(tmux.ParseSessions(tmuxList())) { + for _, r := range fleetRows(tmux.ParseSessions(tmuxList()), manifests) { if r.Workspace == ws.Name { n++ } diff --git a/internal/cli/commands/statusline.go b/internal/cli/commands/statusline.go index 51fde73..2dbfaa5 100644 --- a/internal/cli/commands/statusline.go +++ b/internal/cli/commands/statusline.go @@ -32,18 +32,47 @@ func renderStatusline(session string) string { if session == "" { return "" } - ws, tool := session, "" - if i := strings.LastIndex(session, "__"); i >= 0 { - ws, tool = session[:i], session[i+2:] + manifests, _ := adapter.Load() + ws, tool, instance := splitSession(session, manifests) + label := tool + if instance != "" { + label = tool + "·" + instance // distinguish a second agent of the same tool } st := tmux.StatusUnknown if m, ok := fleetstate.Read(session); ok { st = stateToStatus(m.Status) } else if out, err := tmux.Output(tmux.BuildCaptureArgs(session)...); err == nil { - manifests, _ := adapter.Load() st = tmux.ClassifyStatus(string(out), manifests[tool]) } - return fmt.Sprintf("⚓ %s %s %s", ws, tool, tmuxStatusLabel(st)) + return fmt.Sprintf("⚓ %s %s %s%s", ws, label, tmuxStatusLabel(st), renderFleetBadge(session)) +} + +// waitingBadge formats the fleet-wide waiting count for the status bar, empty +// when nothing is waiting so the bar stays clean. +func waitingBadge(n int) string { + if n <= 0 { + return "" + } + return fmt.Sprintf(" #[fg=yellow]⏳ %d waiting#[default]", n) +} + +// renderFleetBadge counts sloop sessions whose fresh marker is `waiting`, +// excluding the current session (so it reads "others need you"). Markers only — +// no capture-pane — because this re-renders every status-interval. +func renderFleetBadge(exclude string) string { + n := 0 + for _, s := range tmux.ParseSessions(tmuxList()) { + if s.Name == exclude { + continue + } + if strings.LastIndex(s.Name, "__") < 0 { + continue + } + if m, ok := fleetstate.Read(s.Name); ok && m.Status == "waiting" { + n++ + } + } + return waitingBadge(n) } var statuslineCmd = &cobra.Command{ diff --git a/internal/cli/commands/statusline_test.go b/internal/cli/commands/statusline_test.go index b1015e4..9ae5113 100644 --- a/internal/cli/commands/statusline_test.go +++ b/internal/cli/commands/statusline_test.go @@ -38,6 +38,19 @@ func TestRenderStatusline(t *testing.T) { } } +func TestWaitingBadge(t *testing.T) { + if got := waitingBadge(0); got != "" { + t.Fatalf("zero should be empty, got %q", got) + } + if got := waitingBadge(-1); got != "" { + t.Fatalf("negative should be empty, got %q", got) + } + got := waitingBadge(2) + if !strings.Contains(got, "2 waiting") || !strings.Contains(got, "fg=yellow") { + t.Fatalf("got %q", got) + } +} + func TestTmuxStatusLabel(t *testing.T) { w := tmuxStatusLabel(tmux.StatusWaiting) if !strings.Contains(w, "waiting") || !strings.Contains(w, "fg=yellow") { diff --git a/internal/cli/commands/statusls_test.go b/internal/cli/commands/statusls_test.go index 5b8c85b..f6c3dc6 100644 --- a/internal/cli/commands/statusls_test.go +++ b/internal/cli/commands/statusls_test.go @@ -8,7 +8,7 @@ import ( func TestRunStatusShowsWorkspaceAndStale(t *testing.T) { dir := t.TempDir() t.Setenv("HOME", t.TempDir()) - if _, err := RunInit(dir, false); err != nil { + if _, err := RunInit(dir, nil, false); err != nil { t.Fatalf("RunInit: %v", err) } var b strings.Builder @@ -24,7 +24,7 @@ func TestRunStatusShowsWorkspaceAndStale(t *testing.T) { func TestRunLsListsWorkspace(t *testing.T) { dir := t.TempDir() t.Setenv("HOME", t.TempDir()) - if _, err := RunInit(dir, false); err != nil { + if _, err := RunInit(dir, nil, false); err != nil { t.Fatalf("RunInit: %v", err) } var b strings.Builder diff --git a/internal/cli/commands/sync.go b/internal/cli/commands/sync.go index 45c8fd1..9984759 100644 --- a/internal/cli/commands/sync.go +++ b/internal/cli/commands/sync.go @@ -13,6 +13,23 @@ import ( "github.com/stroops/sloop/internal/workspace" ) +// needsCanonical reports whether a workspace needs sloop's portable-context layer +// (AGENTS.md + pointer files). It's only worth it when there's something to be +// portable across: two or more tools, or a native-mode tool that reads AGENTS.md +// directly. A lone pointer-mode tool (e.g. just Claude) keeps its own context +// file — sloop stays out of the way and just manages the fleet/skills. +func needsCanonical(tools []string, manifests map[string]adapter.Manifest) bool { + if len(tools) >= 2 { + return true + } + for _, t := range tools { + if m, ok := manifests[t]; ok && m.Context.Mode != "pointer" { + return true + } + } + return false +} + // resolveTool picks the target tool: the given target, or the project default. // (Enabled tools live in .sloop/config.yaml; there is no per-tool profile file.) func resolveTool(target, defaultTool string) (string, error) { @@ -48,15 +65,20 @@ func RunSync(startDir, target string, repair bool) ([]string, error) { return nil, fmt.Errorf("unknown tool %q (no adapter)", tool) } - return syncOne(ws.Root, ws.SloopDir(), m, repair) + return syncOne(ws.Root, ws.SloopDir(), m, repair, needsCanonical(proj.Tools, manifests)) } -func syncOne(root, sloopDir string, m adapter.Manifest, repair bool) ([]string, error) { +// syncOne delivers context + skills for one tool. canonical gates the portable- +// context layer (AGENTS.md + pointer file): when false (a lone pointer-mode +// tool) sloop leaves context to the tool's own file and only manages skills. +func syncOne(root, sloopDir string, m adapter.Manifest, repair, canonical bool) ([]string, error) { var log []string - if a, err := syncpkg.EnsureAgents(root); err != nil { - return nil, err - } else if a == syncpkg.ActionCreated { - log = append(log, "created AGENTS.md") + if canonical { + if a, err := syncpkg.EnsureAgents(root); err != nil { + return nil, err + } else if a == syncpkg.ActionCreated { + log = append(log, "created AGENTS.md") + } } ctx := syncpkg.SyncContext skl := syncpkg.SyncSkills @@ -65,15 +87,17 @@ func syncOne(root, sloopDir string, m adapter.Manifest, repair bool) ([]string, skl = func(r, s string, mm adapter.Manifest) (syncpkg.Action, error) { return syncpkg.RepairSkills(r, s, mm) } } - switch a, err := ctx(root, m); { - case err != nil: - return nil, err - case a == syncpkg.ActionCreated: - log = append(log, "created "+m.Context.File) - case a == syncpkg.ActionForeign: - log = append(log, m.Context.File+" exists, left as-is") - case a == syncpkg.ActionRepaired: - log = append(log, "repaired "+m.Context.File) + if canonical { + switch a, err := ctx(root, m); { + case err != nil: + return nil, err + case a == syncpkg.ActionCreated: + log = append(log, "created "+m.Context.File) + case a == syncpkg.ActionForeign: + log = append(log, m.Context.File+" exists, left as-is") + case a == syncpkg.ActionRepaired: + log = append(log, "repaired "+m.Context.File) + } } switch a, err := skl(root, sloopDir, m); { case err != nil: @@ -107,6 +131,7 @@ func RunSyncAll(startDir string, repair bool) ([]string, error) { if err != nil { return nil, err } + canonical := needsCanonical(proj.Tools, manifests) var log []string for _, tool := range proj.Tools { m, ok := manifests[tool] @@ -114,7 +139,7 @@ func RunSyncAll(startDir string, repair bool) ([]string, error) { log = append(log, tool+": unknown tool (no adapter), skipped") continue } - lines, err := syncOne(ws.Root, ws.SloopDir(), m, repair) + lines, err := syncOne(ws.Root, ws.SloopDir(), m, repair, canonical) if err != nil { return nil, fmt.Errorf("%s: %w", tool, err) } diff --git a/internal/cli/commands/synccmd_test.go b/internal/cli/commands/synccmd_test.go index 2e18c2b..e7cf192 100644 --- a/internal/cli/commands/synccmd_test.go +++ b/internal/cli/commands/synccmd_test.go @@ -12,7 +12,8 @@ import ( func TestRunSyncWritesClaudeMd(t *testing.T) { dir := t.TempDir() t.Setenv("HOME", t.TempDir()) - if _, err := RunInit(dir, false); err != nil { + // Two tools → canonical, so claude gets a CLAUDE.md pointer to AGENTS.md. + if _, err := RunInit(dir, []string{"claude", "cursor"}, false); err != nil { t.Fatalf("RunInit: %v", err) } written, err := RunSync(dir, "claude", false) @@ -32,7 +33,7 @@ func TestRunSyncWritesClaudeMd(t *testing.T) { func TestRunSyncAllDeliversEnabledTools(t *testing.T) { dir := t.TempDir() t.Setenv("HOME", t.TempDir()) - if _, err := RunInit(dir, false); err != nil { + if _, err := RunInit(dir, nil, false); err != nil { t.Fatalf("RunInit: %v", err) } // Enable claude + cursor. diff --git a/internal/config/config.go b/internal/config/config.go index 9ada38d..48a533a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -42,6 +42,14 @@ func UserAdaptersDir() (string, error) { // files so future schema changes can migrate safely. const ConfigVersion = 1 +// Profile is a named launch variant of a provider: the same tool with extra +// env (e.g. CLAUDE_CONFIG_DIR to select a second account). Invoked as +// `sloop run @`; the profile name becomes the session's instance suffix. +type Profile struct { + Tool string `yaml:"tool"` + Env map[string]string `yaml:"env,omitempty"` +} + // Global is the machine-local config at ~/.sloop/config.yaml. type Global struct { Version int `yaml:"version"` @@ -50,6 +58,8 @@ type Global struct { // auto from $SLOOP_LANG/$LANG. Hints nil = enabled, false = off. Lang string `yaml:"lang,omitempty"` Hints *bool `yaml:"hints,omitempty"` + // Profiles are reusable launch variants, keyed by name. + Profiles map[string]Profile `yaml:"profiles,omitempty"` } func GlobalConfigPath() (string, error) { diff --git a/internal/config/global_test.go b/internal/config/global_test.go index 5fae34a..1d78ed4 100644 --- a/internal/config/global_test.go +++ b/internal/config/global_test.go @@ -26,3 +26,35 @@ func TestSaveAndLoadGlobal(t *testing.T) { t.Fatalf("want %q, got %q", ModeAuto, g.Mode) } } + +func TestGlobalProfilesRoundTrip(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + in := &Global{Profiles: map[string]Profile{ + "sec": {Tool: "claude", Env: map[string]string{"CLAUDE_CONFIG_DIR": "~/.claude-sec"}}, + }} + if err := SaveGlobal(in); err != nil { + t.Fatalf("SaveGlobal: %v", err) + } + g, err := LoadGlobal() + if err != nil { + t.Fatalf("LoadGlobal: %v", err) + } + p, ok := g.Profiles["sec"] + if !ok || p.Tool != "claude" || p.Env["CLAUDE_CONFIG_DIR"] != "~/.claude-sec" { + t.Fatalf("profile did not round-trip: %+v", g.Profiles) + } +} + +func TestLoadGlobalNoProfilesIsNil(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + if err := SaveGlobal(&Global{Mode: ModeAuto}); err != nil { + t.Fatalf("SaveGlobal: %v", err) + } + g, err := LoadGlobal() + if err != nil { + t.Fatalf("LoadGlobal: %v", err) + } + if g.Profiles != nil { + t.Fatalf("want nil Profiles, got %+v", g.Profiles) + } +} diff --git a/internal/runner/runner.go b/internal/runner/runner.go index 52f68ff..76339c8 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -3,12 +3,16 @@ package runner import ( "os" "os/exec" + "sort" ) type Spec struct { Dir string Command string Args []string + // Env is extra environment for the launched command (e.g. CLAUDE_CONFIG_DIR + // to select a second account); empty leaves the inherited environment alone. + Env map[string]string } type Runner interface { @@ -21,6 +25,18 @@ func BuildExecCmd(s Spec) *exec.Cmd { cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr + if len(s.Env) > 0 { + keys := make([]string, 0, len(s.Env)) + for k := range s.Env { + keys = append(keys, k) + } + sort.Strings(keys) + env := os.Environ() + for _, k := range keys { + env = append(env, k+"="+s.Env[k]) + } + cmd.Env = env + } return cmd } diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go index a233877..3fa0587 100644 --- a/internal/runner/runner_test.go +++ b/internal/runner/runner_test.go @@ -1,6 +1,9 @@ package runner -import "testing" +import ( + "slices" + "testing" +) func TestBuildExecCmdSetsDirAndArgs(t *testing.T) { cmd := BuildExecCmd(Spec{Dir: "/tmp/backend", Command: "claude", Args: []string{"--resume"}}) @@ -12,3 +15,14 @@ func TestBuildExecCmdSetsDirAndArgs(t *testing.T) { t.Fatalf("unexpected args: %v", cmd.Args) } } + +func TestBuildExecCmdEnv(t *testing.T) { + cmd := BuildExecCmd(Spec{Command: "claude", Env: map[string]string{"CLAUDE_CONFIG_DIR": "/x"}}) + if !slices.Contains(cmd.Env, "CLAUDE_CONFIG_DIR=/x") { + t.Fatalf("env not injected: %v", cmd.Env) + } + // No Env → leave cmd.Env nil (inherit parent), unchanged from before. + if BuildExecCmd(Spec{Command: "claude"}).Env != nil { + t.Fatal("no Env should leave cmd.Env nil") + } +} diff --git a/internal/tmux/peek.go b/internal/tmux/peek.go new file mode 100644 index 0000000..aa1cf60 --- /dev/null +++ b/internal/tmux/peek.go @@ -0,0 +1,69 @@ +package tmux + +import ( + "fmt" + "os" + "os/exec" + "strings" +) + +// peekPopupW/H are the default overlay size for peek — bigger than the HUD +// because you actually work inside the agent while it floats. +const ( + peekPopupW = "90%" + peekPopupH = "80%" +) + +// shellSingleQuote wraps s in single quotes safe for /bin/sh, escaping any +// embedded single quotes. Session names are simple, but never trust input. +func shellSingleQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} + +// BuildNestedAttachCmd returns a /bin/sh command that attaches to session as a +// fresh nested client. display-popup runs with $TMUX set, which would make a +// plain `tmux attach` refuse ("sessions should be nested with care"), so we +// clear TMUX/TMUX_PANE for just this command. Closing the popup kills this +// client → it detaches → the agent session keeps running. +func BuildNestedAttachCmd(session string) string { + return fmt.Sprintf("env -u TMUX -u TMUX_PANE %s attach -t %s", Bin(), shellSingleQuote(session)) +} + +// NestedAttach attaches to session in the current TTY with TMUX cleared, used +// when we are already inside a popup (the keybind path) so no second popup opens. +func NestedAttach(session string) error { + cmd := exec.Command(Bin(), "attach", "-t", session) + cmd.Env = clearTmuxEnv(os.Environ()) + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr + return cmd.Run() +} + +// clearTmuxEnv drops TMUX and TMUX_PANE so a nested attach is permitted. +func clearTmuxEnv(env []string) []string { + out := make([]string, 0, len(env)) + for _, e := range env { + if strings.HasPrefix(e, "TMUX=") || strings.HasPrefix(e, "TMUX_PANE=") { + continue + } + out = append(out, e) + } + return out +} + +// Peek floats session's live pane over the current pane in a display-popup +// (must be run inside tmux ≥ 3.2). The overlay closes back to your work on exit. +func Peek(session string) error { + cmd := exec.Command(Bin(), BuildPopupArgs(peekPopupW, peekPopupH, BuildNestedAttachCmd(session))...) + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr + return cmd.Run() +} + +// BuildPeekBindArgs binds to open the peek popup running command. +func BuildPeekBindArgs(key, command string) []string { + return append([]string{"bind-key", key}, BuildPopupArgs(peekPopupW, peekPopupH, command)...) +} + +// BindPeek binds the peek key on the live tmux server. +func BindPeek(key, command string) error { + return Run(BuildPeekBindArgs(key, command)...) +} diff --git a/internal/tmux/peek_test.go b/internal/tmux/peek_test.go new file mode 100644 index 0000000..4dec330 --- /dev/null +++ b/internal/tmux/peek_test.go @@ -0,0 +1,31 @@ +package tmux + +import ( + "strings" + "testing" +) + +func TestShellSingleQuote(t *testing.T) { + if got := shellSingleQuote("ws__claude"); got != "'ws__claude'" { + t.Fatalf("plain: got %q", got) + } + if got := shellSingleQuote("a'b"); got != `'a'\''b'` { + t.Fatalf("embedded quote: got %q", got) + } +} + +func TestBuildNestedAttachCmd(t *testing.T) { + got := BuildNestedAttachCmd("ws__claude") + want := "env -u TMUX -u TMUX_PANE " + Bin() + " attach -t 'ws__claude'" + if got != want { + t.Fatalf("got %q want %q", got, want) + } +} + +func TestBuildPeekBindArgs(t *testing.T) { + got := BuildPeekBindArgs("p", "CMD") + want := []string{"bind-key", "p", "display-popup", "-w", "90%", "-h", "80%", "-E", "CMD"} + if strings.Join(got, " ") != strings.Join(want, " ") { + t.Fatalf("got %v want %v", got, want) + } +} diff --git a/internal/tmux/tmux.go b/internal/tmux/tmux.go index a535f63..44dc416 100644 --- a/internal/tmux/tmux.go +++ b/internal/tmux/tmux.go @@ -6,6 +6,7 @@ import ( "log/slog" "os" "os/exec" + "sort" "strings" "sync" @@ -84,6 +85,44 @@ func SessionName(workspace, tool string) string { return sanitize(workspace) + "__" + sanitize(tool) } +// InstanceName is SessionName plus an optional instance suffix; instance=="" is +// the default session (byte-identical to SessionName), so a second agent of the +// same provider in one workspace gets a distinct `ws__tool__instance` name. +func InstanceName(workspace, tool, instance string) string { + base := SessionName(workspace, tool) + if instance == "" { + return base + } + return base + "__" + sanitize(instance) +} + +// envPrefix returns ["env","K1=V1",…] in sorted key order, or nil when empty, so +// a launched command can carry extra env (e.g. CLAUDE_CONFIG_DIR for a second +// account) on any tmux version — no dependency on `new-session -e`. +func envPrefix(env map[string]string) []string { + if len(env) == 0 { + return nil + } + keys := make([]string, 0, len(env)) + for k := range env { + keys = append(keys, k) + } + sort.Strings(keys) + out := []string{"env"} + for _, k := range keys { + out = append(out, k+"="+env[k]) + } + return out +} + +// buildNewDetachedArgs builds `new-session -d -s -c [env K=V…] cmd args…`. +func buildNewDetachedArgs(session, dir string, env map[string]string, command string, cmdArgs []string) []string { + args := []string{"new-session", "-d", "-s", session, "-c", dir} + args = append(args, envPrefix(env)...) + args = append(args, command) + return append(args, cmdArgs...) +} + // DetachHint is the one place that explains how to detach (hide the agent and // return to the terminal), using the user's actual tmux prefix. Shared by run, // attach, and ps so the wording stays consistent. @@ -206,7 +245,7 @@ func (r Runner) Launch(s runner.Spec) error { // Create the session detached so we can give it sloop's own status bar // before attaching; then attach (or switch if already inside tmux). if !hasSession(r.Session) { - create := append([]string{"new-session", "-d", "-s", r.Session, "-c", s.Dir, s.Command}, s.Args...) + create := buildNewDetachedArgs(r.Session, s.Dir, s.Env, s.Command, s.Args) if err := Run(create...); err != nil { return err } diff --git a/internal/tmux/tmux_test.go b/internal/tmux/tmux_test.go index 721e100..9a98058 100644 --- a/internal/tmux/tmux_test.go +++ b/internal/tmux/tmux_test.go @@ -15,6 +15,42 @@ func TestTmuxSessionNameSanitizes(t *testing.T) { } } +func TestInstanceName(t *testing.T) { + if got := InstanceName("repo", "claude", ""); got != "repo__claude" { + t.Fatalf("empty instance: got %s", got) + } + if got := InstanceName("repo", "claude", "sec"); got != "repo__claude__sec" { + t.Fatalf("named instance: got %s", got) + } + if got := InstanceName("repo", "claude", "a b"); got != "repo__claude__a_b" { + t.Fatalf("sanitized instance: got %s", got) + } +} + +func TestEnvPrefix(t *testing.T) { + if envPrefix(nil) != nil { + t.Fatal("empty env should yield nil prefix") + } + got := envPrefix(map[string]string{"B": "2", "A": "1"}) + want := []string{"env", "A=1", "B=2"} // sorted by key + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v want %v", got, want) + } +} + +func TestBuildNewDetachedArgs(t *testing.T) { + plain := buildNewDetachedArgs("repo__claude", "/tmp/repo", nil, "claude", []string{"--resume"}) + wantPlain := []string{"new-session", "-d", "-s", "repo__claude", "-c", "/tmp/repo", "claude", "--resume"} + if !reflect.DeepEqual(plain, wantPlain) { + t.Fatalf("plain: got %v want %v", plain, wantPlain) + } + withEnv := buildNewDetachedArgs("repo__claude__sec", "/tmp/repo", map[string]string{"CLAUDE_CONFIG_DIR": "/x"}, "claude", nil) + wantEnv := []string{"new-session", "-d", "-s", "repo__claude__sec", "-c", "/tmp/repo", "env", "CLAUDE_CONFIG_DIR=/x", "claude"} + if !reflect.DeepEqual(withEnv, wantEnv) { + t.Fatalf("withEnv: got %v want %v", withEnv, wantEnv) + } +} + func TestBuildTmuxNewArgs(t *testing.T) { args := BuildNewArgs("backend__claude", runner.Spec{Dir: "/tmp/backend", Command: "claude", Args: []string{"--resume"}}) want := []string{"new-session", "-A", "-s", "backend__claude", "-c", "/tmp/backend", "claude", "--resume"} diff --git a/internal/tui/input.go b/internal/tui/input.go index dadd5f9..5340f3c 100644 --- a/internal/tui/input.go +++ b/internal/tui/input.go @@ -28,7 +28,11 @@ func ReadLine(prompt string) (string, bool) { var buf []byte in := make([]byte, 16) for { - n, _ := os.Stdin.Read(in) + n, err := os.Stdin.Read(in) + if err != nil { + fmt.Print("\r\n") + return "", false // stdin closed / hard error → cancel, like Esc (never spin) + } if n == 0 { continue } @@ -74,7 +78,10 @@ func Confirm(prompt string) bool { fmt.Print(prompt) in := make([]byte, 1) for { - n, _ := os.Stdin.Read(in) + n, err := os.Stdin.Read(in) + if err != nil { + return false // stdin closed / hard error → treat as "no" (never spin) + } if n == 0 { continue }