@skillsgate/tui 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/postinstall.cjs +12 -3
- package/bin/skillsgate-tui +31 -4
- package/package.json +1 -1
- package/src/components/agent-filter.tsx +22 -1
- package/src/components/help-overlay.tsx +1 -0
- package/src/components/layout.tsx +2 -0
- package/src/components/skill-list-item.tsx +7 -1
- package/src/components/skill-list.tsx +23 -2
- package/src/data/api-client.ts +8 -0
- package/src/data/use-favorites.ts +31 -0
- package/src/data/use-trending.ts +57 -0
- package/src/db/context.tsx +3 -0
- package/src/db/favorites.ts +39 -0
- package/src/db/migrations.ts +23 -0
- package/src/db/trending-cache.ts +45 -0
- package/src/store/reducers.ts +4 -0
- package/src/store/types.ts +5 -1
- package/src/views/discover.tsx +98 -27
- package/src/views/home.tsx +7 -4
package/bin/postinstall.cjs
CHANGED
|
@@ -16,7 +16,7 @@ const pkg = platformMap[os.platform()]?.[os.arch()];
|
|
|
16
16
|
if (!pkg) process.exit(0);
|
|
17
17
|
|
|
18
18
|
// Check if already available as a sibling (optionalDeps worked)
|
|
19
|
-
const siblingPath = path.join(__dirname, "..", "..",
|
|
19
|
+
const siblingPath = path.join(__dirname, "..", "..", `@skillsgate/${pkg}`);
|
|
20
20
|
if (fs.existsSync(siblingPath)) process.exit(0);
|
|
21
21
|
|
|
22
22
|
// Also check nested node_modules
|
|
@@ -27,11 +27,20 @@ try {
|
|
|
27
27
|
|
|
28
28
|
// Not found — install it explicitly
|
|
29
29
|
const version = require("../package.json").version;
|
|
30
|
+
const fullPkg = `@skillsgate/${pkg}@${version}`;
|
|
30
31
|
try {
|
|
31
|
-
execSync(`npm install -g
|
|
32
|
+
execSync(`npm install -g ${fullPkg} --no-save`, {
|
|
32
33
|
stdio: "inherit",
|
|
33
34
|
timeout: 30000,
|
|
34
35
|
});
|
|
35
36
|
} catch {
|
|
36
|
-
console.warn(`
|
|
37
|
+
console.warn(`Could not install ${fullPkg}, falling back to @latest (versions may differ)`);
|
|
38
|
+
try {
|
|
39
|
+
execSync(`npm install -g @skillsgate/${pkg}@latest --no-save`, {
|
|
40
|
+
stdio: "inherit",
|
|
41
|
+
timeout: 30000,
|
|
42
|
+
});
|
|
43
|
+
} catch {
|
|
44
|
+
console.warn(`Warning: could not install @skillsgate/${pkg}. Run manually: npm install -g @skillsgate/${pkg}`);
|
|
45
|
+
}
|
|
37
46
|
}
|
package/bin/skillsgate-tui
CHANGED
|
@@ -25,14 +25,34 @@ const binName = platform === "win32" ? "skillsgate-tui.exe" : "skillsgate-tui";
|
|
|
25
25
|
const require = createRequire(import.meta.url);
|
|
26
26
|
const thisDir = path.dirname(fileURLToPath(import.meta.url));
|
|
27
27
|
|
|
28
|
-
function
|
|
28
|
+
function getNpmGlobalRoot() {
|
|
29
|
+
try {
|
|
30
|
+
return execFileSync("npm", ["root", "-g"], {
|
|
31
|
+
encoding: "utf8",
|
|
32
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
33
|
+
timeout: 10000,
|
|
34
|
+
}).trim();
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function findBinary({ includeGlobal = false } = {}) {
|
|
29
41
|
const candidates = [
|
|
30
42
|
// Nested node_modules (local installs)
|
|
31
43
|
() => require.resolve(`@skillsgate/${pkg}/${binName}`),
|
|
32
44
|
// Sibling in global node_modules (npm install -g)
|
|
33
|
-
() => path.join(thisDir, "..", "..",
|
|
45
|
+
() => path.join(thisDir, "..", "..", `@skillsgate/${pkg}`, binName),
|
|
34
46
|
];
|
|
35
47
|
|
|
48
|
+
if (includeGlobal) {
|
|
49
|
+
candidates.push(() => {
|
|
50
|
+
const globalRoot = getNpmGlobalRoot();
|
|
51
|
+
if (!globalRoot) throw new Error("npm global root not found");
|
|
52
|
+
return path.join(globalRoot, `@skillsgate/${pkg}`, binName);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
36
56
|
for (const resolve of candidates) {
|
|
37
57
|
try {
|
|
38
58
|
const p = resolve();
|
|
@@ -54,9 +74,16 @@ if (!binPath) {
|
|
|
54
74
|
console.error(`Installing platform binary (${fullPkg})...`);
|
|
55
75
|
try {
|
|
56
76
|
execSync(`npm install -g ${fullPkg}`, { stdio: "inherit", timeout: 30000 });
|
|
57
|
-
binPath = findBinary();
|
|
77
|
+
binPath = findBinary({ includeGlobal: true });
|
|
58
78
|
} catch {
|
|
59
|
-
//
|
|
79
|
+
// Fallback to latest if exact version is not published for this platform
|
|
80
|
+
console.warn(`Could not install ${fullPkg}, falling back to @latest (versions may differ)`);
|
|
81
|
+
try {
|
|
82
|
+
execSync(`npm install -g @skillsgate/${pkg}@latest`, { stdio: "inherit", timeout: 30000 });
|
|
83
|
+
binPath = findBinary({ includeGlobal: true });
|
|
84
|
+
} catch {
|
|
85
|
+
binPath = findBinary({ includeGlobal: true });
|
|
86
|
+
}
|
|
60
87
|
}
|
|
61
88
|
}
|
|
62
89
|
|
package/package.json
CHANGED
|
@@ -15,6 +15,12 @@ export function AgentFilter() {
|
|
|
15
15
|
const { servers } = useDb()
|
|
16
16
|
|
|
17
17
|
const allCount = state.installedSkills.length
|
|
18
|
+
// Count favorites that are currently installed.
|
|
19
|
+
const favSet = new Set(state.favorites)
|
|
20
|
+
const favoritesCount = state.installedSkills.reduce(
|
|
21
|
+
(n, s) => (favSet.has(s.name) ? n + 1 : n),
|
|
22
|
+
0,
|
|
23
|
+
)
|
|
18
24
|
|
|
19
25
|
// Remote servers with skill counts
|
|
20
26
|
const serverList = servers.list()
|
|
@@ -38,7 +44,7 @@ export function AgentFilter() {
|
|
|
38
44
|
if (state.focusedPane !== "agents") return
|
|
39
45
|
if (state.showHelp) return
|
|
40
46
|
|
|
41
|
-
const allOptions = ["all", ...agentOptions.map((o) => o.value)]
|
|
47
|
+
const allOptions = ["all", "favorites", ...agentOptions.map((o) => o.value)]
|
|
42
48
|
const currentIdx = allOptions.indexOf(state.selectedAgentFilter)
|
|
43
49
|
|
|
44
50
|
if (key.name === "up" || (key.name === "k" && !key.ctrl)) {
|
|
@@ -84,6 +90,21 @@ export function AgentFilter() {
|
|
|
84
90
|
<text fg={colors.textDim}> ({allCount})</text>
|
|
85
91
|
</box>
|
|
86
92
|
|
|
93
|
+
{/* Favorites */}
|
|
94
|
+
<box
|
|
95
|
+
style={{
|
|
96
|
+
paddingLeft: 1,
|
|
97
|
+
paddingRight: 1,
|
|
98
|
+
height: 1,
|
|
99
|
+
backgroundColor: state.selectedAgentFilter === "favorites" ? colors.bgAlt : "transparent",
|
|
100
|
+
}}
|
|
101
|
+
>
|
|
102
|
+
<text fg={state.selectedAgentFilter === "favorites" ? colors.primary : colors.text}>
|
|
103
|
+
{"★ "}Favorites
|
|
104
|
+
</text>
|
|
105
|
+
<text fg={colors.textDim}> ({favoritesCount})</text>
|
|
106
|
+
</box>
|
|
107
|
+
|
|
87
108
|
{/* Spacer */}
|
|
88
109
|
<box style={{ height: 1 }}>
|
|
89
110
|
<text>{" "}</text>
|
|
@@ -10,6 +10,7 @@ const SHORTCUTS_LEFT: ShortcutEntry[] = [
|
|
|
10
10
|
{ key: "g", description: "Jump to first item" },
|
|
11
11
|
{ key: "G", description: "Jump to last item" },
|
|
12
12
|
{ key: "v", description: "View skill detail" },
|
|
13
|
+
{ key: "f", description: "Toggle favorite on selected skill" },
|
|
13
14
|
{ key: "n", description: "Create local skill (home)" },
|
|
14
15
|
{ key: "c", description: "Manage collections (home)" },
|
|
15
16
|
{ key: "/", description: "Focus search input" },
|
|
@@ -4,6 +4,7 @@ import { useStore, useDispatch } from "../store/context.js"
|
|
|
4
4
|
import { useDb } from "../db/context.js"
|
|
5
5
|
import { useDetectedAgents } from "../data/use-agents.js"
|
|
6
6
|
import { useInstalledSkills } from "../data/use-installed-skills.js"
|
|
7
|
+
import { useFavorites } from "../data/use-favorites.js"
|
|
7
8
|
import { StatusBar } from "./status-bar.js"
|
|
8
9
|
import { HelpOverlay } from "./help-overlay.js"
|
|
9
10
|
import { HomeView } from "../views/home.js"
|
|
@@ -38,6 +39,7 @@ export function Layout() {
|
|
|
38
39
|
// Load agent + skill data on mount
|
|
39
40
|
useDetectedAgents()
|
|
40
41
|
useInstalledSkills()
|
|
42
|
+
useFavorites()
|
|
41
43
|
|
|
42
44
|
// Global keyboard shortcuts
|
|
43
45
|
useKeyboard((key) => {
|
|
@@ -4,13 +4,14 @@ import { colors, agentBadges as badgeMap } from "../utils/colors.js"
|
|
|
4
4
|
interface SkillListItemProps {
|
|
5
5
|
skill: EnrichedSkill
|
|
6
6
|
selected?: boolean
|
|
7
|
+
favorited?: boolean
|
|
7
8
|
}
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Compact skill list item for the middle panel.
|
|
11
12
|
* Shows just the name and small agent dot indicators.
|
|
12
13
|
*/
|
|
13
|
-
export function SkillListItem({ skill, selected }: SkillListItemProps) {
|
|
14
|
+
export function SkillListItem({ skill, selected, favorited }: SkillListItemProps) {
|
|
14
15
|
// Build compact agent dots (single-char badges)
|
|
15
16
|
const agentDots = skill.agents.slice(0, 3).map((a) => {
|
|
16
17
|
const badge = badgeMap[a]
|
|
@@ -27,6 +28,11 @@ export function SkillListItem({ skill, selected }: SkillListItemProps) {
|
|
|
27
28
|
backgroundColor: selected ? colors.bgAlt : "transparent",
|
|
28
29
|
}}
|
|
29
30
|
>
|
|
31
|
+
{/* Star indicator (favorited skills only) */}
|
|
32
|
+
<text fg={favorited ? colors.warning : colors.textDim}>
|
|
33
|
+
{favorited ? "★ " : " "}
|
|
34
|
+
</text>
|
|
35
|
+
|
|
30
36
|
{/* Skill name */}
|
|
31
37
|
<text fg={selected ? colors.primary : colors.text} style={{ flexGrow: 1 }}>
|
|
32
38
|
{skill.name}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { useState, useEffect } from "react"
|
|
1
|
+
import { useState, useEffect, useMemo } from "react"
|
|
2
2
|
import { useKeyboard } from "@opentui/react"
|
|
3
3
|
import { useStore, useDispatch } from "../store/context.js"
|
|
4
4
|
import { useSkillActions } from "../data/use-skill-actions.js"
|
|
5
|
+
import { useFavorites } from "../data/use-favorites.js"
|
|
5
6
|
import { SkillListItem } from "./skill-list-item.js"
|
|
6
7
|
import { ConfirmDialog } from "./confirm-dialog.js"
|
|
7
8
|
import { colors, agentBadges as badgeMap } from "../utils/colors.js"
|
|
@@ -30,6 +31,8 @@ export function SkillList({ skills }: SkillListProps) {
|
|
|
30
31
|
const dispatch = useDispatch()
|
|
31
32
|
const state = useStore()
|
|
32
33
|
const { removeSkill, removeSkillFromOneAgent, updateSkill } = useSkillActions()
|
|
34
|
+
const { favorites, toggleFavorite } = useFavorites()
|
|
35
|
+
const favoritesSet = useMemo(() => new Set(favorites), [favorites])
|
|
33
36
|
const [selectedIndex, setSelectedIndex] = useState(0)
|
|
34
37
|
const [pendingAction, setPendingAction] = useState<PendingAction>(null)
|
|
35
38
|
const [removeMode, setRemoveMode] = useState<RemoveMode>(null)
|
|
@@ -114,6 +117,21 @@ export function SkillList({ skills }: SkillListProps) {
|
|
|
114
117
|
if (key.name === "u" && skills[selectedIndex]) {
|
|
115
118
|
setPendingAction({ type: "update", skill: skills[selectedIndex] })
|
|
116
119
|
}
|
|
120
|
+
|
|
121
|
+
// f to toggle favorite for selected skill
|
|
122
|
+
if (key.name === "f" && skills[selectedIndex]) {
|
|
123
|
+
const skill = skills[selectedIndex]
|
|
124
|
+
const isFavoritedAfter = toggleFavorite(skill.name)
|
|
125
|
+
dispatch({
|
|
126
|
+
type: "SHOW_NOTIFICATION",
|
|
127
|
+
notification: {
|
|
128
|
+
type: "info",
|
|
129
|
+
message: isFavoritedAfter
|
|
130
|
+
? `Added "${skill.name}" to favorites`
|
|
131
|
+
: `Removed "${skill.name}" from favorites`,
|
|
132
|
+
},
|
|
133
|
+
})
|
|
134
|
+
}
|
|
117
135
|
})
|
|
118
136
|
|
|
119
137
|
// Handle confirm/cancel for pending actions
|
|
@@ -201,7 +219,9 @@ export function SkillList({ skills }: SkillListProps) {
|
|
|
201
219
|
<text fg={colors.textDim}>
|
|
202
220
|
{state.installedLoading
|
|
203
221
|
? "Scanning for installed skills..."
|
|
204
|
-
:
|
|
222
|
+
: state.selectedAgentFilter === "favorites"
|
|
223
|
+
? "No favorites yet. Press f on any skill to save it here."
|
|
224
|
+
: "No skills found. Install skills with: skillsgate install <source>"}
|
|
205
225
|
</text>
|
|
206
226
|
</box>
|
|
207
227
|
)
|
|
@@ -237,6 +257,7 @@ export function SkillList({ skills }: SkillListProps) {
|
|
|
237
257
|
key={skill.name}
|
|
238
258
|
skill={skill}
|
|
239
259
|
selected={i === selectedIndex}
|
|
260
|
+
favorited={favoritesSet.has(skill.name)}
|
|
240
261
|
/>
|
|
241
262
|
))}
|
|
242
263
|
</scrollbox>
|
package/src/data/api-client.ts
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
// Re-export the shared trending browse helpers from the CLI core so the
|
|
2
|
+
// scraping/filtering logic lives in exactly one place.
|
|
3
|
+
export {
|
|
4
|
+
fetchTrending,
|
|
5
|
+
filterSkills,
|
|
6
|
+
type SkillsShSkill,
|
|
7
|
+
} from "../../../cli/src/core/skills-sh-client.js"
|
|
8
|
+
|
|
1
9
|
const SKILLS_SH_BASE = "https://skills.sh"
|
|
2
10
|
const GITHUB_API_BASE = "https://api.github.com"
|
|
3
11
|
const GITHUB_RAW_BASE = "https://raw.githubusercontent.com"
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { useCallback, useEffect } from "react"
|
|
2
|
+
import { useDispatch, useStore } from "../store/context.js"
|
|
3
|
+
import { useDb } from "../db/context.js"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Hydrates the favorites list from the local DB on mount and exposes a
|
|
7
|
+
* `toggleFavorite` callback that updates both DB and store state.
|
|
8
|
+
*/
|
|
9
|
+
export function useFavorites() {
|
|
10
|
+
const dispatch = useDispatch()
|
|
11
|
+
const state = useStore()
|
|
12
|
+
const { favorites } = useDb()
|
|
13
|
+
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
dispatch({ type: "SET_FAVORITES", favorites: favorites.list() })
|
|
16
|
+
}, [])
|
|
17
|
+
|
|
18
|
+
const toggleFavorite = useCallback(
|
|
19
|
+
(name: string): boolean => {
|
|
20
|
+
const next = favorites.toggle(name)
|
|
21
|
+
dispatch({ type: "SET_FAVORITES", favorites: favorites.list() })
|
|
22
|
+
return next
|
|
23
|
+
},
|
|
24
|
+
[favorites, dispatch],
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
favorites: state.favorites,
|
|
29
|
+
toggleFavorite,
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { useEffect, useState } from "react"
|
|
2
|
+
import { useDb } from "../db/context.js"
|
|
3
|
+
import {
|
|
4
|
+
loadTrendingCache,
|
|
5
|
+
saveTrendingCache,
|
|
6
|
+
} from "../db/trending-cache.js"
|
|
7
|
+
import { fetchTrending, type SkillsShSkill } from "./api-client.js"
|
|
8
|
+
|
|
9
|
+
interface UseTrendingResult {
|
|
10
|
+
trending: SkillsShSkill[]
|
|
11
|
+
loading: boolean
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Loads the ranked, most-installed skills for an instant local browse + filter.
|
|
16
|
+
* Reads a fresh local cache first; if it's stale or missing, scrapes the live
|
|
17
|
+
* list and persists it. A scrape failure is non-fatal — trending falls back to
|
|
18
|
+
* empty and the live search path still works.
|
|
19
|
+
*/
|
|
20
|
+
export function useTrending(): UseTrendingResult {
|
|
21
|
+
const { db } = useDb()
|
|
22
|
+
const [trending, setTrending] = useState<SkillsShSkill[]>([])
|
|
23
|
+
const [loading, setLoading] = useState(true)
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
let cancelled = false
|
|
27
|
+
|
|
28
|
+
const cached = loadTrendingCache(db)
|
|
29
|
+
if (cached) {
|
|
30
|
+
setTrending(cached)
|
|
31
|
+
setLoading(false)
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const load = async () => {
|
|
36
|
+
try {
|
|
37
|
+
const skills = await fetchTrending()
|
|
38
|
+
if (cancelled) return
|
|
39
|
+
saveTrendingCache(db, skills)
|
|
40
|
+
setTrending(skills)
|
|
41
|
+
} catch {
|
|
42
|
+
// Non-fatal: live search remains available without trending.
|
|
43
|
+
if (!cancelled) setTrending([])
|
|
44
|
+
} finally {
|
|
45
|
+
if (!cancelled) setLoading(false)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
void load()
|
|
50
|
+
|
|
51
|
+
return () => {
|
|
52
|
+
cancelled = true
|
|
53
|
+
}
|
|
54
|
+
}, [db])
|
|
55
|
+
|
|
56
|
+
return { trending, loading }
|
|
57
|
+
}
|
package/src/db/context.tsx
CHANGED
|
@@ -3,12 +3,14 @@ import type { Database } from "bun:sqlite"
|
|
|
3
3
|
import { SettingsStore } from "./settings.js"
|
|
4
4
|
import { RemoteServerStore } from "./servers.js"
|
|
5
5
|
import { RemoteSkillStore } from "./skills.js"
|
|
6
|
+
import { FavoritesStore } from "./favorites.js"
|
|
6
7
|
|
|
7
8
|
export interface DbContext {
|
|
8
9
|
db: Database
|
|
9
10
|
settings: SettingsStore
|
|
10
11
|
servers: RemoteServerStore
|
|
11
12
|
skills: RemoteSkillStore
|
|
13
|
+
favorites: FavoritesStore
|
|
12
14
|
}
|
|
13
15
|
|
|
14
16
|
const DbCtx = createContext<DbContext | null>(null)
|
|
@@ -24,6 +26,7 @@ export function DbProvider({ db, children }: DbProviderProps) {
|
|
|
24
26
|
settings: new SettingsStore(db),
|
|
25
27
|
servers: new RemoteServerStore(db),
|
|
26
28
|
skills: new RemoteSkillStore(db),
|
|
29
|
+
favorites: new FavoritesStore(db),
|
|
27
30
|
}
|
|
28
31
|
|
|
29
32
|
return <DbCtx.Provider value={ctx}>{children}</DbCtx.Provider>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite"
|
|
2
|
+
|
|
3
|
+
export class FavoritesStore {
|
|
4
|
+
private db: Database
|
|
5
|
+
|
|
6
|
+
constructor(db: Database) {
|
|
7
|
+
this.db = db
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
list(): string[] {
|
|
11
|
+
const rows = this.db
|
|
12
|
+
.query("SELECT skill_name FROM favorites ORDER BY created_at DESC")
|
|
13
|
+
.all() as Array<{ skill_name: string }>
|
|
14
|
+
return rows.map((r) => r.skill_name)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
has(name: string): boolean {
|
|
18
|
+
const row = this.db
|
|
19
|
+
.query("SELECT 1 FROM favorites WHERE skill_name = ?")
|
|
20
|
+
.get(name)
|
|
21
|
+
return row !== null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
toggle(name: string): boolean {
|
|
25
|
+
if (this.has(name)) {
|
|
26
|
+
this.db.query("DELETE FROM favorites WHERE skill_name = ?").run(name)
|
|
27
|
+
return false
|
|
28
|
+
}
|
|
29
|
+
this.db.query("INSERT INTO favorites (skill_name) VALUES (?)").run(name)
|
|
30
|
+
return true
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
count(): number {
|
|
34
|
+
const row = this.db
|
|
35
|
+
.query("SELECT COUNT(*) as c FROM favorites")
|
|
36
|
+
.get() as { c: number }
|
|
37
|
+
return row.c
|
|
38
|
+
}
|
|
39
|
+
}
|
package/src/db/migrations.ts
CHANGED
|
@@ -70,6 +70,29 @@ const MIGRATIONS: Migration[] = [
|
|
|
70
70
|
INSERT OR IGNORE INTO schema_version VALUES (2);
|
|
71
71
|
`,
|
|
72
72
|
},
|
|
73
|
+
{
|
|
74
|
+
version: 3,
|
|
75
|
+
up: `
|
|
76
|
+
CREATE TABLE IF NOT EXISTS favorites (
|
|
77
|
+
skill_name TEXT PRIMARY KEY,
|
|
78
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
INSERT OR IGNORE INTO schema_version VALUES (3);
|
|
82
|
+
`,
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
version: 4,
|
|
86
|
+
up: `
|
|
87
|
+
CREATE TABLE IF NOT EXISTS trending_cache (
|
|
88
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
89
|
+
fetched_at TEXT NOT NULL,
|
|
90
|
+
payload TEXT NOT NULL
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
INSERT OR IGNORE INTO schema_version VALUES (4);
|
|
94
|
+
`,
|
|
95
|
+
},
|
|
73
96
|
]
|
|
74
97
|
|
|
75
98
|
function getCurrentVersion(db: Database): number {
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite"
|
|
2
|
+
import type { SkillsShSkill } from "../../../cli/src/core/skills-sh-client.js"
|
|
3
|
+
|
|
4
|
+
// How long a cached trending payload stays fresh before a re-fetch is needed.
|
|
5
|
+
const TTL_MS = 6 * 60 * 60 * 1000 // 6 hours
|
|
6
|
+
|
|
7
|
+
interface TrendingCacheRow {
|
|
8
|
+
id: number
|
|
9
|
+
fetched_at: string
|
|
10
|
+
payload: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Returns the cached trending skills if the single-row cache exists and is
|
|
15
|
+
* still within the TTL window; otherwise returns null so the caller can
|
|
16
|
+
* re-fetch.
|
|
17
|
+
*/
|
|
18
|
+
export function loadTrendingCache(db: Database): SkillsShSkill[] | null {
|
|
19
|
+
const row = db
|
|
20
|
+
.query("SELECT * FROM trending_cache WHERE id = 1")
|
|
21
|
+
.get() as TrendingCacheRow | null
|
|
22
|
+
|
|
23
|
+
if (!row) return null
|
|
24
|
+
|
|
25
|
+
const fetchedAt = Date.parse(row.fetched_at)
|
|
26
|
+
if (Number.isNaN(fetchedAt)) return null
|
|
27
|
+
if (Date.now() - fetchedAt >= TTL_MS) return null
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
return JSON.parse(row.payload) as SkillsShSkill[]
|
|
31
|
+
} catch {
|
|
32
|
+
return null
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Upserts the single-row (id=1) trending cache with the given skills and the
|
|
38
|
+
* current timestamp.
|
|
39
|
+
*/
|
|
40
|
+
export function saveTrendingCache(db: Database, skills: SkillsShSkill[]): void {
|
|
41
|
+
db.query(
|
|
42
|
+
`INSERT OR REPLACE INTO trending_cache (id, fetched_at, payload)
|
|
43
|
+
VALUES (1, ?, ?)`
|
|
44
|
+
).run(new Date().toISOString(), JSON.stringify(skills))
|
|
45
|
+
}
|
package/src/store/reducers.ts
CHANGED
|
@@ -10,6 +10,7 @@ export const initialState: AppState = {
|
|
|
10
10
|
installedSkills: [],
|
|
11
11
|
installedLoading: true,
|
|
12
12
|
installedFilter: "",
|
|
13
|
+
favorites: [],
|
|
13
14
|
searchQuery: "",
|
|
14
15
|
searchResults: [],
|
|
15
16
|
searchLoading: false,
|
|
@@ -61,6 +62,9 @@ export function appReducer(state: AppState, action: Action): AppState {
|
|
|
61
62
|
case "SET_INSTALLED_FILTER":
|
|
62
63
|
return { ...state, installedFilter: action.filter }
|
|
63
64
|
|
|
65
|
+
case "SET_FAVORITES":
|
|
66
|
+
return { ...state, favorites: action.favorites }
|
|
67
|
+
|
|
64
68
|
case "SET_SEARCH_QUERY":
|
|
65
69
|
return { ...state, searchQuery: action.query }
|
|
66
70
|
|
package/src/store/types.ts
CHANGED
|
@@ -63,11 +63,14 @@ export interface AppState {
|
|
|
63
63
|
detectedAgents: DetectedAgent[]
|
|
64
64
|
|
|
65
65
|
// Installed skills (home view)
|
|
66
|
-
selectedAgentFilter: string // agent name or "
|
|
66
|
+
selectedAgentFilter: string // agent name, "all", or "favorites"
|
|
67
67
|
installedSkills: EnrichedSkill[]
|
|
68
68
|
installedLoading: boolean
|
|
69
69
|
installedFilter: string
|
|
70
70
|
|
|
71
|
+
// Favorites (skill names)
|
|
72
|
+
favorites: string[]
|
|
73
|
+
|
|
71
74
|
// Search / discover
|
|
72
75
|
searchQuery: string
|
|
73
76
|
searchResults: unknown[]
|
|
@@ -98,6 +101,7 @@ export type Action =
|
|
|
98
101
|
| { type: "SET_INSTALLED_SKILLS"; skills: EnrichedSkill[] }
|
|
99
102
|
| { type: "SET_INSTALLED_LOADING"; loading: boolean }
|
|
100
103
|
| { type: "SET_INSTALLED_FILTER"; filter: string }
|
|
104
|
+
| { type: "SET_FAVORITES"; favorites: string[] }
|
|
101
105
|
| { type: "SET_SEARCH_QUERY"; query: string }
|
|
102
106
|
| { type: "SET_SEARCH_RESULTS"; results: unknown[] }
|
|
103
107
|
| { type: "SET_SEARCH_LOADING"; loading: boolean }
|
package/src/views/discover.tsx
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
|
-
import { useState, useEffect } from "react"
|
|
1
|
+
import { useState, useEffect, useMemo } from "react"
|
|
2
2
|
import { useKeyboard } from "@opentui/react"
|
|
3
3
|
import { useStore, useDispatch } from "../store/context.js"
|
|
4
4
|
import { useSearch } from "../data/use-search.js"
|
|
5
|
+
import { useTrending } from "../data/use-trending.js"
|
|
5
6
|
import { useSkillActions } from "../data/use-skill-actions.js"
|
|
6
7
|
import { ConfirmDialog } from "../components/confirm-dialog.js"
|
|
7
|
-
import type { CatalogSkill } from "../data/api-client.js"
|
|
8
|
+
import type { CatalogSkill, SkillsShSkill } from "../data/api-client.js"
|
|
9
|
+
import { filterSkills } from "../data/api-client.js"
|
|
8
10
|
import { colors } from "../utils/colors.js"
|
|
9
11
|
|
|
12
|
+
// Sentinel selection index for the focusable "Official only" toggle row that
|
|
13
|
+
// sits above the skill rows in the list pane.
|
|
14
|
+
const TOGGLE_INDEX = -1
|
|
15
|
+
|
|
10
16
|
/**
|
|
11
17
|
* Discover view: two-column layout with search results.
|
|
12
18
|
* LEFT - Search input + results list (40%)
|
|
@@ -17,8 +23,9 @@ export function DiscoverView() {
|
|
|
17
23
|
const dispatch = useDispatch()
|
|
18
24
|
const [query, setQuery] = useState("")
|
|
19
25
|
const [selectedIndex, setSelectedIndex] = useState(0)
|
|
26
|
+
const [officialOnly, setOfficialOnly] = useState(false)
|
|
20
27
|
const [installTarget, setInstallTarget] = useState<CatalogSkill | null>(null)
|
|
21
|
-
const [previewSkill, setPreviewSkill] = useState<
|
|
28
|
+
const [previewSkill, setPreviewSkill] = useState<SkillsShSkill | null>(null)
|
|
22
29
|
|
|
23
30
|
// Auto-focus search input when Discover view mounts
|
|
24
31
|
useEffect(() => {
|
|
@@ -29,16 +36,47 @@ export function DiscoverView() {
|
|
|
29
36
|
|
|
30
37
|
const { results, loading, error, total, hasMore, loadMore } =
|
|
31
38
|
useSearch(query)
|
|
39
|
+
const { trending, loading: trendingLoading } = useTrending()
|
|
32
40
|
const { installSkill } = useSkillActions()
|
|
33
41
|
|
|
42
|
+
const isSearching = query.trim().length >= 2
|
|
43
|
+
|
|
44
|
+
// Compose the list shown to the user:
|
|
45
|
+
// - short query -> ranked trending (instant local browse)
|
|
46
|
+
// - 2+ chars -> local filter over trending FIRST, then live API results
|
|
47
|
+
// whose id is not already present, then optionally restrict to official.
|
|
48
|
+
const visibleSkills = useMemo<SkillsShSkill[]>(() => {
|
|
49
|
+
let combined: SkillsShSkill[]
|
|
50
|
+
if (!isSearching) {
|
|
51
|
+
combined = trending
|
|
52
|
+
} else {
|
|
53
|
+
const local = filterSkills(trending, query)
|
|
54
|
+
const seen = new Set(local.map((s) => s.id))
|
|
55
|
+
const remote = results.filter((s) => !seen.has(s.id))
|
|
56
|
+
combined = [...local, ...remote]
|
|
57
|
+
}
|
|
58
|
+
if (officialOnly) {
|
|
59
|
+
combined = combined.filter((s) => s.isOfficial === true)
|
|
60
|
+
}
|
|
61
|
+
return combined
|
|
62
|
+
}, [isSearching, query, trending, results, officialOnly])
|
|
63
|
+
|
|
64
|
+
// Clamp selection whenever the visible set changes.
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
setSelectedIndex((i) => {
|
|
67
|
+
if (i === TOGGLE_INDEX) return TOGGLE_INDEX
|
|
68
|
+
return Math.min(Math.max(0, i), Math.max(0, visibleSkills.length - 1))
|
|
69
|
+
})
|
|
70
|
+
}, [visibleSkills.length])
|
|
71
|
+
|
|
34
72
|
// Update preview when selection changes
|
|
35
73
|
useEffect(() => {
|
|
36
|
-
if (
|
|
37
|
-
setPreviewSkill(
|
|
74
|
+
if (selectedIndex >= 0 && visibleSkills[selectedIndex]) {
|
|
75
|
+
setPreviewSkill(visibleSkills[selectedIndex])
|
|
38
76
|
} else {
|
|
39
77
|
setPreviewSkill(null)
|
|
40
78
|
}
|
|
41
|
-
}, [selectedIndex,
|
|
79
|
+
}, [selectedIndex, visibleSkills])
|
|
42
80
|
|
|
43
81
|
// Keyboard navigation for the discover list
|
|
44
82
|
useKeyboard((key) => {
|
|
@@ -47,15 +85,24 @@ export function DiscoverView() {
|
|
|
47
85
|
if (state.focusedPane === "search") return
|
|
48
86
|
if (installTarget) return
|
|
49
87
|
|
|
50
|
-
//
|
|
88
|
+
// space/enter toggles the focusable "Official only" row when selected
|
|
89
|
+
if (
|
|
90
|
+
selectedIndex === TOGGLE_INDEX &&
|
|
91
|
+
(key.name === "space" || key.name === "return")
|
|
92
|
+
) {
|
|
93
|
+
setOfficialOnly((v) => !v)
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// j/k or arrow keys (TOGGLE_INDEX sits above the first skill row)
|
|
51
98
|
if (key.name === "up" || (key.name === "k" && !key.ctrl)) {
|
|
52
|
-
setSelectedIndex((i) => Math.max(
|
|
99
|
+
setSelectedIndex((i) => Math.max(TOGGLE_INDEX, i - 1))
|
|
53
100
|
}
|
|
54
101
|
if (key.name === "down" || (key.name === "j" && !key.ctrl)) {
|
|
55
102
|
setSelectedIndex((i) => {
|
|
56
|
-
const next = Math.min(
|
|
57
|
-
// If we're near the bottom and
|
|
58
|
-
if (next >=
|
|
103
|
+
const next = Math.min(visibleSkills.length - 1, i + 1)
|
|
104
|
+
// If we're near the bottom and the live API has more, load next page.
|
|
105
|
+
if (isSearching && next >= visibleSkills.length - 3 && hasMore && !loading) {
|
|
59
106
|
loadMore()
|
|
60
107
|
}
|
|
61
108
|
return next
|
|
@@ -67,12 +114,12 @@ export function DiscoverView() {
|
|
|
67
114
|
setSelectedIndex(0)
|
|
68
115
|
}
|
|
69
116
|
if (key.name === "g" && key.shift) {
|
|
70
|
-
setSelectedIndex(Math.max(0,
|
|
117
|
+
setSelectedIndex(Math.max(0, visibleSkills.length - 1))
|
|
71
118
|
}
|
|
72
119
|
|
|
73
120
|
// v to open full detail view
|
|
74
|
-
if (key.name === "v" &&
|
|
75
|
-
const skill =
|
|
121
|
+
if (key.name === "v" && visibleSkills[selectedIndex]) {
|
|
122
|
+
const skill = visibleSkills[selectedIndex]
|
|
76
123
|
dispatch({
|
|
77
124
|
type: "SELECT_SKILL",
|
|
78
125
|
skill: catalogSkillToEnriched(skill),
|
|
@@ -81,8 +128,8 @@ export function DiscoverView() {
|
|
|
81
128
|
}
|
|
82
129
|
|
|
83
130
|
// i to install
|
|
84
|
-
if (key.name === "i" &&
|
|
85
|
-
setInstallTarget(
|
|
131
|
+
if (key.name === "i" && visibleSkills[selectedIndex]) {
|
|
132
|
+
setInstallTarget(visibleSkills[selectedIndex])
|
|
86
133
|
return
|
|
87
134
|
}
|
|
88
135
|
})
|
|
@@ -143,13 +190,11 @@ export function DiscoverView() {
|
|
|
143
190
|
>
|
|
144
191
|
{/* Results info */}
|
|
145
192
|
<text fg={colors.textDim}>
|
|
146
|
-
{loading
|
|
193
|
+
{isSearching && loading
|
|
147
194
|
? "Loading..."
|
|
148
|
-
: error
|
|
195
|
+
: isSearching && error
|
|
149
196
|
? `Error: ${error}`
|
|
150
|
-
:
|
|
151
|
-
? `${results.length} result${results.length !== 1 ? "s" : ""}`
|
|
152
|
-
: `${results.length}/${total} skills`}
|
|
197
|
+
: `${visibleSkills.length} ${isSearching ? "result" : "skill"}${visibleSkills.length !== 1 ? "s" : ""}`}
|
|
153
198
|
</text>
|
|
154
199
|
</box>
|
|
155
200
|
|
|
@@ -164,15 +209,38 @@ export function DiscoverView() {
|
|
|
164
209
|
flexDirection: "column",
|
|
165
210
|
} as any}
|
|
166
211
|
>
|
|
167
|
-
{/*
|
|
212
|
+
{/* Section header */}
|
|
168
213
|
<box style={{ height: 1, paddingLeft: 1, backgroundColor: colors.bgAlt }}>
|
|
169
|
-
<text fg={colors.textDim}>
|
|
214
|
+
<text fg={colors.textDim}>{isSearching ? "Results" : "Trending"}</text>
|
|
215
|
+
</box>
|
|
216
|
+
|
|
217
|
+
{/* Focusable "Official only" filter row */}
|
|
218
|
+
<box
|
|
219
|
+
style={{
|
|
220
|
+
height: 1,
|
|
221
|
+
width: "100%",
|
|
222
|
+
paddingLeft: 1,
|
|
223
|
+
paddingRight: 1,
|
|
224
|
+
flexDirection: "row",
|
|
225
|
+
backgroundColor:
|
|
226
|
+
selectedIndex === TOGGLE_INDEX ? colors.bgAlt : "transparent",
|
|
227
|
+
}}
|
|
228
|
+
>
|
|
229
|
+
<text
|
|
230
|
+
fg={selectedIndex === TOGGLE_INDEX ? colors.primary : colors.textDim}
|
|
231
|
+
>
|
|
232
|
+
{officialOnly ? "[x]" : "[ ]"} Official only
|
|
233
|
+
</text>
|
|
170
234
|
</box>
|
|
171
235
|
|
|
172
|
-
{
|
|
236
|
+
{!isSearching && trendingLoading && trending.length === 0 ? (
|
|
237
|
+
<box style={{ padding: 1 }}>
|
|
238
|
+
<text fg={colors.textDim}>Loading popular skills…</text>
|
|
239
|
+
</box>
|
|
240
|
+
) : visibleSkills.length === 0 && !(isSearching && loading) ? (
|
|
173
241
|
<box style={{ padding: 1 }}>
|
|
174
242
|
<text fg={colors.textDim}>
|
|
175
|
-
{
|
|
243
|
+
{isSearching
|
|
176
244
|
? "No skills found matching your query."
|
|
177
245
|
: "No skills available in the catalog."}
|
|
178
246
|
</text>
|
|
@@ -194,7 +262,7 @@ export function DiscoverView() {
|
|
|
194
262
|
},
|
|
195
263
|
}}
|
|
196
264
|
>
|
|
197
|
-
{
|
|
265
|
+
{visibleSkills.map((skill, i) => (
|
|
198
266
|
<box
|
|
199
267
|
key={skill.id ?? `${skill.skillId}-${i}`}
|
|
200
268
|
style={{
|
|
@@ -205,12 +273,15 @@ export function DiscoverView() {
|
|
|
205
273
|
backgroundColor: i === selectedIndex ? colors.bgAlt : "transparent",
|
|
206
274
|
}}
|
|
207
275
|
>
|
|
276
|
+
{skill.isOfficial ? (
|
|
277
|
+
<text fg={colors.primary}>{"✓ "}</text>
|
|
278
|
+
) : null}
|
|
208
279
|
<text fg={i === selectedIndex ? colors.primary : colors.text}>
|
|
209
280
|
{skill.name}
|
|
210
281
|
</text>
|
|
211
282
|
</box>
|
|
212
283
|
))}
|
|
213
|
-
{hasMore && (
|
|
284
|
+
{isSearching && hasMore && (
|
|
214
285
|
<box style={{ paddingLeft: 1, height: 1 }}>
|
|
215
286
|
<text fg={colors.textDim}>
|
|
216
287
|
{loading ? "Loading more..." : "Scroll down to load more..."}
|
package/src/views/home.tsx
CHANGED
|
@@ -73,8 +73,11 @@ export function HomeView() {
|
|
|
73
73
|
skills = skills.filter((skill) => ids.has(skill.canonicalPath))
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
// Agent filter
|
|
77
|
-
if (state.selectedAgentFilter
|
|
76
|
+
// Agent filter (or favorites)
|
|
77
|
+
if (state.selectedAgentFilter === "favorites") {
|
|
78
|
+
const favSet = new Set(state.favorites)
|
|
79
|
+
skills = skills.filter((s) => favSet.has(s.name))
|
|
80
|
+
} else if (state.selectedAgentFilter !== "all") {
|
|
78
81
|
skills = skills.filter((s) =>
|
|
79
82
|
s.agents.includes(state.selectedAgentFilter as any)
|
|
80
83
|
)
|
|
@@ -96,7 +99,7 @@ export function HomeView() {
|
|
|
96
99
|
}
|
|
97
100
|
|
|
98
101
|
return skills
|
|
99
|
-
}, [state.installedSkills, state.selectedAgentFilter, state.installedFilter, selectedCollection, collections, collectionsVersion])
|
|
102
|
+
}, [state.installedSkills, state.selectedAgentFilter, state.installedFilter, state.favorites, selectedCollection, collections, collectionsVersion])
|
|
100
103
|
|
|
101
104
|
useKeyboard((key) => {
|
|
102
105
|
if (state.activeView !== "home") return
|
|
@@ -383,7 +386,7 @@ function DetailPanel({ skill, collections, selectedCollection }: DetailPanelProp
|
|
|
383
386
|
<text>{" "}</text>
|
|
384
387
|
|
|
385
388
|
{/* Shortcut hints */}
|
|
386
|
-
<text fg={colors.textDim}>v=view detail d=remove u=update n=create skill c=collections Tab=switch pane</text>
|
|
389
|
+
<text fg={colors.textDim}>v=view detail d=remove u=update f=favorite n=create skill c=collections Tab=switch pane</text>
|
|
387
390
|
<text fg={colors.border}>---</text>
|
|
388
391
|
|
|
389
392
|
{/* SKILL.md content */}
|