Merge branch 'main' into bb/gui
This commit is contained in:
+220
-24
@@ -270,11 +270,20 @@ import time as _time
|
||||
from datetime import datetime
|
||||
|
||||
from hermes_cli import __version__, __release_date__
|
||||
from hermes_constants import AI_GATEWAY_BASE_URL, OPENROUTER_BASE_URL
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _is_termux_startup_environment(env: dict[str, str] | None = None) -> bool:
|
||||
"""Import-safe Termux check for cold-start-sensitive CLI paths."""
|
||||
check = env or os.environ
|
||||
prefix = str(check.get("PREFIX", ""))
|
||||
return bool(
|
||||
check.get("TERMUX_VERSION")
|
||||
or "com.termux/files/usr" in prefix
|
||||
or prefix.startswith("/data/data/com.termux/")
|
||||
)
|
||||
|
||||
|
||||
def _relative_time(ts) -> str:
|
||||
"""Format a timestamp as relative time (e.g., '2h ago', 'yesterday')."""
|
||||
if not ts:
|
||||
@@ -976,6 +985,72 @@ def _tui_need_npm_install(root: Path) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
_TUI_BUILD_INPUT_DIRS = (
|
||||
"src",
|
||||
"packages/hermes-ink/src",
|
||||
)
|
||||
|
||||
_TUI_BUILD_INPUT_FILES = (
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"tsconfig.json",
|
||||
"tsconfig.build.json",
|
||||
"babel.compiler.config.cjs",
|
||||
"scripts/build.mjs",
|
||||
"packages/hermes-ink/package.json",
|
||||
"packages/hermes-ink/package-lock.json",
|
||||
"packages/hermes-ink/index.js",
|
||||
"packages/hermes-ink/text-input.js",
|
||||
)
|
||||
|
||||
_TUI_BUILD_INPUT_SUFFIXES = frozenset(
|
||||
{".cjs", ".js", ".jsx", ".json", ".mjs", ".ts", ".tsx"}
|
||||
)
|
||||
|
||||
|
||||
def _iter_tui_build_inputs(root: Path):
|
||||
"""Yield source/config files that affect ``ui-tui/dist/entry.js``."""
|
||||
for rel in _TUI_BUILD_INPUT_FILES:
|
||||
path = root / rel
|
||||
if path.is_file():
|
||||
yield path
|
||||
|
||||
for rel in _TUI_BUILD_INPUT_DIRS:
|
||||
base = root / rel
|
||||
if not base.is_dir():
|
||||
continue
|
||||
for path in base.rglob("*"):
|
||||
if path.is_file() and path.suffix in _TUI_BUILD_INPUT_SUFFIXES:
|
||||
yield path
|
||||
|
||||
|
||||
def _tui_need_rebuild(root: Path) -> bool:
|
||||
"""True when ``dist/entry.js`` is missing or older than TUI inputs.
|
||||
|
||||
The TUI bundle is self-contained. Rebuilding it on every launch adds a
|
||||
visible cold-start tax on slow Termux CPUs, while a simple mtime freshness
|
||||
check still rebuilds immediately after source updates, dependency updates,
|
||||
or local edits. Set ``HERMES_TUI_FORCE_BUILD=1`` to force the old behaviour.
|
||||
"""
|
||||
force = (os.environ.get("HERMES_TUI_FORCE_BUILD") or "").strip().lower()
|
||||
if force in {"1", "true", "yes", "on"}:
|
||||
return True
|
||||
|
||||
entry = root / "dist" / "entry.js"
|
||||
try:
|
||||
output_mtime = entry.stat().st_mtime
|
||||
except OSError:
|
||||
return True
|
||||
|
||||
for path in _iter_tui_build_inputs(root):
|
||||
try:
|
||||
if path.stat().st_mtime > output_mtime:
|
||||
return True
|
||||
except OSError:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _ensure_tui_node() -> None:
|
||||
"""Make sure `node` + `npm` are on PATH for the TUI.
|
||||
|
||||
@@ -1090,6 +1165,7 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]:
|
||||
|
||||
# 2. Normal flow: npm install if needed, always esbuild, then node dist/entry.js.
|
||||
# --dev flow: npm install if needed, then tsx src/entry.tsx.
|
||||
did_install = False
|
||||
if _tui_need_npm_install(tui_dir):
|
||||
npm = _node_bin("npm")
|
||||
if not os.environ.get("HERMES_QUIET"):
|
||||
@@ -1109,6 +1185,7 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]:
|
||||
if preview:
|
||||
print(preview)
|
||||
sys.exit(1)
|
||||
did_install = True
|
||||
|
||||
if tui_dev:
|
||||
# Keep the local @hermes/ink package exports in sync with source.
|
||||
@@ -1137,21 +1214,28 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]:
|
||||
return [str(tsx), "src/entry.tsx"], tui_dir
|
||||
return [npm, "start"], tui_dir
|
||||
|
||||
# Always rebuild — esbuild is fast and this avoids staleness-edge-case bugs.
|
||||
npm = _node_bin("npm")
|
||||
result = subprocess.run(
|
||||
[npm, "run", "build"],
|
||||
cwd=str(tui_dir),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
combined = f"{result.stdout or ''}{result.stderr or ''}".strip()
|
||||
preview = "\n".join(combined.splitlines()[-30:])
|
||||
print("TUI build failed.")
|
||||
if preview:
|
||||
print(preview)
|
||||
sys.exit(1)
|
||||
# Desktop/dev launches retain the historical "always rebuild" behaviour.
|
||||
# Termux cold starts use the freshness check because esbuild startup is
|
||||
# expensive on old mobile CPUs.
|
||||
should_build = True
|
||||
if _is_termux_startup_environment():
|
||||
should_build = did_install or _tui_need_rebuild(tui_dir)
|
||||
|
||||
if should_build:
|
||||
npm = _node_bin("npm")
|
||||
result = subprocess.run(
|
||||
[npm, "run", "build"],
|
||||
cwd=str(tui_dir),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
combined = f"{result.stdout or ''}{result.stderr or ''}".strip()
|
||||
preview = "\n".join(combined.splitlines()[-30:])
|
||||
print("TUI build failed.")
|
||||
if preview:
|
||||
print(preview)
|
||||
sys.exit(1)
|
||||
|
||||
node = _node_bin("node")
|
||||
return [node, str(tui_dir / "dist" / "entry.js")], tui_dir
|
||||
@@ -1413,6 +1497,29 @@ def cmd_chat(args):
|
||||
# If resolution fails, keep the original value — _init_agent will
|
||||
# report "Session not found" with the original input
|
||||
|
||||
# xAI retirement warning — one-shot, non-blocking, never fails startup
|
||||
try:
|
||||
from hermes_cli.xai_retirement import (
|
||||
MIGRATION_GUIDE_URL,
|
||||
RETIREMENT_DATE,
|
||||
find_retired_xai_refs,
|
||||
format_issue,
|
||||
)
|
||||
from hermes_cli.config import load_config as _load_config_for_xai_check
|
||||
|
||||
_retired_xai_refs = find_retired_xai_refs(_load_config_for_xai_check())
|
||||
if _retired_xai_refs:
|
||||
sys.stderr.write(
|
||||
f"\033[33m⚠ xAI retires {len(_retired_xai_refs)} model(s) "
|
||||
f"in your config on {RETIREMENT_DATE}:\033[0m\n"
|
||||
)
|
||||
for _ref in _retired_xai_refs:
|
||||
sys.stderr.write(f" \033[33m⚠\033[0m {format_issue(_ref)}\n")
|
||||
sys.stderr.write(f" \033[2mMigration guide: {MIGRATION_GUIDE_URL}\033[0m\n")
|
||||
sys.stderr.write(" \033[2mRun 'hermes doctor' for details.\033[0m\n\n")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# First-run guard: check if any provider is configured before launching
|
||||
if not _has_any_provider_configured():
|
||||
print()
|
||||
@@ -2592,6 +2699,7 @@ def _prompt_provider_choice(choices, *, default=0):
|
||||
|
||||
def _model_flow_openrouter(config, current_model=""):
|
||||
"""OpenRouter provider: ensure API key, then pick model."""
|
||||
from hermes_constants import OPENROUTER_BASE_URL
|
||||
from hermes_cli.auth import (
|
||||
ProviderConfig,
|
||||
_prompt_model_selection,
|
||||
@@ -2652,6 +2760,7 @@ def _model_flow_openrouter(config, current_model=""):
|
||||
|
||||
def _model_flow_ai_gateway(config, current_model=""):
|
||||
"""Vercel AI Gateway provider: ensure API key, then pick model with pricing."""
|
||||
from hermes_constants import AI_GATEWAY_BASE_URL
|
||||
from hermes_cli.auth import (
|
||||
PROVIDER_REGISTRY,
|
||||
_prompt_model_selection,
|
||||
@@ -4245,8 +4354,11 @@ def _model_flow_named_custom(config, provider_info):
|
||||
print(f" Provider: {name} ({base_url})")
|
||||
|
||||
|
||||
# Curated model lists for direct API-key providers — single source in models.py
|
||||
from hermes_cli.models import _PROVIDER_MODELS
|
||||
# Keep the historical eager model catalog import on desktop/CI. Termux defers
|
||||
# it to the model-selection handlers so plain `hermes --tui` does not pay for
|
||||
# requests/models.dev catalog imports before the Node TUI starts.
|
||||
if not _is_termux_startup_environment():
|
||||
from hermes_cli.models import _PROVIDER_MODELS
|
||||
|
||||
|
||||
def _current_reasoning_effort(config) -> str:
|
||||
@@ -4363,6 +4475,7 @@ def _model_flow_copilot(config, current_model=""):
|
||||
)
|
||||
from hermes_cli.config import save_env_value, load_config, save_config
|
||||
from hermes_cli.models import (
|
||||
_PROVIDER_MODELS,
|
||||
fetch_api_models,
|
||||
fetch_github_model_catalog,
|
||||
github_model_reasoning_efforts,
|
||||
@@ -4555,6 +4668,7 @@ def _model_flow_copilot_acp(config, current_model=""):
|
||||
resolve_external_process_provider_credentials,
|
||||
)
|
||||
from hermes_cli.models import (
|
||||
_PROVIDER_MODELS,
|
||||
fetch_github_model_catalog,
|
||||
normalize_copilot_model_id,
|
||||
)
|
||||
@@ -4758,6 +4872,7 @@ def _model_flow_kimi(config, current_model=""):
|
||||
load_config,
|
||||
save_config,
|
||||
)
|
||||
from hermes_cli.models import _PROVIDER_MODELS
|
||||
|
||||
provider_id = "kimi-coding"
|
||||
pconfig = PROVIDER_REGISTRY[provider_id]
|
||||
@@ -4868,7 +4983,7 @@ def _model_flow_stepfun(config, current_model=""):
|
||||
load_config,
|
||||
save_config,
|
||||
)
|
||||
from hermes_cli.models import fetch_api_models
|
||||
from hermes_cli.models import _PROVIDER_MODELS, fetch_api_models
|
||||
|
||||
provider_id = "stepfun"
|
||||
pconfig = PROVIDER_REGISTRY[provider_id]
|
||||
@@ -5248,6 +5363,7 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""):
|
||||
save_config,
|
||||
)
|
||||
from hermes_cli.models import (
|
||||
_PROVIDER_MODELS,
|
||||
fetch_api_models,
|
||||
opencode_model_api_mode,
|
||||
normalize_opencode_model_id,
|
||||
@@ -7669,9 +7785,7 @@ def _install_python_dependencies_with_optional_fallback(
|
||||
|
||||
|
||||
def _is_termux_env(env: dict[str, str] | None = None) -> bool:
|
||||
check = env or os.environ
|
||||
prefix = str(check.get("PREFIX", ""))
|
||||
return "com.termux" in prefix or prefix.startswith("/data/data/com.termux/")
|
||||
return _is_termux_startup_environment(env)
|
||||
|
||||
|
||||
def _is_android_python() -> bool:
|
||||
@@ -10348,7 +10462,7 @@ _BUILTIN_SUBCOMMANDS = frozenset(
|
||||
"computer-use",
|
||||
"config", "cron", "curator", "dashboard", "debug", "doctor",
|
||||
"dump", "fallback", "gateway", "hooks", "import", "insights",
|
||||
"kanban", "login", "logout", "logs", "lsp", "mcp", "memory",
|
||||
"kanban", "login", "logout", "logs", "lsp", "mcp", "memory", "migrate",
|
||||
"model", "pairing", "plugins", "postinstall", "profile", "proxy",
|
||||
"send", "sessions", "setup",
|
||||
"skills", "slack", "status", "tools", "uninstall", "update",
|
||||
@@ -10442,6 +10556,47 @@ def _plugin_cli_discovery_needed() -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _try_termux_fast_tui_launch() -> bool:
|
||||
"""Launch obvious Termux TUI invocations before building every subparser.
|
||||
|
||||
`hermes --tui` is the hot path on phones. The full parser setup imports
|
||||
command modules for model, fallback, migrate, kanban, bundles, plugins,
|
||||
etc. even though the TUI immediately execs Node. On Termux only, parse the
|
||||
lightweight top-level/chat parser and hand off to ``cmd_chat`` when the
|
||||
invocation is unambiguously the built-in TUI/chat path.
|
||||
"""
|
||||
if not _is_termux_startup_environment():
|
||||
return False
|
||||
|
||||
if "-h" in sys.argv[1:] or "--help" in sys.argv[1:]:
|
||||
return False
|
||||
|
||||
wants_tui = os.environ.get("HERMES_TUI") == "1" or "--tui" in sys.argv[1:]
|
||||
if not wants_tui:
|
||||
return False
|
||||
|
||||
first = _first_positional_argv()
|
||||
if first not in {None, "chat"}:
|
||||
return False
|
||||
|
||||
from hermes_cli._parser import build_top_level_parser
|
||||
|
||||
parser, _subparsers, chat_parser = build_top_level_parser()
|
||||
chat_parser.set_defaults(func=cmd_chat)
|
||||
args = parser.parse_args(_coalesce_session_name_args(sys.argv[1:]))
|
||||
|
||||
# Preserve top-level behaviours whose semantics are not "launch chat/TUI".
|
||||
if getattr(args, "version", False) or getattr(args, "oneshot", None):
|
||||
return False
|
||||
if getattr(args, "command", None) not in {None, "chat"}:
|
||||
return False
|
||||
if not (getattr(args, "tui", False) or os.environ.get("HERMES_TUI") == "1"):
|
||||
return False
|
||||
|
||||
cmd_chat(args)
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point for hermes CLI."""
|
||||
# Force UTF-8 stdio on Windows before anything prints. No-op elsewhere.
|
||||
@@ -10459,6 +10614,9 @@ def main():
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if _try_termux_fast_tui_launch():
|
||||
return
|
||||
|
||||
from hermes_cli._parser import build_top_level_parser
|
||||
|
||||
parser, subparsers, chat_parser = build_top_level_parser()
|
||||
@@ -10555,6 +10713,44 @@ def main():
|
||||
)
|
||||
fallback_parser.set_defaults(func=cmd_fallback)
|
||||
|
||||
# =========================================================================
|
||||
# migrate command
|
||||
# =========================================================================
|
||||
from hermes_cli.migrate import cmd_migrate, cmd_migrate_xai
|
||||
|
||||
migrate_parser = subparsers.add_parser(
|
||||
"migrate",
|
||||
help="Migrate configuration for retired models or deprecated settings",
|
||||
description=(
|
||||
"Diagnose and (optionally) rewrite the active config.yaml to "
|
||||
"replace references to retired models or deprecated settings."
|
||||
),
|
||||
)
|
||||
migrate_subparsers = migrate_parser.add_subparsers(dest="migrate_type")
|
||||
|
||||
migrate_xai = migrate_subparsers.add_parser(
|
||||
"xai",
|
||||
help="Migrate xAI models scheduled for retirement on May 15, 2026",
|
||||
description=(
|
||||
"Scan config.yaml for references to xAI models retiring on "
|
||||
"May 15, 2026 and, with --apply, rewrite them in-place to the "
|
||||
"official replacements per the xAI migration guide. The original "
|
||||
"config.yaml is backed up before any rewrite."
|
||||
),
|
||||
)
|
||||
migrate_xai.add_argument(
|
||||
"--apply",
|
||||
action="store_true",
|
||||
help="Rewrite config.yaml in-place (default: dry-run, no writes)",
|
||||
)
|
||||
migrate_xai.add_argument(
|
||||
"--no-backup",
|
||||
action="store_true",
|
||||
help="Skip the timestamped backup of config.yaml when applying",
|
||||
)
|
||||
migrate_xai.set_defaults(func=cmd_migrate_xai)
|
||||
migrate_parser.set_defaults(func=cmd_migrate)
|
||||
|
||||
# =========================================================================
|
||||
# gateway command
|
||||
# =========================================================================
|
||||
|
||||
Reference in New Issue
Block a user