Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea0efea2bd |
@@ -7302,24 +7302,66 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
self._handle_browser_command(cmd_original)
|
||||
elif canonical == "plugins":
|
||||
try:
|
||||
from hermes_cli.plugins import get_plugin_manager
|
||||
mgr = get_plugin_manager()
|
||||
plugins = mgr.list_plugins()
|
||||
if not plugins:
|
||||
print("No plugins installed.")
|
||||
print(f"Drop plugin directories into {display_hermes_home()}/plugins/ to get started.")
|
||||
# Discover from disk (bundled + user), matching `hermes plugins
|
||||
# list` — so installed-but-not-enabled plugins are visible here
|
||||
# too. The plugin manager only knows about *loaded* plugins, so
|
||||
# using it alone made freshly-installed, not-yet-enabled plugins
|
||||
# look like "nothing installed".
|
||||
from hermes_cli.plugins_cmd import (
|
||||
_discover_all_plugins,
|
||||
_get_disabled_set,
|
||||
_get_enabled_set,
|
||||
_plugin_status,
|
||||
)
|
||||
|
||||
entries = _discover_all_plugins()
|
||||
enabled = _get_enabled_set()
|
||||
disabled = _get_disabled_set()
|
||||
|
||||
# `/plugins` is a quick glance — default to user-installed
|
||||
# plugins (what the user actually added). Bundled provider/
|
||||
# platform plugins are summarized on one line; the full
|
||||
# catalog lives behind `hermes plugins list`.
|
||||
user_entries = [e for e in entries if e[3] != "bundled"]
|
||||
bundled_count = len(entries) - len(user_entries)
|
||||
|
||||
if not user_entries:
|
||||
print("No user plugins installed.")
|
||||
print(" Install one: hermes plugins install owner/repo")
|
||||
print(f" Or drop a plugin directory into {display_hermes_home()}/plugins/")
|
||||
if bundled_count:
|
||||
print(f" ({bundled_count} bundled plugins available — see: hermes plugins list)")
|
||||
else:
|
||||
print(f"Plugins ({len(plugins)}):")
|
||||
for p in plugins:
|
||||
status = "✓" if p["enabled"] else "✗"
|
||||
version = f" v{p['version']}" if p["version"] else ""
|
||||
tools = f"{p['tools']} tools" if p["tools"] else ""
|
||||
hooks = f"{p['hooks']} hooks" if p["hooks"] else ""
|
||||
commands = f"{p['commands']} commands" if p.get("commands") else ""
|
||||
parts = [x for x in [tools, hooks, commands] if x]
|
||||
detail = f" ({', '.join(parts)})" if parts else ""
|
||||
error = f" — {p['error']}" if p["error"] else ""
|
||||
print(f" {status} {p['name']}{version}{detail}{error}")
|
||||
# Loaded-plugin details (tools/hooks/commands counts, errors)
|
||||
# keyed by name, when available.
|
||||
loaded: dict = {}
|
||||
try:
|
||||
from hermes_cli.plugins import get_plugin_manager
|
||||
for p in get_plugin_manager().list_plugins():
|
||||
loaded[p["name"]] = p
|
||||
except Exception:
|
||||
loaded = {}
|
||||
|
||||
print(f"User plugins ({len(user_entries)}):")
|
||||
for name, version, _desc, source, _dir, key in sorted(user_entries):
|
||||
state = _plugin_status(name, enabled, disabled, key=key)
|
||||
glyph = {"enabled": "✓", "disabled": "✗"}.get(state, "○")
|
||||
ver = f" v{version}" if version else ""
|
||||
info = loaded.get(name) or {}
|
||||
bits = []
|
||||
if info.get("tools"):
|
||||
bits.append(f"{info['tools']} tools")
|
||||
if info.get("hooks"):
|
||||
bits.append(f"{info['hooks']} hooks")
|
||||
if info.get("commands"):
|
||||
bits.append(f"{info['commands']} commands")
|
||||
detail = f" ({', '.join(bits)})" if bits else ""
|
||||
label = "" if state == "enabled" else f" [{state}]"
|
||||
error = f" — {info['error']}" if info.get("error") else ""
|
||||
print(f" {glyph} {name}{ver}{label}{detail}{error}")
|
||||
if bundled_count:
|
||||
print(f" (+{bundled_count} bundled — see: hermes plugins list)")
|
||||
print(" Enable/disable: hermes plugins enable/disable <name>")
|
||||
except Exception as e:
|
||||
print(f"Plugin system error: {e}")
|
||||
elif canonical == "rollback":
|
||||
|
||||
+1
-78
@@ -175,7 +175,6 @@ _LONG_HANDLERS = frozenset(
|
||||
{
|
||||
"browser.manage",
|
||||
"cli.exec",
|
||||
"plugins.manage",
|
||||
"session.branch",
|
||||
"session.compress",
|
||||
"session.resume",
|
||||
@@ -9021,83 +9020,7 @@ def _(rid, params: dict) -> dict:
|
||||
return _err(rid, 5025, str(e))
|
||||
|
||||
|
||||
@method("plugins.manage")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""List installed plugins with activation state, or toggle one on/off.
|
||||
|
||||
Backs the TUI Plugins Hub. Uses the same disk-discovery + enable/disable
|
||||
primitives as ``hermes plugins`` / the dashboard, so the three surfaces
|
||||
agree on what's installed and what's enabled.
|
||||
|
||||
Actions:
|
||||
- ``list`` → {"plugins": [{name, version, description, source,
|
||||
status}], "user_count": N, "bundled_count": M}
|
||||
- ``toggle`` → flip ``name`` based on ``enable`` (bool). Returns the
|
||||
refreshed row plus {"ok", "unchanged"}.
|
||||
"""
|
||||
action = params.get("action", "list")
|
||||
try:
|
||||
from hermes_cli.plugins_cmd import (
|
||||
_discover_all_plugins,
|
||||
_get_disabled_set,
|
||||
_get_enabled_set,
|
||||
_plugin_status,
|
||||
)
|
||||
|
||||
def _rows():
|
||||
enabled = _get_enabled_set()
|
||||
disabled = _get_disabled_set()
|
||||
out = []
|
||||
for name, version, desc, source, _dir, key in sorted(
|
||||
_discover_all_plugins()
|
||||
):
|
||||
out.append(
|
||||
{
|
||||
"name": name,
|
||||
"version": str(version or ""),
|
||||
"description": desc or "",
|
||||
"source": source,
|
||||
"status": _plugin_status(name, enabled, disabled, key=key),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
if action == "list":
|
||||
rows = _rows()
|
||||
user_count = sum(1 for r in rows if r["source"] != "bundled")
|
||||
return _ok(
|
||||
rid,
|
||||
{
|
||||
"plugins": rows,
|
||||
"user_count": user_count,
|
||||
"bundled_count": len(rows) - user_count,
|
||||
},
|
||||
)
|
||||
|
||||
if action == "toggle":
|
||||
from hermes_cli.plugins_cmd import dashboard_set_agent_plugin_enabled
|
||||
|
||||
name = (params.get("name") or "").strip()
|
||||
if not name:
|
||||
return _err(rid, 4019, "plugins.toggle requires a 'name'")
|
||||
enable = bool(params.get("enable"))
|
||||
result = dashboard_set_agent_plugin_enabled(name, enabled=enable)
|
||||
if not result.get("ok"):
|
||||
return _err(rid, 5026, result.get("error") or "toggle failed")
|
||||
row = next((r for r in _rows() if r["name"] == name), None)
|
||||
return _ok(
|
||||
rid,
|
||||
{
|
||||
"ok": True,
|
||||
"unchanged": bool(result.get("unchanged")),
|
||||
"name": name,
|
||||
"plugin": row,
|
||||
},
|
||||
)
|
||||
|
||||
return _err(rid, 4017, f"unknown plugins action: {action}")
|
||||
except Exception as e:
|
||||
return _err(rid, 5026, str(e))
|
||||
# ── Methods: shell ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
@method("shell.exec")
|
||||
|
||||
@@ -93,7 +93,6 @@ export interface OverlayState {
|
||||
confirm: ConfirmReq | null
|
||||
modelPicker: boolean
|
||||
pager: null | PagerState
|
||||
pluginsHub: boolean
|
||||
secret: null | SecretReq
|
||||
sessions: boolean
|
||||
skillsHub: boolean
|
||||
|
||||
@@ -10,7 +10,6 @@ const buildOverlayState = (): OverlayState => ({
|
||||
confirm: null,
|
||||
modelPicker: false,
|
||||
pager: null,
|
||||
pluginsHub: false,
|
||||
secret: null,
|
||||
sessions: false,
|
||||
skillsHub: false,
|
||||
@@ -21,10 +20,8 @@ export const $overlayState = atom<OverlayState>(buildOverlayState())
|
||||
|
||||
export const $isBlocked = computed(
|
||||
$overlayState,
|
||||
({ agents, approval, clarify, confirm, modelPicker, pager, pluginsHub, secret, sessions, skillsHub, sudo }) =>
|
||||
Boolean(
|
||||
agents || approval || clarify || confirm || modelPicker || pager || pluginsHub || secret || sessions || skillsHub || sudo
|
||||
)
|
||||
({ agents, approval, clarify, confirm, modelPicker, pager, secret, sessions, skillsHub, sudo }) =>
|
||||
Boolean(agents || approval || clarify || confirm || modelPicker || pager || secret || sessions || skillsHub || sudo)
|
||||
)
|
||||
|
||||
export const getOverlayState = () => $overlayState.get()
|
||||
@@ -49,7 +46,6 @@ export const resetFlowOverlays = () =>
|
||||
agents: $overlayState.get().agents,
|
||||
agentsInitialHistoryIndex: $overlayState.get().agentsInitialHistoryIndex,
|
||||
modelPicker: $overlayState.get().modelPicker,
|
||||
pluginsHub: $overlayState.get().pluginsHub,
|
||||
sessions: $overlayState.get().sessions,
|
||||
skillsHub: $overlayState.get().skillsHub
|
||||
})
|
||||
|
||||
@@ -652,34 +652,6 @@ export const opsCommands: SlashCommand[] = [
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
help: 'view & toggle plugins (no arg opens the hub; enable/disable <name> for direct toggle)',
|
||||
name: 'plugins',
|
||||
run: (arg, ctx, cmd) => {
|
||||
// No argument → open the interactive Plugins Hub overlay. Any
|
||||
// subcommand (enable/disable/list/install/…) falls through to the
|
||||
// text slash worker so it stays at parity with `hermes plugins`.
|
||||
if (!arg.trim()) {
|
||||
return patchOverlayState({ pluginsHub: true })
|
||||
}
|
||||
|
||||
ctx.gateway.gw
|
||||
.request<SlashExecResponse>('slash.exec', { command: cmd.slice(1), session_id: ctx.sid })
|
||||
.then(r => {
|
||||
if (ctx.stale()) {
|
||||
return
|
||||
}
|
||||
|
||||
const body = r?.output || '/plugins: no output'
|
||||
const text = r?.warning ? `warning: ${r.warning}\n${body}` : body
|
||||
const long = text.length > 180 || text.split('\n').filter(Boolean).length > 2
|
||||
|
||||
long ? ctx.transcript.page(text, 'Plugins') : ctx.transcript.sys(text)
|
||||
})
|
||||
.catch(ctx.guardedErr)
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
help: 'enable or disable tools (client-side history reset on change)',
|
||||
name: 'tools',
|
||||
|
||||
@@ -151,10 +151,6 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
|
||||
return patchOverlayState({ skillsHub: false })
|
||||
}
|
||||
|
||||
if (overlay.pluginsHub) {
|
||||
return patchOverlayState({ pluginsHub: false })
|
||||
}
|
||||
|
||||
if (overlay.sessions) {
|
||||
return patchOverlayState({ sessions: false })
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import { FloatBox } from './appChrome.js'
|
||||
import { MaskedPrompt } from './maskedPrompt.js'
|
||||
import { ModelPicker } from './modelPicker.js'
|
||||
import { OverlayHint } from './overlayControls.js'
|
||||
import { PluginsHub } from './pluginsHub.js'
|
||||
import { ApprovalPrompt, ClarifyPrompt, ConfirmPrompt } from './prompts.js'
|
||||
import { SkillsHub } from './skillsHub.js'
|
||||
|
||||
@@ -126,7 +125,6 @@ export function FloatingOverlays({
|
||||
overlay.pager ||
|
||||
overlay.sessions ||
|
||||
overlay.skillsHub ||
|
||||
overlay.pluginsHub ||
|
||||
completions.length
|
||||
|
||||
if (!hasAny) {
|
||||
@@ -176,12 +174,6 @@ export function FloatingOverlays({
|
||||
</FloatBox>
|
||||
)}
|
||||
|
||||
{overlay.pluginsHub && (
|
||||
<FloatBox color={theme.color.border}>
|
||||
<PluginsHub gw={gw} onClose={() => patchOverlayState({ pluginsHub: false })} t={theme} />
|
||||
</FloatBox>
|
||||
)}
|
||||
|
||||
{overlay.pager && (
|
||||
<FloatBox color={theme.color.border}>
|
||||
<Box flexDirection="column" paddingX={1} paddingY={1}>
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
import { Box, Text, useInput, useStdout } from '@hermes/ink'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import type { GatewayClient } from '../gatewayClient.js'
|
||||
import { rpcErrorMessage } from '../lib/rpc.js'
|
||||
import type { Theme } from '../theme.js'
|
||||
|
||||
import { OverlayHint, useOverlayKeys, windowItems, windowOffset } from './overlayControls.js'
|
||||
|
||||
const VISIBLE = 12
|
||||
const MIN_WIDTH = 44
|
||||
const MAX_WIDTH = 96
|
||||
|
||||
interface PluginRow {
|
||||
description?: string
|
||||
name: string
|
||||
source?: string
|
||||
status?: string
|
||||
version?: string
|
||||
}
|
||||
|
||||
interface PluginsListResponse {
|
||||
bundled_count?: number
|
||||
plugins?: PluginRow[]
|
||||
user_count?: number
|
||||
}
|
||||
|
||||
interface PluginsToggleResponse {
|
||||
name?: string
|
||||
ok?: boolean
|
||||
plugin?: PluginRow
|
||||
unchanged?: boolean
|
||||
}
|
||||
|
||||
type Scope = 'all' | 'user'
|
||||
|
||||
const GLYPH: Record<string, string> = {
|
||||
disabled: '✗',
|
||||
enabled: '✓'
|
||||
}
|
||||
|
||||
export function PluginsHub({ gw, onClose, t }: PluginsHubProps) {
|
||||
const [rows, setRows] = useState<PluginRow[]>([])
|
||||
const [bundledCount, setBundledCount] = useState(0)
|
||||
const [userCount, setUserCount] = useState(0)
|
||||
const [idx, setIdx] = useState(0)
|
||||
const [scope, setScope] = useState<Scope>('user')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [err, setErr] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const { stdout } = useStdout()
|
||||
const width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6))
|
||||
|
||||
const load = () => {
|
||||
gw.request<PluginsListResponse>('plugins.manage', { action: 'list' })
|
||||
.then(r => {
|
||||
setRows(r?.plugins ?? [])
|
||||
setUserCount(Number(r?.user_count ?? 0))
|
||||
setBundledCount(Number(r?.bundled_count ?? 0))
|
||||
setErr('')
|
||||
setLoading(false)
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
setErr(rpcErrorMessage(e))
|
||||
setLoading(false)
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(load, [gw])
|
||||
|
||||
// Default to user plugins; fall back to all when there are none so the
|
||||
// overlay is never empty when bundled plugins exist.
|
||||
const visibleRows = scope === 'user' ? rows.filter(r => r.source !== 'bundled') : rows
|
||||
const effectiveRows = scope === 'user' && !visibleRows.length && rows.length ? rows : visibleRows
|
||||
const effectiveScope: Scope = effectiveRows === visibleRows ? scope : 'all'
|
||||
const clampedIdx = Math.min(idx, Math.max(0, effectiveRows.length - 1))
|
||||
|
||||
useOverlayKeys({ disabled: busy, onClose })
|
||||
|
||||
const toggle = (row: PluginRow) => {
|
||||
if (busy || !row) {
|
||||
return
|
||||
}
|
||||
|
||||
const enable = row.status !== 'enabled'
|
||||
setBusy(true)
|
||||
setErr('')
|
||||
|
||||
gw.request<PluginsToggleResponse>('plugins.manage', { action: 'toggle', enable, name: row.name })
|
||||
.then(r => {
|
||||
if (r?.plugin) {
|
||||
setRows(prev => prev.map(p => (p.name === r.plugin!.name ? r.plugin! : p)))
|
||||
} else {
|
||||
load()
|
||||
}
|
||||
})
|
||||
.catch((e: unknown) => setErr(rpcErrorMessage(e)))
|
||||
.finally(() => setBusy(false))
|
||||
}
|
||||
|
||||
useInput((ch, key) => {
|
||||
if (busy) {
|
||||
return
|
||||
}
|
||||
|
||||
const count = effectiveRows.length
|
||||
|
||||
if (key.upArrow && clampedIdx > 0) {
|
||||
setIdx(clampedIdx - 1)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (key.downArrow && clampedIdx < count - 1) {
|
||||
setIdx(clampedIdx + 1)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Tab toggles user-only vs all (bundled) scope.
|
||||
if (key.tab) {
|
||||
setScope(s => (s === 'user' ? 'all' : 'user'))
|
||||
setIdx(0)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (key.return || ch === ' ') {
|
||||
const row = effectiveRows[clampedIdx]
|
||||
|
||||
if (row) {
|
||||
toggle(row)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const n = ch === '0' ? 10 : parseInt(ch, 10)
|
||||
|
||||
if (!Number.isNaN(n) && n >= 1 && n <= Math.min(10, count)) {
|
||||
const next = windowOffset(count, clampedIdx, VISIBLE) + n - 1
|
||||
const row = effectiveRows[next]
|
||||
|
||||
if (row) {
|
||||
setIdx(next)
|
||||
toggle(row)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (loading) {
|
||||
return <Text color={t.color.muted}>loading plugins…</Text>
|
||||
}
|
||||
|
||||
if (err && !rows.length) {
|
||||
return (
|
||||
<Box flexDirection="column" width={width}>
|
||||
<Text color={t.color.label}>error: {err}</Text>
|
||||
<OverlayHint t={t}>Esc/q close</OverlayHint>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (!rows.length) {
|
||||
return (
|
||||
<Box flexDirection="column" width={width}>
|
||||
<Text bold color={t.color.accent}>
|
||||
Plugins Hub
|
||||
</Text>
|
||||
<Text color={t.color.muted}>no plugins installed</Text>
|
||||
<Text color={t.color.muted}>install: hermes plugins install owner/repo</Text>
|
||||
<OverlayHint t={t}>Esc/q close</OverlayHint>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
const labels = effectiveRows.map(r => {
|
||||
const status = r.status ?? 'not enabled'
|
||||
const glyph = GLYPH[status] ?? '○'
|
||||
const ver = r.version ? ` v${r.version}` : ''
|
||||
const src = effectiveScope === 'all' && r.source === 'bundled' ? ' [bundled]' : ''
|
||||
const state = status === 'enabled' ? '' : ` (${status})`
|
||||
|
||||
return `${glyph} ${r.name}${ver}${src}${state}`
|
||||
})
|
||||
|
||||
const { items, offset } = windowItems(labels, clampedIdx, VISIBLE)
|
||||
|
||||
const scopeLabel =
|
||||
effectiveScope === 'user'
|
||||
? `${userCount} user plugin(s)${bundledCount ? ` · +${bundledCount} bundled (Tab)` : ''}`
|
||||
: `all ${rows.length} plugins`
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width={width}>
|
||||
<Text bold color={t.color.accent}>
|
||||
Plugins Hub
|
||||
</Text>
|
||||
|
||||
<Text color={t.color.muted}>{scopeLabel}</Text>
|
||||
{offset > 0 && <Text color={t.color.muted}> ↑ {offset} more</Text>}
|
||||
|
||||
{items.map((row, i) => {
|
||||
const lineIdx = offset + i
|
||||
const active = clampedIdx === lineIdx
|
||||
|
||||
return (
|
||||
<Text
|
||||
bold={active}
|
||||
color={active ? t.color.accent : t.color.muted}
|
||||
inverse={active}
|
||||
key={effectiveRows[lineIdx]?.name ?? row}
|
||||
wrap="truncate-end"
|
||||
>
|
||||
{active ? '▸ ' : ' '}
|
||||
{i + 1}. {row}
|
||||
</Text>
|
||||
)
|
||||
})}
|
||||
|
||||
{offset + VISIBLE < labels.length && (
|
||||
<Text color={t.color.muted}> ↓ {labels.length - offset - VISIBLE} more</Text>
|
||||
)}
|
||||
|
||||
{err ? <Text color={t.color.label}>error: {err}</Text> : null}
|
||||
{busy ? <Text color={t.color.accent}>updating…</Text> : null}
|
||||
|
||||
<OverlayHint t={t}>↑/↓ select · Enter/Space toggle · Tab user/all · 1-9,0 quick · Esc/q close</OverlayHint>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
interface PluginsHubProps {
|
||||
gw: GatewayClient
|
||||
onClose: () => void
|
||||
t: Theme
|
||||
}
|
||||
Reference in New Issue
Block a user