nonoka-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
nonoka_cli/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Nonoka CLI — Terminal frontend for the Nonoka Agent framework."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.1.0"
nonoka_cli/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Allow running nonoka-cli as a module: python -m nonoka_cli."""
2
+
3
+ from nonoka_cli.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ import sys
7
+ sys.exit(main())
nonoka_cli/cli.py ADDED
@@ -0,0 +1,134 @@
1
+ """CLI entry point for nonoka-cli.
2
+
3
+ Handles argument parsing, configuration loading, and REPL startup.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import asyncio
10
+ import logging
11
+ import signal
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ import structlog
16
+
17
+ from nonoka_cli.core.orchestrator import Orchestrator
18
+ from nonoka_cli.shell.repl import REPL
19
+ from nonoka_cli.ui.renderer import Renderer
20
+ from nonoka_cli.utils.logging import setup_logging
21
+
22
+ logger = structlog.get_logger("nonoka_cli.cli")
23
+
24
+
25
+ def _build_parser() -> argparse.ArgumentParser:
26
+ """Build the argument parser."""
27
+ parser = argparse.ArgumentParser(
28
+ prog="nonoka-cli",
29
+ description="Terminal frontend for the Nonoka Agent framework",
30
+ )
31
+ parser.add_argument(
32
+ "--config",
33
+ type=Path,
34
+ default=None,
35
+ help="Path to configuration file (default: ~/.config/nonoka/config.yaml)",
36
+ )
37
+ parser.add_argument(
38
+ "--model",
39
+ type=str,
40
+ default=None,
41
+ help="Override the model specified in config",
42
+ )
43
+ parser.add_argument(
44
+ "--verbose", "-v",
45
+ action="store_true",
46
+ help="Output INFO level logs to console",
47
+ )
48
+ parser.add_argument(
49
+ "--debug",
50
+ action="store_true",
51
+ help="Output DEBUG level logs to console",
52
+ )
53
+ return parser
54
+
55
+
56
+ async def _run_repl(args: argparse.Namespace) -> int:
57
+ """Initialize and run the REPL.
58
+
59
+ Args:
60
+ args: Parsed command-line arguments.
61
+
62
+ Returns:
63
+ Exit code (0 for success).
64
+ """
65
+ orchestrator = Orchestrator()
66
+ renderer = Renderer()
67
+ repl = REPL(orchestrator, renderer)
68
+
69
+ # Handle Ctrl+C by interrupting the current execution
70
+ loop = asyncio.get_event_loop()
71
+
72
+ def _sigint_handler():
73
+ asyncio.create_task(repl.interrupt())
74
+
75
+ if sys.platform != "win32":
76
+ loop.add_signal_handler(signal.SIGINT, _sigint_handler)
77
+
78
+ try:
79
+ await orchestrator.initialize(config_path=args.config)
80
+ except Exception as exc:
81
+ print(f"Failed to initialize: {exc}", file=sys.stderr)
82
+ logger.error("initialization_failed", error=str(exc))
83
+ return 1
84
+
85
+ # Apply model override if provided
86
+ if args.model:
87
+ orchestrator.config.model = args.model
88
+ orchestrator._agent_factory.rebuild()
89
+ logger.info("model_overridden", model=args.model)
90
+
91
+ print(f"nonoka-cli initialized. Model: {orchestrator.config.model}")
92
+ print("Type /help for commands, /exit to quit.")
93
+ print()
94
+
95
+ try:
96
+ await repl.run()
97
+ finally:
98
+ await orchestrator.shutdown()
99
+ if sys.platform != "win32":
100
+ loop.remove_signal_handler(signal.SIGINT)
101
+
102
+ return 0
103
+
104
+
105
+ def main() -> int:
106
+ """Main entry point.
107
+
108
+ Returns:
109
+ Exit code (0 for success, 1 for error).
110
+ """
111
+ parser = _build_parser()
112
+ args = parser.parse_args()
113
+
114
+ # Setup logging
115
+ log_level = (
116
+ logging.DEBUG if args.debug
117
+ else logging.INFO if args.verbose
118
+ else logging.WARNING
119
+ )
120
+ setup_logging(level=log_level, console=args.verbose or args.debug)
121
+
122
+ try:
123
+ return asyncio.run(_run_repl(args))
124
+ except KeyboardInterrupt:
125
+ print("\nInterrupted.")
126
+ return 130
127
+ except Exception as exc:
128
+ logger.error("fatal_error", error=str(exc))
129
+ print(f"Fatal error: {exc}", file=sys.stderr)
130
+ return 1
131
+
132
+
133
+ if __name__ == "__main__":
134
+ sys.exit(main())
@@ -0,0 +1,8 @@
1
+ """Configuration loading and validation for nonoka-cli."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from nonoka_cli.config.models import CLIConfig
6
+ from nonoka_cli.config.loader import ConfigLoader, load_config
7
+
8
+ __all__ = ["CLIConfig", "ConfigLoader", "load_config"]
@@ -0,0 +1,130 @@
1
+ """Configuration loading with env-var substitution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import structlog
11
+
12
+ from nonoka_cli.config.models import CLIConfig
13
+ from nonoka_cli.utils.errors import ConfigError, ConfigNotFoundError
14
+
15
+ logger = structlog.get_logger("nonoka_cli.config")
16
+
17
+ _ENV_PATTERN = re.compile(r"\$\{(?P<name>[A-Za-z_][A-Za-z0-9_]*)(?::-?(?P<default>[^}]*))?\}")
18
+
19
+
20
+ def _substitute_env_vars(value: Any) -> Any:
21
+ """Recursively substitute ``${VAR}`` and ``${VAR:-default}`` in strings."""
22
+ if isinstance(value, str):
23
+ def replacer(match: re.Match[str]) -> str:
24
+ var_name = match.group("name")
25
+ default = match.group("default")
26
+ result = os.getenv(var_name)
27
+ if result is None:
28
+ if default is not None:
29
+ return default
30
+ raise ConfigError(
31
+ f"Environment variable '{var_name}' is not set and no default provided"
32
+ )
33
+ return result
34
+ return _ENV_PATTERN.sub(replacer, value)
35
+ if isinstance(value, dict):
36
+ return {k: _substitute_env_vars(v) for k, v in value.items()}
37
+ if isinstance(value, list):
38
+ return [_substitute_env_vars(item) for item in value]
39
+ return value
40
+
41
+
42
+ class ConfigLoader:
43
+ """Loads and validates CLI configuration from YAML files."""
44
+
45
+ DEFAULT_PATH = Path.home() / ".config" / "nonoka" / "config.yaml"
46
+ FALLBACK_PATH = Path.cwd() / "nonoka.yaml"
47
+
48
+ @classmethod
49
+ def find_config_file(
50
+ cls,
51
+ explicit_path: Path | str | None = None,
52
+ ) -> Path:
53
+ """Search for a configuration file in priority order.
54
+
55
+ Priority: explicit_path > ~/.config/nonoka/config.yaml > ./nonoka.yaml
56
+ """
57
+ if explicit_path is not None:
58
+ path = Path(explicit_path)
59
+ if path.exists():
60
+ return path
61
+ raise ConfigNotFoundError(f"Explicit config file not found: {path}")
62
+
63
+ if cls.DEFAULT_PATH.exists():
64
+ return cls.DEFAULT_PATH
65
+
66
+ if cls.FALLBACK_PATH.exists():
67
+ return cls.FALLBACK_PATH
68
+
69
+ raise ConfigNotFoundError(
70
+ f"No config file found. Searched: {cls.DEFAULT_PATH}, {cls.FALLBACK_PATH}"
71
+ )
72
+
73
+ @classmethod
74
+ def load(
75
+ cls,
76
+ path: Path | str | None = None,
77
+ ) -> CLIConfig:
78
+ """Load configuration from a YAML file.
79
+
80
+ Args:
81
+ path: Explicit config file path. If None, searches default locations.
82
+
83
+ Returns:
84
+ Validated CLIConfig instance.
85
+
86
+ Raises:
87
+ ConfigNotFoundError: If no config file is found.
88
+ ConfigError: If parsing or validation fails.
89
+ """
90
+ config_path = cls.find_config_file(path)
91
+ logger.info("loading_config", path=str(config_path))
92
+
93
+ try:
94
+ import yaml
95
+ except ImportError as exc:
96
+ raise ConfigError("PyYAML is required. Install: pip install pyyaml") from exc
97
+
98
+ try:
99
+ raw = config_path.read_text(encoding="utf-8")
100
+ data = yaml.safe_load(raw)
101
+ except Exception as exc:
102
+ raise ConfigError(f"Failed to parse YAML: {exc}") from exc
103
+
104
+ if data is None:
105
+ data = {}
106
+ if not isinstance(data, dict):
107
+ raise ConfigError(
108
+ f"Config file must contain a top-level object, got {type(data).__name__}"
109
+ )
110
+
111
+ # Substitute env vars before validation
112
+ try:
113
+ data = _substitute_env_vars(data)
114
+ except ConfigError:
115
+ raise
116
+ except Exception as exc:
117
+ raise ConfigError(f"Environment variable substitution failed: {exc}") from exc
118
+
119
+ try:
120
+ config = CLIConfig.model_validate(data)
121
+ except Exception as exc:
122
+ raise ConfigError(f"Config validation failed: {exc}") from exc
123
+
124
+ logger.info("config_loaded", model=config.model)
125
+ return config
126
+
127
+
128
+ def load_config(path: Path | str | None = None) -> CLIConfig:
129
+ """Convenience function: load CLI configuration."""
130
+ return ConfigLoader.load(path)
@@ -0,0 +1,51 @@
1
+ """Pydantic models for CLI configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from pydantic import BaseModel, Field, field_validator
9
+
10
+
11
+ class CLIConfigModel(BaseModel):
12
+ """CLI behavior configuration."""
13
+ theme: str = "dark"
14
+ auto_approve: bool = False
15
+ editor: str = "${EDITOR:-nano}"
16
+ max_history: int = 1000
17
+ multi_line_trigger: str = '"""'
18
+
19
+
20
+ class HITLConfigModel(BaseModel):
21
+ """HITL (Human-in-the-Loop) configuration."""
22
+ policy: str = "interactive"
23
+ dangerous_tools: list[str] = Field(default_factory=list)
24
+
25
+
26
+ class MCPServerConfigModel(BaseModel):
27
+ """Single MCP server configuration."""
28
+ transport: str
29
+ command: str
30
+ args: list[str] = Field(default_factory=list)
31
+
32
+
33
+ class CLIConfig(BaseModel):
34
+ """Top-level configuration for nonoka-cli.
35
+
36
+ Matches the expected structure of ~/.config/nonoka/config.yaml.
37
+ """
38
+ model: str = "gpt-4o"
39
+ system_prompt: str = "You are a helpful AI assistant."
40
+ mcp_servers: dict[str, MCPServerConfigModel] = Field(default_factory=dict)
41
+ tool_paths: list[Path] = Field(default_factory=list)
42
+ skills: list[str] = Field(default_factory=list)
43
+ cli: CLIConfigModel = Field(default_factory=CLIConfigModel)
44
+ hitl: HITLConfigModel = Field(default_factory=HITLConfigModel)
45
+
46
+ @field_validator("tool_paths", mode="before")
47
+ @classmethod
48
+ def _resolve_tool_paths(cls, v: Any) -> list[Path]:
49
+ if v is None:
50
+ return []
51
+ return [Path(p).expanduser() for p in v]
@@ -0,0 +1,9 @@
1
+ """Core orchestration layer for nonoka-cli."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from nonoka_cli.core.context import CLIContext
6
+ from nonoka_cli.core.agent_factory import AgentFactory
7
+ from nonoka_cli.core.orchestrator import Orchestrator
8
+
9
+ __all__ = ["CLIContext", "AgentFactory", "Orchestrator"]
@@ -0,0 +1,78 @@
1
+ """Agent factory — builds nonoka Agent from CLI configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import structlog
8
+
9
+ from nonoka import Agent, AgentBuilder
10
+ from nonoka_cli.config.models import CLIConfig
11
+ from nonoka_cli.utils.errors import AgentBuildError
12
+
13
+ logger = structlog.get_logger("nonoka_cli.core")
14
+
15
+
16
+ class AgentFactory:
17
+ """Builds nonoka Agent instances from CLI configuration.
18
+
19
+ TODO: Integrate MCP tools, local tools, and skills.
20
+ Currently supports model and system_prompt only.
21
+ """
22
+
23
+ def __init__(self, config: CLIConfig):
24
+ self._config = config
25
+ self._agent: Agent | None = None
26
+
27
+ @property
28
+ def config(self) -> CLIConfig:
29
+ """Current configuration."""
30
+ return self._config
31
+
32
+ def build(self) -> Agent:
33
+ """Build (or rebuild) an Agent from the current configuration.
34
+
35
+ Returns:
36
+ A nonoka Agent instance.
37
+
38
+ Raises:
39
+ AgentBuildError: If model is not configured.
40
+ """
41
+ if not self._config.model:
42
+ raise AgentBuildError("No model configured. Set 'model' in config.yaml.")
43
+
44
+ logger.info(
45
+ "building_agent",
46
+ model=self._config.model,
47
+ system_prompt_length=len(self._config.system_prompt),
48
+ )
49
+
50
+ self._agent = (
51
+ AgentBuilder()
52
+ .model(self._config.model)
53
+ .system_prompt(self._config.system_prompt)
54
+ .max_turns(20)
55
+ .build()
56
+ )
57
+ return self._agent
58
+
59
+ def rebuild(self, config_patch: dict[str, Any] | None = None) -> Agent:
60
+ """Rebuild Agent with an optional configuration patch.
61
+
62
+ Args:
63
+ config_patch: Dict of config overrides (e.g. {"model": "gpt-4o"}).
64
+
65
+ Returns:
66
+ The rebuilt Agent.
67
+ """
68
+ if config_patch:
69
+ # Apply patch by creating a new config
70
+ data = self._config.model_dump()
71
+ data.update(config_patch)
72
+ self._config = self._config.__class__.model_validate(data)
73
+
74
+ return self.build()
75
+
76
+ def get_agent(self) -> Agent | None:
77
+ """Return the currently built Agent, if any."""
78
+ return self._agent
@@ -0,0 +1,17 @@
1
+ """CLI runtime context passed to tools via RunContext.deps."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ from nonoka_cli.config.models import CLIConfig
9
+
10
+
11
+ @dataclass
12
+ class CLIContext:
13
+ """CLI runtime context injected into tools."""
14
+ user: str
15
+ session_id: str
16
+ config: CLIConfig
17
+ working_dir: Path
@@ -0,0 +1,138 @@
1
+ """Orchestrator — coordinates config, agent, runner, and execution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from collections.abc import AsyncIterator
7
+ from pathlib import Path
8
+
9
+ import structlog
10
+ from nonoka import Runner
11
+ from nonoka.core.runner import StreamEvent
12
+
13
+ from nonoka_cli.config.loader import ConfigLoader
14
+ from nonoka_cli.config.models import CLIConfig
15
+ from nonoka_cli.core.agent_factory import AgentFactory
16
+ from nonoka_cli.core.context import CLIContext
17
+ from nonoka_cli.utils.errors import ConfigError, OrchestratorError
18
+
19
+ logger = structlog.get_logger("nonoka_cli.core")
20
+
21
+
22
+ class Orchestrator:
23
+ """Orchestration layer — the bridge between Shell and nonoka kernel.
24
+
25
+ Responsibilities:
26
+ - Load configuration
27
+ - Build Agent and Runner
28
+ - Execute prompts via nonoka's streaming ReAct API
29
+ - Manage a single session_id
30
+
31
+ TODO: Add MCP, Skill, session management, HITL.
32
+ """
33
+
34
+ def __init__(self, config: CLIConfig | None = None):
35
+ """Initialize the orchestrator.
36
+
37
+ Args:
38
+ config: Pre-loaded configuration. If None, will be loaded on initialize().
39
+ """
40
+ self._config = config
41
+ self._agent_factory: AgentFactory | None = None
42
+ self._runner: Runner | None = None
43
+ self._session_id: str = str(uuid.uuid4())
44
+ self._initialized = False
45
+
46
+ @property
47
+ def config(self) -> CLIConfig:
48
+ """Current configuration."""
49
+ if self._config is None:
50
+ raise OrchestratorError("Orchestrator not initialized. Call initialize() first.")
51
+ return self._config
52
+
53
+ @property
54
+ def session_id(self) -> str:
55
+ """Current session identifier."""
56
+ return self._session_id
57
+
58
+ async def initialize(self, config_path: Path | str | None = None) -> None:
59
+ """Load config, build agent, and create runner.
60
+
61
+ Args:
62
+ config_path: Optional explicit path to config file.
63
+
64
+ Raises:
65
+ ConfigError: If configuration cannot be loaded.
66
+ OrchestratorError: If agent or runner cannot be built.
67
+ """
68
+ if self._config is None:
69
+ try:
70
+ self._config = ConfigLoader.load(config_path)
71
+ except ConfigError:
72
+ raise
73
+ except Exception as exc:
74
+ raise ConfigError(f"Failed to load configuration: {exc}") from exc
75
+
76
+ self._agent_factory = AgentFactory(self._config)
77
+ agent = self._agent_factory.build()
78
+
79
+ self._runner = Runner()
80
+
81
+ logger.info(
82
+ "orchestrator_initialized",
83
+ model=self._config.model,
84
+ session_id=self._session_id,
85
+ )
86
+ self._initialized = True
87
+
88
+ async def execute(self, prompt: str) -> AsyncIterator[StreamEvent]:
89
+ """Execute a user prompt and yield streaming events.
90
+
91
+ Args:
92
+ prompt: The user's input text.
93
+
94
+ Yields:
95
+ StreamEvent objects from nonoka's ReAct streaming API.
96
+
97
+ Raises:
98
+ OrchestratorError: If not initialized or execution fails.
99
+ """
100
+ if not self._initialized or self._agent_factory is None or self._runner is None:
101
+ raise OrchestratorError("Orchestrator not initialized. Call initialize() first.")
102
+
103
+ agent = self._agent_factory.get_agent()
104
+ if agent is None:
105
+ raise OrchestratorError("No Agent available. Build failed during initialization.")
106
+
107
+ logger.info("executing_prompt", prompt_length=len(prompt), session_id=self._session_id)
108
+
109
+ deps = CLIContext(
110
+ user="local",
111
+ session_id=self._session_id,
112
+ config=self._config,
113
+ working_dir=Path.cwd(),
114
+ )
115
+
116
+ try:
117
+ async for event in self._runner.run_react_stream(
118
+ agent, prompt, deps=deps, session_id=self._session_id
119
+ ):
120
+ yield event
121
+ except Exception as exc:
122
+ logger.error("execution_failed", error=str(exc))
123
+ raise OrchestratorError(f"Execution failed: {exc}") from exc
124
+
125
+ def new_session(self) -> str:
126
+ """Create a new session, discarding the old one.
127
+
128
+ Returns:
129
+ The new session_id.
130
+ """
131
+ self._session_id = str(uuid.uuid4())
132
+ logger.info("new_session_created", session_id=self._session_id)
133
+ return self._session_id
134
+
135
+ async def shutdown(self) -> None:
136
+ """Graceful shutdown — clean up resources."""
137
+ logger.info("orchestrator_shutdown")
138
+ self._initialized = False
@@ -0,0 +1,5 @@
1
+ """Shell / REPL layer for nonoka-cli."""
2
+
3
+ from nonoka_cli.shell.repl import REPL
4
+
5
+ __all__ = ["REPL"]
@@ -0,0 +1,164 @@
1
+ """REPL — Read-Eval-Print Loop for nonoka-cli.
2
+
3
+ Provides interactive terminal session with command parsing,
4
+ interrupt handling, and prompt execution.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import sys
11
+
12
+ import structlog
13
+
14
+ from nonoka_cli.core.orchestrator import Orchestrator
15
+ from nonoka_cli.ui.renderer import Renderer
16
+ from nonoka_cli.utils.errors import CLIError, OrchestratorError
17
+
18
+ logger = structlog.get_logger("nonoka_cli.shell")
19
+
20
+ PROMPT = "nonoka> "
21
+
22
+
23
+ class REPL:
24
+ """Interactive REPL for nonoka-cli.
25
+
26
+ Features:
27
+ - Read user input line by line
28
+ - Route /-prefixed commands to handlers
29
+ - Pass plain text to orchestrator for Agent execution
30
+ - Handle Ctrl+C (interrupt) and Ctrl+D (exit)
31
+ """
32
+
33
+ def __init__(
34
+ self,
35
+ orchestrator: Orchestrator,
36
+ renderer: Renderer | None = None,
37
+ ):
38
+ self._orchestrator = orchestrator
39
+ self._renderer = renderer or Renderer()
40
+ self._running = False
41
+ self._current_task: asyncio.Task | None = None
42
+
43
+ async def run(self) -> None:
44
+ """Start the REPL main loop.
45
+
46
+ Reads from stdin, dispatches commands, and executes prompts
47
+ via the orchestrator until the user exits.
48
+ """
49
+ self._running = True
50
+ logger.info("repl_started")
51
+
52
+ while self._running:
53
+ try:
54
+ user_input = await self._read_input()
55
+ except EOFError:
56
+ self._running = False
57
+ print("\nGoodbye!")
58
+ break
59
+ except KeyboardInterrupt:
60
+ print("\n")
61
+ continue
62
+
63
+ if not user_input:
64
+ continue
65
+
66
+ if user_input.startswith("/"):
67
+ await self._handle_command(user_input)
68
+ else:
69
+ await self._handle_prompt(user_input)
70
+
71
+ logger.info("repl_stopped")
72
+
73
+ async def _read_input(self) -> str:
74
+ """Read a line of input from stdin asynchronously.
75
+
76
+ Returns:
77
+ Stripped user input string.
78
+
79
+ Raises:
80
+ EOFError: On Ctrl+D (empty read).
81
+ """
82
+ loop = asyncio.get_event_loop()
83
+ # Use loop.run_in_executor so input() does not block the event loop
84
+ raw = await loop.run_in_executor(None, input, PROMPT)
85
+ if raw is None:
86
+ raise EOFError()
87
+ return raw.strip()
88
+
89
+ async def _handle_command(self, raw: str) -> None:
90
+ """Handle a /-prefixed CLI command.
91
+
92
+ Args:
93
+ raw: The full command string (e.g. "/exit").
94
+ """
95
+ parts = raw[1:].split()
96
+ if not parts:
97
+ return
98
+
99
+ cmd = parts[0].lower()
100
+ args = parts[1:]
101
+
102
+ match cmd:
103
+ case "exit" | "quit":
104
+ self._running = False
105
+ print("Goodbye!")
106
+
107
+ case "new":
108
+ session_id = self._orchestrator.new_session()
109
+ print(f"New session: {session_id}")
110
+
111
+ case "help":
112
+ self._print_help()
113
+
114
+ case _:
115
+ print(f"Unknown command: /{cmd}. Type /help for available commands.")
116
+
117
+ async def _handle_prompt(self, prompt: str) -> None:
118
+ """Execute a plain-text prompt through the orchestrator.
119
+
120
+ Args:
121
+ prompt: User's natural language input.
122
+ """
123
+ try:
124
+ stream = self._orchestrator.execute(prompt)
125
+ self._current_task = asyncio.create_task(
126
+ self._renderer.render_stream(stream)
127
+ )
128
+ await self._current_task
129
+ except asyncio.CancelledError:
130
+ print("\n[Cancelled]")
131
+ except OrchestratorError as exc:
132
+ print(f"\n[Error] {exc}")
133
+ except CLIError as exc:
134
+ print(f"\n[Error] {exc}")
135
+ except Exception as exc:
136
+ logger.error("unexpected_error", error=str(exc))
137
+ print(f"\n[Unexpected Error] {exc}")
138
+ finally:
139
+ self._current_task = None
140
+
141
+ def _print_help(self) -> None:
142
+ """Print available commands."""
143
+ print("""
144
+ Available commands:
145
+ /exit Exit the CLI
146
+ /new Start a new session
147
+ /help Show this help message
148
+
149
+ Any other text is sent to the AI assistant.
150
+ """)
151
+
152
+ async def interrupt(self) -> None:
153
+ """Interrupt the current execution (called on Ctrl+C).
154
+
155
+ Cancels the running render task and clears output.
156
+ """
157
+ if self._current_task and not self._current_task.done():
158
+ self._current_task.cancel()
159
+ self._renderer.clear_current_output()
160
+ try:
161
+ await self._current_task
162
+ except asyncio.CancelledError:
163
+ pass
164
+ print("\n[Cancelled]")
@@ -0,0 +1,5 @@
1
+ """UI / rendering layer for nonoka-cli."""
2
+
3
+ from nonoka_cli.ui.renderer import Renderer
4
+
5
+ __all__ = ["Renderer"]
@@ -0,0 +1,78 @@
1
+ """Basic text renderer — streams nonoka StreamEvent to the terminal.
2
+
3
+ TODO: Add Markdown rendering, code highlighting, tool cards, stats panel.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import sys
9
+ from collections.abc import AsyncIterator
10
+
11
+ from nonoka.core.runner import StreamEvent
12
+
13
+
14
+ class Renderer:
15
+ """Renders nonoka StreamEvent stream to the terminal.
16
+
17
+ Implements minimal text streaming:
18
+ - content_delta: print incremental text
19
+ - tool_call_start / tool_call_result: print simple indicators
20
+ - error: print error message
21
+ - final: newline after response
22
+ """
23
+
24
+ def __init__(self, file: object | None = None):
25
+ """Args:
26
+ file: Output stream (default: sys.stdout).
27
+ """
28
+ self._file = file or sys.stdout
29
+ self._in_tool_call = False
30
+
31
+ async def render_stream(self, stream: AsyncIterator[StreamEvent]) -> None:
32
+ """Consume a StreamEvent async iterator and render to terminal.
33
+
34
+ Args:
35
+ stream: AsyncIterator of StreamEvent from nonoka Runner.
36
+ """
37
+ async for event in stream:
38
+ self._render_event(event)
39
+
40
+ def _render_event(self, event: StreamEvent) -> None:
41
+ """Render a single StreamEvent."""
42
+ match event.type:
43
+ case "content_delta":
44
+ content = event.data.get("content", "")
45
+ if content:
46
+ self._file.write(content)
47
+ self._file.flush()
48
+
49
+ case "tool_call_start":
50
+ self._in_tool_call = True
51
+ tool_calls = event.data.get("tool_calls", [])
52
+ names = [tc.get("name", "unknown") for tc in tool_calls]
53
+ self._file.write(f"\n[Tool: {', '.join(names)}]\n")
54
+ self._file.flush()
55
+
56
+ case "tool_call_result":
57
+ self._in_tool_call = False
58
+ result = event.data.get("result")
59
+ if isinstance(result, dict) and "error" in result:
60
+ self._file.write(f"[Tool error: {result['error']}]\n")
61
+ else:
62
+ self._file.write("[Tool done]\n")
63
+ self._file.flush()
64
+
65
+ case "error":
66
+ error_msg = event.data.get("error", "Unknown error")
67
+ error_type = event.data.get("error_type", "error")
68
+ self._file.write(f"\n[{error_type.upper()}] {error_msg}\n")
69
+ self._file.flush()
70
+
71
+ case "final":
72
+ if not self._in_tool_call:
73
+ self._file.write("\n")
74
+ self._file.flush()
75
+
76
+ def clear_current_output(self) -> None:
77
+ """Clear current output area (no-op for basic renderer)."""
78
+ pass
@@ -0,0 +1 @@
1
+ """Utility modules for nonoka-cli."""
@@ -0,0 +1,28 @@
1
+ """Custom exceptions for nonoka-cli."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class CLIError(Exception):
7
+ """Base exception for all CLI errors."""
8
+ pass
9
+
10
+
11
+ class ConfigError(CLIError):
12
+ """Configuration loading or validation failed."""
13
+ pass
14
+
15
+
16
+ class ConfigNotFoundError(ConfigError):
17
+ """Configuration file not found."""
18
+ pass
19
+
20
+
21
+ class AgentBuildError(CLIError):
22
+ """Failed to build Agent from configuration."""
23
+ pass
24
+
25
+
26
+ class OrchestratorError(CLIError):
27
+ """Orchestrator execution error."""
28
+ pass
@@ -0,0 +1,75 @@
1
+ """Logging configuration for nonoka-cli using structlog."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import structlog
10
+
11
+
12
+ def setup_logging(
13
+ level: int = logging.INFO,
14
+ log_file: Path | None = None,
15
+ console: bool = False,
16
+ ) -> structlog.stdlib.BoundLogger:
17
+ """Configure structlog for nonoka-cli.
18
+
19
+ Args:
20
+ level: Logging level (default: INFO).
21
+ log_file: Path to log file. If None, defaults to
22
+ ~/.local/share/nonoka/logs/nonoka-cli.log.
23
+ console: Whether to also output logs to stderr.
24
+
25
+ Returns:
26
+ A configured structlog logger.
27
+ """
28
+ # Default log file path
29
+ if log_file is None:
30
+ log_file = Path.home() / ".local" / "share" / "nonoka" / "logs" / "nonoka-cli.log"
31
+
32
+ # Ensure log directory exists
33
+ log_file.parent.mkdir(parents=True, exist_ok=True)
34
+
35
+ shared_processors = [
36
+ structlog.stdlib.filter_by_level,
37
+ structlog.stdlib.add_logger_name,
38
+ structlog.stdlib.add_log_level,
39
+ structlog.processors.TimeStamper(fmt="iso"),
40
+ structlog.processors.StackInfoRenderer(),
41
+ structlog.processors.format_exc_info,
42
+ ]
43
+
44
+ handlers: list[logging.Handler] = [
45
+ logging.FileHandler(log_file, encoding="utf-8"),
46
+ ]
47
+
48
+ if console:
49
+ handlers.append(logging.StreamHandler(sys.stderr))
50
+
51
+ logging.basicConfig(
52
+ format="%(message)s",
53
+ level=level,
54
+ handlers=handlers,
55
+ )
56
+
57
+ structlog.configure(
58
+ processors=shared_processors + [
59
+ structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
60
+ ],
61
+ context_class=dict,
62
+ logger_factory=structlog.stdlib.LoggerFactory(),
63
+ wrapper_class=structlog.stdlib.BoundLogger,
64
+ cache_logger_on_first_use=True,
65
+ )
66
+
67
+ # Configure formatter for stdlib handlers
68
+ formatter = structlog.stdlib.ProcessorFormatter(
69
+ processor=structlog.dev.ConsoleRenderer(colors=console),
70
+ foreign_pre_chain=shared_processors,
71
+ )
72
+ for handler in handlers:
73
+ handler.setFormatter(formatter)
74
+
75
+ return structlog.get_logger("nonoka_cli")
@@ -0,0 +1,69 @@
1
+ Metadata-Version: 2.4
2
+ Name: nonoka-cli
3
+ Version: 0.1.0
4
+ Summary: Terminal frontend for the Nonoka Agent framework
5
+ License: MIT
6
+ Classifier: Development Status :: 3 - Alpha
7
+ Classifier: Intended Audience :: Developers
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.10
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Requires-Python: >=3.10
15
+ Requires-Dist: nonoka>=1.1.7
16
+ Requires-Dist: rich>=13.0.0
17
+ Requires-Dist: structlog>=24.0.0
18
+ Provides-Extra: dev
19
+ Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
20
+ Requires-Dist: pytest-cov>=5.0.0; extra == 'dev'
21
+ Requires-Dist: pytest>=8.0.0; extra == 'dev'
22
+ Description-Content-Type: text/markdown
23
+
24
+ # nonoka-cli
25
+
26
+ Terminal frontend for the [Nonoka](https://pypi.org/project/nonoka) Agent framework.
27
+
28
+ ## Quick Start
29
+
30
+ ```bash
31
+ # Install
32
+ pip install nonoka-cli
33
+
34
+ # Start interactive REPL
35
+ nonoka-cli
36
+
37
+ # With custom config
38
+ nonoka-cli --config ~/.config/nonoka/config.yaml
39
+
40
+ # Override model
41
+ nonoka-cli --model gpt-4o
42
+ ```
43
+
44
+ ## Configuration
45
+
46
+ Create `~/.config/nonoka/config.yaml`:
47
+
48
+ ```yaml
49
+ model: "gpt-4o"
50
+ system_prompt: |
51
+ You are a helpful AI assistant.
52
+ ```
53
+
54
+ ## Commands
55
+
56
+ - `/exit` — Quit the CLI
57
+ - `/new` — Start a new session
58
+ - `/help` — Show help
59
+
60
+ ## Development
61
+
62
+ ```bash
63
+ # Setup
64
+ uv venv
65
+ uv pip install -e ".[dev]"
66
+
67
+ # Run tests
68
+ pytest
69
+ ```
@@ -0,0 +1,21 @@
1
+ nonoka_cli/__init__.py,sha256=bWtxzZxy-h9XeGsGBQ4-CMXhbjiMwQkotjDpnaGnzyU,130
2
+ nonoka_cli/__main__.py,sha256=Xa46NPUKiCdUSR0GUnJY9HtEt5RXXheOwam96pX3_N4,159
3
+ nonoka_cli/cli.py,sha256=oxKAopJt9tzxUJ2qJ2l163OYrm9CyRBH2xB2FwXhPuw,3163
4
+ nonoka_cli/config/__init__.py,sha256=CQ1ti5x1HKsIG00lyx7KedmAGiibbzirAL_YbSpOP3k,262
5
+ nonoka_cli/config/loader.py,sha256=3OSW3OIOP5l7SbXYKFLarzrRNuhJcu32O3buMudZeHk,3747
6
+ nonoka_cli/config/models.py,sha256=Q-tcG94zFgWIk0vJ5ikSVnEhcILMVS-pDa-AJa5NrpU,1472
7
+ nonoka_cli/core/__init__.py,sha256=cHCJXJHqQ12GV2P3kVhSXvgIjcTbQ2pDsH-G1dsGy7M,298
8
+ nonoka_cli/core/agent_factory.py,sha256=TjsnNEEySegVQ17eKzHgREaXqRzhetpjXdo1XNe_epI,1996
9
+ nonoka_cli/core/context.py,sha256=Ti81oHE0FjaUp32X_3h7OX1Z8iM64Iy7fi4Gidz052Q,357
10
+ nonoka_cli/core/orchestrator.py,sha256=CV7sppeuqR-f24v4CZlF-gjBXX5LJMsIZlwzJEW0FE4,4105
11
+ nonoka_cli/shell/__init__.py,sha256=8ytQyy-XtRw6-45WFMVpCevjADUDBmXVmDkAn5Qn2BQ,101
12
+ nonoka_cli/shell/repl.py,sha256=3CceBY2arH9J5i6SdINmJeOUVlQWBFq40o-fothv-L0,4173
13
+ nonoka_cli/ui/__init__.py,sha256=fcxR0JTtkc0LBIMrN9kAchfw7UJ4zYaAfLTE_3gMYG8,112
14
+ nonoka_cli/ui/renderer.py,sha256=5FEQaG75JjNtUSNVwkDx7ELxX2b_d3du8RmzMzRBooo,2389
15
+ nonoka_cli/utils/__init__.py,sha256=mGxMnE0iPLieZWXRJAv1hJEoSOkA51lzv6M3tZtel6Q,38
16
+ nonoka_cli/utils/errors.py,sha256=zuVmKqbyf5jVnOJ0map-AGUMfOX4fZWM8xzsVGC16L0,506
17
+ nonoka_cli/utils/logging.py,sha256=dSSaOpnuWt3SMBeao69z6wMin6wGvFCC83CfuGD85_U,1972
18
+ nonoka_cli-0.1.0.dist-info/METADATA,sha256=ch_1NxyysF109_K9R36Apontxmu4o73g_KH_xYKrjvY,1480
19
+ nonoka_cli-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
20
+ nonoka_cli-0.1.0.dist-info/entry_points.txt,sha256=laiysCgrJzjJNv7s_-puse-hfAng0lD9skaT-YPG8Ag,51
21
+ nonoka_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ nonoka-cli = nonoka_cli.cli:main