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:
@@ -26,17 +26,55 @@ describe('createSlashHandler', () => {
|
|||||||
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
|
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()
|
const ctx = buildCtx()
|
||||||
|
|
||||||
expect(createSlashHandler(ctx)('/skills install foo')).toBe(true)
|
expect(createSlashHandler(ctx)('/skills install foo')).toBe(true)
|
||||||
expect(getOverlayState().skillsHub).toBe(false)
|
expect(getOverlayState().skillsHub).toBe(false)
|
||||||
expect(ctx.gateway.rpc).toHaveBeenCalledWith('slash.exec', {
|
expect(ctx.gateway.rpc).toHaveBeenCalledWith('skills.manage', {
|
||||||
command: 'skills install foo',
|
action: 'install',
|
||||||
session_id: null
|
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 () => {
|
it('cycles details mode and persists it', async () => {
|
||||||
const ctx = buildCtx()
|
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 { patchOverlayState } from '../../overlayStore.js'
|
||||||
import type { SlashCommand } from '../types.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[] = [
|
export const opsCommands: SlashCommand[] = [
|
||||||
{
|
{
|
||||||
help: 'browse, inspect, and install skills',
|
help: 'browse, inspect, install skills',
|
||||||
name: 'skills',
|
name: 'skills',
|
||||||
run: (arg, ctx) => {
|
run: (arg, ctx) => {
|
||||||
if (!arg.trim()) {
|
const text = arg.trim()
|
||||||
|
|
||||||
|
if (!text) {
|
||||||
return patchOverlayState({ skillsHub: true })
|
return patchOverlayState({ skillsHub: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.gateway
|
const [sub, ...rest] = text.split(/\s+/)
|
||||||
.rpc<SlashExecResponse>('slash.exec', { command: `skills ${arg}`, session_id: ctx.sid })
|
const query = rest.join(' ').trim()
|
||||||
.then(
|
const { rpc } = ctx.gateway
|
||||||
ctx.guarded<SlashExecResponse>(r => {
|
const { page, panel, sys } = ctx.transcript
|
||||||
if (r.output) {
|
|
||||||
ctx.transcript.page(r.output, 'Skills')
|
if (sub === 'list') {
|
||||||
}
|
rpc<SkillsListResponse>('skills.manage', { action: 'list' })
|
||||||
})
|
.then(
|
||||||
)
|
ctx.guarded<SkillsListResponse>(r => {
|
||||||
.catch(ctx.guardedErr)
|
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 (stage === 'actions') {
|
||||||
if (key.return || ch.toLowerCase() === 'x') {
|
if (key.return) {
|
||||||
if (skillName) {
|
setStage('skill')
|
||||||
install(skillName)
|
setInfo(null)
|
||||||
}
|
setErr('')
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ch.toLowerCase() === 'x' && skillName) {
|
||||||
|
install(skillName)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -271,7 +277,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
|
|||||||
{err ? <Text color={t.color.label}>error: {err}</Text> : null}
|
{err ? <Text color={t.color.label}>error: {err}</Text> : null}
|
||||||
{installing ? <Text color={t.color.amber}>installing…</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>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user