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存东西,然后在子命令函数里取出来
- 查看路由信息:通过ctx.invoked_subcommand知道用户到底访问了哪个"接口"(子命令),所以在main方法中
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更好看
- rich_help_panel="Session":这纯粹是为了好看。就像Swagger里的@Tag或者是 API文档的分组。它会让你的
- 剩下的自己看吧
根命令方法体
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_repl和run_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 list和oh mcp add,就得写成oh mcp-list和oh 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循环中
写到最后
