@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.
@@ -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 skillMdPath = path.join(skillsDir, entry.name, SKILL_MD)
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(skillName)
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(skillName, {
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,5 @@
1
1
  import { useState, useEffect, useRef, useCallback } from "react"
2
- import { fetchCatalog, searchSkills, type CatalogSkill, type SearchMode } from "./api-client.js"
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 the catalog with pagination
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 or mode changes
30
+ // Reset pagination when query changes
38
31
  useEffect(() => {
39
32
  setResults([])
40
33
  setOffset(0)
41
34
  setError(null)
42
- }, [query, mode])
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
- if (query.trim()) {
56
- const data = await searchSkills(query, mode, token)
57
- setResults(data.skills)
58
- setTotal(data.total)
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, mode, token])
73
+ }, [query])
90
74
 
91
75
  const loadMore = useCallback(async () => {
92
- if (query.trim() || loading) return
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 fetchCatalog(PAGE_SIZE, offset)
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 type { EnrichedSkill } from "../store/types.js"
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 { cloneRepo, cleanupTempDir, fetchTreeSha } from "../../../cli/src/core/git.js"
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, ParsedSource } from "../../../cli/src/types.js"
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 (GitHub URL, SkillsGate slug, or install command).
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
- // Detect installed agents to install to
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 SkillsGate API
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 cloned/downloaded directory
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 installedAgents) {
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 ${installedAgents.length} agent(s)`,
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. "skillsgate add @user/slug")
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
- // Extract the source from "skillsgate add <source>" or "skillsgate install <source>"
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 {
@@ -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
+ }
@@ -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={values[field.name]}
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>