core/cli: config open via VISUAL/EDITOR or system default

Prefer $VISUAL then $EDITOR (blocking). Without either, fall back to
xdg-open / open / startfile (non-blocking) for config paths only.
/edit stays blocking-only so the temp buffer can be re-read.
This commit is contained in:
2026-07-19 22:33:02 +08:00
parent d528f65c4c
commit 58ff646034
6 changed files with 245 additions and 66 deletions
+14 -3
View File
@@ -525,10 +525,21 @@ def config_path_cmd(config_path: Path | None) -> 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``)."""
"""Open the config file in $VISUAL/$EDITOR, or system default if unset.
Blocking editors (e.g. ``codium --wait``) wait for exit. Without VISUAL/EDITOR,
falls back to xdg-open / open / startfile (non-blocking).
"""
path = resolve_config_path(config_path)
open_in_editor(path)
click.echo(f"edited {path}")
outcome = open_in_editor(path, allow_system_open=True)
if outcome == "system":
click.secho(
f"opened {path} with system default (not waiting for the app to exit)",
fg="yellow",
err=True,
)
else:
click.echo(f"edited {path}")
if __name__ == "__main__":
+125 -38
View File
@@ -4,8 +4,9 @@ import contextlib
import os
import shlex
import subprocess
import sys
from pathlib import Path
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Literal
import click
@@ -14,6 +15,8 @@ from plyngent import config as config_mod
if TYPE_CHECKING:
from plyngent.config.store import ConfigStore
type OpenOutcome = Literal["waited", "system"]
_MINIMAL_CONFIG = """\
# plyngent configuration
# edit providers below
@@ -54,9 +57,16 @@ _MINIMAL_CONFIG = """\
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
"""Return ``$VISUAL`` or ``$EDITOR``, or ``None`` if both unset/empty.
``VISUAL`` is preferred when set (common Unix convention for full-screen
editors); otherwise ``EDITOR``.
"""
for key in ("VISUAL", "EDITOR"):
value = os.environ.get(key, "").strip()
if value:
return value
return None
def resolve_config_path(config_path: Path | None) -> Path:
@@ -73,31 +83,14 @@ def ensure_config_file(path: Path) -> None:
_ = path.write_text(_MINIMAL_CONFIG, encoding="utf-8")
def open_in_editor(
path: Path,
*,
editor: str | None = None,
ensure_exists: bool = True,
) -> None:
"""Open ``path`` with ``EDITOR`` (supports values like ``codium --wait``).
When ``ensure_exists`` is true (default), create a minimal config template
if the file is missing (used for ``plyngent config edit``).
"""
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)
if ensure_exists:
ensure_config_file(path)
def _run_blocking_editor(editor_cmd: str, path: Path) -> None:
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}"
msg = f"invalid editor value {editor_cmd!r}: {exc}"
raise click.ClickException(msg) from exc
if not argv:
msg = "EDITOR is empty after parsing"
msg = "editor command is empty after parsing"
raise click.ClickException(msg)
try:
@@ -114,15 +107,97 @@ def open_in_editor(
raise click.ClickException(msg)
def edit_text_in_editor(initial: str = "", *, suffix: str = ".md") -> str | None:
"""Edit ``initial`` in ``$EDITOR``; return text or ``None`` if empty/cancelled.
def _open_with_system_default(path: Path) -> None:
"""Open *path* with the OS file association (non-blocking).
Uses a temporary file. Does not create a config template.
Linux: ``xdg-open``; macOS: ``open``; Windows: ``os.startfile``.
Does not wait for the application to exit.
"""
resolved = str(path.resolve())
if sys.platform == "win32":
try:
os.startfile(resolved) # type: ignore[attr-defined]
except OSError as exc:
msg = f"failed to open with system default: {exc}"
raise click.ClickException(msg) from exc
return
if sys.platform == "darwin":
argv = ["open", resolved]
else:
# Linux and other POSIX: Free Desktop opener
argv = ["xdg-open", resolved]
try:
completed = subprocess.run(
argv,
check=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except FileNotFoundError as exc:
cmd = argv[0]
msg = (
f"{cmd} not found and neither VISUAL nor EDITOR is set; "
"set VISUAL or EDITOR to a blocking editor (e.g. nano, vim, codium --wait)"
)
raise click.ClickException(msg) from exc
except OSError as exc:
msg = f"failed to open with system default: {exc}"
raise click.ClickException(msg) from exc
if completed.returncode != 0:
msg = f"system open failed (exit {completed.returncode}); set VISUAL or EDITOR to a blocking editor"
raise click.ClickException(msg)
def open_in_editor(
path: Path,
*,
editor: str | None = None,
ensure_exists: bool = True,
allow_system_open: bool = True,
) -> OpenOutcome:
"""Open ``path`` for editing.
Prefer a blocking editor (``editor`` arg, else ``$VISUAL`` / ``$EDITOR``).
When none is set and ``allow_system_open`` is true, fall back to the OS
default association (``xdg-open`` / ``open`` / ``os.startfile``) — this
does **not** wait for the app to exit.
Returns:
``"waited"`` if a blocking editor ran to completion;
``"system"`` if the OS opener was used (non-blocking).
When ``ensure_exists`` is true (default), create a minimal config template
if the file is missing (used for ``plyngent config edit``).
"""
if ensure_exists:
ensure_config_file(path)
editor_cmd = editor if editor is not None else get_editor()
if editor_cmd is not None:
_run_blocking_editor(editor_cmd, path)
return "waited"
if not allow_system_open:
msg = "neither VISUAL nor EDITOR is set"
raise click.ClickException(msg)
_open_with_system_default(path)
return "system"
def edit_text_in_editor(initial: str = "", *, suffix: str = ".md") -> str | None:
"""Edit ``initial`` in a blocking editor; return text or ``None`` if empty.
Uses a temporary file. Requires ``$VISUAL`` or ``$EDITOR`` (no system-open
fallback — we must wait for the process and re-read the buffer).
"""
import tempfile
if get_editor() is None:
msg = "EDITOR is not set; cannot /edit"
msg = "neither VISUAL nor EDITOR is set; cannot /edit"
raise click.ClickException(msg)
with tempfile.NamedTemporaryFile(
@@ -136,7 +211,7 @@ def edit_text_in_editor(initial: str = "", *, suffix: str = ".md") -> str | None
_ = handle.write(initial)
try:
open_in_editor(path, ensure_exists=False)
_ = open_in_editor(path, ensure_exists=False, allow_system_open=False)
text = path.read_text(encoding="utf-8")
finally:
with contextlib.suppress(OSError):
@@ -148,19 +223,24 @@ def edit_text_in_editor(initial: str = "", *, suffix: str = ".md") -> str | None
return cleaned
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
def prompt_edit_config(path: Path, *, reason: str | None = None) -> OpenOutcome | None:
"""Ask whether to edit ``path``. Returns open outcome, or ``None`` if skipped.
Offers when a blocking editor is set **or** system open can be attempted.
"""
has_editor = get_editor() is not None
# System open is always attempted as fallback when no editor; we still
# prompt so the user can decline on headless hosts.
message = f"{reason} Edit config file {path}?" if reason else f"Edit config file {path}?"
if not has_editor:
message = f"{message} (no VISUAL/EDITOR; will try system default open — non-blocking)"
if not click.confirm(message, default=False):
return False
open_in_editor(path)
return True
return None
return open_in_editor(path, allow_system_open=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.
"""Load config; if there are no providers, offer to edit and reload when waited.
Raises:
config_mod.ConfigFormatError: Invalid TOML (caller should surface path).
@@ -172,6 +252,13 @@ def load_config_with_optional_edit(config_path: Path | None) -> ConfigStore:
reason = "No providers configured."
if not path.exists():
reason = f"Config file not found ({path})."
if prompt_edit_config(path, reason=reason):
outcome = prompt_edit_config(path, reason=reason)
if outcome == "waited":
store = config_mod.load(path)
elif outcome == "system":
click.secho(
f"opened {path} with system default (not waiting). Save the file, then re-run plyngent to load providers.",
fg="yellow",
err=True,
)
return store
+14 -7
View File
@@ -87,7 +87,7 @@ HELP_FOOTER = (
"continue a prior chat after restart.\n"
"\n"
'Multiline: start a message with """ then end a later line with """.\n'
"Long prompts: /edit opens $EDITOR.\n"
"Long prompts: /edit opens $VISUAL/$EDITOR (blocking).\n"
)
@@ -453,10 +453,10 @@ def clear_cmd(state: ReplState) -> None:
@slash.command("edit")
@click.pass_obj
def edit_cmd(state: ReplState) -> None:
"""Compose a user message in ``$EDITOR``, then send it.
"""Compose a user message in ``$VISUAL``/``$EDITOR``, then send it.
Opens a temporary buffer; save and quit the editor to submit.
Empty buffer cancels. Requires ``EDITOR`` (e.g. ``codium --wait``).
Empty buffer cancels. Requires a blocking editor (no system-open fallback).
"""
from plyngent.cli.editor import edit_text_in_editor
@@ -475,20 +475,27 @@ def edit_cmd(state: ReplState) -> None:
@slash.command("config")
@click.pass_obj
def config_cmd(state: ReplState) -> None:
"""Open plyngent.toml in ``$EDITOR``, then reload providers/agent settings.
"""Open plyngent.toml in ``$VISUAL``/``$EDITOR`` (or system default), then reload.
Same file as ``plyngent config edit``. After the editor exits, config is
re-read; current provider/model are kept when still valid.
Same file as ``plyngent config edit``. After a **blocking** editor exits,
config is re-read. System-open fallback does not wait — reload is skipped
with a hint to re-run ``/config`` after saving.
"""
from plyngent import config as config_mod
from plyngent.cli.editor import open_in_editor
path = state.config.path
try:
open_in_editor(path)
outcome = open_in_editor(path, allow_system_open=True)
except click.ClickException as exc:
click.echo(f"error: {exc}")
return
if outcome == "system":
click.secho(
f"opened {path} with system default (not waiting). Save the file, then run /config again to reload.",
fg="yellow",
)
return
try:
state.reload_config_from_disk()
except (config_mod.ConfigFormatError, ValueError, OSError) as exc: