@skillsgate/tui 0.1.10 → 0.1.12
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/package.json +6 -6
- package/src/components/agent-filter.tsx +2 -2
- package/src/components/help-overlay.tsx +2 -0
- package/src/components/layout.tsx +10 -9
- package/src/data/use-installed-skills.ts +178 -3
- package/src/data/use-skill-actions.ts +19 -2
- package/src/db/skills.ts +24 -0
- package/src/db/ssh.ts +45 -1
- package/src/store/types.ts +8 -0
- package/src/types/bun-sqlite.d.ts +12 -0
- package/src/views/add-server.tsx +3 -3
- package/src/views/discover.tsx +10 -5
- package/src/views/favorites.tsx +7 -2
- package/src/views/home.tsx +531 -9
- package/src/views/login.tsx +1 -1
- package/src/views/server-skills.tsx +61 -4
- package/src/views/servers.tsx +2 -2
- package/src/views/skill-detail.tsx +3 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skillsgate/tui",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.12",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"skillsgate-tui": "bin/skillsgate-tui"
|
|
@@ -16,11 +16,11 @@
|
|
|
16
16
|
"gray-matter": "4.0.3"
|
|
17
17
|
},
|
|
18
18
|
"optionalDependencies": {
|
|
19
|
-
"@skillsgate/tui-darwin-arm64": "0.1.
|
|
20
|
-
"@skillsgate/tui-darwin-x64": "0.1.
|
|
21
|
-
"@skillsgate/tui-linux-x64": "0.1.
|
|
22
|
-
"@skillsgate/tui-linux-arm64": "0.1.
|
|
23
|
-
"@skillsgate/tui-win32-x64": "0.1.
|
|
19
|
+
"@skillsgate/tui-darwin-arm64": "0.1.12",
|
|
20
|
+
"@skillsgate/tui-darwin-x64": "0.1.12",
|
|
21
|
+
"@skillsgate/tui-linux-x64": "0.1.12",
|
|
22
|
+
"@skillsgate/tui-linux-arm64": "0.1.12",
|
|
23
|
+
"@skillsgate/tui-win32-x64": "0.1.12"
|
|
24
24
|
},
|
|
25
25
|
"peerDependencies": {
|
|
26
26
|
"react": "19.1.5"
|
|
@@ -58,11 +58,11 @@ export function AgentFilter() {
|
|
|
58
58
|
style={{
|
|
59
59
|
flexDirection: "column",
|
|
60
60
|
width: 22,
|
|
61
|
-
|
|
61
|
+
border: true,
|
|
62
62
|
borderColor: isFocused ? colors.primary : colors.border,
|
|
63
63
|
backgroundColor: colors.bg,
|
|
64
64
|
paddingTop: 0,
|
|
65
|
-
}}
|
|
65
|
+
} as any}
|
|
66
66
|
>
|
|
67
67
|
{/* Library section header */}
|
|
68
68
|
<box style={{ paddingLeft: 1, height: 1, backgroundColor: colors.bgAlt }}>
|
|
@@ -10,6 +10,8 @@ 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: "n", description: "Create local skill (home)" },
|
|
14
|
+
{ key: "c", description: "Manage collections (home)" },
|
|
13
15
|
{ key: "/", description: "Focus search input" },
|
|
14
16
|
{ key: "Tab", description: "Cycle focus: agents > search > list" },
|
|
15
17
|
{ key: "Esc", description: "Clear search / go back" },
|
|
@@ -95,9 +95,10 @@ export function Layout() {
|
|
|
95
95
|
if (state.showHelp) return
|
|
96
96
|
|
|
97
97
|
// Tab switching (only when not in detail/form views)
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
||
|
|
98
|
+
const activeView = state.activeView as string
|
|
99
|
+
const inFormView = activeView === "detail" || activeView === "add-server"
|
|
100
|
+
|| activeView === "edit-server" || activeView === "settings"
|
|
101
|
+
|| activeView === "server-skills" || activeView === "login"
|
|
101
102
|
if (!inFormView) {
|
|
102
103
|
if (key.name === "1") dispatch({ type: "NAVIGATE", view: "home" })
|
|
103
104
|
if (key.name === "2") dispatch({ type: "NAVIGATE", view: "discover" })
|
|
@@ -109,7 +110,7 @@ export function Layout() {
|
|
|
109
110
|
}
|
|
110
111
|
|
|
111
112
|
// "s" to open settings (only from home/favorites views when not in search)
|
|
112
|
-
if (key.name === "s" && state.focusedPane !== "search"
|
|
113
|
+
if (key.name === "s" && (state.focusedPane as string) !== "search"
|
|
113
114
|
&& state.activeView !== "discover" && state.activeView !== "detail"
|
|
114
115
|
&& !inFormView) {
|
|
115
116
|
dispatch({ type: "NAVIGATE", view: "settings" })
|
|
@@ -139,20 +140,20 @@ export function Layout() {
|
|
|
139
140
|
if (state.installedFilter) {
|
|
140
141
|
dispatch({ type: "SET_INSTALLED_FILTER", filter: "" })
|
|
141
142
|
}
|
|
142
|
-
if (state.focusedPane === "search") {
|
|
143
|
+
if ((state.focusedPane as string) === "search") {
|
|
143
144
|
dispatch({ type: "SET_FOCUSED_PANE", pane: "list" })
|
|
144
145
|
}
|
|
145
146
|
return
|
|
146
147
|
}
|
|
147
148
|
|
|
148
149
|
// "l" to navigate to login view (always -- allows re-login if token expired)
|
|
149
|
-
if (key.name === "l" && state.focusedPane !== "search" &&
|
|
150
|
+
if (key.name === "l" && (state.focusedPane as string) !== "search" && activeView !== "detail" && activeView !== "login") {
|
|
150
151
|
dispatch({ type: "NAVIGATE", view: "login" })
|
|
151
152
|
return
|
|
152
153
|
}
|
|
153
154
|
|
|
154
155
|
// "r" to refresh installed skills (when not typing in search, not on login view)
|
|
155
|
-
if (key.name === "r" && state.focusedPane !== "search" &&
|
|
156
|
+
if (key.name === "r" && (state.focusedPane as string) !== "search" && activeView !== "detail" && activeView !== "login") {
|
|
156
157
|
dispatch({ type: "REFRESH_SKILLS" })
|
|
157
158
|
return
|
|
158
159
|
}
|
|
@@ -186,7 +187,7 @@ export function Layout() {
|
|
|
186
187
|
}}
|
|
187
188
|
>
|
|
188
189
|
<text fg={colors.primary}>
|
|
189
|
-
<strong>SkillsGate TUI</strong> <span fg={colors.textDim}>v0.1.
|
|
190
|
+
<strong>SkillsGate TUI</strong> <span fg={colors.textDim}>v0.1.12</span>
|
|
190
191
|
</text>
|
|
191
192
|
</box>
|
|
192
193
|
|
|
@@ -194,7 +195,7 @@ export function Layout() {
|
|
|
194
195
|
<tab-select
|
|
195
196
|
options={TAB_OPTIONS}
|
|
196
197
|
focused={state.activeView !== "detail" && !state.showHelp}
|
|
197
|
-
selectedIndex
|
|
198
|
+
{...({ selectedIndex: activeTabIndex >= 0 ? activeTabIndex : 0 } as any)}
|
|
198
199
|
selectedBackgroundColor={colors.tabActive}
|
|
199
200
|
selectedTextColor={colors.tabText}
|
|
200
201
|
textColor={colors.textDim}
|
|
@@ -1,14 +1,97 @@
|
|
|
1
1
|
import { useEffect } from "react"
|
|
2
2
|
import fs from "node:fs/promises"
|
|
3
3
|
import path from "node:path"
|
|
4
|
+
import os from "node:os"
|
|
4
5
|
import matter from "gray-matter"
|
|
5
6
|
import { useStore, useDispatch } from "../store/context.js"
|
|
7
|
+
import { useDb } from "../db/context.js"
|
|
6
8
|
import { agents } from "../../../cli/src/core/agents.js"
|
|
7
9
|
import { readSkillLock } from "../../../cli/src/core/skill-lock.js"
|
|
8
10
|
import { SKILL_MD } from "../../../cli/src/constants.js"
|
|
9
11
|
import type { EnrichedSkill } from "../store/types.js"
|
|
10
12
|
import type { AgentType, SkillLockFile } from "../../../cli/src/types.js"
|
|
11
13
|
|
|
14
|
+
const home = os.homedir()
|
|
15
|
+
const PROJECT_PROBES = [
|
|
16
|
+
".claude/skills",
|
|
17
|
+
".cursor/skills",
|
|
18
|
+
".cursor/rules",
|
|
19
|
+
".codex/skills",
|
|
20
|
+
".github/skills",
|
|
21
|
+
".windsurf/skills",
|
|
22
|
+
".continue/skills",
|
|
23
|
+
".cline/skills",
|
|
24
|
+
".amp/skills",
|
|
25
|
+
".opencode/skills",
|
|
26
|
+
".goose/skills",
|
|
27
|
+
".junie/skills",
|
|
28
|
+
".kilo-code/skills",
|
|
29
|
+
".pear-ai/skills",
|
|
30
|
+
".roo-code/skills",
|
|
31
|
+
".trae/skills",
|
|
32
|
+
".zed/skills",
|
|
33
|
+
".agents/skills",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
function getScopeForPath(resolvedPath: string): "global" | "project" | "custom" {
|
|
37
|
+
const globalRoots = [
|
|
38
|
+
path.join(home, ".agents", "skills"),
|
|
39
|
+
...Object.values(agents).map((agent) => agent.globalSkillsDir),
|
|
40
|
+
].map((root) => path.resolve(root))
|
|
41
|
+
|
|
42
|
+
if (globalRoots.some((root) => resolvedPath.startsWith(root))) {
|
|
43
|
+
return "global"
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (resolvedPath.split(path.sep).some((segment) => segment.startsWith("."))) {
|
|
47
|
+
return "project"
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return "custom"
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function getProjectNameForPath(resolvedPath: string): string | null {
|
|
54
|
+
const parts = path.resolve(resolvedPath).split(path.sep).filter(Boolean)
|
|
55
|
+
for (let i = 1; i < parts.length; i++) {
|
|
56
|
+
if (parts[i].startsWith(".")) {
|
|
57
|
+
return parts[i - 1] || null
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function listSupportingFiles(skillDir: string): Promise<Array<{ relativePath: string; size: number }>> {
|
|
64
|
+
const files: Array<{ relativePath: string; size: number }> = []
|
|
65
|
+
|
|
66
|
+
async function walk(currentDir: string, prefix = ""): Promise<void> {
|
|
67
|
+
const entries = await fs.readdir(currentDir, { withFileTypes: true })
|
|
68
|
+
for (const entry of entries) {
|
|
69
|
+
const absolutePath = path.join(currentDir, entry.name)
|
|
70
|
+
const relativePath = prefix ? path.join(prefix, entry.name) : entry.name
|
|
71
|
+
|
|
72
|
+
if (entry.isDirectory()) {
|
|
73
|
+
await walk(absolutePath, relativePath)
|
|
74
|
+
continue
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!entry.isFile() || relativePath === SKILL_MD) continue
|
|
78
|
+
const stat = await fs.stat(absolutePath)
|
|
79
|
+
files.push({
|
|
80
|
+
relativePath: relativePath.split(path.sep).join("/"),
|
|
81
|
+
size: stat.size,
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
await walk(skillDir)
|
|
88
|
+
} catch {
|
|
89
|
+
return []
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return files.sort((a, b) => a.relativePath.localeCompare(b.relativePath))
|
|
93
|
+
}
|
|
94
|
+
|
|
12
95
|
/**
|
|
13
96
|
* Scans all detected agent globalSkillsDir paths for SKILL.md files,
|
|
14
97
|
* parses them with gray-matter, enriches with lock file data, and
|
|
@@ -17,6 +100,7 @@ import type { AgentType, SkillLockFile } from "../../../cli/src/types.js"
|
|
|
17
100
|
export function useInstalledSkills() {
|
|
18
101
|
const dispatch = useDispatch()
|
|
19
102
|
const { installedLoading } = useStore()
|
|
103
|
+
const { settings } = useDb()
|
|
20
104
|
|
|
21
105
|
useEffect(() => {
|
|
22
106
|
// Only scan when installedLoading is true (initial mount or refresh triggered)
|
|
@@ -41,26 +125,37 @@ export function useInstalledSkills() {
|
|
|
41
125
|
// Include both real directories and symlinks (skills are often symlinked)
|
|
42
126
|
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue
|
|
43
127
|
|
|
44
|
-
const
|
|
128
|
+
const skillDirPath = path.join(skillsDir, entry.name)
|
|
129
|
+
const canonicalPath = await fs.realpath(skillDirPath).catch(() => skillDirPath)
|
|
130
|
+
const skillMdPath = path.join(skillDirPath, SKILL_MD)
|
|
45
131
|
try {
|
|
46
132
|
const raw = await fs.readFile(skillMdPath, "utf-8")
|
|
47
133
|
const { data: frontmatter } = matter(raw)
|
|
48
134
|
const skillName = entry.name
|
|
135
|
+
const canonicalPath = await fs.realpath(skillDirPath).catch(() => skillDirPath)
|
|
136
|
+
const scope = getScopeForPath(canonicalPath)
|
|
137
|
+
const supportingFiles = await listSupportingFiles(canonicalPath)
|
|
49
138
|
|
|
50
|
-
const existing = skillMap.get(
|
|
139
|
+
const existing = skillMap.get(canonicalPath)
|
|
51
140
|
if (existing) {
|
|
52
141
|
// Skill already seen from another agent - add this agent
|
|
53
142
|
if (!existing.agents.includes(agent.name)) {
|
|
54
143
|
existing.agents.push(agent.name)
|
|
55
144
|
}
|
|
56
145
|
} else {
|
|
57
|
-
skillMap.set(
|
|
146
|
+
skillMap.set(canonicalPath, {
|
|
58
147
|
name: skillName,
|
|
59
148
|
description:
|
|
60
149
|
(frontmatter.description as string) ??
|
|
61
150
|
extractFirstLine(raw),
|
|
62
151
|
filePath: skillMdPath,
|
|
152
|
+
canonicalPath,
|
|
63
153
|
agents: [agent.name],
|
|
154
|
+
scope,
|
|
155
|
+
projectName:
|
|
156
|
+
scope === "project" ? getProjectNameForPath(canonicalPath) : null,
|
|
157
|
+
hasSupportingFiles: supportingFiles.length > 0,
|
|
158
|
+
supportingFiles,
|
|
64
159
|
metadata: frontmatter as Record<string, unknown>,
|
|
65
160
|
lock: lock.skills[skillName],
|
|
66
161
|
})
|
|
@@ -74,6 +169,17 @@ export function useInstalledSkills() {
|
|
|
74
169
|
}
|
|
75
170
|
}
|
|
76
171
|
|
|
172
|
+
const customScanPaths = settings.get<string[]>("scan.customPaths", [])
|
|
173
|
+
for (const customPath of customScanPaths) {
|
|
174
|
+
const resolvedRoot = path.resolve(customPath.replace(/^~(?=$|\/|\\)/, home))
|
|
175
|
+
const collected = await collectCustomSkills(resolvedRoot, lock)
|
|
176
|
+
for (const skill of collected) {
|
|
177
|
+
if (!skillMap.has(skill.canonicalPath)) {
|
|
178
|
+
skillMap.set(skill.canonicalPath, skill)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
77
183
|
if (cancelled) return
|
|
78
184
|
|
|
79
185
|
const skills = Array.from(skillMap.values()).sort((a, b) =>
|
|
@@ -103,6 +209,75 @@ export function useInstalledSkills() {
|
|
|
103
209
|
}, [installedLoading])
|
|
104
210
|
}
|
|
105
211
|
|
|
212
|
+
async function collectCustomSkills(
|
|
213
|
+
rootPath: string,
|
|
214
|
+
lock: SkillLockFile,
|
|
215
|
+
): Promise<EnrichedSkill[]> {
|
|
216
|
+
const results: EnrichedSkill[] = []
|
|
217
|
+
|
|
218
|
+
async function maybeCollect(skillDir: string, scope: "project" | "custom") {
|
|
219
|
+
const skillMdPath = path.join(skillDir, SKILL_MD)
|
|
220
|
+
try {
|
|
221
|
+
const raw = await fs.readFile(skillMdPath, "utf-8")
|
|
222
|
+
const { data: frontmatter } = matter(raw)
|
|
223
|
+
const canonicalPath = await fs.realpath(skillDir).catch(() => skillDir)
|
|
224
|
+
const folderName = path.basename(skillDir)
|
|
225
|
+
const supportingFiles = await listSupportingFiles(canonicalPath)
|
|
226
|
+
results.push({
|
|
227
|
+
name: String((frontmatter.name as string) ?? folderName),
|
|
228
|
+
description:
|
|
229
|
+
(frontmatter.description as string) ?? extractFirstLine(raw),
|
|
230
|
+
filePath: skillMdPath,
|
|
231
|
+
canonicalPath,
|
|
232
|
+
agents: [],
|
|
233
|
+
scope,
|
|
234
|
+
projectName:
|
|
235
|
+
scope === "project" ? getProjectNameForPath(canonicalPath) : null,
|
|
236
|
+
hasSupportingFiles: supportingFiles.length > 0,
|
|
237
|
+
supportingFiles,
|
|
238
|
+
metadata: frontmatter as Record<string, unknown>,
|
|
239
|
+
lock: lock.skills[folderName],
|
|
240
|
+
})
|
|
241
|
+
} catch {
|
|
242
|
+
// ignore
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
await maybeCollect(rootPath, "custom")
|
|
247
|
+
|
|
248
|
+
let entries: Array<{ name: string; isDirectory: () => boolean }> = []
|
|
249
|
+
try {
|
|
250
|
+
entries = await fs.readdir(rootPath, { withFileTypes: true })
|
|
251
|
+
} catch {
|
|
252
|
+
return results
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
for (const entry of entries) {
|
|
256
|
+
if (!entry.isDirectory()) continue
|
|
257
|
+
await maybeCollect(path.join(rootPath, entry.name), "custom")
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
for (const entry of entries) {
|
|
261
|
+
if (!entry.isDirectory()) continue
|
|
262
|
+
const projectRoot = path.join(rootPath, entry.name)
|
|
263
|
+
for (const probe of PROJECT_PROBES) {
|
|
264
|
+
const probeDir = path.join(projectRoot, probe)
|
|
265
|
+
let probeEntries: Array<{ name: string; isDirectory: () => boolean }> = []
|
|
266
|
+
try {
|
|
267
|
+
probeEntries = await fs.readdir(probeDir, { withFileTypes: true })
|
|
268
|
+
} catch {
|
|
269
|
+
continue
|
|
270
|
+
}
|
|
271
|
+
for (const skillEntry of probeEntries) {
|
|
272
|
+
if (!skillEntry.isDirectory()) continue
|
|
273
|
+
await maybeCollect(path.join(probeDir, skillEntry.name), "project")
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return results
|
|
279
|
+
}
|
|
280
|
+
|
|
106
281
|
/** Extracts the first non-empty, non-heading line from markdown content. */
|
|
107
282
|
function extractFirstLine(content: string): string {
|
|
108
283
|
const lines = content.split("\n")
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useCallback } from "react"
|
|
2
2
|
import { useStore, useDispatch } from "../store/context.js"
|
|
3
|
+
import { useDb } from "../db/context.js"
|
|
3
4
|
import type { EnrichedSkill } from "../store/types.js"
|
|
4
5
|
|
|
5
6
|
// CLI core imports -- these share the same Bun runtime
|
|
@@ -36,6 +37,7 @@ interface UseSkillActionsResult {
|
|
|
36
37
|
export function useSkillActions(): UseSkillActionsResult {
|
|
37
38
|
const state = useStore()
|
|
38
39
|
const dispatch = useDispatch()
|
|
40
|
+
const { settings } = useDb()
|
|
39
41
|
|
|
40
42
|
/**
|
|
41
43
|
* Install a skill from its source (GitHub URL, SkillsGate slug, or install command).
|
|
@@ -69,6 +71,21 @@ export function useSkillActions(): UseSkillActionsResult {
|
|
|
69
71
|
return
|
|
70
72
|
}
|
|
71
73
|
|
|
74
|
+
const defaultAgents = settings.get<string[]>("install.defaultAgents", [])
|
|
75
|
+
const mirrorAgents = settings.get<string[]>("sync.mirrorAgents", [])
|
|
76
|
+
const preferredNames =
|
|
77
|
+
defaultAgents.length > 0
|
|
78
|
+
? Array.from(new Set([...defaultAgents, ...mirrorAgents]))
|
|
79
|
+
: Array.from(
|
|
80
|
+
new Set([
|
|
81
|
+
...installedAgents.map((agent) => agent.name),
|
|
82
|
+
...mirrorAgents,
|
|
83
|
+
]),
|
|
84
|
+
)
|
|
85
|
+
const targetAgents = installedAgents.filter((agent) =>
|
|
86
|
+
preferredNames.includes(agent.name),
|
|
87
|
+
)
|
|
88
|
+
|
|
72
89
|
let tmpDir: string
|
|
73
90
|
if (source.type === "skillsgate") {
|
|
74
91
|
// Download from SkillsGate API
|
|
@@ -112,7 +129,7 @@ export function useSkillActions(): UseSkillActionsResult {
|
|
|
112
129
|
|
|
113
130
|
for (const skillToInstall of targetSkills) {
|
|
114
131
|
// Install to all detected agents
|
|
115
|
-
for (const agent of
|
|
132
|
+
for (const agent of targetAgents) {
|
|
116
133
|
const result = await installSkillForAgent(
|
|
117
134
|
skillToInstall,
|
|
118
135
|
agent,
|
|
@@ -141,7 +158,7 @@ export function useSkillActions(): UseSkillActionsResult {
|
|
|
141
158
|
type: "SHOW_NOTIFICATION",
|
|
142
159
|
notification: {
|
|
143
160
|
type: "success",
|
|
144
|
-
message: `Installed ${targetSkills.length} skill(s): ${skillNames} to ${
|
|
161
|
+
message: `Installed ${targetSkills.length} skill(s): ${skillNames} to ${targetAgents.length} agent(s)`,
|
|
145
162
|
},
|
|
146
163
|
})
|
|
147
164
|
} finally {
|
package/src/db/skills.ts
CHANGED
|
@@ -135,4 +135,28 @@ export class RemoteSkillStore {
|
|
|
135
135
|
.get(id) as { content: string | null } | null
|
|
136
136
|
return row?.content ?? null
|
|
137
137
|
}
|
|
138
|
+
|
|
139
|
+
getByPath(serverId: string, remotePath: string): RemoteSkill | null {
|
|
140
|
+
const row = this.db
|
|
141
|
+
.query(
|
|
142
|
+
"SELECT * FROM remote_skills WHERE server_id = ? AND remote_path = ? LIMIT 1",
|
|
143
|
+
)
|
|
144
|
+
.get(serverId, remotePath) as SkillRow | null
|
|
145
|
+
return row ? rowToSkill(row) : null
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
updateContent(
|
|
149
|
+
serverId: string,
|
|
150
|
+
remotePath: string,
|
|
151
|
+
content: string,
|
|
152
|
+
contentHash: string,
|
|
153
|
+
): void {
|
|
154
|
+
this.db
|
|
155
|
+
.query(
|
|
156
|
+
`UPDATE remote_skills
|
|
157
|
+
SET content = ?, content_hash = ?, synced_at = datetime('now')
|
|
158
|
+
WHERE server_id = ? AND remote_path = ?`,
|
|
159
|
+
)
|
|
160
|
+
.run(content, contentHash, serverId, remotePath)
|
|
161
|
+
}
|
|
138
162
|
}
|
package/src/db/ssh.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { spawn } from "node:child_process"
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process"
|
|
2
2
|
import os from "node:os"
|
|
3
3
|
import path from "node:path"
|
|
4
4
|
import fs from "node:fs"
|
|
@@ -95,6 +95,50 @@ export async function testConnection(
|
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
export async function readRemoteFile(
|
|
99
|
+
server: RemoteServer,
|
|
100
|
+
remotePath: string,
|
|
101
|
+
): Promise<string> {
|
|
102
|
+
const escaped = `'${remotePath.replace(/'/g, "'\\''")}'`
|
|
103
|
+
const result = await sshExec(server, `cat ${escaped}`)
|
|
104
|
+
if (result.exitCode !== 0) {
|
|
105
|
+
throw new Error(result.stderr.trim() || "Failed to read remote file")
|
|
106
|
+
}
|
|
107
|
+
return result.stdout
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function writeRemoteFile(
|
|
111
|
+
server: RemoteServer,
|
|
112
|
+
remotePath: string,
|
|
113
|
+
content: string,
|
|
114
|
+
): Promise<void> {
|
|
115
|
+
await new Promise<void>((resolve, reject) => {
|
|
116
|
+
const escaped = `'${remotePath.replace(/'/g, "'\\''")}'`
|
|
117
|
+
const command = `mkdir -p "$(dirname ${escaped})" && cat > ${escaped}`
|
|
118
|
+
const args = [...buildSshArgs(server), command]
|
|
119
|
+
const proc = spawn("ssh", args, {
|
|
120
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
let stderr = ""
|
|
124
|
+
proc.stderr.on("data", (data: Buffer) => {
|
|
125
|
+
stderr += data.toString("utf-8")
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
proc.on("error", (err) => reject(new Error(err.message)))
|
|
129
|
+
proc.on("close", (code) => {
|
|
130
|
+
if ((code ?? 1) === 0) {
|
|
131
|
+
resolve()
|
|
132
|
+
} else {
|
|
133
|
+
reject(new Error(stderr.trim() || `SSH exited with code ${code ?? 1}`))
|
|
134
|
+
}
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
proc.stdin.write(content, "utf-8")
|
|
138
|
+
proc.stdin.end()
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
|
|
98
142
|
// ---------- Remote Skill Scanner ----------
|
|
99
143
|
|
|
100
144
|
function shellQuotePath(remotePath: string): string {
|
package/src/store/types.ts
CHANGED
|
@@ -20,8 +20,16 @@ export interface EnrichedSkill {
|
|
|
20
20
|
name: string
|
|
21
21
|
description: string
|
|
22
22
|
filePath: string
|
|
23
|
+
canonicalPath: string
|
|
23
24
|
/** Which agents have this skill installed (by agent name) */
|
|
24
25
|
agents: AgentType[]
|
|
26
|
+
scope: "global" | "project" | "custom"
|
|
27
|
+
projectName: string | null
|
|
28
|
+
hasSupportingFiles: boolean
|
|
29
|
+
supportingFiles: Array<{
|
|
30
|
+
relativePath: string
|
|
31
|
+
size: number
|
|
32
|
+
}>
|
|
25
33
|
/** Frontmatter metadata from the SKILL.md */
|
|
26
34
|
metadata: Record<string, unknown>
|
|
27
35
|
/** Lock file entry if tracked */
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
declare module "bun:sqlite" {
|
|
2
|
+
export class Database {
|
|
3
|
+
constructor(path: string)
|
|
4
|
+
query(sql: string): {
|
|
5
|
+
all(...params: unknown[]): unknown[]
|
|
6
|
+
get(...params: unknown[]): unknown
|
|
7
|
+
run(...params: unknown[]): { changes: number }
|
|
8
|
+
}
|
|
9
|
+
exec(sql: string): void
|
|
10
|
+
close(): void
|
|
11
|
+
}
|
|
12
|
+
}
|
package/src/views/add-server.tsx
CHANGED
|
@@ -201,16 +201,16 @@ export function AddServerView({ editServerId, onServerCountChange }: AddServerVi
|
|
|
201
201
|
<input
|
|
202
202
|
placeholder={field.placeholder}
|
|
203
203
|
focused={i === focusedFieldIndex && !state.showHelp && !saving}
|
|
204
|
-
defaultValue
|
|
204
|
+
{...({ defaultValue: values[field.name] } as any)}
|
|
205
205
|
onInput={(value: string) => handleFieldChange(field.name, value)}
|
|
206
|
-
onSubmit={() => {
|
|
206
|
+
onSubmit={(() => {
|
|
207
207
|
// When pressing Enter on the last field, save
|
|
208
208
|
if (i === FIELDS.length - 1) {
|
|
209
209
|
handleSave()
|
|
210
210
|
} else {
|
|
211
211
|
setFocusedFieldIndex(i + 1)
|
|
212
212
|
}
|
|
213
|
-
}}
|
|
213
|
+
}) as any}
|
|
214
214
|
/>
|
|
215
215
|
</box>
|
|
216
216
|
</box>
|
package/src/views/discover.tsx
CHANGED
|
@@ -100,7 +100,7 @@ export function DiscoverView() {
|
|
|
100
100
|
if (searchMode === "keyword") {
|
|
101
101
|
if (!isAuthenticated) {
|
|
102
102
|
dispatch({
|
|
103
|
-
type: "
|
|
103
|
+
type: "SHOW_NOTIFICATION",
|
|
104
104
|
notification: { type: "info", message: "Sign in to use AI search (press l)" },
|
|
105
105
|
})
|
|
106
106
|
return
|
|
@@ -154,10 +154,10 @@ export function DiscoverView() {
|
|
|
154
154
|
: "Search by keyword... (Enter to search)"
|
|
155
155
|
}
|
|
156
156
|
focused={state.activeView === "discover" && !state.showHelp}
|
|
157
|
-
onSubmit={(value: string) => {
|
|
157
|
+
onSubmit={((value: string) => {
|
|
158
158
|
setQuery(value)
|
|
159
159
|
setSelectedIndex(0)
|
|
160
|
-
}}
|
|
160
|
+
}) as any}
|
|
161
161
|
/>
|
|
162
162
|
) : (
|
|
163
163
|
<text fg={colors.textDim}>/ to search, Tab to cycle panes</text>
|
|
@@ -213,10 +213,10 @@ export function DiscoverView() {
|
|
|
213
213
|
<box
|
|
214
214
|
style={{
|
|
215
215
|
width: "40%",
|
|
216
|
-
|
|
216
|
+
border: true,
|
|
217
217
|
borderColor: state.focusedPane === "list" ? colors.primary : colors.border,
|
|
218
218
|
flexDirection: "column",
|
|
219
|
-
}}
|
|
219
|
+
} as any}
|
|
220
220
|
>
|
|
221
221
|
{/* List header */}
|
|
222
222
|
<box style={{ height: 1, paddingLeft: 1, backgroundColor: colors.bgAlt }}>
|
|
@@ -396,7 +396,12 @@ function catalogSkillToEnriched(skill: CatalogSkill): import("../store/types.js"
|
|
|
396
396
|
name: skill.name,
|
|
397
397
|
description: skill.summary || skill.description || "",
|
|
398
398
|
filePath: "", // No local file for catalog items
|
|
399
|
+
canonicalPath: "",
|
|
399
400
|
agents: [],
|
|
401
|
+
scope: "custom",
|
|
402
|
+
projectName: null,
|
|
403
|
+
hasSupportingFiles: false,
|
|
404
|
+
supportingFiles: [],
|
|
400
405
|
metadata: {
|
|
401
406
|
categories: skill.categories,
|
|
402
407
|
capabilities: skill.capabilities,
|
package/src/views/favorites.tsx
CHANGED
|
@@ -164,10 +164,10 @@ export function FavoritesView() {
|
|
|
164
164
|
<box
|
|
165
165
|
style={{
|
|
166
166
|
width: "40%",
|
|
167
|
-
|
|
167
|
+
border: true,
|
|
168
168
|
borderColor: colors.border,
|
|
169
169
|
flexDirection: "column",
|
|
170
|
-
}}
|
|
170
|
+
} as any}
|
|
171
171
|
>
|
|
172
172
|
{/* List header */}
|
|
173
173
|
<box style={{ height: 1, paddingLeft: 1, backgroundColor: colors.bgAlt }}>
|
|
@@ -336,7 +336,12 @@ function catalogSkillToEnriched(
|
|
|
336
336
|
name: skill.name,
|
|
337
337
|
description: skill.summary || skill.description || "",
|
|
338
338
|
filePath: "",
|
|
339
|
+
canonicalPath: "",
|
|
339
340
|
agents: [],
|
|
341
|
+
scope: "custom",
|
|
342
|
+
projectName: null,
|
|
343
|
+
hasSupportingFiles: false,
|
|
344
|
+
supportingFiles: [],
|
|
340
345
|
metadata: {
|
|
341
346
|
categories: skill.categories,
|
|
342
347
|
capabilities: skill.capabilities,
|