diff --git a/CLAUDE.md b/CLAUDE.md index 72a587f..3f0d9af 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,6 +72,8 @@ Click app + readline REPL. Entry: `plyngent` / `python -m plyngent`. - **`plyngent chat`**: provider/model selection (flags or interactive), SQLite sessions via config `[database]`, `/help` slash commands. - **`plyngent providers`**: list config providers. +- **`plyngent config path|edit`**: print or open config in `$EDITOR` (`shlex`-split, e.g. `codium --wait`). +- If no providers and `$EDITOR` is set, chat/providers prompt to edit config then reload. - Tools default on (`--tools` / `--no-tools`); workspace defaults to cwd. - Readline: Tab completion for slash commands/args; history file under platformdirs user data (`repl_history`). diff --git a/src/plyngent/cli/app.py b/src/plyngent/cli/app.py index 1a42807..44b1700 100644 --- a/src/plyngent/cli/app.py +++ b/src/plyngent/cli/app.py @@ -7,7 +7,11 @@ from typing import TYPE_CHECKING import click import msgspec -from plyngent import config as config_mod +from plyngent.cli.editor import ( + load_config_with_optional_edit, + open_in_editor, + resolve_config_path, +) from plyngent.cli.repl import run_repl from plyngent.cli.selection import select_model, select_provider from plyngent.cli.state import ReplState @@ -21,7 +25,7 @@ if TYPE_CHECKING: def _load_config(config_path: Path | None) -> ConfigStore: - return config_mod.load(config_path) + return load_config_with_optional_edit(config_path) def _database_config(store: ConfigStore) -> DatabaseConfig: @@ -137,5 +141,38 @@ def providers_cmd(config_path: Path | None) -> None: click.secho(f"bad: {', '.join(sorted(store.bad_providers.keys()))}", fg="yellow") +@main.group("config") +def config_group() -> None: + """Manage plyngent configuration.""" + + +@config_group.command("path") +@click.option( + "--config", + "config_path", + type=click.Path(path_type=Path, dir_okay=False), + default=None, + help="Override config path (prints the path that would be used).", +) +def config_path_cmd(config_path: Path | None) -> None: + """Print the resolved config file path.""" + click.echo(str(resolve_config_path(config_path))) + + +@config_group.command("edit") +@click.option( + "--config", + "config_path", + type=click.Path(path_type=Path, dir_okay=False), + default=None, + help="Path to plyngent.toml (default: platform config dir).", +) +def config_edit_cmd(config_path: Path | None) -> None: + """Open the config file in $EDITOR (supports e.g. ``codium --wait``).""" + path = resolve_config_path(config_path) + open_in_editor(path) + click.echo(f"edited {path}") + + if __name__ == "__main__": main() diff --git a/src/plyngent/cli/editor.py b/src/plyngent/cli/editor.py new file mode 100644 index 0000000..dd31904 --- /dev/null +++ b/src/plyngent/cli/editor.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +import os +import shlex +import subprocess +from pathlib import Path +from typing import TYPE_CHECKING + +import click + +from plyngent import config as config_mod + +if TYPE_CHECKING: + from plyngent.config.store import ConfigStore + +_MINIMAL_CONFIG = """\ +# plyngent configuration +# edit providers below + +[database] +implementation = "sqlite" +url = ":memory:" + +# [providers.example] +# preset = "openai-compatible" +# url = "https://api.openai.com/v1" +# access_key_or_token = "sk-..." +# +# [providers.example.models] +# "gpt-4o-mini" = { text = true } +""" + + +def get_editor() -> str | None: + """Return the ``EDITOR`` environment value, or ``None`` if unset/empty.""" + value = os.environ.get("EDITOR", "").strip() + return value or None + + +def resolve_config_path(config_path: Path | None) -> Path: + """Resolve CLI ``--config`` or the platform default path.""" + if config_path is not None: + return config_path + return Path(config_mod.default_config_source) + + +def ensure_config_file(path: Path) -> None: + """Create parent dirs and a minimal template if the file does not exist.""" + path.parent.mkdir(parents=True, exist_ok=True) + if not path.exists(): + _ = path.write_text(_MINIMAL_CONFIG, encoding="utf-8") + + +def open_in_editor(path: Path, *, editor: str | None = None) -> None: + """Open ``path`` with ``EDITOR`` (supports values like ``codium --wait``).""" + editor_cmd = editor if editor is not None else get_editor() + if editor_cmd is None: + msg = "EDITOR is not set" + raise click.ClickException(msg) + + ensure_config_file(path) + try: + argv = [*shlex.split(editor_cmd, posix=os.name != "nt"), str(path)] + except ValueError as exc: + msg = f"invalid EDITOR value {editor_cmd!r}: {exc}" + raise click.ClickException(msg) from exc + if not argv: + msg = "EDITOR is empty after parsing" + raise click.ClickException(msg) + + try: + completed = subprocess.run(argv, check=False) + except FileNotFoundError as exc: + msg = f"editor executable not found: {argv[0]}" + raise click.ClickException(msg) from exc + except OSError as exc: + msg = f"failed to run editor: {exc}" + raise click.ClickException(msg) from exc + + if completed.returncode != 0: + msg = f"editor exited with status {completed.returncode}" + raise click.ClickException(msg) + + +def prompt_edit_config(path: Path, *, reason: str | None = None) -> bool: + """If ``EDITOR`` is set, ask whether to edit ``path``. Returns True if opened.""" + if get_editor() is None: + return False + message = f"{reason} Edit config file {path}?" if reason else f"Edit config file {path}?" + if not click.confirm(message, default=False): + return False + open_in_editor(path) + return True + + +def load_config_with_optional_edit(config_path: Path | None) -> ConfigStore: + """Load config; if there are no providers and EDITOR is set, offer to edit and reload.""" + path = resolve_config_path(config_path) + store = config_mod.load(path) + if store.providers: + return store + reason = "No providers configured." + if not path.exists(): + reason = f"Config file not found ({path})." + if prompt_edit_config(path, reason=reason): + store = config_mod.load(path) + return store diff --git a/tests/test_cli/test_editor.py b/tests/test_cli/test_editor.py new file mode 100644 index 0000000..5c03b3c --- /dev/null +++ b/tests/test_cli/test_editor.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from click.testing import CliRunner + +from plyngent.cli.app import main +from plyngent.cli.editor import ( + ensure_config_file, + get_editor, + open_in_editor, + prompt_edit_config, + resolve_config_path, +) + +if TYPE_CHECKING: + from pathlib import Path + + +def test_get_editor(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("EDITOR", raising=False) + assert get_editor() is None + monkeypatch.setenv("EDITOR", " codium --wait ") + assert get_editor() == "codium --wait" + + +def test_resolve_config_path_override(tmp_path: Path) -> None: + path = tmp_path / "custom.toml" + assert resolve_config_path(path) == path + + +def test_ensure_config_file_creates_template(tmp_path: Path) -> None: + path = tmp_path / "sub" / "plyngent.toml" + ensure_config_file(path) + assert path.is_file() + text = path.read_text(encoding="utf-8") + assert "database" in text + + +def test_open_in_editor_splits_args(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + path = tmp_path / "plyngent.toml" + calls: list[list[str]] = [] + + def fake_run(argv: list[str], check: bool = False) -> object: # noqa: FBT001, FBT002 + del check + calls.append(list(argv)) + + class Result: + returncode: int = 0 + + return Result() + + monkeypatch.setattr("plyngent.cli.editor.subprocess.run", fake_run) + open_in_editor(path, editor="codium --wait") + assert len(calls) == 1 + assert calls[0][0] == "codium" + assert calls[0][1] == "--wait" + assert calls[0][2] == str(path) + assert path.is_file() + + +def test_open_in_editor_missing_editor(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("EDITOR", raising=False) + with pytest.raises(Exception, match="EDITOR"): + open_in_editor(tmp_path / "x.toml", editor=None) + + +def test_prompt_edit_no_editor(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("EDITOR", raising=False) + assert prompt_edit_config(tmp_path / "p.toml") is False + + +def test_prompt_edit_declined(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("EDITOR", "true") + + def _confirm(*_a: object, **_k: object) -> bool: + return False + + monkeypatch.setattr("click.confirm", _confirm) + assert prompt_edit_config(tmp_path / "p.toml", reason="empty") is False + + +def test_prompt_edit_accepted(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + path = tmp_path / "p.toml" + monkeypatch.setenv("EDITOR", "true") + + def _confirm(*_a: object, **_k: object) -> bool: + return True + + monkeypatch.setattr("click.confirm", _confirm) + opened: list[Path] = [] + + def fake_open(p: Path, *, editor: str | None = None) -> None: + del editor + opened.append(p) + + monkeypatch.setattr("plyngent.cli.editor.open_in_editor", fake_open) + assert prompt_edit_config(path, reason="No providers.") is True + assert opened == [path] + + +def test_config_path_command(tmp_path: Path) -> None: + runner = CliRunner() + result = runner.invoke(main, ["config", "path", "--config", str(tmp_path / "a.toml")]) + assert result.exit_code == 0 + assert str(tmp_path / "a.toml") in result.output + + +def test_config_edit_command(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + path = tmp_path / "edit.toml" + monkeypatch.setenv("EDITOR", "true") + calls: list[list[str]] = [] + + def fake_run(argv: list[str], check: bool = False) -> object: # noqa: FBT001, FBT002 + del check + calls.append(list(argv)) + + class Result: + returncode: int = 0 + + return Result() + + monkeypatch.setattr("plyngent.cli.editor.subprocess.run", fake_run) + runner = CliRunner() + result = runner.invoke(main, ["config", "edit", "--config", str(path)]) + assert result.exit_code == 0 + assert calls and calls[0][-1] == str(path)