core/cli: one-shot chat with -p/stdin and exit codes

Add plyngent chat -p/--prompt (and non-TTY stdin), --stream/--no-stream,
--yes, --quiet. Non-interactive mode uses NonInteractiveBackend and
disables limit prompts. Exit codes: 0 ok, 1 config, 2 cancelled, 3 failed.
This commit is contained in:
2026-07-15 13:33:18 +08:00
parent ec5e6ab0f4
commit 723fa85879
7 changed files with 386 additions and 46 deletions
+2 -2
View File
@@ -115,14 +115,14 @@ Basedpyright `recommended`. Ruff includes `ANN` (private return types `ANN202` i
- G2: `/rename`, `/delete` (confirm), `/export md|json` - G2: `/rename`, `/delete` (confirm), `/export md|json`
- G2.5: Click slash registry (`cli/slash.py`) + `awaitlet` for sync Click / async memory; auto `/help`; completer from `slash.list_commands` - G2.5: Click slash registry (`cli/slash.py`) + `awaitlet` for sync Click / async memory; auto `/help`; completer from `slash.list_commands`
- G3: multiline `"""``"""` input (`cli/input_text.py`); `/edit` via `$EDITOR` (`edit_text_in_editor`) - G3: multiline `"""``"""` input (`cli/input_text.py`); `/edit` via `$EDITOR` (`edit_text_in_editor`)
- G4: `plyngent chat -p/--prompt` (+ non-TTY stdin); exit codes 0/1/2/3; `--yes` / non-interactive confirm deny; `--stream/--no-stream`, `--quiet`
**Planned** **Planned**
- **G4 — One-shot**: `plyngent chat -p/--prompt` (and stdin non-TTY); exit codes; non-interactive safety (`--yes` / deny destructive)
- **G5 — Hardening**: tool timeouts/defaults review, config error clarity, cancel edges, optional `--log-level`, no secrets in status/export - **G5 — Hardening**: tool timeouts/defaults review, config error clarity, cancel edges, optional `--log-level`, no secrets in status/export
- **G6 — Docs**: real README, example TOML, CLAUDE/help accuracy - **G6 — Docs**: real README, example TOML, CLAUDE/help accuracy
Milestone order: G4 → G5 → G6. Milestone order: G5 → G6.
- **Phase H**: multi-tenant platform (`router/`, auth, sandboxed tools, web). - **Phase H**: multi-tenant platform (`router/`, auth, sandboxed tools, web).
+182 -36
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import sys
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@@ -14,12 +15,15 @@ from plyngent.cli.editor import (
open_in_editor, open_in_editor,
resolve_config_path, resolve_config_path,
) )
from plyngent.cli.exit_codes import EXIT_CANCELLED, EXIT_OK, EXIT_TURN_FAILED
from plyngent.cli.limits import install_cli_limit_hooks from plyngent.cli.limits import install_cli_limit_hooks
from plyngent.cli.repl import run_repl from plyngent.cli.repl import run_repl
from plyngent.cli.retry import run_user_text_with_retries
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
from plyngent.config.models import DatabaseConfig from plyngent.config.models import DatabaseConfig
from plyngent.memory import MemoryStore from plyngent.memory import MemoryStore
from plyngent.prompting import NonInteractiveBackend, configure_prompting
from plyngent.runtime import ProviderNotSupportedError, create_client from plyngent.runtime import ProviderNotSupportedError, create_client
from plyngent.tools import set_workspace_root from plyngent.tools import set_workspace_root
@@ -33,16 +37,103 @@ def _load_config(config_path: Path | None) -> ConfigStore:
return load_config_with_optional_edit(config_path) return load_config_with_optional_edit(config_path)
def _database_config(store: ConfigStore) -> DatabaseConfig: def _database_config(store: ConfigStore, *, quiet: bool = False) -> DatabaseConfig:
raw = dict(store.database) raw = dict(store.database)
# Prefer a durable file DB so sessions survive CLI restarts. # Prefer a durable file DB so sessions survive CLI restarts.
if raw.get("url") in {None, "", ":memory:"} and raw.get("implementation", "sqlite") == "sqlite": if raw.get("url") in {None, "", ":memory:"} and raw.get("implementation", "sqlite") == "sqlite":
db_path = user_data_path("plyngent", ensure_exists=True) / _DEFAULT_DB_FILENAME db_path = user_data_path("plyngent", ensure_exists=True) / _DEFAULT_DB_FILENAME
raw = {**raw, "implementation": "sqlite", "url": str(db_path)} raw = {**raw, "implementation": "sqlite", "url": str(db_path)}
click.secho(f"using database: {db_path}", fg="bright_black") if not quiet:
click.secho(f"using database: {db_path}", fg="bright_black", err=True)
return msgspec.convert(raw, DatabaseConfig) return msgspec.convert(raw, DatabaseConfig)
def _read_prompt_text(prompt: str | None, *, stdin_isatty: bool) -> str | None:
"""Resolve one-shot prompt from ``-p`` and/or non-TTY stdin."""
chunks: list[str] = []
if prompt is not None and prompt.strip():
chunks.append(prompt)
if not stdin_isatty:
data = sys.stdin.read()
if data.strip():
chunks.append(data.rstrip("\n"))
if not chunks:
return None
text = "\n".join(chunks).strip()
return text or None
def _setup_workspace_and_hooks(
store: ConfigStore,
workspace: Path,
*,
interactive: bool,
) -> None:
_ = set_workspace_root(workspace)
from plyngent.tools import set_path_denylist
set_path_denylist(store.agent_config.path_denylist or None)
if interactive:
install_cli_limit_hooks()
else:
from plyngent.tools.process.pty_session import PtyManager
PtyManager.set_limit_continue_hook(None)
async def _bind_session(
state: ReplState,
*,
session_id: int | None,
new_session: bool,
oneshot: bool,
quiet: bool,
) -> None:
if session_id is not None:
try:
await state.resume_session(session_id)
except ValueError as exc:
raise click.ClickException(str(exc)) from exc
if not quiet and not oneshot:
click.echo(
f"resumed session {session_id} ({len(state.agent.messages)} messages) workspace={state.workspace}",
err=True,
)
return
if new_session or oneshot:
await state.new_session()
if not quiet and not oneshot:
click.echo(
f"new session {state.session_id} (workspace={state.workspace})",
err=True,
)
return
mode = await state.resume_latest_or_new()
if quiet:
return
if mode == "resume":
click.echo(
f"resumed latest session {state.session_id} for this workspace "
f"({len(state.agent.messages)} messages); use --new for a fresh chat",
err=True,
)
else:
click.echo(
f"new session {state.session_id} (workspace={state.workspace})",
err=True,
)
async def _run_oneshot(state: ReplState, prompt_text: str) -> int:
try:
ok = await run_user_text_with_retries(state.agent, prompt_text, delays=())
except asyncio.CancelledError:
return EXIT_CANCELLED
except KeyboardInterrupt:
return EXIT_CANCELLED
return EXIT_OK if ok else EXIT_TURN_FAILED
async def _run_chat( async def _run_chat(
*, *,
config_path: Path | None, config_path: Path | None,
@@ -53,25 +144,37 @@ async def _run_chat(
session_id: int | None, session_id: int | None,
max_rounds: int, max_rounds: int,
new_session: bool, new_session: bool,
) -> None: prompt_text: str | None,
stream: bool,
yes: bool,
quiet: bool,
) -> int:
oneshot = prompt_text is not None
interactive = not oneshot and sys.stdin.isatty() and sys.stdout.isatty()
if oneshot:
configure_prompting(backend=NonInteractiveBackend())
store = _load_config(config_path) store = _load_config(config_path)
if store.bad_providers: if store.bad_providers and not quiet:
names = ", ".join(sorted(store.bad_providers.keys())) names = ", ".join(sorted(store.bad_providers.keys()))
click.secho(f"warning: ignored bad providers: {names}", fg="yellow") click.secho(f"warning: ignored bad providers: {names}", fg="yellow", err=True)
try: try:
pname, provider = select_provider(store.providers, preferred=provider_name) pname, provider = select_provider(
model_id = select_model(provider, preferred=model) store.providers,
_ = create_client(provider) # fail early if unsupported preferred=provider_name,
interactive=interactive,
)
model_id = select_model(provider, preferred=model, interactive=interactive)
_ = create_client(provider)
except ProviderNotSupportedError as exc: except ProviderNotSupportedError as exc:
raise click.ClickException(str(exc)) from exc raise click.ClickException(str(exc)) from exc
_ = set_workspace_root(workspace) _setup_workspace_and_hooks(store, workspace, interactive=interactive)
from plyngent.tools import set_path_denylist confirm_destructive: bool | None = False if yes else None
set_path_denylist(store.agent_config.path_denylist or None) memory = await MemoryStore.open(_database_config(store, quiet=quiet or oneshot))
install_cli_limit_hooks()
memory = await MemoryStore.open(_database_config(store))
try: try:
state = ReplState( state = ReplState(
config=store, config=store,
@@ -82,29 +185,27 @@ async def _run_chat(
model=model_id, model=model_id,
tools_enabled=tools, tools_enabled=tools,
max_rounds=max_rounds, max_rounds=max_rounds,
stream_enabled=stream,
interactive_limits=interactive,
confirm_destructive=confirm_destructive,
) )
click.secho(f"workspace: {state.workspace}", fg="bright_black") if not quiet and not oneshot:
if session_id is not None: click.secho(f"workspace: {state.workspace}", fg="bright_black", err=True)
try:
await state.resume_session(session_id) await _bind_session(
except ValueError as exc: state,
raise click.ClickException(str(exc)) from exc session_id=session_id,
click.echo( new_session=new_session,
f"resumed session {session_id} ({len(state.agent.messages)} messages) workspace={state.workspace}" oneshot=oneshot,
) quiet=quiet,
elif new_session: )
await state.new_session()
click.echo(f"new session {state.session_id} (workspace={state.workspace})") if oneshot:
else: assert prompt_text is not None
mode = await state.resume_latest_or_new() return await _run_oneshot(state, prompt_text)
if mode == "resume":
click.echo(
f"resumed latest session {state.session_id} for this workspace "
f"({len(state.agent.messages)} messages); use --new for a fresh chat"
)
else:
click.echo(f"new session {state.session_id} (workspace={state.workspace})")
await run_repl(state) await run_repl(state)
return EXIT_OK
finally: finally:
await memory.close() await memory.close()
@@ -147,6 +248,31 @@ def main() -> None:
show_default=True, show_default=True,
help="Max tool-loop rounds per user turn.", help="Max tool-loop rounds per user turn.",
) )
@click.option(
"-p",
"--prompt",
"prompt",
default=None,
help="One-shot user message (non-interactive). Also reads stdin when not a TTY.",
)
@click.option(
"--stream/--no-stream",
default=True,
show_default=True,
help="Stream model output (one-shot and REPL default).",
)
@click.option(
"--yes",
is_flag=True,
default=False,
help="Allow destructive tools without confirm (one-shot / non-interactive).",
)
@click.option(
"--quiet",
is_flag=True,
default=False,
help="Less status noise on stderr.",
)
def chat_cmd( def chat_cmd(
config_path: Path | None, config_path: Path | None,
provider_name: str | None, provider_name: str | None,
@@ -156,16 +282,30 @@ def chat_cmd(
session_id: int | None, session_id: int | None,
new_session: bool, # noqa: FBT001 new_session: bool, # noqa: FBT001
max_rounds: int, max_rounds: int,
prompt: str | None,
stream: bool, # noqa: FBT001
yes: bool, # noqa: FBT001
quiet: bool, # noqa: FBT001
) -> None: ) -> None:
"""Interactive chat REPL with optional tools and session memory.""" """Interactive chat REPL, or one-shot with ``-p`` / stdin.
Exit codes (one-shot): 0 ok, 1 config/usage, 2 cancelled, 3 turn failed.
"""
if max_rounds < 1: if max_rounds < 1:
msg = "--max-rounds must be >= 1" msg = "--max-rounds must be >= 1"
raise click.ClickException(msg) raise click.ClickException(msg)
if session_id is not None and new_session: if session_id is not None and new_session:
msg = "use either --session or --new, not both" msg = "use either --session or --new, not both"
raise click.ClickException(msg) raise click.ClickException(msg)
prompt_text = _read_prompt_text(prompt, stdin_isatty=sys.stdin.isatty())
if prompt is None and prompt_text is None and not sys.stdin.isatty():
# Non-TTY with empty stdin and no -p: still require an explicit prompt.
msg = "no prompt: pass -p/--prompt or pipe text on stdin"
raise click.ClickException(msg)
root = workspace if workspace is not None else Path.cwd() root = workspace if workspace is not None else Path.cwd()
asyncio.run( code = asyncio.run(
_run_chat( _run_chat(
config_path=config_path, config_path=config_path,
provider_name=provider_name, provider_name=provider_name,
@@ -175,8 +315,14 @@ def chat_cmd(
session_id=session_id, session_id=session_id,
max_rounds=max_rounds, max_rounds=max_rounds,
new_session=new_session, new_session=new_session,
prompt_text=prompt_text,
stream=stream,
yes=yes,
quiet=quiet,
) )
) )
if code != EXIT_OK:
raise SystemExit(code)
@main.command("providers") @main.command("providers")
+8
View File
@@ -0,0 +1,8 @@
"""Process exit codes for ``plyngent chat`` (one-shot and fatal paths)."""
from __future__ import annotations
EXIT_OK = 0
EXIT_ERROR = 1 # config / usage / fatal
EXIT_CANCELLED = 2
EXIT_TURN_FAILED = 3 # API / retry exhausted / incomplete turn
+8 -3
View File
@@ -185,9 +185,14 @@ async def run_turn_with_retries(
return True return True
async def run_user_text_with_retries(agent: ChatAgent, text: str) -> bool: async def run_user_text_with_retries(
"""Send a new user message with auto-retry.""" agent: ChatAgent,
return await run_turn_with_retries(agent, starter=lambda: agent.run(text)) text: str,
*,
delays: tuple[float, ...] = DEFAULT_RETRY_DELAYS_SECONDS,
) -> bool:
"""Send a new user message with auto-retry (empty ``delays`` = no auto-retry)."""
return await run_turn_with_retries(agent, starter=lambda: agent.run(text), delays=delays)
async def retry_pending_with_retries(agent: ChatAgent) -> bool: async def retry_pending_with_retries(agent: ChatAgent) -> bool:
+15 -2
View File
@@ -14,6 +14,7 @@ def select_provider(
providers: Mapping[str, Provider], providers: Mapping[str, Provider],
*, *,
preferred: str | None = None, preferred: str | None = None,
interactive: bool = True,
) -> tuple[str, Provider]: ) -> tuple[str, Provider]:
"""Pick a provider by name or interactive prompt.""" """Pick a provider by name or interactive prompt."""
if not providers: if not providers:
@@ -29,9 +30,13 @@ def select_provider(
if len(names) == 1: if len(names) == 1:
name = names[0] name = names[0]
click.echo(f"Using provider: {name}") click.echo(f"Using provider: {name}", err=True)
return name, providers[name] return name, providers[name]
if not interactive:
msg = f"multiple providers; pass --provider ({', '.join(names)})"
raise click.ClickException(msg)
click.echo("Available providers:") click.echo("Available providers:")
for index, name in enumerate(names, start=1): for index, name in enumerate(names, start=1):
preset = type(providers[name]).__struct_config__.tag preset = type(providers[name]).__struct_config__.tag
@@ -44,6 +49,7 @@ def select_model(
provider: Provider, provider: Provider,
*, *,
preferred: str | None = None, preferred: str | None = None,
interactive: bool = True,
) -> str: ) -> str:
"""Pick a model id from provider.models or free-form prompt.""" """Pick a model id from provider.models or free-form prompt."""
model_names = sorted(provider.models.keys()) model_names = sorted(provider.models.keys())
@@ -55,9 +61,16 @@ def select_model(
if len(model_names) == 1: if len(model_names) == 1:
model = model_names[0] model = model_names[0]
click.echo(f"Using model: {model}") click.echo(f"Using model: {model}", err=True)
return model return model
if not interactive:
if model_names:
msg = f"multiple models; pass --model ({', '.join(model_names)})"
else:
msg = "no models listed; pass --model"
raise click.ClickException(msg)
if model_names: if model_names:
click.echo("Available models:") click.echo("Available models:")
for index, name in enumerate(model_names, start=1): for index, name in enumerate(model_names, start=1):
+12 -3
View File
@@ -31,6 +31,10 @@ class ReplState:
max_rounds: int = DEFAULT_MAX_ROUNDS max_rounds: int = DEFAULT_MAX_ROUNDS
stream_enabled: bool = True stream_enabled: bool = True
verbose: bool = False verbose: bool = False
# One-shot / scripts: never prompt to raise tool-loop limits.
interactive_limits: bool = True
# When False, skip destructive-tool confirms (e.g. --yes).
confirm_destructive: bool | None = None
# Set by /edit; REPL sends as the next user turn then clears. # Set by /edit; REPL sends as the next user turn then clears.
pending_user_text: str | None = None pending_user_text: str | None = None
client: ChatClient = field(init=False) client: ChatClient = field(init=False)
@@ -56,14 +60,18 @@ class ReplState:
raise RuntimeError(msg) raise RuntimeError(msg)
return key return key
def _confirm_destructive(self) -> bool:
if self.confirm_destructive is not None:
return self.confirm_destructive
return self.config.agent_config.confirm_destructive
def _tool_registry(self) -> ToolRegistry | None: def _tool_registry(self) -> ToolRegistry | None:
if not self.tools_enabled: if not self.tools_enabled:
return None return None
from plyngent.cli.limits import prompt_confirm_tool_async from plyngent.cli.limits import prompt_confirm_tool_async
from plyngent.tools.danger import classify_danger from plyngent.tools.danger import classify_danger
agent_cfg = self.config.agent_config if self._confirm_destructive():
if agent_cfg.confirm_destructive:
return ToolRegistry( return ToolRegistry(
list(DEFAULT_TOOLS), list(DEFAULT_TOOLS),
danger=classify_danger, danger=classify_danger,
@@ -76,6 +84,7 @@ class ReplState:
agent_cfg = self.config.agent_config agent_cfg = self.config.agent_config
system_prompt = agent_cfg.system_prompt or None system_prompt = agent_cfg.system_prompt or None
on_limit = prompt_continue_limit_async if self.interactive_limits else None
return ChatAgent( return ChatAgent(
self.client, self.client,
model=self.model, model=self.model,
@@ -83,7 +92,7 @@ class ReplState:
memory=self.memory, memory=self.memory,
session_id=self.session_id, session_id=self.session_id,
max_rounds=self.max_rounds, max_rounds=self.max_rounds,
on_limit=prompt_continue_limit_async, on_limit=on_limit,
stream=self.stream_enabled, stream=self.stream_enabled,
system_prompt=system_prompt, system_prompt=system_prompt,
max_tool_result_chars=agent_cfg.max_tool_result_chars, max_tool_result_chars=agent_cfg.max_tool_result_chars,
+159
View File
@@ -0,0 +1,159 @@
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING, Literal, overload
import pytest
from click.testing import CliRunner
from plyngent.cli.app import _read_prompt_text, main
from plyngent.cli.exit_codes import EXIT_OK
from plyngent.lmproto.openai_compatible.model import (
AssistantChatMessage,
ChatCompletionChoice,
ChatCompletionChunk,
ChatCompletionResponse,
ChatCompletionsParam,
)
if TYPE_CHECKING:
from collections.abc import AsyncIterator
def test_read_prompt_flag_only() -> None:
assert _read_prompt_text("hello", stdin_isatty=True) == "hello"
assert _read_prompt_text(" ", stdin_isatty=True) is None
assert _read_prompt_text(None, stdin_isatty=True) is None
def test_read_prompt_stdin(monkeypatch: pytest.MonkeyPatch) -> None:
class FakeStdin:
def read(self) -> str:
return "from stdin\n"
monkeypatch.setattr("plyngent.cli.app.sys.stdin", FakeStdin())
assert _read_prompt_text(None, stdin_isatty=False) == "from stdin"
assert _read_prompt_text("flag", stdin_isatty=False) == "flag\nfrom stdin"
def test_chat_oneshot_requires_provider_flags(tmp_path: Path) -> None:
config = tmp_path / "plyngent.toml"
_ = config.write_text(
"""
[providers.a]
preset = "openai-compatible"
url = "https://example.com/v1"
access_key_or_token = "sk"
[providers.a.models]
"m1" = {}
[providers.b]
preset = "openai-compatible"
url = "https://example.com/v1"
access_key_or_token = "sk"
[providers.b.models]
"m2" = {}
""",
encoding="utf-8",
)
runner = CliRunner()
result = runner.invoke(
main,
["chat", "--config", str(config), "-p", "hi", "--workspace", str(tmp_path)],
)
assert result.exit_code != 0
assert "provider" in result.output.lower() or "provider" in (result.stderr or "").lower()
def test_chat_oneshot_success(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
config = tmp_path / "plyngent.toml"
_ = config.write_text(
"""
[providers.local]
preset = "openai-compatible"
url = "https://example.com/v1"
access_key_or_token = "sk"
[providers.local.models]
"tiny" = {}
""",
encoding="utf-8",
)
class DummyClient:
@overload
async def chat_completions(
self, param: ChatCompletionsParam, *, stream: Literal[False] = False
) -> ChatCompletionResponse: ...
@overload
async def chat_completions(
self, param: ChatCompletionsParam, *, stream: Literal[True]
) -> AsyncIterator[ChatCompletionChunk]: ...
async def chat_completions(
self, param: ChatCompletionsParam, *, stream: bool = False
) -> ChatCompletionResponse | AsyncIterator[ChatCompletionChunk]:
del param
if stream:
async def empty() -> AsyncIterator[ChatCompletionChunk]:
if False:
yield # type: ignore[misc]
return
return empty()
return ChatCompletionResponse(
id="1",
object="chat.completion",
created=0,
model="tiny",
choices=[
ChatCompletionChoice(
index=0,
message=AssistantChatMessage(content="pong"),
logprobs={},
finish_reason="stop",
)
],
system_fingerprint="",
usage={},
)
monkeypatch.setattr("plyngent.cli.app.create_client", lambda _p: DummyClient())
monkeypatch.setattr(
"plyngent.cli.state.create_client",
lambda _p: DummyClient(),
)
runner = CliRunner()
result = runner.invoke(
main,
[
"chat",
"--config",
str(config),
"--provider",
"local",
"--model",
"tiny",
"-p",
"ping",
"--no-stream",
"--workspace",
str(tmp_path),
"--quiet",
],
)
assert result.exit_code == EXIT_OK
assert "pong" in result.output
def test_chat_help_mentions_prompt() -> None:
runner = CliRunner()
result = runner.invoke(main, ["chat", "--help"])
assert result.exit_code == 0
assert "--prompt" in result.output or "-p" in result.output
assert "Exit codes" in result.output or "one-shot" in result.output.lower()