mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
feat(plugins): one name resolver for every verb; one switch turns a unified package on
- hermes_cli: _find_plugin_entry is the single resolver (key / manifest name / unique leaf / catalog name via the installer-owned record). enable/disable/show/capabilities/update/remove, the dashboard and plugins.manage settings all go through it, so the catalog name you installed with keeps working (17 catalog entries install under another manifest name). _catalog_install_on_disk reuses the same catalog lookup. - Desktop hub: join agent rows to desktop halves by package folder (what Electron stamps), not manifest name, so a self-cloned package is one row instead of two. - Desktop: turning a unified package on (install dialog 'Enable after install' or the Agent switch) also turns its opt-in desktop half on, unless the user switched it off. Off stays per half. Capability consent is untouched. - docs: plugin-catalog page.
This commit is contained in:
1 parent
1748b89455
commit
8f463316fd
13 files changed
+277
-40
No files matched your search
@@ -36,6 +36,28 @@ describe('mergePluginPackages', () => {
|
||||
expect(rows[0].agent?.name).toBe('hermes-media-studio')
|
||||
})
|
||||
|
||||
it('pairs a desktop half with an agent row whose folder differs from its manifest name', () => {
|
||||
// Electron names the half after the package FOLDER (plugins/<folder>/desktop);
|
||||
// the agent row is named from its manifest. `hermes plugins install` lands a
|
||||
// package at plugins/<manifest name>, but a self-cloned one (the README's
|
||||
// `git clone … ~/.hermes/plugins/hermes-subscription-meter`) keeps the repo
|
||||
// name. The join key has to be the folder or it splits into two rows.
|
||||
const rows = mergePluginPackages(
|
||||
[desktop({ id: 'meter-ui', name: 'Meter', packageName: 'hermes-subscription-meter' })],
|
||||
[
|
||||
agent({
|
||||
name: 'subscription-meter',
|
||||
has_desktop_half: true,
|
||||
install_dir: '/home/u/.hermes/plugins/hermes-subscription-meter'
|
||||
})
|
||||
]
|
||||
)
|
||||
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0]).toMatchObject({ kind: 'both', agentMissingInProfile: false, desktopMissing: false })
|
||||
expect(rows[0].agent?.name).toBe('subscription-meter')
|
||||
})
|
||||
|
||||
it('a desktop half whose agent half is absent from THIS profile offers the install-here affordance', () => {
|
||||
const rows = mergePluginPackages([desktop({ id: 'media', packageName: 'hermes-media-studio' })], [])
|
||||
|
||||
|
||||
@@ -49,6 +49,19 @@ export function desktopPackageName(record: PluginRecord): null | string {
|
||||
const KIND_RANK: Record<PackageKind, number> = { both: 0, agent: 1, desktop: 2 }
|
||||
const DESKTOP_KIND_RANK: Record<PluginRecord['kind'], number> = { disk: 0, runtime: 1, bundled: 2 }
|
||||
|
||||
/** The package folder an agent row lives in: the join key Electron stamps as
|
||||
* `packageName` (it names the desktop half after the top-level
|
||||
* `plugins/<folder>/`, and a hand-copied folder need not match its manifest
|
||||
* name). Nested rows (`image_gen/foo`) keep their unique canonical key; the
|
||||
* manifest name is the fallback for backends that report no `install_dir`. */
|
||||
function agentPackageName(row: AgentPluginRow): string {
|
||||
if (row.key?.includes('/')) {
|
||||
return row.key
|
||||
}
|
||||
|
||||
return row.install_dir?.split(/[\\/]/).filter(Boolean).pop() ?? row.name
|
||||
}
|
||||
|
||||
export function mergePluginPackages(
|
||||
desktopRecords: readonly PluginRecord[],
|
||||
agentRows: readonly AgentPluginRow[]
|
||||
@@ -56,7 +69,7 @@ export function mergePluginPackages(
|
||||
const byKey = new Map<string, PluginPackage>()
|
||||
|
||||
for (const row of agentRows) {
|
||||
const key = row.name
|
||||
const key = agentPackageName(row)
|
||||
byKey.set(key, {
|
||||
key,
|
||||
name: row.name,
|
||||
|
||||
@@ -299,6 +299,58 @@ describe('PluginsTab', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('turns a unified package on with ONE switch: Agent on also turns its desktop half on', async () => {
|
||||
publishPlugin(
|
||||
{ id: 'meter-ui', name: 'Meter', kind: 'disk', status: 'disabled', packageName: 'meter' },
|
||||
{ activate: () => patchPlugin('meter-ui', { status: 'loaded' }), deactivate: () => undefined }
|
||||
)
|
||||
$agentPlugins.set([
|
||||
{ description: '', key: 'meter', name: 'meter', source: 'git', status: 'not enabled', version: '1' }
|
||||
])
|
||||
requestGateway.mockImplementation((async (_method: string, params?: { action?: string }) =>
|
||||
params?.action === 'toggle'
|
||||
? { ok: true, plugin: { key: 'meter', name: 'meter', status: 'enabled' } }
|
||||
: { plugins: [] }) as never)
|
||||
|
||||
render(<PluginsTab profile={null} />)
|
||||
|
||||
screen.getByRole('switch', { name: 'Agent: Meter' }).click()
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('switch', { name: 'Desktop: Meter' }).getAttribute('aria-checked')).toBe('true')
|
||||
)
|
||||
expect($pluginDecisions.get()['meter-ui']).toBe(true)
|
||||
requestGateway.mockImplementation(async () => ({ plugins: [] }))
|
||||
$pluginDecisions.set({})
|
||||
})
|
||||
|
||||
it('leaves a desktop half the user switched off alone when its agent half is turned on', async () => {
|
||||
$pluginDecisions.set({ 'meter-ui': false })
|
||||
publishPlugin(
|
||||
{ id: 'meter-ui', name: 'Meter', kind: 'disk', status: 'disabled', packageName: 'meter' },
|
||||
{ activate: () => patchPlugin('meter-ui', { status: 'loaded' }), deactivate: () => undefined }
|
||||
)
|
||||
$agentPlugins.set([
|
||||
{ description: '', key: 'meter', name: 'meter', source: 'git', status: 'not enabled', version: '1' }
|
||||
])
|
||||
requestGateway.mockImplementation((async (_method: string, params?: { action?: string }) =>
|
||||
params?.action === 'toggle'
|
||||
? { ok: true, plugin: { key: 'meter', name: 'meter', status: 'enabled' } }
|
||||
: { plugins: [] }) as never)
|
||||
|
||||
render(<PluginsTab profile={null} />)
|
||||
|
||||
screen.getByRole('switch', { name: 'Agent: Meter' }).click()
|
||||
|
||||
await waitFor(() =>
|
||||
expect(requestGateway).toHaveBeenCalledWith('plugins.manage', expect.objectContaining({ action: 'toggle' }))
|
||||
)
|
||||
expect($pluginDecisions.get()['meter-ui']).toBe(false)
|
||||
expect(screen.getByRole('switch', { name: 'Desktop: Meter' }).getAttribute('aria-checked')).toBe('false')
|
||||
requestGateway.mockImplementation(async () => ({ plugins: [] }))
|
||||
$pluginDecisions.set({})
|
||||
})
|
||||
|
||||
it('renders keyless rows read-only (no name-addressed toggle RPC)', () => {
|
||||
// Name-addressed toggles flip every same-named plugin across category
|
||||
// dirs — pre-contract-v6 rows must never reach the RPC.
|
||||
|
||||
@@ -15,7 +15,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { $pluginRecords, type PluginRecord, setPluginEnabled } from '@/contrib/plugins-store'
|
||||
import { $pluginRecords, enablePackageDesktopHalf, type PluginRecord, setPluginEnabled } from '@/contrib/plugins-store'
|
||||
import { useContributions } from '@/contrib/react/use-contributions'
|
||||
import { discoverRuntimePlugins, uninstallDiskPlugin } from '@/contrib/runtime-loader'
|
||||
import { pluginSettingsRouteHref, SETTINGS_PLUGINS_AREA } from '@/contrib/settings-pages'
|
||||
@@ -753,7 +753,17 @@ export const PluginsTab = memo(function PluginsTab({
|
||||
return
|
||||
}
|
||||
|
||||
void toggleAgentPlugin(requestGateway, row.key, enable, p.toggleFailed(row.name), scope)
|
||||
void toggleAgentPlugin(requestGateway, row.key, enable, p.toggleFailed(row.name), scope).then(ok => {
|
||||
// Turning a unified package on turns its desktop half on too
|
||||
// (unless the user switched that half off on purpose).
|
||||
// Off stays per half: the desktop half is app-wide, and
|
||||
// another profile may still run the agent half.
|
||||
const half = pkg.desktop?.packageName
|
||||
|
||||
if (ok && enable && half) {
|
||||
void enablePackageDesktopHalf(half, { keepUserChoice: true })
|
||||
}
|
||||
})
|
||||
}}
|
||||
onAgentUpdate={row => {
|
||||
const finish = (outcome: AgentPluginUpdateOutcome) => {
|
||||
|
||||
@@ -18,6 +18,7 @@ vi.mock('@/hermes', async importOriginal => ({
|
||||
getProfiles: async () => ({ profiles: [] })
|
||||
}))
|
||||
|
||||
import { $pluginDecisions, $pluginRecords, dropPlugin, patchPlugin, publishPlugin } from '@/contrib/plugins-store'
|
||||
import { queryClient } from '@/lib/query-client'
|
||||
import {
|
||||
$pluginInstallRequest,
|
||||
@@ -227,6 +228,36 @@ describe('Unified package desktop half on a local backend', () => {
|
||||
expect(reconcileDesktopPlugins).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('turns the desktop half on with the agent half: one "Enable after install" covers the package', async () => {
|
||||
// The half lands opt-in (marker => defaultEnabled false). Before, the
|
||||
// dialog enabled only the agent half and the Desktop switch stayed off.
|
||||
$connection.set({ mode: 'local' } as NonNullable<ReturnType<typeof $connection.get>>)
|
||||
probePluginRepo.mockResolvedValue({ ok: true, agent: true, agentName: 'pkg', desktop: true, warnings: [] })
|
||||
requestGateway.mockImplementation(async (method, params) =>
|
||||
method === 'plugins.manage' && params?.action === 'install'
|
||||
? { ok: true, plugin_name: 'pkg', enabled: true }
|
||||
: { plugins: [] }
|
||||
)
|
||||
reconcileDesktopPlugins.mockImplementation(async () => {
|
||||
publishPlugin(
|
||||
{ id: 'pkg-ui', name: 'Pkg', kind: 'disk', status: 'disabled', packageName: 'pkg' },
|
||||
{ activate: () => patchPlugin('pkg-ui', { status: 'loaded' }), deactivate: () => undefined }
|
||||
)
|
||||
|
||||
return ['/app/desktop-plugins/pkg']
|
||||
})
|
||||
vi.stubGlobal('hermesDesktop', { installDesktopPlugin, probePluginRepo, reconcileDesktopPlugins })
|
||||
renderFlow()
|
||||
act(() => openPluginInstallRequest({ repo: 'https://github.com/example/pkg' }))
|
||||
expect(await screen.findByText('This package includes')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Install' }))
|
||||
|
||||
await waitFor(() => expect($pluginRecords.get()['pkg-ui']?.status).toBe('loaded'))
|
||||
expect($pluginDecisions.get()['pkg-ui']).toBe(true)
|
||||
dropPlugin('pkg-ui')
|
||||
$pluginDecisions.set({})
|
||||
})
|
||||
|
||||
it('does not start the desktop half or offer a retry when the agent install outcome is unknown', async () => {
|
||||
$connection.set({ mode: 'remote' } as NonNullable<ReturnType<typeof $connection.get>>)
|
||||
requestGateway.mockImplementation(async (method, params) => {
|
||||
|
||||
@@ -18,12 +18,19 @@ import {
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { enablePackageDesktopHalf } from '@/contrib/plugins-store'
|
||||
import { discoverRuntimePlugins } from '@/contrib/runtime-loader'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { ExternalLink } from '@/lib/external-link'
|
||||
import { AlertTriangle } from '@/lib/icons'
|
||||
import { resolvePluginSourceLinks } from '@/lib/plugin-source-urls'
|
||||
import { type AgentPluginLiveNow, COMMIT_SHA_RE, installAgentPlugin, loadAgentPlugins } from '@/store/agent-plugins'
|
||||
import {
|
||||
type AgentPluginInstallResult,
|
||||
type AgentPluginLiveNow,
|
||||
COMMIT_SHA_RE,
|
||||
installAgentPlugin,
|
||||
loadAgentPlugins
|
||||
} from '@/store/agent-plugins'
|
||||
import { notify } from '@/store/notifications'
|
||||
import {
|
||||
$pluginInstallRequest,
|
||||
@@ -51,6 +58,16 @@ function installOutcome(m: InstallModalCopy, live: AgentPluginLiveNow, nextChat:
|
||||
]
|
||||
}
|
||||
|
||||
/** One "Enable after install" covers both halves of a unified package: its
|
||||
* desktop half lands opt-in (it matches an inert agent half), so an install
|
||||
* that enabled the agent half turns the desktop half on too. Before, the
|
||||
* user had to find and flip the row's Desktop switch afterwards. */
|
||||
async function enableInstalledDesktopHalf(result?: AgentPluginInstallResult): Promise<void> {
|
||||
if (result?.enabled && result.pluginName) {
|
||||
await enablePackageDesktopHalf(result.pluginName)
|
||||
}
|
||||
}
|
||||
|
||||
export function PluginInstallModal() {
|
||||
const request = useStore($pluginInstallRequest)
|
||||
const { t } = useI18n()
|
||||
@@ -223,6 +240,7 @@ export function PluginInstallModal() {
|
||||
const errors: string[] = []
|
||||
const successes: string[] = []
|
||||
let agentInstalled = false
|
||||
let agentResult: AgentPluginInstallResult | undefined
|
||||
let live: AgentPluginLiveNow = { mcpServers: [], skills: [] }
|
||||
|
||||
try {
|
||||
@@ -244,6 +262,7 @@ export function PluginInstallModal() {
|
||||
].join(' · ')
|
||||
)
|
||||
agentInstalled = true
|
||||
agentResult = result
|
||||
live = result.live
|
||||
|
||||
if (result.missingEnv?.length) {
|
||||
@@ -314,6 +333,8 @@ export function PluginInstallModal() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await enableInstalledDesktopHalf(agentResult)
|
||||
}
|
||||
|
||||
await loadAgentPlugins(requestGateway, targetProfile)
|
||||
|
||||
@@ -111,6 +111,25 @@ export function dropPlugin(id: string): void {
|
||||
handles.delete(id)
|
||||
}
|
||||
|
||||
/** Turn on the desktop half of unified package *packageName*. A half ships
|
||||
* opt-in to match its inert agent half, so turning the package on (the
|
||||
* install dialog's "Enable after install", the hub's Agent switch) turns the
|
||||
* half on too instead of leaving a second switch to find. *keepUserChoice*
|
||||
* leaves a half the user explicitly switched off alone. */
|
||||
export async function enablePackageDesktopHalf(packageName: string, { keepUserChoice = false } = {}): Promise<void> {
|
||||
const decisions = $pluginDecisions.get()
|
||||
|
||||
for (const record of Object.values($pluginRecords.get())) {
|
||||
if (record.packageName !== packageName || record.status !== 'disabled') {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!keepUserChoice || !(record.id in decisions)) {
|
||||
await setPluginEnabled(record.id, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Live toggle: deactivate + remember, or forget + reactivate. */
|
||||
export async function setPluginEnabled(id: string, enabled: boolean): Promise<void> {
|
||||
saveDecisions({ ...$pluginDecisions.get(), [id]: enabled })
|
||||
|
||||
+46
-15
@@ -453,7 +453,9 @@ def _require_installed_plugin(name: str, plugins_dir: Path, console) -> Path:
|
||||
except ValueError as e:
|
||||
_fail(console, f"[red]Error:[/red] {e}")
|
||||
if not target.exists():
|
||||
_fail(console, _unknown_plugin_message(name, downloaded_only=True))
|
||||
target = _resolved_user_plugin_dir(name)
|
||||
if target is None:
|
||||
_fail(console, _unknown_plugin_message(name, downloaded_only=True))
|
||||
return target
|
||||
|
||||
|
||||
@@ -645,22 +647,43 @@ def _resolve_plugin_key(name: str) -> Optional[str]:
|
||||
return resolved[0] if resolved else None
|
||||
|
||||
|
||||
def _catalog_installed_dir(name: str) -> Optional[Path]:
|
||||
"""Install dir whose installer-owned record names catalog entry *name* (``hermes-memory-wiki`` is
|
||||
installed as ``plugins/memory-wiki``), else None (also when two dirs claim it). Read through
|
||||
:func:`catalog_install_record` (``.install-metadata.json``), never the tree, so a URL-installed
|
||||
repo cannot claim a catalog entry's name."""
|
||||
from hermes_cli.plugins_cmd_catalog import catalog_install_record
|
||||
try:
|
||||
keys = list(_read_install_metadata())
|
||||
except PluginOperationError:
|
||||
return None
|
||||
hits = [target for key in keys if (target := _plugins_dir() / key).is_dir()
|
||||
and (record := catalog_install_record(target)) and record["catalog_name"] == name]
|
||||
return hits[0] if len(hits) == 1 else None
|
||||
|
||||
|
||||
def _find_plugin_entry(name: str) -> Optional[tuple]:
|
||||
"""First discovered ``(name, version, description, source, dir_path, key)`` entry whose
|
||||
manifest name or canonical key equals *name*."""
|
||||
return next((entry for entry in _discover_all_plugins() if name in (entry[0], entry[5])), None)
|
||||
"""THE plugin name resolver for every verb (enable/disable/show/capabilities/remove/update, the
|
||||
dashboard and the Desktop hub): the discovered ``(name, version, description, source, dir_path, key)``
|
||||
entry for a canonical key or manifest name; else a bare leaf (``langfuse`` ->
|
||||
``observability/langfuse``) only when unique; else the catalog name the plugin was installed under.
|
||||
Never guesses between two candidates."""
|
||||
entries = _discover_all_plugins()
|
||||
exact = next((entry for entry in entries if name in (entry[0], entry[5])), None)
|
||||
if exact is not None:
|
||||
return exact
|
||||
leaf_matches = [entry for entry in entries if name == entry[5].split("/")[-1]]
|
||||
if leaf_matches:
|
||||
return leaf_matches[0] if len(leaf_matches) == 1 else None
|
||||
target = _catalog_installed_dir(name)
|
||||
return next((entry for entry in entries if target is not None and entry[4] and Path(str(entry[4])) == target),
|
||||
None)
|
||||
|
||||
|
||||
def _resolve_plugin_key_and_source(name: str) -> Optional[tuple]:
|
||||
"""Resolve *name* to ``(canonical_key, source)`` or ``None``. Exact key/manifest-name match
|
||||
first; then a bare leaf match (``langfuse`` -> ``observability/langfuse``) only when unique,
|
||||
so a same-named nested plugin is never picked silently."""
|
||||
entries = _discover_all_plugins()
|
||||
for entry in entries:
|
||||
if name in (entry[0], entry[5]):
|
||||
return (entry[5], entry[3])
|
||||
leaf_matches = [(entry[5], entry[3]) for entry in entries if name == entry[5].split("/")[-1]]
|
||||
return leaf_matches[0] if len(leaf_matches) == 1 else None
|
||||
"""Resolve *name* to ``(canonical_key, source)`` or ``None`` through :func:`_find_plugin_entry`."""
|
||||
entry = _find_plugin_entry(name)
|
||||
return (entry[5], entry[3]) if entry else None
|
||||
|
||||
|
||||
def cmd_enable(name: str, allow_tool_override: Optional[bool] = None) -> None:
|
||||
@@ -974,12 +997,20 @@ def dashboard_set_agent_plugin_enabled(name: str, *, enabled: bool) -> dict[str,
|
||||
|
||||
|
||||
def _user_installed_plugin_dir(name: str) -> Optional[Path]:
|
||||
"""Resolved path under ``~/.hermes/plugins/<name>`` if it exists."""
|
||||
"""Resolved path under ``~/.hermes/plugins/<name>`` if it exists, else the user install
|
||||
:func:`_find_plugin_entry` resolves *name* to (its manifest or catalog name)."""
|
||||
try:
|
||||
target = _sanitize_plugin_name(name, _plugins_dir(), allow_subdir=True)
|
||||
except ValueError:
|
||||
return None
|
||||
return target if target.is_dir() else None
|
||||
return target if target.is_dir() else _resolved_user_plugin_dir(name)
|
||||
|
||||
|
||||
def _resolved_user_plugin_dir(name: str) -> Optional[Path]:
|
||||
"""Dir of the user install :func:`_find_plugin_entry` resolves *name* to (manifest or catalog name)."""
|
||||
entry = _find_plugin_entry(name)
|
||||
found = Path(str(entry[4])) if entry and entry[3] in ("user", "git") and entry[4] else None
|
||||
return found if found is not None and found.is_dir() else None
|
||||
|
||||
|
||||
def cmd_plugin_doctor(target: str = ".", *, ci: bool = False) -> None:
|
||||
|
||||
@@ -107,6 +107,11 @@ def cmd_capabilities(name: Optional[str] = None) -> None:
|
||||
plugin_capability_granted,
|
||||
)
|
||||
console = _pc()._console()
|
||||
if name is not None:
|
||||
hit = _pc()._find_plugin_entry(name)
|
||||
if hit is None:
|
||||
_pc()._fail(console, _pc()._unknown_plugin_message(name))
|
||||
name = hit[5] or hit[0]
|
||||
rows = []
|
||||
for entry in _pc()._discover_all_plugins():
|
||||
key = entry[5] or entry[0]
|
||||
|
||||
@@ -722,18 +722,14 @@ def _catalog_install_on_disk(catalog_name: str, ref: Optional[str]) -> Optional[
|
||||
Like ``hermes plugins enable``, the tree is used at the commit it has; an explicit *ref* only
|
||||
matches a tree checked out at that commit."""
|
||||
from hermes_cli.plugins_cmd_catalog import catalog_install_record
|
||||
enabled = _pc()._get_enabled_set()
|
||||
for key in _pc()._read_install_metadata():
|
||||
target = _pc()._plugins_dir() / key
|
||||
record = catalog_install_record(target) if target.is_dir() else None
|
||||
if not record or record["catalog_name"] != catalog_name:
|
||||
continue
|
||||
if ref and str(record["sha"]).lower() != ref.lower():
|
||||
return None
|
||||
manifest = _pc()._read_manifest(target)
|
||||
installed_name = manifest.get("name") or target.name
|
||||
return None if {installed_name, target.name} & enabled else (target, manifest, installed_name)
|
||||
return None
|
||||
target = _pc()._catalog_installed_dir(catalog_name)
|
||||
if target is None:
|
||||
return None
|
||||
if ref and str(catalog_install_record(target)["sha"]).lower() != ref.lower():
|
||||
return None
|
||||
manifest = _pc()._read_manifest(target)
|
||||
installed_name = manifest.get("name") or target.name
|
||||
return None if {installed_name, target.name} & _pc()._get_enabled_set() else (target, manifest, installed_name)
|
||||
|
||||
|
||||
def _resolve_source(identifier: str, catalog_name: Optional[str]) -> tuple:
|
||||
|
||||
@@ -134,6 +134,32 @@ def test_kill_list_blocks_cli_dashboard_and_tui_paths(world, monkeypatch):
|
||||
cat.resolved_removed_entries()) == "malware"
|
||||
|
||||
|
||||
def test_the_catalog_name_you_installed_with_works_for_every_later_verb(world, tmp_path):
|
||||
"""17 catalog entries install under a different manifest name (`hermes-memory-wiki` lands as
|
||||
`plugins/memory-wiki`). Every later verb must accept the name the user typed at install, resolved
|
||||
through the installer's own record; a URL install shipping a forged sidecar must not claim it."""
|
||||
repo = world["repo"]
|
||||
(repo / "plugin.yaml").write_text("name: renamed-plugin\nversion: 1.0.0\ndescription: d\n")
|
||||
world["state"]["pin"] = _commit(repo, "manifest name differs from catalog name")
|
||||
forged = json.dumps({"catalog_name": "cat-plugin", "tier": "official", "sha": "0" * 40, "repo": "x"})
|
||||
_install_url(tmp_path / "evil", "evil-plugin", {cat.CATALOG_SIDECAR: forged})
|
||||
assert pc._find_plugin_entry("cat-plugin") is None
|
||||
|
||||
pc.cmd_install("cat-plugin", enable=False)
|
||||
target = world["plugins_dir"] / "renamed-plugin"
|
||||
assert target.is_dir() and pc._get_enabled_set() == set()
|
||||
|
||||
pc.cmd_enable("cat-plugin")
|
||||
assert pc._get_enabled_set() == {"renamed-plugin"}
|
||||
pc.cmd_show("cat-plugin")
|
||||
pc.cmd_capabilities("cat-plugin")
|
||||
pc.cmd_disable("cat-plugin")
|
||||
assert pc._get_disabled_set() == {"renamed-plugin"}
|
||||
assert pc.dashboard_set_agent_plugin_enabled("cat-plugin", enabled=True)["name"] == "renamed-plugin"
|
||||
assert pc.dashboard_update_user_plugin("cat-plugin")["ok"] is True
|
||||
assert pc.dashboard_remove_user_plugin("cat-plugin")["ok"] is True
|
||||
assert not target.exists()
|
||||
assert "renamed-plugin" not in pc._get_enabled_set()
|
||||
|
||||
|
||||
def test_custom_install_records_an_anonymous_extension_install_and_reinstall_none(world, monkeypatch):
|
||||
|
||||
@@ -1941,7 +1941,7 @@ def _plugins_settings(rid, params):
|
||||
if not key or not isinstance(values, dict):
|
||||
return _err(rid, 4019, "plugins.settings requires a 'key' and a 'values' mapping")
|
||||
pc = _tools_mod("hermes_cli.plugins_cmd")
|
||||
found = next((p for p in pc._discover_all_plugins() if key in (p[5], p[0])), None)
|
||||
found = pc._find_plugin_entry(key)
|
||||
if found is None:
|
||||
return _err(rid, 4020, f"plugin '{key}' not found")
|
||||
_name, _version, _desc, _source, plugin_dir, canonical = found
|
||||
|
||||
@@ -139,20 +139,31 @@ hermes plugins enable <name>
|
||||
```
|
||||
|
||||
The install prompt shows the entry's capability summary — declared tools,
|
||||
hooks, and required env vars — before anything is cloned.
|
||||
hooks, and required env vars — before anything is cloned. In a terminal it
|
||||
then asks "Enable now?", so the two commands above are one step when you
|
||||
answer yes (`--enable` / `--no-enable` answer it for scripts).
|
||||
|
||||
The catalog name and the plugin's own manifest name can differ; `hermes
|
||||
plugins install` prints the installed name, and `enable` takes that one. For
|
||||
example the `touchdesigner` entry (a portable Agent Plugins v1 package that
|
||||
bundles the twozero MCP server with the `touchdesigner-mcp` skill) installs as
|
||||
`td`, kept short so its generated MCP tool names stay under provider
|
||||
function-name limits:
|
||||
The catalog name and the plugin's own manifest name can differ. Every later
|
||||
command (`enable`, `disable`, `show`, `capabilities`, `update`, `remove`)
|
||||
accepts either one: the catalog name resolves through the install record
|
||||
Hermes wrote when it cloned the entry, never through a file in the plugin's
|
||||
own repo. For example the `touchdesigner` entry (a portable Agent Plugins v1
|
||||
package that bundles the twozero MCP server with the `touchdesigner-mcp`
|
||||
skill) installs as `td`, kept short so its generated MCP tool names stay
|
||||
under provider function-name limits:
|
||||
|
||||
```bash
|
||||
hermes plugins install touchdesigner
|
||||
hermes plugins enable td
|
||||
hermes plugins enable touchdesigner # same as: hermes plugins enable td
|
||||
```
|
||||
|
||||
In the Desktop app a package with both an agent half and a Desktop half
|
||||
(`desktop/plugin.js`) is one row with one switch per half. Turning the
|
||||
package on (the install dialog's **Enable agent plugin after install**, or
|
||||
the row's **Agent** switch) turns its Desktop half on as well, unless you
|
||||
switched that half off yourself. Turning it off stays per half, because the
|
||||
Desktop half is shared by every profile.
|
||||
|
||||
Portable packages can also carry a stdio MCP server. The `snyk` entry pins the
|
||||
Snyk CLI (`npx -y snyk@<version> mcp`) and bundles the `snyk-security-scan`
|
||||
skill, so one install gives Hermes code, dependency, container and IaC scanning
|
||||
|
||||
Reference in new issue
Block a user