├── 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 | 
47 |
48 | Technical requirements:
49 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 |
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 |
--------------------------------------------------------------------------------