@skillsgate/tui 0.1.10 → 0.1.13
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 +1 -8
- package/src/components/agent-filter.tsx +2 -2
- package/src/components/help-overlay.tsx +4 -4
- package/src/components/layout.tsx +15 -40
- package/src/components/status-bar.tsx +4 -7
- package/src/data/api-client.ts +78 -109
- package/src/data/use-favorites.ts +18 -4
- package/src/data/use-installed-skills.ts +178 -3
- package/src/data/use-search.ts +14 -31
- package/src/data/use-skill-actions.ts +87 -18
- 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 +42 -130
- package/src/views/favorites.tsx +10 -349
- 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/settings.tsx +0 -6
- package/src/views/skill-detail.tsx +17 -16
- package/tmp.json +0 -0
|
@@ -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")
|
package/src/data/use-search.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useState, useEffect, useRef, useCallback } from "react"
|
|
2
|
-
import {
|
|
2
|
+
import { searchSkills, type CatalogSkill } from "./api-client.js"
|
|
3
3
|
|
|
4
4
|
const DEBOUNCE_MS = 300
|
|
5
5
|
const PAGE_SIZE = 20
|
|
@@ -11,35 +11,28 @@ interface UseSearchResult {
|
|
|
11
11
|
total: number
|
|
12
12
|
hasMore: boolean
|
|
13
13
|
loadMore: () => void
|
|
14
|
-
remainingSearches: number | null
|
|
15
14
|
}
|
|
16
15
|
|
|
17
16
|
/**
|
|
18
17
|
* Hook that manages search state with debounce and pagination.
|
|
19
|
-
* - When query is empty, loads
|
|
18
|
+
* - When query is empty, loads popular skills
|
|
20
19
|
* - When query is provided, searches after 300ms debounce
|
|
21
|
-
* - Supports keyword and semantic search modes
|
|
22
20
|
*/
|
|
23
|
-
export function useSearch(
|
|
24
|
-
query: string,
|
|
25
|
-
mode: SearchMode,
|
|
26
|
-
token?: string | null
|
|
27
|
-
): UseSearchResult {
|
|
21
|
+
export function useSearch(query: string): UseSearchResult {
|
|
28
22
|
const [results, setResults] = useState<CatalogSkill[]>([])
|
|
29
23
|
const [loading, setLoading] = useState(false)
|
|
30
24
|
const [error, setError] = useState<string | null>(null)
|
|
31
25
|
const [total, setTotal] = useState(0)
|
|
32
26
|
const [offset, setOffset] = useState(0)
|
|
33
|
-
const [remainingSearches, setRemainingSearches] = useState<number | null>(null)
|
|
34
27
|
|
|
35
28
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
36
29
|
|
|
37
|
-
// Reset pagination when query
|
|
30
|
+
// Reset pagination when query changes
|
|
38
31
|
useEffect(() => {
|
|
39
32
|
setResults([])
|
|
40
33
|
setOffset(0)
|
|
41
34
|
setError(null)
|
|
42
|
-
}, [query
|
|
35
|
+
}, [query])
|
|
43
36
|
|
|
44
37
|
useEffect(() => {
|
|
45
38
|
if (timerRef.current) {
|
|
@@ -52,19 +45,10 @@ export function useSearch(
|
|
|
52
45
|
setError(null)
|
|
53
46
|
|
|
54
47
|
try {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
if (data.remainingSearches !== undefined) {
|
|
60
|
-
setRemainingSearches(data.remainingSearches)
|
|
61
|
-
}
|
|
62
|
-
} else {
|
|
63
|
-
const data = await fetchCatalog(PAGE_SIZE, 0)
|
|
64
|
-
setResults(data.skills)
|
|
65
|
-
setTotal(data.total)
|
|
66
|
-
setOffset(PAGE_SIZE)
|
|
67
|
-
}
|
|
48
|
+
const data = await searchSkills(query, PAGE_SIZE)
|
|
49
|
+
setResults(data.skills)
|
|
50
|
+
setTotal(data.total)
|
|
51
|
+
setOffset(PAGE_SIZE)
|
|
68
52
|
} catch (err) {
|
|
69
53
|
const msg = err instanceof Error ? err.message : String(err)
|
|
70
54
|
if (!msg.includes("abort")) {
|
|
@@ -86,17 +70,17 @@ export function useSearch(
|
|
|
86
70
|
clearTimeout(timerRef.current)
|
|
87
71
|
}
|
|
88
72
|
}
|
|
89
|
-
}, [query
|
|
73
|
+
}, [query])
|
|
90
74
|
|
|
91
75
|
const loadMore = useCallback(async () => {
|
|
92
|
-
if (
|
|
93
|
-
if (results.length >= total) return
|
|
76
|
+
if (loading) return
|
|
77
|
+
if (results.length >= total && total > 0) return
|
|
94
78
|
|
|
95
79
|
setLoading(true)
|
|
96
80
|
try {
|
|
97
|
-
const data = await
|
|
81
|
+
const data = await searchSkills(query, PAGE_SIZE, offset)
|
|
98
82
|
setResults((prev) => [...prev, ...data.skills])
|
|
99
|
-
setTotal(data.total)
|
|
83
|
+
setTotal((prev) => prev + data.total)
|
|
100
84
|
setOffset((prev) => prev + PAGE_SIZE)
|
|
101
85
|
} catch (err) {
|
|
102
86
|
const msg = err instanceof Error ? err.message : String(err)
|
|
@@ -113,6 +97,5 @@ export function useSearch(
|
|
|
113
97
|
total,
|
|
114
98
|
hasMore: results.length < total,
|
|
115
99
|
loadMore,
|
|
116
|
-
remainingSearches,
|
|
117
100
|
}
|
|
118
101
|
}
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { useCallback } from "react"
|
|
2
|
+
import { exec as execCb } from "node:child_process"
|
|
3
|
+
import { promisify } from "node:util"
|
|
2
4
|
import { useStore, useDispatch } from "../store/context.js"
|
|
3
|
-
import
|
|
5
|
+
import { useDb } from "../db/context.js"
|
|
6
|
+
import type { EnrichedSkill, Action } from "../store/types.js"
|
|
4
7
|
|
|
5
8
|
// CLI core imports -- these share the same Bun runtime
|
|
6
9
|
import { parseSource } from "../../../cli/src/core/source-parser.js"
|
|
7
|
-
import {
|
|
10
|
+
import { cleanupTempDir, fetchTreeSha } from "../../../cli/src/core/git.js"
|
|
8
11
|
import { discoverSkills } from "../../../cli/src/core/skill-discovery.js"
|
|
9
12
|
import {
|
|
10
13
|
installSkillForAgent,
|
|
@@ -15,12 +18,11 @@ import {
|
|
|
15
18
|
import {
|
|
16
19
|
addSkillToLock,
|
|
17
20
|
removeSkillFromLock,
|
|
18
|
-
readSkillLock,
|
|
19
21
|
} from "../../../cli/src/core/skill-lock.js"
|
|
20
22
|
import { agents, detectInstalledAgents } from "../../../cli/src/core/agents.js"
|
|
21
23
|
import { downloadSkill } from "../../../cli/src/core/skillsgate-client.js"
|
|
22
24
|
import { getToken } from "../../../cli/src/utils/auth-store.js"
|
|
23
|
-
import type { Skill, AgentConfig
|
|
25
|
+
import type { Skill, AgentConfig } from "../../../cli/src/types.js"
|
|
24
26
|
|
|
25
27
|
interface UseSkillActionsResult {
|
|
26
28
|
installSkill: (skill: EnrichedSkill) => Promise<void>
|
|
@@ -36,9 +38,12 @@ interface UseSkillActionsResult {
|
|
|
36
38
|
export function useSkillActions(): UseSkillActionsResult {
|
|
37
39
|
const state = useStore()
|
|
38
40
|
const dispatch = useDispatch()
|
|
41
|
+
const { settings } = useDb()
|
|
39
42
|
|
|
40
43
|
/**
|
|
41
|
-
* Install a skill from its source
|
|
44
|
+
* Install a skill from its source.
|
|
45
|
+
* For public skills (with a source in owner/repo format), runs `npx skills add`.
|
|
46
|
+
* For private skills, uses the existing download flow.
|
|
42
47
|
*/
|
|
43
48
|
const installSkill = useCallback(async (skill: EnrichedSkill) => {
|
|
44
49
|
dispatch({
|
|
@@ -59,7 +64,16 @@ export function useSkillActions(): UseSkillActionsResult {
|
|
|
59
64
|
|
|
60
65
|
const source = parseSource(sourceStr)
|
|
61
66
|
|
|
62
|
-
//
|
|
67
|
+
// Public skills (owner/repo format): use `npx skills add`
|
|
68
|
+
if (source.type === "github" || isOwnerRepoFormat(sourceStr)) {
|
|
69
|
+
const repo = source.type === "github"
|
|
70
|
+
? `${source.owner}/${source.repo}`
|
|
71
|
+
: sourceStr
|
|
72
|
+
await runSkillsAdd(repo, dispatch)
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Private skills: use the existing download flow
|
|
63
77
|
const installedAgents = await detectInstalledAgents()
|
|
64
78
|
if (installedAgents.length === 0) {
|
|
65
79
|
dispatch({
|
|
@@ -69,21 +83,33 @@ export function useSkillActions(): UseSkillActionsResult {
|
|
|
69
83
|
return
|
|
70
84
|
}
|
|
71
85
|
|
|
86
|
+
const defaultAgents = settings.get<string[]>("install.defaultAgents", [])
|
|
87
|
+
const mirrorAgents = settings.get<string[]>("sync.mirrorAgents", [])
|
|
88
|
+
const preferredNames =
|
|
89
|
+
defaultAgents.length > 0
|
|
90
|
+
? Array.from(new Set([...defaultAgents, ...mirrorAgents]))
|
|
91
|
+
: Array.from(
|
|
92
|
+
new Set([
|
|
93
|
+
...installedAgents.map((agent) => agent.name),
|
|
94
|
+
...mirrorAgents,
|
|
95
|
+
]),
|
|
96
|
+
)
|
|
97
|
+
const targetAgents = installedAgents.filter((agent) =>
|
|
98
|
+
preferredNames.includes(agent.name),
|
|
99
|
+
)
|
|
100
|
+
|
|
72
101
|
let tmpDir: string
|
|
73
102
|
if (source.type === "skillsgate") {
|
|
74
|
-
// Download from
|
|
103
|
+
// Download from private API
|
|
75
104
|
const token = await getToken()
|
|
76
105
|
tmpDir = await downloadSkill(source.username!, source.slug!, token)
|
|
77
|
-
} else if (source.type === "github") {
|
|
78
|
-
// Clone from GitHub
|
|
79
|
-
tmpDir = await cloneRepo(source)
|
|
80
106
|
} else {
|
|
81
107
|
// Local path -- use directly
|
|
82
108
|
tmpDir = source.localPath!
|
|
83
109
|
}
|
|
84
110
|
|
|
85
111
|
try {
|
|
86
|
-
// Discover skills in the
|
|
112
|
+
// Discover skills in the downloaded directory
|
|
87
113
|
const skills = await discoverSkills(tmpDir, source.subpath)
|
|
88
114
|
|
|
89
115
|
if (skills.length === 0) {
|
|
@@ -112,7 +138,7 @@ export function useSkillActions(): UseSkillActionsResult {
|
|
|
112
138
|
|
|
113
139
|
for (const skillToInstall of targetSkills) {
|
|
114
140
|
// Install to all detected agents
|
|
115
|
-
for (const agent of
|
|
141
|
+
for (const agent of targetAgents) {
|
|
116
142
|
const result = await installSkillForAgent(
|
|
117
143
|
skillToInstall,
|
|
118
144
|
agent,
|
|
@@ -141,7 +167,7 @@ export function useSkillActions(): UseSkillActionsResult {
|
|
|
141
167
|
type: "SHOW_NOTIFICATION",
|
|
142
168
|
notification: {
|
|
143
169
|
type: "success",
|
|
144
|
-
message: `Installed ${targetSkills.length} skill(s): ${skillNames} to ${
|
|
170
|
+
message: `Installed ${targetSkills.length} skill(s): ${skillNames} to ${targetAgents.length} agent(s)`,
|
|
145
171
|
},
|
|
146
172
|
})
|
|
147
173
|
} finally {
|
|
@@ -303,9 +329,49 @@ export function useSkillActions(): UseSkillActionsResult {
|
|
|
303
329
|
|
|
304
330
|
// ---------- Helpers ----------
|
|
305
331
|
|
|
332
|
+
const execAsync = promisify(execCb)
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Checks if a string matches the owner/repo format (e.g. "vercel/skills").
|
|
336
|
+
*/
|
|
337
|
+
function isOwnerRepoFormat(str: string): boolean {
|
|
338
|
+
return /^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/.test(str)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Runs `npx skills add <source> --all -y` as a child process to install
|
|
343
|
+
* all skills from a public repository.
|
|
344
|
+
*/
|
|
345
|
+
async function runSkillsAdd(
|
|
346
|
+
source: string,
|
|
347
|
+
dispatch: (action: Action) => void
|
|
348
|
+
): Promise<void> {
|
|
349
|
+
try {
|
|
350
|
+
await execAsync(
|
|
351
|
+
`npx skills add ${source} --all -y`,
|
|
352
|
+
{ timeout: 60_000 }
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
dispatch({ type: "REFRESH_SKILLS" })
|
|
356
|
+
dispatch({
|
|
357
|
+
type: "SHOW_NOTIFICATION",
|
|
358
|
+
notification: {
|
|
359
|
+
type: "success",
|
|
360
|
+
message: `Installed skills from ${source}`,
|
|
361
|
+
},
|
|
362
|
+
})
|
|
363
|
+
} catch (err) {
|
|
364
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
365
|
+
dispatch({
|
|
366
|
+
type: "SHOW_NOTIFICATION",
|
|
367
|
+
notification: { type: "error", message: `Install failed: ${msg}` },
|
|
368
|
+
})
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
306
372
|
/**
|
|
307
373
|
* Resolves the source string for a skill from its metadata.
|
|
308
|
-
* Checks: lock.source, metadata.githubUrl, metadata.installCommand
|
|
374
|
+
* Checks: lock.source, metadata.source, metadata.githubUrl, metadata.installCommand
|
|
309
375
|
*/
|
|
310
376
|
function resolveSource(skill: EnrichedSkill): string | null {
|
|
311
377
|
// From lock file entry
|
|
@@ -313,17 +379,20 @@ function resolveSource(skill: EnrichedSkill): string | null {
|
|
|
313
379
|
return skill.lock.source
|
|
314
380
|
}
|
|
315
381
|
|
|
316
|
-
// From metadata (catalog skills)
|
|
382
|
+
// From metadata (catalog skills -- owner/repo format)
|
|
317
383
|
const meta = skill.metadata
|
|
384
|
+
if (meta?.source && typeof meta.source === "string") {
|
|
385
|
+
return meta.source
|
|
386
|
+
}
|
|
387
|
+
|
|
318
388
|
if (meta?.githubUrl && typeof meta.githubUrl === "string") {
|
|
319
389
|
return meta.githubUrl
|
|
320
390
|
}
|
|
321
391
|
|
|
322
|
-
// From install command (e.g. "
|
|
392
|
+
// From install command (e.g. "skills add <source>")
|
|
323
393
|
if (meta?.installCommand && typeof meta.installCommand === "string") {
|
|
324
394
|
const cmd = meta.installCommand as string
|
|
325
|
-
|
|
326
|
-
const match = cmd.match(/skillsgate\s+(?:add|install)\s+(.+)/)
|
|
395
|
+
const match = cmd.match(/skills?\s+(?:add|install)\s+(.+)/)
|
|
327
396
|
if (match) {
|
|
328
397
|
return match[1].trim()
|
|
329
398
|
}
|
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>
|