├── src ├── __init__.py ├── nautex │ ├── tui │ │ ├── __init__.py │ │ ├── screens │ │ │ └── __init__.py │ │ ├── styles.py │ │ └── widgets │ │ │ ├── __init__.py │ │ │ ├── plan_context.py │ │ │ ├── views.py │ │ │ ├── integration.py │ │ │ ├── dialogs.py │ │ │ ├── info_help_dialog.py │ │ │ ├── integration_status.py │ │ │ ├── system_info.py │ │ │ ├── inputs.py │ │ │ └── loadable_list.py │ ├── models │ │ ├── __init__.py │ │ ├── plan_context.py │ │ ├── config.py │ │ └── integration_status.py │ ├── prompts │ │ ├── __init__.py │ │ ├── terminology.py │ │ └── consts.py │ ├── agent_setups │ │ ├── __init__.py │ │ ├── gemini.py │ │ ├── cursor.py │ │ ├── files_based_mcp.py │ │ ├── section_managed_rules_mixin.py │ │ ├── opencode.py │ │ ├── codex.py │ │ ├── base.py │ │ └── claude.py │ ├── __init__.py │ ├── utils │ │ ├── __init__.py │ │ ├── mcp_toml_utils.py │ │ └── opencode_config_utils.py │ ├── services │ │ ├── __init__.py │ │ ├── agent_rules_service.py │ │ ├── mcp_config_service.py │ │ ├── ui_service.py │ │ ├── document_service.py │ │ ├── integration_status_service.py │ │ ├── section_managed_file_service.py │ │ ├── nautex_api_service.py │ │ └── config_service.py │ ├── api │ │ ├── __init__.py │ │ ├── scope_context_model.py │ │ └── test_client.py │ └── cli.py └── .gitignore ├── doc ├── join_discord.png ├── setup_screen.png ├── howitworks_tasks.png ├── howitworks_coding.png ├── howitworks_diagram.png ├── howitworks_filemap.png ├── howitworks_refinement.png ├── howitworks_integration.png └── howitworks_specifications.png ├── requirements.txt ├── .gitignore ├── LICENSE ├── pyproject.toml ├── Makefile ├── README.md └── tests └── check_mcp_models.py /src/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/nautex/tui/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/nautex/models/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/nautex/prompts/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/.gitignore: -------------------------------------------------------------------------------- 1 | CLAUDE.md 2 | AGENTS.md 3 | opencode.json 4 | -------------------------------------------------------------------------------- /src/nautex/agent_setups/__init__.py: -------------------------------------------------------------------------------- 1 | """Agent setup and configuration classes.""" -------------------------------------------------------------------------------- /src/nautex/__init__.py: -------------------------------------------------------------------------------- 1 | """Nautex CLI package.""" 2 | 3 | __version__ = "0.1.0" 4 | 5 | -------------------------------------------------------------------------------- /doc/join_discord.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmldns/nautex/HEAD/doc/join_discord.png -------------------------------------------------------------------------------- /doc/setup_screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmldns/nautex/HEAD/doc/setup_screen.png -------------------------------------------------------------------------------- /doc/howitworks_tasks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmldns/nautex/HEAD/doc/howitworks_tasks.png -------------------------------------------------------------------------------- /doc/howitworks_coding.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmldns/nautex/HEAD/doc/howitworks_coding.png -------------------------------------------------------------------------------- /doc/howitworks_diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmldns/nautex/HEAD/doc/howitworks_diagram.png -------------------------------------------------------------------------------- /doc/howitworks_filemap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmldns/nautex/HEAD/doc/howitworks_filemap.png -------------------------------------------------------------------------------- /doc/howitworks_refinement.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmldns/nautex/HEAD/doc/howitworks_refinement.png -------------------------------------------------------------------------------- /doc/howitworks_integration.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmldns/nautex/HEAD/doc/howitworks_integration.png -------------------------------------------------------------------------------- /doc/howitworks_specifications.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmldns/nautex/HEAD/doc/howitworks_specifications.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiohttp~=3.12.13 2 | textual~=4.0.0 3 | pydantic~=2.11.7 4 | pydantic-settings~=2.9.1 5 | fastmcp~=2.13.0 6 | aiofiles~=23.2.1 7 | tomli==2.3.0 8 | tomlkit==0.13.3 -------------------------------------------------------------------------------- /src/nautex/tui/screens/__init__.py: -------------------------------------------------------------------------------- 1 | """TUI screens for the Nautex CLI application.""" 2 | 3 | from .setup_screen import SetupScreen, SetupApp 4 | 5 | __all__ = ["SetupScreen", "SetupApp"] 6 | -------------------------------------------------------------------------------- /src/nautex/prompts/terminology.py: -------------------------------------------------------------------------------- 1 | class Terminology: 2 | """Centralized terminology for consistent phrasing across prompts. 3 | 4 | Use these terms in prompt templates and user-facing text to ensure 5 | consistent naming across the system. 6 | """ 7 | 8 | # Primary product and protocol names 9 | PRODUCT = "Nautex" 10 | PROTOCOL = "Model-Context-Protocol (MCP)" 11 | 12 | # Agent naming 13 | AGENT = "Coding Agent" 14 | 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | dev-debug.log 8 | 9 | # Environment variables 10 | .env 11 | 12 | # Editor directories and files 13 | .idea 14 | .vscode 15 | *.suo 16 | *.ntvs* 17 | *.njsproj 18 | *.sln 19 | *.sw? 20 | 21 | # OS specific 22 | .DS_Store 23 | 24 | __pycache__ 25 | 26 | .venv/ 27 | venv 28 | 29 | *.egg-info 30 | 31 | .pypirc 32 | 33 | dist 34 | 35 | .cursor 36 | .claude 37 | .nautex 38 | .gemini 39 | uv.lock 40 | CLAUDE.md 41 | GEMINI.md 42 | -------------------------------------------------------------------------------- /src/nautex/utils/__init__.py: -------------------------------------------------------------------------------- 1 | """Utility functions for Nautex.""" 2 | 3 | from pathlib import Path 4 | 5 | 6 | def path2display(path: Path) -> str: 7 | """Convert a Path object to a string for display. 8 | 9 | Replaces the home directory with "~/" for better display. 10 | 11 | Args: 12 | path: Path object to convert 13 | 14 | Returns: 15 | String representation of the path 16 | """ 17 | home = Path.home() 18 | if path.is_relative_to(home): 19 | relative = path.relative_to(home) 20 | return "~/" + str(relative) 21 | else: 22 | return str(path) -------------------------------------------------------------------------------- /src/nautex/tui/styles.py: -------------------------------------------------------------------------------- 1 | """Centralized TUI styling constants for the Nautex CLI.""" 2 | 3 | 4 | class Colors: 5 | """Color constants for TUI elements.""" 6 | PRIMARY = "#0078d4" 7 | SUCCESS = "#107c10" 8 | WARNING = "#ffb900" 9 | ERROR = "#d13438" 10 | SECONDARY = "#605e5c" 11 | 12 | 13 | class StatusIndicators: 14 | """Emoji indicators for status display.""" 15 | SUCCESS = "🟢" 16 | ERROR = "🔴" 17 | WARNING = "🟡" 18 | PENDING = "⚪" 19 | 20 | 21 | class Styles: 22 | """CSS-like styling constants for Textual widgets.""" 23 | PANEL_BORDER = "solid" 24 | INPUT_BORDER = "round" 25 | BUTTON_STYLE = "bold" -------------------------------------------------------------------------------- /src/nautex/services/__init__.py: -------------------------------------------------------------------------------- 1 | """Services module for Nautex CLI.""" 2 | 3 | from .config_service import ConfigurationService, ConfigurationError 4 | from .nautex_api_service import NautexAPIService 5 | from .ui_service import UIService 6 | from .mcp_config_service import MCPConfigService 7 | from .integration_status_service import IntegrationStatusService 8 | from ..models.integration_status import IntegrationStatus 9 | from .mcp_service import MCPService 10 | 11 | __all__ = [ 12 | "ConfigurationService", 13 | "ConfigurationError", 14 | "NautexAPIService", 15 | "UIService", 16 | "MCPConfigService", 17 | "IntegrationStatusService", 18 | "MCPService", 19 | ] 20 | -------------------------------------------------------------------------------- /src/nautex/tui/widgets/__init__.py: -------------------------------------------------------------------------------- 1 | """Reusable TUI widgets for the Nautex CLI.""" 2 | 3 | from .dialogs import ConfirmationDialog 4 | from .inputs import ValidatedTextInput 5 | from .integration_status import StatusDisplay, IntegrationStatusPanel 6 | from .views import ConfigurationSummaryView 7 | from .integration import IntegrationStatusWidget 8 | from .plan_context import PlanContextWidget 9 | from .loadable_list import LoadableList 10 | from .system_info import SystemInfoWidget 11 | 12 | __all__ = [ 13 | "ConfirmationDialog", 14 | "ValidatedTextInput", 15 | "StatusDisplay", 16 | "IntegrationStatusPanel", 17 | "ConfigurationSummaryView", 18 | "IntegrationStatusWidget", 19 | "PlanContextWidget", 20 | "LoadableList", 21 | "SystemInfoWidget", 22 | ] 23 | -------------------------------------------------------------------------------- /src/nautex/api/__init__.py: -------------------------------------------------------------------------------- 1 | """Nautex API module with client factory for production and test modes.""" 2 | 3 | from .client import NautexAPIClient, NautexAPIError 4 | from .test_client import NautexTestAPIClient 5 | 6 | 7 | def create_api_client(base_url: str = "https://api.nautex.ai", test_mode: bool = True): 8 | """Factory function to create the appropriate API client. 9 | 10 | Args: 11 | base_url: Base URL for the API (ignored in test mode) 12 | test_mode: If True, returns test client with dummy responses 13 | 14 | Returns: 15 | NautexTestAPIClient if test_mode=True, otherwise NautexAPIClient 16 | """ 17 | if test_mode: 18 | return NautexTestAPIClient(base_url) 19 | else: 20 | return NautexAPIClient(base_url) 21 | 22 | 23 | __all__ = [ 24 | 'NautexAPIClient', 25 | 'NautexTestAPIClient', 26 | 'NautexAPIError', 27 | 'create_api_client' 28 | ] 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright 2025 Nautex AI Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /src/nautex/models/plan_context.py: -------------------------------------------------------------------------------- 1 | """Plan Context dataclass for aggregated plan status.""" 2 | 3 | from dataclasses import dataclass 4 | from typing import Optional, Any 5 | from pathlib import Path 6 | 7 | from ..utils.mcp_utils import MCPConfigStatus 8 | from ..api.api_models import Task 9 | 10 | 11 | @dataclass 12 | class PlanContext: 13 | """Aggregated context for current plan status. 14 | 15 | This model is used by PlanContextService to provide a comprehensive 16 | view of the current CLI state, including configuration, API connectivity, 17 | and next available task. 18 | """ 19 | config_loaded: bool 20 | mcp_status: MCPConfigStatus 21 | api_connected: bool 22 | advised_action: str 23 | timestamp: str 24 | 25 | # Optional fields 26 | config_path: Optional[Path] = None 27 | mcp_config_path: Optional[Path] = None 28 | api_response_time: Optional[float] = None 29 | next_task: Optional[Task] = None 30 | 31 | # Using Any for config to avoid circular import with NautexConfig 32 | config_summary: Optional[Any] = None -------------------------------------------------------------------------------- /src/nautex/tui/widgets/plan_context.py: -------------------------------------------------------------------------------- 1 | """Plan context widget for the Nautex TUI.""" 2 | 3 | from textual.widgets import Static 4 | from textual.containers import Vertical 5 | 6 | 7 | class PlanContextWidget(Vertical): 8 | """A simple widget that displays plan context information.""" 9 | 10 | DEFAULT_CSS = """ 11 | PlanContextWidget { 12 | height: auto; 13 | margin: 0; 14 | padding: 0; 15 | } 16 | 17 | PlanContextWidget > Static { 18 | height: auto; 19 | margin: 0; 20 | padding: 0; 21 | } 22 | """ 23 | 24 | def __init__(self, **kwargs): 25 | super().__init__(**kwargs) 26 | self.content_text = Static("Loading...", id="plan_context_content") 27 | 28 | def compose(self): 29 | """Compose the plan context widget layout.""" 30 | yield self.content_text 31 | 32 | def update_from_plan_context(self, plan_context) -> None: 33 | """Update the widget based on plan context. 34 | 35 | Args: 36 | plan_context: PlanContext object from plan_context_service 37 | """ 38 | lines = [] 39 | 40 | if plan_context.next_task: 41 | task = plan_context.next_task 42 | lines.append(f"Next: {task.task_designator} - {task.name}") 43 | lines.append(f"Status: {task.status}") 44 | else: 45 | lines.append("No tasks available") 46 | 47 | lines.append(f"Updated: {plan_context.timestamp}") 48 | 49 | self.content_text.update("\n".join(lines)) -------------------------------------------------------------------------------- /src/nautex/tui/widgets/views.py: -------------------------------------------------------------------------------- 1 | """View-related widgets for the Nautex TUI.""" 2 | 3 | from textual.widgets import Static 4 | 5 | 6 | class ConfigurationSummaryView(Static): 7 | """A read-only view of the full configuration.""" 8 | 9 | def __init__(self, **kwargs): 10 | super().__init__("Configuration summary will appear here", **kwargs) 11 | 12 | def show_config(self, config_data: dict) -> None: 13 | """Display configuration summary. 14 | 15 | Args: 16 | config_data: Configuration data to display 17 | """ 18 | lines = [] 19 | lines.append("📋 Configuration Summary") 20 | lines.append("=" * 25) 21 | 22 | for key, value in config_data.items(): 23 | # Format the key nicely 24 | display_key = key.replace('_', ' ').title() 25 | 26 | # Handle different value types 27 | if isinstance(value, bool): 28 | display_value = "✅ Yes" if value else "❌ No" 29 | elif isinstance(value, str) and value: 30 | # Mask sensitive values 31 | if any(sensitive in key.lower() for sensitive in ['token', 'key', 'password']): 32 | display_value = "*" * min(len(value), 8) + "..." 33 | else: 34 | display_value = value 35 | elif isinstance(value, (int, float)): 36 | display_value = str(value) 37 | elif value is None: 38 | display_value = "Not set" 39 | else: 40 | display_value = str(value) 41 | 42 | lines.append(f"{display_key}: {display_value}") 43 | 44 | self.update("\n".join(lines)) -------------------------------------------------------------------------------- /src/nautex/tui/widgets/integration.py: -------------------------------------------------------------------------------- 1 | """Integration-status-related widgets for the Nautex TUI.""" 2 | 3 | from textual.widgets import Static 4 | from textual.containers import Vertical 5 | from .integration_status import IntegrationStatusPanel 6 | from ...models.integration_status import IntegrationStatus 7 | 8 | 9 | class IntegrationStatusWidget(Vertical): 10 | """A simple 2-line status widget for terminal display.""" 11 | 12 | DEFAULT_CSS = """ 13 | IntegrationStatusWidget { 14 | height: auto; 15 | margin: 0; 16 | padding: 0; 17 | } 18 | 19 | IntegrationStatusWidget Static { 20 | margin: 0; 21 | padding: 0; 22 | } 23 | 24 | IntegrationStatusWidget IntegrationStatusPanel { 25 | margin: 0; 26 | padding: 0; 27 | } 28 | 29 | IntegrationStatusWidget > IntegrationStatusPanel { 30 | padding: 0 0 0 1; 31 | } 32 | """ 33 | 34 | # DEFAULT_CSS = """ 35 | # IntegrationStatusWidget { 36 | # height: 5; 37 | # margin: 0; 38 | # padding: 0; 39 | # } 40 | # 41 | # IntegrationStatusWidget > Static { 42 | # margin: 0; 43 | # padding: 0; 44 | # } 45 | # 46 | # IntegrationStatusWidget > IntegrationStatusPanel { 47 | # margin: 0; 48 | # padding: 0; 49 | # } 50 | # """ 51 | 52 | def __init__(self, **kwargs): 53 | super().__init__(**kwargs) 54 | self.status_panel = IntegrationStatusPanel() 55 | self.status_text = Static("Checking status...", id="status_text") 56 | 57 | def compose(self): 58 | """Compose the integration status widget layout.""" 59 | yield self.status_panel 60 | yield self.status_text 61 | 62 | def update_data(self, integration_status: IntegrationStatus) -> None: 63 | """Update the widget based on integration status. 64 | 65 | Args: 66 | integration_status: IntegrationStatus object from integration_status_service 67 | """ 68 | self.status_panel.update_data(integration_status) 69 | 70 | # Update status text 71 | if integration_status.integration_ready: 72 | self.status_text.update("✅ Ready to work") 73 | else: 74 | self.status_text.update(f"⚠️ {integration_status.get_status_message()}") 75 | -------------------------------------------------------------------------------- /src/nautex/services/agent_rules_service.py: -------------------------------------------------------------------------------- 1 | """Agent Rules Service for managing agent workflow rules files.""" 2 | from pathlib import Path 3 | from typing import Tuple, Optional 4 | import logging 5 | 6 | from . import ConfigurationService 7 | from ..agent_setups.base import AgentRulesStatus 8 | 9 | # Set up logging 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | class AgentRulesService: 14 | """Service for managing agent workflow rules files. 15 | 16 | This service acts as a hub that delegates to the agent_base for the implementation. 17 | It handles checking existing agent rules files, validating them, 18 | and writing the rules files to integrate with agent tools. 19 | """ 20 | 21 | def __init__(self, config_service: ConfigurationService): 22 | """Initialize the agent rules service. 23 | 24 | Args: 25 | config_service: The configuration service to use 26 | """ 27 | self.config_service = config_service 28 | 29 | @property 30 | def agent_setup(self): 31 | return self.config_service.agent_setup 32 | 33 | # Alias methods for backward compatibility 34 | def validate_rules(self) -> Tuple[AgentRulesStatus, Optional[Path]]: 35 | """Check the status of agent rules file. 36 | 37 | Validates file content against expected content. 38 | 39 | Returns: 40 | Tuple of (status, path_to_rules_file) 41 | - AgentRulesStatus.OK: Rules file exists and is correctly configured 42 | - AgentRulesStatus.OUTDATED: File exists but content is incorrect 43 | - AgentRulesStatus.ERROR: There was an error reading/parsing the rules file 44 | - AgentRulesStatus.NOT_FOUND: No rules file found 45 | """ 46 | return self.agent_setup.validate_rules() 47 | 48 | def ensure_rules(self) -> bool: 49 | """Ensure that the rules file exists and is up to date. 50 | 51 | Returns: 52 | True if rules file was successfully written, False otherwise 53 | """ 54 | return self.agent_setup.ensure_rules() 55 | 56 | def get_rules_info(self) -> str: 57 | """Get the rendered string content for the rules file. 58 | 59 | Returns: 60 | Rendered string content for the rules file 61 | """ 62 | return self.agent_setup.get_rules_info() 63 | -------------------------------------------------------------------------------- /src/nautex/tui/widgets/dialogs.py: -------------------------------------------------------------------------------- 1 | """Dialog widgets for the Nautex TUI.""" 2 | 3 | from textual.widgets import Static, Button 4 | from textual.containers import Horizontal, Vertical, Center, Middle 5 | from textual.screen import Screen 6 | from textual import events 7 | 8 | 9 | class ConfirmationDialog(Screen): 10 | """A modal screen for yes/no confirmation.""" 11 | 12 | DEFAULT_CSS = """ 13 | ConfirmationDialog { 14 | align: center middle; 15 | } 16 | 17 | #dialog { 18 | width: 50; 19 | height: 11; 20 | border: solid $primary; 21 | background: $surface; 22 | padding: 1 2; 23 | } 24 | 25 | #message { 26 | height: 3; 27 | text-align: center; 28 | padding: 1; 29 | } 30 | 31 | #buttons { 32 | height: 3; 33 | align: center middle; 34 | } 35 | 36 | Button { 37 | margin: 0 1; 38 | min-width: 8; 39 | } 40 | """ 41 | 42 | def __init__(self, message: str, title: str = "Confirm", **kwargs): 43 | super().__init__(**kwargs) 44 | self.message = message 45 | self.title = title 46 | 47 | def compose(self): 48 | """Compose the dialog layout.""" 49 | with Center(): 50 | with Middle(): 51 | with Vertical(id="dialog"): 52 | yield Static(self.title, id="title") 53 | yield Static(self.message, id="message") 54 | with Horizontal(id="buttons"): 55 | yield Button("Yes", id="yes", variant="primary") 56 | yield Button("No", id="no", variant="default") 57 | 58 | def on_button_pressed(self, event: Button.Pressed) -> None: 59 | """Handle button press events.""" 60 | if event.button.id == "yes": 61 | self.dismiss(True) 62 | elif event.button.id == "no": 63 | self.dismiss(False) 64 | 65 | def on_key(self, event: events.Key) -> None: 66 | """Handle key events for keyboard shortcuts.""" 67 | if event.key == "escape": 68 | event.stop() 69 | self.dismiss(False) 70 | elif event.key == "enter": 71 | self.dismiss(True) 72 | elif event.key in ("y", "Y"): 73 | self.dismiss(True) 74 | elif event.key in ("n", "N"): 75 | self.dismiss(False) 76 | -------------------------------------------------------------------------------- /src/nautex/agent_setups/gemini.py: -------------------------------------------------------------------------------- 1 | """Gemini agent setup and configuration.""" 2 | from pathlib import Path 3 | from typing import Tuple, Optional 4 | 5 | from .base import AgentRulesStatus, AgentSetupBase 6 | from .files_based_mcp import FilesBasedMCPAgentSetup 7 | from .section_managed_rules_mixin import SectionManagedRulesMixin 8 | from ..models.config import AgentType 9 | from ..prompts.common_workflow import COMMON_WORKFLOW_PROMPT 10 | from ..prompts.consts import ( 11 | NAUTEX_SECTION_START, 12 | NAUTEX_SECTION_END, 13 | rules_reference_content_for, 14 | default_agents_rules_template_for, 15 | DIR_NAUTEX, 16 | ) 17 | from ..services.section_managed_file_service import SectionManagedFileService 18 | from ..utils import path2display 19 | 20 | 21 | class GeminiAgentSetup(SectionManagedRulesMixin, FilesBasedMCPAgentSetup): 22 | """Gemini agent setup and configuration. 23 | 24 | This class provides Gemini-specific implementation of the agent setup interface. 25 | """ 26 | 27 | def __init__(self, config_service): 28 | super().__init__(config_service, AgentType.GEMINI) 29 | self.config_folder = Path(".gemini") 30 | self.rules_filename = Path("GEMINI.md") 31 | self.section_service = SectionManagedFileService(NAUTEX_SECTION_START, NAUTEX_SECTION_END) 32 | 33 | def get_agent_mcp_config_path(self) -> Path: 34 | """Get the full path to the MCP configuration file for the Gemini agent. 35 | 36 | Returns: 37 | Path object pointing to the settings.json file in the project root. 38 | """ 39 | return self.config_folder / Path("settings.json") 40 | 41 | def get_rules_path(self) -> Path: 42 | return self.cwd / Path(DIR_NAUTEX) / self.rules_filename 43 | 44 | @property 45 | def root_gemini_path(self) -> Path: 46 | """Path to the root GEMINI.md file (with reference section).""" 47 | return self.cwd / self.rules_filename 48 | 49 | def get_root_rules_path(self) -> Path: 50 | return self.root_gemini_path 51 | 52 | @property 53 | def workflow_rules_content(self) -> str: 54 | return COMMON_WORKFLOW_PROMPT 55 | 56 | def get_rules_info(self) -> str: 57 | return f"Rules Path: {path2display(self.get_rules_path())}" 58 | 59 | def get_reference_section_content(self) -> str: 60 | return rules_reference_content_for("GEMINI.md") 61 | 62 | def get_default_rules_template(self) -> str: 63 | # Avoid relying on instance type (may be str in some contexts) 64 | return default_agents_rules_template_for("GEMINI.md", AgentType.GEMINI.display_name()) 65 | -------------------------------------------------------------------------------- /src/nautex/prompts/consts.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Commands 4 | CMD_NAUTEX_SETUP = 'uvx nautex setup' 5 | 6 | # __ is how the tool name is proclaimed via mcp lib 7 | CMD_STATUS = 'nautex__status' 8 | CMD_NEXT_SCOPE = 'nautex__next_scope' 9 | CMD_TASKS_UPDATE = 'nautex__tasks_update' 10 | 11 | # Directories 12 | DIR_NAUTEX = '.nautex' 13 | DIR_NAUTEX_DOCS = f"{DIR_NAUTEX}/docs" 14 | 15 | NAUTEX_SECTION_START = '' 16 | NAUTEX_SECTION_END = '' 17 | 18 | NAUTEX_RULES_REFERENCE_CONTENT = f"""# Nautex MCP Integration 19 | 20 | This project uses Nautex Model-Context-Protocol (MCP). Nautex manages requirements and task-driven LLM assisted development. 21 | 22 | Whenever user requests to operate with nautex, the following applies: 23 | 24 | - read full Nautex workflow guidelines from `{DIR_NAUTEX}/CLAUDE.md` 25 | - note that all paths managed by nautex are relative to the project root 26 | - note primary workflow commands: `{CMD_NEXT_SCOPE}`, `{CMD_TASKS_UPDATE}` 27 | - NEVER edit files in `{DIR_NAUTEX}` directory 28 | 29 | """ 30 | 31 | def rules_reference_content_for(rules_filename: str) -> str: 32 | """Reference section content parameterized by rules file name. 33 | 34 | Args: 35 | rules_filename: The filename stored under .nautex/ with full rules 36 | 37 | Returns: 38 | String content for the managed reference section 39 | """ 40 | return f"""# Nautex MCP Integration 41 | 42 | This project uses Nautex Model-Context-Protocol (MCP). Nautex manages requirements and task-driven LLM assisted development. 43 | 44 | Whenever user requests to operate with nautex, the following applies: 45 | 46 | - read full Nautex workflow guidelines from `{DIR_NAUTEX}/{rules_filename}` 47 | - note that all paths managed by nautex are relative to the project root 48 | - note primary workflow commands: `{CMD_NEXT_SCOPE}`, `{CMD_TASKS_UPDATE}` 49 | - NEVER edit files in `{DIR_NAUTEX}` directory 50 | 51 | """ 52 | 53 | DEFAULT_RULES_TEMPLATE = """# CLAUDE.md 54 | 55 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 56 | 57 | """ 58 | 59 | def default_agents_rules_template_for(rules_filename: str, tool_name: str) -> str: 60 | """Default root rules template for generic coding agents (AGENTS.md). 61 | 62 | Args: 63 | rules_filename: The root-facing filename such as 'AGENTS.md'. 64 | tool_name: Human-friendly tool name, e.g. 'OpenCode', 'Codex', 'Gemini'. 65 | 66 | Returns: 67 | A short Markdown template used when creating the root rules file. 68 | """ 69 | return f"""# {rules_filename} 70 | 71 | This file provides guidance to {tool_name} or similar coding agents when working with code in this repository. 72 | 73 | """ 74 | -------------------------------------------------------------------------------- /src/nautex/agent_setups/cursor.py: -------------------------------------------------------------------------------- 1 | """Cursor agent setup and configuration.""" 2 | from pathlib import Path 3 | from typing import Tuple, Optional 4 | 5 | from .base import AgentRulesStatus 6 | from .files_based_mcp import FilesBasedMCPAgentSetup 7 | from ..models.config import AgentType 8 | from ..prompts.common_workflow import COMMON_WORKFLOW_PROMPT 9 | from ..utils import path2display 10 | 11 | 12 | class CursorAgentSetup(FilesBasedMCPAgentSetup): 13 | """Cursor agent setup and configuration. 14 | 15 | This class provides Cursor-specific implementation of the agent setup interface. 16 | """ 17 | 18 | def __init__(self, config_service): 19 | """Initialize the Cursor agent setup.""" 20 | super().__init__(config_service, AgentType.CURSOR) 21 | self.config_folder = Path(".cursor") 22 | self.rules_filename = Path("nautex_workflow.mdc") 23 | self.rules_folder = self.config_folder / Path("rules") 24 | 25 | def get_agent_mcp_config_path(self) -> Path: 26 | """Get the full path to the MCP configuration file for the Cursor agent. 27 | 28 | Returns: 29 | Path object pointing to the .mcp.json file in the project root. 30 | """ 31 | return self.config_folder / Path("mcp.json") 32 | 33 | def get_rules_path(self) -> Path: 34 | return self.cwd / self.rules_folder / self.rules_filename 35 | 36 | def validate_rules(self) -> Tuple[AgentRulesStatus, Optional[Path]]: 37 | # Check if rules file exists 38 | rules_path = self.get_rules_path() 39 | if rules_path.exists(): 40 | status = self._validate_rules_file(rules_path, self.workflow_rules_content) 41 | return status, rules_path 42 | 43 | return AgentRulesStatus.NOT_FOUND, None 44 | 45 | def ensure_rules(self) -> bool: 46 | try: 47 | # Get the rules path and content 48 | rules_path = self.get_rules_path() 49 | content = self.workflow_rules_content 50 | 51 | # Ensure parent directory exists 52 | rules_path.parent.mkdir(parents=True, exist_ok=True) 53 | 54 | # Write the rules file 55 | with open(rules_path, 'w', encoding='utf-8') as f: 56 | f.write(content) 57 | 58 | return True 59 | 60 | except Exception as e: 61 | return False 62 | 63 | 64 | @property 65 | def workflow_rules_content(self) -> str: 66 | return f"""--- 67 | description: Workflow reference for Nautex MCP usage for project implementation guidance 68 | globs: **/* 69 | alwaysApply: true 70 | --- 71 | 72 | {COMMON_WORKFLOW_PROMPT} 73 | """ 74 | 75 | def get_rules_info(self) -> str: 76 | return f"Rules Path: {path2display(self.get_rules_path())}" 77 | -------------------------------------------------------------------------------- /src/nautex/services/mcp_config_service.py: -------------------------------------------------------------------------------- 1 | """MCP Configuration Service for managing IDE mcp.json integration.""" 2 | from pathlib import Path 3 | from typing import Tuple, Optional 4 | import logging 5 | import warnings 6 | import asyncio 7 | 8 | from . import ConfigurationService 9 | from ..utils.mcp_utils import MCPConfigStatus 10 | 11 | # Re-export MCPConfigStatus for backward compatibility 12 | __all__ = ['MCPConfigService', 'MCPConfigStatus'] 13 | 14 | # Set up logging 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | class MCPConfigService: 19 | """Service for managing IDE's mcp.json configuration file. 20 | 21 | This service acts as a hub that delegates to the agent_setup for the implementation. 22 | It handles checking existing MCP configurations, validating them, 23 | and writing the Nautex CLI's MCP server entry to integrate with IDE tools. 24 | """ 25 | 26 | def __init__(self, config_service: ConfigurationService): 27 | """Initialize the MCP configuration service. 28 | 29 | Args: 30 | config_service: The configuration service to use 31 | """ 32 | self.config_service = config_service 33 | 34 | @property 35 | def agent_setup(self): 36 | return self.config_service.agent_setup 37 | 38 | async def check_mcp_configuration(self) -> Tuple[MCPConfigStatus, Optional[Path]]: 39 | """Check the status of MCP configuration integration. 40 | 41 | Checks if the MCP configuration file exists and validates the 'nautex' entry against template. 42 | 43 | Returns: 44 | Tuple of (status, path_to_config_file) 45 | - MCPConfigStatus.OK: Nautex entry exists and is correctly configured 46 | - MCPConfigStatus.MISCONFIGURED: File exists but nautex entry is incorrect 47 | - MCPConfigStatus.NOT_FOUND: No MCP configuration file found or no nautex entry 48 | """ 49 | return await self.agent_setup.check_mcp_configuration() 50 | 51 | async def write_mcp_configuration(self) -> bool: 52 | """Write or update MCP configuration with Nautex CLI server entry. 53 | 54 | Reads the target MCP configuration file (or creates if not exists), adds/updates 55 | the 'nautex' server entry in mcpServers object, and saves the file. 56 | 57 | Returns: 58 | True if configuration was successfully written, False otherwise 59 | """ 60 | return await self.agent_setup.write_mcp_configuration() 61 | 62 | async def get_configuration_info(self) -> str: 63 | """Get information about the MCP configuration. 64 | 65 | Returns: 66 | String with information about the MCP configuration 67 | """ 68 | return await self.agent_setup.get_mcp_configuration_info() 69 | -------------------------------------------------------------------------------- /src/nautex/tui/widgets/info_help_dialog.py: -------------------------------------------------------------------------------- 1 | """Info and Help dialog widget for the Nautex TUI.""" 2 | 3 | from textual.widgets import Button, Markdown 4 | from textual.containers import Horizontal, Vertical, Center, Middle 5 | from textual.screen import Screen 6 | from textual import events 7 | 8 | # Dialog content as markdown 9 | HELP_CONTENT = """ 10 | # 🚀 Nautex - AI-Powered Development 11 | 12 | ## 📋 About 13 | Nautex is a requirements-first development platform that helps Coding Agents to execute better by detailed and complete system design and detailed plan. 14 | 15 | [GitHub Repository](https://github.com/hmldns/nautex) 16 | 17 | Created by [Ivan Makarov](https://x.com/ivan_mkrv) 18 | 19 | ## 💬 Join Our Community! 20 | Get help, share ideas, and connect with other developers 21 | [🎮 Join Discord Server](https://discord.gg/nautex) 22 | 23 | 24 | ## ⌨️ Keyboard Shortcuts 25 | - **Ctrl+C / ESC**: Quit 26 | - **Tab / Enter**: Navigate fields 27 | - **Ctrl+T**: MCP Config 28 | - **Ctrl+R**: Agent Rules 29 | - **Ctrl+Y**: Select Agent Type 30 | - **F1**: Show this help 31 | """ 32 | 33 | 34 | class InfoHelpDialog(Screen): 35 | """A modal screen displaying info, help, and community links.""" 36 | 37 | DEFAULT_CSS = """ 38 | InfoHelpDialog { 39 | align: center middle; 40 | } 41 | 42 | #dialog { 43 | width: 60; 44 | height: auto; 45 | max-height: 80vh; 46 | border: solid $primary; 47 | background: $surface; 48 | padding: 1 2; 49 | } 50 | 51 | #content { 52 | height: 1fr; 53 | overflow-y: auto; 54 | margin-bottom: 1; 55 | } 56 | 57 | #buttons { 58 | height: 3; 59 | align: center middle; 60 | margin-top: 1; 61 | } 62 | 63 | Button { 64 | margin: 0 1; 65 | min-width: 10; 66 | } 67 | """ 68 | 69 | def __init__(self, **kwargs): 70 | super().__init__(**kwargs) 71 | 72 | def compose(self): 73 | """Compose the dialog layout.""" 74 | with Center(): 75 | with Middle(): 76 | with Vertical(id="dialog"): 77 | yield Markdown(HELP_CONTENT, id="content") 78 | 79 | with Horizontal(id="buttons"): 80 | yield Button("Close", id="close", variant="primary") 81 | 82 | def on_button_pressed(self, event: Button.Pressed) -> None: 83 | """Handle button press events.""" 84 | if event.button.id == "close": 85 | self.dismiss() 86 | 87 | def on_key(self, event: events.Key) -> None: 88 | """Handle key events.""" 89 | if event.key in ("escape", "f1"): 90 | event.stop() 91 | self.dismiss() 92 | elif event.key == "enter": 93 | self.dismiss() -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=64.0", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "nautex" 7 | version = "0.2.25" 8 | description = "Nautex AI MCP server that works as Product and Project manager for coding agents" 9 | readme = "README.md" 10 | requires-python = ">=3.10,<3.11" 11 | license = "MIT" 12 | authors = [ 13 | {name = "Ivan Makarov", email = "ivan@nautex.ai"} 14 | ] 15 | classifiers = [ 16 | "Development Status :: 4 - Beta", 17 | "Intended Audience :: Developers", 18 | "Environment :: Console", 19 | "Programming Language :: Python :: 3.10", 20 | "Topic :: Software Development :: Code Generators", 21 | "Topic :: Utilities", 22 | "Operating System :: Microsoft :: Windows", 23 | "Operating System :: POSIX" 24 | ] 25 | 26 | dependencies = [ 27 | "pydantic>=2.0.0,<3.0.0", 28 | "pydantic-settings>=2.0.0,<3.0.0", 29 | "aiohttp>=3.9.0,<4.0.0", 30 | "textual>=3.0.0,<5.0.0", 31 | "fastmcp>=2.13.0", 32 | "python-dotenv>=1.0.0,<2.0.0", 33 | "aiofiles>=20.0.0", 34 | "tomlkit>=0.13.0", 35 | "tomli>=2.0.1" 36 | ] 37 | 38 | [project.optional-dependencies] 39 | dev = [ 40 | "flake8>=6.0.0", 41 | "mypy>=1.5.0", 42 | "black>=23.0.0", 43 | "isort>=5.12.0", 44 | "build>=0.10.0", 45 | "twine>=4.0.0", 46 | ] 47 | 48 | [project.scripts] 49 | nautex = "nautex.cli:main" 50 | 51 | [project.urls] 52 | Homepage = "https://github.com/hmldns/nautex" 53 | Repository = "https://github.com/hmldns/nautex" 54 | Documentation = "https://github.com/hmldns/nautex#readme" 55 | "Bug Reports" = "https://github.com/hmldns/nautex/issues" 56 | 57 | [tool.setuptools.packages.find] 58 | where = ["src"] 59 | 60 | [tool.setuptools.package-dir] 61 | "" = "src" 62 | 63 | [tool.black] 64 | line-length = 88 65 | target-version = ['py310'] 66 | include = '\.pyi?$' 67 | extend-exclude = ''' 68 | /( 69 | # directories 70 | \.eggs 71 | | \.git 72 | | \.hg 73 | | \.mypy_cache 74 | | \.tox 75 | | \.venv 76 | | build 77 | | dist 78 | )/ 79 | ''' 80 | 81 | [tool.isort] 82 | profile = "black" 83 | multi_line_output = 3 84 | line_length = 88 85 | known_first_party = ["nautex"] 86 | 87 | [tool.mypy] 88 | python_version = "3.10" 89 | warn_return_any = true 90 | warn_unused_configs = true 91 | disallow_untyped_defs = true 92 | disallow_incomplete_defs = true 93 | check_untyped_defs = true 94 | disallow_untyped_decorators = true 95 | no_implicit_optional = true 96 | warn_redundant_casts = true 97 | warn_unused_ignores = true 98 | warn_no_return = true 99 | warn_unreachable = true 100 | strict_equality = true 101 | 102 | [[tool.mypy.overrides]] 103 | module = "textual.*" 104 | ignore_missing_imports = true 105 | -------------------------------------------------------------------------------- /src/nautex/utils/mcp_toml_utils.py: -------------------------------------------------------------------------------- 1 | """Utility functions for MCP configuration in TOML format (for Codex). 2 | 3 | Strict TOML parsing via `tomli` (Python 3.10). 4 | """ 5 | 6 | from pathlib import Path 7 | from typing import Any, Dict 8 | import logging 9 | import tomli 10 | 11 | from .mcp_utils import MCPConfigStatus 12 | import tomlkit 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | def _toml_load(path: Path) -> Dict[str, Any]: 18 | try: 19 | with open(path, "rb") as f: 20 | return tomli.load(f) 21 | except Exception as e: 22 | logger.error(f"Error reading/parsing TOML at {path}: {e}") 23 | raise 24 | 25 | 26 | def _toml_dump(data: Dict[str, Any]) -> str: 27 | # Use tomlkit to serialize, it handles quoting and formatting correctly 28 | return tomlkit.dumps(data) 29 | 30 | 31 | def validate_mcp_toml_file(mcp_path: Path, cwd: Path | None = None) -> MCPConfigStatus: 32 | try: 33 | if not mcp_path.exists(): 34 | return MCPConfigStatus.NOT_FOUND 35 | 36 | config = _toml_load(mcp_path) 37 | 38 | if not isinstance(config, dict) or "mcp_servers" not in config: 39 | return MCPConfigStatus.NOT_FOUND 40 | 41 | servers = config.get("mcp_servers") 42 | if not isinstance(servers, dict): 43 | return MCPConfigStatus.MISCONFIGURED 44 | 45 | nautex = servers.get("nautex") 46 | if not isinstance(nautex, dict): 47 | return MCPConfigStatus.NOT_FOUND 48 | 49 | if nautex.get("command") != "uvx": 50 | return MCPConfigStatus.MISCONFIGURED 51 | if nautex.get("args") != ["nautex", "mcp"]: 52 | return MCPConfigStatus.MISCONFIGURED 53 | 54 | return MCPConfigStatus.OK 55 | except Exception as e: 56 | logger.error(f"Error validating TOML MCP file: {e}") 57 | return MCPConfigStatus.MISCONFIGURED 58 | 59 | 60 | def write_mcp_toml_configuration(target_path: Path, cwd: Path | None = None) -> bool: 61 | try: 62 | target_path.parent.mkdir(parents=True, exist_ok=True) 63 | 64 | base: Dict[str, Any] = {} 65 | if target_path.exists(): 66 | base = _toml_load(target_path) 67 | if not isinstance(base, dict): 68 | base = {} 69 | 70 | # Ensure correct top-level table for Codex 71 | if "mcp_servers" not in base or not isinstance(base.get("mcp_servers"), dict): 72 | base["mcp_servers"] = {} 73 | 74 | nautex_entry: Dict[str, Any] = { 75 | "command": "uvx", 76 | "args": ["nautex", "mcp"], 77 | } 78 | # env table is optional; omit by default 79 | 80 | base["mcp_servers"]["nautex"] = nautex_entry 81 | 82 | toml_text = _toml_dump(base) 83 | with open(target_path, "w", encoding="utf-8") as f: 84 | f.write(toml_text) 85 | logger.info(f"Successfully wrote Nautex MCP TOML configuration to {target_path}") 86 | return True 87 | except Exception as e: 88 | logger.error(f"Failed to write TOML MCP configuration: {e}") 89 | return False 90 | -------------------------------------------------------------------------------- /src/nautex/services/ui_service.py: -------------------------------------------------------------------------------- 1 | """UI Service for managing TUI applications and interactions.""" 2 | 3 | from typing import Optional 4 | from pathlib import Path 5 | 6 | from ..services.config_service import ConfigurationService 7 | from ..services.integration_status_service import IntegrationStatusService 8 | from ..services.nautex_api_service import NautexAPIService 9 | from ..models.plan_context import PlanContext 10 | from ..tui.screens import SetupApp 11 | 12 | 13 | class UIService: 14 | """Service for managing TUI operations and screen orchestration.""" 15 | 16 | def __init__( 17 | self, 18 | config_service: ConfigurationService, 19 | integration_status_service: IntegrationStatusService, 20 | api_service: NautexAPIService, 21 | mcp_config_service=None, 22 | agent_rules_service=None, 23 | ): 24 | """Initialize the UI service. 25 | 26 | Args: 27 | config_service: Service for configuration management 28 | integration_status_service: Service for integration status management 29 | api_service: Service for API interactions 30 | mcp_config_service: Service for MCP configuration management 31 | agent_rules_service: Service for agent rules management 32 | """ 33 | self.config_service = config_service 34 | self.integration_status_service = integration_status_service 35 | self.api_service = api_service 36 | self.mcp_config_service = mcp_config_service 37 | self.agent_rules_service = agent_rules_service 38 | 39 | async def handle_setup_command(self) -> None: 40 | """Handle the setup command by launching the interactive SetupScreen TUI. 41 | 42 | This method creates the SetupApp with all necessary services and runs it. 43 | The SetupApp will handle the full setup flow including: 44 | - Token input and validation 45 | - Agent name configuration 46 | - Project/plan selection 47 | - Configuration saving 48 | - MCP configuration check 49 | """ 50 | try: 51 | # Create the setup app with the necessary services 52 | app = SetupApp( 53 | config_service=self.config_service, 54 | integration_status_service=self.integration_status_service, 55 | api_service=self.api_service, 56 | mcp_config_service=self.mcp_config_service, 57 | agent_rules_service=self.agent_rules_service 58 | ) 59 | await app.run_async() 60 | 61 | except Exception as e: 62 | # If the TUI fails, fall back to a simple error message 63 | print(f"Setup failed: {e}") 64 | print("Please check your configuration and try again.") 65 | 66 | finally: 67 | # Ensure API client is closed even if an exception occurs 68 | # This prevents "Unclosed client session" errors when the app is terminated 69 | self.integration_status_service.stop_polling() 70 | await self.api_service.api_client.close() 71 | 72 | async def handle_status_command(self, noui: bool = False) -> None: 73 | print("Status Screen: Under development") 74 | -------------------------------------------------------------------------------- /src/nautex/models/config.py: -------------------------------------------------------------------------------- 1 | """Pydantic models for configuration management.""" 2 | from enum import Enum 3 | from pathlib import Path 4 | from typing import Optional, Dict, List 5 | from pydantic import SecretStr, Field 6 | from pydantic_settings import BaseSettings 7 | 8 | 9 | class AgentType(str, Enum): 10 | """Supported agent types. 11 | 12 | Used to identify the type of agent to use. 13 | """ 14 | NOT_SELECTED = "not_selected" 15 | CURSOR = "cursor" 16 | CLAUDE = "claude" 17 | CODEX = "codex" 18 | OPENCODE = "opencode" 19 | GEMINI = "gemini" 20 | 21 | @classmethod 22 | def list(cls) -> List['AgentType']: 23 | """Get a list of all supported agent types. 24 | 25 | Returns: 26 | List of agent type values as strings. 27 | """ 28 | return [agent_type for agent_type in cls] 29 | 30 | def display_name(self) -> str: 31 | 32 | if self == AgentType.NOT_SELECTED: 33 | return "Not Selected" 34 | elif self == AgentType.CURSOR: 35 | return "Cursor" 36 | elif self == AgentType.CLAUDE: 37 | return "Claude Code" 38 | elif self == AgentType.CODEX: 39 | return "Codex" 40 | elif self == AgentType.OPENCODE: 41 | return "OpenCode" 42 | elif self == AgentType.GEMINI: 43 | return "Gemini" 44 | return self.value.title() 45 | 46 | 47 | class NautexConfig(BaseSettings): 48 | """Main configuration model using pydantic-settings for .env support. 49 | 50 | This model manages all configuration settings for the Nautex CLI, 51 | supporting both JSON file storage and environment variable overrides. 52 | """ 53 | api_host: str = Field("https://api.nautex.ai", description="Base URL for the Nautex.ai API") 54 | api_token: Optional[SecretStr] = Field(None, description="Bearer token for Nautex.ai API authentication") 55 | 56 | agent_instance_name: str = Field("Coding Agent", description="User-defined name for this CLI instance") 57 | project_id: Optional[str] = Field(None, description="Selected Nautex.ai project ID") 58 | plan_id: Optional[str] = Field(None, description="Selected implementation plan ID") 59 | documents_path: Optional[str] = Field(None, description="Path to store downloaded documents") 60 | 61 | agent_type: Optional[AgentType] = Field(AgentType.NOT_SELECTED, description="AI agent to guide") 62 | 63 | class Config: 64 | """Pydantic configuration for environment variables and JSON files.""" 65 | env_file = [] # we got custom loading calls 66 | env_file_encoding = "utf-8" 67 | env_prefix = "NAUTEX_" # Environment variables should be prefixed with NAUTEX_k 68 | case_sensitive = False 69 | extra = "ignore" # Ignore extra environment variables that don't match our model 70 | 71 | 72 | def get_token(self): 73 | """Get the API token from the config.""" 74 | return self.api_token.get_secret_value() if self.api_token else None 75 | 76 | 77 | def to_config_dict(self) -> Dict: 78 | return self.model_dump(exclude_none=True, 79 | exclude={"api_host", "api_token"} # don't serializing these 2 80 | ) 81 | @property 82 | def agent_type_selected(self) -> bool: 83 | return self.agent_type != AgentType.NOT_SELECTED 84 | -------------------------------------------------------------------------------- /src/nautex/tui/widgets/integration_status.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from textual.widgets import Static, Button, Digits 4 | from textual.containers import Horizontal, HorizontalGroup 5 | 6 | 7 | from ...models.integration_status import IntegrationStatus 8 | 9 | 10 | class StatusDisplay(Static): 11 | """A read-only display for a single status item.""" 12 | 13 | DEFAULT_CSS = """ 14 | StatusDisplay { 15 | height: auto; 16 | width: auto; 17 | margin: 0 1 0 0; 18 | padding: 0; 19 | min-width: 10; 20 | } 21 | """ 22 | 23 | def __init__(self, label: str, status: bool = False, **kwargs): 24 | """Initialize status display. 25 | 26 | Args: 27 | label: The label text 28 | status: The status indicator (emoji) 29 | """ 30 | self.label_text = label 31 | self.status_flag = status 32 | super().__init__(self._disp_render(), **kwargs) 33 | 34 | 35 | def set_status(self, flag: bool): 36 | self.status_flag = flag 37 | 38 | def _disp_render_status(self) -> str: 39 | if self.status_flag is not None: 40 | return "✅" if self.status_flag else "⚠️" 41 | else: 42 | return "❓" 43 | 44 | def _disp_render(self) -> str: 45 | return f"{self._disp_render_status()} {self.label_text}" 46 | 47 | def update_status(self, status_flag: Optional[bool]) -> None: 48 | self.set_status(status_flag) 49 | self.update(self._disp_render()) 50 | 51 | 52 | class IntegrationStatusPanel(HorizontalGroup): 53 | """A horizontal strip of StatusDisplay widgets for integration status.""" 54 | # 55 | DEFAULT_CSS = """ 56 | IntegrationStatusPanel { 57 | width: 1fr; 58 | height: auto; 59 | width: 100%; 60 | border: solid $primary; 61 | } 62 | 63 | IntegrationStatusPanel StatusDisplay { 64 | height: auto; 65 | margin: 0 1 0 0; 66 | padding: 1 1; 67 | } 68 | """ 69 | 70 | def __init__(self, **kwargs): 71 | super().__init__(**kwargs) 72 | 73 | self.status_network = StatusDisplay("Connection") 74 | self.status_api = StatusDisplay("API") 75 | self.status_project = StatusDisplay("Project") 76 | self.status_plan = StatusDisplay("Plan") 77 | self.status_agent_type = StatusDisplay("Agent Type") 78 | self.status_mcp = StatusDisplay("MCP Config") 79 | self.agent_rules = StatusDisplay("Agent Rules") 80 | 81 | self.border_title = "Integration Status" 82 | 83 | def compose(self): 84 | """Compose the status panel layout.""" 85 | yield self.status_network 86 | yield self.status_api 87 | yield self.status_project 88 | yield self.status_plan 89 | yield self.status_agent_type 90 | yield self.status_mcp 91 | yield self.agent_rules 92 | 93 | def update_data(self, integration_status: IntegrationStatus) -> None: 94 | # Network status 95 | 96 | self.status_network.update_status(integration_status.network_connected) 97 | self.status_api.update_status(integration_status.api_connected) 98 | self.status_project.update_status(integration_status.project_selected) 99 | self.status_plan.update_status(integration_status.plan_selected) 100 | self.status_agent_type.update_status(integration_status.agent_type_selected) 101 | self.status_mcp.update_status(integration_status.mcp_config_set) 102 | self.agent_rules.update_status(integration_status.agent_rules_set) 103 | -------------------------------------------------------------------------------- /src/nautex/agent_setups/files_based_mcp.py: -------------------------------------------------------------------------------- 1 | """Files-based MCP agent setup and configuration.""" 2 | from abc import abstractmethod 3 | from pathlib import Path 4 | from typing import Optional, Tuple 5 | import asyncio 6 | 7 | from .base import AgentSetupBase 8 | from ..utils.mcp_utils import MCPConfigStatus, validate_mcp_file, write_mcp_configuration 9 | import logging 10 | 11 | # Set up logging 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | class FilesBasedMCPAgentSetup(AgentSetupBase): 16 | """Base class for file-based MCP agent setup and configuration. 17 | 18 | This class provides a file-based implementation of the MCP configuration methods. 19 | It is intended to be used by agent setups that store MCP configuration in files. 20 | """ 21 | 22 | @abstractmethod 23 | def get_agent_mcp_config_path(self) -> Optional[Path]: 24 | """Get the full path to the MCP configuration file for the agent type. 25 | 26 | Returns: 27 | Path object pointing to the MCP configuration file (e.g., .mcp.json in project root). 28 | 29 | Raises: 30 | ValueError: If the agent type is not supported. 31 | """ 32 | raise NotImplementedError() 33 | 34 | def get_config_path(self) -> Path: 35 | return self.config_service.cwd / self.get_agent_mcp_config_path() 36 | 37 | async def get_mcp_configuration_info(self) -> str: 38 | """Get information about the MCP configuration. 39 | 40 | Returns: 41 | String with information about the MCP configuration path 42 | """ 43 | return f"MCP Configuration Path: {self.get_agent_mcp_config_path()}" 44 | 45 | async def check_mcp_configuration(self) -> Tuple[MCPConfigStatus, Optional[Path]]: 46 | """Check the status of MCP configuration integration. 47 | 48 | Checks if the MCP configuration file exists and validates the 'nautex' entry against template. 49 | 50 | Returns: 51 | Tuple of (status, path_to_config_file) 52 | - MCPConfigStatus.OK: Nautex entry exists and is correctly configured 53 | - MCPConfigStatus.MISCONFIGURED: File exists but nautex entry is incorrect 54 | - MCPConfigStatus.NOT_FOUND: No MCP configuration file found or no nautex entry 55 | """ 56 | # Get the MCP configuration path 57 | mcp_path = self.get_agent_mcp_config_path() 58 | 59 | # Use asyncio.to_thread to run the file operations in a separate thread 60 | if mcp_path is not None: 61 | path_exists = await asyncio.to_thread(lambda: (self.cwd / mcp_path).exists()) 62 | if path_exists: 63 | # Pass the current working directory for cwd validation 64 | status = await asyncio.to_thread(validate_mcp_file, self.cwd / mcp_path, self.cwd) 65 | return status, self.cwd / mcp_path 66 | 67 | # No MCP configuration file found 68 | logger.debug(f"No MCP configuration file found at {mcp_path}") 69 | return MCPConfigStatus.NOT_FOUND, None 70 | 71 | async def write_mcp_configuration(self) -> bool: 72 | """Write or update MCP configuration with Nautex CLI server entry. 73 | 74 | Reads the target MCP configuration file (or creates if not exists), adds/updates 75 | the 'nautex' server entry in mcpServers object, and saves the file. 76 | 77 | The current working directory (cwd) is added to the configuration. 78 | 79 | Returns: 80 | True if configuration was successfully written, False otherwise 81 | """ 82 | # Get the MCP configuration path 83 | target_path = self.cwd / self.get_agent_mcp_config_path() 84 | # Pass the current working directory to be added to the configuration 85 | return await asyncio.to_thread(write_mcp_configuration, target_path, self.cwd) -------------------------------------------------------------------------------- /src/nautex/agent_setups/section_managed_rules_mixin.py: -------------------------------------------------------------------------------- 1 | """Mixin providing DRY validation/ensuring of section-managed rules files. 2 | 3 | This mixin encapsulates the shared logic used by agents that: 4 | - store full workflow rules in a file under `.nautex/` 5 | - maintain a root-level file with a managed reference section 6 | 7 | Requirements for the consumer class: 8 | - must provide `section_service` (SectionManagedFileService) 9 | - must implement: 10 | - get_rules_path() -> Path 11 | - get_root_rules_path() -> Path 12 | - workflow_rules_content -> str (property) 13 | - get_reference_section_content() -> str 14 | - get_default_rules_template() -> str 15 | - must inherit from AgentSetupBase (for _validate_rules_file and cwd) 16 | """ 17 | from pathlib import Path 18 | from typing import Tuple, Optional 19 | 20 | from .base import AgentSetupBase, AgentRulesStatus 21 | from ..prompts.consts import NAUTEX_SECTION_START, NAUTEX_SECTION_END 22 | 23 | 24 | class SectionManagedRulesMixin: 25 | def get_rules_path(self) -> Path: # pragma: no cover - abstract expectation 26 | raise NotImplementedError 27 | 28 | def get_root_rules_path(self) -> Path: # pragma: no cover - abstract expectation 29 | raise NotImplementedError 30 | 31 | @property 32 | def workflow_rules_content(self) -> str: # pragma: no cover - abstract expectation 33 | raise NotImplementedError 34 | 35 | def get_reference_section_content(self) -> str: # pragma: no cover - abstract expectation 36 | raise NotImplementedError 37 | 38 | def get_default_rules_template(self) -> str: # pragma: no cover - abstract expectation 39 | raise NotImplementedError 40 | 41 | def validate_rules(self) -> Tuple[AgentRulesStatus, Optional[Path]]: 42 | rules_path = self.get_rules_path() 43 | 44 | if not rules_path.exists(): 45 | return AgentRulesStatus.NOT_FOUND, None 46 | 47 | status = self._validate_rules_file(rules_path, self.workflow_rules_content) 48 | if status != AgentRulesStatus.OK: 49 | return status, rules_path 50 | 51 | root_path = self.get_root_rules_path() 52 | if not root_path.exists(): 53 | return AgentRulesStatus.OUTDATED, rules_path 54 | 55 | current_content = root_path.read_text(encoding="utf-8") 56 | section_bounds = self.section_service.find_section_boundaries(current_content) 57 | if not section_bounds: 58 | return AgentRulesStatus.OUTDATED, rules_path 59 | 60 | start, end = section_bounds 61 | current_section = current_content[start:end] 62 | expected_section = f"{NAUTEX_SECTION_START}\n\n{self.get_reference_section_content().strip()}\n\n{NAUTEX_SECTION_END}" 63 | if current_section.strip() != expected_section.strip(): 64 | return AgentRulesStatus.OUTDATED, rules_path 65 | 66 | return AgentRulesStatus.OK, rules_path 67 | 68 | def ensure_rules(self) -> bool: 69 | try: 70 | status, _ = self.validate_rules() 71 | if status == AgentRulesStatus.OK: 72 | return True 73 | 74 | # Write full rules under .nautex 75 | rules_path = self.get_rules_path() 76 | rules_path.parent.mkdir(parents=True, exist_ok=True) 77 | rules_path.write_text(self.workflow_rules_content, encoding="utf-8") 78 | 79 | # Ensure managed section exists/updated at root file 80 | root_path = self.get_root_rules_path() 81 | self.section_service.ensure_file_with_section( 82 | root_path, 83 | self.get_reference_section_content(), 84 | self.get_default_rules_template(), 85 | ) 86 | 87 | final_status, _ = self.validate_rules() 88 | return final_status == AgentRulesStatus.OK 89 | except Exception: 90 | return False 91 | 92 | -------------------------------------------------------------------------------- /src/nautex/models/integration_status.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from pathlib import Path 3 | from typing import Optional, Dict, Any 4 | 5 | from .config import NautexConfig 6 | from ..api.api_models import AccountInfo, ImplementationPlan 7 | from ..prompts.consts import CMD_NAUTEX_SETUP 8 | from ..utils.mcp_utils import MCPConfigStatus 9 | from ..agent_setups.base import AgentRulesStatus 10 | 11 | 12 | @dataclass(kw_only=True) 13 | class IntegrationStatus: 14 | """Data class representing current integration status.""" 15 | 16 | @property 17 | def config_loaded(self): 18 | return bool(self.config) 19 | 20 | config: Optional[NautexConfig] = None 21 | 22 | # Network connectivity status 23 | network_connected: bool = False 24 | network_response_time: Optional[float] = None 25 | network_error: Optional[str] = None 26 | 27 | # API connectivity status 28 | api_connected: bool = False 29 | account_info: Optional[AccountInfo] = None 30 | 31 | # MCP configuration status 32 | mcp_status: MCPConfigStatus = MCPConfigStatus.NOT_FOUND 33 | mcp_config_path: Optional[Path] = None 34 | 35 | # Agent rules status 36 | agent_rules_status: AgentRulesStatus = AgentRulesStatus.NOT_FOUND 37 | agent_rules_path: Optional[Path] = None 38 | 39 | implementation_plan: Optional[ImplementationPlan] = None 40 | 41 | @property 42 | def project_selected(self): 43 | return self.config and self.config.project_id 44 | 45 | @property 46 | def plan_selected(self): 47 | return self.config and self.config.plan_id 48 | 49 | @property 50 | def agent_type_selected(self): 51 | return self.config.agent_type_selected 52 | 53 | @property 54 | def mcp_config_set(self): 55 | return self.mcp_status == MCPConfigStatus.OK 56 | 57 | @property 58 | def agent_rules_set(self): 59 | return self.agent_rules_status == AgentRulesStatus.OK 60 | 61 | @property 62 | def integration_ready(self) -> bool: 63 | """Returns True if all integration checks pass.""" 64 | return all([ 65 | self.config_loaded, 66 | self.network_connected, 67 | self.api_connected, 68 | self.project_selected, 69 | self.plan_selected, 70 | self.agent_type_selected, 71 | self.mcp_config_set, 72 | self.agent_rules_set, 73 | ]) 74 | 75 | 76 | def get_status_message(self, from_mcp: bool = False) -> str: 77 | """Returns a status message based on the first failed check. 78 | 79 | Args: 80 | from_mcp: If True, adds CMD_NAUTEX_SETUP suggestion at the beginning. 81 | If False, provides original UI-specific actions. 82 | """ 83 | mcp_prefix = f"Run '{CMD_NAUTEX_SETUP}' to configure, then " if from_mcp else "" 84 | 85 | if not self.config_loaded: 86 | return f"Configuration not found - run '{CMD_NAUTEX_SETUP}'" 87 | if not self.network_connected: 88 | return f"Network connectivity failed - {mcp_prefix}check internet connection or Host URL" 89 | if not self.api_connected: 90 | return f"API connectivity failed - {mcp_prefix}check token" 91 | if not self.project_selected: 92 | return f"Project not selected - {mcp_prefix}select project from list" 93 | if not self.plan_selected: 94 | return f"Implementation plan not selected - {mcp_prefix}select plan in list" 95 | 96 | if not self.agent_type_selected: 97 | return f"Agent type not selected - {mcp_prefix}press 'Ctrl+Y' to select agent type" 98 | 99 | if not self.mcp_config_set: 100 | return f"MCP configuration needed - {mcp_prefix}press 'Ctrl+T' to configure MCP integration" 101 | 102 | if not self.agent_rules_set: 103 | return f"Agent rules needed - {mcp_prefix}press 'Ctrl+R' to configure agent workflow rules" 104 | 105 | return f"Fully integrated and ready to work" 106 | -------------------------------------------------------------------------------- /src/nautex/utils/opencode_config_utils.py: -------------------------------------------------------------------------------- 1 | """Utility functions for OpenCode JSON/JSONC configuration. 2 | 3 | We operate on the per-project `opencode.json` (JSON) file when possible. 4 | If the file cannot be parsed as JSON (eg. JSONC with comments), we fall back 5 | to creating or updating a minimal JSON file and writing a backup alongside. 6 | """ 7 | 8 | from __future__ import annotations 9 | 10 | import json 11 | from pathlib import Path 12 | from typing import Any, Dict, Tuple 13 | import logging 14 | 15 | from .mcp_utils import MCPConfigStatus 16 | 17 | logger = logging.getLogger(__name__) 18 | 19 | 20 | REQUIRED_NAUTEX_MCP = { 21 | "type": "local", 22 | "command": ["uvx", "nautex", "mcp"], 23 | "enabled": True, 24 | } 25 | 26 | 27 | def _load_json_or_none(path: Path) -> Dict[str, Any] | None: 28 | try: 29 | with open(path, "r", encoding="utf-8") as f: 30 | return json.load(f) 31 | except Exception as e: 32 | logger.warning(f"Unable to parse JSON at {path}: {e}") 33 | return None 34 | 35 | 36 | def validate_opencode_config_file(config_path: Path) -> MCPConfigStatus: 37 | """Validate opencode config for the required Nautex MCP server entry. 38 | 39 | Rules per https://opencode.ai/docs/mcp-servers/: 40 | - under key `mcp`, ensure key `nautex` is an object 41 | - it must have `type` == 'local' 42 | - and `command` == ["uvx", "nautex", "mcp"] 43 | - `enabled` may be true (recommended) but is optional for validation if present it should be truthy 44 | """ 45 | if not config_path.exists(): 46 | return MCPConfigStatus.NOT_FOUND 47 | 48 | data = _load_json_or_none(config_path) 49 | if data is None: 50 | return MCPConfigStatus.MISCONFIGURED 51 | 52 | mcp = data.get("mcp") 53 | if not isinstance(mcp, dict): 54 | return MCPConfigStatus.NOT_FOUND 55 | 56 | nautex = mcp.get("nautex") 57 | if not isinstance(nautex, dict): 58 | return MCPConfigStatus.NOT_FOUND 59 | 60 | if nautex.get("type") != REQUIRED_NAUTEX_MCP["type"]: 61 | return MCPConfigStatus.MISCONFIGURED 62 | if nautex.get("command") != REQUIRED_NAUTEX_MCP["command"]: 63 | return MCPConfigStatus.MISCONFIGURED 64 | 65 | return MCPConfigStatus.OK 66 | 67 | 68 | def write_opencode_config(config_path: Path) -> bool: 69 | """Write or update opencode config with a Nautex MCP server entry. 70 | 71 | - Reads existing JSON if possible and preserves unrelated fields 72 | - Otherwise writes a minimal valid JSON with `$schema` and `mcp.nautex` 73 | - Creates a one-time `.bak` backup if overwriting a non-empty, unparsable file 74 | """ 75 | try: 76 | config_path.parent.mkdir(parents=True, exist_ok=True) 77 | 78 | data: Dict[str, Any] | None = None 79 | if config_path.exists(): 80 | data = _load_json_or_none(config_path) 81 | 82 | backup_needed = config_path.exists() and data is None 83 | if backup_needed: 84 | bak = config_path.with_suffix(config_path.suffix + ".bak") 85 | try: 86 | if not bak.exists(): 87 | bak.write_text(config_path.read_text(encoding="utf-8"), encoding="utf-8") 88 | except Exception: 89 | # best-effort backup 90 | pass 91 | 92 | if data is None or not isinstance(data, dict): 93 | data = {"$schema": "https://opencode.ai/config.json"} 94 | 95 | # Ensure mcp dict exists 96 | mcp = data.get("mcp") 97 | if not isinstance(mcp, dict): 98 | mcp = {} 99 | data["mcp"] = mcp 100 | 101 | # Upsert nautex entry 102 | mcp["nautex"] = REQUIRED_NAUTEX_MCP.copy() 103 | 104 | with open(config_path, "w", encoding="utf-8") as f: 105 | json.dump(data, f, indent=2, ensure_ascii=False) 106 | return True 107 | except Exception as e: 108 | logger.error(f"Failed to write opencode config at {config_path}: {e}") 109 | return False 110 | 111 | -------------------------------------------------------------------------------- /src/nautex/agent_setups/opencode.py: -------------------------------------------------------------------------------- 1 | """OpenCode agent setup and configuration. 2 | 3 | Implements: 4 | - MCP server integration via per-project `opencode.json` (JSON/JSONC compatible write) 5 | - Managed rules: full content under `.nautex/AGENTS.md` and a managed section in root `AGENTS.md` 6 | """ 7 | 8 | from __future__ import annotations 9 | 10 | from pathlib import Path 11 | from typing import Tuple, Optional 12 | import asyncio 13 | 14 | from .base import AgentSetupBase 15 | from .section_managed_rules_mixin import SectionManagedRulesMixin 16 | from ..models.config import AgentType 17 | from ..prompts.common_workflow import COMMON_WORKFLOW_PROMPT 18 | from ..prompts.consts import ( 19 | NAUTEX_SECTION_START, 20 | NAUTEX_SECTION_END, 21 | rules_reference_content_for, 22 | default_agents_rules_template_for, 23 | DIR_NAUTEX, 24 | ) 25 | from ..services.section_managed_file_service import SectionManagedFileService 26 | from ..utils import path2display 27 | from ..utils.opencode_config_utils import ( 28 | validate_opencode_config_file, 29 | write_opencode_config, 30 | ) 31 | from ..utils.mcp_utils import MCPConfigStatus 32 | 33 | 34 | class OpenCodeAgentSetup(SectionManagedRulesMixin, AgentSetupBase): 35 | """OpenCode agent setup and configuration.""" 36 | 37 | def __init__(self, config_service): 38 | super().__init__(config_service, AgentType.OPENCODE) 39 | self.section_service = SectionManagedFileService(NAUTEX_SECTION_START, NAUTEX_SECTION_END) 40 | 41 | # ---------- MCP configuration (project opencode.json) ---------- 42 | def get_agent_mcp_config_path(self) -> Path: 43 | """Prefer per-project config `opencode.json` in project root.""" 44 | return Path("opencode.json") 45 | 46 | def get_global_mcp_config_path(self) -> Path: 47 | """Global OpenCode config path per docs.""" 48 | return Path.home() / ".config" / "opencode" / "opencode.json" 49 | 50 | async def get_mcp_configuration_info(self) -> str: 51 | local = self.cwd / self.get_agent_mcp_config_path() 52 | glob = self.get_global_mcp_config_path() 53 | return ( 54 | f"OpenCode config (project): {local}\n" 55 | f"OpenCode config (global): {glob}\n" 56 | f"Writes project file; considers global for read-only checks" 57 | ) 58 | 59 | async def check_mcp_configuration(self) -> Tuple[MCPConfigStatus, Optional[Path]]: 60 | # Check project config first 61 | local_path = self.cwd / self.get_agent_mcp_config_path() 62 | if await asyncio.to_thread(lambda: local_path.exists()): 63 | status = await asyncio.to_thread(validate_opencode_config_file, local_path) 64 | return status, local_path 65 | 66 | # Fallback to global for status info 67 | global_path = self.get_global_mcp_config_path() 68 | if await asyncio.to_thread(lambda: global_path.exists()): 69 | status = await asyncio.to_thread(validate_opencode_config_file, global_path) 70 | return status, global_path 71 | 72 | return MCPConfigStatus.NOT_FOUND, None 73 | 74 | async def write_mcp_configuration(self) -> bool: 75 | # Always write/update the per-project config file 76 | target = self.cwd / self.get_agent_mcp_config_path() 77 | return await asyncio.to_thread(write_opencode_config, target) 78 | 79 | # ---------- Rules management ---------- 80 | def get_rules_path(self) -> Path: 81 | return self.cwd / DIR_NAUTEX / "AGENTS.md" 82 | 83 | def get_root_rules_path(self) -> Path: 84 | return self.cwd / "AGENTS.md" 85 | 86 | @property 87 | def workflow_rules_content(self) -> str: 88 | return COMMON_WORKFLOW_PROMPT 89 | 90 | def get_rules_info(self) -> str: 91 | return f"Rules Path: {path2display(self.get_rules_path())}" 92 | 93 | def get_reference_section_content(self) -> str: 94 | return rules_reference_content_for("AGENTS.md") 95 | 96 | def get_default_rules_template(self) -> str: 97 | # Avoid relying on instance type (may be str in some contexts) 98 | return default_agents_rules_template_for("AGENTS.md", AgentType.OPENCODE.display_name()) 99 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: help install install-dev lint format check test build publish clean run-cli run-setup run-status run-mcp 2 | 3 | # Default target 4 | help: 5 | @echo "Available targets:" 6 | @echo " setup Run the complete setup (recommended)" 7 | @echo " venv Create a virtual environment" 8 | @echo " install-dev Install the package in development mode with dev dependencies" 9 | @echo " install Install the package in normal mode" 10 | @echo " install-wheel Install built wheel for testing" 11 | @echo " install-wheel-user Install built wheel for current user" 12 | @echo " install-wheel-global Install built wheel globally (requires sudo)" 13 | @echo " uninstall-global Uninstall package globally" 14 | @echo " freeze Generate requirements.txt from current environment" 15 | @echo " reinstall-global Reinstall package globally" 16 | @echo " run-cli Run CLI without installation (shows help)" 17 | @echo " run-setup Run setup command without installation" 18 | @echo " run-status Run status command without installation" 19 | @echo " run-mcp Run MCP server without installation" 20 | @echo " run-mcp-inspector Run MCP inspector without authentication" 21 | @echo " lint Run linters (flake8, mypy)" 22 | @echo " format Format code with black and isort" 23 | @echo " check Run format check without modifying files" 24 | @echo " test Run tests (manual for MVP)" 25 | @echo " build Build the package for distribution" 26 | @echo " publish Publish to PyPI (requires build first)" 27 | @echo " clean Clean build artifacts and virtual environment" 28 | 29 | # Complete setup 30 | setup: 31 | @echo "Running complete setup..." 32 | ./setup.sh 33 | 34 | # Virtual environment setup 35 | venv: 36 | @echo "Creating virtual environment..." 37 | @if [ ! -d "venv" ]; then \ 38 | python3 -m venv venv; \ 39 | echo "Virtual environment created. Activate with:"; \ 40 | echo " source venv/bin/activate"; \ 41 | else \ 42 | echo "Virtual environment already exists."; \ 43 | fi 44 | 45 | install-wheel: 46 | @echo "Installing built wheel for testing..." 47 | pip install dist/*.whl 48 | 49 | install-wheel-user: 50 | @echo "Installing built wheel for current user..." 51 | pip install --user dist/*.whl 52 | 53 | install-wheel-global: 54 | @echo "Installing built wheel globally (requires sudo)..." 55 | sudo pip install dist/*.whl 56 | 57 | uninstall-global: 58 | sudo pip uninstall nautex 59 | 60 | freeze: 61 | pip3 freeze > requirements.txt 62 | 63 | reinstall-global: uninstall-global install-wheel-global 64 | @echo "Reinstalled" 65 | 66 | # Installation targets 67 | install-dev: 68 | @echo "Installing in development mode with dev dependencies..." 69 | @if [ -n "$$VIRTUAL_ENV" ]; then \ 70 | pip install -e .[dev]; \ 71 | else \ 72 | echo "Warning: Not in a virtual environment. Installing with --user flag..."; \ 73 | pip install --user -e .[dev]; \ 74 | fi 75 | 76 | install: 77 | @echo "Installing package..." 78 | @if [ -n "$$VIRTUAL_ENV" ]; then \ 79 | pip install -e .; \ 80 | else \ 81 | echo "Warning: Not in a virtual environment. Installing with --user flag..."; \ 82 | pip install --user -e .; \ 83 | fi 84 | 85 | # Code quality targets 86 | lint: 87 | @echo "Running flake8..." 88 | flake8 src/nautex/ 89 | @echo "Running mypy..." 90 | mypy src/nautex/ 91 | 92 | format: 93 | @echo "Running black..." 94 | black src/nautex/ 95 | @echo "Running isort..." 96 | isort src/nautex/ 97 | 98 | check: 99 | @echo "Checking black formatting..." 100 | black --check src/nautex/ 101 | @echo "Checking isort formatting..." 102 | isort --check-only src/nautex/ 103 | @echo "Running flake8..." 104 | flake8 src/nautex/ 105 | @echo "Running mypy..." 106 | mypy src/nautex/ 107 | 108 | # Build and publish targets 109 | build: 110 | @echo "Building package..." 111 | python3 -m build 112 | 113 | publish: clean build 114 | @echo "Publishing to PyPI..." 115 | twine upload dist/* 116 | 117 | # Run without installation targets 118 | run-cli: 119 | PYTHONPATH=src python3 -m nautex.cli --help 120 | 121 | run-setup: 122 | PYTHONPATH=src python3 -m nautex.cli setup 123 | 124 | run-status: 125 | PYTHONPATH=src python3 -m nautex.cli status 126 | 127 | run-mcp: 128 | PYTHONPATH=src python3 -m nautex.cli mcp 129 | 130 | run-mcp-inspector: 131 | DANGEROUSLY_OMIT_AUTH=true npx @modelcontextprotocol/inspector 132 | 133 | dev-claude-mcp-setup: 134 | claude mcp remove nautex 135 | claude mcp add nautex -s local -- uv run python -m nautex.cli mcp 136 | 137 | dev-nautex-setup: 138 | uv run python -m nautex.cli setup 139 | 140 | # Cleanup 141 | clean: 142 | @echo "Cleaning build artifacts..." 143 | rm -rf build/ 144 | rm -rf dist/ 145 | rm -rf *.egg-info/ 146 | rm -rf src/*.egg-info/ 147 | find . -type d -name __pycache__ -exec rm -rf {} + 148 | find . -type f -name "*.pyc" -delete 149 | -------------------------------------------------------------------------------- /src/nautex/agent_setups/codex.py: -------------------------------------------------------------------------------- 1 | """Codex agent setup and configuration (file-based MCP + AGENT.md).""" 2 | from pathlib import Path 3 | from typing import Tuple, Optional 4 | import subprocess 5 | 6 | from .files_based_mcp import FilesBasedMCPAgentSetup 7 | from .section_managed_rules_mixin import SectionManagedRulesMixin 8 | from .base import AgentRulesStatus 9 | from ..models.config import AgentType 10 | from ..prompts.common_workflow import COMMON_WORKFLOW_PROMPT 11 | from ..services.section_managed_file_service import SectionManagedFileService 12 | from ..prompts.consts import ( 13 | NAUTEX_SECTION_START, 14 | NAUTEX_SECTION_END, 15 | rules_reference_content_for, 16 | default_agents_rules_template_for, 17 | DIR_NAUTEX, 18 | ) 19 | from ..utils import path2display 20 | from ..utils.mcp_toml_utils import validate_mcp_toml_file, write_mcp_toml_configuration 21 | from ..utils.mcp_utils import MCPConfigStatus 22 | import asyncio 23 | 24 | 25 | # Default template will be provided via function at call time 26 | 27 | 28 | class CodexAgentSetup(SectionManagedRulesMixin, FilesBasedMCPAgentSetup): 29 | """Codex agent setup and configuration. 30 | 31 | - Uses the user-home TOML MCP config at `~/.codex/config.toml`. 32 | Before first write, creates a backup `config.toml.bak` alongside if absent. 33 | - Manages `.nautex/AGENT.md` with full rules and a root `AGENT.md` including a managed reference section. 34 | """ 35 | 36 | def __init__(self, config_service): 37 | super().__init__(config_service, AgentType.CODEX) 38 | self.config_folder = Path(".codex") 39 | self.rules_filename = Path("AGENTS.md") 40 | self.section_service = SectionManagedFileService(NAUTEX_SECTION_START, NAUTEX_SECTION_END) 41 | 42 | # ---------- MCP configuration (file-based, mergable) ---------- 43 | def get_agent_mcp_config_path(self) -> Path: 44 | """Absolute path to the MCP configuration file for Codex agent (TOML).""" 45 | return Path.home() / ".codex" / "config.toml" 46 | 47 | def get_agent_mcp_backup_path(self) -> Path: 48 | """Backup path for the Codex MCP configuration file. 49 | 50 | Kept as a separate getter to avoid duplication wherever backup path is needed. 51 | """ 52 | cfg = self.get_agent_mcp_config_path() 53 | return cfg.with_suffix(cfg.suffix + ".bak") 54 | 55 | async def get_mcp_configuration_info(self) -> str: 56 | cfg = self.get_agent_mcp_config_path() 57 | bak = self.get_agent_mcp_backup_path() 58 | return ( 59 | f"Codex MCP config (home): {cfg}\n" 60 | f"Backup: creates once as {bak} before first write" 61 | ) 62 | 63 | async def check_mcp_configuration(self) -> Tuple[MCPConfigStatus, Optional[Path]]: 64 | full_path = self.get_agent_mcp_config_path() 65 | if await asyncio.to_thread(lambda: full_path.exists()): 66 | status = await asyncio.to_thread(validate_mcp_toml_file, full_path, self.cwd) 67 | return status, full_path 68 | return MCPConfigStatus.NOT_FOUND, None 69 | 70 | async def write_mcp_configuration(self) -> bool: 71 | full_path = self.get_agent_mcp_config_path() 72 | # Ensure parent directory exists 73 | await asyncio.to_thread(lambda: full_path.parent.mkdir(parents=True, exist_ok=True)) 74 | # Create backup once if original exists and backup doesn't 75 | backup_path = self.get_agent_mcp_backup_path() 76 | def _maybe_backup(): 77 | if full_path.exists() and not backup_path.exists(): 78 | try: 79 | subprocess.run(["cp", str(full_path), str(backup_path)], check=True) 80 | except Exception: 81 | pass 82 | await asyncio.to_thread(_maybe_backup) 83 | return await asyncio.to_thread(write_mcp_toml_configuration, full_path, self.cwd) 84 | 85 | # ---------- Rules paths ---------- 86 | def get_rules_path(self) -> Path: 87 | """Full rules content lives in `.nautex/AGENT.md`.""" 88 | return self.cwd / Path(DIR_NAUTEX) / self.rules_filename 89 | 90 | @property 91 | def root_agent_path(self) -> Path: 92 | """Path to the root AGENT.md file (with reference section).""" 93 | return self.cwd / self.rules_filename 94 | 95 | # ---------- Rules validation / ensure ---------- 96 | def get_root_rules_path(self) -> Path: 97 | return self.root_agent_path 98 | 99 | # ---------- Rules content ---------- 100 | @property 101 | def workflow_rules_content(self) -> str: 102 | return COMMON_WORKFLOW_PROMPT 103 | 104 | def get_rules_info(self) -> str: 105 | return f"Rules Path: {path2display(self.get_rules_path())}" 106 | 107 | def get_reference_section_content(self) -> str: 108 | return rules_reference_content_for("AGENTS.md") 109 | 110 | def get_default_rules_template(self) -> str: 111 | # Avoid relying on instance type (may be str in some contexts) 112 | return default_agents_rules_template_for("AGENTS.md", AgentType.CODEX.display_name()) 113 | -------------------------------------------------------------------------------- /src/nautex/tui/widgets/system_info.py: -------------------------------------------------------------------------------- 1 | """System information widget for displaying host, email, and network stats.""" 2 | 3 | from typing import Optional 4 | from textual.widgets import DataTable, Static 5 | from textual.containers import Vertical 6 | from textual.reactive import reactive 7 | 8 | from ...utils.mcp_utils import MCPConfigStatus 9 | from ...agent_setups.base import AgentRulesStatus 10 | 11 | 12 | 13 | class SystemInfoWidget(Vertical): 14 | """Widget displaying system information in a DataTable format.""" 15 | 16 | DEFAULT_CSS = """ 17 | SystemInfoWidget { 18 | height: auto; 19 | width: 50; 20 | min-height: 8; 21 | max-height: 14; 22 | border: solid $primary; 23 | margin: 0 0 1 0; 24 | padding: 1; 25 | } 26 | 27 | SystemInfoWidget DataTable { 28 | height: 1fr; 29 | border: none; 30 | } 31 | 32 | SystemInfoWidget Static { 33 | height: auto; 34 | margin: 0 0 1 0; 35 | text-style: bold; 36 | } 37 | """ 38 | 39 | # Reactive properties 40 | host: reactive[str] = reactive("") 41 | email: reactive[str] = reactive("") 42 | network_delay: reactive[float] = reactive(0.0) 43 | agent_type: reactive[str] = reactive("") 44 | mcp_config_status: reactive[MCPConfigStatus] = reactive(MCPConfigStatus.NOT_FOUND) 45 | agent_rules_status: reactive[AgentRulesStatus] = reactive(AgentRulesStatus.NOT_FOUND) 46 | 47 | def __init__( 48 | self, 49 | **kwargs 50 | ): 51 | """Initialize the SystemInfoWidget.""" 52 | super().__init__(**kwargs) 53 | 54 | 55 | self.border_title = "System Information" 56 | 57 | # Create data table - defer column setup until mount 58 | self.data_table = DataTable(show_header=False, show_row_labels=False) 59 | self._table_initialized = False 60 | 61 | def compose(self): 62 | """Compose the widget layout.""" 63 | 64 | yield self.data_table 65 | 66 | async def on_mount(self) -> None: 67 | """Called when the widget is mounted.""" 68 | # Initialize the table structure - this is safe now that we're in an app context 69 | if not self._table_initialized: 70 | self.data_table.add_columns("Property", "Value") 71 | self._table_initialized = True 72 | 73 | self._setup_table() 74 | 75 | def _setup_table(self) -> None: 76 | """Set up the data table with initial rows.""" 77 | # Clear existing rows 78 | self.data_table.clear() 79 | 80 | # Add rows for each system info item 81 | self.data_table.add_row("Host", self.host or "Not configured") 82 | self.data_table.add_row("Acc Email", self.email or "Not available") 83 | self.data_table.add_row("ping", f"{self.network_delay:.3f}s" if self.network_delay > 0 else "N/A") 84 | self.data_table.add_row("Agent Type", self.agent_type or "Not configured") 85 | self.data_table.add_row("MCP Config", self.mcp_config_status.value) 86 | self.data_table.add_row("Agent Rules", self.agent_rules_status.value) 87 | 88 | 89 | async def refresh_data(self) -> None: 90 | """Refresh the system information data. 91 | 92 | This method is a placeholder. The widget should be updated using update_system_info directly. 93 | """ 94 | # This method is intentionally left empty as per the issue requirements. 95 | # The widget should be updated using update_system_info directly. 96 | pass 97 | 98 | def update_system_info( 99 | self, 100 | *, 101 | host: Optional[str] = None, 102 | email: Optional[str] = None, 103 | network_delay: Optional[float] = None, 104 | agent_type: Optional[str] = None, 105 | mcp_config_status: Optional[MCPConfigStatus] = None, 106 | agent_rules_status: Optional[AgentRulesStatus] = None, 107 | ) -> None: 108 | # Update reactive properties 109 | if host is not None: 110 | self.host = host 111 | if email is not None: 112 | self.email = email 113 | if network_delay is not None: 114 | self.network_delay = network_delay 115 | if agent_type is not None: 116 | self.agent_type = agent_type 117 | if mcp_config_status is not None: 118 | self.mcp_config_status = mcp_config_status 119 | if agent_rules_status is not None: 120 | self.agent_rules_status = agent_rules_status 121 | 122 | # Update table display 123 | try: 124 | # Update host row 125 | self.data_table.update_cell_at((0, 1), self.host or "Not configured", update_width=True) 126 | # Update email row 127 | self.data_table.update_cell_at((1, 1), self.email or "Not available", update_width=True) 128 | # Update network delay row 129 | network_delay_text = f"{self.network_delay:.3f}s" if self.network_delay > 0.0 else "N/A" 130 | self.data_table.update_cell_at((2, 1), network_delay_text, update_width=True) 131 | # Update agent type row 132 | self.data_table.update_cell_at((3, 1), self.agent_type or "Not configured", update_width=True) 133 | # Update MCP config status row 134 | self.data_table.update_cell_at((4, 1), self.mcp_config_status.value, update_width=True) 135 | # Update agent rules status row 136 | self.data_table.update_cell_at((5, 1), self.agent_rules_status.value, update_width=True) 137 | 138 | except Exception: 139 | # If table update fails, rebuild it 140 | self._setup_table() 141 | -------------------------------------------------------------------------------- /src/nautex/agent_setups/base.py: -------------------------------------------------------------------------------- 1 | """Base class for agent setup and configuration.""" 2 | from abc import ABC, abstractmethod 3 | from enum import Enum 4 | from pathlib import Path 5 | from typing import Optional, Tuple, List 6 | import logging 7 | import warnings 8 | import asyncio 9 | 10 | from ..utils.mcp_utils import MCPConfigStatus, validate_mcp_file, write_mcp_configuration 11 | 12 | # Set up logging 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | class AgentRulesStatus(str, Enum): 17 | """Status of agent rules file. 18 | 19 | Used to indicate the current state of the agent rules file. 20 | """ 21 | OK = "OK" 22 | OUTDATED = "OUTDATED" 23 | IOERROR = "IOERROR" 24 | NOT_FOUND = "NOT_FOUND" 25 | AGENT_TYPE_NOT_SELECTED = "AGENT_TYPE_NOT_SELECTED" 26 | 27 | 28 | 29 | class AgentSetupBase(ABC): 30 | """Base class for agent setup and configuration. 31 | 32 | This class defines the interface for agent-specific setup and configuration. 33 | Concrete implementations should be provided for each supported agent type. 34 | """ 35 | 36 | def __init__(self, config_service, agent_type: str): 37 | """Initialize the agent setup. 38 | 39 | Args: 40 | agent_type: The type of agent to setup 41 | """ 42 | self.agent_type = agent_type 43 | self.config_service = config_service 44 | 45 | @property 46 | def cwd(self): 47 | return self.config_service.cwd 48 | 49 | @abstractmethod 50 | def get_agent_mcp_config_path(self) -> Optional[Path]: 51 | """Get the full path to the MCP configuration file for the agent type. 52 | 53 | Returns: 54 | Path object pointing to the MCP configuration file (e.g., .mcp.json in project root). 55 | 56 | Raises: 57 | ValueError: If the agent type is not supported. 58 | """ 59 | return None 60 | 61 | 62 | @abstractmethod 63 | def get_rules_info(self) -> str: 64 | raise NotImplementedError 65 | 66 | 67 | def _validate_rules_file(self, rules_path: Path, expected_content: str) -> AgentRulesStatus: 68 | """Validate a specific rules file against the expected content. 69 | 70 | Args: 71 | rules_path: Path to the rules file 72 | cwd: The current working directory 73 | 74 | Returns: 75 | AgentRulesStatus indicating the validation result 76 | """ 77 | try: 78 | # Read the existing rules file 79 | with open(rules_path, 'r', encoding='utf-8') as f: 80 | existing_content = f.read() 81 | 82 | # Compare the contents 83 | if existing_content == expected_content: 84 | logger.debug(f"Valid rules file found at {rules_path}") 85 | return AgentRulesStatus.OK 86 | else: 87 | logger.debug(f"Invalid rules file found at {rules_path}") 88 | return AgentRulesStatus.OUTDATED 89 | 90 | except IOError as e: 91 | logger.error(f"Error reading rules file at {rules_path}: {e}") 92 | return AgentRulesStatus.IOERROR 93 | 94 | 95 | @abstractmethod 96 | def validate_rules(self) -> Tuple[AgentRulesStatus, Optional[Path]]: 97 | raise UnboundLocalError() 98 | 99 | @abstractmethod 100 | def ensure_rules(self) -> bool: 101 | raise UnboundLocalError() 102 | 103 | @abstractmethod 104 | async def get_mcp_configuration_info(self) -> str: 105 | """Get information about the MCP configuration. 106 | 107 | Returns: 108 | String with information about the MCP configuration path 109 | """ 110 | raise NotImplementedError() 111 | 112 | @abstractmethod 113 | async def check_mcp_configuration(self) -> Tuple[MCPConfigStatus, Optional[Path]]: 114 | """Check the status of MCP configuration integration. 115 | 116 | Checks if the MCP configuration file exists and validates the 'nautex' entry against template. 117 | 118 | Returns: 119 | Tuple of (status, path_to_config_file) 120 | - MCPConfigStatus.OK: Nautex entry exists and is correctly configured 121 | - MCPConfigStatus.MISCONFIGURED: File exists but nautex entry is incorrect 122 | - MCPConfigStatus.NOT_FOUND: No MCP configuration file found or no nautex entry 123 | """ 124 | raise NotImplementedError() 125 | 126 | @abstractmethod 127 | async def write_mcp_configuration(self) -> bool: 128 | """Write or update MCP configuration with Nautex CLI server entry. 129 | 130 | Reads the target MCP configuration file (or creates if not exists), adds/updates 131 | the 'nautex' server entry in mcpServers object, and saves the file. 132 | 133 | Returns: 134 | True if configuration was successfully written, False otherwise 135 | """ 136 | raise NotImplementedError() 137 | 138 | 139 | class AgentSetupNotSelected(AgentSetupBase): 140 | def get_agent_mcp_config_path(self) -> Optional[Path]: 141 | return None 142 | 143 | def get_rules_info(self): 144 | return "Agent Type not selected." 145 | 146 | def validate_rules(self) -> Tuple[AgentRulesStatus, Optional[Path]]: 147 | return AgentRulesStatus.AGENT_TYPE_NOT_SELECTED, Path("") 148 | 149 | def ensure_rules(self) -> bool: 150 | return False 151 | 152 | async def get_mcp_configuration_info(self) -> str: 153 | """Get information about the MCP configuration. 154 | 155 | Returns: 156 | String with information about the MCP configuration path 157 | """ 158 | return "Agent Type not selected." 159 | 160 | async def check_mcp_configuration(self) -> Tuple[MCPConfigStatus, Optional[Path]]: 161 | """Check the status of MCP configuration integration. 162 | 163 | Returns: 164 | Tuple of (status, path_to_config_file) indicating that no agent type is selected 165 | """ 166 | return MCPConfigStatus.NOT_FOUND, None 167 | 168 | async def write_mcp_configuration(self) -> bool: 169 | """Write or update MCP configuration with Nautex CLI server entry. 170 | 171 | Returns: 172 | False since no agent type is selected 173 | """ 174 | return False -------------------------------------------------------------------------------- /src/nautex/cli.py: -------------------------------------------------------------------------------- 1 | # import logging 2 | # 3 | # logging.basicConfig( 4 | # level=logging.ERROR 5 | # ) 6 | 7 | import argparse 8 | import asyncio 9 | 10 | import sys 11 | 12 | from pathlib import Path 13 | 14 | from .services.ui_service import UIService 15 | from .services.config_service import ConfigurationService 16 | from .services.nautex_api_service import NautexAPIService 17 | from .services.integration_status_service import IntegrationStatusService 18 | from .services.document_service import DocumentService 19 | from .services.mcp_service import MCPService, mcp_server_set_service_instance, mcp_server_run, \ 20 | mcp_handle_next_scope, mcp_handle_status 21 | from .services.mcp_config_service import MCPConfigService 22 | from .services.agent_rules_service import AgentRulesService 23 | from .api import create_api_client 24 | import json 25 | 26 | 27 | def handle_test_commands(args): 28 | """Handle test commands for MCP functionality. 29 | 30 | Args: 31 | args: Command line arguments 32 | """ 33 | if args.test_command == "next_scope": 34 | # Run the next_scope test command 35 | # Call the next_scope function and get the result 36 | result = asyncio.run(mcp_handle_next_scope()) 37 | 38 | # Print the result with proper indentation 39 | if result["success"] and "data" in result: 40 | print(json.dumps(result["data"], indent=4)) 41 | else: 42 | print(json.dumps(result, indent=4)) 43 | 44 | elif args.test_command == "status": 45 | # Run the next_scope test command 46 | # Call the next_scope function and get the result 47 | result = asyncio.run(mcp_handle_status()) 48 | 49 | # Print the result with proper indentation 50 | if result["success"] and "data" in result: 51 | print(json.dumps(result["data"], indent=4)) 52 | else: 53 | print(json.dumps(result, indent=4)) 54 | else: 55 | print("Please specify a test command. Available commands: next_scope, status.") 56 | 57 | 58 | def main() -> None: 59 | """Main entry point for the Nautex CLI.""" 60 | parser = argparse.ArgumentParser( 61 | prog="nautex", 62 | description="nautex - Nautex AI platform MCP integration tool and server" 63 | ) 64 | 65 | subparsers = parser.add_subparsers(dest="command", help="Available commands") 66 | 67 | # Setup command 68 | setup_parser = subparsers.add_parser("setup", help="Interactive setup configuration") 69 | 70 | # Status command 71 | status_parser = subparsers.add_parser("status", help="View integration status") 72 | status_parser.add_argument("--noui", action="store_true", help="Print status to console instead of TUI") 73 | 74 | # MCP command 75 | mcp_parser = subparsers.add_parser("mcp", help="Start MCP server for IDE integration") 76 | 77 | # MCP subcommands 78 | mcp_subparsers = mcp_parser.add_subparsers(dest="mcp_command", help="MCP commands") 79 | 80 | # MCP test command 81 | mcp_test_parser = mcp_subparsers.add_parser("test", help="Test MCP functionality") 82 | mcp_test_subparsers = mcp_test_parser.add_subparsers(dest="test_command", help="Test commands") 83 | 84 | # MCP test commands 85 | mcp_test_next_scope_parser = mcp_test_subparsers.add_parser("next_scope", help="Test next_scope functionality") 86 | mcp_test_status_parser = mcp_test_subparsers.add_parser("status", help="Test status functionality") 87 | 88 | args = parser.parse_args() 89 | 90 | if args.command is None: 91 | parser.print_help() 92 | return 93 | 94 | # 1. Base services that don't depend on other services 95 | config_service = ConfigurationService() 96 | config_service.load_configuration() 97 | 98 | # 2. Initialize services that depend on config 99 | mcp_config_service = MCPConfigService(config_service) 100 | agent_rules_service = AgentRulesService(config_service) 101 | 102 | # 3. Initialize API client and service if config is available 103 | 104 | api_client = create_api_client(base_url=config_service.config.api_host, test_mode=False) 105 | nautex_api_service = NautexAPIService(api_client, config_service) 106 | 107 | # 4. Services that depend on other services 108 | integration_status_service = IntegrationStatusService( 109 | config_service=config_service, 110 | mcp_config_service=mcp_config_service, 111 | agent_rules_service=agent_rules_service, 112 | nautex_api_service=nautex_api_service, 113 | ) 114 | 115 | 116 | # Initialize document service 117 | document_service = DocumentService( 118 | nautex_api_service=nautex_api_service, 119 | config_service=config_service 120 | ) 121 | 122 | # 5. UI service for TUI commands 123 | ui_service = UIService( 124 | config_service=config_service, 125 | integration_status_service=integration_status_service, 126 | api_service=nautex_api_service, 127 | mcp_config_service=mcp_config_service, 128 | agent_rules_service=agent_rules_service, 129 | ) 130 | 131 | # Command dispatch 132 | if args.command == "setup": 133 | # Run the interactive setup TUI 134 | asyncio.run(ui_service.handle_setup_command()) 135 | 136 | elif args.command == "status": 137 | # Run the status command 138 | asyncio.run(ui_service.handle_status_command(noui=args.noui)) 139 | 140 | elif args.command == "mcp": 141 | # Initialize MCP service 142 | try: 143 | mcp_service = MCPService( 144 | config_service=config_service, 145 | nautex_api_service=nautex_api_service, # This can be None 146 | integration_status_service=integration_status_service, 147 | document_service=document_service 148 | ) 149 | 150 | # Set the global MCP service instance 151 | mcp_server_set_service_instance(mcp_service) 152 | 153 | # Check for MCP subcommands 154 | if args.mcp_command == "test": 155 | handle_test_commands(args) 156 | else: 157 | # Run the MCP server in the main thread 158 | mcp_server_run() 159 | 160 | except Exception as e: 161 | print(f"MCP server error: {e}", file=sys.stderr) 162 | sys.exit(1) 163 | 164 | 165 | if __name__ == "__main__": 166 | main() 167 | -------------------------------------------------------------------------------- /src/nautex/services/document_service.py: -------------------------------------------------------------------------------- 1 | """Document Service for handling document operations.""" 2 | 3 | import os 4 | import logging 5 | from typing import List, Optional, Dict 6 | from pathlib import Path 7 | import aiofiles 8 | 9 | from ..api.api_models import Document 10 | from ..services.nautex_api_service import NautexAPIService 11 | from ..services.config_service import ConfigurationService 12 | 13 | # Set up logging 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | class DocumentService: 18 | """Service for handling document operations.""" 19 | 20 | def __init__( 21 | self, 22 | nautex_api_service: NautexAPIService, 23 | config_service: ConfigurationService 24 | ): 25 | """Initialize the document service. 26 | 27 | Args: 28 | nautex_api_service: The Nautex API service 29 | config_service: The configuration service 30 | """ 31 | self.nautex_api_service = nautex_api_service 32 | self.config_service = config_service 33 | logger.debug("DocumentService initialized") 34 | 35 | async def get_document(self, project_id: str, doc_designator: str) -> Optional[Document]: 36 | """Get a document by designator. 37 | 38 | Args: 39 | project_id: The ID of the project 40 | doc_designator: The designator of the document 41 | 42 | Returns: 43 | A Document object, or None if the document was not found 44 | """ 45 | try: 46 | return await self.nautex_api_service.get_document_tree(project_id, doc_designator) 47 | except Exception as e: 48 | logger.error(f"Error getting document {doc_designator}: {e}") 49 | return None 50 | 51 | async def save_document_to_file(self, document: Document, output_path: Path) -> tuple[bool, str]: 52 | """Save a document to a file. 53 | 54 | Args: 55 | document: The document to save 56 | output_path: The path to save the document to 57 | 58 | Returns: 59 | Tuple of (success, path or error message) 60 | """ 61 | try: 62 | # Create directory if it doesn't exist 63 | os.makedirs(output_path.parent, exist_ok=True) 64 | 65 | # Generate markdown content 66 | # FIXME, introduce doc trees types 67 | if document.designator.startswith("FILE"): 68 | content_str = document.render_tree() 69 | else: 70 | content_str = document.render_markdown() 71 | 72 | # Write to file 73 | async with aiofiles.open(output_path, 'w') as f: 74 | await f.write(content_str) 75 | 76 | logger.debug(f"Document {document.designator} saved to {output_path}") 77 | return True, str(output_path) 78 | except Exception as e: 79 | error_msg = f"Error saving document {document.designator} to {output_path}: {e}" 80 | logger.error(error_msg) 81 | return False, error_msg 82 | 83 | async def ensure_documents(self, project_id: str, doc_designators: List[str]) -> Dict[str, str]: 84 | """Ensure documents are available locally. 85 | 86 | Args: 87 | project_id: The ID of the project 88 | doc_designators: List of document designators to ensure 89 | 90 | Returns: 91 | Dictionary mapping document designators to file paths or error messages 92 | """ 93 | results = {} 94 | documents_path = Path(self.config_service.documents_path) 95 | 96 | # Create documents directory if it doesn't exist 97 | os.makedirs(documents_path, exist_ok=True) 98 | 99 | # Process each document 100 | for designator in doc_designators: 101 | try: 102 | # Get document from API 103 | document = await self.get_document(project_id, designator) 104 | 105 | if document: 106 | # Determine output path 107 | output_filename = f"{designator}.md" 108 | output_path = documents_path / output_filename 109 | 110 | # Save document to file 111 | success, result = await self.save_document_to_file(document, output_path) 112 | results[designator] = result 113 | else: 114 | error_msg = f"Document {designator} not found" 115 | logger.warning(error_msg) 116 | results[designator] = error_msg 117 | except Exception as e: 118 | error_msg = f"Error ensuring document {designator}: {e}" 119 | logger.error(error_msg) 120 | results[designator] = error_msg 121 | 122 | return results 123 | 124 | async def ensure_plan_dependency_documents(self, project_id: str, plan_id: str) -> Dict[str, str]: 125 | """Ensure all dependency documents for a plan are available locally. 126 | 127 | Args: 128 | project_id: The ID of the project 129 | plan_id: The ID of the implementation plan 130 | 131 | Returns: 132 | Dictionary mapping document designators to file paths or error messages, or empty dict if plan not found 133 | """ 134 | try: 135 | # Get the implementation plan 136 | plan = await self.nautex_api_service.get_implementation_plan(project_id, plan_id) 137 | 138 | if not plan: 139 | error_msg = f"Implementation plan {plan_id} not found for project {project_id}" 140 | logger.warning(error_msg) 141 | return {} 142 | 143 | # Get dependency documents 144 | dependency_docs = plan.dependency_documents or [] 145 | 146 | if not dependency_docs: 147 | logger.info(f"No dependency documents found for plan {plan_id}") 148 | return {} 149 | 150 | logger.info(f"Ensuring {len(dependency_docs)} dependency documents for plan {plan_id}") 151 | 152 | # Ensure all dependency documents are available locally 153 | return await self.ensure_documents(project_id, dependency_docs) 154 | 155 | except Exception as e: 156 | error_msg = f"Error ensuring dependency documents for plan {plan_id}: {e}" 157 | logger.error(error_msg) 158 | return {} 159 | -------------------------------------------------------------------------------- /src/nautex/agent_setups/claude.py: -------------------------------------------------------------------------------- 1 | """Claude agent setup and configuration.""" 2 | import subprocess 3 | import re 4 | import asyncio 5 | from pathlib import Path 6 | from typing import Tuple, Optional, List, Dict 7 | 8 | from .base import AgentSetupBase, AgentRulesStatus 9 | from .section_managed_rules_mixin import SectionManagedRulesMixin 10 | from ..models.config import AgentType 11 | from ..prompts.common_workflow import COMMON_WORKFLOW_PROMPT 12 | from ..prompts.consts import ( 13 | NAUTEX_SECTION_START, 14 | NAUTEX_SECTION_END, 15 | DEFAULT_RULES_TEMPLATE, 16 | rules_reference_content_for, 17 | DIR_NAUTEX, 18 | ) 19 | from ..services.section_managed_file_service import SectionManagedFileService 20 | from ..utils import path2display 21 | from ..utils.mcp_utils import MCPConfigStatus 22 | import logging 23 | 24 | # Set up logging 25 | logger = logging.getLogger(__name__) 26 | 27 | 28 | class ClaudeAgentSetup(SectionManagedRulesMixin, AgentSetupBase): 29 | """Claude agent setup and configuration. 30 | 31 | This class provides Claude-specific implementation of the agent setup interface. 32 | It uses process-based MCP configuration via the 'claude mcp' command. 33 | """ 34 | 35 | def __init__(self, config_service): 36 | """Initialize the Claude agent setup.""" 37 | super().__init__(config_service, AgentType.CLAUDE) 38 | self.section_service = SectionManagedFileService(NAUTEX_SECTION_START, NAUTEX_SECTION_END) 39 | 40 | def get_agent_mcp_config_path(self) -> Path: 41 | """Get the full path to the MCP configuration file for the Claude agent. 42 | 43 | Note: This method is kept for compatibility, but Claude uses process-based 44 | configuration rather than file-based configuration. 45 | 46 | Returns: 47 | Path object pointing to a non-existent file. 48 | """ 49 | return Path(".claude/mcp.json") 50 | 51 | async def get_mcp_configuration_info(self) -> str: 52 | """Get information about the MCP configuration. 53 | 54 | Returns: 55 | String with information about the MCP configuration 56 | """ 57 | return "Claude MCP Configuration: Process-based (via 'claude mcp' command)" 58 | 59 | async def check_mcp_configuration(self) -> Tuple[MCPConfigStatus, Optional[Path]]: 60 | """Check the status of MCP configuration integration. 61 | 62 | Runs 'claude mcp list' command to check if nautex is configured. 63 | 64 | Returns: 65 | Tuple of (status, None) 66 | - MCPConfigStatus.OK: Nautex entry exists and is correctly configured 67 | - MCPConfigStatus.MISCONFIGURED: Nautex entry exists but is not connected 68 | - MCPConfigStatus.NOT_FOUND: No nautex entry found 69 | """ 70 | try: 71 | # Run 'claude mcp list' command asynchronously 72 | process = await asyncio.create_subprocess_exec( 73 | "claude", "mcp", "list", 74 | stdout=asyncio.subprocess.PIPE, 75 | stderr=asyncio.subprocess.PIPE, 76 | ) 77 | 78 | stdout, stderr = await process.communicate() 79 | 80 | if process.returncode != 0: 81 | logger.error(f"Error running 'claude mcp list': {stderr}") 82 | return MCPConfigStatus.NOT_FOUND, None 83 | 84 | # Parse the output to check for nautex 85 | output = stdout.decode("utf-8") 86 | 87 | # Look for a line like "nautex: uvx nautex mcp - ✓ Connected" 88 | nautex_pattern = r"nautex:\s+uvx\s+nautex\s+mcp\s+-\s+([✓✗])\s+(Connected|Error)" 89 | match = re.search(nautex_pattern, output) 90 | 91 | # Also check for debug setup: "nautex: uv run python -m nautex.cli mcp - ✓ Connected" 92 | nautex_pattern_debug = r"nautex:\s+uv\s+run\s+python\s+-m\s+nautex\.cli\s+mcp\s+-\s+([✓✗])\s+(Connected|Error)" 93 | match_debug_setup = re.search(nautex_pattern_debug, output) 94 | 95 | # Use whichever pattern matched 96 | final_match = match or match_debug_setup 97 | 98 | if final_match: 99 | status_symbol = final_match.group(1) 100 | if status_symbol == "✓": 101 | return MCPConfigStatus.OK, None 102 | else: 103 | return MCPConfigStatus.MISCONFIGURED, None 104 | else: 105 | return MCPConfigStatus.NOT_FOUND, None 106 | 107 | except Exception as e: 108 | logger.error(f"Error checking Claude MCP configuration: {e}") 109 | return MCPConfigStatus.NOT_FOUND, None 110 | 111 | async def write_mcp_configuration(self) -> bool: 112 | """Write or update MCP configuration with Nautex CLI server entry. 113 | 114 | Runs 'claude mcp add nautex -s local -- uvx nautex mcp' command to configure nautex. 115 | 116 | Returns: 117 | True if configuration was successfully written, False otherwise 118 | """ 119 | try: 120 | # Run 'claude mcp add nautex' command asynchronously 121 | process = await asyncio.create_subprocess_exec( 122 | "claude", "mcp", "add", "nautex", "-s", "local", "--", "uvx", "nautex", "mcp", 123 | stdout=asyncio.subprocess.PIPE, 124 | stderr=asyncio.subprocess.PIPE, 125 | ) 126 | 127 | stdout, stderr = await process.communicate() 128 | 129 | stderr_str = stderr.decode("utf-8") 130 | 131 | if process.returncode != 0 and 'nautex already exists' not in stderr_str: 132 | logger.error(f"Error running 'claude mcp add nautex': {stderr}") 133 | return False 134 | 135 | # Verify the configuration was added successfully 136 | status, _ = await self.check_mcp_configuration() 137 | return status == MCPConfigStatus.OK 138 | 139 | except Exception as e: 140 | logger.error(f"Error writing Claude MCP configuration: {e}") 141 | return False 142 | 143 | def get_rules_path(self,) -> Path: 144 | return self.cwd / DIR_NAUTEX / "CLAUDE.md" 145 | 146 | @property 147 | def root_claude_path(self) -> Path: 148 | """Path to the root CLAUDE.md file.""" 149 | return self.cwd / "CLAUDE.md" 150 | 151 | def get_root_rules_path(self) -> Path: 152 | return self.root_claude_path 153 | 154 | @property 155 | def workflow_rules_content(self) -> str: 156 | return COMMON_WORKFLOW_PROMPT 157 | 158 | def get_rules_info(self) -> str: 159 | return f"Rules Path: {path2display(self.get_rules_path())}" 160 | 161 | def get_reference_section_content(self) -> str: 162 | return rules_reference_content_for("CLAUDE.md") 163 | 164 | def get_default_rules_template(self) -> str: 165 | return DEFAULT_RULES_TEMPLATE 166 | -------------------------------------------------------------------------------- /src/nautex/services/integration_status_service.py: -------------------------------------------------------------------------------- 1 | """Integration Status Service for managing API validation, config validation, and MCP status.""" 2 | 3 | import logging 4 | import asyncio 5 | from typing import Optional, Tuple, Dict, Any, Callable 6 | from .config_service import ConfigurationService, ConfigurationError 7 | from .nautex_api_service import NautexAPIService 8 | from .mcp_config_service import MCPConfigService 9 | from ..utils.mcp_utils import MCPConfigStatus 10 | from .agent_rules_service import AgentRulesService 11 | 12 | from ..models.integration_status import IntegrationStatus 13 | 14 | # Set up logging 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | class IntegrationStatusService: 19 | 20 | def __init__( 21 | self, 22 | config_service: ConfigurationService, 23 | mcp_config_service: MCPConfigService, 24 | agent_rules_service: AgentRulesService, 25 | nautex_api_service: Optional[NautexAPIService], 26 | ): 27 | """Initialize the integration status service. 28 | 29 | Args: 30 | config_service: Service for configuration management 31 | mcp_config_service: Service for MCP configuration management 32 | agent_rules_service: Service for agent rules management 33 | nautex_api_service: Service for Nautex API operations (can be None if not configured) 34 | project_root: Root directory for the project 35 | """ 36 | self.config_service = config_service 37 | self.mcp_config_service = mcp_config_service 38 | self.agent_rules_service = agent_rules_service 39 | self._nautex_api_service = nautex_api_service 40 | 41 | # Polling related attributes 42 | self._polling_task = None 43 | self._polling_interval = 5.0 # seconds 44 | self._on_update_callback = None 45 | 46 | async def get_integration_status(self) -> IntegrationStatus: 47 | """Get comprehensive integration status. 48 | 49 | Returns: 50 | IntegrationStatus object containing all integration health information 51 | """ 52 | logger.debug("Gathering integration status...") 53 | 54 | # Initialize status 55 | status = IntegrationStatus( 56 | config=self.config_service.config 57 | ) 58 | 59 | if status.config_loaded: 60 | 61 | await self._check_network_connectivity(status) 62 | await self._check_api_connectivity(status) 63 | await self._update_implementation_plan(status) 64 | 65 | if self.config_service.config.agent_type_selected: 66 | await self._check_mcp_status(status) 67 | self._check_agent_rules_status(status) 68 | 69 | return status 70 | 71 | async def _check_mcp_status(self, status: IntegrationStatus) -> None: 72 | """Check MCP integration status.""" 73 | logger.debug("Checking MCP configuration...") 74 | status.mcp_status, status.mcp_config_path = await self.mcp_config_service.check_mcp_configuration() 75 | logger.debug(f"MCP status: {status.mcp_status}, path: {status.mcp_config_path}") 76 | 77 | def _check_agent_rules_status(self, status: IntegrationStatus) -> None: 78 | """Check agent rules status.""" 79 | logger.debug("Checking agent rules...") 80 | status.agent_rules_status, status.agent_rules_path = self.agent_rules_service.validate_rules() 81 | logger.debug(f"Agent rules status: {status.agent_rules_status}, path: {status.agent_rules_path}") 82 | 83 | async def _check_network_connectivity(self, status: IntegrationStatus) -> None: 84 | """Test network connectivity to API host with short timeout.""" 85 | 86 | try: 87 | logger.debug("Testing network connectivity...") 88 | 89 | network_ok, response_time, error_msg = await self._nautex_api_service.check_network_connectivity(timeout=5.0) 90 | 91 | # Store network status as a custom attribute 92 | status.network_connected = network_ok 93 | status.network_response_time = response_time 94 | status.network_error = error_msg 95 | 96 | if network_ok: 97 | logger.debug(f"Network connectivity verified in {response_time:.3f}s") 98 | else: 99 | logger.warning(f"Network connectivity failed: {error_msg}") 100 | 101 | except Exception as e: 102 | logger.warning(f"Network connectivity check failed: {e}") 103 | status.network_connected = False 104 | status.network_response_time = None 105 | status.network_error = str(e) 106 | 107 | async def _check_api_connectivity(self, status: IntegrationStatus) -> None: 108 | """Test API connectivity with a longer timeout.""" 109 | try: 110 | logger.debug("Testing API connectivity...") 111 | acc_info = await self._nautex_api_service.get_account_info(timeout=5.0) 112 | status.api_connected = bool(acc_info) 113 | status.account_info = acc_info 114 | except Exception as e: 115 | # logger.warning(f"API connectivity check failed: {e}") 116 | status.api_connected = False 117 | status.api_response_time = None 118 | 119 | async def _update_implementation_plan(self, status: IntegrationStatus): 120 | """Update the implementation plan.""" 121 | try: 122 | if self.config_service.config.plan_id: 123 | plan = await self._nautex_api_service.get_implementation_plan(status.config.project_id, status.config.plan_id) 124 | status.implementation_plan = plan 125 | 126 | except Exception: 127 | raise 128 | 129 | def start_polling(self, on_update: Optional[Callable[[IntegrationStatus], None]] = None, interval: Optional[float] = None) -> None: 130 | """Start a background task to poll for integration status updates. 131 | 132 | Args: 133 | on_update: Optional callback function to be called when new status is available 134 | interval: Optional polling interval in seconds (defaults to self._polling_interval) 135 | """ 136 | if interval is not None: 137 | self._polling_interval = interval 138 | 139 | self._on_update_callback = on_update 140 | 141 | if self._polling_task is None: 142 | self._polling_task = asyncio.create_task(self._poll_integration_status()) 143 | logger.debug("Started integration status polling task") 144 | 145 | def stop_polling(self) -> None: 146 | """Stop the polling task if it's running.""" 147 | if self._polling_task is not None: 148 | self._polling_task.cancel() 149 | self._polling_task = None 150 | logger.debug("Stopped integration status polling task") 151 | 152 | async def _poll_integration_status(self) -> None: 153 | """Continuously poll for integration status updates.""" 154 | try: 155 | while True: 156 | await asyncio.sleep(self._polling_interval) 157 | 158 | try: 159 | status = await self.get_integration_status() 160 | 161 | # Call the callback if provided 162 | if self._on_update_callback: 163 | self._on_update_callback(status) 164 | 165 | except Exception as e: 166 | logger.error(f"Error getting integration status: {e}") 167 | 168 | except asyncio.CancelledError: 169 | # Task was cancelled, clean up 170 | logger.debug("Integration status polling task cancelled") 171 | except Exception as e: 172 | logger.error(f"Error in integration status polling: {e}") 173 | # Attempt to restart polling after a brief delay 174 | await asyncio.sleep(1.0) 175 | self.start_polling(self._on_update_callback) 176 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | This is an MCP server that integrates PRD and TRD building tool [Nautex AI](https://nautex.ai) with the Coding Agents. 3 | 4 | Supported agents: 5 | - Claude Code 6 | - Codex 7 | - Cursor 8 | - OpenCode 9 | - Gemini CLI (coming soon) 10 | 11 | # Motivation 12 | 13 | Since LLM Coding Agents do not attend team meetings, there is the challenge of conveying complete and detailed product and technical requirements to them. 14 | 15 | Nautex AI tool-chain manages step by step guiding of Coding Agents so they implement specification using small, relevant and testable steps. 16 | 17 | Core principles are: 18 | 1) start from foundational parts, de-risk them, then build up; 19 | 2) do not overwhelm Coding Agents by large problem at once; 20 | 3) plan project files map and link them to requirements and to tasks: Coding Agents don't get lost, you know how to navigate brand new code base; 21 | 4) manage developer attention for verification and validation in right moment for review. 22 | 23 | # How It Works 24 | 25 | Nautex AI acts as an Architect, Technical Product Manager, and Project Manager for coding agents, 26 | speeding up AI-assisted development by communicating requirements effectively. 27 | This MCP server pulls guidance instructions from Nautex AI; tasks contain to-do items, 28 | references to the affected files, and requirements that are automatically synced for the Coding Agent's availability. 29 | 30 | By [Ivan Makarov](https://x.com/ivan_mkrv) 31 | 32 | 33 | ⬇️⬇️⬇️ 📚 **Check Presentation** ⬇️⬇️⬇️ 34 | 35 | 36 |
37 | 💡 Usage Flow Presentation (unfold me) 38 | 39 | ## Requirements Specifications 40 | 41 | The chatbot conducts a briefing session with you, gathering questions and ideas until complete. It then generates comprehensive product and technical specifications. 42 | 43 | (Example: A project I initiated to explore WebRTC.) 44 | 45 | Product requirements: 46 | ![howitworks_specifications](doc/howitworks_specifications.png) 47 | 48 | Technical requirements: 49 | ![howitworks_diagram.png](doc/howitworks_diagram.png) 50 | 51 | ## Specification Refinement 52 | 53 | You fill in details, clarify the specification, and resolve any TODOs flagged by the chatbot during the interview. 54 | 55 | ![howitworks_refinement](doc/howitworks_refinement.png) 56 | 57 | ## Codebase Map and Project Files 58 | 59 | You'll occasionally need to review the code, so it's best to know in advance where to look and how everything is organized. This prevents the AI from making decisions—allowing it to focus on writing higher-quality code with greater attention to the task. 60 | 61 | The image displays a file map generated by Nautex AI, with files linked to specific requirements and sections. 62 | 63 | ![howitworks_filemap](doc/howitworks_filemap.png) 64 | 65 | ## Agent Tasks 66 | 67 | With the code location clarified, tasks are planned: Coding, Testing, and Review. 68 | 69 | Reviews are scheduled early to demonstrate progress and verify alignment with goals. 70 | 71 | The plan is structured in small, self-contained layers, building your project incrementally like floors in a skyscraper. 72 | 73 | ![howitworks_tasks](doc/howitworks_tasks.png) 74 | 75 | ## Integration 76 | 77 | Next, configure the MCP server for Cursor integration: connect to the Nautex cloud platform for tasks, select the project, and choose the plan. 78 | 79 | Set the MCP server parameters and provide usage rules for Cursor. Use mouse clicks for automation—the terminal UI functions like a web page. 80 | 81 | This utility is available on GitHub. 82 | 83 | Once all indicators are green, initiate plan execution. 84 | 85 | ![howitworks_integration](doc/howitworks_integration.png) 86 | 87 | ## Coding with Coding Agents 88 | 89 | In agent mode, instruct: "pull nautex rules, and proceed with the next scope." 90 | 91 | At this stage, your specifications are synchronized in the .nautex directory and accessible to the Coding Agent. The MCP server continuously monitors their relevance. 92 | 93 | That's it. You then review and accept substantial code segments that fully align with your expectations and requirements. 94 | 95 | ![howitworks_coding](doc/howitworks_coding.png) 96 | 97 |
98 | 99 | # Setup 100 | 101 | ## Via Terminal UI 102 | 103 | 1. Go to the new project folder and run in the terminal: 104 | ```bash 105 | uvx nautex setup 106 | ``` 107 | 108 |
109 | How to Install uv 110 | 111 | On macOS and linux: 112 | ```bash 113 | curl -LsSf https://astral.sh/uv/install.sh | sh 114 | ``` 115 | 116 | On Windows 117 | ```bash 118 | powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" 119 | ``` 120 | 121 | Check the latest instruction from [UV repo](https://github.com/astral-sh/uv) for details and updates 122 |
123 | 124 | You should see the terminal user interface 125 | 126 | ![Setup Screenshot](doc/setup_screen.png) 127 | 128 | 2. Follow the guidelines via UI 129 | - go [Nautex.ai](https://app.nautex.ai/settings/nautex-api) to sign up and create API token 130 | - In the web app, create PRD and TRD documents: 131 | - Chat with the bot for capturing requirements. 132 | - After initial documents generation Create files map of the project inside the map. 133 | - Then, after reviewing resulted map create implementation plan. 134 | - You can follow `Connect Coding Agent` onboarding flow or setup via TUI. Button will disappear on first MCP request 135 | - Go back to the CLI UI 136 | - Select project 137 | - Select implementation plan 138 | - Select agent type 139 | - Ensure you've got right MCP config: manually or via TUI (it will merge with any existing config) 140 |
141 | For cursor 142 | 143 | - in `.cursor/mcp.json`, 144 | 145 | ```json 146 | { 147 | "mcpServers": { 148 | "nautex": { 149 | "command": "uvx", 150 | "args": [ 151 | "nautex", 152 | "mcp" 153 | ] 154 | } 155 | } 156 | } 157 | ``` 158 | 159 | **Note:** At config update Cursor asks via popup either you want to enable new MCP, answer yes. In any case in 160 | `File -> Preferences -> Cursor Preferences -> Tools & Integrations` nautex MCP should be enabled and green. 161 | 162 | - Rules are in `.cursor/rules/` folder via TUI command. 163 |
164 | 165 |
166 | For Claude Code 167 | 168 | TUI setup launches this command to add Nautex MCP: 169 | ``` 170 | claude mcp add nautex -s local -- uvx nautex mcp 171 | ``` 172 | 173 | - Rules are in `./CLAUDE.md` after set via TUI. 174 | - Verify integration: run `claude mcp list` and ensure an entry like `nautex: uvx nautex mcp - ✓ Connected` is present. 175 |
176 | 177 |
178 | For Codex 179 | 180 | Codex uses a file-based MCP config at `~/.codex/config.toml`. Nautex will merge/update this file and create a backup `config.toml.bak` before the first overwrite if needed. 181 | 182 | - Rules live under `.nautex/AGENTS.md`, with a managed reference section in the root `AGENTS.md`. 183 | - Verify integration in Codex: open the MCP command UI (e.g., use the `/mcp` command) and confirm `nautex` is listed/enabled. Alternatively, inspect `~/.codex/config.toml` for a `nautex` entry pointing to `uvx nautex mcp`. 184 |
185 | 186 |
187 | For OpenCode 188 | 189 | OpenCode uses a per-project config `opencode.json` in the repository root. Nautex writes/updates this file, preserving unrelated fields and backing up unparsable files once to `opencode.json.bak`. 190 | 191 | Minimal required structure written by Nautex: 192 | ```json 193 | { 194 | "$schema": "https://opencode.ai/config.json", 195 | "mcp": { 196 | "nautex": { 197 | "type": "local", 198 | "command": ["uvx", "nautex", "mcp"], 199 | "enabled": true 200 | } 201 | } 202 | } 203 | ``` 204 | 205 | - Rules live under `.nautex/AGENTS.md`, with a managed reference section in the root `AGENTS.md`. 206 | - Verify integration: from OpenCode, invoke the Nautex MCP tool and run `status` (e.g., “nautex: status”). You should see the Nautex server respond. Optionally inspect `opencode.json` as shown above. 207 |
208 | 209 | 3. (Optional) Check MCP server response ```uvx nautex mcp test next_scope``` 210 | 4. Check MCP configuration works and Coding Agent sees the tools: 211 | > Check nautex status 212 | 5. Tell Coding Agent: 213 | > Pull nautex rules and proceed to the next scope 214 | 215 | 6. Proceed with the plan by reviewing progress and supporting the Agent with validation feedback and inputs. 216 | 217 | # Projects built with nautex 218 | 219 | - [Collaborative Pixel Canvas](https://pixall.art) - [repo](https://github.com/hmldns/pix-canvas) 220 | 221 | # Best practice from the community 222 | 223 | drawing 224 | -------------------------------------------------------------------------------- /src/nautex/api/scope_context_model.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | from pydantic import BaseModel, Field 3 | import os 4 | from enum import Enum 5 | 6 | 7 | class TaskStatus(str, Enum): 8 | NOT_STARTED = "Not started" 9 | IN_PROGRESS = "In progress" 10 | DONE = "Done" 11 | BLOCKED = "Blocked" 12 | 13 | 14 | class TaskType(str, Enum): 15 | CODE = "Code" 16 | REVIEW = "Review" 17 | TEST = "Test" 18 | INPUT = "Input" 19 | 20 | 21 | class ScopeContextMode(str, Enum): 22 | """Enum for the state of a scope context.""" 23 | ExecuteSubtasks = "ExecuteSubtasks" 24 | FinalizeMasterTask = "FinalizeMasterTask" 25 | 26 | 27 | class Reference(BaseModel): 28 | """Base class for all references.""" 29 | root_id: Optional[str] = Field(None, description="Root document ID", exclude=True) 30 | item_id: Optional[str] = Field(None, description="Item ID", exclude=True) 31 | 32 | 33 | 34 | class TaskReference(Reference): 35 | """Reference to a task by its designator.""" 36 | task_designator: Optional[str] = Field(None, description="Unique task identifier like TASK-123") 37 | 38 | 39 | class RequirementReference(Reference): 40 | """Reference to a requirement by its designator.""" 41 | requirement_designator: Optional[str] = Field(None, description="Unique requirement identifier like REQ-45") 42 | 43 | 44 | class FileReference(Reference): 45 | """Reference to a file by its path.""" 46 | file_path: str = Field(..., description="Path to the file") 47 | 48 | 49 | class ScopeTask(BaseModel): 50 | """Task model for scope context with subtasks and references.""" 51 | task_designator: str = Field(..., description="Unique task identifier like TASK-123") 52 | name: str = Field(..., description="Human-readable task name") 53 | description: str = Field(None, description="Detailed task description") 54 | status: TaskStatus = Field(..., description="Current task status") 55 | type: TaskType = Field(..., description="Type of the task (Code, Review, Test, Input)") 56 | subtasks: List["ScopeTask"] = Field(default_factory=list, description="List of subtasks") 57 | 58 | # parent_task: Optional[TaskReference] = Field(None, description="Reference to parent task") 59 | 60 | requirements: List[RequirementReference] = Field(default_factory=list, description="List of requirement references") 61 | files: List[FileReference] = Field(default_factory=list, description="List of file references") 62 | 63 | 64 | class ScopeContext(BaseModel): 65 | """Root model for scope context representing a tasks tree fragment.""" 66 | tasks: List[ScopeTask] = Field(default_factory=list, description="List of tasks in the scope") 67 | project_id: Optional[str] = Field(None, description="Project identifier") 68 | mode: ScopeContextMode = Field(..., description="Current state of the scope context") 69 | focus_tasks: List[str] = Field(default_factory=list, description="List of task designators to focus on") 70 | 71 | def find_task_by_designator(self, designator: str) -> Optional[ScopeTask]: 72 | """ 73 | Find a task by its designator. 74 | 75 | Args: 76 | designator: The task designator to search for 77 | 78 | Returns: 79 | The task with the specified designator, or None if not found 80 | """ 81 | def _find_task_recursive(task: ScopeTask) -> Optional[ScopeTask]: 82 | if task.task_designator == designator: 83 | return task 84 | 85 | for subtask in task.subtasks: 86 | found = _find_task_recursive(subtask) 87 | if found: 88 | return found 89 | 90 | return None 91 | 92 | for task in self.tasks: 93 | found = _find_task_recursive(task) 94 | if found: 95 | return found 96 | 97 | return None 98 | 99 | def is_done(self): 100 | all_tasks = [] 101 | 102 | def _traverse_tasks(task): 103 | all_tasks.append(task) 104 | for subtask in task.subtasks: 105 | _traverse_tasks(subtask) 106 | 107 | one = len(self.tasks) == 0 108 | two = all([t.status == TaskStatus.DONE for t in all_tasks]) 109 | 110 | return one or two 111 | 112 | def print_scope_tree(self) -> None: 113 | """ 114 | Print the scope tree structure to the console. 115 | This is a helper function to visualize the task hierarchy. 116 | """ 117 | if not self.tasks: 118 | print("Scope is empty or all tasks are done.") 119 | return 120 | 121 | for task in self.tasks: 122 | self._print_task_tree(task) 123 | 124 | def _print_task_tree(self, task: ScopeTask, prefix: str = "", is_last: bool = True) -> None: 125 | """ 126 | Helper function to print a task and its subtasks in a tree structure. 127 | 128 | Args: 129 | task: The task to print 130 | prefix: Current line prefix for formatting 131 | is_last: Whether this is the last item in its branch 132 | """ 133 | # Print current task with appropriate tree characters 134 | status_info = f"[{task.status.value}]" 135 | print(prefix + ("└── " if is_last else "├── ") + f"{task.task_designator} - {task.name} {status_info}") 136 | 137 | # Prepare prefix for children 138 | child_prefix = prefix + (" " if is_last else "│ ") 139 | 140 | # Print all subtasks 141 | if task.subtasks: 142 | for i, subtask in enumerate(task.subtasks): 143 | self._print_task_tree(subtask, child_prefix, i == len(task.subtasks) - 1) 144 | 145 | def render_as_plain_text(self, base_path: Optional[str] = None) -> str: 146 | """ 147 | Render the scope as plain text. 148 | 149 | Args: 150 | base_path: Optional base path for rendering relative file paths. 151 | If not provided, file paths will be rendered as is. 152 | 153 | Returns: 154 | A string representation of the scope. 155 | """ 156 | lines = [] 157 | 158 | if self.project_id: 159 | lines.append(f"Project: {self.project_id}") 160 | lines.append("") 161 | 162 | for task in self.tasks: 163 | lines.extend(self._render_task(task, 0, base_path)) 164 | 165 | return "\n".join(lines) 166 | 167 | def _render_task(self, task: ScopeTask, indent_level: int, base_path: Optional[str] = None) -> List[str]: 168 | """ 169 | Render a task and its subtasks as plain text. 170 | 171 | Args: 172 | task: The task to render. 173 | indent_level: The current indentation level. 174 | base_path: Optional base path for rendering relative file paths. 175 | 176 | Returns: 177 | A list of strings representing the task. 178 | """ 179 | indent = " " * indent_level 180 | lines = [] 181 | 182 | # Render task header 183 | task_header = f"{indent}Task: {task.task_designator}" 184 | if task.name: 185 | task_header += f" - {task.name}" 186 | lines.append(task_header) 187 | 188 | # Render task status 189 | lines.append(f"{indent} Status: {task.status.value}") 190 | 191 | # Render task type 192 | lines.append(f"{indent} Type: {task.type.value}") 193 | 194 | # Render task description if available 195 | if task.description: 196 | lines.append(f"{indent} Description: {task.description}") 197 | 198 | # Render requirements 199 | if task.requirements: 200 | lines.append(f"{indent} Requirements:") 201 | for req in task.requirements: 202 | lines.append(f"{indent} - {req.requirement_designator}") 203 | 204 | # Render files 205 | if task.files: 206 | lines.append(f"{indent} Files:") 207 | for file in task.files: 208 | file_path = file.file_path 209 | if base_path and os.path.isabs(file_path): 210 | try: 211 | file_path = os.path.relpath(file_path, base_path) 212 | except ValueError: 213 | # If paths are on different drives, keep the original path 214 | pass 215 | lines.append(f"{indent} - {file_path}") 216 | 217 | # Parent task reference is commented out in the model definition 218 | # Uncomment the following if parent_task is added to the model 219 | # if hasattr(task, 'parent_task') and task.parent_task: 220 | # lines.append(f"{indent} Parent: {task.parent_task.task_designator}") 221 | 222 | # Add a blank line after task details 223 | lines.append("") 224 | 225 | # Render subtasks 226 | for subtask in task.subtasks: 227 | lines.extend(self._render_task(subtask, indent_level + 1, base_path)) 228 | 229 | return lines 230 | 231 | # Resolve forward reference for ScopeTask.subtasks 232 | ScopeTask.model_rebuild() 233 | -------------------------------------------------------------------------------- /src/nautex/tui/widgets/inputs.py: -------------------------------------------------------------------------------- 1 | """Input-related widgets for the Nautex TUI.""" 2 | 3 | from typing import Callable, Optional, Union, Awaitable 4 | 5 | from textual.widgets import Input, Label, Button 6 | from textual.containers import Horizontal, Vertical 7 | from textual.widgets import Static, Markdown 8 | 9 | 10 | class ValidatedTextInput(Vertical): 11 | """A text input with validation, check mark, and error message.""" 12 | 13 | DEFAULT_CSS = """ 14 | ValidatedTextInput { 15 | height: auto; 16 | margin: 0; 17 | padding: 0 1; 18 | border: solid $primary; 19 | } 20 | 21 | /* ───────────────── title ───────────────── */ 22 | ValidatedTextInput > .title-row { 23 | height: 1; 24 | margin-left: 1; 25 | } 26 | 27 | /* ───────────────── input row ───────────────── */ 28 | ValidatedTextInput > .input-row { 29 | height: auto; 30 | } 31 | 32 | ValidatedTextInput .input-field { 33 | width: 1fr; /* fill remaining space */ 34 | } 35 | 36 | /* status icon as a button so it is clickable / focusable */ 37 | ValidatedTextInput .status-button { 38 | width: 3; 39 | height: 3; 40 | border: none; 41 | margin: 0 1 0 0; 42 | padding: 0; 43 | color: $text; 44 | } 45 | 46 | ValidatedTextInput .status-button-success { 47 | background: $success-darken-2; 48 | } 49 | 50 | ValidatedTextInput .status-button-error { 51 | background: $error-darken-2; 52 | } 53 | 54 | ValidatedTextInput .status-button-neutral { 55 | background: $surface; 56 | } 57 | 58 | /* ───────────────── footer (error + hint) ───────────────── */ 59 | ValidatedTextInput > .footer-row { 60 | height: 1; /* single terminal row */ 61 | margin-top: 0; 62 | } 63 | 64 | ValidatedTextInput .error-row { 65 | width: 1fr; /* stretch; pushes hint to the right */ 66 | color: $error; 67 | margin-left: 1; 68 | } 69 | 70 | ValidatedTextInput .save-message { 71 | width: auto; 72 | align-horizontal: right; 73 | color: $text-muted; 74 | display: none; /* shown only when value changes */ 75 | margin-right: 1; 76 | } 77 | """ 78 | 79 | def __init__( 80 | self, 81 | title: str, 82 | placeholder: str = "", 83 | validator: Optional[Callable[[str], Awaitable[tuple[bool, str]]]] = None, 84 | title_extra: Optional[Union[Static, Markdown]] = None, 85 | default_value: str = "", 86 | on_change: Optional[Callable[[str], Awaitable[None]]] = None, 87 | validate_on_init: bool = False, 88 | **kwargs 89 | ): 90 | super().__init__(**kwargs) 91 | self.border_title = title 92 | self.placeholder = placeholder 93 | self.validator = validator 94 | self.title_extra = title_extra 95 | self.default_value = default_value 96 | self.on_change = on_change 97 | self.validate_on_init = validate_on_init 98 | 99 | # Create widgets 100 | self.input_field = Input(placeholder=placeholder, value=default_value, classes="input-field") 101 | 102 | # Create a button for the status icon 103 | self.status_button = Button(" ", classes="status-button status-button-neutral") 104 | 105 | self.status_button.styles.max_width = 7 106 | 107 | # Add a message for when value changes 108 | self.save_message = Static("press enter to save", classes="save-message") 109 | self.save_message.display = False 110 | 111 | self.error_text = Static("", classes="error-row") 112 | 113 | # Track validation state 114 | self.is_valid = True 115 | self.error_message = "" 116 | self.value_changed = False 117 | self.validation_occurred = False 118 | 119 | def compose(self): 120 | """Compose the validated input layout.""" 121 | with Horizontal(classes="title-row"): 122 | if self.title_extra: 123 | yield self.title_extra 124 | 125 | with Horizontal(classes="input-row"): 126 | yield self.input_field 127 | yield self.status_button 128 | 129 | with Horizontal(classes="footer-row"): 130 | yield self.error_text # left 131 | yield self.save_message # right 132 | 133 | def on_mount(self): 134 | """Called when the widget is mounted.""" 135 | # Validate the initial value when the widget is mounted (if validate_on_init is True) 136 | if self.validator and self.validate_on_init: 137 | self.app.call_later(self.validate_initial) 138 | # If no validator or not validating on init, ensure we stay in neutral state 139 | else: 140 | self.status_button.label = " " 141 | self.status_button.add_class("status-button-neutral") 142 | self.status_button.remove_class("status-button-success") 143 | self.status_button.remove_class("status-button-error") 144 | 145 | async def validate_initial(self): 146 | """Validate the initial value.""" 147 | if self.validator: 148 | await self.validate() 149 | 150 | def on_input_changed(self, event): 151 | """Handle input value changes.""" 152 | # Show the save message when the value changes 153 | self.value_changed = True 154 | self.save_message.display = True 155 | 156 | # No validation here - validation happens only on Enter key press 157 | 158 | async def on_input_submitted(self, event): 159 | """Handle input submission (Enter key).""" 160 | # Always hide the save message when Enter is pressed 161 | self.save_message.display = False 162 | 163 | # Validate the input when Enter is pressed 164 | self.set_status("wait") 165 | 166 | if self.validator: 167 | valid = await self.validate() 168 | else: 169 | valid = True 170 | 171 | self.set_status("valid" if valid else "invalid") 172 | 173 | # Only call on_change if the value has changed and validation passed 174 | if self.value_changed and self.on_change and valid: 175 | self.value_changed = False 176 | # Call the on_change callback 177 | await self.on_change(self.value) 178 | 179 | 180 | def set_status(self, status: str): 181 | if status == "valid": 182 | self.status_button.label = "✓" 183 | self.status_button.remove_class("status-button-error") 184 | self.status_button.remove_class("status-button-neutral") 185 | self.status_button.add_class("status-button-success") 186 | self.error_text.update("") 187 | elif status == "wait": 188 | self.status_button.label = "⌛" 189 | self.status_button.remove_class("status-button-error") 190 | self.status_button.remove_class("status-button-success") 191 | 192 | self.status_button.add_class("status-button-neutral") 193 | self.error_text.update("") 194 | elif status == "invalid": 195 | self.status_button.label = "✗" 196 | self.status_button.remove_class("status-button-success") 197 | self.status_button.remove_class("status-button-neutral") 198 | self.status_button.add_class("status-button-error") 199 | self.error_text.update(self.error_message) 200 | else: 201 | self.status_button.label = "" 202 | self.status_button.remove_class("status-button-success") 203 | self.status_button.remove_class("status-button-error") 204 | self.status_button.add_class("status-button-neutral") 205 | self.error_text.update("") 206 | 207 | 208 | async def validate(self) -> bool: 209 | """Validate the current input value.""" 210 | if self.validator: 211 | self.is_valid, self.error_message = await self.validator(self.value) 212 | self.validation_occurred = True 213 | 214 | # Remove neutral state if this is the first validation 215 | self.status_button.remove_class("status-button-neutral") 216 | 217 | return self.is_valid 218 | 219 | @property 220 | def value(self) -> str: 221 | """Get the input value.""" 222 | return self.input_field.value 223 | 224 | def set_value(self, value: str) -> None: 225 | """Set the input value.""" 226 | self.input_field.value = value 227 | 228 | # Reset the value_changed flag and hide the save message 229 | self.value_changed = False 230 | self.save_message.display = False 231 | 232 | # Reset to neutral state unless we've already validated 233 | if not self.validation_occurred: 234 | self.status_button.label = " " 235 | self.status_button.remove_class("status-button-success") 236 | self.status_button.remove_class("status-button-error") 237 | self.status_button.add_class("status-button-neutral") 238 | 239 | # Only validate if validate_on_init is True 240 | if self.validator and self.validate_on_init: 241 | self.app.call_later(self.validate) 242 | 243 | def focus(self, scroll_visible: bool = True) -> None: 244 | """Focus the input field.""" 245 | self.input_field.focus() 246 | -------------------------------------------------------------------------------- /src/nautex/services/section_managed_file_service.py: -------------------------------------------------------------------------------- 1 | import re 2 | from pathlib import Path 3 | from typing import Optional, Tuple 4 | 5 | 6 | class SectionManagedFileService: 7 | """Generic service for managing files with marked sections that can be safely updated.""" 8 | 9 | def __init__(self, section_start_marker: str, section_end_marker: str): 10 | """Initialize with custom section markers.""" 11 | self.section_start = section_start_marker 12 | self.section_end = section_end_marker 13 | 14 | def has_section(self, file_path: Path) -> bool: 15 | """Check if file contains the marked section.""" 16 | if not file_path.exists(): 17 | return False 18 | 19 | try: 20 | content = file_path.read_text(encoding='utf-8') 21 | return self.section_start in content and self.section_end in content 22 | except Exception: 23 | return False 24 | 25 | def find_section_boundaries(self, content: str) -> Optional[Tuple[int, int]]: 26 | """Find start and end positions of the marked section.""" 27 | start_match = re.search(re.escape(self.section_start), content) 28 | end_match = re.search(re.escape(self.section_end), content) 29 | 30 | if start_match and end_match and end_match.start() > start_match.start(): 31 | return start_match.start(), end_match.end() 32 | return None 33 | 34 | def read_file_or_default(self, file_path: Path, default_content: str) -> str: 35 | """Read file content or return default if file doesn't exist.""" 36 | if file_path.exists(): 37 | return file_path.read_text(encoding='utf-8') 38 | return default_content 39 | 40 | def update_section(self, file_path: Path, section_content: str, default_content: str = "") -> bool: 41 | """ 42 | Update or add marked section in file. 43 | Returns True if file was modified. 44 | """ 45 | # Read existing content or use default 46 | content = self.read_file_or_default(file_path, default_content) 47 | 48 | # Prepare full section with markers 49 | full_section = f"{self.section_start}\n\n{section_content.strip()}\n\n{self.section_end}" 50 | 51 | # Find existing section boundaries 52 | section_bounds = self.find_section_boundaries(content) 53 | 54 | if section_bounds: 55 | # Replace existing section, preserving surrounding whitespace 56 | start, end = section_bounds 57 | # Check if there's already a newline before the section 58 | prefix = "\n" if start > 0 and content[start-1] != '\n' else "" 59 | # Check if there's already a newline after the section 60 | suffix = "\n" if end < len(content) and content[end] != '\n' else "" 61 | new_content = content[:start] + prefix + full_section + suffix + content[end:] 62 | else: 63 | # Append section at the end 64 | new_content = content.rstrip() + "\n\n" + full_section + "\n" 65 | 66 | # Ensure parent directory exists and write content 67 | file_path.parent.mkdir(parents=True, exist_ok=True) 68 | file_path.write_text(new_content, encoding='utf-8') 69 | 70 | return True 71 | 72 | def ensure_file_with_section( 73 | self, 74 | file_path: Path, 75 | section_content: str, 76 | default_content: str = "" 77 | ) -> bool: 78 | """ 79 | Ensure file exists and contains the marked section with correct content. 80 | Returns True if any changes were made. 81 | """ 82 | # Check if file exists and has section 83 | if not file_path.exists(): 84 | # File doesn't exist, create it with section 85 | self.update_section(file_path, section_content, default_content) 86 | return True 87 | 88 | # File exists, check if it has the section and if content matches 89 | content = file_path.read_text(encoding='utf-8') 90 | section_bounds = self.find_section_boundaries(content) 91 | 92 | if not section_bounds: 93 | # Section doesn't exist, add it 94 | self.update_section(file_path, section_content, default_content) 95 | return True 96 | 97 | # Extract current section content (without markers) 98 | start, end = section_bounds 99 | current_section = content[start:end] 100 | 101 | # Build expected section with markers for comparison (same format as update_section) 102 | expected_section = f"{self.section_start}\n\n{section_content.strip()}\n\n{self.section_end}" 103 | 104 | # Compare normalized content (strip extra whitespace for comparison) 105 | if current_section.strip() != expected_section.strip(): 106 | # Content differs, update it 107 | self.update_section(file_path, section_content, default_content) 108 | return True 109 | 110 | return False 111 | 112 | 113 | # Tests for the service 114 | def test_section_managed_file_service(): 115 | """Test cases for SectionManagedFileService.""" 116 | import tempfile 117 | 118 | # Test markers 119 | start_marker = "" 120 | end_marker = "" 121 | service = SectionManagedFileService(start_marker, end_marker) 122 | 123 | with tempfile.TemporaryDirectory() as temp_dir: 124 | test_file = Path(temp_dir) / "test.md" 125 | 126 | # Test 1: File doesn't exist - should create with section 127 | section_content = "# Test Section\nThis is test content." 128 | default_content = "# Default File\nDefault content here." 129 | 130 | result = service.ensure_file_with_section(test_file, section_content, default_content) 131 | assert result == True, "Should return True when file is created" 132 | assert test_file.exists(), "File should be created" 133 | 134 | content = test_file.read_text() 135 | assert start_marker in content, "Should contain start marker" 136 | assert end_marker in content, "Should contain end marker" 137 | assert "Test Section" in content, "Should contain section content" 138 | print("✓ Test 1 passed: File creation with section") 139 | 140 | # Test 2: File exists without section - should add section 141 | test_file2 = Path(temp_dir) / "test2.md" 142 | test_file2.write_text("# Existing File\nExisting content.") 143 | 144 | result = service.ensure_file_with_section(test_file2, section_content) 145 | assert result == True, "Should return True when section is added" 146 | 147 | content = test_file2.read_text() 148 | assert "Existing File" in content, "Should preserve existing content" 149 | assert start_marker in content, "Should add start marker" 150 | assert "Test Section" in content, "Should add section content" 151 | print("✓ Test 2 passed: Adding section to existing file") 152 | 153 | # Test 3: File exists with section - should not modify 154 | result = service.ensure_file_with_section(test_file2, section_content) 155 | assert result == False, "Should return False when no changes needed" 156 | print("✓ Test 3 passed: No modification when section exists") 157 | 158 | # Test 4: Update existing section 159 | new_section_content = "# Updated Section\nUpdated content." 160 | service.update_section(test_file2, new_section_content) 161 | 162 | content = test_file2.read_text() 163 | assert "Updated Section" in content, "Should contain updated content" 164 | assert "Test Section" not in content, "Should not contain old content" 165 | assert content.count(start_marker) == 1, "Should have only one start marker" 166 | print("✓ Test 4 passed: Section update") 167 | 168 | # Test 5: Section boundaries detection 169 | test_content = f"""# File Header 170 | Some content 171 | {start_marker} 172 | # Section Content 173 | {end_marker} 174 | More content""" 175 | 176 | boundaries = service.find_section_boundaries(test_content) 177 | assert boundaries is not None, "Should find section boundaries" 178 | start_pos, end_pos = boundaries 179 | assert test_content[start_pos:end_pos].startswith(start_marker), "Start position should be correct" 180 | assert test_content[start_pos:end_pos].endswith(end_marker), "End position should be correct" 181 | print("✓ Test 5 passed: Section boundary detection") 182 | 183 | # Test 6: Content comparison and update 184 | test_file3 = Path(temp_dir) / "test3.md" 185 | old_section = "# Old Content\nOld text" 186 | new_section = "# New Content\nNew text" 187 | 188 | # Create file with old section 189 | service.ensure_file_with_section(test_file3, old_section, default_content) 190 | initial_content = test_file3.read_text() 191 | 192 | # Try with same content - should not update 193 | result = service.ensure_file_with_section(test_file3, old_section, default_content) 194 | assert result == False, "Should not update when content is the same" 195 | 196 | # Try with different content - should update 197 | result = service.ensure_file_with_section(test_file3, new_section, default_content) 198 | assert result == True, "Should update when content differs" 199 | 200 | updated_content = test_file3.read_text() 201 | assert "New Content" in updated_content, "Should contain new content" 202 | assert "Old Content" not in updated_content, "Should not contain old content" 203 | print("✓ Test 6 passed: Content comparison and update") 204 | 205 | print("All tests passed! ✅") 206 | 207 | 208 | if __name__ == "__main__": 209 | test_section_managed_file_service() 210 | -------------------------------------------------------------------------------- /tests/check_mcp_models.py: -------------------------------------------------------------------------------- 1 | """Sample script to generate ScopeContext instances and convert them to MCP responses.""" 2 | 3 | import json 4 | 5 | 6 | from src.nautex.api.scope_context_model import ScopeContextMode, ScopeContext, ScopeTask, TaskStatus, TaskType, \ 7 | RequirementReference, FileReference 8 | from src.nautex.models.mcp import convert_scope_context_to_mcp_response 9 | 10 | 11 | def process_and_print_scope_context(scope_context: ScopeContext, case_name: str) -> None: 12 | """ 13 | Process a ScopeContext through convert_scope_context_to_mcp_response and print the result. 14 | 15 | Args: 16 | scope_context: The ScopeContext to process 17 | case_name: A name for this case to identify it in the output 18 | """ 19 | print(f"\n\n{'=' * 80}") 20 | print(f"CASE: {case_name}") 21 | print(f"{'=' * 80}") 22 | 23 | # Convert to MCP response 24 | response = convert_scope_context_to_mcp_response(scope_context, {}) 25 | 26 | # Print the response as JSON 27 | print(json.dumps(response.model_dump(), indent=2)) 28 | 29 | 30 | def generate_basic_scope_context() -> ScopeContext: 31 | """Generate a basic ScopeContext with a single task.""" 32 | task = ScopeTask( 33 | task_designator="TASK-1", 34 | name="Test Task", 35 | description="A test task", 36 | status=TaskStatus.NOT_STARTED, 37 | type=TaskType.CODE, 38 | requirements=[ 39 | RequirementReference(requirement_designator="REQ-1") 40 | ], 41 | files=[ 42 | FileReference(file_path="/path/to/file.py") 43 | ] 44 | ) 45 | 46 | return ScopeContext( 47 | tasks=[task], 48 | project_id="PROJECT-1", 49 | mode=ScopeContextMode.ExecuteSubtasks, 50 | focus_tasks=["TASK-1"] 51 | ) 52 | 53 | 54 | def generate_task_hierarchy_scope_context() -> ScopeContext: 55 | """Generate a ScopeContext with a task hierarchy.""" 56 | subtask1 = ScopeTask( 57 | task_designator="TASK-1.1", 58 | name="Subtask 1", 59 | description="A subtask", 60 | status=TaskStatus.NOT_STARTED, 61 | type=TaskType.CODE 62 | ) 63 | 64 | subtask2 = ScopeTask( 65 | task_designator="TASK-1.2", 66 | name="Subtask 2", 67 | description="Another subtask", 68 | status=TaskStatus.IN_PROGRESS, 69 | type=TaskType.TEST 70 | ) 71 | 72 | parent_task = ScopeTask( 73 | task_designator="TASK-1", 74 | name="Parent Task", 75 | description="A parent task", 76 | status=TaskStatus.IN_PROGRESS, 77 | type=TaskType.CODE, 78 | subtasks=[subtask1, subtask2] 79 | ) 80 | 81 | return ScopeContext( 82 | tasks=[parent_task], 83 | project_id="PROJECT-1", 84 | mode=ScopeContextMode.ExecuteSubtasks, 85 | focus_tasks=["TASK-1.1", "TASK-1.2"] 86 | ) 87 | 88 | 89 | def generate_finalize_master_task_scope_context() -> ScopeContext: 90 | """Generate a ScopeContext with FinalizeMasterTask mode.""" 91 | # Create subtasks that are in DONE state 92 | subtask1 = ScopeTask( 93 | task_designator="TASK-1.1", 94 | name="Subtask 1", 95 | description="A completed subtask", 96 | status=TaskStatus.DONE, 97 | type=TaskType.CODE 98 | ) 99 | 100 | subtask2 = ScopeTask( 101 | task_designator="TASK-1.2", 102 | name="Subtask 2", 103 | description="Another completed subtask", 104 | status=TaskStatus.DONE, 105 | type=TaskType.TEST 106 | ) 107 | 108 | # Create the master task with subtasks in DONE state 109 | master_task = ScopeTask( 110 | task_designator="TASK-1", 111 | name="Master Task", 112 | description="A master task with completed subtasks", 113 | status=TaskStatus.IN_PROGRESS, 114 | type=TaskType.CODE, 115 | subtasks=[subtask1, subtask2] 116 | ) 117 | 118 | return ScopeContext( 119 | tasks=[master_task], 120 | project_id="PROJECT-1", 121 | mode=ScopeContextMode.FinalizeMasterTask, 122 | focus_tasks=["TASK-1"] 123 | ) 124 | 125 | 126 | def generate_focus_tasks_scope_context() -> ScopeContext: 127 | """Generate a ScopeContext with multiple tasks but only one in focus.""" 128 | task1 = ScopeTask( 129 | task_designator="TASK-1", 130 | name="Task 1", 131 | description="First task", 132 | status=TaskStatus.NOT_STARTED, 133 | type=TaskType.CODE 134 | ) 135 | 136 | task2 = ScopeTask( 137 | task_designator="TASK-2", 138 | name="Task 2", 139 | description="Second task", 140 | status=TaskStatus.NOT_STARTED, 141 | type=TaskType.CODE 142 | ) 143 | 144 | return ScopeContext( 145 | tasks=[task1, task2], 146 | project_id="PROJECT-1", 147 | mode=ScopeContextMode.ExecuteSubtasks, 148 | focus_tasks=["TASK-1"] # Only TASK-1 is in focus 149 | ) 150 | 151 | 152 | def generate_task_status_scope_context() -> ScopeContext: 153 | """Generate a ScopeContext with tasks of different statuses.""" 154 | not_started_task = ScopeTask( 155 | task_designator="TASK-1", 156 | name="Not Started Task", 157 | description="A task not started", 158 | status=TaskStatus.NOT_STARTED, 159 | type=TaskType.CODE 160 | ) 161 | 162 | in_progress_task = ScopeTask( 163 | task_designator="TASK-2", 164 | name="In Progress Task", 165 | description="A task in progress", 166 | status=TaskStatus.IN_PROGRESS, 167 | type=TaskType.CODE 168 | ) 169 | 170 | done_task = ScopeTask( 171 | task_designator="TASK-3", 172 | name="Done Task", 173 | description="A completed task", 174 | status=TaskStatus.DONE, 175 | type=TaskType.CODE 176 | ) 177 | 178 | blocked_task = ScopeTask( 179 | task_designator="TASK-4", 180 | name="Blocked Task", 181 | description="A blocked task", 182 | status=TaskStatus.BLOCKED, 183 | type=TaskType.CODE 184 | ) 185 | 186 | return ScopeContext( 187 | tasks=[not_started_task, in_progress_task, done_task, blocked_task], 188 | project_id="PROJECT-1", 189 | mode=ScopeContextMode.ExecuteSubtasks, 190 | focus_tasks=["TASK-1", "TASK-2", "TASK-3", "TASK-4"] 191 | ) 192 | 193 | 194 | def generate_task_type_scope_context() -> ScopeContext: 195 | """Generate a ScopeContext with tasks of different types.""" 196 | code_task = ScopeTask( 197 | task_designator="TASK-1", 198 | name="Code Task", 199 | description="A coding task", 200 | status=TaskStatus.NOT_STARTED, 201 | type=TaskType.CODE 202 | ) 203 | 204 | review_task = ScopeTask( 205 | task_designator="TASK-2", 206 | name="Review Task", 207 | description="A review task", 208 | status=TaskStatus.NOT_STARTED, 209 | type=TaskType.REVIEW 210 | ) 211 | 212 | test_task = ScopeTask( 213 | task_designator="TASK-3", 214 | name="Test Task", 215 | description="A testing task", 216 | status=TaskStatus.NOT_STARTED, 217 | type=TaskType.TEST 218 | ) 219 | 220 | input_task = ScopeTask( 221 | task_designator="TASK-4", 222 | name="Input Task", 223 | description="An input task", 224 | status=TaskStatus.NOT_STARTED, 225 | type=TaskType.INPUT 226 | ) 227 | 228 | return ScopeContext( 229 | tasks=[code_task, review_task, test_task, input_task], 230 | project_id="PROJECT-1", 231 | mode=ScopeContextMode.ExecuteSubtasks, 232 | focus_tasks=["TASK-1", "TASK-2", "TASK-3", "TASK-4"] 233 | ) 234 | 235 | 236 | def generate_complex_hierarchy_scope_context() -> ScopeContext: 237 | """Generate a complex ScopeContext with a deep task hierarchy and mixed statuses.""" 238 | grandchild1 = ScopeTask( 239 | task_designator="TASK-1.1.1", 240 | name="Grandchild 1", 241 | description="A grandchild task", 242 | status=TaskStatus.DONE, 243 | type=TaskType.CODE 244 | ) 245 | 246 | grandchild2 = ScopeTask( 247 | task_designator="TASK-1.1.2", 248 | name="Grandchild 2", 249 | description="Another grandchild task", 250 | status=TaskStatus.IN_PROGRESS, 251 | type=TaskType.TEST 252 | ) 253 | 254 | child1 = ScopeTask( 255 | task_designator="TASK-1.1", 256 | name="Child 1", 257 | description="A child task", 258 | status=TaskStatus.IN_PROGRESS, 259 | type=TaskType.CODE, 260 | subtasks=[grandchild1, grandchild2] 261 | ) 262 | 263 | child2 = ScopeTask( 264 | task_designator="TASK-1.2", 265 | name="Child 2", 266 | description="Another child task", 267 | status=TaskStatus.NOT_STARTED, 268 | type=TaskType.REVIEW 269 | ) 270 | 271 | parent = ScopeTask( 272 | task_designator="TASK-1", 273 | name="Parent Task", 274 | description="A parent task", 275 | status=TaskStatus.IN_PROGRESS, 276 | type=TaskType.CODE, 277 | subtasks=[child1, child2] 278 | ) 279 | 280 | return ScopeContext( 281 | tasks=[parent], 282 | project_id="PROJECT-1", 283 | mode=ScopeContextMode.ExecuteSubtasks, 284 | focus_tasks=["TASK-1.1.2", "TASK-1.2"] # Focus on specific tasks 285 | ) 286 | 287 | 288 | def generate_empty_scope_context() -> ScopeContext: 289 | """Generate an empty ScopeContext.""" 290 | return ScopeContext( 291 | tasks=[], 292 | project_id="PROJECT-1", 293 | mode=ScopeContextMode.ExecuteSubtasks, 294 | focus_tasks=[] 295 | ) 296 | 297 | 298 | def main(): 299 | """Main function to generate and process all scope contexts.""" 300 | # Define all the scope context generators 301 | scope_generators = { 302 | "Basic Scope Context": generate_basic_scope_context, 303 | "Task Hierarchy Scope Context": generate_task_hierarchy_scope_context, 304 | "Finalize Master Task Scope Context": generate_finalize_master_task_scope_context, 305 | "Focus Tasks Scope Context": generate_focus_tasks_scope_context, 306 | "Task Status Scope Context": generate_task_status_scope_context, 307 | "Task Type Scope Context": generate_task_type_scope_context, 308 | "Complex Hierarchy Scope Context": generate_complex_hierarchy_scope_context, 309 | "Empty Scope Context": generate_empty_scope_context 310 | } 311 | 312 | # Generate and process each scope context 313 | for name, generator in scope_generators.items(): 314 | scope_context = generator() 315 | process_and_print_scope_context(scope_context, name) 316 | 317 | 318 | if __name__ == "__main__": 319 | main() 320 | -------------------------------------------------------------------------------- /src/nautex/services/nautex_api_service.py: -------------------------------------------------------------------------------- 1 | """Nautex API Service for business logic and model mapping.""" 2 | 3 | from typing import Optional, List, Dict, Any, Tuple 4 | import logging 5 | import asyncio 6 | import aiohttp 7 | from pydantic import SecretStr 8 | import time 9 | 10 | from . import ConfigurationService 11 | from ..api.client import NautexAPIClient, NautexAPIError 12 | from ..api.api_models import ( 13 | AccountInfo, 14 | Project, 15 | ImplementationPlan, 16 | Task, 17 | APIResponse, TaskOperation 18 | ) 19 | 20 | # Set up logging 21 | logger = logging.getLogger(__name__) 22 | 23 | 24 | class NautexAPIService: 25 | """Business logic layer for interacting with the Nautex.ai API.""" 26 | 27 | def __init__(self, api_client: NautexAPIClient, config_service: ConfigurationService): 28 | """Initialize the API service. 29 | 30 | Args: 31 | api_client: The API client 32 | config: Application configuration containing API settings 33 | """ 34 | self.api_client = api_client 35 | self.config_service = config_service 36 | self.api_client.setup_token(self.get_token) 37 | 38 | logger.debug("NautexAPIService initialized") 39 | 40 | def get_token(self): 41 | rv = self.config_service.config.get_token() 42 | return rv 43 | 44 | 45 | async def check_network_connectivity(self, timeout: float = 5.0) -> Tuple[bool, Optional[float], Optional[str]]: 46 | """Check network connectivity to the API host with short timeout. 47 | 48 | Args: 49 | timeout: Request timeout in seconds (default: 5.0) 50 | 51 | Returns: 52 | Tuple of (is_connected, response_time, error_message) 53 | """ 54 | 55 | start_time = time.time() 56 | try: 57 | # Use get_account_info with the specified timeout to check connectivity 58 | await self.api_client.get_account_info(timeout=timeout, token_override="Not valid token for connection check") 59 | 60 | response_time = time.time() - start_time 61 | return True, response_time, None 62 | 63 | except NautexAPIError as e: 64 | response_time = time.time() - start_time 65 | # Even if we get an API error (like 401 unauthorized), it means network is reachable 66 | if e.status_code is not None and e.status_code < 500: 67 | return True, response_time, None 68 | else: 69 | return False, response_time, f"API error: {str(e)}" 70 | except asyncio.TimeoutError: 71 | response_time = time.time() - start_time 72 | return False, response_time, "Connection timeout" 73 | except aiohttp.ClientConnectorError as e: 74 | response_time = time.time() - start_time 75 | return False, response_time, f"Connection failed: {str(e)}" 76 | except Exception as e: 77 | response_time = time.time() - start_time 78 | return False, response_time, f"Network error: {str(e)}" 79 | 80 | # Latency properties 81 | 82 | @property 83 | def latency_stats(self) -> Dict[str, Tuple[float, float]]: 84 | """Get min/max latency statistics for all endpoint types. 85 | 86 | Returns: 87 | Dictionary mapping endpoint types to (min, max) latency tuples 88 | """ 89 | return self.api_client.get_latency_stats() 90 | 91 | @property 92 | def api_latency(self) -> Tuple[float, float]: 93 | """Get min/max latency across all endpoint types. 94 | 95 | Returns: 96 | Tuple of (min_latency, max_latency) in seconds 97 | """ 98 | stats = self.api_client.get_latency_stats() 99 | if not stats: 100 | return (0.0, 0.0) 101 | 102 | # Collect all latency measurements 103 | all_min_values = [min_val for min_val, _ in stats.values() if min_val > 0] 104 | all_max_values = [max_val for _, max_val in stats.values() if max_val > 0] 105 | 106 | # Calculate overall min/max 107 | if all_min_values and all_max_values: 108 | return (min(all_min_values), max(all_max_values)) 109 | return (0.0, 0.0) 110 | 111 | # For backward compatibility 112 | @property 113 | def account_latency(self) -> Tuple[float, float]: 114 | """Get min/max latency across all endpoints (for backward compatibility). 115 | 116 | Returns: 117 | Tuple of (min_latency, max_latency) in seconds 118 | """ 119 | return self.api_latency 120 | 121 | # API endpoint implementations 122 | 123 | 124 | async def get_account_info(self, *, token_override: Optional[str] = None, raise_exception: bool = True, timeout: Optional[float] = None) -> Optional[AccountInfo]: 125 | """Retrieve account information using the current token. 126 | 127 | Returns: 128 | Account information 129 | 130 | Raises: 131 | NautexAPIError: If token is invalid or API call fails 132 | """ 133 | try: 134 | return await self.api_client.get_account_info(token_override=token_override, timeout=timeout) 135 | except NautexAPIError as e: 136 | if raise_exception: 137 | raise 138 | return None 139 | 140 | async def verify_token_and_get_account_info(self, token: Optional[str] = None) -> AccountInfo: 141 | # TODO update 142 | """Verify API token and retrieve account information. 143 | 144 | This method is maintained for backward compatibility. 145 | New code should use verify_token() and get_account_info() separately. 146 | 147 | Args: 148 | token: API token to verify (uses config token if not provided) 149 | 150 | Returns: 151 | Account information 152 | 153 | Raises: 154 | NautexAPIError: If token is invalid or API call fails 155 | """ 156 | if token: 157 | # Temporarily set the token for verification 158 | original_token = self.api_client._token 159 | self.api_client.setup_token(token) 160 | 161 | try: 162 | account_info = await self.api_client.get_account_info() 163 | 164 | # If verification succeeded, update config with the new token 165 | self.config.api_token = SecretStr(token) 166 | 167 | return account_info 168 | except Exception: 169 | # Restore original token if verification failed 170 | self.api_client.setup_token(original_token) 171 | raise 172 | else: 173 | # Use the current token 174 | return await self.api_client.get_account_info() 175 | 176 | async def list_projects(self) -> List[Project]: 177 | """List all projects available to the user. 178 | 179 | Returns: 180 | List of projects 181 | 182 | Raises: 183 | NautexAPIError: If API call fails 184 | """ 185 | try: 186 | return await self.api_client.list_projects() 187 | except NautexAPIError as e: 188 | logger.error(f"Failed to list projects: {e}") 189 | raise 190 | 191 | async def list_implementation_plans(self, project_id: str, from_mcp: bool = False) -> List[ImplementationPlan]: 192 | """List implementation plans for a specific project. 193 | 194 | Args: 195 | project_id: ID of the project 196 | from_mcp: Whether the request is coming from MCP 197 | 198 | Returns: 199 | List of implementation plans 200 | 201 | Raises: 202 | NautexAPIError: If API call fails 203 | """ 204 | try: 205 | return await self.api_client.list_implementation_plans(project_id, from_mcp=from_mcp) 206 | except NautexAPIError as e: 207 | logger.error(f"Failed to list implementation plans for project {project_id}: {e}") 208 | raise 209 | 210 | async def next_scope(self, project_id: str, plan_id: str, from_mcp: bool = False) -> Optional["ScopeContext"]: 211 | """Get the next scope for a specific project and plan. 212 | 213 | Args: 214 | project_id: ID of the project 215 | plan_id: ID of the implementation plan 216 | from_mcp: Whether the request is coming from MCP 217 | 218 | Returns: 219 | A ScopeContext object containing the next scope information, or None if no scope is available 220 | 221 | Raises: 222 | NautexAPIError: If API call fails 223 | """ 224 | 225 | try: 226 | return await self.api_client.get_next_scope(project_id, plan_id, from_mcp=from_mcp) 227 | except NautexAPIError as e: 228 | logger.error(f"Failed to get next scope for project {project_id}, plan {plan_id}: {e}") 229 | raise 230 | 231 | async def update_tasks(self, project_id: str, plan_id: str, operations: List["TaskOperation"], from_mcp: bool = False) -> APIResponse: 232 | """Update multiple tasks in a batch operation. 233 | 234 | Args: 235 | project_id: ID of the project 236 | plan_id: ID of the implementation plan 237 | operations: List of TaskOperation objects, each containing: 238 | - task_designator: The designator of the task to update 239 | - updated_status: Optional new status for the task 240 | - new_note: Optional new note to add to the task 241 | from_mcp: Whether the request is coming from MCP 242 | 243 | Returns: 244 | API response containing the results of the operations 245 | 246 | Raises: 247 | NautexAPIError: If API call fails 248 | """ 249 | 250 | try: 251 | response_data = await self.api_client.update_tasks_batch(project_id, plan_id, operations, from_mcp=from_mcp) 252 | return APIResponse.model_validate(response_data) 253 | except NautexAPIError as e: 254 | logger.error(f"Failed to execute batch task update: {e}") 255 | raise 256 | 257 | async def get_implementation_plan(self, project_id: str, plan_id: str, from_mcp: bool = False) -> Optional["ImplementationPlan"]: 258 | """Get a specific implementation plan by plan_id. 259 | 260 | Args: 261 | project_id: ID of the project 262 | plan_id: ID of the implementation plan 263 | from_mcp: Whether the request is coming from MCP 264 | 265 | Returns: 266 | An ImplementationPlan object containing the plan details, or None if the plan was not found 267 | 268 | Raises: 269 | NautexAPIError: If API call fails 270 | """ 271 | 272 | try: 273 | return await self.api_client.get_implementation_plan(project_id, plan_id, from_mcp=from_mcp) 274 | except NautexAPIError as e: 275 | logger.error(f"Failed to get implementation plan {plan_id} for project {project_id}: {e}") 276 | raise 277 | 278 | async def get_document_tree(self, project_id: str, doc_designator: str, from_mcp: bool = False) -> Optional["Document"]: 279 | """Get a document tree by designator. 280 | 281 | Args: 282 | project_id: The ID of the project 283 | doc_designator: The designator of the document 284 | from_mcp: Whether the request is coming from MCP 285 | 286 | Returns: 287 | A Document object containing the document tree, or None if the document was not found 288 | 289 | Raises: 290 | NautexAPIError: If API call fails 291 | """ 292 | 293 | try: 294 | return await self.api_client.get_document_tree(project_id, doc_designator, from_mcp=from_mcp) 295 | except NautexAPIError as e: 296 | logger.error(f"Failed to get document tree for {doc_designator} in project {project_id}: {e}") 297 | raise 298 | -------------------------------------------------------------------------------- /src/nautex/tui/widgets/loadable_list.py: -------------------------------------------------------------------------------- 1 | """Loadable list widget for the Nautex TUI.""" 2 | 3 | import asyncio 4 | import inspect 5 | from typing import Callable, List, Optional, Any, Union, Awaitable, Iterable, Tuple 6 | 7 | from textual.app import ComposeResult 8 | from textual.containers import Vertical, Horizontal 9 | from textual.widgets import Static, Button, LoadingIndicator, ListView, ListItem, Label 10 | from textual.reactive import reactive 11 | from textual.binding import Binding 12 | from textual.message import Message 13 | 14 | 15 | class LoadableList(Vertical): 16 | """A list widget that can load data asynchronously and display a loading indicator.""" 17 | 18 | DEFAULT_CSS = """ 19 | LoadableList { 20 | height: 1fr; 21 | margin: 0; 22 | padding: 0; 23 | border: solid $primary; 24 | border-bottom: solid $primary; 25 | } 26 | 27 | LoadableList.disabled { 28 | opacity: 0.5; 29 | border: solid $error; 30 | border-bottom: solid $error; 31 | } 32 | 33 | LoadableList .list-view > ListItem { 34 | height: 1; 35 | margin: 0; 36 | padding: 0 1; 37 | } 38 | 39 | LoadableList .loading-container { 40 | height: 3; 41 | align: center middle; 42 | background: $surface-lighten-1; 43 | width: 100%; 44 | } 45 | 46 | LoadableList .save-message { 47 | width: auto; 48 | align-horizontal: right; 49 | color: $text-muted; 50 | display: none; /* shown only when value changes */ 51 | margin-top: 0; 52 | height: 1; 53 | padding: 0 1; 54 | } 55 | 56 | /* Taller item to show animated LoadingIndicator clearly */ 57 | LoadableList .loading-item { 58 | height: 3; 59 | align: center middle; /* centers children both ways */ 60 | background: $surface-lighten-1; 61 | } 62 | 63 | /* Style the spinner for visibility */ 64 | LoadableList .loading-item > LoadingIndicator { 65 | color: $primary; 66 | } 67 | 68 | /* Add vertical breathing room between border and first/last item */ 69 | LoadableList .list-view { 70 | padding-top: 1; 71 | padding-bottom: 1; 72 | } 73 | """ 74 | 75 | # Define a message class for selection changes 76 | class SelectionChanged(Message): 77 | """Message sent when the selection changes.""" 78 | 79 | def __init__(self, sender, selected_item: Optional[Any] = None): 80 | self.selected_item = selected_item 81 | super().__init__() 82 | 83 | # Reactive properties 84 | is_loading = reactive(False) 85 | is_disabled = reactive(False) 86 | value_changed = reactive(False) 87 | 88 | def __init__( 89 | self, 90 | title: str, 91 | data_loader: Optional[Callable[[], Awaitable[Tuple[List, Optional[int]]]]] = None, 92 | on_change: Optional[Callable[[Any], Awaitable[None]]] = None, 93 | **kwargs 94 | ): 95 | """Initialize the LoadableList widget. 96 | 97 | Args: 98 | title: The title of the list widget 99 | data_loader: A callable that returns data to be displayed in the list (can be async). 100 | Can return either a list of items or a tuple of (items_list, selected_index) 101 | where selected_index is the index of the item to be selected after loading. 102 | on_change: Async callback function called when the selection changes and Enter is pressed 103 | """ 104 | super().__init__(**kwargs) 105 | self.border_title = title 106 | self.data_loader = data_loader 107 | self.on_change = on_change 108 | self.empty_message = "No items found" 109 | 110 | # Create widgets 111 | self.save_message = Static("press enter to save", classes="save-message") 112 | self.save_message.display = False 113 | self.item_data = [] 114 | 115 | # Create the ListView 116 | self.list_view = ListView(classes="list-view", initial_index=None) 117 | 118 | def compose(self) -> ComposeResult: 119 | """Compose the loadable list layout.""" 120 | # Set the border title 121 | self.styles.border_title = self.border_title 122 | 123 | # Yield the ListView and the save message 124 | yield self.list_view 125 | yield self.save_message 126 | 127 | def on_mount(self): 128 | """Called when the widget is mounted. 129 | 130 | This method schedules the load_data method to be called in the next event loop iteration. 131 | It works with both synchronous and asynchronous data loaders. 132 | """ 133 | # Load initial data 134 | self.app.call_later(self.load_data) 135 | 136 | def reload(self): 137 | """Reload the list data. 138 | 139 | This method schedules the load_data method to be called in the next event loop iteration. 140 | It works with both synchronous and asynchronous data loaders. 141 | """ 142 | # Set loading state immediately to provide visual feedback 143 | self.is_loading = True 144 | # Schedule the load_data method to be called in the next event loop iteration 145 | self.app.call_later(self.load_data) 146 | 147 | async def load_data(self): 148 | """Load data into the list. 149 | 150 | If the data_loader returns a tuple of (items_list, selected_index), 151 | the selected_index will be used to set the selected item after loading. 152 | Otherwise, it expects the data_loader to return just a list of items. 153 | """ 154 | # Show loading state 155 | self.is_loading = True 156 | 157 | # Clear existing items 158 | await self.list_view.clear() 159 | 160 | # Create loading indicator dynamically to avoid re-mounting issues 161 | loading_spinner = LoadingIndicator() 162 | loading_item = ListItem(loading_spinner, classes="loading-item") 163 | await self.list_view.append(loading_item) 164 | 165 | # Disable interaction while loading 166 | self.list_view.disabled = True 167 | 168 | # Check if the list is disabled 169 | if self.is_disabled: 170 | # If disabled, show a message and don't load data 171 | self.is_loading = False 172 | await self.list_view.clear() 173 | await self.list_view.append(ListItem(Label("List is disabled"))) 174 | return 175 | 176 | # Load data 177 | if self.data_loader: 178 | try: 179 | # Check if the data_loader is a coroutine function 180 | if inspect.iscoroutinefunction(self.data_loader): 181 | # If it's async, await it 182 | result = await self.data_loader() 183 | else: 184 | # If it's not async, call it directly 185 | result = self.data_loader() 186 | except Exception as e: 187 | self.app.log(f"Error loading data: {str(e)}") 188 | result = ["Error loading data"] 189 | else: 190 | # No data loader provided 191 | result = [] 192 | 193 | # Update UI with data 194 | self.is_loading = False 195 | 196 | # Clear previous items (loading indicator) 197 | await self.list_view.clear() 198 | 199 | # Check if result is a tuple with (items_list, selected_index) 200 | selected_index = None 201 | if isinstance(result, tuple) and len(result) == 2: 202 | data, selected_index = result 203 | else: 204 | data = result 205 | 206 | # Add items to the list 207 | self.item_data = [] 208 | if data: 209 | for item in data: 210 | # Use name attribute if available, otherwise convert to string 211 | item_str = item.name if hasattr(item, 'name') else str(item) 212 | list_item = ListItem(Label(item_str)) 213 | self.item_data.append(item) 214 | await self.list_view.append(list_item) 215 | 216 | # Set selected item if provided 217 | if selected_index is not None and 0 <= selected_index < len(self.item_data): 218 | self.list_view.index = selected_index 219 | else: 220 | # If no data, show the empty message 221 | items = self.empty_message.split("\n") 222 | list_items = [ListItem(Label(l)) for l in items] 223 | await self.list_view.extend(list_items) 224 | 225 | # Re-enable interaction 226 | self.list_view.disabled = self.is_disabled # remain disabled only if explicitly disabled 227 | 228 | def toggle_disabled(self): 229 | """Toggle the disabled state of the widget.""" 230 | # Deprecated in favour of explicit enable/disable helpers 231 | if self.is_disabled: 232 | self.enable() 233 | else: 234 | self.disable() 235 | 236 | # --------------------------------------------------------------------- 237 | # New explicit helpers for clarity when controlling the list externally 238 | # --------------------------------------------------------------------- 239 | 240 | def disable(self): 241 | """Disable interaction with the list and apply disabled styles.""" 242 | self.is_disabled = True 243 | self.list_view.disabled = True 244 | self.add_class("disabled") 245 | self.app.log("List disabled") 246 | self.refresh() 247 | 248 | def enable(self): 249 | """Enable interaction with the list and remove disabled styles.""" 250 | self.is_disabled = False 251 | self.list_view.disabled = False 252 | self.remove_class("disabled") 253 | self.app.log("List enabled") 254 | self.refresh() 255 | 256 | def watch_is_disabled(self, is_disabled: bool): 257 | """React to changes in the disabled state.""" 258 | # Update the disabled property of the ListView 259 | self.list_view.disabled = is_disabled 260 | if is_disabled: 261 | self.add_class("disabled") 262 | else: 263 | self.remove_class("disabled") 264 | 265 | # Force a refresh to ensure the disabled state is applied 266 | self.refresh() 267 | 268 | def on_list_view_highlighted(self, event: ListView.Highlighted) -> None: 269 | """Handle the highlighted event from ListView.""" 270 | if self.is_disabled: 271 | return 272 | 273 | # Show the save message when the selection changes 274 | self.value_changed = True 275 | self.save_message.display = True 276 | # Force a refresh to ensure the save message is displayed 277 | self.save_message.refresh() 278 | 279 | # Post a message about the selection change 280 | if event.item is not None and self.list_view.index is not None and 0 <= self.list_view.index < len(self.item_data): 281 | selected_item = self.item_data[self.list_view.index] 282 | self.post_message(self.SelectionChanged(self, selected_item)) 283 | 284 | async def on_list_view_selected(self, event: ListView.Selected) -> None: 285 | """Handle the selected event from ListView.""" 286 | if self.is_disabled: 287 | return 288 | 289 | # Hide the save message 290 | self.save_message.display = False 291 | 292 | # Call the on_change callback if provided 293 | if self.value_changed and self.on_change and self.list_view.index is not None and 0 <= self.list_view.index < len(self.item_data): 294 | self.value_changed = False 295 | selected_item = self.item_data[self.list_view.index] 296 | if callable(self.on_change): 297 | await self.on_change(selected_item) 298 | 299 | @property 300 | def selected_item(self) -> Optional[Any]: 301 | """Get the currently selected item.""" 302 | if self.list_view.index is not None and 0 <= self.list_view.index < len(self.item_data): 303 | return self.item_data[self.list_view.index] 304 | return None 305 | 306 | def focus(self, scroll_visible: bool = True) -> None: 307 | """Focus the input field.""" 308 | self.list_view.focus() 309 | 310 | def set_empty_message(self, message: str) -> None: 311 | """Set the message to display when the list is empty. 312 | 313 | Args: 314 | message: The message to display 315 | """ 316 | self.empty_message = message 317 | -------------------------------------------------------------------------------- /src/nautex/services/config_service.py: -------------------------------------------------------------------------------- 1 | """Configuration service for loading and saving Nautex CLI settings.""" 2 | import gc 3 | import json 4 | import os 5 | import stat 6 | import platform 7 | import subprocess 8 | from pathlib import Path 9 | from typing import Optional, Dict, Any, List, Tuple 10 | from pydantic import ValidationError 11 | 12 | from ..models.config import NautexConfig, AgentType 13 | from ..agent_setups.base import AgentSetupBase, AgentSetupNotSelected 14 | from ..agent_setups.cursor import CursorAgentSetup 15 | from ..agent_setups.claude import ClaudeAgentSetup 16 | from ..agent_setups.codex import CodexAgentSetup 17 | from ..agent_setups.opencode import OpenCodeAgentSetup 18 | from ..agent_setups.gemini import GeminiAgentSetup 19 | from ..prompts.consts import DIR_NAUTEX, DIR_NAUTEX_DOCS 20 | 21 | 22 | class ConfigurationError(Exception): 23 | """Custom exception for configuration-related errors.""" 24 | pass 25 | 26 | 27 | class ConfigurationService: 28 | """Service for managing Nautex CLI configuration settings. 29 | 30 | This service handles loading configuration from .nautex/config.json and 31 | optionally from environment variables via .env file support. It also 32 | manages saving configuration with appropriate file permissions. 33 | """ 34 | 35 | def __init__(self, project_root: Optional[Path] = None): 36 | """Initialize the configuration service. 37 | 38 | Args: 39 | project_root: Root directory for the project. Defaults to current working directory. 40 | """ 41 | 42 | self.project_root = project_root or Path.cwd() 43 | self.config_dir = self.project_root / self.nautex_dir 44 | self.config_file = self.config_dir / "config.json" 45 | self.env_file = self.project_root / ".env" 46 | self.nautex_env_file = self.project_root / self.nautex_dir / ".env" 47 | 48 | self._config: Optional[NautexConfig] = None 49 | 50 | @property 51 | def config(self) -> NautexConfig: 52 | return self._config 53 | 54 | @property 55 | def cwd(self) -> Path : 56 | return Path.cwd() 57 | 58 | @property 59 | def nautex_dir(self): 60 | return Path(DIR_NAUTEX) 61 | 62 | @property 63 | def documents_path(self) -> Path : 64 | if self.config.documents_path: 65 | return Path(self.config.documents_path) 66 | else: 67 | return Path(DIR_NAUTEX_DOCS) 68 | 69 | @property 70 | def agent_setup(self) -> Optional[AgentSetupBase]: 71 | """Get the agent setup base for the configured agent type. 72 | 73 | Returns: 74 | AgentSetupBase implementation for the configured agent type. 75 | 76 | Raises: 77 | ValueError: If the agent type is not supported. 78 | """ 79 | if self.config.agent_type == AgentType.CURSOR: 80 | return CursorAgentSetup(self) 81 | elif self.config.agent_type == AgentType.CLAUDE: 82 | return ClaudeAgentSetup(self) 83 | elif self.config.agent_type == AgentType.CODEX: 84 | return CodexAgentSetup(self) 85 | elif self.config.agent_type == AgentType.OPENCODE: 86 | return OpenCodeAgentSetup(self) 87 | elif self.config.agent_type == AgentType.GEMINI: 88 | return GeminiAgentSetup(self) 89 | else: 90 | return AgentSetupNotSelected(self, AgentType.NOT_SELECTED.value) 91 | 92 | def get_supported_agent_types(self) -> List[AgentType]: 93 | """Get a list of supported agent types. 94 | 95 | Returns: 96 | List of supported agent types as strings. 97 | """ 98 | return AgentType.list() 99 | 100 | def load_configuration(self) -> NautexConfig: 101 | """Load configuration from .nautex/config.json and environment variables. 102 | 103 | The configuration is loaded with the following precedence: 104 | 1. Environment variables (with NAUTEX_ prefix) 105 | 2. .env file in project root 106 | 3. .env file in .nautex/ folder 107 | 4. .nautex/config.json file 108 | 5. Default values from the model 109 | 110 | Returns: 111 | NautexConfig: Loaded and validated configuration 112 | 113 | Raises: 114 | ConfigurationError: If configuration cannot be loaded or is invalid 115 | """ 116 | try: 117 | # Load environment variables first (they have highest precedence) 118 | env_vars = self._load_environment_variables() 119 | 120 | # Load from config file if it exists 121 | config_data = {} 122 | if self.config_file.exists(): 123 | try: 124 | with open(self.config_file, 'r', encoding='utf-8') as f: 125 | config_data = json.load(f) 126 | except json.JSONDecodeError as e: 127 | raise ConfigurationError(f"Invalid JSON in config file: {e}") 128 | except IOError as e: 129 | raise ConfigurationError(f"Cannot read config file: {e}") 130 | 131 | # Merge config file data with environment variables (env vars take precedence) 132 | merged_config = {**config_data, **env_vars} 133 | 134 | # Create NautexConfig with merged data 135 | # pydantic-settings will also automatically check for env vars with NAUTEX_ prefix 136 | NautexConfig.Config.case_sensitive = [".env", self.nautex_env_file] 137 | NautexConfig.model_rebuild() 138 | try: 139 | config = NautexConfig(**merged_config) 140 | except ValidationError as e: 141 | raise ConfigurationError(f"Invalid configuration data: {e}") 142 | 143 | self._config = config 144 | 145 | return config 146 | 147 | except Exception as e: 148 | if isinstance(e, ConfigurationError): 149 | raise 150 | raise ConfigurationError(f"Unexpected error loading configuration: {e}") 151 | 152 | 153 | def _load_nautex_vars(self, filename: str): 154 | """ 155 | Loads a .env-like text file in binary mode line by line to minimize time other's sensitive data is held in memory. 156 | 157 | :param filename: Path to the file to load. 158 | :return: Dict of relevant NAUTEX_ variables (keys: str, values: bytearray). 159 | """ 160 | result = {} 161 | # Convert prefix to bytes using utf-8 encoding 162 | prefix_bytes = NautexConfig.Config.env_prefix.encode('utf-8') 163 | with open(filename, 'rb', buffering=120) as f: 164 | for line_bytes in f: 165 | # Convert to bytearray for mutability and shredding 166 | line = bytearray(line_bytes.strip()) 167 | 168 | if prefix_bytes in line and b'=' in line: 169 | try: 170 | # Split on first '=' (keep as bytes) 171 | key_bytes, value_bytes = line.split(b'=', 1) 172 | key_str = key_bytes.decode('utf-8').strip() 173 | value = value_bytes.decode('utf-8').strip() 174 | result[key_str] = value 175 | except ValueError: 176 | pass # Skip malformed lines 177 | else: 178 | # Shred non-matching line: overwrite with zeros 179 | for i in range(len(line)): 180 | line[i] = 0 181 | 182 | # Delete reference to encourage deallocation (shredded data is already cleared) 183 | del line 184 | 185 | gc.collect() # Force GC to reclaim sooner 186 | 187 | return result 188 | 189 | def _load_environment_variables(self) -> Dict[str, Any]: 190 | """Load environment variables with NAUTEX_ prefix. 191 | 192 | Reads env files directly and filters out non-relevant lines in memory. 193 | 194 | Returns: 195 | Dict with environment variable values (keys without NAUTEX_ prefix) 196 | """ 197 | env_vars = {} 198 | prefix = NautexConfig.Config.env_prefix 199 | 200 | # Check for environment variables with NAUTEX_ prefix 201 | env_vars = {k: v for k, v in os.environ.items() if k.startswith(prefix)} 202 | 203 | if self.env_file.exists(): 204 | cfg = self._load_nautex_vars(str(self.env_file)) 205 | env_vars.update(cfg) 206 | 207 | if self.nautex_env_file.exists(): 208 | cfg = self._load_nautex_vars(str(self.nautex_env_file)) 209 | env_vars.update(cfg) 210 | 211 | # Remove prefix and convert to lowercase 212 | env_vars = {k[len(prefix):].lower(): v for k, v in env_vars.items()} 213 | 214 | return env_vars 215 | 216 | def save_configuration(self, config_data: Optional[NautexConfig] = None) -> None: 217 | if config_data is None: 218 | config_data = self._config 219 | 220 | try: 221 | # Ensure .nautex directory exists 222 | self.config_dir.mkdir(exist_ok=True) 223 | 224 | # Write JSON to file 225 | config_dict = config_data.to_config_dict() 226 | try: 227 | with open(self.config_file, 'w', encoding='utf-8') as f: 228 | json.dump(config_dict, f, indent=2, ensure_ascii=False) 229 | except IOError as e: 230 | raise ConfigurationError(f"Cannot write config file: {e}") 231 | 232 | except Exception as e: 233 | if isinstance(e, ConfigurationError): 234 | raise 235 | raise ConfigurationError(f"Unexpected error saving configuration: {e}") 236 | 237 | # need that to avoid messing with user's env and making json config git commitable 238 | def save_token_to_nautex_env(self, token: str): 239 | self.nautex_env_file.parent.mkdir(exist_ok=True) 240 | 241 | # Read existing content 242 | existing_lines = [] 243 | token_key = NautexConfig.Config.env_prefix + 'api_token'.upper() 244 | token_found = False 245 | 246 | if self.nautex_env_file.exists(): 247 | with open(self.nautex_env_file, 'r') as f: 248 | for line in f: 249 | line = line.strip() 250 | if line.startswith(f"{token_key}="): 251 | # Replace existing token 252 | existing_lines.append(f"{token_key}={token}") 253 | token_found = True 254 | elif line: # Keep non-empty lines that aren't the token 255 | existing_lines.append(line) 256 | 257 | # Add token if it wasn't found 258 | if not token_found: 259 | existing_lines.append(f"{token_key}={token}") 260 | 261 | # Write back all lines 262 | with open(self.nautex_env_file, 'w') as f: 263 | for line in existing_lines: 264 | f.write(f"{line}\n") 265 | 266 | self._ensure_gitignore(self.nautex_env_file.parent) 267 | 268 | def _ensure_gitignore(self, path: Path): 269 | """Ensure .gitignore exists in .nautex dir with .env entry.""" 270 | gitignore_path = path / ".gitignore" 271 | if not gitignore_path.exists(): 272 | with open(gitignore_path, 'w') as f: 273 | f.write(".env\n") 274 | 275 | def config_exists(self) -> bool: 276 | """Check if a configuration file exists. 277 | 278 | Returns: 279 | True if .nautex/config.json exists, False otherwise 280 | """ 281 | return self.config_file.exists() 282 | 283 | def get_config_path(self) -> Path: 284 | """Get the path to the configuration file. 285 | 286 | Returns: 287 | Path to the configuration file 288 | """ 289 | return self.config_file 290 | 291 | def delete_configuration(self) -> None: 292 | """Delete the configuration file if it exists. 293 | 294 | Raises: 295 | ConfigurationError: If file cannot be deleted 296 | """ 297 | if self.config_file.exists(): 298 | try: 299 | self.config_file.unlink() 300 | except OSError as e: 301 | raise ConfigurationError(f"Cannot delete config file: {e}") 302 | 303 | def create_api_client(self, config: NautexConfig): 304 | """Create a nautex API client configured for the given config. 305 | 306 | Args: 307 | config: Configuration to create client for 308 | 309 | Returns: 310 | Configured NautexAPIClient 311 | """ 312 | # Import the client here to avoid circular imports 313 | from ..api.client import NautexAPIClient 314 | import os 315 | 316 | # Use API host from config instead of hardcoded URL 317 | api_host = config.api_host 318 | return NautexAPIClient(api_host) 319 | -------------------------------------------------------------------------------- /src/nautex/api/test_client.py: -------------------------------------------------------------------------------- 1 | """Test client for emulating successful API responses during development.""" 2 | 3 | import asyncio 4 | import logging 5 | from typing import Dict, Any, Optional, Tuple 6 | from datetime import datetime, timezone 7 | 8 | # Set up logging 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | class NautexTestAPIClient: 13 | """Test API client that emulates successful responses from Nautex.ai API. 14 | 15 | This client provides dummy responses for all API endpoints to enable 16 | development and testing without requiring actual API connectivity. 17 | """ 18 | 19 | def __init__(self, base_url: str = "http://localhost:8000"): 20 | """Initialize the test API client. 21 | 22 | Args: 23 | base_url: Base URL (ignored in test mode, but maintained for interface compatibility) 24 | """ 25 | self.base_url = base_url.rstrip('/') 26 | self._token = None 27 | 28 | # Simulated latency tracking 29 | self._latency_measurements = { 30 | "account": [], 31 | "projects": [], 32 | "plans": [], 33 | "tasks": [], 34 | "requirements": [] 35 | } 36 | 37 | logger.info("NautexTestAPIClient initialized in test mode") 38 | 39 | def setup_token(self, token: str) -> None: 40 | """Set up the API token for authentication. 41 | 42 | Args: 43 | token: API token string 44 | """ 45 | self._token = token 46 | logger.debug("API token configured in test mode") 47 | 48 | def get_latency_stats(self, endpoint_type: Optional[str] = None) -> Dict[str, Tuple[float, float]]: 49 | """Get min/max latency statistics for endpoint types. 50 | 51 | Args: 52 | endpoint_type: Specific endpoint type to get stats for, or None for all 53 | 54 | Returns: 55 | Dictionary mapping endpoint types to (min, max) latency tuples 56 | """ 57 | stats = {} 58 | 59 | for ep_type, measurements in self._latency_measurements.items(): 60 | if measurements: 61 | stats[ep_type] = (min(measurements), max(measurements)) 62 | else: 63 | stats[ep_type] = (0.0, 0.0) 64 | 65 | if endpoint_type: 66 | return {endpoint_type: stats.get(endpoint_type, (0.0, 0.0))} 67 | 68 | return stats 69 | 70 | async def verify_token(self) -> bool: 71 | """Verify if the current token is valid. 72 | 73 | Returns: 74 | Always True in test mode 75 | """ 76 | await self._simulate_network_delay() 77 | return True 78 | 79 | async def get_account_info(self, timeout: Optional[float] = None) -> (): 80 | """Get account information. 81 | 82 | Returns: 83 | AccountInfo object with test data 84 | """ 85 | await self._simulate_network_delay() 86 | 87 | # Record latency 88 | self._latency_measurements["account"].append(0.123) 89 | 90 | # Return test account info 91 | from .api_models import AccountInfo 92 | return AccountInfo( 93 | profile_email="test.user@example.com", 94 | api_version="1.0.0" 95 | ) 96 | 97 | async def list_projects(self): 98 | """List projects. 99 | 100 | Returns: 101 | List of Project objects with test data 102 | """ 103 | await self._simulate_network_delay() 104 | 105 | # Record latency 106 | self._latency_measurements["projects"].append(0.156) 107 | 108 | from .api_models import Project 109 | return [ 110 | Project( 111 | id="PROJ-001", 112 | name="Test Project Alpha", 113 | description="Sample project for development testing" 114 | ), 115 | Project( 116 | id="PROJ-002", 117 | name="Demo Project Beta", 118 | description="Another sample project for testing" 119 | ) 120 | ] 121 | 122 | async def list_implementation_plans(self, project_id: str): 123 | """List implementation plans for a project. 124 | 125 | Args: 126 | project_id: Project ID 127 | 128 | Returns: 129 | List of ImplementationPlan objects with test data 130 | """ 131 | await self._simulate_network_delay() 132 | 133 | # Record latency 134 | self._latency_measurements["plans"].append(0.189) 135 | 136 | from .api_models import ImplementationPlan 137 | return [ 138 | ImplementationPlan( 139 | id="PLAN-001", 140 | project_id=project_id, 141 | name="Initial Implementation", 142 | description="First phase implementation plan" 143 | ) 144 | ] 145 | 146 | async def __aenter__(self): 147 | """Async context manager entry.""" 148 | return self 149 | 150 | async def __aexit__(self, exc_type, exc_val, exc_tb): 151 | """Async context manager exit.""" 152 | await self.close() 153 | 154 | async def close(self): 155 | """Close method for interface compatibility.""" 156 | logger.debug("NautexTestAPIClient closed") 157 | 158 | async def _simulate_network_delay(self, min_ms: int = 50, max_ms: int = 200): 159 | """Simulate realistic network delay for testing.""" 160 | import random 161 | delay = random.randint(min_ms, max_ms) / 1000.0 162 | await asyncio.sleep(delay) 163 | 164 | async def get(self, endpoint_url: str, headers: Dict[str, str]) -> Dict[str, Any]: 165 | """Make a simulated GET request. 166 | 167 | Args: 168 | endpoint_url: Full endpoint URL 169 | headers: Request headers (analyzed to determine response type) 170 | 171 | Returns: 172 | Dummy JSON response based on endpoint 173 | """ 174 | await self._simulate_network_delay() 175 | 176 | logger.debug(f"Test API GET request to: {endpoint_url}") 177 | 178 | # Parse endpoint to determine what dummy data to return 179 | if "/d/v1/account" in endpoint_url: 180 | return self._get_account_info_response() 181 | elif "/d/v1/projects" in endpoint_url: 182 | return self._get_projects_response() 183 | elif "/d/v1/implementation-plans" in endpoint_url: 184 | return self._get_implementation_plans_response() 185 | elif "/d/v1/agents" in endpoint_url: 186 | return self._get_agents_response() 187 | else: 188 | return self._get_generic_success_response() 189 | 190 | async def post( 191 | self, 192 | endpoint_url: str, 193 | headers: Dict[str, str], 194 | json_payload: Dict[str, Any] 195 | ) -> Dict[str, Any]: 196 | """Make a simulated POST request. 197 | 198 | Args: 199 | endpoint_url: Full endpoint URL 200 | headers: Request headers 201 | json_payload: JSON request body 202 | 203 | Returns: 204 | Dummy JSON response based on endpoint and payload 205 | """ 206 | await self._simulate_network_delay(100, 300) # POST requests typically take longer 207 | 208 | logger.debug(f"Test API POST request to: {endpoint_url} with payload: {json_payload}") 209 | 210 | # Parse endpoint to determine what dummy data to return 211 | if "/d/v1/agents" in endpoint_url: 212 | return self._post_agent_response(json_payload) 213 | elif "/d/v1/projects" in endpoint_url: 214 | return self._post_project_response(json_payload) 215 | elif "/d/v1/implementation-plans" in endpoint_url: 216 | return self._post_implementation_plan_response(json_payload) 217 | else: 218 | return self._get_generic_success_response() 219 | 220 | def _get_account_info_response(self) -> Dict[str, Any]: 221 | """Generate dummy account info response.""" 222 | return { 223 | "profile_email": "test.user@example.com", 224 | "api_version": "1.0.0", 225 | "response_latency": 0.123 226 | } 227 | 228 | def _get_projects_response(self) -> Dict[str, Any]: 229 | """Generate dummy projects list response.""" 230 | return { 231 | "success": True, 232 | "data": [ 233 | { 234 | "id": "PROJ-001", 235 | "name": "Test Project Alpha", 236 | "description": "Sample project for development testing", 237 | "status": "active", 238 | "created_at": "2024-01-15T10:30:00Z", 239 | "updated_at": "2024-01-20T14:45:00Z" 240 | }, 241 | { 242 | "id": "PROJ-002", 243 | "name": "Demo Project Beta", 244 | "description": "Another sample project for testing", 245 | "status": "active", 246 | "created_at": "2024-01-18T09:15:00Z", 247 | "updated_at": "2024-01-22T16:20:00Z" 248 | } 249 | ], 250 | "timestamp": datetime.now(timezone.utc).isoformat(), 251 | "total_count": 2 252 | } 253 | 254 | def _get_implementation_plans_response(self) -> Dict[str, Any]: 255 | """Generate dummy implementation plans response.""" 256 | return { 257 | "success": True, 258 | "data": [ 259 | { 260 | "id": "PLAN-001", 261 | "project_id": "PROJ-001", 262 | "name": "Initial Implementation", 263 | "description": "First phase implementation plan", 264 | "status": "active", 265 | "phases": [ 266 | { 267 | "id": "PHASE-001", 268 | "name": "Setup & Configuration", 269 | "status": "completed" 270 | }, 271 | { 272 | "id": "PHASE-002", 273 | "name": "Core Development", 274 | "status": "in_progress" 275 | } 276 | ], 277 | "created_at": "2024-01-16T11:00:00Z", 278 | "updated_at": "2024-01-21T15:30:00Z" 279 | } 280 | ], 281 | "timestamp": datetime.now(timezone.utc).isoformat(), 282 | "total_count": 1 283 | } 284 | 285 | def _get_agents_response(self) -> Dict[str, Any]: 286 | """Generate dummy agents list response.""" 287 | return { 288 | "success": True, 289 | "data": [ 290 | { 291 | "id": "AGENT-001", 292 | "name": "test-dev-agent", 293 | "project_id": "PROJ-001", 294 | "implementation_plan_id": "PLAN-001", 295 | "status": "active", 296 | "last_activity": "2024-01-23T12:00:00Z", 297 | "created_at": "2024-01-16T11:30:00Z" 298 | } 299 | ], 300 | "timestamp": datetime.now(timezone.utc).isoformat(), 301 | "total_count": 1 302 | } 303 | 304 | def _post_agent_response(self, payload: Dict[str, Any]) -> Dict[str, Any]: 305 | """Generate dummy agent creation response.""" 306 | agent_name = payload.get("name", "new-agent") 307 | return { 308 | "success": True, 309 | "data": { 310 | "id": f"AGENT-{datetime.now().strftime('%Y%m%d%H%M%S')}", 311 | "name": agent_name, 312 | "project_id": payload.get("project_id"), 313 | "implementation_plan_id": payload.get("implementation_plan_id"), 314 | "status": "active", 315 | "created_at": datetime.now(timezone.utc).isoformat() 316 | }, 317 | "timestamp": datetime.now(timezone.utc).isoformat() 318 | } 319 | 320 | def _post_project_response(self, payload: Dict[str, Any]) -> Dict[str, Any]: 321 | """Generate dummy project creation response.""" 322 | project_name = payload.get("name", "New Project") 323 | return { 324 | "success": True, 325 | "data": { 326 | "id": f"PROJ-{datetime.now().strftime('%Y%m%d%H%M%S')}", 327 | "name": project_name, 328 | "description": payload.get("description", ""), 329 | "status": "active", 330 | "created_at": datetime.now(timezone.utc).isoformat() 331 | }, 332 | "timestamp": datetime.now(timezone.utc).isoformat() 333 | } 334 | 335 | def _post_implementation_plan_response(self, payload: Dict[str, Any]) -> Dict[str, Any]: 336 | """Generate dummy implementation plan creation response.""" 337 | plan_name = payload.get("name", "New Implementation Plan") 338 | return { 339 | "success": True, 340 | "data": { 341 | "id": f"PLAN-{datetime.now().strftime('%Y%m%d%H%M%S')}", 342 | "project_id": payload.get("project_id"), 343 | "name": plan_name, 344 | "description": payload.get("description", ""), 345 | "status": "active", 346 | "phases": [], 347 | "created_at": datetime.now(timezone.utc).isoformat() 348 | }, 349 | "timestamp": datetime.now(timezone.utc).isoformat() 350 | } 351 | 352 | def _get_generic_success_response(self) -> Dict[str, Any]: 353 | """Generate a generic successful response.""" 354 | return { 355 | "success": True, 356 | "data": {"message": "Operation completed successfully"}, 357 | "timestamp": datetime.now(timezone.utc).isoformat() 358 | } 359 | --------------------------------------------------------------------------------