From 54712b541a5c2228abc33f8aca9f081208b49168 Mon Sep 17 00:00:00 2001 From: worldmozara Date: Wed, 15 Jul 2026 14:52:19 +0800 Subject: [PATCH] core/cli: /config opens plyngent.toml and reloads Add slash /config to edit the active config path in $EDITOR, then reload providers/agent settings while keeping a valid provider/model selection. --- README.md | 1 + src/plyngent/cli/slash.py | 29 ++++++++++++++++++++++++ src/plyngent/cli/state.py | 33 ++++++++++++++++++++++++++++ src/plyngent/config/store.py | 5 +++++ tests/test_cli/test_repl_commands.py | 20 +++++++++++++++++ 5 files changed, 88 insertions(+) diff --git a/README.md b/README.md index 8cfeda0..63a9cdb 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,7 @@ Type `/help` in the REPL for the live list. Common ones: | `/stream` `/verbose` `/tools` `/rounds` | Toggles and limits | | `/retry` | Re-run incomplete last user turn (after error/cancel) | | `/provider` `/model` | Switch without restarting | +| `/config` | Edit `plyngent.toml` in `$EDITOR` and reload | | `/quit` | Leave the REPL | User messages are saved immediately. On API error or Ctrl+C, partial assistant/tool output is discarded but the user message stays so `/retry` works after resume. Interactive auto-retry uses 10s / 20s / 30s delays. diff --git a/src/plyngent/cli/slash.py b/src/plyngent/cli/slash.py index fad42d0..17a8781 100644 --- a/src/plyngent/cli/slash.py +++ b/src/plyngent/cli/slash.py @@ -293,6 +293,35 @@ def edit_cmd(state: ReplState) -> None: click.echo(f"(edit) {len(text)} characters ready to send") +@slash.command("config") +@click.pass_obj +def config_cmd(state: ReplState) -> None: + """Open plyngent.toml in ``$EDITOR``, then reload providers/agent settings. + + Same file as ``plyngent config edit``. After the editor exits, config is + re-read; current provider/model are kept when still valid. + """ + from plyngent import config as config_mod + from plyngent.cli.editor import open_in_editor + + path = state.config.path + try: + open_in_editor(path) + except click.ClickException as exc: + click.echo(f"error: {exc}") + return + try: + state.reload_config_from_disk() + except (config_mod.ConfigFormatError, ValueError, OSError) as exc: + click.secho(f"error: config reload failed: {exc}", fg="red") + click.echo(f"config file: {path}") + return + if state.config.bad_providers: + names = ", ".join(sorted(state.config.bad_providers.keys())) + click.secho(f"warning: ignored bad providers: {names}", fg="yellow") + click.echo(f"config reloaded from {path}\nprovider={state.provider_name} model={state.model}") + + @slash.command("status") @click.pass_obj def status_cmd(state: ReplState) -> None: diff --git a/src/plyngent/cli/state.py b/src/plyngent/cli/state.py index d32e4fe..2413dc6 100644 --- a/src/plyngent/cli/state.py +++ b/src/plyngent/cli/state.py @@ -111,6 +111,39 @@ class ReplState: self.agent.messages = messages self.sync_display_flags() + def reload_config_from_disk(self) -> None: + """Re-read TOML config and re-bind provider/model when still valid.""" + import click + + from plyngent.cli.selection import select_model, select_provider + from plyngent.runtime import ProviderNotSupportedError + from plyngent.tools import set_path_denylist + + self.config.reload() + set_path_denylist(self.config.agent_config.path_denylist or None) + + preferred_provider = self.provider_name if self.provider_name in self.config.providers else None + preferred_model = self.model + try: + pname, provider = select_provider( + self.config.providers, + preferred=preferred_provider, + interactive=False, + ) + except (click.ClickException, ProviderNotSupportedError) as exc: + msg = f"config reloaded but provider selection failed: {exc}" + raise ValueError(msg) from exc + try: + model_id = select_model(provider, preferred=preferred_model, interactive=False) + except click.ClickException: + # Previous model not on this provider; pick first listed or keep free-form. + model_id = next(iter(sorted(provider.models.keys()))) if provider.models else preferred_model + + self.provider_name = pname + self.provider = provider + self.model = model_id + self.rebuild_client() + def _set_workspace(self, path: Path) -> None: """Update REPL + tool workspace root.""" resolved = path.expanduser().resolve() diff --git a/src/plyngent/config/store.py b/src/plyngent/config/store.py index 60a7ba1..d8d1564 100644 --- a/src/plyngent/config/store.py +++ b/src/plyngent/config/store.py @@ -99,6 +99,11 @@ class ConfigStore: self._agent = _parse_agent(cast("dict[str, object]", raw.get("agent", {}))) self._providers, self._bad_providers = _parse_providers(document) + @property + def path(self) -> Path: + """Filesystem path of the TOML config file.""" + return self._path + # -- database (read-only) -- @property diff --git a/tests/test_cli/test_repl_commands.py b/tests/test_cli/test_repl_commands.py index 95b7d2e..ccbc108 100644 --- a/tests/test_cli/test_repl_commands.py +++ b/tests/test_cli/test_repl_commands.py @@ -173,6 +173,26 @@ async def test_rename_slash(state: ReplState) -> None: assert row.name == "my-chat" +async def test_config_slash_reloads( + state: ReplState, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + path = state.config.path + assert path is not None + # Fixture ConfigStore may not have written the file yet. + state.config.write() + + def fake_open(p: object, **_k: object) -> None: + assert str(p) == str(path) + + monkeypatch.setattr("plyngent.cli.editor.open_in_editor", fake_open) + assert await handle_slash(state, "/config") is True + out = capsys.readouterr().out + assert "config reloaded" in out + assert state.provider_name + + async def test_model_switch_persists(state: ReplState) -> None: from plyngent.config.models import ModelConfig