diff --git a/.envrc b/.envrc index 01232045f16..663714366d2 100644 --- a/.envrc +++ b/.envrc @@ -1,4 +1,4 @@ -watch_file pyproject.toml uv.lock +watch_file pyproject.toml uv.lock hermes watch_file package-lock.json package.json web/package.json ui-tui/package.json website/package.json apps/shared/package.json apps/desktop/package.json ui-tui/packages/hermes-ink/package.json watch_file flake.nix flake.lock nix/devShell.nix nix/tui.nix nix/package.nix nix/python.nix nix/hermes-agent.nix nix/desktop.nix diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts index f36d74ad73e..f057bca4f24 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts @@ -39,6 +39,7 @@ import { import { clearSessionSubagents, pruneDelegateFallbackSubagents, upsertSubagent } from '@/store/subagents' import { clearActiveSessionTodos } from '@/store/todos' import { recordToolDiff } from '@/store/tool-diffs' +import { reportInstallMethodWarning } from '@/store/updates' import { notifyWorkspaceChanged, toolMayMutateFiles } from '@/store/workspace-events' import type { RpcEvent } from '@/types/hermes' @@ -216,6 +217,10 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { requestDesktopOnboarding(payload.credential_warning) } + if (apply) { + reportInstallMethodWarning(payload?.install_warning) + } + void refreshHermesConfig() if (modelChanged || providerChanged) { diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts index 254a58e1298..d299fe51b7e 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts @@ -19,7 +19,7 @@ import { setSessions, setYoloActive } from '@/store/session' -import { reportBackendContract } from '@/store/updates' +import { reportBackendContract, reportInstallMethodWarning } from '@/store/updates' import type { SessionCreateResponse, SessionInfo, SessionRuntimeInfo } from '@/types/hermes' import type { ClientSessionState } from '../../../types' @@ -270,6 +270,8 @@ export function applyRuntimeInfo(info: SessionRuntimeInfo | undefined): SessionR requestDesktopOnboarding(info.credential_warning) } + reportInstallMethodWarning(info.install_warning) + if (typeof info.model === 'string') { setCurrentModel(info.model) sessionState.model = info.model diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index cd046f8f098..0ed3e0ff881 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -119,6 +119,7 @@ export const en: Translations = { backendOutOfDateTitle: 'Backend out of date', backendOutOfDateMessage: 'Your Hermes backend is older than this desktop build and may not work correctly. Update to align them.', + installMethodUnsupportedTitle: 'Unsupported install method', updateHermes: 'Update Hermes', updateReadyTitle: 'Update ready', updateReadyMessage: count => `${count} new change${count === 1 ? '' : 's'} available.`, diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 22792ea8fed..10088833cd6 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -120,6 +120,7 @@ export const ja = defineLocale({ backendOutOfDateTitle: 'バックエンドが古いです', backendOutOfDateMessage: 'Hermes バックエンドがこのデスクトップビルドより古く、正常に動作しない場合があります。更新して揃えてください。', + installMethodUnsupportedTitle: 'サポート対象外のインストール方法', updateHermes: 'Hermes を更新', updateReadyTitle: '更新の準備ができました', updateReadyMessage: count => `${count} 件の新しい変更が利用可能です。`, diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index b4e41e29ec5..5c10c3d6873 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -161,6 +161,7 @@ export interface Translations { copyDetailFailed: string backendOutOfDateTitle: string backendOutOfDateMessage: string + installMethodUnsupportedTitle: string updateHermes: string updateReadyTitle: string updateReadyMessage: (count: number) => string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index bcfed891ddf..095bf6e286f 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -116,6 +116,7 @@ export const zhHant = defineLocale({ copyDetailFailed: '無法複製通知詳情', backendOutOfDateTitle: '後端版本過舊', backendOutOfDateMessage: '您的 Hermes 後端早於目前的桌面版本,可能無法正常運作。請更新以保持一致。', + installMethodUnsupportedTitle: '不受支援的安裝方式', updateHermes: '更新 Hermes', updateReadyTitle: '有可用更新', updateReadyMessage: count => `有 ${count} 項新變更可用。`, diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 5e688a5c320..411bfcf6ac1 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -116,6 +116,7 @@ export const zh: Translations = { copyDetailFailed: '无法复制通知详情', backendOutOfDateTitle: '后端版本过旧', backendOutOfDateMessage: '你的 Hermes 后端早于当前桌面构建,可能无法正常工作。请更新以保持一致。', + installMethodUnsupportedTitle: '不受支持的安装方式', updateHermes: '更新 Hermes', updateReadyTitle: '有可用更新', updateReadyMessage: count => `有 ${count} 项新更改可用。`, diff --git a/apps/desktop/src/lib/chat-messages.ts b/apps/desktop/src/lib/chat-messages.ts index 47ac077cd6c..c6829420a1d 100644 --- a/apps/desktop/src/lib/chat-messages.ts +++ b/apps/desktop/src/lib/chat-messages.ts @@ -51,6 +51,7 @@ export type GatewayEventPayload = { cwd?: string branch?: string credential_warning?: string + install_warning?: string personality?: string usage?: Partial // agent.terminal.output — live chunk for a read-only agent terminal tab diff --git a/apps/desktop/src/store/updates.ts b/apps/desktop/src/store/updates.ts index bb5a6828488..b07347fa88b 100644 --- a/apps/desktop/src/store/updates.ts +++ b/apps/desktop/src/store/updates.ts @@ -111,6 +111,24 @@ function isSkewToastSnoozed(): boolean { return Number.isFinite(until) && Date.now() < until } +const INSTALL_METHOD_TOAST_ID = 'install-method-not-supported' +// Same time-based snooze pattern as the update/skew toasts: the warning is +// re-derived from every session.info (session.create/resume/activate all +// route through applyRuntimeInfo), so without a snooze it would re-pop on +// every session switch even right after the user dismissed it. +const INSTALL_METHOD_TOAST_SNOOZE_KEY = 'hermes:install-method-toast-snooze-until' +const INSTALL_METHOD_TOAST_COOLDOWN_MS = 24 * 60 * 60 * 1000 + +function snoozeInstallMethodToast(): void { + persistString(INSTALL_METHOD_TOAST_SNOOZE_KEY, String(Date.now() + INSTALL_METHOD_TOAST_COOLDOWN_MS)) +} + +function isInstallMethodToastSnoozed(): boolean { + const until = Number(storedString(INSTALL_METHOD_TOAST_SNOOZE_KEY) || 0) + + return Number.isFinite(until) && Date.now() < until +} + /** * Guard against a desktop GUI talking to a backend that predates its contract * (e.g. a bb/gui-built app pointed at a `main` checkout). Rather than failing @@ -151,6 +169,27 @@ export function reportBackendContract(contract: number | undefined): void { }) } +export function reportInstallMethodWarning(message: string | undefined): void { + if (!message) { + dismissNotification(INSTALL_METHOD_TOAST_ID) + + return + } + + if (isInstallMethodToastSnoozed()) { + return + } + + notify({ + durationMs: 0, + id: INSTALL_METHOD_TOAST_ID, + kind: 'warning', + message, + onDismiss: () => snoozeInstallMethodToast(), + title: translateNow('notifications.installMethodUnsupportedTitle') + }) +} + /** * Fire a toast when an update is available, at most once per cooldown window. * Closing the toast — dismissing it or opening the updates window from it — diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index 63ac7f9de40..9133fb4dfbf 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -399,6 +399,7 @@ export interface SessionRuntimeInfo { cwd?: string desktop_contract?: number fast?: boolean + install_warning?: string model?: string personality?: string provider?: string diff --git a/hermes_cli/banner.py b/hermes_cli/banner.py index 01c64c7cdaf..7b0beef7bb3 100644 --- a/hermes_cli/banner.py +++ b/hermes_cli/banner.py @@ -2,7 +2,6 @@ Pure display functions with no HermesCLI state dependency. """ - import json import logging import os @@ -322,8 +321,8 @@ def check_for_updates() -> Optional[int]: # both the Rich banner (build_welcome_banner) and the Ink badge # (branding.tsx, guarded on `typeof === 'number' && > 0`) show nothing. try: - from hermes_cli.config import detect_install_method - if detect_install_method() == "docker": + from hermes_cli.config import detect_install_method, get_project_root + if detect_install_method(get_project_root()) == "docker": return None except Exception: pass @@ -889,17 +888,23 @@ def build_welcome_banner(console: "Console", model: str, cwd: str, except Exception: pass # Never break the banner over an update check - # Pip-install warning — `pip install hermes-agent` is not the supported - # install path (it exists on PyPI for internal/CI reasons, not end users). - # Such installs miss the git checkout + installer-managed deps, so updates, - # self-update, and issue triage don't behave correctly. Warn, don't block. + # Unsupported install-method warning — pip/PyPI and Homebrew are no + # longer an officially supported distribution method (see + # website/docs/getting-started/platform-support.md). Such installs miss + # the git checkout + installer-managed deps, so updates, self-update, and + # issue triage don't behave correctly. Warn, don't block. NixOS is fully + # supported and never hits this. try: - from hermes_cli.config import detect_install_method - if detect_install_method() == "pip": + from hermes_cli.config import ( + detect_install_method, + format_unsupported_install_warning, + is_unsupported_install_method, + get_project_root + ) + _install_method = detect_install_method(get_project_root()) + if is_unsupported_install_method(_install_method): right_lines.append( - "[bold yellow]⚠ pip install not officially supported[/]" - "[dim yellow] — exists for reasons other than user install; " - "expect instability and an inability to support issues[/]" + f"[bold yellow]⚠ {format_unsupported_install_warning(_install_method)}[/]" ) except Exception: pass # Never break the banner over the install-method check diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 184350ae3a4..e24cc220f4f 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -441,8 +441,20 @@ def detect_install_method(project_root: Optional[Path] = None) -> str: managed = get_managed_system() if managed: return managed.lower().replace(" ", "-") - if (root / ".git").is_dir(): + + # detect git repo installs (normal installer, development env) + git_path = root / ".git" + if git_path.is_dir(): return "git" + + # detect git repo installs from worktrees + if git_path.is_file(): + try: + content = git_path.read_text(encoding="utf-8").strip() + if content.startswith("gitdir:"): + return "git" + except OSError: + pass return "pip" @@ -528,10 +540,54 @@ def recommended_update_command() -> str: managed_cmd = get_managed_update_command() if managed_cmd: return managed_cmd - method = detect_install_method() + method = detect_install_method(get_project_root()) return recommended_update_command_for_method(method) +# ============================================================================= +# Unsupported install methods (pip, Homebrew) — deprecation notice +# ============================================================================= +# +# pip/PyPI and Homebrew are NOT an officially supported distribution method +# (see website/docs/getting-started/platform-support.md, "Unsupported" +# section). pip exists on PyPI for internal/CI reasons, not end-user installs; +# Homebrew is a legacy packaging path. Unlike NixOS/Homebrew "managed mode" +# (which hard-blocks config writes), this is a warn-don't-block deprecation +# notice surfaced everywhere the user might see install-method state: the CLI +# banner, the TUI/desktop session info panel, and ``hermes update``. NixOS +# stays fully supported (Tier 2) and must never hit this path. + +PLATFORM_SUPPORT_DOCS_URL = "https://hermes-agent.nousresearch.com/docs/getting-started/platform-support" + +_UNSUPPORTED_INSTALL_METHODS = frozenset({"pip", "homebrew"}) + + +def is_unsupported_install_method(method: str) -> bool: + """Whether ``method`` (from ``detect_install_method()``) is deprecated.""" + return method in _UNSUPPORTED_INSTALL_METHODS + + +def unsupported_install_method_label(method: str) -> str: + """Human-readable name for an unsupported install method.""" + return "pip" if method == "pip" else "Homebrew" + + +def format_unsupported_install_warning(method: str) -> str: + """Plain-text (no markup) deprecation notice for pip/Homebrew installs. + + Shared verbatim across the CLI banner, TUI/desktop ``session.info``, and + ``hermes update`` / ``hermes update --check`` so the wording — and the + docs link — stays consistent across every surface instead of drifting + into three slightly different warnings. + """ + label = unsupported_install_method_label(method) + return ( + f"{label} installs are no longer an officially supported platform and " + f"will not receive further updates. See {PLATFORM_SUPPORT_DOCS_URL} " + "for supported install methods." + ) + + # Long-form text for ``hermes update`` / ``--check`` when running inside the # Docker image. Surfaced by ``cmd_update`` and ``_cmd_update_check`` in # hermes_cli/main.py; lives here so the wording stays consistent and we diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 7326badcda8..f21388c2181 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -229,9 +229,9 @@ def _read_openai_version_fast() -> str | None: def _print_fast_version_info() -> None: from hermes_cli import __release_date__, __version__ - project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir)) print(f"Hermes Agent v{__version__} ({__release_date__})") - print(f"Project: {project_root}") + print(f"Install directory: {PROJECT_ROOT}") + print(f"Python: {sys.version.split()[0]}") openai_version = _read_openai_version_fast() @@ -4329,10 +4329,12 @@ def cmd_import(args): def _print_version_info(*, check_updates: bool = True) -> None: + from hermes_cli.config import detect_install_method from hermes_cli.banner import format_banner_version_label print(format_banner_version_label()) - print(f"Project: {PROJECT_ROOT}") + print(f"Install directory: {PROJECT_ROOT}") + print(f"Install method: {detect_install_method(PROJECT_ROOT)}") # Show Python version print(f"Python: {sys.version.split()[0]}") @@ -8371,8 +8373,14 @@ def _cmd_update_check(branch: str = "main", *, branch_explicit: bool = False): on a PyPI install we surface a one-line notice instead of silently dropping the flag. """ - from hermes_cli.config import detect_install_method + from hermes_cli.config import ( + detect_install_method, + format_unsupported_install_warning, + is_unsupported_install_method, + ) method = detect_install_method(PROJECT_ROOT) + if is_unsupported_install_method(method): + print(f"⚠ {format_unsupported_install_warning(method)}") if method == "docker": # Docker can't ``git fetch`` from within the container. Surface the # same long-form ``docker pull`` guidance ``hermes update`` (apply @@ -9255,10 +9263,21 @@ def cmd_update(args): from hermes_cli.config import ( detect_install_method, format_docker_update_message, + format_unsupported_install_warning, is_managed, + is_unsupported_install_method, managed_error, ) + # Deprecation notice for pip/Homebrew installs — printed before the + # managed-mode early-return below so Homebrew users (who are blocked from + # applying the update here) still see it. Warn, don't block: the update + # itself still proceeds (except Homebrew, which is managed-mode blocked + # for an unrelated reason — brew owns its own upgrade path). + _install_method_for_warning = detect_install_method(PROJECT_ROOT) + if is_unsupported_install_method(_install_method_for_warning): + print(f"⚠ {format_unsupported_install_warning(_install_method_for_warning)}") + if is_managed(): managed_error("update Hermes Agent") return diff --git a/nix/devShell.nix b/nix/devShell.nix index 28fb380a913..b7dd91fae11 100644 --- a/nix/devShell.nix +++ b/nix/devShell.nix @@ -28,13 +28,20 @@ packages = with pkgs; [ + (pkgs.runCommand "hermes" { } '' + mkdir -p $out/bin + install -Dm755 ${../hermes} $out/bin/hermes + '') uv ] ++ self'.packages.default.passthru.devDeps; shellHook = '' - echo "Hermes Agent dev shell" ${combinedNonNpm} ${hermesNpmLib.mkNpmDevShellHook npmPackageJsonPaths} + + # for the devshell to pick up the src + export HERMES_PYTHON_SRC_ROOT=$(git rev-parse --show-toplevel) + echo "Hermes Agent dev shell in $HERMES_PYTHON_SRC_ROOT" echo "Ready. Run 'hermes' to start." ''; }; diff --git a/nix/hermes-agent.nix b/nix/hermes-agent.nix index 3d7dc260f5e..4013e80c1fe 100644 --- a/nix/hermes-agent.nix +++ b/nix/hermes-agent.nix @@ -44,7 +44,7 @@ let dependency-groups = [ "all" ] ++ extraDependencyGroups; }; - hermesVenv = mkHermesVenv extraDependencyGroups; + hermesVenv = (mkHermesVenv extraDependencyGroups).venv; hermesNpmLib = callPackage ./lib.nix { inherit npm-lockfile-fix nodejs; @@ -200,33 +200,37 @@ stdenv.mkDerivation (finalAttrs: { runHook postInstall ''; - passthru = { - inherit - hermesTui - hermesWeb - hermesNpmLib - hermesVenv - ; + passthru = + let + devPython = (mkHermesVenv (extraDependencyGroups ++ [ "dev" ])).editableVenv; + in + { + inherit + hermesTui + hermesWeb + hermesNpmLib + hermesVenv + ; - # `hermesDesktop` references `finalAttrs.finalPackage` (this whole - # derivation, after all overrides are applied) so the desktop wrapper - # can prepend its `/bin` to PATH. The desktop's resolver step 4 - # ("existing hermes on PATH") then picks up the fully wrapped - # `hermes` binary — venv with all deps, bundled skills/plugins, - # runtime PATH (ripgrep/git/ffmpeg/etc). No re-implementation - # of the agent resolution in the desktop wrapper. - hermesDesktop = callPackage ./desktop.nix { - inherit hermesNpmLib electron; - hermesAgent = finalAttrs.finalPackage; + # `hermesDesktop` references `finalAttrs.finalPackage` (this whole + # derivation, after all overrides are applied) so the desktop wrapper + # can prepend its `/bin` to PATH. The desktop's resolver step 4 + # ("existing hermes on PATH") then picks up the fully wrapped + # `hermes` binary — venv with all deps, bundled skills/plugins, + # runtime PATH (ripgrep/git/ffmpeg/etc). No re-implementation + # of the agent resolution in the desktop wrapper. + hermesDesktop = callPackage ./desktop.nix { + inherit hermesNpmLib electron; + hermesAgent = finalAttrs.finalPackage; + }; + + devShellHook = '' + export HERMES_PYTHON=${devPython}/bin/python3 + ''; + + devDeps = runtimeDeps ++ [ devPython ]; }; - devShellHook = '' - export HERMES_PYTHON=${hermesVenv}/bin/python3 - ''; - - devDeps = runtimeDeps ++ [ (mkHermesVenv (extraDependencyGroups ++ [ "dev" ])) ]; - }; - meta = with lib; { description = "AI agent with advanced tool-calling capabilities"; homepage = "https://github.com/NousResearch/hermes-agent"; diff --git a/nix/python.nix b/nix/python.nix index 16d8eaedad6..0ba5c1717ad 100644 --- a/nix/python.nix +++ b/nix/python.nix @@ -27,7 +27,8 @@ let dependency-groups = { }; }; - mkPrebuiltOverride = final: from: dependencies: + mkPrebuiltOverride = + final: from: dependencies: hacks.nixpkgsPrebuilt { inherit from; prev = { @@ -38,64 +39,100 @@ let # Legacy alibabacloud packages ship only sdists with setup.py/setup.cfg # and no pyproject.toml, so setuptools isn't declared as a build dep. - buildSystemOverrides = final: prev: builtins.mapAttrs - (name: _: prev.${name}.overrideAttrs (old: { - nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ final.setuptools ]; - })) - (lib.genAttrs [ - "alibabacloud-credentials-api" - "alibabacloud-endpoint-util" - "alibabacloud-gateway-dingtalk" - "alibabacloud-gateway-spi" - "alibabacloud-tea" - ] (_: null)); + buildSystemOverrides = + final: prev: + builtins.mapAttrs + ( + name: _: + prev.${name}.overrideAttrs (old: { + nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ final.setuptools ]; + }) + ) + ( + lib.genAttrs [ + "alibabacloud-credentials-api" + "alibabacloud-endpoint-util" + "alibabacloud-gateway-dingtalk" + "alibabacloud-gateway-spi" + "alibabacloud-tea" + ] (_: null) + ); - pythonPackageOverrides = final: _prev: - if isAarch64Darwin then { - numpy = mkPrebuiltOverride final python312.pkgs.numpy { }; + pythonPackageOverrides = + final: _prev: + if isAarch64Darwin then + { + numpy = mkPrebuiltOverride final python312.pkgs.numpy { }; - pyarrow = mkPrebuiltOverride final python312.pkgs.pyarrow { }; + pyarrow = mkPrebuiltOverride final python312.pkgs.pyarrow { }; - av = mkPrebuiltOverride final python312.pkgs.av { }; + av = mkPrebuiltOverride final python312.pkgs.av { }; - humanfriendly = mkPrebuiltOverride final python312.pkgs.humanfriendly { }; + humanfriendly = mkPrebuiltOverride final python312.pkgs.humanfriendly { }; - coloredlogs = mkPrebuiltOverride final python312.pkgs.coloredlogs { - humanfriendly = [ ]; - }; + coloredlogs = mkPrebuiltOverride final python312.pkgs.coloredlogs { + humanfriendly = [ ]; + }; - onnxruntime = mkPrebuiltOverride final python312.pkgs.onnxruntime { - coloredlogs = [ ]; - numpy = [ ]; - packaging = [ ]; - }; + onnxruntime = mkPrebuiltOverride final python312.pkgs.onnxruntime { + coloredlogs = [ ]; + numpy = [ ]; + packaging = [ ]; + }; - ctranslate2 = mkPrebuiltOverride final python312.pkgs.ctranslate2 { - numpy = [ ]; - pyyaml = [ ]; - }; + ctranslate2 = mkPrebuiltOverride final python312.pkgs.ctranslate2 { + numpy = [ ]; + pyyaml = [ ]; + }; - faster-whisper = mkPrebuiltOverride final python312.pkgs.faster-whisper { - av = [ ]; - ctranslate2 = [ ]; - huggingface-hub = [ ]; - onnxruntime = [ ]; - tokenizers = [ ]; - tqdm = [ ]; - }; - } else {}; + faster-whisper = mkPrebuiltOverride final python312.pkgs.faster-whisper { + av = [ ]; + ctranslate2 = [ ]; + huggingface-hub = [ ]; + onnxruntime = [ ]; + tokenizers = [ ]; + tqdm = [ ]; + }; + } + else + { }; pythonSet = (callPackage pyproject-nix.build.packages { python = python312; }).overrideScope - (lib.composeManyExtensions [ - pyproject-build-systems.overlays.default - overlay - buildSystemOverrides - pythonPackageOverrides - ]); + ( + lib.composeManyExtensions [ + pyproject-build-systems.overlays.default + overlay + buildSystemOverrides + pythonPackageOverrides + ] + ); + + editableOverlay = workspace.mkEditablePyprojectOverlay { + root = "$HERMES_PYTHON_SRC_ROOT"; # resolved at shellHook time + }; + + workspaceRoot = ./..; + editableSet = pythonSet.overrideScope ( + lib.composeManyExtensions [ + editableOverlay + (final: prev: { + hermes-agent = prev.hermes-agent.overrideAttrs (old: { + # point straight at the real source instead of the filtered nix store copy + src = workspaceRoot; + nativeBuildInputs = old.nativeBuildInputs ++ final.resolveBuildSystem { editables = [ ]; }; + }); + }) + ] + ); in -pythonSet.mkVirtualEnv "hermes-agent-env" { - hermes-agent = dependency-groups; +{ + venv = pythonSet.mkVirtualEnv "hermes-agent-env" { + hermes-agent = dependency-groups; + }; + editableVenv = editableSet.mkVirtualEnv "hermes-agent-editable-env" { + hermes-agent = dependency-groups; + }; } diff --git a/tests/hermes_cli/test_pip_install_detection.py b/tests/hermes_cli/test_pip_install_detection.py index 673ea568759..c852b57052a 100644 --- a/tests/hermes_cli/test_pip_install_detection.py +++ b/tests/hermes_cli/test_pip_install_detection.py @@ -195,7 +195,33 @@ def test_banner_warns_on_pip_install(tmp_path): out = buf.getvalue() assert "officially" in out - assert "instability" in out + assert "platform-support" in out + + +def test_banner_warns_on_homebrew_install(tmp_path): + """The welcome banner surfaces a warning when the install method is homebrew.""" + import io + from rich.console import Console + from hermes_cli import banner + + hh = tmp_path / ".hermes" + hh.mkdir() + (hh / ".install_method").write_text("homebrew\n") + + with patch("hermes_cli.config.get_hermes_home", return_value=hh), \ + patch("hermes_constants.get_hermes_home", return_value=hh): + buf = io.StringIO() + console = Console(file=buf, width=400, force_terminal=False, color_system=None) + banner.build_welcome_banner( + console, model="m", cwd="/tmp", + tools=[{"function": {"name": "terminal"}}], + enabled_toolsets=["terminal"], + ) + out = buf.getvalue() + + assert "officially" in out + assert "Homebrew" in out + assert "platform-support" in out def test_banner_no_pip_warning_on_git_install(tmp_path): diff --git a/tests/hermes_cli/test_tui_resume_flow.py b/tests/hermes_cli/test_tui_resume_flow.py index 34de95b826d..7c83ff2a502 100644 --- a/tests/hermes_cli/test_tui_resume_flow.py +++ b/tests/hermes_cli/test_tui_resume_flow.py @@ -408,7 +408,7 @@ def test_termux_ultrafast_version_runs_before_heavy_startup( out = capsys.readouterr().out assert "Hermes Agent v" in out - assert "Project:" in out + assert "Install directory:" in out assert "Python:" in out assert "OpenAI SDK:" in out diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 5e4dfea99d9..6f58d35d82c 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -4727,6 +4727,25 @@ def test_session_info_includes_session_title(monkeypatch): assert info["title"] == "Dashboard title" +def test_session_info_includes_install_warning_for_pip(monkeypatch): + """pip installs surface install_warning; git installs don't (issue: pip/brew deprecation).""" + monkeypatch.setattr("hermes_cli.config.detect_install_method", lambda: "pip") + + info = server._session_info(types.SimpleNamespace(tools=[], model="", provider="")) + + assert "install_warning" in info + assert "pip" in info["install_warning"] + assert "platform-support" in info["install_warning"] + + +def test_session_info_omits_install_warning_for_git(monkeypatch): + monkeypatch.setattr("hermes_cli.config.detect_install_method", lambda: "git") + + info = server._session_info(types.SimpleNamespace(tools=[], model="", provider="")) + + assert "install_warning" not in info + + # --------------------------------------------------------------------------- # History-mutating commands must reject while session.running is True. # Without these guards, prompt.submit's post-run history write either diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 3c0e8372d68..b5fa9857946 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -3238,6 +3238,18 @@ def _session_info(agent, session: dict | None = None) -> dict: "usage": _get_usage(agent), "profile_name": _current_profile_name(), } + try: + from hermes_cli.config import ( + detect_install_method, + format_unsupported_install_warning, + is_unsupported_install_method, + ) + + _install_method = detect_install_method() + if is_unsupported_install_method(_install_method): + info["install_warning"] = format_unsupported_install_warning(_install_method) + except Exception: + pass try: from hermes_cli import __version__, __release_date__ diff --git a/ui-tui/src/components/branding.tsx b/ui-tui/src/components/branding.tsx index 136b97db90c..3c9cdd5f251 100644 --- a/ui-tui/src/components/branding.tsx +++ b/ui-tui/src/components/branding.tsx @@ -412,6 +412,12 @@ export function SessionPanel({ info, maxWidth, sid, t }: SessionPanelProps) { )} + + {info.install_warning && ( + + ! {info.install_warning} + + )} ) diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts index 4f7ffa225d2..14ab98ca18d 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -150,6 +150,7 @@ export interface McpServerStatus { export interface SessionInfo { cwd?: string fast?: boolean + install_warning?: string lazy?: boolean mcp_servers?: McpServerStatus[] model: string