@skillsgate/tui 0.1.14 → 0.2.0
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 -1
- package/src/components/help-overlay.tsx +1 -0
- package/src/data/api-client.ts +0 -4
- package/src/data/use-skill-actions.ts +2 -14
- package/src/db/local-skills.ts +64 -0
- package/src/db/push.ts +220 -0
- package/src/db/ssh.ts +122 -12
- package/src/store/reducers.ts +0 -12
- package/src/store/types.ts +0 -19
- package/src/utils/colors.ts +2 -0
- package/src/views/push-dialog.tsx +251 -0
- package/src/views/servers.tsx +32 -7
- package/src/data/use-auth.ts +0 -136
- package/src/data/use-favorites.ts +0 -161
- package/src/views/favorites.tsx +0 -19
- package/src/views/login.tsx +0 -202
package/package.json
CHANGED
|
@@ -29,6 +29,7 @@ const SHORTCUTS_RIGHT: ShortcutEntry[] = [
|
|
|
29
29
|
{ key: "", description: "" },
|
|
30
30
|
{ key: "-- Servers View --", description: "" },
|
|
31
31
|
{ key: "S", description: "Sync selected server" },
|
|
32
|
+
{ key: "P", description: "push local skills to selected server" },
|
|
32
33
|
{ key: "a", description: "Add new server" },
|
|
33
34
|
{ key: "e", description: "Edit server" },
|
|
34
35
|
{ key: "t", description: "Test connection" },
|
package/src/data/api-client.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
const SKILLSGATE_API_BASE = process.env.SKILLSGATE_SEARCH_API_URL ?? "https://api.skillsgate.ai"
|
|
2
1
|
const SKILLS_SH_BASE = "https://skills.sh"
|
|
3
2
|
const GITHUB_API_BASE = "https://api.github.com"
|
|
4
3
|
const GITHUB_RAW_BASE = "https://raw.githubusercontent.com"
|
|
@@ -115,6 +114,3 @@ export async function fetchSkillContent(
|
|
|
115
114
|
return null
|
|
116
115
|
}
|
|
117
116
|
|
|
118
|
-
// ---------- SkillsGate API (auth, favorites, private skills) ----------
|
|
119
|
-
|
|
120
|
-
export { SKILLSGATE_API_BASE }
|
|
@@ -20,8 +20,6 @@ import {
|
|
|
20
20
|
removeSkillFromLock,
|
|
21
21
|
} from "../../../cli/src/core/skill-lock.js"
|
|
22
22
|
import { agents, detectInstalledAgents } from "../../../cli/src/core/agents.js"
|
|
23
|
-
import { downloadSkill } from "../../../cli/src/core/skillsgate-client.js"
|
|
24
|
-
import { getToken } from "../../../cli/src/utils/auth-store.js"
|
|
25
23
|
import type { Skill, AgentConfig } from "../../../cli/src/types.js"
|
|
26
24
|
|
|
27
25
|
interface UseSkillActionsResult {
|
|
@@ -43,7 +41,6 @@ export function useSkillActions(): UseSkillActionsResult {
|
|
|
43
41
|
/**
|
|
44
42
|
* Install a skill from its source.
|
|
45
43
|
* For public skills (with a source in owner/repo format), runs `npx skills add`.
|
|
46
|
-
* For private skills, uses the existing download flow.
|
|
47
44
|
*/
|
|
48
45
|
const installSkill = useCallback(async (skill: EnrichedSkill) => {
|
|
49
46
|
dispatch({
|
|
@@ -73,7 +70,6 @@ export function useSkillActions(): UseSkillActionsResult {
|
|
|
73
70
|
return
|
|
74
71
|
}
|
|
75
72
|
|
|
76
|
-
// Private skills: use the existing download flow
|
|
77
73
|
const installedAgents = await detectInstalledAgents()
|
|
78
74
|
if (installedAgents.length === 0) {
|
|
79
75
|
dispatch({
|
|
@@ -98,15 +94,8 @@ export function useSkillActions(): UseSkillActionsResult {
|
|
|
98
94
|
preferredNames.includes(agent.name),
|
|
99
95
|
)
|
|
100
96
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
// Download from private API
|
|
104
|
-
const token = await getToken()
|
|
105
|
-
tmpDir = await downloadSkill(source.username!, source.slug!, token)
|
|
106
|
-
} else {
|
|
107
|
-
// Local path -- use directly
|
|
108
|
-
tmpDir = source.localPath!
|
|
109
|
-
}
|
|
97
|
+
// Local path source uses resolved path directly
|
|
98
|
+
const tmpDir = source.localPath!
|
|
110
99
|
|
|
111
100
|
try {
|
|
112
101
|
// Discover skills in the downloaded directory
|
|
@@ -277,7 +266,6 @@ export function useSkillActions(): UseSkillActionsResult {
|
|
|
277
266
|
/**
|
|
278
267
|
* Update a skill by re-fetching from its source.
|
|
279
268
|
* For GitHub skills: checks tree SHA for changes before re-installing.
|
|
280
|
-
* For SkillsGate skills: always re-downloads.
|
|
281
269
|
*/
|
|
282
270
|
const updateSkill = useCallback(async (skill: EnrichedSkill) => {
|
|
283
271
|
if (!skill.lock) {
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// packages/tui/src/db/local-skills.ts
|
|
2
|
+
/**
|
|
3
|
+
* Pure async scanner for local canonical skills under ~/.agents/skills.
|
|
4
|
+
* Returns the minimal shape needed by the push orchestrator.
|
|
5
|
+
* Does NOT depend on React, the store, or the DB layer.
|
|
6
|
+
*/
|
|
7
|
+
import fs from "node:fs/promises"
|
|
8
|
+
import path from "node:path"
|
|
9
|
+
import os from "node:os"
|
|
10
|
+
|
|
11
|
+
const home = os.homedir()
|
|
12
|
+
export const CANONICAL_SKILLS_DIR = path.join(home, ".agents", "skills")
|
|
13
|
+
|
|
14
|
+
export interface LocalCanonicalSkill {
|
|
15
|
+
folderName: string
|
|
16
|
+
canonicalPath: string
|
|
17
|
+
name: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* List all skill directories under ~/.agents/skills that contain a SKILL.md.
|
|
22
|
+
* Uses the folder name as both folderName and name (SKILL.md name field is
|
|
23
|
+
* parsed by the push orchestrator's hash step, which reads the file again).
|
|
24
|
+
*/
|
|
25
|
+
export async function listLocalCanonicalSkills(): Promise<LocalCanonicalSkill[]> {
|
|
26
|
+
const results: LocalCanonicalSkill[] = []
|
|
27
|
+
let names: string[]
|
|
28
|
+
try {
|
|
29
|
+
names = await fs.readdir(CANONICAL_SKILLS_DIR)
|
|
30
|
+
} catch {
|
|
31
|
+
return []
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
for (const name of names) {
|
|
35
|
+
const skillDir = path.join(CANONICAL_SKILLS_DIR, name)
|
|
36
|
+
let stat: Awaited<ReturnType<typeof fs.stat>>
|
|
37
|
+
try {
|
|
38
|
+
stat = await fs.stat(skillDir)
|
|
39
|
+
} catch {
|
|
40
|
+
continue // broken symlink or unreadable
|
|
41
|
+
}
|
|
42
|
+
if (!stat.isDirectory()) continue
|
|
43
|
+
|
|
44
|
+
let canonicalPath: string
|
|
45
|
+
try {
|
|
46
|
+
canonicalPath = await fs.realpath(skillDir)
|
|
47
|
+
} catch {
|
|
48
|
+
continue
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const skillMdPath = path.join(canonicalPath, "SKILL.md")
|
|
52
|
+
try {
|
|
53
|
+
await fs.access(skillMdPath)
|
|
54
|
+
} catch {
|
|
55
|
+
continue // no SKILL.md
|
|
56
|
+
}
|
|
57
|
+
results.push({
|
|
58
|
+
folderName: name,
|
|
59
|
+
canonicalPath,
|
|
60
|
+
name,
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
return results
|
|
64
|
+
}
|
package/src/db/push.ts
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// packages/tui/src/db/push.ts
|
|
2
|
+
import crypto from "node:crypto"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
import { promises as fs } from "node:fs"
|
|
5
|
+
|
|
6
|
+
import type { RemoteServer } from "./servers.js"
|
|
7
|
+
import {
|
|
8
|
+
scanRemoteSkills,
|
|
9
|
+
uploadSkillDir,
|
|
10
|
+
deleteRemoteSkillDir,
|
|
11
|
+
} from "./ssh.js"
|
|
12
|
+
import { listLocalCanonicalSkills, CANONICAL_SKILLS_DIR } from "./local-skills.js"
|
|
13
|
+
|
|
14
|
+
export interface PushPlanEntry {
|
|
15
|
+
folderName: string
|
|
16
|
+
name: string
|
|
17
|
+
localPath: string
|
|
18
|
+
remotePath: string
|
|
19
|
+
remoteDir: string
|
|
20
|
+
reason: "added" | "updated" | "deleted" | "unchanged"
|
|
21
|
+
localHash?: string
|
|
22
|
+
remoteHash?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface PushPreview {
|
|
26
|
+
toAdd: PushPlanEntry[]
|
|
27
|
+
toUpdate: PushPlanEntry[]
|
|
28
|
+
toDelete: PushPlanEntry[]
|
|
29
|
+
unchanged: PushPlanEntry[]
|
|
30
|
+
mirror: boolean
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface PushResult {
|
|
34
|
+
added: number
|
|
35
|
+
updated: number
|
|
36
|
+
deleted: number
|
|
37
|
+
unchanged: number
|
|
38
|
+
errors: { folderName: string; message: string }[]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface PushOptions {
|
|
42
|
+
mirror: boolean
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function sha256(s: string): string {
|
|
46
|
+
return crypto.createHash("sha256").update(s, "utf-8").digest("hex")
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Resolve the remote skills base path to an absolute path relative to the server,
|
|
51
|
+
* dropping a trailing slash. We don't expand "~/" here -- that happens server-side
|
|
52
|
+
* via $HOME inside shellQuotePath.
|
|
53
|
+
*/
|
|
54
|
+
function normalizeRemoteBase(p: string): string {
|
|
55
|
+
return p.replace(/\/+$/, "")
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Diff local canonical skills against the remote scan. Returns the plan.
|
|
60
|
+
* Does NOT execute anything.
|
|
61
|
+
*/
|
|
62
|
+
export async function planPush(
|
|
63
|
+
server: RemoteServer,
|
|
64
|
+
options: PushOptions,
|
|
65
|
+
): Promise<PushPreview> {
|
|
66
|
+
// 1. List local canonical skills under ~/.agents/skills
|
|
67
|
+
const localCanonical = await listLocalCanonicalSkills()
|
|
68
|
+
|
|
69
|
+
// 2. Hash each local SKILL.md
|
|
70
|
+
const localByFolder = new Map<
|
|
71
|
+
string,
|
|
72
|
+
{ folderName: string; name: string; localPath: string; hash: string }
|
|
73
|
+
>()
|
|
74
|
+
for (const s of localCanonical) {
|
|
75
|
+
const skillMdPath = path.join(s.canonicalPath, "SKILL.md")
|
|
76
|
+
let content: string
|
|
77
|
+
try {
|
|
78
|
+
content = await fs.readFile(skillMdPath, "utf-8")
|
|
79
|
+
} catch {
|
|
80
|
+
continue // skill dir without SKILL.md -- skip
|
|
81
|
+
}
|
|
82
|
+
localByFolder.set(s.folderName, {
|
|
83
|
+
folderName: s.folderName,
|
|
84
|
+
name: s.name,
|
|
85
|
+
localPath: s.canonicalPath,
|
|
86
|
+
hash: sha256(content),
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 3. Scan remote
|
|
91
|
+
const remote = await scanRemoteSkills(server)
|
|
92
|
+
const remoteBase = normalizeRemoteBase(server.skillsBasePath)
|
|
93
|
+
|
|
94
|
+
// Match remote skills to local folder names by remotePath suffix.
|
|
95
|
+
// remotePath looks like "<skillsBasePath>/<folderName>/SKILL.md".
|
|
96
|
+
// We strip the basePath prefix and the "/SKILL.md" suffix to recover folderName.
|
|
97
|
+
const remoteByFolder = new Map<string, { remotePath: string; hash: string }>()
|
|
98
|
+
for (const r of remote) {
|
|
99
|
+
const rp = r.remotePath
|
|
100
|
+
if (!rp.endsWith("/SKILL.md")) continue
|
|
101
|
+
// Remove the trailing "/SKILL.md"
|
|
102
|
+
const skillDir = rp.slice(0, -"/SKILL.md".length)
|
|
103
|
+
// Try to match by basename so we tolerate "~/.agents/skills" vs "$HOME/..." differences
|
|
104
|
+
const folderName = path.posix.basename(skillDir)
|
|
105
|
+
if (!folderName) continue
|
|
106
|
+
remoteByFolder.set(folderName, { remotePath: rp, hash: r.contentHash })
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const toAdd: PushPlanEntry[] = []
|
|
110
|
+
const toUpdate: PushPlanEntry[] = []
|
|
111
|
+
const unchanged: PushPlanEntry[] = []
|
|
112
|
+
const toDelete: PushPlanEntry[] = []
|
|
113
|
+
|
|
114
|
+
for (const [folderName, local] of localByFolder) {
|
|
115
|
+
const remoteEntry = remoteByFolder.get(folderName)
|
|
116
|
+
const remoteDir = `${remoteBase}/${folderName}`
|
|
117
|
+
const remotePath = `${remoteDir}/SKILL.md`
|
|
118
|
+
if (!remoteEntry) {
|
|
119
|
+
toAdd.push({
|
|
120
|
+
folderName,
|
|
121
|
+
name: local.name,
|
|
122
|
+
localPath: local.localPath,
|
|
123
|
+
remotePath,
|
|
124
|
+
remoteDir,
|
|
125
|
+
reason: "added",
|
|
126
|
+
localHash: local.hash,
|
|
127
|
+
})
|
|
128
|
+
} else if (remoteEntry.hash !== local.hash) {
|
|
129
|
+
toUpdate.push({
|
|
130
|
+
folderName,
|
|
131
|
+
name: local.name,
|
|
132
|
+
localPath: local.localPath,
|
|
133
|
+
remotePath,
|
|
134
|
+
remoteDir,
|
|
135
|
+
reason: "updated",
|
|
136
|
+
localHash: local.hash,
|
|
137
|
+
remoteHash: remoteEntry.hash,
|
|
138
|
+
})
|
|
139
|
+
} else {
|
|
140
|
+
unchanged.push({
|
|
141
|
+
folderName,
|
|
142
|
+
name: local.name,
|
|
143
|
+
localPath: local.localPath,
|
|
144
|
+
remotePath,
|
|
145
|
+
remoteDir,
|
|
146
|
+
reason: "unchanged",
|
|
147
|
+
localHash: local.hash,
|
|
148
|
+
remoteHash: remoteEntry.hash,
|
|
149
|
+
})
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (options.mirror) {
|
|
154
|
+
for (const [folderName, remoteEntry] of remoteByFolder) {
|
|
155
|
+
if (localByFolder.has(folderName)) continue
|
|
156
|
+
const remoteDir = `${remoteBase}/${folderName}`
|
|
157
|
+
toDelete.push({
|
|
158
|
+
folderName,
|
|
159
|
+
name: folderName,
|
|
160
|
+
localPath: "",
|
|
161
|
+
remotePath: remoteEntry.remotePath,
|
|
162
|
+
remoteDir,
|
|
163
|
+
reason: "deleted",
|
|
164
|
+
remoteHash: remoteEntry.hash,
|
|
165
|
+
})
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return { toAdd, toUpdate, toDelete, unchanged, mirror: options.mirror }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Apply a previously-computed plan. Errors per skill are collected, not thrown,
|
|
174
|
+
* so a single failure doesn't abort the entire push.
|
|
175
|
+
*/
|
|
176
|
+
export async function applyPush(
|
|
177
|
+
server: RemoteServer,
|
|
178
|
+
preview: PushPreview,
|
|
179
|
+
): Promise<PushResult> {
|
|
180
|
+
const errors: { folderName: string; message: string }[] = []
|
|
181
|
+
const remoteBase = normalizeRemoteBase(server.skillsBasePath)
|
|
182
|
+
|
|
183
|
+
// Uploads (added + updated). Use uploadSkillDir which handles tar pipeline.
|
|
184
|
+
for (const entry of [...preview.toAdd, ...preview.toUpdate]) {
|
|
185
|
+
try {
|
|
186
|
+
await uploadSkillDir(server, CANONICAL_SKILLS_DIR, entry.folderName, remoteBase)
|
|
187
|
+
} catch (err) {
|
|
188
|
+
errors.push({
|
|
189
|
+
folderName: entry.folderName,
|
|
190
|
+
message: err instanceof Error ? err.message : String(err),
|
|
191
|
+
})
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Deletions (mirror only)
|
|
196
|
+
if (preview.mirror) {
|
|
197
|
+
for (const entry of preview.toDelete) {
|
|
198
|
+
try {
|
|
199
|
+
await deleteRemoteSkillDir(server, entry.remoteDir)
|
|
200
|
+
} catch (err) {
|
|
201
|
+
errors.push({
|
|
202
|
+
folderName: entry.folderName,
|
|
203
|
+
message: err instanceof Error ? err.message : String(err),
|
|
204
|
+
})
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Compute counts: subtract failures
|
|
210
|
+
const failedFolders = new Set(errors.map((e) => e.folderName))
|
|
211
|
+
return {
|
|
212
|
+
added: preview.toAdd.filter((e) => !failedFolders.has(e.folderName)).length,
|
|
213
|
+
updated: preview.toUpdate.filter((e) => !failedFolders.has(e.folderName)).length,
|
|
214
|
+
deleted: preview.mirror
|
|
215
|
+
? preview.toDelete.filter((e) => !failedFolders.has(e.folderName)).length
|
|
216
|
+
: 0,
|
|
217
|
+
unchanged: preview.unchanged.length,
|
|
218
|
+
errors,
|
|
219
|
+
}
|
|
220
|
+
}
|
package/src/db/ssh.ts
CHANGED
|
@@ -198,34 +198,59 @@ function sha256(content: string): string {
|
|
|
198
198
|
return crypto.createHash("sha256").update(content).digest("hex")
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
+
/**
|
|
202
|
+
* Detect "directory does not exist" errors from `find` stderr.
|
|
203
|
+
*/
|
|
204
|
+
function isMissingDirError(stderr: string): boolean {
|
|
205
|
+
return /no such file or directory/i.test(stderr)
|
|
206
|
+
}
|
|
207
|
+
|
|
201
208
|
export async function scanRemoteSkills(
|
|
202
209
|
server: RemoteServer
|
|
203
210
|
): Promise<ScannedRemoteSkill[]> {
|
|
204
211
|
const basePath = shellQuotePath(server.skillsBasePath)
|
|
205
212
|
|
|
206
|
-
// Round trip 1: Find all SKILL.md files
|
|
213
|
+
// Round trip 1: Find all SKILL.md files. Capture stderr (do NOT redirect to
|
|
214
|
+
// /dev/null) so we can distinguish "directory doesn't exist" from real errors.
|
|
207
215
|
const findResult = await sshExec(
|
|
208
216
|
server,
|
|
209
|
-
`find ${basePath} -name 'SKILL.md' -type f
|
|
217
|
+
`find ${basePath} -name 'SKILL.md' -type f`
|
|
210
218
|
)
|
|
211
|
-
if (findResult.exitCode !== 0 && findResult.stdout.trim() === "") {
|
|
212
|
-
throw new Error(`Find failed: ${findResult.stderr.trim()}`)
|
|
213
|
-
}
|
|
214
219
|
|
|
215
|
-
const paths = findResult.stdout
|
|
220
|
+
const paths = findResult.stdout
|
|
221
|
+
.split("\n")
|
|
222
|
+
.map((p) => p.trim())
|
|
223
|
+
.filter(Boolean)
|
|
224
|
+
|
|
225
|
+
if (findResult.exitCode !== 0 && paths.length === 0) {
|
|
226
|
+
const stderr = findResult.stderr.trim()
|
|
227
|
+
// Missing skills directory on a fresh remote is a normal state.
|
|
228
|
+
if (isMissingDirError(stderr)) return []
|
|
229
|
+
throw new Error(
|
|
230
|
+
stderr || `find exited with code ${findResult.exitCode} on ${server.skillsBasePath}`
|
|
231
|
+
)
|
|
232
|
+
}
|
|
216
233
|
if (paths.length === 0) return []
|
|
217
234
|
|
|
218
|
-
// Round trip 2: Batch read all files in a single SSH call
|
|
219
|
-
|
|
235
|
+
// Round trip 2: Batch read all files in a single SSH call.
|
|
236
|
+
// Single-quote escaping handles arbitrary characters; only reject paths with
|
|
237
|
+
// chars that would break our delimiter parser.
|
|
238
|
+
const safePaths = paths.filter((p) => !/[\x00\n\r]/.test(p))
|
|
239
|
+
if (safePaths.length === 0) return []
|
|
240
|
+
|
|
241
|
+
const catCommands = safePaths
|
|
220
242
|
.map((p) => {
|
|
221
243
|
const escaped = `'${p.replace(/'/g, "'\\''")}'`
|
|
222
|
-
|
|
244
|
+
const delimPath = p.replace(/'/g, "'\\''")
|
|
245
|
+
return `printf '%s\\n' '${DELIMITER_PREFIX}${delimPath}${DELIMITER_SUFFIX}' && cat ${escaped}`
|
|
223
246
|
})
|
|
224
247
|
.join(" && ")
|
|
225
248
|
|
|
226
249
|
const catResult = await sshExec(server, catCommands)
|
|
227
|
-
if (catResult.exitCode !== 0) {
|
|
228
|
-
throw new Error(
|
|
250
|
+
if (catResult.exitCode !== 0 && catResult.stdout.trim() === "") {
|
|
251
|
+
throw new Error(
|
|
252
|
+
catResult.stderr.trim() || `cat exited with code ${catResult.exitCode}`
|
|
253
|
+
)
|
|
229
254
|
}
|
|
230
255
|
|
|
231
256
|
return parseDelimitedOutput(catResult.stdout)
|
|
@@ -274,6 +299,89 @@ function parseDelimitedOutput(output: string): ScannedRemoteSkill[] {
|
|
|
274
299
|
return skills
|
|
275
300
|
}
|
|
276
301
|
|
|
302
|
+
/**
|
|
303
|
+
* Upload an entire local skill directory to the remote server.
|
|
304
|
+
* Streams `tar -cf - -C <parentLocalDir> <folderName>` into
|
|
305
|
+
* `tar -xf - -C <remoteBaseDir>` over SSH.
|
|
306
|
+
*
|
|
307
|
+
* Both directories must already exist on the remote (caller ensures via mkdir -p).
|
|
308
|
+
*/
|
|
309
|
+
export async function uploadSkillDir(
|
|
310
|
+
server: RemoteServer,
|
|
311
|
+
parentLocalDir: string,
|
|
312
|
+
folderName: string,
|
|
313
|
+
remoteBaseDir: string,
|
|
314
|
+
): Promise<void> {
|
|
315
|
+
// Ensure remote base dir exists first
|
|
316
|
+
const baseQuoted = shellQuotePath(remoteBaseDir)
|
|
317
|
+
const mkdirResult = await sshExec(server, `mkdir -p ${baseQuoted}`)
|
|
318
|
+
if (mkdirResult.exitCode !== 0) {
|
|
319
|
+
throw new Error(
|
|
320
|
+
mkdirResult.stderr.trim() || `mkdir -p failed for ${remoteBaseDir}`,
|
|
321
|
+
)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
await new Promise<void>((resolve, reject) => {
|
|
325
|
+
const tarLocal = spawn("tar", ["-cf", "-", "-C", parentLocalDir, folderName], {
|
|
326
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
const sshArgs = [...buildSshArgs(server), `tar -xf - -C ${baseQuoted}`]
|
|
330
|
+
const tarRemote = spawn("ssh", sshArgs, {
|
|
331
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
332
|
+
timeout: 120_000,
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
let localStderr = ""
|
|
336
|
+
let remoteStderr = ""
|
|
337
|
+
tarLocal.stderr.on("data", (c: Buffer) => (localStderr += c.toString("utf-8")))
|
|
338
|
+
tarRemote.stderr.on("data", (c: Buffer) => (remoteStderr += c.toString("utf-8")))
|
|
339
|
+
|
|
340
|
+
tarLocal.stdout.pipe(tarRemote.stdin)
|
|
341
|
+
|
|
342
|
+
tarLocal.on("error", (err) => reject(err))
|
|
343
|
+
tarRemote.on("error", (err) => reject(err))
|
|
344
|
+
|
|
345
|
+
tarRemote.on("close", (code) => {
|
|
346
|
+
if ((code ?? 1) === 0) {
|
|
347
|
+
resolve()
|
|
348
|
+
} else {
|
|
349
|
+
reject(
|
|
350
|
+
new Error(
|
|
351
|
+
remoteStderr.trim() ||
|
|
352
|
+
localStderr.trim() ||
|
|
353
|
+
`tar pipeline exited with code ${code ?? 1}`,
|
|
354
|
+
),
|
|
355
|
+
)
|
|
356
|
+
}
|
|
357
|
+
})
|
|
358
|
+
})
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Delete a single skill directory on the remote.
|
|
363
|
+
* Used only by Mirror mode. Caller is responsible for confirming with the user.
|
|
364
|
+
*/
|
|
365
|
+
export async function deleteRemoteSkillDir(
|
|
366
|
+
server: RemoteServer,
|
|
367
|
+
remoteSkillDir: string,
|
|
368
|
+
): Promise<void> {
|
|
369
|
+
const quoted = shellQuotePath(remoteSkillDir)
|
|
370
|
+
// Refuse to delete the entire skills base path or anything ending in '/'
|
|
371
|
+
if (
|
|
372
|
+
!remoteSkillDir ||
|
|
373
|
+
remoteSkillDir === "/" ||
|
|
374
|
+
remoteSkillDir === server.skillsBasePath ||
|
|
375
|
+
remoteSkillDir === server.skillsBasePath.replace(/\/+$/, "")
|
|
376
|
+
) {
|
|
377
|
+
throw new Error(`Refusing to delete suspicious remote path: ${remoteSkillDir}`)
|
|
378
|
+
}
|
|
379
|
+
const result = await sshExec(server, `rm -rf ${quoted}`)
|
|
380
|
+
if (result.exitCode !== 0) {
|
|
381
|
+
throw new Error(result.stderr.trim() || `rm -rf failed for ${remoteSkillDir}`)
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
277
385
|
// ---------- Sync Orchestrator ----------
|
|
278
386
|
|
|
279
387
|
export interface SyncResult {
|
|
@@ -283,6 +391,7 @@ export interface SyncResult {
|
|
|
283
391
|
unchanged: number
|
|
284
392
|
total: number
|
|
285
393
|
log: string[]
|
|
394
|
+
error?: string
|
|
286
395
|
}
|
|
287
396
|
|
|
288
397
|
export async function syncRemoteServer(
|
|
@@ -349,7 +458,7 @@ export async function syncRemoteServer(
|
|
|
349
458
|
}
|
|
350
459
|
} catch (err) {
|
|
351
460
|
const errorMsg = err instanceof Error ? err.message : String(err)
|
|
352
|
-
log.push(`
|
|
461
|
+
log.push(`Sync failed: ${errorMsg}`)
|
|
353
462
|
serverStore.updateSyncStatus(server.id, errorMsg)
|
|
354
463
|
return {
|
|
355
464
|
added: 0,
|
|
@@ -358,6 +467,7 @@ export async function syncRemoteServer(
|
|
|
358
467
|
unchanged: 0,
|
|
359
468
|
total: 0,
|
|
360
469
|
log,
|
|
470
|
+
error: errorMsg,
|
|
361
471
|
}
|
|
362
472
|
}
|
|
363
473
|
}
|
package/src/store/reducers.ts
CHANGED
|
@@ -5,7 +5,6 @@ const FOCUS_ORDER: FocusedPane[] = ["agents", "search", "list"]
|
|
|
5
5
|
export const initialState: AppState = {
|
|
6
6
|
activeView: "home",
|
|
7
7
|
previousView: null,
|
|
8
|
-
auth: null,
|
|
9
8
|
detectedAgents: [],
|
|
10
9
|
selectedAgentFilter: "all",
|
|
11
10
|
installedSkills: [],
|
|
@@ -14,8 +13,6 @@ export const initialState: AppState = {
|
|
|
14
13
|
searchQuery: "",
|
|
15
14
|
searchResults: [],
|
|
16
15
|
searchLoading: false,
|
|
17
|
-
favorites: [],
|
|
18
|
-
favoritesLoading: false,
|
|
19
16
|
selectedSkill: null,
|
|
20
17
|
selectedServerId: null,
|
|
21
18
|
showHelp: false,
|
|
@@ -40,9 +37,6 @@ export function appReducer(state: AppState, action: Action): AppState {
|
|
|
40
37
|
previousView: null,
|
|
41
38
|
}
|
|
42
39
|
|
|
43
|
-
case "SET_AUTH":
|
|
44
|
-
return { ...state, auth: action.auth }
|
|
45
|
-
|
|
46
40
|
case "SET_DETECTED_AGENTS":
|
|
47
41
|
return { ...state, detectedAgents: action.agents }
|
|
48
42
|
|
|
@@ -76,12 +70,6 @@ export function appReducer(state: AppState, action: Action): AppState {
|
|
|
76
70
|
case "SET_SEARCH_LOADING":
|
|
77
71
|
return { ...state, searchLoading: action.loading }
|
|
78
72
|
|
|
79
|
-
case "SET_FAVORITES":
|
|
80
|
-
return { ...state, favorites: action.favorites, favoritesLoading: false }
|
|
81
|
-
|
|
82
|
-
case "SET_FAVORITES_LOADING":
|
|
83
|
-
return { ...state, favoritesLoading: action.loading }
|
|
84
|
-
|
|
85
73
|
case "SELECT_SKILL":
|
|
86
74
|
return {
|
|
87
75
|
...state,
|
package/src/store/types.ts
CHANGED
|
@@ -5,14 +5,12 @@ import type { AgentType, SkillLockEntry, SourceType } from "../../../cli/src/typ
|
|
|
5
5
|
export type ViewName =
|
|
6
6
|
| "home"
|
|
7
7
|
| "discover"
|
|
8
|
-
| "favorites"
|
|
9
8
|
| "servers"
|
|
10
9
|
| "server-skills"
|
|
11
10
|
| "add-server"
|
|
12
11
|
| "edit-server"
|
|
13
12
|
| "settings"
|
|
14
13
|
| "detail"
|
|
15
|
-
| "login"
|
|
16
14
|
|
|
17
15
|
// ---------- Enriched Skill ----------
|
|
18
16
|
|
|
@@ -44,13 +42,6 @@ export interface DetectedAgent {
|
|
|
44
42
|
skillCount: number
|
|
45
43
|
}
|
|
46
44
|
|
|
47
|
-
// ---------- Auth ----------
|
|
48
|
-
|
|
49
|
-
export interface AuthState {
|
|
50
|
-
token: string
|
|
51
|
-
user: { id: string; name: string; email: string }
|
|
52
|
-
}
|
|
53
|
-
|
|
54
45
|
// ---------- Notification ----------
|
|
55
46
|
|
|
56
47
|
export interface Notification {
|
|
@@ -68,9 +59,6 @@ export interface AppState {
|
|
|
68
59
|
activeView: ViewName
|
|
69
60
|
previousView: ViewName | null
|
|
70
61
|
|
|
71
|
-
// Auth
|
|
72
|
-
auth: AuthState | null
|
|
73
|
-
|
|
74
62
|
// Agent detection
|
|
75
63
|
detectedAgents: DetectedAgent[]
|
|
76
64
|
|
|
@@ -85,10 +73,6 @@ export interface AppState {
|
|
|
85
73
|
searchResults: unknown[]
|
|
86
74
|
searchLoading: boolean
|
|
87
75
|
|
|
88
|
-
// Favorites
|
|
89
|
-
favorites: unknown[]
|
|
90
|
-
favoritesLoading: boolean
|
|
91
|
-
|
|
92
76
|
// Detail
|
|
93
77
|
selectedSkill: EnrichedSkill | null
|
|
94
78
|
|
|
@@ -108,7 +92,6 @@ export interface AppState {
|
|
|
108
92
|
export type Action =
|
|
109
93
|
| { type: "NAVIGATE"; view: ViewName }
|
|
110
94
|
| { type: "GO_BACK" }
|
|
111
|
-
| { type: "SET_AUTH"; auth: AuthState | null }
|
|
112
95
|
| { type: "SET_DETECTED_AGENTS"; agents: DetectedAgent[] }
|
|
113
96
|
| { type: "UPDATE_AGENT_COUNTS"; counts: Record<string, number> }
|
|
114
97
|
| { type: "SET_AGENT_FILTER"; filter: string }
|
|
@@ -118,8 +101,6 @@ export type Action =
|
|
|
118
101
|
| { type: "SET_SEARCH_QUERY"; query: string }
|
|
119
102
|
| { type: "SET_SEARCH_RESULTS"; results: unknown[] }
|
|
120
103
|
| { type: "SET_SEARCH_LOADING"; loading: boolean }
|
|
121
|
-
| { type: "SET_FAVORITES"; favorites: unknown[] }
|
|
122
|
-
| { type: "SET_FAVORITES_LOADING"; loading: boolean }
|
|
123
104
|
| { type: "SELECT_SKILL"; skill: EnrichedSkill }
|
|
124
105
|
| { type: "PREVIEW_SKILL"; skill: EnrichedSkill }
|
|
125
106
|
| { type: "CLEAR_SKILL" }
|
package/src/utils/colors.ts
CHANGED
|
@@ -25,6 +25,8 @@ export const agentBadges: Record<string, { label: string; color: string }> = {
|
|
|
25
25
|
cursor: { label: "Cu", color: "#5599FF" }, // blue
|
|
26
26
|
windsurf: { label: "W", color: "#00CED1" }, // cyan
|
|
27
27
|
"codex-cli": { label: "Cx", color: "#FF4444" }, // red
|
|
28
|
+
"droid-cli": { label: "Dr", color: "#22D3EE" }, // cyan
|
|
29
|
+
"ob-1": { label: "OB", color: "#12B3DF" }, // openblock cyan
|
|
28
30
|
opencode: { label: "O", color: "#2ECCAA" }, // teal
|
|
29
31
|
zed: { label: "Z", color: "#FFFF00" }, // yellow
|
|
30
32
|
"github-copilot": { label: "Gh", color: "#8B5CF6" }, // purple
|