@skillsgate/tui 0.2.0 → 0.2.4

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.
@@ -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, "..", "..", "..", `@skillsgate/${pkg}`);
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 @skillsgate/${pkg}@${version} --no-save`, {
32
+ execSync(`npm install -g ${fullPkg} --no-save`, {
32
33
  stdio: "inherit",
33
34
  timeout: 30000,
34
35
  });
35
36
  } catch {
36
- console.warn(`Warning: could not install @skillsgate/${pkg}. Run manually: npm install -g @skillsgate/${pkg}`);
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
  }
@@ -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 findBinary() {
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, "..", "..", "..", `@skillsgate/${pkg}`, binName),
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
- // fall through to error below
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skillsgate/tui",
3
- "version": "0.2.0",
3
+ "version": "0.2.4",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "skillsgate-tui": "bin/skillsgate-tui"
@@ -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
- : "No skills found. Install skills with: skillsgate install <source>"}
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>
@@ -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
+ }
@@ -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
+ }
@@ -70,6 +70,17 @@ 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
+ },
73
84
  ]
74
85
 
75
86
  function getCurrentVersion(db: Database): number {
@@ -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
 
@@ -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 "all"
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 }
@@ -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 !== "all") {
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 */}