@skillsgate/tui 0.1.9 → 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.
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env node
2
- import { execFileSync } from "node:child_process";
2
+ import { execFileSync, execSync } from "node:child_process";
3
3
  import { createRequire } from "node:module";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import path from "node:path";
6
+ import fs from "node:fs";
6
7
  import os from "node:os";
7
8
 
8
9
  const platform = os.platform();
@@ -20,35 +21,47 @@ if (!pkg) {
20
21
  process.exit(1);
21
22
  }
22
23
 
23
- // Try multiple resolution strategies since global installs place optional
24
- // deps as siblings rather than nested under this package's node_modules.
25
24
  const binName = platform === "win32" ? "skillsgate-tui.exe" : "skillsgate-tui";
26
25
  const require = createRequire(import.meta.url);
27
26
  const thisDir = path.dirname(fileURLToPath(import.meta.url));
28
27
 
29
- const candidates = [
30
- // 1. Nested node_modules (local installs)
31
- () => require.resolve(`@skillsgate/${pkg}/${binName}`),
32
- // 2. Sibling in global node_modules (npm install -g)
33
- // thisDir = .../node_modules/@skillsgate/tui/bin go up 3 to node_modules
34
- () => path.join(thisDir, "..", "..", "..", `@skillsgate/${pkg}`, binName),
35
- ];
28
+ function findBinary() {
29
+ const candidates = [
30
+ // Nested node_modules (local installs)
31
+ () => require.resolve(`@skillsgate/${pkg}/${binName}`),
32
+ // Sibling in global node_modules (npm install -g)
33
+ () => path.join(thisDir, "..", "..", "..", `@skillsgate/${pkg}`, binName),
34
+ ];
36
35
 
37
- let binPath = null;
38
- for (const resolve of candidates) {
36
+ for (const resolve of candidates) {
37
+ try {
38
+ const p = resolve();
39
+ fs.accessSync(p, fs.constants.X_OK);
40
+ return p;
41
+ } catch {
42
+ continue;
43
+ }
44
+ }
45
+ return null;
46
+ }
47
+
48
+ let binPath = findBinary();
49
+
50
+ // Auto-install the platform binary if missing (npm v11+ skips optionalDeps for global installs)
51
+ if (!binPath) {
52
+ const version = require("../package.json").version;
53
+ const fullPkg = `@skillsgate/${pkg}@${version}`;
54
+ console.error(`Installing platform binary (${fullPkg})...`);
39
55
  try {
40
- const p = resolve();
41
- // Verify it exists by importing fs synchronously
42
- require("fs").accessSync(p, require("fs").constants.X_OK);
43
- binPath = p;
44
- break;
56
+ execSync(`npm install -g ${fullPkg}`, { stdio: "inherit", timeout: 30000 });
57
+ binPath = findBinary();
45
58
  } catch {
46
- continue;
59
+ // fall through to error below
47
60
  }
48
61
  }
49
62
 
50
63
  if (!binPath) {
51
- console.error("Platform binary not found. Try reinstalling: npm install -g @skillsgate/tui");
64
+ console.error(`Platform binary not found. Run manually: npm install -g @skillsgate/${pkg}`);
52
65
  process.exit(1);
53
66
  }
54
67
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skillsgate/tui",
3
- "version": "0.1.9",
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.9",
20
- "@skillsgate/tui-darwin-x64": "0.1.9",
21
- "@skillsgate/tui-linux-x64": "0.1.9",
22
- "@skillsgate/tui-linux-arm64": "0.1.9",
23
- "@skillsgate/tui-win32-x64": "0.1.9"
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
- borderRight: true,
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 inFormView = state.activeView === "detail" || state.activeView === "add-server"
99
- || state.activeView === "edit-server" || state.activeView === "settings"
100
- || state.activeView === "server-skills" || state.activeView === "login"
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" && state.activeView !== "detail" && state.activeView !== "login") {
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" && state.activeView !== "detail" && state.activeView !== "login") {
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.0</span>
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={activeTabIndex >= 0 ? activeTabIndex : 0}
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 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,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 installedAgents) {
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 ${installedAgents.length} agent(s)`,
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 {
@@ -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>
@@ -100,7 +100,7 @@ export function DiscoverView() {
100
100
  if (searchMode === "keyword") {
101
101
  if (!isAuthenticated) {
102
102
  dispatch({
103
- type: "SET_NOTIFICATION",
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
- borderRight: true,
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,