返回 JoyAI-Echo
commands.py
1 """CLI commands for nanobot."""
2
3 import asyncio
4 import os
5 import select
6 import signal
7 import sys
8 from contextlib import nullcontext
9 from pathlib import Path
10 from typing import Any
11
12 # Force UTF-8 encoding for Windows console
13 if sys.platform == "win32":
14 if sys.stdout.encoding != "utf-8":
15 os.environ["PYTHONIOENCODING"] = "utf-8"
16 # Re-open stdout/stderr with UTF-8 encoding
17 try:
18 sys.stdout.reconfigure(encoding="utf-8", errors="replace")
19 sys.stderr.reconfigure(encoding="utf-8", errors="replace")
20 except Exception:
21 pass
22
23 import typer
24 from loguru import logger
25 from prompt_toolkit import PromptSession, print_formatted_text
26 from prompt_toolkit.application import run_in_terminal
27 from prompt_toolkit.formatted_text import ANSI, HTML
28 from prompt_toolkit.history import FileHistory
29 from prompt_toolkit.patch_stdout import patch_stdout
30 from rich.console import Console
31 from rich.markdown import Markdown
32 from rich.table import Table
33 from rich.text import Text
34
35 from nanobot import __logo__, __version__
36 from nanobot.cli.stream import StreamRenderer, ThinkingSpinner
37 from nanobot.config.paths import get_workspace_path, is_default_workspace
38 from nanobot.config.schema import Config
39 from nanobot.utils.helpers import sync_workspace_templates
40 from nanobot.utils.restart import (
41 consume_restart_notice_from_env,
42 format_restart_completed_message,
43 should_show_cli_restart_notice,
44 )
45
46
47 class SafeFileHistory(FileHistory):
48 """FileHistory subclass that sanitizes surrogate characters on write.
49
50 On Windows, special Unicode input (emoji, mixed-script) can produce
51 surrogate characters that crash prompt_toolkit's file write.
52 See issue #2846.
53 """
54
55 def store_string(self, string: str) -> None:
56 safe = string.encode("utf-8", errors="surrogateescape").decode("utf-8", errors="replace")
57 super().store_string(safe)
58
59 app = typer.Typer(
60 name="nanobot",
61 context_settings={"help_option_names": ["-h", "--help"]},
62 help=f"{__logo__} nanobot - Personal AI Assistant",
63 no_args_is_help=True,
64 )
65
66
67 console = Console()
68 EXIT_COMMANDS = {"exit", "quit", "/exit", "/quit", ":q"}
69
70 # ---------------------------------------------------------------------------
71 # CLI input: prompt_toolkit for editing, paste, history, and display
72 # ---------------------------------------------------------------------------
73
74 _PROMPT_SESSION: PromptSession | None = None
75 _SAVED_TERM_ATTRS = None # original termios settings, restored on exit
76
77
78 def _flush_pending_tty_input() -> None:
79 """Drop unread keypresses typed while the model was generating output."""
80 try:
81 fd = sys.stdin.fileno()
82 if not os.isatty(fd):
83 return
84 except Exception:
85 return
86
87 try:
88 import termios
89
90 termios.tcflush(fd, termios.TCIFLUSH)
91 return
92 except Exception:
93 pass
94
95 try:
96 while True:
97 ready, _, _ = select.select([fd], [], [], 0)
98 if not ready:
99 break
100 if not os.read(fd, 4096):
101 break
102 except Exception:
103 return
104
105
106 def _restore_terminal() -> None:
107 """Restore terminal to its original state (echo, line buffering, etc.)."""
108 if _SAVED_TERM_ATTRS is None:
109 return
110 try:
111 import termios
112
113 termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, _SAVED_TERM_ATTRS)
114 except Exception:
115 pass
116
117
118 def _init_prompt_session() -> None:
119 """Create the prompt_toolkit session with persistent file history."""
120 global _PROMPT_SESSION, _SAVED_TERM_ATTRS
121
122 # Save terminal state so we can restore it on exit
123 try:
124 import termios
125
126 _SAVED_TERM_ATTRS = termios.tcgetattr(sys.stdin.fileno())
127 except Exception:
128 pass
129
130 from nanobot.config.paths import get_cli_history_path
131
132 history_file = get_cli_history_path()
133 history_file.parent.mkdir(parents=True, exist_ok=True)
134
135 _PROMPT_SESSION = PromptSession(
136 history=SafeFileHistory(str(history_file)),
137 enable_open_in_editor=False,
138 multiline=False, # Enter submits (single line mode)
139 )
140
141
142 def _make_console() -> Console:
143 return Console(file=sys.stdout)
144
145
146 def _render_interactive_ansi(render_fn) -> str:
147 """Render Rich output to ANSI so prompt_toolkit can print it safely."""
148 ansi_console = Console(
149 force_terminal=sys.stdout.isatty(),
150 color_system=console.color_system or "standard",
151 width=console.width,
152 )
153 with ansi_console.capture() as capture:
154 render_fn(ansi_console)
155 return capture.get()
156
157
158 def _print_agent_response(
159 response: str,
160 render_markdown: bool,
161 metadata: dict | None = None,
162 ) -> None:
163 """Render assistant response with consistent terminal styling."""
164 console = _make_console()
165 content = response or ""
166 body = _response_renderable(content, render_markdown, metadata)
167 console.print()
168 console.print(f"[cyan]{__logo__} nanobot[/cyan]")
169 console.print(body)
170 console.print()
171
172
173 def _response_renderable(content: str, render_markdown: bool, metadata: dict | None = None):
174 """Render plain-text command output without markdown collapsing newlines."""
175 if not render_markdown:
176 return Text(content)
177 if (metadata or {}).get("render_as") == "text":
178 return Text(content)
179 return Markdown(content)
180
181
182 async def _print_interactive_line(text: str) -> None:
183 """Print async interactive updates with prompt_toolkit-safe Rich styling."""
184 def _write() -> None:
185 ansi = _render_interactive_ansi(
186 lambda c: c.print(f" [dim]↳ {text}[/dim]")
187 )
188 print_formatted_text(ANSI(ansi), end="")
189
190 await run_in_terminal(_write)
191
192
193 async def _print_interactive_response(
194 response: str,
195 render_markdown: bool,
196 metadata: dict | None = None,
197 ) -> None:
198 """Print async interactive replies with prompt_toolkit-safe Rich styling."""
199 def _write() -> None:
200 content = response or ""
201 ansi = _render_interactive_ansi(
202 lambda c: (
203 c.print(),
204 c.print(f"[cyan]{__logo__} nanobot[/cyan]"),
205 c.print(_response_renderable(content, render_markdown, metadata)),
206 c.print(),
207 )
208 )
209 print_formatted_text(ANSI(ansi), end="")
210
211 await run_in_terminal(_write)
212
213
214 def _print_cli_progress_line(text: str, thinking: ThinkingSpinner | None) -> None:
215 """Print a CLI progress line, pausing the spinner if needed."""
216 with thinking.pause() if thinking else nullcontext():
217 console.print(f" [dim]↳ {text}[/dim]")
218
219
220 async def _print_interactive_progress_line(text: str, thinking: ThinkingSpinner | None) -> None:
221 """Print an interactive progress line, pausing the spinner if needed."""
222 with thinking.pause() if thinking else nullcontext():
223 await _print_interactive_line(text)
224
225
226 def _is_exit_command(command: str) -> bool:
227 """Return True when input should end interactive chat."""
228 return command.lower() in EXIT_COMMANDS
229
230
231 async def _read_interactive_input_async() -> str:
232 """Read user input using prompt_toolkit (handles paste, history, display).
233
234 prompt_toolkit natively handles:
235 - Multiline paste (bracketed paste mode)
236 - History navigation (up/down arrows)
237 - Clean display (no ghost characters or artifacts)
238 """
239 if _PROMPT_SESSION is None:
240 raise RuntimeError("Call _init_prompt_session() first")
241 try:
242 with patch_stdout():
243 return await _PROMPT_SESSION.prompt_async(
244 HTML("<b fg='ansiblue'>You:</b> "),
245 )
246 except EOFError as exc:
247 raise KeyboardInterrupt from exc
248
249
250 def version_callback(value: bool):
251 if value:
252 console.print(f"{__logo__} nanobot v{__version__}")
253 raise typer.Exit()
254
255
256 @app.callback()
257 def main(
258 version: bool = typer.Option(
259 None, "--version", "-v", callback=version_callback, is_eager=True
260 ),
261 ):
262 """nanobot - Personal AI Assistant."""
263 pass
264
265
266 # ============================================================================
267 # Onboard / Setup
268 # ============================================================================
269
270
271 @app.command()
272 def onboard(
273 workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
274 config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
275 wizard: bool = typer.Option(False, "--wizard", help="Use interactive wizard"),
276 ):
277 """Initialize nanobot configuration and workspace."""
278 from nanobot.config.loader import get_config_path, load_config, save_config, set_config_path
279 from nanobot.config.schema import Config
280
281 if config:
282 config_path = Path(config).expanduser().resolve()
283 set_config_path(config_path)
284 console.print(f"[dim]Using config: {config_path}[/dim]")
285 else:
286 config_path = get_config_path()
287
288 def _apply_workspace_override(loaded: Config) -> Config:
289 if workspace:
290 loaded.agents.defaults.workspace = workspace
291 return loaded
292
293 # Create or update config
294 if config_path.exists():
295 if wizard:
296 config = _apply_workspace_override(load_config(config_path))
297 else:
298 console.print(f"[yellow]Config already exists at {config_path}[/yellow]")
299 console.print(
300 " [bold]y[/bold] = overwrite with defaults (existing values will be lost)"
301 )
302 console.print(
303 " [bold]N[/bold] = refresh config, keeping existing values and adding new fields"
304 )
305 if typer.confirm("Overwrite?"):
306 config = _apply_workspace_override(Config())
307 save_config(config, config_path)
308 console.print(f"[green]✓[/green] Config reset to defaults at {config_path}")
309 else:
310 config = _apply_workspace_override(load_config(config_path))
311 save_config(config, config_path)
312 console.print(
313 f"[green]✓[/green] Config refreshed at {config_path} (existing values preserved)"
314 )
315 else:
316 config = _apply_workspace_override(Config())
317 # In wizard mode, don't save yet - the wizard will handle saving if should_save=True
318 if not wizard:
319 save_config(config, config_path)
320 console.print(f"[green]✓[/green] Created config at {config_path}")
321
322 # Run interactive wizard if enabled
323 if wizard:
324 from nanobot.cli.onboard import run_onboard
325
326 try:
327 result = run_onboard(initial_config=config)
328 if not result.should_save:
329 console.print("[yellow]Configuration discarded. No changes were saved.[/yellow]")
330 return
331
332 config = result.config
333 save_config(config, config_path)
334 console.print(f"[green]✓[/green] Config saved at {config_path}")
335 except Exception as e:
336 console.print(f"[red]✗[/red] Error during configuration: {e}")
337 console.print("[yellow]Please run 'nanobot onboard' again to complete setup.[/yellow]")
338 raise typer.Exit(1)
339 _onboard_plugins(config_path)
340
341 # Create workspace, preferring the configured workspace path.
342 workspace_path = get_workspace_path(config.workspace_path)
343 if not workspace_path.exists():
344 workspace_path.mkdir(parents=True, exist_ok=True)
345 console.print(f"[green]✓[/green] Created workspace at {workspace_path}")
346
347 sync_workspace_templates(workspace_path)
348
349 agent_cmd = 'nanobot agent -m "Hello!"'
350 gateway_cmd = "nanobot gateway"
351 if config:
352 agent_cmd += f" --config {config_path}"
353 gateway_cmd += f" --config {config_path}"
354
355 console.print(f"\n{__logo__} nanobot is ready!")
356 console.print("\nNext steps:")
357 if wizard:
358 console.print(f" 1. Chat: [cyan]{agent_cmd}[/cyan]")
359 console.print(f" 2. Start gateway: [cyan]{gateway_cmd}[/cyan]")
360 else:
361 console.print(f" 1. Add your API key to [cyan]{config_path}[/cyan]")
362 console.print(" Get one at: https://openrouter.ai/keys")
363 console.print(f" 2. Chat: [cyan]{agent_cmd}[/cyan]")
364 console.print(
365 "\n[dim]Want Telegram/WhatsApp? See: https://github.com/HKUDS/nanobot#-chat-apps[/dim]"
366 )
367
368
369 def _merge_missing_defaults(existing: Any, defaults: Any) -> Any:
370 """Recursively fill in missing values from defaults without overwriting user config."""
371 if not isinstance(existing, dict) or not isinstance(defaults, dict):
372 return existing
373
374 merged = dict(existing)
375 for key, value in defaults.items():
376 if key not in merged:
377 merged[key] = value
378 else:
379 merged[key] = _merge_missing_defaults(merged[key], value)
380 return merged
381
382
383 def _onboard_plugins(config_path: Path) -> None:
384 """Inject default config for all discovered channels (built-in + plugins)."""
385 import json
386
387 from nanobot.channels.registry import discover_all
388
389 all_channels = discover_all()
390 if not all_channels:
391 return
392
393 with open(config_path, encoding="utf-8") as f:
394 data = json.load(f)
395
396 channels = data.setdefault("channels", {})
397 for name, cls in all_channels.items():
398 if name not in channels:
399 channels[name] = cls.default_config()
400 else:
401 channels[name] = _merge_missing_defaults(channels[name], cls.default_config())
402
403 with open(config_path, "w", encoding="utf-8") as f:
404 json.dump(data, f, indent=2, ensure_ascii=False)
405
406
407 def _make_provider(config: Config):
408 """Create the appropriate LLM provider from config.
409
410 Routing is driven by ``ProviderSpec.backend`` in the registry.
411 """
412 from nanobot.providers.base import GenerationSettings
413 from nanobot.providers.registry import find_by_name
414
415 model = config.agents.defaults.model
416 provider_name = config.get_provider_name(model)
417 p = config.get_provider(model)
418 spec = find_by_name(provider_name) if provider_name else None
419 backend = spec.backend if spec else "openai_compat"
420
421 # --- validation ---
422 if backend == "azure_openai":
423 if not p or not p.api_key or not p.api_base:
424 console.print("[red]Error: Azure OpenAI requires api_key and api_base.[/red]")
425 console.print("Set them in ~/.nanobot/config.json under providers.azure_openai section")
426 console.print("Use the model field to specify the deployment name.")
427 raise typer.Exit(1)
428 elif backend == "openai_compat" and not model.startswith("bedrock/"):
429 needs_key = not (p and p.api_key)
430 exempt = spec and (spec.is_oauth or spec.is_local or spec.is_direct)
431 if needs_key and not exempt:
432 console.print("[red]Error: No API key configured.[/red]")
433 console.print("Set one in ~/.nanobot/config.json under providers section")
434 raise typer.Exit(1)
435
436 # --- instantiation by backend ---
437 if backend == "openai_codex":
438 from nanobot.providers.openai_codex_provider import OpenAICodexProvider
439
440 provider = OpenAICodexProvider(default_model=model)
441 elif backend == "azure_openai":
442 from nanobot.providers.azure_openai_provider import AzureOpenAIProvider
443
444 provider = AzureOpenAIProvider(
445 api_key=p.api_key,
446 api_base=p.api_base,
447 default_model=model,
448 )
449 elif backend == "github_copilot":
450 from nanobot.providers.github_copilot_provider import GitHubCopilotProvider
451 provider = GitHubCopilotProvider(default_model=model)
452 elif backend == "anthropic":
453 from nanobot.providers.anthropic_provider import AnthropicProvider
454
455 provider = AnthropicProvider(
456 api_key=p.api_key if p else None,
457 api_base=config.get_api_base(model),
458 default_model=model,
459 extra_headers=p.extra_headers if p else None,
460 )
461 else:
462 from nanobot.providers.openai_compat_provider import OpenAICompatProvider
463
464 provider = OpenAICompatProvider(
465 api_key=p.api_key if p else None,
466 api_base=config.get_api_base(model),
467 default_model=model,
468 extra_headers=p.extra_headers if p else None,
469 spec=spec,
470 )
471
472 defaults = config.agents.defaults
473 provider.generation = GenerationSettings(
474 temperature=defaults.temperature,
475 max_tokens=defaults.max_tokens,
476 reasoning_effort=defaults.reasoning_effort,
477 )
478 return provider
479
480
481 def _make_session_manager(config: Config, provider) -> "SessionManager":
482 from nanobot.session.manager import SessionManager
483 from nanobot.session.preview import SessionPreviewGenerator
484
485 preview_generator = SessionPreviewGenerator(
486 provider,
487 config.agents.defaults.model,
488 )
489 return SessionManager(
490 config.workspace_path,
491 preview_generator=preview_generator,
492 )
493
494
495 def _load_runtime_config(config: str | None = None, workspace: str | None = None) -> Config:
496 """Load config and optionally override the active workspace."""
497 from nanobot.config.loader import load_config, resolve_config_env_vars, set_config_path
498
499 config_path = None
500 if config:
501 config_path = Path(config).expanduser().resolve()
502 if not config_path.exists():
503 console.print(f"[red]Error: Config file not found: {config_path}[/red]")
504 raise typer.Exit(1)
505 set_config_path(config_path)
506 console.print(f"[dim]Using config: {config_path}[/dim]")
507
508 try:
509 loaded = resolve_config_env_vars(load_config(config_path))
510 except ValueError as e:
511 console.print(f"[red]Error: {e}[/red]")
512 raise typer.Exit(1)
513 _warn_deprecated_config_keys(config_path)
514 if workspace:
515 loaded.agents.defaults.workspace = workspace
516 _init_prompt_engineering(loaded)
517 return loaded
518
519
520 def _init_prompt_engineering(config: "Config") -> None:
521 """Point the process-wide PEManager at the configured PE set root and active set."""
522 from nanobot.prompts import PEManager, default_pe_root
523
524 pe = config.prompt_engineering
525 root = Path(pe.root).expanduser() if pe.root else default_pe_root()
526 PEManager.instance().configure(root, active=pe.active, enabled=pe.enabled)
527
528
529 def _warn_deprecated_config_keys(config_path: Path | None) -> None:
530 """Hint users to remove obsolete keys from their config file."""
531 import json
532
533 from nanobot.config.loader import get_config_path
534
535 path = config_path or get_config_path()
536 try:
537 raw = json.loads(path.read_text(encoding="utf-8"))
538 except Exception:
539 return
540 if "memoryWindow" in raw.get("agents", {}).get("defaults", {}):
541 console.print(
542 "[dim]Hint: `memoryWindow` in your config is no longer used "
543 "and can be safely removed.[/dim]"
544 )
545
546
547 def _migrate_cron_store(config: "Config") -> None:
548 """One-time migration: move legacy global cron store into the workspace."""
549 from nanobot.config.paths import get_cron_dir
550
551 legacy_path = get_cron_dir() / "jobs.json"
552 new_path = config.workspace_path / "cron" / "jobs.json"
553 if legacy_path.is_file() and not new_path.exists():
554 new_path.parent.mkdir(parents=True, exist_ok=True)
555 import shutil
556
557 shutil.move(str(legacy_path), str(new_path))
558
559
560 # ============================================================================
561 # OpenAI-Compatible API Server
562 # ============================================================================
563
564
565 @app.command()
566 def serve(
567 port: int | None = typer.Option(None, "--port", "-p", help="API server port"),
568 host: str | None = typer.Option(None, "--host", "-H", help="Bind address"),
569 timeout: float | None = typer.Option(None, "--timeout", "-t", help="Per-request timeout (seconds)"),
570 verbose: bool = typer.Option(False, "--verbose", "-v", help="Show nanobot runtime logs"),
571 workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
572 config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
573 ):
574 """Start the OpenAI-compatible API server (/v1/chat/completions)."""
575 try:
576 from aiohttp import web # noqa: F401
577 except ImportError:
578 console.print("[red]aiohttp is required. Install with: pip install 'echo-director-agent[api]'[/red]")
579 raise typer.Exit(1)
580
581 from loguru import logger
582
583 from nanobot.agent.loop import AgentLoop
584 from nanobot.api.server import create_app
585 from nanobot.bus.queue import MessageBus
586 from nanobot.session.manager import SessionManager
587
588 if verbose:
589 logger.enable("nanobot")
590 else:
591 logger.disable("nanobot")
592
593 runtime_config = _load_runtime_config(config, workspace)
594 api_cfg = runtime_config.api
595 host = host if host is not None else api_cfg.host
596 port = port if port is not None else api_cfg.port
597 timeout = timeout if timeout is not None else api_cfg.timeout
598 sync_workspace_templates(runtime_config.workspace_path)
599 bus = MessageBus()
600 provider = _make_provider(runtime_config)
601 session_manager = _make_session_manager(runtime_config, provider)
602 agent_loop = AgentLoop(
603 bus=bus,
604 provider=provider,
605 workspace=runtime_config.workspace_path,
606 model=runtime_config.agents.defaults.model,
607 max_iterations=runtime_config.agents.defaults.max_tool_iterations,
608 context_window_tokens=runtime_config.agents.defaults.context_window_tokens,
609 context_block_limit=runtime_config.agents.defaults.context_block_limit,
610 max_tool_result_chars=runtime_config.agents.defaults.max_tool_result_chars,
611 provider_retry_mode=runtime_config.agents.defaults.provider_retry_mode,
612 web_config=runtime_config.tools.web,
613 exec_config=runtime_config.tools.exec,
614 restrict_to_workspace=runtime_config.tools.restrict_to_workspace,
615 session_manager=session_manager,
616 mcp_servers=runtime_config.tools.mcp_servers,
617 channels_config=runtime_config.channels,
618 timezone=runtime_config.agents.defaults.timezone,
619 unified_session=runtime_config.agents.defaults.unified_session,
620 disabled_skills=runtime_config.agents.defaults.disabled_skills,
621 session_ttl_minutes=runtime_config.agents.defaults.session_ttl_minutes,
622 tools_config=runtime_config.tools,
623 config=runtime_config,
624 prompt_stacker_enabled=runtime_config.prompt_stacker.enabled,
625 prompt_stacker_max_traces=runtime_config.prompt_stacker.max_traces,
626 )
627
628 model_name = runtime_config.agents.defaults.model
629 console.print(f"{__logo__} Starting OpenAI-compatible API server")
630 console.print(f" [cyan]Endpoint[/cyan] : http://{host}:{port}/v1/chat/completions")
631 console.print(f" [cyan]Model[/cyan] : {model_name}")
632 console.print(" [cyan]Session[/cyan] : api:default")
633 console.print(f" [cyan]Timeout[/cyan] : {timeout}s")
634 if host in {"0.0.0.0", "::"}:
635 console.print(
636 "[yellow]Warning:[/yellow] API is bound to all interfaces. "
637 "Only do this behind a trusted network boundary, firewall, or reverse proxy."
638 )
639 console.print()
640
641 api_app = create_app(agent_loop, model_name=model_name, request_timeout=timeout)
642
643 async def on_startup(_app):
644 await agent_loop._connect_mcp()
645
646 async def on_cleanup(_app):
647 await agent_loop.close_mcp()
648
649 api_app.on_startup.append(on_startup)
650 api_app.on_cleanup.append(on_cleanup)
651
652 web.run_app(api_app, host=host, port=port, print=lambda msg: logger.info(msg))
653
654
655 # ============================================================================
656 # Gateway / Server
657 # ============================================================================
658
659
660 @app.command()
661 def gateway(
662 port: int | None = typer.Option(None, "--port", "-p", help="Gateway port"),
663 workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
664 verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
665 config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
666 debug: bool = typer.Option(False, "--debug", help="Print detailed per-iteration debug output"),
667 ):
668 """Start the nanobot gateway."""
669 if verbose:
670 import logging
671
672 logging.basicConfig(level=logging.DEBUG)
673 cfg = _load_runtime_config(config, workspace)
674 _run_gateway(cfg, port=port, debug=debug)
675
676
677 def _run_gateway(
678 config: Config,
679 *,
680 port: int | None = None,
681 open_browser_url: str | None = None,
682 debug: bool = False,
683 ) -> None:
684 """Shared gateway runtime; ``open_browser_url`` opens a tab once channels are up."""
685 from nanobot.agent.loop import AgentLoop
686 from nanobot.bus.queue import MessageBus
687 from nanobot.channels.manager import ChannelManager
688 from nanobot.cron.service import CronService
689 from nanobot.cron.types import CronJob
690 from nanobot.heartbeat.service import HeartbeatService
691 from nanobot.session.manager import SessionManager
692
693 port = port if port is not None else config.gateway.port
694
695 console.print(f"{__logo__} Starting nanobot gateway version {__version__} on port {port}...")
696 sync_workspace_templates(config.workspace_path)
697 bus = MessageBus()
698 provider = _make_provider(config)
699 session_manager = _make_session_manager(config, provider)
700
701 # Preserve existing single-workspace installs, but keep custom workspaces clean.
702 if is_default_workspace(config.workspace_path):
703 _migrate_cron_store(config)
704
705 # Create cron service with workspace-scoped store
706 cron_store_path = config.workspace_path / "cron" / "jobs.json"
707 cron = CronService(cron_store_path)
708
709 # Create agent with cron service
710 gateway_hooks = []
711 if debug:
712 from nanobot.agent.debug_hook import DebugHook
713 gateway_hooks.append(DebugHook(show_full_content=True, show_messages=True))
714
715 agent = AgentLoop(
716 bus=bus,
717 provider=provider,
718 workspace=config.workspace_path,
719 model=config.agents.defaults.model,
720 max_iterations=config.agents.defaults.max_tool_iterations,
721 context_window_tokens=config.agents.defaults.context_window_tokens,
722 web_config=config.tools.web,
723 context_block_limit=config.agents.defaults.context_block_limit,
724 max_tool_result_chars=config.agents.defaults.max_tool_result_chars,
725 provider_retry_mode=config.agents.defaults.provider_retry_mode,
726 exec_config=config.tools.exec,
727 cron_service=cron,
728 restrict_to_workspace=config.tools.restrict_to_workspace,
729 session_manager=session_manager,
730 mcp_servers=config.tools.mcp_servers,
731 channels_config=config.channels,
732 timezone=config.agents.defaults.timezone,
733 unified_session=config.agents.defaults.unified_session,
734 disabled_skills=config.agents.defaults.disabled_skills,
735 session_ttl_minutes=config.agents.defaults.session_ttl_minutes,
736 tools_config=config.tools,
737 config=config,
738 hooks=gateway_hooks if gateway_hooks else None,
739 prompt_stacker_enabled=config.prompt_stacker.enabled,
740 prompt_stacker_max_traces=config.prompt_stacker.max_traces,
741 )
742
743 # Set cron callback (needs agent)
744 async def on_cron_job(job: CronJob) -> str | None:
745 """Execute a cron job through the agent."""
746 # Dream is an internal job — run directly, not through the agent loop.
747 if job.name == "dream":
748 try:
749 await agent.dream.run()
750 logger.info("Dream cron job completed")
751 except Exception:
752 logger.exception("Dream cron job failed")
753 return None
754
755 from nanobot.agent.tools.cron import CronTool
756 from nanobot.agent.tools.message import MessageTool
757 from nanobot.utils.evaluator import evaluate_response
758
759 reminder_note = (
760 "[Scheduled Task] Timer finished.\n\n"
761 f"Task '{job.name}' has been triggered.\n"
762 f"Scheduled instruction: {job.payload.message}"
763 )
764
765 cron_tool = agent.tools.get("cron")
766 cron_token = None
767 if isinstance(cron_tool, CronTool):
768 cron_token = cron_tool.set_cron_context(True)
769
770 async def _silent(*_args, **_kwargs):
771 pass
772
773 try:
774 resp = await agent.process_direct(
775 reminder_note,
776 session_key=f"cron:{job.id}",
777 channel=job.payload.channel or "cli",
778 chat_id=job.payload.to or "direct",
779 on_progress=_silent,
780 )
781 finally:
782 if isinstance(cron_tool, CronTool) and cron_token is not None:
783 cron_tool.reset_cron_context(cron_token)
784
785 response = resp.content if resp else ""
786
787 message_tool = agent.tools.get("message")
788 if job.payload.deliver and isinstance(message_tool, MessageTool) and message_tool._sent_in_turn:
789 return response
790
791 if job.payload.deliver and job.payload.to and response:
792 should_notify = await evaluate_response(
793 response, reminder_note, provider, agent.model,
794 )
795 if should_notify:
796 from nanobot.bus.events import OutboundMessage
797 await bus.publish_outbound(OutboundMessage(
798 channel=job.payload.channel or "cli",
799 chat_id=job.payload.to,
800 content=response,
801 ))
802 return response
803
804 cron.on_job = on_cron_job
805
806 # Create channel manager (forwards SessionManager so the WebSocket channel
807 # can serve the embedded webui's REST surface).
808 channels = ChannelManager(
809 config,
810 bus,
811 session_manager=session_manager,
812 provider=provider,
813 gateway_debug=debug,
814 )
815
816 def _pick_heartbeat_target() -> tuple[str, str]:
817 """Pick a routable channel/chat target for heartbeat-triggered messages."""
818 enabled = set(channels.enabled_channels)
819 # Prefer the most recently updated non-internal session on an enabled channel.
820 for item in session_manager.list_sessions():
821 key = item.get("key") or ""
822 if ":" not in key:
823 continue
824 channel, chat_id = key.split(":", 1)
825 if channel in {"cli", "system"}:
826 continue
827 if channel in enabled and chat_id:
828 return channel, chat_id
829 # Fallback keeps prior behavior but remains explicit.
830 return "cli", "direct"
831
832 # Create heartbeat service
833 async def on_heartbeat_execute(tasks: str) -> str:
834 """Phase 2: execute heartbeat tasks through the full agent loop."""
835 channel, chat_id = _pick_heartbeat_target()
836
837 async def _silent(*_args, **_kwargs):
838 pass
839
840 resp = await agent.process_direct(
841 tasks,
842 session_key="heartbeat",
843 channel=channel,
844 chat_id=chat_id,
845 on_progress=_silent,
846 )
847
848 # Keep a small tail of heartbeat history so the loop stays bounded
849 # without losing all short-term context between runs.
850 session = agent.sessions.get_or_create("heartbeat")
851 session.retain_recent_legal_suffix(hb_cfg.keep_recent_messages)
852 agent.sessions.save(session)
853
854 return resp.content if resp else ""
855
856 async def on_heartbeat_notify(response: str) -> None:
857 """Deliver a heartbeat response to the user's channel."""
858 from nanobot.bus.events import OutboundMessage
859 channel, chat_id = _pick_heartbeat_target()
860 if channel == "cli":
861 return # No external channel available to deliver to
862 await bus.publish_outbound(OutboundMessage(channel=channel, chat_id=chat_id, content=response))
863
864 hb_cfg = config.gateway.heartbeat
865 heartbeat = HeartbeatService(
866 workspace=config.workspace_path,
867 provider=provider,
868 model=agent.model,
869 on_execute=on_heartbeat_execute,
870 on_notify=on_heartbeat_notify,
871 interval_s=hb_cfg.interval_s,
872 enabled=hb_cfg.enabled,
873 timezone=config.agents.defaults.timezone,
874 )
875
876 if channels.enabled_channels:
877 console.print(f"[green]✓[/green] Channels enabled: {', '.join(channels.enabled_channels)}")
878 else:
879 console.print("[yellow]Warning: No channels enabled[/yellow]")
880
881 cron_status = cron.status()
882 if cron_status["jobs"] > 0:
883 console.print(f"[green]✓[/green] Cron: {cron_status['jobs']} scheduled jobs")
884
885 console.print(f"[green]✓[/green] Heartbeat: every {hb_cfg.interval_s}s")
886
887 async def _health_server(host: str, health_port: int):
888 """Lightweight HTTP health endpoint on the gateway port."""
889 import json as _json
890
891 async def handle(reader, writer):
892 try:
893 data = await asyncio.wait_for(reader.read(4096), timeout=5)
894 except (asyncio.TimeoutError, ConnectionError):
895 writer.close()
896 return
897
898 request_line = data.split(b"\r\n", 1)[0].decode("utf-8", errors="replace")
899 method, path = "", ""
900 parts = request_line.split(" ")
901 if len(parts) >= 2:
902 method, path = parts[0], parts[1]
903
904 if method == "GET" and path == "/health":
905 body = _json.dumps({"status": "ok"})
906 resp = (
907 f"HTTP/1.0 200 OK\r\n"
908 f"Content-Type: application/json\r\n"
909 f"Content-Length: {len(body)}\r\n"
910 f"\r\n{body}"
911 )
912 else:
913 body = "Not Found"
914 resp = (
915 f"HTTP/1.0 404 Not Found\r\n"
916 f"Content-Type: text/plain\r\n"
917 f"Content-Length: {len(body)}\r\n"
918 f"\r\n{body}"
919 )
920
921 writer.write(resp.encode())
922 await writer.drain()
923 writer.close()
924
925 server = await asyncio.start_server(handle, host, health_port)
926 console.print(f"[green]✓[/green] Health endpoint: http://{host}:{health_port}/health")
927 async with server:
928 await server.serve_forever()
929 # Register Dream system job (always-on, idempotent on restart)
930 dream_cfg = config.agents.defaults.dream
931 if dream_cfg.model_override:
932 agent.dream.model = dream_cfg.model_override
933 agent.dream.max_batch_size = dream_cfg.max_batch_size
934 agent.dream.max_iterations = dream_cfg.max_iterations
935 agent.dream.annotate_line_ages = dream_cfg.annotate_line_ages
936 from nanobot.cron.types import CronJob, CronPayload
937 cron.register_system_job(CronJob(
938 id="dream",
939 name="dream",
940 schedule=dream_cfg.build_schedule(config.agents.defaults.timezone),
941 payload=CronPayload(kind="system_event"),
942 ))
943 console.print(f"[green]✓[/green] Dream: {dream_cfg.describe_schedule()}")
944
945 async def _open_browser_when_ready() -> None:
946 """Wait for the gateway to bind, then point the user's browser at the webui."""
947 if not open_browser_url:
948 return
949 import webbrowser
950 # Channels start asynchronously; a short poll lets us avoid racing the bind.
951 for _ in range(40): # ~4s max
952 try:
953 reader, writer = await asyncio.open_connection(
954 config.gateway.host or "127.0.0.1", port
955 )
956 writer.close()
957 try:
958 await writer.wait_closed()
959 except Exception:
960 pass
961 break
962 except OSError:
963 await asyncio.sleep(0.1)
964 try:
965 webbrowser.open(open_browser_url)
966 console.print(f"[green]✓[/green] Opened browser at {open_browser_url}")
967 except Exception as e:
968 console.print(f"[yellow]Could not open browser ({e}); visit {open_browser_url}[/yellow]")
969
970 async def run():
971 try:
972 await cron.start()
973 await heartbeat.start()
974 tasks = [
975 agent.run(),
976 channels.start_all(),
977 _health_server(config.gateway.host, port),
978 ]
979 if open_browser_url:
980 tasks.append(_open_browser_when_ready())
981 await asyncio.gather(*tasks)
982 except KeyboardInterrupt:
983 console.print("\nShutting down...")
984 except Exception:
985 import traceback
986
987 console.print("\n[red]Error: Gateway crashed unexpectedly[/red]")
988 console.print(traceback.format_exc())
989 finally:
990 await agent.close_mcp()
991 heartbeat.stop()
992 cron.stop()
993 agent.stop()
994 await channels.stop_all()
995 # Flush all cached sessions to durable storage before exit.
996 # This prevents data loss on filesystems with write-back
997 # caching (rclone VFS, NFS, FUSE mounts, etc.).
998 flushed = agent.sessions.flush_all()
999 if flushed:
1000 logger.info("Shutdown: flushed {} session(s) to disk", flushed)
1001
1002 asyncio.run(run())
1003
1004
1005 # ============================================================================
1006 # Agent Commands
1007 # ============================================================================
1008
1009
1010 @app.command()
1011 def agent(
1012 message: str = typer.Option(None, "--message", "-m", help="Message to send to the agent"),
1013 session_id: str = typer.Option("cli:direct", "--session", "-s", help="Session ID"),
1014 workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
1015 config: str | None = typer.Option(None, "--config", "-c", help="Config file path"),
1016 markdown: bool = typer.Option(True, "--markdown/--no-markdown", help="Render assistant output as Markdown"),
1017 logs: bool = typer.Option(False, "--logs/--no-logs", help="Show nanobot runtime logs during chat"),
1018 debug: bool = typer.Option(False, "--debug", help="Print detailed per-iteration debug output"),
1019 ):
1020 """Interact with the agent directly."""
1021 from loguru import logger
1022
1023 from nanobot.agent.loop import AgentLoop
1024 from nanobot.bus.queue import MessageBus
1025 from nanobot.cron.service import CronService
1026
1027 config = _load_runtime_config(config, workspace)
1028 sync_workspace_templates(config.workspace_path)
1029
1030 bus = MessageBus()
1031 provider = _make_provider(config)
1032 session_manager = _make_session_manager(config, provider)
1033
1034 # Preserve existing single-workspace installs, but keep custom workspaces clean.
1035 if is_default_workspace(config.workspace_path):
1036 _migrate_cron_store(config)
1037
1038 # Create cron service with workspace-scoped store
1039 cron_store_path = config.workspace_path / "cron" / "jobs.json"
1040 cron = CronService(cron_store_path)
1041
1042 if logs or debug:
1043 logger.enable("nanobot")
1044 else:
1045 logger.disable("nanobot")
1046
1047 extra_hooks = []
1048 if debug:
1049 from nanobot.agent.debug_hook import DebugHook
1050 extra_hooks.append(DebugHook(show_full_content=True, show_messages=True))
1051
1052 agent_loop = AgentLoop(
1053 bus=bus,
1054 provider=provider,
1055 workspace=config.workspace_path,
1056 model=config.agents.defaults.model,
1057 max_iterations=config.agents.defaults.max_tool_iterations,
1058 context_window_tokens=config.agents.defaults.context_window_tokens,
1059 web_config=config.tools.web,
1060 context_block_limit=config.agents.defaults.context_block_limit,
1061 max_tool_result_chars=config.agents.defaults.max_tool_result_chars,
1062 provider_retry_mode=config.agents.defaults.provider_retry_mode,
1063 exec_config=config.tools.exec,
1064 cron_service=cron,
1065 restrict_to_workspace=config.tools.restrict_to_workspace,
1066 session_manager=session_manager,
1067 mcp_servers=config.tools.mcp_servers,
1068 channels_config=config.channels,
1069 timezone=config.agents.defaults.timezone,
1070 unified_session=config.agents.defaults.unified_session,
1071 disabled_skills=config.agents.defaults.disabled_skills,
1072 session_ttl_minutes=config.agents.defaults.session_ttl_minutes,
1073 tools_config=config.tools,
1074 config=config,
1075 hooks=extra_hooks if extra_hooks else None,
1076 prompt_stacker_enabled=config.prompt_stacker.enabled,
1077 prompt_stacker_max_traces=config.prompt_stacker.max_traces,
1078 )
1079 restart_notice = consume_restart_notice_from_env()
1080 if restart_notice and should_show_cli_restart_notice(restart_notice, session_id):
1081 _print_agent_response(
1082 format_restart_completed_message(restart_notice.started_at_raw),
1083 render_markdown=False,
1084 )
1085
1086 # Shared reference for progress callbacks
1087 _thinking: ThinkingSpinner | None = None
1088
1089 async def _cli_progress(content: str, *, tool_hint: bool = False, **_kwargs: Any) -> None:
1090 ch = agent_loop.channels_config
1091 if ch and tool_hint and not ch.send_tool_hints:
1092 return
1093 if ch and not tool_hint and not ch.send_progress:
1094 return
1095 _print_cli_progress_line(content, _thinking)
1096
1097 if message:
1098 # Single message mode — direct call, no bus needed
1099 async def run_once():
1100 renderer = StreamRenderer(render_markdown=markdown)
1101 response = await agent_loop.process_direct(
1102 message, session_id,
1103 on_progress=_cli_progress,
1104 on_stream=renderer.on_delta,
1105 on_stream_end=renderer.on_end,
1106 )
1107 if not renderer.streamed:
1108 await renderer.close()
1109 _print_agent_response(
1110 response.content if response else "",
1111 render_markdown=markdown,
1112 metadata=response.metadata if response else None,
1113 )
1114 await agent_loop.close_mcp()
1115
1116 asyncio.run(run_once())
1117 else:
1118 # Interactive mode — route through bus like other channels
1119 from nanobot.bus.events import InboundMessage
1120 _init_prompt_session()
1121 console.print(f"{__logo__} Interactive mode [bold blue]({config.agents.defaults.model})[/bold blue] — type [bold]exit[/bold] or [bold]Ctrl+C[/bold] to quit\n")
1122
1123 if ":" in session_id:
1124 cli_channel, cli_chat_id = session_id.split(":", 1)
1125 else:
1126 cli_channel, cli_chat_id = "cli", session_id
1127
1128 def _handle_signal(signum, frame):
1129 sig_name = signal.Signals(signum).name
1130 _restore_terminal()
1131 console.print(f"\nReceived {sig_name}, goodbye!")
1132 sys.exit(0)
1133
1134 signal.signal(signal.SIGINT, _handle_signal)
1135 signal.signal(signal.SIGTERM, _handle_signal)
1136 # SIGHUP is not available on Windows
1137 if hasattr(signal, 'SIGHUP'):
1138 signal.signal(signal.SIGHUP, _handle_signal)
1139 # Ignore SIGPIPE to prevent silent process termination when writing to closed pipes
1140 # SIGPIPE is not available on Windows
1141 if hasattr(signal, 'SIGPIPE'):
1142 signal.signal(signal.SIGPIPE, signal.SIG_IGN)
1143
1144 async def run_interactive():
1145 bus_task = asyncio.create_task(agent_loop.run())
1146 turn_done = asyncio.Event()
1147 turn_done.set()
1148 turn_response: list[tuple[str, dict]] = []
1149 renderer: StreamRenderer | None = None
1150
1151 async def _consume_outbound():
1152 while True:
1153 try:
1154 msg = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0)
1155
1156 if msg.metadata.get("_stream_delta"):
1157 if renderer:
1158 await renderer.on_delta(msg.content)
1159 continue
1160 if msg.metadata.get("_stream_end"):
1161 if renderer:
1162 await renderer.on_end(
1163 resuming=msg.metadata.get("_resuming", False),
1164 )
1165 continue
1166 if msg.metadata.get("_streamed"):
1167 turn_done.set()
1168 continue
1169
1170 if msg.metadata.get("_progress"):
1171 is_tool_hint = msg.metadata.get("_tool_hint", False)
1172 ch = agent_loop.channels_config
1173 if ch and is_tool_hint and not ch.send_tool_hints:
1174 pass
1175 elif ch and not is_tool_hint and not ch.send_progress:
1176 pass
1177 else:
1178 await _print_interactive_progress_line(msg.content, _thinking)
1179 continue
1180
1181 if not turn_done.is_set():
1182 if msg.content:
1183 turn_response.append((msg.content, dict(msg.metadata or {})))
1184 turn_done.set()
1185 elif msg.content:
1186 await _print_interactive_response(
1187 msg.content,
1188 render_markdown=markdown,
1189 metadata=msg.metadata,
1190 )
1191
1192 except asyncio.TimeoutError:
1193 continue
1194 except asyncio.CancelledError:
1195 break
1196
1197 outbound_task = asyncio.create_task(_consume_outbound())
1198
1199 try:
1200 while True:
1201 try:
1202 _flush_pending_tty_input()
1203 # Stop spinner before user input to avoid prompt_toolkit conflicts
1204 if renderer:
1205 renderer.stop_for_input()
1206 user_input = await _read_interactive_input_async()
1207 command = user_input.strip()
1208 if not command:
1209 continue
1210
1211 if _is_exit_command(command):
1212 _restore_terminal()
1213 console.print("\nGoodbye!")
1214 break
1215
1216 turn_done.clear()
1217 turn_response.clear()
1218 renderer = StreamRenderer(render_markdown=markdown)
1219
1220 await bus.publish_inbound(InboundMessage(
1221 channel=cli_channel,
1222 sender_id="user",
1223 chat_id=cli_chat_id,
1224 content=user_input,
1225 metadata={"_wants_stream": True},
1226 ))
1227
1228 await turn_done.wait()
1229
1230 if turn_response:
1231 content, meta = turn_response[0]
1232 if content and not meta.get("_streamed"):
1233 if renderer:
1234 await renderer.close()
1235 _print_agent_response(
1236 content, render_markdown=markdown, metadata=meta,
1237 )
1238 elif renderer and not renderer.streamed:
1239 await renderer.close()
1240 except KeyboardInterrupt:
1241 _restore_terminal()
1242 console.print("\nGoodbye!")
1243 break
1244 except EOFError:
1245 _restore_terminal()
1246 console.print("\nGoodbye!")
1247 break
1248 finally:
1249 agent_loop.stop()
1250 outbound_task.cancel()
1251 await asyncio.gather(bus_task, outbound_task, return_exceptions=True)
1252 await agent_loop.close_mcp()
1253
1254 asyncio.run(run_interactive())
1255
1256
1257 # ============================================================================
1258 # Channel Commands
1259 # ============================================================================
1260
1261
1262 channels_app = typer.Typer(help="Manage channels")
1263 app.add_typer(channels_app, name="channels")
1264
1265
1266 @channels_app.command("status")
1267 def channels_status(
1268 config_path: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
1269 ):
1270 """Show channel status."""
1271 from nanobot.channels.registry import discover_all
1272 from nanobot.config.loader import load_config, set_config_path
1273
1274 resolved_config_path = Path(config_path).expanduser().resolve() if config_path else None
1275 if resolved_config_path is not None:
1276 set_config_path(resolved_config_path)
1277
1278 config = load_config(resolved_config_path)
1279
1280 table = Table(title="Channel Status")
1281 table.add_column("Channel", style="cyan")
1282 table.add_column("Enabled")
1283
1284 for name, cls in sorted(discover_all().items()):
1285 section = getattr(config.channels, name, None)
1286 if section is None:
1287 enabled = False
1288 elif isinstance(section, dict):
1289 enabled = section.get("enabled", False)
1290 else:
1291 enabled = getattr(section, "enabled", False)
1292 table.add_row(
1293 cls.display_name,
1294 "[green]\u2713[/green]" if enabled else "[dim]\u2717[/dim]",
1295 )
1296
1297 console.print(table)
1298
1299
1300 def _get_bridge_dir() -> Path:
1301 """Get the bridge directory, setting it up if needed."""
1302 import shutil
1303 import subprocess
1304
1305 # User's bridge location
1306 from nanobot.config.paths import get_bridge_install_dir
1307
1308 user_bridge = get_bridge_install_dir()
1309
1310 # Check if already built
1311 if (user_bridge / "dist" / "index.js").exists():
1312 return user_bridge
1313
1314 # Check for npm
1315 npm_path = shutil.which("npm")
1316 if not npm_path:
1317 console.print("[red]npm not found. Please install Node.js >= 18.[/red]")
1318 raise typer.Exit(1)
1319
1320 # Find source bridge: first check package data, then source dir
1321 pkg_bridge = Path(__file__).parent.parent / "bridge" # nanobot/bridge (installed)
1322 src_bridge = Path(__file__).parent.parent.parent / "bridge" # repo root/bridge (dev)
1323
1324 source = None
1325 if (pkg_bridge / "package.json").exists():
1326 source = pkg_bridge
1327 elif (src_bridge / "package.json").exists():
1328 source = src_bridge
1329
1330 if not source:
1331 console.print("[red]Bridge source not found.[/red]")
1332 console.print("Try reinstalling: pip install --force-reinstall nanobot")
1333 raise typer.Exit(1)
1334
1335 console.print(f"{__logo__} Setting up bridge...")
1336
1337 # Copy to user directory
1338 user_bridge.parent.mkdir(parents=True, exist_ok=True)
1339 if user_bridge.exists():
1340 shutil.rmtree(user_bridge)
1341 shutil.copytree(source, user_bridge, ignore=shutil.ignore_patterns("node_modules", "dist"))
1342
1343 # Install and build
1344 try:
1345 console.print(" Installing dependencies...")
1346 subprocess.run([npm_path, "install"], cwd=user_bridge, check=True, capture_output=True)
1347
1348 console.print(" Building...")
1349 subprocess.run([npm_path, "run", "build"], cwd=user_bridge, check=True, capture_output=True)
1350
1351 console.print("[green]✓[/green] Bridge ready\n")
1352 except subprocess.CalledProcessError as e:
1353 console.print(f"[red]Build failed: {e}[/red]")
1354 if e.stderr:
1355 console.print(f"[dim]{e.stderr.decode()[:500]}[/dim]")
1356 raise typer.Exit(1)
1357
1358 return user_bridge
1359
1360
1361 @channels_app.command("login")
1362 def channels_login(
1363 channel_name: str = typer.Argument(..., help="Channel name (e.g. weixin, whatsapp)"),
1364 force: bool = typer.Option(False, "--force", "-f", help="Force re-authentication even if already logged in"),
1365 config_path: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
1366 ):
1367 """Authenticate with a channel via QR code or other interactive login."""
1368 from nanobot.channels.registry import discover_all
1369 from nanobot.config.loader import load_config, set_config_path
1370
1371 resolved_config_path = Path(config_path).expanduser().resolve() if config_path else None
1372 if resolved_config_path is not None:
1373 set_config_path(resolved_config_path)
1374
1375 config = load_config(resolved_config_path)
1376 channel_cfg = getattr(config.channels, channel_name, None) or {}
1377
1378 # Validate channel exists
1379 all_channels = discover_all()
1380 if channel_name not in all_channels:
1381 available = ", ".join(all_channels.keys())
1382 console.print(f"[red]Unknown channel: {channel_name}[/red] Available: {available}")
1383 raise typer.Exit(1)
1384
1385 console.print(f"{__logo__} {all_channels[channel_name].display_name} Login\n")
1386
1387 channel_cls = all_channels[channel_name]
1388 channel = channel_cls(channel_cfg, bus=None)
1389
1390 success = asyncio.run(channel.login(force=force))
1391
1392 if not success:
1393 raise typer.Exit(1)
1394
1395
1396 # ============================================================================
1397 # Plugin Commands
1398 # ============================================================================
1399
1400 plugins_app = typer.Typer(help="Manage channel plugins")
1401 app.add_typer(plugins_app, name="plugins")
1402
1403
1404 @plugins_app.command("list")
1405 def plugins_list():
1406 """List all discovered channels (built-in and plugins)."""
1407 from nanobot.channels.registry import discover_all, discover_channel_names
1408 from nanobot.config.loader import load_config
1409
1410 config = load_config()
1411 builtin_names = set(discover_channel_names())
1412 all_channels = discover_all()
1413
1414 table = Table(title="Channel Plugins")
1415 table.add_column("Name", style="cyan")
1416 table.add_column("Source", style="magenta")
1417 table.add_column("Enabled")
1418
1419 for name in sorted(all_channels):
1420 cls = all_channels[name]
1421 source = "builtin" if name in builtin_names else "plugin"
1422 section = getattr(config.channels, name, None)
1423 if section is None:
1424 enabled = False
1425 elif isinstance(section, dict):
1426 enabled = section.get("enabled", False)
1427 else:
1428 enabled = getattr(section, "enabled", False)
1429 table.add_row(
1430 cls.display_name,
1431 source,
1432 "[green]yes[/green]" if enabled else "[dim]no[/dim]",
1433 )
1434
1435 console.print(table)
1436
1437
1438 # ============================================================================
1439 # Status Commands
1440 # ============================================================================
1441
1442
1443 @app.command()
1444 def status():
1445 """Show nanobot status."""
1446 from nanobot.config.loader import get_config_path, load_config
1447
1448 config_path = get_config_path()
1449 config = load_config()
1450 workspace = config.workspace_path
1451
1452 console.print(f"{__logo__} nanobot Status\n")
1453
1454 console.print(f"Config: {config_path} {'[green]✓[/green]' if config_path.exists() else '[red]✗[/red]'}")
1455 console.print(f"Workspace: {workspace} {'[green]✓[/green]' if workspace.exists() else '[red]✗[/red]'}")
1456
1457 if config_path.exists():
1458 from nanobot.providers.registry import PROVIDERS
1459
1460 console.print(f"Model: {config.agents.defaults.model}")
1461
1462 # Check API keys from registry
1463 for spec in PROVIDERS:
1464 p = getattr(config.providers, spec.name, None)
1465 if p is None:
1466 continue
1467 if spec.is_oauth:
1468 console.print(f"{spec.label}: [green]✓ (OAuth)[/green]")
1469 elif spec.is_local:
1470 # Local deployments show api_base instead of api_key
1471 if p.api_base:
1472 console.print(f"{spec.label}: [green]✓ {p.api_base}[/green]")
1473 else:
1474 console.print(f"{spec.label}: [dim]not set[/dim]")
1475 else:
1476 has_key = bool(p.api_key)
1477 console.print(f"{spec.label}: {'[green]✓[/green]' if has_key else '[dim]not set[/dim]'}")
1478
1479
1480 # ============================================================================
1481 # OAuth Login
1482 # ============================================================================
1483
1484 provider_app = typer.Typer(help="Manage providers")
1485 app.add_typer(provider_app, name="provider")
1486
1487
1488 _LOGIN_HANDLERS: dict[str, callable] = {}
1489
1490
1491 def _register_login(name: str):
1492 def decorator(fn):
1493 _LOGIN_HANDLERS[name] = fn
1494 return fn
1495
1496 return decorator
1497
1498
1499 @provider_app.command("login")
1500 def provider_login(
1501 provider: str = typer.Argument(..., help="OAuth provider (e.g. 'openai-codex', 'github-copilot')"),
1502 ):
1503 """Authenticate with an OAuth provider."""
1504 from nanobot.providers.registry import PROVIDERS
1505
1506 key = provider.replace("-", "_")
1507 spec = next((s for s in PROVIDERS if s.name == key and s.is_oauth), None)
1508 if not spec:
1509 names = ", ".join(s.name.replace("_", "-") for s in PROVIDERS if s.is_oauth)
1510 console.print(f"[red]Unknown OAuth provider: {provider}[/red] Supported: {names}")
1511 raise typer.Exit(1)
1512
1513 handler = _LOGIN_HANDLERS.get(spec.name)
1514 if not handler:
1515 console.print(f"[red]Login not implemented for {spec.label}[/red]")
1516 raise typer.Exit(1)
1517
1518 console.print(f"{__logo__} OAuth Login - {spec.label}\n")
1519 handler()
1520
1521
1522 @_register_login("openai_codex")
1523 def _login_openai_codex() -> None:
1524 try:
1525 from oauth_cli_kit import get_token, login_oauth_interactive
1526
1527 token = None
1528 try:
1529 token = get_token()
1530 except Exception:
1531 pass
1532 if not (token and token.access):
1533 console.print("[cyan]Starting interactive OAuth login...[/cyan]\n")
1534 token = login_oauth_interactive(
1535 print_fn=lambda s: console.print(s),
1536 prompt_fn=lambda s: typer.prompt(s),
1537 )
1538 if not (token and token.access):
1539 console.print("[red]✗ Authentication failed[/red]")
1540 raise typer.Exit(1)
1541 console.print(f"[green]✓ Authenticated with OpenAI Codex[/green] [dim]{token.account_id}[/dim]")
1542 except ImportError:
1543 console.print("[red]oauth_cli_kit not installed. Run: pip install oauth-cli-kit[/red]")
1544 raise typer.Exit(1)
1545
1546
1547 @_register_login("github_copilot")
1548 def _login_github_copilot() -> None:
1549 try:
1550 from nanobot.providers.github_copilot_provider import login_github_copilot
1551
1552 console.print("[cyan]Starting GitHub Copilot device flow...[/cyan]\n")
1553 token = login_github_copilot(
1554 print_fn=lambda s: console.print(s),
1555 prompt_fn=lambda s: typer.prompt(s),
1556 )
1557 account = token.account_id or "GitHub"
1558 console.print(f"[green]✓ Authenticated with GitHub Copilot[/green] [dim]{account}[/dim]")
1559 except Exception as e:
1560 console.print(f"[red]Authentication error: {e}[/red]")
1561 raise typer.Exit(1)
1562
1563
1564 if __name__ == "__main__":
1565 app()
1566
1566 lines PYTHON