Files
beaver_project/app-instance/backend/nanobot/cli/commands.py
steven_li cdfc222c9f feat: 添加swarms团队编排功能并优化agent委派系统
- 引入AgentTeamOrchestrator支持多agent协同任务执行
- 增加第三方swarms库依赖并配置git协议替换以改善包管理
- 扩展DelegationManager支持团队任务调度和进度跟踪
- 实现中文bigram分词算法提升中文任务检索准确性
- 调整A2AClient和DelegationManager超时时间从30秒增至600秒
- 优化AgentRunResult状态判断逻辑增加有意义摘要检测
- 修改Dockerfile配置npm仓库镜像地址和git协议映射
- 更新CLI命令行接口支持网关端口配置传递
- 调整提供者超时配置机制增强请求稳定性
- 移除过时的support_group字段简化agent描述符结构
- 增强错误处理和进度事件报告机制改进用户体验
2026-04-14 14:34:23 +08:00

1418 lines
55 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Boardware Genius 命令行入口。
本文件职责:
1. 定义所有 CLI 命令onboard / agent / gateway / cron / channels / provider
2. 组装运行时依赖Config、AgentLoop、MessageBus、ChannelManager 等)
3. 提供交互式终端体验prompt_toolkit + rich
阅读建议:
- 先看 `onboard()`,理解配置与工作区如何初始化
- 再看 `agent()`,理解 CLI 单轮与交互模式
- 最后看 `gateway()`,理解多渠道常驻运行模式
"""
import asyncio
import os
import signal
from pathlib import Path
import select
import sys
import typer
from rich.console import Console
from rich.markdown import Markdown
from rich.table import Table
from rich.text import Text
from prompt_toolkit import PromptSession
from prompt_toolkit.formatted_text import HTML
from prompt_toolkit.history import FileHistory
from prompt_toolkit.patch_stdout import patch_stdout
from nanobot import __brand__, __version__
from nanobot.config.schema import Config
app = typer.Typer(
name="nanobot",
help=f"{__brand__} - Personal AI Assistant",
no_args_is_help=True,
)
console = Console()
# 交互模式下可用于退出会话的命令集合(统一做 lower() 比较)。
EXIT_COMMANDS = {"exit", "quit", "/exit", "/quit", ":q"}
# ---------------------------------------------------------------------------
# CLI input: prompt_toolkit for editing, paste, history, and display
# ---------------------------------------------------------------------------
_PROMPT_SESSION: PromptSession | None = None
_SAVED_TERM_ATTRS = None # original termios settings, restored on exit
def _flush_pending_tty_input() -> None:
"""Drop unread keypresses typed while the model was generating output."""
# 目的:避免“模型输出期间用户按键残留”,导致下一次输入提示符出现脏字符。
# 对于 TTY 终端,尽量清空 stdin 缓冲;非 TTY 场景直接返回。
try:
fd = sys.stdin.fileno()
if not os.isatty(fd):
return
except Exception:
return
try:
import termios
# 优先使用系统原生 tcflush最可靠。
termios.tcflush(fd, termios.TCIFLUSH)
return
except Exception:
pass
try:
# 兼容兜底:通过非阻塞 read 手动把可读缓冲清掉。
while True:
ready, _, _ = select.select([fd], [], [], 0)
if not ready:
break
if not os.read(fd, 4096):
break
except Exception:
return
def _restore_terminal() -> None:
"""Restore terminal to its original state (echo, line buffering, etc.)."""
# 某些情况下Ctrl+C、中断、异常退出终端可能残留“无回显”等状态
# 这里恢复到启动 prompt_toolkit 之前的属性,避免终端被“弄坏”。
if _SAVED_TERM_ATTRS is None:
return
try:
import termios
termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, _SAVED_TERM_ATTRS)
except Exception:
pass
def _init_prompt_session() -> None:
"""Create the prompt_toolkit session with persistent file history."""
global _PROMPT_SESSION, _SAVED_TERM_ATTRS
# 保存当前终端状态,退出交互模式时恢复。
try:
import termios
_SAVED_TERM_ATTRS = termios.tcgetattr(sys.stdin.fileno())
except Exception:
pass
history_file = Path.home() / ".nanobot" / "history" / "cli_history"
history_file.parent.mkdir(parents=True, exist_ok=True)
_PROMPT_SESSION = PromptSession(
# FileHistory 会把输入历史持久化到本地文件,支持上下键回看历史命令。
history=FileHistory(str(history_file)),
enable_open_in_editor=False,
multiline=False, # Enter submits (single line mode)
)
def _print_agent_response(response: str, render_markdown: bool) -> None:
"""Render assistant response with consistent terminal styling."""
# 同一出口统一渲染回复:便于 CLI 单轮和交互模式共用显示逻辑。
content = response or ""
body = Markdown(content) if render_markdown else Text(content)
console.print()
console.print(f"[cyan]{__brand__}[/cyan]")
console.print(body)
console.print()
def _is_exit_command(command: str) -> bool:
"""Return True when input should end interactive chat."""
# 注意:调用方会先 .strip(),这里仅负责集合匹配。
return command.lower() in EXIT_COMMANDS
async def _read_interactive_input_async() -> str:
"""Read user input using prompt_toolkit (handles paste, history, display).
prompt_toolkit natively handles:
- Multiline paste (bracketed paste mode)
- History navigation (up/down arrows)
- Clean display (no ghost characters or artifacts)
"""
if _PROMPT_SESSION is None:
raise RuntimeError("Call _init_prompt_session() first")
try:
# patch_stdout 可避免“后台日志输出”打乱 prompt_toolkit 输入界面。
with patch_stdout():
return await _PROMPT_SESSION.prompt_async(
HTML("<b fg='ansiblue'>You:</b> "),
)
except EOFError as exc:
# Ctrl+D 等 EOF 统一转为 KeyboardInterrupt简化上层退出处理。
raise KeyboardInterrupt from exc
def version_callback(value: bool):
"""处理 --version/-v 选项并立即退出。"""
if value:
console.print(f"{__brand__} v{__version__}")
raise typer.Exit()
@app.callback()
def main(
version: bool = typer.Option(
None, "--version", "-v", callback=version_callback, is_eager=True
),
):
"""Boardware Genius - Personal AI Assistant."""
pass
# ============================================================================
# Onboard / Setup
# ============================================================================
@app.command()
def onboard():
"""Initialize Boardware Genius configuration and workspace."""
from nanobot.config.loader import get_config_path, load_config, save_config
from nanobot.config.schema import Config
from nanobot.utils.helpers import get_workspace_path
# 第 1 步:确定配置文件路径(默认是 ~/.nanobot/config.json
# 这个路径由 config.loader.get_config_path() 统一管理,避免硬编码路径。
config_path = get_config_path()
# 第 2 步:处理配置文件
# - 如果已有配置:给用户两个选择(覆盖重置 / 刷新保留旧值)
# - 如果没有配置:直接创建默认配置
if config_path.exists():
console.print(f"[yellow]Config already exists at {config_path}[/yellow]")
console.print(" [bold]y[/bold] = overwrite with defaults (existing values will be lost)")
console.print(" [bold]N[/bold] = refresh config, keeping existing values and adding new fields")
if typer.confirm("Overwrite?"):
# 覆盖模式:直接写入一个全新的默认 Config
config = Config()
save_config(config)
console.print(f"[green]✓[/green] Config reset to defaults at {config_path}")
else:
# 刷新模式:先读取旧配置,再按新 schema 重新保存
# 这样可以保留用户已有值,同时补全新版本新增字段。
config = load_config()
save_config(config)
console.print(f"[green]✓[/green] Config refreshed at {config_path} (existing values preserved)")
else:
# 首次安装:写入默认配置
save_config(Config())
console.print(f"[green]✓[/green] Created config at {config_path}")
# 第 3 步:准备工作区(默认 ~/.nanobot/workspace
# get_workspace_path() 内部会 expanduser 并保证目录存在。
workspace = get_workspace_path()
if not workspace.exists():
# 这里是额外保险:即使 helper 已创建过,重复 mkdir 也安全exist_ok=True
workspace.mkdir(parents=True, exist_ok=True)
console.print(f"[green]✓[/green] Created workspace at {workspace}")
# 第 4 步:把内置模板文件写入工作区(只在文件不存在时创建)
# 这些文件会参与系统提示词构建,例如 AGENTS.md / USER.md / TOOLS.md。
_create_workspace_templates(workspace)
# 第 5 步:输出下一步操作提示,指导用户继续配置 API Key 并开始对话。
console.print(f"\n{__brand__} is ready!")
console.print("\nNext steps:")
console.print(" 1. Add your API key to [cyan]~/.nanobot/config.json[/cyan]")
console.print(" Get one at: https://openrouter.ai/keys")
console.print(" 2. Chat with Boardware Genius: [cyan]nanobot agent -m \"Hello!\"[/cyan]")
console.print("\n[dim]Want Telegram/WhatsApp? See: https://github.com/HKUDS/nanobot#-chat-apps[/dim]")
def _create_workspace_templates(workspace: Path):
"""Create default workspace template files from bundled templates."""
from importlib.resources import files as pkg_files
# 从安装包里定位模板目录 nanobot/templates
# 注意:这里是“包内资源”,不是当前工作目录的相对路径。
templates_dir = pkg_files("nanobot") / "templates"
# 把 templates 根目录下的 .md 文件复制到 workspace 根目录。
# 采用“仅缺失时创建”策略,避免覆盖用户已编辑的文件。
for item in templates_dir.iterdir():
if not item.name.endswith(".md"):
continue
dest = workspace / item.name
if not dest.exists():
dest.write_text(item.read_text(encoding="utf-8"), encoding="utf-8")
console.print(f" [dim]Created {item.name}[/dim]")
# memory 目录用于长期记忆和历史归档。
memory_dir = workspace / "memory"
memory_dir.mkdir(exist_ok=True)
# 创建 memory/MEMORY.md长期记忆可被 agent 读取并更新)
memory_template = templates_dir / "memory" / "MEMORY.md"
memory_file = memory_dir / "MEMORY.md"
if not memory_file.exists():
memory_file.write_text(memory_template.read_text(encoding="utf-8"), encoding="utf-8")
console.print(" [dim]Created memory/MEMORY.md[/dim]")
# 创建 memory/HISTORY.md追加式历史日志便于 grep 检索。
history_file = memory_dir / "HISTORY.md"
if not history_file.exists():
history_file.write_text("", encoding="utf-8")
console.print(" [dim]Created memory/HISTORY.md[/dim]")
# 创建 skills 目录存放用户自定义技能workspace 级别,优先级高于内置技能)。
(workspace / "skills").mkdir(exist_ok=True)
def _make_provider(config: Config):
"""Create the appropriate LLM provider from config."""
from nanobot.providers.litellm_provider import LiteLLMProvider
from nanobot.providers.openai_codex_provider import OpenAICodexProvider
from nanobot.providers.custom_provider import CustomProvider
# 根据模型名推断 providerschema.Config 内部已经实现匹配规则。
model = config.agents.defaults.model
provider_name = config.get_provider_name(model)
p = config.get_provider(model)
# OpenAI Codex (OAuth)
if provider_name == "openai_codex" or model.startswith("openai-codex/"):
return OpenAICodexProvider(
default_model=model,
request_timeout_seconds=p.request_timeout_seconds if p else 600,
)
# Custom: direct OpenAI-compatible endpoint, bypasses LiteLLM
if provider_name == "custom":
return CustomProvider(
api_key=p.api_key if p else "no-key",
api_base=config.get_api_base(model) or "http://localhost:8000/v1",
default_model=model,
request_timeout_seconds=p.request_timeout_seconds if p else 600,
)
# LiteLLM 通道:绝大多数 provider 走这里。
from nanobot.providers.registry import find_by_name
spec = find_by_name(provider_name)
if not model.startswith("bedrock/") and not (p and p.api_key) and not (spec and spec.is_oauth):
console.print("[red]Error: No API key configured.[/red]")
console.print("Set one in ~/.nanobot/config.json under providers section")
raise typer.Exit(1)
return LiteLLMProvider(
api_key=p.api_key if p else None,
api_base=config.get_api_base(model),
default_model=model,
extra_headers=p.extra_headers if p else None,
provider_name=provider_name,
request_timeout_seconds=p.request_timeout_seconds if p else 600,
)
# ============================================================================
# Gateway / Server
# ============================================================================
@app.command()
def gateway(
port: int = typer.Option(18790, "--port", "-p", help="Gateway port"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
):
"""启动 Boardware Genius 网关常驻服务。
这是“生产运行入口”之一,主要职责:
1. 初始化配置、总线、模型提供方、会话管理、Agent 主循环;
2. 启动渠道监听Telegram/Slack/Discord/...
3. 启动 cron 定时任务与 heartbeat 心跳任务;
4. 在进程退出时按顺序清理所有长连接与后台任务。
与 `agent` 命令的区别:
- `gateway` 是常驻服务负责“自动触发类任务”cron/heartbeat
- `agent` 更偏交互调试/本地会话,不默认承担常驻调度职责。
"""
from nanobot.config.loader import load_config
from nanobot.bus.queue import MessageBus
from nanobot.agent.loop import AgentLoop
from nanobot.channels.manager import ChannelManager
from nanobot.cron.runtime import run_cron_job
from nanobot.session.manager import SessionManager
from nanobot.cron.service import CronService
from nanobot.cron.types import CronJob
from nanobot.heartbeat.service import HeartbeatService
from nanobot.utils.helpers import get_cron_store_path
# verbose 模式仅放大 Python logging 级别,便于排查启动和连接问题。
if verbose:
import logging
logging.basicConfig(level=logging.DEBUG)
console.print(f"{__brand__}: starting gateway on port {port}...")
# 运行时核心对象初始化顺序:
# config -> bus -> provider -> sessions -> cron -> agent -> channels -> heartbeat
config = load_config()
bus = MessageBus()
provider = _make_provider(config)
session_manager = SessionManager(config.workspace_path)
# 先创建 CronService后续拿到 agent 再注入执行回调)。
# 这样可保证 cron 与 agent 使用同一运行时实例,避免上下文不一致。
cron_store_path = get_cron_store_path(config.workspace_path)
cron = CronService(cron_store_path)
# 创建 AgentLoop 并注入 cron_service。
# 注意:这里只是“把 cron 工具能力挂到 agent”真正定时执行要靠 cron.start()。
agent = AgentLoop(
bus=bus,
provider=provider,
workspace=config.workspace_path,
model=config.agents.defaults.model,
temperature=config.agents.defaults.temperature,
max_tokens=config.agents.defaults.max_tokens,
max_iterations=config.agents.defaults.max_tool_iterations,
memory_window=config.agents.defaults.memory_window,
brave_api_key=config.tools.web.search.api_key or None,
exec_config=config.tools.exec,
a2a_config=config.tools.a2a,
cron_service=cron,
restrict_to_workspace=config.tools.restrict_to_workspace,
session_manager=session_manager,
mcp_servers=config.tools.mcp_servers,
channels_config=config.channels,
authz_config=config.authz,
backend_identity=config.backend_identity,
gateway_port=config.gateway.port,
)
# 把 cron 执行回调绑定到 agent定时触发时会走一次完整 agent 处理流程。
# 回调契约:输入 CronJob返回本次执行得到的文本结果可为空
async def on_cron_job(job: CronJob):
"""通过 AgentLoop 执行单个 cron 任务并按配置投递结果。
关键点:
- task 型任务优先复用创建时的 session_key保留原会话上下文
- 若任务未记录来源 session才回退到 `cron:{job.id}` 隔离会话;
- channel/chat_id 从 job payload 读取,不存在时回退到 `cli:direct`
- 仅当 `deliver=True` 且 `to` 非空时,才把结果真正发到渠道。
"""
return await run_cron_job(
job,
agent=agent,
bus=bus,
default_channel="cli",
default_chat_id="direct",
)
cron.on_job = on_cron_job
# 渠道管理器负责建立外部 IM 连接并把消息接入 MessageBus。
channels = ChannelManager(config, bus)
def _pick_heartbeat_target() -> tuple[str, str]:
"""为 heartbeat 选择一个“可路由”的目标会话。
选择策略(按优先级):
1. 最近活跃且属于启用渠道的外部会话;
2. 若没有可用外部会话,回退到 `cli:direct`。
"""
enabled = set(channels.enabled_channels)
# Prefer the most recently updated non-internal session on an enabled channel.
for item in session_manager.list_sessions():
key = item.get("key") or ""
if ":" not in key:
continue
channel, chat_id = key.split(":", 1)
if channel in {"cli", "system"}:
continue
if channel in enabled and chat_id:
return channel, chat_id
# 若没有可路由外部会话,退回 CLI 虚拟会话。
return "cli", "direct"
# 心跳服务:周期性触发 agent 读取 HEARTBEAT.md
# 设计目标是“后台自检/主动推进”,不是抢占用户会话。
async def on_heartbeat(prompt: str) -> str:
"""执行一次 heartbeat prompt得到 agent 输出。"""
channel, chat_id = _pick_heartbeat_target()
async def _silent(*_args, **_kwargs):
pass
return await agent.process_direct(
prompt,
session_key="heartbeat",
channel=channel,
chat_id=chat_id,
on_progress=_silent, # suppress: heartbeat should not push progress to external channels
)
async def on_heartbeat_notify(response: str) -> None:
"""把 heartbeat 结果投递到外部渠道(若存在可用目标)。"""
from nanobot.bus.events import OutboundMessage
channel, chat_id = _pick_heartbeat_target()
if channel == "cli":
return # No external channel available to deliver to
await bus.publish_outbound(OutboundMessage(channel=channel, chat_id=chat_id, content=response))
heartbeat = HeartbeatService(
workspace=config.workspace_path,
on_heartbeat=on_heartbeat,
on_notify=on_heartbeat_notify,
interval_s=30 * 60, # 30 minutes
enabled=True
)
if channels.enabled_channels:
console.print(f"[green]✓[/green] Channels enabled: {', '.join(channels.enabled_channels)}")
else:
console.print("[yellow]Warning: No channels enabled[/yellow]")
cron_status = cron.status()
if cron_status["jobs"] > 0:
console.print(f"[green]✓[/green] Cron: {cron_status['jobs']} scheduled jobs")
console.print(f"[green]✓[/green] Heartbeat: every 30m")
async def run():
"""网关主协程:并发拉起 cron/heartbeat/agent/channels 并统一收尾。"""
# gateway 常驻主循环:并发运行 agent 消费循环 + 各渠道监听循环。
try:
await cron.start()
await heartbeat.start()
await asyncio.gather(
agent.run(),
channels.start_all(),
)
except KeyboardInterrupt:
console.print("\nShutting down...")
finally:
# 统一清理顺序尽量避免资源泄漏MCP 连接、定时器、渠道连接等)。
await agent.close_mcp()
heartbeat.stop()
cron.stop()
agent.stop()
await channels.stop_all()
asyncio.run(run())
# ============================================================================
# Web Commands
# ============================================================================
@app.command()
def web(
port: int = typer.Option(18080, "--port", "-p", help="Web API server port"),
host: str = typer.Option("0.0.0.0", "--host", help="Web API host"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
):
"""启动单用户 Web 后端(用于前后端分离场景)。"""
import uvicorn
from nanobot.config.loader import load_config
from nanobot.web.server import create_app
if verbose:
import logging
logging.basicConfig(level=logging.DEBUG)
config = load_config()
config.gateway.port = port
_create_workspace_templates(config.workspace_path)
console.print(f"{__brand__}: starting web backend on {host}:{port}...")
web_app = create_app(config=config)
uvicorn.run(web_app, host=host, port=port)
# ============================================================================
# Agent Commands
# ============================================================================
@app.command()
def agent(
message: str = typer.Option(None, "--message", "-m", help="Message to send to the agent"),
session_id: str = typer.Option("cli:direct", "--session", "-s", help="Session ID"),
markdown: bool = typer.Option(True, "--markdown/--no-markdown", help="Render assistant output as Markdown"),
logs: bool = typer.Option(False, "--logs/--no-logs", help="Show Boardware Genius runtime logs during chat"),
):
"""直接与 agent 交互(单轮模式或交互模式)。
两种工作形态:
- `-m/--message`:单轮执行,输入一次得到一次回复后退出;
- 无 message进入交互循环持续走 bus 的 inbound/outbound 链路。
说明:
- 这里也会注入 CronService但默认不启动 cron 定时器;
- 目的是保留“任务管理能力”add/list/remove而非常驻调度。
"""
from nanobot.config.loader import load_config
from nanobot.bus.queue import MessageBus
from nanobot.agent.loop import AgentLoop
from nanobot.cron.service import CronService
from loguru import logger
from nanobot.utils.helpers import get_cron_store_path
# CLI 模式也复用与 gateway 基本一致的运行时组件。
config = load_config()
bus = MessageBus()
provider = _make_provider(config)
# CLI 模式下也要注入 CronService主要是为了支持 cron 工具链的“任务管理能力”:
# 1) agent 在当前会话里调用 cron 相关工具时需要统一的持久化入口jobs.json
# 2) 这里默认不启动常驻调度循环,因此暂时不需要绑定 on_job 执行回调。
# 3) 真正按时间触发任务并回调执行,通常由 gateway 常驻模式在 start() 后接管。
cron_store_path = get_cron_store_path(config.workspace_path)
cron = CronService(cron_store_path)
if logs:
logger.enable("nanobot")
else:
logger.disable("nanobot")
agent_loop = AgentLoop(
bus=bus,
provider=provider,
workspace=config.workspace_path,
model=config.agents.defaults.model,
temperature=config.agents.defaults.temperature,
max_tokens=config.agents.defaults.max_tokens,
max_iterations=config.agents.defaults.max_tool_iterations,
memory_window=config.agents.defaults.memory_window,
brave_api_key=config.tools.web.search.api_key or None,
exec_config=config.tools.exec,
a2a_config=config.tools.a2a,
cron_service=cron,
restrict_to_workspace=config.tools.restrict_to_workspace,
mcp_servers=config.tools.mcp_servers,
channels_config=config.channels,
authz_config=config.authz,
backend_identity=config.backend_identity,
gateway_port=config.gateway.port,
)
# `_thinking_ctx` 统一封装“思考中”UI 的上下文管理器。
# 设计原因:
# 1) logs=True 时,终端会持续打印运行日志;如果同时显示 spinner
# 两者会争用同一行渲染区域,出现闪烁/覆盖,影响可读性。
# 2) 因此日志模式返回 `nullcontext()`(空上下文):保持 `with` 调用形态一致,
# 但不额外渲染任何加载动画。
# 3) logs=False 时,终端较干净,使用 rich 的 `console.status(...)` 显示 spinner
# 给用户明确反馈“模型仍在处理”,避免误判为卡死。
# 4) 这里使用的 status/spinner 与当前 prompt_toolkit 输入流程兼容,
# 不会破坏后续输入提示符状态。
def _thinking_ctx():
if logs:
from contextlib import nullcontext
# 空上下文:进入/退出都不做事,仅用于统一 with 接口。
return nullcontext()
# 非日志模式下启用转圈动画,提升等待期间的交互感知。
return console.status(f"[dim]{__brand__} is thinking...[/dim]", spinner="dots")
async def _cli_progress(content: str, *, tool_hint: bool = False) -> None:
"""CLI 进度回调:按 channels 配置过滤后渲染中间态输出。"""
ch = agent_loop.channels_config
if ch and tool_hint and not ch.send_tool_hints:
return
if ch and not tool_hint and not ch.send_progress:
return
console.print(f" [dim]↳ {content}[/dim]")
if message:
# 单轮模式:直接调用 agent.process_direct不启动总线循环。
async def run_once():
with _thinking_ctx():
response = await agent_loop.process_direct(message, session_id, on_progress=_cli_progress)
_print_agent_response(response, render_markdown=markdown)
await agent_loop.close_mcp()
asyncio.run(run_once())
else:
# 交互模式:
# - 不直接调用 process_direct而是走 MessageBus 完整链路;
# - 路径与 Telegram/WhatsApp 等外部渠道一致inbound -> agent -> outbound
# 便于在本地 CLI 复现真实运行行为与事件时序。
from nanobot.bus.events import InboundMessage
# 初始化 prompt_toolkit 会话(历史记录、编辑能力、粘贴兼容等)。
_init_prompt_session()
# 打印一次交互模式提示,告知退出方式。
console.print(f"{__brand__} interactive mode (type [bold]exit[/bold] or [bold]Ctrl+C[/bold] to quit)\n")
# session_id 解析规则:
# 1) 传入 "channel:chat_id" 时,显式使用对应渠道与会话;
# 2) 仅传入 "xxx" 时,默认视作 CLI 渠道下的 chat_id=xxx。
# 这样既支持模拟外部渠道,也兼容最常见的纯 CLI 对话场景。
if ":" in session_id:
cli_channel, cli_chat_id = session_id.split(":", 1)
else:
cli_channel, cli_chat_id = "cli", session_id
def _exit_on_sigint(signum, frame):
# prompt_toolkit 场景下 Ctrl+C 需要主动恢复终端并快速退出。
_restore_terminal()
console.print("\nGoodbye!")
os._exit(0)
signal.signal(signal.SIGINT, _exit_on_sigint)
async def run_interactive():
# 1) 启动 agent 主循环任务:
# 它会持续消费 inbound 队列并把结果写入 outbound 队列。
bus_task = asyncio.create_task(agent_loop.run())
# 2) `turn_done` 是“当前用户这一轮是否完成”的同步信号。
# 初始 set() 表示当前没有待完成轮次idle
turn_done = asyncio.Event()
turn_done.set()
# 存放“当前轮”最终回复文本(通常只取第一条主回复)。
turn_response: list[str] = []
async def _consume_outbound():
# 专门消费 outbound 队列,职责分三类:
# - 进度消息_progress实时打印不结束本轮
# - 当前轮主回复:写入 turn_response 并 set(turn_done)
# - 轮次外消息(例如异步通知):即时打印。
while True:
try:
# 用短超时轮询,既能及时处理消息,也便于取消时快速退出。
msg = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0)
if msg.metadata.get("_progress"):
# 进度消息可按配置开关过滤:
# - 工具提示_tool_hint
# - 普通进度文本
is_tool_hint = msg.metadata.get("_tool_hint", False)
ch = agent_loop.channels_config
if ch and is_tool_hint and not ch.send_tool_hints:
pass
elif ch and not is_tool_hint and not ch.send_progress:
pass
else:
console.print(f" [dim]↳ {msg.content}[/dim]")
elif not turn_done.is_set():
# 仍在等待“当前轮”结束:把正式回复记下来并唤醒等待方。
if msg.content:
turn_response.append(msg.content)
turn_done.set()
elif msg.content:
# 非当前轮的额外消息(如工具主动发送),直接展示。
console.print()
_print_agent_response(msg.content, render_markdown=markdown)
except asyncio.TimeoutError:
# 轮询超时属于正常情况,继续等下一条 outbound。
continue
except asyncio.CancelledError:
# 外层 finally 会 cancel 本任务,这里优雅退出。
break
# 独立启动 outbound 消费协程,避免主输入循环被队列消费阻塞。
outbound_task = asyncio.create_task(_consume_outbound())
try:
while True:
try:
# 清掉模型输出期间残留按键,避免下一次输入提示符“脏输入”。
_flush_pending_tty_input()
user_input = await _read_interactive_input_async()
command = user_input.strip()
if not command:
# 空输入不发给 agent直接进入下一轮读取。
continue
if _is_exit_command(command):
_restore_terminal()
console.print("\nGoodbye!")
break
# 发布新一轮之前先重置轮次状态,防止误用上一轮结果。
turn_done.clear()
turn_response.clear()
# 把用户输入发布到 inbound 队列,交由 agent_loop.run() 处理。
await bus.publish_inbound(InboundMessage(
channel=cli_channel,
sender_id="user",
chat_id=cli_chat_id,
content=user_input,
))
with _thinking_ctx():
# 等待本轮 agent 产出回复或结束信号。
await turn_done.wait()
if turn_response:
# 仅渲染当前轮收集到的主回复(通常第一条即可)。
_print_agent_response(turn_response[0], render_markdown=markdown)
except KeyboardInterrupt:
# 兼容未被 signal handler 接住的中断路径。
_restore_terminal()
console.print("\nGoodbye!")
break
except EOFError:
# Ctrl+D/管道 EOF 的统一退出路径。
_restore_terminal()
console.print("\nGoodbye!")
break
finally:
# 收尾顺序:
# 1) 请求 agent 主循环停止;
# 2) 取消 outbound 消费任务;
# 3) 等待两者结束(忽略取消异常);
# 4) 关闭 MCP 连接,避免资源泄漏。
agent_loop.stop()
outbound_task.cancel()
await asyncio.gather(bus_task, outbound_task, return_exceptions=True)
await agent_loop.close_mcp()
asyncio.run(run_interactive())
# ============================================================================
# Channel Commands
# ============================================================================
channels_app = typer.Typer(help="Manage channels")
app.add_typer(channels_app, name="channels")
def _exit_after_group_help(ctx: typer.Context) -> None:
"""Print group help and exit successfully when no subcommand is provided."""
if ctx.invoked_subcommand is None:
typer.echo(ctx.get_help())
raise typer.Exit()
@channels_app.callback(invoke_without_command=True)
def channels_main(ctx: typer.Context):
_exit_after_group_help(ctx)
@channels_app.command("status")
def channels_status():
"""展示渠道启用状态与关键配置摘要。
设计原则:
- 让用户快速判断“渠道是否可用”;
- 只显示必要摘要,不直接打印敏感凭据全量内容。
"""
from nanobot.config.loader import load_config
config = load_config()
table = Table(title="Channel Status")
table.add_column("Channel", style="cyan")
table.add_column("Enabled", style="green")
table.add_column("Configuration", style="yellow")
# 下方按渠道逐项展示:是否启用 + 核心配置是否已填写。
# 为避免泄露敏感信息token/app_id 仅展示前缀片段。
# WhatsApp
wa = config.channels.whatsapp
table.add_row(
"WhatsApp",
"" if wa.enabled else "",
wa.bridge_url
)
dc = config.channels.discord
table.add_row(
"Discord",
"" if dc.enabled else "",
dc.gateway_url
)
# Feishu
fs = config.channels.feishu
fs_config = f"app_id: {fs.app_id[:10]}..." if fs.app_id else "[dim]not configured[/dim]"
table.add_row(
"Feishu",
"" if fs.enabled else "",
fs_config
)
# Mochat
mc = config.channels.mochat
mc_base = mc.base_url or "[dim]not configured[/dim]"
table.add_row(
"Mochat",
"" if mc.enabled else "",
mc_base
)
# Telegram
tg = config.channels.telegram
tg_config = f"token: {tg.token[:10]}..." if tg.token else "[dim]not configured[/dim]"
table.add_row(
"Telegram",
"" if tg.enabled else "",
tg_config
)
# Slack
slack = config.channels.slack
slack_config = "socket" if slack.app_token and slack.bot_token else "[dim]not configured[/dim]"
table.add_row(
"Slack",
"" if slack.enabled else "",
slack_config
)
# DingTalk
dt = config.channels.dingtalk
dt_config = f"client_id: {dt.client_id[:10]}..." if dt.client_id else "[dim]not configured[/dim]"
table.add_row(
"DingTalk",
"" if dt.enabled else "",
dt_config
)
# QQ
qq = config.channels.qq
qq_config = f"app_id: {qq.app_id[:10]}..." if qq.app_id else "[dim]not configured[/dim]"
table.add_row(
"QQ",
"" if qq.enabled else "",
qq_config
)
# Matrix
mx = config.channels.matrix
mx_config = f"user_id: {mx.user_id}" if mx.user_id else "[dim]not configured[/dim]"
table.add_row(
"Matrix",
"" if mx.enabled else "",
mx_config
)
# Email
em = config.channels.email
em_config = em.imap_host if em.imap_host else "[dim]not configured[/dim]"
table.add_row(
"Email",
"" if em.enabled else "",
em_config
)
console.print(table)
def _get_bridge_dir() -> Path:
"""获取并准备本地 bridge 目录(如缺失则自动构建)。
返回值:
- 可直接用于 `npm start` 的 bridge 运行目录。
处理流程:
1. 优先复用已构建产物;
2. 其次从安装包目录或源码目录复制;
3. 最后执行 npm install + npm run build。
"""
import shutil
import subprocess
# bridge 运行目录统一放在用户数据目录,避免污染源码目录。
user_bridge = Path.home() / ".nanobot" / "bridge"
# Check if already built
if (user_bridge / "dist" / "index.js").exists():
return user_bridge
# Check for npm
if not shutil.which("npm"):
console.print("[red]npm not found. Please install Node.js >= 18.[/red]")
raise typer.Exit(1)
# 支持两种运行形态:
# 1) pip 安装bridge 可能在包数据里
# 2) 源码开发bridge 在仓库根目录
pkg_bridge = Path(__file__).parent.parent / "bridge" # nanobot/bridge (installed)
src_bridge = Path(__file__).parent.parent.parent / "bridge" # repo root/bridge (dev)
source = None
if (pkg_bridge / "package.json").exists():
source = pkg_bridge
elif (src_bridge / "package.json").exists():
source = src_bridge
if not source:
console.print("[red]Bridge source not found.[/red]")
console.print("Try reinstalling: pip install --force-reinstall nanobot")
raise typer.Exit(1)
console.print(f"{__brand__}: setting up bridge...")
# 重新复制并构建,确保 bridge 资源与当前版本同步。
user_bridge.parent.mkdir(parents=True, exist_ok=True)
if user_bridge.exists():
shutil.rmtree(user_bridge)
shutil.copytree(source, user_bridge, ignore=shutil.ignore_patterns("node_modules", "dist"))
# Install and build
try:
console.print(" Installing dependencies...")
subprocess.run(["npm", "install"], cwd=user_bridge, check=True, capture_output=True)
console.print(" Building...")
subprocess.run(["npm", "run", "build"], cwd=user_bridge, check=True, capture_output=True)
console.print("[green]✓[/green] Bridge ready\n")
except subprocess.CalledProcessError as e:
console.print(f"[red]Build failed: {e}[/red]")
if e.stderr:
console.print(f"[dim]{e.stderr.decode()[:500]}[/dim]")
raise typer.Exit(1)
return user_bridge
@channels_app.command("login")
def channels_login():
"""启动 bridge 并显示二维码登录流程(主要用于 WhatsApp"""
import subprocess
from nanobot.config.loader import load_config
config = load_config()
bridge_dir = _get_bridge_dir()
console.print(f"{__brand__}: starting bridge...")
console.print("Scan the QR code to connect.\n")
# 可选注入 BRIDGE_TOKEN 做 bridge 鉴权。
env = {**os.environ}
if config.channels.whatsapp.bridge_token:
env["BRIDGE_TOKEN"] = config.channels.whatsapp.bridge_token
try:
subprocess.run(["npm", "start"], cwd=bridge_dir, check=True, env=env)
except subprocess.CalledProcessError as e:
console.print(f"[red]Bridge failed: {e}[/red]")
except FileNotFoundError:
console.print("[red]npm not found. Please install Node.js.[/red]")
# ============================================================================
# Cron Commands
# ============================================================================
cron_app = typer.Typer(help="Manage scheduled tasks")
app.add_typer(cron_app, name="cron")
@cron_app.callback(invoke_without_command=True)
def cron_main(ctx: typer.Context):
_exit_after_group_help(ctx)
@cron_app.command("list")
def cron_list(
all: bool = typer.Option(False, "--all", "-a", help="Include disabled jobs"),
):
"""列出已配置的 cron 任务。"""
from nanobot.config.loader import load_config
from nanobot.cron.service import CronService
from nanobot.utils.helpers import get_cron_store_path
# CLI 侧每次命令调用都“现读现用” store避免长驻缓存带来的陈旧视图。
store_path = get_cron_store_path(load_config().workspace_path)
service = CronService(store_path)
jobs = service.list_jobs(include_disabled=all)
if not jobs:
console.print("No scheduled jobs.")
return
# 使用表格输出,便于快速对比任务 ID/调度表达式/状态。
table = Table(title="Scheduled Jobs")
table.add_column("ID", style="cyan")
table.add_column("Name")
table.add_column("Schedule")
table.add_column("Status")
table.add_column("Next Run")
# Next Run 展示时优先按 job 的 tz 渲染,失败再回退本地时区显示。
import time
from datetime import datetime as _dt
from zoneinfo import ZoneInfo
for job in jobs:
# Format schedule
if job.schedule.kind == "every":
sched = f"every {(job.schedule.every_ms or 0) // 1000}s"
elif job.schedule.kind == "cron":
sched = f"{job.schedule.expr or ''} ({job.schedule.tz})" if job.schedule.tz else (job.schedule.expr or "")
else:
sched = "one-time"
# Format next run
next_run = ""
if job.state.next_run_at_ms:
ts = job.state.next_run_at_ms / 1000
try:
tz = ZoneInfo(job.schedule.tz) if job.schedule.tz else None
next_run = _dt.fromtimestamp(ts, tz).strftime("%Y-%m-%d %H:%M")
except Exception:
next_run = time.strftime("%Y-%m-%d %H:%M", time.localtime(ts))
# 状态列只反映 enabled 开关,不代表“最近执行是否成功”。
status = "[green]enabled[/green]" if job.enabled else "[dim]disabled[/dim]"
table.add_row(job.id, job.name, sched, status, next_run)
console.print(table)
@cron_app.command("add")
def cron_add(
name: str = typer.Option(..., "--name", "-n", help="Job name"),
message: str = typer.Option(..., "--message", "-m", help="Message or prompt for the job"),
mode: str = typer.Option("task", "--mode", help="Execution mode: reminder or task"),
session_key: str = typer.Option(None, "--session-key", help="Reuse an existing session for task jobs"),
every: int = typer.Option(None, "--every", "-e", help="Run every N seconds"),
cron_expr: str = typer.Option(None, "--cron", "-c", help="Cron expression (e.g. '0 9 * * *')"),
tz: str | None = typer.Option(None, "--tz", help="IANA timezone for cron (e.g. 'America/Vancouver')"),
at: str = typer.Option(None, "--at", help="Run once at time (ISO format)"),
deliver: bool = typer.Option(False, "--deliver", "-d", help="Deliver response to channel"),
to: str = typer.Option(None, "--to", help="Recipient for delivery"),
channel: str = typer.Option(None, "--channel", help="Channel for delivery (e.g. 'telegram', 'whatsapp')"),
):
"""新增 cron 任务every / cron / at 三选一)。"""
from nanobot.config.loader import load_config
from nanobot.cron.service import CronService
from nanobot.cron.types import CronSchedule
from nanobot.utils.helpers import get_cron_store_path
# tz 仅对 cron_expr 有意义,提前拦截无效组合,减少用户困惑。
if tz and not cron_expr:
console.print("[red]Error: --tz can only be used with --cron[/red]")
raise typer.Exit(1)
normalized_mode = mode.strip().lower()
if normalized_mode not in {"reminder", "task"}:
console.print("[red]Error: --mode must be 'reminder' or 'task'[/red]")
raise typer.Exit(1)
payload_kind = "system_event" if normalized_mode == "reminder" else "agent_turn"
# 三种调度类型互斥:
# - every: 固定秒间隔
# - cron: cron 表达式
# - at: 单次执行
if every:
# every 单位是秒CronService 内部用毫秒。
schedule = CronSchedule(kind="every", every_ms=every * 1000)
elif cron_expr:
# cron 表达式调度,具体语义由 croniter + tz 解释。
schedule = CronSchedule(kind="cron", expr=cron_expr, tz=tz)
elif at:
import datetime
# ISO 格式解析失败会抛 ValueError由下方 except 统一处理文案)。
dt = datetime.datetime.fromisoformat(at)
schedule = CronSchedule(kind="at", at_ms=int(dt.timestamp() * 1000))
else:
console.print("[red]Error: Must specify --every, --cron, or --at[/red]")
raise typer.Exit(1)
# 命令入口只是管理面:创建任务并写盘,不直接触发执行。
store_path = get_cron_store_path(load_config().workspace_path)
service = CronService(store_path)
try:
job = service.add_job(
name=name,
schedule=schedule,
message=message,
payload_kind=payload_kind,
session_key=session_key,
deliver=deliver,
to=to,
channel=channel,
)
except ValueError as e:
console.print(f"[red]Error: {e}[/red]")
raise typer.Exit(1) from e
console.print(f"[green]✓[/green] Added job '{job.name}' ({job.id})")
@cron_app.command("remove")
def cron_remove(
job_id: str = typer.Argument(..., help="Job ID to remove"),
):
"""删除指定 cron 任务(仅管理面,不执行 agent"""
# 这里只做“管理面”删除,不触发 agent 流程。
from nanobot.config.loader import load_config
from nanobot.cron.service import CronService
from nanobot.utils.helpers import get_cron_store_path
store_path = get_cron_store_path(load_config().workspace_path)
service = CronService(store_path)
if service.remove_job(job_id):
console.print(f"[green]✓[/green] Removed job {job_id}")
else:
console.print(f"[red]Job {job_id} not found[/red]")
@cron_app.command("enable")
def cron_enable(
job_id: str = typer.Argument(..., help="Job ID"),
disable: bool = typer.Option(False, "--disable", help="Disable instead of enable"),
):
"""启用或禁用指定任务。"""
# --disable 为 True 时enabled=False否则启用。
from nanobot.config.loader import load_config
from nanobot.cron.service import CronService
from nanobot.utils.helpers import get_cron_store_path
store_path = get_cron_store_path(load_config().workspace_path)
service = CronService(store_path)
job = service.enable_job(job_id, enabled=not disable)
if job:
status = "disabled" if disable else "enabled"
console.print(f"[green]✓[/green] Job '{job.name}' {status}")
else:
console.print(f"[red]Job {job_id} not found[/red]")
@cron_app.command("run")
def cron_run(
job_id: str = typer.Argument(..., help="Job ID to run"),
force: bool = typer.Option(False, "--force", "-f", help="Run even if disabled"),
):
"""手动立即执行一个任务(可选忽略禁用状态)。"""
from loguru import logger
from nanobot.config.loader import load_config
from nanobot.cron.runtime import run_cron_job
from nanobot.cron.service import CronService
from nanobot.cron.types import CronExecutionResult, CronJob
from nanobot.bus.queue import MessageBus
from nanobot.agent.loop import AgentLoop
from nanobot.utils.helpers import get_cron_store_path
# 手动 run 只关心最终结果,默认关闭冗余日志,避免 CLI 输出噪声。
logger.disable("nanobot")
config = load_config()
provider = _make_provider(config)
bus = MessageBus()
# 为单次执行构建“轻量运行时”:只初始化执行链路,不启动 channels/gateway 常驻服务。
agent_loop = AgentLoop(
bus=bus,
provider=provider,
workspace=config.workspace_path,
model=config.agents.defaults.model,
temperature=config.agents.defaults.temperature,
max_tokens=config.agents.defaults.max_tokens,
max_iterations=config.agents.defaults.max_tool_iterations,
memory_window=config.agents.defaults.memory_window,
brave_api_key=config.tools.web.search.api_key or None,
exec_config=config.tools.exec,
a2a_config=config.tools.a2a,
restrict_to_workspace=config.tools.restrict_to_workspace,
mcp_servers=config.tools.mcp_servers,
channels_config=config.channels,
authz_config=config.authz,
backend_identity=config.backend_identity,
gateway_port=config.gateway.port,
)
store_path = get_cron_store_path(config.workspace_path)
service = CronService(store_path)
# 用列表容器保存异步回调结果,便于命令结束后在同步上下文打印。
# 这样可以把 on_job 内部拿到的 response 带出 asyncio.run 的作用域。
result_holder: list[str | None] = []
async def on_job(job: CronJob) -> CronExecutionResult:
# 手动触发时也沿用“agent 处理 + session key 命名”策略。
result = await run_cron_job(
job,
agent=agent_loop,
bus=bus,
default_channel="cli",
default_chat_id="direct",
)
result_holder.append(result.response)
return result
service.on_job = on_job
async def run():
# run_job 只是调用服务层入口,是否执行取决于 job.enabled 与 force 参数。
return await service.run_job(job_id, force=force)
if asyncio.run(run()):
console.print("[green]✓[/green] Job executed")
if result_holder:
_print_agent_response(result_holder[0], render_markdown=True)
else:
console.print(f"[red]Failed to run job {job_id}[/red]")
# ============================================================================
# Status Commands
# ============================================================================
@app.command()
def status():
"""展示 Boardware Genius 运行配置与 provider 状态概览。"""
from nanobot.config.loader import load_config, get_config_path
config_path = get_config_path()
config = load_config()
workspace = config.workspace_path
console.print(f"{__brand__} Status\n")
console.print(f"Config: {config_path} {'[green]✓[/green]' if config_path.exists() else '[red]✗[/red]'}")
console.print(f"Workspace: {workspace} {'[green]✓[/green]' if workspace.exists() else '[red]✗[/red]'}")
if config_path.exists():
from nanobot.providers.registry import PROVIDERS
console.print(f"Model: {config.agents.defaults.model}")
# 按 registry 顺序展示 provider 配置状态。
# OAuth 显示“已接入 OAuth”本地 provider 显示 api_base。
for spec in PROVIDERS:
p = getattr(config.providers, spec.name, None)
if p is None:
continue
if spec.is_oauth:
# OAuth provider 不一定有 api_key展示“OAuth 已接入”更符合真实状态。
console.print(f"{spec.label}: [green]✓ (OAuth)[/green]")
elif spec.is_local:
# Local deployments show api_base instead of api_key
if p.api_base:
console.print(f"{spec.label}: [green]✓ {p.api_base}[/green]")
else:
console.print(f"{spec.label}: [dim]not set[/dim]")
else:
has_key = bool(p.api_key)
console.print(f"{spec.label}: {'[green]✓[/green]' if has_key else '[dim]not set[/dim]'}")
# ============================================================================
# OAuth Login
# ============================================================================
provider_app = typer.Typer(help="Manage providers")
app.add_typer(provider_app, name="provider")
@provider_app.callback(invoke_without_command=True)
def provider_main(ctx: typer.Context):
_exit_after_group_help(ctx)
_LOGIN_HANDLERS: dict[str, callable] = {}
def _register_login(name: str):
"""注册 OAuth 登录处理器的小装饰器。
用法:
- 通过 `@_register_login("provider_name")` 把函数挂入 `_LOGIN_HANDLERS`
- `provider login` 命令再按 provider 名称分发到对应处理函数。
"""
def decorator(fn):
_LOGIN_HANDLERS[name] = fn
return fn
return decorator
@provider_app.command("login")
def provider_login(
provider: str = typer.Argument(..., help="OAuth provider (e.g. 'openai-codex', 'github-copilot')"),
):
"""触发指定 OAuth provider 的登录流程。"""
from nanobot.providers.registry import PROVIDERS
# 命令行允许 hyphen 写法,这里归一化到 registry 的 underscore 名称。
key = provider.replace("-", "_")
spec = next((s for s in PROVIDERS if s.name == key and s.is_oauth), None)
if not spec:
names = ", ".join(s.name.replace("_", "-") for s in PROVIDERS if s.is_oauth)
console.print(f"[red]Unknown OAuth provider: {provider}[/red] Supported: {names}")
raise typer.Exit(1)
# 通过注册表映射到具体 provider 的登录函数,实现命令层与实现层解耦。
handler = _LOGIN_HANDLERS.get(spec.name)
if not handler:
console.print(f"[red]Login not implemented for {spec.label}[/red]")
raise typer.Exit(1)
console.print(f"{__brand__} OAuth Login - {spec.label}\n")
handler()
@_register_login("openai_codex")
def _login_openai_codex() -> None:
"""OpenAI Codex OAuth 登录流程。
流程说明:
1. 先尝试读取本地缓存 token
2. 若无可用 token进入交互式设备授权
3. 授权成功后输出账户标识,失败则非零退出。
"""
try:
from oauth_cli_kit import get_token, login_oauth_interactive
token = None
try:
token = get_token()
except Exception:
pass
if not (token and token.access):
console.print("[cyan]Starting interactive OAuth login...[/cyan]\n")
token = login_oauth_interactive(
print_fn=lambda s: console.print(s),
prompt_fn=lambda s: typer.prompt(s),
)
if not (token and token.access):
console.print("[red]✗ Authentication failed[/red]")
raise typer.Exit(1)
console.print(f"[green]✓ Authenticated with OpenAI Codex[/green] [dim]{token.account_id}[/dim]")
except ImportError:
console.print("[red]oauth_cli_kit not installed. Run: pip install oauth-cli-kit[/red]")
raise typer.Exit(1)
@_register_login("github_copilot")
def _login_github_copilot() -> None:
"""GitHub Copilot 设备流登录触发。
通过一次最小 LiteLLM 请求触发底层授权流程。
触发成功即表示 OAuth 凭证已写入可用缓存。
"""
import asyncio
console.print("[cyan]Starting GitHub Copilot device flow...[/cyan]\n")
async def _trigger():
from litellm import acompletion
await acompletion(model="github_copilot/gpt-4o", messages=[{"role": "user", "content": "hi"}], max_tokens=1)
try:
asyncio.run(_trigger())
console.print("[green]✓ Authenticated with GitHub Copilot[/green]")
except Exception as e:
console.print(f"[red]Authentication error: {e}[/red]")
raise typer.Exit(1)
if __name__ == "__main__":
app()