Files
wehub-resource-sync 0418dc5cf9
Autopilot Pages / deploy (push) Waiting to run
CI / Python tests (3.10) (push) Waiting to run
CI / Python tests (3.11) (push) Waiting to run
CI / Python quality (push) Waiting to run
CI / Frontend typecheck (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 12:45:00 +08:00

711 lines
26 KiB
Python

"""CLI entry point for the ohmo personal-agent app."""
from __future__ import annotations
import asyncio
import logging
import sys
from pathlib import Path
import typer
from openharness.auth.manager import AuthManager
from openharness.config import load_settings
from ohmo.gateway.config import load_gateway_config, save_gateway_config
from ohmo.gateway.models import GatewayConfig
from ohmo.gateway.service import (
OhmoGatewayService,
gateway_status,
start_gateway_process,
stop_gateway_process,
)
from ohmo.memory import add_memory_entry, list_memory_files, remove_memory_entry
from ohmo.runtime import launch_ohmo_react_tui, run_ohmo_backend, run_ohmo_print_mode
from ohmo.session_storage import OhmoSessionBackend
from ohmo.workspace import (
get_gateway_config_path,
get_logs_dir,
get_workspace_root,
get_soul_path,
get_state_path,
get_user_path,
initialize_workspace,
workspace_health,
)
app = typer.Typer(
name="ohmo",
help="ohmo: a personal-agent app built on top of OpenHarness.",
invoke_without_command=True,
add_completion=False,
)
memory_app = typer.Typer(name="memory", help="Manage .ohmo memory")
soul_app = typer.Typer(name="soul", help="Inspect or edit soul.md")
user_app = typer.Typer(name="user", help="Inspect or edit user.md")
gateway_app = typer.Typer(name="gateway", help="Run the ohmo gateway")
app.add_typer(memory_app)
app.add_typer(soul_app)
app.add_typer(user_app)
app.add_typer(gateway_app)
_INTERACTIVE_CHANNELS = ("telegram", "slack", "discord", "feishu")
_WORKSPACE_HELP = "Path to the ohmo workspace (defaults to ~/.ohmo)"
def _can_use_questionary() -> bool:
"""Return True when a real interactive terminal is available."""
if not (sys.stdin.isatty() and sys.stdout.isatty()):
return False
if sys.stdin is not sys.__stdin__ or sys.stdout is not sys.__stdout__:
return False
try:
import questionary # noqa: F401
except ImportError:
return False
return True
def _select_with_questionary(
title: str,
options: list[tuple[str, str]],
*,
default_value: str | None = None,
) -> str:
import questionary
choices = [
questionary.Choice(
title=label,
value=value,
checked=(value == default_value),
)
for value, label in options
]
result = questionary.select(title, choices=choices, default=default_value).ask()
if result is None:
raise typer.Abort()
return str(result)
def _confirm_prompt(message: str, *, default: bool = False) -> bool:
"""Ask for confirmation, preferring questionary in a real TTY."""
if _can_use_questionary():
import questionary
result = questionary.confirm(message, default=default).ask()
if result is None:
raise typer.Abort()
return bool(result)
return typer.confirm(message, default=default)
def _text_prompt(message: str, *, default: str = "") -> str:
"""Prompt for text input, preferring questionary in a real TTY."""
if _can_use_questionary():
import questionary
result = questionary.text(message, default=default).ask()
if result is None:
raise typer.Abort()
return str(result)
return typer.prompt(message, default=default)
def _select_from_menu(
title: str,
options: list[tuple[str, str]],
*,
default_value: str | None = None,
) -> str:
"""Render a simple numbered picker and return the selected value."""
if _can_use_questionary():
return _select_with_questionary(title, options, default_value=default_value)
print(title)
default_index = 1
for index, (value, label) in enumerate(options, 1):
marker = " (default)" if value == default_value else ""
if value == default_value:
default_index = index
print(f" {index}. {label}{marker}")
raw = typer.prompt("Choose", default=str(default_index))
try:
selected = options[int(raw) - 1]
except (ValueError, IndexError):
raise typer.BadParameter(f"Invalid selection: {raw}") from None
return selected[0]
def _format_provider_profile_label(info: dict[str, object]) -> str:
label = str(info["label"])
if bool(info["configured"]):
return label
return f"{label} (missing)"
def _prompt_provider_profile(workspace: str | Path) -> str:
settings = load_settings()
statuses = AuthManager(settings).get_profile_statuses()
default_value = load_gateway_config(workspace).provider_profile
hints = {
"claude-api": ("Claude / Kimi / GLM / MiniMax", "fg:#7aa2f7"),
"openai-compatible": ("OpenAI / OpenRouter", "fg:#9ece6a"),
}
if _can_use_questionary():
import questionary
choices = []
for name, info in statuses.items():
label = str(info["label"])
missing = "" if bool(info["configured"]) else " (missing)"
hint = hints.get(name)
if hint is None:
title = label if not missing else [("", label), ("fg:#d3869b", missing)]
else:
hint_text, hint_style = hint
title = [
("", f"{label} "),
(hint_style, hint_text),
]
if missing:
title.extend([("", " "), ("fg:#d3869b", missing.strip())])
choices.append(questionary.Choice(title=title, value=name, checked=(name == default_value)))
result = questionary.select("Choose provider profile for ohmo:", choices=choices, default=default_value).ask()
if result is None:
raise typer.Abort()
return str(result)
options = []
for name, info in statuses.items():
label = _format_provider_profile_label(info)
hint = hints.get(name)
if hint is not None:
label = f"{label} ({hint[0]})"
options.append((name, label))
return _select_from_menu(
"Choose provider profile for ohmo:",
options,
default_value=default_value,
)
def _prompt_channels(existing: GatewayConfig) -> tuple[list[str], dict[str, dict]]:
enabled: list[str] = []
configs: dict[str, dict] = {}
print("Configure channels for ohmo gateway:")
for channel in _INTERACTIVE_CHANNELS:
current = channel in existing.enabled_channels
prior = dict(existing.channel_configs.get(channel, {}))
if current:
enabled.append(channel)
if not _confirm_prompt(f"Reconfigure {channel}?", default=False):
configs[channel] = prior
continue
elif not _confirm_prompt(f"Enable {channel}?", default=False):
continue
else:
enabled.append(channel)
allow_from_raw = _text_prompt(
f"{channel} allow_from (comma separated user/chat IDs; leave blank to deny all; '*' for everyone)",
default=",".join(prior.get("allow_from", [])),
)
allow_from = [item.strip() for item in allow_from_raw.split(",") if item.strip()]
config: dict[str, object] = {"allow_from": allow_from}
if channel == "telegram":
config["token"] = _text_prompt(
"Telegram bot token",
default=str(prior.get("token", "")),
)
config["reply_to_message"] = _confirm_prompt(
"Reply to the original Telegram message?",
default=bool(prior.get("reply_to_message", True)),
)
elif channel == "slack":
config["bot_token"] = _text_prompt(
"Slack bot token",
default=str(prior.get("bot_token", "")),
)
config["app_token"] = _text_prompt(
"Slack app token",
default=str(prior.get("app_token", "")),
)
config["mode"] = "socket"
config["reply_in_thread"] = _confirm_prompt(
"Reply in thread?",
default=bool(prior.get("reply_in_thread", True)),
)
config["group_policy"] = _select_from_menu(
"Slack group policy:",
[
("mention", "Mention only"),
("open", "Always reply in channels"),
("allowlist", "Only allow configured channels"),
],
default_value=str(prior.get("group_policy", "mention")),
)
elif channel == "discord":
config["token"] = _text_prompt(
"Discord bot token",
default=str(prior.get("token", "")),
)
config["gateway_url"] = _text_prompt(
"Discord gateway URL",
default=str(prior.get("gateway_url", "wss://gateway.discord.gg/?v=10&encoding=json")),
)
config["intents"] = int(
_text_prompt(
"Discord intents bitmask",
default=str(prior.get("intents", 513)),
)
)
config["group_policy"] = _select_from_menu(
"Discord group policy:",
[
("mention", "Mention only"),
("open", "Always reply in channels"),
],
default_value=str(prior.get("group_policy", "mention")),
)
elif channel == "feishu":
config["domain"] = _select_from_menu(
"Feishu domain:",
[
("https://open.feishu.cn", "Feishu (China)"),
("https://open.larksuite.com", "Lark (International)"),
],
default_value=str(prior.get("domain", "https://open.feishu.cn")),
)
config["app_id"] = _text_prompt(
"Feishu app id",
default=str(prior.get("app_id", "")),
)
config["app_secret"] = _text_prompt(
"Feishu app secret",
default=str(prior.get("app_secret", "")),
)
config["encrypt_key"] = _text_prompt(
"Feishu encrypt key",
default=str(prior.get("encrypt_key", "")),
)
config["verification_token"] = _text_prompt(
"Feishu verification token",
default=str(prior.get("verification_token", "")),
)
config["react_emoji"] = _text_prompt(
"Feishu reaction emoji",
default=str(prior.get("react_emoji", "OK")),
)
config["group_policy"] = _select_from_menu(
"Feishu group policy:",
[
("managed_or_mention", "Managed groups open; other groups require @mention"),
("mention", "Always require @mention in groups"),
("open", "Always reply to group messages"),
],
default_value=str(prior.get("group_policy", "managed_or_mention")),
)
prior_bot_names = prior.get("bot_names", ["ohmo", "openclaw", "openharness"])
if isinstance(prior_bot_names, str):
prior_bot_names_default = prior_bot_names
else:
prior_bot_names_default = ",".join(str(item) for item in prior_bot_names)
bot_names_raw = _text_prompt(
"Feishu bot mention names (comma separated)",
default=prior_bot_names_default,
)
config["bot_names"] = [item.strip() for item in bot_names_raw.split(",") if item.strip()]
config["bot_open_id"] = _text_prompt(
"Feishu bot open_id for exact mention detection (optional)",
default=str(prior.get("bot_open_id", "")),
)
configs[channel] = config
return enabled, configs
def _run_gateway_config_wizard(workspace: str | Path) -> GatewayConfig:
"""Interactive flow for provider/channel setup."""
existing = load_gateway_config(workspace)
provider_profile = _prompt_provider_profile(workspace)
enabled_channels, channel_configs = _prompt_channels(existing)
send_progress = _confirm_prompt(
"Send progress updates to channels?",
default=existing.send_progress,
)
send_tool_hints = _confirm_prompt(
"Send tool hints to channels?",
default=existing.send_tool_hints,
)
allow_remote_admin_commands = _confirm_prompt(
"Allow explicitly listed administrative slash commands from remote channels?",
default=existing.allow_remote_admin_commands,
)
default_allowlist = ", ".join(existing.allowed_remote_admin_commands)
allowed_remote_admin_commands: list[str] = []
if allow_remote_admin_commands:
allowlist_raw = _text_prompt(
"Allowed remote admin commands (comma-separated, e.g. permissions, plan)",
default=default_allowlist,
)
allowed_remote_admin_commands = [
item.strip().lstrip("/")
for item in allowlist_raw.split(",")
if item.strip()
]
config = existing.model_copy(
update={
"provider_profile": provider_profile,
"enabled_channels": enabled_channels,
"channel_configs": channel_configs,
"send_progress": send_progress,
"send_tool_hints": send_tool_hints,
"allow_remote_admin_commands": allow_remote_admin_commands,
"allowed_remote_admin_commands": allowed_remote_admin_commands,
}
)
save_gateway_config(config, workspace)
return config
def _print_gateway_config_summary(config: GatewayConfig) -> None:
if config.enabled_channels:
print(
"Configured channels: "
+ ", ".join(config.enabled_channels)
+ f" | provider_profile={config.provider_profile}"
)
deny_all_channels = [
name for name in config.enabled_channels
if not list(config.channel_configs.get(name, {}).get("allow_from", []))
]
if deny_all_channels:
print(
"Remote access denied until allow_from is configured for: "
+ ", ".join(deny_all_channels)
)
else:
print(f"Configured provider_profile={config.provider_profile}; no channels enabled yet.")
if config.allow_remote_admin_commands and config.allowed_remote_admin_commands:
print(
"Remote admin opt-in enabled for: "
+ ", ".join(f"/{name}" for name in config.allowed_remote_admin_commands)
)
else:
print("Remote admin commands remain local-only.")
def _maybe_restart_gateway(*, cwd: str | Path, workspace: str | Path) -> None:
state = gateway_status(cwd, workspace)
if not state.running:
return
if not _confirm_prompt("Gateway is running. Restart now to apply changes?", default=True):
print("Configuration saved. Restart later with `ohmo gateway restart`.")
return
stop_gateway_process(cwd, workspace)
pid = start_gateway_process(cwd, workspace)
print(f"ohmo gateway restarted (pid={pid})")
def _configure_gateway_logging(
workspace: str | Path | None = None,
*,
console: bool = True,
log_file: bool = True,
) -> None:
"""Configure foreground gateway logging."""
config = load_gateway_config(workspace)
level_name = str(config.log_level or "INFO").upper()
level = getattr(logging, level_name, logging.INFO)
handlers = _build_gateway_logging_handlers(workspace, console=console, log_file=log_file)
logging.basicConfig(
level=level,
format="%(asctime)s [%(name)s] %(levelname)s %(message)s",
handlers=handlers,
force=True,
)
def _build_gateway_logging_handlers(
workspace: str | Path | None = None,
*,
console: bool,
log_file: bool,
) -> list[logging.Handler]:
"""Build gateway log handlers for foreground and daemon modes."""
handlers: list[logging.Handler] = []
if console:
handlers.append(logging.StreamHandler())
if log_file:
log_path = get_logs_dir(workspace) / "gateway.log"
log_path.parent.mkdir(parents=True, exist_ok=True)
handlers.append(logging.FileHandler(log_path, encoding="utf-8", delay=True))
return handlers
@app.callback(invoke_without_command=True)
def main(
ctx: typer.Context,
print_mode: str | None = typer.Option(None, "--print", "-p", help="Run a single prompt and exit"),
model: str | None = typer.Option(None, "--model", help="Model override for this session"),
profile: str | None = typer.Option(None, "--profile", help="Provider profile to use"),
workspace: str | None = typer.Option(None, "--workspace", help=_WORKSPACE_HELP),
max_turns: int | None = typer.Option(None, "--max-turns", help="Override max turns"),
cwd: str = typer.Option(str(Path.cwd()), "--cwd", help="Working directory"),
backend_only: bool = typer.Option(False, "--backend-only", hidden=True),
resume: str | None = typer.Option(None, "--resume", help="Resume an ohmo session by id"),
continue_session: bool = typer.Option(False, "--continue", help="Continue the latest ohmo session"),
) -> None:
"""Launch the ohmo app or invoke a subcommand."""
if ctx.invoked_subcommand is not None:
return
cwd_path = str(Path(cwd).resolve())
workspace_root = initialize_workspace(workspace)
backend = OhmoSessionBackend(workspace_root)
restore_messages = None
restore_tool_metadata = None
if continue_session:
latest = backend.load_latest(cwd_path)
if latest is None:
print("No previous ohmo session found in this directory.", file=sys.stderr)
raise typer.Exit(1)
restore_messages = latest.get("messages")
restore_tool_metadata = latest.get("tool_metadata")
elif resume:
snapshot = backend.load_by_id(cwd_path, resume)
if snapshot is None:
print(f"ohmo session not found: {resume}", file=sys.stderr)
raise typer.Exit(1)
restore_messages = snapshot.get("messages")
restore_tool_metadata = snapshot.get("tool_metadata")
if backend_only:
raise SystemExit(
asyncio.run(
run_ohmo_backend(
cwd=cwd_path,
workspace=workspace_root,
model=model,
max_turns=max_turns,
provider_profile=profile,
restore_messages=restore_messages,
restore_tool_metadata=restore_tool_metadata,
)
)
)
if print_mode is not None:
raise SystemExit(
asyncio.run(
run_ohmo_print_mode(
prompt=print_mode,
cwd=cwd_path,
workspace=workspace_root,
model=model,
max_turns=max_turns,
provider_profile=profile,
)
)
)
raise SystemExit(
asyncio.run(
launch_ohmo_react_tui(
cwd=cwd_path,
workspace=workspace_root,
model=model,
max_turns=max_turns,
provider_profile=profile,
)
)
)
@app.command("init")
def init_cmd(
cwd: str = typer.Option(str(Path.cwd()), "--cwd", help="Project working directory (reserved for future project overrides)"),
workspace: str | None = typer.Option(None, "--workspace", help=_WORKSPACE_HELP),
interactive: bool = typer.Option(
True,
"--interactive/--no-interactive",
help="Run the provider/channel setup wizard when attached to a terminal",
),
) -> None:
"""Initialize the .ohmo workspace."""
root_path = get_workspace_root(workspace)
already_exists = root_path.exists()
root = initialize_workspace(root_path)
print(f"Initialized ohmo workspace at {root}")
if already_exists:
print("ohmo workspace already exists.")
if not interactive:
print("Use `ohmo config` to update provider and channel settings.")
return
if not _confirm_prompt("Open configuration now?", default=True):
print("Use `ohmo config` when you want to change provider or channel settings.")
return
if interactive:
config = _run_gateway_config_wizard(root)
_print_gateway_config_summary(config)
print(f"Saved gateway config to {get_gateway_config_path(root)}")
@app.command("config")
def config_cmd(
cwd: str = typer.Option(str(Path.cwd()), "--cwd", help="Project working directory"),
workspace: str | None = typer.Option(None, "--workspace", help=_WORKSPACE_HELP),
) -> None:
"""Configure provider profile and gateway channels."""
cwd_path = str(Path(cwd).resolve())
workspace_root = initialize_workspace(workspace)
config = _run_gateway_config_wizard(workspace_root)
_print_gateway_config_summary(config)
print(f"Saved gateway config to {get_gateway_config_path(workspace_root)}")
_maybe_restart_gateway(cwd=cwd_path, workspace=workspace_root)
@app.command("doctor")
def doctor_cmd(
cwd: str = typer.Option(str(Path.cwd()), "--cwd", help="Project working directory"),
workspace: str | None = typer.Option(None, "--workspace", help=_WORKSPACE_HELP),
) -> None:
"""Check .ohmo workspace and provider readiness."""
cwd_path = str(Path(cwd).resolve())
workspace_root = initialize_workspace(workspace)
health = workspace_health(workspace_root)
settings = load_settings()
statuses = AuthManager(settings).get_profile_statuses()
lines = ["ohmo doctor:"]
for name, ok in health.items():
lines.append(f"- {name}: {'ok' if ok else 'missing'}")
lines.append(f"- project_cwd: {cwd_path}")
lines.append(f"- workspace_root: {workspace_root}")
lines.append(f"- workspace_state: {get_state_path(workspace_root)}")
lines.append(f"- gateway_config: {get_gateway_config_path(workspace_root)}")
lines.append("- available_profiles:")
for name, info in statuses.items():
lines.append(
f" - {name}: {info['label']} ({'configured' if info['configured'] else 'missing auth'})"
)
print("\n".join(lines))
@memory_app.command("list")
def memory_list_cmd(workspace: str | None = typer.Option(None, "--workspace", help=_WORKSPACE_HELP)) -> None:
for path in list_memory_files(workspace):
print(path.name)
@memory_app.command("add")
def memory_add_cmd(
title: str = typer.Argument(...),
content: str = typer.Argument(...),
workspace: str | None = typer.Option(None, "--workspace", help=_WORKSPACE_HELP),
) -> None:
path = add_memory_entry(workspace, title, content)
print(f"Added memory entry {path.name}")
@memory_app.command("remove")
def memory_remove_cmd(
name: str = typer.Argument(...),
workspace: str | None = typer.Option(None, "--workspace", help=_WORKSPACE_HELP),
) -> None:
if remove_memory_entry(workspace, name):
print(f"Removed memory entry {name}")
return
print(f"Memory entry not found: {name}", file=sys.stderr)
raise typer.Exit(1)
def _show_or_edit(path: Path, set_text: str | None) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
if set_text is not None:
path.write_text(set_text.strip() + "\n", encoding="utf-8")
print(f"Updated {path}")
return
if not path.exists():
print(f"{path} does not exist yet.", file=sys.stderr)
raise typer.Exit(1)
print(path.read_text(encoding="utf-8"))
@soul_app.command("show")
def soul_show_cmd(workspace: str | None = typer.Option(None, "--workspace", help=_WORKSPACE_HELP)) -> None:
_show_or_edit(get_soul_path(workspace), None)
@soul_app.command("edit")
def soul_edit_cmd(
workspace: str | None = typer.Option(None, "--workspace", help=_WORKSPACE_HELP),
set_text: str | None = typer.Option(None, "--set", help="Replace soul.md with this text"),
) -> None:
_show_or_edit(get_soul_path(workspace), set_text)
@user_app.command("show")
def user_show_cmd(workspace: str | None = typer.Option(None, "--workspace", help=_WORKSPACE_HELP)) -> None:
_show_or_edit(get_user_path(workspace), None)
@user_app.command("edit")
def user_edit_cmd(
workspace: str | None = typer.Option(None, "--workspace", help=_WORKSPACE_HELP),
set_text: str | None = typer.Option(None, "--set", help="Replace user.md with this text"),
) -> None:
_show_or_edit(get_user_path(workspace), set_text)
@gateway_app.command("run")
def gateway_run_cmd(
cwd: str = typer.Option(str(Path.cwd()), "--cwd", help="Project working directory"),
workspace: str | None = typer.Option(None, "--workspace", help=_WORKSPACE_HELP),
console_log: bool = typer.Option(True, "--console-log/--no-console-log", hidden=True),
log_file: bool = typer.Option(True, "--log-file/--no-log-file", hidden=True),
) -> None:
"""Run the ohmo gateway in the foreground."""
_configure_gateway_logging(workspace, console=console_log, log_file=log_file)
service = OhmoGatewayService(cwd, workspace)
raise SystemExit(asyncio.run(service.run_foreground()))
@gateway_app.command("start")
def gateway_start_cmd(
cwd: str = typer.Option(str(Path.cwd()), "--cwd", help="Project working directory"),
workspace: str | None = typer.Option(None, "--workspace", help=_WORKSPACE_HELP),
) -> None:
pid = start_gateway_process(cwd, workspace)
print(f"ohmo gateway started (pid={pid})")
@gateway_app.command("stop")
def gateway_stop_cmd(
cwd: str = typer.Option(str(Path.cwd()), "--cwd", help="Project working directory"),
workspace: str | None = typer.Option(None, "--workspace", help=_WORKSPACE_HELP),
) -> None:
if stop_gateway_process(cwd, workspace):
print("ohmo gateway stopped.")
return
print("ohmo gateway is not running.")
@gateway_app.command("restart")
def gateway_restart_cmd(
cwd: str = typer.Option(str(Path.cwd()), "--cwd", help="Project working directory"),
workspace: str | None = typer.Option(None, "--workspace", help=_WORKSPACE_HELP),
) -> None:
stop_gateway_process(cwd, workspace)
pid = start_gateway_process(cwd, workspace)
print(f"ohmo gateway restarted (pid={pid})")
@gateway_app.command("status")
def gateway_status_cmd(
cwd: str = typer.Option(str(Path.cwd()), "--cwd", help="Project working directory"),
workspace: str | None = typer.Option(None, "--workspace", help=_WORKSPACE_HELP),
) -> None:
state = gateway_status(cwd, workspace)
print(state.model_dump_json(indent=2))