diff --git a/docs/agents/index.md b/docs/agents/index.md index 4fd4b9b..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. +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/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..1906386 --- /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 + +```text +~/.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 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) +``` + +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..62d5a31 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}") @@ -272,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 @@ -300,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) ] @@ -347,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. @@ -371,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]") @@ -419,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) @@ -470,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 new file mode 100644 index 0000000..03b96be --- /dev/null +++ b/src/vibepod/commands/profile.py @@ -0,0 +1,107 @@ +"""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, + profile_exists, + remove_profile, + resolve_profile, + validate_profile_name, +) +from vibepod.utils.console import console, error, success, warning + +app = typer.Typer(help="Manage credential profiles (separate agent logins per environment)") + + +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): + agent_dir = root / agent + if agent_dir.is_dir() and any(agent_dir.iterdir()): + found.append(agent) + return found + + +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 as exc: + warning(str(exc)) + return None + + +@app.command("list") +def list_() -> None: + """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_data(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 + 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}") + + +@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) + 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) + try: + remove_profile(name) + 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/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..7338867 --- /dev/null +++ b/src/vibepod/core/profiles.py @@ -0,0 +1,88 @@ +"""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 lowercase 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.") + validate_profile_name(name) + 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.""" + 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): + 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..4b972b1 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 @@ -25,7 +25,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, + lambda _agent, _profile="default": tmp_path, ) future_ms = int((time.time() + 3600) * 1000) (tmp_path / ".credentials.json").write_text( @@ -49,7 +49,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, + lambda _agent, _profile="default": tmp_path, ) monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) @@ -74,15 +74,13 @@ def test_doctor_expired_creds_but_stored_token_is_ok(tmp_path: Path, monkeypatch """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, + 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"]) @@ -93,7 +91,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, + lambda _agent, _profile="default": tmp_path, ) future_ms = int((time.time() + 3600) * 1000) (tmp_path / ".credentials.json").write_text( @@ -114,7 +112,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, + lambda _agent, _profile="default": tmp_path, ) monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) @@ -127,7 +125,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, + 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..ae46716 --- /dev/null +++ b/tests/test_profile_cmd.py @@ -0,0 +1,127 @@ +"""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_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_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") + 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..ff3e672 --- /dev/null +++ b/tests/test_profile_plumbing.py @@ -0,0 +1,118 @@ +"""--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_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: + 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 + + +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 new file mode 100644 index 0000000..f582574 --- /dev/null +++ b/tests/test_profiles.py @@ -0,0 +1,141 @@ +"""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", {}) + + +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_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: + 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()