Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions tests/tui_gateway/test_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -1692,6 +1692,34 @@ def test_skills_manage_search_uses_tools_hub_sources(server):
search.assert_called_once_with("showroom", ["source"], source_filter="all", limit=20)


def test_skills_manage_install_requires_review_surface(server):
inspect_skill = MagicMock(return_value={"name": "showroom", "skill_md_preview": "# showroom"})
do_install = MagicMock()
fake_skills_hub = types.SimpleNamespace(
do_install=do_install,
inspect_skill=inspect_skill,
)

with patch.dict(sys.modules, {"hermes_cli.skills_hub": fake_skills_hub}):
resp = server.handle_request({
"id": "skills-install",
"method": "skills.manage",
"params": {"action": "install", "query": "showroom"},
})

assert "error" not in resp
assert resp["result"] == {
"info": {"name": "showroom", "skill_md_preview": "# showroom"},
"installed": False,
"message": "Review the skill details before installing from the CLI.",
"name": "showroom",
"review_required": True,
"status": "review_required",
}
inspect_skill.assert_called_once_with("showroom")
do_install.assert_not_called()


def test_command_dispatch_steer_fallback_sends_message(server):
"""command.dispatch /steer with no active agent falls back to send."""
sid = "test-session"
Expand Down
19 changes: 12 additions & 7 deletions tui_gateway/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -15761,14 +15761,19 @@ def _(rid, params: dict) -> dict:
},
)
if action == "install":
from hermes_cli.skills_hub import do_install

class _Q:
def print(self, *a, **k):
pass
from hermes_cli.skills_hub import inspect_skill

do_install(query, skip_confirm=True, console=_Q())
return _ok(rid, {"installed": True, "name": query})
return _ok(
rid,
{
"installed": False,
"name": query,
"status": "review_required",
"review_required": True,
"message": "Review the skill details before installing from the CLI.",
"info": inspect_skill(query) or {},
},
)
if action == "browse":
from hermes_cli.skills_hub import browse_skills

Expand Down
44 changes: 44 additions & 0 deletions ui-tui/src/__tests__/createSlashHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,50 @@ describe('createSlashHandler', () => {
})
})

it('shows review-required status for /skills install', async () => {
const rpc = vi.fn(() =>
Promise.resolve({
info: {
description: 'Demo skill',
identifier: 'example/foo',
name: 'foo',
skill_md_preview: '# foo\nreview me',
source: 'community'
},
installed: false,
message: 'Review the skill details before installing from the CLI.',
review_required: true,
status: 'review_required'
})
)

const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })

expect(createSlashHandler(ctx)('/skills install foo')).toBe(true)
expect(ctx.gateway.rpc).toHaveBeenCalledWith('skills.manage', {
action: 'install',
query: 'foo'
})
expect(ctx.transcript.sys).toHaveBeenCalledWith('checking install requirements for foo…')

await vi.waitFor(() => {
expect(ctx.transcript.panel).toHaveBeenCalledWith('Skill Review Required', [
{
rows: [
['Name', 'foo'],
['Source', 'community'],
['Identifier', 'example/foo']
]
},
{ text: 'Demo skill' },
{ text: '# foo\nreview me', title: 'SKILL.md preview' }
])
expect(ctx.transcript.sys).toHaveBeenCalledWith(
'review required for foo: Review the skill details before installing from the CLI.'
)
})
})

it('opens the pet picker for /pet list only', () => {
const ctx = buildCtx()

Expand Down
39 changes: 36 additions & 3 deletions ui-tui/src/app/slash/commands/ops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,11 @@ import type { SlashCommand } from '../types.js'
interface SkillInfo {
category?: string
description?: string
identifier?: string
name?: string
path?: string
skill_md_preview?: string
source?: string
}

interface SkillsListResponse {
Expand All @@ -39,8 +42,12 @@ interface SkillsSearchResponse {
}

interface SkillsInstallResponse {
info?: SkillInfo
installed?: boolean
message?: string
name?: string
review_required?: boolean
status?: string
}

interface SkillsBrowseItem {
Expand Down Expand Up @@ -604,13 +611,39 @@ export const opsCommands: SlashCommand[] = [
return sys('usage: /skills install <name or url>')
}

sys(`installing ${query}…`)
sys(`checking install requirements for ${query}…`)

rpc<SkillsInstallResponse>('skills.manage', { action: 'install', query })
.then(
ctx.guarded<SkillsInstallResponse>(r =>
ctx.guarded<SkillsInstallResponse>(r => {
if (r.review_required) {
const info = r.info ?? {}
const name = info.name ?? r.name ?? query

const rows: [string, string][] = [
['Name', String(name)],
['Source', String(info.source ?? '')],
['Identifier', String(info.identifier ?? info.path ?? query)]
]

const sections: PanelSection[] = [{ rows }]

if (info.description) {
sections.push({ text: String(info.description) })
}

if (info.skill_md_preview) {
sections.push({ text: String(info.skill_md_preview), title: 'SKILL.md preview' })
}

panel('Skill Review Required', sections)
sys(`review required for ${name}: ${r.message ?? 'inspect the skill before installing from the CLI'}`)

return
}

sys(r.installed ? `installed ${r.name ?? query}` : 'install failed')
)
})
)
.catch(ctx.guardedErr)

Expand Down
49 changes: 43 additions & 6 deletions ui-tui/src/components/skillsHub.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
const [info, setInfo] = useState<null | SkillInfo>(null)
const [installing, setInstalling] = useState(false)
const [err, setErr] = useState('')
const [notice, setNotice] = useState('')
const [loading, setLoading] = useState(true)

const { stdout } = useStdout()
Expand Down Expand Up @@ -47,6 +48,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
setStage('skill')
setInfo(null)
setErr('')
setNotice('')

return
}
Expand All @@ -66,6 +68,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
const inspect = (name: string) => {
setInfo(null)
setErr('')
setNotice('')

gw.request<{ info?: SkillInfo }>('skills.manage', { action: 'inspect', query: name })
.then(r => setInfo(r?.info ?? { name }))
Expand All @@ -75,9 +78,25 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
const install = (name: string) => {
setInstalling(true)
setErr('')
setNotice('')

gw.request<{ installed?: boolean; name?: string }>('skills.manage', { action: 'install', query: name })
.then(() => onClose())
gw.request<SkillsInstallResponse>('skills.manage', { action: 'install', query: name })
.then(r => {
if (r?.review_required) {
setInfo(r.info ?? { name })
setNotice(r.message ?? 'Review required before installing this skill.')

return
}

if (r?.installed) {
onClose()

return
}

setErr('install failed')
})
.catch((e: unknown) => setErr(rpcErrorMessage(e)))
.finally(() => setInstalling(false))
}
Expand All @@ -92,6 +111,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
setStage('skill')
setInfo(null)
setErr('')
setNotice('')

return
}
Expand Down Expand Up @@ -285,11 +305,18 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
<Text color={t.color.muted}>{info?.category ?? selectedCat}</Text>
{info?.description ? <Text color={t.color.text}>{info.description}</Text> : null}
{info?.path ? <Text color={t.color.muted}>path: {info.path}</Text> : null}
{!info && !err ? <Text color={t.color.muted}>loading…</Text> : null}
{!info && !err && !notice ? <Text color={t.color.muted}>loading…</Text> : null}
{notice ? <Text color={t.color.label}>review required: {notice}</Text> : null}
{err ? <Text color={t.color.label}>error: {err}</Text> : null}
{installing ? <Text color={t.color.accent}>installing…</Text> : null}

<OverlayHint t={t}>i reinspect · x reinstall · Enter/Esc back · q close</OverlayHint>
{info?.skill_md_preview ? (
<>
<Text color={t.color.muted}>SKILL.md preview</Text>
<Text color={t.color.text}>{info.skill_md_preview}</Text>
</>
) : null}
{installing ? <Text color={t.color.accent}>checking install requirements…</Text> : null}

<OverlayHint t={t}>i reinspect · x review install · Enter/Esc back · q close</OverlayHint>
</Box>
)
}
Expand All @@ -299,6 +326,16 @@ interface SkillInfo {
description?: string
name?: string
path?: string
skill_md_preview?: string
}

interface SkillsInstallResponse {
info?: SkillInfo
installed?: boolean
message?: string
name?: string
review_required?: boolean
status?: string
}

interface SkillsHubProps {
Expand Down
Loading