core/cli: open config in $EDITOR with optional prompt

shlex-split EDITOR (e.g. codium --wait); prompt when no providers;
add config path/edit commands.
This commit is contained in:
2026-07-14 18:44:32 +08:00
parent a017adda39
commit 5d90e48618
4 changed files with 276 additions and 2 deletions
+2
View File
@@ -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 chat`**: provider/model selection (flags or interactive), SQLite sessions via config `[database]`, `/help` slash commands.
- **`plyngent providers`**: list config providers. - **`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. - 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`). - Readline: Tab completion for slash commands/args; history file under platformdirs user data (`repl_history`).
+39 -2
View File
@@ -7,7 +7,11 @@ from typing import TYPE_CHECKING
import click import click
import msgspec 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.repl import run_repl
from plyngent.cli.selection import select_model, select_provider from plyngent.cli.selection import select_model, select_provider
from plyngent.cli.state import ReplState from plyngent.cli.state import ReplState
@@ -21,7 +25,7 @@ if TYPE_CHECKING:
def _load_config(config_path: Path | None) -> ConfigStore: 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: 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") 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__": if __name__ == "__main__":
main() main()
+107
View File
@@ -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
+128
View File
@@ -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)