Skip to content
Open
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ dependencies = [
"typer>=0.15",
"rich>=14.0",
"httpx>=0.27",
"python-dotenv>=1.0",
]

[project.urls]
Expand Down
164 changes: 154 additions & 10 deletions src/xcstrings_translator/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,14 @@

from __future__ import annotations

import os
import re
import sys
from pathlib import Path
from typing import Annotated

import typer
from dotenv import load_dotenv, set_key
from rich.console import Console
from rich.panel import Panel
from rich.progress import (
Expand All @@ -25,6 +28,7 @@
TaskProgressColumn,
TextColumn,
)
from rich.prompt import IntPrompt, Prompt
from rich.table import Table

from .models import SUPPORTED_LANGUAGES, XCStringsFile
Expand All @@ -36,13 +40,147 @@
resolve_model,
)

# Load .env from the current working directory (searches upward) so that keys
# saved by the interactive setup are picked up on subsequent runs.
load_dotenv()

app = typer.Typer(
name="xcstrings",
help="Translate Apple Localizable.xcstrings files using AI (Anthropic/OpenAI/Gemini)",
add_completion=False,
)
console = Console()

# Provider metadata used by the interactive API-key setup. Ordered for the menu.
PROVIDERS = [
{
"key": "anthropic",
"label": "Anthropic (Claude)",
"env": "ANTHROPIC_API_KEY",
"default_model": "sonnet",
"url": "https://console.anthropic.com/",
},
{
"key": "openai",
"label": "OpenAI (GPT)",
"env": "OPENAI_API_KEY",
"default_model": "gpt-5.4",
"url": "https://platform.openai.com/api-keys",
},
{
"key": "google-gla",
"label": "Google (Gemini)",
"env": "GEMINI_API_KEY",
"default_model": "gemini-2.5-flash",
"url": "https://aistudio.google.com/apikey",
},
{
"key": "openrouter",
"label": "OpenRouter",
"env": "OPENROUTER_API_KEY",
"default_model": "or-sonnet",
"url": "https://openrouter.ai/keys",
},
]
PROVIDER_BY_KEY = {p["key"]: p for p in PROVIDERS}


def _provider_for_model(resolved: str) -> str:
"""Provider key (e.g. 'anthropic') for a resolved 'provider:model' string."""
return resolved.split(":", 1)[0]


def _save_env_key(env_var: str, value: str) -> None:
"""Persist a key to ./.env and export it for the current process."""
os.environ[env_var] = value
env_path = Path.cwd() / ".env"
set_key(str(env_path), env_var, value)


def _render_provider_menu() -> dict:
"""Show a provider picker and return the chosen provider metadata dict."""
table = Table(show_header=True, header_style="bold cyan", box=None, pad_edge=False)
table.add_column("#", style="cyan", justify="right")
table.add_column("Provider", style="bold")
table.add_column("Default model", style="green")
for i, p in enumerate(PROVIDERS, start=1):
table.add_row(str(i), p["label"], p["default_model"])
console.print(
Panel(
table,
title="[bold]No API key found — choose a provider[/bold]",
border_style="cyan",
padding=(1, 2),
)
)
choice = IntPrompt.ask(
"[cyan]Select provider[/cyan]",
choices=[str(i) for i in range(1, len(PROVIDERS) + 1)],
default=1,
)
return PROVIDERS[choice - 1]


def _prompt_api_key(provider: dict) -> None:
"""Show a clean input box for the provider's API key and save it."""
body = (
f"[bold]{provider['label']}[/bold] needs an API key.\n\n"
f"Environment variable: [cyan]{provider['env']}[/cyan]\n"
f"Get a key at: [blue underline]{provider['url']}[/blue underline]\n\n"
f"It will be saved to [cyan].env[/cyan] for future runs."
)
console.print(
Panel(
body,
title="[bold]API key required[/bold]",
border_style="cyan",
padding=(1, 2),
)
)
key = ""
while not key.strip():
key = Prompt.ask(
f"[cyan]Enter your {provider['label']} API key[/cyan]", password=True
)
if not key.strip():
console.print("[yellow]Key cannot be empty.[/yellow]")
_save_env_key(provider["env"], key.strip())
console.print("[green]✓[/green] Saved to .env\n")


def _ensure_provider_and_key(
model: str | None, *, require_key: bool = True
) -> tuple[str, str]:
"""
Resolve the model and make sure the matching provider key is available,
prompting interactively when running in a TTY.

Returns the concrete ``(model, resolved)`` to use for translation.
"""
interactive = require_key and sys.stdin.isatty()

# No model specified: use the default provider if its key is present,
# otherwise offer the provider menu (interactive only).
if model is None:
if os.environ.get("ANTHROPIC_API_KEY") or not interactive:
return "sonnet", resolve_model("sonnet")
provider = _render_provider_menu()
if not os.environ.get(provider["env"]):
_prompt_api_key(provider)
model = provider["default_model"]
return model, resolve_model(model)

# Model specified: derive the provider from it and prompt for that key.
resolved = resolve_model(model)
provider_key = _provider_for_model(resolved)
provider = PROVIDER_BY_KEY.get(provider_key)
if provider is None:
# Unknown provider: let pydantic-ai handle validation downstream.
return model, resolved
if not os.environ.get(provider["env"]) and interactive:
_prompt_api_key(provider)
return model, resolved


def _canonicalize_bcp47_tag(tag: str) -> str:
"""
Expand Down Expand Up @@ -372,13 +510,13 @@ def translate(
),
] = None,
model: Annotated[
str,
str | None,
typer.Option(
"-m",
"--model",
help="Model: sonnet, gpt-5, gemini-2.5-flash, openrouter:vendor/model (or provider:model)",
help="Model: sonnet, gpt-5, gemini-2.5-flash, openrouter:vendor/model (or provider:model). Default: sonnet",
),
] = "sonnet",
] = None,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
batch_size: Annotated[
int, typer.Option("-b", "--batch-size", help="Strings per API call")
] = 25,
Expand Down Expand Up @@ -482,13 +620,19 @@ def translate(

# Validate model (accept aliases or provider:model format). Every known alias
# and every provider:model string resolves to a value containing ":"; anything
# without one is an unrecognised shorthand (likely a typo).
resolved = resolve_model(model)
if resolved not in MODEL_PRICING and ":" not in resolved:
console.print(f"[red]Error:[/red] Unknown model: {model}")
console.print(f"Shortcuts: {', '.join(MODEL_ALIASES.keys())}")
console.print("Or use provider:model format (e.g., openai:gpt-4o)")
raise typer.Exit(1)
# without one is an unrecognised shorthand (likely a typo). Only validate when
# the user explicitly passed a model.
if model is not None:
resolved = resolve_model(model)
if resolved not in MODEL_PRICING and ":" not in resolved:
console.print(f"[red]Error:[/red] Unknown model: {model}")
console.print(f"Shortcuts: {', '.join(MODEL_ALIASES.keys())}")
console.print("Or use provider:model format (e.g., openai:gpt-4o)")
raise typer.Exit(1)

# Resolve the provider/model and ensure the matching API key is available,
# prompting interactively when one is missing. Dry runs need no key.
model, resolved = _ensure_provider_and_key(model, require_key=not dry_run)

common = {
"languages": target_langs,
Expand Down
78 changes: 78 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
"""Tests for xcstrings_translator.cli - CLI commands."""

import json
import os
from unittest.mock import MagicMock, patch

from typer.testing import CliRunner

from xcstrings_translator.cli import (
_canonicalize_bcp47_tag,
_ensure_provider_and_key,
_normalize_language_tag,
_parse_target_languages,
_provider_for_model,
_save_env_key,
app,
)
from xcstrings_translator.models import SUPPORTED_LANGUAGES
Expand Down Expand Up @@ -241,6 +245,80 @@ def mock_translate_file(xc, langs, **kwargs):
assert mock_instance.translate_file.called


class TestProviderKeySetup:
"""Tests for the interactive provider + API-key setup helpers."""

def test_provider_for_model(self):
Comment thread
jaylann marked this conversation as resolved.
Outdated
assert _provider_for_model("anthropic:claude-sonnet-4-6") == "anthropic"
assert _provider_for_model("openai:gpt-5.4") == "openai"
assert _provider_for_model("google-gla:gemini-2.5-flash") == "google-gla"
assert _provider_for_model("openrouter:anthropic/claude-sonnet-4.6") == (
"openrouter"
)

def test_save_env_key_writes_and_exports(self, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("MY_TEST_KEY", raising=False)
_save_env_key("MY_TEST_KEY", "abc123")
assert os.environ["MY_TEST_KEY"] == "abc123"
env_file = tmp_path / ".env"
assert env_file.exists()
assert "MY_TEST_KEY" in env_file.read_text()
assert "abc123" in env_file.read_text()

def test_save_env_key_updates_without_clobbering(self, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
(tmp_path / ".env").write_text("OTHER_KEY=keepme\n")
_save_env_key("ANTHROPIC_API_KEY", "sk-new")
content = (tmp_path / ".env").read_text()
assert "OTHER_KEY=keepme" in content
assert "sk-new" in content

def test_ensure_key_present_returns_unchanged(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "sk-openai")
model, resolved = _ensure_provider_and_key("gpt-5.4")
assert model == "gpt-5.4"
assert resolved == "openai:gpt-5.4"

def test_ensure_default_model_with_anthropic_key(self, monkeypatch):
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant")
model, resolved = _ensure_provider_and_key(None)
assert model == "sonnet"
assert resolved == "anthropic:claude-sonnet-4-6"

def test_ensure_non_tty_no_prompt(self, monkeypatch):
# No key, no TTY -> must not prompt; falls back to sonnet default.
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
monkeypatch.setattr("sys.stdin.isatty", lambda: False)
model, resolved = _ensure_provider_and_key(None)
assert model == "sonnet"
Comment thread
coderabbitai[bot] marked this conversation as resolved.

def test_ensure_dry_run_skips_key(self, monkeypatch):
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.setattr("sys.stdin.isatty", lambda: True)
# require_key=False (dry run) must not prompt even on a TTY.
model, resolved = _ensure_provider_and_key("gpt-5.4", require_key=False)
assert resolved == "openai:gpt-5.4"
Comment thread
coderabbitai[bot] marked this conversation as resolved.

def test_ensure_menu_then_key_prompt(self, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
for env in ("ANTHROPIC_API_KEY", "OPENAI_API_KEY"):
monkeypatch.delenv(env, raising=False)
monkeypatch.setattr("sys.stdin.isatty", lambda: True)
# Pick provider #2 (OpenAI) from the menu, then enter a key.
monkeypatch.setattr(
"xcstrings_translator.cli.IntPrompt.ask", lambda *a, **k: 2
)
monkeypatch.setattr(
"xcstrings_translator.cli.Prompt.ask", lambda *a, **k: "sk-entered"
)
model, resolved = _ensure_provider_and_key(None)
assert model == "gpt-5.4"
assert resolved == "openai:gpt-5.4"
assert os.environ["OPENAI_API_KEY"] == "sk-entered"
assert "OPENAI_API_KEY" in (tmp_path / ".env").read_text()


class TestInfoCommand:
"""Tests for the 'info' CLI command."""

Expand Down
4 changes: 3 additions & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading