From b95cbafefccf66b43f3b9b6afb00c59733eaa508 Mon Sep 17 00:00:00 2001 From: Liam Date: Fri, 27 Mar 2026 01:55:23 +0100 Subject: [PATCH 1/4] Add CLI management, diagnostics, and completion commands --- CLI/README.md | 45 ++++ CLI/pointer_cli/config.py | 174 ++++++++++++- CLI/pointer_cli/doctor.py | 165 +++++++++++++ CLI/pointer_cli/main.py | 473 ++++++++++++++++++++++++++++++++--- CLI/tests/test_config.py | 62 +++++ CLI/tests/test_doctor.py | 101 ++++++++ CLI/tests/test_main.py | 506 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 1476 insertions(+), 50 deletions(-) create mode 100644 CLI/pointer_cli/doctor.py create mode 100644 CLI/tests/test_doctor.py create mode 100644 CLI/tests/test_main.py diff --git a/CLI/README.md b/CLI/README.md index e074b9f..8321a79 100644 --- a/CLI/README.md +++ b/CLI/README.md @@ -26,6 +26,51 @@ pointer On first run, the CLI will prompt for initialization and configuration. +Run a quick environment check with: + +```bash +pointer doctor +pointer doctor --json +``` + +The doctor command verifies your Python runtime, config directory, config initialization status, workspace detection, and API reachability. + +Inspect or update config values with: + +```bash +pointer config show +pointer config show api.base_url +pointer config set api.base_url http://localhost:1234 +``` + +Show the current environment with: + +```bash +pointer status +``` + +Manage codebase context from top-level commands: + +```bash +pointer context show +pointer context refresh +pointer context search TODO +pointer context config +``` + +Initialize without prompts with: + +```bash +pointer init --non-interactive --api-base-url http://localhost:1234 --model gpt-oss-20b +``` + +Enable shell completion with Typer's built-in commands: + +```bash +pointer --install-completion +pointer --show-completion +``` + ## Configuration The CLI supports custom API base URLs and model selection for local AI services. diff --git a/CLI/pointer_cli/config.py b/CLI/pointer_cli/config.py index 5adb4fb..99f30d8 100644 --- a/CLI/pointer_cli/config.py +++ b/CLI/pointer_cli/config.py @@ -3,10 +3,9 @@ """ import json -import os from pathlib import Path -from typing import Optional, Dict, Any, List -from dataclasses import dataclass, asdict +from typing import Any, List, Optional, get_args, get_origin + from pydantic import BaseModel, Field class APIConfig(BaseModel): @@ -103,6 +102,7 @@ def initialize( model_name: str, auto_run_mode: bool = True, show_ai_responses: bool = True, + config_path: Optional[str] = None, **kwargs ) -> None: """Initialize configuration with provided values.""" @@ -113,28 +113,28 @@ def initialize( self.initialized = True # Save the configuration - self.save() + self.save(config_path) - def update_api_config(self, **kwargs) -> None: + def update_api_config(self, config_path: Optional[str] = None, **kwargs) -> None: """Update API configuration.""" for key, value in kwargs.items(): if hasattr(self.api, key): setattr(self.api, key, value) - self.save() + self.save(config_path) - def update_ui_config(self, **kwargs) -> None: + def update_ui_config(self, config_path: Optional[str] = None, **kwargs) -> None: """Update UI configuration.""" for key, value in kwargs.items(): if hasattr(self.ui, key): setattr(self.ui, key, value) - self.save() + self.save(config_path) - def update_mode_config(self, **kwargs) -> None: + def update_mode_config(self, config_path: Optional[str] = None, **kwargs) -> None: """Update mode configuration.""" for key, value in kwargs.items(): if hasattr(self.mode, key): setattr(self.mode, key, value) - self.save() + self.save(config_path) def toggle_auto_run_mode(self) -> bool: """Toggle auto-run mode.""" @@ -155,3 +155,157 @@ def toggle_thinking(self) -> bool: self.ui.show_thinking = not self.ui.show_thinking self.save() return self.ui.show_thinking + + def validate(self) -> List[str]: + """Validate configuration values for CLI usage.""" + issues: List[str] = [] + + if not self.api.base_url or not self.api.base_url.startswith(("http://", "https://")): + issues.append("api.base_url must start with http:// or https://") + if not self.api.model_name.strip(): + issues.append("api.model_name cannot be empty") + if self.api.timeout <= 0: + issues.append("api.timeout must be greater than 0") + if self.api.max_retries < 0: + issues.append("api.max_retries cannot be negative") + if self.ui.max_output_lines <= 0: + issues.append("ui.max_output_lines must be greater than 0") + if self.codebase.max_context_files <= 0: + issues.append("codebase.max_context_files must be greater than 0") + if self.codebase.context_depth < 0: + issues.append("codebase.context_depth cannot be negative") + if self.codebase.context_cache_duration < 0: + issues.append("codebase.context_cache_duration cannot be negative") + if not self.codebase.context_file_types: + issues.append("codebase.context_file_types cannot be empty") + + return issues + + def get_value(self, key_path: str) -> Any: + """Get a configuration value by dotted path.""" + target, field_name = self._resolve_key_path(key_path) + return getattr(target, field_name) + + def list_key_paths(self) -> List[str]: + """List all supported dotted configuration keys.""" + key_paths: List[str] = [] + + for field_name, field_info in self.__class__.model_fields.items(): + value = getattr(self, field_name) + if isinstance(value, BaseModel): + for nested_name in value.__class__.model_fields: + key_paths.append(f"{field_name}.{nested_name}") + else: + key_paths.append(field_name) + + return sorted(key_paths) + + def suggest_values(self, key_path: str) -> List[str]: + """Return suggested values for a given config key.""" + target, field_name = self._resolve_key_path(key_path) + current_value = getattr(target, field_name) + + if isinstance(current_value, bool): + return ["true", "false"] + if isinstance(current_value, int) and not isinstance(current_value, bool): + return [str(current_value)] + if isinstance(current_value, list): + return [json.dumps(current_value), ",".join(str(item) for item in current_value)] + if current_value is None: + return ["null"] + + return [str(current_value)] + + def set_value(self, key_path: str, raw_value: str, config_path: Optional[str] = None) -> Any: + """Set a configuration value by dotted path.""" + target, field_name = self._resolve_key_path(key_path) + field_info = target.__class__.model_fields[field_name] + current_value = getattr(target, field_name) + coerced_value = self._coerce_value(raw_value, field_info.annotation, current_value) + setattr(target, field_name, coerced_value) + self.save(config_path) + return coerced_value + + def _resolve_key_path(self, key_path: str) -> tuple[BaseModel, str]: + """Resolve a dotted config key into a model instance and field name.""" + parts = key_path.split(".") + if not parts: + raise KeyError("Configuration key cannot be empty.") + + if len(parts) == 1: + field_name = parts[0] + if field_name not in self.__class__.model_fields: + raise KeyError(f"Unknown configuration key: {key_path}") + return self, field_name + + section_name = parts[0] + field_name = ".".join(parts[1:]) + + if section_name not in self.__class__.model_fields: + raise KeyError(f"Unknown configuration section: {section_name}") + + target = getattr(self, section_name) + if not isinstance(target, BaseModel): + raise KeyError(f"Configuration key {key_path} does not point to a nested section.") + + if field_name not in target.__class__.model_fields: + raise KeyError(f"Unknown configuration key: {key_path}") + + return target, field_name + + def _coerce_value(self, raw_value: str, annotation: Any, current_value: Any) -> Any: + """Coerce a string input into the correct config value type.""" + origin = get_origin(annotation) + args = [arg for arg in get_args(annotation) if arg is not type(None)] + + if origin in (list, List): + return self._parse_list_value(raw_value) + + if origin is None and annotation is bool: + return self._parse_bool_value(raw_value) + + if origin is None and annotation is int: + return int(raw_value) + + if origin is None and annotation is float: + return float(raw_value) + + if origin is None and annotation is str: + return raw_value + + if args: + non_none_type = args[0] + if raw_value.lower() in {"none", "null"}: + return None + return self._coerce_value(raw_value, non_none_type, current_value) + + if isinstance(current_value, bool): + return self._parse_bool_value(raw_value) + if isinstance(current_value, int) and not isinstance(current_value, bool): + return int(raw_value) + if isinstance(current_value, float): + return float(raw_value) + if isinstance(current_value, list): + return self._parse_list_value(raw_value) + + return raw_value + + def _parse_bool_value(self, raw_value: str) -> bool: + """Parse common boolean string values.""" + normalized = raw_value.strip().lower() + if normalized in {"1", "true", "yes", "on"}: + return True + if normalized in {"0", "false", "no", "off"}: + return False + raise ValueError(f"Invalid boolean value: {raw_value}") + + def _parse_list_value(self, raw_value: str) -> List[str]: + """Parse list values from JSON or comma-separated strings.""" + stripped = raw_value.strip() + if stripped.startswith("["): + parsed = json.loads(stripped) + if not isinstance(parsed, list): + raise ValueError("Expected a JSON array for list configuration.") + return parsed + + return [item.strip() for item in stripped.split(",") if item.strip()] diff --git a/CLI/pointer_cli/doctor.py b/CLI/pointer_cli/doctor.py new file mode 100644 index 0000000..b92fda1 --- /dev/null +++ b/CLI/pointer_cli/doctor.py @@ -0,0 +1,165 @@ +""" +Health checks for Pointer CLI environments. +""" + +from __future__ import annotations + +import os +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, List, Optional +from urllib import error, request + +from .config import Config +from .utils import ensure_config_dir, get_config_path, get_project_root, is_git_repo + + +@dataclass +class DoctorCheck: + """Represents a single doctor check result.""" + + name: str + status: str + details: str + + +def run_doctor( + config: Config, + config_path: Optional[str] = None, + cwd: Optional[Path] = None, + timeout: float = 2.0, +) -> List[DoctorCheck]: + """Run environment checks and return structured results.""" + working_directory = cwd or Path.cwd() + resolved_config_path = Path(config_path) if config_path else Config.get_default_config_path() + + checks = [ + _check_python_version(), + _check_config_directory(), + _check_config_file(config, resolved_config_path), + _check_config_validity(config), + _check_workspace(working_directory), + _check_api_endpoint(config.api.base_url, timeout=timeout), + ] + + return checks + + +def checks_to_dict(checks: List[DoctorCheck]) -> List[Dict[str, Any]]: + """Serialize doctor checks for machine-readable output.""" + return [ + { + "name": check.name, + "status": check.status, + "details": check.details, + } + for check in checks + ] + + +def summarize_results(checks: List[DoctorCheck]) -> tuple[int, int, int]: + """Return counts for passing, warning, and failing checks.""" + passing = sum(1 for check in checks if check.status == "pass") + warnings = sum(1 for check in checks if check.status == "warn") + failing = sum(1 for check in checks if check.status == "fail") + return passing, warnings, failing + + +def _check_python_version() -> DoctorCheck: + """Ensure the current Python version is supported.""" + version = sys.version_info + version_text = f"{version.major}.{version.minor}.{version.micro}" + + if version >= (3, 8): + return DoctorCheck("Python", "pass", f"Using Python {version_text}.") + + return DoctorCheck("Python", "fail", f"Python {version_text} is too old; Pointer CLI requires 3.8+.") + + +def _check_config_directory() -> DoctorCheck: + """Verify the config directory exists and is writable.""" + ensure_config_dir() + config_dir = get_config_path() + + if not config_dir.exists(): + return DoctorCheck("Config directory", "fail", f"Directory {config_dir} could not be created.") + + if os.access(config_dir, os.W_OK): + return DoctorCheck("Config directory", "pass", f"Directory {config_dir} is writable.") + + return DoctorCheck("Config directory", "fail", f"Directory {config_dir} is not writable.") + + +def _check_config_file(config: Config, config_path: Path) -> DoctorCheck: + """Report config file and initialization status.""" + if not config_path.exists(): + return DoctorCheck( + "Configuration", + "warn", + f"No config file found at {config_path}. Run `pointer --init` to create one.", + ) + + if not config.is_initialized(): + return DoctorCheck( + "Configuration", + "warn", + f"Config file exists at {config_path}, but setup is incomplete. Run `pointer --init`.", + ) + + return DoctorCheck( + "Configuration", + "pass", + f"Loaded initialized config from {config_path} using model `{config.api.model_name}`.", + ) + + +def _check_workspace(cwd: Path) -> DoctorCheck: + """Detect whether the current working directory belongs to a git workspace.""" + project_root = get_project_root() + if project_root and is_git_repo(): + return DoctorCheck("Workspace", "pass", f"Git repository detected at {project_root}.") + + return DoctorCheck( + "Workspace", + "warn", + f"No git repository detected from {cwd}. Pointer works best inside a project checkout.", + ) + + +def _check_config_validity(config: Config) -> DoctorCheck: + """Validate the loaded configuration values.""" + issues = config.validate() + if not issues: + return DoctorCheck("Config validity", "pass", "Configuration values are valid.") + + return DoctorCheck("Config validity", "fail", "; ".join(issues)) + + +def _check_api_endpoint(base_url: str, timeout: float = 2.0) -> DoctorCheck: + """Check whether the configured API base URL appears reachable.""" + normalized = base_url.rstrip("/") + candidates = [f"{normalized}/health", normalized] + + last_error = None + for url in candidates: + try: + with request.urlopen(url, timeout=timeout) as response: + status_code = getattr(response, "status", response.getcode()) + if 200 <= status_code < 500: + return DoctorCheck("API endpoint", "pass", f"Connected to {url} (HTTP {status_code}).") + except error.HTTPError as exc: + if 200 <= exc.code < 500: + return DoctorCheck("API endpoint", "pass", f"Connected to {url} (HTTP {exc.code}).") + last_error = f"HTTP {exc.code}" + except error.URLError as exc: + reason = getattr(exc, "reason", exc) + last_error = str(reason) + except Exception as exc: # pragma: no cover - defensive fallback + last_error = str(exc) + + return DoctorCheck( + "API endpoint", + "warn", + f"Could not reach {base_url}. Start your local API or update the configured base URL. Last error: {last_error or 'unknown'}", + ) diff --git a/CLI/pointer_cli/main.py b/CLI/pointer_cli/main.py index 3e89867..36e9570 100644 --- a/CLI/pointer_cli/main.py +++ b/CLI/pointer_cli/main.py @@ -3,34 +3,47 @@ Main entry point for Pointer CLI. """ -import sys -import os +import json from pathlib import Path +import sys from typing import Optional import typer from rich.console import Console from rich.panel import Panel -from rich.text import Text +from rich.table import Table -from .core import PointerCLI +from .codebase_context import CodebaseContext from .config import Config -from .utils import get_config_path, ensure_config_dir +from .core import PointerCLI +from .doctor import checks_to_dict, run_doctor, summarize_results +from .utils import ensure_config_dir, get_project_root, is_git_repo app = typer.Typer( name="pointer", help="Pointer CLI - AI-powered local codebase assistant", no_args_is_help=False, - add_completion=False, invoke_without_command=True, ) +config_app = typer.Typer(help="Inspect and update Pointer CLI configuration.") +context_app = typer.Typer(help="Inspect and manage codebase context.") +app.add_typer(config_app, name="config") +app.add_typer(context_app, name="context") console = Console() +EXIT_OK = 0 +EXIT_GENERAL_ERROR = 1 +EXIT_CONFIG_ERROR = 2 +EXIT_DEPENDENCY_ERROR = 3 +EXIT_USER_CANCELLED = 4 + + def main() -> None: """Entry point for the pointer command.""" app() + @app.callback(invoke_without_command=True) def cli_main( ctx: typer.Context, @@ -40,76 +53,456 @@ def cli_main( ) -> None: """ Pointer CLI - A professional command-line interface for AI-powered local codebase assistance. - + On first run, the CLI will prompt for initialization and configuration. """ if ctx.invoked_subcommand is not None: return - + if version: from . import __version__ + console.print(f"Pointer CLI v{__version__}") return - + try: - # Ensure config directory exists ensure_config_dir() - - # Load or create configuration config = Config.load(config_path) - + if init or not config.is_initialized(): - if not _initialize_config(config): + if not _initialize_config(config, config_path=config_path): console.print("[red]Initialization cancelled.[/red]") - return - - # Initialize and run the CLI + raise typer.Exit(code=EXIT_USER_CANCELLED) + + _raise_for_invalid_config(config) + cli = PointerCLI(config) cli.run() - + + except typer.Exit: + raise except KeyboardInterrupt: console.print("\n[yellow]Goodbye![/yellow]") - sys.exit(0) - except Exception as e: - console.print(f"[red]Error: {e}[/red]") - sys.exit(1) + sys.exit(EXIT_OK) + except Exception as exc: + console.print(f"[red]Error: {exc}[/red]") + sys.exit(EXIT_GENERAL_ERROR) -def _initialize_config(config: Config) -> bool: + +def _initialize_config(config: Config, config_path: Optional[str] = None) -> bool: """Initialize the configuration interactively.""" - console.print(Panel.fit( - "[bold blue]Welcome to Pointer CLI![/bold blue]\n\n" - "This is your first time running Pointer CLI. Let's set up your configuration.", - title="Initialization" - )) - + console.print( + Panel.fit( + "[bold blue]Welcome to Pointer CLI![/bold blue]\n\n" + "This is your first time running Pointer CLI. Let's set up your configuration.", + title="Initialization", + ) + ) + response = typer.confirm("Initialize Pointer CLI?", default=True) if not response: return False - - # API Configuration + console.print("\n[bold]API Configuration[/bold]") api_base_url = typer.prompt( - "API Base URL", + "API Base URL", default="http://localhost:8000", - help="Base URL for your local AI API" + help="Base URL for your local AI API", ) - + model_name = typer.prompt( "Model Name", default="gpt-oss-20b", - help="Model to use for AI interactions" + help="Model to use for AI interactions", ) - - # Initialize configuration + config.initialize( api_base_url=api_base_url, model_name=model_name, auto_run_mode=True, - show_ai_responses=True + show_ai_responses=True, + config_path=config_path, ) - - console.print("[green]✓ Configuration initialized successfully![/green]") + + console.print("[green]Configuration initialized successfully.[/green]") return True + +def _raise_for_invalid_config(config: Config) -> None: + """Raise a config-specific exit when validation fails.""" + issues = config.validate() + if issues: + for issue in issues: + console.print(f"[red]{issue}[/red]") + raise typer.Exit(code=EXIT_CONFIG_ERROR) + + +def _load_validated_config(config_path: Optional[str] = None) -> Config: + """Load config and stop with a config exit code if invalid.""" + ensure_config_dir() + config = Config.load(config_path) + _raise_for_invalid_config(config) + return config + + +def _get_codebase_context(config_path: Optional[str] = None) -> tuple[Config, CodebaseContext]: + """Create a validated codebase context helper.""" + config = _load_validated_config(config_path) + return config, CodebaseContext(config) + + +def _complete_config_keys(ctx: typer.Context, incomplete: str) -> list[str]: + """Autocomplete dotted configuration keys.""" + config_path = ctx.params.get("config_path") + try: + config = Config.load(config_path) + candidates = config.list_key_paths() + except Exception: + candidates = Config().list_key_paths() + + return [candidate for candidate in candidates if candidate.startswith(incomplete)] + + +def _complete_config_values(ctx: typer.Context, incomplete: str) -> list[str]: + """Autocomplete plausible values for a config key.""" + key_path = ctx.params.get("key_path") + config_path = ctx.params.get("config_path") + if not key_path: + return [] + + try: + config = Config.load(config_path) + candidates = config.suggest_values(key_path) + except Exception: + return [] + + normalized = incomplete.lower() + return [candidate for candidate in candidates if candidate.lower().startswith(normalized)] + + +def _complete_context_query(ctx: typer.Context, incomplete: str) -> list[str]: + """Autocomplete context search queries from indexed filenames and paths.""" + config_path = ctx.params.get("config_path") + + try: + config, codebase_context = _get_codebase_context(config_path) + except typer.Exit: + return [] + except Exception: + return [] + + if not config.codebase.include_context: + return [] + + codebase_context.force_refresh() + suggestions = set() + for file_info in codebase_context.context_cache.values(): + suggestions.add(file_info.name) + suggestions.add(file_info.relative_path) + + normalized = incomplete.lower() + return sorted(suggestion for suggestion in suggestions if suggestion.lower().startswith(normalized))[:25] + + +@app.command("init") +def init_command( + config_path: Optional[str] = typer.Option(None, "--config", "-c", help="Path to config file"), + api_base_url: str = typer.Option("http://localhost:8000", "--api-base-url", help="Base URL for your local AI API"), + model_name: str = typer.Option("gpt-oss-20b", "--model", help="Model to use for AI interactions"), + auto_run: bool = typer.Option(True, "--auto-run/--manual", help="Enable or disable automatic tool execution"), + show_ai_responses: bool = typer.Option( + True, + "--show-ai-responses/--hide-ai-responses", + help="Show or hide AI responses in the UI", + ), + non_interactive: bool = typer.Option( + False, + "--non-interactive", + help="Initialize immediately without prompts using the provided option values.", + ), +) -> None: + """Initialize Pointer CLI configuration.""" + ensure_config_dir() + config = Config.load(config_path) + + if non_interactive: + config.initialize( + api_base_url=api_base_url, + model_name=model_name, + auto_run_mode=auto_run, + show_ai_responses=show_ai_responses, + config_path=config_path, + ) + _raise_for_invalid_config(config) + target_path = config_path or str(Config.get_default_config_path()) + console.print(f"[green]Initialized configuration at {target_path}.[/green]") + return + + if not _initialize_config(config, config_path=config_path): + console.print("[red]Initialization cancelled.[/red]") + raise typer.Exit(code=EXIT_USER_CANCELLED) + + _raise_for_invalid_config(config) + + +@config_app.command("show") +def config_show_command( + key_path: Optional[str] = typer.Argument( + None, + help="Optional dotted config key like api.base_url", + autocompletion=_complete_config_keys, + ), + config_path: Optional[str] = typer.Option(None, "--config", "-c", help="Path to config file"), +) -> None: + """Show the current configuration or a single config value.""" + config = _load_validated_config(config_path) + + if key_path: + try: + value = config.get_value(key_path) + except KeyError as exc: + console.print(f"[red]{exc}[/red]") + raise typer.Exit(code=EXIT_CONFIG_ERROR) + + if isinstance(value, (dict, list)): + console.print(json.dumps(value, indent=2)) + else: + console.print(value) + return + + console.print(json.dumps(config.model_dump(), indent=2)) + + +@config_app.command("set") +def config_set_command( + key_path: str = typer.Argument( + ..., + help="Dotted config key like api.base_url or ui.show_diffs", + autocompletion=_complete_config_keys, + ), + value: str = typer.Argument(..., help="New value to store", autocompletion=_complete_config_values), + config_path: Optional[str] = typer.Option(None, "--config", "-c", help="Path to config file"), +) -> None: + """Set a configuration value by dotted path.""" + ensure_config_dir() + config = Config.load(config_path) + + try: + new_value = config.set_value(key_path, value, config_path=config_path) + _raise_for_invalid_config(config) + except (KeyError, ValueError, json.JSONDecodeError) as exc: + console.print(f"[red]{exc}[/red]") + raise typer.Exit(code=EXIT_CONFIG_ERROR) + + console.print(f"[green]Updated {key_path} to {new_value!r}.[/green]") + + +@app.command("status") +def status_command( + config_path: Optional[str] = typer.Option(None, "--config", "-c", help="Path to config file"), +) -> None: + """Show the current CLI environment and configuration status.""" + config = _load_validated_config(config_path) + table = _build_status_table(config, config_path) + console.print(table) + + +def _build_status_table(config: Config, config_path: Optional[str] = None) -> Table: + """Create a status table for the current environment.""" + config_file = config_path or str(Config.get_default_config_path()) + project_root = get_project_root() + + table = Table(title="Pointer CLI Status") + table.add_column("Field", style="bold") + table.add_column("Value", overflow="fold") + + table.add_row("Config file", config_file) + table.add_row("Initialized", str(config.is_initialized())) + table.add_row("Current directory", str(Path.cwd())) + table.add_row("Project root", str(project_root) if project_root else "Not detected") + table.add_row("Git repository", str(is_git_repo())) + table.add_row("API base URL", config.api.base_url) + table.add_row("Model", config.api.model_name) + table.add_row("Mode", "Auto-Run" if config.mode.auto_run_mode else "Manual") + table.add_row("Show AI responses", str(config.ui.show_ai_responses)) + table.add_row("Context enabled", str(config.codebase.include_context)) + + return table + + +@context_app.command("show") +def context_show_command( + config_path: Optional[str] = typer.Option(None, "--config", "-c", help="Path to config file"), +) -> None: + """Show a summary of the current codebase context.""" + config, codebase_context = _get_codebase_context(config_path) + if not config.codebase.include_context: + console.print("[yellow]Codebase context is disabled.[/yellow]") + return + + summary = codebase_context.get_context_summary() + if not summary: + console.print("[yellow]No codebase context available.[/yellow]") + return + + table = Table(title="Pointer CLI Context") + table.add_column("Field", style="bold") + table.add_column("Value", overflow="fold") + table.add_row("Project root", str(summary.get("project_root") or "Not detected")) + table.add_row("Total files", str(summary.get("total_files", 0))) + table.add_row( + "File types", + ", ".join(f"{ext}({count})" for ext, count in summary.get("file_types", {}).items()) or "None", + ) + table.add_row("Last updated", str(summary.get("last_updated", "Never"))) + console.print(table) + + +@context_app.command("refresh") +def context_refresh_command( + config_path: Optional[str] = typer.Option(None, "--config", "-c", help="Path to config file"), +) -> None: + """Refresh cached codebase context.""" + config, codebase_context = _get_codebase_context(config_path) + if not config.codebase.include_context: + console.print("[yellow]Codebase context is disabled.[/yellow]") + return + + codebase_context.force_refresh() + summary = codebase_context.get_context_summary() + console.print(f"[green]Context refreshed. Indexed {summary.get('total_files', 0)} files.[/green]") + + +@context_app.command("search") +def context_search_command( + query: str = typer.Argument( + ..., + help="Text to search within the cached context", + autocompletion=_complete_context_query, + ), + limit: int = typer.Option(10, "--limit", min=1, help="Maximum number of files to show"), + config_path: Optional[str] = typer.Option(None, "--config", "-c", help="Path to config file"), +) -> None: + """Search filenames, paths, and previews in the cached codebase context.""" + config, codebase_context = _get_codebase_context(config_path) + if not config.codebase.include_context: + console.print("[yellow]Codebase context is disabled.[/yellow]") + return + + results = codebase_context.search_context(query) + if not results: + console.print(f"[yellow]No context files found for '{query}'.[/yellow]") + return + + table = Table(title=f"Context Search: {query}") + table.add_column("File", style="bold") + table.add_column("Lines") + table.add_column("Preview", overflow="fold") + + for file_info in results[:limit]: + preview = file_info.content_preview.replace("\n", " ")[:120] + table.add_row(file_info.relative_path, str(file_info.lines), preview) + + console.print(table) + + +@context_app.command("config") +def context_config_command( + config_path: Optional[str] = typer.Option(None, "--config", "-c", help="Path to config file"), +) -> None: + """Show codebase context-related configuration.""" + config = _load_validated_config(config_path) + console.print( + json.dumps( + { + "include_context": config.codebase.include_context, + "max_context_files": config.codebase.max_context_files, + "context_depth": config.codebase.context_depth, + "auto_refresh_context": config.codebase.auto_refresh_context, + "context_cache_duration": config.codebase.context_cache_duration, + "context_file_types": config.codebase.context_file_types, + "exclude_patterns": config.codebase.exclude_patterns, + }, + indent=2, + ) + ) + + +@context_app.command("enable") +def context_enable_command( + config_path: Optional[str] = typer.Option(None, "--config", "-c", help="Path to config file"), +) -> None: + """Enable codebase context collection.""" + config = _load_validated_config(config_path) + config.set_value("codebase.include_context", "true", config_path=config_path) + console.print("[green]Codebase context enabled.[/green]") + + +@context_app.command("disable") +def context_disable_command( + config_path: Optional[str] = typer.Option(None, "--config", "-c", help="Path to config file"), +) -> None: + """Disable codebase context collection.""" + config = _load_validated_config(config_path) + config.set_value("codebase.include_context", "false", config_path=config_path) + console.print("[yellow]Codebase context disabled.[/yellow]") + + +@app.command("doctor") +def doctor_command( + config_path: Optional[str] = typer.Option(None, "--config", "-c", help="Path to config file"), + timeout: float = typer.Option(2.0, "--timeout", help="HTTP timeout in seconds for API connectivity checks"), + json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON output"), +) -> None: + """Run basic health checks for the local Pointer CLI setup.""" + ensure_config_dir() + config = Config.load(config_path) + checks = run_doctor(config, config_path=config_path, timeout=timeout) + passing, warnings, failing = summarize_results(checks) + + if json_output: + console.print( + json.dumps( + { + "summary": { + "passing": passing, + "warnings": warnings, + "failures": failing, + }, + "checks": checks_to_dict(checks), + }, + indent=2, + ) + ) + if failing: + raise typer.Exit(code=EXIT_DEPENDENCY_ERROR) + return + + table = Table(title="Pointer CLI Doctor") + table.add_column("Check", style="bold") + table.add_column("Status") + table.add_column("Details", overflow="fold") + + status_styles = { + "pass": "[green]PASS[/green]", + "warn": "[yellow]WARN[/yellow]", + "fail": "[red]FAIL[/red]", + } + + for check in checks: + table.add_row(check.name, status_styles.get(check.status, check.status.upper()), check.details) + + console.print(table) + console.print( + Panel.fit( + f"[green]Passing:[/green] {passing} [yellow]Warnings:[/yellow] {warnings} [red]Failures:[/red] {failing}", + title="Summary", + ) + ) + + if failing: + raise typer.Exit(code=EXIT_DEPENDENCY_ERROR) + + if __name__ == "__main__": app() diff --git a/CLI/tests/test_config.py b/CLI/tests/test_config.py index e1fa36f..f95b9d2 100644 --- a/CLI/tests/test_config.py +++ b/CLI/tests/test_config.py @@ -102,3 +102,65 @@ def test_config_update_methods(self): auto_run_mode=False ) assert config.mode.auto_run_mode is False + + def test_get_value(self): + """Test dotted configuration lookup.""" + config = Config() + + assert config.get_value("api.base_url") == "http://localhost:8000" + assert config.get_value("initialized") is False + + def test_set_value(self): + """Test dotted configuration updates with type coercion.""" + with tempfile.TemporaryDirectory() as temp_dir: + config_file = Path(temp_dir) / "test_config.json" + config = Config() + + config.set_value("api.timeout", "45", config_path=str(config_file)) + config.set_value("ui.show_diffs", "false", config_path=str(config_file)) + config.set_value("codebase.exclude_patterns", "node_modules,.git,dist", config_path=str(config_file)) + + assert config.api.timeout == 45 + assert config.ui.show_diffs is False + assert config.codebase.exclude_patterns == ["node_modules", ".git", "dist"] + + def test_set_value_invalid_key(self): + """Unknown dotted keys should raise KeyError.""" + config = Config() + + with pytest.raises(KeyError): + config.set_value("api.missing_key", "value") + + def test_validate_reports_invalid_values(self): + """Validation should report user-fixable config issues.""" + config = Config() + config.api.base_url = "localhost:8000" + config.api.model_name = "" + config.api.timeout = 0 + config.codebase.max_context_files = 0 + + issues = config.validate() + + assert "api.base_url must start with http:// or https://" in issues + assert "api.model_name cannot be empty" in issues + assert "api.timeout must be greater than 0" in issues + assert "codebase.max_context_files must be greater than 0" in issues + + def test_list_key_paths(self): + """Config should expose dotted keys for shell completion.""" + config = Config() + + keys = config.list_key_paths() + + assert "api.base_url" in keys + assert "ui.show_diffs" in keys + assert "codebase.context_depth" in keys + assert "initialized" in keys + + def test_suggest_values(self): + """Config should suggest sensible completion candidates for values.""" + config = Config() + + assert config.suggest_values("ui.show_diffs") == ["true", "false"] + assert config.suggest_values("api.timeout") == ["30"] + assert config.suggest_values("api.base_url") == ["http://localhost:8000"] diff --git a/CLI/tests/test_doctor.py b/CLI/tests/test_doctor.py new file mode 100644 index 0000000..6911691 --- /dev/null +++ b/CLI/tests/test_doctor.py @@ -0,0 +1,101 @@ +""" +Tests for Pointer CLI doctor checks. +""" + +from pointer_cli.config import Config +from pointer_cli.doctor import DoctorCheck, run_doctor, summarize_results + + +class DummyResponse: + """Minimal HTTP response stub for urlopen tests.""" + + def __init__(self, status: int): + self.status = status + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def getcode(self) -> int: + return self.status + + +class TestDoctor: + """Test doctor functionality.""" + + def test_summarize_results(self): + """The summary helper should count statuses correctly.""" + checks = [ + DoctorCheck("Python", "pass", "ok"), + DoctorCheck("Workspace", "warn", "warn"), + DoctorCheck("API", "fail", "fail"), + ] + + assert summarize_results(checks) == (1, 1, 1) + + def test_run_doctor_with_initialized_config(self, monkeypatch, tmp_path): + """Doctor should report an initialized config and healthy workspace.""" + config_path = tmp_path / "config.json" + config = Config() + config.api.base_url = "http://localhost:8000" + config.api.model_name = "test-model" + config.initialized = True + config.save(str(config_path)) + + monkeypatch.setattr("pointer_cli.doctor.get_config_path", lambda: tmp_path) + monkeypatch.setattr("pointer_cli.doctor.ensure_config_dir", lambda: None) + monkeypatch.setattr("pointer_cli.doctor.get_project_root", lambda: tmp_path) + monkeypatch.setattr("pointer_cli.doctor.is_git_repo", lambda: True) + monkeypatch.setattr( + "pointer_cli.doctor.request.urlopen", + lambda url, timeout=2.0: DummyResponse(200), + ) + + checks = run_doctor(config, config_path=str(config_path), cwd=tmp_path) + check_map = {check.name: check for check in checks} + + assert check_map["Configuration"].status == "pass" + assert check_map["Workspace"].status == "pass" + assert check_map["API endpoint"].status == "pass" + + def test_run_doctor_warns_when_config_missing(self, monkeypatch, tmp_path): + """Doctor should warn when no config file exists yet.""" + config = Config() + + monkeypatch.setattr("pointer_cli.doctor.get_config_path", lambda: tmp_path) + monkeypatch.setattr("pointer_cli.doctor.ensure_config_dir", lambda: None) + monkeypatch.setattr("pointer_cli.doctor.get_project_root", lambda: None) + monkeypatch.setattr("pointer_cli.doctor.is_git_repo", lambda: False) + monkeypatch.setattr( + "pointer_cli.doctor.request.urlopen", + lambda url, timeout=2.0: DummyResponse(200), + ) + + checks = run_doctor(config, config_path=str(tmp_path / "missing.json"), cwd=tmp_path) + check_map = {check.name: check for check in checks} + + assert check_map["Configuration"].status == "warn" + assert check_map["Workspace"].status == "warn" + + def test_run_doctor_warns_when_api_unreachable(self, monkeypatch, tmp_path): + """Doctor should warn when the configured API cannot be reached.""" + config = Config() + config.api.base_url = "http://localhost:9999" + + monkeypatch.setattr("pointer_cli.doctor.get_config_path", lambda: tmp_path) + monkeypatch.setattr("pointer_cli.doctor.ensure_config_dir", lambda: None) + monkeypatch.setattr("pointer_cli.doctor.get_project_root", lambda: tmp_path) + monkeypatch.setattr("pointer_cli.doctor.is_git_repo", lambda: True) + + def raise_url_error(url, timeout=2.0): + raise OSError("connection refused") + + monkeypatch.setattr("pointer_cli.doctor.request.urlopen", raise_url_error) + + checks = run_doctor(config, config_path=str(tmp_path / "config.json"), cwd=tmp_path) + check_map = {check.name: check for check in checks} + + assert check_map["API endpoint"].status == "warn" + assert "Could not reach" in check_map["API endpoint"].details diff --git a/CLI/tests/test_main.py b/CLI/tests/test_main.py new file mode 100644 index 0000000..e93982f --- /dev/null +++ b/CLI/tests/test_main.py @@ -0,0 +1,506 @@ +""" +Tests for Pointer CLI command entry points. +""" + +import json +from types import SimpleNamespace + +from typer.testing import CliRunner + +from pointer_cli.main import ( + app, + EXIT_CONFIG_ERROR, + EXIT_DEPENDENCY_ERROR, + EXIT_USER_CANCELLED, + _complete_config_keys, + _complete_config_values, + _complete_context_query, +) + + +runner = CliRunner() + + +class TestMainCommands: + """Test top-level CLI commands.""" + + def test_config_show_outputs_json(self, tmp_path): + """`pointer config show` should print the config file contents.""" + config_path = tmp_path / "config.json" + config_path.write_text( + json.dumps( + { + "api": { + "base_url": "http://localhost:8000", + "model_name": "test-model", + "api_key": None, + "timeout": 30, + "max_retries": 3, + }, + "ui": { + "show_ai_responses": True, + "show_thinking": True, + "show_tool_outputs": True, + "show_diffs": True, + "render_markdown": True, + "theme": "default", + "max_output_lines": 100, + }, + "mode": { + "auto_run_mode": True, + }, + "codebase": { + "include_context": True, + "max_context_files": 20, + "context_file_types": [".py"], + "exclude_patterns": [".git"], + "context_depth": 3, + "auto_refresh_context": False, + "context_cache_duration": 3600, + }, + "initialized": True, + } + ), + encoding="utf-8", + ) + + result = runner.invoke(app, ["config", "show", "--config", str(config_path)]) + + assert result.exit_code == 0 + assert "test-model" in result.stdout + assert '"initialized": true' in result.stdout.lower() + + def test_config_set_updates_nested_value(self, tmp_path): + """`pointer config set` should persist nested config changes.""" + config_path = tmp_path / "config.json" + + result = runner.invoke( + app, + ["config", "set", "api.base_url", "http://localhost:9000", "--config", str(config_path)], + ) + + assert result.exit_code == 0 + saved = json.loads(config_path.read_text(encoding="utf-8")) + assert saved["api"]["base_url"] == "http://localhost:9000" + + def test_init_non_interactive_writes_config(self, tmp_path): + """`pointer init --non-interactive` should create an initialized config.""" + config_path = tmp_path / "config.json" + + result = runner.invoke( + app, + [ + "init", + "--non-interactive", + "--config", + str(config_path), + "--api-base-url", + "http://localhost:1234", + "--model", + "demo-model", + "--manual", + "--hide-ai-responses", + ], + ) + + assert result.exit_code == 0 + saved = json.loads(config_path.read_text(encoding="utf-8")) + assert saved["initialized"] is True + assert saved["api"]["base_url"] == "http://localhost:1234" + assert saved["api"]["model_name"] == "demo-model" + assert saved["mode"]["auto_run_mode"] is False + assert saved["ui"]["show_ai_responses"] is False + + def test_config_set_invalid_key_returns_config_exit_code(self, tmp_path): + """Invalid config keys should return the config-specific exit code.""" + config_path = tmp_path / "config.json" + + result = runner.invoke( + app, + ["config", "set", "api.missing", "value", "--config", str(config_path)], + ) + + assert result.exit_code == EXIT_CONFIG_ERROR + + def test_init_cancel_returns_user_cancelled_exit_code(self, tmp_path): + """Interactive init cancellation should use the user-cancelled exit code.""" + config_path = tmp_path / "config.json" + + result = runner.invoke( + app, + ["init", "--config", str(config_path)], + input="n\n", + ) + + assert result.exit_code == EXIT_USER_CANCELLED + + def test_status_command_outputs_environment_summary(self, tmp_path): + """`pointer status` should print a concise environment summary.""" + config_path = tmp_path / "config.json" + config_path.write_text( + json.dumps( + { + "api": { + "base_url": "http://localhost:7777", + "model_name": "status-model", + "api_key": None, + "timeout": 30, + "max_retries": 3, + }, + "ui": { + "show_ai_responses": False, + "show_thinking": True, + "show_tool_outputs": True, + "show_diffs": True, + "render_markdown": True, + "theme": "default", + "max_output_lines": 100, + }, + "mode": { + "auto_run_mode": False, + }, + "codebase": { + "include_context": True, + "max_context_files": 20, + "context_file_types": [".py"], + "exclude_patterns": [".git"], + "context_depth": 3, + "auto_refresh_context": False, + "context_cache_duration": 3600, + }, + "initialized": True, + } + ), + encoding="utf-8", + ) + + result = runner.invoke(app, ["status", "--config", str(config_path)]) + + assert result.exit_code == 0 + assert "Pointer CLI Status" in result.stdout + assert "status-model" in result.stdout + assert "http://localhost:7777" in result.stdout + + def test_doctor_json_outputs_machine_readable_result(self, tmp_path): + """`pointer doctor --json` should emit structured JSON.""" + config_path = tmp_path / "config.json" + config_path.write_text( + json.dumps( + { + "api": { + "base_url": "http://localhost:8000", + "model_name": "json-model", + "api_key": None, + "timeout": 30, + "max_retries": 3, + }, + "ui": { + "show_ai_responses": True, + "show_thinking": True, + "show_tool_outputs": True, + "show_diffs": True, + "render_markdown": True, + "theme": "default", + "max_output_lines": 100, + }, + "mode": {"auto_run_mode": True}, + "codebase": { + "include_context": True, + "max_context_files": 20, + "context_file_types": [".py"], + "exclude_patterns": [".git"], + "context_depth": 3, + "auto_refresh_context": False, + "context_cache_duration": 3600, + }, + "initialized": True, + } + ), + encoding="utf-8", + ) + + result = runner.invoke(app, ["doctor", "--json", "--config", str(config_path)]) + + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert payload["summary"]["passing"] >= 1 + assert any(check["name"] == "Config validity" for check in payload["checks"]) + + def test_doctor_invalid_config_returns_dependency_exit_code(self, tmp_path): + """Invalid config should surface as a doctor failure in JSON mode.""" + config_path = tmp_path / "config.json" + config_path.write_text( + json.dumps( + { + "api": { + "base_url": "localhost:8000", + "model_name": "", + "api_key": None, + "timeout": 0, + "max_retries": 3, + }, + "ui": { + "show_ai_responses": True, + "show_thinking": True, + "show_tool_outputs": True, + "show_diffs": True, + "render_markdown": True, + "theme": "default", + "max_output_lines": 100, + }, + "mode": {"auto_run_mode": True}, + "codebase": { + "include_context": True, + "max_context_files": 20, + "context_file_types": [".py"], + "exclude_patterns": [".git"], + "context_depth": 3, + "auto_refresh_context": False, + "context_cache_duration": 3600, + }, + "initialized": True, + } + ), + encoding="utf-8", + ) + + result = runner.invoke(app, ["doctor", "--json", "--config", str(config_path)]) + + assert result.exit_code == EXIT_DEPENDENCY_ERROR + payload = json.loads(result.stdout) + assert payload["summary"]["failures"] >= 1 + + def test_context_show_outputs_summary(self, tmp_path, monkeypatch): + """`pointer context show` should summarize indexed project files.""" + (tmp_path / ".git").mkdir() + (tmp_path / "app.py").write_text("print('hello')\n", encoding="utf-8") + config_path = tmp_path / "config.json" + config_path.write_text( + json.dumps( + { + "api": { + "base_url": "http://localhost:8000", + "model_name": "context-model", + "api_key": None, + "timeout": 30, + "max_retries": 3, + }, + "ui": { + "show_ai_responses": True, + "show_thinking": True, + "show_tool_outputs": True, + "show_diffs": True, + "render_markdown": True, + "theme": "default", + "max_output_lines": 100, + }, + "mode": {"auto_run_mode": True}, + "codebase": { + "include_context": True, + "max_context_files": 20, + "context_file_types": [".py"], + "exclude_patterns": ["node_modules"], + "context_depth": 3, + "auto_refresh_context": False, + "context_cache_duration": 0, + }, + "initialized": True, + } + ), + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + + result = runner.invoke(app, ["context", "show", "--config", str(config_path)]) + + assert result.exit_code == 0 + assert "Pointer CLI Context" in result.stdout + assert "Total files" in result.stdout + + def test_context_search_finds_file(self, tmp_path, monkeypatch): + """`pointer context search` should find matches in file content previews.""" + (tmp_path / ".git").mkdir() + (tmp_path / "module.py").write_text("special_keyword = True\n", encoding="utf-8") + config_path = tmp_path / "config.json" + config_path.write_text( + json.dumps( + { + "api": { + "base_url": "http://localhost:8000", + "model_name": "context-model", + "api_key": None, + "timeout": 30, + "max_retries": 3, + }, + "ui": { + "show_ai_responses": True, + "show_thinking": True, + "show_tool_outputs": True, + "show_diffs": True, + "render_markdown": True, + "theme": "default", + "max_output_lines": 100, + }, + "mode": {"auto_run_mode": True}, + "codebase": { + "include_context": True, + "max_context_files": 20, + "context_file_types": [".py"], + "exclude_patterns": ["node_modules"], + "context_depth": 3, + "auto_refresh_context": False, + "context_cache_duration": 0, + }, + "initialized": True, + } + ), + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + + result = runner.invoke(app, ["context", "search", "special_keyword", "--config", str(config_path)]) + + assert result.exit_code == 0 + assert "module.py" in result.stdout + + def test_invalid_config_returns_config_exit_code_for_status(self, tmp_path): + """Validated commands should stop with the config exit code.""" + config_path = tmp_path / "config.json" + config_path.write_text( + json.dumps( + { + "api": { + "base_url": "localhost:8000", + "model_name": "bad", + "api_key": None, + "timeout": 30, + "max_retries": 3, + }, + "ui": { + "show_ai_responses": True, + "show_thinking": True, + "show_tool_outputs": True, + "show_diffs": True, + "render_markdown": True, + "theme": "default", + "max_output_lines": 100, + }, + "mode": {"auto_run_mode": True}, + "codebase": { + "include_context": True, + "max_context_files": 20, + "context_file_types": [".py"], + "exclude_patterns": [".git"], + "context_depth": 3, + "auto_refresh_context": False, + "context_cache_duration": 3600, + }, + "initialized": True, + } + ), + encoding="utf-8", + ) + + result = runner.invoke(app, ["status", "--config", str(config_path)]) + + assert result.exit_code == EXIT_CONFIG_ERROR + + def test_complete_config_keys_suggests_matching_dotted_keys(self): + """Config key completion should return matching dotted keys.""" + ctx = SimpleNamespace(params={"config_path": None}) + + completions = _complete_config_keys(ctx, "api.") + + assert "api.base_url" in completions + assert "api.model_name" in completions + + def test_complete_config_values_uses_current_field_type(self, tmp_path): + """Value completion should offer boolean and current-value suggestions.""" + config_path = tmp_path / "config.json" + config_path.write_text( + json.dumps( + { + "api": { + "base_url": "http://localhost:8080", + "model_name": "demo-model", + "api_key": None, + "timeout": 15, + "max_retries": 3, + }, + "ui": { + "show_ai_responses": True, + "show_thinking": True, + "show_tool_outputs": True, + "show_diffs": True, + "render_markdown": True, + "theme": "default", + "max_output_lines": 100, + }, + "mode": {"auto_run_mode": True}, + "codebase": { + "include_context": True, + "max_context_files": 20, + "context_file_types": [".py"], + "exclude_patterns": [".git"], + "context_depth": 3, + "auto_refresh_context": False, + "context_cache_duration": 3600, + }, + "initialized": True, + } + ), + encoding="utf-8", + ) + + bool_ctx = SimpleNamespace(params={"config_path": str(config_path), "key_path": "ui.show_diffs"}) + text_ctx = SimpleNamespace(params={"config_path": str(config_path), "key_path": "api.base_url"}) + + assert _complete_config_values(bool_ctx, "t") == ["true"] + assert _complete_config_values(text_ctx, "http://loc") == ["http://localhost:8080"] + + def test_complete_context_query_uses_indexed_files(self, tmp_path, monkeypatch): + """Context query completion should suggest filenames and relative paths.""" + (tmp_path / ".git").mkdir() + (tmp_path / "services").mkdir() + (tmp_path / "services" / "api.py").write_text("print('api')\n", encoding="utf-8") + config_path = tmp_path / "config.json" + config_path.write_text( + json.dumps( + { + "api": { + "base_url": "http://localhost:8000", + "model_name": "demo-model", + "api_key": None, + "timeout": 30, + "max_retries": 3, + }, + "ui": { + "show_ai_responses": True, + "show_thinking": True, + "show_tool_outputs": True, + "show_diffs": True, + "render_markdown": True, + "theme": "default", + "max_output_lines": 100, + }, + "mode": {"auto_run_mode": True}, + "codebase": { + "include_context": True, + "max_context_files": 20, + "context_file_types": [".py"], + "exclude_patterns": ["node_modules"], + "context_depth": 3, + "auto_refresh_context": False, + "context_cache_duration": 3600, + }, + "initialized": True, + } + ), + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + ctx = SimpleNamespace(params={"config_path": str(config_path)}) + + completions = _complete_context_query(ctx, "ser") + + assert "services/api.py" in completions From c62fb5f9c094eac9c42790193efbe04779c7354a Mon Sep 17 00:00:00 2001 From: Liam Date: Fri, 27 Mar 2026 02:03:49 +0100 Subject: [PATCH 2/4] Extend CLI config editing and machine-readable status tools --- CLI/README.md | 5 + CLI/pointer_cli/config.py | 15 +++ CLI/pointer_cli/doctor.py | 57 +++++++++++ CLI/pointer_cli/main.py | 114 +++++++++++++++++++++- CLI/tests/test_config.py | 10 ++ CLI/tests/test_doctor.py | 19 +++- CLI/tests/test_main.py | 194 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 412 insertions(+), 2 deletions(-) diff --git a/CLI/README.md b/CLI/README.md index 8321a79..84655c8 100644 --- a/CLI/README.md +++ b/CLI/README.md @@ -31,6 +31,7 @@ Run a quick environment check with: ```bash pointer doctor pointer doctor --json +pointer doctor --fix ``` The doctor command verifies your Python runtime, config directory, config initialization status, workspace detection, and API reachability. @@ -41,12 +42,15 @@ Inspect or update config values with: pointer config show pointer config show api.base_url pointer config set api.base_url http://localhost:1234 +pointer config unset ui.show_diffs +pointer config edit ``` Show the current environment with: ```bash pointer status +pointer status --json ``` Manage codebase context from top-level commands: @@ -55,6 +59,7 @@ Manage codebase context from top-level commands: pointer context show pointer context refresh pointer context search TODO +pointer context files --ext .py pointer context config ``` diff --git a/CLI/pointer_cli/config.py b/CLI/pointer_cli/config.py index 99f30d8..035e9db 100644 --- a/CLI/pointer_cli/config.py +++ b/CLI/pointer_cli/config.py @@ -226,6 +226,14 @@ def set_value(self, key_path: str, raw_value: str, config_path: Optional[str] = self.save(config_path) return coerced_value + def unset_value(self, key_path: str, config_path: Optional[str] = None) -> Any: + """Reset a configuration value back to its default.""" + target, field_name = self._resolve_key_path(key_path) + default_value = self._get_default_value(target, field_name) + setattr(target, field_name, default_value) + self.save(config_path) + return default_value + def _resolve_key_path(self, key_path: str) -> tuple[BaseModel, str]: """Resolve a dotted config key into a model instance and field name.""" parts = key_path.split(".") @@ -309,3 +317,10 @@ def _parse_list_value(self, raw_value: str) -> List[str]: return parsed return [item.strip() for item in stripped.split(",") if item.strip()] + + def _get_default_value(self, target: BaseModel, field_name: str) -> Any: + """Read the default value for a field from its Pydantic model.""" + field_info = target.__class__.model_fields[field_name] + if field_info.default_factory is not None: + return field_info.default_factory() + return field_info.default diff --git a/CLI/pointer_cli/doctor.py b/CLI/pointer_cli/doctor.py index b92fda1..1b04915 100644 --- a/CLI/pointer_cli/doctor.py +++ b/CLI/pointer_cli/doctor.py @@ -58,6 +58,63 @@ def checks_to_dict(checks: List[DoctorCheck]) -> List[Dict[str, Any]]: ] +def apply_safe_fixes(config: Config, config_path: Optional[str] = None) -> List[str]: + """Apply safe, local fixes for common doctor findings.""" + fixes: List[str] = [] + ensure_config_dir() + + resolved_config_path = Path(config_path) if config_path else Config.get_default_config_path() + if not resolved_config_path.exists(): + config.save(str(resolved_config_path)) + fixes.append(f"Created config file at {resolved_config_path}.") + + if not config.is_initialized(): + config.initialized = True + config.save(str(resolved_config_path)) + fixes.append("Marked configuration as initialized.") + + if not config.api.base_url.startswith(("http://", "https://")): + config.api.base_url = "http://localhost:8000" + fixes.append("Reset api.base_url to http://localhost:8000.") + + if not config.api.model_name.strip(): + config.api.model_name = "gpt-oss-20b" + fixes.append("Reset api.model_name to gpt-oss-20b.") + + if config.api.timeout <= 0: + config.api.timeout = 30 + fixes.append("Reset api.timeout to 30.") + + if config.api.max_retries < 0: + config.api.max_retries = 3 + fixes.append("Reset api.max_retries to 3.") + + if config.ui.max_output_lines <= 0: + config.ui.max_output_lines = 100 + fixes.append("Reset ui.max_output_lines to 100.") + + if config.codebase.max_context_files <= 0: + config.codebase.max_context_files = 20 + fixes.append("Reset codebase.max_context_files to 20.") + + if config.codebase.context_depth < 0: + config.codebase.context_depth = 3 + fixes.append("Reset codebase.context_depth to 3.") + + if config.codebase.context_cache_duration < 0: + config.codebase.context_cache_duration = 3600 + fixes.append("Reset codebase.context_cache_duration to 3600.") + + if not config.codebase.context_file_types: + config.codebase.context_file_types = [".py", ".js", ".ts", ".jsx", ".tsx", ".md", ".json"] + fixes.append("Restored default codebase.context_file_types.") + + if fixes: + config.save(str(resolved_config_path)) + + return fixes + + def summarize_results(checks: List[DoctorCheck]) -> tuple[int, int, int]: """Return counts for passing, warning, and failing checks.""" passing = sum(1 for check in checks if check.status == "pass") diff --git a/CLI/pointer_cli/main.py b/CLI/pointer_cli/main.py index 36e9570..7caa67a 100644 --- a/CLI/pointer_cli/main.py +++ b/CLI/pointer_cli/main.py @@ -4,6 +4,7 @@ """ import json +import os from pathlib import Path import sys from typing import Optional @@ -16,7 +17,7 @@ from .codebase_context import CodebaseContext from .config import Config from .core import PointerCLI -from .doctor import checks_to_dict, run_doctor, summarize_results +from .doctor import apply_safe_fixes, checks_to_dict, run_doctor, summarize_results from .utils import ensure_config_dir, get_project_root, is_git_repo app = typer.Typer( @@ -297,12 +298,57 @@ def config_set_command( console.print(f"[green]Updated {key_path} to {new_value!r}.[/green]") +@config_app.command("unset") +def config_unset_command( + key_path: str = typer.Argument( + ..., + help="Dotted config key to reset back to its default value", + autocompletion=_complete_config_keys, + ), + config_path: Optional[str] = typer.Option(None, "--config", "-c", help="Path to config file"), +) -> None: + """Reset a configuration value back to its default.""" + ensure_config_dir() + config = Config.load(config_path) + + try: + new_value = config.unset_value(key_path, config_path=config_path) + _raise_for_invalid_config(config) + except KeyError as exc: + console.print(f"[red]{exc}[/red]") + raise typer.Exit(code=EXIT_CONFIG_ERROR) + + console.print(f"[green]Reset {key_path} to {new_value!r}.[/green]") + + +@config_app.command("edit") +def config_edit_command( + config_path: Optional[str] = typer.Option(None, "--config", "-c", help="Path to config file"), +) -> None: + """Open the config file in the default editor, or print its path if opening fails.""" + ensure_config_dir() + resolved_path = Path(config_path) if config_path else Config.get_default_config_path() + config = Config.load(str(resolved_path)) + if not resolved_path.exists(): + config.save(str(resolved_path)) + + try: + os.startfile(str(resolved_path)) # type: ignore[attr-defined] + console.print(f"[green]Opened config file: {resolved_path}[/green]") + except Exception: + console.print(f"Config file: {resolved_path}") + + @app.command("status") def status_command( config_path: Optional[str] = typer.Option(None, "--config", "-c", help="Path to config file"), + json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON output"), ) -> None: """Show the current CLI environment and configuration status.""" config = _load_validated_config(config_path) + if json_output: + console.print(json.dumps(_build_status_payload(config, config_path), indent=2)) + return table = _build_status_table(config, config_path) console.print(table) @@ -330,6 +376,23 @@ def _build_status_table(config: Config, config_path: Optional[str] = None) -> Ta return table +def _build_status_payload(config: Config, config_path: Optional[str] = None) -> dict: + """Create machine-readable status output.""" + project_root = get_project_root() + return { + "config_file": config_path or str(Config.get_default_config_path()), + "initialized": config.is_initialized(), + "current_directory": str(Path.cwd()), + "project_root": str(project_root) if project_root else None, + "git_repository": is_git_repo(), + "api_base_url": config.api.base_url, + "model": config.api.model_name, + "mode": "auto-run" if config.mode.auto_run_mode else "manual", + "show_ai_responses": config.ui.show_ai_responses, + "context_enabled": config.codebase.include_context, + } + + @context_app.command("show") def context_show_command( config_path: Optional[str] = typer.Option(None, "--config", "-c", help="Path to config file"), @@ -406,6 +469,42 @@ def context_search_command( console.print(table) +@context_app.command("files") +def context_files_command( + limit: int = typer.Option(25, "--limit", min=1, help="Maximum number of indexed files to show"), + extension: Optional[str] = typer.Option(None, "--ext", help="Optional file extension filter such as .py"), + config_path: Optional[str] = typer.Option(None, "--config", "-c", help="Path to config file"), +) -> None: + """List files currently indexed in codebase context.""" + config, codebase_context = _get_codebase_context(config_path) + if not config.codebase.include_context: + console.print("[yellow]Codebase context is disabled.[/yellow]") + return + + codebase_context.force_refresh() + files = list(codebase_context.context_cache.values()) + if extension: + files = [file_info for file_info in files if file_info.extension == extension] + + if not files: + message = f"No indexed files found for extension '{extension}'." if extension else "No indexed files found." + console.print(f"[yellow]{message}[/yellow]") + return + + files.sort(key=lambda file_info: file_info.relative_path) + + table = Table(title="Pointer CLI Context Files") + table.add_column("File", style="bold") + table.add_column("Type") + table.add_column("Lines") + table.add_column("Size") + + for file_info in files[:limit]: + table.add_row(file_info.relative_path, file_info.extension, str(file_info.lines), file_info.size_formatted) + + console.print(table) + + @context_app.command("config") def context_config_command( config_path: Optional[str] = typer.Option(None, "--config", "-c", help="Path to config file"), @@ -453,10 +552,15 @@ def doctor_command( config_path: Optional[str] = typer.Option(None, "--config", "-c", help="Path to config file"), timeout: float = typer.Option(2.0, "--timeout", help="HTTP timeout in seconds for API connectivity checks"), json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON output"), + fix: bool = typer.Option(False, "--fix", help="Apply safe local fixes for common config issues before checking"), ) -> None: """Run basic health checks for the local Pointer CLI setup.""" ensure_config_dir() config = Config.load(config_path) + applied_fixes = [] + if fix: + applied_fixes = apply_safe_fixes(config, config_path=config_path) + config = Config.load(config_path) checks = run_doctor(config, config_path=config_path, timeout=timeout) passing, warnings, failing = summarize_results(checks) @@ -470,6 +574,7 @@ def doctor_command( "failures": failing, }, "checks": checks_to_dict(checks), + "fixes": applied_fixes, }, indent=2, ) @@ -478,6 +583,13 @@ def doctor_command( raise typer.Exit(code=EXIT_DEPENDENCY_ERROR) return + if applied_fixes: + fix_table = Table(title="Applied Fixes") + fix_table.add_column("Fix", overflow="fold") + for item in applied_fixes: + fix_table.add_row(item) + console.print(fix_table) + table = Table(title="Pointer CLI Doctor") table.add_column("Check", style="bold") table.add_column("Status") diff --git a/CLI/tests/test_config.py b/CLI/tests/test_config.py index f95b9d2..15d4e94 100644 --- a/CLI/tests/test_config.py +++ b/CLI/tests/test_config.py @@ -164,3 +164,13 @@ def test_suggest_values(self): assert config.suggest_values("ui.show_diffs") == ["true", "false"] assert config.suggest_values("api.timeout") == ["30"] assert config.suggest_values("api.base_url") == ["http://localhost:8000"] + + def test_unset_value_restores_default(self): + """Unset should restore a field to its default value.""" + config = Config() + config.ui.show_diffs = False + + restored = config.unset_value("ui.show_diffs") + + assert restored is True + assert config.ui.show_diffs is True diff --git a/CLI/tests/test_doctor.py b/CLI/tests/test_doctor.py index 6911691..cad81ea 100644 --- a/CLI/tests/test_doctor.py +++ b/CLI/tests/test_doctor.py @@ -3,7 +3,7 @@ """ from pointer_cli.config import Config -from pointer_cli.doctor import DoctorCheck, run_doctor, summarize_results +from pointer_cli.doctor import DoctorCheck, apply_safe_fixes, run_doctor, summarize_results class DummyResponse: @@ -99,3 +99,20 @@ def raise_url_error(url, timeout=2.0): assert check_map["API endpoint"].status == "warn" assert "Could not reach" in check_map["API endpoint"].details + + def test_apply_safe_fixes_restores_invalid_defaults(self, tmp_path): + """Safe fixes should restore obvious invalid config values.""" + config_path = tmp_path / "config.json" + config = Config() + config.api.base_url = "localhost:8000" + config.api.model_name = "" + config.api.timeout = 0 + config.initialized = False + + fixes = apply_safe_fixes(config, config_path=str(config_path)) + + assert fixes + assert config.api.base_url == "http://localhost:8000" + assert config.api.model_name == "gpt-oss-20b" + assert config.api.timeout == 30 + assert config.initialized is True diff --git a/CLI/tests/test_main.py b/CLI/tests/test_main.py index e93982f..a7ca401 100644 --- a/CLI/tests/test_main.py +++ b/CLI/tests/test_main.py @@ -83,6 +83,60 @@ def test_config_set_updates_nested_value(self, tmp_path): saved = json.loads(config_path.read_text(encoding="utf-8")) assert saved["api"]["base_url"] == "http://localhost:9000" + def test_config_unset_restores_default_value(self, tmp_path): + """`pointer config unset` should reset fields to their defaults.""" + config_path = tmp_path / "config.json" + config_path.write_text( + json.dumps( + { + "api": { + "base_url": "http://localhost:8000", + "model_name": "test-model", + "api_key": None, + "timeout": 30, + "max_retries": 3, + }, + "ui": { + "show_ai_responses": True, + "show_thinking": True, + "show_tool_outputs": True, + "show_diffs": False, + "render_markdown": True, + "theme": "default", + "max_output_lines": 100, + }, + "mode": {"auto_run_mode": True}, + "codebase": { + "include_context": True, + "max_context_files": 20, + "context_file_types": [".py"], + "exclude_patterns": [".git"], + "context_depth": 3, + "auto_refresh_context": False, + "context_cache_duration": 3600, + }, + "initialized": True, + } + ), + encoding="utf-8", + ) + + result = runner.invoke(app, ["config", "unset", "ui.show_diffs", "--config", str(config_path)]) + + assert result.exit_code == 0 + saved = json.loads(config_path.read_text(encoding="utf-8")) + assert saved["ui"]["show_diffs"] is True + + def test_config_edit_falls_back_to_printing_path(self, tmp_path, monkeypatch): + """`pointer config edit` should print the config path if opening fails.""" + config_path = tmp_path / "config.json" + monkeypatch.setattr("pointer_cli.main.os.startfile", lambda path: (_ for _ in ()).throw(OSError("nope")), raising=False) + + result = runner.invoke(app, ["config", "edit", "--config", str(config_path)]) + + assert result.exit_code == 0 + assert str(config_path) in result.stdout + def test_init_non_interactive_writes_config(self, tmp_path): """`pointer init --non-interactive` should create an initialized config.""" config_path = tmp_path / "config.json" @@ -181,6 +235,51 @@ def test_status_command_outputs_environment_summary(self, tmp_path): assert "status-model" in result.stdout assert "http://localhost:7777" in result.stdout + def test_status_json_outputs_machine_readable_status(self, tmp_path): + """`pointer status --json` should emit structured JSON.""" + config_path = tmp_path / "config.json" + config_path.write_text( + json.dumps( + { + "api": { + "base_url": "http://localhost:7777", + "model_name": "status-model", + "api_key": None, + "timeout": 30, + "max_retries": 3, + }, + "ui": { + "show_ai_responses": False, + "show_thinking": True, + "show_tool_outputs": True, + "show_diffs": True, + "render_markdown": True, + "theme": "default", + "max_output_lines": 100, + }, + "mode": {"auto_run_mode": False}, + "codebase": { + "include_context": True, + "max_context_files": 20, + "context_file_types": [".py"], + "exclude_patterns": [".git"], + "context_depth": 3, + "auto_refresh_context": False, + "context_cache_duration": 3600, + }, + "initialized": True, + } + ), + encoding="utf-8", + ) + + result = runner.invoke(app, ["status", "--json", "--config", str(config_path)]) + + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert payload["model"] == "status-model" + assert payload["api_base_url"] == "http://localhost:7777" + def test_doctor_json_outputs_machine_readable_result(self, tmp_path): """`pointer doctor --json` should emit structured JSON.""" config_path = tmp_path / "config.json" @@ -270,6 +369,53 @@ def test_doctor_invalid_config_returns_dependency_exit_code(self, tmp_path): payload = json.loads(result.stdout) assert payload["summary"]["failures"] >= 1 + def test_doctor_fix_repairs_invalid_config(self, tmp_path): + """`pointer doctor --fix --json` should repair safe config issues.""" + config_path = tmp_path / "config.json" + config_path.write_text( + json.dumps( + { + "api": { + "base_url": "localhost:8000", + "model_name": "", + "api_key": None, + "timeout": 0, + "max_retries": 3, + }, + "ui": { + "show_ai_responses": True, + "show_thinking": True, + "show_tool_outputs": True, + "show_diffs": True, + "render_markdown": True, + "theme": "default", + "max_output_lines": 100, + }, + "mode": {"auto_run_mode": True}, + "codebase": { + "include_context": True, + "max_context_files": 20, + "context_file_types": [".py"], + "exclude_patterns": [".git"], + "context_depth": 3, + "auto_refresh_context": False, + "context_cache_duration": 3600, + }, + "initialized": False, + } + ), + encoding="utf-8", + ) + + result = runner.invoke(app, ["doctor", "--fix", "--json", "--config", str(config_path)]) + + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert payload["fixes"] + saved = json.loads(config_path.read_text(encoding="utf-8")) + assert saved["api"]["base_url"] == "http://localhost:8000" + assert saved["api"]["model_name"] == "gpt-oss-20b" + def test_context_show_outputs_summary(self, tmp_path, monkeypatch): """`pointer context show` should summarize indexed project files.""" (tmp_path / ".git").mkdir() @@ -363,6 +509,54 @@ def test_context_search_finds_file(self, tmp_path, monkeypatch): assert result.exit_code == 0 assert "module.py" in result.stdout + def test_context_files_lists_indexed_files(self, tmp_path, monkeypatch): + """`pointer context files` should list indexed files and respect extension filters.""" + (tmp_path / ".git").mkdir() + (tmp_path / "module.py").write_text("print('x')\n", encoding="utf-8") + (tmp_path / "notes.md").write_text("# hi\n", encoding="utf-8") + config_path = tmp_path / "config.json" + config_path.write_text( + json.dumps( + { + "api": { + "base_url": "http://localhost:8000", + "model_name": "context-model", + "api_key": None, + "timeout": 30, + "max_retries": 3, + }, + "ui": { + "show_ai_responses": True, + "show_thinking": True, + "show_tool_outputs": True, + "show_diffs": True, + "render_markdown": True, + "theme": "default", + "max_output_lines": 100, + }, + "mode": {"auto_run_mode": True}, + "codebase": { + "include_context": True, + "max_context_files": 20, + "context_file_types": [".py", ".md"], + "exclude_patterns": ["node_modules"], + "context_depth": 3, + "auto_refresh_context": False, + "context_cache_duration": 0, + }, + "initialized": True, + } + ), + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + + result = runner.invoke(app, ["context", "files", "--ext", ".py", "--config", str(config_path)]) + + assert result.exit_code == 0 + assert "module.py" in result.stdout + assert "notes.md" not in result.stdout + def test_invalid_config_returns_config_exit_code_for_status(self, tmp_path): """Validated commands should stop with the config exit code.""" config_path = tmp_path / "config.json" From 7bdd7728d4103d0f1565534973e589e16cd686f2 Mon Sep 17 00:00:00 2001 From: Liam Date: Fri, 27 Mar 2026 02:06:39 +0100 Subject: [PATCH 3/4] Add CLI chat export and API utility commands --- CLI/README.md | 15 +++ CLI/pointer_cli/chat_manager.py | 64 ++++++++++ CLI/pointer_cli/main.py | 144 +++++++++++++++++++++++ CLI/tests/test_main.py | 199 ++++++++++++++++++++++++++++++++ 4 files changed, 422 insertions(+) diff --git a/CLI/README.md b/CLI/README.md index 84655c8..8b99aee 100644 --- a/CLI/README.md +++ b/CLI/README.md @@ -60,9 +60,24 @@ pointer context show pointer context refresh pointer context search TODO pointer context files --ext .py +pointer context inspect src/app.py pointer context config ``` +Manage saved chats with: + +```bash +pointer chats export chat_20260327_010000 --format markdown +pointer chats rename chat_20260327_010000 "Bug triage" +``` + +Inspect API connectivity and model setup with: + +```bash +pointer models +pointer ping +``` + Initialize without prompts with: ```bash diff --git a/CLI/pointer_cli/chat_manager.py b/CLI/pointer_cli/chat_manager.py index 47b81cb..4bc2648 100644 --- a/CLI/pointer_cli/chat_manager.py +++ b/CLI/pointer_cli/chat_manager.py @@ -164,6 +164,70 @@ def delete_chat(self, chat_id: str) -> bool: return True return False + + def rename_chat(self, chat_id: str, title: str) -> bool: + """Rename an existing chat session.""" + chat = self.load_chat(chat_id) + if chat is None: + return False + + chat.title = title + self.save_chat(chat) + if self.current_chat and self.current_chat.id == chat_id: + self.current_chat = chat + return True + + def export_chat(self, chat_id: str, export_format: str = "markdown") -> Optional[str]: + """Export a chat session in a portable format.""" + chat = self.load_chat(chat_id) + if chat is None: + return None + + if export_format == "json": + return json.dumps( + { + "id": chat.id, + "title": chat.title, + "created_at": chat.created_at, + "last_modified": chat.last_modified, + "total_tokens": chat.total_tokens, + "messages": [ + { + "role": msg.role, + "content": msg.content, + "timestamp": msg.timestamp, + "tokens_used": msg.tokens_used, + } + for msg in chat.messages + ], + }, + indent=2, + ensure_ascii=False, + ) + + lines = [ + f"# {chat.title}", + "", + f"- Chat ID: {chat.id}", + f"- Created: {chat.created_at}", + f"- Last Modified: {chat.last_modified}", + f"- Total Tokens: {chat.total_tokens}", + "", + ] + + for msg in chat.messages: + lines.extend( + [ + f"## {msg.role.title()}", + "", + msg.content, + "", + f"_Timestamp: {msg.timestamp} | Tokens: {msg.tokens_used}_", + "", + ] + ) + + return "\n".join(lines) def add_message(self, role: str, content: str, tokens_used: int = 0) -> None: """Add a message to the current chat.""" diff --git a/CLI/pointer_cli/main.py b/CLI/pointer_cli/main.py index 7caa67a..7745f6b 100644 --- a/CLI/pointer_cli/main.py +++ b/CLI/pointer_cli/main.py @@ -8,12 +8,14 @@ from pathlib import Path import sys from typing import Optional +from urllib import error, request import typer from rich.console import Console from rich.panel import Panel from rich.table import Table +from .chat_manager import ChatManager from .codebase_context import CodebaseContext from .config import Config from .core import PointerCLI @@ -28,8 +30,10 @@ ) config_app = typer.Typer(help="Inspect and update Pointer CLI configuration.") context_app = typer.Typer(help="Inspect and manage codebase context.") +chats_app = typer.Typer(help="Manage saved chat sessions.") app.add_typer(config_app, name="config") app.add_typer(context_app, name="context") +app.add_typer(chats_app, name="chats") console = Console() @@ -152,6 +156,26 @@ def _get_codebase_context(config_path: Optional[str] = None) -> tuple[Config, Co return config, CodebaseContext(config) +def _get_chat_manager(config_path: Optional[str] = None) -> ChatManager: + """Create a chat manager rooted at the active config directory.""" + config_file = Path(config_path) if config_path else Config.get_default_config_path() + config_dir = config_file.parent + config_dir.mkdir(parents=True, exist_ok=True) + return ChatManager(config_dir) + + +def _complete_chat_ids(ctx: typer.Context, incomplete: str) -> list[str]: + """Autocomplete saved chat ids.""" + config_path = ctx.params.get("config_path") + try: + manager = _get_chat_manager(config_path) + chat_ids = [chat["id"] for chat in manager.list_chats()] + except Exception: + return [] + + return [chat_id for chat_id in chat_ids if chat_id.startswith(incomplete)] + + def _complete_config_keys(ctx: typer.Context, incomplete: str) -> list[str]: """Autocomplete dotted configuration keys.""" config_path = ctx.params.get("config_path") @@ -505,6 +529,33 @@ def context_files_command( console.print(table) +@context_app.command("inspect") +def context_inspect_command( + file_path: str = typer.Argument(..., help="Relative path of the indexed file to inspect"), + config_path: Optional[str] = typer.Option(None, "--config", "-c", help="Path to config file"), +) -> None: + """Inspect a single indexed file from codebase context.""" + config, codebase_context = _get_codebase_context(config_path) + if not config.codebase.include_context: + console.print("[yellow]Codebase context is disabled.[/yellow]") + return + + codebase_context.force_refresh() + file_info = codebase_context.get_file_context(file_path) + if file_info is None: + console.print(f"[yellow]No indexed file found for '{file_path}'.[/yellow]") + return + + panel_body = ( + f"Path: {file_info.relative_path}\n" + f"Extension: {file_info.extension}\n" + f"Lines: {file_info.lines}\n" + f"Size: {file_info.size_formatted}\n\n" + f"Preview:\n{file_info.content_preview or '[No preview available]'}" + ) + console.print(Panel(panel_body, title="Context File", border_style="blue")) + + @context_app.command("config") def context_config_command( config_path: Optional[str] = typer.Option(None, "--config", "-c", help="Path to config file"), @@ -547,6 +598,99 @@ def context_disable_command( console.print("[yellow]Codebase context disabled.[/yellow]") +@chats_app.command("export") +def chats_export_command( + chat_id: str = typer.Argument(..., help="Chat ID to export", autocompletion=_complete_chat_ids), + export_format: str = typer.Option("markdown", "--format", help="Export format: markdown or json"), + output_path: Optional[str] = typer.Option(None, "--output", "-o", help="Optional file path to write the export to"), + config_path: Optional[str] = typer.Option(None, "--config", "-c", help="Path to config file"), +) -> None: + """Export a saved chat as markdown or JSON.""" + if export_format not in {"markdown", "json"}: + console.print("[red]Export format must be 'markdown' or 'json'.[/red]") + raise typer.Exit(code=EXIT_CONFIG_ERROR) + + manager = _get_chat_manager(config_path) + exported = manager.export_chat(chat_id, export_format=export_format) + if exported is None: + console.print(f"[red]Chat not found: {chat_id}[/red]") + raise typer.Exit(code=EXIT_CONFIG_ERROR) + + if output_path: + destination = Path(output_path) + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text(exported, encoding="utf-8") + console.print(f"[green]Exported chat to {destination}.[/green]") + return + + console.print(exported) + + +@chats_app.command("rename") +def chats_rename_command( + chat_id: str = typer.Argument(..., help="Chat ID to rename", autocompletion=_complete_chat_ids), + title: str = typer.Argument(..., help="New title for the chat"), + config_path: Optional[str] = typer.Option(None, "--config", "-c", help="Path to config file"), +) -> None: + """Rename a saved chat session.""" + manager = _get_chat_manager(config_path) + if not manager.rename_chat(chat_id, title): + console.print(f"[red]Chat not found: {chat_id}[/red]") + raise typer.Exit(code=EXIT_CONFIG_ERROR) + + console.print(f"[green]Renamed {chat_id} to {title!r}.[/green]") + + +@app.command("models") +def models_command( + config_path: Optional[str] = typer.Option(None, "--config", "-c", help="Path to config file"), +) -> None: + """Show the configured model and try to discover remote models.""" + config = _load_validated_config(config_path) + configured_model = config.api.model_name + discovered_models = [] + + try: + with request.urlopen(f"{config.api.base_url.rstrip('/')}/v1/models", timeout=config.api.timeout) as response: + payload = json.loads(response.read().decode("utf-8")) + for item in payload.get("data", []): + model_id = item.get("id") + if model_id: + discovered_models.append(model_id) + except Exception: + discovered_models = [] + + table = Table(title="Pointer CLI Models") + table.add_column("Type", style="bold") + table.add_column("Value", overflow="fold") + table.add_row("Configured", configured_model) + table.add_row("API Base URL", config.api.base_url) + table.add_row("Discovered", ", ".join(discovered_models) if discovered_models else "No remote model list available") + console.print(table) + + +@app.command("ping") +def ping_command( + config_path: Optional[str] = typer.Option(None, "--config", "-c", help="Path to config file"), +) -> None: + """Check API reachability and print simple latency information.""" + config = _load_validated_config(config_path) + health_url = f"{config.api.base_url.rstrip('/')}/health" + start = __import__("time").time() + + try: + with request.urlopen(health_url, timeout=config.api.timeout) as response: + latency_ms = int((__import__("time").time() - start) * 1000) + status = getattr(response, "status", response.getcode()) + console.print(f"[green]OK[/green] {health_url} responded with HTTP {status} in {latency_ms} ms.") + except error.HTTPError as exc: + latency_ms = int((__import__("time").time() - start) * 1000) + console.print(f"[yellow]WARN[/yellow] {health_url} responded with HTTP {exc.code} in {latency_ms} ms.") + except Exception as exc: + console.print(f"[red]Ping failed:[/red] {exc}") + raise typer.Exit(code=EXIT_DEPENDENCY_ERROR) + + @app.command("doctor") def doctor_command( config_path: Optional[str] = typer.Option(None, "--config", "-c", help="Path to config file"), diff --git a/CLI/tests/test_main.py b/CLI/tests/test_main.py index a7ca401..e48f91e 100644 --- a/CLI/tests/test_main.py +++ b/CLI/tests/test_main.py @@ -557,6 +557,53 @@ def test_context_files_lists_indexed_files(self, tmp_path, monkeypatch): assert "module.py" in result.stdout assert "notes.md" not in result.stdout + def test_context_inspect_shows_preview(self, tmp_path, monkeypatch): + """`pointer context inspect` should show a detailed preview for one file.""" + (tmp_path / ".git").mkdir() + (tmp_path / "module.py").write_text("special_keyword = True\nprint('hello')\n", encoding="utf-8") + config_path = tmp_path / "config.json" + config_path.write_text( + json.dumps( + { + "api": { + "base_url": "http://localhost:8000", + "model_name": "context-model", + "api_key": None, + "timeout": 30, + "max_retries": 3, + }, + "ui": { + "show_ai_responses": True, + "show_thinking": True, + "show_tool_outputs": True, + "show_diffs": True, + "render_markdown": True, + "theme": "default", + "max_output_lines": 100, + }, + "mode": {"auto_run_mode": True}, + "codebase": { + "include_context": True, + "max_context_files": 20, + "context_file_types": [".py"], + "exclude_patterns": ["node_modules"], + "context_depth": 3, + "auto_refresh_context": False, + "context_cache_duration": 0, + }, + "initialized": True, + } + ), + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + + result = runner.invoke(app, ["context", "inspect", "module.py", "--config", str(config_path)]) + + assert result.exit_code == 0 + assert "Context File" in result.stdout + assert "special_keyword = True" in result.stdout + def test_invalid_config_returns_config_exit_code_for_status(self, tmp_path): """Validated commands should stop with the config exit code.""" config_path = tmp_path / "config.json" @@ -599,6 +646,158 @@ def test_invalid_config_returns_config_exit_code_for_status(self, tmp_path): assert result.exit_code == EXIT_CONFIG_ERROR + def test_chats_export_writes_markdown_file(self, tmp_path): + """`pointer chats export` should write a markdown export.""" + config_path = tmp_path / "config.json" + chats_dir = tmp_path / "chats" + chats_dir.mkdir() + chat_id = "chat_20260327_010000" + (chats_dir / f"{chat_id}.json").write_text( + json.dumps( + { + "id": chat_id, + "title": "Demo Chat", + "created_at": "2026-03-27T01:00:00", + "last_modified": "2026-03-27T01:05:00", + "total_tokens": 42, + "messages": [ + { + "role": "user", + "content": "Hello", + "timestamp": "2026-03-27T01:00:00", + "tokens_used": 10, + } + ], + } + ), + encoding="utf-8", + ) + output_path = tmp_path / "export.md" + + result = runner.invoke( + app, + ["chats", "export", chat_id, "--output", str(output_path), "--config", str(config_path)], + ) + + assert result.exit_code == 0 + assert output_path.exists() + assert "Demo Chat" in output_path.read_text(encoding="utf-8") + + def test_chats_rename_updates_saved_chat(self, tmp_path): + """`pointer chats rename` should update the saved chat title.""" + config_path = tmp_path / "config.json" + chats_dir = tmp_path / "chats" + chats_dir.mkdir() + chat_id = "chat_20260327_010000" + chat_path = chats_dir / f"{chat_id}.json" + chat_path.write_text( + json.dumps( + { + "id": chat_id, + "title": "Old Title", + "created_at": "2026-03-27T01:00:00", + "last_modified": "2026-03-27T01:05:00", + "total_tokens": 42, + "messages": [], + } + ), + encoding="utf-8", + ) + + result = runner.invoke( + app, + ["chats", "rename", chat_id, "New Title", "--config", str(config_path)], + ) + + assert result.exit_code == 0 + saved = json.loads(chat_path.read_text(encoding="utf-8")) + assert saved["title"] == "New Title" + + def test_models_command_lists_configured_model(self, tmp_path): + """`pointer models` should always show the configured model.""" + config_path = tmp_path / "config.json" + config_path.write_text( + json.dumps( + { + "api": { + "base_url": "http://localhost:8000", + "model_name": "demo-model", + "api_key": None, + "timeout": 30, + "max_retries": 3, + }, + "ui": { + "show_ai_responses": True, + "show_thinking": True, + "show_tool_outputs": True, + "show_diffs": True, + "render_markdown": True, + "theme": "default", + "max_output_lines": 100, + }, + "mode": {"auto_run_mode": True}, + "codebase": { + "include_context": True, + "max_context_files": 20, + "context_file_types": [".py"], + "exclude_patterns": [".git"], + "context_depth": 3, + "auto_refresh_context": False, + "context_cache_duration": 3600, + }, + "initialized": True, + } + ), + encoding="utf-8", + ) + + result = runner.invoke(app, ["models", "--config", str(config_path)]) + + assert result.exit_code == 0 + assert "demo-model" in result.stdout + + def test_ping_command_returns_dependency_error_when_unreachable(self, tmp_path): + """`pointer ping` should use the dependency exit code on failure.""" + config_path = tmp_path / "config.json" + config_path.write_text( + json.dumps( + { + "api": { + "base_url": "http://localhost:65530", + "model_name": "demo-model", + "api_key": None, + "timeout": 1, + "max_retries": 3, + }, + "ui": { + "show_ai_responses": True, + "show_thinking": True, + "show_tool_outputs": True, + "show_diffs": True, + "render_markdown": True, + "theme": "default", + "max_output_lines": 100, + }, + "mode": {"auto_run_mode": True}, + "codebase": { + "include_context": True, + "max_context_files": 20, + "context_file_types": [".py"], + "exclude_patterns": [".git"], + "context_depth": 3, + "auto_refresh_context": False, + "context_cache_duration": 3600, + }, + "initialized": True, + } + ), + encoding="utf-8", + ) + + result = runner.invoke(app, ["ping", "--config", str(config_path)]) + + assert result.exit_code == EXIT_DEPENDENCY_ERROR + def test_complete_config_keys_suggests_matching_dotted_keys(self): """Config key completion should return matching dotted keys.""" ctx = SimpleNamespace(params={"config_path": None}) From f5cea096061c05e2017b04ee696313c84480515c Mon Sep 17 00:00:00 2001 From: Liam Date: Fri, 27 Mar 2026 03:38:19 +0100 Subject: [PATCH 4/4] Expand CLI context, chat, and JSON utility commands --- CLI/README.md | 8 + CLI/pointer_cli/main.py | 212 +++++++++++++++++++++++++- CLI/tests/test_main.py | 322 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 541 insertions(+), 1 deletion(-) diff --git a/CLI/README.md b/CLI/README.md index 8b99aee..65c3654 100644 --- a/CLI/README.md +++ b/CLI/README.md @@ -61,6 +61,9 @@ pointer context refresh pointer context search TODO pointer context files --ext .py pointer context inspect src/app.py +pointer context inspect src/app.py --json +pointer context rebuild +pointer context stats --json pointer context config ``` @@ -69,13 +72,18 @@ Manage saved chats with: ```bash pointer chats export chat_20260327_010000 --format markdown pointer chats rename chat_20260327_010000 "Bug triage" +pointer chats list --json +pointer chats current --json +pointer chats delete chat_20260327_010000 ``` Inspect API connectivity and model setup with: ```bash pointer models +pointer models --json pointer ping +pointer ping --json ``` Initialize without prompts with: diff --git a/CLI/pointer_cli/main.py b/CLI/pointer_cli/main.py index 7745f6b..b8540bc 100644 --- a/CLI/pointer_cli/main.py +++ b/CLI/pointer_cli/main.py @@ -229,6 +229,29 @@ def _complete_context_query(ctx: typer.Context, incomplete: str) -> list[str]: return sorted(suggestion for suggestion in suggestions if suggestion.lower().startswith(normalized))[:25] +def _complete_context_files(ctx: typer.Context, incomplete: str) -> list[str]: + """Autocomplete indexed relative file paths for context inspection.""" + config_path = ctx.params.get("config_path") + + try: + config, codebase_context = _get_codebase_context(config_path) + except typer.Exit: + return [] + except Exception: + return [] + + if not config.codebase.include_context: + return [] + + codebase_context.force_refresh() + normalized = incomplete.lower() + return sorted( + relative_path + for relative_path in codebase_context.context_cache + if relative_path.lower().startswith(normalized) + )[:50] + + @app.command("init") def init_command( config_path: Optional[str] = typer.Option(None, "--config", "-c", help="Path to config file"), @@ -531,7 +554,12 @@ def context_files_command( @context_app.command("inspect") def context_inspect_command( - file_path: str = typer.Argument(..., help="Relative path of the indexed file to inspect"), + file_path: str = typer.Argument( + ..., + help="Relative path of the indexed file to inspect", + autocompletion=_complete_context_files, + ), + json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON output"), config_path: Optional[str] = typer.Option(None, "--config", "-c", help="Path to config file"), ) -> None: """Inspect a single indexed file from codebase context.""" @@ -546,6 +574,20 @@ def context_inspect_command( console.print(f"[yellow]No indexed file found for '{file_path}'.[/yellow]") return + payload = { + "path": file_info.relative_path, + "extension": file_info.extension, + "lines": file_info.lines, + "size": file_info.size, + "size_formatted": file_info.size_formatted, + "modified": file_info.modified, + "preview": file_info.content_preview, + } + + if json_output: + console.print(json.dumps(payload, indent=2)) + return + panel_body = ( f"Path: {file_info.relative_path}\n" f"Extension: {file_info.extension}\n" @@ -556,6 +598,79 @@ def context_inspect_command( console.print(Panel(panel_body, title="Context File", border_style="blue")) +@context_app.command("rebuild") +def context_rebuild_command( + config_path: Optional[str] = typer.Option(None, "--config", "-c", help="Path to config file"), +) -> None: + """Fully rebuild the cached codebase context.""" + config, codebase_context = _get_codebase_context(config_path) + if not config.codebase.include_context: + console.print("[yellow]Codebase context is disabled.[/yellow]") + return + + codebase_context.context_cache.clear() + codebase_context.last_refresh = 0 + codebase_context.force_refresh() + summary = codebase_context.get_context_summary() + console.print(f"[green]Context rebuilt. Indexed {summary.get('total_files', 0)} files.[/green]") + + +@context_app.command("stats") +def context_stats_command( + json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON output"), + config_path: Optional[str] = typer.Option(None, "--config", "-c", help="Path to config file"), +) -> None: + """Show detailed statistics about indexed codebase context.""" + config, codebase_context = _get_codebase_context(config_path) + if not config.codebase.include_context: + console.print("[yellow]Codebase context is disabled.[/yellow]") + return + + codebase_context.force_refresh() + files = list(codebase_context.context_cache.values()) + extension_counts = codebase_context._get_file_type_summary() + total_size = sum(file_info.size for file_info in files) + largest_files = sorted(files, key=lambda file_info: file_info.size, reverse=True)[:5] + + payload = { + "total_files": len(files), + "total_size_bytes": total_size, + "extensions": extension_counts, + "largest_files": [ + { + "path": file_info.relative_path, + "size": file_info.size, + "size_formatted": file_info.size_formatted, + "lines": file_info.lines, + } + for file_info in largest_files + ], + "exclude_patterns": config.codebase.exclude_patterns, + "context_depth": config.codebase.context_depth, + } + + if json_output: + console.print(json.dumps(payload, indent=2)) + return + + table = Table(title="Pointer CLI Context Stats") + table.add_column("Metric", style="bold") + table.add_column("Value", overflow="fold") + table.add_row("Total files", str(payload["total_files"])) + table.add_row("Total size", f"{total_size} bytes") + table.add_row( + "Extensions", + ", ".join(f"{ext}({count})" for ext, count in extension_counts.items()) or "None", + ) + table.add_row( + "Largest files", + ", ".join(f"{item['path']} ({item['size_formatted']})" for item in payload["largest_files"]) or "None", + ) + table.add_row("Exclude patterns", ", ".join(config.codebase.exclude_patterns)) + table.add_row("Context depth", str(config.codebase.context_depth)) + console.print(table) + + @context_app.command("config") def context_config_command( config_path: Optional[str] = typer.Option(None, "--config", "-c", help="Path to config file"), @@ -641,9 +756,85 @@ def chats_rename_command( console.print(f"[green]Renamed {chat_id} to {title!r}.[/green]") +@chats_app.command("list") +def chats_list_command( + json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON output"), + config_path: Optional[str] = typer.Option(None, "--config", "-c", help="Path to config file"), +) -> None: + """List saved chat sessions.""" + manager = _get_chat_manager(config_path) + chats = manager.list_chats() + + if json_output: + console.print(json.dumps(chats, indent=2)) + return + + if not chats: + console.print("[yellow]No saved chats found.[/yellow]") + return + + table = Table(title="Pointer CLI Chats") + table.add_column("Chat ID", style="bold") + table.add_column("Title") + table.add_column("Messages") + table.add_column("Tokens") + table.add_column("Last Modified") + + for chat in chats: + table.add_row( + chat["id"], + chat["title"], + str(chat["message_count"]), + str(chat["total_tokens"]), + chat["last_modified"], + ) + + console.print(table) + + +@chats_app.command("delete") +def chats_delete_command( + chat_id: str = typer.Argument(..., help="Chat ID to delete", autocompletion=_complete_chat_ids), + config_path: Optional[str] = typer.Option(None, "--config", "-c", help="Path to config file"), +) -> None: + """Delete a saved chat session.""" + manager = _get_chat_manager(config_path) + if not manager.delete_chat(chat_id): + console.print(f"[red]Chat not found: {chat_id}[/red]") + raise typer.Exit(code=EXIT_CONFIG_ERROR) + + console.print(f"[green]Deleted chat {chat_id}.[/green]") + + +@chats_app.command("current") +def chats_current_command( + json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON output"), + config_path: Optional[str] = typer.Option(None, "--config", "-c", help="Path to config file"), +) -> None: + """Show the most recently modified saved chat.""" + manager = _get_chat_manager(config_path) + chats = manager.list_chats() + if not chats: + console.print("[yellow]No saved chats found.[/yellow]") + return + + current_chat = chats[0] + if json_output: + console.print(json.dumps(current_chat, indent=2)) + return + + table = Table(title="Pointer CLI Current Chat") + table.add_column("Field", style="bold") + table.add_column("Value", overflow="fold") + for key in ["id", "title", "message_count", "total_tokens", "last_modified"]: + table.add_row(key, str(current_chat[key])) + console.print(table) + + @app.command("models") def models_command( config_path: Optional[str] = typer.Option(None, "--config", "-c", help="Path to config file"), + json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON output"), ) -> None: """Show the configured model and try to discover remote models.""" config = _load_validated_config(config_path) @@ -660,6 +851,16 @@ def models_command( except Exception: discovered_models = [] + payload = { + "configured_model": configured_model, + "api_base_url": config.api.base_url, + "discovered_models": discovered_models, + } + + if json_output: + console.print(json.dumps(payload, indent=2)) + return + table = Table(title="Pointer CLI Models") table.add_column("Type", style="bold") table.add_column("Value", overflow="fold") @@ -672,6 +873,7 @@ def models_command( @app.command("ping") def ping_command( config_path: Optional[str] = typer.Option(None, "--config", "-c", help="Path to config file"), + json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON output"), ) -> None: """Check API reachability and print simple latency information.""" config = _load_validated_config(config_path) @@ -682,11 +884,19 @@ def ping_command( with request.urlopen(health_url, timeout=config.api.timeout) as response: latency_ms = int((__import__("time").time() - start) * 1000) status = getattr(response, "status", response.getcode()) + if json_output: + console.print(json.dumps({"ok": True, "url": health_url, "status": status, "latency_ms": latency_ms}, indent=2)) + return console.print(f"[green]OK[/green] {health_url} responded with HTTP {status} in {latency_ms} ms.") except error.HTTPError as exc: latency_ms = int((__import__("time").time() - start) * 1000) + if json_output: + console.print(json.dumps({"ok": False, "url": health_url, "status": exc.code, "latency_ms": latency_ms}, indent=2)) + raise typer.Exit(code=EXIT_DEPENDENCY_ERROR) console.print(f"[yellow]WARN[/yellow] {health_url} responded with HTTP {exc.code} in {latency_ms} ms.") except Exception as exc: + if json_output: + console.print(json.dumps({"ok": False, "url": health_url, "error": str(exc)}, indent=2)) console.print(f"[red]Ping failed:[/red] {exc}") raise typer.Exit(code=EXIT_DEPENDENCY_ERROR) diff --git a/CLI/tests/test_main.py b/CLI/tests/test_main.py index e48f91e..eb54dca 100644 --- a/CLI/tests/test_main.py +++ b/CLI/tests/test_main.py @@ -557,6 +557,100 @@ def test_context_files_lists_indexed_files(self, tmp_path, monkeypatch): assert "module.py" in result.stdout assert "notes.md" not in result.stdout + def test_context_rebuild_reindexes_files(self, tmp_path, monkeypatch): + """`pointer context rebuild` should succeed and report indexed file count.""" + (tmp_path / ".git").mkdir() + (tmp_path / "module.py").write_text("print('x')\n", encoding="utf-8") + config_path = tmp_path / "config.json" + config_path.write_text( + json.dumps( + { + "api": { + "base_url": "http://localhost:8000", + "model_name": "context-model", + "api_key": None, + "timeout": 30, + "max_retries": 3, + }, + "ui": { + "show_ai_responses": True, + "show_thinking": True, + "show_tool_outputs": True, + "show_diffs": True, + "render_markdown": True, + "theme": "default", + "max_output_lines": 100, + }, + "mode": {"auto_run_mode": True}, + "codebase": { + "include_context": True, + "max_context_files": 20, + "context_file_types": [".py"], + "exclude_patterns": ["node_modules"], + "context_depth": 3, + "auto_refresh_context": False, + "context_cache_duration": 3600, + }, + "initialized": True, + } + ), + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + + result = runner.invoke(app, ["context", "rebuild", "--config", str(config_path)]) + + assert result.exit_code == 0 + assert "Context rebuilt" in result.stdout + + def test_context_stats_json_outputs_summary(self, tmp_path, monkeypatch): + """`pointer context stats --json` should emit summary statistics.""" + (tmp_path / ".git").mkdir() + (tmp_path / "module.py").write_text("print('x')\n", encoding="utf-8") + config_path = tmp_path / "config.json" + config_path.write_text( + json.dumps( + { + "api": { + "base_url": "http://localhost:8000", + "model_name": "context-model", + "api_key": None, + "timeout": 30, + "max_retries": 3, + }, + "ui": { + "show_ai_responses": True, + "show_thinking": True, + "show_tool_outputs": True, + "show_diffs": True, + "render_markdown": True, + "theme": "default", + "max_output_lines": 100, + }, + "mode": {"auto_run_mode": True}, + "codebase": { + "include_context": True, + "max_context_files": 20, + "context_file_types": [".py"], + "exclude_patterns": ["node_modules"], + "context_depth": 3, + "auto_refresh_context": False, + "context_cache_duration": 0, + }, + "initialized": True, + } + ), + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + + result = runner.invoke(app, ["context", "stats", "--json", "--config", str(config_path)]) + + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert payload["total_files"] >= 1 + assert ".py" in payload["extensions"] + def test_context_inspect_shows_preview(self, tmp_path, monkeypatch): """`pointer context inspect` should show a detailed preview for one file.""" (tmp_path / ".git").mkdir() @@ -604,6 +698,54 @@ def test_context_inspect_shows_preview(self, tmp_path, monkeypatch): assert "Context File" in result.stdout assert "special_keyword = True" in result.stdout + def test_context_inspect_json_outputs_structured_data(self, tmp_path, monkeypatch): + """`pointer context inspect --json` should emit machine-readable file details.""" + (tmp_path / ".git").mkdir() + (tmp_path / "module.py").write_text("print('hello')\n", encoding="utf-8") + config_path = tmp_path / "config.json" + config_path.write_text( + json.dumps( + { + "api": { + "base_url": "http://localhost:8000", + "model_name": "context-model", + "api_key": None, + "timeout": 30, + "max_retries": 3, + }, + "ui": { + "show_ai_responses": True, + "show_thinking": True, + "show_tool_outputs": True, + "show_diffs": True, + "render_markdown": True, + "theme": "default", + "max_output_lines": 100, + }, + "mode": {"auto_run_mode": True}, + "codebase": { + "include_context": True, + "max_context_files": 20, + "context_file_types": [".py"], + "exclude_patterns": ["node_modules"], + "context_depth": 3, + "auto_refresh_context": False, + "context_cache_duration": 0, + }, + "initialized": True, + } + ), + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + + result = runner.invoke(app, ["context", "inspect", "module.py", "--json", "--config", str(config_path)]) + + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert payload["path"] == "module.py" + assert payload["extension"] == ".py" + def test_invalid_config_returns_config_exit_code_for_status(self, tmp_path): """Validated commands should stop with the config exit code.""" config_path = tmp_path / "config.json" @@ -713,6 +855,98 @@ def test_chats_rename_updates_saved_chat(self, tmp_path): saved = json.loads(chat_path.read_text(encoding="utf-8")) assert saved["title"] == "New Title" + def test_chats_list_json_outputs_saved_chats(self, tmp_path): + """`pointer chats list --json` should emit stored chat metadata.""" + config_path = tmp_path / "config.json" + chats_dir = tmp_path / "chats" + chats_dir.mkdir() + chat_id = "chat_20260327_010000" + (chats_dir / f"{chat_id}.json").write_text( + json.dumps( + { + "id": chat_id, + "title": "Demo Chat", + "created_at": "2026-03-27T01:00:00", + "last_modified": "2026-03-27T01:05:00", + "total_tokens": 42, + "messages": [], + } + ), + encoding="utf-8", + ) + + result = runner.invoke(app, ["chats", "list", "--json", "--config", str(config_path)]) + + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert payload[0]["id"] == chat_id + + def test_chats_delete_removes_saved_chat(self, tmp_path): + """`pointer chats delete` should remove the chat file.""" + config_path = tmp_path / "config.json" + chats_dir = tmp_path / "chats" + chats_dir.mkdir() + chat_id = "chat_20260327_010000" + chat_path = chats_dir / f"{chat_id}.json" + chat_path.write_text( + json.dumps( + { + "id": chat_id, + "title": "Demo Chat", + "created_at": "2026-03-27T01:00:00", + "last_modified": "2026-03-27T01:05:00", + "total_tokens": 42, + "messages": [], + } + ), + encoding="utf-8", + ) + + result = runner.invoke(app, ["chats", "delete", chat_id, "--config", str(config_path)]) + + assert result.exit_code == 0 + assert not chat_path.exists() + + def test_chats_current_json_outputs_latest_chat(self, tmp_path): + """`pointer chats current --json` should return the most recently modified chat.""" + config_path = tmp_path / "config.json" + chats_dir = tmp_path / "chats" + chats_dir.mkdir() + older_id = "chat_older" + newer_id = "chat_newer" + (chats_dir / f"{older_id}.json").write_text( + json.dumps( + { + "id": older_id, + "title": "Older Chat", + "created_at": "2026-03-27T01:00:00", + "last_modified": "2026-03-27T01:05:00", + "total_tokens": 1, + "messages": [], + } + ), + encoding="utf-8", + ) + (chats_dir / f"{newer_id}.json").write_text( + json.dumps( + { + "id": newer_id, + "title": "Newer Chat", + "created_at": "2026-03-27T02:00:00", + "last_modified": "2026-03-27T02:05:00", + "total_tokens": 2, + "messages": [], + } + ), + encoding="utf-8", + ) + + result = runner.invoke(app, ["chats", "current", "--json", "--config", str(config_path)]) + + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert payload["id"] == newer_id + def test_models_command_lists_configured_model(self, tmp_path): """`pointer models` should always show the configured model.""" config_path = tmp_path / "config.json" @@ -756,6 +990,50 @@ def test_models_command_lists_configured_model(self, tmp_path): assert result.exit_code == 0 assert "demo-model" in result.stdout + def test_models_json_outputs_configured_model(self, tmp_path): + """`pointer models --json` should emit machine-readable model data.""" + config_path = tmp_path / "config.json" + config_path.write_text( + json.dumps( + { + "api": { + "base_url": "http://localhost:8000", + "model_name": "demo-model", + "api_key": None, + "timeout": 30, + "max_retries": 3, + }, + "ui": { + "show_ai_responses": True, + "show_thinking": True, + "show_tool_outputs": True, + "show_diffs": True, + "render_markdown": True, + "theme": "default", + "max_output_lines": 100, + }, + "mode": {"auto_run_mode": True}, + "codebase": { + "include_context": True, + "max_context_files": 20, + "context_file_types": [".py"], + "exclude_patterns": [".git"], + "context_depth": 3, + "auto_refresh_context": False, + "context_cache_duration": 3600, + }, + "initialized": True, + } + ), + encoding="utf-8", + ) + + result = runner.invoke(app, ["models", "--json", "--config", str(config_path)]) + + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert payload["configured_model"] == "demo-model" + def test_ping_command_returns_dependency_error_when_unreachable(self, tmp_path): """`pointer ping` should use the dependency exit code on failure.""" config_path = tmp_path / "config.json" @@ -798,6 +1076,50 @@ def test_ping_command_returns_dependency_error_when_unreachable(self, tmp_path): assert result.exit_code == EXIT_DEPENDENCY_ERROR + def test_ping_json_outputs_error_payload_when_unreachable(self, tmp_path): + """`pointer ping --json` should emit structured failure details.""" + config_path = tmp_path / "config.json" + config_path.write_text( + json.dumps( + { + "api": { + "base_url": "http://localhost:65530", + "model_name": "demo-model", + "api_key": None, + "timeout": 1, + "max_retries": 3, + }, + "ui": { + "show_ai_responses": True, + "show_thinking": True, + "show_tool_outputs": True, + "show_diffs": True, + "render_markdown": True, + "theme": "default", + "max_output_lines": 100, + }, + "mode": {"auto_run_mode": True}, + "codebase": { + "include_context": True, + "max_context_files": 20, + "context_file_types": [".py"], + "exclude_patterns": [".git"], + "context_depth": 3, + "auto_refresh_context": False, + "context_cache_duration": 3600, + }, + "initialized": True, + } + ), + encoding="utf-8", + ) + + result = runner.invoke(app, ["ping", "--json", "--config", str(config_path)]) + + assert result.exit_code == EXIT_DEPENDENCY_ERROR + payload = json.loads(result.stdout) + assert payload["ok"] is False + def test_complete_config_keys_suggests_matching_dotted_keys(self): """Config key completion should return matching dotted keys.""" ctx = SimpleNamespace(params={"config_path": None})