fix(tui): route /skills subcommands through skills.manage instead of curses slash.exec
/skills install, inspect, search, browse, list now call the typed skills.manage RPC and render results via panel/page. Previously they fell through to slash.exec which invokes v1's curses code path — that hangs or crashes inside the Ink worker per the §2 parity-audit finding. Also drop Enter-as-install from the Skills Hub action stage since the Hub lists locally installed skills; primary action is inspect-and-close. x still triggers a manual reinstall for power users.
This commit is contained in:
parent
949b8f5521
commit
5e148ca3d0
@ -26,17 +26,55 @@ describe('createSlashHandler', () => {
|
||||
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls through /skills with args to slash.exec without opening overlay', () => {
|
||||
it('routes /skills install <name> to skills.manage without opening overlay', () => {
|
||||
const ctx = buildCtx()
|
||||
|
||||
expect(createSlashHandler(ctx)('/skills install foo')).toBe(true)
|
||||
expect(getOverlayState().skillsHub).toBe(false)
|
||||
expect(ctx.gateway.rpc).toHaveBeenCalledWith('slash.exec', {
|
||||
command: 'skills install foo',
|
||||
session_id: null
|
||||
expect(ctx.gateway.rpc).toHaveBeenCalledWith('skills.manage', {
|
||||
action: 'install',
|
||||
query: 'foo'
|
||||
})
|
||||
})
|
||||
|
||||
it('routes /skills inspect <name> to skills.manage', () => {
|
||||
const ctx = buildCtx()
|
||||
|
||||
createSlashHandler(ctx)('/skills inspect my-skill')
|
||||
expect(ctx.gateway.rpc).toHaveBeenCalledWith('skills.manage', {
|
||||
action: 'inspect',
|
||||
query: 'my-skill'
|
||||
})
|
||||
})
|
||||
|
||||
it('routes /skills search <query> to skills.manage', () => {
|
||||
const ctx = buildCtx()
|
||||
|
||||
createSlashHandler(ctx)('/skills search vibe')
|
||||
expect(ctx.gateway.rpc).toHaveBeenCalledWith('skills.manage', {
|
||||
action: 'search',
|
||||
query: 'vibe'
|
||||
})
|
||||
})
|
||||
|
||||
it('routes /skills browse [page] to skills.manage with a numeric page', () => {
|
||||
const ctx = buildCtx()
|
||||
|
||||
createSlashHandler(ctx)('/skills browse 3')
|
||||
expect(ctx.gateway.rpc).toHaveBeenCalledWith('skills.manage', {
|
||||
action: 'browse',
|
||||
page: 3
|
||||
})
|
||||
})
|
||||
|
||||
it('shows usage for an unknown /skills subcommand', () => {
|
||||
const ctx = buildCtx()
|
||||
|
||||
createSlashHandler(ctx)('/skills zzz')
|
||||
expect(ctx.gateway.rpc).not.toHaveBeenCalled()
|
||||
expect(ctx.transcript.sys).toHaveBeenCalledWith(expect.stringContaining('usage: /skills'))
|
||||
})
|
||||
|
||||
it('cycles details mode and persists it', async () => {
|
||||
const ctx = buildCtx()
|
||||
|
||||
|
||||
@ -1,26 +1,158 @@
|
||||
import type { SlashExecResponse, ToolsConfigureResponse } from '../../../gatewayTypes.js'
|
||||
import type { ToolsConfigureResponse } from '../../../gatewayTypes.js'
|
||||
import type { PanelSection } from '../../../types.js'
|
||||
import { patchOverlayState } from '../../overlayStore.js'
|
||||
import type { SlashCommand } from '../types.js'
|
||||
|
||||
interface SkillInfo {
|
||||
category?: string
|
||||
description?: string
|
||||
name?: string
|
||||
path?: string
|
||||
}
|
||||
|
||||
interface SkillsListResponse {
|
||||
skills?: Record<string, string[]>
|
||||
}
|
||||
|
||||
interface SkillsInspectResponse {
|
||||
info?: SkillInfo
|
||||
}
|
||||
|
||||
interface SkillsSearchResponse {
|
||||
results?: { description?: string; name: string }[]
|
||||
}
|
||||
|
||||
interface SkillsInstallResponse {
|
||||
installed?: boolean
|
||||
name?: string
|
||||
}
|
||||
|
||||
export const opsCommands: SlashCommand[] = [
|
||||
{
|
||||
help: 'browse, inspect, and install skills',
|
||||
help: 'browse, inspect, install skills',
|
||||
name: 'skills',
|
||||
run: (arg, ctx) => {
|
||||
if (!arg.trim()) {
|
||||
const text = arg.trim()
|
||||
|
||||
if (!text) {
|
||||
return patchOverlayState({ skillsHub: true })
|
||||
}
|
||||
|
||||
ctx.gateway
|
||||
.rpc<SlashExecResponse>('slash.exec', { command: `skills ${arg}`, session_id: ctx.sid })
|
||||
.then(
|
||||
ctx.guarded<SlashExecResponse>(r => {
|
||||
if (r.output) {
|
||||
ctx.transcript.page(r.output, 'Skills')
|
||||
}
|
||||
})
|
||||
)
|
||||
.catch(ctx.guardedErr)
|
||||
const [sub, ...rest] = text.split(/\s+/)
|
||||
const query = rest.join(' ').trim()
|
||||
const { rpc } = ctx.gateway
|
||||
const { page, panel, sys } = ctx.transcript
|
||||
|
||||
if (sub === 'list') {
|
||||
rpc<SkillsListResponse>('skills.manage', { action: 'list' })
|
||||
.then(
|
||||
ctx.guarded<SkillsListResponse>(r => {
|
||||
const cats = Object.entries(r.skills ?? {}).sort()
|
||||
|
||||
if (!cats.length) {
|
||||
return sys('no skills available')
|
||||
}
|
||||
|
||||
panel(
|
||||
'Skills',
|
||||
cats.map<PanelSection>(([title, items]) => ({ items, title }))
|
||||
)
|
||||
})
|
||||
)
|
||||
.catch(ctx.guardedErr)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'inspect') {
|
||||
if (!query) {
|
||||
return sys('usage: /skills inspect <name>')
|
||||
}
|
||||
|
||||
rpc<SkillsInspectResponse>('skills.manage', { action: 'inspect', query })
|
||||
.then(
|
||||
ctx.guarded<SkillsInspectResponse>(r => {
|
||||
const info = r.info ?? {}
|
||||
|
||||
if (!info.name) {
|
||||
return sys(`unknown skill: ${query}`)
|
||||
}
|
||||
|
||||
const rows: [string, string][] = [
|
||||
['Name', String(info.name)],
|
||||
['Category', String(info.category ?? '')],
|
||||
['Path', String(info.path ?? '')]
|
||||
]
|
||||
|
||||
const sections: PanelSection[] = [{ rows }]
|
||||
|
||||
if (info.description) {
|
||||
sections.push({ text: String(info.description) })
|
||||
}
|
||||
|
||||
panel('Skill', sections)
|
||||
})
|
||||
)
|
||||
.catch(ctx.guardedErr)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'search') {
|
||||
if (!query) {
|
||||
return sys('usage: /skills search <query>')
|
||||
}
|
||||
|
||||
rpc<SkillsSearchResponse>('skills.manage', { action: 'search', query })
|
||||
.then(
|
||||
ctx.guarded<SkillsSearchResponse>(r => {
|
||||
const results = r.results ?? []
|
||||
|
||||
if (!results.length) {
|
||||
return sys(`no results for: ${query}`)
|
||||
}
|
||||
|
||||
panel(`Search: ${query}`, [{ rows: results.map(s => [s.name, s.description ?? '']) }])
|
||||
})
|
||||
)
|
||||
.catch(ctx.guardedErr)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'install') {
|
||||
if (!query) {
|
||||
return sys('usage: /skills install <name or url>')
|
||||
}
|
||||
|
||||
sys(`installing ${query}…`)
|
||||
|
||||
rpc<SkillsInstallResponse>('skills.manage', { action: 'install', query })
|
||||
.then(
|
||||
ctx.guarded<SkillsInstallResponse>(r =>
|
||||
sys(r.installed ? `installed ${r.name ?? query}` : 'install failed')
|
||||
)
|
||||
)
|
||||
.catch(ctx.guardedErr)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'browse') {
|
||||
const pageNum = parseInt(query, 10) || 1
|
||||
|
||||
rpc<Record<string, unknown>>('skills.manage', { action: 'browse', page: pageNum })
|
||||
.then(
|
||||
ctx.guarded<Record<string, unknown>>(r =>
|
||||
page(JSON.stringify(r, null, 2).slice(0, 4000), `Browse Skills — p${pageNum}`)
|
||||
)
|
||||
)
|
||||
.catch(ctx.guardedErr)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
sys('usage: /skills [list | inspect <n> | install <n> | search <q> | browse [page]]')
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@ -89,10 +89,16 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
|
||||
}
|
||||
|
||||
if (stage === 'actions') {
|
||||
if (key.return || ch.toLowerCase() === 'x') {
|
||||
if (skillName) {
|
||||
install(skillName)
|
||||
}
|
||||
if (key.return) {
|
||||
setStage('skill')
|
||||
setInfo(null)
|
||||
setErr('')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (ch.toLowerCase() === 'x' && skillName) {
|
||||
install(skillName)
|
||||
|
||||
return
|
||||
}
|
||||
@ -271,7 +277,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
|
||||
{err ? <Text color={t.color.label}>error: {err}</Text> : null}
|
||||
{installing ? <Text color={t.color.amber}>installing…</Text> : null}
|
||||
|
||||
<Text color={t.color.dim}>Enter install · i inspect · x install · Esc back</Text>
|
||||
<Text color={t.color.dim}>i reinspect · x reinstall · Enter/Esc back</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user