OpenHarness源码研究-2-CLI构建工具Typer

OpenHarness源码研究-2-CLI构建工具Typer

前文

从cli.py,用传统web开发的视角,看typer框架如何定义通信和交互的,以及观察命令是如何设计的

运行主方法

bash 复制代码
#在前文中已经安装整个项目到系统中就可以直接使用oh来运行项目

#确认环境已经同步了
uv sync --extra dev

#运行方式-1(全局脚本)
oh

#运行方式-2(项目脚本)
uv run oh

#运行方式-3(uv指定环境+python模块)
uv run python -m openharness

#运行方式-4(手动激活环境+python模块)
source .venv/bin/activate
python -m openharness

 
#输出报错如下
Connecting to backend...
Error: No API key configured.
Run `oh auth login` to set up authentication, or set the
ℹ backend exited with code 1
Connecting to backend..

入口方法

openharness中__main__可以看到主方法就是cli中app,并且从toml中可以看到oh脚本指定的代码在openharness.cli:app

bash 复制代码
app = typer.Typer(
    name="openharness",
    help=(
        "Oh my Harness! An AI-powered coding assistant.\n\n"
        "Starts an interactive session by default, use -p/--print for non-interactive output."
    ),
    add_completion=False,
    rich_markup_mode="rich",
    invoke_without_command=True,
)
  • typer.Typer:是一个命令行参数解析器,相当于命令行的Web框架,一个能像开发微服务一样开发命令行工具的利器,如果你写过FastAPI那么Typer就像是把路由从URL搬到了终端。它利用Python的类型提示(Type Hints),让你的CLI程序像写Web API一样优雅:自动参数转换、自动帮助文档、自动错误拦截 。它不再是原始的 sys.argv 字符串切割,而是一个现代化的、声明式的命令行框架
  • help: uv ----help
  • add_completion:是否自动添加安装 shell 补全的命令。
  • rich_markup_mode:是否开启 Rich 渲染支持(让报错和帮助更漂亮)。
  • invoke_without_command
    • 代理模式(True):像python,直接输入 python 进入交互环境。
    • 命令模式(False):像docker直,接输入 docker 会报错,必须指定 docker run

根命令

根命令入参

python 复制代码
# ---------------------------------------------------------------------------
# Main command
# ---------------------------------------------------------------------------
@app.callback(invoke_without_command=True)
def main(
    ctx: typer.Context,
    # 版本信息选项
    version: bool = typer.Option(
        False,
        "--version",
        "-v",
        help="Show version and exit",
        callback=_version_callback,
        is_eager=True,
    ),
    # --- 会话管理 (Session) ---
    continue_session: bool = typer.Option(
        False,
        "--continue",
        "-c",
        help="Continue the most recent conversation in the current directory",
        rich_help_panel="Session",
    ),
    resume: str | None = typer.Option(
        None,
        "--resume",
        "-r",
        help="Resume a conversation by session ID, or open picker",
        rich_help_panel="Session",
    ),
    name: str | None = typer.Option(
        None,
        "--name",
        "-n",
        help="Set a display name for this session",
        rich_help_panel="Session",
    ),
    # --- 模型与性能 (Model & Effort) ---
    model: str | None = typer.Option(
        None,
        "--model",
        "-m",
        help="Model alias (e.g. 'sonnet', 'opus') or full model ID",
        rich_help_panel="Model & Effort",
    ),
    effort: str | None = typer.Option(
        None,
        "--effort",
        help="Effort level for the session (low, medium, high, max)",
        rich_help_panel="Model & Effort",
    ),
    verbose: bool = typer.Option(
        False,
        "--verbose",
        help="Override verbose mode setting from config",
        rich_help_panel="Model & Effort",
    ),
    max_turns: int | None = typer.Option(
        None,
        "--max-turns",
        help="Maximum number of agentic turns (enforced by default in --print; optional cap for interactive mode)",
        rich_help_panel="Model & Effort",
    ),
    # --- 输出控制 (Output) ---
    print_mode: str | None = typer.Option(
        None,
        "--print",
        "-p",
        help="Print response and exit. Pass your prompt as the value: -p 'your prompt'",
        rich_help_panel="Output",
    ),
    output_format: str | None = typer.Option(
        None,
        "--output-format",
        help="Output format with --print: text (default), json, or stream-json",
        rich_help_panel="Output",
    ),
    # --- 权限管理 (Permissions) ---
    permission_mode: str | None = typer.Option(
        None,
        "--permission-mode",
        help="Permission mode: default, plan, or full_auto",
        rich_help_panel="Permissions",
    ),
    dangerously_skip_permissions: bool = typer.Option(
        False,
        "--dangerously-skip-permissions",
        help="Bypass all permission checks (only for sandboxed environments)",
        rich_help_panel="Permissions",
    ),
    allowed_tools: Optional[list[str]] = typer.Option(
        None,
        "--allowed-tools",
        help="Comma or space-separated list of tool names to allow",
        rich_help_panel="Permissions",
    ),
    disallowed_tools: Optional[list[str]] = typer.Option(
        None,
        "--disallowed-tools",
        help="Comma or space-separated list of tool names to deny",
        rich_help_panel="Permissions",
    ),
    # --- 系统与上下文 (System & Context) ---
    system_prompt: str | None = typer.Option(
        None,
        "--system-prompt",
        "-s",
        help="Override the default system prompt",
        rich_help_panel="System & Context",
    ),
    append_system_prompt: str | None = typer.Option(
        None,
        "--append-system-prompt",
        help="Append text to the default system prompt",
        rich_help_panel="System & Context",
    ),
    settings_file: str | None = typer.Option(
        None,
        "--settings",
        help="Path to a JSON settings file or inline JSON string",
        rich_help_panel="System & Context",
    ),
    base_url: str | None = typer.Option(
        None,
        "--base-url",
        help="Anthropic-compatible API base URL",
        rich_help_panel="System & Context",
    ),
    api_key: str | None = typer.Option(
        None,
        "--api-key",
        "-k",
        help="API key (overrides config and environment)",
        rich_help_panel="System & Context",
    ),
    bare: bool = typer.Option(
        False,
        "--bare",
        help="Minimal mode: skip hooks, plugins, MCP, and auto-discovery",
        rich_help_panel="System & Context",
    ),
    api_format: str | None = typer.Option(
        None,
        "--api-format",
        help="API format: 'anthropic' (default), 'openai' (DashScope, GitHub Models, etc.), or 'copilot' (GitHub Copilot)",
        rich_help_panel="System & Context",
    ),
    theme: str | None = typer.Option(
        None,
        "--theme",
        help="TUI theme: default, dark, minimal, cyberpunk, solarized, or custom name",
        rich_help_panel="System & Context",
    ),
    # --- 高级选项 (Advanced) ---
    debug: bool = typer.Option(
        False,
        "--debug",
        "-d",
        help="Enable debug logging",
        rich_help_panel="Advanced",
    ),
    mcp_config: Optional[list[str]] = typer.Option(
        None,
        "--mcp-config",
        help="Load MCP servers from JSON files or strings",
        rich_help_panel="Advanced",
    ),
    cwd: str = typer.Option(
        str(Path.cwd()),
        "--cwd",
        help="Working directory for the session",
        hidden=True,
    ),
    backend_only: bool = typer.Option(
        False,
        "--backend-only",
        help="Run the structured backend host for the React terminal UI",
        hidden=True,
    ),
) -> None:
   xxx
  • 装饰器@app.callback:指定invoke_without_command=True,即使没有输入任何子命令(比如mcp, plugin等),也允许直接触发这个被@app.callback装饰的 main 函数,类比Web路由中的根路径/的get请求处理器
  • main方法中的入参typer.Context,这个就是此次会话的上下文对象,在SprintBoot中,它类似于HttpServletRequest,在FastAPI中,它类似于Request对象,作用如下:
    • 查看路由信息:通过ctx.invoked_subcommand知道用户到底访问了哪个"接口"(子命令),所以在main方法中if ctx.invoked_subcommand is not None: return代表如果用户运行的是oh mcp(即invoked_subcommand为"mcp"),那么main函数就只负责解析参数,解析完就直接 return。如果用户只输入了oh,没有子命令才继续执行main下面的代码
    • 共享数据:可以在main函数里往ctx.obj存东西,然后在子命令函数里取出来
  • version: bool = typer.Option
    • False:默认值
    • "--version", "-v":命令行标签:长标签和短标签
    • help="Show version and exit":帮助文档中的说明oh ---help中可以看到
    • callback=_version_callback:就是简单的回调函数
    • is_eager=True:优先级,最优先处理,不管后面还有啥参数
  • continue_session: bool = typer.Option
    • rich_help_panel="Session":这纯粹是为了好看。就像Swagger里的@Tag或者是 API文档的分组。它会让你的oh --help更好看
  • 剩下的自己看吧

根命令方法体

python 复制代码
# ---------------------------------------------------------------------------
# Main command
# ---------------------------------------------------------------------------
@app.callback(invoke_without_command=True)
def main(.....)->None
 """启动交互式会话或运行单个提示。"""
    
    # 如果用户输入了子命令(如 oh mcp),则 main 函数仅负责解析全局参数并退出,由子命令接管逻辑
    if ctx.invoked_subcommand is not None:
        return

    import asyncio
    import logging

    # 初始化日志系统
    if debug:
        logging.basicConfig(
            level=logging.DEBUG,
            format="%(asctime)s [%(name)s] %(levelname)s %(message)s",
            stream=sys.stderr,
        )
        logging.getLogger("openharness").setLevel(logging.DEBUG)
    elif os.environ.get("OPENHARNESS_LOG_LEVEL"):
        lvl = getattr(logging, os.environ["OPENHARNESS_LOG_LEVEL"].upper(), logging.WARNING)
        logging.basicConfig(level=lvl, format="%(asctime)s [%(name)s] %(levelname)s %(message)s", stream=sys.stderr)

    # 权限跳过处理
    # 注意:这会绕过所有权限检查
    if dangerously_skip_permissions:
        permission_mode = "full_auto"

    # 主题设置应用
    if theme:
        from openharness.config.settings import load_settings, save_settings
        settings = load_settings()
        settings.theme = theme
        save_settings(settings)

    from openharness.ui.app import run_print_mode, run_repl

    # --- 处理会话恢复逻辑 (--continue / --resume) ---
    if continue_session or resume is not None:
        from openharness.services.session_storage import (
            list_session_snapshots,
            load_session_by_id,
            load_session_snapshot,
        )

        session_data = None
        if continue_session:
            # 恢复最近一次会话
            session_data = load_session_snapshot(cwd)
            if session_data is None:
                print("No previous session found in this directory.", file=sys.stderr)
                raise typer.Exit(1)
            print(f"Continuing session: {session_data.get('summary', '(untitled)')[:60]}")
        elif resume == "" or resume is None:
            # 显示会话选择器供用户手动选择
            sessions = list_session_snapshots(cwd, limit=10)
            if not sessions:
                print("No saved sessions found.", file=sys.stderr)
                raise typer.Exit(1)
            print("Saved sessions:")
            for i, s in enumerate(sessions, 1):
                print(f"  {i}. [{s['session_id']}] {s.get('summary', '?')[:50]} ({s['message_count']} msgs)")
            choice = typer.prompt("Enter session number or ID")
            try:
                idx = int(choice) - 1
                if 0 <= idx < len(sessions):
                    session_data = load_session_by_id(cwd, sessions[idx]["session_id"])
                else:
                    print("Invalid selection.", file=sys.stderr)
                    raise typer.Exit(1)
            except ValueError:
                session_data = load_session_by_id(cwd, choice)
            if session_data is None:
                print(f"Session not found: {choice}", file=sys.stderr)
                raise typer.Exit(1)
        else:
            # 根据指定的 ID 恢复会话
            session_data = load_session_by_id(cwd, resume)
            if session_data is None:
                print(f"Session not found: {resume}", file=sys.stderr)
                raise typer.Exit(1)

        # 启动 REPL 并加载历史消息
        asyncio.run(
            run_repl(
                prompt=None,
                cwd=cwd,
                model=session_data.get("model") or model,
                backend_only=backend_only,
                base_url=base_url,
                system_prompt=session_data.get("system_prompt") or system_prompt,
                api_key=api_key,
                restore_messages=session_data.get("messages"),
                permission_mode=permission_mode,
                api_format=api_format,
            )
        )
        return

    # --- 处理单次打印模式 (--print / -p) ---
    if print_mode is not None:
        prompt = print_mode.strip()
        if not prompt:
            print("Error: -p/--print requires a prompt value, e.g. -p 'your prompt'", file=sys.stderr)
            raise typer.Exit(1)
        asyncio.run(
            run_print_mode(
                prompt=prompt,
                output_format=output_format or "text",
                cwd=cwd,
                model=model,
                base_url=base_url,
                system_prompt=system_prompt,
                append_system_prompt=append_system_prompt,
                api_key=api_key,
                api_format=api_format,
                permission_mode=permission_mode,
                max_turns=max_turns,
            )
        )
        return

    # --- 默认行为:进入全交互式 REPL 会话 ---
    asyncio.run(
        run_repl(
            prompt=None,
            cwd=cwd,
            model=model,
            max_turns=max_turns,
            backend_only=backend_only,
            base_url=base_url,
            system_prompt=system_prompt,
            api_key=api_key,
            api_format=api_format,
            permission_mode=permission_mode,
        )
    )
  • 在处理会话asyncio.run之前的代码很清晰,没啥好说的
  • 主要关心的是asyncio.run中调用的run_replrun_print_mode这两个异步方法,asyncio.run是 "同步进入异步" 唯一方法,所以这里不能用await
  • 我们在运行最基本的程序oh时,并没有指定比如cwd,model,max_turns等等参数,默认值为None,也会传递给run_repl函数,在函数内部会去自动读取环境变量或者配置文件~/.openharness/settings.json,这也是直接运行oh报错的原因,因为都没读取到
  • run_repl中的repl就是所谓的 REPL (Read-Eval-Print Loop,读取-执行-打印-循环) 模式
    • Read (读):程序停在input()或者prompt_toolkit的输入框,等待你打字
    • Eval (算):你按下回车,程序prompt发给AI
    • Print (印):AI 回答后,程序把文字打印出来
    • Loop (回):代码里有一个while True 循环,会重新跳回到第一步,继续等输入
  • 其他参数都很好懂,backend_only默认为false就是把命令行交互页面激活,为true的时候暂时不分析

单层子命令-setup

python 复制代码
@app.command("setup")
def setup_cmd(
    profile: str | None = typer.Argument(None, help="Provider profile name to configure"),
) -> None:
    """统一配置流程:选择工作流、必要时进行身份验证,然后设置模型。"""
    from openharness.auth.manager import AuthManager
    from openharness.config.settings import display_model_setting

    # 初始化认证管理器并获取所有配置文件的状态
    manager = AuthManager()
    statuses = manager.get_profile_statuses()
    if not statuses:
        print("No provider profiles available.", file=sys.stderr)
        raise typer.Exit(1)

    target = profile
    # 如果用户没有在命令行指定 profile,则弹出交互式菜单供用户选择
    if target is None:
        target = _select_setup_workflow(
            statuses,
            default_value=manager.get_active_profile(),
        )

    # 处理特定的设置目标(例如某些特定的快捷方式或别名)
    target = _specialize_setup_target(manager, target)
    manager = AuthManager()
    statuses = manager.get_profile_statuses()

    # 检查目标 profile 是否合法
    if target not in statuses:
        print(f"Unknown provider profile: {target!r}", file=sys.stderr)
        raise typer.Exit(1)

    # 检查目标 profile 是否已经配置过认证信息(如 API Key)
    info = statuses[target]
    if not info["configured"]:
        source_label = _AUTH_SOURCE_LABELS.get(info["auth_source"], info["auth_source"])
        print(f"{info['label']} requires {source_label}.", flush=True)
        # 如果未配置,则引导用户进行认证(登录流程)
        _ensure_profile_auth(manager, target)
        manager = AuthManager()

    # 获取选定的 profile 对象并引导用户选择默认模型
    profile_obj = manager.list_profiles()[target]
    model_setting = _prompt_model_for_profile(profile_obj)
    
    # 更新 profile 的模型设置
    if model_setting.lower() == "default":
        manager.update_profile(target, last_model="")
    else:
        manager.update_profile(target, last_model=model_setting)
    
    # 将该 profile 设置为当前活跃的 profile
    manager.use_profile(target)

    # 打印最终的设置摘要
    updated = manager.list_profiles()[target]
    print(
        "Setup complete:\n"
        f"- profile: {target}\n"
        f"- provider: {updated.provider}\n"
        f"- auth_source: {updated.auth_source}\n"
        f"- model: {display_model_setting(updated)}",
        flush=True,
    )

多层子命令-5个

定义

python 复制代码
# ---------------------------------------------------------------------------
# Subcommands
# ---------------------------------------------------------------------------

mcp_app = typer.Typer(name="mcp", help="Manage MCP servers")
plugin_app = typer.Typer(name="plugin", help="Manage plugins")
auth_app = typer.Typer(name="auth", help="Manage authentication")
provider_app = typer.Typer(name="provider", help="Manage provider profiles")
cron_app = typer.Typer(name="cron", help="Manage cron scheduler and jobs")

app.add_typer(mcp_app)
app.add_typer(plugin_app)
app.add_typer(auth_app)
app.add_typer(provider_app)
app.add_typer(cron_app)
  • 在构建大型 CLI 工具时,单一入口往往力不从心。OpenHarness 利用 Typer 的 add_typer 机制,实现了一套类似 FastAPI APIRouter 的分层路由体系。这不仅让代码结构清晰(登录归登录,配置归配置),更让用户获得了一套逻辑严密的'动词+名词'式命令行交互体验
  • 这里定义了5个子命令,mcp,plugin,auth,provider,cron,使用如oh mcp执行
  • 在app.add_typer这种挂载方式下,在主app定义的全局参数(如--debug),在运行子命令 oh --debug mcp list 时依然生效
  • add_typer(mcp_app)方式比起@app.command("setup")方式来说更合适,因为一旦用了后者,就没法在mcp后面再跟别的操作了。如果你想实现 oh mcp listoh mcp add,就得写成 oh mcp-listoh mcp-add 这种扁平的命令

MCP子命令

python 复制代码
# ---- mcp subcommands ----

@mcp_app.command("list")
def mcp_list() -> None:
    """List configured MCP servers."""
    from openharness.config import load_settings
    from openharness.mcp.config import load_mcp_server_configs
    from openharness.plugins import load_plugins

    settings = load_settings()
    plugins = load_plugins(settings, str(Path.cwd()))
    configs = load_mcp_server_configs(settings, plugins)
    if not configs:
        print("No MCP servers configured.")
        return
    for name, cfg in configs.items():
        transport = cfg.get("transport", cfg.get("command", "unknown"))
        print(f"  {name}: {transport}")

@mcp_app.command("add")
def mcp_add(
    name: str = typer.Argument(..., help="Server name"),
    config_json: str = typer.Argument(..., help="Server config as JSON string"),
) -> None:
    """Add an MCP server configuration."""
    from openharness.config import load_settings, save_settings

    settings = load_settings()
    try:
        cfg = json.loads(config_json)
    except json.JSONDecodeError as exc:
        print(f"Invalid JSON: {exc}", file=sys.stderr)
        raise typer.Exit(1)
    if not isinstance(settings.mcp_servers, dict):
        settings.mcp_servers = {}
    settings.mcp_servers[name] = cfg
    save_settings(settings)
    print(f"Added MCP server: {name}")

@mcp_app.command("remove")
def mcp_remove(
    name: str = typer.Argument(..., help="Server name to remove"),
) -> None:
    """Remove an MCP server configuration."""
    from openharness.config import load_settings, save_settings

    settings = load_settings()
    if not isinstance(settings.mcp_servers, dict) or name not in settings.mcp_servers:
        print(f"MCP server not found: {name}", file=sys.stderr)
        raise typer.Exit(1)
    del settings.mcp_servers[name]
    save_settings(settings)
    print(f"Removed MCP server: {name}")
  • @mcp_app.command("xxx")代表为mcp添加子命令,为什么不使用mcp_app.add_typer这种方式,是因为到这里就不会再有子命令了,这也是CLI的常见设计
  • 就是简单的增删查操作,先暂时不展开到方法内部
  • 通过load_settings()save_settings(),大概知道是先把本地配置读到内存,操作完成后再写回磁盘,大概是这个文件~/.openharness/settings.json

plugin子命令

python 复制代码
# ---- plugin subcommands ----

@plugin_app.command("list")
def plugin_list() -> None:
    """List installed plugins."""
    from openharness.config import load_settings
    from openharness.plugins import load_plugins

    settings = load_settings()
    plugins = load_plugins(settings, str(Path.cwd()))
    if not plugins:
        print("No plugins installed.")
        return
    for plugin in plugins:
        status = "enabled" if plugin.enabled else "disabled"
        print(f"  {plugin.name} [{status}] - {plugin.description or ''}")

@plugin_app.command("install")
def plugin_install(
    source: str = typer.Argument(..., help="Plugin source (path or URL)"),
) -> None:
    """Install a plugin from a source path."""
    from openharness.plugins.installer import install_plugin_from_path

    result = install_plugin_from_path(source)
    print(f"Installed plugin: {result}")

@plugin_app.command("uninstall")
def plugin_uninstall(
    name: str = typer.Argument(..., help="Plugin name to uninstall"),
) -> None:
    """Uninstall a plugin."""
    from openharness.plugins.installer import uninstall_plugin

    uninstall_plugin(name)
    print(f"Uninstalled plugin: {name}")
  • 插件的增删改查,同样没啥好说的,很清晰
  • 这里的函数内部的from openharness.plugins.installer import ...是CLI 工具优化的常用手段,如果把所有包都写在文件顶部,每次运行 oh --help 都要加载成百上千个库,速度会非常慢

cron子命令

python 复制代码
# ---- cron subcommands ----

@cron_app.command("start")
def cron_start() -> None:
    """Start the cron scheduler daemon."""
    from openharness.services.cron_scheduler import is_scheduler_running, start_daemon

    if is_scheduler_running():
        print("Cron scheduler is already running.")
        return
    pid = start_daemon()
    print(f"Cron scheduler started (pid={pid})")

@cron_app.command("stop")
def cron_stop() -> None:
    """Stop the cron scheduler daemon."""
    from openharness.services.cron_scheduler import stop_scheduler

    if stop_scheduler():
        print("Cron scheduler stopped.")
    else:
        print("Cron scheduler is not running.")

@cron_app.command("status")
def cron_status_cmd() -> None:
    """Show cron scheduler status and job summary."""
    from openharness.services.cron_scheduler import scheduler_status

    status = scheduler_status()
    state = "running" if status["running"] else "stopped"
    print(f"Scheduler: {state}" + (f" (pid={status['pid']})" if status["pid"] else ""))
    print(f"Jobs:      {status['enabled_jobs']} enabled / {status['total_jobs']} total")
    print(f"Log:       {status['log_file']}")

@cron_app.command("list")
def cron_list_cmd() -> None:
    """List all registered cron jobs with schedule and status."""
    from openharness.services.cron import load_cron_jobs

    jobs = load_cron_jobs()
    if not jobs:
        print("No cron jobs configured.")
        return
    for job in jobs:
        enabled = "on " if job.get("enabled", True) else "off"
        last = job.get("last_run", "never")
        if last != "never":
            last = last[:19]  # trim to readable datetime
        last_status = job.get("last_status", "")
        status_indicator = f" [{last_status}]" if last_status else ""
        print(f"  [{enabled}] {job['name']}  {job.get('schedule', '?')}")
        print(f"        cmd: {job['command']}")
        print(f"        last: {last}{status_indicator}  next: {job.get('next_run', 'n/a')[:19]}")

@cron_app.command("toggle")
def cron_toggle_cmd(
    name: str = typer.Argument(..., help="Cron job name"),
    enabled: bool = typer.Argument(..., help="true to enable, false to disable"),
) -> None:
    """Enable or disable a cron job."""
    from openharness.services.cron import set_job_enabled

    if not set_job_enabled(name, enabled):
        print(f"Cron job not found: {name}")
        raise typer.Exit(1)
    state = "enabled" if enabled else "disabled"
    print(f"Cron job '{name}' is now {state}")

@cron_app.command("history")
def cron_history_cmd(
    name: str | None = typer.Argument(None, help="Filter by job name"),
    limit: int = typer.Option(20, "--limit", "-n", help="Number of entries"),
) -> None:
    """Show cron execution history."""
    from openharness.services.cron_scheduler import load_history

    entries = load_history(limit=limit, job_name=name)
    if not entries:
        print("No execution history.")
        return
    for entry in entries:
        ts = entry.get("started_at", "?")[:19]
        status = entry.get("status", "?")
        rc = entry.get("returncode", "?")
        print(f"  {ts}  {entry.get('name', '?')}  {status} (rc={rc})")
        stderr = entry.get("stderr", "").strip()
        if stderr and status != "success":
            for line in stderr.splitlines()[:3]:
                print(f"    stderr: {line}")

@cron_app.command("logs")
def cron_logs_cmd(
    lines: int = typer.Option(30, "--lines", "-n", help="Number of lines to show"),
) -> None:
    """Show recent cron scheduler log output."""
    from openharness.config.paths import get_logs_dir

    log_path = get_logs_dir() / "cron_scheduler.log"
    if not log_path.exists():
        print("No scheduler log found. Start the scheduler with: oh cron start")
        return
    content = log_path.read_text(encoding="utf-8", errors="replace")
    tail = content.splitlines()[-lines:]
    for line in tail:
        print(line)
  • cron定时任务管理系统,在当前层面也很清晰
  • toggle就是"开关"或"切换"的意思,用于控制单个cron任务,比如有个任务叫bakcup,oh cron toggle backup false代表关了这个任务
  • start/stop是粗颗粒度的。它控制的是进程的生死。当需要升级代码或彻底停止调度时,才会用到
  • start/stop是电源总闸,toggle是每个房间的电灯开关

auth子命令

python 复制代码
@auth_app.command("login")
def auth_login(
    provider: Optional[str] = typer.Argument(None, help="Provider name (anthropic, openai, copilot, ...)"),
) -> None:
    """Interactively authenticate with a provider.

    Run without arguments to choose a provider from a menu.
    Supported providers: anthropic, anthropic_claude, openai, openai_codex, copilot, dashscope, bedrock, vertex, moonshot.
    """
    if provider is None:
        print("Select a provider to authenticate:", flush=True)
        labels = list(_PROVIDER_LABELS.items())
        for i, (name, label) in enumerate(labels, 1):
            print(f"  {i}. {label} [{name}]", flush=True)
        raw = typer.prompt("Enter number or provider name", default="1")
        try:
            idx = int(raw.strip()) - 1
            if 0 <= idx < len(labels):
                provider = labels[idx][0]
            else:
                print("Invalid selection.", file=sys.stderr)
                raise typer.Exit(1)
        except ValueError:
            provider = raw.strip()

    provider = provider.lower()
    _login_provider(provider)

@auth_app.command("status")
def auth_status_cmd() -> None:
    """Show authentication source and provider profile status."""
    from openharness.auth.manager import AuthManager

    manager = AuthManager()
    auth_sources = manager.get_auth_source_statuses()
    profiles = manager.get_profile_statuses()

    print("Auth sources:")
    print(f"{'Source':<24} {'State':<14} {'Origin':<10} Active")
    print("-" * 60)
    for name, info in auth_sources.items():
        label = _AUTH_SOURCE_LABELS.get(name, name)
        active_str = "<-- active" if info["active"] else ""
        print(f"{label:<24} {info['state']:<14} {info['source']:<10} {active_str}")
        if info.get("detail"):
            print(f"  detail: {info['detail']}")

    print()
    print("Provider profiles:")
    print(f"{'Profile':<20} {'Provider':<18} {'Auth source':<22} {'State':<12} Active")
    print("-" * 92)
    for name, info in profiles.items():
        status_str = "ready" if info["configured"] else info.get("auth_state", "missing auth")
        active_str = "<-- active" if info["active"] else ""
        print(f"{name:<20} {info['provider']:<18} {info['auth_source']:<22} {status_str:<12} {active_str}")

@auth_app.command("logout")
def auth_logout(
    provider: Optional[str] = typer.Argument(None, help="Provider to log out (default: active provider)"),
) -> None:
    """Clear stored authentication for a provider."""
    from openharness.auth.manager import AuthManager

    manager = AuthManager()
    if provider is None:
        target = manager.get_active_profile()
        manager.clear_profile_credential(target)
        print(f"Authentication cleared for profile: {target}", flush=True)
        return
    manager.clear_credential(provider)
    print(f"Authentication cleared for provider: {provider}", flush=True)

@auth_app.command("switch")
def auth_switch(
    provider: str = typer.Argument(..., help="Auth source or profile to activate"),
) -> None:
    """Switch the auth source for the active profile, or use a profile by name."""
    from openharness.auth.manager import AuthManager

    manager = AuthManager()
    try:
        manager.switch_provider(provider)
    except ValueError as exc:
        print(f"Error: {exc}", file=sys.stderr)
        raise typer.Exit(1)
    print(f"Switched auth/profile to: {provider}", flush=True)

# ---------------------------------------------------------------------------
# Copilot login helper (kept as a named function for reuse and backward compat)
# ---------------------------------------------------------------------------

def _run_copilot_login() -> None:
    """Run the GitHub Copilot device-code flow and persist the result."""
    from openharness.api.copilot_auth import save_copilot_auth
    from openharness.auth.flows import DeviceCodeFlow

    print("Select GitHub deployment type:", flush=True)
    print("  1. GitHub.com (public)", flush=True)
    print("  2. GitHub Enterprise (data residency / self-hosted)", flush=True)
    choice = typer.prompt("Enter choice", default="1")

    enterprise_url: str | None = None
    github_domain = "github.com"

    if choice.strip() == "2":
        raw_url = typer.prompt("Enter your GitHub Enterprise URL or domain (e.g. company.ghe.com)")
        domain = raw_url.replace("https://", "").replace("http://", "").rstrip("/")
        if not domain:
            print("Error: domain cannot be empty.", file=sys.stderr, flush=True)
            raise typer.Exit(1)
        enterprise_url = domain
        github_domain = domain

    print(flush=True)
    flow = DeviceCodeFlow(github_domain=github_domain, enterprise_url=enterprise_url)
    try:
        token = flow.run()
    except RuntimeError as exc:
        print(f"Error: {exc}", file=sys.stderr, flush=True)
        raise typer.Exit(1)

    save_copilot_auth(token, enterprise_url=enterprise_url)
    print("GitHub Copilot authenticated successfully.", flush=True)
    if enterprise_url:
        print(f"  Enterprise domain: {enterprise_url}", flush=True)
    print(flush=True)
    print("To use Copilot as the provider, run:", flush=True)
    print("  oh provider use copilot", flush=True)

@auth_app.command("copilot-login")
def auth_copilot_login() -> None:
    """Authenticate with GitHub Copilot via device flow (alias for 'oh auth login copilot')."""
    _run_copilot_login()

@auth_app.command("codex-login")
def auth_codex_login() -> None:
    """Bind OpenHarness to a local Codex CLI subscription session."""
    _bind_external_provider("openai_codex")

@auth_app.command("claude-login")
def auth_claude_login() -> None:
    """Bind OpenHarness to a local Claude CLI subscription session."""
    _bind_external_provider("anthropic_claude")

@auth_app.command("copilot-logout")
def auth_copilot_logout() -> None:
    """Remove stored GitHub Copilot authentication."""
    from openharness.api.copilot_auth import clear_github_token

    clear_github_token()
    print("Copilot authentication cleared.")
  • 这段代码实现了OpenHarness的认证子系统,负责管理API Key、OAuth令牌以及与不同 AI供应商的链接状态
  • 交互式设计的体现:如果用户直接输入 oh auth login ,会打印一个带数字编号的菜单,并使用typer.prompt等待输入,根据用户选择的provider,最终调用_login_provider,实现不同的供应商触发不同的登录流,有的输入key,有的跳浏览器
  • copilot,codex和claude的认证方式各不相同,这里只是去拿他们的key,没啥好说的,先搁置,后面看有没有分析的必要

provier命令

python 复制代码
# ---- provider subcommands ----

@provider_app.command("list")
def provider_list() -> None:
    """List configured provider profiles."""
    from openharness.auth.manager import AuthManager

    statuses = AuthManager().get_profile_statuses()
    for name, info in statuses.items():
        marker = "*" if info["active"] else " "
        configured = "ready" if info["configured"] else "missing auth"
        base = info["base_url"] or "(default)"
        print(f"{marker} {name}: {info['label']} [{configured}]")
        print(f"    auth={info['auth_source']} model={info['model']} base_url={base}")

@provider_app.command("use")
def provider_use(
    name: str = typer.Argument(..., help="Provider profile name"),
) -> None:
    """Activate a provider profile."""
    from openharness.auth.manager import AuthManager

    manager = AuthManager()
    try:
        manager.use_profile(name)
    except ValueError as exc:
        print(f"Error: {exc}", file=sys.stderr)
        raise typer.Exit(1)
    print(f"Activated provider profile: {name}", flush=True)

@provider_app.command("add")
def provider_add(
    name: str = typer.Argument(..., help="Provider profile name"),
    label: str = typer.Option(..., "--label", help="Display label"),
    provider: str = typer.Option(..., "--provider", help="Runtime provider id"),
    api_format: str = typer.Option(..., "--api-format", help="API format"),
    auth_source: str = typer.Option(..., "--auth-source", help="Auth source name"),
    model: str = typer.Option(..., "--model", help="Default model"),
    base_url: str | None = typer.Option(None, "--base-url", help="Optional base URL"),
    credential_slot: str | None = typer.Option(None, "--credential-slot", help="Optional profile-specific credential slot"),
    allowed_models: list[str] | None = typer.Option(None, "--allowed-model", help="Allowed model values for this profile"),
) -> None:
    """Create a provider profile."""
    from openharness.auth.manager import AuthManager
    from openharness.config.settings import ProviderProfile

    manager = AuthManager()
    manager.upsert_profile(
        name,
        ProviderProfile(
            label=label,
            provider=provider,
            api_format=api_format,
            auth_source=auth_source,
            default_model=model,
            last_model=model,
            base_url=base_url,
            credential_slot=credential_slot or _default_credential_slot_for_profile(name, auth_source),
            allowed_models=allowed_models or ([model] if credential_slot or _default_credential_slot_for_profile(name, auth_source) else []),
        ),
    )
    print(f"Saved provider profile: {name}", flush=True)

@provider_app.command("edit")
def provider_edit(
    name: str = typer.Argument(..., help="Provider profile name"),
    label: str | None = typer.Option(None, "--label", help="Display label"),
    provider: str | None = typer.Option(None, "--provider", help="Runtime provider id"),
    api_format: str | None = typer.Option(None, "--api-format", help="API format"),
    auth_source: str | None = typer.Option(None, "--auth-source", help="Auth source name"),
    model: str | None = typer.Option(None, "--model", help="Default model"),
    base_url: str | None = typer.Option(None, "--base-url", help="Optional base URL"),
    credential_slot: str | None = typer.Option(None, "--credential-slot", help="Optional profile-specific credential slot"),
    allowed_models: list[str] | None = typer.Option(None, "--allowed-model", help="Allowed model values for this profile"),
) -> None:
    """Edit a provider profile."""
    from openharness.auth.manager import AuthManager

    manager = AuthManager()
    try:
        manager.update_profile(
            name,
            label=label,
            provider=provider,
            api_format=api_format,
            auth_source=auth_source,
            default_model=model,
            last_model=model,
            base_url=base_url,
            credential_slot=credential_slot,
            allowed_models=allowed_models,
        )
    except ValueError as exc:
        print(f"Error: {exc}", file=sys.stderr)
        raise typer.Exit(1)
    print(f"Updated provider profile: {name}", flush=True)

@provider_app.command("remove")
def provider_remove(
    name: str = typer.Argument(..., help="Provider profile name"),
) -> None:
    """Remove a provider profile."""
    from openharness.auth.manager import AuthManager

    manager = AuthManager()
    try:
        manager.remove_profile(name)
    except ValueError as exc:
        print(f"Error: {exc}", file=sys.stderr)
        raise typer.Exit(1)
    print(f"Removed provider profile: {name}", flush=True)
  • 有了auth后得到了一大堆模型,只是就可以在把这些模型管理起来,这里也没啥好说

总结

  • 里面还有些内部调用方法就先不管了
  • typer.Typer就是一个现代化的、声明式的命令行框架,如果自己写的命令行工具,这框架可以学习下
  • 命令也要有工程化的思维,需要有分组的概念,利用命令的特性使用不同的声明方式
  • Read-Eval-Print Loop,(读取-执行-打印-循环) 模式
  • asyncio.run是 "同步进入异步" 唯一方法,所以这里不能用await,这个cli的核心就在REPL循环中

写到最后

相关推荐
xixixi777777 小时前
从5G标准到6G前沿:Polar码的技术演进与未来之路
开发语言·人工智能·5g·大模型·php·通信·polar码
pixle07 小时前
【 LangChain v1.2 入门系列教程】【四】结构化输出,让 Agent 返回可预测的结构
python·ai·langchain·agent·智能体
小林coding8 小时前
万字长文图解 Agent 面试题:ReAct、MCP、Skills、Function call、Tools、A2A、Workflow 等
agent
@不误正业9 小时前
AI-Agent记忆系统深度实战-3大范式源码对比与鸿蒙端实现
人工智能·agent·鸿蒙
RxGc9 小时前
2026年AI Agent开发实战:MCP协议深度解析与多智能体协作架构完全指南
人工智能·agent·mcp
DanCheOo9 小时前
我写了一个轻量 AI 网关库,多模型路由 + 自动降级 + 预算控制,一个包全搞定
agent·ai编程
CoderJia程序员甲9 小时前
GitHub 热榜项目 - 日榜(2026-04-13)
ai·大模型·github·ai教程
Pkmer9 小时前
Agent的ReAct(推理+行动)模式
llm·agent