From 8ebd28f9f3af7907075076768695384b72ec8027 Mon Sep 17 00:00:00 2001 From: Amanbig Date: Sat, 31 Oct 2026 15:30:30 +0530 Subject: [PATCH 1/3] feat(cli): slash commands for permissions, sessions, hooks and trust; skip-permissions flag Bring the REPL closer to the non-interactive CLI: /permissions can now allow, deny, set and reset (with --session for session-only rules), and new /sessions, /hooks, /trust, /untrust and /exit commands. The CLI trust and sessions commands share the same code. devorch and devorch chat gain --mode, and every entry point gains --dangerously-skip-permissions, which selects AUTO mode: no prompts, while dangerous-command blocks and deny rules still apply. --- CHANGELOG.md | 5 + README.md | 25 +++- cli/commands/_shared.py | 23 ++- cli/commands/ask.py | 6 +- cli/commands/edit.py | 6 +- cli/commands/run.py | 6 +- cli/constants.py | 7 +- cli/main.py | 196 +++++++++++-------------- cli/repl_commands.py | 259 +++++++++++++++++++++++++++++++++ config/project_settings.py | 12 ++ tests/test_repl_commands.py | 277 ++++++++++++++++++++++++++++++++++++ 11 files changed, 702 insertions(+), 120 deletions(-) create mode 100644 cli/repl_commands.py create mode 100644 tests/test_repl_commands.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 113ec4b..349b264 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,10 @@ cost estimates, and a much more robust terminal UI. OpenAI/DeepSeek/Kimi/Gemini automatic caches keep hitting. - **`/cost`** and cost estimates in the per-turn line, `/tokens`, the bottom bar and JSON output; prices are approximate and overridable under `pricing:` in `config.yaml`. +- **More slash commands**, matching the CLI: `/permissions allow|deny|set|reset`, `/sessions`, `/hooks`, `/trust`, + `/untrust`, `/exit`. +- **`--mode` for `devorch` / `devorch chat`, and `--dangerously-skip-permissions`** on every entry point (same as + `--mode auto`: no prompts; dangerous-command blocks and deny rules still apply). - **Input surface**: open input box with a coloured mode label, `Shift+Tab` mode cycling, `Ctrl+J` newline, a width-aware placeholder and bottom bar, context-usage gauge. @@ -42,6 +46,7 @@ cost estimates, and a much more robust terminal UI. - The bottom toolbar re-ran `git` on every keystroke; it is now cached. - The bottom toolbar rendered as a bright reversed bar, and was clipped on narrow terminals, hiding the mode. - Saving config no longer drops a `pricing:` section. +- The slash-command dropdown vanished when a command was fully typed (`/cost`, `/models`). ### Changed - CI runs on pull requests only. diff --git a/README.md b/README.md index 69ddab7..bda912c 100644 --- a/README.md +++ b/README.md @@ -447,7 +447,23 @@ With `--output json` stdout contains a single JSON object (`result`, `is_error`, on failure, so it is safe to use in scripts and CI. `stream-json` emits `tool_call` events as they happen followed by a final `result` event. Available on `ask` and `run`. -All non-interactive commands support `--provider`, `--model`, `--mode`, `--mcp`, and `--no-mcp`. +All non-interactive commands support `--provider`, `--model`, `--mode`, `--mcp`, `--no-mcp` and +`--dangerously-skip-permissions`. `devorch` / `devorch chat` take `--mode` and `--dangerously-skip-permissions` +too, so you can start the REPL in a given mode. + +### Skipping permission prompts + +```bash +devorch --dangerously-skip-permissions # start the REPL in AUTO mode +devorch ask --dangerously-skip-permissions "run the tests and fix failures" +devorch --mode plan # or start in plan / ask mode +``` + +`--dangerously-skip-permissions` is the same as `--mode auto`: **no prompts at all**, so the agent can edit files +and run commands unasked. It is not a full bypass: the built-in dangerous-command blocks (`rm -rf *`, `sudo`, ...) +and your own `deny` rules still apply, and project hooks still run. It conflicts with `--mode ask|plan`, and it +overrides a project's `default_mode`. In the REPL, `/auto` and `/ask` switch the same behaviour on and off, and +`/permissions allow --session shell pytest*` skips prompts for just one kind of command. ## All Slash Commands @@ -484,7 +500,12 @@ All non-interactive commands support `--provider`, `--model`, `--mode`, `--mcp`, | `/mcp start ` | Reconnect a server from config | | `/mcp stop ` | Disconnect a server and remove its tools | | `/config` | Show configuration settings | -| `/permissions` | Show and manage tool permission settings | +| `/permissions` | Show permissions; change them with `/permissions allow\|deny [--session] `, `set `, `reset` | +| `/sessions` | `list [n]`, `show `, `delete ` (resume with `devorch --resume `) | +| `/hooks` | Show active hooks and whether project hooks are trusted | +| `/trust` / `/untrust` | Trust (or stop trusting) this project's `.devorch` settings and hooks | +| `/cost` | Estimated session cost per model | +| `/exit` | Exit DevOrch (also `exit`, `quit`, or Ctrl+C twice) | | `/tasks` | Show multi-step task list and execution progress | ## Configuration diff --git a/cli/commands/_shared.py b/cli/commands/_shared.py index 323bcc2..951e239 100644 --- a/cli/commands/_shared.py +++ b/cli/commands/_shared.py @@ -303,8 +303,27 @@ def print_project_notices(settings) -> None: print_warning(notice) -def resolve_mode(mode_str: str | None, default: AgentMode = AgentMode.AUTO) -> AgentMode: - """Parse and validate a mode string, exit with error on bad input.""" +SKIP_PERMISSIONS_HELP = ( + "Skip every permission prompt (same as --mode auto). Built-in dangerous-command blocks and " + "your deny rules still apply." +) + + +def resolve_mode( + mode_str: str | None, + default: AgentMode = AgentMode.AUTO, + skip_permissions: bool = False, +) -> AgentMode: + """Parse and validate a mode string, exit with error on bad input. + + ``skip_permissions`` (``--dangerously-skip-permissions``) forces AUTO, which never prompts; deny + rules and the built-in dangerous-command blocks are still enforced by the executor. + """ + if skip_permissions: + if mode_str and mode_str.lower() != "auto": + print_error(f"--dangerously-skip-permissions conflicts with --mode {mode_str}") + raise typer.Exit(1) + return AgentMode.AUTO if not mode_str: project_mode = load_project_mode() return AgentMode(project_mode) if project_mode else default diff --git a/cli/commands/ask.py b/cli/commands/ask.py index cdce0b7..fb2a38a 100644 --- a/cli/commands/ask.py +++ b/cli/commands/ask.py @@ -4,6 +4,7 @@ from cli.commands._output import configure_output, run_with_output, validate_format from cli.commands._shared import ( + SKIP_PERMISSIONS_HELP, build_agent, build_tools, console, @@ -30,6 +31,9 @@ def ask( mode: str = typer.Option(None, "--mode", help="Execution mode: ask, auto, plan"), no_mcp: bool = typer.Option(False, "--no-mcp", help="Disable all MCP servers for this run"), mcp: list[str] = typer.Option(None, "--mcp", help="Use only these MCP servers (repeatable)"), + skip_permissions: bool = typer.Option( + False, "--dangerously-skip-permissions", help=SKIP_PERMISSIONS_HELP + ), output: str = typer.Option( "text", "--output", "-o", help="Output format: text, json, stream-json" ), @@ -56,7 +60,7 @@ def ask( llm = create_provider(provider, model, settings) tools = build_tools(settings, no_mcp=no_mcp, mcp_only=mcp or None) - agent_mode = resolve_mode(mode, default=AgentMode.AUTO) + agent_mode = resolve_mode(mode, default=AgentMode.AUTO, skip_permissions=skip_permissions) memory_ctx = MemoryManager().get_context_prompt() project_ctx = ProjectContextLoader().load() project_prompt = project_ctx.to_system_prompt_block() if project_ctx else "" diff --git a/cli/commands/edit.py b/cli/commands/edit.py index 2654f87..12100cc 100644 --- a/cli/commands/edit.py +++ b/cli/commands/edit.py @@ -3,6 +3,7 @@ import typer from cli.commands._shared import ( + SKIP_PERMISSIONS_HELP, build_agent, build_tools, console, @@ -23,6 +24,9 @@ def edit( mode: str = typer.Option("auto", "--mode", help="Execution mode: ask, auto, plan"), no_mcp: bool = typer.Option(False, "--no-mcp", help="Disable all MCP servers for this run"), mcp: list[str] = typer.Option(None, "--mcp", help="Use only these MCP servers (repeatable)"), + skip_permissions: bool = typer.Option( + False, "--dangerously-skip-permissions", help=SKIP_PERMISSIONS_HELP + ), ): """ Edit a specific file using an AI instruction. @@ -42,7 +46,7 @@ def edit( llm = create_provider(provider, model, settings) # edit only needs file-oriented tools — no terminal/task tools tools = build_tools(settings, include_terminal=False, no_mcp=no_mcp, mcp_only=mcp or None) - agent_mode = resolve_mode(mode) + agent_mode = resolve_mode(mode, skip_permissions=skip_permissions) memory_ctx = MemoryManager().get_context_prompt() agent = build_agent(llm, tools, memory_ctx, agent_mode, pricing=settings.pricing) diff --git a/cli/commands/run.py b/cli/commands/run.py index bc87acf..4ac56dc 100644 --- a/cli/commands/run.py +++ b/cli/commands/run.py @@ -4,6 +4,7 @@ from cli.commands._output import configure_output, run_with_output, validate_format from cli.commands._shared import ( + SKIP_PERMISSIONS_HELP, build_agent, build_tools, console, @@ -28,6 +29,9 @@ def run( ), no_mcp: bool = typer.Option(False, "--no-mcp", help="Disable all MCP servers for this run"), mcp: list[str] = typer.Option(None, "--mcp", help="Use only these MCP servers (repeatable)"), + skip_permissions: bool = typer.Option( + False, "--dangerously-skip-permissions", help=SKIP_PERMISSIONS_HELP + ), output: str = typer.Option( "text", "--output", "-o", help="Output format: text, json, stream-json" ), @@ -62,7 +66,7 @@ def run( final_prompt = render_prompt(skill_data, extra) tools = build_tools(settings, no_mcp=no_mcp, mcp_only=mcp or None) - agent_mode = resolve_mode(mode) + agent_mode = resolve_mode(mode, skip_permissions=skip_permissions) memory_ctx = MemoryManager().get_context_prompt() project_ctx = ProjectContextLoader().load() project_prompt = project_ctx.to_system_prompt_block() if project_ctx else "" diff --git a/cli/constants.py b/cli/constants.py index f3471a6..e78396e 100644 --- a/cli/constants.py +++ b/cli/constants.py @@ -44,7 +44,12 @@ "/clear": "Clear conversation history (saves accomplishments to project memory)", "/session": "Show current session ID, model, and message count", "/config": "Show configuration settings and provider status", - "/permissions": "Show and manage tool permission settings", + "/permissions": "Show or change permissions (/permissions allow|deny|set|reset)", + "/sessions": "List, show or delete chat sessions (/sessions list|show |delete )", + "/hooks": "Show active hooks and whether project hooks are trusted", + "/trust": "Trust this project's .devorch settings and hooks (MCP, allow rules, hooks)", + "/untrust": "Revoke trust for this project's .devorch files", + "/exit": "Exit DevOrch (also: exit, quit, Ctrl+C twice)", "/compact": "Summarize and compact conversation history to save tokens", "/models": "Search, browse, and switch models (interactive 15/page pagination)", "/model": "Switch model directly (/model ) or open interactive search", diff --git a/cli/main.py b/cli/main.py index 64b61e3..9e507a1 100644 --- a/cli/main.py +++ b/cli/main.py @@ -10,10 +10,12 @@ from rich.table import Table from cli.commands._shared import ( # noqa: E402 + SKIP_PERMISSIONS_HELP, SYSTEM_PROMPT, # noqa: E402, F401 (used by start_repl via SimplePlanner) SimplePlanner, console, create_provider, + resolve_mode, ) from cli.commands.ask import ask # noqa: E402 from cli.commands.edit import edit # noqa: E402 @@ -39,19 +41,21 @@ toolbar_text, ) from cli.prompt_ui import prompt_message as build_prompt_message +from cli.repl_commands import ( # noqa: E402 + handle_hooks, + handle_permissions, + handle_sessions, + handle_trust, + handle_untrust, + print_session_details, + print_sessions_table, +) from config.permissions import ( PERMISSIONS_FILE, PermissionLevel, get_permissions, reset_permissions, ) -from config.project_settings import ( - HOOKS_REL, - find_hooks_root, - load_project_settings, - trust_root, - untrust_project, -) from config.settings import ( CONFIG_FILE, ProviderConfig, @@ -88,7 +92,6 @@ from utils.logger import ( print_error, print_info, - print_panel, print_response, print_success, print_warning, @@ -678,6 +681,8 @@ def start_repl( resume: str | None = None, message_limit: int = DEFAULT_MESSAGE_LIMIT, show_banner: bool = True, + mode: str | None = None, + skip_permissions: bool = False, ): """Start the interactive REPL session.""" if show_banner: @@ -767,10 +772,13 @@ def start_repl( # Create mode manager (shared between agent and executor) mode_manager = ModeManager( - default_mode=AgentMode(settings.project_default_mode) - if settings.project_default_mode - else AgentMode.ASK + default_mode=resolve_mode(mode, default=AgentMode.ASK, skip_permissions=skip_permissions) ) + if skip_permissions: + print_warning( + "Permission prompts are skipped (AUTO mode). Dangerous-command blocks and your deny " + "rules still apply." + ) for notice in settings.project_notices: print_warning(notice) @@ -921,7 +929,7 @@ def prompt_gutter(width, line_number, wrap_count): key_bindings=prompt_kb, ) - if user_input.lower() in ("exit", "quit", "q"): + if user_input.lower() in ("exit", "quit", "q", "/exit", "/quit"): mcp_manager.stop_all() if agent.history and session_manager.current_session_id: project_memory_manager.update_from_session( @@ -950,6 +958,8 @@ def prompt_gutter(width, line_number, wrap_count): "/session", "/history", "/tokens", + "/cost", + "/sessions", "/copy", "/paste", "/undo", @@ -957,6 +967,7 @@ def prompt_gutter(width, line_number, wrap_count): "/clear", "/compact", "/save", + "/exit", ], "Memory": ["/memory", "/remember", "/forget"], "Skills": ["/skills", "/skill"], @@ -965,6 +976,9 @@ def prompt_gutter(width, line_number, wrap_count): "/tasks", "/config", "/permissions", + "/hooks", + "/trust", + "/untrust", "/mcp", "/status", "/auth", @@ -978,8 +992,6 @@ def prompt_gutter(width, line_number, wrap_count): console.print(f" [cyan]{slash_cmd:<16}[/cyan] {desc}") console.print() - console.print(f" [cyan]{'exit':<16}[/cyan] Exit DevOrch") - # Show available skills as shortcuts skill_names = [s["name"] for s in skill_manager.list_skills()] if skill_names: @@ -1172,20 +1184,36 @@ def prompt_gutter(width, line_number, wrap_count): continue elif cmd == "permissions": - perms = get_permissions() - console.print("\n[bold]Tool Permissions:[/bold]") - for tool_name, perm in perms.tools.items(): - level_color = {"allow": "green", "deny": "red", "ask": "yellow"}.get( - perm.level.value, "white" - ) - console.print( - f" {tool_name}: [{level_color}]{perm.level.value}[/{level_color}]" - ) - if perms.session_allowed: - console.print( - f"\n[green]Session allowed:[/green] {len(perms.session_allowed)} patterns" + handle_permissions( + cmd_arg, + get_permissions(), + confirm=lambda msg: questionary.confirm(msg, default=False).ask(), + ) + continue + + elif cmd == "sessions": + handle_sessions( + cmd_arg, + session_manager, + session_manager.current_session_id, + confirm=lambda msg: questionary.confirm(msg, default=False).ask(), + ) + continue + + elif cmd == "hooks": + handle_hooks() + continue + + elif cmd == "trust": + if handle_trust(lambda msg: questionary.confirm(msg, default=False).ask()): + print_info( + "Restart DevOrch for MCP servers, allow rules and hooks to take effect." ) - console.print() + continue + + elif cmd == "untrust": + handle_untrust() + print_info("Restart DevOrch for this to take effect.") continue elif cmd == "compact": @@ -1999,6 +2027,10 @@ def main_callback( provider: str = typer.Option(None, "--provider", "-p", help="LLM Provider"), model: str = typer.Option(None, "--model", "-m", help="Model name"), resume: str = typer.Option(None, "--resume", "-r", help="Resume session by ID"), + mode: str = typer.Option(None, "--mode", help="Start in this mode: ask, auto, plan"), + skip_permissions: bool = typer.Option( + False, "--dangerously-skip-permissions", help=SKIP_PERMISSIONS_HELP + ), ): """ DevOrch - Your AI Coding Assistant @@ -2022,7 +2054,13 @@ def main_callback( provider = configured_provider # Start REPL - start_repl(provider=provider, model=model, resume=resume) + start_repl( + provider=provider, + model=model, + resume=resume, + mode=mode, + skip_permissions=skip_permissions, + ) @app.command() @@ -2033,6 +2071,10 @@ def chat( message_limit: int = typer.Option( DEFAULT_MESSAGE_LIMIT, "--limit", "-l", help="Messages before auto-summarization" ), + mode: str = typer.Option(None, "--mode", help="Start in this mode: ask, auto, plan"), + skip_permissions: bool = typer.Option( + False, "--dangerously-skip-permissions", help=SKIP_PERMISSIONS_HELP + ), ): """ Start an interactive chat session (alias for running devorch directly). @@ -2046,7 +2088,14 @@ def chat( settings = Settings.load() provider = configured_provider - start_repl(provider=provider, model=model, resume=resume, message_limit=message_limit) + start_repl( + provider=provider, + model=model, + resume=resume, + message_limit=message_limit, + mode=mode, + skip_permissions=skip_permissions, + ) @app.command() @@ -2307,35 +2356,8 @@ def sessions_list( """ List recent chat sessions. """ - session_manager = SessionManager() - sessions = session_manager.list_sessions(limit=limit) - - if not sessions: - print_info("No sessions found.") - return - - table = Table(title="Chat Sessions") - table.add_column("ID", style="cyan") - table.add_column("Name", style="white") - table.add_column("Provider", style="green") - table.add_column("Model", style="blue") - table.add_column("Msgs", justify="right") - table.add_column("Parent", style="dim") - table.add_column("Updated", style="dim") - - for session in sessions: - parent = session.get("parent_session_id") or "-" - table.add_row( - session["id"], - (session["name"] or "-")[:20], - session["provider"], - session["model"][:15], - str(session["message_count"]), - parent[:8] if parent != "-" else "-", - session["updated_at"][:16], - ) - - console.print(table) + print_sessions_table(SessionManager(), limit) + console.print("\n[dim]Resume a session: devorch --resume [/dim]") @sessions_app.command("show") @@ -2343,27 +2365,8 @@ def sessions_show(session_id: str = typer.Argument(..., help="Session ID to show """ Show details of a specific session. """ - session_manager = SessionManager() - - try: - session_info, messages = session_manager.load_session(session_id) - except ValueError as e: - print_error(str(e)) - raise typer.Exit(1) from e - - console.print(f"\n[bold]Session: {session_id}[/bold]") - console.print(f"Name: {session_info.get('name', '-')}") - console.print(f"Provider: {session_info['provider']}") - console.print(f"Model: {session_info['model']}") - console.print(f"Messages: {len(messages)}") - - if session_info.get("parent_session_id"): - console.print(f"Parent: {session_info['parent_session_id']}") - - if session_info.get("summary"): - console.print("\n[bold]Context Summary:[/bold]") - summary = session_info["summary"] - print_panel(summary[:500] + "..." if len(summary) > 500 else summary, border_style="dim") + if not print_session_details(SessionManager(), session_id): + raise typer.Exit(1) @sessions_app.command("delete") @@ -2514,39 +2517,13 @@ def permissions_reset(force: bool = typer.Option(False, "--force", "-f", help="S print_success("Permissions reset to defaults.") -def _project_trust_files() -> list[tuple[Path, Path]]: - """(root, file) pairs needing trust for the current project: settings and hooks.""" - pairs: list[tuple[Path, Path]] = [] - ps = load_project_settings() - if ps is not None: - pairs.append((ps.root, ps.path)) - hooks_root = find_hooks_root() - if hooks_root is not None: - pairs.append((hooks_root, hooks_root / HOOKS_REL)) - return pairs - - @app.command("trust") def trust_command(): """ Trust this project's .devorch settings and hooks (MCP servers, allow rules, auto mode, hooks). """ - pairs = _project_trust_files() - if not pairs: - print_warning("No .devorch/settings.yaml or hooks.yaml found here or in a parent folder.") + if not handle_trust(lambda msg: questionary.confirm(msg, default=False).ask()): raise typer.Exit(1) - for _root, path in pairs: - console.print(f"[bold]{path}[/bold]") - console.print(path.read_text(encoding="utf-8"), markup=False, highlight=False) - if not questionary.confirm( - "Trust these files? (MCP servers and hooks run programs; allow rules skip prompts)", - default=False, - ).ask(): - print_info("Not trusted.") - raise typer.Exit(1) - for root in dict.fromkeys(root for root, _ in pairs): - trust_root(root) - print_success(f"Trusted {root}. Re-run `devorch trust` if these files change.") @app.command("untrust") @@ -2554,12 +2531,7 @@ def untrust_command(): """ Revoke trust for this project's .devorch settings and hooks. """ - roots = list(dict.fromkeys(root for root, _ in _project_trust_files())) or [Path.cwd()] - for root in roots: - if untrust_project(root): - print_success(f"Revoked trust for {root}") - else: - print_info(f"{root} was not trusted.") + handle_untrust() @app.command(name="init") diff --git a/cli/repl_commands.py b/cli/repl_commands.py new file mode 100644 index 0000000..7fc4eb6 --- /dev/null +++ b/cli/repl_commands.py @@ -0,0 +1,259 @@ +"""Slash-command handlers that mirror the non-interactive CLI (`devorch permissions|sessions|trust`). + +Each handler prints through ``console`` and takes its collaborators as arguments, so it can be tested +without a running REPL. The CLI commands call the same functions where they share logic. +""" + +import shlex +from collections.abc import Callable +from pathlib import Path + +from rich.table import Table + +from config.permissions import PermissionLevel, Permissions +from config.project_settings import ( + is_trusted, + project_trust_files, + trust_root, + untrust_project, +) +from core.hooks import HookManager +from utils.logger import get_console, print_error, print_info, print_success, print_warning + +console = get_console() + +Confirm = Callable[[str], bool] + +PERMISSIONS_USAGE = ( + "Usage: /permissions [list]\n" + " /permissions allow [--session] e.g. /permissions allow shell git *\n" + " /permissions deny [--session] \n" + " /permissions set \n" + " /permissions reset" +) + + +# ── permissions ───────────────────────────────────────────────────────────── + + +def show_permissions(perms: Permissions) -> None: + level_color = {"allow": "green", "deny": "red", "ask": "yellow"} + console.print("\n[bold]Tool Permissions[/bold]") + for tool_name, perm in perms.tools.items(): + color = level_color.get(perm.level.value, "white") + counts = [] + if perm.allowed_patterns: + counts.append(f"{len(perm.allowed_patterns)} allowed patterns") + if perm.denied_patterns: + counts.append(f"{len(perm.denied_patterns)} denied patterns") + extra = f" [dim]({', '.join(counts)})[/dim]" if counts else "" + console.print(f" {tool_name}: [{color}]{perm.level.value}[/{color}]{extra}") + if perms.session_allowed: + console.print(f"\n[green]Session allowed:[/green] {len(perms.session_allowed)} patterns") + if perms.session_denied: + console.print(f"[red]Session denied:[/red] {len(perms.session_denied)} patterns") + console.print("[dim] Details and changes: /permissions allow|deny|set|reset[/dim]\n") + + +def handle_permissions(arg: str | None, perms: Permissions, confirm: Confirm | None = None) -> None: + try: + tokens = shlex.split(arg or "") + except ValueError as e: + print_error(f"Could not parse arguments: {e}") + return + if not tokens or tokens[0] == "list": + show_permissions(perms) + return + + action, rest = tokens[0].lower(), tokens[1:] + session_only = "--session" in rest + rest = [t for t in rest if t != "--session"] + + if action in ("allow", "deny"): + if len(rest) < 2: + print_error(f"/permissions {action} needs a tool and a pattern.") + print_info(PERMISSIONS_USAGE) + return + tool, pattern = rest[0], " ".join(rest[1:]) + if action == "allow": + perms.add_allowed_pattern(tool, pattern, session_only=session_only) + else: + perms.add_denied_pattern(tool, pattern, session_only=session_only) + scope = "for this session" if session_only else "(saved)" + print_success(f"{action.capitalize()}ed {tool}: {pattern} {scope}") + return + + if action == "set": + if len(rest) != 2: + print_error("/permissions set needs a tool and a level.") + print_info(PERMISSIONS_USAGE) + return + try: + level = PermissionLevel(rest[1].lower()) + except ValueError: + print_error(f"Invalid level '{rest[1]}'. Use: allow, deny or ask.") + return + perms.set_tool_permission(rest[0], level) + print_success(f"Set {rest[0]} permission to: {level.value}") + return + + if action == "reset": + from config.permissions import get_permissions, reset_permissions + + if confirm is not None and not confirm("Reset all permissions to defaults?"): + print_warning("Cancelled.") + return + reset_permissions() + # keep the live object in sync so the rest of this session uses the defaults too + fresh = get_permissions() + perms.tools = fresh.tools + perms.session_allowed = [] + perms.session_denied = [] + print_success("Permissions reset to defaults.") + return + + print_error(f"Unknown /permissions action '{action}'.") + print_info(PERMISSIONS_USAGE) + + +# ── sessions ──────────────────────────────────────────────────────────────── + + +def print_sessions_table(session_manager, limit: int = 20, current_id: str | None = None) -> None: + sessions = session_manager.list_sessions(limit=limit) + if not sessions: + print_info("No sessions found.") + return + table = Table(title="Chat Sessions") + table.add_column("ID", style="cyan") + table.add_column("Name", style="white") + table.add_column("Provider", style="green") + table.add_column("Model", style="blue") + table.add_column("Msgs", justify="right") + table.add_column("Parent", style="dim") + table.add_column("Updated", style="dim") + for session in sessions: + sid = session["id"] + table.add_row( + sid + (" ◀ current" if sid == current_id else ""), + (session["name"] or "-")[:20], + session["provider"], + session["model"], + str(session["message_count"]), + session.get("parent_session_id") or "-", + str(session["updated_at"])[:16], + ) + console.print(table) + + +def print_session_details(session_manager, session_id: str) -> bool: + try: + info, messages = session_manager.load_session(session_id) + except ValueError as e: + print_error(str(e)) + return False + console.print(f"\n[bold]Session: {session_id}[/bold]", highlight=False) + console.print(f"Name: {info.get('name') or '-'}", highlight=False) + console.print(f"Provider: {info['provider']}", highlight=False) + console.print(f"Model: {info['model']}", highlight=False) + console.print(f"Messages: {len(messages)}", highlight=False) + if info.get("parent_session_id"): + console.print(f"Parent: {info['parent_session_id']}", highlight=False) + if info.get("summary"): + summary = info["summary"] + console.print("\n[bold]Context summary:[/bold]") + console.print(summary[:500] + ("..." if len(summary) > 500 else ""), markup=False) + return True + + +def handle_sessions( + arg: str | None, session_manager, current_id: str | None, confirm: Confirm | None = None +) -> None: + tokens = (arg or "").split() + action = tokens[0].lower() if tokens else "list" + if action == "list": + limit = 20 + if len(tokens) > 1: + if not tokens[1].isdigit(): + print_error("Usage: /sessions list [count]") + return + limit = max(1, int(tokens[1])) + print_sessions_table(session_manager, limit, current_id) + console.print("[dim] Resume a session with: devorch --resume [/dim]\n") + elif action == "show" and len(tokens) == 2: + print_session_details(session_manager, tokens[1]) + elif action == "delete" and len(tokens) == 2: + sid = tokens[1] + if sid == current_id: + print_error("That is the current session; it is still in use.") + return + if not session_manager.session_exists(sid): + print_error(f"Session '{sid}' not found.") + return + if confirm is not None and not confirm(f"Delete session {sid}?"): + print_warning("Cancelled.") + return + if session_manager.delete_session(sid): + print_success(f"Session '{sid}' deleted.") + else: + print_error("Failed to delete session.") + else: + print_error("Usage: /sessions [list [count]] | show | delete ") + + +# ── hooks / trust ─────────────────────────────────────────────────────────── + + +def handle_hooks(cwd: Path | None = None) -> None: + mgr = HookManager.load(cwd) + console.print("\n[bold]Hooks[/bold]") + if not mgr.hooks or not any(mgr.hooks.values()): + console.print(" [dim]No active hooks.[/dim]") + for event, hooks in mgr.hooks.items(): + for hook in hooks: + matcher = hook.matcher or "*" + line = f" {event} [{matcher}] {hook.command} ({hook.timeout}s)" + console.print(line, markup=False, highlight=False) + for notice in mgr.notices: + print_warning(notice) + console.print( + "[dim] Configure in ~/.devorch/hooks.yaml or /.devorch/hooks.yaml; " + "project hooks need /trust.[/dim]\n" + ) + + +def handle_trust(confirm: Confirm, start: Path | None = None) -> bool: + """Show the project's settings/hooks files and trust them if confirmed. True if trusted.""" + pairs = project_trust_files(start) + if not pairs: + print_warning("No .devorch/settings.yaml or hooks.yaml found here or in a parent folder.") + return False + for _root, path in pairs: + console.print(f"[bold]{path}[/bold]", highlight=False) + console.print(path.read_text(encoding="utf-8"), markup=False, highlight=False) + if not confirm( + "Trust these files? (MCP servers and hooks run programs; allow rules skip prompts)" + ): + print_info("Not trusted.") + return False + for root in dict.fromkeys(root for root, _ in pairs): + trust_root(root) + print_success(f"Trusted {root}. Re-run trust if these files change.") + return True + + +def handle_untrust(start: Path | None = None) -> None: + roots = list(dict.fromkeys(root for root, _ in project_trust_files(start))) or [ + start or Path.cwd() + ] + for root in roots: + if untrust_project(root): + print_success(f"Revoked trust for {root}") + else: + print_info(f"{root} was not trusted.") + + +def trust_status(start: Path | None = None) -> list[tuple[Path, bool]]: + return [ + (root, is_trusted(root)) for root in dict.fromkeys(r for r, _ in project_trust_files(start)) + ] diff --git a/config/project_settings.py b/config/project_settings.py index 089095f..8128a78 100644 --- a/config/project_settings.py +++ b/config/project_settings.py @@ -221,3 +221,15 @@ def load_project_settings(start: Path | None = None) -> ProjectSettings | None: f"review the file, then run `devorch trust` to enable" ) return ps + + +def project_trust_files(start: Path | None = None) -> list[tuple[Path, Path]]: + """(root, file) pairs that need trust for the project containing ``start``: settings and hooks.""" + pairs: list[tuple[Path, Path]] = [] + settings_file = find_settings_file(start) + if settings_file is not None: + pairs.append((settings_file.parent.parent, settings_file)) + hooks_root = find_hooks_root(start) + if hooks_root is not None: + pairs.append((hooks_root, hooks_root / HOOKS_REL)) + return pairs diff --git a/tests/test_repl_commands.py b/tests/test_repl_commands.py new file mode 100644 index 0000000..c6b6e7e --- /dev/null +++ b/tests/test_repl_commands.py @@ -0,0 +1,277 @@ +"""Tests for the slash commands that mirror the CLI, and the skip-permissions flag.""" + +import io +import re +from pathlib import Path + +import pytest +import typer +from rich.console import Console +from typer.testing import CliRunner + +from cli import repl_commands as rc +from cli.commands._shared import resolve_mode +from config import permissions as perm_mod +from config.permissions import PermissionLevel, Permissions +from config.project_settings import is_trusted +from core import sessions as sessions_mod +from core.executor import ToolExecutor +from core.modes import AgentMode, ModeManager +from core.sessions import SessionManager +from tools.shell import ShellTool +from utils import logger + + +@pytest.fixture +def out(monkeypatch): + buf = io.StringIO() + con = Console(file=buf, width=120, force_terminal=False, color_system=None) + monkeypatch.setattr(rc, "console", con) + monkeypatch.setattr(logger, "console", con) + return buf + + +@pytest.fixture +def home(tmp_path, monkeypatch): + h = tmp_path / "home" + (h / ".devorch").mkdir(parents=True) + monkeypatch.setattr(Path, "home", lambda: h) + monkeypatch.setattr(perm_mod, "CONFIG_DIR", h / ".devorch") + monkeypatch.setattr(perm_mod, "PERMISSIONS_FILE", h / ".devorch" / "permissions.yaml") + monkeypatch.setattr(perm_mod, "_permissions", None) + monkeypatch.setattr(sessions_mod, "DATA_DIR", h / ".devorch") + monkeypatch.setattr(sessions_mod, "DB_PATH", h / ".devorch" / "sessions.db") + return h + + +# ── /permissions ──────────────────────────────────────────────────────────── + + +def test_permissions_list_is_default(home, out): + rc.handle_permissions("", Permissions.load()) + assert "Tool Permissions" in out.getvalue() and "shell" in out.getvalue() + out.truncate(0) + rc.handle_permissions("list", Permissions.load()) + assert "Tool Permissions" in out.getvalue() + + +def test_allow_and_deny_patterns_with_spaces_and_quotes(home, out): + perms = Permissions.load() + rc.handle_permissions("allow shell git log *", perms) + rc.handle_permissions('deny shell "git push*"', perms) + assert "git log *" in perms.tools["shell"].allowed_patterns + assert "git push*" in perms.tools["shell"].denied_patterns + # persisted + again = Permissions.load() + assert "git log *" in again.tools["shell"].allowed_patterns + + +def test_session_flag_is_not_persisted(home, out): + perms = Permissions.load() + rc.handle_permissions("allow --session shell pytest*", perms) + assert "pytest*" in perms.session_allowed or any("pytest" in p for p in perms.session_allowed) + assert "pytest*" not in Permissions.load().tools["shell"].allowed_patterns + assert "for this session" in out.getvalue() + + +def test_set_level_and_validation(home, out): + perms = Permissions.load() + rc.handle_permissions("set filesystem allow", perms) + assert perms.tools["filesystem"].level == PermissionLevel.ALLOW + rc.handle_permissions("set filesystem sometimes", perms) + assert "Invalid level" in out.getvalue() + assert perms.tools["filesystem"].level == PermissionLevel.ALLOW + + +def test_permissions_argument_errors(home, out): + perms = Permissions.load() + for arg in ("allow shell", "deny", "set shell", "bogus x", 'allow shell "unterminated'): + out.truncate(0) + rc.handle_permissions(arg, perms) + assert ( + "Usage" in out.getvalue() or "Unknown" in out.getvalue() or "parse" in out.getvalue() + ), arg + + +def test_permissions_reset_needs_confirmation(home, out): + perms = Permissions.load() + rc.handle_permissions("allow shell make *", perms) + rc.handle_permissions("reset", perms, confirm=lambda m: False) + assert "Cancelled" in out.getvalue() and "make *" in perms.tools["shell"].allowed_patterns + rc.handle_permissions("reset", perms, confirm=lambda m: True) + assert "make *" not in perms.tools["shell"].allowed_patterns # live object reset too + assert not perm_mod.PERMISSIONS_FILE.exists() + + +# ── /sessions ─────────────────────────────────────────────────────────────── + + +def _sessions(home): + sm = SessionManager() + a = sm.create_session("openai", "gpt-4o", name="first") + b = sm.create_session("anthropic", "claude", name="second") + return sm, a, b + + +def test_sessions_list_marks_current(home, out): + sm, a, b = _sessions(home) + rc.handle_sessions("", sm, b) + text = out.getvalue() + assert a in text and b in text and "current" in text and "--resume" in text + + +def test_sessions_list_count_validation(home, out): + sm, *_ = _sessions(home) + rc.handle_sessions("list abc", sm, None) + assert "Usage" in out.getvalue() + + +def test_sessions_show(home, out): + sm, a, _ = _sessions(home) + rc.handle_sessions(f"show {a}", sm, None) + assert "gpt-4o" in out.getvalue() + out.truncate(0) + rc.handle_sessions("show nope", sm, None) + assert "not found" in out.getvalue().lower() or "Error" in out.getvalue() + + +def test_sessions_delete_rules(home, out): + sm, a, b = _sessions(home) + rc.handle_sessions(f"delete {b}", sm, b) # the current one + assert "current session" in out.getvalue() and sm.session_exists(b) + rc.handle_sessions(f"delete {a}", sm, b, confirm=lambda m: False) + assert sm.session_exists(a) + rc.handle_sessions(f"delete {a}", sm, b, confirm=lambda m: True) + assert not sm.session_exists(a) + out.truncate(0) + rc.handle_sessions("delete nope", sm, b, confirm=lambda m: True) + assert "not found" in out.getvalue() + + +def test_sessions_bad_usage(home, out): + sm, *_ = _sessions(home) + for arg in ("show", "delete", "frobnicate"): + out.truncate(0) + rc.handle_sessions(arg, sm, None) + assert "Usage" in out.getvalue(), arg + + +# ── /hooks, /trust, /untrust ──────────────────────────────────────────────── + + +@pytest.fixture +def project(home, monkeypatch): + proj = home / "proj" + (proj / ".devorch").mkdir(parents=True) + (proj / ".devorch" / "hooks.yaml").write_text( + "hooks:\n PreToolUse:\n - matcher: shell\n command: ./guard.sh\n" + ) + (proj / ".devorch" / "settings.yaml").write_text("default_mode: ask\n") + monkeypatch.chdir(proj) + return proj + + +def test_hooks_untrusted_project_shows_notice_not_hooks(project, out): + rc.handle_hooks(project) + text = out.getvalue() + assert "guard.sh" not in text and "untrusted" in text and "No active hooks" in text + + +def test_hooks_trusted_project_lists_them(project, out): + rc.trust_root(project) + rc.handle_hooks(project) + assert "PreToolUse" in out.getvalue() and "./guard.sh" in out.getvalue() + + +def test_trust_declined_then_accepted_then_revoked(project, out): + assert rc.handle_trust(lambda m: False, project) is False and not is_trusted(project) + assert "guard.sh" in out.getvalue() # the file is shown before asking + assert rc.handle_trust(lambda m: True, project) is True and is_trusted(project) + rc.handle_untrust(project) + assert not is_trusted(project) + out.truncate(0) + rc.handle_untrust(project) + assert "was not trusted" in out.getvalue() + + +def test_trust_without_project_files(home, out, tmp_path, monkeypatch): + empty = home / "empty" + empty.mkdir() + monkeypatch.chdir(empty) + assert rc.handle_trust(lambda m: True, empty) is False + assert "No .devorch" in out.getvalue() + + +def test_trust_prints_yaml_brackets_literally(project, out): + (project / ".devorch" / "settings.yaml").write_text( + 'mcp_servers:\n x:\n command: c\n args: ["-y", "[/bold]"]\n' + ) + rc.handle_trust(lambda m: False, project) + assert "[/bold]" in out.getvalue() + + +# ── --dangerously-skip-permissions / --mode ───────────────────────────────── + + +def test_skip_permissions_means_auto(home): + assert resolve_mode(None, default=AgentMode.ASK, skip_permissions=True) == AgentMode.AUTO + assert resolve_mode("auto", skip_permissions=True) == AgentMode.AUTO + + +def test_skip_permissions_conflicts_with_other_modes(home): + with pytest.raises(typer.Exit) as exc: + resolve_mode("ask", skip_permissions=True) + assert exc.value.exit_code == 1 + + +def test_skip_permissions_overrides_project_default_mode(home, project): + assert resolve_mode(None, default=AgentMode.ASK) == AgentMode.ASK # project says ask + assert resolve_mode(None, default=AgentMode.ASK, skip_permissions=True) == AgentMode.AUTO + + +def test_mode_flag_still_works(home): + assert resolve_mode("plan", default=AgentMode.ASK) == AgentMode.PLAN + + +def test_skipping_prompts_still_enforces_deny_rules_and_dangerous_blocks(home): + # AUTO (what the flag selects) must never prompt, yet blocked commands stay blocked + perms = Permissions.load() + perms.add_denied_pattern("shell", "git push*", session_only=True) + ex = ToolExecutor( + [ShellTool()], + require_confirmation=True, + permissions=perms, + mode_manager=ModeManager(default_mode=AgentMode.AUTO), + ) + + def no_prompt(*a, **k): + raise AssertionError("a permission prompt was shown in AUTO mode") + + ex._ask_permission = no_prompt + assert "hello" in ex.execute("shell", {"command": "echo hello"}) + assert "denied" in ex.execute("shell", {"command": "git push origin main"}).lower() + assert "denied" in ex.execute("shell", {"command": "rm -rf *"}).lower() + + +def test_flag_is_available_on_every_entry_point(): + from cli.main import app + + runner = CliRunner() + for args in ([], ["chat"], ["ask"], ["run"], ["edit"]): + result = runner.invoke(app, [*args, "--help"]) + assert result.exit_code == 0, args + assert "--dangerously-skip-permissions" in result.output, args + assert "--mode" in result.output, args + + +# ── dropdown <-> dispatcher consistency ───────────────────────────────────── + + +def test_every_slash_command_in_the_dropdown_has_a_handler(): + from cli.constants import SLASH_COMMANDS + + source = Path(__file__).resolve().parents[1].joinpath("cli", "main.py").read_text() + for cmd in SLASH_COMMANDS: + name = cmd[1:] + pattern = rf'"{re.escape(name)}"|"{re.escape(cmd)}"' + assert re.search(pattern, source), f"{cmd} is offered in the dropdown but never handled" From cb2f24d495a39663db7ac84283f3cc772374d90b Mon Sep 17 00:00:00 2001 From: Amanbig Date: Sat, 31 Oct 2026 15:37:47 +0530 Subject: [PATCH 2/3] test: stop asserting on colourised --help text and harden two JSON tests test_flag_is_available_on_every_entry_point searched the --help output, which is colourised, wrapped and truncated depending on the terminal, so it failed in CI (ANSI codes split the option names). Inspect the registered click options instead. Two JSON output tests used a real agent whose spinner can write to stdout when colours are forced; give them the same UI redirect ask applies. The suite now passes with and without FORCE_COLOR, NO_COLOR and narrow terminals. --- tests/test_pricing.py | 18 ++++++++++++++++-- tests/test_repl_commands.py | 35 ++++++++++++++++++++++++++++------- 2 files changed, 44 insertions(+), 9 deletions(-) diff --git a/tests/test_pricing.py b/tests/test_pricing.py index 962c470..7a3e77a 100644 --- a/tests/test_pricing.py +++ b/tests/test_pricing.py @@ -169,14 +169,28 @@ def test_sub_agent_cost_is_shared_with_parent(): assert agent.costs.total == pytest.approx(12.5) -def test_json_output_includes_cost(capsys): +@pytest.fixture +def json_mode_console(monkeypatch): + """What `ask --output json` does before running: UI output goes elsewhere, keeping stdout pure. + + A StringIO sink (not sys.stderr): pytest's per-phase capture streams are closed by the time the + test body runs. + """ + import io + + from utils.logger import get_console + + monkeypatch.setattr(get_console(), "_file", io.StringIO()) + + +def test_json_output_includes_cost(capsys, json_mode_console): agent = make_agent() run_with_output(agent, "hi", output="json", provider="openai", model="gpt-4o") usage = json.loads(capsys.readouterr().out)["usage"] assert usage["cost_usd"] == pytest.approx(12.5) and usage["cost_complete"] is True -def test_json_output_cost_null_for_unpriced_model(capsys): +def test_json_output_cost_null_for_unpriced_model(capsys, json_mode_console): prov = UsageProvider() prov.model = "mystery-model" agent = make_agent(prov) diff --git a/tests/test_repl_commands.py b/tests/test_repl_commands.py index c6b6e7e..21933da 100644 --- a/tests/test_repl_commands.py +++ b/tests/test_repl_commands.py @@ -7,7 +7,6 @@ import pytest import typer from rich.console import Console -from typer.testing import CliRunner from cli import repl_commands as rc from cli.commands._shared import resolve_mode @@ -253,15 +252,37 @@ def no_prompt(*a, **k): assert "denied" in ex.execute("shell", {"command": "rm -rf *"}).lower() +def _option_names(command) -> set[str]: + return {opt for param in command.params for opt in getattr(param, "opts", [])} + + def test_flag_is_available_on_every_entry_point(): + # Inspect the registered options, not the --help text: help output is colourised, wrapped to the + # terminal width and truncated, so substring checks on it break between environments (CI). + import typer.main + + from cli.main import app + + root = typer.main.get_command(app) + entry_points = { + "devorch": root, + **{name: root.commands[name] for name in ("chat", "ask", "run", "edit")}, + } + for name, command in entry_points.items(): + options = _option_names(command) + assert "--dangerously-skip-permissions" in options, name + assert "--mode" in options, name + + +def test_flag_is_a_boolean_switch_with_help_text(): + import typer.main + from cli.main import app - runner = CliRunner() - for args in ([], ["chat"], ["ask"], ["run"], ["edit"]): - result = runner.invoke(app, [*args, "--help"]) - assert result.exit_code == 0, args - assert "--dangerously-skip-permissions" in result.output, args - assert "--mode" in result.output, args + ask = typer.main.get_command(app).commands["ask"] + flag = next(p for p in ask.params if "--dangerously-skip-permissions" in p.opts) + assert flag.is_flag and flag.default is False + assert "same as --mode auto" in flag.help # ── dropdown <-> dispatcher consistency ───────────────────────────────────── From cc9f3fbb3fe600b8ebbbff044910afb660530cbf Mon Sep 17 00:00:00 2001 From: Amanbig Date: Sat, 31 Oct 2026 15:44:28 +0530 Subject: [PATCH 3/3] test: wait for the virtual terminal to settle instead of a fixed sleep The screen-test harness snapshotted after a fixed 0.15s, which is racy on a slow CI runner: one job snapshotted before the first frame was drawn and indexed into an empty screen. Wait at least that long, then until the output has stopped changing, and fail with a clear message if nothing renders. --- tests/_screen.py | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/tests/_screen.py b/tests/_screen.py index c8fc260..1df62c9 100644 --- a/tests/_screen.py +++ b/tests/_screen.py @@ -15,7 +15,16 @@ from prompt_toolkit.output.vt100 import Vt100_Output -def render_screen(keys: str, cols=60, rows=14, wait=0.15, **kwargs) -> pyte.Screen: +def render_screen( + keys: str, + cols=60, + rows=14, + wait=0.15, + limit=8.0, + poll=0.02, + quiet_polls=8, + **kwargs, +) -> pyte.Screen: """Type ``keys`` (without Enter) and snapshot the screen while the prompt is still active.""" class Buf(io.StringIO): @@ -31,15 +40,30 @@ class Buf(io.StringIO): def pre_run(): app = session.app - loop = asyncio.get_event_loop() - def finish(): + async def settle(): + # A fixed sleep is racy on slow CI runners (the snapshot can land before the + # first frame is drawn). Wait at least `wait`, then until the output has stopped + # changing for a while, and give up (clearly) after `limit` seconds. + loop = asyncio.get_running_loop() + deadline = loop.time() + limit + await asyncio.sleep(wait) + last, quiet = -1, 0 + while loop.time() < deadline: + size = len(buf.getvalue()) + quiet = quiet + 1 if size and size == last else 0 + last = size + if quiet >= quiet_polls: + break + await asyncio.sleep(poll) snap.append(buf.getvalue()) app.exit(result="") - loop.call_later(wait, finish) + asyncio.ensure_future(settle()) session.prompt(kwargs.get("message", "> "), pre_run=pre_run) + if not snap or not snap[0]: + raise AssertionError(f"nothing was rendered within {limit}s (keys={keys!r})") screen = pyte.Screen(cols, rows) pyte.Stream(screen).feed(snap[0]) return screen