From af83bbfab984bbd9b2d71fc98093eb83dde1b75f Mon Sep 17 00:00:00 2001 From: WTW0313 Date: Wed, 1 Jul 2026 17:02:02 +0800 Subject: [PATCH 01/15] docs: record responsive TUI polish decisions (#12) --- CONTEXT.md | 8 ++++++++ docs/adr/0023-support-responsive-terminal-envelope.md | 3 +++ .../0024-prioritize-frequent-use-efficiency-in-the-tui.md | 3 +++ .../0025-preserve-core-skill-state-in-compact-layouts.md | 3 +++ docs/adr/0026-centralize-responsive-tui-layout-rules.md | 3 +++ docs/adr/0027-keep-inventory-as-a-dense-list.md | 3 +++ ...028-use-sectioned-detail-view-for-compact-terminals.md | 3 +++ .../0029-use-bounded-scrolling-in-all-content-views.md | 3 +++ docs/adr/0030-use-adaptive-shortcuts-with-help-overlay.md | 3 +++ docs/adr/0031-confirm-destructive-and-broad-actions.md | 3 +++ docs/adr/0032-keep-direct-shortcuts-primary.md | 3 +++ docs/adr/0033-do-not-rely-on-color-or-glyphs-alone.md | 3 +++ docs/adr/0034-defer-mouse-support-for-tui-polish.md | 3 +++ .../0035-verify-tui-layout-at-terminal-envelope-sizes.md | 3 +++ 14 files changed, 47 insertions(+) create mode 100644 docs/adr/0023-support-responsive-terminal-envelope.md create mode 100644 docs/adr/0024-prioritize-frequent-use-efficiency-in-the-tui.md create mode 100644 docs/adr/0025-preserve-core-skill-state-in-compact-layouts.md create mode 100644 docs/adr/0026-centralize-responsive-tui-layout-rules.md create mode 100644 docs/adr/0027-keep-inventory-as-a-dense-list.md create mode 100644 docs/adr/0028-use-sectioned-detail-view-for-compact-terminals.md create mode 100644 docs/adr/0029-use-bounded-scrolling-in-all-content-views.md create mode 100644 docs/adr/0030-use-adaptive-shortcuts-with-help-overlay.md create mode 100644 docs/adr/0031-confirm-destructive-and-broad-actions.md create mode 100644 docs/adr/0032-keep-direct-shortcuts-primary.md create mode 100644 docs/adr/0033-do-not-rely-on-color-or-glyphs-alone.md create mode 100644 docs/adr/0034-defer-mouse-support-for-tui-polish.md create mode 100644 docs/adr/0035-verify-tui-layout-at-terminal-envelope-sizes.md diff --git a/CONTEXT.md b/CONTEXT.md index 700e741..b3b2bcd 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -52,6 +52,14 @@ _Avoid_: Installed skill, Skillpack-created skill The cross-provider view of discovered skills, their provenance, availability, and health. _Avoid_: Skill editor, authoring workspace +**Terminal Envelope**: +The terminal size range the TUI deliberately designs and verifies for: a full baseline layout at 80x24, a compact usable layout down to 60x18, and a minimal too-small layout below that. +_Avoid_: Responsive target, screen size + +**Terminal Compatibility**: +The TUI's ability to remain understandable across terminals with different color, glyph, width, and resize behavior. +_Avoid_: Pretty terminal rendering, theme support + **Scan Root**: A directory Skillpack inspects to discover provider, shared global, or project skills. _Avoid_: Skill, provider, install source diff --git a/docs/adr/0023-support-responsive-terminal-envelope.md b/docs/adr/0023-support-responsive-terminal-envelope.md new file mode 100644 index 0000000..2d621d9 --- /dev/null +++ b/docs/adr/0023-support-responsive-terminal-envelope.md @@ -0,0 +1,3 @@ +# Support responsive Terminal Envelope + +Skillpack will treat 80x24 as the full TUI baseline, support a compact usable layout down to 60x18, and show a minimal too-small layout below that. This keeps polish work grounded in explicit terminal constraints instead of chasing every possible terminal size with the same dense layout. diff --git a/docs/adr/0024-prioritize-frequent-use-efficiency-in-the-tui.md b/docs/adr/0024-prioritize-frequent-use-efficiency-in-the-tui.md new file mode 100644 index 0000000..c6a53fc --- /dev/null +++ b/docs/adr/0024-prioritize-frequent-use-efficiency-in-the-tui.md @@ -0,0 +1,3 @@ +# Prioritize frequent-use efficiency in the TUI + +Skillpack will optimize TUI polish for frequent-use efficiency while keeping enough inline state, empty states, and errors for first-time clarity. This means dense rows, compact shortcuts, and direct action feedback should take precedence over persistent tutorial-style explanatory copy in the main flow. diff --git a/docs/adr/0025-preserve-core-skill-state-in-compact-layouts.md b/docs/adr/0025-preserve-core-skill-state-in-compact-layouts.md new file mode 100644 index 0000000..4f79aca --- /dev/null +++ b/docs/adr/0025-preserve-core-skill-state-in-compact-layouts.md @@ -0,0 +1,3 @@ +# Preserve core Skill state in compact layouts + +When the TUI enters compact layouts, Skillpack will preserve the selected Skill name, Skill Provider, Skill Availability, and warning or update state before secondary metadata. Counts, long paths, descriptions, and some shortcut labels may be shortened or hidden first so narrow terminals still answer what the user is focused on, where it applies, and what action is safe. diff --git a/docs/adr/0026-centralize-responsive-tui-layout-rules.md b/docs/adr/0026-centralize-responsive-tui-layout-rules.md new file mode 100644 index 0000000..c126384 --- /dev/null +++ b/docs/adr/0026-centralize-responsive-tui-layout-rules.md @@ -0,0 +1,3 @@ +# Centralize responsive TUI layout rules + +Skillpack will centralize shared responsive TUI rules in small layout primitives and hooks while letting each view decide its essential content. The current views repeat terminal row calculations and fixed column widths, so common handling for Terminal Envelope bands, truncation, reserved chrome, and shortcut overflow should keep responsive behavior consistent without making every view identical. diff --git a/docs/adr/0027-keep-inventory-as-a-dense-list.md b/docs/adr/0027-keep-inventory-as-a-dense-list.md new file mode 100644 index 0000000..f371584 --- /dev/null +++ b/docs/adr/0027-keep-inventory-as-a-dense-list.md @@ -0,0 +1,3 @@ +# Keep Inventory as a dense list + +Skillpack will keep the main Skill Inventory as a table-like dense list rather than a card-style layout. The list should become more forgiving through adaptive column widths, clearer selected state, compact badges, and better empty or error states, because card layouts would spend too much vertical space inside the compact Terminal Envelope. diff --git a/docs/adr/0028-use-sectioned-detail-view-for-compact-terminals.md b/docs/adr/0028-use-sectioned-detail-view-for-compact-terminals.md new file mode 100644 index 0000000..dcf24c6 --- /dev/null +++ b/docs/adr/0028-use-sectioned-detail-view-for-compact-terminals.md @@ -0,0 +1,3 @@ +# Use sectioned Detail view for compact terminals + +Skillpack Detail views will keep summary and provider-native state visible first, then expose Paths, Source, Description, Actions, and Warnings as sections when terminal space is constrained. At the full 80x24 Terminal Envelope baseline the view may read as one page when content fits, but compact layouts should prevent long paths or descriptions from crowding out Skill Availability and action state. diff --git a/docs/adr/0029-use-bounded-scrolling-in-all-content-views.md b/docs/adr/0029-use-bounded-scrolling-in-all-content-views.md new file mode 100644 index 0000000..da5af06 --- /dev/null +++ b/docs/adr/0029-use-bounded-scrolling-in-all-content-views.md @@ -0,0 +1,3 @@ +# Use bounded scrolling in all content views + +Every Skillpack content view that can exceed the viewport will use explicit bounded scrolling within the Terminal Envelope. Inventory, Project Skills, Settings, Updates results, Install results, and Detail sections should reserve stable space for headers and status bars, then scroll only their content region so compact terminals do not hide navigation or action state. diff --git a/docs/adr/0030-use-adaptive-shortcuts-with-help-overlay.md b/docs/adr/0030-use-adaptive-shortcuts-with-help-overlay.md new file mode 100644 index 0000000..0d18fc9 --- /dev/null +++ b/docs/adr/0030-use-adaptive-shortcuts-with-help-overlay.md @@ -0,0 +1,3 @@ +# Use adaptive shortcuts with help overlay + +Skillpack will make the shortcut/status bar adaptive across the Terminal Envelope: full labels at baseline widths, reduced high-priority shortcuts in compact widths, and a `?` help overlay for the complete shortcut list. This avoids overflowing narrow terminals while keeping keyboard discovery available without persistent tutorial text. diff --git a/docs/adr/0031-confirm-destructive-and-broad-actions.md b/docs/adr/0031-confirm-destructive-and-broad-actions.md new file mode 100644 index 0000000..7160bcd --- /dev/null +++ b/docs/adr/0031-confirm-destructive-and-broad-actions.md @@ -0,0 +1,3 @@ +# Confirm destructive and broad actions + +Skillpack will require clear confirmation for actions that delete content, update content, or toggle a Plugin-Owned Skill whose provider state affects sibling skills. Ordinary provider-native single-Skill availability toggles may remain one-keystroke actions, but they should produce transient success or error feedback so the user can verify the result without rescanning mentally. diff --git a/docs/adr/0032-keep-direct-shortcuts-primary.md b/docs/adr/0032-keep-direct-shortcuts-primary.md new file mode 100644 index 0000000..52ec76d --- /dev/null +++ b/docs/adr/0032-keep-direct-shortcuts-primary.md @@ -0,0 +1,3 @@ +# Keep direct shortcuts primary + +Skillpack will keep direct keyboard shortcuts as the primary interaction model for the TUI polish pass. A full command palette is deferred until the responsive layout and contextual help model are stable, because adding another navigation model now would increase complexity without addressing the current Terminal Envelope problems. diff --git a/docs/adr/0033-do-not-rely-on-color-or-glyphs-alone.md b/docs/adr/0033-do-not-rely-on-color-or-glyphs-alone.md new file mode 100644 index 0000000..76b5f02 --- /dev/null +++ b/docs/adr/0033-do-not-rely-on-color-or-glyphs-alone.md @@ -0,0 +1,3 @@ +# Do not rely on color or glyphs alone + +Skillpack TUI state will remain understandable without relying on color or symbolic glyphs alone. Colors and tasteful glyphs may improve scanning, but important state such as enabled, disabled, warning, update available, and read-only should also have text labels or an ASCII-safe fallback to preserve Terminal Compatibility. diff --git a/docs/adr/0034-defer-mouse-support-for-tui-polish.md b/docs/adr/0034-defer-mouse-support-for-tui-polish.md new file mode 100644 index 0000000..1727211 --- /dev/null +++ b/docs/adr/0034-defer-mouse-support-for-tui-polish.md @@ -0,0 +1,3 @@ +# Defer mouse support for TUI polish + +Skillpack will not include mouse support in the current TUI polish pass. Keyboard behavior, bounded scrolling, action clarity, and Terminal Envelope responsiveness are the priority because they directly affect the existing TUI management workflow. diff --git a/docs/adr/0035-verify-tui-layout-at-terminal-envelope-sizes.md b/docs/adr/0035-verify-tui-layout-at-terminal-envelope-sizes.md new file mode 100644 index 0000000..74f9d8c --- /dev/null +++ b/docs/adr/0035-verify-tui-layout-at-terminal-envelope-sizes.md @@ -0,0 +1,3 @@ +# Verify TUI layout at Terminal Envelope sizes + +Responsive TUI changes will be verified at the Terminal Envelope sizes: 80x24, 60x18, and below-minimum dimensions. Tests should focus on shared layout helpers and representative view states for visible core state, bounded scrolling, stable reserved regions, and no obvious overflow, with manual terminal smoke checks before release rather than exhaustive snapshots of every row. From ed350387088c617b031022235c45fd0f9572c048 Mon Sep 17 00:00:00 2001 From: WTW0313 Date: Wed, 1 Jul 2026 17:02:18 +0800 Subject: [PATCH 02/15] feat(tui): add responsive inventory foundation (#13) --- packages/tui/src/components/skill-row.tsx | 29 ++- packages/tui/src/lib/responsive-layout.ts | 257 +++++++++++++++++++ packages/tui/src/views/list-view.tsx | 32 ++- packages/tui/tests/responsive-layout.test.ts | 182 +++++++++++++ 4 files changed, 476 insertions(+), 24 deletions(-) create mode 100644 packages/tui/src/lib/responsive-layout.ts create mode 100644 packages/tui/tests/responsive-layout.test.ts diff --git a/packages/tui/src/components/skill-row.tsx b/packages/tui/src/components/skill-row.tsx index c1cdc07..2787f5b 100644 --- a/packages/tui/src/components/skill-row.tsx +++ b/packages/tui/src/components/skill-row.tsx @@ -1,41 +1,46 @@ import { Box, Text } from 'ink'; import type { Skill } from '@skillpack/core'; +import { fitCell, formatInventoryStatus, getGlyphSet, type InventoryLayout } from '../lib/responsive-layout.js'; export const COL_NAME_WIDTH = 30; export const COL_AGENT_WIDTH = 10; -function truncate(s: string, max: number): string { - if (s.length <= max) return s.padEnd(max); - return s.slice(0, max - 1) + '…'; -} - interface SkillRowProps { skill: Skill; isSelected: boolean; isDuplicate: boolean; + columns?: InventoryLayout['columns']; + statusVariant?: 'full' | 'compact'; } -export function SkillRow({ skill, isSelected, isDuplicate }: SkillRowProps) { - const statusIcon = skill.enabled ? '●' : '○'; +export function SkillRow({ + skill, + isSelected, + isDuplicate, + columns = { name: COL_NAME_WIDTH, provider: COL_AGENT_WIDTH, status: 8 }, + statusVariant = 'full', +}: SkillRowProps) { + const statusLabel = formatInventoryStatus(skill.enabled, statusVariant); const statusColor = skill.enabled ? 'green' : undefined; + const glyphs = getGlyphSet(); return ( - {isSelected ? '❯' : ' '} + {isSelected ? glyphs.selected : ' '} - {truncate(skill.name, COL_NAME_WIDTH)} + {fitCell(skill.name, columns.name)} - {truncate(skill.provider, COL_AGENT_WIDTH)} + {fitCell(skill.provider, columns.provider)} - {statusIcon} + {fitCell(statusLabel, columns.status)} - {isDuplicate && } + {isDuplicate && {glyphs.warning}} ); } diff --git a/packages/tui/src/lib/responsive-layout.ts b/packages/tui/src/lib/responsive-layout.ts new file mode 100644 index 0000000..b25de35 --- /dev/null +++ b/packages/tui/src/lib/responsive-layout.ts @@ -0,0 +1,257 @@ +export type TerminalMode = 'full' | 'compact' | 'too-small'; +export type StatusBarVariant = 'full' | 'compact' | 'minimal'; + +export interface TerminalSize { + columns: number; + rows: number; +} + +export interface GlyphSet { + brand: string; + selected: string; + warning: string; + enabled: string; + disabled: string; +} + +export function getTerminalMode(size: TerminalSize): TerminalMode { + if (size.columns < 60 || size.rows < 18) return 'too-small'; + if (size.columns < 80 || size.rows < 24) return 'compact'; + return 'full'; +} + +export function shouldUseAsciiGlyphs(env: NodeJS.ProcessEnv = process.env): boolean { + return env.SKILLPACK_ASCII === '1' || env.TERM === 'dumb'; +} + +export function getGlyphSet(ascii = shouldUseAsciiGlyphs()): GlyphSet { + return ascii + ? { brand: '*', selected: '>', warning: '!', enabled: '+', disabled: '-' } + : { brand: '◆', selected: '❯', warning: '⚠', enabled: '●', disabled: '○' }; +} + +export function fitCell(value: string, width: number): string { + if (width <= 0) return ''; + if (value.length <= width) return value.padEnd(width); + if (width === 1) return '…'; + return `${value.slice(0, width - 1)}…`; +} + +export function formatInventoryStatus(enabled: boolean, variant: 'full' | 'compact'): string { + if (variant === 'compact') return fitCell(enabled ? 'on' : 'off', COMPACT_STATUS_WIDTH); + return fitCell(enabled ? 'enabled' : 'disabled', FULL_STATUS_WIDTH); +} + +export interface InventoryLayoutInput { + size: TerminalSize; + searching: boolean; + hasSearchQuery: boolean; + skillCount: number; +} + +export interface InventoryLayout { + mode: TerminalMode; + visibleRows: number; + rowWidth: number; + statusBarVariant: StatusBarVariant; + columns: { + name: number; + provider: number; + status: number; + }; +} + +export interface ShortcutLayoutItem { + key: string; + label: string; + compactLabel?: string; + displayLabel?: string; +} + +export type DetailSectionId = 'summary' | 'paths' | 'source' | 'description' | 'warnings' | 'actions'; + +export interface DetailSection { + id: DetailSectionId; + label: string; +} + +export interface DetailLayoutInput { + size: TerminalSize; + hasDescription: boolean; + hasWarnings: boolean; +} + +export interface DetailLayout { + mode: TerminalMode; + sectioned: boolean; + visibleRows: number; + sections: DetailSection[]; +} + +export interface BoundedContentLayoutInput { + size: TerminalSize; + fullChromeLines: number; + compactChromeLines: number; +} + +export interface BoundedContentLayout { + mode: TerminalMode; + visibleRows: number; +} + +export interface ProjectSkillsColumns { + name: number; + path: number; + state: number; + rowWidth: number; +} + +export interface SettingsColumns { + scope: number; + kind: number; + status: number; + path: number; + rowWidth: number; +} + +const FULL_CHROME_LINES = 7; +const COMPACT_CHROME_LINES = 5; +const HORIZONTAL_PADDING = 2; +const MIN_NAME_WIDTH = 12; +const MAX_NAME_WIDTH = 30; +const FULL_PROVIDER_WIDTH = 10; +const COMPACT_PROVIDER_WIDTH = 8; +const FULL_STATUS_WIDTH = 8; +const COMPACT_STATUS_WIDTH = 3; +const ROW_FIXED_WIDTH = 6; +const DETAIL_FULL_CHROME_LINES = 13; +const DETAIL_COMPACT_CHROME_LINES = 6; +const DETAIL_SECTIONS: DetailSection[] = [ + { id: 'summary', label: 'Summary' }, + { id: 'paths', label: 'Paths' }, + { id: 'source', label: 'Source' }, + { id: 'description', label: 'Description' }, + { id: 'warnings', label: 'Warnings' }, + { id: 'actions', label: 'Actions' }, +]; +const PROJECT_STATE_WIDTH = 15; +const SETTINGS_STATUS_WIDTH = 8; + +export function getInventoryLayout(input: InventoryLayoutInput): InventoryLayout { + const mode = getTerminalMode(input.size); + if (mode === 'too-small') { + return { + mode, + visibleRows: 0, + rowWidth: Math.max(0, input.size.columns), + statusBarVariant: 'minimal', + columns: { name: 0, provider: 0, status: 0 }, + }; + } + + const provider = mode === 'full' ? FULL_PROVIDER_WIDTH : COMPACT_PROVIDER_WIDTH; + const status = mode === 'full' ? FULL_STATUS_WIDTH : COMPACT_STATUS_WIDTH; + const contentWidth = Math.max(0, input.size.columns - HORIZONTAL_PADDING); + const availableNameWidth = contentWidth - ROW_FIXED_WIDTH - provider - status; + const name = Math.max(MIN_NAME_WIDTH, Math.min(MAX_NAME_WIDTH, availableNameWidth)); + const rowWidth = ROW_FIXED_WIDTH + name + provider + status; + const searchLines = input.searching ? 2 : 0; + const filterLines = !input.searching && input.hasSearchQuery ? 1 : 0; + const chromeLines = mode === 'full' ? FULL_CHROME_LINES : COMPACT_CHROME_LINES; + + return { + mode, + visibleRows: Math.max(1, input.size.rows - chromeLines - searchLines - filterLines), + rowWidth, + statusBarVariant: mode === 'full' ? 'full' : 'compact', + columns: { name, provider, status }, + }; +} + +export function measureShortcutLine(shortcuts: ShortcutLayoutItem[]): number { + return shortcuts.reduce((width, shortcut, index) => { + const label = shortcut.displayLabel ?? shortcut.label; + return width + shortcut.key.length + 1 + label.length + (index > 0 ? 2 : 0); + }, 0); +} + +export function getVisibleShortcuts( + shortcuts: ShortcutLayoutItem[], + size: TerminalSize, +): Array { + const mode = getTerminalMode(size); + const maxWidth = Math.max(0, size.columns - HORIZONTAL_PADDING); + const result: Array = []; + + for (const shortcut of shortcuts) { + const displayLabel = mode === 'compact' + ? (shortcut.compactLabel ?? shortcut.label) + : shortcut.label; + const next = [...result, { ...shortcut, displayLabel }]; + if (mode === 'compact' && measureShortcutLine(next) > maxWidth) break; + result.push({ ...shortcut, displayLabel }); + } + + return result; +} + +export function getDetailLayout(input: DetailLayoutInput): DetailLayout { + const mode = getTerminalMode(input.size); + if (mode === 'too-small') { + return { + mode, + sectioned: true, + visibleRows: 0, + sections: DETAIL_SECTIONS, + }; + } + + const chromeLines = mode === 'full' ? DETAIL_FULL_CHROME_LINES : DETAIL_COMPACT_CHROME_LINES; + return { + mode, + sectioned: mode === 'compact', + visibleRows: Math.max(1, input.size.rows - chromeLines), + sections: DETAIL_SECTIONS, + }; +} + +export function getBoundedContentLayout(input: BoundedContentLayoutInput): BoundedContentLayout { + const mode = getTerminalMode(input.size); + if (mode === 'too-small') return { mode, visibleRows: 0 }; + const chromeLines = mode === 'full' ? input.fullChromeLines : input.compactChromeLines; + return { + mode, + visibleRows: Math.max(1, input.size.rows - chromeLines), + }; +} + +export function getProjectSkillsColumns(size: TerminalSize): ProjectSkillsColumns { + const mode = getTerminalMode(size); + const contentWidth = Math.max(0, size.columns - HORIZONTAL_PADDING); + const state = mode === 'compact' ? 14 : PROJECT_STATE_WIDTH; + const fixedWidth = 5 + state; + const name = Math.max(12, Math.min(30, Math.floor((contentWidth - fixedWidth) * 0.4))); + const path = Math.max(12, contentWidth - fixedWidth - name); + return { + name, + path, + state, + rowWidth: fixedWidth + name + path, + }; +} + +export function getSettingsColumns(size: TerminalSize): SettingsColumns { + const contentWidth = Math.max(0, size.columns - HORIZONTAL_PADDING); + const scope = Math.max(8, Math.min(12, Math.floor(contentWidth * 0.2))); + const kind = Math.max(8, Math.min(13, Math.floor(contentWidth * 0.22))); + const status = SETTINGS_STATUS_WIDTH; + const fixedWidth = scope + kind + status + 3; + const path = Math.max(10, contentWidth - fixedWidth); + return { + scope, + kind, + status, + path, + rowWidth: fixedWidth + path, + }; +} diff --git a/packages/tui/src/views/list-view.tsx b/packages/tui/src/views/list-view.tsx index bf67cb8..2e0f3e3 100644 --- a/packages/tui/src/views/list-view.tsx +++ b/packages/tui/src/views/list-view.tsx @@ -5,15 +5,14 @@ import { useAppContext } from '../context/app-context.js'; import { useFilteredSkills, TABS } from '../hooks/use-skills.js'; import { useTerminalSize } from '../hooks/use-terminal-size.js'; import { TabBar } from '../components/tab-bar.js'; -import { SkillRow, COL_NAME_WIDTH, COL_AGENT_WIDTH } from '../components/skill-row.js'; +import { SkillRow } from '../components/skill-row.js'; import { SearchInput } from '../components/search-input.js'; import { StatusBar } from '../components/status-bar.js'; import { ConfirmDialog } from '../components/confirm-dialog.js'; import { formatPluginToggleMessage, isPluginOwnedSkill } from '../lib/plugin-toggle.js'; +import { fitCell, getGlyphSet, getInventoryLayout } from '../lib/responsive-layout.js'; import type { Skill } from '@skillpack/core'; -const CHROME_LINES = 7; - export function ListView() { const { exit } = useApp(); const { @@ -21,17 +20,22 @@ export function ListView() { searchQuery, setSearchQuery, refresh, manager, skills: allSkills, } = useAppContext(); const { skills, tabs } = useFilteredSkills(); - const { rows } = useTerminalSize(); + const { columns, rows } = useTerminalSize(); const [cursor, setCursor] = useState(0); const [scrollOffset, setScrollOffset] = useState(0); const [searching, setSearching] = useState(false); const [confirmingPluginToggle, setConfirmingPluginToggle] = useState(null); + const glyphs = getGlyphSet(); const prevSkillsLenRef = useRef(skills.length); - const searchLines = searching ? 2 : 0; - const filterLines = (!searching && searchQuery) ? 1 : 0; - const visibleRows = Math.max(1, rows - CHROME_LINES - searchLines - filterLines); + const layout = getInventoryLayout({ + size: { columns, rows }, + searching, + hasSearchQuery: Boolean(searchQuery), + skillCount: skills.length, + }); + const visibleRows = layout.visibleRows; useEffect(() => { refresh(); }, [refresh]); useEffect(() => { setCursor(0); setScrollOffset(0); }, [activeTab]); @@ -141,7 +145,9 @@ export function ListView() { } const showScroll = skills.length > visibleRows; - const scrollBarHeight = Math.max(1, Math.round(visibleRows * (visibleRows / skills.length))); + const scrollBarHeight = showScroll + ? Math.max(1, Math.round(visibleRows * (visibleRows / skills.length))) + : 0; const scrollBarOffset = skills.length <= visibleRows ? 0 : Math.round(scrollOffset / (skills.length - visibleRows) * (visibleRows - scrollBarHeight)); @@ -150,7 +156,7 @@ export function ListView() { {/* Header */} - ◆ skillpack + {glyphs.brand} skillpack {skills.length} skill{skills.length !== 1 ? 's' : ''} {showScroll && ( {scrollOffset + 1}–{Math.min(scrollOffset + visibleRows, skills.length)} of {skills.length} @@ -187,9 +193,9 @@ export function ListView() { {' '} - {'NAME'.padEnd(COL_NAME_WIDTH)} - {'AGENT'.padEnd(COL_AGENT_WIDTH)} - {'⏻'} + {fitCell('NAME', layout.columns.name)} + {fitCell('AGENT', layout.columns.provider)} + {fitCell('STATE', layout.columns.status)} @@ -223,6 +229,8 @@ export function ListView() { skill={skill} isSelected={scrollOffset + index === cursor} isDuplicate={manager.isDuplicate(skill.name)} + columns={layout.columns} + statusVariant={layout.statusBarVariant === 'compact' ? 'compact' : 'full'} /> )) )} diff --git a/packages/tui/tests/responsive-layout.test.ts b/packages/tui/tests/responsive-layout.test.ts new file mode 100644 index 0000000..3695f91 --- /dev/null +++ b/packages/tui/tests/responsive-layout.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from 'vitest'; +import { + fitCell, + getBoundedContentLayout, + getDetailLayout, + getGlyphSet, + getProjectSkillsColumns, + getSettingsColumns, + formatInventoryStatus, + getVisibleShortcuts, + getInventoryLayout, + getTerminalMode, + measureShortcutLine, +} from '../src/lib/responsive-layout.js'; + +describe('getTerminalMode', () => { + it('classifies the agreed Terminal Envelope sizes', () => { + expect(getTerminalMode({ columns: 80, rows: 24 })).toBe('full'); + expect(getTerminalMode({ columns: 60, rows: 18 })).toBe('compact'); + expect(getTerminalMode({ columns: 59, rows: 18 })).toBe('too-small'); + expect(getTerminalMode({ columns: 60, rows: 17 })).toBe('too-small'); + }); +}); + +describe('getGlyphSet', () => { + it('provides default and ASCII-safe terminal glyphs', () => { + expect(getGlyphSet(false).selected).toBe('❯'); + expect(getGlyphSet(false).warning).toBe('⚠'); + expect(getGlyphSet(true).selected).toBe('>'); + expect(getGlyphSet(true).warning).toBe('!'); + }); +}); + +describe('read-only view columns', () => { + it('keeps Project Skills rows within compact width', () => { + const columns = getProjectSkillsColumns({ columns: 60, rows: 18 }); + + expect(columns.name).toBeGreaterThanOrEqual(12); + expect(columns.path).toBeGreaterThanOrEqual(12); + expect(columns.state).toBeGreaterThanOrEqual(8); + expect(columns.rowWidth).toBeLessThanOrEqual(58); + }); + + it('keeps Settings rows within compact width', () => { + const columns = getSettingsColumns({ columns: 60, rows: 18 }); + + expect(columns.scope).toBeGreaterThanOrEqual(8); + expect(columns.kind).toBeGreaterThanOrEqual(8); + expect(columns.status).toBe(8); + expect(columns.path).toBeGreaterThanOrEqual(10); + expect(columns.rowWidth).toBeLessThanOrEqual(58); + }); +}); + +describe('getBoundedContentLayout', () => { + it('reserves stable chrome and gives workflow views bounded rows', () => { + const full = getBoundedContentLayout({ + size: { columns: 80, rows: 24 }, + fullChromeLines: 6, + compactChromeLines: 4, + }); + + expect(full.mode).toBe('full'); + expect(full.visibleRows).toBe(18); + + const compact = getBoundedContentLayout({ + size: { columns: 60, rows: 18 }, + fullChromeLines: 6, + compactChromeLines: 4, + }); + + expect(compact.mode).toBe('compact'); + expect(compact.visibleRows).toBe(14); + }); +}); + +describe('getDetailLayout', () => { + it('uses one-page detail at full size and sections at compact size', () => { + const full = getDetailLayout({ size: { columns: 80, rows: 24 }, hasDescription: true, hasWarnings: true }); + + expect(full.mode).toBe('full'); + expect(full.sectioned).toBe(false); + expect(full.sections.map((section) => section.id)).toEqual(['summary', 'paths', 'source', 'description', 'warnings', 'actions']); + + const compact = getDetailLayout({ size: { columns: 60, rows: 18 }, hasDescription: true, hasWarnings: true }); + + expect(compact.mode).toBe('compact'); + expect(compact.sectioned).toBe(true); + expect(compact.visibleRows).toBeGreaterThan(0); + expect(compact.sections.map((section) => section.id)).toEqual(['summary', 'paths', 'source', 'description', 'warnings', 'actions']); + }); +}); + +describe('shortcut layout', () => { + const shortcuts = [ + { key: '↑↓', label: 'navigate', compactLabel: 'nav' }, + { key: 'space', label: 'toggle when supported', compactLabel: 'toggle' }, + { key: 'enter', label: 'detail' }, + { key: '/', label: 'search' }, + { key: '?', label: 'help' }, + { key: 'tab', label: 'tabs' }, + { key: 'p', label: 'project' }, + { key: 's', label: 'settings' }, + { key: 'u', label: 'updates' }, + { key: 'i', label: 'install' }, + { key: 'q', label: 'quit' }, + ]; + + it('keeps all shortcuts at full width', () => { + const visible = getVisibleShortcuts(shortcuts, { columns: 120, rows: 24 }); + + expect(visible.map((shortcut) => shortcut.key)).toEqual(shortcuts.map((shortcut) => shortcut.key)); + }); + + it('keeps compact shortcuts within the footer width and preserves help', () => { + const visible = getVisibleShortcuts(shortcuts, { columns: 60, rows: 18 }); + + expect(visible.length).toBeLessThan(shortcuts.length); + expect(visible.map((shortcut) => shortcut.key)).toContain('?'); + expect(measureShortcutLine(visible)).toBeLessThanOrEqual(58); + }); +}); + +describe('Inventory row formatting', () => { + it('truncates and pads cells to a stable width', () => { + expect(fitCell('short', 8)).toBe('short '); + expect(fitCell('very-long-skill-name', 10)).toBe('very-long…'); + expect(fitCell('anything', 0)).toBe(''); + }); + + it('uses full and compact availability labels', () => { + expect(formatInventoryStatus(true, 'full')).toBe('enabled '); + expect(formatInventoryStatus(false, 'full')).toBe('disabled'); + expect(formatInventoryStatus(true, 'compact')).toBe('on '); + expect(formatInventoryStatus(false, 'compact')).toBe('off'); + }); +}); + +describe('getInventoryLayout', () => { + it('keeps Inventory usable at full and compact Terminal Envelope sizes', () => { + const full = getInventoryLayout({ + size: { columns: 80, rows: 24 }, + searching: false, + hasSearchQuery: false, + skillCount: 25, + }); + + expect(full.mode).toBe('full'); + expect(full.visibleRows).toBe(17); + expect(full.statusBarVariant).toBe('full'); + expect(full.columns.name).toBe(30); + expect(full.columns.provider).toBe(10); + expect(full.columns.status).toBe(8); + + const compact = getInventoryLayout({ + size: { columns: 60, rows: 18 }, + searching: false, + hasSearchQuery: false, + skillCount: 25, + }); + + expect(compact.mode).toBe('compact'); + expect(compact.visibleRows).toBeGreaterThan(0); + expect(compact.statusBarVariant).toBe('compact'); + expect(compact.columns.name).toBeGreaterThanOrEqual(12); + expect(compact.columns.provider).toBeGreaterThanOrEqual(6); + expect(compact.rowWidth).toBeLessThanOrEqual(58); + }); + + it('returns a minimal layout below the compact Terminal Envelope', () => { + const layout = getInventoryLayout({ + size: { columns: 59, rows: 18 }, + searching: false, + hasSearchQuery: false, + skillCount: 25, + }); + + expect(layout.mode).toBe('too-small'); + expect(layout.visibleRows).toBe(0); + expect(layout.statusBarVariant).toBe('minimal'); + }); +}); From 7e1680fd64e0dad28a7d596fcf32049260e686e3 Mon Sep 17 00:00:00 2001 From: WTW0313 Date: Wed, 1 Jul 2026 17:02:31 +0800 Subject: [PATCH 03/15] feat(tui): add adaptive shortcuts and help overlay (#14) --- packages/tui/src/app.tsx | 37 ++++++++- packages/tui/src/components/help-overlay.tsx | 42 ++++++++++ packages/tui/src/components/status-bar.tsx | 82 +++---------------- packages/tui/src/lib/shortcuts.ts | 86 ++++++++++++++++++++ 4 files changed, 174 insertions(+), 73 deletions(-) create mode 100644 packages/tui/src/components/help-overlay.tsx create mode 100644 packages/tui/src/lib/shortcuts.ts diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index bd6cc68..07c4353 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -1,4 +1,6 @@ +import { useState } from 'react'; import { Box, Text } from 'ink'; +import { useInput } from 'ink'; import { Spinner } from '@inkjs/ui'; import { AppProvider, useAppContext } from './context/app-context.js'; import { useSkillManager } from './hooks/use-skill-manager.js'; @@ -9,6 +11,8 @@ import { ProjectSkillsView } from './views/project-skills-view.js'; import { SettingsView } from './views/settings-view.js'; import { UpdatesView } from './views/updates-view.js'; import { useTerminalSize } from './hooks/use-terminal-size.js'; +import { getTerminalMode } from './lib/responsive-layout.js'; +import { HelpOverlay } from './components/help-overlay.js'; function Router() { const { view } = useAppContext(); @@ -23,9 +27,26 @@ function Router() { } } +function AppFrame() { + const [helpOpen, setHelpOpen] = useState(false); + + useInput((input, key) => { + if (input === '?') { + setHelpOpen((open) => !open); + return; + } + if (key.escape && helpOpen) { + setHelpOpen(false); + } + }); + + return helpOpen ? setHelpOpen(false)} /> : ; +} + export function App() { const { manager, config, error } = useSkillManager(); - const { rows } = useTerminalSize(); + const { columns, rows } = useTerminalSize(); + const terminalMode = getTerminalMode({ columns, rows }); if (error) { return ( @@ -43,10 +64,22 @@ export function App() { ); } + if (terminalMode === 'too-small') { + return ( + + skillpack + Terminal too small + Resize to at least 60x18. + + q quit + + ); + } + return ( - + ); diff --git a/packages/tui/src/components/help-overlay.tsx b/packages/tui/src/components/help-overlay.tsx new file mode 100644 index 0000000..43827b4 --- /dev/null +++ b/packages/tui/src/components/help-overlay.tsx @@ -0,0 +1,42 @@ +import { Box, Text } from 'ink'; +import { useAppContext } from '../context/app-context.js'; +import { getShortcutsForView } from '../lib/shortcuts.js'; + +interface HelpOverlayProps { + onClose: () => void; +} + +function formatViewTitle(view: string): string { + if (view === 'list') return 'Inventory'; + if (view === 'project') return 'Project Skills'; + return view[0].toUpperCase() + view.slice(1); +} + +export function HelpOverlay(_props: HelpOverlayProps) { + const { view, selectedSkill, manager } = useAppContext(); + const canToggle = selectedSkill + ? Boolean(manager.getProvider(selectedSkill.provider)?.getDisableStrategy(selectedSkill)) + : false; + const shortcuts = getShortcutsForView({ view, selectedSkill, canToggle }); + + return ( + + + ‹ esc/? + {formatViewTitle(view)} Help + + + + {shortcuts.map((shortcut) => ( + + {shortcut.key.padEnd(8)} + {shortcut.label} + + ))} + + + + esc or ? to close + + ); +} diff --git a/packages/tui/src/components/status-bar.tsx b/packages/tui/src/components/status-bar.tsx index ccc672d..e46f8d5 100644 --- a/packages/tui/src/components/status-bar.tsx +++ b/packages/tui/src/components/status-bar.tsx @@ -1,86 +1,26 @@ import { Box, Text } from 'ink'; import { useAppContext } from '../context/app-context.js'; - -interface Shortcut { - key: string; - label: string; -} - -const SHORTCUTS: Record = { - list: [ - { key: '↑↓', label: 'navigate' }, - { key: 'space', label: 'toggle when supported' }, - { key: 'enter', label: 'detail' }, - { key: '/', label: 'search' }, - { key: 'tab', label: 'tabs' }, - { key: 'p', label: 'project' }, - { key: 's', label: 'settings' }, - { key: 'u', label: 'updates' }, - { key: 'i', label: 'install' }, - { key: 'q', label: 'quit' }, - ], - install: [ - { key: 'esc', label: 'back' }, - { key: '↑↓', label: 'navigate' }, - { key: 'enter', label: 'select' }, - ], - project: [ - { key: 'esc', label: 'back' }, - { key: '↑↓', label: 'navigate' }, - { key: 'q', label: 'quit' }, - ], - settings: [ - { key: 'esc', label: 'back' }, - { key: '↑↓', label: 'scroll' }, - { key: 'q', label: 'quit' }, - ], - updates: [ - { key: 'esc', label: 'back' }, - { key: 'enter', label: 'check/apply' }, - { key: 'r', label: 'recheck' }, - { key: '↑↓', label: 'navigate' }, - { key: 'q', label: 'quit' }, - ], -}; - -const DETAIL_BASE: Shortcut[] = [ - { key: 'esc', label: 'back' }, -]; - -const DETAIL_TAIL: Shortcut[] = [ - { key: 'o', label: 'open folder' }, -]; +import { useTerminalSize } from '../hooks/use-terminal-size.js'; +import { getVisibleShortcuts } from '../lib/responsive-layout.js'; +import { getShortcutsForView } from '../lib/shortcuts.js'; export function StatusBar() { const { view, selectedSkill, manager } = useAppContext(); - - let shortcuts: Shortcut[]; - if (view === 'detail') { - const sourceType = selectedSkill?.source?.type; - const isUpdatable = sourceType === 'skillssh'; - const isRemovable = selectedSkill?.provider === 'global' && sourceType === 'skillssh'; - const canToggle = selectedSkill - ? Boolean(manager.getProvider(selectedSkill.provider)?.getDisableStrategy(selectedSkill)) - : false; - const detailBase = canToggle - ? [...DETAIL_BASE, { key: 'space', label: selectedSkill?.origin?.type === 'plugin' ? 'toggle plugin' : 'toggle' }] - : DETAIL_BASE; - const tail = isRemovable ? [...DETAIL_TAIL, { key: 'd', label: 'delete' }] : DETAIL_TAIL; - shortcuts = isUpdatable - ? [...detailBase, { key: 'u', label: 'check update' }, ...tail] - : [...detailBase, ...tail]; - } else { - shortcuts = SHORTCUTS[view] ?? []; - } + const { columns, rows } = useTerminalSize(); + const canToggle = selectedSkill + ? Boolean(manager.getProvider(selectedSkill.provider)?.getDisableStrategy(selectedSkill)) + : false; + const shortcuts = getShortcutsForView({ view, selectedSkill, canToggle }); + const visibleShortcuts = getVisibleShortcuts(shortcuts, { columns, rows }); return ( {'─'.repeat(2)} - {shortcuts.map((s, i) => ( + {visibleShortcuts.map((s, i) => ( {i > 0 && } {s.key} - {s.label} + {s.displayLabel} ))} diff --git a/packages/tui/src/lib/shortcuts.ts b/packages/tui/src/lib/shortcuts.ts new file mode 100644 index 0000000..5ad9c80 --- /dev/null +++ b/packages/tui/src/lib/shortcuts.ts @@ -0,0 +1,86 @@ +import type { Skill } from '@skillpack/core'; +import type { ViewType } from '../context/app-context.js'; +import type { ShortcutLayoutItem } from './responsive-layout.js'; + +export interface Shortcut extends ShortcutLayoutItem {} + +const SHORTCUTS: Record = { + list: [ + { key: '↑↓', label: 'navigate', compactLabel: 'nav' }, + { key: 'space', label: 'toggle when supported', compactLabel: 'toggle' }, + { key: 'enter', label: 'detail' }, + { key: '/', label: 'search' }, + { key: '?', label: 'help' }, + { key: 'tab', label: 'tabs' }, + { key: 'p', label: 'project' }, + { key: 's', label: 'settings' }, + { key: 'u', label: 'updates' }, + { key: 'i', label: 'install' }, + { key: 'q', label: 'quit' }, + ], + install: [ + { key: 'esc', label: 'back' }, + { key: '?', label: 'help' }, + { key: '↑↓', label: 'navigate', compactLabel: 'nav' }, + { key: 'enter', label: 'select' }, + ], + project: [ + { key: 'esc', label: 'back' }, + { key: '?', label: 'help' }, + { key: '↑↓', label: 'navigate', compactLabel: 'nav' }, + { key: 'q', label: 'quit' }, + ], + settings: [ + { key: 'esc', label: 'back' }, + { key: '?', label: 'help' }, + { key: '↑↓', label: 'scroll' }, + { key: 'q', label: 'quit' }, + ], + updates: [ + { key: 'esc', label: 'back' }, + { key: '?', label: 'help' }, + { key: 'enter', label: 'check/apply', compactLabel: 'apply' }, + { key: 'r', label: 'recheck' }, + { key: '↑↓', label: 'navigate', compactLabel: 'nav' }, + { key: 'q', label: 'quit' }, + ], +}; + +const DETAIL_BASE: Shortcut[] = [ + { key: 'esc', label: 'back' }, + { key: '?', label: 'help' }, + { key: 'tab', label: 'sections' }, +]; + +const DETAIL_TAIL: Shortcut[] = [ + { key: 'o', label: 'open folder', compactLabel: 'open' }, +]; + +interface ShortcutContext { + view: ViewType; + selectedSkill: Skill | null; + canToggle: boolean; +} + +export function getShortcutsForView({ view, selectedSkill, canToggle }: ShortcutContext): Shortcut[] { + if (view !== 'detail') return SHORTCUTS[view] ?? []; + + const sourceType = selectedSkill?.source?.type; + const isUpdatable = sourceType === 'skillssh'; + const isRemovable = selectedSkill?.provider === 'global' && sourceType === 'skillssh'; + const detailBase = canToggle + ? [ + ...DETAIL_BASE, + { + key: 'space', + label: selectedSkill?.origin?.type === 'plugin' ? 'toggle plugin' : 'toggle', + compactLabel: 'toggle', + }, + ] + : DETAIL_BASE; + const tail = isRemovable ? [...DETAIL_TAIL, { key: 'd', label: 'delete' }] : DETAIL_TAIL; + + return isUpdatable + ? [...detailBase, { key: 'u', label: 'check update', compactLabel: 'update' }, ...tail] + : [...detailBase, ...tail]; +} From 79c20fe54811941916c4a6049805bafaececfd70 Mon Sep 17 00:00:00 2001 From: WTW0313 Date: Wed, 1 Jul 2026 17:02:45 +0800 Subject: [PATCH 04/15] feat(tui): make detail view sectioned and responsive (#15) --- packages/tui/src/views/detail-view.tsx | 248 +++++++++++++++++++++++-- 1 file changed, 229 insertions(+), 19 deletions(-) diff --git a/packages/tui/src/views/detail-view.tsx b/packages/tui/src/views/detail-view.tsx index 2fc5cb2..2b6d234 100644 --- a/packages/tui/src/views/detail-view.tsx +++ b/packages/tui/src/views/detail-view.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo } from 'react'; +import { useState, useMemo, type ReactNode } from 'react'; import { Box, Text, useInput } from 'ink'; import { Spinner } from '@inkjs/ui'; import { execSync } from 'node:child_process'; @@ -7,6 +7,7 @@ import { useTerminalSize } from '../hooks/use-terminal-size.js'; import { ConfirmDialog } from '../components/confirm-dialog.js'; import { StatusBar } from '../components/status-bar.js'; import { formatPluginToggleMessage, isPluginOwnedSkill } from '../lib/plugin-toggle.js'; +import { getDetailLayout, getGlyphSet, type DetailSectionId } from '../lib/responsive-layout.js'; import type { UpdateInfo } from '@skillpack/core'; function formatRelativeTime(iso: string): string { @@ -26,14 +27,17 @@ function formatRelativeTime(iso: string): string { export function DetailView() { const { selectedSkill, setView, refresh, manager } = useAppContext(); - const { rows } = useTerminalSize(); - const [confirming, setConfirming] = useState<'remove' | 'plugin-toggle' | null>(null); + const { columns, rows } = useTerminalSize(); + const [confirming, setConfirming] = useState<'remove' | 'plugin-toggle' | 'update' | null>(null); + const [activeSection, setActiveSection] = useState('summary'); const [descScroll, setDescScroll] = useState(0); const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); const [busy, setBusy] = useState(false); const [updateInfo, setUpdateInfo] = useState(null); const [checkingUpdate, setCheckingUpdate] = useState(false); const [updating, setUpdating] = useState(false); + const glyphs = getGlyphSet(); const sourceType = selectedSkill?.source?.type; const isUpdatable = sourceType === 'skillssh'; @@ -56,7 +60,13 @@ export function DetailView() { return selectedSkill.description.split('\n'); }, [selectedSkill]); - const visibleDescRows = useMemo(() => { + const detailLayout = getDetailLayout({ + size: { columns, rows }, + hasDescription: descLines.length > 0, + hasWarnings: Boolean(duplicate), + }); + + const fullVisibleDescRows = useMemo(() => { if (!selectedSkill) return 0; let used = 2; // padding (top + bottom) used += 1; // title @@ -77,8 +87,20 @@ export function DetailView() { return Math.max(0, rows - used); }, [selectedSkill, duplicate, error, rows, isUpdatable, disableStrategy]); + const visibleDescRows = detailLayout.sectioned + ? Math.max(1, detailLayout.visibleRows - 1) + : fullVisibleDescRows; + useInput((input, key) => { if (key.escape) { setView('list'); return; } + if (key.tab && detailLayout.sectioned) { + const idx = detailLayout.sections.findIndex((section) => section.id === activeSection); + const next = key.shift + ? (idx - 1 + detailLayout.sections.length) % detailLayout.sections.length + : (idx + 1) % detailLayout.sections.length; + setActiveSection(detailLayout.sections[next].id); + return; + } if ((input === 'o' || input === 'O') && selectedSkill) { const opener = process.platform === 'darwin' ? 'open' : 'xdg-open'; try { @@ -92,9 +114,11 @@ export function DetailView() { return; } setError(null); + setNotice(null); setBusy(true); manager.toggleSkill(selectedSkill) .then(() => refresh()) + .then(() => setNotice('Availability updated.')) .catch((err: Error) => setError(err.message)) .finally(() => setBusy(false)); return; @@ -105,18 +129,9 @@ export function DetailView() { } if (input === 'u' && selectedSkill && isUpdatable && !busy && !checkingUpdate && !updating) { setError(null); + setNotice(null); if (updateInfo?.hasUpdate) { - setUpdating(true); - manager.updateSkill(selectedSkill) - .then(() => refresh()) - .then(() => { - setUpdateInfo(null); - setUpdating(false); - }) - .catch((err: Error) => { - setError(err.message); - setUpdating(false); - }); + setConfirming('update'); } else if (!updateInfo) { setCheckingUpdate(true); manager.checkSkillUpdate(selectedSkill) @@ -125,10 +140,10 @@ export function DetailView() { .finally(() => setCheckingUpdate(false)); } } - if (key.downArrow) { + if (key.downArrow && (!detailLayout.sectioned || activeSection === 'description')) { setDescScroll((s) => Math.min(s + 1, Math.max(0, descLines.length - visibleDescRows))); } - if (key.upArrow) { + if (key.upArrow && (!detailLayout.sectioned || activeSection === 'description')) { setDescScroll((s) => Math.max(0, s - 1)); } }, { isActive: !confirming }); @@ -168,6 +183,7 @@ export function DetailView() { setBusy(true); manager.toggleSkill(selectedSkill) .then(() => refresh()) + .then(() => setNotice('Plugin availability updated.')) .catch((err: Error) => setError(err.message)) .finally(() => { setBusy(false); @@ -180,15 +196,204 @@ export function DetailView() { ); } + if (confirming === 'update') { + return ( + + { + setError(null); + setNotice(null); + setUpdating(true); + manager.updateSkill(selectedSkill) + .then(() => refresh()) + .then(() => { + setUpdateInfo(null); + setNotice('Skill updated.'); + }) + .catch((err: Error) => setError(err.message)) + .finally(() => { + setUpdating(false); + setConfirming(null); + }); + }} + onCancel={() => setConfirming(null)} + /> + + ); + } + const visibleDesc = descLines.slice(descScroll, descScroll + visibleDescRows); const descScrollable = descLines.length > visibleDescRows; + if (detailLayout.sectioned) { + const renderSection = (): ReactNode => { + switch (activeSection) { + case 'summary': + return ( + <> + + {'agent'.padEnd(10)} + {selectedSkill.provider} + + + {'status'.padEnd(10)} + + {selectedSkill.enabled ? 'enabled' : 'disabled'} + + + {selectedSkill.version && ( + + {'version'.padEnd(10)} + {selectedSkill.version} + + )} + {addedAt && ( + + {'added'.padEnd(10)} + {formatRelativeTime(addedAt)} + + )} + + ); + case 'paths': + return ( + + path + {selectedSkill.path} + {selectedSkill.resolvedPath && ( + <> + resolved + {selectedSkill.resolvedPath} + + )} + + ); + case 'source': + return ( + <> + {selectedSkill.source && ( + + {'source'.padEnd(10)} + {selectedSkill.source.type}{selectedSkill.source.repo ? ` ${selectedSkill.source.repo}` : ''} + + )} + {selectedSkill.origin?.type === 'plugin' && ( + <> + + {'plugin'.padEnd(10)} + {selectedSkill.origin.displayName ?? selectedSkill.origin.pluginName} + + + {'plugin on'.padEnd(10)} + {selectedSkill.origin.pluginEnabled ? 'enabled' : 'disabled'} + + + )} + {disableStrategy && ( + + {'toggle'.padEnd(10)} + {disableStrategy.description} + + )} + + ); + case 'description': + return descLines.length === 0 ? ( + No description. + ) : ( + + {descScrollable && ( + {descScroll + 1}-{Math.min(descScroll + visibleDescRows, descLines.length)} of {descLines.length} + )} + {visibleDesc.map((line, i) => ( + {line} + ))} + + ); + case 'warnings': + return duplicate ? ( + + Duplicates + {duplicate.instances.map((inst) => ( + + {inst.provider} {'->'} {inst.path} + + ))} + + ) : ( + No warnings. + ); + case 'actions': + return ( + + {canToggle ? 'space toggle availability' : 'toggle unavailable'} + {isUpdatable ? 'u check/apply update' : 'update unavailable'} + {isRemovable ? 'd delete skills.sh Global Skill' : 'delete unavailable'} + o open folder + + ); + } + }; + + return ( + + + ‹ esc + {glyphs.brand} + {selectedSkill.name} + + + + + {'agent'.padEnd(8)} + {selectedSkill.provider} + + + {'status'.padEnd(8)} + + {selectedSkill.enabled ? 'enabled' : 'disabled'} + + + + + + {detailLayout.sections.map((section, index) => ( + + {index > 0 && } + + {section.label.toLowerCase()} + + + ))} + + + + {renderSection()} + + + + {notice && ( + + {notice} + + )} + {error && ( + + ✗ {error} + + )} + + + ); + } + return ( {/* Navigation + title */} ‹ esc - + {glyphs.brand} {selectedSkill.name} @@ -289,7 +494,7 @@ export function DetailView() { {/* Duplicates */} {duplicate && ( - ⚠ Duplicates + {glyphs.warning} Duplicates {duplicate.instances.map((inst) => ( {' '}{inst.provider} → {inst.path} @@ -322,6 +527,11 @@ export function DetailView() { )} + {notice && ( + + {notice} + + )} {error && ( ✗ {error} From 834c6210bea6dd8f1178bcb600fb73a4339c3919 Mon Sep 17 00:00:00 2001 From: WTW0313 Date: Wed, 1 Jul 2026 17:02:57 +0800 Subject: [PATCH 05/15] feat(tui): make install and updates responsive (#17) --- packages/tui/src/views/install-view.tsx | 62 +++++++++++++++++++------ packages/tui/src/views/updates-view.tsx | 44 +++++++++++++++--- 2 files changed, 84 insertions(+), 22 deletions(-) diff --git a/packages/tui/src/views/install-view.tsx b/packages/tui/src/views/install-view.tsx index 7d4604c..aa51d8e 100644 --- a/packages/tui/src/views/install-view.tsx +++ b/packages/tui/src/views/install-view.tsx @@ -1,8 +1,10 @@ -import { useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Box, Text, useInput } from 'ink'; import { TextInput, Spinner } from '@inkjs/ui'; import { useAppContext } from '../context/app-context.js'; import { StatusBar } from '../components/status-bar.js'; +import { useTerminalSize } from '../hooks/use-terminal-size.js'; +import { fitCell, getBoundedContentLayout, getGlyphSet } from '../lib/responsive-layout.js'; import type { RemoteSkill } from '@skillpack/core'; type InstallStep = 'query' | 'results' | 'installing'; @@ -12,8 +14,31 @@ export function InstallView() { const [step, setStep] = useState('query'); const [results, setResults] = useState([]); const [cursor, setCursor] = useState(0); + const [scrollOffset, setScrollOffset] = useState(0); const [error, setError] = useState(''); const [searching, setSearching] = useState(false); + const glyphs = getGlyphSet(); + const { columns, rows } = useTerminalSize(); + const layout = getBoundedContentLayout({ + size: { columns, rows }, + fullChromeLines: error ? 8 : 6, + compactChromeLines: error ? 6 : 4, + }); + const resultRows = Math.max(1, layout.visibleRows - 2); + const resultNameWidth = Math.max(12, Math.min(30, columns - 22)); + + useEffect(() => { + if (cursor < scrollOffset) { + setScrollOffset(cursor); + } else if (cursor >= scrollOffset + resultRows) { + setScrollOffset(cursor - resultRows + 1); + } + }, [cursor, resultRows, scrollOffset]); + + const visibleResults = useMemo( + () => results.slice(scrollOffset, scrollOffset + resultRows), + [results, scrollOffset, resultRows], + ); useInput((_input, key) => { if (key.escape) { @@ -25,7 +50,7 @@ export function InstallView() { if (step === 'query') return; if (step === 'results') { - if (key.downArrow) setCursor((c) => Math.min(c + 1, results.length - 1)); + if (key.downArrow) setCursor((c) => Math.min(c + 1, Math.max(0, results.length - 1))); if (key.upArrow) setCursor((c) => Math.max(c - 1, 0)); if (key.return && results[cursor]) { doInstall(results[cursor].identifier); @@ -40,6 +65,7 @@ export function InstallView() { const found = await manager.searchRemote('skillssh', value); setResults(found); setCursor(0); + setScrollOffset(0); if (found.length === 0) { setError(`No results for "${value}"`); } @@ -103,7 +129,7 @@ export function InstallView() { ) : ( - + {glyphs.selected} {results.length} result{results.length !== 1 ? 's' : ''} - + {results.length === 0 ? ( Nothing found. Press esc to try again. ) : ( - results.map((r, i) => ( - - - {i === cursor ? '❯' : ' '} - - - {r.name} - - {r.description && {r.description}} - - )) + visibleResults.map((r, i) => { + const absoluteIndex = scrollOffset + i; + return ( + + + {absoluteIndex === cursor ? glyphs.selected : ' '} + + + {fitCell(r.name, resultNameWidth)} + + {r.description && {r.description}} + + ); + }) )} + {results.length > resultRows && ( + {scrollOffset + 1}-{Math.min(scrollOffset + resultRows, results.length)} of {results.length} + )} )} diff --git a/packages/tui/src/views/updates-view.tsx b/packages/tui/src/views/updates-view.tsx index f654511..036b16e 100644 --- a/packages/tui/src/views/updates-view.tsx +++ b/packages/tui/src/views/updates-view.tsx @@ -1,8 +1,10 @@ -import { useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Box, Text, useApp, useInput } from 'ink'; import { Spinner } from '@inkjs/ui'; import { useAppContext } from '../context/app-context.js'; import { StatusBar } from '../components/status-bar.js'; +import { useTerminalSize } from '../hooks/use-terminal-size.js'; +import { fitCell, getBoundedContentLayout, getGlyphSet } from '../lib/responsive-layout.js'; import type { Skill, UpdateInfo } from '@skillpack/core'; type UpdatesState = 'idle' | 'checking' | 'checked' | 'updating'; @@ -18,7 +20,30 @@ export function UpdatesView() { const [state, setState] = useState('idle'); const [updates, setUpdates] = useState([]); const [cursor, setCursor] = useState(0); + const [scrollOffset, setScrollOffset] = useState(0); const [error, setError] = useState(''); + const glyphs = getGlyphSet(); + const { columns, rows } = useTerminalSize(); + const layout = getBoundedContentLayout({ + size: { columns, rows }, + fullChromeLines: error ? 6 : 5, + compactChromeLines: error ? 5 : 4, + }); + const resultRows = Math.max(1, layout.visibleRows - 2); + const resultNameWidth = Math.max(12, Math.min(30, columns - 24)); + + useEffect(() => { + if (cursor < scrollOffset) { + setScrollOffset(cursor); + } else if (cursor >= scrollOffset + resultRows) { + setScrollOffset(cursor - resultRows + 1); + } + }, [cursor, resultRows, scrollOffset]); + + const visibleUpdates = useMemo( + () => updates.slice(scrollOffset, scrollOffset + resultRows), + [updates, scrollOffset, resultRows], + ); const runCheck = async () => { setError(''); @@ -27,6 +52,7 @@ export function UpdatesView() { const found = await manager.checkUpdates(); setUpdates(found); setCursor(0); + setScrollOffset(0); setState('checked'); } catch (err) { setError(err instanceof Error ? err.message : String(err)); @@ -66,7 +92,7 @@ export function UpdatesView() { return; } if (key.downArrow) { - setCursor((c) => Math.min(c + 1, updates.length - 1)); + setCursor((c) => Math.min(c + 1, Math.max(0, updates.length - 1))); } if (key.upArrow) { setCursor((c) => Math.max(c - 1, 0)); @@ -109,16 +135,17 @@ export function UpdatesView() { {state === 'checked' && ( {updates.length} update{updates.length !== 1 ? 's' : ''} available - + {updates.length === 0 ? ( No skills.sh updates found. Press r to check again. ) : ( - updates.map((row, index) => { - const selected = index === cursor; + visibleUpdates.map((row, index) => { + const absoluteIndex = scrollOffset + index; + const selected = absoluteIndex === cursor; return ( - {selected ? '❯' : ' '} - {row.skill.name} + {selected ? glyphs.selected : ' '} + {fitCell(row.skill.name, resultNameWidth)} {row.update.currentVersion ?? '?'} {row.update.latestVersion ?? 'latest'} @@ -127,6 +154,9 @@ export function UpdatesView() { }) )} + {updates.length > resultRows && ( + {scrollOffset + 1}-{Math.min(scrollOffset + resultRows, updates.length)} of {updates.length} + )} )} From 40658224bc5ec9b62e4d3f25e430dd4156a17a23 Mon Sep 17 00:00:00 2001 From: WTW0313 Date: Wed, 1 Jul 2026 17:03:13 +0800 Subject: [PATCH 06/15] feat(tui): make project and settings views responsive (#16) --- .../tui/src/views/project-skills-view.tsx | 41 +++++++++-------- packages/tui/src/views/settings-view.tsx | 44 +++++++++---------- 2 files changed, 40 insertions(+), 45 deletions(-) diff --git a/packages/tui/src/views/project-skills-view.tsx b/packages/tui/src/views/project-skills-view.tsx index b2c05a5..2e2c4f8 100644 --- a/packages/tui/src/views/project-skills-view.tsx +++ b/packages/tui/src/views/project-skills-view.tsx @@ -4,32 +4,31 @@ import { useAppContext } from '../context/app-context.js'; import { useTerminalSize } from '../hooks/use-terminal-size.js'; import { StatusBar } from '../components/status-bar.js'; import { formatDisplayPath } from '../lib/format-path.js'; - -const CHROME_LINES = 5; -const NAME_WIDTH = 30; -const PATH_WIDTH = 54; - -function truncate(value: string, max: number): string { - if (value.length <= max) return value.padEnd(max); - return value.slice(0, max - 1) + '…'; -} +import { fitCell, getBoundedContentLayout, getGlyphSet, getProjectSkillsColumns } from '../lib/responsive-layout.js'; export function ProjectSkillsView() { const { exit } = useApp(); const { projectSkills, setView } = useAppContext(); - const { rows } = useTerminalSize(); + const { columns, rows } = useTerminalSize(); const [cursor, setCursor] = useState(0); const [scrollOffset, setScrollOffset] = useState(0); + const glyphs = getGlyphSet(); - const visibleRows = Math.max(1, rows - CHROME_LINES); + const layout = getBoundedContentLayout({ + size: { columns, rows }, + fullChromeLines: 5, + compactChromeLines: 4, + }); + const tableColumns = getProjectSkillsColumns({ columns, rows }); + const visibleRows = layout.visibleRows; useInput((input, key) => { if (input === 'q') { exit(); return; } if (key.escape) { setView('list'); return; } if (key.downArrow) { - setCursor((c) => Math.min(c + 1, projectSkills.length - 1)); + setCursor((c) => Math.min(c + 1, Math.max(0, projectSkills.length - 1))); setScrollOffset((offset) => { - const next = Math.min(cursor + 1, projectSkills.length - 1); + const next = Math.min(cursor + 1, Math.max(0, projectSkills.length - 1)); return next >= offset + visibleRows ? next - visibleRows + 1 : offset; }); } @@ -62,13 +61,13 @@ export function ProjectSkillsView() { {' '} - {'NAME'.padEnd(NAME_WIDTH)} - {'PROJECT PATH'.padEnd(PATH_WIDTH)} - STATE + {fitCell('NAME', tableColumns.name)} + {fitCell('PROJECT PATH', tableColumns.path)} + {fitCell('STATE', tableColumns.state)} - + {projectSkills.length === 0 ? ( No Project Skills found for this repository. @@ -80,14 +79,14 @@ export function ProjectSkillsView() { return ( - {selected ? '❯' : ' '} + {selected ? glyphs.selected : ' '} - {truncate(skill.name, NAME_WIDTH)} + {fitCell(skill.name, tableColumns.name)} - {truncate(formatDisplayPath(skill.path), PATH_WIDTH)} + {fitCell(formatDisplayPath(skill.path), tableColumns.path)} - {hasIssues ? 'needs attention' : 'read-only'} + {fitCell(hasIssues ? 'needs attention' : 'read-only', tableColumns.state)} ); diff --git a/packages/tui/src/views/settings-view.tsx b/packages/tui/src/views/settings-view.tsx index a99469e..ac77827 100644 --- a/packages/tui/src/views/settings-view.tsx +++ b/packages/tui/src/views/settings-view.tsx @@ -4,23 +4,13 @@ import { useAppContext } from '../context/app-context.js'; import { StatusBar } from '../components/status-bar.js'; import { formatDisplayPath } from '../lib/format-path.js'; import { useTerminalSize } from '../hooks/use-terminal-size.js'; - -const PROVIDER_WIDTH = 12; -const KIND_WIDTH = 13; -const STATUS_WIDTH = 8; -const PATH_WIDTH = 64; -const CHROME_LINES = 3; +import { fitCell, getBoundedContentLayout, getSettingsColumns } from '../lib/responsive-layout.js'; interface SettingsRow { key: string; element: ReactNode; } -function truncate(value: string, max: number): string { - if (value.length <= max) return value.padEnd(max); - return value.slice(0, max - 1) + '…'; -} - function formatEnabled(enabled: boolean): string { return enabled ? 'enabled' : 'disabled'; } @@ -28,8 +18,14 @@ function formatEnabled(enabled: boolean): string { export function SettingsView() { const { exit } = useApp(); const { config, scanPaths, setView } = useAppContext(); - const { rows } = useTerminalSize(); + const { columns, rows } = useTerminalSize(); const [scrollOffset, setScrollOffset] = useState(0); + const layout = getBoundedContentLayout({ + size: { columns, rows }, + fullChromeLines: 3, + compactChromeLines: 3, + }); + const tableColumns = getSettingsColumns({ columns, rows }); useInput((input, key) => { if (input === 'q') { exit(); return; } @@ -60,10 +56,10 @@ export function SettingsView() { key: 'scan-header', element: ( - {truncate('SCOPE', PROVIDER_WIDTH)} - {truncate('KIND', KIND_WIDTH)} - {truncate('STATUS', STATUS_WIDTH)} - {truncate('PATH', PATH_WIDTH)} + {fitCell('SCOPE', tableColumns.scope)} + {fitCell('KIND', tableColumns.kind)} + {fitCell('STATUS', tableColumns.status)} + {fitCell('PATH', tableColumns.path)} ), }, @@ -78,12 +74,12 @@ export function SettingsView() { key: `scan:${scanPath.scope}:${scanPath.provider ?? 'project'}:${scanPath.path}`, element: ( - {truncate(scope, PROVIDER_WIDTH)} - {truncate(scanPath.kind, KIND_WIDTH)} + {fitCell(scope, tableColumns.scope)} + {fitCell(scanPath.kind, tableColumns.kind)} - {truncate(scanPath.exists ? 'exists' : 'missing', STATUS_WIDTH)} + {fitCell(scanPath.exists ? 'exists' : 'missing', tableColumns.status)} - {truncate(formatDisplayPath(scanPath.path), PATH_WIDTH)} + {fitCell(formatDisplayPath(scanPath.path), tableColumns.path)} ), }); @@ -97,7 +93,7 @@ export function SettingsView() { key: `provider:${provider.id}`, element: ( - {truncate(provider.id, PROVIDER_WIDTH)} + {fitCell(provider.id, tableColumns.scope)} {formatEnabled(provider.enabled)} {provider.rootCount} root{provider.rootCount === 1 ? '' : 's'} @@ -112,7 +108,7 @@ export function SettingsView() { key: `source:${source.id}`, element: ( - {truncate(source.id, PROVIDER_WIDTH)} + {fitCell(source.id, tableColumns.scope)} {formatEnabled(source.enabled)} ), @@ -120,9 +116,9 @@ export function SettingsView() { } return result; - }, [providerRows, scanPaths, sourceRows]); + }, [providerRows, scanPaths, sourceRows, tableColumns]); - const visibleRows = Math.max(1, rows - CHROME_LINES); + const visibleRows = layout.visibleRows; const visibleContent = contentRows.slice(scrollOffset, scrollOffset + visibleRows); const showScroll = contentRows.length > visibleRows; From 17ba7f0cb3a2d3c99912dad2be39c1eb326ee576 Mon Sep 17 00:00:00 2001 From: WTW0313 Date: Wed, 1 Jul 2026 17:03:47 +0800 Subject: [PATCH 07/15] feat(tui): add terminal-compatible confirmation glyphs (#18) --- packages/tui/src/components/confirm-dialog.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/tui/src/components/confirm-dialog.tsx b/packages/tui/src/components/confirm-dialog.tsx index 430f2d4..f2a33b8 100644 --- a/packages/tui/src/components/confirm-dialog.tsx +++ b/packages/tui/src/components/confirm-dialog.tsx @@ -1,4 +1,5 @@ import { Box, Text, useInput } from 'ink'; +import { getGlyphSet } from '../lib/responsive-layout.js'; interface ConfirmDialogProps { message: string; @@ -7,6 +8,8 @@ interface ConfirmDialogProps { } export function ConfirmDialog({ message, onConfirm, onCancel }: ConfirmDialogProps) { + const glyphs = getGlyphSet(); + useInput((input) => { if (input === 'y' || input === 'Y') onConfirm(); if (input === 'n' || input === 'N') onCancel(); @@ -15,7 +18,7 @@ export function ConfirmDialog({ message, onConfirm, onCancel }: ConfirmDialogPro return ( - + {glyphs.warning} {message} From b3c5f916bb19db8dcfa6229e19134300a36fe39e Mon Sep 17 00:00:00 2001 From: WTW0313 Date: Thu, 2 Jul 2026 09:47:39 +0800 Subject: [PATCH 08/15] feat(tui): wrap detail descriptions --- ...ioned-detail-view-for-compact-terminals.md | 2 +- packages/tui/src/lib/responsive-layout.ts | 39 +++++++++++++++++++ packages/tui/src/views/detail-view.tsx | 31 +++++++++------ packages/tui/tests/responsive-layout.test.ts | 22 +++++++++++ 4 files changed, 82 insertions(+), 12 deletions(-) diff --git a/docs/adr/0028-use-sectioned-detail-view-for-compact-terminals.md b/docs/adr/0028-use-sectioned-detail-view-for-compact-terminals.md index dcf24c6..5290592 100644 --- a/docs/adr/0028-use-sectioned-detail-view-for-compact-terminals.md +++ b/docs/adr/0028-use-sectioned-detail-view-for-compact-terminals.md @@ -1,3 +1,3 @@ # Use sectioned Detail view for compact terminals -Skillpack Detail views will keep summary and provider-native state visible first, then expose Paths, Source, Description, Actions, and Warnings as sections when terminal space is constrained. At the full 80x24 Terminal Envelope baseline the view may read as one page when content fits, but compact layouts should prevent long paths or descriptions from crowding out Skill Availability and action state. +Skillpack Detail views will keep summary and provider-native state visible first, then expose Paths, Source, Description, Actions, and Warnings as sections when terminal space is constrained. At the full 80x24 Terminal Envelope baseline the view may read as one page when content fits, but compact layouts should prevent long paths or descriptions from crowding out Skill Availability and action state. The Description content itself should wrap into readable visual lines and scroll within its bounded region instead of truncating long prose to one ellipsized row. diff --git a/packages/tui/src/lib/responsive-layout.ts b/packages/tui/src/lib/responsive-layout.ts index b25de35..6114bfc 100644 --- a/packages/tui/src/lib/responsive-layout.ts +++ b/packages/tui/src/lib/responsive-layout.ts @@ -37,6 +37,45 @@ export function fitCell(value: string, width: number): string { return `${value.slice(0, width - 1)}…`; } +export function wrapTextLines(lines: string[], width: number): string[] { + if (width <= 0) return []; + + const wrapped: string[] = []; + for (const line of lines) { + if (line.length === 0) { + wrapped.push(''); + continue; + } + + let current = ''; + for (const word of line.trim().split(/\s+/)) { + if (word.length > width) { + if (current.length > 0) { + wrapped.push(current); + current = ''; + } + for (let offset = 0; offset < word.length; offset += width) { + wrapped.push(word.slice(offset, offset + width)); + } + continue; + } + + if (current.length === 0) { + current = word; + } else if (current.length + 1 + word.length <= width) { + current = `${current} ${word}`; + } else { + wrapped.push(current); + current = word; + } + } + + if (current.length > 0) wrapped.push(current); + } + + return wrapped; +} + export function formatInventoryStatus(enabled: boolean, variant: 'full' | 'compact'): string { if (variant === 'compact') return fitCell(enabled ? 'on' : 'off', COMPACT_STATUS_WIDTH); return fitCell(enabled ? 'enabled' : 'disabled', FULL_STATUS_WIDTH); diff --git a/packages/tui/src/views/detail-view.tsx b/packages/tui/src/views/detail-view.tsx index 2b6d234..cd0729b 100644 --- a/packages/tui/src/views/detail-view.tsx +++ b/packages/tui/src/views/detail-view.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo, type ReactNode } from 'react'; +import { useEffect, useState, useMemo, type ReactNode } from 'react'; import { Box, Text, useInput } from 'ink'; import { Spinner } from '@inkjs/ui'; import { execSync } from 'node:child_process'; @@ -7,7 +7,7 @@ import { useTerminalSize } from '../hooks/use-terminal-size.js'; import { ConfirmDialog } from '../components/confirm-dialog.js'; import { StatusBar } from '../components/status-bar.js'; import { formatPluginToggleMessage, isPluginOwnedSkill } from '../lib/plugin-toggle.js'; -import { getDetailLayout, getGlyphSet, type DetailSectionId } from '../lib/responsive-layout.js'; +import { getDetailLayout, getGlyphSet, wrapTextLines, type DetailSectionId } from '../lib/responsive-layout.js'; import type { UpdateInfo } from '@skillpack/core'; function formatRelativeTime(iso: string): string { @@ -40,7 +40,7 @@ export function DetailView() { const glyphs = getGlyphSet(); const sourceType = selectedSkill?.source?.type; - const isUpdatable = sourceType === 'skillssh'; + const isUpdatable = selectedSkill?.provider === 'global' && sourceType === 'skillssh'; const isRemovable = selectedSkill?.provider === 'global' && sourceType === 'skillssh'; const canToggle = selectedSkill ? Boolean(manager.getProvider(selectedSkill.provider)?.getDisableStrategy(selectedSkill)) @@ -59,6 +59,11 @@ export function DetailView() { if (!selectedSkill?.description) return []; return selectedSkill.description.split('\n'); }, [selectedSkill]); + const descriptionWidth = Math.max(1, columns - 4); + const wrappedDescLines = useMemo( + () => wrapTextLines(descLines, descriptionWidth), + [descLines, descriptionWidth], + ); const detailLayout = getDetailLayout({ size: { columns, rows }, @@ -91,6 +96,10 @@ export function DetailView() { ? Math.max(1, detailLayout.visibleRows - 1) : fullVisibleDescRows; + useEffect(() => { + setDescScroll((s) => Math.min(s, Math.max(0, wrappedDescLines.length - visibleDescRows))); + }, [wrappedDescLines.length, visibleDescRows]); + useInput((input, key) => { if (key.escape) { setView('list'); return; } if (key.tab && detailLayout.sectioned) { @@ -141,7 +150,7 @@ export function DetailView() { } } if (key.downArrow && (!detailLayout.sectioned || activeSection === 'description')) { - setDescScroll((s) => Math.min(s + 1, Math.max(0, descLines.length - visibleDescRows))); + setDescScroll((s) => Math.min(s + 1, Math.max(0, wrappedDescLines.length - visibleDescRows))); } if (key.upArrow && (!detailLayout.sectioned || activeSection === 'description')) { setDescScroll((s) => Math.max(0, s - 1)); @@ -223,8 +232,8 @@ export function DetailView() { ); } - const visibleDesc = descLines.slice(descScroll, descScroll + visibleDescRows); - const descScrollable = descLines.length > visibleDescRows; + const visibleDesc = wrappedDescLines.slice(descScroll, descScroll + visibleDescRows); + const descScrollable = wrappedDescLines.length > visibleDescRows; if (detailLayout.sectioned) { const renderSection = (): ReactNode => { @@ -304,10 +313,10 @@ export function DetailView() { ) : ( {descScrollable && ( - {descScroll + 1}-{Math.min(descScroll + visibleDescRows, descLines.length)} of {descLines.length} + {descScroll + 1}-{Math.min(descScroll + visibleDescRows, wrappedDescLines.length)} of {wrappedDescLines.length} )} {visibleDesc.map((line, i) => ( - {line} + {line} ))} ); @@ -513,14 +522,14 @@ export function DetailView() { description {descScroll > 0 && } - {descScroll + 1}–{Math.min(descScroll + visibleDescRows, descLines.length)} of {descLines.length} - {descScroll + visibleDescRows < descLines.length && } + {descScroll + 1}–{Math.min(descScroll + visibleDescRows, wrappedDescLines.length)} of {wrappedDescLines.length} + {descScroll + visibleDescRows < wrappedDescLines.length && } )} {!descScrollable && description} {visibleDesc.map((line, i) => ( - {line} + {line} ))} diff --git a/packages/tui/tests/responsive-layout.test.ts b/packages/tui/tests/responsive-layout.test.ts index 3695f91..47c6c41 100644 --- a/packages/tui/tests/responsive-layout.test.ts +++ b/packages/tui/tests/responsive-layout.test.ts @@ -11,6 +11,7 @@ import { getInventoryLayout, getTerminalMode, measureShortcutLine, + wrapTextLines, } from '../src/lib/responsive-layout.js'; describe('getTerminalMode', () => { @@ -31,6 +32,27 @@ describe('getGlyphSet', () => { }); }); +describe('wrapTextLines', () => { + it('wraps long description lines into visual rows', () => { + expect(wrapTextLines(['alpha beta gamma delta'], 12)).toEqual([ + 'alpha beta', + 'gamma delta', + ]); + }); + + it('splits words longer than the available width', () => { + expect(wrapTextLines(['supercalifragilistic'], 8)).toEqual([ + 'supercal', + 'ifragili', + 'stic', + ]); + }); + + it('preserves explicit blank description lines', () => { + expect(wrapTextLines(['first', '', 'second'], 20)).toEqual(['first', '', 'second']); + }); +}); + describe('read-only view columns', () => { it('keeps Project Skills rows within compact width', () => { const columns = getProjectSkillsColumns({ columns: 60, rows: 18 }); From 00bc0e88f0f047fcd4807c4139e211a4cdf54db3 Mon Sep 17 00:00:00 2001 From: WTW0313 Date: Thu, 2 Jul 2026 09:47:47 +0800 Subject: [PATCH 09/15] fix(tui): scope update actions to global skills --- docs/adr/0019-use-manual-update-checks.md | 2 +- packages/core/src/manager.ts | 18 +++++- packages/core/src/sources/skillssh.ts | 59 +++++++++++------ packages/core/src/sources/source.ts | 1 + packages/core/tests/manager.test.ts | 68 ++++++++++++++++++++ packages/core/tests/sources/skillssh.test.ts | 23 +++++++ packages/tui/src/lib/shortcuts.ts | 4 +- packages/tui/tests/shortcuts.test.ts | 32 +++++++++ 8 files changed, 181 insertions(+), 26 deletions(-) create mode 100644 packages/tui/tests/shortcuts.test.ts diff --git a/docs/adr/0019-use-manual-update-checks.md b/docs/adr/0019-use-manual-update-checks.md index f971cb1..ec9046d 100644 --- a/docs/adr/0019-use-manual-update-checks.md +++ b/docs/adr/0019-use-manual-update-checks.md @@ -1,3 +1,3 @@ # Use manual update checks -Skillpack v1 will not check skills.sh updates automatically on startup. The TUI should load local inventory first and let users explicitly run update checks from the Updates section or a focused action, avoiding startup delays and network failures in the primary inventory experience. +Skillpack v1 will not check skills.sh updates automatically on startup. The TUI should load local inventory first and let users explicitly run update checks from the Updates section or a focused action on a skills.sh-managed Global Skill, avoiding startup delays and network failures in the primary inventory experience. Provider rows that reference shared Global Skill content may show provenance, but update actions belong to the Global Skill row. diff --git a/packages/core/src/manager.ts b/packages/core/src/manager.ts index ea8aedf..b215202 100644 --- a/packages/core/src/manager.ts +++ b/packages/core/src/manager.ts @@ -202,7 +202,15 @@ export class SkillManager { async checkUpdates(): Promise> { const updates: Array<{ skill: Skill; update: UpdateInfo }> = []; - for (const skill of this.skills) { + const skillshSkills = this.skills.filter(isSkillShManagedGlobalSkill); + const skillshSource = this.sources.get('skillssh'); + + if (skillshSource?.checkUpdates) { + updates.push(...await skillshSource.checkUpdates(skillshSkills)); + return updates; + } + + for (const skill of skillshSkills) { const update = await this.checkSkillUpdate(skill); if (update) updates.push({ skill, update }); } @@ -210,7 +218,7 @@ export class SkillManager { } async checkSkillUpdate(skill: Skill): Promise { - if (!skill.source || skill.source.type === 'local') return null; + if (!isSkillShManagedGlobalSkill(skill)) return null; for (const source of this.sources.values()) { const update = await source.checkUpdate(skill); if (update?.hasUpdate) return update; @@ -223,7 +231,7 @@ export class SkillManager { throw new Error('Cannot update an unmanaged on-disk skill'); } - if (skill.source.type !== 'skillssh') { + if (!isSkillShManagedGlobalSkill(skill)) { throw new Error('Only skills.sh-managed Global Skills can be updated'); } @@ -273,3 +281,7 @@ async function pathExists(targetPath: string): Promise { function resolveProjectSkillsPath(cwd: string, dir: string): string { return path.isAbsolute(dir) ? dir : path.join(cwd, dir); } + +function isSkillShManagedGlobalSkill(skill: Skill): boolean { + return skill.provider === 'global' && skill.source?.type === 'skillssh'; +} diff --git a/packages/core/src/sources/skillssh.ts b/packages/core/src/sources/skillssh.ts index 9efeda5..d8d9c65 100644 --- a/packages/core/src/sources/skillssh.ts +++ b/packages/core/src/sources/skillssh.ts @@ -94,30 +94,26 @@ export class SkillsShSource implements IInstallSource { async checkUpdate(skill: Skill): Promise { if (skill.source?.type !== 'skillssh') return null; try { - const { stdout, stderr } = await this.runCommand('npx', ['skills', 'check'], { - timeout: 30_000, - env: { ...process.env, NO_COLOR: '1' }, - }); - const clean = stripAnsi(stdout || stderr); - const lines = clean.split('\n'); - for (const line of lines) { - if (!line.includes(skill.name)) continue; - if (/update|available|outdated/i.test(line)) { - const hashPrefix = skill.source.skillFolderHash?.slice(0, 7); - return { - currentVersion: hashPrefix ?? skill.version, - latestVersion: 'latest', - hasUpdate: true, - }; - } - } - if (clean.includes('up to date') || clean.includes('All')) { - return null; - } + return parseUpdateForSkill(await this.runCheckCommand(), skill); } catch { /* skip */ } return null; } + async checkUpdates(skills: Skill[]): Promise> { + const managedSkills = skills.filter((skill) => skill.source?.type === 'skillssh'); + if (managedSkills.length === 0) return []; + + try { + const clean = await this.runCheckCommand(); + return managedSkills.flatMap((skill) => { + const update = parseUpdateForSkill(clean, skill); + return update?.hasUpdate ? [{ skill, update }] : []; + }); + } catch { + return []; + } + } + async updateViaCli(skillName: string): Promise { try { await this.runCommand('npx', ['skills', 'update', skillName, '-g', '-y'], { @@ -142,4 +138,27 @@ export class SkillsShSource implements IInstallSource { } } + private async runCheckCommand(): Promise { + const { stdout, stderr } = await this.runCommand('npx', ['skills', 'check'], { + timeout: 30_000, + env: { ...process.env, NO_COLOR: '1' }, + }); + return stripAnsi(stdout || stderr); + } +} + +function parseUpdateForSkill(clean: string, skill: Skill): UpdateInfo | null { + for (const line of clean.split('\n')) { + if (!line.includes(skill.name)) continue; + if (/update|available|outdated/i.test(line)) { + const hashPrefix = skill.source?.skillFolderHash?.slice(0, 7); + return { + currentVersion: hashPrefix ?? skill.version, + latestVersion: 'latest', + hasUpdate: true, + }; + } + } + + return null; } diff --git a/packages/core/src/sources/source.ts b/packages/core/src/sources/source.ts index 3dede0f..c59232e 100644 --- a/packages/core/src/sources/source.ts +++ b/packages/core/src/sources/source.ts @@ -7,4 +7,5 @@ export interface IInstallSource { search(query: string): Promise; fetch(identifier: string): Promise; checkUpdate(skill: Skill): Promise; + checkUpdates?(skills: Skill[]): Promise>; } diff --git a/packages/core/tests/manager.test.ts b/packages/core/tests/manager.test.ts index 68dc2c9..0bcf80c 100644 --- a/packages/core/tests/manager.test.ts +++ b/packages/core/tests/manager.test.ts @@ -131,6 +131,74 @@ describe('SkillManager', () => { expect(fetchCount).toBe(0); }); + it('checks updates only for skills.sh-managed Global Skills', async () => { + let checkCount = 0; + const source: IInstallSource = { + id: 'skillssh', + displayName: 'skills.sh', + search: async () => [], + fetch: async () => ({ tempDir: dir, skillName: 'managed-skill', files: [] }), + checkUpdate: async () => { + checkCount += 1; + return { hasUpdate: true, latestVersion: 'latest' }; + }, + }; + manager.registerSource(source); + + const skillDir = path.join(dir, 'linked-skill'); + await mkdir(skillDir); + await writeFile(path.join(skillDir, 'SKILL.md'), '---\nname: linked-skill\ndescription: linked\n---\n'); + await manager.scanAll(); + manager.getAllSkills()[0].source = { type: 'skillssh' }; + + await expect(manager.checkUpdates()).resolves.toEqual([]); + expect(checkCount).toBe(0); + + await expect(manager.checkSkillUpdate({ + name: 'managed-skill', + description: '', + provider: 'global', + path: path.join(dir, 'managed-skill'), + enabled: true, + scope: 'global', + metadata: {}, + source: { type: 'skillssh' }, + })).resolves.toEqual({ hasUpdate: true, latestVersion: 'latest' }); + expect(checkCount).toBe(1); + }); + + it('updates only skills.sh-managed Global Skills', async () => { + const calls: Array<{ command: string; args: string[] }> = []; + manager.registerSource(new SkillsShSource(async (command, args) => { + calls.push({ command, args }); + return { stdout: '', stderr: '' }; + })); + + const linkedSkill: Skill = { + name: 'linked-skill', + description: '', + provider: 'codex', + path: path.join(dir, 'linked-skill'), + enabled: true, + scope: 'global', + metadata: {}, + source: { type: 'skillssh' }, + }; + await expect(manager.updateSkill(linkedSkill)).rejects.toThrow('Only skills.sh-managed Global Skills can be updated'); + expect(calls).toEqual([]); + + await manager.updateSkill({ + ...linkedSkill, + provider: 'global', + path: path.join(dir, 'managed-skill'), + }); + + expect(calls).toEqual([{ + command: 'npx', + args: ['skills', 'update', 'linked-skill', '-g', '-y'], + }]); + }); + it('reports provider and project scan paths while scanning custom paths and skipping missing paths', async () => { const missingProviderPath = path.join(dir, 'missing-provider'); const customProviderPath = path.join(dir, 'custom-provider'); diff --git a/packages/core/tests/sources/skillssh.test.ts b/packages/core/tests/sources/skillssh.test.ts index a12a9ff..e3e294e 100644 --- a/packages/core/tests/sources/skillssh.test.ts +++ b/packages/core/tests/sources/skillssh.test.ts @@ -46,6 +46,29 @@ describe('SkillsShSource', () => { expect(calls).toEqual([{ command: 'npx', args: ['skills', 'check'] }]); }); + it('checks multiple skills for updates with one skills CLI call', async () => { + const calls: Array<{ command: string; args: string[] }> = []; + const source = new SkillsShSource(async (command, args) => { + calls.push({ command, args }); + return { stdout: 'alpha update available\nbeta up to date\ngamma outdated\n', stderr: '' }; + }); + const skills: Skill[] = ['alpha', 'beta', 'gamma'].map((name) => ({ + name, + description: '', + provider: 'global', + path: `/fake/${name}`, + enabled: true, + scope: 'global', + metadata: {}, + source: { type: 'skillssh', skillFolderHash: `${name}-hash` }, + })); + + const updates = await source.checkUpdates(skills); + + expect(updates.map(({ skill }) => skill.name)).toEqual(['alpha', 'gamma']); + expect(calls).toEqual([{ command: 'npx', args: ['skills', 'check'] }]); + }); + it('updates a skills.sh-managed Global Skill through the skills CLI', async () => { const calls: Array<{ command: string; args: string[] }> = []; const source = new SkillsShSource(async (command, args) => { diff --git a/packages/tui/src/lib/shortcuts.ts b/packages/tui/src/lib/shortcuts.ts index 5ad9c80..52149e0 100644 --- a/packages/tui/src/lib/shortcuts.ts +++ b/packages/tui/src/lib/shortcuts.ts @@ -14,7 +14,7 @@ const SHORTCUTS: Record = { { key: 'tab', label: 'tabs' }, { key: 'p', label: 'project' }, { key: 's', label: 'settings' }, - { key: 'u', label: 'updates' }, + { key: 'u', label: 'global updates', compactLabel: 'updates' }, { key: 'i', label: 'install' }, { key: 'q', label: 'quit' }, ], @@ -66,7 +66,7 @@ export function getShortcutsForView({ view, selectedSkill, canToggle }: Shortcut if (view !== 'detail') return SHORTCUTS[view] ?? []; const sourceType = selectedSkill?.source?.type; - const isUpdatable = sourceType === 'skillssh'; + const isUpdatable = selectedSkill?.provider === 'global' && sourceType === 'skillssh'; const isRemovable = selectedSkill?.provider === 'global' && sourceType === 'skillssh'; const detailBase = canToggle ? [ diff --git a/packages/tui/tests/shortcuts.test.ts b/packages/tui/tests/shortcuts.test.ts new file mode 100644 index 0000000..8930a3a --- /dev/null +++ b/packages/tui/tests/shortcuts.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { getShortcutsForView } from '../src/lib/shortcuts.js'; +import type { Skill } from '@skillpack/core'; + +function skill(provider: string): Skill { + return { + name: 'demo', + description: '', + provider, + path: `/fake/${provider}/demo`, + enabled: true, + scope: 'global', + metadata: {}, + source: { type: 'skillssh' }, + }; +} + +describe('getShortcutsForView', () => { + it('labels the list shortcut as Global updates', () => { + const shortcuts = getShortcutsForView({ view: 'list', selectedSkill: null, canToggle: false }); + + expect(shortcuts.find((shortcut) => shortcut.key === 'u')?.label).toBe('global updates'); + }); + + it('shows Detail update shortcut only for skills.sh-managed Global Skills', () => { + const codexShortcuts = getShortcutsForView({ view: 'detail', selectedSkill: skill('codex'), canToggle: false }); + const globalShortcuts = getShortcutsForView({ view: 'detail', selectedSkill: skill('global'), canToggle: false }); + + expect(codexShortcuts.map((shortcut) => shortcut.key)).not.toContain('u'); + expect(globalShortcuts.map((shortcut) => shortcut.key)).toContain('u'); + }); +}); From de6b3a836c5c759cc0277506ae3b36e166a48af4 Mon Sep 17 00:00:00 2001 From: WTW0313 Date: Thu, 2 Jul 2026 10:02:25 +0800 Subject: [PATCH 10/15] fix(tui): remove list update shortcut --- docs/adr/0019-use-manual-update-checks.md | 2 +- packages/tui/src/lib/shortcuts.ts | 1 - packages/tui/src/views/list-view.tsx | 5 ++--- packages/tui/tests/shortcuts.test.ts | 4 ++-- 4 files changed, 5 insertions(+), 7 deletions(-) diff --git a/docs/adr/0019-use-manual-update-checks.md b/docs/adr/0019-use-manual-update-checks.md index ec9046d..937ba2e 100644 --- a/docs/adr/0019-use-manual-update-checks.md +++ b/docs/adr/0019-use-manual-update-checks.md @@ -1,3 +1,3 @@ # Use manual update checks -Skillpack v1 will not check skills.sh updates automatically on startup. The TUI should load local inventory first and let users explicitly run update checks from the Updates section or a focused action on a skills.sh-managed Global Skill, avoiding startup delays and network failures in the primary inventory experience. Provider rows that reference shared Global Skill content may show provenance, but update actions belong to the Global Skill row. +Skillpack v1 will not check skills.sh updates automatically on startup. The TUI should load local inventory first and let users explicitly run update checks from a focused action on a skills.sh-managed Global Skill, avoiding startup delays and network failures in the primary inventory experience. Provider rows that reference shared Global Skill content may show provenance, but update actions belong to the Global Skill row; the main inventory list should not expose a generic update shortcut. diff --git a/packages/tui/src/lib/shortcuts.ts b/packages/tui/src/lib/shortcuts.ts index 52149e0..4230d66 100644 --- a/packages/tui/src/lib/shortcuts.ts +++ b/packages/tui/src/lib/shortcuts.ts @@ -14,7 +14,6 @@ const SHORTCUTS: Record = { { key: 'tab', label: 'tabs' }, { key: 'p', label: 'project' }, { key: 's', label: 'settings' }, - { key: 'u', label: 'global updates', compactLabel: 'updates' }, { key: 'i', label: 'install' }, { key: 'q', label: 'quit' }, ], diff --git a/packages/tui/src/views/list-view.tsx b/packages/tui/src/views/list-view.tsx index 2e0f3e3..d671a48 100644 --- a/packages/tui/src/views/list-view.tsx +++ b/packages/tui/src/views/list-view.tsx @@ -94,9 +94,8 @@ export function ListView() { if (input === '/') { setSearching(true); return; } if (input === 'p') { setView('project'); return; } if (input === 's') { setView('settings'); return; } - if (input === 'u') { setView('updates'); return; } if (input === 'i') { setView('install'); return; } - + if (input === ' ' && skills[cursor]) { const selected = skills[cursor]; const canToggle = Boolean(manager.getProvider(selected.provider)?.getDisableStrategy(selected)); @@ -156,7 +155,7 @@ export function ListView() { {/* Header */} - {glyphs.brand} skillpack + {glyphs.brand} Skillpack {skills.length} skill{skills.length !== 1 ? 's' : ''} {showScroll && ( {scrollOffset + 1}–{Math.min(scrollOffset + visibleRows, skills.length)} of {skills.length} diff --git a/packages/tui/tests/shortcuts.test.ts b/packages/tui/tests/shortcuts.test.ts index 8930a3a..8befbf0 100644 --- a/packages/tui/tests/shortcuts.test.ts +++ b/packages/tui/tests/shortcuts.test.ts @@ -16,10 +16,10 @@ function skill(provider: string): Skill { } describe('getShortcutsForView', () => { - it('labels the list shortcut as Global updates', () => { + it('does not show a list update shortcut', () => { const shortcuts = getShortcutsForView({ view: 'list', selectedSkill: null, canToggle: false }); - expect(shortcuts.find((shortcut) => shortcut.key === 'u')?.label).toBe('global updates'); + expect(shortcuts.map((shortcut) => shortcut.key)).not.toContain('u'); }); it('shows Detail update shortcut only for skills.sh-managed Global Skills', () => { From 047c766324e83b3df17bd4dabc75f797139e046b Mon Sep 17 00:00:00 2001 From: WTW0313 Date: Thu, 2 Jul 2026 10:27:57 +0800 Subject: [PATCH 11/15] refactor: Refactor inventory-driven skill UI and provider actions - Switch list/detail/help/status behavior to use inventory groups and instance actions instead of direct provider capability checks - Add update availability tracking in `SkillManager` and surface `update-available` as inventory health signals - Tighten identity confidence logic for skills.sh provenance vs shared realpaths - Simplify provider capability shape to toggle-only and remove unused create/uninstall code paths from providers/tests - Set default config `autoCheckUpdates` to `false` - Update README/TUI docs for Skill Group navigation and remove obsolete superpowers planning/spec documents --- README.md | 19 +- .../superpowers/plans/2026-04-09-skillpack.md | 3152 ----------------- .../specs/2026-04-09-skill-disable-design.md | 202 -- .../specs/2026-04-09-skillpack-design.md | 542 --- packages/core/src/config.ts | 2 +- packages/core/src/manager.ts | 33 +- packages/core/src/models/inventory.ts | 36 +- packages/core/src/providers/claude.ts | 2 +- packages/core/src/providers/codex.ts | 30 +- packages/core/src/providers/global.ts | 27 +- packages/core/src/providers/provider.ts | 15 +- packages/core/src/sources/skillssh.ts | 5 +- packages/core/src/sources/source.ts | 4 +- packages/core/tests/inventory.test.ts | 45 +- packages/core/tests/providers/codex.test.ts | 14 - packages/tui/README.md | 8 +- packages/tui/src/components/help-overlay.tsx | 6 +- packages/tui/src/components/skill-row.tsx | 30 +- packages/tui/src/components/status-bar.tsx | 6 +- packages/tui/src/context/app-context.tsx | 40 +- packages/tui/src/hooks/use-search.ts | 13 +- packages/tui/src/hooks/use-skills.ts | 17 +- packages/tui/src/lib/plugin-toggle.ts | 10 +- packages/tui/src/lib/responsive-layout.ts | 25 +- packages/tui/src/lib/shortcuts.ts | 9 +- packages/tui/src/views/detail-view.tsx | 116 +- packages/tui/src/views/list-view.tsx | 63 +- .../tui/src/views/project-skills-view.tsx | 16 +- packages/tui/src/views/updates-view.tsx | 24 +- packages/tui/tests/responsive-layout.test.ts | 4 +- packages/tui/tests/shortcuts.test.ts | 12 +- 31 files changed, 355 insertions(+), 4172 deletions(-) delete mode 100644 docs/superpowers/plans/2026-04-09-skillpack.md delete mode 100644 docs/superpowers/specs/2026-04-09-skill-disable-design.md delete mode 100644 docs/superpowers/specs/2026-04-09-skillpack-design.md diff --git a/README.md b/README.md index dd32709..ba1b45c 100644 --- a/README.md +++ b/README.md @@ -39,9 +39,9 @@ node packages/tui/dist/skillpack.js ## Quick Start -Launch `skillpack` to see all discovered skills grouped by provider. Use `↑↓` arrow keys to navigate, `Tab` / `Shift+Tab` to switch between provider tabs (All, Codex, Claude, Global), and `/` to search. +Launch `skillpack` to see discovered skills grouped into Skill Groups with provider status and Health Signals. Use `↑↓` arrow keys to navigate, `Tab` / `Shift+Tab` to filter by provider (All, Codex, Claude, Global), and `/` to search. -Press `Space` to toggle a Codex or Claude provider instance on or off, `Enter` to view details, `p` to inspect read-only Project Skills, `s` to inspect Settings and Scan Roots, `i` to install a skills.sh Global Skill, or `u` to open manual updates. Plugin-owned skills require confirmation because the action toggles the owning plugin and affects sibling skills from the same plugin. +Press `Enter` to inspect a Skill Group, then use left/right to choose a provider instance before taking instance-level actions. Press `p` to inspect read-only Project Skills, `s` to inspect Settings and Scan Roots, `i` to install a skills.sh Global Skill, or `u` to open manual updates. Plugin-owned skills require confirmation because the action toggles the owning plugin and affects sibling skills from the same plugin. ## Keyboard Shortcuts @@ -50,8 +50,7 @@ Press `Space` to toggle a Codex or Claude provider instance on or off, `Enter` t | Key | Action | Description | |-----|--------|-------------| | `↑` / `↓` | Navigate | Move selection up / down | -| `Space` | Toggle | Enable or disable a selected Codex or Claude skill; plugin-owned skills ask for confirmation | -| `Enter` | Detail | Open skill detail view | +| `Enter` | Detail | Open Skill Group detail view | | `Tab` / `Shift+Tab` | Switch tab | Cycle through All / Codex / Claude / Global | | `/` | Search | Fuzzy match on name + description | | `Esc` | Clear search | Clear the active search filter | @@ -66,6 +65,7 @@ Press `Space` to toggle a Codex or Claude provider instance on or off, `Enter` t | Key | Action | Description | |-----|--------|-------------| | `Esc` | Back | Return to list view | +| `←` / `→` | Provider instance | Switch the selected provider instance inside the Skill Group | | `Space` | Toggle | Enable or disable a Codex or Claude skill; plugin-owned skills ask for confirmation | | `o` / `O` | Open folder | Open skill directory in system file manager | | `d` | Delete | Remove a skills.sh-managed Global Skill with confirmation | @@ -78,7 +78,7 @@ Skillpack stores its configuration at `~/.config/skillpack/config.json`. On firs ```json { "editor": "vi", - "autoCheckUpdates": true, + "autoCheckUpdates": false, "projectSkillsDirs": [ ".codex/skills", ".claude/skills", @@ -107,15 +107,12 @@ class MyProvider extends BaseProvider { readonly displayName = 'My Platform'; readonly basePaths = ['/path/to/skills']; readonly capabilities: ProviderCapabilities = { - canInstall: true, - canUninstall: true, - canUpdate: false, canToggle: true, - canCreate: true, }; - override async uninstall(name: string) { /* ... */ } - override async create(template) { /* return Skill */ } + override async scan() { /* return provider-native Skill instances */ } + override async setEnabled(skill, enabled) { /* update provider-native availability */ } + override getDisableStrategy(skill) { /* describe the availability mechanism */ } } ``` diff --git a/docs/superpowers/plans/2026-04-09-skillpack.md b/docs/superpowers/plans/2026-04-09-skillpack.md deleted file mode 100644 index c692069..0000000 --- a/docs/superpowers/plans/2026-04-09-skillpack.md +++ /dev/null @@ -1,3152 +0,0 @@ -# Skillpack Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Build a TUI application that unifies management of agent skills across multiple platforms (Codex, Cursor, Claude, skills.sh), with install, update, create, edit, conflict detection, and version locking. - -**Architecture:** Monorepo with two packages — `@skillpack/core` (platform-agnostic logic: providers, sources, models, lock files) and `@skillpack/tui` (Ink-based terminal UI). Core exposes a `SkillManager` that coordinates providers and sources. TUI consumes core's API. - -**Tech Stack:** TypeScript, Ink 6.x, React 19, @inkjs/ui, gray-matter, fuse.js, pnpm workspaces - -**Spec:** `docs/superpowers/specs/2026-04-09-skillpack-design.md` - ---- - -## File Structure - -``` -skillpack/ -├── package.json # workspace root -├── pnpm-workspace.yaml # pnpm workspace 配置 -├── tsconfig.json # base tsconfig -├── packages/ -│ ├── core/ -│ │ ├── package.json -│ │ ├── tsconfig.json -│ │ ├── src/ -│ │ │ ├── index.ts # public API barrel export -│ │ │ ├── models/ -│ │ │ │ ├── skill.ts # Skill, SkillTemplate, RemoteSkill interfaces -│ │ │ │ ├── conflict.ts # ConflictInfo, DiffResult -│ │ │ │ ├── source.ts # InstallRequest, UpdateInfo, DownloadResult -│ │ │ │ └── index.ts -│ │ │ ├── parser.ts # SKILL.md frontmatter parser -│ │ │ ├── lockfile.ts # Lock file read/write/verify -│ │ │ ├── config.ts # Config file manager (~/.config/skillpack/) -│ │ │ ├── providers/ -│ │ │ │ ├── provider.ts # ISkillProvider interface + BaseProvider -│ │ │ │ ├── codex.ts -│ │ │ │ ├── cursor.ts -│ │ │ │ ├── claude.ts -│ │ │ │ ├── skillssh.ts -│ │ │ │ └── index.ts -│ │ │ ├── sources/ -│ │ │ │ ├── source.ts # IInstallSource interface -│ │ │ │ ├── github.ts -│ │ │ │ ├── skillssh.ts -│ │ │ │ └── index.ts -│ │ │ ├── conflicts.ts # ConflictDetector -│ │ │ └── manager.ts # SkillManager (central coordinator) -│ │ └── tests/ -│ │ ├── parser.test.ts -│ │ ├── lockfile.test.ts -│ │ ├── conflicts.test.ts -│ │ ├── providers/ -│ │ │ └── codex.test.ts # representative provider test -│ │ └── manager.test.ts -│ └── tui/ -│ ├── package.json -│ ├── tsconfig.json -│ ├── bin/ -│ │ └── skillpack.ts # CLI entry point (#!/usr/bin/env node) -│ └── src/ -│ ├── app.tsx # Root Ink component -│ ├── hooks/ -│ │ ├── use-skill-manager.ts # Initialize & provide SkillManager -│ │ ├── use-skills.ts # Skill list state + filtering -│ │ └── use-search.ts # Fuse.js fuzzy search -│ ├── components/ -│ │ ├── tab-bar.tsx # [All] [Codex] [Cursor] ... -│ │ ├── skill-list.tsx # Scrollable skill list with indicators -│ │ ├── skill-row.tsx # Single row: name, provider, status, conflict -│ │ ├── status-bar.tsx # Bottom hotkey bar -│ │ ├── search-input.tsx # / search overlay -│ │ └── confirm-dialog.tsx # Yes/No confirmation -│ ├── views/ -│ │ ├── list-view.tsx # Main list view (tabs + list + status) -│ │ ├── detail-view.tsx # Skill detail page -│ │ ├── install-view.tsx # Install wizard flow -│ │ ├── create-view.tsx # Create skill wizard -│ │ └── update-view.tsx # Update confirmation view -│ └── context/ -│ └── app-context.tsx # React context for SkillManager + state -``` - ---- - -## Task 1: Monorepo Scaffolding - -**Files:** -- Create: `package.json` -- Create: `tsconfig.json` -- Create: `packages/core/package.json` -- Create: `packages/core/tsconfig.json` -- Create: `packages/tui/package.json` -- Create: `packages/tui/tsconfig.json` - -- [ ] **Step 1: Create root package.json with npm workspaces** - -```json -{ - "name": "skillpack", - "version": "0.1.0", - "private": true, - "workspaces": ["packages/*"], - "scripts": { - "build": "npm run build --workspaces", - "test": "npm run test --workspaces", - "dev": "npm run dev --workspace=packages/tui" - } -} -``` - -- [ ] **Step 2: Create root tsconfig.json** - -```json -{ - "compilerOptions": { - "target": "ES2022", - "module": "Node16", - "moduleResolution": "Node16", - "strict": true, - "esModuleInterop": true, - "declaration": true, - "declarationMap": true, - "sourceMap": true, - "skipLibCheck": true - } -} -``` - -- [ ] **Step 3: Create packages/core/package.json** - -```json -{ - "name": "@skillpack/core", - "version": "0.1.0", - "type": "module", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "scripts": { - "build": "tsc", - "test": "vitest run", - "test:watch": "vitest" - }, - "dependencies": { - "gray-matter": "^4.0.3", - "glob": "^11.0.0" - }, - "devDependencies": { - "typescript": "^5.7.0", - "vitest": "^3.0.0", - "@types/node": "^22.0.0" - } -} -``` - -- [ ] **Step 4: Create packages/core/tsconfig.json** - -```json -{ - "extends": "../../tsconfig.json", - "compilerOptions": { - "outDir": "./dist", - "rootDir": "./src" - }, - "include": ["src/**/*"], - "exclude": ["tests/**/*", "dist"] -} -``` - -- [ ] **Step 5: Create packages/tui/package.json** - -```json -{ - "name": "@skillpack/tui", - "version": "0.1.0", - "type": "module", - "bin": { - "skillpack": "./dist/bin/skillpack.js" - }, - "scripts": { - "build": "tsc", - "dev": "tsc --watch", - "test": "vitest run" - }, - "dependencies": { - "@skillpack/core": "*", - "ink": "^6.5.0", - "react": "^18.3.0", - "@inkjs/ui": "^2.0.0", - "fuse.js": "^7.0.0" - }, - "devDependencies": { - "typescript": "^5.7.0", - "@types/react": "^18.3.0", - "vitest": "^3.0.0", - "@types/node": "^22.0.0" - } -} -``` - -- [ ] **Step 6: Create packages/tui/tsconfig.json** - -```json -{ - "extends": "../../tsconfig.json", - "compilerOptions": { - "outDir": "./dist", - "rootDir": ".", - "jsx": "react-jsx", - "references": [{ "path": "../core" }] - }, - "include": ["src/**/*", "bin/**/*"], - "exclude": ["dist"] -} -``` - -- [ ] **Step 7: Install dependencies** - -Run: `npm install` - -Expected: Successful install, `node_modules` created, workspaces linked. - -- [ ] **Step 8: Commit** - -```bash -git add -A -git commit -m "chore: scaffold monorepo with core and tui packages" -``` - ---- - -## Task 2: Core Data Models - -**Files:** -- Create: `packages/core/src/models/skill.ts` -- Create: `packages/core/src/models/conflict.ts` -- Create: `packages/core/src/models/source.ts` -- Create: `packages/core/src/models/index.ts` - -- [ ] **Step 1: Create Skill model** - -Write `packages/core/src/models/skill.ts`: - -```typescript -export interface SkillMetadata { - license?: string; - author?: string; - tags?: string[]; -} - -export interface SkillSource { - type: 'github' | 'skillssh' | 'local'; - repo?: string; - ref?: string; - commit?: string; - createdAt?: string; - installedAt?: string; - forkedFrom?: { - source: 'github' | 'skillssh'; - identifier: string; - }; -} - -export interface Skill { - name: string; - description: string; - provider: string; - path: string; - version?: string; - enabled: boolean; - scope: 'global' | 'project'; - readonly: boolean; - metadata: SkillMetadata; - source?: SkillSource; -} - -export interface SkillTemplate { - name: string; - description: string; - metadata?: Partial; -} -``` - -- [ ] **Step 2: Create Conflict model** - -Write `packages/core/src/models/conflict.ts`: - -```typescript -export interface ConflictInstance { - provider: string; - path: string; - version?: string; -} - -export interface ConflictInfo { - skillName: string; - instances: ConflictInstance[]; -} - -export interface DiffChange { - field: string; - a: string; - b: string; -} - -export interface DiffResult { - identical: boolean; - changes: DiffChange[]; -} -``` - -- [ ] **Step 3: Create Source model** - -Write `packages/core/src/models/source.ts`: - -```typescript -export interface InstallRequest { - sourceType: 'github' | 'skillssh'; - identifier: string; - tempDir: string; -} - -export interface RemoteSkill { - name: string; - description: string; - source: 'github' | 'skillssh'; - identifier: string; - stars?: number; - installs?: number; - version?: string; -} - -export interface UpdateInfo { - currentVersion?: string; - latestVersion?: string; - hasUpdate: boolean; - changelog?: string; -} - -export interface DownloadResult { - tempDir: string; - skillName: string; - files: string[]; -} -``` - -- [ ] **Step 4: Create barrel export** - -Write `packages/core/src/models/index.ts`: - -```typescript -export * from './skill.js'; -export * from './conflict.js'; -export * from './source.js'; -``` - -- [ ] **Step 5: Commit** - -```bash -git add -A -git commit -m "feat(core): add data model interfaces" -``` - ---- - -## Task 3: SKILL.md Parser - -**Files:** -- Create: `packages/core/src/parser.ts` -- Create: `packages/core/tests/parser.test.ts` - -- [ ] **Step 1: Write the failing test** - -Write `packages/core/tests/parser.test.ts`: - -```typescript -import { describe, it, expect } from 'vitest'; -import { parseSkillMd, generateSkillMd } from '../src/parser.js'; - -describe('parseSkillMd', () => { - it('parses valid SKILL.md with frontmatter', () => { - const content = `--- -name: my-skill -description: A test skill ---- - -# My Skill - -## Instructions - -Do the thing. -`; - const result = parseSkillMd(content); - expect(result.name).toBe('my-skill'); - expect(result.description).toBe('A test skill'); - expect(result.body).toContain('# My Skill'); - expect(result.body).toContain('Do the thing.'); - }); - - it('parses optional metadata fields', () => { - const content = `--- -name: my-skill -description: A test skill -license: MIT ---- - -# Content -`; - const result = parseSkillMd(content); - expect(result.metadata.license).toBe('MIT'); - }); - - it('returns empty description when missing', () => { - const content = `--- -name: my-skill ---- - -# Content -`; - const result = parseSkillMd(content); - expect(result.name).toBe('my-skill'); - expect(result.description).toBe(''); - }); - - it('handles file with no frontmatter', () => { - const content = `# Just Markdown - -No frontmatter here. -`; - const result = parseSkillMd(content); - expect(result.name).toBe(''); - expect(result.body).toContain('# Just Markdown'); - }); -}); - -describe('generateSkillMd', () => { - it('generates SKILL.md from template', () => { - const md = generateSkillMd({ name: 'test-skill', description: 'A skill' }); - expect(md).toContain('name: test-skill'); - expect(md).toContain('description: A skill'); - expect(md).toContain('# Test Skill'); - }); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cd packages/core && npx vitest run tests/parser.test.ts` - -Expected: FAIL — `parseSkillMd` not found. - -- [ ] **Step 3: Implement parser** - -Write `packages/core/src/parser.ts`: - -```typescript -import matter from 'gray-matter'; -import type { SkillMetadata } from './models/index.js'; - -export interface ParsedSkillMd { - name: string; - description: string; - body: string; - metadata: Partial; - raw: Record; -} - -export function parseSkillMd(content: string): ParsedSkillMd { - const { data, content: body } = matter(content); - - return { - name: typeof data.name === 'string' ? data.name : '', - description: typeof data.description === 'string' ? data.description : '', - body: body.trim(), - metadata: { - license: typeof data.license === 'string' ? data.license : undefined, - author: typeof data.author === 'string' ? data.author : undefined, - tags: Array.isArray(data.tags) ? data.tags : undefined, - }, - raw: data, - }; -} - -function kebabToTitle(s: string): string { - return s - .split('-') - .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) - .join(' '); -} - -export function generateSkillMd(template: { name: string; description: string; metadata?: Partial }): string { - const frontmatter: Record = { - name: template.name, - description: template.description, - }; - if (template.metadata?.license) frontmatter.license = template.metadata.license; - if (template.metadata?.author) frontmatter.author = template.metadata.author; - if (template.metadata?.tags) frontmatter.tags = template.metadata.tags; - - const fm = matter.stringify('', frontmatter).trim(); - const title = kebabToTitle(template.name); - - return `${fm} - -# ${title} - -## When to Use This Skill - - - -## Instructions - - -`; -} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `cd packages/core && npx vitest run tests/parser.test.ts` - -Expected: All tests PASS. - -- [ ] **Step 5: Commit** - -```bash -git add -A -git commit -m "feat(core): add SKILL.md parser with gray-matter" -``` - ---- - -## Task 4: Lock File Manager - -**Files:** -- Create: `packages/core/src/lockfile.ts` -- Create: `packages/core/tests/lockfile.test.ts` - -- [ ] **Step 1: Write the failing test** - -Write `packages/core/tests/lockfile.test.ts`: - -```typescript -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { mkdtemp, rm, readFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import path from 'node:path'; -import { LockfileManager } from '../src/lockfile.js'; - -describe('LockfileManager', () => { - let dir: string; - let lm: LockfileManager; - - beforeEach(async () => { - dir = await mkdtemp(path.join(tmpdir(), 'skillpack-lock-')); - lm = new LockfileManager(path.join(dir, 'skillpack.lock')); - }); - - afterEach(async () => { - await rm(dir, { recursive: true, force: true }); - }); - - it('creates a new lock file when none exists', async () => { - await lm.load(); - expect(lm.getEntries()).toEqual({}); - }); - - it('adds and persists an entry', async () => { - await lm.load(); - lm.setEntry('my-skill', { - source: 'github', - repo: 'owner/repo', - commit: 'abc123', - installedAt: '2026-04-09T00:00:00Z', - integrity: 'sha256-test', - }); - await lm.save(); - - const raw = JSON.parse(await readFile(path.join(dir, 'skillpack.lock'), 'utf-8')); - expect(raw.skills['my-skill'].commit).toBe('abc123'); - }); - - it('removes an entry', async () => { - await lm.load(); - lm.setEntry('my-skill', { - source: 'github', - repo: 'owner/repo', - commit: 'abc123', - installedAt: '2026-04-09T00:00:00Z', - integrity: 'sha256-test', - }); - lm.removeEntry('my-skill'); - await lm.save(); - - const raw = JSON.parse(await readFile(path.join(dir, 'skillpack.lock'), 'utf-8')); - expect(raw.skills['my-skill']).toBeUndefined(); - }); - - it('roundtrips load -> save -> load', async () => { - await lm.load(); - lm.setEntry('a', { source: 'skillssh', identifier: 'pkg@a', version: '1.0.0', installedAt: '2026-04-09T00:00:00Z', integrity: 'sha256-aaa' }); - await lm.save(); - - const lm2 = new LockfileManager(path.join(dir, 'skillpack.lock')); - await lm2.load(); - const entry = lm2.getEntry('a'); - expect(entry?.version).toBe('1.0.0'); - }); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cd packages/core && npx vitest run tests/lockfile.test.ts` - -Expected: FAIL — `LockfileManager` not found. - -- [ ] **Step 3: Implement LockfileManager** - -Write `packages/core/src/lockfile.ts`: - -```typescript -import { readFile, writeFile, mkdir } from 'node:fs/promises'; -import path from 'node:path'; - -export interface LockEntry { - source: string; - identifier?: string; - repo?: string; - ref?: string; - commit?: string; - path?: string; - version?: string; - installedAt: string; - integrity: string; -} - -interface LockfileData { - lockfileVersion: number; - skills: Record; -} - -export class LockfileManager { - private filePath: string; - private data: LockfileData = { lockfileVersion: 1, skills: {} }; - - constructor(filePath: string) { - this.filePath = filePath; - } - - async load(): Promise { - try { - const raw = await readFile(this.filePath, 'utf-8'); - this.data = JSON.parse(raw); - } catch { - this.data = { lockfileVersion: 1, skills: {} }; - } - } - - async save(): Promise { - await mkdir(path.dirname(this.filePath), { recursive: true }); - await writeFile(this.filePath, JSON.stringify(this.data, null, 2) + '\n', 'utf-8'); - } - - getEntries(): Record { - return { ...this.data.skills }; - } - - getEntry(name: string): LockEntry | undefined { - return this.data.skills[name]; - } - - setEntry(name: string, entry: LockEntry): void { - this.data.skills[name] = entry; - } - - removeEntry(name: string): void { - delete this.data.skills[name]; - } -} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `cd packages/core && npx vitest run tests/lockfile.test.ts` - -Expected: All tests PASS. - -- [ ] **Step 5: Commit** - -```bash -git add -A -git commit -m "feat(core): add lock file manager for version pinning" -``` - ---- - -## Task 5: Config Manager - -**Files:** -- Create: `packages/core/src/config.ts` - -- [ ] **Step 1: Implement ConfigManager** - -Write `packages/core/src/config.ts`: - -```typescript -import { readFile, writeFile, mkdir, access } from 'node:fs/promises'; -import path from 'node:path'; -import os from 'node:os'; - -export interface ProviderConfig { - enabled: boolean; - paths: string[]; -} - -export interface SourceConfig { - enabled: boolean; -} - -export interface SkillpackConfig { - editor: string; - autoCheckUpdates: boolean; - projectSkillsDir: string; - providers: Record; - sources: Record; -} - -const DEFAULT_CONFIG: SkillpackConfig = { - editor: process.env.EDITOR || 'vi', - autoCheckUpdates: true, - projectSkillsDir: '.skillpack/skills', - providers: { - codex: { enabled: true, paths: [path.join(os.homedir(), '.codex', 'skills')] }, - cursor: { enabled: true, paths: [path.join(os.homedir(), '.cursor', 'skills-cursor')] }, - claude: { enabled: true, paths: [path.join(os.homedir(), '.claude', 'plugins', 'cache')] }, - skillssh: { enabled: true, paths: [path.join(os.homedir(), '.agents', 'skills')] }, - }, - sources: { - github: { enabled: true }, - skillssh: { enabled: true }, - }, -}; - -export class ConfigManager { - private configPath: string; - private config: SkillpackConfig = { ...DEFAULT_CONFIG }; - - constructor(configDir?: string) { - const dir = configDir ?? path.join(os.homedir(), '.config', 'skillpack'); - this.configPath = path.join(dir, 'config.json'); - } - - async load(): Promise { - try { - const raw = await readFile(this.configPath, 'utf-8'); - this.config = { ...DEFAULT_CONFIG, ...JSON.parse(raw) }; - } catch { - this.config = { ...DEFAULT_CONFIG }; - await this.autoDetectProviders(); - } - return this.config; - } - - async save(): Promise { - await mkdir(path.dirname(this.configPath), { recursive: true }); - await writeFile(this.configPath, JSON.stringify(this.config, null, 2) + '\n', 'utf-8'); - } - - getConfig(): SkillpackConfig { - return this.config; - } - - private async autoDetectProviders(): Promise { - for (const [id, provider] of Object.entries(this.config.providers)) { - let found = false; - for (const p of provider.paths) { - try { - await access(p); - found = true; - break; - } catch { /* not found */ } - } - this.config.providers[id].enabled = found; - } - } -} -``` - -- [ ] **Step 2: Commit** - -```bash -git add -A -git commit -m "feat(core): add config manager with auto-detection" -``` - ---- - -## Task 6: Provider Interface & Base Class - -**Files:** -- Create: `packages/core/src/providers/provider.ts` -- Create: `packages/core/src/providers/index.ts` - -- [ ] **Step 1: Define provider interface and base class** - -Write `packages/core/src/providers/provider.ts`: - -```typescript -import type { Skill, SkillTemplate } from '../models/index.js'; -import type { InstallRequest } from '../models/source.js'; -import { readdir, access } from 'node:fs/promises'; -import { readFile } from 'node:fs/promises'; -import path from 'node:path'; -import { parseSkillMd } from '../parser.js'; - -export interface ProviderCapabilities { - canInstall: boolean; - canUninstall: boolean; - canUpdate: boolean; - canToggle: boolean; - canCreate: boolean; -} - -export interface ISkillProvider { - readonly id: string; - readonly displayName: string; - readonly basePaths: string[]; - readonly capabilities: ProviderCapabilities; - - scan(): Promise; - install(name: string, request: InstallRequest): Promise; - uninstall(name: string): Promise; - update(name: string): Promise; - enable(name: string): Promise; - disable(name: string): Promise; - create(template: SkillTemplate): Promise; -} - -export abstract class BaseProvider implements ISkillProvider { - abstract readonly id: string; - abstract readonly displayName: string; - abstract readonly basePaths: string[]; - abstract readonly capabilities: ProviderCapabilities; - - async scan(): Promise { - const skills: Skill[] = []; - for (const basePath of this.basePaths) { - try { - await access(basePath); - } catch { - continue; - } - const entries = await readdir(basePath, { withFileTypes: true }); - for (const entry of entries) { - if (!entry.isDirectory() || entry.name.startsWith('.')) continue; - const skillDir = path.join(basePath, entry.name); - const skillMdPath = path.join(skillDir, 'SKILL.md'); - try { - const content = await readFile(skillMdPath, 'utf-8'); - const parsed = parseSkillMd(content); - skills.push({ - name: parsed.name || entry.name, - description: parsed.description, - provider: this.id, - path: skillDir, - version: parsed.raw.version as string | undefined, - enabled: true, - scope: 'global', - readonly: true, - metadata: { - license: parsed.metadata.license, - author: parsed.metadata.author, - tags: parsed.metadata.tags, - }, - }); - } catch { - // no SKILL.md or unreadable — skip - } - } - } - return skills; - } - - async install(_name: string, _request: InstallRequest): Promise { - throw new Error(`${this.displayName} provider does not support install`); - } - - async uninstall(_name: string): Promise { - throw new Error(`${this.displayName} provider does not support uninstall`); - } - - async update(_name: string): Promise { - throw new Error(`${this.displayName} provider does not support update`); - } - - async enable(_name: string): Promise { - throw new Error(`${this.displayName} provider does not support enable`); - } - - async disable(_name: string): Promise { - throw new Error(`${this.displayName} provider does not support disable`); - } - - async create(_template: SkillTemplate): Promise { - throw new Error(`${this.displayName} provider does not support create`); - } -} -``` - -- [ ] **Step 2: Create barrel export** - -Write `packages/core/src/providers/index.ts`: - -```typescript -export { type ISkillProvider, type ProviderCapabilities, BaseProvider } from './provider.js'; -export { CodexProvider } from './codex.js'; -export { CursorProvider } from './cursor.js'; -export { ClaudeProvider } from './claude.js'; -export { SkillsShProvider } from './skillssh.js'; -``` - -Note: The individual provider files will be created in following tasks. Create this file after all providers are implemented. - -- [ ] **Step 3: Commit** - -```bash -git add -A -git commit -m "feat(core): add provider interface and base class with scan" -``` - ---- - -## Task 7: Codex Provider - -**Files:** -- Create: `packages/core/src/providers/codex.ts` -- Create: `packages/core/tests/providers/codex.test.ts` - -- [ ] **Step 1: Write the failing test** - -Write `packages/core/tests/providers/codex.test.ts`: - -```typescript -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import path from 'node:path'; -import { CodexProvider } from '../../src/providers/codex.js'; - -describe('CodexProvider', () => { - let dir: string; - let provider: CodexProvider; - - beforeEach(async () => { - dir = await mkdtemp(path.join(tmpdir(), 'skillpack-codex-')); - provider = new CodexProvider([dir]); - }); - - afterEach(async () => { - await rm(dir, { recursive: true, force: true }); - }); - - it('scans skills from directory', async () => { - const skillDir = path.join(dir, 'test-skill'); - await mkdir(skillDir); - await writeFile( - path.join(skillDir, 'SKILL.md'), - '---\nname: test-skill\ndescription: A test\n---\n\n# Test\n', - ); - - const skills = await provider.scan(); - expect(skills).toHaveLength(1); - expect(skills[0].name).toBe('test-skill'); - expect(skills[0].provider).toBe('codex'); - }); - - it('skips directories without SKILL.md', async () => { - await mkdir(path.join(dir, 'empty-dir')); - const skills = await provider.scan(); - expect(skills).toHaveLength(0); - }); - - it('creates a new skill', async () => { - const skill = await provider.create({ name: 'new-skill', description: 'Brand new' }); - expect(skill.name).toBe('new-skill'); - expect(skill.readonly).toBe(false); - expect(skill.source?.type).toBe('local'); - }); - - it('uninstalls a skill', async () => { - const skillDir = path.join(dir, 'to-delete'); - await mkdir(skillDir); - await writeFile(path.join(skillDir, 'SKILL.md'), '---\nname: to-delete\ndescription: Delete me\n---\n'); - - await provider.uninstall('to-delete'); - const skills = await provider.scan(); - expect(skills).toHaveLength(0); - }); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cd packages/core && npx vitest run tests/providers/codex.test.ts` - -Expected: FAIL — `CodexProvider` not found. - -- [ ] **Step 3: Implement CodexProvider** - -Write `packages/core/src/providers/codex.ts`: - -```typescript -import { BaseProvider, type ProviderCapabilities } from './provider.js'; -import type { Skill, SkillTemplate } from '../models/index.js'; -import type { InstallRequest } from '../models/source.js'; -import { generateSkillMd } from '../parser.js'; -import { mkdir, writeFile, rm, cp } from 'node:fs/promises'; -import path from 'node:path'; -import os from 'node:os'; - -export class CodexProvider extends BaseProvider { - readonly id = 'codex'; - readonly displayName = 'Codex'; - readonly basePaths: string[]; - readonly capabilities: ProviderCapabilities = { - canInstall: true, - canUninstall: true, - canUpdate: true, - canToggle: false, - canCreate: true, - }; - - constructor(basePaths?: string[]) { - super(); - this.basePaths = basePaths ?? [path.join(os.homedir(), '.codex', 'skills')]; - } - - override async install(name: string, request: InstallRequest): Promise { - const dest = path.join(this.basePaths[0], name); - await cp(request.tempDir, dest, { recursive: true }); - } - - override async uninstall(name: string): Promise { - const skillPath = path.join(this.basePaths[0], name); - await rm(skillPath, { recursive: true, force: true }); - } - - override async create(template: SkillTemplate): Promise { - const skillDir = path.join(this.basePaths[0], template.name); - await mkdir(skillDir, { recursive: true }); - const content = generateSkillMd(template); - await writeFile(path.join(skillDir, 'SKILL.md'), content, 'utf-8'); - - return { - name: template.name, - description: template.description, - provider: this.id, - path: skillDir, - enabled: true, - scope: 'global', - readonly: false, - metadata: template.metadata ?? {}, - source: { type: 'local', createdAt: new Date().toISOString() }, - }; - } -} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `cd packages/core && npx vitest run tests/providers/codex.test.ts` - -Expected: All tests PASS. - -- [ ] **Step 5: Commit** - -```bash -git add -A -git commit -m "feat(core): add Codex provider with scan, install, uninstall, create" -``` - ---- - -## Task 8: Cursor, Claude, and skills.sh Providers - -**Files:** -- Create: `packages/core/src/providers/cursor.ts` -- Create: `packages/core/src/providers/claude.ts` -- Create: `packages/core/src/providers/skillssh.ts` - -These follow the same pattern as CodexProvider with platform-specific differences. - -- [ ] **Step 1: Implement CursorProvider** - -Write `packages/core/src/providers/cursor.ts`: - -```typescript -import { BaseProvider, type ProviderCapabilities } from './provider.js'; -import path from 'node:path'; -import os from 'node:os'; - -export class CursorProvider extends BaseProvider { - readonly id = 'cursor'; - readonly displayName = 'Cursor'; - readonly basePaths: string[]; - readonly capabilities: ProviderCapabilities = { - canInstall: false, - canUninstall: false, - canUpdate: false, - canToggle: false, - canCreate: false, - }; - - constructor(basePaths?: string[]) { - super(); - this.basePaths = basePaths ?? [path.join(os.homedir(), '.cursor', 'skills-cursor')]; - } -} -``` - -Cursor provider is read-only — skills are managed by Cursor itself. - -- [ ] **Step 2: Implement ClaudeProvider** - -Write `packages/core/src/providers/claude.ts`: - -The Claude plugins cache has a nested structure: `cache////skills//`. Override `scan()` to walk this deeper hierarchy. - -```typescript -import { BaseProvider, type ProviderCapabilities } from './provider.js'; -import type { Skill } from '../models/index.js'; -import { readdir, readFile, access } from 'node:fs/promises'; -import path from 'node:path'; -import os from 'node:os'; -import { parseSkillMd } from '../parser.js'; - -export class ClaudeProvider extends BaseProvider { - readonly id = 'claude'; - readonly displayName = 'Claude'; - readonly basePaths: string[]; - readonly capabilities: ProviderCapabilities = { - canInstall: false, - canUninstall: false, - canUpdate: false, - canToggle: false, - canCreate: false, - }; - - constructor(basePaths?: string[]) { - super(); - this.basePaths = basePaths ?? [path.join(os.homedir(), '.claude', 'plugins', 'cache')]; - } - - override async scan(): Promise { - const skills: Skill[] = []; - for (const basePath of this.basePaths) { - try { await access(basePath); } catch { continue; } - const publishers = await readdir(basePath, { withFileTypes: true }); - for (const pub of publishers) { - if (!pub.isDirectory()) continue; - const plugins = await readdir(path.join(basePath, pub.name), { withFileTypes: true }); - for (const plugin of plugins) { - if (!plugin.isDirectory()) continue; - const versions = await readdir(path.join(basePath, pub.name, plugin.name), { withFileTypes: true }); - for (const ver of versions) { - if (!ver.isDirectory()) continue; - const skillsDir = path.join(basePath, pub.name, plugin.name, ver.name, 'skills'); - try { await access(skillsDir); } catch { continue; } - const skillEntries = await readdir(skillsDir, { withFileTypes: true }); - for (const entry of skillEntries) { - if (!entry.isDirectory()) continue; - const skillMdPath = path.join(skillsDir, entry.name, 'SKILL.md'); - try { - const content = await readFile(skillMdPath, 'utf-8'); - const parsed = parseSkillMd(content); - skills.push({ - name: parsed.name || entry.name, - description: parsed.description, - provider: this.id, - path: path.join(skillsDir, entry.name), - version: ver.name !== 'unknown' ? ver.name : undefined, - enabled: true, - scope: 'global', - readonly: true, - metadata: { - license: parsed.metadata.license, - author: parsed.metadata.author ?? pub.name, - tags: parsed.metadata.tags, - }, - }); - } catch { /* skip */ } - } - } - } - } - } - return skills; - } -} -``` - -- [ ] **Step 3: Implement SkillsShProvider** - -Write `packages/core/src/providers/skillssh.ts`: - -```typescript -import { BaseProvider, type ProviderCapabilities } from './provider.js'; -import type { Skill, SkillTemplate } from '../models/index.js'; -import { generateSkillMd } from '../parser.js'; -import { mkdir, writeFile, rm } from 'node:fs/promises'; -import path from 'node:path'; -import os from 'node:os'; - -export class SkillsShProvider extends BaseProvider { - readonly id = 'skillssh'; - readonly displayName = 'skills.sh'; - readonly basePaths: string[]; - readonly capabilities: ProviderCapabilities = { - canInstall: true, - canUninstall: true, - canUpdate: true, - canToggle: false, - canCreate: true, - }; - - constructor(basePaths?: string[]) { - super(); - this.basePaths = basePaths ?? [path.join(os.homedir(), '.agents', 'skills')]; - } - - override async uninstall(name: string): Promise { - const skillPath = path.join(this.basePaths[0], name); - await rm(skillPath, { recursive: true, force: true }); - } - - override async create(template: SkillTemplate): Promise { - const skillDir = path.join(this.basePaths[0], template.name); - await mkdir(skillDir, { recursive: true }); - const content = generateSkillMd(template); - await writeFile(path.join(skillDir, 'SKILL.md'), content, 'utf-8'); - - return { - name: template.name, - description: template.description, - provider: this.id, - path: skillDir, - enabled: true, - scope: 'global', - readonly: false, - metadata: template.metadata ?? {}, - source: { type: 'local', createdAt: new Date().toISOString() }, - }; - } -} -``` - -- [ ] **Step 4: Create provider barrel export** - -Write `packages/core/src/providers/index.ts`: - -```typescript -export { type ISkillProvider, type ProviderCapabilities, BaseProvider } from './provider.js'; -export { CodexProvider } from './codex.js'; -export { CursorProvider } from './cursor.js'; -export { ClaudeProvider } from './claude.js'; -export { SkillsShProvider } from './skillssh.js'; -``` - -- [ ] **Step 5: Run all tests** - -Run: `cd packages/core && npx vitest run` - -Expected: All tests PASS. - -- [ ] **Step 6: Commit** - -```bash -git add -A -git commit -m "feat(core): add Cursor, Claude, and skills.sh providers" -``` - ---- - -## Task 9: Conflict Detector - -**Files:** -- Create: `packages/core/src/conflicts.ts` -- Create: `packages/core/tests/conflicts.test.ts` - -- [ ] **Step 1: Write the failing test** - -Write `packages/core/tests/conflicts.test.ts`: - -```typescript -import { describe, it, expect } from 'vitest'; -import { ConflictDetector } from '../src/conflicts.js'; -import type { Skill } from '../src/models/index.js'; - -function makeSkill(name: string, provider: string): Skill { - return { - name, - description: '', - provider, - path: `/fake/${provider}/${name}`, - enabled: true, - scope: 'global', - readonly: true, - metadata: {}, - }; -} - -describe('ConflictDetector', () => { - const detector = new ConflictDetector(); - - it('detects no conflicts when names are unique', () => { - const skills = [makeSkill('a', 'codex'), makeSkill('b', 'cursor')]; - expect(detector.detect(skills)).toHaveLength(0); - }); - - it('detects conflict when same name across providers', () => { - const skills = [ - makeSkill('figma', 'codex'), - makeSkill('figma', 'cursor'), - makeSkill('other', 'codex'), - ]; - const conflicts = detector.detect(skills); - expect(conflicts).toHaveLength(1); - expect(conflicts[0].skillName).toBe('figma'); - expect(conflicts[0].instances).toHaveLength(2); - }); - - it('detects multiple conflicts', () => { - const skills = [ - makeSkill('a', 'codex'), - makeSkill('a', 'cursor'), - makeSkill('b', 'codex'), - makeSkill('b', 'claude'), - makeSkill('b', 'skillssh'), - ]; - const conflicts = detector.detect(skills); - expect(conflicts).toHaveLength(2); - expect(conflicts.find((c) => c.skillName === 'b')?.instances).toHaveLength(3); - }); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cd packages/core && npx vitest run tests/conflicts.test.ts` - -Expected: FAIL — `ConflictDetector` not found. - -- [ ] **Step 3: Implement ConflictDetector** - -Write `packages/core/src/conflicts.ts`: - -```typescript -import type { Skill } from './models/index.js'; -import type { ConflictInfo, DiffResult } from './models/conflict.js'; - -export class ConflictDetector { - detect(skills: Skill[]): ConflictInfo[] { - const groups = new Map(); - - for (const skill of skills) { - const existing = groups.get(skill.name) ?? []; - existing.push(skill); - groups.set(skill.name, existing); - } - - const conflicts: ConflictInfo[] = []; - for (const [name, group] of groups) { - if (group.length < 2) continue; - conflicts.push({ - skillName: name, - instances: group.map((s) => ({ - provider: s.provider, - path: s.path, - version: s.version, - })), - }); - } - - return conflicts; - } - - diff(a: Skill, b: Skill): DiffResult { - const changes: DiffResult['changes'] = []; - - if (a.description !== b.description) { - changes.push({ field: 'description', a: a.description, b: b.description }); - } - if (a.version !== b.version) { - changes.push({ field: 'version', a: a.version ?? '', b: b.version ?? '' }); - } - - return { identical: changes.length === 0, changes }; - } -} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `cd packages/core && npx vitest run tests/conflicts.test.ts` - -Expected: All tests PASS. - -- [ ] **Step 5: Commit** - -```bash -git add -A -git commit -m "feat(core): add conflict detector" -``` - ---- - -## Task 10: Install Sources (GitHub + skills.sh) - -**Files:** -- Create: `packages/core/src/sources/source.ts` -- Create: `packages/core/src/sources/github.ts` -- Create: `packages/core/src/sources/skillssh.ts` -- Create: `packages/core/src/sources/index.ts` - -- [ ] **Step 1: Define IInstallSource interface** - -Write `packages/core/src/sources/source.ts`: - -```typescript -import type { RemoteSkill, UpdateInfo, DownloadResult } from '../models/source.js'; -import type { Skill } from '../models/skill.js'; - -export interface IInstallSource { - readonly id: string; - readonly displayName: string; - - search(query: string): Promise; - fetch(identifier: string): Promise; - checkUpdate(skill: Skill): Promise; -} -``` - -- [ ] **Step 2: Implement GitHubSource** - -Write `packages/core/src/sources/github.ts`: - -```typescript -import type { IInstallSource } from './source.js'; -import type { RemoteSkill, UpdateInfo, DownloadResult } from '../models/source.js'; -import type { Skill } from '../models/skill.js'; -import { execFile } from 'node:child_process'; -import { mkdtemp } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import path from 'node:path'; -import { promisify } from 'node:util'; - -const execFileAsync = promisify(execFile); - -export interface ParsedGitHubRef { - owner: string; - repo: string; - ref: string; - path: string; -} - -export function parseGitHubIdentifier(identifier: string): ParsedGitHubRef { - const urlMatch = identifier.match( - /github\.com\/([^/]+)\/([^/]+)\/tree\/([^/]+)\/(.+)/, - ); - if (urlMatch) { - return { owner: urlMatch[1], repo: urlMatch[2], ref: urlMatch[3], path: urlMatch[4] }; - } - - // owner/repo@path or owner/repo --path path - const atMatch = identifier.match(/^([^/]+)\/([^@]+)@(.+)$/); - if (atMatch) { - return { owner: atMatch[1], repo: atMatch[2], ref: 'main', path: atMatch[3] }; - } - - const slashMatch = identifier.match(/^([^/]+)\/([^/]+)$/); - if (slashMatch) { - return { owner: slashMatch[1], repo: slashMatch[2], ref: 'main', path: '.' }; - } - - throw new Error(`Cannot parse GitHub identifier: ${identifier}`); -} - -export class GitHubSource implements IInstallSource { - readonly id = 'github'; - readonly displayName = 'GitHub'; - - async search(_query: string): Promise { - // GitHub doesn't have a skill-specific search API. - // Return empty — users provide identifiers directly. - return []; - } - - async fetch(identifier: string): Promise { - const parsed = parseGitHubIdentifier(identifier); - const tempDir = await mkdtemp(path.join(tmpdir(), 'skillpack-gh-')); - const repoUrl = `https://github.com/${parsed.owner}/${parsed.repo}.git`; - - await execFileAsync('git', [ - 'clone', '--depth', '1', '--filter=blob:none', '--sparse', - '--branch', parsed.ref, - repoUrl, tempDir, - ]); - - if (parsed.path !== '.') { - await execFileAsync('git', ['-C', tempDir, 'sparse-checkout', 'set', parsed.path]); - } - - const skillName = path.basename(parsed.path); - const skillDir = parsed.path === '.' ? tempDir : path.join(tempDir, parsed.path); - - return { - tempDir: skillDir, - skillName, - files: [], - }; - } - - async checkUpdate(skill: Skill): Promise { - if (!skill.source?.repo || !skill.source?.commit) return null; - - try { - const { stdout } = await execFileAsync('git', [ - 'ls-remote', `https://github.com/${skill.source.repo}.git`, - skill.source.ref || 'HEAD', - ]); - const latestCommit = stdout.split('\t')[0]; - - if (latestCommit && latestCommit !== skill.source.commit) { - return { - currentVersion: skill.source.commit.slice(0, 7), - latestVersion: latestCommit.slice(0, 7), - hasUpdate: true, - }; - } - } catch { /* network error — skip */ } - - return null; - } -} -``` - -- [ ] **Step 3: Implement SkillsShSource** - -Write `packages/core/src/sources/skillssh.ts`: - -```typescript -import type { IInstallSource } from './source.js'; -import type { RemoteSkill, UpdateInfo, DownloadResult } from '../models/source.js'; -import type { Skill } from '../models/skill.js'; -import { execFile } from 'node:child_process'; -import { promisify } from 'node:util'; - -const execFileAsync = promisify(execFile); - -export class SkillsShSource implements IInstallSource { - readonly id = 'skillssh'; - readonly displayName = 'skills.sh'; - - async search(query: string): Promise { - try { - const { stdout } = await execFileAsync('npx', ['skills', 'find', query, '--json'], { - timeout: 30_000, - }); - const results = JSON.parse(stdout); - return (results as Array>).map((r) => ({ - name: r.name as string, - description: (r.description as string) ?? '', - source: 'skillssh' as const, - identifier: r.identifier as string, - installs: r.installs as number | undefined, - })); - } catch { - return []; - } - } - - async fetch(identifier: string): Promise { - // Delegate to npx skills add — it handles download and placement - await execFileAsync('npx', ['skills', 'add', identifier, '-g', '-y'], { - timeout: 60_000, - }); - - const skillName = identifier.split('@').pop() ?? identifier; - return { - tempDir: '', - skillName, - files: [], - }; - } - - async checkUpdate(skill: Skill): Promise { - if (skill.source?.type !== 'skillssh') return null; - - try { - const { stdout } = await execFileAsync('npx', ['skills', 'check', '--json'], { - timeout: 30_000, - }); - const updates = JSON.parse(stdout); - const match = (updates as Array>).find( - (u) => u.name === skill.name, - ); - if (match) { - return { - currentVersion: skill.version, - latestVersion: match.version as string, - hasUpdate: true, - }; - } - } catch { /* skip */ } - - return null; - } -} -``` - -- [ ] **Step 4: Create barrel export** - -Write `packages/core/src/sources/index.ts`: - -```typescript -export { type IInstallSource } from './source.js'; -export { GitHubSource, parseGitHubIdentifier } from './github.js'; -export { SkillsShSource } from './skillssh.js'; -``` - -- [ ] **Step 5: Commit** - -```bash -git add -A -git commit -m "feat(core): add GitHub and skills.sh install sources" -``` - ---- - -## Task 11: SkillManager (Central Coordinator) - -**Files:** -- Create: `packages/core/src/manager.ts` -- Create: `packages/core/tests/manager.test.ts` -- Create: `packages/core/src/index.ts` - -- [ ] **Step 1: Write the failing test** - -Write `packages/core/tests/manager.test.ts`: - -```typescript -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import path from 'node:path'; -import { SkillManager } from '../src/manager.js'; -import { CodexProvider } from '../src/providers/codex.js'; - -describe('SkillManager', () => { - let dir: string; - let manager: SkillManager; - - beforeEach(async () => { - dir = await mkdtemp(path.join(tmpdir(), 'skillpack-mgr-')); - const provider = new CodexProvider([dir]); - manager = new SkillManager(); - manager.registerProvider(provider); - }); - - afterEach(async () => { - await rm(dir, { recursive: true, force: true }); - }); - - it('scans all providers', async () => { - const skillDir = path.join(dir, 'test'); - await mkdir(skillDir); - await writeFile(path.join(skillDir, 'SKILL.md'), '---\nname: test\ndescription: t\n---\n'); - - await manager.scanAll(); - expect(manager.getAllSkills()).toHaveLength(1); - }); - - it('detects conflicts', async () => { - const dir2 = await mkdtemp(path.join(tmpdir(), 'skillpack-mgr2-')); - const provider2 = new CodexProvider([dir2]); - // Hack: override id to simulate a different provider - (provider2 as any).id = 'other'; - manager.registerProvider(provider2); - - for (const d of [dir, dir2]) { - const skillDir = path.join(d, 'same-skill'); - await mkdir(skillDir); - await writeFile(path.join(skillDir, 'SKILL.md'), '---\nname: same-skill\ndescription: dup\n---\n'); - } - - await manager.scanAll(); - const conflicts = manager.getConflicts(); - expect(conflicts).toHaveLength(1); - expect(conflicts[0].skillName).toBe('same-skill'); - - await rm(dir2, { recursive: true, force: true }); - }); - - it('creates a skill', async () => { - const skill = await manager.createSkill('codex', { name: 'new', description: 'New skill' }); - expect(skill.name).toBe('new'); - - await manager.scanAll(); - expect(manager.getAllSkills()).toHaveLength(1); - }); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cd packages/core && npx vitest run tests/manager.test.ts` - -Expected: FAIL — `SkillManager` not found. - -- [ ] **Step 3: Implement SkillManager** - -Write `packages/core/src/manager.ts`: - -```typescript -import type { ISkillProvider } from './providers/provider.js'; -import type { IInstallSource } from './sources/source.js'; -import type { Skill, SkillTemplate } from './models/index.js'; -import type { ConflictInfo } from './models/conflict.js'; -import type { RemoteSkill, UpdateInfo } from './models/source.js'; -import { ConflictDetector } from './conflicts.js'; - -export class SkillManager { - private providers = new Map(); - private sources = new Map(); - private skills: Skill[] = []; - private conflicts: ConflictInfo[] = []; - private conflictDetector = new ConflictDetector(); - - registerProvider(provider: ISkillProvider): void { - this.providers.set(provider.id, provider); - } - - registerSource(source: IInstallSource): void { - this.sources.set(source.id, source); - } - - getProvider(id: string): ISkillProvider | undefined { - return this.providers.get(id); - } - - getProviders(): ISkillProvider[] { - return [...this.providers.values()]; - } - - getSources(): IInstallSource[] { - return [...this.sources.values()]; - } - - async scanAll(): Promise { - const results = await Promise.all( - [...this.providers.values()].map((p) => p.scan()), - ); - this.skills = results.flat(); - this.conflicts = this.conflictDetector.detect(this.skills); - } - - getAllSkills(): Skill[] { - return this.skills; - } - - getSkillsByProvider(providerId: string): Skill[] { - return this.skills.filter((s) => s.provider === providerId); - } - - getConflicts(): ConflictInfo[] { - return this.conflicts; - } - - isConflicting(skillName: string): boolean { - return this.conflicts.some((c) => c.skillName === skillName); - } - - async createSkill(providerId: string, template: SkillTemplate): Promise { - const provider = this.providers.get(providerId); - if (!provider) throw new Error(`Provider not found: ${providerId}`); - if (!provider.capabilities.canCreate) { - throw new Error(`Provider ${providerId} does not support creating skills`); - } - return provider.create(template); - } - - async uninstallSkill(skill: Skill): Promise { - const provider = this.providers.get(skill.provider); - if (!provider) throw new Error(`Provider not found: ${skill.provider}`); - await provider.uninstall(skill.name); - } - - async searchRemote(sourceId: string, query: string): Promise { - const source = this.sources.get(sourceId); - if (!source) throw new Error(`Source not found: ${sourceId}`); - return source.search(query); - } - - async installFromSource(sourceId: string, identifier: string, providerId: string): Promise { - const source = this.sources.get(sourceId); - if (!source) throw new Error(`Source not found: ${sourceId}`); - const provider = this.providers.get(providerId); - if (!provider) throw new Error(`Provider not found: ${providerId}`); - - const result = await source.fetch(identifier); - - // skills.sh handles its own placement — just rescan - if (sourceId === 'skillssh') { - await this.scanAll(); - return; - } - - await provider.install(result.skillName, { - sourceType: sourceId as 'github' | 'skillssh', - identifier, - tempDir: result.tempDir, - }); - await this.scanAll(); - } - - async checkUpdates(): Promise> { - const updates: Array<{ skill: Skill; update: UpdateInfo }> = []; - for (const skill of this.skills) { - if (!skill.source || skill.source.type === 'local') continue; - for (const source of this.sources.values()) { - const update = await source.checkUpdate(skill); - if (update?.hasUpdate) { - updates.push({ skill, update }); - break; - } - } - } - return updates; - } -} -``` - -- [ ] **Step 4: Create core barrel export** - -Write `packages/core/src/index.ts`: - -```typescript -export * from './models/index.js'; -export * from './providers/index.js'; -export * from './sources/index.js'; -export { ConflictDetector } from './conflicts.js'; -export { SkillManager } from './manager.js'; -export { LockfileManager, type LockEntry } from './lockfile.js'; -export { ConfigManager, type SkillpackConfig } from './config.js'; -export { parseSkillMd, generateSkillMd } from './parser.js'; -``` - -- [ ] **Step 5: Run all core tests** - -Run: `cd packages/core && npx vitest run` - -Expected: All tests PASS. - -- [ ] **Step 6: Commit** - -```bash -git add -A -git commit -m "feat(core): add SkillManager coordinator and core barrel export" -``` - ---- - -## Task 12: TUI — App Shell & Context - -**Files:** -- Create: `packages/tui/src/context/app-context.tsx` -- Create: `packages/tui/src/hooks/use-skill-manager.ts` -- Create: `packages/tui/src/app.tsx` -- Create: `packages/tui/bin/skillpack.ts` - -- [ ] **Step 1: Create app context** - -Write `packages/tui/src/context/app-context.tsx`: - -```tsx -import React, { createContext, useContext, useState } from 'react'; -import type { SkillManager, Skill, ConflictInfo } from '@skillpack/core'; - -export type View = 'list' | 'detail' | 'install' | 'create' | 'update'; - -interface AppState { - manager: SkillManager; - skills: Skill[]; - conflicts: ConflictInfo[]; - activeTab: string; - view: View; - selectedSkill: Skill | null; - searchQuery: string; - loading: boolean; -} - -interface AppContextValue extends AppState { - setActiveTab: (tab: string) => void; - setView: (view: View) => void; - setSelectedSkill: (skill: Skill | null) => void; - setSearchQuery: (query: string) => void; - refresh: () => Promise; -} - -const AppContext = createContext(null); - -export function AppProvider({ - manager, - children, -}: { - manager: SkillManager; - children: React.ReactNode; -}) { - const [skills, setSkills] = useState([]); - const [conflicts, setConflicts] = useState([]); - const [activeTab, setActiveTab] = useState('All'); - const [view, setView] = useState('list'); - const [selectedSkill, setSelectedSkill] = useState(null); - const [searchQuery, setSearchQuery] = useState(''); - const [loading, setLoading] = useState(false); - - const refresh = async () => { - setLoading(true); - await manager.scanAll(); - setSkills(manager.getAllSkills()); - setConflicts(manager.getConflicts()); - setLoading(false); - }; - - return ( - - {children} - - ); -} - -export function useApp(): AppContextValue { - const ctx = useContext(AppContext); - if (!ctx) throw new Error('useApp must be used within AppProvider'); - return ctx; -} -``` - -- [ ] **Step 2: Create SkillManager initialization hook** - -Write `packages/tui/src/hooks/use-skill-manager.ts`: - -```typescript -import { useEffect, useState } from 'react'; -import { - SkillManager, - ConfigManager, - CodexProvider, - CursorProvider, - ClaudeProvider, - SkillsShProvider, - GitHubSource, - SkillsShSource, -} from '@skillpack/core'; - -export function useSkillManager() { - const [manager, setManager] = useState(null); - const [error, setError] = useState(null); - - useEffect(() => { - (async () => { - try { - const config = new ConfigManager(); - const cfg = await config.load(); - - const mgr = new SkillManager(); - - if (cfg.providers.codex?.enabled) { - mgr.registerProvider(new CodexProvider(cfg.providers.codex.paths)); - } - if (cfg.providers.cursor?.enabled) { - mgr.registerProvider(new CursorProvider(cfg.providers.cursor.paths)); - } - if (cfg.providers.claude?.enabled) { - mgr.registerProvider(new ClaudeProvider(cfg.providers.claude.paths)); - } - if (cfg.providers.skillssh?.enabled) { - mgr.registerProvider(new SkillsShProvider(cfg.providers.skillssh.paths)); - } - - if (cfg.sources.github?.enabled) mgr.registerSource(new GitHubSource()); - if (cfg.sources.skillssh?.enabled) mgr.registerSource(new SkillsShSource()); - - setManager(mgr); - } catch (err) { - setError(String(err)); - } - })(); - }, []); - - return { manager, error }; -} -``` - -- [ ] **Step 3: Create App root component** - -Write `packages/tui/src/app.tsx`: - -```tsx -import React, { useEffect } from 'react'; -import { Box, Text } from 'ink'; -import { Spinner } from '@inkjs/ui'; -import { AppProvider, useApp } from './context/app-context.js'; -import { useSkillManager } from './hooks/use-skill-manager.js'; -import { ListView } from './views/list-view.js'; -import { DetailView } from './views/detail-view.js'; -import { InstallView } from './views/install-view.js'; -import { CreateView } from './views/create-view.js'; -import { UpdateView } from './views/update-view.js'; - -function Router() { - const { view, loading, refresh } = useApp(); - - useEffect(() => { - refresh(); - }, []); - - if (loading) { - return ( - - - - ); - } - - switch (view) { - case 'list': return ; - case 'detail': return ; - case 'install': return ; - case 'create': return ; - case 'update': return ; - } -} - -export function App() { - const { manager, error } = useSkillManager(); - - if (error) { - return Error: {error}; - } - - if (!manager) { - return ; - } - - return ( - - - - ); -} -``` - -Note: The view components (`ListView`, `DetailView`, etc.) will be implemented in the following tasks. Create placeholder files that export empty components first, then fill them in. - -- [ ] **Step 4: Create CLI entry point** - -Write `packages/tui/bin/skillpack.ts`: - -```typescript -#!/usr/bin/env node -import React from 'react'; -import { render } from 'ink'; -import { App } from '../src/app.js'; - -render(React.createElement(App)); -``` - -- [ ] **Step 5: Commit** - -```bash -git add -A -git commit -m "feat(tui): add app shell, context, and CLI entry point" -``` - ---- - -## Task 13: TUI — Tab Bar & Status Bar Components - -**Files:** -- Create: `packages/tui/src/components/tab-bar.tsx` -- Create: `packages/tui/src/components/status-bar.tsx` -- Create: `packages/tui/src/components/skill-row.tsx` - -- [ ] **Step 1: Create TabBar** - -Write `packages/tui/src/components/tab-bar.tsx`: - -```tsx -import React from 'react'; -import { Box, Text } from 'ink'; - -interface TabBarProps { - tabs: string[]; - activeTab: string; - onTabChange: (tab: string) => void; -} - -export function TabBar({ tabs, activeTab }: TabBarProps) { - return ( - - {tabs.map((tab) => ( - - {tab === activeTab ? `[${tab}]` : ` ${tab} `} - - ))} - - ); -} -``` - -- [ ] **Step 2: Create StatusBar** - -Write `packages/tui/src/components/status-bar.tsx`: - -```tsx -import React from 'react'; -import { Box, Text } from 'ink'; -import { useApp } from '../context/app-context.js'; - -export function StatusBar() { - const { view } = useApp(); - - const keys = - view === 'detail' - ? 'e edit d delete u update f fork Esc back' - : '↑↓/jk navigate Tab group / search i install c create d delete u update q quit'; - - return ( - - {keys} - - ); -} -``` - -- [ ] **Step 3: Create SkillRow** - -Write `packages/tui/src/components/skill-row.tsx`: - -```tsx -import React from 'react'; -import { Box, Text } from 'ink'; -import type { Skill } from '@skillpack/core'; - -interface SkillRowProps { - skill: Skill; - isSelected: boolean; - isConflicting: boolean; -} - -export function SkillRow({ skill, isSelected, isConflicting }: SkillRowProps) { - return ( - - - {isSelected ? '▸' : ' '} - - - {skill.name.padEnd(24)} - - {skill.provider.padEnd(12)} - - {skill.enabled ? 'installed' : 'disabled'} - - {skill.readonly && ro} - {isConflicting && } - - ); -} -``` - -- [ ] **Step 4: Commit** - -```bash -git add -A -git commit -m "feat(tui): add tab bar, status bar, and skill row components" -``` - ---- - -## Task 14: TUI — List View with Navigation & Search - -**Files:** -- Create: `packages/tui/src/views/list-view.tsx` -- Create: `packages/tui/src/hooks/use-skills.ts` -- Create: `packages/tui/src/hooks/use-search.ts` -- Create: `packages/tui/src/components/search-input.tsx` - -- [ ] **Step 1: Create search hook** - -Write `packages/tui/src/hooks/use-search.ts`: - -```typescript -import { useMemo } from 'react'; -import Fuse from 'fuse.js'; -import type { Skill } from '@skillpack/core'; - -export function useSearch(skills: Skill[], query: string): Skill[] { - const fuse = useMemo( - () => new Fuse(skills, { keys: ['name', 'description'], threshold: 0.4 }), - [skills], - ); - - if (!query) return skills; - return fuse.search(query).map((r) => r.item); -} -``` - -- [ ] **Step 2: Create skills filtering hook** - -Write `packages/tui/src/hooks/use-skills.ts`: - -```typescript -import { useMemo } from 'react'; -import type { Skill } from '@skillpack/core'; -import { useApp } from '../context/app-context.js'; -import { useSearch } from './use-search.js'; - -export function useFilteredSkills(): Skill[] { - const { skills, activeTab, searchQuery } = useApp(); - - const tabFiltered = useMemo(() => { - if (activeTab === 'All') return skills; - const tabToProvider: Record = { - Codex: 'codex', - Cursor: 'cursor', - 'skills.sh': 'skillssh', - Claude: 'claude', - Project: 'project', - }; - const providerId = tabToProvider[activeTab]; - if (!providerId) return skills; - if (providerId === 'project') return skills.filter((s) => s.scope === 'project'); - return skills.filter((s) => s.provider === providerId); - }, [skills, activeTab]); - - return useSearch(tabFiltered, searchQuery); -} -``` - -- [ ] **Step 3: Create SearchInput component** - -Write `packages/tui/src/components/search-input.tsx`: - -```tsx -import React from 'react'; -import { Box, Text } from 'ink'; -import { TextInput } from '@inkjs/ui'; - -interface SearchInputProps { - value: string; - onChange: (value: string) => void; - onSubmit: () => void; -} - -export function SearchInput({ value, onChange, onSubmit }: SearchInputProps) { - return ( - - / - - - ); -} -``` - -- [ ] **Step 4: Create ListView** - -Write `packages/tui/src/views/list-view.tsx`: - -```tsx -import React, { useState, useCallback } from 'react'; -import { Box, Text, useInput } from 'ink'; -import { useApp } from '../context/app-context.js'; -import { useFilteredSkills } from '../hooks/use-skills.js'; -import { TabBar } from '../components/tab-bar.js'; -import { SkillRow } from '../components/skill-row.js'; -import { StatusBar } from '../components/status-bar.js'; -import { SearchInput } from '../components/search-input.js'; - -const TABS = ['All', 'Codex', 'Cursor', 'skills.sh', 'Claude', 'Project']; - -export function ListView() { - const app = useApp(); - const skills = useFilteredSkills(); - const [cursor, setCursor] = useState(0); - const [searching, setSearching] = useState(false); - const [searchDraft, setSearchDraft] = useState(''); - - const handleTabChange = useCallback( - (direction: number) => { - const idx = TABS.indexOf(app.activeTab); - const next = (idx + direction + TABS.length) % TABS.length; - app.setActiveTab(TABS[next]); - setCursor(0); - }, - [app.activeTab], - ); - - useInput((input, key) => { - if (searching) { - if (key.escape) { - setSearching(false); - app.setSearchQuery(''); - setSearchDraft(''); - } - return; - } - - if (input === '/' || input === 's') { - setSearching(true); - return; - } - - if (key.upArrow || input === 'k') { - setCursor((c) => Math.max(0, c - 1)); - } else if (key.downArrow || input === 'j') { - setCursor((c) => Math.min(skills.length - 1, c + 1)); - } else if (key.tab) { - handleTabChange(key.shift ? -1 : 1); - } else if (key.return && skills[cursor]) { - app.setSelectedSkill(skills[cursor]); - app.setView('detail'); - } else if (input === 'i') { - app.setView('install'); - } else if (input === 'c') { - app.setView('create'); - } else if (input === 'q') { - process.exit(0); - } - }); - - return ( - - - skillpack - ? help - - - - - {searching && ( - { - setSearchDraft(v); - app.setSearchQuery(v); - }} - onSubmit={() => setSearching(false)} - /> - )} - - - {skills.length === 0 ? ( - No skills found. - ) : ( - skills.map((skill, i) => ( - c.skillName === skill.name)} - /> - )) - )} - - - - - ); -} -``` - -- [ ] **Step 5: Commit** - -```bash -git add -A -git commit -m "feat(tui): add list view with tab navigation, search, and keyboard shortcuts" -``` - ---- - -## Task 15: TUI — Detail View - -**Files:** -- Create: `packages/tui/src/views/detail-view.tsx` - -- [ ] **Step 1: Implement DetailView** - -Write `packages/tui/src/views/detail-view.tsx`: - -```tsx -import React from 'react'; -import { Box, Text, useInput } from 'ink'; -import { useApp } from '../context/app-context.js'; -import { StatusBar } from '../components/status-bar.js'; -import { execSync } from 'node:child_process'; - -export function DetailView() { - const { selectedSkill, setView, manager, refresh, conflicts } = useApp(); - - if (!selectedSkill) { - setView('list'); - return null; - } - - const conflict = conflicts.find((c) => c.skillName === selectedSkill.name); - - useInput((input, key) => { - if (key.escape) { - setView('list'); - } else if (input === 'e' && !selectedSkill.readonly) { - const editor = process.env.EDITOR || 'vi'; - const skillMdPath = `${selectedSkill.path}/SKILL.md`; - try { - execSync(`${editor} "${skillMdPath}"`, { stdio: 'inherit' }); - refresh(); - } catch { /* user cancelled */ } - } else if (input === 'E' && !selectedSkill.readonly) { - const editor = process.env.EDITOR || 'vi'; - const skillMdPath = `${selectedSkill.path}/SKILL.md`; - try { - execSync(`${editor} "${skillMdPath}"`, { stdio: 'inherit' }); - refresh(); - } catch { /* user cancelled */ } - } else if (input === 'd') { - manager.uninstallSkill(selectedSkill).then(() => { - refresh(); - setView('list'); - }); - } - }); - - return ( - - - ← Back - {selectedSkill.name} - - - - Platform: {selectedSkill.provider} - Path: {selectedSkill.path} - {selectedSkill.version && Version: {selectedSkill.version}} - Source: {selectedSkill.source?.type ?? 'unknown'}{selectedSkill.source?.repo ? ` (${selectedSkill.source.repo})` : ''} - Status: {selectedSkill.enabled ? 'enabled' : 'disabled'} - Editable: {selectedSkill.readonly ? 'read-only' : 'yes'} - {conflict && ( - ⚠ Conflict: also exists in {conflict.instances.filter(i => i.provider !== selectedSkill.provider).map(i => i.provider).join(', ')} - )} - - - - {selectedSkill.description} - - - - - ); -} -``` - -- [ ] **Step 2: Commit** - -```bash -git add -A -git commit -m "feat(tui): add skill detail view with edit and delete actions" -``` - ---- - -## Task 16: TUI — Install View - -**Files:** -- Create: `packages/tui/src/views/install-view.tsx` - -- [ ] **Step 1: Implement InstallView (multi-step wizard)** - -Write `packages/tui/src/views/install-view.tsx`: - -```tsx -import React, { useState } from 'react'; -import { Box, Text, useInput } from 'ink'; -import { TextInput, Spinner } from '@inkjs/ui'; -import { useApp } from '../context/app-context.js'; -import type { RemoteSkill } from '@skillpack/core'; - -type Step = 'source' | 'query' | 'results' | 'provider' | 'installing'; - -export function InstallView() { - const { manager, setView, refresh } = useApp(); - const [step, setStep] = useState('source'); - const [sourceId, setSourceId] = useState(''); - const [query, setQuery] = useState(''); - const [results, setResults] = useState([]); - const [selectedResult, setSelectedResult] = useState(0); - const [installing, setInstalling] = useState(false); - const [error, setError] = useState(''); - - const sources = manager.getSources(); - const providers = manager.getProviders().filter((p) => p.capabilities.canInstall); - - useInput((input, key) => { - if (key.escape) { - if (step === 'source') { - setView('list'); - } else { - setStep('source'); - } - } - - if (step === 'source') { - if (input === '1' && sources.length >= 1) { - setSourceId(sources[0].id); - setStep('query'); - } else if (input === '2' && sources.length >= 2) { - setSourceId(sources[1].id); - setStep('query'); - } - } - - if (step === 'results') { - if (key.upArrow) setSelectedResult((c) => Math.max(0, c - 1)); - if (key.downArrow) setSelectedResult((c) => Math.min(results.length - 1, c + 1)); - if (key.return && results[selectedResult]) { - setStep('provider'); - } - } - - if (step === 'provider') { - const idx = parseInt(input) - 1; - if (idx >= 0 && idx < providers.length) { - setInstalling(true); - setStep('installing'); - const result = results[selectedResult]; - manager - .installFromSource(sourceId, result.identifier, providers[idx].id) - .then(() => refresh()) - .then(() => setView('list')) - .catch((err) => { - setError(String(err)); - setInstalling(false); - }); - } - } - }); - - return ( - - Install Skill - - {step === 'source' && ( - - Select source: - {sources.map((s, i) => ( - {i + 1}) {s.displayName} - ))} - Esc to cancel - - )} - - {step === 'query' && ( - - Enter search query or identifier: - { - const res = await manager.searchRemote(sourceId, query); - if (sourceId === 'github' && res.length === 0) { - // Treat query as direct identifier for GitHub - setResults([{ - name: query.split('/').pop()?.split('@').pop() ?? query, - description: 'Direct install from GitHub', - source: 'github', - identifier: query, - }]); - } else { - setResults(res); - } - setStep('results'); - }} - placeholder="e.g. gsap or owner/repo@skill-name" - /> - - )} - - {step === 'results' && ( - - Results ({results.length}): - {results.map((r, i) => ( - - {i === selectedResult ? '▸' : ' '} {r.name} — {r.description} - - ))} - Enter to select, Esc to go back - - )} - - {step === 'provider' && ( - - Install to which platform? - {providers.map((p, i) => ( - {i + 1}) {p.displayName} - ))} - - )} - - {step === 'installing' && ( - - - - )} - - {error && {error}} - - ); -} -``` - -- [ ] **Step 2: Commit** - -```bash -git add -A -git commit -m "feat(tui): add install wizard with source selection and search" -``` - ---- - -## Task 17: TUI — Create View - -**Files:** -- Create: `packages/tui/src/views/create-view.tsx` - -- [ ] **Step 1: Implement CreateView** - -Write `packages/tui/src/views/create-view.tsx`: - -```tsx -import React, { useState } from 'react'; -import { Box, Text, useInput } from 'ink'; -import { TextInput } from '@inkjs/ui'; -import { useApp } from '../context/app-context.js'; -import { execSync } from 'node:child_process'; - -type Step = 'provider' | 'name' | 'description' | 'creating'; - -export function CreateView() { - const { manager, setView, refresh } = useApp(); - const [step, setStep] = useState('provider'); - const [providerId, setProviderId] = useState(''); - const [name, setName] = useState(''); - const [description, setDescription] = useState(''); - const [error, setError] = useState(''); - - const providers = manager.getProviders().filter((p) => p.capabilities.canCreate); - - useInput((input, key) => { - if (key.escape) { - setView('list'); - return; - } - - if (step === 'provider') { - const idx = parseInt(input) - 1; - if (idx >= 0 && idx < providers.length) { - setProviderId(providers[idx].id); - setStep('name'); - } - } - }); - - const handleNameSubmit = () => { - if (!name.trim()) { - setError('Name cannot be empty'); - return; - } - setError(''); - setStep('description'); - }; - - const handleDescriptionSubmit = async () => { - setStep('creating'); - try { - const skill = await manager.createSkill(providerId, { - name: name.trim(), - description: description.trim(), - }); - - const editor = process.env.EDITOR || 'vi'; - try { - execSync(`${editor} "${skill.path}/SKILL.md"`, { stdio: 'inherit' }); - } catch { /* user cancelled editor */ } - - await refresh(); - setView('list'); - } catch (err) { - setError(String(err)); - setStep('provider'); - } - }; - - return ( - - Create New Skill - - {step === 'provider' && ( - - Select target platform: - {providers.map((p, i) => ( - {i + 1}) {p.displayName} - ))} - Esc to cancel - - )} - - {step === 'name' && ( - - Skill name (kebab-case): - - - )} - - {step === 'description' && ( - - Short description: - - - )} - - {step === 'creating' && Creating skill and opening editor...} - - {error && {error}} - - ); -} -``` - -- [ ] **Step 2: Commit** - -```bash -git add -A -git commit -m "feat(tui): add create skill wizard with editor integration" -``` - ---- - -## Task 18: TUI — Update View & Placeholder Views - -**Files:** -- Create: `packages/tui/src/views/update-view.tsx` -- Create: `packages/tui/src/components/confirm-dialog.tsx` - -- [ ] **Step 1: Create ConfirmDialog** - -Write `packages/tui/src/components/confirm-dialog.tsx`: - -```tsx -import React from 'react'; -import { Box, Text, useInput } from 'ink'; - -interface ConfirmDialogProps { - message: string; - onConfirm: () => void; - onCancel: () => void; -} - -export function ConfirmDialog({ message, onConfirm, onCancel }: ConfirmDialogProps) { - useInput((input) => { - if (input === 'y' || input === 'Y') onConfirm(); - if (input === 'n' || input === 'N' || input === 'q') onCancel(); - }); - - return ( - - {message} - (y/n) - - ); -} -``` - -- [ ] **Step 2: Implement UpdateView** - -Write `packages/tui/src/views/update-view.tsx`: - -```tsx -import React, { useState, useEffect } from 'react'; -import { Box, Text, useInput } from 'ink'; -import { Spinner } from '@inkjs/ui'; -import { useApp } from '../context/app-context.js'; -import type { Skill, UpdateInfo } from '@skillpack/core'; - -export function UpdateView() { - const { manager, setView, refresh } = useApp(); - const [checking, setChecking] = useState(true); - const [updates, setUpdates] = useState>([]); - const [cursor, setCursor] = useState(0); - - useEffect(() => { - manager.checkUpdates().then((u) => { - setUpdates(u); - setChecking(false); - }); - }, []); - - useInput((input, key) => { - if (key.escape) setView('list'); - if (checking) return; - - if (key.upArrow) setCursor((c) => Math.max(0, c - 1)); - if (key.downArrow) setCursor((c) => Math.min(updates.length - 1, c + 1)); - }); - - if (checking) { - return ( - - - - ); - } - - return ( - - Available Updates ({updates.length}) - - {updates.length === 0 ? ( - All skills are up to date. - ) : ( - - {updates.map(({ skill, update }, i) => ( - - {i === cursor ? '▸' : ' '} {skill.name}: {update.currentVersion} → {update.latestVersion} - - ))} - - )} - - Esc to go back - - ); -} -``` - -- [ ] **Step 3: Commit** - -```bash -git add -A -git commit -m "feat(tui): add update view and confirm dialog" -``` - ---- - -## Task 19: Fork to Local & Lock File Wiring - -**Files:** -- Modify: `packages/core/src/manager.ts` -- Modify: `packages/tui/src/views/detail-view.tsx` - -- [ ] **Step 1: Add forkToLocal to SkillManager** - -In `packages/core/src/manager.ts`, add: - -```typescript -async forkToLocal(skill: Skill, targetProviderId: string): Promise { - const provider = this.providers.get(targetProviderId); - if (!provider) throw new Error(`Provider not found: ${targetProviderId}`); - if (!provider.capabilities.canCreate) { - throw new Error(`Provider ${targetProviderId} does not support creating skills`); - } - - const destDir = path.join(provider.basePaths[0], skill.name); - await cp(skill.path, destDir, { recursive: true }); - - return { - ...skill, - provider: targetProviderId, - path: destDir, - readonly: false, - source: { - type: 'local', - createdAt: new Date().toISOString(), - forkedFrom: skill.source?.type !== 'local' - ? { source: skill.source!.type as 'github' | 'skillssh', identifier: skill.source!.repo ?? skill.name } - : undefined, - }, - }; -} -``` - -Add the `cp` import from `node:fs/promises` at the top. - -- [ ] **Step 2: Add fork keybinding to DetailView** - -In `packages/tui/src/views/detail-view.tsx`, add handling for `f` key: - -```typescript -} else if (input === 'f' && selectedSkill.readonly) { - // Fork to local — pick first writable provider - const writableProviders = manager.getProviders().filter(p => p.capabilities.canCreate); - if (writableProviders.length > 0) { - manager.forkToLocal(selectedSkill, writableProviders[0].id).then(() => { - refresh(); - setView('list'); - }); - } -} -``` - -- [ ] **Step 3: Wire LockfileManager into install flow** - -In `packages/core/src/manager.ts`, add a `lockfile` property initialized in a new `init()` method. After each `installFromSource` call, write the installed skill's info to the lock file: - -```typescript -private globalLock?: LockfileManager; - -async init(configDir?: string): Promise { - const lockPath = path.join(configDir ?? path.join(os.homedir(), '.config', 'skillpack'), 'skillpack.lock'); - this.globalLock = new LockfileManager(lockPath); - await this.globalLock.load(); -} - -// In installFromSource, after successful install: -if (this.globalLock) { - this.globalLock.setEntry(result.skillName, { - source: sourceId, - identifier, - installedAt: new Date().toISOString(), - integrity: '', // TODO: compute hash in a follow-up - }); - await this.globalLock.save(); -} -``` - -Add the required imports (`os`, `LockfileManager`) at the top. - -- [ ] **Step 4: Run tests** - -Run: `cd packages/core && npx vitest run` - -Expected: All tests PASS. - -- [ ] **Step 5: Commit** - -```bash -git add -A -git commit -m "feat: add fork-to-local, wire lock file into install flow" -``` - ---- - -## Task 20: Integration — Build, Link & Smoke Test - -**Files:** -- Modify: `packages/tui/src/app.tsx` (ensure all views are imported correctly) - -- [ ] **Step 1: Verify core builds** - -Run: `cd packages/core && npx tsc --noEmit` - -Expected: No errors. - -- [ ] **Step 2: Verify tui builds** - -Run: `cd packages/tui && npx tsc --noEmit` - -Expected: No errors. Fix any import issues. - -- [ ] **Step 3: Run all core tests** - -Run: `cd packages/core && npx vitest run` - -Expected: All tests PASS. - -- [ ] **Step 4: Build both packages** - -Run: `npm run build` - -Expected: Successful build, `dist/` created in both packages. - -- [ ] **Step 5: Test CLI launch** - -Run: `node packages/tui/dist/bin/skillpack.js` - -Expected: TUI launches, scans skills from local directories, displays tab bar and skill list. Press `q` to quit. - -- [ ] **Step 6: Commit** - -```bash -git add -A -git commit -m "chore: verify build and integration smoke test" -``` - ---- - -## Task 21: Project-Level Skill Scanning with Override Priority - -**Files:** -- Modify: `packages/core/src/manager.ts` -- Modify: `packages/tui/src/hooks/use-skill-manager.ts` -- Modify: `packages/tui/src/context/app-context.tsx` - -- [ ] **Step 1: Add project-level scanning to SkillManager** - -In `packages/core/src/manager.ts`, add a method to scan the current working directory for project-level skills: - -```typescript -async scanProjectSkills(cwd: string, projectSkillsDir: string): Promise { - const projectPath = path.join(cwd, projectSkillsDir); - try { - await access(projectPath); - } catch { - return []; - } - const entries = await readdir(projectPath, { withFileTypes: true }); - const skills: Skill[] = []; - for (const entry of entries) { - if (!entry.isDirectory() || entry.name.startsWith('.')) continue; - const skillDir = path.join(projectPath, entry.name); - const skillMdPath = path.join(skillDir, 'SKILL.md'); - try { - const content = await readFile(skillMdPath, 'utf-8'); - const parsed = parseSkillMd(content); - skills.push({ - name: parsed.name || entry.name, - description: parsed.description, - provider: 'project', - path: skillDir, - version: parsed.raw.version as string | undefined, - enabled: true, - scope: 'project', - readonly: false, - metadata: { - license: parsed.metadata.license, - author: parsed.metadata.author, - tags: parsed.metadata.tags, - }, - source: { type: 'local', createdAt: undefined }, - }); - } catch { /* skip */ } - } - return skills; -} -``` - -Add the required imports (`access`, `readdir`, `readFile`, `path`, `parseSkillMd`) at the top of the file. - -Update `scanAll()` to accept optional `cwd` and `projectSkillsDir` parameters, merge project skills, and handle override priority (project skills shadow global skills with the same name): - -```typescript -async scanAll(cwd?: string, projectSkillsDir?: string): Promise { - const results = await Promise.all( - [...this.providers.values()].map((p) => p.scan()), - ); - let allSkills = results.flat(); - - if (cwd && projectSkillsDir) { - const projectSkills = await this.scanProjectSkills(cwd, projectSkillsDir); - const projectNames = new Set(projectSkills.map((s) => s.name)); - // Project skills override global skills with the same name - allSkills = allSkills.filter((s) => !projectNames.has(s.name)); - allSkills = [...allSkills, ...projectSkills]; - } - - this.skills = allSkills; - this.conflicts = this.conflictDetector.detect(this.skills); -} -``` - -- [ ] **Step 2: Pass cwd to refresh in TUI** - -Update `packages/tui/src/context/app-context.tsx` so `refresh()` passes `process.cwd()` and the configured `projectSkillsDir` to `scanAll()`. Store the config in context or pass it as a prop to `AppProvider`. - -- [ ] **Step 3: Run tests** - -Run: `cd packages/core && npx vitest run` - -Expected: All tests PASS (existing tests don't pass cwd, so project scanning is skipped). - -- [ ] **Step 4: Commit** - -```bash -git add -A -git commit -m "feat: add project-level skill scanning from cwd" -``` - ---- - -## Task 22: README & Package Finalization - -**Files:** -- Create: `README.md` -- Modify: `package.json` (ensure scripts are correct) - -- [ ] **Step 1: Write README.md** - -Write `README.md` with: -- Project description -- Installation instructions (`npm install -g @skillpack/tui`) -- Quick start (run `skillpack` in terminal) -- Keyboard shortcuts reference -- Configuration guide -- How to add a new provider - -- [ ] **Step 2: Final build and test** - -Run: `npm run build && npm test` - -Expected: Build succeeds, all tests pass. - -- [ ] **Step 3: Commit** - -```bash -git add -A -git commit -m "docs: add README with installation and usage guide" -``` diff --git a/docs/superpowers/specs/2026-04-09-skill-disable-design.md b/docs/superpowers/specs/2026-04-09-skill-disable-design.md deleted file mode 100644 index 7ff1b24..0000000 --- a/docs/superpowers/specs/2026-04-09-skill-disable-design.md +++ /dev/null @@ -1,202 +0,0 @@ -# Skill 禁用功能设计 - -## 概述 - -在 skillpack 中添加 skill 的禁用/启用功能,允许用户快速关闭不需要的 skill,避免在特定场景下出现 skill 污染。禁用在文件系统层面生效,agent 平台会真正停止加载被禁用的 skill。 - -## 动机 - -同一个 agent 平台在不同项目中可能不需要所有 skill。例如在一个纯后端项目中,前端相关的 skill(gsap、figma 等)是不必要的,可能干扰 agent 的行为。需要一种快速切换 skill 状态的机制。 - -## 设计 - -### 核心机制:目录重命名 - -禁用一个 skill 时,将其目录名加上 `.disabled-` 前缀: - -``` -禁用: -~/.codex/skills/gsap-core/ → ~/.codex/skills/.disabled-gsap-core/ - -启用: -~/.codex/skills/.disabled-gsap-core/ → ~/.codex/skills/gsap-core/ -``` - -所有 provider 的 `scan()` 已有 `entry.name.startsWith('.') → skip` 的逻辑,agent 平台扫描目录时也遵循忽略隐藏目录的 Unix 惯例。因此重命名后 agent 平台自动停止加载该 skill,无需额外配置。 - -### 作用域 - -全局禁用,对所有项目生效。禁用状态持久化在文件系统中(目录名本身就是状态),不依赖额外配置文件。 - -### 平台能力 - -所有平台均支持禁用/启用。重命名是可逆的安全操作,不同于 install/uninstall/create 等写操作。 - -| 平台 | canToggle | 其他写操作 | -|------|-----------|-----------| -| Codex | true | install, uninstall, create | -| skills.sh | true | install, uninstall, create | -| Cursor | true | 无(其他操作仍只读) | -| Claude | true | 无(其他操作仍只读) | - -### Symlink 兼容性 - -Node.js 的 `fs.rename()` 操作的是 symlink 本身而不是 follow 到目标。禁用/启用 symlink 类型的 skill 目录时,只有链接文件被重命名,目标内容不受影响。`readFile()` 读取 SKILL.md 时自动 follow symlink,行为正确。 - -## Provider 层变更 - -### BaseProvider.scan() 扩展 - -在现有的目录扫描逻辑之外,增加对 `.disabled-*` 目录的扫描: - -```typescript -async scan(): Promise { - const skills: Skill[] = []; - for (const basePath of this.basePaths) { - // ... existing access check ... - const entries = await readdir(basePath, { withFileTypes: true }); - for (const entry of entries) { - if (!entry.isDirectory()) continue; - - // 识别 disabled 状态 - const isDisabled = entry.name.startsWith('.disabled-'); - const skillDirName = isDisabled ? entry.name.slice('.disabled-'.length) : entry.name; - - // 跳过其他 dot 目录(非 .disabled-) - if (entry.name.startsWith('.') && !isDisabled) continue; - - const skillDir = path.join(basePath, entry.name); - const skillMdPath = path.join(skillDir, 'SKILL.md'); - try { - const content = await readFile(skillMdPath, 'utf-8'); - const parsed = parseSkillMd(content); - skills.push({ - name: parsed.name || skillDirName, - description: parsed.description, - provider: this.id, - path: skillDir, - enabled: !isDisabled, - // ... rest of fields - }); - } catch { /* skip */ } - } - } - return skills; -} -``` - -### BaseProvider.disable() / enable() - -```typescript -async disable(name: string): Promise { - for (const basePath of this.basePaths) { - const src = path.join(basePath, name); - const dest = path.join(basePath, `.disabled-${name}`); - try { - await access(src); - await access(dest).then( - () => { throw new Error(`Target ${dest} already exists`); }, - () => { /* dest doesn't exist, good */ }, - ); - await rename(src, dest); - return; - } catch (err) { - if ((err as Error).message?.includes('already exists')) throw err; - // src not found in this basePath, try next - } - } - throw new Error(`Skill "${name}" not found in ${this.displayName}`); -} - -async enable(name: string): Promise { - for (const basePath of this.basePaths) { - const src = path.join(basePath, `.disabled-${name}`); - const dest = path.join(basePath, name); - try { - await access(src); - await rename(src, dest); - return; - } catch { - // not found in this basePath, try next - } - } - throw new Error(`Disabled skill "${name}" not found in ${this.displayName}`); -} -``` - -### ClaudeProvider.scan() 适配 - -`ClaudeProvider` 有自己的 `scan()` override(遍历 `cache////skills//` 深层结构)。需要在最内层 skill 目录遍历中增加 `.disabled-` 识别,逻辑与 BaseProvider 一致。同时 ClaudeProvider 需要 override `disable()`/`enable()` 方法,操作最内层的 skill 目录。 - -### Provider capabilities 更新 - -```typescript -// CodexProvider, SkillsShProvider — canToggle: true -readonly capabilities: ProviderCapabilities = { - canInstall: true, canUninstall: true, canUpdate: true, - canToggle: true, - canCreate: true, -}; - -// CursorProvider — canToggle: true, 其余保持 false -readonly capabilities: ProviderCapabilities = { - canInstall: false, canUninstall: false, canUpdate: false, - canToggle: true, - canCreate: false, -}; - -// ClaudeProvider — 同 CursorProvider -``` - -## SkillManager 层变更 - -新增 `toggleSkill` 方法: - -```typescript -async toggleSkill(skill: Skill): Promise { - const provider = this.providers.get(skill.provider); - if (!provider) throw new Error(`Provider not found: ${skill.provider}`); - if (!provider.capabilities.canToggle) { - throw new Error(`${provider.displayName} does not support toggle`); - } - - if (skill.enabled) { - await provider.disable(skill.name); - } else { - await provider.enable(skill.name); - } -} -``` - -## TUI 变更 - -### 列表视图 - -- `Space` 键:对选中 skill 调用 `manager.toggleSkill()`,然后 `refresh()` -- 所有平台均支持 toggle -- 状态列显示: - - `on`(绿色)— 正常启用 - - `disabled`(黄色,名称暗淡)— 已禁用 - -``` - Name Provider Status -> gsap-core codex on - figma codex on - colorize skillssh disabled -``` - -### 详情页 - -- Status 字段显示 `enabled` / `disabled` -- `Space` 键可切换,底部快捷键动态显示 `Space:enable` 或 `Space:disable` - -### 状态栏 - -列表视图快捷键增加 `Space:toggle`。 - -## 安全约束 - -1. 禁用前检查目标路径 `.disabled-` 不存在,避免覆盖 -2. 所有平台均支持 toggle,TUI 中 Space 键对所有 skill 生效 -3. Symlink 安全:rename 操作 symlink 本身,不 follow -4. 操作即时生效,无需确认(因为恢复同样只需一次 Space) diff --git a/docs/superpowers/specs/2026-04-09-skillpack-design.md b/docs/superpowers/specs/2026-04-09-skillpack-design.md deleted file mode 100644 index 034d7b0..0000000 --- a/docs/superpowers/specs/2026-04-09-skillpack-design.md +++ /dev/null @@ -1,542 +0,0 @@ -# Skillpack — 统一 Agent Skill 管理器 - -## 概述 - -Skillpack 是一个 TUI(终端交互式界面)应用,用于统一管理分散在多个 agent 平台中的 skills。支持浏览、搜索、安装、卸载、更新、启停、编辑、创建 skills,并提供冲突检测能力。 - -**形态:** 终端内交互式界面(类似 lazygit / k9s) -**技术栈:** TypeScript + Ink (React for CLI) -**架构:** Core 库 + TUI 前端分离的 Monorepo - -## 目标平台 - -当前需要管理的 agent 平台 skills 分布在以下位置: - -| 平台 | 路径 | 说明 | -|------|------|------| -| Codex | `~/.codex/skills/` | 含 `.system/` 预装 skills | -| Cursor | `~/.cursor/skills-cursor/` | Cursor 管理的 skills | -| Claude | `~/.claude/plugins/cache/` | Claude 插件缓存 | -| skills.sh | `~/.agents/skills/` | 开放 agent skills 生态 | - -架构设计为可扩展——新增平台只需实现 Provider 接口并注册。 - -## 作用域:全局 vs 项目级 - -Skills 分为两个作用域: - -### 全局 Skills - -各 agent 平台目录下的 skills 作为全局默认加载项: - -- `~/.codex/skills/`、`~/.cursor/skills-cursor/`、`~/.claude/plugins/cache/`、`~/.agents/skills/` -- 对所有项目生效 -- 由 `skillpack.lock`(位于 `~/.config/skillpack/skillpack.lock`)锁定版本 - -### 项目级 Skills - -运行时自动扫描当前工作目录,发现项目级 skill 定义: - -- 扫描路径:`/.skillpack/skills/`(项目本地 skills 目录) -- 项目级 lock 文件:`/.skillpack/skillpack.lock` -- 项目级 skills 优先级高于全局同名 skill(覆盖) -- 适合团队共享的项目专属 skills(提交到 git) - -``` -my-project/ -├── .skillpack/ -│ ├── skills/ # 项目级 skills -│ │ └── my-project-lint/ -│ │ └── SKILL.md -│ └── skillpack.lock # 项目级版本锁定 -└── ... -``` - -### TUI 中的体现 - -Tab 栏新增作用域指示: - -``` -[All] [Codex] [Cursor] [skills.sh] [Claude] [Project] -``` - -列表中通过标签区分 `global` / `project`。项目级 skills 在 Project tab 下集中展示。 - -## 版本锁定 - -### Lock 文件格式 - -`skillpack.lock` 记录每个远程安装的 skill 的精确版本,确保可复现: - -```json -{ - "lockfileVersion": 1, - "skills": { - "gsap-core": { - "source": "skillssh", - "identifier": "vercel-labs/agent-skills@gsap-core", - "version": "1.2.3", - "installedAt": "2026-04-09T10:00:00Z", - "integrity": "sha256-xxxx" - }, - "figma": { - "source": "github", - "repo": "openai/skills", - "ref": "main", - "commit": "abc123def456", - "path": "skills/.curated/figma", - "installedAt": "2026-04-08T15:00:00Z", - "integrity": "sha256-yyyy" - } - } -} -``` - -### Lock 文件行为 - -- **安装时**:写入精确的 commit SHA / version,计算内容 integrity hash -- **更新时**:更新 lock 文件中对应条目 -- **校验**:启动时可选校验本地文件 integrity 是否与 lock 一致(检测手动篡改) -- **全局 lock**:`~/.config/skillpack/skillpack.lock` -- **项目 lock**:`/.skillpack/skillpack.lock`(可提交到 git,团队共享) - -## 更新机制 - -### 检查更新 - -- 启动时可选自动检查(配置项 `autoCheckUpdates: true`) -- 手动触发:主界面按 `U`(大写)批量检查所有 skill 的更新 -- 单个 skill:详情页或列表中按 `u` 检查并更新 - -### 更新流程 - -``` -检查更新 - -> Source.checkUpdate(skill) 对比 lock 中的 commit/version 与远程最新 - -> 有更新时显示:当前版本、最新版本、changelog(如果有) - -> 用户确认后: - -> GitHub: 下载新版本到临时目录 -> 替换本地文件 -> 更新 lock - -> skills.sh: npx skills update -> 更新 lock - -> scan() 刷新列表 -``` - -### 批量更新 - -``` -按 U 触发 - -> 并行检查所有远程安装的 skills - -> 列出可更新的 skills(版本对比表) - -> 用户选择全部更新 / 逐个确认 / 取消 - -> 执行更新 -> 更新 lock 文件 -``` - -## 安装来源 - -两个一等公民的远程安装通道,加上本地创建: - -- **GitHub** — 从任意 GitHub repo 安装(公开 + 私有),支持 `owner/repo` + path 或完整 URL -- **skills.sh** — 通过 skills.sh registry 搜索和安装,委托 `npx skills add` 执行 -- **本地创建** — 用户在 TUI 中选择平台、填写信息,生成模板后用 $EDITOR 编辑 - -## 可编辑性规则 - -只有本地创建的 skills(`source.type === 'local'`)可以被编辑。远程安装的 skills 为只读: - -| 来源 | 浏览 | 编辑 | 卸载 | 更新 | -|------|------|------|------|------| -| `local` | Yes | Yes | Yes | N/A | -| `github` | Yes | **只读** | Yes | Yes(从远程拉取) | -| `skillssh` | Yes | **只读** | Yes | Yes(npx skills update) | - -- TUI 中远程 skill 按 `e` 时提示"此 skill 为远程安装,不可编辑" -- 如需修改远程 skill,提供 **Fork to Local** 操作:复制到本地 skills 目录,`source.type` 改为 `local`,断开与远程的关联 -- Fork 后的 skill 不再接收远程更新 - -## 项目结构 - -Monorepo,pnpm workspaces + TypeScript project references 管理: - -``` -skillpack/ -├── packages/ -│ ├── core/ # @skillpack/core -│ │ ├── src/ -│ │ │ ├── providers/ # 平台适配器 -│ │ │ │ ├── provider.ts # ISkillProvider 接口 -│ │ │ │ ├── codex.ts -│ │ │ │ ├── cursor.ts -│ │ │ │ ├── claude.ts -│ │ │ │ └── skillssh.ts -│ │ │ ├── sources/ # 安装来源 -│ │ │ │ ├── github.ts -│ │ │ │ └── skillssh.ts -│ │ │ ├── models/ # 数据模型 -│ │ │ ├── conflicts.ts # 冲突检测引擎 -│ │ │ └── index.ts -│ │ └── package.json -│ └── tui/ # @skillpack/tui -│ ├── src/ -│ │ ├── components/ # Ink 组件 -│ │ ├── views/ # Tab / 页面视图 -│ │ ├── hooks/ # React hooks -│ │ └── app.tsx # 入口 -│ └── package.json -├── package.json # workspace root -├── pnpm-workspace.yaml # pnpm workspace 配置 -└── tsconfig.json -``` - -**分层原则:** - -- `core` 不依赖任何 UI 库,纯逻辑 + 文件系统操作 -- `tui` 依赖 `core`,只负责渲染和用户交互 -- `core` 的 API 可被 CLI 命令或 Web UI 复用 - -## 数据模型 - -### Skill - -```typescript -interface Skill { - name: string; - description: string; - provider: string; // 来源平台 ID - path: string; // 本地绝对路径 - version?: string; - enabled: boolean; - scope: 'global' | 'project'; // 作用域 - readonly: boolean; // 是否只读 (remote=true, local=false) - metadata: { - license?: string; - author?: string; - tags?: string[]; - }; - source?: { - type: 'github' | 'skillssh' | 'local'; - repo?: string; // github: "owner/repo" - ref?: string; // github: branch/tag - commit?: string; // github: 精确 commit SHA - createdAt?: string; // local: 创建时间 - installedAt?: string; // 远程: 安装时间 - forkedFrom?: { // Fork to Local 时记录原始来源 - source: 'github' | 'skillssh'; - identifier: string; - }; - }; -} -``` - -### ConflictInfo - -```typescript -interface ConflictInfo { - skillName: string; - instances: Array<{ - provider: string; - path: string; - version?: string; - }>; -} -``` - -### RemoteSkill - -```typescript -interface RemoteSkill { - name: string; - description: string; - source: 'github' | 'skillssh'; - identifier: string; // "owner/repo@skill" 或包名 - stars?: number; - installs?: number; - version?: string; -} -``` - -### SkillTemplate - -```typescript -interface SkillTemplate { - name: string; - description: string; - metadata?: Partial; -} -``` - -### InstallRequest - -```typescript -interface InstallRequest { - sourceType: 'github' | 'skillssh'; - identifier: string; // "owner/repo@path" 或 skills.sh 包名 - tempDir: string; // 下载的临时目录路径 -} -``` - -### DiffResult - -```typescript -interface DiffResult { - identical: boolean; - changes: Array<{ - field: string; // "description", "content", "version" 等 - a: string; - b: string; - }>; -} -``` - -### UpdateInfo - -```typescript -interface UpdateInfo { - currentVersion?: string; - latestVersion?: string; - hasUpdate: boolean; - changelog?: string; -} -``` - -## Provider 接口 - -```typescript -interface ISkillProvider { - readonly id: string; - readonly displayName: string; - readonly basePaths: string[]; - - scan(): Promise; - install(name: string, request: InstallRequest): Promise; - uninstall(name: string): Promise; - update(name: string): Promise; - enable(name: string): Promise; - disable(name: string): Promise; - create(template: SkillTemplate): Promise; - - readonly capabilities: { - canInstall: boolean; - canUninstall: boolean; - canUpdate: boolean; - canToggle: boolean; - canCreate: boolean; - }; -} -``` - -`capabilities` 让 TUI 根据平台能力动态控制可用操作。例如 Claude plugins cache 为只读扫描,不支持直接安装或创建。 - -**新增平台的全部工作:** -1. 实现 `ISkillProvider`(一个文件,约 100-200 行) -2. 入口处 `manager.registerProvider(new MyProvider())` -3. TUI 自动在 Tab 栏显示新平台 - -## 安装源接口 - -```typescript -interface IInstallSource { - readonly id: string; - readonly displayName: string; - - search(query: string): Promise; - fetch(identifier: string): Promise; - checkUpdate(skill: Skill): Promise; -} -``` - -### GitHub Source - -- 支持 `owner/repo` + path,支持完整 URL 粘贴 -- 公开 repo 走 GitHub API 直接下载 -- 私有 repo 走 git sparse checkout -- 依赖 `GITHUB_TOKEN` / `GH_TOKEN` 或本地 git 凭证 - -### skills.sh Source - -- 搜索:调用 `npx skills find ` 或请求 skills.sh API -- 安装:委托 `npx skills add -g -y`,安装后落盘到 `~/.agents/skills/` -- 安装完成后触发 `scan()` 刷新列表(不重新实现下载逻辑,保持和 skills.sh 生态兼容) - -### 安装流程 - -``` -TUI 触发安装 - -> 选择安装源 (GitHub / skills.sh) - -> 搜索或输入标识符 - -> 选择目标平台 - -> Source.fetch() 下载到临时目录 (GitHub) 或 npx skills add (skills.sh) - -> Provider.install() 移到最终位置 (GitHub) 或 scan() 刷新 (skills.sh) - -> 更新列表 -``` - -## 冲突检测 - -```typescript -class ConflictDetector { - detect(skills: Skill[]): ConflictInfo[]; - diff(a: Skill, b: Skill): DiffResult; -} -``` - -- 在所有 Provider `scan()` 完成后,按 `skill.name` 聚合 -- 同名 skill 出现在 2+ Provider 中即标记为冲突 -- **非阻断**:TUI 中标黄 ⚠ 提示,不阻止任何操作 -- 用户可选择:查看差异、保留其中一个、全部保留、忽略 - -## TUI 视图 - -### 主界面 - -界面固定在终端视口内,列表内容超出时自动滚动。列表带有表头行标注各列含义。只读状态不在列表中显示,仅在详情页展示。冲突用 `!` 标记。 - -``` -┌─────────────────────────────────────────────────────┐ -│ skillpack — 49 skills [1-10/49] │ -├─────────────────────────────────────────────────────┤ -│ [All] Codex Cursor skills.sh Claude Project │ -├─────────────────────────────────────────────────────┤ -│ Name Provider Status │ -│ > figma codex on │ -│ gh-address-comments codex on │ -│ gh-fix-ci codex on │ -│ pdf codex on │ -│ babysit cursor on │ -│ create-rule cursor on │ -│ ... │ -├─────────────────────────────────────────────────────┤ -│ ↑↓:navigate Tab:switch /:search Enter:detail │ -│ i:install c:create u:updates q:quit │ -└─────────────────────────────────────────────────────┘ -``` - -**列表行为:** -- Name 列固定宽度,超长名称以 `…` 截断 -- 滚动位置在标题栏显示 `[起始-结束/总数]` -- `>` 指示当前选中行 - -### 快捷键 - -| 键 | 操作 | 说明 | -|----|------|------| -| `↑↓` | 导航 | 列表上下移动,自动滚动 | -| `Tab` | 切换分组 | All / 各平台 / Project | -| `/` | 搜索 | 模糊匹配 name + description | -| `Enter` | 详情 | 进入 skill 详情页 | -| `i` | 安装 | 选择源 -> 搜索 -> 选平台 -> 确认 | -| `d` | 卸载 | 确认后 Provider.uninstall()(仅 local skills) | -| `e` / `E` | 编辑 | 仅限 local skills;用 $EDITOR 打开 SKILL.md | -| `c` | 创建 | 选平台 -> 填名称/描述 -> 生成模板 -> $EDITOR | -| `u` | 更新 | 检查远程 skill 的更新 | -| `f` | Fork to Local | 复制远程 skill 为本地可编辑副本 | -| `q` | 退出 | | - -### 详情页 - -描述区域超出视口时可用 `↑↓` 滚动,显示滚动位置指示器。只读状态和 Fork 提示仅在此页展示。 - -``` -┌─────────────────────────────────────────────────┐ -│ < Esc figma │ -├─────────────────────────────────────────────────┤ -│ Platform: codex │ -│ Path: ~/.codex/skills/figma/ │ -│ Version: 2.0.7 │ -│ Source: github (openai/skills) │ -│ Status: enabled │ -│ Editable: read-only (f to fork) │ -│ │ -│ ⚠ Conflicts │ -│ cursor: ~/.cursor/skills-cursor/figma/ │ -├─────────────────────────────────────────────────┤ -│ Description ▲ [1-8/12] ▼ │ -│ Use the Figma MCP server to fetch design │ -│ context, screenshots, variables, and assets... │ -│ │ -├─────────────────────────────────────────────────┤ -│ Esc:back e:edit d:delete f:fork │ -└─────────────────────────────────────────────────┘ -``` - -## 编辑与创建 - -### 创建流程 - -1. 按 `c` -> 选择目标平台(列出 `canCreate=true` 的 Provider) -2. 输入 skill 名称(实时校验重名) -3. 输入简短描述 -4. `Provider.create()` 生成目录和 SKILL.md 模板 -5. 自动打开 `$EDITOR` -6. 编辑器关闭后 `scan()` 刷新列表 - -### SKILL.md 模板 - -```markdown ---- -name: {{name}} -description: {{description}} ---- - -# {{Name}} - -## When to Use This Skill - - - -## Instructions - - -``` - -### 编辑流程(仅限 local skills) - -- `e` — 进入编辑模式:name、description、enabled 内联修改(写回 frontmatter) -- `E` — 用 `$EDITOR` 打开完整 SKILL.md -- 编辑器关闭后解析更新的 frontmatter 刷新视图 -- 远程安装的 skill 按 `e`/`E` 时提示只读,并建议使用 `f` Fork to Local - -### Fork to Local 流程 - -1. 按 `f` -> 选择目标 Provider(canCreate=true 的平台) -2. 复制 skill 目录到目标 Provider 的 basePath -3. 修改 `source.type` 为 `local`,记录 `forkedFrom` 信息 -4. 断开与远程的关联(不再接收更新) -5. 新副本出现在列表中,可编辑 - -## 配置 - -skillpack 维护配置文件 `~/.config/skillpack/config.json`: - -```json -{ - "editor": "$EDITOR", - "autoCheckUpdates": true, - "projectSkillsDir": ".skillpack/skills", - "providers": { - "codex": { "enabled": true, "paths": ["~/.codex/skills"] }, - "cursor": { "enabled": true, "paths": ["~/.cursor/skills-cursor"] }, - "claude": { "enabled": true, "paths": ["~/.claude/plugins/cache"] }, - "skillssh": { "enabled": true, "paths": ["~/.agents/skills"] } - }, - "sources": { - "github": { "enabled": true }, - "skillssh": { "enabled": true } - } -} -``` - -- 用户可禁用不关心的平台、自定义扫描路径 -- 首次运行时自动检测已存在的平台目录生成默认配置 -- `autoCheckUpdates`:启动时是否自动检查远程 skill 更新 -- `projectSkillsDir`:项目级 skills 目录名(默认 `.skillpack/skills`) - -## 关键设计决策 - -1. **core/tui 分离** — 核心逻辑可测试、可复用,未来可加 CLI 或 Web 前端 -2. **Provider capabilities** — 不是所有平台都支持所有操作,TUI 动态适配 -3. **skills.sh 委托** — 安装走 `npx skills add` 而非自行实现,保持生态兼容 -4. **冲突非阻断** — 提示但不强制,用户有最终决定权 -5. **Local 可编辑,Remote 只读** — 远程安装的 skill 不可直接修改,需 Fork to Local 后编辑;只读状态仅在详情页展示,列表中不显示 -6. **版本锁定** — lock 文件记录精确版本/commit,确保环境可复现 -7. **全局 + 项目级** — 全局 skills 默认加载,项目级 skills 自动扫描 cwd 并可覆盖同名全局 skill -8. **配置即发现** — 首次运行自动检测平台,零配置开箱即用 -9. **固定视口** — TUI 固定在终端高度内,列表超出时自动滚动,避免终端输出无限增长 -10. **箭头键导航** — 使用标准 `↑↓` 箭头键,不使用 vim 风格的 `j/k` 键,降低学习门槛 diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index b9e99eb..c364021 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -32,7 +32,7 @@ export interface ConfigManagerOptions { export function createDefaultConfig(homeDir = os.homedir()): SkillpackConfig { return { editor: process.env.EDITOR || 'vi', - autoCheckUpdates: true, + autoCheckUpdates: false, projectSkillsDirs: ['.codex/skills', '.claude/skills', '.agents/skills'], providers: { codex: { enabled: true, paths: [path.join(homeDir, '.codex', 'skills'), path.join(homeDir, '.codex', 'plugins', 'cache')] }, diff --git a/packages/core/src/manager.ts b/packages/core/src/manager.ts index b215202..eb1ae4d 100644 --- a/packages/core/src/manager.ts +++ b/packages/core/src/manager.ts @@ -22,6 +22,7 @@ export class SkillManager { private duplicateDetector = new DuplicateDetector(); private skillsLock = new SkillsLockReader(); private globalSkillsDir = path.join(os.homedir(), '.agents', 'skills'); + private updateAvailability = new Map(); registerProvider(provider: ISkillProvider): void { this.providers.set(provider.id, provider); } registerSource(source: IInstallSource): void { this.sources.set(source.id, source); } @@ -126,6 +127,7 @@ export class SkillManager { getInventory(): SkillGroup[] { return buildSkillInventory(this.skills, { getDisableStrategy: (skill) => this.providers.get(skill.provider)?.getDisableStrategy(skill), + hasUpdate: (skill) => this.updateAvailability.get(skillKey(skill))?.hasUpdate === true, }); } getProjectSkills(): SkillInventoryInstance[] { @@ -170,7 +172,7 @@ export class SkillManager { await provider.setEnabled(instance, targetEnabled); } - async uninstallSkill(skill: Skill): Promise { + async uninstallSkill(skill: Pick): Promise { if (skill.provider === 'global' && skill.source?.type === 'skillssh') { const source = this.sources.get('skillssh') as import('./sources/skillssh.js').SkillsShSource | undefined; if (!source) throw new Error('skills.sh source not registered'); @@ -207,6 +209,7 @@ export class SkillManager { if (skillshSource?.checkUpdates) { updates.push(...await skillshSource.checkUpdates(skillshSkills)); + this.rememberUpdateResults(skillshSkills, updates); return updates; } @@ -214,19 +217,24 @@ export class SkillManager { const update = await this.checkSkillUpdate(skill); if (update) updates.push({ skill, update }); } + this.rememberUpdateResults(skillshSkills, updates); return updates; } - async checkSkillUpdate(skill: Skill): Promise { + async checkSkillUpdate(skill: Pick): Promise { if (!isSkillShManagedGlobalSkill(skill)) return null; for (const source of this.sources.values()) { const update = await source.checkUpdate(skill); - if (update?.hasUpdate) return update; + if (update?.hasUpdate) { + this.updateAvailability.set(skillKey(skill), update); + return update; + } } + this.updateAvailability.delete(skillKey(skill)); return null; } - async updateSkill(skill: Skill): Promise { + async updateSkill(skill: Pick): Promise { if (!skill.source || skill.source.type === 'local') { throw new Error('Cannot update an unmanaged on-disk skill'); } @@ -238,6 +246,17 @@ export class SkillManager { const source = this.sources.get('skillssh') as import('./sources/skillssh.js').SkillsShSource | undefined; if (!source) throw new Error('skills.sh source not registered'); await source.updateViaCli(skill.name); + this.updateAvailability.delete(skillKey(skill)); + } + + private rememberUpdateResults( + checkedSkills: Array>, + updates: Array<{ skill: Pick; update: UpdateInfo }>, + ): void { + for (const skill of checkedSkills) this.updateAvailability.delete(skillKey(skill)); + for (const { skill, update } of updates) { + if (update.hasUpdate) this.updateAvailability.set(skillKey(skill), update); + } } private async collectScanPathDiagnostics(cwd?: string, projectSkillsDirs?: string[]): Promise { @@ -282,6 +301,10 @@ function resolveProjectSkillsPath(cwd: string, dir: string): string { return path.isAbsolute(dir) ? dir : path.join(cwd, dir); } -function isSkillShManagedGlobalSkill(skill: Skill): boolean { +function isSkillShManagedGlobalSkill(skill: Pick): boolean { return skill.provider === 'global' && skill.source?.type === 'skillssh'; } + +function skillKey(skill: Pick): string { + return `${skill.provider}\0${skill.path}`; +} diff --git a/packages/core/src/models/inventory.ts b/packages/core/src/models/inventory.ts index f6c77c6..f6dd8dc 100644 --- a/packages/core/src/models/inventory.ts +++ b/packages/core/src/models/inventory.ts @@ -57,17 +57,18 @@ export function normalizeSkillName(name: string): string { return name.trim().toLowerCase().replace(/[\s_]+/g, '-'); } -function strongIdentityFor(skill: Skill): { key: string; reason: string } | null { +function strongIdentityFor(skill: Skill): { key: string; reason: string; confirmsSingleInstance: boolean } | null { const realPath = skill.resolvedPath ?? skill.path; - if (skill.resolvedPath) { - return { key: `realpath:${realPath}`, reason: 'shared real path' }; - } if (skill.source?.type === 'skillssh' && (skill.source.skillFolderHash || skill.source.repo)) { return { key: `skillssh:${skill.source.skillFolderHash ?? skill.source.repo}`, reason: 'skills.sh provenance', + confirmsSingleInstance: true, }; } + if (skill.resolvedPath) { + return { key: `realpath:${realPath}`, reason: 'shared real path', confirmsSingleInstance: false }; + } return null; } @@ -79,7 +80,7 @@ function inferredIdentityFor(skill: Skill): { key: string; confidence: SkillIden }; } -function actionsFor(skill: Skill): SkillAction[] { +function actionsFor(skill: Skill, disableStrategy: DisableStrategy | undefined): SkillAction[] { const toggleState = skill.origin?.type === 'plugin' ? skill.origin.pluginEnabled : skill.enabled; const toggleAction: SkillAction = toggleState ? 'disable' : 'enable'; if (skill.scope === 'project') return []; @@ -87,10 +88,10 @@ function actionsFor(skill: Skill): SkillAction[] { return ['update', 'remove']; } if (skill.provider === 'global') return []; - return [toggleAction]; + return disableStrategy ? [toggleAction] : []; } -function healthSignalsFor(skill: Skill): HealthSignal[] { +function healthSignalsFor(skill: Skill, options: BuildSkillInventoryOptions): HealthSignal[] { const signals: HealthSignal[] = (skill.scanIssues ?? []).map((issue) => ({ code: issue.code, message: issue.message, @@ -98,14 +99,19 @@ function healthSignalsFor(skill: Skill): HealthSignal[] { if (skill.provider === 'global' && skill.source?.type !== 'skillssh') { signals.push({ code: 'unmanaged-global-skill', message: 'Global Skill is not managed by skills.sh metadata' }); } + if (options.hasUpdate?.(skill)) { + signals.push({ code: 'update-available', message: 'skills.sh update is available' }); + } return signals; } export interface BuildSkillInventoryOptions { getDisableStrategy?: (skill: Skill) => DisableStrategy | undefined; + hasUpdate?: (skill: Skill) => boolean; } function toInstance(skill: Skill, options: BuildSkillInventoryOptions): SkillInventoryInstance { + const disableStrategy = options.getDisableStrategy?.(skill); return { name: skill.name, description: skill.description, @@ -116,15 +122,15 @@ function toInstance(skill: Skill, options: BuildSkillInventoryOptions): SkillInv enabled: skill.enabled, origin: skill.origin, source: skill.source, - disableStrategy: options.getDisableStrategy?.(skill), - actions: actionsFor(skill), - healthSignals: healthSignalsFor(skill), + disableStrategy, + actions: actionsFor(skill, disableStrategy), + healthSignals: healthSignalsFor(skill, options), }; } export function buildSkillInventory(skills: Skill[], options: BuildSkillInventoryOptions = {}): SkillGroup[] { const inventorySkills = skills.filter((s) => s.scope !== 'project'); - const strongGroups = new Map(); + const strongGroups = new Map(); for (const skill of inventorySkills) { const identity = strongIdentityFor(skill); if (!identity) continue; @@ -132,14 +138,18 @@ export function buildSkillInventory(skills: Skill[], options: BuildSkillInventor if (existing) { existing.skills.push(skill); } else { - strongGroups.set(identity.key, { reason: identity.reason, skills: [skill] }); + strongGroups.set(identity.key, { + reason: identity.reason, + confirmsSingleInstance: identity.confirmsSingleInstance, + skills: [skill], + }); } } const assigned = new Set(); const groups = new Map(); for (const [key, group] of strongGroups) { - if (group.skills.length < 2) continue; + if (group.skills.length < 2 && !group.confirmsSingleInstance) continue; for (const skill of group.skills) assigned.add(skill); groups.set(key, { identity: { confidence: 'confirmed', reason: group.reason }, skills: group.skills }); } diff --git a/packages/core/src/providers/claude.ts b/packages/core/src/providers/claude.ts index 64bc040..6e64449 100644 --- a/packages/core/src/providers/claude.ts +++ b/packages/core/src/providers/claude.ts @@ -19,7 +19,7 @@ export class ClaudeProvider extends BaseProvider { readonly displayName = 'Claude'; readonly basePaths: string[]; readonly capabilities: ProviderCapabilities = { - canInstall: false, canUninstall: false, canUpdate: false, canToggle: true, canCreate: false, + canToggle: true, }; readonly flatPaths: string[]; readonly settingsPath: string; diff --git a/packages/core/src/providers/codex.ts b/packages/core/src/providers/codex.ts index ae41b1b..9ebc758 100644 --- a/packages/core/src/providers/codex.ts +++ b/packages/core/src/providers/codex.ts @@ -1,14 +1,13 @@ import { BaseProvider, type ProviderCapabilities } from './provider.js'; -import type { Skill, SkillTemplate } from '../models/index.js'; -import type { InstallRequest } from '../models/source.js'; -import { generateSkillMd, parseSkillMd } from '../parser.js'; +import type { Skill } from '../models/index.js'; +import { parseSkillMd } from '../parser.js'; import { readCodexPluginConfig, readCodexSkillConfigEnabled, writeCodexPluginEnabled, writeCodexSkillConfigEnabled, } from './provider-settings.js'; -import { mkdir, writeFile, rm, cp, readdir, access, readFile, stat, realpath } from 'node:fs/promises'; +import { readdir, access, readFile, stat, realpath } from 'node:fs/promises'; import path from 'node:path'; import os from 'node:os'; @@ -73,7 +72,7 @@ export class CodexProvider extends BaseProvider { readonly pluginCachePaths: string[]; readonly configPath: string; readonly capabilities: ProviderCapabilities = { - canInstall: true, canUninstall: true, canUpdate: true, canToggle: true, canCreate: true, + canToggle: true, }; constructor(basePaths?: string[], configPath?: string) { @@ -154,27 +153,6 @@ export class CodexProvider extends BaseProvider { await this.setEnabled(skill, true); } - override async install(name: string, request: InstallRequest): Promise { - const dest = path.join(this.skillPaths[0] ?? this.basePaths[0], name); - await cp(request.tempDir, dest, { recursive: true }); - } - - override async uninstall(name: string): Promise { - await rm(path.join(this.skillPaths[0] ?? this.basePaths[0], name), { recursive: true, force: true }); - } - - override async create(template: SkillTemplate): Promise { - const skillDir = path.join(this.skillPaths[0] ?? this.basePaths[0], template.name); - await mkdir(skillDir, { recursive: true }); - await writeFile(path.join(skillDir, 'SKILL.md'), generateSkillMd(template), 'utf-8'); - return { - name: template.name, description: template.description, - provider: this.id, path: skillDir, enabled: true, scope: 'global', - metadata: template.metadata ?? {}, - source: { type: 'local', createdAt: new Date().toISOString() }, - }; - } - private async scanPluginCache(): Promise { const pluginConfig = await readCodexPluginConfig(this.configPath); const pluginRoots = await this.selectPluginRoots(pluginConfig); diff --git a/packages/core/src/providers/global.ts b/packages/core/src/providers/global.ts index d2a4c4a..94098ca 100644 --- a/packages/core/src/providers/global.ts +++ b/packages/core/src/providers/global.ts @@ -1,8 +1,4 @@ import { BaseProvider, type ProviderCapabilities } from './provider.js'; -import type { Skill, SkillTemplate } from '../models/index.js'; -import type { InstallRequest } from '../models/source.js'; -import { generateSkillMd } from '../parser.js'; -import { mkdir, writeFile, rm, cp } from 'node:fs/promises'; import path from 'node:path'; import os from 'node:os'; @@ -11,31 +7,10 @@ export class GlobalProvider extends BaseProvider { readonly displayName = 'Global'; readonly basePaths: string[]; readonly capabilities: ProviderCapabilities = { - canInstall: true, canUninstall: true, canUpdate: true, canToggle: false, canCreate: true, + canToggle: false, }; constructor(basePaths?: string[]) { super(); this.basePaths = basePaths ?? [path.join(os.homedir(), '.agents', 'skills')]; } - - override async install(name: string, request: InstallRequest): Promise { - const dest = path.join(this.basePaths[0], name); - await cp(request.tempDir, dest, { recursive: true }); - } - - override async uninstall(name: string): Promise { - await rm(path.join(this.basePaths[0], name), { recursive: true, force: true }); - } - - override async create(template: SkillTemplate): Promise { - const skillDir = path.join(this.basePaths[0], template.name); - await mkdir(skillDir, { recursive: true }); - await writeFile(path.join(skillDir, 'SKILL.md'), generateSkillMd(template), 'utf-8'); - return { - name: template.name, description: template.description, - provider: this.id, path: skillDir, enabled: true, scope: 'global', - metadata: template.metadata ?? {}, - source: { type: 'local', createdAt: new Date().toISOString() }, - }; - } } diff --git a/packages/core/src/providers/provider.ts b/packages/core/src/providers/provider.ts index 073361c..a9c65fd 100644 --- a/packages/core/src/providers/provider.ts +++ b/packages/core/src/providers/provider.ts @@ -1,15 +1,10 @@ -import type { DisableStrategy, ProviderScanPath, Skill, SkillTemplate } from '../models/index.js'; -import type { InstallRequest } from '../models/source.js'; +import type { DisableStrategy, ProviderScanPath, Skill } from '../models/index.js'; import { readdir, access, readFile, rename, stat, realpath } from 'node:fs/promises'; import path from 'node:path'; import { parseSkillMd } from '../parser.js'; export interface ProviderCapabilities { - canInstall: boolean; - canUninstall: boolean; - canUpdate: boolean; canToggle: boolean; - canCreate: boolean; } export type SkillToggleTarget = Pick; @@ -21,15 +16,11 @@ export interface ISkillProvider { readonly capabilities: ProviderCapabilities; scan(): Promise; - install(name: string, request: InstallRequest): Promise; - uninstall(name: string): Promise; - update(name: string): Promise; enable(name: string): Promise; disable(name: string): Promise; setEnabled(skill: SkillToggleTarget, enabled: boolean): Promise; getDisableStrategy(skill: SkillToggleTarget): DisableStrategy | undefined; getScanPaths(): ProviderScanPath[]; - create(template: SkillTemplate): Promise; } export abstract class BaseProvider implements ISkillProvider { @@ -123,9 +114,6 @@ export abstract class BaseProvider implements ISkillProvider { return this.scanBasePaths(this.basePaths); } - async install(_name: string, _request: InstallRequest): Promise { throw new Error(`${this.displayName} does not support install`); } - async uninstall(_name: string): Promise { throw new Error(`${this.displayName} does not support uninstall`); } - async update(_name: string): Promise { throw new Error(`${this.displayName} does not support update`); } getDisableStrategy(_skill: SkillToggleTarget): DisableStrategy | undefined { if (!this.capabilities.canToggle) return undefined; return { @@ -191,5 +179,4 @@ export abstract class BaseProvider implements ISkillProvider { } throw new Error(`Skill "${name}" not found in ${this.displayName}`); } - async create(_template: SkillTemplate): Promise { throw new Error(`${this.displayName} does not support create`); } } diff --git a/packages/core/src/sources/skillssh.ts b/packages/core/src/sources/skillssh.ts index d8d9c65..63199e1 100644 --- a/packages/core/src/sources/skillssh.ts +++ b/packages/core/src/sources/skillssh.ts @@ -1,4 +1,5 @@ import type { IInstallSource } from './source.js'; +import type { UpdateCheckSkill } from './source.js'; import type { RemoteSkill, UpdateInfo, DownloadResult } from '../models/source.js'; import type { Skill } from '../models/skill.js'; import { execFile } from 'node:child_process'; @@ -91,7 +92,7 @@ export class SkillsShSource implements IInstallSource { return { tempDir: '', skillName, files: [] }; } - async checkUpdate(skill: Skill): Promise { + async checkUpdate(skill: UpdateCheckSkill): Promise { if (skill.source?.type !== 'skillssh') return null; try { return parseUpdateForSkill(await this.runCheckCommand(), skill); @@ -147,7 +148,7 @@ export class SkillsShSource implements IInstallSource { } } -function parseUpdateForSkill(clean: string, skill: Skill): UpdateInfo | null { +function parseUpdateForSkill(clean: string, skill: UpdateCheckSkill): UpdateInfo | null { for (const line of clean.split('\n')) { if (!line.includes(skill.name)) continue; if (/update|available|outdated/i.test(line)) { diff --git a/packages/core/src/sources/source.ts b/packages/core/src/sources/source.ts index c59232e..f43df38 100644 --- a/packages/core/src/sources/source.ts +++ b/packages/core/src/sources/source.ts @@ -1,11 +1,13 @@ import type { RemoteSkill, UpdateInfo, DownloadResult } from '../models/source.js'; import type { Skill } from '../models/skill.js'; +export type UpdateCheckSkill = Pick; + export interface IInstallSource { readonly id: string; readonly displayName: string; search(query: string): Promise; fetch(identifier: string): Promise; - checkUpdate(skill: Skill): Promise; + checkUpdate(skill: UpdateCheckSkill): Promise; checkUpdates?(skills: Skill[]): Promise>; } diff --git a/packages/core/tests/inventory.test.ts b/packages/core/tests/inventory.test.ts index 0825ff4..24a6dfd 100644 --- a/packages/core/tests/inventory.test.ts +++ b/packages/core/tests/inventory.test.ts @@ -146,13 +146,56 @@ describe('Skill Inventory', () => { enabled: true, scope: 'global', metadata: {}, - source: { type: 'skillssh' }, + source: { type: 'skillssh', repo: 'owner/repo' }, }]); expect(inventory).toHaveLength(1); + expect(inventory[0].identity.confidence).toBe('confirmed'); expect(inventory[0].instances[0].actions).toEqual(['update', 'remove']); }); + it('does not expose toggle actions when the provider has no Disable Strategy', () => { + const inventory = buildSkillInventory([{ + name: 'plugin-skill', + description: '', + provider: 'codex', + path: path.join(root, 'codex', 'plugin-skill'), + enabled: true, + scope: 'global', + metadata: {}, + origin: { + type: 'plugin', + pluginId: 'github@openai-curated', + pluginName: 'github', + marketplace: 'openai-curated', + pluginEnabled: true, + identityStatus: 'mismatched', + }, + source: { type: 'local' }, + }], { + getDisableStrategy: () => undefined, + }); + + expect(inventory[0].instances[0].actions).toEqual([]); + }); + + it('surfaces remembered skills.sh update checks as health signals', () => { + const inventory = buildSkillInventory([{ + name: 'managed-global', + description: '', + provider: 'global', + path: path.join(root, 'global', 'managed-global'), + enabled: true, + scope: 'global', + metadata: {}, + source: { type: 'skillssh', repo: 'owner/repo' }, + }], { + hasUpdate: () => true, + }); + + expect(inventory[0].healthSignals.map((signal) => signal.code)).toContain('update-available'); + }); + it('exposes the provider Disable Strategy for mutable inventory instances', async () => { const codexDir = path.join(root, 'codex'); const codexConfig = path.join(root, 'codex-config.toml'); diff --git a/packages/core/tests/providers/codex.test.ts b/packages/core/tests/providers/codex.test.ts index f9e584f..c8414c0 100644 --- a/packages/core/tests/providers/codex.test.ts +++ b/packages/core/tests/providers/codex.test.ts @@ -59,20 +59,6 @@ describe('CodexProvider', () => { expect(skills).toHaveLength(0); }); - it('creates a new skill', async () => { - const skill = await provider.create({ name: 'new-skill', description: 'Brand new' }); - expect(skill.name).toBe('new-skill'); - expect(skill.source?.type).toBe('local'); - }); - - it('uninstalls a skill', async () => { - const skillDir = path.join(dir, 'to-delete'); - await writeSkill(skillDir, 'to-delete', 'Delete me'); - await provider.uninstall('to-delete'); - const skills = await provider.scan(); - expect(skills).toHaveLength(0); - }); - it('reads enabled state from Codex config entries', async () => { const skillDir = path.join(dir, 'my-skill'); await writeSkill(skillDir, 'my-skill', 'Toggle me'); diff --git a/packages/tui/README.md b/packages/tui/README.md index 51f1263..9b3ba8a 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -28,9 +28,9 @@ npx skillpack-tui ## Quick Start -Launch `skillpack` to see discovered skills grouped by provider. Use the arrow keys to navigate, `Tab` / `Shift+Tab` to switch provider tabs, and `/` to search. +Launch `skillpack` to see discovered skills grouped into Skill Groups with provider status and Health Signals. Use the arrow keys to navigate, `Tab` / `Shift+Tab` to filter by provider, and `/` to search. -Press `Space` to toggle a Codex or Claude skill on or off, `Enter` to view details, `p` to inspect read-only Project Skills, `s` to inspect Settings and Scan Roots, `i` to install a Global Skill, or `u` to open manual updates. Plugin-owned skills ask for confirmation because the toggle affects every skill from the owning plugin. +Press `Enter` to inspect a Skill Group, then use left/right to choose a provider instance before taking instance-level actions. Press `p` to inspect read-only Project Skills, `s` to inspect Settings and Scan Roots, `i` to install a Global Skill, or `u` to open manual updates. Plugin-owned skills ask for confirmation because the toggle affects every skill from the owning plugin. ## Keyboard Shortcuts @@ -39,8 +39,7 @@ Press `Space` to toggle a Codex or Claude skill on or off, `Enter` to view detai | Key | Action | | --- | --- | | `↑` / `↓` | Navigate skills | -| `Space` | Enable or disable selected Codex or Claude skill; plugin-owned skills ask for confirmation | -| `Enter` | Open skill detail view | +| `Enter` | Open Skill Group detail view | | `Tab` / `Shift+Tab` | Switch provider tab | | `/` | Search | | `Esc` | Clear search | @@ -55,6 +54,7 @@ Press `Space` to toggle a Codex or Claude skill on or off, `Enter` to view detai | Key | Action | | --- | --- | | `Esc` | Return to list view | +| `←` / `→` | Switch provider instance | | `Space` | Enable or disable Codex or Claude skill; plugin-owned skills ask for confirmation | | `o` / `O` | Open skill folder | | `d` | Remove a skills.sh-managed Global Skill | diff --git a/packages/tui/src/components/help-overlay.tsx b/packages/tui/src/components/help-overlay.tsx index 43827b4..50fbf76 100644 --- a/packages/tui/src/components/help-overlay.tsx +++ b/packages/tui/src/components/help-overlay.tsx @@ -13,10 +13,8 @@ function formatViewTitle(view: string): string { } export function HelpOverlay(_props: HelpOverlayProps) { - const { view, selectedSkill, manager } = useAppContext(); - const canToggle = selectedSkill - ? Boolean(manager.getProvider(selectedSkill.provider)?.getDisableStrategy(selectedSkill)) - : false; + const { view, selectedSkill } = useAppContext(); + const canToggle = selectedSkill?.actions.some((action) => action === 'enable' || action === 'disable') ?? false; const shortcuts = getShortcutsForView({ view, selectedSkill, canToggle }); return ( diff --git a/packages/tui/src/components/skill-row.tsx b/packages/tui/src/components/skill-row.tsx index 2787f5b..be349d2 100644 --- a/packages/tui/src/components/skill-row.tsx +++ b/packages/tui/src/components/skill-row.tsx @@ -1,28 +1,29 @@ import { Box, Text } from 'ink'; -import type { Skill } from '@skillpack/core'; -import { fitCell, formatInventoryStatus, getGlyphSet, type InventoryLayout } from '../lib/responsive-layout.js'; +import type { SkillGroup } from '@skillpack/core'; +import { fitCell, getGlyphSet, type InventoryLayout } from '../lib/responsive-layout.js'; export const COL_NAME_WIDTH = 30; export const COL_AGENT_WIDTH = 10; interface SkillRowProps { - skill: Skill; + skill: SkillGroup; isSelected: boolean; - isDuplicate: boolean; columns?: InventoryLayout['columns']; - statusVariant?: 'full' | 'compact'; } export function SkillRow({ skill, isSelected, - isDuplicate, columns = { name: COL_NAME_WIDTH, provider: COL_AGENT_WIDTH, status: 8 }, - statusVariant = 'full', }: SkillRowProps) { - const statusLabel = formatInventoryStatus(skill.enabled, statusVariant); - const statusColor = skill.enabled ? 'green' : undefined; const glyphs = getGlyphSet(); + const providerLabel = skill.providers + .map((provider) => `${provider.provider}:${provider.enabled ? 'on' : 'off'}`) + .join(' '); + const healthLabel = skill.healthSignals.length === 0 + ? 'ok' + : [...new Set(skill.healthSignals.map((signal) => signal.code.replace(/-/g, ' ')))].join(', '); + const hasWarnings = skill.healthSignals.length > 0; return ( @@ -30,17 +31,16 @@ export function SkillRow({ {isSelected ? glyphs.selected : ' '} {fitCell(skill.name, columns.name)} - {fitCell(skill.provider, columns.provider)} - - {fitCell(statusLabel, columns.status)} + {fitCell(providerLabel, columns.provider)} + + {fitCell(healthLabel, columns.status)} - {isDuplicate && {glyphs.warning}} + {hasWarnings && {glyphs.warning}} ); } diff --git a/packages/tui/src/components/status-bar.tsx b/packages/tui/src/components/status-bar.tsx index e46f8d5..0d9aada 100644 --- a/packages/tui/src/components/status-bar.tsx +++ b/packages/tui/src/components/status-bar.tsx @@ -5,11 +5,9 @@ import { getVisibleShortcuts } from '../lib/responsive-layout.js'; import { getShortcutsForView } from '../lib/shortcuts.js'; export function StatusBar() { - const { view, selectedSkill, manager } = useAppContext(); + const { view, selectedSkill } = useAppContext(); const { columns, rows } = useTerminalSize(); - const canToggle = selectedSkill - ? Boolean(manager.getProvider(selectedSkill.provider)?.getDisableStrategy(selectedSkill)) - : false; + const canToggle = selectedSkill?.actions.some((action) => action === 'enable' || action === 'disable') ?? false; const shortcuts = getShortcutsForView({ view, selectedSkill, canToggle }); const visibleShortcuts = getVisibleShortcuts(shortcuts, { columns, rows }); diff --git a/packages/tui/src/context/app-context.tsx b/packages/tui/src/context/app-context.tsx index d6aa561..8cbf9ba 100644 --- a/packages/tui/src/context/app-context.tsx +++ b/packages/tui/src/context/app-context.tsx @@ -1,5 +1,13 @@ import { createContext, useContext, useState, useCallback, type ReactNode } from 'react'; -import type { SkillManager, Skill, DuplicateInfo, SkillInventoryInstance, SkillpackConfig, ScanPathDiagnostic } from '@skillpack/core'; +import type { + SkillManager, + Skill, + DuplicateInfo, + SkillGroup, + SkillInventoryInstance, + SkillpackConfig, + ScanPathDiagnostic, +} from '@skillpack/core'; export type ViewType = 'list' | 'detail' | 'install' | 'project' | 'settings' | 'updates'; @@ -7,12 +15,14 @@ interface AppState { manager: SkillManager; config: SkillpackConfig; skills: Skill[]; + inventory: SkillGroup[]; projectSkills: SkillInventoryInstance[]; scanPaths: ScanPathDiagnostic[]; duplicates: DuplicateInfo[]; activeTab: string; view: ViewType; - selectedSkill: Skill | null; + selectedGroup: SkillGroup | null; + selectedSkill: SkillInventoryInstance | null; searchQuery: string; loading: boolean; } @@ -20,7 +30,8 @@ interface AppState { interface AppContextValue extends AppState { setActiveTab: (tab: string) => void; setView: (view: ViewType) => void; - setSelectedSkill: (skill: Skill | null) => void; + setSelectedGroup: (group: SkillGroup | null) => void; + setSelectedSkill: (skill: SkillInventoryInstance | null) => void; setSearchQuery: (query: string) => void; setLoading: (loading: boolean) => void; refresh: () => Promise; @@ -42,12 +53,14 @@ interface AppProviderProps { export function AppProvider({ manager, config, children }: AppProviderProps) { const [skills, setSkills] = useState(manager.getAllSkills()); + const [inventory, setInventory] = useState(manager.getInventory()); const [projectSkills, setProjectSkills] = useState(manager.getProjectSkills()); const [scanPaths, setScanPaths] = useState(manager.getScanPathDiagnostics()); const [duplicates, setDuplicates] = useState(manager.getDuplicates()); const [activeTab, setActiveTab] = useState('All'); const [view, setView] = useState('list'); - const [selectedSkill, setSelectedSkill] = useState(null); + const [selectedGroup, setSelectedGroup] = useState(null); + const [selectedSkill, setSelectedSkill] = useState(null); const [searchQuery, setSearchQuery] = useState(''); const [loading, setLoading] = useState(false); @@ -55,21 +68,32 @@ export function AppProvider({ manager, config, children }: AppProviderProps) { setLoading(true); await manager.scanAll(process.cwd(), config.projectSkillsDirs); const newSkills = manager.getAllSkills(); + const newInventory = manager.getInventory(); setSkills(newSkills); + setInventory(newInventory); setProjectSkills(manager.getProjectSkills()); setScanPaths(manager.getScanPathDiagnostics()); setDuplicates(manager.getDuplicates()); + setSelectedGroup((prev) => { + if (!prev) return null; + return newInventory.find((group) => group.id === prev.id) ?? null; + }); setSelectedSkill((prev) => { if (!prev) return null; - return newSkills.find((s) => s.name === prev.name && s.provider === prev.provider) ?? null; + const nextGroup = newInventory.find((group) => group.id === selectedGroup?.id); + const nextInstance = nextGroup?.instances.find((instance) => ( + instance.provider === prev.provider && instance.path === prev.path + )); + return nextInstance ?? nextGroup?.instances[0] ?? null; }); setLoading(false); - }, [manager, config]); + }, [manager, config, selectedGroup?.id]); return ( {children} diff --git a/packages/tui/src/hooks/use-search.ts b/packages/tui/src/hooks/use-search.ts index 5abdd53..0c019e1 100644 --- a/packages/tui/src/hooks/use-search.ts +++ b/packages/tui/src/hooks/use-search.ts @@ -1,15 +1,16 @@ import { useMemo } from 'react'; import Fuse from 'fuse.js'; -import type { Skill } from '@skillpack/core'; -export function useSearch(skills: Skill[], query: string): Skill[] { +const DEFAULT_SEARCH_KEYS = ['name', 'description']; + +export function useSearch(items: T[], query: string, keys = DEFAULT_SEARCH_KEYS): T[] { const fuse = useMemo( - () => new Fuse(skills, { keys: ['name', 'description'], threshold: 0.4 }), - [skills], + () => new Fuse(items, { keys, threshold: 0.4 }), + [items, keys], ); return useMemo(() => { - if (!query) return skills; + if (!query) return items; return fuse.search(query).map((result) => result.item); - }, [fuse, skills, query]); + }, [fuse, items, query]); } diff --git a/packages/tui/src/hooks/use-skills.ts b/packages/tui/src/hooks/use-skills.ts index 5ccea9f..6adcc06 100644 --- a/packages/tui/src/hooks/use-skills.ts +++ b/packages/tui/src/hooks/use-skills.ts @@ -1,7 +1,7 @@ import { useMemo } from 'react'; import { useAppContext } from '../context/app-context.js'; import { useSearch } from './use-search.js'; -import type { Skill } from '@skillpack/core'; +import type { SkillGroup } from '@skillpack/core'; const TAB_PROVIDER_MAP: Record = { All: null, @@ -12,17 +12,18 @@ const TAB_PROVIDER_MAP: Record = { export const TABS = Object.keys(TAB_PROVIDER_MAP); -export function useFilteredSkills(): { skills: Skill[]; tabs: string[] } { - const { skills, activeTab, searchQuery } = useAppContext(); +const GROUP_SEARCH_KEYS = ['name', 'identity.reasons', 'instances.description', 'instances.provider', 'healthSignals.message']; + +export function useFilteredSkills(): { skills: SkillGroup[]; tabs: string[] } { + const { inventory, activeTab, searchQuery } = useAppContext(); const tabFiltered = useMemo(() => { const provider = TAB_PROVIDER_MAP[activeTab]; - const inventorySkills = skills.filter((s) => s.scope !== 'project'); - if (provider === null) return inventorySkills; - return inventorySkills.filter((s) => s.provider === provider); - }, [skills, activeTab]); + if (provider === null) return inventory; + return inventory.filter((group) => group.instances.some((instance) => instance.provider === provider)); + }, [inventory, activeTab]); - const filtered = useSearch(tabFiltered, searchQuery); + const filtered = useSearch(tabFiltered, searchQuery, GROUP_SEARCH_KEYS); return { skills: filtered, tabs: TABS }; } diff --git a/packages/tui/src/lib/plugin-toggle.ts b/packages/tui/src/lib/plugin-toggle.ts index e9deeef..9f1a4f4 100644 --- a/packages/tui/src/lib/plugin-toggle.ts +++ b/packages/tui/src/lib/plugin-toggle.ts @@ -1,10 +1,14 @@ import type { Skill } from '@skillpack/core'; -export function isPluginOwnedSkill(skill: Skill | null | undefined): skill is Skill & { origin: NonNullable } { +type PluginToggleSkill = Pick; + +export function isPluginOwnedSkill( + skill: T, +): skill is NonNullable & { origin: NonNullable } { return skill?.origin?.type === 'plugin'; } -export function getAffectedPluginSkills(skills: Skill[], skill: Skill): Skill[] { +export function getAffectedPluginSkills(skills: T[], skill: PluginToggleSkill): T[] { if (!isPluginOwnedSkill(skill)) return []; return skills .filter((candidate) => ( @@ -15,7 +19,7 @@ export function getAffectedPluginSkills(skills: Skill[], skill: Skill): Skill[] .sort((left, right) => left.name.localeCompare(right.name)); } -export function formatPluginToggleMessage(skill: Skill, skills: Skill[]): string { +export function formatPluginToggleMessage(skill: PluginToggleSkill, skills: T[]): string { if (!isPluginOwnedSkill(skill)) return ''; const action = skill.origin.pluginEnabled ? 'Disable' : 'Enable'; const affected = getAffectedPluginSkills(skills, skill); diff --git a/packages/tui/src/lib/responsive-layout.ts b/packages/tui/src/lib/responsive-layout.ts index 6114bfc..ac7a61c 100644 --- a/packages/tui/src/lib/responsive-layout.ts +++ b/packages/tui/src/lib/responsive-layout.ts @@ -77,8 +77,8 @@ export function wrapTextLines(lines: string[], width: number): string[] { } export function formatInventoryStatus(enabled: boolean, variant: 'full' | 'compact'): string { - if (variant === 'compact') return fitCell(enabled ? 'on' : 'off', COMPACT_STATUS_WIDTH); - return fitCell(enabled ? 'enabled' : 'disabled', FULL_STATUS_WIDTH); + if (variant === 'compact') return fitCell(enabled ? 'on' : 'off', COMPACT_AVAILABILITY_STATUS_WIDTH); + return fitCell(enabled ? 'enabled' : 'disabled', FULL_AVAILABILITY_STATUS_WIDTH); } export interface InventoryLayoutInput { @@ -140,6 +140,7 @@ export interface BoundedContentLayout { export interface ProjectSkillsColumns { name: number; + description: number; path: number; state: number; rowWidth: number; @@ -158,10 +159,12 @@ const COMPACT_CHROME_LINES = 5; const HORIZONTAL_PADDING = 2; const MIN_NAME_WIDTH = 12; const MAX_NAME_WIDTH = 30; -const FULL_PROVIDER_WIDTH = 10; -const COMPACT_PROVIDER_WIDTH = 8; -const FULL_STATUS_WIDTH = 8; -const COMPACT_STATUS_WIDTH = 3; +const FULL_PROVIDER_WIDTH = 24; +const COMPACT_PROVIDER_WIDTH = 18; +const FULL_STATUS_WIDTH = 18; +const COMPACT_STATUS_WIDTH = 14; +const FULL_AVAILABILITY_STATUS_WIDTH = 8; +const COMPACT_AVAILABILITY_STATUS_WIDTH = 3; const ROW_FIXED_WIDTH = 6; const DETAIL_FULL_CHROME_LINES = 13; const DETAIL_COMPACT_CHROME_LINES = 6; @@ -269,13 +272,17 @@ export function getProjectSkillsColumns(size: TerminalSize): ProjectSkillsColumn const contentWidth = Math.max(0, size.columns - HORIZONTAL_PADDING); const state = mode === 'compact' ? 14 : PROJECT_STATE_WIDTH; const fixedWidth = 5 + state; - const name = Math.max(12, Math.min(30, Math.floor((contentWidth - fixedWidth) * 0.4))); - const path = Math.max(12, contentWidth - fixedWidth - name); + const name = Math.max(12, Math.min(24, Math.floor((contentWidth - fixedWidth) * 0.3))); + const description = mode === 'compact' + ? 0 + : Math.max(12, Math.min(24, Math.floor((contentWidth - fixedWidth - name) * 0.45))); + const path = Math.max(12, contentWidth - fixedWidth - name - description); return { name, + description, path, state, - rowWidth: fixedWidth + name + path, + rowWidth: fixedWidth + name + description + path, }; } diff --git a/packages/tui/src/lib/shortcuts.ts b/packages/tui/src/lib/shortcuts.ts index 4230d66..19724c3 100644 --- a/packages/tui/src/lib/shortcuts.ts +++ b/packages/tui/src/lib/shortcuts.ts @@ -1,4 +1,4 @@ -import type { Skill } from '@skillpack/core'; +import type { SkillInventoryInstance } from '@skillpack/core'; import type { ViewType } from '../context/app-context.js'; import type { ShortcutLayoutItem } from './responsive-layout.js'; @@ -7,14 +7,14 @@ export interface Shortcut extends ShortcutLayoutItem {} const SHORTCUTS: Record = { list: [ { key: '↑↓', label: 'navigate', compactLabel: 'nav' }, - { key: 'space', label: 'toggle when supported', compactLabel: 'toggle' }, - { key: 'enter', label: 'detail' }, + { key: 'enter', label: 'group detail', compactLabel: 'detail' }, { key: '/', label: 'search' }, { key: '?', label: 'help' }, { key: 'tab', label: 'tabs' }, { key: 'p', label: 'project' }, { key: 's', label: 'settings' }, { key: 'i', label: 'install' }, + { key: 'u', label: 'updates' }, { key: 'q', label: 'quit' }, ], install: [ @@ -49,6 +49,7 @@ const DETAIL_BASE: Shortcut[] = [ { key: 'esc', label: 'back' }, { key: '?', label: 'help' }, { key: 'tab', label: 'sections' }, + { key: '←→', label: 'provider instance', compactLabel: 'provider' }, ]; const DETAIL_TAIL: Shortcut[] = [ @@ -57,7 +58,7 @@ const DETAIL_TAIL: Shortcut[] = [ interface ShortcutContext { view: ViewType; - selectedSkill: Skill | null; + selectedSkill: SkillInventoryInstance | null; canToggle: boolean; } diff --git a/packages/tui/src/views/detail-view.tsx b/packages/tui/src/views/detail-view.tsx index cd0729b..eb02934 100644 --- a/packages/tui/src/views/detail-view.tsx +++ b/packages/tui/src/views/detail-view.tsx @@ -26,7 +26,7 @@ function formatRelativeTime(iso: string): string { } export function DetailView() { - const { selectedSkill, setView, refresh, manager } = useAppContext(); + const { selectedGroup, selectedSkill, setSelectedSkill, setView, refresh, manager } = useAppContext(); const { columns, rows } = useTerminalSize(); const [confirming, setConfirming] = useState<'remove' | 'plugin-toggle' | 'update' | null>(null); const [activeSection, setActiveSection] = useState('summary'); @@ -39,19 +39,18 @@ export function DetailView() { const [updating, setUpdating] = useState(false); const glyphs = getGlyphSet(); - const sourceType = selectedSkill?.source?.type; - const isUpdatable = selectedSkill?.provider === 'global' && sourceType === 'skillssh'; - const isRemovable = selectedSkill?.provider === 'global' && sourceType === 'skillssh'; - const canToggle = selectedSkill - ? Boolean(manager.getProvider(selectedSkill.provider)?.getDisableStrategy(selectedSkill)) - : false; - const disableStrategy = selectedSkill - ? manager.getProvider(selectedSkill.provider)?.getDisableStrategy(selectedSkill) - : undefined; - - const duplicate = selectedSkill - ? manager.getDuplicates().find((d) => d.skillName === selectedSkill.name) - : undefined; + const isUpdatable = selectedSkill?.actions.includes('update') ?? false; + const isRemovable = selectedSkill?.actions.includes('remove') ?? false; + const canToggle = selectedSkill?.actions.some((action) => action === 'enable' || action === 'disable') ?? false; + const disableStrategy = selectedSkill?.disableStrategy; + const groupHealthSignals = selectedGroup?.healthSignals ?? []; + const instanceHealthSignals = selectedSkill?.healthSignals ?? []; + const hasWarnings = groupHealthSignals.length > 0 || instanceHealthSignals.length > 0; + const selectedInstanceIndex = selectedGroup && selectedSkill + ? selectedGroup.instances.findIndex((instance) => ( + instance.provider === selectedSkill.provider && instance.path === selectedSkill.path + )) + : -1; const addedAt = selectedSkill?.source?.installedAt ?? selectedSkill?.source?.createdAt; @@ -68,7 +67,7 @@ export function DetailView() { const detailLayout = getDetailLayout({ size: { columns, rows }, hasDescription: descLines.length > 0, - hasWarnings: Boolean(duplicate), + hasWarnings, }); const fullVisibleDescRows = useMemo(() => { @@ -83,14 +82,14 @@ export function DetailView() { if (disableStrategy) used += 1; if (isUpdatable) used += 1; // update row if (addedAt) used += 1; - if (duplicate) used += 1 + 1 + duplicate.instances.length; // gap + heading + instances + if (hasWarnings) used += 1 + 1 + groupHealthSignals.length + instanceHealthSignals.length; // gap + heading + signals used += 1; // gap before description used += 1; // separator used += 1; // "description" label used += 1; // status bar if (error) used += 1; return Math.max(0, rows - used); - }, [selectedSkill, duplicate, error, rows, isUpdatable, disableStrategy]); + }, [selectedSkill, groupHealthSignals.length, instanceHealthSignals.length, hasWarnings, error, rows, isUpdatable, disableStrategy]); const visibleDescRows = detailLayout.sectioned ? Math.max(1, detailLayout.visibleRows - 1) @@ -102,6 +101,16 @@ export function DetailView() { useInput((input, key) => { if (key.escape) { setView('list'); return; } + if ((key.leftArrow || key.rightArrow) && selectedGroup && selectedSkill && selectedGroup.instances.length > 1) { + const currentIndex = selectedInstanceIndex >= 0 ? selectedInstanceIndex : 0; + const direction = key.rightArrow ? 1 : -1; + const nextIndex = (currentIndex + direction + selectedGroup.instances.length) % selectedGroup.instances.length; + setSelectedSkill(selectedGroup.instances[nextIndex]); + setUpdateInfo(null); + setError(null); + setNotice(null); + return; + } if (key.tab && detailLayout.sectioned) { const idx = detailLayout.sections.findIndex((section) => section.id === activeSection); const next = key.shift @@ -125,7 +134,7 @@ export function DetailView() { setError(null); setNotice(null); setBusy(true); - manager.toggleSkill(selectedSkill) + manager.toggleInventoryInstance(selectedSkill) .then(() => refresh()) .then(() => setNotice('Availability updated.')) .catch((err: Error) => setError(err.message)) @@ -144,7 +153,10 @@ export function DetailView() { } else if (!updateInfo) { setCheckingUpdate(true); manager.checkSkillUpdate(selectedSkill) - .then((info) => setUpdateInfo(info ?? { hasUpdate: false })) + .then(async (info) => { + setUpdateInfo(info ?? { hasUpdate: false }); + await refresh(); + }) .catch((err: Error) => setError(err.message)) .finally(() => setCheckingUpdate(false)); } @@ -157,8 +169,8 @@ export function DetailView() { } }, { isActive: !confirming }); - if (!selectedSkill) { - return No skill selected; + if (!selectedGroup || !selectedSkill) { + return No Skill Group selected; } if (confirming === 'remove') { @@ -190,7 +202,7 @@ export function DetailView() { onConfirm={() => { setError(null); setBusy(true); - manager.toggleSkill(selectedSkill) + manager.toggleInventoryInstance(selectedSkill) .then(() => refresh()) .then(() => setNotice('Plugin availability updated.')) .catch((err: Error) => setError(err.message)) @@ -241,9 +253,17 @@ export function DetailView() { case 'summary': return ( <> + + {'identity'.padEnd(10)} + {selectedGroup.identity.confidence} + {selectedGroup.identity.reasons.join(', ')} + {'agent'.padEnd(10)} {selectedSkill.provider} + {selectedGroup.instances.length > 1 && ( + {selectedInstanceIndex + 1}/{selectedGroup.instances.length} + )} {'status'.padEnd(10)} @@ -263,6 +283,14 @@ export function DetailView() { {formatRelativeTime(addedAt)} )} + + provider instances + {selectedGroup.instances.map((instance) => ( + + {instance.provider} {instance.enabled ? 'enabled' : 'disabled'} + + ))} + ); case 'paths': @@ -321,12 +349,12 @@ export function DetailView() { ); case 'warnings': - return duplicate ? ( + return hasWarnings ? ( - Duplicates - {duplicate.instances.map((inst) => ( - - {inst.provider} {'->'} {inst.path} + Health Signals + {[...groupHealthSignals, ...instanceHealthSignals].map((signal, index) => ( + + {signal.code}: {signal.message} ))} @@ -350,7 +378,7 @@ export function DetailView() { ‹ esc {glyphs.brand} - {selectedSkill.name} + {selectedGroup.name} @@ -403,11 +431,29 @@ export function DetailView() { ‹ esc {glyphs.brand} - {selectedSkill.name} + {selectedGroup.name} {/* Metadata */} + + {'identity'.padEnd(10)} + {selectedGroup.identity.confidence} + {selectedGroup.identity.reasons.join(', ')} + + + {'providers'.padEnd(10)} + {selectedGroup.instances.map((instance) => ( + `${instance.provider}:${instance.enabled ? 'enabled' : 'disabled'}` + )).join(' ')} + + {selectedGroup.instances.length > 1 && ( + + {'selected'.padEnd(10)} + {selectedInstanceIndex + 1}/{selectedGroup.instances.length} + use left/right to switch provider instance + + )} {'agent'.padEnd(10)} {selectedSkill.provider} @@ -500,13 +546,13 @@ export function DetailView() { - {/* Duplicates */} - {duplicate && ( + {/* Health Signals */} + {hasWarnings && ( - {glyphs.warning} Duplicates - {duplicate.instances.map((inst) => ( - - {' '}{inst.provider} → {inst.path} + {glyphs.warning} Health Signals + {[...groupHealthSignals, ...instanceHealthSignals].map((signal, index) => ( + + {' '}{signal.code}: {signal.message} ))} diff --git a/packages/tui/src/views/list-view.tsx b/packages/tui/src/views/list-view.tsx index d671a48..065c95a 100644 --- a/packages/tui/src/views/list-view.tsx +++ b/packages/tui/src/views/list-view.tsx @@ -8,23 +8,19 @@ import { TabBar } from '../components/tab-bar.js'; import { SkillRow } from '../components/skill-row.js'; import { SearchInput } from '../components/search-input.js'; import { StatusBar } from '../components/status-bar.js'; -import { ConfirmDialog } from '../components/confirm-dialog.js'; -import { formatPluginToggleMessage, isPluginOwnedSkill } from '../lib/plugin-toggle.js'; import { fitCell, getGlyphSet, getInventoryLayout } from '../lib/responsive-layout.js'; -import type { Skill } from '@skillpack/core'; export function ListView() { const { exit } = useApp(); const { - loading, activeTab, setActiveTab, setView, setSelectedSkill, - searchQuery, setSearchQuery, refresh, manager, skills: allSkills, + loading, activeTab, setActiveTab, setView, setSelectedGroup, setSelectedSkill, + searchQuery, setSearchQuery, refresh, inventory, } = useAppContext(); const { skills, tabs } = useFilteredSkills(); const { columns, rows } = useTerminalSize(); const [cursor, setCursor] = useState(0); const [scrollOffset, setScrollOffset] = useState(0); const [searching, setSearching] = useState(false); - const [confirmingPluginToggle, setConfirmingPluginToggle] = useState(null); const glyphs = getGlyphSet(); const prevSkillsLenRef = useRef(skills.length); @@ -63,19 +59,20 @@ export function ListView() { const tabCounts = useMemo(() => { const counts: Record = {}; - const inventorySkills = allSkills.filter((s) => s.scope !== 'project'); for (const tab of TABS) { if (tab === 'All') { - counts[tab] = inventorySkills.length; + counts[tab] = inventory.length; } else { const providerMap: Record = { Codex: 'codex', Claude: 'claude', Global: 'global', }; - counts[tab] = inventorySkills.filter((s) => s.provider === providerMap[tab]).length; + counts[tab] = inventory.filter((group) => ( + group.instances.some((instance) => instance.provider === providerMap[tab]) + )).length; } } return counts; - }, [allSkills]); + }, [inventory]); useInput((input, key) => { if (input === 'q') { exit(); return; } @@ -84,7 +81,7 @@ export function ListView() { return; } if (key.downArrow) { - setCursor((c) => Math.min(c + 1, skills.length - 1)); + setCursor((c) => Math.min(c + 1, Math.max(0, skills.length - 1))); return; } if (key.upArrow) { @@ -95,21 +92,10 @@ export function ListView() { if (input === 'p') { setView('project'); return; } if (input === 's') { setView('settings'); return; } if (input === 'i') { setView('install'); return; } - - if (input === ' ' && skills[cursor]) { - const selected = skills[cursor]; - const canToggle = Boolean(manager.getProvider(selected.provider)?.getDisableStrategy(selected)); - if (canToggle) { - if (isPluginOwnedSkill(selected)) { - setConfirmingPluginToggle(selected); - return; - } - manager.toggleSkill(selected).then(() => refresh()).catch(() => {}); - } - return; - } + if (input === 'u') { setView('updates'); return; } if (key.return && skills[cursor]) { - setSelectedSkill(skills[cursor]); + setSelectedGroup(skills[cursor]); + setSelectedSkill(skills[cursor].instances[0] ?? null); setView('detail'); return; } @@ -120,29 +106,12 @@ export function ListView() { : (idx + 1) % tabs.length; setActiveTab(tabs[next]); } - }, { isActive: !searching && !confirmingPluginToggle }); + }, { isActive: !searching }); if (loading) { return ; } - if (confirmingPluginToggle) { - return ( - - { - manager.toggleSkill(confirmingPluginToggle) - .then(() => refresh()) - .catch(() => {}) - .finally(() => setConfirmingPluginToggle(null)); - }} - onCancel={() => setConfirmingPluginToggle(null)} - /> - - ); - } - const showScroll = skills.length > visibleRows; const scrollBarHeight = showScroll ? Math.max(1, Math.round(visibleRows * (visibleRows / skills.length))) @@ -193,8 +162,8 @@ export function ListView() { {' '} {fitCell('NAME', layout.columns.name)} - {fitCell('AGENT', layout.columns.provider)} - {fitCell('STATE', layout.columns.status)} + {fitCell('PROVIDERS', layout.columns.provider)} + {fitCell('HEALTH', layout.columns.status)} @@ -224,12 +193,10 @@ export function ListView() { ) : ( visibleSkills.map((skill, index) => ( )) )} diff --git a/packages/tui/src/views/project-skills-view.tsx b/packages/tui/src/views/project-skills-view.tsx index 2e2c4f8..8afca57 100644 --- a/packages/tui/src/views/project-skills-view.tsx +++ b/packages/tui/src/views/project-skills-view.tsx @@ -8,7 +8,7 @@ import { fitCell, getBoundedContentLayout, getGlyphSet, getProjectSkillsColumns export function ProjectSkillsView() { const { exit } = useApp(); - const { projectSkills, setView } = useAppContext(); + const { projectSkills, inventory, setView } = useAppContext(); const { columns, rows } = useTerminalSize(); const [cursor, setCursor] = useState(0); const [scrollOffset, setScrollOffset] = useState(0); @@ -46,6 +46,10 @@ export function ProjectSkillsView() { [projectSkills, scrollOffset, visibleRows], ); const showScroll = projectSkills.length > visibleRows; + const inventoryNames = useMemo( + () => new Set(inventory.map((group) => group.name.toLowerCase())), + [inventory], + ); return ( @@ -62,6 +66,7 @@ export function ProjectSkillsView() { {' '} {fitCell('NAME', tableColumns.name)} + {tableColumns.description > 0 && {fitCell('DESCRIPTION', tableColumns.description)}} {fitCell('PROJECT PATH', tableColumns.path)} {fitCell('STATE', tableColumns.state)} @@ -76,6 +81,8 @@ export function ProjectSkillsView() { visibleSkills.map((skill, index) => { const selected = scrollOffset + index === cursor; const hasIssues = skill.healthSignals.length > 0; + const overlapsInventory = inventoryNames.has(skill.name.toLowerCase()); + const state = hasIssues ? 'needs attention' : overlapsInventory ? 'overlaps' : 'read-only'; return ( @@ -84,9 +91,12 @@ export function ProjectSkillsView() { {fitCell(skill.name, tableColumns.name)} + {tableColumns.description > 0 && ( + {fitCell(skill.description || '-', tableColumns.description)} + )} {fitCell(formatDisplayPath(skill.path), tableColumns.path)} - - {fitCell(hasIssues ? 'needs attention' : 'read-only', tableColumns.state)} + + {fitCell(state, tableColumns.state)} ); diff --git a/packages/tui/src/views/updates-view.tsx b/packages/tui/src/views/updates-view.tsx index 036b16e..507268f 100644 --- a/packages/tui/src/views/updates-view.tsx +++ b/packages/tui/src/views/updates-view.tsx @@ -3,6 +3,7 @@ import { Box, Text, useApp, useInput } from 'ink'; import { Spinner } from '@inkjs/ui'; import { useAppContext } from '../context/app-context.js'; import { StatusBar } from '../components/status-bar.js'; +import { ConfirmDialog } from '../components/confirm-dialog.js'; import { useTerminalSize } from '../hooks/use-terminal-size.js'; import { fitCell, getBoundedContentLayout, getGlyphSet } from '../lib/responsive-layout.js'; import type { Skill, UpdateInfo } from '@skillpack/core'; @@ -22,6 +23,7 @@ export function UpdatesView() { const [cursor, setCursor] = useState(0); const [scrollOffset, setScrollOffset] = useState(0); const [error, setError] = useState(''); + const [confirmingUpdate, setConfirmingUpdate] = useState(false); const glyphs = getGlyphSet(); const { columns, rows } = useTerminalSize(); const layout = getBoundedContentLayout({ @@ -54,6 +56,7 @@ export function UpdatesView() { setCursor(0); setScrollOffset(0); setState('checked'); + await refresh(); } catch (err) { setError(err instanceof Error ? err.message : String(err)); setState('idle'); @@ -78,6 +81,7 @@ export function UpdatesView() { useInput((input, key) => { if (input === 'q') { exit(); return; } if (key.escape) { setView('list'); return; } + if (confirmingUpdate) return; if (state === 'checking' || state === 'updating') return; if (input === 'r') { void runCheck(); @@ -86,8 +90,8 @@ export function UpdatesView() { if (key.return) { if (state === 'idle') { void runCheck(); - } else { - void applySelected(); + } else if (updates[cursor]) { + setConfirmingUpdate(true); } return; } @@ -99,6 +103,22 @@ export function UpdatesView() { } }); + if (confirmingUpdate) { + const selected = updates[cursor]; + return ( + + { + setConfirmingUpdate(false); + void applySelected(); + }} + onCancel={() => setConfirmingUpdate(false)} + /> + + ); + } + return ( diff --git a/packages/tui/tests/responsive-layout.test.ts b/packages/tui/tests/responsive-layout.test.ts index 47c6c41..e5cb681 100644 --- a/packages/tui/tests/responsive-layout.test.ts +++ b/packages/tui/tests/responsive-layout.test.ts @@ -171,8 +171,8 @@ describe('getInventoryLayout', () => { expect(full.visibleRows).toBe(17); expect(full.statusBarVariant).toBe('full'); expect(full.columns.name).toBe(30); - expect(full.columns.provider).toBe(10); - expect(full.columns.status).toBe(8); + expect(full.columns.provider).toBe(24); + expect(full.columns.status).toBe(18); const compact = getInventoryLayout({ size: { columns: 60, rows: 18 }, diff --git a/packages/tui/tests/shortcuts.test.ts b/packages/tui/tests/shortcuts.test.ts index 8befbf0..c95e6a1 100644 --- a/packages/tui/tests/shortcuts.test.ts +++ b/packages/tui/tests/shortcuts.test.ts @@ -1,25 +1,25 @@ import { describe, expect, it } from 'vitest'; import { getShortcutsForView } from '../src/lib/shortcuts.js'; -import type { Skill } from '@skillpack/core'; +import type { SkillInventoryInstance } from '@skillpack/core'; -function skill(provider: string): Skill { +function skill(provider: string): SkillInventoryInstance { return { name: 'demo', description: '', provider, path: `/fake/${provider}/demo`, enabled: true, - scope: 'global', - metadata: {}, source: { type: 'skillssh' }, + actions: provider === 'global' ? ['update', 'remove'] : [], + healthSignals: [], }; } describe('getShortcutsForView', () => { - it('does not show a list update shortcut', () => { + it('shows the manual Updates section shortcut in the list', () => { const shortcuts = getShortcutsForView({ view: 'list', selectedSkill: null, canToggle: false }); - expect(shortcuts.map((shortcut) => shortcut.key)).not.toContain('u'); + expect(shortcuts.map((shortcut) => shortcut.key)).toContain('u'); }); it('shows Detail update shortcut only for skills.sh-managed Global Skills', () => { From 17fdac1fe93cfd4266f7c9e8cf0df34ff7767bed Mon Sep 17 00:00:00 2001 From: WTW0313 Date: Thu, 2 Jul 2026 11:33:30 +0800 Subject: [PATCH 12/15] refactor: Refactor inventory model to instances and findings --- CONTEXT.md | 38 ++++++- docs/adr/0012-group-inventory-by-skill.md | 4 + ...health-signals-to-structural-provenance.md | 4 + docs/adr/0018-defer-llm-advisory-guidance.md | 2 +- ...-use-explain-before-action-detail-views.md | 2 +- ...rve-core-skill-state-in-compact-layouts.md | 2 +- ...ioned-detail-view-for-compact-terminals.md | 2 +- ...e-rows-with-relationship-aware-ordering.md | 5 + ...inventory-notices-from-inventory-issues.md | 5 + docs/prds/provider-native-skillpack.md | 29 +++--- .../provider-native-skillpack-refactor.md | 57 ++++++----- packages/core/src/manager.ts | 3 +- packages/core/src/models/inventory.ts | 61 +++++++----- packages/core/tests/inventory.test.ts | 46 ++++++--- packages/tui/README.md | 6 +- packages/tui/src/components/skill-row.tsx | 42 +++++--- packages/tui/src/context/app-context.tsx | 6 +- packages/tui/src/hooks/use-skills.ts | 61 ++++++++++-- packages/tui/src/lib/responsive-layout.ts | 34 ++++--- packages/tui/src/lib/shortcuts.ts | 2 +- packages/tui/src/views/detail-view.tsx | 98 ++++++++++++------- packages/tui/src/views/list-view.tsx | 21 ++-- .../tui/src/views/project-skills-view.tsx | 4 +- packages/tui/tests/responsive-layout.test.ts | 21 ++-- packages/tui/tests/shortcuts.test.ts | 3 +- 25 files changed, 380 insertions(+), 178 deletions(-) create mode 100644 docs/adr/0036-use-provider-instance-rows-with-relationship-aware-ordering.md create mode 100644 docs/adr/0037-split-inventory-notices-from-inventory-issues.md diff --git a/CONTEXT.md b/CONTEXT.md index b3b2bcd..1071186 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -52,6 +52,10 @@ _Avoid_: Installed skill, Skillpack-created skill The cross-provider view of discovered skills, their provenance, availability, and health. _Avoid_: Skill editor, authoring workspace +**Provider Instance**: +A discovered skill as represented by one Skill Provider, including that provider's availability and provenance for the skill. +_Avoid_: Skill Group row, duplicate + **Terminal Envelope**: The terminal size range the TUI deliberately designs and verifies for: a full baseline layout at 80x24, a compact usable layout down to 60x18, and a minimal too-small layout below that. _Avoid_: Responsive target, screen size @@ -64,18 +68,44 @@ _Avoid_: Pretty terminal rendering, theme support A directory Skillpack inspects to discover provider, shared global, or project skills. _Avoid_: Skill, provider, install source -**Health Signal**: -A deterministic inventory finding that helps the user understand a skill's provider coverage, provenance, or loadability. -_Avoid_: Security score, quality rating +**Inventory Notice**: +A non-problem inventory fact that helps the user understand provider coverage, provenance, or grouping confidence. +_Avoid_: Warning, issue + +**Inventory Issue**: +A deterministic inventory finding that means a Provider Instance likely needs attention or a user decision. +_Avoid_: Notice, security score, quality rating + +Inventory Notices include update opportunities, unmanaged local provenance, and weak name-only relationships. Inventory Issues are reserved for findings that directly affect reliability or safe operation, such as invalid `SKILL.md` files, broken symlinks, and provider state that conflicts with the discovered filesystem state. + +**Update Opportunity**: +A known newer version for a managed Global Skill. It is useful action context, not evidence that the current skill is broken. +_Avoid_: Inventory Issue, stale skill warning **Skill Group**: -The inventory row that collects provider-specific instances believed to represent the same skill. +A relationship object that collects Provider Instances believed to represent the same skill. _Avoid_: Duplicate, provider row +**Inventory Row**: +The primary row in Skill Inventory. It should represent the object the user can inspect or act on directly. +_Avoid_: Abstract grouping, hidden provider selection + +**Relationship-Aware Ordering**: +An inventory ordering model where Provider Instance rows remain individually actionable but related instances are kept near each other when possible. +_Avoid_: Provider partitioning, abstract group row + +**Related Providers**: +The main-list hint that a Provider Instance has confirmed same-skill relationships in other providers. It should only be based on strong provenance evidence and should not include name-only matches. +_Avoid_: Duplicate badge, weak match count + **Skill Identity**: The evidence Skillpack uses to decide which provider-specific instances belong to the same Skill Group. _Avoid_: Display name, directory name +**Name-Only Relationship**: +A weak relationship between Provider Instances based only on normalized skill names, without path or source provenance confirming they are the same skill. +_Avoid_: Confirmed relationship, duplicate + **Disable Strategy**: The provider-specific mechanism Skillpack uses to make a skill unavailable to an agent. _Avoid_: Universal toggle, hidden directory rule diff --git a/docs/adr/0012-group-inventory-by-skill.md b/docs/adr/0012-group-inventory-by-skill.md index e303cca..076d8c5 100644 --- a/docs/adr/0012-group-inventory-by-skill.md +++ b/docs/adr/0012-group-inventory-by-skill.md @@ -1,3 +1,7 @@ +--- +status: superseded by ADR-0036 +--- + # Group inventory by Skill Skillpack's primary inventory view will group provider-specific instances by normalized Skill identity rather than rendering each instance as a separate top-level row. This makes cross-provider coverage, duplication, provenance, and drift visible first, while detailed instance-level actions remain available inside the Skill Group detail view. diff --git a/docs/adr/0014-limit-v1-health-signals-to-structural-provenance.md b/docs/adr/0014-limit-v1-health-signals-to-structural-provenance.md index f853a84..376a28c 100644 --- a/docs/adr/0014-limit-v1-health-signals-to-structural-provenance.md +++ b/docs/adr/0014-limit-v1-health-signals-to-structural-provenance.md @@ -1,3 +1,7 @@ +--- +status: superseded by ADR-0037 +--- + # Limit v1 Health Signals to structure and provenance Skillpack v1 will surface deterministic Health Signals: provider coverage, inferred versus confirmed Skill Identity, enabled or disabled state, missing or invalid `SKILL.md`, broken symlinks, available skills.sh updates, and unmanaged Global Skills. Security or risk scoring is out of scope for v1 because it requires a separate threat model and would be easy to overstate. diff --git a/docs/adr/0018-defer-llm-advisory-guidance.md b/docs/adr/0018-defer-llm-advisory-guidance.md index b318e68..4ef8066 100644 --- a/docs/adr/0018-defer-llm-advisory-guidance.md +++ b/docs/adr/0018-defer-llm-advisory-guidance.md @@ -1,3 +1,3 @@ # Defer LLM advisory guidance -Skillpack v1 will show deterministic inventory facts and Health Signals only, not LLM-generated best-practice recommendations. Advisory workflows such as overlap analysis, content quality review, and risk suggestions are deferred until the inventory model is stable enough for users to distinguish facts from recommendations. +Skillpack v1 will show deterministic inventory facts, Inventory Issues, and Inventory Notices only, not LLM-generated best-practice recommendations. Advisory workflows such as overlap analysis, content quality review, and risk suggestions are deferred until the inventory model is stable enough for users to distinguish facts from recommendations. diff --git a/docs/adr/0021-use-explain-before-action-detail-views.md b/docs/adr/0021-use-explain-before-action-detail-views.md index 614bb2d..8cd1677 100644 --- a/docs/adr/0021-use-explain-before-action-detail-views.md +++ b/docs/adr/0021-use-explain-before-action-detail-views.md @@ -1,3 +1,3 @@ # Use explain-before-action detail views -Skillpack detail views will show summary, Skill Identity confidence, provider instances, paths and resolved paths, Disable Strategy, Health Signals, and skills.sh metadata before exposing valid actions. This keeps lifecycle operations grounded in visible provider-native state instead of blind toggles. +Skillpack detail views will show summary, Skill Identity confidence, provider instances, paths and resolved paths, Disable Strategy, Inventory Issues, Inventory Notices, and skills.sh metadata before exposing valid actions. This keeps lifecycle operations grounded in visible provider-native state instead of blind toggles. diff --git a/docs/adr/0025-preserve-core-skill-state-in-compact-layouts.md b/docs/adr/0025-preserve-core-skill-state-in-compact-layouts.md index 4f79aca..fcd2b33 100644 --- a/docs/adr/0025-preserve-core-skill-state-in-compact-layouts.md +++ b/docs/adr/0025-preserve-core-skill-state-in-compact-layouts.md @@ -1,3 +1,3 @@ # Preserve core Skill state in compact layouts -When the TUI enters compact layouts, Skillpack will preserve the selected Skill name, Skill Provider, Skill Availability, and warning or update state before secondary metadata. Counts, long paths, descriptions, and some shortcut labels may be shortened or hidden first so narrow terminals still answer what the user is focused on, where it applies, and what action is safe. +When the TUI enters compact layouts, Skillpack will preserve the selected Skill name, Skill Provider, Skill Availability, and Inventory Issue state before secondary metadata. Counts, long paths, descriptions, notices, and some shortcut labels may be shortened or hidden first so narrow terminals still answer what the user is focused on, where it applies, and what action is safe. diff --git a/docs/adr/0028-use-sectioned-detail-view-for-compact-terminals.md b/docs/adr/0028-use-sectioned-detail-view-for-compact-terminals.md index 5290592..75d1862 100644 --- a/docs/adr/0028-use-sectioned-detail-view-for-compact-terminals.md +++ b/docs/adr/0028-use-sectioned-detail-view-for-compact-terminals.md @@ -1,3 +1,3 @@ # Use sectioned Detail view for compact terminals -Skillpack Detail views will keep summary and provider-native state visible first, then expose Paths, Source, Description, Actions, and Warnings as sections when terminal space is constrained. At the full 80x24 Terminal Envelope baseline the view may read as one page when content fits, but compact layouts should prevent long paths or descriptions from crowding out Skill Availability and action state. The Description content itself should wrap into readable visual lines and scroll within its bounded region instead of truncating long prose to one ellipsized row. +Skillpack Detail views will keep summary and provider-native state visible first, then expose Paths, Source, Description, Findings, and Actions as sections when terminal space is constrained. Findings contain Inventory Issues and Inventory Notices without treating non-problem notices as warnings. At the full 80x24 Terminal Envelope baseline the view may read as one page when content fits, but compact layouts should prevent long paths or descriptions from crowding out Skill Availability and action state. The Description content itself should wrap into readable visual lines and scroll within its bounded region instead of truncating long prose to one ellipsized row. diff --git a/docs/adr/0036-use-provider-instance-rows-with-relationship-aware-ordering.md b/docs/adr/0036-use-provider-instance-rows-with-relationship-aware-ordering.md new file mode 100644 index 0000000..d5dfcb9 --- /dev/null +++ b/docs/adr/0036-use-provider-instance-rows-with-relationship-aware-ordering.md @@ -0,0 +1,5 @@ +# Use Provider Instance rows with relationship-aware ordering + +Skillpack's primary inventory view will render Provider Instances as the actionable top-level rows, superseding ADR-0012's Skill Group row model. Skill Groups remain useful relationship objects for detail views and ordering, but the main list should not hide provider-specific availability, provenance, or actions behind an abstract group row. + +Provider Instance rows in the All view should use relationship-aware ordering: related instances are clustered near each other when possible, while provider tabs still filter to the provider-specific inventory. The main list may show a lightweight Related Providers hint for confirmed relationships only. Weak name-only relationships are detail-level context, not main-list grouping evidence. diff --git a/docs/adr/0037-split-inventory-notices-from-inventory-issues.md b/docs/adr/0037-split-inventory-notices-from-inventory-issues.md new file mode 100644 index 0000000..8b6a573 --- /dev/null +++ b/docs/adr/0037-split-inventory-notices-from-inventory-issues.md @@ -0,0 +1,5 @@ +# Split Inventory Notices from Inventory Issues + +Skillpack will replace the broad Health Signal bucket with Inventory Notices and Inventory Issues. Notices are non-problem context such as update opportunities, unmanaged local provenance, and weak name-only relationships; Issues are deterministic findings that directly affect safe operation or require attention, such as invalid `SKILL.md` files, broken symlinks, or provider state that conflicts with the filesystem. + +This split prevents the main inventory from warning on normal skill states while still making genuinely actionable problems visible without requiring the user to open every detail view. diff --git a/docs/prds/provider-native-skillpack.md b/docs/prds/provider-native-skillpack.md index 13adba0..39d4a11 100644 --- a/docs/prds/provider-native-skillpack.md +++ b/docs/prds/provider-native-skillpack.md @@ -10,17 +10,17 @@ Users need a trustworthy TUI that first explains their Skill Inventory, then exp Refactor Skillpack into an understanding-first, provider-native management console. -Skillpack will scan each Skill Provider according to that provider's own rules, group provider instances into Skill Groups using provenance-aware Skill Identity, and surface deterministic Health Signals. It will not edit or create skills. It will install, update, and remove only Global Skills managed by skills.sh. Codex and Claude provider-local skills can be scanned and enabled/disabled, but not removed, updated, edited, or created. Project Skills are shown in a separate read-only view because they are maintained by repository authors through git. +Skillpack will scan each Skill Provider according to that provider's own rules, render provider instances as the primary Inventory rows, use Skill Groups as relationship context, and surface deterministic Inventory Issues separately from lower-priority Inventory Notices. It will not edit or create skills. It will install, update, and remove only Global Skills managed by skills.sh. Codex and Claude provider-local skills can be scanned and enabled/disabled, but not removed, updated, edited, or created. Project Skills are shown in a separate read-only view because they are maintained by repository authors through git. ## User Stories 1. As a Skillpack user, I want to open the TUI and immediately see my cross-provider Skill Inventory, so that I understand what skills exist before changing anything. -2. As a Skillpack user, I want related provider instances grouped into one Skill Group, so that duplicate or shared skills are obvious. -3. As a Skillpack user, I want each Skill Group to show provider badges, so that I can see whether Codex, Claude, or Global can load it. +2. As a Skillpack user, I want each provider instance to be an actionable Inventory row, so that provider-specific state and actions are not hidden behind a group. +3. As a Skillpack user, I want confirmed related providers shown as a lightweight row hint, so that duplicate or shared skills are obvious without making Skill Groups the primary row. 4. As a Skillpack user, I want enabled and disabled states shown per provider, so that I know which agents can currently load a skill. -5. As a Skillpack user, I want Skill Identity confidence shown, so that I know whether a group is confirmed by provenance or inferred by name. -6. As a Skillpack user, I want Health Signals shown in the inventory, so that broken or stale skills are visible without opening every detail view. -7. As a Skillpack user, I want to open a Skill Group detail view, so that I can inspect each provider instance before acting. +5. As a Skillpack user, I want Skill Identity confidence shown in detail, so that I know whether relationships are confirmed by provenance or inferred by name. +6. As a Skillpack user, I want Inventory Issues shown in the main list, so that broken skills are visible without opening every detail view. +7. As a Skillpack user, I want to open a provider instance detail view, so that I can inspect its relationship context before acting. 8. As a Skillpack user, I want detail views to show paths and resolved paths, so that symlinked or shared skills are understandable. 9. As a Skillpack user, I want detail views to show skills.sh metadata when available, so that Global Skill provenance is clear. 10. As a Skillpack user, I want actions to appear only when valid for the selected provider instance, so that I do not accidentally perform unsupported operations. @@ -36,9 +36,9 @@ Skillpack will scan each Skill Provider according to that provider's own rules, 20. As a Skillpack user, I want to manually check for updates, so that opening the TUI is fast and does not depend on network calls. 21. As a Skillpack user, I want the Updates section to show "not checked" before I run a check, so that update freshness is not implied. 22. As a Skillpack user, I want to update skills.sh-managed Global Skills after a manual check, so that I control when remote changes are applied. -23. As a Skillpack user, I want unmanaged Global Skills called out, so that I know which Global Skills lack skills.sh lock metadata. -24. As a Skillpack user, I want invalid `SKILL.md` files surfaced as Health Signals, so that broken skills are visible. -25. As a Skillpack user, I want broken symlinks surfaced as Health Signals, so that filesystem problems are visible. +23. As a Skillpack user, I want unmanaged Global Skills shown as notices, so that I know which Global Skills lack skills.sh lock metadata without treating that as a warning. +24. As a Skillpack user, I want invalid `SKILL.md` files surfaced as Inventory Issues, so that broken skills are visible. +25. As a Skillpack user, I want broken symlinks surfaced as Inventory Issues, so that filesystem problems are visible. 26. As a Skillpack user, I want provider paths auto-detected, so that Skillpack works without setup for common installations. 27. As a Skillpack user, I want provider paths configurable, so that custom installations can still be scanned. 28. As a Skillpack user, I want Scan Roots shown in a separate Settings section, so that Inventory stays focused on skills. @@ -56,10 +56,11 @@ Skillpack will scan each Skill Provider according to that provider's own rules, - Skillpack is a provider-native management console, not the canonical owner of skill content. - The primary TUI experience is the Skill Inventory. - The top-level TUI sections are Inventory, Project Skills, Settings, Install, and Updates. -- Inventory rows are Skill Groups, not provider-instance rows. -- Skill Groups are built from provenance-aware Skill Identity. -- Normalized-name grouping is a fallback and should be marked as inferred. -- Health Signals are deterministic only in v1. +- Inventory rows are Provider Instances, not Skill Groups. +- Skill Groups are relationship objects built from provenance-aware Skill Identity. +- The All view uses relationship-aware ordering so related Provider Instance rows stay near each other. +- Normalized-name relationships are weak and should be shown only in detail. +- Inventory Issues are deterministic only in v1; non-problem context is shown as Inventory Notices. - LLM-generated advisory guidance is deferred. - Project Skills are read-only and shown in a separate section. - Editing and creating skills are out of scope. @@ -76,7 +77,7 @@ Skillpack will scan each Skill Provider according to that provider's own rules, ## Testing Decisions -- The highest-value testing seam is the core inventory derivation API: provider instances in, Skill Groups, Project Skills, actions, and Health Signals out. +- The highest-value testing seam is the core inventory derivation API: provider instances in, Skill Groups, Project Skills, actions, Inventory Issues, and Inventory Notices out. - Provider tests should cover scan behavior, enabled/disabled detection, symlink handling, invalid skills, and disable strategies. - Manager-level tests should verify action availability and lifecycle routing, not implementation details. - skills.sh source tests should mock CLI behavior and verify install/update/remove/check semantics. diff --git a/docs/specs/provider-native-skillpack-refactor.md b/docs/specs/provider-native-skillpack-refactor.md index 24a1dd4..92aee8b 100644 --- a/docs/specs/provider-native-skillpack-refactor.md +++ b/docs/specs/provider-native-skillpack-refactor.md @@ -4,13 +4,13 @@ Refactor Skillpack into an understanding-first TUI for managing agent skills across Codex, Claude, Global skills from skills.sh, and read-only Project Skills. -Skillpack should not become the canonical owner of skill content. Each Skill Provider keeps its provider-native state. Skillpack scans that state, groups related instances into a Skill Inventory, exposes deterministic Health Signals, and allows only the lifecycle actions that are safe for each provider. +Skillpack should not become the canonical owner of skill content. Each Skill Provider keeps its provider-native state. Skillpack scans that state, renders provider instances as the actionable Skill Inventory rows, uses Skill Groups as relationship context, exposes deterministic Inventory Issues separately from Inventory Notices, and allows only the lifecycle actions that are safe for each provider. ## Product Shape The TUI has five top-level sections: -- **Inventory**: grouped view of provider-native skills from Codex, Claude, and Global. +- **Inventory**: provider-instance list for Codex, Claude, and Global, ordered with relationship context. - **Project Skills**: read-only inventory of skills stored in the current project repository. - **Settings**: read-only configuration diagnostics, including Scan Roots, providers, and sources. - **Install**: skills.sh search and install into Global Skills only. @@ -21,8 +21,10 @@ The main experience is Inventory. It should answer: - Which skills exist? - Which providers can load them? - Are they enabled or disabled? -- Are grouped instances confirmed by provenance or inferred by name? -- Are any provider instances broken, unmanaged, duplicated, or stale? +- Which provider instances have confirmed related providers? +- Which relationships are only weak name matches? +- Are any provider instances broken? +- Which provider instances have non-problem notices such as unmanaged provenance or available updates? ## Scope @@ -30,8 +32,9 @@ In scope: - Scan Codex, Claude, Global, and Project Skill locations using provider-specific rules. - Scan Codex provider-local skills from `~/.codex/skills` and active-looking Codex plugin skills from `~/.codex/plugins/cache` by default. Stale or duplicate cached plugin copies should not appear in the main inventory by default. -- Build Skill Groups from provider instances using provenance-aware Skill Identity. -- Show provider badges, Health Signals, and provenance summaries in the main Inventory. +- Build Skill Groups from provider instances using provenance-aware Skill Identity as relationship context. +- Show provider, availability state, confirmed related providers, and Inventory Issues in the main Inventory. +- Show weak name-only relationships and Inventory Notices in detail views. - Show Project Skills in a separate read-only view. - Show Scan Roots in a separate read-only Settings view instead of inline in Inventory. - Enable or disable Codex and Claude provider instances through provider-specific Disable Strategies. @@ -76,7 +79,8 @@ A discovered skill in one provider. It includes: - parsed SKILL.md metadata when valid - enabled/disabled state - source/provenance metadata -- Health Signals +- Inventory Issues +- Inventory Notices - supported actions ### Scan Root @@ -101,27 +105,32 @@ Settings should not show editable JSON, raw config file contents, or provider-na ### Skill Group -The primary Inventory row. A Skill Group contains provider instances that likely represent the same Skill. +A relationship object that contains provider instances believed to represent the same Skill. Skill Groups support detail context and relationship-aware ordering, but they are not the primary Inventory row. Grouping confidence should be explicit: - **confirmed**: shared real path, skills.sh lock metadata, or known source identity ties instances together. - **inferred**: normalized names match, but provenance is unavailable. -### Health Signal +### Inventory Issue -V1 Health Signals are deterministic: +V1 Inventory Issues are deterministic findings that affect safe operation or likely require attention: -- grouped across multiple providers -- inferred identity -- enabled or disabled per provider - missing or invalid `SKILL.md` - broken symlink -- skills.sh update available -- unmanaged Global Skill without skills.sh lock metadata +- provider/plugin identity mismatch Security/risk scoring is out of scope. +### Inventory Notice + +Inventory Notices are non-problem context. They should not render as warnings in the main list. + +- confirmed related providers +- weak name-only relationships +- skills.sh update available +- unmanaged Global Skill without skills.sh lock metadata + ### Disable Strategy Each provider adapter owns enable/disable behavior. A strategy should prefer known provider-native config mechanisms. Provider config determines Skill Availability when a provider has a known config file or native disable mechanism. `.disabled-` directory renaming is a fallback only when a scanned location has no provider-specific mechanism. @@ -159,14 +168,15 @@ A missing Codex plugin config entry means the active-looking plugin root is enab ### Inventory -Rows are Skill Groups, not individual provider instances. +Rows are Provider Instances. Skill Groups remain relationship context for ordering and detail views. Recommended columns: - name -- provider badges with enabled/disabled state -- Health Signals -- source/provenance summary +- provider +- enabled/disabled state +- confirmed related providers +- Inventory Issue Provider filters can exist inside Inventory: @@ -186,7 +196,8 @@ The detail view uses an explain-before-action pattern: - path and resolved path - enabled/disabled state - Disable Strategy -- Health Signals +- Inventory Issues +- Inventory Notices - skills.sh metadata when present - actions valid for the selected provider instance @@ -200,7 +211,7 @@ Project Skills appear in a separate read-only section. They should show: - project-relative path - description - validity of `SKILL.md` -- whether the skill name overlaps with non-project Skill Groups +- whether the skill name overlaps with non-project Provider Instances No mutation actions are available. @@ -229,7 +240,7 @@ Updates are manual: The current core model is provider-centric and action-heavy. The refactor should reshape it around inventory facts: -- Replace duplicate-only grouping with Skill Group construction. +- Replace duplicate-only grouping with relationship-aware Skill Group construction. - Replace provider capabilities with action availability per provider instance. - Remove create/edit APIs from the manager and TUI. - Remove GitHub install source from v1 registration and UI. @@ -247,6 +258,6 @@ Use core tests as the primary seam. The most valuable external behavior tests ar - Project Skills are excluded from controllable Inventory and appear in read-only Project Skills output - skills.sh-managed Global Skills expose install/update/remove actions - provider-local Codex/Claude skills do not expose remove/update/install/edit/create actions -- invalid skills and broken symlinks produce Health Signals instead of silently disappearing +- invalid skills and broken symlinks produce Inventory Issues instead of silently disappearing TUI tests do not exist yet. V1 can keep TUI verification manual unless a test harness is introduced, but the core should expose enough derived state that the TUI remains thin. diff --git a/packages/core/src/manager.ts b/packages/core/src/manager.ts index eb1ae4d..66f7803 100644 --- a/packages/core/src/manager.ts +++ b/packages/core/src/manager.ts @@ -142,10 +142,11 @@ export class SkillManager { origin: skill.origin, source: skill.source, actions: [], - healthSignals: (skill.scanIssues ?? []).map((issue) => ({ + issues: (skill.scanIssues ?? []).map((issue) => ({ code: issue.code, message: issue.message, })), + notices: [], })); } getSkillsByProvider(providerId: string): Skill[] { return this.skills.filter((s) => s.provider === providerId); } diff --git a/packages/core/src/models/inventory.ts b/packages/core/src/models/inventory.ts index f6dd8dc..12c2946 100644 --- a/packages/core/src/models/inventory.ts +++ b/packages/core/src/models/inventory.ts @@ -2,17 +2,24 @@ import type { Skill, SkillSource } from './skill.js'; export type SkillIdentityConfidence = 'confirmed' | 'inferred'; -export type HealthSignalCode = - | 'grouped-across-providers' - | 'inferred-identity' +export type InventoryIssueCode = | 'invalid-skill-md' | 'broken-symlink' - | 'plugin-identity-mismatch' + | 'plugin-identity-mismatch'; + +export type InventoryNoticeCode = + | 'related-providers' + | 'name-only-relationship' | 'unmanaged-global-skill' | 'update-available'; -export interface HealthSignal { - code: HealthSignalCode; +export interface InventoryIssue { + code: InventoryIssueCode; + message: string; +} + +export interface InventoryNotice { + code: InventoryNoticeCode; message: string; } @@ -35,7 +42,8 @@ export interface SkillInventoryInstance { source?: SkillSource; disableStrategy?: DisableStrategy; actions: SkillAction[]; - healthSignals: HealthSignal[]; + issues: InventoryIssue[]; + notices: InventoryNotice[]; } export interface SkillGroup { @@ -50,7 +58,8 @@ export interface SkillGroup { enabled: boolean; }>; instances: SkillInventoryInstance[]; - healthSignals: HealthSignal[]; + issues: InventoryIssue[]; + notices: InventoryNotice[]; } export function normalizeSkillName(name: string): string { @@ -91,18 +100,22 @@ function actionsFor(skill: Skill, disableStrategy: DisableStrategy | undefined): return disableStrategy ? [toggleAction] : []; } -function healthSignalsFor(skill: Skill, options: BuildSkillInventoryOptions): HealthSignal[] { - const signals: HealthSignal[] = (skill.scanIssues ?? []).map((issue) => ({ +function issuesFor(skill: Skill): InventoryIssue[] { + return (skill.scanIssues ?? []).map((issue) => ({ code: issue.code, message: issue.message, })); +} + +function noticesFor(skill: Skill, options: BuildSkillInventoryOptions): InventoryNotice[] { + const notices: InventoryNotice[] = []; if (skill.provider === 'global' && skill.source?.type !== 'skillssh') { - signals.push({ code: 'unmanaged-global-skill', message: 'Global Skill is not managed by skills.sh metadata' }); + notices.push({ code: 'unmanaged-global-skill', message: 'Global Skill is not managed by skills.sh metadata' }); } if (options.hasUpdate?.(skill)) { - signals.push({ code: 'update-available', message: 'skills.sh update is available' }); + notices.push({ code: 'update-available', message: 'skills.sh update is available' }); } - return signals; + return notices; } export interface BuildSkillInventoryOptions { @@ -124,7 +137,8 @@ function toInstance(skill: Skill, options: BuildSkillInventoryOptions): SkillInv source: skill.source, disableStrategy, actions: actionsFor(skill, disableStrategy), - healthSignals: healthSignalsFor(skill, options), + issues: issuesFor(skill), + notices: noticesFor(skill, options), }; } @@ -167,17 +181,13 @@ export function buildSkillInventory(skills: Skill[], options: BuildSkillInventor return [...groups.entries()].map(([id, group]) => { const instances = group.skills.map((skill) => toInstance(skill, options)); - const healthSignals = instances.flatMap((instance) => instance.healthSignals); + const notices: InventoryNotice[] = []; if (instances.length > 1) { - healthSignals.push({ - code: 'grouped-across-providers', - message: 'Skill appears in multiple providers', - }); - } - if (group.identity.confidence === 'inferred') { - healthSignals.push({ - code: 'inferred-identity', - message: 'Skill identity is inferred from normalized name', + notices.push({ + code: group.identity.confidence === 'confirmed' ? 'related-providers' : 'name-only-relationship', + message: group.identity.confidence === 'confirmed' + ? 'Skill has confirmed related provider instances' + : 'Skill has same-name provider instances without confirming provenance', }); } @@ -193,7 +203,8 @@ export function buildSkillInventory(skills: Skill[], options: BuildSkillInventor enabled: instance.enabled, })), instances, - healthSignals, + issues: instances.flatMap((instance) => instance.issues), + notices, }; }); } diff --git a/packages/core/tests/inventory.test.ts b/packages/core/tests/inventory.test.ts index 24a6dfd..f7ce5a3 100644 --- a/packages/core/tests/inventory.test.ts +++ b/packages/core/tests/inventory.test.ts @@ -44,9 +44,30 @@ describe('Skill Inventory', () => { expect(inventory[0].name).toBe('figma'); expect(inventory[0].identity.confidence).toBe('confirmed'); expect(inventory[0].instances.map((instance) => instance.provider).sort()).toEqual(['codex', 'global']); + expect(inventory[0].notices.map((notice) => notice.code)).toContain('related-providers'); }); - it('groups provider instances by normalized name as inferred when provenance is unavailable', async () => { + it('does not create findings for a single local skill with name-only identity', () => { + const inventory = buildSkillInventory([{ + name: 'local-codex', + description: '', + provider: 'codex', + path: path.join(root, 'codex', 'local-codex'), + enabled: true, + scope: 'global', + metadata: {}, + source: { type: 'local' }, + }]); + + expect(inventory).toHaveLength(1); + expect(inventory[0].identity.confidence).toBe('inferred'); + expect(inventory[0].issues).toEqual([]); + expect(inventory[0].notices).toEqual([]); + expect(inventory[0].instances[0].issues).toEqual([]); + expect(inventory[0].instances[0].notices).toEqual([]); + }); + + it('keeps same-name provider instances as inferred detail relationships when provenance is unavailable', async () => { const codexDir = path.join(root, 'codex'); const globalDir = path.join(root, 'global'); await writeSkill(path.join(codexDir, 'figma-tool'), 'figma-tool'); @@ -61,7 +82,8 @@ describe('Skill Inventory', () => { const inventory = manager.getInventory(); expect(inventory).toHaveLength(1); expect(inventory[0].identity.confidence).toBe('inferred'); - expect(inventory[0].healthSignals.map((signal) => signal.code)).toContain('inferred-identity'); + expect(inventory[0].notices.map((notice) => notice.code)).toContain('name-only-relationship'); + expect(inventory[0].issues).toEqual([]); }); it('keeps project skills out of controllable inventory without shadowing provider skills', async () => { @@ -83,7 +105,7 @@ describe('Skill Inventory', () => { expect(manager.getProjectSkills()[0].actions).toEqual([]); }); - it('surfaces invalid SKILL.md files as health signals', async () => { + it('surfaces invalid SKILL.md files as inventory issues', async () => { const codexDir = path.join(root, 'codex'); const brokenSkill = path.join(codexDir, 'broken'); await mkdir(brokenSkill, { recursive: true }); @@ -97,10 +119,10 @@ describe('Skill Inventory', () => { const inventory = manager.getInventory(); expect(inventory).toHaveLength(1); expect(inventory[0].name).toBe('broken'); - expect(inventory[0].healthSignals.map((signal) => signal.code)).toContain('invalid-skill-md'); + expect(inventory[0].instances[0].issues.map((issue) => issue.code)).toContain('invalid-skill-md'); }); - it('surfaces broken skill symlinks as health signals', async () => { + it('surfaces broken skill symlinks as inventory issues', async () => { const codexDir = path.join(root, 'codex'); await mkdir(codexDir); await symlink(path.join(root, 'missing-skill'), path.join(codexDir, 'missing-skill')); @@ -113,10 +135,10 @@ describe('Skill Inventory', () => { const inventory = manager.getInventory(); expect(inventory).toHaveLength(1); expect(inventory[0].name).toBe('missing-skill'); - expect(inventory[0].healthSignals.map((signal) => signal.code)).toContain('broken-symlink'); + expect(inventory[0].instances[0].issues.map((issue) => issue.code)).toContain('broken-symlink'); }); - it('exposes provider-safe actions and flags unmanaged Global Skills', async () => { + it('exposes provider-safe actions and records unmanaged Global Skills as notices', async () => { const codexDir = path.join(root, 'codex'); const globalDir = path.join(root, 'global'); await writeSkill(path.join(codexDir, 'local-codex'), 'local-codex'); @@ -134,7 +156,8 @@ describe('Skill Inventory', () => { expect(codex.instances[0].actions).toEqual(['disable']); expect(global.instances[0].actions).toEqual([]); - expect(global.healthSignals.map((signal) => signal.code)).toContain('unmanaged-global-skill'); + expect(global.instances[0].notices.map((notice) => notice.code)).toContain('unmanaged-global-skill'); + expect(global.instances[0].issues).toEqual([]); }); it('does not expose enable or disable actions for skills.sh-managed Global Skills', () => { @@ -179,7 +202,7 @@ describe('Skill Inventory', () => { expect(inventory[0].instances[0].actions).toEqual([]); }); - it('surfaces remembered skills.sh update checks as health signals', () => { + it('surfaces remembered skills.sh update checks as notices', () => { const inventory = buildSkillInventory([{ name: 'managed-global', description: '', @@ -193,7 +216,8 @@ describe('Skill Inventory', () => { hasUpdate: () => true, }); - expect(inventory[0].healthSignals.map((signal) => signal.code)).toContain('update-available'); + expect(inventory[0].instances[0].notices.map((notice) => notice.code)).toContain('update-available'); + expect(inventory[0].issues).toEqual([]); }); it('exposes the provider Disable Strategy for mutable inventory instances', async () => { @@ -250,6 +274,6 @@ describe('Skill Inventory', () => { expect(projectSkills).toHaveLength(1); expect(projectSkills[0].name).toBe('broken-project'); expect(projectSkills[0].actions).toEqual([]); - expect(projectSkills[0].healthSignals.map((signal) => signal.code)).toContain('invalid-skill-md'); + expect(projectSkills[0].issues.map((issue) => issue.code)).toContain('invalid-skill-md'); }); }); diff --git a/packages/tui/README.md b/packages/tui/README.md index 9b3ba8a..09d3db7 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -28,9 +28,9 @@ npx skillpack-tui ## Quick Start -Launch `skillpack` to see discovered skills grouped into Skill Groups with provider status and Health Signals. Use the arrow keys to navigate, `Tab` / `Shift+Tab` to filter by provider, and `/` to search. +Launch `skillpack` to see discovered provider instances with availability state, confirmed related providers, and Inventory Issues. Use the arrow keys to navigate, `Tab` / `Shift+Tab` to filter by provider, and `/` to search. -Press `Enter` to inspect a Skill Group, then use left/right to choose a provider instance before taking instance-level actions. Press `p` to inspect read-only Project Skills, `s` to inspect Settings and Scan Roots, `i` to install a Global Skill, or `u` to open manual updates. Plugin-owned skills ask for confirmation because the toggle affects every skill from the owning plugin. +Press `Enter` to inspect a provider instance, then use left/right to switch related provider instances when relationship context exists. Press `p` to inspect read-only Project Skills, `s` to inspect Settings and Scan Roots, `i` to install a Global Skill, or `u` to open manual updates. Plugin-owned skills ask for confirmation because the toggle affects every skill from the owning plugin. ## Keyboard Shortcuts @@ -39,7 +39,7 @@ Press `Enter` to inspect a Skill Group, then use left/right to choose a provider | Key | Action | | --- | --- | | `↑` / `↓` | Navigate skills | -| `Enter` | Open Skill Group detail view | +| `Enter` | Open detail view | | `Tab` / `Shift+Tab` | Switch provider tab | | `/` | Search | | `Esc` | Clear search | diff --git a/packages/tui/src/components/skill-row.tsx b/packages/tui/src/components/skill-row.tsx index be349d2..518acc5 100644 --- a/packages/tui/src/components/skill-row.tsx +++ b/packages/tui/src/components/skill-row.tsx @@ -1,12 +1,17 @@ import { Box, Text } from 'ink'; -import type { SkillGroup } from '@skillpack/core'; -import { fitCell, getGlyphSet, type InventoryLayout } from '../lib/responsive-layout.js'; +import type { InventoryListRow } from '../hooks/use-skills.js'; +import { + fitCell, + formatInventoryStatus, + getGlyphSet, + type InventoryLayout, +} from '../lib/responsive-layout.js'; export const COL_NAME_WIDTH = 30; export const COL_AGENT_WIDTH = 10; interface SkillRowProps { - skill: SkillGroup; + skill: InventoryListRow; isSelected: boolean; columns?: InventoryLayout['columns']; } @@ -14,16 +19,17 @@ interface SkillRowProps { export function SkillRow({ skill, isSelected, - columns = { name: COL_NAME_WIDTH, provider: COL_AGENT_WIDTH, status: 8 }, + columns = { name: COL_NAME_WIDTH, provider: COL_AGENT_WIDTH, state: 8, related: 14, issue: 10 }, }: SkillRowProps) { const glyphs = getGlyphSet(); - const providerLabel = skill.providers - .map((provider) => `${provider.provider}:${provider.enabled ? 'on' : 'off'}`) - .join(' '); - const healthLabel = skill.healthSignals.length === 0 - ? 'ok' - : [...new Set(skill.healthSignals.map((signal) => signal.code.replace(/-/g, ' ')))].join(', '); - const hasWarnings = skill.healthSignals.length > 0; + const stateLabel = formatInventoryStatus(skill.instance.enabled, columns.state <= 3 ? 'compact' : 'full'); + const relatedLabel = skill.relatedProviders.length > 0 + ? skill.relatedProviders.map((provider) => provider.provider).join(' ') + : '-'; + const issueLabel = skill.issues.length > 0 + ? skill.issues[0].code.replace(/-/g, ' ') + : '-'; + const hasIssues = skill.issues.length > 0; return ( @@ -34,13 +40,17 @@ export function SkillRow({ color={isSelected ? 'white' : undefined} bold={isSelected} > - {fitCell(skill.name, columns.name)} + {fitCell(skill.instance.name, columns.name)} - {fitCell(providerLabel, columns.provider)} - - {fitCell(healthLabel, columns.status)} + {fitCell(skill.instance.provider, columns.provider)} + + {stateLabel} - {hasWarnings && {glyphs.warning}} + {fitCell(relatedLabel, columns.related)} + + {fitCell(issueLabel, columns.issue)} + + {hasIssues && {glyphs.warning}} ); } diff --git a/packages/tui/src/context/app-context.tsx b/packages/tui/src/context/app-context.tsx index 8cbf9ba..e9de8c4 100644 --- a/packages/tui/src/context/app-context.tsx +++ b/packages/tui/src/context/app-context.tsx @@ -80,14 +80,16 @@ export function AppProvider({ manager, config, children }: AppProviderProps) { }); setSelectedSkill((prev) => { if (!prev) return null; - const nextGroup = newInventory.find((group) => group.id === selectedGroup?.id); + const nextGroup = newInventory.find((group) => group.instances.some((instance) => ( + instance.provider === prev.provider && instance.path === prev.path + ))); const nextInstance = nextGroup?.instances.find((instance) => ( instance.provider === prev.provider && instance.path === prev.path )); return nextInstance ?? nextGroup?.instances[0] ?? null; }); setLoading(false); - }, [manager, config, selectedGroup?.id]); + }, [manager, config]); return ( = { All: null, @@ -12,18 +12,67 @@ const TAB_PROVIDER_MAP: Record = { export const TABS = Object.keys(TAB_PROVIDER_MAP); -const GROUP_SEARCH_KEYS = ['name', 'identity.reasons', 'instances.description', 'instances.provider', 'healthSignals.message']; +export interface InventoryListRow { + id: string; + group: SkillGroup; + instance: SkillInventoryInstance; + relatedProviders: Array<{ + provider: string; + enabled: boolean; + }>; + issues: InventoryIssue[]; + notices: InventoryNotice[]; +} + +const PROVIDER_ORDER = ['codex', 'claude', 'global']; +const ROW_SEARCH_KEYS = [ + 'instance.name', + 'instance.description', + 'instance.provider', + 'group.identity.reasons', + 'group.notices.message', + 'instance.issues.message', + 'instance.notices.message', +]; + +function providerRank(provider: string): number { + const rank = PROVIDER_ORDER.indexOf(provider); + return rank === -1 ? PROVIDER_ORDER.length : rank; +} + +function rowId(group: SkillGroup, instance: SkillInventoryInstance): string { + return `${group.id}:${instance.provider}:${instance.path}`; +} + +function toRows(inventory: SkillGroup[], provider: string | null): InventoryListRow[] { + const sortedGroups = [...inventory].sort((a, b) => a.name.localeCompare(b.name)); + return sortedGroups.flatMap((group) => { + const instances = [...group.instances] + .filter((instance) => provider === null || instance.provider === provider) + .sort((a, b) => providerRank(a.provider) - providerRank(b.provider) || a.path.localeCompare(b.path)); + + return instances.map((instance) => ({ + id: rowId(group, instance), + group, + instance, + relatedProviders: group.identity.confidence === 'confirmed' + ? group.providers.filter((related) => related.provider !== instance.provider) + : [], + issues: instance.issues, + notices: instance.notices, + })); + }); +} -export function useFilteredSkills(): { skills: SkillGroup[]; tabs: string[] } { +export function useFilteredSkills(): { skills: InventoryListRow[]; tabs: string[] } { const { inventory, activeTab, searchQuery } = useAppContext(); const tabFiltered = useMemo(() => { const provider = TAB_PROVIDER_MAP[activeTab]; - if (provider === null) return inventory; - return inventory.filter((group) => group.instances.some((instance) => instance.provider === provider)); + return toRows(inventory, provider); }, [inventory, activeTab]); - const filtered = useSearch(tabFiltered, searchQuery, GROUP_SEARCH_KEYS); + const filtered = useSearch(tabFiltered, searchQuery, ROW_SEARCH_KEYS); return { skills: filtered, tabs: TABS }; } diff --git a/packages/tui/src/lib/responsive-layout.ts b/packages/tui/src/lib/responsive-layout.ts index ac7a61c..adc0eb0 100644 --- a/packages/tui/src/lib/responsive-layout.ts +++ b/packages/tui/src/lib/responsive-layout.ts @@ -96,7 +96,9 @@ export interface InventoryLayout { columns: { name: number; provider: number; - status: number; + state: number; + related: number; + issue: number; }; } @@ -107,7 +109,7 @@ export interface ShortcutLayoutItem { displayLabel?: string; } -export type DetailSectionId = 'summary' | 'paths' | 'source' | 'description' | 'warnings' | 'actions'; +export type DetailSectionId = 'summary' | 'paths' | 'source' | 'description' | 'findings' | 'actions'; export interface DetailSection { id: DetailSectionId; @@ -117,7 +119,7 @@ export interface DetailSection { export interface DetailLayoutInput { size: TerminalSize; hasDescription: boolean; - hasWarnings: boolean; + hasFindings: boolean; } export interface DetailLayout { @@ -159,12 +161,14 @@ const COMPACT_CHROME_LINES = 5; const HORIZONTAL_PADDING = 2; const MIN_NAME_WIDTH = 12; const MAX_NAME_WIDTH = 30; -const FULL_PROVIDER_WIDTH = 24; -const COMPACT_PROVIDER_WIDTH = 18; -const FULL_STATUS_WIDTH = 18; -const COMPACT_STATUS_WIDTH = 14; +const FULL_PROVIDER_WIDTH = 8; +const COMPACT_PROVIDER_WIDTH = 7; +const FULL_RELATED_WIDTH = 14; +const COMPACT_RELATED_WIDTH = 10; +const FULL_ISSUE_WIDTH = 10; +const COMPACT_ISSUE_WIDTH = 8; const FULL_AVAILABILITY_STATUS_WIDTH = 8; -const COMPACT_AVAILABILITY_STATUS_WIDTH = 3; +const COMPACT_AVAILABILITY_STATUS_WIDTH = 8; const ROW_FIXED_WIDTH = 6; const DETAIL_FULL_CHROME_LINES = 13; const DETAIL_COMPACT_CHROME_LINES = 6; @@ -173,7 +177,7 @@ const DETAIL_SECTIONS: DetailSection[] = [ { id: 'paths', label: 'Paths' }, { id: 'source', label: 'Source' }, { id: 'description', label: 'Description' }, - { id: 'warnings', label: 'Warnings' }, + { id: 'findings', label: 'Findings' }, { id: 'actions', label: 'Actions' }, ]; const PROJECT_STATE_WIDTH = 15; @@ -187,16 +191,18 @@ export function getInventoryLayout(input: InventoryLayoutInput): InventoryLayout visibleRows: 0, rowWidth: Math.max(0, input.size.columns), statusBarVariant: 'minimal', - columns: { name: 0, provider: 0, status: 0 }, + columns: { name: 0, provider: 0, state: 0, related: 0, issue: 0 }, }; } const provider = mode === 'full' ? FULL_PROVIDER_WIDTH : COMPACT_PROVIDER_WIDTH; - const status = mode === 'full' ? FULL_STATUS_WIDTH : COMPACT_STATUS_WIDTH; + const state = mode === 'full' ? FULL_AVAILABILITY_STATUS_WIDTH : COMPACT_AVAILABILITY_STATUS_WIDTH; + const related = mode === 'full' ? FULL_RELATED_WIDTH : COMPACT_RELATED_WIDTH; + const issue = mode === 'full' ? FULL_ISSUE_WIDTH : COMPACT_ISSUE_WIDTH; const contentWidth = Math.max(0, input.size.columns - HORIZONTAL_PADDING); - const availableNameWidth = contentWidth - ROW_FIXED_WIDTH - provider - status; + const availableNameWidth = contentWidth - ROW_FIXED_WIDTH - provider - state - related - issue; const name = Math.max(MIN_NAME_WIDTH, Math.min(MAX_NAME_WIDTH, availableNameWidth)); - const rowWidth = ROW_FIXED_WIDTH + name + provider + status; + const rowWidth = ROW_FIXED_WIDTH + name + provider + state + related + issue; const searchLines = input.searching ? 2 : 0; const filterLines = !input.searching && input.hasSearchQuery ? 1 : 0; const chromeLines = mode === 'full' ? FULL_CHROME_LINES : COMPACT_CHROME_LINES; @@ -206,7 +212,7 @@ export function getInventoryLayout(input: InventoryLayoutInput): InventoryLayout visibleRows: Math.max(1, input.size.rows - chromeLines - searchLines - filterLines), rowWidth, statusBarVariant: mode === 'full' ? 'full' : 'compact', - columns: { name, provider, status }, + columns: { name, provider, state, related, issue }, }; } diff --git a/packages/tui/src/lib/shortcuts.ts b/packages/tui/src/lib/shortcuts.ts index 19724c3..1cdf2ab 100644 --- a/packages/tui/src/lib/shortcuts.ts +++ b/packages/tui/src/lib/shortcuts.ts @@ -7,7 +7,7 @@ export interface Shortcut extends ShortcutLayoutItem {} const SHORTCUTS: Record = { list: [ { key: '↑↓', label: 'navigate', compactLabel: 'nav' }, - { key: 'enter', label: 'group detail', compactLabel: 'detail' }, + { key: 'enter', label: 'detail' }, { key: '/', label: 'search' }, { key: '?', label: 'help' }, { key: 'tab', label: 'tabs' }, diff --git a/packages/tui/src/views/detail-view.tsx b/packages/tui/src/views/detail-view.tsx index eb02934..679ad67 100644 --- a/packages/tui/src/views/detail-view.tsx +++ b/packages/tui/src/views/detail-view.tsx @@ -43,9 +43,10 @@ export function DetailView() { const isRemovable = selectedSkill?.actions.includes('remove') ?? false; const canToggle = selectedSkill?.actions.some((action) => action === 'enable' || action === 'disable') ?? false; const disableStrategy = selectedSkill?.disableStrategy; - const groupHealthSignals = selectedGroup?.healthSignals ?? []; - const instanceHealthSignals = selectedSkill?.healthSignals ?? []; - const hasWarnings = groupHealthSignals.length > 0 || instanceHealthSignals.length > 0; + const relationshipNotices = selectedGroup?.notices ?? []; + const instanceIssues = selectedSkill?.issues ?? []; + const instanceNotices = selectedSkill?.notices ?? []; + const hasFindings = relationshipNotices.length > 0 || instanceIssues.length > 0 || instanceNotices.length > 0; const selectedInstanceIndex = selectedGroup && selectedSkill ? selectedGroup.instances.findIndex((instance) => ( instance.provider === selectedSkill.provider && instance.path === selectedSkill.path @@ -67,7 +68,7 @@ export function DetailView() { const detailLayout = getDetailLayout({ size: { columns, rows }, hasDescription: descLines.length > 0, - hasWarnings, + hasFindings, }); const fullVisibleDescRows = useMemo(() => { @@ -82,14 +83,14 @@ export function DetailView() { if (disableStrategy) used += 1; if (isUpdatable) used += 1; // update row if (addedAt) used += 1; - if (hasWarnings) used += 1 + 1 + groupHealthSignals.length + instanceHealthSignals.length; // gap + heading + signals + if (hasFindings) used += 1 + 2 + relationshipNotices.length + instanceIssues.length + instanceNotices.length; // gap + headings + findings used += 1; // gap before description used += 1; // separator used += 1; // "description" label used += 1; // status bar if (error) used += 1; return Math.max(0, rows - used); - }, [selectedSkill, groupHealthSignals.length, instanceHealthSignals.length, hasWarnings, error, rows, isUpdatable, disableStrategy]); + }, [selectedSkill, relationshipNotices.length, instanceIssues.length, instanceNotices.length, hasFindings, error, rows, isUpdatable, disableStrategy]); const visibleDescRows = detailLayout.sectioned ? Math.max(1, detailLayout.visibleRows - 1) @@ -170,7 +171,7 @@ export function DetailView() { }, { isActive: !confirming }); if (!selectedGroup || !selectedSkill) { - return No Skill Group selected; + return No skill selected; } if (confirming === 'remove') { @@ -246,6 +247,45 @@ export function DetailView() { const visibleDesc = wrappedDescLines.slice(descScroll, descScroll + visibleDescRows); const descScrollable = wrappedDescLines.length > visibleDescRows; + const notices = [...relationshipNotices, ...instanceNotices]; + + const renderFindings = (): ReactNode => { + if (!hasFindings) return No findings.; + + return ( + + {instanceIssues.length > 0 && ( + <> + {glyphs.warning} Issues + {instanceIssues.map((issue, index) => ( + + {issue.code}: {issue.message} + + ))} + + )} + {notices.length > 0 && ( + 0 ? 1 : 0}> + Notices + {notices.map((item, index) => ( + + {item.code}: {item.message} + + ))} + + )} + + ); + }; + + const renderActionRow = (keyLabel: string | null, label: string, enabled = true): ReactNode => ( + + + {keyLabel ? keyLabel.padEnd(5) : ''.padEnd(5)} + + {label} + + ); if (detailLayout.sectioned) { const renderSection = (): ReactNode => { @@ -348,26 +388,21 @@ export function DetailView() { ))} ); - case 'warnings': - return hasWarnings ? ( - - Health Signals - {[...groupHealthSignals, ...instanceHealthSignals].map((signal, index) => ( - - {signal.code}: {signal.message} - - ))} - - ) : ( - No warnings. - ); + case 'findings': + return renderFindings(); case 'actions': return ( - {canToggle ? 'space toggle availability' : 'toggle unavailable'} - {isUpdatable ? 'u check/apply update' : 'update unavailable'} - {isRemovable ? 'd delete skills.sh Global Skill' : 'delete unavailable'} - o open folder + {canToggle + ? renderActionRow('space', 'toggle availability') + : renderActionRow(null, 'toggle unavailable', false)} + {isUpdatable + ? renderActionRow('u', 'check/apply update') + : renderActionRow(null, 'update unavailable', false)} + {isRemovable + ? renderActionRow('d', 'delete skills.sh Global Skill') + : renderActionRow(null, 'delete unavailable', false)} + {renderActionRow('o', 'open folder')} ); } @@ -378,7 +413,7 @@ export function DetailView() { ‹ esc {glyphs.brand} - {selectedGroup.name} + {selectedSkill.name} @@ -431,7 +466,7 @@ export function DetailView() { ‹ esc {glyphs.brand} - {selectedGroup.name} + {selectedSkill.name} {/* Metadata */} @@ -546,15 +581,10 @@ export function DetailView() { - {/* Health Signals */} - {hasWarnings && ( + {/* Findings */} + {hasFindings && ( - {glyphs.warning} Health Signals - {[...groupHealthSignals, ...instanceHealthSignals].map((signal, index) => ( - - {' '}{signal.code}: {signal.message} - - ))} + {renderFindings()} )} diff --git a/packages/tui/src/views/list-view.tsx b/packages/tui/src/views/list-view.tsx index 065c95a..bc6a6bc 100644 --- a/packages/tui/src/views/list-view.tsx +++ b/packages/tui/src/views/list-view.tsx @@ -61,14 +61,15 @@ export function ListView() { const counts: Record = {}; for (const tab of TABS) { if (tab === 'All') { - counts[tab] = inventory.length; + counts[tab] = inventory.reduce((count, group) => count + group.instances.length, 0); } else { const providerMap: Record = { Codex: 'codex', Claude: 'claude', Global: 'global', }; - counts[tab] = inventory.filter((group) => ( - group.instances.some((instance) => instance.provider === providerMap[tab]) - )).length; + counts[tab] = inventory.reduce( + (count, group) => count + group.instances.filter((instance) => instance.provider === providerMap[tab]).length, + 0, + ); } } return counts; @@ -94,8 +95,8 @@ export function ListView() { if (input === 'i') { setView('install'); return; } if (input === 'u') { setView('updates'); return; } if (key.return && skills[cursor]) { - setSelectedGroup(skills[cursor]); - setSelectedSkill(skills[cursor].instances[0] ?? null); + setSelectedGroup(skills[cursor].group); + setSelectedSkill(skills[cursor].instance); setView('detail'); return; } @@ -125,7 +126,7 @@ export function ListView() { {/* Header */} {glyphs.brand} Skillpack - {skills.length} skill{skills.length !== 1 ? 's' : ''} + {skills.length} instance{skills.length !== 1 ? 's' : ''} {showScroll && ( {scrollOffset + 1}–{Math.min(scrollOffset + visibleRows, skills.length)} of {skills.length} )} @@ -162,8 +163,10 @@ export function ListView() { {' '} {fitCell('NAME', layout.columns.name)} - {fitCell('PROVIDERS', layout.columns.provider)} - {fitCell('HEALTH', layout.columns.status)} + {fitCell('PROVIDER', layout.columns.provider)} + {fitCell('STATUS', layout.columns.state)} + {fitCell('RELATED', layout.columns.related)} + {fitCell('ISSUE', layout.columns.issue)} diff --git a/packages/tui/src/views/project-skills-view.tsx b/packages/tui/src/views/project-skills-view.tsx index 8afca57..d9d6bb9 100644 --- a/packages/tui/src/views/project-skills-view.tsx +++ b/packages/tui/src/views/project-skills-view.tsx @@ -47,7 +47,7 @@ export function ProjectSkillsView() { ); const showScroll = projectSkills.length > visibleRows; const inventoryNames = useMemo( - () => new Set(inventory.map((group) => group.name.toLowerCase())), + () => new Set(inventory.flatMap((group) => group.instances.map((instance) => instance.name.toLowerCase()))), [inventory], ); @@ -80,7 +80,7 @@ export function ProjectSkillsView() { ) : ( visibleSkills.map((skill, index) => { const selected = scrollOffset + index === cursor; - const hasIssues = skill.healthSignals.length > 0; + const hasIssues = skill.issues.length > 0; const overlapsInventory = inventoryNames.has(skill.name.toLowerCase()); const state = hasIssues ? 'needs attention' : overlapsInventory ? 'overlaps' : 'read-only'; return ( diff --git a/packages/tui/tests/responsive-layout.test.ts b/packages/tui/tests/responsive-layout.test.ts index e5cb681..bcc8e05 100644 --- a/packages/tui/tests/responsive-layout.test.ts +++ b/packages/tui/tests/responsive-layout.test.ts @@ -98,18 +98,18 @@ describe('getBoundedContentLayout', () => { describe('getDetailLayout', () => { it('uses one-page detail at full size and sections at compact size', () => { - const full = getDetailLayout({ size: { columns: 80, rows: 24 }, hasDescription: true, hasWarnings: true }); + const full = getDetailLayout({ size: { columns: 80, rows: 24 }, hasDescription: true, hasFindings: true }); expect(full.mode).toBe('full'); expect(full.sectioned).toBe(false); - expect(full.sections.map((section) => section.id)).toEqual(['summary', 'paths', 'source', 'description', 'warnings', 'actions']); + expect(full.sections.map((section) => section.id)).toEqual(['summary', 'paths', 'source', 'description', 'findings', 'actions']); - const compact = getDetailLayout({ size: { columns: 60, rows: 18 }, hasDescription: true, hasWarnings: true }); + const compact = getDetailLayout({ size: { columns: 60, rows: 18 }, hasDescription: true, hasFindings: true }); expect(compact.mode).toBe('compact'); expect(compact.sectioned).toBe(true); expect(compact.visibleRows).toBeGreaterThan(0); - expect(compact.sections.map((section) => section.id)).toEqual(['summary', 'paths', 'source', 'description', 'warnings', 'actions']); + expect(compact.sections.map((section) => section.id)).toEqual(['summary', 'paths', 'source', 'description', 'findings', 'actions']); }); }); @@ -153,8 +153,8 @@ describe('Inventory row formatting', () => { it('uses full and compact availability labels', () => { expect(formatInventoryStatus(true, 'full')).toBe('enabled '); expect(formatInventoryStatus(false, 'full')).toBe('disabled'); - expect(formatInventoryStatus(true, 'compact')).toBe('on '); - expect(formatInventoryStatus(false, 'compact')).toBe('off'); + expect(formatInventoryStatus(true, 'compact')).toBe('on '); + expect(formatInventoryStatus(false, 'compact')).toBe('off '); }); }); @@ -171,8 +171,10 @@ describe('getInventoryLayout', () => { expect(full.visibleRows).toBe(17); expect(full.statusBarVariant).toBe('full'); expect(full.columns.name).toBe(30); - expect(full.columns.provider).toBe(24); - expect(full.columns.status).toBe(18); + expect(full.columns.provider).toBe(8); + expect(full.columns.state).toBe(8); + expect(full.columns.related).toBe(14); + expect(full.columns.issue).toBe(10); const compact = getInventoryLayout({ size: { columns: 60, rows: 18 }, @@ -186,6 +188,9 @@ describe('getInventoryLayout', () => { expect(compact.statusBarVariant).toBe('compact'); expect(compact.columns.name).toBeGreaterThanOrEqual(12); expect(compact.columns.provider).toBeGreaterThanOrEqual(6); + expect(compact.columns.state).toBe(8); + expect(compact.columns.related).toBe(10); + expect(compact.columns.issue).toBe(8); expect(compact.rowWidth).toBeLessThanOrEqual(58); }); diff --git a/packages/tui/tests/shortcuts.test.ts b/packages/tui/tests/shortcuts.test.ts index c95e6a1..86cf3c3 100644 --- a/packages/tui/tests/shortcuts.test.ts +++ b/packages/tui/tests/shortcuts.test.ts @@ -11,7 +11,8 @@ function skill(provider: string): SkillInventoryInstance { enabled: true, source: { type: 'skillssh' }, actions: provider === 'global' ? ['update', 'remove'] : [], - healthSignals: [], + issues: [], + notices: [], }; } From 4fba2d23afedd1c6992c105eb48e7b0ccf21aec7 Mon Sep 17 00:00:00 2001 From: WTW0313 Date: Thu, 2 Jul 2026 12:24:20 +0800 Subject: [PATCH 13/15] fix: Surface plugin scan issues and Claude plugin metadata --- packages/core/src/providers/claude.ts | 129 +++++++++++++++++-- packages/core/src/providers/codex.ts | 99 +++++++++++--- packages/core/tests/providers/claude.test.ts | 49 ++++++- packages/core/tests/providers/codex.test.ts | 30 +++++ packages/tui/src/lib/plugin-toggle.ts | 2 +- packages/tui/src/lib/responsive-layout.ts | 2 +- packages/tui/src/views/detail-view.tsx | 12 +- packages/tui/tests/plugin-toggle.test.ts | 30 +++++ packages/tui/tests/responsive-layout.test.ts | 3 +- 9 files changed, 315 insertions(+), 41 deletions(-) create mode 100644 packages/tui/tests/plugin-toggle.test.ts diff --git a/packages/core/src/providers/claude.ts b/packages/core/src/providers/claude.ts index 6e64449..8819061 100644 --- a/packages/core/src/providers/claude.ts +++ b/packages/core/src/providers/claude.ts @@ -6,12 +6,29 @@ import path from 'node:path'; import os from 'node:os'; import { parseSkillMd } from '../parser.js'; -async function isDirEntry(entry: { isDirectory(): boolean; isSymbolicLink(): boolean; name: string }, parentPath: string): Promise { - if (entry.isDirectory()) return true; +type SkillDirEntryStatus = 'directory' | 'broken-symlink' | 'other'; + +async function getSkillDirEntryStatus(entry: { isDirectory(): boolean; isSymbolicLink(): boolean; name: string }, parentPath: string): Promise { + if (entry.isDirectory()) return 'directory'; if (entry.isSymbolicLink()) { - try { return (await stat(path.join(parentPath, entry.name))).isDirectory(); } catch { return false; } + try { return (await stat(path.join(parentPath, entry.name))).isDirectory() ? 'directory' : 'other'; } catch { return 'broken-symlink'; } } - return false; + return 'other'; +} + +async function isDirEntry(entry: { isDirectory(): boolean; isSymbolicLink(): boolean; name: string }, parentPath: string): Promise { + return await getSkillDirEntryStatus(entry, parentPath) === 'directory'; +} + +function pluginOrigin(publisher: string, pluginName: string, version: string, pluginEnabled: boolean): NonNullable { + return { + type: 'plugin', + pluginId: `${pluginName}@${publisher}`, + pluginName, + marketplace: publisher, + version: version !== 'unknown' ? version : undefined, + pluginEnabled, + }; } export class ClaudeProvider extends BaseProvider { @@ -100,18 +117,37 @@ export class ClaudeProvider extends BaseProvider { try { await access(basePath); } catch { continue; } const entries = await readdir(basePath, { withFileTypes: true }); for (const entry of entries) { - let isDir = entry.isDirectory(); - if (!isDir && entry.isSymbolicLink()) { - try { isDir = (await stat(path.join(basePath, entry.name))).isDirectory(); } catch { continue; } - } - if (!isDir) continue; const isDisabled = entry.name.startsWith('.disabled-'); const skillDirName = isDisabled ? entry.name.slice('.disabled-'.length) : entry.name; if (entry.name.startsWith('.') && !isDisabled) continue; const skillDir = path.join(basePath, entry.name); + const entryStatus = await getSkillDirEntryStatus(entry, basePath); + if (entryStatus === 'broken-symlink') { + skills.push({ + name: skillDirName, + description: '', + provider: this.id, + path: skillDir, + enabled: !isDisabled, + scope: 'global', + metadata: {}, + source: { type: 'local' }, + scanIssues: [{ + code: 'broken-symlink', + message: 'Broken skill symlink', + }], + }); + continue; + } + if (entryStatus !== 'directory') continue; const skillMdPath = path.join(skillDir, 'SKILL.md'); + let content: string; + try { + content = await readFile(skillMdPath, 'utf-8'); + } catch { + continue; + } try { - const content = await readFile(skillMdPath, 'utf-8'); const parsed = parseSkillMd(content); const resolved = await realpath(skillDir); const dirStat = await stat(resolved); @@ -129,7 +165,25 @@ export class ClaudeProvider extends BaseProvider { metadata: { license: parsed.metadata.license, author: parsed.metadata.author, tags: parsed.metadata.tags }, source: { type: 'local', createdAt: dirStat.birthtime.toISOString() }, }); - } catch { /* skip */ } + } catch (err) { + const resolved = await realpath(skillDir).catch(() => skillDir); + const dirStat = await stat(resolved).catch(() => undefined); + skills.push({ + name: skillDirName, + description: '', + provider: this.id, + path: skillDir, + resolvedPath: resolved !== skillDir ? resolved : undefined, + enabled: !isDisabled, + scope: 'global', + metadata: {}, + source: { type: 'local', createdAt: dirStat?.birthtime.toISOString() }, + scanIssues: [{ + code: 'invalid-skill-md', + message: err instanceof Error ? err.message : 'Invalid SKILL.md', + }], + }); + } } } return skills; @@ -156,14 +210,40 @@ export class ClaudeProvider extends BaseProvider { try { await access(skillsDir); } catch { continue; } const skillEntries = await readdir(skillsDir, { withFileTypes: true }); for (const entry of skillEntries) { - if (!(await isDirEntry(entry, skillsDir))) continue; const isDisabled = entry.name.startsWith('.disabled-'); const skillDirName = isDisabled ? entry.name.slice('.disabled-'.length) : entry.name; if (entry.name.startsWith('.') && !isDisabled) continue; const skillPath = path.join(skillsDir, entry.name); + const entryStatus = await getSkillDirEntryStatus(entry, skillsDir); + const origin = pluginOrigin(pub.name, plugin.name, ver.name, pluginEnabled); + if (entryStatus === 'broken-symlink') { + skills.push({ + name: skillDirName, + description: '', + provider: this.id, + path: skillPath, + version: ver.name !== 'unknown' ? ver.name : undefined, + enabled: !isDisabled && pluginEnabled, + scope: 'global', + metadata: {}, + origin, + source: { type: 'local' }, + scanIssues: [{ + code: 'broken-symlink', + message: 'Broken skill symlink', + }], + }); + continue; + } + if (entryStatus !== 'directory') continue; const skillMdPath = path.join(skillPath, 'SKILL.md'); + let content: string; + try { + content = await readFile(skillMdPath, 'utf-8'); + } catch { + continue; + } try { - const content = await readFile(skillMdPath, 'utf-8'); const parsed = parseSkillMd(content); const resolved = await realpath(skillPath); const dirStat = await stat(resolved); @@ -174,9 +254,30 @@ export class ClaudeProvider extends BaseProvider { version: ver.name !== 'unknown' ? ver.name : undefined, enabled: !isDisabled && pluginEnabled, scope: 'global', metadata: { license: parsed.metadata.license, author: parsed.metadata.author ?? pub.name, tags: parsed.metadata.tags }, + origin, source: { type: 'local', createdAt: dirStat.birthtime.toISOString() }, }); - } catch { /* skip */ } + } catch (err) { + const resolved = await realpath(skillPath).catch(() => skillPath); + const dirStat = await stat(resolved).catch(() => undefined); + skills.push({ + name: skillDirName, + description: '', + provider: this.id, + path: skillPath, + resolvedPath: resolved !== skillPath ? resolved : undefined, + version: ver.name !== 'unknown' ? ver.name : undefined, + enabled: !isDisabled && pluginEnabled, + scope: 'global', + metadata: {}, + origin, + source: { type: 'local', createdAt: dirStat?.birthtime.toISOString() }, + scanIssues: [{ + code: 'invalid-skill-md', + message: err instanceof Error ? err.message : 'Invalid SKILL.md', + }], + }); + } } } } diff --git a/packages/core/src/providers/codex.ts b/packages/core/src/providers/codex.ts index 9ebc758..5246eeb 100644 --- a/packages/core/src/providers/codex.ts +++ b/packages/core/src/providers/codex.ts @@ -29,6 +29,8 @@ interface CodexPluginManifest { }; } +type SkillDirEntryStatus = 'directory' | 'broken-symlink' | 'other'; + async function isDirectory(filePath: string): Promise { try { return (await stat(filePath)).isDirectory(); @@ -37,6 +39,18 @@ async function isDirectory(filePath: string): Promise { } } +async function getSkillDirEntryStatus(entry: { isDirectory(): boolean; isSymbolicLink(): boolean; name: string }, parentPath: string): Promise { + if (entry.isDirectory()) return 'directory'; + if (entry.isSymbolicLink()) { + try { + return (await stat(path.join(parentPath, entry.name))).isDirectory() ? 'directory' : 'other'; + } catch { + return 'broken-symlink'; + } + } + return 'other'; +} + function looksLikePluginCache(basePath: string): boolean { const segments = path.resolve(basePath).split(path.sep); return segments.at(-1) === 'cache' && segments.at(-2) === 'plugins'; @@ -239,16 +253,56 @@ export class CodexProvider extends BaseProvider { const entries = await readdir(skillsPath, { withFileTypes: true }); for (const entry of entries) { - if (!entry.isDirectory()) continue; if (entry.name.startsWith('.')) continue; const skillPath = path.join(skillsPath, entry.name); const skillMdPath = path.join(skillPath, 'SKILL.md'); + const skillConfigEnabled = await readCodexSkillConfigEnabled(this.configPath, skillMdPath); + const origin: Skill['origin'] = { + type: 'plugin', + pluginId: pluginRoot.pluginId, + pluginName: pluginRoot.pluginName, + marketplace: pluginRoot.marketplace, + version: pluginRoot.version, + displayName: pluginRoot.displayName, + pluginEnabled, + skillConfigEnabled, + identityStatus, + }; + const identityIssues = identityStatus === 'mismatched' ? [{ + code: 'plugin-identity-mismatch' as const, + message: `Codex plugin manifest name "${pluginRoot.manifestName}" does not match cache plugin "${pluginRoot.pluginName}"`, + }] : []; + const entryStatus = await getSkillDirEntryStatus(entry, skillsPath); + if (entryStatus === 'broken-symlink') { + skills.push({ + name: entry.name, + description: '', + provider: this.id, + path: skillPath, + version: pluginRoot.version, + enabled: pluginEnabled && skillConfigEnabled !== false, + scope: 'global', + metadata: {}, + origin, + source: { type: 'local' }, + scanIssues: [ + ...identityIssues, + { code: 'broken-symlink', message: 'Broken skill symlink' }, + ], + }); + continue; + } + if (entryStatus !== 'directory') continue; + let content: string; + try { + content = await readFile(skillMdPath, 'utf-8'); + } catch { + continue; + } try { - const content = await readFile(skillMdPath, 'utf-8'); const parsed = parseSkillMd(content); const resolved = await realpath(skillPath); const dirStat = await stat(resolved); - const skillConfigEnabled = await readCodexSkillConfigEnabled(this.configPath, skillMdPath); skills.push({ name: parsed.name || entry.name, description: parsed.description, @@ -259,24 +313,31 @@ export class CodexProvider extends BaseProvider { enabled: pluginEnabled && skillConfigEnabled !== false, scope: 'global', metadata: { license: parsed.metadata.license, author: parsed.metadata.author, tags: parsed.metadata.tags }, - origin: { - type: 'plugin', - pluginId: pluginRoot.pluginId, - pluginName: pluginRoot.pluginName, - marketplace: pluginRoot.marketplace, - version: pluginRoot.version, - displayName: pluginRoot.displayName, - pluginEnabled, - skillConfigEnabled, - identityStatus, - }, + origin, source: { type: 'local', createdAt: dirStat.birthtime.toISOString() }, - scanIssues: identityStatus === 'mismatched' ? [{ - code: 'plugin-identity-mismatch', - message: `Codex plugin manifest name "${pluginRoot.manifestName}" does not match cache plugin "${pluginRoot.pluginName}"`, - }] : undefined, + scanIssues: identityIssues.length > 0 ? identityIssues : undefined, }); - } catch { /* skip invalid plugin skills for now */ } + } catch (err) { + const resolved = await realpath(skillPath).catch(() => skillPath); + const dirStat = await stat(resolved).catch(() => undefined); + skills.push({ + name: entry.name, + description: '', + provider: this.id, + path: skillPath, + resolvedPath: resolved !== skillPath ? resolved : undefined, + version: pluginRoot.version, + enabled: pluginEnabled && skillConfigEnabled !== false, + scope: 'global', + metadata: {}, + origin, + source: { type: 'local', createdAt: dirStat?.birthtime.toISOString() }, + scanIssues: [ + ...identityIssues, + { code: 'invalid-skill-md', message: err instanceof Error ? err.message : 'Invalid SKILL.md' }, + ], + }); + } } return skills; diff --git a/packages/core/tests/providers/claude.test.ts b/packages/core/tests/providers/claude.test.ts index 5ba2a39..140e2e0 100644 --- a/packages/core/tests/providers/claude.test.ts +++ b/packages/core/tests/providers/claude.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { mkdtemp, rm, mkdir, writeFile, readFile, access } from 'node:fs/promises'; +import { mkdtemp, rm, mkdir, writeFile, readFile, access, symlink } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { ClaudeProvider } from '../../src/providers/claude.js'; @@ -102,6 +102,14 @@ describe('ClaudeProvider', () => { expect(skills).toHaveLength(1); expect(skills[0].enabled).toBe(false); + expect(skills[0].origin).toMatchObject({ + type: 'plugin', + pluginId: 'deploy-plugin@team-tools', + pluginName: 'deploy-plugin', + marketplace: 'team-tools', + version: '1.0.0', + pluginEnabled: false, + }); }); it('toggles plugin skills through enabledPlugins for the owning plugin', async () => { @@ -118,4 +126,43 @@ describe('ClaudeProvider', () => { expect(JSON.parse(await readFile(settingsPath, 'utf-8')).enabledPlugins['deploy-plugin@team-tools']).toBe(true); }); + + it('surfaces broken flat skill symlinks as scan issues', async () => { + await mkdir(flatDir, { recursive: true }); + await symlink(path.join(root, 'missing-skill'), path.join(flatDir, 'missing-skill')); + + const skills = await provider.scan(); + + expect(skills).toHaveLength(1); + expect(skills[0]).toMatchObject({ + name: 'missing-skill', + provider: 'claude', + scanIssues: [{ + code: 'broken-symlink', + message: expect.any(String), + }], + }); + }); + + it('surfaces invalid plugin SKILL.md files as scan issues', async () => { + const skillDir = path.join(cacheDir, 'team-tools', 'deploy-plugin', '1.0.0', 'skills', 'broken'); + await mkdir(skillDir, { recursive: true }); + await writeFile(path.join(skillDir, 'SKILL.md'), '---\nname: [\n---\n'); + + const skills = await provider.scan(); + + expect(skills).toHaveLength(1); + expect(skills[0]).toMatchObject({ + name: 'broken', + provider: 'claude', + origin: { + type: 'plugin', + pluginId: 'deploy-plugin@team-tools', + }, + scanIssues: [{ + code: 'invalid-skill-md', + message: expect.any(String), + }], + }); + }); }); diff --git a/packages/core/tests/providers/codex.test.ts b/packages/core/tests/providers/codex.test.ts index c8414c0..f700668 100644 --- a/packages/core/tests/providers/codex.test.ts +++ b/packages/core/tests/providers/codex.test.ts @@ -238,4 +238,34 @@ describe('CodexProvider', () => { }]); expect(pluginProvider.getDisableStrategy(skill)).toBeUndefined(); }); + + it('surfaces invalid plugin SKILL.md files as scan issues', async () => { + const pluginCache = path.join(dir, 'plugins', 'cache'); + const pluginProvider = new CodexProvider([pluginCache], configPath); + const pluginRoot = path.join(pluginCache, 'openai-curated', 'github', '1.2.3'); + const skillDir = path.join(pluginRoot, 'skills', 'broken'); + await mkdir(path.join(pluginRoot, '.codex-plugin'), { recursive: true }); + await writeFile(path.join(pluginRoot, '.codex-plugin', 'plugin.json'), JSON.stringify({ + name: 'github', + version: '1.2.3', + skills: './skills/', + }), 'utf-8'); + await mkdir(skillDir, { recursive: true }); + await writeFile(path.join(skillDir, 'SKILL.md'), '---\nname: [\n---\n'); + + const [skill] = await pluginProvider.scan(); + + expect(skill).toMatchObject({ + name: 'broken', + provider: 'codex', + origin: { + type: 'plugin', + pluginId: 'github@openai-curated', + }, + scanIssues: [{ + code: 'invalid-skill-md', + message: expect.any(String), + }], + }); + }); }); diff --git a/packages/tui/src/lib/plugin-toggle.ts b/packages/tui/src/lib/plugin-toggle.ts index 9f1a4f4..f804d75 100644 --- a/packages/tui/src/lib/plugin-toggle.ts +++ b/packages/tui/src/lib/plugin-toggle.ts @@ -25,5 +25,5 @@ export function formatPluginToggleMessage(skill: Pl const affected = getAffectedPluginSkills(skills, skill); const names = affected.map((item) => item.name).join(', '); const count = affected.length; - return `${action} plugin ${skill.origin.pluginId}? This affects ${count} Codex skill${count === 1 ? '' : 's'}: ${names}.`; + return `${action} plugin ${skill.origin.pluginId}? This affects ${count} skill${count === 1 ? '' : 's'}: ${names}.`; } diff --git a/packages/tui/src/lib/responsive-layout.ts b/packages/tui/src/lib/responsive-layout.ts index adc0eb0..a56f8ce 100644 --- a/packages/tui/src/lib/responsive-layout.ts +++ b/packages/tui/src/lib/responsive-layout.ts @@ -162,7 +162,7 @@ const HORIZONTAL_PADDING = 2; const MIN_NAME_WIDTH = 12; const MAX_NAME_WIDTH = 30; const FULL_PROVIDER_WIDTH = 8; -const COMPACT_PROVIDER_WIDTH = 7; +const COMPACT_PROVIDER_WIDTH = 8; const FULL_RELATED_WIDTH = 14; const COMPACT_RELATED_WIDTH = 10; const FULL_ISSUE_WIDTH = 10; diff --git a/packages/tui/src/views/detail-view.tsx b/packages/tui/src/views/detail-view.tsx index 679ad67..ce1c96c 100644 --- a/packages/tui/src/views/detail-view.tsx +++ b/packages/tui/src/views/detail-view.tsx @@ -524,10 +524,14 @@ export function DetailView() { {selectedSkill.origin.pluginEnabled ? '● enabled' : '○ disabled'} - skill override - - {selectedSkill.origin.skillConfigEnabled === false ? 'disabled' : 'default'} - + {selectedSkill.provider === 'codex' && ( + <> + skill override + + {selectedSkill.origin.skillConfigEnabled === false ? 'disabled' : 'default'} + + + )} )} diff --git a/packages/tui/tests/plugin-toggle.test.ts b/packages/tui/tests/plugin-toggle.test.ts new file mode 100644 index 0000000..0be90eb --- /dev/null +++ b/packages/tui/tests/plugin-toggle.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { formatPluginToggleMessage } from '../src/lib/plugin-toggle.js'; +import type { Skill } from '@skillpack/core'; + +function pluginSkill(provider: string, name: string): Pick { + return { + name, + provider, + origin: { + type: 'plugin', + pluginId: 'deploy-plugin@team-tools', + pluginName: 'deploy-plugin', + marketplace: 'team-tools', + pluginEnabled: true, + }, + }; +} + +describe('formatPluginToggleMessage', () => { + it('uses provider-agnostic wording for plugin-owned skills', () => { + const skill = pluginSkill('claude', 'deploy'); + const message = formatPluginToggleMessage(skill, [ + skill, + pluginSkill('claude', 'audit'), + ]); + + expect(message).toBe('Disable plugin deploy-plugin@team-tools? This affects 2 skills: audit, deploy.'); + expect(message).not.toContain('Codex'); + }); +}); diff --git a/packages/tui/tests/responsive-layout.test.ts b/packages/tui/tests/responsive-layout.test.ts index bcc8e05..2cd5aaf 100644 --- a/packages/tui/tests/responsive-layout.test.ts +++ b/packages/tui/tests/responsive-layout.test.ts @@ -187,7 +187,8 @@ describe('getInventoryLayout', () => { expect(compact.visibleRows).toBeGreaterThan(0); expect(compact.statusBarVariant).toBe('compact'); expect(compact.columns.name).toBeGreaterThanOrEqual(12); - expect(compact.columns.provider).toBeGreaterThanOrEqual(6); + expect(compact.columns.provider).toBeGreaterThanOrEqual('PROVIDER'.length); + expect(fitCell('PROVIDER', compact.columns.provider)).toBe('PROVIDER'); expect(compact.columns.state).toBe(8); expect(compact.columns.related).toBe(10); expect(compact.columns.issue).toBe(8); From 587e377028837769c9e2a0f156fb4a4ae4e26ccf Mon Sep 17 00:00:00 2001 From: WTW0313 Date: Thu, 2 Jul 2026 13:35:26 +0800 Subject: [PATCH 14/15] test(tui): add terminal UI and CLI I/O coverage --- CONTEXT.md | 8 + ...38-split-tui-ui-tests-from-cli-io-tests.md | 15 ++ packages/tui/bin/skillpack.ts | 25 +-- packages/tui/package.json | 1 + packages/tui/src/app.tsx | 29 ++- packages/tui/src/cli.tsx | 75 ++++++++ packages/tui/src/hooks/use-terminal-size.ts | 12 +- packages/tui/tests/README.md | 23 +++ .../tui/tests/cli/run-skillpack-cli.test.ts | 139 ++++++++++++++ packages/tui/tests/fixtures/inventory.ts | 138 ++++++++++++++ packages/tui/tests/helpers/mock-manager.ts | 66 +++++++ packages/tui/tests/helpers/render-tui.tsx | 98 ++++++++++ .../__snapshots__/app-surface.test.tsx.snap | 49 +++++ packages/tui/tests/ui/app-surface.test.tsx | 171 ++++++++++++++++++ pnpm-lock.yaml | 16 ++ 15 files changed, 836 insertions(+), 29 deletions(-) create mode 100644 docs/adr/0038-split-tui-ui-tests-from-cli-io-tests.md create mode 100644 packages/tui/src/cli.tsx create mode 100644 packages/tui/tests/README.md create mode 100644 packages/tui/tests/cli/run-skillpack-cli.test.ts create mode 100644 packages/tui/tests/fixtures/inventory.ts create mode 100644 packages/tui/tests/helpers/mock-manager.ts create mode 100644 packages/tui/tests/helpers/render-tui.tsx create mode 100644 packages/tui/tests/ui/__snapshots__/app-surface.test.tsx.snap create mode 100644 packages/tui/tests/ui/app-surface.test.tsx diff --git a/CONTEXT.md b/CONTEXT.md index 1071186..8ed087f 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -64,6 +64,14 @@ _Avoid_: Responsive target, screen size The TUI's ability to remain understandable across terminals with different color, glyph, width, and resize behavior. _Avoid_: Pretty terminal rendering, theme support +**Terminal UI Test**: +A test that verifies the rendered Skillpack TUI state and keyboard-driven interaction from the user's terminal perspective. +_Avoid_: CLI I/O Test, unit test + +**CLI I/O Test**: +A test that verifies the Skillpack command process contract through standard input, standard output, signals, and terminal control sequences. +_Avoid_: Terminal UI Test, component test + **Scan Root**: A directory Skillpack inspects to discover provider, shared global, or project skills. _Avoid_: Skill, provider, install source diff --git a/docs/adr/0038-split-tui-ui-tests-from-cli-io-tests.md b/docs/adr/0038-split-tui-ui-tests-from-cli-io-tests.md new file mode 100644 index 0000000..fff9376 --- /dev/null +++ b/docs/adr/0038-split-tui-ui-tests-from-cli-io-tests.md @@ -0,0 +1,15 @@ +# Split TUI UI Tests from CLI I/O Tests + +Skillpack will test terminal UI behavior separately from the command process contract. Terminal UI Tests render an injectable application surface with Ink Testing Library and mocked Skillpack state so view routing, keyboard interaction, search, help, and terminal envelope behavior can be verified without scanning user directories; CLI I/O Tests exercise an injectable CLI runner that owns stdin, stdout, signals, alternate screen buffer control, and process exit, with only a thin process-level smoke test for the built binary. + +Snapshots are limited to stable golden frames such as the inventory, detail, help, and too-small terminal states after ANSI output and dynamic values are normalized. Interaction tests use semantic assertions instead of broad snapshots. + +The first Terminal UI Test suite will use shared inventory fixture builders and cover user-path behavior before mutation success paths: navigation, detail routing, search, help, terminal envelope states, and the entry or confirmation state for toggle, update, remove, and install flows. Real provider mutations and external commands remain outside Ink UI tests. + +Terminal UI Tests will share a harness that fixes terminal size, normalizes output, controls glyph mode, supplies Skillpack inventory fixtures, and provides a mock SkillManager with only the methods exercised by the views. Individual tests should not each invent stream setup, environment setup, or manager mocks. + +The first UI and CLI I/O suites prioritize deterministic serial execution over parallel speed. They must unmount Ink renders explicitly and avoid sharing mutable stream, environment, or process-exit state across tests. + +Tests isolate side effects by layer. Terminal UI Tests do not open system file managers, run external skill commands, or send real process signals; those paths use mocks or are deferred. CLI I/O Tests exercise terminal control, unmount, wait, and exit behavior through an injectable runner, with real signals limited to a spawned child process smoke test. + +This keeps user-facing Ink behavior testable without coupling it to provider scanning, while still protecting the terminal lifecycle guarantees that are easy to break around alternate screen buffer teardown. diff --git a/packages/tui/bin/skillpack.ts b/packages/tui/bin/skillpack.ts index 7834fb4..7b576d5 100644 --- a/packages/tui/bin/skillpack.ts +++ b/packages/tui/bin/skillpack.ts @@ -1,25 +1,4 @@ #!/usr/bin/env node -import React from 'react'; -import { render } from 'ink'; -import { App } from '../src/app.js'; +import { runSkillpackCli } from '../src/cli.js'; -process.stdout.write('\x1B[?1049h'); -process.stdout.write('\x1B[H'); -process.stdout.write('\x1B[?25l'); - -const { waitUntilExit, unmount } = render(React.createElement(App), { - patchConsole: false, -}); - -function exit() { - unmount(); -} - -process.on('SIGINT', exit); -process.on('SIGTERM', exit); - -waitUntilExit().then(() => { - process.stdout.write('\x1B[?25h'); - process.stdout.write('\x1B[?1049l'); - process.exit(0); -}); +void runSkillpackCli(); diff --git a/packages/tui/package.json b/packages/tui/package.json index 7efd0ff..94c545a 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -44,6 +44,7 @@ "@skillpack/core": "workspace:*", "@types/node": "25.9.3", "@types/react": "19.2.17", + "ink-testing-library": "^4.0.0", "tsup": "^8.5.1", "typescript": "6.0.3", "vitest": "4.1.8" diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 07c4353..6b0ae7e 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -10,9 +10,10 @@ import { InstallView } from './views/install-view.js'; import { ProjectSkillsView } from './views/project-skills-view.js'; import { SettingsView } from './views/settings-view.js'; import { UpdatesView } from './views/updates-view.js'; -import { useTerminalSize } from './hooks/use-terminal-size.js'; -import { getTerminalMode } from './lib/responsive-layout.js'; +import { TerminalSizeProvider, useTerminalSize } from './hooks/use-terminal-size.js'; +import { getTerminalMode, type TerminalSize } from './lib/responsive-layout.js'; import { HelpOverlay } from './components/help-overlay.js'; +import type { SkillManager, SkillpackConfig } from '@skillpack/core'; function Router() { const { view } = useAppContext(); @@ -43,8 +44,14 @@ function AppFrame() { return helpOpen ? setHelpOpen(false)} /> : ; } -export function App() { - const { manager, config, error } = useSkillManager(); +export interface AppSurfaceProps { + manager: SkillManager | null; + config: SkillpackConfig | null; + error: string | null; + terminalSize?: TerminalSize; +} + +function AppSurfaceContent({ manager, config, error }: Omit) { const { columns, rows } = useTerminalSize(); const terminalMode = getTerminalMode({ columns, rows }); @@ -84,3 +91,17 @@ export function App() { ); } + +export function AppSurface({ manager, config, error, terminalSize }: AppSurfaceProps) { + const content = ; + + return terminalSize + ? {content} + : content; +} + +export function App() { + const { manager, config, error } = useSkillManager(); + + return ; +} diff --git a/packages/tui/src/cli.tsx b/packages/tui/src/cli.tsx new file mode 100644 index 0000000..3ddd12a --- /dev/null +++ b/packages/tui/src/cli.tsx @@ -0,0 +1,75 @@ +import React, { type ReactNode } from 'react'; +import { render, type Instance, type RenderOptions } from 'ink'; +import { App } from './app.js'; + +export const ENTER_ALTERNATE_SCREEN = '\x1B[?1049h'; +export const RESET_CURSOR_POSITION = '\x1B[H'; +export const HIDE_CURSOR = '\x1B[?25l'; +export const SHOW_CURSOR = '\x1B[?25h'; +export const EXIT_ALTERNATE_SCREEN = '\x1B[?1049l'; + +type RenderApp = (node: ReactNode, options: RenderOptions) => Pick; + +export interface SkillpackCliOptions { + app?: ReactNode; + renderApp?: RenderApp; + stdout?: NodeJS.WriteStream; + stdin?: NodeJS.ReadStream; + stderr?: NodeJS.WriteStream; + process?: NodeJS.Process; + exit?: (code: number) => void; +} + +function removeSignalHandler(processLike: NodeJS.Process, signal: NodeJS.Signals, handler: () => void): void { + if (typeof processLike.off === 'function') { + processLike.off(signal, handler); + return; + } + processLike.removeListener(signal, handler); +} + +export async function runSkillpackCli(options: SkillpackCliOptions = {}): Promise { + const processLike = options.process ?? process; + const stdout = options.stdout ?? processLike.stdout; + const stdin = options.stdin ?? processLike.stdin; + const stderr = options.stderr ?? processLike.stderr; + const renderApp = options.renderApp ?? render; + const exit = options.exit ?? ((code: number) => { processLike.exit(code); }); + const app = options.app ?? React.createElement(App); + let instance: Pick | null = null; + let exitCode = 0; + let error: unknown; + + const unmount = () => { + instance?.unmount(); + }; + + try { + stdout.write(ENTER_ALTERNATE_SCREEN); + stdout.write(RESET_CURSOR_POSITION); + stdout.write(HIDE_CURSOR); + + instance = renderApp(app, { + stdout, + stdin, + stderr, + patchConsole: false, + }); + + processLike.on('SIGINT', unmount); + processLike.on('SIGTERM', unmount); + + await instance.waitUntilExit(); + } catch (err) { + exitCode = 1; + error = err; + } finally { + removeSignalHandler(processLike, 'SIGINT', unmount); + removeSignalHandler(processLike, 'SIGTERM', unmount); + stdout.write(SHOW_CURSOR); + stdout.write(EXIT_ALTERNATE_SCREEN); + exit(exitCode); + } + + if (error) throw error; +} diff --git a/packages/tui/src/hooks/use-terminal-size.ts b/packages/tui/src/hooks/use-terminal-size.ts index 2273b1f..28090e1 100644 --- a/packages/tui/src/hooks/use-terminal-size.ts +++ b/packages/tui/src/hooks/use-terminal-size.ts @@ -1,7 +1,15 @@ -import { useState, useEffect } from 'react'; +import { createContext, createElement, useContext, useState, useEffect, type ReactNode } from 'react'; import { useStdout } from 'ink'; +import type { TerminalSize } from '../lib/responsive-layout.js'; + +const TerminalSizeOverrideContext = createContext(null); + +export function TerminalSizeProvider({ size, children }: { size: TerminalSize; children: ReactNode }) { + return createElement(TerminalSizeOverrideContext.Provider, { value: size }, children); +} export function useTerminalSize() { + const override = useContext(TerminalSizeOverrideContext); const { stdout } = useStdout(); const [size, setSize] = useState({ columns: stdout?.columns ?? 80, @@ -17,5 +25,5 @@ export function useTerminalSize() { return () => { stdout.off('resize', onResize); }; }, [stdout]); - return size; + return override ?? size; } diff --git a/packages/tui/tests/README.md b/packages/tui/tests/README.md new file mode 100644 index 0000000..564f9f5 --- /dev/null +++ b/packages/tui/tests/README.md @@ -0,0 +1,23 @@ +# TUI Testing + +Skillpack keeps terminal UI behavior separate from the command process contract. + +## Test Types + +`*.test.ts` files at the current test root cover pure functions such as layout, shortcut selection, and formatting. + +`ui/*.test.tsx` files are Terminal UI Tests. They render an injectable app surface with Ink Testing Library, shared inventory fixtures, a fixed terminal size, normalized output, and a mock SkillManager. They should verify user-visible behavior through keyboard input and rendered frames without scanning user directories. + +`cli/*.test.ts` files are CLI I/O Tests. They exercise the injectable CLI runner that owns stdin, stdout, terminal control sequences, signal handlers, render lifecycle, and process exit. They should not depend on provider scan results or detailed view content. + +## Snapshots + +Use snapshots only for stable golden frames such as inventory, detail, help, and too-small terminal states. Normalize output before snapshotting, and keep dynamic values such as timestamps, absolute paths, and spinner frames out of snapshots. + +Prefer semantic assertions for interactions: assert that search mode opens, a filtered row appears, detail content is shown, or a confirmation prompt is visible. + +## Side Effects + +Terminal UI Tests must not open the system file manager, run external `skills` commands, mutate provider config, access user skill directories, or send real signals to the Vitest process. + +Mock side effects at the layer where they are introduced. Real process signals are only appropriate in a spawned child process smoke test. diff --git a/packages/tui/tests/cli/run-skillpack-cli.test.ts b/packages/tui/tests/cli/run-skillpack-cli.test.ts new file mode 100644 index 0000000..bba62e6 --- /dev/null +++ b/packages/tui/tests/cli/run-skillpack-cli.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + ENTER_ALTERNATE_SCREEN, + EXIT_ALTERNATE_SCREEN, + HIDE_CURSOR, + RESET_CURSOR_POSITION, + SHOW_CURSOR, + runSkillpackCli, +} from '../../src/cli.js'; + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function createWritable() { + const chunks: string[] = []; + return { + chunks, + write: vi.fn((chunk: string) => { + chunks.push(chunk); + return true; + }), + }; +} + +function createProcessLike() { + const handlers = new Map void>>(); + const stdout = createWritable(); + const stderr = createWritable(); + + return { + stdout, + stderr, + stdin: {}, + exit: vi.fn(), + on: vi.fn((signal: string, handler: () => void) => { + const existing = handlers.get(signal) ?? new Set(); + existing.add(handler); + handlers.set(signal, existing); + return undefined; + }), + off: vi.fn((signal: string, handler: () => void) => { + handlers.get(signal)?.delete(handler); + return undefined; + }), + removeListener: vi.fn((signal: string, handler: () => void) => { + handlers.get(signal)?.delete(handler); + return undefined; + }), + emitSignal(signal: string) { + for (const handler of handlers.get(signal) ?? []) handler(); + }, + }; +} + +describe.sequential('runSkillpackCli', () => { + it('writes terminal lifecycle sequences in order around the render lifecycle', async () => { + const processLike = createProcessLike(); + const wait = deferred(); + const renderApp = vi.fn(() => ({ + unmount: vi.fn(), + waitUntilExit: vi.fn(() => wait.promise), + })); + + const run = runSkillpackCli({ + process: processLike as unknown as NodeJS.Process, + renderApp, + exit: processLike.exit, + }); + + expect(processLike.stdout.chunks).toEqual([ + ENTER_ALTERNATE_SCREEN, + RESET_CURSOR_POSITION, + HIDE_CURSOR, + ]); + + wait.resolve(); + await run; + + expect(processLike.stdout.chunks).toEqual([ + ENTER_ALTERNATE_SCREEN, + RESET_CURSOR_POSITION, + HIDE_CURSOR, + SHOW_CURSOR, + EXIT_ALTERNATE_SCREEN, + ]); + expect(processLike.exit).toHaveBeenCalledWith(0); + expect(processLike.off).toHaveBeenCalledWith('SIGINT', expect.any(Function)); + expect(processLike.off).toHaveBeenCalledWith('SIGTERM', expect.any(Function)); + }); + + it('unmounts the Ink app when SIGTERM is received', async () => { + const processLike = createProcessLike(); + const wait = deferred(); + const unmount = vi.fn(() => wait.resolve()); + const renderApp = vi.fn(() => ({ + unmount, + waitUntilExit: vi.fn(() => wait.promise), + })); + + const run = runSkillpackCli({ + process: processLike as unknown as NodeJS.Process, + renderApp, + exit: processLike.exit, + }); + + processLike.emitSignal('SIGTERM'); + await run; + + expect(unmount).toHaveBeenCalledTimes(1); + expect(processLike.exit).toHaveBeenCalledWith(0); + }); + + it('restores the terminal before surfacing render lifecycle failures', async () => { + const processLike = createProcessLike(); + const renderApp = vi.fn(() => ({ + unmount: vi.fn(), + waitUntilExit: vi.fn(async () => { + throw new Error('render failed'); + }), + })); + + await expect(runSkillpackCli({ + process: processLike as unknown as NodeJS.Process, + renderApp, + exit: processLike.exit, + })).rejects.toThrow('render failed'); + + expect(processLike.stdout.chunks).toContain(SHOW_CURSOR); + expect(processLike.stdout.chunks).toContain(EXIT_ALTERNATE_SCREEN); + expect(processLike.exit).toHaveBeenCalledWith(1); + }); +}); diff --git a/packages/tui/tests/fixtures/inventory.ts b/packages/tui/tests/fixtures/inventory.ts new file mode 100644 index 0000000..be7271c --- /dev/null +++ b/packages/tui/tests/fixtures/inventory.ts @@ -0,0 +1,138 @@ +import type { + Skill, + SkillGroup, + SkillInventoryInstance, + SkillpackConfig, +} from '@skillpack/core'; + +export function testConfig(overrides: Partial = {}): SkillpackConfig { + return { + editor: 'vi', + autoCheckUpdates: false, + projectSkillsDirs: ['.codex/skills', '.claude/skills', '.agents/skills'], + providers: { + codex: { enabled: true, paths: [] }, + claude: { enabled: true, paths: [] }, + global: { enabled: true, paths: [] }, + }, + sources: { + skillssh: { enabled: true }, + }, + ...overrides, + }; +} + +export function inventoryInstance(overrides: Partial = {}): SkillInventoryInstance { + const provider = overrides.provider ?? 'codex'; + const name = overrides.name ?? 'alpha'; + + return { + name, + description: `${name} test skill`, + provider, + path: `/tmp/skillpack/${provider}/${name}/SKILL.md`, + enabled: true, + source: { type: 'local' }, + actions: [], + issues: [], + notices: [], + ...overrides, + }; +} + +export function inventoryGroup( + name: string, + instances: SkillInventoryInstance[] = [inventoryInstance({ name })], + overrides: Partial = {}, +): SkillGroup { + return { + id: `name:${name}`, + name, + identity: { + confidence: 'inferred', + reasons: ['normalized name'], + }, + providers: instances.map((instance) => ({ + provider: instance.provider, + enabled: instance.enabled, + })), + instances, + issues: instances.flatMap((instance) => instance.issues), + notices: [], + ...overrides, + }; +} + +export function skillFromInstance(instance: SkillInventoryInstance): Skill { + return { + name: instance.name, + description: instance.description, + provider: instance.provider, + path: instance.path, + resolvedPath: instance.resolvedPath, + version: instance.version, + enabled: instance.enabled, + scope: instance.provider === 'project' ? 'project' : 'global', + metadata: {}, + origin: instance.origin, + source: instance.source, + scanIssues: instance.issues, + }; +} + +export function skillsFromInventory(inventory: SkillGroup[]): Skill[] { + return inventory.flatMap((group) => group.instances.map(skillFromInstance)); +} + +export function basicInventory(): SkillGroup[] { + return [ + inventoryGroup('alpha', [ + inventoryInstance({ + name: 'alpha', + provider: 'codex', + description: 'Alpha helps with coding workflows.', + actions: ['disable'], + disableStrategy: { + type: 'provider-config', + description: 'Codex config entry', + }, + }), + ]), + inventoryGroup('bravo', [ + inventoryInstance({ + name: 'bravo', + provider: 'claude', + description: 'Bravo helps with writing workflows.', + enabled: false, + actions: ['enable'], + disableStrategy: { + type: 'provider-config', + description: 'Claude settings override', + }, + }), + ]), + inventoryGroup('charlie', [ + inventoryInstance({ + name: 'charlie', + provider: 'global', + description: 'Charlie is managed by skills.sh.', + version: '1.0.0', + source: { + type: 'skillssh', + repo: 'owner/charlie', + skillFolderHash: 'abc1234', + }, + actions: ['update', 'remove'], + }), + ], { + identity: { + confidence: 'confirmed', + reasons: ['skills.sh provenance'], + }, + }), + ]; +} + +export function emptyInventory(): SkillGroup[] { + return []; +} diff --git a/packages/tui/tests/helpers/mock-manager.ts b/packages/tui/tests/helpers/mock-manager.ts new file mode 100644 index 0000000..c0a1628 --- /dev/null +++ b/packages/tui/tests/helpers/mock-manager.ts @@ -0,0 +1,66 @@ +import { vi } from 'vitest'; +import type { + DuplicateInfo, + RemoteSkill, + ScanPathDiagnostic, + Skill, + SkillGroup, + SkillInventoryInstance, + SkillManager, + UpdateInfo, +} from '@skillpack/core'; +import { basicInventory, skillsFromInventory } from '../fixtures/inventory.js'; + +export type MockSkillManager = SkillManager & { + getAllSkills: ReturnType; + getInventory: ReturnType; + getProjectSkills: ReturnType; + getScanPathDiagnostics: ReturnType; + getDuplicates: ReturnType; + scanAll: ReturnType; + toggleInventoryInstance: ReturnType; + uninstallSkill: ReturnType; + updateSkill: ReturnType; + checkSkillUpdate: ReturnType; + checkUpdates: ReturnType; + searchRemote: ReturnType; + installFromSource: ReturnType; +}; + +export interface MockSkillManagerOptions { + inventory?: SkillGroup[]; + skills?: Skill[]; + projectSkills?: SkillInventoryInstance[]; + scanPaths?: ScanPathDiagnostic[]; + duplicates?: DuplicateInfo[]; + remoteResults?: RemoteSkill[]; + updateInfo?: UpdateInfo | null; + updates?: Array<{ skill: Skill; update: UpdateInfo }>; +} + +export function createMockManager(options: MockSkillManagerOptions = {}): MockSkillManager { + const inventory = options.inventory ?? basicInventory(); + const skills = options.skills ?? skillsFromInventory(inventory); + const projectSkills = options.projectSkills ?? []; + const scanPaths = options.scanPaths ?? []; + const duplicates = options.duplicates ?? []; + const remoteResults = options.remoteResults ?? []; + const updates = options.updates ?? []; + const updateInfo = options.updateInfo ?? { hasUpdate: false }; + + return { + getAllSkills: vi.fn(() => skills), + getInventory: vi.fn(() => inventory), + getProjectSkills: vi.fn(() => projectSkills), + getScanPathDiagnostics: vi.fn(() => scanPaths), + getDuplicates: vi.fn(() => duplicates), + scanAll: vi.fn(async () => {}), + toggleInventoryInstance: vi.fn(async () => {}), + uninstallSkill: vi.fn(async () => {}), + updateSkill: vi.fn(async () => {}), + checkSkillUpdate: vi.fn(async () => updateInfo), + checkUpdates: vi.fn(async () => updates), + searchRemote: vi.fn(async () => remoteResults), + installFromSource: vi.fn(async () => {}), + } as unknown as MockSkillManager; +} diff --git a/packages/tui/tests/helpers/render-tui.tsx b/packages/tui/tests/helpers/render-tui.tsx new file mode 100644 index 0000000..da51068 --- /dev/null +++ b/packages/tui/tests/helpers/render-tui.tsx @@ -0,0 +1,98 @@ +import React from 'react'; +import { render } from 'ink-testing-library'; +import { AppSurface } from '../../src/app.js'; +import type { SkillGroup, SkillManager, SkillpackConfig } from '@skillpack/core'; +import type { TerminalSize } from '../../src/lib/responsive-layout.js'; +import { testConfig } from '../fixtures/inventory.js'; +import { createMockManager, type MockSkillManager } from './mock-manager.js'; + +type InkTestInstance = ReturnType; + +export interface RenderTuiOptions { + manager?: MockSkillManager; + inventory?: SkillGroup[]; + config?: SkillpackConfig; + terminalSize?: TerminalSize; + error?: string | null; +} + +export interface RenderTuiResult extends InkTestInstance { + manager: MockSkillManager; + config: SkillpackConfig; + normalizedFrame: () => string; + dispose: () => void; +} + +export const keypress = { + enter: '\r', + escape: '\x1B', + down: '\x1B[B', + up: '\x1B[A', + left: '\x1B[D', + right: '\x1B[C', + tab: '\t', +}; + +export function stripAnsi(value: string): string { + return value.replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, ''); +} + +export function normalizeFrame(frame: string | undefined): string { + return stripAnsi(frame ?? '') + .replace(/\r/g, '') + .replace(/[ \t]+$/gm, '') + .trimEnd(); +} + +export function renderTui(options: RenderTuiOptions = {}): RenderTuiResult { + const previousAscii = process.env.SKILLPACK_ASCII; + process.env.SKILLPACK_ASCII = '1'; + + const manager = options.manager ?? createMockManager({ inventory: options.inventory }); + const config = options.config ?? testConfig(); + const result = render( + , + ); + + const restoreEnv = () => { + if (previousAscii === undefined) { + delete process.env.SKILLPACK_ASCII; + } else { + process.env.SKILLPACK_ASCII = previousAscii; + } + }; + + return { + ...result, + manager, + config, + normalizedFrame: () => normalizeFrame(result.lastFrame()), + dispose: () => { + result.unmount(); + result.cleanup(); + restoreEnv(); + }, + }; +} + +export async function waitForFrame( + result: Pick, + predicate: (frame: string) => boolean, + timeoutMs = 1000, +): Promise { + const startedAt = Date.now(); + let frame = result.normalizedFrame(); + + while (Date.now() - startedAt < timeoutMs) { + frame = result.normalizedFrame(); + if (predicate(frame)) return frame; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + + throw new Error(`Timed out waiting for frame.\n\nLast frame:\n${frame}`); +} diff --git a/packages/tui/tests/ui/__snapshots__/app-surface.test.tsx.snap b/packages/tui/tests/ui/__snapshots__/app-surface.test.tsx.snap new file mode 100644 index 0000000..e37ee50 --- /dev/null +++ b/packages/tui/tests/ui/__snapshots__/app-surface.test.tsx.snap @@ -0,0 +1,49 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Terminal UI Tests > renders a stable inventory frame 1`] = ` +" * Skillpack 3 instances + All 3 │ Codex 1 │ Claude 1 │ Global 1 + + NAME PROVIDER STATUS RELATED ISSUE + > alpha codex enabled - - + bravo claude disabled - - + charlie global enabled - - + + + + + + + + + + + + + + + + ──↑↓ navigat enter detai / searc ? hel tab tab p projec s setting i u update q quit + install" +`; + +exports[`Terminal UI Tests > renders a stable too-small terminal frame 1`] = ` +" skillpack + Terminal too small + Resize to at least 60x18. + + + + + + + + + + + + + + + q quit" +`; diff --git a/packages/tui/tests/ui/app-surface.test.tsx b/packages/tui/tests/ui/app-surface.test.tsx new file mode 100644 index 0000000..9fb514b --- /dev/null +++ b/packages/tui/tests/ui/app-surface.test.tsx @@ -0,0 +1,171 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { inventoryGroup, inventoryInstance } from '../fixtures/inventory.js'; +import { createMockManager } from '../helpers/mock-manager.js'; +import { keypress, renderTui, waitForFrame, type RenderTuiResult } from '../helpers/render-tui.js'; + +let tui: RenderTuiResult | null = null; + +function renderApp(...args: Parameters): RenderTuiResult { + tui = renderTui(...args); + return tui; +} + +function selectedInventoryLine(frame: string): string | undefined { + return frame.split('\n').find((line) => line.trimStart().startsWith('>')); +} + +afterEach(() => { + tui?.dispose(); + tui = null; +}); + +describe.sequential('Terminal UI Tests', () => { + it('renders a stable inventory frame', async () => { + const app = renderApp(); + + const frame = await waitForFrame(app, (output) => ( + output.includes('* Skillpack') && !output.includes('Scanning skills') + )); + + expect(frame).toMatchSnapshot(); + }); + + it('moves the inventory selection with arrow keys', async () => { + const app = renderApp(); + await waitForFrame(app, (output) => output.includes('* Skillpack') && !output.includes('Scanning skills')); + + expect(selectedInventoryLine(app.normalizedFrame())).toContain('alpha'); + + app.stdin.write(keypress.down); + await waitForFrame(app, (output) => selectedInventoryLine(output)?.includes('bravo') ?? false); + + app.stdin.write(keypress.up); + await waitForFrame(app, (output) => selectedInventoryLine(output)?.includes('alpha') ?? false); + }); + + it('routes from inventory to detail and back', async () => { + const app = renderApp(); + await waitForFrame(app, (output) => output.includes('* Skillpack') && !output.includes('Scanning skills')); + + app.stdin.write(keypress.enter); + await waitForFrame(app, (output) => output.includes('alpha') && output.includes('identity')); + + app.stdin.write(keypress.escape); + await waitForFrame(app, (output) => output.includes('* Skillpack') && output.includes('alpha')); + }); + + it('filters inventory through search input', async () => { + const app = renderApp(); + await waitForFrame(app, (output) => output.includes('* Skillpack') && !output.includes('Scanning skills')); + + app.stdin.write('/'); + await waitForFrame(app, (output) => output.includes('filter skills')); + + for (const char of 'charlie') { + app.stdin.write(char); + } + await waitForFrame(app, (output) => output.includes('charlie')); + app.stdin.write(keypress.enter); + + const frame = await waitForFrame(app, (output) => ( + output.includes('filtered by') && output.includes('charlie') + )); + expect(frame).toContain('1 instance'); + expect(frame).toContain('charlie'); + expect(frame).not.toContain('alpha'); + }); + + it('opens and closes the help overlay', async () => { + const app = renderApp(); + await waitForFrame(app, (output) => output.includes('* Skillpack') && !output.includes('Scanning skills')); + + app.stdin.write('?'); + await waitForFrame(app, (output) => output.includes('Inventory Help') && output.includes('search')); + + app.stdin.write('?'); + await waitForFrame(app, (output) => output.includes('* Skillpack') && !output.includes('Inventory Help')); + }); + + it('renders a stable too-small terminal frame', async () => { + const app = renderApp({ terminalSize: { columns: 59, rows: 18 } }); + + const frame = await waitForFrame(app, (output) => output.includes('Terminal too small')); + + expect(frame).toMatchSnapshot(); + }); + + it('calls the toggle mutation from detail without running a real provider mutation', async () => { + const manager = createMockManager(); + const app = renderApp({ manager }); + await waitForFrame(app, (output) => output.includes('* Skillpack') && !output.includes('Scanning skills')); + + app.stdin.write(keypress.enter); + await waitForFrame(app, (output) => output.includes('alpha') && output.includes('identity')); + + app.stdin.write(' '); + await waitForFrame(app, (output) => output.includes('Availability updated.')); + + expect(manager.toggleInventoryInstance).toHaveBeenCalledTimes(1); + }); + + it('opens remove confirmation for skills.sh global skills', async () => { + const inventory = [ + inventoryGroup('charlie', [ + inventoryInstance({ + name: 'charlie', + provider: 'global', + source: { type: 'skillssh', repo: 'owner/charlie' }, + actions: ['update', 'remove'], + }), + ]), + ]; + const app = renderApp({ inventory }); + await waitForFrame(app, (output) => output.includes('* Skillpack') && !output.includes('Scanning skills')); + + app.stdin.write(keypress.enter); + await waitForFrame(app, (output) => output.includes('charlie') && output.includes('source')); + + app.stdin.write('d'); + await waitForFrame(app, (output) => output.includes('Delete "charlie"?')); + }); + + it('checks update availability for skills.sh global skills', async () => { + const inventory = [ + inventoryGroup('charlie', [ + inventoryInstance({ + name: 'charlie', + provider: 'global', + version: '1.0.0', + source: { type: 'skillssh', repo: 'owner/charlie' }, + actions: ['update', 'remove'], + }), + ]), + ]; + const manager = createMockManager({ + inventory, + updateInfo: { + hasUpdate: true, + currentVersion: '1.0.0', + latestVersion: '1.1.0', + }, + }); + const app = renderApp({ manager }); + await waitForFrame(app, (output) => output.includes('* Skillpack') && !output.includes('Scanning skills')); + + app.stdin.write(keypress.enter); + await waitForFrame(app, (output) => output.includes('charlie') && output.includes('press') && output.includes('to check')); + + app.stdin.write('u'); + await waitForFrame(app, (output) => output.includes('1.0.0') && output.includes('1.1.0')); + + expect(manager.checkSkillUpdate).toHaveBeenCalledTimes(1); + }); + + it('opens the install flow from inventory', async () => { + const app = renderApp(); + await waitForFrame(app, (output) => output.includes('* Skillpack') && !output.includes('Scanning skills')); + + app.stdin.write('i'); + await waitForFrame(app, (output) => output.includes('Install Skill') && output.includes('Search skills.sh')); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5205817..86e3094 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -51,6 +51,9 @@ importers: '@types/react': specifier: 19.2.17 version: 19.2.17 + ink-testing-library: + specifier: ^4.0.0 + version: 4.0.0(@types/react@19.2.17) tsup: specifier: ^8.5.1 version: 8.5.1(postcss@8.5.15)(typescript@6.0.3)(yaml@2.9.0) @@ -590,6 +593,15 @@ packages: resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} engines: {node: '>=12'} + ink-testing-library@4.0.0: + resolution: {integrity: sha512-yF92kj3pmBvk7oKbSq5vEALO//o7Z9Ck/OaLNlkzXNeYdwfpxMQkSowGTFUCS5MSu9bWfSZMewGpp7bFc66D7Q==} + engines: {node: '>=18'} + peerDependencies: + '@types/react': '>=18.0.0' + peerDependenciesMeta: + '@types/react': + optional: true + ink@7.0.5: resolution: {integrity: sha512-zWNjGHQPxSeiSAmDUOq+QPQ6CfmMhmNi85vrJIuy4prafKKUSoZlXEy4wbM7LuLuF1pDURk7qvF4fxrQlLxv3w==} engines: {node: '>=22'} @@ -1340,6 +1352,10 @@ snapshots: indent-string@5.0.0: {} + ink-testing-library@4.0.0(@types/react@19.2.17): + optionalDependencies: + '@types/react': 19.2.17 + ink@7.0.5(@types/react@19.2.17)(react@19.2.7): dependencies: '@alcalzone/ansi-tokenize': 0.3.0 From 25907c5b6ac6bc681de8844f7a74484d52133657 Mon Sep 17 00:00:00 2001 From: WTW0313 Date: Thu, 2 Jul 2026 13:43:53 +0800 Subject: [PATCH 15/15] fix: Handle quit input in too-small terminal view --- packages/tui/src/app.tsx | 30 ++++++++++++++-------- packages/tui/tests/ui/app-surface.test.tsx | 9 +++++++ 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 6b0ae7e..0eec63d 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { Box, Text } from 'ink'; +import { Box, Text, useApp } from 'ink'; import { useInput } from 'ink'; import { Spinner } from '@inkjs/ui'; import { AppProvider, useAppContext } from './context/app-context.js'; @@ -44,6 +44,24 @@ function AppFrame() { return helpOpen ? setHelpOpen(false)} /> : ; } +function TooSmallTerminalView({ rows }: { rows: number }) { + const { exit } = useApp(); + + useInput((input) => { + if (input === 'q') exit(); + }); + + return ( + + skillpack + Terminal too small + Resize to at least 60x18. + + q quit + + ); +} + export interface AppSurfaceProps { manager: SkillManager | null; config: SkillpackConfig | null; @@ -72,15 +90,7 @@ function AppSurfaceContent({ manager, config, error }: Omit - skillpack - Terminal too small - Resize to at least 60x18. - - q quit - - ); + return ; } return ( diff --git a/packages/tui/tests/ui/app-surface.test.tsx b/packages/tui/tests/ui/app-surface.test.tsx index 9fb514b..b55a6f0 100644 --- a/packages/tui/tests/ui/app-surface.test.tsx +++ b/packages/tui/tests/ui/app-surface.test.tsx @@ -94,6 +94,15 @@ describe.sequential('Terminal UI Tests', () => { expect(frame).toMatchSnapshot(); }); + it('keeps the too-small terminal state interactive until the user quits', async () => { + const app = renderApp({ terminalSize: { columns: 59, rows: 18 } }); + await waitForFrame(app, (output) => output.includes('Terminal too small')); + + app.stdin.write('q'); + + await waitForFrame(app, (output) => output === ''); + }); + it('calls the toggle mutation from detail without running a real provider mutation', async () => { const manager = createMockManager(); const app = renderApp({ manager });