mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 15:37:55 +08:00
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:
+14
-3
@@ -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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user