feat(plugins): pin a plugin to a commit from Desktop, and show pins in every list
Only the CLI could install a plugin at an exact commit (`--ref <sha>`); the Desktop "Install from Git" dialog, the TUI gateway `plugins.manage install` method and the dashboard `/agent-plugins/install` endpoint all called `_install_plugin_core` without a ref, so a team that wanted everyone on the same private-plugin commit had to leave the app for a terminal. - `dashboard_install_plugin(ref=)` threads the pin to `_install_plugin_core`; the 40-hex validation and HEAD verification are unchanged. The gateway method and dashboard body accept `ref`. - Desktop dialog gains an optional "Pin to commit" field (custom sources only, client-side 40-hex check disables Install on anything shorter). - `plugins.manage list` rows carry `pinned_sha`; the Desktop plugins tab shows a `pinned @ <sha8>` badge and `hermes plugins list` prints `git pinned@<sha8>` in Source, so a team can eyeball that everyone runs the same commit.
This commit is contained in:
@@ -111,4 +111,27 @@ describe('Install from Git entry flow', () => {
|
||||
expect(requestGateway).not.toHaveBeenCalledWith('plugins.manage', expect.objectContaining({ action: 'install' }))
|
||||
expect(installDesktopPlugin).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('pins a custom install to a full commit SHA and refuses anything shorter', async () => {
|
||||
probePluginRepo.mockResolvedValue({ ok: true, agent: true, desktop: false, warnings: [] })
|
||||
requestGateway.mockImplementation(async (method: string) =>
|
||||
method === 'plugins.manage' ? { ok: true, plugin_name: 'plugin', plugins: [] } : { plugins: [] }
|
||||
)
|
||||
renderFlow()
|
||||
act(() => openPluginInstallRequest({ repo: 'https://github.com/example/plugin' }))
|
||||
const pin = await screen.findByRole('textbox', { name: 'Pin to commit (optional)' })
|
||||
const install = screen.getByRole('button', { name: 'Install' }) as HTMLButtonElement
|
||||
fireEvent.change(pin, { target: { value: 'main' } })
|
||||
expect(install.disabled).toBe(true)
|
||||
const sha = 'ABCDEF0123456789abcdef0123456789abcdef01'
|
||||
fireEvent.change(pin, { target: { value: ` ${sha} ` } })
|
||||
expect(install.disabled).toBe(false)
|
||||
fireEvent.click(install)
|
||||
await waitFor(() =>
|
||||
expect(requestGateway).toHaveBeenCalledWith(
|
||||
'plugins.manage',
|
||||
expect.objectContaining({ action: 'install', ref: sha.toLowerCase() })
|
||||
)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -22,7 +22,7 @@ import { useI18n } from '@/i18n'
|
||||
import { ExternalLink } from '@/lib/external-link'
|
||||
import { AlertTriangle } from '@/lib/icons'
|
||||
import { resolvePluginSourceLinks } from '@/lib/plugin-source-urls'
|
||||
import { installAgentPlugin, loadAgentPlugins } from '@/store/agent-plugins'
|
||||
import { COMMIT_SHA_RE, installAgentPlugin, loadAgentPlugins } from '@/store/agent-plugins'
|
||||
import { notify } from '@/store/notifications'
|
||||
import {
|
||||
$pluginInstallRequest,
|
||||
@@ -57,6 +57,7 @@ export function PluginInstallModal() {
|
||||
const [installDesktop, setInstallDesktop] = useState(true)
|
||||
const [enableAgent, setEnableAgent] = useState(true)
|
||||
const [forceReinstall, setForceReinstall] = useState(false)
|
||||
const [pinRef, setPinRef] = useState('')
|
||||
const [installing, setInstalling] = useState(false)
|
||||
const [installError, setInstallError] = useState<string | null>(null)
|
||||
const probeToken = useRef(0)
|
||||
@@ -69,6 +70,7 @@ export function PluginInstallModal() {
|
||||
setInstallDesktop(true)
|
||||
setEnableAgent(true)
|
||||
setForceReinstall(false)
|
||||
setPinRef('')
|
||||
setInstalling(false)
|
||||
setInstallError(null)
|
||||
}, [])
|
||||
@@ -195,6 +197,7 @@ export function PluginInstallModal() {
|
||||
force: forceReinstall,
|
||||
enable: enableAgent,
|
||||
catalogName: request.catalogName,
|
||||
ref: pinRefTrimmed || undefined,
|
||||
profile: request.profile
|
||||
})
|
||||
|
||||
@@ -280,6 +283,8 @@ export function PluginInstallModal() {
|
||||
|
||||
const open = request !== null && !onSettings
|
||||
const busy = phase === 'probing' || installing
|
||||
const pinRefTrimmed = pinRef.trim().toLowerCase()
|
||||
const pinRefInvalid = pinRefTrimmed !== '' && !COMMIT_SHA_RE.test(pinRefTrimmed)
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@@ -433,7 +438,9 @@ export function PluginInstallModal() {
|
||||
className="mt-0.5 size-3.5 shrink-0 text-amber-600 dark:text-amber-400"
|
||||
/>
|
||||
<span>
|
||||
{[...(probe.warnings ?? []), probe.insecure ? m.insecureWarning : ''].filter(Boolean).join(' ')}
|
||||
{[...new Set([...(probe.warnings ?? []), probe.insecure ? m.insecureWarning : ''])]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -455,6 +462,28 @@ export function PluginInstallModal() {
|
||||
<Switch checked={forceReinstall} disabled={busy} onCheckedChange={setForceReinstall} />
|
||||
</label>
|
||||
)}
|
||||
|
||||
{!request.catalogName && probe.agent && (
|
||||
<label className="block space-y-1">
|
||||
<span className="text-[length:var(--conversation-caption-font-size)] text-foreground">
|
||||
{m.pinToCommit}
|
||||
</span>
|
||||
<Input
|
||||
aria-invalid={pinRefInvalid || undefined}
|
||||
aria-label={m.pinToCommit}
|
||||
disabled={busy || !installAgent}
|
||||
onChange={event => setPinRef(event.target.value)}
|
||||
placeholder={m.pinToCommitPlaceholder}
|
||||
spellCheck={false}
|
||||
value={pinRef}
|
||||
/>
|
||||
<span
|
||||
className={`block text-[length:var(--conversation-caption-font-size)] ${pinRefInvalid ? 'text-destructive' : 'text-(--ui-text-tertiary)'}`}
|
||||
>
|
||||
{pinRefInvalid ? m.pinToCommitInvalid : m.pinToCommitHint}
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -475,7 +504,10 @@ export function PluginInstallModal() {
|
||||
{m.reviewRepository}
|
||||
</Button>
|
||||
) : (
|
||||
<Button disabled={busy || phase !== 'ready' || !probe?.ok} onClick={() => void handleInstall()}>
|
||||
<Button
|
||||
disabled={busy || phase !== 'ready' || !probe?.ok || pinRefInvalid}
|
||||
onClick={() => void handleInstall()}
|
||||
>
|
||||
{installing ? m.installing : m.install}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -114,6 +114,13 @@ function AgentPluginListRow({
|
||||
</span>
|
||||
</Tip>
|
||||
)}
|
||||
{row.pinned_sha && (
|
||||
<Tip label={t.skills.plugins.pinnedProvenance(row.pinned_sha.slice(0, 8))}>
|
||||
<span className="rounded border border-(--ui-stroke-tertiary) px-1 font-mono text-[0.65rem] text-(--ui-text-tertiary)">
|
||||
{t.skills.plugins.pinnedBadge(row.pinned_sha.slice(0, 8))}
|
||||
</span>
|
||||
</Tip>
|
||||
)}
|
||||
{row.update_available && onUpdate && (
|
||||
<Button
|
||||
className="h-5 px-1.5 text-[0.65rem]"
|
||||
|
||||
@@ -490,6 +490,11 @@ export const en: Translations = {
|
||||
gitCloneLabel: 'Git clone URL',
|
||||
enableAgent: 'Enable agent plugin after install',
|
||||
forceReinstall: 'Force reinstall (replace if already installed)',
|
||||
pinToCommit: 'Pin to commit (optional)',
|
||||
pinToCommitPlaceholder: 'Full 40-character commit SHA',
|
||||
pinToCommitHint:
|
||||
'Everyone installing this SHA gets the same code; the plugin then refuses updates until re-pinned. Leave empty for the latest commit.',
|
||||
pinToCommitInvalid: 'Must be a full 40-character commit SHA (branches and tags are not accepted).',
|
||||
install: 'Install',
|
||||
installing: 'Installing…',
|
||||
probing: 'Inspecting repository…',
|
||||
@@ -1520,6 +1525,9 @@ export const en: Translations = {
|
||||
'Hit "+ Add to this Agent" on any plugin — reviewed entries install at their pinned commit into the selected profile. Bundled agent+desktop plugins offer both halves.',
|
||||
alreadyInstalled: (name: string) => `${name} is already installed in this profile.`,
|
||||
catalogProvenance: (sha: string) => `Installed from the Hermes catalog${sha ? ` at pin ${sha}` : ''}.`,
|
||||
pinnedProvenance: (sha: string) =>
|
||||
`Pinned to commit ${sha}. Updates are refused until it is reinstalled with a new pin.`,
|
||||
pinnedBadge: (sha: string) => `pinned @ ${sha}`,
|
||||
tierOfficial: 'official',
|
||||
tierCommunity: 'community',
|
||||
updateToPin: (sha: string) => `Update to ${sha}`,
|
||||
|
||||
@@ -458,6 +458,11 @@ export const ru = defineLocale({
|
||||
gitCloneLabel: 'URL для git clone',
|
||||
enableAgent: 'Включить плагин агента после установки',
|
||||
forceReinstall: 'Принудительная переустановка (заменить, если уже установлен)',
|
||||
pinToCommit: 'Закрепить на коммите (необязательно)',
|
||||
pinToCommitPlaceholder: 'Полный SHA коммита (40 символов)',
|
||||
pinToCommitHint:
|
||||
'Все, кто установит этот SHA, получат одинаковый код; плагин перестанет обновляться до смены пина. Оставьте пустым для последнего коммита.',
|
||||
pinToCommitInvalid: 'Нужен полный SHA коммита из 40 символов (ветки и теги не принимаются).',
|
||||
install: 'Установить',
|
||||
installing: 'Установка…',
|
||||
probing: 'Осмотр репозитория…',
|
||||
|
||||
@@ -427,6 +427,10 @@ export interface Translations {
|
||||
gitCloneLabel: string
|
||||
enableAgent: string
|
||||
forceReinstall: string
|
||||
pinToCommit: string
|
||||
pinToCommitPlaceholder: string
|
||||
pinToCommitHint: string
|
||||
pinToCommitInvalid: string
|
||||
install: string
|
||||
installing: string
|
||||
probing: string
|
||||
@@ -1331,6 +1335,8 @@ export interface Translations {
|
||||
catalogHint: string
|
||||
alreadyInstalled: (name: string) => string
|
||||
catalogProvenance: (sha: string) => string
|
||||
pinnedProvenance: (sha: string) => string
|
||||
pinnedBadge: (sha: string) => string
|
||||
tierOfficial: string
|
||||
tierCommunity: string
|
||||
updateToPin: (sha: string) => string
|
||||
|
||||
@@ -474,6 +474,11 @@ export const zh: Translations = {
|
||||
gitCloneLabel: 'Git 克隆地址',
|
||||
enableAgent: '安装后启用智能体插件',
|
||||
forceReinstall: '强制重装(替换已存在的安装)',
|
||||
pinToCommit: '固定到提交(可选)',
|
||||
pinToCommitPlaceholder: '完整的 40 位提交 SHA',
|
||||
pinToCommitHint:
|
||||
'安装同一 SHA 的所有人都会得到相同的代码;固定后插件将拒绝更新,直到重新固定。留空则安装最新提交。',
|
||||
pinToCommitInvalid: '必须是完整的 40 位提交 SHA(不接受分支和标签)。',
|
||||
install: '安装',
|
||||
installing: '正在安装…',
|
||||
probing: '正在检查仓库…',
|
||||
@@ -1690,6 +1695,8 @@ export const zh: Translations = {
|
||||
'点击任意插件上的「+ Add to this Agent」— 经过审核的条目会以其固定提交安装到所选配置。捆绑的 agent+桌面插件会同时提供两部分。',
|
||||
alreadyInstalled: (name: string) => `${name} 已安装在此配置中。`,
|
||||
catalogProvenance: (sha: string) => `从 Hermes 目录安装${sha ? `,固定提交 ${sha}` : ''}。`,
|
||||
pinnedProvenance: (sha: string) => `已固定到提交 ${sha}。重新固定前将拒绝更新。`,
|
||||
pinnedBadge: (sha: string) => `固定 @ ${sha}`,
|
||||
tierOfficial: '官方',
|
||||
tierCommunity: '社区',
|
||||
updateToPin: (sha: string) => `更新到 ${sha}`,
|
||||
|
||||
@@ -34,8 +34,13 @@ export interface AgentPluginRow {
|
||||
catalog_sha?: string
|
||||
/** Installed SHA differs from the catalog pin — an update is available. */
|
||||
update_available?: boolean
|
||||
/** Full commit SHA a `--ref` install is pinned to (custom sources; refuses `update`). */
|
||||
pinned_sha?: string
|
||||
}
|
||||
|
||||
/** A `--ref` pin is a full 40-hex commit SHA; branches and tags are refused server-side. */
|
||||
export const COMMIT_SHA_RE = /^[0-9a-f]{40}$/i
|
||||
|
||||
export type AgentPluginsStatus = 'idle' | 'loading' | 'ready' | 'error'
|
||||
|
||||
/** The recovering `requestGateway` from `useGatewayRequest`. */
|
||||
@@ -194,6 +199,8 @@ export async function installAgentPlugin(
|
||||
/** Curated-catalog install: the backend resolves repo + pinned SHA from
|
||||
* its own plugin-catalog and records provenance in the sidecar. */
|
||||
catalogName?: string
|
||||
/** Pin a custom source to one full commit SHA (team-wide reproducible install). */
|
||||
ref?: string
|
||||
/** Target profile's HERMES_HOME (null/undefined = backend launch profile). */
|
||||
profile?: string | null
|
||||
}
|
||||
@@ -213,7 +220,8 @@ export async function installAgentPlugin(
|
||||
identifier: opts.identifier,
|
||||
force: Boolean(opts.force),
|
||||
enable: opts.enable ?? true,
|
||||
...(opts.catalogName ? { catalog_name: opts.catalogName } : {})
|
||||
...(opts.catalogName ? { catalog_name: opts.catalogName } : {}),
|
||||
...(opts.ref ? { ref: opts.ref } : {})
|
||||
},
|
||||
opts.profile
|
||||
)
|
||||
|
||||
@@ -446,6 +446,19 @@ def _write_install_metadata(metadata: dict[str, dict[str, object]]) -> None:
|
||||
path, json.dumps(metadata, indent=2, sort_keys=True) + "\n", tmp_prefix=f"{path.name}.tmp-")
|
||||
|
||||
|
||||
def pinned_revision(name: str, metadata: Optional[dict] = None) -> Optional[str]:
|
||||
"""Full SHA a ``--ref`` install of *name* is pinned to, else ``None``."""
|
||||
entry = (metadata if metadata is not None else _read_install_metadata()).get(name)
|
||||
if isinstance(entry, dict) and entry.get("pinned") is True and isinstance(entry.get("revision"), str):
|
||||
return entry["revision"]
|
||||
return None
|
||||
|
||||
|
||||
def _pin_annotation(name: str, metadata: dict) -> Optional[str]:
|
||||
sha = pinned_revision(name, metadata)
|
||||
return f"git pinned@{sha[:8]}" if sha else None
|
||||
|
||||
|
||||
def _normalize_exact_revision(ref: str) -> str:
|
||||
"""Lowercase a full 40-hex commit SHA; anything else is a PluginOperationError."""
|
||||
if not isinstance(ref, str) or not _EXACT_COMMIT_RE.fullmatch(ref):
|
||||
@@ -1313,10 +1326,13 @@ def cmd_list(args: Any | None = None) -> None:
|
||||
disabled = _get_disabled_set()
|
||||
entries = _filter_plugin_entries(entries, args, enabled, disabled)
|
||||
from hermes_cli import plugins_cmd_catalog as catalog
|
||||
# Source shows catalog provenance (``catalog:<tier>@<sha8>``); a kill-listed install is flagged.
|
||||
# Source shows catalog provenance (``catalog:<tier>@<sha8>``) or a ``--ref`` pin
|
||||
# (``git pinned@<sha8>``) so a team can eyeball that everyone runs the same commit.
|
||||
pins = _read_install_metadata()
|
||||
rows = [
|
||||
(name, _plugin_status(name, enabled, disabled, key=key), str(version), description,
|
||||
catalog.catalog_annotation(_dir) or source, catalog.removed_annotation(name, _dir))
|
||||
catalog.catalog_annotation(_dir) or _pin_annotation(name, pins) or source,
|
||||
catalog.removed_annotation(name, _dir))
|
||||
for name, version, description, source, _dir, key in entries
|
||||
]
|
||||
|
||||
@@ -1686,9 +1702,11 @@ def _run_composite_fallback(plugin_keys, plugin_labels, plugin_selected, disable
|
||||
|
||||
def dashboard_install_plugin(
|
||||
identifier: str, *, force: bool, enable: bool, catalog_name: Optional[str] = None,
|
||||
ref: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Non-interactive install for the dashboard/TUI. *catalog_name* installs a curated entry at its
|
||||
pinned SHA (identifier may be empty); every path enforces the kill list (no GUI bypass)."""
|
||||
pinned SHA (identifier may be empty); *ref* pins a custom source to one full commit SHA (same
|
||||
contract as ``--ref``); every path enforces the kill list (no GUI bypass)."""
|
||||
from hermes_cli import plugins_cmd_catalog as catalog
|
||||
warnings: list[str] = []
|
||||
entry = None
|
||||
@@ -1713,7 +1731,8 @@ def dashboard_install_plugin(
|
||||
target, installed_manifest, installed_name = catalog.install_catalog_entry(
|
||||
entry, force=force, allow_removed=True)
|
||||
else:
|
||||
target, installed_manifest, installed_name = _install_plugin_core(identifier, force=force)
|
||||
target, installed_manifest, installed_name = _install_plugin_core(
|
||||
identifier, force=force, ref=(ref or "").strip() or None)
|
||||
except PluginScanBlocked as exc:
|
||||
fields = ("pattern_id", "severity", "category", "file", "line", "description")
|
||||
return {
|
||||
|
||||
@@ -498,6 +498,8 @@ class _AgentPluginInstallBody(BaseModel):
|
||||
enable: bool = True
|
||||
# Install by curated-catalog name (resolves repo + pinned SHA server-side).
|
||||
catalog_name: Optional[str] = None
|
||||
# Pin a custom source to one full 40-hex commit SHA (same contract as ``--ref``).
|
||||
ref: Optional[str] = None
|
||||
|
||||
class _PluginProvidersPutBody(BaseModel):
|
||||
memory_provider: Optional[str] = None
|
||||
|
||||
@@ -204,7 +204,7 @@ async def post_agent_plugin_install(request: Request, body: _AgentPluginInstallB
|
||||
if not identifier and not catalog_name:
|
||||
raise HTTPException(status_code=400, detail="Provide an identifier or a catalog_name.")
|
||||
result = dashboard_install_plugin(
|
||||
identifier, force=body.force, enable=body.enable, catalog_name=catalog_name or None)
|
||||
identifier, force=body.force, enable=body.enable, catalog_name=catalog_name or None, ref=body.ref)
|
||||
result = _plugin_action(result, "Install failed.", rescan=True)
|
||||
# Strip internal paths from the response
|
||||
result.pop("after_install_path", None)
|
||||
|
||||
@@ -38,6 +38,7 @@ def test_plugins_manage_install_success():
|
||||
force=True,
|
||||
enable=False,
|
||||
catalog_name=None,
|
||||
ref=None,
|
||||
)
|
||||
|
||||
|
||||
@@ -99,6 +100,7 @@ def test_plugins_manage_install_catalog_name_only():
|
||||
force=False,
|
||||
enable=False,
|
||||
catalog_name="weather-plugin",
|
||||
ref=None,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Desktop/TUI ``plugins.manage`` honours a ``ref`` pin exactly like ``hermes plugins install --ref``."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from tui_gateway import methods_tools, server
|
||||
|
||||
|
||||
def test_plugins_manage_install_threads_ref_to_headless_install():
|
||||
sha = "a" * 40
|
||||
with patch("hermes_cli.plugins_cmd.dashboard_install_plugin", return_value={"ok": True}) as install:
|
||||
server.handle_request({"id": "1", "method": "plugins.manage",
|
||||
"params": {"action": "install", "identifier": "org/private-plugin", "ref": sha}})
|
||||
assert install.call_args.kwargs["ref"] == sha
|
||||
|
||||
|
||||
def test_plugins_manage_list_reports_ref_pin(monkeypatch, tmp_path):
|
||||
sha = "b" * 40
|
||||
pc = methods_tools._tools_mod("hermes_cli.plugins_cmd")
|
||||
monkeypatch.setattr(pc, "_discover_all_plugins", lambda: [("team-plugin", "1.0", "", "git", tmp_path, "team-plugin")])
|
||||
monkeypatch.setattr(pc, "_read_install_metadata", lambda: {"team-plugin": {"pinned": True, "revision": sha, "source": "x"}})
|
||||
monkeypatch.setattr(pc, "_get_enabled_set", set)
|
||||
monkeypatch.setattr(pc, "_get_disabled_set", set)
|
||||
monkeypatch.setattr(methods_tools._tools_mod("hermes_cli.plugins_cmd_catalog"), "catalog_pins", dict)
|
||||
(row,) = methods_tools._plugin_rows()
|
||||
assert row["pinned_sha"] == sha
|
||||
@@ -1331,6 +1331,7 @@ def _plugin_rows() -> list[dict]:
|
||||
cat = _tools_mod("hermes_cli.plugins_cmd_catalog")
|
||||
enabled, disabled = pc._get_enabled_set(), pc._get_disabled_set()
|
||||
pins = cat.catalog_pins() # powers the desktop's "Update to <pin>" affordance
|
||||
ref_pins = pc._read_install_metadata() # ``--ref`` installs: pinned_sha so the desktop can show the pin
|
||||
out = []
|
||||
for name, version, desc, source, _dir, key in sorted(pc._discover_all_plugins()):
|
||||
status = pc._plugin_status(name, enabled, disabled, key=key)
|
||||
@@ -1342,7 +1343,8 @@ def _plugin_rows() -> list[dict]:
|
||||
out.append({
|
||||
"name": name, "key": key, "version": str(version or ""), "description": desc or "",
|
||||
"source": source, "status": status, "portable": pc._is_portable_plugin_dir(_dir),
|
||||
**cat.catalog_row_fields(_dir, pins)})
|
||||
**cat.catalog_row_fields(_dir, pins),
|
||||
**({"pinned_sha": sha} if (sha := pc.pinned_revision(name, ref_pins)) else {})})
|
||||
return out
|
||||
|
||||
|
||||
@@ -1373,7 +1375,8 @@ def _plugins_install(rid, params):
|
||||
if not ident and not catalog_name:
|
||||
return _err(rid, 4019, "plugins.install requires 'identifier', 'repo', or 'catalog_name'")
|
||||
result = _tools_mod("hermes_cli.plugins_cmd").dashboard_install_plugin(
|
||||
ident, force=bool(params.get("force")), enable=params.get("enable", True), catalog_name=catalog_name or None)
|
||||
ident, force=bool(params.get("force")), enable=params.get("enable", True), catalog_name=catalog_name or None,
|
||||
ref=str(params.get("ref") or "").strip() or None)
|
||||
return _ok(rid, result) if result.get("ok") else _err(rid, 5026, result.get("error") or "install failed")
|
||||
|
||||
|
||||
|
||||
@@ -468,7 +468,10 @@ Hermes, in two sections on one page:
|
||||
Discovery sits underneath: the live [Plugin Catalog](./features/plugin-catalog.md)
|
||||
picker installs reviewed entries at their pinned commit into the selected
|
||||
profile, and **Install from Git** takes any other repository through the same
|
||||
review-then-install dialog. Old `Settings → Plugins` links redirect here.
|
||||
review-then-install dialog; its optional **Pin to commit** field installs one
|
||||
exact 40-character commit SHA (private repos included), and pinned plugins
|
||||
carry a `pinned @ <sha8>` badge in the list. Old `Settings → Plugins` links
|
||||
redirect here.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
@@ -191,6 +191,13 @@ plugin; choose a new exact commit explicitly with
|
||||
profile-local install metadata contains no config values, environment values,
|
||||
secrets, or capability grants.
|
||||
|
||||
The same pin is available in Hermes Desktop: **Skills → Plugins → Install from
|
||||
Git** has a *Pin to commit* field that takes the full 40-character SHA, and the
|
||||
plugins list shows a `pinned @ <sha8>` badge on every pinned install so a team
|
||||
can confirm everyone is running the same commit. `hermes plugins list` prints
|
||||
the pin in its Source column (`git pinned@<sha8>`). Pins work for private
|
||||
repositories too, through the same stored credentials described below.
|
||||
|
||||
### Installing from a private repository
|
||||
|
||||
`hermes plugins install` clones non-interactively (it never prompts for a
|
||||
|
||||
Reference in New Issue
Block a user