From 835fe7af9837c70c490bad967cef300e8fa5b854 Mon Sep 17 00:00:00 2001 From: JaviChulvi Date: Thu, 9 Jul 2026 23:31:04 +0200 Subject: [PATCH 1/4] Improve interactive CLI controls --- chulk/cli/__init__.py | 12 +- chulk/cli/commands.py | 297 ++++++++++++++++++++----------- chulk/cli/history.py | 14 ++ chulk/cli/progress.py | 83 +++++++-- chulk/cli/terminal.py | 160 ++++++++++++----- chulk/tests/test_cli_commands.py | 46 +++++ chulk/tests/test_cli_history.py | 25 +++ chulk/tests/test_cli_progress.py | 55 +++++- 8 files changed, 526 insertions(+), 166 deletions(-) create mode 100644 chulk/tests/test_cli_commands.py diff --git a/chulk/cli/__init__.py b/chulk/cli/__init__.py index 20f76e6..f4be1a9 100644 --- a/chulk/cli/__init__.py +++ b/chulk/cli/__init__.py @@ -1,17 +1,27 @@ """Terminal UI helpers for the ChulkHarness CLI.""" -from chulk.cli.commands import CLICommandContext, EXIT_COMMANDS, handle_cli_command +from chulk.cli.commands import ( + CLI_COMMANDS, + CLICommand, + CLICommandContext, + EXIT_COMMANDS, + command_completion_candidates, + handle_cli_command, +) from chulk.cli.history import PromptHistory from chulk.cli.progress import ProgressReporter, ProgressSettings, Spinner from chulk.cli.terminal import TerminalUI __all__ = [ + "CLICommand", "CLICommandContext", + "CLI_COMMANDS", "EXIT_COMMANDS", "PromptHistory", "ProgressReporter", "ProgressSettings", "Spinner", "TerminalUI", + "command_completion_candidates", "handle_cli_command", ] diff --git a/chulk/cli/commands.py b/chulk/cli/commands.py index 1a6e872..b716a8c 100644 --- a/chulk/cli/commands.py +++ b/chulk/cli/commands.py @@ -1,9 +1,10 @@ -"""Interactive slash-command handling.""" +"""Registry-backed interactive slash-command handling.""" from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +from difflib import get_close_matches from chulk.cli.progress import ProgressSettings from chulk.cli.terminal import TerminalUI @@ -12,11 +13,23 @@ from chulk.sessions import AmbiguousSessionError, SessionNotFoundError, SQLiteSessionStore -EXIT_COMMANDS = {"/exit", "/quit", "/q", "exit", "quit"} -HELP_COMMANDS = {"/help", "help", "?"} -VERBOSE_COMMANDS = {"/verbose on", "/verbose off"} -QUIET_COMMANDS = {"/quiet on", "/quiet off"} -SUMMARY_COMMANDS = {"/summary on", "/summary off"} +CommandHandler = Callable[[str, "CLICommandContext"], None] + + +@dataclass(frozen=True) +class CLICommand: + """One discoverable interactive command.""" + + name: str + usage: str + description: str + category: str + handler: CommandHandler | None + aliases: tuple[str, ...] = () + + @property + def names(self) -> tuple[str, ...]: + return (self.name, *self.aliases) @dataclass @@ -28,108 +41,192 @@ class CLICommandContext: terminal: TerminalUI progress_settings: ProgressSettings output_func: Callable[[str], None] + response_func: Callable[[str], None] | None = None session_store: SQLiteSessionStore | None = None agent_factory: Callable[[str], Agent] | None = None switch_agent: Callable[[Agent], None] | None = None +def _help(_arguments: str, context: CLICommandContext) -> None: + context.output_func(context.terminal.help_text(CLI_COMMANDS)) + + +def _status(_arguments: str, context: CLICommandContext) -> None: + if context.config is None: + context.output_func(context.terminal.warning("status unavailable: no config object")) + return + context.output_func(context.terminal.status(context.config, context.agent)) + + +def _context(_arguments: str, context: CLICommandContext) -> None: + context.output_func(context.terminal.context(context.agent)) + + +def _tools(_arguments: str, context: CLICommandContext) -> None: + context.output_func(context.terminal.tools(context.agent)) + + +def _mcp(_arguments: str, context: CLICommandContext) -> None: + if context.config is None: + context.output_func(context.terminal.warning("mcp unavailable: no config object")) + return + context.output_func(context.terminal.mcp(context.config, context.agent)) + + +def _sessions(_arguments: str, context: CLICommandContext) -> None: + if context.session_store is None: + context.output_func(context.terminal.warning("sessions unavailable: no session store")) + return + context.output_func(context.terminal.sessions(context.session_store.list_conversations())) + + +def _history(_arguments: str, context: CLICommandContext) -> None: + if context.session_store is None: + context.output_func(context.terminal.warning("history unavailable: no session store")) + return + messages = context.session_store.list_messages(context.agent.state.conversation_id, limit=40) + context.output_func(context.terminal.history(messages)) + + +def _resume(arguments: str, context: CLICommandContext) -> None: + if not arguments: + context.output_func(context.terminal.warning("usage: /resume ")) + return + if context.agent_factory is None or context.switch_agent is None: + context.output_func(context.terminal.warning("resume unavailable: no agent factory")) + return + try: + next_agent = context.agent_factory(arguments) + except (SessionNotFoundError, AmbiguousSessionError) as exc: + context.output_func(context.terminal.warning(str(exc))) + return + context.switch_agent(next_agent) + context.output_func(context.terminal.warning(f"resumed session {next_agent.state.conversation_id[:8]}")) + + +def _trace(_arguments: str, context: CLICommandContext) -> None: + context.output_func(context.terminal.trace(context.agent)) + + +def _plan(arguments: str, context: CLICommandContext) -> None: + if not arguments: + context.output_func(context.terminal.plan_status(context.agent)) + return + response = context.agent.run_planned_turn(arguments) + _respond(context, response) + + +def _approve(_arguments: str, context: CLICommandContext) -> None: + response = context.agent.approve_plan() + _respond(context, response) + + +def _reject(_arguments: str, context: CLICommandContext) -> None: + response = context.agent.reject_plan() + _respond(context, response) + + +def _display(arguments: str, context: CLICommandContext) -> None: + mode = arguments.strip().lower() + if not mode: + context.output_func(context.terminal.warning(f"display mode {context.progress_settings.mode}")) + return + try: + context.progress_settings.set_mode(mode) + except ValueError: + context.output_func(context.terminal.warning("usage: /display compact|verbose|quiet")) + return + context.output_func(context.terminal.warning(f"display mode {mode}")) + + +def _clear(_arguments: str, context: CLICommandContext) -> None: + context.output_func(context.terminal.clear()) + + +def _respond(context: CLICommandContext, response: str) -> None: + if context.response_func is not None: + context.response_func(response) + return + context.output_func(context.terminal.assistant_message(response)) + + +CLI_COMMANDS: tuple[CLICommand, ...] = ( + CLICommand("/help", "/help", "show this command list", "General", _help, aliases=("help", "?")), + CLICommand( + "/exit", + "/exit", + "end the session", + "General", + None, + aliases=("/quit", "/q", "exit", "quit"), + ), + CLICommand("/plan", "/plan [request]", "show or propose an approval plan", "Run", _plan), + CLICommand("/approve", "/approve", "approve the pending plan", "Run", _approve), + CLICommand("/reject", "/reject", "reject the pending plan", "Run", _reject), + CLICommand("/status", "/status", "show runtime status", "Inspect", _status), + CLICommand("/context", "/context", "show the latest prompt context", "Inspect", _context), + CLICommand("/tools", "/tools", "list registered tools", "Inspect", _tools), + CLICommand("/mcp", "/mcp", "show configured MCP servers", "Inspect", _mcp), + CLICommand("/trace", "/trace", "show the current trace file", "Inspect", _trace), + CLICommand("/sessions", "/sessions", "list recent persisted sessions", "Sessions", _sessions), + CLICommand("/resume", "/resume ", "resume a persisted session", "Sessions", _resume), + CLICommand("/history", "/history", "show recent persisted messages", "Sessions", _history), + CLICommand( + "/display", + "/display compact|verbose|quiet", + "choose transcript detail", + "Display", + _display, + ), + CLICommand("/clear", "/clear", "clear the terminal screen", "Display", _clear), +) + + +EXIT_COMMANDS = frozenset( + name.lower() + for command in CLI_COMMANDS + if command.name == "/exit" + for name in command.names +) + + +def command_completion_candidates() -> tuple[str, ...]: + """Return canonical slash-command names for readline completion.""" + return tuple(command.name for command in CLI_COMMANDS) + + def handle_cli_command(command: str, context: CLICommandContext) -> bool: - """Handle a CLI command. Returns True when the input was consumed.""" + """Handle a CLI command and consume unknown slash-prefixed input.""" raw_command = command.strip() - normalized_command = raw_command.lower() + if raw_command.startswith("/"): + command_name, _, raw_arguments = raw_command.partition(" ") + else: + command_name = raw_command + raw_arguments = "" + normalized_name = command_name.lower() + arguments = raw_arguments.strip() - if normalized_command in HELP_COMMANDS: - context.output_func(context.terminal.help_text()) - return True - if normalized_command == "/status": - if context.config is None: - context.output_func(context.terminal.warning("status unavailable: no config object")) - else: - context.output_func(context.terminal.status(context.config, context.agent)) - return True - if normalized_command == "/context": - context.output_func(context.terminal.context(context.agent)) - return True - if normalized_command == "/tools": - context.output_func(context.terminal.tools(context.agent)) - return True - if normalized_command == "/mcp": - if context.config is None: - context.output_func(context.terminal.warning("mcp unavailable: no config object")) - else: - context.output_func(context.terminal.mcp(context.config, context.agent)) + for command_spec in CLI_COMMANDS: + if normalized_name not in {name.lower() for name in command_spec.names}: + continue + if command_spec.handler is not None: + command_spec.handler(arguments, context) return True - if normalized_command == "/sessions": - if context.session_store is None: - context.output_func(context.terminal.warning("sessions unavailable: no session store")) - return True - context.output_func(context.terminal.sessions(context.session_store.list_conversations())) - return True - if normalized_command == "/history": - if context.session_store is None: - context.output_func(context.terminal.warning("history unavailable: no session store")) - return True - messages = context.session_store.list_messages(context.agent.state.conversation_id, limit=40) - context.output_func(context.terminal.history(messages)) - return True - if normalized_command == "/resume" or normalized_command.startswith("/resume "): - if normalized_command == "/resume": - context.output_func(context.terminal.warning("usage: /resume ")) - return True - if context.agent_factory is None or context.switch_agent is None: - context.output_func(context.terminal.warning("resume unavailable: no agent factory")) - return True - session_id = raw_command[len("/resume") :].strip() - try: - next_agent = context.agent_factory(session_id) - except SessionNotFoundError as exc: - context.output_func(context.terminal.warning(str(exc))) - return True - except AmbiguousSessionError as exc: - context.output_func(context.terminal.warning(str(exc))) - return True - context.switch_agent(next_agent) - context.output_func(context.terminal.warning(f"resumed session {next_agent.state.conversation_id[:8]}")) - return True - if normalized_command == "/trace": - context.output_func(context.terminal.trace(context.agent)) - return True - if normalized_command == "/plan": - context.output_func(context.terminal.plan_status(context.agent)) - return True - if normalized_command.startswith("/plan "): - planned_message = raw_command[len("/plan ") :].strip() - if not planned_message: - context.output_func(context.terminal.warning("usage: /plan ")) - return True - response = context.agent.run_planned_turn(planned_message) - context.output_func(context.terminal.assistant_message(response)) - return True - if normalized_command == "/approve": - response = context.agent.approve_plan() - context.output_func(context.terminal.assistant_message(response)) - return True - if normalized_command == "/reject": - response = context.agent.reject_plan() - context.output_func(context.terminal.assistant_message(response)) - return True - if normalized_command == "/clear": - context.output_func(context.terminal.clear()) - return True - if normalized_command in VERBOSE_COMMANDS: - context.progress_settings.verbose = normalized_command.endswith(" on") - context.output_func( - context.terminal.warning(f"verbose mode {'on' if context.progress_settings.verbose else 'off'}") - ) - return True - if normalized_command in QUIET_COMMANDS: - context.progress_settings.quiet = normalized_command.endswith(" on") - context.output_func(context.terminal.warning(f"quiet mode {'on' if context.progress_settings.quiet else 'off'}")) - return True - if normalized_command in SUMMARY_COMMANDS: - context.progress_settings.summary = normalized_command.endswith(" on") - context.output_func( - context.terminal.warning(f"turn summary {'on' if context.progress_settings.summary else 'off'}") - ) + + if raw_command.startswith("/"): + canonical_names = [command_spec.name for command_spec in CLI_COMMANDS] + suggestion = get_close_matches(normalized_name, canonical_names, n=1, cutoff=0.5) + suffix = f" Did you mean {suggestion[0]}?" if suggestion else " Type /help for commands." + context.output_func(context.terminal.warning(f"Unknown command: {command_name}.{suffix}")) return True return False + + +__all__ = [ + "CLICommand", + "CLICommandContext", + "CLI_COMMANDS", + "EXIT_COMMANDS", + "command_completion_candidates", + "handle_cli_command", +] diff --git a/chulk/cli/history.py b/chulk/cli/history.py index a1eb597..b3172a8 100644 --- a/chulk/cli/history.py +++ b/chulk/cli/history.py @@ -43,6 +43,20 @@ def add(self, prompt: str) -> None: return self.readline.add_history(clean_prompt) + def configure_completion(self, candidates: Iterable[str]) -> None: + """Enable tab completion for the registered interactive commands.""" + if not self.enabled or self.readline is None: + return + values = tuple(dict.fromkeys(value.strip() for value in candidates if value.strip())) + + def complete(text: str, state: int) -> str | None: + matches = [value for value in values if value.startswith(text)] + return matches[state] if state < len(matches) else None + + self.readline.set_completer_delims("\n") + self.readline.set_completer(complete) + self.readline.parse_and_bind("tab: complete") + def _latest(self) -> str | None: if self.readline is None: return None diff --git a/chulk/cli/progress.py b/chulk/cli/progress.py index 0ef7f61..0972ba2 100644 --- a/chulk/cli/progress.py +++ b/chulk/cli/progress.py @@ -16,11 +16,26 @@ @dataclass class ProgressSettings: - """Runtime display settings toggled by slash commands.""" + """Single, explicit display mode for interactive progress.""" - quiet: bool = False - verbose: bool = False - summary: bool = True + mode: str = "compact" + + def __post_init__(self) -> None: + self.set_mode(self.mode) + + @property + def quiet(self) -> bool: + return self.mode == "quiet" + + @property + def verbose(self) -> bool: + return self.mode == "verbose" + + def set_mode(self, mode: str) -> None: + clean_mode = mode.strip().lower() + if clean_mode not in {"compact", "verbose", "quiet"}: + raise ValueError("display mode must be compact, verbose, or quiet") + self.mode = clean_mode class Spinner: @@ -103,6 +118,7 @@ def __init__( self.current_activity: str | None = None self.streamed_answer: bool = False self._stream_open: bool = False + self._pending_summary: str | None = None def callback(self, event_type: str, payload: dict) -> None: """Handle one agent event.""" @@ -125,18 +141,24 @@ def callback(self, event_type: str, payload: dict) -> None: if self.settings.quiet: return - line = self.terminal.progress( - event_type, - payload, - elapsed_seconds=self._elapsed(now), - duration_seconds=self._duration(event_type, payload, now), - verbose=self.settings.verbose, - ) - if line is not None: - self.output_func(line) + if self.settings.verbose or _show_in_compact_mode(event_type): + line = self.terminal.progress( + event_type, + payload, + elapsed_seconds=self._elapsed(now), + duration_seconds=self._duration(event_type, payload, now), + verbose=self.settings.verbose, + ) + if line is not None: + self.output_func(line) - if event_type == TraceEvent.TURN_FINISHED and self.settings.summary: - self.output_func(self.terminal.turn_summary(payload, config=self.config, agent=self.agent)) + if event_type == TraceEvent.TURN_FINISHED: + self._pending_summary = self.terminal.turn_summary( + payload, + config=self.config, + agent=self.agent, + compact=not self.settings.verbose, + ) self._start_spinner_if_needed(event_type, payload) @@ -149,6 +171,15 @@ def close(self) -> None: def reset_stream_state(self) -> None: self.streamed_answer = False self._stream_open = False + self._pending_summary = None + + def flush_summary(self) -> None: + """Print a completed turn summary after the assistant response.""" + if self._pending_summary is None or self.settings.quiet: + self._pending_summary = None + return + self.output_func(self._pending_summary) + self._pending_summary = None def _elapsed(self, now: float) -> float | None: if self.turn_started_at is None: @@ -228,6 +259,28 @@ def _handle_stream_event(self, event_type: str, payload: dict) -> bool: return True +_COMPACT_PROGRESS_EVENTS = { + TraceEvent.CONTEXT_SUMMARY_CREATED, + TraceEvent.PLAN_CREATED, + TraceEvent.PLAN_APPROVED, + TraceEvent.PLAN_REJECTED, + TraceEvent.PLAN_REVISION_REQUESTED, + TraceEvent.PLAN_STEP_STARTED, + TraceEvent.PLAN_STEP_COMPLETED, + TraceEvent.PLAN_STEP_BLOCKED, + TraceEvent.TOOL_PERMISSION_REQUESTED, + TraceEvent.TOOL_PERMISSION_DECIDED, + TraceEvent.TOOL_CALL_STARTED, + TraceEvent.TOOL_CALL_COMPLETED, + TraceEvent.TOOL_CALL_FAILED, + TraceEvent.TURN_FAILED, +} + + +def _show_in_compact_mode(event_type: str) -> bool: + return event_type in _COMPACT_PROGRESS_EVENTS + + def _tool_key(payload: dict) -> int: iteration = payload.get("iteration") return iteration if isinstance(iteration, int) else -1 diff --git a/chulk/cli/terminal.py b/chulk/cli/terminal.py index 9d0ce2f..126c82c 100644 --- a/chulk/cli/terminal.py +++ b/chulk/cli/terminal.py @@ -12,6 +12,10 @@ import os from pathlib import Path import shutil +import sys +import textwrap +from collections.abc import Iterable, Mapping +from typing import TextIO from chulk.config import Config from chulk.core import Agent, TraceEvent @@ -31,59 +35,82 @@ class TerminalUI: color_enabled: bool = True width: int = 80 + interactive: bool = True @classmethod - def themed(cls) -> "TerminalUI": - """Create the default Hulk-green terminal formatter.""" - width = shutil.get_terminal_size((80, 24)).columns - return cls(color_enabled=True, width=max(64, min(width, 110))) + def themed( + cls, + *, + stream: TextIO | None = None, + color: str = "auto", + environ: Mapping[str, str] | None = None, + width: int | None = None, + ) -> "TerminalUI": + """Create a TTY-aware Hulk-green terminal formatter.""" + output_stream = stream or sys.stdout + env = os.environ if environ is None else environ + interactive = bool(getattr(output_stream, "isatty", lambda: False)()) + color_mode = color.strip().lower() + if color_mode not in {"auto", "always", "never"}: + raise ValueError("color mode must be auto, always, or never") + color_enabled = color_mode == "always" or ( + color_mode == "auto" + and interactive + and "NO_COLOR" not in env + and env.get("TERM", "").lower() != "dumb" + ) + terminal_width = width if width is not None else shutil.get_terminal_size((80, 24)).columns + return cls( + color_enabled=color_enabled, + width=max(32, min(terminal_width, 110)), + interactive=interactive, + ) def prompt(self) -> str: """Return the interactive input prompt.""" return f"{self.accent('>')} " def banner(self, config: Config, agent: Agent) -> str: - """Return the startup banner.""" - title = "ChulkHarness CLI" - top = _rule(title, self.width) - bottom = "+" + "-" * (len(top) - 2) + "+" - trace_path = agent.trace_logger.path if agent.trace_logger else None - rows = [ - self._row("mode", "interactive agent harness"), - self._row("provider", _provider_text(config)), - self._row("session", _short_id(agent.state.conversation_id)), - self._row("project", _short_path(config.project_root)), - self._row("permissions", config.permission_profile), - self._row("tools", str(len(agent.tool_registry.list_tools()))), - self._row("mcp", _mcp_status_text(config, agent)), - self._row("trace", _short_path(trace_path) if trace_path else "disabled"), - ] - return "\n".join([self.accent(top), *rows, self.accent(bottom)]) - - def help_text(self) -> str: - """Return slash-command help.""" - commands = [ - ("/help", "show this command list"), - ("/status", "show provider, model, project, tools, and trace"), - ("/context", "show latest prompt context report"), - ("/tools", "list registered tools"), - ("/mcp", "show configured MCP servers and provider path"), - ("/sessions", "list recent persisted sessions"), - ("/resume ", "resume a persisted session"), - ("/history", "show recent persisted messages"), - ("/trace", "show the current trace file"), - ("/plan ", "propose a plan for one request"), - ("/plan", "show current plan status"), - ("/approve", "approve a pending plan"), - ("/reject", "reject a pending plan"), - ("/verbose on|off", "show or hide extra progress details"), - ("/quiet on|off", "show or hide live progress lines"), - ("/summary on|off", "show or hide the end-of-turn summary"), - ("/clear", "clear the terminal screen"), - ("/q", "exit the session"), - ] + """Return a compact, responsive startup banner.""" + primary = f"ChulkHarness CLI · {_provider_text(config)} · {config.permission_profile}" + secondary = ( + f"{_short_path(config.project_root)} · session {_short_id(agent.state.conversation_id)} · " + f"{len(agent.tool_registry.list_tools())} tools · mcp {_mcp_status_text(config, agent)}" + ) + lines = _wrap_text(primary, self.width) + lines.extend(_wrap_text(secondary, self.width)) + return "\n".join( + [self.accent(lines[0]), *[self.muted(line) for line in lines[1:]]] + ) + + def help_text(self, commands: Iterable[object] | None = None) -> str: + """Return grouped help generated from the command registry.""" + if commands is None: + from chulk.cli.commands import CLI_COMMANDS + + commands = CLI_COMMANDS + command_list = list(commands) lines = [self.heading("Commands")] - lines.extend(f" {self.accent(command):<12} {description}" for command, description in commands) + categories = list(dict.fromkeys(str(getattr(command, "category")) for command in command_list)) + for category in categories: + lines.append(self.muted(f" {category}")) + category_commands = [ + command for command in command_list if str(getattr(command, "category")) == category + ] + usages = [str(getattr(command, "usage")) for command in category_commands] + column_width = min(max((len(usage) for usage in usages), default=0), 32) + for command in category_commands: + usage = str(getattr(command, "usage")) + description = str(getattr(command, "description")) + if self.width < 60 or len(usage) > column_width: + lines.append(f" {self.accent(usage)}") + lines.extend(f" {line}" for line in _wrap_text(description, max(20, self.width - 4))) + continue + padded_usage = usage.ljust(column_width) + description_width = max(20, self.width - column_width - 5) + wrapped_description = _wrap_text(description, description_width) + lines.append(f" {self.accent(padded_usage)} {wrapped_description[0]}") + lines.extend(f" {' ' * column_width} {line}" for line in wrapped_description[1:]) return "\n".join(lines) def status(self, config: Config, agent: Agent) -> str: @@ -254,8 +281,15 @@ def progress( return None return f"{self.muted('..')} {message}" - def turn_summary(self, payload: dict, *, config: Config | None = None, agent: Agent | None = None) -> str: - """Return a compact end-of-turn summary.""" + def turn_summary( + self, + payload: dict, + *, + config: Config | None = None, + agent: Agent | None = None, + compact: bool = False, + ) -> str: + """Return a one-line or detailed end-of-turn summary.""" turn = payload.get("turn", {}) tool_counts = _tool_counts(turn.get("tool_calls", [])) skills = turn.get("loaded_skill_names", []) @@ -264,6 +298,21 @@ def turn_summary(self, payload: dict, *, config: Config | None = None, agent: Ag plan_text = "none" if isinstance(plan, dict): plan_text = plan.get("status", "unknown") + if compact: + duration = _format_duration(_turn_duration(turn)) + requests = int(turn.get("model_request_count") or 0) + tool_total = sum(tool_counts.values()) + usage = turn.get("model_usage_totals", {}).get("usage", {}) + token_total = int(usage.get("total_tokens") or 0) if isinstance(usage, dict) else 0 + parts = [ + "done", + duration, + f"{requests} model request{'s' if requests != 1 else ''}", + f"{tool_total} tool call{'s' if tool_total != 1 else ''}", + ] + if token_total: + parts.append(f"{_format_count(token_total)} tokens") + return self.muted(" · ".join(parts)) lines = [ self.heading("Turn Summary"), f" worked for {_format_duration(_turn_duration(turn))}", @@ -287,6 +336,9 @@ def turn_summary(self, payload: dict, *, config: Config | None = None, agent: Ag def bye(self, agent: Agent | None = None) -> str: if agent is None: return self.muted("bye") + recorder = getattr(agent, "session_recorder", None) + if recorder is not None and not bool(getattr(recorder, "persisted", False)): + return self.muted("bye") command = f"/resume {agent.state.conversation_id}" return "\n".join( [ @@ -297,10 +349,10 @@ def bye(self, agent: Agent | None = None) -> str: ) def hint(self) -> str: - return self.muted("Type /help for commands. Type /exit, /quit, or /q to end the session.") + return self.muted("Type /help for commands. Type /exit to end the session.") def clear(self) -> str: - return "\033[2J\033[H" if self.color_enabled else "[screen cleared]" + return "\033[2J\033[H" if self.interactive else "[screen cleared]" def heading(self, text: str) -> str: return self.accent(f"> {text}") @@ -318,7 +370,7 @@ def muted(self, text: str) -> str: return self._paint(text, MUTED) def _row(self, label: str, value: str) -> str: - return f"| {self.muted(label):<10} {value}" + return f"| {self.muted(label.ljust(10))} {value}" def _paint(self, text: str, rgb: tuple[int, int, int], *, bold: bool = False) -> str: if not self.color_enabled: @@ -464,6 +516,16 @@ def _context_section_detail(section: dict) -> str: return "" +def _wrap_text(text: str, width: int) -> list[str]: + """Wrap plain terminal text before ANSI styling is applied.""" + return textwrap.wrap( + text, + width=max(20, width), + break_long_words=True, + break_on_hyphens=False, + ) or [""] + + def _rule(title: str, width: int) -> str: label = f"+-- {title} " return label + "-" * max(2, width - len(label) - 1) + "+" diff --git a/chulk/tests/test_cli_commands.py b/chulk/tests/test_cli_commands.py new file mode 100644 index 0000000..dd91749 --- /dev/null +++ b/chulk/tests/test_cli_commands.py @@ -0,0 +1,46 @@ +"""Focused regressions for interactive command routing.""" + +from __future__ import annotations + +import json + +import pytest + +from chulk.llm import LLMClient +from chulk.main import main + + +class RecordingLLMClient(LLMClient): + def __init__(self) -> None: + self.requests: list[list[dict[str, str]]] = [] + + def complete(self, messages: list[dict[str, str]]) -> str: + self.requests.append(messages) + return json.dumps({"type": "final_answer", "content": "model handled prompt"}) + + +@pytest.mark.parametrize( + "prompt", + [ + "help me inspect this repo", + "exit interview questions", + "quit smoking tips", + ], +) +def test_bare_alias_prefixes_remain_model_prompts(prompt, monkeypatch, tmp_path, capsys): + monkeypatch.setenv("CHULK_PROJECT_ROOT", str(tmp_path)) + client = RecordingLLMClient() + inputs = iter([prompt, "/q"]) + + exit_code = main( + [], + input_func=lambda _prompt: next(inputs), + llm_client_factory=lambda _config: client, + ) + + output = capsys.readouterr().out + + assert exit_code == 0 + assert "model handled prompt" in output + assert len(client.requests) == 1 + assert any(message["role"] == "user" and message["content"] == prompt for message in client.requests[0]) diff --git a/chulk/tests/test_cli_history.py b/chulk/tests/test_cli_history.py index 153ca8e..1304f01 100644 --- a/chulk/tests/test_cli_history.py +++ b/chulk/tests/test_cli_history.py @@ -7,6 +7,9 @@ class FakeReadline: def __init__(self) -> None: self.items: list[str] = [] + self.completer = None + self.delimiters = "" + self.binding = "" def clear_history(self) -> None: self.items.clear() @@ -22,6 +25,15 @@ def get_history_item(self, index: int) -> str | None: return None return self.items[index - 1] + def set_completer_delims(self, delimiters: str) -> None: + self.delimiters = delimiters + + def set_completer(self, completer) -> None: + self.completer = completer + + def parse_and_bind(self, binding: str) -> None: + self.binding = binding + def test_prompt_history_loads_user_messages_only(): readline = FakeReadline() @@ -64,3 +76,16 @@ def test_prompt_history_does_not_duplicate_latest_prompt(): history.add("next prompt") assert readline.items == ["same prompt", "next prompt"] + + +def test_prompt_history_configures_command_completion(): + readline = FakeReadline() + history = PromptHistory(readline=readline, enabled=True) + + history.configure_completion(["/status", "/sessions", "/status"]) + + assert readline.delimiters == "\n" + assert readline.binding == "tab: complete" + assert readline.completer("/sta", 0) == "/status" + assert readline.completer("/sta", 1) is None + assert readline.completer("/s", 1) == "/sessions" diff --git a/chulk/tests/test_cli_progress.py b/chulk/tests/test_cli_progress.py index 3c699dc..24c86e5 100644 --- a/chulk/tests/test_cli_progress.py +++ b/chulk/tests/test_cli_progress.py @@ -3,9 +3,11 @@ from __future__ import annotations from io import StringIO +import re import time +from types import SimpleNamespace -from chulk.cli import Spinner, TerminalUI +from chulk.cli import CLI_COMMANDS, Spinner, TerminalUI class TTYBuffer(StringIO): @@ -66,3 +68,54 @@ def test_turn_summary_formats_token_usage_and_cost(): ) assert "usage 15 tokens (12 in, 3 out est), ~$0.000004" in output + + +def test_terminal_color_auto_respects_tty_no_color_and_force_modes(): + tty = TTYBuffer() + + automatic = TerminalUI.themed(stream=tty, environ={"TERM": "xterm-256color"}) + disabled = TerminalUI.themed(stream=tty, environ={"TERM": "xterm-256color", "NO_COLOR": ""}) + forced = TerminalUI.themed(stream=StringIO(), color="always", environ={"TERM": "dumb"}) + + assert automatic.color_enabled is True + assert disabled.color_enabled is False + assert forced.color_enabled is True + assert TerminalUI.themed(stream=StringIO()).color_enabled is False + + +def test_help_alignment_is_calculated_before_ansi_styling(): + ui = TerminalUI(color_enabled=True, width=100) + output = re.sub(r"\x1b\[[0-9;]*m", "", ui.help_text(CLI_COMMANDS)) + command_lines = [line for line in output.splitlines() if line.startswith(" /")] + status_line = next(line for line in command_lines if line.lstrip().startswith("/status")) + context_line = next(line for line in command_lines if line.lstrip().startswith("/context")) + + assert status_line.index("show runtime") == context_line.index("show the latest") + + +def test_help_reflows_without_exceeding_narrow_terminal_width(): + ui = TerminalUI(color_enabled=False, width=36) + + output = ui.help_text(CLI_COMMANDS) + + assert max(len(line) for line in output.splitlines()) <= 36 + + +def test_banner_reflows_without_exceeding_narrow_terminal_width(tmp_path): + ui = TerminalUI(color_enabled=False, width=36) + config = SimpleNamespace( + llm_provider="openai", + model="gpt-4.1-mini", + llm_fallback_providers=(), + permission_profile="workspace-write", + project_root=tmp_path, + mcp_servers=(), + ) + agent = SimpleNamespace( + state=SimpleNamespace(conversation_id="conversation-123"), + tool_registry=SimpleNamespace(list_tools=lambda: [object(), object()]), + ) + + output = ui.banner(config, agent) + + assert max(len(line) for line in output.splitlines()) <= 36 From 2e318b2b0f6959246523025e311619315aeae14e Mon Sep 17 00:00:00 2001 From: JaviChulvi Date: Thu, 9 Jul 2026 23:31:12 +0200 Subject: [PATCH 2/4] Persist only active CLI sessions --- chulk/runtime.py | 11 ++++- chulk/sessions/recorder.py | 25 +++++++++--- chulk/sessions/sqlite_store.py | 14 +++++++ chulk/tests/test_sessions.py | 74 +++++++++++++++++++++++++++++++++- chulk/tracing/logger.py | 32 ++++++++++++++- 5 files changed, 146 insertions(+), 10 deletions(-) diff --git a/chulk/runtime.py b/chulk/runtime.py index 422f6d8..05c85e6 100644 --- a/chulk/runtime.py +++ b/chulk/runtime.py @@ -11,7 +11,7 @@ from chulk.config import Config from chulk.core import Agent, AgentState from chulk.core.context import ContextBudget -from chulk.core.events import AgentEvent +from chulk.core.events import AgentEvent, TraceEvent from chulk.core.prompts import BASE_SYSTEM_PROMPT from chulk.llm import LLMClient, create_llm_client, resolve_model_capabilities from chulk.mcp import create_mcp_bridge_tools @@ -91,12 +91,18 @@ def create_agent( max_content_chars=config.max_skill_content_chars, ) state = _create_agent_state(session_store, conversation_id) - trace_logger = JSONLTraceLogger(config.traces_dir, state.conversation_id) + trace_logger = JSONLTraceLogger( + config.traces_dir, + state.conversation_id, + defer_until_event=TraceEvent.TURN_STARTED if conversation_id is None else None, + ) skill_registry.load_metadata() skill_resolution = _resolve_skill_specs(skill_registry, skill_specs) for warning_payload in skill_resolution.warnings: warnings.warn(warning_payload["message"], UserWarning, stacklevel=2) trace_logger.log("skill_config_warning", warning_payload) + if skill_resolution.warnings: + trace_logger.activate() conversation_memory = ConversationMemory(max_messages=config.history_limit) if conversation_id is not None: latest_summary = session_store.load_latest_summary(state.conversation_id) @@ -118,6 +124,7 @@ def create_agent( provider=config.llm_provider, model=config.model, trace_path=trace_logger.path, + lazy=conversation_id is None, ) client = llm_client if llm_client is not None else llm_client_factory(config) if hasattr(client, "bind_config"): diff --git a/chulk/sessions/recorder.py b/chulk/sessions/recorder.py index 6efc6f4..e1568e4 100644 --- a/chulk/sessions/recorder.py +++ b/chulk/sessions/recorder.py @@ -20,20 +20,22 @@ def __init__( provider: str, model: str, trace_path: Path | str | None = None, + lazy: bool = False, ) -> None: self.store = store self.conversation_id = conversation_id self.current_turn_id: str | None = None self._observation_counts: dict[str, int] = {} - self.store.create_conversation( - conversation_id, - provider=provider, - model=model, - trace_path=str(trace_path) if trace_path is not None else None, - ) + self.provider = provider + self.model = model + self.trace_path = str(trace_path) if trace_path is not None else None + self.persisted = False + if not lazy: + self._ensure_conversation() def callback(self, event_type: str, payload: dict[str, Any]) -> None: """Persist the subset of trace events needed to resume and inspect sessions.""" + self._ensure_conversation() if event_type == TraceEvent.TURN_STARTED: turn = payload.get("turn") if isinstance(turn, dict): @@ -185,6 +187,17 @@ def callback(self, event_type: str, payload: dict[str, Any]) -> None: if isinstance(turn, dict): self.store.save_turn_snapshot(self.conversation_id, turn) + def _ensure_conversation(self) -> None: + if self.persisted: + return + self.store.create_conversation( + self.conversation_id, + provider=self.provider, + model=self.model, + trace_path=self.trace_path, + ) + self.persisted = True + def _payload_turn_id(payload: dict[str, Any], fallback: str | None) -> str | None: turn_id = payload.get("turn_id") diff --git a/chulk/sessions/sqlite_store.py b/chulk/sessions/sqlite_store.py index 70a4b1b..05e937a 100644 --- a/chulk/sessions/sqlite_store.py +++ b/chulk/sessions/sqlite_store.py @@ -259,6 +259,20 @@ def list_conversations(self, limit: int = 20) -> list[ConversationRecord]: ).fetchall() return [_row_to_conversation(row) for row in rows] + def latest_conversation(self, *, require_turn: bool = True) -> ConversationRecord | None: + """Return the most recently updated resumable conversation.""" + where = ( + "WHERE EXISTS (SELECT 1 FROM conversation_turns " + "WHERE conversation_turns.conversation_id = conversations.id)" + if require_turn + else "" + ) + with self._connect() as conn: + row = conn.execute( + _conversation_select_sql(f"{where} ORDER BY conversations.updated_at DESC LIMIT 1") + ).fetchone() + return _row_to_conversation(row) if row is not None else None + def save_message( self, conversation_id: str, diff --git a/chulk/tests/test_sessions.py b/chulk/tests/test_sessions.py index 6c7de68..42a98bc 100644 --- a/chulk/tests/test_sessions.py +++ b/chulk/tests/test_sessions.py @@ -275,11 +275,19 @@ def fake_create_bridge_tools(servers): agent = create_agent(config, lambda _config: FakeLLMClient(), tool_specs=[]) + assert agent.trace_logger.path.exists() is False + assert SQLiteSessionStore(config.store_path).list_conversations() == [] + agent.run_turn("inspect MCP configuration") + events = [json.loads(line) for line in agent.trace_logger.path.read_text(encoding="utf-8").splitlines()] assert calls == [["docs"]] assert agent.mcp_bridge_tool_names == ["mcp_docs_search_docs"] assert agent.tool_registry.get("mcp_docs_search_docs").requires_confirmation is True - assert [event["type"] for event in events] == ["mcp_config_loaded", "mcp_tool_discovery_completed"] + assert [event["type"] for event in events[:3]] == [ + "mcp_config_loaded", + "mcp_tool_discovery_completed", + "turn_started", + ] assert events[0]["payload"]["provider_path"] == "bridge" assert events[1]["payload"]["bridge_required"] is True @@ -447,6 +455,70 @@ def test_cli_lists_resumes_and_shows_history(monkeypatch, tmp_path, capsys): assert any(message["content"] == "first persisted answer" for message in resumed_request_messages) +def test_cli_resume_flag_starts_in_existing_session(monkeypatch, tmp_path, capsys): + monkeypatch.setenv("CHULK_PROJECT_ROOT", str(tmp_path)) + first_exit = main( + ["--once", "remember startup resume"], + llm_client_factory=lambda _config: FakeLLMClient( + [json.dumps({"type": "final_answer", "content": "remembered"})] + ), + ) + store = SQLiteSessionStore(tmp_path / "chulk" / "store.sqlite") + session_id = store.list_conversations()[0].id + resumed_llm = FakeLLMClient([json.dumps({"type": "final_answer", "content": "resumed directly"})]) + inputs = iter(["continue directly", "/q"]) + + second_exit = main( + ["--resume", session_id[:8]], + input_func=lambda _prompt: next(inputs), + llm_client_factory=lambda _config: resumed_llm, + ) + + output = capsys.readouterr().out + + assert first_exit == 0 + assert second_exit == 0 + assert "resumed directly" in output + assert any(message["content"] == "remember startup resume" for message in resumed_llm.requests[0]) + assert len(store.list_conversations()) == 1 + + +def test_cli_continue_resumes_latest_nonempty_session(monkeypatch, tmp_path, capsys): + monkeypatch.setenv("CHULK_PROJECT_ROOT", str(tmp_path)) + main( + ["--once", "latest session marker"], + llm_client_factory=lambda _config: FakeLLMClient( + [json.dumps({"type": "final_answer", "content": "stored"})] + ), + ) + continued_llm = FakeLLMClient([json.dumps({"type": "final_answer", "content": "continued latest"})]) + inputs = iter(["continue", "/q"]) + + exit_code = main( + ["--continue"], + input_func=lambda _prompt: next(inputs), + llm_client_factory=lambda _config: continued_llm, + ) + + output = capsys.readouterr().out + + assert exit_code == 0 + assert "continued latest" in output + assert any(message["content"] == "latest session marker" for message in continued_llm.requests[0]) + + +def test_cli_continue_without_session_fails_cleanly(monkeypatch, tmp_path, capsys): + monkeypatch.setenv("CHULK_PROJECT_ROOT", str(tmp_path)) + + exit_code = main(["--continue"], llm_client_factory=lambda _config: FakeLLMClient()) + + captured = capsys.readouterr() + + assert exit_code == 2 + assert captured.out == "" + assert "No persisted session is available to continue" in captured.err + + def test_cli_resume_reloads_arrow_key_prompt_history(monkeypatch, tmp_path, capsys): monkeypatch.setenv("CHULK_PROJECT_ROOT", str(tmp_path)) first_exit = main( diff --git a/chulk/tracing/logger.py b/chulk/tracing/logger.py index 228ae3f..56c9cf5 100644 --- a/chulk/tracing/logger.py +++ b/chulk/tracing/logger.py @@ -27,22 +27,52 @@ def to_dict(self) -> dict[str, Any]: class JSONLTraceLogger: """Append-only JSONL trace logger.""" - def __init__(self, traces_dir: Path | str, conversation_id: str) -> None: + def __init__( + self, + traces_dir: Path | str, + conversation_id: str, + *, + defer_until_event: str | None = None, + ) -> None: self.traces_dir = Path(traces_dir) self.conversation_id = conversation_id self.traces_dir.mkdir(parents=True, exist_ok=True) self.path = self.traces_dir / f"{conversation_id}.jsonl" self.artifacts_dir = self.traces_dir / f"{conversation_id}_artifacts" + self._defer_until_event = defer_until_event + self._active = defer_until_event is None + self._pending_events: list[dict[str, Any]] = [] def log(self, event_type: str, payload: dict[str, Any] | None = None) -> None: """Append a trace event.""" safe_payload = _redact(payload or {}) event = TraceEvent(type=event_type, payload=safe_payload).to_dict() + if not self._active: + if event_type != self._defer_until_event: + self._pending_events.append(event) + return + self._active = True + for pending_event in self._pending_events: + self._append_event(pending_event) + self._pending_events.clear() + self._append_event(event) + + def activate(self) -> None: + """Flush deferred startup events when runtime work begins.""" + if self._active: + return + self._active = True + for event in self._pending_events: + self._append_event(event) + self._pending_events.clear() + + def _append_event(self, event: dict[str, Any]) -> None: with self.path.open("a", encoding="utf-8") as trace_file: trace_file.write(json.dumps(event, sort_keys=True) + "\n") def write_artifact(self, name: str, content: str) -> dict[str, Any]: """Persist full trace-adjacent content that is too large for model context.""" + self.activate() self.artifacts_dir.mkdir(parents=True, exist_ok=True) safe_name = _safe_artifact_name(name) path = self.artifacts_dir / f"{safe_name}-{uuid4().hex}.txt" From c493c5b2fdea8c4e16d77ea115e188f4d99e1ea0 Mon Sep 17 00:00:00 2001 From: JaviChulvi Date: Thu, 9 Jul 2026 23:31:26 +0200 Subject: [PATCH 3/4] Add CLI automation and maintenance commands --- .github/workflows/ci.yml | 2 +- chulk/cli/entrypoints.py | 224 +++++++++++++ chulk/cli/maintenance.py | 502 ++++++++++++++++++++++++++++ chulk/cli/parser.py | 80 +++++ chulk/config.py | 22 ++ chulk/main.py | 168 +++++++--- chulk/tests/conftest.py | 12 + chulk/tests/test_cli_maintenance.py | 385 +++++++++++++++++++++ chulk/tests/test_config.py | 8 +- chulk/tests/test_main.py | 343 +++++++++++++++++-- chulk/tracing/__init__.py | 3 +- chulk/tracing/reader.py | 169 ++++++++++ pyproject.toml | 3 + 13 files changed, 1831 insertions(+), 90 deletions(-) create mode 100644 chulk/cli/entrypoints.py create mode 100644 chulk/cli/maintenance.py create mode 100644 chulk/cli/parser.py create mode 100644 chulk/tests/conftest.py create mode 100644 chulk/tests/test_cli_maintenance.py create mode 100644 chulk/tracing/reader.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 19d2b09..4f24b86 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,7 @@ jobs: - name: Install package run: | python -m pip install --upgrade pip - python -m pip install -e ".[dev]" + python -m pip install -e ".[dev,openai]" - name: Run tests run: python -m pytest diff --git a/chulk/cli/entrypoints.py b/chulk/cli/entrypoints.py new file mode 100644 index 0000000..01ba78b --- /dev/null +++ b/chulk/cli/entrypoints.py @@ -0,0 +1,224 @@ +"""Execution helpers for non-interactive CLI entrypoints.""" + +from __future__ import annotations + +from collections.abc import Callable +import json +from pathlib import Path + +from chulk.cli.maintenance import ( + TraceFormatError, + export_trace_html, + format_doctor_report, + format_init_changes, + format_trace_summary, + initialize_project, + inspect_trace, + run_doctor, +) +from chulk.core import Agent +from chulk.llm import LLMConfigurationError, LLMError +from chulk.tools.permissions import PermissionDecision + + +EXIT_OK = 0 +EXIT_RUNTIME_ERROR = 1 +EXIT_CONFIGURATION_ERROR = 2 +EXIT_APPROVAL_REQUIRED = 3 + + +def run_exec_command( + message: str, + *, + agent_factory: Callable[[], Agent], + json_output: bool, + output_func: Callable[[str], None], + error_func: Callable[[str], None], +) -> int: + """Run one request without ever blocking for interactive approval.""" + approval_requested = False + + def deny_approval(_request: object, _record: object) -> PermissionDecision: + nonlocal approval_requested + approval_requested = True + return PermissionDecision.DENY + + try: + agent = agent_factory() + agent.permission_callback = deny_approval + response = agent.run_turn(message) + except (ValueError, LLMConfigurationError) as exc: + return _emit_error( + "configuration_error", + exc, + json_output=json_output, + output_func=output_func, + error_func=error_func, + exit_code=EXIT_CONFIGURATION_ERROR, + ) + except LLMError as exc: + return _emit_error( + "runtime_error", + exc, + json_output=json_output, + output_func=output_func, + error_func=error_func, + exit_code=EXIT_RUNTIME_ERROR, + ) + except Exception as exc: + return _emit_error( + "runtime_error", + exc, + json_output=json_output, + output_func=output_func, + error_func=error_func, + exit_code=EXIT_RUNTIME_ERROR, + unexpected=True, + ) + + turn = agent.state.turns[-1] + approval_required = turn.status == "waiting_for_approval" or approval_requested + payload = { + "ok": not approval_required and turn.status == "completed", + "status": "approval_required" if approval_required else turn.status, + "content": response, + "conversation_id": agent.state.conversation_id, + "trace_path": str(agent.trace_logger.path) if agent.trace_logger is not None else None, + "usage": turn.model_usage_totals or None, + "tool_calls": [ + { + "tool_name": record.tool_name, + "success": record.success, + "error": record.error, + "failure_kind": record.failure_kind, + } + for record in turn.tool_calls + ], + } + if json_output: + output_func(json_text(payload)) + elif approval_required: + error_func(response) + error_func("approval required: a tool call was denied in non-interactive mode") + elif turn.status == "completed": + output_func(response) + else: + error_func(response) + if approval_required: + return EXIT_APPROVAL_REQUIRED + if turn.status != "completed": + return EXIT_RUNTIME_ERROR + return EXIT_OK + + +def run_doctor_command(*, json_output: bool, output_func: Callable[[str], None]) -> int: + report = run_doctor() + output_func(json_text(report.to_dict()) if json_output else format_doctor_report(report)) + return EXIT_OK if report.ok else EXIT_CONFIGURATION_ERROR + + +def run_init_command( + project_root: Path | str, + *, + mode: str, + json_output: bool, + output_func: Callable[[str], None], + error_func: Callable[[str], None], +) -> int: + root = Path(project_root).expanduser().resolve() + try: + changes = initialize_project(root, mode=mode) + except (OSError, ValueError) as exc: + return _emit_error( + "init_error", + exc, + json_output=json_output, + output_func=output_func, + error_func=error_func, + exit_code=EXIT_RUNTIME_ERROR, + ) + if json_output: + output_func( + json_text( + { + "ok": True, + "status": "initialized", + "project_root": str(root), + "mode": mode, + "changes": [change.to_dict() for change in changes], + } + ) + ) + else: + output_func(format_init_changes(root, changes)) + return EXIT_OK + + +def run_trace_command( + command: str, + path: Path | str, + *, + json_output: bool, + output_path: Path | str | None, + force: bool, + output_func: Callable[[str], None], + error_func: Callable[[str], None], +) -> int: + try: + if command == "inspect": + summary = inspect_trace(path) + output_func(json_text(summary) if json_output else format_trace_summary(summary)) + return EXIT_OK + destination = export_trace_html(path, output_path=output_path, force=force) + payload = { + "ok": True, + "status": "exported", + "format": "html", + "output_path": str(destination), + } + output_func(json_text(payload) if json_output else f"Trace exported to {destination}") + return EXIT_OK + except (TraceFormatError, FileExistsError, OSError, ValueError) as exc: + return _emit_error( + "trace_error", + exc, + json_output=json_output, + output_func=output_func, + error_func=error_func, + exit_code=EXIT_RUNTIME_ERROR, + ) + + +def json_text(value: object) -> str: + return json.dumps(value, sort_keys=True, ensure_ascii=False) + + +def _emit_error( + status: str, + error: Exception, + *, + json_output: bool, + output_func: Callable[[str], None], + error_func: Callable[[str], None], + exit_code: int, + unexpected: bool = False, +) -> int: + if json_output: + output_func(json_text({"ok": False, "status": status, "error": str(error)})) + else: + prefix = "error: unexpected failure" if unexpected else status.replace("_", " ") + error_func(f"{prefix}: {error}") + return exit_code + + +__all__ = [ + "EXIT_APPROVAL_REQUIRED", + "EXIT_CONFIGURATION_ERROR", + "EXIT_OK", + "EXIT_RUNTIME_ERROR", + "json_text", + "run_doctor_command", + "run_exec_command", + "run_init_command", + "run_trace_command", +] diff --git a/chulk/cli/maintenance.py b/chulk/cli/maintenance.py new file mode 100644 index 0000000..74ff823 --- /dev/null +++ b/chulk/cli/maintenance.py @@ -0,0 +1,502 @@ +"""Non-agent CLI diagnostics, initialization, and trace commands.""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass +import json +import os +from pathlib import Path +import subprocess +from typing import Any + +from chulk.config import Config, load_config, resolve_cli_environment +from chulk.llm import resolve_model_capabilities +from chulk.tracing import Trace, TraceFormatError + + +@dataclass(frozen=True) +class DiagnosticCheck: + """One human- and machine-readable doctor result.""" + + name: str + status: str + detail: str + remedy: str | None = None + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass(frozen=True) +class DoctorReport: + """Collected local runtime diagnostics.""" + + project_root: Path + checks: tuple[DiagnosticCheck, ...] + + @property + def ok(self) -> bool: + return all(check.status != "fail" for check in self.checks) + + def to_dict(self) -> dict[str, Any]: + return { + "ok": self.ok, + "project_root": str(self.project_root), + "checks": [check.to_dict() for check in self.checks], + } + + +@dataclass(frozen=True) +class InitChange: + """One filesystem result from ``chulk init``.""" + + path: Path + action: str + + def to_dict(self) -> dict[str, str]: + return {"path": str(self.path), "action": self.action} + + +def run_doctor(*, environ: dict[str, str] | None = None) -> DoctorReport: + """Run offline configuration, credential, runtime, and ignore checks.""" + env = resolve_cli_environment(environ) + project_root = Path(env["CHULK_PROJECT_ROOT"]).expanduser().resolve() + checks: list[DiagnosticCheck] = [] + try: + config = load_config(env) + except (OSError, ValueError) as exc: + checks.append( + DiagnosticCheck( + "configuration", + "fail", + str(exc), + "Correct the reported environment or .env value, then rerun chulk doctor.", + ) + ) + return DoctorReport(project_root, tuple(checks)) + + project_root = config.project_root + checks.append(DiagnosticCheck("configuration", "pass", "configuration parsed successfully")) + checks.append(_provider_check(config)) + checks.append(_model_check(config)) + checks.append(_runtime_check(config)) + checks.extend(_mcp_checks(config)) + checks.append(_gitignore_check(config)) + return DoctorReport(project_root, tuple(checks)) + + +def format_doctor_report(report: DoctorReport) -> str: + lines = ["Chulk doctor", f" project {report.project_root}"] + markers = {"pass": "ok", "warn": "warn", "fail": "fail"} + for check in report.checks: + lines.append(f" [{markers.get(check.status, check.status)}] {check.name}: {check.detail}") + if check.remedy: + lines.append(f" fix: {check.remedy}") + lines.append(" result " + ("ready" if report.ok else "action required")) + return "\n".join(lines) + + +def initialize_project(project_root: Path | str, *, mode: str = "coding-agent") -> tuple[InitChange, ...]: + """Create safe project-local Chulk scaffolding without overwriting files.""" + root = Path(project_root).expanduser().resolve() + root.mkdir(parents=True, exist_ok=True) + clean_mode = mode.strip().lower() + if clean_mode not in {"sdk", "coding-agent", "read-only"}: + raise ValueError("init mode must be sdk, coding-agent, or read-only") + permission_profile = "workspace-write" if clean_mode == "coding-agent" else "read-only" + changes: list[InitChange] = [] + + runtime_dir = root / ".chulk" + skills_dir = runtime_dir / "skills" + mcp_path = runtime_dir / "mcp.json" + env_example_path = root / ".env.example" + gitignore_path = root / ".gitignore" + for target in (runtime_dir, skills_dir, mcp_path, env_example_path, gitignore_path): + _validate_init_target(root, target) + + for directory in (runtime_dir, skills_dir): + existed = directory.exists() + directory.mkdir(parents=True, exist_ok=True) + changes.append(InitChange(directory, "exists" if existed else "created")) + + if mcp_path.exists(): + changes.append(InitChange(mcp_path, "exists")) + else: + mcp_path.write_text(json.dumps({"servers": []}, indent=2) + "\n", encoding="utf-8") + changes.append(InitChange(mcp_path, "created")) + + if env_example_path.exists(): + changes.append(InitChange(env_example_path, "exists")) + else: + env_example_path.write_text(_env_example(permission_profile), encoding="utf-8") + changes.append(InitChange(env_example_path, "created")) + + action = _ensure_gitignore(gitignore_path) + changes.append(InitChange(gitignore_path, action)) + return tuple(changes) + + +def format_init_changes(project_root: Path | str, changes: tuple[InitChange, ...]) -> str: + root = Path(project_root).expanduser().resolve() + lines = ["Chulk initialized", f" project {root}"] + for change in changes: + try: + shown_path = change.path.relative_to(root) + except ValueError: + shown_path = change.path + lines.append(f" {change.action:<7} {shown_path}") + return "\n".join(lines) + + +def inspect_trace(path: Path | str) -> dict[str, Any]: + return Trace.from_jsonl(path).summary() + + +def format_trace_summary(summary: dict[str, Any]) -> str: + event_types = summary.get("event_types", {}) + type_text = ", ".join(f"{name} x{count}" for name, count in event_types.items()) + lines = [ + "Chulk trace", + f" path {summary.get('path')}", + f" conversation {summary.get('conversation_id')}", + f" events {summary.get('event_count')}", + f" turns {summary.get('turn_count')}", + f" failures {summary.get('failure_count')}", + f" started {summary.get('started_at')}", + f" ended {summary.get('ended_at')}", + f" event types {type_text or 'none'}", + ] + if summary.get("final_answer"): + lines.extend([" final answer", *[f" {line}" for line in str(summary["final_answer"]).splitlines()]]) + lines.append(" warning traces may contain sensitive runtime data") + return "\n".join(lines) + + +def export_trace_html( + path: Path | str, + *, + output_path: Path | str | None = None, + force: bool = False, +) -> Path: + trace = Trace.from_jsonl(path) + destination = ( + Path(output_path).expanduser().resolve() + if output_path is not None + else trace.path.with_suffix(".html") + ) + if destination == trace.path or (destination.exists() and destination.samefile(trace.path)): + raise ValueError(f"Trace export output cannot overwrite the source trace: {trace.path}") + if destination.exists() and not force: + raise FileExistsError(f"Output already exists: {destination}. Pass --force to replace it.") + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text(trace.to_html(), encoding="utf-8") + return destination + + +def _provider_check(config: Config) -> DiagnosticCheck: + providers = _configured_provider_models(config) + missing: list[str] = [] + for label, provider, _model in providers: + if provider == "openai" and not config.openai_api_key: + missing.append(f"{label} openai (OPENAI_API_KEY)") + elif provider == "deepseek" and not config.deepseek_api_key: + missing.append(f"{label} deepseek (DEEPSEEK_API_KEY)") + if missing: + return DiagnosticCheck( + "provider", + "fail", + "credentials are missing for: " + ", ".join(missing), + "Set the listed variables in the environment or project .env.", + ) + if len(providers) == 1: + provider = providers[0][1] + detail = ( + f"{provider} credentials are set" + if provider in {"openai", "deepseek"} + else f"{provider} provider configured" + ) + return DiagnosticCheck("provider", "pass", detail) + return DiagnosticCheck( + "provider", + "pass", + f"primary and {len(providers) - 1} fallback provider(s) are configured", + ) + + +def _model_check(config: Config) -> DiagnosticCheck: + providers = _configured_provider_models(config) + capabilities = [] + invalid: list[str] = [] + for label, provider, model in providers: + try: + capabilities.append(resolve_model_capabilities(provider, model)) + except ValueError as exc: + invalid.append(f"{label} {provider}/{model}: {exc}") + if invalid: + return DiagnosticCheck( + "model", + "fail", + "invalid model configuration: " + "; ".join(invalid), + "Set primary and fallback models to registered models.", + ) + if len(providers) > 1: + return DiagnosticCheck( + "model", + "pass", + f"capability metadata is available for {len(providers)} configured models", + ) + capabilities_for_primary = capabilities[0] + return DiagnosticCheck( + "model", + "pass", + f"{config.llm_provider}/{config.model}, {capabilities_for_primary.context_window_tokens} token context", + ) + + +def _runtime_check(config: Config) -> DiagnosticCheck: + errors = [ + error + for label, path in ( + ("runtime directory", config.runtime_dir), + ("SQLite store directory", config.store_path.parent), + ("trace directory", config.traces_dir), + ) + if (error := _directory_write_error(label, path)) is not None + ] + if config.store_path.exists(): + if not config.store_path.is_file(): + errors.append(f"SQLite store path is not a file: {config.store_path}") + elif not os.access(config.store_path, os.W_OK): + errors.append(f"SQLite store path is not writable: {config.store_path}") + if not errors: + return DiagnosticCheck( + "runtime", + "pass", + "runtime, SQLite store, and trace destinations are writable", + ) + return DiagnosticCheck( + "runtime", + "fail", + "; ".join(errors), + "Choose writable runtime, SQLite store, and trace destinations.", + ) + + +def _mcp_checks(config: Config) -> list[DiagnosticCheck]: + if not config.mcp_servers: + return [DiagnosticCheck("mcp", "pass", "no MCP servers configured")] + missing = [ + server.authorization_env + for server in config.mcp_servers + if server.authorization_env and not server.authorization + ] + if missing: + names = ", ".join(str(name) for name in missing) + return [ + DiagnosticCheck( + "mcp", + "fail", + f"missing authorization environment variables: {names}", + "Set the missing variables without putting secret values in mcp.json.", + ) + ] + return [DiagnosticCheck("mcp", "pass", f"{len(config.mcp_servers)} server(s) configured")] + + +def _gitignore_check(config: Config) -> DiagnosticCheck: + git_root = _git_root(config.project_root) + if git_root is None: + return DiagnosticCheck("gitignore", "warn", "project is not inside a Git worktree") + try: + project_prefix = config.project_root.relative_to(git_root) + except ValueError: + project_prefix = Path() + tracked = _tracked_runtime_paths(git_root, config) + if tracked: + return DiagnosticCheck( + "gitignore", + "fail", + "runtime paths are already tracked by Git: " + ", ".join(tracked), + "Remove runtime state from the index with git rm --cached, then keep the ignore rules.", + ) + candidates = tuple( + str(project_prefix / candidate) + for candidate in (".chulk/store.sqlite", "traces/example.jsonl", "chulk/store.sqlite", "state.sqlite") + ) + missing = [candidate for candidate in candidates if not _git_ignores(git_root, candidate)] + if not missing: + return DiagnosticCheck("gitignore", "pass", "runtime databases and traces are ignored") + return DiagnosticCheck( + "gitignore", + "fail", + "runtime paths are not fully ignored: " + ", ".join(missing), + "Run chulk init or add .chulk/, traces/, chulk/store.sqlite, and *.sqlite to .gitignore.", + ) + + +def _git_root(project_root: Path) -> Path | None: + try: + result = subprocess.run( + ["git", "-C", str(project_root), "rev-parse", "--show-toplevel"], + check=False, + capture_output=True, + text=True, + timeout=3, + ) + except (OSError, subprocess.SubprocessError): + return None + return Path(result.stdout.strip()).resolve() if result.returncode == 0 and result.stdout.strip() else None + + +def _git_ignores(git_root: Path, relative_path: str) -> bool: + try: + result = subprocess.run( + ["git", "check-ignore", "--no-index", "-q", "--", relative_path], + cwd=git_root, + check=False, + timeout=3, + ) + except (OSError, subprocess.SubprocessError): + return False + return result.returncode == 0 + + +def _configured_provider_models(config: Config) -> tuple[tuple[str, str, str], ...]: + return ( + ("primary", config.llm_provider, config.model), + *( + (f"fallback #{index}", fallback.provider, fallback.model) + for index, fallback in enumerate(config.llm_fallback_providers, start=1) + ), + ) + + +def _directory_write_error(label: str, path: Path) -> str | None: + if path.exists(): + if not path.is_dir(): + return f"{label} is not a directory: {path}" + if not os.access(path, os.W_OK): + return f"{label} is not writable: {path}" + return None + if path.is_symlink(): + return f"{label} is a broken symlink: {path}" + + ancestor = path.parent + while not ancestor.exists() and ancestor != ancestor.parent: + ancestor = ancestor.parent + if not ancestor.is_dir(): + return f"parent for {label} is not a directory: {ancestor}" + if not os.access(ancestor, os.W_OK): + return f"parent for {label} is not writable: {ancestor}" + return None + + +def _tracked_runtime_paths(git_root: Path, config: Config) -> tuple[str, ...]: + try: + result = subprocess.run( + ["git", "ls-files", "-z"], + cwd=git_root, + check=False, + capture_output=True, + timeout=3, + ) + except (OSError, subprocess.SubprocessError): + return () + if result.returncode != 0: + return () + + directory_prefixes = tuple( + relative + for directory in (config.runtime_dir, config.traces_dir) + if (relative := _relative_to_git_root(directory, git_root)) is not None + ) + store_path = _relative_to_git_root(config.store_path, git_root) + project_prefix = _relative_to_git_root(config.project_root, git_root) + tracked: list[str] = [] + for raw_path in result.stdout.decode("utf-8", errors="surrogateescape").split("\0"): + if not raw_path: + continue + path = Path(raw_path) + project_path = None + if project_prefix is not None: + try: + project_path = path.relative_to(project_prefix) + except ValueError: + pass + if ( + any(path == prefix or prefix in path.parents for prefix in directory_prefixes) + or path == store_path + or (project_path is not None and project_path.suffix in {".sqlite", ".sqlite3"}) + ): + tracked.append(raw_path) + return tuple(sorted(tracked)) + + +def _relative_to_git_root(path: Path, git_root: Path) -> Path | None: + try: + return path.resolve().relative_to(git_root) + except ValueError: + return None + + +def _env_example(permission_profile: str) -> str: + return "\n".join( + [ + "OPENAI_API_KEY=", + "DEEPSEEK_API_KEY=", + "CHULK_LLM_PROVIDER=openai", + "CHULK_MODEL=", + f"CHULK_PERMISSION_PROFILE={permission_profile}", + "CHULK_RUNTIME_DIR=.chulk", + "", + ] + ) + + +def _validate_init_target(project_root: Path, target: Path) -> None: + if target.is_symlink(): + raise ValueError(f"Refusing to initialize symlinked path: {target}") + try: + target.resolve(strict=False).relative_to(project_root) + except ValueError as exc: + raise ValueError(f"Refusing to initialize path outside project root: {target}") from exc + + +def _ensure_gitignore(path: Path) -> str: + required = [ + ".env", + ".env.*", + "!.env.example", + ".chulk/", + "traces/", + "chulk/store.sqlite", + "*.sqlite", + "*.sqlite3", + ] + existing = path.read_text(encoding="utf-8") if path.exists() else "" + existing_lines = {line.strip() for line in existing.splitlines()} + missing = [line for line in required if line not in existing_lines] + if not missing: + return "exists" + if existing: + prefix = "\n" if existing.endswith("\n") else "\n\n" + content = existing + prefix + "# Chulk runtime state\n" + "\n".join(missing) + "\n" + else: + content = "# Chulk runtime state\n" + "\n".join(missing) + "\n" + path.write_text(content, encoding="utf-8") + return "created" if not existing else "updated" + + +__all__ = [ + "DiagnosticCheck", + "DoctorReport", + "InitChange", + "TraceFormatError", + "export_trace_html", + "format_doctor_report", + "format_init_changes", + "format_trace_summary", + "initialize_project", + "inspect_trace", + "run_doctor", +] diff --git a/chulk/cli/parser.py b/chulk/cli/parser.py new file mode 100644 index 0000000..33f9983 --- /dev/null +++ b/chulk/cli/parser.py @@ -0,0 +1,80 @@ +"""Argument parser for the Chulk command-line interface.""" + +from __future__ import annotations + +import argparse + + +def build_parser() -> argparse.ArgumentParser: + """Build the backwards-compatible CLI and its explicit subcommands.""" + parser = argparse.ArgumentParser( + prog="chulk", + description="Run the ChulkHarness agent runtime.", + ) + parser.add_argument("--version", action="store_true", help="Print the ChulkHarness version and exit.") + parser.add_argument( + "--show-config", + action="store_true", + help="Print resolved local configuration and exit.", + ) + parser.add_argument("--once", metavar="MESSAGE", help="Compatibility alias for 'chulk exec MESSAGE'.") + parser.add_argument( + "--color", + choices=("auto", "always", "never"), + default="auto", + help="Control ANSI color output (default: auto).", + ) + session_group = parser.add_mutually_exclusive_group() + session_group.add_argument("--resume", metavar="ID", help="Start in a persisted interactive session.") + session_group.add_argument( + "--continue", + dest="continue_session", + action="store_true", + help="Resume the most recently updated session.", + ) + + subparsers = parser.add_subparsers(dest="command") + _add_exec_parser(subparsers) + _add_doctor_parser(subparsers) + _add_init_parser(subparsers) + _add_trace_parser(subparsers) + return parser + + +def _add_exec_parser(subparsers: argparse._SubParsersAction) -> None: + parser = subparsers.add_parser("exec", help="Run one non-interactive agent request.") + parser.add_argument("message", nargs="+", help="Message to send to the agent.") + parser.add_argument("--json", action="store_true", dest="json_output", help="Emit structured JSON.") + + +def _add_doctor_parser(subparsers: argparse._SubParsersAction) -> None: + parser = subparsers.add_parser("doctor", help="Validate local Chulk configuration.") + parser.add_argument("--json", action="store_true", dest="json_output", help="Emit structured JSON.") + + +def _add_init_parser(subparsers: argparse._SubParsersAction) -> None: + parser = subparsers.add_parser("init", help="Initialize project-local Chulk files.") + parser.add_argument("--project-root", default=".", help="Project directory to initialize.") + mode = parser.add_mutually_exclusive_group() + mode.add_argument("--sdk", action="store_const", const="sdk", dest="init_mode") + mode.add_argument("--coding-agent", action="store_const", const="coding-agent", dest="init_mode") + mode.add_argument("--read-only", action="store_const", const="read-only", dest="init_mode") + parser.set_defaults(init_mode="coding-agent") + parser.add_argument("--json", action="store_true", dest="json_output", help="Emit structured JSON.") + + +def _add_trace_parser(subparsers: argparse._SubParsersAction) -> None: + parser = subparsers.add_parser("trace", help="Inspect or export a JSONL trace.") + trace_subparsers = parser.add_subparsers(dest="trace_command", required=True) + inspect_parser = trace_subparsers.add_parser("inspect", help="Summarize a trace file.") + inspect_parser.add_argument("path", help="Path to a Chulk JSONL trace.") + inspect_parser.add_argument("--json", action="store_true", dest="json_output", help="Emit structured JSON.") + export_parser = trace_subparsers.add_parser("export", help="Export a trace report.") + export_parser.add_argument("path", help="Path to a Chulk JSONL trace.") + export_parser.add_argument("--format", choices=("html",), default="html") + export_parser.add_argument("--output", help="Destination path (defaults beside the trace).") + export_parser.add_argument("--force", action="store_true", help="Replace an existing destination.") + export_parser.add_argument("--json", action="store_true", dest="json_output", help="Emit structured JSON.") + + +__all__ = ["build_parser"] diff --git a/chulk/config.py b/chulk/config.py index d4d162c..f39958f 100644 --- a/chulk/config.py +++ b/chulk/config.py @@ -193,6 +193,28 @@ def load_config(environ: Mapping[str, str] | None = None) -> Config: ) +def resolve_cli_environment( + environ: Mapping[str, str] | None = None, + *, + cwd: Path | str | None = None, +) -> dict[str, str]: + """Return CLI environment values with one explicit project root.""" + env = dict(os.environ if environ is None else environ) + if not env.get("CHULK_PROJECT_ROOT"): + project_root = Path.cwd() if cwd is None else Path(cwd) + env["CHULK_PROJECT_ROOT"] = str(project_root.expanduser().resolve()) + return env + + +def load_cli_config( + environ: Mapping[str, str] | None = None, + *, + cwd: Path | str | None = None, +) -> Config: + """Load CLI configuration relative to the current project directory.""" + return load_config(resolve_cli_environment(environ, cwd=cwd)) + + def bundled_skills_dir() -> Path: """Return the installed path for Chulk's bundled skill playbooks.""" return Path(__file__).resolve().parent / "skills" / "bundled" diff --git a/chulk/main.py b/chulk/main.py index 2c940cc..c032523 100644 --- a/chulk/main.py +++ b/chulk/main.py @@ -2,8 +2,9 @@ from __future__ import annotations -import argparse +from argparse import Namespace from collections.abc import Sequence +import sys from typing import Callable from chulk import __version__ @@ -14,9 +15,20 @@ ProgressReporter, ProgressSettings, TerminalUI, + command_completion_candidates, handle_cli_command, ) -from chulk.config import Config, load_config +from chulk.cli.entrypoints import ( + EXIT_CONFIGURATION_ERROR, + EXIT_OK, + json_text, + run_doctor_command, + run_exec_command, + run_init_command, + run_trace_command, +) +from chulk.cli.parser import build_parser +from chulk.config import Config, load_cli_config from chulk.core import Agent from chulk.llm import ( DeepSeekProvider, @@ -29,34 +41,10 @@ ) from chulk.presets import software_engineer from chulk.runtime import create_agent -from chulk.sessions import SQLiteSessionStore +from chulk.sessions import AmbiguousSessionError, SessionNotFoundError, SQLiteSessionStore from chulk.tools.permissions import PermissionDecision, PermissionDecisionRecord, PermissionRequest -def build_parser() -> argparse.ArgumentParser: - """Build the CLI argument parser.""" - parser = argparse.ArgumentParser( - prog="chulk", - description="Run the ChulkHarness agent runtime.", - ) - parser.add_argument( - "--version", - action="store_true", - help="Print the ChulkHarness version and exit.", - ) - parser.add_argument( - "--show-config", - action="store_true", - help="Print resolved local configuration and exit.", - ) - parser.add_argument( - "--once", - metavar="MESSAGE", - help="Send one message to the agent and exit.", - ) - return parser - - def format_config(config: Config) -> str: """Format non-secret configuration values for terminal output.""" values = { @@ -158,8 +146,10 @@ def run_chat_loop( agent_factory: Callable[[str], Agent] | None = None, input_func: Callable[[str], str] = input, output_func: Callable[[str], None] = print, + error_func: Callable[[str], None] | None = None, ) -> int: """Run the interactive chat loop.""" + error_func = error_func or _print_stderr terminal = terminal or TerminalUI.themed() progress_settings = ProgressSettings() progress_reporter = ProgressReporter( @@ -180,6 +170,8 @@ def run_chat_loop( agent.permission_callback = permission_callback session_store = SQLiteSessionStore(config.store_path) if config is not None else None prompt_history = PromptHistory.create(enabled=input_func is input) + if hasattr(prompt_history, "configure_completion"): + prompt_history.configure_completion(command_completion_candidates()) _load_prompt_history(prompt_history, session_store, agent) if config is not None: output_func(terminal.banner(config, agent)) @@ -202,6 +194,11 @@ def switch_agent(next_agent: Agent) -> None: terminal=terminal, progress_settings=progress_settings, output_func=output_func, + response_func=lambda response: ( + None + if progress_reporter.streamed_answer + else output_func(terminal.assistant_message(response)) + ), session_store=session_store, agent_factory=agent_factory, switch_agent=switch_agent, @@ -228,36 +225,40 @@ def switch_agent(next_agent: Agent) -> None: prompt_history.add(user_message) + progress_reporter.reset_stream_state() + handled = False try: - if handle_cli_command(user_message.strip(), command_context): - continue + handled = handle_cli_command(user_message.strip(), command_context) except LLMError as exc: - output_func(terminal.error(f"error: {exc}")) + error_func(terminal.error(f"error: {exc}")) return 1 except Exception as exc: - output_func(terminal.error(f"error: unexpected failure: {exc}")) + error_func(terminal.error(f"error: unexpected failure: {exc}")) return 1 finally: progress_reporter.close() + if handled: + progress_reporter.flush_summary() + continue if command_context.agent.has_pending_plan(): output_func(terminal.warning("A plan is waiting for approval. Use /approve to execute it or /reject to cancel it.")) continue try: - progress_reporter.reset_stream_state() assistant_response = command_context.agent.run_turn(user_message) except LLMError as exc: - output_func(terminal.error(f"error: {exc}")) + error_func(terminal.error(f"error: {exc}")) return 1 except Exception as exc: - output_func(terminal.error(f"error: unexpected failure: {exc}")) + error_func(terminal.error(f"error: unexpected failure: {exc}")) return 1 finally: progress_reporter.close() if not progress_reporter.streamed_answer: output_func(terminal.assistant_message(assistant_response)) + progress_reporter.flush_summary() def _load_prompt_history( @@ -278,40 +279,89 @@ def main( *, input_func: Callable[[str], str] = input, output_func: Callable[[str], None] = print, + error_func: Callable[[str], None] | None = None, llm_client_factory: Callable[[Config], LLMClient] | None = None, ) -> int: """Run the current CLI.""" + error_func = error_func or _print_stderr parser = build_parser() args = parser.parse_args(argv) - terminal = TerminalUI.themed() + terminal = TerminalUI.themed(color=args.color) if args.version: - print(f"ChulkHarness {__version__}") - return 0 + output_func(f"ChulkHarness {__version__}") + return EXIT_OK + + if args.command == "init": + return run_init_command( + args.project_root, + mode=args.init_mode, + json_output=args.json_output, + output_func=output_func, + error_func=error_func, + ) + if args.command == "doctor": + return run_doctor_command(json_output=args.json_output, output_func=output_func) + if args.command == "trace": + return run_trace_command( + args.trace_command, + args.path, + json_output=args.json_output, + output_path=getattr(args, "output", None), + force=bool(getattr(args, "force", False)), + output_func=output_func, + error_func=error_func, + ) if args.show_config: - print(format_config(load_config())) - return 0 + try: + output_func(format_config(load_cli_config())) + except (OSError, ValueError) as exc: + error_func(terminal.error(f"configuration error: {exc}")) + return EXIT_CONFIGURATION_ERROR + return EXIT_OK try: - config = load_config() - agent = create_cli_agent(config, llm_client_factory) + config = load_cli_config() + except (OSError, ValueError, LLMConfigurationError) as exc: + if args.command == "exec" and getattr(args, "json_output", False): + output_func(json_text({"ok": False, "status": "configuration_error", "error": str(exc)})) + else: + error_func(terminal.error(f"configuration error: {exc}")) + return EXIT_CONFIGURATION_ERROR + + if args.command == "exec" or args.once is not None: + if args.resume or args.continue_session: + error_func(terminal.error("configuration error: --resume and --continue are interactive-only")) + return EXIT_CONFIGURATION_ERROR + message = " ".join(args.message) if args.command == "exec" else str(args.once) + json_output = bool(getattr(args, "json_output", False)) + return run_exec_command( + message, + agent_factory=lambda: create_cli_agent(config, llm_client_factory), + json_output=json_output, + output_func=output_func, + error_func=error_func, + ) + + try: + conversation_id = _resolve_startup_conversation(config, args) + except (SessionNotFoundError, AmbiguousSessionError) as exc: + error_func(terminal.error(f"session error: {exc}")) + return EXIT_CONFIGURATION_ERROR + try: + agent = create_cli_agent(config, llm_client_factory, conversation_id=conversation_id) agent.permission_callback = _make_cli_permission_callback( terminal, input_func=input_func, output_func=output_func, ) + except (SessionNotFoundError, AmbiguousSessionError) as exc: + error_func(terminal.error(f"session error: {exc}")) + return EXIT_CONFIGURATION_ERROR except (ValueError, LLMConfigurationError) as exc: - output_func(terminal.error(f"configuration error: {exc}")) - return 1 - - if args.once is not None: - try: - output_func(agent.run_turn(args.once)) - except LLMError as exc: - output_func(f"error: {exc}") - return 1 - return 0 + error_func(terminal.error(f"configuration error: {exc}")) + return EXIT_CONFIGURATION_ERROR return run_chat_loop( agent, @@ -324,9 +374,25 @@ def main( ), input_func=input_func, output_func=output_func, + error_func=error_func, ) +def _resolve_startup_conversation(config: Config, args: Namespace) -> str | None: + if args.resume: + return str(args.resume) + if not args.continue_session: + return None + latest = SQLiteSessionStore(config.store_path).latest_conversation() + if latest is None: + raise SessionNotFoundError("No persisted session is available to continue") + return latest.id + + +def _print_stderr(message: str) -> None: + print(message, file=sys.stderr) + + def _make_cli_permission_callback( terminal: TerminalUI, *, diff --git a/chulk/tests/conftest.py b/chulk/tests/conftest.py new file mode 100644 index 0000000..47e5711 --- /dev/null +++ b/chulk/tests/conftest.py @@ -0,0 +1,12 @@ +"""Shared test isolation from developer-local provider configuration.""" + +from __future__ import annotations + +import pytest + + +@pytest.fixture(autouse=True) +def stable_test_provider(monkeypatch): + """Keep repository .env values from changing deterministic test defaults.""" + monkeypatch.setenv("CHULK_LLM_PROVIDER", "openai") + monkeypatch.setenv("CHULK_MODEL", "gpt-4.1-mini") diff --git a/chulk/tests/test_cli_maintenance.py b/chulk/tests/test_cli_maintenance.py new file mode 100644 index 0000000..fbf84a7 --- /dev/null +++ b/chulk/tests/test_cli_maintenance.py @@ -0,0 +1,385 @@ +"""Tests for non-agent CLI maintenance commands.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import subprocess + +import pytest + +from chulk.cli.maintenance import run_doctor +from chulk.main import main + + +def test_init_creates_safe_project_scaffolding_and_is_idempotent(tmp_path, capsys): + project_root = tmp_path / "project" + + first_exit = main(["init", "--project-root", str(project_root), "--coding-agent", "--json"]) + first_payload = json.loads(capsys.readouterr().out) + second_exit = main(["init", "--project-root", str(project_root), "--coding-agent", "--json"]) + second_payload = json.loads(capsys.readouterr().out) + + assert first_exit == 0 + assert second_exit == 0 + assert first_payload["ok"] is True + assert (project_root / ".chulk" / "skills").is_dir() + assert json.loads((project_root / ".chulk" / "mcp.json").read_text(encoding="utf-8")) == {"servers": []} + env_example = (project_root / ".env.example").read_text(encoding="utf-8") + assert "CHULK_PERMISSION_PROFILE=workspace-write" in env_example + assert "API_KEY=\n" in env_example + gitignore = (project_root / ".gitignore").read_text(encoding="utf-8") + assert all( + entry in gitignore + for entry in (".env", "!.env.example", ".chulk/", "traces/", "chulk/store.sqlite", "*.sqlite") + ) + assert all(change["action"] == "exists" for change in second_payload["changes"]) + + +def test_init_read_only_uses_safe_permission_default(tmp_path, capsys): + project_root = tmp_path / "read-only-project" + + exit_code = main(["init", "--project-root", str(project_root), "--read-only"]) + + output = capsys.readouterr().out + env_example = (project_root / ".env.example").read_text(encoding="utf-8") + + assert exit_code == 0 + assert "Chulk initialized" in output + assert "CHULK_PERMISSION_PROFILE=read-only" in env_example + + +def test_init_rejects_symlinked_target_before_writing(tmp_path, capsys): + project_root = tmp_path / "project" + project_root.mkdir() + outside_gitignore = tmp_path / "outside.gitignore" + outside_gitignore.write_text("keep-this-line\n", encoding="utf-8") + (project_root / ".gitignore").symlink_to(outside_gitignore) + + exit_code = main(["init", "--project-root", str(project_root), "--json"]) + + payload = json.loads(capsys.readouterr().out) + + assert exit_code == 1 + assert payload["ok"] is False + assert payload["status"] == "init_error" + assert "symlinked path" in payload["error"] + assert outside_gitignore.read_text(encoding="utf-8") == "keep-this-line\n" + assert not (project_root / ".chulk").exists() + + +def test_doctor_uses_current_directory_as_inferred_project_root(monkeypatch, tmp_path): + project_root = tmp_path / "project" + project_root.mkdir() + (project_root / ".env").write_text( + "CHULK_LLM_PROVIDER=local\nCHULK_MODEL=project-local-model\n", + encoding="utf-8", + ) + monkeypatch.chdir(project_root) + + report = run_doctor(environ={}) + + assert report.project_root == project_root.resolve() + assert report.ok is True + assert next(check for check in report.checks if check.name == "provider").detail == "local provider configured" + assert "local/project-local-model" in next( + check for check in report.checks if check.name == "model" + ).detail + + +def test_doctor_json_reports_local_runtime_without_requiring_network(monkeypatch, tmp_path, capsys): + monkeypatch.setenv("CHULK_PROJECT_ROOT", str(tmp_path)) + monkeypatch.setenv("CHULK_LLM_PROVIDER", "local") + monkeypatch.setenv("CHULK_MODEL", "local-test-model") + + exit_code = main(["doctor", "--json"]) + + payload = json.loads(capsys.readouterr().out) + + assert exit_code == 0 + assert payload["ok"] is True + assert payload["project_root"] == str(tmp_path) + assert {check["name"] for check in payload["checks"]} >= { + "configuration", + "provider", + "model", + "runtime", + "mcp", + "gitignore", + } + + +def test_doctor_reports_invalid_configuration_without_traceback(monkeypatch, tmp_path, capsys): + monkeypatch.setenv("CHULK_PROJECT_ROOT", str(tmp_path)) + monkeypatch.setenv("CHULK_LLM_PROVIDER", "invalid") + + exit_code = main(["doctor"]) + + captured = capsys.readouterr() + + assert exit_code == 2 + assert "[fail] configuration" in captured.out + assert "Traceback" not in captured.out + captured.err + + +@pytest.mark.parametrize("config_path", [Path(".env"), Path(".chulk/mcp.json")]) +def test_doctor_reports_configuration_read_errors(config_path, monkeypatch, tmp_path, capsys): + unreadable_path = tmp_path / config_path + unreadable_path.mkdir(parents=True) + monkeypatch.setenv("CHULK_PROJECT_ROOT", str(tmp_path)) + monkeypatch.setenv("CHULK_LLM_PROVIDER", "local") + monkeypatch.setenv("CHULK_MODEL", "local-test-model") + + exit_code = main(["doctor", "--json"]) + + captured = capsys.readouterr() + payload = json.loads(captured.out) + configuration_check = payload["checks"][0] + + assert exit_code == 2 + assert captured.err == "" + assert payload["ok"] is False + assert configuration_check["name"] == "configuration" + assert configuration_check["status"] == "fail" + assert "Traceback" not in captured.out + + +def test_doctor_rejects_missing_fallback_credentials(tmp_path): + report = run_doctor( + environ={ + "CHULK_PROJECT_ROOT": str(tmp_path), + "CHULK_LLM_PROVIDER": "local", + "CHULK_MODEL": "local-test-model", + "CHULK_LLM_FALLBACK_PROVIDERS": "openai:gpt-4.1-mini", + } + ) + + provider_check = next(check for check in report.checks if check.name == "provider") + + assert report.ok is False + assert provider_check.status == "fail" + assert "fallback #1 openai" in provider_check.detail + assert "OPENAI_API_KEY" in provider_check.detail + + +def test_doctor_rejects_unregistered_fallback_model(tmp_path): + report = run_doctor( + environ={ + "CHULK_PROJECT_ROOT": str(tmp_path), + "CHULK_LLM_PROVIDER": "local", + "CHULK_MODEL": "local-test-model", + "CHULK_LLM_FALLBACK_PROVIDERS": "openai:unknown-model", + "OPENAI_API_KEY": "test-key", + } + ) + + model_check = next(check for check in report.checks if check.name == "model") + + assert report.ok is False + assert model_check.status == "fail" + assert "fallback #1 openai/unknown-model" in model_check.detail + + +def test_doctor_rejects_trace_destination_that_is_a_file(tmp_path): + (tmp_path / "traces").write_text("not a directory", encoding="utf-8") + + report = run_doctor( + environ={ + "CHULK_PROJECT_ROOT": str(tmp_path), + "CHULK_LLM_PROVIDER": "local", + "CHULK_MODEL": "local-test-model", + } + ) + + runtime_check = next(check for check in report.checks if check.name == "runtime") + + assert report.ok is False + assert runtime_check.status == "fail" + assert "trace directory is not a directory" in runtime_check.detail + + +def test_doctor_rejects_unwritable_store_parent(monkeypatch, tmp_path): + store_parent = tmp_path / "chulk" + store_parent.mkdir() + real_access = os.access + + def fake_access(path, mode): + if Path(path) == store_parent: + return False + return real_access(path, mode) + + monkeypatch.setattr("chulk.cli.maintenance.os.access", fake_access) + + report = run_doctor( + environ={ + "CHULK_PROJECT_ROOT": str(tmp_path), + "CHULK_LLM_PROVIDER": "local", + "CHULK_MODEL": "local-test-model", + } + ) + + runtime_check = next(check for check in report.checks if check.name == "runtime") + + assert report.ok is False + assert runtime_check.status == "fail" + assert "SQLite store directory is not writable" in runtime_check.detail + + +def test_doctor_reports_ignored_runtime_state_already_tracked_by_git(tmp_path): + subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True) + (tmp_path / ".gitignore").write_text( + ".chulk/\ntraces/\nchulk/store.sqlite\n*.sqlite\n", + encoding="utf-8", + ) + store_path = tmp_path / "chulk" / "store.sqlite" + store_path.parent.mkdir() + store_path.write_bytes(b"sqlite state") + subprocess.run( + ["git", "add", ".gitignore"], + cwd=tmp_path, + check=True, + ) + subprocess.run( + ["git", "add", "-f", "chulk/store.sqlite"], + cwd=tmp_path, + check=True, + ) + + report = run_doctor( + environ={ + "CHULK_PROJECT_ROOT": str(tmp_path), + "CHULK_LLM_PROVIDER": "local", + "CHULK_MODEL": "local-test-model", + } + ) + + gitignore_check = next(check for check in report.checks if check.name == "gitignore") + + assert report.ok is False + assert gitignore_check.status == "fail" + assert "already tracked by Git" in gitignore_check.detail + assert "chulk/store.sqlite" in gitignore_check.detail + + +def test_trace_inspect_and_export_are_machine_readable_and_escape_html(tmp_path, capsys): + trace_path = tmp_path / "conversation-1.jsonl" + trace_path.write_text( + "\n".join( + [ + json.dumps( + { + "type": "turn_started", + "created_at": "2026-01-01T00:00:00+00:00", + "payload": {"turn": {"turn_id": "turn-1"}}, + } + ), + json.dumps( + { + "type": "final_answer", + "created_at": "2026-01-01T00:00:01+00:00", + "payload": {"content": ""}, + } + ), + json.dumps( + { + "type": "turn_finished", + "created_at": "2026-01-01T00:00:02+00:00", + "payload": { + "agent_state": {"conversation_id": "conversation-1"}, + "turn": {"model_usage_totals": {"usage": {"total_tokens": 12}}}, + }, + } + ), + ] + ) + + "\n", + encoding="utf-8", + ) + + inspect_exit = main(["trace", "inspect", str(trace_path), "--json"]) + summary = json.loads(capsys.readouterr().out) + html_path = tmp_path / "report.html" + export_exit = main( + ["trace", "export", str(trace_path), "--output", str(html_path), "--json"] + ) + export_payload = json.loads(capsys.readouterr().out) + + assert inspect_exit == 0 + assert export_exit == 0 + assert summary["conversation_id"] == "conversation-1" + assert summary["event_count"] == 3 + assert summary["turn_count"] == 1 + assert export_payload["output_path"] == str(html_path) + html = html_path.read_text(encoding="utf-8") + assert "<script>alert" in html + assert "