diff --git a/dev-notes/architecture/pi-config-import.md b/dev-notes/architecture/pi-config-import.md new file mode 100644 index 000000000..766752d56 --- /dev/null +++ b/dev-notes/architecture/pi-config-import.md @@ -0,0 +1,42 @@ +# Pi configuration import + +Tau can now import a best-effort subset of an existing Pi configuration into Tau's provider settings. + +## What was added + +- `tau config import-pi ` imports provider/model settings from a Pi JSON or TOML config file. +- `--dry-run` prints the resulting Tau `providers.json` payload without writing. +- `--yes` allows updating an existing `~/.tau/providers.json`; without it, existing provider settings are protected. +- `--default-pi-config` searches common Pi locations such as `~/.pi/config.json` and `~/.config/pi/config.toml`. + +## Mapping decisions + +The importer lives in `tau_coding` because it deals with CLI behavior and user-level config files. The portable `tau_agent` package remains independent of Pi/Tau filesystem conventions. + +Supported import fields are intentionally conservative: + +- provider name (`provider`, `default_provider`, provider table/list names) +- model (`model`, `default_model`, `defaultModel`) +- base URL (`base_url`, `baseUrl`, `baseURL`, `api_base`) +- API key env var (`api_key_env`, `apiKeyEnv`, `api_key_env_var`) + +Raw API keys are not copied into Tau. When the Pi config contains a raw key, Tau warns and records only the expected environment variable reference. Unknown fields also generate warnings so users can decide whether to migrate them manually. + +## How it maps to Pi + +Pi and Tau share provider/model configuration concepts, but Tau splits durable provider metadata (`catalog.toml`) from runtime preferences (`providers.json`). The importer writes through Tau's existing provider settings helpers so custom provider definitions are persisted using the same path as `tau setup` and provider/model picker updates. + +## How to test + +```bash +uv run pytest tests/test_pi_config_import.py +uv run pytest +uv run ruff check . +uv run mypy +``` + +Manual dry run example: + +```bash +tau --dry-run config import-pi ~/.pi/config.json +``` diff --git a/src/tau_coding/cli.py b/src/tau_coding/cli.py index d98cd03bb..c8826132a 100644 --- a/src/tau_coding/cli.py +++ b/src/tau_coding/cli.py @@ -22,6 +22,12 @@ from tau_coding import __version__ from tau_coding.catalog_loader import user_catalog_path from tau_coding.credentials import FileCredentialStore +from tau_coding.pi_config_import import ( + PiConfigImportError, + default_pi_config_path, + import_pi_config, + plan_pi_config_import, +) from tau_coding.provider_config import ( DEFAULT_MODEL, DEFAULT_PROVIDER_NAME, @@ -129,6 +135,40 @@ def setup_command( typer.echo(f"Set {provider.api_key_env} before running Tau with this provider.", err=True) +def import_pi_config_command( + source: Path | None, + *, + use_default: bool = False, + dry_run: bool = False, + yes: bool = False, +) -> None: + """Import a Pi provider/model config into Tau.""" + resolved_source = source + if resolved_source is None: + if not use_default: + raise RuntimeError("Usage: tau config import-pi [--dry-run] [--yes]") + resolved_source = default_pi_config_path() + if resolved_source is None: + raise RuntimeError("No Pi config found in a default location") + + if dry_run: + plan = plan_pi_config_import(resolved_source) + typer.echo(plan.to_json_text(), nl=False) + _render_pi_import_warnings(plan.warnings) + return + + plan, path = import_pi_config(resolved_source, overwrite_existing=yes) + typer.echo( + f"Imported Pi providers {', '.join(plan.imported_providers)} from {plan.source} to {path}" + ) + _render_pi_import_warnings(plan.warnings) + + +def _render_pi_import_warnings(warnings: tuple[str, ...]) -> None: + for warning in warnings: + typer.echo(f"Warning: {warning}", err=True) + + @app.callback(invoke_without_command=True) def main( ctx: typer.Context, @@ -178,6 +218,18 @@ def main( bool, typer.Option("--set-default/--no-set-default", help="Make setup provider the default."), ] = True, + config_import_default: Annotated[ + bool, + typer.Option("--default-pi-config", help="Import from the first known Pi config path."), + ] = False, + config_import_dry_run: Annotated[ + bool, + typer.Option("--dry-run", help="Print imported Tau config without writing."), + ] = False, + config_import_yes: Annotated[ + bool, + typer.Option("--yes", "-y", help="Update existing Tau provider settings."), + ] = False, cwd: Annotated[ Path | None, typer.Option("--cwd", help="Working directory for built-in coding tools."), @@ -259,6 +311,19 @@ def main( ) raise typer.Exit() + if prompt_option is None and command == "config": + try: + config_source = _parse_config_import_cli_args(positional_args[1:]) + import_pi_config_command( + config_source, + use_default=config_import_default, + dry_run=config_import_dry_run, + yes=config_import_yes, + ) + except (RuntimeError, PiConfigImportError) as exc: + raise typer.BadParameter(str(exc)) from exc + raise typer.Exit() + if prompt_option is None: notice = _startup_update_notice() try: @@ -366,6 +431,18 @@ async def export_session_command( ) +def _parse_config_import_cli_args(args: list[str]) -> Path | None: + if not args: + raise RuntimeError("Usage: tau config import-pi [--dry-run] [--yes]") + if args[0] != "import-pi": + raise RuntimeError("Usage: tau config import-pi [--dry-run] [--yes]") + if len(args) == 1: + return None + if len(args) == 2: + return Path(args[1]).expanduser() + raise RuntimeError("Usage: tau config import-pi [--dry-run] [--yes]") + + def _parse_export_cli_args(args: list[str]) -> tuple[str, Path | None, str | None]: if not args: raise RuntimeError("Usage: tau export [--format html|jsonl] [output]") diff --git a/src/tau_coding/pi_config_import.py b/src/tau_coding/pi_config_import.py new file mode 100644 index 000000000..ddedf2619 --- /dev/null +++ b/src/tau_coding/pi_config_import.py @@ -0,0 +1,304 @@ +"""Import best-effort Pi configuration into Tau's provider settings.""" + +from __future__ import annotations + +import json +import tomllib +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from tau_ai import DEFAULT_ANTHROPIC_BASE_URL +from tau_ai.env import DEFAULT_OPENAI_COMPATIBLE_BASE_URL +from tau_coding.paths import TauPaths +from tau_coding.provider_config import ( + AnthropicProviderConfig, + OpenAICompatibleProviderConfig, + ProviderConfig, + ProviderSettings, + load_provider_settings, + provider_settings_path, + save_provider_settings, + set_default_provider_model, + upsert_provider, +) + +PI_DEFAULT_CONFIG_CANDIDATES = ( + Path("~/.pi/config.json"), + Path("~/.pi/config.toml"), + Path("~/.config/pi/config.json"), + Path("~/.config/pi/config.toml"), +) + +_PROVIDER_ALIASES = { + "anthropic": "anthropic", + "claude": "anthropic", + "openai": "openai", + "openai-compatible": "openai", + "openai_compatible": "openai", +} + +_API_KEY_ENV_BY_PROVIDER = { + "anthropic": "ANTHROPIC_API_KEY", + "openai": "OPENAI_API_KEY", +} + +_BASE_URL_BY_PROVIDER = { + "anthropic": DEFAULT_ANTHROPIC_BASE_URL, + "openai": DEFAULT_OPENAI_COMPATIBLE_BASE_URL, +} + +_DEFAULT_MODEL_BY_PROVIDER = { + "anthropic": "claude-sonnet-4-6", + "openai": "gpt-5.4", +} + + +class PiConfigImportError(ValueError): + """Raised when Pi configuration cannot be imported.""" + + +@dataclass(frozen=True, slots=True) +class PiConfigImportPlan: + """A dry-run friendly import result.""" + + source: Path + settings: ProviderSettings + warnings: tuple[str, ...] = () + imported_providers: tuple[str, ...] = () + + def to_json_text(self) -> str: + """Return the Tau providers.json payload this import would write.""" + return json.dumps(self.settings.to_json(), indent=2, sort_keys=True) + "\n" + + +@dataclass(slots=True) +class _ImportState: + warnings: list[str] = field(default_factory=list) + imported_providers: list[str] = field(default_factory=list) + + +def default_pi_config_path() -> Path | None: + """Return the first existing known Pi config path, if any.""" + for candidate in PI_DEFAULT_CONFIG_CANDIDATES: + path = candidate.expanduser() + if path.exists(): + return path + return None + + +def plan_pi_config_import( + source: Path, + *, + paths: TauPaths | None = None, + base_settings: ProviderSettings | None = None, +) -> PiConfigImportPlan: + """Build the Tau provider settings produced by importing a Pi config.""" + resolved_paths = paths or TauPaths() + resolved_source = _resolve_source(source) + raw = _load_config_object(resolved_source) + state = _ImportState() + providers = _providers_from_pi_config(raw, state) + if not providers: + raise PiConfigImportError("Pi config did not contain an importable provider/model") + + settings = base_settings or load_provider_settings(resolved_paths) + default_provider = _optional_string(_first_present(raw, "provider", "default_provider")) + default_provider = _normalize_provider_name(default_provider) if default_provider else None + default_model = _optional_string(_first_present(raw, "model", "default_model")) + + updated = settings + for provider in providers: + set_default = provider.name == default_provider or len(providers) == 1 + updated = upsert_provider(updated, provider, set_default=set_default) + updated = set_default_provider_model( + updated, + provider_name=provider.name, + model=provider.default_model, + ) + state.imported_providers.append(provider.name) + + if default_provider is not None and default_provider not in {item.name for item in providers}: + state.warnings.append(f"Default provider {default_provider!r} was not importable") + if default_model is not None and default_provider is None and len(providers) != 1: + state.warnings.append("Default model was present but no unambiguous provider was found") + + return PiConfigImportPlan( + source=resolved_source, + settings=updated, + warnings=tuple(state.warnings), + imported_providers=tuple(dict.fromkeys(state.imported_providers)), + ) + + +def import_pi_config( + source: Path, + *, + paths: TauPaths | None = None, + overwrite_existing: bool = False, +) -> tuple[PiConfigImportPlan, Path]: + """Import Pi config and persist Tau provider settings. + + Existing ``providers.json`` is protected unless ``overwrite_existing`` is true. + """ + resolved_paths = paths or TauPaths() + destination = provider_settings_path(resolved_paths) + if destination.exists() and not overwrite_existing: + raise PiConfigImportError( + f"Tau provider settings already exist at {destination}; rerun with --yes to update them" + ) + plan = plan_pi_config_import(source, paths=resolved_paths) + written = save_provider_settings(plan.settings, resolved_paths) + return plan, written + + +def _resolve_source(source: Path) -> Path: + path = source.expanduser() + if path.is_dir(): + for name in ("config.json", "config.toml", "settings.json", "settings.toml"): + candidate = path / name + if candidate.exists(): + return candidate + raise PiConfigImportError( + f"Pi config directory does not contain a supported config file: {path}" + ) + if not path.exists(): + raise PiConfigImportError(f"Pi config does not exist: {path}") + return path + + +def _load_config_object(path: Path) -> dict[str, Any]: + text = path.read_text(encoding="utf-8") + try: + data = tomllib.loads(text) if path.suffix.lower() == ".toml" else json.loads(text) + except (tomllib.TOMLDecodeError, json.JSONDecodeError) as exc: + raise PiConfigImportError(f"Invalid Pi config file {path}: {exc}") from exc + if not isinstance(data, dict): + raise PiConfigImportError("Pi config root must be an object") + return data + + +def _providers_from_pi_config(data: dict[str, Any], state: _ImportState) -> list[ProviderConfig]: + raw_providers = data.get("providers") + providers: list[ProviderConfig] = [] + if isinstance(raw_providers, list): + for item in raw_providers: + if isinstance(item, dict): + provider = _provider_from_mapping(item, state) + if provider is not None: + providers.append(provider) + else: + state.warnings.append("Skipped non-object entry in Pi providers list") + elif isinstance(raw_providers, dict): + for name, item in raw_providers.items(): + if isinstance(item, dict): + provider = _provider_from_mapping({"name": name, **item}, state) + if provider is not None: + providers.append(provider) + else: + state.warnings.append(f"Skipped non-object Pi provider entry: {name}") + + root_provider = _provider_from_mapping(data, state, allow_missing_name=True) + if root_provider is not None and root_provider.name not in { + provider.name for provider in providers + }: + providers.append(root_provider) + + for key in sorted(set(data) - _KNOWN_ROOT_KEYS): + state.warnings.append(f"Pi config field was not imported: {key}") + return providers + + +def _provider_from_mapping( + data: dict[str, Any], + state: _ImportState, + *, + allow_missing_name: bool = False, +) -> ProviderConfig | None: + provider_name = _optional_string(_first_present(data, "name", "provider", "id")) + if provider_name is None: + if not allow_missing_name: + state.warnings.append("Skipped Pi provider without a name") + return None + provider_name = _normalize_provider_name(provider_name) + + model = _optional_string(_first_present(data, "model", "default_model", "defaultModel")) + if model is None: + model = _DEFAULT_MODEL_BY_PROVIDER.get(provider_name) + state.warnings.append(f"Provider {provider_name!r} had no model; using {model!r}") + if model is None: + state.warnings.append(f"Skipped provider {provider_name!r} because it has no model") + return None + + base_url = _optional_string(_first_present(data, "base_url", "baseURL", "baseUrl", "api_base")) + base_url = ( + base_url or _BASE_URL_BY_PROVIDER.get(provider_name, DEFAULT_OPENAI_COMPATIBLE_BASE_URL) + ).rstrip("/") + api_key_env = _optional_string( + _first_present(data, "api_key_env", "apiKeyEnv", "api_key_env_var") + ) + api_key_env = api_key_env or _API_KEY_ENV_BY_PROVIDER.get(provider_name, "OPENAI_API_KEY") + + if _first_present(data, "api_key", "apiKey", "key") is not None: + state.warnings.append( + f"Provider {provider_name!r} includes a raw API key; " + f"Tau imported only env var {api_key_env}" + ) + + if provider_name == "anthropic": + return AnthropicProviderConfig( + name="anthropic", + base_url=base_url, + api_key_env=api_key_env, + credential_name="anthropic", + models=(model,), + default_model=model, + ) + return OpenAICompatibleProviderConfig( + name=provider_name, + base_url=base_url, + api_key_env=api_key_env, + credential_name=None if provider_name == "openai" else provider_name, + models=(model,), + default_model=model, + ) + + +def _normalize_provider_name(value: str) -> str: + normalized = value.strip().lower().replace("_", "-") + return _PROVIDER_ALIASES.get(normalized, normalized) + + +def _first_present(data: dict[str, object], *keys: str) -> object | None: + for key in keys: + value = data.get(key) + if value is not None: + return value + return None + + +def _optional_string(value: object | None) -> str | None: + if isinstance(value, str) and value.strip(): + return value.strip() + return None + + +_KNOWN_ROOT_KEYS = { + "apiKey", + "api_key", + "api_key_env", + "api_key_env_var", + "api_base", + "baseURL", + "baseUrl", + "base_url", + "defaultModel", + "default_model", + "default_provider", + "id", + "key", + "model", + "name", + "provider", + "providers", +} diff --git a/tests/test_pi_config_import.py b/tests/test_pi_config_import.py new file mode 100644 index 000000000..79a7b081a --- /dev/null +++ b/tests/test_pi_config_import.py @@ -0,0 +1,152 @@ +import json +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from tau_coding.cli import app +from tau_coding.paths import TauPaths +from tau_coding.pi_config_import import PiConfigImportError, plan_pi_config_import +from tau_coding.provider_config import load_provider_settings + + +def test_plan_pi_config_import_maps_root_provider(tmp_path: Path) -> None: + pi_config = tmp_path / "config.json" + pi_config.write_text( + json.dumps( + { + "provider": "anthropic", + "model": "claude-3-5-sonnet-latest", + "api_key": "secret", + "unknown": True, + } + ), + encoding="utf-8", + ) + + plan = plan_pi_config_import( + pi_config, + paths=TauPaths(home=tmp_path / ".tau", agents_home=tmp_path / ".agents"), + ) + + provider = plan.settings.get_provider("anthropic") + assert plan.imported_providers == ("anthropic",) + assert plan.settings.default_provider == "anthropic" + assert provider.default_model == "claude-3-5-sonnet-latest" + assert provider.api_key_env == "ANTHROPIC_API_KEY" + assert any("raw API key" in warning for warning in plan.warnings) + assert "Pi config field was not imported: unknown" in plan.warnings + + +def test_plan_pi_config_import_maps_provider_collection(tmp_path: Path) -> None: + pi_config = tmp_path / "config.toml" + pi_config.write_text( + """ +default_provider = "local" + +[providers.local] +base_url = "http://localhost:11434/v1/" +api_key_env = "LOCAL_API_KEY" +default_model = "qwen-coder" +""", + encoding="utf-8", + ) + + plan = plan_pi_config_import( + pi_config, + paths=TauPaths(home=tmp_path / ".tau", agents_home=tmp_path / ".agents"), + ) + + provider = plan.settings.get_provider("local") + assert provider.base_url == "http://localhost:11434/v1" + assert provider.api_key_env == "LOCAL_API_KEY" + assert provider.default_model == "qwen-coder" + assert plan.settings.default_provider == "local" + + +def test_plan_pi_config_import_rejects_missing_provider(tmp_path: Path) -> None: + pi_config = tmp_path / "config.json" + pi_config.write_text(json.dumps({"theme": "dark"}), encoding="utf-8") + + with pytest.raises(PiConfigImportError, match="importable provider"): + plan_pi_config_import( + pi_config, + paths=TauPaths(home=tmp_path / ".tau", agents_home=tmp_path / ".agents"), + ) + + +def test_config_import_pi_dry_run_does_not_write( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setenv("HOME", str(tmp_path)) + pi_config = tmp_path / "pi.json" + pi_config.write_text( + json.dumps({"provider": "openai", "model": "gpt-4.1", "api_key_env": "OPENAI_KEY"}), + encoding="utf-8", + ) + + result = CliRunner().invoke(app, ["--dry-run", "config", "import-pi", str(pi_config)]) + + assert result.exit_code == 0 + assert '"default_provider": "openai"' in result.stdout + assert '"default_model": "gpt-4.1"' in result.stdout + assert not (tmp_path / ".tau" / "providers.json").exists() + + +def test_config_import_pi_writes_settings(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setenv("HOME", str(tmp_path)) + pi_config = tmp_path / "pi.json" + pi_config.write_text( + json.dumps({"provider": "openai", "model": "gpt-4.1", "api_key_env": "OPENAI_KEY"}), + encoding="utf-8", + ) + + result = CliRunner().invoke(app, ["config", "import-pi", str(pi_config)]) + + settings = load_provider_settings(TauPaths(home=tmp_path / ".tau")) + assert result.exit_code == 0 + assert "Imported Pi providers openai" in result.stdout + assert settings.get_provider("openai").default_model == "gpt-4.1" + + +def test_config_import_pi_requires_yes_for_existing_settings( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("HOME", str(tmp_path)) + tau_home = tmp_path / ".tau" + tau_home.mkdir() + (tau_home / "providers.json").write_text( + json.dumps({"default_provider": "openai", "provider_preferences": {"openai": {}}}), + encoding="utf-8", + ) + pi_config = tmp_path / "pi.json" + pi_config.write_text(json.dumps({"provider": "openai", "model": "gpt-4.1"}), encoding="utf-8") + + result = CliRunner().invoke(app, ["config", "import-pi", str(pi_config)]) + + assert result.exit_code == 2 + assert "Tau provider settings already exist" in result.output + + +def test_config_import_pi_yes_updates_existing_settings( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("HOME", str(tmp_path)) + tau_home = tmp_path / ".tau" + tau_home.mkdir() + (tau_home / "providers.json").write_text( + json.dumps({"default_provider": "openai", "provider_preferences": {"openai": {}}}), + encoding="utf-8", + ) + pi_config = tmp_path / "pi.json" + pi_config.write_text(json.dumps({"provider": "openai", "model": "gpt-4.1"}), encoding="utf-8") + + result = CliRunner().invoke(app, ["--yes", "config", "import-pi", str(pi_config)]) + + assert result.exit_code == 0 + assert ( + load_provider_settings(TauPaths(home=tau_home)).get_provider("openai").default_model + == "gpt-4.1" + ) diff --git a/website/content/guides/migrating-from-pi.md b/website/content/guides/migrating-from-pi.md new file mode 100644 index 000000000..ba086b0e4 --- /dev/null +++ b/website/content/guides/migrating-from-pi.md @@ -0,0 +1,50 @@ +--- +title: Migrating from Pi +description: Import an existing Pi provider/model configuration into Tau. +--- + +Tau can import a best-effort subset of an existing Pi configuration so you do not +have to recreate provider/model settings from scratch. + +## Import a config file + +```bash +tau config import-pi ~/.pi/config.json +``` + +Tau accepts JSON and TOML files. You can also pass a directory; Tau will look for +`config.json`, `config.toml`, `settings.json`, or `settings.toml` inside it. + +If you want Tau to search common Pi locations, use: + +```bash +tau --default-pi-config config import-pi +``` + +## Preview before writing + +Use `--dry-run` to print the Tau `providers.json` payload without changing files: + +```bash +tau --dry-run config import-pi ~/.pi/config.json +``` + +Tau protects an existing `~/.tau/providers.json`. To update it from a Pi import, +pass `--yes`: + +```bash +tau --yes config import-pi ~/.pi/config.json +``` + +## What is imported + +The importer maps provider/model settings when the fields are present: + +- provider name (`provider`, `default_provider`, provider table/list names) +- model (`model`, `default_model`, `defaultModel`) +- base URL (`base_url`, `baseUrl`, `baseURL`, `api_base`) +- API key environment variable (`api_key_env`, `apiKeyEnv`, `api_key_env_var`) + +Raw API keys are not copied. If Tau sees a raw key in the Pi config, it warns and +records only the expected environment variable name. Unmapped fields are reported +as warnings so you can decide whether to migrate them manually. diff --git a/website/content/reference/cli.md b/website/content/reference/cli.md index b6362a0d8..d0d430de9 100644 --- a/website/content/reference/cli.md +++ b/website/content/reference/cli.md @@ -29,6 +29,7 @@ one-time release-notes message to the transcript with the new features and fixes | `tau sessions` | List indexed sessions (id, title, model, cwd) | | `tau export [dest] [--format html\|jsonl]` | Export a session id or JSONL path (HTML default) | | `tau providers` | List configured providers and how each authenticates | +| `tau config import-pi ` | Import provider/model settings from a Pi config | | `tau [setup options] setup` | Create/update an OpenAI-compatible provider | ## Options @@ -45,6 +46,16 @@ one-time release-notes message to the transcript with the new features and fixes | `--auto-compact-threshold INT` | Auto-compact above this rough token estimate | | `--version` | Print the version and exit | +### Pi config import options + +Put these flags before `config import-pi`: + +| Flag | Description | +| --- | --- | +| `--dry-run` | Print the resulting Tau provider settings without writing | +| `--yes, -y` | Update an existing `~/.tau/providers.json` | +| `--default-pi-config` | Search common Pi config paths when no path is provided | + ### Provider setup options Tau's setup mode registers an OpenAI-compatible provider. Put these flags before the final `setup` argument: diff --git a/website/content/reference/configuration.md b/website/content/reference/configuration.md index d99cf234a..26832d7e8 100644 --- a/website/content/reference/configuration.md +++ b/website/content/reference/configuration.md @@ -30,6 +30,9 @@ Startup update checks cache their latest PyPI result in `~/.tau/cache/update-check.json` and refresh at most once per day. Set `TAU_NO_UPDATE_CHECK=1` to disable the check; Tau also skips it when `CI` is set. +If you already have a Pi config, `tau config import-pi ` can import its +provider/model settings into Tau. See [Migrating from Pi]({{< relref "../guides/migrating-from-pi.md" >}}). + ## Providers Tau separates provider metadata from runtime preferences: