From 8ff58530afa2d4b7d661c407b49f5ec74257bc7b Mon Sep 17 00:00:00 2001 From: Harald Nezbeda Date: Wed, 29 Jul 2026 20:30:09 +0000 Subject: [PATCH 1/6] Add credential profiles for multiple agent environments --- docs/agents/index.md | 2 +- docs/configuration.md | 5 ++ docs/profiles.md | 70 +++++++++++++++++++++ mkdocs.yml | 1 + src/vibepod/cli.py | 12 ++++ src/vibepod/commands/doctor.py | 18 +++++- src/vibepod/commands/profile.py | 86 ++++++++++++++++++++++++++ src/vibepod/commands/run.py | 14 ++++- src/vibepod/commands/task.py | 24 +++++++- src/vibepod/core/agents.py | 6 +- src/vibepod/core/profiles.py | 82 +++++++++++++++++++++++++ tests/test_doctor.py | 42 ++++++------- tests/test_profile_cmd.py | 86 ++++++++++++++++++++++++++ tests/test_profile_plumbing.py | 81 ++++++++++++++++++++++++ tests/test_profiles.py | 105 ++++++++++++++++++++++++++++++++ 15 files changed, 599 insertions(+), 35 deletions(-) create mode 100644 docs/profiles.md create mode 100644 src/vibepod/commands/profile.py create mode 100644 src/vibepod/core/profiles.py create mode 100644 tests/test_profile_cmd.py create mode 100644 tests/test_profile_plumbing.py create mode 100644 tests/test_profiles.py diff --git a/docs/agents/index.md b/docs/agents/index.md index 4fd4b9b..70e1ad3 100644 --- a/docs/agents/index.md +++ b/docs/agents/index.md @@ -1,6 +1,6 @@ # Agents -VibePod manages each agent as a Docker or Podman container. Credentials and config are persisted to `~/.config/vibepod/agents//` on your host and mounted into the container on every run, so you only need to authenticate once. +VibePod manages each agent as a Docker or Podman container. Credentials and config are persisted to `~/.config/vibepod/agents//` on your host and mounted into the container on every run, so you only need to authenticate once. To keep several credential sets per agent (subscription vs. API key vs. Ollama), see [Credential Profiles](../profiles.md). ## Supported Agents diff --git a/docs/configuration.md b/docs/configuration.md index 8c286e8..1e10c6e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -173,6 +173,11 @@ These variables override the corresponding config keys without editing any file: | `VP_LLM_API_KEY` | `llm.api_key` | `VP_LLM_API_KEY=ollama` | | `VP_LLM_MODEL` | `llm.model` | `VP_LLM_MODEL=qwen3:14b` | | `VP_CONFIG_DIR` | *(config root)* | `VP_CONFIG_DIR=/custom/path` | +| `VP_PROFILE` | `profile` | `VP_PROFILE=work` | + +The `profile` key selects the active [credential profile](profiles.md); the +`--profile` flag on `vp run`, `vp task create`, and `vp doctor claude` takes +precedence over both the variable and the config key. ### Image overrides diff --git a/docs/profiles.md b/docs/profiles.md new file mode 100644 index 0000000..db2c161 --- /dev/null +++ b/docs/profiles.md @@ -0,0 +1,70 @@ +# Credential Profiles + +Profiles let you keep multiple credential sets per agent and switch between them +at run time — for example a Claude subscription login, a separate API-key setup, +and an environment prepared for Ollama. + +A profile only switches the **credential directories** that get mounted into the +agent container. Everything else (skills, allowed directories, proxy, logging) +stays shared. Environment variables such as `ANTHROPIC_API_KEY` are still +configured via `agents..env` or `-e` flags — combine them with a profile +via a project config (see below). + +## Layout + +``` +~/.config/vibepod/ + agents// # the built-in "default" profile + profiles//agents// # named profiles +``` + +Your existing credentials in `~/.config/vibepod/agents/` are the `default` +profile — nothing moves when you start using profiles. + +## Managing profiles + +```bash +vp profile list # list profiles; * marks the active one, + # agents with stored credentials shown in parentheses +vp profile create work # create an empty profile +vp profile remove work # delete a profile and its credentials (asks first) +``` + +Profile names are lowercase slugs: letters, digits, `-` and `_`. +The `default` profile always exists and cannot be removed. + +## Using a profile + +```bash +vp run claude --profile work +vp task create claude "summarize the diff" --profile work +vp doctor claude --profile work +``` + +The first run with a fresh profile starts unauthenticated — log in once and the +credentials are persisted inside that profile. + +## Selecting a profile without the flag + +Resolution order (first match wins): + +1. `--profile` flag +2. `VP_PROFILE` environment variable +3. `profile:` key in the merged config (global `~/.config/vibepod/config.yaml` + or project `.vibepod/config.yaml`) +4. `default` + +Pinning a profile per project pairs well with per-project env vars: + +```yaml +# .vibepod/config.yaml — a project wired to a local Ollama +version: 1 +profile: ollama +agents: + claude: + env: + ANTHROPIC_BASE_URL: http://host.docker.internal:11434 +``` + +Referencing a profile that does not exist is a hard error — create it first +with `vp profile create `. diff --git a/mkdocs.yml b/mkdocs.yml index 3a8dd90..3caa890 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -52,6 +52,7 @@ nav: - Quickstart: quickstart.md - Development: development.md - Agents: agents/index.md + - Profiles: profiles.md - Skills: - Overview: skills/index.md - Locator format: skills/locators.md diff --git a/src/vibepod/cli.py b/src/vibepod/cli.py index 9a16ac9..9565eab 100644 --- a/src/vibepod/cli.py +++ b/src/vibepod/cli.py @@ -13,6 +13,7 @@ doctor, list_cmd, logs, + profile, proxy, run, skills, @@ -84,6 +85,10 @@ def run_command( help="I Know What I'm Doing: enable auto-approval / skip permission prompts", ), ] = False, + profile: Annotated[ + str | None, + typer.Option("--profile", help="Credential profile to use (see `vp profile list`)"), + ] = None, ) -> None: """Start an agent container.""" run.run( @@ -99,6 +104,7 @@ def run_command( network=network, paste_images=paste_images, ikwid=ikwid, + profile=profile, passthrough_args=_context_args(ctx), ) @@ -114,6 +120,7 @@ def run_command( app.add_typer(logs.app, name="logs") app.add_typer(config.app, name="config") +app.add_typer(profile.app, name="profile") app.add_typer(proxy.app, name="proxy") app.add_typer(doctor.app, name="doctor") app.add_typer(skills.app, name="skills") @@ -170,6 +177,10 @@ def _alias( help="I Know What I'm Doing: enable auto-approval / skip permission prompts", ), ] = False, + profile: Annotated[ + str | None, + typer.Option("--profile", help="Credential profile to use (see `vp profile list`)"), + ] = None, ) -> None: run.run( agent=agent_name, @@ -184,6 +195,7 @@ def _alias( network=network, paste_images=paste_images, ikwid=ikwid, + profile=profile, passthrough_args=_context_args(ctx), ) diff --git a/src/vibepod/commands/doctor.py b/src/vibepod/commands/doctor.py index c7acf18..ab98712 100644 --- a/src/vibepod/commands/doctor.py +++ b/src/vibepod/commands/doctor.py @@ -13,6 +13,8 @@ import typer from vibepod.core.agents import agent_config_dir +from vibepod.core.config import get_config +from vibepod.core.profiles import resolve_profile from vibepod.utils.console import console, error, success, warning app = typer.Typer(help="Inspect agent auth and config state") @@ -64,10 +66,20 @@ def _file_ownership(path: Path) -> str: @app.command("claude") -def claude() -> None: +def claude( + profile: Annotated[ + str | None, + typer.Option("--profile", help="Credential profile to inspect (see `vp profile list`)"), + ] = None, +) -> None: """Inspect Claude Code credential state for diagnosing auth/refresh issues.""" - cfg_dir = agent_config_dir("claude") - console.print(f"[bold]Claude config dir:[/bold] {cfg_dir}") + try: + active_profile = resolve_profile(profile, get_config()) + except ValueError as exc: + error(str(exc)) + raise typer.Exit(1) from exc + cfg_dir = agent_config_dir("claude", active_profile) + console.print(f"[bold]Claude config dir:[/bold] {cfg_dir} (profile: {active_profile})") if not cfg_dir.exists(): error(f"Config dir does not exist: {cfg_dir}") diff --git a/src/vibepod/commands/profile.py b/src/vibepod/commands/profile.py new file mode 100644 index 0000000..26e7e93 --- /dev/null +++ b/src/vibepod/commands/profile.py @@ -0,0 +1,86 @@ +"""Profile subcommands: manage named agent credential environments.""" + +from __future__ import annotations + +import os +from typing import Annotated + +import typer + +from vibepod.constants import SUPPORTED_AGENTS +from vibepod.core.config import get_config +from vibepod.core.profiles import ( + DEFAULT_PROFILE, + create_profile, + list_profiles, + profile_agents_root, + remove_profile, + resolve_profile, +) +from vibepod.utils.console import console, error, success + +app = typer.Typer(help="Manage credential profiles (separate agent logins per environment)") + + +def _agents_with_credentials(profile: str) -> list[str]: + root = profile_agents_root(profile) + found: list[str] = [] + for agent in sorted(SUPPORTED_AGENTS): + agent_dir = root / agent + if agent_dir.is_dir() and any(agent_dir.iterdir()): + found.append(agent) + return found + + +def _active_profile() -> str: + try: + return resolve_profile(None, get_config()) + except ValueError: + return DEFAULT_PROFILE + + +@app.command("list") +def list_() -> None: + """List profiles, the active one, and which agents have credentials.""" + active = _active_profile() + for name in list_profiles(): + marker = "*" if name == active else " " + agents = ", ".join(_agents_with_credentials(name)) + suffix = f" ({agents})" if agents else "" + console.print(f"{marker} {name}{suffix}") + + +@app.command("create") +def create( + name: Annotated[str, typer.Argument(help="Profile name (lowercase slug)")], +) -> None: + """Create a new empty profile.""" + try: + path = create_profile(name) + except ValueError as exc: + error(str(exc)) + raise typer.Exit(code=1) from exc + success(f"Created profile '{name}' at {path.parent}") + + +@app.command("remove") +def remove( + name: Annotated[str, typer.Argument(help="Profile to remove")], + yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation")] = False, +) -> None: + """Remove a profile and its stored credentials.""" + if name == DEFAULT_PROFILE: + error("Profile 'default' cannot be removed.") + raise typer.Exit(code=1) + if not yes: + typer.confirm( + f"Remove profile '{name}' and all credentials stored in it?", abort=True + ) + try: + remove_profile(name) + except ValueError as exc: + error(str(exc)) + raise typer.Exit(code=1) from exc + if os.environ.get("VP_PROFILE") == name: + console.print(f"Note: VP_PROFILE still points at removed profile '{name}'.") + success(f"Removed profile '{name}'") diff --git a/src/vibepod/commands/run.py b/src/vibepod/commands/run.py index f313ab4..21e05da 100644 --- a/src/vibepod/commands/run.py +++ b/src/vibepod/commands/run.py @@ -88,6 +88,7 @@ from vibepod.core.launch import ( x11_volumes_and_env as _x11_volumes_and_env, ) +from vibepod.core.profiles import resolve_profile from vibepod.core.session_logger import SessionLogger from vibepod.utils.console import error, info, success, warning @@ -306,6 +307,10 @@ def run( help="I Know What I'm Doing: enable auto-approval / skip permission prompts", ), ] = False, + profile: Annotated[ + str | None, + typer.Option("--profile", help="Credential profile to use (see `vp profile list`)"), + ] = None, passthrough_args: list[str] | None = None, ) -> None: """Start an agent container. @@ -316,6 +321,11 @@ def run( """ passthrough_args = passthrough_args or [] config = get_config() + try: + active_profile = resolve_profile(profile, config) + except ValueError as exc: + error(str(exc)) + raise typer.Exit(1) from exc selected_agent_input = agent or str(config.get("default_agent", "claude")) selected_agent = resolve_agent_name(selected_agent_input) if selected_agent is None: @@ -397,7 +407,7 @@ def run( and "ANTHROPIC_API_KEY" not in merged_env and "setup-token" not in passthrough_args ): - stored_token = _read_claude_stored_token(agent_config_dir(selected_agent)) + stored_token = _read_claude_stored_token(agent_config_dir(selected_agent, active_profile)) if stored_token: merged_env["CLAUDE_CODE_OAUTH_TOKEN"] = stored_token info("Using stored Claude OAuth token (from `vp run claude setup-token`)") @@ -494,7 +504,7 @@ def run( raise typer.Exit(1) from exc command = list(command or []) + passthrough_args - config_dir = agent_config_dir(selected_agent) + config_dir = agent_config_dir(selected_agent, active_profile) config_dir.mkdir(parents=True, exist_ok=True) proxy_cfg = config.get("proxy", {}) diff --git a/src/vibepod/commands/task.py b/src/vibepod/commands/task.py index 4f6624f..01d8f7d 100644 --- a/src/vibepod/commands/task.py +++ b/src/vibepod/commands/task.py @@ -43,6 +43,7 @@ terminal_env_defaults, update_container_mapping, ) +from vibepod.core.profiles import resolve_profile from vibepod.core.tasks import ( TASK_STATUS_CANCELLED, TASK_STATUS_COMPLETED, @@ -319,6 +320,10 @@ def task_create_command( help="I Know What I'm Doing: enable auto-approval flags for supported agents", ), ] = False, + profile: Annotated[ + str | None, + typer.Option("--profile", help="Credential profile to use (see `vp profile list`)"), + ] = None, ) -> None: """Start an agent task in the background and print its id.""" task_create( @@ -334,6 +339,7 @@ def task_create_command( rebuild_overlay=rebuild_overlay, no_herdr=no_herdr, ikwid=ikwid, + profile=profile, passthrough_args=_context_args(ctx), ) @@ -390,6 +396,10 @@ def task_run_command( help="I Know What I'm Doing: enable auto-approval flags for supported agents", ), ] = False, + profile: Annotated[ + str | None, + typer.Option("--profile", help="Credential profile to use (see `vp profile list`)"), + ] = None, ) -> None: """Deprecated alias for `task create`.""" task_create( @@ -405,6 +415,7 @@ def task_run_command( rebuild_overlay=rebuild_overlay, no_herdr=no_herdr, ikwid=ikwid, + profile=profile, passthrough_args=_context_args(ctx), deprecated_alias=True, ) @@ -456,6 +467,10 @@ def task_create( help="I Know What I'm Doing: enable auto-approval flags for supported agents", ), ] = False, + profile: Annotated[ + str | None, + typer.Option("--profile", help="Credential profile to use (see `vp profile list`)"), + ] = None, passthrough_args: list[str] | None = None, deprecated_alias: bool = False, ) -> None: @@ -470,6 +485,11 @@ def task_create( timeout_seconds = _parse_task_timeout(timeout) config = get_config() + try: + active_profile = resolve_profile(profile, config) + except ValueError as exc: + error(str(exc)) + raise typer.Exit(1) from exc selected = resolve_agent_name(agent) if selected is None: error(f"Unknown agent '{agent}'.") @@ -532,7 +552,7 @@ def task_create( and "CLAUDE_CODE_OAUTH_TOKEN" not in merged_env and "ANTHROPIC_API_KEY" not in merged_env ): - stored_token = read_claude_stored_token(agent_config_dir(selected)) + stored_token = read_claude_stored_token(agent_config_dir(selected, active_profile)) if stored_token: merged_env["CLAUDE_CODE_OAUTH_TOKEN"] = stored_token info("Using stored Claude OAuth token") @@ -617,7 +637,7 @@ def task_create( + passthrough_args ) - config_dir = agent_config_dir(selected) + config_dir = agent_config_dir(selected, active_profile) config_dir.mkdir(parents=True, exist_ok=True) extra_volumes = agent_extra_volumes(selected, config_dir) diff --git a/src/vibepod/core/agents.py b/src/vibepod/core/agents.py index c244d54..c55cb29 100644 --- a/src/vibepod/core/agents.py +++ b/src/vibepod/core/agents.py @@ -7,7 +7,7 @@ from typing import Any from vibepod.constants import AGENT_ALIASES, AGENT_SHORTCUTS, DEFAULT_IMAGES, SUPPORTED_AGENTS -from vibepod.core.config import get_config_root +from vibepod.core.profiles import DEFAULT_PROFILE, profile_agents_root @dataclass(frozen=True) @@ -204,6 +204,6 @@ def effective_agent_image(agent: str, config: dict[str, Any]) -> str: return str(config.get("agents", {}).get(agent, {}).get("image", spec.image)) -def agent_config_dir(agent: str) -> Path: +def agent_config_dir(agent: str, profile: str = DEFAULT_PROFILE) -> Path: spec = get_agent_spec(agent) - return get_config_root() / "agents" / spec.config_subdir + return profile_agents_root(profile) / spec.config_subdir diff --git a/src/vibepod/core/profiles.py b/src/vibepod/core/profiles.py new file mode 100644 index 0000000..45d2eee --- /dev/null +++ b/src/vibepod/core/profiles.py @@ -0,0 +1,82 @@ +"""Named credential profiles: separate agent credential dirs per environment.""" + +from __future__ import annotations + +import os +import re +import shutil +from pathlib import Path +from typing import Any + +from vibepod.core.config import get_config_root + +DEFAULT_PROFILE = "default" +PROFILE_NAME_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]*$") + + +def validate_profile_name(name: str) -> None: + if not PROFILE_NAME_PATTERN.fullmatch(name): + raise ValueError( + f"Invalid profile name '{name}': use lowercase letters, digits, '-' and '_', " + "starting with a letter or digit." + ) + + +def profiles_root() -> Path: + return get_config_root() / "profiles" + + +def profile_agents_root(profile: str) -> Path: + """Return the directory holding per-agent credential dirs for a profile.""" + if profile == DEFAULT_PROFILE: + return get_config_root() / "agents" + return profiles_root() / profile / "agents" + + +def list_profiles() -> list[str]: + names = [DEFAULT_PROFILE] + root = profiles_root() + if root.is_dir(): + names.extend(sorted(entry.name for entry in root.iterdir() if entry.is_dir())) + return names + + +def profile_exists(profile: str) -> bool: + if profile == DEFAULT_PROFILE: + return True + return (profiles_root() / profile).is_dir() + + +def create_profile(name: str) -> Path: + validate_profile_name(name) + if name == DEFAULT_PROFILE: + raise ValueError("Profile 'default' always exists; no need to create it.") + if profile_exists(name): + raise ValueError(f"Profile '{name}' already exists.") + path = profile_agents_root(name) + path.mkdir(parents=True) + return path + + +def remove_profile(name: str) -> None: + if name == DEFAULT_PROFILE: + raise ValueError("Profile 'default' cannot be removed.") + if not profile_exists(name): + raise ValueError(f"Profile '{name}' does not exist.") + shutil.rmtree(profiles_root() / name) + + +def resolve_profile(cli_value: str | None, config: dict[str, Any]) -> str: + """Resolve the active profile: CLI flag > VP_PROFILE > config key > default.""" + configured = config.get("profile") + selected = ( + cli_value + or os.environ.get("VP_PROFILE") + or (configured if isinstance(configured, str) and configured else None) + or DEFAULT_PROFILE + ) + if not profile_exists(selected): + raise ValueError( + f"Profile '{selected}' does not exist. Create it with: vp profile create {selected}" + ) + return selected diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 8078369..d57d084 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -16,7 +16,7 @@ def test_doctor_missing_dir(tmp_path: Path, monkeypatch) -> None: monkeypatch.setattr( "vibepod.commands.doctor.agent_config_dir", - lambda _agent: tmp_path / "does-not-exist", + lambda _agent, _profile="default": tmp_path / "does-not-exist", ) result = runner.invoke(app, ["doctor", "claude"]) assert result.exit_code == 1 @@ -24,8 +24,7 @@ def test_doctor_missing_dir(tmp_path: Path, monkeypatch) -> None: def test_doctor_valid_token(tmp_path: Path, monkeypatch) -> None: monkeypatch.setattr( - "vibepod.commands.doctor.agent_config_dir", - lambda _agent: tmp_path, + "vibepod.commands.doctor.agent_config_dir", lambda _agent, _profile="default": tmp_path ) future_ms = int((time.time() + 3600) * 1000) (tmp_path / ".credentials.json").write_text( @@ -36,9 +35,9 @@ def test_doctor_valid_token(tmp_path: Path, monkeypatch) -> None: "refreshToken": "r", "expiresAt": future_ms, "scopes": ["user:inference"], - }, - }, - ), + } + } + ) ) result = runner.invoke(app, ["doctor", "claude"]) assert result.exit_code == 0 @@ -48,8 +47,7 @@ def test_doctor_valid_token(tmp_path: Path, monkeypatch) -> None: def test_doctor_expired_token(tmp_path: Path, monkeypatch) -> None: monkeypatch.setattr( - "vibepod.commands.doctor.agent_config_dir", - lambda _agent: tmp_path, + "vibepod.commands.doctor.agent_config_dir", lambda _agent, _profile="default": tmp_path ) monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) @@ -61,9 +59,9 @@ def test_doctor_expired_token(tmp_path: Path, monkeypatch) -> None: "accessToken": "a", "refreshToken": "r", "expiresAt": past_ms, - }, - }, - ), + } + } + ) ) result = runner.invoke(app, ["doctor", "claude"]) assert result.exit_code == 2 @@ -73,16 +71,15 @@ def test_doctor_expired_token(tmp_path: Path, monkeypatch) -> None: def test_doctor_expired_creds_but_stored_token_is_ok(tmp_path: Path, monkeypatch) -> None: """Expired credentials.json should NOT exit 2 when a stored token covers auth.""" monkeypatch.setattr( - "vibepod.commands.doctor.agent_config_dir", - lambda _agent: tmp_path, + "vibepod.commands.doctor.agent_config_dir", lambda _agent, _profile="default": tmp_path ) monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) past_ms = int((time.time() - 3600) * 1000) (tmp_path / ".credentials.json").write_text( json.dumps( - {"claudeAiOauth": {"accessToken": "a", "expiresAt": past_ms}}, - ), + {"claudeAiOauth": {"accessToken": "a", "expiresAt": past_ms}} + ) ) (tmp_path / "oauth-token").write_text("sk-stored\n", encoding="utf-8") result = runner.invoke(app, ["doctor", "claude"]) @@ -92,8 +89,7 @@ def test_doctor_expired_creds_but_stored_token_is_ok(tmp_path: Path, monkeypatch def test_doctor_missing_refresh_token(tmp_path: Path, monkeypatch) -> None: monkeypatch.setattr( - "vibepod.commands.doctor.agent_config_dir", - lambda _agent: tmp_path, + "vibepod.commands.doctor.agent_config_dir", lambda _agent, _profile="default": tmp_path ) future_ms = int((time.time() + 3600) * 1000) (tmp_path / ".credentials.json").write_text( @@ -102,9 +98,9 @@ def test_doctor_missing_refresh_token(tmp_path: Path, monkeypatch) -> None: "claudeAiOauth": { "accessToken": "a", "expiresAt": future_ms, - }, - }, - ), + } + } + ) ) result = runner.invoke(app, ["doctor", "claude"]) assert result.exit_code == 0 @@ -113,8 +109,7 @@ def test_doctor_missing_refresh_token(tmp_path: Path, monkeypatch) -> None: def test_doctor_reports_stored_token_mode(tmp_path: Path, monkeypatch) -> None: monkeypatch.setattr( - "vibepod.commands.doctor.agent_config_dir", - lambda _agent: tmp_path, + "vibepod.commands.doctor.agent_config_dir", lambda _agent, _profile="default": tmp_path ) monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) @@ -126,8 +121,7 @@ def test_doctor_reports_stored_token_mode(tmp_path: Path, monkeypatch) -> None: def test_doctor_reports_host_env_mode(tmp_path: Path, monkeypatch) -> None: monkeypatch.setattr( - "vibepod.commands.doctor.agent_config_dir", - lambda _agent: tmp_path, + "vibepod.commands.doctor.agent_config_dir", lambda _agent, _profile="default": tmp_path ) monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "host-token-abc") diff --git a/tests/test_profile_cmd.py b/tests/test_profile_cmd.py new file mode 100644 index 0000000..91cb7d1 --- /dev/null +++ b/tests/test_profile_cmd.py @@ -0,0 +1,86 @@ +"""Tests for `vp profile` subcommands.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from vibepod.cli import app +from vibepod.core.agents import agent_config_dir + +runner = CliRunner() + + +@pytest.fixture() +def config_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + monkeypatch.setenv("VP_CONFIG_DIR", str(tmp_path)) + monkeypatch.delenv("VP_PROFILE", raising=False) + return tmp_path + + +def test_profile_help_lists_subcommands() -> None: + result = runner.invoke(app, ["profile", "--help"]) + assert result.exit_code == 0 + for sub in ("list", "create", "remove"): + assert sub in result.stdout + + +def test_profile_create_and_list(config_root: Path) -> None: + result = runner.invoke(app, ["profile", "create", "work"]) + assert result.exit_code == 0, result.output + assert (config_root / "profiles" / "work" / "agents").is_dir() + + result = runner.invoke(app, ["profile", "list"]) + assert result.exit_code == 0 + assert "default" in result.stdout + assert "work" in result.stdout + + +def test_profile_list_marks_agents_with_credentials(config_root: Path) -> None: + runner.invoke(app, ["profile", "create", "work"]) + creds = agent_config_dir("claude", "work") + creds.mkdir(parents=True) + (creds / "oauth-token").write_text("tok\n") + + result = runner.invoke(app, ["profile", "list"]) + assert result.exit_code == 0 + assert "claude" in result.stdout + + +def test_profile_create_rejects_invalid_name(config_root: Path) -> None: + result = runner.invoke(app, ["profile", "create", "Bad Name"]) + assert result.exit_code != 0 + result = runner.invoke(app, ["profile", "create", "default"]) + assert result.exit_code != 0 + + +def test_profile_create_rejects_duplicate(config_root: Path) -> None: + assert runner.invoke(app, ["profile", "create", "work"]).exit_code == 0 + result = runner.invoke(app, ["profile", "create", "work"]) + assert result.exit_code != 0 + + +def test_profile_remove(config_root: Path) -> None: + runner.invoke(app, ["profile", "create", "work"]) + result = runner.invoke(app, ["profile", "remove", "work", "--yes"]) + assert result.exit_code == 0, result.output + assert not (config_root / "profiles" / "work").exists() + + +def test_profile_remove_refuses_default(config_root: Path) -> None: + result = runner.invoke(app, ["profile", "remove", "default", "--yes"]) + assert result.exit_code != 0 + + +def test_profile_remove_unknown(config_root: Path) -> None: + result = runner.invoke(app, ["profile", "remove", "missing", "--yes"]) + assert result.exit_code != 0 + + +def test_profile_remove_asks_for_confirmation(config_root: Path) -> None: + runner.invoke(app, ["profile", "create", "work"]) + result = runner.invoke(app, ["profile", "remove", "work"], input="n\n") + assert result.exit_code != 0 + assert (config_root / "profiles" / "work").exists() diff --git a/tests/test_profile_plumbing.py b/tests/test_profile_plumbing.py new file mode 100644 index 0000000..6e46fb9 --- /dev/null +++ b/tests/test_profile_plumbing.py @@ -0,0 +1,81 @@ +"""--profile plumbing tests for run/task/doctor.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from vibepod.cli import app +from vibepod.commands import run as run_cmd +from vibepod.core.agents import agent_config_dir +from vibepod.core.docker import DockerClientError +from vibepod.core.profiles import create_profile + +runner = CliRunner() + + +@pytest.fixture() +def config_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + monkeypatch.setenv("VP_CONFIG_DIR", str(tmp_path)) + monkeypatch.delenv("VP_PROFILE", raising=False) + monkeypatch.setattr(run_cmd, "is_dir_allowed", lambda p: True) + return tmp_path + + +def test_run_unknown_profile_fails_fast(config_root: Path) -> None: + result = runner.invoke(app, ["run", "claude", "--profile", "nope"]) + assert result.exit_code == 1 + assert "vp profile create nope" in result.output + + +def test_run_unknown_profile_via_vp_profile_env( + config_root: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("VP_PROFILE", "ghost") + result = runner.invoke(app, ["run", "claude"]) + assert result.exit_code == 1 + assert "vp profile create ghost" in result.output + + +def test_run_uses_profile_config_dir_for_stored_token( + config_root: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + create_profile("work") + seen: dict[str, Path] = {} + + def fake_read(config_dir: Path) -> str | None: + seen["config_dir"] = config_dir + return None + + def broken_docker() -> None: + raise DockerClientError("docker unavailable in test") + + monkeypatch.setattr(run_cmd, "_read_claude_stored_token", fake_read) + monkeypatch.setattr(run_cmd, "DockerManager", broken_docker) + + result = runner.invoke(app, ["run", "claude", "--profile", "work"]) + assert result.exit_code != 0 # stops at docker, after token lookup + assert seen["config_dir"] == agent_config_dir("claude", "work") + + +def test_task_create_unknown_profile_fails_fast(config_root: Path) -> None: + result = runner.invoke(app, ["task", "create", "claude", "hi", "--profile", "nope"]) + assert result.exit_code == 1 + assert "vp profile create nope" in result.output + + +def test_doctor_claude_reports_profile_dir(config_root: Path) -> None: + create_profile("work") + profile_dir = agent_config_dir("claude", "work") + profile_dir.mkdir(parents=True) + + result = runner.invoke(app, ["doctor", "claude", "--profile", "work"]) + assert str(profile_dir) in result.output.replace("\n", "") + + +def test_doctor_claude_unknown_profile_fails(config_root: Path) -> None: + result = runner.invoke(app, ["doctor", "claude", "--profile", "nope"]) + assert result.exit_code == 1 + assert "vp profile create nope" in result.output diff --git a/tests/test_profiles.py b/tests/test_profiles.py new file mode 100644 index 0000000..aba33bc --- /dev/null +++ b/tests/test_profiles.py @@ -0,0 +1,105 @@ +"""Profile core tests: paths, validation, listing, resolution.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from vibepod.core.agents import agent_config_dir +from vibepod.core.profiles import ( + DEFAULT_PROFILE, + create_profile, + list_profiles, + profile_exists, + remove_profile, + resolve_profile, + validate_profile_name, +) + + +@pytest.fixture() +def config_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + monkeypatch.setenv("VP_CONFIG_DIR", str(tmp_path)) + monkeypatch.delenv("VP_PROFILE", raising=False) + return tmp_path + + +def test_agent_config_dir_default_profile_keeps_legacy_path(config_root: Path) -> None: + assert agent_config_dir("claude") == config_root / "agents" / "claude" + assert agent_config_dir("claude", DEFAULT_PROFILE) == config_root / "agents" / "claude" + + +def test_agent_config_dir_named_profile(config_root: Path) -> None: + assert ( + agent_config_dir("claude", "work") + == config_root / "profiles" / "work" / "agents" / "claude" + ) + assert ( + agent_config_dir("gemini", "ollama") + == config_root / "profiles" / "ollama" / "agents" / "gemini" + ) + + +def test_validate_profile_name_accepts_slugs() -> None: + for name in ("work", "api-key", "ollama_local", "a", "x1"): + validate_profile_name(name) + + +def test_validate_profile_name_rejects_bad_names() -> None: + for name in ("", "Work", "-lead", "_lead", "sp ace", "dot.name", "a/b", "..", "über"): + with pytest.raises(ValueError): + validate_profile_name(name) + + +def test_default_profile_always_exists(config_root: Path) -> None: + assert profile_exists(DEFAULT_PROFILE) is True + assert list_profiles() == [DEFAULT_PROFILE] + + +def test_create_and_list_profiles(config_root: Path) -> None: + create_profile("work") + create_profile("apikey") + assert list_profiles() == [DEFAULT_PROFILE, "apikey", "work"] + assert profile_exists("work") is True + assert profile_exists("missing") is False + assert (config_root / "profiles" / "work" / "agents").is_dir() + + +def test_create_profile_rejects_default_and_duplicates(config_root: Path) -> None: + with pytest.raises(ValueError): + create_profile(DEFAULT_PROFILE) + create_profile("work") + with pytest.raises(ValueError): + create_profile("work") + + +def test_remove_profile(config_root: Path) -> None: + create_profile("work") + remove_profile("work") + assert profile_exists("work") is False + with pytest.raises(ValueError): + remove_profile(DEFAULT_PROFILE) + with pytest.raises(ValueError): + remove_profile("missing") + + +def test_resolve_profile_precedence( + config_root: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + create_profile("flagged") + create_profile("envvar") + create_profile("configured") + + # config key < env var < CLI flag + config = {"profile": "configured"} + assert resolve_profile(None, {}) == DEFAULT_PROFILE + assert resolve_profile(None, config) == "configured" + monkeypatch.setenv("VP_PROFILE", "envvar") + assert resolve_profile(None, config) == "envvar" + assert resolve_profile("flagged", config) == "flagged" + + +def test_resolve_profile_unknown_raises(config_root: Path) -> None: + with pytest.raises(ValueError, match="vp profile create"): + resolve_profile("nope", {}) From 35329eb0413939bbb65149762ba3cb76a3764bfb Mon Sep 17 00:00:00 2001 From: Harald Nezbeda Date: Wed, 29 Jul 2026 20:34:18 +0000 Subject: [PATCH 2/6] Reject path-traversal profile names in resolve/remove --- src/vibepod/core/profiles.py | 5 ++++- tests/test_profile_plumbing.py | 13 +++++++++++++ tests/test_profiles.py | 19 +++++++++++++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/vibepod/core/profiles.py b/src/vibepod/core/profiles.py index 45d2eee..d8dff1e 100644 --- a/src/vibepod/core/profiles.py +++ b/src/vibepod/core/profiles.py @@ -18,7 +18,7 @@ def validate_profile_name(name: str) -> None: if not PROFILE_NAME_PATTERN.fullmatch(name): raise ValueError( f"Invalid profile name '{name}': use lowercase letters, digits, '-' and '_', " - "starting with a letter or digit." + "starting with a lowercase letter or digit." ) @@ -61,6 +61,7 @@ def create_profile(name: str) -> Path: def remove_profile(name: str) -> None: if name == DEFAULT_PROFILE: raise ValueError("Profile 'default' cannot be removed.") + validate_profile_name(name) if not profile_exists(name): raise ValueError(f"Profile '{name}' does not exist.") shutil.rmtree(profiles_root() / name) @@ -75,6 +76,8 @@ def resolve_profile(cli_value: str | None, config: dict[str, Any]) -> str: or (configured if isinstance(configured, str) and configured else None) or DEFAULT_PROFILE ) + if selected != DEFAULT_PROFILE: + validate_profile_name(selected) if not profile_exists(selected): raise ValueError( f"Profile '{selected}' does not exist. Create it with: vp profile create {selected}" diff --git a/tests/test_profile_plumbing.py b/tests/test_profile_plumbing.py index 6e46fb9..9b7beaf 100644 --- a/tests/test_profile_plumbing.py +++ b/tests/test_profile_plumbing.py @@ -30,6 +30,19 @@ def test_run_unknown_profile_fails_fast(config_root: Path) -> None: assert "vp profile create nope" in result.output +def test_run_rejects_traversal_profile_name(config_root: Path) -> None: + result = runner.invoke(app, ["run", "claude", "--profile", ".."]) + assert result.exit_code == 1 + assert "Invalid profile name" in result.output + + +def test_profile_remove_rejects_traversal_name(config_root: Path) -> None: + create_profile("work") # ensure profiles/ exists so ".." would resolve + result = runner.invoke(app, ["profile", "remove", "..", "--yes"]) + assert result.exit_code == 1 + assert (config_root / "profiles" / "work").is_dir() + + def test_run_unknown_profile_via_vp_profile_env( config_root: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/test_profiles.py b/tests/test_profiles.py index aba33bc..e091957 100644 --- a/tests/test_profiles.py +++ b/tests/test_profiles.py @@ -103,3 +103,22 @@ def test_resolve_profile_precedence( def test_resolve_profile_unknown_raises(config_root: Path) -> None: with pytest.raises(ValueError, match="vp profile create"): resolve_profile("nope", {}) + + +def test_resolve_profile_rejects_traversal_names( + config_root: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + for name in ("..", "../../tmp", "a/b"): + with pytest.raises(ValueError, match="Invalid profile name"): + resolve_profile(name, {}) + monkeypatch.setenv("VP_PROFILE", "..") + with pytest.raises(ValueError, match="Invalid profile name"): + resolve_profile(None, {}) + + +def test_remove_profile_rejects_traversal_names(config_root: Path) -> None: + (config_root / "profiles").mkdir() + for name in ("..", "../..", "a/b"): + with pytest.raises(ValueError, match="Invalid profile name"): + remove_profile(name) + assert config_root.is_dir() From 8c815dfe278d1e1bb66af04ca36cedcff7b34c45 Mon Sep 17 00:00:00 2001 From: Harald Nezbeda Date: Wed, 29 Jul 2026 20:48:27 +0000 Subject: [PATCH 3/6] Harden profile resolution and removal UX --- src/vibepod/commands/profile.py | 10 ++++++++++ src/vibepod/core/profiles.py | 11 +++++------ tests/test_profile_cmd.py | 13 +++++++++++++ tests/test_profiles.py | 6 ++++++ 4 files changed, 34 insertions(+), 6 deletions(-) diff --git a/src/vibepod/commands/profile.py b/src/vibepod/commands/profile.py index 26e7e93..8d7dd23 100644 --- a/src/vibepod/commands/profile.py +++ b/src/vibepod/commands/profile.py @@ -14,8 +14,10 @@ create_profile, list_profiles, profile_agents_root, + profile_exists, remove_profile, resolve_profile, + validate_profile_name, ) from vibepod.utils.console import console, error, success @@ -72,6 +74,14 @@ def remove( if name == DEFAULT_PROFILE: error("Profile 'default' cannot be removed.") raise typer.Exit(code=1) + try: + validate_profile_name(name) + except ValueError as exc: + error(str(exc)) + raise typer.Exit(code=1) from exc + if not profile_exists(name): + error(f"Profile '{name}' does not exist.") + raise typer.Exit(code=1) if not yes: typer.confirm( f"Remove profile '{name}' and all credentials stored in it?", abort=True diff --git a/src/vibepod/core/profiles.py b/src/vibepod/core/profiles.py index d8dff1e..86f422d 100644 --- a/src/vibepod/core/profiles.py +++ b/src/vibepod/core/profiles.py @@ -70,12 +70,11 @@ def remove_profile(name: str) -> None: def resolve_profile(cli_value: str | None, config: dict[str, Any]) -> str: """Resolve the active profile: CLI flag > VP_PROFILE > config key > default.""" configured = config.get("profile") - selected = ( - cli_value - or os.environ.get("VP_PROFILE") - or (configured if isinstance(configured, str) and configured else None) - or DEFAULT_PROFILE - ) + if configured is not None and (not isinstance(configured, str) or not configured): + raise ValueError( + f"Config key 'profile' must be a non-empty string, got {configured!r}." + ) + selected = cli_value or os.environ.get("VP_PROFILE") or configured or DEFAULT_PROFILE if selected != DEFAULT_PROFILE: validate_profile_name(selected) if not profile_exists(selected): diff --git a/tests/test_profile_cmd.py b/tests/test_profile_cmd.py index 91cb7d1..d12c45e 100644 --- a/tests/test_profile_cmd.py +++ b/tests/test_profile_cmd.py @@ -79,6 +79,19 @@ def test_profile_remove_unknown(config_root: Path) -> None: assert result.exit_code != 0 +def test_profile_remove_validates_before_confirmation(config_root: Path) -> None: + # invalid/unknown names must error out without ever prompting + result = runner.invoke(app, ["profile", "remove", "missing"]) + assert result.exit_code == 1 + assert "does not exist" in result.output + assert "Remove profile" not in result.output + + result = runner.invoke(app, ["profile", "remove", ".."]) + assert result.exit_code == 1 + assert "Invalid profile name" in result.output + assert "Remove profile" not in result.output + + def test_profile_remove_asks_for_confirmation(config_root: Path) -> None: runner.invoke(app, ["profile", "create", "work"]) result = runner.invoke(app, ["profile", "remove", "work"], input="n\n") diff --git a/tests/test_profiles.py b/tests/test_profiles.py index e091957..6cd1538 100644 --- a/tests/test_profiles.py +++ b/tests/test_profiles.py @@ -105,6 +105,12 @@ def test_resolve_profile_unknown_raises(config_root: Path) -> None: resolve_profile("nope", {}) +def test_resolve_profile_rejects_non_string_config_value(config_root: Path) -> None: + for configured in (123, True, [], {}, ""): + with pytest.raises(ValueError, match="'profile' must be"): + resolve_profile(None, {"profile": configured}) + + def test_resolve_profile_rejects_traversal_names( config_root: Path, monkeypatch: pytest.MonkeyPatch ) -> None: From f9bb435bbe05672559d5058bfb6ab80228cae885 Mon Sep 17 00:00:00 2001 From: Harald Nezbeda Date: Thu, 30 Jul 2026 06:11:20 +0000 Subject: [PATCH 4/6] Address Codex review on PR #124 --- docs/profiles.md | 4 ++-- src/vibepod/commands/profile.py | 27 ++++++++++++++++++++------- src/vibepod/core/profiles.py | 16 ++++++++++------ tests/test_profile_cmd.py | 26 ++++++++++++++++++++++++++ tests/test_profiles.py | 11 +++++++++++ 5 files changed, 69 insertions(+), 15 deletions(-) diff --git a/docs/profiles.md b/docs/profiles.md index db2c161..686996a 100644 --- a/docs/profiles.md +++ b/docs/profiles.md @@ -24,8 +24,8 @@ profile — nothing moves when you start using profiles. ## Managing profiles ```bash -vp profile list # list profiles; * marks the active one, - # agents with stored credentials shown in parentheses +vp profile list # list profiles; * marks the active one, agents with + # stored data (config, caches, credentials) in parentheses vp profile create work # create an empty profile vp profile remove work # delete a profile and its credentials (asks first) ``` diff --git a/src/vibepod/commands/profile.py b/src/vibepod/commands/profile.py index 8d7dd23..4ad1f68 100644 --- a/src/vibepod/commands/profile.py +++ b/src/vibepod/commands/profile.py @@ -19,12 +19,13 @@ resolve_profile, validate_profile_name, ) -from vibepod.utils.console import console, error, success +from vibepod.utils.console import console, error, success, warning app = typer.Typer(help="Manage credential profiles (separate agent logins per environment)") -def _agents_with_credentials(profile: str) -> list[str]: +def _agents_with_data(profile: str) -> list[str]: + """Agents with any stored data (config, caches, credentials) in the profile.""" root = profile_agents_root(profile) found: list[str] = [] for agent in sorted(SUPPORTED_AGENTS): @@ -34,20 +35,22 @@ def _agents_with_credentials(profile: str) -> list[str]: return found -def _active_profile() -> str: +def _active_profile() -> str | None: + """Resolved active profile, or None when the current selection is broken.""" try: return resolve_profile(None, get_config()) - except ValueError: - return DEFAULT_PROFILE + except ValueError as exc: + warning(str(exc)) + return None @app.command("list") def list_() -> None: - """List profiles, the active one, and which agents have credentials.""" + """List profiles, the active one, and which agents have stored data.""" active = _active_profile() for name in list_profiles(): marker = "*" if name == active else " " - agents = ", ".join(_agents_with_credentials(name)) + agents = ", ".join(_agents_with_data(name)) suffix = f" ({agents})" if agents else "" console.print(f"{marker} {name}{suffix}") @@ -62,6 +65,9 @@ def create( except ValueError as exc: error(str(exc)) raise typer.Exit(code=1) from exc + except OSError as exc: + error(f"Could not create profile '{name}': {exc}") + raise typer.Exit(code=1) from exc success(f"Created profile '{name}' at {path.parent}") @@ -91,6 +97,13 @@ def remove( except ValueError as exc: error(str(exc)) raise typer.Exit(code=1) from exc + except OSError as exc: + error( + f"Could not remove profile '{name}': {exc}. The profile may be partially " + "deleted. Files created by agent containers can be owned by another user; " + "fix ownership (e.g. `sudo chown -R $USER ...`) and retry." + ) + raise typer.Exit(code=1) from exc if os.environ.get("VP_PROFILE") == name: console.print(f"Note: VP_PROFILE still points at removed profile '{name}'.") success(f"Removed profile '{name}'") diff --git a/src/vibepod/core/profiles.py b/src/vibepod/core/profiles.py index 86f422d..771440f 100644 --- a/src/vibepod/core/profiles.py +++ b/src/vibepod/core/profiles.py @@ -69,12 +69,16 @@ def remove_profile(name: str) -> None: def resolve_profile(cli_value: str | None, config: dict[str, Any]) -> str: """Resolve the active profile: CLI flag > VP_PROFILE > config key > default.""" - configured = config.get("profile") - if configured is not None and (not isinstance(configured, str) or not configured): - raise ValueError( - f"Config key 'profile' must be a non-empty string, got {configured!r}." - ) - selected = cli_value or os.environ.get("VP_PROFILE") or configured or DEFAULT_PROFILE + selected = cli_value or os.environ.get("VP_PROFILE") + if not selected: + # only validate the config value when it is actually the selected source, + # so a valid flag or VP_PROFILE can override a broken config key + configured = config.get("profile") + if configured is not None and (not isinstance(configured, str) or not configured): + raise ValueError( + f"Config key 'profile' must be a non-empty string, got {configured!r}." + ) + selected = configured or DEFAULT_PROFILE if selected != DEFAULT_PROFILE: validate_profile_name(selected) if not profile_exists(selected): diff --git a/tests/test_profile_cmd.py b/tests/test_profile_cmd.py index d12c45e..60b2571 100644 --- a/tests/test_profile_cmd.py +++ b/tests/test_profile_cmd.py @@ -92,6 +92,32 @@ def test_profile_remove_validates_before_confirmation(config_root: Path) -> None assert "Remove profile" not in result.output +def test_profile_list_broken_selection_shows_warning_not_default( + config_root: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("VP_PROFILE", "ghost") + result = runner.invoke(app, ["profile", "list"]) + assert result.exit_code == 0 + assert "ghost" in result.output + assert "* default" not in result.output + + +def test_profile_remove_reports_filesystem_errors( + config_root: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + runner.invoke(app, ["profile", "create", "work"]) + + def broken_rmtree(path: object) -> None: + raise PermissionError(13, "Permission denied", str(path)) + + monkeypatch.setattr("vibepod.core.profiles.shutil.rmtree", broken_rmtree) + result = runner.invoke(app, ["profile", "remove", "work", "--yes"]) + assert result.exit_code == 1 + assert not isinstance(result.exception, OSError) # handled, no traceback + assert "work" in result.output + assert "Permission denied" in result.output + + def test_profile_remove_asks_for_confirmation(config_root: Path) -> None: runner.invoke(app, ["profile", "create", "work"]) result = runner.invoke(app, ["profile", "remove", "work"], input="n\n") diff --git a/tests/test_profiles.py b/tests/test_profiles.py index 6cd1538..1527d7f 100644 --- a/tests/test_profiles.py +++ b/tests/test_profiles.py @@ -111,6 +111,17 @@ def test_resolve_profile_rejects_non_string_config_value(config_root: Path) -> N resolve_profile(None, {"profile": configured}) +def test_resolve_profile_higher_priority_overrides_bad_config_value( + config_root: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # first-match precedence: a valid flag or env var must win over an + # invalid lower-priority config value instead of erroring on it + create_profile("work") + assert resolve_profile("work", {"profile": 123}) == "work" + monkeypatch.setenv("VP_PROFILE", "work") + assert resolve_profile(None, {"profile": 123}) == "work" + + def test_resolve_profile_rejects_traversal_names( config_root: Path, monkeypatch: pytest.MonkeyPatch ) -> None: From 14d764f66cd3228d873ae6f305b976107c78f509 Mon Sep 17 00:00:00 2001 From: Harald Nezbeda Date: Sat, 1 Aug 2026 09:26:03 +0000 Subject: [PATCH 5/6] Wire profiles into herdr doctor and apply pre-commit formatting --- src/vibepod/commands/doctor.py | 18 +++++++++++---- src/vibepod/commands/profile.py | 6 ++--- src/vibepod/core/profiles.py | 6 ++--- tests/test_doctor.py | 40 ++++++++++++++++++--------------- tests/test_profile_cmd.py | 6 +++-- tests/test_profile_plumbing.py | 28 +++++++++++++++++++++-- tests/test_profiles.py | 10 ++++----- 7 files changed, 76 insertions(+), 38 deletions(-) diff --git a/src/vibepod/commands/doctor.py b/src/vibepod/commands/doctor.py index ab98712..62d5a31 100644 --- a/src/vibepod/commands/doctor.py +++ b/src/vibepod/commands/doctor.py @@ -284,7 +284,7 @@ def _herdr_log_relpath(agent: str) -> str | None: }.get(agent) -def _herdr_agent_summary() -> None: +def _herdr_agent_summary(profile: str) -> None: """One line per supported agent: integration, injection, registration, activity.""" from rich.table import Table @@ -312,7 +312,7 @@ def _herdr_agent_summary() -> None: else: integration = "none" - cfg_dir = agent_config_dir(name) + cfg_dir = agent_config_dir(name, profile) dests = [dest for _, dest in builtin] + [ str(entry.get("dest")) for entry in custom_entries if isinstance(entry, dict) ] @@ -359,6 +359,10 @@ def herdr_doctor( str | None, typer.Argument(help="Agent to inspect in depth; omit for an all-agents summary"), ] = None, + profile: Annotated[ + str | None, + typer.Option("--profile", help="Credential profile to inspect (see `vp profile list`)"), + ] = None, ) -> None: """Diagnose herdr terminal-multiplexer wiring end to end. @@ -383,6 +387,12 @@ def herdr_doctor( raise typer.Exit(1) agent = resolved + try: + active_profile = resolve_profile(profile, get_config()) + except ValueError as exc: + error(str(exc)) + raise typer.Exit(1) from exc + failures = 0 console.print("[bold]Pane environment[/bold]") @@ -431,7 +441,7 @@ def herdr_doctor( if agent is None: console.print() - _herdr_agent_summary() + _herdr_agent_summary(active_profile) if failures: error(f"{failures} problem(s) found") raise typer.Exit(1) @@ -482,7 +492,7 @@ def herdr_doctor( console.print() console.print(f"[bold]Injected files ({agent})[/bold]") - cfg_dir = agent_config_dir(agent) + cfg_dir = agent_config_dir(agent, active_profile) entries = herdr_core.BUILTIN_INTEGRATIONS.get(agent, []) if not entries: console.print(" (no built-in integration for this agent)") diff --git a/src/vibepod/commands/profile.py b/src/vibepod/commands/profile.py index 4ad1f68..03b96be 100644 --- a/src/vibepod/commands/profile.py +++ b/src/vibepod/commands/profile.py @@ -89,9 +89,7 @@ def remove( error(f"Profile '{name}' does not exist.") raise typer.Exit(code=1) if not yes: - typer.confirm( - f"Remove profile '{name}' and all credentials stored in it?", abort=True - ) + typer.confirm(f"Remove profile '{name}' and all credentials stored in it?", abort=True) try: remove_profile(name) except ValueError as exc: @@ -101,7 +99,7 @@ def remove( error( f"Could not remove profile '{name}': {exc}. The profile may be partially " "deleted. Files created by agent containers can be owned by another user; " - "fix ownership (e.g. `sudo chown -R $USER ...`) and retry." + "fix ownership (e.g. `sudo chown -R $USER ...`) and retry.", ) raise typer.Exit(code=1) from exc if os.environ.get("VP_PROFILE") == name: diff --git a/src/vibepod/core/profiles.py b/src/vibepod/core/profiles.py index 771440f..7338867 100644 --- a/src/vibepod/core/profiles.py +++ b/src/vibepod/core/profiles.py @@ -18,7 +18,7 @@ def validate_profile_name(name: str) -> None: if not PROFILE_NAME_PATTERN.fullmatch(name): raise ValueError( f"Invalid profile name '{name}': use lowercase letters, digits, '-' and '_', " - "starting with a lowercase letter or digit." + "starting with a lowercase letter or digit.", ) @@ -76,13 +76,13 @@ def resolve_profile(cli_value: str | None, config: dict[str, Any]) -> str: configured = config.get("profile") if configured is not None and (not isinstance(configured, str) or not configured): raise ValueError( - f"Config key 'profile' must be a non-empty string, got {configured!r}." + f"Config key 'profile' must be a non-empty string, got {configured!r}.", ) selected = configured or DEFAULT_PROFILE if selected != DEFAULT_PROFILE: validate_profile_name(selected) if not profile_exists(selected): raise ValueError( - f"Profile '{selected}' does not exist. Create it with: vp profile create {selected}" + f"Profile '{selected}' does not exist. Create it with: vp profile create {selected}", ) return selected diff --git a/tests/test_doctor.py b/tests/test_doctor.py index d57d084..4b972b1 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -24,7 +24,8 @@ def test_doctor_missing_dir(tmp_path: Path, monkeypatch) -> None: def test_doctor_valid_token(tmp_path: Path, monkeypatch) -> None: monkeypatch.setattr( - "vibepod.commands.doctor.agent_config_dir", lambda _agent, _profile="default": tmp_path + "vibepod.commands.doctor.agent_config_dir", + lambda _agent, _profile="default": tmp_path, ) future_ms = int((time.time() + 3600) * 1000) (tmp_path / ".credentials.json").write_text( @@ -35,9 +36,9 @@ def test_doctor_valid_token(tmp_path: Path, monkeypatch) -> None: "refreshToken": "r", "expiresAt": future_ms, "scopes": ["user:inference"], - } - } - ) + }, + }, + ), ) result = runner.invoke(app, ["doctor", "claude"]) assert result.exit_code == 0 @@ -47,7 +48,8 @@ def test_doctor_valid_token(tmp_path: Path, monkeypatch) -> None: def test_doctor_expired_token(tmp_path: Path, monkeypatch) -> None: monkeypatch.setattr( - "vibepod.commands.doctor.agent_config_dir", lambda _agent, _profile="default": tmp_path + "vibepod.commands.doctor.agent_config_dir", + lambda _agent, _profile="default": tmp_path, ) monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) @@ -59,9 +61,9 @@ def test_doctor_expired_token(tmp_path: Path, monkeypatch) -> None: "accessToken": "a", "refreshToken": "r", "expiresAt": past_ms, - } - } - ) + }, + }, + ), ) result = runner.invoke(app, ["doctor", "claude"]) assert result.exit_code == 2 @@ -71,15 +73,14 @@ def test_doctor_expired_token(tmp_path: Path, monkeypatch) -> None: def test_doctor_expired_creds_but_stored_token_is_ok(tmp_path: Path, monkeypatch) -> None: """Expired credentials.json should NOT exit 2 when a stored token covers auth.""" monkeypatch.setattr( - "vibepod.commands.doctor.agent_config_dir", lambda _agent, _profile="default": tmp_path + "vibepod.commands.doctor.agent_config_dir", + lambda _agent, _profile="default": tmp_path, ) monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) past_ms = int((time.time() - 3600) * 1000) (tmp_path / ".credentials.json").write_text( - json.dumps( - {"claudeAiOauth": {"accessToken": "a", "expiresAt": past_ms}} - ) + json.dumps({"claudeAiOauth": {"accessToken": "a", "expiresAt": past_ms}}), ) (tmp_path / "oauth-token").write_text("sk-stored\n", encoding="utf-8") result = runner.invoke(app, ["doctor", "claude"]) @@ -89,7 +90,8 @@ def test_doctor_expired_creds_but_stored_token_is_ok(tmp_path: Path, monkeypatch def test_doctor_missing_refresh_token(tmp_path: Path, monkeypatch) -> None: monkeypatch.setattr( - "vibepod.commands.doctor.agent_config_dir", lambda _agent, _profile="default": tmp_path + "vibepod.commands.doctor.agent_config_dir", + lambda _agent, _profile="default": tmp_path, ) future_ms = int((time.time() + 3600) * 1000) (tmp_path / ".credentials.json").write_text( @@ -98,9 +100,9 @@ def test_doctor_missing_refresh_token(tmp_path: Path, monkeypatch) -> None: "claudeAiOauth": { "accessToken": "a", "expiresAt": future_ms, - } - } - ) + }, + }, + ), ) result = runner.invoke(app, ["doctor", "claude"]) assert result.exit_code == 0 @@ -109,7 +111,8 @@ def test_doctor_missing_refresh_token(tmp_path: Path, monkeypatch) -> None: def test_doctor_reports_stored_token_mode(tmp_path: Path, monkeypatch) -> None: monkeypatch.setattr( - "vibepod.commands.doctor.agent_config_dir", lambda _agent, _profile="default": tmp_path + "vibepod.commands.doctor.agent_config_dir", + lambda _agent, _profile="default": tmp_path, ) monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) @@ -121,7 +124,8 @@ def test_doctor_reports_stored_token_mode(tmp_path: Path, monkeypatch) -> None: def test_doctor_reports_host_env_mode(tmp_path: Path, monkeypatch) -> None: monkeypatch.setattr( - "vibepod.commands.doctor.agent_config_dir", lambda _agent, _profile="default": tmp_path + "vibepod.commands.doctor.agent_config_dir", + lambda _agent, _profile="default": tmp_path, ) monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "host-token-abc") diff --git a/tests/test_profile_cmd.py b/tests/test_profile_cmd.py index 60b2571..ae46716 100644 --- a/tests/test_profile_cmd.py +++ b/tests/test_profile_cmd.py @@ -93,7 +93,8 @@ def test_profile_remove_validates_before_confirmation(config_root: Path) -> None def test_profile_list_broken_selection_shows_warning_not_default( - config_root: Path, monkeypatch: pytest.MonkeyPatch + config_root: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setenv("VP_PROFILE", "ghost") result = runner.invoke(app, ["profile", "list"]) @@ -103,7 +104,8 @@ def test_profile_list_broken_selection_shows_warning_not_default( def test_profile_remove_reports_filesystem_errors( - config_root: Path, monkeypatch: pytest.MonkeyPatch + config_root: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: runner.invoke(app, ["profile", "create", "work"]) diff --git a/tests/test_profile_plumbing.py b/tests/test_profile_plumbing.py index 9b7beaf..ff3e672 100644 --- a/tests/test_profile_plumbing.py +++ b/tests/test_profile_plumbing.py @@ -44,7 +44,8 @@ def test_profile_remove_rejects_traversal_name(config_root: Path) -> None: def test_run_unknown_profile_via_vp_profile_env( - config_root: Path, monkeypatch: pytest.MonkeyPatch + config_root: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setenv("VP_PROFILE", "ghost") result = runner.invoke(app, ["run", "claude"]) @@ -53,7 +54,8 @@ def test_run_unknown_profile_via_vp_profile_env( def test_run_uses_profile_config_dir_for_stored_token( - config_root: Path, monkeypatch: pytest.MonkeyPatch + config_root: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: create_profile("work") seen: dict[str, Path] = {} @@ -92,3 +94,25 @@ def test_doctor_claude_unknown_profile_fails(config_root: Path) -> None: result = runner.invoke(app, ["doctor", "claude", "--profile", "nope"]) assert result.exit_code == 1 assert "vp profile create nope" in result.output + + +def test_doctor_herdr_unknown_profile_fails(config_root: Path) -> None: + result = runner.invoke(app, ["doctor", "herdr", "--profile", "nope"]) + assert result.exit_code == 1 + assert "vp profile create nope" in result.output + + +def test_doctor_herdr_summary_uses_profile_dirs( + config_root: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + create_profile("work") + seen: set[str] = set() + + def fake_config_dir(agent: str, profile: str = "default") -> Path: + seen.add(profile) + return config_root / "x" / agent + + monkeypatch.setattr("vibepod.commands.doctor.agent_config_dir", fake_config_dir) + runner.invoke(app, ["doctor", "herdr", "--profile", "work"]) + assert seen == {"work"} diff --git a/tests/test_profiles.py b/tests/test_profiles.py index 1527d7f..f582574 100644 --- a/tests/test_profiles.py +++ b/tests/test_profiles.py @@ -84,9 +84,7 @@ def test_remove_profile(config_root: Path) -> None: remove_profile("missing") -def test_resolve_profile_precedence( - config_root: Path, monkeypatch: pytest.MonkeyPatch -) -> None: +def test_resolve_profile_precedence(config_root: Path, monkeypatch: pytest.MonkeyPatch) -> None: create_profile("flagged") create_profile("envvar") create_profile("configured") @@ -112,7 +110,8 @@ def test_resolve_profile_rejects_non_string_config_value(config_root: Path) -> N def test_resolve_profile_higher_priority_overrides_bad_config_value( - config_root: Path, monkeypatch: pytest.MonkeyPatch + config_root: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: # first-match precedence: a valid flag or env var must win over an # invalid lower-priority config value instead of erroring on it @@ -123,7 +122,8 @@ def test_resolve_profile_higher_priority_overrides_bad_config_value( def test_resolve_profile_rejects_traversal_names( - config_root: Path, monkeypatch: pytest.MonkeyPatch + config_root: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: for name in ("..", "../../tmp", "a/b"): with pytest.raises(ValueError, match="Invalid profile name"): From c71c4b02acc0be27793e4d1edcb82d3e6f5a4e15 Mon Sep 17 00:00:00 2001 From: Harald Nezbeda Date: Sat, 1 Aug 2026 13:18:48 +0000 Subject: [PATCH 6/6] Address CodeRabbit review on PR #124 --- docs/agents/index.md | 2 +- docs/profiles.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/agents/index.md b/docs/agents/index.md index 70e1ad3..5ba0f9e 100644 --- a/docs/agents/index.md +++ b/docs/agents/index.md @@ -1,6 +1,6 @@ # Agents -VibePod manages each agent as a Docker or Podman container. Credentials and config are persisted to `~/.config/vibepod/agents//` on your host and mounted into the container on every run, so you only need to authenticate once. To keep several credential sets per agent (subscription vs. API key vs. Ollama), see [Credential Profiles](../profiles.md). +VibePod manages each agent as a Docker or Podman container. Credentials and config are persisted on your host — at `~/.config/vibepod/agents//` for the `default` profile — and mounted into the container on every run, so you only need to authenticate once. To keep several credential sets per agent (subscription vs. API key vs. Ollama, stored under `~/.config/vibepod/profiles//`), see [Credential Profiles](../profiles.md). ## Supported Agents diff --git a/docs/profiles.md b/docs/profiles.md index 686996a..1906386 100644 --- a/docs/profiles.md +++ b/docs/profiles.md @@ -12,7 +12,7 @@ via a project config (see below). ## Layout -``` +```text ~/.config/vibepod/ agents// # the built-in "default" profile profiles//agents// # named profiles