mirror of
https://github.com/NCBM/plyngent.git
synced 2026-07-23 05:55:16 +08:00
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:
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user