feat(install): warn pip/Homebrew installs are unsupported (CLI, TUI, desktop) (#57225)
* feat(install): warn pip/Homebrew installs are unsupported (CLI, TUI, desktop) pip and Homebrew are now Unsupported install methods per website/docs/getting-started/platform-support.md. Surface a warn-don't-block deprecation notice everywhere the install method is already shown, pointing at the platform-support docs and noting these installs will not receive further updates. NixOS (Tier 2) is untouched. - hermes_cli/config.py: shared is_unsupported_install_method() / format_unsupported_install_warning() helpers so the wording and docs link stay consistent across every surface. - hermes_cli/banner.py: generalize the existing pip-only banner warning to also cover Homebrew. - hermes_cli/main.py: hermes update and hermes update --check print the warning before proceeding (still update; warn, don't block). - tui_gateway/server.py: session.info gains install_warning. - ui-tui: SessionPanel renders install_warning alongside the existing 'N commits behind' notice. - apps/desktop: SessionRuntimeInfo/GatewayEventPayload gain install_warning; applyRuntimeInfo + the live session.info event fire a snoozable warning toast via a new reportInstallMethodWarning(), mirroring the existing backend-contract-skew toast pattern. i18n strings added for en/zh/zh-hant/ja. - Tests: updated pip banner assertions for the new wording, added a Homebrew banner test, and two tui_gateway session_info tests (install_warning present for pip, absent for git). * fix(nix): make `hermes` in developement environment actually work install modules as editable overlay with uv * feat: print install method when running --version * fix: correct detect install method when running from a subtreefix/verification-admin-route-recovery
parent
9de9c25f62
commit
4d7f8ade3e
2
.envrc
2
.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 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
|
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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ import {
|
||||||
import { clearSessionSubagents, pruneDelegateFallbackSubagents, upsertSubagent } from '@/store/subagents'
|
import { clearSessionSubagents, pruneDelegateFallbackSubagents, upsertSubagent } from '@/store/subagents'
|
||||||
import { clearActiveSessionTodos } from '@/store/todos'
|
import { clearActiveSessionTodos } from '@/store/todos'
|
||||||
import { recordToolDiff } from '@/store/tool-diffs'
|
import { recordToolDiff } from '@/store/tool-diffs'
|
||||||
|
import { reportInstallMethodWarning } from '@/store/updates'
|
||||||
import { notifyWorkspaceChanged, toolMayMutateFiles } from '@/store/workspace-events'
|
import { notifyWorkspaceChanged, toolMayMutateFiles } from '@/store/workspace-events'
|
||||||
import type { RpcEvent } from '@/types/hermes'
|
import type { RpcEvent } from '@/types/hermes'
|
||||||
|
|
||||||
|
|
@ -216,6 +217,10 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
|
||||||
requestDesktopOnboarding(payload.credential_warning)
|
requestDesktopOnboarding(payload.credential_warning)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (apply) {
|
||||||
|
reportInstallMethodWarning(payload?.install_warning)
|
||||||
|
}
|
||||||
|
|
||||||
void refreshHermesConfig()
|
void refreshHermesConfig()
|
||||||
|
|
||||||
if (modelChanged || providerChanged) {
|
if (modelChanged || providerChanged) {
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ import {
|
||||||
setSessions,
|
setSessions,
|
||||||
setYoloActive
|
setYoloActive
|
||||||
} from '@/store/session'
|
} from '@/store/session'
|
||||||
import { reportBackendContract } from '@/store/updates'
|
import { reportBackendContract, reportInstallMethodWarning } from '@/store/updates'
|
||||||
import type { SessionCreateResponse, SessionInfo, SessionRuntimeInfo } from '@/types/hermes'
|
import type { SessionCreateResponse, SessionInfo, SessionRuntimeInfo } from '@/types/hermes'
|
||||||
|
|
||||||
import type { ClientSessionState } from '../../../types'
|
import type { ClientSessionState } from '../../../types'
|
||||||
|
|
@ -270,6 +270,8 @@ export function applyRuntimeInfo(info: SessionRuntimeInfo | undefined): SessionR
|
||||||
requestDesktopOnboarding(info.credential_warning)
|
requestDesktopOnboarding(info.credential_warning)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
reportInstallMethodWarning(info.install_warning)
|
||||||
|
|
||||||
if (typeof info.model === 'string') {
|
if (typeof info.model === 'string') {
|
||||||
setCurrentModel(info.model)
|
setCurrentModel(info.model)
|
||||||
sessionState.model = info.model
|
sessionState.model = info.model
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,7 @@ export const en: Translations = {
|
||||||
backendOutOfDateTitle: 'Backend out of date',
|
backendOutOfDateTitle: 'Backend out of date',
|
||||||
backendOutOfDateMessage:
|
backendOutOfDateMessage:
|
||||||
'Your Hermes backend is older than this desktop build and may not work correctly. Update to align them.',
|
'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',
|
updateHermes: 'Update Hermes',
|
||||||
updateReadyTitle: 'Update ready',
|
updateReadyTitle: 'Update ready',
|
||||||
updateReadyMessage: count => `${count} new change${count === 1 ? '' : 's'} available.`,
|
updateReadyMessage: count => `${count} new change${count === 1 ? '' : 's'} available.`,
|
||||||
|
|
|
||||||
|
|
@ -120,6 +120,7 @@ export const ja = defineLocale({
|
||||||
backendOutOfDateTitle: 'バックエンドが古いです',
|
backendOutOfDateTitle: 'バックエンドが古いです',
|
||||||
backendOutOfDateMessage:
|
backendOutOfDateMessage:
|
||||||
'Hermes バックエンドがこのデスクトップビルドより古く、正常に動作しない場合があります。更新して揃えてください。',
|
'Hermes バックエンドがこのデスクトップビルドより古く、正常に動作しない場合があります。更新して揃えてください。',
|
||||||
|
installMethodUnsupportedTitle: 'サポート対象外のインストール方法',
|
||||||
updateHermes: 'Hermes を更新',
|
updateHermes: 'Hermes を更新',
|
||||||
updateReadyTitle: '更新の準備ができました',
|
updateReadyTitle: '更新の準備ができました',
|
||||||
updateReadyMessage: count => `${count} 件の新しい変更が利用可能です。`,
|
updateReadyMessage: count => `${count} 件の新しい変更が利用可能です。`,
|
||||||
|
|
|
||||||
|
|
@ -161,6 +161,7 @@ export interface Translations {
|
||||||
copyDetailFailed: string
|
copyDetailFailed: string
|
||||||
backendOutOfDateTitle: string
|
backendOutOfDateTitle: string
|
||||||
backendOutOfDateMessage: string
|
backendOutOfDateMessage: string
|
||||||
|
installMethodUnsupportedTitle: string
|
||||||
updateHermes: string
|
updateHermes: string
|
||||||
updateReadyTitle: string
|
updateReadyTitle: string
|
||||||
updateReadyMessage: (count: number) => string
|
updateReadyMessage: (count: number) => string
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,7 @@ export const zhHant = defineLocale({
|
||||||
copyDetailFailed: '無法複製通知詳情',
|
copyDetailFailed: '無法複製通知詳情',
|
||||||
backendOutOfDateTitle: '後端版本過舊',
|
backendOutOfDateTitle: '後端版本過舊',
|
||||||
backendOutOfDateMessage: '您的 Hermes 後端早於目前的桌面版本,可能無法正常運作。請更新以保持一致。',
|
backendOutOfDateMessage: '您的 Hermes 後端早於目前的桌面版本,可能無法正常運作。請更新以保持一致。',
|
||||||
|
installMethodUnsupportedTitle: '不受支援的安裝方式',
|
||||||
updateHermes: '更新 Hermes',
|
updateHermes: '更新 Hermes',
|
||||||
updateReadyTitle: '有可用更新',
|
updateReadyTitle: '有可用更新',
|
||||||
updateReadyMessage: count => `有 ${count} 項新變更可用。`,
|
updateReadyMessage: count => `有 ${count} 項新變更可用。`,
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,7 @@ export const zh: Translations = {
|
||||||
copyDetailFailed: '无法复制通知详情',
|
copyDetailFailed: '无法复制通知详情',
|
||||||
backendOutOfDateTitle: '后端版本过旧',
|
backendOutOfDateTitle: '后端版本过旧',
|
||||||
backendOutOfDateMessage: '你的 Hermes 后端早于当前桌面构建,可能无法正常工作。请更新以保持一致。',
|
backendOutOfDateMessage: '你的 Hermes 后端早于当前桌面构建,可能无法正常工作。请更新以保持一致。',
|
||||||
|
installMethodUnsupportedTitle: '不受支持的安装方式',
|
||||||
updateHermes: '更新 Hermes',
|
updateHermes: '更新 Hermes',
|
||||||
updateReadyTitle: '有可用更新',
|
updateReadyTitle: '有可用更新',
|
||||||
updateReadyMessage: count => `有 ${count} 项新更改可用。`,
|
updateReadyMessage: count => `有 ${count} 项新更改可用。`,
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ export type GatewayEventPayload = {
|
||||||
cwd?: string
|
cwd?: string
|
||||||
branch?: string
|
branch?: string
|
||||||
credential_warning?: string
|
credential_warning?: string
|
||||||
|
install_warning?: string
|
||||||
personality?: string
|
personality?: string
|
||||||
usage?: Partial<UsageStats>
|
usage?: Partial<UsageStats>
|
||||||
// agent.terminal.output — live chunk for a read-only agent terminal tab
|
// agent.terminal.output — live chunk for a read-only agent terminal tab
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,24 @@ function isSkewToastSnoozed(): boolean {
|
||||||
return Number.isFinite(until) && Date.now() < until
|
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
|
* 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
|
* (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.
|
* 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 —
|
* Closing the toast — dismissing it or opening the updates window from it —
|
||||||
|
|
|
||||||
|
|
@ -399,6 +399,7 @@ export interface SessionRuntimeInfo {
|
||||||
cwd?: string
|
cwd?: string
|
||||||
desktop_contract?: number
|
desktop_contract?: number
|
||||||
fast?: boolean
|
fast?: boolean
|
||||||
|
install_warning?: string
|
||||||
model?: string
|
model?: string
|
||||||
personality?: string
|
personality?: string
|
||||||
provider?: string
|
provider?: string
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
|
|
||||||
Pure display functions with no HermesCLI state dependency.
|
Pure display functions with no HermesCLI state dependency.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
|
@ -322,8 +321,8 @@ def check_for_updates() -> Optional[int]:
|
||||||
# both the Rich banner (build_welcome_banner) and the Ink badge
|
# both the Rich banner (build_welcome_banner) and the Ink badge
|
||||||
# (branding.tsx, guarded on `typeof === 'number' && > 0`) show nothing.
|
# (branding.tsx, guarded on `typeof === 'number' && > 0`) show nothing.
|
||||||
try:
|
try:
|
||||||
from hermes_cli.config import detect_install_method
|
from hermes_cli.config import detect_install_method, get_project_root
|
||||||
if detect_install_method() == "docker":
|
if detect_install_method(get_project_root()) == "docker":
|
||||||
return None
|
return None
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
@ -889,17 +888,23 @@ def build_welcome_banner(console: "Console", model: str, cwd: str,
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # Never break the banner over an update check
|
pass # Never break the banner over an update check
|
||||||
|
|
||||||
# Pip-install warning — `pip install hermes-agent` is not the supported
|
# Unsupported install-method warning — pip/PyPI and Homebrew are no
|
||||||
# install path (it exists on PyPI for internal/CI reasons, not end users).
|
# longer an officially supported distribution method (see
|
||||||
# Such installs miss the git checkout + installer-managed deps, so updates,
|
# website/docs/getting-started/platform-support.md). Such installs miss
|
||||||
# self-update, and issue triage don't behave correctly. Warn, don't block.
|
# 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:
|
try:
|
||||||
from hermes_cli.config import detect_install_method
|
from hermes_cli.config import (
|
||||||
if detect_install_method() == "pip":
|
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(
|
right_lines.append(
|
||||||
"[bold yellow]⚠ pip install not officially supported[/]"
|
f"[bold yellow]⚠ {format_unsupported_install_warning(_install_method)}[/]"
|
||||||
"[dim yellow] — exists for reasons other than user install; "
|
|
||||||
"expect instability and an inability to support issues[/]"
|
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # Never break the banner over the install-method check
|
pass # Never break the banner over the install-method check
|
||||||
|
|
|
||||||
|
|
@ -441,8 +441,20 @@ def detect_install_method(project_root: Optional[Path] = None) -> str:
|
||||||
managed = get_managed_system()
|
managed = get_managed_system()
|
||||||
if managed:
|
if managed:
|
||||||
return managed.lower().replace(" ", "-")
|
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"
|
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"
|
return "pip"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -528,10 +540,54 @@ def recommended_update_command() -> str:
|
||||||
managed_cmd = get_managed_update_command()
|
managed_cmd = get_managed_update_command()
|
||||||
if managed_cmd:
|
if managed_cmd:
|
||||||
return managed_cmd
|
return managed_cmd
|
||||||
method = detect_install_method()
|
method = detect_install_method(get_project_root())
|
||||||
return recommended_update_command_for_method(method)
|
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
|
# Long-form text for ``hermes update`` / ``--check`` when running inside the
|
||||||
# Docker image. Surfaced by ``cmd_update`` and ``_cmd_update_check`` in
|
# Docker image. Surfaced by ``cmd_update`` and ``_cmd_update_check`` in
|
||||||
# hermes_cli/main.py; lives here so the wording stays consistent and we
|
# hermes_cli/main.py; lives here so the wording stays consistent and we
|
||||||
|
|
|
||||||
|
|
@ -229,9 +229,9 @@ def _read_openai_version_fast() -> str | None:
|
||||||
def _print_fast_version_info() -> None:
|
def _print_fast_version_info() -> None:
|
||||||
from hermes_cli import __release_date__, __version__
|
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"Hermes Agent v{__version__} ({__release_date__})")
|
||||||
print(f"Project: {project_root}")
|
print(f"Install directory: {PROJECT_ROOT}")
|
||||||
|
|
||||||
print(f"Python: {sys.version.split()[0]}")
|
print(f"Python: {sys.version.split()[0]}")
|
||||||
|
|
||||||
openai_version = _read_openai_version_fast()
|
openai_version = _read_openai_version_fast()
|
||||||
|
|
@ -4329,10 +4329,12 @@ def cmd_import(args):
|
||||||
|
|
||||||
|
|
||||||
def _print_version_info(*, check_updates: bool = True) -> None:
|
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
|
from hermes_cli.banner import format_banner_version_label
|
||||||
|
|
||||||
print(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
|
# Show Python version
|
||||||
print(f"Python: {sys.version.split()[0]}")
|
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
|
on a PyPI install we surface a one-line notice instead of silently
|
||||||
dropping the flag.
|
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)
|
method = detect_install_method(PROJECT_ROOT)
|
||||||
|
if is_unsupported_install_method(method):
|
||||||
|
print(f"⚠ {format_unsupported_install_warning(method)}")
|
||||||
if method == "docker":
|
if method == "docker":
|
||||||
# Docker can't ``git fetch`` from within the container. Surface the
|
# Docker can't ``git fetch`` from within the container. Surface the
|
||||||
# same long-form ``docker pull`` guidance ``hermes update`` (apply
|
# same long-form ``docker pull`` guidance ``hermes update`` (apply
|
||||||
|
|
@ -9255,10 +9263,21 @@ def cmd_update(args):
|
||||||
from hermes_cli.config import (
|
from hermes_cli.config import (
|
||||||
detect_install_method,
|
detect_install_method,
|
||||||
format_docker_update_message,
|
format_docker_update_message,
|
||||||
|
format_unsupported_install_warning,
|
||||||
is_managed,
|
is_managed,
|
||||||
|
is_unsupported_install_method,
|
||||||
managed_error,
|
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():
|
if is_managed():
|
||||||
managed_error("update Hermes Agent")
|
managed_error("update Hermes Agent")
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -28,13 +28,20 @@
|
||||||
packages =
|
packages =
|
||||||
with pkgs;
|
with pkgs;
|
||||||
[
|
[
|
||||||
|
(pkgs.runCommand "hermes" { } ''
|
||||||
|
mkdir -p $out/bin
|
||||||
|
install -Dm755 ${../hermes} $out/bin/hermes
|
||||||
|
'')
|
||||||
uv
|
uv
|
||||||
]
|
]
|
||||||
++ self'.packages.default.passthru.devDeps;
|
++ self'.packages.default.passthru.devDeps;
|
||||||
shellHook = ''
|
shellHook = ''
|
||||||
echo "Hermes Agent dev shell"
|
|
||||||
${combinedNonNpm}
|
${combinedNonNpm}
|
||||||
${hermesNpmLib.mkNpmDevShellHook npmPackageJsonPaths}
|
${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."
|
echo "Ready. Run 'hermes' to start."
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ let
|
||||||
dependency-groups = [ "all" ] ++ extraDependencyGroups;
|
dependency-groups = [ "all" ] ++ extraDependencyGroups;
|
||||||
};
|
};
|
||||||
|
|
||||||
hermesVenv = mkHermesVenv extraDependencyGroups;
|
hermesVenv = (mkHermesVenv extraDependencyGroups).venv;
|
||||||
|
|
||||||
hermesNpmLib = callPackage ./lib.nix {
|
hermesNpmLib = callPackage ./lib.nix {
|
||||||
inherit npm-lockfile-fix nodejs;
|
inherit npm-lockfile-fix nodejs;
|
||||||
|
|
@ -200,33 +200,37 @@ stdenv.mkDerivation (finalAttrs: {
|
||||||
runHook postInstall
|
runHook postInstall
|
||||||
'';
|
'';
|
||||||
|
|
||||||
passthru = {
|
passthru =
|
||||||
inherit
|
let
|
||||||
hermesTui
|
devPython = (mkHermesVenv (extraDependencyGroups ++ [ "dev" ])).editableVenv;
|
||||||
hermesWeb
|
in
|
||||||
hermesNpmLib
|
{
|
||||||
hermesVenv
|
inherit
|
||||||
;
|
hermesTui
|
||||||
|
hermesWeb
|
||||||
|
hermesNpmLib
|
||||||
|
hermesVenv
|
||||||
|
;
|
||||||
|
|
||||||
# `hermesDesktop` references `finalAttrs.finalPackage` (this whole
|
# `hermesDesktop` references `finalAttrs.finalPackage` (this whole
|
||||||
# derivation, after all overrides are applied) so the desktop wrapper
|
# derivation, after all overrides are applied) so the desktop wrapper
|
||||||
# can prepend its `/bin` to PATH. The desktop's resolver step 4
|
# can prepend its `/bin` to PATH. The desktop's resolver step 4
|
||||||
# ("existing hermes on PATH") then picks up the fully wrapped
|
# ("existing hermes on PATH") then picks up the fully wrapped
|
||||||
# `hermes` binary — venv with all deps, bundled skills/plugins,
|
# `hermes` binary — venv with all deps, bundled skills/plugins,
|
||||||
# runtime PATH (ripgrep/git/ffmpeg/etc). No re-implementation
|
# runtime PATH (ripgrep/git/ffmpeg/etc). No re-implementation
|
||||||
# of the agent resolution in the desktop wrapper.
|
# of the agent resolution in the desktop wrapper.
|
||||||
hermesDesktop = callPackage ./desktop.nix {
|
hermesDesktop = callPackage ./desktop.nix {
|
||||||
inherit hermesNpmLib electron;
|
inherit hermesNpmLib electron;
|
||||||
hermesAgent = finalAttrs.finalPackage;
|
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; {
|
meta = with lib; {
|
||||||
description = "AI agent with advanced tool-calling capabilities";
|
description = "AI agent with advanced tool-calling capabilities";
|
||||||
homepage = "https://github.com/NousResearch/hermes-agent";
|
homepage = "https://github.com/NousResearch/hermes-agent";
|
||||||
|
|
|
||||||
131
nix/python.nix
131
nix/python.nix
|
|
@ -27,7 +27,8 @@ let
|
||||||
dependency-groups = { };
|
dependency-groups = { };
|
||||||
};
|
};
|
||||||
|
|
||||||
mkPrebuiltOverride = final: from: dependencies:
|
mkPrebuiltOverride =
|
||||||
|
final: from: dependencies:
|
||||||
hacks.nixpkgsPrebuilt {
|
hacks.nixpkgsPrebuilt {
|
||||||
inherit from;
|
inherit from;
|
||||||
prev = {
|
prev = {
|
||||||
|
|
@ -38,64 +39,100 @@ let
|
||||||
|
|
||||||
# Legacy alibabacloud packages ship only sdists with setup.py/setup.cfg
|
# Legacy alibabacloud packages ship only sdists with setup.py/setup.cfg
|
||||||
# and no pyproject.toml, so setuptools isn't declared as a build dep.
|
# and no pyproject.toml, so setuptools isn't declared as a build dep.
|
||||||
buildSystemOverrides = final: prev: builtins.mapAttrs
|
buildSystemOverrides =
|
||||||
(name: _: prev.${name}.overrideAttrs (old: {
|
final: prev:
|
||||||
nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ final.setuptools ];
|
builtins.mapAttrs
|
||||||
}))
|
(
|
||||||
(lib.genAttrs [
|
name: _:
|
||||||
"alibabacloud-credentials-api"
|
prev.${name}.overrideAttrs (old: {
|
||||||
"alibabacloud-endpoint-util"
|
nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ final.setuptools ];
|
||||||
"alibabacloud-gateway-dingtalk"
|
})
|
||||||
"alibabacloud-gateway-spi"
|
)
|
||||||
"alibabacloud-tea"
|
(
|
||||||
] (_: null));
|
lib.genAttrs [
|
||||||
|
"alibabacloud-credentials-api"
|
||||||
|
"alibabacloud-endpoint-util"
|
||||||
|
"alibabacloud-gateway-dingtalk"
|
||||||
|
"alibabacloud-gateway-spi"
|
||||||
|
"alibabacloud-tea"
|
||||||
|
] (_: null)
|
||||||
|
);
|
||||||
|
|
||||||
pythonPackageOverrides = final: _prev:
|
pythonPackageOverrides =
|
||||||
if isAarch64Darwin then {
|
final: _prev:
|
||||||
numpy = mkPrebuiltOverride final python312.pkgs.numpy { };
|
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 {
|
coloredlogs = mkPrebuiltOverride final python312.pkgs.coloredlogs {
|
||||||
humanfriendly = [ ];
|
humanfriendly = [ ];
|
||||||
};
|
};
|
||||||
|
|
||||||
onnxruntime = mkPrebuiltOverride final python312.pkgs.onnxruntime {
|
onnxruntime = mkPrebuiltOverride final python312.pkgs.onnxruntime {
|
||||||
coloredlogs = [ ];
|
coloredlogs = [ ];
|
||||||
numpy = [ ];
|
numpy = [ ];
|
||||||
packaging = [ ];
|
packaging = [ ];
|
||||||
};
|
};
|
||||||
|
|
||||||
ctranslate2 = mkPrebuiltOverride final python312.pkgs.ctranslate2 {
|
ctranslate2 = mkPrebuiltOverride final python312.pkgs.ctranslate2 {
|
||||||
numpy = [ ];
|
numpy = [ ];
|
||||||
pyyaml = [ ];
|
pyyaml = [ ];
|
||||||
};
|
};
|
||||||
|
|
||||||
faster-whisper = mkPrebuiltOverride final python312.pkgs.faster-whisper {
|
faster-whisper = mkPrebuiltOverride final python312.pkgs.faster-whisper {
|
||||||
av = [ ];
|
av = [ ];
|
||||||
ctranslate2 = [ ];
|
ctranslate2 = [ ];
|
||||||
huggingface-hub = [ ];
|
huggingface-hub = [ ];
|
||||||
onnxruntime = [ ];
|
onnxruntime = [ ];
|
||||||
tokenizers = [ ];
|
tokenizers = [ ];
|
||||||
tqdm = [ ];
|
tqdm = [ ];
|
||||||
};
|
};
|
||||||
} else {};
|
}
|
||||||
|
else
|
||||||
|
{ };
|
||||||
|
|
||||||
pythonSet =
|
pythonSet =
|
||||||
(callPackage pyproject-nix.build.packages {
|
(callPackage pyproject-nix.build.packages {
|
||||||
python = python312;
|
python = python312;
|
||||||
}).overrideScope
|
}).overrideScope
|
||||||
(lib.composeManyExtensions [
|
(
|
||||||
pyproject-build-systems.overlays.default
|
lib.composeManyExtensions [
|
||||||
overlay
|
pyproject-build-systems.overlays.default
|
||||||
buildSystemOverrides
|
overlay
|
||||||
pythonPackageOverrides
|
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
|
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;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -195,7 +195,33 @@ def test_banner_warns_on_pip_install(tmp_path):
|
||||||
out = buf.getvalue()
|
out = buf.getvalue()
|
||||||
|
|
||||||
assert "officially" in out
|
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):
|
def test_banner_no_pip_warning_on_git_install(tmp_path):
|
||||||
|
|
|
||||||
|
|
@ -408,7 +408,7 @@ def test_termux_ultrafast_version_runs_before_heavy_startup(
|
||||||
|
|
||||||
out = capsys.readouterr().out
|
out = capsys.readouterr().out
|
||||||
assert "Hermes Agent v" in out
|
assert "Hermes Agent v" in out
|
||||||
assert "Project:" in out
|
assert "Install directory:" in out
|
||||||
assert "Python:" in out
|
assert "Python:" in out
|
||||||
assert "OpenAI SDK:" in out
|
assert "OpenAI SDK:" in out
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4727,6 +4727,25 @@ def test_session_info_includes_session_title(monkeypatch):
|
||||||
assert info["title"] == "Dashboard title"
|
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.
|
# History-mutating commands must reject while session.running is True.
|
||||||
# Without these guards, prompt.submit's post-run history write either
|
# Without these guards, prompt.submit's post-run history write either
|
||||||
|
|
|
||||||
|
|
@ -3238,6 +3238,18 @@ def _session_info(agent, session: dict | None = None) -> dict:
|
||||||
"usage": _get_usage(agent),
|
"usage": _get_usage(agent),
|
||||||
"profile_name": _current_profile_name(),
|
"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:
|
try:
|
||||||
from hermes_cli import __version__, __release_date__
|
from hermes_cli import __version__, __release_date__
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -412,6 +412,12 @@ export function SessionPanel({ info, maxWidth, sid, t }: SessionPanelProps) {
|
||||||
</Text>
|
</Text>
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{info.install_warning && (
|
||||||
|
<Text bold color={t.color.warn} wrap="wrap">
|
||||||
|
! {info.install_warning}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -150,6 +150,7 @@ export interface McpServerStatus {
|
||||||
export interface SessionInfo {
|
export interface SessionInfo {
|
||||||
cwd?: string
|
cwd?: string
|
||||||
fast?: boolean
|
fast?: boolean
|
||||||
|
install_warning?: string
|
||||||
lazy?: boolean
|
lazy?: boolean
|
||||||
mcp_servers?: McpServerStatus[]
|
mcp_servers?: McpServerStatus[]
|
||||||
model: string
|
model: string
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue