From a9e425a29b58b51388b4120ee389bf8084063c1e Mon Sep 17 00:00:00 2001 From: Paul O'Reilly Date: Tue, 27 Oct 2026 16:56:53 +1300 Subject: [PATCH] claude-sessions: profile picker when creating a new session --- memory/script-claude-sessions.md | 1 + scripts/claude-sessions | 61 ++++++++++++++++++++++++++++++-- 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/memory/script-claude-sessions.md b/memory/script-claude-sessions.md index 6f18d3c..977a2e1 100644 --- a/memory/script-claude-sessions.md +++ b/memory/script-claude-sessions.md @@ -14,6 +14,7 @@ Python script (`scripts/claude-sessions`, symlinked to `~/sbin/`). Manages Claud - **Location column.** Live and resumable rows show the session folder (`~` for `$HOME`). Live rows take it from the registry (`project_dir`), falling back to tmux `#{session_path}`. - **`n` New session.** Asks for a folder: Enter accepts the nearest `CLAUDE.md` folder, `b` opens the folder browser, or type a project name/path (same lookup as `claude-tmux`). - **Folder browser.** `zenity --file-selection --directory` when `DISPLAY`/`WAYLAND_DISPLAY` is set and zenity exists (has Create Folder). Otherwise a terminal browser: number = open, `u` up, `n` new folder, `/text` filter, `>` `<` page, `h` hidden, `~`/`/path` jump, Enter/`s` select, `q` cancel. +- **Profile.** After the topic, a numbered picker lists every `~/.claude-[company]-[profile]` dir that has a `projects/` folder, shown as `Company (profile)` (e.g. `O'Reilly IT (anthropic)`). A dir with no profile part shows `(base)`; `~/.claude` shows `Personal (main)`. Company names come from `profiles.yaml` `display_name` (parenthetical stripped), else the Title-Cased segment. Enter accepts `oreillyit-anthropic`. The choice is passed as `--profile` to `claude-tmux`. - **Topic.** Becomes the tmux session name and the Remote Control name (passed as `--name` to `claude-tmux`). Defaults to the Title-Cased folder name. If that name is already a live tmux session, no default is offered and a unique topic is required. Topics containing `.` or `:` are rejected because tmux rewrites them to `_`. ## Headless spawn diff --git a/scripts/claude-sessions b/scripts/claude-sessions index 9471482..f034285 100755 --- a/scripts/claude-sessions +++ b/scripts/claude-sessions @@ -3,7 +3,7 @@ claude-sessions — Session lifecycle manager for Claude Code tmux sessions. Commands: - (no args) Interactive menu (new session: folder browser + unique topic) + (no args) Interactive menu (new session: folder browser, unique topic, profile) list Show live Claude tmux sessions and resumable registry entries recover [--days N] Detect sessions active before a restart spawn Spawn a new session (wraps claude-tmux); --headless for scripted use @@ -47,6 +47,8 @@ PATH_SCAN_ROOTS = [ (HOME, 1), ] +DEFAULT_PROFILE_NAME = "oreillyit-anthropic" # same default as claude-tmux + DEFAULT_PROFILES = { "oreillyit-anthropic": { "display_name": "O'Reilly IT", @@ -346,6 +348,58 @@ def prompt_topic(folder: Path, live: set[str]) -> Optional[str]: return topic +def discover_profile_choices(profiles_cfg: dict) -> list[tuple[str, str]]: + """Profiles from ~/.claude-[company]-[profile] dirs as (name, "Company (profile)"). + + A dir counts as a profile if it has a projects/ folder. Company display names + come from profiles.yaml display_name (parenthetical stripped) when known, + otherwise the Title-Cased directory segment. ~/.claude is the "default" profile. + """ + import re + company_names: dict[str, str] = {} + for name, cfg in profiles_cfg.items(): + company = name.split("-", 1)[0] + disp = re.sub(r"\s*\(.*?\)\s*$", "", cfg.get("display_name", "")).strip() + if disp: + company_names.setdefault(company, disp) + + choices = [] + for d in sorted(HOME.iterdir()): + if not (d.is_dir() and d.name.startswith(".claude-") and (d / "projects").is_dir()): + continue + name = d.name[len(".claude-"):] + company, _, variant = name.partition("-") + label = f"{company_names.get(company) or title_case(company)} ({variant or 'base'})" + choices.append((name, label)) + if (HOME / ".claude" / "projects").is_dir(): + choices.append(("default", "Personal (main)")) + return choices + + +def prompt_profile(profiles_cfg: dict) -> Optional[str]: + """Numbered profile picker. Enter accepts the default. Returns the profile name, or None to cancel.""" + choices = discover_profile_choices(profiles_cfg) + if not choices: + return DEFAULT_PROFILE_NAME + names = [n for n, _ in choices] + default = DEFAULT_PROFILE_NAME if DEFAULT_PROFILE_NAME in names else names[0] + print(f"\n {BOLD}Profile:{NC}") + for i, (n, label) in enumerate(choices, 1): + mark = f" {DIM}(default){NC}" if n == default else "" + print(f" {CYAN}{i:>2}){NC} {label}{mark}") + while True: + try: + raw = input(f" Profile [{BOLD}{names.index(default) + 1}{NC}]: ").strip() + except (KeyboardInterrupt, EOFError): + print() + return None + if not raw: + return default + if raw.isdigit() and 1 <= int(raw) <= len(names): + return names[int(raw) - 1] + print(f" {YELLOW}Enter a number from 1 to {len(names)}.{NC}") + + # ── Session file parsing ───────────────────────────────────────────────────── def read_jsonl_header(path: Path, max_lines: int = 30) -> dict: @@ -882,8 +936,11 @@ def cmd_menu(_args=None) -> None: topic = prompt_topic(folder, list_tmux_sessions()) if topic is None: continue + profile = prompt_profile(profiles_cfg) + if profile is None: + continue import types - a = types.SimpleNamespace(project=str(folder), mode=None, profile=None, name=topic, attach=True) + a = types.SimpleNamespace(project=str(folder), mode=None, profile=profile, name=topic, attach=True) print() cmd_spawn(a) # exec's claude-tmux --attach; replaces this process