nonoka-cli 0.1.0__tar.gz
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-0.1.0/.gitignore +49 -0
- nonoka_cli-0.1.0/PKG-INFO +69 -0
- nonoka_cli-0.1.0/README.md +46 -0
- nonoka_cli-0.1.0/config.yaml.example +40 -0
- nonoka_cli-0.1.0/pyproject.toml +52 -0
- nonoka_cli-0.1.0/src/nonoka_cli/__init__.py +5 -0
- nonoka_cli-0.1.0/src/nonoka_cli/__main__.py +7 -0
- nonoka_cli-0.1.0/src/nonoka_cli/cli.py +134 -0
- nonoka_cli-0.1.0/src/nonoka_cli/config/__init__.py +8 -0
- nonoka_cli-0.1.0/src/nonoka_cli/config/loader.py +130 -0
- nonoka_cli-0.1.0/src/nonoka_cli/config/models.py +51 -0
- nonoka_cli-0.1.0/src/nonoka_cli/core/__init__.py +9 -0
- nonoka_cli-0.1.0/src/nonoka_cli/core/agent_factory.py +78 -0
- nonoka_cli-0.1.0/src/nonoka_cli/core/context.py +17 -0
- nonoka_cli-0.1.0/src/nonoka_cli/core/orchestrator.py +138 -0
- nonoka_cli-0.1.0/src/nonoka_cli/shell/__init__.py +5 -0
- nonoka_cli-0.1.0/src/nonoka_cli/shell/repl.py +164 -0
- nonoka_cli-0.1.0/src/nonoka_cli/ui/__init__.py +5 -0
- nonoka_cli-0.1.0/src/nonoka_cli/ui/renderer.py +78 -0
- nonoka_cli-0.1.0/src/nonoka_cli/utils/__init__.py +1 -0
- nonoka_cli-0.1.0/src/nonoka_cli/utils/errors.py +28 -0
- nonoka_cli-0.1.0/src/nonoka_cli/utils/logging.py +75 -0
- nonoka_cli-0.1.0/tests/__init__.py +1 -0
- nonoka_cli-0.1.0/tests/conftest.py +72 -0
- nonoka_cli-0.1.0/tests/integration/__init__.py +1 -0
- nonoka_cli-0.1.0/tests/integration/test_real_llm.py +215 -0
- nonoka_cli-0.1.0/tests/unit/__init__.py +1 -0
- nonoka_cli-0.1.0/tests/unit/test_config.py +151 -0
- nonoka_cli-0.1.0/tests/unit/test_core.py +193 -0
- nonoka_cli-0.1.0/tests/unit/test_shell.py +158 -0
- nonoka_cli-0.1.0/tests/unit/test_ui.py +164 -0
- nonoka_cli-0.1.0/uv.lock +3268 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# IDE / Editor
|
|
2
|
+
self-files/
|
|
3
|
+
.claude/
|
|
4
|
+
.idea/
|
|
5
|
+
.vscode/
|
|
6
|
+
*.swp
|
|
7
|
+
*.swo
|
|
8
|
+
*~
|
|
9
|
+
|
|
10
|
+
# Environment
|
|
11
|
+
.env
|
|
12
|
+
.venv/
|
|
13
|
+
venv/
|
|
14
|
+
|
|
15
|
+
# Python
|
|
16
|
+
__pycache__/
|
|
17
|
+
*.py[cod]
|
|
18
|
+
*$py.class
|
|
19
|
+
*.so
|
|
20
|
+
.Python
|
|
21
|
+
build/
|
|
22
|
+
develop-eggs/
|
|
23
|
+
dist/
|
|
24
|
+
downloads/
|
|
25
|
+
eggs/
|
|
26
|
+
.eggs/
|
|
27
|
+
lib/
|
|
28
|
+
lib64/
|
|
29
|
+
parts/
|
|
30
|
+
sdist/
|
|
31
|
+
var/
|
|
32
|
+
wheels/
|
|
33
|
+
*.egg-info/
|
|
34
|
+
.installed.cfg
|
|
35
|
+
*.egg
|
|
36
|
+
MANIFEST
|
|
37
|
+
|
|
38
|
+
# Testing
|
|
39
|
+
.pytest_cache/
|
|
40
|
+
.coverage
|
|
41
|
+
htmlcov/
|
|
42
|
+
.tox/
|
|
43
|
+
|
|
44
|
+
# OS
|
|
45
|
+
.DS_Store
|
|
46
|
+
Thumbs.db
|
|
47
|
+
|
|
48
|
+
# Logs
|
|
49
|
+
*.log
|
|
@@ -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,46 @@
|
|
|
1
|
+
# nonoka-cli
|
|
2
|
+
|
|
3
|
+
Terminal frontend for the [Nonoka](https://pypi.org/project/nonoka) Agent framework.
|
|
4
|
+
|
|
5
|
+
## Quick Start
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# Install
|
|
9
|
+
pip install nonoka-cli
|
|
10
|
+
|
|
11
|
+
# Start interactive REPL
|
|
12
|
+
nonoka-cli
|
|
13
|
+
|
|
14
|
+
# With custom config
|
|
15
|
+
nonoka-cli --config ~/.config/nonoka/config.yaml
|
|
16
|
+
|
|
17
|
+
# Override model
|
|
18
|
+
nonoka-cli --model gpt-4o
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Configuration
|
|
22
|
+
|
|
23
|
+
Create `~/.config/nonoka/config.yaml`:
|
|
24
|
+
|
|
25
|
+
```yaml
|
|
26
|
+
model: "gpt-4o"
|
|
27
|
+
system_prompt: |
|
|
28
|
+
You are a helpful AI assistant.
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Commands
|
|
32
|
+
|
|
33
|
+
- `/exit` — Quit the CLI
|
|
34
|
+
- `/new` — Start a new session
|
|
35
|
+
- `/help` — Show help
|
|
36
|
+
|
|
37
|
+
## Development
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
# Setup
|
|
41
|
+
uv venv
|
|
42
|
+
uv pip install -e ".[dev]"
|
|
43
|
+
|
|
44
|
+
# Run tests
|
|
45
|
+
pytest
|
|
46
|
+
```
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# nonoka-cli configuration example — Deepseek API
|
|
2
|
+
# Copy to ~/.config/nonoka/config.yaml to use
|
|
3
|
+
|
|
4
|
+
model: "deepseek-chat"
|
|
5
|
+
|
|
6
|
+
system_prompt: |
|
|
7
|
+
You are nonoka, a helpful AI assistant running in a terminal.
|
|
8
|
+
Be concise but thorough in your responses.
|
|
9
|
+
|
|
10
|
+
# TODO: MCP server configuration (enabled in Phase 5)
|
|
11
|
+
# mcp_servers:
|
|
12
|
+
# filesystem:
|
|
13
|
+
# transport: stdio
|
|
14
|
+
# command: npx
|
|
15
|
+
# args:
|
|
16
|
+
# - "-y"
|
|
17
|
+
# - "@modelcontextprotocol/server-filesystem"
|
|
18
|
+
# - "~"
|
|
19
|
+
|
|
20
|
+
# TODO: Local tool scan paths (enabled in Phase 6)
|
|
21
|
+
# tool_paths:
|
|
22
|
+
# - ~/.config/nonoka/tools/
|
|
23
|
+
|
|
24
|
+
# TODO: Enabled skills (enabled in Phase 6)
|
|
25
|
+
# skills:
|
|
26
|
+
# - code-review
|
|
27
|
+
|
|
28
|
+
cli:
|
|
29
|
+
theme: dark
|
|
30
|
+
auto_approve: false
|
|
31
|
+
editor: "${EDITOR:-nano}"
|
|
32
|
+
max_history: 1000
|
|
33
|
+
|
|
34
|
+
hitl:
|
|
35
|
+
policy: interactive
|
|
36
|
+
dangerous_tools:
|
|
37
|
+
- write_file
|
|
38
|
+
- edit_file
|
|
39
|
+
- delete_file
|
|
40
|
+
- execute_command
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "nonoka-cli"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Terminal frontend for the Nonoka Agent framework"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 3 - Alpha",
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"License :: OSI Approved :: MIT License",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.10",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
]
|
|
22
|
+
dependencies = [
|
|
23
|
+
"nonoka>=1.1.7",
|
|
24
|
+
"rich>=13.0.0",
|
|
25
|
+
"structlog>=24.0.0",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.scripts]
|
|
29
|
+
nonoka-cli = "nonoka_cli.cli:main"
|
|
30
|
+
|
|
31
|
+
[project.optional-dependencies]
|
|
32
|
+
dev = [
|
|
33
|
+
"pytest>=8.0.0",
|
|
34
|
+
"pytest-asyncio>=0.23.0",
|
|
35
|
+
"pytest-cov>=5.0.0",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[tool.hatch.build.targets.wheel]
|
|
39
|
+
packages = ["src/nonoka_cli"]
|
|
40
|
+
|
|
41
|
+
[tool.pytest.ini_options]
|
|
42
|
+
testpaths = ["tests"]
|
|
43
|
+
pythonpath = ["src"]
|
|
44
|
+
asyncio_mode = "auto"
|
|
45
|
+
addopts = "-v --tb=short"
|
|
46
|
+
|
|
47
|
+
[tool.coverage.run]
|
|
48
|
+
source = ["src/nonoka_cli"]
|
|
49
|
+
|
|
50
|
+
[tool.ruff]
|
|
51
|
+
line-length = 100
|
|
52
|
+
select = ["E", "F", "W", "I"]
|
|
@@ -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
|