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/_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 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 new file mode 100644 index 0000000..21933da --- /dev/null +++ b/tests/test_repl_commands.py @@ -0,0 +1,298 @@ +"""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 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 _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 + + 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 ───────────────────────────────────── + + +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"